/* All DOM chrome for REFRACT: tokens, top bar, dock, hint strip, modals, cursors. */

:root {
  /* Board metrics, written by js/ui/hud.js from the render transform. */
  --board-x: 0px;
  --board-y: 0px;
  --board-size: 568px;

  /* Neutrals — every UI grey carries a cool violet cast, measured off the reference. */
  --ink-0: #000000;
  --ink-title: #f5f2f6;
  --ink-strong: #c9c6cc;
  --ink-mid: #858286;
  --ink-soft: #767478;
  --ink-dim: #575557;
  --ink-tag: #4e4c4f;
  --ink-mute: #2a282c;

  --line: #2f2d32;
  /* The chip row is the QUIETEST chrome in the reference, not the loudest. Measured chip
     perimeter luminance over five reference frames: RESET 14.8, LEVELS 12.6, SOUND 17.6,
     with the stroke pixels themselves reading 20-21 (ref_001's top border at x=570 reads
     3,4,21,20,2,8,7 — antialiased, so the perimeter mean lands below the stroke value).
     --line rendered crisply at DSF 2 gave a flat 45-46 on every perimeter pixel and a
     perimeter mean of 43.1 / 34.9 / 34.5, 2.5x the reference and 30 % more ink per chip
     than the reference spends on the whole chip including its label. The dock tile keeps
     --line: its perimeter measures 54.9 against a reference 46.9-51.8, i.e. the reference
     really does draw its dock brighter than its chips. Cool-violet cast preserved (B >= R).
     #141317 was the first move and measured 18.9 — better, but still above the reference's
     12.6-17.6 band. #100F11 is --line scaled to 33.9 % of its luminance and measures 14.9,
     which is the reference's own three-chip mean of 15.0. */
  --line-chip: #100f11;
  --line-soft: #201c1d;
  --line-dead: #110f13;
  --fill-chip: #0a0810;
  --fill-tile: #040107;
  --fill-panel: #07050a;
  --fill-raised: #0f0d13;

  --white: #fffdff;

  /* State colours — NEUTRAL BY CONTRACT. Measured over the reference's title, chip row and
     dock, chrome chroma (max channel minus min channel) is mean 4.3-5.5 with p95 <= 12; the
     amber #EDB950 (chroma 157), blue #4696E7 (161) and green #6FBF7A (80) this chrome used
     to carry were 13-30x that and were the loudest thing on screen. State is signalled with
     BRIGHTNESS instead: dimmer than base means recessive, brighter means active, brightest
     means best. Every value below stays inside chroma 12. */
  --sig-hi: #f0eef2;    /* chroma 4  — best / beaten par / active */
  --sig-mid: #c9c6cc;   /* chroma 6  — pinned, current, focused */
  --sig-warn: #e2dad6;  /* chroma 12 — alert, over par: a warm cast, not a hue */

  /* Type — sized against the board so the chrome scales with it. */
  --font: 'Share Tech Mono', 'SFMono-Regular', 'Menlo', 'Consolas', monospace;
  --fs-title: clamp(15px, calc(var(--board-size) * 0.0317), 27px);
  --fs-micro: clamp(7px, calc(var(--board-size) * 0.0123), 12px);
  /* Buttons are the one micro role the reference sets SMALLER than the rest: cap 4.5 px
     against the 5 px of every other small label (REFERENCE 3, "Measured metrics"). At the
     565.89 px board of the capture harness this resolves to 6.25 px. */
  --fs-btn: clamp(5.5px, calc(var(--board-size) * 0.01104), 11px);
  --fs-small: clamp(8.5px, calc(var(--board-size) * 0.0150), 14px);
  --fs-solved: clamp(28px, calc(var(--board-size) * 0.0563), 54px);
  /* The one heading role that is OURS, not the reference's: the levels panel has no
     counterpart in the footage, and it was set entirely at --fs-micro — 73 of its 74 text
     runs at one size, a type range of 1.00x on the largest surface in the game outside the
     board. Every cap height in this chrome resolves to (pre-widening size) x 0.696, because
     the --gw-k widening is cancelled by --gw-y vertically, so a 1.4-1.6x cap step over the
     card meta is exactly a 1.4-1.6x step over --fs-micro.

     Expressed as a MULTIPLE of --fs-micro rather than as its own clamp on --board-size: the
     first version was `clamp(9px, calc(var(--board-size) * 0.0190), 16px)`, which measured
     1.54x on desktop and then INVERTED on a phone — (pointer: coarse) floors --fs-micro at
     9.5 px while a 350 px board drove this term down onto its own 9 px floor, so the heading
     came out 11.93 px against a 12.59 px card meta. Tied to --fs-micro the step is 1.55x at
     every breakpoint by construction. */
  --fs-panel-title: calc(var(--fs-micro) * 1.55);

  /* ---- LETTERFORM WIDTH ----
     REFERENCE 3 states the target itself: ink width / cap height measures 0.62-0.77. Share
     Tech Mono is the right LETTERFORM (rounded-rect O, barred G, straight R leg, shallow M
     vertex) but the wrong PROPORTION — 0.5 em advance against a 0.72 em cap gives ink/cap
     0.515, measured on our own rendered title. Padding the difference with tracking lands
     the advance on the reference's 12.7 px while leaving the glyphs 30 % too narrow: right
     spacing, wrong letters, and the chrome reads sparse and hairline instead of engraved.

     Michroma was considered and rejected by arithmetic, not taste: at cap 13 px its em is
     ~18.6 px and a single M advances ~22 px against the measured 12.7, so no amount of
     negative tracking recovers it without collisions.

     The fix keeps the face and changes its proportion. Every widened role is set --gw-k
     times larger and squashed back by --gw-y = 1 / --gw-k, so cap height is unchanged and
     ink grows 32.5 % wider. Crucially the HORIZONTAL layout is untouched: per-glyph advance
     is (0.5em + tracking) x font-size, and every tracking token below is pre-divided by
     --gw-k so that product is identical to what it was. Chip widths, the 3 px chip gap, the
     dock tile, the title's 13.5 px advance and every other measured box survive intact. */
  --gw-k: 1.325;
  --gw-y: 0.754717;

  /* Tracking, expressed in the WIDENED em. The comment gives the effective tracking, i.e.
     what these resolve to at the pre-widening font size: tr_stored = (0.5 + tr_eff) / 1.325
     - 0.5. The smaller the text, the wider the tracking. */
  --tr-title: 0.0358em;   /* effective 0.21em */
  --tr-micro: 0.2321em;   /* effective 0.47em */
  /* Measured off ref_001: glyph advance in RESET/HINT/LEVELS/SOUND is 4.7 px at a 4.5 px
     cap, i.e. 0.75 em total advance — 0.21 em of tracking on top of the 0.54 em face. */
  --tr-btn: 0.0358em;     /* effective 0.21em */
  --tr-small: 0.1340em;   /* effective 0.34em */
  --tr-hint: 0.1038em;    /* effective 0.30em */
  /* The dock label is the one 7 px role the reference tracks TIGHT: measured advance across
     MIRROR in ref_001 is 4.6 px, so the whole word clears the 53 px tile with 12 px of air
     each side. At --tr-micro it overran the tile's own border. */
  --tr-tile: -0.0094em;   /* effective 0.15em */

  /* Rhythm */
  --inset: clamp(12px, 2.08vw, 26px);
  --dock-zone: clamp(76px, 12.2vh, 108px);
  --tile-w: clamp(52px, calc(var(--board-size) * 0.0933), 86px);
  --tile-h: clamp(45px, calc(var(--board-size) * 0.0792), 74px);
  --radius: 2px;
  --radius-tile: 3px;
  /* Tallest a centred panel may be and still leave the dock and its badge uncovered. */
  --panel-max-h: calc(100vh - 2 * (max(var(--inset), env(safe-area-inset-bottom)) + var(--tile-h) + 26px));

  /* Motion */
  --t-fast: 110ms;
  --t-mid: 200ms;
  --t-slow: 400ms;
  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  --ease-back: cubic-bezier(0.34, 1.56, 0.64, 1);

  /* Cursors — replaced with tinted image-set() by js/ui/cursors.js. */
  --cur-idle: url(../assets/cursors/pointer_b.svg) 9 7;
  --cur-point: url(../assets/cursors/hand_point.svg) 10 2;
  --cur-open: url(../assets/cursors/hand_open.svg) 16 16;
  --cur-closed: url(../assets/cursors/hand_closed.svg) 16 16;
  --cur-cross: url(../assets/cursors/cross_large.svg) 16 16;
  --cur-disabled: url(../assets/cursors/cursor_disabled.svg) 16 16;
  --cur-busy: url(../assets/cursors/cursor_busy.svg) 16 16;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: var(--ink-0);
  overflow: hidden;
  overscroll-behavior: none;
  touch-action: none;
  -webkit-tap-highlight-color: transparent;
  -webkit-text-size-adjust: 100%;
}

