/* #160, the word-match beat's board (#307 split out of study.css). */

/* #160's board: Hebrew in the source order the server sent, English shuffled
   beside it.

   One grid rather than two columns, filled column-first. The Hebrew is set
   larger than the English (19px against 15px), so as two independent flex
   columns each side computes its own row heights and the pairs slide visually
   out of line further down the board -- and matching the line-heights by hand
   would only hold until an English gloss wrapped. `1fr` rows make the two cells
   of a row share a height whatever is in them.

   `--rows` comes from the markup because the row count is drawn per board
   (#1417: `BOARD_MIN`-`BOARD_MAX` in word_match.py, no longer a constant);
   a literal `repeat(5, …)` here would misgrid every board that draws
   anything but 5.

   #2586, Sharp eye rung 3 ("A wider board"): `BOARD_MAX` itself now varies
   with the level (`word_match.py`'s `BOARD_MAX_BY_LEVEL`, 5 below rung 3, 6
   from rung 3 on) -- this file needed NO change for that, which is the point
   of `--rows` being markup-driven rather than a literal here: a sixth row is
   exactly as equal-height as a fifth. Measured at 375px (#2586, a real
   headless Chrome render of this beat's own `html()`): the card grows from
   442px to 522px (+80px, one tile row plus its 20px gap), no horizontal
   overflow, comfortably inside the visible stage -- this board starts from a
   much shorter card than picture.css's, so the margin #2586 flags there does
   not apply here. */
/* #475, the owner from real use: "the matching game should have its design
   spread out a bit more. They are stacked a bit too on top of each other."

   Measured before changing anything: 8px between 44px tiles is under a fifth
   of a tile, and the ten of them read as one striped block rather than ten
   things you pick from. That works directly against the beat's own job --
   #429's whole argument is that the eye should land on what is still
   unmatched, and a dense grid gives it nothing to land *between*.

   Row gap 8->16. The column gap goes further, 12->20, and the asymmetry is the
   point rather than an oversight: at 8 and 12 the board reads as a 2x5 grid of
   tiles, and "the Hebrew side and the English side" is the structure the game
   is actually played on. A column gap comfortably wider than the row gap is
   what makes two columns look like two columns.

   The space was already there and unbought: at 390x844 the stage is a centred
   flex column and the board was 252px of it. It is now 340px, which is still
   well inside the fold on the smallest phone the app targets.

   #482, the same owner on the same board a day later: "the matching game
   spacing is *better* -- the button height could be a bit higher and the
   spacing between the buttons a few more pixels. I don't want the user
   feeling cramped with clicking on the items." So the same move again, one
   step further -- rows 16->20, columns 20->24, tile floor 52->60 -- and the
   asymmetry #475 introduced is preserved rather than flattened, because the
   reason for it has not changed: a column gap wider than the row gap is what
   makes two columns look like two columns.

   That is 400px of a 844px screen at 390 wide, against a stage that also
   carries the prompt above the board and the skip below it. It is the last
   step this board has room for; the next request for air has to come out of
   the board's size (`BOARD_MIN`/`BOARD_MAX`, word_match.py), not out of the
   gaps. */
/* #1584, the owner from the same worksheet answer as the wrong-hold ticket:
   "Cards could also be a bit wider as some of the cards are too narrow to
   easily show the cards." Measured (real Chrome, real `beat_answers.chosen`
   text from demo.db, both phone widths this app targets -- see the ticket
   comment for the full table) before touching anything:

     narrowest phone (320px, beats/safe_cracker.css's own floor), column
     content width 95px: roughly a fifth of the English glosses this beat has
     actually dealt (14-19 chars, ~22% of demo.db's `beat_answers` rows) wrap
     to two lines already, and the four longest ever dealt (20-23 chars) wrap
     everywhere no matter how much width a change here can buy -- there is no
     amount of tile-widening within this phone's own frame that fits
     "adult, grown up, mature" on one line at two columns.

     this app's own standard test width (393px, `shared/build_stamp.css`'s
     "a true 393x852"), column content width 131.5px: only the three RAREST
     glosses (21-23 chars, 3 of 358 real answers, 0.8%) wrap under the
     UNCHANGED CSS -- this width was never the complaint's real target.

   So the fix that actually answers "too narrow" is at 320px, and the only
   lever available inside this file (`.stage`'s own 24px side padding is
   shared with every other beat, out of this ticket's scope) is bleeding the
   grid itself into that padding rather than shrinking anything inside it --
   the tile's own padding/min-height are #475's/#482's floor, not this
   ticket's to spend, and the column gap is already at #475/#482's own
   asymmetry floor (must stay wider than the 20px row gap, or two columns
   stop reading as two columns -- see the gap comment above).

   12px each side (half of `.stage`'s 24px, so a real margin from the phone's
   own edge survives): +12px per column, 123px -> 135px at 320, 159.5px ->
   171.5px at 393. Measured effect on this same real content: at 393 this
   takes every real answer shorter than the 21-char extreme down to one
   line (matches "quickly, rapidly, fast" and "to be left, to remain" go
   from wrapping to fitting); at 320 it still cannot rescue most of the
   14-19-char band -- that band's fix, if the owner still wants zero wraps
   at 320, is fewer/shorter glosses or fewer columns, both of which are
   `word_match.py`/board-shape changes outside a CSS ticket, and are named
   here rather than silently attempted.

   `margin-inline` bleeds symmetrically (stays centred in `.stage`), and can
   never exceed `.stage`'s own padding -- `.match` still ends inside
   `.stage`'s padding box, never past `.phone`'s border, so this cannot
   reopen #125's sideways-pan guard. Measured with a real Chrome dump of the
   full column (`document.documentElement.scrollWidth`): unchanged at both
   widths, confirming no new horizontal scroll. */
