/**
 * THE MOTION SYSTEM
 *
 * A closed vocabulary: five reveal roles, three hover roles, three depth planes.
 * A component needing a behaviour that is not here has found a gap in the design
 * direction — it does not write a bespoke animation in its own stylesheet. Nine
 * bespoke animations is how a motion system dies, and it never happens in one
 * visible step.
 *
 * Two engines, split by semantics rather than by browser capability:
 *
 *   REVEALS use IntersectionObserver, one-shot, in every browser. A scroll-linked
 *     reveal runs backwards when the visitor scrolls up, fading text out while it is
 *     being read. One-shot is correct for content regardless of support, so there is
 *     no browser fork here at all.
 *
 *   DEPTH uses CSS scroll timelines where available and is otherwise absent. Continuous
 *     linkage to scroll position is the entire semantic of parallax and cannot be
 *     expressed one-shot. Where the timeline is missing the layer sits still, which is
 *     a correct rendering of the design rather than a broken one.
 *
 * Loaded last, and that ordering is load-bearing — see the note on [data-hover] below.
 */

/* ════════════════════════════════════════════════════════════════════════════
   1 · REVEALS
   ════════════════════════════════════════════════════════════════════════════

   Scoped to html[data-js], which a blocking head script sets before first paint.
   Without JavaScript the attribute never appears, none of these rules match, and the
   content is simply visible. That is not defensive styling for its own sake — an
   entrance animation must never be able to leave content permanently at opacity 0. */

html[data-js] [data-reveal][data-reveal-state='armed'],
html[data-js] [data-reveal='phase'][data-reveal-state='armed'] > * {
  opacity: 0;
}

/* `phase` staggers its children; every other role animates the element itself. */
html[data-js] [data-reveal='phase'][data-reveal-state='running'] > * {
  animation: reveal-phase var(--duration-slow) var(--ease-entrance) both;
  animation-delay: var(--phase-delay, 0ms);
}

html[data-js] [data-reveal='rise'][data-reveal-state='running'] {
  animation: reveal-rise var(--duration-slow) var(--ease-entrance) both;
}

html[data-js] [data-reveal='settle'][data-reveal-state='running'] {
  animation: reveal-settle var(--duration-deliberate) var(--ease-entrance) both;
}

html[data-js] [data-reveal='sweep'][data-reveal-state='running'] {
  animation: reveal-sweep var(--duration-slow) var(--ease-standard) both;
}

html[data-js] [data-reveal='field'][data-reveal-state='running'] {
  animation: reveal-field calc(var(--duration-deliberate) * 2) var(--ease-standard) both;
}

@keyframes reveal-phase {
  from { opacity: 0; transform: translate3d(0, var(--reveal-phase-distance), 0); }
  to   { opacity: 1; transform: none; }
}

@keyframes reveal-rise {
  from { opacity: 0; transform: translate3d(0, var(--reveal-rise-distance), 0); }
  to   { opacity: 1; transform: none; }
}

@keyframes reveal-settle {
  from {
    opacity: 0;
    transform: translate3d(0, var(--reveal-settle-distance), 0) scale(var(--reveal-settle-scale));
  }
  to { opacity: 1; transform: none; }
}

/* Text uncovers from below its own baseline rather than fading in place — the headline
   reads as arriving from somewhere, which fading does not convey. */
@keyframes reveal-sweep {
  from {
    opacity: 0;
    clip-path: inset(0 0 100% 0);
    transform: translate3d(0, var(--reveal-sweep-distance), 0);
  }
  to {
    opacity: 1;
    clip-path: inset(0 0 -0.15em 0);
    transform: none;
  }
}