body {
  font-family: var(--font);
  color: var(--ink-mid);
  font-weight: 400;
  user-select: none;
  -webkit-user-select: none;
}

/* ---------- widened glyph run ----------
   Used as an inner span wherever the element's own box must keep its padding and border at
   the unwidened size (chips, panel buttons). Leaf text elements apply the same three
   properties directly — see .level-tag, .hint-line and friends. */

.gw {
  display: inline-block;
  font-size: calc(1em * var(--gw-k));
  letter-spacing: var(--tr-btn);
  /* Relative to the widened font size, so the line box is exactly 1em of the PARENT. */
  line-height: var(--gw-y);
  transform: scaleY(var(--gw-y));
}

/* ---------- cursors ---------- */

html { cursor: var(--cur-idle), none; }
body, #hud, #modal-root, #cursor-layer, canvas { cursor: inherit; }

html[data-cursor="point"]    { cursor: var(--cur-point), none; }
html[data-cursor="open"]     { cursor: var(--cur-open), none; }
html[data-cursor="closed"]   { cursor: var(--cur-closed), none; }
html[data-cursor="cross"]    { cursor: var(--cur-cross), none; }
html[data-cursor="disabled"] { cursor: var(--cur-disabled), none; }
html[data-cursor="busy"]     { cursor: var(--cur-busy), none; }

button, a, input, label, .dock-tile { cursor: var(--cur-point), none; }
.dock-tile.is-empty { cursor: var(--cur-disabled), none; }

/* A held or forced state wins over every element-level cursor. */
html[data-cursor="closed"] *,
html[data-cursor="cross"] *,
html[data-cursor="disabled"] *,
html[data-cursor="busy"] *  { cursor: inherit !important; }

/* Capture mode: the DOM cursor sprite stands in for the OS one. */
html[data-cursor-mode="dom"],
html[data-cursor-mode="dom"] * { cursor: none !important; }

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

#board {
  position: fixed;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  background: var(--ink-0);
  z-index: 1;
  touch-action: none;
}

/* ---------- modal dim, board only ----------
   Sits between the canvas (z 1) and the chrome (z 10), so its blur and dim reach the board
   and nothing else. The overlay's own scrim used to carry this backdrop-filter from z 30,
   which dropped the level title's peak luminance by 77 % and spread its edges from 0.5 px
   to 7.5 px — the chip row and the title turned to smear. ref_034 keeps every piece of
   chrome fully crisp behind its solve panel. */

#dim-layer {
  position: fixed;
  inset: 0;
  z-index: 5;
  opacity: 0;
  pointer-events: none;
  background: rgba(2, 1, 4, 0.55);
  backdrop-filter: blur(3px) saturate(0.75);
  -webkit-backdrop-filter: blur(3px) saturate(0.75);
  transition: opacity var(--t-mid) var(--ease);
}

html[data-modal="open"] #dim-layer { opacity: 1; }

/* ---------- hud shell ---------- */

#hud {
  position: fixed;
  inset: 0;
  z-index: 10;
  pointer-events: none;
  padding: max(var(--inset), env(safe-area-inset-top)) var(--inset)
           max(var(--inset), env(safe-area-inset-bottom));
}

#hud button { pointer-events: auto; }

.hud-top {
  position: absolute;
  left: var(--inset);
  right: var(--inset);
  /* REFERENCE 1.4: the button row's border box starts at y = 13 px in a 694 px frame, and
     --inset resolves to 15 px there, so 0.867 * inset lands it exactly without pinning the
     chrome to one viewport size. */
  top: max(calc(var(--inset) * 0.867), env(safe-area-inset-top));
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: start;
  gap: 10px 18px;
}

/* ---------- title block ---------- */

