/* ═══════════════════════════════════════════════════════════════════
   VELONOVA MOTION LAYER · site-wide
   ═══════════════════════════════════════════════════════════════════

   Vanilla ports of the 21st.dev patterns Tanya approved. Extracted from
   the inline MOTION LAYER block on /fractional-product-manager/ so every
   page can use them.

   The contract: motion.js publishes ONE number per tracked element,
   `--p` in 0..1, describing how far that element has travelled through
   the viewport (plus `--q` per stacked card). Everything below is a pure
   CSS function of that number. No effect here animates on a timer, so
   nothing fights the browser's scheduler.

   Rules that must hold:
   · Use the individual `translate` / `rotate` / `scale` properties, never
     `transform`, so motion composes with style.css's `.reveal` transforms
     instead of overwriting them.
   · Every effect is disabled under BOTH `prefers-reduced-motion` and
     `html.a11y-motion` (the site's own accessibility toggle), and its
     resting state must be the fully-revealed one.
   · Nothing here may hide text from a crawler. Word and line splitting
     happens in JS only; the served HTML keeps one clean string, the
     container carries `aria-label`, and the generated spans are
     `aria-hidden`.

   Effects, in the order they were specified:
     01  .vn-wall            parallax columns
     02  [data-vn-wipe]      block wipe reveal
     03  [data-vn-whisper]   per-word fade + slide
     04  [data-vn-say]       scroll statement
     05  .vn-stack/.vn-beam  story chapters + tracing beam
     06  .vn-planes          layered parallax planes
   ═══════════════════════════════════════════════════════════════════ */

:root { --vn-spring: cubic-bezier(.22,1,.36,1); }

/* Writing direction. motion.js flips this to -1 on RTL pages so every
   horizontal offset below mirrors without a second rule. */
[data-px], .vn-planes, .vn-wall { --pxd: 1; }


/* ── 01 · Parallax columns ─────────────────────────────────────────
   The grid keeps its normal document flow and its DOM order, so cards
   stay selectable, linkable and in tab order, and any filtering the
   page already does keeps working untouched.

   Drift is NOT keyed off :nth-child. The coffee listing hides filtered
   cards with `display:none`, and :nth-child still counts hidden
   elements, so a card's DOM index stops matching the column it is
   actually rendered in the moment a filter is applied. motion.js
   instead measures each card's real offsetLeft and writes `--vnd`, the
   drift distance for the column it is genuinely sitting in.           */
/* Drift is RATE-based, not normalised across --p.

   Normalising was the original mistake: --p runs 0..1 across the whole
   pass of the element through the viewport, so on the coffee grid (4,326px
   tall) a 44px offset was smeared over ~9,700px of scrolling and produced
   0.7% of page height in travel. Invisible.

   motion.js writes --vny, the raw pixels scrolled past the grid's start.
   Each column multiplies it by its own small rate, so relative separation
   grows with distance the way real parallax does, and is clamped so cards
   never drift outside the section. */
.vn-wall {
  /* Rate-based drift pulls columns past the container edges, so the wall
     absorbs the maximum excursion as padding and clips what still escapes.
     Without this the grid opens a visible gap at the top as you scroll. */
  overflow: clip;
  padding-block: var(--vn-wall-pad, 120px);
  margin-block: calc(var(--vn-wall-pad, 120px) * -0.5);
}
.vn-wall > * {
  translate: 0 clamp(-120px, calc(var(--vny, 0px) * var(--vnr, 0)), 120px);
}
@media (max-width: 900px) {
  .vn-wall { overflow: visible; padding-block: 0; margin-block: 0; }
}


/* ── 02 · Block wipe ───────────────────────────────────────────────
   A coral block grows across the first half of each line's local
   progress, then releases across the second half, leaving the text
   behind it. clip-path drives both halves so the transform origin never
   has to flip mid-sweep.                                              */
