/*
  #1473's memory-match warm-up board, and #1568's practice tile for it.

  Lifted WHOLE out of `study.css` by #1568 -- every rule, every comment, in
  the order it was written, with nothing else changed. The board is now shown
  on two pages: before a story's first cue (`study/warm_up.js`'s `boot`, the
  screen #1473 shipped) and on demand from the practice hub
  (`skills.js`, the "Flip and match" tile), and one rendering needs one sheet.

  Its own file rather than a section of `shared/beat_card.css` beside it: this
  board is NOT a beat. It writes no answer, `select` never draws it, and
  `warm_up.js`'s own comment records the live bug behind that distinction --
  `session_summary.js` watches `#stage .beat`, so a screen wearing that class
  before a cue has loaded is counted toward "Finish this sitting" for nothing.
  Two files keep the two kinds of card from drifting into one.

  `.warmup-on` is the STUDY page's stage class and stays meaningful only
  there; the hub gives its own host element the same class so one rule set
  serves both. Tokens (`--ink`, `--muted`, `--serif`, `--sans-he`, `--ok-soft`,
  `--no`, `--no-soft`, `--line`, `--surface`, `--mono`) come from the host
  page's own sheet, the same contract every `shared/*.css` component follows.
*/

/* ---- #1473: the memory-match warm-up -------------------------------------

   Appended here, in ONE block, rather than folded beside `.st-map`'s own
   tokens above or `.beat, .fr-screen`'s shared layout rule earlier in this
   file: `fix/1467-button-everywhere` is editing this file's own
   ~2244/~2524-2601 at the same time this branch is, and the only collision-
   free move for two branches touching one file is to append, never to
   insert. No new stylesheet either -- a new per-beat stylesheet under
   `app/static/beats/`, or a new one under `app/static/study/`, would
   register as a COMPONENT in `scripts/page_map.py` and bump
   `EXPECTED_COMPONENTS` (`tests/test_page_map.py`),
   which is the guard that says this landed as a SCREEN on an existing page,
   not a new page. `.warmup-on` on `#stage` is this screen's own class, the
   same shape `.first-run-on`/`.skill-map-on` already use.

   ## The flip is `transition`, not `animation` -- and that is not a stylistic
      choice, it sidesteps a real trap

   `word_match.js`'s own coin-spin precedent (`safe_cracker.css`) and this
   file's own `.hooray`/`.cuereplay.playing` blocks all use `@keyframes` +
   `animation` for a ONE-SHOT, forward-only effect, and each one has to
   restate a resting state under reduced motion because a flattened keyframe's
   landing frame depends on `animation-fill-mode` -- the exact trap #1473's
   own brief warns about ("a one-frame `rotate:y` with no fill-mode leaves the
   card face-down").

   A memory-match flip is not forward-only: the SAME card genuinely reverses
   (face-up red, then face-down again on a miss), so there is no single
   "resting state" a fill-mode question could even be asked about --
   `.warmup-card.flipped .warmup-card-inner { transform:rotateY(180deg); }`
   is a plain, static, per-class rule, always painting the exact end state the
   class says, with `transition:transform` as the only thing that makes it
   ANIMATE. `transition`, unlike `animation`, never has a fill-mode question:
   a transition's target value IS the computed style the instant it starts,
   whether or not anything is mid-flight. So the flip degrades safely to a
   plain swap under reduced motion however it is switched off, and cannot land
   face-down the way a flattened keyframe can.

   **This paragraph used to end differently and it was wrong.** It said the
   flip needed no reduced-motion rule of its own because "this file's own
   EXISTING blanket rule -- `* { transition-duration:.01ms !important }`, both
   copies, already above this block" took care of it. There is no such rule in
   this file and there never has been: both copies are in `app/static/
   study.css`, and the sentence was written while this block still lived in
   that file. #1568 moved the block here and the claim came with it, stale, in
   the one direction that matters -- `skills.html` loads this sheet and does
   NOT load `study.css`, so on the practice hub the flip has been running its
   full .5s for a reduced-motion learner since the move. Found while unifying
   the wrong-answer treatment (#1586); the honest line is now in this file's
   own escape at the foot, where it is reachable from both pages.

   ## The grid is always 2x4, and the (now narrower still) declined case

   The owner's 2026-09-02 ruling, mid-branch: "always show six pairs... fill
   the remaining pairs with KNOWN words rather than shrinking the grid or
   skipping the warm-up... do not follow word_match.py's decline-rather-
   than-shrink precedent here." Quoted at the pair count it was made at (six)
   -- #1545 (2026-09-03) changed the number the rule fills TO, not the rule
   itself: a short UNKNOWN pool still never shrinks or skips the board, it
   leaks known words in (`warm_up_service.py`'s own draw already does this by
   construction, needing no new branch). What remains is a narrower case the
   ruling does not name: a story with fewer than `PAIR_COUNT` defensible
   pairs of ANY status, unknown or known combined, cannot field a full board
   no matter what leaks in, and `warm_up_service.py` keeps `word_match.py`'s
   decline-rather-than-shrink answer for that one -- unreached on the real
   corpus at either pair count (measured 20-47 candidates per served story
   against a floor of 4, #1545's own PR body), flagged on #1473 as this
   module's own judgement call rather than read into the 2026-09-02 ruling.
   So `--rows`/`--cols` are never read off the payload the way
   `word_match.css`'s `--rows` is off `BOARD_SIZE` -- the grid is fixed at 2
   columns x 4 rows (8 cards) because a decline never paints a grid at all,
   and every board this screen DOES paint is the full four pairs.

   ## 8 not 12, bigger, and shaped like the safe-cracker's card (#1545)

   Three in-app reports, 2026-09-03, and the owner linked them himself as one
   change: fewer cards buys the room bigger cards need, and the shape spends
   that room on something specific rather than a generic "closer to a
   playing card" ratio he asked for first and then overrode ("I mentioned in
   another report card dimensions. But go with this instead.") while looking
   at the safe-cracker beat on screen.

   `beats/safe_cracker.css` has exactly one element with a literal, fixed
   width AND height together (`.cracker-slot`, 36x44 -- everything else in
   that file is either unconstrained or, like `.cracker-key`, a FIXED height
   against a FLUID grid-column width that only resolves to a number at one
   breakpoint) -- but `.cracker-key`'s keypad is the dominant visual grid on
   that beat's own screen: a 3-column grid of bordered, `--surface`-filled,
   `border-radius:10px` rounded rectangles, the same DOM role (a grid of
   tappable cards) this board's own `.warmup-card` plays, where
   `.cracker-slot` is a small entry blank, not a tappable tile. Read as "the
   keypad's own shape" rather than "the entry slots' shape": at
   `.cracker-pad`'s own `max-width:260px`/`gap:14px`/3 columns, a key resolves
   to (260 - 2*14) / 3 = 77.33px wide by its own `min-height:64px` --
   77.33/64 = 29/24, a card WIDER than it is tall, the opposite of the
   playing-card ratio (taller than wide) the first report asked for. That
   reversal is exactly what "go with this instead" says happened, so it reads
   as the intended pivot rather than a smaller refinement of the same ask.

   Flagged rather than silently assumed: `.cracker-slot`'s own 36:44 (taller
   than wide, closer kin to the playing-card ask) is the one candidate this
   file's own reasoning does not rule out, and is the alternative if this
   guess is wrong -- see #1545's own PR body and ticket comment. */

