/* TALLY — "Gallery" ----------------------------------------------------------
   Warm ivory paper, Instrument Serif display + Outfit UI, ceramic letterpress
   tiles, and a signature target dial. Single light theme (design is light-only).
   Amber is the identity accent (target/dial/selection); green/red are semantic. */

:root {
  /* paper / surfaces */
  --page: #e7e2d6;
  --card: #faf7f0;
  --inset: #fffdf8;
  --soft: #f3ebda;         /* clue + week panel */
  --tile: #fffdf7;
  --tile-press: #fbf5e8;
  --tile-used: #efe8d8;
  /* ink / text */
  --ink: #2b2723;
  --ink-soft: #4a4237;
  --ink-med: #5d5343;    /* v2 §2: mid-ink — ? button, word/tip labels */
  --used-ink: #c3b7a0;   /* v2 §2: used-tile letter + value badge */
  /* secondary-text ramp — darkened to clear WCAG AA (4.5:1) on the ivory card
     (the handoff's original warm faints measured 2.3–3.8:1). Kept as light as the
     bar allows, so the airy Gallery feel survives. */
  --muted: #706656;   /* 5.27:1 */
  --faint: #78705e;   /* 4.59:1 */
  --faint-2: #7a7060; /* 4.55:1 */
  --faint-3: #7a7060; /* 4.55:1 */
  /* accent — amber */
  --accent: #b5822e;
  --on-accent: #fff;     /* v2 §3: glyph/text on an accent fill */
  --accent-deep: #96691f;
  --accent-deeper: #7d561a;
  --accent-ink: #8a611f;
  --accent-label: #a07f3a;
  --soft-border: #e2d6bc;
  /* semantic */
  --good: #2f8f5b;
  --bad: #c1493f;
  /* lines */
  --line-tile: #ddd2ba;    /* v2 §2: darker tile border for ceramic "pop" (was #e8e0cf) */
  --line: #e2d9c8;
  --line-2: #ece3d1;
  --line-3: #ddd3c2;
  --line-strong: #c2b494;  /* v2 §2/§3: stronger edge — ? button, daily pill, operator idle */
  --dash: #d4c9b4;
  /* shadows */
  --sh-tile: 0 3px 0 #dfd4bb;   /* v2 §2: darker tile shadow (was #e5dcc7) */
  --sh-mini: 0 2px 0 #dfd4bb;
  /* radii */
  --r-mini: 8px; --r-chip: 9px; --r-tile: 10px; --r-btn: 12px; --r-eq: 14px; --r-card: 26px;
  /* type */
  --display: "Instrument Serif", Georgia, "Times New Roman", serif;
  --ui: "Outfit", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  /* motion */
  --ease: cubic-bezier(.22, 1, .36, 1);
  --flip: cubic-bezier(.4, 0, .2, 1);
  /* Stacking order, low → high. The ? button sits ABOVE the help scrim on purpose (it
     toggles that sheet closed), which is why --z-help-btn is above --z-scrim. The RESULT
     sheet must sit above the button — it has no job there, and used to float over the
     "Flawless!" title (B-017). */
  --z-scrim: 20; --z-help-btn: 21; --z-result: 22; --z-toast: 30; --z-coach: 40;
}

* { box-sizing: border-box; }
/* author display rules (flex/grid) otherwise beat the [hidden] attribute */
[hidden] { display: none !important; }
:where(button, a, [tabindex], input, select):focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--r-mini);
}
:where(button):focus:not(:focus-visible) { outline: none; }
html, body { margin: 0; }
body {
  background: var(--page); color: var(--ink);
  font-family: var(--ui); -webkit-font-smoothing: antialiased; min-height: 100dvh;
}

/* keyframes ---------------------------------------------------------------- */
@keyframes tl-pop { 0% { transform: translateY(10px) scale(.6); opacity: 0; } 55% { transform: translateY(-3px) scale(1.12); opacity: 1; } 100% { transform: translateY(0) scale(1); opacity: 1; } }
@keyframes tl-fly { 0% { transform: translate(-50%,-50%) scale(1); opacity: 0; } 12% { opacity: 1; } 100% { transform: translate(-50%, calc(-50% - 46px)) scale(.7); opacity: 0; } }
@keyframes tl-hit { 0%,100% { transform: scale(1); } 45% { transform: scale(1.16); } }
@keyframes tl-chip { from { transform: translateY(6px) scale(.8); opacity: 0; } to { transform: translateY(0) scale(1); opacity: 1; } }
@keyframes tl-shake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-5px); } 40% { transform: translateX(5px); } 60% { transform: translateX(-3px); } 80% { transform: translateX(3px); } }
@keyframes tl-spin { from { transform: rotateX(0); } to { transform: rotateX(180deg); } }
@keyframes tl-win { 0% { transform: scale(1); } 35% { transform: scale(1.12); } 100% { transform: scale(1); } }
@keyframes spin { to { transform: rotate(360deg); } }

/* page + card -------------------------------------------------------------- */
.page { min-height: 100dvh; display: flex; align-items: flex-start; justify-content: center; padding: 36px 16px; }
.card {
  position: relative; width: min(432px, 100%);
  background: var(--card); border-radius: var(--r-card);
  box-shadow: 0 30px 70px -30px rgba(60,45,20,.5), 0 2px 0 #fff inset;
  padding: 28px 24px 30px;
}

/* header ------------------------------------------------------------------- */
.topbar { display: flex; justify-content: space-between; align-items: flex-start; }
.brand { display: flex; flex-direction: column; }
.wordmark { font-family: var(--display); font-size: 36px; line-height: 1; letter-spacing: .5px; color: var(--ink); }
/* one line, always — the header is exactly two lines (wordmark + tagline), never three */
.tagline { font-size: 11px; letter-spacing: 2.2px; text-transform: uppercase; color: var(--faint); margin-top: 5px; white-space: nowrap; }
/* top-align the ?/day/streak cluster with the "SumHunt" title (not centered against the taller
   title+tagline block, which floated it too low) — balances the header (D-27 refinement) */
.meta { display: flex; align-items: flex-start; gap: 9px; padding-top: 3px; }
.icon-btn {
  width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
  border: 1.5px solid var(--line-strong); background: var(--card); color: var(--ink-med);
  font: 600 15px var(--ui); cursor: pointer; padding: 0; transition: transform .12s var(--ease), color .2s;
  /* stay above the pop-up scrim so ? remains clickable (to toggle the sheet closed) while open */
  position: relative; z-index: var(--z-help-btn);
}
@media (hover: hover) { .icon-btn:hover { color: var(--ink); } }
.icon-btn:active { transform: scale(.92); }
/* info panel (D-27, option C/S1): one soft shape grouping the passive day caption + streak,
   set apart from the lone ? button. No divider lines; tight two-line stacks inside. */
.info-panel {
  display: flex; align-items: center; gap: 12px;
  background: var(--soft); border: 1px solid var(--soft-border);
  border-radius: var(--r-btn); padding: 6px 12px;
}
/* passive daily/theme indicator — a QUIET caption, deliberately not button-shaped (only ?
   wears a button up here). Amber "✦ Theme / day" over two lines on theme days.
   Not a player control; only clickable (to preview) in testing mode. */