.match { display:grid; grid-auto-flow:column;
         grid-template-rows:repeat(var(--rows, 5), 1fr);
         grid-template-columns:1fr 1fr; gap:20px 24px;
         width:calc(100% + 24px); margin-inline:-12px; max-width:420px;
         /* #1053's sheen band, scoped to this board rather than added to
            study.css's `:root` palette -- one beat's transient wants no place
            in the tokens every page reads. The RGB triplets are `--ok`'s own,
            per theme, COPIED rather than `var(--ok)`'d for the reason
            study.css's `--ok-soft`/`--accent-soft`/`--glow` are all written
            the same way: CSS has no way to re-alpha an existing token, so a
            translucent sibling of a colour is always a second literal.
            Alphas are the measured ones -- see the `.matched` comment below
            for both themes' numbers and why they differ.

            #1586: `--wrong-sheen` and `--wrong-pulse-count` used to be
            declared here too, for #1411's ring. Both are gone -- the ring is
            one shared wrong-answer treatment in `shared/beat_card.css` now,
            declared on the elements themselves for every judged surface in
            the app at once, and this board's two tiles are in its list. The
            names are unchanged, so nothing that reads them had to move.

            ## #1798: the triplets move to Lantern's green; the alphas do not

            The paragraph above says the triplets are "`--ok`'s own". That
            stopped being true the day Phase 1 landed. #1759 retuned `--ok`
            (#6BCB8B -> #3FDC7C dark, #2E7A4D -> #137A42 light) and this
            literal, being a COPY, stayed on the retired pair -- so the one
            band that sweeps across a settled green tile was a DIFFERENT
            green from the tile under it, the border around it and every
            other verdict in the app. That is exactly the drift the copy was
            written to permit, arriving.

            The roadmap's own instruction (05-handoff-roadmap.md, Phase 4)
            is "its `--match-sheen` reads `--ok-halo`", and taken literally
            that is not shippable -- MEASURED, both themes, against the
            tokens `study.css` actually declares today:

                                    band vs settled tile   --ink vs band
                var(--ok-halo)    dark 1.81:1  light 1.31:1   6.78 / 10.19

            `tests/test_beat_word_match_sheen.py`'s
            `test_the_sheen_is_actually_visible_against_the_tile_it_crosses`
            holds this band to 1.5:1, so the light theme fails outright and
            the two themes stop being equally visible -- which is the one
            thing the alphas below were chosen for. `--ok-halo` is a RING
            token: it is read at 6px of spread beside an opaque 2px stroke,
            never as a wash the learner reads a word through, and its alpha
            was fitted to that job.

            So what moves here is the COLOUR and not the alpha. The triplets
            are Lantern's `--ok`/`--ok-halo` green (they share one triplet
            per theme), the alphas are #1053's measured ones, unchanged:

                                    band vs settled tile   --ink vs band
                this rule, dark   2.41:1   (was 2.27:1)      5.10:1
                this rule, light  2.22:1   (was 2.11:1)      6.02:1

            Both still clear the 1.5:1 visibility floor and the 4.5:1 AA
            floor for the word being read through the band, and the two
            themes stay within .2 of each other exactly as before. The
            divergence from the roadmap's wording is deliberate and is on
            the ticket, not silent. */
         --match-sheen:rgba(63,220,124,.38); }
@media (prefers-color-scheme: light) {
  /* Light `--ok` (#137A42) is DARKER than the tile it sweeps across, where
     dark `--ok` (#3FDC7C) is brighter than its own -- so the same alpha buys
     a much weaker band here. .60 is what makes the two themes' bands equally
     visible (2.22:1 against the settled tile, against dark's 2.41:1) rather
     than equally specified.

     #1798: the numbers above and the hex pair naming them are re-measured
     against the tokens Phase 1 shipped -- they used to read #2E7A4D/#6BCB8B
     and 2.12:1/2.29:1, which were true of the palette `--ok` had before
     #1759 and of nothing since. Re-measured, never widened. */
  /* #1586: the light `--wrong-sheen` that used to sit beside this is in
     `shared/beat_card.css`'s own light-theme block now, at the same value. */
  .match { --match-sheen:rgba(19,122,66,.60); }
}
/* #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. Dark
   restates the base rule's own `--match-sheen` (the `.match` rule above);
   light is the `@media` block above, unchanged. */
html[data-theme="dark"] .match { --match-sheen:rgba(63,220,124,.38); }
html[data-theme="light"] .match { --match-sheen:rgba(19,122,66,.60); }

