/* =====================================================
   kid-motion.css — the motion vocabulary the kid games share.

   WHY THIS IS ITS OWN SHEET
   css/style.css is one flat 1312-line namespace served to the story reader,
   the Arcade and both kid pages at once. Six shipped games between them own
   13 keyframes, all of them a bare opacity/scale/translate, and each was
   written where it was needed. Motion written that way cannot be consistent:
   two buttons on one screen press at two different speeds because two people
   picked two durations. So the curves, the durations and the five reusable
   gestures live here, once, as tokens — and every game spends them rather
   than inventing its own.

   Every class and every @keyframes name below is prefixed kg-. An unprefixed
   one would reach the reader and the Arcade through that shared namespace.
   These five are deliberately NOT used, at any specificity: .puzzle-stage
   .spy-stage .zoom-stage .scene-stage .champ-wall — css/style.css:621-624 and
   :1186 select on them with :has(), so re-using one silently re-widths a
   .game-area three pages away.

   REDUCED MOTION
   css/style.css:559-562 already flattens EVERY animation-duration and
   transition-duration to .001ms globally, with !important. No such block is
   repeated here, and none of these classes needs one: each is written so that
   collapsing its duration to zero leaves the END state intact, never a
   half-applied transform. `animation-fill-mode: both` on every keyframed
   class is what buys that — the child who asks for less motion still lands on
   the same screen, she just does not travel to it.

   What that global rule CANNOT reach is motion JavaScript drives frame by
   frame: a requestAnimationFrame loop writing transforms inline outruns any
   stylesheet. That is js/kidgames/motion.js's job, and it checks
   prefersReducedMotion() before it starts one. Same rule on both sides of the
   line: keep the state change, delete the travel.

   NO SCREEN SHAKE, anywhere, at any setting. Shaking the whole viewport reads
   as punishment to an under-8 and is a vestibular trigger besides. The wrong
   answer moves the ANSWER (.kg-wobble, one element, 8px), never the page.
   Nothing here flashes: the fastest repeat is the wobble at 10Hz of
   translation, which changes no luminance at all.
   ===================================================== */

:root {
  /* ---------------- Easing ----------------
     Five curves, and a rule for which. Material's deceleration/acceleration
     pair for anything entering or leaving (things arrive slowly and leave
     quickly, as objects do), the emphasized curve for a move that has to be
     noticed, and two overshooting curves for the two places a kid game earns
     one: acknowledging a press, and celebrating. --ease at style.css:34 is
     kept for anything already using it; nothing here overrides it. */
  --kg-ease-enter:    cubic-bezier(0.05, 0.7, 0.1, 1);    /* decelerate — arriving */
  --kg-ease-exit:     cubic-bezier(0.3, 0, 0.8, 0.15);    /* accelerate — leaving  */
  --kg-ease-emphasis: cubic-bezier(0.2, 0, 0, 1);         /* look at this          */
  --kg-ease-back:     cubic-bezier(0.34, 1.56, 0.64, 1);  /* playful back-out      */
  --kg-ease-pop:      cubic-bezier(0.68, -0.6, 0.32, 1.6);/* big pop, both ends    */

  /* ---------------- Duration ----------------
     Four bands. Micro is a state echo (100-200ms), entrance is one element
     arriving (250-400), screen is a whole view changing (400-600), and
     celebration is hard-capped at 1600 — past that a five-year-old has
     already moved her hand to the next thing and the reward is in her way. */
  --kg-dur-micro:     140ms;
  --kg-dur-press:      90ms;   /* finger down: inside the 80-120 window       */
  --kg-dur-release:   200ms;   /* + press = 290ms, inside the 220-320 window  */
  --kg-dur-enter:     320ms;
  --kg-dur-screen:    480ms;
  --kg-dur-out:       200ms;   /* outgoing screen accelerating away           */
  --kg-dur-in:        350ms;   /* incoming screen decelerating in             */
  --kg-overlap:       100ms;   /* how far the two overlap — never a cross-fade */
  --kg-dur-celebrate: 1600ms;  /* the cap, not a suggestion                   */

  /* ---------------- Stagger ----------------
     Per-child step, and the total the whole group may not exceed. 400ms is
     the point where a staggered grid stops reading as one group arriving and
     starts reading as items loading one at a time. js/kidgames/motion.js
     compresses the step to hold this, it never lets the total grow. */
  --kg-stagger-step: 60ms;
  --kg-stagger-cap: 400ms;
  --kg-stagger-delay: 0ms;   /* written per element by stagger() */

  /* ---------------- Amplitude ----------------
     k is the squash constant: scale(1+k, 1-k) keeps area within k² of 1, so
     the thing looks squeezed rather than resized. 0.08-0.18 for UI; the top
     of that range is already cartoonish on a 76px button. */
  --kg-k: 0.12;
  --kg-wobble-amp: 8px;   /* the "no" nudge: 6-10px, three cycles, one element */
  --kg-rise: 20px;        /* how far a staggered child rises from: 16-24px     */
}

