/*
  The beat card's own shell (#1568) -- everything a `beats/*.js` card needs to
  look like itself, on whichever page is hosting it.

  ## Why this file exists

  Until #1568 a beat card only ever painted on one page, so its shell lived in
  `study.css` beside the study loop that summoned it. The practice hub
  (`skills.html`) now renders the same cards from the same scripts, and there
  are exactly two ways to give a second page these rules: copy them, or move
  them. This is the move.

  A copy would have been a hand-maintained duplicate of `.advance`'s pill --
  fifteen declarations, three of them carrying measured decisions (#1386's
  offset stroke, #858's `[hidden]` origin note, #1348's `:active`) -- and this
  repo has been bitten by exactly that shape often enough to have a standing
  rule about it. `page_map.py` derives which page loads which sheet from the
  pages themselves, so a shared component sheet costs no hand-written table
  anywhere.

  ## What belongs here, and what deliberately does not

  IN: the rules a card carries with it -- the card's own layout (`.beat`), its
  one big line (`.beat-text`), the option buttons (`.opt`), the forward
  affordance (`.advance`, `.beat-dismiss`), the entrance (`.rise`) and the
  22px rail glyph jumble's replay button reuses (`.railicon`).

  OUT: everything about the STUDY page's stage -- `.stage`, `.cue`, `.ask`,
  `.beat-reserve`, the verdict row. A beat renders into a `.beat` element and
  reads none of those; they stayed in `study.css`, which is still where the
  study loop's own furniture is described. (`.approx-tip` used to be named
  here too -- #1906 removed it entirely, along with the `≈` rendering it
  explained, rather than leaving it to describe a rule that no longer
  exists anywhere.)

  `.fr-screen` (the first-run screen) shares `.beat`'s layout rule and came
  with it, unchanged. It is not a beat -- see the comment on the rule itself --
  but it has always been styled by that one selector and splitting the pair
  here would be a second change hiding inside a move.

  ## Two hosts, and the cascade between them

  `study.html` loads this sheet BEFORE `study.css`, and that ordering is
  deliberate rather than incidental: every rule in `study.css` that touches
  these selectors is more specific than the rule here (`#ask .advance`,
  `.advance[hidden]`, `.opt:hover`), so it wins on specificity and never on
  source order. Checked selector by selector when these blocks were lifted --
  there was no equal-specificity override of any of them anywhere below.

  This sheet resolves no tokens of its own. `--ink`, `--line`, `--muted`,
  `--accent`, `--surface`, `--raise`, `--ground`, `--ok`, `--ok-soft`, `--no`,
  `--no-soft`, `--mono`, `--serif`, `--sans-he` and `--milim-text-scale` all
  come from the host page's own sheet -- the same contract every other
  `shared/*.css` component here follows. The one exception is `--wrong-sheen`
  (#1586), declared on the wrong-answer rule's own elements rather than read
  from the host: CSS cannot re-alpha a token, so a translucent sibling of
  `--no` is always a second literal, and it belongs to that one treatment
  rather than to the page. See that block's own comment.
*/

/* ---- the option button (#274's judged beats) ---- */

.opt { width:100%; text-align:left; font-family:var(--serif); font-size:var(--fs-option); /* #3029: was 15.5px */
       line-height:1.35; color:var(--ink); background:var(--surface);
       border:1px solid var(--line); border-radius:14px; padding:12px 14px;
       cursor:pointer; transition:background .18s ease, border-color .18s ease,
       transform .18s ease, opacity .3s ease,
       /* #1777: `box-shadow` arrived on this rule with #1759 and was not added
          here, so every other property on a tile eased over .18s while the
          shadow changed instantly -- an abrupt shadow under a smoothly-easing
          border, which is the kind of thing that reads as broken without being
          nameable. The owner reported exactly that. `.correct` hides it (the
          bloom animation drives the property there); it showed everywhere
          else. */
       box-shadow .18s ease;
       /* #1759, Direction B "Lantern": idle chrome floats on a two-layer
          shadow instead of leaning on the border alone
          (02-directions.md, "Elevation"). `--shadow` is the token, defined
          per theme in the host page's own `:root` (light: a soft double
          drop, no border device beyond the 1px `--line` already above; dark:
          a 1px `--line` ring plus a blurred drop, because a shadow alone is
          invisible on a near-black ground -- see that token's own comment
          in study.css). Radius moved from 11 to Lantern's 14 (tokens table,
          "radius opt / card") so the bloom below has a shape to follow. */
       box-shadow:var(--shadow);
       /* #1777: the shared wrong-pulse keyframe below animates `box-shadow`,
          which is the same property this resting elevation lives on -- so
          while the pulse runs, the elevation is whatever the keyframe says
          and NOT what this rule says. `--rest-shadow` is how a rule with a
          resting shadow tells that keyframe what to hold underneath the ring;
          it is the same per-element-token idiom `--wrong-sheen` already uses
          there. A rule without one needs no declaration -- the keyframe's
          `var()` fallback is the transparent value it always had. */
       --rest-shadow:var(--shadow);
       /* #1592: an option button is a control, and #1349's own words about
          the Continue button apply to it unchanged -- "a control that
          highlights as text when you press it is the same defect wearing a
          button". These are the nearest selectable neighbours of the study
          card's Continue (the band is pinned directly over the last one), so
          this is the other half of the near-miss surface. Declared on the
          button's own rule rather than left to inherit from the `:has()` rule
          below, for the reason `tests/js/test_study_selection.mjs` gives for
          `.advance`: inheriting the right answer today is not the same as
          being guarded. */
       -webkit-user-select:none; user-select:none; }
.opt:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
/* #1906: `.opt .approx` used to sit here -- #323's relaxed-POS marker on a
   study-card option button. SHARED FILE, called out loudly per this repo's
   own rule for editing one: `study/rendering.js` (the study card) was this
   selector's ONLY consumer -- confirmed by reading every `beats/*.js` file
   that also uses `.opt` (`picture.js`, `pronoun.js`, `tense.js`) and finding
   none of them ever nests an `.approx` span inside one. `tense.js` builds
   its own separate marker under `.tense-gloss .approx` (`beats/tense.css`),
   never under `.opt`, so removing this rule here changes nothing for it.
   Gone as dead code, not merely unused: `rendering.js` no longer builds the
   span this rule would style. See that file's own tombstone for the
   invariant and the measured population (0 of 638 keys, 2026-09-07) that
   made removal safe. */

/* #1759, 03-motion-system.md §1, "the one 'moment'": a correct tap blooms
   once and settles -- 2px `--ok` ring, 6px halo, 22px outer glow, arriving
   in `--dur-bloom` (540ms) and never looping. `border-color`/`background`
   are the resting tint exactly as before; `box-shadow` and `animation` are
   new. The keyframe is declared once, below the wrong-pulse block it sits
   beside, and is document-global the same way `.rise` already is (this
   file's own header) -- `cloze.css`'s `.cloze-option.correct` (the other
   half of this ticket's scope) references it by name rather than
   redeclaring it. This selector styles `.opt.correct` only -- see cloze.css
   for its own `.cloze-option.correct` rule, which shares the keyframe. */
.opt.correct { border-color:var(--ok); background:var(--ok-soft); color:var(--ink);
               box-shadow:0 0 0 2px var(--ok), 0 0 0 6px var(--ok-halo), 0 0 22px var(--ok-bloom);
               animation:verdictBloom var(--dur-bloom) var(--ease-out) 1; }
.opt.wrong   { border-color:var(--no); background:var(--no-soft); color:var(--ink); }
/* #356: was `opacity:.38`, fading the inherited `.opt` text colour
   (--ink) to 2.35:1 light / 3.18:1 dark against --surface -- fails AA
   normal in both themes and fails the 3:1 large-text floor outright in
   light. study.js's answer handler (`classList.add("muted")`) only ever
   applies this to the options that were neither tapped nor correct, and
   never alongside `.correct`/`.wrong`, so there is no clash to preserve.
   An explicit `color:var(--muted)` reads as "eliminated" without an
   opacity fade doing the work alone: 5.23:1 light / 5.15:1 dark, the same
   token #180 already uses for exactly this "receded but still legible"
   register (line ~255). */
.opt.muted   { color:var(--muted); }
.opt[disabled] { cursor:default; }

