/* ============================================================================
   HYDRO2050 - MOTION
   ----------------------------------------------------------------------------
   The CSS half of the motion layer. The JavaScript half is js/motion.js.

   THE FAILSAFE, WHICH IS THE IMPORTANT PART OF THIS FILE
   Nothing below hides anything unless <html> carries .js-reveal, and only
   motion.js adds that class. So if the script fails to load, is blocked, or
   throws before it runs, no element is ever hidden and the page reads exactly
   as it would with no motion at all. Content is never left invisible by a
   script that did not run. This is carried over from the superseded mockup's
   reveal.js, where the pattern was established.
   ========================================================================== */

/* ---- SCROLL REVEAL ------------------------------------------------------- */

/* KEYFRAMES, NOT TRANSITIONS, AND `translate`, NOT `transform`.
   Both of those are corrections, and both are about not standing on things
   that belong to the element being revealed.

   A transition is written on the `transition` property, which is a single
   shorthand an element only has one of. `.reveal` and `[data-stagger] > *`
   match ordinary page components, tiles, cards, buttons, and declaring a
   transition here REPLACED whatever transition that component had declared for
   its own hover and focus. Every `.tile` in a staggered grid was reading back
   `transition-property: opacity, transform` from this file rather than its own.
   An animation is a separate channel: it plays, it ends, it releases, and the
   element's own transitions are untouched throughout.

   The same argument applies to `transform`. Decorative art on this site is
   flipped with `transform: scaleX(-1)` and several things are centred with
   `translateX(-50%)`, and a reveal written as a transform silently overwrites
   them. The standalone `translate` property composes with `transform` instead
   of replacing it, which is the lesson the hero entrance below already learned
   the hard way. Both are composited identically.

   And no `will-change`. It was declared on every `.reveal` from parse, which
   on the Home page is twenty compositor layers held open for elements that are
   mostly not going to animate for another ten seconds, if ever. A running
   opacity or translate animation is promoted by the compositor on its own; the
   hint bought nothing and cost a layer each. */

.js-reveal .reveal {
  opacity: 0;
  translate: 0 var(--reveal-rise);
}
/* One-way. Once a block has arrived it never hides again, so scrolling back up
   does not replay the page. The animation has no fill forwards, so when it
   ends the element is simply left at the values declared here and nothing
   lingers. */
.js-reveal .reveal.is-in {
  opacity: 1;
  translate: none;
  animation: h2050-reveal var(--dur-reveal) var(--ease-out) backwards;
}

/* The stagger. Set as a custom property per child so one rule can serve a
   whole section, and capped at eight steps in motion.js: past that the last
   child is waiting more than half a second after the first, which reads as a
   fault rather than as a sequence.

   `backwards` is what makes the delay work. Without it a child would sit at
   its finished, visible state for the length of its own delay and then jump
   back to the start to animate, which is a flash of the thing you are about to
   reveal. */
.js-reveal .reveal[data-stagger] { opacity: 1; translate: none; }
.js-reveal .reveal[data-stagger] > * {
  opacity: 0;
  translate: 0 var(--reveal-rise);
}
.js-reveal .reveal[data-stagger].is-in > * {
  opacity: 1;
  translate: none;
  animation: h2050-reveal var(--dur-reveal) var(--ease-out)
             calc(var(--reveal-step) * var(--i, 0)) backwards;
}

@keyframes h2050-reveal {
  from { opacity: 0; translate: 0 var(--reveal-rise); }
}

/* The hero entrance. Same rule as the reveals: the from-state only exists
   while <html> carries .js-reveal, so a page whose script never runs shows a
   finished hero rather than an empty one.

   The stagger is a custom property here too, rather than the inline
   `style.transitionDelay` motion.js used to write onto each item. That inline
   delay never expired: it applied to every transition the element would ever
   run, so the last hero item kept a 360ms delay on its hover state for the
   life of the page. */