/* REFERENCE 1.4: LEVEL nn cap band y 16-20, title cap band y 31-43, 23 px baseline to
   baseline. The chip row above starts at 13 px, so the tag needs a little air over it and
   the two lines need a wider gap than the default leading gives. */
.title-block {
  margin: 0;
  min-width: 0;
  /* Measured drop: puts the LEVEL nn cap band on y 16-20 and the title's on y 31-43, the
     reference's own rhythm, with the chip row's border box still starting at y 13. */
  margin-top: calc(var(--inset) * 0.2);
}

.level-tag {
  /* 0.81em at the pre-widening size; the widened font is --gw-k larger, so the em-based
     margin is pre-divided to keep the measured cap bands where they were. */
  margin: 0 0 0.6113em;
  font-size: calc(var(--fs-micro) * var(--gw-k));
  letter-spacing: var(--tr-micro);
  color: var(--ink-tag);
  line-height: var(--gw-y);
  transform: scaleY(var(--gw-y));
}

.level-name {
  margin: 0;
  font-size: var(--fs-title);
  font-weight: 400;
  line-height: 1.05;
  color: var(--ink-title);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: clip;
  text-shadow: 0 0 18px rgba(200, 196, 210, 0.14);
}

.level-name .gw {
  letter-spacing: var(--tr-title);
  /* 1.05 at the pre-widening size. */
  line-height: 0.7925;
}

.title-block.is-swapping .level-tag { animation: tag-in 420ms var(--ease) both; }
.title-block.is-swapping .level-name { animation: title-in 420ms var(--ease) both; }

/* The tag carries its own scaleY and the title carries it on its inner glyph run, so the
   two need different keyframes: a shared one would either flatten the tag's letterforms
   back to hairline for 420 ms or squash the title twice over. */
@keyframes tag-in {
  from { opacity: 0; transform: translateY(-4px) scaleY(var(--gw-y)); filter: blur(2px); }
  to   { opacity: 1; transform: translateY(0) scaleY(var(--gw-y)); filter: none; }
}

@keyframes title-in {
  from { opacity: 0; transform: translateY(-4px); filter: blur(2px); }
  to   { opacity: 1; transform: none; filter: none; }
}

/* ---------- chip row ---------- */

.chip-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  /* 3 px between border boxes, measured between the four reference chips. */
  gap: clamp(2px, calc(var(--board-size) * 0.0053), 6px);
  pointer-events: auto;
}

.chip {
  position: relative;
  appearance: none;
  font: inherit;
  font-size: var(--fs-btn);
  /* No indent compensation: the reference label is left-biased inside its box (ink starts
     5 px from the border, ends 8 px from the far one — the trailing track is left in). */
  text-indent: 0;
  color: var(--ink-mid);
  background: var(--fill-chip);
  border: 1px solid var(--line-chip);
  border-radius: var(--radius);
  /* Hugs the label horizontally, generous vertically: 5 px sides, 3.9 px top and bottom at
     the 6.25 px reference size, landing RESET on 35 x 16 px. The label's widened glyph run
     occupies exactly the same layout width it did before, so this box is unchanged. */
  padding: 0.62em 0.8em;
  line-height: 1;
  transition: color var(--t-fast) var(--ease),
              border-color var(--t-fast) var(--ease),
              background-color var(--t-fast) var(--ease),
              box-shadow var(--t-fast) var(--ease);
}

.chip .gw { letter-spacing: var(--tr-btn); }

.chip::after {
  content: '';
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  box-shadow: 0 0 0 0 rgba(245, 242, 246, 0.16);
  transition: box-shadow var(--t-mid) var(--ease);
  pointer-events: none;
}

.chip:hover {
  color: var(--ink-title);
  border-color: #35323a;
  background: #131019;
}

.chip:hover::after { box-shadow: 0 0 14px 0 rgba(200, 196, 210, 0.10); }

.chip:active { transform: translateY(0.5px); }

/* A 1 px inner shadow at 35 % alpha over a black board was not a focus ring anyone could
   find. Every focusable control now takes a real 2 px offset outline; nothing is focused in
   the capture harness, so the reference frames are untouched by this. */
.chip:focus-visible {
  outline: 2px solid var(--sig-mid);
  outline-offset: 2px;
  border-color: var(--sig-mid);
  color: var(--ink-title);
  box-shadow: 0 0 0 1px rgba(201, 198, 204, 0.35);
}

/* Pinned hint: brighter ink and a brighter border, no hue. */
.chip[aria-pressed="true"][data-action="hint"] {
  color: var(--ink-title);
  border-color: rgba(245, 242, 246, 0.15);
}

/* The sound chip carries its state in the label — SOUND / MUTED, five glyphs either way —
   rather than in an inline LED. The dot cost 7 px of row width on a 36 px box and was the
   only blue in the whole top strip; swapping the word is both narrower and louder. */
.chip-sound[aria-pressed="false"] {
  color: var(--ink-tag);
  border-color: #0d0c10;
}

.chip-sound[aria-pressed="false"]:hover { color: var(--ink-soft); border-color: var(--line-chip); }

/* ---------- the strip under the board ---------- */

/* REFERENCE 10.2 item 9 and ref_001: the reference gives the hint and USED/PAR ONE 9 px
   strip centred under the board and shows exactly one of them at a time. Ours used to run
   the hint here and exile the readout to the bottom-left viewport corner, where at
   1440x900 it ended up ~590 px from the dock it describes, alone in an empty margin.
   Anchored by its bottom edge, so a long hint grows upward into the empty band between the
   board and the dock instead of pushing down into the tiles. */
.board-strip {
  position: absolute;
  left: 50%;
  /* +7 px, not +4: at +4 the readout's cap band ended at y 629.2 and the dock badge disc
     began at 629.6, leaving 0.4 px of air where the reference leaves 3.5. At +7 the hint
     band lands on y 617.8-625.7 against the reference's 618-626. */
  bottom: calc(max(var(--inset), env(safe-area-inset-bottom)) + var(--tile-h) + 7px);
  transition: opacity var(--t-mid) var(--ease);
  transform: translateX(-50%);
  /* max() floors the width: --board-size is written from the render transform, and a
     degenerate transform must not collapse the strip to nothing. */
  width: min(92vw, max(240px, calc(var(--board-size) * 1.02)));
  text-align: center;
}