/* #2648-rework, held on #2586's own coordinator review rather than shipped
   blind: Sharp eye rung 3 raises `BOARD_MAX` to 6, and the practice hub
   (skills.html)'s `.roundstage` -- header/nav real geometry included -- was
   measured (`paintBeat`, Chrome, 667px-tall viewport) at 418/418 for 4
   pairs, 498/441 for 5 (57px over) and 578/441 for 6 (137px over). A current
   iPhone Safari viewport (~730px, ~63px more stage) lets 5 fit but still
   leaves 6 roughly 75-120px short depending on how long the dealt captions
   are -- the last pair sits below the fold either way. Put to the owner as
   q12 on the 2026-09-27 decisions page; the recommended and CHOSEN answer is
   this rule: shrink the tile floor for a 6-pair board specifically, so 6
   fits without scrolling, rather than shipping the overflow or dropping the
   level to pictures-only.

   Scoped to `data-pairs="6"` (word_match.js), not to `--rows` or a bare
   `.match` change -- 4- and 5-pair boards must stay byte-identical, and
   `--rows` already drives the grid's own `repeat()` track count, so reading
   it a second way risked drifting the two apart on some future edit.

   Measured with a real headless-Chrome render of this file's own real
   `word_match.js`/`.css` (scripts/build_card_frame_page.py's `Chrome`
   CDP harness, a throwaway scratch script per this ticket -- not committed),
   at 390px, against the WORST CASE this beat has ever actually dealt: the
   six longest real (lemma, caption) pairs in `beat_answers` for this beat
   (`chosen_kind='label', correct=1`, scratch copy of `demo.db`, 2026-09-27) --
   headed by "adult, grown up, mature" (23 chars), which wraps to two lines
   at this tile's width and is why the RENDERED tile below is 56px, not the
   declared 44px floor: CSS Grid's `1fr` row tracks equalise every row in the
   board to its tallest cell (this file's own comment on `.match`), so one
   long caption anywhere on the board sets every row's height, not only its
   own.

     pairs         tile (declared/rendered)  row gap  match height  .roundstage content  available(730)  overflow
     6 (before)    60px / 76px               20px     556px         634px                515.8px         118.1px
     6 (after)     44px / 56px               12px     396px         474px                515.8px         0px (41.8px to spare)

   44px is the WCAG tap-target minimum itself, not a design choice -- there
   was no room left to spend on comfort at this rung without reopening the
   scroll #475/#482 fixed, so this rung's board gives up the comfort those
   two tickets bought (44px is what 4/5-pair boards had before #475) rather
   than the ceiling itself. No font-size is touched here at all (the 16px
   iOS zoom-floor test, `test_css_contract.mjs`, does not even reach this
   file's buttons -- they are not text ENTRY -- but the instruction was to
   leave type size alone regardless, and the wrap that drives the worst case
   is a WIDTH property, unaffected by a vertical padding/gap trim). 4- and
   5-pair boards read none of this rule at all (`data-pairs` never equals
   "6" on them) and are unchanged down to the byte -- confirmed by
   re-running this same probe against them before and after this rule
   landed: 442px/538px content at both 667 and 730, identical to `main`. */
.match[data-pairs="6"] { gap:12px 24px; }
.match[data-pairs="6"] .match-he,
.match[data-pairs="6"] .match-en { min-height:44px; padding:6px 14px; }
/* `min-height` 44->52 (#475) ->60 (#482) and more padding with it. 44 was the
   floor, not a design: it is the minimum comfortable touch target for a thumb,
   and it must never go below that again -- so the tile grows from a floor it
   was sitting exactly on, which is why it felt cramped rather than merely
   close. */