/* =====================================================
   1. PRESS — the only motion with a latency budget
   Visual acknowledgement lands on the pointerdown frame. Nothing waits for
   this to finish: js/kidgames/motion.js adds .kg-is-pressed synchronously and
   the game's own handler has already run by then.
   ===================================================== */

.kg-press {
  transition: transform var(--kg-dur-press) var(--kg-ease-exit),
              filter    var(--kg-dur-press) var(--kg-ease-exit);
  transform-origin: center;
}

/* Down. The brightness step is not decoration — it is the half of the press
   that survives when durations are flattened to nothing, so a child on
   reduced motion still sees her finger land. */
.kg-press.kg-is-pressed {
  transform: scale(0.94) translateY(2px);
  filter: brightness(0.94);
}

/* Up, through 1.06 and back. The overshoot is what makes a button feel like
   an object with mass instead of a rectangle changing size. */
.kg-press.kg-is-releasing {
  animation: kg-press-release var(--kg-dur-release) var(--kg-ease-back) both;
}

@keyframes kg-press-release {
  0%   { transform: scale(0.94) translateY(2px); filter: brightness(0.94); }
  55%  { transform: scale(1.06)  translateY(-1px); filter: none; }
  100% { transform: scale(1)     translateY(0);    filter: none; }
}

/* =====================================================
   2. SQUASH AND STRETCH — volume preserved
   scale(1+k, 1-k) then a smaller counter-stretch, settling at 1. The pair
   multiplies to 1-k² (0.986 at the default k), which is the classic
   animation cheat: near enough to constant volume that the eye reads it as
   one solid object deforming.
   ===================================================== */

.kg-squash { animation: kg-squash calc(var(--kg-dur-micro) * 2) var(--kg-ease-emphasis) both; }
.kg-stretch { animation: kg-stretch calc(var(--kg-dur-micro) * 2) var(--kg-ease-emphasis) both; }

/* Landing: wide-and-flat first. Use on the thing that was dropped or hit. */
@keyframes kg-squash {
  0%   { transform: scale(1, 1); }
  30%  { transform: scale(calc(1 + var(--kg-k)), calc(1 - var(--kg-k))); }
  62%  { transform: scale(calc(1 - var(--kg-k) * 0.55), calc(1 + var(--kg-k) * 0.55)); }
  100% { transform: scale(1, 1); }
}

/* Take-off: tall-and-thin first. Use on the thing that is about to move. */
@keyframes kg-stretch {
  0%   { transform: scale(1, 1); }
  30%  { transform: scale(calc(1 - var(--kg-k)), calc(1 + var(--kg-k))); }
  62%  { transform: scale(calc(1 + var(--kg-k) * 0.55), calc(1 - var(--kg-k) * 0.55)); }
  100% { transform: scale(1, 1); }
}

/* A squash reads as a bounce only if the thing has a floor to squash against.
   Put this on anything sitting on a surface. */
