/* ============================================================================
   MOKI · fx-motion.css  (W2-T3)
   SOLE owner of every animated property on the site: all @keyframes, all
   transition declarations, all animation-timeline/animation-range rules, and
   every prefers-reduced-motion override. Nothing outside this file animates
   anything, which is what makes the §11 grep-audit meaningful.

   The motion budget (SITE-BUILD-SPEC §5, hard cap):
     1. ONE signature mechanic ... the Standing Frame (pin + 3-plane parallax
        + eye-glint, one system, §5.4)
     2. ONE reveal family ....... print-wipe / plate-turn (§5 preamble; the
        curtain stamp is an application of the same wipe, §5.1)
     3. ONE ambient marquee ..... #tape (§5.2)
     4. ONE button affordance ... the Field Kit / Night Rounds press (§6)
   Plus two spec-mandated status behaviours that are feedback, not decoration:
   the #market live-pulse/stale-dim (§8) and the game's fail flash (§9,
   user-driven input, the exemption every source doc grants game loops).
   ZERO cursor-follow. ZERO idle bob/breathe loops on the cutout. Killed in
   §1 by the judges; do not reintroduce under any name.

   Property whitelist: transform, opacity, clip-path, filter. The one
   exception is spec-verbatim: §6's button press transitions box-shadow
   ("shadow collapse"), quoted literally from the Arcade/Toybox graft. The
   §10 audit grep (width|height|top|left|margin|padding|border-width|
   font-size) must return nothing; this file avoids those property names
   entirely (logical properties: inline-size / block-size / inset-*), so the
   audit stays clean even when run against the whole file, not just the
   animated blocks.

   JS contracts consumed here (defined for W3, which starts after this file):
     html.js / html.curtain-armed  set by index.html's pre-paint script
     .reveal.in                    app.js IntersectionObserver (threshold .3)
     .stand__stamp.in              app.js, one active exhibit stamp at a time
     .stand__plane.in              app.js, no-scroll-timeline fallback only
     .stand__plane.is-near-view    app.js IO, will-change hygiene (§5.4)
     .stand__eyes.is-glint         app.js, re-added once per exhibit change
     #tape[data-paused]            app.js, on visibilitychange
     .kit__canvas--fade.is-holding pfp.js trait-swap crossfade (§6)
     .toy[data-state="failed"]     game.js fail state (§9)
     #market[data-state=...]       live.js state machine (§8)
   ========================================================================= */

/* ----------------------------------------------------------------------------
   1. THE SHARED REVEAL FAMILY · print-wipe + plate-turn (§5 preamble)
   One DNA, two applications, same ease, same clock. Hidden initial states are
   double-gated: html.js (no JS = everything visible, §11.2) AND
   prefers-reduced-motion: no-preference (a reduced-motion visitor must never
   meet an element whose reveal was the only thing that ever set its opacity,
   §11.3). Removing this stylesheet leaves the page fully usable by design.
--------------------------------------------------------------------------- */
@media (prefers-reduced-motion: no-preference) {

  /* The print-wipe: text prints upward onto the page. Values verbatim from
     the spec: inset(0 0 110% 0) -> inset(0 0 -15% 0), the -15% overshoot so
     descenders and the stamp border are never shaved by the clip edge. */
  html.js .reveal[data-reveal="wipe"] {
    clip-path: inset(0 0 110% 0);
    transition: clip-path var(--duration-wipe) var(--ease-out-expo);
    /* Group stagger (§5 preamble): --d is set by app.js from sibling index;
       the fallback derives the same min(index,6) * 70ms from the --i that
       W1-T1 already ships inline on the archive cards. Single plates carry
       neither and resolve to 0ms: they never stagger against themselves. */
    transition-delay: var(--d, calc(min(var(--i, 0), 6) * 70ms));
  }
  html.js .reveal[data-reveal="wipe"].in {
    clip-path: inset(0 0 -15% 0);
  }

  /* The plate turn: the photo/card settles while a hairline draws beneath it.
     Settle: opacity 0 + scale(1.018) -> 1 + scale(1) over --duration-settle.
     --fan participates so the archive cards' static fan rotation (section 6
     below) survives the reveal instead of being overwritten by it. */
  html.js .reveal[data-reveal="plate"] {
    opacity: 0;
    transform: rotate(var(--fan, 0deg)) scale(1.018);
    transition:
      opacity   var(--duration-settle) var(--ease-out-expo),
      transform var(--duration-settle) var(--ease-out-expo);
    transition-delay: var(--d, calc(min(var(--i, 0), 6) * 70ms));
  }
  html.js .reveal[data-reveal="plate"].in {
    opacity: 1;
    transform: rotate(var(--fan, 0deg)) scale(1);
  }

  /* The hairline: a normal-flow 1px rule at the plate's foot, drawn
     left-to-right by clip-path. Normal flow, not absolute, deliberately:
     .witness__crop is not a positioned box and this file may not redeclare
     styles.css's selectors to make it one. The draw lasts --duration-wipe
     but is delayed by (settle - wipe) = 260ms so hairline and settle finish
     together, per the spec's "timed to finish together".
     NOTE for W2-T2: this claims ::after on plate-reveal elements
     (.witness__crop, .archive__card). If fx-surfaces needs a pseudo on the
     same elements, it takes ::before. */
  html.js .reveal[data-reveal="plate"]::after {
    content: "";
    display: block;
    block-size: 1px;
    margin-block-start: var(--space-3);
    background-image: linear-gradient(90deg, var(--moki-rule) 0%, var(--moki-witness) 100%);
    clip-path: inset(0 100% 0 0);
    transition: clip-path var(--duration-wipe) var(--ease-out-expo);
    transition-delay: calc(var(--d, calc(min(var(--i, 0), 6) * 70ms)) + (var(--duration-settle) - var(--duration-wipe)));
  }
  html.js .reveal[data-reveal="plate"].in::after {
    clip-path: inset(0 0 0 0);
  }
}

