/* ─── Motion foundation ───────────
 * Progressive enhancement: every element is visible by default. Animations
 * either fire on page load (for above-the-fold hero + first sections) or
 * are triggered by IntersectionObserver adding .is-visible (for deep-scroll
 * reveals that we WANT to time to viewport entry). The CSS never leaves
 * content hidden if JS/IO never runs. */

@keyframes fade-up {
  from { opacity: 0; transform: translateY(1rem); }
  to   { opacity: 1; transform: translateY(0); }
}

@keyframes image-reveal {
  from { clip-path: inset(0 0 100% 0); }
  to   { clip-path: inset(0 0 0% 0); }
}

@keyframes slide-in-left {
  from { opacity: 0; transform: translateX(-2rem); }
  to   { opacity: 1; transform: translateX(0); }
}

@keyframes word-rise {
  from { transform: translateY(105%); }
  to   { transform: translateY(0); }
}

@keyframes marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

@keyframes spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

/* Above-fold hero: eyebrow + lead fade up, headline handled by
   .word-stagger (Modern) or shown plainly (Classic). */
@media (prefers-reduced-motion: no-preference) {
  .hero-entrance {
    animation: fade-up var(--transition-slow) ease-out both;
  }
  .hero-entrance-delay {
    animation: fade-up var(--transition-slow) ease-out both;
    animation-delay: 200ms;
  }
  [data-theme="modern"] .hero-entrance-delay {
    animation: image-reveal var(--transition-slow) cubic-bezier(0.16, 1, 0.3, 1) both;
    animation-delay: 250ms;
  }
}

/* Word-level rise for LAIN-style headlines — plays on load, Modern only.
   Classic reverts (see editorial.css). Split into .word > span by
   reveal.js; falls back to plain text if JS never runs. .scrub-words
   shares the same split markup but is driven by scroll (below). */
.word-stagger .word,
.scrub-words .word {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
}
[data-theme="modern"] .word-stagger .word > span,
[data-theme="modern"] .scrub-words .word > span {
  display: inline-block;
}
@media (prefers-reduced-motion: no-preference) {
  html[data-theme="modern"].js-word-stagger .word-stagger .word > span {
    animation: word-rise 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
    animation-delay: calc(var(--i, 0) * 60ms + 80ms);
  }
}

/* Deep-scroll reveals — hidden only once IO has confirmed it will observe
   them, and only if reduce-motion is off. Failsafe: if IO doesn't fire
   within 2s of page load, all pending reveals are shown by
   .js-reveal-timeout applied by reveal.js. */
@media (prefers-reduced-motion: no-preference) {
  html.js-reveal-armed .reveal:not(.is-visible) {
    opacity: 0;
    transform: translateY(1rem);
  }
  html.js-reveal-armed .reveal.is-visible {
    animation: fade-up var(--transition-slow) ease-out forwards;
  }

  html.js-reveal-armed .reveal-image:not(.is-visible) {
    clip-path: inset(0 0 100% 0);
  }
  html.js-reveal-armed .reveal-image.is-visible {
    animation: image-reveal var(--transition-slow) cubic-bezier(0.16, 1, 0.3, 1) both;
  }

  html.js-reveal-armed .reveal-stagger:not(.is-visible) > * {
    opacity: 0;
    transform: translateY(0.75rem);
  }
  html.js-reveal-armed .reveal-stagger.is-visible > * {
    animation: fade-up var(--transition-base) ease-out both;
    animation-delay: calc(var(--stagger-i, 0) * 80ms);
  }

  html.js-reveal-armed .reveal-slide-l:not(.is-visible) {
    opacity: 0;
    transform: translateX(-2rem);
  }
  html.js-reveal-armed .reveal-slide-l.is-visible {
    animation: slide-in-left var(--transition-slow) cubic-bezier(0.16, 1, 0.3, 1) both;
  }
}

/* Editorial image parallax — Modern only, full-bleed banner images.
   Driven entirely by the native scroll-driven-animations API (no scroll JS,
   no rAF loop): the browser ties the keyframe's progress to the image's
   position in the viewport off the main thread. Unsupported browsers just
   never run the animation — the image sits at its normal resting frame, so
   this is a strict progressive enhancement layered on top of .reveal-image. */