/* This used to be `opacity: 0`, taking the strip to nothing whenever a panel opened. The
   reason it was added is gone: the duplication it fixed was the strip reading SOLVED under a
   panel that already said SOLVED, and js/ui/hud.js now hands the strip to USED / PAR the
   moment the board is solved (contextualHint, `ambient`), so the sentence is no longer
   repeated. What was left was an inconsistency a critic could measure — comparing the solved
   board against the solved panel, title / chip / dock / badge peaks are identical to three
   decimal places (dim factor 1.000, because #dim-layer sits at z 5 and #hud at z 10) while
   this one element went to zero. The reference keeps its own readout legible under its solve
   panel (ref_038, y 622-634). So the strip stays, and no chrome element reacts to a panel.
   The rule is kept as an explicit no-op marker rather than deleted, because "why is the
   strip visible under a panel" is a question that has now been answered twice. */
html[data-modal="open"] .board-strip { opacity: 1; }

.board-strip .is-hidden { display: none; }

.hint-line {
  margin: 0;
  font-size: calc(var(--fs-micro) * var(--gw-k));
  letter-spacing: var(--tr-hint);
  text-indent: var(--tr-hint);
  /* CONTRAST, not fidelity. The hint is the game's only teaching text and it is on screen in
     every level, at a 4.5 css px cap — body text by WCAG's definition, so its floor is 4.5:1
     and not the 3:1 large-text one. --ink-dim (#575557) on the board's black measures 2.84:1
     and fails both. Round 3 moved the strip clear of the brick, which took it from the
     reference's own 1.31:1 (#634A48 on #815751, REFERENCE.md 10.2 item 9 flags it as a
     defect) to 2.84, then stopped on the reference's token. This goes one step past it, to
     the value that lands ON the floor rather than past it: --ink-soft (#767478) measures
     4.53:1 against a target of 4.50. The next token up, --ink-mid (#858286), is 5.6:1 and
     starts to compete with the title. --ink-soft is also already the dock label's colour, so
     nothing new enters the palette. */
  color: var(--ink-soft);
  /* 1.5 at the pre-widening size. */
  line-height: 1.1321;
  transform: scaleY(var(--gw-y));
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  transition: color var(--t-mid) var(--ease), opacity var(--t-mid) var(--ease);
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95), 0 0 8px rgba(0, 0, 0, 0.9);
}

/* Brightness, not hue: pinned is brighter than base, alert is brighter still and warm. */
.hint-line.is-pinned { color: var(--sig-mid); }
.hint-line.is-alert { color: var(--sig-warn); }
.hint-line.is-swapping { animation: hint-in 260ms var(--ease) both; }

@keyframes hint-in {
  from { opacity: 0; transform: translateY(2px) scaleY(var(--gw-y)); }
  to   { opacity: 1; transform: translateY(0) scaleY(var(--gw-y)); }
}

.readout {
  margin: 0;
  white-space: nowrap;
  font-size: calc(var(--fs-micro) * var(--gw-k));
  letter-spacing: var(--tr-micro);
  text-indent: var(--tr-micro);
  /* Same strip, same floor — see .hint-line. The two share the row and must not read as two
     different tiers of importance. */
  color: var(--ink-soft);
  line-height: var(--gw-y);
  transform: scaleY(var(--gw-y));
  transition: color var(--t-mid) var(--ease);
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95), 0 0 8px rgba(0, 0, 0, 0.9);
}

.readout-sep { margin: 0 0.0755em; opacity: 0.6; }
.readout.is-over .readout-used { color: var(--sig-warn); }
.readout.is-under .readout-used { color: var(--sig-hi); }

/* ---------- dock ---------- */

.dock-zone {
  position: absolute;
  left: var(--inset);
  right: var(--inset);
  /* REFERENCE 1.4: the dock's bottom edge sits 16 px off the frame bottom (y 634-678 in a
     694 px frame), which also puts it 13 px below the board. */
  bottom: max(var(--inset), env(safe-area-inset-bottom));
  display: flex;
  justify-content: center;
  align-items: center;
}

.dock {
  display: flex;
  gap: 6px;
  pointer-events: auto;
}

.dock-tile {
  position: relative;
  appearance: none;
  font: inherit;
  width: var(--tile-w);
  height: var(--tile-h);
  padding: 0;
  /* REFERENCE 1.4: the icon is centred on the tile (icon centre y656 in a 634-678 tile) and
     the label is tucked against the bottom edge, not stacked with it in a centred column. */
  display: block;
  background: var(--fill-tile);
  border: 1px solid var(--line);
  border-radius: var(--radius-tile);
  color: var(--ink-soft);
  transition: border-color var(--t-fast) var(--ease),
              background-color var(--t-fast) var(--ease),
              color var(--t-fast) var(--ease),
              transform var(--t-fast) var(--ease);
  touch-action: none;
}

.dock-tile .glyph {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  display: block;
  /* Reference mirror bar measures 26 x 5 px inside a 53 px tile. */
  width: 72%;
  height: auto;
  overflow: visible;
  transition: opacity var(--t-fast) var(--ease), transform var(--t-mid) var(--ease);
}

.dock-tile .glyph .stroke {
  fill: none;
  stroke: #81848c;
  stroke-width: 1.6;
  stroke-linejoin: round;
  transition: stroke var(--t-fast) var(--ease);
}

.dock-tile .glyph .bar {
  fill: #a2a1a6;
  transition: fill var(--t-fast) var(--ease);
}

.dock-tile .tile-label {
  position: absolute;
  left: 0;
  right: 0;
  /* 0.29em at the pre-widening size. */
  bottom: 0.2189em;
  text-align: center;
  font-size: calc(var(--fs-micro) * var(--gw-k));
  letter-spacing: var(--tr-tile);
  text-indent: 0;
  line-height: var(--gw-y);
  transform: scaleY(var(--gw-y));
}

.dock-tile:hover:not(.is-empty) {
  border-color: #55525c;
  background: #0c0912;
  color: var(--ink-strong);
  transform: translateY(-1px);
}