.warmup-on { align-items:center; }

/* Same shape as `.beat, .fr-screen` (this file, above) -- centred, a column,
   an 18px rhythm between the kicker/title/grid/skip -- duplicated rather
   than joined to that selector list, because this block is append-only (see
   the header above) and that rule sits mid-file. */
/* #1580: `align-self:stretch` is the whole fix for "the grid is content-sized".
   Both hosts (`#stage.warmup-on` here, `.roundstage.warmup-on` in
   `skills.css`, out of this ticket's file fence) set `align-items:center` on
   THEMSELVES, which is what made `.warmup-screen` -- their flex item, in a
   column flex container, so its CROSS axis is width -- shrink to a
   fit-content width instead of taking the column's own width. `.warmup-grid`
   already declares `width:100%` (below), which is a no-op against an
   indefinite/shrink-to-fit container: a percentage width on a descendant
   used while computing an ancestor's OWN shrink-to-fit size resolves as if
   it were `auto`, so the grid fell back to ITS intrinsic (max-content) width
   -- driven by `.warmup-card`'s aspect-ratio and 64px floor, never by the
   room available (measured 168.7px inside a 672px host, and identically
   168.7px inside a 393px phone's ~361px column -- #1580's own ticket).
   `align-self:stretch` overrides the HOST's `align-items` for this one flex
   item regardless of what it says, giving `.warmup-screen` a real, definite
   width (the host's content-box width) -- which is what makes `width:100%`
   on `.warmup-grid` resolve to something real, up to its own `max-width`
   ceiling. Fixed here, in this file alone: neither host file needed
   touching, which is also why this file's own header can say `.warmup-on`'s
   `align-items:center` (below) never had to change. */
.warmup-screen { text-align:center; display:flex; flex-direction:column;
                 align-items:center; align-self:stretch; gap:18px; }
.warmup-kicker { margin:0; font:600 12px/1 var(--mono); letter-spacing:.04em;
                 text-transform:uppercase; color:var(--muted); }
.warmup-title { margin:0; font-family:var(--serif); font-size:20px; color:var(--ink); }

/* 4 columns x 2 rows, 8 cards -- #1580 (2026-09-03, was 2x4). Still never
   derived from a payload length: a payload this screen ever paints IS four
   pairs, by `warm_up_service.py`'s own contract (an incomplete draw declines
   rather than shipping a smaller grid).

   #1545 (same day, earlier) chose 2 columns because halving the column
   count was what bought `.warmup-card` a bigger floor-driven size -- true
   only because the grid was, at the time, content-sized around that floor
   rather than filling the room it was given (#1580's own finding: 169px
   inside a 672px host). The owner's `wide-4x2` ruling (#1580's ticket
   comment) settles both halves of the shape question at once: the card
   KEEPS `.cracker-key`'s wide-not-tall ratio (`aspect-ratio` below,
   unchanged), and the board goes 4 across / 2 down to use that shape rather
   than towering it 2x4. With the fill fix (`.warmup-screen`'s own
   `align-self:stretch`, above) making `width:100%` real, 4 columns is now
   what makes `.warmup-card` bigger, not smaller -- see that block's own
   comment for the arithmetic that has to hold for this to be a fix and not
   a regression. */