@keyframes image-parallax {
  from { transform: scale(1.22) translateY(3%); }
  to   { transform: scale(1) translateY(0); }
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    /* The img is position:absolute inside the frame, and Chrome does not
       advance a view() timeline whose subject is the abspos element
       itself — so the normal-flow wrapper owns a named view-timeline and
       the img animates against that. */
    [data-theme="modern"] .image-parallax {
      view-timeline: --parallax-frame;
    }
    /* cover-based range: the crop keeps settling the whole time the image
       is on screen, so the hero (already in view at load) still reacts to
       the very first scroll tick instead of arriving pre-settled. */
    [data-theme="modern"] .image-parallax > img {
      animation: image-parallax linear both;
      animation-timeline: --parallax-frame;
      animation-range: cover 0% cover 85%;
    }
  }
}

/* ─── Scroll choreography (Modern homepage) ─────────────────────────
 * The LAIN reference drives its signature moves with scroll-scrubbed,
 * reversible timelines (GSAP ScrollTrigger `scrub`): images open from a
 * directional mask while the crop settles, captions slide in from their
 * grid side, and display text assembles progressively as it travels the
 * viewport. Here the same language is expressed with native CSS
 * view() timelines — no scroll JS, runs off the main thread.
 *
 * Everything below is a strict progressive enhancement:
 *  - gated on @supports so unsupported browsers keep a static,
 *    fully visible baseline (no time-based fallback animation);
 *  - gated on prefers-reduced-motion so reduced-motion users never get
 *    a scroll-linked timeline at all;
 *  - scoped to html[data-theme="modern"] so Classic keeps its own,
 *    deliberately still identity.
 * Keyframes only declare `from` states — the resting frame is the
 * element's normal layout, so nothing depends on animation to exist. */

@keyframes scrub-rise-in {
  from { opacity: 0; transform: translateY(var(--scrub-y, 2.5rem)); }
}
@keyframes scrub-slide-in-l {
  from { opacity: 0; transform: translateX(calc(-1 * var(--scrub-x, 2.75rem))); }
}
@keyframes scrub-slide-in-r {
  from { opacity: 0; transform: translateX(var(--scrub-x, 2.75rem)); }
}
@keyframes scrub-word-up {
  from { opacity: 0; transform: translateY(110%); }
}
@keyframes scrub-frame-wipe {
  from { clip-path: inset(0 100% 0 0); }
  to   { clip-path: inset(0 0 0 0); }
}
@keyframes scrub-rule-draw {
  from { transform: scaleX(0); }
}
@keyframes scrub-depart {
  to { opacity: 0.3; transform: translateY(-3rem); }
}
@keyframes scrub-zoom-settle {
  from { transform: scale(1.28); }
}
/* LAIN's verified grid-item signature is a scrubbed pair: the mask wrapper
   scales up from the item's grid-edge corner while the image pane inside
   counter-scales down from the same origin, so the image is revealed
   through its opening frame. Magnitudes are moderated from LAIN's 0/5 —
   our images sit inside cards with text, not a bare grid. */