/* Atmosphere. Opacity only: nothing to displace, so nothing to shift. */
@keyframes reveal-field {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* ════════════════════════════════════════════════════════════════════════════
   1b · `scramble` — VOCABULARY AMENDMENT
   ════════════════════════════════════════════════════════════════════════════

   A hover behaviour and an arrival behaviour, added 2026-08 at the founder's request.
   Recorded here as an amendment rather than written into one component's stylesheet,
   which is the process §9 of DESIGN-DIRECTION.md requires: a component needing a
   behaviour that is not in the vocabulary has found a gap in the document, and the fix
   is to widen the document.

   | Role | Behaviour | Use |
   |---|---|---|
   | `scramble` | Characters cycle through random glyphs and resolve into place | Interactive text; one arrival headline |

   WHY IT PASSES §1's TEST, WHERE THE TYPEWRITER DID NOT. This started as a typewriter
   and was replaced, and the difference is not taste. Typing is a borrowed idea — it
   says "a terminal" and says nothing about this company. A scramble that resolves is
   the site's own physics: the Coherence motif is a population that begins scattered and
   converges to phase lock, and unresolved characters are drawn in platinum while
   resolved ones sit in the text colour. It is the hero field's behaviour, performed by
   letterforms. That is explanatory rather than decorative, and it is the one version of
   this effect that could not be lifted from a template.

   WHY IT DOES NOT VIOLATE §8. No layout property is animated, and nothing here animates
   at all in the CSS sense — the substitution is a textContent write. Every character's
   box is frozen at its resolved width by text.js before the first substitution, so a
   wide glyph replacing a narrow one cannot reflow the line. Contribution to CLS is 0,
   and that is a property of the width lock rather than a hope about the charset.

   THE CSS HERE IS ONLY THE COLOUR STATE. The sequencing is in text.js, because a
   per-character random substitution is not expressible as a declarative animation. What
   belongs in the stylesheet is what the two states LOOK like, and that is all this is —
   which keeps the platinum-to-gold decision in the same file as every other colour
   decision rather than hidden in a script. */

/* Unresolved characters are the scattered population: platinum, exactly as the hero
   field draws an oscillator that has not locked. Resolved ones inherit normally.

   No transition on the colour. The character's glyph changes in the same frame, so
   easing the colour across 160ms would leave settled letters still catching up while
   the next ones scramble — the resolve has to read as instant to read as a lock. */
[data-scramble-state='running'] .split-char[data-res='0'] {
  color: var(--sync-muted);
}

/* The width lock, applied per character by text.js as an inline px value. Centring
   matters: a substituted glyph narrower than the resolved one would otherwise sit hard
   against the left of its frozen box and the line would look like it was vibrating.

   `transition: none` is the load-bearing declaration here, and it was found by
   measurement rather than foresight. §2b gives every .split-char a colour transition for
   the pointer field; inherited into a scramble it eased the platinum in over
   --duration-normal while each character resolves in --scramble-settle, roughly a tenth
   of that. The tint never arrived before the character snapped back to its resolved
   colour, so the scattered state was invisible and the effect was a plain glyph shuffle.
   Removing the transition is what makes the platinum-to-resolved read at all. */
[data-scramble-state='running'] .split-char {
  display: inline-block;
  text-align: center;
  transition: none;
}

/* Reduced motion never reaches the running state — text.js declines to start — so the
   text is simply itself. Stated rather than left implicit, because "the effect is
   skipped" and "the word is left as X7Q2M" are one early return apart. */
@media (prefers-reduced-motion: reduce) {
  [data-scramble-state] .split-char { color: inherit; }
}

/* ════════════════════════════════════════════════════════════════════════════
   1c · CINEMATIC ARRIVAL — the compound entrance for the hero headline
   ════════════════════════════════════════════════════════════════════════════

   Added 2026-08 (micro-interactions pass), consuming the typography-motion tokens the
   typeface pass introduced (--track-hero-from, --blur-reveal, --ease-cinematic — see
   tokens.css for why each value is what it is). Every token is read with its canonical
   fallback so this file renders correctly even against a token layer that predates the
   typeface pass.

   THE MOVE. The headline arrives wide, blurred and slightly low, and condenses: tracking
   closes from --track-hero-from to the resting value, blur resolves 12px → 0, and the
   block rises 0.16em into place — all on --ease-cinematic, so nearly all the travel
   lands in the first third and the tail is a long asymptotic settle. The scramble
   (§1b) runs concurrently on the same characters: glyphs are still resolving while the
   line is condensing, and text.js shapes the resolve front to the same decay curve, so
   the last characters lock just as the tracking lands. One event, two instruments.

   WHY letter-spacing IS ANIMATED HERE AND NOWHERE ELSE. It is a layout property and it
   relayouts the headline every frame — the exact cost §2b refuses per-character. The
   tokens file sanctions it for one-shot entrances only, and this is that one shot: a
   single 1.4s reveal absorbs the relayouts; nothing scroll-linked ever touches it.

   HOW IT COEXISTS WITH THE SCRAMBLE'S WIDTH LOCK. text.js freezes each character's box
   at its resolved width BEFORE setting data-arrive (same synchronous task, reads before
   the invalidation), so the lock is measured at resting tracking. The animated
   letter-spacing then spaces the locked boxes themselves — letter-spacing applies
   between atomic inlines, not only between glyphs — so the condensing is visible while
   no box ever changes size. The final frame computes to exactly the resting styles.

   THE ATTRIBUTE IS SET ONLY BY text.js, only for arrival scrambles, and never under
   reduced motion (text.js declines to scramble at all there, and §4 below guards the
   remainder). Without JavaScript the attribute never exists and the headline is simply
   itself — the same polarity as every reveal on this site. */

[data-scramble][data-arrive] { position: relative; }

[data-scramble][data-arrive='running'] {
  /* Double --duration-deliberate (1440ms): tuned so the ~44-character hero headline's
     scramble front (34ms/char, cinematically shaped in text.js) finishes inside the
     tracking settle rather than trailing it. */
  animation: arrive-cinematic
    calc(var(--duration-deliberate) * 2)
    var(--ease-cinematic, cubic-bezier(0.19, 1, 0.22, 1))
    both;
}

@keyframes arrive-cinematic {
  0% {
    opacity: 0;
    letter-spacing: var(--track-hero-from, 0.06em);
    filter: blur(var(--blur-reveal, 12px));
    transform: translate3d(0, 0.16em, 0);
  }
  /* Opacity lands early — by 40% the line is fully present and the rest of the
     animation is condensation and focus, not appearance. Unlisted properties keep
     interpolating 0% → 100% as normal. */
  40% { opacity: 1; }
  100% {
    opacity: 1;
    /* The to-value assumes the hero register because the hero H1 is the only arrival
       headline. A future [data-scramble] at another size overrides --arrive-track-to. */
    letter-spacing: var(--arrive-track-to, var(--track-hero, -0.022em));
    filter: blur(0);
    transform: none;
  }
}

/* ── The glint ──
   One gold light-pass across the settled headline, ~900ms after the arrival lands —
   the same lacquer-sheen precedent as .noir-panel, at headline scale. Drawn as an
   animated background-position on an overlay pseudo rather than as a translated band,
   because the H1 must NOT clip overflow (the pointer swell scales characters past the
   box edge) and background-position is paint-only — it can never move a layout box.

   mix-blend-mode: overlay is what keeps it reading as a glint on the letterforms
   rather than a stripe across the block: overlay barely moves the near-black Cosmos
   ground but brightens the light glyphs it crosses — text-selective by blend math,
   with no background-clip:text (which would destroy the per-character scramble
   colours). On Horizon the same pass reads as a soft light sweep over ivory, so it
   runs at reduced strength there. */

[data-scramble][data-arrive='running']::after {
  content: '';
  position: absolute;
  inset: -0.1em 0;
  pointer-events: none;
  mix-blend-mode: overlay;
  opacity: 0.9;
  /* Band lives at the centre of a 200%-wide image; sweeping background-position
     150% → -50% carries it left-to-right across the element — reading order. */
  background: linear-gradient(
    105deg,
    transparent 42%,
    rgb(232 206 122 / 0.9) 50%,
    transparent 58%
  );
  background-size: 200% 100%;
  background-repeat: no-repeat;
  background-position: 150% 0;
  animation: arrive-glint 820ms var(--ease-standard)
    calc(var(--duration-deliberate) * 2 + 900ms) both;
}

:root[data-theme='horizon'] [data-scramble][data-arrive='running']::after { opacity: 0.45; }

@keyframes arrive-glint {
  from { background-position: 150% 0; }
  to   { background-position: -50% 0; }
}

/* ════════════════════════════════════════════════════════════════════════════
   2 · HOVER AND POINTER
   ════════════════════════════════════════════════════════════════════════════

   One language, varying in strength by component weight. Every rule below sits inside
   the capability query: a hover effect that latches on tap is worse than no hover
   effect, and touch devices get :active feedback instead. */

/* Press feedback is universal and belongs outside the hover query — it is the one
   piece of interaction feedback a touch device does receive. */
[data-hover]:active {
  transform: translate3d(0, var(--press-distance), 0);
}

@media (hover: hover) and (pointer: fine) {
  /* This declaration is authoritative for every [data-hover] element, which is why
     motion.css is enqueued last and why the list includes properties the motion system
     does not itself set. `transition` is one property rather than an additive list: a
     component declaring `transition: background-color` at equal specificity does not
     add to this, it replaces it. Owning the whole list here is what stops a component
     from silently dropping the transform and shadow it never mentioned. */
  [data-hover] {
    transition:
      transform var(--duration-normal) var(--ease-standard),
      box-shadow var(--duration-normal) var(--ease-standard),
      border-color var(--duration-normal) var(--ease-standard),
      background-color var(--duration-normal) var(--ease-standard),
      color var(--duration-fast) var(--ease-standard),
      --hover-t var(--duration-normal) var(--ease-standard);
  }

  [data-hover]:hover,
  [data-hover]:focus-within {
    --hover-t: 1;
    /* Scoped deliberately. A permanent will-change is a permanent compositor layer,
       and a page of them costs more memory than the effect is worth. */
    will-change: transform;
  }

  /* ── tint · colour only ── */
  [data-hover='tint'] { transition: color var(--duration-fast) var(--ease-standard); }
  [data-hover='tint']:hover { color: var(--accent-interactive); }

  /* ── lift · elevation, rim light, and 1.2° of material ──
     The tilt maximum is a brand rule, not a tuning value. Template tilt runs 8–15° and
     reads as rotation; below ~1.5° it reads as a surface catching light at a different
     angle. That perceptual boundary is the line between handcrafted and purchased. */
  [data-hover='lift'],
  [data-hover='phase'] {
    position: relative;
    transform-style: preserve-3d;
  }

  [data-hover='lift']:hover,
  [data-hover='phase']:hover,
  [data-hover='lift']:focus-within,
  [data-hover='phase']:focus-within {
    transform:
      translate3d(0, var(--hover-lift-lift), 0)
      perspective(800px)
      rotateX(calc((0.5 - var(--py)) * var(--tilt-max) * 2))
      rotateY(calc((var(--px) - 0.5) * var(--tilt-max) * 2));
    box-shadow: var(--elevation-2);
    border-color: var(--border-accent);
  }

  /* Rim light tracks the pointer across the surface. Radial rather than directional,
     because a directional sheen reads as glass and this must read as a lit material. */
  [data-hover='lift']::after,
  [data-hover='phase']::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
    opacity: var(--hover-t);
    transition: opacity var(--duration-normal) var(--ease-standard);
    background: radial-gradient(
      circle at calc(var(--px) * 100%) calc(var(--py) * 100%),
      var(--accent-glow) 0%,
      transparent 60%
    );
  }

  /* ── phase · the brand signature ──
     The card's coherence marks align toward the pointer. Everything else here is
     borrowed from `lift`; this is the part that could not be bought in a template. */
  [data-hover='phase']:hover .cbar i,
  [data-hover='phase']:focus-within .cbar i {
    background: var(--accent-decorative);
    opacity: 1;
  }

  [data-hover='phase'] .phase-rule {
    transition: opacity var(--duration-normal) var(--ease-standard);
  }
  [data-hover='phase']:hover .phase-rule,
  [data-hover='phase']:focus-within .phase-rule { opacity: 1; }

  /* Directional affordance on any control carrying an arrow. */
  [data-hover] .arrow { transition: transform var(--duration-fast) var(--ease-standard); }
  [data-hover]:hover .arrow { transform: translate3d(3px, 0, 0); }
}

