/*
 * This is a manifest file that'll be compiled into application.css.
 *
 * With Propshaft, assets are served efficiently without preprocessing steps. You can still include
 * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard
 * cascading order, meaning styles declared later in the document or manifest will override earlier ones,
 * depending on specificity.
 *
 * Consider organizing styles into separate files for maintainability.
 */

/* "Clear all" filter affordance — pinned top-right of every filter panel
 * for muscle-memory consistency across silos. Approved 2026-05-28 per
 * `docs/ai/high-fidelity-mocks/clear-all-2026-05-28-filters.html`.
 * Host filter panels need `position: relative` so this anchors correctly. */
.filter-clear-all {
  position: absolute;
  top: 12px;
  right: 14px;
  background: none;
  border: none;
  font-size: 11px;
  color: #94a3b8;
  cursor: pointer;
  text-decoration: underline;
  padding: 2px 4px;
  font-family: inherit;
}
.filter-clear-all:hover { color: #1e3a5f; }
.filter-clear-all:disabled,
.filter-clear-all.is-disabled {
  color: #cbd5e1;
  cursor: default;
  text-decoration: none;
}

/* Turbo page-load progress bar.
 * Turbo injects a div.turbo-progress-bar at the top of <body> ~500ms into
 * any navigation that hasn't completed. Default styling is a blue stripe;
 * we restyle it to amber (matches the globe spinner + app accent) with a
 * subtle glow so it reads as the "language program" loading state. */
.turbo-progress-bar {
  height: 3px;
  background: linear-gradient(90deg, #f59e0b 0%, #fbbf24 60%, #fde68a 100%);
  box-shadow: 0 0 6px rgba(245, 158, 11, 0.55);
}

/* ▶ Text-to-speech button — the site-standard "hear this Italian" affordance,
 * emitted by shared/_tts_button. Four silos carry the class: exercises,
 * quizerator, verb_drill, stars.
 *
 * IT LIVES HERE, GLOBALLY, BECAUSE THE PARTIAL CARRIES NO <style> OF ITS OWN
 * AND THAT SEAM SHIPPED A BROKEN BUTTON TO PRODUCTION. shared/_tts_button is
 * rendered by verb_drill/_result and verb_drill/_question, which the CONTROLLER
 * injects into verb_drill/show — a page that defined `.tts-btn` nowhere. Measured
 * on fasterfluent.com through a real graded answer: the ▶ on every Conjugate-This
 * result card rendered at 0×0 (display:inline-block, padding:0, colour inherited
 * from its amber parent) — invisible and unclickable, clean console, green suite.
 *
 * 🔴 THIS RULE IS THE VISIBILITY FLOOR ONLY, AND THE OMISSIONS ARE THE POINT.
 * A global rule is NOT "safe because later copies override it" — a later
 * equal-specificity rule only wins the declarations it actually SETS, so
 * anything present only here lands unopposed on every consumer. Three would
 * have been moved by the obvious version of this rule, all measured:
 *   • `margin-left: 6px` — `exercises/_exercise_examples`' own copy omits it,
 *     and its button is a flex item in a `gap: 6px` row, so the gap would have
 *     become 12px on the surface Kevin hand-tuned ("the icon next to the
 *     sentence"). `stars/_detail_modal` (.star-detail-tts) and
 *     `verb_drill/_explore` (.vd-explore-example-tts) style the button through
 *     a DIFFERENT class entirely and set no margin, so both would shift too.
 *   • `vertical-align: middle` — same three consumers, same reason.
 * Both are consumer spacing, not a visibility floor, so neither is here.
 *
 * `svg:not([width])` for the same reason, and it is the subtle one: author CSS
 * beats presentational attributes, so a bare `.tts-btn svg { width: 16px }`
 * silently overrides the explicit `width="14"` on stars' icon and `width="12"`
 * on verb_drill's explore row. Only shared/_tts_button's <svg> declares no size
 * — a viewBox with no width/height has NO intrinsic size, which is exactly why
 * the production button measured 0×0 — so scoping to the attribute-less case
 * sizes precisely the icon that needs it and leaves every other one alone.
 *
 * The result is additive BY CONSTRUCTION rather than by claim: every
 * declaration here is either already set by each consumer or inert for it. The
 * five files with their own inline copy (exercises/_exercise_{teach,examples,
 * form,result}, quizerator/show) are untouched; deduping them is a separate
 * change with a wider blast radius.
 * Guarded by test/views/controller_rendered_partial_style_coverage_test.rb. */
.tts-btn {
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px 6px;
  color: #94a3b8;
  display: inline-flex;
  align-items: center;
  border-radius: 4px;
  transition: color 0.15s, background 0.15s;
}
.tts-btn:hover { color: #1e3a5f; background: #f1f5f9; }
.tts-btn.tts-loading { color: #cbd5e1; cursor: progress; }
.tts-btn.tts-playing { color: #1e3a5f; background: #e0e7ff; }
.tts-btn:disabled { cursor: not-allowed; }
.tts-btn svg:not([width]) { width: 16px; height: 16px; }

/* ▣ TAP FLOOR FOR THE GLOBAL NAV'S OWN CONTROLS — AT EVERY WIDTH (@agent1
 * 2026-08-08, routed by @hefe from their `a5b8b9e3` review; moved OUT of the
 * phone media query below by @hefe 2026-08-08 16:0x). `.lt-nav-logout` has
 * carried this overlay since the 2026-08-05 sweep; its three neighbours were
 * measured under the 44px floor on live prod at every width 320–430, in both
 * auth states, on every page in the app:
 *
 *   .lt-nav-settings   26×26      the gear        (signed in)
 *   .lt-nav-login      39.2×19.5  "Log in"        (signed out)
 *   .lt-nav-signup     69.22×30   "Sign up"       (signed out)
 *
 * ⭐ THE ROUTING SAID FOUR AND IT IS THREE, WHICH IS WHY THIS IS THE OVERLAY
 * RECIPE AND NOT A NEW DECLARATION. `.lt-nav-logout` was on that list at
 * 66.91×30 — but that is its ELEMENT box, and it already carries this overlay,
 * so its HIT region measures 64.9×44 and has all along. The instrument that
 * found the other three is the same one that cleared it: measure the region a
 * thumb meets, not the box.
 *
 * 🔴 WHY IT IS OUT HERE RATHER THAN IN THE `@media (max-width: 640px)` BLOCK
 * BELOW, AND THE ANSWER IS THAT IT WAS NEVER DECIDED — IT WAS INHERITED.
 * This rule shipped as a member of that block because that is where the overlay
 * recipe already lived. But the block was opened for the `.cj-chip` rows, whose
 * fix is a `min-height` — a REAL layout change whose cost argument is
 * phone-specific (22 chips wrap onto 13 lines at 390px, +102px on a card
 * already 805px tall on an 844px phone). An absolutely-positioned `::after`
 * costs no layout at any width, so the nav members paid none of that and were
 * bounded by it anyway.
 *   Measured on prod before this move, `WIDTHS=390,641,768,1024,1280`:
 * **32 under-floor readings, 8 of 10 per control** — 390 passed, and
 * 641/768/1024/1280 failed on every page in both auth states. `.lt-nav-login`
 * reads **39.2×19.5** there: 19.5px tall, under even the 24px floor this
 * project calls non-negotiable, on the ACTION path, on every page. A tablet and
 * a touch laptop are on that path too, and `hover: none` is not what the floor
 * is keyed to — a laptop with a touchscreen reports both.
 *   ⚠️ THE CHIPS DELIBERATELY DO NOT COME WITH IT. Their `min-height` sibling is
 * the layout change the phone bound was measured for; widening the query would
 * spend +102px of vertical chrome on a desktop that never asked for it. The
 * asymmetry is asserted in both directions by
 * `test/views/global_nav_control_tap_floor_test.rb`, whose chip control is a
 * LIVE rule rather than a fixture.
 *
 * ⚠️ HEIGHT ONLY, AND THE FLUSH `left:0; right:0` IS LOAD-BEARING HERE RATHER
 * THAN INCIDENTAL. The nav row has ~3px of horizontal slack at 390 — @hefe's
 * burger fix spent 4px of chrome to buy it — so an overlay that claimed
 * horizontal room would land on a neighbour. Measured: a 44px-WIDE overlay on
 * `.lt-nav-settings` puts 18px of its hit region inside `.lt-nav-burger`'s at
 * 320/360/390. Flush, it claims none, and the sweep reads 0 overlaps.
 * ⚠️ `.lt-nav-login` is therefore still under the floor on WIDTH (39.2px). That
 * is open, not forgotten — the rationale block in layouts/application.html.erb
 * carries the refuted cures, and script/measure_global_nav_control_floor.mjs
 * prints it every run.
 * ⭐ `.lt-nav-settings` USED TO BE NAMED HERE TOO (26px) AND IS NOT ANY MORE, so
 * do not count it off this comment. As of 2026-08-08 it is a burger-menu row
 * below 641px (320x46.5 on prod) and takes `min-width: var(--tap-floor)` at
 * 641px+, where paired arms measured that cure free. **One corner survives and
 * it is deliberately NOT called "known-open": a signed-in learner who has not
 * finished onboarding renders no burger menu, so their gear stays 26px wide on
 * phone. That case is measured by NOTHING — every probe account is onboarded —
 * which is a weaker claim than `.lt-nav-login`'s, not an equal one.** The probe
 * prints it under UNMEASURED for exactly that reason.
 * ⚠️ The two `<a>`s take `position: relative` from here and stay INLINE: an
 * inline element is a perfectly good containing block for an absolutely
 * positioned ::after (the `.lt-footer a` precedent measured both arms and
 * found `display: inline-block` an invisible layout change bought for
 * nothing). No display change, so no layout change, so no shrinkable sibling
 * can pay for this. */
.lt-nav-logout, .lt-nav-settings, .lt-nav-login, .lt-nav-signup {
  position: relative;
}
.lt-nav-logout::after, .lt-nav-settings::after,
.lt-nav-login::after, .lt-nav-signup::after {
  content: "";
  position: absolute;
  left: 0; right: 0;
  top: 50%;
  height: var(--tap-floor);
  transform: translateY(-50%);
}

/* ▣ Phone tap floor for the /conjugate chip rows (@hefe 2026-08-03).
 *
 * Measured on prod at 390×844 before touching anything: 38 chips in THREE
 * separately-defined classes across /conjugate and /conjugate/achievements render
 * 33.5-34px tall — every one of them under the 44px floor, and under it on the
 * VERTICAL axis only (widths are 47-161px, already clear).
 *
 * ⚠️ The obvious fix — `min-height: 44px` on the pill — is the wrong one here,
 * and BOTH reasons are measured rather than argued. These are `flex-wrap` rows:
 * at 390px the /conjugate filter's 22 chips wrap onto THIRTEEN lines, so every
 * pixel of chip height is charged thirteen times, onto a filter card that is
 * ALREADY 805px tall on an 844px phone. Applied and measured, min-height took
 * that card to 973px — **+168px** of exactly the vertical chrome above the work
 * area that 18e9a89 / 1af3cdd exist to remove.
 *   And it does not even buy the floor. Six of the 22 chips STILL failed, because
 * `border-radius: 20px` cuts the corners out of hit-testing: on a 49×44 pill the
 * corner of its own 44×44 box lands outside the painted shape, so the tap goes to
 * the card behind it. A taller pill cannot fix a rounded one.
 *
 * So the HIT box grows and the LAYOUT box does not: a transparent overlay,
 * exactly 44px tall and SQUARE (no radius — that is what recovers the corners),
 * centred on the pill. 44px is a floor on what a thumb must be able to hit, not
 * on what the page must spend. Measured: 805 → 907px, **+102px**, and all 38 chips
 * pass. That number is the DEPLOYED one — prod and local both measured 805 before
 * and 907 after, so the cost did not turn out to be smaller on prod the way the
 * per-chip arithmetic predicted (34px pills there vs 30px locally suggested the
 * pinned height would be free). Predicting it was wrong; the page was asked.
 *
 * ⭐ It lives HERE, in the shared sheet, because `.cj-chip` is defined
 * INDEPENDENTLY in verb_drill/show.html.erb and verb_drill/achievements.html.erb
 * with different accents and a comment in the second claiming "same look as the
 * /conjugate filter's .cj-chip" — a claim nothing enforces. Fixing this in one
 * page's <style> would read as shipped and leave the other surface at 34px. One
 * rule, keyed to the class, cannot drift the way two copies did.
 *
 * Height is pinned rather than inset so the region is 44px regardless of what
 * font metrics make the pill; left/right are flush because these chips are all
 * wider than the floor. A future NARROW chip would need horizontal room too, and
 * would collide with its neighbour's 8px column gap if it just took it — which
 * is exactly what verify_unit_conjugate_filter.mjs phase 2 refuses. Let it. */
@media (max-width: 640px) {
  /* ⚠️ The pitch between two stacked chips must be >= 44px or their hit regions
   * overlap and each steals the other's taps — that is geometry, and no overlay
   * trick escapes it. Pitch = chip height + row gap, so the chip height has to be
   * KNOWN. It was not: the same .cj-chip measures 34px on prod and 30px here,
   * because the two environments resolve different font metrics. A row gap tuned
   * to prod left nine chips colliding locally (measured, not reasoned).
   * So pin the height to what prod already renders — this is a no-op there and a
   * +4px correction locally — and the arithmetic stops depending on the font:
   * 34 + 12 = 46px of pitch, 2px clear of the floor, everywhere.
   * Note 34px is deliberately BELOW the 44px floor. It is not a shrunken version
   * of the naive fix; it is the pill staying the size it looks, while the overlay
   * above it does the reaching. */
  /* .ach-level-chip is the CEFR filter on /conjugate/achievements — a third pill,
     measured at the identical 49.1×33.5, on a page this change already touches.
     The prod sweep found it; reading the two files did not, because it is neither
     of the two .cj-chip copies. Fixing its neighbours and not it would leave the
     same surface half-done.
     ⚠️ ITS OVERLAY LEFT THIS BLOCK ON 2026-08-08 AND THIS HEIGHT PIN DID NOT —
     see the `.ach-level-chip` section below the block. The two halves of this
     rule turned out to have different answers, which is why they are now two
     rules. */
  .cj-chip, .cj-group-chip { position: relative; min-height: 34px; box-sizing: border-box; }
  /* Height pin only — `position: relative` is declared for this class OUTSIDE the
     block, because its overlay is. Split rather than repeated: a property
     declared in both places drifts in one and stays green in the other. */
  .ach-level-chip { min-height: 34px; box-sizing: border-box; }
  .cj-chip::after, .cj-group-chip::after {
    content: "";
    position: absolute;
    left: 0; right: 0;
    top: 50%;
    height: var(--tap-floor);
    transform: translateY(-50%);
  }

  /* ▣ EVERYTHING THAT USED TO FOLLOW HERE HAS LEFT THIS BLOCK (@hefe 2026-08-08)
   * — the standalone tap-region overlays and their rationale now sit BELOW it, at
   * top level. The chips keep the phone bound because `min-height: 34px` is a real
   * layout change measured for a phone; nothing else in here paid that cost.
   * ⚠️ AND "THE CHIPS" TURNED OUT TO BE TWO OF THE THREE — see immediately below. */
}

/* ▣ `.ach-level-chip`'s OVERLAY, OUT OF THE PHONE BLOCK — AND ITS `min-height`
 * DELIBERATELY LEFT IN (@agent2 2026-08-08).
 *
 * ➡️ THE OVERLAY ITSELF IS NOT BELOW THIS COMMENT — it is in the shared
 * `::after` selector list further down (search `.ach-level-chip::after`), joined
 * to the other unscoped controls because the body is identical. Only the HOST
 * rule is here, where its scope argument lives. Saying so because a rationale
 * block that names a rule it does not sit above is how the next reader stops
 * looking.
 *
 * 🔴 THE CHIP EXEMPTION ABOVE WAS ONE INFERENCE COVERING THREE CLASSES, AND
 * MEASURING IT SPLIT IT. The argument for keeping all three phone-bounded is
 * PITCH: two stacked 44px regions overlap unless the pitch clears 44, and the
 * phone arithmetic is 34px pinned + a 12px row gap = 46. That arithmetic was
 * measured at 390px, and grids reflow — so above 640px the row gap and the chip
 * height were simply unknown, for all three.
 *
 * Asked on live prod with the registered gate's own instrument
 * (`measure_standalone_tap_overlays.mjs`, `SIM_CSS` injecting exactly this rule)
 * at 641 / 768 / 1280, before/after differing by the stylesheet and nothing else:
 *
 *   .ach-level-chip  ×6   33.86 → 44.69px reachable, 6/6 PASS at all three widths
 *   .cj-group-chip   ×9   34.38 → 43.31px, 3 of 9 still under at all three
 *   .cj-chip        ×13   30.94 → 37.81px, 7 / 7 / 6 still under
 *
 * ⭐ THE DIFFERENCE IS THE ROW, NOT THE PILL. `.ach-filter-row` holds six CEFR
 * chips that do not wrap above 640px, so every one of them has clear air above
 * and below and the overlay reaches its full height. The `.cj-filter-row` chips
 * wrap onto three lines at 768 and their regions meet inside an 8px row gap —
 * the same collision the phone fix opened `row-gap: 12px` to avoid. Clearing
 * those two needs a DESKTOP row-gap change (`.cj-chip` renders 30px tall above
 * 640px, so it wants >= 14px against today's 8px), which is a layout call and
 * is filed rather than taken here.
 *
 * ⚠️ THE `min-height` STAYS PHONE-ONLY AND THAT IS THE HALF THAT IS EASY TO GET
 * WRONG. It is the phone pitch pin, and it is what makes 34 + 12 = 46 hold
 * regardless of font metrics (the same pill measures 34px on prod and 30px
 * locally). Above 640px nothing needs it, and spending it there would be the
 * mirror of the mistake this section documents — a bound inherited rather than
 * chosen, pointing the other way.
 *
 * ⚠️ `position: relative` HAD TO TRAVEL WITH THE OVERLAY. An absolutely-
 * positioned ::after on a host whose `relative` is still phone-bounded anchors
 * to a page-level ancestor above 640px — measured for `.ex-tab` at
 * `top: 2064.89px`, a region that is not merely wrong but elsewhere on the page.
 * Both directions are asserted in `test/views/standalone_tap_overlay_test.rb`
 * (`OVERLAY_UNSCOPED_HEIGHT_PHONE`).
 *
 * ⚖️ AND THE RISK THAT ACTUALLY NEEDED MEASURING WAS THE NEIGHBOURS', NOT THIS
 * CLASS'S: an overlay that reaches further can steal a control that was fine.
 * Control (2) of the gate — every visible interactive control on eight surfaces
 * must still win its own centre pixel — reported **0 stolen at 641 and 768**,
 * unchanged from the before-arm. At 1280 both arms report 15 stolen on
 * /conjugate/achievements: that is PRE-EXISTING on deployed prod, identical
 * before and after, and is filed separately rather than counted against or for
 * this change. */
.ach-level-chip { position: relative; }


/* ============================================================================
 * ▣ THE STANDALONE TAP-REGION OVERLAYS — OUT OF THE PHONE BLOCK (@hefe 2026-08-08).
 *
 * 🔴 EVERY RULE BELOW WAS INSIDE `@media (max-width: 640px)` UNTIL THIS COMMIT, AND
 * NOBODY EVER DECIDED THAT. It is the third time this exact bound has been found
 * inherited rather than chosen: `.cj-chip` opened the block for a `min-height`
 * whose cost argument is genuinely phone-specific (+102px on an 805px card at
 * 390), the global nav joined it on 08-08 because that is where the recipe lived,
 * and so did all of these. An absolutely-positioned `::after` costs no layout at
 * any width, so none of them paid that cost and all of them were bounded by it.
 *
 * MEASURED ON LIVE PROD BEFORE THE MOVE, `measure_standalone_tap_overlays.mjs` at
 * 768×1024 (the gate's own instrument, its default 390×844 run being green):
 * **42 failures**. Simulated with these rules un-scoped and nothing else changed:
 * **12**, and the 27 that cleared are every overlay-backed class on all eight
 * surfaces — `.howto-pill` and `.bug-report-link.is-corner` on SIX pages each,
 * `.cj-card-custom-btn` ×77, `.ach-tense-link` ×10, `.howto-skip` on five.
 * ⭐ AND THE ACTUATION CHECK MOVED WITH THEM: "a tap 8px BELOW .vp-close closes
 * the practice overlay" FAILED at 768 before and passes after, so this is taps a
 * learner was losing rather than a number on a report.
 *
 * ⚠️ THE RISK WAS NEVER THE HEIGHT, IT WAS THE NEIGHBOUR — grids reflow above
 * 640px, so a region that was alone on its row at 390 need not be at 768. That is
 * why the move is measured per surface and not inferred from the nav's: the gate's
 * control (2) samples every visible interactive control on the page and requires
 * each to still win its own centre pixel. **0 stolen on all eight surfaces**, in
 * both arms, including the 88-cell `/vocabulary?view=cards` grid.
 *
 * ⚠️ `.vp-close` IS SHIPPED PARTLY CURED AND SAYING SO IS THE POINT. Its downward
 * 44px region reads **20.28 → 36.27px** at 768: better, actuating, past the 24px
 * non-negotiable floor, and still short of 44. At 390 the card had 211.9–301.9px
 * of dead padding under it; at 768 it has ~36. Height-anchored-downward is right
 * and the ROOM is what ran out, so the cure is that card's bottom padding, not
 * this rule. KNOWN-OPEN with its number rather than quietly rounded up.
 *
 * ⚠️ THE CHIPS DELIBERATELY STAY BEHIND, and the reason is pitch, not height.
 * Two stacked 44px regions overlap unless the pitch clears 44, and that
 * arithmetic (34px pinned + 12px row gap = 46) only holds where the `min-height`
 * applies. Un-scoping their overlay without their `min-height` would hand two
 * chips one tap. Asserted in both directions by
 * `test/views/standalone_tap_overlay_test.rb`.
 * ============================================================================ */
/* ▣ The same overlay for the STANDALONE controls (@agent1 2026-08-05, @hefe's
 * "take them"). `script/measure_tap_targets.mjs` against prod at 390×844
 * returned 23 under-floor classes; these six are the ones alone on their row,
 * so unlike the chips above there is no pitch neighbour to collide with and no
 * density call to make. Measured, prod:
 *
 *   .cj-card-custom-btn       85.6×26.5  ×77   /conjugate card footers
 *   .cj-hide-mastered-toggle  118.2×32         /conjugate grid bar
 *   .cj-achievements-link     161.1×30         /conjugate header
 *   .ach-drill-now              306×35.5       /conjugate/achievements
 *   .bug-report-link.is-corner 74.4×28.5       GLOBAL — every page's corner
 *
 * ⤷ `.lt-nav-logout` (66.9×30, GLOBAL) WAS THE SIXTH ROW OF THIS TABLE AND LEFT
 * FOR ITS OWN RULE IN `0a2b37df`. Struck rather than silently deleted: the
 * paragraph above still says "these six" and the count is now five, so a reader
 * checking one against the other should find the discrepancy explained instead
 * of concluding the table has rotted. (Found by `design-system-reviewer` — the
 * nav commit had this block open and did not touch the cross-reference.)
 *
 * ⭐ EVERY ONE OF THEM MISSES THE FLOOR ON THE HEIGHT AXIS ONLY — the narrowest
 * is 66.9px wide. That is why this is height-only and flush left/right: there
 * is no horizontal room to take, and taking any would reach into a neighbour's
 * column gap. A future narrow member of this set would need width too, and
 * `script/measure_standalone_tap_overlays.mjs` is what refuses it rather than
 * anyone's eye.
 *
 * ⚠️ No border-radius, for the reason measured on the chips above: on a rounded
 * region the corner of its own 44×44 box lands outside the painted shape, so
 * six of 22 chips still failed with a rounded overlay. Every control here is a
 * pill, and the square overlay is what recovers the corners.
 *
 * ⚠️ `.is-corner` AND NOT THE BARE `.bug-report-link`, and the narrowing is the
 * measured half of this change rather than caution. `shared/_action_report_bug`
 * renders the SAME class in two placements: the floating corner (what the sweep
 * measured, ×1 on every page) and an INLINE copy — a row that also holds ⭐ Star.
 * A wrap row is a pitch problem, which is the one thing an overlay cannot solve
 * (see the chips above). So the corner one gets the floor and the inline one is
 * left under it, deliberately and on the record — rather than shipping an
 * unmeasured global change to every modal in the app. The corner placement is
 * already `position: fixed` (layout, 0,2,0), so it needs no `position` from
 * here; the five below are static and do.
 *
 * ▸ MEASURED 2026-08-05 (@agent2), `script/measure_inline_bug_link_pitch.mjs`
 *   against prod at 390×844 — this paragraph used to end "no probe here has
 *   opened a dialog to measure that row's pitch", and that is no longer true.
 *   The inline link is 83.2×32 in ALL of them. Nearest REACHABLE neighbour
 *   (elements behind a modal overlay excluded — the nav is not a tap threat):
 *
 *     stars-detail-modal     .star-detail-head-actions  .star-btn 24×24   dx=58.7 dy=4    clears +24.7 x
 *     phrases-explore-modal  .study-header-actions      .star-btn 24×24   dx=58.7 dy=4    clears +24.7 x
 *     exercise-explore-modal .explore-header            .star-btn 24×24   dx=74.2 dy=1.5  clears +40.2 x
 *     verb-drill-result      .dialog-actions            .vd-submit 89.5×44 dx=3.2 dy=48   clears +4 y   ⚠️
 *     quizerator-result      .dialog-actions            .fb-next 268×46.5  dx=92.4 dy=51.3 clears +6 y   ⚠️
 *
 *   ⭐ 5/5 measured hosts CLEAR: a 44×44 overlay fits without stealing a tap.
 *   ⚠️ But the two thinnest margins are 4px and 6px, both VERTICAL and both
 *   against the surface's PRIMARY CTA — so the overlay is safe at today's
 *   spacing and would be silently unsafe after any 5px change to it. That is a
 *   fact worth having before the rule ships, not after.
 *   🔴 STILL UNMEASURED, and unmeasured is not clean: `grammar_reviews/show_result`
 *   and `sore_spot_reviews/show_result` (both `.dialog-actions`, link alone).
 *   Neither is reachable without SRS data on the probe account. The probe names
 *   them and exits non-zero rather than reporting 5/5 as if it were 7/7.
 *
 * ▸ ↩️ RULED 2026-08-05 21:4x (@hefe) — 5/5 CLEAR AND THE ANSWER IS STILL NO ON
 *   FOUR OF THEM. `.bug-report-link.is-header` joins the overlay; the four
 *   `.dialog-actions` result cards deliberately do not, and the asymmetry is
 *   the point rather than an unfinished job:
 *
 *     modal headers (3)  neighbour .star-btn 24×24        +24.7 / +24.7 / +40.2 x  ✅ overlay
 *     result cards  (4)  neighbour the surface's CTA      +4 / +6 y (2 unmeasured) 🔴 NO overlay
 *
 *   The standing tap rule already carves this out: 44px is absolute on the
 *   ACTION PATH; 🐛 Feedback is a META control and the action path on a result
 *   card is Next/Continue. At 83.2×32 the link already clears both best-effort
 *   floors (≥24px ✅, non-overlapping ✅), so the overlay there is an
 *   IMPROVEMENT, not a debt payment — and an improvement does not get to spend
 *   safety. Growing a meta control's region to within 4px of the primary CTA
 *   swaps a small target for a WRONG-ACTION one, and those two failures are not
 *   comparable: you aim twice at the first, the second loses the learner their
 *   place in the drill. ⭐ The 4px/6px fragility flagged above therefore moves
 *   with the ruling — the tight pair is now deliberately un-overlaid, so a 5px
 *   spacing change there is no longer a silent safety change.
 *   ⚠️ `.is-header` AND NOT `.is-inline`: the same partial renders both, and
 *   widening this selector by one class hands the floor to all seven hosts.
 *   `test/views/inline_bug_link_tap_floor_test.rb` refuses that, and holds the
 *   RENDERED anchors (a floored star modal, an unfloored grammar-review card)
 *   that a CSS rule for an unrendered class would otherwise fake. Unlike the
 *   corner placement this one is STATIC, so it takes `position` from here. */
/* ▣ THE GLOBAL NAV'S FOUR CONTROLS USED TO BE IN THIS RULE AND ARE NOW ABOVE
 * IT, OUTSIDE THIS MEDIA QUERY (@hefe 2026-08-08). They pay none of the
 * phone-only layout cost that opened this block, and being scoped to it left
 * them unfloored on every viewport wider than a phone. Rationale, measurements
 * and the still-open WIDTH gap now live next to that rule. */
.cj-card-custom-btn, .cj-hide-mastered-toggle, .cj-achievements-link,
.ach-drill-now, .bug-report-link.is-header {
  position: relative;
}
/* ⭐ `.ach-level-chip::after` JOINS THIS LIST RATHER THAN OPENING A SIXTH COPY
 * OF THE SAME SEVEN LINES (@agent2 2026-08-08, design review of `ff4039b6`).
 * Its body was byte-identical to this one and it is unscoped for the same
 * reason, so this is the convention this file already states in its own words —
 * "one rule keyed to the class cannot drift the way six copies did". Only the
 * OVERLAY merges: `.ach-level-chip`'s host rule stays its own declaration a few
 * hundred lines up, because its scope story genuinely differs (its `min-height`
 * is still phone-bounded, and the rationale lives next to that). */
.cj-card-custom-btn::after, .cj-hide-mastered-toggle::after, .cj-achievements-link::after,
.ach-drill-now::after, .bug-report-link.is-corner::after,
.bug-report-link.is-header::after, .ach-level-chip::after {
  content: "";
  position: absolute;
  left: 0; right: 0;
  top: 50%;
  height: var(--tap-floor);
  transform: translateY(-50%);
}

/* ▣ Four more of the same shape, named by the 2026-08-05 prod sweep and
 * measured on prod at 390×844 before this change (@agent1 2026-08-05):
 *
 *   .howto-pill           130.4×33.5   /practice, /phrases, /conjugate — the ❔ pill
 *   .howto-skip              64×39     "Skip for now", inside the how-to modal
 *   .examples-readall-btn    90×36     the exercise surface's examples toolbar
 *   .ex-tab                  94×41.5   ×3, that surface's Grammar/Practice/Chat bar
 *
 * Same height-only, flush-left/right treatment for the same measured reason:
 * the narrowest of the four is 64px wide, so there is no horizontal room to
 * take. `script/measure_standalone_tap_overlays.mjs` refused all four before
 * this rule existed (11 failures, each naming its class) and is what has to
 * pass after it.
 *
 * ⚠️ `.examples-stop-btn` RIDES ALONG THOUGH NO SWEEP NAMED IT. It is the same
 * control in its other state — `exercises/show` renders both buttons in the
 * same toolbar, sharing one rule, and swaps them with `display:none` when
 * audio starts. Nothing in the gate can reach the playing state, so its
 * proof is a file read (`standalone_tap_overlay_test.rb`) rather than a
 * measurement, and that is on the record here. Fixing only the button you
 * can measure would drop the floor out from under a learner mid-playback.
 *
 * ⭐ `.ex-tab` IS GIVEN `position: relative` HERE THOUGH TWO OF ITS THREE
 * DEFINITIONS ALREADY SET IT — and the third is why. `.ex-tab` is declared
 * INDEPENDENTLY in `shared/_examples_modal_styles` (relative, top:1px),
 * `flashcards/index` (relative, top:1px) and `exercises/show` (NO position at
 * all). Keying the overlay to the two that happened to be positioned was
 * measured on a local browser: on /exercises/1 the ::after anchored to a
 * page-level ancestor and computed `top: 2064.89px` — a 44px hit region two
 * thousand pixels down the page, on the surface with THREE of these tabs. It
 * measured as a clean rule and a green declaration test the whole time.
 * A rule keyed to the class cannot drift the way three copies did; none of the
 * three sets a conflicting `position`, and the test refuses one that starts to.
 *
 * ⚠️ `.howto-pill` LIKEWISE — and its host had to CHANGE. The pill that
 * actually renders is `.howto-pill--inline` (measured: on all three surfaces
 * the single pill carries both classes), which was deliberately
 * `position: static` so it could never poke through a dialog. `static` cannot
 * host an absolutely-positioned ::after, so `shared/_how_to` now declares
 * `position: relative; z-index: auto` — which creates NO stacking context, so
 * the property that mattered is unchanged and still measured directly by
 * `script/measure_howto_overlay_coreach.mjs`. */
/* ▣ `.ach-tense-link` (55.3×15.8, the clickable tense names on
 * /conjugate/achievements) JOINS THIS SET FROM THE OTHER PILE, and how it got
 * here is the point. It was filed with the four targets an overlay CANNOT fix
 * — on the reasonable-sounding ground that in-table text links sit too close
 * together to grow a hit region without stealing a neighbour's taps. That is a
 * claim about DISTANCE, and nothing had measured the distance.
 *
 * `script/measure_pitch_target_gaps.mjs` asks the page: two 44-squares overlap
 * iff max(|dx|, |dy|) < 44 between their centres, so the deciding number is
 * each target's nearest interactive neighbour by that metric. On prod at
 * 390×844 this class's WORST pair is dx=40.6 dy=58 → max 58, clear of the
 * floor; 0 of 10 would collide. The three it was filed with are not close
 * calls in the other direction: `.ach-verb-link` is 77/77 colliding (dx=0
 * dy=31 — a 31px vertical stack), and `.ex-tts-btn`/`.star-btn` are 10/10 each
 * ON EACH OTHER, 28.5px apart in one row. Those three still need the density
 * call; this one never did. Height-only and flush left/right as above — the
 * link is 55.3px wide, so there is no horizontal room to take and none needed. */
.howto-skip, .examples-readall-btn, .examples-stop-btn, .ex-tab, .ach-tense-link {
  position: relative;
}
.howto-pill::after, .howto-skip::after, .ach-tense-link::after,
.examples-readall-btn::after, .examples-stop-btn::after {
  content: "";
  position: absolute;
  left: 0; right: 0;
  top: 50%;
  height: var(--tap-floor);
  transform: translateY(-50%);
}

/* 🔴 `.ex-tab` LEFT THIS SET ON 2026-08-07, AND IT IS A DEFECT FIX, NOT A
 * TIDY-UP: AN OVERLAY CANNOT GIVE THIS CLASS THE FLOOR, AND THE ONE IT HAD WAS
 * WORSE THAN NOTHING. Measured on live prod at 390×844 by walking a column of
 * `elementFromPoint` down the tab's own centre-x:
 *
 *     tab layout box  159.0 .. 200.5  (41.5px)
 *     declared ::after                 44px
 *     ACTUALLY REACHABLE  159 .. 198 = 40px
 *
 * `.ex-tabs` is `overflow-x: auto; overflow-y: hidden` below 640px — the
 * containment fix for the four-tab strip (exercises/show, and its own comment
 * explains that `overflow-y: hidden` is load-bearing because the tabs sit on
 * `margin-bottom: -2px` over the strip's border). Its box is exactly the tab's
 * 41.5px, and an absolutely-positioned ::after is clipped by an ancestor scroll
 * container. So the 44px was cropped back to 41.5 — and the `top: calc(50% +
 * 1px)` that used to live here, added to re-centre the region against the 2px
 * bottom border, then pushed what survived 1px DOWN into the clip and cost the
 * bottom 2.5px. The overlay delivered 1.5px LESS than the bare tab.
 *
 * ⭐ THE SHAPE, because it will recur: both edits were correct alone, they were
 * made in different files on the same day, and the media query that contains
 * the strip is the media query that voids the overlay — below 640px, which is
 * the only width where the floor is a thing at all. `overflow-y: visible` is
 * not an escape hatch: a computed `overflow-x: auto` forces the other axis to
 * `auto`, so the clip cannot be opted out of while the strip still scrolls.
 * A control inside a scroll container has to carry the height ITSELF, so
 * `exercises/show` now gives `.ex-tab` `min-height: 44px` and this class takes
 * the floor natively — the same call `_step_explainer.html.erb` already makes.
 *
 * ⚠️ `.ex-tab` STAYS IN THE `position: relative` LIST ABOVE ON PURPOSE. It is no
 * longer anchoring an ::after, but it is what paints the tab (and the active
 * tab's underline) OVER the strip's own `border-bottom` that its -2px margin
 * overhangs. Dropping it because its stated reason expired would be a visual
 * regression on the one tab a learner is looking at.
 *
 * ⚠️ AND NOTHING WAS RED FOR FOUR WEEKS. `standalone_tap_overlay_test.rb` checks
 * the DECLARATIONS, which were all present and correct. The browser gate that
 * measures the region — `script/measure_standalone_tap_overlays.mjs`, whose own
 * first line calls it a gate — was never added to `script/prod_gates.json`, so
 * it had not run against prod since it shipped. It refuses this in one line when
 * it runs. Registering it is the other half of this fix. */

/* ▣ `.vp-close` — the practice overlay's "Close" link (@agent1 2026-08-07).
 * 350×19.5 on live prod at 390×844: the SHORTEST control in the vocab lane,
 * and it is on every face of it. Height-axis only, exactly the shape every
 * rule above was written for — and it is the FIRST member that cannot be
 * CENTRED, which is why it gets its own rule instead of one more selector.
 *
 * 🔴 CENTRING IS REFUSED BY A MEASUREMENT, NOT BY TASTE. A 44px region centred
 * on a 19.5px control reaches (44 − 19.5) / 2 = 12.25px ABOVE it. Measured on
 * live prod, the gap from this button to the control above is 12.00px — and
 * that control is the surface's PRIMARY CTA — on four of the five surfaces:
 *
 *   /vocabulary CHOOSER   .vp-chooser-btn "Mixed"     171×47    gap 12.00
 *   /vocabulary FRONT     .vp-reveal      "Reveal"    103.1×45  gap 12.00
 *   /vocabulary WRITTEN   .vp-reveal      "Check"      99.1×45  gap 12.00
 *   /phrases    CHOOSER   .vp-chooser-btn "C2"        171×47    gap 12.00
 *   /vocabulary GRADED    .vp-action      "✓ Got it"  172×47.5  gap 39.50
 *
 * So a centred overlay takes 0.25px of the primary CTA on 4 of 5. That is the
 * case the `.dialog-actions` ruling above already decided — "growing a META
 * control's region to within 4px of the primary CTA swaps a small target for a
 * WRONG-ACTION one" — and Close is a meta control while Reveal/Check is the
 * action path. ⚠️ It is NOT the same verdict as that ruling, though, and the
 * difference is why this one gets a floor at all: `.bug-report-link` was 32px,
 * already clear of the ≥24px best-effort floor, so its overlay was an
 * IMPROVEMENT and "an improvement does not get to spend safety". At 19.5px
 * this one is under BOTH floors — it is a debt payment, so it is owed a fix;
 * it just is not owed one that reaches upward.
 *
 * ⭐ SO THE REGION IS ANCHORED DOWNWARD: `top: 0`, no transform. The room is
 * measured to be there rather than assumed — `.vp-card` carries 24px of bottom
 * padding and 211.9–301.9px of unclaimed space below this button across the
 * five surfaces, and ZERO in-card interactive elements intersect the region on
 * any face. The only thing under those 24.5px is the card's own dead padding,
 * which today does nothing when tapped. Two-armed on live prod by injection:
 * 6/9 sample points missing (thief `div.vp-card`) → 0/9, on all five.
 *
 * 🔴 THE OBVIOUS FIX WAS TRIED FIRST AND PROD REFUTED IT, 5/5. `min-height:
 * 44px; padding-bottom: 24px` reached the floor and swept 0/9 — and moved the
 * painted text UP 12.00px on every surface, because `.vp-card` centres its
 * content vertically, so growing the LAST child by 24.5px lifts the whole modal
 * by half of it. "It only spends dead space at the bottom" was the argument for
 * it and the argument was false. The overlay leaves the layout box at 19.5 and
 * the text rect byte-identical, which the gate now asserts as well.
 *
 * ⚠️ THE CLIP HAZARD THAT COST `.ex-tab` 4px TWICE IS MEASURED ABSENT, not
 * reasoned away: the full ancestor chain from this button to <body> computes
 * `overflow: visible` on every node, on all five surfaces (`clips: []`).
 *
 * ⭐ IT LIVES HERE, IN THE SHARED SHEET, BECAUSE `.vp-close` IS DECLARED TWICE
 * — `shared/_practice_drill_styles` (rendered by /phrases) and
 * `flashcards/index` (its own inline fork for /vocabulary), byte-identical
 * today with nothing enforcing it. That is the `.ex-tab` three-copies failure
 * exactly. And the copies are inline <style> in the BODY, i.e. LATER in
 * document order at equal specificity, so a `position` appearing in either one
 * would silently beat this rule; `vp_close_tap_floor_test.rb` refuses that. */
.vp-close { position: relative; }
.vp-close::after {
  content: "";
  position: absolute;
  left: 0; right: 0;
  top: 0;
  height: var(--tap-floor);
}

/* ============================================================================
 * THE SHARED TOAST BASE — one rule for the app's five confirmation pills.
 *
 * 🔴 WHY THIS IS NOT AN INLINE `<style>` BLOCK, WHICH IS THIS REPO'S DEFAULT.
 * The per-component convention holds for a component that lives on one page.
 * These five live on five (and `.bug-report-toast` on ~16, from the layout), and
 * they were five near-identical copies — so every fix to the shared shape was
 * five edits, and the two we have shipped were:
 *   `92ee62e` — `width: max-content`. A `position: fixed` box with `left: 50%`
 *     and no `width` is shrink-to-fit into the space RIGHT of the offset, so it
 *     caps at exactly viewport/2 and its text folds inside a 999px pill.
 *     `max-width: 90vw` looks like the bound and is irrelevant: the computed
 *     width is already far under the max. FOUR of the five had that max-width
 *     and all four were folding. `.bug-report-toast` — the confirmation after
 *     every 🐛 report, on ~16 pages — was live-broken at 320/360/375 and correct
 *     only at 390+, which is the single width its donor had ever been measured
 *     at. One copy being wrong is a bug; five copies is a bug that hides.
 *   `0182f27` — the 🐛 corner-FAB clearance. Stayed per-view, see below.
 *
 * ⚖️ WHAT IS IN HERE IS MEASURED, NOT ASSUMED. `script/measure_toast_computed_parity.mjs`
 * reads the BROWSER's computed values for all five on their real pages at 320px
 * and 390px. Identical across all five: position · left · width · max-width ·
 * text-align · background · color · border-radius · font-size · font-weight ·
 * opacity · transition. (`transition` only LOOKED divergent — `.cj-toast` spells
 * it `opacity 0.2s ease` and `ease` is the initial timing function, so the
 * browser computes the same string for all five.) `padding`, `box-shadow` and
 * the hidden `transform` agree 4-of-5 and are here with `.cj-toast` overriding.
 *
 * 🔴 FOUR THINGS DELIBERATELY LEFT IN THE VIEWS. Do not "finish the job":
 *   · `bottom` — genuinely differs (28px where there is no corner FAB, 110px
 *     where there is) and it is the FAB clearance, derived per surface.
 *   · `z-index` and `pointer-events` — `ZStackingAudit` scans `app/views` and
 *     ONLY `app/views`. Moving these here does not make the toasts pass its
 *     audit, it removes them from it: they stop being ranked, inert layers and
 *     become invisible ones, while the audit keeps reporting an empty
 *     regression set about a tree it can no longer see.
 *   · `.node-toast`'s flex trio — it is the one toast carrying a control (the
 *     mark-done Undo), which is also why it alone drops to `--z-toast-action`.
 * `test/config/toast_shared_base_test.rb` reds on each of those, and pins the
 * selector list below against `DesignCatalog::TOAST_LAYERS` so a SIXTH toast
 * cannot quietly skip the base — an uncovered toast gets no `width` and folds.
 * ========================================================================= */
.node-toast,
.cj-toast,
.bulk-toast,
.bug-report-toast,
.goal-edit-toast {
  position: fixed;
  left: 50%;
  /* The hidden state. `.show` / `.is-visible` (per view) settle it to 0. */
  transform: translate(-50%, 16px);
  /* `width` is load-bearing and `max-width` alone cannot do this job — see the
     `92ee62e` account above. `max-content` opts out of shrink-to-fit; the 90vw
     stays the ceiling, so a genuinely long message still wraps where it must. */
  width: max-content;
  max-width: 90vw;
  text-align: center;
  background: #1e293b;
  color: #ffffff;
  padding: 11px 18px;
  border-radius: 999px;
  font-size: 13px;
  font-weight: 500;
  box-shadow: 0 8px 24px rgba(15, 23, 42, 0.25);
  opacity: 0;
  transition: opacity 0.2s, transform 0.2s;
}

/* The revealed state, for the four toasts that spell the modifier `show`.
 *
 * 🔴 THIS LIST IS SHORTER THAN THE BASE ABOVE BY ONE, AND THE REASON IS THE
 * MODIFIER NAME, NOT THE RULE. `.cj-toast` spells it `.is-visible` and rises to
 * −8px instead of settling to 0, so it keeps its own rule in verb_drill/
 * _card_grid — unifying THAT would need a markup/JS change, which a CSS refactor
 * does not get to make.
 * ⚠️ `855333f` used that sentence to justify leaving all FIVE reveal rules in the
 * views, and the post-push design review refuted it: it is true of one class and
 * was applied to four. The other four were byte-identical, same selector spelling
 * and same body, and needed no markup change at all. Four blocks hand-kept in
 * sync — one of them on ~16 pages — is the exact shape the base above exists to
 * end. `toast_shared_base_test.rb` now derives this list from the registry, so a
 * SIXTH toast spelling its modifier `show` cannot be left out of it.
 */
.node-toast.show,
.bulk-toast.show,
.bug-report-toast.show,
.goal-edit-toast.show {
  opacity: 1;
  transform: translate(-50%, 0);
}

/* `.sr-only` — perceivable to a screen reader, to nothing else. The app's ONE
 * copy of the visually-hidden idiom, and the toast announcer's hiding half.
 *
 * 🔴 IT IS SHARED BECAUSE IT WAS ALREADY DUPLICATED, AND I ADDED THE THIRD COPY
 * BEFORE THE POST-PUSH REVIEW POINTED AT IT. The same declaration block was
 * already living as a per-view one-off in `app/views/geography/index.html.erb`
 * (`.geo-sr-list`) and `app/views/history/show.html.erb` (`.montage .sr-only`),
 * and the announcer made it three under a fourth name — one commit after
 * `855333f`, which existed to fold five copies of the toast pill into one rule.
 * Both call sites now point here. Geography KEEPS `.geo-sr-list` as a semantic
 * hook (five assertions in `geography_controller_test` target it, and they are
 * about the list's contents, not its hiding) — it just stops re-declaring how to
 * hide.
 *
 * 🔴 EVERY DECLARATION IS CHOSEN AGAINST A WAY OF HIDING THAT WOULD BREAK IT.
 * `display: none` and `visibility: hidden` are the two obvious ways to hide an
 * element and BOTH remove it from the accessibility tree — which for the
 * announcer is precisely the bug this exists to fix (`.bug-report-toast` shipped
 * `role="status" aria-live="polite"` at `display: none` and announced nothing
 * for its whole life; measured on live production 2026-08-07). `width/height: 0`
 * is the third trap: a zero-area box is treated as invisible by some assistive
 * tech, so this keeps a real 1px.
 *
 * The clip-rect + clip-path pair is the standard idiom; both are present because
 * `clip` is deprecated-but-honoured and `clip-path` is the replacement, and
 * dropping either loses one browser generation. This is the SUPERSET of the two
 * local copies (history's had no `clip-path`), so neither call site loses
 * anything. `white-space: nowrap` stops long content reflowing the page from
 * inside a 1px box.
 *
 * `test/config/toast_announcement_test.rb` reds on the display / visibility /
 * out-of-flow halves AND on any view re-declaring this idiom locally, which is
 * what stops a fourth copy. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  /* It sits at the top-left of the body, out of flow and 1px across. That is
   * almost certainly un-hittable already, but "almost certainly" is how this
   * project acquired its no-overlapping-UI rule, and an invisible element that
   * can take a click is the worst version of it to debug. Costs nothing.
   * ⚠️ NOT visible to `ZStackingAudit`, which reads `pointer-events` out of
   * `app/views` only — correctly so here: none of these are ranked layers and
   * none are in `DesignCatalog::TOAST_LAYERS`, so they are outside that model
   * rather than hidden from it. */
  pointer-events: none;
}