.kg-ground { transform-origin: center bottom; }

/* =====================================================
   3. THE "NO" WOBBLE — and what it replaces
   A wrong answer nudges the ANSWER: ±8px, three cycles, 300ms, decaying.
   This is the sanctioned replacement for screen shake, which appears nowhere
   in this codebase and must not be added: at five, a screen that lurches is
   not "wrong, try again", it is "something broke and it was your fault".
   ===================================================== */

.kg-wobble { animation: kg-wobble 300ms var(--kg-ease-emphasis) both; }

@keyframes kg-wobble {
  0%,   100% { transform: translateX(0); }
  16.6%      { transform: translateX(calc(var(--kg-wobble-amp) * -1)); }
  33.3%      { transform: translateX(var(--kg-wobble-amp)); }
  50%        { transform: translateX(calc(var(--kg-wobble-amp) * -0.85)); }
  66.6%      { transform: translateX(calc(var(--kg-wobble-amp) * 0.6)); }
  83.3%      { transform: translateX(calc(var(--kg-wobble-amp) * -0.3)); }
}

/* The colour half of "not that one". Applied alongside the wobble, and
   ALONE when the child asked for reduced motion — the travel goes, the
   answer to "did it hear me?" stays. Amber, never red: red is a mistake,
   amber is a hint. */