.vn-wipe-ln {
  display: block; position: relative; overflow: hidden;
  --t: clamp(0, calc((var(--p, 1) - var(--i, 0) * .17) * 1.15), 1);
}
.vn-wipe-tx { display: block; opacity: clamp(0, calc((var(--t) - .5) * 9), 1); }
.vn-wipe-ln::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: var(--vn-accent, #fda1a2);
  clip-path: inset(
    0
    calc(clamp(0, calc(1 - var(--t) * 2), 1) * 100%)
    0
    calc(clamp(0, calc((var(--t) - .5) * 2), 1) * 100%)
  );
}
/* RTL: sweep enters from the right instead of the left. */
[dir="rtl"] .vn-wipe-ln::after {
  clip-path: inset(
    0
    calc(clamp(0, calc((var(--t) - .5) * 2), 1) * 100%)
    0
    calc(clamp(0, calc(1 - var(--t) * 2), 1) * 100%)
  );
}


/* ── 03 · Whisper ──────────────────────────────────────────────────
   Per-word fade up from a small horizontal offset with the blur coming
   off it. `--i` is written NORMALISED (0..1 across the sentence) by
   motion.js, so one rule staggers correctly whether the copy runs to
   eight words or eighty.                                              */
.vn-w {
  display: inline-block;
  --t: clamp(0, calc((var(--p, 1) - var(--i, 0) * .6) * 5.5), 1);
  opacity: var(--t);
  translate: calc((1 - var(--t)) * -16px * var(--pxd, 1)) 0;
  filter: blur(calc((1 - var(--t)) * 3px));
}


/* Above-the-fold variant. A hero lede has no scroll distance to map, so
   it plays once on load instead of tracking --p.

   This is deliberately a CSS animation rather than a rAF loop writing
   --p. A rAF never fires in a background tab, which would leave the copy
   stuck invisible until the reader came back to it. Here the RESTING
   state is fully revealed (the base .vn-w rule falls back to --p:1 with
   no [data-px] ancestor) and the keyframes only add the entrance, so if
   the animation never runs the text is simply there.                   */
.vn-intro .vn-w {
  animation: vnWhisperIn .62s var(--vn-spring) both;
  animation-delay: calc(var(--i, 0) * .5s);
}
@keyframes vnWhisperIn {
  from { opacity: 0; translate: calc(-16px * var(--pxd, 1)) 0; filter: blur(3px); }
  to   { opacity: 1; translate: 0 0; filter: blur(0); }
}


/* ── 04 · Scroll statement ─────────────────────────────────────────
   Words brighten one at a time as the section crosses the viewport.
   The .42 floor is deliberate: below it the un-revealed words become
   unreadable over imagery.                                            */
.vn-s { display: inline-block; opacity: clamp(.42, calc((var(--p, 1) - var(--i, 0) * .62) * 5), 1); }


/* ── 05 · Story chapters + tracing beam ────────────────────────────
   The beam's coral fill tracks the section's own --p; each chapter
   fades and lifts on a stagger. Pairs with .vn-slot for the sticky
   variant, where --q says how far the next card has closed in.        */