@keyframes scrub-mask-settle {
  from { transform: scale(var(--mask-from, 0.62)); }
}
@keyframes scrub-pane-settle {
  from { transform: scale(var(--pane-from, 1.6)); }
}
@keyframes scrub-pane-in-l {
  from { transform: translateX(calc(-1 * var(--scrub-counter, 1.75rem))) scale(var(--pane-from, 1.2)); }
}
@keyframes scrub-pane-in-r {
  from { transform: translateX(var(--scrub-counter, 1.75rem)) scale(var(--pane-from, 1.2)); }
}
@keyframes scrub-numeral-in {
  from { opacity: 0.15; transform: scale(0.8); }
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    /* Shared travel distances — reduced on small screens so nothing
       escapes a narrow viewport or demands excess scroll. Distances are
       deliberately large: motion that only travels a couple of rem is
       imperceptible at normal scroll speed. */
    html[data-theme="modern"] {
      --scrub-y: 2rem;
      --scrub-x: 2rem;
      --scrub-counter: 0.75rem;
    }
    @media (min-width: 768px) {
      html[data-theme="modern"] {
        --scrub-y: 4.5rem;
        --scrub-x: 5.5rem;
        --scrub-counter: 3rem;
      }
    }

    /* Hero hand-off — as the hero leaves the scrollport it recedes:
       sinks upward faster than the page and dims. The very first wheel
       tick already changes the composition, and scrolling back restores
       it. (The hero image's own counter-zoom parallax runs inside this,
       so outer and inner planes move at different rates.) */
    html[data-theme="modern"] .scrub-hero-exit {
      animation: scrub-depart linear both;
      animation-timeline: view();
      animation-range: exit 0% exit 85%;
    }

    /* Section arrival — headings and the mission quote rise while they
       travel the lower half of the viewport, settling around reading
       height rather than at the fold. */
    html[data-theme="modern"] .scrub-rise {
      animation: scrub-rise-in ease-out both;
      animation-timeline: view();
      animation-range: entry 0% cover 55%;
    }

    /* Section numerals participate without ever leaving their section:
       no translation — they inflate in place from their own top-left
       corner, so the label/number pair stays exactly where layout put it. */
    html[data-theme="modern"] .scrub-numeral {
      transform-origin: 0 0;
      animation: scrub-numeral-in ease-out both;
      animation-timeline: view();
      animation-range: entry 0% cover 40%;
    }
    /* The closing contact block uses a pure entry range: it sits at the
       bottom of the document, so its range must be completable at
       max scroll. Small travel — it is the page's quiet resolution. */
    html[data-theme="modern"] .scrub-rise-soft {
      --scrub-y: 2rem;
      animation: scrub-rise-in ease-out both;
      animation-timeline: view();
      animation-range: entry 0% entry 100%;
    }

    /* Typography — the "who we are" statement assembles word by word.
       Each word rises out of its clipped wrapper on its own view
       timeline with a per-word range shift, so assembly is still
       visibly completing as the statement crosses reading height.
       Split markup comes from reveal.js; without JS there are no .word
       spans and the text is simply static. */
    /* The overflow:hidden .word wrappers are scroll containers, so a
       span's own view() timeline would resolve against its wrapper and
       never advance. The statement block owns a named view-timeline
       (it sits in the viewport scroller) and every span scrubs against
       that, with a per-word range offset. */
    html[data-theme="modern"] .scrub-words {
      view-timeline: --words-assembly;
    }
    html[data-theme="modern"] .scrub-words .word > span {
      animation: scrub-word-up ease-out both;
      animation-timeline: --words-assembly;
      /* cover-based: each word's own rise spans 30% of the statement's
         cover range (~a third of a viewport of scroll). */
      animation-range: cover calc(0% + var(--i, 0) * 0.8%)
                       cover calc(30% + var(--i, 0) * 0.8%);
    }

    /* Image movement — the video plate opens with a directional wipe
       while the plate content counter-zooms inside the mask at a slower
       rate (LAIN's mask + counter-scale pair). Both live on wrappers;
       the video element itself is never animated, and both settle to
       identity before the plate reaches reading height. */
    /* overflow: clip (not hidden — hidden would make the frame a scroll
       container and freeze the child's view() timeline) crops the
       counter-zooming plate content, so the oversized from-state can
       never contribute scrollable overflow to the document. */
    html[data-theme="modern"] .scrub-frame {
      overflow: clip;
      animation: scrub-frame-wipe linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 55%;
    }
    html[data-theme="modern"] .scrub-frame > * {
      animation: scrub-zoom-settle linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 70%;
    }

    /* Section transition — the mission rule draws itself with scroll
       progress, closing the first stage. */
    html[data-theme="modern"] .scrub-rule {
      transform-origin: left;
      animation: scrub-rule-draw linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 60%;
    }

    /* Programme hand-off strip — slides in from the reading edge across
       the plate's bottom boundary. */
    html[data-theme="modern"] .scrub-slide-l {
      animation: scrub-slide-in-l ease-out both;
      animation-timeline: view();
      animation-range: entry 0% cover 50%;
    }

    /* Event grid — side columns slide inward from their grid side while
       the middle column rises and settles later; inside each card the
       image pane moves at its own slower rate INSIDE the static crop
       frame (the frame itself never leaves the grid), so the crop
       visibly changes while the card travels. Below lg the grid loses
       its three-column shape and falls back to the rise + a gentle
       pane settle. */
    /* The overflow:hidden crop frames are scroll containers, so a pane's
       own view() timeline would resolve against its frame and never
       advance. Each card owns a named view-timeline (the card sits in
       the viewport scroller) and its pane scrubs against that — one
       shared timeline per grid item, like LAIN's per-item ScrollTrigger. */
    html[data-theme="modern"] .scrub-cards > * {
      view-timeline: --card-travel;
      animation: scrub-rise-in ease-out both;
      animation-timeline: --card-travel;
      animation-range: entry 0% cover 55%;
    }
    html[data-theme="modern"] .scrub-cards .event-card__pane {
      --pane-from: 1.15;
      animation: scrub-pane-settle linear both;
      animation-timeline: --card-travel;
      animation-range: entry 0% cover 65%;
    }
    @media (min-width: 1024px) {
      html[data-theme="modern"] .scrub-cards > :nth-child(3n + 1) {
        animation-name: scrub-slide-in-l;
      }
      html[data-theme="modern"] .scrub-cards > :nth-child(3n + 2) {
        animation-range: entry 0% cover 65%;
      }
      /* Side panes counter-move against their card's travel direction —
         the oversized pane keeps the crop frame covered while translated. */
      html[data-theme="modern"] .scrub-cards > :nth-child(3n + 1) .event-card__pane {
        --pane-from: 1.25;
        animation-name: scrub-pane-in-r;
        animation-range: entry 0% cover 75%;
      }
      html[data-theme="modern"] .scrub-cards > :nth-child(3n) .event-card__pane {
        --pane-from: 1.25;
        animation-name: scrub-pane-in-l;
        animation-range: entry 0% cover 75%;
      }
    }
    /* The right column deliberately has no outward slide: a rightward
       from-state would extend the document's scrollable overflow at any
       viewport where the container sits flush with the edge. It rises on
       an earlier range instead, and its pane's leftward counter-move
       (clipped by the crop frame) carries the lateral language. */

    /* Board grid — one grouped composition: columns travel different
       distances and each portrait opens from a bottom mask while its
       card is still rising (frame and image at different rates).
       Entry-based ranges so the last row completes even close to the
       end of the document. */
    html[data-theme="modern"] .scrub-board > * {
      view-timeline: --board-card-travel;
      animation: scrub-rise-in ease-out both;
      animation-timeline: --board-card-travel;
      animation-range: entry 0% entry 100%;
    }
    /* The portrait is the page's clearest LAIN grid-item: the mask
       wrapper scales open from the card's grid-edge corner while the
       pane inside counter-scales down from the same origin — the image
       is revealed through its opening frame while the card is still
       rising. Entry-based ranges so the last row completes even close
       to the end of the document. */
    html[data-theme="modern"] .scrub-board .board-card__portrait {
      transform-origin: 50% 0%;
      animation: scrub-mask-settle ease-out both;
      animation-timeline: --board-card-travel;
      animation-range: entry 0% entry 90%;
    }
    html[data-theme="modern"] .scrub-board .board-card__pane {
      transform-origin: 50% 0%;
      animation: scrub-pane-settle ease-out both;
      animation-timeline: --board-card-travel;
      animation-range: entry 0% entry 100%;
    }
    @media (min-width: 1024px) {
      /* Columns settle at different progress points, so one is still
         travelling while its neighbour has already landed. Side columns
         open toward the grid's inner edge, mirroring LAIN's left/right
         origin logic. */
      html[data-theme="modern"] .scrub-board > :nth-child(3n + 1) {
        animation-range: entry 0% entry 75%;
      }
      html[data-theme="modern"] .scrub-board > :nth-child(3n + 2) {
        --scrub-y: 9rem;
      }
      html[data-theme="modern"] .scrub-board > :nth-child(3n) {
        --scrub-y: 6.5rem;
        animation-range: entry 0% entry 88%;
      }
      html[data-theme="modern"] .scrub-board > :nth-child(3n + 1) .board-card__portrait,
      html[data-theme="modern"] .scrub-board > :nth-child(3n + 1) .board-card__pane {
        transform-origin: 100% 0%;
      }
      html[data-theme="modern"] .scrub-board > :nth-child(3n) .board-card__portrait,
      html[data-theme="modern"] .scrub-board > :nth-child(3n) .board-card__pane {
        transform-origin: 0% 0%;
      }
    }

    /* Studies index — lateral choreography: rows enter from the reading
       edge in an offset cascade (later rows start and settle later),
       while each row's metadata trails in from the far edge on its own
       slower range. */
    html[data-theme="modern"] .scrub-index-row {
      animation: scrub-slide-in-l ease-out both;
      animation-timeline: view();
      animation-range: entry calc(var(--stagger-i, 0) * 6%)
                       cover calc(55% + var(--stagger-i, 0) * 4%);
    }
    html[data-theme="modern"] .scrub-index-row .index-list__meta {
      animation: scrub-slide-in-r ease-out both;
      animation-timeline: view();
      animation-range: entry calc(var(--stagger-i, 0) * 6%)
                       cover calc(72% + var(--stagger-i, 0) * 4%);
    }
    @media (min-width: 768px) {
      /* Rows may travel further from the left — leftward overhang adds no
         scrollable overflow in LTR. The meta keeps the shared rightward
         distance so the from-state stays inside the envelope the layout
         already contains. */
      html[data-theme="modern"] .scrub-index-row {
        --scrub-x: 7rem;
      }
      html[data-theme="modern"] .scrub-index-row .index-list__meta {
        --scrub-x: 5.5rem;
      }
    }
  }
}

