/* Motion layer — scroll reveals, hero parallax, section depth, line draw,
   load entrances. Loaded by ChapterLayout (every chapter page) and /rules/
   via a `?url` import; the classes are opt-in per element.

   Everything here is progressive enhancement with two gates:

   - Scrubbed motion (scroll reveal, parallax, depth, line draw) is nested in
     `@supports (animation-timeline: view())`, so browsers without scroll-
     driven animations render the fully static page — no pre-animated
     sections, no half-run states.
   - All motion is nested in `prefers-reduced-motion: no-preference`, so
     opted-out users get the static page by construction rather than by
     subtracting motion from them.

   Scrubbed animations use `linear` easing: the scroll position is the
     timing function, and any curve here would double-ease. Time-based
     entrances use the house curve from tokens.css. `transform` and
     `opacity` only — both composite on the GPU and keep the 200ms INP
     budget intact (.agents/rules/animation.md).

   Keyframes and reveals deliberately have no stagger: sections move as one
   unit, which is this site's stated point of view on cascades. */

@media (prefers-reduced-motion: no-preference) {
  /* Scroll reveal: a section rises into place as it enters the viewport.
     `both` holds the hidden start state until entry begins, and the range
     ends at 45% of entry, so a section is fully settled before its top
     reaches mid-screen — reading pace is never gated on the animation.
     Anchor jumps land past the range and render the final state. */
  @supports (animation-timeline: view()) {
    .reveal {
      animation: reveal-rise linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 45%;
    }

    /* Parallax: hero pieces lag the scroll at two depths while the hero
       exits through the top of the viewport. The distances are small on
       purpose — this is depth, not spectacle — and each drift completes
       only once its element has left the screen. */
    .parallax-near {
      animation: parallax-near linear both;
      animation-timeline: view();
      animation-range: exit 0% exit 100%;
    }

    .parallax-far {
      animation: parallax-far linear both;
      animation-timeline: view();
      animation-range: exit 0% exit 100%;
    }

    /* Section depth: the two columns of a `.pipeline` / `.practice` grid
       drift at different rates for the whole time the section is on screen,
       so the heading reads at a different distance from the panel beside it.
       This is the hero's parallax extended to mid-page sections, and the
       range differs for a reason — the hero animates on `exit` because it
       starts already on screen, while a mid-page section is watched from
       first pixel to last, so the range is `entry 0% exit 100%`.

       Not a stagger. Nothing waits its turn: both columns move continuously
       and simultaneously, only at different rates. The no-cascade point of
       view in the header still holds. */
    .depth-slow {
      animation: depth-slow linear both;
      animation-timeline: view();
      animation-range: entry 0% exit 100%;
    }

    .depth-fast {
      animation: depth-fast linear both;
      animation-timeline: view();
      animation-range: entry 0% exit 100%;
    }

    /* Line draw: a hairline paints itself along its own path as the section
       scrolls in. This is the native stand-in for the frame-by-frame runtime
       this site refuses to ship — a Lottie player is ~60KB gzipped driving
       `requestAnimationFrame` on the main thread, against a 200ms INP
       budget; this is 0KB, and the scroll position is the clock.

       `pathLength="100"` on the path normalises its geometry to 100 units,
       so these numbers hold for any rendered size with no JS measurement
       step. `stroke-dasharray` and `stroke-dashoffset` must both equal that
       length, or the line starts part-drawn (offset too small) or never
       closes (offset too large).

       `stroke-dashoffset` is the one property here that is not `transform`
       or `opacity`. It repaints the path; it does not force layout, and the
       subject is a 1px hairline, so the cost is a repaint of a few hundred
       pixels per frame. That is the deliberate exception to the
       transform/opacity rule in .agents/rules/animation.md — the only way
       to draw a line along a path is to change how it is stroked.

       The range is `cover`, not `entry`. Every `view()` range keys to first
       visibility, and for a section on a tall page that is long before the
       section is read — an `entry`-based draw measured out at scrollY
       190-654 on /skills/, finishing while the reader was still on the hero.
       `cover 40%` to `cover 75%` is the band where the subject sits around
       the middle of the screen, which is where a reader actually is. */
    .draw-line path {
      stroke-dasharray: 100;
      stroke-dashoffset: 100;
      animation: line-draw linear both;
      animation-timeline: view();
      animation-range: cover 40% cover 75%;
    }
  }

  /* Load entrance for above-the-fold heroes. Time-based, not scrubbed: one
     coordinated rise of the whole hero block in the house curve. */
  .rise-in {
    animation: load-rise var(--dur-rise) var(--ease-out) both;
  }
}

@keyframes reveal-rise {
  from {
    opacity: 0.01;
    transform: translateY(16px);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes parallax-near {
  from {
    transform: translateY(0);
  }

  to {
    transform: translateY(14px);
  }
}

@keyframes parallax-far {
  from {
    transform: translateY(0);
  }

  to {
    transform: translateY(30px);
  }
}

/* The two depth rates. Same direction, different magnitude — that is what
   reads as distance; opposite directions read as the page coming apart.
   The differential is the whole effect, so it has to clear the perception
   threshold: an earlier 22/6 pair measured out at 3-10px of separation
   across the window where a section is actually read, which is to say no
   effect at all. 38/10 puts ~25px of separation in that window and still
   reads as drift rather than spectacle. */
@keyframes depth-slow {
  from {
    transform: translateY(38px);
  }

  to {
    transform: translateY(-38px);
  }
}

@keyframes depth-fast {
  from {
    transform: translateY(10px);
  }

  to {
    transform: translateY(-10px);
  }
}

@keyframes line-draw {
  to {
    stroke-dashoffset: 0;
  }
}

@keyframes load-rise {
  from {
    opacity: 0.01;
    transform: translateY(10px);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Printing freezes animations at their current time; a not-yet-entered
   section would print invisible. Static for paper. */
@media print {
  .reveal,
  .parallax-near,
  .parallax-far,
  .depth-slow,
  .depth-fast,
  .rise-in {
    animation: none;
  }

  /* A drawn line frozen mid-animation prints as a fragment. Undo the dash
     pattern rather than the animation, so the full rule prints. */
  .draw-line path {
    animation: none;
    stroke-dasharray: none;
    stroke-dashoffset: 0;
  }
}