/* #2661: a 5-pair board's five LONGEST real captions ("adult, grown up,
   mature" leads, 23 chars) overflow the practice hub's stage by ~22px at a
   ~730px phone -- #2648-rework's own worst-case probe found it while landing
   the 6-pair rule above, out of that ticket's own scope. TYPICAL 5-pair
   content already fits with room to spare (#2648-rework's own table: 458px
   of 515.8px available at 730, 0 overflow), so this is a rare edge case, not
   the common board -- unlike the 6-pair rung, which is always the denser
   ceiling and gets the unconditional shrink above.

   Scoped on BOTH `data-pairs="5"` and `data-long` -- the second attribute is
   set by `word_match.js`'s `wire()` only when a real caption actually
   wrapped to two lines on THIS board (a `Range`-over-text-node check, since
   wrapping depends on the tile's rendered width, unknowable at markup-build
   time). A 5-pair board that never wraps reads neither this rule nor any
   different layout at all -- byte-identical to before this ticket.

   Gap alone (20px -> 12px, the same value the 6-pair rule above uses) was
   tried first and measured: it clears the overflow at 730 with exactly 0px
   to spare -- a knife's-edge fit this file will not ship on. Adding the same
   44px/6px-padding tile shrink the 6-pair rule already uses buys real
   margin instead, at the cost this rule is content-gated to pay only on the
   rare board that actually wraps: measured with a real headless-Chrome
   render of this file's own shipped word_match.js/.css
   (scripts/build_card_frame_page.py's `Chrome` CDP harness, a throwaway
   scratch script per house style -- not committed) against the practice
   hub's real `skills.html` shell and the five longest real (lemma, caption)
   pairs `beat_answers` has ever recorded for this beat (`chosen_kind='label',
   correct=1`, a scratch copy of `demo.db`):

     `.roundstage` (`#stage`) content vs available space, worst-case content, 390px wide

     pairs                 gap/floor/pad     match height  content  available(730)  overflow
     5 (before)             20px / 60 / 16    460px         528px    526px           2px
     5 (after, gap only)    12px / 60 / 16    428px         526px    526px           0px
     5 (after, shipped)     12px / 44 / 6-14  328px         526px    526px           0px

   `content` reads 526px in both "after" rows, not the match's own smaller
   height: `.beat` (shared/beat_card.css) is `flex:1` with no `min-height:0`
   of its own, so once its own content (match + gap + Continue) fits inside
   the space `#stage` hands it, `.beat` GROWS to fill that space rather than
   sitting at its content's true minimum -- `#stage.scrollHeight` then reads
   the filled box, not the content's real floor, and "0px overflow" is all
   this specific metric can show once a board fits at all. The MATCH HEIGHT
   row is where the real margin shows: gap-only left the board's actual
   content at 428px against 526px available (98px of slack the "0px
   overflow" figure alone hides); the shipped rule takes that down to 328px,
   198px of slack -- real headroom for a future caption longer than any of
   today's, not a knife's edge.

   (These numbers are this ticket's own re-measurement, not #2648-rework's
   quoted 538/515.8/22.1 -- a different scratch harness run, a different
   exact caption sample and a different session/header state can each move
   the absolute numbers a little; both runs agree on the DIRECTION and the
   order of magnitude, and this rule is sized against ITS OWN before/after
   pair, measured the same way both times.)

   The 44px floor is still the WCAG tap-target minimum, never below it, same
   as the 6-pair rule's own comment -- and it is paid only when a caption
   actually wraps, which #2648-rework's own table says is the rare case
   (typical 5-pair content: 0 wraps, 0 overflow, untouched by this rule at
   all). Column gap is untouched (24px, same asymmetry floor #475/#482 set: a
   column gap wider than the row gap is what makes two columns read as two
   columns) and stays wider than the shrunk row gap here too. */
.match[data-pairs="5"][data-long] { gap:12px 24px; }
.match[data-pairs="5"][data-long] .match-he,
.match[data-pairs="5"][data-long] .match-en { min-height:44px; padding:6px 14px; }

/* #2663: the 6-pair rule above (`.match[data-pairs="6"]`) was sized against
   the practice hub (skills.html) only -- #2648-rework's own probe never
   painted `study.html`, whose chrome (the `.pace` bar, `#ask`'s tappable
   card and study's own header) leaves LESS vertical room than the practice
   hub's `.roundstage` for the identical board. Measured with a real
   headless-Chrome render of this file's own shipped word_match.js/.css
   (scripts/build_card_frame_page.py's `Chrome` CDP harness, a throwaway
   scratch script per house style -- not committed) against BOTH real page
   shells, 390px wide, the six longest real (lemma, caption) pairs
   `beat_answers` has ever recorded for this beat (`chosen_kind='label',
   correct=1`, a scratch copy of `demo.db`, headed by "adult, grown up,
   mature" which wraps to two lines at this tile's width, same worst case
   #2648-rework used):

   `study.html`'s own `#stage` does not grow-to-fill the way `.roundstage`
   does on the practice hub (see the 5-pair rule's comment above on that
   growth) -- it sizes to its board's actual content up to a real ceiling,
   so a board that fits reads `content == available` (the box just hugs its
   content, 0 overflow, uninformative) and only a board that OVERFLOWS
   reveals the true ceiling (the box gets clamped, `content > available`).
   The clamp only ever showed up on the BEFORE state below, so that row is
   what fixes the ceiling this rule is measured against (504px at 730,
   441px at 667 -- both agree exactly with #2663's own filed numbers, "514
   vs 504"); `content`/`available` on the AFTER rows are the same
   tautological "0 overflow" reading the 5-pair comment above already
   documents, so MATCH HEIGHT is the real before/after signal there, same
   as that comment's own table:

     `#stage`, worst-case 6-pair content, 390px wide

     page          state                     match height  content@730  ceiling@730  overflow@730
     study.html    6-pair rule only (before)  396px         514px        504px        10.4px
     study.html    + this rule (after)        352px         470px        504px        0px (34px to spare)
     skills.html   6-pair rule only (before)  396px         526px        (never clamped at 730; fits)
     skills.html   + this rule (after)        352px         526px        (still never clamped; more spare)

   Content-gated on `data-long` (set by `word_match.js`'s `wire()` only when
   a real caption actually wrapped to two lines on THIS board), the same
   pattern #2661 used for 5 pairs above, rather than tightening the
   unconditional 6-pair rule itself: a 6-pair board that never wraps is
   measured unaffected (`study-6-typical`/`skills-6-typical`: 442px/442px
   and 526px/526px respectively, both before and after this rule, match
   height 324px unchanged) and stays byte-identical to before this ticket.
   5- and 4-pair boards are also unaffected (own selectors; match height and
   content identical before/after this rule at every size measured).

   Row gap 12px -> 8px (below the 5-pair-long rule's 12px, because the
   6-pair board is always the denser ceiling -- one more row than 5 pairs in
   the same stage) and vertical padding 6px -> 4px. `min-height:44px` is
   UNCHANGED and reasserted here only so this rule does not depend on
   cascade order with the base 6-pair rule -- it is still the WCAG tap-target
   minimum, the same floor every rule in this file holds, and this rule never
   goes under it (the rendered tile is 52px, still above it, the same way
   the 6-pair rule's own 56px sits above its floor). No font-size is touched
   (16px iOS zoom-floor test does not reach these buttons at all -- see the
   6-pair rule's own comment -- and the wrap that drives the worst case is a
   WIDTH property, unaffected by a vertical padding/gap trim). Column gap is
   untouched (24px), same asymmetry floor #475/#482 set.

   Measured with a real headless-Chrome render of this file's own shipped
   word_match.js/.css (scripts/build_card_frame_page.py's `Chrome` CDP
   harness, a throwaway scratch script per house style -- not committed)
   against BOTH real page shells, 390px wide, the six longest real (lemma,
   caption) pairs `beat_answers` has ever recorded for this beat
   (`chosen_kind='label', correct=1`, a scratch copy of `demo.db`), headed by
   "adult, grown up, mature" which wraps to two lines at this tile's width,
   the same worst case #2648-rework used. */