/* ─── Tier-2 fallback: no scroll-timeline support ────────────────────
 * Browsers without scroll-driven animations (e.g. current Firefox)
 * must not get a lifeless page: the scrub hooks rejoin the existing
 * one-shot IntersectionObserver reveal system instead. reveal.js only
 * observes these elements when CSS.supports rejects view() timelines —
 * the same predicate as this @supports block — so the two systems can
 * never drive the same element. Hidden states are still gated on
 * .js-reveal-armed (JS present + IO confirmed) and reduced motion. */
@supports not (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    html[data-theme="modern"].js-reveal-armed .scrub-rise:not(.is-visible),
    html[data-theme="modern"].js-reveal-armed .scrub-rise-soft:not(.is-visible),
    html[data-theme="modern"].js-reveal-armed .scrub-words:not(.is-visible) {
      opacity: 0;
      transform: translateY(1rem);
    }
    html[data-theme="modern"].js-reveal-armed .scrub-rise.is-visible,
    html[data-theme="modern"].js-reveal-armed .scrub-rise-soft.is-visible,
    html[data-theme="modern"].js-reveal-armed .scrub-words.is-visible {
      animation: fade-up var(--transition-slow) ease-out both;
    }

    html[data-theme="modern"].js-reveal-armed .scrub-slide-l:not(.is-visible),
    html[data-theme="modern"].js-reveal-armed .scrub-index-row:not(.is-visible) {
      opacity: 0;
      transform: translateX(-2rem);
    }
    html[data-theme="modern"].js-reveal-armed .scrub-slide-l.is-visible,
    html[data-theme="modern"].js-reveal-armed .scrub-index-row.is-visible {
      animation: slide-in-left var(--transition-slow) cubic-bezier(0.16, 1, 0.3, 1) both;
    }

    html[data-theme="modern"].js-reveal-armed .scrub-frame:not(.is-visible) {
      clip-path: inset(0 0 100% 0);
    }
    html[data-theme="modern"].js-reveal-armed .scrub-frame.is-visible {
      animation: image-reveal var(--transition-slow) cubic-bezier(0.16, 1, 0.3, 1) both;
    }

    html[data-theme="modern"].js-reveal-armed .scrub-cards:not(.is-visible) > *,
    html[data-theme="modern"].js-reveal-armed .scrub-board:not(.is-visible) > * {
      opacity: 0;
      transform: translateY(0.75rem);
    }
    html[data-theme="modern"].js-reveal-armed .scrub-cards.is-visible > *,
    html[data-theme="modern"].js-reveal-armed .scrub-board.is-visible > * {
      animation: fade-up var(--transition-base) ease-out both;
      animation-delay: calc(var(--stagger-i, 0) * 80ms);
    }
  }
}