.mode-pill {
  font: 700 10.5px var(--ui); letter-spacing: 1.2px; text-transform: uppercase; line-height: 1.35;
  text-align: center; padding: 0; border: none; border-radius: 0; background: transparent;
  color: var(--muted); cursor: default; white-space: nowrap; transition: color .2s var(--ease);
}
.mode-pill[data-variant="theme"] { color: var(--accent-deep); }
body.testing .mode-pill { cursor: pointer; }
/* dev-only badge — absolutely positioned at the card's top edge so it never widens the
   header row (the row is width-critical now the tagline is nowrap + the info panel exists) */
.test-flag { position: absolute; top: 7px; left: 50%; transform: translateX(-50%); font: 700 9.5px/1 var(--ui); letter-spacing: .1em; color: var(--bad); border: 1px solid var(--bad); border-radius: 5px; padding: 3px 5px; }
/* B-067: the practice chip. Same geometry as .test-flag — absolutely positioned, so it costs no
   width in the width-critical meta row and no height on a board that already overflows several
   phones (CR-017). Accent rather than --bad: practising is an offered, legitimate thing to be
   doing, not an error state, and colouring it like a warning would misdescribe it. The two chips
   are mutually exclusive by construction — ?test=1 persists nothing, so no stored record exists to
   derive a practice run from — but the render guard does not rely on that alone. */
.practice-flag { position: absolute; top: 7px; left: 50%; transform: translateX(-50%); font: 700 9.5px/1 var(--ui); letter-spacing: .1em; color: var(--accent-deep); border: 1px solid var(--accent); border-radius: 5px; padding: 3px 5px; background: var(--soft); }
/* tight stack (S1): number directly over its caption, matching the 2-line theme label */
.streak { text-align: center; }
.streak-num { display: block; font-family: var(--display); font-size: 17px; line-height: .95; }
.streak-num.pop { color: var(--accent); animation: tl-pop .45s cubic-bezier(.22,1.4,.36,1) both; }
.streak-label { display: block; font-size: 8.5px; letter-spacing: 1.2px; line-height: 1.2; margin-top: 1px; color: var(--faint); }

/* ---- responsive header steps -------------------------------------------------
   These MUST sit after the .mode-pill / .streak-num base rules above: those use a
   `font:` shorthand, which resets font-size, so an earlier media block loses to them
   at equal specificity. The pre-existing ≤430px overrides for .mode-pill/.streak-num
   were dead for exactly that reason (found while fixing B-003). */
/* PHONES (all of them): the nowrap tagline + info panel are width-critical — compress both a
   notch. Was max-width:430px, which stranded 431–639px devices (iPhone Pro Max is 440pt): the
   card narrows with the viewport there, but no compression applied, so the wider two-line
   "✦ Theme day" panel overhung the card's right edge on theme days (B-002). Matches the
   mobile-layout boundary used further down (max-width: 639px). */
@media (max-width: 639px) {
  .tagline { font-size: 8.5px; letter-spacing: 1.2px; }
  .info-panel { padding: 5px 8px; gap: 8px; }
  .mode-pill { font-size: 10px; }
  .streak-num { font-size: 16px; }
  .meta { gap: 6px; }
}
/* NARROW phones (≤400px) — B-003 step 1. The theme label is the widest state the header
   ever wears (~19px wider than "Daily"), and at 360px (the most common Android width) that
   was enough to push the cluster past the card. Tighten the meta side only; the brand is
   untouched here. */
@media (max-width: 400px) {
  .icon-btn { width: 30px; height: 30px; font-size: 14px; }
  .info-panel { padding: 4px 7px; gap: 6px; }
  .mode-pill { font-size: 9.5px; letter-spacing: .9px; }
  .streak-num { font-size: 15px; }
  .streak-label { font-size: 8px; letter-spacing: .9px; }
  .meta { gap: 5px; }
}
/* SMALLEST phones (≤340px) — B-003 step 2. Measured: the wordmark ALONE is 158px and the
   tagline measures exactly the same, so trimming the tagline saves nothing — the brand block
   itself has to scale. Scaling keeps D-27's two-line header intact (wordmark + tagline, never
   three lines) instead of restructuring the row; the card's own padding also gives way, since
   on a 320px screen breathing room costs more than it buys. */
@media (max-width: 340px) {
  .card { padding: 22px 16px 24px; }
  /* par 3 puts THREE word slots in the row that holds two, leaving ~39px per slot here. A
     six-letter label at 9px/.1em inks ~40px, so it overflowed by 3px (measured, D-38); dropping
     the tracking and a point of size gives the worst label ~5px of slack. par 2 is unaffected —
     its slots are 112px — but this is keyed to width, not par, so the two never diverge. */
  .wslot-word { font-size: 8px; letter-spacing: 0; }
  .wordmark { font-size: 27px; }
  .tagline { font-size: 7px; letter-spacing: .8px; }
  .icon-btn { width: 28px; height: 28px; font-size: 13px; }
  .info-panel { padding: 3px 6px; gap: 5px; }
  .mode-pill { font-size: 9px; letter-spacing: .6px; }
  .streak-num { font-size: 14px; }
  .streak-label { font-size: 7.5px; letter-spacing: .7px; }
  .meta { gap: 4px; }
}

/* board layout ------------------------------------------------------------- */
.board { display: block; }

/* target dial -------------------------------------------------------------- */
.dial-section { text-align: center; margin-top: 30px; }
.dial-wrap { position: relative; width: 190px; height: 190px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; }
.dial-ring { position: absolute; inset: 0; transform: rotate(-90deg); }
.ring-track { stroke: rgba(181,130,46,.13); }
.ring-progress { stroke: var(--accent); transition: stroke-dashoffset .55s var(--ease), stroke .3s; }
.dial-label { font-size: 10.5px; letter-spacing: 2.2px; white-space: nowrap; color: var(--faint); transition: color .3s; }
.dial-num { font-family: var(--display); font-size: 74px; line-height: 1; color: var(--accent); font-variant-numeric: tabular-nums; transition: color .3s; }
.dial-num.win { animation: tl-win .6s cubic-bezier(.22,1.4,.36,1); text-shadow: 0 0 28px rgba(47,143,91,.4); }
/* digit-aware shrink (D-31): x days carry 4-digit targets; a wrong-op overshoot can
   transiently reach 5 — the number must never clip the ring */
.dial-num.d4 { font-size: 52px; }
.dial-num.d5 { font-size: 41px; }
/* pre-line: the resting subline is two lines ("Daily No. 31" / "Fri 31 Jul") fed as a \n through
   textContent — one line was ~118px wide and crossed the ring on the 150px mobile dial (B-036).
   In-play states carry no \n and render exactly as before. */
.dial-sub { font-size: 12px; line-height: 1.35; color: var(--muted); font-variant-numeric: tabular-nums; transition: color .3s; white-space: pre-line; text-align: center; }

/* clue card ---------------------------------------------------------------- */
.clue-card { margin-top: 22px; padding: 14px 18px; background: var(--soft); border: 1px solid var(--soft-border); border-radius: var(--r-btn); animation: tl-chip .28s var(--ease) both; }
.clue-label { display: block; font-size: 10px; font-weight: 700; letter-spacing: 2px; color: var(--accent-label); }
.clue-text { display: block; font-family: var(--display); font-style: italic; font-size: 18px; margin-top: 4px; color: var(--ink-soft); text-wrap: pretty; }

