/**
 * Motion.
 *
 * The governing rule: every animation here is DECORATION, never a gate. The finished,
 * visible state is always the default; animation only ever re-times something that is
 * already correct. Delete this entire file and the page is complete and legible.
 *
 * That is what makes the Firefox gap acceptable - scroll-driven animations have no
 * Firefox implementation, and the common mistake of writing `opacity: 0` as the base
 * state would leave those users a blank page. Nothing here does that.
 *
 * prefers-reduced-motion does NOT suppress scroll-driven animations automatically, so
 * every block below is authored inside `@media not (prefers-reduced-motion: reduce)`
 * by hand.
 */

/* -------------------------------------------------------------------------- */
/* Keyframes                                                                   */
/* -------------------------------------------------------------------------- */

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(14px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@keyframes reveal-up {
  from {
    opacity: 0;
    transform: translateY(28px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@keyframes draw {
  from {
    stroke-dashoffset: var(--draw-length, 400);
  }
  to {
    stroke-dashoffset: 0;
  }
}

@keyframes drift {
  from {
    transform: translate3d(0, 0, 0);
  }
  to {
    transform: translate3d(0, -4%, 0);
  }
}

@keyframes pulse-ring {
  0% {
    box-shadow: 0 0 0 0 color-mix(in oklch, var(--brand) 35%, transparent);
  }
  70% {
    box-shadow: 0 0 0 12px transparent;
  }
  100% {
    box-shadow: 0 0 0 0 transparent;
  }
}

/* -------------------------------------------------------------------------- */
/* Motion-safe enhancements                                                    */
/* -------------------------------------------------------------------------- */

@media not (prefers-reduced-motion: reduce) {
  /* Entrance for above-the-fold content. Time-based, so it works in every engine. */
  .hero h1,
  .hero__lede,
  .hero__actions,
  .hero__facts {
    animation: rise var(--dur-slow) var(--ease-out) backwards;
  }

  .hero h1 {
    animation-delay: 60ms;
  }
  .hero__lede {
    animation-delay: 160ms;
  }
  .hero__actions {
    animation-delay: 240ms;
  }
  .hero__facts {
    animation-delay: 320ms;
  }

  .order__progress li {
    transition:
      background-color var(--dur) var(--ease-out),
      color var(--dur) var(--ease-out);
  }

  /* The order panel used to fade in on every step change. It does not any more, and the
     reason is worth keeping: `rise` starts at opacity 0, and an animation that is paused
     at its first frame - which is what happens to every CSS animation in a document the
     browser has decided is not visible - leaves the order form invisible until the tab is
     looked at again. A panel that simply appears is not as smooth and cannot fail. This
     is the one element on the page where that trade is not close. */

  .result__badge {
    animation: pulse-ring 2.4s var(--ease-out) 400ms 2;
  }

  /* Scroll-linked work. Anonymous view()/scroll() timelines need no named-timeline
     plumbing, so there is nothing to keep in sync with the markup. */
  @supports (animation-timeline: view()) {
    [data-reveal] {
      animation: reveal-up linear both;
      animation-timeline: view();
      animation-range: entry 8% cover 26%;
    }

    /* The woven backdrop drifts as the hero scrolls past. Transform only, so it is
       excluded from CLS and stays on the compositor. */
    .hero__weave {
      animation: drift linear both;
      animation-timeline: scroll(root block);
      animation-range: 0 90vh;
      will-change: transform;
    }

    /* The rule under a section heading draws itself as the heading arrives. */
    .rule-draw {
      animation: draw linear both;
      animation-timeline: view();
      animation-range: entry 10% cover 30%;
      stroke-dasharray: var(--draw-length, 400);
    }
  }

  /* Same-document view transitions are Baseline; a browser without them simply swaps. */
  @supports (view-transition-name: none) {
    .order__body {
      view-transition-name: order-body;
    }
  }
}

/* -------------------------------------------------------------------------- */
/* Reduced motion                                                              */
/* -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  /* The spinner is the one piece of motion that carries meaning: it says "working".
     Slowed right down rather than removed, so a busy button still reads as busy. */
  .btn.is-busy .btn__spinner {
    animation: spin 2.4s linear infinite !important;
    animation-duration: 2.4s !important;
  }

  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

/* Cross-document view transitions were tried and removed.
 *
 * On a site that is essentially one page plus a privacy notice they change almost
 * nothing visually, and the opt-in made Chrome start a transition on every navigation
 * that was then skipped, surfacing as `AbortError: Transition was skipped` in the
 * console. Noise with no benefit is not a trade worth making.
 *
 * The same-document transition on the order steps IS used - see js/main.js - because
 * there it smooths a panel swap the customer actually watches. */

/* ========================================================================== */
/* Scroll storytelling                                                         */
/* ========================================================================== */

/**
 * The premium layer.
 *
 * Built on the same rule as everything above it: the finished state is the default, and
 * motion only re-times something already correct. Delete this block and the page is
 * whole. That is what lets it ship without a JS animation library - the reference site
 * this is modelled on drives the same effects with GSAP, ScrollTrigger, SplitText and
 * Lenis, about 70 KB of JavaScript that has to run before anything moves. Here the
 * browser runs them on the compositor from CSS, so a slow phone gets the page instantly
 * and the motion costs it nothing.
 *
 * Every rule below animates ONLY transform, translate, scale, opacity or clip-path.
 * Nothing touches layout, so none of it can move the page or add to CLS.
 */

@keyframes parallax-drift {
  from {
    translate: 0 var(--parallax-from, -4%);
  }
  to {
    translate: 0 var(--parallax-to, 4%);
  }
}

@keyframes settle-in {
  from {
    opacity: 0;
    transform: translateY(2.2rem);
    clip-path: inset(0 0 38% 0);
  }
  to {
    opacity: 1;
    transform: none;
    clip-path: inset(0 0 0 0);
  }
}

@keyframes zoom-out {
  from {
    scale: 1.12;
  }
  to {
    scale: 1;
  }
}

@keyframes progress-grow {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

@media not (prefers-reduced-motion: reduce) {
  @supports (animation-timeline: view()) {
    /* --- Parallax ---------------------------------------------------------
       The picture drifts against its own frame while the frame scrolls past.
       `overflow: hidden` on the figure is what makes it read as depth rather than as a
       picture that has come loose.

       The plates used to drift too, and it was burying their captions. `overflow:
       hidden` clips the drift to the FIGURE, and on a plate the figure contains the
       caption as well as the photograph - so a picture sliding 34px down at one scroll
       position simply covered the words underneath it. Measured: caption top 453,
       picture bottom 476 on the odd plates and 491 on the even ones, with
       `elementFromPoint` over every caption returning the IMG.

       That is what the client circled. It looked like "some captions are missing",
       because how far each picture had drifted depended on where it happened to be in
       the viewport, so the lower two were buried and the upper two only clipped.

       The band keeps the effect: it is a single full-width photograph whose caption sits
       inside it by design, with no text below the frame to lose. On a 300px card the
       drift was barely perceptible anyway, which is a poor trade for the caption. */
    .band,
    .plate {
      overflow: hidden;
    }

    .band picture {
      display: block;
      animation: parallax-drift linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
      will-change: translate;
    }

    /* Deeper drift on the full-width band, which has the most room for it. */
    .band picture {
      --parallax-from: -6%;
      --parallax-to: 6%;
    }

    /* --- Arrival ----------------------------------------------------------
       Photographs settle in with the frame wiping upward rather than a plain fade. It is
       the one effect that reads as "expensive" for almost no cost. */
    .plate picture,
    .band picture,
    .tile__media {
      view-timeline-name: none;
    }

    .tile__media img {
      animation: zoom-out linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 45%;
    }

    .plate,
    .band {
      animation: settle-in linear both;
      animation-timeline: view();
      animation-range: entry 4% cover 30%;
    }

    /* --- Section headings -------------------------------------------------
       A stagger, not a splitter. Real line-splitting needs JS and a re-measure on every
       resize; giving the heading and its lede different ranges reads almost the same and
       survives a text change. */
    .section__head > :nth-child(1) {
      animation: reveal-up linear both;
      animation-timeline: view();
      animation-range: entry 6% cover 22%;
    }

    .section__head > :nth-child(2) {
      animation: reveal-up linear both;
      animation-timeline: view();
      animation-range: entry 10% cover 30%;
    }

    .section__head > :nth-child(3) {
      animation: reveal-up linear both;
      animation-timeline: view();
      animation-range: entry 14% cover 36%;
    }

    /* The four steps used to be a sticky stack here. It was removed after looking at it:
       the cards escaped their container, overlapped each other, and sat at 45% opacity
       once scrolled past - so the section read as broken text rather than as a stack.
       A stagger that always leaves the copy readable is worth more than an effect that
       only works in theory. */

    /* --- Reading progress -------------------------------------------------
       One bar under the header, scrubbed by the document. No scroll listener, so it
       cannot jank. */
    .scroll-progress span {
      animation: progress-grow linear both;
      animation-timeline: scroll(root block);
      transform-origin: 0 50%;
    }
  }
}

/* -------------------------------------------------------------------------- */
/* The stack needs real height to slide against, and that is layout, so it sits
   outside the motion query: without it the sticky cards have nothing to stick to.
   Reduced motion keeps the stacking (it is layout, not animation) but the recede
   above never runs, so nothing moves. */
/* -------------------------------------------------------------------------- */

/* -------------------------------------------------------------------------- */
/* Handover to the GSAP layer                                                  */
/* -------------------------------------------------------------------------- */

/*
 * Everything above is the layer that runs with no JavaScript at all: scroll-driven CSS,
 * no library, no listeners. When js/motion/ boots it adds `motion-ready` to <html> and
 * takes over the same elements with GSAP and ScrollTrigger.
 *
 * Both running at once is not two nice effects layered up. A CSS animation sits in a
 * higher cascade origin than an inline style, so while `reveal-up` was running it held
 * the opacity of elements GSAP had just set to 0 as the start of its own `from()` tween.
 * The result was six section headings, the price-list footnote and the full-width band
 * sitting at opacity 0 for the entire page - invisible, on a finished site, with no
 * error anywhere to say so.
 *
 * So whoever is driving, drives alone. `.rule-draw` and the reading-progress bar stay:
 * the GSAP layer has no equivalent for either, and nothing else touches them.
 */
@supports (animation-timeline: view()) {
  .motion-ready [data-reveal],
  .motion-ready .section__head > :nth-child(1),
  .motion-ready .section__head > :nth-child(2),
  .motion-ready .section__head > :nth-child(3),
  .motion-ready .plate,
  .motion-ready .band,
  .motion-ready .tile__media img,
  .motion-ready .band picture,
  .motion-ready .plate picture {
    animation: none;
    /* The `animation` shorthand resets this too, but only in engines that implement the
       newer shorthand. Stated outright so the handover does not depend on that. */
    animation-timeline: auto;
  }
}

/* -------------------------------------------------------------------------- */
/* Hero handover                                                               */
/* -------------------------------------------------------------------------- */

/*
 * The four hero elements have a time-based `rise` above. It is the no-JS entrance, and it
 * has to stand down when the GSAP layer arrives, for the same reason the scroll-driven
 * block does: one owner per element.
 *
 * Two separate problems, both real. A CSS animation outranks an inline style, so while
 * `rise` was running GSAP's own hero tween had no effect at all - the JavaScript entrance
 * was dead code. And `rise` is declared `backwards`, which means it holds opacity 0 for
 * the whole of its delay; a CSS animation in a document the browser treats as not visible
 * never advances past that first frame, so the hero sat invisible indefinitely. A QA pass
 * caught exactly that: h1, lede, actions and facts all at opacity 0 after seven seconds.
 *
 * The GSAP versions go through the runtime's guarded `from`, which forces the finished
 * state after four seconds on a setTimeout - and setTimeout keeps running when rAF does
 * not. So the handover also turns "invisible forever" into "appears without animating".
 */
@media not (prefers-reduced-motion: reduce) {
  .motion-ready .hero h1,
  .motion-ready .hero__lede,
  .motion-ready .hero__actions,
  .motion-ready .hero__facts {
    animation: none;
  }
}