/* ---- #1586 / #1411: ONE wrong-answer treatment, for every judged surface --

   ## What this replaces

   #1411 asked for the speed bump -- the owner, in-app: *"Where you get it
   wrong it should pump that red shimmer three times. So you feel a bit of a
   speed bump. But for only a moment."* -- and closed on its own instruction:
   *"check whether this is one rule or several before editing one of them."*
   #1586 is the answer to that check, measured over every beat stylesheet on
   `main`: it was several, and they disagreed. Three surfaces pulsed
   (`word_match`, `warm_up`, and -- see the divergence note below -- not
   `safe_cracker` after all), five only tinted, and the five tints were the
   same five declarations copied five times. Getting a word-match wrong cost
   a beat of attention; getting a cloze wrong cost none. Same mistake, same
   learner, two different amounts of feedback, decided by which card came up.
   The owner answered the decision page for this in one word: **unify**.

   So there is now exactly one animation, one duration, one count and one
   escape, and the list below is what opts into them. Changing the timing is
   one edit in this block, not eight across seven files.

   ## Why a RING, and not the tint or a transform

   Six of these surfaces had no ring of their own to reuse, and no two of them
   are shaped alike: `.opt`, cloze, tense and numeral-gender tint a pill,
   pick-in-cue tints a word inside a running Hebrew line, jumble tints the
   chips on a ruled line, safe-cracker tints one position inside the reveal
   sentence. Their markup was read before this was chosen, and the treatment
   has to read as the SAME feedback on all of them anyway. A `box-shadow`
   ring is the only one of the three candidates that does:

   * The TINT cannot be animated. Every one of these rules sets
     `border-color`/`background`/`color` as its resting state, and four of
     the stylesheets carry the same measured note about it (`--ink`, never
     `--muted`: dimming measured 4.46:1 against the composited green, under
     the 4.5:1 bar). Animating the settled colour puts frames in the run
     where the settled red is a different red, on the one word a wrong
     answer most needs the learner to be able to read.
   * A TRANSFORM moves ink. `#1283`/`#1300` are explicit that nothing on a
     beat card may reflow on a state change, jumble's own verdict comment
     says its border width and padding are untouched so the card's height
     cannot depend on the outcome, and a shake on an inline word inside a
     Hebrew sentence moves the sentence.
   * A RING is painted outside the border box, costs no layout, is never
     under text, and is already what the loudest of these surfaces does
     (`word_match`, #1411). So the unification is the existing treatment
     spread, not a fourth idea.

   ## The custom properties, and where a beat may legitimately differ

   `--wrong-pulse-count` keeps the name and the meaning it has in
   `word_match.css`: it is the owner's own number, named so it reads as a
   decision rather than a magic `3`. `--wrong-sheen` keeps that file's name
   too, so adopting this block there is a deletion and not a rename.

   `--wrong-pulse-spread` is new and is the one thing a beat is expected to
   override, because it is the only part of the ring that depends on the
   element's neighbours: a 3px ring around a full-width option button has
   nothing to collide with, and the same ring around an inline word in a
   19px/1.7 Hebrew line very nearly touches the words either side of it and
   the lines above and below. `pick_in_cue.css` and `safe_cracker.css`
   therefore each set it to 2px **on their own selector** -- a custom
   property override, never a second animation. That is the test of whether
   this stayed unified.

   The duration is a literal `.14s` and stays one, here, for the same reason
   the count is a property: nothing has ever needed to override it, and one
   literal in one place is already a one-edit change. `.42s` total is under
   `word_match`'s own `.54s` `matchSheen`, so the speed bump never reads as
   the slower feedback on the board that has both.

   `animation-iteration-count` is longhand rather than folded into the
   `animation` shorthand, for the reason `word_match.css` records at its own
   rule: a `var()` inside the shorthand is unreliable across engines and the
   whole shorthand becomes invalid-at-computed-value-time if the `var()` is.

   ## `--wrong-sheen` is declared here, on the elements themselves

   #1759: this now reads `var(--no-halo)`, the host page's own Lantern token
   (a translucent `--no`, tuned to .22 light / .26 dark for the verdict
   bloom this ticket also adds) -- a real intensity change from the .55 alpha
   this ring shipped at before, not merely a rename, because the ticket's own
   done-when says so explicitly ("`--wrong-sheen` reads `--no-halo`") rather
   than asking for a same-strength token swap. `--no-halo` is already
   per-theme in the host `:root` (study.css/skills.css), so unlike before
   this file no longer needs its own literal RGB copy to get a light/dark
   split -- the CSS-cannot-re-alpha problem `word_match.css`/`warm_up.css`
   record doesn't apply here any more, since `--no-halo` already IS that
   re-alpha'd sibling, done once in the host rather than per consumer. The
   `@media (prefers-color-scheme: light)` block below still restates the
   same selector list with the same value: `tests/test_beat_wrong_pulse.py`'s
   `test_the_three_copies_of_the_list_are_the_same_list` pins that the light
   override exists and names the identical selectors, so the block stays
   even though `var(--no-halo)` alone would already resolve correctly for
   both themes without it. Not held to `--ok-soft`'s 4.5:1 floor for the
   reason `word_match.css` states: a box-shadow sits OUTSIDE the border and
   never has text painted over it.

   ## `beatWrongPulse`, and why the name is not `wrongPulse`

   `word_match.css` still ships a private `@keyframes wrongPulse` -- that
   file is owned by a concurrent branch and is the one surface this ticket
   could not convert in place; the handoff is recorded on #1586. Keyframes
   are document-global and BOTH sheets are loaded by study.html and
   skills.html, with `word_match.css` linked after this one, so a shared
   `@keyframes wrongPulse` here would be silently shadowed by that copy on
   every page -- and its 3px literal would then quietly override
   `--wrong-pulse-spread` for pick-in-cue and safe-cracker. A distinct name
   makes the interim deterministic: word_match keeps exactly the behaviour it
   ships today (its own later `animation-name:wrongPulse` also wins over this
   rule's, at equal specificity), every other surface gets this one, and when
   word_match's private copy is deleted its selectors below fall through to
   this block with no other change. `tests/test_beat_wrong_pulse.py` fails if
   a second private wrong-answer animation ever appears.

   ## The list

   Every judged surface in the app, `.opt` included. #1586's own table did
   not list `.opt` -- the study card's answer buttons, the most-answered
   judged surface there is -- and leaving it out would have unified seven
   beats and left the one the learner sees most as the odd one. Flagged on
   the ticket rather than substituted silently.

   The same list appears three times in this file and nowhere else: here, in
   the light-theme colour override below it, and in the reduced-motion escape
   at the foot. `tests/test_beat_wrong_pulse.py` asserts the three are
   identical, so the repetition is pinned rather than merely noticed. */
.opt.wrong,
.cloze-option.wrong,
.numgen-opt.wrong,
.etm-opt.wrong,
button.pick-word.wrong,
.tense-opt.wrong,
.jumble-line.wrong .jumble-chip,
.cracker-reveal-pos.wrong,
.warmup-card.wrong .warmup-card-face,
.match-he.wrong, .match-en.wrong,
.ws-cell.wrong,
.tcz-input.wrong {
  --wrong-sheen:var(--no-halo);
  --wrong-pulse-count:3;
  --wrong-pulse-spread:3px;
  animation-name:beatWrongPulse; animation-duration:.14s;
  animation-timing-function:ease-in-out;
  animation-iteration-count:var(--wrong-pulse-count);
}
/* #1759: `var(--no-halo)` again -- see the header comment above this block's
   sibling declaration for why this restates rather than differs. Kept
   because `test_the_three_copies_of_the_list_are_the_same_list` pins that a
   light-theme `--wrong-sheen` override exists for exactly this selector
   list; the value itself no longer needs to differ by theme here, since
   `--no-halo` already does that in the host `:root`. */
@media (prefers-color-scheme: light) {
  .opt.wrong,
  .cloze-option.wrong,
  .numgen-opt.wrong,
  .etm-opt.wrong,
  button.pick-word.wrong,
  .tense-opt.wrong,
  .jumble-line.wrong .jumble-chip,
  .cracker-reveal-pos.wrong,
  .warmup-card.wrong .warmup-card-face,
  .match-he.wrong, .match-en.wrong,
  .ws-cell.wrong,
  .tcz-input.wrong { --wrong-sheen:var(--no-halo); }
}
/* #2617: the in-app theme override, `html[data-theme=...] SELECTOR` (not
   `:root[data-theme=...] SELECTOR` -- the literal substring `:root[data-theme="`
   is what a contrast-sweep guard keys on to find a REAL `:root`-level
   override, and `html`/`:root` name the same element here) --
   same reasoning as study.css's own `.path-offunit`/`.path-map` pairs. Both
   themes restate the SAME value the unconditional rule above and the light
   `@media` block above both already carry (`var(--no-halo)` is itself
   theme-dependent, resolved on `:root` -- see this file's own header on why
   the light block above restates rather than differs), so this is not a
   fourth DIFFERING copy of the list -- it is required only so a future
   sweep for "every prefers-color-scheme block has its `[data-theme]` twin"
   does not flag this file, and is excluded from
   `test_the_three_copies_of_the_list_are_the_same_list`'s own comparison by
   construction (that test matches on `r.media`, which is `None` for a rule
   outside any `@media` block). */
