/* ============================================================
   motion.css — motion lane · "scroll = altitude"
   Animates ONLY transform & opacity (hard constraint); the
   living sky is a single JS-painted canvas (see js/motion.js),
   not a CSS animation — this file only positions it.
   All pre-hidden states are gated behind html.has-js, which
   js/motion.js adds only when motion is allowed — so every
   element is fully visible if JS never runs.
   Timing voice: entrances 500–800ms cubic-bezier(0.22,1,0.36,1);
   the sky drifts in seconds-to-minutes, never in milliseconds.
   ============================================================ */

/* ------------------------------------------------------------
   Scroll-progress hairline (element injected by js/motion.js)
   1px of --accent fixed at the very top edge; grows with scroll.
   ------------------------------------------------------------ */
.scroll-progress{
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: 0 50%;
  z-index: 9999;
  pointer-events: none;
  will-change: transform;
}

/* ------------------------------------------------------------
   Hero figure — the cloud ride.
   The figure (.hero-3d) receives the scroll-driven drift/fade
   as an inline transform+opacity from JS; the idle bob lives on
   the inner img so the two never fight (CSS animations override
   inline transforms on the same element).
   ------------------------------------------------------------ */
.hero-3d{
  pointer-events: none;          /* a faded hero must never block clicks */
  will-change: transform, opacity;
}

.has-js #hero3d{
  animation:
    hero-in  800ms cubic-bezier(0.22, 1, 0.36, 1) both,
    hero-bob 6s    ease-in-out   800ms            infinite;
}

/* Where the browser can compose animations additively, a slow
   lateral sway (37s — mutually prime with the 6s bob, so the
   pair never phase-locks) rides on top of the bob: the idle
   float stops looking metronomic. Guarded so browsers that
   would *replace* the bob instead of adding never see it. */
@supports (animation-composition: add){
  .has-js #hero3d{
    animation:
      hero-in   800ms cubic-bezier(0.22, 1, 0.36, 1) both,
      hero-bob  6s    ease-in-out   800ms            infinite,
      hero-sway 37s   ease-in-out   800ms            infinite;
    animation-composition: replace, replace, add;
  }
}

@keyframes hero-in{
  from{ opacity: 0; transform: translate3d(0, 26px, 0); }
  to  { opacity: 1; transform: translate3d(0, 0, 0); }
}

/* gentle idle float: 0 → -6px → 0 over ~6s */
@keyframes hero-bob{
  0%, 100%{ transform: translate3d(0, 0, 0); }
  50%     { transform: translate3d(0, -6px, 0); }
}

/* additive lateral drift, deliberately lopsided (28% / 71%) */
@keyframes hero-sway{
  0%, 100%{ transform: translate3d(0, 0, 0); }
  28%     { transform: translate3d(5px, 0, 0); }
  71%     { transform: translate3d(-4px, 0, 0); }
}

/* ------------------------------------------------------------
   Hero copy — soft rise on load (transform/opacity only, so
   CLS stays 0). The hint's intro animation is released by JS
   (style.animation = "none") before its scroll fade begins.
   ------------------------------------------------------------ */
.has-js .hero-name,
.has-js .hero-role,
.has-js .hero-hint{
  animation: rise-in 800ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.has-js .hero-role{ animation-delay: 120ms; }

/* The hint rises in, then breathes — a slow 5.6s exhale that
   starts only after the entrance has fully settled. JS's release
   (style.animation = "none") kills the breath together with the
   intro the moment scrolling starts, exactly as before. */
.has-js .hero-hint{
  animation:
    rise-in      800ms cubic-bezier(0.22, 1, 0.36, 1) 320ms both,
    hint-breathe 5.6s  ease-in-out                    1.9s  infinite;
}

@keyframes rise-in{
  from{ opacity: 0; transform: translate3d(0, 22px, 0); }
  to  { opacity: 1; transform: translate3d(0, 0, 0); }
}

@keyframes hint-breathe{
  0%, 100%{ opacity: 1; }
  50%     { opacity: 0.65; }
}

/* ------------------------------------------------------------
   Reveals — [data-reveal] rises 24px and fades in once,
   triggered by IntersectionObserver in js/motion.js.
   Stagger comes from an inline transition-delay set (and later
   cleared) by JS.
   ------------------------------------------------------------ */
.has-js [data-reveal]{
  opacity: 0;
  transform: translate3d(0, 24px, 0);
  transition:
    opacity   700ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 700ms cubic-bezier(0.22, 1, 0.36, 1);
}

.has-js [data-reveal].is-in{
  opacity: 1;
  transform: translate3d(0, 0, 0);
}

/* ------------------------------------------------------------
   Work items — quiet hover slide + accent underline grow.
   Slide sits on the <li> (always a block box). Selectors are
   split so the slide wins over the reveal's resting transform
   after .is-in, and still works when JS never ran (no .has-js).
   ------------------------------------------------------------ */
html:not(.has-js) .work-item{
  transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1);
}

.has-js .work-item.is-in:hover,
.has-js .work-item.is-in:focus-within,
html:not(.has-js) .work-item:hover,
html:not(.has-js) .work-item:focus-within{
  transform: translate3d(6px, 0, 0);
}

.work-name{ position: relative; }
.work-name::after{
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -4px;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1);
}

.work-item:hover .work-name::after,
.work-item:focus-within .work-name::after{
  transform: scaleX(1);
}

/* ------------------------------------------------------------
   The sky — ONE full-page generative canvas behind everything.
   js/motion.js injects it (fixed, pointer-transparent,
   aria-hidden) and paints a wrapping cloudscape where scroll is
   altitude. No-JS / reduced-motion visitors simply never get
   the element; the page stays a clean still.

   The canvas sits at z-index:-1 in the root stacking context.
   Root-context paint order puts body's own background OVER
   negative-z children, so body must not paint: html carries the
   identical --bg canvas (theme.css), which makes this rule a
   visual no-op whenever the layer is absent (no JS, reduced
   motion, init failure).
   ------------------------------------------------------------ */
body{ background-color: transparent; }

.sky-canvas{
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;        /* backing store is half-res; CSS stretches it */
  z-index: -1;
  pointer-events: none;
}

/* ------------------------------------------------------------
   Reduced motion — everything collapses to static-but-visible.
   !important also defeats any inline styles JS may have set
   before the preference flipped (JS tears itself down too).
   ------------------------------------------------------------ */
@media (prefers-reduced-motion: reduce){
  .has-js [data-reveal],
  .has-js .hero-name,
  .has-js .hero-role,
  .has-js .hero-hint,
  .has-js #hero3d{
    animation: none !important;
    transition: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
  .hero-3d{
    transform: none !important;
    opacity: 1 !important;
    visibility: visible !important;
    will-change: auto;
  }
  .scroll-progress{ display: none !important; }
  .sky-canvas{ display: none !important; }
  .work-item,
  .work-item:hover,
  .work-item:focus-within{
    transition: none !important;
    transform: none !important;
  }
  .work-name::after{ transition: none !important; }
}
