/* THE TWO FACES, SERVED FROM THIS ORIGIN (JIO-1300). They arrived from
   fonts.googleapis.com until then, as a stylesheet `<link>` in the head of both
   pages -- which is to say as a RENDER-BLOCKING request to a host the browser
   had never spoken to, in front of every pixel of the first paint, and whose
   response was not even the fonts but the URLs of two more files on a third
   host. Nothing else on either page reaches outside this origin, so the
   slowest thing on the critical path was also the only thing on it we did not
   serve.

   WHAT WAS DOWNLOADED, AND WHY THESE EXACT FILES (confirmed 2026-09-20). They
   are Google Fonts' own `latin` subsets, byte-for-byte what the css2 stylesheet
   pointed a modern Chrome at -- so this is the same type, not a re-cut of it,
   and nothing about the rendering moved. The other subsets it offered
   (latin-ext, cyrillic, cyrillic-ext, vietnamese) are dropped: every string
   this game and its landing page can render is its own copy, in English, and
   the unicode-range below is the honest statement of what these files cover.
   Bungee is 14,296 bytes and Nunito Sans 49,612, which is what the two
   cross-origin requests already cost -- the saving here is the round trips, not
   the bytes.

   NUNITO SANS IS ONE VARIABLE FILE FOR ALL THREE WEIGHTS, and that is a fact
   about the file rather than a shortcut: Google's css2 response gave the
   IDENTICAL URL for 400, 600 and 700, because the face carries a `wght` axis
   and the browser instances it. `font-weight: 400 700` is the range declaration
   that says so; three static @font-face rules over one file would be three
   names for the same download.

   BUNGEE HAS EXACTLY ONE WEIGHT, so every rule that sets it also sets
   `font-weight: 400` (see the display roles below) -- a heading asking for 700
   of a single-weight face gets a synthesised bold, which is Bungee smeared.

   THE URLS ARE RELATIVE, and they have to be. This sheet is served from
   /v/<version>/dicelock/style.css (server/server.ts, VERSIONED_CACHE) and from
   /dicelock/<sha>/style.css on jiolab.dev (scripts/publish.js), and a relative
   `fonts/...` resolves against whichever of those it was loaded from -- so the
   faces are versioned and year-cached with the sheet, by doing nothing. An
   absolute `/dicelock/fonts/...` here would have escaped both rewrites and
   landed on the one-hour bare path.

   Both are SIL Open Font License 1.1; fonts/OFL.txt is the licence and names
   both. */
@font-face {
  font-family: "Bungee";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("fonts/bungee-latin-400.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+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Nunito Sans";
  font-style: normal;
  font-weight: 400 700;
  font-stretch: 100%;
  font-display: swap;
  src: url("fonts/nunito-sans-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+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* TWO THEMES, ONE SET OF ROLES (JIO-1061). Every token below that a light page
   has to re-light is declared twice and read once, and the two declarations are
   in this order for a reason:

     1. `:root, [data-theme="dark"]` -- dark, and dark is the default. It is the
        palette this game shipped with, value for value, so the theme port is
        not also a redesign of the screen everybody is already looking at
        (design/foundation/FOUNDATION.md: "dark is the game's current"). Being
        on BARE `:root` as well as on the attribute is what makes the game black
        with no JavaScript at all, on any machine, at any hour.
     2. `[data-theme="light"]` -- the same roles re-lit, from FOUNDATION section
        1 and the Vs Computer light artboard. It is reached by ASKING for it on
        the Settings screen and by nothing else.

   ~~3. the `prefers-color-scheme: light` block, on `:root:not([data-theme])`~~
   WENT IN JIO-1455, and this is the whole of that ticket: the machine's light
   mode used to re-light the game under a player who had never asked it to, so
   dicelockgame.com was near-white for anybody on a light Mac. There is no
   third declaration to add back -- `settings.theme` has no value that leaves
   the attribute off, so `:root:not([data-theme])` matches nothing after
   settings.ts has run and only the dark defaults before it.

   Dice faces, locked, the ability tints and the six role colours are IDENTICAL
   in both themes and stay in the shared block below: they are the colours of
   the game's own objects, and a die that changed colour with the page would be
   a different die. --ink is the text drawn on teal, amber and a die face, so it
   is dark in both for the same reason.

   design/foundation/tokens.css is the spec of record for every value here. */
:root, [data-theme="dark"] {
  --bg: #0c0e12;
  --panel: #151922;
  --panel-2: #1c2230;
  --line: #2a3242;
  --text: #e8ecf3;
  /* The body copy under a heading -- help rules, a card's paragraph, the
     tutorial banner's line, the touch diagram's legend. Those four rules each
     carried a bare `#c8cfdb` before this was a token, which is exactly the
     shape that cannot be re-lit. */
  --text-2: #c8cfdb;
  --muted: #8d97ab;
  --ink: #0b0d12;
  /* The canvas's own fill and grid. A <canvas> INHERITS a custom property but
     cannot be painted by one, so render/renderer.ts reads these two off the
     element with getComputedStyle rather than the stylesheet drawing them --
     which also means any container that pins `data-theme` pins the board
     inside it, and that is how the landing page's hero stays dark. */
  --board-bg: #0a0c10;
  --board-grid: #161b26;
  --left: #2dd4bf;
  --left-dim: #135e58;
  --right: #f5a524;
  --right-dim: #6b4a10;
  /* THE SAME ACCENT, TAKEN DOWN FAR ENOUGH TO BE READ. On white --left is
     3.3:1, --right 3.5:1 and --ok 3.8:1 -- the AA bar for LARGE text and a
     fail for anything smaller, which is most of what this stylesheet sets in
     them: a 14px help heading, a 13px card title, a 12px stat label. These
     three are those hues at 5:1 or better against both --panel and --panel-2,
     and in dark they are the base accent unchanged, so nothing about the
     shipped theme moved. A BAR, A BORDER, A DIE, A MARK and display type keep
     the bright one -- the wordmark's LOCK at 40px and a result title at 44px
     are large text, and being bright is the point of them.
     scripts/probe-theme-contrast.ts is what found the 3.49:1 heading and is
     what fails if a new rule sets small text in the base accent (JIO-1061). */
  --left-text: #2dd4bf;
  --right-text: #f5a524;
  --ok-text: #34d399;
  /* AND --danger-text, WHICH IS THE ONE OF THESE THAT ALSO MOVES IN DARK
     (JIO-1081). The other three are the base accent unchanged on a dark page;
     `--danger` is #ef4444, which is 4.67:1 on `--panel` and **4.22:1 on
     `--panel-2`** -- and a History row was `--panel-2` everywhere when this was
     measured, so "Topped out" on every Endless run in the list was under the
     4.5 bar in the DARK theme as well as the light one. A row now takes
     `--card`, which is `--panel-2` on the desktop card and `--panel` on the
     full-bleed phone screen: the 4.22 is the WORSE of the two surfaces and is
     still the one this value has to answer, so nothing here moves. It read as passing for two tickets because
     probe-theme-contrast.ts walked History on a fresh profile, where there are
     no rows to measure; seeding that walk is what found it (measured
     2026-09-13). Red-400 over red-500 in dark, red-700 over red-600 in light:
     the same hue one rung along, which is what the other three -text values are.
     A result title at 40px keeps the base accent -- §1's own rule, large text
     is where being bright is the point. */
  --danger-text: #f87171;
  /* HOW MUCH INK A ROLE COLOUR TAKES BEFORE IT IS READ AS TEXT (JIO-1061).
     The six role colours, the six dice faces, the risk strip's six levels and
     the six ability tints are the colours of the game's OWN OBJECTS and are
     identical in both themes on purpose -- a die that changed colour with the
     page would be a different die. But the help screen's diagrams also SET
     WORDS in them: a key cap lettered "Shift" in --role-raise, a legend reading
     "Risky" in the risk strip's amber. On a dark panel those land between 7:1
     and 12:1; on white they land between 1.4:1 and 2.6:1, which is not text at
     all. scripts/probe-theme-contrast.ts measured every one of them.
     So the OBJECT keeps its colour and the WORD takes the same hue with half
     the page's ink mixed into it -- the coding survives, the label is legible,
     and dark is untouched because the mix is 0% there. It is a percentage
     rather than two colours because the thing being mixed into differs per
     call site (a role, a face, an ability), and a token per pairing would be
     forty of them. ui/sketch.ts reads it the same way for the figure captions
     it draws from renderer.ts's own constants. */
  --legend-mix: 0%;
  --danger: #ef4444;
  --ok: #34d399;
  --card-shadow: 0 1px 0 rgba(255,255,255,0.03) inset, 0 8px 24px rgba(0,0,0,0.35);
  /* HOW MUCH OF A WORLD THE PAGE WEARS (JIO-1230). Four numbers, all four
     per-palette, because the same ground does opposite things to the two.

     `--world-shade` IS THE ONE THAT MATTERS AND IT IS 1 HERE AND 0 IN LIGHT:
     how much of the compositing's LIGHTNESS survives. On a dark page the whole
     of it does -- every ground in `config.puzzles.themes` is dark, so 55% of
     one is a tint of a tint, and the worst run on the darkest screen is 5.41:1
     on a card, which a world moves by nothing. On a light page none of it does,
     and that is a measured rule: `--left` is 3.04:1 on `--bg` against the 3:1
     bar large text takes (section 1 of design/foundation/FOUNDATION.md accepts
     that deliberately), so seven percent of the Workshop's ground put the
     wordmark's LOCK at 2.66:1 -- under the bar, on the title screen, in five
     worlds at once. ui/world.ts pulls the light tint back to --bg's own
     luminance, which makes every ratio on a light page the House's exactly.
     scripts/probe-theme-contrast.ts is what found it and what holds it: since
     this ticket it walks every screen once per world in BOTH palettes rather
     than once in the House.

     The texture and the motif are dimmed separately because they are different
     amounts of ink: the texture covers the whole page and the motif is one
     shape, so a motif at the texture's opacity disappears and a texture at the
     motif's is a pattern you read instead of a page. */
  --world-ground: 0.55;
  --world-shade: 1;
  --world-texture: 0.10;
  --world-motif: 0.16;
  /* AND THE FOUR THE HOUSE'S OWN SHEET IS DRAWN IN (JIO-1702). The House wears
     no world, so it has no `--world-ink` to tint and no ground to dim against:
     the Pips sheet is drawn in resolved colours, and these are them. The near
     lattice is the page's own `--line`, the hairline every card edge is, at
     1.502:1 on `--bg`; the far one is a step under it at 1.121:1, so the
     parallax is a difference in WEIGHT as well as in size. */
  --sheet-near: var(--line);
  --sheet-far: #161b26;
  --sheet-left: #25b3a2;
  --sheet-right: #cf8c1f;
}
[data-theme="light"] {
  --bg: #f4f6fa;
  --panel: #ffffff;
  --panel-2: #eef1f6;
  --line: #d5dbe6;
  --text: #14181f;
  --text-2: #3a4250;
  --muted: #5b6578;
  --ink: #0b0d12;
  --board-bg: #e6eaf1;
  --board-grid: rgba(20, 24, 31, 0.06);
  --left: #0f9f92;
  --left-dim: #b8ece6;
  --right: #c07a00;
  --right-dim: #ffe2ad;
  --left-text: #0a736a;
  --right-text: #8a5800;
  --ok-text: #047857;
  --danger-text: #b91c1c;
  --legend-mix: 50%;
  --danger: #dc2626;
  --ok: #059669;
  --card-shadow: 0 1px 2px rgba(20,24,31,0.06), 0 8px 24px rgba(20,24,31,0.08);
  --world-ground: 0.35;
  --world-shade: 0;
  --world-texture: 0.09;
  --world-motif: 0.14;
  /* One step UNDER `--line` here rather than on it, and the step is measured:
     #d5dbe6 on this page's #f4f6fa is 1.285:1 where the dark hairline is
     1.502:1, so the light lattice is #c5cdda at 1.479:1 -- the same drawing in
     both palettes rather than the same token. It is the one place the sheet
     does NOT follow a world, which is an order of magnitude fainter here
     (`--world-shade` is 0) because its ink is composited and this ink is
     chosen. The lit pair stay `--left` and `--right`: a light page is where
     those two are already the darker of the two sets. */
  --sheet-near: #c5cdda;
  --sheet-far: #e6eaf1;
  --sheet-left: var(--left);
  --sheet-right: var(--right);
}
:root {
  /* One colour per job a control does, shared by all three help-screen input
     diagrams (ui/cursorFigure.ts names them, JIO-890). They are here rather
     than in the diagram that draws them so the keyboard's Reroll and the
     gamepad's Reroll can never drift apart one tab from each other. Colour is
     never the only carrier: every control is also lettered and every role is
     named in the legend beside its swatch. */
  --role-move: #60a5fa;
  --role-swap: #f472b6;
  --role-reroll: #f5a524;
  --role-ability: #c084fc;
  --role-raise: #34d399;
  --role-pause: #aab4c8;
  /* One tint per ability id in `CONFIG.abilities.list` (JIO-920), and
     deliberately NOT part of the six above. Those are CONTROL colours -- one
     per job a button does -- so `--role-ability` is a single purple for the
     whole list and can say "this key casts something" but never which thing.
     These say which, and they are what a BOARD wears while an effect runs on
     it (JIO-1074), what the meter takes when it rolls, and what the chip and
     the banner over the board are drawn in -- so the ability is caught in
     peripheral vision instead of read off a 9px line in a panel. Colour is not
     the only carrier here either: the meter's glyph box carries the ability's
     own glyph and the status card names it in words.
     The pairs that could be confused are the pairs that never share a screen:
     Row is solo-only and Lock versus-only, so the yellow and the orange are
     never both castable. A new id in `abilities.list` needs a line here and a
     mapping rule further down -- test/abilityFeedback.test.js fails on a missing
     one rather than letting that ability colour nothing at all. */
  --ability-cleanse: #38bdf8;
  --ability-barrage: #f43f5e;
  --ability-shield: #22c55e;
  --ability-erase: #a78bfa;
  --ability-lock: #f97316;
  --ability-stop: #cbd5e1;
  /* Hint (JIO-1660) is the seventh, and it is a YELLOW because it is the
     one ability that marks the board instead of changing it: the mark is
     two cell outlines drawn over live dice (render/renderer.ts), so the
     colour has to read against every face at once rather than against the
     board it tints. Nothing it could be confused with shares a screen with
     it -- Lock's orange is Versus-only and Hint is the Tower's. */
  --ability-hint: #facc15;
  /* Full clear (JIO-1661) is the eighth, and it is a MAGENTA for the reason
     Hint is a yellow: nothing it could be confused with shares a screen with
     it. Erase's violet is the one near neighbour and the two are the pair a
     player most has to tell apart -- one face gone against every die gone --
     so the hue is pushed the whole way to magenta rather than left a shade off
     the violet. It is also the only ability colour that is never drawn over
     live dice: the wipe is painted over a board that has already been
     re-dealt, so what it has to read against is the board's own ground. */
  --ability-fullclear: #e879f9;
  /* Wild (JIO-1662) is the ninth, and it is a LIME because that is the one gap
     left on the wheel: the eight above spend sky, rose, green, violet, orange,
     slate, yellow and fuchsia, and the seven FACE colours
     (render/renderer.ts) spend blue, green, yellow, red, purple, orange and
     pink. It is the only ability colour that is a DIE BODY rather than a wash,
     an outline or a chip -- a wild stops wearing a face colour, which is the
     one thing on a board that has always meant "which face is this" -- so it
     is read against 2's green and 3's yellow rather than against the board,
     and it sits between them, 37 degrees off the nearer. */
  --ability-wild: #a3e635;
  /* Transmute (JIO-1663) is the tenth, and it is an INDIGO because the wheel is
     now spent twice over: the nine above take sky, rose, green, violet, orange,
     slate, yellow, fuchsia and lime, and the seven face colours
     (render/renderer.ts) take blue, green, yellow, red, purple, orange and
     pink. What is left is the blue-violet between Cleanse's sky and Erase's
     violet, and Erase is the near neighbour that matters -- the two are the
     pair a player has to tell apart, one face deleted against one face
     rewritten -- so this is held on the BLUE side of the gap where Erase's
     #a78bfa is on the purple side. It never shares a screen with Cleanse: a
     Transmute is the Tower's and a Cleanse over a board with no locked dice is
     not dealt at all. It is a chip, a banner and an outline and is never
     painted on the canvas, so renderer.ts does not carry it (the PAINTED list
     in test/abilityFeedback.test.js is Hint, Full clear and Wild, and that
     test fails if a fourth hex turns up there). */
  --ability-transmute: #818cf8;
  /* Frost (JIO-1940) is the eleventh, and it is THE ICE'S OWN RIM rather than a
     gap on the wheel. Every colour since Hint was chosen by elimination, and
     the hue this one wants is spent three times over: Cleanse has sky #38bdf8,
     the LEFT PLAYER'S ACCENT has teal #2dd4bf (render/renderer.ts's ACCENT, and
     an ability token that matched a player's own accent would be the worst of
     the ten), and face 1 has #5eb0ff. What is left in the blues is the pale
     cyan the ice is actually DRAWN in -- the rim of `rFrost` in ui/sketch.ts
     and the crust `drawFrost` paints on a real iced die -- so the token is the
     thing rather than a colour standing for it.
     CLEANSE IS THE PAIR THAT MATTERS and the two do share a screen, which no
     ability colour before this had to answer for: both are Versus and both are
     about locked dice, one freeing them and one landing dice it cannot free.
     They are told apart by VALUE and not by hue -- 81% lightness against 60% --
     which is the comparison that survives a 300ms board flash. Against Stop's
     pale slate #cbd5e1, the other light token, it is the saturation: 92%
     against 25%, a vivid cyan beside something all but neutral.
     It is a chip, a banner and an outline and is never painted on the canvas,
     so renderer.ts does not carry it -- the PAINTED list in
     test/abilityFeedback.test.js is Hint, Full clear and Wild, and that test
     fails if a fourth hex turns up there. */
  --ability-frost: #a5f3fc;
  /* Scramble (JIO-1941) is the twelfth, and it is a DEEP MAGENTA. Its one
     near neighbour in hue is Full clear's #e879f9, one degree away -- and the
     two never share a screen, which is the argument Hint, Full clear, Wild and
     Transmute were all chosen under: Full clear is the Tower's and a Scramble
     is Versus's. What it is held against instead is Erase's violet #a78bfa,
     which IS castable beside it: 38 degrees of hue and 27 points of lightness
     apart, which is the widest separation any ability colour added since Hint
     has managed against something on the same screen. It is also held off face
     7's hot pink #ff5ec8 (render/renderer.ts, dealt by Endless Hard alone) by
     29 degrees and 19 points, because a chip that read as a die body would be
     the one confusion a wash cannot survive.
     It is a chip, a banner and an outline and is never painted on the canvas,
     so renderer.ts does not carry it. */
  --ability-scramble: #c026d3;
  /* Jam (JIO-1942) is the thirteenth, and it is the ONE HUE NOTHING ON A VERSUS
     SCREEN SPENDS. Every colour since Hint was chosen by elimination and this
     one has the widest gap any of them has had: what a Versus board can show is
     Cleanse sky 199, Frost cyan 187, Shield green 142, Erase violet 260, Lock
     orange 25, Stop slate 213, Barrage rose 350 and Scramble magenta 294, the
     two player accents are teal 172 and amber 38, and the seven face colours
     (render/renderer.ts) take 212, 157, 46, 0, 262, orange and 320. That leaves
     46 -> 142 empty, and this sits in the middle of it: 48 degrees off face 3's
     yellow and 48 off Shield's green, the two nearest things that are on screen
     with it.
     WILD'S LIME #a3e635 IS THE NEAR NEIGHBOUR, 13 degrees away, and it is the
     Tower's -- which is the argument Hint, Full clear, Wild, Transmute and
     Scramble were every one of them chosen under, and Scramble took a hue one
     degree off Full clear's on it. SHIELD is the pair that does share a screen
     and the one worth stating: 48 degrees and 13 points of lightness, and the
     shapes carry it too -- a Shield is a ring around your OWN board and a jam
     is a chip on theirs.
     It is a chip, a banner and an outline and is never painted on the canvas,
     so renderer.ts does not carry it. */
  --ability-jam: #8ae047;
  /* THE EMERGENCY STOP (JIO-1115), and it is deliberately NOT an --ability-*
     token. The danger-zone rise pause and a cast Stop are the same event to a
     player -- the board is not rising -- and they used to be the same pixels,
     one amber zone line for both, so a free reprieve you earned and a whole
     meter you spent read identically. This is what tells them apart: whitish
     red against Stop's whitish slate, the same value the danger line's #ef4444
     would reach washed out the way that slate is.
     The name is the rule. Nothing DEALS an emergency stop -- it is not in
     `CONFIG.abilities.list`, no meter rolls it and the help screen's ability
     list must never grow a row for it -- so an `--ability-emergency` would have
     put it in front of every walk over that list (the meter's tinting, the help
     grid, test/abilityFeedback.test.js's own palette sweep) as an ability
     that does not exist. It is mapped onto --effect-color by one rule beside
     the six, further down, which is all the board and the chip ever ask for.
     render/renderer.ts carries the same colour again as EMERGENCY_HUE, because
     a canvas cannot be painted by a custom property; the two are held equal by
     test/abilityFeedback.test.js rather than by memory. */
  --rise-emergency: #fecaca;
  /* HOW LONG A SPENT SHIELD STAYS ON SCREEN (JIO-1075). The charge is gone in
     the tick that eats a volley, so the chip reading SHIELD · BLOCKED n and the
     ring around the board are both outliving the thing they name -- the window
     is ui/feedback.ts's BLOCKED_MS, which owns the clock, and this is the same
     number where CSS can reach it. It is written here rather than hard-coded
     into the two animations for the reason --topbar-h is: two copies of a
     duration drift, and a ring that outlives its chip by 200ms is a shield that
     looks like it blocked twice. test/abilityFeedback.test.js reads BLOCKED_MS
     out of feedback.ts and fails when this disagrees with it. */
  --blocked-ms: 900ms;
  /* What the top bar measures, which the fixed element that hangs under it
     (.tutorial) is positioned from. The coarse-pointer block at the
     bottom of this file grows the top bar, and it follows from here rather
     than carrying its own copy of the number (JIO-836). */
  --topbar-h: 52px;
  /* THE FOUNDATION'S TWO FACES (design/foundation/FOUNDATION.md §2, JIO-1062),
     the same pair and the same fallback stacks web/landing.html already loads,
     so the page a player lands on and the game they land in are one type
     system. Bungee is a POSTER face: all-caps by design, unreadable in a
     paragraph, and never set below 16px -- the smallest role §2 and §5 give it
     is a button. Nunito Sans carries every sentence.
     Both are THIS ORIGIN'S OWN FILES since JIO-1300 -- the @font-face rules at
     the top of this sheet, the woff2 under fonts/ beside it; every fallback
     here is a system face, so a font that fails to arrive costs the menus
     their display face and nothing else.
     --hud-font is the face the GAME wears and it is deliberately unchanged:
     this ticket retypes the menus and leaves the in-game HUD -- score, clock,
     the dice-info line, the meter labels -- on the face it was tuned at, where
     a digit is read at a glance out of the corner of an eye rather than read
     as prose. */
  --display: "Bungee", "Impact", "Haettenschweiler", sans-serif;
  --font: "Nunito Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
  --hud-font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  /* THE SCALE, and the menus set nothing outside it. Every size on an overlay
     screen was its own number before this -- forty-odd of them between 9px and
     44px, chosen a screen at a time -- which is how a help paragraph came to be
     13px and a puzzle tally 11px with nothing to say they were wrong.
     14px IS THE FLOOR for anything a player reads, and it outranks the scale's
     own mono row: §2 lists mono at 13 for times and scores, and every mono run
     on a menu screen (a date, a duration, a tally, a <kbd> cap) is something a
     player reads, so those take --t-small instead. --t-mono is left for the
     in-game readouts that are genuinely a glance and not a sentence.
     scripts/probe-landing.ts measures the rendered size of every run of text on
     the title, help, Settings and opponent screens against the floor
     (JIO-1062), the way it already does for the landing page (JIO-898). Match
     settings was the fourth of those until JIO-1068 deleted the screen; the
     probe kept opening the opponent screen under its label until JIO-1080,
     which is how Settings -- the screen with the smallest type in the game --
     came to be the one menu the floor was never measured on.

     THE SCALE IS IN rem, AND NOT BECAUSE OF BROWSER ZOOM (JIO-1188). The
     ticket that converted it asked for rem so that zoom could reach 200%, and
     that premise is disproven: measured in Chrome on 2026-09-17, a 200% zoom
     is the CSS VIEWPORT HALVING (390 -> 195) and nothing else -- `font-size:
     16px` and `font-size: 1rem` both still compute to 16px, and both are
     painted twice as large. Zoom has never cared what unit a size is written
     in, so WCAG 1.4.4 passed here before this change and passes after it.
     What px actually blocks is the OTHER control, the browser's own default
     font size (Chrome Settings -> Appearance -> Font size, which a low-vision
     reader sets once and expects every page to honour). Measured the same day
     with `--blink-settings=defaultFontSize=32`: the root goes to 32px, a rem
     size follows it (14 -> 28), and a px size is FROZEN at 14 whatever the
     reader asks for. That is the accessibility failure this sheet had, and
     it is the one rem fixes.
     Every size here is a whole number of px at the default root, so every
     token is an exact multiple of 1/16rem and the rendered size at the default
     setting is unchanged to the pixel -- test/type.test.js multiplies each
     token by 16 and matches it against FOUNDATION §2's table, which still
     states the scale in px because px is what the artboards are drawn at.
     The comments below give each role's px size for that reason: it is the
     number the design is drawn and measured in, and rem is how it is spent. */
  --t-hero: 2.5rem;      /* Bungee 40/1.05 -- the wordmark, the result title */
  --t-h1: 2rem;          /* Bungee 32/1.05 -- a screen title */
  --t-h2: 1.5rem;        /* Bungee 24/1.1  -- a section inside a screen */
  --t-card: 1.25rem;     /* Bungee 20/1.1  -- a card title, a menu row's name */
  --t-lede: 1.125rem;    /* Nunito 18/1.5/600 -- the sentence under a screen title */
  --t-body: 1rem;        /* Nunito 16/1.5/400 -- prose, and a secondary button */
  --t-small: 0.875rem;   /* Nunito 14/1.4/600 -- small print, and THE FLOOR */
  --t-mono: 0.8125rem;   /* mono 13 -- a time or a count in the game's own chrome */
  --t-mono-md: 0.9375rem; /* mono 15 -- a leaderboard row, and nothing else
     (design/pages/puzzles/NOTES.md, "times in mono 15"). A board is a COLUMN of
     times read against each other rather than one number read beside something,
     so it sits above the glance size and below the readout -- and it is drawn in
     `text` and never in teal, because on a board of ten the accent is what marks
     the top three ranks, and a column of teal times would take that mark away
     (JIO-1090). */
  --t-mono-lg: 1.125rem; /* mono 18 -- the Daily's clock, the one in-game number
     that is the POINT of the screen rather than a readout beside it
     (design/pages/puzzles/NOTES.md, "the timer sits beside the dots in mono 18
     teal"). It is a step ABOVE the scale's mono row and the row was widened
     here rather than the clock being typed at 18px where it is drawn: §2 is the
     spec of record and test/type.test.js reads it, so a size that lived only in
     a rule would be a size the foundation does not know about. */
}

* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font-family: var(--hud-font); }
body {
  overflow: hidden;
  /* The page itself never scrolls or zooms: it is a fixed game screen driven by
     keyboard and gamepad, so a stray drag on a phone must not rubber-band the
     document, select the HUD text, or double-tap-zoom the board. */
  overscroll-behavior: none;
  touch-action: manipulation;
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}
button { font: inherit; }

.app {
  /* The page's colour is painted HERE as well as on <body>, so that flipping
     `data-theme` on `#app` in devtools restyles the whole screen and not only
     the panels on it -- which is the check JIO-1061's acceptance is written
     as. `body` keeps its own copy for the rubber-band area past the app's box
     on a phone.

     `--world-wash` IS THE SAME COLOUR ONE WORLD DEEPER (JIO-1230): the worse of
     the two composited ground stops, written by ui/world.ts. It is a flat
     COLOUR and never a gradient on purpose -- scripts/probe-theme-contrast.ts
     composites a run of text's ancestors to find what is behind it and gives up
     on the first `background-image`, so a gradient here would have made every
     measured run in the game unmeasurable without failing anything. The
     gradient, the texture and the motif are on `.world` below, which is a
     sibling of the content rather than an ancestor of it. */
  background: var(--world-wash, var(--bg));
  /* The world sits at z-index -1 INSIDE this box: between `.app`'s own
     background and everything in it. That only holds while `.app` is a stacking
     context of its own -- without `isolation`, a negative child escapes to the
     root's and paints behind the background it is supposed to be in front of. */
  position: relative;
  isolation: isolate;
  /* dvh, not vh: on a phone 100vh is the viewport with the URL bar retracted,
     which pushes the footer (and the bottom of the arena) off the screen while
     the bar is showing. The vh line is the fallback for browsers without dvh. */
  height: 100vh;
  height: 100dvh;
  display: grid;
  /* topbar, the step's goal row, the puzzle's moves left (JIO-1908), arena,
     the puzzle HUD, the replay transport, footer. Every row but the arena is explicit rather than implicit so that a
     mode which does not draw one (every mode but the Tower for the goal row,
     every mode but puzzle for the first HUD, everything but a replay for the
     second, where each is `hidden`) collapses to nothing instead of taking a
     share of the slack the arena needs (JIO-928, JIO-992, JIO-1272). */
  grid-template-rows: auto auto auto 1fr auto auto auto;
  /* minmax(0, 1fr), not the default auto minimum: an auto track grows to the
     widest item's min-content, and the footer's nowrap bindings hint has a
     min-content of ~1069px. That one line of text was what laid the whole page
     out 1171px wide at a 375px viewport (JIO-833). Every track down the page is
     floored at 0 for the same reason. */
  grid-template-columns: minmax(0, 1fr);
  gap: 0;
}

/* THE WORLD BEHIND EVERY SCREEN (JIO-1230). Three layers, low contrast, behind
   content: the ground gradient out of `config.puzzles.themes`, a repeating
   texture and one motif in the world's heading colour. Menus, Settings,
   Records, Endless, Versus, Puzzles and the Tower map all sit on it, because
   all of them sit inside `.app` and this is the whole of `.app`.

   NOTHING THE GAME IS PLAYED WITH IS IN HERE. A die, a pip, the cursor and the
   locked hatch are the game's own objects and do not change with the place --
   the rule is written beside the themes in config.ts and probe-puzzles.ts
   samples both halves of it off the canvas.

   `color` IS THE WHOLE OF THE WORLD'S PALETTE. The drawing is `currentColor`
   throughout, so one property tints every stroke in it, and the two groups
   inside are dimmed separately because a texture and a motif are different
   amounts of ink (see --world-texture / --world-motif above). */
.world {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  overflow: hidden;
  color: var(--world-ink, var(--muted));
  background: linear-gradient(180deg, var(--world-top, var(--bg)), var(--world-bottom, var(--bg)));
}
/* ---- AND THE HOUSE WEARS THE PIPS SHEET (JIO-1702) ------------------------

   ~~House -- no world -- and the layer is not merely empty but absent: an
   empty box still paints its gradient.~~ STILL NO WORLD, NO LONGER EMPTY:
   `applyWorld()` draws `art/sheets/house.svg` into it for a fresh profile
   (ui/world.ts). The screen every new player sees first was the only one in
   the game with nothing behind it. Decision 0101, as amended, is the argument
   and FOUNDATION section 10 is the drawing's spec and its two contrast bands.

   `data-world` STAYS OFF, which is what keeps the House a palette: no ground,
   no `--world-ink`, no transparent menu panel, and every ratio
   probe-theme-contrast.ts reads on a light page is still the House's own. All
   three rules below hang off that attribute's ABSENCE, and they are the whole
   of the difference.

   0101's COST IS STILL PAID -- `--bg` over `--bg` is a repaint for nothing --
   by the EMPTY layer, which is the state between first paint and the first
   `applyWorld()`, and by dropping the gradient under the full one.

   FULL OPACITY, because `--world-texture` and `--world-motif` dim a
   `currentColor` drawing onto its ground and the sheet is drawn in
   `--sheet-near`/`--sheet-far`, which are already what it must read at.
   Dimming is the tuning applied twice.

   THE FADE IS THE LAYER'S OWN ALPHA, for the reason the scroller further down
   this file fades that way: a mask composites once, and the drift moves
   THROUGH it rather than carrying it along. Its stops are px because the tile
   is px -- a phone and a 1440 desktop get the same quiet band, not the same
   fraction of a different screen. */
:root:not([data-world]) .world:empty { display: none; }
:root:not([data-world]) .world {
  background: none;
  -webkit-mask-image: linear-gradient(180deg, rgba(0,0,0,0.4) 0, rgba(0,0,0,0.4) 150px, #000 520px);
  mask-image: linear-gradient(180deg, rgba(0,0,0,0.4) 0, rgba(0,0,0,0.4) 150px, #000 520px);
}
:root:not([data-world]) .world .texture, :root:not([data-world]) .world .motif { opacity: 1; }
.world > svg { display: block; width: 100%; height: 100%; }
.world .texture { opacity: var(--world-texture, 0.1); }
.world .motif { opacity: var(--world-motif, 0.16); }

/* ---- AND THE PLACE MOVES, SLOWLY, AND STOPS DEAD (JIO-1696) ---------------

   The two groups above drift against each other, and the parallax between two
   speeds is the whole of the effect. This one layer is BOTH halves of the
   ticket -- it is the whole of `.app`, so the menus and the arena a board is
   played in are behind the same rule. Nothing the board does changes it:
   motion that answered a game event would be saying so, and would then owe a
   readout that is not motion. docs/decisions/0100 is the argument.

   `transform` AND NOTHING ELSE. Sliding the ground gradient would repaint the
   viewport every frame -- the cost the `display: none` above refuses to pay for
   an EMPTY layer. These two are composited: no JavaScript, no frame callback,
   and `loop.frameJsP95Ms` in perf-budgets.json measured 3.4ms against its 6
   either side of this change (2026-10-06).

   IT PING-PONGS RATHER THAN SCROLLING. A one-way scroll must travel exactly one
   pattern tile to arrive back at itself, and across the 27 drawings those tiles
   are 60 to 220 units tall -- further than the margin below buys. `alternate`
   with `ease-in-out` has zero velocity at each turn, so there is no seam.

   THE TRAVEL IS SPENT OUT OF THE SCALE, and the two are owed each other. Every
   world is a 900x900 viewBox whose `.texture` is a 900x900 rect filling it
   exactly, so a translate uncovers its own edge -- and it runs INSIDE the scale,
   so the margin owes travel x scale: 1.12 buys 54 a side against 35.8, the
   motif's 1.07 buys 31.5 against 21.4. test/world.test.js holds the relation,
   so JIO-1701 doubling the pace moved the scales too (0100).

   THE HOUSE'S SHEET BUYS ITS MARGIN THE OTHER WAY (JIO-1702), and it has to:
   it has no viewBox, its units are device px, and a scale of 1.12 on a 600px
   window is 36px of margin against 35.84 of travel -- a hairline, and the
   motif's 20px of HORIZONTAL travel at 1.07 is 21.4 against 13.65 on a 390
   phone, which is not a margin at all. So its three rects are `x="-50%"
   width="200%"`: half a viewport of pattern past every edge, which no travel
   this layer is allowed can reach. The bound above still holds the keyframes;
   this is why the sheet is not held to it.

   AND NEITHER RULE BELOW MAY DECLARE A TRANSFORM OF ITS OWN. `animation: none`
   leaves an element at what its rule says, so reduced motion is scale 1 at
   offset 0 -- the frame design/foundation/worlds/ was approved at and the frame
   scripts/probe-world-art.ts reads all 27 contrast bands from. A base transform
   here would re-measure every one of them silently. */
.world .texture, .world .motif { transform-box: view-box; transform-origin: 50% 50%; }
.world .texture { animation: world-drift 11s ease-in-out infinite alternate; }
.world .motif { animation: world-float 15.5s ease-in-out infinite alternate; }
@keyframes world-drift {
  from { transform: scale(1.12) translate(0, 32px); }
  to { transform: scale(1.12) translate(0, -32px); }
}
@keyframes world-float {
  from { transform: scale(1.07) translate(-20px, 15px); }
  to { transform: scale(1.07) translate(20px, -15px); }
}
/* THE PAIR (decision 0097, generalised off the Tower's scene onto this layer):
   test/world.test.js reads the PAIRING off this file and never the count -- a
   count passes a layer whose newest motion has no variant at all. */
.app.reduced-motion .world .texture { animation: none; }
.app.reduced-motion .world .motif { animation: none; }

/* ---- THE GUARDIAN'S OBJECT (JIO-1234, canvas §5) -------------------------

   A Guardian is a thing on the screen and the fight is read off the thing.
   Five drawings in `art/guardians/`, one layer, and every state the fight is
   in arrives here as a `data-` attribute or a custom property that
   ui/guardianObject.ts wrote -- so the whole of what a fight LOOKS like is in
   this block and the whole of what it MEANS is in that module.

   IT BORROWS THE WORLD'S COLOUR AND NOT ITS OWN. `--world-ink` is the floor's
   heading colour, which is the same ink the emblem in the top bar is drawn in
   (ui/world.ts) -- so the door, the bench and the vines are the Vault's, the
   Workshop's and the Greenhouse's rather than five hues chosen here. On the
   House palette, where no world is worn, it falls back to `--muted` for the
   world layer's reason: a drawing at full text contrast behind a board is a
   drawing that competes with the dice.

   THREE PLACES, AND ONLY ONE OF THEM COSTS THE BOARD ANYTHING. */
.guardian-object {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  overflow: hidden;
  color: var(--world-ink, var(--muted));
  opacity: var(--guardian-object, 0.38);
}
/* No fight, no layer. Same rule as the world's, same reason: an empty
   absolutely-positioned box is a thing the compositor still visits. */
.guardian-object:not([data-object]) { display: none; }
.guardian-object > svg { display: block; width: 100%; height: 100%; }

/* A `top` object -- the Warden's door, the Foreman's bench -- IS NOT A ROW OF
   THE PAGE ANY MORE, AT ANY WIDTH (2026-10-02). It was one from JIO-1234: the
   grid gained a row, chromeHeight() charged it, and a fight's board was drawn
   a fifth smaller than Endless's under it. George, on the web build: "The board
   size should not change across any mode", and then, asked what a phone should
   do with no column to put the door in: draw it behind the board.

   So `place: 'top'` now says where the drawing stands and never what it costs.
   OFF A PHONE it is in the column beside the board (`.tower-side`, further
   down, where its rules are). ON A PHONE it is this same layer every `behind`
   object uses, pinned to the top of the board instead of centred on the page:
   the drawing is a 900x240 band, the board covers most of it, and what is left
   is its top edge behind the goal row and its ends past the board's sides. The
   health it is cut into is in words in the goal row either way
   (ui/guardianObject.ts), which is what makes a covered mark a lost picture
   and not a lost number.

   `--board-y` is the canvas's own top, published by main.ts's syncMeters() off
   the box it just sized; 50px is the goal row the drawing's head goes behind. */
.app.compact .guardian-object[data-place="top"] > svg {
  height: auto;
  aspect-ratio: 900 / 240;
  margin-top: max(0px, calc(var(--board-y, 0px) - 50px));
}
/* `sides` and `behind` stay at `inset: 0` behind the whole app, which is where
   the vines' two stems and the eye's rings want to be: the vines grow down from
   the TOP CORNERS OF THE PAGE, and the eye is behind the board in the glow. */

/* ---- the health, which is the object -------------------------------------

   Every object cuts its health into marks -- nine bolts, nine tools, nine
   lengths of vine, seven stars, five rings -- and a mark that is spent carries
   `data-gone`, written by ui/guardianObject.ts from the same `guardianHealth()`
   the header bar's number comes out of.

   GREY AND STILL THERE, NEVER REMOVED. A bolt that vanished would leave a door
   with a hole in it and would say the Warden has fewer bolts rather than fewer
   left; the ticket's word is "snaps grey". The struck tool, the cut vine and
   the dead star are the same idea -- what a Guardian has lost has to stay
   countable, which is the whole reason nine of them are drawn. */
.guardian-object [data-mark] {
  opacity: 1;
  transition: opacity 320ms ease, stroke-width 320ms ease;
}
.guardian-object [data-mark][data-gone] { opacity: 0.16; }
/* A snapped bolt and a dead star lose their weight as well as their light: at
   0.16 alone the nine read as one dim row rather than as five out of nine. */
.guardian-object[data-object="vault-door"] [data-mark][data-gone],
.guardian-object[data-object="star-chart"] [data-mark][data-gone] { stroke-width: 2; }
/* A tool taken down is off the bench and on its way, so it drops as it fades --
   `transform-box: fill-box` because an SVG element's transform origin is the
   user-space origin otherwise, which would swing it across the page. */
.guardian-object[data-object="workbench"] [data-mark] {
  transform-box: fill-box;
  transform-origin: 50% 0;
  transition: opacity 320ms ease, transform 320ms ease;
}
.guardian-object[data-object="workbench"] [data-mark][data-gone] { transform: translateY(26px) rotate(8deg); }
/* THE HEALTH THAT APPEARS INSTEAD OF FADING (JIO-1442). The Smith's anvil, the
   Bellringer's bell and the Frost's pillars lose their health as CRACKS, and a
   crack is a thing that shows up: a `.fissure` is a hairline while it is still
   to come and full ink once it has opened, so the count left is the hairlines.
   The Queen's cells are the same idea the other way round -- an empty cell is
   health, and a spent one fills with honey. */
.guardian-object [data-mark].fissure { opacity: 0.2; }
.guardian-object [data-mark].fissure[data-gone] { opacity: 1; }
.guardian-object [data-mark].cell[data-gone] { opacity: 1; fill: currentColor; fill-opacity: 0.55; }
/* The Pinnacle's flag comes down a notch per mark spent. 14 is the notch pitch
   in design/foundation/guardians/peak.svg, in its own user units. */
.guardian-object .flag { transform: translateY(calc(var(--g-spent, 0) * 14px)); transition: transform 320ms ease; }
/* The constellation's links go out with the star they end at, so a half-dead
   constellation is a broken figure and not a full one with gaps in it. */
.guardian-object [data-link] { opacity: 0.7; transition: opacity 320ms ease; }
.guardian-object[data-spent="1"] [data-link="1"],
.guardian-object[data-spent="2"] :is([data-link="1"], [data-link="2"]),
.guardian-object[data-spent="3"] :is([data-link="1"], [data-link="2"], [data-link="3"]),
.guardian-object[data-spent="4"] :is([data-link="1"], [data-link="2"], [data-link="3"], [data-link="4"]),
.guardian-object[data-spent="5"] :is([data-link="1"], [data-link="2"], [data-link="3"], [data-link="4"], [data-link="5"]),
.guardian-object[data-spent="6"] :is([data-link="1"], [data-link="2"], [data-link="3"], [data-link="4"], [data-link="5"], [data-link="6"]),
.guardian-object[data-spent="7"] [data-link] { opacity: 0.12; }

/* ---- the barrage clock, which is the Warden's wheel -----------------------

   `--g-arc` is 0 at the last volley and 1 at the next, written every frame from
   `battle.barrage.ms`. The arc is drawn with `pathLength="100"` in the file, so
   the dash is that fraction of a hundred and the circle's real circumference
   never enters into it -- which is what lets the door be redrawn at a different
   radius without this number moving. */
.guardian-object .arc {
  stroke: var(--danger);
  stroke-dasharray: calc(var(--g-arc, 0) * 100) 100;
  opacity: calc(0.35 + var(--g-arc, 0) * 0.65);
}
/* THE VOLLEY: the wheel turns, the door cracks, and 700ms later both are gone.
   `data-volley` is a deadline ui/guardianObject.ts holds off the engine's own
   `guardianBarrage` event -- the same event the columns' incoming markers are
   drawn from, so the door cannot crack on a volley the board did not get. */
@keyframes g-spin { to { transform: rotate(360deg); } }
.guardian-object[data-volley] .wheel {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: g-spin 700ms cubic-bezier(0.4, 0, 0.2, 1);
}
.guardian-object .crack { stroke: var(--danger); transition: opacity 400ms ease; }
.guardian-object[data-volley] .crack { opacity: 0.9; }

/* ---- the Overgrowth's leaves ---------------------------------------------

   One leaf per barrage, and a big clear near the top burns them all back
   (ui/guardianObject.ts's BURN_SIZE / BURN_ROWS). They grow in rather than
   appearing, because the whole point of the Overgrowth's object is that the
   vine is DOING something while you are not cutting it. */
.guardian-object [data-leaf] {
  opacity: 0;
  transform-box: fill-box;
  transform-origin: 0 50%;
  transform: scale(0.3);
  transition: opacity 420ms ease, transform 420ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.guardian-object [data-leaf][data-shown] { opacity: 0.85; transform: none; }

/* ---- the five rules of the twenty-five-floor climb (JIO-1666) -------------

   Each flag below is a `data-` attribute ui/guardianObject.ts holds off an
   engine event -- never off a reading of the board -- and each selector names
   the one part group of the one drawing that answers it, which is the leaves'
   own arrangement: the module writes every flag for every object, and the
   stylesheet is what decides that only the sluice's water freezes.

   THE ICE (`iced:n`, the Sexton's crypt and the Tide's sluice). `data-iced`
   holds for the rule's own seconds, restarted by every volley that carries
   it. The sluice's water stiffens to a frozen dash and brightens; the crypt's
   candles gutter down -- and relight when the ice lets go, which is the one
   thing the Sexton's line promises. 600ms transitions, so the ice SPREADS
   rather than switches. */
.guardian-object .water,
.guardian-object .candles { transition: opacity 600ms ease; }
.guardian-object[data-iced] .water { opacity: 0.95; stroke-dasharray: 2 12; }
.guardian-object[data-iced] .candles { opacity: 0.35; }

/* THE REVEAL (`hidden:n`, the Jester's mirror and the Keeper of the Lamp's
   lantern). The clear that shows a hidden face is answered for REVEAL_MS: the
   lamp's flame leaps -- "Borrow my light" -- and the mirror's glass glints.
   The flame scales about its own foot (`fill-box`, the workbench's reason:
   an SVG transform origin is the user-space origin otherwise). */
.guardian-object .glass { transition: opacity 400ms ease; }
.guardian-object .flame {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  transition: opacity 400ms ease, transform 400ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.guardian-object[data-reveal] .flame { opacity: 1; transform: scale(1.3); }
.guardian-object[data-reveal] .glass { opacity: 0.8; }

/* THE FUSE (`fuse:n`, the Magmaw's vent). `data-fuse` burns for the engine's
   own `burnMs` and goes out EARLY on the wave that defused it: the vent's
   smoke thickens while a die is lit, and smoke still thickening past the
   clear that answered it would say the answer did not work. */
.guardian-object .smoke { transition: opacity 600ms ease, stroke-width 600ms ease; }
.guardian-object[data-fuse] .smoke { opacity: 1; stroke-width: 9; }

/* THE GROWTH (`grow:n`, the Besieger's trebuchet and the Ringmaster's cage).
   One `data-load` per volley SENT -- the queue of stones waiting by the
   engine, the extra hoops winding the cage tighter -- numbered like the
   leaves and capped by what the drawing carries. They are NOT marks: the
   health never moves through them. */
.guardian-object [data-load] { opacity: 0; transition: opacity 420ms ease; }
.guardian-object [data-load][data-shown] { opacity: 0.85; }

/* THE RE-DEAL (`reshuffle:n`, the Dealer's roulette). One turn of the wheel
   per `guardianReshuffle`, on the Warden's own clock and curve: the wedges
   ride the spin and the spent pockets ride it with them, so the health reads
   the same the moment it stops. The marks' own `fill-box` would spin each
   wedge about itself; the group's box is the whole ring, whose centre is the
   wheel's. */
.guardian-object[data-shuffle] .wedges {
  transform-box: fill-box;
  transform-origin: 50% 50%;
  animation: g-spin 700ms cubic-bezier(0.4, 0, 0.2, 1);
}

/* ---- the Leviathan's eye -------------------------------------------------

   The pupil follows the CURSOR and not the pointer: every input the game has
   moves the same cursor, and a pupil that tracked a mouse would be still for
   the whole of a fight played on a phone. The two numbers are the eye's travel
   in its own user units, here beside the transform that spends them, so a
   redrawn eye moves its pupil by editing the picture. */
.guardian-object .pupil {
  transform: translate(calc(var(--g-pupil-x, 0) * 46px), calc(var(--g-pupil-y, 0) * 34px));
  transition: transform 180ms ease-out;
}
/* THE BLINK IS THE 4+ FEEDBACK ON THIS FLOOR. A refused three shakes the three
   dice and leaves a white outline (JIO-1233); down here the eye shuts and opens
   once, which is the object saying the same no. It is 180ms because a blink
   that outlasted the shake would be answering a second refusal with the first
   one's lid still down. */
@keyframes g-blink { 0%, 100% { opacity: 0; } 30%, 70% { opacity: 1; } }
.guardian-object[data-blink] .lid { stroke: currentColor; animation: g-blink 180ms ease-in-out; }

/* A BEATEN GUARDIAN'S OBJECT GOES OUT WITH IT. The chain that crossed the bar
   is still settling (SPEC §20), so there are a dozen frames after the last mark
   snaps in which the object is still on screen -- and an object at full
   strength over a board that has already won reads as a fight still running. */
.guardian-object[data-dead] { opacity: calc(var(--guardian-object, 0.38) * 0.4); transition: opacity 600ms ease; }

/* ---- less motion ---------------------------------------------------------

   A player who asked for less motion gets the same OBJECT and none of its
   movement: the marks still go out, the arc still grows (it is a clock, and a
   clock that did not move would be the one readout on the screen that lies),
   and the three things that MOVE rather than change -- the wheel's spin, the
   tool's drop, the leaf's pop -- are cut to a change of state. The eye still
   blinks: a blink IS the feedback here, and dropping it would leave a refused
   three with no answer at all on this floor. It is stepped rather than eased,
   which is the same bargain the refused shake takes (render/renderer.ts). */
.app.reduced-motion .guardian-object [data-mark],
.app.reduced-motion .guardian-object [data-leaf],
.app.reduced-motion .guardian-object [data-link],
.app.reduced-motion .guardian-object [data-load],
.app.reduced-motion .guardian-object .pupil,
.app.reduced-motion .guardian-object .water,
.app.reduced-motion .guardian-object .candles,
.app.reduced-motion .guardian-object .glass,
.app.reduced-motion .guardian-object .flame,
.app.reduced-motion .guardian-object .smoke,
.app.reduced-motion .guardian-object .flag { transition: none; }
.app.reduced-motion .guardian-object[data-volley] .wheel { animation: none; }
/* The re-deal's spin is a MOVE and is cut like the wheel's; the frozen water,
   the guttered candles, the thickened smoke, the glint and the waiting stones
   all SURVIVE as changes of state, because each is the one thing on its floor
   saying what the Guardian just did. The flame keeps its flare's light and
   loses its leap. */
.app.reduced-motion .guardian-object[data-shuffle] .wedges { animation: none; }
.app.reduced-motion .guardian-object[data-reveal] .flame { transform: none; }
.app.reduced-motion .guardian-object[data-object="workbench"] [data-mark][data-gone] { transform: none; }
.app.reduced-motion .guardian-object [data-leaf][data-shown] { transform: none; }
.app.reduced-motion .guardian-object[data-blink] .lid { animation: g-blink 180ms steps(1, end); }
/* EVERY ROW IS PLACED EXPLICITLY, so that hiding one does not move the rest
   (JIO-1064). `display: none` takes an element out of grid FLOW, and with
   auto-placement the tracks are then handed out to whatever is left, in order:
   taking the top bar off a menu screen moved the arena into the `auto` track
   and the FOOTER into the `1fr`, which stretched a 28px bar to 39 and resized
   the board under it. Three of these five are `display: none` in most modes, so
   auto-placement was only ever safe because the two that are not happened to
   sit above them. An empty track collapses to 0 and costs nothing. */
.topbar { grid-row: 1; }
.step-goal { grid-row: 2; }
.puzzle-count { grid-row: 3; }
.arena { grid-row: 4; }
.puzzle-hud { grid-row: 5; }
.replay-hud { grid-row: 6; }
.footer { grid-row: 7; }

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 6px 12px;
  padding: calc(8px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right)) 8px calc(16px + env(safe-area-inset-left));
  border-bottom: 1px solid var(--line);
  background: var(--panel);
}
/* THE HOME LINK IS THE 24px MARK (JIO-1063), not the text "jiolab.dev". It has
   no colour and no font size any more, which is why the compact rules below no
   longer shrink it -- the mark is the smallest it is ever drawn at already. */
.topbar .home { display: inline-flex; align-items: center; flex: 0 0 auto; text-decoration: none; }
.topbar .home img { display: block; width: 24px; height: 24px; }
.topbar .home:focus-visible { outline: 2px solid var(--left); outline-offset: 3px; border-radius: 6px; }
.match-info { font-size: 0.875rem; color: var(--muted); display: flex; gap: 8px; align-items: center; }
.match-info .clock { font-family: var(--mono); color: var(--text); min-width: 4ch; text-align: center; }
.match-info .clock.sudden { color: var(--danger); }
/* ~~THE TOWER'S TARGET BAR, in the clock's slot and in the clock's mono face:
   `412 / 600` over a 64px track~~ (JIO-1232). The readout is a ROW of its own
   now and its rules are with `.step-goal` further down this file (JIO-1272).
   The struck paragraph stays because the 64px was argued for -- "a track that
   grew with the bar would be a different picture of the same score at two
   widths" -- and that argument is still true of anything put back in this
   slot; what changed is that the slot was the wrong place for the one rule a
   step is played against.

   `hidden` HAS TO WIN over the `display` this rule sets, the same trap
   `.deck[hidden]` carries a comment about: the UA's `[hidden] { display: none }`
   loses to any authored `display`, so without this a Guardian fight drew the
   step's target bar under the Guardian's health bar (JIO-1233). */
.match-info .guardian[hidden] { display: none; }
/* WHO YOU ARE FIGHTING (JIO-1233, canvas §5, emptied by JIO-1272): the emblem
   and the name, beside the floor line. The health that used to hang off this
   readout is in the goal row below the bar, at a step's target's own size. */
.match-info .guardian { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.match-info .g-emblem { display: flex; line-height: 0; color: var(--world-ink, var(--danger)); }
.match-info .g-emblem svg { display: block; }
/* The name in the display face, and TRUNCATED rather than wrapped: the top bar
   wraps as a row, and a Guardian whose name took two lines would push the chip
   row and the board down by the height of one of them mid-fight. */
.match-info .g-name {
  font-family: var(--display); font-weight: 400; font-size: 0.8125rem; line-height: 1.1;
  letter-spacing: 0.04em; color: var(--world-ink, var(--text));
  max-width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* THE STEP'S GOAL ROW (JIO-1272): a row of the page between the top bar and the
   board, carrying the one rule a Tower step is played against.

   WHY IT IS A ROW AND NOT A SLOT IN THE BAR. The readout was a 64px track four
   pixels tall in the clock's slot with `412 / 600` in 12px mono beside it --
   "It took me a while to even find it" (George, 2026-09-18) -- which made the
   whole rule of the mode the least visible thing on the screen. Prominence is
   the deliverable here, so the score is drawn at 1.75rem in the display face:
   the size `.score-line` gives the board's own score on a phone, which is the
   biggest number the game draws outside a result card, and the floor this
   ticket asks for ("at least the size of the board's score").

   THE ROW STAYS OFF A PHONE. It is the step's rule and not a phone
   accommodation, so there is no `.compact` gate anywhere in this block.

   THE WIDTH IS THE BOARD'S OWN GUTTER and not the readout's: the same
   horizontal padding `.arena` carries, safe-area insets and all, so the track
   runs the width the board is laid out in rather than 64px of the middle of the
   bar. `.arena` narrows that gutter from 24 to 12 under 1100px and this row is
   in the same rule, or the track would hang 12px past the board on each side on
   every phone -- which is the one width the ticket was written about.

   ONE GEOMETRY, ~~TWO~~ THREE READOUTS, NEVER TWO AT ONCE. `#info-target`
   counts UP to a step's target, `#guardian-goal` counts a Guardian's health
   DOWN to zero, and `#duel-goal` counts the rows of the Guardian's OWN board
   (JIO-1725) -- three readings of one rule, so they are the same big number,
   the same goal phrase and the same 10px track, because a fight drawn at a
   different height would re-proportion the board between a step and a fight.
   What differs is the direction and the colour: the first two carry theirs on
   `.drains` below and the duel's are on `.duel`. ui/hud.ts's setTower() is
   still the one place that decides which is on screen.

   THE DUEL STRIP ADDS NO ROW, and that is the rule above honoured rather than
   quoted: its twelve segments ARE the track and the meter the fight's words are
   cast off fills the chip that names the next one, so the strip is one line and
   one track like the other two. A second track would have been 14px off the
   board on every phone (SPEC §20: the board is one size in every mode). */
.step-goal {
  display: flex;
  flex-direction: column;
  padding: 8px calc(24px + env(safe-area-inset-right)) 0 calc(24px + env(safe-area-inset-left));
}
/* `hidden` has to win over the `display` above, the trap `.deck[hidden]` and
   the readouts in the top bar both carry a comment about. */
.step-goal[hidden], .step-goal .goal[hidden] { display: none; }
.step-goal .goal { display: flex; flex-direction: column; gap: 4px; }
.step-goal .goal-line { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
/* NEITHER WORD WRAPS, and that is the layout order rather than taste: the row
   is measured as chrome BEFORE the board is sized and capped to the board's
   width AFTER, so a row whose height could change with its width would size the
   board against a row that is about to move. One line and a 10px track, at
   every width. */
.step-goal .goal-score, .step-goal .goal-want { white-space: nowrap; }
/* THE HELD POWER-UPS' TOKENS, AT THE RIGHT END OF THE LINE ABOVE (JIO-1899,
   canvas board 5), and on a phone only: off one this row is a card beside the
   board and the chip row under it names the same power-ups.

   OUT OF FLOW, AND THAT IS GEORGE'S ONE CONDITION ON THE DESIGN (2026-10-16):
   the board is the same size with tokens as without, on every layout. The
   button is absolutely placed over the row's first line, its content box
   exactly that line's 1.75rem (`.goal-score`'s line-height 1), and the line
   makes room with a HORIZONTAL padding -- 30px a token, the 24px ring and its
   gap -- so nothing here can add a pixel to the height chromeHeight()
   measures. Its 8px above and below is the row's own top padding and the
   track's gap: a 44px target that overlaps nothing but the track's picture.

   `right` is the row's own gutter, which is 24px and 12px under 1100px (the
   `@media` below says it twice). NOT SCOPED TO THE PHONE'S CLASS, which is this
   row's own rule (test/towerScreens.test.js): what takes the tokens off the
   desktop is the column the row is moved into, `.tower-side`. */
.step-goal { position: relative; }
.step-goal .goal-line { padding-right: calc(var(--goal-tokens, 0) * 30px); }
.goal-tokens[hidden], .tower-side .goal-tokens { display: none; }
.goal-tokens {
  position: absolute; top: 0; right: calc(24px + env(safe-area-inset-right));
  box-sizing: content-box; height: 1.75rem; padding: 8px 0 8px 6px; margin: 0;
  display: flex; align-items: center; gap: 6px;
  border: 0; background: none; color: inherit; cursor: pointer;
}
/* ~~FULL BOARD WIDTH OFF A PHONE~~ -- off a phone this is not a row at all any
   more: main.ts's placeTowerReadouts() moves it into the column beside the
   board, and its rules there are with `.tower-side` further down this file.
   The row cost the board a cell in ten at 1440x900 and the board is one size
   in every mode there now (docs/decisions/0038). */
.step-goal .goal-score {
  font-family: var(--display); font-weight: 400; font-size: 1.75rem; line-height: 1;
  color: var(--text); font-variant-numeric: tabular-nums;
}
/* The goal, in the map's own words and the world's own ink -- "REACH 450" is
   what the lit die on ui/towerMap.ts says over the step you are standing on,
   and a player arrives here from that screen. */
.step-goal .goal-want {
  font-family: var(--display); font-weight: 400; font-size: 0.875rem; line-height: 1.1;
  letter-spacing: 0.06em; color: var(--world-ink, var(--left)); text-align: right;
}
/* 10px, where the bar's track was 4. The ticket's floor is 8 and the two extra
   pixels are the radius: a 8px track with a 4px radius reads as a lozenge at
   the ends and as a bar in the middle. */
.step-goal .goal-track { height: 10px; border-radius: 5px; background: var(--line); overflow: hidden; }
.step-goal .goal-fill { display: block; height: 100%; width: 0; background: var(--left); border-radius: 5px; }
/* MET IS SAID AND NOT ONLY DRAWN. The bar is full for the whole of the settle
   that ends the step (SPEC §20), and the one instant a step is won has to read
   differently from the dozen frames before it. The old readout carried that in
   `font-weight: 700` over its colour; the display face has ONE weight, so the
   word carries it instead -- ui/hud.ts writes REACH 450 / REACHED and
   DEFEATED, which is a state a palette cannot take away. */
.step-goal .goal.met .goal-score,
.step-goal .goal.met .goal-want { color: var(--world-ink, var(--left)); }
.step-goal .goal.met .goal-fill { background: var(--world-ink, var(--left)); }
/* A GUARDIAN'S HALF DRAINS (JIO-1233, moved here by JIO-1272), so its fill is
   anchored RIGHT -- it empties towards the number counting down with it rather
   than away from it -- and starts full rather than empty. The fill's colour is
   written inline by ui/hud.ts from ui/guardian.ts's amber-to-red mix: the
   fraction is continuous, so a threshold in a stylesheet would say "close" once
   instead of all the way down. */
.step-goal .goal.drains .goal-fill { width: 100%; margin-left: auto; background: #f59e0b; }
.step-goal .goal.drains.met .goal-score,
.step-goal .goal.drains.met .goal-want { color: var(--danger); }

/* ---- A BOUNTY'S ROW (JIO-1793) --------------------------------------------

   THE CALLED FACE'S DIE, before the count it is the unit of. It sits on the
   line's own baseline at the figure's cap height rather than at its full
   line-box: the figure is display type at 1.5rem and a 22px die hung off the
   top of it would push the row taller, and this row is a row of the grid
   main.ts's chromeHeight() measures. `flex: none` because everything else on
   the line grows and this is 22px whatever the width is. */
.step-goal .goal-count { display: inline; white-space: nowrap; }

/* THE TRACK AS A ROW OF SEGMENTS, which is the duel strip's shape below and is
   the same 10px at the same radius, so a bounty step and every other board are
   one row at one height. A chain's waves and a big one's matches are counts of
   between one and six: lighting them one at a time says how many are left,
   where a fill at 1/3 says only "some". The gaps are 2px, the duel's own.

   UNLIT IS THE TRACK'S OWN `--line` and not a dimmed accent, for the duel's
   reason: a step with nothing done yet should read as nothing done at a glance
   rather than as a bar at 8%. */
.step-goal .goal.segmented .goal-track { display: flex; gap: 2px; background: none; overflow: visible; }
/* THE BAR GOES BY THE `hidden` ATTRIBUTE and not by the `.segmented` class,
   which is `.step-goal`'s own rule and a test (test/towerScreens.test.js): every
   selector in this file that takes part of this row off the page keys on the
   attribute ui/hud.ts writes rather than on a width or a state, so the row can
   never be hidden by a viewport again. The explicit rule is needed because
   `.goal-fill` sets `display: block`, which wins over the UA stylesheet's own
   `[hidden]`. */
.step-goal .goal-fill[hidden] { display: none; }
.step-goal .goal.segmented .goal-track > i.seg {
  flex: 1 1 0; min-width: 0; height: 100%; border-radius: 2px; background: var(--line);
}
.step-goal .goal.segmented .goal-track > i.seg[data-full] { background: var(--left); }
/* Met is the row's own word AND the colour the other branches take, which keeps
   the one instant a step is won reading differently from the frame before it on
   this branch too. */
.step-goal .goal.segmented.met .goal-track > i.seg[data-full] { background: var(--world-ink, var(--left)); }
/* ---- A PUZZLE STEP'S RINGS (JIO-1797) -------------------------------------
   The segmented branch read the other way: those segments are PROGRESS and
   light up, where a puzzle's budget is SPENT -- so its one to three are slots
   that get used, as wide as they are tall rather than stretched across the row
   (a 10px bar the width of the screen over a budget of one is 0% or 100% and
   never anything else). UNLIT IS A RING AND NOT A BLOCK: `--line` at 2px with
   nothing inside says "a move you still have". design/pages/tower/NOTES.md. */
.step-goal .goal.rings .goal-track { display: flex; gap: 4px; background: none; overflow: visible; justify-content: flex-start; }
.step-goal .goal.rings .goal-track > i.seg {
  flex: 0 0 auto; width: 10px; height: 10px; border-radius: 50%;
  background: none; border: 2px solid var(--line); box-sizing: border-box;
}
.step-goal .goal.rings .goal-track > i.seg[data-full] { background: var(--left); border-color: var(--left); }
.step-goal .goal.rings.met .goal-track > i.seg[data-full] { background: var(--world-ink, var(--left)); border-color: var(--world-ink, var(--left)); }
/* A BUDGET SPENT WITH DICE STILL STANDING is the puzzle HUD's own red: the dots
   under the board go red on `puzzleStuck` and this row carries the same state,
   because UNDO OR RESET is what both are telling the player. */
.step-goal .goal.rings.spent .goal-track > i.seg[data-full] { background: var(--danger); border-color: var(--danger); }
.step-goal .goal.rings.spent .goal-want { color: var(--danger); }

/* ---- THE DUEL STRIP (JIO-1725) -------------------------------------------
   A Guardian plays a board of its own and that board is UNDRAWN -- `.side.right`
   is `display: none` in this mode, so the player's board keeps the one size
   every mode draws it at -- but for a portrait phone's window (JIO-1818). These
   twelve segments are therefore not a decoration beside a grid; they are the
   only reading of that grid there always is, one per row of it, lighting from
   the FOOT up because that is the direction a stack grows.

   THE GAPS ARE 2px AND THE TRACK IS THE SAME 10px, so a fight and a step are
   the same row at the same height. Unlit is the track's own `--line`, which is
   what makes a nearly-empty board read as empty rather than as a bar at 8%: the
   Guardian is in no trouble and the strip should say so at a glance. */
.step-goal .goal.duel .goal-track {
  display: flex; gap: 2px; background: none; overflow: visible;
}
.step-goal .goal.duel .goal-track > i {
  flex: 1 1 0; min-width: 0; height: 100%; border-radius: 2px; background: var(--line);
}
/* AMBER AT THE FOOT AND RED AT THE TOP, which is the health bar's own mix read
   the other way (ui/guardian.ts's HEALTH_FULL / HEALTH_LOW): red means the
   Guardian is nearly finished, there as here. Per-segment and in the
   stylesheet rather than written inline per frame, because a segment is either
   lit or it is not -- the same quantisation the object's marks are drawn under
   -- so there is no continuous fraction for a style attribute to carry. */
.step-goal .goal.duel .goal-track > i[data-full] { background: #f59e0b; }
.step-goal .goal.duel .goal-track > i:nth-child(n+8)[data-full] { background: #ef4444; }
.step-goal .goal.duel.met .goal-score,
.step-goal .goal.duel.met .goal-want { color: var(--danger); }

/* THE CAST METER IS THE CHIP. The word the Guardian's meter has rolled, with
   the meter itself filling behind it -- so WHAT is coming and HOW SOON are one
   readout in the space of one, which is what keeps this strip to a single line.
   `--cast` is the only thing ui/hud.ts writes per frame. */
.step-goal .goal.duel .goal-cast {
  font-family: var(--display); font-weight: 400; font-size: 0.75rem; line-height: 1.1;
  letter-spacing: 0.08em; white-space: nowrap; color: var(--muted);
  padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line);
  background: linear-gradient(to right, color-mix(in srgb, #f59e0b 28%, transparent) var(--cast, 0%), transparent var(--cast, 0%));
}
.step-goal .goal.duel .goal-cast[hidden] { display: none; }
/* HELD, AND IT IS SAID RATHER THAN ONLY FILLED: a full meter is the one state
   of this chip a player has to be able to act on -- it is the next thing that
   happens to their board -- and a bar that merely reached its own end reads
   like a bar at 95%. The border and the ink carry it, which is a palette and a
   colour-blind eye's two carriers rather than one. */
.step-goal .goal.duel .goal-cast.ready { color: var(--text); border-color: #f59e0b; }

/* THE MUTATION CHIP ROW (JIO-1233). ~~ON A PHONE, a second LINE OF THE TOP BAR
   -- 100% basis in a wrapping `.topbar` -- so the rules of the fight are legible
   for the whole of it without a word being laid over a die~~ -- **ON A PHONE IT
   IS CLIPPED AND DRAWN NOWHERE (JIO-1783, 2026-10-08)**: a line of that bar is
   a line off every Tower board, and asked to choose between the two George took
   the board ("just remove the explanatory pills because that is affecting the
   height in tower mode"). The rule the first sentence is about still holds and
   is why the row cannot simply move onto the board instead
   (design/pages/gameplay/NOTES.md Revision 3, and SPEC §20's rule for a
   Guardian): nothing may be laid over a die, so a phone reads the fight's rules
   on the Guardian's sheet and the floor's briefing BEFORE the board, and the
   clipped row keeps them for a screen reader during it.
   The declarations below are the row as it is drawn OFF a phone, in the column
   beside the board (`.tower-side`), where it costs the board nothing. ~~The
   bar's width and not the readout's: inside `.match-info` the row had ~184px at
   a 390px phone and two chips wrapped onto two lines.~~ */
.guardian-chips {
  flex: 1 0 100%;
  display: flex; flex-wrap: wrap; justify-content: center; gap: 6px;
  margin-top: 4px;
}
.guardian-chips[hidden] { display: none; }
.guardian-chips .g-chip, .gs-chips .g-chip, .tb-board-chips .g-chip {
  font-family: var(--display); font-weight: 400; font-size: 0.6875rem; line-height: 1;
  letter-spacing: 0.06em; padding: 5px 8px; border-radius: 999px;
  border: 1px solid var(--line); color: var(--muted); background: none;
}
/* A TOOL THE FIGHT TOOK AWAY is the danger outline, and it is the same red the
   hatched deck key and the hatched meter wear: one colour for "this is gone",
   said on the control and in the row that names it. */
.guardian-chips .g-chip[data-kind="taken"], .gs-chips .g-chip[data-kind="taken"], .tb-board-chips .g-chip[data-kind="taken"] {
  border-color: var(--danger); color: var(--danger);
}
/* The barrage is the one chip whose text changes during the fight: it counts
   down to the next volley, and it goes SOLID for the 1.5 s the volley spends in
   the air. Solid rather than flashing, because the markers over the columns are
   already pulsing and two rhythms for one event is one of them being read as
   something else. */
.guardian-chips .g-chip[data-kind="sent"], .gs-chips .g-chip[data-kind="sent"], .tb-board-chips .g-chip[data-kind="sent"] {
  border-color: var(--danger); color: var(--danger); font-variant-numeric: tabular-nums;
}
.guardian-chips .g-chip[data-kind="sent"][data-warning="1"] {
  background: var(--danger); color: #0b0d12;
}
/* THE FLOOR'S POWER-UP, AND IT IS THE ONE CHIP IN THE ROW ON THE PLAYER'S SIDE
   (JIO-1758). The three above it are what the floor and its Guardian do TO the
   board and they are all the danger red; this one is SOLID in the floor's own
   ink, which is the token applyWorld() writes for the place the board is being
   played in -- so the chip is in the world's style without this rule naming a
   single world. `--left` is the fallback for the one case there is no world:
   the debug panel's jump, which deals a step with no floor dressing on the page.

   ~~SOLID RATHER THAN OUTLINED~~ -- TINTED AND IN ITS FAMILY'S COLOUR SINCE
   JIO-1899, one chip per held power-up with its glyph at the head. A row of
   three solid pills in one shared ink said "three things you hold" and not
   which; the family is the colour the reveal card and the token wear, so the
   chip is the token's name said aloud. It still reads apart from the danger
   chips: those are an outline in red, and this is a FILL -- the held-thing
   carrier the barrage chip uses for its volley in the air. `--world-ink` is
   the fallback for an id with no family.

   A COUNT IS WHAT IS LEFT THIS BOARD (Shield, Second Wind, Spare Rerolls), and
   at 0 the chip dims for the rest of the board; the engine refills it on the
   next one. */
.guardian-chips .g-chip[data-kind="gift"], .gs-chips .g-chip[data-kind="gift"], .tb-board-chips .g-chip[data-kind="gift"] {
  --pu: var(--world-ink, var(--left));
  display: inline-flex; align-items: center; gap: 5px;
  border-color: var(--pu);
  background: color-mix(in srgb, var(--pu) 16%, transparent);
  color: var(--pu);
}
.g-chip[data-kind="gift"] .pu-glyph { flex: none; }
.g-chip[data-kind="gift"] .g-count { font-variant-numeric: tabular-nums; }
.g-chip[data-kind="gift"][data-spent] { opacity: 0.4; }

/* THE TOKEN (JIO-1899): ui/powerUpLook.ts's `powerUpToken()`, the one small
   drawing of a held power-up -- on the map's pill, in the phone's goal row and
   down the sheet. `--pu` is the family's colour, set off the same four tokens
   `.tower-powerup[data-family]` sets the reveal card's off, so a token and its
   card are one colour. The badge is what is left this board, and a token at 0
   dims for the rest of it. */
.pu-token, .g-chip[data-kind="gift"][data-family="board"] { --pu: var(--left); }
.pu-token[data-family="kit"], .g-chip[data-kind="gift"][data-family="kit"] { --pu: var(--right); }
.pu-token[data-family="guard"], .g-chip[data-kind="gift"][data-family="guard"] { --pu: var(--role-move); }
.pu-token[data-family="foe"], .g-chip[data-kind="gift"][data-family="foe"] { --pu: var(--role-ability); }
.pu-token {
  position: relative; flex: none; box-sizing: border-box;
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--pu-size, 24px); height: var(--pu-size, 24px); border-radius: 50%;
  border: 2px solid var(--pu); color: var(--pu);
  background: color-mix(in srgb, var(--pu) 13%, transparent);
}
.pu-token[data-spent] { opacity: 0.35; }
.pu-token .pu-count {
  position: absolute; right: -6px; top: -5px; box-sizing: border-box;
  min-width: 14px; height: 14px; padding: 0 3px; border-radius: 999px;
  border: 1px solid var(--line); background: var(--bg); color: var(--text);
  font-family: var(--hud-font); font-style: normal; font-weight: 800; font-size: 0.625rem; line-height: 12px;
  text-align: center; font-variant-numeric: tabular-nums;
}

/* THE MAP'S PILL (JIO-1899): what the floor you are standing on holds, under
   the floor head, and the door to the sheet. A floor with nothing drawn yet
   has no pill at all rather than an empty one. */
html .app .overlay .panel .menu button.tower-active {
  flex: none; display: inline-flex; align-items: center; justify-content: flex-start; gap: 8px; align-self: flex-start;
  min-height: 44px; padding: 6px 12px 6px 8px; border-radius: 999px;
  border: 1.5px solid var(--line); background: var(--panel); color: var(--text);
  font-family: var(--hud-font); font-weight: 800; font-size: 0.75rem; letter-spacing: 0.04em; cursor: pointer;
}
html .app .overlay .panel .menu button.tower-active .tower-active-more { margin-left: 4px; color: var(--muted); }

/* THE SHEET (JIO-1899): one row per held power-up -- the token, the name, the
   effect sentence and what is left -- then the footer that says how long it
   all lasts. The row is the design's `srow`: a card of its own in the sheet. */
.pu-rows { display: grid; gap: 8px; margin: 0 0 12px; padding: 0; list-style: none; }
.pu-row {
  display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 12px;
  background: color-mix(in srgb, var(--text) 6%, transparent);
}
.pu-row .pu-row-body { display: grid; gap: 2px; min-width: 0; text-align: left; }
.pu-row .pu-name { font-family: var(--display); font-weight: 400; font-size: 0.8125rem; letter-spacing: 0.04em; color: var(--text); }
.pu-row .pu-effect { font-size: 0.75rem; line-height: 1.35; color: var(--muted); }
.pu-row .pu-left {
  margin-left: auto; flex: none; max-width: 9ch; text-align: right;
  font-family: var(--hud-font); font-weight: 800; font-size: 0.6875rem; line-height: 1.2; color: var(--pu, var(--text));
}
.pu-row[data-family="board"] { --pu: var(--left); }
.pu-row[data-family="kit"] { --pu: var(--right); }
.pu-row[data-family="guard"] { --pu: var(--role-move); }
.pu-row[data-family="foe"] { --pu: var(--role-ability); }
.pu-row[data-spent] .pu-left { color: var(--muted); }
.pu-foot { text-align: center; }

/* The floor's rise, as a chip: the one number that says how fast this board
   climbs, and it does not change for the length of the step (SPEC §20). */
.match-info .rise-chip {
  font-size: 0.75rem; font-weight: 700; line-height: 1; padding: 3px 6px;
  border: 1px solid var(--line); border-radius: 999px; color: var(--muted);
}
/* THE VOLLEY-GROWTH CHIP (JIO-1540) IS THE SAME CHIP, in the same slot: from
   2:00 every Versus volley carries an extra locked die and one more a minute
   (SPEC §9, JIO-1536), and the player has to be able to see it. It is one shape
   rather than two because the two can never collide -- the rise chip is a Tower
   floor's and this is Versus's -- so the only rules below are the number and
   the step.

   ONLY THE STEP IS COLOURED. The chip rests in chrome neutral because the
   growth is symmetric -- both boards send more -- and a chip in either side's
   accent would read as that side's; what CHANGED is worse, so the change is
   red and the readout is not. It fills for a second at 2:00 and at every minute
   after, in place: no banner, which is the Shield chip's rule
   (gameplay NOTES.md Revision 4) applied to the one other readout that changes
   mid-match. It does not ramp: `+10` is the same chip as `+1`.

   The fill is `--danger` under #0b0d12, which is the solid the Guardian's own
   sent chip already wears twenty lines above -- one "this is arriving" colour,
   said the same way in both places. */
.match-info .growth-chip b { color: var(--text); font-weight: inherit; }
.match-info .growth-chip.step {
  background: var(--danger); border-color: var(--danger); color: #0b0d12;
  transition: none;
}
.match-info .growth-chip.step b { color: inherit; }
/* Coming OUT of the step is the animated half, and it is a fade of colour and
   not a movement -- so what reduced motion drops is the fade, leaving the fill
   and its return as two steps. The number is untouched either way, which is the
   part a player who turned the animation down still has to be able to read. */
.match-info .growth-chip { transition: background-color 400ms ease, border-color 400ms ease, color 400ms ease; }
.app.reduced-motion .match-info .growth-chip { transition: none; }
.topbar-controls { display: flex; gap: 6px; }
.topbar-controls button, .menu button, .debug button {
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 6px 12px;
  cursor: pointer;
}
.topbar-controls button:hover, .menu button:hover { border-color: var(--muted); }
.topbar-controls button:focus-visible, .menu button:focus-visible, .debug button:focus-visible, .debug input:focus-visible, .debug select:focus-visible {
  outline: 2px solid var(--left);
  outline-offset: 2px;
}
/* THE SPEAKER IS THE CHROME BAR'S GLYPH (JIO-2049): muted-coloured in both
   states, as §4 draws it on every menu page, and the glyph itself says which
   state -- sound-on or sound-off, swapped by ui/hud.ts. ~~The word Sound,
   struck through when off~~ went with the ticket. A square target, 36px here
   and the 44px floor on a coarse pointer (the media query at the foot). */
.topbar-controls .bar-icon { width: 36px; min-width: 36px; padding: 0; display: inline-flex; align-items: center; justify-content: center; color: var(--muted); }
.topbar-controls .bar-icon .glyph { display: block; }
.topbar-controls .bar-icon:hover { color: var(--text); }
.app.compact .topbar-controls .bar-icon { padding: 0; }

/* The touch deck. Tapping the board moves the cursor and swaps (JIO-835); these
   are the actions a finger has nowhere else to go, so they need real controls.
   Since JIO-1072 it is THREE buttons -- Reroll L, Raise, Reroll R -- and it is a row
   under the board in both phone shapes as well as under the boards on a wide
   screen, so layout() counts its height in all of them.

   ~~The two `.deck-group` boxes (JIO-923) are gone with the two buttons they
   existed to separate.~~ They were a box only in the landscape shape, where
   three buttons went along the bottom edge and Ability and Pause stacked up the
   side; the layout ticket moved Ability onto the meter and Pause into the top
   bar, so there is one run of three in every shape and nothing left to group.
   The note is struck rather than deleted because `display: contents` on a
   wrapper is the obvious thing to reach for again the next time a deck grows,
   and the reason it is not needed here is the deck's size. */
.deck {
  grid-column: 1 / -1;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
}
.deck[hidden] { display: none; } /* or the display above outranks the attribute */
.deck button {
  background: var(--panel-2);
  color: var(--text);
  font: inherit;
  font-size: 0.8125rem;
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 6px 12px;
  /* 44px is the smallest control a thumb hits without aiming, and this deck is
     only ever used by a thumb. */
  min-height: 44px;
  min-width: 64px;
  cursor: pointer;
  /* The press belongs to the game: no scroll, no double-tap zoom, no text
     selection, and no 300ms blue flash over a button being held down. */
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}
/* RAISE IS NOT ONE OF THREE EQUAL BUTTONS (JIO-1072, Gameplay NOTES.md Rev 3).
   It wears the raise ROLE colour -- the same `--role-raise` the keyboard diagram
   tints Shift and Space with, so the green means one thing across the whole game
   -- with a 2px border, a 13% fill and an up-chevron over the word. Height and
   width are the phone's and live in the compact block below; the colour is here
   because it is the button's identity in every shape.
   The chevron is CSS and not an inline <svg> so the button's text node stays the
   label alone: TOUCH_LABELS, the README's drawing and the test helper all read
   it, and an <svg> in front of the word breaks every one of them. */
.deck-raise {
  --raise: var(--role-raise);
  border-width: 2px !important;
  border-color: var(--raise) !important;
  background: color-mix(in srgb, var(--raise) 13%, transparent) !important;
  color: var(--raise) !important;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
}
.deck-raise::before {
  content: '';
  width: 10px;
  height: 10px;
  border-top: 2.5px solid currentColor;
  border-left: 2.5px solid currentColor;
  border-radius: 1px;
  transform: rotate(45deg) translate(1px, 1px);
}
/* Held is a state the player has to be able to see: `raise` is a hold, and a
   thumb covers the button it is holding. */
.deck button.held { border-color: var(--left); background: var(--left-dim); color: var(--text); }
.deck-raise.held { background: color-mix(in srgb, var(--raise) 34%, transparent) !important; color: var(--text) !important; }
.deck button:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; }
/* A TOOL A GUARDIAN HAS TAKEN AWAY IS DRAWN, NOT HIDDEN (JIO-1233, canvas §5).
   `noRerolls` strikes both deck keys through and hatches them; `noAbilities`
   hatches the meter, which then never fills because the engine never charges it
   (engine/battle.ts). Present and struck rather than removed, for the reason
   the ticket gives and the reason the deck is five controls in every shape: a
   control that VANISHED would be a deck that re-flows mid-climb, and a player
   who reached for Reroll L would learn nothing about why the board did not move.

   THE HATCH RUNS THE SAME WAY A LOCKED DIE'S DOES (render/renderer.ts draws it
   top-right to bottom-left, and the refusal's bar deliberately runs the other
   way): "this is locked" is one diagonal across the whole game.

   The button is NOT disabled. `engine/board.ts` refuses the reroll by name and
   `render/renderer.ts` draws the refusal where the cursor is (JIO-1203), which
   is the answer to "why did nothing happen" -- and a `disabled` button gives a
   thumb no event at all and drops out of the tab order the touch probe walks. */
.app.no-rerolls .deck-roll,
.app.no-abilities .meter-track {
  background-image: repeating-linear-gradient(
    -45deg,
    color-mix(in srgb, var(--danger) 26%, transparent) 0 4px,
    transparent 4px 9px
  );
}
.app.no-rerolls .deck-roll { border-color: var(--danger); color: var(--muted); text-decoration: line-through; }
.app.no-abilities .meter-track { border-radius: 999px; }
.app.no-abilities .meter-box { border-color: var(--danger); color: var(--muted); }
.app.no-abilities .meter-pct { text-decoration: line-through; }
/* A FLOOR WHOSE KIT NAMES NO ABILITY HAS NO METER (JIO-1435), and it is not
   drawn -- not hatched, which says a Guardian took a tool, but absent, because
   nothing was handed out. `visibility` and not `display` for the reason the
   hatch above is not a removal: the column is what the board is sized around,
   and a meter that left the layout would re-flow the board between two steps. */
.app.no-meter .meter { visibility: hidden; }
/* The Guardian's meter is not the floor's: drawn if it casts, its column gone if not (JIO-1821). */
.app.no-meter.duel:not(.no-cast) .side.right > .meter { visibility: visible; }
.app.compact.no-meter.no-cast .side.right > .meter { display: none; }
.app.compact.no-meter.no-cast .side.right > .board { grid-column: 1 / -1; justify-self: center; }

/* ---- THE FIRST-RUN WALKTHROUGH'S BOARD (JIO-1970) -------------------------

   PLAY! on a first open raises a Survival board with no deck on it: the three
   deck controls arrive one at a time, each on the beat that teaches it
   (ui/firstRunSteps.ts's `reveals`, written onto `.app` by main.ts's
   dressFirstRun()). A board with its whole apparatus on it is the screen the
   eight beats exist to not be -- a first-time player cannot tell the control
   they are being asked for from the four they are not.

   THE METER IS NOT ONE OF THEM (JIO-2015, 2026-10-20). A deck button is a
   control the walkthrough is about to ASK for, so holding it back is the
   lesson; the meter asks for nothing until it fills, and hidden it left the
   board beside a blank strip. decisions/0178 is the argument.

   `visibility` AND NOT `display`, WHICH IS THE WHOLE OF HOW WHAT IS LEFT IS
   WRITTEN, and it is the rule `.app.no-meter` above already follows for the
   Tower's own reason: the deck's row is what the board is SIZED around
   (main.ts's chromeHeight() charges `#deck`), so a control that left the layout
   would re-flow the board -- and here it would do it SEVEN TIMES, once per
   reveal, each one re-sizing every die mid-walkthrough and moving the cell the
   next beat is pointing at. Invisible and in place, the board is the same board
   from the arrival to beat 8.

   The deck itself keeps its row from the first frame for the same reason, so
   `.deck[hidden]`'s `display: none` is not what hides it here. */
.app.first-run .deck button { visibility: hidden; }
/* ...and each one comes back on its own beat. One attribute per control rather
   than a class per control, so the whole reveal state is one write and a beat
   that stopped revealing something cannot leave it on. */
.app.first-run[data-fr-reroll-left] .deck button[data-action="rerollLeft"],
.app.first-run[data-fr-reroll-right] .deck button[data-action="rerollRight"],
.app.first-run[data-fr-raise] .deck button[data-action="raise"] { visibility: visible; }

/* ...AND THE BAR AND THE BACKDROP (JIO-1993). The mark and Pause stay, the
   readouts go whole with `#match-info`, and the House Pips sheet waits for the
   hand-over. `visibility` for the block above's reason -- chromeHeight()
   charges the bar. decisions/0178 is the argument; the fade is cfg's. */
.app.first-run #match-info,
.app.first-run #btn-sound { visibility: hidden; }
.app.first-run[data-fr-handover] #match-info,
.app.first-run[data-fr-handover] #btn-sound { visibility: visible; }
/* ...AND MENU GOES LAST, SO IT SITS AT THE EDGE (JIO-2015, 2026-10-20). The
   speaker is invisible and still charges its width -- the rule above working --
   and Menu (~~Pause~~, JIO-2049) is its FIRST sibling, so it stood in from the
   right of an empty bar. IT MOVES ONCE, AND NOT ON A BEAT: beat 8 brings them back IN
   PLACE, and the order reverts with the class at the END of beat 8
   (main.ts's endFirstRun()). decisions/0178 is the argument. */
.app.first-run #btn-pause { order: 1; }
.app.first-run .world { opacity: 0; }
.app.first-run[data-fr-handover] .world { opacity: 1; transition: opacity var(--fr-sheet-ms, 500ms) ease-in; }
.app.first-run.reduced-motion[data-fr-handover] .world { transition: none; }
/* ...AND THE METER, IN THE BATTLE WALKTHROUGH (JIO-2018): hidden until the act
   that teaches it, by the deck's rule above -- `visibility`, in place, so the
   board is the same board from the arrival to Play on. */

/* THE LIFT (JIO-2018): the opponent window, the meter column or the deck,
   ringed in the beat's colour while the board behind it dims. It is the
   spotlight ring drawn in CSS -- the 3px stroke and the halo -- because these
   three are DOM and the board's rings are bitmap. Over nothing: the dim is on
   the canvas, so a z-index would only fight the enlarged opponent's. */
.fr-lift {
  outline: 3px solid var(--spot, var(--left));
  outline-offset: 4px;
  border-radius: 10px;
  box-shadow: 0 0 0 7px color-mix(in srgb, var(--spot, var(--left)) 28%, transparent), 0 0 28px color-mix(in srgb, var(--spot, var(--left)) 55%, transparent);
  animation: fr-lift-glow 1600ms ease-in-out infinite;
}
@keyframes fr-lift-glow {
  0%, 100% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--spot, var(--left)) 28%, transparent), 0 0 28px color-mix(in srgb, var(--spot, var(--left)) 55%, transparent); }
  50% { box-shadow: 0 0 0 9px color-mix(in srgb, var(--spot, var(--left)) 22%, transparent), 0 0 40px color-mix(in srgb, var(--spot, var(--left)) 70%, transparent); }
}
.app.reduced-motion .fr-lift { animation: none; }

/* ---- THE SPOTLIGHT (JIO-1971) ---------------------------------------------

   Each beat dims the board to the dice that matter and floats one sentence over
   them. The DIM, the bright dice, the rings and the in-board gesture are on the
   bitmap (render/renderer.ts's drawSpotlight) -- a wash behind a canvas cannot
   dim a die, which is JIO-920's lesson and the reason the ability tint moved
   onto the board. What is here is the three drawings that have to leave the
   board's box: the pill above it, the band across it, and the dashed line from
   a deck button up to a die.

   THE LAYER IS BUILT BY ui/hud.ts ON FIRST USE, not written into dicelock.html:
   /play's request and byte gates are measured on a page that has never opened
   the walkthrough, and five elements in the markup would be five elements every
   other board carries for one mode's sake.

   `pointer-events: none` ON ALL OF IT. The line is drawn OVER the deck button
   it starts at, and a layer that ate the tap would be a walkthrough that draws
   the press it then refuses to take. */
/* ---- THE IMPACT LAYER (JIO-2000) ------------------------------------------
   The rings a chain leaves, their words, and the freeze's ring and numeral,
   over the arena: the board's bitmap is clipped to its own box and the ×4 ring
   George picked crosses the board's edge. Pointer-events none, like the
   spotlight; the same z, since neither is ever up with the enlarged opponent.
   The words are Bungee at one weight, white, outlined in the rung's colour. */
.impact {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 30;
  pointer-events: none;
  overflow: visible;
}
.impact[hidden] { display: none; }
.impact circle { fill: none; }
.impact text {
  font-family: "Bungee", Impact, Haettenschweiler, sans-serif;
  font-weight: 400;
  text-anchor: middle;
  dominant-baseline: middle;
  paint-order: stroke fill;
  stroke-linejoin: round;
}

.spotlight {
  position: absolute;
  inset: 0;
  /* Over the board and over the deck, under the enlarged opponent (z 41) and
     its scrim (40) -- neither of which a one-board walkthrough ever raises,
     which is why this is below them rather than argued with them. */
  z-index: 30;
  pointer-events: none;
}
.spotlight[hidden] { display: none; }
.spot-line { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
/* DASHED AND STILL. Every other dashed mark on this board marches (the hint's
   box, the volley's cells) and this one does not: the beat it is drawn on is
   asking for one press, and the only thing moving on the screen is the glow on
   the button it points at. One moving part, one instruction. */
.spot-path { fill: none; stroke: var(--spot, var(--role-reroll)); stroke-width: 3; stroke-dasharray: 7 6; stroke-linecap: round; }
.spot-head { fill: var(--spot, var(--role-reroll)); }

/* THE PILL: the beat's one sentence, in Bungee 18 over the board, with an 8px
   tail standing on the cell it is about. `nowrap` is the drawing's own -- a
   caption that wrapped would be two lines of a different shape on every beat --
   and ui/hud.ts slides the pill inward until it is 12px inside the window, so
   the tail can sit well off its centre and still be on the right die.
   ...AND IT IS NEVER WIDER THAN THE WINDOW (George, 2026-10-20, build 31: the
   Battle walkthrough's longer sentences "slide to the right"). The slide
   cannot bring a pill inside a screen it is wider than, so past 24px short of
   the viewport the sentence wraps rather than leaves: a two-line pill is
   worse than the drawing and better than one the player cannot read. The
   captions themselves are held short (ui/battleRunSteps.ts), so on a phone
   this is the net and not the look. */
.spot-pill {
  position: absolute;
  box-sizing: border-box;
  background: #151922;
  color: #e8ecf3;
  border: 1px solid #2a3242;
  border-radius: 12px;
  padding: 10px 16px;
  font-family: var(--display);
  font-size: 1.125rem;
  line-height: 1.1;
  white-space: nowrap;
  max-width: calc(100vw - 24px);
  text-align: center;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.spot-pill[hidden] { display: none; }
.spot-pill .spot-say { display: block; white-space: normal; text-wrap: balance; }
.spot-pill .spot-note {
  display: block;
  white-space: normal;
  margin-top: 4px;
  font-family: inherit;
  /* 14 AND NOT THE ARTBOARD'S 13, which is FOUNDATION §2's floor beating the
     drawing: "14px is the floor for anything a player reads", and this line is
     read. `.ar-chip` was raised off 12 for the same reason (JIO-1188). The unit
     is rem for that ticket's other half -- a px size is the one line on the
     page that does not move when a reader raises their browser's font size. */
  font-size: 0.875rem;
  color: var(--left);
  /* THE SECOND, SMALLER LABEL IS IN THE PILL and not beside the cells it names,
     which is the one place this departs from the artboards (`A-Chain` draws
     "then these three match" under the three dice it points at). Those cells do
     not exist yet: they are what FALLS once the swap has cleared, so a label
     pinned to them would be pointing at dice that are not there while the
     sentence is being read, and gone by the time they are. */
  font-weight: 700;
}
.spot-pill .spot-note[hidden] { display: none; }
/* ...and the same line carrying a wrong input's answer, in the beat's own
   colour rather than the board's accent (JIO-1972): the hint and the ring it is
   about read as one thing, which is the tutorial's own rule for its hint row
   (design/pages/tutorial/NOTES.md). */
.spot-pill .spot-note.hint { color: var(--spot, var(--left)); }
.spot-pill .spot-tail {
  position: absolute;
  bottom: -8px;
  transform: translateX(-50%);
  border: 8px solid transparent;
  border-bottom: 0;
  border-top-color: #151922;
}
/* ...and the same tail on top, for the one case there is no room above the
   target: a cell in the board's top row on a short phone. */
.spot-pill.under .spot-tail {
  bottom: auto;
  top: -8px;
  border-top: 0;
  border-bottom: 8px solid #151922;
}

/* THE LOCK BEAT TRADES THE PILL FOR THIS. It is the only warning in the eight
   beats and the canvas shouts it: a red strip laid across the board at four
   degrees, the caption in Bungee 27 and the rule under it in the page's own
   text face. A pill would have made it one more polite instruction. */
.spot-band {
  position: absolute;
  box-sizing: border-box;
  transform: rotate(-4deg);
  /* The beat's colour (JIO-2018): the lock beat's red, and an act opener's own
     -- the Battle walkthrough's bands are the act's role, written onto the
     layer as `--spot` with the rest of the beat. */
  background: var(--spot, #ef4444);
  color: #ffffff;
  padding: 18px 12px;
  text-align: center;
  font-family: var(--display);
  /* 27 is the band's own size and is not on FOUNDATION §2's scale, which is a
     deliberate exception rather than a drift: the scale sizes headings on a
     screen, and this is a strip laid across the board at an angle with one
     sentence in it -- h2's 24 leaves the band half empty and h1's 32 wraps it.
     In rem, like every other size in this sheet. */
  font-size: 1.6875rem;
  line-height: 1.05;
  box-shadow: 0 12px 32px rgba(239, 68, 68, 0.45);
}
.spot-band[hidden] { display: none; }
/* TAP TO CONTINUE (JIO-2040): under the band's note, and under a read beat's
   pill, from the moment a tap would count. Small caps in the display face so
   it reads as a control's label and not as more of the sentence. */
.spot-pill .spot-tap, .spot-band .spot-tap {
  display: block; margin-top: 8px;
  font-family: var(--display); font-weight: 400; font-size: 0.6875rem; line-height: 1.1;
  letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.8;
}
.spot-pill .spot-tap[hidden], .spot-band .spot-tap[hidden] { display: none; }
.spot-band .spot-band-note {
  display: block;
  margin-top: 6px;
  font-family: var(--font);
  font-weight: 700;
  font-size: 0.875rem;
  opacity: 0.95;
}

/* THE BUTTON THE BEAT IS ASKING FOR, AND THE ONE IT HAS JUST HANDED OVER. It is
   not `.deck button.tut-lit` two thousand lines down: that one FILLS with the
   role colour and inks the label dark, and this is the artboards' own (`A-Press`)
   -- a role-colour border, a tint of it inside, the label in the colour and a
   glow outside it. Two modes, two looks, never on one screen.

   `!important` ON THE RAISE'S FOUR, because `.deck-raise` declares its own
   border, background and colour that way (its identity in every shape, 1500
   lines up) -- and an `!important` is the one thing a longer selector cannot
   outrank. */
.app.first-run .deck button { position: relative; }
.app.first-run .deck button.fr-lit {
  border-width: 2px !important;
  border-color: var(--spot, var(--left)) !important;
  background: color-mix(in srgb, var(--spot, var(--left)) 22%, transparent) !important;
  color: var(--spot, var(--left)) !important;
  box-shadow:
    0 0 0 4px color-mix(in srgb, var(--spot, var(--left)) 35%, transparent),
    0 0 24px color-mix(in srgb, var(--spot, var(--left)) 45%, transparent);
  animation: fr-glow 1600ms ease-in-out infinite;
}
/* The raise is half a button wider and carries a chevron, and its artboard sits
   a shade heavier than the two rolls: 30% against 22%. */
.app.first-run .deck .deck-raise.fr-lit { background: color-mix(in srgb, var(--spot, var(--left)) 30%, transparent) !important; }
@keyframes fr-glow {
  0%, 100% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--spot, var(--left)) 35%, transparent), 0 0 24px color-mix(in srgb, var(--spot, var(--left)) 45%, transparent); }
  50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--spot, var(--left)) 20%, transparent), 0 0 12px color-mix(in srgb, var(--spot, var(--left)) 25%, transparent); }
}
/* REDUCED MOTION DROPS THE PULSE AND KEEPS THE GLOW, which is this game's rule
   for every breathing thing on the screen: what the drawing says is "press
   this", and that survives being still. Nothing else in the spotlight moves --
   the dashed line does not march and the band does not swing. */
.app.reduced-motion .deck button.fr-lit { animation: none; }
/* NEW, on the beat that hands the button over. A pseudo-element and not markup,
   for the reason the raise's chevron is one (1500 lines up): the button's text
   node has to stay the label alone, because TOUCH_LABELS, the README's drawing
   and the test helper all read it. */
.app.first-run .deck button.fr-new::after {
  content: 'NEW';
  position: absolute;
  top: -12px;
  right: -8px;
  font-family: var(--display);
  /* 14 AND NOT THE ARTBOARD'S 11, for the floor's reason above: NEW is a word
     a player reads, and it is the one word on the screen that says a control
     they have never had is now theirs. */
  font-size: 0.875rem;
  letter-spacing: 0.04em;
  color: #0b0d12;
  background: var(--spot, var(--left));
  padding: 2px 8px;
  border-radius: 999px;
  white-space: nowrap;
}

/* ---- THE WALKTHROUGH'S LEAVE CARD (JIO-1977) ------------------------------

   The card is the pause sheet's own box -- `.overlay .panel`, which is what the
   sheet one press back is drawn in -- so the only thing new here is the row of
   tiles. A TILE IS A SECTION 5 CARD TURNED ON ITS SIDE, declared the way
   `.mode-card` declares the same move: `.menu .card` above is a 64px ROW, and
   this overrides the direction, the padding and the gap rather than opting out
   of the hover state, the focus ring and the transition that come with it.

   THE ART BOX IS FIXED AT THE CANVAS'S 96x68 and is not a ratio: three of them
   plus two 8px gaps and the card's padding is the 312px the drawing is wide in
   a 390 frame, and a percentage box would let one tile differ from its
   neighbours the day a title wraps. `min-width: 0` is what lets three fit.

   14 AND NOT `--t-card`'s 20 ON THE TITLE, the one §5 size this screen argues
   with: three titles at 20 in a 96px column wrap PUZZLES, and the drawing sets
   them on one line. Four classes, because `.app.compact` is two and a shorter
   selector loses to it on a phone -- which is every frame this is drawn in. */
.leave-tiles { display: flex; gap: 8px; }
.menu .card.leave-tile {
  flex: 1 1 0; min-width: 0;
  flex-direction: column; align-items: center; justify-content: flex-start;
  gap: 8px; padding: 10px 6px 12px; min-height: 0;
  text-align: center;
}
.leave-art { display: block; width: 96px; height: 68px; overflow: hidden; border-radius: 10px; }
html .app .overlay .menu .card.leave-tile .card-title { font-size: 0.875rem; line-height: 1.1; }
/* The second line is `.desc`, at --t-small. ~~The drawing's 12~~ is under 14,
   and 14 is THE FLOOR for anything a player reads (the scale at the head of
   this file) -- the NEW badge above took the same correction. */
html .app .overlay .menu .card.leave-tile .desc { text-align: center; }
/* MENU IS MUTED AND CENTRED, which the canvas draws and which says what it is:
   the way out of all three tiles rather than a fourth of them. A pause sheet's
   rows stay left-aligned like every other menu row in the game. */
html .app .overlay .menu button.leave-menu { color: var(--muted); justify-content: center; }

.arena {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  /* The boards, and the touch deck under them. Written out rather than left to
     the implicit rows, because two AUTO rows in a grid taller than its contents
     SHARE the slack: the deck's box then grew and shrank with the boards' row,
     and its buttons stretch to it. Measured 2026-09-08 at 768x1024 with a
     touchscreen, mid-match: the deck went 46px -> 65px tall and its buttons
     moved 20px UP as the "locked incoming" warning appeared -- the same
     wrong-button press JIO-835 fixed in the rail, on the layout nobody had
     re-measured since. 1fr takes the slack for the boards, min-content leaves
     the deck exactly its own height, at the bottom. */
  grid-template-rows: minmax(0, 1fr) min-content;
  gap: 24px;
  padding: 12px calc(24px + env(safe-area-inset-right)) 12px calc(24px + env(safe-area-inset-left));
  min-height: 0;
  align-items: stretch;
  /* THE CONTAINING BLOCK for everything laid over a board. It opened a stacking
     context for the arena-wide tint's `z-index: -1` until JIO-1074 removed that
     layer; what keeps `position: relative` here now is the callout, the effect
     chip and the hit banner, which are absolutely positioned into a board's
     grid area -- and on a phone `.side.left` is `display: contents` and
     generates no box, so this is the nearest positioned ancestor they have. */
  position: relative;
  z-index: 0;
}
/* THE ARENA-WIDE ABILITY TINT IS GONE (JIO-1074). It lived here as an
   `.arena::before` wash in `--ability-tint`, driven by `data-ability` on
   `.app`, and it said which ability the PLAYER could fire (JIO-920). Two
   things retired it, and both are worth writing down because the obvious
   reading of its absence is that somebody forgot to draw it:

   ~~while a roll is ready the arena carries that ability's tint~~ -- the
   READY signal is now carried three times over by the meter beside the board
   (fill and glyph box in the ability's colour, a ring glow and a TAP label)
   and once more in words by the status card's pick row, which is what
   FOUNDATION §6 and NOTES.md Revision 3 moved it to. A fifth copy behind the
   whole page was the one that could not say WHO.

   And that is the second thing: what the ability feedback has to answer is
   whose board an effect is happening to (FOUNDATION §7). A wash behind both
   boards is the one shape that cannot -- a debuff on the opponent and the same
   debuff on you were the same colour behind both. So the colour moved ONTO the
   board that wears the effect, which is the block below. (The debuff that made
   the case, Degrade, was deleted by JIO-1279; the rule is about the surface,
   not about that ability, and Barrage and Lock still land on a board that is
   not the caster's.)

   `.arena` keeps `position: relative` and its stacking context: the callout,
   the chip and the banner are all absolutely positioned into a board's grid
   area and the arena is the containing block for them on a phone, where
   `.side.left` is `display: contents` and generates no box of its own. */

/* ---------------------------------------------------------------------------
   PER-BOARD ABILITY FEEDBACK (JIO-1074, FOUNDATION.md §7, and the Gameplay
   canvas's Stop and Shield artboards)

   Three surfaces, one colour token each, and the id is the contract exactly as
   it was for the tint this replaced: `data-effect` on the canvas and
   `data-ability` on the two panels, mapped to `--ability-*` by the six rules
   at the foot of this block. What a hue means stays the stylesheet's business,
   and a probe or a test reads an id.

   The wash itself is NOT here. A 30% background wash under the dice and a 10%
   overlay over them are drawn on the canvas by render/renderer.ts, because a
   canvas paints over anything a stylesheet puts behind it -- that is the whole
   reason JIO-920 could promise the arena tint would never cost a die a point of
   contrast, and the whole reason this one cannot be done the same way. What
   CSS owns is the part OUTSIDE the bitmap: the 4px outline and its 28px glow.

   THE CHAIN'S RIM IS THE OTHER WAY ROUND, AND ON PURPOSE (JIO-1961). It is
   stroked on the bitmap by render/renderer.ts's drawChainRim, because the rule
   below hands `box-shadow` on this element to `data-effect` and a second owner
   of one property is a rule that silently deletes the other. They are two
   different rims besides: an ability outlines the whole canvas, and a chain
   lights the GRID the dice stand on, inside the board's own pad. */
.board[data-effect] {
  box-shadow: 0 0 0 4px var(--effect-color), 0 0 28px var(--effect-color);
}
/* A Shield eating a volley: the ring swells to 7px, settles back, AND THEN
   GOES OUT (NOTES.md Revision 4, JIO-1075) -- "the outline ring pulses, the
   column markers shatter, then the chip and outline fade as the shield is
   spent". One animation and no second banner: the block is announced by the
   chip that was already there changing its own second half, by the markers
   shattering on the canvas, and by this.

   THE LAST STOP IS THE ONE THIS TICKET ADDED. The charge is gone the instant it
   eats a volley, so `data-effect` comes off the canvas the moment the chip's
   window closes -- and until the fade was written in, that removal was the ring
   VANISHING between two frames, on the one beat a player is looking straight at
   it. `forwards` holds the faded end state for the few frames between the
   animation finishing and hud.ts dropping the attribute, so the two cannot
   disagree; the timing is `--blocked-ms`, which is ui/feedback.ts's BLOCKED_MS
   and is asserted against it by test/abilityFeedback.test.js rather than being
   typed twice.

   Fading a box-shadow to `transparent` is safe rather than muddy: CSS
   interpolates colour premultiplied, so the ring thins out in its own hue
   instead of travelling through black on its way to nothing. */
.board[data-effect].blocked { animation: shield-block var(--blocked-ms) ease-out forwards; }
@keyframes shield-block {
  0% { box-shadow: 0 0 0 7px var(--effect-color), 0 0 34px var(--effect-color); }
  35% { box-shadow: 0 0 0 4px var(--effect-color), 0 0 28px var(--effect-color); }
  100% { box-shadow: 0 0 0 4px transparent, 0 0 28px transparent; }
}

/* THE BOARD'S GRID AREA, SHARED BY THE THREE THINGS LAID OVER IT. The callout
   (JIO-1071), the effect chip and the hit banner are all absolutely positioned
   into whichever cell the board landed in, and `.side` is laid out in five
   different shapes -- two boards, the narrow-desktop band, Endless's one board,
   a phone in portrait and a phone on its side. Writing the placement once
   against `.board-overlay` rather than three times against three class names is
   what stops the next shape teaching two of them and forgetting the third. */
.board-chip, .board-banner {
  position: absolute;
  z-index: 3;
  pointer-events: none;
  /* Aligned to the board and not to the area it sits in. On a phone the board
     is `justify-self: start` inside a column pair wider than it is, so a panel
     centred in the area hangs ~12px off the board's right edge; `--board-w` is
     the board's own measured width, published by syncMeters() in main.ts from
     the same canvas the meter's height comes from, so the two cannot disagree.
     The 16px is the artboards' 8px inset on each side. */
  align-self: start;
  justify-self: start;
  box-sizing: border-box;
  width: calc(var(--board-w, 100%) - 16px);
  margin-left: 8px;
  color: var(--text);
  /* SOLID over an empty board, and the artboards draw it that way. `data-over`
     is written by hud.ts the moment the stack reaches the rows these sit in --
     see the translucent pair below, which is NOTES.md Revision 3's rule. */
  background: color-mix(in srgb, var(--bg) 93%, transparent);
}
.board-chip[hidden], .board-banner[hidden] { display: none; }

/* THE CHAIN'S CHIP, ON THE BOARD'S TOP RIM (JIO-1961). `CHAIN ×n · total` in the
   impact ladder's colour for the newest ring (SPEC §13), from a second wave.
   Placed with the pair above rather than beside them, because it is the same
   problem: absolutely positioned into whichever cell the board landed in, and
   aligned to the board's own measured width instead of to the area, which on a
   phone is wider than the board.

   ON the rim and not inside it: the effect chip starts 8px below the canvas top
   and this one sits where the border is, which is the only place on a board that
   is never a die. `--chain-color` is written by ui/hud.ts from the same resolved
   step the rim on the bitmap is stroked in, so the two cannot be two colours for
   a frame.

   ALWAYS TRANSLUCENT, which is the callout's rule (NOTES.md Revision 3) rather
   than the effect chip's `data-over`: a chain deep enough to be worth a chip is
   often being played to dig out of a stack that HAS reached the top rows, so
   there is no beat where the solid form would be the right one. */
.chain-chip {
  position: absolute;
  z-index: 4;
  pointer-events: none;
  align-self: start;
  justify-self: start;
  box-sizing: border-box;
  margin-left: 8px;
  margin-top: 4px;
  max-width: calc(var(--board-w, 100%) - 16px);
  padding: 3px 10px;
  border-radius: 999px;
  border: 2px solid var(--chain-color, var(--left));
  background: color-mix(in srgb, var(--bg) 62%, transparent);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  color: var(--chain-color, var(--left));
  font-family: var(--display);
  font-size: 0.75rem;
  line-height: 1.4;
  letter-spacing: 0.04em;
  white-space: nowrap;
  animation: chip-in 180ms ease-out;
}
.chain-chip[hidden] { display: none; }
/* AND THE EFFECT CHIP MOVES DOWN FOR IT. Two panels aligned to the same top rim
   is the collision this avoids, and it is one rule with one owner rather than
   each of them measuring the other: `data-chain` is written on the side by
   ui/hud.ts on the frame the chip appears and removed on the frame it goes. */
.side[data-chain] .board-chip { margin-top: 38px; }
/* Reduced motion keeps the chip and the rim and drops the arrival, for the
   reason every other `prefers-reduced-motion` rule in this sheet gives: the
   readout is the point and the movement is not. */
@media (prefers-reduced-motion: reduce) {
  .chain-chip { animation: none; }
}
/* THE NO-HIDING RULE (NOTES.md Revision 3): a panel over dice is translucent
   with a 6px blur and the text stays fully legible; over an empty part of the
   board it may be solid. The ticket's acceptance is "dice remain readable under
   any banner", and a chip is a banner as far as a die underneath it is
   concerned -- so both wear it, even though the four artboards happen to draw
   the chip over a low stack. */
.board-chip[data-over="dice"], .board-banner[data-over="dice"] {
  background: color-mix(in srgb, var(--bg) 60%, transparent);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}
.board-chip {
  margin-top: 8px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 8px 10px;
  border-radius: 10px;
  border: 2px solid var(--effect-color, var(--left));
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
  animation: chip-in 180ms ease-out;
}
/* A SPENT SHIELD'S CHIP READS, THEN FADES (JIO-1075), and `data-blocked` is
   what hud.ts writes on the frame the note changes to BLOCKED n. It holds full
   strength for the first half of the window so the count is READ and not
   glimpsed, and goes out over the second half beside the ring, which is the
   other half of the same sentence in NOTES.md Revision 4. It overrides
   `chip-in` rather than running beside it: the chip is not arriving, it is the
   one that was already there ending. */
.board-chip[data-blocked] { animation: chip-spend var(--blocked-ms) ease-out forwards; }
@keyframes chip-spend {
  0%, 55% { opacity: 1; }
  100% { opacity: 0; }
}
.board-chip .bc-head { display: flex; align-items: center; gap: 8px; }
.board-chip .bc-icon {
  width: 24px;
  height: 24px;
  border-radius: 6px;
  background: var(--effect-color, var(--left));
  color: var(--ink);
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  line-height: 0;
}
.board-chip .bc-icon svg { display: block; width: 16px; height: 16px; }
.board-chip .bc-name { font-family: var(--display); font-size: 0.875rem; line-height: 1; letter-spacing: 0.02em; }
/* The clock, or UNTIL HIT, or BLOCKED 4 -- one slot, because a Shield that has
   just eaten a volley is the same chip saying something else, never a second
   one (NOTES.md Revision 4). */
.board-chip .bc-note { margin-left: auto; font-family: var(--mono); font-size: 0.75rem; font-weight: 700; color: var(--effect-color, var(--left)); }
/* The bar drains over the window the effect was armed with, which is what
   `totalMs` is for. Shield has no window and so has no bar at all: hud.ts
   hides this rather than drawing a full bar that never moves, which would read
   as a countdown that had stopped. */
.board-chip .bc-bar { height: 6px; border-radius: 3px; background: var(--panel-2); overflow: hidden; display: block; }
.board-chip .bc-bar[hidden] { display: none; }
.board-chip .bc-fill { display: block; height: 100%; background: var(--effect-color, var(--left)); }
.board-banner {
  margin-top: 8px;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  border-radius: 8px;
  border: 1px solid var(--effect-color, var(--left));
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
  animation: chip-in 180ms ease-out;
}
/* Under the chip when there is one, at the top of the board when there is not.
   The class is written by hud.ts because the two are siblings and a stylesheet
   cannot ask whether the one above it is on screen; 60px is the chip's own
   height plus its 8px inset, measured off the chip artboards. */
.board-banner.under-chip { margin-top: 60px; }
.board-banner .bb-icon {
  width: 22px;
  height: 22px;
  border-radius: 5px;
  background: var(--effect-color, var(--left));
  color: var(--ink);
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  line-height: 0;
}
.board-banner .bb-icon svg { display: block; width: 14px; height: 14px; }
.board-banner .bb-text { font-family: var(--font); font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.04em; }
@keyframes chip-in {
  from { opacity: 0; transform: translateY(-6px); }
  to { opacity: 1; transform: none; }
}

/* ~~The seven~~ The eight, on all three surfaces at once. Same shape as the meter's mapping
   below and the tint's before it: one token per id in `abilities.list`, and
   test/abilityFeedback.test.js fails on a missing one rather than letting a
   tenth ability colour nothing at all. */
.board[data-effect="cleanse"], .board-chip[data-ability="cleanse"], .board-banner[data-ability="cleanse"] { --effect-color: var(--ability-cleanse); }
.board[data-effect="barrage"], .board-chip[data-ability="barrage"], .board-banner[data-ability="barrage"] { --effect-color: var(--ability-barrage); }
.board[data-effect="shield"], .board-chip[data-ability="shield"], .board-banner[data-ability="shield"] { --effect-color: var(--ability-shield); }
.board[data-effect="erase"], .board-chip[data-ability="erase"], .board-banner[data-ability="erase"] { --effect-color: var(--ability-erase); }
.board[data-effect="lock"], .board-chip[data-ability="lock"], .board-banner[data-ability="lock"] { --effect-color: var(--ability-lock); }
.board[data-effect="stop"], .board-chip[data-ability="stop"], .board-banner[data-ability="stop"] { --effect-color: var(--ability-stop); }
.board[data-effect="hint"], .board-chip[data-ability="hint"], .board-banner[data-ability="hint"] { --effect-color: var(--ability-hint); }
.board[data-effect="fullclear"], .board-chip[data-ability="fullclear"], .board-banner[data-ability="fullclear"] { --effect-color: var(--ability-fullclear); }
.board[data-effect="wild"], .board-chip[data-ability="wild"], .board-banner[data-ability="wild"] { --effect-color: var(--ability-wild); }
.board[data-effect="transmute"], .board-chip[data-ability="transmute"], .board-banner[data-ability="transmute"] { --effect-color: var(--ability-transmute); }
.board[data-effect="frost"], .board-chip[data-ability="frost"], .board-banner[data-ability="frost"] { --effect-color: var(--ability-frost); }
.board[data-effect="scramble"], .board-chip[data-ability="scramble"], .board-banner[data-ability="scramble"] { --effect-color: var(--ability-scramble); }
.board[data-effect="jam"], .board-chip[data-ability="jam"], .board-banner[data-ability="jam"] { --effect-color: var(--ability-jam); }
/* The emergency stop is the seventh board colour and the first that no ability
   deals (JIO-1115). Two surfaces and not three: the danger-zone pause is a
   state the board is IN, so it wears the ring and the wash and carries the
   chip, but nothing ever lands it -- there is no hit to flash and no banner to
   raise, and a `.board-banner` rule here would be a promise nothing keeps. */
.board[data-effect="emergency"], .board-chip[data-ability="emergency"] { --effect-color: var(--rise-emergency); }

/* THREE columns since JIO-1071, and the third is the meter: [status][board]
   [meter] on the left and the mirror of it on the right, which is FOUNDATION
   §6's "two boards, meters on the inner edges". `position: relative` is what
   makes this the callout's containing block -- see .meter-callout above. */
.side {
  display: grid;
  /* The status card's column, NAMED because five rules need the same number:
     both sides here, both again in the narrow-desktop band below, and Endless's
     one-board shape, which has to re-declare the whole track list because the
     `max-width: 900px` band rewrites it for the two-board one. It was the
     literal `200px`/`160px` in each of them until JIO-1073. main.ts's layout()
     still carries its own copy, and says so. */
  --status-col: 200px;
  grid-template-columns: var(--status-col) minmax(0, auto) 44px;
  gap: 12px;
  min-height: 0;
  align-items: start;
  justify-content: end;
  position: relative;
}
.side.right { grid-template-columns: 44px minmax(0, auto) var(--status-col); justify-content: start; }
.side.right .status { order: 3; }
.side.right .board { order: 2; }
.side.right .meter { order: 1; }
/* The board's own grid area, named for everything laid over it -- the callout,
   the effect chip and the hit banner all carry `.board-overlay` (JIO-1074).
   Written as column/row pairs rather than a `grid-area` name because the board
   is placed by auto-flow and an absolutely-positioned item cannot ask where
   that put it. */
.side.left > .board-overlay { grid-column: 2; grid-row: 1; }
.side.right > .board-overlay { grid-column: 2; grid-row: 1; }

.status {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  font-size: 0.8125rem;
}
.status-head { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.char-badge { width: 14px; height: 14px; border-radius: 4px; background: var(--left); transform: rotate(45deg); display: inline-block; }
.side.right .char-badge { background: var(--right); }
.chain-badge {
  margin-left: auto;
  font-family: var(--mono);
  font-size: 0.75rem;
  padding: 2px 6px;
  border-radius: 4px;
  background: var(--panel-2);
  border: 1px solid var(--line);
}

/* THE HORIZONTAL BARS ARE BOTH GONE, and with them the whole .bar family --
   the trough, its fill, the label inside it and the `pulse` keyframe that lit
   it. JIO-1068 took the health bar out (a match is lost by top-out and nothing
   else) and JIO-1071 replaced the "Meter N%" row with the vertical column
   below, so no element in dicelock.html carries `class="bar"` any more. The
   rules are struck rather than left dead: each one carried a comment arguing
   for a control the page does not have, which is the sentence a later reader
   would have taken for a live constraint. */

/* ---------------------------------------------------------------------------
   THE VERTICAL METER (JIO-1071, FOUNDATION.md §6)

   A 22px column the height of the board with a 40px glyph box under it, and it
   is the ability CONTROL on touch as well as the readout. It replaced the
   "Meter N%" row in the status panel, which had two problems the design brief
   names: it was a horizontal bar beside a vertical board, and on a phone its
   own label was `display: none` -- so the whole of what a phone said about the
   meter was a 13px bar and a word in the panel under it (JIO-919).

   Four states, written by hud.ts onto `data-state` from ui/meter.ts:
   empty | filling | full | spent. The attribute is the contract: every rule
   below hangs off it, and a probe or a test reads it rather than inferring the
   state from a colour.

   Colour comes through `--meter-accent`, mapped from `data-ability` the way the
   board's own effect colour is mapped from `data-effect` (JIO-1074). Same nine
   tokens, one indirection, so a tenth ability is a token and a mapping rule in
   ONE place per surface and test/abilityFeedback.test.js fails when either is
   missing. */
/* `align-self: start` and a height in PX, written by layout() from the canvas
   it sits beside (syncMeters in main.ts). Stretching to the grid row is what it
   did first, and on a phone the row is the whole arena -- the board spans three
   rows and the last one is the 1fr that absorbs the slack -- so the column ran
   150px past the bottom of the board it was supposed to be the height of
   (measured 2026-09-11 at 393x852). The canvas is already sized in px by the
   same pass, so this is the one number that cannot disagree with it. */
.meter { display: flex; align-self: start; min-height: 0; }
/* The hit target, and the 44px is on THIS box rather than on the 22px track:
   JIO-836's floor is about the area a thumb lands in, and a bar drawn at the
   width a thumb needs would be a bar drawn for the wrong reason.
   `button` on the player's side, plain `div` on the AI's -- the reset below is
   written for both so the two boxes measure the same. */
.meter-tap {
  width: 44px;
  height: 100%;
  flex: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  -webkit-tap-highlight-color: transparent;
  /* Or a double-tap on the meter zooms the page instead of firing twice. */
  touch-action: manipulation;
}
button.meter-tap { cursor: pointer; }
button.meter-tap:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; border-radius: 12px; }
.meter-track {
  width: 22px;
  flex: 1 1 auto;
  min-height: 44px;
  border-radius: 11px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  /* The fill grows from the BOTTOM. A column that filled downwards would be
     draining, which is what every other bar on this page does when it is losing
     something. */
  justify-content: flex-end;
}
/* `height` and not a transform: the track has a 11px radius and `overflow:
   hidden`, so a scaled child is a rectangle clipped to a capsule -- correct at
   the ends and wrong everywhere between, because scaleY also stretches the
   fill's own rounded cap into an ellipse. */
.meter-fill {
  width: 100%;
  height: 0%;
  border-radius: 11px;
  background: var(--left);
  transition: height 120ms linear, background-color 200ms, box-shadow 200ms;
}
.side.right .meter-fill { background: var(--right); }
.meter-box {
  flex: none;
  width: 40px;
  height: 40px;
  box-sizing: border-box;
  border-radius: 10px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1px;
  color: var(--text);
  transition: background-color 200ms, border-color 200ms, color 200ms;
}
.meter-pct { font-family: var(--mono); font-size: 0.75rem; line-height: 1; }
.meter-icon { display: none; line-height: 0; }
.meter-icon svg { display: block; width: 16px; height: 16px; }
/* The TAP affordance is a COARSE-POINTER rule and nothing else (see the
   (pointer: coarse) block at the foot of this file): on a desktop the meter is
   a readout and F is the control, and a word telling a mouse to tap is an
   instruction for a gesture that screen has no way to make. */
.meter-word { display: none; font-family: var(--display); font-size: 0.625rem; line-height: 1; letter-spacing: 0.04em; }

/* Full: the bar and the box take the ability's colour, the box shows the glyph
   instead of the percentage, and the fill gets the glow FOUNDATION §6 asks for.
   `--meter-accent` falls back to the side's own accent so a config with an
   ability nothing has mapped still lights rather than going transparent. */
.meter:not([data-cells])[data-state="full"] .meter-fill {
  background: var(--meter-accent, var(--left));
  box-shadow: 0 0 18px var(--meter-accent, var(--left));
}
/* THE STACKED COLUMN (2026-10-13, decision 0145; JIO-1438's two cells made the
   default): a two-cell column splits the track in half with a hairline, and
   each cell fills its own half from the bottom. A cell is the side's own colour
   while it fills -- it is not a roll yet -- and takes ITS OWN ability's colour
   and glyph once it holds one, because each cell is a different roll and a
   held cell is a button of its own on touch. The PRIMED cell -- what the key
   fires and the glyph box shows -- wears a 2px `text` ring inside its glow. A
   STUCK cell -- held, but the board gives it nothing to act on -- is hatched
   the way a locked die is (the no-abilities hatch above, the same diagonal):
   "this is locked" is one diagonal across the whole game, and a stuck roll no
   longer blocks the bar, which is the whole point of the second cell. */
.meter[data-cells] .meter-track { position: relative; }
.meter[data-cells="2"] .meter-track::after {
  content: '';
  position: absolute;
  left: 3px;
  right: 3px;
  top: 50%;
  height: 2px;
  margin-top: -1px;
  border-radius: 1px;
  background: var(--line);
  pointer-events: none;
}
.meter[data-cells] .meter-fill { position: relative; background: var(--left); box-shadow: none; }
.side.right .meter[data-cells] .meter-fill { background: var(--right); }
.meter[data-cells] .meter-fill.held {
  background: var(--meter-accent, var(--left));
  box-shadow: 0 0 18px var(--meter-accent, var(--left));
}
.meter[data-cells] .meter-fill.held.primed {
  box-shadow: 0 0 18px var(--meter-accent, var(--left)), inset 0 0 0 2px var(--text);
}
.meter[data-cells] .meter-fill.held.stuck {
  box-shadow: none;
  background-image: repeating-linear-gradient(-45deg, color-mix(in srgb, var(--ink) 28%, transparent) 0 4px, transparent 4px 9px);
}
/* The 16px glyph inside a 22px cell: the smallest the glyph is drawn anywhere,
   in ink on the ability's colour, centred in the cell. Hidden in the phone's
   6px mini strip, where there is no room for a picture. */
.meter[data-cells] .meter-fill svg { position: absolute; left: 50%; top: 50%; width: 16px; height: 16px; margin: -8px 0 0 -8px; color: var(--ink); pointer-events: none; }
.app.compact .side.right .meter-fill svg { display: none; }
.meter[data-state="full"] .meter-box {
  background: var(--meter-accent, var(--left));
  border-color: var(--meter-accent, var(--left));
  color: var(--ink);
}
.meter[data-state="full"] .meter-pct { display: none; }
.meter[data-state="full"] .meter-icon { display: block; }
/* Spent: the box goes flat and the bar drains. It is the one state the engine
   leaves no trace of -- `use()` clears the roll in the same tick it spends the
   charge -- so without the 500ms ui/meter.ts holds, the meter would go from the
   ability's colour to an empty track between two frames. */
.meter[data-state="spent"] .meter-box { opacity: 0.55; }
.meter[data-state="spent"] .meter-pct { color: var(--muted); }
/* Filling is not tappable, so it does not pretend to be: only a full meter's
   box pulses, and only where a press would actually fire (`tappable` in
   ui/meter.ts, which is false for a roll a Shield of your own is refusing). */
.meter[data-state="full"].tappable .meter-box { animation: meter-pulse 900ms ease-in-out infinite alternate; }
@keyframes meter-pulse { from { transform: scale(1); } to { transform: scale(1.08); } }

.meter[data-ability="cleanse"], .meter-callout[data-ability="cleanse"] { --meter-accent: var(--ability-cleanse); }
.meter[data-ability="barrage"], .meter-callout[data-ability="barrage"] { --meter-accent: var(--ability-barrage); }
.meter[data-ability="shield"], .meter-callout[data-ability="shield"] { --meter-accent: var(--ability-shield); }
.meter[data-ability="erase"], .meter-callout[data-ability="erase"] { --meter-accent: var(--ability-erase); }
.meter[data-ability="lock"], .meter-callout[data-ability="lock"] { --meter-accent: var(--ability-lock); }
.meter[data-ability="stop"], .meter-callout[data-ability="stop"] { --meter-accent: var(--ability-stop); }
.meter[data-ability="hint"], .meter-callout[data-ability="hint"] { --meter-accent: var(--ability-hint); }
.meter[data-ability="fullclear"], .meter-callout[data-ability="fullclear"] { --meter-accent: var(--ability-fullclear); }
.meter[data-ability="wild"], .meter-callout[data-ability="wild"] { --meter-accent: var(--ability-wild); }
.meter[data-ability="transmute"], .meter-callout[data-ability="transmute"] { --meter-accent: var(--ability-transmute); }
.meter[data-ability="frost"], .meter-callout[data-ability="frost"] { --meter-accent: var(--ability-frost); }
.meter[data-ability="scramble"], .meter-callout[data-ability="scramble"] { --meter-accent: var(--ability-scramble); }
.meter[data-ability="jam"], .meter-callout[data-ability="jam"] { --meter-accent: var(--ability-jam); }

/* The callout (FOUNDATION §6, and NOTES.md Revision 3's overlay rule): the
   glyph and the bare ability name over the board for ~1500ms when the meter
   fills. ALWAYS TRANSLUCENT -- ability colour at 72% with a 6px blur -- because
   it sits mid-board and the rule George set in review is that nothing the game
   puts over the board may hide a die.

   Absolutely positioned INTO THE BOARD'S GRID AREA rather than over the side as
   a whole: an absolutely-positioned child of a grid container that carries
   grid-placement properties is positioned against that area, so the pill is
   centred on the board in every one of the four shapes .side is laid out in
   without a single measurement in JavaScript -- and it costs the layout
   nothing, which a flow child centred with a negative margin would not. */
.meter-callout {
  position: absolute;
  place-self: center;
  z-index: 3;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 14px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--meter-accent, var(--left)) 72%, transparent);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  color: var(--ink);
  box-shadow: var(--card-shadow);
  pointer-events: none;
  animation: callout-in 180ms ease-out;
}
.meter-callout[hidden] { display: none; }
.meter-callout .mc-name { font-family: var(--display); font-size: var(--t-card); line-height: 1; letter-spacing: 0.02em; }
.meter-callout .mc-icon { display: flex; line-height: 0; }
.meter-callout .mc-icon svg { display: block; width: 24px; height: 24px; }
@keyframes callout-in { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
/* THE ABILITY PICKER (JIO-1439): one button per kit ability, over the TOP of the
   board, which is where the stack is not. Translucent and blurred for the
   callout's rule -- nothing over the board may hide a die -- and every option is
   a 44px target (JIO-836's floor). The option the keyboard and the pad are on
   wears its ability's colour as a ring; an option the board gives nothing to
   act on is dimmed, not removed, so the row never re-flows under a thumb. */
.ability-picker {
  position: absolute;
  place-self: start center;
  margin-top: 12px;
  z-index: 4;
  display: flex;
  gap: 8px;
  padding: 8px;
  border-radius: 16px;
  background: color-mix(in srgb, var(--panel) 72%, transparent);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  box-shadow: var(--card-shadow);
  animation: callout-in 180ms ease-out;
}
.ability-picker[hidden] { display: none; }
.ability-picker .ap-opt {
  min-width: 64px;
  min-height: 44px;
  margin: 0;
  padding: 6px 10px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  border-radius: 12px;
  border: 2px solid transparent;
  background: color-mix(in srgb, var(--meter-accent, var(--left)) 34%, transparent);
  color: var(--text);
  font-family: var(--display);
  font-size: 0.75rem;
  letter-spacing: 0.02em;
  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}
.ability-picker .ap-opt-icon { display: flex; line-height: 0; }
.ability-picker .ap-opt-icon svg { display: block; width: 20px; height: 20px; }
.ability-picker .ap-opt.focused { border-color: var(--meter-accent, var(--left)); box-shadow: 0 0 12px var(--meter-accent, var(--left)); }
.ability-picker .ap-opt[aria-disabled="true"] { opacity: 0.45; }
/* ~~SIX LINES~~ EIGHT LINES, TWO COMPONENTS, for the board group's reason
   above: a selector added to these lines borrows the one map of id to hue,
   where a fourth copy of it would be an ability coloured in three places and
   grey in the fourth. `.tfc-kit` is the floor-cleared screen's card for an
   ability the next floor deals (JIO-1654) -- the same thing the picker's option
   is.
   THAT IS NOT A HYPOTHETICAL: Hint had no line here from JIO-1660 until
   JIO-1661 added its own, so for one ticket's life the seventh ability's picker
   option and its NEW card on the floor-cleared screen fell back to `--left`
   while its meter, its board and its chip were yellow. Nothing failed -- the
   palette sweep in test/abilityFeedback.test.js walks the three surfaces above
   and had never been pointed at these two, which it now is. */
.ap-opt[data-ability="cleanse"], .tfc-kit[data-ability="cleanse"] { --meter-accent: var(--ability-cleanse); }
.ap-opt[data-ability="barrage"], .tfc-kit[data-ability="barrage"] { --meter-accent: var(--ability-barrage); }
.ap-opt[data-ability="shield"], .tfc-kit[data-ability="shield"] { --meter-accent: var(--ability-shield); }
.ap-opt[data-ability="erase"], .tfc-kit[data-ability="erase"] { --meter-accent: var(--ability-erase); }
.ap-opt[data-ability="lock"], .tfc-kit[data-ability="lock"] { --meter-accent: var(--ability-lock); }
.ap-opt[data-ability="stop"], .tfc-kit[data-ability="stop"] { --meter-accent: var(--ability-stop); }
.ap-opt[data-ability="hint"], .tfc-kit[data-ability="hint"] { --meter-accent: var(--ability-hint); }
.ap-opt[data-ability="fullclear"], .tfc-kit[data-ability="fullclear"] { --meter-accent: var(--ability-fullclear); }
.ap-opt[data-ability="wild"], .tfc-kit[data-ability="wild"] { --meter-accent: var(--ability-wild); }
.ap-opt[data-ability="transmute"], .tfc-kit[data-ability="transmute"] { --meter-accent: var(--ability-transmute); }
.ap-opt[data-ability="frost"], .tfc-kit[data-ability="frost"] { --meter-accent: var(--ability-frost); }
.ap-opt[data-ability="scramble"], .tfc-kit[data-ability="scramble"] { --meter-accent: var(--ability-scramble); }
.ap-opt[data-ability="jam"], .tfc-kit[data-ability="jam"] { --meter-accent: var(--ability-jam); }
/* The granted ability, in the player's status panel (JIO-857). It names what
   Use / F / LT will fire and what it costs, which the meter's glyph box says in
   a picture and not in words. Hidden outright while the meter fills since
   JIO-1071 -- see the comment on it in dicelock.html. */
.ability-pick[hidden] { display: none; }
.ability-pick {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 6px;
  font-family: var(--mono);
  font-size: 0.6875rem;
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
}
/* `margin-right: auto` and not the row's own `space-between`: with the glyph in
   front of it the row holds three items, and space-between would push the NAME
   into the middle of the panel away from the icon it belongs to. On a wrapped
   compact line the name is alone and the auto margin does nothing, which is
   what keeps READY at the left edge there. */
.ability-pick .ap-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; margin-right: auto; }
.ability-pick.ready .ap-name { color: var(--left-text); }
/* The AI's card wears the AI's accent (JIO-1073), the way their badge, their
   meter fill and their effect chips already do. READY in teal on the amber side
   would say the roll is yours to fire. */
.side.right .ability-pick.ready .ap-name { color: var(--right-text); }
.ability-pick .ap-cost { flex: none; }
/* The ability's glyph (JIO-924), in front of its name and drawn in the name's
   own colour -- `currentColor` in the <svg> config.ts writes, so READY moves
   the glyph with the word and no second palette exists to drift from the
   first. The colour of WHICH ability this is is already the arena's (the
   --ability-* tints, JIO-920); this glyph carries the SHAPE, which is the half
   of it that survives being 13px on a phone.
   `:empty` is load-bearing: `gap` is charged for a flex item whether it has a
   box or not, so an icon span left empty while the meter fills would hold a
   6px hole in front of "Random next". */
.ability-pick .ap-icon { flex: none; display: inline-flex; align-self: center; color: var(--text); }
.ability-pick .ap-icon:empty { display: none; }
.ability-pick.ready .ap-icon { color: var(--left); }
.side.right .ability-pick.ready .ap-icon { color: var(--right); }
.ability-pick .ap-icon svg { display: block; width: 1.35em; height: 1.35em; }

/* Active timed effects (JIO-862), under the pick. Wraps rather than scrolls:
   a chip clipped to "Sto" is the same failure as a clipped ability name.
   The wrap has to work INSIDE a chip and not only between them: the compact
   rail measures 61px and the widest chip the game ever drew, "Empower ×2 ·
   2.8s", was 83px, so a row that only wrapped between chips overflowed on a
   phone with a single effect running. That ability is gone (JIO-1279) and
   "Stop · 4.2s" fits, which is a reason to keep the wrap rather than to drop
   it: the rail did not get wider. Hence an
   inline-block chip whose text wraps at the spaces around the separator, the
   same answer .ability-pick already gives for the ability name below. */
.effects {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  font-family: var(--mono);
  font-size: 0.625rem;
}
.effects[hidden] { display: none; }
.effects .effect {
  display: inline-block;
  padding: 1px 5px;
  border-radius: 999px;
  border: 1px solid var(--left);
  color: var(--left-text);
  background: color-mix(in srgb, var(--left) 12%, transparent);
  text-align: center;
  line-height: 1.3;
  /* A label with no space in it -- "Stop · 1.4s" -- has only the separator to
     break at, and a longer one would have nothing at all. Breaking mid-word is ugly; overflowing the rail is unreadable. */
  overflow-wrap: anywhere;
}
.effects .ef-sep, .effects .ef-time { color: var(--muted); }
.side.right .effects .effect { border-color: var(--right); color: var(--right-text); background: color-mix(in srgb, var(--right) 12%, transparent); }
/* A DEBUFF IS DRAWN IN THE CASTER'S COLOUR (Jam, JIO-1942), which is what
   `data-source` on the chip is for (ui/hud.ts). The two rules above colour this
   row by the CARD it sits in, and that was right while every effect in the game
   was self-cast: the board wearing one and the board that bought it were the
   same board. A jam runs on the side it was cast at, so the victim's card would
   otherwise draw it in the victim's own accent -- a chip that reads as
   something they did to themselves.
   Same specificity as the two above (three classes and an attribute against
   four classes) and written after them, so order is what decides it: a
   self-cast chip publishes its own side here and lands on exactly the colour it
   already had, which is why this is one pair of rules and not a third state. */
.side .effects .effect[data-source="left"] { border-color: var(--left); color: var(--left-text); background: color-mix(in srgb, var(--left) 12%, transparent); }
.side .effects .effect[data-source="right"] { border-color: var(--right); color: var(--right-text); background: color-mix(in srgb, var(--right) 12%, transparent); }

/* Endless (solo): one board, and the whole of the opponent's side goes with the
   opponent (JIO-1073). This used to empty `.side.right` rule by rule -- their
   status, their board, then their meter and callout when JIO-1071 added them --
   and keep the box, because the Endless score card was a child of it. The card
   is a child of `.side.left` now (see dicelock.html), so what is left here is
   an empty grid box with a gap on each side, and the mode that already had
   nothing to put in that column says so the way puzzle mode does, in one rule.

   It also takes the NEXT component that lands on the opponent's side with it
   for free, which is the half the rule-by-rule form kept getting wrong. */
.solo-panel { display: none; }
/* TWO SELECTORS, AND THE SECOND IS THE ONE THAT DOES THE WORK ON A PHONE. The
   rail block further down puts `display: grid` on the same element, at the same
   (0,4,0) specificity and later in the file, so the plain form here loses to it
   and the opponent's block comes back into an Endless rail -- in the very cell
   the score card is placed in, one drawn over the other. That is what it looked
   like at 375x667 with the plain form alone, inside this ticket and before the
   `.compact` selector was added (measured 2026-09-12) -- and it is why the old
   rule-by-rule version reached for the CHILDREN: each of those carried a class
   more than the rail rule did and won by accident.
   Puzzle mode below has needed the pair since JIO-927 and never had it: nothing
   hid ITS children, so a puzzle on a phone drew an `AI` card and an empty black
   opponent board in the rail, in a mode that has no opponent. Measured at HEAD
   the same day, with the same screenshot. */
.app:is([data-mode="solo"], [data-mode="tower"]) .side.right,
.app.compact:is([data-mode="solo"], [data-mode="tower"]) .side.right { display: none; }
/* ...but a duel's is Versus's corner window on a portrait phone (JIO-1818):
   absolutely positioned, so it costs your board nothing. On `.duel`, never the
   mode; main.ts's oppWindowUp() asks the same gate before rendering it. */
.app.compact.duel[data-mode="tower"]:not(.landscape):not(.opp-hidden) .side.right { display: grid; }
.app[data-mode="solo"] .solo-panel {
  display: grid;
  gap: 10px;
  align-content: start;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 16px;
  max-width: 320px;
}
.solo-title { font-weight: 700; color: var(--right-text); letter-spacing: 0.06em; text-transform: uppercase; font-size: 0.8125rem; }
.solo-stat { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; font-size: 0.875rem; }
.solo-stat .k { color: var(--muted); }
.solo-stat .v { font-family: var(--mono); font-weight: 600; }
.solo-stat .v.big { font-size: 1.75rem; }
.solo-stat .v.beating { color: var(--left-text); }
.solo-hint { color: var(--muted); font-size: 0.75rem; margin: 4px 0 0; }
.stats.score-block .big { font-size: var(--t-h2); }

/* ENDLESS OFF A PHONE (JIO-1073): one board with its meter on the inner edge
   and the score card on the LEFT, under your status panel.

   `:not(.compact)` AND NO MEDIA QUERY. The width at which the rail shape takes
   over is COMPACT_MAX_WIDTH in main.ts -- deliberately not written in this file,
   because layout() sizes the canvas in px and the two have to agree about which
   shape is on screen (JIO-834) -- and `compact` is the class it publishes to say
   so. A `min-width: 701px` here would have been that number written twice, and
   it would have been wrong on a landscape phone, which is 667-932px wide and
   compact anyway. It also makes this one shape for EVERY non-compact width
   rather than two: the status panel goes above the board below 900px because two
   boards cannot each afford a 200px column beside them, and one board can. At
   768x1024 that is the difference between a cell of 48 and a cell of 64.

   Placed explicitly, all five, for the reason the .app rows are: auto-flow puts
   the card in row 2 of a grid whose row 1 is the BOARD, which is 787px down the
   page and off the bottom of it. The board and the meter span both rows and the
   two cards stack in the first column, so the card sits under the status panel
   and beside the dice.

   The arena is ONE track here and not the two equal halves two boards need. Left
   at two, this mode parked the board and the card in the left half and left the
   right half of a 1440px screen empty; centred, the group is where the eye
   already is. main.ts's layout() reads the same fact off `data-mode` -- see the
   `oneBoard` branch there -- so the cell is sized against the width this shape
   really has, and the status panel is not moved above the board either.

   TWO MODES WEAR THIS SHAPE SINCE JIO-1232, and they are written as one `:is()`
   rather than as a second copy of the block: a Tower step is one board with
   nobody on the other side of it, which is what every rule here is about.
   `engine/battle.ts`'s SOLO_MODES is the same fact in the engine and
   `alone(this.app.dataset.mode)` is how layout() reads it, so the three cannot
   drift into disagreeing about which shape is on screen.

   WHAT THE TOWER DOES *NOT* TAKE WITH IT is the score card -- `.solo-panel` is
   `display: none` outside Endless and stays that way. ~~The Tower's readout is
   the target bar in the top bar~~ The Tower has a card of its own in the same
   slot, `.tower-side` below, holding the step's goal and what a Guardian fight
   adds to it; a panel reporting a best, a speed and a clock that a step does
   not have is the argument the puzzle's own missing panel is made of. It is still PLACED by the rule below, because placement is a property
   of the shape and display is a property of the mode; a child of `.side.left`
   that one shape does not place lands wherever auto-flow puts it the day
   somebody shows it. */
.app:not(.compact):is([data-mode="solo"], [data-mode="tower"]) .arena { grid-template-columns: minmax(0, 1fr); }
.app:not(.compact):is([data-mode="solo"], [data-mode="tower"]) .side.left {
  justify-content: center;
  /* Re-declared, not inherited: the `max-width: 900px` band rewrites `.side` to
     the two columns the two-board shape needs there, and this shape keeps three
     at every width it covers. */
  grid-template-columns: var(--status-col) minmax(0, auto) 44px;
  grid-template-rows: min-content minmax(0, 1fr);
}
.app:not(.compact):is([data-mode="solo"], [data-mode="tower"]) .side.left > .status { grid-column: 1; grid-row: 1; }
.app:not(.compact):is([data-mode="solo"], [data-mode="tower"]) .side.left > .solo-panel { grid-column: 1; grid-row: 2; }
.app:not(.compact):is([data-mode="solo"], [data-mode="tower"]) .side.left > .board { grid-column: 2; grid-row: 1 / span 2; }
.app:not(.compact):is([data-mode="solo"], [data-mode="tower"]) .side.left > .meter { grid-column: 3; grid-row: 1 / span 2; }
/* THE TOWER'S READOUTS, BESIDE THE BOARD AND NOT ABOVE IT. Off a phone the
   step's goal, a Guardian's `top` object and the mutation chips are children of
   `.tower-side` -- main.ts's placeTowerReadouts() moves them in -- so none of
   them is a row of the page and the board is the size Endless draws it, in a
   step and in a fight alike (docs/decisions/0038). It is the slot Endless's
   score card has. Empty and `display: none` on a phone, where the three are
   back in the rows dicelock.html writes them in. */
.tower-side { display: none; }
.app:not(.compact)[data-mode="tower"] .side.left > .tower-side {
  grid-column: 1; grid-row: 2;
  display: flex; flex-direction: column; gap: 12px;
  min-width: 0; min-height: 0;
}
/* The goal as the card Endless's score is: the same panel, the same 16px. The
   two words stack because the column is 200px (160 under 1100) and the row's
   one line needed the board's whole width; nothing measures this height any
   more, so it may be two lines. */
.tower-side .step-goal {
  padding: 16px;
  background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
}
.tower-side .step-goal .goal { width: auto; gap: 8px; }
.tower-side .step-goal .goal-line { flex-direction: column; align-items: flex-start; gap: 6px; }
.tower-side .step-goal .goal-want { text-align: left; }
/* No tokens in the column (JIO-1899), so no room kept for them. */
.tower-side .step-goal .goal-line { padding-right: 0; }
/* The object keeps the height the band had and the drawing's own 900x240, so
   it is WIDER than the column: it is pinned to the column's right edge -- the
   board's side -- and leans out over the empty page to the left, as far as
   `--side-x` says there is page (main.ts's syncMeters()). Never narrower than
   the column, where the drawing simply scales down. */
.tower-side .guardian-object[data-place="top"] {
  position: static; inset: auto; z-index: 0;
  /* No taller than a sixth of the viewport, which is the band's old clamp and
     is still what keeps it clear of the chips in a short window. */
  height: clamp(56px, 16dvh, 132px);
  opacity: var(--guardian-object-band, 0.72);
  flex: none; align-self: flex-end;
  aspect-ratio: 900 / 240;
  max-width: max(100%, calc(100% + var(--side-x, 0px) - 24px));
}
.tower-side .guardian-object[data-place="top"] > svg { width: 100%; height: 100%; }
/* The chips from the left edge, wrapping in the column: centred is what a row
   the width of the bar wanted. */
.tower-side .guardian-chips { flex: none; justify-content: flex-start; margin-top: 0; }
/* Over the board, which is now two rows tall: the callout is placed by area and
   an area that stopped at row 1 would put it over the status panel. */
.app:not(.compact):is([data-mode="solo"], [data-mode="tower"]) .side.left > .board-overlay { grid-column: 2; grid-row: 1 / span 2; }

/* Puzzle mode (JIO-927) wears Endless's one-board shape: the opponent's side of
   the arena goes entirely, and the board keeps the column and the status panel
   it has in every other mode. It does NOT take Endless's score panel with it --
   a puzzle has no score and no rise, and a panel reporting numbers that are all
   zero is worse than an empty column. (It has no MATCH clock either -- the
   Daily's own stopwatch is in the row below the board, not up here, and the
   thirty fixed puzzles have none at all: JIO-1088.) The top bar carries
   the budget as text (ui/hud.ts: the clock slot holds `1/2 changes`) and the
   HUD below the board carries it as dots -- one readout in two forms, both read
   off the same Puzzle, so they cannot drift. */
.app[data-mode="puzzle"] .side.right,
.app.compact[data-mode="puzzle"] .side.right { display: none; }
/* The meter and the ability it would roll go with them: a puzzle charges no
   meter (main.ts zeroes the gain, and says why), so a bar that never moves and
   a "Random next" that never rolls are two readouts promising something the
   mode does not have. */
.app[data-mode="puzzle"] .meter, .app[data-mode="puzzle"] .ability-pick { display: none; }
/* And so does the cursor-dice readout, for the same reason rather than for
   room: it reports the risk of rerolling each die and whether the reroll is
   ready, and in a puzzle a reroll takes a printed face, cannot lock, and is
   refused outright on a die with no pip (engine/puzzle.ts). "Low 1/6 · Reroll
   ready" over a die that cannot be rerolled at all is the one kind of readout
   worse than none. */
.app[data-mode="puzzle"] .dice-info { display: none; }

/* ---- A BOARD THAT HOLDS A PUZZLE, WHICHEVER MODE IT IS (JIO-1797) ---------
   `data-mode` is the MODE and this class is the BOARD. A Tower puzzle step is a
   board with a Puzzle laid on it in `tower` mode, so every rule above that is
   about what the board does NOT have is true of it -- plus the Undo/Reset row,
   the one about what it DOES have, which a step was played without because the
   row's `display` keyed on the mode. THE RULES ABOUT A LEVEL STAY ON THE
   ATTRIBUTE, which is why this is a second block: a step wears its FLOOR's
   world, so the ground, the row's tint and the heading colour are a level's.
   main.ts toggles it in `applyPuzzleTheme()`, beside every `this.puzzle`. */
.app.puzzle-board .side.right { display: none; }
.app.puzzle-board .meter, .app.puzzle-board .ability-pick { display: none; }
/* NO RAISE ON A PUZZLE BOARD (JIO-2046, docs/decisions/0214). A held raise does
   nothing on a puzzle -- ui/puzzleRun.ts's raiseAllowed() is false and
   engine/puzzle.ts ignores it -- and the deck still drew it as the biggest of
   its three buttons. The button STAYS IN THE MARKUP (TOUCH_LABELS, the tutorial
   and test/touch.test.js read the deck out of the file); it is not drawn here,
   the two rerolls share the row, and the row is the rerolls' own 48px rather
   than the 56 the raise stood up to. */
.app.puzzle-board .deck .deck-raise { display: none; }
.app.compact.puzzle-board .deck { height: 48px; }
.app.puzzle-board .dice-info { display: none; }
.app.puzzle-board :is(.puzzle-hud, .puzzle-count):not([hidden]) { display: flex; }
.app.compact.puzzle-board .side.left > .cursor-risk { display: none; }

/* ---- the puzzle HUD (JIO-928) --------------------------------------------
   A row under the arena, shown only in puzzle mode. It holds the whole of what
   a puzzle player reads and presses: N dots that fill as changes are spent,
   three buttons, and -- on the Daily alone -- the clock (JIO-1088). No score
   and no meter: a puzzle has neither, and a readout of a number that never
   changes is worse than an empty row. The clock was in that list until
   JIO-1088, and it is out of it because the Daily is the one board everybody
   plays on the same day and so the one that can be ranked. ~~The one note
   levels 5-6 need~~ was in it until JIO-1211, which folded that sentence into
   the first-encounter card (JIO-1210): the row says what this attempt has spent
   and never what the board's furniture means. */
/* FLEX AND WRAPPING, not a three-column grid. Measured 2026-09-08 at 375x667:
   as a grid of `auto minmax(0,1fr) auto` the dots and the three buttons are two
   auto tracks that cannot shrink below their own content, so the note's track
   collapsed to zero width and its one sentence wrapped to a word a line -- a
   212px row, which took the board's cell from 39px to 28. Flex let it wrap to a
   LINE of its own instead of to a column of words, and the row was ~76px at the
   same viewport. The note is gone (JIO-1211) and the wrapping is not:
   what pushes the row past a phone's width now is the state line, which reads
   ~~"Playing the solution -- Pause stops it"~~ "PLAYING THE SOLUTION" while the
   solution plays and ~~"Undo or Reset"~~ "UNDO OR RESET" on a stuck board
   (JIO-1892 put the label UNDER the dots, in a 12px display face), and the row
   wraps to a count above the buttons rather than overflowing (the arithmetic is
   under .app.compact .puzzle-hud). Every pixel of this row is a pixel off a
   13-row board, which is why its height is measured rather than eyeballed. */
.puzzle-hud {
  display: none;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 6px 14px;
  /* THE BOTTOM INSET IS THIS ROW'S (JIO-1908). It is the last row of the page
     whenever it is drawn, so the home indicator sits under it; the arena's own
     inset is taken off below while it is up. */
  padding: 8px calc(16px + env(safe-area-inset-right)) calc(8px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left));
  border-top: 1px solid var(--line);
  background: var(--panel);
}
.app[data-mode="puzzle"] :is(.puzzle-hud, .puzzle-count):not([hidden]) { display: flex; }
/* THE MOVES LEFT'S OWN ROW (JIO-1908): between the bar and the board, centred,
   where the eye already is during play. It is the Tower goal row's slot, and a
   Tower Puzzle step draws no goal row, so the two never stand together. */
.puzzle-count {
  display: none;
  justify-content: center;
  align-items: center;
  padding: 6px calc(16px + env(safe-area-inset-right)) 8px calc(16px + env(safe-area-inset-left));
  border-bottom: 1px solid var(--line);
  background: var(--panel);
}

/* ---- THE LOCKBOX (JIO-2041) --------------------------------------------------
   A puzzle board is a lockbox (George chose the look on 2026-10-20 over a
   tray and a blueprint). The moves-left row and the arena are ONE BODY -- an
   amber frame round both, the row's corners above and the arena's below -- the
   dials in the row are the combination, and the shackle stands over the row.
   `data-lock` on the row is the state: set, jammed, unlocked, shown. The
   Tower's puzzle steps are not this: they are Tower boards under a goal row. */
.app[data-mode="puzzle"] .puzzle-count:not([hidden]) {
  position: relative;
  margin: 64px 12px 0;
  border: 3px solid var(--right);
  border-bottom: 0;
  border-radius: 16px 16px 0 0;
  background: var(--panel);
  transition: border-color 200ms, box-shadow 300ms;
}
/* ON A DESKTOP THE BODY HUGS THE COLUMN, not the window: a lock 1,400px wide
   round a 280px board is a frame and not a lock. 720 is §4's shell column. */
.app:not(.compact)[data-mode="puzzle"] :is(.puzzle-count:not([hidden]), .arena) { width: min(100% - 24px, 720px); margin-left: auto; margin-right: auto; box-sizing: border-box; }
.app[data-mode="puzzle"] .arena {
  margin: 0 12px 8px;
  border: 3px solid var(--right);
  border-top: 0;
  border-radius: 0 0 16px 16px;
  background: var(--panel);
  transition: border-color 200ms, box-shadow 300ms;
}
/* THE SHACKLE: a U of two straight legs and a rounded top, never a half
   circle -- 84 tall on a 50 radius leaves 34px of leg, and the legs run 12px
   into the body. It pivots on its left leg to open and rattles when jammed. */
.lock-shackle {
  position: absolute; left: 50%; top: -72px; z-index: 2;
  width: 120px; height: 84px; margin-left: -60px;
  box-sizing: border-box;
  border: 12px solid var(--right); border-bottom: 0;
  border-radius: 50px 50px 0 0;
  transform-origin: 12px 100%;
  transition: transform 300ms ease-out, border-color 200ms;
  pointer-events: none;
}
.app:not([data-mode="puzzle"]) .lock-shackle { display: none; }
/* SET: the dials are the combination. A dial is a 28px ring in amber with its
   number until its move is made, and teal and solid once it is. */
.app[data-mode="puzzle"] .ph-dots { counter-reset: dial; gap: 8px; }
.app[data-mode="puzzle"] .ph-dots i {
  counter-increment: dial;
  width: 28px; height: 28px; border-radius: 50%;
  border: 3px solid var(--right); background: transparent; color: var(--right-text);
  display: inline-flex; align-items: center; justify-content: center;
  font-family: var(--display); font-weight: 400; font-size: 0.75rem; line-height: 1;
  transition: background-color 120ms, border-color 120ms;
}
.app[data-mode="puzzle"] .ph-dots i::after { content: counter(dial); }
.app[data-mode="puzzle"] .ph-dots i.set { background: var(--left); border-color: var(--left); color: var(--ink); }
.app[data-mode="puzzle"] .puzzle-count[data-left="1"] .ph-dots i.on { background: transparent; border-color: var(--right); }
/* JAMMED: every dial set and dice still standing. The last dial goes red, the
   body's edge and the shackle go red, the shackle rattles, and the two
   buttons that get the player out light up. This replaces the hollow red dots. */
.app[data-mode="puzzle"] .puzzle-count.stuck .ph-dots i { border-color: var(--left); background: var(--left); color: var(--ink); }
.app[data-mode="puzzle"] .puzzle-count.stuck .ph-dots i.bad { border-color: var(--danger); background: var(--danger); }
.app[data-mode="puzzle"] .puzzle-count[data-lock="jammed"],
.app[data-mode="puzzle"]:has(.puzzle-count[data-lock="jammed"]) .arena { border-color: var(--danger); }
.puzzle-count[data-lock="jammed"] .lock-shackle { border-color: var(--danger); animation: lock-rattle 120ms ease-in-out 2; }
@keyframes lock-rattle {
  0%, 100% { transform: translateX(0) rotate(0); }
  25% { transform: translateX(3px) rotate(3deg); }
  75% { transform: translateX(-3px) rotate(-3deg); }
}
.app[data-mode="puzzle"]:has(.puzzle-count[data-lock="jammed"]) .puzzle-hud button:not([disabled]) { border-color: var(--left); color: var(--left-text); }
/* UNLOCKED: the shackle swings open on its left leg, the body goes teal and
   glows, the row says UNLOCKED. main.ts holds the poster for LOCK_OPEN_MS. */
.app[data-mode="puzzle"] .puzzle-count[data-lock="unlocked"],
.app[data-mode="puzzle"]:has(.puzzle-count[data-lock="unlocked"]) .arena { border-color: var(--left); box-shadow: 0 0 44px rgba(45, 212, 191, 0.35); }
/* The swing is small -- 8px up and 14 degrees on the left leg -- because the
   row stands 64px under the top bar and a shackle that swung further would go
   under it (the first build's -32 degrees did, measured on a phone). */
.puzzle-count[data-lock="unlocked"] .lock-shackle { border-color: var(--left); transform: translate(0, -8px) rotate(-14deg); }
.puzzle-count[data-lock="unlocked"] .ph-state { color: var(--left-text); }
/* SHOWN: the solution plays in grey -- the dials it sets, the body and the
   shackle -- and the clear pays one star, which the poster says. */
.app[data-mode="puzzle"] .puzzle-count[data-lock="shown"],
.app[data-mode="puzzle"]:has(.puzzle-count[data-lock="shown"]) .arena { border-color: var(--muted); }
.puzzle-count[data-lock="shown"] .lock-shackle { border-color: var(--muted); }
.app[data-mode="puzzle"] .puzzle-count[data-lock="shown"] .ph-dots i.set { background: var(--muted); border-color: var(--muted); }
.app.reduced-motion .lock-shackle { transition: none; animation: none; }
/* THE POSTER'S ART: the open lock, drawn in CSS on the puzzle field -- the
   body and the swung shackle -- so the result reads as the thing just opened. */
.overlay .poster[data-field="puzzle"] .poster-field { position: relative; }
.overlay .poster[data-field="puzzle"] .poster-field::before {
  content: ''; position: absolute; right: 24px; top: 112px;
  width: 54px; height: 40px; border-radius: 8px;
  border: 3px solid var(--left); background: var(--panel);
  box-shadow: 0 0 24px rgba(45, 212, 191, 0.35);
}
.overlay .poster[data-field="puzzle"] .poster-field::after {
  content: ''; position: absolute; right: 28px; top: 76px;
  width: 46px; height: 40px; box-sizing: border-box;
  border: 7px solid var(--left); border-bottom: 0; border-radius: 20px 20px 0 0;
  transform-origin: 7px 100%; transform: translate(-4px, -8px) rotate(-32deg);
}

/* ---- THE TOWER WALKTHROUGH (JIO-2066): the Spotlight on the stair ----------
   ~~One screen per beat: the act and its pips, a drawing, the caption~~
   (JIO-2044). It is the real stair screen at a scripted climb with ONE layer
   laid over it by ui/towerRun.ts, rebuilt per beat from measured boxes: the
   wash is one SVG path with the chrome row, the plaque and the lifted controls
   cut out of it, the die and the door are a second copy of the stair's own
   <svg> over the wash, and the pill and the band are the shipped `.spot-pill`
   and `.spot-band` above. In the overlay and not in the panel, over the sheet
   scrim (z 1) and the panel's own tab bar (z 4), because a beat is laid over a
   sheet and over a poster as well as over the stair. */
.overlay .spotlight.tower-spot { position: absolute; inset: 0; z-index: 5; pointer-events: none; opacity: 1; transition: opacity 250ms linear; }
.app.reduced-motion .overlay .spotlight.tower-spot { transition: none; }
.tower-spot-wash { position: absolute; left: 0; top: 0; display: block; overflow: visible; }
/* The 78% of #0a0c10 every walkthrough's wash is (docs/decisions/0186). */
.tower-spot-wash path { fill: #0a0c10; fill-opacity: 0.78; }
.tower-spot-ring, .tower-spot-glow { fill: none; stroke: var(--spot, var(--left)); }
.tower-spot-glow { stroke-opacity: 0.35; }
.tower-spot-map { position: absolute; pointer-events: none; }
.tower-spot-map .tower-stair { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: visible; }
/* A held beat's tap lands here, on the pill or on the band -- and nowhere
   else, so the chrome row's back arrow is still the way to the pause sheet. */
.tower-spot-catch { position: absolute; pointer-events: auto; cursor: pointer; }
.tower-spot .tower-spot-press { pointer-events: auto; cursor: pointer; }
/* The band is the act's colour with the page's ink on it, as the stair
   storyboard draws all four; the arena's band is white on red. */
.tower-spot .spot-band { color: var(--ink); border-radius: 6px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); }
.tower-spot .spot-band .spot-tap { opacity: 0.75; }
/* The pill's second line wears the beat's colour -- amber under the Guardian's
   door and the rebuff, red under TOPPED OUT -- as the storyboard draws it;
   the arena's pill keeps its teal. */
.tower-spot .spot-pill .spot-note { color: var(--spot, var(--left)); }
/* The act and its pips, where the FLOORS pill stands outside the walkthrough. */
.tower-run-place { display: inline-flex; align-items: center; gap: 10px; height: 36px; }
.tower-run-act { font-family: var(--display); font-weight: 400; font-size: 0.75rem; letter-spacing: 0.08em; color: var(--text-2); white-space: nowrap; }
.tower-run-pips { display: inline-flex; gap: 6px; }
.tower-run-pips i { width: 8px; height: 8px; border-radius: 50%; background: var(--line); display: inline-block; }
.tower-run-pips i.on { background: var(--left); }
/* While the walkthrough is up the tab bar and the ? are drawn and not
   pressable: the only ways out are the pause sheet's. */
.app[data-tower-run] .overlay .panel > .tabbar, .app[data-tower-run] .overlay .panel > .chrome-bar .chrome-help { pointer-events: none; }
/* ...and there is no board under it (docs/decisions/0212), so the arena, the
   in-game bar and the deck that TOPPED OUT, the card and the poster are drawn
   over in play are not drawn here: an empty board and a COMPUTER window with
   nothing in it, showing through a card about losing, read as a board. */
.app[data-tower-run] .topbar, .app[data-tower-run] .arena, .app[data-tower-run] .footer { visibility: hidden; }

/* ---- the replay transport (JIO-992) ---------------------------------------
   The row under the arena while a kept run is watched back: the run's name and
   its clock, the scrubber, and the four controls. A ROW and not a panel over
   the board, the same call the puzzle HUD was built on (JIO-928): it is pressed
   as well as read, and what it is about is the board it would otherwise cover.

   It is a column and not a wrapping flex row, unlike that one, because the
   scrubber has to be the full width of the page at every viewport -- a slider
   sharing a line with four buttons on a 375px phone is a 90px slider over a
   three-minute run, which is about a second of run per pixel. main.ts's
   chromeHeight() measures this row, so the boards are sized with it on screen.

   `[data-state="replay"]` and not a mode: a replay is a STATE the whole app is
   in -- there is no battle, no input reaches a board, and the deck and the two
   match buttons in the top bar are hidden below for the same reason. The mode
   is still set beside it, because how the arena lays out (one board and a rail,
   or two) is a fact about the run being replayed. */
.replay-hud {
  display: none;
  flex-direction: column;
  gap: 6px;
  padding: 8px calc(16px + env(safe-area-inset-right)) calc(8px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left));
  border-top: 1px solid var(--line);
  background: var(--panel);
}
.app[data-state="replay"] .replay-hud:not([hidden]) { display: flex; }
.rp-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px 12px; flex-wrap: wrap; }
.rp-name { font-size: 0.8125rem; font-weight: 600; }
.rp-clock { font-family: var(--mono); font-size: var(--t-mono); color: var(--muted); }
/* The full width, and 44px tall on every pointer rather than only a coarse one.
   scripts/probe-touch.ts measures every <input> on the page against the 44px
   floor and a slider does not get a thinner target for being a slider -- and
   the height here is the whole hit area, since the track is a few pixels of it
   and the rest is the box around it. */
.rp-scrub { width: 100%; height: 44px; margin: 0; accent-color: var(--left); cursor: pointer; }
.rp-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
/* EQUAL SHARES OF THE ROW, NOT EACH BUTTON'S OWN WORD (JIO-1481, decision
   0044). Two of these four labels change while the row is on screen -- the play
   button reads Play, Pause or Replay and the speed reads 0.5x to 4x (ui/hud.ts)
   -- so `flex: 1 1 auto` re-laid the row out under the thumb that pressed it:
   Leave went 94px at x=281 to 86px at x=288 over the three play labels,
   measured at 390x844 on 2026-09-29. A basis of 0 makes each column the row's
   width over four, the same in every state. The old 72px floor is gone with it,
   because four of those and three 8px gaps is 312 of a 320px phone's 288 and
   Leave wrapped to a second row there, full width. `flex-wrap` stays as the way
   out below 320, which is narrower than any device ever shipped. */
.rp-buttons button { flex: 1 1 0; min-width: 0; min-height: 44px; padding: 10px 12px; font-size: 0.875rem; }
/* ...and the small phone takes the room off the padding, as `.ph-buttons` does
   and at the width `.deck` steps down at (JIO-1322). "Replay" is the longest
   label this row can show -- 47px of 14px Inter, 71px of button with 12px
   either side -- and the 320px column is 66px, so 12px wraps it to two lines
   inside the box and 6px (59px) does not. */
@media (max-width: 359px) {
  .rp-buttons button { padding: 10px 6px; }
}
.rp-note { margin: 0; font-size: 0.75rem; color: var(--warn, var(--muted)); }
/* Nothing that plays the game is on screen during a replay. The deck's three
   buttons act on a board that is not there, and Pause and Restart act on a
   battle that has been dropped -- a control that does nothing is the quiet kind
   of dead button this game does not ship. Hidden, they measure 0, so
   chromeHeight() and layout() size the boards for the row that IS there.

   `#deck` AND NOT `.deck`, WHICH IS WHY THIS WAS ONLY TRUE ON A DESKTOP
   (JIO-1481). `.app.compact .deck` -- the phone's own deck, 900 lines below --
   ties this on specificity at three classes each and comes later in the file,
   so it won, and every phone watched a replay with REROLL L, RAISE and REROLL R
   sitting over the board: measured 2026-09-29 at 390x844 and 320x568, a 56px
   row of live-looking controls that queue an action onto a battle the viewer
   dropped. An id outranks any of them, and it is what the two buttons beside it
   were already written with. */
.app[data-state="replay"] #deck,
.app[data-state="replay"] #btn-pause,
.app[data-state="replay"] #btn-restart { display: none; }
/* ---- the level's theme (JIO-929) -----------------------------------------
   Six levels, six backgrounds, and NOTHING that carries information moves
   between them: no die face, no pip, no cursor, no gray hatch, no reroll pip,
   no --role-* colour. What a theme may touch is the ground behind the board,
   this row, the level's row on the Puzzles screen and the level's own name in
   the top bar -- a puzzle that read differently on level 6 than on level 1
   would be a puzzle whose difficulty is partly the paint.

   main.ts writes `--puzzle-*` onto `.app` when a puzzle loads and REMOVES them
   when one is left, so every rule below falls back to exactly what it painted
   before this ticket. Written as a gradient of one colour rather than as
   `background-color`, for the same reason the ability tint is: the row already
   has `background: var(--panel)` and this is a tint LAID OVER it, so the theme
   supplies an alpha and the panel underneath keeps the row a panel. An unset
   var resolves to `transparent` and paints nothing at all.

   No reduced-motion rule here because nothing in this block moves. The one
   animated part of a theme is the Abyss's and the Forge's glow, which is drawn
   on the canvas -- render/renderer.ts's paintGround() holds it still under
   reduced motion, and the Observatory's star field is static for everyone. */
.app[data-mode="puzzle"] :is(.puzzle-hud, .puzzle-count) {
  background-image: linear-gradient(var(--puzzle-panel, transparent), var(--puzzle-panel, transparent));
}
/* The theme's name, which is the one heading in the game that names a level.
   `#info-difficulty` reads "Puzzle 3-2 · Greenhouse" in puzzle mode; the pace
   and the clock beside it stay --muted, because those are readouts. */
.app[data-mode="puzzle"] .match-info #info-difficulty { color: var(--puzzle-heading, var(--muted)); }
/* THE FLOOR AND THE STEP, IN THE WORLD'S HEADING COLOUR (JIO-1232) -- the same
   rule as the puzzle's line above and for the same reason: `#info-difficulty` is
   the slot that names WHERE you are, and in the Tower that is "FLOOR 1 · STEP 3"
   on the floor's own world. `--world-ink` is the token applyWorld() writes
   (ui/world.ts), so the heading follows the place with no second copy of the
   colour; `--left` is the fallback for the one frame before the world lands.
   The pace and the separators go: a Tower step has no clock to be timed or
   untimed against, and a readout of a mode the board is not in is worse than an
   empty slot (the argument the puzzle HUD's own missing rows are made of). */
.app[data-mode="tower"] .match-info #info-difficulty {
  color: var(--world-ink, var(--left)); font-family: var(--display); font-size: 0.8125rem; letter-spacing: 0.04em;
}
.app[data-mode="tower"] .match-info #info-pace,
.app[data-mode="tower"] .match-info .sep { display: none; }
/* ...and both of them SURVIVE THE COMPACT BAR, which is the one place this
   differs from every other mode. The phone bar hides difficulty and pace because
   they are chosen on the title screen and never change during a match; the floor
   and the rise are chosen by the LADDER and change from step to step, and the
   Tower is played on a phone. */
.app.compact[data-mode="tower"] .match-info #info-difficulty { display: inline; }
/* ...except during a Guardian fight, when the header is the emblem, the name
   and the health bar (canvas §5) and "FLOOR 3 · THE OVERGROWTH" beside them
   would name the Guardian twice and wrap the bar onto a second line. hud.ts
   sets `hidden` for the fight; this is the rule that lets `hidden` beat the
   `display: inline` above. */
.app.compact[data-mode="tower"] .match-info #info-difficulty[hidden] { display: none; }
/* A PUZZLE'S NUMBER SURVIVES IT TOO (George, 2026-10-12: "each puzzle should
   indicate the puzzle number on the screen with the board"). The phone bar
   showed only the moves count, which the row under the board already shows, so
   a played board never said which of the eighty it was. Here the name takes the
   bar and the count is left to the row: "Puzzle 2-3 · Vault", or the Daily's
   date and world, ellipsised rather than wrapped, so the bar stays one line. */
.app.compact[data-mode="puzzle"] .match-info #info-difficulty {
  display: inline; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.app.compact[data-mode="puzzle"] .match-info #info-clock { display: none; }
/* The canvas's own CSS background, which is what shows in the frame or two
   between a resize reallocating the backing store and the next render. Plain
   --board-bg there under a Greenhouse board is a flash of the page's own board
   colour; the ground's top stop is not. The renderer paints over this every
   frame either way -- and a level's ground is the same six gradients on both
   themes, because a level is a PLACE and not a lighting preference. */
.app[data-mode="puzzle"] canvas.board { background: var(--puzzle-ground-top, var(--board-bg)); }
/* THE ROW LEADS WITH THE MOVES LEFT (JIO-1892, docs/decisions/0155): a 28px
   numeral in the display face, then a column of the dots over their label.
   `--left-text` and not `--left` on the numeral for the reason the clock below
   gives -- a run of text on a light panel takes the `-text` value; in dark the
   two are one colour. */
.ph-count { display: flex; align-items: center; gap: 10px; }
.ph-left { font-family: var(--display); font-weight: 400; font-size: 2.25rem; line-height: 1; color: var(--left-text); min-width: 1ch; text-align: center; }
.ph-stack { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }
.ph-dots { display: flex; gap: 6px; }
/* A dot per move ~~change, filled as it is spent~~ LEFT, filled while it is
   still to be made and hollow once spent (JIO-1892). 14px is a mark read at
   arm's length rather than a control: nothing here is tapped, so the 44px
   thumb floor that binds the buttons does not bind these. */
.ph-dots i {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 2px solid var(--line);
  display: inline-block;
}
.ph-dots i.on { background: var(--left); border-color: var(--left); }
/* ONE MOVE LEFT IS AMBER, the colour a bound about to fire wears everywhere
   else in this game (JIO-1474's float, renderer.ts). The numeral, the one
   filled dot and the label go together, keyed on the `data-left` ui/hud.ts
   writes. */
.puzzle-count[data-left="1"] .ph-left { color: var(--right-text); }
.puzzle-count[data-left="1"] .ph-dots i.on { background: var(--right); border-color: var(--right); }
.puzzle-count[data-left="1"] .ph-state { color: var(--right-text); }
/* Out of changes with dice still standing. Every dot goes red and hollow --
   there is nothing left to fill -- because what is red is the budget rather
   than one change of it, the numeral reads 0 in the danger colour, and
   .ph-state says the two things that get the player out of it. */
.puzzle-count.stuck .ph-left { color: var(--danger-text); }
.puzzle-count.stuck .ph-dots i { border-color: var(--danger); background: transparent; }
/* THE DAILY'S CLOCK (JIO-1088), beside the dots, mono 18 in teal --
   design/pages/puzzles/NOTES.md's "Daily HUD with timer".

   `--left-text` AND NOT `--left`, which is the same colour in dark and a
   different one in light. FOUNDATION section 1's rule: on white `left` is
   3.3:1, so a bar, a border, a die, a MARK and display type keep the bright
   accent and anything carrying a RUN OF TEXT takes `-text`. The filled dots
   beside this are marks and keep `--left`; an 18px readout a player reads a
   time off is text, and 18px is nowhere near the >= 24px the 3:1 band is for.
   Computed 2026-09-15: #0a736a on light `panel` is 5.72:1 against #0f9f92's
   3.28:1, and in dark both tokens are #2dd4bf at 9.45:1, so the artboard's
   teal is unchanged where the artboard is drawn.
   Hidden for the thirty fixed puzzles, which are not timed -- AND FOR THE
   DAILY SINCE JIO-1138: the run is still timed and ranked, but George,
   2026-09-16, "puzzles in general shouldn't show a time count down as it puts
   too much pressure on the player", so ui/hud.ts writes the `hidden` attribute
   on every board. The rule below is kept for the element, which stays in the
   row; the row measures the same as it always did on one of the thirty and
   chromeHeight() keeps sizing the board the way it did. The 76px row and the
   2px of cell measured below are what the readout cost while it was shown.

   `tabular-nums` and a `min-width` in ch: without both, a clock ticking in
   tenths reflows the row ten times a second as the digits change width, which
   on a phone is the dots beside it visibly jittering. 6ch holds "0:00.0"
   through "9:59.9" and the readout simply grows past that -- a run longer than
   ten minutes is rare and a clock that stopped counting would be a lie.

   WHAT IT COSTS THE BOARD ON A PHONE, measured by scripts/probe-daily.ts at
   390x844 on 2026-09-15: the row goes from 51px to 76px and the board's cell
   from 46px to 44px. The clock is ~66px of mono 18 and the row was already
   within 15px of its width (see the 375px sizing note below), so on a phone the
   Daily's row wraps to two lines -- dots, clock and state above, the three
   buttons under them. That is 2px of cell, against a COMPACT_CELL_FLOOR of 28,
   and it is the price of the one number the screen is about. */
.ph-timer {
  font-family: var(--mono);
  font-size: var(--t-mono-lg);
  font-variant-numeric: tabular-nums;
  color: var(--left-text);
  min-width: 6ch;
}
.ph-timer[hidden] { display: none; }
/* A DISQUALIFIED DAY SHOWS A DASH, not a frozen number. Solve keeps no time and
   posts none, so a number left on screen would be a time the player could read
   off a board they will not appear on. Muted rather than red: nothing has gone
   wrong, the run simply no longer counts. */
.ph-timer.void { color: var(--muted); }
/* The label under the dots (JIO-1892): the display face at 12px, letter-spaced
   as every small Bungee label in this chrome is, saying what the numeral is. */
.ph-state { font-family: var(--display); font-weight: 400; font-size: 0.75rem; line-height: 1.1; letter-spacing: 0.04em; color: var(--muted); }
.puzzle-count.stuck .ph-state { color: var(--danger-text); }
/* `.ph-note` was here -- the reroll sentence, which took the slack between the
   count and the buttons on a wide screen and a line of its own on a phone. It
   is the first-encounter card's copy since JIO-1211, so the slack was the
   ~~`margin-left: auto` below~~ count's alone -- and since JIO-1908 the count
   has a row of its own above the board, so the buttons are the whole row and
   sit in its centre. */
/* THE ROW WEARS THE DECK'S FACE (JIO-2047, George 2026-10-20: "Reset, Undo and
   Hint should be more prominent buttons in the puzzle view"). Undo, Reset and
   Hint are the three controls a puzzle player presses most, and they were the
   smallest on the screen: three word-buttons in the top bar's 6px/12px padding
   under a deck of 48px display-face buttons. Now they ARE deck buttons -- the
   same 48px, the same face, the same radius, sharing the row -- capped at 420px
   on a wide screen so three buttons do not stretch across a desktop. */
.ph-buttons { display: flex; gap: 8px; width: 100%; max-width: 420px; }
.ph-buttons button {
  flex: 1 1 0;
  min-width: 0;
  height: 48px;
  min-height: 48px;
  padding: 0 8px;
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: 10px;
  font-family: var(--display); font-weight: 400; font-size: 0.9375rem; letter-spacing: 0.02em;
  text-transform: uppercase;
  cursor: pointer;
}
/* `display` above outranks the UA's `[hidden]`, the trap `.deck[hidden]` names --
   and it is the trap the Daily fell into before this ticket: ui/hud.ts set
   `hidden` on the Hint (docs/decisions/0141) and `.ph-hint`'s own
   `display: inline-flex` drew it anyway. One rule for every button in the row. */
.ph-buttons button[hidden] { display: none; }
.ph-buttons button svg { flex: none; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.ph-buttons button:hover:not(:disabled), .ph-buttons button:focus-visible { border-color: var(--left); }
.ph-buttons button:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; }
.ph-buttons button:disabled { opacity: 0.4; cursor: default; }
.ph-buttons button.held { border-color: var(--left); background: var(--left-dim); }
/* "WHAT'S ON THIS BOARD" (JIO-1425): the fourth control, and a SQUARE rather
   than a fourth word-width button -- the row had 21px of slack at 375px and a
   spelled-out label wants ninety (the arithmetic is below, where the other
   three lost 4px each to pay for this one). The accessible name is on the
   element, because a glyph is not a label. */
.ph-what { flex: 0 0 44px; width: 44px; min-width: 44px; padding: 0; border-radius: 50%; font-size: 1.0625rem; }
/* THE PLAY GLYPH SAYS THE PRICE BEFORE THE PRESS (docs/decisions/0141): a Hint
   is an ad first, and the button is the one place that is said -- and since
   JIO-2047 it wears the amber of a thing that costs, which the other two are
   not. */
.ph-buttons .ph-hint { border-color: var(--right); color: var(--right-text); background: color-mix(in srgb, var(--right) 12%, transparent); }
.ph-hint svg path { fill: currentColor; }

/* The phone shape: the count and the FOUR buttons on one line. Every number
   below was MEASURED in a browser at 375x812 on 2026-09-27 (JIO-1425); the sum
   that stood here was added up, and wrong twice over -- it used the 16px padding
   of the rule this one overrides, and called "Undo or Reset" the longest state
   line when `updatePuzzle()` also writes "Playing the solution -- Pause stops
   it". THE COUNT IS A DIFFERENT SHAPE SINCE JIO-1892 -- a 24px numeral, an 8px
   gap, then the dots OVER a 12px "MOVES LEFT" -- so the 121.6 below is the old
   row's and the new one is narrower at rest (the label no longer sits beside
   the dots) and NOT re-measured in a browser by that ticket; the buttons'
   numbers stand. Room is 355 (375 less 10px either side). ~~The count is 121.6
   at rest -- three dots 54, an 8px gap, "3/3 spent" 59.6.~~ The four buttons are 212.3 --
   the 44px `?`, Undo 52, Reset 52.3, Solve 52, three 4px gaps. One line needs
   343.9 of the 355.

   THE THREE CAME DOWN FROM 56px TO 52 TO PAY FOR THE FOURTH: at 56 the row
   needed 355.6 and wrapped in the ORDINARY state, the one a player looks at for
   a whole board. 52 is under all three natural label widths (50.5, 52.3, 51.1).

   STUCK AND SOLVING STILL WRAP AND THAT IS THE ANSWER. ~~"Undo or Reset" needs
   370.4 and the solution playing needs 541~~ (the old row's numbers; since
   JIO-1892 both labels sit under the dots at 12px and the stack is what sets
   the count's width), the second of which wrapped before
   this ticket too. Measured at the floor -- 44px buttons, 3px gaps -- stuck
   still needs 363.1, so no width this row can be given fits it, and a second
   line is the right way to be over: it wraps rather than overflowing
   (`flex-wrap` above; `scrollWidth - clientWidth` was 0 in every case), at 18px
   off a 13-row board in a state the player is already stopped in. */
.app.compact .puzzle-hud { padding: 6px calc(10px + env(safe-area-inset-right)) calc(6px + env(safe-area-inset-bottom)) calc(10px + env(safe-area-inset-left)); gap: 4px 10px; }
.app.compact .ph-count { gap: 8px; }
.app.compact .ph-left { font-size: 2rem; }
.app.compact .ph-state { font-size: 0.6875rem; }
/* THE PHONE'S ROW IS THE WHOLE WIDTH (JIO-2047): the three share it as the deck's
   buttons share theirs, 6px apart, at the deck's compact size. ~~52px minimums
   and 10px/8px padding~~ were the word-button row's arithmetic; a flex row of
   48px buttons needs none. scripts/probe-puzzle-solve.ts still measures every
   one against the 44px floor. */
.app.compact .ph-buttons { gap: 6px; max-width: none; }
.app.compact .ph-buttons button { font-size: 0.875rem; }
/* ...except the `?`, which is a glyph and does not need a word's width: 44px is
   the coarse-pointer floor (the media query at the foot of this file). */
.app.compact .ph-buttons .ph-what { flex: 0 0 44px; }

/* ~~Tutorial banner, its hint row, GOT IT, the fork and the spoiled-board card~~
   -- the eighteen-step lesson's, deleted with it (JIO-2021). */
/* ---- THE FORFEIT COUNTDOWN OF A SHARED MATCH (JIO-1162) -------------------
   ~~The same card as `.tutorial-spoiled`~~ (gone with the lesson, JIO-2021):
   "the game has stopped and here is why", and the shape the first-encounter
   card below copies.

   What it adds is the NUMBER, which is the whole point of it -- a countdown a
   player has to infer from a draining bar is a bar. The bar is still here under
   it, because the seconds tick once and the bar moves every frame: between 7 and
   6 the bar is what says the wait is still running rather than stuck. */
.match-hold {
  position: fixed;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: min(420px, calc(100vw - 64px));
  z-index: 18;
  padding: 16px;
  border-radius: 14px;
  background: var(--panel);
  border: 1px solid var(--line);
  box-shadow: var(--card-shadow);
  display: grid;
  gap: 10px;
  text-align: center;
}
.match-hold[hidden] { display: none; }
.mh-title { font-family: var(--display); font-weight: 400; font-size: 1.125rem; line-height: 1.05; text-transform: uppercase; }
.mh-count { font-family: var(--display); font-weight: 400; font-size: 2.75rem; line-height: 1; color: var(--left); }
.mh-text { font-size: 0.9375rem; line-height: 1.45; color: var(--muted); }
.mh-bar { height: 4px; border-radius: 2px; background: var(--panel-2); overflow: hidden; }
.mh-fill { display: block; height: 100%; width: 0; background: var(--left); }
/* The boards dim under the card: neither of them is moving, and a board at full contrast under a card
   saying the match has stopped is two things asking to be read. */
.app.match-held .arena { opacity: 0.45; filter: saturate(0.6); }

/* ---- THE FIRST-ENCOUNTER CARD (JIO-1210) ---------------------------------
   The second card on this page with this box, after `.match-hold` (and
   ~~`.tutorial-spoiled`~~, gone with the lesson, JIO-2021), and deliberately the
   same one: both are "the board has
   stopped and here is why", none of them is ever on screen with another, and a
   third shape for the same job is a third thing to keep in step. What it adds
   is the LIST -- one row per piece of furniture this board holds and this
   browser has never been shown -- and the button that ends it.

   It scrolls rather than growing. No frozen board draws more than two rows
   (measured 2026-09-17: 35 of the eighty draw one, 12 draw two, 33 draw none),
   but the DAILY is cut from the whole ladder and a hand-written board can spell
   four of the five pieces at once -- and four rows plus the heading and a 52px
   button is taller than the short side of a 375x667 phone in landscape. A card
   that runs off the bottom takes GOT IT with it, and GOT IT is the way back to
   the board. */
.first-encounter {
  position: fixed;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: min(420px, calc(100vw - 64px));
  /* The vh line is the fallback for a browser with no dvh, exactly as `.app`'s
     own height is written at the top of this file. */
  max-height: calc(100vh - 48px);
  max-height: calc(100dvh - 48px);
  overflow-y: auto;
  z-index: 18;
  padding: 16px;
  border-radius: 14px;
  background: var(--panel);
  border: 1px solid var(--line);
  box-shadow: var(--card-shadow);
  display: grid;
  gap: 12px;
}
.first-encounter[hidden] { display: none; }
.fe-title { font-family: var(--display); font-weight: 400; font-size: 1.125rem; line-height: 1.05; text-transform: uppercase; }
.fe-list { display: grid; gap: 12px; }
.fe-item { display: grid; gap: 6px; }
/* THE STRIP (JIO-2050): the mark, a plus, the action in a word, an arrow, the
   result -- the dice are ui/furnitureMarks.ts's SVGs of the renderer's own
   drawing. The pill is the display face in the accent, the dials are the
   moves row's own 26px rings. It wraps on a narrow card rather than shrinking
   the dice: a 48px die is the size the eye has just learnt on the board. */
.fe-strip { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.fe-strip .fe-die { display: block; flex: none; }
.fe-act { font-family: var(--display); font-weight: 400; font-size: 0.6875rem; letter-spacing: 0.06em; color: var(--left); border: 1.5px solid var(--left); border-radius: 999px; padding: 5px 10px; white-space: nowrap; }
.fe-plus { font-weight: 700; font-size: 1rem; color: var(--muted); }
.fe-arrow { display: inline-flex; color: var(--muted); }
.fe-dials { display: inline-flex; gap: 6px; }
.fe-dial { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--right); box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; font-family: var(--display); font-style: normal; font-size: 0.6875rem; color: var(--right-text); }
.fe-dial.set { background: var(--left); border-color: var(--left); color: var(--ink); }
.fe-break { flex-basis: 100%; height: 0; }
/* The phone's card is ~318px inside its padding: a 40px die, a 6px gap and a
   smaller pill put MARK + ACTION -> RESULT on one line (measured 2026-10-20
   at 390px; at 48px the result die wrapped onto a line of its own). */
.app.compact .fe-strip { gap: 6px; }
.app.compact .fe-strip .fe-die { width: 40px; height: 40px; }
.app.compact .fe-act { font-size: 0.625rem; padding: 4px 8px; }
.app.compact .fe-dial { width: 24px; height: 24px; }
/* The piece's NAME in the accent, its sentence in body text: two rows of the
   same weight would read as one paragraph with a full stop in the middle, and
   the whole point of the list is that a board holding two pieces is two things
   to learn rather than one longer sentence. */
.fe-item-title { font-size: 0.8125rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--left); }
.fe-item-copy { font-size: 0.9375rem; line-height: 1.45; color: var(--text); }
/* The same button the lesson's passive step had (~~`.tutorial-gotit`~~, gone
   with it, JIO-2021), down to the height: "I have read this". */
.fe-gotit {
  margin-top: 4px; height: 52px; width: 100%;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid var(--left); border-radius: 10px;
  background: var(--left); color: #0b0d12;
  font-family: var(--display); font-weight: 400; font-size: 1.125rem;
  text-transform: uppercase; cursor: pointer;
}
.fe-gotit:not(:disabled):hover { filter: brightness(1.06); }
.fe-gotit:not(:disabled):active { transform: translateY(1px); }
.fe-gotit:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
/* THE SECOND THE BUTTON REFUSES (JIO-1426). The same 0.4 and the same cursor as
   `.ph-buttons button:disabled` further up this file, because it is the same
   sentence: this control is here and is not yours yet. It is the WHOLE of what
   the card says about the delay -- no countdown, no second line -- which is why
   the two rules above are narrowed to a live button instead: a press that travels
   and brightens under a thumb, on a button that will not answer, is the card
   claiming to have been pressed. It snaps to full rather than fading, or §4 would
   owe the one control whose point is that it has just become pressable a
   reduced-motion variant saying it arrives some other way. */
.fe-gotit:disabled { opacity: 0.4; cursor: default; }
/* §4's rule, and the reason it is written twice here: EVERY pressed control has
   a reduced-motion variant, and `.tutorial-gotit` was shipped without one --
   found while copying it (JIO-1210). What the variant drops is the travel; the
   hover brightness stays, because a control that stops answering the pointer at
   all is a control somebody asked for less motion and got less feedback. */
.app.reduced-motion .fe-gotit:active { transform: none; }

/* THE PAINT BESIDE THE ATTRIBUTE (ui/screens.ts, and the first-visit prompt's
   note further down this file): main.ts puts `inert` on the top bar, the arena
   and the puzzle row while the card is up, and opacity says nothing about what
   can be pressed -- so the two are one decision written in the two places it
   has to be written. All three and not just the arena, because unlike the two
   cards above this one the thing being taken away is not only the board: Undo,
   Reset, Solve, Pause and Restart are all one press from changing the board the
   card is talking about. */
.app.first-encounter-up .topbar,
.app.first-encounter-up .arena,
.app.first-encounter-up .puzzle-hud,
.app.first-encounter-up .puzzle-count { opacity: 0.45; filter: saturate(0.6); }

/* ~~THE TUTORIAL'S PAUSE CARD (JIO-1093)~~ and ~~THE CONTROL THE STEP IS ASKING
   FOR, LIT (`tut-lit`)~~ -- the lesson's, deleted with it (JIO-2021). The
   walkthroughs light a control with `fr-lit` (the `.app.first-run` block). */

/* THE INCOMING PANEL IS GONE (JIO-1070). `.incoming`, its count, its warning
   bar and the .noroom / .shielded colours on them were a readout of how many
   and how long, on a rail beside the board, and none of it said which columns.
   All four states are drawn on the canvas now, one marker per die at the top of
   the column that die lands in -- see render/renderer.ts's
   drawIncomingMarkers(). Nothing on this side of the app has an incoming rule
   left, which is why there is a comment here and not a ruleset. */

.dice-info { display: grid; gap: 6px; font-size: 0.75rem; }
/* THE PANEL IS HIDDEN WHEN THE AID IS OFF (JIO-1496), and the attribute needs a
   rule of its own because the `display: grid` above outranks the UA's
   `[hidden] { display: none }` -- the same trap `.cursor-risk[hidden]` at the
   foot of this file carries the note for. ui/hud.ts sets the attribute; the two
   readouts of one fact go off together. */
.dice-info[hidden] { display: none !important; }
.dice-info .die-line { display: grid; grid-template-columns: 34px 1fr; gap: 6px; align-items: center; padding: 4px 6px; background: var(--panel-2); border-radius: 6px; border: 1px solid var(--line); }
.dice-info .die-line .tag { font-family: var(--mono); color: var(--muted); }
.dice-info .risk { font-weight: 600; }
.dice-info .risk-meter { display: inline-flex; gap: 2px; margin-left: 6px; vertical-align: middle; }
.dice-info .risk-meter i { width: 6px; height: 8px; background: var(--line); border-radius: 1px; display: inline-block; }
.dice-info .risk-meter i.on { background: var(--text); }
.dice-info .sub { color: var(--muted); }

canvas.board {
  background: var(--board-bg);
  border-radius: 10px;
  border: 1px solid var(--line);
  display: block;
  /* No max-height here. The size is set in px by layout() in main.ts, which
     fits it to the viewport; a CSS bound would squash the fixed-size backing
     store against a width the script had already chosen, and the board would
     render stretched (JIO-833). */
  touch-action: none;
}

.footer {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  padding: 6px calc(16px + env(safe-area-inset-right)) calc(6px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left));
  border-top: 1px solid var(--line);
  color: var(--muted);
  font-size: 0.75rem;
  background: var(--panel);
  overflow: hidden;
}
.footer .hint { font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }

/* AND IT IS NOT ON THE FIRST SCREEN AT ALL (JIO-986). `data-state` is `title`
   from the moment the page loads until a countdown starts -- the title screen
   and every menu reached from it -- so what a visitor arriving at
   dicelockgame.com/play or jiolab.dev/dicelock met along the bottom of it was
   the whole control table in mono, before they had chosen a mode. Teaching the
   controls is Help's job (the title screen's own "How to play", and the top
   bar's Help), and this line's job is reminding a player MID-MATCH, which is
   the only state it is read in -- the same argument the compact layout already
   drops it on below.
   `.hint` and not `.footer`: the input-method readout beside it keeps holding
   the row's 28px, so chromeHeight() in main.ts measures the same footer on
   every state and the board is not sized against a bar that comes and goes.
   `puzzles` is here for the same reason and not as an extra: it is the puzzle
   LIST, another menu with no match under it (a puzzle being played is
   `playing`), and it was the one screen reachable from the title that set a
   `data-state` of its own -- left out, the strip would blink back on the way
   to the list and off again on the way into a puzzle. `paused` and `result`
   are deliberately NOT here: both sit over a match the player is already in,
   which is the state the line is written for.
   `start`, `howto`, `records` and `settings` are the Play menu's four submenus
   (JIO-1078, JIO-1079) and are here for exactly the reason `puzzles` is. They
   are also what the screens BEYOND them inherit: `difficulty`, `endless`,
   `scores`, `history`, `keybinds` and `help` still write no `data-state` of
   their own, so a Vs Computer picker opened from Start is read as `start` --
   which is the answer this rule wants either way. Left out, the strip would
   blink back on the way into every one of them, and on `keybinds` it would be
   a second, shorter copy of the table filling the screen. */
.app[data-state="title"] .footer .hint,
.app[data-state="start"] .footer .hint,
.app[data-state="howto"] .footer .hint,
.app[data-state="records"] .footer .hint,
.app[data-state="settings"] .footer .hint,
.app[data-state="puzzles"] .footer .hint,
/* A walkthrough's completion screen is a menu screen too (JIO-1094; the
   lesson's own went with it in JIO-2021, the Puzzles walkthrough's poster
   keeps the state), and the strip would name the keys that play the game under
   a screen that has just finished teaching them. */
.app[data-state="tutorialDone"] .footer .hint,
/* ...and not over a replay either (JIO-992): the strip names the keys that
   play the game, and none of them reaches the board being watched back. The
   keys that DO reach it are named on the transport row's own buttons. */
.app[data-state="replay"] .footer .hint { display: none; }

/* Overlay screens */
.overlay {
  position: fixed;
  inset: 0;
  display: none;
  align-items: center;
  justify-content: center;
  /* The scrim dims the SCREEN BEHIND the panel, so it is dark on both themes
     and mixed from the page's own bg rather than pinned to one palette: a
     light overlay over a light page is a panel with no edge (JIO-1061). */
  background: color-mix(in srgb, #06080c 82%, transparent);
  z-index: 20;
  /* The padding is the panel's bound: with max-height 100% below, the panel can
     never reach outside the safe area, whatever the screen. */
  padding: calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(12px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));
  /* EVERY MENU SCREEN IS INSIDE THIS ELEMENT, which is why the menu faces are
     set here and not on the body: the body is the game, and the game keeps
     --hud-font (JIO-1062). One declaration moves the title, its three
     submenus, both pause menus, the opponent and speed pickers, High scores,
     History, Puzzles, Help and every result screen onto Nunito Sans at once,
     and the display face is then asked for by name on the runs that are
     display. */
  font-family: var(--font);
  font-size: var(--t-body);
  line-height: 1.5;
}
.overlay.show { display: flex; }
.overlay .panel {
  width: min(680px, 100%);
  max-height: 100%;
  overflow: auto;
  /* `none` AND NOT `contain` (George, 2026-10-12): `contain` stops the scroll
     chaining to the page and keeps the rubber band at the ends of this box,
     and on a phone that is the pull-past-the-top gesture of an app that has
     nothing to refresh. `none` keeps the chain stopped and drops the bounce. */
  overscroll-behavior: none;
  /* NO SCROLLBAR ON A MENU (George, 2026-10-12). This is a phone app's menu:
     every screen is a column a thumb flicks, and the bar a desktop draws down
     the right edge of the panel -- and the indicator a phone draws over it --
     is the one piece of the chrome the artboards never drew. The content still
     scrolls, the cut-off row at the bottom edge is the cue that it does, and
     the keyboard and the pad never used the bar. Both spellings, because
     WebKit and Blink read the pseudo and Firefox and Safari 18 read the
     property. */
  scrollbar-width: none;
  user-select: text;
  background: var(--panel);
  /* THE SURFACE A ROW SITS ON, named once because it MOVES (JIO-1081). A
     scoreboard row and a History row are cards inside the screen, and what
     they have to lift off is whatever is behind them: the floating panel's own
     --panel on a desktop, the page's --bg on a phone, where the panel is now
     full-bleed. Both artboards draw the row as #151922 on #0c0e12, which is
     --panel on --bg -- and reading that pair straight off the artboard is how
     `.score-row` came to be `background: var(--panel)` INSIDE a --panel panel,
     i.e. five rows distinguishable from the screen behind them only by their
     1px border, on the desktop the artboard says nothing about (found
     2026-09-14). One property, set where the surface is decided. */
  --card: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: 28px 32px;
}
.overlay .panel::-webkit-scrollbar { display: none; }
/* THE HEAD OF A MENU STAYS PUT WHILE THE BODY SCROLLS (George, 2026-10-12).
   Row 1 -- the back arrow and the speaker -- and the screen's own h2 are
   `sticky` to the top of the panel, which is the scroller: a Settings tab or
   the Rules page scrolled to its foot kept no way back on the screen and no
   speaker to reach, and on a phone that is the title of the page gone the
   moment a thumb moves. `sticky` and not a second fixed box, because at a
   scroll of 0 nothing moves -- every artboard measures where it did -- and a
   screen shorter than its panel never engages it at all. The poster screens
   lift the bar into the field's corner with `position: absolute` further down
   and win on specificity; the Tower's panel is `overflow: hidden` with its own
   scroller inside, so neither rule has anything to stick to there.

   ~~BOTH CARRY THE PANEL'S OWN GROUND, AT 94% OVER A BLUR~~ -- NEITHER PAINTS
   ANYTHING; THE BAND UNDER THEM IS ONE FROSTED PANE THAT APPEARS ON SCROLL
   (George, 2026-10-15: "the header, back button and the mute button all have
   a transparent black background that is obvious when you scroll down the
   page"). The bar and the h2 each painted `--head-ground` at 94% as their own
   box, with the 20px column gap between them left open -- so a scrolled Rules
   or Puzzles page showed a lit box round the arrow and the speaker, a strip of
   rows sliding through the gap, and a second box round the title, all three of
   them a flat colour over a page whose ground is a world's gradient and
   texture (measured on a 390x844 phone, Workshop worn, 2026-10-15). A flat
   paint can never match a textured ground, so nothing here is painted flat:

   ONE PSEUDO-ELEMENT, ON THE h2, IS THE WHOLE BAND. `h2::before` reaches up
   from the title through the gap, the 44px bar and the panel's top padding to
   the frame's edge, and 28px below the title's own box, full-bleed across the
   panel (`-50vw` either side, clipped by the panel's `overflow-x: hidden` in
   the frame block). It is a blur with a 70% wash of `--head-ground` over it,
   masked to fade out over its last 28px, so it has no bottom edge and no side
   edges: rows passing under the arrow, the speaker and the title are smeared
   into the ground rather than hidden behind a box. One element and not three
   because `backdrop-filter` samples its own box and mirrors at its edges --
   two blurred boxes meeting show a seam where one does not. It is the h2's
   and not the bar's so its height is the title's own: the h2 is `position:
   sticky`, which is a containing block, so `top` and `bottom` are measured
   from the title wherever it is pinned, and nothing has to know how tall a
   Bungee line is at either width. `z-index: -1` puts it under the title's
   text inside the h2's own stacking context and over every row beneath; the
   bar is one step above the h2 so the arrow and the speaker paint over the
   band too.

   IT IS INVISIBLE AT A SCROLL OF 0. `opacity: 0` until the panel carries
   `data-scrolled`, which ui/screens.ts's show() writes off the panel's own
   scroll event -- so at rest the head is the page, exactly as every artboard
   draws it, and the frosted band fades in the moment a row moves under it.
   Scroll-driven animations would do this with no script and are not in
   iOS 17's WebKit, which the iPhone app has to run on.

   THE WASH IS `--head-ground`, NOT `inherit` (George, 2026-10-12, the Start
   page): with a world worn the panel is TRANSPARENT (the `html[data-behind]`
   rule in the frame block), so a band that inherited it would carry no tint
   and the mode cards' scenes would read through the title with only the blur
   between. The property is set where the panel's own ground is decided --
   `--panel` on the floating desktop panel, the world wash on a full-bleed one,
   `--bg` on a pinned palette.

   NOT THE TOWER MAP (George, 2026-10-12, the same afternoon). Its panel is
   `overflow: hidden` with the staircase scrolling inside it, its title is
   OUT OF FLOW across the chrome bar (the rule in the Tower block, which wins
   on specificity for `position` but not for the offsets), and the wall behind
   it is the floor's own ground. Every rule of this head is scoped off that
   one screen; the Tower keeps the head it drew. NOR A WORLD'S OWN PAGE: the
   level page's rule in the puzzles block drops the band, for the reason
   written there.

   AND IT PINS WHERE IT RESTS (George, 2026-10-12, the Start page: "the
   Start header should not move when I scroll"). A stuck element is measured
   from the scrollport's CONTENT edge, under the panel's padding -- so `top:
   0` is the bar's own resting place and it never moved. The title did: `top:
   44px` put it flush under the bar, 20px above where it rests, so START slid
   up on the first flick and stopped. Its offset is now the resting one -- the
   44px bar plus the panel column's gap (`--menu-gap`, written beside the gap
   at each width; the bar's own margin is zeroed on a menu panel) -- and
   nothing on the head moves. Measured: bar at y=16 and title at y=80 on a
   390x844 phone, 24 and 92 at 1440x900, at a scroll of 0 and scrolled. */
.overlay .panel { --head-ground: var(--panel); }
.overlay .panel.menu-panel:not([data-screen="tower"]) > .chrome-bar { position: sticky; top: 0; z-index: 4; }
.overlay .panel.menu-panel:not([data-screen="tower"]) > h2 { position: sticky; top: calc(44px + var(--menu-gap, 20px)); z-index: 3; }

/* ---- THE MENU'S TAB BAR (JIO-1966) ---------------------------------------

   Five targets along the foot of every menu page: the mode last played,
   MODES, DIE, RECORDS, SETTINGS. It replaced the main menu's four cards, so
   `.main-menu` below now draws nothing -- the rules are kept because the
   Settings and Records grids read the same breakpoint off them.

   STUCK TO THE BOTTOM OF THE PANEL, which is the scroller: the chrome bar
   above is `position: sticky; top: 0` for the same reason and from the same
   element, so the two ends of a menu page are pinned by one mechanism. Fixed
   to the VIEWPORT it would float over the board behind a sheet and over the
   §4 shell's gutter on a desktop; stuck to the panel it is inside the column
   every menu is already drawn in.

   44px IS THE TARGET AND 56 IS THE BOX. The row is 56px tall so the icon and
   its name both fit, and the pressable button fills it -- §4's floor is 44
   and this clears it in both directions at every width, including the 390px
   phone where five tabs are 78px each.

   ~~THE PANEL PAYS FOR IT IN PADDING (`.has-tabs`), not the bar in margin.~~
   **THE PANEL PAYS NOTHING AND THAT RULE NEVER APPLIED** (JIO-1994): four
   classes against the five-and-an-element rule that sets the panel's padding,
   and the bar is the panel's last child in flow, so nothing can rest under it.
   ~~The class stays as the mark that a screen has a bar~~ **and since JIO-2016
   the class is what takes the panel's BOTTOM padding to zero**, because a
   sticky bar cannot leave the content box and 24px of padding under it was a
   24px gap over the bottom of the phone (the `.has-tabs` rule beside the daily
   board's, and decisions/0192).

   TEAL IS THE ACTIVE TAB AND IT IS `aria-current`, not a class: the attribute
   is what a screen reader is told and what `show()` writes, so a tab that
   looked active and did not say so could not happen. */
.overlay .panel.menu-panel > .tabbar {
  position: sticky; bottom: 0; z-index: 4;
  display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));
  /* ~~`margin: auto 0 0`~~ -- PINNED WITH THE OTHER TWO FEET INSTEAD, in the
     block below (JIO-1994, decisions/0192): written here it lost to the rule
     that zeroes every panel child's `margin-block`, so the bar floated. */
  padding-bottom: env(safe-area-inset-bottom);
  background: color-mix(in srgb, var(--head-ground) 92%, transparent);
  -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
  border-top: 1px solid var(--line);
}
.overlay .panel.menu-panel > .tabbar > .tab {
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
  min-height: 56px; min-width: 44px; padding: 6px 2px;
  background: none; border: 0; border-radius: 10px;
  color: var(--muted); cursor: pointer;
  transition: color 120ms;
}
.overlay .panel.menu-panel > .tabbar > .tab > .tab-art {
  display: flex; align-items: center; justify-content: center; position: relative;
  width: 24px; height: 24px;
}
/* The padlock sits on the corner of a guest's die, where the avatar picker
   already puts it on a locked chip (ui/avatar.ts). */
.overlay .panel.menu-panel > .tabbar > .tab > .tab-art > .tab-lock {
  position: absolute; right: -4px; bottom: -3px; color: var(--muted);
}
.overlay .panel.menu-panel > .tabbar > .tab > .tab-name {
  font-family: var(--display); font-weight: 400; font-size: 0.625rem;
  letter-spacing: 0.04em; line-height: 1;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.overlay .panel.menu-panel > .tabbar > .tab:hover { color: var(--text); }
.overlay .panel.menu-panel > .tabbar > .tab[aria-current="page"] { color: var(--left); }
/* The ring every other menu item wears, at this weight (the identity chip's
   rule says why it is restated rather than inherited). */
.overlay .panel.menu-panel > .tabbar > .tab.focused,
.overlay .panel.menu-panel > .tabbar > .tab:focus-visible {
  color: var(--text); box-shadow: inset 0 0 0 2px var(--left-dim);
}
.app.reduced-motion .overlay .panel.menu-panel > .tabbar > .tab { transition: none; }
.overlay .panel.menu-panel:not([data-screen="tower"]) > h2::before {
  content: ''; position: absolute; z-index: -1; pointer-events: none;
  left: -50vw; right: -50vw;
  /* The panel's top padding is 16px (24 on a desktop) plus the safe area; 60
     reaches past both and the panel's edge clips the rest. */
  top: calc(-1 * (60px + env(safe-area-inset-top) + 44px + var(--menu-gap, 20px)));
  bottom: -28px;
  background: color-mix(in srgb, var(--head-ground) 70%, transparent);
  -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent);
  opacity: 0; transition: opacity 0.25s;
}
.overlay .panel.menu-panel[data-scrolled]:not([data-screen="tower"]) > h2::before { opacity: 1; }
/* THE THREE DISPLAY LEVELS, and nothing else on a menu is Bungee. h1 is the
   wordmark and the verdict line (hero 40), h2 is the screen's own title (32),
   h3 is a section inside it (24). `font-weight: 400` on all of them because
   Bungee ships one weight: a browser asked for 700 synthesises a bold by
   smearing the outline, which on a poster face reads as a rendering fault.
   The three were 40 / 22 / 13-or-14 and all in the body face. */
.overlay h1 { margin: 0 0 4px; font-family: var(--display); font-weight: 400; font-size: var(--t-hero); line-height: 1.05; letter-spacing: 0.02em; }
/* LOCK IS TEAL, not amber (JIO-1078). `logo-lockup.svg` sets it `#2dd4bf`,
   web/landing.html's wordmark is `var(--left)`, FOUNDATION §1 names "the
   wordmark's LOCK at 40px" among the things that keep the BASE accent, and all
   three Main menu artboards draw it teal -- the game's own title was the one
   place it was the other accent, which is exactly the drift JIO-930 found in
   "Play vs AI" one screen over. In light it is --left's own #0f9f92, which is
   the colour MainLight.dc.html draws. */
.overlay h1 .lock { color: var(--left); }
.overlay h2 { margin: 0 0 12px; font-family: var(--display); font-weight: 400; font-size: var(--t-h1); line-height: 1.05; }
.overlay h3 { font-family: var(--display); font-weight: 400; font-size: var(--t-h2); line-height: 1.1; }
/* The sentence under a screen title: §2's lede, and the one run of prose on a
   menu set above body size. */
.overlay .tagline { color: var(--muted); margin: 0 0 20px; font-size: var(--t-lede); font-weight: 600; line-height: 1.5; }
.overlay .result-title { font-family: var(--display); font-weight: 400; font-size: var(--t-hero); line-height: 1.05; margin: 0; }
/* An inline <code> inherits its size from the paragraph but not its metrics:
   the UA stylesheet's monospace default lands it at ~0.83em, which under the
   14px small print is 11.6px -- a run of prose below the floor that nothing in
   this file had ever set a size for. */
.overlay code { font-family: var(--mono); font-size: 1em; }
.overlay .result-title.victory { color: var(--left); }
.overlay .result-title.defeat { color: var(--danger); }
.overlay .result-title.draw { color: var(--muted); }

/* THE RESULT SHEET (JIO-1076) -- design/pages/gameplay/Results.dc.html.

   The reason line is BODY TEXT and not the muted lede every other screen's
   `.tagline` is. It is the one sentence this screen exists to say -- "They
   buried you. Rematch?" -- and the sheet sets it in the text colour at the
   lede size, under a verdict rather than under a screen title. */
.overlay .result-why { color: var(--text); font-weight: 400; font-size: var(--t-lede); line-height: 1.5; margin: 6px 0 0; }
/* Who the match was against, in one pill: amber with ink on it, the pair
   already used for a die face and the right-hand side's accent.

   THERE ARE TWO `.chip`s IN THIS FILE AND THEY ARE NOT THE SAME COMPONENT. This
   one is a static label on a result screen; `.overlay .menu .chips .chip` is
   High scores' speed selector, which is a radio button (JIO-1081). The word is
   the speed selector's by right -- FOUNDATION §3 lists `chip 999` among the
   radii and `design/pages/menu/NOTES.md` calls those three chips -- and the
   selector's own rules are four classes deep, so they cannot reach each other
   whichever screen is open. ui/screens.ts queries `.chips .chip` rather than
   `.chip` for the same reason: a selector that is right only because the two
   never share a screen is right until they do. */
.overlay .result-vs { margin: 10px 0 0; }
/* THE REMATCH VOTE, DIRECTLY OVER THE BUTTON IT IS ABOUT (JIO-1171): what this
   seat has asked for, or what the other one has. It is small print rather than
   a second lede -- the verdict is still the sentence this screen says, and this
   is a note about what the next press will do -- and it sits under the stats
   card so that the eye reaches it on the way to the buttons rather than after
   them. The colour is the muted one every other note on a menu screen takes: a
   line that appears only while somebody is waiting is already the newest thing
   on the screen without being coloured like an alarm. */
.overlay .result-rematch { color: var(--muted); font-size: var(--t-small); font-weight: 600; margin: 10px 0 0; }
.overlay .chip {
  display: inline-block; font-family: var(--display); font-weight: 400; font-size: var(--t-small);
  color: var(--ink); background: var(--right); border-radius: 999px; padding: 4px 10px; line-height: 1.1;
}
/* The sheet's stats panel: one card, the labels in the body face and the
   numbers in mono, right-aligned under the column head they belong to. The
   alignment is `nth-child` rather than a class on every cell because the grid
   is strictly three spans to a row -- a class would have to be written on
   twenty-one of them and would be forgotten on the twenty-second. */
.overlay .stats.stats-card {
  font-family: var(--font);
  background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
  padding: 12px 14px; gap: 6px 16px; margin: 12px 0 0;
}
.overlay .stats.stats-card .h { font-weight: 600; }
/* THE SCORE BLOCK'S LABEL COLUMN IS `auto`, NOT `1fr`. The long cells on that
   block are the values -- "12,450 · NEW HIGH SCORE" is wider than anything in
   the stats table under it -- and with the label column flexible they were what
   got squeezed: "Best on Hard" and "All-time best" each wrapped to two lines,
   and those two lines are what pushed the Endless result's last row of buttons
   below the fold of a 390x844 frame. A label never wraps; a value may. */
.overlay .stats.score-block.stats-card { grid-template-columns: auto 1fr auto; }
.overlay .stats.stats-card .l, .overlay .stats.stats-card .r { font-family: var(--mono); }
.overlay .stats.stats-card > span:nth-child(3n+2), .overlay .stats.stats-card > span:nth-child(3n) { text-align: right; }
/* THE COACHING CARD, where the statistics table goes on a quiet loss
   (JIO-1318). Same card as the stats panel -- same fill, line, radius and pad --
   because it stands in exactly its place and a different-looking box would read
   as a warning rather than as the screen's own answer. The head is the stats
   table's `.h` in every respect but the letter-spacing, which it needs because
   it is three words rather than one column label. */
.overlay .coach-card {
  background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
  padding: 12px 14px; margin: 12px 0 0;
}
.overlay .coach-head {
  margin: 0; font-family: var(--display); font-size: var(--t-small);
  letter-spacing: 0.06em; color: var(--muted);
}
.overlay .coach-line { margin: 8px 0 0; font-size: var(--t-small); line-height: 1.5; color: var(--text); }
/* A LIST AND NOT A PARAGRAPH (2026-10-21): two short tips with the browser's
   own bullets, under the head, at the card's text size. */
.overlay .coach-list { margin: 8px 0 0; padding: 0 0 0 18px; }
.overlay .coach-list .coach-line { margin: 4px 0 0; }
.overlay .coach-line strong { color: var(--left-text); font-weight: 700; }
/* The key it names is drawn by the shared <kbd> rule further down, which this
   selector is added to rather than copied -- the help overlay, the rules cards,
   the keybind chart and the puzzle sheet all print keys, and a fifth drawing of
   one is a fifth thing to forget when the key style changes. */
/* The two the sheet pairs on one row (Change opponent | History). They are
   still two menu items and still carry their own `data-row`, so the pad and
   the arrow keys walk left and right across the pair. */
.overlay .btn-pair { display: flex; gap: 8px; }
.overlay .btn-pair .btn { flex: 1; min-width: 0; }
/* THE NEXT RUNG, UNDER THE WAYS OFF EVERY RESULT SCREEN (JIO-1465). One line
   and one button ON THE SAME ROW, which is all the Endless result can afford:
   that screen has the least room of anything in the game that is not a list --
   it is the one result screen this row makes scroll (SPEC 13) -- and a stacked
   line over a full-width button would have cost it the sentence, the gap and
   the button together. Side by side the row costs the button's own 44px.

   The line is the muted small print every note on a menu screen is set in -- it
   is a recommendation, not the sentence the screen exists to say -- and it
   takes the width the button leaves rather than the button shrinking to it: a
   two-word destination and a nine-word line must not swap sizes between
   screens. The margin is written here in full so the row does not pick up
   `.menu`'s 16px top and bottom -- and on a result screen it is not what spaces
   the row at all, because `.panel.menu-panel > *` zeroes margin-block and the
   panel's own gap does it. Where it does apply, the Tower's floating card, the
   block above stops being `.menu:last-child` and the 16px that rule drops comes
   back, so this 12px collapses into a 16 (JIO-1465, measured) -- which is the
   number design/pages/tower draws that gap with. */
.overlay .menu.next-rung {
  flex-direction: row; align-items: center; gap: 10px; margin: 12px 0 0;
}
.overlay .next-rung .rung-line {
  flex: 1; min-width: 0; margin: 0;
  font-size: var(--t-small); font-weight: 600; line-height: 1.4; color: var(--muted);
}
.overlay .next-rung .btn { flex: 0 0 auto; width: auto; padding: 0 14px; }

/* ============================================================================
   THE RESULTS POSTER (JIO-1500) -- design/pages/results/ and its NOTES.md, the
   row George picked on 2026-09-29.

   ONE SECTION FOR FOUR SCREENS, and it is here rather than at the end of the
   file because it stands exactly where the result sheet above it does: the two
   are the same screen before and after the pick, and a reader who comes looking
   for what a finish looks like must not find the old answer under the heading
   and the new one 4,700 lines below it.

   EVERY FIELD TOKEN IS IN THIS BLOCK. JIO-1501..1504 wire the four modes onto
   `posterBody()` and ~~NONE of them adds CSS~~ **none of them adds a FIELD** -- a
   mode names its field (`data-field`, one of ui/screens.ts's POSTER_FIELDS) and
   hands in at most the two paints that are data rather than a token. Four tickets
   each writing their own field colour is four near-copies of this block, which is
   what the one builder exists to stop. The struck absolute did not survive the
   Tower (JIO-1503): its hero is three star GLYPHS and not a number, and where
   they are drawn decides their colour -- teal on the map's sheet, the field's own
   ink here -- which is three declarations under `.poster-hero` below and is not a
   field.

   THE BANDS ADD UP TO THE FRAME: 486 + 84 + 274 = 844. The foot is what is
   LEFT rather than a third fixed number, so a taller phone spends the extra on
   the controls, and the strip is a MINIMUM, so a key that wraps to a third line
   takes the foot's slack instead of being clipped.
   ~~AND A SHORTER PHONE IS THE SAME SUM READ BACKWARDS~~ -- **it is not, and
   that was never written down** (JIO-1552, 2026-09-30, docs/decisions/0073).
   BELOW 844 THE FIELD IS THE BAND THAT PAYS -- it is bare colour by direction
   and the only band with anything to give -- and the foot no longer shrinks at
   all. Two `flex` shorthands further down, and no second number anywhere.

   §2's FLOOR WINS OVER THE DRAWING. The artboards set the label at 12 and a
   cell's key at 11, which is what a drawing can afford and a screen cannot (§2:
   14px is the floor for anything a player reads). Both take --t-small, and the
   consequence lands on the four mode tickets rather than here -- a cell's key is
   two short words, because three lines of 14px do not fit an 84px strip. THE
   HERO IS THE ONE SIZE OFF THE SCALE: §2 tops out at the wordmark's 40 and this
   is the drawing's 84, a token so the Tower can pass 64 for three star glyphs,
   which are wider than any number.
   ========================================================================== */
.overlay .poster {
  /* The bands, and the hero the field is built around. */
  --poster-field: 486px;
  --poster-strip: 84px;
  --poster-hero: 5.25rem;
  /* THE FOUR FIELDS, UP FRONT AND NAMED, so a mode ticket reaches one by name
     and adds no CSS of its own. Endless is face 5 and Puzzles is face 1 -- the
     die faces themselves, which is why test/screens.test.js asserts these two
     against `render/renderer.ts`'s FACE_COLORS rather than trusting a hex typed
     twice. Versus is `--left`, the player's own side of every board in this
     game, with the opponent's amber naming the computer at the foot of it. The
     Tower's is the floor's own world, off the skin tokens `applyWorld()` writes.
     NONE OF THEM HAS A LIGHT VARIANT OF ITS OWN, and two of the four are the
     same colour in both palettes for it: a die face is a die face (FOUNDATION
     §1) and so is a world skin. ~~So the FOOT is what flips.~~ **Versus flips
     too** (JIO-1502, 2026-09-29: `--left` is #2dd4bf on the dark palette and
     #0f9f92 on the light one, two declarations up this file) -- not because a variant was added here but because
     `--left` IS a palette token, mixed for each page, and so is the `--right`
     the band under it takes. That is the pairing being kept rather than broken:
     a teal mixed for a dark page, pasted onto a light one beside a re-lit amber,
     would be the two sides of the board no longer matching the board. The
     struck clause is kept because it is still true of the other three. */
  --field-endless: #b28dff;
  --field-versus: var(--left);
  --field-puzzle: #5eb0ff;
  --field-tower: linear-gradient(180deg, var(--world-top, var(--panel)), var(--world-bottom, var(--panel-2)));
  --field: var(--panel);
  --field-ink: var(--ink);
  /* The muted ink ON the field: the label and the warm line. Ink at 68% rather
     than `--muted`, which is a grey mixed for a dark page and would be a smear
     on face 5. */
  --field-sub: rgba(11, 13, 18, 0.68);
  /* What the primary button is filled with. It is separate from `--field`
     because the Tower's field is a gradient and a border-color cannot be one. */
  --field-accent: var(--field);
  display: flex; flex-direction: column;
  height: 100%; min-height: 100%;
  background: var(--bg); color: var(--text);
}
.overlay .poster[data-field="endless"] { --field: var(--field-endless); }
.overlay .poster[data-field="versus"] { --field: var(--field-versus); }
.overlay .poster[data-field="puzzle"] { --field: var(--field-puzzle); }
/* THE TOWER'S FIELD IS THE FLOOR IT WAS CLIMBED ON, read here rather than passed
   in: `applyWorld()` already puts the skin's two ground stops and its heading
   colour on the element it is handed (ui/world.ts's WORLD_TOKENS), so a
   nineteenth world arrives on this screen with nothing edited. The ground is
   wanted at FULL strength -- 0.55 is the wash a Tower SCREEN wears behind a
   board, and here the field IS the thing (results/NOTES.md) -- which is the
   caller's business: the strength is an argument to `worldStyle()`, not a
   colour. The ink is light, because a world ground is dark in both palettes. */
.overlay .poster[data-field="tower"] {
  --field: var(--field-tower);
  --field-ink: var(--world-ink, var(--text));
  --field-sub: rgba(232, 236, 243, 0.82);
  --field-accent: var(--world-ink, var(--left));
}
/* THE FIRST GAME'S FIELD IS THE PLAYER'S OWN TEAL (JIO-1986). A modifier on
   whichever field the finish already names rather than a fifth field: the run
   is still a Survival run, so `data-field` is still `endless` and everything
   but the colour is the poster above. `--left` says "yours" (FOUNDATION §1,
   and the Versus poster's field is the same token). ONE DECLARATION, because
   the rest follows: `--field-accent` is `var(--field)` on the block above, so
   the primary goes teal with the field, and `--field-sub` is already the dark
   ink a light field wants. AFTER the four field rules and not before: three
   classes against two and an attribute is a specificity TIE, and a tie goes to
   whichever rule is later in the file. */
.overlay .poster.first { --field: var(--left); }
/* THE PANEL IS THE POSTER. Every other menu screen is a 16/20/24 frame with a
   20px column gap (the block further down); a poster is full-bleed colour to
   all four edges, so the gutter and the gap come off and the poster's own bands
   put the padding back inside themselves. Five classes, because the rule it is
   replacing has four. */
html .app .overlay .panel.menu-panel:has(.poster) {
  padding: 0; gap: 0; display: block; position: relative;
}
/* ...AND IT STAYS OPAQUE WITH SOMETHING BEHIND THE PAGE (JIO-1503).
   `html[data-behind] .app .overlay .panel.menu-panel` further down this file
   makes a full-bleed menu transparent so what is behind reaches it, and that
   rule is about a panel holding CARDS IN A GUTTER. A poster IS the frame and
   paints every pixel of it, and the Tower's is the one poster drawn while a
   world is worn -- its own field is that world, at full strength, inside the
   poster. Nothing on the screen moves either way; what it fixes is a panel that
   reported itself transparent to anything measuring the frame, which is
   scripts/probe-artboards.ts's fill check against an artboard root of --bg
   (2026-09-30). The attribute is in the selector rather than the rule being
   moved, because the rule it outranks is LATER in the file and a tie on
   specificity would go to that one.

   ~~`html[data-world]`~~ `html[data-behind]` SINCE JIO-1703, in step with the
   rule it outranks. */
html[data-behind] .app .overlay .panel.menu-panel:has(.poster) { background: var(--bg); }
/* AND THE SPEAKER STAYS IN THE FIELD'S TOP-RIGHT (FOUNDATION §4). It is
   `show()`'s own chrome row -- the same button `setSound()` patches on every
   screen, not a second one drawn in the poster -- lifted out of the column and
   into the corner the artboards draw it in. The empty 44px box beside it goes:
   it exists to hold the speaker's place against a back arrow (ui/chrome.ts), and
   a poster never has one. */
html .app .overlay .panel.menu-panel:has(.poster) > .chrome-bar {
  position: absolute; z-index: 2; margin: 0;
  top: calc(16px + env(safe-area-inset-top));
  right: calc(10px + env(safe-area-inset-right));
}
html .app .overlay .panel.menu-panel:has(.poster) > .chrome-bar .chrome-spacer { display: none; }
/* The field's own type is the ink the mode chose, so the speaker is too. */
html .app .overlay .panel.menu-panel:has(.poster) > .chrome-bar .chrome-sound { color: var(--field-sub); }

/* ---- the field ----------------------------------------------------------- */

/* THE CONTENT SITS AT THE FOOT OF THE FIELD. Roughly the top half of every
   poster is bare colour, which is the direction rather than a gap: a hero at
   84px only reads as one with nothing above it competing (NOTES.md). The auto
   margin does it, so the label stays pinned to the top row by the speaker. */
/* AND ON A SHORT PHONE THIS IS THE BAND THAT GIVES WAY (docs/decisions/0073).
   It was `flex: none` at 486, so a frame shorter than 844 pushed the whole
   overrun past the bottom: at 375x667 the rung row ended 122px under the fold.
   `min-height` is spelt because `overflow: hidden` makes the automatic minimum
   zero, and it is `min-content` rather than a px floor because the field's own
   content moves with the warm line's wrap (298px at 375 wide, 328 at 320). */
.overlay .poster-field {
  position: relative; overflow: hidden;
  flex: 0 1 auto; height: var(--poster-field); min-height: min-content;
  display: flex; flex-direction: column;
  padding: calc(16px + env(safe-area-inset-top)) 20px 0;
  background: var(--field); color: var(--field-ink);
}
.overlay .poster-world { position: absolute; inset: 0; }
.overlay .poster-label {
  flex: none; height: 44px; margin: 0;
  display: flex; align-items: center;
  /* Clear of the speaker, which is out of flow in the same row. */
  padding-right: 44px;
  font-size: var(--t-small); font-weight: 700; line-height: 1.2;
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--field-sub);
}
/* The verdict and its chip on ONE BASELINE, which is what lets NEW BEST sit
   beside GAME OVER rather than under it. */
.overlay .poster-verdict-row {
  margin-top: auto; display: flex; align-items: baseline;
  justify-content: space-between; gap: 12px;
}
/* FOUR CLASSES, because `.app.compact .overlay h2` takes every other h2 in the
   game one rung down to 24 and a poster's verdict is not a section heading: it
   is the screen's own word, second only to the hero, and the artboards draw it
   at 30. Measured at 24 in a 390x844 frame before this selector was widened
   (2026-09-30) -- the rule said `--t-h1` and the compact block was winning. */
html .app .overlay .poster .poster-verdict {
  margin: 0; font-family: var(--display); font-weight: 400;
  font-size: var(--t-h1); line-height: 1.05; color: var(--field-ink);
}
/* NOT `.chip`, which is the amber pill on the result sheet (two of them in this
   file already, and the comment above the first says why a third would be one
   too many): this one is the field's own ink on a wash of it, because an amber
   pill on face 5 would be the loudest thing on a screen whose hero is 84px. */
.overlay .poster-chip {
  flex: none; display: inline-flex; align-items: center; height: 28px; padding: 0 12px;
  border-radius: 999px; background: rgba(11, 13, 18, 0.12); border: 1px solid rgba(11, 13, 18, 0.3);
  font-family: var(--display); font-weight: 400; font-size: var(--t-small); line-height: 1;
  color: var(--field-ink);
}
.overlay .poster[data-field="tower"] .poster-chip {
  background: rgba(232, 236, 243, 0.1); border-color: rgba(232, 236, 243, 0.26);
}
.overlay .poster-hero {
  margin: 6px 0 0; font-family: var(--display); font-weight: 400;
  font-size: var(--poster-hero); line-height: 1; letter-spacing: 0.01em;
  color: var(--field-ink); font-variant-numeric: tabular-nums;
}
/* THE ONE HERO THAT IS NOT A NUMBER (JIO-1503): the Tower's is three star glyphs,
   `starRow()`'s own markup, which is drawn on the map's step sheets as well and
   is coloured there in teal on grey (`.tower-stars` further down). On a poster
   both marks are the FIELD's ink and the unearned ones are dimmed, because the
   field is a world ground and teal on it is a third colour nothing on the screen
   is in. Two opacities and not two colours, which is what the artboards draw:
   an outline star is the same mark not yet paid for, and a second hue would read
   as a different kind of thing. The letter-spacing is `.tower-earned`'s, which
   this replaces. */
.overlay .poster-hero .tower-stars { letter-spacing: 0.12em; }
.overlay .poster-hero .tower-stars .on,
.overlay .poster-hero .tower-stars .off { color: var(--field-ink); }
.overlay .poster-hero .tower-stars .off { opacity: 0.42; }
.overlay .poster-line {
  margin: 12px 0 20px; font-size: var(--t-lede); font-weight: 600; line-height: 1.45;
  color: var(--field-sub);
}
/* WHERE THE SCORE CAME FROM, UNDER THE WARM LINE (JIO-1515). The field's own
   muted ink at §2's small size -- the same pair the label at the top of the field
   and the note in the foot wear -- because it is a footnote to the hero and not a
   second sentence competing with the one above it.
   IT IS IN THE FIELD AND NOT THE FOOT, and the reason is the 274px sum: the foot
   holds its four rows with 12px to spare (results/NOTES.md, measured over CDP),
   so a fifth line there is the one screen in the game whose rule is that nothing
   scrolls, scrolling. The field's top half is bare colour by direction, and it is
   what pays for this line.
   THE WARM LINE CLOSES UP ABOVE IT, the same way it does above the Versus band a
   few rules down: the two are one block under the hero, and 20px between them
   would read as two unrelated sentences. */
.overlay .poster-source {
  margin: 0 0 20px; font-size: var(--t-small); font-weight: 700;
  line-height: 1.35; letter-spacing: 0.01em; color: var(--field-sub);
}
.overlay .poster-line:has(+ .poster-source) { margin-bottom: 8px; }
/* THE BAND ACROSS THE FOOT OF THE FIELD, full-bleed inside the field's own
   gutter -- which is why it carries negative margins rather than the field
   dropping its padding for it. Amber on teal is the game's own pairing (§1): the
   player's side is the field and the other one names what lost on it. */
.overlay .poster-band {
  margin: 0 -20px; display: flex; align-items: center; justify-content: space-between;
  gap: 12px; height: 44px; padding: 0 20px;
  background: var(--right); color: var(--ink);
}
.overlay .poster-line:has(+ .poster-band) { margin-bottom: 16px; }
.overlay .poster-band-who { font-family: var(--display); font-weight: 400; font-size: var(--t-small); line-height: 1.1; }
.overlay .poster-band-how { font-size: var(--t-small); font-weight: 700; line-height: 1.2; }

/* ---- the strip ---------------------------------------------------------- */

/* One to three cells, each a value over its key, divided by a hairline.
   `min-width: 0` is what lets a two-word key wrap inside its own third of the
   strip instead of pushing its neighbour off the frame. */
.overlay .poster-strip {
  flex: none; min-height: var(--poster-strip); display: flex; align-items: stretch;
  background: var(--panel-2); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
}
.overlay .poster-cell {
  flex: 1 1 0; min-width: 0; display: flex; flex-direction: column;
  justify-content: center; gap: 6px; padding: 12px 14px;
}
.overlay .poster-cell + .poster-cell { border-left: 1px solid var(--line); }
.overlay .poster-value {
  font-family: var(--display); font-weight: 400; font-size: var(--t-lede); line-height: 1.1;
  color: var(--text); font-variant-numeric: tabular-nums;
}
/* THE ONE ACCENT A STRIP CELL MAY CARRY (JIO-1566's drawing, JIO-1569's screen):
   the placing, the moment the run is really on a board. `--left` is the player's
   own side of every board here (FOUNDATION §1). */
.overlay .poster-value.poster-accent { color: var(--left); }
.overlay .poster-key {
  font-size: var(--t-small); font-weight: 700; line-height: 1.25;
  letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted);
}

/* ---- the foot ----------------------------------------------------------- */

/* THE FOOT GROWS AND NEVER SHRINKS (docs/decisions/0073). At `1 1 auto` a frame
   the poster overflows took the shortfall off this band too, and the foot
   shrinking is the buttons leaving it. */
.overlay .poster-foot {
  flex: 1 0 auto; display: flex; flex-direction: column;
  padding: 16px 20px calc(24px + env(safe-area-inset-bottom));
  background: var(--bg);
}
.overlay .poster-note {
  margin: 0 0 10px; font-size: var(--t-small); font-weight: 700; line-height: 1.35; color: var(--muted);
}
/* THE ROW THAT OPENS THE STATISTICS. A real <button> in a `.menu`, for the
   reason the rung row is one: the ring the arrows walk is built out of `.menu
   button`, so a control outside one is unstyled and unreachable (JIO-1391).
   Taller than a secondary and filled like a card, because it opens something
   rather than going somewhere -- and the chevron is the back arrow turned down,
   the same one transform `chevron()` takes rather than an eighteenth icon.
   Three classes, so it outranks `.overlay .btn-secondary` further down. */
.overlay .poster-foot .menu { margin: 0; gap: 0; }
.overlay .poster-detail .poster-details {
  height: 48px; justify-content: space-between; text-align: left; padding: 0 16px;
  background: var(--panel); border-color: var(--line); color: var(--text);
  font-size: var(--t-body);
}
.overlay .poster-detail .poster-details:hover { background: var(--panel-2); border-color: var(--muted); }
.overlay .poster-details .glyph { color: var(--muted); transform: rotate(-90deg); }
.app.reduced-motion .overlay .poster-details .glyph { transform: rotate(-90deg); }
/* The primary in the field's own colour, which is the whole point of the poster:
   the way on is the same colour as the screen it is on. Ink on it in both
   palettes -- every field is a bright die face, a teal or a world ground, and
   all of them are dark-ink surfaces (§1).
   `.poster-foot` IS IN THIS SELECTOR AND HAS TO BE (docs/decisions/0073). Two
   classes against the three of the `.poster-foot .menu` reset ten lines up, so
   the reset won and both numbers here were 0: the buttons touched. The 12 and
   the 10 are `design/pages/results/build.mjs`'s own spacers and cost the foot
   22px, which a 390x844 frame has. */
.overlay .poster-foot .poster-menu { margin-top: 12px; gap: 10px; }
.overlay .poster-menu .btn-primary {
  background: var(--field-accent); border-color: var(--field-accent); color: var(--ink);
}
/* THE SECOND WORD ON A PRIMARY THAT NAMES WHERE IT GOES (JIO-1504). `NEXT
   PUZZLE 1-5` is the poster rule's own spelling for this mode, and `.desc` --
   the span every menu row's live second line is -- is `--muted`, a grey mixed
   for a dark page and unreadable on face 1. It is the field's own ink dimmed,
   which is what the drawing measures it at (design/pages/results/build.mjs,
   0.72). Two declarations, and they are the second bend in "no mode ticket
   adds CSS" after the Tower's stars: the rule is about FIELDS, and this is a
   colour that only exists because the primary is filled with one. */
.overlay .poster-menu .btn-primary .desc { color: var(--ink); opacity: 0.72; }
.overlay .poster-secondary { display: flex; gap: 8px; }
.overlay .poster-secondary .btn { flex: 1 1 0; min-width: 0; }
/* The rung row is JIO-1465's, unchanged (`.menu.next-rung` above): the poster
   gives it the 12px the artboards draw and nothing else, because a recommendation
   that looked different on four screens would be four recommendations. */
.overlay .poster-foot .next-rung { margin-top: 12px; }
/* THE SAME SLOT, DRAWN AS THE SIGN-IN DOOR (JIO-1568, docs/decisions/0081 and
   design/pages/results/NOTES.md's markup contract). It is the rung row's shape
   to the pixel -- a sentence, a 10px gap and a 44px button, at the 12px the
   artboards draw -- because that identity is the whole argument for the door
   being HERE rather than a sheet over every finish or a band at the foot of the
   field: 486 + 84 + 274 is unchanged and nothing scrolls.

   TWO DECLARATIONS ARE NOT THE RUNG'S, and both are what make this an ask
   rather than a recommendation. The line is `--text-2` body copy where the rung
   is `--muted` small print: it is the one sentence in 274px of grey a player is
   meant to read, because it says where THIS result would sit. And the button
   wears the FIELD's own accent instead of the hairline every other secondary
   wears, which makes SIGN IN the single outlined thing in the foot.

   THE ACCENT AS TYPE CLEARS THE CONTRAST BAR ON EVERY FIELD, measured over the
   foot's #0c0e12 on 2026-09-30: face 5 7.49:1, face 1 8.40, `--left` 10.38, the
   Greenhouse's heading 12.54 -- and the bound is not any of those but the
   darkest heading the twenty worlds carry, the Volcano's #ff6b3d at 6.83,
   because a Guardian's door is drawn in whichever world it was crowned in. All
   of them over the 4.5:1 a 16px Bungee word takes, and it is arithmetic in
   test/world.test.js rather than a number in this comment. */
.overlay .menu.poster-door {
  flex-direction: row; align-items: center; gap: 10px; margin: 12px 0 0;
}
.overlay .poster-foot .poster-door { margin-top: 12px; }
.overlay .poster-door .door-line {
  flex: 1; min-width: 0; margin: 0;
  font-size: var(--t-small); font-weight: 600; line-height: 1.4; color: var(--text-2);
}
/* The placing in full-strength ink, which is the half of the sentence a player
   reads: "would sit" is the grammar and "#7 of 213" is the answer. */
.overlay .poster-door .door-rank { color: var(--text); font-weight: 700; }
.overlay .poster-door .btn {
  flex: 0 0 auto; width: auto; padding: 0 14px;
  color: var(--field-accent); border-color: var(--field-accent);
}
/* THE STILL DOOR (2026-10-11, docs/decisions/0130): the Endless and Versus
   posters' guest row, the sentence alone with no button. The row keeps the
   button's 44px so the three bands still add up to 844 and the slot does not
   move between a guest's finish and a signed-in one. */
.overlay .poster-door-still { min-height: 44px; }

/* ---- the slot once the games are attached (JIO-1569) --------------------- */

/* THE SAME ROW WITH THE ASK TAKEN OUT OF IT: `.poster-posted` is `.poster-door`
   to the pixel, and the one thing it does not inherit is the field's accent on
   the button -- BOARD is a way on rather than something being asked for. The
   line keeps `.door-line`, because a second copy of those four declarations is
   the pair drifting by a line-height. */
.overlay .menu.poster-posted {
  flex-direction: row; align-items: center; gap: 10px; margin: 12px 0 0;
}
.overlay .poster-foot .poster-posted { margin-top: 12px; }
.overlay .poster-posted .door-line {
  flex: 1; min-width: 0; margin: 0;
  font-size: var(--t-small); font-weight: 600; line-height: 1.4; color: var(--text-2);
}
/* The handle in full-strength ink, which is `.door-rank`'s argument on the row
   this replaces: the name is the half of the sentence a player reads. */
.overlay .poster-posted .door-name { color: var(--text); font-weight: 700; }
.overlay .poster-posted .btn { flex: 0 0 auto; width: auto; padding: 0 14px; }

/* ---- and the slot when a look was just paid (JIO-1738) ------------------- */

/* `.poster-posted`'s ROW, for `.poster-posted`'s own reason: one 44px row in one
   place, three times over, and a fourth copy of those declarations is the set
   drifting by a line-height. What it does NOT take is the field's accent on the
   button -- that is the DOOR's alone, because the door is the one row in the
   foot that is asking for something. SEE IT is an answer to a sentence the
   screen has just made, so it wears the hairline every other secondary does. */
.overlay .menu.poster-reward {
  flex-direction: row; align-items: center; gap: 10px; margin: 12px 0 0;
}
.overlay .poster-foot .poster-reward { margin-top: 12px; }
.overlay .poster-reward .door-line {
  flex: 1; min-width: 0; margin: 0;
  font-size: var(--t-small); font-weight: 600; line-height: 1.4; color: var(--text-2);
}
/* The look's NAME in full-strength ink, which is `.door-rank`'s and
   `.door-name`'s argument a third time: it is the half of the sentence a player
   reads. "New look earned" is the grammar and "Tumble" is the answer. */
.overlay .poster-reward .reward-look { color: var(--text); font-weight: 700; }
.overlay .poster-reward .btn { flex: 0 0 auto; width: auto; padding: 0 14px; }

/* ---- the phone held sideways -------------------------------------------- */

/* A POSTER TURNED ON ITS SIDE IS TWO COLUMNS, NOT THREE BANDS (JIO-1505,
   docs/decisions/0076). 486 + 84 + 274 does not fit 390 at any split: at 844x390
   the field gave way to its own min-content 282 (decision 0073) and the foot
   still began 366px down. What a rotation hands back is width -- the trade the
   board itself makes in SPEC's "what rotates is the strip" -- so the field takes
   the left half at full height and the strip and the foot stack down the right.
   A grid, because the three are siblings and `flex-direction: row` would stand
   all three side by side; the alternative is a fourth wrapper in ui/screens.ts
   that every mode's poster would carry for one orientation. The second row is
   `1fr`, whose automatic minimum is its content, so a foot with more in it grows
   the poster rather than losing the buttons -- 0073's "the foot never shrinks".
   AND THE SIDE INSETS GO LIVE HERE: the poster's bands put back the safe area
   `.overlay:has(.menu-panel)` drops, top and bottom only, because an upright
   iPhone has no left or right one. Turned it has ~59px of each, and the band is
   full-bleed INSIDE the field, so it pays the inset back as padding. The speaker
   is specified into the FIELD's top-right (FOUNDATION §4), which is the middle
   of the frame here and not its right edge. */
.app.landscape .overlay .poster {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: auto 1fr;
  grid-template-areas: 'field strip' 'field foot';
}
/* `height` and not `flex`: the field carries the 486 as a height, which a grid
   item keeps. */
.app.landscape .overlay .poster-field {
  grid-area: field; height: auto;
  padding-left: calc(20px + env(safe-area-inset-left));
}
.app.landscape .overlay .poster-band {
  margin-left: calc(-20px - env(safe-area-inset-left));
  padding-left: calc(20px + env(safe-area-inset-left));
}
.app.landscape .overlay .poster-strip {
  grid-area: strip; border-top: 0; padding-right: env(safe-area-inset-right);
}
.app.landscape .overlay .poster-foot {
  grid-area: foot; padding-right: calc(20px + env(safe-area-inset-right));
}
html .app.landscape .overlay .panel.menu-panel:has(.poster) > .chrome-bar {
  right: calc(50% + 10px);
}

.menu { display: flex; flex-direction: column; gap: 8px; margin: 16px 0; }
/* ...and where the menu ENDS the panel, that bottom margin is dead space above
   the panel's own padding. It is 16px of a 390x844 frame, which is what the
   Endless result screen -- the tallest thing in the game that is not a list --
   has to find (JIO-1076). */
.overlay .menu:last-child { margin-bottom: 0; }
/* A MENU ROW IS A CARD (§5): the name in Bungee 20, and the live second line
   beside it in Nunito 14/600. The row used to be 16 over 13 in one face, so
   the name and the value it carried read as one sentence at two sizes. */
.menu button { text-align: left; padding: 10px 14px; font-family: var(--display); font-weight: 400; font-size: var(--t-card); line-height: 1.1; display: flex; justify-content: space-between; gap: 12px; }
.menu button .desc { color: var(--muted); font-family: var(--font); font-weight: 600; font-size: var(--t-small); line-height: 1.4; }
.menu button.focused, .menu button:focus-visible { border-color: var(--left); box-shadow: 0 0 0 2px var(--left-dim); }
.menu button.selected { border-color: var(--right); }
/* ============================================================================
   THE MENU CHROME (JIO-1064) -- design/foundation/FOUNDATION.md §3-§5.

   ui/chrome.ts writes the markup; this is the whole of what it looks like. Two
   rules the rest of the file does not have and this one needs:

   - EVERY STATE IS DECLARED, not left to a browser default. §5 asks for hover,
     focus and active on a card and a button, and the focus one is the reason:
     a keyboard player moving with the arrows gets `.focused` written on the
     item by Screens.applyFocus(), while a Tab reaches it as `:focus-visible`.
     Both have to look the same or the ring exists for one hand only.
   - AND EVERY ONE OF THEM HAS A REDUCED-MOTION VARIANT. `.app.reduced-motion`
     is the game's own switch and it already DEFAULTS from the OS
     (settings.ts reads `prefers-reduced-motion: reduce` for a player who has
     never touched it), so one selector covers both -- and a player who turned
     it off on purpose is not overruled by their OS, which a bare media query
     here would do. What the variant drops is the travel: no transition, no
     lift, no press. What it keeps is the STATE -- the border and the fill still
     change, so nothing becomes invisible to somebody who asked for less motion.
   ========================================================================== */

/* §4: MENUS HAVE NO TOP BAR. main.ts puts this class on `.app` when Screens
   shows a screen that owns the chrome and takes it off for the overlays that
   sit over a live or finished match (both pause menus, the results, the
   countdown), which keep the in-game bar §4 leaves unchanged. It is a class on
   `.app` and not a `[data-state]` rule because `data-state` is written at
   twelve sites in main.ts and never for `difficulty`, `endless`, `settings`,
   `scores`, `history` or `help` -- six menu screens a state rule would have
   missed. main.ts calls layout() on the same tick, because chromeHeight()
   measures the bar this hides. */
.app.menu-chrome .topbar { display: none; }

/* The panel a menu screen is drawn in takes §3's gutter -- 20px on a phone, 32
   desktop -- rather than the 32 the in-game overlays keep. A 40px icon and a
   Bungee 20 title do not fit a 375px screen through 64px of padding. */
.menu-panel { padding: 16px 20px 24px; }
@media (min-width: 720px) { .menu-panel { padding: 24px 32px 32px; } }

/* ON A PHONE THE MENU IS THE SCREEN, NOT A CARD ON TOP OF ONE (JIO-1081).
   All eight phone artboards under `design/pages/menu` are the same frame: a
   390x844 box filled with the page's own --bg, 16/20/24 of padding, and
   nothing at all between the content and the edge. The implementation drew
   every one of them in the IN-GAME overlay's floating card instead -- the
   overlay's own 12px scrim gutter, then a 1px border and a 14px radius, then
   the panel's 20 -- which is 33px of side inset against the artboard's 20, and
   a scrim dimming a screen that has nothing behind it. George's review of High
   scores and History listed it first of eight discrepancies (2026-09-14).

   IT SITS ON `.menu-panel` AND NOT ON THOSE TWO SCREENS. The same frame is
   Start's, How to play's, Records' and Settings' artboard too, so scoping the
   fix to `[data-screen="scores"]` and `[data-screen="history"]` would have put
   two screens on the canvas and left the four beside them off it -- a
   difference a player reads as the two new screens being the broken ones.

   ~~THE SEAM IS 720px ... a desktop keeps the centred 680px card, its scrim and
   its border.~~ IT IS THE PAGE AT EVERY WIDTH (JIO-1086's validation). The
   sentence struck above read MainDesktop.dc.html as "a different composition"
   and kept the card above 720px; the artboard is a 1440x900 frame filled with
   --bg, a 720px column, and no card, scrim or border anywhere on it -- and so
   are EndlessDesktop.dc.html and VersusDesktop.dc.html, the two George opened
   beside the game on 2026-09-15 and found a bordered card floating over a
   dimmed arena. The 720 seam stays, but what changes at it is the gutter and
   the shell, not whether the menu is a page. The block below is therefore not
   inside a media query; the desktop adjustments follow it in one.

   `html .app .overlay .panel.menu-panel` -- FOUR CLASSES AND AN ELEMENT. This
   read `.app .overlay .panel.menu-panel` under a note claiming four classes beat
   the compact block's `.app.compact .overlay .panel { padding: 20px 16px }` at
   the bottom of this file. That selector is four classes too -- `.app.compact`
   is two -- and it is later in source order, so it won the tie: every phone
   menu drew at a 16px gutter with 20px top and bottom, the exact numbers this
   rule exists to replace, while every check that measured a row passed. It
   showed the first time the screen was measured box for box against the
   artboard rendered at its own width (2026-09-14). The `html` is the
   tie-breaker, and the title and lede rules below need it for the same reason:
   the same compact block takes both of them one rung down. */
/* The scrim goes with the card: it exists to give a floating panel an edge
   against the game behind it (JIO-1061), and a full-bleed screen has no
   edge to find. A sheet still brings its own `.sheet-scrim`, which is a
   separate element over the top and is untouched here. */
.overlay:has(.menu-panel) { padding: 0; background: none; align-items: stretch; }
html .app .overlay .panel.menu-panel {
    --card: var(--panel);
    width: 100%;
    height: 100%;
    max-height: none;
    /* The head's frosted band reaches 50vw past the column either side (the
       menu block); without this the panel would scroll sideways to show it.
       Nothing on a menu overflows sideways on purpose -- the keyboard drawing
       has a scroller of its own (`.kbd-scroll`). */
    overflow-x: hidden;
    background: var(--bg);
    border: none;
    border-radius: 0;
    /* The artboard's 16/20/24, with the safe area added to it rather than
       replacing it -- the overlay used to hold that inset and no longer does,
       so a notch would otherwise sit on the back arrow. */
    padding: calc(16px + env(safe-area-inset-top)) calc(20px + env(safe-area-inset-right)) calc(24px + env(safe-area-inset-bottom)) calc(20px + env(safe-area-inset-left));
    /* THE ARTBOARD'S FRAME IS A COLUMN WITH A 20px GAP, and so is this. Every
       phone artboard under design/pages/menu is `display: flex;
       flex-direction: column; gap: 20px` with no margin on anything inside it.
       The screen spaced the same children with whatever margin each carried --
       12 under the chrome row, 12 under the title, 16 above a menu, 12 above
       History's list -- which put every block 4 to 27px off where the artboard
       draws it (JIO-1081). The children's margins go to zero below and the gap
       does the spacing. The auto margin that pins a foot to the floor still
       wins: it is written further down with a longer selector. */
    display: flex;
    flex-direction: column;
    gap: 20px; --menu-gap: 20px;
  }
html .app .overlay .panel.menu-panel > * { margin-block: 0; }
/* AND THE PINNED BOARD ROW REACHES THIS PANEL'S BOTTOM EDGE (JIO-1485). The
   foot is `position: sticky; bottom: 0`, and a sticky element is clamped by TWO
   boxes: the scrollport, and its own containing block. The scrollport ends at
   the frame's edge; `.daily-board` ends 24px above it, at the panel's content
   box -- so the foot stuck at 820 of 844 and a board row scrolling under it
   stayed visible in the 24px strip below (measured in a phone frame on
   2026-09-29, before this rule). The board's own box is what has to reach the
   edge, so the padding is taken off it as a negative margin and put back on the
   foot, which leaves every row exactly where it was drawn and gives the pinned
   one a background all the way down. The number is this rule's own padding plus
   the safe area; the floating desktop panel has its own and does not scroll far
   enough to need any of this. */
html .app .overlay .panel.menu-panel .daily-board {
  margin-bottom: calc(-24px - env(safe-area-inset-bottom));
}
html .app .overlay .panel.menu-panel .board-foot {
  bottom: calc(-24px - env(safe-area-inset-bottom));
  padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
/* AND UNDER A TAB BAR THE PANEL HAS NO BOTTOM PADDING AT ALL (JIO-2016). The bar
   is `position: sticky; bottom: 0` and a sticky box is clamped to its containing
   block's CONTENT edge, so with the frame's 24px (plus the safe area) still on
   the panel the bar stopped 58px above the bottom of an iPhone -- George's
   2026-10-20 phone pass: "a weird space between the buttons and the bottom of
   the phone". The daily board above takes the padding off with a negative
   margin because its foot is a child of a child; the bar is the panel's own last
   child, so the padding simply goes, and the bar's own `padding-bottom:
   env(safe-area-inset-bottom)` is what keeps the five targets clear of the home
   indicator. Chrome's emulation reports every inset as 0 (scripts/probe-phone.ts),
   so the artboards -- `tabbar.mjs` draws the bar with a negative bottom margin
   the size of the frame's padding -- and this rule agree to the pixel there, and
   the 34px only exists on a device. `[data-screen]` is on the selector for its
   specificity alone: the 720px title screen sets its own bottom padding with an
   attribute selector of the same weight, and this has to beat it. */
html .app .overlay .panel.menu-panel.has-tabs[data-screen] { padding-bottom: 0; }

/* A FULL-BLEED MENU STOPS BEING OPAQUE WHEN ~~THE PAGE IS IN A PLACE~~ THERE IS
   ANYTHING BEHIND IT (JIO-1230, corrected by JIO-1703).
   The panel is `width: 100%; height: 100%` filled with --bg, which is how it
   hides the arena behind it -- and it is also how it would hide the world, so
   the menus, Settings, Records, Endless, Versus and the Puzzles page would have
   been the six screens a world did NOT reach. It goes transparent instead, and
   what it used to cover is hidden explicitly.

   THE CONDITION IS `data-behind` AND IT WAS `data-world`, which is JIO-1703.
   The House sets no `data-world` on purpose, so when JIO-1702 hung the Pips
   sheet in the layer these rules did not fire and every menu painted --bg over
   it: title, Start, Settings, Records and How to play, ink on 0.00% of the page
   in both palettes, measured 2026-10-08. The sheet was visible on the one
   surface probe-world-art.ts reads, a played board. ui/world.ts answers
   `data-behind` for a place (its GROUND, drawing or not) and for the sheet.

   VISIBILITY AND NOT `display: none`: the arena's boards are <canvas> elements
   whose size is measured from their laid-out box (main.ts's layout()), and a
   board taken out of flow while a menu is open comes back 0x0. `visibility`
   keeps every box exactly where it was and paints none of it.

   THE CONTRAST WALK STILL WORKS, which is the reason the panel is transparent
   rather than tinted: scripts/probe-theme-contrast.ts composites a run's
   ancestors until it finds an opaque one, so it now walks panel (transparent)
   -> overlay (no background under a menu) -> `.app`, whose flat `--world-wash`
   is the colour actually behind the text. A panel painted with the wash would
   have measured the same number; a panel painted with the GRADIENT would have
   made every run in every menu unmeasurable. */
html[data-behind] .app .overlay .panel.menu-panel { background: transparent; }
/* ...and the sticky head's ground is the wash that is actually behind the
   text, on its own line because test/world.test.js reads the one above
   verbatim. */
html[data-behind] .app .overlay .panel.menu-panel { --head-ground: var(--world-wash, var(--bg)); }
/* ...EXCEPT A PANEL THAT PINS ITS OWN PALETTE, WHICH OWNS ITS GROUND
   (JIO-1703). `show()` puts `data-theme` on a screen whose background does not
   change with the palette -- the Tower's wall is the floors' own dark grounds
   in both -- so its words are set in the palette that drawing is dark in
   (ui/screens.ts). The fill is that pin's other half: re-light the tokens and
   paint none of them and the light page keeps the dark ink, at 1.1:1. It had
   been doing that with a world worn since JIO-1230 -- the six LIGHT runs
   probe-theme-contrast.ts has failed since 2026-09-28 are this screen, every
   one of them, and they pass now. */
html[data-behind] .app .overlay .panel.menu-panel[data-theme] { background: var(--bg); --head-ground: var(--bg); }
html[data-behind] .app:has(> .overlay .panel.menu-panel) > .topbar,
html[data-behind] .app:has(> .overlay .panel.menu-panel) > .arena,
html[data-behind] .app:has(> .overlay .panel.menu-panel) > .puzzle-hud,
html[data-behind] .app:has(> .overlay .panel.menu-panel) > .puzzle-count,
html[data-behind] .app:has(> .overlay .panel.menu-panel) > .replay-hud,
html[data-behind] .app:has(> .overlay .panel.menu-panel) > .footer { visibility: hidden; }
/* §2's h1 and lede, which the compact block takes one rung down on every
   overlay. FOUNDATION §4 says "Screen title: h1 Bungee 32, directly under the
   row" and every phone menu artboard draws it at 32 in the 390 frame, with an
   18px lede under it. The rung down is for the in-game overlays, which share
   a phone with a board; a menu has the whole screen. */
/* ...and `:not(.result-title)` since JIO-1191, which is the ONE thing that
   changed when the verdict became a real heading (audit W8: the result screen
   was the only dialog with no heading in it at all). A verdict is not a screen
   title: it is sized by the two `.result-title` rules above, 40 on a desktop
   and 32 on a phone, and this selector out-specifies both of them four classes
   to two. Excluding it here keeps the cascade the verdict already had rather
   than restating its size at a fifth weight -- one `<p>`-to-`<h2>` change, no
   pixel moved. */
html .app .overlay .panel.menu-panel > h2:not(.result-title) { font-size: var(--t-h1); }
html .app .overlay .panel.menu-panel > .tagline { font-size: var(--t-lede); }
/* ~~And the wordmark, which is an h1~~, ~~and Main.dc.html's own 8/-12/8 rhythm
   around it~~ -- GONE WITH THE WORDMARK (JIO-1967). The main menu's column is
   the mode last played now; the only h1 on it is the hero numeral inside
   `.centre` below, which is not a direct child of the panel and carries its own
   size. `firstGate` still draws a 40px DICELOCK and takes it from `.overlay h1`,
   which is where that size has always been. */

/* THE IDENTITY CHIP (JIO-1275): the main menu's top-left corner, opposite the
   speaker, reading GUEST or who is playing. It is drawn LAST in the markup --
   after the four cards, which is where it sits in the ring the arrows and the
   pad walk -- and lifted out of flow into the corner here, because those are
   two different orders and only one of them is the stylesheet's business.

   THE GUTTER IS `padding: inherit` AND NOT A SECOND COPY OF IT. An absolutely
   positioned box resolves `left: 0` against the panel's PADDING box, which on a
   phone is 20px + the safe area out from the text and on a desktop is
   `max(32px, (100% - 720px) / 2)` -- the rule further down that makes the 720
   column. Inheriting the panel's own padding puts the chip on the column's left
   edge at both widths, and on the notch's right side of it, with the two
   expressions written once each where they already were. `padding-bottom` is
   dropped because the box is pinned to the top and its height is the chip's.

   THE BAND IS `pointer-events: none` AND THE CHIP IS NOT. It spans the whole
   width so the chip can start at the column edge, which puts it over the
   speaker at the other end; without this the top-right corner of the main menu
   would be a mute button that could not be pressed. */
html .app .overlay .panel.menu-panel[data-screen="title"] { position: relative; }
html .app .overlay .panel.menu-panel[data-screen="title"] > .identity {
  position: absolute; top: 0; left: 0; right: 0;
  padding: inherit; padding-bottom: 0;
  margin: 0; flex-direction: row; align-items: center; gap: 0;
  pointer-events: none;
  /* ABOVE THE STICKY BAR. Since JIO-1830 every menu panel's chrome bar is
     `position: sticky` with a z-index (3 then, 4 since the frosted head of
     2026-10-15), and this band sits at the same top edge with no z-index of
     its own -- so the bar, which then carried a 94% ground, painted over the
     chip, and what was left of GUEST was a faint grey box nobody could press
     (build 20, 2026-10-14, seen on the phone). The band spans the bar's whole
     width, which is why it is pointer-events: none above: the speaker under
     it still gets its tap, and only the chip is a target. */
  z-index: 5;
}
/* 44px tall and radius 999 (§3: the hit floor and the chip radius), in the
   display face at --t-small, which is §2's floor and the size the Abilities
   screen's mode chip already wears (JIO-1374). Panel-2 on a 1px line, muted
   text: a status the eye can skip, which is what a chip that is not asking for
   anything has to be.

   `line-height: 42px` AND NOT A FLEX BOX, so a long name can be cut with an
   ellipsis rather than pushing the chip across the row -- `text-overflow` wants
   a block container, and a flex item is not one. The 42 is the 44 less the two
   1px borders. */
html .app .overlay .panel.menu-panel[data-screen="title"] > .identity > .identity-chip {
  pointer-events: auto;
  display: inline-block; height: 44px; max-width: 55%;
  padding: 0 14px; line-height: 42px;
  background: var(--panel-2); color: var(--muted);
  border: 1px solid var(--line); border-radius: 999px;
  font-family: var(--display); font-weight: 400; font-size: var(--t-small);
  letter-spacing: 0.02em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  transition: background-color 120ms, color 120ms, border-color 120ms;
}
html .app .overlay .panel.menu-panel[data-screen="title"] > .identity > .identity-chip:hover {
  color: var(--text); border-color: var(--muted);
}
/* The ring the other four items wear, restated at this weight: `.menu
   button.focused` is two classes and the rule above is four, so the border
   colour there would lose the tie to this one. */
html .app .overlay .panel.menu-panel[data-screen="title"] > .identity > .identity-chip.focused,
html .app .overlay .panel.menu-panel[data-screen="title"] > .identity > .identity-chip:focus-visible {
  border-color: var(--left); box-shadow: 0 0 0 2px var(--left-dim); color: var(--text);
}
.app.reduced-motion .overlay .panel[data-screen="title"] .identity-chip { transition: none; }

/* ---- THE MAIN MENU'S CENTRE (JIO-1967) ------------------------------------

   The first-run canvas's `Main menu · last played Survival`, the frame George
   chose on 2026-10-19: the mode's scene, the best score as a hero with its rank
   beside it, YOU vs THE FIELD under it, and PLAY AGAIN. The bar below reaches
   every other screen (JIO-1966); this is what the screen a player opens most is
   FOR.

   ~~The wordmark and the lede~~ came off for it. A 40px DICELOCK, 8px, an 18px
   lede and its 20px gap is ~90px of a 390x844 column, and the centre needs 579
   of the 660 the panel leaves once the bar and the foot's lockup are paid for
   (measured 2026-10-19). The game's name is still on the screen -- the small
   lockup at the foot, which is where §4 puts the main menu's home link.

   `flex: 1` AND `justify-content: center` IS THE WHOLE LAYOUT. The panel is
   already a flex column with a 20px gap (the menu block above), so this block
   takes what the chrome bar, the lockup and the bar leave and centres its own
   five children in it at the artboard's 14px. It also takes the auto margins
   out of the fight: `.home-lockup` and `.tabbar` both carry `margin-top: auto`,
   which with nothing growing split the slack between them and left the lockup
   floating in the middle of the screen. */
html .app .overlay .panel.menu-panel[data-screen="title"] > .centre {
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column; justify-content: center; gap: 14px;
}
/* THE SCENE IS THE ONE CHILD THAT MAY SHRINK, and it is what makes this column
   survive a short viewport without a media query: 170px is the artboard's
   height (238x170 of the 280x200 file, so nothing is cropped -- `heroScene`'s
   `meet`), and `flex: 0 1 170px` lets it give the height back on a landscape
   phone, where the alternative is a hero number scrolled off the screen. The
   floor is 72, below which the drawing is a smudge and is better gone. */
.overlay .panel[data-screen="title"] > .centre > .centre-art { flex: 0 1 170px; min-height: 72px; position: relative; }
/* WHERE YOU ARE ON THE STAIR (JIO-2057, decisions/0221): the Tower's art is the
   map's own picture of the step the die stands on (`towerCrop()`), `slice`d to
   the same 170 box the scene takes, at the card radius; the floor's label is a
   chip in its corner -- Bungee at the tab bar's 10px over the panel's dark at
   80% -- so the picture says which floor it is of without a word in the hero. */
.overlay .panel[data-screen="title"] .centre-art > .tower-crop { width: 100%; height: 100%; display: block; border-radius: var(--r-card); }
.overlay .panel[data-screen="title"] .centre-floor {
  position: absolute; left: 12px; top: 12px; height: 26px; padding: 0 12px; border-radius: 999px;
  display: inline-flex; align-items: center; background: color-mix(in srgb, var(--bg) 80%, transparent);
  font-family: var(--display); font-weight: 400; font-size: 0.6875rem; letter-spacing: 0.08em; color: var(--text); white-space: nowrap;
}
/* THE HERO IS A WORD AND A NUMBER (`STEP 4`, or MID-BOSS, or GUARDIAN), so it
   is set at 56 rather than the numeral's 88: eight letters of Bungee at 88 are
   wider than the column. Neither it nor the rank beside it may break inside
   itself -- STEP over 3 on two lines read as two heroes on the phone, measured
   2026-10-21 -- so the row wraps between them instead: the rank sits beside the
   step where the column is wide enough (the 1440 frame) and under it where it
   is not (the 350 phone column), on its own baseline. */
.overlay .panel[data-screen="title"] .centre-hero:has(.ch-step) { flex-wrap: wrap; column-gap: 14px; row-gap: 2px; }
.overlay .panel[data-screen="title"] .ch-score.ch-step { font-size: 3.5rem; letter-spacing: 0.02em; white-space: nowrap; }
.overlay .panel[data-screen="title"] .ch-step + .ch-rank { white-space: nowrap; }
/* The label over the number: the artboard's 12/700 at 0.12em, which is
   `.br-you`'s size and the same job. It is a `<p>` and not the heading -- the
   heading is the numeral under it, because that is the biggest claim the screen
   makes and the element scripts/probe-artboards.ts finds on the drawing. */
.overlay .panel[data-screen="title"] .centre-eyebrow {
  margin: 0; font-family: var(--font); font-weight: 700; font-size: 0.75rem;
  letter-spacing: 0.12em; line-height: 1.2; color: var(--muted);
}
/* AND THE EMPTY STATE'S HEADING, which is the mode's name at a screen title's
   own size: a profile with no run has no number to be the hero, and the name is
   what the scene is of. */
.overlay .panel[data-screen="title"] .centre-name {
  margin: 0; font-size: var(--t-h1); text-align: center;
}
/* THE HERO AND ITS RANK SHARE A BASELINE, which is why they are one line and
   not two blocks: 88px of Bungee with an 18px rank hung off its foot is the
   artboard, and `align-items: baseline` is the only way to get it that does not
   depend on either one's box. Tabular figures so a score that ticks over 1,000
   does not shift the rank sideways. */
.overlay .panel[data-screen="title"] .centre-hero {
  margin: 0; display: flex; align-items: baseline; gap: 14px;
}
.overlay .panel[data-screen="title"] .ch-score {
  margin: 0; font-size: 5.5rem; line-height: 1;
  letter-spacing: 0.01em; font-variant-numeric: tabular-nums;
}
.overlay .panel[data-screen="title"] .ch-rank {
  font-family: var(--display); font-weight: 400; font-size: 1.125rem; color: var(--left);
}
/* YOU vs THE FIELD: a card on `--panel` at the 14px radius §3 gives a card,
   holding three `.board-row`s -- the same component every other board in this
   game is drawn with, so a player recognises it as a board. The rows lose their
   rule (three lines, not ten, and the middle one is already a lifted card) and
   keep everything else. */
.overlay .panel[data-screen="title"] .field-card {
  background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
  padding: 14px 10px 10px;
  display: flex; flex-direction: column; gap: 6px;
}
.overlay .panel[data-screen="title"] .fc-head {
  display: flex; justify-content: space-between; align-items: baseline; gap: 10px; padding: 0 6px;
}
.overlay .panel[data-screen="title"] .fc-title {
  flex: none; font-family: var(--display); font-weight: 400; font-size: 0.8125rem;
  line-height: 1.4; letter-spacing: 0.06em; color: var(--muted);
}
/* The gap to the one above, or "Top of the board". It takes what the title
   leaves and ellipsises, because the name in it came back over the network and
   may be twenty characters. */
.overlay .panel[data-screen="title"] .fc-gap {
  min-width: 0; font-size: 0.8125rem; line-height: 1.4; font-weight: 600; color: var(--muted);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.overlay .panel[data-screen="title"] .field-card .board-row { border-bottom-color: transparent; padding: 0 12px; }
/* ...AND `.lift` IS THE TOWER'S GUARDIAN ROW (JIO-1979), on the same two rules
   as the pinned one and under a name that is not a lie: `.you` is the player's
   own row everywhere else in this game, and the row drawn to stand off this
   card is the Guardian at the top of the floor. */
.overlay .panel[data-screen="title"] .field-card .board-row:is(.you, .lift) { border-radius: 10px; }
/* THE RECORD'S DASH (JIO-1980): muted between two figures at 88px, which is
   what the frame draws -- the pair reads as a score line and the dash is not
   one of the two numbers. */
.overlay .panel[data-screen="title"] .ch-dash { color: var(--muted); }
/* THE LAST MATCH'S THREE NUMBERS ARE THE POSTER'S OWN STRIP, re-skinned for a
   card rather than redrawn: three tiles with a gap, not one band with rules
   between its cells, because here it sits INSIDE `.field-card` and the band's
   own borders would be a second frame inside that one. */
.overlay .panel[data-screen="title"] .field-card .poster-strip {
  min-height: 0; background: none; border: 0; gap: 8px; padding: 0 2px;
}
.overlay .panel[data-screen="title"] .field-card .poster-cell {
  background: var(--panel-2); border-radius: 10px; padding: 10px 12px; gap: 2px; border-left: 0;
}
/* ...AND THE LABEL IS 10px HERE AND NOT 13. A poster's strip is the full 390
   across three cells; inside this card they are ~100px, and at the poster's own
   size every one of the three wrapped to three lines and took the card to 144px
   -- which is the frame's compact strip drawn tall. */
.overlay .panel[data-screen="title"] .field-card .poster-key { font-size: 0.625rem; letter-spacing: 0.04em; }
/* PLAY AGAIN is the biggest button on any menu, which is the one thing this
   screen is asking for: 60px against the primary's 52, Bungee 22, and the teal
   glow the artboard draws under it. `.menu` is what puts it in the ring
   (`collectRing`), and `.menu`'s own 16px margins are zeroed here because the
   column's 14px gap is what spaces it. */
.overlay .panel[data-screen="title"] .menu.centre-go { margin: 0; gap: 0; }
.overlay .panel[data-screen="title"] .centre-go .btn-primary {
  height: 60px; border-radius: 12px; font-size: 1.375rem;
  box-shadow: 0 8px 24px color-mix(in srgb, var(--left) 25%, transparent);
}
.app.reduced-motion .overlay .panel[data-screen="title"] .centre-go .btn-primary { box-shadow: none; }
/* THE BATTLE CENTRE'S FIGUREHEAD (JIO-2051, docs/decisions/0213 rules 7-9).
   With no match it is the hero in the scene's slot -- 250px of die over the
   mode's name and the rank's catchphrase -- and shrinks the way the scene did
   (`flex: 0 1`, floor 120) so a short viewport keeps the button. With a
   record it stands 126px tall beside the score, on one row, which is the
   drawing: the face of the computer last fought and the numbers against it. */
.overlay .panel[data-screen="title"] > .centre > .centre-figure { flex: 0 1 250px; min-height: 120px; display: flex; justify-content: center; align-items: flex-end; }
.overlay .panel[data-screen="title"] .centre-figure .figurehead { height: 100%; width: auto; display: block; }
.overlay .panel[data-screen="title"] .centre-line { margin: 0; text-align: center; font-size: 0.9375rem; font-weight: 600; line-height: 1.4; color: var(--text-2); }
.overlay .panel[data-screen="title"] .centre-row { display: flex; align-items: center; gap: 18px; }
.overlay .panel[data-screen="title"] .centre-row .centre-figure.small { flex: none; height: 126px; }
.overlay .panel[data-screen="title"] .centre-row .centre-hero { flex-direction: column; align-items: flex-start; gap: 6px; min-width: 0; }
.overlay .panel[data-screen="title"] .centre-row .ch-score { font-size: 4.5rem; }
/* THE LOSING BOARD IN THE LAST MATCH CARD: the picture left, captioned in the
   side's colour, and the three numbers stacked beside it rather than in a
   strip -- a 102px board and three ~100px tiles do not share 350px. */
.overlay .panel[data-screen="title"] .field-card .fc-gap.won { color: var(--left-text); }
.overlay .panel[data-screen="title"] .field-card .fc-gap.lost { color: var(--danger-text); }
.overlay .panel[data-screen="title"] .field-card .fc-body { display: flex; gap: 14px; align-items: stretch; padding: 0 2px; }
.overlay .panel[data-screen="title"] .field-card .fc-board { flex: none; margin: 0; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.overlay .panel[data-screen="title"] .field-card .fc-board .board-picture { display: block; }
.overlay .panel[data-screen="title"] .field-card .fc-board-label { font-family: var(--display); font-size: 0.625rem; letter-spacing: 0.04em; }
.overlay .panel[data-screen="title"] .field-card .fc-board-label.lost { color: var(--danger-text); }
.overlay .panel[data-screen="title"] .field-card .fc-board-label.won { color: var(--right-text); }
.overlay .panel[data-screen="title"] .field-card .fc-right { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; gap: 8px; }
.overlay .panel[data-screen="title"] .field-card .fc-note { margin: 0; font-size: 0.875rem; line-height: 1.4; color: var(--text-2); }
.overlay .panel[data-screen="title"] .field-card.has-board .poster-strip { flex-direction: column; gap: 4px; padding: 0; }
.overlay .panel[data-screen="title"] .field-card.has-board .poster-cell { flex-direction: row; align-items: baseline; gap: 8px; padding: 0; background: none; border-radius: 0; }
.overlay .panel[data-screen="title"] .field-card.has-board .poster-value { font-size: 1.25rem; }
/* THE STRIP'S FACES ARE PRESSES (JIO-2051): the cell was a span and is a
   44px button, which is §3's floor and what makes a face a tap. The mini is
   centred in it unchanged; the ring and the padlock are as they were. */
.overlay .rank-cell { width: 44px; height: 44px; border-radius: 12px; background: none; padding: 0; cursor: pointer; }
.overlay .rank-cell.focused, .overlay .rank-cell:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; }
.overlay .rank-cell:hover { background: var(--panel-2); }
.overlay .rank-cell:active { transform: scale(0.94); }
.app.reduced-motion .overlay .rank-cell:active { transform: none; }
/* ~~The two mode pages'~~ THE THREE mode pages' phone artboards wrap the scene
   in 4px of padding above and below (`padding: 4px 0` on the hero's row in
   design/pages/modes), which is 8px the desktop artboards do not have. The
   frame block zeroes every child's margin, so the 4 is put back here, under the
   seam only. The Create Lobby page (JIO-1181) is the third, and it was left off
   this list for exactly as long as it took probe-artboards.ts to run: its TIME
   row came out at y=404 against the artboard's 412, which is this 8px and
   nothing else. */
@media (max-width: 719px) {
  html .app .overlay .panel.menu-panel[data-screen="endless"] > .hero-art,
  html .app .overlay .panel.menu-panel[data-screen="lobby"] > .hero-art,
  html .app .overlay .panel.menu-panel[data-screen="versus"] > .hero-art { margin-block: 4px; }
}

/* A DESKTOP IS THE SAME PAGE IN MORE ROOM (§4), which the three desktop
   artboards draw the same way: the frame filled with --bg, 24px above the
   chrome row, and ONE 720px COLUMN centred in it -- EndlessDesktop's and
   VersusDesktop's is `width: 720px` under `align-items: center`, MainDesktop's
   is the 720px grid. That column is what the two `width: min(784px, 100%)`
   panel rules used to widen a 680px card for, screen by screen; with the panel
   the whole viewport it is the PADDING that makes the column, so it is one
   rule for every menu, and a screen that needs no 720 (~~Settings, Records~~
   Settings) gets the same shell FOUNDATION §4 gives all of them: "cards in a
   grid, max 720px wide, centred". RECORDS NEEDS THE 720 SINCE JIO-1578 -- its
   five doors are a 2-column grid up here, same as the main menu's four, so the
   shell is its column and not merely its gutter (`.menu.records`, further
   down). `max(32px, ...)` keeps §3's desktop gutter on a window
   narrower than 784, where the halves would go negative. The percentage is the
   containing block's width, which for a `width: 100%` panel is the viewport --
   the one case where padding-as-column does not drift.

   The screen title is 40 up here (EndlessDesktop and VersusDesktop draw it at
   40 over a 32 phone), the blocks sit 24 apart rather than 20, and the bottom
   is 40 -- all three artboards' numbers. */
@media (min-width: 720px) {
  html .app .overlay .panel.menu-panel {
    --shell: 720px;
    padding: calc(24px + env(safe-area-inset-top)) max(calc(32px + env(safe-area-inset-right)), calc((100% - var(--shell)) / 2)) calc(40px + env(safe-area-inset-bottom)) max(calc(32px + env(safe-area-inset-left)), calc((100% - var(--shell)) / 2));
    gap: 24px; --menu-gap: 24px;
  }
  html .app .overlay .panel.menu-panel > h2:not(.result-title) { font-size: var(--t-hero); }
  /* MainDesktop.dc.html: the speaker row at the top, the lockup at the foot,
     and the centre in the room between them. ~~The two auto margins on the
     outermost blocks are what centres the middle~~ -- `.centre` is `flex: 1`
     and takes that room itself (JIO-1967), at every width, which is one
     mechanism where this was two. */
  /* ~~MainDesktop's wordmark is 64 over a 20px lede~~ -- gone with the wordmark
     (JIO-1967). What is centred in the room now is the mode last played, and
     the column it is drawn in is held to a phone's width so the field card is
     not a 720px-wide table of three rows: 420 is the widest the three rows and
     the 88px numeral read as one block, and it is a judgment rather than a
     number off a drawing -- this canvas has no desktop frame of this screen. */
  html .app .overlay .panel.menu-panel[data-screen="title"] > .centre > * { width: min(420px, 100%); margin-inline: auto; }
  /* The middle block is centred in the room between the speaker row and the
     lockup, so the room has to be the artboard's: 32 at the foot (the mode
     pages' 40 is theirs alone) and a lockup that is its 20px mark tall, not
     the 44px a thumb needs -- a mouse is what is at this width, and the
     coarse-pointer block below still stretches it to 44 for a touch screen. */
  html .app .overlay .panel.menu-panel[data-screen="title"] { padding-bottom: calc(32px + env(safe-area-inset-bottom)); }
}
@media (min-width: 720px) and (pointer: fine) {
  html .app .overlay .panel.menu-panel[data-screen="title"] > .home-lockup { min-height: 20px; }
}

/* THE FIRST SCREEN (JIO-1985): the logo, PLAY! and CONTINUE!, on the ground.
   It is the frame the splash fades onto, so it is the splash's own box -- full
   bleed on --bg, nothing floating, no scrim -- and not a menu panel: the mark
   and the wordmark must land on the pixels the splash left them on. The
   full-bleed rules are repeated here rather than shared with `.menu-panel`
   because that class is what `show()` adds when a screen takes the chrome row,
   and this screen has no back arrow to go in one.

   THE BUTTONS ARE BIGGER THAN §5's. 68 and 60 against 52 and 44, at 26 and 22
   against 18 and 16: this is the only screen in the game with two controls on
   it and nothing else to read, and the canvas draws them at the size that
   makes that obvious. Every other screen keeps §5's sizes, which is why this
   is written against one `data-screen` rather than loosening `.btn`. */
.overlay:has(.panel[data-screen="firstGate"]) { padding: 0; background: none; align-items: stretch; }
html .app .overlay .panel[data-screen="firstGate"] {
  width: 100%; height: 100%; max-height: none;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 18px;
  background: var(--bg); border: none; border-radius: 0;
  padding: calc(16px + env(safe-area-inset-top)) calc(28px + env(safe-area-inset-right)) calc(32px + env(safe-area-inset-bottom)) calc(28px + env(safe-area-inset-left));
}
/* 72 under the wordmark, which is the canvas's gap between the lockup and the
   first button; the 18 above it is the column gap and the loading frame's. */
.overlay .panel[data-screen="firstGate"] > h1 { margin: 0 0 54px; }
.overlay .panel[data-screen="firstGate"] > .menu { display: flex; flex-direction: column; gap: 16px; width: 100%; max-width: 334px; }
.overlay .panel[data-screen="firstGate"] .btn { border-radius: 14px; }
.overlay .panel[data-screen="firstGate"] .btn-primary { height: 68px; font-size: 1.625rem; }
.overlay .panel[data-screen="firstGate"] .btn-secondary { height: 60px; font-size: 1.375rem; }

/* Row 1 (§4): back arrow left, speaker right, 44px each, same spot on every
   screen. The left slot is an empty 44px box on the main menu, which has no
   back arrow -- `space-between` with one child would slide the speaker left on
   exactly that screen. */
/* ~~justify-content: space-between~~ -- the bar is two OR three icons since the ?
   (JIO-2038), and space-between would centre a third. The second child takes
   the slack instead, so the speaker is at the right edge either way and the ?
   sits against it. */
.chrome-bar { display: flex; align-items: center; justify-content: flex-start; height: 44px; margin: 0 0 12px; }
.chrome-bar > :nth-child(2) { margin-left: auto; }
.chrome-help { color: var(--muted); }
.chrome-icon {
  width: 44px; height: 44px; flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  padding: 0; border: 0; border-radius: 10px;
  background: none; color: var(--text); cursor: pointer;
  transition: background-color 120ms, transform 120ms;
}
.chrome-spacer { cursor: default; }
/* The speaker is muted-coloured (§4) and the back arrow is not: one is the way
   out of the screen and the other is a setting that is usually already right. */
.chrome-sound { color: var(--muted); }
.chrome-icon:hover { background: var(--panel-2); color: var(--text); }
.chrome-icon:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; }
.chrome-icon:active { transform: scale(0.92); background: var(--panel-2); }
.chrome-icon[aria-pressed="false"] { color: var(--muted); }
.app.reduced-motion .chrome-icon { transition: none; }
.app.reduced-motion .chrome-icon:active { transform: none; }

/* §5's menu card: 40px icon left, Bungee 20 title, and the one right-aligned
   muted value §5 allows. ~~No subtitle slot -- that is the component's point.~~
   **One muted line where the card has something to say its own name does not**
   -- `.card-sub` below: the Endless speeds since JIO-1316, the main menu's
   START card since JIO-1464. The struck sentence was about the SENTENCES the
   title screen's nine cards carried, which is what the submenus replaced.
   `.menu .card` and not `.card`, because `.menu button` above sets a padding,
   a background and a radius of its own and would otherwise win on specificity. */
.menu .card {
  display: flex; align-items: center; gap: 16px;
  min-height: 64px; padding: 12px 16px;
  background: var(--panel); color: var(--text);
  border: 1px solid var(--line); border-radius: 14px;
  box-shadow: var(--card-shadow);
  text-align: left; text-decoration: none; cursor: pointer;
  transition: border-color 120ms, background-color 120ms, transform 120ms;
}
.card-icon { flex: none; display: inline-flex; color: var(--text); }
/* Title and value on one line where the card is wide enough and stacked where
   it is not. `flex-wrap` rather than a media query: the same card is 157px in
   the mode grid and 640px on a desktop menu, and what decides is the string in
   it, not the viewport. */
.card-body { flex: 1 1 auto; min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 12px; }
.card-title { font-family: var(--display); font-weight: 400; font-size: var(--t-card); line-height: 1.1; }
/* TABULAR FIGURES IN THE VALUE SLOT (JIO-1084, and `design/pages/modes`'
   NOTES.md asks for them by name on the Endless speeds). §5 allows one
   right-aligned number here and this is the first screen to draw one: three
   scores stacked in a column with proportional digits set their commas at three
   different x positions, which reads as three sloppy rows rather than as a
   board. It is on the slot and not on that one screen because every number that
   ever lands here is a number stacked under another one. */
.card-value { font-family: var(--font); font-weight: 600; font-size: var(--t-small); line-height: 1.4; color: var(--muted); font-variant-numeric: tabular-nums; }
/* ONE MUTED LINE UNDER THE TITLE (George, 2026-09-16): the Endless page's
   ~~speed~~ dice cards say ~~"Play Forever", "+ Faster Rise", "+ Seven-faced
   Die"~~ "Six-faced Die" and "Seven-faced Die" (0217) here
   and nothing else (screens.ts speedSub), the Versus chooser's two cards say
   which press is which, and since JIO-1315 each of Start's four says what its
   mode is. `flex-basis: 100%` is
   what puts it on its own line under the title and the value, whatever width
   the card is -- the same wrap `.card-body` already relies on. IT IS ALSO WHAT
   MAKES THE LINE NARROW IN A ROW-SHAPED CARD: 100% of a body the art has
   already taken 96px from, which is why the four-card grid withholds the line
   below 500px of height rather than wrapping it to three (the rule at the foot
   of the mode-card block).

   AND THE MAIN MENU'S START CARD SINCE JIO-1464: "Next: <the first unearned
   rung's line>". It is THIS slot and not a rule of its own -- a `#m-start
   .card-sub` would be a second 14/600 muted line for the next screen to copy
   whichever of the two it found first. */
.card-sub { flex: 1 0 100%; font-family: var(--font); font-weight: 600; font-size: var(--t-small); line-height: 1.4; color: var(--muted); }
.menu .card:hover { border-color: var(--muted); background: var(--panel-2); }
.menu .card.focused, .menu .card:focus-visible { border-color: var(--left); box-shadow: 0 0 0 2px var(--left-dim), var(--card-shadow); outline: none; }
.menu .card.selected { border-color: var(--right); }
.menu .card:active { transform: translateY(1px); background: var(--panel-2); }
.app.reduced-motion .menu .card { transition: none; }
.app.reduced-motion .menu .card:active { transform: none; }
/* THE TIGHT CARD IS GONE (JIO-1078), and it was always written down as the
   thing this ticket would remove. It was a 44px card -- the §3 hit-target
   floor, not the §3 card floor -- for the title screen's row of five options,
   because nine 64px cards is ~200px past the fold of an 844px frame. The main
   menu is four cards now and every one of them is §5's own size, so the bound
   has no caller and the variant is not kept warm against a screen that might
   want it: a second card size nothing draws is a second card size the next
   screen reaches for. */

/* The mode card (§5): the scene across the card's top, the title under it,
   padding 8/16/16. A mode with no scene of its own centres its 40px icon in
   the same box; nothing draws one that way since JIO-1079 moved Tutorial to
   How to play, and the slot stays because it is §5's, not this screen's. */
/* ~~padding 8/16/16 on the card~~ -- the padding is the TITLE's, not the
   card's: Start.dc.html draws the scene edge to edge across a card with
   `overflow: hidden` (8px above it, nothing beside it) and pads the title
   8/16/16 underneath, which is 206px of card; the padding on the card put the
   scene inside 16 (14 on a phone) of side inset and the card at 193
   (JIO-1086, scripts/probe-artboards.ts). The radius clips the scene's top
   corners, which is what `overflow: hidden` is for. */
.menu .card.mode-card { flex-direction: column; align-items: stretch; gap: 0; padding: 0; overflow: hidden; }
.menu .card.mode-card .mode-art { margin-top: 8px; border-radius: 0; }
.menu .card.mode-card .card-body { padding: 8px 16px 16px; }
/* 7:3, which is the 350x150 the Play menu's NOTES.md records George choosing
   for Start ("so all three fit an 844 frame without scrolling"). It was 10:3
   for one ticket, while these cards were the title screen's 2x2 of four and
   every 10px of art was 20px of screen (JIO-1078); three cards one press in
   can afford the crop the artboard draws. The scene fills the box by `slice`
   (ui/chrome.ts), so what changes here is the shape of the hole, never the
   drawing. */
.mode-art { display: block; position: relative; width: 100%; aspect-ratio: 7 / 3; overflow: hidden; border-radius: 10px; }
.mode-glyph { display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; color: var(--muted); }
.menu .card.mode-card .card-body { flex: none; }
/* FOUR CARDS TAKE THE SHALLOWER CROP AT EVERY HEIGHT (JIO-1232), and the rule
   counts them rather than being told how many there are. The Play menu's
   NOTES.md records George choosing 350x150 "so all three fit an 844 frame
   without scrolling", and the Tower makes it four: at 7:3 a card is 206px and
   four of them are 796px of the 686 an 844 frame has left under the row, the
   title and their gaps -- 110px past the fold, which is the JIO-930 fault with
   pictures on it. 10:3 is the crop these same cards wore as the title screen's
   2x2 (JIO-1078), so it is a shape this art has already been drawn in; it brings
   the card to 159px and the four to 672, which fits with 14 to spare.

   `:has()` RATHER THAN A CLASS SAYING "FOUR". The crop is decided by the count,
   so the count is what the selector asks about -- a `.modes.four` would be the
   number written twice, in a stylesheet and in the markup, and the day a fifth
   mode lands one of the two would be right. The Versus chooser's two cards
   (`.modes.two`) are untouched, which is the point: they fit at 7:3 and the
   shallower crop would be spending their picture for room they do not need. */
/* ~~10:3~~ **4:1, AND THE GRID'S GAP GOES TO 8 WITH IT (JIO-1315)**, because
   each of the four cards now carries a muted line under its title as well --
   ~21px apiece, against the 11px of an 844 frame the 10:3 grid had spare. The
   struck ratio stays because it is still the crop four cards wear with a title
   and nothing else. `scripts/probe-viewport.ts` is what fails when a
   card goes under the fold, which is the check that says this rule is right. */
.modes:has(> .mode-card:nth-child(4)) { gap: 8px; }
.modes:has(> .mode-card:nth-child(4)) .mode-art { aspect-ratio: 4 / 1; }
.modes:has(> .mode-card:nth-child(4)) .card.mode-card .card-body { padding-bottom: 12px; }
/* A SHORT PHONE HELD UPRIGHT TAKES A SHALLOWER CROP. §4's frame is 390x844 and
   the 350x150 (7:3) crop is measured against it; a 375x667 phone -- the SE, and
   probe-viewport.ts's shortest upright viewport -- is 177px shorter, and three
   179px cards put PUZZLES 36px under the fold (measured 2026-09-12). 10:3 is
   the crop these same cards wore as the title screen's 2x2 (JIO-1078), so the
   shallower box is a shape this art has already been drawn in rather than a new
   one, and it brings the card to 139px. Nothing changes above 700px, which is
   every frame the artboards are drawn at.
   IT SITS BEFORE THE SIDEWAYS RULE BELOW, which is also a max-height query and
   at equal specificity would otherwise lose to this one on source order. */
@media (max-height: 700px) {
  .mode-art { aspect-ratio: 10 / 3; }
  /* ...AND FOUR CARDS TAKE THE SIDEWAYS SHAPE THERE (JIO-1232), one breakpoint
     earlier than a phone held sideways does. At 10:3 a card is 139px on a 375x667
     SE and four of them are 592px of the 509 that frame has left, so the crop
     that rescued three cannot rescue four -- and the next shape down is already
     drawn: the mode card as the menu card, a 96px box of art with the title
     beside it, which is 78px and puts the four at 348. It spends the width a
     short phone has instead of the height it has not, which is the argument the
     landscape rule below is made of. */
  .modes:has(> .mode-card:nth-child(4)) .card.mode-card { flex-direction: row; align-items: center; gap: 12px; padding: 12px 16px; }
  .modes:has(> .mode-card:nth-child(4)) .mode-art { width: 96px; flex: none; aspect-ratio: 16 / 9; margin-top: 0; border-radius: 10px; }
  .modes:has(> .mode-card:nth-child(4)) .card.mode-card .card-body { padding: 0; flex: 1 1 auto; }
}
/* A PHONE HELD SIDEWAYS HAS NO HEIGHT TO GIVE THE SCENE. Measured 2026-09-11 at
   667x375, a mode card with the art above its title is 139px, so the second row
   of the 2x2 lands 101px under the fold -- the JIO-930 fault with pictures on
   it, and what `probe-viewport.ts` fails on. Below main.ts's
   LANDSCAPE_MAX_HEIGHT the mode card therefore takes the menu card's own shape:
   the scene moves to the left as a 96px box and the title sits beside it, so it
   spends the width a sideways phone has instead of the height it has not. The
   card comes out 78px, and both rows end 40px clear of the fold.
   500 is repeated from main.ts rather than derived, because a media query
   cannot read a module constant -- and it is safe to repeat for the reason the
   constant gives: the tallest phone in landscape is 430 and the shortest tablet
   is 768, so nothing sits in the band. */
@media (max-height: 500px) {
  .menu .card.mode-card { flex-direction: row; align-items: center; gap: 12px; padding: 12px 16px; }
  .mode-art { width: 96px; flex: none; aspect-ratio: 16 / 9; }
  /* ...AND THE FOUR START CARDS DROP THEIR SUBTITLE HERE (JIO-1315), the same
     withholding the Vs Computer page already does with its hero at 667x375.
     `.card-sub` is `flex: 1 0 100%` of a body the art has taken 96px from, so
     sideways the line comes out 96-200px wide and wraps to three (measured
     2026-09-22) -- and this shape exists because the frame has no height, which
     is the wrong thing to spend on wrapped copy. Upright at 375x667 it is kept.
     THE CHOOSER'S TWO ARE UNTOUCHED, which is why this is scoped by the count:
     telling a bolt from a chain at card size (JIO-1180) is what they are for. */
  .modes:has(> .mode-card:nth-child(4)) .card-sub { display: none; }
  /* The upright card's 8 above the scene and 8/16/16 around the title are the
     column layout's; a row has the card's own 12/16 back. */
  .menu .card.mode-card .mode-art { margin-top: 0; border-radius: 10px; }
  .menu .card.mode-card .card-body { padding: 0; }
  .menu .card.mode-card .card-body { flex: 1 1 auto; }
}

/* THE START PAGE IS FOUR BANDS (JIO-1831, George 2026-10-12; direction C of
   the Start page canvas, docs/decisions/0138). Every rule above this one about
   four cards -- the 4:1 crop, the gap of 8, the row shape under 700px -- was a
   way of fitting four pictures and four sentences inside an 844 frame, and
   George's answer was to stop explaining the modes: "show all modes without
   needing to scroll; we don't have to explain what each section does". So the
   four are rows on hairlines rather than cards: no border, no ground, no
   radius, the scene a 150px block on the left and the name beside it at 24, and
   the four SHARE THE HEIGHT under the title (`flex: 1 1 0`) so the fourth is
   on the screen whatever the frame -- 171px apiece on a 390x844 phone, 127 on
   a 375x667 SE. `html .app .overlay` on every rule, because the count rules
   above and `.app.compact`'s `padding: 0` on a mode card are what these have
   to win against, and they are written later in the file.

   EDGE TO EDGE ON A PHONE: the hairlines run the frame's width, through the
   panel's 20px gutters, which is what the drawing shows; the row's own padding
   puts the scene back on the gutter. Above 720 the column is the 420px one the
   three-card page already keeps, and the lines run its width.

   THE TAG STAYS OVER THE ART. It is `modeCard()`'s one word in the corner of
   the scene (JIO-1232), inside the button so the row is read as "ENDLESS,
   NEXT", and the band gives it nothing new to do.

   THE MENU FILLS THE PANEL for this one screen, which is what lets the bands
   share a height at all: `.menu` is a flex column of the panel's and takes
   `flex: 1 1 auto` here, with `min-height: 0` so it may shrink to the room it
   is given rather than to its content. */
html .app .overlay .panel.menu-panel[data-screen="start"] > .menu { flex: 1 1 auto; min-height: 0; margin: 0; }
html .app .overlay .modes.bands {
  display: flex; flex-direction: column; gap: 0; height: 100%; min-height: 0;
  border-top: 1px solid var(--line);
}
@media (max-width: 719px) {
  html .app .overlay .modes.bands {
    margin-left: calc(-20px - env(safe-area-inset-left));
    margin-right: calc(-20px - env(safe-area-inset-right));
  }
}
html .app .overlay .modes.bands .card.mode-card {
  flex: 1 1 0; min-height: 0;
  flex-direction: row; align-items: center; gap: 18px;
  padding: 0 calc(20px + env(safe-area-inset-right)) 0 calc(20px + env(safe-area-inset-left));
  border: 0; border-bottom: 1px solid var(--line); border-radius: 0;
  background: transparent; box-shadow: none;
}
html .app .overlay .modes.bands .card.mode-card .mode-art {
  width: 150px; height: 118px; aspect-ratio: auto; flex: none; margin: 0; border-radius: 14px;
}
html .app .overlay .modes.bands .card.mode-card .card-body { padding: 0; flex: 1 1 auto; }
html .app .overlay .modes.bands .card.mode-card .card-title { font-size: 1.5rem; line-height: 1.05; }
/* The band's own states, because the card's are a border it no longer has:
   hover is the panel's lift, focus and press are a 2px inset ring and the
   same lift. */
html .app .overlay .modes.bands .card.mode-card:hover,
html .app .overlay .modes.bands .card.mode-card:active { background: var(--panel-2); border-color: var(--line); transform: none; }
html .app .overlay .modes.bands .card.mode-card.focused,
html .app .overlay .modes.bands .card.mode-card:focus-visible { border-color: var(--line); box-shadow: inset 0 0 0 2px var(--left); }
/* A SHORT UPRIGHT PHONE (the 375x667 SE) has 127px of band: the scene comes
   down to 120x90 so the row keeps 18px of air above and below it. */
@media (max-height: 700px) {
  html .app .overlay .modes.bands .card.mode-card .mode-art { width: 120px; height: 90px; }
}
/* ...AND SIDEWAYS THE BANDS ARE A 2x2, which is what every four-card layout on
   this screen does below 500px of height (the `.modes` rule and
   probe-viewport.ts's column count): four rows in 217px of height are 54px
   each, which is no room for a scene. Two columns of two, the scene at 96x64
   and the name at 20, each cell still a band on its hairline. */
@media (max-height: 500px) {
  html .app .overlay .modes.bands { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; }
  html .app .overlay .modes.bands .card.mode-card { gap: 12px; padding: 0 12px 0 calc(20px + env(safe-area-inset-left)); }
  html .app .overlay .modes.bands .card.mode-card:nth-child(even) { padding: 0 calc(20px + env(safe-area-inset-right)) 0 12px; }
  html .app .overlay .modes.bands .card.mode-card .mode-art { width: 96px; height: 64px; border-radius: 10px; }
  html .app .overlay .modes.bands .card.mode-card .card-title { font-size: 1.25rem; }
  /* AND THE BAND KEEPS ITS STATE LINE SIDEWAYS (JIO-1968). The rule above that
     drops `.card-sub` at four cards is JIO-1315's, and what it withheld was a
     SENTENCE explaining the mode; this line is a figure, and in a 2x2 the body
     still has ~220px beside a 96px scene. It bites only on a four-band draw --
     three bands never match that selector. */
  html .app .overlay .modes.bands .card-sub { display: block; }
}

/* §5's buttons, and the row control below it, are scoped to `.overlay` for the
   reason `.menu .card` is: `.menu button` earlier in this file sets a padding,
   a background and a 6px radius on EVERY button inside a menu, and it outranks
   a bare `.btn`. A primary button dropped into a menu would have come out in
   `--panel-2` rather than teal, and a chevron would have come out as a bordered
   box -- both only when they were finally used, which is the Play menu
   milestone rather than here.
   §5's buttons. Primary is 52px, teal, ink text, Bungee 18, ONE PER SCREEN --
   a rule about screens, which is why it is written here and not enforced by the
   function that draws them. Secondary is 44px, a 1px line border, text colour,
   Bungee 16. */
.overlay .btn {
  display: flex; align-items: center; justify-content: center;
  /* `text-align` as well as `justify-content`: the first centres the label as a
     flex item, the second centres it WITHIN itself, and `.menu button` above
     sets it left. A label that fits is identical either way -- CHANGE OPPONENT
     beside HISTORY does not fit one line at 390px, and came out ragged left. */
  text-align: center;
  width: 100%; border-radius: 10px; cursor: pointer;
  font-family: var(--display); font-weight: 400; line-height: 1.1;
  transition: filter 120ms, border-color 120ms, background-color 120ms, transform 120ms;
}
.overlay .btn-primary { height: 52px; background: var(--left); color: var(--ink); border: 1px solid var(--left); font-size: var(--t-lede); }
.overlay .btn-secondary { height: 44px; background: none; color: var(--text); border: 1px solid var(--line); font-size: var(--t-body); }
.overlay .btn-primary:hover { filter: brightness(1.08); }
/* A DISABLED MENU BUTTON HAS TO LOOK DISABLED (JIO-1171). There was no rule for
   one until a rematch vote made a primary button say WAITING FOR THEM: it came
   out in full teal, indistinguishable from the button that had been pressable a
   moment earlier, and the same was true of the Daily card's PLAY IT on a day
   whose board could not be read (JIO-1090). Not in the focus ring since
   JIO-1087, which is the keyboard's half of the same fact -- this is the half an
   eye can see, and it is what stops a player pressing a button that has already
   taken their answer. The hover brightening goes with it: a control that lights
   up under the pointer says it is live. */
.overlay .btn:disabled { opacity: 0.55; cursor: default; }
.overlay .btn-primary:disabled:hover { filter: none; }
.overlay .btn-secondary:disabled:hover { border-color: var(--line); background: none; }
.overlay .btn-secondary:hover { border-color: var(--muted); background: var(--panel-2); }
/* ---- THE THIRD AND FOURTH KINDS, BOTH FOR ONE SCREEN (JIO-1277) -----------

   TERTIARY IS A LABEL AND NOT A BUTTON SHAPE, and that is the point of adding
   a kind rather than reusing `secondary`. DELETE ACCOUNT sits under SIGN OUT on
   the Account screen: two bordered boxes in a column read as two things a
   player is being offered, and the second of them is not an offer -- it is the
   way out for somebody who has already decided. Borderless, muted, and the
   body size, so it is findable and never the next thing the eye lands on.
   App Store Review Guideline 5.1.1(v) requires the route to exist inside the
   app; nothing requires it to compete with the screen.

   IT KEEPS THE 44px BOX. The border is gone and the target is not: §4's minimum
   is about a thumb and has nothing to do with how loud a control looks, and a
   28px text link is the failure that rule was written against.

   DANGER IS THE SECONDARY WITH THE ACCENT ON IT, and it is `--danger-text`
   rather than `--danger` for the reason FOUNDATION.md's colour section gives:
   `#ef4444` is 4.22:1 on dark `panel-2`, which is a fail for a run of text, and
   this button is a run of text. The BORDER takes the base accent -- a border is
   not text and the bright one is the point of it. */
.overlay .btn-tertiary {
  height: 44px; background: none; color: var(--muted); border: 1px solid transparent;
  font-size: var(--t-body);
}
.overlay .btn-tertiary:hover { color: var(--text); background: var(--panel-2); }
.overlay .btn-tertiary:disabled:hover { color: var(--muted); background: none; }
.overlay .btn-danger {
  height: 44px; background: none; color: var(--danger-text); border: 1px solid var(--danger);
  font-size: var(--t-body);
}
.overlay .btn-danger:hover { background: color-mix(in srgb, var(--danger) 14%, transparent); }
.overlay .btn-danger:disabled:hover { background: none; }
/* THE APP'S TWO SIGN-IN BUTTONS (JIO-1453, ui/screens.ts `providerButton`).
   Each is drawn to its brand's rule and not to §5, because the brand's rule is
   what an App Store reviewer and a Google console check the button against:
   Apple's HIG for a web-drawn Sign in with Apple button -- the logo, the
   system face, black on light or white on dark, a corner radius, 44pt tall --
   and Google's for Sign in with Google -- the four-colour G on a white tile,
   the words in a 500-weight sans, #747775 border on light, its own dark
   variant (#131314 / #8E918F / #E3E3E3), the G never recoloured. The label is
   TITLE CASE in the DOM and stays that way here: `text-transform` is a paint,
   but the brands ask for their words as written, and Bungee has no lowercase
   to draw them in, so these two alone take the system face.
   `.menu button` earlier sets Bungee, left alignment and a 6px radius on every
   button in a menu and outranks a bare `.btn`, which is why these are scoped
   the way `.overlay .btn` above is. */
.overlay .btn-apple, .overlay .btn-google {
  height: 48px; gap: 10px; border-radius: 10px;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
  font-size: 1.0625rem; font-weight: 500; letter-spacing: 0; text-transform: none;
}
.overlay .btn-apple .brand-mark { width: 20px; height: 20px; flex: 0 0 auto; }
.overlay .btn-google .brand-mark { width: 20px; height: 20px; flex: 0 0 auto; }
/* Dark is the default palette (`:root, [data-theme="dark"]`): Apple's white
   button and Google's dark variant. */
.overlay .btn-apple { background: #fff; color: #000; border: 1px solid #fff; }
.overlay .btn-google { background: #131314; color: #e3e3e3; border: 1px solid #8e918f; }
.overlay .btn-google .brand-mark { background: #fff; border-radius: 50%; padding: 2px; box-sizing: border-box; }
.overlay .btn-apple:hover { filter: brightness(0.94); }
.overlay .btn-google:hover { background: #1f1f20; }
.overlay .btn-apple:disabled:hover, .overlay .btn-google:disabled:hover { filter: none; background: revert-layer; }
/* And on the light palette, the brands' light variants: Apple's black button
   and Google's white one. `[data-theme="light"]` is the one hook, because it is
   the one hook the palette itself is written under at the top of the file --
   ~~and `prefers-color-scheme` is what an unpinned page takes~~, which JIO-1455
   deleted along with the unpinned page. */
[data-theme="light"] .overlay .btn-apple { background: #000; color: #fff; border-color: #000; }
[data-theme="light"] .overlay .btn-google { background: #fff; color: #1f1f1f; border-color: #747775; }
[data-theme="light"] .overlay .btn-google:hover { background: #f5f5f5; }
.overlay .btn.focused, .overlay .btn:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; }
.overlay .btn:active { transform: translateY(1px); }
.app.reduced-motion .overlay .btn { transition: none; }
.app.reduced-motion .overlay .btn:active { transform: none; }
.app.reduced-motion .overlay .btn-primary:hover { filter: none; background: var(--left-dim); color: var(--text); }

/* §5's row control: label left, value centred between two 44px chevrons. The
   VALUE cycles forward too -- three targets, not two -- because a value that
   looks pressable and is not is the trap the row exists to avoid. Vs Computer's
   Opponent and Time rows are built on this by milestone 4; nothing ships on it
   yet, which is why it is drawn here and asserted in test/chrome.test.js
   rather than only being looked at. ~~and Settings' Theme row~~ -- Theme is a
   segmented control (below) and not a row, since JIO-1080: the artboard draws
   one and design/pages/menu/NOTES.md records the decision. */
.menu .card.row-control { justify-content: space-between; cursor: default; flex-wrap: wrap; gap: 4px 12px; }
/* ONE MUTED LINE UNDER THE ROW (JIO-1316): the same `.card-sub` the mode cards
   and the Endless speeds draw, which is `flex: 1 0 100%` and therefore takes
   the row's second line wherever it is put. `flex-wrap` and a 4px ROW gap are
   the whole of the change -- the 12px column gap between the label and the
   cycler is §5's and is unchanged -- so a row with no sub is the 64px card it
   has always been and a row with one is that card plus a 14/1.4 line. */
.rc-cycle { display: flex; align-items: center; flex: none; }
.overlay .rc-step, .overlay .rc-value {
  min-height: 44px; border: 0; background: none; color: var(--text);
  cursor: pointer; border-radius: 10px; padding: 0;
  transition: background-color 120ms, transform 120ms;
}
.overlay .rc-step { width: 44px; display: inline-flex; align-items: center; justify-content: center; color: var(--muted); }
/* `display: inline-flex` and the centring pair, because `.menu button` above
   made this a flex container with `justify-content: space-between` -- under
   which `text-align: center` centres nothing, since the label is a single
   anonymous flex item pushed to the start. The artboard's value is centred
   in a 88px box between the chevrons; 96 is that box with §3's 4px either
   side of it. */
.overlay .rc-value { min-width: 96px; padding: 0 8px; display: inline-flex; align-items: center; justify-content: center; text-align: center; font-family: var(--font); font-weight: 600; font-size: var(--t-body); }
.overlay .rc-step:hover, .overlay .rc-value:hover { background: var(--panel-2); color: var(--text); }
.overlay .rc-step.focused, .overlay .rc-value.focused,
.overlay .rc-step:focus-visible, .overlay .rc-value:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; }
.overlay .rc-step:active, .overlay .rc-value:active { transform: scale(0.94); background: var(--panel-2); }
.app.reduced-motion .overlay .rc-step, .app.reduced-motion .overlay .rc-value { transition: none; }
.app.reduced-motion .overlay .rc-step:active, .app.reduced-motion .overlay .rc-value:active { transform: none; }

/* THE PORTRAIT CONTROL (2026-10-20, docs/decisions/0213) -- the row control
   above with a figurehead where the value was (ui/figurehead.ts), the Vs
   Computer page's one row since the TIME row went. The card turns into a
   column: label top-left, the three targets across the middle with the
   portrait between the chevrons, the rank's NAME in Bungee 24, ONE line under
   it (the catchphrase, or what opens a locked rank), and THE STRIP -- one 24px
   mini face per rank, the one on screen ringed in the player's teal, the
   locked ones hatched with a padlock and one pip per win still owed. The
   portrait button is a 44px target and then some: 176x214 of die, which is
   what §3 calls a hero and what this page has instead of one. `.row-control`'s
   wrap and 4px row gap are overridden rather than inherited because a column
   has no second line to wrap onto. */
.menu .card.row-control.portrait-control { flex-direction: column; align-items: center; gap: 10px; padding: 16px 12px; }
.menu .card.row-control.portrait-control:hover { border-color: var(--line); background: var(--panel); }
.menu .card.portrait-control .card-title { align-self: flex-start; padding-left: 8px; }
.menu .card.portrait-control .rc-cycle { width: 100%; justify-content: space-between; }
.overlay .rc-portrait { min-width: 0; padding: 4px 8px; border-radius: 16px; }
.overlay .rc-portrait .figurehead { display: block; width: auto; height: 214px; }
.rank-name { display: inline-flex; align-items: center; gap: 10px; font-family: var(--display); font-size: var(--t-h2); line-height: 1.1; color: var(--text); }
.rank-name.is-locked { color: var(--muted); }
/* A step brighter than the muted profile sentence it replaced, because it is
   the rank SPEAKING and not the page describing it: text-2 at 15px. */
.menu .card.portrait-control .card-sub.rank-line { flex: none; text-align: center; color: var(--text-2); font-size: 0.9375rem; padding: 0 8px; }
.menu .card.portrait-control.is-locked .card-sub.rank-line { color: var(--muted); }
.rank-strip { display: flex; align-items: center; justify-content: center; gap: 12px; padding-top: 2px; }
.rank-cell { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 10px; border: 2px solid transparent; }
.rank-cell.is-viewed { border-color: var(--left); }
/* A locked rank being LOOKED AT wears a muted ring, never the teal: the teal
   says "START plays this", and on a locked view START is LOCKED. */
.rank-cell.is-locked.is-viewed { border-color: var(--muted); }
.rank-cell .rank-mini { display: block; }
.rank-cell .lock-mark { position: absolute; right: -5px; bottom: -5px; width: 14px; height: 14px; margin: 0; padding: 2px; border-radius: 50%; background: var(--panel-2); border: 1px solid var(--line); color: var(--text-2); box-sizing: border-box; }
.win-pips { display: inline-flex; gap: 4px; align-items: center; }
.rank-cell .win-pips { position: absolute; left: 50%; bottom: -12px; transform: translateX(-50%); }
.win-pip { display: block; width: 7px; height: 7px; border-radius: 50%; border: 1.5px solid var(--muted); box-sizing: border-box; }
.win-pip.is-won { background: var(--left); border-color: var(--left); }
.rank-name .win-pip { width: 9px; height: 9px; }
/* START on a locked view: the primary's height in the panel's quiet colours,
   with the padlock, disabled. Not `.btn-secondary`, which is a press. */
.overlay .btn-locked { height: 52px; background: var(--panel-2); color: var(--muted); border: 1px solid var(--line); font-size: var(--t-lede); gap: 10px; cursor: default; justify-content: center; }
.overlay .btn-locked:disabled, .overlay .btn-locked:disabled:hover { opacity: 1; background: var(--panel-2); color: var(--muted); border-color: var(--line); }
.overlay .btn-locked .lock-mark { width: 20px; height: 20px; }
/* The Play a Friend page's line where its TIME row was (2026-10-20). */
.overlay .lobby-line { text-align: center; padding: 0 12px; }

/* ---- Settings' three controls (JIO-1080) --------------------------------
   design/pages/menu/Settings.dc.html: a THEME card with a segmented control
   inside it, a REDUCED MOTION card that is a switch, and a SOUND card whose
   switch sits over a volume slider.

   EVERY STATE HERE IS PAINTED OFF AN ARIA ATTRIBUTE and never off a class.
   `aria-checked` is what a screen reader announces, so reading the same
   attribute for the colour means the picture and the announcement cannot
   disagree -- and it keeps the chosen segment off `.menu button.selected`
   above, which is the OPPONENT's amber and not what a chosen theme is. */

/* The 48x28 pill: `panel-2` with a muted knob left, teal with an ink knob
   right. The knob travels with a transform so reduced motion can stop it,
   which a `justify-content` flip could not.
   THE KNOB IS 20px AND NOT THE 22 THE ARTBOARD WRITES. 48x28 there is the
   track's content box, with the 3px padding and the 1px border outside it, so
   the pill it actually renders is 56x36; 48x28 is the number written on both
   the artboard and in this file, so that is the one the pill is, and 20px is
   what its inside then measures. */
.switch { flex: none; width: 48px; height: 28px; border-radius: 999px; padding: 3px; background: var(--panel-2); border: 1px solid var(--line); box-sizing: border-box; }
.switch-knob { display: block; width: 20px; height: 20px; border-radius: 999px; background: var(--muted); transition: transform 140ms, background-color 140ms; }
[aria-checked="true"] > .switch { background: var(--left); border-color: var(--left); }
[aria-checked="true"] > .switch > .switch-knob { background: var(--ink); transform: translateX(20px); }
.app.reduced-motion .switch-knob { transition: none; }

/* A card that IS a switch: §5's card with the pill where the muted value
   sits. The title takes the space between, so the pill is hard right on a
   390px phone and on a 640px desktop card alike.
   `gap: 12px` and not §5's 16, which is what `.row-control` above already
   does for the same reason: a card whose right-hand side is a control has one
   less place to spend the width. On §4's 390px frame that leaves the title
   208px -- a 350px card, less its two 1px borders, less the 14px sides
   `.app.compact .menu button` gives a card on a phone, less the 40px icon, the
   48px pill and the two 12px gaps. The longest label on this screen is OPPONENT
   WINDOW at 207.5px of Bungee 20 (REDUCED MOTION is 189.5), so it clears by
   half a pixel; with 16px gaps it is 200 and OPPONENT WINDOW wraps to two
   lines. Measured over CDP at 390x844 on 2026-09-22, which is also where
   design/pages/menu and design/pages/tower's Settings drawings take their 14.
   ~~"REDUCED MOTION is 189px ... the card gives it 190 ... with 16px gaps it is
   182"~~ is struck rather than cut: the 189 still measures, but the 190 and the
   182 are a budget this card has not had for some time, and REDUCED MOTION
   stopped being the longest label when JIO-1323 added OPPONENT WINDOW above.
   Narrower than 390 it wraps anyway and that is
   allowed to happen: the alternative is a label that fits on the frame of
   record and breaks on a 375px phone, which is worse than one that breaks on
   both. */
.menu .card.switch-card { gap: 12px; }
.menu .card.switch-card .card-title { flex: 1 1 auto; }

/* A card whose head sits over a second row. The card keeps §5's panel, border
   and radius; the head keeps §5's 64px and its padding, and the padding moves
   off the card onto the parts so the segmented control and the slider can each
   have the inset the artboard draws. */
.menu .card.stack-card { flex-direction: column; align-items: stretch; gap: 0; padding: 0; cursor: default; }
.menu .card.stack-card .card-head { display: flex; align-items: center; gap: 16px; min-height: 64px; padding: 12px 16px; }
.menu .card.stack-card .card-head .card-title { flex: 1 1 auto; }
/* The head of the Sound card is a button, and `.menu button` earlier in this
   file would otherwise give it a panel, a border and a 6px radius inside a
   card that already has all three. */
.overlay .menu .card.stack-card .switch-head { background: none; border: 0; border-radius: 14px 14px 0 0; color: var(--text); cursor: pointer; text-align: left; font-size: inherit; }
.overlay .menu .card.stack-card .switch-head:hover { background: var(--panel-2); }
.menu .card.stack-card .switch-head.focused, .menu .card.stack-card .switch-head:focus-visible { outline: 2px solid var(--left); outline-offset: -2px; box-shadow: none; }
/* And the DOOR row at the bottom edge (JIO-1825), which is the switch head's
   treatment upside down: `.menu button` above would give it a panel, a border
   and a 6px radius inside a card that already has all three. It takes
   `.slider-row`'s 72px inset so the label starts under the title and not under
   the icon, and §3's 44px floor as a height -- the row it replaced was a 44px
   slider in a 12px-bottom box, so the card is the height it was. */
.overlay .menu .card.stack-card .card-open {
  display: flex; align-items: center; gap: 12px; width: 100%;
  min-height: 44px; padding: 0 16px 12px 72px;
  background: none; border: 0; border-radius: 0 0 14px 14px;
  color: var(--muted); text-align: left; font-size: inherit; cursor: pointer;
}
.overlay .menu .card.stack-card .card-open .card-sub { flex: 1 1 auto; }
.overlay .menu .card.stack-card .card-open:hover { background: var(--panel-2); }
.menu .card.stack-card .card-open.focused, .menu .card.stack-card .card-open:focus-visible { outline: 2px solid var(--left); outline-offset: -2px; box-shadow: none; }

/* §5's segmented control: a `panel-2` trough with 4px between the segments and
   the chosen one filled teal with ink text. 36px tall inside a 3px trough, so
   the control is 44px with its card padding -- §3's hit floor is met by the
   card row and not by the segment alone, which is why the segments may be the
   36px the artboard draws. */
.segmented { display: flex; gap: 4px; align-self: flex-start; margin: 0 16px 16px; padding: 3px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 10px; }
.overlay .menu .segmented .seg {
  position: relative;
  height: 36px; padding: 0 12px; border: 0; border-radius: 8px;
  background: none; color: var(--muted);
  font-family: var(--font); font-weight: 700; font-size: var(--t-small); line-height: 1.1;
  display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
  transition: background-color 120ms, color 120ms;
}
/* THE PAINT IS 36px AND THE TARGET IS 44px, WHERE THE POINTER IS FINE. The
   Settings artboard draws a 36px segment and FOUNDATION §3 floors a hit target
   at 44 -- a rule and a picture, disagreeing -- so the overlay grows the box a
   cursor can land on by 4px above and below, into the card's own padding,
   which is empty either way. The drawing is left exactly as approved; growing
   the segment itself would have made the control 52px tall, heavier than the
   head row above it.
   4px vertically and 0 horizontally: the segments are 4px apart, so two
   overlays that grew sideways would each claim half of the other's gap.
   ON A COARSE POINTER THE SEGMENT ITSELF GROWS, and the overlay switches off --
   see the (pointer: coarse) block at the foot of this file, which has floored
   every menu button at 44px since JIO-836 and is the game's answer everywhere
   else that an artboard and a thumb disagree. scripts/probe-touch.ts measures
   the element's own box, which is the right thing to measure on the device
   that block is for. */
.overlay .menu .segmented .seg::after { content: ''; position: absolute; inset: -4px 0; }
.overlay .menu .segmented .seg:hover { color: var(--text); }
.overlay .menu .segmented .seg[aria-checked="true"] { background: var(--left); color: var(--ink); }
.overlay .menu .segmented .seg.focused, .overlay .menu .segmented .seg:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; }
.app.reduced-motion .overlay .menu .segmented .seg { transition: none; }

/* THE WORLD PICKER (JIO-1230), under the Settings screen's WORLD head. ~~Six
   tiles~~ ~~-- since JIO-1323 it is House plus whatever the Tower has paid out,
   and with nothing paid out there are no tiles at all and `.world-note` below
   stands in its place --~~ EVERY WORLD SINCE JIO-1933, House and twenty-three
   places, the unwon ones padlocked -- two across on a phone, each showing the
   place it chooses rather than naming it: the swatch IS the world's ground gradient, off `--wo-top` and
   `--wo-bottom`, which is the same trick a level tile plays with
   `--puzzle-tile-*`.

   44px IS THE PAINT, not an overlay on a smaller one. A tile is two lines of
   text beside a swatch and clears §3's hit floor on its own, so unlike the
   segmented control above there is nothing to reconcile.

   A LOCKED TILE IS DIMMED AND STILL READABLE. It is the one on this screen a
   player has a reason to read closely -- it is the only place that says which
   floor opens the world -- so the state line keeps `--muted` and the fade is on
   the swatch, which is the part that is a promise rather than information.
   `cursor: default` says "this does nothing" without `pointer-events`, which
   would take the tile out of the pointer's reach while leaving the ring's Enter
   on it. */
/* AND THE CLEAR PICKER UNDER IT (JIO-1737), which is THE SAME TILE and is
   written as the same rules rather than as a copy of them: a 28px cell beside a
   name over a line, two across, 52 tall. The two cards sit under one head on
   one tab, so a geometry that drifted would be two pickers a reader could tell
   apart by accident. What the 28 holds is the difference -- a world's is its own
   GROUND and a look's is a line drawing, because what a look chooses is a
   movement and no still swatch shows one -- and that is the only rule below
   that is one picker's and not both.

   ~~NO LOCKED STATE HERE, and it is an assertion: `lookRows()` reports what the
   profile holds and nothing else, so `[data-locked]` is a selector with nothing
   to match on a clear tile.~~ THE LOCKED STATE IS SHARED TOO (JIO-1933), which
   is the same rule read the same way: `lookRows()` reports every clear in the
   registry now, and what a padlocked clear looks like may not be a second answer
   to what a padlocked world looks like. The dim falls on the 28px cell in both
   -- the swatch and the mark are the PROMISE, and the state line beside them is
   the information, so it keeps `--muted` and full contrast. */
/* ~~ONE RULE FOR BOTH PICKERS~~ -- TWO SINCE JIO-2028 (docs/decisions/0203). The
   CLEAR tile keeps the 28px mark beside a name over a line, two across: a look
   is a movement and a glyph is a mark for it. The WORLD tile is a SQUARE OF
   THE WORLD'S OWN ART, four across on a phone and seven in the 720 column,
   with the name and the floor under it -- because the drawing IS the thing
   being chosen, and a 28px gradient beside a sentence could not show a place.
   The padlock, the dim and the teal ring read the same on both. */
.look-picker { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 0 16px 16px; }
.overlay .menu .look-picker .look-opt {
  display: grid;
  grid-template-columns: 28px minmax(0, 1fr);
  grid-template-rows: auto auto;
  align-items: center;
  gap: 0 10px;
  min-height: 52px;
  padding: 8px 10px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel-2);
  color: var(--text);
  font-family: var(--font);
  text-align: left;
  cursor: pointer;
  transition: border-color 120ms, background-color 120ms;
}
/* THE WORLD GRID: 4 tiles across a phone's 318px (73 each with 8px gaps) and
   7 across the 720 shell's 656. The tile is a flex column -- the square, the
   name, the floor -- with no box of its own: the square is the control's
   paint, and the teal ring on the chosen one is drawn on it. */
.world-picker { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 0 16px 16px; }
@media (min-width: 720px) { .world-picker { grid-template-columns: repeat(7, minmax(0, 1fr)); } }
.overlay .menu .world-picker .world-opt {
  display: flex; flex-direction: column; align-items: stretch; gap: 6px;
  min-width: 0; min-height: 44px; padding: 0; border: 0; border-radius: 12px;
  background: none; color: var(--text); font-family: var(--font); text-align: left; cursor: pointer;
}
/* THE SQUARE: the world's ground as the gradient, its art over it in its own
   ink -- `currentColor` on the layer, the way the page's own world layer takes
   it -- at a TILE's opacities rather than the page's. The page dims a texture
   to 10% because it sits behind every card; a 73px square has nothing over it
   and reads at 35% / 70%, which is what makes the Forge's fire and the
   Observatory's chart tell the two apart at a glance. The art is 900x900
   `slice` (FOUNDATION §10), so a square shows the middle of it, where every
   motif sits. */
.overlay .menu .world-picker .wo-swatch {
  position: relative; display: block; aspect-ratio: 1; overflow: hidden;
  border-radius: 12px; border: 1px solid var(--line); box-sizing: border-box;
  background: linear-gradient(180deg, var(--wo-top, var(--panel)), var(--wo-bottom, var(--panel-2)));
  color: var(--wo-ink, var(--muted));
  transition: box-shadow 120ms, border-color 120ms;
}
.overlay .menu .world-picker .wo-swatch > svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.overlay .menu .world-picker .wo-swatch .texture { opacity: 0.35; }
.overlay .menu .world-picker .wo-swatch .motif { opacity: 0.7; }
/* THE HOUSE HAS NO GROUND AND NO ART: `panel-2` with the bar's plain die in it. */
.overlay .menu .world-picker .world-opt[data-value="house"] .wo-swatch { background: var(--panel-2); }
.overlay .menu .world-picker .world-opt[data-value="house"] .wo-swatch::after {
  content: ''; position: absolute; left: 30%; top: 30%; width: 40%; height: 40%; border-radius: 18%;
  background: var(--panel); border: 1px solid var(--line); box-sizing: border-box;
}
.overlay .menu .world-picker .wo-name { font-weight: 700; font-size: 0.75rem; line-height: 1.2; overflow-wrap: anywhere; }
.overlay .menu .look-picker .lo-mark {
  grid-row: 1 / span 2;
  width: 28px; height: 28px;
  display: flex; align-items: center; justify-content: center;
}
.overlay .menu .look-picker .lo-mark .look-glyph { display: block; }
.overlay .menu .look-picker .lo-name { font-weight: 700; font-size: var(--t-small); line-height: 1.2; overflow-wrap: anywhere; }
/* THE FLOOR APPLIES TO THE CLEAR TILE'S LINE (JIO-1239): `--t-small` IS the
   floor (see the token). THE WORLD TILE'S TWO LINES ARE 12 AND 11 (JIO-2028)
   -- under the floor, and deliberately: a 73px column cannot carry 14px of
   "Hall of Mirrors" and "locked · Floor 18" without wrapping every tile to
   three lines, and the name is said at full size in the tile's accessible
   name and in the WORLD card's head. `scripts/probe-theme-contrast.ts` holds
   the colour, which is unchanged. */
.overlay .menu .world-picker .wo-state { font-size: 0.6875rem; font-weight: 600; line-height: 1.2; color: var(--muted); margin-top: -2px; }
.overlay .menu .look-picker .lo-state { font-size: var(--t-small); line-height: 1.2; color: var(--muted); }
.overlay .menu .world-picker .world-opt:hover .wo-swatch,
.overlay .menu .look-picker .look-opt:hover { border-color: var(--left); }
.overlay .menu .world-picker .world-opt[aria-checked="true"] .wo-swatch { border-color: var(--left); box-shadow: inset 0 0 0 1px var(--left); }
.overlay .menu .look-picker .look-opt[aria-checked="true"] { border-color: var(--left); background: var(--panel); box-shadow: inset 0 0 0 1px var(--left); }
.overlay .menu .world-picker .world-opt[data-locked="true"],
.overlay .menu .look-picker .look-opt[data-locked="true"] { cursor: default; }
.overlay .menu .world-picker .world-opt[data-locked="true"]:hover .wo-swatch,
.overlay .menu .look-picker .look-opt[data-locked="true"]:hover { border-color: var(--line); }
.overlay .menu .world-picker .world-opt[data-locked="true"] .wo-swatch,
.overlay .menu .look-picker .look-opt[data-locked="true"] .lo-mark { opacity: 0.45; }
/* THE PADLOCK, IN THE STATE LINE AND SIZED TO IT (JIO-1933). The tab strip's
   own 14px drawing (`.tab-lock` at the foot of this file), at the line's own
   size and colour: `1em` rather than 14px because the state line is `--t-small`
   and a glyph that stayed put while the type grew would be the one thing on the
   tile that did not scale. Nudged 2px below the baseline, which is where a
   14px square sits level beside 14px text, and 4px clear of the word. */
.lock-mark {
  width: 1em; height: 1em; flex: none; vertical-align: -2px; margin-right: 4px;
  fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round;
}
.overlay .menu .world-picker .world-opt.focused .wo-swatch, .overlay .menu .world-picker .world-opt:focus-visible .wo-swatch,
.overlay .menu .look-picker .look-opt.focused, .overlay .menu .look-picker .look-opt:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; }
.overlay .menu .world-picker .world-opt:focus-visible { outline: none; }
.app.reduced-motion .overlay .menu .world-picker .wo-swatch,
.app.reduced-motion .overlay .menu .look-picker .look-opt { transition: none; }

/* THE PALETTE PICKER (JIO-2028, docs/decisions/0203): two tiles, each a
   PICTURE OF THIS PAGE in that palette. `data-theme` on the picture pins the
   tokens (the landing page's hero pins its board the same way), so what the
   light tile shows on a dark page is what the stylesheet would do to every
   screen -- the ground, a card with the player's own die, two pills, a row of
   dice and the primary button, at a sixth of the size. 4:5, like a phone's
   top. The chosen one is ringed in teal on the picture, as the world tile is. */
.palette-picker { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 0 16px 16px; }
.overlay .menu .palette-picker .palette-opt {
  display: flex; flex-direction: column; align-items: stretch; gap: 6px;
  min-width: 0; min-height: 44px; padding: 0; border: 0; border-radius: 12px;
  background: none; color: var(--text); font-family: var(--font); text-align: left; cursor: pointer;
}
.overlay .menu .palette-picker .pal-shot {
  display: flex; flex-direction: column; gap: 8px; aspect-ratio: 4 / 5; padding: 12px; overflow: hidden;
  border-radius: 12px; border: 1px solid var(--line); box-sizing: border-box;
  background: var(--bg); color: var(--text); transition: box-shadow 120ms, border-color 120ms;
}
.overlay .menu .palette-picker .pal-row { display: flex; justify-content: space-between; }
.overlay .menu .palette-picker .pal-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--muted); }
.overlay .menu .palette-picker .pal-title { font-family: var(--display); font-size: 0.75rem; line-height: 1.05; color: var(--text); }
.overlay .menu .palette-picker .pal-card {
  display: flex; align-items: center; gap: 6px; padding: 6px;
  background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--card-shadow);
}
.overlay .menu .palette-picker .pal-card .avatar-die { display: block; flex: none; }
.overlay .menu .palette-picker .pal-lines { display: flex; flex-direction: column; gap: 4px; flex: 1 1 auto; min-width: 0; }
.overlay .menu .palette-picker .pal-line { height: 6px; width: 60%; border-radius: 3px; background: var(--text); }
.overlay .menu .palette-picker .pal-line.thin { height: 4px; width: 85%; background: var(--muted); }
.overlay .menu .palette-picker .pal-pills { display: flex; gap: 4px; }
.overlay .menu .palette-picker .pal-pill {
  height: 16px; padding: 0 7px; display: flex; align-items: center; border-radius: 999px;
  font-family: var(--display); font-size: 0.4375rem; line-height: 1;
  background: var(--panel); border: 1px solid var(--line); color: var(--text);
}
.overlay .menu .palette-picker .pal-pill.on { background: var(--left); border-color: var(--left); color: var(--ink); }
.overlay .menu .palette-picker .pal-dice { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; }
.overlay .menu .palette-picker .pal-die { aspect-ratio: 1; border-radius: 6px; background: var(--panel-2); display: flex; align-items: center; justify-content: center; }
.overlay .menu .palette-picker .pal-die .avatar-die { width: 70%; height: 70%; display: block; }
.overlay .menu .palette-picker .pal-cta { margin-top: auto; height: 18px; border-radius: 6px; background: var(--left); }
.overlay .menu .palette-picker .pal-name { font-weight: 700; font-size: 0.75rem; line-height: 1.2; }
.overlay .menu .palette-picker .palette-opt:hover .pal-shot { border-color: var(--left); }
.overlay .menu .palette-picker .palette-opt[aria-checked="true"] .pal-shot { border-color: var(--left); box-shadow: inset 0 0 0 1px var(--left); }
.overlay .menu .palette-picker .palette-opt.focused .pal-shot, .overlay .menu .palette-picker .palette-opt:focus-visible .pal-shot { outline: 2px solid var(--left); outline-offset: 2px; }
.overlay .menu .palette-picker .palette-opt:focus-visible { outline: none; }
.app.reduced-motion .overlay .menu .palette-picker .pal-shot { transition: none; }

/* THE CLEAR CARD'S OWN BOARD (JIO-1932), under the head and over the tiles.

   NO WIDTH AND NO HEIGHT HERE, the reward screen's rule for the same canvas one
   screen over: the RENDERER writes them off the cell it was resized to
   (`ui/lookSample.ts`'s 6x4 at 36 with `MINI_PAD`, which is 224x188), and the
   artboards read the same three constants. CENTRED rather than stretched,
   because a board that sized itself to the card would be a different shape per
   phone. The paint is shared with `.look-prize .look-sample` below. The 16 under
   it is the gap to the tiles and the head's own 12px padding is the gap above,
   so the card keeps the 16px rhythm every other card on this tab has. */
.overlay .menu .look-stage { display: flex; justify-content: center; margin: 0 16px 16px; }

/* ~~THE WORLD CARD WITH NO TILES IN IT (JIO-1323)~~ -- GONE WITH THE CARD
   (JIO-1933). The one muted line that stood where the grid would be is the grid
   again, every world padlocked until it is won, so `.world-note` is a class
   nothing draws and is deleted rather than kept: it is a layout and not a
   decision, and ui/screens.ts holds the copy it carried. */

/* THE SPEED CHIPS (JIO-1081) -- design/pages/menu/HighScores.dc.html draws
   EASY / MEDIUM / HARD across the top of the board, each `flex: 1`, 44px tall,
   radius 999, the chosen one filled teal with ink text and the other two
   `panel` with a `line` border and muted text.

   44px IS THE PAINT HERE, not a paint inside an overlay, which is the one thing
   that separates this from the segmented control above it. A segment is 36px
   because it sits inside a card whose own row is already 44; a chip is the
   screen's own control with nothing around it, so the artboard's height and
   §3's hit floor are the same number and there is nothing to reconcile.

   The chosen chip is read off `aria-checked`, which is what a screen reader
   announces -- never off `.selected`, which is the OPPONENT's amber in this
   file (`.menu button.selected`) and not what a chosen speed is. */
.chips { display: flex; gap: 8px; }
.overlay .menu .chips .chip {
  flex: 1 1 0; min-width: 0;
  height: 44px; padding: 0 8px; border-radius: 999px;
  background: var(--panel); color: var(--muted); border: 1px solid var(--line);
  font-family: var(--display); font-weight: 400; font-size: var(--t-body); line-height: 1.1;
  display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
  transition: background-color 120ms, color 120ms, border-color 120ms;
}
.overlay .menu .chips .chip:hover { color: var(--text); border-color: var(--muted); }
.overlay .menu .chips .chip[aria-checked="true"] { background: var(--left); color: var(--ink); border-color: var(--left); }
.overlay .menu .chips .chip.focused, .overlay .menu .chips .chip:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; }
.app.reduced-motion .overlay .menu .chips .chip { transition: none; }

/* ~~THE TUTORIAL ENTRY SCREEN (JIO-1092)~~, ~~THE COMPLETION SCREEN (JIO-1094)~~
   and the completion screen's WORLD card (JIO-1391) were the eighteen-step
   lesson's, and went with it (JIO-2021). */

/* ---- THE LOOK REWARD SCREEN (JIO-1738) ---------------------------------- */

/* `design/pages/results`' two LookReward artboards. ~~The block above~~ (the
   lesson's WORLD card, gone with it in JIO-2021) was the same screen's shape for
   a WORLD -- a mark, the thing itself, one line saying where it is worn -- and
   this is it for a LOOK, with the one difference that makes
   this screen exist: the thing itself MOVES, so where the world has a 44px
   swatch this has a board.

   IT IS NOT IN A `.card`, and that is the difference from the world block. A
   world's swatch is a preview inside the Settings card it will be changed from;
   a 224px board is the subject of the screen, not a row in a box -- and the
   CLEAR card under it is the chooser, which is where the card belongs. */
/* NO MARGIN, BECAUSE THIS BLOCK IS A CHILD OF THE PANEL AND THE PANEL'S GAP IS
   WHAT SPACES IT. `html .app .overlay .panel.menu-panel > * { margin-block: 0 }`
   out-specifies anything written here four classes to two, so the `14px 0 18px`
   this rule carried was a number the screen never drew -- which is worse than no
   number, because the next reader measures the live 20px gap, finds 14 written
   down, and goes looking for the rule that is winning. */
.overlay .look-prize {
  display: flex; flex-direction: column; align-items: center; gap: 10px;
}
.overlay .look-prize .lp-head {
  display: flex; align-items: center; gap: 10px; color: var(--text);
}
/* AND THE MARK IS A FLEX ITEM AND NOT A LINE OF TEXT, which is the CLEAR card's
   own rule one block up (`.look-picker .lo-mark .look-glyph { display: block }`)
   and is here for the same reason it is there: an inline <svg> sits on a
   baseline, so a 32px glyph in a span with no display of its own makes a 38px
   line box and takes six pixels of descender space with it. Measured against
   `design/pages/results/LookReward.dc.html` on 2026-10-07: the head was 38 live
   against the drawing's 32, and everything under it -- the board, the CLEAR card
   -- sat nine pixels low. Two drawings of one glyph on one screen family may not
   differ by whether somebody remembered this. */
.overlay .look-prize .lp-mark { display: flex; align-items: center; }
.overlay .look-prize .lp-mark .look-glyph { display: block; }
.overlay .look-prize .lp-name {
  font-family: var(--display); font-weight: 400; font-size: var(--t-card); line-height: 1.1;
  letter-spacing: 0.02em;
}
/* `--board-bg` AND NOT `--panel`: a board is its own surface and is darker than
   a card, which is the one thing a drawing of a board on a panel gets wrong in
   a way the next reader copies (design/pages/tower/build.mjs says the same).
   The size is the RENDERER's -- it writes `style.width`/`height` off the cell
   it was resized to (ui/lookSample.ts's 6x4 at 36) -- so there is no width
   here to disagree with it.

   ONE PAINT FOR TWO SCREENS (JIO-1932): Settings' CLEAR card draws the same
   canvas, and a second copy of these three lines is how one board ends up a
   shade off the other. Where it sits is `.look-stage`'s rule, in the pickers. */
.overlay .look-prize .look-sample,
.overlay .menu .look-stage .look-sample {
  display: block; border-radius: 10px;
  background: var(--board-bg); border: 1px solid var(--line);
}
/* The state line, sized like `.rw-state` above and for its reason: it says the
   look is yours, not a footnote. Centred because the board over it is. */
.overlay .look-prize .lp-state {
  font-size: var(--t-small); line-height: 1.3; color: var(--muted); text-align: center;
}
/* ===== HOW TO PLAY (JIO-1096) ==============================================
   design/pages/howtoplay's five artboards, which
   `scripts/probe-artboards.ts` measures this against box for box. Everything
   below is one of those drawings' numbers; `design/pages/howtoplay/build.mjs`
   carries the same ones as inline CSS, because it renders the artboards from
   the same strings and the same figures the screen does (ui/rules.ts). */

/* THE TABS ARE A PILL SEGMENTED CONTROL, which is §4's control with three
   things changed and NOTES.md is where each one was decided: the trough is a
   999px pill rather than a 10px box, each tab is `flex: 1` across the full
   width rather than shrink-wrapped, and the label is Bungee 14 rather than
   Nunito 14/700 -- 14 because three of these words do not fit a 390px row at
   16. The 4px trough padding and the 4px gap are the control's own.

   NO 44px OVERLAY on the segment (`::after`), because there is nothing to
   reconcile here: the tab's own paint is 40px and the coarse-pointer block at
   the foot of this file takes it to 44 on a thumb. The Settings control needs
   the overlay because its 36px segment sits inside a card; this is the
   screen's own control with the row to itself. */
.overlay .menu.rules-tab-row { margin: 0; }
.overlay .menu .segmented.rules-tabs {
  align-self: stretch; margin: 0; padding: 4px; gap: 4px; border-radius: 999px;
}
.overlay .menu .segmented.rules-tabs .seg {
  flex: 1 1 0; min-width: 0; height: 40px; padding: 0 4px; border-radius: 999px;
  font-family: var(--display); font-weight: 400; font-size: var(--t-small); line-height: 1.1;
}
.overlay .menu .segmented.rules-tabs .seg::after { content: none; }

/* THE SECTIONS SIT 20px APART AND NOT `.menu`'s 8, because on the artboard they
   are children of the frame and the frame's gap is §4's 20. The group is still
   ONE `.menu` so every card is inside the element ui/screens.ts builds the
   focus ring from -- and so scripts/probe-artboards.ts reads them, which looks
   for `.menu .card`. */
.overlay .menu.rules-cards { margin: 0; gap: 20px; }
.overlay .menu.rules-foot { margin: 0; }

/* §5's card turned on its side: the figure at the left, the title and ONE
   sentence beside it. `min-height: 0` because the 64px floor is for a row a
   thumb presses and these are read, not pressed -- the figure is 90 tall and
   makes the card's height on its own. */
.overlay .menu .card.rules-card {
  align-items: flex-start; gap: 14px; padding: 14px; min-height: 0; cursor: default;
}
.overlay .menu .card.rules-card:hover { border-color: var(--line); background: var(--panel); }
.overlay .menu .card.rules-card:active { transform: none; background: var(--panel); }
.rules-fig { flex: none; display: block; line-height: 0; }
.rules-fig > svg { display: block; width: 120px; height: 90px; }
.rules-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.rules-text { font-size: var(--t-body); line-height: 1.5; color: var(--text); }

/* THE NINE ARE ONE CARD, not nine: a table read down the page, where nine
   shadowed boxes would be the same list with eight more edges in it. The rows
   are separated by a hairline, which is what the first row's `border-top`
   under the title is doing as well. */
.overlay .menu .card.rules-nine { flex-direction: column; }
.ability-rows { display: flex; flex-direction: column; }
.ability-row { display: flex; gap: 12px; align-items: center; padding: 10px 0; border-top: 1px solid var(--line); }
.ar-glyph {
  width: 40px; height: 40px; flex: none; border-radius: 10px;
  background: var(--tint, var(--left)); color: var(--ink);
  display: inline-flex; align-items: center; justify-content: center;
}
.ar-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.ar-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ar-name { font-family: var(--display); font-weight: 400; font-size: 1.125rem; line-height: 1.1; }
/* A CHIP SAYS ITS MODE IN WORDS, and the colour is the second carrier and never
   the only one: VS COMPUTER in the computer's amber and ENDLESS in the player's
   teal, which is the pairing the two boards already wear. `-text` rather than
   the raw accents, because these are WORDS -- FOUNDATION §1's rule and the
   reason those tokens exist.
   ~~`font-size: 12px`~~ IS `--t-small`, AND THIS WAS THE FLOOR'S BLIND SPOT
   (JIO-1188). Thirteen runs on the help screen's Abilities tab were drawn at
   12px, against a scale whose own comment twenty lines into this file says 14px
   IS THE FLOOR for anything a player reads -- and a mode chip is two words a
   player reads. It survived because the probe that enforces the floor never
   measured this tab: scripts/probe-landing.ts drove the help tabs through
   `screens.tabs`, which is `.tabs button`, and `rules()` draws a segmented
   control -- so the array was empty, the loop ran zero times, and three tabs
   were reported as measured since JIO-1062 while none of them was. Both probes
   now press `.rules-tabs .seg` and assert the count is 3 (2026-09-17). The
   struck size is kept because 12 was a deliberate choice here, argued from §1
   in the sentence above it, and a reader who finds 14 without this would put it
   back. `.ar-head` already wraps, which is what absorbs the two extra px. */
.ar-chip {
  font-size: var(--t-small); font-weight: 700; letter-spacing: 0.04em; line-height: 1.4;
  border: 1px solid currentColor; border-radius: 999px; padding: 1px 7px;
}
.ar-chip[data-mode="versus"] { color: var(--right-text); }
.ar-chip[data-mode="solo"] { color: var(--left-text); }
.ar-text { font-size: 0.9375rem; line-height: 1.4; color: var(--text); }

/* A DESKTOP IS THE SAME PAGE IN MORE ROOM, and this is the one menu whose shell
   is 1160 rather than §4's 720: `design/pages/howtoplay/NOTES.md` line 15 is
   George's record of it, and it is the page's own shape rather than a licence
   -- six sections of a 120px figure plus a sentence do not read as two columns
   inside 720. The tabs and the button are held to 360 so neither becomes a
   1160-wide control. */
@media (min-width: 720px) {
  html .app .overlay .panel.menu-panel[data-screen="rules"] { --shell: 1160px; }
  /* THE TWO CARDS IN A ROW ARE THE SAME HEIGHT, which is the grid's own
     default and not a choice made here: `align-items: start` was tried first
     and it is what makes a two-column page look like two unrelated columns of
     boxes. The artboard draws them stretched, and a shorter card carrying more
     white under its sentence is what a grid is for. */
  .overlay .menu.rules-cards {
    display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px;
  }
  .overlay .menu.rules-tab-row, .overlay .menu.rules-foot { width: 360px; }
}

/* The volume slider, and the 72px left inset is the artboard's: the track
   starts where the card's title does, clear of the 40px icon above it. */
.slider-row { display: flex; align-items: center; gap: 12px; padding: 0 16px 12px 72px; }
/* A muted game still sets its volume -- the slider is live and the number
   beside it says "· off" (ui/screens.ts's volumeLabel). The row is dimmed so
   the reason is visible before the number is read. */
.menu .card.stack-card[data-on="false"] .slider-row { opacity: 0.55; }
/* 44px TALL AND A 6px TRACK, for the reason the segments carry an overlay: the
   artboard draws a 24px thumb on a 6px line and §3 floors the target at 44, so
   the box is 44 and the drawing inside it is unchanged. A range input centres
   its track in its own height, and the thumb is centred on the track, so the
   whole 44px band drags. */
.overlay .slider {
  flex: 1 1 auto; min-width: 0; height: 44px; margin: 0; padding: 0;
  background: none; border: 0; cursor: pointer;
  -webkit-appearance: none; appearance: none;
}
/* The track is one gradient and `--pct` is the stop: a range input has no
   standard way to colour the part behind its thumb, and the two vendor track
   pseudo-elements below cannot read the element's own value. ui/chrome.ts sets
   it inline for the first paint and ui/screens.ts moves it on every step. */
.overlay .slider::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: linear-gradient(to right, var(--left) var(--pct, 50%), var(--panel-2) var(--pct, 50%)); }
.overlay .slider::-moz-range-track { height: 6px; border-radius: 3px; background: linear-gradient(to right, var(--left) var(--pct, 50%), var(--panel-2) var(--pct, 50%)); }
.overlay .slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 24px; height: 24px; margin-top: -9px; border: 0; border-radius: 999px; background: var(--text); box-shadow: 0 2px 6px rgba(0,0,0,0.4); }
.overlay .slider::-moz-range-thumb { width: 24px; height: 24px; border: 0; border-radius: 999px; background: var(--text); box-shadow: 0 2px 6px rgba(0,0,0,0.4); }
.overlay .slider.focused, .overlay .slider:focus-visible { outline: 2px solid var(--left); outline-offset: 4px; border-radius: 3px; }
/* Mono for a number that is read at a glance, at --t-small and not --t-mono:
   this is a percentage a player reads on a menu screen, and 14px is the floor
   for those (see the type scale above). */
.slider-value { flex: none; min-width: 4ch; text-align: right; font-family: var(--mono); font-size: var(--t-small); color: var(--muted); }

/* THE START SCREEN'S THREE MODES (JIO-1079) -- design/pages/menu/Start.dc.html.
   ~~The 2x2 of JIO-930~~: it was four cards in two columns while it was the
   title screen and for the one ticket that moved it here, and the artboard
   draws three full-width cards in one column, each with a 350x150 scene over
   its title. A 155px-wide card is why the 2x2 cropped its art to 10:3 and why
   its titles could not be read at Bungee 20; one column is what buys the crop
   NOTES.md asks for.
   `.title-menu` went with the fourth card: it was this grid's wrapper on the
   title screen, it set a gap the grid already sets, and nothing else ever
   used it. */
/* No margin: the frame's gap puts the first mode card at the artboard's 134
   (Start.dc.html), and the 12 this carried put it at 146 (JIO-1086). */
.modes { display: grid; gap: 12px; margin: 0; }
/* TWO COLUMNS WHERE THERE IS NO HEIGHT, exactly as the main menu takes them at
   the same 500px (below): a phone held sideways is 375-430px tall, and three
   stacked cards under a 32px title do not fit that however short the card
   gets. The third card is then a full-width row under the two, which is what
   `auto-fill` would give up in exchange for a track count that collapses. */
@media (max-height: 500px) {
  .modes { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
}
/* ...and on a DESKTOP the same design in more room rather than a wider one
   (§4). The cards are held to a phone's column width: a 616px card is a 584px
   scene 250px tall, and three of those is 900px of a 900px viewport -- the
   artboard's design, scrolling, which is the one thing the crop exists to
   prevent. CENTRED, with the title over it (JIO-1136): ~~left, not centred: the
   artboard puts the heading and the cards on the same gutter~~ -- George,
   validating the redesign on a desktop on 2026-09-16, "the Start page should be
   centered on web". A 420px column on the left gutter of a 720px panel leaves
   300px of nothing on the right, and the main menu one press back is centred
   (MainDesktop.dc.html), so this is the same page in the same place rather than
   the one screen that hugs the edge. The title comes with it, the way the
   title screen's h1 does, so the column and its heading are one layout. */
@media (min-width: 720px) {
  /* `width: 100%` because .modes is a flex item of .menu: auto margins on
     the cross axis size it to its content instead of stretching it, and the
     cards came out 282px wide (measured 2026-09-16 at 1440x900). */
  .modes { width: 100%; max-width: 420px; margin-inline: auto; }
  html .app .overlay .panel.menu-panel[data-screen="start"] > h2 { width: fit-content; margin-inline: auto; }
  /* ...EXCEPT A GRID OF TWO, which takes the shell whole (JIO-1180). The
     Versus chooser has two cards, and a 420px column of two is a desktop page
     with one card's worth of width used and the second card pushed under the
     fold of nothing -- the ChooserDesktop artboard puts them side by side in
     §4's 720. Three cards keep the phone column: 720/3 is a 240px card, which
     is narrower than the 282px the Start page already draws. */
  .modes.two { max-width: none; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
}

/* THE TWO MODE PAGES' HERO (JIO-1084, JIO-1085) -- design/pages/modes' Endless
   and Versus phone (dark, light) and desktop artboards. Both boards are the
   same page in two modes: row 1, the name in Bungee 32, the mode's own scene as
   the hero, and then what that mode is chosen with -- three plain speed cards
   on Endless, two row controls and one START on Vs Computer.

   THE HERO IS THE WHOLE DRAWING, WHICH IS WHAT SEPARATES IT FROM `.mode-art`.
   A mode card's scene is a 7:3 CROP of a 280x200 file (`slice`, ui/chrome.ts),
   because three cards with pictures have to fit an 844 frame together; a mode
   PAGE has one picture and the artboard draws all of it, at 350x250 on the
   phone. So the box is the file's own 7:5 and nothing is cropped out of it --
   `aspect-ratio` here and `meet` in ui/chrome.ts are the same decision written
   on both sides of the box.

   ONE CLASS, AND THE TWO PAGES' DIFFERENCES SCOPED BY `data-screen`, because
   the artboards differ in exactly three measured places and agreeing to a
   single compromise in each would have followed neither board. 350px is the
   VERSUS artboard's phone width (390 less §3's two 20px gutters) and it is
   centred under a centred START, which is drawn rather than inferred --
   VersusDesktop.dc.html centres the scene and the button and leaves the title
   and the rows on the gutter. Endless draws its hero full-width on the same
   gutter as its cards, so it takes the cap back off. The third place is the
   landscape query at the bottom: Endless SHRINKS the picture and Vs Computer
   WITHHOLDS it, and each has its measurement in the comment there.

   THE TITLE STAYS BUNGEE 32 ON THE DESKTOP, where both sets of artboards draw
   40. §4 gives a screen title as Bungee 32 and §2 keeps 40 for the wordmark and
   the result title, and this milestone's working rules put FOUNDATION.md in
   charge of type -- so the artboards' 40 is the one thing on these boards that
   is not followed, and it is followed nowhere else on the site either (every
   submenu's h2 is 32 at every width).

   A ROW CONTROL IS A CARD WITH A CONTROL WHERE ITS VALUE WOULD BE, and its
   label is Bungee 16 and not §5's card 20 -- which is §2's "button 16-18" and
   what both Versus artboards draw. It is a fit and not a preference: OPPONENT
   at Bungee 20 is ~130px, the right-hand side of the row is 44 + 96 + 44 = 184,
   and a 302px card on a 390px phone has 118px left for the label. At 20 it
   wraps to two lines on the frame of record. */
.hero-art { width: 100%; max-width: 350px; aspect-ratio: 7 / 5; margin: 12px auto 0; overflow: hidden; }
.overlay .panel[data-screen="endless"] .hero-art { max-width: none; margin: 4px 0; }
/* No margin above the grid: the frame's 20px gap is the spacing (the menu
   frame block), and the 12 this carried put the three cards 12px under the
   artboard's on a desktop and 4 under it on a phone -- measured by
   scripts/probe-artboards.ts, which is how a number this small gets seen. */
.speeds { display: grid; gap: 12px; margin: 0; }
.rows { display: grid; gap: 12px; }

/* ---- THE WAITING SCREEN OF A SHARED MATCH (JIO-1162) ----------------------
   Three blocks the rest of the overlay has no shape for: the address of the
   table, the two buttons that shortcut copying it, and the head-to-head.

   ~~THE ADDRESS IS SELECTABLE AND IT WRAPS.~~ THE ROOM CODE IS READ ALOUD
   (2026-10-11). The link this block drew -- a 24-character slug in a monospace
   face, selectable in one tap, with COPY and SHARE under it -- is gone: a
   friend match is joined by four letters the host says to the person beside
   them. So the block is the letters at display size, spaced so each one is its
   own shape at arm's length, with one line under them saying what to do. It is
   `user-select: all` still, for the player who does want to paste it into a
   chat, and `.room-code-hint` is the tagline's voice one size down. The
   `menu-row` pair rule went with the two buttons it existed for. */
.overlay .menu .room-code {
  margin: 0; padding: 18px 14px; border: 1px solid var(--line); border-radius: 10px;
  background: var(--panel-2); color: var(--text); text-align: center;
  font-family: var(--display); font-weight: 400; font-size: 3rem; line-height: 1;
  letter-spacing: 0.28em; text-indent: 0.28em; user-select: all;
}
.overlay .menu .room-code-hint {
  margin: 0; color: var(--muted); font-size: var(--t-small); font-weight: 600; line-height: 1.4; text-align: center;
}
/* The friend's field for those letters: the Account screen's `.nick-field`
   input, in the display face, capitals, and the same spacing the host's block
   draws them at -- so the two screens show one code in one shape. */
.nick-field .code-input {
  font-family: var(--display); font-weight: 400; font-size: 2rem; line-height: 1.2; text-align: center;
  letter-spacing: 0.28em; text-indent: 0.28em; text-transform: uppercase;
}
/* The record at this table. 0-0 on a new one is the truth rather than an empty
   state, and the note under it is what stops 0-0 reading as a match already
   played and drawn.

   NOT SCOPED TO `.menu` ANY MORE (JIO-1170): the result screen draws the same
   block after a shared match, where it sits under the title rather than among
   the buttons, so the rule is on the overlay and the position is the caller's.
   IT TAKES THE WIDTH OF WHATEVER COLUMN IT IS IN and centres only its contents
   -- a 320px box bounded here read as an orphan between the full-width address
   above it on the waiting screen and the full-width stats card under it on the
   result screen (both seen in `scripts/probe-match.ts`'s shots, 2026-09-16). */
.overlay .head-to-head {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px;
}
.overlay .head-to-head .h2h-score { font-family: var(--display); font-size: var(--t-lede); color: var(--text); }
.overlay .head-to-head .h2h-note { color: var(--muted); font-size: var(--t-small); font-weight: 600; text-align: center; }
.menu .card.row-control .card-title { font-size: var(--t-body); }
/* ...and on a DESKTOP the same design in more room rather than a wider one
   (§4), which for both pages is the MAIN MENU'S shell: 720 of grid inside a 784
   panel, which is 32 of §3 gutter either side. Every other menu is drawn in a
   680 panel, so these two screens are picked out by `data-screen` exactly as
   `title` is -- and 720 is the breakpoint as well as the width, for the reason
   it is on the main menu: it is the width the grid needs, it is where
   `.menu-panel` already switches to its desktop gutter, and below it the phone
   artboards draw one column. (Not because the cards could not hold their line:
   the landscape rule below puts three Endless cards in 198px and the title and
   the score still share one row -- measured 2026-09-14.) Both heroes grow to
   the artboards' 520 and stay centred; full-width the Endless one would be
   720x514, a picture taller than a laptop's fold. */
@media (min-width: 720px) {
  /* ~~`.overlay .panel[data-screen="versus"] { width: min(784px, 100%); }`~~
     and the Endless twin: the 784 was a CARD widened to hold the 720, and the
     card is gone (the menu frame block above) -- the panel is the viewport and
     `--shell: 720px` in its padding is the column, for every menu. Struck
     rather than deleted because the paragraph above still explains the 720. */
  .hero-art { max-width: 520px; }
  .overlay .panel[data-screen="endless"] .hero-art { max-width: 520px; margin-inline: auto; }
  .rows { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
  /* NO `margin-inline: auto` ON EITHER GRID, where the heroes above take one:
     each is a flex item of `.menu` (a column flex box), and an auto margin in
     the cross axis turns off `stretch` -- the track sizing then falls back to
     the content and the three Endless cards came out 474px of a 720px shell,
     centred, which reads as a narrower design rather than as a bug. Neither
     needs centring: the shell they fill IS the panel's content box. START does
     take one, because a 352px button is narrower than the shell by design.
     ~~repeat(3, ...)~~ TWO COLUMNS since docs/decisions/0217: the page holds
     6-FACE and 7-FACE, and three tracks would leave the right third empty. */
  .speeds { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; max-width: 720px; }
  /* THE CREATE LOBBY PAGE HAS ONE ROW, SO IT TAKES THE SHELL (JIO-1181). The
     two-column rule above is what puts Opponent beside Time on a desktop, and
     applied to a page with a single row it draws that row across the left half
     of the 720 and leaves the right half empty -- which reads as a row that has
     lost its neighbour rather than as a page that only ever had one. Specific
     enough to beat the landscape query further down as well, where the same
     two columns arrive early for a sideways phone. */
  .overlay .panel[data-screen="lobby"] .rows { grid-template-columns: minmax(0, 1fr); }
  /* ...AND THE VS COMPUTER PAGE TOO SINCE 2026-10-20: its TIME row went, so
     the portrait control is a page's one row as well, and the two-column grid
     would draw it across the left half of the 720 (docs/decisions/0213). The
     portrait grows with the shell: 268px of die on a desktop. */
  .overlay .panel[data-screen="versus"] .rows { grid-template-columns: minmax(0, 1fr); }
  .overlay .rc-portrait .figurehead { height: 268px; }
  .menu .card.portrait-control .rc-cycle { justify-content: center; gap: 40px; }
  /* ...and the two pages' primary buttons are the same button: 352px centred,
     because a CREATE LINK stretched across 720 beside a 352px START would be
     two widths for one control, one press apart off the same chooser. */
  .overlay .panel[data-screen="versus"] .btn-primary,
  .overlay .panel[data-screen="lobby"] .btn-primary { width: 352px; margin-inline: auto; }
  .overlay .panel[data-screen="versus"] .btn-locked { width: 352px; margin-inline: auto; }
}
/* A PHONE HELD SIDEWAYS HAS NO HEIGHT FOR A HERO, and the two pages answer that
   differently because the arithmetic came out differently -- both measured
   2026-09-14 with scripts/probe-viewport.ts.

   ENDLESS SHRINKS IT. At 667x375 the 7:5 box across a 627px panel is 448px of
   drawing in a 375px viewport, so the first card sat 183px under the fold
   before a card was drawn at all. The picture is sized by its HEIGHT here --
   120px, with the width following the ratio -- and the three cards take the row
   they have the width for, which brings the page to 334px.

   VS COMPUTER WITHHOLDS IT ALTOGETHER, and that is the one bound on that page.
   At 852x393 it ran 259px past the fold and at 667x375 it ran 134px past, with
   both rows and START under it -- the JIO-930 fault, one rotation over. The
   arithmetic leaves no room to shrink into: row 1, the title, the two rows, the
   button and §3's gutters are ~336px of a 393px frame, so the largest hero that
   would fit is ~55px tall. A 55px picture is not the scene drawn smaller, it is
   a smudge where a drawing was, and the two rows are what the player came for.
   The mode card at this same 500px keeps its art because it has a WIDTH to
   spend it in (96px beside the title); a hero centred over a column has none.

   EITHER WAY THE CHOICES TAKE TWO OR THREE COLUMNS, which is the desktop grid
   arriving early for the reason §4 gives -- spend the width a sideways phone
   has instead of the height it has not. 500 is repeated from main.ts's
   LANDSCAPE_MAX_HEIGHT rather than derived, because a media query cannot read a
   module constant: the tallest phone in landscape is 430 and the shortest
   tablet is 768, so nothing sits in the band.

   IT SITS AFTER THE DESKTOP QUERY ABOVE, which is also unconditional at these
   sizes: 852x393 matches both, and at equal specificity the later rule wins.
   Without the order a sideways phone wider than 720 drew a 520x371 hero. */
@media (max-height: 500px) {
  .hero-art { display: none; }
  .overlay .panel[data-screen="endless"] .hero-art { display: block; height: 120px; width: auto; margin-inline: auto; }
  .rows { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
  /* ~~3~~ 2 tracks: the two dice (0217). */
  .speeds { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 8px; }
}

/* THE MAIN MENU (JIO-1078) -- design/pages/menu/Main.dc.html and MainDesktop.
   One column of four full-width cards on a phone, and §4's 2-column grid on a
   desktop: "cards in a 2-column grid, max 720px wide, centred".

   720 IS WIDER THAN THE PANEL EVERY OTHER MENU IS DRAWN IN, which is 680
   (`.overlay .panel`). So the main menu's panel is widened to hold it rather
   than the grid being shrunk to fit: §4 gives a number and the artboard draws
   it at that number, and 616px of grid inside a 680px panel is neither. The
   panel is picked out by `data-screen`, which show() writes from the name it
   was already handed (ui/screens.ts) -- no other screen moves.

   720 IS ALSO THE BREAKPOINT, which is not a coincidence: it is the width the
   grid needs, so below it there is no room for two columns and the cards stack.
   It is the same number `.menu-panel`'s desktop gutter already switches at. */
.main-menu { display: grid; gap: 12px; margin: 12px 0 20px; }
@media (min-width: 720px) {
  /* ~~`.overlay .panel[data-screen="title"] { width: min(784px, 100%); }`~~ --
     the menu frame block's `--shell: 720px` is the column now, for every
     menu screen, and there is no card left to widen (JIO-1086). */
  /* No `margin-inline: auto`: the shell the frame block pads to IS 720, and
     an auto cross-axis margin on a flex item turns `stretch` off, which sized
     the grid to its content -- four 229px cards where MainDesktop draws 352
     (the same trap the Endless page's commit describes for `.speeds`). */
  /* `grid-auto-rows: 1fr` IS WHERE THE START CARD'S SECOND LINE IS PAID FOR ON
     A DESKTOP (JIO-1464). It makes that card 70px against the other three's 66,
     and in a 2x2 that is a first row standing 4px over the second -- four doors
     of two heights. Equal rows put the line in the room §4's layout already
     had. In one column every card is its own row and the START card grows
     alone, which is what the artboards draw. */
  .main-menu { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 16px; max-width: 720px; }
  /* ~~MainDesktop.dc.html centres the wordmark and its lede over the grid~~ --
     there is no wordmark and no lede on this screen since JIO-1967, and what is
     centred in the room instead is `.centre`, by the rule in the menu block
     above. The argument is kept because it is still why that block is centred:
     a column hard against the left edge of a 720px panel reads as the start of
     a column that is not there. */
}

/* A PHONE HELD SIDEWAYS TAKES THE TWO COLUMNS TOO, and for the reason the mode
   card turns at the same height (above): a landscape phone is 375-430px tall,
   and four stacked 64px cards under a 40px wordmark put the last two under the
   fold. Measured 2026-09-12 at 667x375, RECORDS landed at y=327 and SETTINGS at
   y=405 in a 375px viewport -- which is the JIO-930 fault, one rotation over,
   and `probe-viewport.ts` is what found it here as well.
   It is the SAME grid, so this is more room rather than a different design (§4)
   -- the cards keep §5's own size and only the track count moves. The hero and
   the lede shrink with `.app.compact` already, which is what buys the rest.
   500 is repeated from main.ts's LANDSCAPE_MAX_HEIGHT rather than derived,
   because a media query cannot read a module constant, and it is safe to repeat
   for the reason the constant gives: the tallest phone in landscape is 430 and
   the shortest tablet is 768, so nothing sits in the band.
   `grid-auto-rows: 1fr` comes with the second column for the reason given
   where the desktop grid takes it: the same 2x2 in less room. */
@media (max-height: 500px) {
  .main-menu { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 8px; margin: 8px 0 12px; }
  .overlay h1 + .tagline { margin-bottom: 0; }
}

/* RECORDS IN THE 720 SHELL (JIO-1578) -- design/pages/menu/RecordsDesktop.dc.html.
   Five full-width cards in one 720px column at 1440 is a phone screen with
   margins, and Records was the one menu screen that stayed a narrow stack while
   the shell around it had already widened: §4's desktop column is "cards in a
   grid, max 720px wide, centred", which `.main-menu` took at JIO-1078 and this
   screen never did.

   ~~FOUR DOORS IN THE TWO COLUMNS AND THE FIFTH ACROSS BOTH.~~ TWO DOORS IN
   THE TWO COLUMNS AND THE THIRD ACROSS BOTH (JIO-2029, docs/decisions/0201):
   SCORES and BADGES share a row and HISTORY, the list of every run, reaches
   across under them. Three cards in one column at 1440 would be the phone
   screen with margins this rule was written against.

   `:last-child` AND NOT AN ID, because the SPAN IS THE ORDER (JIO-1578).
   The ring the arrows and the pad walk is DOM order and the phone stack is DOM
   order, so HISTORY being drawn last is what keeps the ring, the phone column
   and this grid one list -- SCORES, BADGES, HISTORY at every width. Keying the
   span on the id would let a reorder move a door up the ring while the span
   stayed at the foot, which is two orders where there is meant to be one.

   THE TWO FIGURES ARE THE CARD'S SUB LINE SPREAD TO ITS ENDS (JIO-2029): what
   you have on the left, where it stands or what is next on the right, the
   shape every row of the rooms behind the doors already has. The rule is
   outside the media query because the pair reads the same on a phone. */
.menu.records .card-sub { display: flex; justify-content: space-between; gap: 12px; font-variant-numeric: tabular-nums; }
.menu.records .card-sub .fig:last-child { text-align: right; }
/* RECORDS IN THE 720 SHELL, continued (JIO-1578).

   12 AND NOT THE MAIN MENU'S 16: these are five boxes to that screen's four, so
   the taller grid takes the tighter gap -- and 12 is half the 24 the panel
   already spaces this column's blocks by at this width, which is the same
   number `.main-menu` is spaced by on a phone rather than a rhythm invented at
   the seam. No `grid-auto-rows: 1fr` -- the main menu needs
   it because its START card carries a second line and the other three do not
   (JIO-1464); all five doors here are one 66px shape, so each row sizes to it
   and equal rows would be a declaration with nothing to equalise. */
@media (min-width: 720px) {
  .menu.records { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
  .menu.records > .card:last-child { grid-column: 1 / -1; }
}

/* THE FIRST-VISIT PROMPT (JIO-1082) -- design/pages/menu/SignInGate.dc.html.
   A bottom sheet over the main menu, and what is behind it is the REAL main
   menu: the artboard dims it to 0.35 under a 0.55 wash, so both numbers are
   here and ui/screens.ts's show() writes the two elements they belong to.

   `.dimmed` IS A PAINT AND `inert` IS THE ATTRIBUTE BESIDE IT (ui/screens.ts).
   Opacity says nothing about what can be pressed -- a card at 0.35 is still a
   64px target under a thumb -- so the fade and the inertness are two
   declarations of one decision, and the one a tap obeys is the one in the
   markup. A stylesheet cannot make a menu modal.

   The two washes stack, and they come out where the artboard is. Dark theme,
   measured from the tokens: `.overlay`'s own 82% leaves ~#06080c behind the
   panel, `--panel` at 0.35 over it is luma ~11 with its text at ~84, and 55%
   more of #06080c lands the menu's text at ~40 on a background of ~8. The
   artboard's own numbers are 47 on 12. That is the same screen. */
.overlay .panel.dimmed { opacity: 0.35; }
/* ...AND THE WASH GOES BACK BEHIND A MENU PANEL WHEN IT IS DIMMED. The
   arithmetic in the paragraph above starts from "`.overlay`'s own 82% leaves
   ~#06080c behind the panel", and for a menu panel that has not been true since
   the frame block below made a full-bleed menu screen the page itself
   (`.overlay:has(.menu-panel) { background: none }`). A menu panel is opaque, so
   nothing showed -- until it is dimmed to 0.35, and then the ARENA behind it
   does: an empty board and the word AI, faint but legible, under the first-visit
   prompt, under the Daily post-a-time sheet, and under the Tower map's own (found while
   driving that screen in a browser, 2026-09-18, JIO-1231). Restored only for
   the dimmed case, so a menu screen with no sheet over it is the untouched
   full-bleed page every artboard under design/pages draws. */
.overlay:has(.menu-panel.dimmed) { background: color-mix(in srgb, #06080c 82%, transparent); }
/* Fixed rather than absolute: it is a wash over the whole frame, and
   `.overlay`'s padding is the PANEL's bound (it is what keeps the panel inside
   the safe area) rather than this one's. The 20px gutter is the artboard's. */
.sheet-scrim {
  position: fixed; inset: 0; z-index: 1;
  display: flex; align-items: flex-end; justify-content: center;
  padding: calc(20px + env(safe-area-inset-top)) calc(20px + env(safe-area-inset-right)) calc(20px + env(safe-area-inset-bottom)) calc(20px + env(safe-area-inset-left));
  /* The same mix `.overlay` uses, at the artboard's 0.55: dark on both themes
     and not pinned to one palette (JIO-1061). */
  background: color-mix(in srgb, #06080c 55%, transparent);
}
/* 680 is the width every other menu panel is drawn at, so a sheet is never
   wider than the screens it covers. The main menu's own panel is 784 on a
   desktop (above) -- the one place the two edges do not line up, and a narrower
   thing centred on top reads as deliberate where a wider one would read as a
   mis-measurement. */
.sheet {
  width: min(680px, 100%);
  /* `none` and no scrollbar, for `.overlay .panel`'s two reasons (George,
     2026-10-12): a sheet taller than the phone is a column a thumb flicks. */
  max-height: 100%; overflow: auto; overscroll-behavior: none; scrollbar-width: none;
  display: flex; flex-direction: column; gap: 16px;
  padding: 24px 20px 20px;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: 14px; box-shadow: var(--card-shadow);
  animation: sheet-up 240ms ease-out;
}
.sheet::-webkit-scrollbar { display: none; }
/* §5's THIRD display level, which is the 24px one: `.overlay h3` already is
   Bungee 24 and the artboard draws 24 here. The margin is the browser's 1em
   otherwise, and the sheet's own gap is doing the spacing. */
.sheet h3 { margin: 0; }
/* ...AND IT HAD TO BE SAID AGAIN, LOUDER (JIO-1090). The paragraph above was
   true of `.overlay h3` and false of every phone, because the compact block at
   the foot of this file takes `.app.compact .overlay h3` down to `--t-card`
   (20px) -- and a bottom sheet is a phone shape, so BOTH sheets were drawing
   their 24px artboard head at 20 on exactly the device they are for. Three
   classes beats that rule's two-and-an-element, and it is scoped to the sheet
   rather than lifting the compact step-down, which is right for the in-game
   overlays it was written for. Found by scripts/probe-artboards.ts failing to
   find a 24px Bungee head on the Daily sheet at all; the first-visit prompt
   had been 4px short since JIO-1082 with nothing to notice it.
   `html .app .overlay` is the same four-part selector `.menu-panel`'s own h2
   override is written with, and for the same arithmetic: `.app.compact` is TWO
   classes, so three classes alone TIES it and loses on source order. */
html .app .overlay .sheet h3 { font-size: var(--t-h2); }
/* 16/400 muted -- `--t-body`, which is prose. NOT `.tagline`'s 18/600: that is
   the lede under a SCREEN title, and the screen behind this sheet has its own. */
.sheet-copy { margin: 0; color: var(--muted); font-size: var(--t-body); line-height: 1.5; }
/* THE RUN THE GATE IS ABOUT, IN ONE PILL (JIO-1987): the mode and the figure,
   over the question. Teal with ink on it -- the score is the player's own
   (FOUNDATION §1) -- where `.overlay .chip` is the amber one naming the
   OPPONENT on a result sheet; two pills, two colours, and the pair says whose
   number it is. A name of its own rather than a third `.chip`, for the reason
   the note over that rule gives. Bungee at --t-small, which is what a 14px
   pill of caps is everywhere else. */
.sheet-chip {
  align-self: flex-start; display: inline-flex; align-items: center; height: 36px; padding: 0 14px;
  margin: 0; border-radius: 999px; background: var(--left); color: var(--ink);
  font-family: var(--display); font-weight: 400; font-size: var(--t-small); line-height: 1;
  letter-spacing: 0.04em; text-transform: uppercase;
}
/* The artboard's 300x140 scene box, centred, cropping by `slice` like every
   other scene in the game (ui/chrome.ts sceneArt). */
.sheet-art { width: 100%; max-width: 300px; margin-inline: auto; aspect-ratio: 15 / 7; overflow: hidden; border-radius: 10px; }
/* The sheet's own 16px gap is the spacing, so the menu adds none: the two
   buttons are two of the sheet's four children in the artboard rather than a
   list inside it. */
.sheet .menu { margin: 0; gap: 16px; }
/* ~~THE THIRD LINE (JIO-1276): "Played before? Sign in", tertiary, under the
   two answers, `.menu button.sheet-signin` at 14/500 muted on a 44px height.~~
   Gone with the sign-in gate (George, 2026-10-11, docs/decisions/0128): the
   offer is a primary button on the first sheet now, drawn by `.btn` like every
   other answer, and the two first-visit sheets carry nothing but §5's buttons. */
/* A PHONE HELD SIDEWAYS GIVES THE SCENE UP FIRST, for the reason the mode card
   turns at the same height: 140px of art plus a title, a sentence and a 52px
   and a 44px button is ~420px, and a landscape phone is 375-430 tall. The sheet
   scrolls rather than overflowing either way (`max-height` above), but a bottom
   sheet a player has to scroll to find the buttons in is a prompt with its
   answers off screen. 500 is main.ts's LANDSCAPE_MAX_HEIGHT, repeated for the
   reason given where `.main-menu` repeats it. */
@media (max-height: 500px) {
  .sheet { gap: 10px; padding: 16px 20px 20px; }
  .sheet-art { max-width: 200px; aspect-ratio: 20 / 7; }
  .sheet .menu { gap: 10px; }
}
/* IT SLIDES UP, AND UNDER REDUCED MOTION IT APPEARS -- the ticket's own line,
   and the rule this file already keeps everywhere else: what a reduced-motion
   variant drops is the TRAVEL, never the state. There is nothing to keep here,
   because the sheet's state is that it is on screen. Only the transform is
   animated, so the wash behind it does not fade either way: a sheet whose
   backdrop faded in would be a second moving thing to turn off. */
@keyframes sheet-up { from { transform: translateY(100%); } to { transform: none; } }
.app.reduced-motion .sheet { animation: none; }

/* §4: "The main menu ends with the logo lockup at 20px mark + 14px wordmark,
   centred, muted. No 'jiolab.dev'." It is the home link, and on a menu screen
   it is the only one: §4 takes the top bar off, and the top bar is where the
   mark lives during a match (JIO-1063).

   It is NOT a menu item -- it sits outside `.menu`, so the arrows and the d-pad
   do not walk it, for the same reason the back arrow and the speaker do not
   (ui/chrome.ts). Tab reaches it and it wears the same focus ring.

   44px of height, like every other target on the page: `probe-touch.ts`
   measures every anchor against that floor, and the home link in the top bar
   was half a hit target for a month for want of exactly this (JIO-1064). */
.home-lockup {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  min-height: 44px;
  color: var(--muted); text-decoration: none; border-radius: 10px;
}
.hl-mark { display: block; width: 20px; height: 20px; flex: none; }
.hl-word { font-family: var(--display); font-weight: 400; font-size: var(--t-small); letter-spacing: 0.02em; color: var(--text); }
/* --left-text AND NOT --left, which is the one place this lockup differs from
   the artboard's own hex. FOUNDATION §1: the bright accent is for a bar, a
   border, a die, a mark and DISPLAY type; "an accent that carries a run of text
   takes `-text` instead". The hero's LOCK is 40px and keeps --left (§1 names it
   by name); this one is 14px, which is body text by every measure that matters,
   and #0f9f92 on the light page's white is 3.28:1 -- under the 4.5 small text
   needs. scripts/probe-theme-contrast.ts measured it and failed, which is
   exactly the job that probe exists for. In dark --left-text IS --left, so the
   dark artboard is untouched. */
.hl-word .lock { color: var(--left-text); }
.home-lockup:hover { background: var(--panel-2); }
.home-lockup:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; }

/* THE PUZZLES PAGE (JIO-1087) -- design/pages/puzzles' Puzzles phone, Daily
   solved, Daily shown, Puzzles light and Puzzles desktop. The Daily card, the
   hook line and six world tiles; every number below is one of those artboards'.

   The page is three blocks in the panel's own 20px column (the frame block far
   above), so nothing here sets the spacing BETWEEN them -- a rule that did
   would be the fourth place this game has written down the artboard's gap. */

/* THE HOOK, and it is not `.tagline`. A tagline is the muted lede under a
   screen title; this is 18/600 in the TEXT colour under a card, which is the
   one sentence this page exists to say. Same size, different role, different
   colour -- the distinction FOUNDATION §2 draws between a lede and a line. */
/* ~~`.puzzle-hook`~~ -- the lede is gone (JIO-1889): the Puzzles page is two
   tabs and nothing stands between its title and the strip. The failed-set
   sentence that shared the class is a plain `.tagline` now. */

/* HOW A PUZZLE WORKS (George, 2026-09-16): §5's panel ~~between the hook and
   the worlds~~ -- THE HOW TO PLAY TAB since JIO-1889 (docs/decisions/0154) --
   three sentences, then the three puzzle-mark figures from ui/sketch.ts in
   `.help-figure`s, which is the 264px-wide, never-scaled frame the Controls
   screen drew them in (see `.help-figure` below). One column at a phone; the
   grid goes to two abreast wherever two 264px figures and the gap fit, which
   is the desktop and not much else. It keeps §5's card look inside the panel
   for the reason Settings' tabs hold cards: a tab body is a column of cards. */
.puzzle-how { display: flex; flex-direction: column; gap: 14px; margin: 0; padding: 16px 20px 18px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--card-shadow); }
.puzzle-how p { margin: 0; font-size: var(--t-body); line-height: 1.5; color: var(--text); }
/* ~~The two ladder sentences (JIO-1155) sit above the how-to prose~~ -- gone
   with JIO-1889; the worlds are a tab of their own and the lock rule is the
   world page's small print. */
/* THE WORLDS TAB KEEPS THE MENU'S OWN GAP between the lead card and the grid:
   a tab panel's column is 14 (`.tabpanel.on`), and the card over the worlds
   was drawn 20 above them on every artboard since JIO-1475. */
.menu-panel[data-screen="puzzles"] .tabpanel.on { gap: 20px; }
.puzzle-marks { display: grid; grid-template-columns: repeat(auto-fit, minmax(264px, 1fr)); gap: 12px 24px; }
.puzzle-marks .help-figure { margin: 0; }

/* ---- the Daily card ----------------------------------------------------- */
/* §5's card with the scene across its top, which is `modeCard()`'s shape -- but
   it is a DIV and not a button, because the artboard draws a button INSIDE it
   (PLAY IT, or LEADERBOARD once the board is finished) and a control inside a
   control is not a thing a pointer or a screen reader can resolve. The picture
   is the second way in, and it is its own <a>. */
.menu .card.daily-card {
  flex-direction: column; align-items: stretch; gap: 0;
  padding: 0; overflow: hidden; cursor: default;
}
.menu .card.daily-card:hover { border-color: var(--line); background: var(--panel); }
/* 8px above the scene and nothing beside it, which is the artboard's `padding:
   8px 0 0` on a centred 310-wide drawing in a 350 card -- so the 40 is 20 of
   side inset either way, and the box is 7:5 because the drawing is 280x200. */
.daily-art { display: block; width: calc(100% - 40px); margin: 8px auto 0; aspect-ratio: 7 / 5; border-radius: 0; }
a.daily-art { cursor: pointer; }
a.daily-art:focus-visible { outline: 2px solid var(--left); outline-offset: -2px; }
a.daily-art.focused { outline: 2px solid var(--left); outline-offset: -2px; }
.daily-body { display: flex; flex-direction: column; gap: 14px; padding: 8px 20px 20px; }
.daily-head { display: flex; align-items: center; gap: 12px; }
/* Bungee 24 -- §2's third display level, the size the first-visit sheet's title
   takes, and NOT the 20 a card title is: this card is the lead of the page. */
.daily-card .card-title { font-size: var(--t-h2); }
/* The SHOWN pill: a chip's paint at a label's size, beside the lead rather than
   under it, so "this one was shown" is read in the same glance as the title. */
.daily-chip {
  padding: 4px 10px; border-radius: 999px;
  background: var(--panel-2); border: 1px solid var(--line);
  font-size: 0.8125rem; font-weight: 700; letter-spacing: 0.04em; color: var(--muted);
  /* `normal` and not the panel's 1.5: the chip is the TALLEST thing in the head
     row of the shown card, so its line box is what the card's height is measured
     from -- and 13px at 1.5 is 1.8px more than the artboard's own, which is the
     whole of the 3px the artboard probe reported (2026-09-15). */
  line-height: normal;
}
.daily-line { font-size: var(--t-body); line-height: 1.5; color: var(--text); }
/* The clock sentence on the open card (George, 2026-09-16): small print in the
   muted colour, between the streak row and PLAY IT, and absent once the day is
   solved or shown -- the two artboards for those states draw no such line. */
.daily-clock { font-size: var(--t-small); line-height: 1.4; font-weight: 600; color: var(--muted); }
.daily-score { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
/* Bungee 36 teal: the one number on this page, and the biggest thing on the
   card after the picture. Tabular figures for `.card-value`'s reason -- two
   Daily times under each other are two times to compare. */
.daily-time { font-family: var(--display); font-weight: 400; font-size: 2.25rem; line-height: 1; color: var(--left-text); font-variant-numeric: tabular-nums; }
/* ~~THE RANK IS DRAWN OFF, and it says so by being muted rather than by being
   absent~~ -- the slot is FILLED since JIO-1090, and the disabled variant went
   with it. `#12 today` beside the time, read from the answer the post got, and
   drawn only when there is one: a solved day with no answer yet has no rank
   element at all, which is a shorter card rather than a muted placeholder. The
   struck sentence is kept because a muted rank is a state a reader would
   otherwise put back. */
.daily-rank { font-size: var(--t-body); font-weight: 700; color: var(--text); }
.daily-meta { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.daily-streak { display: flex; align-items: center; gap: 8px; font-size: var(--t-body); font-weight: 600; color: var(--text); }
/* The glyph keeps the accent while the words go muted on a shown day, which is
   the artboard: the streak is still a streak, it just is not this day's. */
.streak-glyph { display: block; flex: none; color: var(--left-text); }
.daily-card.shown .daily-streak { color: var(--muted); }
.daily-date { font-size: var(--t-small); font-weight: 600; color: var(--muted); }
.daily-note { margin: 0; }

/* ---- PLAY IT while it waits (JIO-1877, docs/decisions/0151) ----------------
   The press is the loading state, which is what George chose over an
   interstitial: the same 52px primary, dimmed to `left-dim` with the text
   colour on it, a 22px teal die tumbling at its left and a sweep across it --
   design/pages/puzzles/DailyLoading.dc.html. Drawn only after main.ts's
   DAILY_WAIT_MS, and disabled so the second press a silent button invites has
   nothing to land on. `opacity: 1` over `.overlay .btn:disabled`'s 0.55 and
   `cursor: progress` over its `default`: a waiting button is not a dead one.
   `justify-content: center` over `.menu button`'s space-between, so the die
   and the word sit together the way the artboard draws them. */
.overlay .daily-card .btn.daily-wait,
.overlay .daily-card .btn.daily-wait:disabled {
  position: relative; overflow: hidden;
  display: flex; align-items: center; justify-content: center; gap: 12px;
  background: var(--left-dim); border-color: var(--left-dim); color: var(--text);
  opacity: 1; cursor: progress;
}
.daily-wait-sweep {
  position: absolute; top: 0; bottom: 0; left: 0; width: 40%;
  background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--left) 35%, transparent) 50%, transparent 100%);
  animation: daily-wait-sweep 1.6s linear infinite;
}
.daily-wait-die { position: relative; display: block; width: 22px; height: 22px; animation: daily-wait-tumble 1.4s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; transform-origin: 50% 50%; }
.daily-wait-label { position: relative; }
@keyframes daily-wait-sweep { from { transform: translateX(-100%); } to { transform: translateX(260%); } }
@keyframes daily-wait-tumble {
  0% { transform: rotate(0deg) translateY(0); }
  50% { transform: rotate(180deg) translateY(-3px); }
  100% { transform: rotate(360deg) translateY(0); }
}
/* Both ways the game says "no motion": the OS setting and the Settings switch
   (`.app.reduced-motion`, the class every other animation here checks). */
@media (prefers-reduced-motion: reduce) { .daily-wait-sweep, .daily-wait-die { animation: none; } }
.app.reduced-motion .daily-wait-sweep, .app.reduced-motion .daily-wait-die { animation: none; }

/* ---- the START HERE card (JIO-1320) -------------------------------------- */
/* The lead of this page for a browser that has finished nothing, standing where
   the Daily card stands and built out of the same box -- one slot in two states
   rather than two cards that drifted apart. It is a DIV, for `.daily-card`'s
   reason: the button is inside it. */
.menu .card.start-card {
  flex-direction: column; align-items: stretch; gap: 0;
  padding: 0; overflow: hidden; cursor: default;
}
.menu .card.start-card:hover { border-color: var(--line); background: var(--panel); }
/* THE PICTURE IS THE WORLD: its ground gradient out of `config.puzzles.themes`
   with its backdrop drawn whole over it, which is how the world's own page
   paints itself (JIO-1137). The 7:5 box and the 20px side inset are the Daily
   card's, so the two states of this slot are the same size; the 10px radius is
   not, because this drawing is a filled block rather than a transparent scene. */
.start-art {
  display: block; position: relative; overflow: hidden;
  width: calc(100% - 40px); margin: 8px auto 0; aspect-ratio: 7 / 5;
  border-radius: 10px; cursor: pointer;
  background-image: linear-gradient(180deg, var(--puzzle-tile-top, var(--panel-2)), var(--puzzle-tile-bottom, var(--panel-2)));
}
.start-art .tile-art { position: absolute; inset: 0; width: 100%; height: 100%; }
.start-art:focus-visible, .start-art.focused { outline: 2px solid var(--left); outline-offset: -2px; }
.start-body { display: flex; flex-direction: column; gap: 14px; padding: 8px 20px 20px; }
.start-head { display: flex; align-items: center; gap: 12px; }
/* Bungee 24, the Daily card's size: a lead at a tile's 20 reads as the first of
   the worlds. */
.start-card .card-title { font-size: var(--t-h2); }
/* The board, in the world's own heading colour, so WORKSHOP here and WORKSHOP
   in the grid below are recognisably one place. */
.start-where { font-size: var(--t-body); font-weight: 700; letter-spacing: 0.02em; color: var(--puzzle-heading, var(--text)); }
/* One sentence of small print, in the slot and the colour the Daily card's
   clock rule takes. */
.start-line { font-size: var(--t-small); line-height: 1.4; font-weight: 600; color: var(--muted); }
/* ~~The Daily once it has moved under the world grid, card and note in one
   wrapper so the desktop grid below has one area to put them in~~ -- `.daily-later`
   IS GONE (JIO-1429): the Daily has a page and never moves under the world grid.
   What it found out is kept, because the next wrapper anybody puts a `.menu`
   inside will hit it -- `.menu` carries `margin: 16px 0` and `.menu-panel > *`
   only zeroes the panel's OWN children, so a `.menu` nested one level down gets
   that margin back and lands 16px below the drawing (measured against the
   artboard, 2026-09-22). */

/* ~~the Tutorial card on How to play (JIO-1392)~~ -- the lesson's hero card and
   its two badges, gone with the lesson's view (JIO-2021). */
/* HOW TO PLAY'S TWO GROUPS (JIO-2039). An eyebrow is a heading inside `.menu`
   and not an item: the ring's `.menu button` never sees it. The negative
   bottom margin pulls its row up against the menu's own 8px gap. */
html .app .overlay .menu .menu-eyebrow { font-family: var(--display); font-weight: 400; font-size: var(--t-small); line-height: 1.1; letter-spacing: 0.1em; color: var(--muted); margin: 8px 0 -2px 2px; }
/* `html .app .overlay .menu .menu-eyebrow` and not `.menu-eyebrow`: `.overlay
   h3` sets every heading in a menu to Bungee 24 and `.app.compact .overlay h3`
   takes a phone's to 20 -- and that one is (0,3,1), which `html .app .overlay
   .menu-eyebrow` only ties and, written earlier in the file, loses (measured
   by the menu probe, 2026-10-20: 28px of eyebrow against the drawing's 21). */
/* THE ROW'S END: the minute count in words and the mark, at the right edge,
   outside the body so they never wrap under the title. */
.card-end { flex: none; display: inline-flex; align-items: center; gap: 10px; }
/* THE MARK IS A DISC: a ring until earned, the achievement colour once it is
   (the achievement shelf's own amber). */
.tutor-mark { width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; border: 2px solid var(--line); color: var(--line); }
.tutor-mark.earned { border-color: var(--right); background: var(--right); color: var(--ink); }

/* ---- the eight worlds ---------------------------------------------------- */
/* Two to a row on a phone, three on a desktop, which is what the two artboards
   draw. `minmax(0, 1fr)` rather than `1fr`: a tile is a grid item holding a
   Bungee 20 word, and `1fr` is `minmax(auto, 1fr)` -- OBSERVATORY would refuse
   to shrink below its own width and push the row off a 375px phone, which is
   JIO-833 with a longer word. */
.menu.level-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
/* The tile is a §5 card wearing its level's ground instead of `--panel`. The
   two stops and the heading come from `config.puzzles.themes` through inline
   tokens on the element (ui/screens.ts), so the tile and the board a press on
   it opens are painted out of one table -- the fallbacks are what a level the
   table does not name draws. */
/* `min-height`, NOT `height`, and the title wraps rather than being cut
   (JIO-1188). Both halves of that are one bug: the tile is `overflow: hidden`
   so that the backdrop texture stops at its corners, and a title that could
   not wrap inside a box that could not grow was simply SLICED. Measured on
   2026-09-17 at 390x844, before any of this ticket's other changes and
   identically on `main`: GREENHOUSE lost 20px of itself and OBSERVATORY 33px,
   at the DEFAULT font size on the frame of record -- a locked world whose name
   a player cannot read is the one thing the tile is there to say. Nine
   elements were cut at 195px and five at a 24px root.
   A world name is one long word in a wide display face inside half a phone, so
   `anywhere` is what it takes: there is no space in OBSERVATORY to break at.
   A name broken across two lines is legible and a name with its last four
   letters missing is a different name, which is the whole of the choice here.
   The tile then has to be able to hold two lines, and a reader who has raised
   their browser's font size needs it to hold two TALLER lines -- so the fixed
   height becomes a floor. Nothing moves at the default setting: one line of
   Bungee 20 over a 15px count is 59px inside a 72px content box, which is what
   it was. */
.menu .card.level-tile {
  position: relative; overflow: hidden;
  flex-direction: column; align-items: stretch; justify-content: space-between;
  gap: 0; min-height: 100px; padding: 14px 16px;
  background: linear-gradient(180deg, var(--puzzle-tile-top, var(--panel)), var(--puzzle-tile-bottom, var(--panel-2)));
  text-decoration: none;
}
.menu .card.level-tile:hover { background: linear-gradient(180deg, var(--puzzle-tile-top, var(--panel)), var(--puzzle-tile-bottom, var(--panel-2))); }
/* The backdrop stretches to the tile rather than fitting it: the drawing is a
   texture and not a picture, every shape in it sits on the right half, and the
   world's name is what has to stay readable over it. */
.level-tile .tile-art { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
/* Everything but the backdrop is above it, which is what `position: relative`
   on a static-positioned child buys without a z-index anywhere. */
.level-tile .tile-head { position: relative; display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; }
.level-tile .card-title { color: var(--puzzle-heading, var(--text)); min-width: 0; overflow-wrap: anywhere; }
.level-tile .lock-glyph { display: block; flex: none; color: var(--text); }
/* ~~Five 12px squares: teal solved, amber shown, hollow open~~ -- A COUNT SINCE
   JIO-1152. A world holds TEN boards now and ten squares in a 158px tile is a
   progress bar drawn out of dots: it reads as full a board early, and it cannot
   say the number the lock rule is about. "7 / 10" can. The struck rule is kept
   because `.tile-mark` is still the world PAGE's glyph one press in
   (`.puzzle-set .tile .tile-mark` below), and a reader who found it there would
   otherwise read this deletion as an accident.

   WHITE IN BOTH PALETTES, AND A LITERAL RATHER THAN `--text`. A tile wears its
   in-game ground in light as well as in dark -- the light artboard draws the
   same eight gradients -- so `--text` would put near-black figures on a dark
   field on exactly one of the two themes. That is the rule the hollow mark
   carried at 35% and the reason it carried it. Tabular figures because eight of
   these are read down a grid. */
.level-tile .tile-count {
  position: relative;
  font-size: 0.9375rem; font-weight: 700; line-height: 1;
  color: rgba(255, 255, 255, 0.92); font-variant-numeric: tabular-nums;
}
/* DIMMED, NOT HIDDEN: a locked world is what the set has coming, and a player
   who cannot see it cannot want it. It is still focusable and still a real link
   -- pressing it here says what opens the level, and following the link cold
   opens the world (see the world's own page). */
.menu .card.level-tile.locked { opacity: 0.55; }

/* ---- one world's page ---------------------------------------------------- */
/* The boards JIO-927 built as tiles, one press in from the grid above -- five a
   world then, ten since JIO-1151. They are <a>s
   and not buttons -- each carries its own deep link, so a puzzle can be copied,
   opened in a tab, shared or reported by seed -- which is why every rule the
   menu writes for `button` is written for `.tile` beside it. */
.puzzle-set { gap: 14px; }
/* THE WORLD'S OWN PAGE IS PAINTED LIKE ITS TILE (JIO-1137). `.world-ground` is
   the first child of the level panel: the tile's ground gradient over the whole
   panel and the tile's backdrop drawn at `meet` in its top-right, under every
   line of content. The panel is made a stacking context (`isolation`) so the
   ground's z-index -1 sits above the panel's own --bg and below its in-flow
   children without any of them needing a z-index. `overflow` is left alone:
   the panel is the page's scroll box and a clip here would be a page that does
   not scroll. The art is 0.7 opaque on top of shapes the tile already draws at
   half strength, so the tile labels and the note read over it. */
html .app .overlay .panel.menu-panel[data-screen="level"] { position: relative; isolation: isolate; }
/* ...AND ITS STICKY HEAD PAINTS NOTHING (George, 2026-10-12, the Vault). The
   head's `--head-ground` ~~band~~ wash is one flat colour, and this page's
   ground is the world's gradient with its drawing over it, so the bar and the
   title sat in a black box over the Vault's steel. The head stays sticky; it
   just carries no ground and no blur, so the world reads through it. Since
   2026-10-15 the head's paint is the frosted `h2::before` in the menu block,
   and this is the one screen that drops it. */
html .app .overlay .panel.menu-panel[data-screen="level"] > h2::before { display: none; }
.world-ground {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: linear-gradient(180deg, var(--puzzle-tile-top, var(--bg)), var(--puzzle-tile-bottom, var(--bg)));
}
/* The drawing is the width of the content column and no wider: `meet` inside a
   full-bleed 1440x900 panel scaled the Abyss's rings to the whole desktop
   (measured 2026-09-16), so the box is capped at the 720px the column is and
   centred on it, which puts the shapes -- all drawn on the right of the 160x100
   box -- under the right half of the tiles, where the tile puts them. */
.world-ground .tile-art {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 100%;
  max-width: 720px;
  height: auto;
  aspect-ratio: 16 / 10;
  opacity: 0.7;
}
/* The Abyss's and the Forge's glow is a <rect> the size of the drawing's box,
   filled with a radial gradient; on a tile the box IS the tile and the glow
   fades into its edges, but on a page the box is a 720x450 rectangle with a
   visible edge (seen 2026-09-16 at 1440x900). The page keeps the rings and
   the sparks and drops the wash; the tile is untouched. */
.world-ground .tile-art rect[fill^="url("] { display: none; }
/* The world's own tint behind its tiles (JIO-929), inline on the section from
   the same config the board is painted from. The padding is what makes a tint a
   row rather than a wash behind the text; 6px 8px and no more, because every
   pixel here is width off the tiles. */
.puzzle-set .level {
  padding: 6px 8px;
  border-radius: 10px;
  background-image: linear-gradient(var(--puzzle-panel, transparent), var(--puzzle-panel, transparent));
}
/* ~~auto-fit rather than a fixed count... At 390 it comes out three across, so
   ten boards are 3/3/3/1.~~ FIVE ACROSS EVERYWHERE since the boards became
   pictures (2026-10-17, canvas KSLEogk8cyddgvAaM5sWjZ direction C): a world is
   two rows of five small boards under the preview, which is the desktop block's
   shape on a phone too -- a 390 screen has room for five 60px boards, and five
   is the only even split of ten that is not a column (JIO-1152's reason). */
.puzzle-set .tiles { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
.puzzle-set .tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  min-height: 44px;
  padding: 8px 6px 6px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel-2);
  color: var(--text);
  font-family: var(--mono);
  font-size: var(--t-small);
  text-decoration: none;
}
.puzzle-set .tile .board-picture { display: block; width: 100%; max-width: 64px; height: auto; }
.puzzle-set .tile .tile-foot { display: flex; align-items: center; gap: 3px; line-height: 1; }
.puzzle-set .tile[aria-current="true"] { border-color: var(--text); box-shadow: 0 0 0 1px var(--text); }
.puzzle-set .tile:hover { border-color: var(--muted); }
.puzzle-set .tile.focused, .puzzle-set .tile:focus-visible { border-color: var(--left); box-shadow: 0 0 0 2px var(--left-dim); outline: none; }
/* The world page's own glyph -- a tick or a diamond, not a square -- which is
   why it needs no box of its own since the grid's squares went (JIO-1152). */
.puzzle-set .tile .tile-mark { font-size: var(--t-small); }
.puzzle-set .tile .tile-mark:empty { display: none; }
.puzzle-set .tile.solved { border-color: var(--left); }
.puzzle-set .tile.solved .tile-mark { color: var(--left-text); }
.puzzle-set .tile.shown { border-color: var(--right); }
.puzzle-set .tile.shown .tile-mark { color: var(--right-text); }
/* A board already done is drawn quieter, so the ones left read first. */
.puzzle-set .tile.solved .board-picture, .puzzle-set .tile.shown .board-picture { opacity: 0.6; }
/* The holes of every board picture are the board's own ground. */
.board-picture .bp-hole { fill: var(--board-grid); }
/* THE PREVIEW: the picked board, large, over the strip. The picture is sized
   by its ROW COUNT (`--bp-rows`, set on the svg by boardPicture) at 26px a
   row, so a world of seven-row boards and a world of ten-row ones draw dice
   of one size, capped so a ten-row board still leaves the strip on screen. */
.puzzle-set .board-preview {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  padding: 14px 16px 16px;
  background-image: linear-gradient(var(--puzzle-panel, transparent), var(--puzzle-panel, transparent));
}
.puzzle-set .board-preview .bp-head { align-self: stretch; display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.puzzle-set .board-preview .bp-label { font-family: var(--display); font-size: 1.25rem; color: var(--text); }
.puzzle-set .board-preview .bp-flag { font-weight: 800; font-size: var(--t-small); color: var(--puzzle-heading, var(--left-text)); }
.puzzle-set .board-preview[data-state="solved"] .bp-flag { color: var(--left-text); }
.puzzle-set .board-preview[data-state="shown"] .bp-flag { color: var(--right-text); }
.puzzle-set .board-preview[data-state="locked"] .bp-flag { color: var(--muted); }
.puzzle-set .board-preview .bp-board { display: block; padding: 8px; border-radius: 10px; background: var(--board-bg); }
.puzzle-set .board-preview .board-picture { display: block; height: min(calc(var(--bp-rows) * 26px), 34vh); width: auto; aspect-ratio: var(--bp-cols) / var(--bp-rows); }
.puzzle-set .board-preview .bp-line { color: var(--text-2); font-weight: 700; font-size: var(--t-small); text-align: center; }
.puzzle-set .board-preview .btn { align-self: stretch; }
.puzzle-set .board-preview[data-state="locked"] .bp-board { opacity: 0.45; }
.puzzle-set .level.locked { opacity: 0.45; }
.puzzle-set .tile.locked { border-style: dashed; }

/* ---- the desktop composition (PuzzlesDesktop.dc.html) -------------------- */
/* ~~A DESKTOP IS THE SAME PAGE IN MORE ROOM (§4), and this is the one menu
   where "more room" is a second column rather than a wider one: the artboard
   puts the Daily card at 440 on the left and the hook over a three-wide grid on
   the right, inside a 1096 content column rather than the 720 every other menu
   takes. The panel is a flex column on a phone and a grid here, laid out by
   AREAS... `row-gap: 20` is the right column's own gap (hook to grid); the 24
   the frame uses between the title and the blocks under it is put back as 4px of
   margin on the two rows above.~~

   THERE IS NO SECOND COLUMN ANY MORE (JIO-1429). The two-column grid, the
   1096px shell and the six named areas existed to park the Daily card beside the
   page; the Daily is a page of its own now, so what is left is the hook, the §5
   panel and the grid -- three blocks in a column, which is the flex column the
   frame rule already gives every menu, in §4's own 720px shell. The struck
   paragraph is kept because a reader who finds this screen three blocks tall in a
   720 column would otherwise put the 1096 back for the room the tiles have lost.
   Nothing here replaces it: a grid whose template is one column of three stacked
   areas is the flex column spelled out twice.

   WHAT DOES NEED SAYING is the width of the ONE card either of these pages can
   still lead with. Left to the shell, the Daily card and START HERE stretch to
   720 and their 7:5 picture becomes a 680px scene over a 24px lead -- so both
   keep the 440 the desktop artboard drew the Daily at, at the shell's own left
   edge under a left-aligned title. */
@media (min-width: 720px) {
  html .app .overlay .panel.menu-panel[data-screen="puzzles"] .tabpanel > .start-menu,
  html .app .overlay .panel.menu-panel[data-screen="daily"] > .daily-menu,
  html .app .overlay .panel.menu-panel[data-screen="daily"] > .daily-note { max-width: 440px; }
  .menu-panel[data-screen="puzzles"] .tabpanel.on { gap: 24px; }
  .menu.level-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .menu .card.level-tile { min-height: 120px; }
  /* A WORLD IS TWO ROWS OF FIVE IN MORE ROOM (JIO-1152), not whatever `auto-fit`
     happens to fit. Ten 84px tiles in a 720px column auto-fit to SEVEN across,
     which draws a world as a row of seven and a row of three -- a shape a
     player reads as "and three left over" rather than as ten boards. Five is
     the only even split of ten that is not a column, and it is the phone's
     three read at a desk. A world of five (every set before JIO-1151, and any
     hand-cut file) draws one row of five under the same rule. THE FIVE MOVED
     UP to the phone rule since 2026-10-17; what is left here is the preview,
     held at the 440 the start card keeps rather than stretched to 720. */
  .puzzle-set .board-preview { align-self: center; width: 100%; max-width: 440px; box-sizing: border-box; }
}

.overlay .row { display: flex; gap: 12px; flex-wrap: wrap; }
.overlay .row .menu { flex: 1; }
.overlay .small { color: var(--muted); font-size: var(--t-small); font-weight: 600; line-height: 1.4; }
/* THE BOX THE SIGN-IN WIDGET IS PUT INTO (JIO-1220). It is EMPTY until a player
   presses SIGN IN, so it carries no padding, no border and no margin of its own
   until it has something in it -- an empty bordered box under the button would
   read as a control that failed to draw. What goes inside is another company's
   markup and is deliberately left to style itself: the one thing said about it
   here is how wide it may be, because a widget sized to the viewport inside a
   680px panel is the one way it can break the screen around it. */
/* WHERE A SIGN-IN RAISED FROM A FINISH LANDS WHEN THE PROVIDER REDIRECTED
   (JIO-1571). A section of content, built like the Tower's shelf on Records and
   for its reason: a standing has nothing behind it to open, so a card here
   would be a door to a room that is not there. The rows are the `.stats` grid
   this screen already has, with its own margin dropped because the section owns
   the spacing. */
.account-boards { display: flex; flex-direction: column; gap: 10px; margin: 16px 0; }
.account-boards > h3 { margin: 0; font-family: var(--display); font-weight: 400; font-size: var(--t-h2); line-height: 1.1; }
.account-boards .stats { margin: 0; }
/* WHAT THE ARTBOARDS PIN TO THE FLOOR OF THE FRAME (JIO-1081). Two elements
   carry `margin-top: auto` across `design/pages/menu`: the PLAY button's box in
   HighScores.dc.html -- the ticket says it in words too, "one primary PLAY
   <speed> button at the BOTTOM" -- and the home lockup in Main.dc.html and
   MainLight.dc.html.

   Neither was pinned, and it did not show while a menu was a card sized to its
   own content and centred in the viewport. Full-bleed, the panel is the whole
   844 and both floated in the middle of it: the PLAY button sat directly under
   the caption with 430px of empty frame beneath it, which reads as a list that
   was cut off rather than as a screen with room to spare, and puts the one
   control a thumb is reaching for in the middle of the screen instead of under
   it. Pinning them is the other half of making the panel the frame.

   ~~`:has(...)` and not `.menu-panel` flat~~ -- every phone menu panel is a
   flex column now (the frame rule above), because the artboards' vertical
   rhythm is one 20px gap and not the margins the screens happened to carry. The
   panel only became a column here, where something asked to be pinned, until
   that was measured. No media query around it any more: the panel is the
   frame at every width (JIO-1086), so a desktop pins the same foot. */
/* AND THE TAB BAR IS THE THIRD (JIO-1994, decisions/0192): it asked for the
   same auto margin in its own block above and lost, so it sat wherever the
   content left it. Every artboard that draws it puts it on the floor. */
.app .overlay .panel.menu-panel > .menu.foot,
.app .overlay .panel.menu-panel > .tabbar,
.app .overlay .panel.menu-panel > .home-lockup { margin-top: auto; }
/* Small print that is the artboard's CENTRED caption rather than a paragraph
   pinned to the left margin (JIO-1081). High scores is the only screen with
   one: it is one sentence under a board of five rows, and HighScores.dc.html
   centres it under them. `.small` is still what sizes and colours it -- this
   adds the one property that differs. */
.overlay .small.caption { text-align: center; }
.help-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 20px; font-size: var(--t-body); }
.help-grid h3 { margin: 8px 0 4px; color: var(--right-text); }
.help-grid p { margin: 0 0 6px; color: var(--text-2); }
/* Every rules entry carries a figure drawn in the game's own dice (JIO-933),
   from the same ui/sketch.ts the landing page's four step cards are generated
   from. The drawing sits between the heading and the paragraph, at its own
   size: the figures declare width and height attributes, so `max-width: 100%`
   with `height: auto` is a floor that never fires at any viewport
   scripts/probe-viewport.ts measures -- the narrowest column this grid gives
   one is 298px at a desktop (a 680px panel, 32px of padding, two tracks and a
   20px gap) and 343px on a 375px phone, against a 264px figure. It matters that
   it never fires: a scaled figure takes its 14px labels down with it, and a
   label nobody can read is a drawing that has to be explained by the paragraph
   it was drawn to replace. */
.help-entry { margin: 0 0 14px; }
.help-entry:last-child { margin-bottom: 0; }
.help-figure { margin: 6px 0 8px; }
/* The DIRECT child only, the way web/landing.html's `.card > .die` is a direct
   child: the figures put dice INSIDE the sketch as nested <svg> elements, and a
   CSS width matching those would override the x/y/width/height that place each
   die in the drawing. */
.help-figure > svg { display: block; width: 264px; max-width: 100%; height: auto; }
.help-grid kbd, .card kbd, .keybinds kbd, .puzzle-how kbd, .coach-line kbd {
  font-family: var(--mono);
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-bottom-width: 2px;
  border-radius: 4px;
  padding: 1px 6px;
  font-size: var(--t-small);
}
/* The help overlay's input-method tabs (JIO-888). The bindings used to be one
   flat four-column table -- Action, Keyboard, Gamepad, Touch -- under all the
   prose, which meant "X" the key sat beside "X" the pad button and a phone
   player scrolled past two keyboards to reach their own row. One panel per
   method, opened on the method that player is actually using, is what removes
   both: there is only ever one control column on screen, so nothing has to be
   told apart, and it is the right one.
   flex-wrap rather than three grid columns: at 375px the strip is allowed to
   wrap onto a second line, where a fixed three-column track would push the
   panel wider than the screen -- which is the JIO-833 failure exactly. */
/* PILLS, NOT TABLETS (JIO-1099). Every Controls artboard draws the strip as
   44px pills -- `999px`, 20px of side padding, the open one FILLED in the
   player's teal with ink on it and the rest `panel` behind a `line` border.
   What was here was a row of 6px-radius tablets, each `flex: 1 1 auto` so two
   tabs took half the screen each, marked by an AMBER border: `--right` is the
   opponent's colour everywhere else in the game, and the open tab is the one
   thing on this screen that is the player's. They are shrink-wrapped now, so a
   strip of two does not stretch to the width of a strip of three -- which is
   what a pad connecting would otherwise do to the two already on screen. */
.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; min-width: 0; }
.tabs button {
  flex: 0 1 auto;
  min-width: 0;
  height: 44px;
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 0 20px;
  font-family: var(--display);
  font-weight: 400;
  font-size: var(--t-body);
  cursor: pointer;
}
.tabs button:hover { border-color: var(--muted); }
.tabs button:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; }
.tabs button[aria-selected="true"] { color: var(--ink); background: var(--left); border-color: var(--left); }
/* `display` lives on .on alone and never on .tabpanel itself, so the `hidden`
   attribute cannot be outranked by a display rule -- the bug .deck[hidden]
   above had to be patched for. */
.tabpanel { display: none; }
 /* ...and the open one is a COLUMN with one gap, so a diagram, its legend and
   the sentence under it are spaced by one number rather than by whatever margin
   each child carried -- the same argument `.menu-panel`'s frame gap is written
   on further up. */
.tabpanel.on { display: flex; flex-direction: column; gap: 14px; }
.tabpanel > * { margin: 0; }

/* ---- THE CONTROLS PAGE'S FRAME (JIO-1099) -------------------------------
   design/pages/controls draws this screen at three widths and the frame differs
   at each, so it is written here rather than taken from `.menu-panel`'s shared
   desktop rule.

   A DESKTOP IS A 1000px COLUMN, not §4's 720: the keyboard drawing is 483
   units wide with two hands under it and a six-role legend beside it, and the
   artboards (Main / KeyboardWasd / KeyboardLight / Gamepad, all 1440x900 or
   820) are drawn around exactly 1000. It is the same exception the rules screen
   already takes at 1160, and for the same reason -- a page whose subject is a
   wide picture is not a page of cards.

   The frame is 16 at the top against §4's 24, and the title sits 8px under the
   chrome row rather than a full gap: both are the artboards', and the -16 is
   how a flat column with one gap draws a first gap that is smaller than the
   rest (the same trick the main menu's wordmark used before JIO-1967 took it
   off; the lesson's pause card did it too, until JIO-2021). */
@media (min-width: 720px) {
  /* §3's desktop gutter, the artboards' 16 at the top and 32 at the foot -- and
     the 1000px column is made by WIDTH on the children rather than by the
     padding `.menu-panel` makes its 720 with. That is not a preference: the
     artboards draw the back arrow on the page's own 32px gutter with the
     content column starting at 220, and a column made of padding takes the
     chrome row in with it. Pulling the row back out with a negative margin does
     not work either -- a percentage in a child's margin resolves against the
     PARENT'S CONTENT BOX, which is the column, so the number needed is not
     expressible there (measured 2026-09-16: it computed to 0 and the arrow
     stayed at 220). */
  html .app .overlay .panel.menu-panel[data-screen="help"] {
    padding: calc(16px + env(safe-area-inset-top)) calc(32px + env(safe-area-inset-right)) calc(32px + env(safe-area-inset-bottom)) calc(32px + env(safe-area-inset-left));
  }
  html .app .overlay .panel.menu-panel[data-screen="help"] > *:not(.chrome-bar) { width: min(1000px, 100%); margin-inline: auto; }
  html .app .overlay .panel.menu-panel[data-screen="help"]:not(:has(.controls-head)) > .chrome-bar { margin-bottom: -16px; }
  html .app .overlay .panel.menu-panel[data-screen="help"]:has(.controls-head) {
    padding: calc(12px + env(safe-area-inset-top)) calc(24px + env(safe-area-inset-right)) calc(24px + env(safe-area-inset-bottom)) calc(24px + env(safe-area-inset-left));
    gap: 14px;
  }
}
.controls-head { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 24px; }
/* 28, which is the artboard's and is NOT §2's h1: sideways the title is one
   item in a row beside a 44px strip, and 32 pushes the strip onto a second line
   at 844. Shrink-wrapped, because it is a flex item and the artboard draws it
   as one. */
.controls-head > h2 { font-size: 1.75rem; margin: 0; }
/* The help screen's input diagrams (JIO-890). `width: 100%` with a max-width
   and no width attribute on the <svg> is what keeps the drawing inside a 375px
   overlay: the viewBox scales it and the page never lays out wider than the
   viewport, which is the rule the whole of "Fitting the viewport" above is
   about. The legend under it is HTML and not more SVG text so it wraps and
   follows the reader's font size. */
.diagram { margin: 8px 0 12px; }
.diagram .dg { display: block; width: 100%; max-width: 320px; height: auto; margin: 0 auto; font-family: var(--font); }
.diagram-legend { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 4px 14px; font-size: var(--t-small); margin: 10px 0 0; padding: 0; list-style: none; }
.diagram-legend li { display: contents; }
.diagram-legend .r { color: var(--text); }
.diagram-legend .c { color: var(--muted); }
.diagram-legend .sw { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 7px; }
.diagram .small { margin: 8px 0 0; }

/* The Touch tab's diagram (JIO-891). The phone is drawn at 1:1 -- the svg's
   width IS its viewBox width -- because the labels inside it are the deck's own
   button names and a viewBox scaled down to share a line made them 7px. The
   legend is HTML rather than <text> in the svg for two reasons: it wraps by
   itself at any width, and a screen reader reads a list, not a picture.
   flex-wrap, not a media query: the legend's 240px basis plus the 300px phone
   is 558px, so it sits beside the phone in the 616px panel a desktop gets and
   drops below it inside the 317px one a 375px phone gets -- which is the
   ticket's "callout text below the phone on narrow screens", done by the
   layout rather than by a second copy of the drawing. */
.touch-diagram { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px 18px; margin: 8px 0 12px; }
/* The drawing is rendered at its own viewBox width, so one user unit is one CSS
   pixel and an 11px label inside it is 11px on screen (JIO-891). Each shape
   passes its width in `--tdg-w`; 300 is the two portrait ones, and the
   landscape shape's 440 is why the property exists at all (JIO-923). max-width
   still wins where the panel is narrower than the drawing, which is a scale
   down and a smaller label -- the landscape shape is only ever drawn on a
   viewport wide enough that it does not happen. */
.touch-diagram svg { flex: 0 0 auto; width: var(--tdg-w, 300px); max-width: 100%; height: auto; }
/* THE SEVEN SIZES IN THIS FILE THAT STAY px, and the four below (JIO-1188).
   Every other font-size in the sheet is rem so that a reader who raises their
   browser's default font size gets larger text; these style <text> INSIDE an
   SVG, and there the unit means something different. The svg above is drawn at
   its own viewBox width, so a size here is a USER UNIT that is then multiplied
   by whatever scale the viewBox is laid out at -- `font-size: 0.6875rem` would
   be scaled twice, once by the reader's root preference and again by the
   drawing, and a reader at 32px would get a label two dice wide sitting across
   the board it labels. The drawing is a picture of the game, not a run of
   prose: it scales as a picture, with the viewBox, which is the behaviour
   `scripts/probe-viewport.ts`'s `keyPx` already measures (it multiplies the
   computed size by the svg's scale before checking it against the 14px floor).
   A future sweep that converts these is the bug, not the fix. */
.tdg-key { font-family: var(--font); font-size: 11px; font-weight: 600; }
.tdg-tag { font-family: var(--mono); font-size: 9px; fill: var(--muted); }
.tdg-n { font-family: var(--font); font-size: 11px; font-weight: 700; fill: var(--ink); }
/* The score in your card, in the display face the real strip uses (JIO-1072).
   It is the one number in the drawing, and it is drawn rather than left out
   because the strip above the board is most of what changed about the phone. */
.tdg-score { font-family: var(--display); font-size: 15px; fill: var(--text); }
.tdg-keys {
  flex: 1 1 240px;
  min-width: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  counter-reset: tdg;
  display: grid;
  gap: 8px;
  align-content: start;
  font-size: var(--t-small);
  color: var(--text-2);
}
.tdg-keys li { counter-increment: tdg; display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 8px; align-items: start; }
/* The number is the same badge as the one on the drawing, so the eye pairs them
   without being told. ::before rather than a span in the markup: the list's
   order IS the numbering, and two places to keep that in step is one too many. */
.tdg-keys li::before {
  content: counter(tdg);
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--right);
  color: var(--ink);
  font-weight: 700;
  font-size: var(--t-small);
  display: flex;
  align-items: center;
  justify-content: center;
}

/* The Keyboard panel's drawing (JIO-889): one keyboard per grip, above the rows.
   Each figure gets its OWN horizontal scroller. The SVG carries a fixed
   width/height -- a keyboard shrunk to 375px is a keyboard whose caps cannot be
   read -- so at a phone width it is wider than the panel, and the container
   scrolls rather than the page. `.overlay .panel` is `width: min(680px, 100%)`,
   so nothing here can widen the document the way the footer's nowrap hint once
   laid the page out 1171px wide at 375 (JIO-833); `probe-viewport.ts` measures
   `documentElement.scrollWidth` against the viewport with Help open, which is
   the assertion that says so. */
/* ~~`.kbd-strip` and `.kbd-figure`~~ -- GONE WITH JIO-1099, and struck rather
   than deleted because the shape they held is what the grip toggle replaced.
   The Keyboard panel drew BOTH grips, one under the other, each a <figure>
   captioned by its own movement keys with "— the grip you are using" on one of
   them (JIO-889): two keyboards, because nothing on the screen let a player say
   which was theirs. `.grip-row` below is that control, so there is one drawing
   now -- the grip in hand, with the other grip's row dimmed on it, which the
   diagram has always drawn. */
.kbd-scroll {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  /* The page itself never pans (body is `touch-action: manipulation`); this one
     box does, sideways, so a thumb can read the far end of the keyboard. */
  touch-action: pan-x;
  padding-bottom: 4px;
}
.kbd-scroll svg { display: block; }
/* A DESKTOP DRAWS THE KEYBOARD AT THE ARTBOARD'S SIZE (JIO-1099). The SVG's own
   units are a 30px key, which is what fits a 375px phone with room to scroll
   sideways; design/pages/controls draws a 46px key -- 706 units against 483.5 --
   because in a 1000px column the phone-sized drawing is a third of the room and
   the hands under it are the subject. It has a viewBox, so this is one number
   and not a second set of coordinates. Centred, as the artboard centres it. */
@media (min-width: 720px) {
  .kbd-scroll { display: flex; justify-content: center; }
  .kbd-scroll svg { width: min(706px, 100%); height: auto; }
}
/* One line per role, and the three rules under them do keys, wires and legend
   swatches from it: adding a seventh role is one entry in KEY_ROLES, one
   `--role-*` above and one line here. */
.kbd-diagram .role-move, .kbd-legend .role-move, .keybinds .role-move { --tint: var(--role-move); }
.kbd-diagram .role-reroll, .kbd-legend .role-reroll, .keybinds .role-reroll { --tint: var(--role-reroll); }
.kbd-diagram .role-swap, .kbd-legend .role-swap, .keybinds .role-swap { --tint: var(--role-swap); }
.kbd-diagram .role-ability, .kbd-legend .role-ability, .keybinds .role-ability { --tint: var(--role-ability); }
.kbd-diagram .role-raise, .kbd-legend .role-raise, .keybinds .role-raise { --tint: var(--role-raise); }
.kbd-diagram .role-pause, .kbd-legend .role-pause, .keybinds .role-pause { --tint: var(--role-pause); }
.kbd-diagram .key rect { fill: var(--tint, var(--text)); fill-opacity: 0.14; stroke: var(--tint, var(--line)); }
.kbd-diagram .key text { fill: color-mix(in srgb, var(--tint, var(--muted)), var(--ink) var(--legend-mix)); font-family: var(--mono); text-anchor: middle; }
.kbd-diagram .key .hint { font-family: var(--font); font-size: 9px; fill-opacity: 0.85; }
/* Everything not bound to one of the six, dimmed rather than hidden: the caps
   around the tinted ones are what make the tinted ones a PLACE on a keyboard. */
.kbd-diagram .key.dim { opacity: 0.42; }
.kbd-diagram .wire { fill: none; stroke: var(--tint); stroke-width: 1.4; stroke-opacity: 0.75; }
.kbd-diagram .wire-end { fill: var(--tint); }
.kbd-diagram .cursor .frame { fill: none; stroke: var(--text); stroke-opacity: 0.45; stroke-dasharray: 3 3; }
.kbd-diagram .cursor .caption { fill: var(--muted); font-family: var(--font); font-size: 9px; text-anchor: middle; }
/* The dice need to read as dice against the panel, and --panel-2 on --panel is
   a 7-point difference in lightness: the pips floated with no cube under them
   until the outline was lifted off --line. */
.kbd-diagram .die rect { fill: color-mix(in srgb, var(--text) 8%, var(--panel-2)); stroke: var(--muted); stroke-opacity: 0.55; }
.kbd-diagram .die circle { fill: var(--text); }
.kbd-diagram .glyph .backing { fill: var(--panel); }
.kbd-diagram .glyph path { fill: none; stroke: var(--tint); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.kbd-diagram .pill rect { fill: var(--tint); fill-opacity: 0.16; stroke: var(--tint); }
.kbd-diagram .pill text { fill: color-mix(in srgb, var(--tint), var(--ink) var(--legend-mix)); font-family: var(--font); font-size: 10px; text-anchor: middle; }
/* The legend is HTML and wraps: it is the part that has to stay readable when
   the drawing beside it is scrolled off to the right. */
.kbd-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; list-style: none; margin: 2px 0 0; padding: 0; font-size: var(--t-small); color: var(--muted); }
.kbd-legend li { display: flex; align-items: center; gap: 6px; }
.kbd-legend .dot { width: 10px; height: 10px; border-radius: 3px; background: var(--tint); flex: none; }
/* THE HANDS (JIO-1099) -- design/pages/controls' six artboards draw two
   translucent silhouettes under the caps they are resting on, because a tint
   says what a key DOES and never which hand is on it. 16% of the text colour is
   the artboard's own number and it is the whole trick: at anything heavier the
   letters under the palm stop being readable, which is the one thing a keyboard
   drawing may not cost. The fingertip RINGS are drawn at full strength over the
   top -- which cap exactly is the part that has to be read, and it is the part
   a 16% blob cannot say. Both follow --text, so the light theme gets a dark
   hand on a pale board rather than a pale hand that vanishes. */
.kbd-diagram .hand { fill: var(--text); stroke: var(--text); opacity: 0.16; }
.kbd-diagram .fingertip { fill: none; stroke: var(--text); stroke-width: 1.6; opacity: 0.78; }
/* The grip toggle: a label and a segmented control on one row, the artboards'
   "Which hand steers?". It is a `.menu` so the pad and the keyboard reach the
   two segments the way they reach every other control in the game, and so the
   segmented control's own paint (`.overlay .menu .segmented`) applies -- the
   game has exactly one of those and this is not a second. */
/* `flex-direction: row` is not decoration: `.menu` is a COLUMN everywhere else
   in the game, so without it the question stacks above its own control and the
   `align-items: center` below centres the question over a left-aligned one. */
.overlay .menu.grip-row { display: flex; flex-direction: row; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 0; }
.grip-q { font-size: var(--t-small); font-weight: 600; color: var(--muted); }
.overlay .menu .segmented.grip-seg { margin: 0; }
/* The grip names are the config's own keys ("arrows", "wasd") joined to the row
   they put under the free hand, so the caps are already upper case and only the
   name needs lifting. */
.overlay .menu .segmented.grip-seg .seg { text-transform: uppercase; }
.kbd-off { color: var(--muted); font-size: var(--t-small); margin: 6px 0 0; }

/* THE KEYBINDS CHART (JIO-1079) -- design/pages/howtoplay/Keybinds.dc.html,
   which is milestone 7's artboard; this ticket routes the card to it and fills
   it with the live table so the screen is never empty.

   One grid, used by both blocks and by their heading rows, so a header and the
   cells under it cannot fall out of step: the puzzle block's KEYBOARD header
   carries the same `kb-both` the cells do, and spans the two grip columns with
   them. The action column is the wide one -- "Reroll right" in a quarter of a
   350px panel is two lines, and every other cell is one or two caps. */
.kb-lede { margin: 0 0 16px; font-size: var(--t-body); line-height: 1.5; }
.keybinds {
  background: var(--panel); color: var(--text);
  border: 1px solid var(--line); border-radius: 14px;
  box-shadow: var(--card-shadow);
  padding: 12px 14px 4px; margin: 0 0 16px;
}
.keybinds h3 { margin: 0 0 6px; font-family: var(--display); font-weight: 400; font-size: var(--t-card); line-height: 1.1; }
.kb-head, .kb-row { display: grid; grid-template-columns: minmax(0, 1.3fr) repeat(3, minmax(0, 1fr)); gap: 8px; align-items: center; }
/* 14px and not the artboard's 12: §2's floor is 14px for everything but the
   mono role, and `test/type.test.js` fails a size under it rather than letting
   a header shrink to fit (JIO-1062). What gives instead is the letter-spacing
   and the colour, which is what was carrying the header anyway. */
.kb-head { padding-bottom: 6px; font-size: var(--t-small); font-weight: 700; letter-spacing: 0.06em; color: var(--muted); }
.kb-row { padding: 10px 0; border-top: 1px solid var(--line); }
.kb-action { display: flex; align-items: center; gap: 8px; font-size: var(--t-small); font-weight: 700; }
/* The role dot of the keyboard drawing's own legend, in the same colour for the
   same action (ui/keyboardDiagram.ts's KEY_ROLES). Round rather than the
   legend's rounded square, which is the artboard's, and the two are never on
   screen together. */
.kb-action .dot { width: 8px; height: 8px; border-radius: 999px; background: var(--tint); flex: none; }
.kb-keys { display: flex; gap: 4px; flex-wrap: wrap; }
/* A tighter cap than the help screen's, because four of them share one column
   here -- Move on the arrows grip is Up, Down, Left, Right in an 80px track.
   The four still stack, and that is the cost of naming keys in WORDS: the
   artboard draws them as arrow glyphs, and a glyph here would be a SECOND
   printed name for a code that already has one (input.ts: "a code has ONE
   printed name", which is how `;` once read "Semicolon" in one place and ";"
   in another). Four caps in a column read as the arrow cluster they are. The
   size floor is the type scale's -- 14px, `test/type.test.js` -- so the padding
   is the only thing left to give. */
.keybinds kbd { padding: 1px 5px; }
/* One cell across both grip columns, for an action that is the same key
   whichever hand steers -- raise, pause and back, which have no entry in
   `bindings.grips` at all (ui/screens.ts says why it is a span rather than the
   artboard's two cells). */
.kb-both { grid-column: span 2; }
/* A pad button is round and a key cap is square, the same distinction the help
   screen's gamepad panel draws (JIO-888). */
.kb-pad kbd { border-radius: 999px; border-bottom-width: 1px; padding: 2px 8px; text-align: center; }
/* "This method has no control for that action" (JIO-1189). Muted and NOT a
   `<kbd>`, because a cap is a key you can press: the one row that has it is
   Sound on/off, which is a key and two buttons on screen and nothing on a pad.
   An empty cell said the same thing to nobody. */
.kb-none { color: var(--muted); }
.kb-foot { margin: 0; font-size: var(--t-small); line-height: 1.4; color: var(--muted); }
/* One CARD per action (JIO-892), which is what the panel holds instead of the
   two-column table it used to. A row reading "Reroll left die | Z" named the
   key and said nothing about what pressing it does, on the one screen a player
   opens because they do not know; the card stacks the control, the name and
   two or three sentences of mechanics, so the answer and the key are the same
   object.
   auto-fit with a min track rather than a fixed column count: this is the same
   markup at 375px and at 1920px, and a fixed track is the JIO-833 overflow
   exactly -- at 375px the cards fall to one column on their own. */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; margin: 8px 0 12px; }
.card { background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; min-width: 0; }
/* The caps sit ABOVE the name: on a tab whose whole job is "here is your
   control", the control is the first thing read. flex-wrap because Reroll
   carries two of them and the keyboard's raise cap is three codes long. */
.card .caps { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
.card h4 { margin: 0 0 4px; font-family: var(--display); font-weight: 400; font-size: var(--t-card); line-height: 1.1; color: var(--right-text); }
.card p { margin: 0; font-size: var(--t-body); color: var(--text-2); line-height: 1.5; }
/* A pad button is round and a key cap is square, and drawing "A" the pad
   button as a key is exactly the confusion the tab strip was built to remove
   (JIO-888). One attribute on the grid does it, so the cards themselves stay
   method-agnostic. */
.cards[data-method="gamepad"] kbd {
  border-radius: 999px;
  border-bottom-width: 1px;
  padding: 2px 8px;
  min-width: 14px;
  text-align: center;
}
.stats { display: grid; grid-template-columns: 1fr auto auto; gap: 4px 16px; font-size: var(--t-small); margin: 12px 0; font-family: var(--mono); }

/* ---- THE TOWER'S TWO ENDINGS (JIO-1232) -----------------------------------

   The cash-out and the topped-out sheet, and the four boxes they are made of:
   the number the step paid, the tally under it, the stars, and one line of
   grade. They are `.tally` and not `.stats` above for the reason the high-score
   board is `.scoreboard` and not `.stats`: that grid is three columns of mono at
   --t-small and this is a two-column ledger with a rule over its total, and
   dressing one into the other would move every table that shares it. */
.tower-total {
  font-family: var(--mono); font-weight: 700; font-size: var(--t-hero); line-height: 1.05;
  color: var(--left); margin: 10px 0 4px; font-variant-numeric: tabular-nums;
}
/* ~~TABULAR FIGURES ARE WHAT MAKE THE ROLL-UP READABLE~~ -- the roll-up went
   when the cash-out's strip became clocks (JIO-1609). What is left here is the
   TOP-OUT's total, which never rolled: `.tower-total.short` is the only thing
   that still wears this block. */
.tower-total.short { color: var(--danger); }
.tower-total.short .of { color: var(--muted); font-weight: 400; font-size: var(--t-h2); }
/* `.tower-tally` AND NOT `.tally`, AND THE NAME IS THE WHOLE OF THE BUG IT
   FIXES. `.tally` was already taken -- it is the status card's sent / locked row
   (`#tally-left`), and `.score-line, .tally { display: none }` further down this
   file hides it everywhere but a compact Versus or tutorial board. A cash-out
   whose tally wore that class was a cash-out with NO TALLY: the screen drew the
   total, then the stars, and the four-row panel this ticket is mostly about
   never appeared on a page at all. Every test passed -- they read the markup
   this file then hid -- and what found it was probe-artboards.ts's CashOut row
   measuring the box at 0x0 (2026-09-18). A second class with one name is a
   collision whichever of the two is older. */
/* ~~AND THE ROWS IN IT~~ -- **THE PANEL IS THE TIP'S NOW AND NOTHING ELSE'S**
   (JIO-1503). The cash-out drew its tally here: two lines, a dashed spare
   reserved in the LAYOUT for a currency a later ticket adds, and a ruled SCORE.
   That screen is a Poster and its three numbers are its strip, so the rows and
   the reserve are gone with it -- eight dead declarations are bytes a phone
   downloads (web/ is served unminified), and a spare kept for a panel nothing
   draws reserves nothing. A currency arriving now is a fourth strip cell, which
   `POSTER_STRIP_MAX` refuses at three: that is the decision to re-take, and
   design/pages/results/NOTES.md is where it is written down. */
.tower-tally {
  display: grid; gap: 0; margin: 12px 0;
  background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 4px 0;
}
/* The stars, and the line under them that says what they cost. A star is a
   glyph rather than a drawing because it is one character in both palettes and
   at every size the sheet is read at; what it MEANS is in the `aria-label` on
   the row and in the sentence below it, so the mark is never the only carrier
   (the rule the high-score board's ALL-TIME BEST tag already keeps).

   ~~THE COLOURS ARE NOT HERE.~~ ~~`.tower-earned .tower-stars` sized the
   cash-out's row, a cash-out's stars being the display the screen is read for
   where the sheet's are a line in a definition list.~~ **The cash-out is a Poster
   and its stars are its HERO** (JIO-1503): they are sized by `--poster-hero` and
   coloured in the field's own ink, both in the Results poster block further up
   this file, so the display size and the two opacities live where the screen
   they belong to does. `.tower-stars` is still one component and still coloured
   once, below -- what is gone is the second sizing of it. */
/* THE TOP-OUT'S EYEBROW AND ITS BEST (JIO-2064). `FLOOR 1 · STEP 3 · WORKSHOP`
   under the title in the display face at 12px, letter-spaced and muted -- the
   size and ink of the panel's other eyebrows (`.tp-kicker`, `.basecamp-eyebrow`)
   -- on the 6px the sentence it replaces had (`.overlay .result-why`). `Best
   512` is one small muted line between the strip and the buttons, and it is
   drawn only once there is a best. ~~`.tower-grade` and the `.tower-tip`
   card~~ went with the first-attempt line and the tip. */
.overlay .tower-where { margin: 6px 0 0; font-family: var(--display); font-weight: 400; font-size: 0.75rem; line-height: 1.2; letter-spacing: 0.12em; color: var(--muted); }
.overlay .tower-best { color: var(--muted); font-size: var(--t-small); margin: 0 0 8px; }

/* THE FLOOR CLEARED (JIO-1235): the label, CLEARED, the emblem, the line, and
   the three rows of what was paid.

   THE EMBLEM IS THE ONE BIG OBJECT ON THE SCREEN and it is the Guardian's own
   mark (ui/emblems.ts), in `--right` -- the amber a Guardian is drawn in
   everywhere else in this mode, on the staircase and on its landing. Teal is
   the player's colour and would have made the floor's crest read as the
   player's own.

   EACH ROW SAYS WHERE ITS REWARD IS WORN, and the tag is the only thing on the
   screen in a box: NEW on the two that are new objects, RANK on the one that is
   a word, because a third NEW beside a rank would say a rank is a thing you
   collect rather than what you are now called. */
.tower-floor-line { margin: 0; font-family: var(--display); font-weight: 400; font-size: var(--t-small); letter-spacing: 0.04em; color: var(--muted); }
.tower-crest { display: flex; justify-content: center; margin: 12px 0 6px; color: var(--right); }
.tower-emblem { display: block; }
.tower-rewards { display: flex; flex-direction: column; gap: 8px; margin: 14px 0 0; }
.reward-row {
  display: flex; align-items: center; gap: 12px;
  padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel);
}
.reward-mark { display: flex; align-items: center; justify-content: center; width: 32px; flex: none; color: var(--right); }
.reward-body { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; min-width: 0; }
.reward-name { font-family: var(--display); font-weight: 400; font-size: var(--t-card); line-height: 1.1; }
.reward-note { color: var(--muted); font-size: var(--t-small); line-height: 1.4; font-weight: 600; }
.reward-tag {
  flex: none; font-size: 0.75rem; font-weight: 700; line-height: 1; letter-spacing: 0.06em;
  padding: 4px 8px; border-radius: 999px; background: var(--left); color: var(--bg);
}
/* The rank's tag is the outline of the other two: it is a word the player is
   given, not an object added to a shelf. */
.reward-row:last-child .reward-tag { background: none; border: 1px solid var(--line); color: var(--muted); }

/* AND WHAT THE NEXT FLOOR HANDS YOU (JIO-1654), which is decision 0087's second
   surface and the first thing on this screen that is not a reward.

   A KIT CARD IS IN THE TOOL'S OWN COLOUR, off `--meter-accent` and the one map
   of id to hue the ability picker's block holds (further up this file): the
   glyph and the hue beside it are what the meter will show when the thing
   fires. The three kit items that are not abilities -- rerolls, the second
   charge, the picker -- have no hue of their own and fall back to `--left`,
   which is the player's own colour and right for a thing that is simply theirs
   now.

   A RULE CARD IS AMBER AND HAS NO FIGURE. `--right` is the Guardian's colour
   everywhere in this mode, and a rule is the half of what is coming that is done
   TO the player.

   THE REWARD ROWS' OWN PILL, `.reward-tag`, and not a third one: two pill shapes
   on one screen for one idea would be two components. */
.tower-upstairs { margin: 18px 0 0; }
html .app .overlay .panel .tower-upstairs > h3 {
  margin: 0 0 8px; font-family: var(--display); font-weight: 400; font-size: 0.8125rem;
  line-height: 1; letter-spacing: 0.08em; color: var(--muted);
}
.overlay .tower-upstairs .card.tfc-card {
  display: flex; align-items: center; gap: 12px; padding: 10px 14px; cursor: default;
}
.overlay .tower-upstairs .card.tfc-kit {
  border-color: color-mix(in srgb, var(--meter-accent, var(--left)) 55%, var(--line));
  background: color-mix(in srgb, var(--meter-accent, var(--left)) 10%, var(--panel));
}
.tower-upstairs .tfc-kit .card-title { color: var(--meter-accent, var(--left)); }
/* THE MARK BOX IS ONE HEIGHT AND NOT ONE WIDTH, whichever of the two marks is in
   it: an ability's square 16-unit path, or a kit item's 120x90 figure, which
   ui/sketch.ts draws UNSIZED so this rule can set the 40. `width: auto` keeps
   the reroll deck from being squashed into the reward rows' 32px square, which
   is why the box is a `min-width`. The two never share a stack anyway -- no
   floor in `tower.json` is the first to carry both an ability and one of the
   three kit items. */
.tower-upstairs .tfc-figure { display: flex; align-items: center; justify-content: center; flex: none; min-width: 40px; color: var(--meter-accent, var(--left)); }
.tower-upstairs .tfc-figure svg { display: block; height: 40px; width: auto; max-width: 72px; }
.overlay .tower-upstairs .card.tfc-rule {
  border-color: color-mix(in srgb, var(--right) 55%, var(--line));
  background: color-mix(in srgb, var(--right) 10%, var(--panel));
}
.tower-upstairs .tfc-rule .card-title { color: var(--right); }
.tower-upstairs .tfc-rule .reward-tag { background: var(--right); }
/* THE CARD'S OWN BODY AND NOT A SECOND LAYOUT OF IT: `.card-body` is already a
   wrapping flex row and `.card-sub` already `flex: 1 0 100%`, which puts the line
   under the name with the pill beside both. Only the ink moves -- `--text-2`,
   because this line is the card's content and not a note under something else. */
.tower-upstairs .card-sub { color: var(--text-2); }
/* The cards' own stack and not `.menu`'s 16px either side of it: this section
   sets its own 18 above and the button block keeps its 16, so a menu margin in
   here would be a third gap nobody chose (JIO-1465's arithmetic). */
.tower-upstairs .menu { margin: 0; }

/* THE TOWER ON RECORDS (JIO-1235). A section of content under the two cards,
   not a third card -- the map is the climb's own screen and this page has
   nothing to open.

   THE CELLS ARE ~~A ROW OF SIX~~ TWO ROWS OF FOUR AND NOT A TABLE. ~~Five steps
   and a Guardian is the floor~~ -- six steps, a mid-boss and a Guardian is
   (JIO-1740) -- and eight boxes read as a floor at a glance where eight table
   rows read as a list of two hundred things. `repeat(4, 1fr)` rather than the
   `repeat(6, 1fr)` that held while a floor was six: at a sixth of a 320px phone
   a cell was already 44px wide, and an eighth of one cannot hold a four-digit
   best. Four and four also breaks where the floor does -- each row ends on a
   boss, the mid-boss closing the first and the Guardian the second. */
.tower-records { display: flex; flex-direction: column; gap: 10px; }
.tower-records > h3 { margin: 0; font-family: var(--display); font-weight: 400; font-size: var(--t-h2); line-height: 1.1; }
.tower-standing-line { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.tower-badges { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
.tower-badge {
  display: flex; flex-direction: column; align-items: center; gap: 4px; text-align: center;
  padding: 10px 4px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel);
  color: var(--muted); opacity: 0.5;
}
/* A LOCKED BADGE IS DIMMED AND NOT HIDDEN, which is this game's rule for a
   locked anything -- the Puzzles page dims a locked world so you can see what is
   coming, and the map's floor sheet lists floors nobody has reached. */
.tower-badge.earned { opacity: 1; color: var(--right); border-color: var(--right); }
.tower-badge .badge-name { font-family: var(--display); font-weight: 400; font-size: 0.75rem; line-height: 1.1; color: var(--text); }
.tower-badge .badge-note { font-size: 0.6875rem; line-height: 1.2; font-weight: 600; color: var(--muted); }
.tower-record-floors { display: flex; flex-direction: column; gap: 8px; }
.tower-record-floor { border: 1px solid var(--line); border-radius: 10px; background: var(--panel); padding: 10px 12px; }
.tower-record-floor.unreached { opacity: 0.55; }
.tower-record-floor .floor-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.tower-record-floor .card-title { font-size: var(--t-small); letter-spacing: 0.02em; flex: 1 1 auto; min-width: 0; }
.tower-record-floor .floor-stars { color: var(--muted); font-size: var(--t-small); font-weight: 600; white-space: nowrap; }
.tower-cells { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
.tower-cell {
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  padding: 6px 2px; border-radius: 8px; background: var(--panel-2); min-width: 0;
}
.tower-cell.is-locked { opacity: 0.5; }
.tower-cell.is-current { outline: 1px solid var(--left); }
.tower-cell .cell-name { font-family: var(--display); font-weight: 400; font-size: 0.75rem; line-height: 1; color: var(--muted); }
.tower-cell.guardian .cell-name { color: var(--right); }
.tower-cell .cell-stars { font-size: 0.6875rem; line-height: 1; }
.tower-cell .cell-best { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 0.6875rem; line-height: 1.2; color: var(--text); }

/* THE SHELF ON RECORDS (JIO-1389). A section of content under the Tower's,
   built like it and for its reason -- an achievement has nothing to open, so a
   card here would be a door to a room that is not there.

   A COLUMN OF WIDE ROWS AND NOT A GRID OF TILES, which is where it parts from
   the Tower's badge shelf a few rules up. A badge is one word under a mark and
   five of them fit a phone; an achievement carries a sentence -- "Finish the
   Basics tutorial" is what an unearned row is FOR -- and five of those across a
   320px screen is four words per column. So the mark is on the left, the title
   and the line beside it, and the date at the end.

   AN UNEARNED ROW IS DIMMED AND NOT HIDDEN, which is this game's rule for a
   locked anything: the Puzzles page dims a locked world so you can see what is
   coming, the map's floor sheet lists floors nobody has reached, and the badge
   shelf above draws all five. It is the same 0.5 those use, so the two sections
   of one screen read as one decision. */
.achievements { display: flex; flex-direction: column; gap: 10px; }
.achievements > h3 { margin: 0; font-family: var(--display); font-weight: 400; font-size: var(--t-h2); line-height: 1.1; }
.achievement-rows { display: flex; flex-direction: column; gap: 8px; }
.achievement {
  display: grid; grid-template-columns: 32px 1fr auto; align-items: center; gap: 4px 12px;
  padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel);
  opacity: 0.5;
}
.achievement.earned { opacity: 1; border-color: var(--right); }
/* THE MARK SPANS BOTH ROWS, so the title and its line sit as one block beside
   it rather than the note wrapping back under the icon. `grid-row: span 2` and
   not a flex column inside a wrapper, because the date in the third column has
   to align with the TITLE's line and not with the middle of the pair. */
.achievement .achievement-mark { grid-row: span 2; display: flex; align-items: center; color: var(--muted); }
.achievement.earned .achievement-mark { color: var(--right); }
.achievement .card-title { font-family: var(--display); font-weight: 400; font-size: var(--t-small); line-height: 1.1; letter-spacing: 0.02em; color: var(--text); }
/* THE DATE IS PINNED TO THE TITLE'S ROW rather than left to auto-placement,
   which put it under the icon at the bottom-left and made an earned row 85px to
   an unearned one's 60px: the note above spans both remaining columns, so by the
   time the date is placed the only cell free is a third row. It takes the
   title's 1.1 as well as its row: the line box is what the row's first track is
   as tall as, and at the page's own 1.5 an earned row would still be 4px taller
   than an unearned one. */
.achievement .achievement-when { grid-row: 1; grid-column: 3; font-size: var(--t-small); line-height: 1.1; font-weight: 600; color: var(--muted); white-space: nowrap; }
/* The line is under the title and across both of the columns above it: it is a
   sentence, and a sentence in a column beside a date wraps to three words. */
.achievement .achievement-note { grid-column: 2 / -1; font-size: var(--t-small); line-height: 1.3; color: var(--muted); }
/* THE TOWER'S BADGES UNDER THE RUNGS (JIO-2031), in the rung's own paint so
   the two sets read as one shelf; a locked tile is the rung's 0.5 with a lock. */
.tower-badges { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; }
.tower-badges-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.tower-badges-head h3 { margin: 0; font-family: var(--display); font-weight: 400; font-size: var(--t-body); line-height: 1.1; }
.tower-badges-tally { font-size: var(--t-small); font-weight: 600; color: var(--muted); }
.badge-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; }
.badge-tile {
  display: flex; flex-direction: column; align-items: center; gap: 3px; text-align: center;
  padding: 8px 2px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel);
  opacity: 0.5; min-width: 0; color: var(--muted);
}
.badge-tile.earned { opacity: 1; border-color: var(--right); color: var(--right); }
.badge-tile-mark { display: flex; align-items: center; justify-content: center; height: 28px; }
.badge-tile-mark .lock-mark { width: 16px; height: 16px; }
.badge-tile-name { font-family: var(--display); font-weight: 400; font-size: var(--t-small); line-height: 1.1; color: var(--text); overflow-wrap: anywhere; }
.badge-tile-floor { font-size: var(--t-small); line-height: 1.1; font-weight: 600; color: var(--muted); }
/* THE NEW TAG ON A MODE CARD (JIO-1232), over the art's top-left corner (the
   `position: relative` it is placed against is on `.mode-art` itself). It is a
   WORD inside the button, so a screen reader reads "TOWER, NEW" off the card's
   own name rather than needing a label of its own -- and so a player who cannot
   tell the teal from the panel still gets told. */
.card-tag {
  position: absolute; top: 8px; left: 8px;
  font-family: var(--display); font-size: 0.6875rem; line-height: 1; letter-spacing: 0.06em;
  padding: 5px 8px; border-radius: 999px; background: var(--left-text); color: var(--bg);
}
/* THE CHIP IS FILLED IN `--left-text` FOR THE REASON SMALL TEXT IS SET IN IT
   (section 1). An 11px word reversed out of the bright accent is small text
   whichever of the two colours is the ink: white on `--left` is 3.04:1 on a
   light page, which scripts/probe-theme-contrast.ts failed on in all seven
   light passes (2026-09-18) from the day the tag landed. The text-safe teal is
   the same colour in dark, so the tag a player sees today is unchanged. */
/* THE HIGH-SCORE BOARD (JIO-1081) -- design/pages/menu/HighScores.dc.html. Five
   rows for the chosen speed, each a 52px card: the rank in Bungee 16, the score
   in mono 20/700, the date right-aligned in Nunito 14/600 muted.

   ~~It reuses `.stats`' three columns (JIO-873)~~ -- it does not, since this
   ticket. That grid is one line of mono per row at `--t-small`, which is what
   the result screen's tables are; the artboard draws a bordered card per run
   with a display-face rank and a 20px score, and dressing `.stats` into that
   would have changed every table that shares it. The struck line stays because
   a reader who finds two grids doing similar work should know the split was
   made on purpose.

   THE ALL-TIME ROW IS OUTLINED IN TEAL AND ALSO SAYS SO IN WORDS (the
   `ALL-TIME BEST` tag ui/screens.ts draws into it): colour is never the only
   carrier of a state on these screens, which is the rule the run rows, the
   puzzle tiles and the keyboard legend already keep. */
/* `.scoreboard` AND NOT `.board`, which is the first name this rule was given
   and is already taken: `web/dicelock.html` gives each `<canvas>` in the arena
   `class="board"`, so a bare `.board { display: flex }` laid the GAME's two
   canvases out as flex columns with a 16px bottom margin -- which shrank the
   cell size and failed probe-viewport.ts's "player cell 43 < 44" at one of the
   eight viewports while every unit test and every screenshot of this screen
   passed (2026-09-13). A menu screen and the arena share one stylesheet, and a
   name that reads as obvious on one of them is the other one's already. */
.scoreboard { display: flex; flex-direction: column; gap: 8px; margin: 0 0 16px; }
.scoreboard .score-row {
  display: flex; align-items: center; gap: 12px;
  min-height: 52px; padding: 8px 16px;
  background: var(--card); border: 1px solid var(--line); border-radius: 14px;
}
.scoreboard .score-row.best { border-color: var(--left); }
.scoreboard .score-rank { flex: none; width: 28px; font-family: var(--display); font-weight: 400; font-size: var(--t-body); color: var(--muted); }
.scoreboard .score-row.best .score-rank { color: var(--left-text); }
/* 20px is `--t-card`'s number, asked for by the token rather than typed: the
   artboard draws the score at 20 in mono, and a size on this screen that is not
   one of §2's eight is a ninth size nothing approved. The result screen's
   `.big` takes `--t-h2` the same way, for the same reason. */
.scoreboard .score-points { font-family: var(--mono); font-weight: 700; font-size: var(--t-card); color: var(--text); }
/* THE TAG IS 14px AND THE ARTBOARD DRAWS 12, and the floor wins -- the same
   call the Keybinds chart's header row made (JIO-1079). §2 floors everything
   but the mono role at 14px for anything a player reads, `test/type.test.js`
   fails a size under it, and this is a label read at a glance on the one row
   the screen is pointing at. The letter-spacing and the accent are what make it
   read as a tag rather than as a sentence, which is what the 12 was doing. */
.scoreboard .score-tag { font-family: var(--font); font-weight: 700; font-size: var(--t-small); letter-spacing: 0.06em; color: var(--left-text); }
.scoreboard .score-date { margin-left: auto; font-family: var(--font); font-weight: 600; font-size: var(--t-small); color: var(--muted); }
/* THE HEADING OVER A RETIRED WIDTH'S RUNS (JIO-1147). It is `--muted` and not
   the tag's accent on purpose: the accent on this screen means "this row is
   the one being pointed at", which is the all-time best, and a second thing
   wearing it would make the retired game look like the achievement. Muted, at
   §2's small role with the tag's letter-spacing, it reads as a divider naming
   what is under it -- which is all it is. The top margin is 8px against the
   `.scoreboard` gap's 8, so the break between two groups is twice the break
   between two rows and the grouping is legible without a rule drawn across. */
.scoreboard .score-group {
  margin: 8px 0 0; font-family: var(--font); font-weight: 700;
  font-size: var(--t-small); letter-spacing: 0.06em; color: var(--muted);
}
.scoreboard .score-group:first-child { margin-top: 0; }
/* THE WIDTH ON A RETIRED-SCORING ROW (JIO-1547), in the column `.score-rank`
   holds on the board above. It takes the `.score-group` heading's type rather
   than the rank's display numeral on purpose: it is the same kind of fact as
   the heading -- which game this row is from -- and wearing the rank's face
   would read as a rank, which is the one thing these rows do not have. The
   column is wider than the rank's 28px because `7-FACE` is six glyphs where a
   rank is one, and it is fixed rather than `auto` so the scores line up down
   the list the way they do under a heading. */
.scoreboard .score-width {
  flex: none; width: 60px; font-family: var(--font); font-weight: 700;
  font-size: var(--t-small); letter-spacing: 0.06em; color: var(--muted);
}
/* The board with nothing on it yet. `--t-body` and not `--t-small`: it is the
   only sentence in that space and it is prose, which is §2's body role. */
.overlay .empty { color: var(--muted); font-size: var(--t-body); margin: 0 0 16px; }

/* The History screen (JIO-991). One row per finished run, two lines in two
   columns: what it was over what it came to. It is NOT `.stats` -- that grid is
   three fixed columns of one line each, and a run carries seven fields -- but
   it borrows its mono column and its colours so a duration here and a duration
   on the result screen are the same thing to look at.

   Since JIO-992 a row is a <button> inside the `.menu`, so the keyboard and the
   pad walk the rows as well as Back -- a row opens the replay, and a control
   only a mouse can reach is not one this game ships. Every rule below is
   written for both shapes: over a paused run the list is handed no `onOpen` and
   the rows are the <div>s they were, because a replay cannot be opened there
   (ui/screens.ts).

   `.app .overlay .runs .run` IS FOUR CLASSES BECAUSE THREE WAS NOT ENOUGH. This
   rule read `.runs .run` and its comment claimed the menu-line rules were
   "deliberately outgunned by the selectors here" -- which was true of `.menu
   button` and false of `.app.compact .menu button { padding: 12px 14px }` at
   the bottom of this file: three classes, and later in source order. So on
   every phone the row's horizontal padding was the compact menu button's 14px
   and never the number written here, silently, in both themes. probe-chrome.ts
   found it the first time anything measured the row's computed padding against
   the artboard's rather than measuring its height (2026-09-14), which is the
   same reason the rest of this ticket exists: a constraint nothing compares to
   the design passes by never being looked at. */
.runs { margin: 12px 0; display: flex; flex-direction: column; gap: 8px; }
.app .overlay .runs .run {
  display: grid;
  /* THE ARTBOARD'S TWO LINES -- and they are two LINES, each with its own two
     columns, rather than one 2x2 grid (JIO-1081). History.dc.html draws the row
     as `repeat(2, minmax(0, 1fr))` because every string in its sample data is
     short enough for the half it lands in; a real row's numbers are not.
     "6:48 - 9,915 pts - chain 4" is 26 mono characters against the 159px half
     of a 390 frame's row, so equal columns wrapped the numbers -- the second
     line becoming a third, and the ~87px row George measured (2026-09-14).
     Sizing the right column to its content fixed the wrap and bought a worse
     bug: a grid's columns are SHARED down its rows, so the widest numbers set
     the mode's column too and every Endless row drew as "ENDLES...". A row's
     title is the one field on it that may never be cut.
     So each line is its own box and sizes itself, which is what the artboard
     looks like at the artboard's strings and degrades by shortening the MOMENT
     -- recoverable by opening the row -- rather than the mode or the numbers. */
  grid-template-columns: minmax(0, 1fr);
  gap: 4px;
  padding: 12px 16px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--card);
  /* A row sits inside `.menu` and is deliberately not a menu card: four fields
     in a grid, so it takes the body face back off `.menu button` above. */
  font-family: var(--font);
  font-size: var(--t-small);
  line-height: 1.4;
}
/* The run this page load played, marked by a rail down its leading edge as well
   as by the words in the row: colour is never the only carrier of a state here,
   the same rule the puzzle tiles and the keyboard legend already keep. */
.app .overlay .runs .run.mine { border-color: var(--left); box-shadow: inset 3px 0 0 var(--left); }
/* A row as a control: the button reset a menu line does not need, because a
   <button> brings its own centring, its own colour and its own width. */
.app .overlay .runs button.run { width: 100%; color: inherit; text-align: left; cursor: pointer; }
/* A TABLE'S ROW IS AN ANCHOR AND NOT A BUTTON (JIO-1172), because following it
   is a navigation: `?lobby=<slug>` on the host that runs the match server, which
   may not be this one -- the published jiolab.dev copy links to
   dicelockgame.com. The reset is the anchor's own: `text-decoration` and the
   link colour, which would otherwise make one list on this screen blue and
   underlined and the one above it not. Everything else -- the grid, the border,
   the focus ring -- it already takes from `.run` beside the buttons. */
.app .overlay .runs a.run { color: inherit; text-decoration: none; cursor: pointer; }
/* The focus ring is written HERE rather than inherited from `.menu
   button.focused`: `.runs .run.mine` is three classes and would outrank it, so
   the run this page load played would be the one row that could take the focus
   and not show it. The marked row keeps its rail AND takes the ring. */
.app .overlay .runs .run.focused, .app .overlay .runs .run:focus-visible { border-color: var(--left); box-shadow: 0 0 0 2px var(--left-dim); outline: none; }
.app .overlay .runs .run.mine.focused, .app .overlay .runs .run.mine:focus-visible { box-shadow: inset 3px 0 0 var(--left), 0 0 0 2px var(--left-dim); }
/* A run that will not replay, marked by its edge as well as by the words "no
   replay" in its numbers: colour and typography are never the only carrier of a
   state on these screens. It still opens -- what is behind it is the reason,
   beside everything the recording does still hold (JIO-992). */
.app .overlay .runs .run.no-replay { border-style: dashed; }
/* One line of the row: what it was, then what it came to, pushed apart. `auto`
   on the right and `minmax(0, 1fr)` on the left is the artboard's split with
   the right-hand cell allowed the width its string actually needs. */
.app .overlay .runs .run-line { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: baseline; line-height: 1.3; }
/* 1.3 and not the menu button's own: History.dc.html's lines are the faces' own
   line boxes (Bungee 16 is 21px, Nunito 14 is 19px), which is a 70px row. The
   inherited leading drew 72, and six rows of 2px is the 12px the list ran long. */
/* THE MODE IS DISPLAY TYPE, which is the artboard's own call and was the
   sixth of George's eight (2026-09-14): History.dc.html sets every mode name in
   Bungee 16 and the implementation set it in Nunito 600, so a list of runs read
   as a paragraph of sentences rather than as a column of labels. Bungee ships
   uppercase glyphs on both cases, so `text-transform` is what stops "Vs
   Computer" being drawn with a cap height it did not ask for -- MODE_LABELS
   stays title-case, because the same strings name the mode in prose on the
   replay-refused screen (ui/screens.ts).
   `--t-body` at `--display` is the same rung `.score-rank` and the speed chips
   take: Bungee 16 is not one of §2's four display sizes, and all three of these
   are a LABEL inside a row rather than a heading over one. */
.runs .run-mode {
  font-family: var(--display);
  font-weight: 400;
  font-size: var(--t-body);
  text-transform: uppercase;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.runs .run-vs { font-family: var(--font); font-size: var(--t-small); text-transform: none; color: var(--muted); font-weight: 600; }
.runs .run-result { text-align: right; font-weight: 700; white-space: nowrap; }
/* A win is the ok tone and not the player's teal: History.dc.html draws Won and
   Solved in #34d399, which is `--ok`, and the ticket asks for "win/loss/neutral
   tone" -- teal is the colour of the left-hand player and of focus on these
   screens, so a teal Won beside a teal focus ring read as two rows selected. */
.runs .run-result.victory { color: var(--ok-text); }
.runs .run-result.defeat { color: var(--danger-text); }
.runs .run-result.draw { color: var(--muted); }
/* THE MOMENT IS BODY TYPE AND NOT MONO, off the artboard (JIO-1081), and that
   is also what buys the two columns their width back: "Today, 14:02" is ~84px
   in Nunito 14 against ~101px in Menlo at the same size, on the one line that
   was overflowing. It is not a column of figures to be scanned down -- it is
   three different shapes ("Today, 14:02", "Yesterday", "Sep 8") and mono does
   nothing for it. The NUMBERS stay mono: a duration over a duration and a score
   over a score are read against each other down the list, which is what the
   mono role is for.
   14px and not the artboard's 13: §2's floor wins over the scale's mono row for
   anything a player reads on a menu, which is the collision
   `test/type.test.js` pins deliberately. */
.runs .run-when { color: var(--muted); font-family: var(--font); font-size: var(--t-small); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.runs .run-when em { font-style: normal; color: var(--left-text); }
/* THE NUMBERS MAY WRAP, AND THE `auto` TRACK IS WHAT MAKES THAT THE LAST RESORT
   RATHER THAN THE DEFAULT. An `auto` grid track sizes to its MAX-content where
   there is room -- one line, which is the artboard -- and shrinks no further
   than its MIN-content when there is not, which is the widest unbreakable token
   in it ("1,234,567"). So the moment gives up its width first, then the numbers
   break at their own separators, and the row only ever gets taller when the
   string genuinely cannot sit on one line at that width.
   It read `white-space: nowrap` for exactly one probe run. That pins the cell at
   max-content at every width, and probe-viewport.ts measured the widest row this
   screen can draw (59:59, 1,234,567 pts, chain 12) needing 450px inside a 343px
   box on four of its eight viewports -- a row scrolling SIDEWAYS, which is worse
   than the tall row this ticket set out to fix and is invisible in a 390 frame
   (2026-09-14). A bound that only holds at the one width you looked at is not a
   bound. */
.runs .run-nums { text-align: right; font-family: var(--mono); font-size: var(--t-small); color: var(--muted); }
.stats .h { color: var(--muted); font-family: var(--font); font-size: var(--t-small); }
.stats .l { color: var(--left-text); }
.stats .r { color: var(--right-text); }
/* THE COUNTDOWN (JIO-1085) -- design/pages/modes/Countdown.dc.html. The 3-2-1
   is unchanged as a mechanism (ui/screens.ts's countdown(n), which is the one
   overlay in the game that does not go through show()); what the artboard
   settles is how it is DRESSED, and it drew none of what was here.

   BUNGEE 160 IN THE BASE ACCENT, not mono 120/800 in the text colour. §2 makes
   Bungee the display face and a bare numeral over a board is display if
   anything in the game is; the teal is the artboard's, and it is the colour the
   glow under it was already mixed from -- the numeral used to be the one thing
   in front of a board wearing a shadow of a colour it was not.
   ~~`.app.compact .countdown { font-size: 84px; }`~~ went with it: the artboard
   IS a 390x844 phone and it draws 160, so the compact size was the frame of
   record's size being shrunk on the frame of record. One size at every width,
   which is §4's "more room, not a different design" read the other way.

   AND THE BOARD IS DIMMED RATHER THAN BURIED. `.overlay`'s 82% scrim is the
   right thing behind a MENU panel -- it is what gives the panel an edge on both
   themes (JIO-1061) -- and the wrong thing behind three digits: the artboard
   draws the board at 0.55 with the top bar at full strength beside it, and a
   player watching 3-2-1 is reading the deal they are about to play. So the
   scrim is dropped for this one state and the dimming moves onto the boards,
   which is where the artboard puts it. `[data-state="countdown"]` and not a
   class: main.ts already writes that state for the same three seconds. */
.countdown { font-size: 10rem; font-weight: 400; font-family: var(--display); line-height: 1; color: var(--left); text-shadow: 0 0 40px color-mix(in srgb, var(--left) 40%, transparent); }
.app[data-state="countdown"] .overlay { background: none; }
.app[data-state="countdown"] .board { opacity: 0.55; }

/* TAP TO START, IN THE 3-2-1's PLACE (2026-10-11, docs/decisions/0130). The same
   state, so the same dropped scrim and the same dimmed board: what the player
   is reading is the deal underneath. One goal line in the body face over the
   prompt in the display face, in the countdown's teal and glow, so the prompt
   reads as the thing the digits will replace. The whole overlay is the tap
   target (ui/screens.ts's `startPrompt()`); the button is the keyboard's and a
   reader's, and it is drawn as the words alone -- a boxed button here would say
   "press this" when the rule is "press anything". The pulse is one slow fade on
   the words and nothing on the board, and it is off under reduced motion both
   ways the game can be asked for it. */
.start-prompt { display: flex; flex-direction: column; align-items: center; gap: 20px; padding: 0 16px; text-align: center; }
.start-goal { margin: 0; max-width: 30ch; font-size: var(--t-body); font-weight: 600; line-height: 1.4; color: var(--text); }
.start-tap {
  appearance: none; background: none; border: 0; padding: 8px 12px; cursor: pointer;
  font-family: var(--display); font-weight: 400; font-size: 2.25rem; line-height: 1.1;
  color: var(--left); text-shadow: 0 0 40px color-mix(in srgb, var(--left) 40%, transparent);
  animation: start-pulse 1.8s ease-in-out infinite;
}
.start-tap:focus-visible { outline: 2px solid var(--left); outline-offset: 6px; border-radius: 8px; }
@keyframes start-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
.app.reduced-motion .start-tap { animation: none; }
@media (prefers-reduced-motion: reduce) { .start-tap { animation: none; } }

/* Debug panel */
.debug {
  position: fixed;
  right: calc(12px + env(safe-area-inset-right));
  bottom: calc(40px + env(safe-area-inset-bottom));
  width: min(300px, calc(100vw - 24px));
  max-height: 80vh;
  overflow: auto;
  background: color-mix(in srgb, var(--panel) 97%, transparent);
  border: 1px solid var(--right);
  border-radius: 10px;
  padding: 12px;
  font-size: 0.75rem;
  z-index: 40;
  display: grid;
  gap: 6px;
}
.debug[hidden] { display: none; }

/* THE LIVE REGION THE FOUR DECISIVE EVENTS ARE SPOKEN INTO (JIO-1191). Off
   screen for a sighted player and IN the accessibility tree for everybody else,
   which is the one thing `display: none`, `visibility: hidden` and the `hidden`
   attribute all get wrong: each of the three takes the element out of that tree
   entirely, so a region built with any of them is a region nothing is ever
   announced from -- and every test still passes, because the text IS in the
   DOM. The clip-rect shape is the standard one for exactly that reason.

   `white-space: nowrap` is part of it rather than tidying: without it a
   sentence wraps inside a 1px box and some readers walk the fragments.

   THE GUARDIAN'S BARRAGE CHIP JOINS IT RATHER THAN COPYING IT (JIO-1234). Once
   a Guardian's OBJECT is on the screen the barrage clock is drawn by the object
   -- a red arc closing over twenty seconds -- and canvas §5 asks for "no
   countdown chip". Taking it off with `display: none` would delete words a
   reader needs, so it is clipped by this same rule and stays in the tree. One
   definition of "off screen and still readable", because two would be one of
   them going stale the day a reader changes what it honours.

   THE HEALTH READOUT IS NOT IN THIS LIST ANY MORE. JIO-1234 clipped it too --
   "no health bar", the object IS the health -- and George, validating on a
   phone (JIO-1238, 2026-09-18): "you can't see the health bar of the guardian
   on mobile". On a 390px phone the board fills the width, so the three
   objects placed `sides` and `behind` are under the dice, and a bench or a
   door at 0.72 opacity in the world's ink is not a number. So the readout is
   on the screen at every width, drawn beside the object rather than instead of
   it, and the object keeps its marks as the picture of the same number.

   IT IS `#guardian-goal` IN THE GOAL ROW UNDER THE BAR that stays (JIO-1272,
   landing after the two above): what JIO-1238 put back in the top bar's slot
   is drawn in the row a step's target is drawn in, at a step's target's size.
   The placement moved and the rule did not -- a fight and a step are one row,
   and a number a player can read is in it either way. So no selector in this
   list may name `.step-goal .goal`: clipping it would take the health off a
   phone again by a second route, which is why the test below asserts the
   absence rather than leaving the selector free to return.

   AND ON A PHONE THE WHOLE TOWER CHIP ROW JOINS IT (JIO-1783, 2026-10-08).
   George: "I think we should just remove the explanatory pills because that is
   affecting the height in tower mode". The row was the second LINE of a compact
   bar and `chromeHeight()` measures that bar, so since JIO-1758 put the gift at
   the head of it every Tower board paid: two cells at 390x844 and THREE on a
   375x667 SE (scripts/probe-grid-height.ts, 2026-10-08). Clipped rather than
   `display: none`-d for this rule's own reason -- the gift's name and the
   mutations are words a reader needs -- and read before the board rather than
   during it by everybody else: the floor briefing, the reveal card, the
   Guardian's sheet. Off a phone the row is drawn as it was, in the column
   beside the board (`placeTowerReadouts()`), where it costs nothing.

   `.sr-only` IS LAST IN THE LIST AND THAT IS NOT A STYLE CHOICE:
   test/announce.test.js reads this rule back with `/\.sr-only \{[^}]*\}/` to
   check the live region is clipped rather than `display: none`-d, and a
   selector list that ended in a comma would leave that test matching nothing
   and passing on an empty set. */
.app.compact .guardian-chips,
.app.guardian-object-up .guardian-chips .g-chip[data-kind="sent"],
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.debug h3 { margin: 0 0 4px; font-size: 0.8125rem; color: var(--right-text); }
.debug .rowd { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.debug label { color: var(--muted); }
.debug input, .debug select { background: var(--panel-2); color: var(--text); border: 1px solid var(--line); border-radius: 4px; padding: 3px 6px; font: inherit; width: 70px; }
.debug select { width: auto; }
/* Was an inline style="width:auto" in debug.ts, which no stylesheet rule can
   override -- including the coarse-pointer sizing at the bottom of this file. */
.debug input[type="checkbox"] { width: auto; }
.debug button { padding: 3px 8px; }

.app.shake .arena { animation: shake 180ms linear; }
@keyframes shake {
  0% { transform: translate(0, 0); }
  25% { transform: translate(-4px, 2px); }
  50% { transform: translate(4px, -2px); }
  75% { transform: translate(-2px, 1px); }
  100% { transform: translate(0, 0); }
}
.app.reduced-motion .arena { animation: none !important; }
/* Reduced motion keeps the SIGNAL and drops the movement: the full meter's
   box stays lit and stops pulsing, and the callout appears without its
   scale-in. Neither is decoration -- the box is the ability's colour and the
   callout is its name -- so neither may be removed with the animation. */
.app.reduced-motion .meter[data-state="full"].tappable .meter-box { animation: none; filter: brightness(1.15); }
.app.reduced-motion .meter-callout { animation: none; }
.app.reduced-motion .ability-picker { animation: none; }
/* The tint still appears and still clears -- it is the FADE that is motion, and
   the only animated property the callout has. */
/* REDUCED MOTION: a static chip and a static banner, and a Shield that blocks
   without the ring swelling (the ticket's own line). The ~1s flash stays, as
   the callout's colour did before it: a fade is not motion, and dropping it
   would leave the banner announcing a hit with nothing on the board to point
   at. What goes is every keyframe -- the panels' slide-in and the block pulse,
   which is the one thing here that moves twice. */
.app.reduced-motion .board-chip, .app.reduced-motion .board-banner { animation: none; }
/* ...AND THE SPENT SHIELD STILL FADES (JIO-1075), for the same reason the flash
   does. The block reads as a thicker ring that holds and then goes out: no
   swell, because that is the size moving, and no snap, because a ring and a
   chip disappearing between two frames is the fade's absence rather than its
   reduced-motion form. NOTES.md Revision 4's reduced-motion line is about the
   shatter becoming a fade, and this is the same trade one layer out. Both rules
   are more specific than the `animation: none` above and sit after it. */
.app.reduced-motion .board-chip[data-blocked] { animation: chip-spend var(--blocked-ms) ease-out forwards; }
.app.reduced-motion .board[data-effect].blocked { animation: shield-spent var(--blocked-ms) ease-out forwards; }
@keyframes shield-spent {
  0%, 55% { box-shadow: 0 0 0 7px var(--effect-color), 0 0 34px var(--effect-color); }
  100% { box-shadow: 0 0 0 7px transparent, 0 0 34px transparent; }
}

@media (max-width: 1100px) {
  .side { --status-col: 160px; }
  .arena { gap: 12px; padding: 8px calc(12px + env(safe-area-inset-right)) 8px calc(12px + env(safe-area-inset-left)); }
  /* The goal row keeps the board's own gutter (JIO-1272), so it narrows here
     with it -- the row is the width the board is laid out in, and a row that
     stayed at 24 would run 12px past the board on both sides. */
  .step-goal { padding-right: calc(12px + env(safe-area-inset-right)); padding-left: calc(12px + env(safe-area-inset-left)); }
  /* ...and the tokens at its right end keep to it (JIO-1899). */
  .goal-tokens { right: calc(12px + env(safe-area-inset-right)); }
}
@media (max-width: 900px) {
  /* The status panel goes above the board, and the meter stays BESIDE it: a
     meter that stacked under the board would be a horizontal bar again, drawn
     vertically. Two columns, the status spanning both. */
  .side, .side.right { grid-template-columns: minmax(0, 1fr) 44px; }
  .side .status, .side.right .status { grid-column: 1 / -1; }
  .side.right .status { order: 1; }
  .side.right .board { order: 2; }
  .side.right .meter { order: 3; }
  .side.left > .board-overlay, .side.right > .board-overlay { grid-column: 1; grid-row: 2; }
  /* Endless is NOT in this band: one board can afford the column beside it that
     two cannot, so `.app:not(.compact)[data-mode="solo"]` above keeps the
     status panel and the score card at the board's side down to the width the
     rail takes over at. main.ts's `statusAbove` carries the same exception. */
}

/* ===========================================================================
   THE PHONE (JIO-1072, Gameplay canvas "Phone Vs Computer" + NOTES.md Rev 3)

   ONE BIG BOARD, the meter down its left edge, the opponent as a corner window
   in a status strip above the board, and three buttons under it. layout() in
   main.ts owns the breakpoint and publishes it as `compact` on .app -- it is
   deliberately NOT a media query, because the script sizes the canvas in px and
   the two have to agree about which layout is on screen (JIO-834). The 900px
   and 1100px breakpoints above are still written twice.

   ~~THE RAIL IS GONE.~~ Until this ticket a phone was one board plus a ~70px
   column down the right holding both status panels, the opponent's mini board,
   the meter and the deck -- and every one of those was there because thirteen
   rows at a thumb-sized cell left the board only ~284px wide, so the rest of
   the screen was a leftover strip rather than a designed place. The note is
   struck rather than deleted because the arithmetic that produced it is still
   true and still the first thing a reader will re-derive: the board IS narrow,
   there IS width left over. What changed is where the leftover goes. Stacking
   the opponent ABOVE the board in a 108px strip costs the board 108px of
   height, and the board is height-bound either way -- so the cell is the same
   and the width the rail was hoarding goes back to the dice.

   THE VERTICAL BUDGET, from NOTES.md Rev 3, at the canvas's 390x844 frame:
   top bar 52 + 4 + strip 108 + 8 + board 596 + 8 + deck 56 + 4 = 836 of 844.
   Two of those numbers are different in the shipped game and both are measured
   rather than assumed:
   - the top bar is 64px on a coarse pointer, not 52, because its buttons carry
     JIO-836's 44px floor. That is 12px off the board, which at 13 rows is under
     one pixel of cell.
   - the board is THIRTEEN rows, not twelve: CONFIG.board.visibleRows is 12 and
     the renderer draws one more for the row that is arriving (renderer.ts
     resize()). The canvas's 596px board is 12 rows of 48; the same box of
     height in the game is 13 rows of 44. So the cell on a 390x844 phone is 44,
     not the 48 the artboard is drawn at, and every other number in the budget
     is kept exactly. 44 is above JIO-836's own floor, which is what matters.
   =========================================================================== */
.app.compact .arena {
  /* TWO columns: the meter and the board (JIO-1141). ~~THREE columns: the
     meter, the board, and the corner window beside the status card~~ -- the
     108px status strip above the board is gone from the phone. George,
     validating on a phone on 2026-09-16: "the grid view in general is too
     small. Score and time should be in the top navigation bar together", and
     the opponent viewer "should be hidable and transparent ... this will allow
     the grid to be larger". So your score card's numbers are in the top bar,
     the opponent's window is laid over the board's own top-right corner
     (below), and the board takes the row the strip had: measured 2026-09-16
     at 390x844 the cell goes from 45 to 51 and is width-bound for the first
     time, which is the whole 108px of the strip coming back as dice. */
  grid-template-columns: 44px minmax(0, 1fr);
  /* The board, the deck. The BOARD takes the 1fr, which is what makes the
     deck safe to pin: every height the chrome gains mid-match comes out of
     the slack above the deck rather than moving it. That is JIO-835's rule,
     and it is structural here instead of being bought with `align-self: end`. */
  grid-template-rows: minmax(0, 1fr) min-content;
  /* NOTES.md Rev 3: the arena padding dropped from 8/12 to 4/4 vertically to
     pay for the taller Raise button. The 12px horizontal gutter is the canvas's
     and is unchanged -- the budget it is spent against is the vertical one. */
  gap: 8px;
  padding: 4px calc(12px + env(safe-area-inset-right)) calc(4px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));
  align-items: stretch;
  /* Only reachable when the viewport is shorter than the cell floor can fit.
     Portrait never scrolls. */
  overflow-y: auto;
  overscroll-behavior: contain;
  touch-action: pan-y;
}
/* ...EXCEPT UNDER A PUZZLE, WHOSE BUTTONS ARE THE LAST ROW (JIO-1908). The
   inset is the bottom row's, and with the puzzle HUD drawn the arena is not the
   bottom row: carried here it put a home-indicator-high gap above the HUD and
   the HUD itself in the strip, which is the bug the ticket is. layout() reads
   this padding, so the board is sized with the inset where it now is. */
.app.compact:has(> .puzzle-hud:not([hidden])) .arena { padding-bottom: 4px; }
/* .side.left dissolves so its children can sit in different arena areas: the
   meter in the first column, the board in the second. */
.app.compact .side.left { display: contents; }
/* YOUR SCORE CARD IS NOT DRAWN ON A PHONE (JIO-1141). Everything it said is
   said elsewhere there: the score and the sent / locked tally are in the top
   bar (`.bar-score`, `.bar-tally`, below), the chain multiplier is drawn on the
   board, the running effect is the board's own chip (JIO-1074) and the ability
   the meter rolled is its glyph and, when it is full, the callout over the
   board. The tutorial in particular asked for this: its banner above the arena
   plus a 108px card left a board "smaller due to the tutorial message also
   being stacked at the top of the page" (George, 2026-09-16), and a score in a
   lesson is a number nothing is being scored against. */
.app.compact .side.left > .status { display: none; }
.app.compact .side.left > .meter { grid-column: 1; grid-row: 1; }
/* START, not centre: the meter is the column immediately to the left, and the
   whole of "the meter sits on the board's left edge" is that the two are one gap
   apart. Centred in its area the board drifted 30px off the meter and the pair
   read as two separate things (measured 2026-09-12 at 390x844). */
.app.compact .side.left > .board { grid-column: 2; grid-row: 1; justify-self: start; }
/* `.side.left` generates no box here, so the callout's containing block is the
   ARENA -- which is `position: relative` for the tint's stacking context -- and
   the placement below is read against the arena's own tracks. */
.app.compact .side.left > .board-overlay { grid-column: 2; grid-row: 1; }

/* --- the meter, back on the board's edge ---------------------------------
   The component is the desktop one, unchanged: a 22px track inside a 44px
   button with the 40px glyph box under it (FOUNDATION §6). It spent JIO-1071
   in the rail as a squat 16x52 row because a 44px column off the rail layout
   came straight out of the dice -- measured at 393x852 and 430x932, cell
   48 -> 40, under JIO-836's own thumb floor. With the rail gone the board is
   height-bound and there are ~90px of width to spare at 390, so the column is
   free and the row form is deleted rather than kept as a second shape.

   THE COLUMN IS 44 AND THE ARTBOARD'S IS 40, and that is the hit target rather
   than a disagreement: the canvas draws a 40px glyph box, which is exactly what
   `.meter-box` is, and the 22px bar sits inside the 44px transparent target
   §6 requires. scripts/probe-touch.ts measures the button, not the bar. */
.app.compact .side.left > .meter { justify-self: center; }

/* --- the corner window, over the board -----------------------------------
   `.side.right` IS the window: their name and score on the top line, their mini
   board and, beside it, the 6px meter strip with the 18px glyph box under it.
   Its marker row is the mini board's own (renderer.ts drawIncomingMarkers,
   JIO-1070), which is why nothing here draws one.

   IT IS OVER THE DICE SINCE JIO-1141, AND TRANSLUCENT AND HIDABLE BECAUSE IT IS.
   ~~It is in the STRIP and not over the dice, which is the whole point of the
   shape~~ -- the strip cost the board 108px of a phone's height, and George,
   2026-09-16: "the opponent grid viewer should be hidable and transparent. This
   with [score and time in the top bar] will allow the grid to be larger." The
   window sits in the arena's top-right, over the board's empty top rows and CLEAR
   OF YOUR OWN MARKER ROW (JIO-1541: the top ~34px of the CANVAS is not an empty
   row -- it is where incoming dice are marked, and a block's warning is a bar up
   to six columns of it wide), at 85%
   so the dice under it still read; #opp-hide on its corner takes it away and
   #opp-show (below) brings it back. Absolutely positioned against the arena,
   which is the containing block every overlay on a phone already uses. */
.app.compact .side.right {
  position: absolute;
  /* CLEAR OF THE MARKER ROW AS IT IS PAINTED (JIO-1541). ~~8px~~ sat level
     with the markers and ~~32px~~ still cut six pixels off a block's warning,
     and both were arithmetic in the wrong origin: THIS `top` is measured from
     the arena, EVERY number the renderer computes is measured from the board
     canvas, and the canvas starts 4px lower. The artboards use a third origin,
     the padded board box: theirs is 38. Measured at 390x844 with a twelve-die
     block queued, 2026-09-30: a two-row bar paints to canvas y 34 -- 24px of
     rows, 3px of hang above the pad, 6px of taper, 1.5px of stroke -- which is
     page y 95, where 32 put this box at 89. 40 puts it at 97. probe-blocks.ts
     measures that gap in a browser, and the Gameplay canvas's NOTES.md says why
     two rows is a measurement and not a limit. The cost is 32px more of your
     own board, against a warning that was not arriving. */
  top: 40px;
  /* The arena's gutter, plus the slack between your board's right edge and the
     arena's content edge (main.ts publishes --your-board-gap), plus 4px in:
     the window sits on the board's own corner, not the screen's. */
  right: calc(12px + env(safe-area-inset-right) + var(--your-board-gap, 0px) + 4px);
  z-index: 4;
  display: grid;
  /* 1fr, not auto, for the board's column: the name line is kept out of the
     track sizing (below), so with `auto` the tracks were the mini board and
     the meter strip alone and the line spanning them had 64px whatever the
     window's floor was -- "AI · NOR..." at 118px (measured 2026-09-16, when
     the name was AI · Normal). The free width goes to the first column, the
     board stays at its start and the strip at its end, and the line has the
     whole of it. */
  grid-template-columns: minmax(0, 1fr) min-content;
  grid-auto-rows: min-content;
  align-content: start;
  gap: 2px 4px;
  min-width: 0;
  /* Its own content's width: ~~the canvas's 108px~~ was the strip's cell, and
     the window is no longer in a strip. layout() sizes the mini board in px
     from YOUR cell rather than from this box, so there is no circle to settle. */
  width: auto;
  /* ...with a floor the name line fits in: "AI · NORMAL" at 9px is 64px and the
     hide badge takes 20 of the line, and a 94px window (measured 2026-09-16)
     drew it as "AI · NOR...". The mini board keeps to the left and the meter
     strip to the right, so the floor is a gap between them, not a bigger
     board.

     SINCE JIO-1322 THE NAME IS "Computer · Normal" AND IT TAKES TWO LINES, and
     118 is kept rather than raised to meet it. Measured 2026-09-22 at 390x844
     and at 320x568: the name is 99px of one line where "AI · Normal" was 64,
     and the line box inside this window is ~90 after the hide badge's 20 and
     the padding -- so fitting it on one line means a ~157px window, 39px more
     of the player's own board covered, against 10px of extra height (147 ->
     157) for the second line. Two lines is what `line-clamp: 2` is here for:
     W16 in docs/accessibility-audit.md put it there for a NICKNAME from a
     shared table, text from somebody else's browser with no length this box
     could plan for, and "Waiting for an opponent" has wrapped to two lines in
     exactly this window since JIO-1161. */
  min-width: 118px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 4px;
  opacity: 0.85;
}
/* Hidden by choice: the window goes and the pill stands in for it. */
.app.compact.opp-hidden .side.right { display: none; }
.app.compact.opp-hidden .opp-hide { display: none; }
.app.compact .side.right > .status {
  grid-column: 1 / -1;
  grid-row: 1;
  /* It may shrink: the name line spans both of the window's columns, and a
     spanning item's min-content is what pushed the meter's column to 69px. */
  min-width: 0;
  /* ...AND IT DOES NOT SIZE THE WINDOW (JIO-1141). The window is as wide as
     its content now that it is not the strip's fixed 108px, and the name line
     is the widest thing in it: `width: 0` keeps it out of the track sizing and
     `min-width: 100%` then stretches it to whatever the mini board and the
     meter strip made the window. Measured 2026-09-16 at 390x844: 145px wide
     with the line in the arithmetic, 92 without. This is also what keeps
     JIO-1322's longer name from widening the window at all -- it wraps inside
     the box the board and the strip made, and the window stays 118px. The right
     padding is the hide badge's disc on the window's corner
     (`.opp-hide::before`), so their score sits beside the name rather than
     under the badge. */
  width: 0;
  min-width: 100%;
  box-sizing: border-box;
  background: none;
  border: 0;
  border-radius: 0;
  padding: 0 20px 0 0;
  gap: 0;
}
.app.compact .side.right > .board { grid-column: 1; grid-row: 2; align-self: end; justify-self: start; }
/* 18px, WRITTEN rather than left to the `min-content` track. The window's top
   row is the name line spanning both columns, and a spanning item's min-content
   is distributed across the tracks it crosses -- so the name line (then
   "AI · NORMAL", "Computer · Normal" since JIO-1322) pushed this column to 69px
   and the strip's three boxes summed to 117 inside a 100px window (measured
   2026-09-12). The bar is 6 and the glyph box under it is 18, and 18 is what
   the component is here. */
.app.compact .side.right > .meter { grid-column: 2; grid-row: 2; align-self: end; width: 18px; justify-self: end; }
/* The opponent's callout is the one thing this component cannot draw on a
   phone, and it is a bound rather than an omission: their board is a mini one
   at cell 5-6 inside a 108px window, and a pill with a glyph and a name in it is
   wider than the board it would be covering. What the window still says about
   their roll is the glyph in the strip's box beside it. */
.app.compact .side.right > .meter-callout { display: none; }
/* THE WINDOW'S OWN CHIP AND BANNER (JIO-1074), and they are NOT dropped the way
   the callout above them is. The callout is a pill carrying a glyph and a 20px
   name and is simply wider than a mini board; these are a strip along the
   bottom edge of the whole window, which is what the Gameplay canvas's Flashes
   sheet draws -- and they are the only thing on a phone that can say the
   computer just fired Erase or Cleanse, because both act on the CASTER's board
   and neither one touches yours. They are positioned against the window rather
   than against the mini board inside it, so `--board-w` does not reach here. */
.app.compact .side.right > .board-chip, .app.compact .side.right > .board-banner {
  /* THE WHOLE WINDOW IS THE AREA, and `auto` is how you ask for it. An
     absolutely-positioned grid child with a DEFINITE grid position is laid out
     inside that AREA rather than inside the container, so the `grid-column: 2`
     the base rule gives every `.board-overlay` would pin these into the 18px
     meter strip and the insets below would be measured off it. With no definite
     position the containing block is the grid container's padding box, which is
     the window.
     `1 / -1` was the first attempt and it is the trap: the window declares
     `grid-auto-rows` and no `grid-template-rows`, so its explicit row grid is
     one line long and -1 resolves to line 1 -- `grid-row: 1 / 1`, which is not
     a span of anything. Measured at 390x844: the banner came out 62px wide and
     6px ABOVE the window it was supposed to be inside. */
  grid-column: auto;
  grid-row: auto;
  inset: auto 4px 4px 4px;
  /* ...and `stretch`, because the base rule's `justify-self: start` makes an
     absolutely-positioned box SHRINK TO FIT even with both insets set: measured
     at 390x844, the banner was 62px wide inside a 108px window until this line.
     Only the horizontal one does anything -- `top` is auto here, so there is
     nothing for the vertical half to stretch between, and the strip is as tall
     as its own text. */
  place-self: stretch;
  width: auto;
  margin: 0;
  gap: 4px;
  padding: 3px 6px;
  border-radius: 6px;
  box-shadow: none;
}
.app.compact .side.right > .board-banner.under-chip { margin-top: 0; }
/* The chip in the window drops its bar and its second line: 108px of width is
   a glyph, a name and a clock, and a 6px bar under them inside a 4px strip is a
   line nobody can read a fraction off. What the window still says is WHICH
   effect and HOW LONG, in the same two slots the full-size chip uses. */
.app.compact .side.right > .board-chip { flex-direction: row; align-items: center; border-width: 1px; }
.app.compact .side.right > .board-chip .bc-bar { display: none; }
/* ...AND IN THE WINDOW THE NAME IS THE GLYPH (JIO-1075). Measured 2026-09-12 at
   390x844: the strip is 98px wide inside the 108px window, and its content is a
   12px glyph box, two 4px gaps, "SHIELD" at 38px and "BLOCKED 4" at 51px -- 13px
   past its own right border, with the note running out over the arena beside it.
   Shield is the effect this bites on, because it is the only one whose note is a
   WORD rather than a clock: "2.8s" is 20px and sits beside the name comfortably,
   which is why the overflow was invisible until the Shield sheet was built.
   ~~What the window still says is WHICH effect and HOW LONG, in the same two
   slots the full-size chip uses~~ -- it says WHICH in the glyph box, which
   already carries the ability's own mark in the ability's own colour and is the
   same identification the meter's box makes two columns away, and the note keeps
   the half that changes. The full name is on the board it belongs to, at the
   size the four artboards draw it. */
.app.compact .side.right > .board-chip .bc-name { display: none; }
.app.compact .side.right > .board-chip .bc-note { margin-left: 0; }
.app.compact .side.right .bc-icon, .app.compact .side.right .bb-icon { width: 12px; height: 12px; border-radius: 3px; }
.app.compact .side.right .bc-icon svg, .app.compact .side.right .bb-icon svg { width: 9px; height: 9px; }
.app.compact .side.right .bc-name { font-size: 0.5625rem; }
.app.compact .side.right .bc-note { font-size: 0.5625rem; }
.app.compact .side.right .bb-text { font-size: 0.5625rem; }
/* Their name and score on one line, in the window's own type sizes. */
/* ~~nowrap~~ -- THE NAME HAS THE FIRST LINE TO ITSELF (JIO-1918, decision 0229):
   beside the score it had forty-odd pixels, less than COMPUTER is wide, and the
   word broke after the E (`overflow-wrap: anywhere`) or, at `normal`, clipped.
   The score line wraps under it, still one unit (JIO-1319). */
.app.compact .side.right .status-head { gap: 2px 4px; flex-wrap: wrap; min-width: 0; }
.app.compact .side.right .char-badge { display: none; }
.app.compact .side.right .status-head { line-height: 1; }
/* TWO LINES, NOT ONE (JIO-1189, WCAG 1.4.4, audit finding W16). This was
   `white-space: nowrap` with an ellipsis, and at the browser's own "Large"
   default font size -- a 24px root, which is the setting a low-vision reader
   actually changes -- "AI - Normal" measured 20px wider than the 108px window
   and came out cut off. 1.4.4 asks for text at 200% with no loss of CONTENT,
   and a truncated opponent name is content lost rather than a layout reflowed:
   which computer you are playing is the one thing this line says.
   `line-clamp: 2` rather than plain wrapping, because the other thing that goes
   here is a nickname from a shared table -- text from somebody else's browser,
   which has no length this window can plan for. Two lines holds every difficulty
   label at every root size the browser offers, and a nickname long enough to
   pass that still ellipsises rather than pushing the mini board down the window.
   The `-webkit-` pair is written beside the standard property because Safari
   still answers to it alone. */
.app.compact .side.right .char-name {
  font-family: var(--display);
  font-weight: 400;
  font-size: 0.5625rem;
  line-height: 1.1;
  letter-spacing: 0.04em;
  color: var(--right-text);
  text-transform: uppercase;
  flex: 1 1 100%;
  min-width: 0;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  /* ~~`anywhere`~~ -- `normal`: `anywhere` let the browser break COMPUTER after
     the E when "COMPUTER · EASY" overran the window, and COMPUTE / R is not a
     word (George, 2026-10-21). At `normal`, on a line of its own, it breaks at
     the dot -- COMPUTER over · EASY -- inside the two-line clamp; a nickname
     with no space in it is the one case the clamp's ellipsis is still for. */
  overflow-wrap: normal;
}
.app.compact .side.right .opp-score { display: inline; }
/* Their effects and their cursor readout are both off in the window: 108px of
   width holds a name and a number, and the chips were sized for a rail. The
   effect running on their board is drawn ON their board (FOUNDATION §7), which
   the window shows. */
.app.compact .side.right .effects,
.app.compact .side.right .chain-badge { display: none; }
.opp-score { display: none; font-family: var(--mono); font-size: 0.5625rem; color: var(--muted); }
/* THE WORD IN FRONT OF THEIR NUMBER (JIO-1319). The pair is pushed to the end
   of the head as ONE item -- the `margin-left: auto` that used to be on the
   number is on the wrapper, because two elements each claiming the free space
   would have the key at one end of the line and the figure at the other. */
.opp-score-line { display: none; }
.app.compact .side.right .opp-score-line { display: inline-flex; align-items: baseline; gap: 3px; margin-left: auto; }
.opp-score-k { font-family: var(--mono); font-size: 0.5rem; font-weight: 700; letter-spacing: 0.06em; color: var(--muted); }

/* --- the mini meter, as the window's edge strip --------------------------- */
.app.compact .side.right .meter-tap { width: 18px; gap: 3px; height: 100%; }
.app.compact .side.right .meter-track { width: 6px; min-height: 0; border-radius: 3px; }
.app.compact .side.right .meter-fill { border-radius: 3px; }
.app.compact .side.right .meter-box { width: 18px; height: 18px; border-radius: 5px; }
.app.compact .side.right .meter-pct { display: none; }
.app.compact .side.right .meter-icon svg { width: 11px; height: 11px; }

/* --- tap the window, and the modal behind that tap -----------------------
   The button is laid over the whole window rather than wrapping it: the window
   holds a <canvas> the renderer owns and a meter hud.ts writes into, and neither
   wants a <button> in its ancestry. `inset: -2px` so the target covers the
   window's border as well as its box -- a 2px miss on the edge of a 108px
   target is a tap that does nothing, which reads as a window that does not
   open. */
.opp-zoom { display: none; }
.app.compact.duel[data-mode="tower"]:not(.landscape) .opp-zoom,
.app.compact[data-mode="versus"] .opp-zoom {
  display: block;
  position: absolute;
  inset: -2px;
  z-index: 2;
  padding: 0;
  margin: 0;
  border: 0;
  border-radius: 12px;
  background: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}
.opp-zoom:focus-visible { outline: 2px solid var(--right); outline-offset: 2px; }
/* --- hide it, and bring it back (JIO-1141) --------------------------------
   The hide control is a 44px target on the window's top-right corner (JIO-836's
   thumb floor, measured by scripts/probe-touch.ts on every <button>), drawing a
   22px "x" disc in its middle so the target reads as a small badge and not a
   second window. Inset 6px from the corner rather than hanging off it: the
   arena scrolls where it must, and an overflow container clips an absolutely
   positioned descendant that leaves its box. Portrait phone only -- sideways
   the window is in a column beside the board, over nothing, and off a phone
   both boards are full size. */
.opp-hide { display: none; }
.app.compact:not(.landscape).duel[data-mode="tower"] .opp-hide,
.app.compact:not(.landscape)[data-mode="versus"] .opp-hide {
  display: block;
  position: absolute;
  top: -6px;
  right: -6px;
  width: 44px;
  height: 44px;
  z-index: 3;
  padding: 0;
  margin: 0;
  border: 0;
  background: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}
.opp-hide::before {
  content: '\00d7';
  position: absolute;
  top: 6px;
  right: 6px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--panel);
  border: 1px solid var(--line);
  color: var(--text);
  font: 600 15px/20px var(--font);
  text-align: center;
}
.opp-hide:focus-visible { outline: 2px solid var(--right); outline-offset: 2px; }
/* The pill: their badge and their live score, in the corner the window would
   be in, 44px tall for the same floor. It is drawn ONLY while the window is
   hidden, on a portrait phone, in a mode with an opponent. */
.opp-show { display: none; }
.app.compact:not(.landscape).opp-hidden.duel[data-mode="tower"] .opp-show,
.app.compact:not(.landscape).opp-hidden[data-mode="versus"] .opp-show {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  position: absolute;
  top: 8px;
  right: calc(12px + env(safe-area-inset-right) + var(--your-board-gap, 0px) + 4px);
  z-index: 4;
  height: 44px;
  padding: 0 14px;
  border: 1px solid var(--line);
  border-radius: 22px;
  background: color-mix(in srgb, var(--panel-2) 85%, transparent);
  color: var(--text);
  font-family: var(--display);
  font-size: 0.875rem;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}
.opp-show .char-badge { width: 10px; height: 10px; border-radius: 3px; background: var(--right); }
.opp-show:focus-visible { outline: 2px solid var(--right); outline-offset: 2px; }
.opp-hint { display: none; }
.opp-scrim { display: none; }
.opp-scrim[hidden] { display: none; }
/* ENLARGED (the canvas's "Phone Opponent enlarged"). The window is lifted out of
   the strip and centred over a wash, their board redrawn at cell 24 by layout()
   and their meter given the full component back. Nothing is moved in the DOM and
   nothing is drawn twice: it is the same <section>, the same canvas and the same
   meter, so it keeps updating live for as long as it is open -- which is the
   acceptance line ("the opponent window updates live") and also why the modal
   does not pause. */
/* NO SHAKE WHILE THE WINDOW IS OPEN, and this is a correctness rule rather than
   a taste one. `.app.shake .arena` animates a TRANSFORM, and a transformed
   ancestor becomes the containing block for `position: fixed` descendants -- so
   for the 180ms of a shake the centred modal was positioned against the arena's
   box instead of the viewport and slid off the right-hand edge of the screen.
   Seen in scripts/probe-phone.ts's screenshot on 2026-09-12, with every
   measurement in the same run reporting a perfectly centred panel, because the
   shake had ended by the time the rects were read. The shake is feedback about
   YOUR board, which is behind the wash anyway. */
.app.opp-zoomed .arena { animation: none !important; }
/* The scrim is a child of .arena (dicelock.html, JIO-1140), so its z-index and
   the window's are compared inside the arena's own stacking context: 40 under
   41. As a sibling of the arena it was 40 against the arena's 0 and covered the
   window it exists to frame. */
.app.opp-zoomed .opp-scrim {
  display: block;
  position: fixed;
  inset: 0;
  z-index: 40;
  padding: 0;
  margin: 0;
  border: 0;
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.app.compact.opp-zoomed .side.right {
  position: fixed;
  z-index: 41;
  opacity: 1;
  width: auto;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: auto;
  max-width: calc(100vw - 32px);
  grid-template-columns: min-content min-content;
  gap: 10px;
  padding: 12px;
  background: var(--panel);
  border-color: var(--line);
  border-radius: 14px;
  box-shadow: var(--card-shadow);
}
.app.compact.opp-zoomed .side.right .char-name { font-size: 0.75rem; }
.app.compact.opp-zoomed .side.right .opp-score { font-family: var(--display); font-size: 1.25rem; color: var(--text); }
.app.compact.opp-zoomed .side.right > .board { align-self: start; }
.app.compact.opp-zoomed .side.right > .meter { align-self: start; grid-column: 2; width: 44px; }
.app.compact.opp-zoomed .side.right .meter-tap { width: 44px; gap: 8px; }
.app.compact.opp-zoomed .side.right .meter-track { width: 22px; border-radius: 11px; }
.app.compact.opp-zoomed .side.right .meter-fill { border-radius: 11px; }
.app.compact.opp-zoomed .side.right .meter-box { width: 40px; height: 40px; border-radius: 10px; }
.app.compact.opp-zoomed .side.right .meter-icon svg { width: 16px; height: 16px; }
.app.compact.opp-zoomed .side.right .meter-pct { display: block; }
/* The way out, said in words as well as by the wash: the canvas's own sentence,
   and the second half of it is the reason the modal exists at all rather than a
   pause. */
.app.compact.opp-zoomed .opp-hint {
  display: block;
  /* Row 3, WRITTEN: the window's other children carry explicit areas and this
     one did not, so auto-placement put it in the first empty slot -- above the
     name line, which is the one place a way out must not be. */
  grid-column: 1 / -1;
  grid-row: 3;
  margin: 0;
  text-align: center;
  font-size: 0.75rem;
  color: var(--muted);
}
/* ...AND NOT IN THE BATTLE WALKTHROUGH (decision 0229): the beat opened the
   window, the beat's own TAP TO CONTINUE is the way out, and the match's
   sentence under it was the small hidden line George read as a lost tutorial
   step. The spotlight layer rises over the scrim (40) and the window (41) for
   the same beats, so the pill is read over the enlarged board and not under
   the wash. */
.app.first-run.opp-zoomed .opp-hint { display: none; }
.app.first-run.opp-zoomed .spotlight { z-index: 43; }
/* The tap target stays over the window while it is open, so a second tap on the
   board closes it as readily as a tap on the wash does. It sits UNDER the
   scrim's z-index nowhere: both close. */
.app.compact.opp-zoomed .opp-zoom { z-index: 42; border-radius: 14px; }
/* No hide badge on the enlarged window: the scrim and the window itself are
   both the way out of it, and a third control on its corner would be a way
   out of the wrong thing. */
/* `[data-mode]` for the specificity, not the match: the rule that shows the
   badge names the mode (`[data-mode="versus"]`, five classes), and without the
   attribute this one lost to it and the enlarged window kept its × -- the
   third control the sentence above says it must not have. */
.app.compact.opp-zoomed[data-mode] .opp-hide { display: none; }
/* ...and none on the small window either while a Battle walkthrough is up
   (decision 0229): the window is the lesson, and hiding it would be a lesson
   about a thing that is not there. setOppHidden() refuses it too. */
.app.compact.first-run[data-mode] .opp-hide, .app.compact.first-run[data-mode] .opp-show { display: none; }

/* --- your score card ------------------------------------------------------
   The strip's left half: YOU and the chain badge, the score in the display face
   at 28, and the sent / locked tally under it, spread to the 108px box the
   canvas draws. */
.app.compact .status { padding: 8px 10px; gap: 4px; font-size: 0.6875rem; border-radius: 10px; }
.app.compact .status-head { gap: 6px; flex-wrap: wrap; }
.app.compact .char-badge { width: 10px; height: 10px; border-radius: 3px; }
.app.compact .char-name { font-family: var(--display); font-weight: 400; font-size: 0.6875rem; letter-spacing: 0.04em; text-transform: uppercase; }
.app.compact .chain-badge { margin-left: auto; font-family: var(--display); font-size: 0.625rem; padding: 2px 8px; border-radius: 999px; background: var(--left); color: var(--ink); border: 0; }
.score-line, .tally { display: none; }
/* A puzzle has no score, no sent and no locked at all, and three readouts of
   zero is worse than an empty card -- so the tally is Vs Computer's. The SCORE is every playing mode's, Endless included: the canvas's
   Phone Endless artboard puts it in exactly this card at exactly this size, and
   the score panel beside it drops its own copy (below) rather than printing the
   number twice. */
.app.compact[data-mode="versus"] .score-line,
.app.compact[data-mode="solo"] .score-line { display: block; font-family: var(--display); font-size: 1.75rem; line-height: 1; }
.app.compact[data-mode="versus"] .tally { display: flex; gap: 12px; font-size: 0.75rem; font-weight: 600; color: var(--muted); }
.tally b { color: var(--text); font-weight: 700; }
/* THE WORD IN FRONT OF THE SCORE (JIO-1319), in the mono the tally's own "sent"
   and "locked" are set in, because it is the same part of speech: a key, not a
   readout. Deliberately a fraction of the figure's size -- a key as big as its
   value reads as a second number -- and raised off the baseline so it sits
   against the top of the 28px display face rather than under it. */
.score-k { display: none; font-family: var(--mono); font-size: 0.625rem; font-weight: 700; letter-spacing: 0.06em; color: var(--muted); margin-right: 6px; vertical-align: 0.5em; }
.app.compact[data-mode="versus"] .score-line .score-k,
.app.compact[data-mode="solo"] .score-line .score-k { display: inline; }
/* ~~The ability's NAME in words is the one thing the meter does not say, so the
   row stays, under the tally -- at a 13px floor, because scripts/probe-touch.ts
   fails it under 12px (JIO-919); and READY takes the cost's place, because
   colour is never the only carrier of a state on this page.~~ THE THREE RULES
   THAT SAID THAT ARE DELETED (JIO-1206): every one of them dressed
   `#ability-left`, which lives inside the left `.status` card, and JIO-1141 took
   that whole card off the phone (`.app.compact .side.left > .status` above).
   They had been styling a `display: none` subtree ever since -- and the probe
   that was meant to defend them was reading a 0x0 box and failing six screens on
   clean main for it, which is what JIO-1206 found. The note is struck rather
   than dropped because the REQUIREMENT is still live and is still met: what
   names the rolled ability on a phone is the meter's glyph and its
   `#meter-tap-left` label in words, plus the callout over the board -- and that
   is what scripts/probe-touch.ts measures now.

   THIS RULE IS NOT ONE OF THEM. The AI's card is NOT hidden on a phone: it is
   the floating window over the board (JIO-1141), and it shows their badge, their
   name and their score. Without this rule their ready ability would be named in
   words inside a 108px window whose whole job is a mini board -- and what that
   window says about their roll is the glyph in their 18px meter box, which is
   what it has said since JIO-1071 and JIO-1073. */
.app.compact .side.right .ability-pick { display: none; }
.app.compact .effects { font-size: 0.5625rem; gap: 3px; }
.app.compact .effects .effect { padding: 0 4px; }
/* The cursor-dice readout is the one panel with no room, and it is hidden here
   rather than shrunk. WHAT IT SAID IS NOT GONE (JIO-1206): the wedge and the
   protection ring are drawn on the die, the risk band is the reach of the
   cursor's own frame (render/renderer.ts's cursorRisk) and the strip along the
   die's foot, and the BAND'S NAME -- the one thing none of those can say -- is
   `.cursor-risk` below, in the slack between the board and the deck. Before that
   row this rule left "Risky 3/6" nowhere on a phone at all, which is the G2
   must-fix JIO-1206 closed. */
.app.compact .dice-info { display: none; }
/* --- the named risk of the two dice under the cursor (JIO-1206) -------------
   REQUIREMENTS.md's G2 wants outline intensity, corner marks OR a named status
   near the selected die, and the phone had none of the three: the cursor drew
   one fixed bracket whatever the die was in, and the only panel that put a NAME
   to a risk band went with the status card in JIO-1141. The frame is now keyed
   to the band (render/renderer.ts) and this row is the words beside it.

   IT IS PLACED IN THE BOARD'S OWN TRACK, `align-self: end`. That track is
   `minmax(0, 1fr)` -- its height is whatever the deck and the top bar leave, and
   never its contents -- so nothing here can move the deck or change the cell
   main.ts computes (chromeHeight() measures the top bar and the deck, not this).
   Measured at 390x844 on 2026-09-17: the board ends at y=705 and the deck starts
   at y=784, so this sits in 79px that were empty. Sideways at 844x390 the same
   gap is 15px and the row is 14, which is what the type size below is for; the
   board spans rows 1-2 there, so it is row 2 that holds the slack.

   `pointer-events: none` because the board's own swipe and tap handlers reach
   the canvas and nothing else should be able to swallow one on the way past. */
.cursor-risk { display: none; }
.app.compact .side.left > .cursor-risk {
  display: flex;
  grid-column: 2;
  grid-row: 1;
  align-self: end;
  justify-self: start;
  gap: 12px;
  /* 12px and bold: it is read at a glance between two presses, and the deck's
     own labels are 14. Below 11 the band names stop being words on a 390px
     screen, and this row has the width of the board to spend. */
  font-size: 0.75rem;
  font-weight: 700;
  line-height: 1.15;
  color: var(--muted);
  pointer-events: none;
  /* NO VERTICAL PADDING, and that is the measurement rather than a default:
     12px at a line-height of 1.15 is a 13.8px row, and sideways at 844x390 the
     gap between the board and the deck is 15. 2px a side overflowed it and put
     the words across the board's own bottom rail (seen 2026-09-17). */
  padding: 0;
  white-space: nowrap;
}
.app.compact.landscape .side.left > .cursor-risk { grid-column: 2; grid-row: 2; }
/* The band's NAME is the readout; the L/R tag and the count are context, so they
   are the muted half and the name is the text colour. Colour is not doing any
   work here -- the words are -- which is the whole reason this row exists. */
.cursor-risk .cr-die b { color: var(--text); font-weight: 700; }
/* A puzzle has no risk to name. `puzzleReroll()` takes the face printed on the
   die, never calls applyReroll() and rewrites the history to the one face now
   showing, so no die in a puzzle can ever self-lock (JIO-925) -- which is the
   same sentence, and the same ticket, as the `.dice-info` rule up at the puzzle
   block and the renderer's own `!board.puzzle` guard on the strip (JIO-928).
   Three layers, one fact: a named risk band in a puzzle would be a readout of a
   risk the mode does not have. */
.app.compact[data-mode="puzzle"] .side.left > .cursor-risk { display: none; }
/* ...or the display above outranks the attribute, the same trap `.deck[hidden]`
   is written for: hud.ts hides this row between matches and on a board whose
   cursor is over two empty cells. */
.cursor-risk[hidden] { display: none !important; }
.app.compact canvas.board { border-radius: 6px; }

/* --- the deck: REROLL L | RAISE | REROLL R ------------------------------------
   One row under the board, across the whole arena, in BOTH orientations. It is
   an arena row rather than a floating bar so chromeHeight() can measure it, and
   the board's own 1fr row above it is what keeps it still: JIO-835 measured the
   deck jumping by a whole button 36 times in 12 seconds when it inherited the
   height changes of everything stacked above it, and a finger already on its
   way down in that window presses the button above the one it aimed at.

   The heights are NOTES.md Rev 3's: rolls 48, Raise 56, and the row is the
   taller of the two so the three buttons sit on one bottom edge. FOUNDATION.md
   §4 said 56/72 when this was written; NOTES.md is the newer record and 48/56 is
   what the 836px budget sums with, so §4 was corrected in the same commit. */
.app.compact .deck {
  grid-column: 1 / -1;
  grid-row: 2;
  display: flex;
  flex-wrap: nowrap;
  align-items: flex-end;
  gap: 10px;
  height: 56px;
}
.app.compact .deck button {
  flex: 1 1 0;
  min-width: 0;
  height: 48px;
  min-height: 48px;
  padding: 0 4px;
  border-radius: 10px;
  font-family: var(--display);
  font-weight: 400;
  font-size: 0.875rem;
  letter-spacing: 0.02em;
  /* The canvas draws REROLL L; the button's text says "Reroll L", because that is
     the string the tutorial writes into a sentence and a screen reader reads
     out. Uppercasing is presentation, so it is done here. */
  text-transform: uppercase;
}
/* Half a button wider and 8px taller than the rolls it sits between -- the two
   things a thumb finds without looking, on top of the green. */
.app.compact .deck .deck-raise { flex: 1.25 1 0; height: 56px; min-height: 56px; font-size: 0.9375rem; border-radius: 12px; }
/* A STEP SMALLER ON A 320px PHONE (JIO-1322), and the smaller of the two words
   is what needs it. "Reroll R" is 78px of the 14px display face and the button
   is 85px wide there with 4px of padding each side, so it wrapped to two lines
   inside a box whose height is written down -- the same fault the landscape
   rule further down was written about, and one the probe could not see until
   this ticket taught it to measure the TEXT rather than the button.
   Measured 2026-09-22, headless, in a Vs Computer match: at 320x568 the label
   goes 78px on two lines -> 70px on one in a 77px line box; at 360x740,
   375x667 and 390x844 both rolls are 75px on one line in 89px or more, so the
   step belongs to the small phone and not to the deck. Raise keeps its lead of
   one step over them, because size is half of how a thumb finds it. */
@media (max-width: 359px) {
  .app.compact .deck button { font-size: 0.8125rem; }
  .app.compact .deck .deck-raise { font-size: 0.875rem; }
}

/* --- Endless: the window's place is taken by the score panel --------------
   `#solo-panel` is a child of `.side.left` (JIO-1073), and `.side.left` is
   `display: contents` on a phone -- so the card is an ITEM OF THE ARENA here
   and is placed in the window's own area by hand rather than by dressing
   `.side.right` down into a bare wrapper. `.side.right` stays `display: none`
   in this mode (the pair of rules up by `.solo-panel`), which is the same
   sentence the desktop shape says: the opponent's side goes with the opponent.

   The window's 108px is the mini board's budget and Endless has no mini board,
   so the third track is `max-content` here instead: the card takes the width
   its three stats need. The board spans columns 2 and 3 either way, and it is
   height-bound on a phone, so the wider track costs it nothing. */
/* `:not(.landscape)` because this outranks the landscape track list below it
   (four compound parts against three) and would otherwise win sideways as
   well, where the three tracks are meter / board / strip-column. */
/* ~~.app.compact[data-mode="solo"]:not(.landscape) .arena { ... max-content }~~
   -- there is no strip for the panel to stand in on a portrait phone since
   JIO-1141. The score is in the top bar there like every other mode's; Best,
   Time and Rise are read on the result screen. Sideways the panel keeps the
   column beside the board, where it costs the board nothing. */
.app.compact .side.left > .solo-panel { display: none; }
.app.compact .solo-panel {
  padding: 8px 10px;
  gap: 6px 14px;
  max-width: none;
  border-radius: 10px;
  /* Two by two rather than the four stacked rows it is on a desktop: the strip
     is a wide short box and a column of four in it is either clipped or a strip
     twice as tall as the one the budget has. */
  grid-template-columns: auto auto;
  align-content: center;
}
.app.compact .solo-title { display: none; }
/* The score is in YOUR card on a phone (above), so the panel beside it is Best,
   Time and Rise -- one number in one place. */
.app.compact .solo-stat.is-score { display: none; }
.app.compact .solo-stat { display: grid; gap: 0; font-size: 0.6875rem; }
.app.compact .solo-stat .v.big { font-size: 1.375rem; }
.app.compact .solo-hint { display: none; }

/* ===========================================================================
   ...AND THE PHONE HELD SIDEWAYS (JIO-923's shape, rebuilt to the Controls
   canvas's "Touch landscape" by JIO-1072).

   The arrangement is the portrait one turned through 90 degrees: meter, board,
   and then the column that portrait stacks above the board -- your card over the
   opponent's window -- beside it instead, with the same three buttons under the
   board. Height is the scarce dimension here and width is the abundant one, so
   what portrait stacks, landscape lays out side by side.

   It is not a preference. A 390px-tall viewport less the top bar, the deck row
   and the padding leaves ~254px for the board; a 108px strip above it as well
   would leave ~138, which is thirteen rows at a cell of 9. The board would be
   drawn at under half the size of the dice a thumb has to hit.
   ~~`deck-split` and `deck-below`~~ are both gone with the shape that needed
   them: the deck was in two boxes because Ability and Pause stacked up the side,
   and it was conditionally under the board because the rail was the other place
   it could go. There is one deck in one place now, and layout() publishes
   `landscape` instead of latching a decision. */
.app.compact.landscape .arena {
  /* The meter, the board, and the column your card and the window share. */
  grid-template-columns: 44px min-content minmax(0, 1fr);
  /* Your card, the slack, and the deck row. The board spans the first two, so a
     card that grows mid-match eats the slack instead of moving the deck
     (JIO-835). */
  grid-template-rows: min-content minmax(0, 1fr) min-content;
  align-items: start;
}
.app.compact.landscape .side.left > .status { grid-column: 3; grid-row: 1; max-width: 220px; min-height: 0; }
.app.compact.landscape .side.left > .meter { grid-column: 1; grid-row: 1 / span 2; }
.app.compact.landscape .side.left > .board { grid-column: 2; grid-row: 1 / span 2; justify-self: start; }
.app.compact.landscape .side.left > .board-overlay { grid-column: 2; grid-row: 1 / span 2; }
/* Back IN the grid sideways (JIO-1141): the column beside the board is not
   over any dice, so the window stands in it opaque, at the top of the column
   your card used to head. */
.app.compact.landscape .side.right { position: relative; top: auto; right: auto; z-index: auto; opacity: 1; grid-column: 3; grid-row: 1; align-self: start; justify-self: start; }
/* No hide badge sideways, so no room is kept for one on the name line. */
.app.compact.landscape .side.right > .status { padding-right: 0; }
/* ...and in Endless the score card stands where the window would, under your
   status card, because `.side.right` is not on the page in that mode. Placed
   by hand for the same reason every other child of `.side.left` is here: the
   element is `display: contents`, so anything left to auto-flow lands in a
   track this shape chose for something else. */
.app.compact.landscape .side.left > .solo-panel { grid-column: 3; grid-row: 1; justify-self: start; }
/* Shown again sideways -- in Endless only, which is the one mode that has it:
   `.app.compact .side.left > .solo-panel { display: none }` above outranks the
   mode rule that draws it, so the sideways shape says both halves itself. */
.app.compact.landscape[data-mode="solo"] .side.left > .solo-panel { display: grid; }
/* THE DECK RUNS THE WHOLE WIDTH SIDEWAYS, not just the board's column, and that
   is JIO-923's measurement rather than a liberty with the canvas: a phone held
   sideways is held in TWO HANDS, so the bottom corners are where the thumbs
   already are -- Reroll L under the left one and Reroll R under the right one,
   with Raise between them. Under the board alone the three buttons share the
   ~180px the board's column is (a 390px-tall viewport caps the board at a cell
   of 19), which wrapped the reroll labels to two lines and put both of them
   under the left thumb -- measured when they read ROLL L and ROLL R, and only
   more true of the two characters JIO-1322 added to each.
   The Controls canvas draws them under the board because that is where the board
   is in its schematic; the row is still under the board either way. */
.app.compact.landscape .deck { grid-column: 1 / -1; grid-row: 3; }
/* The window is a fixed centred panel when it is open, so it leaves the grid in
   landscape exactly as it does in portrait and needs nothing of its own. */

/* One row of top bar. Every line it wraps to is ~24px off the cell size, and
   nothing here may overflow: the match info is the item that gives. It used to
   be the home link, back when that was text that could ellipsise; the 24px
   mark that replaced it (JIO-1063) has one size and holds it. */
/* ~~THE CHIP ROW IS A SECOND LINE ON A PHONE TOO (JIO-1233)~~ ~~...AND A CHIP
   ROW WITH NOTHING ON IT TO READ IS NOT A LINE OF THE BAR (2026-10-02)~~ --
   **A PHONE'S BAR HAS NO CHIP LINE IN ANY MODE (JIO-1783, 2026-10-08)**, so
   both rules are gone rather than conditioned: there is no `:has()` turning
   this bar's wrapping back on and no second rule taking an empty row out of
   flow. The whole row is clipped on a compact layout, by the `.sr-only` rule
   above -- George: "I think we should just remove the explanatory pills because
   that is affecting the height in tower mode", and `chromeHeight()` measures
   `.topbar`, so every line the row claimed was a line off every Tower board.
   The words are still in the accessibility tree and the row is still drawn in
   full off a phone, in the column beside the board.

   THE READOUT'S `flex: 1 1 0` STAYS, and it is now belt rather than braces
   (JIO-1551). A flex line breaks on an item's CONTENT width before any item is
   shrunk, so the readout at `flex-basis: auto` put the break in front of the
   CONTROLS and `min-width: 0` did nothing about it. Nothing here may wrap now,
   so the basis cannot break a line; it is kept because the next thing to grow
   should still find the readout giving. The artboards draw the same
   declaration and test/phoneLayout.test.js carries the measurement. */
.app.compact .topbar {
  flex-wrap: nowrap;
  gap: 8px;
  padding: calc(6px + env(safe-area-inset-top)) calc(8px + env(safe-area-inset-right)) 6px calc(8px + env(safe-area-inset-left));
}
.app.compact .topbar .home { flex: 0 0 auto; }
.app.compact .match-info { flex: 1 1 0; min-width: 0; overflow: hidden; font-size: 0.75rem; gap: 0 4px; flex-wrap: wrap; justify-content: center; }
/* Difficulty and pace are chosen on the title screen and never change during a
   match; the clock does. */
.app.compact .match-info #info-difficulty,
.app.compact .match-info #info-pace,
.app.compact .match-info .sep { display: none; }
/* YOUR SCORE BESIDE THE CLOCK (JIO-1141), in the display face the score card
   drew it in, and the sent / locked tally on a line under the pair in Vs
   Computer. Endless shows the score and no tally (nothing is sent); the
   tutorial shows neither, which is the first thing George asked for on
   2026-09-16 -- a score in a lesson is a number nothing is being scored
   against, and the card it sat in was squeezing the board. A puzzle's slot is
   its change budget (hud.ts) and has no score either. */
.bar-score, .bar-sep, .bar-tally, .bar-score-k { display: none; }
.app.compact[data-mode="versus"] .match-info .bar-score,
.app.compact[data-mode="solo"] .match-info .bar-score { display: inline; font-family: var(--display); font-size: 1.125rem; line-height: 1; color: var(--text); }
.app.compact[data-mode="versus"] .match-info .bar-sep,
.app.compact[data-mode="solo"] .match-info .bar-sep { display: inline; }
/* ...and the key beside it (JIO-1319), in the tally's own muted type one line
   below. `.match-info` is `nowrap` for everything but the tally's second row,
   so this adds a word to the bar's ONE line and scripts/probe-phone.ts measures
   that the line is still one line at 390. */
.app.compact[data-mode="versus"] .match-info .bar-score-k,
.app.compact[data-mode="solo"] .match-info .bar-score-k { display: inline; font-family: var(--mono); font-size: 0.625rem; font-weight: 700; letter-spacing: 0.06em; color: var(--muted); }
.app.compact[data-mode="versus"] .match-info .bar-tally { display: block; flex-basis: 100%; text-align: center; font-size: 0.625rem; font-weight: 600; line-height: 1.2; color: var(--muted); }
.bar-tally b { color: var(--text); font-weight: 700; }
/* THE PHONE'S BAR IS TWO ROWS AND STAYS TWO ROWS WITH THE GROWTH CHIP IN IT
   (JIO-1540). The chip is a fourth readout on a bar that has 157px at 390, and
   the row it cannot be pinned to is the clock's: at sudden death that slot reads
   SUDDEN DEATH, 87px of the 157, so a chip beside it pushes the tally onto a
   THIRD row at exactly 3:00 -- `pace.timedSeconds` is 180 and the second growth
   step is the same minute. Measured on 2026-09-30 at 390x844: the bar went
   51px to 63px and the board slid 12px down the screen with it, mid-match,
   under the player's thumb. That is JIO-835's bug and the reason the tally's
   own rule above says this bar is one line and the tally's.

   So the tally stops claiming a whole row WHILE THE CHIP IS UP, and flex decides
   between them: where there is room beside the clock the chip stays on the
   clock's row and the tally keeps its own, and where there is not it drops onto
   the tally's. Two rows either way, at every clock and every score. It wears the
   tally's type down here for the same reason -- 68px of chip does not fit beside
   a tally and 48px does -- and nothing about it changes off a phone. */
.app.compact .match-info .growth-chip { font-size: 0.625rem; padding: 2px 5px; }
.app.compact[data-mode="versus"] .match-info:has(#info-growth:not([hidden])) .bar-tally { flex-basis: auto; }
.app.compact .topbar-controls { flex: 0 0 auto; gap: 4px; }
.app.compact .topbar-controls button { padding: 10px; font-size: 0.8125rem; }
/* Restart is one tap away in the pause menu, and four buttons do not fit on one
   375px row beside the clock. */
.app.compact #btn-restart { display: none; }

/* No footer: 28px of screen for a key-binding hint that Help repeats, and it is
   the nowrap line that laid the page out 1171px wide in the first place. */
.app.compact .footer { display: none; }

/* ~~The tutorial banner's row on a phone (JIO-1093, JIO-1433, JIO-1929)~~ -- the
   eighteen-step lesson's, deleted with it (JIO-2021). */

/* Overlays: the menus are the only thing on a phone that is actually tapped.

   AND IT REACHES §5's CARDS, WHICH THE LINE ABOVE WAS NOT AIMED AT. `.menu
   button` at the top of this file is `padding: 10px 14px`, so this rule reads
   as 10 -> 12 of vertical tap target and nothing else. But `.menu .card` is
   `12px 16px` and a card on a menu is a <button>, so `.app.compact .menu
   button` (0,3,1) beats it (0,2,0) and takes every card's SIDES from 16 to 14
   on a phone as well. Measured over CDP at 390x844 on 2026-09-22: the Settings
   screen's button cards compute `12px 14px` and its two <a> cards `12px 16px`.

   It is load-bearing now rather than merely incidental, which is why it is
   written down here rather than tidied away. Those 4px are what keep OPPONENT
   WINDOW on one line in `.menu .card.switch-card` above -- 207.5px of title in
   208 -- and design/pages/menu and design/pages/tower's Settings drawings take
   their 14 from this rule and are measured against it. Narrowing this to a
   `padding-block` would re-wrap that label and shift the contents of every
   menu card on every phone artboard by 2px. */
.app.compact .menu button { padding: 12px 14px; }
/* Not the row control's three targets, and not the mode card. Both are
   buttons, so the line above reaches them at (0,3,1) and beats their own
   rules: the Opponent and Time rows came out 78px on a phone against the
   artboard's 70 (the chevrons took 12px of padding on top of their 44), and
   Start's three mode cards drew their scene inside 14px of side padding where
   Start.dc.html runs it edge to edge (JIO-1086, both measured by
   scripts/probe-artboards.ts). */
.app.compact .overlay .rc-step { padding: 0; }
.app.compact .overlay .rc-value { padding: 0 8px; }
.app.compact .menu .card.mode-card { padding: 0; }
.app.compact .overlay .panel { padding: 20px 16px; }
/* A phone takes every display level down one rung of the same scale rather
   than to a size of its own: hero -> h1, screen title -> section, section ->
   card. The body face is untouched -- 14px is the floor at every width. */
.app.compact .overlay h1 { font-size: var(--t-h1); }
.app.compact .overlay .result-title { font-size: var(--t-h1); }
.app.compact .overlay h2 { font-size: var(--t-h2); }
.app.compact .overlay h3 { font-size: var(--t-card); }
.app.compact .overlay .tagline { font-size: var(--t-body); font-weight: 600; }
.app.compact .help-grid { grid-template-columns: 1fr; }
/* A phone needs no per-row block treatment here any more. It used to: the flat
   table stacked to one column said which keys exist and not which is which,
   because "X" is a key and also a pad button, so each row became a block
   labelling both (JIO-836). The tab strip answers that question once, above the
   whole table (JIO-888), and since JIO-892 each control is a card that names
   its own action -- so one column of cards is the phone layout. The gamepad
   panel's diagram legend is still two columns and still needs to fit 375px. */
.app.compact .cards { grid-template-columns: 1fr; gap: 8px; }
.app.compact .diagram-legend { gap: 6px 12px; }
.app.compact .tabs button { padding: 10px 8px; font-size: var(--t-small); }

/* --- the Daily leaderboard (JIO-1090) --------------------------------------
   design/pages/puzzles/Leaderboard.dc.html and LeaderboardStreaks.dc.html, and
   NOTES.md's line: "a grid of five mode pills over the mode's own variant
   tabs, ten rows, ranks 1-3 in teal, your row pinned at the bottom with a teal
   border and a YOU tag. Figures in mono 15." (Two rows of control since
   JIO-1485; it read "segmented Today / Streaks tabs" and "Times" before.)

   THE SUBTITLE IS NOT `.tagline`. The artboard draws 14/600 muted 6px under the
   title, where every other screen's lede is 18/600 twenty pixels under it. The
   negative margin is the frame's 20px gap taken back down to 6, which is
   exactly what the main menu's wordmark already does two hundred lines up --
   and the h2 has to stay a DIRECT CHILD of the panel for it, because the
   32px-on-a-phone rule is a direct-child selector and the compact block would
   otherwise take a nested title down to 24. */
/* ~~`[data-screen="leaderboard"]`~~ -- THE EVERYONE SIDE OF SCORES since
   JIO-2030 (docs/decisions/0201). The head row sits under the room's three
   control rows rather than under the title, so the title keeps its gap and the
   head takes the rows' 8px instead. */
.panel[data-screen="scores"] .board-head { margin-top: -8px; }

/* THE SCORES ROOM'S OWN ROWS (JIO-2030): the YOU / EVERYONE switch is a full
   width pill of two Bungee words -- the canvas draws it as the one control on
   the screen with the teal fill -- and the three mode pills under it share the
   width (`.chips.board-modes`, three cells since JIO-2030). The YOU side's
   boards are two tiles over a list: the tile is `--panel` with the card's
   radius, lit teal on the one that is "what you have", and the figure is the
   mono role at the result screen's `--t-h2` because it is the one number the
   screen is about. The standing foot is the same card with its copy on the left
   and SEE BOARD on the right, which is the switch pressed for you. */
.overlay .menu .segmented.scores-side { align-self: stretch; margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 3px; border-radius: 999px; }
.overlay .menu .segmented.scores-side .seg { height: 38px; padding: 0; border-radius: 999px; font-family: var(--display); font-weight: 400; font-size: var(--t-body); }
.scores-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.scores-tiles { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 0 0 12px; }
.scores-tile { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; min-width: 0; }
.scores-tile.lit { border-color: var(--left); }
.scores-tile-label { font-family: var(--font); font-weight: 700; font-size: var(--t-small); letter-spacing: 0.06em; color: var(--muted); }
.scores-tile-figure { font-family: var(--mono); font-weight: 700; font-size: var(--t-h2); line-height: 1.1; color: var(--text); font-variant-numeric: tabular-nums; }
.scores-tile-figure .scores-tile-of { font-size: var(--t-body); color: var(--muted); }
.scores-tile-sub { font-family: var(--font); font-weight: 600; font-size: var(--t-small); color: var(--muted); }
.scores-tile.lit .scores-tile-sub { color: var(--left-text); }
.scores-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; margin: 0 0 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; }
.scores-foot-copy { font-family: var(--font); font-weight: 600; font-size: var(--t-small); line-height: 1.4; color: var(--muted); }
.scores-foot-copy b { color: var(--text); font-weight: 700; }
.scores-foot .btn { flex: none; min-width: 0; height: 40px; padding: 0 14px; }
.scoreboard .score-row.missed .score-points, .scoreboard .score-row.missed .score-rank { color: var(--muted); }
.scoreboard .score-day { width: 64px; font-family: var(--font); font-weight: 700; font-size: var(--t-small); color: var(--text); }
.scoreboard .score-world { width: auto; font-size: var(--t-small); }
.scoreboard .score-world-name { font-family: var(--font); font-weight: 600; font-size: var(--t-small); color: var(--muted); }
.scores-body > .score-group { margin: 4px 0 8px; font-family: var(--font); font-weight: 700; font-size: var(--t-small); letter-spacing: 0.06em; color: var(--muted); }
.board-sub { margin: 0; color: var(--muted); font-size: var(--t-small); font-weight: 600; line-height: 1.4; }
/* THE HEAD IS THE SUBTITLE AND A COUNT CHIP (JIO-1845): "412 playing" off the
   route's `total`, on the right of the same line so the subtitle keeps the
   left edge every artboard draws it on. Mono 14 -- §2's floor, and the chip is
   a figure -- in a pill of `panel-2` with the 1px line, which is the Records
   screen's figure chip and not a §5 control: nothing presses it. It wraps
   under the subtitle on a date line too long for one row, and `flex-wrap` is
   what keeps the chip whole rather than ellipsised. */
.board-head { display: flex; align-items: center; justify-content: space-between; gap: 8px 12px; flex-wrap: wrap; }
.board-count {
  flex: none; height: 28px; padding: 0 10px; border-radius: 999px; display: inline-flex; align-items: center;
  background: var(--panel-2); border: 1px solid var(--line);
  font-family: var(--mono); font-size: var(--t-small); font-weight: 700; color: var(--text); white-space: nowrap;
}

/* THE TAB STRIP IS THE SCREEN'S OWN CONTROL, not §5's segmented one at a wider
   setting -- the same argument `.chips` is written under. §5's is a trough
   INSIDE a card, sized to its own content and sitting beside a label; this is
   the width of the screen, one 40px half each, Bungee 16, and what it chooses
   is which board is drawn rather than a value that is stored. It reuses
   `segmented()`'s MARKUP because two values with exactly one true is a radio
   group whatever it is painted like, and a reader saying "Streaks, radio
   button, 2 of 2" is the whole reason not to draw two toggle buttons. */
/* TWO ROWS OF CONTROL IN ONE BLOCK (JIO-1485): the mode pills over the variant
   tabs, 8px apart, which is half the 20px the screen's own blocks are apart --
   they are two halves of one question ("which board") and not two sections. */
.board-tabrow { margin: 0; display: flex; flex-direction: column; gap: 8px; }

/* THE MODE ROW IS `.chips` IN A GRID, AND THE MEASUREMENT IS WHY (JIO-1485).
   High scores' three speed chips are one flex row of equal cells; five of these
   do not fit one. Measured in headless Chrome against the shipped Bungee on
   2026-09-29: DAILY / ENDLESS / TOWER / VERSUS / PUZZLE take 70, 89, 73, 80 and
   79px of pill at the §2 type floor of 14px -- 391px plus four gaps, inside the
   350px a 390pt phone leaves in the panel. One flex row at `flex: 1 1 0` clips
   ENDLESS at 66.8px a cell; one that sizes to its words WRAPS, and it wraps
   RAGGED, four pills and a fifth hanging under DAILY.
   THREE COLUMNS IS THE SHAPE THE DESIGN SYSTEM ALREADY HAS FOR THIS. FOUNDATION
   §5's rule is that a segmented control is a trough of bare words sized by its
   own content and that six entries is not a row of words -- which is why the
   Settings screen's six worlds are a GRID (`worldPicker`). Five is the same
   answer at 390: three over two, every cell 112px, no label anywhere near its
   box, and a deliberate two rows rather than an accidental one.
   14px and not 16: --t-small is §2's floor, and the alternative was below it. */
.chips.board-modes { display: grid; grid-template-columns: repeat(3, 1fr); }
/* HISTORY'S FOUR FILTERS SHARE THE WIDTH THE SAME WAY (JIO-2034), at the §2
   floor with no side padding: four Bungee words in 350px is the measurement
   that clipped PUZZLES in a flex row. */
.menu.runs .chips.history-filter { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin: 0 0 4px; }
.overlay .menu .chips.history-filter .chip { font-size: var(--t-small); padding: 0 2px; height: 40px; }
.overlay .menu .chips.board-modes .chip { font-size: var(--t-small); padding: 0 6px; }
.overlay .menu .segmented.board-tabs {
  align-self: stretch; margin: 0; padding: 4px; gap: 4px; border-radius: 12px;
}
.overlay .menu .segmented.board-tabs .seg {
  flex: 1; height: 40px; padding: 0; border-radius: 9px;
  font-family: var(--display); font-weight: 400; font-size: 1rem;
}
/* The chosen tab is `panel` with the card lift, NOT the teal fill §5's
   segmented control uses: on this screen teal already means "top three", and a
   tab wearing it would be the one place the accent says two things. */
.overlay .menu .segmented.board-tabs .seg[aria-checked="true"] {
  background: var(--panel); color: var(--text); box-shadow: var(--card-shadow);
}
/* FOUR OR MORE TABS ARE A GRID (2026-10-21): Battle's five ranks -- NOVICE,
   APPRENTICE, JOURNEYMAN, EXPERT, MASTER -- are 440px of Bungee at 1rem and
   drew over one another in a 350px trough. Three columns at the §2 floor,
   the shape `.chips.board-modes` already takes for the same reason. */
.overlay .menu .segmented.board-tabs.board-tabs-many { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.overlay .menu .segmented.board-tabs.board-tabs-many .seg { font-size: var(--t-small); min-width: 0; overflow: hidden; }
/* THE BATTLE RECORD ROWS (2026-10-21): the rank's name where a rank number
   would be, the record as the figure, the fastest win on the right. */
.scoreboard .score-row.battle-row .score-width { width: 118px; }

/* The board fills what is left and pins its foot to the floor, which is the
   artboard's `margin-top: auto` on the "Your time" block. */
.panel[data-screen="scores"] .daily-board { display: flex; flex-direction: column; gap: 20px; flex: 1; min-height: 0; }
.board-rows { display: flex; flex-direction: column; }
/* A ROW IS NOT A `card`, and §4's "a list row on a menu takes card" does not
   reach it: these are ten lines of a table -- 48px, a 1px rule under each, no
   lift, nothing to press -- and the only one that lifts is the player's own,
   which is what pinning it is for. It is also what keeps this screen at zero
   `.menu .card`s, which is the count scripts/probe-artboards.ts reads. */
.board-row {
  display: flex; align-items: center; gap: 14px;
  min-height: 48px; padding: 0 14px; border-radius: 12px;
  border-bottom: 1px solid var(--line);
}
/* `min-width` AND NOT `width` (JIO-1485). 28px holds two figures, and this
   screen now draws a shared rank as `=2` (SPEC §18's joint second) on a board
   whose `total` can be four figures -- a fixed 28 clipped both. The cell still
   floors at the artboard's width, so a board of one-figure ranks is drawn exactly
   as approved and only a wider rank moves the name. */
.br-rank { min-width: 34px; flex: none; font-family: var(--display); font-weight: 400; font-size: 1rem; color: var(--muted); }
/* Ranks 1-3 in teal. COLOUR IS NEVER THE ONLY CARRIER (§1): the rank number is
   drawn either way, so a player who cannot tell the two apart reads exactly the
   same board and loses only the decoration. */
.br-rank.top { color: var(--left); }
/* THE DIE ON EVERY ROW (JIO-1845): 26px, which is the biggest square a 48px
   row holds with the 1px rule under it and a breath each side, between the
   rank and the name. `flex: none` so a long name ellipsises and the die does
   not shrink; `display: block` on the svg so it sits on the row's centre line
   rather than on a text baseline. */
.br-die { flex: none; width: 26px; height: 26px; }
.br-die .avatar-die { display: block; }
.br-name { flex: 1; min-width: 0; font-size: var(--t-body); font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.br-value { flex: none; font-family: var(--mono); font-size: var(--t-mono-md); color: var(--text); }
/* The pinned row: `panel` with a teal border and the card lift, which is the
   one thing on this screen that is drawn to stand off the surface. */
.board-row:is(.you, .lift) {
  background: var(--panel); border: 1px solid var(--left); box-shadow: var(--card-shadow);
}
.board-row:is(.you, .lift) .br-name { font-weight: 700; }
/* The YOU tag, and `.br-tag` is the Tower centre's GUARDIAN beside it
   (JIO-1979): the same 12/700 mark in the same slot, so the two rows of that
   card tag their names the way every board row tags the player's own. */
.br-you, .br-tag { margin-left: 8px; font-size: 0.75rem; font-weight: 700; color: var(--left); letter-spacing: 0.06em; }
/* THE PODIUM (JIO-1845): the top three as three steps, first in the middle
   and tallest, second left, third right. DOM order is rank order (a reader
   hears the winner first) and `order` places them. Each step is a column --
   die, rank, name, figure -- on a `panel` ground with the row's own 12px
   radius, the steps at least 160 / 142 / 126 tall (the third's content holds
   it at 128; measured by scripts/probe-leaderboard.ts) and the dice 52 / 40 /
   32, so the staircase reads at a glance and the first step has room for its
   die under the crown. The three
   share the row width equally (`flex: 1 1 0`), which at 390 is three 111px
   steps: a name wider than that ellipsises, the way a row's does. The crown
   is `--right` (the amber the game already uses for the best) outlined in
   ink, pinned to the top edge of the first step; the first step's rank is
   teal like a row's top-three rank. Figures in the row's mono 15. The whole
   block is 16px above the rows so it reads as the board's head and not as
   three odd rows of it. Zero `.menu .card`s, still: a step is not pressed. */
.board-podium { display: flex; align-items: flex-end; gap: 8px; padding: 10px 0 0; }
.podium-step {
  flex: 1 1 0; min-width: 0; position: relative;
  display: flex; flex-direction: column; align-items: center; justify-content: flex-end; gap: 4px;
  padding: 12px 6px 10px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line);
}
.podium-step.first { order: 2; min-height: 160px; border-color: var(--left); box-shadow: var(--card-shadow); }
.podium-step.second { order: 1; min-height: 142px; }
.podium-step.third { order: 3; min-height: 126px; }
.podium-step.you { border-color: var(--left); }
.podium-crown { position: absolute; top: -7px; left: 50%; transform: translateX(-50%); display: block; }
.podium-die { display: block; }
.podium-die .avatar-die { display: block; }
.podium-rank { font-family: var(--display); font-weight: 400; font-size: 1rem; line-height: 1; color: var(--left); }
.podium-name {
  max-width: 100%; font-size: var(--t-small); font-weight: 700; color: var(--text); text-align: center;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.podium-name .br-you { margin-left: 6px; }
.podium-value { font-family: var(--mono); font-size: var(--t-mono-md); color: var(--text); white-space: nowrap; }
/* On a desktop panel (680px) the steps would stretch to 220px each; cap the
   podium's width so the three read as a group under the title rather than as
   a banner, and keep it centred. */
.panel[data-screen="scores"] .board-podium { max-width: 420px; width: 100%; align-self: center; }
/* THE NEAR-YOU SLICE (JIO-1485): the two rows above the player and the two
   below, under the top ten, with its own muted caption. The 20px above it is the
   board's own gap, so the slice reads as a second block of the same table rather
   than as eleven rows with a gap in them -- which is the whole point: the ranks
   jump from 10 to somewhere else, and a caption is what says the jump is meant.
   The caller is drawn in it AND in the pinned foot, on purpose:
   docs/decisions/0054. */
.board-near { display: flex; flex-direction: column; gap: 8px; }
.board-near-label { margin: 0; padding: 0 14px; font-size: 0.8125rem; font-weight: 600; color: var(--muted); }

/* PINNED MEANS STICKY SINCE JIO-1485, and `margin-top: auto` alone stopped being
   enough the moment the board grew a mode row and a near-you slice: ten rows, five
   more under them and two rows of control is taller than an 844pt phone, so the
   auto margin pinned the row to the bottom of the SCROLL CONTENT, off the screen.
   `.overlay .panel` is the scroller (`max-height: 100%; overflow: auto`), so
   `bottom: 0` holds the row against its floor while the board scrolls behind it.
   It keeps the auto margin: on a board short enough to fit, that is what puts the
   row at the foot of the frame, which is what both artboards draw.
   THE BACKGROUND IS NOT OPTIONAL. Sticky leaves the row over whatever scrolls
   under it, and this one is a table of rows with 1px rules -- transparent, the
   pinned row reads as two rows overlapping. `--bg` and not `--panel`: on a phone
   the panel is full-bleed over the page's own ground (`.overlay .panel`'s
   --card note), and the row's own fill is already --panel. */
.board-foot {
  margin-top: auto; display: flex; flex-direction: column; gap: 8px;
  position: sticky; bottom: 0; padding-top: 8px; background: var(--bg);
}

/* The nudge's one button, for a player who is on no board and could not be
   (a guest, or an account with no username). `.menu` is on the wrapper so the
   button is styled and in the focus ring like every other primary in the game;
   it is the only pressable thing inside the board. */
.board-nudge { margin: 0; }
.board-foot-label { margin: 0; padding: 0 14px; font-size: 0.8125rem; font-weight: 600; color: var(--muted); }
.board-empty, .board-local { margin: 0; padding: 0 14px; color: var(--muted); font-size: var(--t-small); font-weight: 600; line-height: 1.4; }
/* "Couldn't reach the leaderboard" -- the ticket's own sentence, and it is
   `right-text` rather than the bright accent for §1's reason: it is a run of
   text a player reads, not a mark. The local row is still drawn under it. */
.board-error { margin: 0; padding: 0 14px; color: var(--right-text); font-size: var(--t-body); font-weight: 600; line-height: 1.4; }

/* ---- THE BOARD IN FLIGHT (JIO-1878) ----------------------------------------
   `boardSkeleton()` (ui/screens.ts): the podium's three steps and four rows as
   the loaded board's own boxes, with grey bars where the die, the name and
   the figure will be, under the head's chip saying "Fetching the board…".
   design/pages/puzzles/LeaderboardFetching.dc.html is the drawing. A bar is
   `panel-2` with a sheen running across it, and `display: block` so an inline
   span takes its width. The steps lose the first step's teal and lift: nothing
   has won yet. Widths are here and not inline, for the reason every other
   size on this screen is. */
/* `board-fetching` AND NOT `board-count` WITH A MODIFIER: it is the count's
   pill in the count's slot, but it is not a count, and the test that holds
   "no count until the board has answered" reads the class name. */
.board-fetching {
  flex: none; height: 28px; padding: 0 10px; border-radius: 999px; display: inline-flex; align-items: center;
  background: var(--panel-2); border: 1px solid var(--line);
  font-size: var(--t-small); font-weight: 600; color: var(--muted); white-space: nowrap;
}
.board-skeleton { display: flex; flex-direction: column; gap: 20px; }
.board-skeleton .podium-step { border-color: var(--line); box-shadow: none; }
.board-skeleton .board-row { border-bottom-color: var(--line); }
.bone {
  display: block; flex: none; border-radius: 6px;
  background: linear-gradient(90deg, var(--panel-2) 0%, color-mix(in srgb, var(--panel-2) 70%, var(--muted)) 50%, var(--panel-2) 100%);
  background-size: 600px 100%;
  animation: bone-sheen 1.8s linear infinite;
}
.bone-pdie { border-radius: 18%; }
.podium-step.first .bone-pdie { width: 52px; height: 52px; }
.podium-step.second .bone-pdie { width: 40px; height: 40px; }
.podium-step.third .bone-pdie { width: 32px; height: 32px; }
.bone-pname { width: 56px; height: 12px; }
.podium-step.first .bone-pname { width: 64px; }
.podium-step.third .bone-pname { width: 52px; }
.bone-pvalue { width: 40px; height: 12px; }
.podium-step.first .bone-pvalue { width: 44px; }
.podium-step.third .bone-pvalue { width: 36px; }
.bone-rank { min-width: 34px; width: 20px; height: 14px; }
.bone-die { width: 26px; height: 26px; border-radius: 18%; }
.bone-name { width: 120px; height: 14px; }
.board-row:nth-child(2) .bone-name { width: 96px; }
.board-row:nth-child(3) .bone-name { width: 140px; }
.board-row:nth-child(4) .bone-name { width: 110px; }
.bone-value { width: 56px; height: 14px; }
@keyframes bone-sheen { from { background-position: -200px 0; } to { background-position: 400px 0; } }
@media (prefers-reduced-motion: reduce) { .bone { animation: none; } }
.app.reduced-motion .bone { animation: none; }

/* --- the docked bottom sheet (JIO-1090, JIO-1516, JIO-1570) ------------------
   DOCKED WHERE THE FIRST SHEET FLOATS. design/pages/menu/SignInGate.dc.html
   draws a card inside a 20px gutter with a 14px radius; ~~the Daily's
   post-a-time sheet~~ design/pages/results/UsernameSheet.dc.html draws this one
   against the left, right and bottom edges of the frame with a 20px top radius
   and a grab handle. The first-visit prompt keeps its shape, so the difference
   is a MODIFIER rather than a change to `.sheet`, and this comment is here so
   the next person to find two sheet shapes knows which artboard each one is. */
.sheet-scrim:has(.sheet-dock) { padding: 0; }
.sheet.sheet-dock {
  width: 100%; max-width: none;
  gap: 18px; padding: 12px 20px 28px;
  border: 0; border-top: 1px solid var(--line);
  border-radius: 20px 20px 0 0;
  box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.5);
}
/* Decoration and nothing else: it is the affordance a phone reads as "this
   pulls up from the bottom", and there is no drag to go with it -- both answers
   are buttons. `aria-hidden` in the markup for the same reason. */
.sheet-grip { width: 40px; height: 4px; flex: none; border-radius: 999px; background: var(--line); align-self: center; }
/* "NICE." and the time on one baseline, the word in `text` and the time in
   teal -- two elements because they are two colours, which is the artboard. */
.nick-head { display: flex; align-items: baseline; gap: 12px; font-family: var(--display); font-weight: 400; font-size: var(--t-h2); line-height: 1.1; }
.nick-time { color: var(--left); }
/* The copy in this sheet is `text` rather than `.sheet-copy`'s muted: the
   artboard draws #e8ecf3, and it is the one sentence that says what the player
   has to do to be on the board. */
.sheet-dock .sheet-copy { color: var(--text); }
/* THE NAME FIELD, which the Account screen's username is (JIO-1470). It was
   drawn for the Daily nickname sheet, and outlived it (JIO-1516). */
.nick-field { display: flex; flex-direction: column; gap: 8px; }
/* §2's `small` role exactly -- 14/1.4/600 -- and the line-height is written
   rather than inherited: `.overlay`'s 1.5 made the label 21px against the
   artboard's 19, which is the whole of the 2px the sheet was measuring tall
   (scripts/probe-artboards.ts, JIO-1090). */
.nick-field label { font-size: var(--t-small); font-weight: 600; line-height: 1.4; color: var(--muted); }
/* A NATIVE `<input>`, outside `.menu` ON PURPOSE. input/input.ts hands a key
   pressed on a form control back to the browser only when the control is
   OUTSIDE a menu; inside one the game takes the key and preventDefaults it,
   which is what makes the volume slider steppable and would make this field
   impossible to type in.
   THERE IS NO `maxlength`. The cap is 20 CODE POINTS (leaderboard.ts) and
   maxlength counts UTF-16 units, so it would cut an emoji name in half and
   refuse a name that is visibly short. The check is on SAVE and the reason is
   the sentence under the field. */
.nick-field input {
  height: 52px; padding: 0 16px;
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 10px;
  color: var(--text); font-family: var(--font); font-size: 1.125rem;
}
/* `:focus` AND NOT `:focus-visible`, which is the opposite of every button in
   this file. A button's ring is a keyboard affordance and a mouse does not
   need it; a text field that is focused is a field the next keystroke goes
   into, whoever put it there and however they did it, and the artboard draws
   the teal border as the field's normal state. A sheet whose one field looks
   inert until you guess it is focused is worse than a ring a mouse did not
   ask for. */
.nick-field input:focus { outline: none; border-color: var(--left); box-shadow: 0 0 0 1px var(--left); }
/* A REFUSED NAME IS `danger` WHEREVER THE FIELD IS (JIO-1570). ~~`--right`~~ was
   the Daily nickname sheet's amber and that sheet is gone; what is left is one
   field in two places -- the Account screen and the sheet over a finish -- asking
   for the same name, refused by the same route with the same sentence under it,
   so the colour cannot depend on which screen is on top. It is `danger` and not
   amber because nothing about the account CHANGED: a name somebody else has is a
   write that did not happen, which is §1's accent for exactly that. */
.nick-field input[aria-invalid="true"] { border-color: var(--danger); }
/* THE ACCOUNT SCREEN'S NAME FIELD (JIO-1470). The field itself is `.nick-field`
   above -- one label, one native input outside `.menu`, one sentence under it --
   because it is the same shape doing the same job, and a second copy of those
   rules is a second place the 52px and the focus border can drift. What this
   class adds is the SPACING the Account screen needs between the stats block
   above it and the buttons below, which the sheet gets from `.sheet-dock .menu`.

   ~~AND THE REFUSAL IS `danger`~~ -- it is `danger` on `.nick-field` itself
   since JIO-1570, where the rule above says why, so this class adds the SPACING
   and nothing else. */
.acct-username { margin: 12px 0 16px; }
.acct-error { margin: 0; color: var(--danger-text); font-size: var(--t-small); font-weight: 600; line-height: 1.4; }
/* THE DIE PICKER (JIO-1844, SPEC §19 *The die*): the heading in `.stats`' own
   voice, the current pick at 72px with its words under it, then six faces and
   six colours as two chip rows. A chip here is a die and not a word, so it is
   SQUARE and the §5 pill's 999px radius becomes the die's own 12 -- the chip
   is the die's frame and nothing else -- and the chosen one keeps the teal
   border the pill uses for its fill, because a teal fill behind a yellow die is
   a seventh colour. 44px on a phone is the §3 hit target; six of them plus five
   8px gaps is 304px, inside the 350 a 390pt panel leaves. The two rows are in
   `.menu` so the ring walks them (ui/screens.ts), and `.avatar-menu` takes the
   block's 16px margin away: the heading above already carries the gap. */
/* THE ACCOUNT SCREEN'S HEADER CARD AND ITS LOCKED TAB (JIO-1881, SPEC §19
   *The screen*). The card is §5's menu card -- 64 min-height, 12/16 padding,
   the 14 radius, the shadow -- with the die at 64 where the icon goes and the
   handle in the display face at the card-title size, cut with an ellipsis
   rather than wrapping: Bungee is unicase and a long handle is still one word.
   A guest reads GUEST in `muted`, which is the identity chip's colour for the
   same word on the main menu. The locked tab keeps the strip's pill and adds
   the padlock before its word in `muted`; it is not faded, so the lock reads
   as a state and not as a rendering fault. */
.acct-head {
  display: flex; align-items: center; gap: 16px; min-height: 64px; padding: 12px 16px;
  background: var(--panel); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--card-shadow);
}
.acct-head .avatar-die { display: block; flex: none; }
.acct-head-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.acct-head-name {
  font-family: var(--display); font-weight: 400; font-size: var(--t-card); line-height: 1.1; color: var(--text);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acct-head-name.guest { color: var(--muted); }
.acct-head-sub { font-size: var(--t-small); font-weight: 600; line-height: 1.4; color: var(--muted); }
/* THE THIRD LINE, ON THE STYLE PAGE ALONE (JIO-2027): the world, the clear and
   the palette worn, in `.stats`' mono voice -- three names that are always
   three names, which is why the card can carry a line the handle's words were
   refused (`accountHead()`). */
.acct-head-wear { font-family: var(--mono); font-size: var(--t-mono); line-height: 1.4; letter-spacing: 0.02em; color: var(--muted); text-transform: uppercase; }
/* AND THE BADGE ON A GUEST'S DIE, ON THE DIE PAGE ALONE (JIO-1983): the tab
   strip's own 14px padlock in a 26px disc on the corner of the plain die, the
   panel's own ground with the line around it so the disc reads against the
   card it sits on. The card is the whole state of the page -- what is worn,
   and that it cannot be changed yet -- which is why the lock is ON the die and
   not a word beside it. */
.acct-head-figure { position: relative; display: block; flex: none; }
.acct-head-lock {
  position: absolute; right: -6px; bottom: -6px;
  display: flex; align-items: center; justify-content: center;
  width: 26px; height: 26px; border-radius: 999px;
  background: var(--panel); border: 1px solid var(--line); color: var(--text);
}
/* AND THE CARD PINS UNDER THE TITLE (George, 2026-10-18: "the image of the
   die should be fixed at the top underneath the header so changes made to the
   appearance don't jump the user back to the top of the page"). The card IS
   the picker's preview -- the die under the strip was cut for it (ui/screens.ts
   `die()`'s picker) -- so on a phone every row of chips below the fold was a
   pick made blind, and the redraw a pick causes threw the scroll away besides
   (Screens.show() keeps it now). It sticks the way the title does: the offset
   is its own resting place -- the bar, the gap, the title at its line-height,
   the gap -- so nothing moves at a scroll of 0 and the artboards still measure.
   Under z-index 3's title and 4's bar. Its ground is the title band's rule
   again, from the title's edge to 28px under the card with the same fade, and
   keyed off the same `data-scrolled`, so a row of chips sliding under the card
   is frosted and not seen through the gap. */
/* AND IT IS THE ONE CHILD THAT MUST NOT GIVE (JIO-1994, decisions/0192):
   `min-height` lowers a flex item's automatic minimum, so the bar's 77px
   squashed this card 90 -> 64 on the DIE page the probe measures. */
.overlay .panel.menu-panel > .acct-head { flex: none; position: sticky; top: calc(44px + 2 * var(--menu-gap, 20px) + 1.05 * var(--t-h1)); z-index: 2; }
@media (min-width: 720px) { .overlay .panel.menu-panel > .acct-head { top: calc(44px + 2 * var(--menu-gap, 24px) + 1.05 * var(--t-hero)); } }
.overlay .panel.menu-panel > .acct-head::before {
  content: ''; position: absolute; z-index: -1; pointer-events: none;
  left: -50vw; right: -50vw; top: calc(-1 * var(--menu-gap, 20px)); bottom: -28px;
  background: color-mix(in srgb, var(--head-ground) 70%, transparent);
  -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent);
  opacity: 0; transition: opacity 0.25s;
}
.overlay .panel.menu-panel[data-scrolled] > .acct-head::before { opacity: 1; }
.tabs button[aria-disabled="true"] { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); cursor: default; }
.tabs button[aria-disabled="true"]:hover { border-color: var(--line); }
.tab-lock { width: 14px; height: 14px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.avatar-block { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 16px; }
.avatar-label { margin: 0; font-family: var(--mono); font-size: var(--t-small); color: var(--muted); }
.overlay .menu.avatar-menu { margin: 0; }
.avatar-pick { display: flex; flex-direction: column; gap: 12px; }
/* FIVE LABELLED ROWS SINCE JIO-1882 -- FACE, COLOUR, PIPS, INK, FINISH -- each
   a mono label in `.stats`' voice over its chips, because a row of four dice
   that differ only in their sheen does not say what it is choosing the way a
   row of six colours does. */
.avatar-row { display: flex; flex-direction: column; gap: 6px; }
.avatar-row-label { font-family: var(--mono); font-size: var(--t-small); line-height: 1.4; color: var(--muted); }
/* THE LOCKED DIE PAGE (JIO-1983): the sell in a teal-bordered card -- the one
   card on a menu screen that is not `.card`, because it is the page's own
   argument and not a door -- over the picker at 45%, which is the only fade
   in it: every chip under here is `disabled`, so the rows are out of the ring
   and nothing in them can be pressed. 45% and not `hidden`: a guest can see
   the forty chips an account buys, which is the ticket. Each row label wears
   the strip's padlock inline, so the mono label is a flex row here. */
.die-locked-card {
  margin: 16px 0 0; padding: 14px 16px;
  background: var(--panel); border: 1px solid var(--left); border-radius: 14px;
}
.overlay .die-locked-title { margin: 0 0 12px; font-size: var(--t-card); }
.die-locked-card > .tagline { margin: 0 0 12px; }
.die-locked-card > .menu { margin: 0; }
.die-locked-card > .acct-email { margin-bottom: 0; }
.die-locked { margin-top: 20px; opacity: 0.45; }
.die-locked .avatar-row-label { display: inline-flex; align-items: center; gap: 6px; }
/* THE EMBLEM GRID (JIO-1883): NONE and the Tower's badges, six to a row on a
   grid rather than a flex row, because twenty-six chips wrap. A locked tile is
   the mark at 40% with a padlock in the corner, `disabled` and out of the
   ring; the padlock is what says locked, not the fade alone. */
.overlay .menu .chips.avatar-emblems { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
/* THE FACE AND COLOUR ROWS ARE THE SAME GRID SINCE JIO-1885: the face row has
   a seventh die and the colour row grows a magenta and a tile per crowned
   world, so both wrap six to a row the way the emblems do. */
.overlay .menu .chips.avatar-faces, .overlay .menu .chips.avatar-colors { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
.overlay .menu .chips.avatar-faces .chip.avatar-chip, .overlay .menu .chips.avatar-colors .chip.avatar-chip { max-width: none; position: relative; }
.overlay .menu .chips .chip.avatar-chip.locked .avatar-die { opacity: 0.4; }
.overlay .menu .chips .chip.avatar-chip.avatar-chip-emblem, .overlay .menu .chips .chip.avatar-chip.avatar-chip-none { max-width: none; position: relative; color: var(--text); }
.overlay .menu .chips .chip.avatar-chip.avatar-chip-none { font-family: var(--display); font-size: 0.6875rem; color: var(--muted); }
.overlay .menu .chips .chip.avatar-chip.avatar-chip-none[aria-checked="true"] { color: var(--text); }
.avatar-emblem-tile { display: block; width: 60%; height: 60%; }
.overlay .menu .chips .chip.avatar-chip.locked { cursor: default; }
.overlay .menu .chips .chip.avatar-chip.locked .avatar-emblem-tile { opacity: 0.4; }
.overlay .menu .chips .chip.avatar-chip.locked:hover { color: var(--text); border-color: var(--line); }
.avatar-lock { position: absolute; right: 4px; bottom: 4px; fill: none; stroke: var(--muted); stroke-width: 2; stroke-linecap: round; }
/* AROUND THE DIE (JIO-1884): two §5 switch cards under their own label, 8px
   apart the way every card stack is; the picker's row gap keeps them off the
   emblem grid above. */
.avatar-switches { display: flex; flex-direction: column; gap: 8px; }
/* ROLL FOR ONE, AND THE DIE LEAVES ON A TAP (JIO-1887). The header card's die
   is a button when the account is signed in: a tap plays the player's own
   clear on it (ui/avatar.ts's `avatarLeave`, driven by the looks registry) and
   it comes back. ROLL stores a whole pick and the header die turns end over
   end once, on the Tower's own roll clock, landing on what was rolled. Both
   are stilled under reduced motion: the tap does nothing and the roll lands
   without turning, which is the registry's own `reduced` rule for a clear. */
.acct-head-die { background: none; border: 0; padding: 0; margin: 0; display: block; flex: none; cursor: pointer; border-radius: 12px; perspective: 400px; }
.acct-head-die:focus-visible { outline: 2px solid var(--left); outline-offset: 2px; }
.acct-head-die .avatar-die { transform-origin: 50% 50%; will-change: transform, opacity; }
.avatar-rolled { animation: avatar-roll var(--tower-roll-ms, 600ms) cubic-bezier(0.3, 0.7, 0.2, 1) both; }
@keyframes avatar-roll { from { transform: rotateX(-540deg) scale(0.6); } 70% { transform: rotateX(20deg) scale(1.08); } to { transform: rotateX(0) scale(1); } }
@media (prefers-reduced-motion: reduce) { .avatar-rolled { animation: none; } }
.app.reduced-motion .avatar-rolled { animation: none; }
.avatar-current { display: flex; align-items: center; gap: 14px; min-height: 72px; }
.avatar-current .avatar-die { display: block; flex: none; }
.avatar-current-label { font-size: var(--t-body); font-weight: 600; color: var(--text); }
.overlay .menu .chips .chip.avatar-chip {
  flex: 1 1 0; max-width: 52px; aspect-ratio: 1; height: auto; min-height: 44px; padding: 0;
  border-radius: 12px; background: var(--panel-2);
}
.overlay .menu .chips .chip.avatar-chip .avatar-die { display: block; width: 70%; height: 70%; }
.overlay .menu .chips .chip.avatar-chip[aria-checked="true"] { background: var(--panel); border: 2px solid var(--left); }
/* AND THE APP'S EMAIL FORM (JIO-1564), which is `.nick-field` again for the
   reason above: two or three label/input pairs in the column it already lays
   out, so nothing here sets a size, a border or a focus ring. The aside over it
   is what separates the brands' buttons from a form that is this game's own. */
.acct-aside { margin: 18px 0 0; color: var(--muted); }
.acct-email { margin: 10px 0 16px; }
/* The artboard's 18px gap is the sheet's own, so the two buttons take it too --
   `.sheet .menu` is 16 for the first-visit prompt, whose artboard draws 16. */
.sheet-dock .menu { gap: 18px; }

/* ---- THE TOWER'S MAP (JIO-1231; the stair since 2026-10-20) ---------------

   design/pages/tower/TowerMap.dc.html. The stair, the floor pill over it, the
   standing along the foot -- and the one screen in this file whose panel does
   NOT scroll.

   EVERY OTHER MENU SCREEN SCROLLS AS A WHOLE and pins nothing. This one cannot:
   the pill says which floor you are on and the foot says how far up you are,
   and a player who had scrolled either off the screen would be reading an
   unlabelled picture. ~~So the panel is a fixed column with one scroller in
   it~~ -- so the panel is a fixed column and NOTHING IN IT SCROLLS: the map is
   one scene, the step the die stands on and the two or three ahead of it
   (ui/towerMap.ts, docs/decisions/0225), and moving is a roll onto the next
   step rather than a scroll. `min-height: 0` on the map's box is what stops a
   flex child refusing to shrink below its own content -- the failure that
   looks exactly like the map being the wrong size. */
html .app .overlay .panel.menu-panel[data-screen="tower"] { overflow: hidden; }
/* AND IT KEEPS A PHONE'S COLUMN AT EVERY SHAPE THE MAP IS ALONE IN. `--shell`
   is the menu frame's own per-screen override (the Rules page already widens
   it to 1160), and this is the one screen that NARROWS it: the stair is drawn
   in a 390x548 box -- a phone, edge to edge, which is the frame every stair
   artboard was approved at -- and scales DOWN to a narrower room but never up,
   because a scene twice the size is a picture of a different stair. ~~420 is
   the map plus the gutter its floor labels already sit in~~ 470 is the 390
   map plus the desktop padding either side of it, so the box is drawn at its
   own size at 1440 rather than at 340. On a viewport with no height the foot
   stands BESIDE the map, and the shell goes back to 720 to hold the pair (the
   media query at the end of this section). */
html .app .overlay .panel.menu-panel[data-screen="tower"] { --shell: 470px; }
/* THE SCENE IS FLUSH WITH THE SCREEN (JIO-2053, docs/decisions/0219; George,
   2026-10-21: "the background should be more flush in the UI"). The column's
   blocks -- the chrome bar, the plaque, this spacer, the foot, the tab bar --
   lay out as before, but the map is OUT OF FLOW: `.tower-view` is pinned to
   the panel's box down to the foot's floor, under everything (z-index 0
   inside the panel's isolation), and the 390x548 map box inside it is
   anchored `--tower-foot-h` up from there, so the die stands
   where the foot's bar can point at it and the scene runs on up behind the
   plaque and the chrome. `.tower-menu` is what is left of the map's row: a
   spacer that takes the height, so the foot and the tab bar still sit on the
   floor of the frame. */
.tower-menu { flex: 1 1 auto; min-height: 0; }
html .app .overlay .panel.menu-panel[data-screen="tower"] { --tower-foot-h: 102px; --tower-floor: calc(57px + env(safe-area-inset-bottom, 0px)); }
html .app .overlay .panel.menu-panel[data-screen="tower"] > .chrome-bar,
html .app .overlay .panel.menu-panel[data-screen="tower"] > .tower-plaque,
html .app .overlay .panel.menu-panel[data-screen="tower"] > .tower-foot { position: relative; z-index: 1; }
/* THE CHROME IS GLASS over the scene: the three icon buttons wear a round of
   the panel's own dark at 72% with a hairline, which is what every artboard
   on the flush canvas draws, and the spacer that stands in for a missing back
   arrow wears nothing. The tab bar is opaque here -- it is
   `color-mix(... 92%, transparent)` over a blur everywhere else, and a blur of
   a stair is a smear. */
html .app .overlay .panel.menu-panel[data-screen="tower"] > .chrome-bar button.chrome-icon {
  border-radius: 999px; background: color-mix(in srgb, var(--bg) 72%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 18%, transparent);
}
html .app .overlay .panel.menu-panel[data-screen="tower"] > .tabbar { background: var(--bg); -webkit-backdrop-filter: none; backdrop-filter: none; }
/* THE MAP'S BOX IS THE ROOM THE COLUMN LEAVES IT, AND THE MAP FITS IT WHOLE.
   `.tower-view` takes whatever height the head and the foot leave and is a
   size container; `.tower-map` is 390 wide, or the view's width, or the width
   a 390:548 box has at the view's height, whichever is least -- so the scene
   is never clipped and never stretched, on an 844 phone (548 tall, edge to
   edge) and on a 667 one alike. The buttons over it are positioned in
   percentages of this box (ui/towerMap.ts's `towerDice()`), which is what
   keeps the hit areas on their treads at every size the box takes.

   `touch-action: none` IS THE SWIPE'S (ui/screens.ts's `towerSwipe`): a
   vertical drag on the map rolls the die and must not scroll the panel, which
   pins itself anyway. `user-select` because a long press on a picture that is
   mostly buttons would otherwise select the one line of text in it. */
.tower-view { position: absolute; inset: 0 0 calc(var(--tower-floor) + var(--menu-gap, 20px)) 0; z-index: 0; overflow: hidden; touch-action: none; }
/* ~~EDGE TO EDGE ON A PHONE: the panel's 20px gutters are taken back~~ -- the
   view is the panel's whole box (above), so the 390 box IS the phone's width
   with nothing to take back. THE BOX IS ANCHORED TO THE BOTTOM and never
   shrinks: on a phone shorter than the box the scene's top is cropped by the
   view, which is the sky and the wall and not the stair, where a box that fit
   the room would have been a smaller stair. Width-bound only: 390, or the
   room's width on a narrower one. */
.tower-map {
  position: absolute; left: 50%; bottom: var(--tower-foot-h);
  transform: translateX(-50%); width: min(100%, 390px); aspect-ratio: 390 / 548;
  touch-action: none; user-select: none; -webkit-user-select: none;
}
/* `overflow: visible` IS THE FLUSH: the <svg> is the box's size and its
   viewBox is the 390x548 the buttons are percentages of, and what the drawing
   paints outside it -- the wall up to H = -300, the backdrop rects
   (ui/towerMap.ts's `backdrop()`), the riser below -- shows through to the
   view's edges. The attribute is on the element too, for a browser that reads
   the SVG's own default before the stylesheet lands. */
.tower-stair { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: visible; }
/* ~~THE ROOMS BEHIND THE CLIMB (JIO-1270, and one room a floor since JIO-1742),
   AND THEY ARE THE WHOLE PAGE~~ -- THERE IS NO LAYER BEHIND THE MAP SINCE THE
   STAIR (2026-10-20). A floor's scene is inside the map's own <svg>: the far
   view, the wall, the treads and a foreground over the frame, all one drawing
   (ui/towerArt/). What survives of the wall's rules is the one that was never
   about the wall: THE PANEL IS PINNED DARK (`data-theme="dark"`, ui/screens.ts's
   `show()` note). The scenes are literal-coloured illustrations drawn for the
   dark panel, so the light palette's ink would be dark text on them. `color`
   is written again here because a token re-lit on the panel re-lights what
   READS it, and the title reads nothing: it inherits `color` already resolved
   to the light ink on <body>.

   `--muted` IS THE BODY COPY'S `--text-2` ON THIS ONE SCREEN, and that is
   arithmetic rather than taste (measured against the room map's grounds and
   kept): the dark palette's muted grey was 3.4:1 on the lightest floor, under
   AA, and the rank's caption and the foot's two tallies all sit on the panel's
   own ground at `--text-2`, which clears 5.4:1 (test/towerMap.test.js). */
html .app .overlay .panel.menu-panel[data-screen="tower"] { position: relative; isolation: isolate; color: var(--text); --muted: var(--text-2); }
}
/* THE THREE CLASSES BELOW ARE THE ROOM MAP'S (JIO-1681, JIO-1683), RETIRED
   WITH IT (2026-10-20): nothing on the stair carries them. They stay, with
   their reduced-motion rules, while ui/towerRooms.ts and test/towerScene.test.js
   do -- a rule read by a test is deleted with the test, not before it.
   ~~THE ONE THING~~ WHAT MOVES ON THE SCENE. The map became
   an adventure map -- ground, a trail, a ledge under every die and the floor's
   own world in ink line (ui/towerScene.ts) -- and a kit may mark one piece a
   floor `hot`: the Workshop's bench lamp, the Vault's time lock, the
   Greenhouse's lantern, the Quarry's brazier. What `hot` buys is a glow, and a
   glow that breathes was the only ambient animation this screen had until the
   Storm arrived with weather in it. ~~Everything
   else that moves here is the player's own doing -- the scroll above, the climb
   below~~ -- EVERYTHING ELSE THE PLAYER DOES, plus the world behind the whole
   page, which has drifted on its own since JIO-1696 and is `.world`'s business
   rather than this scene's. The scroll above and the climb below are why the
   wall's follow is NOT turned off under reduced motion and these three are.

   EVERY ONE IS A CLASS WITH A REDUCED-MOTION RULE BESIDE IT, never a branch in
   the renderer, and that pairing rather than the count is what
   test/towerScene.test.js holds -- docs/decisions/0097 is why.

   IT IS SLOW AND IT IS SHALLOW ON PURPOSE. 5.2s and 0.72 to 1 is a lamp being
   on rather than a lamp flashing, on the one menu screen a player reads a target
   off; the alpha is the glow's own and no text is ever drawn over it
   (test/towerScene.test.js holds the scene to no `<text>` at all). */
.ts-hot { animation: ts-breathe 5.2s ease-in-out infinite; }
@keyframes ts-breathe { 0%, 100% { opacity: 1; } 50% { opacity: 0.72; } }
.app.reduced-motion .ts-hot { animation: none; }
/* THE STORM'S WEATHER (JIO-1683), and the Storm is the only floor with any.
   `--ts-fall` is the tile the rain's pattern repeats on, written onto the rect
   by ui/towerScene.ts, so the fall is exactly one tile and the loop has no seam
   in it whatever tile a later floor picks. The flash's own `opacity` is 0 in
   the MARKUP, which is what makes `animation: none` no lightning rather than a
   floor washed in its own ink. design/pages/tower-map/NOTES.md is the rest. */
.ts-rain { animation: ts-rain-fall 1.1s linear infinite; }
@keyframes ts-rain-fall { from { transform: translateY(0); } to { transform: translateY(var(--ts-fall, 26px)); } }
.app.reduced-motion .ts-rain { animation: none; }
.ts-flash { animation: ts-flash-strike 9s steps(1, end) infinite; }
@keyframes ts-flash-strike { 0%, 2% { opacity: 0; } 3% { opacity: 1; } 4% { opacity: 0.25; } 5% { opacity: 0.7; } 6%, 100% { opacity: 0; } }
.app.reduced-motion .ts-flash { animation: none; }
/* THE TWO LABELS INSIDE THE STAIR ARE px, AND THEY ARE THE EIGHTH AND NINTH
   LIVE px FONT-SIZES IN THIS FILE (test/type.test.js counts them). A size
   inside an <svg> is a USER UNIT the drawing then scales; a rem there would be
   scaled twice, and a reader at a 32px default font would get a tag wider than
   the die it sits over. ~~a floor's name beside the path and the target over
   the die you are on~~ -- the TAG over the die (its ask, in words) and the CHIP
   beside an open door (the floor above's name), since the stair (2026-10-20).
   The tag is 14 rather than the 12 the canvas drew it at, because §2's floor is
   "14px is the floor for anything a player reads" and the ask is the one thing
   on this screen a player reads. The number on a Reach step's face is a third
   `<text>` and sets no size here: it is drawn in the face's own unit square and
   scaled by the die, so its size is the drawing's (ui/towerDie.ts). */
.tower-tag-text { font-family: var(--display); font-weight: 400; font-size: 14px; text-anchor: middle; }
.tower-chip-text { font-family: var(--display); font-weight: 400; font-size: 14px; letter-spacing: 0.02em; }
.tower-face-number { font-family: var(--display); font-weight: 400; }

/* ---- THE CLIMB, PLAYED ONCE (JIO-1271; onto the stair 2026-10-20) ----------

   A cleared step returns to this map and the die rolls one step up it; a swipe
   or a tap rolls it one step either way. The rules below are its timing;
   ui/towerMap.ts draws the scene walking up under the flying die
   (`climbFrame`) and ui/screens.ts writes it on this clock (`rollTower`),
   cutting to the new step's scene -- the same picture by then -- on the frame
   the die lands -- ON A STEP OF THE SAME FLOOR. Through a door (off a beaten
   landing, and through the Gate) the frame it lands on is the arch filling the
   frame, not the step, so since JIO-2065 (2026-10-21) the roll HOLDS there and
   DISSOLVES onto the new step: the `--tower-door-*` rules below.

   IT IS ONE DURATION IN TWO LANGUAGES, and `--tower-roll-ms` is where they
   meet: `TOWER_ROLL_MS` in ui/screens.ts turns the die and these keyframes time
   everything around it, and a tag that arrived before the die did would be two
   animations of one climb. test/towerMap.test.js reads both numbers.

   THE HOLD-BACK IS HALF OF WHAT THIS SAYS. ~~The die just cleared does not show
   its tick and stars until the marker has rolled off it, and the die climbed
   to is the locked die it was a board ago until the marker lands on it~~ --
   there is one die now, and what it says about the step it has landed on (the
   tag, and the tick on a cleared step) is held back until it has landed: the
   new scene is cut to at `--tower-roll-land` of the roll and the two fade up
   over what is left of it, so the screen narrates the climb instead of
   opening with it already finished.

   NO SEPARATE REDUCED-MOTION PATH IS REACHED IN PRACTICE -- main.ts hands the
   screen no climb for a player who asked for less motion, so none of these
   classes is ever emitted -- and the rules are here anyway, because every
   animation in this file has a reduced-motion variant beside it and an
   exception is a thing the next reader has to re-derive. */
.tower-view { --tower-roll-ms: 600ms; --tower-roll-land: 0.78; }
/* THROUGH A DOOR: THE HOLD AND THE DISSOLVE (JIO-2065, decisions/0225),
   ui/towerMap.ts's `DOOR` in the stylesheet's language, held to it by
   test/towerMap.test.js. `rollTower()` writes the frame the die lands on once
   more, puts the arrived scene UNDER it (`.is-through`) and marks the leaving
   one `.is-leaving`; from there this clock runs it. The hold is the
   animation's delay -- the leaving scene stands, opaque, the flying die kept
   in it (the roll's own rule would hide the die at the end of the roll, which
   is mid-hold) -- and the fade is the animation: opacity to nothing while the
   scene scales about the arch's centre (its `transform-origin`, written by
   `rollTower()` off `doorFrameOrigin()`), so the arch's edges slide out past
   the frame and the new step is what is left. The arrived die's tag and tick
   wait for the whole of it and then fade up as on any landing. Reduced motion
   is never handed a door roll (main.ts hands in no climb at all, as above), and
   the rule is here for the same reason the roll's is. */
.tower-view { --tower-door-hold-ms: 400ms; --tower-door-fade-ms: 300ms; }
.tower-stair.is-leaving { animation: tower-door-out var(--tower-door-fade-ms) ease-in var(--tower-door-hold-ms) both; }
@keyframes tower-door-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: scale(1.5); } }
.tower-stair.is-leaving .tower-roll { animation: none; }
.tower-stair.is-through .tower-you.is-landing .tower-tag,
.tower-stair.is-through .tower-you.is-landing .tower-tick { animation-delay: calc(var(--tower-door-hold-ms) + var(--tower-door-fade-ms)); }
.app.reduced-motion .tower-stair.is-leaving { animation: none; }
/* THE REBUFF'S THREE BEATS (JIO-2056, decisions/0220), ui/towerMap.ts's
   `REBUFF` in the stylesheet's language: the lean up, the hold with the next
   tread's edge lit and the amber tag up, the settle back. Nothing here is
   animated by CSS -- the frames are written by `rebuffTower()` -- so these are
   the record a reader finds beside the roll's, held to the module's numbers
   by test/towerMap.test.js. */
.tower-view { --tower-rebuff-lean: 120ms; --tower-rebuff-hold: 400ms; --tower-rebuff-settle: 220ms; }
/* THE FLYING DIE IS DRAWN PER FRAME (JIO-1614): it turns end over end, which
   changes the faces on show, so no CSS transform of a flat drawing can say it.
   What is left here is taking it away: the new scene has replaced the old one
   by the end of the roll, and a flying die nobody removed would be a second
   die on a browser where the frames never ran. */
.tower-roll { animation: tower-roll 1ms linear var(--tower-roll-ms) forwards; }
@keyframes tower-roll { to { visibility: hidden; } }
/* The words and the tick on the die that has just landed. `--tower-roll-land`
   is ui/towerMap.ts's `FLIP.land` -- the fraction of the roll the die is in the
   air for -- so what is left of the roll once the scene has cut is what these
   fade up over, and test/towerMap.test.js reads both numbers. */
.tower-you.is-landing .tower-tag,
.tower-you.is-landing .tower-tick { animation: tower-arrive calc(var(--tower-roll-ms) * (1 - var(--tower-roll-land))) ease-out both; }
@keyframes tower-arrive { from { opacity: 0; } to { opacity: 1; } }
.app.reduced-motion .tower-roll { display: none; }
.app.reduced-motion .tower-you.is-landing .tower-tag,
.app.reduced-motion .tower-you.is-landing .tower-tick { animation: none; }

/* A die is a <button> over the drawing, and it is TRANSPARENT: the die itself
   is drawn in the SVG behind it (ui/towerMap.ts says why there is one geometry
   and not two). What the button carries is the hit area, the name a screen
   reader reads and the focus ring -- and the ring is the menu's own `.focused`,
   so a die takes the same mark every other menu item does. */
/* FOUR CLASSES AND AN ELEMENT, because `.menu button` is two rules with a
   background, a border, a radius, a padding and a font on it -- and a die that
   took them was drawn as a grey rounded slab ON TOP OF the die it is the hit
   area for. That is what the first run of this canvas's probe rows showed:
   every box in the right place and not one die visible under it. Specificity
   beats source order, so being last in the file bought nothing. */
html .app .overlay .panel .menu button.tower-step {
  position: absolute; display: block; padding: 0; margin: 0; border: none;
  background: none; box-shadow: none; border-radius: 8px; cursor: pointer; color: inherit;
}
html .app .overlay .panel .menu button.tower-step.is-locked { cursor: default; }
/* The menu's own ring is a border and a glow on a card's edge; on a transparent
   box over a drawing it would be a rectangle around a die that is not a
   rectangle, so it is an offset outline instead -- the same mark, moved off the
   shape it is marking. */
html .app .overlay .panel .menu button.tower-step.focused,
html .app .overlay .panel .menu button.tower-step:focus-visible {
  border-color: transparent; box-shadow: none; outline: 2px solid var(--left); outline-offset: 3px;
}
/* ...and a Guardian's ring is the Guardian's amber (JIO-1616), lit or beaten:
   the die under it is amber in every state, and a teal ring round it was the
   last thing on the map still calling it a step. */
html .app .overlay .panel .menu button.tower-step[data-key$="-guardian"].focused,
html .app .overlay .panel .menu button.tower-step[data-key$="-guardian"]:focus-visible { outline-color: var(--right); }

/* The floor pill. It is the screen's one control above the map and it opens the
   stacked-floors sheet, so it wears the chevron that says so. */
html .app .overlay .panel .menu button.tower-pill {
  align-self: flex-start; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  min-height: 36px; height: 36px; padding: 0 14px; border-radius: 999px;
  border: 1px solid var(--line); background: var(--panel); color: var(--text);
  font-family: var(--display); font-weight: 400; font-size: var(--t-small); line-height: 1.1;
  letter-spacing: 0.02em; cursor: pointer;
}

/* THE HEAD: the floor pill, the rank and the stars (JIO-1235). The rank and the
   star count MOVED here from the foot rather than being copied into it -- the
   two are about the whole climb and the foot is about the tread you are on, and
   a rank printed on one screen twice is two readings to disagree.

   IT SITS IN THE PILL'S OWN ROW, which is what keeps it free. The camera's
   scroll offset is arithmetic off whatever height is left for the map
   (design/pages/tower/build.mjs), so a header on a row of its own would have
   moved all thirty die boxes the artboards are compared against. The pill was
   already 36px tall and the crown fits inside it. */
.tower-head { display: flex; align-items: center; justify-content: flex-start; gap: 8px; flex: none; height: 36px; }
/* THE HEAD IS IN THE FOOT (JIO-2053): the floor pill, the power-up pill and the
   crown share one 36px row under the map, the crown pushed to the right. The
   power-up pill is 44 tall everywhere else and 36 here, where the row's height
   is what the map's anchor is computed from (`--tower-foot-h`). */
.tower-head .tower-crown { margin-left: auto; }
html .app .overlay .panel .menu.tower-foot button.tower-active { min-height: 36px; height: 36px; padding: 3px 12px 3px 4px; }
/* TWO LINES INSIDE THE PILL'S 36px, AND THE SIZES ARE WHAT KEEP THEM THERE:
   14/1.2 over 11/1.2 with 2px between is 32, and one pixel more would push the
   row taller than the pill and move the whole staircase. */
.tower-crown { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; min-width: 0; }
/* THE RANK IN THE HEAD TAKES `--left-text` AND THE ONE ON RECORDS DOES NOT, and
   that is section 1's rule applied rather than a second opinion about the
   colour: the accent stays bright on display type and on marks, and a run of it
   at body size takes the text-safe hue. This one shrank from `--t-h2` to
   `--t-small` when it moved into the pill's row (JIO-1235) and kept the bright
   teal, which on a light page is 3.04:1 -- under AA, on the one menu screen
   scripts/probe-theme-contrast.ts had never opened. It walks the Tower since
   JIO-1270 and that is how "UNRANKED" was found. Nothing moves in dark, where
   `--left-text` IS `--left`. */
.tower-head .tower-rank { font-size: var(--t-small); line-height: 1.2; letter-spacing: 0.02em; color: var(--left-text); }
/* THE NOUN THE RANK IS OF (JIO-1321), and it is INLINE rather than a caption on
   a line of its own. "UNRANKED" alone is a word in the corner of a screen with
   no noun attached to it -- a difficulty, a placing or a state of the save, and
   nothing on the screen says which. A third line in the crown is what it cannot
   be: the two above it are 14/1.2 over 11/1.2 with 2px between, which is 32 in
   the pill's 36, and a third would push the row taller than the pill and move
   all thirty die boxes the artboards are compared against (the note two rules
   up). So the word sits in front of the rank on the rank's own line, in the
   body face rather than the display one, which is what keeps "rank UNRANKED"
   reading as a label and a value rather than as two words of one title. */
.tower-head .tower-rank-label {
  font-family: var(--font); font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.04em;
  color: var(--muted); text-transform: uppercase;
}
.tower-star-count { color: var(--muted); font-size: 0.6875rem; line-height: 1.2; font-weight: 600; white-space: nowrap; }
/* THE TITLE IS IN THE BACK ARROW'S ROW (JIO-1618), ~~a row of its own with a
   sentence under it~~. The map is the one menu screen that cannot scroll, so
   every row above it is a row the staircase does not get, and the title and
   the tagline were 115px of a phone between them. The sentence went to the foot
   (ui/screens.ts's `towerCue()`); the title is set at §2's third display size, 24,
   over the chrome bar, between the arrow and the speaker.

   OUT OF FLOW WITH NO OFFSETS, which is what puts it there: an absolutely
   positioned child of a flex column with `top: auto` stands where it would as
   the column's only child, the top of the content box, and that is where the
   44px chrome bar is. So nothing here knows the panel's padding at any width.
   `pointer-events: none` because it lies across the bar's two buttons' row. */
html .app .overlay .panel.menu-panel[data-screen="tower"] > h2:not(.tower-plaque) {
  position: absolute; left: 0; right: 0; margin: 0;
  height: 44px; line-height: 44px; text-align: center;
  font-size: var(--t-h2); pointer-events: none;
}
/* ~~THE TOWER over the chrome bar~~ -- THE PLAQUE (JIO-2053, decisions/0219).
   The heading is the floor's name on a plaque under the chrome bar, in flow
   where every other menu's title is: a dark slab with a hairline and an inner
   line, the floor's number small over its name in the display face. It is the
   one line of chrome the scene did not already say, and it is the heading so
   the dialog is named for the floor rather than for the mode. The rule above
   is kept for a title that is not the plaque, which nothing draws today. */
html .app .overlay .panel.menu-panel[data-screen="tower"] > h2.tower-plaque {
  align-self: center; display: inline-flex; flex-direction: column; align-items: center; gap: 2px;
  margin: 0; padding: 8px 20px; border-radius: 6px;
  background: color-mix(in srgb, var(--panel-2) 88%, transparent); border: 1px solid var(--line);
  box-shadow: inset 0 0 0 4px color-mix(in srgb, var(--bg) 60%, transparent), inset 0 0 0 5px var(--line);
  font-size: var(--t-h2); line-height: 1.1; letter-spacing: 0.04em; text-align: center; pointer-events: none;
}
html .app .overlay .panel.menu-panel[data-screen="tower"] > h2.tower-plaque .tower-plaque-floor {
  font-size: 0.6875rem; line-height: 1.2; letter-spacing: 0.14em; color: var(--muted);
}

/* THE FOOT: the floor's bar, the standing on this floor and the cue. It is a
   STANDING and not a score -- it counts steps rather than printing a
   percentage, because nothing the Tower pays is a grade on the player. */
/* THE FOOT'S HEIGHT IS `--tower-foot-h` AND THESE NUMBERS ARE IT: 12 + 36 (the
   head) + 8 + 6 (the bar) + 8 + 20 (the standing) + 12 = 102. The map's box is
   anchored that far above the tab bar (`.tower-map`), so a rule here that
   grows the foot moves the staircase and every box the probe compares. Its
   ground is a scrim: the scene's riser runs on under it (ui/towerMap.ts's
   `backdrop()`), and the words sit on the panel's own dark by the bar. */
html .app .overlay .panel .menu.tower-foot {
  display: flex; flex-direction: column; gap: 8px; flex: none; padding: 12px 0;
  background: linear-gradient(to bottom, transparent, var(--bg) 24px);
}
.tower-rank { font-family: var(--display); font-weight: 400; font-size: var(--t-h2); line-height: 1.1; color: var(--left); }
.tower-bar { height: 6px; display: flex; gap: 4px; }
/* ONE SEGMENT A STEP (JIO-1618; ui/screens.ts's `towerBar()`). Cleared is the
   marker's teal, the lit one is its dim with a hairline of it, and the last is
   the Guardian's amber in the same three states -- which is the map's own
   colour rule for a die, drawn six across. */
.tower-bar > i { display: block; flex: 1 1 0; height: 100%; border-radius: 3px; background: var(--panel-2); }
.tower-bar > i.on { background: var(--left); }
.tower-bar > i.now { background: var(--left-dim); box-shadow: 0 0 0 1px var(--left); }
.tower-bar > i.guardian { background: var(--right-dim); }
.tower-bar > i.guardian.on { background: var(--right); }
.tower-bar > i.guardian.now { box-shadow: 0 0 0 1px var(--right); }
/* THE LINE-HEIGHTS ARE WRITTEN OUT AND THAT IS LOAD-BEARING. The foot's height
   is what is left over for the map above it, and design/pages/tower/build.mjs
   computes the camera's scroll offset from exactly these numbers -- the
   artboard has to know where the staircase sits before a browser has laid it
   out. A `normal` line-height here is the font's own, so the drawing and the
   screen would agree on every machine that had Nunito Sans and nowhere else. */
.tower-standing { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font-size: var(--t-small); line-height: 20px; font-weight: 600; }
/* ~~`.tower-cue`~~ -- the sentence under the bar is gone (JIO-2053): the die's
   sheet says how to play the step, and the Guardian's says the fight. */

/* BASE CAMP (JIO-2054, docs/decisions/0223): the screen a fresh climb opens
   with. The drawing takes whatever height the chrome and the words leave,
   edge to edge, the Tower's foot and the die standing on the words' top edge
   (`xMidYMax meet`: it scales down on a short phone and never crops); the
   words are a column on the floor of the frame with the frame's bottom
   padding kept. §2's sizes: eyebrow 12 in the display face, the hero at 40,
   the lede at 18/600 in `--text-2`, pills at 12. */
html .app .overlay .panel.menu-panel[data-screen="towerBaseCamp"] { overflow: hidden; }
.basecamp { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; gap: 16px; }
.basecamp-field { flex: 1 1 auto; min-height: 0; margin: 0 -20px; display: flex; align-items: flex-end; justify-content: center; }
.basecamp-field .basecamp-art { display: block; width: 100%; height: 100%; }
.basecamp-say { flex: none; display: flex; flex-direction: column; gap: 12px; }
.basecamp-eyebrow { margin: 0; font-family: var(--display); font-weight: 400; font-size: 0.75rem; line-height: 1.2; letter-spacing: 0.08em; color: var(--muted); }
html .app .overlay .panel.menu-panel[data-screen="towerBaseCamp"] .basecamp-title { margin: 0; font-size: var(--t-hero); line-height: 1.05; }
.basecamp-lede { margin: 0; font-size: var(--t-lede); line-height: 1.5; font-weight: 600; color: var(--text-2); }
.basecamp-pills { list-style: none; margin: 4px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.basecamp-pills > li {
  display: flex; align-items: center; height: 32px; padding: 0 12px; border-radius: 999px;
  border: 1px solid var(--line); background: var(--panel); color: var(--text-2);
  font-family: var(--display); font-weight: 400; font-size: 0.75rem; line-height: 1; letter-spacing: 0.02em;
}
.basecamp-ways { margin: 12px 0 0; gap: 10px; }

/* A PHONE HELD SIDEWAYS PUTS THE FOOT BESIDE THE MAP (JIO-1291), and that is
   this stylesheet's own argument rather than a new one: the mode card turns at
   the same breakpoint because it "spends the width a sideways phone has instead
   of the height it has not".

   THE SCREEN WAS A 63px SLIT. Measured 2026-09-19 at 844x390: an 844px-wide
   viewport is past the 720 seam, so the panel took the desktop frame -- a 40px
   title, 24px gaps, a 24/40 top and bottom -- and what was left for thirty
   steps of staircase was 63 pixels, one die and the top half of the REACH
   marker over it. Every other menu screen survives that because it scrolls as a
   whole; this one cannot, for the reason the head of this section gives, so the
   height it is not given is height the climb simply does not get.

   THE MAP COLUMN IS 390 AND THAT IS THE STAIR'S OWN WIDTH (ui/towerStair.ts's
   `MAP_W`; ~~350, `STAIR.width`~~ until the stair, 2026-10-20). Written out
   here for the reason LANDSCAPE_MAX_HEIGHT is written out as 500: a media
   query cannot read a module constant. It is safe to repeat because it is the
   number a CHANGE to which already means re-drawing every artboard on this
   canvas. On a sideways phone the map is height-bound anyway (`.tower-map`'s
   `100cqh` term), so the column is a ceiling and not the size.

   `--shell` GOES BACK UP TO THE MENU FRAME'S OWN 720. The 420 above is the map
   plus its gutter and nothing else, which is right while the map is the whole
   width of the screen and wrong the moment something stands next to it. */
@media (max-height: 500px) {
  html .app .overlay .panel.menu-panel[data-screen="tower"] {
    --shell: 720px;
    display: grid;
    grid-template-columns: minmax(0, 390px) minmax(0, 1fr);
    grid-template-rows: auto minmax(0, 1fr);
    column-gap: 24px;
    row-gap: 8px;
    padding-top: calc(8px + env(safe-area-inset-top));
    padding-bottom: calc(12px + env(safe-area-inset-bottom));
  }
  html .app .overlay .panel.menu-panel[data-screen="tower"] > .chrome-bar { grid-column: 1 / -1; }
  /* The title is out of flow (above) and a grid places an out-of-flow child in
     the area it names: the bar's own cell, across both columns. */
  html .app .overlay .panel.menu-panel[data-screen="tower"] > h2 { grid-column: 1 / -1; grid-row: 1; }
  html .app .overlay .panel.menu-panel[data-screen="tower"] > .tower-menu { grid-column: 1; grid-row: 2; }
  /* The foot is about the tread you are standing on and the map is what you are
     standing on it in, so the two read as one thing side by side. Centred in its
     column rather than pinned to the floor: a bar, two tallies and a sentence
     hung off the bottom of a 276px column would sit under 199px of nothing. */
  html .app .overlay .panel.menu-panel[data-screen="tower"] > .tower-foot {
    grid-column: 2; grid-row: 2; align-self: center; background: none;
  }
  /* The plaque takes the bar's row beside the title's old place, and the map's
     box stands in the first column from the floor: the foot is beside it, not
     under it, so the anchor pays for the tab bar alone. */
  html .app .overlay .panel.menu-panel[data-screen="tower"] > .tower-plaque { grid-column: 1 / -1; grid-row: 1; justify-self: center; }
  html .app .overlay .panel.menu-panel[data-screen="tower"] .tower-view { inset: 0 0 var(--tower-floor) 0; }
  html .app .overlay .panel.menu-panel[data-screen="tower"] .tower-map { left: 0; transform: none; bottom: 0; }
}

/* The sheets behind a die and behind the pill. `.tower-facts` is a definition
   list because that is what it is -- a target, a best and a grade, each a name
   and a value -- and a table of three rows would be a table for the sake of the
   alignment a flex row already gives. */
.tower-facts { display: flex; flex-direction: column; gap: 8px; margin: 0; }
.tower-facts > div { display: flex; justify-content: space-between; gap: 16px; align-items: baseline; }
.tower-facts dt { color: var(--muted); font-size: var(--t-small); font-weight: 600; }
.tower-facts dd { margin: 0; font-family: var(--display); font-weight: 400; font-size: var(--t-card); }
/* WHAT THE THREE STARS COST, UNDER THE THREE STARS (JIO-1321). The row was a
   grade with no bar under it -- three marks, two of them hollow -- and the one
   thing a reader could not guess is that they are a CLOCK since JIO-1281, on a
   sheet whose other two rows are both scores. It wraps under the marks rather
   than beside them: `.tower-facts > div` is a baseline-aligned flex row with the
   name at one end, so a second value on that line would put a 44-character
   string in the column a three-star glyph sits in. */
.tower-facts .tower-star-costs {
  display: block; margin-top: 2px; font-family: var(--font); font-size: 0.6875rem;
  font-weight: 600; line-height: 1.4; letter-spacing: 0; color: var(--muted); text-align: right;
}
/* A PUZZLE STEP CLEARED BY BEING SHOWN HOW (JIO-1796), inline beside the one
   star it paid rather than on the costs line under it: it is a fact about THIS
   mark and the line under it is the price of all three. In the body font, so it
   does not read as a fourth star in the display face's row. */
.tower-facts .tower-star-shown {
  font-family: var(--font); font-size: 0.6875rem; font-weight: 700;
  letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted);
}
/* WHAT THE BOARD ASKS FOR, AS A ROW ON ITS SHEET (JIO-1726). The figure left
   and the words right, which is `.tb-add`'s own shape on the floor briefing --
   the same row that screen explains a floor's new rule with, used here for the
   job rather than the conditions. The picture is 120x90 (ui/sketch.ts) and is
   scaled into a 92px column: at its own size it would be a third of a phone's
   sheet spent on a drawing with no words in it.

   THE SENTENCE KEEPS ITS OWN MARGIN OF NOTHING. `.sheet-copy` is the paragraph
   style every other line on this sheet uses, and the chip above it is in the
   flow rather than floated, so a two-line ask pushes the sentence down instead
   of wrapping around a pill. */
.tower-ask { display: grid; grid-template-columns: 92px 1fr; align-items: start; gap: 12px; }
.tower-ask-art { display: flex; }
.tower-ask-art .sketch { width: 100%; height: auto; }
.tower-ask-say { display: grid; justify-items: start; gap: 6px; }
.tower-ask-say .tower-brief { margin: 0; }
/* THE ASK'S CHIP IS THE FIFTH KIND, and it is the one in the row that is on the
   player's side -- which is `[data-kind="gift"]`'s argument arriving at the same
   place from the other direction (the chip block further up). It is SOLID in the
   marker's own teal, because teal is the colour the tag over this die on the map
   and the goal bar on the board it deals are both drawn in: the chip is that tag
   repeated, and a chip in a different colour would read as a different fact.

   NOT the world's ink, which `gift` wears: a power-up belongs to the floor and
   is dressed in the floor's colour, and the ask belongs to the GAME -- the same
   words in the same teal on floor 1 and on floor 25. */
.tower-ask .g-chip {
  font-family: var(--display); font-weight: 400; font-size: 0.6875rem; line-height: 1;
  letter-spacing: 0.06em; padding: 5px 9px; border-radius: 999px;
  border: 1px solid var(--left); background: var(--left); color: var(--bg);
}
.tower-stars { letter-spacing: 2px; }
.tower-stars .on { color: var(--left); }
.tower-stars .off { color: var(--line); }
.tower-rule { color: var(--text); }
.tower-said { font-style: italic; }

/* THE GUARDIAN'S SHEET WEARS THE FIGHT IT OPENS (JIO-1617). George, 2026-10-02:
   the screen before the Guardian board "should be more stylish, similar to the
   screen you actually see in the guardian screen". So it is the floor's own
   ground, texture and ink (ui/screens.ts writes the three `--world-*` tokens on
   the sheet from the same table the wall is painted from), the Guardian's
   object with every mark still on it, and the health readout the fight drains.

   IT IS PINNED DARK (`data-theme` on the sheet), for the wall's reason: a
   floor's ground is dark in both palettes, and the light palette's ink on it is
   dark text on a dark sheet.

   THE OBJECT CARRIES `.guardian-object` AS WELL, which is what puts it in its
   resting state for free -- no mark gone, no leaf grown, no crack showing --
   out of the rules the fight's own layer is drawn by. Where it SITS is this
   block's and is said with enough weight to beat every rule that places the
   fight's layer: a `top` object (the bench, the door) is a row of the sheet
   under the grip, and everything else is behind the words. */
.sheet.sheet-dock.gsheet {
  position: relative; isolation: isolate; gap: 14px; padding-bottom: 24px;
  background: linear-gradient(180deg, var(--world-top), var(--world-bottom));
  border-top-color: color-mix(in srgb, var(--world-ink) 55%, transparent);
}
.gsheet .gs-world { position: absolute; inset: 0; z-index: -1; pointer-events: none; overflow: hidden; color: var(--world-ink); }
.gsheet .gs-world > svg { display: block; width: 100%; height: 100%; }
.gsheet .gs-world .texture { opacity: 0.12; }
.gsheet .gs-world .motif { opacity: 0.07; }
/* ~~THE GUARDIAN'S OBJECT ON THE SHEET~~ -- THE DOOR (JIO-2058, decisions/0222).
   The Guardian's sheet is the landing's door, drawn by ui/towerMap.ts's
   `sheetDoor()` at a sheet's size and laid behind the words the way the world
   was: `xMidYMax slice`, so the arch stands on the sheet's floor whatever its
   height, and the lintel's words, the plaques on the frame and the stars on
   the threshold are the sheet's own rows over it. `.gs-leaf` is the room the
   rows leave the leaf, where the emblem is. The object's rules went with the
   object: it is the fight's layer (ui/guardianObject.ts) and nothing else's. */
.gsheet .gs-door { position: absolute; inset: 0; z-index: -1; pointer-events: none; overflow: hidden; }
.gsheet .gs-door > svg { display: block; width: 100%; height: 100%; }
.gsheet .gs-lintel { justify-items: center; text-align: center; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); }
.gsheet.gs-is-door .gs-grade { text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); }
.gsheet .gs-lintel .gs-where { justify-content: center; }
.gsheet .gs-leaf { flex: none; height: 168px; }
/* The plaques: the chips the fight's top bar wears, squared like a plate on a
   door frame, centred across it; DUEL and the board's rows are two more of the
   same kind-tinted row. */
.gsheet .gs-plaques { justify-content: center; }
.gsheet .gs-plaques .g-chip { border-radius: 4px; }
.gsheet .gs-plaques .g-chip[data-kind="duel"], .gsheet .gs-plaques .g-chip[data-kind="rows"] {
  color: var(--right); border-color: color-mix(in srgb, var(--right) 55%, transparent); background: color-mix(in srgb, var(--bg) 70%, transparent);
}
.gsheet .sheet-grip { background: color-mix(in srgb, var(--world-ink) 45%, transparent); }
/* THE MID-BOSS'S PORTRAIT (JIO-1751) -- the row a Guardian's `top` object is,
   and it is NOT that rule: a Guardian's object is a 900x240 band and gets the
   sheet's whole width, where this is one 70x75 cube from the map. Stretched to
   a band's aspect it would be a die the width of a phone with a floor's world
   showing through either side of it, so it is centred at a portrait's own size
   and the viewBox it is drawn in is `BOSS_PORTRAIT` (ui/towerMap.ts), which is
   the cube plus the room a lit boss's glow needs. */
.gsheet .gs-boss { display: flex; justify-content: center; }
.gsheet .gs-boss > svg { display: block; width: 176px; max-width: 48%; height: auto; aspect-ratio: 140 / 130; }
.gsheet .gs-head { display: grid; gap: 8px; }
/* WHERE, then WHO: the row the step screens spend on "FLOOR 1 · STEP 3", with
   the Guardian's emblem in front of it, and the name under it in Bungee. */
.gsheet .gs-where {
  display: flex; align-items: center; gap: 8px;
  font-family: var(--display); font-weight: 400; font-size: 0.75rem; line-height: 1;
  letter-spacing: 0.08em; color: var(--muted);
}
.gsheet .gs-where .tower-emblem { color: var(--world-ink); }
html .app .overlay .sheet.gsheet h3 { margin: 0; font-size: 2rem; line-height: 1.05; color: var(--world-ink); }
.gsheet .tower-said { margin: 0; font-size: 1rem; line-height: 1.45; font-style: italic; font-weight: 600; color: var(--text-2, var(--text)); }
/* THE DUEL STRIP AS IT OPENS (JIO-1819): `.goal.duel`'s twelve unlit rows. */
.gsheet .gs-duel { display: grid; gap: 6px; }
.gsheet .gs-duel-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.gsheet .gs-duel-row b { font-family: var(--display); font-weight: 400; font-size: 1.75rem; line-height: 1; color: var(--text); }
.gsheet .gs-duel-row span { font-family: var(--display); font-size: 0.8125rem; letter-spacing: 0.06em; color: #f59e0b; font-variant-numeric: tabular-nums; }
.gsheet .gs-duel-bar { display: flex; gap: 2px; height: 10px; }
.gsheet .gs-duel-bar > i { flex: 1 1 0; min-width: 0; border-radius: 2px; background: var(--line); }
.gs-chips { display: flex; flex-wrap: wrap; gap: 6px; }
/* ~~`.tower-brief`, `.tower-rule`~~ -- no boss sheet carries a sentence of
   prose since JIO-2058; the plaques say what the fight is. */
.gsheet .gs-grade {
  display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: 4px 10px;
  padding-top: 12px; border-top: 1px solid color-mix(in srgb, var(--world-ink) 22%, transparent);
}
/* On the door the grade stands on the lit threshold rather than under a rule. */
.gsheet.gs-is-door .gs-grade { border-top: 0; padding-top: 0;
  color: var(--muted); font-size: var(--t-small); font-weight: 600; line-height: 1.4;
}
.gsheet .gs-grade .tower-stars { font-size: 1.25rem; line-height: 1; }
.gsheet .gs-grade .tower-stars .off { color: var(--world-ink); opacity: 0.55; }
.gsheet .gs-grade .gs-best { color: var(--text); }
.gsheet .gs-grade .tower-star-costs { grid-column: 1 / -1; }
/* FIGHT IS THE DANGER RED, solid, which is what the card it replaces pressed:
   the one press, and what it opens is a fight. The ring is the sheet's ink
   rather than the menu's teal, which on red reads as a second button. */
html .app .overlay .sheet.gsheet .menu .btn-primary { height: 56px; background: var(--danger); border-color: var(--danger); color: #0b0d12; font-size: 1.25rem; }
html .app .overlay .sheet.gsheet .menu .btn-primary.focused,
html .app .overlay .sheet.gsheet .menu .btn-primary:focus-visible { border-color: var(--text); outline-color: var(--text); box-shadow: 0 0 0 2px color-mix(in srgb, var(--text) 35%, transparent); }
html .app .overlay .sheet.gsheet .menu .btn-secondary { background: rgba(11, 13, 18, 0.35); border-color: color-mix(in srgb, var(--world-ink) 35%, transparent); }
/* A dock is the width of the page, and on a wide one the sheet's column is not:
   560px of words under a 1440px health bar is a bar that reads as a rule. */
@media (min-width: 720px) {
  .sheet.sheet-dock.gsheet { padding-inline: max(20px, calc(50% - 280px)); }
}
/* NO SCROLLER INSIDE THE SHEET -- the sheet itself scrolls, and since the climb
   is twenty floors (JIO-1443) it always does: it opens with the ring on the
   floor you are on, which is what brings that row into view. Five rows at §5's
   64px did not fit under the heading on an 844px phone, and the first shape of this was a nested scroller
   that cut FLOOR 1 in half under the CLOSE button -- which reads as the list
   being broken rather than as there being more of it. The rows come in to 56
   instead: a floor row is a title and a line, not a §5 card with a 40px icon
   slot, so it does not owe that height. The sheet's own `max-height: 100%`
   still catches a Tower deep enough to overrun the frame. */
.overlay .menu.tower-floors { gap: 10px; }
.overlay .menu.tower-floors .card.tower-floor-row { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px 14px; }
.overlay .menu.tower-floors .card.tower-floor-row.unreached { opacity: 0.6; }
.tower-swatch { width: 28px; height: 28px; flex: none; border-radius: 8px; }

/* THE FLOOR BRIEFING (JIO-1653) -- design/pages/tower/FloorBriefing.dc.html.
   One screen before a floor's first step: what it adds, what you hold against
   it, what is still in play and the floor's six boards.

   IT IS A SCREEN AND NOT A SHEET, and the panel is the ordinary scrolling menu
   panel rather than the Tower's fixed column -- `[data-screen="tower"]` above
   is `overflow: hidden` because the map's pill and foot are statements about
   where you are; this screen is one long read and nothing on it is pinned.

   NOTHING IN HERE PINS A PALETTE. The screen is pinned dark by `show()` for the
   Tower panel's reason, and every colour below is a token, so the one place a
   floor's own ink appears is in the figures -- which are the shared kit's and
   carry their own fallbacks (ui/sketch.ts). */
/* THE FIGURE SITS ABOVE THE WORDS AND NEVER BESIDE THEM, which is the one
   layout decision on this screen that is forced rather than chosen. A `tower:*`
   figure is 120 units wide and an `ability:*` one is 264 (ui/sketch.ts's RW and
   W), and the wide set CAPTIONS ITSELF at the 14px floor -- so a 120px column
   beside a sentence would scale an ability's drawing to 45% and take every one
   of its labels under the floor, which is exactly the failure
   test/sketch.test.js refuses on the other screen that draws them. Stacked,
   each figure is drawn at 1:1 at every width this panel has. */
.tb-add { display: grid; gap: 10px; }
.tb-add .tb-figure svg { display: block; max-width: 100%; height: auto; }
.tb-add .tb-say { display: grid; gap: 6px; min-width: 0; }
html .app .overlay .panel .tb-add h3 {
  margin: 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  font-family: var(--display); font-weight: 400; font-size: 1.25rem; line-height: 1.05;
  letter-spacing: 0.02em; color: var(--left);
}
.tb-add p { margin: 0; font-size: var(--t-body); line-height: 1.45; color: var(--text); }
/* WHAT TO DO ABOUT IT, set apart from what it is. The two sentences answer two
   different questions and a reader skimming for the second one needs to be able
   to find it: the rule is the paragraph, the answer is the rule with a border
   down its left and the lead-in in the body colour. */
.tb-add .tb-answer {
  border-left: 2px solid var(--line); padding-left: 10px;
  font-size: var(--t-small); line-height: 1.5; color: var(--text-2);
}
.tb-add .tb-answer b { color: var(--text); }
/* The NEW tag, which is the only thing on this screen that is a claim about the
   CLIMB rather than about the floor. It is the Records badge's pill in the
   player's own accent, and it is what George's "there isn't any real indicator
   that I received a new ability" asked for in one word. */
.tb-tag {
  flex: none; font-family: var(--display); font-weight: 400; font-size: 0.625rem; line-height: 1;
  letter-spacing: 0.08em; padding: 4px 7px; border-radius: 999px;
  background: var(--left); color: var(--bg);
}
.tower-briefing { display: grid; gap: 20px; }
.tower-briefing .tb-nothing { margin: 0; }
html .app .overlay .panel .tower-briefing section > h3 {
  margin: 0 0 10px; font-family: var(--display); font-weight: 400; font-size: 0.8125rem;
  line-height: 1; letter-spacing: 0.08em; color: var(--muted);
}
/* THE KIT ROWS ARE CARDS AND ARE NOT PRESSABLE. `.menu .card` is what makes a
   card a card in this stylesheet, so they are in a `.menu` -- and they are
   `div`s, which is also what keeps them out of the keyboard ring (`show()`
   collects `.menu button, .menu a, .menu input`). A row for a tool the floor
   does not deal is DIMMED and not dropped: "no rerolls, no abilities" is the
   most useful thing floor 1's briefing can say. */
.overlay .tower-briefing .card.tb-kit-row {
  display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px 14px; cursor: default;
}
.overlay .tower-briefing .card.tb-kit-row.tb-off { opacity: 0.55; }
.tower-briefing .tb-standing { margin: 0; font-size: var(--t-small); line-height: 1.5; color: var(--text-2); }
.tower-briefing .tb-standing b { color: var(--text); }
/* THE SIX BOARDS, as a list and not a table: a name, a bar and the chips that
   board's top bar will wear. The target is tabular so the six read as a ladder
   going up, which is what they are (SPEC §20's formula). */
.tb-board-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.tb-board-list li {
  display: grid; grid-template-columns: auto auto 1fr; align-items: center; gap: 10px;
  padding: 7px 12px; border-radius: 10px; background: var(--panel-2);
}
.tb-board-list .tb-board-name {
  font-family: var(--display); font-weight: 400; font-size: 0.75rem; line-height: 1;
  letter-spacing: 0.06em; color: var(--muted);
}
.tb-board-list .tb-guardian .tb-board-name { color: var(--right); }
/* ~~The board's target~~ ITS ASK (JIO-1726), which on four boards in eight is
   words and not a figure (`goalTag()`): `tabular-nums` stays for the four that
   are still a number, and the row is `auto auto` so the column is as wide as
   the longest ask on the floor rather than a fixed one. */
.tb-board-list .tb-board-target { font-variant-numeric: tabular-nums; font-size: var(--t-small); font-weight: 700; color: var(--text); }
.tb-board-chips { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; }
/* A phone puts a board's chips on a line of their own: the name, the bar and
   three chips in one 350px row wrapped every chip onto its own line and took
   the six boards past the fold. Measured at 390x844. */
@media (max-width: 420px) {
  .tb-board-list li { grid-template-columns: auto auto; justify-content: start; }
  .tb-board-chips { grid-column: 1 / -1; justify-content: flex-start; }
}

/* THE FLOOR CARD (JIO-1907): the new rule alone, centred, with BEGIN under
   it. The figure is drawn at its own size as on the briefing, and the line is
   body size because it is the only sentence on the screen. */
.tf-kicker {
  margin: 0; font-family: var(--display); font-weight: 400; font-size: 0.875rem;
  letter-spacing: 0.06em; color: var(--muted);
}
.tower-floor-card { display: grid; gap: 28px; justify-items: center; text-align: center; padding-top: 12px; }
.tower-floor-card .tf-new { margin: 0; }
.tf-add { display: grid; gap: 10px; justify-items: center; max-width: 340px; }
.tf-add .tf-figure svg { display: block; max-width: 100%; height: auto; }
html .app .overlay .panel .tf-add h2 {
  margin: 0; font-family: var(--display); font-weight: 400; font-size: 1.75rem; line-height: 1.05;
  letter-spacing: 0.02em; color: var(--left);
}
.tf-add .tf-line { margin: 0; font-size: 1.125rem; line-height: 1.4; color: var(--text); }
.tf-add .tf-tip { margin: 0; font-size: var(--t-small); line-height: 1.45; color: var(--muted); }
.tf-add .tf-say { display: grid; gap: 10px; }
.tower-floor-card .tf-ways { width: 100%; }
/* TWO OR THREE NEW THINGS (floors 5, 6 and 25) are rows: the figure in a
   column of its own beside the words, left-aligned. Stacked and centred, floor
   25's three put BEGIN at 977 on an 844 phone (measured 2026-10-17). */
.tower-floor-card[data-many] { gap: 20px; justify-items: stretch; text-align: left; }
.tower-floor-card[data-many] .tf-new { justify-self: center; }
.tower-floor-card[data-many] .tf-add {
  grid-template-columns: 88px minmax(0, 1fr); align-items: center; justify-items: start; gap: 14px; max-width: none;
}
.tower-floor-card[data-many] .tf-figure { justify-self: center; }
.tower-floor-card[data-many] .tf-figure svg { max-width: 88px; max-height: 88px; }
.tower-floor-card[data-many] .tf-say { gap: 4px; }
html .app .overlay .panel .tower-floor-card[data-many] .tf-add h2 { font-size: 1.25rem; }
.tower-floor-card[data-many] .tf-add .tf-line { font-size: var(--t-body); }

/* THE REVEAL CARD (JIO-1758), drawn as "Emblem" (George's pick, 2026-10-12):
   the power-up's glyph in a ring of its family's colour, then the name, the
   figure as a pill, the file's one-line effect and two marks. A picture and a
   number first, because a sentence is the slowest way to say "the stack rises
   slower" to a player about to start a board.

   `--pu` IS THE FAMILY'S COLOUR and `[data-family]` is the only thing that sets
   it (ui/powerUpLook.ts names the four): the board and its rise in the left
   hand's teal, the kit in the right hand's amber, protection in the move blue
   and weakening the floor in the ability purple -- tokens the game already
   wears, so a power-up's colour means a thing the player has seen. */
.tower-powerup { --pu: var(--left); --pu-tint: color-mix(in srgb, var(--pu) 13%, transparent);
  display: grid; gap: 14px; justify-items: center; text-align: center; }
.tower-powerup[data-family="kit"] { --pu: var(--right); }
.tower-powerup[data-family="guard"] { --pu: var(--role-move); }
.tower-powerup[data-family="foe"] { --pu: var(--role-ability); }
html .app .overlay .panel .tp-kicker {
  margin: 0 0 4px; text-align: center; font-family: var(--display); font-weight: 400;
  font-size: 0.75rem; line-height: 1.2; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted);
}
.tower-powerup .tp-emblem {
  position: relative; display: flex; align-items: center; justify-content: center;
  width: 196px; height: 196px; margin: 6px 0 4px; border-radius: 50%;
  background: var(--pu-tint); border: 2px solid var(--pu); color: var(--pu);
}
/* The dashed orbit is the emblem's one ornament, and it turns slowly so the
   card reads as something that just happened; reduced motion holds it still. */
.tower-powerup .tp-emblem::before {
  content: ""; position: absolute; inset: -18px; border-radius: 50%;
  border: 2px dashed color-mix(in srgb, var(--pu) 55%, transparent);
  animation: tp-orbit 24s linear infinite;
}
.tower-powerup .tp-glyph { animation: tp-pop 0.5s cubic-bezier(.2, .8, .2, 1) both; }
@keyframes tp-orbit { to { transform: rotate(360deg); } }
@keyframes tp-pop { 0% { transform: scale(.7); } 70% { transform: scale(1.06); } 100% { transform: none; } }
html .app .overlay .panel .tower-powerup h2 { margin: 0; }
html .app .overlay .panel .tower-powerup .tp-figure {
  margin: 0; padding: 8px 18px; border-radius: 999px; background: var(--pu-tint); color: var(--pu);
  font-family: var(--display); font-weight: 400; font-size: 1.25rem; line-height: 1.1;
}
html .app .overlay .panel .tower-powerup .tp-effect {
  margin: 0; max-width: 30ch; font-size: 1.125rem; line-height: 1.35; font-weight: 600; color: var(--text-2);
}
.tower-powerup .tp-why { margin: 0; max-width: 34ch; }
/* The reason the dead button is dead, in the muted body: a disabled control
   with no reason beside it is this stylesheet's own named trap. */
.tower-powerup .tp-why { color: var(--text-2); font-size: var(--t-small); }
.tower-powerup .tp-ways { width: 100%; margin-top: 8px; }
.tower-powerup .tp-redraw { display: flex; align-items: center; justify-content: center; gap: 10px; }
/* The price, on the button that charges it: a play mark and the ad's length in
   amber, the colour the kit's other spend (a reroll) already wears. */
.tower-powerup .tp-ad {
  display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 999px;
  background: var(--right); color: var(--ink); font-family: var(--hud-font); font-weight: 800; font-size: 0.75rem; line-height: 1;
}
@media (prefers-reduced-motion: reduce) {
  .tower-powerup .tp-emblem::before, .tower-powerup .tp-glyph { animation: none; }
}

/* RARITY IS THE RING COUNT (JIO-1903, direction A "Rings", canvas board 7):
   common is the one ring above, uncommon adds a second concentric ring in the
   family's colour, and rare adds a holo ring outside both in place of the
   dashed orbit -- so no tier makes the card any bigger. NEVER A COLOUR, because
   `--pu` already says the family. Everything keys off `[data-rarity]` and the
   `--holo` it sets, so a smaller token carrying the same attribute (the board
   and map pills) can wear the same rings. */
[data-rarity] { --holo: conic-gradient(from 200deg, var(--left), var(--role-move), var(--role-ability), var(--role-swap), var(--right), var(--left)); }
.tower-powerup[data-rarity="uncommon"] .tp-emblem,
.tower-powerup[data-rarity="rare"] .tp-emblem { outline: 3px solid var(--pu); outline-offset: 5px; }
.tower-powerup[data-rarity="rare"] .tp-emblem::before {
  inset: -20px; border: 0; background: var(--holo);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 5px), #000 calc(100% - 4px));
  mask: radial-gradient(farthest-side, transparent calc(100% - 5px), #000 calc(100% - 4px));
}
/* The word under the name, tucked up against it so the line costs the card
   less than a row of its own: muted for common, the text ink for uncommon,
   and the holo itself for rare -- the one thing only a rare card wears. */
html .app .overlay .panel .tower-powerup .tp-rarity {
  margin: -10px 0 0; font-family: var(--display); font-weight: 400; font-size: 0.75rem;
  line-height: 1.2; letter-spacing: 0.14em; color: var(--muted);
}
html .app .overlay .panel .tower-powerup[data-rarity="uncommon"] .tp-rarity { color: var(--text); }
html .app .overlay .panel .tower-powerup[data-rarity="rare"] .tp-rarity {
  background: linear-gradient(90deg, var(--left), var(--role-move), var(--role-ability), var(--role-swap), var(--right));
  -webkit-background-clip: text; background-clip: text; color: transparent;
}

/* THE STACK (JIO-1900, canvas board 4): STACK beside REDRAW, a held list, and
   a card that has to fit a 375x667 phone sheet with three buttons on it. The
   board is never what gives way -- the card does: once a floor holds two, the
   hero's emblem drops to 112px, the effect sentence goes (the held list says
   what is held), and the gaps close up. */
.tower-powerup .tp-stack { border-color: var(--right); }
.tower-powerup .tp-stack[disabled] { border-color: var(--line); }
html .app .overlay .panel .tower-powerup .tp-why.tp-full {
  font-family: var(--display); font-weight: 400; font-size: 0.75rem; letter-spacing: 0.1em; color: var(--right);
}
.tower-powerup:not([data-stack="1"]) { gap: 10px; }
.tower-powerup:not([data-stack="1"]) .tp-emblem { width: 112px; height: 112px; margin: 8px 0 0; }
.tower-powerup:not([data-stack="1"]) .tp-emblem::before { inset: -10px; }
.tower-powerup[data-rarity="rare"]:not([data-stack="1"]) .tp-emblem::before { inset: -12px; }
html .app .overlay .panel .tower-powerup:not([data-stack="1"]) .tp-figure { padding: 5px 14px; font-size: 1rem; }
.tower-powerup .tp-held { display: grid; gap: 6px; width: 100%; margin: 0; padding: 0; list-style: none; }
.tower-powerup .tp-held-row {
  display: flex; align-items: center; gap: 10px; padding: 6px 10px; border-radius: 12px; text-align: left;
  background: color-mix(in srgb, var(--text) 6%, transparent);
}
.tp-held-row[data-family="board"] { --pu: var(--left); }
.tp-held-row[data-family="kit"] { --pu: var(--right); }
.tp-held-row[data-family="guard"] { --pu: var(--role-move); }
.tp-held-row[data-family="foe"] { --pu: var(--role-ability); }
.tower-powerup .tp-held-name { font-family: var(--display); font-weight: 400; font-size: 0.8125rem; letter-spacing: 0.04em; color: var(--text); }
.tower-powerup .tp-held-figure { font-family: var(--hud-font); font-weight: 800; font-size: 0.6875rem; color: var(--pu); }
.tower-powerup .tp-new {
  margin-left: auto; font-family: var(--hud-font); font-weight: 800; font-size: 0.625rem; letter-spacing: 0.08em; color: var(--right);
}

/* A SHORT PHONE (an SE's 667px) cannot hold the single card's 196px emblem
   AND three buttons: measured at 375x667 the card ran past the screen with
   STACK under the fold. So under 740px tall the emblem is the stacked card's
   size, and the board is never what gives. */
@media (max-height: 740px) {
  .tower-powerup { gap: 10px; }
  .tower-powerup .tp-emblem { width: 112px; height: 112px; margin: 8px 0 0; }
  .tower-powerup .tp-emblem::before { inset: -10px; }
  .tower-powerup[data-rarity="rare"] .tp-emblem::before { inset: -12px; }
  .tower-powerup .tp-emblem .tp-glyph { width: 60px; height: 60px; }
  html .app .overlay .panel .tower-powerup .tp-figure { padding: 5px 14px; font-size: 1rem; }
  html .app .overlay .panel .tower-powerup .tp-effect { font-size: 1rem; }
}

/* THE TOP-OUT'S ONE STRIP (JIO-1900): what the retry keeps, under the figure.
   A statement and not a control, so it is a pill and not a button. */
.tower-kept {
  display: inline-flex; align-items: center; gap: 8px; margin: 2px 0 8px; padding: 6px 12px; border-radius: 999px;
  background: color-mix(in srgb, var(--text) 6%, transparent);
  font-size: var(--t-small); font-weight: 800; color: var(--text-2);
}

/* THE RINGS AT TOKEN SIZE (JIO-1900, after JIO-1903): the token's own border is
   common's one ring; uncommon adds a second in the family colour and rare a
   second plus the holo outside it. Box-shadow and a pseudo-element only -- no
   border, padding or margin -- so a ring takes no room in the row it sits in
   and the goal bar is the height it was. */
.pu-token[data-rarity="uncommon"], .pu-token[data-rarity="rare"] {
  box-shadow: 0 0 0 1.5px var(--bg), 0 0 0 3px var(--pu);
}
.pu-token[data-rarity="rare"]::before {
  content: ""; position: absolute; inset: -6px; border-radius: 50%; pointer-events: none; background: var(--holo);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px));
  mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 1.5px));
}

/* THE LOADING FRAME (JIO-1984): the mark and the wordmark, on the ground, from
   the page's first frame until boot() hands over to the splash. GENERATED:
   the four rules under this comment are web/dicelock/ui/splash.ts's
   loadingSheet(), verbatim, and test/splash.test.js fails when the two differ
   -- edit the module and paste. NOT ONE ANIMATION IN THEM, which is what makes
   a reduced-motion open show this frame without a branch deciding to; z-index
   71 is one over the splash's, so the splash's paused first frame is behind
   this rather than in front and the hand-over does not change the ground. */
.loading{position:fixed;inset:0;z-index:71;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;background:var(--bg);overflow:hidden}
.ld-mark{display:block;width:132px;height:132px}
.ld-word{font-family:var(--display);font-size:var(--t-hero);line-height:1.05;letter-spacing:.02em;color:var(--text)}
.ld-word span{color:#2dd4bf}

/* THE SPLASH (JIO-1879): one die rolls in and locks, over the title, once the
   game has loaded and the frame above has come down. The layer is in
   web/dicelock.html so it has a first frame to hand over to, and these rules
   are here for the same reason -- style.css is what the first paint already
   waits for. GENERATED: every line between this comment and the
   ad's is web/dicelock/ui/splash.ts's splashSheet(), verbatim, and
   test/splash.test.js fails when the two differ -- edit the module and paste.
   The base rules are the FINAL frame and the keyframes play backwards from it
   with `both`, so `.splash.still` (reduced motion, set by the head script
   off the stored switch) is the mark and the wordmark and not a die mid-air;
   main.ts takes a still one down after SPLASH_MS.still. z-index 70 is above
   the ad's 60: nothing is ever over the splash, and the splash is over
   nothing but the title. */
.splash{position:fixed;inset:0;z-index:70;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:44px;background:var(--bg);overflow:hidden;animation:sp-layer 2850ms 1 both}
.splash.still *,.splash.still{animation:none}
.splash:not(.go),.splash:not(.go) *{animation-play-state:paused}
.sp-stage{position:relative;width:128px;height:176px;display:flex;align-items:flex-end;justify-content:center}
.sp-shackle{position:absolute;left:25.85px;top:27px;width:76.3px;height:55.4px;box-sizing:border-box;border:17.2px solid #2dd4bf;border-bottom:none;border-radius:38.2px 38.2px 0 0;animation:sp-shackle 2850ms 1 both}
.sp-shackle-line{position:absolute;inset:-11.7px -11.7px 0;border:6.2px solid rgba(11,13,18,0.35);border-bottom:none;border-radius:32.6px 32.6px 0 0}
.sp-die{position:relative;width:128px;height:98.5px;border-radius:24.6px;background:var(--panel);z-index:1;will-change:transform;animation:sp-tumble 2850ms 1 both,sp-body 2850ms 1 both}
.sp-face{position:absolute;inset:0;border-radius:inherit;border:2px solid rgba(0,0,0,.35);box-sizing:border-box;opacity:0;will-change:opacity;animation:2850ms 1 both;animation-timing-function:step-end}
.sp-f1{background:radial-gradient(circle 10.9px at 50% 50%, #0b0d12 98%, transparent 100%), #5eb0ff;animation-name:sp-f1}
.sp-f2{background:radial-gradient(circle 10.9px at 28% 28%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 72% 72%, #0b0d12 98%, transparent 100%), #3ddc97;animation-name:sp-f2}
.sp-f3{background:radial-gradient(circle 10.9px at 25% 25%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 50% 50%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 75% 75%, #0b0d12 98%, transparent 100%), #ffd23f;animation-name:sp-f3}
.sp-f4{background:radial-gradient(circle 10.9px at 28% 28%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 72% 28%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 28% 72%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 72% 72%, #0b0d12 98%, transparent 100%), #ff6b6b;animation-name:sp-f4}
.sp-f5{background:radial-gradient(circle 10.9px at 26% 26%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 74% 26%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 50% 50%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 26% 74%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 74% 74%, #0b0d12 98%, transparent 100%), #b28dff;animation-name:sp-f5}
.sp-f6{background:radial-gradient(circle 10.9px at 28% 24%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 72% 24%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 28% 50%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 72% 50%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 28% 76%, #0b0d12 98%, transparent 100%), radial-gradient(circle 10.9px at 72% 76%, #0b0d12 98%, transparent 100%), #ff9f43;animation-name:sp-f6}
.sp-lock{background:radial-gradient(circle 12.3px at 23% 30%, #0b0d12 98%, transparent 100%), radial-gradient(circle 12.3px at 77% 30%, #0b0d12 98%, transparent 100%), radial-gradient(circle 12.3px at 23% 70%, #0b0d12 98%, transparent 100%), radial-gradient(circle 12.3px at 77% 70%, #0b0d12 98%, transparent 100%), radial-gradient(circle 13.5px at 50% 45%, #0b0d12 98%, transparent 100%), #2dd4bf;border-width:4.9px;opacity:1;animation-name:sp-lock;animation-timing-function:ease}
.sp-stem{position:absolute;left:50%;top:55%;width:12.3px;height:17.2px;margin-left:-6.15px;background:#0b0d12;clip-path:polygon(0 0,100% 0,74% 100%,26% 100%);transform-origin:top center;animation:sp-stem 2850ms 1 both}
.sp-word{display:flex;gap:14px;overflow:hidden;padding:6px 0;font-family:var(--display);font-size:2.75rem;line-height:1.05;letter-spacing:.02em;color:var(--text)}
.sp-word span{display:block;animation:sp-word 2850ms 1 both}
.sp-w2{color:#2dd4bf;animation-delay:120ms}
@keyframes sp-layer{0%,91.23%{opacity:1;visibility:visible}100%{opacity:0;visibility:hidden}}
@keyframes sp-tumble{0%{transform:translate(-240px,-440px) rotate(-900deg) scale(.6);animation-timing-function:ease-in}27.72%{transform:translate(-30px,40px) rotate(-130deg) scale(1);animation-timing-function:ease-out}36.14%{transform:translate(18px,-80px) rotate(-45deg);animation-timing-function:ease-in}40.35%{transform:translate(0,0) rotate(0deg) scale(1)}42.46%{transform:scale(1.1,.9)}45.61%,100%{transform:scale(1)}}
@keyframes sp-f1{0%{opacity:1}5.61%{opacity:0}27.72%{opacity:1}36.14%{opacity:0}100%{opacity:0}}
@keyframes sp-f2{0%{opacity:0}5.61%{opacity:1}11.23%{opacity:0}100%{opacity:0}}
@keyframes sp-f3{0%{opacity:0}11.23%{opacity:1}16.84%{opacity:0}36.14%{opacity:1}40.35%{opacity:0}100%{opacity:0}}
@keyframes sp-f4{0%{opacity:0}16.84%{opacity:1}22.46%{opacity:0}100%{opacity:0}}
@keyframes sp-f5{0%{opacity:0}40.35%{opacity:1}59.65%{opacity:0}100%{opacity:0}}
@keyframes sp-f6{0%{opacity:0}22.46%{opacity:1}27.72%{opacity:0}100%{opacity:0}}
@keyframes sp-lock{0%,54.39%{opacity:0}59.65%,100%{opacity:1}}
@keyframes sp-body{0%,54.39%{height:128px;border-radius:23px;animation-timing-function:ease-in-out}59.65%,100%{height:98.5px;border-radius:24.6px}}
@keyframes sp-stem{0%,60.7%{transform:scaleY(0)}64.91%,100%{transform:scaleY(1)}}
@keyframes sp-shackle{0%,40.35%{opacity:0}45.61%,100%{opacity:1}0%,59.65%{transform:translateY(53px)}68.42%{transform:translateY(-4px)}70.88%,100%{transform:translateY(0)}}
@keyframes sp-word{0%,77.19%{transform:translateY(110%)}85.96%,100%{transform:translateY(0)}}

/* THE PLACEHOLDER AD (JIO-1759) -- the one surface in this game whose job is to
   be in the way. It is `position: fixed` over the whole viewport and ABOVE the
   overlay, because it is raised from a screen (the Tower's reveal card) and a
   card the panel could scroll under would be an ad the player can get behind.

   IT IS NOT STYLED AS PART OF THE GAME. A placeholder that wore the game's own
   panel would teach the player that an ad is one of the game's screens, and the
   day a real one lands the difference would read as a bug; so it is its own
   dark field with its own type, and the kicker says what it is in words.

   ~~THE BAR IS A WIDTH ui/ad.ts WRITES~~ THE RING IS A STROKE OFFSET ui/ad.ts
   WRITES (the "Ring" pick, 2026-10-12), not a transition and not a keyframe, so
   the clock has nothing for reduced motion to turn off; the media query below
   holds only the two dice still. */
.ad {
  position: fixed; inset: 0; z-index: 60;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  padding: max(24px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom)); background: #06080f;
}
/* THE HEAD: the trade on the left, SKIP's ring in the corner a player looks for
   an ad's close. Pinned to the top so the card below centres on what is left. */
.ad-head {
  position: absolute; top: max(16px, env(safe-area-inset-top)); left: 16px; right: 16px;
  display: flex; align-items: center; justify-content: space-between;
}
.ad .ad-trade {
  margin: 0; display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 12px 0 10px;
  border-radius: 999px; background: var(--panel); border: 1px solid var(--line); color: var(--muted);
}
.ad .ad-trade svg:last-of-type { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.ad .ad-trade b { font-family: var(--display); font-weight: 400; font-size: 1rem; color: var(--right); }
.ad .ad-skip {
  position: relative; width: 48px; height: 48px; padding: 0; border: 0; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background: var(--panel); color: var(--text); cursor: pointer; font: inherit;
}
.ad .ad-skip[disabled] { cursor: default; }
.ad .ad-ring { position: absolute; inset: 0; transform: rotate(-90deg); }
.ad .ad-ring circle { fill: none; stroke-width: 3; }
.ad .ad-ring-track { stroke: var(--line); }
.ad .ad-fill { stroke: var(--muted); stroke-linecap: round; }
.ad .ad-skip:not([disabled]) .ad-fill { stroke: var(--left); }
.ad .ad-count {
  position: relative; margin: 0; font-family: var(--display); font-size: 1rem; line-height: 1;
  font-variant-numeric: tabular-nums; color: var(--text);
}
.ad .ad-x { position: relative; display: none; fill: none; stroke: currentColor; stroke-width: 2.8; stroke-linecap: round; }
/* The number is the wait; once there is none the cross takes its place. */
.ad .ad-skip:not([disabled]) .ad-count { display: none; }
.ad .ad-skip:not([disabled]) .ad-x { display: block; }
.ad-card {
  display: grid; gap: 20px; justify-items: center; text-align: center;
  width: 100%; max-width: 420px;
}
.ad .ad-kicker {
  margin: 0; font-family: var(--display); font-weight: 400; font-size: 0.6875rem;
  line-height: 1; letter-spacing: 0.16em; text-transform: uppercase; color: var(--muted);
}
/* Two dice, the game's two hands, bobbing out of step. Drawn in CSS because the
   ad is the one card that cannot wait on an image. */
.ad .ad-dice { position: relative; width: 220px; height: 150px; }
.ad .ad-die {
  position: absolute; width: 96px; height: 96px; border-radius: 22px; box-sizing: border-box;
  display: grid; padding: 16px; gap: 4px;
}
.ad .ad-die b { width: 14px; height: 14px; border-radius: 50%; background: var(--ink); }
.ad .ad-die-five { left: 22px; top: 28px; background: var(--left); grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr);
  animation: ad-bob-a 2.4s ease-in-out infinite; }
.ad .ad-die-five b:nth-child(1) { grid-area: 1 / 1; } .ad .ad-die-five b:nth-child(2) { grid-area: 1 / 3; justify-self: end; }
.ad .ad-die-five b:nth-child(3) { grid-area: 2 / 2; justify-self: center; align-self: center; }
.ad .ad-die-five b:nth-child(4) { grid-area: 3 / 1; align-self: end; } .ad .ad-die-five b:nth-child(5) { grid-area: 3 / 3; justify-self: end; align-self: end; }
.ad .ad-die-four { left: 112px; top: 18px; background: var(--right); grid-template-columns: repeat(2, 1fr); padding: 18px; gap: 10px;
  animation: ad-bob-b 2.8s ease-in-out infinite; }
.ad .ad-die-four b:nth-child(even) { justify-self: end; }
@keyframes ad-bob-a { 0%, 100% { transform: translateY(0) rotate(-8deg); } 50% { transform: translateY(-10px) rotate(-4deg); } }
@keyframes ad-bob-b { 0%, 100% { transform: translateY(0) rotate(10deg); } 50% { transform: translateY(-14px) rotate(14deg); } }
.ad .ad-line {
  margin: 0; max-width: 12ch; font-family: var(--display); font-weight: 400; font-size: 1.875rem;
  line-height: 1.1; letter-spacing: 0.01em; color: var(--text);
}
.ad .ad-note { margin: 0; font-size: 1.0625rem; line-height: 1.45; color: var(--text-2); max-width: 28ch; }
.ad-ways { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
/* SHARE wears the primary button by hand: the card is a child of the app and
   not of the overlay, so `.overlay .btn-primary` never reached it. */
.ad .ad-share {
  height: 56px; padding: 0 36px; border-radius: 14px; border: 0; cursor: pointer;
  background: var(--left); color: var(--ink); font-family: var(--display); font-weight: 400; font-size: 1.125rem;
}
.ad .ad-share:hover { filter: brightness(1.08); }
@media (prefers-reduced-motion: reduce) { .ad .ad-die { animation: none; } }

/* Touch. Everything in here is behind (pointer: coarse), so a mouse sees none
   of it -- "desktop control sizing unchanged" is the DoD bullet this scoping
   exists for (JIO-836). Measured in Chrome before this block, topbar buttons
   were 32px tall and overlay menu buttons 40px at every width, both under the
   44px floor Apple's HIG and WCAG 2.5.5 land on.
   (pointer: coarse) alone, NOT `(pointer: coarse), (hover: none)` as the ticket
   suggested: an OR of the two also matches devices whose primary pointer is
   fine but cannot hover (a stylus, a kiosk, a TV browser), and growing hit
   targets there is exactly the desktop regression the bullet forbids. Coarse is
   the property being compensated for; hover is not. */
@media (pointer: coarse) {
  /* The top bar grows by the same amount its buttons do, and .tutorial
     follows it because it positions from this. */
  :root { --topbar-h: 64px; }
  /* `.puzzle-set .tile` is in this list because a tile is a control that
     happens to be an <a> (JIO-927): the selector above is written for buttons,
     and the tiles came out 42px on a 375px phone -- two pixels under, on the
     one screen made entirely of thumb targets. scripts/probe-puzzles.ts
     measures them, which is how the 42 was found rather than eyeballed. */
  .topbar-controls button, .menu button, .debug button, .tabs button, .puzzle-set .tile, .ph-buttons button, .rp-buttons button {
    min-height: 44px;
    min-width: 44px;
  }
  /* Settings' theme segments are in that list through `.menu button`, and they
     are the one control that carries a 44px target of its own on a fine
     pointer (an ::after overlay, so the paint stays the artboard's 36px). Here
     the button really is 44px, so the overlay would only put another 4px of
     claim on either side of it -- and a target that is bigger than every other
     control on the screen is not what the floor is for (JIO-1080). */
  .overlay .menu .segmented .seg::after { content: none; }
  /* The puzzle HUD's row is the one a thumb reaches for mid-puzzle, so it gets
     the deck's own sizing rather than the topbar's: 64px wide, because Undo,
     Reset and Solve are pressed while looking at the board rather than at the
     button (JIO-928). */
  .ph-buttons button { min-width: 64px; }
  .topbar-controls button { padding: 10px 14px; }
  .menu button { padding: 12px 16px; }
  .topbar-controls { gap: 8px; }
  /* The home link is a control too: the mark inside it is 24px, so the anchor
     is stretched to the 44px a thumb needs -- in BOTH directions. It had the
     height alone from JIO-1063 and came out 24x44 on every coarse screen, which
     is the one control scripts/probe-touch.ts failed on every one of its twelve
     phone measurements while this file claimed it was "inside the same
     stretched 44px anchor" (JIO-1064). `justify-content` keeps the mark where
     it was drawn; the extra 20px is the tap area around it. */
  .topbar .home { min-height: 44px; min-width: 44px; justify-content: center; }
  /* The tuning panel is behind ?debug=1, but it is still tapped with a thumb.
     A checkbox cannot be stretched to 44px square without becoming a placard,
     so the label around it carries the hit area and the box itself grows to
     24px. */
  .debug input, .debug select { min-height: 44px; }
  .debug label { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; }
  .debug input[type="checkbox"] { width: 24px; height: 24px; min-height: 0; }
  .debug .rowd { gap: 8px; }
  /* FOUNDATION §6's TAP affordance, and it is scoped to coarse for the reason
     everything in this block is: on a desktop the meter is a readout, F is the
     control, and a word telling a mouse to tap names a gesture that screen
     cannot make. It appears only on a meter that would actually fire -- a roll
     held but refused (a Shield of your own still up) is full and coloured and
     says nothing about tapping. */
  .meter[data-state="full"].tappable .meter-word { display: block; }
  .meter[data-state="full"].tappable .meter-box { gap: 2px; }
  /* Tapping the same kbd-labelled row is not a target, but the help tables are
     read with the panel at arm's length. This was a 15px bump over a 13-14px
     base; against the scale's body 16 it would be a REDUCTION, so the only
     thing left to lift is the legend, from small print to body (JIO-1062). */
  .diagram-legend { font-size: var(--t-body); }
}

/* LET'S PLAY, which is ~~on the Controls page reached from the first-visit
   sheet's JUST PLAY~~ **on the controls SHEET over a first player's first board
   since JIO-1466** -- JUST PLAY deals the board now and the page it used to
   open is a reference screen again (ui/screens.ts `controlsSheet`). Pinned to
   the foot of the scrolling box, because the panel it sits under is the tallest
   thing in the game either way and a way forward the player has to scroll past
   the whole keyboard to find is no better than the back arrow was. The fade
   lets the diagram run under it rather than being cut by a hard edge, and it is
   `--panel` rather than `--bg`: the sheet is a card, and a gradient into the
   page colour was a band of the wrong grey across the bottom of it. */
.controls-continue {
  position: sticky;
  bottom: calc(-20px - env(safe-area-inset-bottom));
  z-index: 2;
  margin-top: auto;
  padding: 24px 0 calc(4px + env(safe-area-inset-bottom));
  background: linear-gradient(to bottom, transparent, var(--panel) 24px);
}
.controls-continue .btn { width: 100%; max-width: 360px; margin-inline: auto; display: block; }
/* THE SHEET WITH NOTHING BEHIND IT (JIO-1466). Every other sheet dims a menu
   panel, and `.overlay`'s own 82% wash is what sits behind that panel; this one
   is over the ARENA, which is the page itself, so the same 82% under the
   scrim's 55% would be a board nobody could see through two washes. The scrim
   alone is the dimming, at the number the sheet's own artboard draws. Written
   as "the scrim is the only thing in here" rather than as a class, for
   `.overlay:has(.menu-panel)`'s reason a few rules up: the state is what the
   markup already says. */
.overlay:has(.sheet-scrim:only-child) { background: none; }
/* ...and the controls sheet is the FLOAT shape holding a screen's worth of
   content -- a keyboard diagram or a phone drawing, its legend and its caption.
   It needs no third shape (`.sheet` already scrolls at the frame's height), and
   what it does need is the panel's 20px lede gap taken off: the sheet spaces
   its children with its own 16px `gap`, so `.tagline`'s bottom margin was a
   34px hole under the first line. Addressed by `data-sheet` rather than by a
   `kind`, because the kinds are the two SHAPES `server/registry/screens.ts`
   records and this is not a third one. */
.sheet[data-sheet="controlsSheet"] .tagline { margin-bottom: 0; }
