/* ============================================================================
   Orb Workday — motion system
   ---------------------------------------------------------------------------
   Every @keyframes in the site lives here; orb.css and the shell only ever
   reference names. Nothing here uses `ease`, `ease-in-out` as a keyword,
   `ease-out` as a keyword or `linear` except where a thing genuinely moves at
   constant speed (a travelling belt, a drifting particle field). Everything
   with mass uses the tokenised curves from tokens.css:

     --ease-orb    the house curve. Heavy launch, long settle. Anything that
                   represents an orb moving, waking or landing.
     --ease-out    quick departure, soft arrival. Interface surfaces.
     --ease-snap   slight overshoot. Only for things that latch or lock.
     --ease-in-out symmetric. Only for loops that must have no seam.

   ---------------------------------------------------------------------------
   THE MOTION CONTRACT  (the whole reduced-motion design in two variables)
   ---------------------------------------------------------------------------
   --motion-k    <number>   1        Global motion scalar. Every duration in
                                     the site is calc()'d from it, so setting
                                     it to 0 zeroes every tween at once.
   --motion-off  <ident>    unset    When set to `none`, every looping or
                                     travelling animation-name in the site
                                     resolves to `none`, because they are all
                                     declared as var(--motion-off, <name>).

   Derived, do not set directly:
   --cue-d        130ms    One-shot perceptual cue. NEVER scaled by --motion-k:
                           this is the settle that keeps state changes audible
                           to the eye when all motion is off.
   --tween-d      0–280ms  Standard structural tween.
   --tween-slow-d 0–520ms  Long settles: ring progress, orb travel.
   --enter-d      130–650ms Card / panel arrival.

   Because every rule in the site reads only these four derived values and the
   two switches, the reduced-motion override is literally two declarations —
   there is no second copy of the design that can drift out of sync with the
   first.
   ========================================================================= */

:root {
  --motion-k: 1;
  --cue-d: 130ms;
  --tween-d:      calc(var(--motion-k) * var(--d-mid));
  --tween-slow-d: calc(var(--motion-k) * var(--d-slow));
  --enter-d:      calc(var(--motion-k) * var(--d-slow) + var(--cue-d));
}

/* ==========================================================================
   1. Orb keyframes
   ---------------------------------------------------------------------------
   These read the orb's own private knobs (--_core-op, --_halo-op, --_bands,
   --_crystal, --_fracture) so that an animation always resolves to the state
   it is animating, and always lands back on the exact static value orb.css
   declares. That is what makes every one-shot here safe to delete: removing
   the animation loses the arrival, never the result.
   ========================================================================== */

/* Breathing: the sphere itself swelling as pressure builds inside it. The
   curve is symmetric so the loop has no seam; the amplitude is deliberately
   under 2% — an orb is heavy, it does not bounce. */
@keyframes orb-breathe {
  0%,
  100% { transform: scale(1); }
  /* Asymmetric on purpose: pressure takes longer to build than to release,
     and there is a beat of stillness at the top before it lets go. A single
     symmetric 50% stop is a metronome, and a metronome has no mass. The
     amplitude stays under 2% — an orb is heavy, it does not bounce. */
  38%  { transform: scale(1.019); }
  52%  { transform: scale(1.017); }
  78%  { transform: scale(1.001); }
}

/* The thing alive inside: brighter and larger on the same period as the body
   but phase-shifted, so the light leads the shell. */
@keyframes orb-core-pulse {
  0%,
  100% { opacity: var(--_core-op); transform: scale(0.94); }
  /* The light leads the shell: it reaches full before the body does, holds,
     and dims slowly. Brightness and size peak at different moments, which is
     what stops the core reading as a disc being scaled. */
  30%  { opacity: calc(var(--_core-op) * 1.26); transform: scale(1.04); }
  48%  { opacity: calc(var(--_core-op) * 1.18); transform: scale(1.07); }
  74%  { opacity: calc(var(--_core-op) * 1.02); transform: scale(0.99); }
}

/* The bloom off the limb, swelling with the breath. Opacity only — the halo
   never changes size, or it reads as a glow div. */
