/* Quarterlight — prototype shared brand system
   Canonical palette from _brand/brand_kit.md §9. Restraint rule: never
   more than four active colours on one surface (each game adds ONE hue). */

/* ⚠ 27 Jul 2026, 19:00 — a second session edited this file while the Serpens
   build was in flight (commit 28c5954). If your copy of it was read before then,
   RE-READ before editing or you will drop four changes: .gstat-invite,
   .gstat-go, .gstat-fresh, .streaks-empty, and a `width: 100%` on .sky-cal that
   fixes a live layout bug. See CROSS_SESSION_2026-07-27.md in the repo root.
   Read that file before touching anything under web/sky/ or web/play/serpens/. */

/* --- §2.1 type ---------------------------------------------------------
   Cormorant Garamond and Inter are both gone. Both are named in Matt's own
   Lovable brief's banned list, both sit near the top of every "AI website
   font" tally, and the S77 brand doc had already found Cormorant's hairlines
   shimmer on the petrol ground at display size.

   Fraunces (display) + Public Sans (body/UI), self-hosted — which also deletes
   two preconnects and a blocking Google Fonts request from every page, so no
   third-party sees a visitor load a puzzle.

   Fraunces carries a real optical-size axis and `font-optical-sizing: auto`
   is the axis doing its job: one file gives a 44px title the high-contrast
   display cut and a 10.5px label the sturdy text cut. SOFT and WONK are not
   set to 0 — the subset has them REMOVED, so nothing can engage them later.
   Provenance and the ITF attribution we owe: assets/fonts/README.md. */
/* Newsreader replaced Fraunces site-wide on 17 Aug — see
   assets/fonts/README.md for the measured reasons and the licence, read from
   the font's own name table. It is declared in BOTH stylesheets rather than
   shared, for the same round-trip reason the note below gives. */
@font-face { font-family: "Newsreader"; src: url("/assets/fonts/newsreader.woff2") format("woff2");
             font-weight: 200 800; font-style: normal; font-display: swap; }
@font-face { font-family: "Archivo"; src: url("/assets/fonts/archivo.woff2") format("woff2");
             font-weight: 100 900; font-style: normal; font-display: swap; }

:root {
  --petrol:   #1F3D4A;  /* dark ground */
  --petrol-2: #17313c;  /* deeper panel */
  --petrol-3: #12252e;  /* deepest well */
  --graphite: #22262E;  /* near-black */
  --ivory:    #F1EDE3;  /* primary text on dark */
  --brass:    #B89968;  /* single brand accent */

  /* §2.2. --smoke was #7B8088, which is 3.97:1 on the ground — BELOW the 4.5:1
     WCAG AA needs for text under 18.66px, and almost every use of it is small:
     the game subtitle, the legend, every message, the footer, the dots. The
     site's entire secondary voice was failing AA and the plan's contrast pass
     did not catch it, because it was looking at surfaces rather than type.
     #979CA4 is 5.72:1 on the ground and 4.31:1 on the raised board.
     Consequence worth knowing: secondary text can no longer be made quieter by
     dimming it, so the hierarchy is carried by SIZE and WEIGHT instead — which
     is how it should have been carried in the first place. */
  --smoke:    #979CA4;  /* secondary text / inactive */

  /* The board is an object on a ground, and it now looks like one.
     ⚠️ The plan asserted #2A3D4C is "2.8:1 against the ground, in the 2.5-3.5:1
     band that reads as raised without going milky". Measured, it is 1.41:1 —
     out by a factor of two. And the goal as stated is unreachable: nothing
     clears 3:1 against #12252E until about #4E7089, which is unmistakably milky
     AND drops secondary text on it to 1.67:1. Those two requirements are in
     direct conflict at this ground; no colour satisfies both.

     So the obligation is met where it actually applies. WCAG 1.4.11 governs
     user-interface components and meaningful graphics — not a decorative panel
     fill. What identifies this object is its EDGE, so the edge carries the
     ratio: --board-edge is 4.76:1 on the ground and 3.59:1 on the fill, clearing
     3:1 against both. The fill is then free to be a quiet perceptual step
     (1.33:1) that keeps every piece of text on it legible. */
  --board-fill: #1E3743;
  --board-edge: #77919F;

  --hue: var(--brass);  /* each game overrides this */

  --serif: "Newsreader", Georgia, "Times New Roman", serif;   /* display */
  --sans:  "Archivo", ui-sans-serif, system-ui, -apple-system, sans-serif;

  /* DURATION HAS A SCALE NOW. Easing was already tokenised and consistent
     (--ease/--ease-out, 50+ var() uses); duration was not, and a de-facto scale
     had formed anyway — 0.25s, 0.3s and 0.4s account for most of it. Naming it
     is what stops the tail growing.

     These are DUPLICATED into both stylesheets on purpose, the same as every
     other token here: site.css:26 explains that an @import costs a round trip
     before a single glyph is discovered. Add to both by hand.

     The one-off values below the scale (0.35, 0.22, 0.18, 0.14, 0.10, 0.06,
     0.02) are LEFT ALONE deliberately. Folding them onto the nearest token
     would change eight timings for tidiness, and none of them has a reason to
     move; they are listed here so the next pass can judge each on purpose
     rather than inherit a sweep. */
  --dur-fast: 0.2s;
  --dur-base: 0.25s;
  --dur-mid:  0.3s;
  --dur-slow: 0.4s;
  --ease: cubic-bezier(0.33, 0, 0.2, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);

  /* One board size for all four games. They were capped at 340–480px, which
     put the game on a quarter of a desktop screen and left no room to grow a
     grid without dropping cells under the 44pt tap minimum.

     --board-chrome is MEASURED, not guessed: 240px above the board (mast,
     title, subtitle, daily line, mode bar), 40px below (footer), and ~150px
     the board spends on its own HUD, dots, message and legend under the grid.
     Total page height comes to boardWidth + 430, confirmed against the live
     layout. Subtracting it is what keeps the whole board on screen while you
     play — a puzzle you have to scroll during is worse than a smaller one.
     The max() floor matters: on a short screen (an iPhone SE is 667px tall)
     the height term alone would hand back a 237px board, smaller than the
     340–480px the games have today. Phones scroll happily and are bound by
     width anyway, so the height term is only allowed to bind once there is
     room for it to.

     THE WIDTH TERM IS THE PAGE GUTTER, NOT A ROUND NUMBER. It was 94vw, and
     94vw is not the column the rest of the page sits in: body pads 16px a side,
     so at 390 the mast, the rule, the mode bar and the footer all run 16→374
     while the board ran 12→378. Every game was 4.3px wider than its own column
     on both sides, and the NIGHTLY chip directly above the board started 4px
     inboard of the board's left edge — one clean left margin with a step in it,
     on all six games. It was reported against Serpens, where the step is easiest
     to see, and it was never a Serpens defect.

     So the two numbers are now one number. --page-gutter feeds body's padding
     and this term, and they cannot drift apart again. */
  --page-gutter: 16px;
  --board-chrome: 440px;   /* 430 fits Lumen/Zenith/Halo exactly; Meridian's meta row wants 6 more */
  --board-floor: 360px;
  --board: min(calc(100vw - 2 * var(--page-gutter)), max(calc(100vh - var(--board-chrome)), var(--board-floor)), 860px);
  --board-pad: 20px;
  --board-inner: calc(var(--board) - 2 * var(--board-pad));
}

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

html, body { height: 100%; }

/* iOS paints its own rgba(51,181,229,0.4) flash over whatever an activatable
   element's box happens to be. On a board that IS one big element, that is the
   whole play area going bright blue on every tap — measured on all six games.
   Lumen had already turned it off on `.cell` alone, which is why only Lumen
   looked right. Every game draws its own press state, so the default is pure
   loss here. */
html { -webkit-tap-highlight-color: transparent; }

/* Off-screen but PRESENT. `display:none` and `visibility:hidden` both remove a
   node from the accessibility tree, which is the one thing a live region for
   announcements must never be — it would be a channel that silently carries
   nothing. Clipped instead: still laid out, still read, never painted, and
   `white-space: nowrap` so a long sentence cannot reflow the page it is
   invisible on. */
.sr-only {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}

body {
  background: var(--petrol-3);
  color: var(--ivory);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;

  display: flex;
  flex-direction: column;
  align-items: center;
  min-height: 100vh;
  min-height: 100dvh;
  padding: 22px var(--page-gutter) 20px;
  position: relative;
  overflow-x: hidden;
}

/* --- the sky ------------------------------------------------------------
   §2.2 + §2.4. The three animated radial gradients on body::before are gone
   and the ground is flat: "flat ground, drawn sky, raised boards, the games as
   the brightest thing on the page."

   And the fourteen CSS radial-gradient dots are gone with them, replaced by
   the REAL southern sky — 1,348 actual stars from the HYG catalogue between
   RA 7h and 15h, stereographically projected, each sized by its true magnitude
   and tinted from its measured B-V colour index. Carina, Vela, Crux and
   Centaurus.

   The point is not accuracy for its own sake. §2.4's argument is that the page
   "contains nothing a person made", and swapping ten generic dots for eight
   hundred randomly-placed ones would be the identical move at higher
   resolution. Real data is not a scatter: the Milky Way is NOT drawn here, it
   emerges, because along the galactic plane that is genuinely where the stars
   are. Regenerate with _tools/build_skyfield.js.

   No twinkle. The field already varies in magnitude and colour because the sky
   does; animating a 1,348-element layer's opacity would spend compositing on
   an effect the real thing does not need. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background-image: url("/assets/sky-field.svg");
  background-size: cover;
  background-position: 50% 30%;
  opacity: 0.85;
  /* a gentle vignette so the corners fall into dark */
  mask-image: radial-gradient(120% 100% at 50% 40%, #000 55%, rgba(0,0,0,0.45) 100%);
  -webkit-mask-image: radial-gradient(120% 100% at 50% 40%, #000 55%, rgba(0,0,0,0.45) 100%);
}

/* --- shared masthead --------------------------------------------------- */
.ql-mast {
  width: 100%;
  max-width: 480px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-bottom: 14px;
  /* Three children, one of them the 153px switcher (5 dots + 4 gaps), none of
     them shrinkable. Measured at 320px: .ql-back — the only way back to the hub
     — sat at left 318 and ran to 372, clipped away entirely by body's
     overflow-x: hidden. Wrapping costs a line on the narrowest phones and keeps
     the navigation reachable. */
  flex-wrap: wrap;
  gap: 4px 12px;
}
/* REAL CAPITALS. `font-variant: small-caps` was synthetic here too — Archivo
   ships no `smcp`, so the browser was scaling capitals and getting their stems
   wrong, exactly as Fraunces did on the landing page's wordmark. */
.ql-wordmark {
  font-family: var(--sans);
  font-variation-settings: "wdth" 88;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.28em;
  font-size: 13px;
  color: var(--ivory);
  text-decoration: none;
  padding-left: 0.34em;
}
.ql-wordmark .notch { color: var(--brass); }
.ql-back {
  font-family: var(--sans);
  font-size: 12.5px;
  letter-spacing: 0.02em;
  color: var(--smoke);
  text-decoration: none;
  transition: color var(--dur-mid) var(--ease);
}
.ql-back:hover { color: var(--brass); }
/* Both were 15-18px tall, which clears no target floor on any input. 24px is
   WCAG 2.2 AA's pointer minimum; the phone block below takes them to 44. */
.ql-wordmark, .ql-back { display: inline-flex; align-items: center; min-height: 24px; }

.ql-rule {
  width: 100%;
  max-width: 480px;
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--brass) 12%, var(--brass) 88%, transparent);
  opacity: 0.45;
}