/* Local, not a token. --reveal-step in tokens.css is the site-wide stagger for
   scroll reveals; the hero runs a little slower than that because it is the
   first thing seen and has five items rather than a grid's dozen. It is
   declared on the stage so it is overridable per hero without touching the
   global scale. */
.js-reveal [data-entrance] { --entrance-step: 90ms; }

.js-reveal [data-entrance] [data-entrance-item] {
  opacity: 0;
  translate: 0 18px;
}
.js-reveal [data-entrance].is-entered [data-entrance-item] {
  opacity: 1;
  translate: none;
  animation: h2050-entrance 700ms var(--ease-out)
             calc(var(--entrance-step) * var(--i, 0)) backwards;
}
@keyframes h2050-entrance {
  from { opacity: 0; translate: 0 18px; }
}

/* ---- ANCHOR OFFSET ------------------------------------------------------- */

/* The CSS half of the anchor offset that goTo() in motion.js implements in
   JavaScript. Both halves are needed and only one of them was here: motion.js
   has been publishing --header-h since it was written and nothing in any
   stylesheet consumed it, so a plain browser jump, a page opened with a #hash
   already in the URL, and every scrollIntoView landed its target underneath
   the sticky bar. `tools/audit.mjs` catches this and was failing on it.

   The 24px matches headerOffset() exactly, so a click and a cold URL land in
   the same place. The literal fallback is the bar's real height at the desktop
   breakpoint, for the case where the script never ran to publish the measured
   one. Wrapped in :where() so it carries no specificity and any page-level
   rule outranks it without needing !important. */
:where([id]) {
  scroll-margin-top: calc(var(--header-h, 84px) + 24px);
}

/* ---- HERO VIDEO ---------------------------------------------------------- */

/* The poster is painted by the browser before the first frame decodes, so the
   hero never flashes an empty box. Under reduced motion the video element is
   removed from the flow entirely and the still underneath is what shows. */
.hero__media img, .hero__media picture { position: absolute; inset: 0; }
.hero__media video { position: relative; }

/* ---- BRAND MARK ---------------------------------------------------------- */

/* The mark cross-fades from the still to the video for the length of one play,
   then fades back. Because the still is the video's own final frame, the
   swap in either direction is invisible.

   It is a cross-fade, so the still has to leave. It never did: only the video
   carried an is-playing rule, and the still sat under it at opacity 1 the
   whole time. Both files carry a real alpha plane, which is the point of them,
   so every transparent pixel of the animation showed the static mark straight
   through it and the logo played against a motionless copy of itself. On a
   34px disc that reads as a smeared or doubled mark on hover, which is what
   the client reported. Nothing caught it because both layers are inside the
   disc and the audit's layering assertion only asks whether the video escapes
   it. */
.brand__still,
.brand__video {
  transition: opacity 180ms linear;
}
/* Keyed to the mark, because the Home hero's mark has no .brand link
   around it. Both spellings match so either structure works. */
.brand__mark.is-playing .brand__video,
.brand.is-playing .brand__video { opacity: 1; }
.brand__mark.is-playing .brand__still,
.brand.is-playing .brand__still { opacity: 0; }

.brand__mark {
  transition: transform var(--dur-hover) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .brand:hover .brand__mark { transform: scale(1.06); }
}

/* ---- DRAWER -------------------------------------------------------------- */

.nav-drawer {
  transform: translate3d(0, -8px, 0);
  opacity: 0;
  transition: opacity var(--dur-drawer) var(--ease-out),
              transform var(--dur-drawer) var(--ease-out);
}
.nav-drawer.is-open { transform: none; opacity: 1; }

/* ---- FILTER TRANSITION --------------------------------------------------- */

/* The grid animates its own height while cards fade, rather than each card
   animating a height of its own. One animated property on one element is one
   layout pass; twenty-six is twenty-six. */