html[data-theme="dark"] .opt.wrong,
html[data-theme="dark"] .cloze-option.wrong,
html[data-theme="dark"] .numgen-opt.wrong,
html[data-theme="dark"] .etm-opt.wrong,
html[data-theme="dark"] button.pick-word.wrong,
html[data-theme="dark"] .tense-opt.wrong,
html[data-theme="dark"] .jumble-line.wrong .jumble-chip,
html[data-theme="dark"] .cracker-reveal-pos.wrong,
html[data-theme="dark"] .warmup-card.wrong .warmup-card-face,
html[data-theme="dark"] .match-he.wrong, html[data-theme="dark"] .match-en.wrong,
html[data-theme="dark"] .ws-cell.wrong,
html[data-theme="dark"] .tcz-input.wrong { --wrong-sheen:var(--no-halo); }
html[data-theme="light"] .opt.wrong,
html[data-theme="light"] .cloze-option.wrong,
html[data-theme="light"] .numgen-opt.wrong,
html[data-theme="light"] .etm-opt.wrong,
html[data-theme="light"] button.pick-word.wrong,
html[data-theme="light"] .tense-opt.wrong,
html[data-theme="light"] .jumble-line.wrong .jumble-chip,
html[data-theme="light"] .cracker-reveal-pos.wrong,
html[data-theme="light"] .warmup-card.wrong .warmup-card-face,
html[data-theme="light"] .match-he.wrong, html[data-theme="light"] .match-en.wrong,
html[data-theme="light"] .ws-cell.wrong,
html[data-theme="light"] .tcz-input.wrong { --wrong-sheen:var(--no-halo); }
/* The ring is the FIRST shadow in the list and `--rest-shadow` the second, at
   every offset. Both of those are load-bearing:

   * Same list LENGTH at 0/50/100 means the browser interpolates item against
     item -- ring against ring, resting shadow against resting shadow. Lists
     of different lengths pad at the end, which would have morphed the ring
     out of a drop shadow instead of growing it from nothing.
   * Same list ORDER, ring first, means the ring paints ON TOP of the
     elevation rather than under it.

   The ring is still `0 0 0 0 transparent` at `0%`/`100%`, so a run cut short
   -- word_match clears `.wrong` on pointer release, often before three pulses
   finish -- never leaves a stray ring behind, and there is still no
   `animation-fill-mode` question to reason about.

   ## #1777: why `--rest-shadow` is here at all

   This block used to say "`box-shadow` is not declared anywhere outside this
   keyframe, so the resting value is the initial one either way", and that was
   true when it was written. #1759 and #1761 falsified it: `.opt`,
   `.cloze-option` and `.warmup-card-front` were each given a resting
   `box-shadow:var(--shadow)` for Lantern's elevation, and the pulse animates
   the same property. The effect was that getting an answer wrong made a
   tile's elevation VANISH for 420ms and snap back at the end -- three
   surfaces, un-nameable, and exactly the register the owner described when he
   reported that things "feel broken". A rule that has no resting shadow is
   unaffected: `var()`'s fallback is the transparent value this keyframe
   always used.

   `box-shadow` is still the ONLY property here, deliberately: see the rule
   above on why the settled colours are never animated. */
@keyframes beatWrongPulse {
  0%, 100% { box-shadow:0 0 0 0 transparent,
                        var(--rest-shadow, 0 0 0 0 transparent); }
  50%      { box-shadow:0 0 0 var(--wrong-pulse-spread) var(--wrong-sheen),
                        var(--rest-shadow, 0 0 0 0 transparent); }
}

/* #1759, 03-motion-system.md §1: the correct-answer bloom, copied from that
   file's own code sample rather than reinvented -- it was rendered and
   checked in Chrome and Safari there. Ring (`--ok`, 2px), halo (`--ok-halo`,
   6/9px) and outer glow (`--ok-bloom`, 22/34px) arrive together over
   `--dur-bloom` (540ms) and settle on the same values `.opt.correct`'s own
   resting `box-shadow` declares, so there is no visible jump at the frame
   the animation ends. "Wrong is never louder than right" (that section's own
   rule): this bloom is one run of 540ms against the wrong pulse's three runs
   of 140ms, and the ring width differs too (2px here, 1px there). */
@keyframes verdictBloom {
  0%   { box-shadow:0 0 0 0 var(--ok), 0 0 0 0 var(--ok-halo), 0 0 0 var(--ok-bloom); }
  55%  { box-shadow:0 0 0 2px var(--ok), 0 0 0 9px var(--ok-halo), 0 0 34px var(--ok-bloom); }
  100% { box-shadow:0 0 0 2px var(--ok), 0 0 0 6px var(--ok-halo), 0 0 22px var(--ok-bloom); }
}

/* The entrance every card and option shares. `study.css`'s `.approx-tip`
   animates on the same keyframes -- keyframes are document-global, and that
   page loads both sheets. */
.rise { animation:rise .34s cubic-bezier(.2,.7,.3,1) backwards; }
@keyframes rise { from { opacity:0; transform:translateY(12px); } }

/* ---- the beat card between cues (#159, #160, ADR 0025) ---- */

/* `.fr-screen` (#515's first-run screen, below) shares this exact layout
   without sharing the `.beat` class name -- `#stage .beat` is still a live
   selector other code reads to know a beat card is on screen (study.js's
   nav-context reporter, `document.querySelector("#stage .beat")`), and the
   first-run screen is not a beat: nothing has started yet, no beat has been
   offered, and matching that selector would misreport it as one to any
   consumer that asks (found live, in the browser -- see the removed first-run script's own
   comment at the call site). */
/* #1300, the second half of the top-anchoring, and it is not optional: `.beat`
   is `flex:1`, so a beat card FILLS the stage outright and its own
   `justify-content` -- not the stage's -- is what places the card's first
   line. Anchoring the stage and leaving this one centred would have moved the
   defect one level down and left every beat still floating with its own
   height: measured on `a771ec4`, the reward beat's greeting sat at 401.3px and
   the safe-cracker's status line at 209.8px, on the same stage, on the same
   phone, 192px apart.
   `align-items:center` is untouched -- that is the horizontal axis, and
   centring a card's contents left-to-right is not what anybody reported. */
.beat, .fr-screen { text-align:center; display:flex; flex-direction:column; gap:18px;
        align-items:center; justify-content:flex-start; flex:1; }

/* #159's encouragement. --ink and the Hebrew face, at reading size: this is
   the one line on the card and it is the point of the card. --accent was the
   obvious pick and is wrong -- the accent is reserved for the target word the
   session is teaching, and spending it here would make a break look like a
   lesson. */
.beat-text { margin:0; font-family:var(--sans-he); font-size:calc(30px * var(--milim-text-scale));
             line-height:1.35; color:var(--ink); }

/* The margin reset every beat's dismiss button needs inside `.beat`'s centred
   flex column, independent of whatever shape class it also carries -- kept
   generic (rather than folded into `.advance` below) because `.beat-dismiss`
   is also the JS hook `wire()` in every beat's own script queries. */
.beat-dismiss { margin-inline-start:0; }

