/* ==========================================================================
   MOTION — scroll reveals, cascades, micro-interactions
   ==========================================================================
   Premium motion rules we follow:
   - Slow and soft beats fast and snappy. Reveals run 700–900ms.
   - One easing does the heavy lifting: expo-out. Things arrive decelerating,
     never bounce (except deliberate spring accents).
   - Movement is short. 24px of travel reads as "settling", 100px as "flying".
   - Cascades are the signature: siblings arrive one after another, never
     all at once.
   - Nothing animates twice. Revealed stays revealed.

   ROBUSTNESS (important):
   Hidden-until-revealed styles apply ONLY under `.has-reveal`, a class set
   by an inline snippet in <head>. Three layers protect the content:
     1. reduced-motion preference → the class is never set
     2. no JS at all → the class is never set
     3. class set but reveal.js fails to load → the inline snippet's
        failsafe timer removes the class again (see the snippet)
   Content is never hostage to a script.
   ========================================================================== */

/* --------------------------------------------------------------------
   Reveal — base
   -------------------------------------------------------------------- */
.has-reveal [data-reveal] {
  /* `--reveal-lead-in` is a whole subtree asking the cascade to hold before
     it starts — a block with an entrance of its own that must finish before
     there is anything worth reading (see `--hero-entrance` in blocks.css).
     It is added, never substituted, so the stagger inside the subtree is
     untouched: the cascade is the same cascade, moved later as one piece.
     Default 0s, which is every block that has nothing to wait for.

     The line cascade further down repeats this sum. It has to — a line's
     delay is written into a `transition` shorthand rather than read from
     this property — so the two are a pair: change one, change both, or the
     headline starts without the lead and nobody can say why. */
  --reveal-delay: calc(
    var(--reveal-lead-in, 0s) + var(--reveal-index, 0) * var(--reveal-stagger));

  opacity: 0;
  transition:
    opacity var(--reveal-duration) var(--ease-out-expo) var(--reveal-delay),
    translate var(--reveal-duration) var(--ease-out-expo) var(--reveal-delay),
    scale var(--reveal-duration) var(--ease-out-expo) var(--reveal-delay),
    filter var(--reveal-duration) var(--ease-out-expo) var(--reveal-delay);
  will-change: opacity, translate;
}

.has-reveal [data-reveal].is-revealed {
  opacity: 1;
  translate: 0 0;
  scale: 1;
  filter: none;
  will-change: auto;
}

/* --------------------------------------------------------------------
   Reveal — variants
   `up` is the default and covers ~80 % of cases.
   -------------------------------------------------------------------- */
.has-reveal [data-reveal="up"],
.has-reveal [data-reveal=""] {
  translate: 0 var(--reveal-distance);
}

.has-reveal [data-reveal="down"] {
  translate: 0 calc(var(--reveal-distance) * -1);
}

.has-reveal [data-reveal="left"] {
  translate: var(--reveal-distance) 0;
}

.has-reveal [data-reveal="right"] {
  translate: calc(var(--reveal-distance) * -1) 0;
}

/* Pure fade — for large images and hero media where movement is too loud */
.has-reveal [data-reveal="fade"] {
  translate: none;
}

/* Scale — for cards and media that should feel like they "settle in" */
.has-reveal [data-reveal="scale"] {
  translate: 0 var(--reveal-distance);
  scale: 0.96;
}

/* Blur — the most premium, most expensive. Hero headings only. */
.has-reveal [data-reveal="blur"] {
  translate: 0 var(--reveal-distance);
  filter: blur(8px);
}

/* --------------------------------------------------------------------
   Cascade helpers
   `data-reveal-group="up"` on a parent → every direct child reveals with
   an incrementing stagger. This is what makes card grids feel designed.
   The stagger index is assigned by scripts/reveal.js.
   -------------------------------------------------------------------- */