/* ════════════════════════════════════════════════════════════════════════════
   2b · POINTER-REACTIVE TYPE
   ════════════════════════════════════════════════════════════════════════════

   Display text reacts to the cursor per character. The script sets one number, --t
   (0 → 1 by proximity); everything about what that number *means* lives here, so a
   headline can swell while an eyebrow brightens without the script knowing either.

   Only transform, colour and opacity are driven. letter-spacing would be the obvious
   way to open tracking under the cursor and it is deliberately not used: it is a
   layout property, and re-laying out a headline every frame is exactly the cost the
   rest of this system is built to avoid.

   The characters are generated by the splitter. Without JavaScript they never exist,
   the text is a plain string, and none of these rules match anything. */

@property --t {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

.split-word {
  display: inline-block;
  white-space: nowrap;   /* the whole point of the word box: wrap between words, never inside one */
}

.split-char {
  display: inline-block;
  /* Without this the inline-block sits on its own baseline and a line of characters
     develops a visible sawtooth against the surrounding text. */
  vertical-align: baseline;
  transform-origin: 50% 70%;
  /* Eases the return when the cursor leaves. The script writes --t as a step per
     frame while the pointer moves, so this mostly governs the release. */
  transition:
    transform var(--duration-normal) var(--ease-standard),
    color var(--duration-normal) var(--ease-standard);
}

/* ── swell · hero display type ──
   Scale plus a small lift. The cap is 1.26 rather than something more dramatic
   because display type at 5rem is already large — past roughly a quarter the letters
   start colliding with their neighbours' side bearings and the line looks broken
   rather than alive. */
[data-split='swell'] .split-char {
  transform: translate3d(0, calc(var(--t) * -7px), 0) scale(calc(1 + var(--t) * 0.26));
}

/* ── lift · section headings ──
   Vertical only. A heading is read as a line, and scaling individual letters in a
   line of body-adjacent size reads as a fault rather than as an effect. */
[data-split='lift'] .split-char {
  transform: translate3d(0, calc(var(--t) * -9px), 0);
  color: color-mix(in srgb, var(--accent-interactive) calc(var(--t) * 100%), var(--text-primary));
}

/* ── glint · eyebrows and structural labels ──
   These are small, tracked and quiet. They brighten and rise a pixel; anything more
   at this size is noise. */
[data-split='glint'] .split-char {
  transform: translate3d(0, calc(var(--t) * -2px), 0) scale(calc(1 + var(--t) * 0.08));
  color: color-mix(in srgb, var(--accent-hover) calc(var(--t) * 100%), var(--accent-interactive));
}

/* The accent word in the hero keeps its colour while it swells — it is already the
   accent, and mixing it toward itself would flatten the contrast against the rest of
   the line. */
[data-split='swell'] .text-accent .split-char {
  color: var(--accent-interactive);
}

/* ── Magnetic controls ──
   The button leans toward the pointer by a few pixels before it is reached. The
   distance is small on purpose: enough to feel like the control wants to be pressed,
   not enough to make it a moving target. */
[data-magnet] {
  transition: transform var(--duration-normal) var(--ease-standard);
}
[data-magnet]:hover {
  transform: translate3d(calc(var(--mx, 0) * 6px), calc(var(--my, 0) * 4px), 0);
}

@media (prefers-reduced-motion: reduce) {
  .split-char,
  [data-magnet],
  [data-magnet]:hover {
    transform: none !important;
    transition: none;
  }
}

/* ════════════════════════════════════════════════════════════════════════════
   2c · SCROLL-DRIVEN TEXT FILL
   ════════════════════════════════════════════════════════════════════════════

   A lede resolves from muted to full strength as it crosses the viewport, so reading
   down the page feels like bringing each passage into focus.

   VOCABULARY ADDITION, stated rather than smuggled in. The motion charter lists the
   properties an animation may target, and `background-position` was not among them. It
   is added here deliberately, on the same grounds that already admit `box-shadow` and
   the colour properties: it is paint-bound, not layout-bound. It cannot move a
   neighbouring element, cannot contribute to CLS, and costs one repaint of a text run.
   The forbidden list remains exactly what it was — anything that triggers layout.

   This is the one effect on the site that is genuinely scroll-*linked* rather than
   one-shot, and that is correct here where it is wrong for a reveal: re-reading a
   paragraph on the way back up should un-resolve it, because the effect tracks where
   the passage is rather than whether it has been seen.

   Both guards are load-bearing, for the reasons recorded in section 3 below. Without
   support, or with reduced motion, the text is simply `--text-secondary` throughout,
   which is a correct rendering rather than a fallback. */

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) and (background-clip: text) {
    /* Deliberately NOT .page-head__lede or .hero__lede. A scroll-linked fill needs the
       element to travel through the viewport to resolve, and text that is already on
       screen at load has not travelled — it sits at whatever progress its resting
       position implies and can look permanently half-faded to a visitor who never
       scrolls past it. Above-the-fold copy is at full strength, always. */
    .section__lede,
    [data-fill] {
      background-image: linear-gradient(
        95deg,
        var(--text-primary) 0 35%,
        var(--text-muted) 62% 100%
      );
      background-size: 280% 100%;
      background-repeat: no-repeat;
      -webkit-background-clip: text;
      background-clip: text;
      color: transparent;

      animation: text-fill linear both;
      animation-timeline: view();
      /* Resolves over the middle of its pass through the viewport, so it is finished
         well before it leaves and is never still filling while being read. */
      animation-range: cover 18% cover 58%;
    }
  }
}