.gallery__grid, .gallery__groups {
  transition: height var(--dur-drawer) var(--ease-out);
}
.js-reveal .study, .js-reveal .cat-group {
  transition: opacity var(--dur-hover) var(--ease-out);
}

/* ---- PARALLAX ------------------------------------------------------------ */

/* Desktop only, and gated in the JavaScript as well as here. Parallax on a
   phone fights the browser's own scroll handling and reads as lag, so there is
   no mobile parallax on this site at any strength. */
.parallax { will-change: transform; }
@media (max-width: 899px), (pointer: coarse) {
  .parallax { transform: none !important; will-change: auto; }
}

/* ---- THE LOADING CURTAIN, HOME ONLY -------------------------------------- */

/* Written by js/chrome.js, and only there: a page with no JavaScript never
   gets a curtain, and so can never be left under one. Everything here is
   presentation for an element that may not exist.

   It carries no content a visitor needs. The page underneath it is complete
   and readable from the first frame; the curtain is a brand moment laid over
   a finished page, not a gate in front of an unfinished one. */
.loader {
  position: fixed;
  inset: 0;
  z-index: var(--z-curtain);
  display: grid;
  place-items: center;
  /* HEAD SESSION 024, at the client's request: the curtain is white, not navy.
     Both logo clips carry a real alpha plane, verified by sampling a mid frame
     onto a transparent canvas, so the mark keys onto white exactly as it keyed
     onto navy. The wave layer below is off with it: it was screened over navy,
     and screen over white is white. */
  background: var(--page);
  /* Only opacity, so the lift is one compositor property and cannot reflow
     the page it is uncovering. */
  transition: opacity 300ms var(--ease-out);
  /* THE LAST BACKSTOP, AND THE ONLY ONE THAT NEEDS NO JAVASCRIPT AT ALL.
     Five things take this curtain down and four of them are scripts: three in
     chrome.js, one in motion.js. This is the fifth, and it is here because
     every one of those four is a script, so a single failure mode, a throw
     between writing the element and arming the timers, could in principle take
     all of them. Four seconds is comfortably past the last scripted route,
     which lands at about 3.3s, so in normal operation this never runs: by the
     time the delay expires the element has already been removed from the
     document.

     `visibility: hidden` rather than pointer-events, because a hidden element
     is not hit-tested at all, so the failsafe cannot leave an invisible sheet
     over the page swallowing clicks. */
  /* 4000, halved with the rest of the sequence, was 8000. The clip is 4.04s of
     footage played at rate 2, so it runs 2.02s, and the scripted backstop now
     sits at 3000. This has to stay behind that or the last-resort failsafe
     would be the thing taking the curtain down on a healthy load. */
  animation: h2050-curtain-failsafe 200ms var(--ease-out) 4000ms forwards;
}
.loader.is-lifting { opacity: 0; pointer-events: none; }
@keyframes h2050-curtain-failsafe {
  to { opacity: 0; visibility: hidden; }
}

/* The wave artwork, screened over the navy so the ground is not a flat panel.
   `screen` because the file carries a white ground: over navy it keeps the
   wave and drops the black, which is the same rule .art--on-dark follows. */