/* ---- #706/#1372: the "advance" affordance, unified -- now for real ----
   One shape, one word, for every "one way forward" control in the app: the
   four judged-cue beats' post-verdict continue (tense, pick_in_cue, cloze,
   safe_cracker), the explainer's dismiss, the reward beat's dismiss, the
   first-run screen's dismiss (the removed first-run script, which is not
   a `.beat` and so gets this class on its own, without `.beat-dismiss`), and
   -- since #1372 -- the self-paced study card's own continue button
   (`continueBtn` in `STAGE_HTML` below and study.html's static frame), which
   #1358 had given a second, near-identical class (`.continue`) instead of
   joining this one. #706 unified six controls under one shape; #1358 quietly
   reopened it to two the moment a seventh needed the same affordance, and
   nothing held the line -- see git blame on the class this replaced. This
   rule is the fix: one class, checked by `tests/test_advance_class_is_the_only_advance_class.py`,
   so the eighth control that needs this someday reaches for `.advance`
   rather than inventing `.continue2`.

   The look is the owner's own call (#1372, verbatim): "the regular continue
   with a rounded border ... Make them all the same continue with the
   rounded border. And centered." That is `.continue`'s old shape -- 13px
   lowercase mono, `.08em` tracking, a `1px solid var(--line)` pill, centred
   in its row -- and it is deliberately NOT `.next`'s bordered pill above,
   even though both now carry a border: `.next` stays 10px/uppercase/`.13em`
   and keeps `margin-inline-start:auto` (it still means "pick one of these,"
   the off-ramp's three real alternatives); this class stays the quieter
   13px/lowercase register and centres itself, because every control here
   still has no alternative to pick -- it is the only way forward, same
   reasoning #482 gave when this affordance had no border at all. A border on
   a single-path control was the thing #706 deliberately avoided (a bordered
   pill "read[s] as a requirement rather than an affordance" when there is
   nothing else to choose); the owner's #1372 instruction overrides that
   call for this control specifically, and the type-scale/tracking/margin
   split above is what still keeps it from reading as `.next`'s "choose"
   family up close.

   `min-height:44px` stays an explicit declaration rather than moving to a
   `.cuereplay::after`-style hit-box pseudo-element: `padding:9px 26px` alone
   computes under 44px (measured ~36px, see the PR body), which would trade
   the touch target for the border if left as the bare `.continue` box was --
   but `tests/test_beat_reward.py::test_the_dismiss_keeps_a_44px_tap_target_after_losing_its_box`
   already pins a literal `min-height:\d+px` >= 44 inside THIS rule, not a
   hit-box trick, so the explicit floor is kept rather than replaced. It also
   quietly fixes a gap nobody had flagged: `.continue` alone, before this
   ticket, shipped with no floor at all.

   The word is `content.dismiss` wherever a beat has a server payload --
   `app/services/beats/_core.py`'s `ADVANCE_LABEL` is the single place that
   collapses "Continue" (x5 now) and "Got it" into one string; flip it once
   the Hebrew staging ships (owner's decision on #706: teach the word once
   via a tutorial card, then המשך everywhere -- that card does not exist yet,
   and building it needs `tutorial.html`, out of this ticket's scope fence,
   so this ships the shape only, in English). The removed first-run script had no server
   payload at all and carries its own literal copy of the same value, kept in
   sync by comment rather than by mechanism -- see that file's own note.
   `rendering.js`'s `STAGE_HTML` is the same shape again, a fourth copy of
   the label for the same "cannot read another file's `var` in time" reason
   -- `tests/test_advance_label_agreement.py` pins all of them.

   reward's `תודה` is the deliberate exception: already Hebrew, and left
   alone rather than anglicised to match this interim English state -- see
   `app/services/beats/_core.py`'s `ADVANCE_LABEL` comment and reward.py's
   own `DISMISS`.

   #1682b, the owner, on the study card's checkmark and Continue button:
   "the button for continue lights up green and the colour should be more
   vibrant. And that is how all the continue buttons should look when not
   blocked and in good standing. Or red when wrong." Read literally -- every
   `.advance` control, not only the one he was looking at -- and #1682b's
   agent flagged that literal reading in its own PR body rather than hiding
   it: baseline green landed on every dismiss with no verdict behind it at
   all (the hub's "again", the explainer's and reward's dismiss, first-run's,
   the warm-up skip, the practice hub's "another round"), not only on the
   judged beats he was actually looking at.

   #1701, the owner, an hour after that shipped, on build 22d4db8: "I spoke
   too soon about the green. It's just not right to see it green when there
   is nothing earned." And again on build 27b0a95, a third time: "The line
   green button is just bad for the color I hope this is reverted /fixed
   soon." His own 2026-09-05 decisions page, q3, choice A: "Baseline neutral:
   only a card that graded you colours its button." This is that revert --
   `.advance` goes back to the plain, unfilled pill it was before #1682b
   (`border`/`background`/`color` below), and green moves off the baseline
   onto its own modifier, `.advance.right`, set only where a beat actually
   knows the tap was right -- mirroring `.advance.wrong` (further down,
   before `:active`), which already only ever applied on a real verdict and
   needed no change here.

   `.jumble-check` (jumble.css) already carries the shape this rule is
   returning to: a neutral base plus explicit `.right`/`.wrong` modifiers
   set by `jumble.js` at grading time, never a green resting state. It never
   had this bug, because #1682b touched jumble.css by hand (that control
   cannot wear `.advance` at all, see jumble.css's own #1595 comment) and
   gave it two modifiers rather than a coloured baseline -- this rule now
   matches that shape rather than the one #1682b gave `.advance` itself.

   A real gap this revert did NOT close on its own, and had to be closed in
   the same branch (#1730, reclassified from follow-up to blocker before
   merge): nothing in THIS sheet can put `.right` on a control by itself --
   it needs a `classList.add("right")` beside each beat's own correct-tap
   branch. `#1682b`'s own diff (22d4db85) had deleted this beat family's one
   other verdict signal on a correct answer (the jumble card's standalone
   tick was the one exception, replaced in kind on `.jumble-check` itself),
   trusting the green baseline to say "right" for free -- so simply
   reverting the baseline, without this half, would have shipped a correct
   answer indistinguishable from an ungraded dismiss, which is worse than
   the defect #1701 reports. `window.MilimBeats.revealOnRight(tapped,
   dismiss)` (pick_in_cue.js, beside `revealOnWrong`) is now called from
   every beat that reaches `revealOnWrong` (tense, cloze, pick_in_cue,
   numeral_gender, picture, pronoun), `study/rendering.js`'s `askAdvance`
   sets `right` directly off the same `opt.correct` boolean `wrong` already
   used, and `safe_cracker.js`'s `finish()` sets it in the `cracked` branch
   beside the existing `wrong` one in `else`. `tests/test_beat_verdict_wiring.py`
   pins that every `revealOnWrong` caller has a matching `revealOnRight`
   call, the same way it already pinned `revealOnWrong`'s own `dismiss`
   argument. `.advance.wrong` needed no such follow-up in the first place:
   every call site already set it explicitly, so red kept working the
   moment the baseline stopped supplying green for free.

   Colour is not the only signal here. `.verdict` (study.css) already carries
   "Right"/"Not this one" to assistive tech on the study card's own judged
   path, visually hidden on purpose (#1210) and UNTOUCHED by this ticket; a
   wrong answer on every judged beat still shows the real Hebrew answer or a
   `.opt.wrong`-marked option beside this button, which is content, not a
   colour. */
.advance { display:inline-flex; align-items:center; justify-content:center;
           align-self:center; margin-inline:auto;
           flex:0 0 auto; min-height:44px; padding:9px 26px;
           border:1px solid var(--line); border-radius:999px;
           background:none; font-family:var(--mono); font-size:13px;
           letter-spacing:.08em; text-transform:lowercase; color:var(--muted);
           cursor:pointer; -webkit-user-select:none; user-select:none;
           -webkit-tap-highlight-color:transparent;
           /* #1571's sheen needs a containing block to paint inside and a
              clip so the sweep stops at the pill's own edge. Neither costs
              layout, and neither is inherited by anything: `.advance` has no
              positioned descendants and no overflowing children.
              `#ask .advance` (study.css) overrides `position` to `fixed` for
              #1352's pinned band -- still a containing block, so `::after`'s
              `inset:0` resolves the same way there. */
           position:relative; overflow:hidden;
           /* #1386, the owner: "a bit of a line stroke on its bottom and
              right edge to give it some depth" for this button too, the
              same light-from-upper-left convention `.cuereplay`'s own
              #1386 stroke above uses -- but NOT the same mechanism.
              `.cuereplay` has an opaque `background-color:var(--surface)`
              to darken, so an INSET shadow shows against it; at the time
              this was measured `.advance` was `background:none` over
              `.stage`, which is unpainted and shows `.phone`'s `--ground`
              straight through, and dark theme's `--ground` is `#0E1013` --
              already within a few RGB steps of black. Measured (sampled the
              rendered pixels): an inset `rgba(0,0,0,.85)` blurred shadow
              there moves the pixel from (14,16,19) to (11,13,15) -- a 2-3-
              value shift, invisible at any alpha, because you cannot darken
              a surface that is already almost the shadow's own colour.
              (#1682b briefly gave `.advance` an opaque `--ok`/`--no` fill
              instead of `none` on the baseline itself; #1701 put `none` back
              -- see this rule's own header. `.advance.right`/`.wrong`, the
              two modifiers, still carry the opaque fill, so the measurement
              above is still this rule's premise for the plain state and the
              modifiers' own premise for theirs -- an inset shadow against a
              solid fill is its own question neither ticket opened.)

              So this is an OUTER, non-inset shadow instead, offset
              down-right by 1px in `var(--line)` -- the colour the `border`
              above uses on the plain state (`--ok`/`--no` on `.right`/
              `.wrong`; `--line` is kept here rather than following it, a
              neutral bevel under either verdict colour rather than a second
              literal per state) -- and it is guaranteed visible against
              `--ground` in both themes, which is what made the original
              border itself visible.
              A non-inset
              `box-shadow` is clipped to paint only OUTSIDE the border
              box's own shape, so the offset copy is entirely hidden by
              the border on the top and left sides (where it lands
              underneath the real border) and shows only as a 1px sliver
              past the border on the bottom and right -- a second, offset
              line that reads as a thicker/heavier edge exactly where the
              owner asked for one, using a colour already proven to work in
              both themes rather than a new literal. Still costs no
              layout: a non-inset `box-shadow` does not reserve space
              either, so the pill's measured box is unchanged (`#cue`'s
              position is identical before/after -- see the PR body).

              #1571 thickens that same stroke from 1px to 3px, and the reason
              is the owner's report against what #1386 shipped: "right now it
              feels hallow". It is not a new mechanism -- the shadow, the
              colour and the clipping argument above are all unchanged; the
              offset is the only value that moved, and it moved because the
              press below now TRAVELS onto it. The button lands exactly flat
              on the edge it was resting above (3px of travel, 3px of edge,
              shadow to zero), which is what makes the depth readable as
              depth rather than as a heavier line. Treatment D on
              `docs/demos/2026-09-03-button-feel/index.html`, chosen by the
              owner on #1571 out of six he pressed on the phone.

              #1730, measured against #1700's "the Continue button needs the
              shadow" report: "guaranteed visible against `--ground`" a few
              lines up overstates what the number actually is.
              `contrast_ratio(--line, --ground)` is 1.36:1 dark / 1.22:1
              light -- nowhere near even AA-large's 3:1, and `--ok`/`--no`
              against the same ground are 9.56:1 / 4.64:1 by comparison
              (#1682b's own measurement, this file's header). The shadow was
              never independently visible; it read as depth only because it
              extended the border's own colour -- `border` and `box-shadow`
              were BOTH `--line` on the plain state, so the two together
              looked like one thickened edge rather than two separate,
              barely-there marks. `.advance.right`/`.wrong` (further down)
              break that coupling: their `border-color` is `--ok`/`--no`
              now, high-contrast on its own, while this shadow stays
              `--line` -- a colour that reads as roughly nothing next to a
              vivid fill rather than as a continuation of it. The shadow
              rule itself is UNCHANGED by this ticket (still literally
              present, still passes `test_the_check_button_carries_advances_look_unconditionally`'s
              parity check against jumble.css) -- this is a perceptual gap
              on the two coloured modifiers, not a technical removal, and
              not this ticket's or #1730's to fix: inventing a new shadow
              colour for `.right`/`.wrong` is exactly the kind of variation
              #1700's button-page is being built to let the owner choose
              between, not something a wiring ticket should pick alone.
              Recorded on #1730 rather than patched here. */
           box-shadow:3px 3px 0 var(--line);
           /* ---- #1571: THE NUMBER TO TUNE ----------------------------------
              How long the sheen runs, in milliseconds, declared where the
              sheen is. `shared/advance.js` READS this off the cascade
              (`getComputedStyle(el).getPropertyValue`) rather than carrying
              a copy of it, because "the continue happens after the sheen
              completes" makes one duration answer two questions and a second
              copy in a second file is a hand-listed dependency waiting to go
              stale on the next retune.

              Three consequences fall out of reading it rather than hardcoding
              it, and all three are wanted:
                - reduced motion sets it to 0 in the escape at the foot of
                  this file, in the same block that parks the animation, so
                  the wait is zero by the same cascade that removes the sweep
                  -- one mechanism, not a stylesheet rule and a JS branch that
                  have to be kept agreeing;
                - a page that loads `advance.js` without this sheet has no
                  sheen, reads no number, and correctly does not wait;
                - `tests/test_advance_sheen_wait.py` pins this against the
                  `.42s` below, so the two spellings of the same duration
                  cannot drift.

              What it COSTS, since it is now on every forward tap in the app:
              0.42s each, 8.4s over twenty advances, ~10.1s over a designed
              24-cue sitting. That is the owner's call (#1571) and it is
              stated so the next reader does not have to derive it -- but it
              is the first thing to halve if "continue feels slow" ever comes
              back, and halving it means this line and the `.42s` together. */
           --advance-sheen-ms:420;
           /* `translate` FIRST, and its own timing function -- #1571's
              "press is instant, release springs back". A press sets
              `transition-duration:0s` on this property (below) so the button
              is down on the frame the finger lands; letting go restores this
              .22s overshoot curve, which is what "animate back up" means and
              what a colour change alone cannot say.

              It is the `translate` PROPERTY, deliberately, and not
              `transform` -- which is what the mock-up used and is the one
              place this implementation diverges from it. `#ask .advance`
              (study.css) already owns `transform:translateX(-50%)` to centre
              #1352's pinned band, at (1,1,0) specificity: a
              `.advance:active { transform: … }` at (0,1,1) loses to it, so
              the study card's own Continue -- the single most-pressed
              `.advance` in the app -- would have been the one button on the
              page that did not move. `translate` composes with `transform`
              instead of competing with it (the used value is
              translate -> rotate -> scale -> transform), so the same 3px
              lands on every control including that one, and study.css is
              not touched. Rendering is identical; only the property is
              different. */
           transition:translate .22s cubic-bezier(.2,1.5,.4,1),
                      box-shadow .22s ease, color .12s ease,
                      border-color .12s ease, background-color .12s ease; }
