/* ------------------------------------------------------------------------
   A design lab for ONE question: what should the vertical judgement line in
   Effortless Play look like, and what should the Perfect/Good/OK/Miss
   readout next to it say?

   Shaped after staging.app.miaoscore.com .../play?mode=effortless-play —
   a horizontal score of glowing paw prints scrolling right to left across a fixed
   vertical line, no keyboard. Two things there are already right and are
   copied rather than reinvented: the channel vocabulary (blue for one hand,
   green for the other) and the line's position about a quarter in. The notes
   themselves are the brand's paw mark here rather than plain circles, and
   meeting the line dissolves them instead of leaving them dimmed on stage.

   Two things there are what this lab is trying to fix:
     1. the band around the line is FLAT — one translucent rectangle, so you
        cannot see where Perfect ends and OK begins;
     2. the tally is collapsed behind a disclosure and set in grey on dark,
        which is unreadable at the exact moment you want it.
   ------------------------------------------------------------------------ */
/* --ink, --pink, --pink-soft, --left-hand, --right-hand and the type scale all come from
   tokens.css now. The two channel colours were already hand-copied to the app's exact hex;
   taking them from the app is what stops the next repaint of a hand leaving this lab behind.
   Only the tier ramp is local, because only this view has tiers. */
:root {
  /* Tier ramp. Deliberately not blue or green — those two already mean
     left channel and right channel on this stage, and a tier that reused
     them would be read as a hand. Amber reads as "hot" without being
     claimed by anything else here. */
  --perfect: #ffcf5c;
  --good: #b98cff;
  --ok: #8593aa;
  --miss: #ff5d7a;
}
/* The sprite <svg> in the markup is zero-sized, and a zero-sized inline element still
   generates a line box — without this the whole layout sits ~18px lower. Taken from the app
   verbatim. Not display:none, which can stop some engines resolving the mask out of it. */
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
body {
  /* Margin, full-height sizing, overflow and the rounded brand face are tokens.css's. Only
     the lab's own width floor, weight and stage wash are set here. */
  min-width: 720px;
  font-size: var(--text-md); font-weight: 500; line-height: 1.4;
  color: var(--pink-soft);
  background: linear-gradient(180deg, #2a1622 0%, #1a0f17 100%);
}
.app { height: 100%; display: grid; grid-template-rows: auto 1fr; }

/* header.css's bar, un-floated — the grid, the columns and every control come from there.
   Two of the app's reasons for floating it do not hold here: its picture is fullscreen and
   its scrubber draws the divider, where this lab is a stage under a readout whose whole
   thesis is that you can always read it. The rule the app's header may never break — never
   make its height conditional, because --fall is measured off main's height — does not reach
   this file either: nothing here is measured off main. So: a real row, always visible. */
header {
  position: static; height: auto; opacity: 1;
  grid-template-rows: auto; padding: 10px 18px;
  border-bottom: 1px solid rgba(255,255,255,.07);
}
button:focus-visible, .settings input:focus-visible { outline: 2px solid var(--pink); outline-offset: 2px; }

/* ---- the settings drawer ----------------------------------------------- */
/* The leading glyph is settings.css's now — it draws `.choice .glyph` for the app's own
   soundfont rows too, so the rule that used to live here went with it. A channel is the one
   that disagrees: it is drawn in its own colour. --hand is tokens.css's mapping off the
   .left-hand / .right-hand class the row carries, so repainting a channel in Appearance
   repaints the row that offers it, exactly as it repaints the paws. */
.choice.left-hand .glyph, .choice.right-hand .glyph { stroke: none; fill: var(--hand); }

/* settings.css draws all of it. One override, and it is the same disagreement the header rule
   above is: the app's bar FLOATS at a fixed --header-h, so its drawer hangs from that number;
   this bar is a real grid row and the drawer lives inside <main>, which already starts where
   the bar ends. Anything else here would be the lab redecorating a panel it is borrowing so
   the two cannot be compared, which is the one thing this file must not do. */
main > .settings { top: 0; }

/* ---- the readout ------------------------------------------------------- */
/* Top-right OF THE STAGE, where a rhythm game puts it — not in the control bar, which is
   where it was and where it did not belong. Three things were wrong there: a number that
   changes every beat was sharing a row with a button you press twice a run and losing the
   comparison; the app's real header auto-hides, so shipping this shape would take the score
   off screen mid-phrase; and the eye is on the line, not on the chrome, so the score has to
   be on the picture to be read without leaving the picture.
   Always open, always legible — the shipped readout hides behind a disclosure and is grey
   on near-black, so the two states you most want mid-phrase (a Miss landing, accuracy
   sliding) are the two you cannot see.
   No panel behind it: bare numerals on the stage, the way the verdict is, carrying the
   verdict's own text-shadow as insurance for the frame a bright paw passes under it. A
   card here would be a second rectangle competing with the zone for "UI laid on the
   stage", which is the thing this lab spent its first draft removing. */
.hud {
  position: absolute; top: 18px; right: 18px; z-index: 9;
  /* The stage under it is the tap target — the whole input model is "tap anywhere". */
  pointer-events: none;
  display: grid; gap: 8px; justify-items: end;
  text-shadow: 0 2px 10px rgba(0,0,0,.7);
}
/* Accuracy is the hero and everything else is its detail: it is the one number that
   answers "how did that go", and the four tiers are why. */
.accuracy { display: grid; justify-items: end; gap: 2px; }
.accuracy b {
  font-size: 34px; font-weight: 800; line-height: 1; color: #fff;
  font-variant-numeric: tabular-nums;
}
.hud span { font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: #a9829a; }

/* The shape of the run, which four numbers cannot give you at a glance — and at a glance
   is the only way this gets read mid-phrase. Same tier ramp as the zone around the line,
   so the band you were aiming at and the bar you earned are one vocabulary.
   ponytail: it grows by flex-grow, which is a layout animation. On a four-child row about
   180px wide that changes a few times a second, that is cheaper than the absolutely
   positioned scaleX rig it would take to composite it. */
.shape {
  width: 100%; height: 4px; display: flex; overflow: hidden; border-radius: 999px;
  background: rgba(255,255,255,.08);
}
.shape i { flex: 0 1 0; min-width: 0; transition: flex-grow 260ms ease; }
.shape .perfect { background: var(--perfect); }
.shape .good { background: var(--good); }
.shape .ok { background: var(--ok); }
.shape .miss { background: var(--miss); }

/* Fixed-width cells plus tabular figures: the row must not re-space itself as a count
   crosses from one digit to two, or every neighbour twitches on someone else's hit. */
.tallies { display: flex; gap: 14px; }
.tally { display: grid; justify-items: center; gap: 2px; min-width: 32px; }
.tally b { font-size: 17px; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; }
.tally.perfect b { color: var(--perfect); }
.tally.good b { color: var(--good); }
.tally.ok b { color: var(--ok); }
.tally.miss b { color: var(--miss); }
/* A tier you have not scored yet should not shout — but it still has to be readable, so
   this is the dimmest mauve that clears 4.5:1 against both ends of the stage gradient.
   The #6b5060 it used to be measured 2.7:1, which is a number you cannot actually read. */
.tally.zero b { color: #9c8092; }

/* The tier that just scored says so, and says it the way the line does: the verdict's own
   overshoot curve, so the pop at the line and the pop in the corner read as one gesture
   rather than two things that happened to move. Rising from below is the roll — the new
   count arrives from under the old one's baseline. Transform and opacity only. */
.tally.hit b { animation: tally-pop 200ms cubic-bezier(.2,1.3,.4,1); }
@keyframes tally-pop {
  0%   { opacity: .3; transform: translateY(6px) scale(.84); }
  100% { opacity: 1; transform: none; }
}
/* A Miss is the one verdict you did not ask for, so it arrives differently: the same rise,
   then a short jolt across. Distinct without spending a fifth colour on it. */
.tally.miss.hit b { animation: tally-miss 260ms cubic-bezier(.3,1.2,.5,1); }
@keyframes tally-miss {
  0%   { opacity: .3; transform: translate(0, 6px) scale(.84); }
  45%  { opacity: 1; transform: translate(-3px, 0) scale(1.07); }
  72%  { transform: translate(3px, 0) scale(1); }
  100% { transform: none; }
}

/* ---- stage ------------------------------------------------------------- */
main { position: relative; min-height: 0; overflow: hidden; }
.stage {
  position: absolute; inset: 0; overflow: hidden;
  /* Scopes the plumes' plus-lighter blend. Without it the additive strike
     leaks past the stage and brightens the page behind it — the same
     containment the app gets for free from .keyboard's drop-shadow filter.
     The isolation belongs HERE and never on .plume itself: isolating an
     individual plume would scope the blend to that one element and kill
     the summing where two strikes overlap, which is the whole reason the
     blend is additive. */
  isolation: isolate;
  background:
    radial-gradient(70% 90% at 18% 30%, rgba(64, 128, 104, .16), transparent 70%),
    radial-gradient(60% 80% at 72% 45%, rgba(96, 78, 150, .16), transparent 70%),
    linear-gradient(180deg, #150c13, #241422);
}
/* The player's own photo, on the stage's wash rather than instead of it — the line and the
   zone still need something to sit on when the image is dark. --bg-image and --bg-alpha are
   the app's own two properties, written by the same applyTheme(), so this is waterfall.css's
   .waterfall-backdrop with no element of its own: nothing in here is measured off the stage's
   children, so a pseudo does the job a spare div does over there.
   Generated first, so it paints under every positioned child and over the gradients. */
.stage::before {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: var(--bg-image, none) center / cover no-repeat;
  opacity: var(--bg-alpha, 0);
}

/* ---- the judgement line, and the target zone around it ----------------- */
/* The whole point of the lab. The zone's WIDTH is the hit window, drawn at
   the stage's own scale: --pps converts seconds of tolerance into pixels of
   travel, so changing the scroll speed rescales the zone instead of letting
   it lie about how much slack you have.

   Paws travel right to left, so the axis has a direction and it is the
   opposite of the one you would guess: a note you hit EARLY has not reached
   the line yet and is still to the RIGHT of it; a note you hit LATE has
   already gone past, to the LEFT. Every offset below is signed that way.

   Audition draws a still bar with a bead sweeping it and a white glowing
   section as the target; only the target transfers here, because the paws
   already carry the beat and a second travelling marker would compete with
   them for the same glance. What they cannot carry is your tolerance. */
/* ONE element with a continuous falloff, not three stacked boxes. The first
   version drew a bordered rectangle with two nested rectangles inside it,
   and the hard edges were the problem: three visible seams read as UI
   chrome layered on the stage rather than as light around the line, and the
   outer border boxed the whole thing in.

   The tier colours survive as gradient stops, so the ramp still teaches —
   the hue under the paw when you tap is the hue the tally will increment —
   but nothing announces an edge. The stops are positioned from the real
   window ratios (--good-pct, --perfect-pct, set in measure()), so the
   colour still changes exactly where the verdict does; it just changes
   gradually instead of stepping.

   The vertical mask is the other half of "not a box": without it the zone
   ends in a hard horizontal cut at the top and bottom of the stage. */
.zone {
  position: absolute; top: 0; bottom: 0; left: 0; z-index: 4; pointer-events: none;
  transform: translateX(-50%);
  width: calc(var(--ok-px, 40) * 2px);
  background: linear-gradient(90deg,
    transparent 0%,
    color-mix(in srgb, var(--ok) 26%, transparent) calc(50% - var(--good-pct, 28%)),
    color-mix(in srgb, var(--good) 38%, transparent) calc(50% - var(--perfect-pct, 14%)),
    color-mix(in srgb, var(--perfect) 52%, transparent) 50%,
    color-mix(in srgb, var(--good) 38%, transparent) calc(50% + var(--perfect-pct, 14%)),
    color-mix(in srgb, var(--ok) 26%, transparent) calc(50% + var(--good-pct, 28%)),
    transparent 100%);
  -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 12%, #000 88%, transparent 100%);
  mask-image: linear-gradient(180deg, transparent 0%, #000 12%, #000 88%, transparent 100%);
}
/* The line itself keeps the shipped pink — it is the one part of this that
   players already recognise, and a bright core so a paw sitting on it
   still reads. */
.line {
  position: absolute; top: 0; bottom: 0; left: 0; width: 2px; z-index: 5;
  transform: translateX(-50%); pointer-events: none;
  background: linear-gradient(180deg, transparent, var(--pink) 8%, #ffe3f0 50%, var(--pink) 92%, transparent);
  box-shadow: 0 0 16px rgba(255, 79, 154, .75);
}

/* ---- the scrolling score ---------------------------------------------- */
/* ONE animated element, not one per note. The strip carries every paw and
   slides as a single composited transform — the shipped stage has hundreds
   of paws in a 2:16 score, and animating each one separately is the
   difference between one transform per frame and hundreds. */
.strip { position: absolute; top: 0; bottom: 0; left: 0; width: 0; will-change: transform; }
.strip.rolling { animation: scroll calc(var(--span-s, 60) * 1s) linear forwards; }
@keyframes scroll {
  from { transform: translateX(calc(var(--line-x, 300) * 1px)); }
  to   { transform: translateX(calc((var(--line-x, 300) - var(--span-px, 6000)) * 1px)); }
}

/* The note is the brand mark. Sized 30px, not smaller: the app found that
   below ~15px a paw's toe beans go sub-pixel and the mark reads as a spark
   instead of a paw — the whole signature is lost at a glance. Each one is
   rotated a few degrees of its own so a run reads as a trail of prints
   rather than one sprite stamped repeatedly. */
.paw {
  position: absolute; width: 30px; height: 28.5px; margin: -14.25px 0 0 -15px;
  filter: drop-shadow(0 0 9px color-mix(in srgb, var(--hand) 72%, transparent));
}
.paw svg { width: 100%; height: 100%; display: block; }
/* .left-hand / .right-hand are tokens.css's and set --hand. The SHAPE is assets/paw.svg,
   cut out of the fill by the #paw mask in the markup; only the gradients are local, because
   only this view paints the mark itself in the channel's colour. The fill sits on the <svg>
   and reaches the masked <rect> by inheritance, so these two rules did not change when the
   geometry stopped being a copy. */
.paw.left-hand svg { fill: url(#paw-left); }
.paw.right-hand svg { fill: url(#paw-right); }

/* The two channel gradients read --left-hand / --right-hand instead of the hex they were
   hand-copied from, which is what makes the panel's channel colour reach the mark itself and
   not just the glow around it. Two things make this possible and both are worth knowing:
   stop-color is a real CSS property, not only an attribute, so a rule beats the markup's
   fallback; and custom properties inherit from :root down through the sprite's own <defs>,
   which is the only route a gradient sitting in a <defs> has to see them.
   The ramp is anchored on the CHOSEN colour rather than on absolute lightnesses: the middle
   stop IS the colour, the top is pushed most of the way to white and drained of chroma, the
   base is darkened. That way a paw reads as its swatch whatever the swatch is — the app's own
   oklch(from ...) idiom, and the reason nothing downstream spells a shade out. */
#paw-left  stop:nth-child(1) { stop-color: oklch(from var(--left-hand)  calc(l + (1 - l) * .82) calc(c * .2) h); }
#paw-left  stop:nth-child(2) { stop-color: var(--left-hand); }
#paw-left  stop:nth-child(3) { stop-color: oklch(from var(--left-hand)  calc(l * .88) c h); }
#paw-right stop:nth-child(1) { stop-color: oklch(from var(--right-hand) calc(l + (1 - l) * .82) calc(c * .2) h); }
#paw-right stop:nth-child(2) { stop-color: var(--right-hand); }
#paw-right stop:nth-child(3) { stop-color: oklch(from var(--right-hand) calc(l * .88) c h); }

/* An unfocused channel recedes. Channel focus hands those onsets to the app, so they are
   accompaniment — still there, still on time, still worth hearing, but not what your eye is
   working on. At full strength a second channel's paws and strikes pull the glance off the one
   you are actually playing, which is the whole thing focus was picked to stop.
   Recede, do NOT hide: the accompaniment arriving is half of why you practise one hand against
   the other, and a paw you cannot see is a channel that stopped existing.
   The dimming goes on the ARTWORK and on the glow, never on the element — .paw.spent,
   .paw.missed and .spark all animate their own opacity, and a rule on the element would be
   overridden the moment a strike ran. A paw that sits dim and then flashes to full as it
   collapses is worse than no dimming at all. The <svg> child is not animated, so the two
   multiply instead of fighting. .plume is the exception and can take it directly: the animation
   there is on its ::before. */
.paw.muted, .spark.muted { filter: none; }
.paw.muted svg, .spark.muted svg { opacity: .38; }
.plume.muted { opacity: .3; }

/* Meeting the line dissolves it: the big paw collapses inward while three
   small ones break off and drift — the app's own signature when a note's
   light dies, reused here so the two views speak the same language.
   Collapsing rather than growing is what makes the small paws read as
   coming OUT of it instead of arriving alongside. */
.paw.spent { animation: paw-collapse 240ms ease-in forwards; }
@keyframes paw-collapse {
  0%   { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(.5); }
}
/* A note that went by untouched just goes out. No light, no prints: the
   strike below is what a note SOUNDING looks like, and a miss that gets
   the same burst tells the player they did something when they did not. */
.paw.missed { animation: paw-fade 520ms ease-out forwards; }
@keyframes paw-fade {
  0%   { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(.82); }
}

/* ---- the strike ------------------------------------------------------- */
/* One bar of light lying ON the line — the app's plume turned a quarter
   turn, because the line here is vertical where the keyboard's is flat.
   Everything else is carried over unchanged, including the reasoning:

   A SOLID pill with a hard edge plus a static glow, NOT a radial gradient.
   The app tried gradients on the theory that softness is drawn rather than
   filtered; it does not apply here, because a radial gradient cannot make a
   crisp rounded end and the result read as a soft mound. The edge IS the
   design.

   The glow is a box-shadow, painted once when the element is created, after
   which only opacity and transform animate. That is a different thing from
   filter: blur(), which re-runs a Gaussian every frame over radius^2 x
   painted area on an element that exists once per struck note. */
.plume {
  position: absolute; z-index: 5; width: 13px; height: 46px;
  margin: -23px 0 0 -6.5px; pointer-events: none;
}
.plume::before {
  content: ""; position: absolute; inset: 0; border-radius: 999px;
  transform-origin: center;
  background: color-mix(in oklch, var(--hand) 22%, white);
  box-shadow: 0 0 20px 6px color-mix(in srgb, var(--hand) 78%, transparent);
  mix-blend-mode: plus-lighter;
  /* Two animations, not one: the bar always opens in the same 190ms, then
     stands before it goes. Scaling a single keyframe set to the note's
     length instead would make a long note open in slow motion. */
  animation:
    plume-open 190ms cubic-bezier(.16,1,.3,1) both,
    plume-fade 260ms ease-in 330ms forwards;
}
/* Opens OUT of the strike point, the way a lit line brightens outward from
   where it was hit. The app's version climbed once (scale .18 -> 1 along
   the travel axis) and that is the one motion a bar must not have, or it
   stops being a strike and becomes a beam. Here the travel axis is x, so
   the bar opens on y and never widens along x. */
@keyframes plume-open {
  0%   { opacity: 0; transform: scaleY(.2); }
  100% { opacity: 1; transform: scaleY(1); }
}
@keyframes plume-fade {
  0%   { opacity: 1; transform: scaleY(1); }
  100% { opacity: 0; transform: scaleY(.74); }
}

/* The break-off prints. 16px against the note's 30px, and they open from
   scale(.45), so they run ~7px to 16px — the app found that below ~15px a
   paw's toe beans go sub-pixel and it reads as a spark, which is exactly
   the size this wants to sit just above.
   They live on .stage, not inside .strip: the burst belongs to the line,
   which does not move, and riding the scroll would drag it ~130px left
   over its life. */
.spark {
  position: absolute; width: 16px; aspect-ratio: 40 / 38;
  opacity: 0; pointer-events: none; z-index: 6;
  filter: drop-shadow(0 0 6px rgba(246, 244, 248, .4));
  animation: paw-scatter 560ms ease-out forwards;
}
/* White, not the channel colour. Nine blue or green prints thrown across
   the stage on every strike competed with the notes still falling — the eye
   kept being pulled to debris instead of to what was coming. The channel is
   already said twice, by the note itself and by the light on the line; a
   third telling in flying particles is noise. White also keeps the burst
   legible over both hands' notes without ever being mistaken for one.
   The glow is neutral and low for the same reason. */
.spark svg { width: 100%; height: 100%; display: block; fill: url(#paw-cream); }
/* A blast, not a drift. The app's plume sends its paws upward because they
   come off a horizontal bar lying on a keyboard; here the paw arrives from
   the right and is struck by the line, so the prints leave the way a
   shotgun sprays — a cone along -x. --dx/--dy carry that cone.
   The 22% opacity stop keeps the app's rule that the print must be fully up
   before it travels, or it reads as sliding in rather than breaking off.
   Centring is translate(-50%, -50%) now that the spread is radial: the old
   version centred x in the transform and y with a margin, which only worked
   while everything flew upward. */
@keyframes paw-scatter {
  0%   { opacity: 0; transform: translate(-50%, -50%) rotate(0) scale(.4); }
  22%  { opacity: .95; }
  100% { opacity: 0; transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) rotate(var(--spin)) scale(1.05); }
}

/* ---- per-hit feedback --------------------------------------------------- */
/* osu!'s hit-error bar, laid along the same axis the notes travel. A tick
   lands where the paw actually was at the moment you tapped, so the scatter
   to the right of the line is everything you rushed and the scatter to the
   left is everything you dragged. ponytail: one element per hit, removed on
   a timer; a long score would want a ring buffer. */
.tick {
  position: absolute; width: 3px; height: 22px; margin: -11px 0 0 -1.5px; border-radius: 2px;
  z-index: 6; pointer-events: none; animation: tick-fade 1.3s ease-out forwards;
}
@keyframes tick-fade { 0% { opacity: 1; } 65% { opacity: .7; } 100% { opacity: 0; } }

.verdict {
  position: absolute; z-index: 7; pointer-events: none; transform: translate(-50%, -50%);
  font-size: var(--text-lg); font-weight: 900; letter-spacing: .11em; text-transform: uppercase;
  text-shadow: 0 2px 10px rgba(0,0,0,.7);
  animation: verdict-pop 680ms cubic-bezier(.2,1.3,.4,1) forwards;
}
@keyframes verdict-pop {
  0%   { opacity: 0; transform: translate(-50%, -40%) scale(.8); }
  22%  { opacity: 1; transform: translate(-50%, -62%) scale(1.07); }
  62%  { opacity: 1; transform: translate(-50%, -72%) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -96%) scale(1); }
}

.hint {
  position: absolute; left: 50%; bottom: 26px; transform: translateX(-50%); z-index: 8;
  padding: 8px 16px; border-radius: 999px; color: #d8b4c6; font-size: var(--text-md);
  background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.09); backdrop-filter: blur(8px);
}
.hint:empty { display: none; }

/* ---- the finale -------------------------------------------------------- */
/* The score runs out and rains paw prints instead of stopping dead. This is effects.css's
   confetti, redrawn here rather than linked: that sheet also carries a .plume rule written
   for a horizontal strike on a keyboard, and pulling it in for one keyframe would put a
   conflicting .plume in the cascade above this file's vertical one — a trap for whoever
   reorders the <link>s next. Everything that matters is carried over unchanged: 18 prints,
   30px, 2.1s on the same curve, drift and spin per print so no two fall alike.
   The shape is assets/paw.svg like everything else here, but the fill is this stage's
   neutral cream rather than the master's own, for the reason the burst already does — the
   app's cream ends on brand pink, and pink here is the judgement line, which is the one place
   the ending should not point. */
.confetti-paw {
  position: absolute; top: -46px; width: 30px; aspect-ratio: 40 / 38;
  opacity: 0; pointer-events: none;
  /* Under the readout, deliberately: the last thing you want to read is the final
     accuracy, and confetti falling over it would be the ending hiding its own result. */
  z-index: 8;
  filter: drop-shadow(0 0 6px rgba(246, 244, 248, .45));
  animation: confetti-fall 2.1s cubic-bezier(.35,.15,.6,1) forwards;
}
.confetti-paw svg { width: 100%; height: 100%; display: block; fill: url(#paw-cream); }
@keyframes confetti-fall {
  0%   { opacity: 0; transform: translate(0, 0) rotate(0) scale(.7); }
  12%  { opacity: .95; }
  100% { opacity: 0; transform: translate(var(--drift), calc(var(--drop, 520px) + 70px)) rotate(var(--spin)) scale(1); }
}

@media (prefers-reduced-motion: reduce) {
  /* Scoped to the decorative bursts, and deliberately NOT to `scroll` —
     killing that would strand the whole score at its start position, the
     same trap the app documents about blanket animation: none.
     The strike still HAPPENS, it just stops flying: the prints appear and
     go without travelling, and the light opens without stretching. */
  .tick, .verdict, .spark, .paw.spent, .paw.missed { animation-duration: 1ms; animation-fill-mode: forwards; }
  .spark { --dx: 0px; --dy: 0px; --spin: 0deg; }
  /* The readout still updates, it just stops moving to do it. The accuracy tween is JS,
     so it checks the same query itself and snaps. */
  .tally.hit b { animation-duration: 1ms; }
  .shape i { transition: none; }
  /* Falling confetti is the exact motion this query exists to stop, so the finale simply
     does not rain. The chord still plays and #done still announces the result. */
  .confetti-paw { animation-duration: 1ms; }
  .plume::before { animation: plume-open 1ms both, plume-fade 200ms ease-in 240ms forwards; }
}