/* equation ----------------------------------------------------------------- */
.equation { margin-top: 14px; }
/* B-069: this box used to arrive at its full 84px in ONE frame, shoving the rack and operator row
   down 100px with no easing — measured, with no intermediate height at any sample point.
   It now grows into place, using the grid-template-rows 0fr->1fr technique rather than animating
   height/max-height directly: that is the approach the design hook recommends, and it animates to
   the content's REAL height instead of to a guessed ceiling. `display: grid` with `place-items:
   center` reproduces exactly what the previous flex centring did, so the scaffold layout tuned by
   B-018 and B-022 is unchanged.
   The transition lives on .has-terms because BOTH ends of the animation carry that class; the
   collapsed end is .eq-opening below, which exists for two frames only. */
.equation.has-terms { margin-top: 16px; min-height: 66px; border: 1px solid var(--line-2); background: var(--inset); border-radius: var(--r-eq); padding: 12px; display: grid; grid-template-rows: 1fr; place-items: center;
  transition: grid-template-rows .26s var(--ease), opacity .2s ease-out,
              margin-top .26s var(--ease), padding .26s var(--ease), min-height .26s var(--ease); }
/* min-height MUST be zeroed here: in CSS min-height beats the collapsed row, so leaving 66px would
   pin the box open and the growth would never be visible. Margin and padding collapse too —
   without that the box would still pop ~42px of chrome into existence in one frame, which is the
   defect in miniature rather than a fix. */
.equation.has-terms.eq-opening { grid-template-rows: 0fr; min-height: 0; margin-top: 0; padding-top: 0; padding-bottom: 0; opacity: 0; }
/* The 0fr row must be able to clip, or the content would spill out during the open. But the clip
   MUST NOT outlive the animation: .wslot.filled draws `box-shadow: 0 3px 0` — a 3px line under each
   word slot — and .chip/.wslot.filled both animate in with an OVERSHOOT easing, so a permanent clip
   cuts the bottom edge off every filled slot. That was owner-reported the moment this shipped to
   local dev. So the clip is released once the box has finished opening. */
.equation.has-terms > .eq-row { overflow: hidden; min-height: 0; }
.equation.has-terms.eq-settled > .eq-row { overflow: visible; }
.eq-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 7px; }
.eq-empty { font-size: 13px; color: var(--muted); text-align: center; max-width: 34ch; line-height: 1.5; }
.eq-empty b { color: var(--ink); font-variant-numeric: tabular-nums; }
.chip { display: inline-flex; flex-direction: column; align-items: center; gap: 1px; background: var(--card); border: 1px solid var(--soft-border); border-radius: var(--r-chip); padding: 4px 10px; animation: tl-chip .28s cubic-bezier(.22,1.4,.4,1) both; }
.chip-word { font-size: 9px; font-weight: 700; letter-spacing: .1em; color: var(--faint-2); }
.chip-val { font-family: var(--display); font-size: 19px; line-height: 1; color: var(--ink); }
.eq-op { font-family: var(--display); font-size: 22px; color: var(--accent); }
.eq-slot { width: 34px; height: 38px; border: 1px dashed var(--dash); border-radius: var(--r-chip); display: inline-block; }