.dock-tile:hover:not(.is-empty) .glyph .stroke { stroke: #d2d4dc; }
.dock-tile:hover:not(.is-empty) .glyph .bar { fill: #eceaf0; }

.dock-tile:focus-visible {
  outline: 2px solid var(--sig-mid);
  outline-offset: 2px;
  border-color: var(--sig-mid);
  box-shadow: 0 0 0 1px rgba(201, 198, 204, 0.35);
}

.dock-tile.is-armed {
  border-color: rgba(240, 238, 242, 0.75);
  background: #131019;
}

.dock-tile.is-empty {
  border-color: var(--line-dead);
  color: var(--ink-mute);
  transform: none;
}

.dock-tile.is-empty .glyph { opacity: 0.22; }

.badge {
  position: absolute;
  top: 0;
  right: 0;
  transform: translate(38%, -38%);
  width: clamp(14px, calc(var(--tile-w) * 0.27), 22px);
  height: clamp(14px, calc(var(--tile-w) * 0.27), 22px);
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: var(--white);
  color: #08060c;
  border: 1px solid transparent;
  font-size: clamp(8px, calc(var(--tile-w) * 0.155), 12px);
  letter-spacing: 0;
  text-indent: 0;
  line-height: 1;
  transition: background-color var(--t-mid) var(--ease), color var(--t-mid) var(--ease);
}

.dock-tile.is-empty .badge {
  background: #08060c;
  color: var(--ink-tag);
  border-color: #4e4c51;
}

.badge.pop { animation: badge-pop 260ms var(--ease-back) both; }

@keyframes badge-pop {
  0%   { transform: translate(38%, -38%) scale(1); }
  38%  { transform: translate(38%, -38%) scale(1.42); }
  100% { transform: translate(38%, -38%) scale(1); }
}

/* ---------- modals ---------- */

#modal-root {
  position: fixed;
  inset: 0;
  z-index: 30;
  display: none;
  align-items: center;
  justify-content: center;
  padding: calc(var(--inset) * 1.4);
}

#modal-root.is-open { display: flex; }

/* The dim and blur live on #dim-layer, under the chrome. This is only the click target that
   dismisses the panel, plus the cover that keeps the HUD's own buttons unreachable while a
   panel is up. */
.scrim {
  position: absolute;
  inset: 0;
  background: transparent;
  animation: scrim-in var(--t-mid) var(--ease) both;
  pointer-events: auto;
}

@keyframes scrim-in { from { opacity: 0; } to { opacity: 1; } }

.panel {
  position: relative;
  pointer-events: auto;
  background: var(--fill-panel);
  border: 1px solid var(--line-soft);
  border-radius: var(--radius);
  box-shadow: 0 30px 90px rgba(0, 0, 0, 0.75), 0 0 0 1px rgba(255, 255, 255, 0.015) inset;
  padding: clamp(20px, calc(var(--board-size) * 0.050), 42px)
           clamp(18px, calc(var(--board-size) * 0.046), 40px);
  max-width: min(94vw, 640px);
  /* Two ceilings, whichever is tighter. The second one keeps the panel clear of the dock:
     #modal-root centres the panel on the viewport, so a panel of height H ends at
     (100vh + H) / 2, and the dock's badge begins one tile-height plus its overhang above
     the bottom inset. The levels panel used to run 614 x 598 in a 720 x 694 frame and put
     its bottom edge at y 646, slicing 12 px off a 45 px dock tile and burying the badge
     entirely. */
  max-height: min(calc(100vh - var(--inset) * 3), var(--panel-max-h));
  overflow: auto;
  text-align: center;
  /* --ease, not --ease-back: the overshoot is now an explicit keyframe, so a back-out
     timing function on top of it would push the peak past the measured value. */
  animation: panel-in var(--t-slow) var(--ease) both;
}

/* REFERENCE 9.2: the panel is measured at 281 px wide on the frame it appears (f128),
   settling to 250 — a 1.124x overshoot — and REFERENCE.md itself asks for "an explicit
   spring reaching 1.12x at ~40 % of the way through". scale(0.9) -> scale(1) on
   cubic-bezier(0.34, 1.56, 0.64, 1) peaks at an output of 1.098, i.e. a scale of 1.0098:
   a 1 % bump where the reference has 12 %, which is invisible. */
@keyframes panel-in {
  0%   { opacity: 0; transform: scale(0.9); }
  40%  { opacity: 1; transform: scale(1.12); }
  100% { opacity: 1; transform: scale(1); }
}

#modal-root.is-closing .panel { animation: panel-out 160ms var(--ease) both; }
#modal-root.is-closing .scrim { animation: scrim-out 160ms var(--ease) both; }

@keyframes panel-out { to { opacity: 0; transform: scale(0.97); } }
@keyframes scrim-out { to { opacity: 0; } }

/* REFERENCE 9.3 measures the reference panel at 254 x 134 with a cadence of
   32 (top pad to SOLVED cap top) / 20 (SOLVED baseline to subtitle cap top) /
   14 (subtitle to button top) / 23 (button bottom to panel bottom), and its content is
   exactly three centred items: the word, ONE score line, the actions.
   Ours measured 304 x 167 on 30.5 / 20.5 / 3.5 / 32.5 / 29.5 — a second score line jammed
   3.5 px under the first, then a 32.5 px void before the buttons. The second line is now
   folded into the first, which removes the cramped pair outright, and the two remaining
   gaps are set to the measured targets. The two actions stay: ORCHESTRATOR-NOTES 5 lists
   the reference's single FREE PLAY button as one of the places to beat it. */
.panel-solved {
  min-width: min(84vw, 254px);
  padding-top: clamp(19px, calc(var(--board-size) * 0.0482), 40px);
  padding-bottom: clamp(16px, calc(var(--board-size) * 0.0389), 32px);
}

.panel-solved .panel-actions {
  margin-top: clamp(6px, calc(var(--board-size) * 0.0154), 14px);
}

/* 1.2367em at the pre-widening size: measured, the previous 1.0189em put the subtitle's cap
   top 18 px under the SOLVED baseline against the reference's 20. */
.panel-solved .panel-sub { margin-top: 1.2367em; }

.panel-word {
  margin: 0;
  font-size: calc(var(--fs-solved) * var(--gw-k));
  font-weight: 400;
  letter-spacing: var(--tr-small);
  text-indent: var(--tr-small);
  color: #ffffff;
  line-height: var(--gw-y);
  transform: scaleY(var(--gw-y));
  text-shadow: 0 0 34px rgba(255, 255, 255, 0.22);
  animation: word-in 520ms var(--ease) 40ms both;
}

/* Animates opacity, tracking and blur only — never transform, so the widening survives. */
@keyframes word-in {
  from { opacity: 0; letter-spacing: 0.62em; filter: blur(4px); }
  to   { opacity: 1; letter-spacing: var(--tr-small); filter: none; }
}

.panel-sub {
  /* 1.35em at the pre-widening size. */
  margin: 1.0189em 0 0;
  font-size: calc(var(--fs-micro) * var(--gw-k));
  letter-spacing: 0.0736em;   /* effective 0.26em */
  text-indent: 0.0736em;
  color: #8a888c;
  line-height: 1.2075;        /* 1.6 at the pre-widening size */
  transform: scaleY(var(--gw-y));
}

.panel-sub .accent { color: var(--sig-warn); }
.panel-sub .good { color: var(--sig-hi); }

.panel-actions {
  margin-top: 1.7em;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
}

.btn {
  appearance: none;
  font: inherit;
  font-size: var(--fs-btn);
  text-indent: 0;
  /* REFERENCE 9.3: FREE PLAY is 71 x 20 px — the same label size as a chip inside far more
     generous padding (12 px sides, 5.9 px top and bottom). */
  padding: 0.94em 2.12em;
  border-radius: var(--radius);
  border: 1px solid var(--line-soft);
  background: transparent;
  color: var(--ink-mid);
  line-height: 1;
  transition: color var(--t-fast) var(--ease),
              border-color var(--t-fast) var(--ease),
              background-color var(--t-fast) var(--ease),
              box-shadow var(--t-fast) var(--ease);
}

.btn .gw { letter-spacing: var(--tr-btn); }

.btn:hover { color: var(--ink-title); border-color: #56525c; background: #121019; }
.btn:focus-visible { outline: 2px solid var(--sig-mid); outline-offset: 2px; border-color: var(--sig-mid); box-shadow: 0 0 0 1px rgba(201, 198, 204, 0.35); }
.btn-primary:focus-visible { border-color: var(--white); box-shadow: 0 0 0 2px rgba(8, 6, 12, 0.9), 0 0 0 3px rgba(245, 244, 246, 0.55); }

/* The panel takes the opening focus (js/ui/modals.js), so the win screen's hero button is
   never painted in a focus colour it did not earn. */
.panel:focus,
.panel:focus-visible { outline: none; }

.btn-primary {
  background: #f5f4f6;
  border-color: #f5f4f6;
  color: #08060c;
}

.btn-primary:hover {
  background: #ffffff;
  border-color: #ffffff;
  color: #000000;
  box-shadow: 0 0 26px rgba(245, 244, 246, 0.28);
}

.btn:disabled { opacity: 0.35; }
.btn:disabled:hover { color: var(--ink-mid); border-color: var(--line-soft); background: transparent; }

/* The only heading a panel gets, so it has to clear the panel fill: --ink-dim on
   --fill-panel is 3.4:1, where the old --ink-tag was 2.4:1. */
.panel-head {
  margin: 0 0 1.2075em;   /* 1.6em at the pre-widening size */
  font-size: calc(var(--fs-micro) * var(--gw-k));
  letter-spacing: var(--tr-micro);
  text-indent: var(--tr-micro);
  color: var(--ink-dim);
  line-height: var(--gw-y);
  transform: scaleY(var(--gw-y));
}

/* levels grid */

/* Sized so 24 cells land in a 4 x 6 block that fits inside --panel-max-h with room to
   spare, instead of the 614 x 598 slab that used to cover the dock. */
.panel-levels {
  max-width: min(90vw, 470px);
  padding: clamp(14px, calc(var(--board-size) * 0.0301), 26px)
           clamp(14px, calc(var(--board-size) * 0.0301), 26px);
  /* One width for the head and the grid, so the rule under the heading is exactly as long as
     the row of cards below it. */
  --levels-w: min(76vw, 404px);
}

/* The panel's own masthead. LEVELS carries the size step; the count is a caption on the same
   baseline, kept at --fs-micro and at --ink-tag so the pair reads as one line with a
   dominant and a subordinate half rather than as two headings. */
.levels-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  width: var(--levels-w);
  margin: 0 auto clamp(8px, calc(var(--board-size) * 0.0177), 15px);
  padding-bottom: clamp(5px, calc(var(--board-size) * 0.0115), 10px);
  border-bottom: 1px solid var(--line-dead);
  text-align: left;
}

.levels-title {
  margin: 0;
  font-size: var(--fs-panel-title);
  font-weight: 400;
  line-height: 1;
  color: var(--ink-title);
  white-space: nowrap;
}

/* Same construction as .level-name: the box keeps the unwidened size, the inner run carries
   the 1.325x widening and the matching --gw-y squash, so cap height is --fs-panel-title x
   0.696 and the title's tracking is the title tracking. */
.levels-title .gw {
  letter-spacing: var(--tr-title);
  line-height: 0.7547;
}

.levels-count {
  margin: 0;
  white-space: nowrap;
  font-size: calc(var(--fs-micro) * var(--gw-k));
  letter-spacing: var(--tr-micro);
  color: var(--ink-tag);
  line-height: var(--gw-y);
  transform: scaleY(var(--gw-y));
}

.level-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
  /* Content-sized rows broke the grid's rhythm exactly once: at 720 x 694 the computed
     template read 71.56 / 71.56 / 71.56 / 58.74 / 71.56 / 71.56 px, because row 4 is the one
     row where all four names fit on a single line. A 17.9 % discontinuity in the middle of a
     grid whose every column is identical and whose every gutter is exactly 5 px. `1fr` on an
     auto-height grid container resolves every row to the tallest row's content, so the six
     rows are equal by construction and stay equal if a level is renamed. */
  grid-auto-rows: 1fr;
  gap: 5px;
  width: var(--levels-w);
  text-align: left;
}