.vn-stack { position: relative; }
.vn-beam {
  position: absolute; top: 8px; bottom: 8px; inset-inline-start: -30px;
  width: 2px; border-radius: 2px; background: rgba(237,233,225,.09);
}
.vn-beam i {
  position: absolute; inset-inline: 0; top: 0; border-radius: 2px;
  height: calc(var(--p, 1) * 100%);
  background: linear-gradient(180deg, rgba(253,161,162,.25), var(--vn-accent, #fda1a2));
}
.vn-beam i::after {
  content: ""; position: absolute; bottom: -4px; inset-inline-start: -3px;
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--vn-accent, #fda1a2);
  box-shadow: 0 0 14px 5px rgba(253,161,162,.5);
}
/* --i is normalised 0..1 across the stack by motion.js, so the last
   chapter always resolves exactly at --p = 1 no matter how many there
   are. A raw index left the tail of a five-phase stack stuck at .84. */
.vn-chapter {
  --t: clamp(0, calc((var(--p, 1) - var(--i, 0) * .55) * 7), 1);
  opacity: clamp(.12, calc((var(--p, 1) - var(--i, 0) * .55) * 7), 1);
  translate: 0 calc((1 - var(--t)) * 34px);
}
/* Sticky stacked variant: a card recedes once the next one closes in. */
.vn-slot > * {
  transform-origin: 50% 0%;
  translate: 0 calc(var(--q, 0) * -12px);
  scale: calc(1 - var(--q, 0) * .05);
  filter: brightness(calc(1 - var(--q, 0) * .28));
}


/* ── 06 · Layered planes ───────────────────────────────────────────
   Depth without imagery: a colour field, a rule grid and a sparse
   streak layer moving at three different speeds behind live text.
   Pure CSS, near-zero bytes, never an LCP candidate.                  */
.vn-planes { position: relative; isolation: isolate; overflow: clip; }
.vn-plane { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
.vn-plane.far  { translate: 0 calc((.5 - var(--p, .5)) * 70px); }
.vn-plane.mid  { translate: 0 calc((.5 - var(--p, .5)) * 140px); }
.vn-plane.near { translate: 0 calc((.5 - var(--p, .5)) * 230px); }
.vn-plane.far::before {
  content: ""; position: absolute; inset: -22%;
  background:
    radial-gradient(42% 46% at 26% 38%, rgba(71,71,135,.55), transparent 70%),
    radial-gradient(38% 42% at 76% 60%, rgba(253,161,162,.13), transparent 70%);
}
.vn-plane.mid::before {
  content: ""; position: absolute; inset: 0;
  background-image: linear-gradient(rgba(237,233,225,.055) 1px, transparent 1px);
  background-size: 100% 34px;
}
/* Image plane. Replaces the `far` colour field when a real plate exists.
   The plate drifts at the far-layer speed and scales down slightly as the
   section resolves, so the tangle appears to settle as you scroll: the
   motion is scroll-reactive, which a video plate cannot be.

   It is decorative only. It is never the LCP element (lazy, below the
   fold, empty alt), and the scrim guarantees the copy on top keeps its
   contrast no matter how bright the plate is. */
.vn-plane.media { translate: 0 calc((.5 - var(--p, .5)) * 70px); }
.vn-plane.media img,
.vn-plane.media video {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover;
  /* Per-instance strength. An abstract plate carries .5 comfortably; a
     busy photographic one needs pushing back so the copy still leads. */
  opacity: var(--vn-plate, .5);
  scale: calc(1.03 + (1 - var(--p, .5)) * .14);
}
/* RTL reads right to left, so the tangle should resolve that way too. */
[dir="rtl"] .vn-plane.media img,
[dir="rtl"] .vn-plane.media video { transform: scaleX(-1); }
/* A video plate still gets the scroll-linked drift and scale above, so it
   reacts to the reader rather than just looping on its own clock. Author
   it as: muted autoplay loop playsinline, with a poster frame, so the
   reduced-motion path below can freeze it to a still. */
html.a11y-motion .vn-plane.media video { display: none; }
.vn-plane.media::after {
  content: ""; position: absolute; inset: 0;
  background:
    radial-gradient(74% 62% at 50% 46%, rgba(7,7,15,.58), rgba(7,7,15,.88) 76%),
    linear-gradient(180deg, var(--vn-canvas, #0b0b14), transparent 15%, transparent 85%, var(--vn-canvas, #0b0b14));
}

.vn-plane .vn-streak {
  position: absolute; inset-inline-start: var(--x); top: var(--y);
  width: 1px; height: 74px; opacity: .45;
  background: linear-gradient(transparent, var(--vn-accent, #fda1a2), transparent);
}
/* Anything that should sit above the planes. */
.vn-planes > *:not(.vn-plane) { position: relative; z-index: 1; }


/* ── Generic drift, for pairing an eyebrow against its body copy ──── */
.vn-px-slow { translate: 0 calc((.5 - var(--p, .5)) * 70px); }
.vn-px-mid  { translate: 0 calc((.5 - var(--p, .5)) * 130px); }
.vn-px-fast { translate: 0 calc((.5 - var(--p, .5)) * 210px); }
.vn-px-drop { translate: 0 calc((.5 - var(--p, .5)) * -52px); }


/* ── Narrow screens ────────────────────────────────────────────────
   Column and plane drift is disabled below 900px: the offsets are tuned
   for a tall viewport and on a phone they scroll content into gaps.   */
@media (max-width: 900px) {
  .vn-wall > *,
  .vn-plane.far, .vn-plane.mid, .vn-plane.near,
  .vn-px-slow, .vn-px-mid, .vn-px-fast, .vn-px-drop { translate: 0 0; }
  .vn-beam { display: none; }
  .vn-slot > * { translate: 0 0; scale: 1; filter: none; }
}


/* ── Reduced motion ────────────────────────────────────────────────
   Both the OS preference and the site's own toggle land on the same
   resting state: everything fully revealed, nothing offset.          */
@media (prefers-reduced-motion: reduce) {
  .vn-wall > *, .vn-plane, .vn-px-slow, .vn-px-mid, .vn-px-fast, .vn-px-drop { translate: 0 0; }
  .vn-plane.media img, .vn-plane.media video { scale: 1.03; }
  .vn-plane.media video { display: none; }
  .vn-wipe-ln::after { display: none; }
  .vn-wipe-tx, .vn-w, .vn-s, .vn-chapter { opacity: 1; translate: 0 0; filter: none; }
  .vn-intro .vn-w { animation: none; }
  .vn-beam i { height: 100%; }
  .vn-slot > * { translate: 0 0; scale: 1; filter: none; }
}
html.a11y-motion .vn-wall > *,
html.a11y-motion .vn-plane,
html.a11y-motion .vn-px-slow, html.a11y-motion .vn-px-mid,
html.a11y-motion .vn-px-fast, html.a11y-motion .vn-px-drop { translate: 0 0; }
html.a11y-motion .vn-plane.media img { scale: 1.03; }
html.a11y-motion .vn-wipe-ln::after { display: none; }
html.a11y-motion .vn-wipe-tx,
html.a11y-motion .vn-w,
html.a11y-motion .vn-s,
html.a11y-motion .vn-chapter { opacity: 1; translate: 0 0; filter: none; }
html.a11y-motion .vn-intro .vn-w { animation: none; }
html.a11y-motion .vn-beam i { height: 100%; }
html.a11y-motion .vn-slot > * { translate: 0 0; scale: 1; filter: none; }

/* ── 07 · Ambient blobs, reusable ──────────────────────────────────
   The homepage hero's floating colour blobs, lifted into a layer any
   section can carry. Same shapes and palette; three differences:

   · they drift with scroll as well as on their own loop, so they react
     to the reader instead of just looping
   · a hover variant (.vn-blobs-hover on an ancestor) brings them up
     from near-invisible, for cards and CTAs
   · sizes are viewport-relative so they behave inside a band, not just
     a full-height hero

   Pure CSS, no bytes, never an LCP candidate.                        */
.vn-blobs { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; }
.vn-blob {
  position: absolute; border-radius: 50%; filter: blur(80px);
  animation: blobFloat var(--dur, 9s) ease-in-out infinite;
  opacity: var(--vn-blob-o, 1);
  transition: opacity .5s ease;
}
/* scroll drift, layered so they separate as the section passes */
.vn-blob-1 { width: min(500px, 46vw); height: min(500px, 46vw); background: rgba(240,112,96,.30); top: -12%; inset-inline-start: -6%;  --dur: 8s;
  translate: 0 calc((.5 - var(--p, .5)) * 60px); }
.vn-blob-2 { width: min(600px, 54vw); height: min(600px, 54vw); background: rgba(83,74,183,.24);  top: -18%; inset-inline-start: 38%;  --dur: 11s; animation-delay: -3s;
  translate: 0 calc((.5 - var(--p, .5)) * 120px); }
.vn-blob-3 { width: min(400px, 38vw); height: min(400px, 38vw); background: rgba(29,158,117,.18); bottom: -10%; inset-inline-end: -5%; --dur: 9s;  animation-delay: -5s;
  translate: 0 calc((.5 - var(--p, .5)) * 90px); }

/* hover variant: dormant until the reader is actually on the element */
.vn-blobs-hover { position: relative; isolation: isolate; }
.vn-blobs-hover .vn-blob { --vn-blob-o: 0; }
.vn-blobs-hover:hover .vn-blob,
.vn-blobs-hover:focus-within .vn-blob { --vn-blob-o: .85; }
.vn-blobs-hover > *:not(.vn-blobs) { position: relative; z-index: 1; }

@media (prefers-reduced-motion: reduce) {
  .vn-blob { animation: none; translate: 0 0; }
  .vn-blobs-hover:hover .vn-blob { --vn-blob-o: .5; }
}
html.a11y-motion .vn-blob { animation: none; translate: 0 0; }