/* #1586: `--warmup-wrong-ring` and its light override used to be declared
   here -- a private, differently-named copy of the ring colour
   `word_match.css` was declaring at the same two literals under the name
   `--wrong-sheen`. Both are gone. The colour, the count, the duration and the
   keyframe are now declared once for every judged surface in
   `shared/beat_card.css`, whose list names
   `.warmup-card.wrong .warmup-card-front`; this file keeps only the tint. */
/* #2033: TWO COLUMNS, Hebrew beside English, both face up. Was
   `grid-template-columns:repeat(4, 1fr)` over eight shuffled face-down cards
   -- the flip board. The owner asked for the matching game instead, so the
   grid holds two `.warmup-col` stacks rather than eight tiles, and the
   playing-card aspect ratio went with the flip: a tile showing a word wants
   to be wider than tall, which is the opposite of a card back. */
.warmup-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px 14px;
               width:100%; max-width:360px; align-items:start;
               touch-action:manipulation; /* #2925, with `.warmup-card` below */ }
.warmup-col { display:flex; flex-direction:column; gap:10px; min-width:0; }

/* #1966: `--warmup-card-ratio:5 / 7` -- back to a playing-card shape, and this
   time it is the LITERAL ratio rather than "closer to a playing card" (see
   the paragraph below on why #1545 went the other way). Source of the
   number: a standard playing card (poker/bridge stock, ANSI/USPCC) is
   2.5in x 3.5in -- 2.5/3.5 = 5/7 = 0.7142857. Poker and bridge width differ
   (2.5in vs 2.25in) but both use the same 3.5in height and the same 5:7-ish
   proportion is what the owner's own report named outright ("2.5 x 3.5 in --
   a 5:7 ratio, 0.714"), so poker's own ratio is used verbatim rather than
   picking a second source and rounding. A custom property, not a bare
   `aspect-ratio:5/7` literal, so the one number the whole shape derives from
   has a name a future reader can grep for.

   #1545 (2026-09-03) had turned this ratio around on purpose, after the
   owner watched the safe-cracker's `.cracker-key` on screen and said "go
   with this instead" of the playing-card ask he opened that same ticket
   with -- SEE THAT TICKET, not this file, for why `29/24` (wider than tall)
   was ever correct. #1966 is a later, separate report asking for the literal
   ratio again, with the exact inches named this time; it is not a revert of
   #1545's reasoning, it is the owner overriding his own override a second
   time, on the record, and this file follows the most recent instruction.

   `min-height:64px` is UNCHANGED on purpose -- see the very next paragraph
   for why (`#1789`'s cross-file coupling to `beats/picture.css`, out of this
   ticket's file fence). At a real measured 393px-phone card width of
   75.25px (`.stage`'s 24px side padding either side of a 393px viewport, 4
   columns, the grid's own 14px column gap -- `#1580`'s own arithmetic below
   ran the same computation against the OLD ratio), the new WIDTH-derived
   height is `75.25 * 7/5` = 105.35px, nowhere near the 64px floor -- taller,
   not shorter, so the floor is even less reachable than it was under
   #1545's wide ratio (which needed `((360-3*14)/4) * 24/29` = 65.8px,
   already barely past it). A headless-Chrome probe against the real,
   running `/static/study.html` (`getBoundingClientRect()`, not hand
   arithmetic alone) read the card at **75.25 x 105.34px** post-change --
   0.01px off the pure math above, sub-pixel track rounding, not a bug.
   4 columns stays the redistribution: a 2-column x 4-row alternative was
   rendered and measured too, at 164.5 x 230.3px per card and ~957px of
   total grid height against an 852px phone viewport -- it does not fit
   without scrolling past the fold, where 4x2 does (~224px of grid, well
   inside the same viewport). See #1966's own ticket comment for both
   renders.

   #1859 deliberately does NOT touch this floor, even though "slightly
   wider and longer" was the owner's own literal ask. Two reasons, in the
   order they were found: first, the actual measured overflow (see
   `overflow-wrap` and the tightened `padding` below) closes without
   moving it -- a bigger floor was never what the defect itself needed.
   Second, and load-bearing even if the first reason had gone the other
   way: `tests/js/test_beat_picture_options_size.mjs` (#1789) reads this
   EXACT `min-height` and asserts `beats/picture.css`'s `.pic-opt` matches
   it pixel-for-pixel -- "if .warmup-card's floor ever changes, .pic-opt's
   own instruction was to track it" (that guard's own words). `beats/*` is
   outside this ticket's file fence and, as of this fix, is a sibling
   branch's own live work -- moving this number would either break that
   guard outright or require editing a file this ticket may not touch.
   This is the cross-file coupling the brief asked to be found before
   resizing anything: named here so a future "make the warm-up card
   visibly bigger" is understood as a two-file change from the start,
   coordinated with whoever owns `.pic-opt` at the time, not a
   `warm_up.css`-only edit.

   `width:100%` and `min-width:0` (below) are the other two things a "derive
   the card from the track" design needs, and both were missing before
   #1580 because the OLD 2-column board never needed them (its floor-driven
   size was always smaller than its track):

   - A grid item carrying `aspect-ratio` does NOT stretch to fill its own
     cell by default -- `justify-items`'s `normal` behaves as `stretch` for
     an ordinary box, but an aspect-ratio'd box with an auto size instead
     sizes itself from the ratio (here, still floor-driven) and merely
     ALIGNS inside the cell, leaving it derived from nothing at all. An
     explicit `width:100%` is what makes the card's own box take the
     track's resolved width, so `aspect-ratio` has something real to turn
     into a height.
   - A grid item's `min-width` defaults to `auto`, which for an
     aspect-ratio'd item resolves to its floor-derived size -- under the OLD
     `29/24` ratio that was 77.33px (`min-height:64px` transferred through
     the ratio), and #1580 measured the study page's own 4-card row wanting
     351.3px of it against ~345px available, ~8px of real overflow this
     `min-width:0` closed. #1966: under the NEW `5/7` ratio the same transfer
     is `64 * 5/7` = 45.71px -- smaller, not larger, because a TALLER ratio's
     min-HEIGHT floor implies a much narrower min-WIDTH. Four cards at
     45.71px plus three 14px gaps want 224.84px, well inside the ~345px
     available, so this floor no longer binds at 393px at all. `min-width:0`
     stays anyway: it is what makes `width:100%` (above) the thing that
     actually sets the card's width rather than an unreachable `auto`
     floor, and it costs nothing now that the floor it once had to defeat
     is no longer close to binding -- the same protection for a host
     narrower than this one has ever been measured against. */