@keyframes text-fill {
  from { background-position: 100% 0; }
  to   { background-position: 0% 0; }
}

/* ════════════════════════════════════════════════════════════════════════════
   2d · PAGE TRANSITIONS
   ════════════════════════════════════════════════════════════════════════════

   Cross-document view transitions. Internal navigation cross-fades instead of flashing
   to the ground colour and reflowing — which was the single thing that most made the
   site read as a series of page loads rather than as one product.

   Entirely declarative: no router, no interception, no JavaScript at all. Browsers
   without support navigate exactly as they did before, which is a correct behaviour
   rather than a degraded one.

   The header is named so it is captured as its own element and stays put across the
   navigation instead of cross-fading with the page under it. Without that the fixed
   header visibly ghosts against itself, which is worse than no transition. The name
   must be unique per document — there is exactly one header, so it is. */

@view-transition {
  navigation: auto;
}

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: var(--duration-normal);
  animation-timing-function: var(--ease-standard);
}

.site-header {
  view-transition-name: site-header;
}

/* Reduced motion gets the navigation without the cross-fade. `animation: none` on the
   pseudo-elements is the only reliable way to neutralise it — the transition is driven
   by the UA stylesheet, so the global duration collapse in tokens.css does not reach
   it, for the same reason it does not reach a scroll timeline. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

/* ════════════════════════════════════════════════════════════════════════════
   3 · DEPTH
   ════════════════════════════════════════════════════════════════════════════

   The double guard is not redundant, and each half was verified rather than assumed:

   @media (prefers-reduced-motion: no-preference)
     `animation-duration: 0.01ms !important` does NOT stop a progress-based timeline.
     A ViewTimeline's computed duration stays `100%` under that override and a parallax
     probe sweeps its full travel identically in both modes, while a time-based control
     on the same page pins correctly. Without this wrapper a visitor who asked for no
     motion gets parallax anyway.

   @supports (animation-timeline: view())
     Not Baseline — Chrome/Edge 115, Safari 26, Firefox unshipped. Where it is missing
     the layer sits still. No JavaScript fallback is written: a scroll listener would
     reintroduce main-thread cost to recover an effect that is decorative by
     construction. Firefox's missing support costs only parallax. */

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    [data-depth='atmosphere'] {
      animation: depth-drift linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
      --depth-travel: var(--parallax-atmosphere);
    }

    [data-depth='foreground'] {
      animation: depth-drift linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
      --depth-travel: var(--parallax-foreground);
    }
  }
}