.match[data-pairs="6"][data-long] { gap:8px 24px; }
.match[data-pairs="6"][data-long] .match-he,
.match[data-pairs="6"][data-long] .match-en { min-height:44px; padding:4px 14px; }
/* #1798, Phase 4 of the Lantern rollout
   (docs/design/2026-09-05-fable/05-handoff-roadmap.md): radius 10 -> 14 and
   `box-shadow:var(--shadow)`, the two declarations `.opt` carries in
   `shared/beat_card.css`, and the reason a board tile and an option tile
   now read as the same KIND of object. Phase 1 put Lantern's TOKENS on both
   host pages' `:root`, so this tile has worn Lantern's
   `--surface`/`--line`/`--ok` since #1759 while keeping the flat pre-Lantern
   SHAPE declared right here -- #1764's finding on `tense.css`, on this
   board.

   `--rest-shadow` is what `beatWrongPulse` (shared/beat_card.css) holds
   underneath its ring; `.match-he.wrong`/`.match-en.wrong` are in that
   keyframe's selector list, so without it a mismatched pair's elevation
   would vanish for the length of the pulse and snap back (#1777). This
   board is where that matters most: `press()` clears `.wrong` on pointer
   release, so the run is routinely cut short mid-pulse. */
/* #2977: Match the pairs left the practice hub for Match Madness
   (`shared/warm_up.css`'s `.warmup-card`), and the owner asked that the
   copy still dealt inside a /study sitting wear Match Madness's tile: the raised
   `--raise` slab with a crisp edge under it (`--match-slab-depth`, the same
   3px and the same `--ground`-darkened edge as `--warmup-press-depth`/
   `--warmup-press-edge`), radius 12, and a semibold Hebrew word. It is a LOOK
   only -- every size on this board (`min-height`, `padding`, the gaps, and the
   44px floors the 5- and 6-pair rules below were measured against) is unchanged,
   so no board's height moves. `--rest-shadow` stays the soft `--shadow` alone:
   the wrong ring (`beatWrongPulse`) runs under it and must not inherit the edge,
   the same reason `.warmup-card` keeps it apart. A picked or settled tile
   declares its own `box-shadow` below, which leaves the edge off: a tile you are
   holding, and a pair that is done, sit flat -- the slab "sunk into the place its
   shadow was", which is what `.warmup-card` does for the press. */