/* --- the play area: everything between rule and footer, optically centred */
.play {
  flex: 1 1 auto;
  width: 100%;
  max-width: 480px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 8px 0;
  gap: 0;
}

/* --- game titles ------------------------------------------------------- */
/* THE SAME SETTING THE LANDING PAGE GIVES A GAME NAME. It carried `--sans` at
   default width after the 17 Aug type change, so "Zenith" on its own page was a
   plain grotesque while "Zenith" on the wall was condensed — the same word, two
   faces, one tap apart. wdth 70 / wght 700 / -0.025em is the shared display
   setting; if it moves in site.css it moves here. */
.game-title {
  font-family: var(--sans);
  font-variation-settings: "wdth" 70;
  font-weight: 700;
  font-size: 38px;
  letter-spacing: -0.025em;
  line-height: 1;
  text-align: center;
  margin-bottom: 7px;
}
.game-title .lead { color: var(--hue); }   /* jewelled first letter */
.game-sub {
  font-family: var(--sans);
  font-size: 12px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--smoke);
  text-align: center;
  margin-bottom: 26px;
  /* The 1040px rail override has carried text-wrap: balance for a while, with a
     comment about orphaning. Centred mobile is where an orphan reads worst, and
     it was the one place without it. */
  text-wrap: balance;
}

/* --- the board panel: a framed viewport onto the sky ------------------- */
/* A raised object, and now legibly one. Three things make a panel read as
   lifted on a dark ground and the old board had all three at homeopathic
   strength: a fill step (was 1.14:1, now 1.33:1), a light top edge, and a
   shadow that actually reaches. The border is the piece carrying WCAG 1.4.11 —
   see the note on --board-edge. */
/* A HELD FINGER MUST NOT RAISE A TEXT MENU OVER THE GAME.

   Found in the iOS Simulator, 16 Aug, and invisible to every headless check we
   have: press and hold anywhere on Lumen's sky and iOS selects the board as
   text — blue selection handles across the grid and a "Copy | Find Selection"
   callout over the play area. Holding still is exactly what a player does while
   working out where the star is.

   Meridian was already immune (`.grid-wrap` sets user-select: none at
   meridian:43) which is why this never showed up there; the other five boards
   had nothing. The rule belongs on the shared board, not in five files.

   Inputs are exempted straight back: Sextant's read field is a real text input
   and must stay selectable and editable. */
.board {
  -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
  position: relative;
  width: var(--board);
  border-radius: 14px;
  background: var(--board-fill);
  border: 1px solid var(--board-edge);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.07),
    0 24px 50px -28px rgba(0,0,0,0.85);
  padding: var(--board-pad);
}
.board input, .board textarea, .board [contenteditable] {
  -webkit-user-select: text; user-select: text;
}

/* --- buttons ----------------------------------------------------------- */
button, .btn {
  font-family: var(--sans);
  font-size: 12.5px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ivory);
  background: transparent;
  border: 1px solid var(--brass);
  border-radius: 3px;
  padding: 11px 22px;
  cursor: pointer;
  transition: background var(--dur-mid) var(--ease), color var(--dur-mid) var(--ease), box-shadow var(--dur-mid) var(--ease), opacity var(--dur-mid) var(--ease);
}
button:hover, .btn:hover {
  background: var(--brass);
  color: var(--graphite);
  box-shadow: 0 6px 20px -8px rgba(184,153,104,0.6);
}
button:active, .btn:active { transform: translateY(1px); }
button:disabled { opacity: 0.3; cursor: default; box-shadow: none; }
button:disabled:hover { background: transparent; color: var(--ivory); }

/* a soft-primary button tinted to the game hue */
.btn-hue { border-color: var(--hue); }
.btn-hue:hover { background: var(--hue); color: var(--graphite); box-shadow: 0 6px 20px -8px var(--hue); }

.hint { color: var(--smoke); font-size: 12.5px; line-height: 1.6; max-width: 420px; text-align: center; }
.stat { color: var(--brass); font-variant-numeric: tabular-nums; }

/* --- footer strip ------------------------------------------------------ */
.foot {
  width: 100%;
  max-width: 480px;
  text-align: center;
  color: var(--smoke);
  font-size: 11.5px;
  letter-spacing: 0.04em;
  line-height: 1.6;
  padding-top: 14px;
  /* NO OPACITY. --smoke measures 5.72:1 on the ground and this line was still
     landing at 4.18:1 at 11.5px — under AA — because `opacity: 0.8` multiplied
     it back down. That is precisely the trap the --smoke note above was written
     about: the token was raised so secondary text would pass, and the fix is
     void wherever something dims it afterwards. The hierarchy here is already
     carried by size (11.5px against the board's 15px), which is how the note
     says it should be carried. Measured after removal: 5.72:1. */
}

canvas { display: block; touch-action: none; }

/* --- floating mute toggle --------------------------------------------- */
.ql-mute {
  position: fixed; left: 14px; bottom: 12px; z-index: 30;
  width: 34px; height: 34px; padding: 0; display: grid; place-items: center;
  border: 1px solid rgba(184,153,104,0.28); border-radius: 50%;
  background: rgba(18,37,46,0.6); color: var(--smoke);
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  transition: color var(--dur-base) var(--ease), border-color var(--dur-base) var(--ease), background var(--dur-base) var(--ease);
}
.ql-mute:hover { color: var(--ivory); border-color: var(--brass); background: rgba(18,37,46,0.85); box-shadow: none; }
.ql-mute:active { transform: translateY(1px); }
.ql-info {
  position: fixed; right: 14px; bottom: 12px; z-index: 30;
  width: 34px; height: 34px; padding: 0; display: grid; place-items: center;
  border: 1px solid rgba(184,153,104,0.28); border-radius: 50%;
  background: rgba(18,37,46,0.6); color: var(--smoke);
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  transition: color var(--dur-base) var(--ease), border-color var(--dur-base) var(--ease), background var(--dur-base) var(--ease);
}
.ql-info:hover { color: var(--ivory); border-color: var(--brass); background: rgba(18,37,46,0.85); box-shadow: none; }
.ql-info:active { transform: translateY(1px); }

/* --- embed mode: just the live board, for landing-page iframes ---------- */
/* An embed is a short iframe with everything but the board hidden, so 100vh
   here is the IFRAME's height and --board-chrome would be subtracting furniture
   that is display:none. Left alone, every embed collapses onto --board-floor
   and Halo's hero canvas drops 440px -> 318px. The board is square once the
   HUD and message are gone, so it just takes the iframe. */
/* ⚠️ 100vw, not 94vw. The 6% inset was invisible dead space — a plate has no
   border of its own, so the only edge anyone can SEE is the board's. At 94vw
   that edge sat 17px inside the plate box on all four sides, which meant the
   landing page's left rail alternated 80px / 97px / 80px / 97px down the page
   as the plate swapped sides: all the grid alignment was nominal and none of it
   was optical. The board now fills the plate exactly, so the visible edge IS
   the rail. (The drop shadow is clipped by overflow:hidden, which is fine —
   it never read against the petrol ground anyway.) */
/* TRANSPARENT, so the landing page's `.plate` supplies the ground. The board
   used to repaint the page's own petrol over the plate, which is why the six
   games measured 1.00 contrast against the page they sat on and read as holes
   rather than objects. This affects embeds only; a game opened on its own page
   still paints its own background. */
body.ql-embed { --board: min(100vw, 100vh); padding: 0 !important; overflow: hidden;
                background: transparent !important; }
body.ql-embed .ql-mast, body.ql-embed .ql-rule, body.ql-embed .mode-bar,
body.ql-embed .foot, body.ql-embed .game-title, body.ql-embed .game-sub,
body.ql-embed .daily, body.ql-embed .ql-mute, body.ql-embed .ql-info,
body.ql-embed .lhud, body.ql-embed .hud, body.ql-embed .row2,
body.ql-embed .controls, body.ql-embed #share,
body.ql-embed .msg, body.ql-embed .par, body.ql-embed .legend,
body.ql-embed .dots,
/* Sextant is the one game that is not a square board, so `--board: min(100vw,
   100vh)` cannot size it — its content ran 642px inside a 335px plate and the
   plate, carrying scrolling="no", simply cut 307px off mid-sentence. On a
   Sextant hero night that sliced line was the first thing a phone visitor saw.
   The demo is not playable (ql-demo kills hit-testing), so the teaching line,
   the word input and the call button are all showing a visitor something they
   cannot use. What identifies Sextant is the scale and the list of rules;
   that is what the plate keeps. Asserted by phone_gate.py's plate-clip check. */
body.ql-embed .how, body.ql-embed .probe, body.ql-embed .callfix,
body.ql-embed .restart,
body.ql-embed .meta { display: none !important; }
/* The rest of Sextant's overflow is NOT solved here, and deliberately not: the
   remaining 58px was the plate being square when the game is not, and the
   answer to that is a taller plate on phone (site.css), not smaller type until
   a rules list pretends to be a board. */
body.ql-embed .play { padding: 0 !important; justify-content: center; }