@keyframes orb-halo-swell {
  0%,
  100% { opacity: calc(var(--_halo-op) * 0.7); }
  /* Trails the core by design — the air takes a moment to catch up. */
  42%  { opacity: var(--_halo-op); }
  64%  { opacity: calc(var(--_halo-op) * 0.94); }
}

/* Internal strata travelling: setup scripts grinding through. A belt moves at
   constant speed, so linear is correct here and nowhere else. One full period
   is exactly one band pitch, so the loop is invisible. */
@keyframes orb-bands-travel {
  from { transform: perspective(6em) rotateX(58deg) scale(1.05) translateY(0); }
  to   { transform: perspective(6em) rotateX(58deg) scale(1.05)
                    translateY(calc(50% / var(--_bands))); }
}

/* Provisioning: a short arc sweeping once round the shell, drawing it in.
   339.292 is the r=54 circumference (see orb.css). */
@keyframes orb-ring-draw {
  0%   { stroke-dashoffset: 0; opacity: 0.45; }
  50%  { opacity: 1; }
  100% { stroke-dashoffset: -339.292; opacity: 0.45; }
}

/* Setup: the dashed band travels one dash pitch (9 + 7 = 16 units) per cycle,
   so the pattern appears to rotate continuously without a jump. */
@keyframes orb-ring-travel {
  from { stroke-dashoffset: 0; }
  to   { stroke-dashoffset: -16; }
}

/* Resuming: the shell snapping back to solid. It overshoots outward once and
   is pulled in — the ring re-seating on the sphere. */
@keyframes orb-ring-snap {
  0%   { opacity: 0.35; transform: rotate(-90deg) scale(1.08); }
  38%  { opacity: 1;    transform: rotate(-90deg) scale(0.985); }
  100% { opacity: 1;    transform: rotate(-90deg) scale(1); }
}

/* Waking: pressure returning to the body. Fast expansion, long settle. */
@keyframes orb-wake-pulse {
  /* The moment the piece is really selling. Pressure returns to a cold body:
     it is slow to move, overshoots because it is heavy, comes back past rest,
     and settles. Four stops, not two — a single overshoot and a linear return
     reads as a UI easing curve; a damped second bounce reads as a physical
     object. Total travel is under 6%. */
  0%   { transform: scale(0.928); }
  28%  { transform: scale(1.042); }
  52%  { transform: scale(0.991); }
  74%  { transform: scale(1.006); }
  100% { transform: scale(1); }
}

/* Frost forming as the orb goes dormant: crystals grow inward from the limb
   and stay. One-shot; the frost itself is a static property, so removing this
   animation loses the forming, never the frost. */
@keyframes orb-frost-settle {
  0%   { opacity: 0; transform: scale(1.1); }
  60%  { opacity: calc(var(--_crystal) * 1.15); }
  100% { opacity: var(--_crystal); transform: scale(1); }
}

/* Failure: the crack arrives all at once and the body flinches. Also one-shot
   — the fracture is a static property, so with motion off the orb is simply
   already broken. */
@keyframes orb-fracture {
  0%   { opacity: 0; transform: scale(1.05); }
  18%  { opacity: 1; transform: scale(0.992); }
  46%  { transform: scale(1.004); }
  100% { opacity: var(--_fracture); transform: scale(1); }
}

/* Ping: a shockwave with weight. It leaves fast, thins as it expands and
   decelerates into nothing — a pressure front, not a ripple. */
@keyframes orb-ping-wave {
  /* Front one: the hard leading edge. It leaves almost instantly, thins as it
     expands, and decelerates into nothing — the deceleration is what makes it
     read as a pressure front losing energy rather than as a ripple. */
  0%   { opacity: 0; transform: scale(0.8);  border-width: 0.24em; }
  8%   { opacity: 1; }
  34%  { opacity: 0.72; border-width: 0.12em; }
  68%  { opacity: 0.3;  border-width: 0.05em; }
  100% { opacity: 0; transform: scale(1.78); border-width: 0.012em; }
}

