/* =====================================================================
 * Motion for KH Portfolio.
 *
 * All motion, transitions, and visibility-driven playback rules live in
 * this file (CSS side) and in scripts/motion.js (JS side). Layout and
 * token files never carry motion.
 *
 * Video playback:
 *   Case-card videos (landing) and device-mockup videos (case pages)
 *   autoplay muted, loop, playsinline, gated to when the element is in
 *   the viewport via IntersectionObserver in scripts/motion.js.
 *
 * Reduced motion:
 *   Under prefers-reduced-motion: reduce, motion.js does not start
 *   playback, so the video element continues to render its poster.
 *   The rule below is a defensive backstop that hides any browser
 *   playback UI if a video somehow started playing.
 * ===================================================================== */

@media (prefers-reduced-motion: reduce) {
  video::-webkit-media-controls-start-playback-button { display: none; }
  video::-internal-media-controls-overlay-cast-button { display: none; }
}


/* =====================================================================
 * Sticky nav.
 *
 * Base layout (position: fixed, full-width, per-page opaque background)
 * lives in layout.css / case.css. Everything scroll-driven is here:
 * scripts/motion.js toggles .nav--scrolled on scrollY > 0, which fades
 * the bar to a translucent state with a backdrop blur so page content
 * scrolling under it stays legible without covering the ambient below.
 *
 * Landing drops the surface entirely — just the backdrop blur remains,
 * so the ocean gradient reads through. Case pages sit over the pale
 * page bg with fewer competing marks, so they keep a soft translucent
 * surface for legibility.
 *
 * The landing nav also switches ink to white once the scroll position
 * puts the nav over the darker half of the ocean gradient. motion.js
 * toggles .nav--dark based on scrollY / .ocean.offsetHeight, so the
 * threshold rides the actual gradient rather than a fixed pixel line.
 *
 * Under prefers-reduced-motion the classes still flip, but the
 * transitions are suppressed so the state changes are instant.
 * ===================================================================== */

.nav {
  transition:
    background-color 0.25s ease,
    color            0.35s ease,
    backdrop-filter  0.25s ease,
    -webkit-backdrop-filter 0.25s ease;
}

.nav--scrolled {
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
}

.page .nav--scrolled {
  background-color: transparent;
}

.case-page .nav--scrolled {
  background-color: color-mix(in srgb, var(--surface-page-light) 55%, transparent);
}