.loader__art {
  display: none;   /* see the ground note above */
  position: absolute;
  inset: 0;
  background: url(../assets/img/pattern-wave.jpg) 50% 50% / cover no-repeat;
  opacity: 0.16;
  mix-blend-mode: screen;
  /* The same edge fade every decorative layer on this site carries, so the
     wave dissolves rather than meeting the viewport at a hard line. */
  -webkit-mask-image: radial-gradient(farthest-side at 50% 50%, #000 0%, #000 40%, transparent 88%);
          mask-image: radial-gradient(farthest-side at 50% 50%, #000 0%, #000 40%, transparent 88%);
}

.loader__stage {
  position: relative;
  /* Above the resolve plate below, which is a sibling pseudo-element and would
     otherwise paint over it on DOM order alone. */
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-6);
}

/* Big enough to read the animation, small enough to sit inside a 375 viewport
   with the wordmark under it. */
.loader__mark {
  width: clamp(112px, 22vw, 168px);
  height: clamp(112px, 22vw, 168px);
  background: transparent;   /* the keyed video needs the navy, not a disc */
  transition: opacity 120ms var(--ease-out);
}
/* The still stays down for the whole curtain. It is a white-ground PNG, so on
   navy it is a white tile rather than a mark, and the resolve now lands on the
   full lockup instead of on it. If the clip never decodes, the symbol is
   simply absent for the second or so before the lockup arrives, which is a
   quieter failure than a white square. */
/* Still hidden, and now for the opposite reason. On navy it was a white tile;
   on white it is invisible, and either way the clip is what should be seen. */
.loader .brand__still { opacity: 0; }
.loader__word {
  font-family: var(--font-display);
  font-size: clamp(20px, 4.4vw, 26px);
  font-weight: var(--wt-medium);
  letter-spacing: -0.01em;
  color: var(--brand-navy);   /* navy ink on the white curtain, was --on-navy */
  opacity: 0;
  animation: loader-word 350ms var(--ease-out) 120ms forwards;
}
@keyframes loader-word {
  from { opacity: 0; transform: translate3d(0, 8px, 0); }
  to   { opacity: 1; transform: none; }
}

/* ---- THE RESOLVE IS GONE, HEAD SESSION 024 -----------------------------
   About sixty lines lived here: a white plate faded in over the navy, the full
   Hydro2050 lockup crossed in over the stage, and the symbol and typed word
   were faded out under it. All of it existed for one reason, that the curtain
   was lifted at roughly two seconds while the clip runs 4.04s, so without it
   the last thing seen of the brand was a half-built shape.

   The clip now plays to `ended` and the ground is already white, so both
   halves of that answer are answered by the change itself. The rules are
   deleted rather than left unreferenced: chrome.js no longer adds
   `.is-resolved` to anything, and CSS for a class nothing applies is the kind
   of thing that survives until someone reintroduces the class and gets a
   sixty-line surprise. Recover it from git if the early lift ever returns. */

/* While the curtain is up the page behind it does not scroll. Released by the
   same routes that lift the curtain, so a stalled video cannot leave the
   document locked.

   The animation is the lock's own expiry date, and it exists for one narrow
   case: the curtain has been written and the class applied, and then every
   script on the page stops. The failsafe on .loader above hides the curtain
   for that case, but a hidden curtain over a page that cannot scroll is still
   a page that cannot scroll. `overflow` is not an interpolable property, so it
   animates discretely and step-end pins it at `hidden` for the whole delay and
   flips it once, at the end. A browser that will not animate it at all runs an
   animation with no animated properties, which changes nothing and breaks
   nothing. */
html.is-loading, html.is-loading body {
  overflow: hidden;
  animation: h2050-lock-expiry 3200ms step-end forwards;
}
@keyframes h2050-lock-expiry {
  from { overflow: hidden; }
  to   { overflow: visible; }
}

/* ---- PAGE TRANSITIONS ---------------------------------------------------- */

/* Cross-document view transitions. This site is thirteen separate HTML
   documents and stays that way: the alternative to these four rules is a
   client-side router, which would mean owning history, focus restoration and
   scroll position by hand for an effect worth none of that.

   `navigation: auto` is declarative and same-origin only, so it costs no
   JavaScript and nothing to fall back to. A browser that does not implement it
   ignores the whole at-rule and navigates the way it always did. */
@view-transition { navigation: auto; }

/* THE OUTGOING PAGE IS HELD, NOT FADED, and that is a correction rather than a
   preference. Two snapshots that both fade, over the browser's own white
   backdrop, are transparent at the same time: sampled mid-transition the old
   page sat at 0.37 and the new at 0.40, which composites to 0.62 and lets 38
   per cent of white through everything on screen for about a tenth of a
   second. On the white parts of a page nobody would ever see it. On the navy
   band and the navy footer it is a wash, and it fires on every navigation.

   The usual fix is `mix-blend-mode: plus-lighter`, which is what the browser's
   default cross-fade uses, but plus-lighter is only correct when the two
   opacities sum to one at every instant. These do not and should not: out is
   deliberately shorter than in. Holding the old page at full opacity underneath
   removes the dip outright and needs no arithmetic to stay true, because there
   is never a moment when the viewport is showing anything but a complete page.
   `animation: none` is what holds it: it replaces the browser's default
   cross-fade with nothing, so the snapshot simply keeps its resting opacity for
   the length of the transition and is discarded at the end. */
::view-transition-old(root) {
  animation: none;
  mix-blend-mode: normal;
}
::view-transition-new(root) {
  animation: h2050-fade-in 320ms var(--ease-out) both;
  mix-blend-mode: normal;
}
@keyframes h2050-fade-in { from { opacity: 0; transform: translate3d(0, 10px, 0); } }

/* The header should not cross-fade with the page: on any two inner pages it is
   the same bar in the same place, so fading it out and back in reads as a
   flicker in the one element a visitor is looking at while they navigate.
   Named, so it is matched across the two documents and simply persists.

   THE HEADER IS NOT IDENTICAL ON BOTH DOCUMENTS, which is what this rule used
   to assume, and the assumption is what the client was seeing. Paper draws the
   bar inside the Home hero at top 15 inset 64, so Home is 99px tall and 1312
   wide, against 116px and 1376 on the other twelve. Both numbers are the
   design's own, so the answer is not to flatten one into the other.

   With a single name the browser matched those two different bars as one
   element and morphed the group between them over 220ms, and because old and
   new both carried `animation: none` neither faded, so for the whole 220ms two
   headers of different heights and different insets were painted on top of
   each other. That is the overlap: it fires on every navigation into or out of
   Home, which is most of them.

   Home takes its own name. A name that is present in one document and absent
   in the other is not a pair to morph, so the browser exits one and enters the
   other instead, and the two bars are never on screen together. Between two
   inner pages the name still matches, the geometry is genuinely identical, and
   the morph resolves to no movement at all, which is the stability this rule
   was written for in the first place. */
.site-header { view-transition-name: h2050-header; }
.page-home .site-header { view-transition-name: h2050-header-home; }

::view-transition-group(h2050-header) { animation-duration: 220ms; }
::view-transition-old(h2050-header),
::view-transition-new(h2050-header) { animation: none; mix-blend-mode: normal; }

/* Home's bar enters and leaves rather than morphing, so it needs a treatment
   of its own. Short, and shorter going out than coming in, so the outgoing bar
   is gone before the incoming one is legible. */
::view-transition-group(h2050-header-home) { animation-duration: 220ms; }
/* Opacity only, not the page's own h2050-fade-in: that one carries a 10px
   rise, which is right for a page arriving and wrong for a bar that is
   supposed to be the fixed thing on screen while the page moves under it. */
::view-transition-old(h2050-header-home) {
  animation: h2050-header-out 120ms var(--ease-out) both;
  mix-blend-mode: normal;
}
::view-transition-new(h2050-header-home) {
  animation: h2050-header-in 200ms var(--ease-out) both;
  mix-blend-mode: normal;
}
@keyframes h2050-header-out { to { opacity: 0; } }
@keyframes h2050-header-in { from { opacity: 0; } }

/* ---- REDUCED MOTION ------------------------------------------------------ */

/* Both halves of the guard are needed. This block stops anything CSS-driven;
   the matching guard at the top of motion.js returns before it builds a Lenis
   instance, a ScrollTrigger or an observer, so nothing JavaScript-driven is
   ever created either. Every element is left in its finished state. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
    scroll-behavior: auto !important;
  }
  /* Every from-state on this page, cancelled at the source. The reveals and
     the hero entrance are keyframe animations now, so `animation: none` is
     what neutralises them, and the declared end values are what is left. The
     `transform` reset stays alongside `translate` because the parallax layers
     and the mirrored art still use it. Content is present and finished;
     nothing is left hidden waiting for a scroll that will not animate. */
  .js-reveal .reveal,
  .js-reveal .reveal[data-stagger],
  .js-reveal .reveal[data-stagger] > *,
  .js-reveal [data-entrance] [data-entrance-item] {
    opacity: 1 !important;
    translate: none !important;
    transform: none !important;
    animation: none !important;
    transition: none !important;
  }

  /* The curtain is not written under reduced motion, so these are belt to
     that file's braces: if it ever appears, it is already lifted, it is not
     hit-testable, and the document is not locked. */
  .loader { display: none !important; animation: none !important; }
  html.is-loading, html.is-loading body { overflow: visible !important; animation: none !important; }
  .loader__word { animation: none !important; opacity: 1 !important; }

  /* Page transitions. The at-rule cannot be conditioned, so the animations are
     zeroed instead: the navigation still happens, it simply cuts. */
  ::view-transition-old(root),
  ::view-transition-new(root) { animation: none !important; }
  /* The hero holds its poster rather than autoplaying. The poster is a real
     frame of the same file, so nothing is lost but the movement. */
  .hero__media video { display: none; }
  .brand__video { display: none; }
  .brand__mark { transform: none !important; }
  .parallax { transform: none !important; }
}