/* Front two: the slower pressure wave behind the edge. It starts later, never
   travels as far, and is a soft field rather than a line, so the two together
   read as one event with depth instead of one expanding circle. */
@keyframes orb-ping-pressure {
  /* NOTE the direction: this element is a child of the leading edge, so the
     parent's scale multiplies into it. Shrinking here is what makes the front
     TRAIL — net travel works out at roughly 0.85 -> 1.32 against the edge's
     0.8 -> 1.78. It carries its own opacity so that, when it is running, it
     supersedes the flash below it in the animation list; when --motion-off
     kills it, the flash is left in charge and the ping is still felt. */
  0%   { transform: scale(1.06); opacity: 0; }
  14%  { opacity: 0.9; }
  55%  { opacity: 0.4; }
  100% { transform: scale(0.74); opacity: 0; }
}

/* The ping's non-moving half: 220ms of light. Runs in every motion mode, so
   a completed task is never silent. */
@keyframes orb-ping-flash {
  0%   { opacity: 0; }
  22%  { opacity: 0.85; }
  100% { opacity: 0; }
}

/* ==========================================================================
   2. Shell keyframes
   ========================================================================== */

/* A counter taking on a new value: the number brightens and the surface under
   it takes a brief tint. No movement, so it survives reduced motion intact. */
@keyframes ui-value-flash {
  0%   { background-color: color-mix(in oklab, var(--flash-tint, var(--brand)) 34%, transparent); }
  100% { background-color: transparent; }
}

/* Cards arriving and leaving. Transform only — opacity is carried separately
   by ui-fade-in / ui-fade-out so the two halves can be gated independently. */
@keyframes ui-card-enter {
  from { transform: translate3d(0, 0.75rem, 0) scale(0.985); }
  to   { transform: none; }
}
@keyframes ui-card-exit {
  from { transform: none; }
  to   { transform: translate3d(0, -0.5rem, 0) scale(0.99); }
}
@keyframes ui-fade-in  { from { opacity: 0; } to { opacity: 1; } }
@keyframes ui-fade-out { from { opacity: 1; } to { opacity: 0; } }

/* A log line arriving: it slides in from the gutter as if printed. */
@keyframes ui-log-in {
  from { transform: translate3d(-0.5rem, 0, 0); }
  to   { transform: none; }
}

/* Idle drift for a fleet: each orb wanders inside a couple of pixels on its
   own period, so a grid of parallel machines is never a static lattice.
   Symmetric curve, no seam. */
@keyframes ui-fleet-drift {
  0%,
  100% { transform: translate3d(0, 0, 0); }
  33%  { transform: translate3d(0.14em, -0.22em, 0); }
  66%  { transform: translate3d(-0.16em, 0.12em, 0); }
}

/* Distant particles in the background field. Constant speed is correct: these
   are meant to read as parallax, not as objects with mass. */
@keyframes field-drift {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(var(--drift-x, 2vw), var(--drift-y, -3vh), 0); }
}

/* ==========================================================================
   3. Motion utilities
   Each is a single class the shell can add. Every one of them pairs a
   transform-carrying animation (gated by --motion-off) with an opacity or
   colour animation that is NOT gated, so the event still registers when
   nothing is allowed to move.
   ========================================================================== */

.u-enter {
  animation-name: var(--motion-off, ui-card-enter), ui-fade-in;
  animation-duration: var(--enter-d), var(--enter-d);
  animation-timing-function: var(--ease-orb), var(--ease-out);
  animation-fill-mode: both, both;
}

.u-exit {
  animation-name: var(--motion-off, ui-card-exit), ui-fade-out;
  animation-duration: var(--enter-d), var(--enter-d);
  animation-timing-function: var(--ease-out), var(--ease-out);
  animation-fill-mode: both, both;
}

.u-log-in {
  animation-name: var(--motion-off, ui-log-in), ui-fade-in;
  animation-duration: var(--tween-d), var(--cue-d);
  animation-timing-function: var(--ease-out), var(--ease-out);
  animation-fill-mode: both, both;
}