/* Universal safety net for reduced-motion. */
@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;
  }
}

:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

/* ─── Theme toggle icon swap ───────────*/
.theme-toggle-icon {
  position: absolute;
  opacity: 0;
  transform: rotate(-90deg) scale(0.5);
  transition: opacity var(--transition-base) ease, transform var(--transition-base) ease;
}
[data-theme="modern"] .theme-toggle-icon--modern,
[data-theme="classic"] .theme-toggle-icon--classic {
  opacity: 1;
  transform: rotate(0deg) scale(1);
}

/* ─── Mobile menu icon swap ───────────*/
.menu-icon {
  position: absolute;
  opacity: 1;
  transform: rotate(0deg) scale(1);
  transition: opacity var(--transition-fast) ease, transform var(--transition-fast) ease;
}
.menu-icon--close {
  opacity: 0;
  transform: rotate(-90deg) scale(0.5);
}
details[open] > summary .menu-icon--open {
  opacity: 0;
  transform: rotate(90deg) scale(0.5);
}
details[open] > summary .menu-icon--close {
  opacity: 1;
  transform: rotate(0deg) scale(1);
}

@media (prefers-reduced-motion: no-preference) {
  details[open] > nav {
    animation: fade-up var(--transition-fast) ease-out both;
  }
}
