/* ─────────────────────────────────────────────────────────────────
   frost-motion.css — every animation of the match, in one place.

   Global (not scoped) so the match screen, the v2 components and the playground's Motion tab use the very same
   rules. Since redesign step 3 it is the prototype's app/motion.css
   (docs/redesign/motion.md) on the v2 tokens: the same keyframes, durations and curves, colours from tokens.css so
   both themes work. MotionParityTests holds it to the prototype beat for beat.

   The interaction playbook's line: crisp, snappy feedback, not bouncy. Overshoot is kept for your score counter only
   (score-roll, CLAUDE.md §15): no other one-shot keyframe here goes past where it ends (the pulses breathe by design),
   and no other timing curve leaves [0, 1]. Nothing here changes layout: transforms, shadows, filters and opacity only
   (§15 nothing jumps).

   Since redesign step 7g every screen is v2, and v1's own selectors (its track, ring ping, countdown circle, streak
   chip and Tailwind animate-* stops) are gone with its components. Where the prototype reshapes a shared class (the
   race beads and lanes), its shape stays scoped to the v2 component (.fm-racetrack …).

   Tailwind's utilities are !important (tailwindInput.css imports it `important`) and live in @layer utilities, and an
   important declaration beats an animation. So keyframes here animate only properties no utility sets on the element
   (transform, filter, box-shadow on our own wrappers). The few static overrides of a utility's value say !important
   AND sit in that same layer: an important declaration in a layer beats any unlayered one, so outside the layer they
   would lose. Inside it, this file loads after Tailwind's, so at equal specificity it wins. The design system's
   ds.css is unlayered, so a static rule here never restyles an .fm-* element (its own look wins); the beats are
   animations, which win over any normal declaration.

   Reduced motion is the switch js/reduced-motion.js puts on <html> (the device, or the app's own setting): the last
   section, :where(:root[data-reduced-motion]) …, in :where() so each rule keeps the specificity of the rule it stills.
   ───────────────────────────────────────────────────────────────── */