/* ----------------------------------------------------------------------------
   2. #curtain · the one-time corner stamp (§5.1)
   An application of the print-wipe, not a fifth budget line. One animation
   carries the whole life: wipe on (0-10% = 400ms of the 4s total), rest
   (10-85% = 3s), fade out on opacity only (85-100% = 600ms), fill forwards
   so it ends transparent. app.js unmounts the node afterwards; until then it
   is aria-hidden and pointer-events: none, so the leftover is inert.
--------------------------------------------------------------------------- */

/* Mount gate: the pre-paint script only sets .curtain-armed once per session
   and never under reduced motion. A .js page without the armed flag must not
   show a frozen stamp forever. (html:not(.js) is styles.css's own hide.) */
html.js:not(.curtain-armed) #curtain { display: none; }

@media (prefers-reduced-motion: no-preference) {
  html.curtain-armed .curtain__stamp {
    animation: curtain-stamp 4s linear forwards;
  }
}

@keyframes curtain-stamp {
  0% {
    clip-path: inset(0 0 110% 0);
    opacity: 1;
    animation-timing-function: var(--ease-out-expo);
  }
  10% {
    clip-path: inset(0 0 -15% 0);
    opacity: 1;
    animation-timing-function: linear;
  }
  85% {
    clip-path: inset(0 0 -15% 0);
    opacity: 1;
  }
  100% {
    clip-path: inset(0 0 -15% 0);
    opacity: 0;
  }
}

/* Belt and braces: the inline script already refuses to arm the curtain
   under prefers-reduced-motion, but if it ever mounted anyway there is no
   animation to remove it, so it must not exist at all. */
@media (prefers-reduced-motion: reduce) {
  #curtain { display: none; }
}

/* ----------------------------------------------------------------------------
   3. #tape · the one ambient marquee (§5.2)
   translateX(-50%) over a track that app.js has doubled to exactly two
   identical groups, so -50% lands on a seamless join. Gated on html.js
   because without the clone there is no second group to hand off to.
--------------------------------------------------------------------------- */
@media (prefers-reduced-motion: no-preference) {
  html.js .tape__track {
    /* 36s tuned by eye against the seven shipped facts: slow enough to read,
       fast enough to register as alive. Linear: a marquee that eases reads
       as broken, not designed. */
    animation: tape-run 36s linear infinite;
  }

  /* Edge mask belongs to the marquee (styles.css defers it here): fades the
     run-in and run-out so entries appear from nothing instead of popping at
     a hard clip edge. Static, never animated. */
  html.js #tape {
    -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 6%, #000 94%, transparent 100%);
    mask-image: linear-gradient(90deg, transparent 0%, #000 6%, #000 94%, transparent 100%);
  }

  /* Pause on hover (spec) and while the tab is hidden (app.js flips
     data-paused on visibilitychange, so a background tab spends nothing). */
  #tape:hover .tape__track,
  #tape[data-paused] .tape__track {
    animation-play-state: paused;
  }
}