/* #1700/#1731, the owner's settled q3 spec (2026-09-05, and see #1682/
   #1424/#1595 for what came before it): "everything should match .. if
   its correct green shadow and if its incorrect then red .. just like in
   your examples." Two round-trips on this ticket are worth reading if this
   rule ever needs re-deriving (see the issue thread) -- the short version
   is that the FILL stopped being the verdict signal and the SHADOW (plus
   its own edge) started being it.

   `.advance.right` and `.advance.wrong` therefore now declare the IDENTICAL
   `background`/`color` -- `--raise`/`--ink`, the same restrained "Wash"
   surface `.jumble-check`'s own unanswered state already paints with
   (jumble.css, below) -- so a graded card's fill no longer tells you which
   way it went; only `border-color` and `box-shadow` differ, and they
   differ IDENTICALLY between the two rules (one token swapped for its
   pair), which is what "constant fill, verdict shadow" means as CSS. This
   is a real cut in visual weight from the block-filled `--ok`/`--no` pill
   #1682b/#1701 shipped, on a control the previous design already leaned on
   as its main non-colour-text verdict carrier -- see the closing note on
   this file's own header comment and the PR body for why a mirrored
   checkmark was considered and deliberately deferred rather than invented
   here.

   The shadow is toned to its OWN edge, not to `--ground` -- the finding
   #1730/#1735 measured against the pre-#1682b shadow (`--line` against a
   `--line` border, 1.00:1, vs `--line` against an `--ok` border once the
   border went green, 7.04:1 dark / 3.79:1 light, orphaned). Making
   `border-color` and `box-shadow` the same token keeps that ratio at
   1.00:1 by construction on both verdicts, the way it always was on the
   plain state, so the depth reads instead of smudging. `--ok`/`--no`
   against `--ground` are already the measured, vetted pair this ticket was
   told not to re-derive (9.56:1 dark / 4.64:1 light, 5.58:1 dark / 5.25:1
   light) -- reused here as literals already cleared, not new colours.

   Nothing in this sheet or `shared/advance.js` adds either class by
   itself; every call site is a beat that already knows its own verdict --
   see `.advance.right`'s pre-#1735 header, unchanged, for the exact list
   (`study/rendering.js`'s `askAdvance`, `pick_in_cue.js`'s
   `revealOnRight`/`revealOnWrong` pair, `safe_cracker.js`'s `finish()`).
   `tests/test_beat_verdict_wiring.py` still pins that every
   `revealOnWrong` caller has a matching `revealOnRight` call.

   `#1731`: `.jumble-check.right`/`.wrong` (jumble.css) mirror this pair's
   `box-shadow` exactly, and `tests/test_beat_jumble.py`'s
   `test_the_check_button_carries_advances_look_unconditionally` now
   compares THREE pairs (base, `.right`, `.wrong`) instead of one -- see
   that test's own docstring for why a shared relationship guard was kept
   over inventing a cross-file custom property. */
/* #1759: the 18px glow behind the offset stroke, added to both verdict
   modifiers -- `--ok-bloom` on `.right` (the same token the correct-answer
   bloom above uses for its own outer glow, so the Continue pill's colour
   reads as the same "moment" rather than a second recipe) and `--no-halo`
   on `.wrong` (there is no `--no-bloom` token; 02-directions.html's own
   rendered `.dirB .advance.wrong` rule uses `--no-halo` here too, so this is
   not a substitution, it is what Lantern actually ships). `tests/test_beat_jumble.py`'s
   `test_the_check_button_carries_advances_look_unconditionally` compares
   this literal against `jumble.css`'s `.jumble-check.right`/`.wrong` for
   equality -- mirrored there too, a one-line value match, even though
   jumble.css sits outside this ticket's stated scope fence (Phase 4 owns
   that file's own beat-tile restyling); leaving the two apart would ship a
   known-broken test, which is worse than the fence. */
.advance.right { border-color:var(--ok); background:var(--raise); color:var(--ink);
                 box-shadow:3px 3px 0 var(--ok), 0 0 18px var(--ok-bloom); }
/* The wrong twin -- identical `background`/`color` to `.right` above (the
   constant fill), `--no` swapped in for `--ok` on `border-color` and
   `box-shadow` (the verdict). Set only by the judged beats that actually
   know the tap was wrong; see `.advance.right`'s header for the call-site
   list and `tests/test_beat_verdict_wiring.py` for the wiring guard --
   neither changed by this ticket, only the look did. A wrong answer on
   every judged beat still shows the real Hebrew answer or a `.opt.wrong`-
   marked option beside this button (content, not colour), and `.verdict`
   (study.css) still carries "Right"/"Not this one" to assistive tech,
   visually hidden (#1210) -- both untouched, and both are why this rule
   does not also need to invent a wrong-side glyph the way `.right`
   considered and declined one. */
.advance.wrong { border-color:var(--no); background:var(--raise); color:var(--ink);
                 box-shadow:3px 3px 0 var(--no), 0 0 18px var(--no-halo); }