@layer utilities {

    /* ══ Answer input / NumPad display ════════════════════════════════════════
       The tint is an inset shadow, not a background: the NumPad display carries `bg-transparent`. */

    @keyframes flash-correct {
        0%   { box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0), 0 0 0 0 rgba(0, 0, 0, 0); }
        30%  { box-shadow: inset 0 0 0 999px var(--correct-soft), 0 0 22px 2px var(--correct); }
        100% { box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0), 0 0 0 0 rgba(0, 0, 0, 0); }
    }

    .input-flash-correct {
        animation: flash-correct 150ms ease-out forwards;
        border-radius: 0.75rem;
    }

    @keyframes flash-wrong {
        0%   { box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0), 0 0 0 0 rgba(0, 0, 0, 0); }
        30%  { box-shadow: inset 0 0 0 999px var(--wrong-soft), 0 0 22px 2px var(--wrong); }
        100% { box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0), 0 0 0 0 rgba(0, 0, 0, 0); }
    }

    /* Horizontal: the axis that reads as "no". */
    @keyframes shake {
        0%, 100% { transform: translateX(0); }
        18%      { transform: translateX(-7px); }
        36%      { transform: translateX(7px); }
        54%      { transform: translateX(-5px); }
        72%      { transform: translateX(5px); }
        90%      { transform: translateX(-2px); }
    }

    .input-flash-wrong {
        animation: flash-wrong 200ms ease-out forwards,
                   shake       200ms ease-out;
        border-radius: 0.75rem;
    }

    /* Correct, but the opponent got there first (motion.md: amber, with frostMotion.tooSlow's falling two-note). */
    @keyframes flash-slow {
        0%   { box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0), 0 0 0 0 rgba(0, 0, 0, 0); }
        30%  { box-shadow: inset 0 0 0 999px var(--warn-soft), 0 0 20px 2px var(--warn); }
        100% { box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0), 0 0 0 0 rgba(0, 0, 0, 0); }
    }

    .input-flash-slow {
        animation: flash-slow 350ms ease-out forwards;
        border-radius: 0.75rem;
    }

    /* Each typed digit on the NumPad display settles in with a tiny pop (keyed per position, so only the
       new digit mounts and plays it). Transform only: the digit is at full opacity on its very first
       frame (a keystroke renders at frame speed), and it never grows past its size. */
    @keyframes digit-pop {
        0%   { transform: translateY(2px) scale(0.85); }
        100% { transform: none; }
    }

    .digit-pop {
        display: inline-block;
        animation: digit-pop 110ms cubic-bezier(0.2, 0.8, 0.3, 1);
    }

    /* ══ NumPad keys ═══════════════════════════════════════════════════════════
       A key looks pressed the instant it is touched: numpad.js sets data-pressed on pointerdown, on
       every browser (CSS :active came ~150 ms into a hold on touch, never on a quick tap, and iOS
       Safari applies it only while a touchstart listener exists). In: no transition. Out: the key's own
       transition (ds.css's --dur-press, 75 ms), so the release eases over 75 ms on
       every engine. This is the keys' only pressed look: they carry no active: utilities (on Chromium/Android :active came
       on again for ~130 ms after a quick tap had lifted, a second darker press; the v2 NumPad takes ds.css's :active
       away). The keys' utilities are !important in this layer, so these are too, and the selector (0,3,1) outranks their
       bg-* (0,1,0). Same rule for both pads.
       The fill keeps the strength that was measured, not the prototype's --accent-soft (8% in Polar night): pressed
       against the resting key, digits 1.83:1 (the old :active was 1.09:1), clear 1.93:1, submit 2.04:1 (its icon stays
       5.2:1); on the v2 pad in Polar night, digits 1.82:1 and clear 1.97:1. The v2 submit key is a solid accent at rest, so pressed it brightens, as the prototype's does. Disabled
       keys never look pressed. */
    [data-numpad] button[data-pressed]:not(:disabled) {
        background-color: color-mix(in srgb, var(--accent) 30%, transparent) !important;
        border-color: color-mix(in srgb, var(--accent) 70%, transparent) !important;
        scale: 0.95 !important;
        transition: none !important;
    }

    /* Polar day's accent is darker on a light key: a digit needs half of it to read as pressed (1.57:1 against the
       resting key; 30% was 1.19:1). Before the clear and submit rules, which keep theirs (clear measures 1.58:1). */
    [data-theme="light"] [data-numpad] button[data-pressed]:not(:disabled) {
        background-color: color-mix(in srgb, var(--accent) 50%, transparent) !important;
    }

    [data-numpad] button[data-key="clear"][data-pressed]:not(:disabled) {
        background-color: color-mix(in srgb, var(--warn) 40%, transparent) !important;
        border-color: color-mix(in srgb, var(--warn) 70%, transparent) !important;
    }

    [data-numpad] button[data-key="submit"][data-pressed]:not(:disabled) {
        background-color: color-mix(in srgb, var(--accent) 60%, transparent) !important;
        border-color: var(--accent) !important;
    }

    [data-numpad] button.fm-key--submit[data-pressed]:not(:disabled) {
        background-color: var(--accent) !important;
        filter: brightness(1.2) !important;
    }

    .fm-key {
        touch-action: manipulation;
        -webkit-tap-highlight-color: transparent;
    }

    /* ══ Question card ═════════════════════════════════════════════════════════
       The card is `relative overflow-hidden`, so ::after is free for a sweep of light and ::before for
       the frost. Order matters: the one-shot feedback rules come after the standing states, so a flash
       wins over the match-point breathing while it plays, and the round states win the border. */

    /* Both players at match point: a warning glow breathes around the card's warning edge (the edge itself stays
       still). */
    @keyframes match-point-breathe {
        0%, 100% { box-shadow: 0 0 14px var(--warn-soft); }
        50%      { box-shadow: 0 0 30px var(--warn); }
    }

    .match-point-both {
        border-color: var(--warn) !important;
        animation: match-point-breathe 1.2s ease-in-out infinite;
    }

    /* Correct: a crisp sweep of frost light across the card and a green edge. No scale. */
    @keyframes question-sheen {
        from { transform: translateX(-110%) skewX(-18deg); }
        to   { transform: translateX(110%) skewX(-18deg); }
    }

    @keyframes question-edge-correct {
        0%   { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
        25%  { box-shadow: 0 0 0 1px var(--correct), 0 0 26px var(--correct-soft); }
        100% { box-shadow: 0 0 0 1px var(--correct-soft), 0 0 0 rgba(0, 0, 0, 0); }
    }

    .question-correct,
    .question-correct-hot,
    .question-correct-blazing {
        animation: question-edge-correct 300ms ease-out forwards;
    }

    /* The sweep stays white light in both themes (motion.md). */
    .question-correct::after,
    .question-correct-hot::after,
    .question-correct-blazing::after {
        content: "";
        position: absolute;
        inset: -10% 0;
        pointer-events: none;
        background: linear-gradient(90deg,
            transparent 0%,
            rgba(255, 255, 255, 0) 30%,
            rgba(200, 245, 255, 0.22) 46%,
            rgba(255, 255, 255, 0.38) 50%,
            rgba(0, 229, 255, 0.20) 56%,
            transparent 72%);
        animation: question-sheen 280ms cubic-bezier(0.3, 0.7, 0.2, 1) forwards;
    }

    /* Combo 3+: an accent edge on top of the green, longer sweep. */
    @keyframes question-edge-hot {
        0%   { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
        25%  { box-shadow: 0 0 0 1px var(--accent), 0 0 32px var(--accent); }
        100% { box-shadow: 0 0 0 1px var(--border-strong), 0 0 0 rgba(0, 0, 0, 0); }
    }

    .question-correct-hot {
        animation: question-edge-hot 360ms ease-out forwards;
    }

    .question-correct-hot::after {
        animation-duration: 320ms;
    }

    /* Combo 5+: a double sweep (two passes of light) and the strongest glow. */
    @keyframes question-edge-blazing {
        0%   { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
        20%  { box-shadow: 0 0 0 2px var(--accent), 0 0 44px var(--accent); }
        100% { box-shadow: 0 0 0 1px var(--border-strong), 0 0 0 rgba(0, 0, 0, 0); }
    }

    .question-correct-blazing {
        animation: question-edge-blazing 440ms ease-out forwards;
    }

    .question-correct-blazing::after {
        animation: question-sheen 240ms cubic-bezier(0.3, 0.7, 0.2, 1) 2 forwards;
    }

    /* Wrong: the card shakes once with the input (smaller), edge flashes red. */
    @keyframes question-edge-wrong {
        0%   { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
        30%  { box-shadow: 0 0 0 1px var(--wrong), 0 0 24px var(--wrong-soft); }
        100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
    }

    @keyframes shake-small {
        0%, 100% { transform: translateX(0); }
        20%      { transform: translateX(-4px); }
        40%      { transform: translateX(4px); }
        60%      { transform: translateX(-3px); }
        80%      { transform: translateX(2px); }
    }

    .question-wrong {
        animation: question-edge-wrong 220ms ease-out forwards,
                   shake-small         200ms ease-out;
    }

    /* Round over for this player, until the next question.
       Claimed: we won it — a quiet green edge holds. Frozen: we're out of it (wrong, too slow, time up,
       opponent scored) — the card frosts over and its question dims, so the eye goes to the revealed answer
       below. Only the question dims: dimming the whole card took the small "QUESTION n" label to 3.2:1, and it
       stays at its 5.5:1. The question is .question-enter on both cards (v2: .fm-q__text question-enter; the v2 card's
       ds.css --waiting state adds the dim opacity the prototype's rule has, and it eases here with the filter). */
    .question-claimed {
        border-color: var(--correct) !important;
        box-shadow: 0 0 22px var(--correct-soft);
        transition: border-color 160ms ease-out, box-shadow 160ms ease-out;
    }

    .question-frozen {
        border-color: var(--border) !important;
        transition: border-color 220ms ease-out;
    }

    .question-frozen .question-enter {
        filter: saturate(0.35) brightness(0.72);
        transition: filter 220ms ease-out, opacity 220ms ease-out;
    }

    /* On a light card, dimming lightens. */
    [data-theme="light"] .question-frozen .question-enter {
        filter: saturate(0.35) brightness(1.4);
    }

    .question-frozen::before {
        content: "";
        position: absolute;
        inset: 0;
        pointer-events: none;
        border-radius: inherit;
        background:
            radial-gradient(120% 90% at 0% 0%, rgba(232, 244, 253, 0.10), transparent 45%),
            radial-gradient(120% 90% at 100% 100%, rgba(232, 244, 253, 0.08), transparent 45%);
        animation: frost-in 220ms ease-out;
    }

    @keyframes frost-in {
        from { opacity: 0; }
        to   { opacity: 1; }
    }

    /* A new question arrives: legible from the first frame (starts at 0.4 opacity), sharp in 160ms. */
    @keyframes question-enter {
        0%   { transform: translateY(6px); opacity: 0.4; filter: blur(2px); }
        100% { transform: none; opacity: 1; filter: none; }
    }

    .question-enter {
        animation: question-enter 160ms cubic-bezier(0.2, 0.8, 0.3, 1);
    }

    /* ══ Player cards ══════════════════════════════════════════════════════════ */

    /* The card lights up around its edge when its player scores — no scale on the card itself. */
    @keyframes card-charge {
        0%   { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
        30%  { box-shadow: 0 0 0 1px var(--accent), 0 0 26px var(--accent); }
        100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
    }

    .card-charge {
        animation: card-charge 280ms ease-out;
    }

    /* The opponent's is fainter and slower: peripheral, never competing with your own input. */
    @keyframes card-charge-soft {
        0%   { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
        40%  { box-shadow: 0 0 0 1px var(--rival), 0 0 16px var(--border); }
        100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
    }

    .card-charge-soft {
        animation: card-charge-soft 420ms ease-out;
    }

    /* The score counter: rolls up from below, overshoots, settles (200ms). */
    @keyframes score-roll {
        0%   { transform: translateY(70%) scale(0.85); opacity: 0.15; filter: brightness(1); }
        55%  { transform: translateY(-14%) scale(1.22); opacity: 1;   filter: brightness(1.8); }
        100% { transform: none; opacity: 1; filter: brightness(1); }
    }

    .score-roll {
        display: inline-block;
        animation: score-roll 200ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
    }

    /* The opponent's rolls in smoothly, no overshoot. */
    @keyframes score-roll-soft {
        0%   { transform: translateY(45%); opacity: 0.2; }
        100% { transform: none; opacity: 1; }
    }

    .score-roll-soft {
        display: inline-block;
        animation: score-roll-soft 300ms ease-out;
    }

    /* The +N that floats up from a card when its player scores (800 ms). Placed by its component. */
    @keyframes score-float {
        0%   { transform: translateY(0); opacity: 1; }
        100% { transform: translateY(-26px); opacity: 0; }
    }

    .score-float {
        pointer-events: none;
        animation: score-float 800ms ease-out forwards;
    }

    /* Anyone at match point: the card pulses in the warning colour (ds.css turns its score warning). */
    @keyframes mp-pulse {
        0%, 100% { box-shadow: 0 0 8px var(--warn-soft); }
        50%      { box-shadow: 0 0 24px var(--warn); }
    }

    .fm-player--matchpoint {
        animation: mp-pulse 1.2s ease-in-out infinite;
    }

    /* ══ Race track (dual progress toward the target) ═════════════════════════
       Your lane is thick and bright, the opponent's thin and muted (peripheral). Both tween forward;
       the opponent's a little slower, so it glides rather than jumps. */

    /* The track lays its lanes out in its own CSS (v2/RaceTrack.razor.css, which also keeps the beads' z-index inside it). */
    .race-row      { position: relative; }

    .race-lane {
        position: absolute;
        inset: 0;
        overflow: hidden;
        border-radius: 9999px;
    }

    .fm-racetrack .race-lane { background: var(--surface-3); }

    /* Full-width fills slid in by transform: GPU-only, and nothing is stretched. */
    .race-fill {
        position: absolute;
        inset: 0;
        border-radius: 9999px;
        transition: transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
        will-change: transform;
    }

    .race-fill-me {
        background: linear-gradient(90deg, var(--primary-from), var(--accent));
        box-shadow: 0 0 10px var(--accent-soft);
    }

    .race-fill-opp {
        background: var(--rival);
        transition-duration: 640ms;
    }

    /* Combo 5+: light flows along your bar. */
    @keyframes race-flow {
        from { background-position: 200% 0; }
        to   { background-position: -200% 0; }
    }

    .race-fill-me.race-blazing {
        background: linear-gradient(90deg,
            var(--primary-from), var(--accent) 35%, var(--ink-strong) 50%, var(--accent) 65%, var(--accent));
        background-size: 200% 100%;
        animation: race-flow 1.1s linear infinite;
        box-shadow: 0 0 16px var(--accent);
    }

    /* The leading edge of each bar: a bead that rides the fill. Like the fill, it moves by transform
       only: the bead sits at the right edge of a full-width layer, and the layer slides by the same
       translateX as the fill, with the same curve. Above the ticks (z-index), so no tick line crosses it. */
    .race-head-track {
        position: absolute;
        inset: 0;
        z-index: 1;
        pointer-events: none;
        transition: transform 520ms cubic-bezier(0.22, 1, 0.36, 1);
        will-change: transform;
    }

    .race-head-track-opp { transition-duration: 640ms; }

    .race-head {
        position: absolute;
        top: 50%;
        left: 100%;
        border-radius: 9999px;
        pointer-events: none;
        transition: opacity 200ms ease;
    }

    /* Faceted diamonds. */
    .fm-racetrack .race-head { border-radius: 0; }

    .fm-racetrack .race-head-me {
        width: 10px;
        height: 10px;
        margin: -5px 0 0 -5px;
        transform: rotate(45deg);
        background: var(--ink-strong);
        box-shadow: 0 0 8px var(--accent);
    }

    .fm-racetrack .race-head-opp {
        width: 7px;
        height: 7px;
        margin: -3.5px 0 0 -3.5px;
        transform: rotate(45deg);
        background: var(--rival);
    }

    .race-head-idle { opacity: 0; }

    /* A point just scored: a flare bursts from the bead (keyed per score, so it plays once per point). */
    @keyframes race-flare {
        0%   { transform: scale(0.4); opacity: 0; }
        30%  { transform: scale(2.8); opacity: 1; }
        100% { transform: scale(3.6); opacity: 0; }
    }

    .race-head-flare {
        position: absolute;
        inset: 0;
        border-radius: 9999px;
        background: radial-gradient(circle, rgba(255, 255, 255, 0.95), var(--accent) 45%, transparent 70%);
        animation: race-flare 520ms ease-out forwards;
    }

    .race-head-opp .race-head-flare {
        background: radial-gradient(circle, var(--ink-muted), transparent 70%);
        animation-duration: 640ms;
    }

    .race-ticks {
        position: absolute;
        inset: 0;
        pointer-events: none;
    }

    .race-tick {
        position: absolute;
        top: 2px;
        bottom: 2px;
        width: 1px;
        background: var(--surface-0);
    }

    .fm-racetrack .race-tick { top: 0; bottom: 0; }

    /* The finish line: brightens to the warning colour once anyone is at match point. Drawn over the beads,
       as it always was. */
    .race-finish {
        position: absolute;
        z-index: 2;
        right: -1px;
        top: 0;
        bottom: 0;
        width: 2px;
        border-radius: 1px;
        background: var(--ink-faint);
        transition: background-color 300ms ease, box-shadow 300ms ease;
    }

    .fm-racetrack .race-finish { top: -3px; bottom: -3px; }

    .race-finish-hot {
        background: var(--warn);
        box-shadow: 0 0 10px var(--warn);
    }

    @keyframes finish-throb {
        0%, 100% { box-shadow: 0 0 6px var(--warn-soft); }
        50%      { box-shadow: 0 0 16px var(--warn); }
    }

    .race-finish-both {
        background: var(--warn);
        animation: finish-throb 1.2s ease-in-out infinite;
    }

    /* ══ Round timer ═══════════════════════════════════════════════════════════ */

    /* From the round's tick (⅔ of the round, at most 10 s) the ring breathes. */
    @keyframes timer-breathe {
        0%, 100% { transform: scale(1); }
        50%      { transform: scale(1.035); }
    }

    .timer-breathe {
        animation: timer-breathe 1s ease-in-out infinite;
    }

    /* From the round's heartbeat (⅓ of the round, at most 5s): a ring of light leaves the timer on every second,
       in step with the heartbeat: the ring's own octagon (an svg). */
    @keyframes timer-ping {
        0%   { transform: scale(0.85); opacity: 0.75; }
        100% { transform: scale(1.45); opacity: 0; }
    }

    .timer-ping {
        position: absolute;
        inset: 0;
        pointer-events: none;
        animation: timer-ping 700ms cubic-bezier(0.2, 0.7, 0.3, 1) forwards;
    }

    .timer-ping polygon {
        fill: none;
        stroke: currentColor;
        stroke-width: 2;
    }

    /* The last seconds darken the edges of the screen (from the round's heartbeat; harder in its last 15%, at most
       2s: MatchMotion.Vignette), and the edge beats with the heart.
       Fixed and click-through: it covers, it never moves anything. Each class sets a still darkness and the
       400 ms fade between them; there is no loop here. The beat is frost-motion.js's tick(): one Web Animation
       on opacity per heartbeat (a second one at +0.5 s once the heart beats twice a second), timed to the sound and shaped like it,
       relative to --urgency-base (a custom property does not transition, so a beat during the fade starts
       from where the fade is going, not from where it is). No utility sets opacity on this element, so
       nothing !important outranks the beat. Under reduced motion there is no beat: the darkness stays still.
       Polar day's edge is a lighter red. */
    .urgency-vignette {
        position: fixed;
        inset: 0;
        z-index: 30;
        pointer-events: none;
        opacity: 0;
        background: radial-gradient(ellipse 75% 70% at 50% 45%,
            transparent 55%,
            rgba(255, 82, 82, 0.10) 78%,
            rgba(40, 4, 8, 0.55) 100%);
        transition: opacity 400ms ease;
    }

    [data-theme="light"] .urgency-vignette {
        background: radial-gradient(ellipse 75% 70% at 50% 45%,
            transparent 55%,
            rgba(198, 40, 40, 0.08) 78%,
            rgba(120, 10, 20, 0.28) 100%);
    }

    .urgency-vignette.urgency-on {
        --urgency-base: 0.55;
        opacity: var(--urgency-base);
    }

    .urgency-vignette.urgency-critical {
        --urgency-base: 0.8;
        opacity: var(--urgency-base);
    }

    /* ══ Countdown 3-2-1 ═══════════════════════════════════════════════════════ */

    /* Lands at its size and stays there: no dip below it on the way in. */
    @keyframes countdown-punch {
        0%   { transform: scale(1.8); opacity: 0; filter: blur(6px); }
        45%  { transform: scale(1); opacity: 1; filter: none; }
        100% { transform: scale(1); opacity: 1; }
    }

    .countdown-punch {
        position: relative;
        display: inline-block;
        animation: countdown-punch 260ms cubic-bezier(0.2, 0.8, 0.3, 1);
    }

    @keyframes countdown-ring {
        0%   { transform: translate(-50%, -50%) scale(0.5); opacity: 0.7; }
        100% { transform: translate(-50%, -50%) scale(2.2); opacity: 0; }
    }

    /* v2: an octagon leaves each number (an svg, keyed with it). */
    .countdown-ring {
        position: absolute;
        left: 50%;
        top: 50%;
        width: 1.1em;
        height: 1.1em;
        pointer-events: none;
        color: var(--accent);
        animation: countdown-ring 600ms cubic-bezier(0.2, 0.7, 0.3, 1) forwards;
    }

    .countdown-ring polygon {
        fill: none;
        stroke: currentColor;
        stroke-width: 2;
        vector-effect: non-scaling-stroke;
    }


    /* ══ Combo chip ═══════════════════════════════════════════════════════════════
       Correct answers in a row: appears at 3, solid with a breathing glow at 5. */

    /* Grows into place and stops there: no back-out curve, no step past its size. */
    @keyframes streak-pop {
        0%   { transform: scale(0.85); opacity: 0; }
        100% { transform: none; opacity: 1; }
    }

    /* The glow breathes; the chip itself stays its size. */
    @keyframes streak-glow {
        0%, 100% { box-shadow: 0 0 6px var(--accent-soft); }
        50%      { box-shadow: 0 0 16px var(--accent); }
    }

    .combo-pop {
        animation: streak-pop 200ms ease-out;
    }

    .fm-combo--hot {
        animation: streak-pop   200ms ease-out,
                   streak-glow  1.1s  ease-in-out infinite 200ms;
    }

    /* The count itself ticks up on every new point of the streak. */
    @keyframes streak-bump {
        0%   { transform: translateY(-60%); opacity: 0; }
        100% { transform: none; opacity: 1; }
    }

    .streak-bump {
        display: inline-block;
        animation: streak-bump 160ms ease-out;
    }

    /* ══ PostMatch ═════════════════════════════════════════════════════════════ */

    /* Scale and blur only: letter-spacing would re-wrap the text and move what is below it. */
    @keyframes result-enter {
        0%   { transform: scale(0.9); opacity: 0; filter: blur(4px); }
        100% { transform: none; opacity: 1; filter: none; }
    }

    .result-enter {
        animation: result-enter 280ms cubic-bezier(0.2, 0.8, 0.3, 1);
    }

    @keyframes result-glow {
        0%   { filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0)); }
        40%  { filter: drop-shadow(0 0 18px var(--correct)); }
        100% { filter: drop-shadow(0 0 6px var(--correct-soft)); }
    }

    .result-enter-win {
        animation: result-enter 280ms cubic-bezier(0.2, 0.8, 0.3, 1),
                   result-glow  900ms ease-out 200ms forwards;
    }

    /* The rating change lands with a short flash of light once its count-up reaches the real number: a
       brightness flash, not a pop (no scale), so the number never grows past its size. */
    @keyframes count-land {
        0%   { filter: brightness(1); }
        35%  { filter: brightness(1.8); }
        100% { filter: brightness(1); }
    }

    /* No display change: the counted elements are blocks, and turning them inline would move the layout. */
    .count-landed {
        animation: count-land 220ms ease-out;
    }

    /* The XP bar (XpBar): its fill eases from the old total to the new one on a transform (never a layout
       property), no overshoot. A level-up empties it with no transition, then the new level's bar fills. */
    .xp-fill {
        transform-origin: left;
        transition: transform 600ms cubic-bezier(0.2, 0.8, 0.3, 1);
    }

    .xp-fill-instant {
        transition: none;
    }

    /* "LEVEL n!" lands with the same brightness flash as the rating's count: no scale. */
    .xp-levelup {
        animation: count-land 220ms ease-out;
    }

    /* Promotion: a sweep of light across the new tier badge, after the count has had its moment. */
    .tier-promoted {
        position: relative;
        overflow: hidden;
        border-radius: 0.75rem;
    }

    .tier-promoted::after {
        content: "";
        position: absolute;
        inset: -20% 0;
        pointer-events: none;
        background: linear-gradient(90deg, transparent 30%, rgba(255, 255, 255, 0.45) 50%, transparent 70%);
        animation: question-sheen 700ms cubic-bezier(0.3, 0.7, 0.2, 1) 500ms both;
    }

    /* ══ v2: pages, overlays, toasts and the rest ══════════════════════════════ */

    /* Something arriving: the IceBot offer, the opponent found (MatchmakingPanel). */
    @keyframes fade-up {
        0%   { transform: translateY(12px); opacity: 0; }
        100% { transform: none; opacity: 1; }
    }

    .fade-up {
        animation: fade-up 280ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
    }

    /* A page's content as it arrives (AppShell, step 4). */
    .page-enter {
        animation: fade-up 220ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
    }

    /* Dialog: the scrim fades in; the panel rises as a sheet below md and scales in, centred, from md. */
    @keyframes scrim-in {
        from { opacity: 0; }
        to   { opacity: 1; }
    }

    @keyframes sheet-up {
        from { transform: translateY(100%); }
        to   { transform: none; }
    }

    @keyframes dialog-in {
        from { transform: scale(0.96); opacity: 0; }
        to   { transform: none; opacity: 1; }
    }

    .scrim-in {
        animation: scrim-in 180ms ease-out both;
    }

    .sheet-in {
        animation: sheet-up 240ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
    }

    @media (min-width: 768px) {
        .sheet-in { animation: dialog-in 200ms cubic-bezier(0.2, 0.8, 0.3, 1) both; }
    }

    /* Toasts (after play only): down from the top on phones, in from the right from md; out quickly; the drain bar
       empties over the toast's own duration (its component sets animation-duration and pauses it on hover). */
    @keyframes toast-in-right {
        from { transform: translateX(110%); opacity: 0; }
        to   { transform: none; opacity: 1; }
    }

    @keyframes toast-in-down {
        from { transform: translateY(-110%); opacity: 0; }
        to   { transform: none; opacity: 1; }
    }

    @keyframes toast-out {
        to { transform: translateY(-8px); opacity: 0; }
    }

    @keyframes toast-drain {
        from { transform: scaleX(1); }
        to   { transform: scaleX(0); }
    }

    .toast-in {
        animation: toast-in-down 220ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
    }

    @media (min-width: 768px) {
        .toast-in { animation-name: toast-in-right; }
    }

    .toast-out {
        animation: toast-out 160ms ease-in forwards;
    }

    .fm-toast__drain {
        animation: toast-drain linear forwards;
    }

    /* The mark on Matchmaking and Home breathes while it waits. */
    @keyframes mark-breathe {
        0%, 100% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(0, 0, 0, 0)); }
        50%      { transform: scale(1.04); filter: drop-shadow(0 0 14px var(--accent-soft)); }
    }

    .mark-breathe {
        animation: mark-breathe 3s ease-in-out infinite;
    }

    /* A solo run's grade square pops in as it is decided. */
    @keyframes grade-in {
        0%   { transform: scale(0.6); opacity: 0; }
        100% { transform: none; opacity: 1; }
    }

    .grade-in {
        animation: grade-in 160ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
    }

    /* The game status bar's live diamond. */
    @keyframes live-pulse {
        0%, 100% { opacity: 1; }
        50%      { opacity: 0.35; }
    }

    .live-pulse {
        animation: live-pulse 1.6s ease-in-out infinite;
    }

    /* REMATCH: the accept state's outline pulses (colour only: under reduced motion the outline stays, still); the
       offer's drain eases each second. */
    @keyframes rematch-pulse {
        0%, 100% { outline-color: var(--correct); }
        50%      { outline-color: var(--correct-soft); }
    }

    .fm-rematch--incoming .fm-btn {
        animation: rematch-pulse 1s ease-in-out infinite;
    }

    .fm-rematch__fill {
        transform-origin: left;
        transition: transform 1s linear;
    }

    /* ══ Reduced motion ════════════════════════════════════════════════════════
       Movement goes; the colour of every signal stays (it carries the information). On whenever <html> carries
       data-reduced-motion (js/reduced-motion.js: the device asks, or the app's "Reduce motion" setting). The
       prototype's list. No screen uses a Tailwind animate-* utility any more (MotionCrispnessTests), so every beat's stop
       is here or in ds.css. */

    /* The wrong flashes keep their colour, without the shake. */
    :where(:root[data-reduced-motion]) .input-flash-wrong { animation: flash-wrong 200ms ease-out forwards; }
    :where(:root[data-reduced-motion]) .question-wrong    { animation: question-edge-wrong 220ms ease-out forwards; }

    /* What only moves is not drawn. */
    :where(:root[data-reduced-motion]) .question-correct::after,
    :where(:root[data-reduced-motion]) .question-correct-hot::after,
    :where(:root[data-reduced-motion]) .question-correct-blazing::after,
    :where(:root[data-reduced-motion]) .tier-promoted::after,
    :where(:root[data-reduced-motion]) .countdown-ring,
    :where(:root[data-reduced-motion]) .race-head-flare,
    :where(:root[data-reduced-motion]) .timer-ping { display: none; }

    /* What arrives is simply there. */
    :where(:root[data-reduced-motion]) .digit-pop,
    :where(:root[data-reduced-motion]) .score-roll,
    :where(:root[data-reduced-motion]) .score-roll-soft,
    :where(:root[data-reduced-motion]) .question-enter,
    :where(:root[data-reduced-motion]) .countdown-punch,
    :where(:root[data-reduced-motion]) .combo-pop,
    :where(:root[data-reduced-motion]) .streak-bump,
    :where(:root[data-reduced-motion]) .result-enter,
    :where(:root[data-reduced-motion]) .count-landed,
    :where(:root[data-reduced-motion]) .xp-levelup,
    :where(:root[data-reduced-motion]) .page-enter,
    :where(:root[data-reduced-motion]) .fade-up,
    :where(:root[data-reduced-motion]) .sheet-in,
    :where(:root[data-reduced-motion]) .toast-in,
    :where(:root[data-reduced-motion]) .grade-in,
    :where(:root[data-reduced-motion]) .timer-breathe,
    :where(:root[data-reduced-motion]) .mark-breathe,
    :where(:root[data-reduced-motion]) .live-pulse,
    :where(:root[data-reduced-motion]) .scrim-in { animation: none; }

    /* A pressed key keeps its colour and does not shrink. */
    :where(:root[data-reduced-motion]) [data-numpad] button[data-pressed]:not(:disabled) { scale: none !important; }

    /* Bars, the XP fill and the rematch offer's drain snap. */
    :where(:root[data-reduced-motion]) .xp-fill,
    :where(:root[data-reduced-motion]) .race-fill,
    :where(:root[data-reduced-motion]) .race-head-track,
    :where(:root[data-reduced-motion]) .fm-rematch__fill { transition-duration: 1ms; }

    /* The win keeps its glow; the +N and a toast on its way out only fade. */
    :where(:root[data-reduced-motion]) .result-enter-win { animation: result-glow 900ms ease-out forwards; }
    :where(:root[data-reduced-motion]) .score-float,
    :where(:root[data-reduced-motion]) .toast-out { animation-name: frost-in; animation-direction: reverse; }

    /* Nothing breathes, flows or pulses: the standing colours stay. */
    :where(:root[data-reduced-motion]) .race-fill-me.race-blazing,
    :where(:root[data-reduced-motion]) .race-finish-both,
    :where(:root[data-reduced-motion]) .match-point-both,
    :where(:root[data-reduced-motion]) .fm-combo--hot,
    :where(:root[data-reduced-motion]) .fm-player--matchpoint,
    :where(:root[data-reduced-motion]) .fm-rematch--incoming .fm-btn { animation: none; }

    /* ds.css's skeleton shimmer and searching dots rest (its own rule ends them at once; this takes them off). */
    :where(:root[data-reduced-motion]) .fm-dots i,
    :where(:root[data-reduced-motion]) .fm-skel { animation: none !important; }

    /* The toast drain stays full and still: a bar that vanished at once would read as "gone". */
    :where(:root[data-reduced-motion]) .fm-toast__drain { animation: none; }

    /* Buttons, tiles and cards never lift. */
    :where(:root[data-reduced-motion]) .fm-btn,
    :where(:root[data-reduced-motion]) .fm-tile,
    :where(:root[data-reduced-motion]) .fm-card--interactive { transform: none !important; }

    /* Loading spinners keep turning: they say something is happening. */

    /* The vignette has no animation of its own to stop: its beat is frost-motion.js's, which plays none under reduced
       motion. Its darkness (urgency-on / urgency-critical) stays, still. */

}