/* AND SEXTANT'S POST-MORTEM COMES OFF TOO. `.working` — "What each word ruled
   out" — is 239px of struck-through 8px type inside the win card. On the desk
   station it had room; in a wall cell the plate is 375px square and the win
   card runs 417px, so the list was clipped 21px mid-sentence by the plate's
   overflow, silently. That is the same defect class as the sliced how-to line
   pulled in July, one surface along.

   It is also the right thing to drop on the merits: at cell size it is
   unreadable, and a demo nobody can play does not need the reasoning it would
   have shown you. What the card keeps is the part that says what happened —
   the law that was found, "3 words · par 3 · level par", and the result dots.
   Measured after: 0px clipped at every state of the loop. */
body.ql-embed .working { display: none !important; }

/* SEXTANT'S SECOND FRAME COMES OFF — IN THE EMBED, AND ONLY IN THE EMBED.
   `.board` draws a 14px-radius card with a 1px border, and the landing page's
   `.plate` derives its own 15px radius from exactly that so the two edges sit
   1px apart and read as ONE frame. That works because five of the six boards
   FILL the iframe. Sextant does not — it is a scale plus six rules, sized by
   its content — so its card floats inside the plate and the page shows a
   rounded rectangle inside an identical rounded rectangle. A nested card, on
   the one game that has to explain itself fastest.

   No aspect ratio fixes this; one of the two frames has to go, and it is this
   one, because `.plate` is the object language the whole site is built in and
   the card is a per-game accident. Scoped to `.ql-embed` so Sextant's own page
   is untouched — there the card IS the board's edge and there is no plate. */
body.ql-embed.ql-g-sextant .board {
  border: 0; border-radius: 0; background: none; box-shadow: none; padding: 0;
}
/* An attract loop throws transient things — a score popup, a gathered-light
   flourish — and any of them landing a few pixels past the board extends the
   embed's document, which the plate then clips. Chasing each one is endless
   and they move; clipping the container makes it impossible instead. Nothing
   in a demo is meant to live outside the board. */
body.ql-embed .play { overflow: hidden; }

/* demo (landing-page attract mode): the board is a picture, not a game — but a
   picture of the game BEING PLAYED. The four loops live in ql-attract.js. */
body.ql-demo .board { pointer-events: none; }

/* The visible hand. A loop where boards change with nothing touching them reads
   as a screensaver; the ring is what makes it read as a person. Sized to the
   44px tap floor the boards themselves enforce, so it lands where a real finger
   would. */
.ql-ghost-layer { position: fixed; inset: 0; pointer-events: none; z-index: 40; }
.ql-ghost {
  position: absolute; width: 44px; height: 44px; margin: -22px 0 0 -22px;
  border: 1.5px solid rgba(241,237,227,0.75); border-radius: 50%;
  animation: ql-ghost-tap 0.62s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes ql-ghost-tap {
  0%   { transform: scale(1.5);  opacity: 0; }
  28%  { transform: scale(1);    opacity: 0.95; }
  100% { transform: scale(0.45); opacity: 0; }
}
/* reduced motion gets a settled board and no hand at all — ql-attract.js never
   emits a ghost in that mode, and this is the belt to that braces */
@media (prefers-reduced-motion: reduce) { .ql-ghost { display: none; } }

/* --- mode bar: Nightly + the Easy→Expert ladder ------------------------- */
.mode-bar { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px; margin: 0 0 22px; }
.mode-chip {
  font-family: var(--sans); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--smoke); background: transparent; border: 1px solid rgba(184,153,104,0.22);
  border-radius: 999px; padding: 7px 14px; cursor: pointer;
  transition: color var(--dur-base) var(--ease), border-color var(--dur-base) var(--ease), background var(--dur-base) var(--ease), box-shadow var(--dur-base) var(--ease);
}
.mode-chip:hover { color: var(--ivory); border-color: var(--brass); background: transparent; box-shadow: none; }
.mode-chip.on { color: var(--graphite); background: var(--hue); border-color: var(--hue); box-shadow: 0 0 18px -6px var(--hue); }
.mode-chip.on:hover { color: var(--graphite); background: var(--hue); }
.mode-chip.locked { opacity: 0.7; }
/* nowrap is the whole point: the ladder is one object, so the bar breaks
   between Nightly and the ladder or not at all — never 4 chips and an orphan. */
.mode-tiers { display: flex; align-items: center; flex-wrap: nowrap; gap: 6px; }
/* Twice the inner gap, which is the proximity ratio that reads as a group
   boundary. Collapses to nothing useful when the ladder wraps to its own line,
   and that is fine — the line break is a stronger boundary than any margin. */
.mode-bar > .mode-tiers { margin-left: 6px; }

/* The tier/par label beside the score. All five games mark it up; only Zenith,
   Halo and Serpens defined it, each in their own <style>, so on Lumen and
   Meridian it rendered at bare body defaults next to a 26px serif score. Here
   so every game gets it; the three inline copies still win where they exist,
   because a document <style> comes after this file. */
.par-h {
  font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--brass); font-variant-numeric: tabular-nums;
}

/* --- game switcher in the masthead ------------------------------------- */
.ql-switch { display: flex; gap: 7px; align-items: center; }
.ql-switch-dot {
  width: 25px; height: 25px; display: grid; place-items: center; border-radius: 50%;
  font-family: var(--sans); font-size: 15px; line-height: 1; text-decoration: none;
  color: var(--smoke); border: 1px solid transparent;
  transition: color var(--dur-base) var(--ease), border-color var(--dur-base) var(--ease), text-shadow var(--dur-base) var(--ease);
}
.ql-switch-dot:hover { color: var(--hue, var(--ivory)); }
.ql-switch-dot.here {
  color: var(--hue, var(--brass));
  border-color: color-mix(in srgb, var(--hue) 45%, transparent);
  text-shadow: 0 0 12px color-mix(in srgb, var(--hue) 70%, transparent);
}

/* --- next-game link on the result sheet -------------------------------- */
/* THE TWO WAYS OUT OF A FINISHED GAME WERE HAIRLINES SIDE BY SIDE. "Next —
   Meridian →" and "View your sky →" are different destinations, and they sat on
   one baseline 3.7px apart at 15.5px and 13.6px tall — each about a third of the
   44px floor, so a thumb landing between them was a coin toss. Measured on the
   Lumen win sheet at 390: next 119.7x15.5, sky 97.8x13.6, gap 3.7.

   Stacked, each in its own box that clears the floor. `width: fit-content` keeps
   the box on the words rather than the full sheet, so the two targets stay
   separated by their own edges and not just by a gap. The hover underline moves
   off border-bottom onto text-decoration for the same reason padding was needed:
   a border on a padded box draws 15px under the word instead of beneath it. */
.sheet-nextgame, .sheet-sky {
  /* ⚠️ min-height, NOT padding arithmetic. This was `padding: 16px` chosen
     because 15px measured 43.6 — 0.4px under the floor — and that number is a
     property of the FONT, not of the rule: swapping Public Sans for Archivo on
     17 Aug moved the line box and put it back at 43.6. A touch target should
     not be one type change away from failing, so the floor is declared. */
  display: flex; align-items: center; justify-content: center;
  /* 46, not 44, and the 2px is not padding-by-feel. At dpr 3 the box snaps to
     device pixels and a 44px min-height resolves to 43.7 CSS px — measured on
     the emulated iPhone Pro the sweep uses, and true on the real one. A floor
     sitting exactly on the limit fails by rounding; this one has room. */
  min-height: 46px; width: fit-content; margin-inline: auto;
  padding: 10px 12px; text-decoration: none;
}
.sheet-nextgame {
  margin-top: 2px; font-family: var(--sans); font-size: 12.5px;
  letter-spacing: 0.05em; color: var(--hue);
  transition: text-decoration-color var(--dur-mid) var(--ease);
}
.sheet-nextgame:hover {
  text-decoration: underline; text-underline-offset: 4px;
  text-decoration-color: color-mix(in srgb, var(--hue) 55%, transparent);
}

/* --- how-to: animated demo + "don't show again" ------------------------ */
.howto-demo {
  margin: 6px auto 14px; width: 100%; max-width: 240px; border-radius: 12px; overflow: hidden;
  background: rgba(255,255,255,0.02); border: 1px solid rgba(184,153,104,0.14);
}
.howto-demo svg, .howto-demo canvas { display: block; width: 100%; height: auto; }
.howto-dsa {
  display: flex; align-items: center; gap: 8px; justify-content: center; margin: 2px 0 18px;
  font-size: 12px; letter-spacing: 0.02em; color: var(--smoke); cursor: pointer; user-select: none;
}
.howto-dsa input { width: 15px; height: 15px; accent-color: var(--hue); cursor: pointer; }
/* the label is the target — the box inside it stays small on purpose */
.howto-dsa { min-height: 24px; }

/* how-to list inside the info card */
.howto-list { text-align: left; margin: 4px 0 22px; padding: 0; list-style: none; counter-reset: step; }
.howto-list li {
  position: relative; padding: 8px 0 8px 34px; font-size: 13.5px; line-height: 1.45;
  color: var(--ivory); opacity: 0.9; border-top: 1px solid rgba(255,255,255,0.05);
}
.howto-list li:first-child { border-top: none; }
.howto-list li::before {
  counter-increment: step; content: counter(step);
  position: absolute; left: 0; top: 7px; width: 22px; height: 22px; border-radius: 50%;
  display: grid; place-items: center; font-size: 11px; font-variant-numeric: tabular-nums;
  color: var(--hue); border: 1px solid color-mix(in srgb, var(--hue) 50%, transparent);
}

/* --- entrance motion --------------------------------------------------- */
@keyframes rise-in {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: translateY(0); }
}
.rise { animation: rise-in 0.6s var(--ease-out) both; }
.rise-1 { animation-delay: 0.05s; }
.rise-2 { animation-delay: 0.13s; }
.rise-3 { animation-delay: 0.22s; }

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
}