.level-cell {
  appearance: none;
  font: inherit;
  display: flex;
  flex-direction: column;
  gap: 0.26em;
  padding: 0.52em 0.6em;
  min-height: 50px;
  background: var(--fill-tile);
  border: 1px solid var(--line-dead);
  border-radius: var(--radius-tile);
  color: var(--ink-soft);
  text-align: left;
  transition: border-color var(--t-fast) var(--ease),
              background-color var(--t-fast) var(--ease),
              color var(--t-fast) var(--ease),
              transform var(--t-fast) var(--ease);
}

.level-cell .cell-no {
  font-size: calc(var(--fs-micro) * var(--gw-k));
  letter-spacing: var(--tr-micro);
  color: var(--ink-tag);
  line-height: var(--gw-y);
  transform: scaleY(var(--gw-y));
}

/* The card's own headline: one size step over its number and its score line, and a two-token
   brightness step on top of it (--ink-strong against --ink-tag). Clamped to two lines so one
   long name cannot lift every row in the grid — 'FEEDING THE SECOND' and 'THE LONG WAY DOWN'
   are the longest of the 24 and both wrap to two. */
.level-cell .cell-name {
  font-size: calc(var(--fs-micro) * var(--gw-k) * 1.12);
  letter-spacing: -0.0019em;   /* effective 0.16em */
  line-height: 1.0189;         /* 1.35 at the pre-widening size */
  transform: scaleY(var(--gw-y));
  color: var(--ink-strong);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}