.has-reveal [data-reveal-group] > * {
  --reveal-stagger: var(--reveal-stagger-group);
}

/* Hide group children immediately, before reveal.js has stamped them with
   `data-reveal`. Without this they paint visible for one frame and then
   snap to hidden — a flash that reads as a bug.
   `:not(.is-revealed)` matters: reveal.js drops `data-reveal` once the
   animation settles, and without the guard this rule would grab the
   element back and hide it again. */
.has-reveal [data-reveal-group] > *:not([data-reveal]):not(.is-revealed) {
  opacity: 0;
  translate: 0 var(--reveal-distance);
}

/* Line-by-line heading cascade — wrap lines in <span class="reveal-line"> */
.reveal-lines {
  display: block;
}

.has-reveal .reveal-line {
  display: block;
  overflow: hidden;
}

.has-reveal .reveal-line > span {
  display: block;
  translate: 0 100%;
  /* The second copy of the lead-in sum — see the note on `--reveal-delay`
     above, which is the one that explains it. */
  transition: translate var(--reveal-duration-slow) var(--ease-out-expo)
    calc(var(--reveal-lead-in, 0s) + var(--reveal-index, 0) * var(--reveal-stagger-line));
}

.has-reveal .reveal-line.is-revealed > span {
  translate: 0 0;
}

/* --------------------------------------------------------------------
   Interactive baseline
   Every clickable thing eases its colour changes. Without this, only the
   elements someone remembered to style feel smooth and the rest snap —
   which reads as sloppiness even when nobody can point at why.

   KEEP THIS LIST AT THE LOWEST SPECIFICITY THAT DOES THE JOB. `a` already
   reaches every link on the page, so listing `.site-footer a` or
   `.pager a` adds nothing — except that at (0,1,1) they outrank
   component rules like `.footer-pill` (0,1,0) and replace the component's
   own transition wholesale. That is how the footer pills lost their lift
   and shadow animation. A baseline that outranks components is not a
   baseline, it is an override.
   -------------------------------------------------------------------- */
a,
summary,
.tag,
.field__input,
.link-arrow {
  transition:
    color var(--motion-duration) var(--ease-out-expo),
    background-color var(--motion-duration) var(--ease-out-expo),
    border-color var(--motion-duration) var(--ease-out-expo),
    opacity var(--motion-duration) var(--ease-out-expo);
}

/* Tag hover — it is a link, so it must answer the pointer. Slots, defaulting
   to the step-up in the ramp this always did; a theme whose tag is an
   outline rather than a fill answers with its edge instead. */
.tag[href]:hover {
  background: var(--tag-hover-surface, var(--color-primary-200));
  color: var(--tag-hover-ink, var(--color-primary-900));
  box-shadow: inset 0 0 0 var(--border-width)
    var(--tag-hover-edge, var(--tag-edge, transparent));
}

/* --------------------------------------------------------------------
   Micro-interactions
   -------------------------------------------------------------------- */

/* Card hover — lift + shadow bloom. Kept subtle; the shadow does the work. */
.card {
  transition:
    box-shadow var(--motion-duration-slow) var(--ease-out-expo),
    translate var(--motion-duration-slow) var(--ease-out-expo),
    border-color var(--motion-duration) var(--ease-out-expo);
}

/* The card keeps its outline on hover — fading the border to transparent
   made the edge dissolve into the page, which read as a glitch rather
   than a lift. Tinting it toward the brand keeps the shape intact and
   makes the card feel picked up instead of half-erased. */
.card:has(a):hover {
  translate: 0 -4px;
  box-shadow: var(--shadow-lg);
  /* Slotted for the chromeless-card themes: `primary-200` is a near-white
     in every ramp, so on a card whose resting border is transparent the
     default hover would flash a bright ring out of nowhere. A glow theme
     answers with a faint brand tint instead. */
  border-color: var(--card-border-hover, var(--color-primary-200));
}