@keyframes tape-run {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-50%, 0, 0); }
}

/* ----------------------------------------------------------------------------
   4. #nav · scroll fill rail + drawer + active tick (§5.3)
--------------------------------------------------------------------------- */

/* The fill rail: a direct 1:1 mapping of scroll position, zero JS, gated
   behind @supports with a display:none fallback exactly as specced. Not
   reduced-motion-gated: the spec calls it out as a position readout, not a
   decorative loop, so it is unaffected by the preference. */
.nav__progress { display: none; }

@supports (animation-timeline: scroll()) {
  .nav__progress {
    display: block;
    position: absolute;           /* #nav is sticky, therefore positioned */
    inset-inline: 0;
    inset-block-end: 0;
    block-size: 2px;
    background-color: var(--moki-witness);
    transform-origin: 0 50%;
    transform: scaleX(0);
    animation: nav-fill linear forwards;
    animation-timeline: scroll(root block); /* must follow the shorthand: the
                                               shorthand resets timeline to auto */
  }
}

@keyframes nav-fill {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

/* The active tick: app.js lights the section currently in the viewport band.
   Declared here because no other layer styles nav state and the tick is part
   of §5.3's motion paragraph. Colour snaps, it does not transition: colour
   is outside the animated-property whitelist. */
.nav__rail a[aria-current="true"] {
  color: var(--moki-witness);
}

/* The drawer: animated across the display swap only where the platform can
   (§5.3), instant everywhere else. @starting-style covers the entry
   direction; [hidden] + allow-discrete covers the exit. */
@media (prefers-reduced-motion: no-preference) {
  @supports (transition-behavior: allow-discrete) {
    .nav__drawer {
      transition:
        opacity   var(--duration-fast) var(--ease-out-expo),
        transform var(--duration-fast) var(--ease-out-expo),
        display   var(--duration-fast) allow-discrete;
    }
    .nav__drawer[hidden] {
      opacity: 0;
      transform: translateY(-6px);
    }
    @starting-style {
      .nav__drawer {
        opacity: 0;
        transform: translateY(-6px);
      }
    }
  }
}

/* ----------------------------------------------------------------------------
   5. #stand · THE STANDING FRAME (§5.4) · the signature mechanic
   The cat cutout itself has NO rule in this file. That is the point: the
   subject does not react to being watched; the world does. Nothing here may
   ever target .stand__cat.
--------------------------------------------------------------------------- */
@media (prefers-reduced-motion: no-preference) {

  /* Primary path: scroll-driven parallax, zero scroll-event JS. The tall
     #stand section names a view timeline; the planes inside the sticky pin
     read it by name (named timelines resolve up the ancestor chain). */
  @supports (animation-timeline: view()) {
    #stand {
      view-timeline-name: --stand-pass;
      view-timeline-axis: block;
    }
    .stand__plane--far {
      animation: plane-far linear both;
      animation-timeline: --stand-pass;
    }
    .stand__plane--mid {
      animation: plane-mid linear both;
      animation-timeline: --stand-pass;
    }
    .stand__plane--near {
      animation: plane-near linear both;
      animation-timeline: --stand-pass;
    }
  }

  /* Fallback: one IntersectionObserver in app.js toggles a resting .in per
     plane, a fixed 2-keyframe transition eases between the same endpoints.
     Trades parallax smoothness for zero scroll math, never the reverse. */
  @supports not (animation-timeline: view()) {
    html.js .stand__plane {
      transition: transform 1400ms var(--ease-out-expo);
    }
    html.js .stand__plane--far      { transform: translate3d(0, -6%, 0); }
    html.js .stand__plane--far.in   { transform: translate3d(0,  6%, 0); }
    html.js .stand__plane--mid      { transform: translate3d(0, -14%, 0); }
    html.js .stand__plane--mid.in   { transform: translate3d(0,  14%, 0); }
    html.js .stand__plane--near     { transform: translate3d(0, -22%, 0); }
    html.js .stand__plane--near.in  { transform: translate3d(0,  22%, 0); }
  }

  /* will-change hygiene (§5.4): app.js grants it only while a plane is
     within 200px of the viewport and removes it after. Never global. */
  .stand__plane.is-near-view {
    will-change: transform;
  }

  /* The eye-glint: the ONLY filter usage on the entire site (§5.4, hard
     rule). One discrete step per exhibit transition, never a loop:
     steps(2, jump-none) holds the bright frame for half the run, then
     drops back. app.js removes and re-adds .is-glint to retrigger. */
  html.js .stand__eyes.is-glint {
    animation: eye-glint 620ms steps(2, jump-none) both;
  }

  /* The exhibit stamps: the print-wipe again (same family, same values).
     All five ship in markup for the no-JS reader; with JS and motion, only
     the stamp app.js marks .in is printed, the outgoing one wipes back up. */
  html.js .stand__stamp {
    clip-path: inset(0 0 110% 0);
    transition: clip-path var(--duration-wipe) var(--ease-out-expo);
  }
  html.js .stand__stamp.in {
    clip-path: inset(0 0 -15% 0);
  }
}