/* ===== THE HERO ENTRANCE, NOW THAT IT IS ACTUALLY SEEN ===================
   Head session 024. motion.js used to fire this two frames after it ran, so on
   the home page the whole entrance played out under an opaque curtain and was
   over before the curtain lifted at 4.04s. It now waits for the curtain to
   start lifting, which makes this the first motion a visitor sees rather than
   the first motion they miss, and it is worth a little more than a uniform
   fade-and-rise on all five items.

   Two changes, and deliberately only two.

   ONE, THE LOCKUP DOES NOT RISE. The curtain ends on the Hydro2050 mark
   animating, and the hero lockup is that same mark. Sliding it up 18px from
   nothing throws away the continuity: the eye is already on the brand, so the
   lockup settles out of a 1.025 scale instead, which reads as the curtain's
   mark coming to rest rather than as a new element arriving. Everything else
   still rises, so the lockup leads and the page assembles under it.

   TWO, THE STAGGER GOES 90 TO 110. Five items at 90 is 360ms end to end, which
   was right when this was a detail nobody watched. It is the opening beat now
   and 440 lets each item read. Still well under the half second where a
   stagger starts to feel like waiting. */
.js-reveal [data-entrance] { --entrance-step: 110ms; }

.js-reveal [data-entrance] .hero__lockup[data-entrance-item] {
  translate: none;
  scale: 1.025;
}
.js-reveal [data-entrance].is-entered .hero__lockup[data-entrance-item] {
  scale: 1;
  animation: h2050-entrance-lockup 900ms var(--ease-out) 0ms backwards;
}
@keyframes h2050-entrance-lockup {
  from { opacity: 0; scale: 1.025; }
}

/* The tagline is the one line that says what the company does, so it gets the
   extra beat rather than sharing the lockup's. --i is written by motion.js in
   source order, and this leaves that untouched: it adds to the computed delay
   rather than renumbering the items, so nothing else has to know about it. */
.js-reveal [data-entrance].is-entered .hero__tagline[data-entrance-item] {
  animation-delay: calc(var(--entrance-step) * var(--i, 0) + 90ms);
}

@media (prefers-reduced-motion: reduce) {
  .js-reveal [data-entrance] .hero__lockup[data-entrance-item] { scale: none; }
  .js-reveal [data-entrance].is-entered .hero__lockup[data-entrance-item],
  .js-reveal [data-entrance].is-entered .hero__tagline[data-entrance-item] {
    animation: none;
  }
}
