/**
 * mote — motion layer. Import once, anywhere:
 *
 *   import "mote/motion.css";
 *
 * Three independent axes:
 *   ambient  — breathe, bob, blink, saccade, per-channel drift. Gated on hover.
 *   gesture  — one-shot, fired. Drives the whole creature and raises `--mo-ch`.
 *   pose     — set and held. Interpolated registered properties.
 *
 * The stylesheet is paid once per app; markup is paid per creature. Every
 * composition question here resolves the same way — put it in the CSS.
 */

@property --mo-amp {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
/**
 * Channel amplitude — the whole reason a gesture never names a part.
 *
 * A gesture raises this; every decoration element reads it through its own
 * seeded stiffness and phase. One `shiver` therefore ripples a sun's petals,
 * flops a pair of ears and buzzes a spike ring without any of those
 * combinations existing anywhere in this file.
 */
@property --mo-ch {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
@property --mo-ch-peak {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
@property --mo-open {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
@property --mo-esx {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}
@property --mo-esy {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}
@property --mo-tilt {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
@property --mo-edy {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
@property --mo-edx {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
@property --mo-bdy {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
@property --mo-lock {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

/**
 * SVG's UA stylesheet sets `transform-origin: 0 0`, where CSS's initial value
 * is `50% 50%` — so without this every body transform pivots on the corner of
 * the frame and a 4% hover scale slides the creature down-right as it grows.
 * `transform-box` is stated rather than assumed: a <g> has no border box.
 *
 * `.mo-eye` and `.mo-el` are absent deliberately — they carry their own
 * origins, emitted per element in user units by the renderer.
 */
.mo-root,
.mo-gest,
.mo-breathe,
.mo-bob {
  transform-box: view-box;
  transform-origin: center;
}

/**
 * Hover controls amplitude, not playback. Every ambient keyframe resolves to
 * the identity at `--mo-amp: 0`, so an unhovered creature oscillates between
 * two poses that are the same pose — the loops never start, stop, restart from
 * zero, or freeze mid-squash.
 *
 * They run continuously rather than pausing because Firefox re-resolves a
 * paused effect but will not interpolate one, so a paused idle layer jump-cuts
 * on hover-out. One code path everywhere beats two split on @supports.
 */
.mo-root {
  --mo-amp: 0;
  /* Declared rather than left to the registered initial: an initial-value is
     not a declared value, so nothing downstream could override it and the whole
     channel layer sat inert while the gesture itself animated. Now that actions
     are Web Animations the override comes from `fire()`, but the declaration
     still has to exist for the transition below to have a start value. */
  --mo-ch: 0;
  --mo-morph: calc(340ms * var(--mo-rate, 1));
  --mo-morph-ease: cubic-bezier(0.45, 0.05, 0.5, 1);
  /* `--mo-ch` is deliberately NOT here. It is a fired channel, not a held pose,
     and its ramp is sampled per-keyframe in `gesture.ts` so the ring follows the
     body on one clock. Transitioning it alongside the pose left it pinned at its
     start value indefinitely. */
  --mo-tp: --mo-esx, --mo-esy, --mo-tilt, --mo-edy, --mo-edx, --mo-bdy,
    --mo-lock, --mo-open;

  transition-property: --mo-amp, transform, var(--mo-tp);
  transition-duration:
    calc(400ms * var(--mo-rate, 1)), calc(160ms * var(--mo-rate, 1)),
    var(--mo-morph), var(--mo-morph), var(--mo-morph), var(--mo-morph),
    var(--mo-morph), var(--mo-morph), var(--mo-morph), var(--mo-morph);
  transition-timing-function:
    ease-out, cubic-bezier(0.23, 1, 0.32, 1), var(--mo-morph-ease),
    var(--mo-morph-ease), var(--mo-morph-ease), var(--mo-morph-ease),
    var(--mo-morph-ease), var(--mo-morph-ease), var(--mo-morph-ease),
    var(--mo-morph-ease);
}

.mo-root:hover {
  --mo-amp: 1;
  transform: translateY(-1.5px) scale(1.04);
  transition-duration:
    calc(400ms * var(--mo-rate, 1)), calc(220ms * var(--mo-rate, 1)),
    var(--mo-morph), var(--mo-morph), var(--mo-morph), var(--mo-morph),
    var(--mo-morph), var(--mo-morph), var(--mo-morph), var(--mo-morph);
}

.mo-root.mo-always {
  --mo-amp: 1;
}

/**
 * The channel ramp — one keyframe every gesture shares.
 *
 * A gesture states only how hard it drives the decoration (`--mo-ch-peak`) and
 * for how long; the shape of the rise and fall lives here once. Holding the
 * peak across the middle and returning to zero is what makes decoration settle
 * *after* the body has finished, which is the whole read of a soft appendage.
 */
/* ------------------------------------------------------------- ambient */

/**
 * Breathe. Non-uniform on purpose: a uniform scale reads as a zoom, a slight
 * squash-and-stretch reads as something soft holding air — the cheapest way to
 * get that quality without touching path data.
 */
.mo-breathe {
  animation: mo-breathe calc(2800ms * var(--mo-rate, 1))
    calc(var(--mo-phase, 0ms) * var(--mo-rate, 1)) ease-in-out infinite alternate;
}

@keyframes mo-breathe {
  to {
    transform: scaleX(calc(1 + 0.022 * var(--mo-amp)))
      scaleY(calc(1 - 0.018 * var(--mo-amp)));
  }
}

/**
 * Bob. 3400ms against breathe's 2800ms, deliberately not a multiple, so the two
 * drift in and out of phase instead of locking into one obvious pulse. Their
 * offsets are seeded independently, so no two creatures share a drift either.
 *
 * The pose's vertical offset takes the free `translate` and *adds* to bob
 * rather than multiplying: an offset scaled by `--mo-amp` would vanish on every
 * unhovered creature, which is most of a grid.
 */
.mo-bob {
  translate: 0 calc(var(--mo-bdy) * 1px);
  animation: mo-bob calc(3400ms * var(--mo-rate, 1))
    calc(var(--mo-bob-phase, 0ms) * var(--mo-rate, 1)) ease-in-out infinite alternate;
}

@keyframes mo-bob {
  from {
    transform: translateY(0px);
  }
  to {
    transform: translateY(calc(-1.1px * var(--mo-amp)));
  }
}

/* -------------------------------------------------------------- channels */

/**
 * Decoration elements. `--st` is this element's stiffness (bigger parts move
 * less, derived from size in `layout.ts`) and `--ph` its golden-angle phase, so
 * a ring never moves in unison with itself.
 */
.mo-el {
  transform-box: view-box;
  --mo-el-amp: calc(var(--mo-amp) * 0.45 + var(--mo-ch) * var(--mo-ch-peak));
  animation-delay: var(--ph, 0ms);
  animation-iteration-count: infinite;
  animation-direction: alternate;
  animation-timing-function: ease-in-out;
}

/* A petal ring breathes outward from the body. */
.mo-petal {
  animation-name: ch-petal;
  animation-duration: calc(2600ms * var(--st, 1) * var(--mo-rate, 1));
}
@keyframes ch-petal {
  to {
    scale: calc(1 + 0.09 * var(--mo-el-amp) / var(--st, 1));
  }
}

/* Lobes drift laterally — clouds and ears, the soft parts. */
.mo-lobe {
  animation-name: ch-lobe;
  animation-duration: calc(3100ms * var(--st, 1) * var(--mo-rate, 1));
}
@keyframes ch-lobe {
  to {
    translate: calc(0.7px * var(--mo-el-amp) / var(--st, 1))
      calc(-0.5px * var(--mo-el-amp) / var(--st, 1));
  }
}

/* Spikes are stiff and fast — they buzz rather than sway. */
.mo-spike {
  animation-name: ch-spike;
  animation-duration: calc(900ms * var(--st, 1) * var(--mo-rate, 1));
}
@keyframes ch-spike {
  to {
    scale: calc(1 + 0.14 * var(--mo-el-amp) / var(--st, 1));
  }
}

/* A nub is heavy and lags behind the body. */
.mo-nub {
  animation-name: ch-nub;
  animation-duration: calc(2100ms * var(--st, 1) * var(--mo-rate, 1));
}
@keyframes ch-nub {
  to {
    translate: 0 calc(0.9px * var(--mo-el-amp) / var(--st, 1));
  }
}

/* ------------------------------------------------------------------ face */

/**
 * The eye, in two elements: the pose on the wrapper, the ambient loops on the
 * shape underneath.
 *
 * The split is not tidiness. Gecko substitutes a keyframe's `var()` from the
 * transition's *endpoint* rather than its current value, so a pose channel
 * living inside @keyframes snaps to its final value on the first frame while
 * every other channel eases — a face arriving in two pieces, which is worse
 * than nothing moving. Everything a transition drives is a plain declaration
 * here; the keyframes below read only amplitude scalars.
 */
.mo-eye {
  transform-box: view-box;
  translate: calc(var(--mo-edx) * var(--mo-wrap, 1) * 1px)
    calc(var(--mo-edy) * 1px);
  rotate: calc(
    (var(--mo-tilt) * var(--mo-wrap, 1) - var(--mo-lean, 0) * var(--mo-lock)) *
      1deg
  );
  /*
   * The pose scale, bracketed by the eye's own lean — and it must be.
   * `superellipse` bakes rotation into the emitted coordinates, so a leaned
   * capsule reaches the DOM already tilted and this element's local axes are
   * the viewport's. A bare scaleY shears the capsule instead of closing it
   * across its own width. rotate(lean)·S·rotate(-lean) is the same scale
   * expressed in the capsule's frame.
   */
  transform: rotate(calc(var(--mo-lean, 0) * 1deg)) scaleX(var(--mo-esx))
    scaleY(var(--mo-esy)) rotate(calc(var(--mo-lean, 0) * -1deg));
}

/**
 * Blink and wrap. A face that blinks reads as alive at a fraction of the cost
 * of everything else here — the layer to keep if only one survives.
 *
 * There is no CSS mechanism for a short event on a long period: keyframe
 * percentages are static and only the duration reads a custom property. So the
 * animation runs for the whole seeded interval and the blink is a 2.8% window
 * at the end. The blink's real duration therefore scales with the interval —
 * ~98ms at 3.5s, ~182ms at 6.5s — which is a feature: a slower blinker reads as
 * sleepier.
 */
.mo-eye > * {
  transform-box: fill-box;
  transform-origin: center;
  animation-name: mo-blink, mo-wrap;
  animation-duration:
    calc(var(--mo-blink, 4800ms) * var(--mo-rate, 1)),
    calc(var(--mo-saccade, 5600ms) * var(--mo-rate, 1));
  animation-delay:
    calc(var(--mo-blink-phase, 0ms) * var(--mo-rate, 1)),
    calc(var(--mo-saccade-phase, 0ms) * var(--mo-rate, 1));
  animation-iteration-count: infinite;
  animation-timing-function: linear;
}

/**
 * The closed pose carries `--mo-amp` rather than being a constant, so a blink
 * caught by hover-out re-opens instead of leaving the creature with its eyes
 * shut — roughly a 3% chance per hover-out otherwise, rare enough to miss in
 * testing and common enough to ship.
 *
 * The shape is close, hold, reopen rather than a there-and-back V: a real lid
 * rests shut for a few frames, and a blink without the hold reads as a flicker.
 * The window still totals 2.8% of the interval — the real duration therefore
 * scales with it, ~98ms at 3.5s and ~182ms at 6.5s, which is a feature: a
 * slower blinker reads as sleepier.
 */
@keyframes mo-blink {
  0%,
  97.2% {
    transform: rotate(calc(var(--mo-lean, 0) * 1deg)) scaleY(1)
      rotate(calc(var(--mo-lean, 0) * -1deg));
    animation-timing-function: ease-in;
  }
  98.4% {
    transform: rotate(calc(var(--mo-lean, 0) * 1deg))
      scaleY(calc(1 - 0.92 * var(--mo-amp)))
      rotate(calc(var(--mo-lean, 0) * -1deg));
    animation-timing-function: linear;
  }
  99.15% {
    transform: rotate(calc(var(--mo-lean, 0) * 1deg))
      scaleY(calc(1 - 0.92 * var(--mo-amp)))
      rotate(calc(var(--mo-lean, 0) * -1deg));
    animation-timing-function: ease-out;
  }
  100% {
    transform: rotate(calc(var(--mo-lean, 0) * 1deg)) scaleY(1)
      rotate(calc(var(--mo-lean, 0) * -1deg));
  }
}

/**
 * Saccades. Real eyes do not drift — they snap between fixations in well under
 * a tenth of a second and hold for a second or more, so these are long holds
 * separated by 1.5% jump windows with linear timing. Easing them gives floating
 * eyeballs, which is unsettling in a way that is hard to name and easy to feel.
 *
 * The eyes may cross outside the silhouette on a hard glance. Nothing clips
 * them, deliberately: an eye riding past the edge reads as a face turning on a
 * round head rather than as a mistake.
 */
.mo-eyes {
  animation: mo-saccade calc(var(--mo-saccade, 5600ms) * var(--mo-rate, 1))
    calc(var(--mo-saccade-phase, 0ms) * var(--mo-rate, 1)) linear infinite;
}

@keyframes mo-saccade {
  0%,
  15% {
    translate: 0 0;
  }
  16.5%,
  31% {
    translate: calc(-0.8px * var(--mo-look-x, 1.4) * var(--mo-amp))
      calc(-0.9px * var(--mo-look-y, 1.1) * var(--mo-amp));
  }
  32.5%,
  47% {
    translate: calc(1px * var(--mo-look-x, 1.4) * var(--mo-amp))
      calc(0.1px * var(--mo-look-y, 1.1) * var(--mo-amp));
  }
  48.5%,
  63% {
    translate: calc(-0.15px * var(--mo-look-x, 1.4) * var(--mo-amp))
      calc(0.85px * var(--mo-look-y, 1.1) * var(--mo-amp));
  }
  64.5%,
  79% {
    translate: calc(0.75px * var(--mo-look-x, 1.4) * var(--mo-amp))
      calc(-0.8px * var(--mo-look-y, 1.1) * var(--mo-amp));
  }
  80.5%,
  98.5% {
    translate: calc(-1px * var(--mo-look-x, 1.4) * var(--mo-amp))
      calc(-0.15px * var(--mo-look-y, 1.1) * var(--mo-amp));
  }
  100% {
    translate: 0 0;
  }
}

/**
 * Wrap — the eyes reading as marks on a sphere rather than stickers on a disc.
 * Rides the saccade's windows exactly: same period, same phase, same jump
 * fractions, so the shape change lands on the same frame as the move.
 *
 * Foreshortening reads the *unsigned* magnitude, because how far a feature
 * compresses depends on how far the face turned, not which way.
 */
@keyframes mo-wrap {
  0%,
  15% {
    scale: 1 1;
  }
  16.5%,
  31% {
    scale: calc(1 - 0.018 * var(--mo-look-mx, 1.4) * var(--mo-amp))
      calc(1 - 0.027 * var(--mo-look-my, 1.1) * var(--mo-amp));
  }
  32.5%,
  47% {
    scale: calc(1 - 0.022 * var(--mo-look-mx, 1.4) * var(--mo-amp))
      calc(1 - 0.003 * var(--mo-look-my, 1.1) * var(--mo-amp));
  }
  48.5%,
  63% {
    scale: calc(1 - 0.003 * var(--mo-look-mx, 1.4) * var(--mo-amp))
      calc(1 - 0.026 * var(--mo-look-my, 1.1) * var(--mo-amp));
  }
  64.5%,
  79% {
    scale: calc(1 - 0.017 * var(--mo-look-mx, 1.4) * var(--mo-amp))
      calc(1 - 0.024 * var(--mo-look-my, 1.1) * var(--mo-amp));
  }
  80.5%,
  98.5% {
    scale: calc(1 - 0.022 * var(--mo-look-mx, 1.4) * var(--mo-amp))
      calc(1 - 0.005 * var(--mo-look-my, 1.1) * var(--mo-amp));
  }
  100% {
    scale: 1 1;
  }
}

/**
 * The maw. Held at scaleY(0) and driven by `--mo-open`, so an open mouth is a
 * compositable scale rather than an interpolated path. The resting value is
 * emitted per creature, so some sit slightly open at idle.
 */
.mo-maw {
  transform-box: view-box;
  /* Clamped: an action adds to whatever the seed rests at, so a creature
     already sitting half-open and then told to laugh asked for 1.4 — deeper
     than the layout reserves room for, and the mouth left the body. */
  scale: 1 clamp(0, calc(var(--mo-open) + var(--mo-open-rest, 0)), 1);
  transition: scale var(--mo-morph) var(--mo-morph-ease);
}

/* ------------------------------------------------------------- gestures */

/**
 * One-shot, fired and forgotten. Each drives `.mo-gest` — which owns all four
 * transform properties, so a gesture never fights the hover lift, the breathe
 * or the bob — and raises `--mo-ch`, which every decoration channel reads.
 *
 * Removed by the caller on `animationend`; `mote/gesture` does it in ~200 B.
 */
.mo-gest {
  transform-box: view-box;
  transform-origin: center;
}

/**
 * Hop. Anticipation, launch, land, settle — the squash is brief enough that it
 * does not trade away the silhouette the way a *held* body deform would.
 */
/** Entrance. Overshoot kept under 0.2 — a grid of bouncy avatars is a toy. */
/** The channel gesture: the body barely moves, the decoration does the talking. */
/** Perk — a small alert double-take. The cheapest "I noticed that". */
/* -------------------------------------------------------------- a11y */

/**
 * Touch devices latch hover on tap and hold it until the next tap elsewhere, so
 * a tapped creature would sit there breathing. Amplitude is the only gate, so
 * defeating the hover rule is the whole fix — and having pinned amplitude at
 * zero, stop paying for the loops that amplitude scales. Sixty creatures is
 * roughly 500 live animations, most of them driving registered properties,
 * which recalculate on the main thread rather than compositing.
 *
 * `animation-play-state`, not `animation: none`: paused effects keep their
 * resolved styles, so the pose a creature holds is the one it already had.
 */
@media not ((hover: hover) and (pointer: fine)) {
  .mo-root:hover {
    --mo-amp: 0;
    transform: none;
  }
  .mo-root.mo-always {
    --mo-amp: 1;
  }
  /* A creature mid-action is exempt: an action is fired, not ambient, and
     freezing one half-way is worse than the work it saves.

     The action itself is a Web Animation and ignores `animation-play-state`
     entirely, so it would survive this rule regardless. The ring decoration
     would not: it is CSS, driven by `--mo-ch`, and pausing it means a fired
     action moves the body while the petals sit still. `fire()` adds and removes
     `.mo-acting` for exactly this selector. */
  .mo-root:not(.mo-always):not(.mo-acting),
  .mo-root:not(.mo-always):not(.mo-acting) * {
    animation-play-state: paused;
  }
}

/**
 * Parked — for a creature an IntersectionObserver has watched leave the
 * viewport. Free at zero amplitude: every ambient keyframe resolves to the
 * identity there, so a parked creature freezes as exactly the creature it was
 * and resumes without a jump. Paused effects still re-resolve on a var change,
 * so park only what cannot be hovered at the same time — which offscreen is.
 *
 * The `.mo-acting` exemption mirrors the touch rule: a fired action is a Web
 * Animation and ignores `animation-play-state`, so the ring decoration driven
 * by `--mo-ch` must keep running with it.
 */
.mo-park:not(.mo-acting),
.mo-park:not(.mo-acting) * {
  animation-play-state: paused;
}

/** Slow motion, for reviewing timing. Put it on any ancestor. */
.mo-slow {
  --mo-rate: 5;
}

/* Failure, or a long wait ending badly. The only gesture that finishes lower
   than it started within its own timeline, which is what makes it read as
   giving up rather than as a bounce. */
/* Attention. Two beats rather than one, because a single scale bump is `pop`
   and reads as arrival; the second beat is what turns it into a request. */
/* Curiosity, and the natural "working on it" hold. Slow, single-sided, and it
   leans without translating — a head turning to consider something. */
/**
 * Reduced motion means fully static. "Reduce, don't remove" applies to motion
 * that aids comprehension; ambient idle is decorative, so removing it costs
 * nothing. A pose survives at full strength — it is a message the consumer
 * chose to send, not decoration — so only the *morph* is removed.
 */
@media (prefers-reduced-motion: reduce) {
  .mo-root,
  .mo-gest,
  .mo-breathe,
  .mo-bob,
  .mo-eyes,
  .mo-eye,
  .mo-eye > *,
  .mo-el,
  .mo-maw {
    animation: none !important;
    transition: none !important;
  }
}