.level-cell .cell-score {
  margin-top: auto;
  font-size: calc(var(--fs-micro) * var(--gw-k));
  letter-spacing: 0.0283em;    /* effective 0.20em */
  color: var(--ink-tag);
  line-height: var(--gw-y);
  transform: scaleY(var(--gw-y));
}

.level-cell.is-open { border-color: var(--line); }
.level-cell.is-open:hover { border-color: #56525c; background: #0d0a13; color: var(--ink-title); transform: translateY(-1px); }
/* .cell-name now carries its own colour for the hierarchy step, so the states that used to
   brighten the whole cell have to name it explicitly or the headline would sit still while
   everything under it moved. */
.level-cell.is-open:hover .cell-name,
.level-cell.is-current .cell-name,
.level-cell:focus-visible .cell-name { color: var(--ink-title); }
/* Cleared, current and beaten-par are all told apart by BRIGHTNESS — a green border, a blue
   border and an amber score were the three loudest hues in the whole build. */
.level-cell.is-done { border-color: rgba(201, 198, 204, 0.30); }
.level-cell.is-done .cell-score { color: var(--ink-soft); }
.level-cell.is-best .cell-score { color: var(--sig-hi); }
.level-cell.is-current { border-color: var(--sig-mid); color: var(--ink-title); }
/* 23 of 24 cells are locked on a first run, so this opacity decides whether the grid is a
   list or a smudge. At 0.34 the names composited to ~#111 on the panel — 1.2:1. */
.level-cell.is-locked { opacity: 0.62; }
/* --ink-soft, not --ink-dim: the 0.62 opacity above is applied to the composite, so a locked
   name set at #575557 landed near #363436 on the panel fill — 1.7:1, a smudge. #767478 under
   the same opacity composites to about 2.9:1, still visibly recessive against the open
   cells' --ink-strong headline but actually readable. */
.level-cell.is-locked .cell-name { color: var(--ink-soft); }
.level-cell.is-locked .cell-no,
.level-cell.is-locked .cell-score { color: var(--ink-tag); }
.level-cell:focus-visible { outline: 2px solid var(--sig-mid); outline-offset: 2px; border-color: var(--sig-mid); box-shadow: 0 0 0 1px rgba(201, 198, 204, 0.35); }

/* forms */

.field {
  display: block;
  text-align: left;
  margin: 0 auto 12px;
  width: min(78vw, 300px);
}

.field span {
  display: block;
  margin-bottom: 0.4528em;    /* 0.6em at the pre-widening size */
  font-size: calc(var(--fs-micro) * var(--gw-k));
  letter-spacing: var(--tr-micro);
  color: var(--ink-tag);
  line-height: var(--gw-y);
  transform: scaleY(var(--gw-y));
}

/* The input keeps its natural proportions: squashing a bordered, padded field vertically
   would drag its border and caret with it. */
.field input {
  width: 100%;
  font: inherit;
  font-size: var(--fs-small);
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--ink-title);
  background: #050308;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0.6em 0.7em;
  transition: border-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
}

.field input::placeholder { color: #35323a; }
.field input:focus { outline: none; border-color: var(--sig-mid); box-shadow: 0 0 0 1px rgba(201, 198, 204, 0.3); }
.field input:focus-visible { outline: 2px solid var(--sig-mid); outline-offset: 2px; }

/* player roster */

.roster {
  list-style: none;
  margin: 0 auto;
  padding: 0;
  width: min(80vw, 320px);
  text-align: left;
}

.roster li {
  display: flex;
  align-items: center;
  gap: 0.8em;
  padding: 0.62em 0;
  border-bottom: 1px solid var(--line-dead);
  font-size: var(--fs-micro);
  color: var(--ink-soft);
}

/* Widened on the spans, not on the row: the row carries the divider rule. */
.roster li span {
  display: inline-block;
  font-size: calc(1em * var(--gw-k));
  letter-spacing: 0.0283em;   /* effective 0.20em */
  line-height: var(--gw-y);
  transform: scaleY(var(--gw-y));
}

.roster li:last-child { border-bottom: 0; }

.roster .swatch {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  flex: 0 0 auto;
  box-shadow: 0 0 8px currentColor;
}

.roster li .self { margin-left: auto; color: var(--ink-tag); letter-spacing: var(--tr-micro); }

.room-code {
  margin: 0 0 1.0566em;       /* 1.4em at the pre-widening size */
  font-size: calc(var(--fs-small) * var(--gw-k));
  letter-spacing: 0.1943em;   /* effective 0.42em */
  text-indent: 0.1943em;
  color: var(--ink-title);
  line-height: 0.9057;        /* 1.2 at the pre-widening size */
  transform: scaleY(var(--gw-y));
}

.panel-note {
  margin: 1.0566em 0 0;       /* 1.4em at the pre-widening size */
  font-size: calc(var(--fs-micro) * var(--gw-k));
  letter-spacing: 0.0283em;   /* effective 0.20em */
  color: var(--ink-mute);
  line-height: 1.2075;        /* 1.6 at the pre-widening size */
  transform: scaleY(var(--gw-y));
}

/* ---------- cursor layer ---------- */

#cursor-layer,
#remote-layer {
  position: fixed;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
}

/* Remote cursors belong to the board, so modals cover them; the local one never is. */
#remote-layer { z-index: 20; }
#cursor-layer { z-index: 40; }

.dom-cursor {
  position: absolute;
  top: 0;
  left: 0;
  width: 32px;
  height: 32px;
  will-change: transform;
  image-rendering: auto;
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.7));
}