/* --- hub cards --------------------------------------------------------- */
.cards { width: 100%; max-width: 480px; display: grid; gap: 12px; }
.card {
  position: relative;
  display: flex; align-items: center; gap: 16px;
  padding: 18px 20px;
  background:
    radial-gradient(140% 120% at 0% 50%, rgba(255,255,255,0.03), transparent 60%),
    linear-gradient(180deg, rgba(23,49,60,0.6), rgba(16,34,43,0.7));
  border: 1px solid rgba(184,153,104,0.18);
  border-radius: 10px;
  text-decoration: none;
  color: var(--ivory);
  overflow: hidden;
  transition: border-color 0.35s var(--ease), transform 0.35s var(--ease-out), box-shadow 0.35s var(--ease);
}
/* the game's hue as a soft light bleeding in from the left edge */
.card::before {
  content: "";
  position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
  background: var(--hue, var(--brass));
  box-shadow: 0 0 18px 1px var(--hue, var(--brass));
  opacity: 0.75;
}
.card:hover { border-color: rgba(184,153,104,0.5); transform: translateY(-2px); box-shadow: 0 16px 36px -22px rgba(0,0,0,0.8); }
.card .glyph {
  flex: none; width: 44px; height: 44px; display: grid; place-items: center;
  font-family: var(--sans); font-weight: 600; font-size: 34px; line-height: 1;
  color: var(--hue, var(--brass));
  text-shadow: 0 0 20px color-mix(in srgb, var(--hue, var(--brass)) 75%, transparent),
               0 0 6px color-mix(in srgb, var(--hue, var(--brass)) 55%, transparent);
}
.card .cn { display: block; font-family: var(--sans); font-size: 24px; letter-spacing: 0.01em; line-height: 1.15; }
.card .cd { display: block; font-size: 12.5px; color: var(--smoke); letter-spacing: 0.01em; margin-top: 3px; line-height: 1.4; }
.card .cv { margin-left: auto; align-self: center; font-size: 10.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--brass); white-space: nowrap; }

/* --- daily framing (date + puzzle number, NYT-style) ------------------- */
.daily {
  font-family: var(--sans); font-size: 10.5px; letter-spacing: 0.22em; text-transform: uppercase;
  color: var(--smoke); text-align: center; margin-bottom: 22px;
}
.daily b { color: var(--brass); font-weight: 500; }
/* Only rendered when a game is replaying a past night (QLSheet.archive). It
   takes the same brass as the number it qualifies rather than a colour of its
   own — this is a label on the date, not a warning about it. */
.daily-archive { color: var(--brass); }

/* --- the result sheet (completion moment) ------------------------------ */
.scrim {
  position: fixed; inset: 0; z-index: 40;
  background: rgba(9,19,24,0.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  display: flex; justify-content: center; padding: 20px;
  /* CENTRING A TALLER-THAN-VIEWPORT BOX PUTS ITS TOP OUT OF REACH. This was
     `align-items: center` with no overflow, and a flex item centred in a
     container it does not fit overflows EQUALLY in both directions — the top
     goes above the container's start edge, and overflow past a start edge
     cannot be scrolled to. The scrim is position:fixed, so scrolling the page
     does not help either.

     Measured on the how-to card at first visit: Lumen trapped at every phone
     height up to 759px (an iPhone 15 with the address bar showing), Serpens at
     667 (iPhone SE / 8), and at 553 it took Halo and Sextant too. Play sat
     below the fold, the × above it, the backdrop does not dismiss and there is
     no Escape key on a phone. A first-time visitor could not start the game.

     `margin: auto` on the item does what align-items was for — it absorbs
     positive free space, so a card that fits is still centred — but when the
     free space is negative the auto margins resolve to zero instead, leaving
     the top at the start edge where `overflow-y: auto` can reach it. */
  overflow-y: auto;
  /* VISIBILITY, NOT JUST OPACITY. A closed scrim was `opacity: 0` +
     `pointer-events: none`, which hides it from a mouse and from the eye and
     from nothing else: its controls stayed in the tab order and in the
     accessibility tree. Dismiss the how-to card and the first three Tab presses
     on the board went to a close button, a "don't show this again" checkbox and
     a Play button that were not on the screen.

     `visibility: hidden` takes descendants out of both, and unlike
     `display: none` it is animatable — so the flip is delayed by the length of
     the fade on the way out and immediate on the way in, and the transition
     still plays in both directions. */
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity var(--dur-slow) var(--ease), visibility 0s linear var(--dur-slow);
}
.scrim.open { opacity: 1; visibility: visible; pointer-events: auto;
              transition: opacity var(--dur-slow) var(--ease), visibility 0s; }
.sheet {
  width: 100%; max-width: 360px; position: relative;
  margin: auto;   /* see .scrim — centres when it fits, stays reachable when it does not */
  background:
    radial-gradient(120% 90% at 50% 0%, color-mix(in srgb, var(--hue) 14%, transparent), transparent 60%),
    linear-gradient(180deg, #1a3743, #12252e);
  border: 1px solid color-mix(in srgb, var(--hue) 34%, rgba(184,153,104,0.2));
  border-radius: 18px; padding: 30px 26px 26px;
  box-shadow: 0 30px 70px -30px rgba(0,0,0,0.85), 0 0 60px -30px var(--hue);
  text-align: center;
  transform: translateY(24px) scale(0.97); opacity: 0;
  transition: transform 0.5s var(--ease-out), opacity 0.5s var(--ease-out);
}
.scrim.open .sheet { transform: translateY(0) scale(1); opacity: 1; }
.sheet-x {
  position: absolute; top: 12px; right: 14px; width: 30px; height: 30px; padding: 0;
  border: none; background: none; color: var(--smoke); font-size: 22px; line-height: 1; cursor: pointer;
}
.sheet-x:hover { background: none; color: var(--ivory); box-shadow: none; }
.sheet-eyebrow { font-size: 10px; letter-spacing: 0.24em; text-transform: uppercase; color: var(--smoke); margin-bottom: 10px; }
.sheet-verdict { font-family: var(--sans); font-size: 30px; line-height: 1.12; color: var(--hue); margin-bottom: 6px; }
.sheet-sub { font-size: 13px; color: var(--ivory); opacity: 0.85; margin-bottom: 20px; letter-spacing: 0.01em; }
.sheet-grid { display: flex; flex-direction: column; align-items: center; gap: 5px; margin-bottom: 22px; }
.sheet-row { display: flex; gap: 5px; flex-wrap: wrap; justify-content: center; max-width: 100%; }
.sheet-cell { width: 22px; height: 22px; border-radius: 5px; background: rgba(255,255,255,0.05); display:grid; place-items:center; font-size: 12px; }
.sheet-stats { display: flex; justify-content: center; gap: 26px; margin-bottom: 20px; }
.sheet-stat { display: flex; flex-direction: column; gap: 3px; }
.sheet-stat .v { font-family: var(--sans); font-size: 26px; color: var(--ivory); line-height: 1; font-variant-numeric: tabular-nums; }
.sheet-stat .l { font-size: 9.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--smoke); }
.sheet-next { font-size: 11px; letter-spacing: 0.08em; color: var(--smoke); margin-bottom: 18px; }
.sheet-next b { color: var(--ivory); font-weight: 500; font-variant-numeric: tabular-nums; letter-spacing: 0.04em; }
.sheet-actions { display: flex; gap: 10px; justify-content: center; }
.sheet-actions .btn { flex: 1; max-width: 180px; }
/* streak + freshly-earned badge line under the stats */
.sheet-note { margin: -6px 0 16px; display: flex; flex-direction: column; gap: 6px; align-items: center; }
.sk-streak { font-size: 12px; letter-spacing: 0.06em; color: var(--brass); }
.sk-badge {
  display: inline-block; margin: 0 3px; font-size: 11px; letter-spacing: 0.04em; color: var(--ivory);
  border: 1px solid color-mix(in srgb, var(--hue) 45%, transparent); border-radius: 999px;
  padding: 3px 11px; background: color-mix(in srgb, var(--hue) 12%, transparent);
  animation: badge-pop 0.6s var(--ease-out) both;
}
@keyframes badge-pop { 0%{transform:scale(0.6);opacity:0} 60%{transform:scale(1.08)} 100%{transform:scale(1);opacity:1} }
/* the quiet link down to the collection */
.sheet-sky {
  font-size: 11.5px; letter-spacing: 0.08em;
  color: var(--smoke); transition: color var(--dur-mid) var(--ease);
}
.sheet-sky:hover { color: var(--brass); }

/* --- the collection: "your sky" ---------------------------------------- */
.wide { max-width: 620px; }
.coll-head { text-align: center; margin-bottom: 24px; }
.coll-head h1 { font-family: var(--sans); font-weight: 500; font-size: 40px; line-height: 1; margin-bottom: 8px; }
.coll-head .k { font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--smoke); }

/* the headline streak — two big numbers */
/* wrap for the same reason .streaks-empty already does, sixty lines down: three
   .streak-fig at min-width 96px plus two 30px gaps is 348px, so at 320 the row
   sat at left -14 and overflowed both edges. The empty-profile variant was
   given flex-wrap when it was built; the populated one never was. */
.streaks { display: flex; justify-content: center; gap: 30px; margin-bottom: 30px; flex-wrap: wrap; }
.streak-fig { text-align: center; min-width: 96px; padding: 16px 8px; border-radius: 12px;
  background: linear-gradient(180deg, rgba(23,49,60,0.5), rgba(16,34,43,0.6)); border: 1px solid rgba(184,153,104,0.16); }
.streak-fig .v { font-family: var(--sans); font-size: 44px; line-height: 1; color: var(--brass); font-variant-numeric: tabular-nums; }
.streak-fig .l { display: block; margin-top: 6px; font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--smoke); }

.coll-section-k { font-size: 10.5px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--brass); text-align: center; margin: 6px 0 16px; }

/* the constellation calendar — a grid of nights, filled stars = nights played */
/* `width: 100%` is load-bearing, not tidying. `main.play` is a centring flex
   column, so a flex item sizes to fit-content unless told otherwise — and
   `repeat(auto-fill, 16px)` against a fit-content width resolves to exactly one
   column. The calendar was rendering as a single vertical line of nights. */