.kg-nope {
  box-shadow: 0 0 0 4px var(--amber, #ff9f1c), var(--shadow, 0 8px 24px rgba(53, 49, 94, .16));
}

/* =====================================================
   4. STAGGERED ENTRANCE
   Children rise 20px and grow from 0.9. The delay comes in as a custom
   property so one class serves a row of three and a grid of twelve, and so
   the ORDER is the caller's business — a game staggers outward from the
   correct answer, not left to right, because left to right teaches a child
   to look at the first tile.
   ===================================================== */

.kg-stagger-in {
  animation: kg-rise-in var(--kg-dur-enter) var(--kg-ease-enter) both;
  animation-delay: var(--kg-stagger-delay, 0ms);
}

/* The playful variant, for a reward row rather than a question. */
.kg-stagger-in.kg-bouncy { animation-timing-function: var(--kg-ease-back); }

@keyframes kg-rise-in {
  from { opacity: 0; transform: translateY(var(--kg-rise)) scale(0.9); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}

/* =====================================================
   5. SCREEN CHOREOGRAPHY
   The outgoing screen accelerates away in 200ms; the incoming decelerates in
   over 350ms, starting 100ms before the old one is gone. That overlap is the
   whole point — two things moving at once reads as one place becoming
   another, where a cross-fade reads as a slideshow. Total 450ms, inside the
   400-600 screen band. The timing lives in motion.js; the look lives here.
   ===================================================== */

.kg-stage { position: relative; }

.kg-screen-out {
  animation: kg-screen-out var(--kg-dur-out) var(--kg-ease-exit) both;
  pointer-events: none;   /* it is leaving; it must not eat a tap on the way */
}
.kg-screen-in { animation: kg-screen-in var(--kg-dur-in) var(--kg-ease-enter) both; }

@keyframes kg-screen-out {
  to { opacity: 0; transform: translateY(-10px) scale(0.96); }
}
@keyframes kg-screen-in {
  from { opacity: 0; transform: translateY(18px) scale(0.98); }
}

/* The element that survives the boundary. motion.js lifts the NODE ITSELF out
   of the outgoing screen into the stage before either animation starts, so it
   is a child of neither and inherits neither transform. It does not fade, it
   does not re-mount, it does not lose its scroll position or its focus. */
.kg-persist { position: relative; z-index: 2; animation: none; }

/* Reduced motion's swap. Opacity only, no transform: this is the 120-180ms
   state change that replaces 450ms of travel. Under style.css:559-562 it
   collapses to an instant swap, which is correct — motion.js still holds the
   150ms before it calls back, so nothing downstream has to know. */
.kg-appear { animation: kg-appear 150ms var(--kg-ease-emphasis) both; }
@keyframes kg-appear { from { opacity: 0; } to { opacity: 1; } }

/* =====================================================
   6. PARTICLES AND CELEBRATION
   Sprites are positioned entirely from JS — motion.js writes transform and
   opacity inline every frame. Nothing here animates them, on purpose: a
   keyframed particle cannot respond to gravity, and a keyframed particle is
   invisible to the reduced-motion check that has to be able to stop it.
   ===================================================== */

/* THE LAYER MAY PAINT OUTSIDE ITS HOST. IT MAY NOT MAKE THE PAGE TALLER.
   A sprite is thrown at up to 420px/s and pulled down at up to 1200px/s^2 for
   up to 1.1s, so the far end of a burst is ~700px below where it started —
   and an absolutely positioned child that ends up outside its host is
   scrollable overflow like any other. Measured on letter-trace at 390x844:
   documentElement.scrollHeight went 844 -> 1567 and scrollWidth 390 -> 573
   about a second in, so a page with no scrollbar grew one, could be dragged
   ~700px, and shrank back when the last sprite died — under a child who was
   being congratulated. puppy-count found the same thing first and clipped its
   own field layer (css/kidgames/puppy-count.css).

   `overflow: clip` is the fix rather than `hidden`: clip is not a scroll
   container at all, so nothing here can be scrolled to, focused into, or
   counted as scrollable overflow by anything up the tree. `overflow-clip-
   margin` then buys the burst back the room it actually needs — that margin
   expands the INK overflow only, which paints and never scrolls, so a sprite
   still flies well clear of the card it came out of and the document is the
   same height throughout. 160px is about the visible half of a throw at this
   speed; past that a sprite is faded, off-screen, or both.

   A host that wants a tighter burst overrides `overflow` on this class from
   its own sheet, which is what puppy-count's field does. */
.kg-particle-layer {
  position: absolute; inset: 0;
  pointer-events: none;     /* a burst must never swallow the next tap */
  overflow: hidden;         /* the floor, for anything without `clip` */
  overflow: clip;
  overflow-clip-margin: 160px;
  z-index: 4;
}
.kg-particle {
  position: absolute; top: 0; left: 0;
  line-height: 1; user-select: none;
  will-change: transform, opacity;
}
.kg-particle-canvas { position: absolute; inset: 0; pointer-events: none; }

/* The reduced-motion reward. One sparkle, sitting still, gone in ~200ms —
   removed by a timer rather than faded, because style.css:559-562 flattens
   any CSS fade to nothing anyway. It is deliberately NOT nothing: stripping
   every trace of "you were right" is a worse bug than the motion was. */
.kg-spark-static {
  position: absolute;
  transform: translate(-50%, -50%);
  font-size: 2.6rem; line-height: 1;
  pointer-events: none;
}

/* The one-off big pop, for a win badge or a token landing. Capped by token. */
.kg-celebrate { animation: kg-celebrate var(--kg-dur-celebrate) var(--kg-ease-pop) both; }
@keyframes kg-celebrate {
  0%   { opacity: 0; transform: scale(0.4) rotate(-8deg); }
  22%  { opacity: 1; transform: scale(1.12) rotate(3deg); }
  34%  { transform: scale(0.98) rotate(-1deg); }
  46%  { transform: scale(1.03) rotate(0deg); }
  60%, 100% { opacity: 1; transform: scale(1) rotate(0deg); }
}

/* =====================================================
   7. AMBIENT
   A slow idle float for something waiting to be tapped. Infinite, so it is
   the one class here that a JS reduced-motion check should strip rather than
   flatten — style.css sets animation-iteration-count: 1, which leaves the
   element parked at whatever 100% happens to be. This keyframe therefore
   ends where it starts.
   ===================================================== */

.kg-float { animation: kg-float 3.4s ease-in-out infinite; }
@keyframes kg-float {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-7px); }
}