.dom-cursor.is-hidden { display: none; }

.remote-cursor {
  position: absolute;
  top: 0;
  left: 0;
  will-change: transform;
  transform-origin: 0 0;
}

.remote-cursor img {
  display: block;
  width: 30px;
  height: 30px;
  filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.8));
}

.remote-name {
  position: absolute;
  left: 20px;
  top: 21px;
  padding: 3px 5px 3px 6px;
  border-radius: var(--radius);
  background: rgba(6, 4, 10, 0.82);
  border: 1px solid currentColor;
  font-size: 9px;
  letter-spacing: 0.22em;
  text-indent: 0.22em;
  line-height: 1;
  white-space: nowrap;
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
}

.remote-cursor.is-entering { animation: remote-in 260ms var(--ease) both; }

@keyframes remote-in { from { opacity: 0; } to { opacity: 1; } }

/* ---------- responsive ---------- */

/* ---------- touch ----------

   The rule here, and the reason this block is not simply "make everything bigger": a chip
   that is 44 px TALL is a fat pill, and the reference's whole top strip is built on the
   opposite instinct — it spends 15 luminance units on a chip's entire perimeter. So the
   DRAWN chip keeps its ~21 px box and an invisible ::before carries the 44 px target. The
   controls that live inside a panel — modal buttons, text fields — have no such restraint
   to protect, so those really do grow. */

@media (pointer: coarse) {
  :root {
    --fs-micro: clamp(9.5px, calc(var(--board-size) * 0.0145), 13px);
    --fs-small: clamp(11px, calc(var(--board-size) * 0.018), 15px);
    --fs-btn: clamp(8.5px, calc(var(--board-size) * 0.0132), 12px);
    /* Apple HIG minimum, and the number js/ui/hud.js reserves board space against. */
    --touch-min: 44px;
  }

  .chip,
  .dock-tile,
  .btn,
  .level-cell,
  .field input { touch-action: manipulation; }

  /* Hover is a lie on a touch screen: it sticks after the finger has gone. */
  .chip:hover { color: var(--ink-mid); border-color: var(--line-chip); background: var(--fill-chip); }
  .chip:hover::after { box-shadow: 0 0 0 0 rgba(245, 242, 246, 0); }
  .dock-tile:hover:not(.is-empty) { transform: none; }

  /* The hit box, not the chip. Centred on the chip so it grows symmetrically into the empty
     band above and below the row, and floored at 44 px wide for the short labels (HINT and
     ROOM draw 38.5 px). The row's 3 px gap keeps neighbouring boxes from colliding: the
     tightest pair is 41.75 px centre to centre plus the gap. */
  .chip::before {
    content: '';
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    width: max(100%, var(--touch-min));
    height: var(--touch-min);
  }

  /* Same trick for the badge-bearing tile is unnecessary — it is already over 44 — but the
     panel controls were 24.7 px tall and 24.5 px for a field, i.e. 1.8x under. */
  .btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: var(--touch-min);
    padding: 0.94em 2.12em;
  }

  .field input { min-height: var(--touch-min); }
  .level-cell { min-height: 56px; }
}

/* Phone portrait. Chrome sizes go to fixed pixels here on purpose: --tile-h used to be
   derived from --board-size, and js/ui/hud.js now derives --board-size from where the dock
   and the strip actually are, so leaving the two coupled would make the layout chase its
   own tail every resize. */
@media (max-width: 560px) {
  :root {
    --inset: 14px;
    --tile-w: 78px;
    --tile-h: 62px;
    --dock-zone: 76px;
  }

  /* A phone frame is 2.16:1 and the board is square, so ~465 px of the viewport can never
     be board. Anchored above the dock, the strip landed 122 px below the board and 29 px
     above the tiles, where it read as a label for the dock. It is the board's caption, so
     here it is anchored to the board itself — 12 px under the bottom wall, the same
     relationship the reference has — and the leftover height collects in ONE band under
     the title instead of two. js/ui/hud.js measures the board's band from the dock for
     exactly this reason: the caption follows the board, never the other way round. */
  .board-strip {
    top: calc(var(--board-y) + var(--board-size) + 12px);
    bottom: auto;
    width: min(96vw, 420px);
  }

  .dock { gap: 10px; }
}

@media (max-width: 620px) {
  .hud-top { grid-template-columns: 1fr; }
  .chip-row { justify-content: flex-start; }
  .level-name .gw { letter-spacing: -0.0019em; }   /* effective 0.16em */
  /* --levels-w, not a width on the grid alone: the head's rule has to stay exactly as long
     as the row of cards under it at every width. 100 % of the panel's content box, not a vw
     fraction: at 375 px the old `min(90vw, 404px)` resolved to 337.5 px inside a 331.7 px
     content box and the grid overhung the panel by 6 px. A plain `100%` is not the fix — the
     grid then makes no width demand at all and the panel shrinks to the masthead's
     max-content, 253 px. This is the panel's own ceiling (min(90vw, 470px)) minus its 18 px
     padding and 1 px border on each side. */
  .panel-levels { --levels-w: min(calc(90vw - 38px), 404px); }
  .level-grid { grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); }
  .panel { padding: 24px 18px; }
  /* Narrow frames: tighten tracking so the strip still fits under the board. */
  .readout { letter-spacing: -0.0019em; text-indent: -0.0019em; }
  .hint-line { letter-spacing: 0.0132em; text-indent: 0.0132em; }   /* effective 0.18em */
}

@media (max-width: 420px) {
  .chip-row { gap: 3px; }
  /* Deliberately NOT a smaller padding than the base rule. This block used to set
     0.55em 0.62em, which came after the (pointer: coarse) block at equal specificity and so
     won on every phone — that is where the measured 45.0 x 19.8 px chip came from. The
     drawn chip stays this size by choice now; the 44 px target is carried by .chip::before
     in the coarse block above. */
  .chip { padding: 0.62em 0.7em; }
}

@media (max-height: 560px) {
  :root {
    --dock-zone: clamp(62px, 15vh, 84px);
    --tile-h: clamp(38px, calc(var(--board-size) * 0.070), 58px);
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 1ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
  }
  .panel { animation: none; }
  .panel-word { animation: none; }
  .dock-tile:hover:not(.is-empty) { transform: none; }
  .level-cell.is-open:hover { transform: none; }
}