/* `content` is declarable but deliberately inert — text is never parallaxed, and that
   is the one rule in the system with no exceptions. Text in motion is harder to read,
   and readability outranks depth every time. The plane exists so a composition can
   state which layer something belongs to. */
[data-depth='content'] { animation: none; }

@keyframes depth-drift {
  from { transform: translate3d(0, calc(var(--depth-travel) * -1), 0); }
  to   { transform: translate3d(0, var(--depth-travel), 0); }
}

/* ════════════════════════════════════════════════════════════════════════════
   4 · REDUCED MOTION
   ════════════════════════════════════════════════════════════════════════════

   tokens.css collapses every duration to 0ms, which covers all time-based motion.
   This block covers what that cannot reach, and states the resulting behaviour
   positively so it can be tested rather than assumed.

   Reveals are decorative, so they are removed entirely rather than shortened — content
   must be visible immediately, never left at opacity 0 by an animation that no longer
   runs. */

@media (prefers-reduced-motion: reduce) {
  html[data-js] [data-reveal],
  html[data-js] [data-reveal][data-reveal-state='armed'],
  html[data-js] [data-reveal][data-reveal-state='running'],
  html[data-js] [data-reveal='phase'][data-reveal-state='armed'] > *,
  html[data-js] [data-reveal='phase'][data-reveal-state='running'] > * {
    opacity: 1;
    animation: none;
    transform: none;
    clip-path: none;
  }

  /* Depth is not merely stopped — `animation: none` is the only reliable way to
     neutralise a progress-based timeline, since duration overrides are discarded. */
  [data-depth] { animation: none; transform: none; }

  /* Hover keeps its state change and loses its movement: colour and elevation still
     communicate interactivity, tilt and rim light do not. */
  [data-hover]:hover,
  [data-hover]:focus-within,
  [data-hover]:active { transform: none; }
  [data-hover]::after { display: none; }
}