.u-flash {
  animation-name: ui-value-flash;
  animation-duration: calc(var(--cue-d) * 3);
  animation-timing-function: var(--ease-out);
}

/* Fleet drift. --drift-i is the item's index; it staggers period and delay so
   no two orbs in a grid are ever in phase. */
.u-drift {
  animation-name: var(--motion-off, ui-fleet-drift);
  animation-duration: calc(7s + var(--drift-i, 0) * 1.31s);
  animation-delay: calc(var(--drift-i, 0) * -0.77s);
  animation-timing-function: var(--ease-in-out);
  animation-iteration-count: infinite;
}

/* The universal state-change cue. Put it on anything whose meaning just
   changed; it is the same 130ms in every motion mode. */
.u-settle {
  transition:
    opacity var(--cue-d) var(--ease-out),
    color var(--cue-d) var(--ease-out),
    background-color var(--cue-d) var(--ease-out),
    border-color var(--cue-d) var(--ease-out),
    box-shadow var(--cue-d) var(--ease-out);
}

/* Structural transitions: these are the ones that go to 0ms when motion is
   reduced, because they move things. */
.u-tween {
  transition:
    transform var(--tween-d) var(--ease-orb),
    width var(--tween-d) var(--ease-orb),
    height var(--tween-d) var(--ease-orb);
}
.u-tween-slow {
  transition: transform var(--tween-slow-d) var(--ease-orb);
}

/* ==========================================================================
   4. REDUCED MOTION
   ---------------------------------------------------------------------------
   This is a design, not a kill switch. Read what it preserves:

   PRESERVED
     * All seven orb states remain fully distinguishable, because every state
       is carried by static structure — ring dash pattern, ring gap, glyph
       shape, frost, fracture, body luminance — and not by its animation.
     * Progress still reads: the ring arc's stroke-dashoffset still tracks
       --orb-progress. It simply arrives at the new value instead of
       travelling to it (--tween-slow-d is 0ms).
     * State CHANGES are still perceptible: every colour, opacity, stroke and
       border transition in the orb and the shell runs on --cue-d, which is
       130ms in every mode and is never scaled. A change of state produces a
       visible settle, never a silent swap.
     * Events still register: the ping's flash half (orb-ping-flash, 220ms of
       light, zero movement) is not gated, and neither is ui-value-flash or
       the opacity half of every enter/exit. The ping is declared as
       `orb-ping-flash, var(--motion-off, orb-ping-pressure)` in that order,
       so with motion on, the pressure front owns the opacity, and with motion
       off the front is `none` and the flash is left in charge. One event,
       two renderings, no second implementation.
     * The background field is still there, still layered, still deep — the
       horizon glow, the depth pools and the vignette are all static paint on
       .field itself, so the atmosphere is unchanged. Only the particle planes
       stop.

   REMOVED
     * Every loop: breathing, core pulse, halo swell, band travel, ring travel,
       ring draw, ring snap, wake pulse, fleet drift, field drift.
     * Every one-shot that works by moving: frost settle, fracture flinch,
       ping shockwave (both fronts), card slide, log-line slide.
     * Nothing is left half-running: --_phase in orb.css only sets
       animation-delay, which is inert once the animation-name is `none`.
     * Every structural tween: transform, width and height transitions go to
       0ms so nothing eases into position.

   Both switches below produce exactly the same result because they set
   exactly the same two variables, and every duration and animation-name in
   the site is derived from those two. There is no second implementation.
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  :root {
    --motion-off: none;
    --motion-k: 0;
  }
}

/* In-page toggle, independent of the OS setting. Placed after the media query
   and carrying higher specificity, so an explicit choice always wins. */
:root[data-motion="reduced"] {
  --motion-off: none;
  --motion-k: 0;
}

/* The opt-back-in. `initial` on a custom property is the guaranteed-invalid
   value, so var(--motion-off, <name>) falls back to <name> again. Only ever
   set this from a control the visitor operated themselves — never as a
   default, or it would silently override the OS preference. */
:root[data-motion="full"],
:root[data-motion="normal"] {
  --motion-off: initial;
  --motion-k: 1;
}