.sky-cal { display: grid; grid-template-columns: repeat(auto-fill, 16px); gap: 6px; justify-content: center; margin-bottom: 8px; width: 100%; }
.sky-night { width: 16px; height: 16px; border-radius: 50%; border: 1px solid rgba(123,128,136,0.3); position: relative; }
.sky-night.lit { border-color: transparent; background: radial-gradient(circle at 50% 40%, #fff 0 1px, var(--brass) 2px, color-mix(in srgb, var(--brass) 40%, transparent) 60%, transparent 75%);
  box-shadow: 0 0 8px -1px var(--brass); }
.sky-night.today { outline: 1px solid var(--brass); outline-offset: 2px; }
.sky-legend { text-align: center; font-size: 11px; color: var(--smoke); letter-spacing: 0.04em; margin-bottom: 30px; }

/* per-game stat cards */
/* `width: 100%` for the same reason .sky-cal has it, twelve lines up. Measured
   on /sky/ before this line existed: .gstats was 397px inside a 620px parent
   and resolved to ONE column for five cards, .badges 191px and one column for
   eleven. auto-fit against a fit-content width has nothing to fit against. */
.gstats { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 14px; margin-bottom: 30px; width: 100%; }
.gstat {
  padding: 18px; border-radius: 12px; position: relative; overflow: hidden;
  background: linear-gradient(180deg, rgba(23,49,60,0.55), rgba(16,34,43,0.65)); border: 1px solid rgba(184,153,104,0.16);
}
.gstat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--hue); box-shadow: 0 0 16px 1px var(--hue); opacity: 0.8; }
.gstat-top { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; }
.gstat-name { font-family: var(--sans); font-size: 22px; }
.gstat-name .lead { color: var(--hue); }
.gstat-tag { font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--smoke); }
.gstat-row { display: flex; gap: 20px; margin-bottom: 12px; }
.gstat-fig .v { font-family: var(--sans); font-size: 24px; color: var(--ivory); font-variant-numeric: tabular-nums; line-height: 1; }
.gstat-fig .l { font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--smoke); }
/* distribution histogram */
.dist { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
.dist-row { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--smoke); }
.dist-row .bk { width: 40px; text-align: right; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
.dist-bar { height: 15px; border-radius: 3px; background: color-mix(in srgb, var(--hue) 55%, transparent); min-width: 3px;
  display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; color: var(--graphite); font-size: 10px; font-variant-numeric: tabular-nums; }
.dist-bar.hot { background: var(--hue); }
.gstat-empty { font-size: 12px; color: var(--smoke); font-style: italic; opacity: 0.7; padding: 6px 0; }
/* an unplayed card carries the game's own invitation instead of "Not yet played."
   It reads at full weight, not the dimmed italic an absence gets — there is
   nothing missing here, the visitor simply hasn't arrived yet. */
.gstat-invite { font-size: 12.5px; line-height: 1.5; color: var(--smoke); padding: 6px 0 0; text-wrap: pretty; }
/* with the figure row gone there is nothing between the title and the invitation,
   so the card recovers the space the figures held rather than collapsing shorter
   than its played siblings in the same grid row. */
.gstat-fresh .gstat-top { margin-bottom: 4px; }
.gstat-fresh { display: flex; flex-direction: column; }
.gstat-fresh .gstat-go { margin-top: auto; padding-top: 10px; }
.gstat-go { display: inline-block; margin-top: 10px; font-size: 11px; letter-spacing: 0.1em;
  text-transform: uppercase; color: var(--hue); text-decoration: none; border-bottom: 1px solid transparent; transition: border-color var(--dur-mid) var(--ease); }
.gstat-go:hover { border-bottom-color: var(--hue); }

/* an empty profile gets one true figure and a way in, never a row of zeros.
   Wraps rather than squeezing: at 375px the pair sat side by side and broke
   "Play tonight's sky" across two lines inside the button. */
.streaks-empty { align-items: center; gap: 22px; flex-wrap: wrap; }
.streaks-empty .streak-fig .l { text-wrap: balance; }

/* badges shelf */
.badges { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; margin-bottom: 30px; width: 100%; }
.badge {
  text-align: center; padding: 16px 10px; border-radius: 12px;
  background: linear-gradient(180deg, rgba(23,49,60,0.4), rgba(16,34,43,0.5)); border: 1px solid rgba(184,153,104,0.12);
  transition: border-color var(--dur-mid) var(--ease), transform var(--dur-mid) var(--ease-out);
}
.badge.earned { border-color: rgba(184,153,104,0.4); }
.badge.locked { opacity: 0.5; }
.badge .bg { font-family: var(--sans); font-size: 26px; width: 46px; height: 46px; margin: 0 auto 8px; border-radius: 50%;
  display: grid; place-items: center; color: var(--brass); border: 1px solid color-mix(in srgb, var(--brass) 40%, transparent); }
.badge.earned .bg { color: var(--graphite); background: var(--brass); box-shadow: 0 0 20px -4px var(--brass); border-color: transparent; }
.badge.locked .bg { color: var(--smoke); border-color: rgba(123,128,136,0.3); }
.badge .bn { font-size: 12px; letter-spacing: 0.02em; color: var(--ivory); margin-bottom: 3px; }
.badge .bd { font-size: 10.5px; color: var(--smoke); line-height: 1.35; }

/* export / import row */
.vault-row { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-top: 6px; }
.vault-note { text-align: center; font-size: 11px; color: var(--smoke); margin-top: 14px; line-height: 1.5; opacity: 0.8; }

/* Short viewports — and a laptop browser IS one: a 1440x900 Mac gives about
   717px of innerHeight once the browser's own chrome is gone, not 900. At that
   height the stack above the board (240px) plus the board on its --board-floor
   overran the viewport by ~16px and the page scrolled DURING PLAY, which §1.1
   set out to prevent. Tightening the vertical rhythm buys ~34px and clears it.
   It does not buy a bigger board: at 717px the no-scroll ceiling is a 378px
   board, so recovering Halo's old 440px canvas needs the Phase 2 layout, not a
   larger number here. */
@media (max-height: 820px) {
  body        { padding-top: 14px; }
  .ql-mast    { padding-bottom: 10px; }
  .play       { padding: 2px 0; }
  .game-title { font-size: 30px; margin-bottom: 5px; }
  .game-sub   { margin-bottom: 14px; }
  .daily      { margin-bottom: 14px; }
  .mode-bar   { margin-bottom: 14px; }
}

/* ---------------------------------------------------------------------------
   WIDE SCREENS: the furniture moves beside the board, not above it.

   Decision (Matt, 18 Jul): fix the layout before growing the grids. The inverse
   audit says why it has to be a rail rather than tighter spacing — a 12x12 Lumen
   needs a 564px board, which on a 717px laptop viewport leaves ~150px for ALL
   furniture, and a masthead + title + subtitle + daily line + mode bar + footer
   cannot stack inside that. Beside the board they cost zero vertical.

   The grid places the four furniture rows in column 1 and the board spanning
   column 2, so no game's HTML changes — the source order already suits it.
   --------------------------------------------------------------------------- */
@media (min-width: 1040px) and (min-height: 560px) {
  /* With the furniture beside the board, the only vertical cost left is the
     masthead, the footer and the board's own HUD/legend. Swept on the live page
     at 1280x717: the board reaches 460px with no page scroll at all. Lumen and
     Meridian go 360 -> 460 on a laptop and 457 -> 640 on a desktop; Halo sets its
     own smaller allowance in halo.html because it carries less in-board furniture.
     The iPad is deliberately NOT in here: at 768px wide a 300px rail would leave
     it a 424px board, worse than the 494px it gets stacked. */
  :root {
    --board-chrome: 257px;
    /* ONE VERTICAL LINE GOVERNS THE RIGHT EDGE, and it is derived, not guessed.
       The furniture was capped at a flat 900px while the board sized itself from
       --board, so nothing lined up: measured at 1440, the masthead, rule and
       footer all ended at 1170 while the board ended at 1112 (Zenith), 1147
       (Sextant), 1297 (Lumen, Meridian) or 1357 (Halo, Serpens). Two games sat
       inside the page and four hung off it.

       The composition is the rail plus the gap plus whatever the board actually
       is, so that is what every element now reads. Per game, automatically,
       because --board is already per game. */
    --rail: 340px;
    --rail-gap: 44px;
    --comp: calc(var(--rail) + var(--rail-gap) + var(--board));
  }

  .ql-mast, .ql-rule, .foot { max-width: var(--comp); }

  .play {
    max-width: var(--comp);
    /* shrink to content and centre in the leftover column space — with flex:1
       the grid filled the viewport and its 1fr row pushed everything to the top,
       leaving the board stranded under the masthead with dead space below */
    flex: 0 1 auto;
    margin-block: auto;
    display: grid;
    grid-template-columns: var(--rail) minmax(0, auto);
    /* a fifth row so the rail has somewhere to put the rules */
    grid-template-rows: auto auto auto auto 1fr;
    grid-auto-rows: auto;
    align-items: start;
    justify-content: center;
    column-gap: var(--rail-gap);
    padding: 8px 0;
    text-align: left;
  }
  /* column 1 — the rail, top-aligned with the board */
  .game-title { grid-column: 1; grid-row: 1; font-size: 44px; text-align: left; margin-bottom: 10px; }
  /* text-wrap: balance stops the subtitle orphaning its last word in the rail */
  .game-sub   { grid-column: 1; grid-row: 2; text-align: left; margin-bottom: 22px; text-wrap: balance; }
  .daily      { grid-column: 1; grid-row: 3; text-align: left; margin-bottom: 20px; }
  .mode-bar   { grid-column: 1; grid-row: 4; justify-content: flex-start; margin-bottom: 0; align-self: start; }
  /* column 2 — the board, spanning the four rail rows (1/5, not 1/-1, so the
     catch-all below still has rows of its own to land in) */
  .board      { grid-column: 2; grid-row: 1 / 6; }
  /* Anything else a game puts in .play belongs with the board, not the rail.
     Zenith has a par line and a Rise button; without this they auto-placed into
     column 1 and ended up stranded at the bottom-left of the page. */
  .play > :not(.game-title):not(.game-sub):not(.daily):not(.mode-bar):not(.board):not(.rail-how) {
    grid-column: 2;
    justify-self: center;
  }

  /* NO BOARD, NO RAIL. The rail exists to sit a game's furniture beside its
     board. Two pages have no board — the hub (/play/) and the collection
     (/sky/) — and they were inheriting the grid anyway, so every element
     auto-placed into column 2 and the whole page rendered hard against an
     empty 340px column. Measured at 1440: the hub's cards centred on 912
     against a true centre of 720, i.e. (340 rail + 44 gap) / 2 = 192px right,
     while the masthead above them was correctly centred — which is exactly
     what makes it read as "off centre" rather than as a margin. Matt caught it
     on the hub at laptop width; /sky/ had the identical defect on all eleven
     of its blocks and nobody had looked. The four game pages are untouched. */
  .play.no-rail {
    display: flex;
    flex-direction: column;
    align-items: center;
  }
  .play.no-rail.wide { max-width: 620px; }
}

/* Embeds are iframes with every piece of furniture display:none, so the rail
   would leave an empty 300px column beside the board. Keep them centred. */
body.ql-embed .play {
  display: flex !important;
  max-width: none !important;
  column-gap: 0 !important;
}

/* ---------------------------------------------------------------------------
   PHONES ARE THE SHORTEST VIEWPORT, and the 820px tier above was tuned for a
   717px laptop rather than a 667px handset. Found by the §Phase-3 375px pass,
   and it is a real defect rather than a cosmetic one:

     iPhone SE, before        Lumen      Meridian
       board bottom             749–780      758
       the game's own message   684–697      683     <- off a 667px screen

   So you tap a cell, get a reading, and the sentence explaining it is below the
   fold. That is exactly the defect §1.5(c) fixed in Zenith — anything the game
   says to you has to be visible at the moment it says it — and Lumen and
   Meridian still had it. It predates this session: on a phone the board is
   WIDTH-bound at 352px, so a square grid plus 245px of furniture above it
   cannot fit in 667 however the height term is tuned.

   Measured: this reclaims 33px above the grid and 44 below it. The legend is
   the one thing that can go without losing information — it is instructional,
   read once, and repeated verbatim in the how-to card, which is still one tap
   away on the ⓘ button.
   --------------------------------------------------------------------------- */
@media (max-height: 700px) {
  body        { padding-top: 10px; }
  .ql-mast    { padding-bottom: 6px; }
  .game-title { font-size: 27px; }
  .game-sub   { margin-bottom: 8px; }
  .daily      { margin-bottom: 8px; }
  .mode-bar   { margin-bottom: 8px; }
  .lhud, .hud { margin-bottom: 8px; }
  .dots       { margin-bottom: 10px; }
  .msg        { margin-top: 10px; }
  .legend     { display: none; }
}

/* --- phone: the touch floor, and the toolbar that eats the bottom -------
   Every control below was under the 44px platform minimum on a phone, and two
   of them were also underneath Safari's bottom toolbar. Found by
   `_tools/phone_gate.py`, which loads all six games at 375/390/430 rather than
   the one page someone happened to open.

   THE SWITCHER IS THE INTERESTING ONE. Six dots at 25px with a 7px gap is a
   32px pitch, so simply padding each dot out to 44px would make neighbouring
   hit areas OVERLAP by 12px — you would aim at Lumen and land on Meridian. A
   hit area cannot exceed the pitch, so the pitch has to grow. The dots tile
   edge to edge at 44px with no gap, and the visible ring is drawn by an
   absolutely-positioned ::before that stays at 30px — so the touch target
   triples while the mark on screen barely moves. */
/* THE TOUCH FLOOR IS A PROPERTY OF THE FINGER, NOT OF THE VIEWPORT.

   These rules were keyed to `max-width: 620px`, and the 44px floor they enforce
   is argued carefully everywhere else in this codebase — lumen-logic.js picks
   the hard tier BECAUSE 7x7 clears 45px on a 375px phone. But width is a proxy
   for touch, and it is the wrong one: README makes iPad an explicit target, and
   iPad is 768 portrait / 1024 landscape, so every one of these fixes switched
   itself off on the device it was written for.

   Measured at 768x1024 with a coarse pointer, before this change: 18 targets
   under the floor on Lumen, Meridian, Halo and Serpens, 20 on Zenith, 34 on
   Sextant. The switcher was the worst of them at 25x25 — six dots, the only way
   between games — and it is the one the phone block already solved by growing
   the pitch. It simply never ran here.

   So the condition is now "or the pointer is coarse". Phones keep exactly what
   they had; anything a finger drives gets the same floor at any width. Layout
   that is genuinely about narrowness — the mode bar turning into a scroller,
   the board trading frame for grid — stays on max-width below. */
@media (max-width: 620px), (pointer: coarse) {
  .ql-switch { gap: 0; }
  .ql-switch-dot {
    width: 44px; height: 44px; border-radius: 0; border-color: transparent;
    position: relative;
  }
  /* absolute, so it is out of flow and never becomes a grid item — the glyph
     stays centred by the dot's own `place-items: center` */
  .ql-switch-dot::before {
    content: ""; position: absolute; left: 50%; top: 50%;
    width: 30px; height: 30px; transform: translate(-50%, -50%);
    border-radius: 50%; border: 1px solid transparent;
    transition: border-color var(--dur-base) var(--ease);
  }
  .ql-switch-dot.here { border-color: transparent; }
  .ql-switch-dot.here::before {
    border-color: color-mix(in srgb, var(--hue) 45%, transparent);
  }

  /* the wordmark and the only route back to the hub were 18px and 15px tall */
  .ql-wordmark, .ql-back {
    display: inline-flex; align-items: center; min-height: 44px;
  }

  /* Both float at bottom:12px, which on iOS is behind the browser toolbar —
     the mute toggle and the how-to button were unreachable on a phone without
     scrolling the toolbar away first. env() alone is not the fix: Safari
     reports 0 for safe-area-inset-bottom when the toolbar is HIDDEN, so max()
     is what keeps them clear in both states. */
  .ql-mute, .ql-info {
    width: 44px; height: 44px;
    bottom: max(16px, calc(env(safe-area-inset-bottom) + 8px));
  }
  .ql-mute { left: max(12px, env(safe-area-inset-left)); }
  .ql-info { right: max(12px, env(safe-area-inset-right)); }

}

@media (max-width: 620px) {
  /* THE DIFFICULTY BAR BECOMES A SCROLLER, NOT A TALLER STACK.
     Five chips at 28px wrapped to two rows on every phone: 64px of chrome
     above the board, and every chip 16px under the touch floor. Padding them
     to 44px in place would have made it 96px — fixing the target by spending
     the board.

     One row that scrolls sideways is the standard answer and it is smaller
     than what it replaces: 44px total, every chip over the floor, and the
     selected one scrolled into view. overscroll-behavior-x keeps a sideways
     flick from turning into a page swipe. */
  .mode-bar {
    flex-wrap: nowrap; justify-content: flex-start;
    overflow-x: auto; overscroll-behavior-x: contain;
    scroll-snap-type: x proximity; scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
    margin-bottom: 16px; padding-bottom: 2px;
    /* A nowrap flex container sizes to its CONTENT, and body centres it — so
       without this the bar came out 423px wide inside a 375px viewport, hung
       24px off both edges and made the whole DOCUMENT scroll sideways. The
       overflow has to be the bar's, not the page's. Caught by this file's own
       gate on the run after the scroller landed. */
    width: 100%; max-width: 100%; min-width: 0;
  }
  /* the tier chips live in a nested nowrap flex box, which would refuse to
     shrink below its content and re-inflate the bar from the inside */
  .mode-tiers { min-width: 0; flex: 0 0 auto; }
  .mode-bar::-webkit-scrollbar { display: none; }
}

@media (max-width: 620px), (pointer: coarse) {
  /* ⚠️ ALL FIVE FIT, AND THAT IS THE POINT — the fade below is the fallback.
     Six independent testers reported EXPERT as clipped, and on an iPhone 17 Pro
     it measured 34px of its 83 at rest: a target under the 44px floor that you
     have to already know is there to reach. Measured at 390: the bar wanted 423
     against 358 of room, chips [88,67,85,70,83] plus four 6px gaps.
     16px -> 10px of side padding gives back 12px a chip, 60px in all, and the
     row lands inside its box with every label whole. The type is untouched —
     11px, 0.1em, uppercase — because shrinking the letters to win 60px would
     cost more than the padding did. min-height keeps the 44px tap floor; the
     narrowest chip is still ~55px wide.
     A second row was rejected on record for costing 64px of chrome, and is
     still rejected. */
  .mode-chip {
    flex: 0 0 auto; scroll-snap-align: start;
    min-height: 44px; padding: 12px 9px;
  }

  /* dismissing a card is the one action every card has, and it was 30px */
  .sheet-x { width: 44px; height: 44px; top: 6px; right: 8px; }

  /* "don't show again" was a 15px checkbox — the smallest target anywhere on
     the site. The box stays 15px because a 44px checkbox looks like a bug; the
     LABEL wraps it, so the whole 44px row is the target. */
  .howto-dsa { min-height: 44px; }
  .howto-dsa input { width: 20px; height: 20px; }

  /* the costed hint and the reset were 36 and 39 */
  .hint, .btn, button.btn-hue { min-height: 44px; }

  /* /sky — the route back into each game from your record was a 24px line of
     11px caps, which is the smallest target on the site and also the only one
     that matters on that page */
  .gstat-go, .streaks-empty a, .sky-actions a, .sky-actions button {
    display: inline-flex; align-items: center; min-height: 44px;
  }

}

@media (max-width: 620px) {
  /* THE BOARD'S OWN PADDING IS THE CHEAPEST 24px ON THE PAGE.
     Meridian's 7x7 grid came out at 39px a cell — under the touch floor on the
     one game you play by dragging THROUGH cells. The arithmetic: a 352px board
     less 40px of padding is 312px of grid, and 7 cells plus 6 six-pixel gaps
     divides that into 39.4.

     Growing the board is not available (the board already fills the page
     column), so the 24px comes back off the padding, which is decoration, and
     2px off each gap. 336px of grid at a 4px gap is 44.6px a cell. Desktop
     keeps its 20px — this is a phone-only trade of frame for board. */
  :root { --board-pad: 8px; }
}

/* --- installed app, and the floating controls that stood on the board -----
   Phase 0 (PHONE_FRICTION_LOG.md) played the games on a real iPhone and found
   one defect wearing several hats. Measured across all six at 402x780:

     zenith  655  Rise 602    —
     lumen   779  Play 827    mute+info over board, PRIMARY BELOW FOLD
     meridian789  Reset 736   mute+info over board
     halo    731              mute+info over board
     serpens 728              mute+info over board
     sextant 863  Call it 649 mute+info over board

   FIVE OF SIX PAINTED TWO FLOATING BUTTONS ON TOP OF THE BOARD, and Zenith
   joined them by rung 3 once its ladder grew. On Meridian that is not cosmetic:
   you play by dragging THROUGH cells, and the mute button sat on row 7 col 1,
   which held a ringed waystar that night.

   The buttons are `position: fixed`, so nothing in normal flow knows they are
   there. The fix is to make the page know: reserve the strip they occupy, so a
   board can never grow into it. */
@media (max-width: 620px) {
  body { padding-bottom: calc(76px + env(safe-area-inset-bottom)); }
}

/* Standalone has no browser toolbar, which is what made the bottom of the
   screen tap-hostile — a tap on Zenith's RISE did nothing until a scroll
   collapsed the chrome. With the toolbar gone the only thing to clear is the
   home indicator, so the reserve shrinks and the board gets the difference. */
@media (display-mode: standalone), (display-mode: minimal-ui) {
  .ql-mute, .ql-info { bottom: max(12px, env(safe-area-inset-bottom)); }
}
html.ql-installed body { padding-top: max(22px, env(safe-area-inset-top)); }

/* --- the install invitation ----------------------------------------------
   Shown once, and only after a night has been finished — see ql-pwa.js for why.
   It sits above the two floating buttons and clears the home indicator. */
.ql-install {
  position: fixed; left: 12px; right: 12px; z-index: 60;
  bottom: calc(12px + env(safe-area-inset-bottom));
  padding: 16px 18px 14px;
  background: rgba(18,37,46,0.94); backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1px solid rgba(184,153,104,0.3); border-radius: 10px;
  box-shadow: 0 18px 40px -18px rgba(0,0,0,0.8);
  transform: translateY(14px); opacity: 0;
  transition: transform var(--dur-slow) var(--ease-out), opacity var(--dur-slow) var(--ease-out);
}
.ql-install.in { transform: none; opacity: 1; }
.ql-install p { font-size: 14.5px; line-height: 1.5; color: var(--ivory);
                margin-bottom: 12px; text-wrap: pretty; }
.ql-install-row { display: flex; gap: 10px; }
.ql-install button {
  flex: 1 1 auto; min-height: 44px; padding: 11px 16px; cursor: pointer;
  font-family: var(--sans); font-size: 14px; border-radius: 3px;
  background: transparent; border: 1px solid rgba(184,153,104,0.4);
  color: var(--smoke); transition: color var(--dur-base) var(--ease), border-color var(--dur-base) var(--ease);
}
.ql-install-yes { color: var(--brass); border-color: var(--brass); }
.ql-install button:hover { color: var(--ivory); border-color: var(--ivory); }
@media (prefers-reduced-motion: reduce) {
  .ql-install { transition: none; transform: none; opacity: 1; }
}

/* --- the utility tray ------------------------------------------------------
   The mute and how-to buttons live here now, in the masthead and in flow. They
   used to be `position: fixed` in the bottom corners, which put them ON the
   board in five of six games (measured 402x780) — and on Meridian over a cell
   holding a ringed waystar, in a game you play by dragging through cells.

   `margin-left: auto` pushes the pair to the right rail without needing the
   mast's `justify-content` to change, so the wordmark, switcher and back link
   keep the positions they had. */
.ql-tray { display: flex; align-items: center; gap: 4px; margin-left: auto; }
.ql-tray .ql-mute, .ql-tray .ql-info {
  position: static; inset: auto; z-index: auto;
  backdrop-filter: none; -webkit-backdrop-filter: none;
  background: transparent; border-color: rgba(184,153,104,0.22);
}
.ql-tray .ql-mute:hover, .ql-tray .ql-info:hover { background: transparent; }

/* With the buttons out of the corners there is nothing left to reserve room
   for, so the bottom strip added earlier comes back off — a phone gets those
   pixels for the board instead. env() still keeps the last row clear of the
   home indicator. */
@media (max-width: 620px) {
  /* ⚠️ THIS PADDING IS A SCROLL RANGE, NOT DECORATION.
     With viewport-fit=cover the page paints under the browser chrome, so a
     board that "fits" innerHeight can still have its last control behind the
     toolbar — and if the content fits, there is NO SCROLL RANGE, so neither the
     player nor scrollIntoView can move it. Sextant's word input sat in that
     trap: visible, hit-tested, deaf to a finger, and impossible to scroll up
     because the page had nowhere to scroll to.

     The padding creates the range. 120px is the dead band (~70) plus the
     toolbar it hides behind, with margin.

     ⚠️ AND IT WAS THEN CUT TO 20px, which is less than the dead band it was
     written to clear. Re-measured across all six at 390x745 (Safari with one
     bar) and 390x664 (both bars), against visualViewport.height and not
     innerHeight: with 20px there was not enough range to lift every per-turn
     control out of the reserve. Zenith at 664 is the clearest case — RISE and
     HINT ran to y=612 against a floor of 574, and the whole document could only
     scroll 26px, so 12px of the button stayed in the dead band with nowhere
     left to go. Meridian's RESET and Sextant's READ were the same shape.

     90px, because that is the reserve iOS actually takes (the scrim above uses
     80px measured between a tap that worked at 752 and one that did not at
     776, and the game shell has a toolbar to clear as well as the gesture
     band). It costs nothing anyone can see: it is scroll range below the
     footer, not space between anything.

     ⚠️ AND THE PADDING ALONE DOES NOTHING, which is why it had been quietly
     wrong for two sessions. `html, body { height: 100% }` at the top of this
     file pins body's BORDER box to the viewport, and `* { box-sizing:
     border-box }` puts the padding inside it — so on a page whose content
     overflows, the reserve sits above the overflow and adds no scroll range at
     all. Measured: raising it from 20px to 90px moved documentElement
     .scrollHeight by exactly 0px on all six games at both 745 and 664.

     `height: auto` is what makes the box able to grow, with `min-height:
     100dvh` (already on body) still holding the floor. Then the two regimes
     both come out right: a page that overflows gets 90px of genuine range
     below it, and a page that fits keeps its full-viewport height while the
     reserve squeezes the centred play column upward, out of the band. Embeds
     are excluded — an embed is a fixed-height iframe with `overflow: hidden`
     and no reserve to make. */
  body { padding-bottom: calc(90px + env(safe-area-inset-bottom)); }
  body:not(.ql-embed) { height: auto; }

  .ql-tray .ql-mute, .ql-tray .ql-info { width: 44px; height: 44px; }
}

/* The tray is appended last, so by DOM order it landed after the back link and
   wrapped the masthead onto a THIRD row — which pushed Meridian's board down
   far enough to put its Reset below the fold. Explicit order puts the tray
   beside the wordmark, where there is room, and leaves the switcher and back
   link sharing row two exactly as before.

   Measured at 402px: wordmark 150 + tray 92 = 242 on row one; switcher 264 +
   back 130 = 394 on row two, inside 402. Two rows, as it was. */
@media (max-width: 620px) {
  .ql-wordmark { order: 1; }
  .ql-tray     { order: 2; }
  .ql-switch   { order: 3; }
  .ql-back     { order: 4; }
}

/* --- the how-to card on a phone -------------------------------------------
   Measured at 402x780, card height / demo / list / steps:

     zenith   584  136  187  3      meridian 589  161  167  3
     sextant  644  140  243  4      halo     683  140  282  4
     serpens  739  140  338  5      LUMEN    878  240  377  5

   Lumen is the outlier on both counts and the only game whose PLAY button
   starts below the fold — 827 in a 780 viewport. Its animated demo is square,
   so `max-width: 240px` made it 240 TALL while every other game's is 136-161.
   Capping the height rather than the width brings it into line without
   shrinking the five demos that were already the right size.

   This is the item left standing in NEXT-SESSION.md ("Lumen's how-to card is
   922px tall … it explains tiers a first-timer cannot reach"). */
@media (max-width: 620px) {
  .howto-demo { max-height: 156px; }
  .howto-demo svg, .howto-demo canvas { height: 156px; width: auto; margin: 0 auto; }
}

/* The result/how-to card takes focus when it opens so a screen reader announces
   the card rather than the word "close" — see openScrim in sheet.js. It is a
   container, not a control, so it must not draw a focus ring: moving the ring
   off the ✕ and onto the whole dialog is not an improvement, it is a bigger
   version of the same defect. Every control INSIDE it keeps its ring, which is
   where a keyboard user actually needs one. */
.scrim .sheet[tabindex]:focus,
.scrim .sheet[tabindex]:focus-visible { outline: none; }

/* --- reclaiming the last 30px above the fold -------------------------------
   With `viewport-fit=cover` the page paints under the browser chrome, so the
   honest measure of "on screen" is visualViewport.height, not innerHeight —
   and against that, two primary controls were below it: Meridian's Reset by
   9-23px and Sextant's Read by 6px. Those are the controls that make a move,
   so the game is unplayable until you scroll, and nothing says to.

   The board cannot give the space back: at 375px it is bound by the page
   gutter, not by height, so `--board-chrome` has no effect. The space comes
   around it instead — every margin here is decoration, and none of it is worth
   a control you cannot reach. */
@media (max-width: 620px) {
  body        { padding-top: 12px; }
  .ql-mast    { padding-bottom: 8px; }
  .game-title { margin-bottom: 2px; }
  .game-sub   { margin-bottom: 8px; }
  .daily      { margin-bottom: 8px; }
  .mode-bar   { margin-bottom: 10px; }
  .foot       { margin-top: 10px; }
}

/* --- keeping controls out of iOS's dead strip ------------------------------
   MEASURED ON THE DEVICE, because nothing else can measure it. On an iPhone 17
   Pro with Safari's toolbar expanded, a tap at y=776 on the dead centre of
   Halo's PLAY did nothing; the identical tap at y=752 opened the game. iOS
   reserves the band above its toolbar for its own reveal gesture, so roughly
   the bottom 50-70px of a page is visible, hit-testable in JS, and deaf to a
   finger.

   It first showed up on Zenith's RISE and looked like a one-off. It is not:
   Halo's PLAY is the FIRST control a new player ever presses, and it silently
   failed. There is no scroll to escape with when the card already fits.

   So the scrim keeps a floor clear. `margin: auto` centres a card in what is
   left, which means no card can seat a button in the dead band, and the taller
   cards simply sit a little higher than dead centre — which is where a card
   with a button at the bottom wants to be anyway.

   80px, not 70: the measurement brackets the boundary between 752 and 776, and
   a floor set exactly at the edge of a range you have bracketed rather than
   found is a floor that fails on the next device. */
@media (max-width: 620px) {
  .scrim { padding-bottom: max(80px, calc(env(safe-area-inset-bottom) + 80px)); }
}

/* --- how-to cards on a SHORT screen ----------------------------------------
   An iPhone SE is 667px tall, and every fold fix before this one was measured
   at 780-812. At 667 the Halo, Serpens and Sextant cards are TALLER THAN THE
   SCREEN (718, 738, 679) with PLAY below the fold — so on the smallest current
   iPhone, three of the six games could not be started at all. 339 checks were
   green at the time, because the gate varied width and never height.

   The card now caps at the viewport and scrolls its own content, with the
   actions row stuck to its bottom edge. PLAY is therefore always visible
   whatever the card contains — which is the property that was missing, rather
   than any particular card being too long.

   The sticky row here is safe in a way the one tried on Sextant's board was
   not: it overlays the step LIST, which has no controls. The only controls in
   this card are the checkbox above it and PLAY itself, and the checkbox stays
   reachable by scrolling. */
@media (max-width: 620px) {
  .scrim .sheet {
    max-height: calc(100dvh - 120px);
    overflow-y: auto;
    overscroll-behavior: contain;
    /* Nothing may be scrolled to and left underneath the plate. The checkbox
       sits directly above it, so a keyboard tab onto it, or any
       scrollIntoView, would otherwise land it in the covered strip. */
    scroll-padding-block-end: 68px;
  }
  .scrim .sheet-actions {
    position: sticky; bottom: 0; z-index: 2;
    padding-top: 10px; margin-top: 4px;
  }

  /* THE PLATE ONLY EXISTS WHEN THERE IS SOMETHING BEHIND IT.
     `background: var(--petrol-2)` was painted unconditionally, and it was wrong
     in both states. On a card that does not overflow — every game at 390x844 —
     it laid an opaque slab of rgb(23,49,60) across a card whose own gradient is
     #1a3743 → #12252e at that point, so a row of buttons sat on a visibly
     different-coloured box for no reason (reported three times over: ZEN-9,
     LUM-8, MER-5). On a card that DOES overflow — every game at 390x664, where
     Safari's chrome caps the card at 544px — the same slab landed mid-sentence
     across rules 3 and 4 on first paint, with a hard edge that read as text
     that simply stopped rather than as a control floating over more.

     So: sheet.js measures the card when it opens and marks it `.scrolls`, and
     only then does the row paint. What it paints is a fade INTO the card's own
     bottom colour rather than a flat plate — the first 22px are translucent, so
     the covered line dissolves instead of being chopped, which is the cue that
     says there is more below. The colour is the card's own gradient end, so
     even at full opacity it is not a foreign box. */
  .scrim .sheet.scrolls .sheet-actions { background: #12252e; }
  /* THE FADE IS DRAWN ABOVE THE PLATE, NOT INSIDE IT. Growing the row's own
     padding to hold a gradient makes the row taller, which makes the card's
     content taller, which covers MORE of the list — the first cut of this fix
     added 14px of overflow to all six cards to buy the cue. An absolutely
     positioned strip on the sticky row travels with it, fades the line it
     covers into the plate colour, and costs the layout nothing: measured
     overflow at 390x664 is identical to the unfixed build. */
  .scrim .sheet.scrolls .sheet-actions::before {
    content: "";
    position: absolute; left: 0; right: 0; bottom: 100%; height: 26px;
    background: linear-gradient(180deg, rgba(18, 37, 46, 0), #12252e);
    pointer-events: none;
  }
  /* AND THE SAME PROBLEM UNDERNEATH IT. Sticky is constrained by the card's
     CONTENT box, so the row comes to rest 26px above the card's edge — the
     sheet's own bottom padding — and the list went on scrolling through that
     26px strip. On Halo at 390x664 that showed as a slice of rule 4 sitting
     below the PLAY button and cut off mid-word by the card's edge, which reads
     as the card being broken rather than as a list continuing above. The strip
     is filled to the padding edge on all three sides; the card's `overflow`
     clips it to the rounded corner. */
  .scrim .sheet.scrolls .sheet-actions::after {
    content: "";
    position: absolute; left: -26px; right: -26px; top: 100%; height: 26px;
    background: #12252e;
    pointer-events: none;
  }
  /* 26px exactly, not "26 with margin": an absolutely positioned box still
     counts towards its scroll container's overflow, so a 30px strip against a
     26px padding added 4px of scroll to every card. Matching the padding keeps
     the measured overflow identical to the unfixed build. */
}

/* --- short screens ---------------------------------------------------------
   A `--board-floor: 300px` was tried here and REVERTED. It let the board shrink
   on a 667px screen and pulled Meridian's Reset up 56px — while dropping
   Meridian's cells to 37px, under the 44px touch floor, on the one game played
   by dragging THROUGH cells. Trading a scroll for cells too small to hit is a
   bad trade; the scroll is the correct answer on a screen this short.

   What remains is spacing, which costs nothing. */
@media (max-height: 700px) and (max-width: 620px) {
  .game-title { font-size: 30px; }
  .ql-mast { padding-bottom: 4px; }
  .mode-bar { margin-bottom: 8px; }
}
/* (a stray closing brace sat here — one more than the file opens. CSS error
   recovery discards it silently at the top level, so nothing broke and nothing
   said anything; it is removed because the next rule appended after it would
   have been the one to find out.) */

/* --- the mode bar has a hidden fifth chip, and now it says so ---------------
   Five chips need 423px in a bar that gets 358 at 390 wide, so EXPERT is
   painted as a 34px sliver of an 83px pill. That is the standard peek
   affordance and the bar does scroll to it by thumb — and six independent
   testers still never found it, which makes it a defect whatever the intent
   was. A second row was considered and rejected on record: it costs 64px of
   chrome above the board, on the games whose primary control is already
   fighting for the fold.

   What was missing is not room, it is the signal. A hard-cut pill at the edge
   of a bar reads as a rendering fault; a pill dissolving into the edge reads as
   more content. ql-modes.js measures the scroll and marks which end is cut, so
   the fade appears only where there is genuinely something past it and
   disappears the moment you reach that end. */
@media (max-width: 620px) {
  .mode-bar { --edge-fade: 30px; }
  .mode-bar.more-right {
    mask-image: linear-gradient(90deg, #000 calc(100% - var(--edge-fade)), transparent);
    -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - var(--edge-fade)), transparent);
  }
  .mode-bar.more-left {
    mask-image: linear-gradient(90deg, transparent, #000 var(--edge-fade));
    -webkit-mask-image: linear-gradient(90deg, transparent, #000 var(--edge-fade));
  }
  .mode-bar.more-left.more-right {
    mask-image: linear-gradient(90deg, transparent, #000 var(--edge-fade),
                                #000 calc(100% - var(--edge-fade)), transparent);
    -webkit-mask-image: linear-gradient(90deg, transparent, #000 var(--edge-fade),
                                        #000 calc(100% - var(--edge-fade)), transparent);
  }
  /* The scrolled-to chip should come to rest clear of the fade, not under it. */
  .mode-bar { scroll-padding-inline: var(--edge-fade); }
}

/* --- 200% zoom: the page must reflow, not scroll sideways ------------------
   WCAG 1.4.10 asks for 320 CSS px with no two-dimensional scrolling, and 200%
   browser zoom on a 390px phone is a 195px CSS viewport — the standard way to
   provoke it. Measured there: every game page reported documentElement
   scrollWidth 280 against clientWidth 195, and window.scrollTo(999,0) really
   did move scrollX to 85. The home page was clean, so this is the game shell.

   The offender is the switcher. Six dots tiled edge to edge at 44px is a fixed
   264px row inside a mast that only has 163px to give, and a flex item does not
   wrap unless it is allowed to — so it simply overhung the viewport and took
   the last two games (Serpens and Sextant) off the page with it, along with the
   utility tray, which was being laid out against the 280px overhang rather than
   the 195px screen.

   Wrapping is the answer rather than an inner scroller: reflow is what 1.4.10
   asks for, and a scroller would keep the same two dots off screen while
   satisfying the letter of the check. Nothing changes at any phone width where
   the row already fits — a flex line only breaks when it has to. */
.ql-switch { flex-wrap: wrap; justify-content: center; min-width: 0; }

/* --- one focus language, and it is the brass ring --------------------------
   The home page draws `2px solid var(--brass)` at a 3px offset on every focus
   stop — 5.86:1 on the ground, and unmistakably part of this site. The games
   inherited nothing and fell back to Chrome's own `1px auto rgb(0,95,204)`: a
   different colour, a different weight, and a blue that belongs to the browser
   rather than to the sky. Same rule, same numbers, so a keyboard player crossing
   from the landing page into a game does not change alphabets.

   :focus-visible, not :focus — the ring is for the keyboard. It is also what
   keeps the how-to card from opening with a ring already drawn on its ✕, which
   is a defect this card has had once already (see openScrim in sheet.js).

   The dialog wrapper is excluded further down the file; it is a container that
   takes focus so a screen reader announces the card, not a control. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
[tabindex]:not([tabindex="-1"]):focus-visible {
  outline: 2px solid var(--brass);
  outline-offset: 3px;
}
/* The dot is round above 620px and a 44px square target below it; `outline`
   follows the element's own border-radius, so both shapes get a ring that
   matches the control instead of a square drawn around a circle. */
.ql-switch-dot:focus-visible { outline-offset: 1px; }

/* The desk rail's rules block. Hidden by default — on a phone the rules live
   behind the ⓘ, because there is no column to put them in. Mounted by
   QLSheet.mountRail from the same `lines` the how-to card uses. */
.rail-how { display: none; }
@media (min-width: 1040px) and (min-height: 560px) {
  .rail-how {
    display: block; grid-column: 1; grid-row: 5;
    align-self: start; margin-top: 26px; max-width: var(--rail);
    border-top: 1px solid color-mix(in srgb, var(--hue) 22%, transparent);
    padding-top: 16px;
  }
  .rail-how-t {
    font-family: var(--sans); font-size: 11px; font-weight: 500;
    letter-spacing: 0.16em; text-transform: uppercase;
    color: var(--smoke); margin-bottom: 12px;
  }
  .rail-how-l { list-style: none; counter-reset: r; display: grid; gap: 11px; }
  .rail-how-l li {
    counter-increment: r; position: relative; padding-left: 26px;
    font-size: 13.5px; line-height: 1.5; color: var(--ivory);
    text-wrap: pretty;
  }
  /* the same numbered-disc language the how-to card uses, one step quieter */
  .rail-how-l li::before {
    content: counter(r); position: absolute; left: 0; top: 1px;
    width: 18px; height: 18px; border-radius: 50%;
    border: 1px solid color-mix(in srgb, var(--hue) 40%, transparent);
    display: grid; place-items: center;
    font-family: var(--sans); font-size: 10px; color: var(--hue);
  }
}

/* A CONTROL IS NOT PROSE. Found on the live build in the Simulator, minutes
   after shipping the same fix for the board: hold a finger on a difficulty chip
   and iOS selects its label — "Copy | Find Selection" over the mode bar. The
   board rule could not cover it, because the mode bar sits outside `.board`.

   Buttons only. Links are deliberately left alone: a long press on an <a> gives
   the iOS link preview, which is the standard affordance and worth keeping on
   the switcher and the back link. */
button, [role="button"] { -webkit-user-select: none; user-select: none; }