/* About page sits over its own gradient shell (#bde7e2 at the top). Keyed   */
/* to About's authored top color so the nav's translucent surface matches    */
/* what the gradient shows underneath.                                       */
.about-page .nav--scrolled {
  background-color: color-mix(in srgb, #bde7e2 20%, transparent);
}

/* Once the nav sits over the darker half of the ocean gradient, ink flips */
/* to white so labels stay legible. Only on landing — case pages sit over  */
/* the pale surface throughout, so the base dark ink still reads.          */
.page .nav--dark {
  color: #ffffff;
}

@media (prefers-reduced-motion: reduce) {
  .nav { transition: none !important; }
}


/* =====================================================================
 * Fish boids.
 *
 * scripts/motion.js creates a full-viewport canvas inside .ocean and
 * draws a school of fish on it, using assets/ambient/fish/body.svg and
 * tail.svg (from Figma frame 344:169 "New Fish"). Nothing here fires
 * under prefers-reduced-motion — motion.js exits early and no canvas
 * is created, so the page has no fish. That's intentional; the fish
 * are a motion element.
 * ===================================================================== */

.motion-fish-canvas {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  pointer-events: none;
  z-index: -1;              /* inside .ocean's stacking context (z:1),   */
                            /* paints above the ocean gradient and       */
                            /* below page content — matches .ambient.    */
}


/* =====================================================================
 * Footer ambient motion.
 *
 * Three motion patterns on ambient sprites in the aquarium (day) and
 * night footers. Each element keeps its authored x/y and rendered size;
 * only transform + opacity animate. scripts/motion.js randomizes each
 * element's --anim-dur and --anim-delay so nothing ever pulses or sways
 * in unison.
 *
 *   Sparkle band (.aquarium__glint):
 *     scale up, peak, fade back — pulses in place.
 *
 *   Seagrass (.aquarium__seaweed, .footer__seaweed):
 *     small rotation with transform-origin at the base so the sprite
 *     sways from the seabed, not from its center.
 *
 *   Small glowing dots (.footer__bio-plankton):
 *     small drift on both axes with a soft opacity pulse. Base transform
 *     centers the sprite on its Figma coordinate; drift is added to that
 *     centering so the anchor never moves.
 *
 * Under prefers-reduced-motion, all three fall back to their authored
 * static state. Sun glints and seaweeds render with no transform, and
 * plankton keeps only the centering translate.
 * ===================================================================== */


/* -------------------- Seagrass: sway from the base + tip lag -------------------- */
/* Base rotates ±9°; tip skew is a delayed, smaller copy of the same swing so the */
/* top of the blade trails the base instead of the blade rotating rigidly.        */

@property --kh-rot  { syntax: '<angle>'; initial-value: 0deg; inherits: false; }
@property --kh-skew { syntax: '<angle>'; initial-value: 0deg; inherits: false; }

.aquarium__seaweed,
.footer__seaweed {
  transform-origin: 50% 100%;
  transform: rotate(var(--kh-rot, 0deg)) skewX(var(--kh-skew, 0deg));
  animation-name: kh-seaweed-sway, kh-seaweed-tip;
  animation-duration: var(--anim-dur, 6s), var(--anim-dur, 6s);
  animation-timing-function: ease-in-out, ease-in-out;
  animation-delay:
    var(--anim-delay, 0s),
    calc(var(--anim-delay, 0s) + var(--anim-dur, 6s) * 0.15);
  animation-iteration-count: infinite, infinite;
  animation-direction: alternate, alternate;
}

@keyframes kh-seaweed-sway {
  from { --kh-rot: -9deg; }
  to   { --kh-rot: 9deg;  }
}

@keyframes kh-seaweed-tip {
  from { --kh-skew: -3deg; }
  to   { --kh-skew: 3deg;  }
}


/* -------------------- Hero glisten: starburst + sparkle cluster -------------------- */
/* Each .ambient__pos wraps a hero star or sparkle sprite (see index.html rows 27-66).*/
/* We pulse the WRAPPER (scale + opacity) so the child <img>'s inline rotation/flip   */
/* transform stays intact — the sprite keeps its authored orientation while the box   */
/* around it glisten-pulses in place. Duration and phase are per-element randoms      */
/* written by motion.js so no two sprites peak together, reading like light glancing  */
/* on moving water (see reference/motion-inspo/water-glisten-*.gif).                  */
/* Color mix (cream stars + white sparkles) and size variation come from the authored */
/* SVGs and their wrapper widths — this rule only modulates transform + opacity.      */
/*                                                                                     */
/* Two independent animations run in parallel on separate typed custom properties so  */
/* they compose in one transform without fighting each other:                          */
/*   kh-glint-pulse → --kh-glint-scale + opacity  (scale up, peak, fade)               */
/*   kh-glint-rot   → --kh-glint-rot              (subtle back-and-forth)              */
/* Each animation has its own duration + delay (motion.js writes both), so a sparkle  */
/* can peak brightness mid-rotation rather than at either extreme.                     */

@property --kh-glint-scale { syntax: '<number>'; initial-value: 1;    inherits: false; }
@property --kh-glint-rot   { syntax: '<angle>';  initial-value: 0deg; inherits: false; }

@keyframes kh-glint-pulse {
  0%, 100% { --kh-glint-scale: 0.9;  opacity: 0.55; }
  50%      { --kh-glint-scale: 1.15; opacity: 1;    }
}

@keyframes kh-glint-rot {
  from { --kh-glint-rot: -3deg; }
  to   { --kh-glint-rot:  3deg; }
}

.ambient__pos,
.kh-seabed-glint {
  transform-origin: center;
  transform: scale(var(--kh-glint-scale, 1)) rotate(var(--kh-glint-rot, 0deg));
  animation:
    kh-glint-pulse var(--anim-dur, 5s) ease-in-out infinite,
    kh-glint-rot   var(--rot-dur,  6s) ease-in-out infinite alternate;
  animation-delay:
    var(--anim-delay, 0s),
    var(--rot-delay,  0s);
}

/* Seabed sparkles are SVG <path>s (135 of them), one per glint in the band.       */
/* motion.js fetches the flattened glisten.svg, inlines it in place of the <img>,  */
/* and tags each <path id="Union_*"> with .kh-seabed-glint so this selector picks  */
/* them up alongside the hero .ambient__pos wrappers. fill-box scopes each path's  */
/* transform-origin to its OWN bounding box, so the default `center` origin rotates */
/* every sparkle around its own authored center — nothing drifts. Colors, sizes,   */
/* and positions come from the SVG paths' authored fills/geometry and are never    */
/* touched here; this rule only modulates transform + opacity.                      */
.kh-seabed-glint {
  transform-box: fill-box;
}


/* -------------------- Hero sunrays: horizontal breath + linked opacity ---------- */
/* Six vertical light shafts sit above the hero: two live in HTML as               */
/* .ambient__ray-wrap > .ambient__ray (CSS gradients on rotated <span>s), and four */
/* are baked as <rect id="Light ray*"> inside underwater-details.svg. At init,     */
/* motion.js fetches that SVG, injects it inline in place of the <img>, and wraps  */
/* each ray <rect> in a <g class="kh-ray"> so the rect's inline rotate stays       */
/* intact. This selector then targets ALL six shafts uniformly.                     */
/*                                                                                   */
/* Each shaft scales horizontally about its top-center so the bottom spreads while  */
/* the top stays anchored where the light enters the water. A single --kh-ray-breath */
/* var animates 0 → 1 → 0 on a per-shaft duration/phase; scaleX and opacity are     */
/* both interpolated from that against per-shaft --sh-min/--sh-max/--opa-min/       */
/* --opa-max ranges. Ranges are per-shaft randoms written by motion.js so some       */
/* shafts open wide (scaleX 0.45↔1.55, opacity 1.0↔0.45) while others stay narrow    */
/* (scaleX 0.80↔1.20, opacity 1.0↔0.80). Opacity is inverse to width — wider reads   */
/* dimmer, narrower reads brighter, as if the same light is spread over more area.  */
/*                                                                                    */
/* Because each shaft's rotation lives on the shaft's own element (SVG rect          */
/* transform attribute on the baked rays; inline transform:rotate on the CSS span    */
/* inside .ambient__ray-wrap), the scale composes with an already-rotated child;     */
/* for the small rotations in play (< 10°) the resulting shear is imperceptible.     */
/*                                                                                    */
/* Duration + delay are per-shaft randoms too (11-20s, negative delays) so no two    */
/* shafts peak together and none sync to the 3-7s glisten pulsing overhead.          */

@property --kh-ray-breath { syntax: '<number>'; initial-value: 0; inherits: false; }

@keyframes kh-ray-breath {
  0%, 100% { --kh-ray-breath: 0; }
  50%      { --kh-ray-breath: 1; }
}

.ambient__ray-wrap {
  transform-origin: 50% 0%;
  transform: scaleX(calc(var(--sh-min, 0.85) + (var(--sh-max, 1.15) - var(--sh-min, 0.85)) * var(--kh-ray-breath)));
  opacity: calc(var(--opa-max, 1) - (var(--opa-max, 1) - var(--opa-min, 0.8)) * var(--kh-ray-breath));
  animation: kh-ray-breath var(--ray-breath-dur, 14s) ease-in-out infinite;
  animation-delay: var(--ray-breath-delay, 0s);
  will-change: transform, opacity;
}

svg g.kh-ray {
  /* fill-box scopes transform-origin to the group's own bbox instead of the SVG   */
  /* root's user-space, so 50% 0% lands at the top-center of the shaft's rendered  */
  /* bounding box (which is where the light enters the water for the baked rays).  */
  transform-box: fill-box;
  transform-origin: 50% 0%;
  transform: scaleX(calc(var(--sh-min, 0.85) + (var(--sh-max, 1.15) - var(--sh-min, 0.85)) * var(--kh-ray-breath)));
  opacity: calc(var(--opa-max, 1) - (var(--opa-max, 1) - var(--opa-min, 0.8)) * var(--kh-ray-breath));
  animation: kh-ray-breath var(--ray-breath-dur, 14s) ease-in-out infinite;
  animation-delay: var(--ray-breath-delay, 0s);
  will-change: transform, opacity;
}


/* -------------------- Hero wordmark: subtle vertical float ---------- */
/* Slow bob applied to the landing hero wordmark so it reads as        */
/* drifting rather than sitting still. 7s ease-in-out, ±3px, infinite. */
/* Horizontal centering is handled by the parent .hero's                */
/* align-items:center — nothing else touches .hero__wordmark's          */
/* transform, so this animation goes directly on the element with no    */
/* wrapper. Reduced-motion falls back to static via the block below.    */

@keyframes kh-hero-float {
  0%, 100% { transform: translateY(-4px); }
  50%      { transform: translateY( 4px); }
}

.hero__wordmark {
  animation: kh-hero-float 5s ease-in-out infinite;
  will-change: transform;
}


/* -------------------- Glowing dots: unified plankton system -------------------- */
/* Landing deep-water plankton, landing night bio-plankton, and case-page day     */
/* glints all share one motion system driven by scripts/motion.js — ported from   */
/* the prototype in reference/coastal_dive_site_...html. Each sprite drifts       */
/* slowly upward (with a small horizontal wander), wraps back down when it        */
/* exits the top, and pulses in place with a phase-offset sine. A per-sprite      */
/* "energy" value rises when the cursor sits nearby, especially if it's still,    */
/* and decays smoothly, which brightens and enlarges the sprite.                  */
/*                                                                                 */
/* JS writes --kh-plk-dx / --kh-plk-dy / --kh-plk-lum / --kh-plk-scale.            */
/* Colors come from the authored SVG sprites — this rule only modulates           */
/* transform + opacity, never fill.                                                */

@property --kh-plk-dx    { syntax: '<length>'; initial-value: 0px; inherits: false; }
@property --kh-plk-dy    { syntax: '<length>'; initial-value: 0px; inherits: false; }
@property --kh-plk-scale { syntax: '<number>'; initial-value: 1;   inherits: false; }
@property --kh-plk-lum   { syntax: '<number>'; initial-value: 1;   inherits: false; }

/* Landing plankton sprites are centred on their Figma coord via translate       */
/* (-50%, -50%). Drift + scale compose ON TOP of that centering.                  */
/* --kh-plk-base-scale / --kh-plk-base-lum are visual size/dim modifiers set by  */
/* the --small variant; default 1 for the authored (large) cohort.                */
.ambient__plankton,
.footer__bio-plankton {
  transform:
    translate(
      calc(-50% + var(--kh-plk-dx, 0px)),
      calc(-50% + var(--kh-plk-dy, 0px))
    )
    scale(calc(var(--kh-plk-base-scale, 1) * var(--kh-plk-scale, 1)));
  transform-origin: center;
  opacity: calc(var(--kh-plk-base-lum, 1) * var(--kh-plk-lum, 1));
}

/* Day-aquarium glints anchor at their top-left (no centering translate), so    */
/* they compose drift + scale without the -50% offset.                            */
.aquarium__glint {
  transform:
    translate(var(--kh-plk-dx, 0px), var(--kh-plk-dy, 0px))
    scale(calc(var(--kh-plk-base-scale, 1) * var(--kh-plk-scale, 1)));
  transform-origin: center;
  opacity: calc(var(--kh-plk-base-lum, 1) * var(--kh-plk-lum, 1));
}

/* Small variant: dynamically spawned by motion.js. Smaller radius + dimmer     */
/* glow so they read as further away. Everything else (drift, pulse, cursor     */
/* energy) is identical — motion.js gives them a higher sensitivity multiplier  */
/* so they react to the cursor a little more readily.                            */
.ambient__plankton--small,
.footer__bio-plankton--small,
.aquarium__glint--small {
  --kh-plk-base-scale: 0.5;
  --kh-plk-base-lum:   0.55;
}


/* -------------------- Reduced-motion fallback -------------------- */

/* -------------------- Per-footer motion palette -------------------- */
/* Bubbles and ripples share one class each. Color varies per footer:  */
/* the day aquarium reads a dark teal; the night footer reads a soft   */
/* pale blue. Both derive from existing ink tokens via color-mix so    */
/* nothing is hardcoded and the two palettes stay authored.            */

.footer {
  --kh-motion-fg: color-mix(in srgb, var(--ink-invert-signature) 55%, transparent);
}

.aquarium {
  --kh-motion-fg: color-mix(in srgb, var(--ink-day-signature) 45%, transparent);
}

/* Landing ocean uses the same recipe as the night footer so bubbles          */
/* spawned in .ocean read as one system with the footer's — same ring color,  */
/* same feel. Legible against the darker ocean-mid / ocean-bottom gradient    */
/* stops, subtle against the pale ocean-top (which is intentional: bubbles    */
/* are meant to be sparse near the surface anyway).                            */
.ocean {
  --kh-motion-fg: color-mix(in srgb, var(--ink-invert-signature) 55%, transparent);
}


/* -------------------- Bubbles -------------------- */
/* Rise from just above the sand line, wobble horizontally, fade out    */
/* before reaching the top. JS spawns them at random x, random size,    */
/* and staggered timing. Sparse, not a stream.                          */

.kh-bubble {
  position: absolute;
  /* Footer bubbles anchor at the bottom (default 40px above the sand);      */
  /* landing bubbles anchor at a specific top (JS writes --kh-bubble-top and */
  /* sets --kh-bubble-bottom: auto). One class, two anchor modes.            */
  bottom: var(--kh-bubble-bottom, 40px);
  top:    var(--kh-bubble-top, auto);
  left: var(--x, 50%);
  width: var(--size, 8px);
  height: var(--size, 8px);
  border: 1.5px solid var(--kh-motion-fg);
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
  transform: translate(-50%, 0);
  animation: kh-bubble-rise var(--dur, 8s) linear forwards;
}

/* Landing variant: sits behind .page content in .ocean's z:1 stacking       */
/* context so bubbles rise through the water column without painting over    */
/* the hero, cards, or any page text. Footer bubbles keep the default        */
/* z-auto so nothing changes there.                                           */
.kh-bubble--behind {
  z-index: -1;
}

@keyframes kh-bubble-rise {
  0%   { transform: translate(-50%, 0);                                          opacity: 0;   }
  10%  { transform: translate(calc(-50% + var(--wobble, 0px) * 0.1), -60px);     opacity: 0.9; }
  50%  { transform: translate(calc(-50% + var(--wobble, 0px) * 0.6), -260px);    opacity: 0.9; }
  80%  { transform: translate(calc(-50% + var(--wobble, 0px)),        -400px);   opacity: 0.4; }
  100% { transform: translate(calc(-50% + var(--wobble, 0px)),        -470px);   opacity: 0;   }
}


/* -------------------- Landing static bubble → animated loop ---------------- */
/* .ambient__bubble is a Figma-authored 10×10 bubble placed near the hero. It */
/* joins the same rise-wobble-fade system as .kh-bubble, but with a top-left  */
/* anchor (not centered) so its transform origin matches its authored inline  */
/* left/top. motion.js writes --anim-dur, --anim-delay, and --wobble per      */
/* element so multiple authored bubbles never sync, and the 0→8% fade-in at   */
/* loop restart hides the position pop back to the authored anchor.            */

.ambient__bubble {
  animation: kh-static-bubble-rise var(--anim-dur, 10s) linear infinite;
  animation-delay: var(--anim-delay, 0s);
  will-change: transform, opacity;
}

@keyframes kh-static-bubble-rise {
  0%   { transform: translate(0, 0);                                          opacity: 0;   }
  8%   { transform: translate(calc(var(--wobble, 0px) * 0.08),  -30px);       opacity: 1;   }
  55%  { transform: translate(calc(var(--wobble, 0px) * 0.6),  -260px);       opacity: 0.9; }
  85%  { transform: translate(calc(var(--wobble, 0px)),        -400px);       opacity: 0.4; }
  100% { transform: translate(calc(var(--wobble, 0px)),        -470px);       opacity: 0;   }
}


/* -------------------- Cursor ripples -------------------- */
/* Double ring following the pointer inside a footer. Outer ring on the */
/* element, inner ring on ::before. JS spawns one on pointer move and   */
/* self-cleans after the animation ends.                                */

.kh-ripple {
  position: absolute;
  left: var(--x, 0);
  top: var(--y, 0);
  width: 30px;
  height: 30px;
  margin: -15px 0 0 -15px;
  border-radius: 50%;
  border: 1.5px solid var(--kh-motion-fg);
  pointer-events: none;
  animation: kh-ripple-outer 1.2s ease-out forwards;
}

.kh-ripple::before {
  content: '';
  position: absolute;
  inset: -6px;
  border: 1px solid var(--kh-motion-fg);
  border-radius: 50%;
  animation: kh-ripple-inner 1.2s ease-out forwards;
}

@keyframes kh-ripple-outer {
  0%   { transform: scale(0.35); opacity: 0.85; }
  100% { transform: scale(1.6);  opacity: 0;    }
}

@keyframes kh-ripple-inner {
  0%   { transform: scale(0.55); opacity: 0.55; }
  100% { transform: scale(1.85); opacity: 0;    }
}


/* -------------------- Reduced-motion fallback -------------------- */

@media (prefers-reduced-motion: reduce) {
  .aquarium__seaweed,
  .footer__seaweed {
    animation: none !important;
    transform: none !important;
  }
  /* Hero glisten + seabed glints: static at authored size, no pulse. Under         */
  /* reduced-motion motion.js also skips the seabed SVG inline-swap, so the band    */
  /* renders as the original flat <img> — matches the reduced-motion intent.        */
  .ambient__pos,
  .kh-seabed-glint {
    animation: none !important;
    transform: none !important;
    opacity: 1 !important;
  }
  /* Hero sunrays: static shafts, no breathing. Under reduced-motion motion.js    */
  /* also skips the SVG inline-swap, so the four baked rays render as they would  */
  /* from the <img> fallback.                                                      */
  .ambient__ray-wrap,
  svg g.kh-ray {
    animation: none !important;
    transform: none !important;
    opacity: 1 !important;
  }
  /* Hero wordmark float: drop the bob and hold at authored position. */
  .hero__wordmark {
    animation: none !important;
    transform: none !important;
  }
  /* Plankton: static at authored position, no drift, no pulse, no cursor.       */
  /* --small variants preserve their smaller base-scale + dim base-lum so the    */
  /* far-field cohort still reads as distinct from the authored (large) cohort.  */
  .aquarium__glint {
    animation: none !important;
    transform: scale(var(--kh-plk-base-scale, 1)) !important;
    opacity: var(--kh-plk-base-lum, 1) !important;
  }
  .footer__bio-plankton,
  .ambient__plankton {
    animation: none !important;
    transform: translate(-50%, -50%) scale(var(--kh-plk-base-scale, 1)) !important;
    opacity: var(--kh-plk-base-lum, 1) !important;
  }
  .kh-bubble,
  .kh-ripple { display: none !important; }
  /* Authored static bubbles: render at their Figma-authored position with   */
  /* no animation, matching prefers-reduced-motion intent. Nothing added to  */
  /* the DOM by the landing spawn loop either — motion.js short-circuits.    */
  .ambient__bubble {
    animation: none !important;
    transform: none !important;
    opacity: 1 !important;
  }
}


/* =====================================================================
 * Hero tagline word rotator.
 *
 * The highlighted chip in "I build ___ people dive into" cycles through
 * a short list of words (see scripts/motion.js). Two coordinated moves:
 *
 *   Chip width:
 *     The chip animates its own width smoothly between each word's
 *     measured width + horizontal padding, so the box always hugs the
 *     current word. Widths are measured once at load in motion.js and
 *     re-driven via style.width on each tick.
 *
 *   Word swap:
 *     The outgoing word fades up and out, the incoming word fades up
 *     from just below and in. Both are absolutely stacked inside the
 *     chip track so the crossfade overlaps rather than snaps.
 *
 * The chip lives inside the .hero__tagline flex row alongside the two
 * static parts, which stays centered inside .hero. As the chip resizes,
 * the two parts follow the transition symmetrically frame-for-frame.
 * The chip's internal padding stays 2/9px and the two --tagline-gap
 * external gaps stay 7px at every word.
 *
 * Under prefers-reduced-motion, scripts/motion.js exits before wrapping
 * or classing anything, so the chip renders as its authored HTML with
 * the starting word "worlds" and never rotates.
 * ===================================================================== */

.tagline__chip--rotating {
  position: relative;
  overflow: hidden;
  transition: width 500ms cubic-bezier(0.4, 0, 0.2, 1);
}

.tagline__chip--rotating .tagline__chip-track {
  position: relative;
  display: inline-block;
  white-space: nowrap;
}

.tagline__chip--rotating .tagline__word {
  display: inline-block;
  white-space: nowrap;
  transition:
    opacity   260ms ease,
    transform 380ms cubic-bezier(0.4, 0, 0.2, 1);
}

/* Incoming word overlays the current one at the track's origin (which sits */
/* inside the chip's padded area since the track is in-flow). Starts a hair */
/* below and transparent; the -enter-active class settles it into place.    */
.tagline__chip--rotating .tagline__word--enter {
  position: absolute;
  left: 0;
  top: 0;
  opacity: 0;
  transform: translateY(0.28em);
}

.tagline__chip--rotating .tagline__word--enter-active {
  opacity: 1;
  transform: translateY(0);
}

/* Outgoing (was current) drifts up and fades. Kept in-flow so the track    */
/* holds its height during the crossfade — the incoming is absolute over it. */
.tagline__chip--rotating .tagline__word--exit {
  opacity: 0;
  transform: translateY(-0.28em);
}

@media (prefers-reduced-motion: reduce) {
  .tagline__chip--rotating {
    transition: none !important;
  }
  .tagline__chip--rotating .tagline__word {
    transition: none !important;
    transform: none !important;
  }
}


/* =====================================================================
 * Case study section rail.
 *
 * Rail sticks inside the left column as the page scrolls, naturally
 * constrained by its .case-main containing block so it stops before
 * running past into the footer. A floating marker owns the visual dot
 * and slides between items when the active section changes; the color
 * transition on items smooths the text darkening. The inline ::before
 * marker in case.css is disabled here so the two never collide.
 *
 * Under prefers-reduced-motion, transitions collapse to zero and the
 * marker jumps between items rather than sliding.
 * ===================================================================== */

.case-rail {
  position: sticky;
  /* Sits under the fixed nav with a small breathing gap so the top rail    */
  /* item never kisses the nav's bottom edge.                               */
  top: calc(var(--nav-h) + 16px);
  align-self: flex-start;
  /* Align the rail's left edge with the nav "kh." logo above it. Logo sits */
  /* at padding --nav-pad-x (40) from .case-page left; rail natively sits   */
  /* at --case-main-pad-x (150), so shift by 40 − 150 = −110. Transform     */
  /* shifts paint only, so .case-content stays at its Figma x.              */
  transform: translateX(-110px);
}

.rail__list {
  position: relative;
}

.rail__item {
  cursor: pointer;
  transition: color 0.3s ease;
  outline: none;
}

.rail__item:focus-visible,
.rail__project:focus-visible {
  outline: 2px solid var(--case-accent, var(--card-pm-accent));
  outline-offset: 2px;
}

.rail__project {
  cursor: pointer;
  outline: none;
}

.rail__item--active::before {
  content: none;
}

.rail__marker {
  position: absolute;
  /* Sit the dot in the gutter left of the text so it reads as attached to  */
  /* the label but not touching it. All item text edges stay at 0; only     */
  /* the dot occupies the gutter. `top: 2px` nudges the glyph so its visual */
  /* center aligns with the cap-height center of the uppercase label,       */
  /* rather than the taller line-box center of Potassium Scandal.           */
  left: -16px;
  top: 2px;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: var(--fs-rail-label);
  line-height: var(--lh-tight);
  color: var(--case-accent, var(--card-pm-accent));
  transform: translateY(0);
  transition:
    transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
    opacity   0.2s ease;
  pointer-events: none;
  opacity: 0;
}

.rail__marker--visible {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .rail__item,
  .rail__marker { transition: none !important; }
}


/* =====================================================================
 * Landing depth meter — active stop transition.
 *
 * Sizing lives in ambient.css (7×7 base, 10×10 active). Here we own the
 * fade + size crossover so the active dot grows and lights up rather
 * than snapping. motion.js normalizes every stop's <img> to the fully
 * opaque asset on init, so the base 0.45 opacity below matches the
 * inactive asset's baked opacity while allowing a clean transition to
 * full opacity when a stop goes active.
 *
 * Under prefers-reduced-motion the active state still flips (so the
 * dot still tracks the current section) but with no transition.
 * ===================================================================== */

.ambient__scrubber-stop {
  opacity: 0.45;
  transition:
    width   0.22s ease,
    height  0.22s ease,
    opacity 0.22s ease;
}

.ambient__scrubber-stop--active {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .ambient__scrubber-stop { transition: none; }
}


/* =====================================================================
 * Scroll snap — guided, not trapped.
 *
 * Proximity, not mandatory: snap only nudges when the user stops near a
 * snap point; fast scrolling passes through untouched.
 *
 * Landing: only the first card snaps — a single gentle settle as you
 * enter the work section, then free-scroll through cards 2–5. CSS
 * proximity has no pull-range knob and grabs across ~1/3 viewport per
 * snap point; five stacked targets read as too aggressive, zero as too
 * loose, so one is the compromise.
 *
 * Case pages: sections the rail already tracks. scripts/motion.js tags
 * each matched .case-block / .case-stack with .case-snap after the rail
 * pairing loop, so unmatched blocks (hero, partnerships, next-card) do
 * not participate.
 *
 * scroll-margin-top matches the JS scroll math (nav.height + 16) that
 * nav links, rail clicks, and the depth-meter use, so smooth-scroll
 * targets land at the same y as snap rest — nothing re-nudges after a
 * click.
 *
 * Reduced motion disables snap entirely.
 * ===================================================================== */

html {
  scroll-snap-type: y proximity;
}

.cases > .card:first-child,
.case-snap {
  scroll-snap-align: start;
  /* --kh-scroll-offset is published by motion.js on <html> (nav.height + */
  /* 16, measured live). Same value the JS scroll handlers use, so a     */
  /* rail click and a snap rest land on the exact same pixel. Fallback   */
  /* is the token math for the first frame before JS runs.               */
  scroll-margin-top: var(--kh-scroll-offset, calc(var(--nav-h) + 16px));
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-snap-type: none; }
  .cases > .card:first-child,
  .case-snap { scroll-snap-align: none; }
}