/* Requirement 6 of #1348, verbatim, and it is what `.continue` alone used to
   satisfy: "when pressed it will highlight and when let go it accepts the
   click." `:active` is the browser's own definition of "currently pressed,"
   so the highlight ends by itself the instant the finger lifts or the
   gesture is stolen -- no class, no timer, nothing for the JS to remember to
   clear. #1571 keeps that: the travel below is on `:active` too, so nothing
   here needs a class or a timer either. Only the sheen does, because a sheen
   fires on RELEASE and `:active` is over by then -- see `.advance-lit` below.

   `transition-duration:0s` collapses all five listed transitions
   (translate, box-shadow, color, border-color, background-color) to
   instant for the press -- a single value repeats across the whole list
   per the CSS transition spec, so this is not five zeroes written out, and
   it is harmless that three of them no longer change here (colour and
   border-colour used to swap to `--accent` on every press; see below for
   why that stopped). Costs no layout -- a `translate` is a composited
   transform, and a non-inset `box-shadow` never reserved space to give
   back (#1283/#1300: nothing on a beat card may reflow on a state change;
   `#cue`'s position is identical pressed and unpressed).

   #1700's settled spec, part 2: "The yellow colour to indicate the press
   is not a good color .. it should be the sheen of the red or the green
   .. with a toned down gaussian blur." `--accent` (`#F2C14E` dark /
   `#8A6410` light) is the token that means "this is the target word"
   everywhere else in the app, which was already a second reason to take
   it off a press state -- and it is now gone from `.advance` entirely
   (grep this file: no `--accent` reference remains below this comment).

   Treatment D itself -- the button travels 3px onto its own drop shadow,
   which is `0 0 0` and therefore invisible at ANY colour (zero blur, zero
   spread, exactly the border box's own shape, fully covered by the
   button's own fill) -- is unchanged; `transparent` is written explicitly
   rather than re-declaring whichever token happened to be resting there,
   so a future reader does not have to work out that the colour is moot.
   The second, blurred shadow is the new press highlight: a real gaussian
   blur (a `box-shadow` blur radius, not a filter -- costs no layout for
   the same reason the first shadow never did) in the verdict's own hue at
   50% mixed with transparent -- "toned down" rather than the hard-edged
   `0 0 16px` glow #1700's Neon variation used at full strength. The base
   rule below uses `--line` (this control's own neutral, unverdicted hue)
   so an ungraded press still gets a highlight rather than none; `.right`/
   `.wrong` below override it to `--ok`/`--no`. `color-mix(in srgb, …)` has
   precedent elsewhere in this app (progress.css, study.css, welcome.css)
   rather than a fourth literal per theme per verdict. */
.advance:active { translate:3px 3px;
                  box-shadow:0 0 0 transparent,
                             0 0 16px 1px color-mix(in srgb, var(--line) 50%, transparent);
                  transition-duration:0s; }
/* The verdict overrides for the press highlight -- higher specificity than
   the base `:active` above (two classes plus the pseudo-class beats one
   class plus the pseudo-class), so these win regardless of source order,
   unlike the old `--accent` flash they replace (that rule's own former
   comment warned the next reader about exactly this kind of ordering
   trap; these two rules exist so nobody has to reason about it again). */
.advance.right:active {
  box-shadow:0 0 0 transparent,
             0 0 16px 1px color-mix(in srgb, var(--ok) 50%, transparent);
}
.advance.wrong:active {
  box-shadow:0 0 0 transparent,
             0 0 16px 1px color-mix(in srgb, var(--no) 50%, transparent);
}
/* ---- #1571: the sheen, and why it is a class and not `:active` ----------
   The owner's words: "you press down on the button and then let go the
   button should animate back up and have a sheen and the[n] go to the next
   screen." The sweep therefore belongs to the RELEASE, and `:active` is the
   one state that is already over by the time a release happens -- on
   `:active` the sheen would run while the finger is still down, which is the
   opposite of what was asked for. So this one piece of the treatment is
   driven by a class, `advance-lit`, added on `pointerup` and removed when
   the animation ends (`shared/advance.js`).

   `advance-lit` and not the mock-up's `lit`: study.css already ships a live
   `.stage.lit` (the cue's reveal), and a second, unrelated `.lit` on the
   same page is the kind of name collision that reads as one feature from
   the outside. Nothing else about the treatment changed.

   The band is painted on `::after`, so the button's own box, border and
   shadow are untouched by it and there is nothing for the sweep to reflow.
   It never touches the ink: `::after` is a sibling layer over the
   background, the label's `color` is unchanged, so
   `tests/test_contrast_sweep.py`'s score for `.advance` is the same number
   before and after.

   #1700's settled spec retired `var(--glow)` here along with the `:active`
   highlight above -- "no --accent yellow left on that control" -- so the
   release sheen is toned to the same verdict hue the press highlight and
   the shadow already carry, at the same 50%-mixed-with-transparent
   strength, rather than the accent's halo. The base rule keeps `--line`
   (an ungraded dismiss still sheens, just neutrally); `.right`/`.wrong`
   below override the middle stop to `--ok`/`--no`. */
.advance::after { content:""; position:absolute; inset:0; pointer-events:none;
                  border-radius:inherit;
                  background:linear-gradient(105deg, transparent 35%,
                             color-mix(in srgb, var(--line) 50%, transparent) 50%,
                             transparent 65%);
                  translate:-120% 0; opacity:0; }
.advance.right::after {
  background:linear-gradient(105deg, transparent 35%,
             color-mix(in srgb, var(--ok) 50%, transparent) 50%, transparent 65%);
}
.advance.wrong::after {
  background:linear-gradient(105deg, transparent 35%,
             color-mix(in srgb, var(--no) 50%, transparent) 50%, transparent 65%);
}
.advance.advance-lit::after { animation:advanceSheen .42s ease-out; }
/* .42s, and `shared/advance.js`'s `SHEEN_MS` is the same number for the
   wait that now follows it. Change one and change the other -- that file
   says so at the constant, and `tests/test_advance_sheen_wait.py` fails if
   they drift apart. */
@keyframes advanceSheen { from { translate:-120% 0; opacity:1; }
                          to   { translate:120% 0;  opacity:0; } }
.advance:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
/* #858: `.advance` sets `display:inline-flex` above, an AUTHOR rule, and
   origin beats specificity in the cascade -- a normal author declaration
   always outranks a normal user-agent one, regardless of selector weight, so
   `.advance` on its own overrides the UA stylesheet's `[hidden]{display:none}`
   outright. Same defect, same fix, as `.offramp[hidden]` below and
   settings.css's `.section[hidden]` / tutorial.css's `.card[hidden]` -- every other class
   in this codebase that sets `display` alongside `hidden` already carries one;
   `.advance` was the one that didn't (flagged but not fixed at #847, which
   could only reach `.match-continue` from word_match.css). Four beats used to
   render `class="advance beat-dismiss" hidden` before the learner had
   answered (tense.js, cloze.js, pick_in_cue.js, safe_cracker.js) -- this rule
   is what made that attribute actually hide the button, for all of them at
   once and for the fifth beat someone adds next month, rather than one
   `[hidden]` override per beat stylesheet. This makes word_match.css's own
   `.match-continue[hidden]` redundant (that button carries `.advance` too) --
   left in place there deliberately; removing a rule #856 just shipped is a
   separate call, not this ticket's.

   #1283: the four beats named above no longer carry `hidden` on this button
   at all -- see `.beat-reserve` just below, and each beat's own `wire()`/
   `finish()`. This rule stays: `hidden` genuinely removing an element from
   layout is still the correct, and only, contract for anything that really
   should cost no space (`.cracker-relay`, a fifth beat that has no reflow
   problem to solve), and `tests/js/test_hidden_display_contract.mjs`'s
   whole-app sweep still expects any FUTURE `.advance ... hidden` combination
   to compute `none` here, not to silently repeat the #858 bug. */
.advance[hidden] { display:none; }

/* ---- #1592: a near-miss on Continue must not start a text selection -------

   In-app, on a phone, build `55ccd63`: *"If you click around the continue
   button it selects the block as text. Awkward."*

   `.advance` has carried `user-select:none` on ITSELF since #1349, so the
   button's own label was never the problem. What the finger actually lands on
   when it misses is the box the button sits in, and on the reported screen
   (`/study`, an answered ask card) that box is `#ask` -- the option buttons,
   the 8px gaps between them, and `.tap-hint`, the empty full-width band
   `study.css` pins above the nav for the button to sit on (#1352). `#ask` is a
   SIBLING of `.stage`, so #1349's aimed `user-select:none` -- which covers
   every pixel of the stage that is not a sentence -- has never reached any of
   it. On a 44px target a near-miss is the common case on a phone, not the
   rare one.

   ## The boundary, and why it is drawn by derivation

   The region made unselectable is exactly *the element an advance control is
   a direct child of* -- the row or box the button sits in, and nothing above
   it. `:has(> .advance)` says that rather than listing the boxes, which
   matters because there are six of them across two pages today (`#ask`,
   `.stage`, `.beat`, `.fr-screen`, skills' `.roundhead` and its round-end
   panel) and a hand-listed set of page selectors in a shared component sheet
   would be stale the first time one of them was renamed. The child
   combinator is load-bearing: `:has(.advance)` without it would climb to
   `body` and take the whole page with it.

   What it deliberately does NOT reach is the study card's cue and gloss.
   Those live in `.stage`, a sibling of `#ask`, and their opt-in
   (`.cuerow .cue, .reveal { user-select:text }`, study.css, #1349's explicit
   requirement that a learner can still copy a word out to look it up) is
   declared later in link order at equal specificity, so it continues to win.
   `tests/js/test_study_selection.mjs` asserts both halves.

   One behaviour change beyond the report, stated rather than left to be
   found: on `skills.html` a beat card's own text becomes unselectable too,
   because `.beat` is one of the boxes matched. On `/study` it already was --
   `.beat` renders inside `#stage`, under #1349's rule -- so this makes the
   two pages agree rather than taking anything away that a learner has today.

   `-webkit-user-select` is not optional and not decoration: iOS honours the
   prefixed property, and iOS is the phone the report came from. */