@keyframes plane-far {
  from { transform: translate3d(0, -6%, 0); }
  to   { transform: translate3d(0,  6%, 0); }
}
@keyframes plane-mid {
  from { transform: translate3d(0, -14%, 0); }
  to   { transform: translate3d(0,  14%, 0); }
}
@keyframes plane-near {
  from { transform: translate3d(0, -22%, 0); }
  to   { transform: translate3d(0,  22%, 0); }
}

@keyframes eye-glint {
  from { filter: brightness(1.35) drop-shadow(0 0 6px var(--moki-witness)); }
  to   { filter: brightness(1); }
}

/* ----------------------------------------------------------------------------
   6. #archive · sticky-stack fan + recede (§5.10)
   The deal mechanic itself is pure position:sticky (styles.css). This file
   adds the --i fan and, where view timelines exist, the outgoing card's
   recede across its exit crossing.
--------------------------------------------------------------------------- */

/* The fan: a static rotation per card, derived from the --i that W1-T1 ships
   inline. Card 1 sits level; neighbours tilt a third of a degree either way.
   Static transform, not an animation; it feeds the reveal via --fan so the
   plate-turn's scale never flattens it. */
.archive__card {
  --fan: calc((var(--i, 1) - 1) * 0.3deg);
}

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .archive__card {
      animation: card-recede linear both;
      animation-timeline: view();
      animation-range: exit-crossing 0% exit-crossing 100%;
    }
  }
}

/* Deliberately no `from` frame: before the range, fill resolves to the
   underlying value, so the plate-turn reveal transition still owns the
   card's entrance; the recede only takes over across the exit crossing. */
@keyframes card-recede {
  to {
    transform: rotate(var(--fan, 0deg)) scale(0.94);
    opacity: 0.35;
  }
}

/* ----------------------------------------------------------------------------
   7. The Field Kit (§6) · trait-swap crossfade + THE button affordance
--------------------------------------------------------------------------- */

/* Crossfade: canvas repaints cannot transition, so pfp.js draws the new
   composite immediately while a snapshot of the previous one sits in the
   #pfp-fade canvas above, fading to transparent. Contract: pfp.js paints the
   snapshot, adds .is-holding (opacity 1, no transition), forces a reflow,
   removes the class; this rule fades it out over --duration-fast. */
html.js .kit__stage {
  position: relative; /* enabling rule: anchors the overlay canvas below */
}
html.js .kit__canvas--fade {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  opacity: 0;
  pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  html.js .kit__canvas--fade {
    transition: opacity var(--duration-fast) linear;
  }
}
html.js .kit__canvas--fade.is-holding {
  opacity: 1;
  transition: none;
}

/* The one button-press affordance (§6, Arcade/Toybox graft, verbatim):
   :active translateY(3px), shadow collapse, 60ms linear release. box-shadow
   here is spec-quoted; the resting shadow itself is fx-surfaces' to draw.
   Scoped to the native controls of the Field Kit and Night Rounds; the
   pressed state applies always, only its 60ms release is motion-gated. */
@media (prefers-reduced-motion: no-preference) {
  #generator button,
  #toys button {
    transition:
      transform  var(--duration-press) linear,
      box-shadow var(--duration-press) linear;
  }
}
#generator button:active,
#toys button:active {
  transform: translateY(3px);
  box-shadow: none;
}