/* Media inside a hovered card zooms — and it has to zoom as part of the
   card's own lift rather than beside it. Two things were making it read as
   two separate movements, one of them toward the reader and one upward:

   **They did not finish together.** The card travels on
   `--motion-duration-slow`; the picture had a hard-coded 600ms, so on
   every theme it was still growing after the card had settled. The eye
   reads two gestures whenever they end at two different moments, whatever
   they are doing. Same token now, so the whole card arrives at once.

   **And they did not agree on a direction.** A picture scaled from its
   centre grows equally in all directions, so while the card rose, the
   picture's own bottom edge went *down* — a small contradiction, but a
   contradiction. Anchoring the growth at the bottom edge means everything
   the picture does is upward: the card lifts, the picture rises with it,
   one gesture with one direction.

   `transform-origin` cannot uncover the frame, whatever value it takes: a
   box scaled above 1 from any origin inside itself still covers the
   original box, so the crop stays full-bleed at every point of the
   transition. That is why this is an origin change and not a `translate`,
   which would have to be kept under the overflow to stay safe. */
.card__media img,
.card__media svg {
  transform-origin: center bottom;
  transition: scale var(--motion-duration-slow) var(--ease-out-expo);
}

.card:has(a):hover .card__media img,
.card:has(a):hover .card__media svg {
  scale: 1.04;
}

/* Nav link marker — an underline that grows from the left by default, or
   whatever else a theme makes of it.

   Two mechanisms share one element, and the rest state is what picks
   between them. Unset, the marker is full-height, full-opacity and
   zero-width, so only `width` has anywhere to travel: the line grows from
   the left exactly as it always did. A theme that instead gives it a width
   at rest and hides it with opacity gets the opposite animation for free —
   nothing to grow, everything to fade — and the transition below already
   lists all three properties, so neither theme pays for the other's. */
.site-nav ul a::after {
  content: "";
  display: block;
  height: var(--nav-marker-height, 2px);
  width: var(--nav-marker-rest-width, 0);
  margin-inline: var(--nav-marker-align, 0);
  /* The marker is drawn but does not count. It is a block inside the
     link, so its height was pushing the label up off the bar's centre
     line — 2px of box for something purely decorative, and the label paid
     for it whether the marker was visible or not. A negative end margin
     equal to its own height leaves it exactly where it renders now and
     takes its contribution back out of the link.

     `--nav-marker-offset` then moves it down from there without giving
     the height back: whatever is added below is subtracted from the same
     margin. A caret sitting tight under the word reads as an underline of
     it; a caret with air under the word reads as a separate object
     waiting there, which is the whole idea. */
  margin-block-start: var(--nav-marker-offset, 0);
  margin-block-end: calc(-1 * (var(--nav-marker-height, 2px) + var(--nav-marker-offset, 0px)));
  background: currentColor;
  border-radius: var(--radius-full);
  opacity: var(--nav-marker-rest-opacity, 1);
  translate: var(--nav-marker-rest-translate, none);
  transition:
    width var(--motion-duration-slow) var(--ease-out-expo),
    opacity var(--motion-duration) var(--motion-easing),
    translate var(--motion-duration-slow) var(--ease-out-expo);
}

.site-nav ul a:hover::after,
.site-nav ul a[aria-current]:not([aria-current="false"])::after {
  width: var(--nav-marker-width, 100%);
  opacity: 1;
  translate: none;
}

/* The marker may blink while the pointer is on it — a terminal caret
   waiting for input, which is a whole personality in one element.

   Hover only, and the rule below takes it back off the current item even
   while hovered. That is a decision about meaning before it is one about
   taste: blinking is an *invitation*, and the page you are already on is
   not inviting you anywhere. It is also the accessible reading of it —
   a caret that blinks under the pointer is user-started and stops the
   moment the pointer leaves, while one blinking permanently on the current
   item would be automatic, indefinite motion nobody asked for (WCAG
   2.2.2). Rate is a theme's business; anything at or above 3Hz is not
   (WCAG 2.3.1), and a caret has no reason to go near it.

   Reduced motion needs nothing extra here: the blanket rule at the foot of
   this file cuts every animation to one 0.01ms iteration, after which the
   marker simply sits at the opacity its state gives it. */