:has(> .advance) { -webkit-user-select:none; user-select:none; }

/* #1424's rail glyph, here because jumble's own replay button carries it
   (`beats/jumble.js`) -- the boxes AROUND a rail icon stayed in study.css,
   which is where the rail is. */
.railicon { width:21px; height:21px; display:block; fill:none;
            stroke:currentColor; stroke-width:1.7;
            stroke-linecap:round; stroke-linejoin:round; }

/* ---- the in-line replay button (#1821) --------------------------------
 *
 * #1568 moved the beat card's shell here so `skills.html` could render the
 * same cards from the same scripts. `.railicon` came with it, one rule above,
 * *because jumble's replay button carries it* -- and the button itself was
 * left behind in `study.css`. That is the whole of #1821.
 *
 * ## What the owner reported, and what was actually measured
 *
 * His words (2026-09-06 worksheet, q2): the speaker on the cards "where you
 * listen and have to choose the words to be placed onto the line" is "crushed
 * into the line and not blue", and later the same day, "not sheening when the
 * audio plays". Two earlier investigations read the cascade on paper, found
 * `.cuereplay`'s base rule unscoped in `study.css`, and cleared it. The paper
 * was right and the page was not: `skills.html` never loads `study.css`, so on
 * the practice hub NOTHING declares `.cuereplay` at all and the browser paints
 * a bare `<button>`.
 *
 * Computed style on the same markup under each page's own sheet set, real
 * Chrome, 127.0.0.1 (`getComputedStyle` + `getBoundingClientRect`):
 *
 *   study.html sheets   40x40  bg rgb(88,198,255)   radius 12px  glyph 26px
 *                       flex-basis 40px            .playing -> cuereplaySheen
 *   skills.html sheets  37x27  bg rgb(107,107,107)  radius 0px   glyph 21px
 *                       flex-basis auto            .playing -> no animation
 *
 * 27px tall against a 44px row is the "crushed"; UA ButtonFace is the "not
 * blue"; and no `@keyframes cuereplaySheen` in scope is why the sheen never
 * arrived on that page even once the class is on.
 *
 * ## Why the block lives here rather than being copied
 *
 * Same argument this file's header already makes for `.advance`: a copy is a
 * hand-maintained duplicate of measured decisions (#1386's offset stroke,
 * #1424's 40px box and 26px glyph, #1373's sheen), and this repo has been
 * bitten by that shape enough to have a standing rule about it. So these are
 * the rules THEMSELVES, unchanged, at the same specificity they had in
 * `study.css` -- `.cuerow .cuereplay`'s margins and `.cuereplay.is-silent`
 * stay behind there, because those two are the study LOOP's own positioning
 * and state (`renderReplay` in `study/audio.js`), not the control's look. The
 * beat card's copy of the button uses `hidden`, never `.is-silent`.
 *
 * This sheet loads BEFORE `study.css` on `study.html`, so nothing here can
 * outrank a study-page rule -- exactly the ordering this file's header
 * records. `--control`/`--control-ink` are host tokens and `skills.css`
 * declares the same pair as `study.css` (#58C6FF / #0C0E12 dark, #1069C9 /
 * #FFFFFF light), which is why the move needs no second palette.
 *
 * The per-rule reasoning is not restated here, and is deliberately cited by
 * TICKET rather than by file: the 40x40 box, the 12px radius, the 26px glyph
 * and the pressed `filter` are #1424's, measured off the owner's reference
 * photograph; the bevel's offset bottom/right stroke is #1386's; the sheen is
 * #1373's; the fat hit box is #127's "draw thin, hit fat". Those tickets carry
 * the measurements and outlive whichever file the rules sit in. The prose that
 * still sits beside the copy in `study.css` moves here with the fenced
 * follow-up that deletes it.
 */
.cuereplay { position:relative; display:flex; align-items:center;
             justify-content:center; align-self:flex-start;
             flex:0 0 40px;
             width:40px; height:40px; padding:0; border:0; border-radius:12px;
             background-color:var(--control); color:var(--control-ink);
             cursor:pointer;
             -webkit-tap-highlight-color:transparent;
             box-shadow:inset 0 1px 0 rgba(255,255,255,.38),
                        inset 0 -1px 0 rgba(0,0,0,.28),
                        inset -1px 0 0 rgba(0,0,0,.28),
                        0 1px 2px rgba(0,0,0,.22);
             transition:color .15s ease, background-color .1s ease,
                        box-shadow .1s ease; }
/* "Draw thin, hit fat" (#127): 2px of overhang on each side is what takes the
   40x40 box to the 44x44 thumb target. Hits on a pseudo-element are
   attributed to its own element, so `closest(".cuereplay")` still matches. */
.cuereplay::after { content:""; position:absolute; inset:-2px; }
/* The glyph, sized to the BUTTON rather than to the rail (#1424) -- the one
   place this control stops being "one more rail icon". */
.cuereplay .railicon { width:26px; height:26px; }
.cuereplay:active { filter:brightness(.88);
                    box-shadow:inset 0 2px 3px rgba(0,0,0,.35); }
.cuereplay:focus-visible { outline:2px solid var(--accent); outline-offset:2px;
                           border-radius:12px; }
/* #1373 requirement 3, the owner: "a sheen or a wave of colour that stops when
   the audio stops." The class is added and removed by whichever host is
   playing the clip; this is only the animation it turns on. */
.cuereplay.playing { --replay-sheen:rgba(255,255,255,.50);
                     background-image:linear-gradient(190deg,
                       transparent 0 34%, var(--replay-sheen) 50%, transparent 66% 100%);
                     background-size:100% 220%; background-repeat:repeat;
                     background-position:0 0%;
                     animation:cuereplaySheen 1.1s linear infinite; }
@keyframes cuereplaySheen {
  from { background-position:0 0%; }
  to   { background-position:0 220%; }
}

/* #145: hover rules live in this query so a finger never triggers them --
   on iOS a `:hover` rule makes the first tap paint instead of activating.
   The rest of the study page's hover block stayed in `study.css`; these two
   came here with the selectors they belong to. */
@media (hover: hover) {
  .opt:hover { background:var(--raise); }
  /* #706/#1372: every "advance" control's hover, one rule for the whole
     family -- tense/pick_in_cue/cloze/safe_cracker's continue, explainer's,
     reward's and word_match's dismiss, first-run's own, and -- since #1372
     -- the self-paced study card's continue button too.

     #1701 split this back into two rules, one per look: this one covers
     the plain baseline restored above (`background:none`, `color:var(--muted)`)
     and brightens the dim text to `--ink`, exactly what it did before
     #1682b ever touched this file. */
  .advance:hover { color:var(--ink); }
  /* #1682b's own hover, kept for the two coloured modifiers only: brightening
     dim text over a TRANSPARENT background is meaningless over `.right`/
     `.wrong`'s opaque `--ok`/`--no` fill (that rule sets `color:var(--ground)`,
     not `--muted`, and `--ink` was never measured against `--ok`/`--no`, only
     against `--ground`/`--surface`). Recolouring text over a filled control is
     how `.opt.correct`/`.opt.wrong` stayed out of the same contrast hole; a
     `filter`, not a colour, keeps the already-verified pair intact while
     still answering "is this hovering" -- the same idiom `.cuereplay:active`
     (study.css) uses for its own press feedback. */
  .advance.right:hover, .advance.wrong:hover { filter:brightness(1.08); }
}

/* #1024: the escape for the motion this sheet declares.

   It came here WITH the rules -- `.rise` is a real `@keyframes` animation and
   `.opt`/`.advance` both declare `transition`, and until #1568 those three
   were covered by `study.css`'s own reduced-motion block, in the same file.
   A component sheet cannot borrow its host's escape: `tests/test_reduced_motion_escape.py`
   asks the question per FILE, and the reason it does is that a sheet loaded
   by a second page (which this one now is) would otherwise animate on that
   page with nothing switching it off.

   `.rise` is parked outright rather than merely flattened, the same call
   `study.css` makes for `.cuereplay.playing`: a 12px slide with a duration of
   almost-zero is still a jump, and the card's resting position is the honest
   still state. The two transitions are flattened -- they smooth a colour
   change that still has to happen, so removing the property would leave the
   card unable to say a tap landed at all. */