/* equation scaffold (CR-001) — fixed word + operator slots; words auto-place by position */
.equation.scaffold { padding: 12px 14px; }
.equation.scaffold .eq-row { width: 100%; flex-wrap: nowrap; gap: 7px; }
.wslot {
  flex: 1 1 0; min-width: 0; max-width: 112px; height: 58px; border-radius: 12px;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px;
  transition: opacity .35s var(--ease);
}
.wslot.empty { border: 1.5px dashed var(--dash); background: var(--inset); }
.wslot.empty .wslot-q { font-family: var(--display); font-size: 24px; color: var(--faint); }
.wslot.filled { background: var(--tile); border: 1px solid var(--soft-border); box-shadow: 0 3px 0 #dfd4bb; animation: tl-chip .28s cubic-bezier(.22,1.4,.4,1) both; }
.wslot-word { font-size: 9px; font-weight: 700; letter-spacing: .1em; color: var(--faint-2); }
.wslot-val { font-family: var(--display); font-size: 22px; line-height: 1; color: var(--ink); }
.oslot {
  flex: 0 0 auto; width: 26px; height: 58px; display: flex; align-items: center; justify-content: center;
  font-family: var(--display); transition: opacity .35s var(--ease);
}
.oslot.filled { font-size: 22px; color: var(--accent); }
.oslot.empty { font-size: 16px; color: var(--faint); }
/* par 3 (D-38, option B): with TWO gaps the player has to say which one an operator fills, so an
   empty gap becomes a mini ceramic key and the selected one sits pressed-in — the same idiom D-25
   already gives a chosen operator key, so the one new interaction has no new language to learn.
   Only added when par > 2 (`.pick`), so a par-2 board renders exactly as it always has. The hit
   area is the full 58px row height even though the key is 34px, to keep the tap target honest at
   the narrow widths where the row is tightest. */
.oslot.pick { width: 32px; cursor: pointer; }
.oslot.pick .okey {
  width: 100%; height: 34px; border-radius: 9px; display: flex; align-items: center; justify-content: center;
  background: var(--tile); border: 1.5px solid var(--line-strong); box-shadow: var(--sh-mini);
  font-size: 16px; color: var(--faint);
  transition: background .18s, border-color .18s, color .18s, box-shadow .18s, transform .1s;
}
.oslot.pick.sel .okey { background: var(--accent); border-color: var(--accent-deep); color: var(--on-accent); box-shadow: none; transform: translateY(2px); }
/* A FILLED gap that is selected wears the same pressed-in key, carrying its operator instead of
   the waiting dot (B-022) — so "your next tap lands here" looks the same everywhere, and tapping
   a gap you have already filled confirms itself instead of appearing to do nothing. Unselected
   filled gaps keep the plain amber symbol. */
.oslot.filled.pick .okey { font-size: 19px; }
.oslot.pick:active .okey { transform: scale(.92); }

/* staging (current word) --------------------------------------------------- */
.staging { text-align: center; margin-top: 16px; min-height: 40px; }
.current-word { display: flex; justify-content: center; gap: 6px; min-height: 34px; align-items: center; flex-wrap: wrap; }
.current-placeholder { font-size: 12px; letter-spacing: 1px; color: var(--faint-3); }
/* count-up collapse number (CR-003) — the summed value that counts up then drops into its slot */
.collapse-num { display: inline-block; font-family: var(--display); font-size: 30px; line-height: 1; color: var(--ink); font-variant-numeric: tabular-nums; animation: cn-in .18s var(--ease) both; }
@keyframes cn-in { from { opacity: 0; transform: scale(.72); } }
.current-sum { font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; min-height: 17px; margin-top: 2px; }
.cs-proj { display: block; margin-top: 3px; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
.cs-proj.good { color: var(--good); }
.cs-proj.over { color: var(--bad); }

/* mini-tile (letter -> value flip) */
.mini-tile { position: relative; width: 30px; height: 34px; transform-style: preserve-3d; }
.mini-tile.pop { animation: tl-pop .34s cubic-bezier(.22,1.4,.36,1) both; }
.mini-tile.flip { animation: tl-spin .5s var(--flip) both; }
.mini-tile.reject { animation: tl-shake .4s; }
.mt-front, .mt-back {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: var(--tile); border: 1px solid var(--line-tile); border-radius: var(--r-mini);
  box-shadow: var(--sh-mini); backface-visibility: hidden;
}
.mt-front { font-family: var(--display); font-size: 19px; color: var(--ink); }
.mt-back { transform: rotateX(180deg); font-family: var(--ui); font-weight: 600; font-size: 15px; color: var(--accent); }
.mt-back.warm, .mt-front.warm { background: var(--accent); border-color: var(--accent-deep); box-shadow: 0 2px 0 var(--accent-deeper), 0 0 18px rgba(181,130,46,.65); color: #fff; }

/* rack --------------------------------------------------------------------- */
/* The rack is a container so the tiles can size their own type against it (B-004): the tiles
   are 6 equal columns of this box, so 1cqw tracks tile width exactly. Previously the letter was
   a FIXED px size while the tile shrank with the screen, so on narrow phones the glyph outgrew
   its tile and collided with the value badge. calc(7.5cqw - 3px) reproduces today's 24px at
   440px and ~26px on desktop, and keeps the same letter-to-tile ratio all the way down. */
.rack { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-top: 10px; container-type: inline-size; }
.tile {
  position: relative; aspect-ratio: .92; border-radius: var(--r-tile);
  display: grid; place-items: center; font-family: var(--display);
  font-size: clamp(15px, calc(7.5cqw - 3px), 28px); color: var(--ink);
  background: var(--tile); border: 1px solid var(--line-tile); box-shadow: var(--sh-tile);
  cursor: pointer; padding: 0; user-select: none;
  transition: transform .12s var(--ease), box-shadow .12s, background .2s, color .2s, opacity .2s;
}
.tile .val { position: absolute; bottom: 5%; right: 8%; font: 600 clamp(8px, 2.6cqw, 10.5px) var(--ui); color: var(--muted); }
.tile:not(:disabled):active { transform: translateY(3px); box-shadow: 0 0 0 #e5dcc7; background: var(--tile-press); }
.tile.used { background: var(--tile-used); border-color: var(--tile-used); box-shadow: none; color: var(--used-ink); transform: translateY(1px); cursor: default; }
.tile.used .val { color: var(--used-ink); }
.tile.warm { background: var(--accent); border-color: var(--accent-deep); box-shadow: var(--sh-tile), 0 0 18px rgba(181,130,46,.6); color: #fff; }
.tile.warm .val { color: #f3e3c3; }
/* CR-020 / D-42 — the quiet half of tip rung 3 ("bright reveal, then quiet residue"). When the
   2.6s amber fades, every tile that still matters keeps this underline for the rest of the puzzle,
   so a player who was interrupted can re-read a rung they PAID for instead of buying it twice.
   Solid, not the dotted treatment (R1): .needop drops unused tiles to 45% opacity, and opacity
   scales the mark's contrast against the tile face itself, so a sparse mark loses most of its
   delta exactly when the board is busiest. --accent-deeper is the darkest accent (≈6.3:1 on the
   tile face) for the same reason.
   It underlines the TILE, not the letter, and that is a correction the owner caught (2026-07-28).
   A mark under the glyph necessarily sits in the same horizontal band as the value badge, and the
   badge is right-anchored with a width that changes between a 1- and a 2-digit value (letters run
   1–26) — so the gap between mark and number visibly changed tile to tile. Measured at 320/390/768:
   5.4–7.7px against a one-digit value but 0.2–4.4px against a two-digit one. Neither lever fixes
   it: no line-height lifts the mark clear of the badge (still overlapping at every width, and it
   pushes the letter off centre), and pinning the badge to a fixed-width box makes the gap constant
   only by squeezing it to 0 and colliding the letter with the badge. Run across the tile's own
   bottom edge the mark has no neighbour, so digit count cannot affect it at all.
   Drawn as an inset shadow rather than a border so it adds no layout box and follows --r-tile's
   rounded corners. --accent-deeper is the darkest accent: 6.42:1 on the tile face, and it is the
   right choice because .needop drops unused tiles to 45% opacity and opacity scales the mark's
   contrast against the tile face itself — the reason the dotted treatment (R1) was rejected.
   Be honest about the limit though: under .needop the mark measures 2.05:1, better than --accent's
   1.64:1 but still under the 3:1 non-text threshold. Logged as B-032 rather than papered over. It
   is survivable because needOp() blocks tile taps outright, so the rack is not actionable in the
   one state where the mark is hard to read. */
.tile.residue { box-shadow: var(--sh-tile), inset 0 -3px 0 var(--accent-deeper); }

/* operators + actions ------------------------------------------------------ */
.controls { display: block; }
.ops { display: flex; justify-content: center; gap: 12px; margin-top: 16px; }
.op {
  width: 50px; height: 42px; border-radius: 11px; display: grid; place-items: center;
  font: 400 19px var(--ui); background: var(--tile); border: 1.5px solid var(--line-strong); color: var(--muted);
  box-shadow: var(--sh-mini);
  cursor: pointer; padding: 0; transition: transform .1s, background .18s, border-color .18s, color .18s, box-shadow .18s;
}
/* chosen operator sits pressed-IN (flat, seated) — not raised/"hovering" like the idle keys */
.op[aria-pressed="true"] { background: var(--accent); border-color: var(--accent-deep); box-shadow: none; color: var(--on-accent); transform: translateY(2px); }
.op:not(:disabled):active { transform: scale(.9); }
.op:disabled { opacity: .4; cursor: default; box-shadow: none; }

/* v2 §4: operator-nudge — a word is locked but no operator picked yet. Unused tiles recede,
   and the operators glow amber + gently bob (staggered) to draw the eye to the next step. */
.needop .tile:not(.used) { opacity: .45; }
/* B-032: a tile still carrying the tip-3 residue recedes LESS far. At .45 the mark measured
   2.05:1 against the tile face — under the 3:1 non-text bar — because a parent opacity scales the
   mark's contrast against that face, not just against the page. .65 measures exactly 3.00:1.
   The nudge still works: UNMARKED tiles keep the full .45 recede, so the contrast between the two
   groups is preserved and the operators remain the brightest thing on the board. This deliberately
   keeps paid information legible in the one state where the rack is not actionable — the player
   cannot act on it yet, but they should never have to re-buy a rung because it dimmed away. */
.needop .tile.residue:not(.used) { opacity: .65; }
.needop .op:not(:disabled):not([aria-pressed="true"]) {
  border-color: var(--accent); box-shadow: 0 2px 0 var(--accent-deeper); color: var(--accent);
  animation: tl-nudge 1.4s ease-in-out infinite;
}
.needop .op:not(:disabled):not([aria-pressed="true"]):nth-child(2) { animation-delay: .15s; }
.needop .op:not(:disabled):not([aria-pressed="true"]):nth-child(3) { animation-delay: .3s; }
@keyframes tl-nudge { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
@media (prefers-reduced-motion: reduce) { .needop .op { animation: none; } }

.actions { display: flex; gap: 10px; margin-top: 20px; }
.btn {
  flex: 1; text-align: center; padding: 14px 0; border-radius: var(--r-btn);
  font: 600 15px var(--ui); cursor: pointer; border: 1px solid var(--line); background: transparent; color: var(--muted);
  transition: transform .1s var(--ease), opacity .2s, background .2s, border-color .2s;
}
.btn:not(:disabled):active { transform: scale(.97); }
.btn:disabled { opacity: .45; cursor: default; }
.btn.ghost { border-color: var(--line); background: transparent; color: var(--muted); }
.btn.word { flex: 1.2; border-color: #c9bda6; background: var(--inset); color: #5d5343; }
.btn.primary { flex: 1.2; border-color: var(--ink); background: var(--ink); color: var(--card); }

/* delta v4 (CR-026): Undo + ONE contextual primary ------------------------- */
/* Undo is demoted — a fixed narrow slot and lighter weight, so the row has exactly one thing
   worth looking at. Behaviour is unchanged (peels a letter, then the operator, then the chip). */
.btn.undo { flex: 0 0 84px; font-weight: 500; }
.btn.undo:disabled { opacity: .35; }
/* The primary carries three appearances, and which one it wears IS the instruction:
   · disabled  → DASHED border, no fill. Reads as an empty slot rather than a broken button,
                 because in these states the label is hint copy, not a call to action.
   · enabled   → solid ink. "This is the move."
   · finishing → gold + the operator-nudge bob (tl-nudge, already in this file), so the thing
                 that wants your tap is always the gold thing that moves. One press wins. */
.btn.primary-cta { flex: 1; letter-spacing: .01em; }
.btn.primary-cta:disabled {
  border: 1px dashed var(--dash); background: transparent; color: var(--faint);
  font-weight: 500; opacity: 1; cursor: default;
}
.btn.primary-cta:not(:disabled) { border: 1px solid var(--ink); background: var(--ink); color: var(--card); }
/* The design specified background:accent with white text. Measured, that is 3.39:1 at 15px/600 —
   below the 4.5:1 bar D-25 set for this project, on the single most important control in the game
   at its highest-stakes moment. (B-009 already flags white-on-accent at exactly 3.39:1.) Filling
   with --accent-deep instead gives 4.84:1, reads as the same gold, and is a colour already in the
   button (it was the design's own border). Deviation from the package, recorded in DECISIONS. */
.btn.primary-cta.finishing:not(:disabled) {
  border: 1px solid var(--accent-deeper); background: var(--accent-deep); color: var(--on-accent);
  box-shadow: 0 3px 0 var(--accent-deeper);   /* the design's accentShadow #7d561a is this token */
  animation: tl-nudge 1.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) { .btn.primary-cta.finishing { animation: none; } }

.status-line { text-align: center; margin: 14px 0 0; font-size: 13.5px; font-weight: 600; color: var(--muted); min-height: 16px; }
.status-line.good { color: var(--good); }
.status-line.warm { color: var(--accent); }
.status-line.bad { color: var(--bad); }

/* tips + give up ----------------------------------------------------------- */
.tip-log { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.tip-item { font-size: 13px; color: var(--accent-ink); background: var(--soft); border: 1px solid var(--soft-border); border-radius: var(--r-tile); padding: 9px 12px; text-align: left; animation: tl-chip .3s ease both; }
.btn.tip { flex: none; width: 100%; margin-top: 10px; padding: 12px; border: 1px dashed var(--dash); border-radius: var(--r-btn); font: 600 14px var(--ui); color: #5d5343; background: transparent; }
.give-up-btn {
  display: block; margin: 16px auto 0; font: 400 13px var(--ui); color: #7a7060; /* AA 4.55:1 */
  text-decoration: underline; text-underline-offset: 3px; background: none; border: none; cursor: pointer;
  transition: color .2s, opacity .2s;
}
.give-up-btn:disabled { opacity: .45; cursor: default; }
@media (hover: hover) { .give-up-btn:not(:disabled):hover { color: var(--bad); } }

/* value flyers ------------------------------------------------------------- */
.flyers { position: absolute; inset: 0; pointer-events: none; overflow: hidden; border-radius: var(--r-card); }
.flyer { position: absolute; font: 600 15px var(--ui); color: var(--accent); transform: translate(-50%,-50%); animation: tl-fly .64s cubic-bezier(.4,0,.5,1) both; pointer-events: none; }

/* loading ------------------------------------------------------------------ */
.loading { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 48px 0; color: var(--muted); }
.spinner { width: 26px; height: 26px; border: 3px solid var(--line-2); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
/* B-024: the loader's honest-failure state. Quiet, not alarming — a failed fetch is not the
   player's fault and the copy says their streak is safe, so the moment reads as recoverable
   rather than as a loss. Sized off the existing loader rhythm; colours are tokens only. */
.load-note { margin: 0; font-size: 13px; color: var(--faint); text-align: center; max-width: 30ch; }
/* .btn is built for the sheets, where buttons are flex:1 and full-width — so its padding is
   `14px 0` and it needs none horizontally. Standing alone here it shrink-wraps the label, leaving
   1px of slack each side and a sub-44px tap target. Restore both explicitly rather than leaning on
   a rule written for a different context. */
.load-retry { margin-top: 4px; flex: none; padding: 12px 22px; min-height: 44px; }

/* sheets ------------------------------------------------------------------- */
/* No dim — the pop-up floats over the live game; a transparent scrim still covers the
   viewport so the game behind isn't interactive. Separation comes from a strong shadow. */
.sheet-scrim { position: fixed; inset: 0; background: transparent; display: grid; place-items: end center; z-index: var(--z-scrim); }
/* B-017: the result sheet outranks the ? button, so the button cannot float over the
   celebration. #helpScrim deliberately stays on the base layer — the ? must remain
   clickable above IT to toggle help closed again. */
#scrim { z-index: var(--z-result); }
.sheet {
  width: 100%; max-width: 432px; background: var(--card); color: var(--ink);
  border-radius: 22px 22px 0 0; padding: 26px 24px calc(28px + env(safe-area-inset-bottom));
  box-shadow: 0 -16px 48px -12px rgba(43,39,35,.5), 0 -1px 0 rgba(43,39,35,.08); text-align: center;
  animation: rise .28s cubic-bezier(.2,.8,.2,1);
  /* B-007: these were desktop-only, so on a short phone the sheet grew past the viewport
     inside a FIXED scrim — Done, Play again and the whole share row sat below the fold with
     nothing to scroll, and reload was the only way out. Every sheet gets them now. */
  max-height: calc(100dvh - 24px);
  overflow-y: auto;
  overscroll-behavior: contain;
}
@keyframes rise { from { transform: translateY(30px); opacity: .4; } }
.sheet-mark { font-family: var(--display); font-size: 34px; line-height: 1; color: var(--accent); }
.sheet-title { margin: 6px 0 0; font-family: var(--display); font-size: 32px; line-height: 1.1; font-weight: 400; }
.sheet-sub { margin: 8px 0 0; font-family: var(--display); font-style: italic; font-size: 16px; color: var(--muted); }
.sheet-sub b { color: var(--ink); font-style: normal; font-variant-numeric: tabular-nums; }
.sheet-stats { display: flex; justify-content: center; gap: 22px; margin-top: 18px; }
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat-num { font-family: var(--display); font-size: 24px; line-height: 1; }
.stat-label { font-size: 9px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--faint); }

.week-panel { margin-top: 20px; padding: 14px 16px; background: var(--soft); border: 1px solid var(--soft-border); border-radius: var(--r-btn); }
.week-label { font-size: 10px; font-weight: 700; letter-spacing: 2px; color: var(--accent-label); text-align: left; }
.week-row { display: flex; justify-content: space-between; gap: 6px; margin-top: 10px; }
.week-day { display: flex; flex-direction: column; align-items: center; gap: 5px; flex: 1; }
/* padding-bottom nudges the glyph up ~1.5px: the symbol glyphs (✦ ✓ – ·) sit low in their
   line box (font baseline metrics), so grid-centering the line box leaves the ink below centre. */
.wk-cell { width: 32px; height: 36px; border-radius: var(--r-chip); display: grid; place-items: center; padding-bottom: 3px; font-size: 14px; transition: transform .2s; }
.wk-clean { background: var(--accent); color: #fff; box-shadow: 0 2px 0 var(--accent-deep); }
.wk-tips { background: var(--ink); color: var(--card); box-shadow: 0 2px 0 #16130f; }
.wk-miss { border: 1px dashed var(--dash); color: #c3b7a0; }
.wk-none { border: 1px solid var(--line-2); color: #d8cfbc; background: var(--inset); }
/* the today cell drops the ceramic bottom drop-shadow: with the green outline hugging the cell,
   the shadow filled the bottom outline-gap and left the top gap open, so the amber box read as
   sitting high/off-centre inside the frame. The outline + scale already mark it as "today". */
.wk-today { outline: 2px solid var(--good); outline-offset: 2px; transform: scale(1.08); box-shadow: none; }
.week-init { font-size: 9px; letter-spacing: .5px; color: var(--faint-2); }
.week-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--soft-border); }
.week-streak { font-size: 12px; font-weight: 600; color: var(--accent-ink); }
.week-legend { display: flex; gap: 10px; font-size: 9.5px; letter-spacing: .5px; color: var(--faint-2); }

/* share panel (option A) — same soft-panel idiom as .week-panel; round ceramic icon
   buttons matching the rack-tile treatment, copies as ghost buttons */
.share-panel { position: relative; margin-top: 14px; padding: 14px 16px; background: var(--soft); border: 1px solid var(--soft-border); border-radius: var(--r-btn); }
/* B-053: the tips used to be native `title` attributes, and the ~1-2s delay before one appears
   (longer still on the first hover) belongs to the BROWSER — it is not settable from CSS or HTML.
   So the owner's "takes too long" could not be tuned, only re-mechanised. One shared bubble,
   timed by app.js and positioned against the PANEL box so it can be clamped inside it and cannot
   clip at 320px. Purely visual and aria-hidden: each control's aria-label remains its accessible
   name. Shown only where a real hover pointer exists, so a tap cannot strand a tip on a phone. */
.share-tip {
  position: absolute; z-index: 2; pointer-events: none;
  max-width: 260px; padding: 6px 9px; border-radius: var(--r-mini);
  background: var(--ink); color: var(--card);
  font: 400 11.5px/1.35 var(--ui); text-align: center;
  box-shadow: 0 6px 16px -6px rgba(60,45,20,.55);
  opacity: 0; transition: opacity .1s var(--ease);
}
.share-tip.show { opacity: 1; }
.share-label { font-size: 10px; font-weight: 700; letter-spacing: 2px; color: var(--accent-label); text-align: left; }
.share-ics { display: flex; justify-content: center; gap: 12px; margin-top: 12px; }
.share-ic {
  width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center;
  background: var(--tile); border: 1px solid var(--tile-border); box-shadow: var(--sh-mini);
  color: var(--ink-med); cursor: pointer; padding: 0; transition: transform .1s var(--ease), box-shadow .1s;
}
.share-ic svg { width: 18px; height: 18px; fill: currentColor; }
.share-ic:active { transform: translateY(2px) scale(.96); box-shadow: none; }
@media (hover: hover) { .share-ic:hover { color: var(--ink); } }
/* CR-034: the primary share path. Full width and ink-filled so it dominates the network icons
   below it — r/sumhunt Rule 4 wants plain results in the Daily Thread, so the rule-aligned action
   must be the obvious one. flex:none because .btn is built to stretch inside a flex row. */
/* B-042: the row's own copy. The icons alone did not say what tapping r/sumhunt DOES — and
   "Share to today's thread" over-promised, since the app copies and opens while the PLAYER pastes
   (Reddit cannot pre-fill a comment). Sits above the icons so it is read before the tap. */
/* B-052: the helper line had 0px above it and 12px below (measured in BOTH end states), so it was
   glued to the icon row while the Copy buttons sat close enough to read as one undifferentiated
   block. It gets air above so it is legible as the icon row's caption, and the gap below grows so
   the Copy buttons read as a SEPARATE action group. Owner-reported after B-048 made the Copy row
   visible on the revealed sheet, but the tightness was always there in both states. */
.share-help { margin: 8px 0 10px; font-size: 12.5px; line-height: 1.45; color: var(--muted); }
.share-help b { color: var(--accent-deep); font-weight: 600; }
/* CR-047: the come-back-empty-handed recovery. Sits directly under the network row because that is
   where the player left from, and reads as a quiet offer rather than an error — nothing has gone
   wrong from their point of view, they just need the text again. */
/* B-064: this was a bordered block with its own sentence and a full-width button — ~104px, which
   pushed the result sheet past the viewport on every phone size, including a tall iPhone where it
   previously fit exactly. The sheet is deliberately tuned to FIT (see SHORT VIEWPORTS below); the
   scrollbar is B-007's safety net, not the normal experience. Same job, one quiet line. */
.share-recover { margin-top: 10px; text-align: center; }
.recover-link {
  background: none; border: none; cursor: pointer; padding: 4px 0;
  font: 400 12.5px var(--ui); color: var(--muted);
  text-decoration: underline; text-underline-offset: 3px;
}
@media (hover: hover) { .recover-link:hover { color: var(--ink); } }

.share-copies { display: flex; gap: 10px; margin-top: 16px; }   /* B-052: separate action group */
.share-copies .btn { padding: 11px 0; font-size: 13.5px; }

/* feedback touchpoints (delta v3 / D-29) ----------------------------------- */
/* §1 dictionary appeal — quiet underlined text link under the rejection status */
.appeal-row { text-align: center; margin-top: 6px; }
.appeal-link {
  background: none; border: none; cursor: pointer; padding: 0;
  font: 400 12.5px var(--ui); color: var(--muted);
  text-decoration: underline; text-underline-offset: 3px; transition: transform .1s var(--ease);
}
.appeal-link:active { transform: scale(.97); }

/* §2 post-solve rating row — ask → comment → done */
.rate-row { margin-top: 14px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--r-btn); }
.rate-ask { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.rate-label { font-size: 13px; color: var(--muted); text-align: left; }
.rate-pills { display: flex; gap: 8px; flex: 0 0 auto; }
.rate-pill {
  padding: 7px 14px; border-radius: 999px; border: 1.5px solid var(--line-strong);
  background: var(--tile); font: 600 12.5px var(--ui); color: var(--muted);
  cursor: pointer; transition: transform .1s var(--ease); white-space: nowrap;
}
.rate-pill.up { color: var(--ink-med); }
.rate-pill:active { transform: scale(.95); }
.rate-comment { display: flex; gap: 8px; }
.rate-input {
  flex: 1; min-width: 0; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px;
  background: var(--inset); font: 400 13.5px var(--ui); color: var(--ink);
}
.rate-input::placeholder { color: var(--faint); }
.rate-send {
  padding: 10px 18px; border-radius: 10px; border: 1px solid var(--ink);
  background: var(--ink); color: var(--card); font: 600 13.5px var(--ui); cursor: pointer;
  transition: transform .1s var(--ease);
}
.rate-send:active { transform: scale(.96); }
.rate-done { text-align: center; font: 600 13px var(--ui); color: var(--good); animation: tl-chip .3s ease both; }

/* §3 feedback form sheet */
.fb-scrim { z-index: var(--z-help-btn); }
.fb-cats { display: flex; gap: 8px; margin-top: 16px; }
.fb-cat {
  flex: 1; padding: 8px 0; border-radius: 999px; border: 1.5px solid var(--line-strong);
  background: var(--tile); font: 600 12.5px var(--ui); color: var(--ink-med);
  cursor: pointer; transition: transform .1s var(--ease), background .15s, color .15s, border-color .15s;
}
.fb-cat[aria-pressed="true"] { background: var(--ink); border-color: var(--ink); color: var(--card); }
.fb-cat:active { transform: scale(.95); }
.fb-text {
  width: 100%; height: 88px; resize: none; margin-top: 12px; padding: 11px 12px;
  border: 1px solid var(--line); border-radius: 10px; background: var(--inset);
  font: 400 13.5px var(--ui); color: var(--ink);
}
.fb-text::placeholder { color: var(--faint); }
.fb-thanks { text-align: center; padding: 10px 0 6px; animation: tl-chip .3s ease both; }

.sheet-note { font-size: 12.5px; color: var(--faint-2); margin: 14px 0 0; }
.sheet-actions { display: flex; gap: 10px; margin-top: 16px; }
/* SHORT VIEWPORTS — the result sheet is a constant ~710px, which fits a tall phone but scrolls
   on anything with a visible viewport under ~734px: an iPhone 15 with Safari's toolbars showing,
   any phone in landscape, a Pro Max with the URL bar expanded. B-007 made it SCROLLABLE so the
   player could never be trapped; this makes it FIT, so the scrollbar is the safety net rather
   than the normal experience. Keyed on HEIGHT because that is the actual constraint — nothing
   here changes on a tall screen. No content removed: only rhythm, and the ✦ mark which the
   title already carries the moment for. */
@media (max-height: 800px) {
  .sheet { padding-top: 18px; padding-bottom: calc(20px + env(safe-area-inset-bottom)); }
  .sheet-mark { font-size: 26px; }
  .sheet-title { font-size: 27px; margin-top: 2px; }
  .sheet-sub { font-size: 15px; margin-top: 6px; }
  .sheet-stats { margin-top: 12px; gap: 18px; }
  .stat-num { font-size: 21px; }
  .week-panel { margin-top: 13px; padding: 11px 14px; }
  .week-row { margin-top: 8px; }
  .share-panel { margin-top: 10px; padding: 11px 14px; }
  .rate-row { margin-top: 10px; padding: 9px 12px; }
  .sheet-note { margin-top: 10px; }
  .sheet-actions { margin-top: 12px; }
}
.sheet-actions.col { flex-direction: column; }
.sheet-actions .btn.primary { flex: 1.4; }
.sheet-actions .btn.ghost { flex: 1; }
.sheet-actions.col .btn { flex: none; width: 100%; }

/* Desktop (≥640px): pop-ups become a centered modal over the card, instead of a
   sheet stuck to the bottom of the browser window (which detaches from the centered
   card). Mobile keeps the native bottom sheet above. */
@media (min-width: 640px) {
  .sheet-scrim { place-items: center; }
  .sheet {
    width: min(432px, calc(100vw - 48px));
    max-height: calc(100dvh - 48px);
    overflow-y: auto;
    border-radius: 22px;
    border: 1px solid rgba(43,39,35,.08);
    padding: 28px 26px;
    box-shadow: 0 30px 80px -22px rgba(43,39,35,.55), 0 12px 34px -16px rgba(43,39,35,.4);
    animation: pop-in .26s cubic-bezier(.2,.8,.2,1);
  }
}
@keyframes pop-in { from { transform: translateY(14px) scale(.97); opacity: 0; } }

/* how to play -------------------------------------------------------------- */
.help-sheet { text-align: left; }
.help-sheet .sheet-title { font-size: 30px; }
/* B-035 / D-44: the cadence line sits tight under the title (it belongs to the title, not to the
   steps), inheriting .sheet-sub's italic serif voice from the result sheet — one voice for
   "the game speaking about itself" on both sheets. The steps' own 18px top margin then gives the
   pair room before instruction begins. */
.help-cadence { margin: 4px 0 0; }
.help-steps { list-style: none; margin: 18px 0 22px; padding: 0; display: flex; flex-direction: column; gap: 15px; }
.help-steps li { display: flex; gap: 12px; font-size: 14.5px; line-height: 1.5; color: var(--ink-soft); }
/* B-037: the digit gets a FIXED gutter — the serif glyphs measure 5/8.1/7.4px, and without a
   fixed width each step's text column started wherever its digit ended (x=41/44.1/43.4). A
   numbered column aligns on the gutter, never on the marker glyph's own width. */
.help-n { font-family: var(--display); font-size: 20px; line-height: 1.2; color: var(--accent); width: 12px; flex: none; }
/* B-037: block, not inline-block — inline the chip landed wherever the sentence happened to wrap
   (step 2's at the text column, step 3's mid-line at x=152.6). Block + fit-content puts every
   example on its own line at the column's left edge, the same place in every step. */
.help-eg { display: block; width: fit-content; margin-top: 6px; font-family: var(--display); color: var(--accent-ink); background: var(--soft); border-radius: var(--r-mini); padding: 3px 9px; }

/* toast -------------------------------------------------------------------- */
/* CR-046 — the Reddit pre-step (touch only; desktop is served by the hover tip).
   position: fixed so it is anchored to the VIEWPORT, not to the result sheet, which is a scroll
   container and would clip it — the mistake B-058 and B-038 both came from. z-index sits above the
   result sheet (--z-result 22) but below the toast (--z-toast 30), so a message can still surface
   over it. Uses the card ground and display face rather than inventing a new look. */
.prestep-scrim {
  position: fixed; inset: 0; z-index: 28;
  display: grid; place-items: center; padding: 20px;
  background: rgba(43, 39, 35, .38);
}
.prestep {
  width: min(340px, 100%); background: var(--card); color: var(--ink);
  border-radius: var(--r-card); padding: 22px 20px calc(18px + env(safe-area-inset-bottom));
  box-shadow: 0 24px 60px -20px rgba(43, 39, 35, .55);
  animation: tl-pop .22s var(--ease) both;
}
.prestep-title { margin: 0; font-family: var(--display); font-size: 24px; font-weight: 400; line-height: 1.15; }
.prestep-steps { margin: 12px 0 0; padding-left: 20px; display: grid; gap: 8px; font-size: 13.5px; line-height: 1.45; color: var(--ink-soft); }
.prestep-actions { display: grid; gap: 8px; margin-top: 18px; }

/* B-058: FIXED, not absolute — this is B-038's lesson applied to the surface it missed. As
   `absolute` the toast anchored to #app (the card), so `bottom` measured up from the CARD's bottom
   edge; the card outgrows a phone screen, so every toast landed below the fold and no phone player
   ever saw one. Measured at 375x667: 166px off-screen. Desktop was fine only because the card ends
   above the fold there, which is why this looked like a mobile-only mystery. The safe-area inset
   keeps it clear of the home indicator. `.coach` and `.sim-panel` were already viewport-anchored;
   this was the one left behind. */
.toast { position: fixed; left: 50%; bottom: calc(26px + env(safe-area-inset-bottom)); transform: translateX(-50%); background: var(--ink); color: var(--card); padding: 10px 16px; border-radius: var(--r-mini); font-size: 13.5px; font-weight: 600; z-index: var(--z-toast); box-shadow: 0 8px 24px -10px rgba(43,39,35,.5); }

/* simulation dev panel (only present in ?sim=1) — a fixed viewport-bottom control bar */
.sim-panel { position: fixed; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 100; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: center; max-width: calc(100vw - 24px); background: var(--ink); color: var(--card); padding: 8px 12px; border-radius: 12px; box-shadow: 0 12px 34px -12px rgba(0,0,0,.6); font: 500 12px var(--ui); }
.sim-tag { font-weight: 700; letter-spacing: .12em; color: #e8a33d; }
.sim-date { font-variant-numeric: tabular-nums; }
.sim-streak { opacity: .8; }
.sim-btn { background: #4a4237; color: var(--card); border: none; border-radius: 8px; padding: 6px 10px; font: 600 12px var(--ui); cursor: pointer; }
.sim-btn:active { transform: scale(.96); }

/* tutorial coach ----------------------------------------------------------- */
/* B-038: FIXED, not absolute. Absolute made this a child of .card, so the bubble's `bottom` was
   measured from the CARD's bottom edge — and once the equation and solved state render the card
   grows to ~945px, pushing the bubble 101px below a 390x844 viewport and 328px below a 320x568
   one. Next and Skip live inside that bubble, so a first-time player could be left with no visible
   way to advance or leave the tutorial. .coach-dim was already `fixed`; this makes the pair agree,
   and it is what lets `bottom: env(safe-area-inset-bottom)` mean the phone's home indicator rather
   than an arbitrary point inside a scrolling card. */
.coach { position: fixed; inset: 0; z-index: var(--z-coach); pointer-events: none; }
.coach-bubble {
  /* centered via margin-auto, NOT translateX — the tl-chip entrance animation animates
     transform (fill-mode both), which would clobber a centering transform. */
  position: absolute; left: 0; right: 0; margin: 0 auto; bottom: calc(20px + env(safe-area-inset-bottom));
  width: min(432px, calc(100% - 28px));
  background: var(--ink); color: var(--card); border-radius: 16px; padding: 16px 18px;
  box-shadow: 0 12px 36px -10px rgba(43,39,35,.55); pointer-events: auto;
  animation: tl-chip .25s ease both;
}
.coach-text { margin: 0; font-size: 14.5px; line-height: 1.5; }
.coach-text b { color: var(--accent); }
.coach-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 12px; }
.coach-skip { background: none; border: none; color: inherit; opacity: .6; font: 400 13px var(--ui); cursor: pointer; padding: 6px 0; }
.coach-next { flex: none; padding: 9px 22px; border-color: var(--accent); background: var(--accent); color: #fff; }
.coach-dim { position: fixed; inset: 0; background: rgba(43,39,35,.5); z-index: calc(var(--z-coach) - 2); }
.coach-spot { position: relative; z-index: calc(var(--z-coach) - 1); box-shadow: 0 0 0 4px var(--accent), 0 0 0 9999px rgba(43,39,35,.5); border-radius: 12px; animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 4px var(--accent), 0 0 0 9999px rgba(43,39,35,.5); } 50% { box-shadow: 0 0 0 7px var(--accent), 0 0 0 9999px rgba(43,39,35,.5); } }

/* mobile compact (D-31): the 18-tile rack adds a third row (~70px), which pushed the
   Submit row below the fold on a 375×812 phone under the old "roomy" rhythm. Moderate
   tightening only — the dial stays the hero — so the core loop (dial · rack · submit)
   shares one screen again. */
@media (max-width: 639px) {
  .dial-section { margin-top: 16px; }
  .dial-wrap { width: 150px; height: 150px; }
  .dial-num { font-size: 58px; }
  .dial-num.d4 { font-size: 42px; }
  .dial-num.d5 { font-size: 34px; }
  .dial-label { font-size: 9px; letter-spacing: 1.6px; }
  .dial-sub { font-size: 11.5px; }
  .clue-card { margin-top: 14px; padding: 10px 16px; }
  .clue-text { font-size: 15px; }
  .equation { margin-top: 10px; }
  .staging { margin-top: 8px; min-height: 30px; }
  .rack { margin-top: 12px; gap: 8px; }
  .tile { aspect-ratio: 1.02; }  /* font-size is tile-relative now (B-004) */
  .ops { margin-top: 12px; }
  .actions { margin-top: 14px; }
  .status-line { margin-top: 8px; }
}

/* compact layout — web / desktop (≥640px wide): fit a 13" laptop on one screen,
   no vertical scroll. Phones (<640px) keep the roomy "mobile app" rhythm. Tighter
   vertical spacing + a smaller dial; type/tiles stay crisp (no scale hacks). */
/* Comfortable compact — web/desktop (≥640px wide). A moderate compaction of the
   roomy mobile layout: the dial stays the hero (150px / 58px number), and vertical
   rhythm is VARIED (bigger breaks around the dial + into the input zone, tighter
   within groups) rather than uniformly squeezed. Fits a normal 13" (~750px). */
@media (min-width: 640px) {
  .page { padding: 12px 16px; }
  .card { padding: 16px 24px 18px; }
  .wordmark { font-size: 32px; }
  /* Desktop keeps its designed 28px letter: the card is a fixed 432px here, so the tile is
     always ~57px and the glyph cannot outgrow it. The tile-relative rule (B-004) exists for
     the phone range, where the tile shrinks; pinning this back avoids drifting the desktop
     design (the proportional curve would have made it ~26px). */
  .tile { font-size: 28px; }
  .dial-section { margin-top: 14px; }
  .dial-wrap { width: 144px; height: 144px; }
  .dial-num { font-size: 56px; }
  .dial-num.d4 { font-size: 40px; }
  .dial-num.d5 { font-size: 32px; }
  .dial-label { font-size: 9px; letter-spacing: 1.5px; }
  .dial-sub { font-size: 11.5px; }
  .clue-card { margin-top: 14px; padding: 10px 16px; }
  .clue-text { font-size: 15px; }
  .equation { margin-top: 9px; }
  .equation.has-terms { min-height: 52px; padding: 10px; }
  .staging { margin-top: 8px; min-height: 30px; }
  .rack { margin-top: 12px; gap: 8px; }
  .tile { aspect-ratio: 1.05; }
  .ops { margin-top: 10px; }
  .actions { margin-top: 14px; }
  .status-line { margin-top: 8px; }
  .tip-log { margin-top: 8px; }
  .btn.tip { margin-top: 8px; }
  .give-up-btn { margin-top: 10px; }
}

/* Extra-tight — only on genuinely short viewports (a small laptop window, or the
   oldest 13" scaling where the usable height is ~715px). Re-tightens spacing and
   shrinks the dial so it still fits without scroll; normal screens never see this. */
@media (min-width: 640px) and (max-height: 800px) {
  .page { padding: 8px 16px; }
  .card { padding: 12px 24px 14px; }
  .wordmark { font-size: 31px; }
  .dial-section { margin-top: 8px; }
  .dial-wrap { width: 114px; height: 114px; }
  .dial-num { font-size: 44px; }
  .dial-label { font-size: 8.5px; letter-spacing: 1.2px; }
  .clue-card { margin-top: 10px; padding: 8px 16px; }
  .equation { margin-top: 7px; }
  .equation.has-terms { min-height: 50px; padding: 9px; }
  .staging { margin-top: 7px; min-height: 30px; }
  .rack { margin-top: 8px; gap: 7px; }
  .tile { aspect-ratio: 1.08; }
  .ops { margin-top: 8px; }
  .op { height: 38px; }
  .actions { margin-top: 10px; }
  .status-line { margin-top: 7px; }
  .tip-log { margin-top: 6px; gap: 5px; }
  .tip-item { padding: 7px 11px; font-size: 12.5px; }
  .btn.tip { margin-top: 7px; padding: 10px; }
  .give-up-btn { margin-top: 8px; }
}

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