.site-nav ul a:hover::after {
  animation: var(--nav-marker-animation, none);
}

.site-nav ul a[aria-current]:not([aria-current="false"])::after {
  animation: none;
}

/* The fullscreen menu can drop the marker altogether. A caret or an
   underline is an affordance for a row of small words competing for the
   eye; at display size, on a screen with nothing else on it, the current
   item is already stated by its colour and the mark is one signal too
   many. Unset, the marker behaves here exactly as it does in the bar. */
.site-nav.is-open ul a::after {
  display: var(--nav-open-marker-display, block);
}

/* Sliding underline — one shared signature line that travels to whichever
   nav item the pointer (or keyboard focus) is over, and rests under the
   current-page item. The per-link `::after` above stays as the no-JS
   fallback: `.has-nav-slider` is added by header.js only once the indicator
   is in the DOM, so a scriptless nav keeps a working hover affordance and
   is never left with none.

   Desktop only — the fullscreen mobile menu is a different treatment, so the
   indicator is removed outright below the nav breakpoint. The 64rem here is
   the one unavoidable hand-typed copy of `--bp-nav` (a media query cannot
   read a custom property); it must move with the token, same as the wash
   query in components.css. */

/* Hidden by default so that below the breakpoint — and in the fullscreen
   menu — it is gone completely, whatever inline width a desktop interaction
   left on it. The desktop query below turns it back on. */
.site-nav__slider {
  display: none;
}

@media (min-width: 64rem) {
  .site-nav.has-nav-slider ul {
    position: relative;
  }

  /* With the shared line present the per-link underline would draw twice.
     Turn it off, but only where the slider actually runs. */
  .site-nav.has-nav-slider ul a::after {
    display: none;
  }

  .site-nav__slider {
    display: block;
    position: absolute;
    left: 0;
    top: 0;
    height: 2px;
    width: 0;
    background: var(--color-primary);
    border-radius: var(--radius-full);
    pointer-events: none;
    /* Anchored at left:0 and moved by translateX so the travel composites
       on the GPU; width animates the stretch between items of unequal size.
       `top` is set inline by the script and carries no transition — it only
       changes on relayout, never during a slide. */
    transform: translateX(0);
    transition:
      transform var(--motion-duration-slow) var(--ease-out-expo),
      width var(--motion-duration-slow) var(--ease-out-expo),
      opacity var(--motion-duration) var(--ease-out-expo);
  }

  /* Nothing hovered and no current item — the line has nowhere to rest, so
     it fades out rather than parking under an arbitrary link. */
  .site-nav__slider.is-idle {
    opacity: 0;
  }

  /* The first placement, at page load, must not animate: on a subpage the
     line marks the current section and belongs there from the first frame,
     not sliding in from the left. header.js drops this class one frame after
     positioning, so every move after that still glides. */
  .site-nav__slider.no-anim {
    transition: none;
  }
}

/* Buttons lift on hover with a shadow — flat at rest, raised when they
   invite a click, the same language the cards and the footer social pills
   speak. The transition that times this lives on `.btn` in components.css
   (one declaration, or a second would replace it); here are only the
   target states. Reduced-motion keeps the shadow and drops the travel to
   a near-instant snap via the blanket rule below. */
.btn:hover:not(:disabled):not(.is-disabled) {
  translate: 0 -3px;
  box-shadow: var(--shadow-lg);
}

/* Pressing settles it part-way back — the raise was the invitation, the
   press is the acknowledgement. Above rest, not below: the button never
   dips under the surface it started on. */