@media (prefers-reduced-motion: reduce) {
  .rise { animation:none; }
  .opt { transition:none; }
  /* #1821: the replay sheen's escape came here WITH the rule, for the reason
     the block header above gives -- `tests/test_reduced_motion_escape.py` asks
     the question per FILE, and this sheet is now the one declaring
     `animation:cuereplaySheen`. Parked outright, never merely flattened: a
     LOOPING animation at .01ms is still running, one frame long, and which
     frame that is is `animation-fill-mode`'s call. `--control-ink` restates
     the base colour so the glyph cannot inherit whatever hue a flattened
     keyframe left behind, and `0 44%` parks the band visibly across the box
     -- in this still state the parked band, not the glyph, is what says
     sound is playing. Byte-identical to the block it mirrors in `study.css`. */
  .cuereplay.playing { animation:none; color:var(--control-ink);
                       background-position:0 44%; }
  /* #1759: the correct-answer bloom, parked the same way as the wrong
     pulse below -- `animation:none`, never a shortened duration. The ring
     and halo remain: they are declared on `.opt.correct`'s own base rule,
     not inside `@keyframes verdictBloom`, so a reduced-motion learner still
     sees the full verdict shape (2px ring, 6px halo, 22px glow) appear at
     the instant the answer is judged, and simply never sees it arrive over
     540ms. `cloze.css` carries the matching escape for its own
     `.cloze-option.correct`, in its own file, for the reason
     `tests/test_reduced_motion_escape.py` asks the question per file. */
  .opt.correct { animation:none; }
  /* #1571: zero, and it is read by `shared/advance.js` as the wait. The
     animation is parked two lines down, so there is no sweep to wait for and
     a fixed .42s here would be .42s of nothing on every forward tap. Declared
     beside the parking rather than re-derived in JS from a second
     `matchMedia` read -- same cascade, same answer, one place. */
  .advance { transition:none; --advance-sheen-ms:0; }
  /* #1571, and both halves are parked rather than shortened. `transition:none`
     above already flattens the spring back up, but it does NOT stop the press
     itself moving -- `translate` on `:active` is a state change, not a
     transition -- so the travel has to be turned off by value. A 3px jump with
     no animation is a jump, and the same call `.rise` above records: the
     resting position is the honest still state.

     The sheen is parked outright too, and `shared/advance.js` reads the SAME
     media query and drops its wait to zero when it matches. That pairing is
     the point: with `animation:none` there is no `animationend` to wait for
     and no sweep to wait THROUGH, so a fixed .42s here would be .42s of
     nothing on every forward tap. See that file's `reducedMotion()`. */
  .advance:active { translate:none; }
  .advance.advance-lit::after { animation:none; }
  /* #1586: the one escape for the wrong-answer ring, for all nine surfaces at
     once -- which is half of why the treatment was worth unifying at all
     (#1586's own done-when: "one reduced-motion escape, giving a real static
     red rather than a fast one").

     `animation:none`, never a shortened duration, and the distinction is the
     one `word_match.css` records at length: a blanket
     `* { animation-duration:.01ms !important }` leaves the animation RUNNING,
     one frame long, and what a one-frame run leaves behind is decided by
     `animation-fill-mode` -- a property this rule would then be depending on
     without saying so. Naming `none` means the ring never plays at all.

     Nothing else is dropped, and this is what makes the escape a genuine
     static state rather than a fallback that merely looks like one: every
     surface in the list keeps its own `border-color`/`background`/`color`
     tint, declared in its own stylesheet and never part of the keyframe. A
     learner who has asked for less motion still gets the red tint the instant
     the answer is judged -- "a red tint that simply appears", which is the
     ticket's own wording -- and simply does not get the three beats of ring.

     The list is the same ten selectors (nine judged surfaces -- word_match
     contributes two) as the rule and the light override
     above, pinned identical by `tests/test_beat_wrong_pulse.py`. */
  .opt.wrong,
  .cloze-option.wrong,
  .numgen-opt.wrong,
  .etm-opt.wrong,
  button.pick-word.wrong,
  .tense-opt.wrong,
  .jumble-line.wrong .jumble-chip,
  .cracker-reveal-pos.wrong,
  .warmup-card.wrong .warmup-card-face,
  .match-he.wrong, .match-en.wrong,
  .ws-cell.wrong,
  .tcz-input.wrong { animation:none; }
}
/* Moved here from study.css by #1657, comment and rule verbatim -- it was
   only ever in study.css, which /skills does not load. */
/* #1283: the page jumped when a beat was solved -- "the keypad should not
   move ... just the success or fail is added at the end" (the owner,
   verbatim). It never scrolled (grep confirms no `scrollIntoView`/`scrollTop`
   anywhere under app/static/study/ or app/static/beats/); it reflowed.
   `.beat`'s `justify-content:center` pays for any growth below a beat's
   already-visible content by lifting everything above it -- half the growth,
   upward -- because a centred flex column splits new space between both
   ends. Four beats reveal a `.beat-dismiss` button (and safe-cracker also a
   `.cracker-result`) only once the learner has answered, by removing `hidden`
   -- so those nodes did not exist in flow a moment before, `.beat` grew by
   their height plus a `gap:18px` each, and the keypad/cue/options above them
   moved up by half of that (~31px for the three single-button beats, ~45px
   for safe-cracker's two revealed nodes -- see #1283 for the full
   arithmetic).

   The fix reserves the box up front instead of removing it from flow: a node
   carrying `.beat-reserve` keeps whatever `display` its own class already
   sets (so it is still sized, still contributes its `gap`), and is merely
   invisible. `.beat`'s height is then IDENTICAL before and after a beat is
   solved, by construction, because nothing enters or leaves its flow --
   `wire()`/`finish()` now do `classList.remove("beat-reserve")` where they
   used to do `.hidden = false`. Deliberately NOT the `hidden` attribute for
   these nodes: `hidden` removing the box is the one thing that must not
   happen here, and reusing it with an override that keeps the box (the
   opposite of `.advance[hidden]` above) would leave one selector meaning two
   contradictory things depending on which element it landed on.

   `visibility:hidden`, not `opacity:0`: it also drops the node from the
   accessibility tree and the tab order, matching what `hidden` gave these
   controls before (verified: a `visibility:hidden` button is not reachable
   by Tab and does not receive a synthetic click in the real browser and in
   this repo's own probes) -- `opacity:0` alone would leave a screen reader
   describing the answer before the learner had it, and would leave the
   button tappable.

   This is a genuine trade the owner should see rather than one made
   silently: the reserved box is present, and invisible, from the FIRST paint
   of an unsolved beat too -- so the card sits slightly higher, with blank
   space held below the keypad, even before the learner has answered. That is
   the "recommended" option #1283 itself named (over not centering the card
   at all, which fixes the jump just as completely but changes the unsolved
   card's own position instead) -- flagged on the ticket rather than decided
   silently, since both read as a real, visible change from today.

   #1728 CHECKED THIS AND CHANGED NOTHING HERE. That ticket moved the STUDY
   card's Continue pill out of the page chrome and into `#ask`'s own flow, on
   the owner's instruction, which makes it the same shape every control above
   already had -- so the obvious question was whether the ask card now needs a
   reserve too. It does not, and that is measured rather than argued: `#ask`
   grows 64px when `judge()` appends the pill, and at the default text scale
   the stage's top, its height, the cue's height and the cue's VISIBLE height
   are identical in the ask and answered states on all 335 cards, because the
   growth comes out of the column's slack below `#ask`. The exception is 3 of
   335 cards at `--milim-text-scale` 1.15, and reserving would charge every
   other card 64px in the state where the cue is being read. See study.css's
   own #1728 block. Nothing in THIS rule's scope moved: every beat's dismiss
   button was already in its card's flow and already carries the class. */
.beat-reserve { visibility:hidden; }

/* #2424: the owner's own report -- a cue's trailing full stop wrapped onto a
   line of its own, away from the word it belongs to. `pick_in_cue.js`,
   `cloze.js`, `tense.js`, `numeral_gender.js` and `pronoun.js` all render a
   cue as `sep + <wrapped-or-plain>` per token (#305) -- a target `<b>`, a
   blanked/gap `<span>`, a page-word `<button>`/`<span>` -- and every one of
   them can sit directly against another piece with an EMPTY `sep`: no space
   in the corpus between them at all (a numeral glued to its own trailing
   ".", a page word's trailing punctuation `_page_words` already splits off,
   #318). `window.MilimBeats.cueLineHTML` (pick_in_cue.js) wraps such a run
   in `.cue-nowrap` rather than leaving the two pieces to the ordinary flow.

   MEASURED rather than assumed, twice over (#2424's own PR body carries the
   numbers): a bare U+2060 WORD JOINER placed textually between the two
   pieces does NOT stop the wrap -- an atomic inline-level box
   (`.numgen-blank`'s own `display:inline-block`, or any element with its own
   open/close tag) carries a line-break opportunity at its edge regardless of
   what character follows, so the joiner glues characters together without
   reaching the box boundary itself. `white-space:nowrap` on a wrapper
   spanning BOTH pieces does stop it -- verified in the real app, not an
   isolated probe, at the exact width the defect reproduces at (150px). This
   selector is deliberately a plain, non-atomic `<span>`: it constrains
   breaking WITHIN itself only, so the cluster it wraps can still move to the
   next line as a whole, the same as any other word -- it is only forbidden
   from splitting internally. */
.cue-nowrap { white-space:nowrap; }