/* #2033: `aspect-ratio` and `perspective` are gone with the flip. The tile is
   a word now, so its height comes from its text and its padding, with the
   app's 44px tap floor as the minimum (#1973's convention -- margin is not the
   hit box, so this is a real min-height on the button itself). */
/* #2033: the floor stays 64px, NOT the app's bare 44px. `.pic-opt`'s own rule
   was instructed to track this number (#1789 measured the two equal at 64px on
   a real 393px render, and `test_beat_picture_options_size.mjs` holds the
   match), so lowering it here would silently shrink the picture round's
   options -- a change nobody asked for, made as a side effect of a warm-up
   ticket. */
.warmup-card { position:relative; width:100%; min-height:64px; min-width:0;
               display:flex; align-items:center; justify-content:center;
               padding:10px 12px; border:1px solid var(--line); border-radius:12px;
               background:var(--raise); box-shadow:var(--shadow);
               --rest-shadow:var(--shadow);
               cursor:pointer; -webkit-tap-highlight-color:transparent;
               /* #2925: iOS holds a quick second tap back as a possible
                  double-tap zoom, and the owner's two fast taps (left, right)
                  lost one. `manipulation` drops that wait and keeps pan/pinch. */
               touch-action:manipulation; }
/* #2033: the selected tile, replacing `.flipped`. A pick is not an answer --
   the answer is the second tile -- so this is a quiet highlight, not a verdict
   colour. */
.warmup-card.picked,
/* #2925: a tap made while a miss's red holds is kept (warm_up.js `queued`) and
   shows the same outline at once, so it is acknowledged on the tile. */
.warmup-card.queued { border-color:var(--accent); }
/* #2853: a tactile press. At rest each tile carries a crisp, slight drop
   shadow (`--warmup-press-depth` below the tile, one layer on top of the soft
   `--shadow` the tile always had). While pressed the tile moves DOWN by exactly
   that depth and the whole shadow goes -- the tile has "sunk into" the place
   its shadow was -- and on release it pops back up and the shadow returns.
   Subtle on purpose (the owner: "not a super obvious thing").

   Scoped to `.warmup-card`, so it reaches only Flip and match's tiles (the
   study page's pre-story board and the /skills hub game share this sheet and
   this class) and no other beat's option button.

   The edge is `--ground` darkened, not a per-theme colour: it is what the
   shadow would be if the page's own floor were in shade, so it follows the
   light/dark/`data-theme` tokens with no theme block of its own here.

   `--rest-shadow` is NOT touched: the wrong-pair ring (`beatWrongPulse`) runs
   on the face inside the tile and reads that variable, and it must not
   inherit the drop. The pressed look is `.pressing` (set by `warm_up.js`'s
   pointer handlers, because `:active` alone is unreliable on iOS Safari) or
   `:active` (keyboard Space/Enter, and any engine that does fire it). A
   settled pair (`.matched`) and a judged miss (`.wrong`) never press: their
   tiles are inert, and a shadow that came and went on them would be a jump. */