.btn:active:not(:disabled):not(.is-disabled) {
  translate: 0 -1px;
  box-shadow: var(--shadow-sm);
}

/* A glowing theme's primary button keeps glowing while the pointer is on
   it. These sit *after* the generic hover/active rules on purpose: the
   `.btn:hover` shadow above would otherwise replace a resting glow with a
   plain drop shadow at the exact moment of interest. The fallback chain
   keeps every non-glow theme on the behaviour above — hover falls back to
   the lift's own shadow, active to the settle's — so a theme that sets
   nothing cannot tell these rules exist. On press the glow returns to its
   resting strength: the raise was the invitation, brighter; the press is
   the acknowledgement, back to rest. */
/* A theme may also give the pointer something that *keeps* moving.
   `--btn-hover-animation` is the slot; it defaults to `none`, so a theme
   that sets nothing gets a static hover and cannot tell this exists.

   It belongs on the hover rather than on the button because that is the
   only state where continuous motion is honest: the reader is pointing at
   it, the movement answers them, and it stops when they leave. A button
   that animates at rest is decoration competing with the page (PRINCIPLES
   §12), and the blanket reduced-motion rule below turns this off with
   everything else. */
.btn--primary:hover:not(:disabled):not(.is-disabled) {
  box-shadow: var(--shadow-button-hover, var(--shadow-lg));
  animation: var(--btn-hover-animation, none);
}

.btn--primary:active:not(:disabled):not(.is-disabled) {
  box-shadow: var(--shadow-button, var(--shadow-sm));
}

/* The ghost button gets the same pair, and it needs them more than the
   filled one does. A drop shadow under a transparent button is a shadow
   under nothing — on a dark theme it is invisible, and on any theme it
   describes an object that is not there. Light is the honest answer for a
   button whose whole body is the page showing through: the edge lights, a
   bloom leaves it, and nothing about the rest state has to change.

   Same fallback chain as above, so a theme that sets neither slot keeps
   the generic lift's shadow and cannot tell these rules exist. */
/* Its own slot, falling back to the filled button's: the two rings are the
   same gesture at different strengths, so a theme that writes one animation
   gets both, and one that wants the quiet button to move differently says
   so without redeclaring the loud one. */
.btn--ghost:hover:not(:disabled):not(.is-disabled) {
  box-shadow: var(--shadow-button-ghost-hover, var(--shadow-lg));
  animation: var(--btn-ghost-hover-animation, var(--btn-hover-animation, none));
}

.btn--ghost:active:not(:disabled):not(.is-disabled) {
  box-shadow: var(--shadow-button-ghost, var(--shadow-sm));
}

/* --------------------------------------------------------------------
   Form receipts — a confirmation arrives like everything else here

   The newsletter's done state is injected mid-page, and an element that
   pops into existence is the cut §12 forbids: it gets the reveals' own
   short rise and expo ease. `both` so there is no first-frame flash
   before the animation claims it. The reduced-motion block below
   flattens this to nothing, like every other entrance.
   -------------------------------------------------------------------- */
.newsletter-form__done {
  animation: form-done-in var(--reveal-duration) var(--ease-out-expo) both;
}

@keyframes form-done-in {
  from {
    opacity: 0;
    translate: 0 var(--reveal-distance);
  }
}

/* --------------------------------------------------------------------
   Reduced motion — hard stop
   Belt and braces: reveal.js never sets .has-reveal under this
   preference, and here we neutralize every transition that remains.
   -------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  /* The blanket rule above would fast-forward the marquee to its end
     state — parked on the duplicate set, with the real logos scrolled
     out of view. Stop it outright and drop the duplicate instead, so
     the strip becomes an ordinary static row. */
  .marquee__track {
    animation: none !important;
  }

  .marquee__duplicate {
    display: none;
  }

  .marquee {
    -webkit-mask-image: none;
    mask-image: none;
  }
}