/* ----------------------------------------------------------------------------
   8. #toys · fail flash (§9)
   User-driven feedback, not ambient motion. The flash is an alert-tinted
   overlay pulsing on OPACITY, not filter: §5.4 reserves filter for the
   eye-glint alone, and §9's fiction keeps his eyes open regardless.
--------------------------------------------------------------------------- */
.toy__figure {
  position: relative; /* enabling rule: anchors the flash overlay */
}
.toy__figure::after {
  content: "";
  position: absolute;
  inset: 0;
  background-color: var(--moki-alert);
  opacity: 0;
  pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  .toy[data-state="failed"] .toy__figure::after {
    /* steps(2): on, then off. A blink of consequence, not a shimmer. */
    animation: toy-flash 360ms steps(2, jump-none) both;
  }
}
@keyframes toy-flash {
  from { opacity: 0.55; }
  to   { opacity: 0; }
}

/* ----------------------------------------------------------------------------
   9. #market · live pulse + staleness dim (§8)
   Status signalling, spec-mandated shape parameters. The dot only pulses in
   the live state; stale/error states hold it solid so "a live badge above an
   n/a" can never happen. live.js owns the markup and flips data-state.
--------------------------------------------------------------------------- */
@media (prefers-reduced-motion: no-preference) {
  #market[data-state="live"] .market__pulse {
    animation: market-pulse 2.4s var(--ease-out-expo) infinite;
  }
  #market .market__rail {
    transition: opacity 400ms var(--ease-out-expo);
  }
}

/* Past 95s without a successful update: every value dims to 45%, demoted,
   never deleted, never replaced with a zero (§8). The state applies with or
   without motion; only the fade into it is motion-gated above. */
#market[data-state="stale"] .market__rail {
  opacity: 0.45;
}

@keyframes market-pulse {
  0%, 100% { opacity: 1;    transform: scale(1); }
  50%      { opacity: 0.45; transform: scale(0.82); }
}

/* ----------------------------------------------------------------------------
   10. REDUCED MOTION · the complete-site guarantee (§11.3)
   Everything above that hides, loops, or translates was already gated behind
   no-preference, so under reduce the page is born in its final visible
   state. This block does the RESTRUCTURING the spec assigns to this file
   (styles.css §6 note): pin collapse, marquee-to-row, stack-to-grid.
--------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {

  /* Safety net on the reveal family: even if a .reveal somehow carries a
     stale inline state, it renders final and still. */
  .reveal,
  .reveal::after {
    clip-path: none;
    opacity: 1;
    transform: none;
    transition: none;
  }

  /* #tape: a static, hand-scrollable row with snap, never a stalled
     duplicate. app.js marks its runtime clone aria-hidden; if the clone
     exists anyway, it is dropped so no fact reads twice. */
  #tape {
    overflow-x: auto;
    scroll-snap-type: x proximity;
  }
  .tape__group[aria-hidden="true"] { display: none; }
  .tape__group span { scroll-snap-align: start; }

  /* #stand: the pin collapses into normal flow. The cat keeps its size and
     centering from styles.css's own grid (place-items: center, zero
     transforms), the exact regression class §11.3 hunts. All five exhibit
     stamps render statically, in order, so the dusk-to-dawn story stays
     complete without a single scroll-driven frame. */
  .stand__pin {
    position: static;
    block-size: auto;
    overflow: visible;
  }
  .stand__plane,
  .stand__eyes {
    /* The planes only mean anything crossing the frame; parked they are
       noise. The eye band duplicates pixels already present in the cat
       photo, so dropping it changes nothing visible. */
    display: none;
  }
  .stand__scroller { display: none; }
  .stand__stamps {
    display: grid;
    row-gap: var(--space-4);
    margin-block: var(--space-6);
  }
  .stand__stamp {
    position: static;
    justify-self: center;
  }

  /* #archive: a simple stacked list, no deal, no recede (§5.10). */
  .archive__card {
    position: static;
    transform: none;
    animation: none;
  }

  /* The game meter and its flash become instant/stepped; the game itself
     stays fully playable (§9). The status line carries the fail message. */
  .toy[data-state="failed"] .toy__figure::after { animation: none; }
}