.warmup-card { --warmup-press-depth:3px;
               --warmup-press-edge:color-mix(in srgb, var(--ground) 78%, #000);
               box-shadow:var(--shadow), 0 var(--warmup-press-depth) 0 var(--warmup-press-edge);
               transition:transform 200ms var(--ease-spring),
                          box-shadow 200ms var(--ease-out); }
.warmup-card:not(.matched):not(.wrong):active,
.warmup-card.pressing:not(.matched):not(.wrong) {
    transform:translateY(var(--warmup-press-depth));
    box-shadow:0 0 0 0 transparent; }
/* The going-down is quick (60ms, no spring); only the release pops. Its own
   rule, and without `:not(.wrong)`: `tests/test_beat_wrong_pulse.py` reads any
   motion property on a selector naming `.wrong` as a private wrong-answer
   treatment. A matched/wrong tile has no depth change to time, so this is
   inert there. */
.warmup-card:active,
.warmup-card.pressing { transition:transform 60ms ease-out, box-shadow 60ms ease-out; }
.warmup-card:focus-visible { outline:2px solid var(--accent); outline-offset:2px;
                              border-radius:10px; }
.warmup-card-inner { position:relative; display:block; width:100%; height:100%;
                     transition:transform 420ms cubic-bezier(.2,.7,.3,1);
                     transform-style:preserve-3d; }
/* #1761: the flip LIFTS. `flipLift` scales to 1.06 at the 90deg midpoint and
   lands on the exact `transform:rotateY(180deg)` this rule already
   declares statically -- the same "animate through, land on the value
   that's already there" shape `shared/beat_card.css`'s `verdictBloom` uses
   on `.opt.correct` (that file's own comment on the rule), so there is no
   fill-mode question and no visible jump when the one-shot run ends and
   this static declaration takes back over. 420ms on the shared ease-out,
   not the 500ms default: `warm_up.js`'s `WRONG_FLOOR_MS` (840ms, untouched
   by this ticket) is measured against it -- 420 + 3*140 (the wrong ring's
   three pulses, `shared/beat_card.css`'s `beatWrongPulse`) = 840, exactly
   the floor. If the flip ever grows past 420ms the floor must grow too. */
.warmup-card.flipped .warmup-card-inner,
.warmup-card.matched .warmup-card-inner { transform:rotateY(180deg);
    animation:flipLift 420ms cubic-bezier(.2,.7,.3,1) 1; }
@keyframes flipLift {
  0%   { transform:rotateY(0) scale(1); }
  50%  { transform:rotateY(90deg) scale(1.06); }
  100% { transform:rotateY(180deg) scale(1); }
}
/* #1859: `overflow-wrap:anywhere` -- there was no wrapping rule of any kind
   here before, on either face. Default `normal` wraps at a SPACE, and the
   two labels that actually overflowed the old 64px-floor card measured
   against the real corpus (`app.services.warm_up_service.build_warm_up`'s
   own candidate pool, every source, not the one word he happened to report)
   are both single, space-free tokens: the longest Hebrew lemma this board
   can ever deal is טמפרטורה ("temperature", 8 chars, clipped ~7px in a
   361px-wide host) and the longest single-word English gloss is
   "grandmother"/"temperature" (11 chars, clipped ~8px) -- his own reported
   word, but not the worst case; nothing in `normal` wrapping can break
   either, so both silently clipped under the old rule regardless of card
   size. `anywhere` is what lets a token break mid-word onto a second line
   when it has nowhere else to go, which is what closes that gap -- but a
   THIRD candidate needed one more px of headroom: the longest multi-word
   English gloss this board can ever deal, "a news article, a report" (24
   chars, `warm_up_service.py`'s own corpus-wide longest), wraps at its
   spaces just fine but still overflowed its own THIRD line by 2px inside
   the old `padding:6px` box. `padding:4px` (below) buys back those 2px on
   every card without touching `min-height` -- see that declaration's own
   comment for why the floor itself stays put (#1789's cross-file guard).
   Measured after both changes: 0px overflow, either axis, on every
   candidate the corpus can deal, at the phone's own ~345px card width. */
.warmup-card-face { position:absolute; inset:0; display:flex; align-items:center;
                    justify-content:center; padding:4px; border-radius:10px;
                    border:1px solid var(--line); background:var(--surface);
                    color:var(--ink); font-size:14.5px; line-height:1.3;
                    overflow-wrap:anywhere;
                    backface-visibility:hidden; overflow:hidden; }
/* #1761: the back is the app's MARK, not a placeholder `?`. The text node
   stays -- a test may still read it (this file's own header on the first
   draft's bug, and #1473's original warning) -- but is painted transparent;
   a `::before` draws the watermark instead, so the glyph and the hidden
   text never fight over the same box. `.warmup-card-face`'s own
   `position:absolute` (above) is what keeps THIS face's height from
   collapsing face-down -- do not make `.warmup-card-back` or its
   `::before` `position:relative`, that is exactly the bug the first draft
   of this screen had. 44px (now 52px, #1966 below) * text scale, in `--heb`
   (the serif reading stack, `shared/fonts.css` -- not `--sans-he`, which the
   front face's Hebrew word uses, below): 44px settled by the owner
   2026-09-05 after seeing a word-sized version too.

   #1966: rescaled to **52px**, proportional to the card's NEW room rather
   than left at the number #1761 tuned for the old one -- read this box's
   own two dimensions, not assumed. Before this ticket the card measured
   75.25 x 64px (width x height, min-height-floored -- `.warmup-card`'s own
   comment above); 44px was never floor- or width-bound in that box (a
   Hebrew glyph's rendered box is roughly square, and 44 clears both 64 and
   75.25 with room spare), so 2026-09-05's number was an eyeballed "reads
   as a watermark" choice, not a computed one -- there was nothing to
   preserve exactly, only the PROPORTION of the box it filled: 44/64 =
   0.6875 of the box's then-shorter axis (height). After #1966 the card is
   75.25 x 105.34px -- WIDTH is now the shorter axis, and it barely moved
   (75.25px before and after; only height grew) -- so the same 0.6875
   proportion applied to the new shorter axis is `75.25 * 0.6875` = 51.7px,
   rounded to 52. This grows the mark modestly rather than leaving it look
   adrift in a much taller box (44/105.34 = 42% fill, visibly smaller
   relative to its own room, against the reasoning above) while staying
   safely inside the 4px-padded 75.25px width (52 + 2*4 = 60 < 75.25) so it
   neither clips nor touches the card's own border. Rendered and read back
   with the eye, not just the arithmetic -- see #1966's own ticket comment
   for the before/after screenshots. */
.warmup-card-back { color:transparent; }
.warmup-card-back::before { content:"מ"; position:absolute; inset:0; display:flex;
    align-items:center; justify-content:center;
    font:800 calc(52px * var(--milim-text-scale))/1 var(--heb);
    color:var(--line); direction:rtl; }
/* The front face is pre-rotated 180deg so it faces the viewer exactly when
   `.flipped`/`.matched` rotates the shared `.warmup-card-inner` the other
   180 -- the same two-face-opposite-rotation shape every flip-card pattern
   uses `backface-visibility:hidden` for. #1761: `box-shadow:var(--shadow)`
   is the "deeper shadow while it is up" the ticket asks for. It costs
   nothing while this face is turned away -- `backface-visibility:hidden`
   hides the whole face, shadow included, the same way it already hides
   this face's content -- and only ever paints once the card has turned to
   show it, so there is no separate `.flipped`/`.matched` selector needed
   to keep it in step with the transform rule above. */
/* #2033: was `.warmup-card-face.warmup-card-front`, pre-rotated 180deg to face
   the viewer only once the card flipped. There is no second face any more, so
   the type rules sit on the one face and the elevation moved up to
   `.warmup-card` itself (which is now the bordered thing). #1777's
   `--rest-shadow` moved with it, for the same reason: `beatWrongPulse`
   animates box-shadow and would otherwise blank it. */
.warmup-card-face { font-family:var(--serif); }
/* #1761: Hebrew 18px semibold (was 16px, no weight) -- the safe-cracker's
   own arm's-length reasoning (this file's header, "8 not 12, bigger")
   applies here too: unreadable at a phone's arm's length below this size.
   At text scale 1.15 this is 20.7px; the longest warm-up Hebrew is one
   word, so it wraps at most once.
   #2609 corrects the last clause above: "wraps at most once" was recorded
   here as an accepted consequence, not flagged as a defect, and the owner's
   own report is that it reads as a bug -- "בסדר is on two lines for some
   reason" (2026-09-25, four letters, nowhere near #1859's 8-char worst
   case). `overflow-wrap:anywhere` (the general rule above) is what GIVES a
   mid-word break permission to happen at all; it stays for the ENGLISH
   face, where a single-word gloss can genuinely run to 11 chars (#1859's
   own measurement) with no space to fall back on. The Hebrew face
   overrides it back to `normal` (its inherited default, restated here so a
   reader does not have to know that) -- Hebrew never wraps at a space
   either (a lemma is one token; `columnsFor` deals one per tile), so
   `normal` really means "never wraps" for this face, not "wraps at a space
   instead". `white-space:nowrap` is the other half: `overflow-wrap` alone
   still permits `anywhere`'s OWN break-if-it-would-otherwise-overflow
   escape hatch to fire even under `nowrap` (that combination is the
   standard "single line unless it truly cannot fit" recipe) -- restating
   `overflow-wrap:normal` here is what actually withdraws the permission,
   `nowrap` is what stops the DEFAULT space-wrapping behaviour from ever
   applying in the first place (moot for a one-token lemma, stated for the
   same reason as the `overflow-wrap` restatement: nothing here should
   depend on what the general rule above happens to say next). Together
   they make this face behave exactly like `text-overflow:ellipsis` expects
   a block to: one line, and `overflow:hidden` (the general rule, above)
   clips instead of breaking on the rare word too wide for its column --
   truncated with a visible mark, never silently clipped bare and never
   broken mid-word, which is what the old `normal`-wrap clipping #1859
   fixed actually looked like for the case this rule now reserves for
   Hebrew alone. */
/* #2033: `data-side` moved onto the BUTTON when the face stopped being the
   element asked -- the handler reads it to refuse two picks from one column. */
/* #3167: the ellipsis is GONE, and so is the clip. The owner's iPhone showed the matched
   tile of בסדר as "...בס" -- a four-letter word cut on a card ~130px wide while its neighbours
   were whole -- and this `text-overflow:ellipsis` is the only thing that can draw that mark
   (Blink ignores it on a flex face, which is why every Chrome sweep read 0 cuts; WebKit does
   not, and truncates early with RTL text in a nowrap flex box). `white-space:nowrap` alone is
   #2609's one-line fix. A word that ever really were too wide now overflows visibly rather
   than being cut: the widest Hebrew the board can deal has 33px to spare, so this is the
   honest failure, not the expected one. `tests/test_warm_up_no_ellipsis_3167.py` pins it. */
.warmup-card[data-side="he"] .warmup-card-face { font-family:var(--sans-he);
    font-size:calc(18px * var(--milim-text-scale)); font-weight:600;
    overflow-wrap:normal; white-space:nowrap; overflow:visible; }

/* Memory rung 4 ("Match to pictures"): a tile that shows a drawing instead of
   the English. The drawing is inline SVG stroking `currentColor` (picture.py's
   header on why it cannot be an `<img>`), so it takes `--ink` from the face
   and is legible in both themes with no second copy. The floor rises from the
   64px a word needs to 96px: a line drawing at 64px minus padding is ~48px, a
   glove or a bench at that size is a smudge (`.pic-tile-art` in
   `beats/picture.css` is the picture round's own answer, ~2x that). The SVG's
   own `role="img"` and `<title>` stay, and the button's `aria-label` names it.

   #2801: the two columns are independent stacks, so a 96px picture tile beside
   a 64px word tile left the rows out of line (the owner: "make the game visually
   balanced"). The floor now sits on the GRID, not on the picture tile:
   `warm_up.js` marks a board that shows any drawing `warmup-grid-pics`, and
   then EVERY tile on both sides is 96px. Both columns hold four tiles with the
   same 10px gap, so equal tile heights are equal row tracks -- no grid rewrite,
   and the word tile never grows past its 64px floor on a board with no
   drawing (that board carries no `-pics` class and is unchanged). The face is
   absolutely positioned, so a tile's height is its floor and never its text:
   a long gloss cannot push one row out of line.

   The shared height is 96px only where the screen has room for it. Four 96px
   rows plus the 30px of gaps are a 414px board, and at 375x667 the fixed
   bottom nav (top edge 615px) then sits on top of the Continue button: the
   board starts 191px down the hub, Continue is 18px below it and 44px tall,
   and the last shipped board (two drawings, 350px) cleared the nav by 12px.
   `(100dvh - 347px) / 4` is that same 12px of clearance solved for the tile
   (347 = 191 + 30 gaps + 18 + 44 + 52 nav + 12), so it reads 80px at 667 and
   is clamped to 96px from ~731px up (812 high: 96px, Continue at 623-667, the
   nav at 760). 80px floors it: a drawing is still 62px square there, against
   the 78px it had at 96. The first `min-height` is the answer for a browser
   that does not know `dvh`, which drops the second declaration. */
.warmup-grid-pics .warmup-card { min-height:80px;
    min-height:clamp(80px, calc((100dvh - 347px) / 4), 96px); }
.warmup-card-pic .warmup-card-face { padding:8px; color:var(--ink); }
.warmup-card-pic .warmup-card-face svg { width:100%; height:100%; display:block;
                                          max-width:80px; max-height:80px; }

/* Matched -- green and STAYS (#1473's own wording), never removed by
   anything in this file. `--ok-soft` on both the border and the background,
   the same receding shape `word_match.css`'s own `.matched` uses and for the
   same reason (#429): the eye should land on what is still unmatched.

   #1761: Lantern verdicts. The pair blooms ONCE (`verdictBloom`,
   `shared/beat_card.css`'s own keyframe -- document-global and already
   loaded by both hosts, so it is reused by name here rather than
   redeclared, the same way `cloze.css`'s `.cloze-option.correct` reuses
   it) and then settles to a quiet 1px `--ok-halo` ring, NOT `.opt.correct`'s
   own permanent 2px ring + 22px glow: a matched pair sits on screen for the
   REST of the game, beside pairs still being worked, so keeping the full
   bloom lit the whole time would be exactly the toy-app note the design
   brief warns against elsewhere (07-beats-and-guide.md #1's own words:
   "stay visibly done ... without glowing"). The static `box-shadow` below
   is that resting ring; when the animation's one run ends it reverts to
   this declared value with no fill-mode question to answer -- the
   difference from `.opt.correct`'s own trick is that the resting value
   here is deliberately NOT the animation's own 100% frame, so the drop
   from bloom to ring IS the "settle" the ticket describes, not a seam to
   hide.

   #1858: `420ms`, NOT `var(--dur-bloom)` (540ms) -- found by reading the
   two numbers this rule was never checked against each other, the same way
   #1547 found the wrong-floor bug next door. `.warmup-card-inner`'s own
   flip (`transition:transform 420ms`, and `flipLift`, both below) and this
   bloom are triggered by the SAME class-add, in the SAME synchronous
   handler (`warm_up.js`'s click listener adds `.matched` to both cards in
   one breath) -- but at the shared token's 540ms the bloom outlives the
   420ms flip by 120ms. `verdictBloom`'s own keyframe peaks at 55% (carries
   the halo/glow OUT past their resting size) and spends the back half
   RECEDING to rest -- so for those 120ms after the card has visibly
   finished turning, its ring is still visibly shrinking on its own clock,
   which is exactly a second, unsynchronised motion tacked onto the end of
   the first -- the "pulses with a hitch when the flip finishes" the ticket
   reports. `--dur-bloom` is a document-global token shared with
   `.opt.correct`/`.cloze-option.correct` (out of this ticket's file
   fence) and is not itself wrong there -- neither of those has a
   geometric flip to stay in step with. The wrong-pair ring next door
   already gets this right on its own: `beatWrongPulse`'s three .14s
   pulses total exactly .42s, matching the flip's own 420ms (see that
   block's comment). This rule now matches it by the same rule: the
   bloom's own duration is pinned to the flip's, not to the shared token,
   so both effects land on their resting frame together. */
/* #2107: `.warmup-card-face`, was `.warmup-card-front`. #2033/#2035
   (2026-09-08) removed the front/back faces from the markup when the flip
   board became two face-up columns, and this selector was left naming an
   element that no longer exists -- so from that day a matched pair painted no
   green at all, only `.picked`'s amber outline (measured in a real Chrome on
   main, 2026-09-13). The owner noticed: "I feel like that did happen. Before."
   It had. The declarations are unchanged; only what they select is. */
.warmup-card.matched .warmup-card-face { background:var(--ok-soft); border-color:var(--ok-soft);
    box-shadow:0 0 0 1px var(--ok-halo);
    animation:verdictBloom 420ms var(--ease-out) 1; }

/* Wrong -- the tint only, since #1586.

   The ring itself (three pulses, `.14s` each, `.42s` total) is unchanged in
   what it does and completely changed in where it is written. This rule used
   to carry a private `@keyframes warmupWrongPulse` and four `animation-*`
   longhands of its own -- an exact copy of `word_match.css`'s numbers under a
   second name, and this file's own comment said so out loud: "ONE constant
   duplicated across two files because a beat may not import from a non-beat
   screen". #1586 answers that by putting the constant somewhere BOTH may read
   it: `shared/beat_card.css`, the sheet this screen's own card shell already
   comes from, whose wrong-answer list names this exact selector. No import,
   no sibling dependency, and now no second name either.

   #1547 (2026-09-03) still holds and is not affected: `warm_up.js`'s
   `WRONG_FLOOR_MS` is 840, not 420 -- see that file's own comment for why
   .42s of ring was never the number that mattered here. This board flips a
   real 3D card (`.warmup-card-inner`'s `transition:transform .5s`, below);
   word_match's identical-looking wrong state never had a transform to wait
   out. The three declarations left on this rule are NOT part of the keyframe
   and hold for the whole floor regardless -- only the ring stops at .42s.

   #2122: `.warmup-card-face`, was `.warmup-card-front`. Same defect as
   #2107/#2124's matched rule next door (#2033/#2035, 2026-09-08, removed the
   front/back faces from the markup) -- this rule painted nothing from that
   day on, so a mismatched pair held only `.picked`'s amber outline for the
   whole 840ms floor and never went red (measured in a real Chrome on main,
   both themes, 2026-09-13). The declarations are unchanged; only what they
   select is. */
.warmup-card.wrong .warmup-card-face { border-color:var(--no); background:var(--no-soft);
    color:var(--ink); }

.warmup-skip { align-self:center; }

@media (prefers-reduced-motion: reduce) {
  /* The FLIP, and this line is new (#1586). See the header's "The flip is
     `transition`, not `animation`" section for the correction it belongs to:
     the blanket `* { transition-duration:.01ms !important }` this block used
     to lean on is in `study.css`, not in this file, so `skills.html` -- which
     loads this sheet and not that one -- was flipping the full .5s for a
     learner who asked for no motion.

     `transition:none`, not a shortened duration, and unlike a keyframe there
     is nothing to reason about when it lands: `.warmup-card.flipped
     .warmup-card-inner` is a plain static rule that always paints the exact
     end state its class says, so removing the transition swaps the card
     instantly to the correct face and never to a half-turn. Nothing about the
     board's behaviour changes -- `warm_up.js` waits on its own timers
     (`WRONG_FLOOR_MS`, #1547), not on a transition event.

     The MISS's ring needs no line here any more. It is declared, and parked,
     in `shared/beat_card.css`'s one wrong-answer escape (#1586), together
     with every other judged surface's -- which was half the point of
     unifying it. The tint two rules up is untouched by that escape and is a
     real static state on its own, so a reduced-motion learner still sees the
     card go red the instant the miss is judged; only the three beats of ring
     are removed. */
  /* #2853: the press keeps its depth change (down, shadow gone) and loses
     only the glide -- it snaps between the two states. */
  .warmup-card { transition:none; }
  .warmup-card:active,
  .warmup-card.pressing { transition:none; }
  .warmup-card-inner { transition:none; }
  /* #1761: `flipLift` is a genuine `@keyframes` animation -- unlike the
     flip's own `transition` just above, it DOES have a fill-mode question --
     so it needs its own escape here, parked at the frame its class already
     paints statically (`transform:rotateY(180deg)`, the rule this selector
     shares, above). */
  .warmup-card.flipped .warmup-card-inner,
  .warmup-card.matched .warmup-card-inner { animation:none; }
  /* #1761: same reasoning for the matched bloom -- parked at the resting
     `box-shadow:0 0 0 1px var(--ok-halo)` its own rule already declares
     statically, above. A reduced-motion learner still sees the pair go
     green and ringed the instant it is judged; only the bloom's rise and
     fall are removed. */
  .warmup-card.matched .warmup-card-face { animation:none; }
}

/* #2606: the standalone "Flip and match" game's own progress bar -- three
   boards per game (`skills.js`'s `warmUpProgressHtml`/`updateWarmUpProgress`
   paint and update this), never painted by `boot`'s pre-story screen, which
   stays one board and never reaches this file's `.warmup-progress` markup
   at all. Sits as `$("stage")`'s first child, ABOVE the board itself --
   "if we later introduce a timer it should go under the completion bar"
   (the owner's own words) is why nothing here is a second column or an
   inline sibling: `.warmup-progress` is a full-width block with nothing
   after it today, so a future timer is a plain sibling appended below it,
   never a rework of this rule.

   `width:100%` on the bar itself (not `align-self:stretch`): unlike
   `.warmup-screen`, which sits inside `.roundstage.warmup-on`'s
   `align-items:center` and needs to override it, `.warmup-progress` sits
   inside plain `.roundstage` (`skills.js` sets `$("stage").className` to
   the bare class, without `.warmup-on`, precisely so its two children --
   this bar and `#warmupBoardHost` -- both get the default flex `stretch`
   cross-axis, no override needed). */
.warmup-progress { width:100%; display:flex; flex-direction:column; gap:4px; }
.warmup-progress-track { width:100%; height:6px; border-radius:3px;
    background:var(--line); overflow:hidden; }
.warmup-progress-fill { height:100%; border-radius:3px; background:var(--accent);
    transition:width 300ms ease-out; }
.warmup-progress-count { margin:0; text-align:center; font:600 12px/1 var(--mono);
    letter-spacing:.04em; color:var(--muted); }
@media (prefers-reduced-motion: reduce) {
  .warmup-progress-fill { transition:none; }
}
