/* The study page's look (#77), carried over from the archived C prototype
   (option-c-interleaved.html, moved to milim-audits/demo-evidence/docs/ux/ by
   #244; scripts/build_ux_pages.py stayed here) so the shipped page is
   recognisably the thing that got picked. Palette: projector dark,
   subtitle white, and Israeli-TV subtitle yellow as the one accent -- reserved
   for the target word and nothing else. Semantic correct/wrong are separate
   hues so "this is the word" never reads as "this is right". Tokens only;
   components never reach past them. */

/* #1759: Direction B "Lantern", chosen by the owner after both directions
   were rendered side by side (docs/design/2026-09-05-fable/02-directions.md,
   "Recommendation"). The values below are that file's "B dark"/"B light"
   columns, copied verbatim -- not re-derived -- and its "Measured contrast"
   table is the number this ticket's contrast-sweep changes answer to.
   `--known`, `--func`, `--scroll-hint` and `--glow` are excluded by that same
   spec (tuned per #935/#1202) and are UNCHANGED below even though `--ink`/
   `--accent` move past them: those three still carry the OLD ink/accent's
   literal RGB (see each token's own #935/#391 note on why they are never
   `var()`), so the pairing each was tuned against has drifted by this ticket
   and nobody has re-measured the new composite. Flagged on #1759 rather than
   silently re-tuned, since re-deriving any of those three is explicitly out
   of this ticket's scope. */
:root {
  /* Lantern Phase 2 (docs/design/2026-09-05-fable/05-handoff-roadmap.md):
     `--ground`, `--surface`, `--line`, `--raise`, `--ink`, `--muted`,
     `--accent` (dark value), `--ok`, `--ok-soft`, `--no`, `--no-soft`
     moved to `shared/beat_tokens.css`, along with the two verdict halos,
     the bloom glow, `--shadow` and the six motion properties that used to
     sit at the foot of this block -- see that file's header for the
     contract and the per-token history that moved with them. `--faint`,
     `--known`, `--func`, `--glow`, `--accent-soft`, `--control`,
     `--control-ink` and `--scroll-hint` are UNCHANGED here:
     `shared/beat_card.css` never reads them, so Phase 2's seam ("the
     tokens a beat needs, and nothing else") leaves this page's own
     furniture on this page. */
  /* --faint was #5C636D (3.14:1 here) -- #715/#716: `.rail`'s inherited
     `.count` progress text and `.source-title`'s caption are prose a learner reads,
     and the owner's ruling (2026-08-17 handoff, Q3 `fix_all_text`) is that
     there is no "furniture" exemption for that -- every such row clears AA
     4.5:1, full stop. #767C84 is the least darkening (lightening, in this
     dark-mode direction) that clears it: 4.52:1. Non-text/decorative uses of
     this token (`.pacepip`, its end-stop ring) were already sub-3:1 before
     this change and are unaffected by it -- pre-existing, out of #715's
     scope, not newly broken. */
  --faint:#8A919B; --known:#A8AFB8;
  /* #935 split-shade: `func` gets its own token rather than sharing `--known`
     (see the header at #180's rule below for the full story). Literally
     `--muted`'s value, copied rather than `var(--muted)`'d, for the same
     reason #391 gave `--known` its own token instead of leaving it on
     `--muted`: a future change to `--muted`'s OTHER uses (`.rail`'s progress
     text, `.gloss`, `.english`, `.count`, ...) must not silently move this
     rule too. 5.69:1 dark against --ground -- clears AA normal (4.5) with
     margin, and recedes further than --known's 8.61:1, per the worksheet's
     "must recede further than --known, not displace it". `--func`/`--known`
     read `--ground` and `--muted` only in their own comments' PROSE, not in
     `var()` -- both are still real literals, unaffected by `--ground` moving
     files, since a custom property resolves the same regardless of which
     stylesheet declares it. */
  --func:#868D97;
  /* #1765: `--accent-soft` tracks `shared/beat_tokens.css`'s `--accent` and
     carries its RGB (255,203,87) by the owner's own rule (2026-09-06
     worksheet: "match them ... so just do it"). `--glow` deliberately does
     NOT track it -- tuned per #935/#1202, stays on the pre-Lantern RGB
     (242,193,78) -- the documented exception rather than an oversight. */
  --glow:rgba(242,193,78,.34); --accent-soft:rgba(255,203,87,.13);
  /* #1424: the filled-control pair. A FOURTH hue, and it earns its place the
     same way `--ok` and `--no` already do -- one job, never the attention
     accent. This file's header reserves subtitle-yellow "for the target word
     and nothing else", so the replay button could not be painted `--accent`
     even though it needed to stop being furniture; the owner's report is that
     it reads as "dark grey, near-invisible against the ground" and "a flat
     piece of paper" rather than a control.

     #4ABFF9 is NOT chosen. It is sampled off the reference photograph he
     attached to #1424 (`docs/decisions/assets/1424-replay-button/reference-
     wanted.jpg`), which is a dark-ground screenshot exactly like this theme:
     the button's fill is rgb(74,191,249) over 47% of its area, flat, with the
     glyph knocked out of it in the page's own ground colour -- which is what
     `--control-ink` is here. 9.1:1 against `--ground`, so it is unmistakably a
     control; 8.6:1 for the glyph on it.

     The light theme cannot take the same value: #4ABFF9 on `--ground`
     (#F3F1EC) is 1.84:1, under WCAG 1.4.11's 3:1 floor for a UI component's
     own boundary, so a filled button would read as a pale sticker. #0D6C9C is
     the same hue (200deg) and saturation family darkened until it clears --
     5.11:1 against the light ground -- and the glyph flips to the light
     ground for 5.77:1 on it. Same instruction ("saturated, filled, clearly a
     control"), two grounds, two values, which is what every other pair in
     these two blocks already does.

     `--control-ink` carries its own literal rather than `var(--ground)` even
     though the two agree today: same reason #935 and #391 give above -- a
     later change to the page's ground must not silently repaint the inside of
     a button. */
  /* #1759: B's own control pair, not the pre-Lantern one #1424 derived from
     the reference photograph above -- Lantern's `--control-ink` is an
     explicit white/near-black rather than "knocked out to the page's own
     ground" (that rationale predates this ticket and stops applying the
     moment `--ground` itself moves), matching both the tokens table's
     `--control-ink` row and 02-directions.html's rendered `.dirB` block,
     which the roadmap prose omits from its "tokens moved" list but the
     measured contrast table (`control-ink / control`) clearly covers. */
  --control:#58C6FF; --control-ink:#0C0E12;
  /* #1202: `.stage.showing .cue`'s scroll-hint band (below, "the third half")
     was painted `var(--line)` -- byte-identical to this page's divider colour,
     confirmed by the owner reading it off the live page. A new token, not a
     recolour of `--line` itself: that token is this file's divider colour
     everywhere else (`.clock`, the rail's borders, chips) and #1202 is
     explicit it must stay that. (`.advance`'s own rounded border was one of
     these too until #1682b gave it `--ok`/`--no` instead, to carry the
     Continue button's own verdict -- shared/beat_card.css.)

     `--scroll-hint` is `--ink` -- this box's own text colour, the same one
     the cue paints its Hebrew in -- at 16% alpha over `--ground`, carried as
     its own literal `rgba()` rather than `rgba(var(--ink-rgb),.16)` (no such
     channel-split var exists here, and #935/#391's reasoning applies anyway:
     a later change to `--ink` must not silently repaint this too). The
     reasoning: the band was reading as "an edge that failed to render",
     because it used this page's own EDGE colour. Painting it in the READING
     colour instead -- the same hue the sentence itself is in, just faded --
     reads as "there is more of what you are reading", which is what it is
     for. 16% is the highest alpha that still clears every one of the cue's
     four possible ink colours at the 3.0 AA large-text floor with margin, in
     BOTH themes (`scripts/contrast_sweep.py`'s `contrast_ratio`, composited
     over `--ground` since `.cue` paints no background of its own -- see
     `.advance`'s own comment on what shows through `.stage`):

       over the band (16%)     dark        light      floor
       --ink                  10.54:1     10.85:1      3.0
       --known                 5.72:1      4.27:1      3.0
       --func                  3.78:1      3.35:1      3.0   (tightest)
       --accent                7.54:1      3.45:1      3.0

     18% already drops `--func` light under 3.0 (2.94:1) -- see the sweep run
     in this ticket's PR body for the full table. `tests/test_contrast_sweep.py`
     pins this composite the same way #356/#425/#715 pin the rest of this
     file's tokens.

     The composited result -- #323334 dark, #D0CFCC light -- is a plainer,
     warmer, more neutral grey than `--line` (#272C35 dark / #DFDBD2 light,
     a cooler blue-grey by construction) and is also perceptibly stronger
     against `--ground` (1.83:1 dark / 1.44:1 light vs `--line`'s own 1.36:1 /
     1.22:1), rather than only numerically different -- a same-strength hue
     swap would still read as "some grey line", which is the whole complaint. */
  --scroll-hint:rgba(237,234,227,.16);

  --serif:"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
  --mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
  /* #1759's six motion properties (`--dur-*`/`--ease-*`) moved to
     shared/beat_tokens.css, theme-independent there too -- see that file's
     header. */
}
@media (prefers-color-scheme: light) {
  :root {
    /* Lantern Phase 2: this override block loses the same eleven-plus-four
       tokens the dark block's own note above names -- `--ground`,
       `--surface`, `--line`, `--raise`, `--ink`, `--muted`, `--accent`,
       `--ok`, `--ok-soft`, `--no`, `--no-soft`, the two halos, the bloom
       glow and `--shadow` -- now `shared/beat_tokens.css`'s light override.
       The six motion properties were never here: they are theme-independent
       and were declared once, in the dark block, before this move too. */
    /* --faint was #7C838C (3.39:1 here). Same #715/#716 ruling as the dark
       block above -- #696E76 is the least darkening that clears AA 4.5:1
       here: 4.55:1. */
    --faint:#646B76; --known:#565D67;
    /* #935: same reasoning as the dark block above. 4.63:1 against --ground
       -- clears AA normal with a 0.13 margin, and recedes further than
       --known's 5.89:1. This is the tighter theme: the AA floor (4.5) and
       --known (5.89) leave only a 1.31-wide band for a third shade to sit
       in, so --func and --known are 1.27:1 apart from each other here
       (1.51:1 dark) -- real, measured, and honestly the light-theme number
       is the more subtle of the two; see the PR for the read-in-browser
       call on whether that subtlety reads as distinct. */
    --func:#666D77;
    --glow:rgba(138,100,16,.16); --accent-soft:rgba(138,100,16,.10);
    /* #1424: see the dark block's own comment for where #4ABFF9 came from and
       why this theme cannot use it. 5.11:1 against this ground, 5.77:1 for the
       glyph on it. #1759 moves both to Lantern's own control pair -- see the
       dark block's `--control`/`--control-ink` note for why `--control-ink`
       is no longer the page's own ground literal. */
    --control:#1069C9; --control-ink:#FFFFFF;
    /* #1202: see the dark block's own comment above for the derivation and
       the contrast table. Same recipe, this theme's own `--ink`/`--ground` --
       `--ink` (#1A1D22) at 16% alpha over `--ground` (#F3F1EC), its own
       literal for the #935/#391 reason. */
    --scroll-hint:rgba(26,29,34,.16);
  }
}
/* #2617: the in-app theme override, same reasoning as settings.css's own
   `[data-theme]` pair -- an unconditional attribute selector on `:root`
   outranks both the bare rule and the `@media` rule above on specificity
   alone, so whichever is present always wins over the OS and neither is
   present for `auto`. This page's base `:root` above is already dark (no
   dark media block exists because there is nothing to override), so the
   dark block here just restates the base's own values for the tokens the
   light block overrides; the light block is that `@media` block above,
   unchanged. */
:root[data-theme="dark"] {
  --faint:#8A919B; --known:#A8AFB8;
  --func:#868D97;
  --glow:rgba(242,193,78,.34); --accent-soft:rgba(255,203,87,.13);
  --control:#58C6FF; --control-ink:#0C0E12;
  --scroll-hint:rgba(237,234,227,.16);
}
:root[data-theme="light"] {
  --faint:#646B76; --known:#565D67;
  --func:#666D77;
  --glow:rgba(138,100,16,.16); --accent-soft:rgba(138,100,16,.10);
  --control:#1069C9; --control-ink:#FFFFFF;
  --scroll-hint:rgba(26,29,34,.16);
}

* { box-sizing:border-box; }
/* #498: `html, body { height:100%; margin:0; overflow:hidden }` and
   `.phone`'s own `min-height:100vh; min-height:100dvh; display:flex;
   flex-direction:column;` moved to `shared/phone.css`'s `[data-milim-main]`
   rule -- `.phone` carries that attribute, so it still gets exactly this
   shape, plus a real inner scroll (this file never had one -- see that
   file's header for why the extraction is not a literal copy). What is
   left here is what is genuinely study-specific: the page's own colours,
   width and border. */
body { background:var(--ground); color:var(--ink);
       font-family:var(--serif); -webkit-font-smoothing:antialiased; }

.phone { max-width:430px; margin:0 auto; background:var(--ground);
         border-inline:1px solid var(--line);
         /* #125 guard: a future child added to `.rail` (or any other direct
            row) that does not shrink must not be able to pan the whole page
            sideways again -- see the `.rail` comment below for the actual
            fix. This is the backstop, not the fix; it is paired with
            `min-width:0` on the flexible child below, never used alone in
            place of that fix, which would just hide an overflowing row
            instead of making it fit. Kept here rather than folded into
            shared/phone.css's own `overflow-x:hidden` (#498) because
            tests/test_api_study_page.py's
            `test_the_rail_cannot_pan_the_study_page_sideways` pins this exact
            selector and property in this exact file -- the shared file's copy
            is a belt-and-braces duplicate for every OTHER page, not a
            replacement for this one. */
         overflow-x:hidden;
         /* #515: this page's only `env(safe-area-inset-bottom)` used to live
            on the removed `.foot` element's own padding -- the last thing in
            the scrolling column, so it was also the thing keeping that
            column's bottom edge off the home indicator. Moved onto the
            column itself (the same place settings.css's and progress.css's
            own top-level column already carry the same
            property) rather than left to the nav bar's flow spacer alone:
            the spacer reserves exactly the bar's own height, and this is the
            breathing room past it, same 18px baseline `.foot` used.
            test_every_page_clears_the_status_bar_in_standalone_mode pins
            this. #572: +20px on top of that 18px baseline reserves
            #buildStamp's own rendered height (17px) plus a little slack, so
            its fixed band never lands on real content (same fix as
            settings.css and tutorial.css).

            #597: the `env(safe-area-inset-bottom)` term is GONE, and the
            18px+20px baseline is now 6px+17px+7px. Both follow from the
            spacer being fixed, not from a taste call about spacing.

            The inset was counted twice. The comment above is right that the
            spacer "reserves exactly the bar's own height" -- but it did not:
            it reserved `--milim-nav-h` while the bar rendered
            `--milim-nav-h` + the inset, so this padding's own `+ inset` was
            silently covering that shortfall (34px paying off a 26px debt).
            nav.css's `--milim-nav-total` now makes the spacer reserve the
            bar's real height, inset included, so repeating the inset here
            reserves it a second time. Measured on `/study` with the inset
            simulated at 34px: 130px of flow was reserved below the last
            content for a bar occupying 84px.

            What is left is the one job this padding still has -- keeping
            content out of #buildStamp's fixed band, which is the topmost
            thing above the bar. The stamp sits 6px above the bar and renders
            ~17px tall, so its band ends 23px above the bar; 7px past that is
            the breathing room, and the arithmetic is written out rather than
            summed so the next person can see which number is load-bearing.

            This is why study.css no longer contains `env(safe-area-inset-
            bottom)` at all.
            `test_every_page_clears_the_status_bar_in_standalone_mode`
            asserted that string was present in this file;
            it now records why /study is the exception, and
            `tests/test_nav_geometry.py` pins the property that assertion was
            standing in for -- that the bottom inset is reserved -- against
            the spacer that actually reserves it.

            #952: the `17px` above went stale the day #731 shipped and this
            padding is what was wrong, not `.offramp`. The paragraph above is
            exactly right about this padding's one remaining job -- "keeping
            content out of #buildStamp's fixed band" -- and about the band
            being 6px + the stamp's own height. What changed underneath it is
            the height of the band: the stamp's CLICKABLE surface is no
            longer its ~17px of ink, it is `#buildStamp::before`, a 44px box
            anchored to the same bottom-right corner and growing up and to
            the left, invisible, `pointer-events:auto`, at
            `z-index:2147483647`. Reserving 23px against a band that ends
            50px up left 20px of this page's own flow inside a hit target
            that wins every hit test, and the bottom-right corner of "Finish
            this sitting" is what landed there -- 455 of its 6,208 interior
            points hit-tested to `A#buildStamp` and a real click on that
            corner opened /release-notes instead of finishing the sitting.
            Measured, before and after, in the PR for #952.

            So the load-bearing operand now comes from the file that owns the
            geometry: `--milim-stamp-keepout-block` (shared/build_stamp.css,
            6px of lift + the 44px box, written out there for the same reason
            it is written out here). The `7px` stays a literal because it is
            still this page's own taste call and nothing else depends on it.

            Fixing it HERE rather than on `.offramp` is deliberate. This
            padding is the page's whole flow column, so every current and
            future row on /study clears the corner, not just the one control
            that happened to be sitting in it -- and `.offramp`'s own
            right-alignment ("the separation is deliberate", its comment) is
            left exactly as designed. /study is also the one page where a
            flow reservation is a COMPLETE fix, because its column does not
            scroll; on a page whose column scrolls, content passes under that
            box mid-scroll and padding cannot reach it. See
            shared/build_stamp.css's own note on the token for that.

            #1124, and this correction is the point of the ticket: the
            parenthesis that used to sit on that sentence -- "`scrollHeight -
            innerHeight === 0` at every viewport height measured" -- was true
            of every measurement this repo could take and FALSE of the screen
            the app is used on. Every instrument here reads the safe-area
            insets as 0: jsdom has no layout engine, and a desktop Chrome tab
            reports no inset. On a notched phone in standalone the insets are
            ~59px top and ~34px bottom, and at that size the column scrolled on
            133 of the 271 ask cards sources 7-11 can serve -- 11 of them with
            the last answer button drawn behind the nav bar. The claim was not
            wrong about the arithmetic, it was unfalsifiable by the suite that
            was supposedly defending it.

            It is true again, and now for a reason rather than a coincidence:
            `.stage` below carries `min-height:0`, so the column's children can
            no longer sum past the column at ANY inset. `scripts/study_layout_
            probe.py` is the instrument that settles it (0 of 271 overflowing,
            at three phone geometries), and
            `tests/js/test_study_column_budget.mjs` holds the shape between
            runs. Read it as a property of this stylesheet, not as a fact about
            the corpus that happened to hold.

            #1196, and it makes the two #952 paragraphs above HISTORY rather
            than a live justification -- read them for the record they are,
            not as a reason this padding is the size it is.

            Everything above describes a HIT-TEST reservation: 455 of the
            6,208 interior points of "Finish this sitting" resolving to
            `A#buildStamp`, a real click opening /release-notes with the
            sitting never finishing. #973/#575 ended that by taking
            `pointer-events: auto` off `#buildStamp::before` -- nothing in
            that corner is hit-testable on any page, at any scroll offset,
            and there has been no tap to steal since 2026-08-23. The
            reservation stayed at the dead box's size anyway, because #973
            deliberately declined to mix a one-line fix with six files of
            churn. This is the cleanup it deferred.

            So this padding was 57px, of which 34px was clearing a box that
            no longer exists. On a `height:100dvh` column that is not
            rounding, and the owner said so directly (#1225): "you should
            not be including the build number on the page as counting
            against the layout either. It feels like there should be enough
            space here." `--milim-stamp-keepout-block` is now the stamp's
            measured INK -- 6px of lift + 17px rendered, in a real browser,
            see build_stamp.css's own comment for the readings -- so this
            same expression resolves to 30px and hands 27px back.

            WHAT DID NOT CHANGE, deliberately, three things:

            1. The `+ 7px`. It is this page's own taste call, as the
               paragraph above says, and #1196 KEPT it rather than quietly
               pocketing it. The hazard that survives #973/#575 is not tap
               theft but OCCLUSION -- the stamp is opaque (#572) at
               z-index 2147483647, so a flow row inside its band is painted
               over, not merely tapped through -- and 7px is what keeps the
               off-ramp's pill off the ink rather than edge-to-edge against
               it. Spending it would return 34px instead of 27px, and #1196's
               own re-run of #1124's probe says that buys one card out of 271.
               How this card divides its space is #1225's question; it should
               be answered there, on its own evidence, not settled here by
               absorbing a literal that was never the hit box's.

            2. The reservation stays on `.phone`, the page's whole flow
               column, not on `.offramp`. The paragraph above is right about
               why, and #1196 leaves it exactly as it is: every current and
               future row on this page clears the corner, not just whichever
               control happens to be sitting in it today.

            3. This reads the published token rather than a literal.
   `test_the_study_page_reserves_the_keep_out_by_reading_it_rather_than_retyping_it`
               still pins that, and it is what let
               #1196 change one declaration in build_stamp.css instead of
               chasing nine hand-typed constants -- which is exactly the
               argument #952 made for publishing the number in the first
               place, now collecting on itself.

            #1225, and this is the answer #1196 asked for above: THE 7px IS
            SPENT. The owner chose it on the 2026-08-31 worksheet, bundling
            this with #1387 and #1239 as one card-frame change rather than
            shipping the alignment alone, and his words on this ticket are
            still the whole of the argument -- "you should not be including
            the build number on the page as counting against the layout".
            This padding is the last place on /study where the stamp counts.

            Two corrections to how that ticket is written, measured rather
            than argued, because the next reader will otherwise go looking
            for a row that does not exist:

              * #1225 says "57px of dead gap, 17px of it the build stamp".
                `#buildStamp` is `position:fixed`, appended to `<body>`,
                OUTSIDE `.phone` -- it occupies 0px of flow on every card.
                It never was a row. What it costs the column is this
                reservation and nothing else, and #1196 already cut that
                from 57px to 30px.
              * The 30px is 23px + 7px, and only the 7px was ever this
                page's to spend. The 23px is `--milim-stamp-keepout-block`,
                a LIVE requirement: the stamp is opaque at
                z-index 2147483647, so a flow row inside its band is painted
                over. That hazard survived #973/#575 even though tap theft
                did not, and returning it would need the stamp MOVED, which
                is `shared/build_stamp.css` and `shared/nav.css` -- not this
                file, and not this branch's to touch. Filed rather than
                reached into.

            What spending it buys, measured on `scripts/study_layout_probe.py`
            (repaired at #1368), 278 ask cards, sources 7-11, both states, at
            a true 393x852 with insets 59/34. The column's flow budget goes
            from 730px to 737px, and 7px turns out to buy more than #1196's
            own re-run predicted ("one card out of 271"), because every card
            that was cut was cut by less than a line:

              cue cut, answered state, raw `cue_clipped > 0` and its px
                on `main`                   2 cards -- 22px, 1px
                with #1387's margin         3 cards -- 22px, 1px, 1px
                and with this 7px           1 card  -- 15px
              worst, in lines               0.5  ->  0.5  ->  0.3
              last answer button vs the nav bar, worst clearance
                                            -63px -> -63px -> -56px

            So the end state is BETTER than the state this branch started
            from, on the metric #1352 is scored by, while the cue's box is
            24px narrower. It is taken because it is the owner's call, it is
            free, and it is the whole of what this page can return on its own.

            What it does NOT do, so nobody expects it to: it does not fill the
            empty column below a short card. That space is #851's, and #851 is
            the owner ruling the other way -- `.stage` was `flex:1`, swallowed
            every spare pixel, and pushed `#ask` to the bottom of the screen;
            his words were "it should just move up and not feel so weighted to
            the bottom of the page". A card that ends where it ends is what he
            asked for. `.stage` stays `flex:0 1 auto`.

            The `calc()` wrapper around a single term is not noise and is not
            a leftover: `tests/js/test_css_contract.mjs`'s stamp floor reads
            this declaration by matching `^calc\((.*)\)$` and summing the
            terms inside, and a bare `var()` here is a value it cannot parse
            -- so dropping the wrapper does not relax the guard, it silences
            it. Keeping it is what leaves the `reserved >= requiredReserve`
            check able to fail on this page. */
         padding-bottom:calc(var(--milim-stamp-keepout-block)); }

/* ---- rail: the TOP row (#485) -- what acts on the screen in front of you ----
   Source picker, audio toggle, counter, and the feedback flag shared/nav.js
   appends. Nothing that takes you off this page lives here any more: "← reader"
   and "queue →" moved to the bottom row, which is identical on all four pages.
   See docs/adr/0036. */
/* #116 follow-up: same as the reader's own (since-removed) body -- in standalone the Island draws
   over this row and takes its touches, and the source picker, the audio toggle
   and the feedback flag all live here. No-op in a browser tab. */
/* #485: `align-items:center`, was `baseline`. The rail is a 10px-font row and
   used to hold nothing taller than its own text; the feedback flag carries the
   44px tap floor, so a baseline-aligned row would hang a 44px box off the
   x-height of a 10px caption and push the whole rail down by its descender.
   Centred, the tall child grows the row symmetrically and the short ones sit in
   the middle of it. */
/* #2032: `justify-content` was `flex-end`, back to `space-between` (#2031)
   now that `.unitkeys` below is a real left cell again -- restoring the
   value the strip used to lean on, not re-deriving it. It is not, on its
   own, what keeps the right-hand icon(s) clustered together any more than
   it was in the strip's day: see `.rail .unitkeys`'s own comment for the
   mechanism that actually does that. As of the owner's 2026-09-09 ask
   there is only one such child again -- the feedback flag, appended
   straight into `.rail` by shared/nav.js -- Progress's own rail icon
   (`railOnly`, #1281) having been retired back into the bottom row; see
   that comment for the fuller history. */
/* #2714: base vertical padding 10px/5px -> 7px/4px (the inset term is
   UNCHANGED -- this only trims the part every viewport pays, inset or not).
   This row's rendered height is `max(the 44px feedback-flag icon nav.js
   appends, the text row) + this padding` -- the icon is the floor and stays
   44px (unaffected: it is centred by `align-items:center`, not sized by
   this padding), so the only thing 4px smaller is the breathing room above
   and below it. Measured: 375x667 standalone rail 59px -> 55px, still well
   clear of the row's own text and the icon's own tap box. */
.rail { display:flex; align-items:center; gap:10px; justify-content:space-between;
        padding:calc(7px + env(safe-area-inset-top))
          calc(18px + env(safe-area-inset-right)) 4px
          calc(18px + env(safe-area-inset-left));
        font-family:var(--mono); font-size:10px;
        letter-spacing:.14em; text-transform:uppercase; color:var(--faint); }
/* #2031: the unit's Keys counter -- `earned`/`total` for the CURRENT
   section, lifetime (study.html's own comment on `#unitkeys` has the full
   asymmetry-with-`#unitBar` argument; not repeated here).

   `margin-inline-end:auto` is the actual left-pin, and it is why
   `justify-content` above can be either `flex-end` or `space-between` with
   an IDENTICAL result: a flex item's auto margins absorb 100% of the row's
   free space before `justify-content` ever runs, so with this margin
   present there is never any free space left for `justify-content` to
   distribute. That is deliberate, not incidental -- study.html's markup
   only ever has ONE of this row's eventual children in it (`.unitkeys`,
   since #2092 deleted its sibling `.audiotoggle`); shared/nav.js appends
   the rest directly into `.rail` at runtime, not into a wrapper `.icos`
   span the way docs/demos/2026-09-09-unit-progress-bar/index.html's mock
   can afford to (nav.js is outside this ticket's scope fence) -- as of the
   owner's 2026-09-09 ask that is one further child, the feedback flag,
   Progress's own rail icon having been retired back into the bottom row
   (see `.rail`'s own comment above). Plain `space-between` over N
   fixed-size children would spread all of them apart instead of clustering
   the trailing ones -- this margin is what makes the row correct regardless
   of how many icons nav.js ends up appending after `.unitkeys`.

   Only `.got` (the earned figure) carries `--accent`; this rule and the
   `svg`/`/total` rule below both stay `--muted` at full opacity, which
   already clears WCAG 1.4.3 on this page without a dimming exemption (see
   `--muted`'s own #356 note further down this file). `font-size` is 12px
   against the row's inherited 10px, and `letter-spacing` is reset from the
   row's own `.14em` (right for spaced-out uppercase captions, not for a
   fraction) down to a mono digit's own near-zero tracking. */
.rail .unitkeys { display:flex; align-items:center; gap:5px; flex:0 0 auto;
                  margin-inline-end:auto; font-size:12px; color:var(--muted);
                  letter-spacing:.02em; }
.rail .unitkeys svg { width:15px; height:15px; stroke:var(--muted); fill:none;
                       stroke-width:1.7; stroke-linecap:round; }
.rail .unitkeys .got { color:var(--accent); font-weight:600; }
/* #125: the one child with real room to give. `flex:1` alone still floors at
   the content's own width (`min-width:auto` is the flexbox default), which
   is what let the `select` inside push the row past the viewport --
   `min-width:0` is what actually lets this shrink below that.
   #146: being the only child that gives means every child ADDED to this row
   is charged here. The queue link took ~62px, and at 390px that read the
   select down from 92px to 30px -- two characters of the source's name.
   #485 is the other half of the same arithmetic, run in reverse: taking the
   two navigation links out of this row (59.4px + 51.9px + two 10px gaps =
   131.3px at 430px) is paid straight back INTO this cell, minus the 44px the
   feedback flag and its own gap now take. Measured on demo.db at `.phone`'s
   430px: the select goes 128.0px -> 205.3px rendered, 90.0px -> 167.3px of
   usable text, and the count of the six titles that fit in the closed control
   goes 0 -> 2.
   `max-width` below is raised from 11rem for that reason and no other: 11rem
   is 176px, which is BELOW the 205.3px the cell now offers, so leaving it
   would have clamped the select and handed the reclaimed width back to
   `justify-content`. At 14rem it is a cap that does not bind at any width
   `.phone` can be, which is what a cap should be. */
/* #2032: `.rail .source`, `.rail select`, `.rail .count` (#2004) and then
   the three-segment section strip that briefly replaced them (#2004's own
   thirteen rules, `.rail .sections` through `.rail .secicon`) were all here
   in turn. The strip is gone in favour of `#unitBar`, a SIBLING of `.rail`
   in study.html rather than a child of it -- so `.rail` no longer has one
   cell "the flexible one" (#146's arithmetic): the feedback flag (and,
   before #2092 deleted it, `.audiotoggle` beside it) is a fixed-size icon
   (Progress's own rail icon having been retired back into the bottom row,
   2026-09-09 -- see `.rail`'s own comment above). #2031 gave the row a new
   left cell instead (`.rail
   .unitkeys` above), and that rule's own `margin-inline-end:auto` -- not
   `.rail`'s `justify-content` -- is what keeps the icon(s) clustered right
   of it; see that comment. */

/* ---- the unit progress bar (#2032) ----------------------------------------
   WHERE YOU ARE IN THE CURRENT SECTION, and nothing else. 10px tall, 6px
   radius, inset 16px each side -- the page gutter `.cuerow`/`.reveal` also
   sit inside, so the bar's edges line up with the cue text below it rather
   than running to the phone's own edge. `--line` is the empty channel and
   `--accent` the fill, the same amber this page reserves "for the target
   word and nothing else" (this file's own header) -- here it is a claim
   about POSITION, not about a word, but it is still the one accent this
   page draws attention with.

   `width` is set inline by `renderUnitBar()` (study/sections.js); the .45s
   transition is what makes each cue read as a step rather than a jump.
   `var(--ease-out)` is this page's own eased curve (shared/beat_tokens.css,
   #1759) and happens to be the exact easing the owner approved on the mock
   (docs/demos/2026-09-09-unit-progress-bar/index.html) -- not re-derived,
   the same curve under its existing name. No separate reduced-motion rule:
   this file's blanket `* { transition-duration:.01ms !important }` (below,
   under `@media (prefers-reduced-motion: reduce)`) already flattens it. */
.unit { margin:0 16px; flex:0 0 auto; display:flex; align-items:center; gap:8px; }
.unitbar { display:block; flex:1 1 auto; height:10px; border-radius:6px;
           background:var(--line); overflow:hidden; }
/* #2260: the unit attempt's medal, at the bar's end. The box is the bar's own
   10px tall, so the row -- and every layout measured below it -- keeps its
   height; the 16px disc is drawn by `::before`, absolutely positioned 3px
   above it. Out of flow rather than a negative margin, which #1300's
   first-line anchor contract refuses on this page. Three states
   (study/sections.js): an outline while nothing is asked, #2244's gold metal
   while every answer is right, grey and struck through after a miss. Hidden,
   the bar is full width exactly as before: a `display:none` item takes no gap. */
.unitmedal { flex:0 0 auto; width:16px; height:10px; position:relative; }
.unitmedal[hidden] { display:none; }
.unitmedal::before { content:""; position:absolute; left:0; top:-3px; width:16px; height:16px;
                     box-sizing:border-box; border-radius:50%; }
.unitmedal.is-open::before { border:2px solid var(--gold-edge); background:transparent; }
.unitmedal.is-perfect::before { border:1px solid var(--gold-edge); background:var(--gold-sheen);
                                box-shadow:0 0 4px var(--gold-fill); }
.unitmedal.is-broken::before { border:2px solid var(--faint); background:var(--line); }
.unitmedal.is-broken::after { content:""; position:absolute; left:7px; top:-5px; width:2px;
                              height:20px; background:var(--faint); transform:rotate(45deg); }
.unitbar i { display:block; height:100%; border-radius:6px;
             background:var(--accent); width:0;
             transition:width .45s var(--ease-out); }

/* #1201: the rail's three ICON controls -- the audio toggle, the off-ramp,
   and (nav.css's own `.milim-flag`, not restyled here) the feedback flag.
   `.railicon` is the glyph; the two selectors below are the boxes around it.

   Every number here is copied from `.milim-flag`/`.milim-flag-icon` in
   shared/nav.css on purpose -- 44px box, 21px glyph, 1.7 stroke, round caps
   and joins -- because the owner's ask was that these read as the same kind
   of thing as the flag. They are NOT shared with that file: nav.css is
   deliberately self-contained (see its header on #509/#530/#532), so a
   control study.css owns is styled by study.css using study.css's own tokens.
   The one thing that is genuinely shared is the shape, and the guard for that
   is tests/js/test_study_rail_icons.mjs, which compares the two files' values
   rather than trusting this comment.

   `flex:0 0 auto` on both: #146's arithmetic above is unchanged by this
   ticket -- `.source` is still the only child that gives, so an icon that
   could grow would be taking the film's title. */
/* #2092: `.rail .audiotoggle` and its states (box, faint/muted/accent
   colours, the `[data-blocked="true"]` dark-out) are gone with the button
   they styled -- see study.html's own #2092 comment on the rail and
   study/audio.js's, where `renderAudioToggle`/`setAudioOn` were deleted.
   `.railicon`, the 21px glyph class, MOVED to `shared/beat_card.css` (#1568)
   and stays there: the cue replay button (study.html, `.cuereplay`) still
   draws through it. */
/* #1348 requirement 1: `.clock`, `.clock i`, `.clock.held i` and
   `.clock.asking i` were here, drawing the 2px drift bar under the top rail.
   The owner asked for the animated line to stop, and the thing it animated
   (a cue's remaining dwell) no longer exists -- so the rules are deleted with
   their element rather than left painting a bar that could never move.

   `scripts/contrast_sweep.py` named three of them in its `NON_TEXT_SUBJECTS`
   table and `tests/test_contrast_sweep.py` scans this file for them; both are
   updated in the same commit. `.pacepip`, the fourth, is untouched -- the
   speed slider stays. */

/* ---- pace (#86, redrawn by #127): a line and a pip, not three buttons.
   Centre (`speedIndex` 2, "Normal") is deliberately near-invisible -- the
   control should only earn attention when it has something to report -- and
   the dot walks toward the edge tapped, "like a playhead": right is faster
   (a shorter dwell), left is slower.

   "Draw thin, hit fat" (#127): `.paceline` is the hit target -- a full
   34px-tall box, generous under a thumb -- and `.paceline-rail`/`.pacepip`
   are what actually paints, both much smaller than the box that catches the
   tap. Splitting hit from paint like this is *why* the control can afford to
   be visually quiet at rest without also being hard to hit. */
/* #388: `pan-y`, not `none`, and on the whole row rather than the visible strip.
   #147 moved the four pointer listeners from `.paceline` to `.pace` so the 92pt
   of empty row on each side would stop being dead space. Only the listeners
   moved -- `touch-action` stayed scoped to `.paceline` below -- so a *tap* in the
   margin worked (a tap needs no `touch-action`) while a *drag* begun there was
   claimed by iOS before the pointer stream started. Reported from a real iPhone:
   "if you tap it does move -- but if you drag nothing happens there."

   `pan-y` rather than `none` is what makes this cheap. The comment below records
   the cost that kept #147 from doing this: `none` on a full-width 48pt band takes
   the page's native vertical gestures away with it, and nobody had measured what
   that band overlaps on a short viewport. `pan-y` gives up only the *horizontal*
   axis -- which is the one the pip wants and the one this page has no other use
   for -- and leaves vertical panning with the browser. So there is no band to
   measure and no gesture to lose. */
.pace { position:relative; display:flex; align-items:center; justify-content:center;
        padding:5px 18px 3px; touch-action:pan-y; }
/* The owner, 2026-08-30 evening: "Can 6px be given back between the speed
   line and the hebrew text? Possibly 2px between the top bar and the speed
   line and another 4px from the speed line to the text?" Read against the
   file: rail-to-pace was `.rail`'s own 5px bottom padding + `.pace`'s 7px top
   = 12px; pace-to-stage was `.pace`'s 7px bottom + `.stage`'s 28px top =
   35px. `.rail` and `.stage` are untouched -- trimming `.stage`'s 28px would
   have given every BEAT card the same 4px back too, more than he asked for --
   so the whole 6px comes out of `.pace`'s own padding: 7->5 top (-2px, gap
   now 10px) and 7->3 bottom (-4px, gap now 31px), exactly his numbers.
   `position:relative` is new here for the two pseudo-elements below, and is
   otherwise inert -- `.pace` was already the CSS-only positioning root for
   nothing before this.

   The padding taken back is not spare whitespace: #127's "draw thin, hit
   fat" and #388 (`tests/test_api_study_page.py`'s
   `test_a_drag_begun_in_the_pace_rows_margin_is_not_claimed_by_ios`) both
   apply, because the four pointer listeners (drift_loop.js) are on THIS
   element, `.pace`, not on the 34px `.paceline` inside it -- so this
   padding was also part of the vertical area a drag could begin in. Shrinking
   it outright would have taken the fix in this ticket and reopened that one.
   See the pseudo-elements immediately below for how the catchment is kept
   at its old size while the visual box shrinks. */
.pace::before, .pace::after { content:""; position:absolute; left:0; right:0; }
/* The two strips this restores, one each side since the cut is not even
   (-2px top, -4px bottom). Each sits OUTSIDE `.pace`'s own border box
   (negative offsets) and inside the space the padding cut just gave back to
   the rail and the stage -- not over `.paceline`, which lives entirely
   inside `.pace`'s own (still 34px-tall) content box, so neither strip can
   ever intercept a tap or drag meant for the visible pip. A hit anywhere on
   either strip is a hit on generated content of `.pace` itself, which is
   what makes it reach the listeners with no `closest()` or extra wiring --
   the same fact `.cuereplay::after`'s own comment records for its hit box.
   `touch-action` is not restated: for hit-testing and gesture purposes a
   pseudo-element's originating element is `.pace`, whose own `pan-y` already
   governs it, exactly as `.cuereplay::after`'s wider hit box needs no
   `touch-action` of its own either. */
.pace::before { top:-2px; height:2px; }
.pace::after  { bottom:-4px; height:4px; }
.paceline { position:relative; width:min(220px, 60%); height:34px;
            display:flex; align-items:center; cursor:pointer;
            -webkit-tap-highlight-color:transparent;
            /* The pip is draggable as well as tappable. `touch-action:none` is
               what makes that possible on a touch screen at all: without it iOS
               claims a horizontal drag for its own scroll/back-swipe gesture
               and the `pointermove` stream stops arriving mid-drag. Scoped to
               this 34px strip -- the rest of the page keeps its native
               gestures. `user-select:none` stops a slow drag from selecting
               the furniture around the control instead of moving the pip. */
            touch-action:none; -webkit-user-select:none; user-select:none; }
/* While dragging, the pip must track the finger rather than easing toward it --
   a .18s ease on `left` reads as lag under a thumb, though it is exactly right
   when a tap steps the pip and the movement should be legible as a step. */
.paceline.dragging .pacepip { transition:background-color .12s ease,
                                          opacity .12s ease, transform .12s ease; }
.paceline.dragging { cursor:grabbing; }
.paceline:focus-visible { outline:2px solid var(--accent); outline-offset:4px;
                           border-radius:4px; }
.paceline-rail { position:absolute; left:0; right:0; top:50%; height:2px;
                  margin-top:-1px; background:var(--line); pointer-events:none; }
/* `--frac` (set by `renderPace` in study.js) is 0 at the slowest step and 1
   at the fastest -- 0.5 lands the pip dead centre at `SPEED_DEFAULT_INDEX`. */
.pacepip { position:absolute; top:50%; left:calc(var(--frac, .5) * 100%);
           width:6px; height:6px; margin-left:-3px; margin-top:-3px;
           border-radius:50%; background:var(--faint); opacity:.5;
           transition:left .18s ease, background-color .12s ease,
                      opacity .12s ease, transform .12s ease;
           pointer-events:none; }
/* Off-centre means the pace has been changed -- its own colour and full
   opacity, so "has this been touched" reads at a glance without having to
   read a number. */
.paceline.off-normal .pacepip { background:var(--accent); opacity:1; }
/* End stops (#127): the old control said `disabled` with `opacity:.3`; a dot
   at the end of a line needs its own way to say "this is as fast/slow as it
   goes", or a tap-acknowledgement on a no-op press at that edge would be a
   lie. A ring, not colour alone -- colour alone would be indistinguishable
   from `.off-normal` at the fast end, which is also an end. */
.paceline.at-min .pacepip, .paceline.at-max .pacepip {
  box-shadow:0 0 0 3px var(--surface), 0 0 0 4px var(--faint); }
.paceline.at-min.off-normal .pacepip, .paceline.at-max.off-normal .pacepip {
  box-shadow:0 0 0 3px var(--surface), 0 0 0 4px var(--accent); }
/* Every tap needs an acknowledgement (#127), and it has to survive
   `prefers-reduced-motion` flattening every transition/animation duration to
   .01ms below. `.tap`/`.tap-blocked` are added and removed on a plain JS
   timer (`paceAck` in study.js), not a CSS animation -- so what makes the
   tap felt is the class existing for ~150ms, a real state change, not the
   transition that (when motion is allowed) also smooths its edges.
   `.tap-blocked` -- a tap against an end stop -- gets its own colour so a
   tap that missed cannot be misread as one that landed. */
.paceline.tap .pacepip { transform:scale(1.7); background:var(--accent); opacity:1; }
.paceline.tap-blocked .pacepip { transform:scale(1.4); background:var(--no); opacity:1; }

/* #2319: "Slower" / "Faster" at the two ends of the line (the readout under the
   rail that #3079 added is gone: owner, 2026-10-06 night calls q1). The
   owner watched a new user drag this line "thinking it would go backwards or
   forwards from the page they were just on -- and it was very confusing for
   them"; the approved Intro prototype (docs/demos/2026-10-05-intro-node) names
   the ends and says where the dot stands, and this is the same thing on the
   real card.

   Costs no height. The end labels are flex siblings of the 34px line on the row
   that already exists, so the row stays 42px, and nothing under it moves.
   `before`/`after` heights are in docs/demos/2026-10-05-speed-labels (that
   probe also measured the readout, which is gone; the end labels' rects there
   still hold).

   Neither end takes a pointer. `pointer-events:none` on the ends means a
   tap on the word falls through to `.pace`, which carries every listener
   (#147), and steps the speed exactly as a tap on that side of the line does --
   the words are part of the control, not dead text beside it. `flex:none` and a
   fixed width keep both ends the same size so the line stays centred. */
.paceend { flex:none; width:48px; font:12px/1 var(--mono); letter-spacing:.04em;
           color:var(--muted); pointer-events:none; -webkit-user-select:none; user-select:none; }
.paceend-slow { text-align:right; margin-right:12px; }
.paceend-fast { text-align:left; margin-left:12px; }

/* ---- stage: the cue, sitting where a subtitle sits ---- */
/* #1124: the stage is the element that YIELDS when the column runs out of room,
   and it is the only one that may.

   The column is a fixed budget -- `shared/phone.css` gives `[data-milim-main]`
   `height:100dvh` -- and five of its six children are non-negotiable: `.rail`
   carries the top safe-area inset, `.clock` and `.pace` are chrome, the flow
   spacer reserves `--milim-nav-total` (the bar's height PLUS the bottom inset),
   and `.phone`'s own `padding-bottom` above reserves the stamp's band. On a
   notched phone in standalone those add ~93px of pure inset that a browser tab
   never charges, which is why this page looks fine in every desktop check and
   is not fine on the owner's phone.

   Something has to absorb the deficit. Before this ticket nothing did: `.stage`
   was `flex:1` (i.e. `flex-basis:0`) but kept flexbox's default
   `min-height:auto`, so its hypothetical size was floored at its own
   min-content height and the whole shortfall fell through to the COLUMN, which
   is `overflow-y:auto`. The column then scrolled -- silently, at `scrollTop:0`,
   with no affordance saying so -- and the last answer button was drawn behind
   the fixed nav bar. Measured with `scripts/study_layout_probe.py` over every
   ask card sources 7-11 can serve, at a simulated 393x852/59px/34px iPhone 15:
   133 of 271 cards scrolled the column and 11 put the last button up to 53px
   behind the bar. At 375x812 it was 225 of 271 and 38 of 271, worst 122px.

   `min-height:0` is the whole mechanism. With it, `.stage`'s hypothetical main
   size really is its `flex-basis` of 0, so the flex algorithm stays in its GROW
   branch: every other child takes exactly the height it needs and the stage is
   handed whatever is left, however little that is. The column's content can
   then no longer sum to more than the column, so the answer buttons are above
   the bar BY CONSTRUCTION rather than by a pixel interim fitted to one
   viewport -- which is what #597's 18px was, and why it went stale the moment
   #885 put `--milim-nav-h` back to 58px.

   Three declarations follow from that one and are not separable from it:

   `overflow-y:auto` -- a stage smaller than its own cue must still be readable.
   Without this the top of a long cue is clipped with no way to reach it. Note
   what this does NOT do: it does not make the stage scroll on a card that fits,
   because a stage that is handed more room than its content needs has nothing
   to scroll.

   `justify-content: flex-start` -- #1300, and it replaced a `safe center` that
   this comment used to argue for at length. The argument was right about the
   overflow trap and wrong about what the page needed: `center` alone spills an
   overflowing column past BOTH edges and the start edge of a scroll container
   is unreachable (offsets cannot go negative), so `safe center` centred while
   the card fit and start-aligned once it did not. That kept the first line
   READABLE. What it could not do is keep the first line in the same PLACE.

   A centred column's first line sits at a y that is a function of the whole
   card's height, so a one-line sentence starts somewhere different from a
   three-line one -- while every transition BETWEEN two such cards still
   measures 0px of displacement, because nothing moved; the next card simply
   began somewhere else. That is why #1283's displacement table read clean for
   three weeks (four of five reports at exactly 0.0px, and they are genuinely
   fixed) while the owner went on reporting, five times, that "it is the top of
   the card that is doing the jump right now".

   Measured with `scripts/study_geometry_probe.py firstline` on `a771ec4`, at
   393x852/59/34, over 30 card kinds plus 30 consecutive ask cards off the live
   corpus -- the ABSOLUTE first-line height, which no earlier instrument here
   reported:

       every card measured   min 180.2   median 237.7   max 401.3   SPREAD 221.1px
       ordinary ask cards    min 196.0   median 237.2   max 272.2   SPREAD  76.2px

   `flex-start` collapses that to zero, and it does it structurally rather than
   by a number: `#stage` is `flex:1` in a column whose four other children are
   fixed-height chrome, so the stage's own border-box top is already identical
   on every card (168.0px on all 30 states, before any change). Start-aligned,
   the first child's top edge IS the stage's content-box top -- border-box top
   plus this rule's own `padding-top` -- which is a constant of the column. So
   "every card starts at the same first line height" is true by construction,
   at any content height, with no pixel fitted anywhere and nothing reserved.

   It also subsumes what `safe` was for: a column that is start-aligned at
   every size has the first-line-reachable property unconditionally, rather
   than acquiring it at the moment of overflow. `overflow-y` below is still
   what makes the rest of a too-tall card reachable, and is not optional.

   THE PADDING IS THE OFFSET, and it is left alone deliberately. 28px above the
   first line, on every card, is what this rule now hands the page; the owner
   asked for the sentence "a bit higher than where the first question card
   usually lands the Hebrew", and the first question card measured 211.6px
   against this anchor's 196.0px. Trimming the padding further would move every
   card up together and is one edit away -- but it is a separate judgement
   about breathing space, not part of removing the slack, and it would be the
   first change here to push a card's first line LOWER-bound down. As it
   stands 196.0px was already the minimum of the ask-card distribution -- the
   tall cards that filled the stage were start-aligned by `safe` and sat
   exactly here -- so no ordinary card's sentence moves down. Exactly one state
   in the whole census does move down: the SOLVED safe-cracker, whose headline
   was painted at 180.2px, well above the stage's own content box, because it
   carried a -0.6em top margin on top of a -3.5deg rotation. See
   beats/safe_cracker.css for that one; it was never inside this anchor to
   begin with, which is the point.

   What this does NOT do, stated so nobody reads more into it: the stage's
   height is unchanged, so `.ask` -- a sibling, sized by its own content -- does
   not move, and a card that overflows the stage today still overflows it. The
   slack that used to sit above the sentence now sits below the translation,
   between the card and the answer buttons.

   `overscroll-behavior-y: contain` -- the stage is now a scroll container
   nested inside another one, and without this a flick that reaches the end of
   the cue chains to the page. It also stops iOS's rubber-band from dragging the
   whole app.

   NOT chosen, and why, so nobody re-derives it: a scroller inside `.ask` (hides
   an option behind a scroll gesture nothing announces -- a learner who sees
   three buttons answers from three); a bigger fixed reserve (spends the space
   this ticket is trying to find, and #597 already proved a literal goes stale);
   fewer options (a server-side design decision, not a layout one); a font-size
   ramp on `.cue`/`.opt` (shrinks the text on exactly the device where it is
   hardest to read, and only lowers the frequency of the defect rather than
   removing it). See the PR for #1124.

   This also settles the claim the `padding-bottom` comment above makes -- that
   this column "does not scroll" -- which #973's keepout argument rests on and
   which was measurably FALSE at a non-zero inset. It is true again, and now for
   a structural reason rather than because no card had happened to be tall
   enough yet. `tests/js/test_study_column_budget.mjs` holds it. */
/* #851 follow-up: `flex:1` is `1 1 0%` -- grow, shrink, and a ZERO basis, so
   `.stage` swallowed every spare pixel in the column and `#ask` (its sibling,
   not its child) was pushed to the bottom of the screen. Measured on cue 7:22:
   224px of empty band between the English line and the question, with the
   options ending at y=765 in an 875px viewport. The owner's words: "it should
   just move up and not feel so weighted to the bottom of the page", and he
   ruled the reflow acceptable himself -- two asked cards never follow each
   other, so nobody watches the block jump.

   `0 1 auto` drops ONLY the grow. `flex-shrink:1` and `min-height:0` are
   untouched, so #1124's mechanism is intact: `.stage` is still the one child
   with room to give when a card is too tall, which is the whole reason those
   two declarations are here. What changes is where the SPARE space goes --
   below the question now, instead of above it. Measured across all 278 ask
   cards, both answer states: zero column overflows, zero buttons under the
   nav, and the clipped-card counts move only in the direction of more room. */
/* #2714: `gap` 20px -> 4px and vertical `padding` 28px -> 8px (horizontal
   24px UNCHANGED -- that is the card's own reading margin, not chrome).
   Measured on the owner's report (375x667, standalone, the crushed English
   line): this padding and this gap are pure fixed overhead on the ONE
   element the column already treats as flexible (`min-height:0`, above) --
   #1124's own mechanism hands 100% of any column deficit to `#cue` first,
   so every pixel this rule stops spending is a pixel `#cue` gets back
   before anything else does. 8px top/bottom is still a real margin (the
   cue never touches the rail above it or `#ask` below it, checked by
   screenshot at both viewports in both answer states); 4px between the cue
   and the reveal is still a visible break (the reveal never merges into the
   Hebrew line it glosses) so the stage still reads as its own block. See
   this ticket's PR body for the measured before/after table. */
.stage { flex:0 1 auto; min-height:0; display:flex; flex-direction:column;
         justify-content:flex-start;
         overflow-y:auto; overscroll-behavior-y:contain;
         gap:4px; padding:8px 24px; text-align:center; cursor:default;
         /* ---- #1349, and it INVERTS #126's follow-up rather than loosening
            it. Read both halves before changing either.

            #126's rule was a blanket `user-select:none` on this whole box,
            and its reason, written down here, was:

              "the hold gesture works, but the stage is a page of *text*, so
               iOS reads a sustained press as the start of a selection -- and
               its selection heuristics reach beyond the touch point, so it
               grabs runs of the cue the finger was never over, raises the
               magnifier, and offers the callout menu. All of that fires on
               top of a gesture whose entire job is to sit still."

            It also wrote down its own cost: "the Hebrew on this page can no
            longer be selected and copied. Accepted because the stage's whole
            interaction is press-and-hold."

            THAT PREMISE EXPIRED WITH #1348. There is no press-and-hold on
            this stage any more -- the gesture was deleted with the drift it
            paused (drift_loop.js) -- so there is no longer a sustained press
            for iOS to misread, and the blanket rule is now buying nothing
            while still costing the copy. Meanwhile #1349 is the owner
            reporting the OTHER half from a phone: "an errant click elsewhere
            ... causes the app to think that you are selecting text. It does
            that now and its super annoying for the space below the
            hebrew/english area."

            So the rule is not relaxed, it is aimed. `user-select:none` stays
            on the CONTAINER, which is what the empty space belongs to -- the
            20px flex gaps, the 28/24px padding, and every pixel of slack
            below the last line, which is exactly the band he named. The two
            text elements opt back IN, below (`.cuerow .cue`, `.reveal`), so a
            learner can still select a word to look it up elsewhere -- #1349's
            explicit requirement, and pinned by
            `tests/js/test_study_selection.mjs` so a later blanket rule cannot
            quietly take it back.

            The other three declarations are unchanged and are NOT part of the
            inversion: `-webkit-touch-callout:none` suppresses the long-press
            callout menu (a separate behaviour the selection rule never
            covered), the tap highlight stays off, and
            `touch-action:manipulation` still drops the double-tap-to-zoom
            delay without touching pinch zoom. */
         -webkit-user-select:none; user-select:none;
         -webkit-touch-callout:none; -webkit-tap-highlight-color:transparent;
         touch-action:manipulation; }
/* #1349: the text opts back in. Two rules, both deliberately scoped to the
   elements that CARRY the sentence rather than to anything structural -- a
   `.stage > *` would have handed selection back to the flex gaps' own boxes
   and to the Continue button, and the button is named in #1349's own closing
   condition: "the continue button ... is not itself selectable -- a control
   that highlights as text when you press it is the same defect wearing a
   button."

   `.reveal` rather than `.english` alone so the gloss row travels with it if
   #1210's `display:none` is ever lifted; a hidden element cannot be selected
   either way, so this costs nothing today and cannot go stale. */
.cuerow .cue, .reveal { -webkit-user-select:text; user-select:text; }
/* ---- #2714, second pass: the Hebrew/English gap is ROOM, not overhead ----

   The owner, on the result of the first pass (the 20px -> 4px `gap` above):
   "every card feels cramped even though there is like 60% of the card
   below ... These reading pages have so much room there is no need to crush
   anything." And on the longer lines of Food and cooking's unit 4: "a slight
   grey line between the hebrew and the english -- there just isn't enough
   room."

   Measured on the served page (real app, copy of demo.db, source 7 cues
   22 and 31-35), both halves are true at once and they are different cards:

     the grey line   is #1124's scroll-hint band (`.stage.showing .cue`,
                     below). It paints only while `#cue` is shorter than its
                     sentence, which happens only when the COLUMN is out of
                     room -- an answered card on a short screen. 375x639
                     standalone, cue 35 answered: `#cue` 118.9px of 135px,
                     0px free below `#ask`. The band sits on the cut third
                     line, which is exactly "between the hebrew and the
                     english".
     the 60% below   is every other card. The same cue as a reading card at
                     393x765 (his phone, standalone): 330px free below the
                     Continue button, with 4px between Hebrew and English.

   A fixed 20px cannot serve both: it is the right gap on the second card
   and it costs the first card's cue 16 more pixels. So the gap is a flex
   item that YIELDS FIRST. It is `order`ed between `.cuerow` and
   `.reveal` (a pseudo-element cannot otherwise sit between two children),
   and its flex-shrink is scaled so that any column deficit is taken from it
   before `#cue` gives up a pixel: shrink 100000 x 20px against the cue
   row's 1 x ~135px hands the spacer 99.99% of a deficit until it is down to
   its 4px floor, and only then does #1124's mechanism reach the cue. A card
   with room
   reads with the pre-#2720 20px; a card without room is exactly as it was
   after #2720 (4px), and never worse. The spacer IS the whole gap: this
   structure sets the stage's own `gap` to 0 and the spacer's `min-height`
   is the 4px floor. (Not `gap` plus a spacer with a negative margin to
   cancel it: a negative block-start margin is what #1300's anchor guard
   refuses on this page, `tests/js/test_study_first_line_anchor.mjs`.)

   Growing `.stage` into the free height instead (`flex:1`) is the obvious
   reading of "use the space below" and it is the one this file already
   tried and the owner already turned down -- see the #851 follow-up above
   `.stage`: it pushed `#ask` to the bottom of the screen ("it should just
   move up and not feel so weighted to the bottom of the page"). This keeps
   `.stage` content-sized, so the question still follows the text.

   The Continue button under the English on a reading card gets the same
   treatment (`::before`, ordered after `.reveal`): it sat 4px under the
   English after #2720, and nothing on a reading card competes for that
   room. Everything here is scoped with `:has()` to the one structure it
   belongs to -- `STAGE_HTML`'s cue row, reveal, `#continueBtn` -- so a
   beat, the path map, the first-run card or the new-words preview, which
   all replace the stage's markup, keep the stage's own 4px `gap` and never
   grow a spacer. `#continueBtn` by id, not `.advance`: a `:has()` naming
   `.advance` is reserved for #1592's selection rule and pinned as the only
   one (`tests/js/test_study_selection.mjs`). */
.stage:has(> .cuerow + .reveal) { gap:0; }
.stage:has(> .cuerow + .reveal)::after {
  content:""; order:1; flex:0 100000 20px; min-height:4px; }
.stage > .reveal { order:2; }
.stage:has(> .reveal + #continueBtn:not([hidden]))::before {
  content:""; order:3; flex:0 100000 20px; min-height:4px; }
.stage > .reveal ~ * { order:4; }
/* #126: the stage is now a press-and-hold target (pointer or space/enter),
   which is what makes it a keyboard-focusable `tabindex="0"`. */
.stage:focus-visible { outline:2px solid var(--accent); outline-offset:-4px; }
/* #1124, the second half: WHICH part of a squeezed stage gives way, and it is
   not the same part before and after the tap.

   With the rule above alone the stage clips from its bottom edge (that is what
   `safe` alignment means: start-aligned once it overflows), and its bottom edge
   is `.reveal` -- the English line and the `word — meaning` gloss. Measured over
   all 271 ask cards at 393x852/59/34: harmless while the question is up (the
   reveal is a median 4px short of fitting, and `.gloss` is at opacity 0 there
   anyway), and NOT harmless once it is answered, where the median card lost
   50px -- the whole gloss line, on the one screen whose job is to say what the
   word meant. `judge()`'s own comment: "a wrong answer re-reads the cue with the
   target pointed and glossed -- the whole point of asking in context".

   `showing` is the class `judge()` adds, so the stylesheet already knows which
   moment it is in. Before the tap the cue is what is being read and the reveal
   is mostly invisible, so the stage clips its bottom as above. After the tap the
   reveal is the answer, so the deficit moves onto `.cue` instead: the sentence
   the learner has had on screen for seconds scrolls, and the thing they have not
   read yet does not.

   The principle, stated so it can be argued with rather than inferred from the
   declarations: A SQUEEZE MUST NOT HIDE CONTENT THAT HAS NOT YET BEEN SHOWN.
   The reveal has existed for one frame. The cue has been on screen for the
   whole dwell and is one scroll away. That is the same rule that makes an
   answer button behind the nav bar unacceptable, applied one level down; it is
   not a preference for translations over Hebrew.

   `flex-shrink:0` on the reveal forces the whole deficit onto the cue rather
   than sharing it out by base size; the cue's `min-height` lets it go below its
   own content, the same mechanism as `.stage` above; and its `overflow-y:auto`
   keeps the lines it gave up reachable.

   The floor is ONE line of the cue's own text, and it is not decoration. With a
   bare `min-height:0` the whole deficit landed on the cue, and the first card
   checked by eye had taken it to ZERO -- reveal intact, options intact, and the
   Hebrew sentence the question is about gone from the screen entirely. Only the
   rendered screenshot showed that; the aggregate said "median 50px" and read as
   fine. `1.5em` is exactly one line box (`em` resolves against the cue's own
   `font-size`, `calc(30px * var(--milim-text-scale))`, and its `line-height` is
   1.5), so the floor follows the text-size setting (#886) rather than pinning a
   pixel count that only holds at scale 1.

   The floor was fitted by measurement, not picked. Answered state, all 271
   cards, 393x852/59/34 -- cards whose reveal is pushed below the stage's edge:

       no rule at all      229 of 271, median 50px   (whole gloss line lost)
       floor 0             5 of 271                  (but the cue can vanish)
       floor 1.5em (1 line)  14 of 271, median 30px
       floor 2.25em          46 of 271, median 20px
       floor 3em (2 lines)  163 of 271, median 22px

   1.5em is the knee: past it the cue stops giving anything back and the
   deficit returns to the stage, which is where it started. The 14 remaining are
   the tallest cards in the corpus plus the five that carry a story cover (#946
   paints a 96px drawing on each film's first cue, and it does not shrink); they
   degrade the way this page did before, with the stage clipping its own bottom
   and the content still reachable by scrolling it.

   This whole block only bites while the stage is genuinely short of room -- a
   stage with space to spare has no deficit to place, and nothing here shrinks
   anything. `showing` is also set mid-drift at 0.68 of the dwell, when there is
   no ask card and therefore no pressure; that is a no-op by the same argument.

   The floor is the one number here the owner may want to move, and #1124
   carries the table above for exactly that. */
.stage.showing .reveal { flex-shrink:0; }
.stage.showing .cue { min-height:1.5em; overflow-y:auto; }

/* #1124, the third half, and it exists because the second half opened a defect
   the first half did not have.

   Squeezing the cue means a learner can be reading ONE line of a two-line
   sentence with nothing on screen saying so, and concluding that is the whole
   sentence. Measured over all 271 ask cards at 393x852/59/34, once answered:
   209 of 271 cards hide part of the cue, and only 62 show it whole. Before this
   ticket that count was 0 of 271, at every viewport and in both states -- the
   column scrolled instead and the cue was always laid out whole. So this is a
   NEW class of defect, not a smaller one, and on a reading app it is not small.

   It is also the exact objection `.ask`'s own guard makes -- "a scrolling
   answer list looks like a shorter answer list, and a learner who can see three
   options answers from three". That reasoning does not stop being true when the
   content is a sentence instead of a button.

   ## What this is, and why not the obvious thing

   A `mask-image` fade on the cue's bottom edge is the obvious answer and it is
   wrong: CSS cannot make a mask conditional on whether the box actually
   overflows, so it would fade all 271 answered cards including the 62 whose
   sentence is complete. A mark that is always present is a STYLE, not a signal
   -- and this file already carries the lesson that a dead declaration which
   looks like a fix is worse than no declaration (see `.rail select`'s
   `text-overflow` note).

   The four-layer background trick is conditional, and that is the whole reason
   for its complexity. Two COVER layers painted in `--ground` are
   `background-attachment: local`, so they are glued to the content and scroll
   with it; two EDGE layers are `scroll`, so they stay pinned to the box. At the
   bottom of the scroll the cover sits exactly over the edge and hides it; with
   more content below, the cover has scrolled away and the edge shows. The
   result is a soft band that appears only while there IS something below and
   disappears the moment you reach it. Backgrounds do not affect layout, so this
   costs nothing in the vertical budget the rest of this ticket is fighting over,
   and it adds no element and no script.

   `--scroll-hint` is the tone (#1202; it was `--line` until then -- see that
   ticket for why: the band read as "a rule that failed to render" rather than
   "more text below" when painted in this page's own divider colour, and was
   confirmed identical to it, byte for byte, in both themes). It is not a black
   shadow either: a black shadow would be invisible on #0E1013 and heavy on
   #F3F1EC. `--scroll-hint`'s own comment, beside its definition near the top
   of this file, carries the derivation and the contrast table.

   The band is 18px against a 26px cover, so the cover always fully hides it at
   rest. Both are well under one 45px line of the cue, which is the constraint
   that matters: this must not eat a glyph. `.cue`'s own line-height is 1.5, so
   the descender of the last visible line sits above the band.

   ## The cue ONLY, and that is a contrast result, not a preference

   `.stage` is a scroll container too -- it is what still clips the reveal on 14
   of 271 answered cards -- and the obvious move is to band both. Scored against
   this repo's own WCAG math (`scripts/contrast_sweep.py`'s `contrast_ratio`),
   with the band at full strength, it fails:

     over the band          dark        light      floor
     .cue's own colours
       --ink               10.54:1     10.85:1      3.0   (large text)
       --known              5.72:1      4.27:1      3.0
       --func               3.78:1      3.35:1      3.0
       --accent             7.54:1      3.45:1      3.0
     the STAGE's prose
       --muted (.gloss,
        .english)           3.78:1      3.35:1      4.5   FAILS BOTH
       --faint
        (.source-title)     3.01:1      3.29:1      4.5   FAILS BOTH
       --no                 3.71:1      3.80:1      4.5   FAILS BOTH

     (re-run against `--scroll-hint` for #1202; `--muted` dark and `--func`
     dark are the same token, #868D97, so they move together above)

   The cue clears its floor everywhere -- it is 30px display type, so AA's
   large-text 3.0 applies, and it only ever paints those four tokens. The stage
   also carries prose at 15-16.5px, where the floor is 4.5, and #715/#716's
   ruling on this page is explicit that there is NO furniture exemption: every
   prose row clears AA, full stop (that ticket raised a token over a 0.04
   shortfall). A band dark enough to read as a signal is a band that fails those
   three; a band that passes them is invisible. So the stage does not get one,
   and its 14 cards keep degrading the way this page did before -- clipped at
   the bottom edge, reachable by scrolling, unannounced. Recorded rather than
   quietly shipped.

   Verified rather than assumed: `background-attachment: local` is supported and
   computes here, the first draft's partly-transparent covers were caught by a
   red/green diagnostic render rather than by reasoning, and the band is present
   on a truncated card and absent on a complete one, in BOTH themes, by
   screenshot. See #1124's PR.

   What this does NOT do is change the truncation counts -- 209 of 271 is 209 of
   271 with this rule and without it. It makes the truncation VISIBLE. That is
   the fix for "the learner thinks that is the whole sentence", and deliberately
   not a fix for "the sentence is cut", which is the cue floor's job and the
   owner's call (#1124 carries the table). */
.stage.showing .cue {
  background-image:
    /* the two COVERS: flat --ground, exactly as tall as an edge band, glued to
       the content by `local`. Flat and not a gradient -- a fading cover only
       PARTLY masks the band underneath it, and the first draft of this rule did
       exactly that: both edges showed at rest and the cue read as a boxed
       highlight rather than a scroll hint. Caught by screenshot, not by
       reasoning. */
    linear-gradient(var(--ground), var(--ground)),
    linear-gradient(var(--ground), var(--ground)),
    /* the two EDGE BANDS, pinned to the box by `scroll` */
    linear-gradient(to bottom, var(--scroll-hint), transparent),
    linear-gradient(to top,    var(--scroll-hint), transparent);
  background-position: 0 0, 0 100%, 0 0, 0 calc(100% - 1px);
  background-repeat: no-repeat;
  /* #2714: the covers are 26px against the bands' 18px -- the margin the
     comment above has always described and the rule never had. With equal
     sizes, ANY overflow at all (1-5px: niqqud under the last line, WebKit
     rounding) scrolls the cover a few px off its band and paints a thin grey
     line on a cue that is not cut, with the whole page empty below it. The
     owner saw exactly that on his phone (2026-09-28, reading a niqqud cue).
     26 - 18 = 8px of overflow now shows nothing; a real truncation (a line
     is ~45px) still shows the whole band. */
  background-size: 100% 26px, 100% 26px, 100% 18px, 100% 18px;
  /* #3046: the bottom band sits 1px above the box's edge, not on it -- on a cue whose
     box is fractional its last device row otherwise peeks out under the cover as a
     hairline (see the #3046 note after `.cuerow .cuereplay`). */
  background-attachment: local, local, scroll, scroll;
}
/* #946 put the story's own cover here, inlined above the first cue, and #783
   put the repeated film title (`.source-title`) beside it. #1282 deleted
   both elements from `/study` (study.html, rendering.js's `STAGE_HTML`) --
   the cover for the jump it cost on every story's first cue, the title
   because the film picker directly above the stage already names it -- but
   left these two rules and `.story-cover svg`/`.story-cover:empty` in place,
   because deleting them broke `tests/test_contrast_sweep.py`'s
   `test_study_rail_and_film_progress_text_clears_aa_after_715`, outside that
   ticket's scope fence. Filed as #1295 and closed here: the test no longer
   hardcodes `.source-title` (see its own comment), so there is nothing left
   holding these four rules up. There is no `.story-cover` or `.source-title`
   rule left anywhere in this file to find. */
/* #886: font-size reads --milim-text-scale -- see shared/fonts.css's own
   comment on that token for why this is one of the two surfaces it is
   scoped to. */
/* ---- #1424: `text-wrap:balance` is why the Hebrew "gets cut off so quickly"
   ----------------------------------------------------------------------------

   The owner, 2026-08-31, with a photograph of the card: "The hebrew lines do
   feel a bit strange because they get cut off so quickly."

   It is NOT #1387's narrower box, and that was the first thing checked rather
   than assumed: #1387 took `#cue` from a shrink-wrapped 297px on a long line
   to a constant 319px, so the box got WIDER. Two more candidates were measured
   before anything was changed, both on the served page in a real Chrome at a
   true 393x852:

     NIQQUD -- ruled out, exactly zero. Every one of the corpus's 306 cues
       carries points (median 20 marks per cue). Laid out with the points and
       with them stripped, the line COUNT differs on 0 of 306 and the first
       line's WIDTH differs on 0 of 306. They are zero-advance combining marks
       in `--sans-he`; they cost no horizontal space at all.
     TEXT SCALE -- ruled out. His photograph measures 30px type on a 45px line
       box, which is `calc(30px * 1)`: the default, the same scale the probes
       run at.

   It is this declaration, which has been here untouched since the study page
   was first built (#83, 2026-07-27) and was never argued for. `balance` tells
   the browser to even the lines up, which it buys by making the FIRST line
   short. That was defensible while this element was centred; #1387 made the
   cue right-justified, so the first line is now the reading edge the eye lands
   on and the one the replay button sits beside -- and `balance` was
   deliberately shortening exactly that line.

   Measured over all 306 corpus cues at 319px, `balance` vs plain `wrap`:

     line count      IDENTICAL on all 306 cues -- 61 one-line, 206 two, 39
                     three, under both. `balance` was never buying a line back.
     first line      median 224.3px (70.3% of the box) under `balance`
                     median 286.5px (89.8% of the box) under `wrap`
     per cue         median +60.9px, mean +53.4px, max +162.8px

   And on the card he photographed (source 7, cue 4) it is +71.3px at the
   shipped width. `wrap`, not `pretty`: `pretty` is the other tidying mode and
   it optimises the LAST lines, which is the same trade in a smaller dose.

   `#english` below keeps its own `text-wrap:balance` and should: it is short,
   centred, LTR prose with no control beside it -- the case balance is for. */
.cue { margin:0; font-family:var(--sans-he); font-size:calc(var(--fs-cue) * var(--milim-text-scale)); line-height:1.5;
       font-weight:500; text-wrap:wrap; }
.cue .tgt { transition:color .55s ease, text-shadow .55s ease; }
/* #2064: the opt-in romanized line ("Show romanized Hebrew", Settings -- off by
   default, so this rule is idle unless a learner asked). A child of `.cue`, so it
   scrolls with the sentence under #1124's squeeze and never adds a row to the
   column; `.cue`'s own `dir="rtl"` is overridden on the element, because Latin
   letters in an RTL box would put the trailing punctuation on the wrong end.
   `--muted` on `--ground` is the pair `.english` already clears AA with, in both
   themes; 16px is a reading aid under 30px Hebrew, not a second headline. Its box
   is a whole number of pixels (24px line, 4px margin) on purpose: a fractional
   height left a hairline of #1124's scroll-hint band under the cue.
   `user-select:none` keeps it out of a copied sentence. */
.cue .cue-roman { display:block; direction:ltr; text-align:start; margin-top:4px;
                  font-family:var(--serif); font-size:calc(16px * var(--milim-text-scale));
                  font-weight:400; line-height:1.5; color:var(--muted);
                  -webkit-user-select:none; user-select:none; }
/* #156: `targeting` is independent of `lit` -- `lit` still stages the
   `.gloss`/`.english` reveal at 0.42/0.68 (below), but the learner could not
   tell which word a card was even about for the ~2s before that fired. This
   rule accents `.tgt` under `targeting` alone, set in `show()` from the first
   painted frame, riding through the `swap` frame (#96) rather than toggling
   after it -- so there is a value here to inherit, never a transition to
   suppress. `lit` still lists the same declaration, harmlessly redundant once
   it also applies. */
.stage.lit .cue .tgt, .stage.targeting .cue .tgt { color:var(--accent); text-shadow:0 0 20px var(--glow); }

/* The niqqud scaffold (#53, ADR 0019), on the page that asks the questions.

   `.tok` is one token of the cue, wrapped by study.js only once the pointing has
   arrived -- before that the cue is a plain string and none of this applies. It
   carries no affordance of its own: unlike the reader's `.w` there is nothing to
   tap here, so the span must be invisible except for what the points do.

   `.nq` is a single combining mark in its own element, which is the only way CSS
   can reach it -- a mark inside a text node is unselectable, and dimming the
   whole word would fade the letters too, which on this page would fight the
   `.tgt` highlight for meaning. .55 is ADR 0019 decision 6's value, read on the
   page at three: .4 vanishes on the dark theme and .7 is indistinguishable from
   full. `full` and `faded` are the same letters and the same marks, so a word
   moving down a rung between two readings reflows nothing. */
.cue .tok { display:inline; }
.cue .tok .nq { opacity:1; }
.cue .tok.faded-points .nq { opacity:.55; }

/* #354: decided as the design, not a defect (worksheet 6, round 11).
   `.cue .tok.faded-points .nq` composites to 3.69:1 light / 5.37:1 dark
   against the page background (measured 2026-08-10, PR #382's fixed sweep;
   the ticket's own title quoted 3.68:1 -- its own table already said 3.69:1,
   so this is rounding drift between two runs, not a real disagreement).
   That fails AA's 4.5:1 normal-text floor in light mode, but the marks are a
   fading aid the learner is being weaned off, not body text they must read
   -- AA's 4.5:1 governs text you must read, and the 3:1 non-text floor
   governs this, which both themes clear. Same case, same numbers shape, in
   the reader's own (since-removed) `.w.faded-points .nq` (3.79:1 / 5.05:1) -- this is a two-file
   decision (#355 established that), not two separate calls. Pinned as
   `scripts/contrast_sweep.py`'s `ACCEPTED_SUB_AA["study.css"]`, floored at
   3:1 rather than at today's number, so a future palette change that drags
   this below the floor still turns `tests/test_contrast_sweep.py` red. Left
   open deliberately: nothing here says a user-facing control to restore
   full contrast is unnecessary -- it says the default is correct. That is a
   separate ticket if anyone wants it. */

/* Word status on the drifting cue (#180).

   The reader paints all four states and this page paints two, because the two
   pages have opposite jobs. The reader is for browsing; this is for *focus* --
   the eye is meant to go to one word, and the yellow is spent on that word and
   nothing else (see the header). So `known` and `func` both recede, to read as
   "you can skip these", and `learning`/`unknown` are deliberately left alone
   rather than putting a second accent on screen beside `.tgt`.

   study.js decides none of this: it puts `s-known`/`s-learning`/`s-func`/
   `s-unknown` on every token span from the payload and stops, so the whole
   per-state table is these two rules and the parked one below.

   --known, and it is its own token rather than --muted (#391, round 15). The
   reasoning that put this on --muted still holds against --faint -- --faint may
   carry receded furniture and a word of the subtitle is not furniture -- but it
   stopped short. Reported after the first real sitting:

     "the known words should not be as pushed back -- those words are still
      important to provide scaffolding for the new word, and it does become a
      lot when you are given a grey, white and yellow ... a closer blend
      between the white and the grey would possibly be less distracting."

   So the recession was tuned for "you can skip these" when the job is nearer
   "these are the ones holding the sentence up". --known is a step back toward
   --ink: 8.61:1 dark and 5.89:1 light against --ground, from --muted's 5.69:1
   and 4.63:1. Still plainly quieter than --ink (15.86:1 dark), so ADR 0018's
   "known recedes" survives -- it recedes less far.

   **Both numbers are a provisional pick.** Nothing measured says how far back a
   known word belongs; they are one step of roughly half the gap to --ink,
   chosen to be legible as a change without collapsing the three-colour system
   into two. #468 carries the refit, and the separate finding under it -- that
   the three colours were not self-explanatory to the person using them.

   Splitting the token off --muted is what keeps this from repainting every
   label and tally on the page, which nobody asked for.

   `:not(.tgt)` is the design worry in one selector. A target is routinely
   `learning` (that is what answering one promotes it to) and can be `func`, and
   greying out the word the card is asking about would be the exact inversion of
   this page's job. `.tgt` and the status class ride the same span, which is what
   makes the exception expressible without study.js knowing about either.

   #935 split-shade -- `known` and `func` used to both land on `--known` right
   here, which meant an all-grey run of tokens claimed two different things at
   once: "you already know this" and "this will never be taught". Measured on
   the owned corpus through the real `build_study`: 1,236 of 2,558 non-punctuation
   tokens (48.3%) are `func`, so that collision was not an edge case. The
   worksheet's answer (2026-08-21, Q2) was `split-shade`, not `both` -- give
   `func` its own tone, distinct from `known`, and say nothing new about it (no
   Guide card; that is a separate ask if the new shade turns out to need
   explaining).

   The rule below still opens with the ORIGINAL grouped selector -- `known`
   stays on `--known`, unmoved (#391, round 15's tuning survives) -- and a
   second, equal-specificity rule for `.s-func` alone follows it. Equal
   specificity, later in source order, wins the cascade for `.s-func`
   elements: this is two declarations, not two conflicting ones, and it means
   the ORIGINAL line is untouched byte-for-byte, which is deliberate --
   `tests/test_api_study_reveal.py::test_a_learned_word_recedes_on_the_study_page` reads
   that exact line and every assertion on it (the token names a CSS variable,
   that variable clears AA in both themes, `.s-func` and `:not(.tgt)` are on
   it) is still true of `known`'s own recession; it was never a claim that
   `func` shares `known`'s value, only that both dim.

   `--func` is `--muted`'s value under its own name (see the `:root` comment
   above): already measured, already AA-clean in both themes, and already
   further back than `--known` in both -- exactly what "recede further than
   `--known`, not displace it" asks for, reusing a number this file had
   already proved rather than inventing an unproven one. */
.cue .tok.s-known:not(.tgt), .cue .tok.s-func:not(.tgt) { color:var(--known); }
.cue .tok.s-func:not(.tgt) { color:var(--func); }

/* #349 defensive floor -- this file's half of a coupling that otherwise lives
   nowhere.

   The dim above and the fade at line 228 are written by two rules that never
   mention each other, and the only reason `--muted` text never also renders at
   .55 opacity is `annotation_service.points_for`'s `if status == "known": return
   POINTS_OFF` -- a known word's points come off entirely, so it can never be
   mid-fade, and #349's own measurement over the whole corpus (66,418 tokens,
   all six films) found zero tokens where a dimmed status and `faded` points
   land on the same token. `tests/test_annotation_service.py` pins that half; this
   is the CSS half, so the stylesheet stays safe even if a later, reasonable
   change (#53 is explicitly a *fading* scaffold, not a binary) lets a dimmed
   status keep faint points.

   Pins niqqud opacity straight back to full whenever `.faded-points` and a
   dimmed status class share a span, beating line 228's `.55` on specificity
   (five classes vs. four) without `!important`. It does not fire today -- that
   is the point, it is insurance against the day `points_for` changes, not a
   fix for anything currently on screen. */
.cue .tok.s-known.faded-points .nq, .cue .tok.s-func.faded-points .nq { opacity:1; }

/* Parked, and it is a decision rather than an omission (#180).

   Uncommenting the line below is the whole of adopting `learning` on this page;
   nothing in study.js or the payload changes, and the guard in
   tests/test_api_study_reveal.py --
   `test_the_study_page_spends_no_second_accent_on_learning_or_unknown`, which
   reads this file with its comments stripped -- is what will go red and ask
   for the decision to be made out loud.

   It is NOT the reader's rule. The reader gives `learning` an amber underline
   plus an amber background, and amber *is* --accent here -- the one token that
   means "this is the target word" on every other rule in this file. Porting it
   would put the page's single accent on two words at once. A neutral rule
   (--line, the same hairline the furniture uses) is the placeholder.

   ANSWERED 2026-08-10 (worksheet 4): leave `learning` unmarked. The line stays
   commented as the record of what was considered, not as a pending task -- do
   not treat it as a TODO and do not re-open it on taste alone.

   The number that decided it, measured over the corpus through the real
   `build_study`: **68.2% of tokens already dim** (45,272 of 66,418 -- 42,891
   `func` plus 2,381 `known`). So a cue arrives with three tiers before `learning` gets
   one -- receded, full-strength, and the target's accent -- and a fourth tier
   would land on words that are already at full strength, which is the second
   accent this page exists to avoid.

   What would reopen it is not an opinion but a change to that shape: if the
   dimmed share falls a long way (a learner early enough that little is `known`,
   or a source with fewer function words), the line stops having three tiers and
   the argument above stops holding. Re-measure before arguing. */
/* .cue .tok.s-learning:not(.tgt) { border-bottom:1px solid var(--line); } */

/* #97: `.reveal`'s own opacity gates the translation (its first child now --
   see `STAGE_HTML`), firing at the 0.42 `lit`+`gloss-on` stage. The
   definition used to ride along on this same fade; it now has its own
   opacity/transition below, gated on the later 0.68 `showing` stage, so the
   two lines arrive at different times instead of both jumping in at 0.42. */
/* #2565: and `visibility`, not opacity alone -- an element at opacity 0 is
   still in the accessibility tree, so a screen reader read the English out
   while the card was still asking. `visibility:hidden` takes it out of the
   tree and keeps its box (nothing moves when it appears -- the beats'
   `.beat-reserve` mechanism). The delayed `visibility` transition lets the
   fade-out finish before it goes; on the way in it flips at once. */
.reveal { display:flex; flex-direction:column; gap:9px; opacity:0;
          visibility:hidden;
          transition:opacity .55s ease, visibility 0s linear .55s; }
/* #162: `.plainenglish` is the same 0.42 reveal for a cue with NO exercise --
   the translation is a property of the line, not of whether the cue was picked
   to teach a word. It is a third path to the same opacity rather than a wider
   `lit`, because `lit` also accents `.english .tgt` (below) and a declined cue
   has no word being taught to accent. `.gloss` is unaffected: it carries its
   own opacity:0 and only `showing` (or `notranslated.lit`) lifts it. */
.stage.lit .reveal.gloss-on, .stage.showing .reveal,
.stage.plainenglish .reveal { opacity:1;
                              visibility:visible;
                              transition:opacity .55s ease, visibility 0s; }
/* #97: muted, not accent -- yellow is reserved for the word being taught,
   in the Hebrew line. Held back from view until `.showing` (0.68 of dwell),
   independent of the surrounding `.reveal`'s own opacity. */
/* #1210: the owner's own worksheet answer, verbatim -- "Remove the
   redefinition on this page. The four options will tell you what the
   meaning is ... Even if the user gets it wrong it will show you what the
   correct answer was." `display:none` here, unconditionally, rather than an
   `:empty` guard: rendering.js's `show()` no longer ever writes text into
   `#gloss` (see its own #1210 comment), so the two would agree on the real
   page either way, but `scripts/study_layout_probe.py` -- this repo's own
   committed, uneditable measurement instrument -- builds an independent
   copy of this markup and always fills it with text. An `:empty` rule would
   read that instrument's "before" and "after" as identical (never empty),
   which is exactly the "instrument reads zero" trap this repo has hit
   before; unconditional `display:none` is what makes the probe's own
   `stage_h`/`overflow` numbers move. The rule keeps `color:var(--muted)`
   below (pinned by tests/test_api_study_reveal.py's
   test_the_definition_is_muted_so_yellow_only_ever_means_the_word) and the
   box, not only the text, is what's gone -- an emptied-but-present row
   still spends its `.reveal` flex `gap`, the same reasoning `.beat-reserve`
   below (#1283) turns around the other way (a box kept in flow on purpose,
   invisible rather than empty). */
.gloss { display:none; margin:0; font-size:15px; font-style:italic; color:var(--muted);
         opacity:0; transition:opacity .55s ease; }
.stage.showing .gloss { opacity:1; }
/* #97 gap, corrected by #1077: this used to say ~98% of the corpus has no
   per-cue English, true only while #73 stage 1 was a sampled translation,
   not corpus-wide (docs/translation/ moved whole to
   milim-audits/demo-evidence/docs/translation/ by #244; see README.md
   there). It has been false for the owned corpus since #696 -- the live
   corpus is 306 of 306 cues with a non-empty translation -- and this branch
   is now the minority case, not the norm: it still applies to any source
   that has not been through that translation step yet (a freshly ingested
   one). Staging the definition behind the translation leaves a dead beat at
   0.42 on those cues (nothing in `#english`) and makes the definition wait
   until 0.68 for no reason. `show()` marks the stage `.notranslated` when
   `cur.english` is falsy; there the definition reveals with `lit` (0.42)
   instead of waiting for `showing` (0.68) -- the same timing this page
   always had before the staged ladder. */
.stage.notranslated.lit .gloss { opacity:1; }
/* The serif has no Hebrew italic, so it fakes an oblique and the surface comes
   out sheared. Set the Hebrew back in the face it was written for. */
.gloss .he { font-family:var(--sans-he); font-style:normal; font-size:calc(16px * var(--milim-text-scale)); }
/* #1352: shrunk from 16.5px/1.45 -- the owner's own third proposal ("maybe it's
   the English text that needs to be smaller instead"), taken after his other
   two were measured out: the page's dead air is already spent (every truncated
   card's gap to the nav floor was 8px, identical to the floor, on every one of
   them -- #1300/#1387/#1409/#1467 already spent what #1124 opened), and a
   Hebrew shrink is the thing he asked NOT to do to his own text-size setting
   (#886) without a fresh trigger, which he has not fired since parking it on
   the 2026-08-31 worksheet.

   This rule is NOT the line that overflows -- #1352's own history established
   that with 1,168 measured states, and nothing here changes it. What it does
   is shrink `.reveal`'s own footprint (`.stage.showing .reveal` is
   `flex-shrink:0` by #1124's design, so the deficit it doesn't carry lands on
   `.cue` instead): less height demanded by the English line, before `.stage`
   ever has to decide how much of the cue to keep on screen.

   `.english`'s size was never tied to `--milim-text-scale` -- only `.cue` and
   `.gloss .he` read that token -- so this does not touch the setting #886 put
   in the learner's hands, and it does not need to.

   Measured on `scripts/study_layout_probe.py`, 335-card fixture (sources
   7-12), real Chrome, true 393x852/59/34, against this file before this rule:

     text scale 1.0 (default)   cue truncated  9 of 335 -> 1 of 335,  worst 1.2 -> 0.5 lines
     text scale 1.15 (max, #886/settings.js TEXT_SCALE_MAX)
                                 cue truncated 112 of 335 -> 28 of 335, worst 1.7 -> 1.4 lines

   For comparison, a same-shaped 4% Hebrew shrink (30px -> 28.8px base, the
   size #886 governs) was measured the same way and bought far less at the
   same "modest change" scale: 9 -> 8 at default, 112 -> 99 at 1.15 -- because
   it fights `.stage.showing .cue`'s own `min-height:1.5em` floor before it can
   give a whole line back, where `.reveal`'s footprint has no such floor to
   fight. That experiment was not kept -- it is not shipped, and this comment
   states the number rather than the code so nobody re-derives it by hand.

   28 of 335 at the largest text size is not zero: `.cue`'s own scroll-hint
   (#1124, `.stage.showing .cue`'s `background-image` rule below) is what
   makes the remainder legible rather than silently short -- reachable by
   scrolling, with a visible band saying there is more. Closing that residue
   the rest of the way is the Hebrew-shrink question the owner parked, and it
   stays parked until he re-fires the trigger he set for it. */
/* #3029: 14px -> `--fs-translation` (17px at a phone's width, ~22px on a tablet;
   shared/beat_tokens.css has the numbers). This UNDOES the shrink the long
   comment above argues for (the 14px was bought to give the cue a line back
   under #1124's squeeze): a new tester called the text too small three times in
   eight minutes. What it costs is measured, not assumed -- see the option-floor
   note in shared/beat_tokens.css: 0 of 316 ask cards truncate a cue at the
   default text size at a true 393 wide. Colour untouched: `--muted` measured
   7.5:1 dark / 5.6:1 light on `--ground`, already past AA. */
.english { margin:0; font-size:var(--fs-translation); line-height:1.3; color:var(--muted); }
/* #97: muted and even-weight at t=0 -- inherits `.english`'s own colour and
   weight, same as the rest of the sentence -- then bold and bright at the
   42% mark, on the same 550ms fade as `.cue .tgt`, so the Hebrew word and the
   English word that renders it light up together. Was `color:var(--ink)`
   unconditionally, which pre-highlighted the answer before the fade. */
.english .tgt { transition:color .55s ease; }
.stage.lit .english .tgt { color:var(--accent); font-weight:700; }

/* ---- #1348: the two controls this ticket puts on the card ---------------

   `[hidden]` FIRST, and it is not decoration. `show()` (rendering.js) hides
   both with the `hidden` attribute, whose UA rule is `display:none` -- and a
   class rule setting `display` on the same element beats a UA rule outright.
   Declaring it here, before either class rule, would NOT be enough (equal
   specificity, and the later rule wins), so it is declared AFTER them, at the
   bottom of this block. This paragraph is here because that ordering looks
   like an accident and is not: `tests/js/test_hidden_display_contract.mjs`
   sweeps every `hidden` attribute app/static ships and requires it to compute
   `display:none` on its own page. */

/* The cue row (requirement 4). It exists only so the replay button has a
   "beside" to sit in; everything about it is chosen to leave `#cue` behaving
   exactly as it did as a direct child of `.stage`:

     * `min-height:0` keeps #1124's squeeze working. `.cue` is now a flex item
       of THIS row rather than of `.stage`, and a flex item's default
       `min-height:auto` would refuse to shrink below its content -- which is
       precisely the deficit #1124 moves onto the cue on an answered card.
     * `align-items` is left at its default `stretch`, so `.cue` still fills
       the row's height and `.stage.showing .cue`'s `overflow-y:auto` still
       has something to scroll inside. The BUTTON is centred on its own
       (`align-self`), not by the row, so it cannot stretch to three lines
       tall beside a three-line sentence. `flex-start`, not `center`, and
       that is a rendered-page correction rather than a preference: centred
       beside a three-line cue the button sits opposite the MIDDLE line, with
       clear air above and below it, and reads as a stray icon floating in the
       margin rather than as a control belonging to the sentence. Beside the
       first line it reads as "this line, again" -- and it also stops moving,
       since #1300 anchors that first line and nothing else does.
     * `safe center`, not `center`. Plain `center` plus `min-height:0` plus no
       overflow is #801's exact triple -- an item taller (or here, wider) than
       the box centred OUT of both ends of it and painted over the box's
       neighbours, reported from the phone as "can't click on the buttons ...
       there is text overlaid". `safe` falls back to `flex-start` at the
       moment of overflow, which is the documented fix and what
       `tests/js/test_css_contract.mjs`'s sweep accepts.
     * No padding, no border, no vertical margin: #1300 anchors every card's
       first line, and the row must add no box above the cue or every card on
       the page moves down together. */
/* The negative inline margin is the whole reason this row is FREE, and it is
   measured rather than asserted. `.stage` has `padding:28px 24px`, so 48px of
   this row's width is padding it is allowed to sit in; two 24px flanks cost
   exactly that 48px; and `#cue`'s own content box comes out at the same 343px
   it has on main as a direct child of `.stage`. Zero pixels of sentence given
   up, zero cards re-wrapped.

   That number was picked off a curve, not guessed. Measured on the served
   page across all 306 owned cues at 393x852, cards whose Hebrew gains a line
   as the cue's box narrows:

       343px (main, and this)     0 of 306
       327px                     11
       311px                     27
       295px                     52
       279px                     87
       255px (in-flow, no eat)  157      <- what a plain 36px flank costs

   There is no knee in that curve -- it is a straight trade of button size for
   re-wrapped sentences -- so the only acceptable answer was the one that pays
   nothing, and eating the padding is what makes a real 24px control cost
   nothing. #1282 and #1300 both spent a ticket buying vertical room on this
   page ("these pages really need all the space they can get"); 157 cards
   growing a line would have handed most of it back to a button pressed
   occasionally. */
/* Qualified `.stage .cuerow`, not a bare `.cuerow`, and the extra selector is
   load-bearing rather than tidy. `tests/js/test_study_column_budget.mjs`
   enforces #1124's rule -- *only the stage and its own children may yield the
   column's deficit* -- by requiring every rule in this file that declares
   `min-height:0` to name `.stage`. It reads SELECTORS, not the DOM, because a
   stylesheet is all it has; a shrinkable `.ask` would hide an ANSWER BUTTON
   rather than a line of a sentence already read, which is #1124's defect
   arriving through a different door, and that guard is what stops it. This row
   really is inside the stage, so saying so costs nothing and keeps the guard
   able to tell. */
/* ---- #1387: the row is RIGHT-JUSTIFIED now, not centred -----------------

   The owner, twice in three minutes, on two cards:

     "The replay button should be placed right justified to the first line
      rather than slammed to the right edge. I'd be opposing the centering of
      the text to the page here for a clean right justified look with some
      margins for reading the page wo feeling crushed by having the word run
      to either side of the phone."

   and then, on source 7 cue 36 -- the single word `atah?` -- which he named
   as the card that already looks RIGHT:

     "The placement of the replay button here is what I expected on all the
      pages ... the icon to the right of the first word."

   That third report is a POSITIVE CONTROL, so this is a measurement rather
   than a taste call. All three of his cards, read off the served page in a
   real Chrome at a true 393x852 (CDP `Emulation.setDeviceMetricsOverride`;
   a `--window-size` flag is floored at 500px on macOS), insets 59/34,
   identical in both themes:

     cue   text            #cue box    first line     replay.left     replay right
                                       right edge     - line.right    edge -> phone
     36    one word        73.8px wide  x=233.4        +0.00px         135.6px   <- HE LIKES THIS
      9    one full line  277.7px wide  x=335.4        +0.00px          33.6px
      5    two lines      343px (full)  x=291.8       +76.22px           1.0px   <- "slammed"

   So the mechanism, measured rather than assumed. `flex:0 1 auto` made `#cue`
   shrink-wrap its own text, and `justify-content:center` then floated the
   whole [balance][cue][button] group in the row. On a cue that FITS one line
   the button therefore sits flush against the last glyph -- the thing he
   likes -- but at an x that is a pure accident of how long the sentence is:
   135.6px from the page edge on a one-word cue, 33.6px on a longer one, and
   1.0px (the "slam") the moment the cue is long enough to fill its box, at
   which point `text-wrap:balance` ALSO pulls the first line 76px inboard and
   the button stops being beside the sentence at all. One rule was producing
   both halves of his complaint and the card he praised.

   The fix is to stop letting the sentence's own length decide the frame:

     * `flex:1 1 auto` -- the cue now FILLS the row. Every line's right edge
       is the box's right edge, so `replay.left - firstline.right` is 0.00px
       on every card in the corpus, which is cue 36's measured signature
       reproduced by construction rather than by luck. It is also #1239's
       constancy rule ("as constant as possible so the eye doesn't jump
       around") on the one axis this row controls: the reading edge and the
       button both stop moving between cards.
     * `text-align:start`, not `:right`. `#cue` carries `dir="rtl"`, so
       `start` IS the right edge here -- and it is the right edge because
       that is the READING edge of the script, which is the reason he gave
       ("Hebrew is RTL, so right-justified is the reading edge"). Spelling it
       `right` would be a coincidence that stops being true the first time
       anything LTR is painted into this element -- `.stage.error .cue`
       below already does exactly that with an English error string.
     * `padding-inline-end:24px` -- the margin. Without it the button's own
       flank IS the margin and its outer edge lands 1px off the phone. 24px
       is not a taste literal and it is not "a bit more than the card he
       liked": it is the number that lands the button's box on `.stage`'s OWN
       content edge. Measured on the served page, the page's right-hand edges
       are `.phone` inner 392, `.rail`'s content 374 (its rightmost control,
       the feedback flag, is a 44px box ending there), and `.stage`'s content
       368 -- which is where `#reveal` and `#english` already end. With this
       padding the button ends at exactly 368 and its 17px glyph at 364.5,
       against the rail flag's glyph at 362. So the sentence, its
       translation, the replay control and the rail's own icons all stop on
       one line, which is #1239's constancy argument rather than a preference.
       After: the button's right edge is 24px inside the column (25px from
       the screen) where it was 1px, and the first line's right edge is 48px
       inside it on every card.

   WHAT IT COSTS, measured, because the comment below this one is emphatic
   that width on this row is paid for in re-wrapped sentences: `#cue`'s
   content box goes 343px -> 319px. Against `scripts/study_layout_probe.py`
   (repaired at #1368), 278 ask cards, sources 7-11, both states -- the
   number that matters is `cue truncated`, not `column overflows`, which
   stays at 0 until ~660px of viewport:

       cue truncated, answered state   1 of 278 (0.5 lines)  ->  1 of 278 (0.5 lines)
       cue truncated, ask state        0 of 278              ->  0 of 278
       column overflows                0 of 556              ->  0 of 556
       stage clipped                   0 of 556              ->  0 of 556

   Three-line cues go 14 -> 26 of 278 and one-line cues 68 -> 57, which is
   how the run proves it SAW the narrower box rather than reporting a
   comfortable zero from an instrument that missed the change. None of the 12
   cards that gained a line truncates: the answered card's slack below the
   last answer button is a median 103.5px against a 45px line box (#1225's
   own re-measure on this same probe), so a card that grows a line spends
   slack it already had.

   Below the probe's own 2px noise floor, the raw `cue_clipped > 0` list goes
   from two cards to three: s11 cue 3 stays cut by exactly 22px (#1352's one
   real case, unmoved), s11 cue 15 stays at 1px, and s11 cue 23 joins at 1px.
   1px of a 45px line box is not a cut sentence and the probe's own threshold
   says so, but it is stated rather than rounded away. A 32px margin measures
   the same three cards and the same 22px worst case (3-line cues 30 of 278),
   so 24px is not the ceiling -- it is the number the frame argues for.

   ---- WHAT #1424 COST ON TOP, same probe, same 278 cards, same day --------

   Everything above is #1387's and is left standing. #1424 narrowed `#cue`
   again, 319px -> 295px, to pay for a 40px button and clause 3's 8px gap
   (see the contract below for why, and for the two options that were priced
   and not taken). Re-run of `scripts/study_layout_probe.py`, unmodified,
   before and after this branch:

       cue line counts, ask and answered   1/2/3 lines: 57/195/26 -> 50/192/36
       cards whose cue got taller          17 of 278, all by exactly one line
       cards whose cue got shorter         0
       cue_clipped > 0                     s11 cue 3 at 15px  ->  s11 cue 3 at
                                           15px. The SAME single card, the same
                                           number. No card is newly cut.
       cue truncated (probe headline)      1 of 278 -> 1 of 278, worst 0.3 lines
       column overflows / stage clipped    0 -> 0
       last button vs nav, worst clearance -41px ask / -56px answered, unmoved

   The 17 cards are the whole cost and it is the same shape #1387's own 12
   were: a card that grows a line spends slack it already had (median 103.5px
   below the last answer button, against a 45px line box). The 17 are also how
   this run proves it SAW the change rather than reporting a comfortable zero
   from an instrument that missed it -- the headline row is identical before
   and after, and on its own it would have proved nothing.

   Counted separately, because the two halves of this branch pull opposite
   ways and only one of them costs anything: dropping `text-wrap:balance` (see
   `.cue` above) changes the line COUNT on 0 of the corpus's 306 cues. All 17
   are the narrower box. What the wrap change buys back, over the same 306
   cues, is +60.9px of median FIRST-line length -- more than twice the 24px
   the button took. */
.stage .cuerow { display:flex; justify-content:safe center; gap:0; min-height:0;
                 margin-inline:-24px; padding-inline-end:24px; }
/* `1 1 auto` -- fill the row. `min-width:0` for the usual reason a flex item
   that must be allowed to wrap needs it.

   This replaces `0 1 auto` ("shrink, but do not grow"), whose comment used to
   say the row was FREE because "a sentence with slack around it keeps its
   natural width and sits centred between the button and its balance". That
   was true and it is exactly what #1387 measured as the defect: the natural
   width is what made the button's position a function of the sentence's
   length. The row is still free -- the 48px of reclaimed `.stage` padding
   still pays for both flanks -- it just no longer moves.

   The five clauses this rule is one third of are stated once, as THE
   READING-EDGE CONTRACT, at `.cuereplay, .cue-balance` below. Read them
   there before changing this declaration: #1400 is a second card kind that
   has to satisfy the same contract, so it is written to be adopted rather
   than re-derived. */
.cuerow .cue { flex:1 1 auto; min-width:0; text-align:start; }
/* The balance box. An empty `<span aria-hidden>` the same width as the
   button, on the other side of the sentence, so the Hebrew stays centred on
   the COLUMN rather than being pushed 12px off it -- which #1300's first-line
   work would have read as the card moving. Cheaper and more robust than
   `padding-inline-start` on the cue, which would have had to be kept in sync
   with the button's own width by hand, and it is also half of what makes the
   row free: two equal flanks is what lets the reclaimed 48px of padding pay
   for both of them exactly.

   #1387 CHANGED WHAT THIS BOX IS FOR, and the declaration is identical, so
   say it here rather than leave the paragraph above reading as current. The
   cue is right-justified now and fills the row, so there is no centring left
   for a balance to preserve: what this 24px does today is hold the reading
   column's LEFT margin, opposite the `padding-inline-end` the row declares
   for the right one. It is kept as an element rather than folded into the
   row's `padding-inline-start` for one measured reason -- `#cue` is a flex
   item that must be free to shrink, and a padding on the row is not a thing
   the flex algorithm can hand back, whereas this flank and the button's are
   already the pair every rule and every guard in this block is written
   against. The NAME is now wrong and is deliberately not being changed:
   `study.html`, `study/rendering.js`'s `STAGE_HTML`,
   `scripts/study_layout_probe.py`'s own mirror and four JS tests all spell
   it, and a rename that has to land in an instrument this branch may not
   edit would be churn bought with drift.

   ---- THE READING-EDGE CONTRACT (#1387) -----------------------------------

   Stated here, on the rule the OTHER card kinds share, because `.cuereplay`
   is not this row's private class: `beats/jumble.js` builds its own
   `#jumbleReplay` carrying it, and #1400 is the owner asking for exactly
   this ticket's fix on that card -- "at the right of the line we are adding
   the items to. Just like a normal listening sentence." That ticket is
   meant to ADOPT what is below rather than re-derive it from a render, so
   it is written as five clauses about any Hebrew line with a replay button
   beside it, not as a description of `.cuerow`.

   A card satisfies the contract when all of them hold. They were measured on
   the served page at a true 393x852, both themes; the values in brackets are
   what `.cuerow` produces today and are the acceptance numbers. #1387 wrote
   five; #1424 amended clause 3 (see its own note, which keeps the superseded
   number) and appended clause 6.

     1. THE TEXT FILLS ITS ROW. Its inline size must not depend on how long
        the sentence is. `.cuerow .cue { flex:1 1 auto }` above. This is the
        clause that actually fixes the bug: while the text shrink-wrapped,
        the button's x was a function of the sentence -- measured at 135.6px
        from the screen edge on a one-word cue and 1.0px on a cue that
        wrapped. [`#cue` 295px on every card in the corpus; it was 319px
        between #1387 and #1424 -- see clause 3 for what the 24px bought]
     2. THE TEXT ALIGNS TO `start`, not to `right` and not to `center`.
        `dir="rtl"` makes start the right edge, and it is the right edge
        because that is the reading edge of the script. Every line's edge is
        then the box's edge, whatever the wrap does. [`text-align:start`]
     3. THE BUTTON SITS BESIDE THAT EDGE, ACROSS A REAL GAP. The acceptance
        number is `replayButton.left - cueBox.right == 8.00px`, which lands
        ~10px of clear air between the button and the first character's ink
        (`#cue`'s own box runs ~2px past the last glyph).

        ---- amended 2026-08-31 by #1424; superseded value recorded, not
             deleted ------------------------------------------------------

        THIS CLAUSE SAID `== 0.00px`, GAP ZERO, from 2026-08-31 until the
        same evening. That zero was not arbitrary and must not be read as a
        mistake: it was REPRODUCED, not chosen -- it is the measured
        geometry of source 7 cue 36, the card the owner had himself named as
        already correct ("the icon to the right of the first word"), and
        #1387 made it hold by construction on every card in the corpus
        instead of by coincidence on one.

        It is superseded because he gave newer and more specific evidence
        than the control it was derived from: two photographs (#1424,
        `docs/decisions/assets/1424-replay-button/`), one of a reference
        layout he wants and one of this card as it shipped, with

            "the audio replay button size and the text going to the right
             look more correct ... I want it to feel more natural on the
             line like the first image, and not like a mistake in the
             second."

        The reference image has a clear gap. Measured off that photograph at
        its native 3x (iPhone 15, 1179px wide): the button is 120x120 device
        px = 40x40 CSS px, and its left edge stands 36 device px = 12 CSS px
        clear of the sentence's rightmost ink. 8px between the two BOXES is
        that 12px of ink-to-button, since `#cue`'s box carries ~2px of side
        bearing at each end.

        So: a photograph of a layout he wants beats a reading taken off one
        card he liked. If a later change re-derives zero from cue 36 again,
        this paragraph is why it should not.

        [+8.00px between boxes, ~10px of visible air, on every card]
     4. THE BUTTON'S OUTER EDGE SITS ON THE CONTAINER'S CONTENT EDGE, never
        on the screen edge. On this row that is `.stage`'s own 24px gutter,
        so the button ends where `#reveal` and `#english` end; on another
        card it is whatever that card's content edge already is. The test is
        that the control shares a margin with the card's other content
        rather than owning one of its own. [button's right edge on 368, 24px
        inside the column; unchanged by #1424 -- the button grew 24px->40px
        INWARD, away from the screen edge, so this clause's number is the one
        number in the contract that #1424 did not move]
     5. THE BUTTON'S BOX NEVER LEAVES THE LAYOUT. A line with no clip gets
        `visibility:hidden` (`.cuereplay.is-silent` below), never
        `display:none` and never a removed element: under clause 1 the text
        absorbs whatever the row does not spend, so a vanishing button moves
        the whole reading edge on the cards that lack audio. #1367 is the
        ticket that proved this the first time, when the mechanism it
        protected was centring; clause 1 makes it matter more, not less.
     6. THE BUTTON IS SQUARE, FILLED, AND READS AS PRESSABLE. ADDED by #1424
        -- clauses 1-5 are #1387's and are numbered as it left them. The
        owner, on the wireframe the same evening: "It should be considered
        like a character and should be a square shape. I want it to feel like
        a button not a flat piece of paper." This is not a decoration clause;
        it is what stops the control reading as furniture, which is the state
        he photographed ("dark grey, near-invisible against the ground").
        It is here rather than in `.cuerow` alone for the reason the whole
        block is here: #1400's card carries the same class and must take the
        same treatment without re-deriving it.
        Square is literally checkable (`width == height`); filled means a fill
        token of its own rather than `--surface`, which is the ground's near
        neighbour; pressable is the bevel #1373 and #1386 already argued for,
        which only became legible once the fill under it stopped being that
        neighbour. [40x40, `border-radius:12px`, `--control`/`--control-ink`,
        the #1386 bevel re-cut in white/black alphas so it reads on a
        saturated fill]

   Clauses 1, 2, 5 and the square half of 6 are guarded by
   `tests/js/test_study_cue_reading_edge.mjs` and
   `tests/test_study_replay_button.py`; 3 and 4 are pixel claims and are
   settled by rendering the page, not in jsdom (that file's header says why,
   and `tests/js/harness.mjs` injects no CSS at all) -- though since #1424
   clause 3's gap is a DECLARED length rather than an emergent zero, so the
   shape of it (a non-zero inline-start margin on the button, inside the row)
   is guarded too, and only its pixel value needs a browser. A second card
   kind adopting this should add its own rows to that guard rather than
   starting a second file.

   NOT part of the contract, deliberately: `.cue-balance`'s 24px below. That
   is what `.stage`'s reclaimed padding happens to pay for on THIS row --
   another card's left margin is whatever its own gutter is worth, and clause
   4 is about sharing the card's margin, not about copying this number.
   `.jumble-replay`'s two existing overrides in `beats/jumble.css`
   (`flex:0 0 auto`, `align-self:center`) are corrections for a COLUMN rather
   than a row; #1424 leaves both standing and only re-words their comment,
   because with clause 6's explicit `width`/`height` the button is square in
   a column too, which it was not before (`flex:0 0 auto` there gave it a
   shrink-to-fit 17px width against a 36px height). */

/* #1424 SPLIT THIS RULE. It was `.cuereplay, .cue-balance { flex:0 0 24px }`
   -- one declaration for both flanks, and `tests/test_study_replay_button.py`
   had a test named for their sharing it. They no longer can: clause 6 makes
   the button 40px and clause 3 puts 8px of air beside it, while the left
   flank's job (clause 4's "the card's own gutter", i.e. `.stage`'s 24px, so
   the Hebrew never out-dents `#reveal`/`#english` below it) is unchanged and
   still 24px. The pair was never a symmetry worth keeping for its own sake --
   #1387 had already recorded that this box stopped being a "balance" and
   became a left margin -- so what is lost here is a coincidence of value, not
   an invariant. */
.cue-balance { flex:0 0 24px; }
/* The two things that are true of this button IN THE CUE ROW and of no other
   copy of it -- scoped to `.cuerow` so `beats/jumble.js`'s `#jumbleReplay`,
   which is the same class in a centred COLUMN, is untouched by construction
   rather than by an override that has to be remembered.

   THE GAP (clause 3, amended). 8px between the button's box and `#cue`'s.
   `#cue` carries about 2px of side bearing past its last glyph, so this
   reads as ~10px of air, against the 12px measured off his reference
   photograph. It is a margin on the BUTTON rather than a `gap` on the row
   because the row's other joint -- `.cue-balance` to `#cue` -- must stay
   flush: that flank is a left margin, and a row-level `gap` would silently
   add 8px to it and move the Hebrew's left edge as well.

   THE VERTICAL CENTRING. His words for the shipped card were "not like a
   mistake"; the specific mistake is that the button "sits high, crowding the
   top of the first word" (#1424's own table). `align-self:flex-start` pins it
   to the top of the row, which was nearly right while the box was 36px in a
   45px line -- 4.5px out. It is 2.5px out at 40px, and the fix is the same
   arithmetic either way: half of whatever the first line box has left over.

   The line box is `.cue`'s `line-height:1.5` on `calc(30px * scale)` = 45px *
   scale, and that token is set on the root by `shared/nav.js`, so it is
   readable from here. `max(0px, ...)` because the slider's low end can make
   the line box shorter than the button, and a negative margin would lift the
   control clear out of the row -- at which point it is no longer "on the
   line" in either direction. `align-self:flex-start` above is what this
   margin is measured from and stays. */
.cuerow .cuereplay { margin-inline-start:8px;
                     margin-block-start:max(0px,
                       calc((45px * var(--milim-text-scale) - 40px) / 2)); }
/* #3046: the replay button stays on the Hebrew line's row, always. #2947 let a
   sentence that fit the column but not the column minus the button keep its one
   line and dropped the button BELOW it (`.is-stacked`, with a padded cue and an
   absolutely-placed button). The owner, 2026-10-04, looking at that card: "I want
   it to fit on one line for that line -- but if it runs onto two that is fine.
   ... there are two bugs on the image. The replay button on the wrong line and
   the thin line." So a sentence that does not fit beside the button WRAPS, onto
   two rows, and the button keeps its place at the end of the first. Nothing else
   is needed: the button is a flex item of `.cuerow` at the end of the row,
   `align-self:flex-start`, and `.cuerow .cuereplay`'s margin above centres it on
   the first line.

   THE THIN GREY RULE is #1124's scroll-hint band (`.stage.showing .cue`'s edge
   gradients), painted where nothing is cut. Two ways, both measured in headless
   Chrome (docs/demos/2026-10-04-cue-row-3046/):
     1. A stacked cue carried 44px of padding under the sentence for the button to
        sit in, and that padding is part of the scrolling box. The cue's bottom edge
        was then the bottom of the button, so any overflow past the covers' 8px
        margin (#2714) exposed the band 18px above that edge: level with the
        button's middle, from the card's left edge to the button. Forcing a stacked
        cue 12px short reproduces his photo's geometry exactly. The padding is gone
        with the stacking.
     2. A cue whose box is not a whole number of pixels (30.19px type at 390 wide,
        #3029's text size, makes a line 45.285px and a two-line cue 90.57px) leaves
        the bottom band's LAST device row peeking out below the cover, strongest
        alpha, as a hairline the width of the cue -- on every wrapped cue at 390,
        with nothing cut and no scrolling at all. #2714 covered an overflow of a few
        px and not this. Wrapping is now the rule, so two-line cues are common and
        this had to go: the bottom band is seated 1px up (below), where the cover
        always reaches. A real truncation still shows the whole band.
   The band itself stays: it is #1124's deliberate "there is more of this sentence"
   signal. */
/* "Draw thin, hit fat" again (#127) -- and since #1424 the box is no longer
   thin: a 40x40 box around a 21px glyph, which is the same `.railicon` the
   top rail's three controls draw, so this reads as one more control in the
   same family rather than a new kind of thing.

   #1373, the owner overruling the sentence this comment used to end on
   ("quiet at rest because it is an offer, not an instruction"): "it should
   ... needs a bevelled box around it to feel like a button."

   #1424, the owner again, with a photograph of each: "I want it to feel more
   natural on the line like the first image, and not like a mistake in the
   second." The three things that changed, all of them read off his reference
   image rather than chosen (see clause 6 and clause 3's amendment above):

     SQUARE, AND BIGGER. 24x36 -> 40x40. `width` and `height` are both
       declared, so the control is square in a COLUMN too -- `beats/jumble.js`
       builds one inside `.beat`, where `flex:0 0 auto` used to leave it
       17px wide against a 36px height. His reference measures 120x120 device
       px at 3x = 40x40 CSS px, so 40 is his number.
     FILLED. `--surface` -> `--control`. `--surface` (#181B21) against
       `--ground` (#0E1013) is 1.15:1: a box you cannot see is not a box, and
       his word for it was "a flat piece of paper". `--control` is 9.1:1. See
       the token's own comment at the top of this file for where the value
       came from and why the light theme takes a different one.
     ROUNDED FURTHER. 8px -> 12px, 0.30 of the box, which is the ratio his
       reference's corner measures (full width reached 34 device px in, on a
       120px box).

   The glyph no longer draws `--muted`: on a saturated fill a grey glyph is
   the one thing the fill was changed to stop happening. `--control-ink` is
   the knocked-out glyph his reference draws, in the page's own ground. */
.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;
             /* The bevel: a lighter inset edge on top (`--raise`, the same
                token `.opt`'s own pressed/hover state reads, so this control
                borrows a shading the page already uses rather than a new
                one) and a darker inset edge on the bottom, which is what
                reads as a slightly raised, pressable box rather than a flat
                tinted square. `box-shadow` costs no layout at all -- unlike
                a `border`, which would need `box-sizing:border-box` just to
                keep this flex item's flex-basis at its declared width, this
                paints entirely inside the box the flex algorithm already
                sized, so the icon still centres in the same box it always
                has (24px when this was written, 40px since #1424; see the
                `.railicon` comment below, which #1424 rewrote for the same
                reason). No new border-radius arithmetic either: 8px is the
                radius `.cuereplay:focus-visible` below already draws its
                own outline at, so the bevel and the focus ring agree on the
                shape without restating it.

                #1386, the owner: "a bit of a line stroke on its bottom and
                right edge to give it some depth" -- a third inset line,
                same darkness and width as the existing bottom edge, added
                on the right. Left and top stay untouched (the `--raise`
                line already owns top), which is what reads as light from
                the upper-left rather than a stroke on all four sides. Still
                `box-shadow`, still zero layout cost, for the identical
                reason the bevel above already gives -- `#cue` measured
                identical before and after (see the PR body). No light-theme
                override: this rgba(0,0,0,...) literal is the SAME choice
                the pre-existing bottom edge already made with no override
                of its own, and it reads fine against both `--surface`
                values (checked in-browser, both themes).

                #1424 RE-CUT THE TOP LINE AND KEPT EVERYTHING ELSE. The
                highlight was `var(--raise)` -- #20242C dark, a grey chosen
                to be one step lighter than `--surface`. Painted on top of
                `--control` it is a step DARKER than the fill, so the light
                edge became a dark smudge and the bevel read upside down.
                It is a white alpha now, which is what "light from the upper
                left" means on a fill of any hue and is why this line no
                longer needs a token that tracks the surface it sits on. The
                bottom and right lines keep #1386's rgba(0,0,0,.28) exactly;
                the drop shadow is deepened .12 -> .22 because a 40px box
                casts a shadow a 24px one could not carry without looking
                smudged. Still `box-shadow` only, still zero layout cost --
                the reason above is unchanged, and the radius the bevel and
                the focus ring agree on is 12px now rather than 8px. */
             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) -- and #1424 is the ticket that made the drawn
   box fat enough that there is barely anything left to reach for. The visible
   box is 40x40 now, so 2px of overhang on each side is the whole of what this
   pseudo-element still has to do to clear the 44x44 thumb target.

   It used to reach 10px sideways, over `.stage`'s own edge on one side and
   over the first line's LEADING EDGE on the other -- which was safe only
   while the two boxes abutted and there was nothing between them. Clause 3
   now puts 8px of deliberate air there, and a hit box that ate 10px of it
   would make the gap the owner asked for a lie under the thumb: a tap two
   pixels off the last letter would replay the audio. -2px keeps every press
   inside the air, and the target is still 44x44.

   Hits on a pseudo-element are attributed to its own element, so
   `closest(".cuereplay")` in drift_loop.js still matches. */
.cuereplay::after { content:""; position:absolute; inset:-2px; }
/* The glyph, sized to the BUTTON rather than to the rail, and the constraint
   that used to decide it is gone rather than overruled.

   This rule read 17px, as a rendered-page correction: the flank was exactly
   the 24px of `.stage` padding the row reclaims, so a 21px icon centred in it
   landed 1.5px from the screen edge and read as clipped, and 17px bought
   3.5px of air on each side. Its own comment ended "deliberately NOT solved
   by widening the flank: that costs the sentence width". #1424 widened the
   flank -- for a different reason, and having paid exactly the cost that
   sentence names (see clause 3's amendment) -- so the 24px that forced 17px
   no longer exists.

   26px is measured off the reference photograph rather than inherited from
   `.railicon`, and this is the one place #1424 stops treating this control as
   "one more rail icon": it is a filled button now, and a glyph sized for a
   bare 24px flank reads as a small icon adrift in a big square. On his
   reference the glyph's INK is 19.3 of the button's 40 CSS px -- 0.48. At
   21px this icon's ink measured 13.7px on the rendered page (0.35 of the
   box), which is visibly under it; the ink is 0.65 of the declared size for
   this artwork (it is an outline speaker with one arc, lighter than the
   reference's filled one), so 0.48 wants ~29.6px and 26px lands at 0.42 --
   short of the reference on purpose, because closing the last of that gap
   means an icon 74% as wide as its own button, which stops reading as this
   app's line-drawn icon family at all. Read on the page in both themes, not
   computed and shipped. */
.cuereplay .railicon { width:26px; height:26px; }
/* Pressed -- the bevel inverted, the same shorthand `.advance:active` below
   uses for the same reason (`:active` ends itself the instant the finger
   lifts or the gesture is stolen, no class to leave stuck on).

   #1424: `color:var(--accent)` and `background-color:var(--raise)` are both
   gone. The first was a yellow glyph, which on `--control` measures 1.2:1 --
   the pressed state would have been the one state you cannot see the icon in.
   The second was a grey, which would have flashed the button back to the
   colourless box this ticket exists to get rid of. `filter:brightness` darkens
   whatever the fill happens to be, in either theme, with no second token to
   keep in sync and no layout cost (a filter is paint; it establishes a
   containing block for fixed/absolute descendants, and the only descendants
   here are the glyph and the `::after` hit box, both already positioned
   against this element). */
.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: "Is it possible for it to animate while
   audio is playing? ... a sheen or a wave of colour that stops when the
   audio stops." `audio.js`'s `ensureAudioPlayer`/`onplaying`/`setReplayAnimating`
   are the ONLY things that add or remove this class -- on `playing`, and off
   on `ended`, `pause` AND `error` (see that file's own comment for why all
   three, and for the dead-man-switch this is deliberately not: #625/#1348).
   Nothing here is a timer or a duration guess; the class is exactly as long
   as the browser itself says sound is coming out.

   `--accent`, not one of the soft tokens: this is the one moment on this
   control the page's single attention colour is earned -- sound is audibly
   playing, which is a stronger claim than the quiet hover states elsewhere
   on this row. Continuous, not one-shot, which is the one respect this does
   NOT copy from `word_match.css`'s own sheen (`tests/test_beat_word_match_sheen.py`)
   -- that one sweeps once on a settled match; this one loops for as long as
   the state holds, which is `.hooray`'s own shape (below, in this file) and
   the one actually copied: a `background-position` sweep, `animation-fill-
   mode` left at its default `none` so removing the class removes every
   trace of the last frame instantly, and a `prefers-reduced-motion` block
   that parks the sweep rather than merely slowing it -- see that block for
   why "flatten the duration" is not enough on its own.

   `background-image`/`-size`/`-position` only, never `background-color` or
   the box-shadow bevel above: #1300 is explicit that nothing here may cost
   layout, and this list is also every property `.hooray`'s own comment
   argues for keeping the ANIMATED half of a rule to, for the unrelated
   reason of not letting the resting colour drift under a flattened frame.
   Both reasons point at the same short list.

   `--replay-sheen`, a local literal rather than `var(--accent-soft)`: the
   same call `.match`'s own `--match-sheen` makes and the same reason -- CSS
   cannot re-alpha an existing token, so a translucent sibling of a colour is
   always a second declaration, and this one's alpha is tuned for a band
   with no text under it (see the light override below), which is not what
   `--accent-soft` was tuned for.

   #1385, the owner: "I bet it would look right jumping up and down" --
   endorsing the animation itself and asking only for the axis to flip.
   `100deg` (near-horizontal, tilted 10deg past `to right`) becomes `190deg`
   (near-vertical, tilted the same 10deg past `to bottom`, so the tilt
   direction stays consistent rather than mirroring). The box was 24px wide
   and 36px TALL when #1385 flipped the axis (`.cuereplay`'s own `height`
   above; it is 40x40 since #1424, so the two axes are the same length now
   and the flip is a pure direction change), so the travel axis was not just
   flipped but a different length -- `background-size`
   swaps from `220% 100%` to `100% 220%` for the same reason (the tile
   widens 1.2x past the box on whichever axis the band travels, so the
   sweep has somewhere to go and the seam at the wrap stays past the visible
   edge), and the keyframes and the reduced-motion still frame below move
   their percentage from the first (x) to the second (y) `background-
   position` component. The 34/50/66 soft-stop split is kept as-is: it is a
   fraction of the gradient LINE length, not a literal pixel width, and a
   36px line reads the same proportionally as a 24px one did -- checked
   in-browser rather than assumed (see the PR body for the mid-play
   screenshot this produced). */
/* #1424 changed the BAND'S COLOUR and dropped the `color` override; the
   geometry, the loop, the fill-mode and the reduced-motion answer are all
   #1373/#1385/#1386's and are untouched.

   `color:var(--accent)` had to go. The comment above argues for it well ("the
   one moment on this control the page's single attention colour is earned"),
   and the argument is still good -- it just cannot survive the fill this
   button now has: light `--accent` gold on `--control` measures 1.2:1 dark,
   so "sound is playing" would have been the one state in which the glyph is
   invisible. The glyph now stays `--control-ink` throughout and the band
   alone carries the state, which is what the owner asked for in #1373 anyway
   ("see the box animate").

   The band is a WHITE alpha in both themes now, and that is why the light
   override that used to sit here is gone rather than retuned. It existed
   because a gold band composited very differently over near-white than over
   near-black -- the same asymmetry `.match`'s `--match-sheen` records. That
   asymmetry was a property of the GROUND showing through a tinted control;
   the band travels over `--control` in both themes now, and both values of
   `--control` are saturated mid-to-light blues, so one alpha reads the same
   on both (checked in the render, both themes, mid-sweep). White also cannot
   hide the glyph the way an opaque tint could: `--control-ink` is the darkest
   thing in the box either way, so the band passes behind it rather than over
   it. Still no contrast floor applies -- there is no text under this band,
   only the icon.

   The box is 40x40 now rather than 24x36, so the 190deg near-vertical sweep
   travels the same distance it does across, which it did not before; the
   `100% 220%` size and the 34/50/66 soft-stop split are unchanged and still
   proportional, and the seam still wraps past the visible edge. */
.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; }
/* One tile, 0% -> 220%, seamless for the same reason `hoorayWave` below is:
   at `background-size:220%` the image is 1.2 box-widths wider than its box,
   so a position swing of 220% walks it exactly `220% - box`... see
   `hoorayWave`'s own comment for the arithmetic this restates -- both ends
   of the gradient are `transparent`, which is what makes THIS seam
   invisible without having to match the destination-box math exactly the
   way a hard-edged image would. `repeat`, not `no-repeat`: unlike
   `.cuereplay::after`'s hit box or `word_match`'s single-pass sweep, this
   loops, and a repeating tile is what keeps the band crossing the box on
   every cycle rather than sweeping once and leaving nothing behind for the
   rest of the 1.1s. */
@keyframes cuereplaySheen {
  from { background-position:0 0%; }
  to   { background-position:0 220%; }
}
/* This file's blanket `* { animation-duration:.01ms !important }` rule
   (top of file) is not enough on its own for a LOOPING animation, for the
   same reason `.hooray`'s own reduced-motion block below gives: flattening
   the duration leaves the animation running, one frame long, and which
   frame that is is `animation-fill-mode`'s call, not stated here -- so the
   fallback is stated instead. `animation:none` stops it outright and the
   base rule's own colour and background-position are restated so the still
   state does not depend on wherever a flattened keyframe happened to leave
   it: `--accent` and a band parked mid-travel, the same "still, but not
   colourless" answer `.hooray`'s own block gives for the same reason. */
@media (prefers-reduced-motion: reduce) {
  /* #1424: the restated colour is `--control-ink`, not `--accent`, for the
     reason the base rule's own comment now gives (a gold glyph on `--control`
     is 1.2:1). Which means the thing that says "sound is playing" in this
     still state is the PARKED BAND, not the glyph -- 0 44% is a band caught
     mid-travel, visibly across the button, and it is the whole of the claim
     here. The colour line stays because that was always its other job: to
     stop the glyph inheriting whatever hue a flattened keyframe left behind,
     rather than to make the claim itself. */
  .cuereplay.playing { animation:none; color:var(--control-ink);
                       background-position:0 44%; }
}

/* The study card's own continue button (#1348 requirements 3, 5 and 6) used
   to have a rule here, `.continue`, of its own -- a class introduced BESIDE
   the `.advance` shape #706 had already unified every beat's forward control
   under, rather than joining it. #1372 is the owner naming that drift ("it
   has three states... make them all the same continue with the rounded
   border") and closing it: `continueBtn` now ships `class="advance"` (see
   `STAGE_HTML` below and study.html's own pre-script frame, which must agree
   -- `tests/js/test_study_selection.mjs` checks both), and every requirement
   this comment used to carry lives on `.advance`'s own comment now, further
   down this file at the "#706/#1372: one shape" section. There is no
   `.continue` rule left anywhere in this file to find. */
/* #1367: turning audio off (or a cue with no clip at all) used to hide
   `.cuereplay` with a `[hidden]` attribute, which the UA stylesheet turns
   into `display:none` -- OUT of the flex row, taking its 24px flank with it.
   `.cue-balance` opposite it has no matching `[hidden]` rule (it is never
   hidden, on purpose, per its own comment above) and stayed, so the row went
   from two 24px flanks to one and the sentence between them slid 24px toward
   the remaining side and got 24px wider -- confirmed live: `#cue` measured
   343px with both flanks, 367px with only `.cue-balance` left.

   Both fixes the ticket itself named -- hide `.cue-balance` to match, or
   hide both flanks and let `.cue` recentre on the full column -- turned out
   not to hold the ACTUAL invariant #1367 closes on ("`#cue`'s width and
   offset identical with audio on and off"): `.cue` is `flex:0 1 auto`, so it
   only ever gives up width it does not have room for, and removing flanks
   (either one or both) hands a WIDE sentence room it would not have had with
   both flanks present -- a corpus sentence long enough to wrap at 343px can
   fit in fewer lines at 367px or 391px, which is a different `#cue` box, not
   a centred one. Symmetric-but-different is still different, and the
   closing condition is IDENTICAL, not merely centred.

   So neither flank is ever removed from the row at all, in any state -- and
   the mechanism that holds `.cuereplay`'s own flex item open is
   `visibility:hidden`, which does everything `hidden` is actually FOR (not
   painted, not hit-testable -- `.cuereplay::after`'s widened hit box goes
   with it -- not in the tab order, not exposed to assistive tech) while
   leaving the button's own box in the layout (24px until #1424, 40px since;
   the mechanism does not depend on the number).

   This is `.is-silent`, a state CLASS, not `.cuereplay[hidden]` -- a first
   version shipped it as `[hidden]` with `display:flex` restated to beat the
   UA rule, and `tests/js/test_hidden_display_contract.mjs` (#866) correctly
   failed it: that guard's whole point is that an element carrying a literal
   `hidden` attribute must compute `display:none`, because `hidden` means
   "not relevant" and an element that still paints and is still tappable
   under that attribute is exactly the bug #866 exists to catch. Widening
   that guard to except this one selector would have been fixing the
   symptom and keeping the semantic lie: the button IS relevant while
   silent -- it is holding the flank open -- so `hidden` was never the right
   word for this state, independent of the CSS. `.is-silent` says what is
   actually true and carries none of `hidden`'s "not relevant" claim, so
   #866's guard does not apply to it at all -- nothing here is exempted,
   because there is no `hidden` attribute left to exempt.

   `.cue-balance` needed no change at all: it was never toggled, and now
   neither, functionally, is `.cuereplay` -- both flanks are permanent
   citizens of the row (24px and, since #1424, 40px -- they stopped being
   equal, which nothing here ever depended on) and `#cue` never sees a
   different amount of room in any combination of audio-on/off and
   clip-exists/not, which is the fix in its strongest form rather than either
   one the ticket offered.

   ---- #1387: THE DECISION, taken deliberately rather than left as-is ------

   #1387 asked for this explicitly, because everything above argues from a
   centring this row no longer does: "a decision recorded for what
   `.cuereplay.is-silent` should now do, since its reason for existing was
   the centring."

   IT STAYS `visibility:hidden`, and it is MORE load-bearing than before,
   not less. The reason changes completely:

     was  -- hold the flank open so `#cue` stays CENTRED between two equal
             flanks; drop either one and the sentence slides 12px or 24px
             sideways.
     now  -- hold the flank open so the reading column's RIGHT EDGE stays
             where it is. `.cuerow .cue` is `flex:1 1 auto` now, so it
             absorbs whatever the row does not spend. `display:none` on this
             button would hand the cue that 24px, and because the cue fills
             the row the whole right-justified edge -- every line of the
             Hebrew, on a card that has no clip -- would step 24px to the
             right, straight into the margin this ticket exists to create,
             while the card beside it in the same sitting did not. That is
             #1239's "the eye jumps around" defect, arriving by the exact
             door #1367 already closed once.

   So the alternative that a right-justified layout might have licensed --
   "the button is at a fixed x now, so a silent card can simply drop it" --
   is REJECTED, and it is rejected on the geometry rather than on
   conservatism. `#1367`'s closing condition ("`#cue`'s width and offset
   identical with audio on and off") is unchanged and still the right one;
   this rule is still the whole of how it is met.

   `opacity:0` was considered as a same-geometry equivalent and declined for
   the reason two paragraphs up already gives: `visibility:hidden` takes the
   button out of hit-testing, out of the tab order and out of the
   accessibility tree, and `tests/js/test_study_audio.mjs`'s "a click on the
   button while `.is-silent` does not reach replayCue" proves that rather
   than asserting it. `opacity:0` would leave a tappable, focusable,
   announced control on a cue with nothing to play. */
.cuereplay.is-silent { visibility:hidden; }

.stage.error .cue { color:var(--no); font-family:var(--serif); font-size:17px;
                     font-weight:400; }

/* #1475: the loading state leaves by fading, never by vanishing between two
   frames. The owner's words: *"if one has to show id rather it be on screen
   for a moment and see it fade away the loading than a single frame flash."*

   Only the transition and the end state live here -- WHEN the state may be
   painted at all, and how long it must stay before this fade starts, are
   timing decisions and they live in study/rendering.js (`LOADING_DELAY_MS`,
   `LOADING_FLOOR_MS`, `LOADING_FADE_MS`). `.18s` is `LOADING_FADE_MS`, and
   tests/js/test_study_loading_indicator.mjs asserts the two are the same
   number, on the precedent #1411 set for `WRONG_FLOOR_MS` and its ring.

   `.is-loading` carries the transition rather than `.cue` itself so that
   nothing outside a wait pays for it -- `#cue` is rebuilt and rewritten on
   every card, and an opacity transition sitting on it permanently would be a
   fade on content that is meant to arrive already there. Both classes are on
   `#stage` and both are dropped by `show()`'s outright `className` assignment
   (#96's `swap` frame), so the incoming cue is never caught mid-fade.

   `prefers-reduced-motion` needs no rule here and deliberately has none: the
   JS never adds `.is-loading-out` under that preference, so the state simply
   ends when its floor is served. Flattening the transition instead (which
   the blanket rule at the bottom of this file would do anyway) would leave
   the stage empty for the length of the fade, which is the same defect this
   ticket is about pointed the other way. The FLOOR is still served under
   reduced motion -- that is legibility, not motion. */
.stage.is-loading .cue { transition:opacity .18s ease; }
.stage.is-loading-out .cue { opacity:0; }

/* #96: suppresses every transition inside the stage for exactly the frame
   `show()` resets to unlit before painting the new cue -- otherwise a fresh
   `.tgt` span or newly-written `.gloss`/`.english` text inherits the outgoing
   cue's lit state and flashes before fading. `show()` forces a reflow before
   removing this class, so the 42%-of-dwell fade still runs afterward. Named
   `swap` so this rule and the JS class name cannot drift apart. */
.stage.swap, .stage.swap * { transition:none; }

/* ---- ask: four meanings, two rows of two, in thumb reach (#1220) ----

   The owner, 2026-08-25: "Can they be two rows of two? Instead of four rows
   of one? This shortens the area for the answers even more." Measured then
   (docs/decisions/2026-08-25-two-by-two.html) and again just before this
   shipped (see this ticket's PR body for the re-derived table on current
   `main`, ~335 cards, not the stale 268-card figure quoted on the ticket):
   the option column IS shorter -- wrapping into half the width still nets
   fewer total rows than four full-width one-line rows, even at the p90/max
   option lengths (46/73 characters).

   #1220 IS REVERSED, 2026-09-06, and this block is left standing because the
   reasoning above is still correct -- it was correct about the wrong thing.

   He asked for four rows of one on 2026-08-25 ("The first one with the four
   rows of one is the better look") and was overridden by the truncation
   number: 4x1 cut a Hebrew sentence on 47 of 268 cards, 2x2 on 9. That
   measurement was honest and the trade was real AT THE TIME.

   What changed is #1728, the same night. The Continue pill stopped being
   pinned to the chrome and joined this grid, which closed a median 145px of
   dead air. The 2x2 had been buying vertical space that the column now has
   for free. Re-measured on the same 335-card fixture, on the build with the
   pill in flow:

       four rows of one   ask 0 of 335 cut   answered 9 of 335, worst 1.2 lines
       two-by-two         ask 0 of 335 cut   answered 0 of 335

   So the cost of his preference fell from 47/268 (17.5%) to 9/335 (2.7%),
   inside the defect rate he has stated he accepts, and ALL NINE are in the
   ANSWERED state -- after the pill appears and adds 64px. While he is
   reading the cue and choosing, nothing is cut at either shape.

   And his own report is what reopened it, with a cause nobody had named:
   "Its really hard to read as the box sizes change all over the place and I
   thought it was just the dictionary doing weird things.. but its the odd
   shapes and lengths to fit into the small box." Half-width boxes make a
   gloss wrap at unpredictable places, so four answers have four different
   silhouettes; he had been reading that as bad dictionary text. The
   truncation number could never have seen that -- it counts cut sentences,
   not how hard four options are to compare.

   `grid-template-columns:1fr` therefore puts the four `.opt` buttons in four
   full-width rows, DOM order unchanged, no JS edit (`rendering.js` still
   just appends four `.opt` buttons into `#ask`). `gap:8px` is now only a row
   gap; there is no column gutter because there is one column.

   `align-items:start` was kept here and reasoned to be inert: with one
   column there is no NEIGHBOUR for a row to match, so the stretch-vs-start
   question #1220 flagged could not arise, and #1770's "the boxes should be
   the same height" was read as answered by the four-row reversal itself, not
   by stretch. That reading did not survive his worksheet answer -- see the
   block below, where `align-items:start` is gone and the four rows ARE made
   to match, on a different axis than #1220 ever raised. */
/* #1770, owner's worksheet answer: `equal-everywhere`. Four full-width rows
   fixed the RAGGED-SHAPE complaint above (no gloss wraps into a neighbour's
   column any more) but not the one underneath it: each row still sized to
   its OWN content, so a short gloss sat in a short box and a long one in a
   tall box -- "the box sizes change all over the place ... its the odd
   shapes and lengths to fit into the small box", the same report, read past
   the two-column shape that used to be the more visible half of it.

   `grid-template-rows:auto repeat(var(--ask-opt-count, 4), 1fr) auto`
   replaces the implicit-row default (`auto` on every row via
   `grid-auto-rows`, which sizes each row to only its own content and carries
   no equalising guarantee -- see `.pic-options`'s own comment on this exact
   point, `beats/picture.css`). Naming a `1fr` track per option lets the grid
   sizing algorithm equalise them to the tallest with no definite container
   height needed (spec 11.7.1, the same citation `.pic-options`'s
   `repeat(2, 1fr)` uses for the identical trick).
   The leading and trailing `auto` tracks are `.asktarget` and (once judged)
   `.advance` -- each the only item in its own row, so `auto` still sizes to
   exactly its own content, unchanged from before this ticket.

   Removing `align-items:start` (default is `stretch`) is the other half: a
   `1fr` track can now be TALLER than a given option's own content, and
   without stretch the shorter button would sit small inside a tall cell
   rather than filling it -- the owner's own words on the follow-up ticket,
   "only the shorter button's own silhouette changes, filling space that was
   already reserved". `.asktarget` and `.advance` stretching too changes
   nothing visible: each is still the only occupant of an `auto` row sized to
   its own content, so 100% of that row IS its natural height.

   `--ask-opt-count` is why this could not stay pure CSS: the option count
   varies (2-4, a thin distractor pool), and `.pic-options`' fixed
   `repeat(2, 1fr)` cannot be copied verbatim -- a bare `repeat(4, 1fr)` here
   would reserve four flexible rows even on a 2-option card, and an EMPTY
   flexible track is not free: the used flex fraction is one number shared by
   every `1fr` track in the axis, populated or not, so two blank rows the
   height of the tallest option would open up under a 2-option card. Setting
   this custom property to `exercise.options.length` in `startAsk()`
   (`study/rendering.js`) before the buttons are appended is the one line of
   JS this ticket needed -- not a DOM restructure, and the `4` fallback only
   guards a page load that reads this rule before any card has run once.
   `.advance` does not exist yet on an unanswered card; an unclaimed trailing
   `auto` track costs nothing, and `.verdict` (also appended before it) is
   `position:absolute` -- not a grid item at all, so it never competes for
   that row either. */
/* #2714: `gap` 8px -> 4px and the bottom padding 8px -> 4px. This grid is
   below the stage on every ask card, so every pixel it stops spending on
   its own rows is a pixel `.stage` -- and, first, `#cue` -- gets back
   instead (the column's total height is fixed; `.ask` and `.stage` split
   what is left after `.rail`/`.unit`/`.pace`, and `.stage` is the side that
   is already compressed on the crushed cards this ticket is about). Six
   rows (target + up to 4 options + the answered advance row) at 8px cost
   40px of pure gap; at 4px that is 20px -- still a real, visible gap
   between every option, checked by screenshot against a 4-option card in
   both themes at both viewports. */
.ask { display:grid; grid-template-columns:1fr;
       grid-template-rows:auto repeat(var(--ask-opt-count, 4), 1fr) auto;
       gap:4px; padding:0 16px 4px; }
.ask:empty { padding:0; }
/* #156: the question card names the word it is asking about, not only the
   drifting sentence above it. `.he` is the same class the reveal uses for
   Hebrew set inside otherwise-English UI (`.gloss .he`); this is its own rule
   because a caption's micro-sizing is wrong for a word meant to be read, not
   skimmed.
   #627 removed the small uppercase caption that used to sit above this, and
   #626 removed the countdown ring beside it -- the owner's call: four options
   under the lit word already say what is being asked, and the ring read as
   distraction rather than help. This is what is left announcing the ask
   now.
   #1570, the owner's own report: this word sat hard left on an otherwise-
   English LTR page, the whole column empty beside it, and he named the
   fix -- "direction/text-align:right on that element", not a float and not
   a change to `.ask`'s own layout. Both properties on THIS node, `.asktarget`
   itself, not the inner `.he` span -- matching `docs/demos/2026-09-03-1403-
   excluded-words/cards.html`, the #1403 mock he pointed back at by name
   ("it showed the hebrew word being asked about on the right side"), whose
   own `.asktarget` rule carries `direction:rtl` and `text-align:right`
   together on the block. `direction:rtl` is what keeps a target that ever
   wraps to a second line ragged-LEFT -- the correct RTL shape -- rather than
   ragged-right, which `text-align:right` alone on an LTR-direction block
   would give it; `text-align:right` is what actually moves the one-line case
   off the left edge, since `direction` alone does not change alignment.
   `.asktarget` never carried the `≈` relaxed-POS marker beside the word --
   see #1906's tombstone in rendering.js for why that marker (and the
   `.approx-tip` rule that used to sit below in this file) is gone rather
   than merely unused here. */
/* #1220: `.ask` is a 2-column grid now, and every OTHER child of it
   (`.opt` x4) is meant to sit in one of those two columns -- this one is not.
   Without `grid-column`, an implicit-grid item this page never fixed a
   position for lands in whichever cell comes next in DOM order, which for
   this node (`#ask`'s first child) is column 1 of row 1, beside the FIRST
   option button rather than above all four. `1 / -1` spans both column
   tracks, same as it always occupied the row's whole width under the old
   flex column. */
.asktarget { grid-column:1 / -1; margin:0 2px 8px; direction:rtl; text-align:right; }
.asktarget .he { font-family:var(--sans-he); font-size:calc(var(--fs-target) * var(--milim-text-scale)); font-weight:700;
                  color:var(--accent); }
/* MOVED to `shared/beat_card.css` (#1568): the `.opt` option button and its states. This page still loads them -- study.html links that sheet before this one. */

/* #1906: the one-time `≈` tip (#315) and its `.approx-tip`/`.approx-tip
   .approx` rules that used to sit here are GONE, not hidden -- see the
   tombstone at the top of app/static/study/rendering.js for the invariant
   this satisfies and the measured population (0 of 638 CONTENT_POS keys
   resolve `exact=False`, 2026-09-07) that made removal safe. */

/* MOVED to `shared/beat_card.css` (#1568): `.rise` and its keyframes. This page still loads them -- study.html links that sheet before this one. */

/* ---- verdict + footer ---- */
/* #1210: the owner's own worksheet answer, verbatim -- "Remove the word
   'right' or whatever is said when its incorrect. It will save a few
   pixels." Read narrowly: he asked to remove the WORD, not the node --
   `judge()` (rendering.js) still creates this element with its `yes`/`no`
   class and "Right"/"Not this one" text unchanged, both because the correct/
   wrong SIGNAL survives on the tapped option's own `.correct`/`.wrong` class
   regardless, and because that text is very likely how a screen reader
   announces the result of an answer -- #715/#716 already ruled there is no
   furniture exemption on this page, so silently dropping an announcement
   would be a regression dressed as a saving. Standard visually-hidden-but-
   not-aria-hidden clip: `position:absolute` with a 1x1px clipped box keeps
   the text reachable to assistive tech (unlike `display:none` or
   `aria-hidden`) while pulling the row out of `.ask`'s normal flex flow, so
   its own `gap` stops charging space for a row nobody sees -- a present
   box, sized or not, still spends layout unless it is pulled out of flow
   like this one is. */
.verdict { position:absolute; width:1px; height:1px; padding:0; margin:-1px;
           overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
           display:flex; align-items:center; gap:10px;
           font-family:var(--mono); font-size:10px; letter-spacing:.13em;
           text-transform:uppercase; }
.verdict.yes { color:var(--ok); } .verdict.no { color:var(--no); }
/* #706: `.next` is the "choose" family's shape -- a real decision between
   named alternatives. It used to be shared with the judged-cue beats'
   single-path "Continue" too, which is exactly the two-families-in-one-class
   problem #706 was filed about; those beats render `.advance` now (below).

   #2338: ONE control still wears it, `#continue` on the end-of-film card,
   and by the rule above that one is arguable -- since #2211 it is the only
   button on its card, so there is nothing left to choose between. The
   section card's pair became a single `.advance` (see `.advance.he` below)
   because the owner asked for that card's shape by name; he has not reported
   the end-of-film card, and #1419's "restyle both or neither" is on #2338
   for him rather than folded in here.

   #1372 gave `.advance` a border too, at the owner's own request, so a
   border no longer tells the two families apart on its own -- `.next` stays
   the louder 10px/UPPERCASE/`.13em` register and pins itself to one edge
   (`margin-inline-start:auto`); `.advance` is the quieter 13px/lowercase/
   `.08em` register and centres itself. See `.advance`'s own comment, further
   down, for the owner's words and why this is a deliberate reversal of the
   "a border reads as pick-one-of-these" reasoning #706 gave for leaving
   `.advance` borderless in the first place. */
.next { margin-inline-start:auto; font-family:var(--mono); font-size:10px;
        letter-spacing:.13em; text-transform:uppercase; color:var(--ink);
        background:none; border:1px solid var(--line); border-radius:999px;
        padding:7px 15px; cursor:pointer; }
.next:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }

/* #1549 (the owner's own report, 2026-09-03): "the congratulations and tap
   to move on line can be removed." `.tap-hint` used to be a two-purpose
   text row -- an instruction ("Tap to move on", #1372) and, on a correct
   answer, a praise phrase folded into the same line (#1178, itself
   superseded by this ticket for the reason below) -- carrying the
   typography, line-height (#1350) and contrast rules (--muted over --faint,
   measured 5.69:1 dark / 4.63:1 light) that content needed. Once #1467 put a
   labelled Continue button on every answered card, the instruction was the
   page explaining its own button, and the praise phrase had no
   accessibility role to lose: `.verdict`, this page's actual screen-reader
   announcement of correct/incorrect (below), is unaffected.
   `rendering.js`'s `judge()` still builds this element and still appends it
   to `#ask` -- see its own comment on why -- but appends nothing INTO it, so
   every rule that styled its now-nonexistent text is gone with the text.
   What is left, at `#ask .tap-hint` further down, is nothing at all: #1352
   kept the emptied box alive as the pinned band's seat, and #1728 unpinned
   the band, so the rule there is now `display:none`. The NODE still exists --
   see that rule's own block for why it is the rule that went and not the
   element. */

/* ---- #1728: the Continue pill FOLLOWS THE CARD ---------------------------

   READ THIS BEFORE "RESTORING" ANYTHING. What used to be here -- a pinned
   band holding the pill at one y above the nav bar on every ask card -- was
   not a bug that crept in. It was a rule the owner set, and it is a rule the
   owner has now REVERSED. Putting it back is not a regression fix; the locked
   home IS the defect he reported four times.

   THE REVERSAL, in his own words, `docs/decisions/2026-09-05-fable-design.html`
   q1, asked whether the pill should keep one home per card type or follow the
   card's own content, with the cost of each stated on the card:

       "Let it follow the card -- I'd rather have the space than the
        consistency."

   This SUPERSEDES the "same place on every card of the same kind" rule he set
   on the 2026-09-05 burn-down #3 worksheet (q1-q3, #1728/#1733), which is the
   rule the pinned band below was built to satisfy, and which
   `tests/js/test_study_advance_band.mjs` used to guard as a mechanism. It has
   been put to him twice now, with measurements on both sides, and this is his
   answer. Do not partially keep it and do not ask a third time.

   HIS FOUR REPORTS, all about this one control:
     2026-09-03 14:38  "the continue button can then move up slightly so it's
                        not slammed to the bottom"
     2026-09-03 21:42  "seems magnetically drawn to the very bottom. Can it
                        have a bit of breathing room from the bottom bar?
                        There is space above it."     (#1593, shipped 8px)
     2026-09-05 15:29  "Continue button is still too low. Why is it glued to
                        the bottom? Feels magnetically heavy."
                                                      (#1728, shipped 32px)
     2026-09-05        q1 above, once he was shown what a locked home costs.

   WHY THE OLD SHAPE COULD NEVER ANSWER HIM, which is the finding rather than
   the change. `#ask .tap-hint` and `#ask .advance` were both `position:fixed;
   bottom:var(--advance-lift)`, and `--advance-lift` was `--milim-nav-total +
   --advance-lead`: the pill was anchored to the PAGE CHROME, not to the card,
   so the distance between it and the card's own last line was simply wherever
   that line happened to fall. The ask card type's content bottom spans
   452-676px over the 335-card fixture, so ONE home cannot also be one gap --
   a home that clears the deepest card sits 224px above the shallowest card's
   content. `--advance-lead` was tuned three times inside that constraint
   (2px -> 8px -> 32px) and the median dead air above the pill still measured
   145px. The lever was never the problem; the anchor was.

   WHAT IT IS NOW. The pill is an ordinary item in `#ask`'s own grid, spanning
   both option columns, centred in its own row, sitting under whatever the
   card actually contains. Nothing here reads the nav bar and nothing writes a
   position at runtime -- `judge()` appends the node and stops.

   MEASURED, `scripts/study_layout_probe.py measure` over its own 335-card
   fixture (sources 7-12, real Chrome, true 393x852 via CDP
   `setDeviceMetricsOverride`, insets top=59 bottom=34), answered state,
   default text scale. Pinned at `--advance-lead:32px` -> in flow:

       dead air above the pill      median 145px -> 20px, max 232px -> 20px
       cards with >100px of it      281 of 335  -> 0 of 335
       column overflows             0 of 335    -> 0 of 335
       last option under the nav    0 of 335    -> 0 of 335
       cue truncated                0 of 335    -> 0 of 335

   WHAT IT COSTS, which is the half he was shown and accepted: the pill lands
   in a different place on 291 of 329 consecutive same-source ask-card pairs
   (it moved on 0 of 329 under the pin), median 44px apart on the pairs that
   move, worst 176px. That is the consistency he traded for the space, and it
   is the reason this block is written out rather than the answer alone. (The
   worksheet card put it to him as 300 of 329 / 42px / 155px, measured a build
   earlier; the shape of the trade is the same and the exact pairs are not.)

   AND AT THE LARGEST TEXT SIZE, stated because it is where this costs
   anything at all. `--milim-text-scale` 1.15 (settings.js's TEXT_SCALE_MAX
   since #1637), same fixture, answered state: 3 of 335 cues truncated, worst
   0.4 of a line -- against the 9 of 335 / 1.1 lines the shipped 32px pin
   measured, and the 9 of 335 / 1.0 lines this shape was PREDICTED to cost on
   #1728. It is cheaper than both, because 20px of air replaces 32px of lead
   plus the band's own 46px reservation. On those same 3 cards the stage gives
   up 18-21px when the pill arrives, which is the only place on this page
   where answering now moves anything -- see the `.beat-reserve` paragraph
   below.

   THE CUE BUDGET NO LONGER OPPOSES THIS, which is why the shape is
   unconditional rather than a conditional pin. #1728's own brief was written
   against a 19-of-335 truncation figure for the in-flow shape; that figure
   was measured BEFORE #1738 gave the column its two option rows back (~86px)
   with the 2x2 grid, and nobody re-ran it. Re-run above: 0 of 335. The same
   hazard as #597 and as `--advance-lead`'s own stale table -- a fitted number
   outliving the thing it was fitted to.

   `.beat-reserve` (#1283) STILL MEANS WHAT IT MEANT, AND THIS CARD STILL DOES
   NOT USE IT. That mechanic is `visibility:hidden` on a control that is
   already in its card's flow, so the box is occupied before the control is
   revealed and revealing it moves nothing. Every beat's dismiss button
   carries it; the ask card's Continue never has, because `judge()` creates
   that node only once the answer is graded (a Continue beside an ungraded
   question reads as "skip it", #1348) -- and under the pin it cost the column
   nothing to create, so there was nothing to reserve.

   Joining the flow changes that arithmetic, so it was MEASURED rather than
   reasoned about. `#ask` grows 64px on answer (44px pill + the grid's 8px row
   gap + `--advance-air`), and at the default text scale `.stage`'s top edge,
   its height, the cue's height and the cue's VISIBLE height are all identical
   in the ask and answered states on all 335 cards: the 64px comes out of the
   column's slack, below `#ask`, so #1283's "nothing moves when the card
   changes state" holds with no reserve. It stops holding on 3 of 335 cards at
   `--milim-text-scale` 1.15, where the column has no slack and the stage gives
   up 18-21px. Reserving the pill would fix those three and charge every other
   card 64px in the ASK state -- the state in which the learner is reading the
   cue -- so it is deliberately not done. If that trade is ever revisited, it
   is a reserve on `#ask`, not a `.beat-reserve` on a node `judge()` has not
   built yet.

   THE CLAMP WENT WITH THE PIN. `#ask.advance-crowded` and
   `clampAdvanceBand()` (study/rendering.js) existed for exactly one failure:
   a band pinned at a constant y landing ON the last answer option of an
   over-full card -- 245 of 332 cards at the largest text size, often covering
   the correct answer just revealed. An in-flow pill cannot reach an option;
   it is BELOW them in the same grid, and no card height changes that. Left in
   place the clamp would have been permanently inert, and on the one day
   something did fire it its `padding-bottom` would have re-opened exactly the
   dead air this ticket closes. An over-full card degrades the way #1124
   designed instead: `.stage` is the one child with room to give, and the cue
   truncates behind its own scroll-edge band.

   THE HINT'S BOX WENT TOO, AND THE NODE DID NOT. `.tap-hint` has carried no
   text since #1549; its only remaining job was to be the pinned band's opaque
   fill and the pill's seat, and neither exists now. The DIV is still built by
   `judge()` on purpose -- `scripts/study_layout_probe.py`,
   `scripts/study_geometry_probe.py` and `scripts/build_card_frame_page.py`
   all hand-copy that markup, and three test files pin the node's presence --
   so what goes is the RULE, not the element. `display:none` rather than a
   zero-height box: an empty grid item would still claim a row and a row gap.

   WHAT DELIBERATELY DID NOT CHANGE: the pill's look (neutral fill, the
   verdict-carrying `.right`/`.wrong` border and shadow, the 18px bloom --
   #1700, #1731, #1759), its `:active` travel and release sheen, and its 44px
   minimum. All of that is in `shared/beat_card.css` and none of it is about
   where the control sits. The beats' own `.advance` controls were ALREADY in
   their card's flow (`.beat` is a flex column at `justify-content:flex-start`,
   shared/beat_card.css), so this rule reaches none of them -- it is
   `#ask`-scoped, which is what it always was, and the ask card was the one
   surface where the pill did not follow its card. */
.ask { /* Extra air ABOVE the pill, on top of `.ask`'s own grid row gap
          (#2714 trimmed that gap 8px -> 4px for the column-height ticket
          below; this token is UNCHANGED by that edit, deliberately -- see
          its own note there -- so the total above the pill moved with it,
          16px now against the 20px this comment originally measured).
          ONE token, on the card TYPE, declared once -- the alternative, a
          literal per surface, is how `--advance-lead` came to be tuned three
          times against a constraint it could not win. It is a STEP, not a
          home: enough that the pill reads as the way out rather than as a
          fifth option, and small enough that it cannot re-open the band he
          reported. */
       --advance-air:12px; }
/* #1549 emptied it; #1728 took its box. The node still exists (see the block
   above for why); its band does not. */
#ask .tap-hint { display:none; }
/* The whole of #1728 in three declarations. `grid-column` because `.ask` is a
   2-column grid (#1220) and an item without one lands in whichever cell comes
   next -- beside an option rather than under both. `justify-self` rather than
   `.advance`'s own `margin-inline:auto`, which would also centre it but by
   accident of auto-margin resolution rather than by saying so. */
#ask .advance { grid-column:1 / -1; justify-self:center;
                margin-block-start:var(--advance-air); }

.end { text-align:center; display:flex; flex-direction:column; gap:14px;
       align-items:center; }
.end p { margin:0; color:var(--muted); font-size:16px; }
/* --muted, not --faint: measured 3.14:1 dark / 2.33:1 light against --ground,
   both below 4.5:1. Same finding as #112 on the queue page -- --faint may carry
   receded furniture, never a line someone is meant to read, and a score is
   still a line someone reads. */
.tally { font-family:var(--mono); font-size:11px; letter-spacing:.13em;
         text-transform:uppercase; color:var(--muted); font-variant-numeric:tabular-nums; }

/* ---- what the session changed (#102) ----
   The end screen's own claims. Deliberately none of this is on --accent: that
   token means "this is the target word" everywhere else on the page (see the
   header), and a summary of five words would spend it on all of them. --ok is
   kept to the moved group's rule, as a mark rather than as text -- it measures
   4.46:1 on the light theme, under the bar for body copy. */
.movedgroup { width:100%; max-width:32rem; text-align:start;
              border-top:1px solid var(--line); padding-top:13px; }
.movedgroup:first-of-type { border-top:2px solid var(--ok); }
.moved-head { margin:0; font-size:15px; font-weight:600; color:var(--ink);
              line-height:1.35; }
.moved-list { list-style:none; margin:9px 0 0; padding:0;
              display:flex; flex-direction:column; gap:7px; }
/* #2609, folding #2136's `.reask-row`-only fix into every moved row: the
   owner on the beta, 2026-09-26, "Why is התקדמות on a different line than
   (I know this +3)" -- the plain declare row (`session_summary.js`'s
   `rowHTML()`) still had `flex-wrap:wrap`, so once the word, its gloss and
   the claim button did not all fit on one line at phone width, the button
   (a whole flex item) dropped to a second line rather than the gloss
   giving way. #2136 already solved this exact shape for the re-ask row
   below -- nowrap, the Hebrew word held to its own width, the gloss
   shrinking with an ellipsis -- and there is no reason the plain row should
   behave differently, so that fix moves up here instead of staying
   `.reask-row`-only (the three rules that used to restate it just below
   are gone; nothing there was left to say). */
.moved-row { display:flex; align-items:baseline; gap:10px; flex-wrap:nowrap; }
.moved-word { font-family:var(--sans-he); font-size:calc(21px * var(--milim-text-scale)); color:var(--ink);
              line-height:1.2; white-space:nowrap; flex:0 0 auto; }
/* #1811: `.moved-rank` (the corpus-frequency "#N" badge) is gone -- the
   owner's report is quoted in session_summary.js's own rowHTML() comment.
   `.moved-gloss` was already flex:1 then, and still is -- #2609 (above)
   changed its BASIS from a fixed 7rem to `auto` plus an ellipsis, not
   whether it is the row's one flexible child. */
.moved-gloss { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis;
               white-space:nowrap; font-size:14px; color:var(--muted); }
.end .moved-note { margin:9px 0 0; font-size:12.5px; color:var(--muted);
                   font-style:italic; line-height:1.5; }
.moved-claim { font-family:var(--mono); font-size:10px; letter-spacing:.1em;
               text-transform:uppercase; color:var(--ink); background:none;
               border:1px solid var(--line); border-radius:999px;
               padding:5px 11px; cursor:pointer; white-space:nowrap; }
.moved-claim:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
/* #535: opacity:.45 puts this at 2.77:1 light / 3.96:1 dark, under the AA
   4.5:1 text floor. Left alone on purpose -- WCAG 2.1 SC 1.4.3 exempts "text
   ... that is part of an inactive user interface component", and the dimmed
   opacity IS the standard signal that this claim cannot be tapped again
   (`session_summary.js`'s `declareKnown()` disables the button the moment
   its "I know this" POST goes out, and only re-enables it if the write
   fails). Raising this button's contrast to pass AA would make it read as
   equally tappable mid-write, the opposite of what `disabled` exists to
   communicate. Recorded EXEMPT, not fixed. See #714. */
.moved-claim[disabled] { opacity:.45; cursor:default; }
/* #517: the "do you still know these?" group (`session_summary.js`'s
   `reaskHTML`). It reuses the moved group's rows and buttons whole; these
   rules are the differences. It is a question, not something the sitting
   achieved, so it does not take the --ok rule `.movedgroup:first-of-type`
   gives the first group on a card -- inside `#sittingExtras` it is always the
   first of its type. An answered row states what happened in the buttons' own
   quiet register, as text rather than as a control. */
.reask.movedgroup:first-of-type { border-top:1px solid var(--line); }
/* #2136: one line, not two. He tried the wrapped, two-line row #517 shipped
   ("I forget" landing under some words and beside others, per that ticket's
   own report) and asked for a single line instead -- Hebrew, English, then a
   yes/no -- describing the card's own ANSWERED row (below) as the look he
   wanted for every row. So the whole `<li>` was pinned to one line, the
   Hebrew word kept its natural width instead of being allowed to break, the
   English gloss took whatever width was left over and truncated with an
   ellipsis rather than wrapping or pushing the buttons off, and
   `.reask-actions` sits on that same line instead of claiming a full row of
   its own the way #517 had it. A long gloss loses its tail rather than the
   row losing its shape -- the least surprising of the two, since a
   truncated word is still legible as *a* translation where a wrapped or
   shrunk one reads as broken. #2609 promoted the three `.reask-row`-scoped
   rules this used to name (`.reask-row`, `.reask-row .moved-word`,
   `.reask-row .moved-gloss`) up onto `.moved-row`/`.moved-word`/
   `.moved-gloss` themselves -- see that ticket's own comment there -- so
   this row's shape is not restated a second time here; only what is
   genuinely still specific to a re-ask (the answer buttons) is. */
.reask-actions { display:flex; gap:10px; flex:0 0 auto; }
/* #2208: #2136's rules (now `.moved-row`/`.moved-gloss` themselves, #2609)
   never actually had anything to shrink INTO. `.end` (further up this file)
   is `align-items:center`, right for its own short, centred paragraphs and
   the Continue button -- but that
   also means every DIRECT child, `#sittingExtras` included, sizes to its own
   preferred (shrink-to-fit) width instead of stretching to fill `.end`'s
   available one. A `white-space:nowrap` span's preferred width is its FULL,
   untruncated text -- `text-overflow:ellipsis` only paints truncation once a
   width is assigned, and shrink-to-fit never assigns one narrower than that
   full string -- so the whole card widened to fit the longest served gloss
   and `shared/phone.css`'s own `overflow-x:hidden` clipped the result
   silently, symmetrically off BOTH edges: measured (a static harness serving
   this real file, headless Chrome, this ticket's PR body carries the
   script) the second button's right edge at 453px against a 393px viewport,
   and 416px against 320px -- both overflowing by the same 60px/96px the
   nowrap gloss's full, untruncated width added, regardless of viewport.
   `align-self:stretch` overrides `.end`'s `align-items` for this one child,
   handing it a DEFINITE width (`.end`'s own) instead of computing one from
   content -- which is what lets `.movedgroup`'s existing `width:100%`
   (above) resolve against a real number, and this file's own `.moved-gloss
   { flex:1 1 auto; min-width:0; ... }` (above) finally have
   room to shrink into. Scoped to `#sittingExtras` rather than loosening
   `.end` itself: `.end`'s other children (the tally line, "That is the
   source.", `#continue`) rely on shrink-to-fit plus `align-items:center` for
   their own compact, centred shape, and stretching `.end` wholesale would
   turn `#continue` into a full-width button on every card, reask rows or
   not. Measured, not assumed: `min-width:0` alongside `align-self:stretch`
   was tried first (the general pattern this kind of fix usually needs) and
   made no further difference here, and `min-width:0` alone (no
   `align-self:stretch`) left the row exactly as broken -- `align-self:
   stretch` is the whole fix; the extra declaration was dropped rather than
   kept "just in case". */
#sittingExtras { align-self:stretch; }
/* #1467's 44px was a tap-target FLOOR, not a comfortable width for two
   adjacent buttons -- #2164, he found them cramped. 72px/10px gap measured
   in-browser at 393px. `min-width`, not `width`: "Yes"/"No" both fit inside
   72px so they stay equal, but a failed row's "Didn't save -- retry" label
   (session_summary.js) must still be able to grow past it. */
.reask-actions .moved-claim { min-height:44px; min-width:72px; padding:0 10px;
                               display:inline-flex; align-items:center;
                               justify-content:center; }
.reask-done { font-family:var(--mono); font-size:10px; letter-spacing:.1em;
              text-transform:uppercase; color:var(--muted); white-space:nowrap; }

/* ---- #2034: the Keys a unit earned ----
   `session_summary.js`'s `unitKeysHTML`: "+12 Keys", then "10 new lines ·
   2 words known", then a line per medal. The owner asked for this to be an
   important moment, so the total is the largest number on the card. It is
   --accent because that is already the Keys colour on this page: the rail's
   own earned figure (`.rail .unitkeys .got`) carries it, and the rail moves
   in the same frame this appears. `.end .unit-keys-*` (0,2,0) so `.end p`
   (0,1,1) cannot put the total back at body size. */
.unit-keys { display:flex; flex-direction:column; align-items:center; gap:6px; }
.end .unit-keys-total { display:flex; align-items:center; gap:8px;
                        font-size:26px; font-weight:600; color:var(--ink);
                        font-variant-numeric:tabular-nums; }
.unit-keys-n { color:var(--accent); }
.unit-keys-glyph { width:24px; height:24px; stroke:var(--accent); fill:none;
                   stroke-width:2; stroke-linecap:round; }
.end .unit-keys-parts { font-size:13px; }
.unit-keys-medals { list-style:none; margin:4px 0 0; padding:0; display:flex;
                    flex-direction:column; gap:4px; font-size:14px;
                    font-weight:600; color:var(--ink); }
/* The "+3" on an "I know this" button, and the "+3 Keys" a declared row
   keeps once the tap has paid: both in the button's own mono register. */
.claim-keys { color:var(--accent); }
/* The summary rows' own "I know this" button, held to the 44px tap floor
   (#482) the re-ask row's Yes/No already meet (`.reask-actions .moved-claim`
   above). Measured at 393px before this rule: 136x24 -- the "· +3" made the
   button the thing a learner is now asked to tap for Keys, on the phone the
   owner reads this card on. A direct child only, so the re-ask buttons keep
   their own rule. `gap` puts back the space before "· +3": a flex item's
   leading white space is dropped, and without it the label rendered as
   "I KNOW THIS· +3" (seen at 393px). #2609: `flex:0 0 auto` so this button
   never gives up its own width in `.moved-row`'s new nowrap layout --
   `.moved-gloss` is the one child that shrinks. A direct-child selector
   again keeps the re-ask Yes/No buttons (nested inside `.reask-actions`,
   never a direct child of `.moved-row`) out of reach, unchanged. */
.moved-row > .moved-claim { min-height:44px; display:inline-flex;
                            align-items:center; justify-content:center;
                            gap:.45em; flex:0 0 auto; }
/* The end card's `#continue` measured 91x28 at 393px, under the same floor.
   Its LOOK is deliberately untouched -- `.next` on this card is #1419's
   "restyle both or neither", left with the owner by #2338 (see `.next`'s own
   comment) -- so only the hit box grows: an invisible 18px band ABOVE it,
   28 + 18 = 46, two pixels over the floor because hit-testing snaps to
   whole pixels (16px measured 43.25). Above only, on measurement: the
   button is the card's last child and `#stage` clips about 3px under it, so
   a symmetric 8px band reached only 41-42px; above it is `.end`'s gap (28px
   to the Keys line). */
.end #continue { position:relative; }
.end #continue::after { content:""; position:absolute; inset:-18px 0 0; }
/* #2810 (H3): the "come back tomorrow" line UNDER a first unit's Continue
   (`session_summary.js` `comeBackMountHTML`). Under, not over, so a late arrival
   never moves the button. Muted and small: a sign-off, not a banner. A `p`
   inside `.end`, so `.end p` (0,1,1) is beaten by this (0,2,0). The mount is
   `hidden` until there is a line, so an arm-B card has no extra gap. */
.end .come-back { margin:2px 0 0; font-size:14px; color:var(--muted); }
.moved-keys { font-family:var(--mono); font-size:10px; letter-spacing:.1em;
              text-transform:uppercase; color:var(--accent); white-space:nowrap; }
/* #2945: the "+3" that floats up from an "I know this" tap
   (`session_summary.js`'s `floatKeysChip`). `position:fixed` on the body at the
   button's own viewport point -- the card repaints under it, so it cannot live
   in the row. `--ink` on `--raise` for the number (the pair every card here
   already reads), the Keys colour only on the glyph and the rim. It never takes
   a tap (`pointer-events:none`), and `.key-chip-glyph` mirrors
   `.unit-keys-glyph` at chip size. */
.key-chip { position:fixed; left:var(--chip-x,50%); top:var(--chip-y,50%); z-index:60; display:inline-flex; align-items:center; gap:4px;
            padding:5px 10px 5px 7px; border-radius:999px; pointer-events:none;
            background:var(--raise); color:var(--ink); border:1px solid var(--accent);
            box-shadow:var(--shadow); font-family:var(--mono); font-size:15px;
            font-weight:600; font-variant-numeric:tabular-nums; white-space:nowrap;
            transform:translate(-50%,-50%); animation:keyChipFloat 900ms ease-out forwards; }
.key-chip-glyph { width:16px; height:16px; stroke:var(--accent); fill:none;
                  stroke-width:2; stroke-linecap:round; }
@keyframes keyChipFloat {
  0%   { opacity:0; transform:translate(-50%,-30%); }
  15%  { opacity:1; transform:translate(-50%,-70%); }
  70%  { opacity:1; }
  100% { opacity:0; transform:translate(-50%,-190%); }
}
/* Reduced motion: no travel, a static fade in place. `!important` because the
   blanket `* { animation-duration:.01ms !important }` rule below would otherwise
   flatten THIS animation to one frame and the chip would never be seen at all;
   `.key-chip` (0,1,0) outranks `*` among important declarations. */
@media (prefers-reduced-motion: reduce) {
  .key-chip { animation:keyChipFade 900ms linear forwards !important; }
  @keyframes keyChipFade { 0%, 70% { opacity:1; } 100% { opacity:0; } }
}

/* ---- the finished-sitting card (#431) ----
   What the off-ramp paints. Structurally the same card as the end screen and
   #377's checkpoint -- `.end` plus `summaryHTML()`'s own groups -- so nothing
   here restyles the rows; it adds the heading that makes the card read as an
   achievement rather than an exit, and the two ways off it. */
/* An `h2`, not a `p` -- see session_summary.js's own note. `.end p` (0,1,1)
   outranks `.hooray` (0,1,0), so a `<p>` heading renders at the card's 16px
   body size with the celebration silently lost. The element choice is what
   keeps it out of that rule's reach; this only has to undo the UA's own
   heading margin, since `.hooray` states the rest. */
.finished-head { margin:0; }
/* #1178: `praiseHTML()` (session_summary.js) can now paint this heading in
   Hebrew once the exposure ramp has carried it there. `body`'s own
   `font-family:var(--serif)` (top of this file) has no Hebrew coverage --
   unlike `.beat-reward`'s own use of `.hooray` (beats/reward.css), THIS
   heading has no ancestor already pointing it at `--sans-he`, so without this
   rule a shifted headline would fall through to the OS's own Hebrew fallback:
   functional, but a different face from every other Hebrew span on this page,
   and exactly the "reads as broken, not as chosen" failure #732 is cited
   against on this ticket. `letter-spacing` and `text-wrap` are untouched --
   `.hooray`'s -.01em is negligible on Hebrew and `balance` still helps a
   two-word phrase wrap evenly if it ever has to. */
.finished-head.he { font-family:var(--sans-he); }
/* #2338: `.finished-exits` and `.finished-exits .next` were here -- the two
   exits, side by side and equal in weight, with a comment arguing that
   neither "leave" nor "keep reading" should be the loud one. There is one
   control now and no choice to weight, so both rules went with the div that
   carried them; the survivor is a plain `.advance` sitting in `.end`'s own
   centred column, which already centres it.

   What it DOES need is a Hebrew face. `.advance` (shared/beat_card.css) is
   `var(--mono)` at 13px with `.08em` tracking and `text-transform:lowercase`
   -- right for "continue", wrong for a Hebrew word: the mono face has no
   Hebrew coverage, so the label would fall through to the OS's own fallback,
   a different face from every other Hebrew span on this page. That is the
   same failure `.finished-head.he` above exists to stop (#1178/#732, "reads
   as broken, not as chosen"), and this is the same fix for the same reason,
   on a control instead of a heading. Tracking goes to 0 because letter-
   spacing on Hebrew separates letters that should join; `text-transform` is
   `none` because `lowercase` is meaningless on an unicameral script and
   would be a lie about the label if it ever wore Latin text.
   `min-height:44px` is NOT restated -- `.advance` already carries it, which
   is half of why this button is an `.advance` at all (#1973). */
.advance.he { font-family:var(--sans-he); font-size:16px; letter-spacing:0;
              text-transform:none; }

/* ---- the off-ramp is GONE (#431, closed out by #2004) ----
   `.offramp`, `.endsitting` and `.endsitting:focus-visible` were here, ~40
   lines of them, and the door they styled went with #2004.

   #431's finding is not reversed and is worth keeping: "I didn't know how to
   end the time. The only thing I could do was go back to the reader." A
   sitting had no reachable end, because the only boundary was 8 answered asks
   on a page whose median sitting was 2 answers. What #2002 changed is that
   there IS a reachable end now -- every section boundary is one, several per
   story -- so the ask is met without a control whose job was to close the
   books EARLY, which is the thing the owner ruled out ("if you leave early you
   are abandoning the unit for that session").

   #1278's finding also survives its own subject: a control that changes
   appearance at a closing moment reads as an instruction to press it. Nothing
   in the rail changes appearance now, and the section strip is drawn to the
   same rule -- the fill and the colour move with what the learner has done,
   never with what the page would like them to do next. */


@media (prefers-reduced-motion: reduce) {
  * { animation-duration:.01ms !important; transition-duration:.01ms !important; }
}

/* #145: every rule below paints a hover state, and on a touch screen that is a
   trap rather than a decoration. iOS applies "sticky hover" -- the first tap on
   an element carrying a `:hover` rule paints the hovered state and does NOT
   fire the click; only a second tap activates it. On the study page that meant
   every question cost two taps and the first one looked like a selection it was
   not (reported from a real iPhone, and the desktop cannot see it: with a mouse
   this behaviour is correct).

   `@media (hover: hover)` is the query for "the primary input can actually
   hover", so these apply to a mouse and never to a finger. Kept together in one
   block per stylesheet rather than wrapped individually: sixteen one-line media
   queries across four files is harder to audit than one place per file that
   says what the rule is. Touch feedback, where a control needs it, belongs in
   `:active` or an explicit class -- never here. */
/* ---- the beat between cues (#159, #160, ADR 0025) ---- */
/* A card that takes the whole stage, like `.end` does -- not an overlay. The
   session is genuinely paused here: `answered` stays true, the drift clock is
   parked, and nothing is running behind this. An overlay would say otherwise. */
/* MOVED to `shared/beat_card.css` (#1568): `.beat`/`.fr-screen`, `.beat-text`, `.beat-dismiss`. This page still loads them -- study.html links that sheet before this one. */



/* MOVED to `shared/beat_card.css` (#1568): the whole `.advance` family, #706/#1372. This page still loads them -- study.html links that sheet before this one. */

/* #1657: `.beat-reserve` MOVED to shared/beat_card.css. It lived here, and
   `/skills` does not load this sheet -- so on the practice hub every beat's
   Continue button was visible from its first paint, six beats deep, with no
   test able to see it (jsdom runs no layout engine and the CSS-contract tier
   reads declarations, not which host loads which sheet). The rule belongs
   with the card mechanic it is part of, which both hosts load. */

/* #307: `.beat`/`.beat-text`/`.beat-dismiss` above are the whole card
   mechanic, shared by every beat (reward's own markup uses only these three
   -- see beats/reward.css). Each beat's own rules -- the word-match board,
   the pick-in-cue cue/options, the cloze cue/gap/options -- now live in
   `app/static/beats/<id>.css`, one `<link>` per beat in study.html, loaded
   after this file. (#515's first-run screen and its `.fr-*` rules were
   here too; #3012 cut the screen and #3052 deleted the dead rules.) */

/* ---- the celebration (#430) ----
   "Getting past that first beat I also saw a reward screen -- but it doesn't
   feel like a reward screen -- the text is no different really." Measured
   rather than taken on trust, and the report is exact: `.beat-text` just above
   and `.cue` near the top of this file were both `font-size:30px`, same weight
   class, same --ink. The card that says "well done" was set identically to the
   subtitle it interrupts. A reward that reads like a status message is a
   notification.

   ## This block's POSITION in the file is load-bearing

   It has to come after `.beat-text`, and it did not on the first attempt. Both
   are single-class selectors -- specificity (0,1,0) each -- so nothing but
   source order decides which `font-size` wins, and with the celebration
   written above `.beat-text` the shipped page rendered the reward at the cue's
   own 30px while every CSS assertion about it passed. Caught by reading
   `getComputedStyle` on the real page, not by the suite. The order is now
   pinned in tests/js/test_study_offramp.mjs -- do not move this block up, and
   do not "fix" a future collision with `!important`.

   Direction is the owner's: bigger, rotated off the grid the rest of the app
   sits on, and moving. Worn by two surfaces -- the reward beat's own text
   (beats/reward.js) and #431's finished-sitting heading -- because those two
   tickets are one moment: stopping only reads as finishing rather than
   quitting if the screen you stop on looks like this.

   Deliberately NOT on `.beat-text` itself, which is shared by three beats:
   the tense beat's "Past or future?" and pick-in-cue's "Which word means …?"
   are functional exercise prompts and rotating them would be worse than the
   defect. Opting in by class is what keeps the celebration on the one card
   that is itself a reward.

   ## --accent here overrides a decision this file already recorded

   `.beat-text`'s own comment (below) says --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."
   That rule is kept everywhere it applies and overridden here, on two
   grounds. First, reachability: the reward beat is a whole-stage takeover --
   `paintBeat` replaces `#stage` entirely and empties `#ask` -- so there is no
   cue, no `.tgt` and no second accent on screen while this card is up, which
   is the collision the rule was written against. Second, a travelling
   highlight is not the same signal as a static colour sitting on a word.

   ## Why this pair and no other

   Measured, 101 blend steps along each candidate gradient against --ground.
   A wave travels through every blend between its stops, so the stops are not
   the population -- the whole ramp is:

       --ink <-> --accent    11.35:1 dark   4.76:1 light   (worst step)
       --ink <-> --ok         9.56:1 dark   4.46:1 light
       --accent <-> --ok      9.54:1 dark   4.46:1 light

   Both --ok pairs fail AA normal on the light theme, the same finding this
   file already records for `.movedgroup` ("--ok ... measures 4.46:1 on the
   light theme, under the bar for body copy"). --ink <-> --accent is the only
   pair above 4.5:1 at every step in both themes, and for both the worst step
   is an endpoint rather than somewhere in the middle. Reported on #430.

   Raised on the ticket rather than decided silently: the fallback, if the
   owner keeps the original rule, is a brightness-only shimmer through
   --muted, which is a duller effect and a one-line change here. */
/* #467, asked for twice: "the latest iteration could even go larger with the
   text", then "the congratulations screen should have bigger font". Up one
   step at every end of the clamp -- 34->42 floor, 12->15vw, 48->62 ceiling.
   The ceiling is the one that carries the ask on a phone held in portrait,
   where 15vw of a 390px screen is 58px and the clamp never reaches its top.
   `text-wrap:balance` below is what keeps the finished-sitting card's English
   heading from going ragged at the larger size; it was already here, and it
   is the reason this could go up without a second rule for that card. */
/* #886: also reads --milim-text-scale, on top of the existing clamp() -- it
   inherits var(--sans-he) from .beat-reward (beats/reward.css) and has to
   stay bigger than .cue at every scale, not just the default (see this
   file's own comparison test in tests/js/test_study_offramp.mjs, updated for
   this multiplied form). */
.hooray { font-size:calc(clamp(42px, 15vw, 62px) * var(--milim-text-scale)); line-height:1.12; font-weight:700;
          letter-spacing:-.01em; text-wrap:balance; color:var(--ink);
          /* Rotation lives on the base rule, not only in the keyframes, so
             the still fallback below and any browser that refuses the
             animation still get the "off the grid" half of the direction. */
          transform:rotate(-3.5deg); }

/* `background-clip:text` with transparent fill is the only no-build-step way
   to run a gradient THROUGH glyphs, and it has exactly one failure mode: a
   browser that does not support it paints transparent text on no background
   and the reward disappears. Gated behind @supports for that reason -- the
   base rule above already stands on its own (bigger, rotated, --ink at
   15.86:1 dark / 14.97:1 light), so a browser that falls out here loses the
   colour and keeps the reward. */
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .hooray {
    background-image:linear-gradient(100deg,
      var(--ink) 0 38%, var(--accent) 50%, var(--ink) 62% 100%);
    /* `repeat`, and the 200%/200% pairing below with it, is a correctness
       requirement rather than a look. With `no-repeat` the image is 2x the
       element and `background-position` slides it off one end, so at most
       frames part of the element has NO background at all -- and since the
       glyphs are painted BY that background (`background-clip:text` +
       transparent fill), the letters over the uncovered region render
       invisible. That shipped in the first draft: "That is a sitting."
       rendered as "That is" on the real page while the element measured its
       full 375px and every test was green. `repeat` guarantees coverage at
       every frame, which is the whole of #430's readability requirement. */
    background-size:200% 100%; background-repeat:repeat;
    background-position:0% 0;
    -webkit-background-clip:text; background-clip:text;
    color:transparent; -webkit-text-fill-color:transparent;
    animation:hoorayWave 3.6s linear infinite,
              hoorayWobble 2.8s ease-in-out infinite;
  }
}
/* Two animations rather than one: the wave and the wobble want different
   durations (3.6s and 2.8s), so they drift in and out of phase and the card
   never settles into a loop the eye can predict. One combined keyframe set
   would have to pick a single period.

   0% -> 200% travels exactly one tile and no more, which is what makes the
   loop seamless. For a background wider than its box, a position of P moves
   the image by (box - image) x P; at `background-size:200%` that is -1 box
   width per 100%, so 200% is -2 box widths -- exactly the 2-box-wide tile.
   Any other pairing teleports the highlight at the loop point. Both ends of
   the gradient are --ink, so the tile seam itself is invisible. */
@keyframes hoorayWave { from { background-position:0% 0; } to { background-position:200% 0; } }
@keyframes hoorayWobble {
  0%, 100% { transform:rotate(-3.5deg) scale(1); }
  50%      { transform:rotate(-1.2deg) scale(1.035); }
}

@media (prefers-reduced-motion: reduce) {
  * { animation-duration:.01ms !important; transition-duration:.01ms !important; }

  /* The rule above is not a still fallback on its own -- it is a fallback
     that happens to look right most of the time, which is worse. Flattening
     `hoorayWobble` to .01ms leaves the element wherever the last frame put
     it, and `animation-fill-mode` decides whether the rotation survives at
     all. So the still state is STATED: the animations stop, the base rule's
     rotation is restated so nothing depends on what a flattened keyframe
     reverted to, and the wave is parked mid-travel rather than at its
     starting edge, so a still reward still has colour running through it.
     Size and rotation are what carry the reward when motion is off, which is
     #430's own requirement. */
  .hooray { animation:none;
            transform:rotate(-3.5deg);
            background-position:44% 0; }
}


/* ---- #1430: the skill-tree map -------------------------------------------

   What `/study` opens on, in place of the flat source picker. Here rather
   than in a new `app/static/study/map.css` on purpose: a stylesheet under a
   subdirectory registers as a COMPONENT in scripts/page_map.py and would
   bump `EXPECTED_COMPONENTS` from 12 (tests/test_page_map.py), which is the
   check that this landed as a SCREEN on an existing page and not as a new
   page. Tokens only, and no `:root` block -- the page's own palette already
   resolves above.

   ## The rail is not touched here either, but for a different reason now

   #1430 argued at length about whether to HIDE `#sourcePicker` while the map
   is up, and settled on keeping it: hiding a flex child relays the row rather
   than leaving a gap (measured at 430px -- hiding one child widened the select
   155px -> 209px and slid the counter and the audio toggle 54px right), and
   ADR 0048's shape says the adaptive default keeps its override beside it.

   **#2004 removed the picker outright, on the owner's own call**, so that
   argument has no subject left. The path is the picker; a flat list of all six
   stories in the rail was the map's competitor rather than its override. What
   still holds, and is what the strip is drawn to, is the mechanism the
   argument turned on: nothing in this row may appear, disappear or resize as a
   screen changes, because that relays every other cell. The strip is present
   on every screen this page has, empty until it has something to say. */

/* ADR 0037, and this is the whole of the ticket's `scrollHeight ===
   clientHeight` requirement. `.stage` is normally its own scroller
   (`overflow-y:auto`, a flex column sized to what is left) because a single
   cue that overflows must still be readable inside a fixed-height page. The
   map is the opposite shape: it is a LIST, it is meant to be long, and two
   nested scrollers on one screen is exactly what ADR 0037 says a page does
   not get. So on this screen the stage stops scrolling and stops being a
   flex column, and `[data-milim-main]` -- the page's one scrolling region,
   per shared/phone.css -- carries it. `html, body { overflow:hidden }` there
   is what keeps the DOCUMENT out of it either way. */
.stage.skill-map-on { display:block; flex:0 0 auto; overflow-y:visible;
                      padding:8px 0 0; text-align:start; }

.st-map { max-width:430px; margin:0 auto; padding:0 16px 12px; }

/* Not `--muted`: this is a caption over the screen's real content, the same
   job `.source-title` had, and `--faint` is the token that is documented as
   clearing AA (4.52:1 dark, 4.55:1 light) at exactly that weight. */
.st-heading { font:600 12px/1.2 var(--mono); letter-spacing:.14em;
              text-transform:uppercase; color:var(--faint);
              margin:4px 0 14px; text-align:center; }

/* #1695 part D -- "4 stories this week", the streak counted in stories.
   A caption on the heading, deliberately: same `--faint` weight, same mono
   family, same centring, one step down in size, and NOT uppercase -- the
   heading is a label and this is a sentence about the map below it. No pill,
   no badge, no colour of its own, nothing that could read as a score.

   `display:block` because `map.js` builds it with the same `span()` helper it
   builds every other caption with; a `<span>` between two blocks would
   otherwise sit on its own line by accident rather than by rule.

   **The gap above it comes off the HEADING's bottom margin, never off this
   element's top (#1300).** `.st-heading`'s 14px bottom margin is the space
   between the heading block and the spine, and a subline belongs inside that
   block rather than halfway down the gap. The first draft closed it with
   `margin:-9px 0 14px` here, which `tests/js/test_study_first_line_anchor.mjs`
   refuses and was right to: once #stage and its cards are top-anchored, a
   negative block-start margin is the one remaining way an in-flow box can
   start above the common first-line height, and safe_cracker.css's solved
   headline is the precedent that made the rule. Reclaim from the edge that
   has a neighbour -- so the heading gives the space up, `.st-week` declares
   `margin-top:0`, and the 14px below it is now this block's own bottom gap.

   `is-captioned` is added by `map.js` only when the caption actually renders,
   which is the point: a week with no sittings draws nothing here, and the
   heading must keep its full 14px on that screen. Same reason the modifier
   exists rather than a smaller margin on `.st-heading` outright. `3px` plus
   this line box's own ~2.2px of leading reads as ~5px, which is where the
   -9px draft put it. */
.st-heading.is-captioned { margin-bottom:3px; }
.st-week { display:block; font:400 11px/1.4 var(--mono); color:var(--faint);
           text-align:center; margin:0 0 14px; }

/* ---- the resume card ----
   #1430's answer to "a same-tab resume no longer bypasses the map": it is
   promoted onto the map instead of skipped, so the learner sees where they
   were AND what is next. First in the column, above the spine, because it is
   the one thing on this screen they did not have to decide.

   #1626: that ranking is for a story still IN PROGRESS. A `done` story's
   card (`.st-resume-wrap--done`, from map.js's `paint`) is appended AFTER
   the spine instead -- see study/map.js's header -- and gets a visibly
   quieter treatment below, because the unread stories above it are now the
   default, not this one. */
/* #1670: `.st-start*` is the SAME card in a different situation, so it shares
   every rule below rather than carrying a copy. It is promoted when there is
   nothing to resume at all -- the map's own recommendation of the story whose
   words the learner knows least -- where `.st-resume` is promoted when there
   IS a remembered position.

   Two classes and not one, deliberately: `test_skill_tree_map.mjs` asserts
   `.st-resume` is ABSENT from the DOM when a remembered position names nothing
   in the tree, and that assertion is about the element, not the styling. One
   shared class would have made it unsatisfiable; one shared RULE keeps the two
   cards visually identical, which is what matters to the learner, while the
   DOM stays greppable for the distinction that matters to the suite.

   #1941: `.st-start-wrap` does NOT fork position the way `.st-resume-wrap`
   does. `map.js`'s `paint` always does `map.insertBefore(startCard(...),
   spine)` -- done or not -- because a start card only ever exists in the
   "nothing to resume" branch, which runs before the spine is walked. Only
   `.st-resume-wrap` forks: not-done stays at the head, done moves to the
   foot (`#1626`, above). So a `--done` card's margin has to follow WHICH
   wrapper class it is, not just whether it is done -- `.st-start-wrap--done`
   needs the same bottom margin as every other head card, not the foot-only
   top margin below, which is where it used to live (grouped with
   `.st-resume-wrap--done` on the assumption both `--done` cards sit at the
   foot). Measured before this fix: a `.st-start-wrap--done` card (a done
   story recommended with no remembered position -- the ordinary case since
   #1749) sat flush against the spine's first row, 0px of computed gap, on
   builds going back to #1670 (2026-09-04) -- three days before #1941
   reported it, because nothing routes through this branch until
   `sessionStorage`'s remembered position is empty (a fresh tab session --
   see `state.js`'s `_rememberedPosition`). Moved to THIS line rather than
   given a rule of its own so `tests/test_nav_geometry.py`'s parsed-shape
   pin for this file doesn't move either: one selector left the line below,
   one joined this one, same total rules and declarations.
   `tests/test_map_classes_are_styled.py` pins the computed gap so this
   cannot silently invert again. */
.st-resume-wrap, .st-start-wrap, .st-start-wrap--done { margin:0 0 18px; }
.st-resume-wrap--done { margin:18px 0 0; }
.st-resume, .st-start { display:flex; flex-direction:column; align-items:flex-start;
             gap:2px; width:100%; min-height:44px; padding:12px 14px;
             background:var(--raise); border:1px solid var(--line);
             border-radius:14px; text-align:start; cursor:pointer;
             font:inherit; color:var(--ink); position:relative; }
/* Quieter, not hidden: `--faint`/`--muted` in place of `--ink`, and the pill
   below drops the filled `--control` treatment for an outline -- the same
   demotion in kind that `nodeCard`'s own `Finished` state badge already
   reads as, so this card does not out-rank the thing it is a re-read of. */
.st-resume--done .st-resume-title,
.st-start--done .st-start-title { color:var(--muted); }
.st-resume-kicker, .st-start-kicker { font:600 10px/1.3 var(--mono); letter-spacing:.12em;
                    text-transform:uppercase; color:var(--faint); }
.st-resume-title, .st-start-title { font:600 16px/1.3 var(--serif); color:var(--ink); }
.st-resume-line { font:400 12px/1.4 var(--mono); color:var(--muted); }
/* `--control`, not `--accent`. This file's header reserves subtitle-yellow
   for the target word and nothing else; #1424 added `--control`/`--control-ink`
   for exactly this -- a filled thing that has to read as a control (9.1:1
   dark, 5.11:1 light, with 8.6:1 / 5.77:1 for the label on it). */
.st-resume-go, .st-start-go { position:absolute; inset-inline-end:12px; top:50%;
                transform:translateY(-50%);
                font:600 12px/1 var(--mono); letter-spacing:.04em;
                padding:9px 12px; border-radius:999px;
                background:var(--control); color:var(--control-ink); }
.st-resume--done .st-resume-go,
.st-start--done .st-start-go { background:none; color:var(--muted);
                               border:1px solid var(--line); }

/* ---- the spine ----
   One column that splits into 2-3 where the tree branches. Rows are the
   graph's own ranks (study/map.js), so a row is 1 wide most of the way down
   and only widens where there is a real choice. */
.st-spine { display:flex; flex-direction:column; align-items:stretch; }
.st-row { display:flex; gap:10px; align-items:stretch; }
/* `min-width:0` on every arm, paired with `.phone`'s own `overflow-x:hidden`
   -- #125's rule, and this is a new flex row of exactly the shape that
   pans a page sideways when a long unbreakable title lands in it. */
.st-row > * { flex:1 1 0; min-width:0; }
.st-across-1 > * { flex:1 1 auto; }

/* The connectors. One <svg> per gap, its viewBox stretched to the column's
   real width (`preserveAspectRatio="none"` in map.js), paths drawn from
   column indices alone -- no `getBoundingClientRect` anywhere, which is what
   makes this both correct at any width and testable in jsdom, where every
   rect is zero (ADR 0021). */
.st-link { display:block; width:100%; height:30px; overflow:visible; }
/* `non-scaling-stroke` is load-bearing, not a nicety: the viewBox is
   stretched non-uniformly (100 units onto ~360px wide, 40 onto 30px tall),
   so without it the stroke is drawn thick horizontally and thin vertically
   and the trunk visibly changes weight as the phone rotates. */
.st-link-path { fill:none; stroke:var(--line); stroke-width:2;
                stroke-linecap:round; vector-effect:non-scaling-stroke; }

/* ---- a node ---- */
.st-node-wrap { display:flex; flex-direction:column; }
/* #1815 (Lantern Phase 3, 05 "Phase 3"): radius 18 and Lantern's own
   elevation device, `--shadow` (02-directions.md, "Elevation") -- the same
   pair `.opt`/`.cloze-option` picked up in Phase 1. `position:relative` is
   new and load-bearing: `.st-check` (below) anchors to THIS box's corner. */
.st-node { display:flex; flex-direction:column; align-items:flex-start; gap:5px;
           width:100%; height:100%; padding:12px 13px; font:inherit;
           text-align:start; cursor:pointer; color:var(--ink); position:relative;
           background:var(--surface); border:1px solid var(--line);
           border-radius:18px; box-shadow:var(--shadow); }
.st-node-head { display:flex; gap:8px; align-items:baseline; }
.st-node-state { font:600 10px/1.3 var(--mono); letter-spacing:.1em;
                 text-transform:uppercase; color:var(--faint); }
.st-node-kind { font:400 10px/1.3 var(--mono); color:var(--faint); }
.st-node-title { font:600 16px/1.25 var(--serif); color:var(--ink); }
.st-node-blurb { font:400 13px/1.4 var(--serif); color:var(--muted); }
/* #1813 -- the "seen" stat, one weight fainter than the teach note above it:
   a secondary figure about the same vocabulary, the same weight this sheet
   already gives `.st-progress-fig`/`.st-star-fig`, not a second headline. */
.st-node-seen { font:400 11px/1.3 var(--mono); color:var(--faint); }

/* ---- the have/need figure, on EVERY state ----
   Locked nodes render this too. That is the whole difference between a soft
   lock and a wall: a lock you can see the distance to is a next step, and a
   lock with no number on it is a closed door. */
.st-progress { display:flex; flex-direction:column; gap:4px; width:100%;
               margin-top:2px; }
/* #1815: 4px -> 5px, the roadmap's own number. */
.st-progress-track { display:block; width:100%; height:5px; border-radius:999px;
                     background:var(--raise); overflow:hidden; }
/* `transition:width` animates the NEXT paint's width change from whatever
   width was already on the element -- see map.js's `progressFigure`, which
   is the half of this that makes that true. `map.js` rebuilds the whole map
   on every paint (this file's own header, "The rail is NOT touched"'s
   neighbour), so a fill born already at its final width has nothing to
   transition FROM; `progressFigure` paints it at the previous ratio first and
   moves it to the real one a frame later (03 §3), which is what gives this
   transition two frames to animate between instead of one. */
.st-progress-fill { display:block; height:100%; border-radius:999px;
                    background:var(--control); position:relative;
                    transition:width var(--dur-base) var(--ease-out); }
/* Lantern's leading edge: a bright cap riding the end of the fill, the same
   "front on the moving bar" 03 §3 asks for. Decorative and unconditional --
   it costs nothing on a bar that never moves and marks the ones that do. */
.st-progress-fill::after { content:""; position:absolute; inset-inline-end:0;
                           top:0; bottom:0; width:10px; border-radius:999px;
                           background:linear-gradient(90deg, transparent,
                             color-mix(in srgb, var(--control) 40%, white)); }
.st-progress-fig { font:400 11px/1.3 var(--mono); color:var(--faint); }

/* ---- what a lock says ----
   Recessed by TREATMENT, never by `opacity`. A dimmed card is a card whose
   every text colour has silently dropped below AA, and this one is carrying
   the prose that explains the lock -- the one thing on it that has to stay
   readable. A dashed border, the page ground instead of a raised surface,
   and `--known` in place of `--ink` (8.61:1 dark, 5.89:1 light) all say
   "not yet" while every string on the card still clears the floor. */
/* The dashed outline goes on the WRAP, not the button, so the override below
   sits INSIDE the card it belongs to. Read off the 393px render before this
   changed: with the border on `.st-node`, "Open anyway" floated in the gap
   underneath the card with nothing tying it to the story it opens -- it read
   as a loose link between two cards rather than as that card's own way out.
   The button keeps its own padding and the card its geometry; only which
   element paints the outline moved. */
.st-node-wrap.is-locked { border:1px dashed var(--line); border-radius:14px; }
/* #1815: `box-shadow:none` is new here, and it is the whole of what keeps
   this state "unchanged" (05 "Phase 3") now that the base `.st-node` rule
   above carries an elevation shadow -- without it a locked card, which is
   otherwise a transparent recess with no visible box, would float a shadow
   with nothing under it. */
.st-node-wrap.is-locked .st-node { background:transparent; box-shadow:none;
                                   border-color:transparent; cursor:default; }
.st-node-wrap.is-locked .st-node-title { color:var(--known); }
.st-node-wrap.is-locked .st-progress-fill { background:var(--known); }

/* ---- finished (#1815, Lantern Phase 3) ----
   "What I have done", the owner's own words on #1815: a filled track and the
   state word both pick up the tick colour everywhere else on this page
   already uses for "met" (`.st-require.is-met`, `.st-star.is-earned`). */
.st-node-wrap.is-done .st-node-state { color:var(--ok); }
.st-node-wrap.is-done .st-progress-fill { background:var(--ok); }
/* The mark itself. A SIBLING glyph pinned to `.st-node`'s own corner
   (`position:relative` above), not a second badge competing with the state
   word in the head -- `aria-hidden` because `.st-node-state` already reads
   "Finished" to a screen reader and this would only repeat it. */
.st-check { position:absolute; top:10px; inset-inline-end:12px;
            font:700 14px/1 var(--mono); color:var(--ok); }

/* ---- current (#1815, Lantern Phase 3) ----
   "What I should work on next": the lit edge on whichever node map.js has
   ALREADY decided is the resume/start target -- see map.js's `paint`, the
   `resumeNode`/`recommendedSource` decision this class rides on rather than
   a second one. `--control` is this page's one other "this is a control, do
   this" colour (the Continue/Begin pill, the progress fill, hover's own
   border -- `.st-node-wrap:not(.is-locked) .st-node:hover`, below), so the
   lit node reads as "the pill's colour is on the map too", not a third hue. */
.st-node-wrap.is-current .st-node { border-color:var(--control);
                                     box-shadow:0 0 0 2px var(--control), var(--shadow); }

/* ---- unlocking, one-shot (#1815, Lantern Phase 3; 03 §2) ----
   `map.js` adds `st-unlocking` for exactly one paint, on a node whose state
   was `locked` on the PREVIOUS payload and is not locked on this one, and
   removes the class itself on `animationend` -- there is nothing here that
   parks it. The kicker text swap (LOCKED -> its real state) is the actual
   state; this ring is decoration on top of it. */
.st-node-wrap.st-unlocking .st-node { animation:unlockRing 520ms var(--ease-out) 1; }
@keyframes unlockRing {
  0%   { box-shadow:0 0 0 0 var(--ok-halo); }
  60%  { box-shadow:0 0 0 8px transparent; }
  100% { box-shadow:var(--shadow); }
}
@media (prefers-reduced-motion: reduce) {
  /* Parked frame: the base `.st-node` shadow, i.e. no ring -- the blanket
     `* { animation-duration:.01ms }` rule already gets there in practice,
     restated here per this file's own reduced-motion convention so the
     parked frame is a stated fact rather than an accident of the flattened
     timing. */
  .st-node-wrap.st-unlocking .st-node { animation:none; }
}

/* ---- the star ladder (#1695 parts A and C) ----
   Borrows `.st-requires`' geometry on purpose -- a mark, the server's prose,
   a figure -- because that is already this map's vocabulary for "here is a
   thing and how close you are to it", and inventing a second one would have
   been the games-console look #1695 warns about arriving by way of layout
   rather than colour.

   What is deliberately NOT here: no gold, no gradient, no fill animation, no
   size change, and no count badge in the card's head. The restrained
   alternative was considered and this IS it -- an earned star is `--ok`, the
   token this sheet already uses for a met requirement's tick, and an unearned
   one is `--faint`, the same weight as every other secondary figure on the
   card. `map.js` renders no star at all on a story with none earned, so the
   only visible change on an untouched account is on the story that earned
   one. */
.st-stars { list-style:none; margin:6px 0 0; padding:0; width:100%;
            display:flex; flex-direction:column; gap:4px; }
.st-star { display:flex; gap:6px; align-items:baseline;
           font:400 12px/1.35 var(--serif); color:var(--faint); }
/* `var(--mono)` so the filled and hollow glyphs occupy the same advance and
   the labels beside them line up whichever rung is earned. */
.st-star-mark { flex:0 0 auto; font-family:var(--mono); }
.st-star-label { flex:1 1 auto; min-width:0; }
.st-star-fig { flex:0 0 auto; font:400 11px/1.35 var(--mono); }
/* The earned rung is the only thing on this block that changes colour, and
   it changes only the GLYPH -- the label stays `--faint` like its unearned
   sibling. A whole row lighting up is a reward animation with the motion
   left out; a filled star in the tick colour is a record. */
.st-star.is-earned .st-star-mark { color:var(--ok); }
/* Same 174px branch-row problem `.st-require` hit and the same fix, scoped
   the same way: a label plus a figure on one flex line strands the figure
   beside the label's first word. */
.st-row:not(.st-across-1) .st-star { display:block; }
.st-row:not(.st-across-1) .st-star-fig { display:block; }
.st-row:not(.st-across-1) .st-star-mark { margin-inline-end:4px; }

/* `requires[].label` is the SERVER's prose, rendered verbatim (map.js). The
   figure beside it is a figure. Nothing here composes a sentence. */
.st-requires { list-style:none; margin:6px 0 0; padding:0; width:100%;
               display:flex; flex-direction:column; gap:4px; }
.st-require { display:flex; gap:6px; align-items:baseline;
              font:400 12px/1.35 var(--serif); color:var(--faint); }
.st-require-mark { flex:0 0 auto; font-family:var(--mono); }
.st-require-label { flex:1 1 auto; min-width:0; }
.st-require-fig { flex:0 0 auto; font:400 11px/1.35 var(--mono); }
.st-require.is-met .st-require-mark { color:var(--ok); }
/* In a branch row a card is ~174px wide at 393px, and a label plus a figure
   on one flex line leaves the label about 90px -- measured in the browser,
   "Finish Night Bus" broke after "Finish" with "0 / 54 cues" stranded beside
   the first line of it. Stacked instead: the mark and the label flow as
   ordinary inline text and the figure takes its own line under them. Scoped
   to non-full-width rows, so the wide cards keep the tidier one-line form. */
.st-row:not(.st-across-1) .st-require { display:block; }
.st-row:not(.st-across-1) .st-require-fig { display:block; }
/* `display:block` above drops the flex `gap`, so the mark has to carry its
   own -- without this it renders "*Finish Night Bus", jammed against the
   label. Caught in the 393px render, not by any assertion. */
.st-row:not(.st-across-1) .st-require-mark { margin-inline-end:4px; }

/* ---- the override (ADR 0048) ----
   A lock is never absolute. This exists so a beta tester is never trapped
   behind a gate nobody anticipated, which is why it is on every locked node
   unconditionally rather than after some number of attempts.

   A SIBLING of `.st-node`, never nested inside it -- a button inside a
   button is invalid HTML. 44px because it is a real tap target and this
   project's floor is 44 (shared/nav.css's own rule, and #1201's rail
   icons). Full contrast on purpose: the card recedes, the way out does not. */
.st-anyway { align-self:flex-start; min-height:44px; padding:0 13px 10px;
             font:600 13px/1 var(--mono);
             background:none; border:0; border-radius:12px;
             color:var(--control); cursor:pointer; }

@media (hover: hover) {
  /* #2092: `.rail .audiotoggle:hover` was here -- gone with the button. */
  /* `.opt:hover` MOVED to `shared/beat_card.css` (#1568). */
  .next:hover { border-color:var(--accent); color:var(--accent); }
  .moved-claim:hover { border-color:var(--ok); color:var(--ok); }
  /* #2004: `.endsitting:hover` was here and went with the off-ramp. */
  /* `.advance:hover` MOVED to `shared/beat_card.css` (#1568), with the
     selector it belongs to. */
  /* #1430. Inside this block for the same #145 reason as everything above it:
     on iOS a `:hover` rule makes the first tap paint instead of activating,
     and a map whose nodes need two taps is not a map. A locked node is
     deliberately absent -- it does not activate, so it must not light. */
  .st-node-wrap:not(.is-locked) .st-node:hover { border-color:var(--control); }
  .st-resume:hover, .st-start:hover { border-color:var(--control); }
  .st-anyway:hover { background:var(--raise); }
}

/* MOVED to `shared/warm_up.css` (#1568): the whole #1473 memory-match block,
   because the practice hub renders the same board. study.html links that
   sheet; nothing about the pre-story screen changed. */

/* =====================================================================
   #2001: the path of subjects -- `study/map.js`'s new renderer.
   ---------------------------------------------------------------------
   The `.st-*` rules above are the OLD ranked-graph map (#1430-#1936) and are
   left in place rather than deleted: nothing in this repo currently proves
   they have no other reader, and this ticket's fence does not extend to
   auditing that. They are unreachable from `study/map.js` as of this
   change, and a follow-up that confirms as much may remove them.

   `.path-*` throughout, deliberately not `.st-*`: the DOM shape is
   different enough (one tile per SUBJECT, not one card per story/warm-up/
   skill node) that sharing a prefix with dead rules above would invite a
   selector collision nobody notices. Real design tokens throughout
   (`--surface`, `--raise`, `--shadow`, `--line`, `--ink`, `--muted`,
   `--faint`, `--accent`, `--accent-soft`, `--glow`, `--ok`, `--ok-soft`,
   `--control`, `--control-ink`) -- the same ones the rest of this file
   uses, not the placeholder palette the mockup at
   docs/demos/2026-09-08-path-and-tree/ invented for its own purposes.
   ===================================================================== */

.stage.path-map-on { display:block; flex:0 0 auto; overflow-y:visible;
                      padding:8px 0 0; text-align:start; }
/* #2198, the owner: "The first page with the tree and nodes does not need the
   [keys] in the top left, or the sequence bar or the audio speed ui ... They
   are only needed on the subsequent pages where you are actually learning
   specific things." study/map.js puts `path-view-on` on <body> while the path
   is painted, and takes it off in `dismiss()` and before a warm-up, so all
   three come back on every screen of a sitting. `display:none`, not
   `visibility:hidden`, so no row keeps their space. The speed VALUE is
   untouched: it lives in state.js, and the slider reads it again the moment it
   is shown.
   The feedback flag is the rail's only other child. `.rail .unitkeys`' own
   `margin-inline-end:auto` was what pinned the flag right, and with that cell
   gone `space-between` would put the one remaining child at the START. So
   `flex-end` holds the flag exactly where it sits in a sitting. */
.path-view-on .rail .unitkeys, .path-view-on #unitBar, .path-view-on .pace { display:none; }
.path-view-on .rail { justify-content:flex-end; }
/* #2201: the same three, hidden while the page has not yet decided which
   screen it is. study.html ships `<body class="path-view-pending">`, because
   `path-view-on` alone arrived only once the path painted: the controls drew
   and then vanished, measured at 61-243ms after first paint on localhost and
   ~940ms with 150ms added per request. Every screen clears it (study/state.js,
   `setPathView`), so a sitting, an error card and the first-run card all show
   them; the path swaps it for `path-view-on`. Same rules as above, so the flag
   does not move when the class changes hands. */
.path-view-pending .rail .unitkeys, .path-view-pending #unitBar, .path-view-pending .pace { display:none; }
.path-view-pending .rail { justify-content:flex-end; }
/* #2215: the gold, scoped to the path and its zoom. `--gold-sheen` is the
   banded metal the owner picked on #1964, byte for byte the colour game's gold
   row (app/services/beats/colour.py, the `sheen` #2127 made reach the board);
   `--gold-fill` is that row's solid tone, used where a gradient cannot be (a
   glow, a thin bar). The same gold in both themes, as the colour game has it.
   #2260: the reading page's unit medal wears the same metal, so the tokens are
   declared for it too rather than copied. */
.path-map { max-width:430px; margin:0 auto; padding:0 16px 20px; }
.path-map, .unitmedal {
            --gold-sheen:linear-gradient(160deg, #f5d987 0%, #ffe9a8 18%, #c79422 46%, #8a6414 58%, #d9a63a 74%, #f0cf76 100%);
            --gold-fill:#e3ab2e; --gold-edge:#c79422; --gold-ink:#2b1d03;
            --gold-halo:rgba(255,240,200,.95); --glint:rgba(255,255,255,.5);
            /* #2227 q3: the same banded metal as silver with a tinge of pink,
               stop for stop the gold's (#1964) shifted to a rosy grey. */
            --silver-sheen:linear-gradient(160deg, #efe4e8 0%, #fff8fa 18%, #bba9af 46%, #7f6d74 58%, #d3c1c7 74%, #ecdee3 100%);
            --silver-edge:#a8949b; --silver-ink:#2a1d22; --silver-halo:rgba(255,250,252,.95); }

.path-heading { font:600 12px/1.2 var(--mono); letter-spacing:.14em;
                text-transform:uppercase; color:var(--faint);
                margin:4px 0 14px; text-align:center; }
.path-heading.is-captioned { margin-bottom:3px; }
/* #3121: the heading in Hebrew when the bar is. The mono face has no Hebrew and the
   .14em tracking would pull the letters apart; 1.3em because Hebrew at 12px reads small
   beside mono caps. line-height:0 is the #3117 lesson: the bigger span would otherwise
   grow the line box (+4.31px measured, docs/demos/2026-10-07-hebrew-path-title). */
.path-heading [lang="he"] { font-family:var(--sans-he); font-size:1.3em; letter-spacing:0; line-height:0; }
.path-week { display:block; font:400 11px/1.4 var(--mono); color:var(--faint);
             text-align:center; margin:0 0 14px; }

/* ---- the spine: rows of 1 or 2 tiles, joined by drawn edges ---- */
.path-spine { display:flex; flex-direction:column; align-items:stretch; position:relative; }
.path-row { display:flex; justify-content:center; gap:22px; }
.path-cell { display:flex; flex-direction:column; align-items:center;
             width:104px; flex:0 0 auto; position:relative; }

/* #2281: a fixed box, centred, exactly as wide as a pair's two cell centres are
   apart (104px cell + 22px gap = 126px) plus the stroke. map.js draws in its
   pixels, so every connector meets a tile's top centre or a cell's bottom
   centre at any column width, never a stretched percentage that landed on a
   pair's outer corners. */
.path-edge { display:block; width:130px; height:26px; margin-inline:auto; overflow:visible; }
/* #2299: a fork below a node draws the same 26px stem a single-to-single link
   already has, THEN the fork -- map.js's `EDGE_FORK_STEM` reuses `EDGE_H`
   rather than a new number, so this is twice that box, not an arbitrary one. */
.path-edge.has-stem { height:52px; }
.path-edge-path { fill:none; stroke:var(--line); stroke-width:2;
                   stroke-linecap:round; vector-effect:non-scaling-stroke; }

/* ---- one tile: 88px, the icon, an "opens" corner badge, a footer bar ---- */
.path-tile { width:88px; height:88px; border-radius:22px; border:1px solid var(--line);
             background:var(--surface); box-shadow:var(--shadow); cursor:pointer;
             padding:0; color:var(--ink); display:flex; flex-direction:column;
             align-items:center; justify-content:center; position:relative;
             overflow:hidden; }
.path-tile .path-ic { width:28px; height:28px; display:block; fill:none;
                       stroke:currentColor; stroke-width:1.6; stroke-linecap:round;
                       stroke-linejoin:round; }
.path-tile.done { border-color:var(--ok); color:var(--ok); }
.path-tile.in_progress { border-color:var(--accent); color:var(--accent); }
.path-tile.available { color:var(--ink); }
.path-tile.locked { background:transparent; border-style:dashed; box-shadow:none;
                     color:var(--known); }
.path-tile.planned { background:transparent; border-style:dotted; box-shadow:none;
                      color:var(--faint); cursor:default; }
.path-tile.current { border-color:var(--control);
                      box-shadow:0 0 0 3px var(--glow), var(--shadow); }

.path-opensbadge { position:absolute; top:-1px; right:-1px; width:22px; height:22px;
                    border-bottom-left-radius:11px; border-top-right-radius:22px;
                    background:var(--accent); color:var(--ground);
                    display:flex; align-items:center; justify-content:center; }
.path-opensbadge.path-ic { width:12px; height:12px; }
.path-tile.locked .path-opensbadge,
.path-tile.planned .path-opensbadge { background:var(--line); color:var(--muted); }

/* #2611: a node that opened since the learner last looked -- a tile, a gate or
   a skill -- wears a small "New" pill on its top-left corner until the first
   tap into it (the "opens" badge already owns the top-right). The
   interactive blue the current tile's ring and every control on this page
   use, with its own ink, so it reads as "tap here" rather than as progress
   (gold, green) or the Tutorials nudge's hot pink. Drawn on the CELL (or the
   offshoot's own button), never inside the 88px tile, whose `overflow:hidden`
   would clip it; a 2px ring of the ground keeps it off the tile's border.
   Still: it never moves, so reduced motion needs nothing here. */
.path-new { position:absolute; z-index:2; pointer-events:none;
            font:600 9.5px/1 var(--mono); letter-spacing:.08em; text-transform:uppercase;
            padding:3px 6px; border-radius:999px;
            background:var(--control); color:var(--control-ink);
            box-shadow:0 0 0 2px var(--ground); }
.path-cell > .path-new { top:-6px; left:2px; }
.path-offshoot > .path-new { top:-6px; left:4px; }

.path-foot { position:absolute; left:0; right:0; bottom:0; height:5px; }
/* #2313: a slice is empty or full, never partly filled -- map.js appends a
   fill only to a unit read through in one sitting, or gold. The ring is the
   empty track shown before the goals arrive; it has no fill any more. */
.path-ring { display:block; height:100%; background:var(--line); }
.path-slices { display:flex; gap:1px; height:100%; }
.path-slice { flex:1 1 0; background:var(--line); overflow:hidden; }
.path-slice-fill { display:block; width:100%; height:100%; background:var(--accent); }
.path-tile.done .path-slice-fill { background:var(--ok); }

/* ---- #2215: gold ----
   A node is gold when every unit of it is (map.js reads the server's `gold`).
   After `.done`/`.current` in file order on purpose, so gold wins the border
   and the glow. On a gold tile the footer strip goes dark on the metal, as the
   prototype drew it; on any other tile a gold unit's slice is gold. */
.path-tile.is-gold, .path-zoom-node.is-gold {
  background:var(--gold-sheen); border-color:var(--gold-edge); color:var(--gold-ink);
  box-shadow:0 0 18px rgba(227,171,46,.45), var(--shadow); }
.path-tile.is-gold .path-ic, .path-zoom-node.is-gold .path-ic, .path-medal.is-met .path-ic {
  filter:drop-shadow(0 0 1.2px var(--gold-halo)) drop-shadow(0 0 .6px var(--gold-halo)); }
.path-slice.is-gold .path-slice-fill { background:var(--gold-fill); }
.path-tile.is-gold .path-slice, .path-zoom-node.is-gold .path-slice { background:rgba(43,29,3,.22); }
.path-tile.is-gold .path-slice-fill, .path-zoom-node.is-gold .path-slice-fill { background:rgba(43,29,3,.42); }

/* The glint: the replay button's travelling band (`.cuereplay.playing`, #1373),
   run once. `--glint-delay` is set by map.js to a NEGATIVE offset when a
   repaint lands inside the band's 1.1s, so the band carries on rather than
   starting again. */
.path-glint::after, .path-medal.is-new::after {
  content:""; position:absolute; inset:0; border-radius:inherit; pointer-events:none;
  background-image:linear-gradient(190deg, transparent 0 34%, var(--glint) 50%, transparent 66% 100%);
  background-size:100% 220%; background-repeat:no-repeat; background-position:0 -120%;
  animation:pathGoldGlint 1.1s linear var(--glint-delay, 0ms) 1 both; }
@keyframes pathGoldGlint { from { background-position:0 -120%; } to { background-position:0 220%; } }

/* The owner's q7: "small little glint sparkles that are not too distracting
   and more subtle ... constantly twinkling -- maybe only 2-5 per node".
   Four-point stars, nine pixels, dark most of their cycle, each at its own
   offset (map.js). A white core with a gold rim, so they read on the dark
   ground and the light one alike. Decoration only: `aria-hidden`, no pointer.
   #2300, the owner: "slow down a bit and not all play at the same time on all
   nodes ... slightly more subtle and more random in terms of the locations."
   The cycle is slower (2.6s -> 4.2s) with a longer rest before a dimmer,
   smaller peak (.92 -> .55 opacity, scale(1) -> scale(.8)); `--sx`/`--sy`/
   `--sparkle-delay` are map.js's seeded-per-node position and phase, set once
   per dot as custom properties rather than as `style.left`/`style.top`
   directly, so this rule is what actually places and times each one. */
.path-sparkles { position:absolute; top:0; left:8px; width:88px; height:88px;
                 pointer-events:none; }
.path-sparkles.is-zoom { left:0; width:104px; height:104px; }
.path-sparkles.is-button { left:0; top:0; width:100%; height:100%; }
.path-sparkle { position:absolute; left:var(--sx, 50%); top:var(--sy, 50%);
                width:9px; height:9px; margin:-4.5px 0 0 -4.5px; opacity:0;
                background:radial-gradient(circle, #fffbe8 0 26%, #f0c24a 56%, #a8791a 84%);
                clip-path:polygon(50% 0, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0 50%, 39% 39%);
                animation:pathSparkle 4.2s ease-in-out var(--sparkle-delay, 0ms) infinite; }
@keyframes pathSparkle {
  /* Upright at its brightest: a star turned 45 degrees at the peak read as a
     small close-cross in the 320px render. Rest now runs to 72% of the cycle
     (was 60%), and the peak itself is dimmer and smaller (was .92 / scale(1)). */
  0%, 72%, 100% { opacity:0; transform:scale(.4) rotate(-45deg); }
  84%           { opacity:.55; transform:scale(.8) rotate(0deg); }
  93%           { opacity:.12; transform:scale(.5) rotate(30deg); }
}
@media (prefers-reduced-motion: reduce) {
  .path-glint::after, .path-medal.is-new::after { animation:none; }
  .path-medal.is-new { animation:none; }
  .path-sparkle { animation:none; opacity:.7; }
}

.path-medals { font-size:12px; letter-spacing:1px; margin-top:5px; height:14px;
               color:var(--accent); }
.path-medals.none { color:var(--line); }
.path-label { font-size:11.5px; line-height:1.25; text-align:center; margin-top:2px;
              max-width:104px; color:var(--muted); }
.path-tile.current + .path-medals + .path-label { color:var(--ink); font-weight:600; }
.path-tile.is-gold ~ .path-label { color:var(--accent); font-weight:600; }

/* One-shot unlock ring, the same animation the old map used
   (`unlockRing`/`--ok-halo`, defined above this block). */
.path-cell.path-unlocking .path-tile { animation:unlockRing 520ms var(--ease-out) 1; }
@media (prefers-reduced-motion: reduce) {
  .path-cell.path-unlocking .path-tile { animation:none; }
}

/* #2214: a ? tile, the row directly below what the learner has revealed.
   Dashed like a locked tile and emptier: no glyph, label, medals or footer,
   only the mark, because the server sent nothing else. A real button, so it
   keeps the pointer: its tap zooms into what reveals it. The look
   is the prototype's (docs/demos/2026-09-14-node-zoom-2215/). */
.path-tile.mystery { background:transparent; border-style:dashed; box-shadow:none;
                     color:var(--faint); font-family:inherit; font-size:30px;
                     font-weight:600; line-height:1; }
.path-edge-path.to-mystery { stroke-dasharray:3 5; }
/* The turn-over, once, when a ? tile comes back revealed: the prototype's
   quarter-turn flip, with the unlock ring folded into its tail so there is one
   animation and one animationend (map.js's `revealingKeys`). */
.path-cell.path-revealing .path-tile { animation:pathRevealTurn 900ms var(--ease-out) 1 both; }
@keyframes pathRevealTurn {
  0%   { transform:perspective(400px) rotateY(90deg); opacity:.2; box-shadow:0 0 0 0 var(--ok-halo); }
  45%  { transform:none; opacity:1; box-shadow:0 0 0 0 var(--ok-halo); }
  80%  { box-shadow:0 0 0 8px transparent; }
  100% { transform:none; opacity:1; }
}
@media (prefers-reduced-motion: reduce) {
  .path-cell.path-revealing .path-tile { animation:none; }
}

/* ---- #2227: skill offshoots ----
   A game hangs off its subject as a smaller hexagon with a row of unit icons
   (one per gold unit it needs) and its name. Waiting: a dashed grey outline,
   not a control. Earned: the silver metal, a drop shadow that makes it read as
   a button, a glint as the path opens and two white sparkles. Geometry is the
   prototype's (docs/demos/2026-09-14-skills-offshoot-2227/), measured there at
   320px: a wing beside a single subject is 84px, the width of one skill. */
.path-row.has-wings { display:grid; grid-template-columns:minmax(0,1fr) 104px minmax(0,1fr);
                      column-gap:8px; }
/* #2345: 88px, which is the tile's own height (`.path-tile` above), so a
   wing's first skill BEGINS where the tile ends. It was 14px, which centred
   the hex on the tile and left the connector nothing to run down to -- the
   link came out of the tile's side and went flat into the hex's side, while
   a strip skill's link already came out, turned, and dropped. One shape for
   both is the owner's ask on #2345 ("that hook to come out the side and
   curve down"), and this is the layout half of it: at 88px the bend's
   quarter-circle ends 25px above the hex, which is the straight run he is
   describing. Not a one-off margin on a neighbour -- it is the wing's own
   padding, the property that already placed its content (#2355). */
.path-wing { display:flex; flex-direction:column; align-items:center; padding-top:88px; min-width:0; }
.path-wing .path-offshoot + .path-offshoot { margin-top:20px; }
/* The wing makes the row taller than its tile: the spine runs on down the cell.
   No `min-height:0`: an empty pseudo-element has no content to shrink below, and
   only `.stage` may yield /study's column (#1124). */
.path-cell.is-through::after { content:""; flex:1 1 auto; margin-top:4px;
                               border-left:2px solid var(--line); }
.path-cell.is-through.to-mystery::after { border-left-style:dashed; }
/* #2281: the pair's two branches have already joined (map.js draws the merge
   directly under the pair), so ONE line runs down the centre of the strip,
   between the two slots, rather than one under each subject. */
.path-offband { position:relative; display:flex; justify-content:center; gap:22px; padding-top:8px; }
.path-offslot { width:104px; flex:0 0 auto; display:flex; flex-direction:column;
                align-items:center; }
.path-offband.is-through::before { content:""; position:absolute; left:calc(50% - 1px); top:0; bottom:0;
                                   border-left:2px solid var(--line); }
.path-offband.is-through.to-mystery::before { border-left-style:dashed; }
/* In the strip a skill sits under its subject's own edge, on its own side. */
.path-offband .path-offshoot.is-left { transform:translateX(-66px); }
.path-offband .path-offshoot.is-right { transform:translateX(66px); }
.path-offband .path-offshoot + .path-offshoot { margin-top:20px; }
.path-offlinks { position:absolute; left:0; top:0; width:100%; height:100%; overflow:visible;
                 pointer-events:none; }
.path-offlink { fill:none; stroke:var(--line); stroke-width:2; stroke-linecap:round;
                stroke-linejoin:round; stroke-dasharray:3 5; }
.path-offlink.is-earned { stroke:var(--silver-edge); stroke-dasharray:none; }

.path-offshoot { position:relative; display:flex; flex-direction:column; align-items:center; width:84px;
                 color:var(--known); text-decoration:none; }
/* The owner, 2026-09-25, reversing #2227's q4: every skill, earned or waiting,
   is a <button> that zooms into it -- so the UA's button box comes off, and the
   look is exactly the link's and the outline's it replaced. */
.path-offshoot { background:none; border:0; padding:0; margin:0; font:inherit;
                 cursor:pointer; -webkit-tap-highlight-color:transparent; }
.path-offshoot:focus-visible { outline:2px solid var(--control); outline-offset:3px; border-radius:12px; }
.path-hex { position:relative; display:block; width:52px; height:60px; flex:0 0 auto; }
.path-hex-shape { position:absolute; inset:0; width:100%; height:100%; overflow:visible; }
.path-hex-shape polygon { fill:transparent; stroke:var(--faint); stroke-width:1.5; stroke-dasharray:4 3;
                          stroke-linejoin:round; }
.path-hex .path-ic { position:absolute; left:50%; top:50%; width:24px; height:24px; margin:-12px 0 0 -12px;
                     fill:none; stroke:currentColor; stroke-width:1.6; stroke-linecap:round;
                     stroke-linejoin:round; }
.path-hex-fill { position:absolute; inset:0; background:var(--silver-sheen);
                 clip-path:polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%); }
.path-offshoot.is-earned { color:var(--silver-ink); }
.path-offshoot.is-earned .path-hex { filter:drop-shadow(0 2px 1.5px rgba(0,0,0,.30)) drop-shadow(0 5px 7px rgba(0,0,0,.22)); }
.path-offshoot.is-earned .path-hex-shape polygon { stroke:none; }
.path-offshoot.is-earned .path-hex .path-ic { stroke-width:1.8;
  filter:drop-shadow(0 0 1.1px var(--silver-halo)) drop-shadow(0 0 .5px var(--silver-halo)); }
.path-offshoot:active .path-hex { transform:translateY(1px);
  filter:drop-shadow(0 1px 1px rgba(0,0,0,.30)); }
/* A unit icon: empty is an outline, gold is the same metal as a gold node. */
.path-offunits { display:flex; gap:3px; height:10px; margin-top:5px; }
/* #2641: a two-part gate's rows, one per requirement. #2685 (the owner,
   reviewing #2682's own screenshot): STACKED, not side by side -- "have the
   gold on one line, then the colour circles" -- so this is a COLUMN, gold
   first (`unitsFirst` in map.js already orders `entry.parts` that way),
   centred under the hexagon exactly like the Skills tile's own requirement
   list already reads top to bottom. `height` moves from the fixed 10px (one
   icon row's own height) to `auto` -- a part naming another story
   (`.path-offpart` below) is now TALLER than a bare icon row, and a fixed
   10px box would let that caption overflow past `.path-offlabel` (the
   game's name, positioned by this element's flex box, not by its
   overflowed content) instead of pushing it down. A one-part offshoot never
   reaches `.is-parts` at all (see `offshootUnits` in map.js), so it is
   untouched. */
.path-offunits.is-parts { flex-direction:column; align-items:center; gap:4px; height:auto; }
.path-offunits.is-parts .path-offunits { margin-top:0; }
/* #2682: the part read off a DIFFERENT story than the one this offshoot
   hangs beside (Build it, beside A Short List, whose second part reads
   Somebody Has to Count) -- its icon row plus a short caption naming that
   story, stacked so the caption sits directly under just that one row and
   never under the part that matches the offshoot's own story. Centred to
   match every other line under a skill (`.path-offlabel`); no fixed width,
   so a longer subject name wraps rather than pushing the hexagon wider. */
.path-offpart { display:flex; flex-direction:column; align-items:center; max-width:80px; }
.path-offpart-name { display:block; font-size:8.5px; line-height:1.15; margin-top:3px;
                      color:var(--muted); text-align:center; }
.path-offunit { display:block; box-sizing:border-box; width:10px; height:10px; border-radius:2.5px;
                border:1.5px solid var(--faint); background:transparent; }
.path-offunit.is-gold { background:var(--gold-sheen); border-color:var(--gold-edge);
                        box-shadow:0 0 5px rgba(227,171,46,.5); }
/* #2412: a WORD icon (a `words_known_in_list` gate) is a round, so it cannot be
   read as a gold unit's square -- the owner read Name the colour's six squares
   as "locked behind SIX units". Same 10px box, so the row does not move.
   Untinted (a future word list), known is a plain filled disc. A colour list
   tints each ring with `--tint` (map.js, from the payload's `tints`: the colour
   game's own neon fills): an outline in the tint until the word is known, then
   filled. Neon reads on this dark ground as it does on the colour board's
   plate (4.9:1 purple to 15.4:1 yellow on `--ground`); the light theme
   darkens the EDGE (below) so a yellow ring still shows on cream: 58% of the
   tint measured 3.33:1 yellow, 3.53:1 green, 5.3-8.0:1 the rest, all over
   the 3:1 non-text floor (62% left yellow at 2.93). */
.path-offunit.is-word { border-radius:50%; }
.path-offunit.is-word.is-known { background:var(--known); border-color:var(--known); }
.path-offunit.is-word.is-tinted { --tint-edge:var(--tint); border-color:var(--tint-edge); }
.path-offunit.is-word.is-tinted.is-known { background:var(--tint);
  box-shadow:0 0 4px color-mix(in srgb, var(--tint) 55%, transparent); }
@media (prefers-color-scheme: light) {
  .path-offunit.is-word.is-tinted { --tint-edge:color-mix(in srgb, var(--tint) 58%, #000); }
  .path-offunit.is-word.is-tinted.is-known { box-shadow:none; }
}
/* #2617: the in-app theme override. `html[data-theme="X"] SELECTOR` rather
   than a bare `:root` -- the attribute lives on `:root`, not on this
   selector's own element, so the ancestor form is what carries the same
   specificity win (`html[data-theme=...]` alone already outranks a plain
   `.path-offunit...` rule and an `@media` one alike; prefixing it here
   keeps that win while still only matching THIS selector, the same as
   every host page's `:root[data-theme=...]` custom-property pair does for
   `:root` itself -- here the ancestor is `html` instead of `:root` so this
   rule's own selector text does not start with the literal substring
   `:root[data-theme="` that `tests/test_contrast_sweep.py`'s synonym guard
   keys on to find a REAL `:root`-level override; `html` and `:root` name
   the identical element in an HTML document, so nothing about the
   selector's real behaviour changes.
   `:root` itself). Dark restates the base rule two blocks up (this page's
   default); light is the `@media` block just above, unchanged. */
html[data-theme="dark"] .path-offunit.is-word.is-tinted { --tint-edge:var(--tint); }
html[data-theme="dark"] .path-offunit.is-word.is-tinted.is-known { box-shadow:0 0 4px color-mix(in srgb, var(--tint) 55%, transparent); }
html[data-theme="light"] .path-offunit.is-word.is-tinted { --tint-edge:color-mix(in srgb, var(--tint) 58%, #000); }
html[data-theme="light"] .path-offunit.is-word.is-tinted.is-known { box-shadow:none; }
/* 2026-09-25, the coordinator relaying the owner: a `rounds_played` gate's
   icon row read as "needs five GOLD" -- the same #2412 confusion a word's
   round icon exists to avoid, one requirement kind later. A diamond (the
   unit square, rotated 45deg), never gold, so "games" can never be mistaken
   for a gold-unit count at a glance. */
/* A 10px square turned 45deg is ~14px tall in a 10px row, and its bottom point
   was clipped (the owner, 2026-09-25). 7px turned is 9.9px: it fits the row,
   and the margin keeps the row's spacing. */
.path-offunit.is-game { width:7px; height:7px; margin:1.5px; transform:rotate(45deg); border-radius:1.5px; }
/* #2641: a `story_star` part -- ONE star glyph, the story's special star (the
   Burger stand waits on the burger story's words star, the owner's call in
   place of nine word circles). No box: the glyph IS the icon, in the row's
   own 10px height, muted until earned and the gold ink once it is. */
.path-offunit.is-star { width:auto; height:10px; border:0; border-radius:0; background:none;
                        font-size:11px; line-height:10px; color:var(--faint); }
.path-offunit.is-star.is-earned { color:var(--gold-edge); }
.path-offunit.is-game.is-played { background:var(--control); border-color:var(--control);
  box-shadow:0 0 5px color-mix(in srgb, var(--control) 50%, transparent); }
/* #2432 SKILLS-B2: a `keys_toll`'s own icon in the row, appended after
   whatever the gate's OTHER requirement already draws (games, gold units,
   words) -- its own shape again, the key glyph `keysAmount` already draws
   elsewhere in this file, so Keys read as Keys rather than as a third kind
   of square. Outline until paid, filled solid ink once it is -- neither
   gold (a unit) nor the game diamond's blue, so a row that ever carries
   both a games icon and a Keys icon still reads as two different things. */
.path-offunit.is-key { display:flex; align-items:center; justify-content:center; }
.path-offunit.is-key .path-ic-sm { width:7px; height:7px; flex:0 0 7px; stroke:var(--faint); }
.path-offunit.is-key.is-played { border-color:var(--ink); }
.path-offunit.is-key.is-played .path-ic-sm { stroke:var(--ink); }
/* Two lines reserved, so a name that wraps does not move the path below it. */
.path-offlabel { display:block; font-size:10.5px; line-height:1.22; text-align:center; margin-top:4px;
                 max-width:80px; min-height:2.44em; color:var(--muted); }
.path-offshoot.is-earned .path-offlabel { color:var(--ink); font-weight:600; }

/* #2432 SKILLS-B1: a gate ON the spine -- the offshoot's own hexagon and icon
   row (#2227), centred in a spine cell like a story tile instead of hanging
   off one's side. Resets the square tile's border/background/radius from
   `.path-tile` entirely; only the hexagon and its outline carry the look, the
   same way `.path-offshoot` resets it for a wing/band skill. */
/* #2607 (second pass): `overflow:visible` too. `.path-tile`'s `overflow:hidden`
   clips a square tile's own face, but a gate's return FLIP scales and moves
   the `.path-hex` INSIDE this button (an offshoot's is not a `.path-tile`, so
   it never had the clip) -- measured in Chrome, the large hexagon shrinking
   home showed only its bottom tip, the rest cut off at the button's box. */
.path-tile.path-gate { width:84px; height:auto; border:0; border-radius:0;
  background:none; box-shadow:none; padding:0; display:flex; flex-direction:column; align-items:center;
  overflow:visible; }
.path-tile.path-gate:active .path-hex { transform:translateY(1px); }
.path-tile.path-gate .path-ic { position:absolute; left:50%; top:50%; width:24px; height:24px;
  margin:-12px 0 0 -12px; }
/* Decision 3, the owner (2026-09-25): "a clear stop... a red outline on the
   hexagon while it blocks." */
.path-tile.path-gate.is-blocking .path-hex-shape polygon { stroke:var(--no); stroke-width:2;
  stroke-dasharray:none; }
.path-tile.path-gate.is-earned { color:var(--silver-ink); }
.path-tile.path-gate.is-earned .path-hex { filter:drop-shadow(0 2px 1.5px rgba(0,0,0,.30)) drop-shadow(0 5px 7px rgba(0,0,0,.22)); }
.path-tile.path-gate.is-earned .path-hex-shape polygon { stroke:none; }
.path-tile.path-gate.current .path-hex-shape polygon { stroke:var(--control); stroke-width:2.5;
  stroke-dasharray:none; }
.path-tile.path-gate .path-offunits { margin-top:5px; }

/* #2608: the owner, 2026-09-25 -- "Instead of a pink square can it be a pink
   hexagon to match the skill?" `shared/tutorials.js`'s tutorial nudge (#2528)
   adds `.tutorial-pulse` straight onto the skill's own BUTTON (`app/data/
   tutorials.json`'s "open-a-skill" tutorial: `.path-gate.is-blocking`,
   `.path-offshoot.is-earned`), and `shared/tutorials.css`'s `.tutorial-pulse` draws
   the "tap this" ring as a plain `outline`/`box-shadow` on that button's
   bounding box -- exactly right for a square story tile (the OTHER move,
   `path-and-node`, pulses `.path-tile.current`, a real square) but wrong
   here: the gate/offshoot button is taller/wider than the hexagon inside it
   (the unit-icon row below, and an offshoot's own label, share the same
   button box), so the ring read as a plain rectangle around the hexagon.
   `shared/tutorials.css` is deliberately generic (no opinion on any one page's
   shape) and stays untouched -- scoped here instead: cancel the generic
   ring on exactly these two buttons and pulse the SAME pink along the
   hexagon's own SVG polygon (`.path-hex-shape`, already in the DOM -- the
   one true hex outline every state already draws) instead. Every state the
   registry can pulse is spelled out at matching-or-higher specificity, the
   way the niqqud/`.faded-points` rule above beats its collision, rather
   than reaching for `!important`. */
.path-gate.tutorial-pulse, .path-offshoot.tutorial-pulse { outline:none; box-shadow:none; animation:none; }
.path-tile.path-gate.is-blocking.tutorial-pulse .path-hex-shape polygon,
.path-tile.path-gate.is-earned.tutorial-pulse .path-hex-shape polygon,
.path-offshoot.is-earned.tutorial-pulse .path-hex-shape polygon,
.path-offshoot.tutorial-pulse .path-hex-shape polygon {
  stroke:#FF2E93; stroke-width:3; stroke-dasharray:none;
  animation:path-hex-pulse 1.2s ease-out infinite;
}
@keyframes path-hex-pulse {
  0% { stroke-opacity:1; }
  70% { stroke-opacity:.35; }
  100% { stroke-opacity:1; }
}
@media (prefers-reduced-motion: reduce) {
  .path-tile.path-gate.is-blocking.tutorial-pulse .path-hex-shape polygon,
  .path-tile.path-gate.is-earned.tutorial-pulse .path-hex-shape polygon,
  .path-offshoot.is-earned.tutorial-pulse .path-hex-shape polygon,
  .path-offshoot.tutorial-pulse .path-hex-shape polygon { animation:none; }
}

/* ---- #2854: every node an isometric object on its own plinth ----
   The owner, 2026-09-30, after four review rounds on
   `docs/demos/2026-09-30-iso-nodes/` ("ship it", Iso A): each story tile and
   skill hexagon stands as a small drawing on a slate plinth, in the trophy art's
   language. `shared/iso_art.js` draws it; map.js puts it inside the tile / hexagon
   it already builds, adds `has-iso`, and everything else stays: the name, the
   medals, the unit icons, the connector lines, the current ring, the lock, the
   tap target, the zoom's FLIP (which measures the button's box). The flat glyph,
   the tile's face and the hexagon's metal stay in the DOM and are hidden here, so
   a page without the module (`has-iso` never added) draws exactly the flat path.
   The ink rules are progress.css's `.trophy-art` and trophy_hero.css's, restated
   under `.path-iso`: `.nos` parts carry no outline, `.thin` a fine one.
   States: locked is desaturated with a padlock; a finished story stands on a gold
   plinth and an earned skill on a silver one (the module picks the metal); the
   current node wears a blue ring under its plinth. */
/* #2932: `.path-deco` is the level-5 decoration (a station's own pieces and tufts of grass round its
   plinth): the same box, projection and ink as the drawing, in two layers (`is-back` behind it,
   `is-front` over it). It is NOT `.path-iso`, so the connectors (which stop short of the drawing's top)
   never count it; absolutely positioned and `pointer-events:none`, so it moves and takes nothing. A
   tuft's blades carry their own darker-green outline inline, which beats the ink below. */
.path-iso, .path-deco { display:block; overflow:visible; pointer-events:none; position:absolute;
            left:50%; bottom:0; transform:translateX(-50%); }
.path-iso .t polygon, .path-iso .t path, .path-iso .t ellipse,
.path-iso .t circle, .path-iso .t rect,
.path-deco .t polygon, .path-deco .t ellipse { stroke:#16181C; stroke-width:3.4;
                                         stroke-linejoin:round; stroke-linecap:round; }
.path-iso .t .thin { stroke-width:2.2; }
.path-deco .t .nos,
.path-iso .t .nos { stroke:none; }
.path-iso .t text { stroke:none; }
/* Locked is ALL grey, drawing and plinth alike (owner, 2026-09-30). The filter sits on
   the <svg> itself, not on the `.t` group inside it: WebKit (every iPhone browser)
   ignores a CSS filter on an inner SVG element, so on his phone only the slate plate
   read as grey while the object kept its colours. */
.path-iso.is-locked { filter:grayscale(1) brightness(.78) contrast(.9); opacity:.8; }
.path-iso .lockbadge circle { fill:var(--surface); stroke:var(--faint); stroke-width:1.6; }
.path-iso .lockbadge path { fill:none; stroke:var(--known); stroke-width:2.2;
                            stroke-linecap:round; stroke-linejoin:round; }

/* A story: the button is the object's box (88 wide, the plinth's width), tall
   enough for the tallest drawing to stand inside it. */
.path-tile.has-iso { height:120px; border:0; border-radius:22px; background:none;
                     box-shadow:none; overflow:visible; }
.path-tile.has-iso > .path-ic:not(.path-opensbadge),
.path-tile.has-iso > .path-foot { display:none; }
.path-tile.has-iso .path-iso { width:106px; height:120px; }
.path-tile.has-iso:active .path-iso { transform:translateX(-50%) translateY(1px); }
.path-tile.has-iso.path-glint::after { display:none; }
.path-tile.has-iso ~ .path-sparkles { height:120px; }
.path-tile.has-iso ~ .path-medals { margin-top:2px; }
/* The "opens a game" mark: the tile's corner is gone, so it sits on the plinth's right. */
.path-tile.has-iso .path-opensbadge { top:auto; bottom:30px; right:-4px; width:20px; height:20px;
                                      border-radius:50%; }
.path-tile.has-iso .path-opensbadge.path-ic { width:20px; height:20px; padding:4px; box-sizing:border-box; }

/* #2994: the node's build-out. The decoration is level-gated (iso_art.js `decoLayer(key, layer, level)`), and the zoom lays a square
   lawn under it (`.lawn`, in the back layer). Each piece is a `.deco-it` group carrying its arrival order in `--i`; while the hexagon
   wears `is-building` (map.js `buildOut`, after the zoom's FLIP in, or the tile's FLIP home) the lawn grows out and the pieces
   pop up one by one from their own floor point on the app's spring, the way the LinkedIn promo's island fills. `--deco-base` is the FLIP's
   duration, so nothing starts until the node has landed. The node's own settle is not here: it is the overshoot inside the FLIP
   itself (map.js `flipFrom`, #3120), so the node lands in one motion. Nothing under reduced motion: map.js does not add the class, and the rule below
   stands down anyway, so the final state is simply there. The lawn's slab is ink-outlined thinner than a piece. */
.path-deco .t .lawn polygon { stroke-width:2.2; }
.path-deco .deco-it { transform-box:fill-box; transform-origin:50% 100%; }
.path-deco .lawn { transform-box:fill-box; transform-origin:50% 50%; }
.path-hex.is-building .path-deco .lawn { animation:deco-lawn .5s cubic-bezier(.2,.7,.3,1) backwards;
                                         animation-delay:var(--deco-base, 0s); }
.path-hex.is-building .path-deco .deco-it { animation:deco-pop .38s cubic-bezier(.34,1.56,.64,1) backwards;
                                            animation-delay:calc(var(--deco-base, 0s) + .3s + var(--i, 0) * var(--deco-step, .06s)); }
@keyframes deco-lawn { from { opacity:0; transform:scale(.25); } to { opacity:1; transform:none; } }
@keyframes deco-pop { 0% { opacity:0; transform:translateY(-14px) scale(.15); } 55% { opacity:1; } 100% { opacity:1; transform:none; } }
/* The zoom's lawn reaches past the hero's box: below it, so the title is pushed down by what it takes (map.js marks the hero). */
.path-zoom-hero.has-lawn { margin-bottom:26px; }
@media (prefers-reduced-motion: reduce) {
  .path-hex.is-building .path-deco .lawn, .path-hex.is-building .path-deco .deco-it { animation:none; }
}

/* A skill: the hexagon's box becomes the object's. The hexagon's outline is kept
   only for the Tutorials pulse (`.tutorial-pulse`, below), which rings it in pink. */
.path-hex.has-iso { width:82px; height:94px; }
.path-hex.has-iso .path-iso, .path-hex.has-iso .path-deco { width:82px; height:94px; }
.path-hex.has-iso > .path-hex-shape,
.path-hex.has-iso > .path-hex-fill,
.path-hex.has-iso > .path-ic { display:none; }
.path-offshoot.tutorial-pulse .path-hex.has-iso > .path-hex-shape,
.path-tile.path-gate.tutorial-pulse .path-hex.has-iso > .path-hex-shape { display:block; }
.path-offshoot.is-earned .path-hex.has-iso,
.path-tile.path-gate.is-earned .path-hex.has-iso { filter:none; }
.path-offshoot:active .path-hex.has-iso .path-iso,
.path-offshoot:active .path-hex.has-iso .path-deco,
.path-tile.path-gate:active .path-hex.has-iso .path-iso { transform:translateX(-50%) translateY(1px); }
.path-tile.path-gate .path-hex.has-iso { margin-top:0; }

/* Fewer and whiter than a gold node's (q3), in the hexagon's empty corners.
   `position:absolute` is restated beside the centring margin (`.path-sparkle`
   already sets it): #1300's sweep reads a negative margin as in-flow unless the
   same rule says the box is out of flow, and this one is. */
.path-sparkles.is-silver { left:0; top:0; width:52px; height:60px; }
.path-sparkles.is-silver .path-sparkle { position:absolute; width:7px; height:7px; margin:-3.5px 0 0 -3.5px;
  background:radial-gradient(circle, #ffffff 0 36%, #f7eef2 62%, #b8a6ac 90%); }

/* ---- #2283: zooming out grows the path back from the node, like a vine ----
   map.js holds every piece but the origin back (`path-vine-wait`) and lets it
   go by path distance (`path-vine-grow`), the locked and ? pieces together at
   the end (`path-vine-late`), all under `.is-vining` on the map, which comes
   off when it is done or the learner taps. Only opacity, transform and
   clip-path are animated: nothing here changes a box's size or place, so the
   page never moves. A node grows from its own centre; a connector or a
   through-line is drawn out from the end nearest the origin (`is-vine-up`
   above it). A cell's children are held back, not the cell, so its
   `is-through` line keeps its own timing. */
.path-map.is-vining .path-vine-wait:not(.path-cell),
.path-map.is-vining .path-cell.path-vine-wait > *,
.path-map.is-vining .path-cell.is-through.path-vine-line-wait::after,
.path-map.is-vining .path-offband.is-through.path-vine-line-wait::before { opacity:0; }
.path-map.is-vining .path-cell.path-vine-grow > *,
.path-map.is-vining .path-offshoot.path-vine-grow > * { animation:pathVineNode 260ms var(--ease-out) both; }
.path-map.is-vining .path-heading.path-vine-grow,
.path-map.is-vining .path-week.path-vine-grow,
.path-map.is-vining .path-offlink.path-vine-grow,
.path-map.is-vining .path-vine-late:not(.path-cell),
.path-map.is-vining .path-cell.path-vine-late > *,
.path-map.is-vining .path-cell.is-through.path-vine-line-late::after,
.path-map.is-vining .path-offband.is-through.path-vine-line-late::before { animation:pathVineFade 200ms linear both; }
.path-map.is-vining .path-edge.path-vine-grow,
.path-map.is-vining .path-cell.is-through.path-vine-line-grow::after,
.path-map.is-vining .path-offband.is-through.path-vine-line-grow::before { animation:pathVineDown 260ms var(--ease-out) both; }
.path-map.is-vining .path-edge.path-vine-grow.is-vine-up,
.path-map.is-vining .path-cell.is-through.is-vine-up.path-vine-line-grow::after,
.path-map.is-vining .path-offband.is-through.is-vine-up.path-vine-line-grow::before { animation-name:pathVineUp; }
@keyframes pathVineNode { from { opacity:0; transform:scale(.55); } to { opacity:1; transform:none; } }
/* #2896: while `drawOffshootLinks` measures (synchronously, so nothing paints under
   this), the vine's own scale is taken off the two things it scales, so a connector is
   drawn against the box the tile will have at rest and never against one mid-grow.
   `!important` is what beats a running CSS animation; the animation is not restarted. */
.path-map.is-measuring .path-cell.path-vine-grow > *,
.path-map.is-measuring .path-offshoot.path-vine-grow > * { transform:none !important; }
@keyframes pathVineFade { from { opacity:0; } to { opacity:1; } }
@keyframes pathVineDown { from { clip-path:inset(0 0 100% 0); } to { clip-path:inset(-4px); } }
@keyframes pathVineUp { from { clip-path:inset(100% 0 0 0); } to { clip-path:inset(-4px); } }
/* map.js never starts the vine under reduced motion; restated here per this
   file's convention, so a stray `.is-vining` would still draw at once. */
@media (prefers-reduced-motion: reduce) {
  .path-map.is-vining .path-vine-wait:not(.path-cell),
  .path-map.is-vining .path-cell.path-vine-wait > *,
  .path-map.is-vining .path-cell.is-through.path-vine-line-wait::after,
  .path-map.is-vining .path-offband.is-through.path-vine-line-wait::before { opacity:1; }
  .path-map.is-vining .path-cell.path-vine-grow > *,
  .path-map.is-vining .path-offshoot.path-vine-grow > *,
  .path-map.is-vining .path-edge.path-vine-grow,
  .path-map.is-vining .path-offlink.path-vine-grow,
  .path-map.is-vining .path-vine-late:not(.path-cell),
  .path-map.is-vining .path-cell.path-vine-late > *,
  .path-map.is-vining .path-cell.is-through.path-vine-line-grow::after,
  .path-map.is-vining .path-offband.is-through.path-vine-line-grow::before,
  .path-map.is-vining .path-cell.is-through.path-vine-line-late::after,
  .path-map.is-vining .path-offband.is-through.path-vine-line-late::before { animation:none; }
}

/* ---- #2215: the zoom, in place of the old inline detail box ----
   The owner's q3 `goals_only`: the goals, the button and the Keys. Geometry
   and type from the prototype (docs/demos/2026-09-14-node-zoom-2215/), in
   this file's own tokens. Drawn in the stage where the path was, so the
   bottom navigation and `.phone`'s scrolling are the page's own. The detail
   box's rules (`.path-detail*`, `.path-notch`, the star ladder and #2031's
   per-section list) left with the box. */
/* #2332: the outgoing half of the zoom. `takePath` (study/map.js) lifts the
   old `.path-map` into this box, fixed at the rect it was measured at, and
   scales it past the camera from the tapped tile's centre while the large node
   grows out of that same tile. The owner: the zoom "reads as a glitch" --
   measured against docs/demos/2026-09-14-node-zoom-2215, the durations were
   4% apart and the MISSING piece was this, the path being cut between two
   frames instead of leaving. `inset:0` and NOT a measured box: #1728 keeps
   every script in `study/` out of the placement business, so the layer is
   sized here and map.js carries the difference in its keyframes instead.
   `pointer-events:none` because it is scenery that outlives the tap that made
   it, and `z-index:1` keeps it over the stage and far under the button bar
   (shared/nav.css runs at 2147483000). It is removed when the animation ends;
   `overflow:visible` so a tile's glow is not clipped as it grows past the box.
   Never created under reduced motion (map.js). */
.path-zoom-ghost { position:fixed; inset:0; pointer-events:none; z-index:1;
                   overflow:visible; will-change:transform, opacity; }

/* #2273, the owner (report mu2jbwqa): "I'd like the contents of this page to
   fit the phone without scrolling ... Can path and the award count at the top
   be removed? Instead put a drop shadow on the main Node". The `‹ Path` row
   (44px) went, the node shrank 132 -> 104px, and the goal cards below were
   compacted; measured at 393x852 and 320x568 in PR r2273's renders. */
.path-zoom { display:flex; flex-direction:column; align-items:stretch; padding:0 0 4px; }
/* #2933, the owner: "the main icon ... could be a bit higher ... get back some of the room for the
   page below". The hero (story and skill zoom alike) rides 14px higher: margin-top 4px -> -10px. The
   iso drawing has its own empty band at the top of its box, so it still hangs below the top edge. */
.path-zoom-hero { position:relative; align-self:center; width:104px; height:104px;
                  /* #2933's 14px lift, WITHOUT a negative block-start margin (the first-line anchor
                     guard bans those on in-flow boxes): `top` moves the drawing up 10px and the
                     equal negative block-END margin hands those 10px back to the page below.
                     Seen from outside it is the old `margin-top:-10px` exactly. */
                  margin:0 0 -10px; top:-10px; }
/* The lift says "press me": a ledge in the theme's own line colour under the
   node plus a soft drop shadow, in EVERY state -- a locked, planned or ? node
   is still the way back, so none of them drops the shadow any more. Pressed,
   the node sinks onto its ledge. */
.path-zoom-node { width:104px; height:104px; border-radius:26px; border:1px solid var(--line);
                  background:var(--surface); color:var(--ink);
                  box-shadow:0 3px 0 var(--line), 0 10px 24px rgba(0,0,0,.24);
                  display:flex; align-items:center; justify-content:center; position:relative;
                  overflow:hidden; padding:0; cursor:pointer; }
.path-zoom-node:active { transform:translateY(2px);
                         box-shadow:0 1px 0 var(--line), 0 4px 10px rgba(0,0,0,.2); }
.path-zoom-node:focus-visible { outline:2px solid var(--control); outline-offset:4px; }
.path-zoom-node .path-ic { width:40px; height:40px; fill:none; stroke:currentColor;
                           stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round; }
.path-zoom-node .path-foot { height:8px; }
.path-zoom-node.done { border-color:var(--ok); color:var(--ok); }
.path-zoom-node.in_progress { border-color:var(--accent); color:var(--accent); }
.path-zoom-node.locked { background:var(--ground); border-style:dashed;
                         color:var(--known); }
.path-zoom-node.planned { background:var(--ground); border-style:dotted;
                          color:var(--faint); }
.path-zoom-node.mystery { background:var(--ground); border-style:dashed;
                          color:var(--faint); font-family:inherit; font-size:40px;
                          font-weight:600; line-height:1; }
.path-zoom-node.done .path-slice-fill { background:var(--ok); }
/* Gold wins over the node's own state colours. Found by rendering: a done
   story's `.done` border, glyph and footer came later in this file at equal
   specificity and drew green on the gold metal. These two come after them. */
.path-zoom-node.is-gold { border-color:var(--gold-edge); color:var(--gold-ink);
                          box-shadow:0 3px 0 var(--gold-edge), 0 0 18px rgba(227,171,46,.45), 0 10px 24px rgba(0,0,0,.24); }
.path-zoom-node.is-gold .path-slice-fill { background:rgba(43,29,3,.42); }
/* #2230: the story's title line under this one (`.path-zoom-who`) is gone, on
   the owner's 2026-09-15 answer, so the title carries the gap to the units. */
.path-zoom-title { display:block; text-align:center; font:600 22px/1.2 var(--serif);
                   color:var(--ink); margin:10px 0 12px; }
.path-zoom-sub { display:block; text-align:center; font:400 13px/1.45 var(--serif);
                 color:var(--muted); margin:4px 0 12px; }

/* q5: the node's units, as many as its story has, gold once each is. Every
   unit is a real 44px target. #2312 took the per-unit fill bar out (a unit is
   played in one run, all or nothing) and #2313 removed its rules
   (`.path-unit-bar`/`-fill` and their is-read/is-gold variants), and the
   "Unit/Units" label's `.path-units-label`, which #2312 also stopped drawing. */
.path-units { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin:0 0 10px; }
.path-units-row { display:flex; flex:1 1 auto; gap:8px; min-width:0; }
/* #2859, the owner's pick (2026-09-30, "A ... except when something is chosen"): a
   unit is the plinth's flat metal SLAB -- a flat metal
   top, an ink outline, and a stack of one-pixel metal layers for the thickness (the
   same recipe the level pucks below use). The metal says what the unit is: slate
   until it is read, the skill's silver once read through, gold once gold, dark and
   pressed in while locked. The ink is the art's own on every top, in both themes. The
   CHOSEN one is not ringed: it carries a short blue line under its slab (the owner:
   "I prefer to just see the blue line under it rather than a circle like border").
   `--depth` is the resting shadow, so the Tutorials pulse (`path-depth-pulse`, below) can
   ring the control without taking its thickness away. */
.path-unit {
  --top:#8A93A8; --side:#454C60;
  --depth:0 0 0 2px #16181C,
    0 1px 0 var(--side), 0 2px 0 var(--side), 0 3px 0 var(--side), 0 4px 0 var(--side), 0 5px 0 var(--side),
    0 1px 0 2px #16181C, 0 2px 0 2px #16181C, 0 3px 0 2px #16181C, 0 4px 0 2px #16181C, 0 5px 0 2px #16181C;
  --pressed:0 0 0 2px #16181C, 0 1px 0 var(--side), 0 2px 0 var(--side), 0 1px 0 2px #16181C, 0 2px 0 2px #16181C;
  background:var(--top); color:#16181C; border:0; border-radius:10px; box-shadow:var(--depth);
  margin-bottom:16px; }
.path-unit:not(:disabled):active { transform:translateY(3px); box-shadow:var(--pressed); }
/* The blue line under the chosen one: clear of the slab's thickness (7px with its ink). */
.path-unit.is-sel::before {
  content:""; position:absolute; left:3px; right:3px; bottom:-14px; height:4px; border-radius:2px;
  background:var(--control); }
.path-unit { position:relative; flex:1 1 0; min-width:0; min-height:44px; padding:6px 2px 7px;
             font:inherit; text-align:center; display:flex; flex-direction:column; align-items:center;
             justify-content:center; gap:5px; cursor:pointer; }
.path-unit-num { font:600 13px/1 var(--mono); }
.path-unit.is-read { --top:#B7C1CF; --side:#646E85; }
.path-unit.is-gold { --top:#FFD772; --side:#B9821A; }
/* #2514: a unit whose predecessor is not read through yet: dark metal, pressed flat
   into the ground, its text in the light ink the dark slab needs (a hard lock, no
   "open anyway"). The same dark metal in both themes, like the art's. */
.path-unit.is-locked { --top:#3C4356; --side:#272C3A; --depth:var(--pressed); color:#B7C1CF;
                       cursor:default; transform:translateY(3px); }
/* #2917: the padlock sat BESIDE the label, which a story of seven units (each
   button ~44px wide at 390px) cannot afford: "Unit" ran out of the slab's right
   edge on Units 6 and 7. It stands ABOVE the label now, in the same column the
   unlocked slabs already stack their two words in, so the label has the full
   slab width. */
.path-unit.is-locked .path-unit-lock { width:12px; height:12px; flex-basis:12px; }
.path-unit-lock-note { display:block; flex-basis:100%; text-align:center;
                       font:400 12.5px/1.4 var(--serif); color:var(--muted); }
/* A node with one unit has nothing to choose between, so its one unit wears no
   selection line (found by rendering Greetings, one unit today). */
.path-unit:only-child.is-sel::before { display:none; }

/* The goals: a medal, the server's label and hint, a figure and a bar. Two
   columns; an odd last goal spans both, so three goals do not leave a hole.
   #2273 ("Can the awards take up less height?"): the medal sits BESIDE the
   label instead of above it, and the hint, figure and bar run the card's full
   width beneath -- a grid of two tracks, medal and label, with every later row
   spanning both. The figure row takes the slack (`1fr` above it), so two cards
   of different hint lengths still line their bars up.
   #2312 stopped drawing the figure and the bar (a goal is earned or not), and
   #2313 removed their rules (`.path-goal-fig`/`-bar`/`-fill`, their is-met
   variants, and their two `grid-column` selectors). `grid-template-rows`
   below still names four tracks and is left exactly as it was: its two empty
   `auto` tracks and their row gaps are part of the card's rendered height
   today, so trimming them would be a visible change, not a cleanup. */
/* #2332: the air around the grid belongs to the GRID, not to whichever block
   happens to sit next to it. It had `margin:0` on both sides, so every
   neighbour butted against it and the owner reported it twice in one day --
   first "Story stars" crashing into the box above, then, once the play button
   moved between the units and the goals, "no visual breathing room between the
   play and the goal boxes below it" (measured before the fix: that seam was
   0px, against 10-20px at every other seam in the zoom). Two one-off margins
   on `.path-buy` and `.path-stars-block` would have fixed those two reports
   and left the next neighbour to find the same edge. 16px is twice the grid's
   own `gap:8px`, deliberately: a block standing beside the grid has to stand
   further from it than its cards stand from each other, or it reads as one of
   them. */
.path-goals { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:8px;
              list-style:none; margin:16px 0; padding:0;
              /* #2312: a rotateX with no `perspective` has no depth to turn
                 through -- it just flattens to a sliver at ±90deg (found
                 rendering the flip in a real browser, not in a unit test).
                 Declared on the LIST so a card can never clip its own
                 sibling's depth. */
              perspective:900px; }
.path-goal { background:var(--surface); border:1px solid var(--line); border-radius:14px;
             padding:9px 10px 10px; display:grid; grid-template-columns:auto minmax(0, 1fr);
             grid-template-rows:auto 1fr auto auto; column-gap:8px; row-gap:4px;
             align-items:center; min-width:0; }
.path-goal > .path-goal-hint { grid-column:1 / -1; }
.path-goal:last-child:nth-child(odd) { grid-column:1 / -1; }
.path-goal.is-met { border-color:var(--gold-edge); }
.path-medal { position:relative; width:30px; height:30px; border-radius:50%; overflow:hidden;
              display:flex; align-items:center; justify-content:center; flex:0 0 auto;
              background:var(--raise); border:1px solid var(--line); color:var(--muted); }
.path-medal .path-ic { width:17px; height:17px; fill:none; stroke:currentColor; stroke-width:1.6;
                       stroke-linecap:round; stroke-linejoin:round; }
.path-medal.is-met { background:var(--gold-sheen); border-color:var(--gold-edge);
                     color:var(--gold-ink); box-shadow:0 0 14px rgba(227,171,46,.55); }
/* Earned just now: the prototype's pop, once, with the glint above. */
.path-medal.has-art { width:54px; height:59px; border-radius:0; overflow:visible; background:none; border:0;
                      box-shadow:none; align-self:start; }
.path-medal.has-art .path-award-art { width:100%; height:100%; }
.path-medal.is-new { animation:pathGoldPop 460ms var(--ease-out) var(--glint-delay, 0ms) 1 both; }
@keyframes pathGoldPop { 0% { transform:scale(.7); } 60% { transform:scale(1.12); } 100% { transform:none; } }
.path-goal-label { font:600 14px/1.2 var(--serif); color:var(--ink); }
.path-goal-hint { font:400 11.5px/1.3 var(--serif); color:var(--muted); align-self:start; }

/* #2610: switching the zoom's selected unit DEALS its goal cards in -- each
   fades up and settles back from a slight tilt, one after the other, and its
   medal pops once the card has landed. That motion is Web Animations in
   `map.js` (`dealUnitGoals`, where the timings are named and pinned), not a
   keyframe here: #2312's `pathGoalFlip` (a 410ms rule whose visible turn was
   168ms, the second card 70ms behind) read to the owner as "filtering
   instead of working", and was removed. `.path-goal.is-flip` is still the
   class `onPickUnit` puts on the NEW render's cards -- the one marker the
   script deals from. Its one rule hides a card's back face, so the tilt it
   settles from can never flash the reverse of a card. Reduced motion is
   honoured where the motion starts: `onPickUnit` withholds the class and
   `dealUnitGoals` starts nothing, so the cards are simply there. */
.path-goal.is-flip { backface-visibility:hidden; }

/* ---- #2311: Story stars ----
   One line per star, no card, no border, no bar, no description and no Keys
   text -- the owner stripped all of that from the goal-card look in his own
   settled pass (docs/demos/2026-09-15-node-stars). A star's medal is a
   plain 24px ring until it is earned, when it takes the same gold the goal
   medals wear (`--gold-sheen`/`-edge`/`-ink`, declared on `.path-map` above);
   the count wears the "muted, accent once met" pairing the goal cards' old
   figure used (that rule went in #2313). */
/* #2332: the air above this block is `.path-goals`'s own bottom margin, not a
   margin here -- see that rule for why it belongs to the grid. */
.path-stars-block { margin:0 0 10px; }
.path-stars-heading { display:block; font:600 10.5px/1 var(--mono); letter-spacing:.1em;
                      text-transform:uppercase; color:var(--faint); margin:0 0 5px; }
.path-story-stars { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:2px; }
.path-star { display:flex; align-items:center; gap:10px; }
.path-star-medal { position:relative; width:24px; height:24px; border-radius:50%;
                   flex:0 0 auto; display:flex; align-items:center; justify-content:center;
                   background:var(--raise); border:1px solid var(--line); color:var(--muted);
                   font-size:13px; line-height:1; }
.path-star-medal.is-earned { background:var(--gold-sheen); border-color:var(--gold-edge);
                             color:var(--gold-ink); box-shadow:0 0 8px rgba(227,171,46,.4); }
.path-star-label { font:600 14px/1.2 var(--serif); color:var(--ink); flex:1 1 auto; min-width:0; }
.path-star-count { font:400 11.5px/1.3 var(--mono); color:var(--faint); flex:0 0 auto; white-space:nowrap; }
.path-star.is-earned .path-star-count { color:var(--accent); font-weight:600; }

/* #2907: iso art. A regular Story star is a BARE iso star token (no slab); the SPECIAL
   star (the payload's `special`, #2641) stands on a ROUND base. #2933, the owner: that base
   is GREY, earned or not (no new colour to explain), and the progress is a GOLD RING filling
   the circle of its top: `--sweep` (turns, set by map.js from the row's own have / need, as the
   screen angle of that share of the ring's arc) is the conic mask that reveals the gold ring
   drawing clockwise from the top. The star is grey until earned and gold once, like the
   regular ones. The art is baked (the .svg files in study/awards/,
   docs/demos/2026-10-02-story-zoom/bake_awards.mjs) in its natural colours; "not earned" is the
   grey filter on the OUTER <img> -- never one on an inner svg element, which WebKit ignores.
   The two unit awards (shield, page) are the same art on a slab, in `.path-medal.has-art`
   above. #2933 also tightened the rows: the stars are 38px tall (were 46), the round one
   70 (was 72), and the gap between rows 2px (was 9), so a story with three stars and its test
   row fits a 390x844 screen. */
.path-award-art, .path-star-art, .path-star-base { display:block; pointer-events:none; user-select:none; }
.path-award-art.is-off, .path-star-art.is-off { filter:grayscale(1) brightness(.78) contrast(.9); opacity:.8; }
.path-star-medal.has-art { width:44px; height:38px; border:0; border-radius:0; background:none; box-shadow:none;
                           overflow:visible; display:block; }
.path-star-medal.has-art .path-star-art { position:absolute; top:0; left:9px; width:26px; height:38px; }
.path-star-medal.has-art .path-star-glyph { position:absolute; width:1px; height:1px; overflow:hidden;
                                            clip:rect(0 0 0 0); white-space:nowrap; }
.path-star-medal.is-round { height:70px; }
.path-star-medal.is-round .path-star-base,
.path-star-medal.is-round .path-star-art { position:absolute; top:0; left:1px; width:41px; height:70px; }
/* The ring's own centre inside the 76x129 box it is baked in: 50% across, 72.1% down. */
.path-star-medal.is-round .path-star-art.is-ring {
  -webkit-mask-image:conic-gradient(from 0turn at 50% 72.1%, #000 var(--sweep, 0turn), transparent 0);
  mask-image:conic-gradient(from 0turn at 50% 72.1%, #000 var(--sweep, 0turn), transparent 0); }

.path-ic-sm { width:14px; height:14px; flex:0 0 14px; fill:none; stroke:currentColor;
              stroke-width:1.6; stroke-linecap:round; stroke-linejoin:round; }

/* ---- the requirement list, in a locked or ? zoom ----
   A mark, the server's prose, a figure: this screen's vocabulary for "here is
   a thing and how close you are to it". */
.path-reqs { list-style:none; margin:0 0 10px; padding:0;
             display:flex; flex-direction:column; gap:4px; }
.path-req { display:flex; align-items:baseline; gap:6px; font-size:12.5px; color:var(--muted); }
.path-req-mark { flex:0 0 auto; color:var(--accent); }
.path-req.is-met .path-req-mark { color:var(--ok); }
.path-req-label { flex:1 1 auto; }
.path-req-fig { flex:0 0 auto; color:var(--faint); font:400 11px/1.3 var(--mono); }

.path-buy { display:flex; gap:8px; }
/* #2859, the owner's pick (2026-09-30, option A): Play and its whole family (Play,
   Play again, Unlock, Try one round first, Open anyway, Open for N Keys, Not
   written yet) are the trophy card's Continue recipe -- an ink plate with a hard offset
   shadow and an ink rim round it. No gradient. `--shade` is the colour of the offset
   (a darker shade of the button's own metal), `--depth` the resting shadow, so the
   Tutorials pulse can ring a button without taking its offset away. The 6px right and
   bottom margin is where the offset falls, so a row's buttons never touch. The
   control blue, the gold of "Read it again" and Play's silver are the same three the
   flat bars were. */
.path-btn { flex:1; min-height:48px; margin:0 6px 6px 0; border-radius:8px; font:inherit; font-size:15px;
            font-weight:700; letter-spacing:.02em; cursor:pointer; border:3px solid #16181C;
            --shade:color-mix(in srgb, var(--control) 70%, #000);
            --depth:6px 6px 0 var(--shade), 6px 6px 0 3px #16181C;
            background:var(--control); color:var(--control-ink); box-shadow:var(--depth); }
.path-btn:not(:disabled):active { transform:translate(4px, 4px);
                                   box-shadow:2px 2px 0 var(--shade), 2px 2px 0 3px #16181C; }
.path-btn.ghost { --shade:#646E85; background:var(--surface); color:var(--ink); font-weight:600; }
/* The owner's addendum to #2215: "when all things earned and all is gold --
   the 'Read it again' button should also be gold." Relative, for its glint and
   its two sparkles. Flat gold metal under the ink, as the slab. */
.path-btn.is-gold { position:relative; --shade:#B9821A; background:#FFD772; color:var(--gold-ink); }
/* The Tutorials pulse (shared/tutorials.css `.tutorial-pulse`) animates `box-shadow`, which would
   take the slab's thickness and the button's offset away for as long as it runs. On
   these two it runs this keyframe instead: the same pink ring, added to the resting
   `--depth`. Named on its own so the reduced-motion escape has to be stated here too. */
@keyframes path-depth-pulse {
  0% { box-shadow:var(--depth), 0 0 0 0 rgba(255, 46, 147, 0.7); }
  70% { box-shadow:var(--depth), 0 0 0 20px rgba(255, 46, 147, 0); }
  100% { box-shadow:var(--depth), 0 0 0 0 rgba(255, 46, 147, 0); }
}
.path-btn.tutorial-pulse, .path-unit.tutorial-pulse { animation-name:path-depth-pulse; }
@media (prefers-reduced-motion: reduce) {
  .path-btn.tutorial-pulse, .path-unit.tutorial-pulse { animation:none; }
}
/* WCAG 1.4.3 exempt: the criterion does not apply to a disabled control, and
   the dimming IS the affordance -- a disabled control meeting AA contrast
   does not read as disabled. The one control this rule reaches is a gap
   subject's own "Not written yet" button (`zoomView`, study/map.js) --
   `ghost.disabled = true`, unconditionally, since there is genuinely nothing
   to tap. Cited because the sweep silences this row permanently once it
   sees `:disabled`, and a silenced row with no reason above it is worse than
   one that keeps nagging (#723). */
.path-btn:disabled { opacity:.42; cursor:not-allowed; --depth:3px 3px 0 var(--shade), 3px 3px 0 3px #16181C; }

/* ---- 2026-09-25: a skill's zoom (map.js's `skillZoomView`) ----
   The subject zoom's own vocabulary -- the large node that is the way back,
   the title, `.path-goals` cards, `.path-buy` rows --
   with the path's hexagon, twice the size, as the node. The owner judges the
   look on his phone; these rules only have to make it one screen's worth of
   the same language. */
.path-zoom-hero.is-skill { width:104px; height:120px; }
.path-zoom-node.is-skill { width:104px; height:120px; border:0; border-radius:0; background:none;
                           box-shadow:none; overflow:visible; color:var(--known); }
.path-zoom-node.is-skill:active { box-shadow:none; }
.path-zoom-node.is-skill.is-earned { color:var(--silver-ink); }
/* The lift that says "press me", on the hexagon's own outline rather than on
   a box it does not have. */
.path-zoom-node.is-skill .path-hex { width:104px; height:120px;
  filter:drop-shadow(0 3px 0 var(--line)) drop-shadow(0 10px 12px rgba(0,0,0,.24)); }
/* `position:absolute` restated beside the centring margin, as the silver
   sparkles' rule does: #1300's sweep reads a negative margin as in-flow unless
   the same rule says the box is out of flow, and this one is. */
.path-zoom-node.is-skill .path-hex .path-ic { position:absolute; width:44px; height:44px; margin:-22px 0 0 -22px;
                                              stroke-width:1.5; }
.path-zoom-node.is-skill.is-earned .path-hex-shape polygon { stroke:none; }
.path-zoom-node.is-skill .path-sparkles.is-silver { width:104px; height:120px; }
.path-skill-zoom .path-zoom-title { margin-bottom:4px; }

/* ---- #2859: the zoom's header object is the path's own node, larger ----
   map.js adds `has-iso` to the hero (and to a story's button) and draws
   `shared/iso_art.js`'s object inside it, the same drawing the path has: the
   plinth, its nameplate as the unit meter, the lock while it is locked. The flat
   square, hexagon, glyph and footer stay in the DOM and are hidden here, as they
   are on the path, so a page without the module keeps the old one. The hero is
   still the ONE way back, a real button far bigger than 44px. The lift the flat
   node had (a ledge and a soft shadow) is a soft shadow under the object. */
.path-zoom-hero.has-iso { width:112px; height:127px; }
.path-zoom-hero.is-skill.has-iso { width:104px; height:120px; }
.path-zoom-hero.has-iso .path-zoom-node { width:100%; height:100%; border:0; border-radius:0;
  background:none; box-shadow:none; overflow:visible;
  filter:drop-shadow(0 6px 5px rgba(0,0,0,.22)); }
.path-zoom-hero.has-iso .path-zoom-node:active { box-shadow:none; transform:translateY(2px); }
.path-zoom-hero.has-iso .path-zoom-node > .path-ic,
.path-zoom-hero.has-iso .path-zoom-node > .path-foot { display:none; }
.path-zoom-hero.has-iso .path-zoom-node.path-glint::after { display:none; }
.path-zoom-hero.has-iso .path-iso { width:112px; height:127px; }
/* #2976: the level-5 decoration is the drawing's own box (iso_art.js gives both one viewBox), so it
   takes the hero's size with the drawing. Left at `.path-hex.has-iso .path-deco`'s 82x94 it stood at
   0.79 of the plinth in the zoom and 1.0 on the path, so each FLIP between the two (in, and back)
   popped it a size on its first frame -- "they jump first and then animate". */
.path-zoom-hero.is-skill.has-iso .path-iso,
.path-zoom-hero.is-skill.has-iso .path-zoom-node .path-hex.has-iso .path-deco { width:104px; height:120px; }
.path-zoom-hero.is-skill.has-iso .path-zoom-node .path-hex.has-iso { width:104px; height:120px; filter:none; }
.path-zoom-hero.has-iso .path-sparkles.is-zoom { width:112px; height:127px; }
.path-skill-body { display:flex; flex-direction:column; }

/* ---- #2931: the tools beside a skill's hero, and the Hebrew practice -------------
   The owner (2026-10-02, q2): "We have lots of space beside the main icon for the page --
   maybe a practice icon can be built". map.js's `fillSkillTools` puts How to play to the
   hero's left and Practise (and, once the page is unlocked, the HE/EN switch) to its right.
   They are the Play family's ink plate at hand size: a 3px ink rim and a hard offset in the
   button's own shade (`.path-btn`'s recipe, #2859), so they stand on the page as the same
   kind of object. Absolute, so the hero's box and the title under it do not move; each is
   at least 44px both ways (tests/test_tap_floor_browser.py). The wrapper takes no pointer
   events: only the buttons do, so the hero is still tapped right up to its edge. */
.path-zoom-tools { position:absolute; inset:0; pointer-events:none; }
.path-tools-side { position:absolute; top:50%; display:flex; align-items:center; gap:12px;
                   transform:translateY(-50%); pointer-events:auto; }
.path-tools-side.is-left { right:calc(100% + 16px); }
.path-tools-side.is-right { left:calc(100% + 16px); }
.path-tool { box-sizing:border-box; min-width:48px; height:48px; padding:0 8px; margin:0 4px 4px 0;
             display:flex; align-items:center; justify-content:center; border-radius:12px;
             border:3px solid #16181C; cursor:pointer; touch-action:manipulation;
             -webkit-tap-highlight-color:transparent;
             --shade:#646E85; --depth:4px 4px 0 var(--shade), 4px 4px 0 3px #16181C;
             background:var(--surface); color:var(--ink); box-shadow:var(--depth);
             font:700 14px/1 var(--mono); letter-spacing:.04em; }
.path-tool .path-ic { width:26px; height:26px; fill:none; stroke:currentColor; stroke-width:1.8;
                      stroke-linecap:round; stroke-linejoin:round; }
.path-tool:not(:disabled):active { transform:translate(3px, 3px);
                                   box-shadow:1px 1px 0 var(--shade), 1px 1px 0 3px #16181C; }
.path-tool:focus-visible { outline:2px solid var(--control); outline-offset:4px; }
.path-tool.is-howto { --shade:#646E85; background:var(--surface); color:var(--ink); }
.path-tool.is-practise { --shade:color-mix(in srgb, var(--control) 70%, #000);
                         background:var(--control); color:var(--control-ink); }
.path-tool.is-lang[aria-pressed="true"] { --shade:color-mix(in srgb, var(--control) 70%, #000);
                                          background:var(--control); color:var(--control-ink); }
.path-tool-aleph { font:700 28px/1 var(--sans-he); letter-spacing:0; }
.skui-he { font-family:var(--sans-he); unicode-bidi:isolate; }
.path-btn .skui-he { font-size:1.15em; }
.path-skill-track .skui-he { font-size:1.2em; line-height:0; }

/* The practice (shared/skill_ui.js), hosted in the zoom's body. Its own markup is the
   beats' (`.beat`, `.opt`, `.advance`, shared/beat_card.css, loaded here already); these
   are the few rules it adds, the same ones skills.css gives it on the round screen. */
.skui-host { align-items:stretch; margin-top:6px; }
.skui-host .skui-practice { gap:12px; }
.skui-practice .skui-kicker { margin:0; font-family:var(--mono); font-size:.72rem;
                              letter-spacing:.08em; text-transform:uppercase;
                              color:var(--muted); }
.skui-practice .skui-title { margin:0; font-family:var(--serif); font-size:1.3rem;
                             font-weight:600; line-height:1.25; color:var(--ink); }
.skui-practice .skui-note { margin:0; max-width:28rem; font-size:.92rem; line-height:1.45;
                            color:var(--muted); }
.skui-practice .skui-ask { margin:0; font-size:.92rem; color:var(--muted); }
.skui-practice .skui-prompt { margin:.4rem 0 0; font-size:calc(44px * var(--milim-text-scale)); }
/* #3227: a phrase card, up to four Hebrew words: smaller than a word's, centred, and balanced so it breaks
   into two even rows rather than leaving one word alone at 390px. */
.skui-practice .skui-prompt-phrase { font-size:calc(32px * var(--milim-text-scale)); line-height:1.25;
                                     text-align:center; text-wrap:balance; overflow-wrap:anywhere; }
.skui-progress { display:flex; gap:3px; width:100%; max-width:22rem; }
.skui-progress span { flex:1 1 0; height:4px; border-radius:999px; background:var(--line); }
.skui-progress span.is-done { background:var(--accent); }
.skui-options { display:flex; flex-direction:column; gap:.55rem; width:100%; max-width:22rem; }
.skui-words { list-style:none; margin:0; padding:0; width:100%; max-width:22rem;
              display:flex; flex-direction:column; gap:.4rem; }
.skui-word { display:flex; align-items:baseline; justify-content:space-between; gap:1rem;
             padding:.55rem .8rem; border:1px solid var(--line); border-radius:12px;
             background:var(--surface); }
.skui-word-he { font-family:var(--sans-he); font-size:1.35rem; color:var(--ink); }
.skui-word-en { font-size:.95rem; color:var(--muted); }
.skui-leave { min-height:44px; padding:0 1rem; background:none; border:0; color:var(--muted);
              font:inherit; font-size:.85rem; text-decoration:underline; cursor:pointer; }
.skui-leave:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
/* #3119: the teaching screen's Start + "Back to the page", wrapped in one footer. Plain
   `display:contents` here, so the two lay out as direct children of the card exactly as
   before the wrapper existed; study.css's `.skui-host .skui-foot` is the one place it
   becomes a real box. */
.skui-foot { display:contents; }
/* #3119: in the zoom the teaching screen sits under the skill's hero, so its 8 words end
   at y 787 on a 390x844 phone and Start lands at 799-843 -- UNDER the bottom bar (top 792),
   with "Back to the page" below the screen. The footer is pinned instead: sticky, offset by
   the bar's own published height (`--milim-nav-total`, the token the bar, its spacer and the
   build stamp all read, safe-area inset included), on the page's own ground so a word
   scrolling beneath it does not show through. Start and the way back share a row, and the
   rows are a little tighter, so on a 390x844 all eight words and both buttons sit above the
   bar with nothing to scroll; on a shorter screen the list scrolls under the footer. */
.skui-host .skui-foot { display:flex; align-items:center; justify-content:center; gap:8px;
                        position:sticky; bottom:var(--milim-nav-total); z-index:2;
                        align-self:stretch; margin-top:auto; padding:8px 0 12px;
                        background:var(--ground); }
.skui-host .skui-foot .skui-go { flex:1 1 0; min-width:0; max-width:15rem; margin-inline:0; }
.skui-host .skui-foot .skui-leave { flex:0 0 auto; }
.skui-host .skui-teach .skui-words { gap:6px; }
.skui-host .skui-teach .skui-word { padding:.3rem .8rem; }
/* A WAITING skill (the owner, 2026-09-25): "Not open yet." straight under the
   blurb, then what opens it -- the unit icons and the server's sentence on one
   plain line, no card round it. */
.path-skill-lock { display:block; text-align:center; font:600 15px/1.3 var(--serif); color:var(--ink);
                   margin:12px 0 8px; }
.path-skill-need { display:flex; flex-direction:column; align-items:center; gap:6px;
                   text-align:center; margin:0 0 6px; }
.path-skill-need-label { font:400 13px/1.4 var(--serif); color:var(--muted); }

/* A gate with several parts, one row each: an empty ring until that part holds,
   a tick once it does. The mark is drawn, not an icon, so it follows the text. */
.path-need-list { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:4px; }
.path-need-item { display:flex; gap:8px; align-items:flex-start; text-align:left;
                  font:400 13px/1.4 var(--serif); color:var(--muted); }
.path-need-item::before { content:"\25CB"; flex:none; color:var(--muted); }
.path-need-item.is-met { color:var(--ink); }
.path-need-item.is-met::before { content:"\2713"; color:var(--ink); }

/* An OPEN skill: /skills' game card fitted to this page. */
.path-skill-levels { display:flex; flex-direction:column; margin:10px 0 10px; }
.path-skill-track { display:block; font:400 11.5px/1.3 var(--mono); color:var(--faint);
                    text-align:center; margin:0 0 8px; }
/* The levels: /skills' row of numbered circles, each a 44px target, the worth
   under the circle. An owned level's circle is the skill's white metal, not
   the story's gold (his words: "show the white metallic instead of the gold
   metallic to match the same colour of the skills"). The digit stays and a
   tick sits under it, so owned is a SHAPE as well as a colour (#2000). */
.path-rungs { display:flex; gap:6px; margin:0; }
.path-rung { flex:1 1 0; min-width:0; min-height:56px; padding:6px 2px 6px; border-radius:12px;
             border:1px solid transparent; background:none; color:var(--muted);
             font:inherit; display:flex; flex-direction:column; align-items:center;
             justify-content:center; gap:4px; cursor:pointer; }
/* #2859: the circle is a PUCK, the plinth's own stacked-ellipse cylinder: a flat
   metal top, an ink outline, and a stack of one-pixel metal layers for the
   thickness (the same recipe `.path-skill-win` below shares). The metals are the
   art's own and the same in both themes, like the art: slate until owned, the
   skill's silver once it is, blue for the level being decided about. The digit
   is the art's ink on each, so its contrast does not move with the theme. */
.path-rung-no, .path-skill-win {
  --puck-top:#8A93A8; --puck-side:#454C60; background:var(--puck-top); color:#16181C;
  box-shadow:0 0 0 2px #16181C,
    0 1px 0 var(--puck-side), 0 2px 0 var(--puck-side), 0 3px 0 var(--puck-side),
    0 4px 0 var(--puck-side), 0 5px 0 var(--puck-side),
    0 1px 0 2px #16181C, 0 2px 0 2px #16181C, 0 3px 0 2px #16181C,
    0 4px 0 2px #16181C, 0 5px 0 2px #16181C, 0 6px 0 2px #16181C; }
.path-rung-no { width:36px; height:26px; margin-bottom:6px; border-radius:50%; box-sizing:border-box;
                border:0; display:flex; align-items:center; justify-content:center;
                font:700 12px/1 var(--mono); }
.path-rung-worth { font:400 11px/1 var(--mono); display:inline-flex; align-items:center; height:11px; }
.path-rung.is-owned .path-rung-no { --puck-top:#EEF2F7; --puck-side:#8A93A8; }
.path-rung.is-owned .path-rung-worth { color:var(--ok); font-weight:700; }
.path-rung.is-next .path-rung-no { --puck-top:#8FD0FA; --puck-side:#4A9BD1; }
/* The level being LOOKED AT (its card is open): a quiet box. The level Play starts
   at (#2893) wears the blue line under its circle instead, the same line the chosen
   unit has -- a picked level needs no box too. */
.path-rung.is-sel:not(.is-play) { border-color:var(--line); background:var(--raise); }
.path-rung { position:relative; }
.path-rung.is-play::before { content:""; position:absolute; left:3px; right:3px; bottom:-2px;
                             height:4px; border-radius:2px; background:var(--control); }
.path-rung:focus-visible { outline:2px solid var(--control); outline-offset:2px; }
/* Not built yet, or not priced yet: dimmed, no text, not a choice. */
.path-rung.is-dim { opacity:.38; cursor:default; }
.path-keys { display:inline-flex; align-items:center; gap:2px; }

/* The chosen level's card: /skills' detail card, notch and all, animating in
   under the row when a level is tapped (and when the zoom first fills). */
.path-skill-card { position:relative; margin:14px 0 4px; padding:14px 14px 14px;
                   background:var(--surface); border:1px solid var(--line); border-radius:14px; }
.path-skill-notch { position:absolute; top:-7px; left:50%; width:12px; height:12px; margin-left:-6px;
                    transform:rotate(45deg); background:var(--surface);
                    border-left:1px solid var(--line); border-top:1px solid var(--line); border-radius:2px; }
/* The notch over the chosen circle: slot k of five sits at (k - 0.5) / 5. */
.path-skill-card[data-at="1"] .path-skill-notch { left:10%; }
.path-skill-card[data-at="2"] .path-skill-notch { left:30%; }
.path-skill-card[data-at="4"] .path-skill-notch { left:70%; }
.path-skill-card[data-at="5"] .path-skill-notch { left:90%; }
.path-skill-card.is-entering { animation:path-skill-card-in .28s ease-out both; }
@keyframes path-skill-card-in {
  from { opacity:0; transform:translateY(10px); }
  to { opacity:1; transform:none; }
}
.path-rung-head { display:block; font:600 14px/1.25 var(--serif); color:var(--ink); margin:0 0 4px; }
.path-rung-eff { display:block; font:400 13px/1.45 var(--serif); color:var(--muted); margin:0 0 10px; }
/* An owned level's card is its head and this one line (2026-09-28), so the line
   is the card's last thing and carries no gap of its own. */
.path-skill-card .path-rung-eff:last-child { margin-bottom:0; }
.path-rung-note, .path-rung-shares { display:block; font:400 12.5px/1.4 var(--serif); color:var(--muted);
                                     margin:6px 0 0; }
.path-skill-balance { display:flex; align-items:center; gap:4px; flex-wrap:wrap; }
.path-rung-note.is-refused { color:var(--ink); }
.path-skill-body > .path-buy { margin-top:10px; }


/* The win milestones (5 / 25 / 75 / 100): one row of small medals, white metal
   once reached, and the tally under them. */
.path-skill-wins { display:flex; flex-direction:column; align-items:center; gap:6px; margin:16px 0 4px; }
.path-skill-winrow { list-style:none; margin:0; padding:0; display:flex; gap:10px; }
.path-skill-win { width:40px; height:28px; margin-bottom:6px; border-radius:50%; box-sizing:border-box;
                  border:0; display:flex; align-items:center; justify-content:center; }
.path-skill-win-no { font:700 11px/1 var(--mono); color:#16181C; }
/* #2936: a goal's own mark (Build it's chef's hat at 5 wins) replaces the digit
   on its medal, in the same ink; not yet reached it is dashed. */
.path-skill-win-mark { width:18px; height:18px; display:block; fill:none; stroke:#16181C;
                       stroke-width:1.7; stroke-linecap:round; stroke-linejoin:round; }
.path-skill-win-mark.is-pending { stroke-dasharray:2.2 2.2; }
.path-skill-win.is-met { --puck-top:#EEF2F7; --puck-side:#8A93A8; }
.path-skill-tally { display:block; font:400 11.5px/1.3 var(--mono); color:var(--faint); }

/* Play: the control blue until every level is owned, then the skill's white
   metal (the owner, 2026-09-25) -- the gold "Read it again" rule, in silver. */
a.path-btn.path-play.is-silver { --shade:#8A93A8; background:#EEF2F7; color:var(--silver-ink); }

@media (prefers-reduced-motion: reduce) {
  .path-skill-card.is-entering { animation:none; }
}

/* Play is a link to the round on /skills, dressed as the zoom's button (a
   <button> is border-box by default; this <a> is told to be). */
a.path-btn.path-play,
a.path-btn.path-try { box-sizing:border-box; display:flex; align-items:center; justify-content:center;
                      text-decoration:none; text-align:center; }

@media (hover: hover) {
  /* #145's own reason, stated on the old map above: on iOS a `:hover` rule
     makes the first tap paint instead of activating, so this stays scoped to
     a pointer that actually hovers. */
  .path-tile:not(.locked):not(.planned):hover { border-color:var(--control); }
  .path-btn:not(:disabled):hover { filter:brightness(1.05); }
  .path-btn.ghost:hover { background:var(--raise); }
  .path-review:hover .path-rv { box-shadow:0 0 0 6px var(--rv-soft), var(--shadow); }
}

/* ---- #2228: the Review node (study/review.js) ----
   A violet CIRCLE beside the node the learner is on, its count on a badge, as
   the prototype draws it (docs/demos/2026-09-15-review-node-2228/): not a
   subject's square, not a skill's hexagon, and never gold or silver -- it has
   nothing to finish, only something to empty. The violet is the prototype's,
   one value per theme. */
.path-map { --rv:#B69CFF; --rv-ink:#140F24; --rv-soft:rgba(182,156,255,.16); --rv-line:rgba(182,156,255,.55); }
@media (prefers-color-scheme: light) {
  .path-map { --rv:#6A45C9; --rv-ink:#FFFFFF; --rv-soft:rgba(106,69,201,.10); --rv-line:rgba(106,69,201,.50); }
}
/* #2617: the in-app theme override, `html[data-theme=...] .path-map`, same
   reasoning as the `.path-offunit` pair above. ONE rule per theme here, not
   two -- `.path-map` carries two separate unconditional/media overrides in
   this file (the review-node `--rv` family here), and `tests/test_contrast_sweep.py::test_no_stylesheet_declares_a_root_family_selector_twice_per_theme_1948`
   flags a REPEATED `:root[data-theme="..."]`-prefixed selector string in one
   file -- `html[data-theme=...]`, not `:root[data-theme=...]`, is used for
   the identical reason `.path-offunit`'s own pair above states, so this
   guard cannot see these rules at all
   file (the #1948 shape it exists to catch), so both property groups are
   folded into the one rule per theme instead of mirroring the two source
   blocks separately. Dark restates both blocks' own base values (this
   page's default); light is both blocks' own `@media` override, unchanged. */
html[data-theme="dark"] .path-map {
  --rv:#B69CFF; --rv-ink:#140F24; --rv-soft:rgba(182,156,255,.16); --rv-line:rgba(182,156,255,.55);
}
html[data-theme="light"] .path-map {
  --rv:#6A45C9; --rv-ink:#FFFFFF; --rv-soft:rgba(106,69,201,.10); --rv-line:rgba(106,69,201,.50);
}
.path-review { background:none; border:0; padding:0; margin:0; font:inherit; color:var(--ink);
               display:flex; flex-direction:column; align-items:center; width:84px; cursor:pointer;
               -webkit-tap-highlight-color:transparent; }
.path-review:focus-visible { outline:2px solid var(--control); outline-offset:3px; border-radius:12px; }
.path-wing .path-offshoot + .path-review { margin-top:20px; }
/* In a pair's strip it sits under its subject's own edge, like a skill. */
.path-offband .path-review.is-left { transform:translateX(-66px); }
.path-offband .path-review.is-right { transform:translateX(66px); }
.path-rv { position:relative; width:64px; height:64px; border-radius:50%; display:flex;
           align-items:center; justify-content:center; background:var(--surface);
           border:2px solid var(--rv); color:var(--rv);
           box-shadow:0 0 0 4px var(--rv-soft), var(--shadow); }
.path-rv .path-ic, .path-zoom-node.is-review .path-ic { fill:none; stroke:currentColor;
                                                        stroke-linecap:round; stroke-linejoin:round; }
.path-rv .path-ic { width:28px; height:28px; stroke-width:1.8; }
.path-rv-badge { position:absolute; top:-6px; right:-8px; min-width:24px; height:24px; padding:0 6px;
                 border-radius:999px; background:var(--rv); color:var(--rv-ink);
                 font:700 12px/24px var(--mono); text-align:center; box-shadow:0 0 0 2px var(--ground); }
.path-rv-label { display:block; font-size:11px; line-height:1.22; text-align:center; margin-top:6px;
                 color:var(--ink); font-weight:600; }
.path-reviewlink { fill:none; stroke:var(--rv-line); stroke-width:2; stroke-linecap:round;
                   stroke-dasharray:2 5; }

/* The zoom. The large circle is the way back, like every node's (#2273). */
.path-zoom-node.is-review { border-radius:50%; border:2px solid var(--rv); color:var(--rv);
                            box-shadow:0 3px 0 var(--rv-line), 0 0 0 5px var(--rv-soft), 0 10px 24px rgba(0,0,0,.24); }
.path-zoom-node.is-review:active { box-shadow:0 1px 0 var(--rv-line), 0 0 0 5px var(--rv-soft), 0 4px 10px rgba(0,0,0,.2); }
.path-zoom-hero .path-rv-badge { top:0; right:-4px; min-width:30px; height:30px; font-size:14px; line-height:30px; }
.path-review-body { display:flex; flex-direction:column; gap:10px; }
.path-review-body > .path-zoom-sub { margin:0; }
.path-review-words, .path-review-ends { list-style:none; margin:0; padding:0; display:flex;
                                        flex-direction:column; gap:8px; }
.path-review-word, .path-review-end { display:grid; grid-template-columns:auto minmax(0,1fr) auto;
                                      align-items:center; column-gap:12px; background:var(--surface);
                                      border:1px solid var(--line); border-radius:14px; padding:10px 12px; }
.path-review-he { font-size:22px; line-height:1.1; min-width:48px; text-align:center; color:var(--ink); }
.path-review-mid { min-width:0; display:flex; flex-direction:column; gap:3px; }
.path-review-gloss { font-size:14px; line-height:1.25; color:var(--ink); }
.path-review-from { font:400 10.5px/1.2 var(--mono); color:var(--faint); }
.path-review-dots { display:flex; gap:5px; }
.path-review-dot { display:block; box-sizing:border-box; width:12px; height:12px; border-radius:50%;
                   border:1.5px solid var(--rv-line); }
.path-review-dot.is-on { background:var(--rv); border-color:var(--rv); }
.path-review-note { margin:0; font-size:12.5px; line-height:1.4; color:var(--muted); text-align:center; }

/* A card of a go. */
.path-review-progress { display:flex; gap:3px; }
.path-review-progress span { flex:1 1 0; height:4px; border-radius:999px; background:var(--line); }
.path-review-progress span.is-done { background:var(--rv); }
.path-review-card { background:var(--surface); border:1px solid var(--line); border-radius:18px;
                    padding:14px; box-shadow:var(--shadow); }
.path-review-head { display:flex; justify-content:space-between; gap:8px; font:600 10.5px/1.2 var(--mono);
                    letter-spacing:.08em; text-transform:uppercase; color:var(--faint); }
.path-review-kind { color:var(--rv); }
.path-review-subject { min-width:0; text-align:right; }
.path-review-prompt { margin:18px 0 6px; text-align:center; font-size:40px; line-height:1.15; color:var(--ink); }
.path-review-prompt.is-en { font:600 22px/1.25 var(--serif); }
.path-review-line { margin:16px 0 6px; text-align:center; font-size:22px; line-height:1.6; color:var(--ink); }
.path-review-gap { display:inline-block; width:54px; height:1.1em; margin:0 3px; vertical-align:-3px;
                   border-bottom:2px solid var(--rv); }
.path-review-ask { margin:0 0 12px; text-align:center; font-size:14px; color:var(--muted); }
.path-review-opts { display:grid; grid-template-columns:minmax(0,1fr); gap:8px; }
.path-review-opt { min-height:46px; border-radius:12px; border:1px solid var(--line); background:var(--raise);
                   color:var(--ink); font:inherit; font-size:15px; padding:6px 12px; cursor:pointer; }
.path-review-opt.is-he { font-size:22px; }
.path-review-opt.is-right { border-color:var(--ok); background:var(--ok-soft); }
.path-review-opt.is-wrong { border-color:var(--no); background:var(--no-soft); }
.path-review-opt:disabled { cursor:default; }

/* The end of a go: this go's marks, the two days, and the claim. */
.path-review-marks { font:600 13px/1 var(--mono); letter-spacing:.1em; }
.path-review-marks .is-right { color:var(--ok); }
.path-review-marks .is-wrong { color:var(--no); }
.path-review-said, .path-review-claim { grid-column:1 / -1; margin:8px 0 0; font-size:12.5px;
                                        line-height:1.35; color:var(--muted); }
.path-review-said.is-known { color:var(--ok); font-weight:600; }
.path-review-q { margin:0 0 8px; font-size:13px; color:var(--ink); }
.path-review-claim .path-btn { min-height:44px; }
/* #2685: a locked game's practice, drawn in its waiting skill's zoom with
   Review's own cards (study/review.js). Its "why not yet" line sits under the
   gate's icons; the "is open" line closes a go whose claim opened the game. */
.path-practice { margin-top:4px; }
.path-practice-why { max-width:32ch; margin:0 auto; }
.path-practice-open { margin:4px 0 0; text-align:center; font-size:14px; }

/* ---- #2103: the "coming up" page (study/new_words_preview.js) ----------
   His spec: five NEW words, English on the LEFT, Hebrew on the RIGHT, one
   line each, revealed 0.4s apart, and a Continue that is green only once
   every line is out.

   A not-yet-revealed line is `visibility:hidden`, NOT `opacity:0`. Two
   reasons: it keeps its box, so the list does not grow under the learner's
   thumb and push Continue down five times; and it is hidden from assistive
   tech until it is shown, which `opacity` is not. The fade is a keyframe on
   the SHOWN state only, so nothing at rest is ever translucent.

   Both columns are `minmax(0,1fr)` with `overflow-wrap:anywhere`: a grid
   track's default minimum is its content, and one long single-word gloss
   would otherwise widen its column past the 393px phone. */
.stage.nwp-on { align-items:center; }
.nwp-screen { display:flex; flex-direction:column; align-items:center;
              align-self:stretch; gap:16px; min-width:0; }
.nwp-kicker { margin:0; font:600 12px/1 var(--mono); letter-spacing:.04em;
              text-transform:uppercase; color:var(--muted); }
.nwp-title { margin:0; font-size:20px; font-weight:600; color:var(--ink); }
.nwp-list { list-style:none; margin:0; padding:0; align-self:stretch;
            border-top:1px solid var(--line); }
.nwp-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr);
           align-items:center; column-gap:16px; padding:12px 4px;
           border-bottom:1px solid var(--line); visibility:hidden; }
.nwp-row.is-shown { visibility:visible;
                    animation:nwpRowIn var(--dur-base) var(--ease-out) both; }
/* The English cell is `dir="ltr"` and the Hebrew cell `dir="rtl"` in the
   markup; the alignment is stated here too so each column hugs its own outer
   edge whatever the stage's `text-align:center` says. */
.nwp-en { text-align:left; font-size:15px; line-height:1.3; color:var(--ink);
          overflow-wrap:anywhere; }
.nwp-he { text-align:right; font:500 22px/1.25 var(--heb); color:var(--ink);
          overflow-wrap:anywhere; }
@keyframes nwpRowIn { from { opacity:0; transform:translateY(6px); }
                      to { opacity:1; transform:none; } }
/* Green, the app's own green Continue: the same four declarations as
   `.advance.right` (shared/beat_card.css), restated rather than borrowing that
   class, because `.right` means "the tap was right" and nothing here was
   answered. Before this class lands the button is the plain `.advance`. */
.advance.nwp-continue.is-ready { border-color:var(--ok); background:var(--raise);
                                 color:var(--ink);
                                 box-shadow:3px 3px 0 var(--ok), 0 0 18px var(--ok-bloom); }
@media (prefers-reduced-motion: reduce) {
  /* The JS already reveals every line at once here; this stops each shown
     line running its own fade on the way in. */
  .nwp-row.is-shown { animation:none; }
}

/* #2521 (round 2): the bubble that replaced the sheet above (2026-09-27,
   see word_sheet.js's own header for the owner's own words). Anchored
   directly to the tapped `.tok` -- `position:fixed` because word_sheet.js's
   own positioning math reads `getBoundingClientRect()`, viewport-relative,
   so `fixed` is what makes this rule's `left`/`top` land where that math
   says without a scroll offset added on either side. Tokens only, the same
   header rule the sheet it replaces already stated: `--surface`/`--ink`/
   `--line`/`--muted`/`--shadow` are `shared/beat_tokens.css`'s, already
   loaded (and already themed for dark/light) by every page this stylesheet
   serves.

   z-index above `shared/nav.css`'s own highest bar chrome (2147483400), same
   value the sheet used, so the bottom nav cannot poke through, and well
   under `shared/build_stamp.css`'s ceiling (2147483647) it must never reach.

   `max-width` is a fixed number rather than a fraction of the viewport on
   purpose -- a gloss is a few words, never a whole sentence, and a bubble
   that scaled with a wide screen would drift far from a word tapped near
   its edge. `left`/`top` are set from JS on every open (`positionWordBubble`
   in word_sheet.js); the values here are never read while the bubble is
   visible, only before its first position is set. */
.word-bubble { position:fixed; left:0; top:0; z-index:2147483450;
  max-width:220px; box-sizing:border-box;
  background:var(--surface); color:var(--ink); border:1px solid var(--line);
  border-radius:10px; box-shadow:var(--shadow);
  padding:6px 10px; font-size:0.9rem; line-height:1.3; }
.word-bubble[hidden] { display:none; }
.word-bubble-gloss { direction:ltr; text-align:start; }

/* The pointer: a 12px square, rotated 45deg into a diamond, with only the
   two edges nearest the token bordered -- the same "rotated square with two
   borders" a CSS triangle-with-a-border-outline is usually built from, one
   diamond doing duty as both fill and outline since `background:inherit`
   picks up `--surface`/`--line` from `.word-bubble` itself. Positioned
   entirely from JS (`pointer.style.left`, set in `positionWordBubble`) --
   the `left` here is only the pre-JS fallback. Sitting BELOW the bubble
   (pointing down, at the word) is the default because "above the token" is
   the preferred placement; `.word-bubble--below` flips which two edges are
   bordered so the same diamond points up at a token the bubble had to sit
   under instead. */
.word-bubble-pointer { position:absolute; left:50%; bottom:-6px;
  width:12px; height:12px; background:inherit;
  border-inline-end:1px solid var(--line); border-block-end:1px solid var(--line);
  transform:rotate(45deg); }
.word-bubble--below .word-bubble-pointer { top:-6px; bottom:auto;
  border-inline-end:0; border-block-end:0;
  border-inline-start:1px solid var(--line); border-block-start:1px solid var(--line); }

/* ---- #2859, the owner 2026-09-30: "I just wish everything fit onto one page" ----
   A node's zoom (a story's or a skill's) must fit between the top rail and the bottom
   nav with no scroll, on a 375x667 phone and on a 390x844 one. Measured in Chrome at both
   sizes before this block, the fullest skill (an unbought next level: its card, the
   Play-at-level row, Play, the level pucks, the win medals) ran 190px past the nav at
   375x667 and 13px past it at 390x844; a story ran 29-62px past at 375x667. Two height
   tiers, so a tall screen keeps the roomy rhythm it has had:
     <= 880px: a tighter rhythm, "Play at level" on one line with its tiles, the card's
               balance and "Also changes" sharing a line;
     <= 740px: a smaller header object, the win medals and their tally on one line, and
               the goal cards and star rows condensed.
   Nothing is dropped and no control shrinks under its 44px target: only the air between
   things, the header object and the lines' spacing. The classes are the ones the Tutorials
   tutorials read (`.path-zoom-node`, `.path-buy .path-btn`, `.path-unit.is-read`),
   untouched. The numbers are pinned by tests/test_path_iso_static.py. */
@media (max-height: 880px) {
  .path-zoom-title { margin:6px 0 8px; }
  .path-skill-zoom .path-zoom-title { margin-bottom:2px; }
  .path-zoom-sub { margin:2px 0 8px; }
  .path-skill-levels { margin:6px 0; }
  .path-skill-track { margin-bottom:4px; }
  .path-skill-wins { margin:8px 0 0; }
  .path-skill-card { margin:10px 0 0; padding:10px 12px; }
  .path-skill-card .path-rung-eff { margin-bottom:6px; }
  .path-rung-note, .path-rung-shares { margin:4px 14px 0 0; }
  .path-rung-note.path-skill-balance { display:inline-flex; }
  .path-rung-shares { display:inline-block; }
}
@media (max-height: 740px) {
  .path-zoom-hero.has-iso { width:68px; height:77px; }
  .path-zoom-hero.is-skill.has-iso { width:58px; height:67px; }
  .path-zoom-hero.has-iso .path-iso { width:68px; height:77px; }
  .path-zoom-hero.is-skill.has-iso .path-iso,
  .path-zoom-hero.is-skill.has-iso .path-zoom-node .path-hex.has-iso,
  .path-zoom-hero.is-skill.has-iso .path-zoom-node .path-hex.has-iso .path-deco { width:58px; height:67px; }
  .path-zoom-hero.has-iso .path-sparkles.is-zoom { width:68px; height:77px; }
  .path-zoom-hero { margin-bottom:-6px; top:-6px; }   /* #2933: 6px higher, the same trick as above */
  .path-zoom-title { margin:2px 0 6px; font-size:20px; }
  .path-skill-zoom .path-zoom-title { margin-bottom:0; }
  .path-zoom-sub { margin:0 0 4px; line-height:1.35; }
  .path-skill-levels { margin:2px 0; }
  .path-skill-track { margin-bottom:0; }
  .path-rung { min-height:48px; padding:3px 2px; gap:0; }
  .path-rung-no { margin-bottom:4px; }
  .path-unit { margin-bottom:12px; }
  .path-unit.is-sel::before { bottom:-11px; height:3px; }
  .path-units { margin-bottom:4px; }
  .path-btn { min-height:44px; }
  .path-tool { min-width:44px; height:44px; }
  .path-skill-body > .path-buy { margin-top:4px; }
  .path-zoom { padding-bottom:0; }
  .path-skill-wins { flex-direction:row; justify-content:center; align-items:center; gap:12px; margin:2px 0 0; }
  .path-skill-win { margin-bottom:4px; }
  .path-skill-card .path-rung-head { margin-bottom:2px; }
  .path-skill-card { margin:8px 0 0; padding:7px 12px; }
  .path-goals { margin:8px 0; gap:6px; }
  .path-goal { padding:6px 8px 7px; row-gap:2px; }
  .path-goal-hint { line-height:1.25; }
  .path-medal { width:26px; height:26px; }
  .path-stars-block { margin:0 0 6px; }
  .path-stars-heading { margin-bottom:4px; }
  .path-story-stars { gap:1px; }
}


/* ---- #1115 step 3: the end-of-story comprehension test -----------------------

   `study/comprehension.js`'s full-screen layer, and the one row in a story's star
   list that opens it. Appended here, in one block, so nothing above moves.
   The options and the forward control are the shared `.opt` and `.advance`
   (shared/beat_card.css), so only what is the quiz's own is declared: the layer,
   the lines, the question, a two-line option and the score card. Tokens only. */
/* Stops above the bottom nav (`--milim-nav-total`), which the app keeps on every screen. */
.quiz { position:fixed; inset:0 0 var(--milim-nav-total, 0px); z-index:60; overflow-y:auto; background:var(--ground); color:var(--ink); }
.quiz-card { box-sizing:border-box; max-width:430px; min-height:100%; margin:0 auto; outline:none;
             padding:12px 16px max(28px, env(safe-area-inset-bottom)); display:flex;
             flex-direction:column; gap:16px; }
.quiz-back { align-self:flex-start; min-height:44px; padding:0; background:none; border:0; cursor:pointer;
             font:400 12px/1 var(--mono); letter-spacing:.06em; color:var(--muted); }
.quiz-step { margin:0; font:600 10.5px/1 var(--mono); letter-spacing:.1em; text-transform:uppercase;
             color:var(--muted); }
.quiz-lines { display:flex; flex-direction:column; gap:10px; padding:14px; border:1px solid var(--line);
              border-radius:14px; background:var(--surface); }
.quiz-line { margin:0; font-family:var(--sans-he); font-size:calc(19px * var(--milim-text-scale)); line-height:1.6; }
.quiz-prompt, .quiz-title { margin:0; font:600 19px/1.3 var(--serif); }
.quiz-options { display:flex; flex-direction:column; gap:10px; }
.quiz-opt { display:flex; align-items:baseline; justify-content:space-between; gap:12px; min-height:48px; }
.quiz-opt-he { font-family:var(--sans-he); font-size:calc(19px * var(--milim-text-scale)); }
.quiz-opt-en { font-size:14px; color:var(--muted); text-align:right; }
.quiz-opt.correct .quiz-opt-en, .quiz-opt.wrong .quiz-opt-en { color:var(--ink); }
.quiz-verdict { margin:0; min-height:1.3em; font:600 14px/1.3 var(--serif); }
.quiz-note, .quiz-best { margin:0; font:400 15px/1.4 var(--serif); color:var(--muted); }
.quiz-score { margin:0; font:400 18px/1 var(--serif); color:var(--muted); }
.quiz-score b { font:600 64px/1 var(--serif); color:var(--ink); }
.quiz-dots { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:6px; }
.quiz-dot { display:flex; align-items:center; gap:10px; font:400 14px/1.3 var(--serif); }
.quiz-dot-mark { width:22px; height:22px; border-radius:50%; display:flex; align-items:center;
                 justify-content:center; font:600 12px/1 var(--mono); border:1px solid var(--line); }
.quiz-dot.is-right .quiz-dot-mark { border-color:var(--ok); background:var(--ok-soft); }
.quiz-dot.is-wrong .quiz-dot-mark { border-color:var(--no); background:var(--no-soft); }
.quiz-again { align-self:center; min-height:44px; padding:0 12px; background:none; border:0; cursor:pointer;
              font:400 12px/1 var(--mono); letter-spacing:.06em; color:var(--muted); text-decoration:underline; }
.quiz-back:focus-visible, .quiz-again:focus-visible, .path-quiz-open:focus-visible { outline:2px solid var(--accent);
                                                                                     outline-offset:2px; }
.path-quiz-row { margin-top:4px; }
.path-quiz-medal { width:24px; height:24px; border-radius:50%; flex:0 0 auto; display:flex; align-items:center;
                   justify-content:center; background:var(--raise); border:1px solid var(--line);
                   color:var(--muted); font-size:13px; line-height:1; box-sizing:border-box; }
.path-quiz-label { font:600 14px/1.2 var(--serif); color:var(--ink); flex:1 1 auto; min-width:0; }
.path-quiz-count { font:400 11.5px/1.3 var(--mono); color:var(--faint); flex:0 0 auto; white-space:nowrap; }
.path-quiz-open { display:flex; align-items:center; gap:10px; width:100%; min-height:44px; padding:0;
                  background:none; border:0; color:inherit; font:inherit; text-align:left; cursor:pointer; }


/* ---- #2950: the study card's buttons wear the skill games' slab ----

   The owner (2026-10-01, /study): "I'd like this unit to have the same style now
   for buttons as we see in the skill games. With the shadow and continue button."
   The skill games' style is `.path-btn` (above, #2859) and the slab Continue of
   #2908 (skills.css): an ink plate with a hard offset shadow, an ink rim round it,
   no gradient. This is that recipe ONCE, for the buttons a study card owns -- the
   answer options (`#ask .opt`, and the quiz's) and every forward control
   (`.advance`: the card's Continue, the new-words screen's, a beat's dismiss) --
   through custom properties, not one copy per button. The cue's replay speaker is
   NOT in it, on purpose: `.cuereplay` is one shared control mounted on this page and
   on the skill games (tests/test_inline_replay_shared.py), and in the games it is the
   bevelled blue plate it already is here, so the two pages already agree:

     --shade     the colour of the offset (a darker shade of the button's metal)
     --slab-off  how far the plate stands off the page; 6px is the zoom's, the
                 options and the replay take 4px so the 3px ink ring still clears
                 the 8px row gap between two options
     --depth     the resting shadow, built from the two above

   Continue is the control blue, as in the games (the new-words screen's "ready"
   green, `.advance.nwp-continue.is-ready`, is overridden: nothing was answered, and
   the games' unjudged Continue is blue all the way down). An option is the zoom's `ghost`
   plate (`--surface`, ink text, a grey shade); a Continue's verdict is its offset, as
   the games' keeps it (`.right`/`.wrong` swap it for `--ok`/`--no`), an option's is
   its fill and, for the right one, its offset too.
   The border is 3px where it was 1px, so the vertical padding comes down 2px and an
   option and a Continue are the SAME OUTER BOX they were: no card moves
   (tests/js/test_study_first_line_anchor.mjs, the advance-band tests) and the 44px
   floor holds. `translate`, not `transform`, does the press: `.advance:active`
   (shared/beat_card.css) always has, and `.opt` carries a `transform` transition
   and `.rise` an entrance. The press travels `--slab-off - 2px` onto a shadow that
   shrinks to 2px, so the plate's far edge stays put. Selectors name `#ask`/`#stage` (and `.quiz`), so an id outranks `.advance.right`, `.opt.correct` and the other (0,3,0) rules
   in shared/beat_card.css that would otherwise put the old pill back. `#round .roundstage .advance` in skills.css is this block's twin on the
   skills page; study.css is not loaded there. */
#ask .opt,
.quiz .opt,
#stage .advance,
#ask .advance,
.quiz .advance {
  --slab-off:6px;
  --slab-ok:var(--ok); --slab-no:var(--no); --slab-no-fill:var(--no-soft);
  --shade:color-mix(in srgb, var(--control) 70%, #000);
  --depth:var(--slab-off) var(--slab-off) 0 var(--shade), var(--slab-off) var(--slab-off) 0 3px #16181C;
  border:3px solid #16181C;
  box-shadow:var(--depth);
}
/* The option: the zoom's ghost plate. */
#ask .opt,
.quiz .opt {
  --slab-off:4px; --shade:#646E85; --rest-shadow:var(--depth);
  padding-block:10px; border-radius:8px; font-weight:600;
  background:var(--surface); color:var(--ink);
}
#ask .opt.muted,
.quiz .opt.muted { color:var(--muted); }
/* Verdict. A right option's offset turns green, as the games' Continue does. The
   bloom is parked -- its keyframe animates `box-shadow` and would swap the slab for
   a ring for 540ms and back; the halo is kept, still. A WRONG option keeps the grey
   offset and takes the red fill: its pulse (`.opt.wrong`, shared/beat_card.css, the
   list #1586 keeps) holds `--rest-shadow` under the ring, and a `.wrong` rule may
   not set a custom property (tests/test_beat_wrong_pulse.py), so a red offset would
   flicker grey for the pulse's 0.42s. The red reaches the rule as `--slab-no-fill`,
   declared on the base: a `.wrong` rule that NAMES `--no-soft` is read as a new
   judged surface that must join that list, and this is a restyle of one already in
   it. Continue's `.wrong` spells its red offset out, as skills.css does (a `.wrong`
   rule may set no custom property), through `--slab-no`; a Continue never pulses. */
#ask .opt.correct,
.quiz .opt.correct { --shade:var(--ok); background:var(--ok-soft); color:var(--ink);
                     animation:none; box-shadow:var(--depth), 0 0 22px var(--ok-bloom); }
#ask .opt.wrong,
.quiz .opt.wrong { background:var(--slab-no-fill); color:var(--ink); }
/* Continue and its family: the control blue, the games' type. */
#stage .advance,
#ask .advance,
.quiz .advance {
  min-height:44px; padding:7px 24px; border-radius:8px;
  background:var(--control); color:var(--control-ink);
  font-family:inherit; font-size:15px; font-weight:700; letter-spacing:.02em; text-transform:none;
}
#stage .advance.he,
#ask .advance.he,
.quiz .advance.he { font-family:var(--sans-he); font-size:16px; letter-spacing:0; }
#stage .advance.right,
#ask .advance.right,
.quiz .advance.right { --shade:var(--ok); }
#stage .advance.wrong, #ask .advance.wrong, .quiz .advance.wrong {
  box-shadow:6px 6px 0 var(--slab-no), 6px 6px 0 3px #16181C;
}
#stage .advance.wrong:active, #ask .advance.wrong:active, .quiz .advance.wrong:active {
  box-shadow:2px 2px 0 var(--slab-no), 2px 2px 0 3px #16181C;
}
#stage .advance::after,
#ask .advance::after,
.quiz .advance::after {
  background:linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, .4) 50%, transparent 65%);
}
/* The press, one rule for both. */
#ask .opt:not(:disabled):active,
.quiz .opt:not(:disabled):active,
#stage .advance:active,
#ask .advance:active,
.quiz .advance:active {
  translate:calc(var(--slab-off) - 2px) calc(var(--slab-off) - 2px);
  box-shadow:2px 2px 0 var(--shade), 2px 2px 0 3px #16181C;
  filter:none; transition-duration:0s;
}
#ask .opt.correct:not(:disabled):active,
.quiz .opt.correct:not(:disabled):active { box-shadow:2px 2px 0 var(--shade), 2px 2px 0 3px #16181C; }
/* #2962: the press moves the plate, so it must not move the TARGET. The press
   above translates the control (--slab-off - 2px) = 4px right and down; a tap that
   lands in the top or left 4px of the resting plate then finds, at lift-off, the
   page where the plate was -- the click goes to a common ancestor and the control
   never hears it. Measured in real Chrome on this page (CDP mouse press, 120ms,
   release at the same point): the top 3px and left 3px of a Continue missed, ~9% of
   a 124x44 plate, and the owner's "I clicked continue a few times" is partly that.
   While pressed, an invisible box the plate's own size sits where the plate WAS
   (shifted back by the press), so the union of the two covers the resting box. It
   is a pseudo-element of the control, so a hit on it is a hit on the control.
   `.advance` clips (`overflow:hidden`, shared/beat_card.css, for its sheen) and
   would clip this too, so the clip is lifted for the length of the press only --
   the sheen lights on release, after `:active` has ended. An `.opt` has no
   positioned box of its own; `relative` moves nothing. (Never set `position` on
   `.advance` here: `#ask .advance` is `fixed`, the pinned band.) */
#ask .opt:not(:disabled):active,
.quiz .opt:not(:disabled):active { position:relative; }
#stage .advance:active,
#ask .advance:active,
.quiz .advance:active { overflow:visible; }
#ask .opt:not(:disabled):active::before,
.quiz .opt:not(:disabled):active::before,
#stage .advance:active::before,
#ask .advance:active::before,
.quiz .advance:active::before {
  content:""; position:absolute; pointer-events:auto;
  top:calc(-3px - (var(--slab-off) - 2px)); left:calc(-3px - (var(--slab-off) - 2px));
  right:calc(-3px + (var(--slab-off) - 2px)); bottom:calc(-3px + (var(--slab-off) - 2px));
}
@media (hover: hover) {
  #stage .advance:hover,
  #ask .advance:hover,
  .quiz .advance:hover { color:var(--control-ink); filter:brightness(1.05); }
}

/* #3013: tomorrow's first picture under the #2810 come-back line
   (`session_summary.js` `nextUpHTML`): the picture the path draws for the next
   unit's story beside "Up next: 8 lines, about 2 minutes". Appended inside
   `#comeBackMount`, under Continue, so it never moves the button. The drawing is
   `MilimIsoArt`'s own `.path-iso`, whose ink rules (above) apply anywhere; only
   its box is set here. Colours are the card's own tokens, so both themes hold. */
.end .next-up { display:flex; align-items:center; justify-content:center; gap:10px; margin-top:2px; }
.end .next-up-pic { position:relative; flex:0 0 auto; width:48px; height:54px; }
.end .next-up-pic .path-iso { width:100%; height:100%; }
.end .next-up-text { font-size:14px; color:var(--ink); text-align:start; }

/* #3045: the unit's closing card on ONE screen (390x844 and 375x667), no scroll.
   The owner: the come-back line and the "Up next" picture sat below the fold, so a
   new learner could finish the card without ever seeing them. The praise headline
   is gone (session_summary.js); what is left is tightened here, ABOVE Continue,
   and nothing moves Continue's slot: the Keys mount holds a fixed box, so the Keys
   arriving a round trip after the paint fill space already there (#2962, #2810),
   and an empty `#sittingExtras` (the re-ask lands in it, rarely, on a card past
   the first) no longer costs a flex gap. The card's own rhythm is 10px (16 on a
   tall phone, where there is room), the gap the rest of `.end` keeps at 14, and the headline
   gives back the 20px UA margin either side that nothing asked for. The wins sit on one line: total, then parts. */
.checkpoint-card { gap:10px; }
.checkpoint-card .section-done { margin:0; }
@media (min-height:780px) { .checkpoint-card { gap:16px; } }
.checkpoint-card #sittingExtras:empty { display:none; }
/* #3065: the FIRST closing card's button label says "very good" and, after
   ~700ms (session_summary.js `CLOSING_SWAP_MS`), slides to its Hebrew. Both
   layers share one grid cell, so the button holds the wider of the two for the
   whole swap and nothing around it moves. The English is `::before` content
   from `data-en`: the label's text is the Hebrew alone, as on every later
   card. 380ms of travel, so ~1.1s in all. The button underneath is untouched
   (no pointer-events, no disabled): it is tappable the whole time. */
.checkpoint-card .swap-word { display:inline-grid; align-items:center; justify-items:center; }
.checkpoint-card .swap-word::before { content:attr(data-en); grid-area:1 / 1; direction:ltr;
  font-family:var(--sans-he); text-transform:none; letter-spacing:0; }
.checkpoint-card .swap-word > .swap-he { grid-area:1 / 1; }
.checkpoint-card .swap-word::before,
.checkpoint-card .swap-word > .swap-he { transition:opacity .38s ease, transform .38s ease; }
.checkpoint-card .swap-word.is-en::before { opacity:1; transform:none; }
.checkpoint-card .swap-word.is-en > .swap-he { opacity:0; transform:translateY(.7em); }
.checkpoint-card .swap-word.is-he::before { opacity:0; transform:translateY(-.7em); }
.checkpoint-card .swap-word.is-he > .swap-he { opacity:1; transform:none; }
@media (prefers-reduced-motion: reduce) {
  /* A swap with no movement: the same two states, a plain crossfade. */
  .checkpoint-card .swap-word::before,
  .checkpoint-card .swap-word > .swap-he { transition:opacity .2s linear; }
  .checkpoint-card .swap-word.is-en > .swap-he,
  .checkpoint-card .swap-word.is-he::before { transform:none; }
}
.checkpoint-card #unitKeysMount { min-height:34px; display:flex; align-items:center; justify-content:center; }
.checkpoint-card .unit-keys { flex-direction:row; flex-wrap:wrap; justify-content:center; align-items:center; gap:0 12px; }
.checkpoint-card .unit-keys-total { font-size:22px; }