.match { --match-slab-depth:3px;
         --match-slab-edge:color-mix(in srgb, var(--ground) 78%, #000); }
.match-he, .match-en { font-family:var(--serif); font-size:15px; color:var(--ink);
                       background:var(--raise); border:1px solid var(--line);
                       border-radius:12px; padding:16px 14px; cursor:pointer;
                       box-shadow:var(--shadow), 0 var(--match-slab-depth) 0 var(--match-slab-edge);
                       --rest-shadow:var(--shadow);
                       min-height:60px;
                       -webkit-tap-highlight-color:transparent; touch-action:manipulation;
                       transition:transform 200ms var(--ease-spring),
                                  box-shadow 200ms var(--ease-out); }
.match-he { font-family:var(--sans-he); font-size:calc(19px * var(--milim-text-scale)); font-weight:600; }
/* The press: down by exactly the edge's depth, edge gone, quick going down and a
   spring back up -- `.warmup-card`'s own. `:active` here (word_match.js sets no
   `.pressing`); a settled or judged tile is inert and never presses. */
.match-he:not(.matched):not(.wrong):active, .match-en:not(.matched):not(.wrong):active {
  transform:translateY(var(--match-slab-depth)); box-shadow:0 0 0 0 transparent; }
.match-he:active, .match-en:active { transition:transform 60ms ease-out, box-shadow 60ms ease-out; }
.match-he:focus-visible, .match-en:focus-visible { outline:2px solid var(--accent);
                                                   outline-offset:2px; }

/* Selected -- a green outline, not a fill (#429). Before this, `.picked`
   spent `--accent`, the palette's one attention colour, on "I tapped a
   thing" -- the most routine event in the beat. The box-shadow rings the
   button an extra 1px outside its existing 1px border, so the outline reads
   at a full 2px without a border-width change reflowing the grid; the
   background stays --surface, unfilled, which is what makes this read as an
   outline rather than the fill `.matched` below uses for "settled". Applies
   to either side now (#440: English can start a pair too), where it used to
   be `.match-he.picked` only. */
/* #1798: `var(--shadow)` is appended, not replaced. `box-shadow` is one
   property, so the ring alone would have DELETED the resting elevation the
   base rule now declares -- a picked tile would drop flat while the rest of
   the board floated, which is the same "elevation vanishes on a state
   change" defect #1777 fixed on the wrong pulse. Ring first so it paints on
   top of the drop, the order `beatWrongPulse` gives its own reason for. */
.match-he.picked, .match-en.picked { border-color:var(--ok);
                                     box-shadow:0 0 0 1px var(--ok), var(--shadow); }

/* Settled, and RECEDING (#429). `.matched` used to share `.picked`'s own
   full-strength `--ok` border, so a finished pair was exactly as loud on
   screen as one still mid-selection -- and the owner's whole point (the eye
   should land on what is still unmatched) had nothing to land on instead.
   `.picked` above keeps the crisp, opaque `--ok` ring; `.matched` washes to
   `--ok-soft` on the BORDER too, not just the fill, so a finished pair
   blends toward the card background instead of competing with the board's
   remaining live tiles. `--ok-soft` rather than a removal: a pair that
   vanishes takes the learner's own progress off the screen, and the point
   of the board is watching it fill.

   The text stays --ink and is NOT dimmed. Dimming to --muted was the first
   version and measured 4.46:1 against the composited green -- under 4.5:1,
   the same trap the `.end p` comment in study.css records for --faint. The
   green border and fill already carry "settled" on their own; the dim was a
   third signal paid for in legibility, on the words the learner just got
   right.

   #1053, the owner from real use: "When it matches is it possible to not just
   turn green but animate a one time sheen on a match?" -- which reads, at
   first, as the opposite of everything above. His own two words resolve it:
   ONE TIME. `matchSheen` (foot of this file) sweeps a band of stronger green
   across the tile once and the tile then settles into exactly the three
   declarations on this rule, unchanged.

   The settling is by CONSTRUCTION, not by a matching final keyframe.
   `animation` here states no `animation-fill-mode`, so it is `none`: the
   moment the run finishes, every property the keyframes touched stops being
   contributed at all and the cascade below is what paints. That matters more
   than it looks -- a `forwards` here would leave the last keyframe's
   background-image on the tile permanently, which is #429 walked straight
   back, and no keyframe rewrite could take it off again. The keyframes are
   also deliberately confined to `background-image`/`-size`/`-position`/
   `-repeat`: `background-color` and `border-color` are never animated, so
   there is no frame in which the RESTING green is a different green.

   `1`, spelled out, not `infinite` and not omitted. Omitted would default to
   1 as well; it is written because a guard can read it, and "the sheen is one
   time" is the whole of what the owner asked for.

   Contrast, both themes, real composite math (scripts/contrast_sweep.py's own
   `composite_over`/`contrast_ratio`, tests/test_beat_word_match_sheen.py):

     dark   rest --ok-soft over --ground  #1B2A24  --ink 12.46:1
            peak --match-sheen over rest  #39674B  --ink  5.44:1
     light  rest --ok-soft over --ground  #DBE3D9  --ink 12.88:1
            peak --match-sheen over rest  #74A686  --ink  6.07:1

   The resting numbers are the ones #429's 4.46:1 finding was about and they
   are untouched. The peak is transient and is still held to the same 4.5:1
   floor rather than excused as decoration -- the learner is reading the word
   under the band while it passes, and the guard measures the band's own peak,
   not its average.

   Motion is gated at the foot of this file.

   #2292, amending the paragraph above IN PLACE rather than reopening it: the
   owner, in-app, 2026-09-15: *"When a pair is right it should stay with the
   glowing green around it."* Measured on `main` before this change: the
   receded fill and border above ARE permanent -- `.matched` is never removed
   for the rest of the sitting -- but the rule painted no ring at all, so a
   settled pair's only edge was `--ok-soft` at 14-16% alpha next to `--surface`
   (`beat_tokens.css`), which reads, at a phone's arm's length, as barely
   distinguishable from an unplayed tile. What the owner is asking for is not
   "reverse #429" -- the eye-should-land-on-what-is-unmatched argument is
   untouched, and the fill/border stay exactly `--ok-soft` -- it is that
   "receded" was shipped with NO settled ring at all, where every sibling
   verdict in this app keeps one.

   The fix is the ring this app already has a name and a precedent for, not a
   new one: `shared/warm_up.css`'s own `.warmup-card.matched .warmup-card-face`
   (the OTHER match-the-pairs board, #1473) blooms once and settles to a quiet
   `box-shadow:0 0 0 1px var(--ok-halo)` -- exactly the shape
   `docs/design/2026-09-05-fable/07-beats-and-guide.md` §1 specifies in so many
   words: *"Matched: the bloom … then settle to a 1 px `--ok-halo` ring over
   `--ok-soft` -- the pair should stay visibly done for the rest of the game
   without glowing."* `word_match.css` never got that half of the same
   instruction (§3's own bullet for this board only ever mentions the sheen's
   colour) -- an omission, not a considered difference, since both boards are
   the identical "many settled pairs stay on screen while the rest of the
   board is worked" shape the brief is describing. Copying the sibling's own
   ring is therefore the narrowest fix that answers the report: still no
   `.opt.correct`-style 2px ring + 6px halo + 22px bloom (that IS "glowing",
   the toy-app note the brief itself warns against for a board where several
   verdicts sit on screen at once) -- just the one settled pixel that makes
   "still green" visible rather than merely true.

   `var(--shadow)` stays appended after the ring, unreplaced, the same
   reasoning `.picked` above gives for its own ring: `box-shadow` is one
   property, so writing only the ring here would flatten a matched tile's
   elevation relative to the rest of the board, the "elevation vanishes on a
   state change" defect #1777 already fixed once on the wrong pulse. Static,
   not animated -- `matchSheen` (below) never touches `box-shadow`, so this
   ring is on screen unconditionally the instant `.matched` lands, survives
   the sheen untouched, and needs no `prefers-reduced-motion` entry of its own
   for the same reason the resting fill/border never needed one: it was never
   part of the animation. `tests/test_beat_word_match_sheen.py`'s `SETTLED`
   and `tests/js/test_beat_word_match.mjs`'s own copy both pin the new value. */
.match-he.matched, .match-en.matched { background:var(--ok-soft);
                                       border-color:var(--ok-soft); cursor:default;
                                       box-shadow:0 0 0 1px var(--ok-halo), var(--shadow);
                                       animation:matchSheen .54s ease-out 1; }

/* Wrong, and ONLY for as long as the tap is held (#440). word_match.js's
   `press()` adds this to both tiles the instant a mismatch is judged and
   `clearWrong()` removes it on release (`pointerup`/`pointercancel`), or,
   for a keyboard tap, in the same handler immediately after. That part is
   unchanged by #1411 below: the red state itself is still a real, held state
   change, not a timer, and still needs no gating on its own account -- a
   learner who asked for less motion still wants to know the pair was wrong.

   Before #1053 the file's closing sentence exempted the WHOLE FILE from
   `prefers-reduced-motion` rather than this rule, and the exemption was never
   a property of the rule: it was the file having no animation in it at all.
   The sheen made that false, and #1411's ring below makes it false again for
   THIS rule specifically -- both now join the one gate at the foot.

   Same border/background pairing as every other beat's wrong state
   (cloze.css / pick_in_cue.css / tense.css's own `.wrong`), and the same
   reason for the explicit `color:var(--ink)`: without it the tile would
   inherit whatever another rule left on `color`, on the one word the
   learner most needs to read correctly right now.

   ## #1411: three pulses, not one

   The owner, in-app: *"Where you get it wrong it should pump that red
   shimmer three times. It feels like one time now... So you feel a bit of a
   speed bump. But for only a moment."* Measured first (see the ticket and
   the PR for the numbers): this rule never had an animation at all before
   #1411 -- the red is, and always was, an instant property swap held for
   exactly as long as the tap, typically ~100-300ms on a phone. What reads as
   "one time" is that single on/then-off. There was no keyframe duration to
   preserve, so `wrongPulse` below is new: `.14s` per pulse, three of them,
   `.42s` total -- under `matchSheen`'s own single `.54s` sweep two rules up,
   so three quick beats of red do not end up reading as the slower feedback
   on this board. `tests/test_beat_word_match_sheen.py` pins both numbers.

   #1586 moved all of that OUT of this file, and only the location changed:
   the ring is `beatWrongPulse` in `shared/beat_card.css` now, at the same
   `.14s` x `--wrong-pulse-count` (3), reached by this board's two tiles being
   named in that block's own list. It still animates `box-shadow` only and
   still states no fill-mode, for the reasons this paragraph used to give and
   that block now gives for all nine judged surfaces at once. What is left on
   the rule below is the resting state -- unchanged, and still what paints at
   the animation's `0%` and `100%` and under `prefers-reduced-motion`.

   The reason it moved is #1586's own measurement: five other judged beats
   gave a wrong answer no shimmer at all, because this treatment was private
   to this file. #1411 closed on exactly that instruction -- "check whether
   this is one rule or several before editing one of them." */
.match-he.wrong, .match-en.wrong { border-color:var(--no); background:var(--no-soft); color:var(--ink); }

/* #883: the beat's forward control used to be TWO -- `.beat-skip` (a
   bespoke word-not-a-pill treatment, #482) and `.match-continue` (`.advance`
   with its own `[hidden]` override, #847). One control now, and it uses
   study.css's shared `.advance.beat-dismiss` shape unmodified -- so there is
   nothing left for this file to add. See word_match.js's header comment on
   the button markup for why one control replaces two, and study.css's own
   `.advance` comment for the shape this now inherits -- #1372 gave that
   shared shape a border at the owner's own request, so "no border" is no
   longer part of what makes it "a hint, not a pill"; the register (13px
   lowercase vs. `.next`'s 10px uppercase) and the centring are what still
   do that job. Nothing in THIS file changed for it. */

@media (hover: hover) {
  .match-he:hover, .match-en:hover { border-color:var(--accent); }
  /* A settled pair stays receded even under a stray hover -- `--ok-soft`,
     not the `--ok` a pre-#429 hover would have brightened it back to,
     which would have undone the recede the moment a cursor drifted past. */
  .match-he.matched:hover, .match-en.matched:hover { border-color:var(--ok-soft); }
}

/* #1053's sheen. A band of stronger green (`--match-sheen`, declared on
   `.match` above) crossing the tile once, left to right, in .54s.

   A background-image sweep rather than a `::after` overlay, and the choice is
   #1049's lesson rather than a preference: 32 falling coins dropped frames on
   the owner's phone, and the cheapest version of anything on this board is
   the one that adds no node. This adds none -- ten tiles at most, one paint
   property, one run each. The angle and the position range are physical, not
   logical, so the Hebrew column (`dir="rtl"`) and the English column sweep
   the same way; a logical direction here would have the two halves of one
   pair travelling in opposite directions.

   `background-size:240%` with `background-position` running 100% -> 0% walks
   the band from just off the left edge to just off the right, so the sweep
   both enters and leaves rather than starting mid-tile. `no-repeat` is not
   optional: the base rule's `background:` shorthand resets repeat to
   `repeat`, and a 240%-wide image shifted by up to 140% would bring a second
   copy of the band into view behind the first.

   The image, size and repeat are restated identically in both keyframes on
   purpose. They are discrete (non-interpolable) properties: given only at
   `from`, they would flip to their un-animated values half way through the
   run and the band would vanish mid-sweep. */
@keyframes matchSheen {
  from { background-image:linear-gradient(105deg, transparent 34%, var(--match-sheen) 50%, transparent 66%);
         background-repeat:no-repeat; background-size:240% 100%; background-position:100% 0; }
  to   { background-image:linear-gradient(105deg, transparent 34%, var(--match-sheen) 50%, transparent 66%);
         background-repeat:no-repeat; background-size:240% 100%; background-position:0% 0; }
}

/* #1586: `@keyframes wrongPulse` used to be here -- #1411's ring, three
   pulses of a box-shadow. It is `beatWrongPulse` in `shared/beat_card.css`
   now, byte-identical in what it animates and shared by every judged surface
   in the app, with this board's spread coming from the shared
   `--wrong-pulse-spread` default rather than a literal `3px`. The reasoning
   that used to live here (a ring rather than a fill, so the tile is as
   legible mid-pulse as settled; `0 0 0 0` at both ends so a run cut short by
   `clearWrong()` leaves nothing behind) is in that block's own comment,
   restated for all nine surfaces rather than for this one. */

/* The file's ONE `prefers-reduced-motion` block, and #1036 is why that is
   stated rather than left to be noticed: `_reduced_motion_block` in
   tests/test_beat_reward.py, tests/test_beat_safe_cracker.py and this beat's
   own tests/test_beat_word_match_sheen.py all slice the FIRST such block in a
   file and nothing anywhere says a file may only have one. A second block
   added below this one would be invisible to every one of them. Whatever this
   beat gates next joins THIS block; it does not open another.

   `animation:none`, not a shortened duration. study.css's blanket
   `* { animation-duration:.01ms !important }` already stops the sheen for
   this user -- which is exactly why it is not a fallback: it 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 reduced-motion path
   never enters the animation at all, so the tile is at the settled
   `--ok-soft` state from the first frame the class lands -- the same resting
   state the animated path arrives at .54s later, reached by a shorter road.
   Same reasoning study.css's own `.hooray` block records for its wave.

   Nothing else is dropped. The match still turns green, the pair still
   recedes, `.picked` and `.wrong` are untouched: this removes the moment,
   not the acknowledgement.

   #1411's ring joins this same block rather than opening a second one (the
   comment above is the reason why). `.wrong`'s base declarations are
   already a real static state -- the tile turns red and stays red for the
   whole hold, with no animation involved in that part at all -- so
   `animation:none` here is the legitimate "a red tint that simply appears"
   equivalent the ticket asks for, not a fallback that merely looks right:
   the ring never plays, the red state the ticket is a report ABOUT is
   completely unaffected, and there is no fill-mode to reason about because
   `box-shadow` was never anything but `0 0 0 0` outside the animation. */
@media (prefers-reduced-motion: reduce) {
  .match-he.matched, .match-en.matched { animation:none; }
  /* #1586: the wrong ring's escape is in `shared/beat_card.css`'s one
     wrong-answer escape now, with every other judged surface's. Only the
     sheen, which is this file's own, is parked here. */
}
