/* ==========================================================================
   UPFRONT × JAPAN — hero demo
   Design source: Figma "Website 3.0" nodes 5858:13491 / 5858:13493
   ========================================================================== */

/* ---- Fonts -----------------------------------------------------------
   Declared once in css/fonts.css, which every page links. */
/* ---- Tokens ---------------------------------------------------------- */
:root {
  --font-sans: "Blender", "Inter", -apple-system, "Helvetica Neue", Arial,
    sans-serif;
  --color-bg: #000000;
  --color-text: #ffffff;
  /* Hero scroll track: zoom → copy → 100vh dissolve hand-off, with NO hold.
     500vh is not arbitrary: the product stage's dissolve owns the hero's last
     100vh, i.e. raw progress (H − 200)/(H − 100). At 500vh that lands on
     exactly 0.75, which is where the copy has just settled (FADE ends 0.73).
     Shorter and the copy would still be arriving while it dissolves. */
  --track-length: 500vh;

  /* Green→white, as a fluid boundary instead of a ruled line. A single
     linear-gradient band put a dead-straight horizontal edge across the page,
     which is what read as unnatural; three wide radial "clouds" hanging off the
     top edge at different sizes and offsets, over a long soft falloff, give the
     green an irregular underside that dissolves into the white.
     Defined once and shared: .matcha__wash and .alles__sticky must be
     pixel-identical or the morph between them shows a seam. */
  --green: 159, 172, 138; /* #9fac8a */
  --green-clouds: radial-gradient(
      118% 62% at 8% -16%,
      rgba(var(--green), 1) 0%,
      rgba(var(--green), 0.92) 30%,
      rgba(var(--green), 0.45) 58%,
      rgba(var(--green), 0) 78%
    ),
    radial-gradient(
      86% 46% at 46% -10%,
      rgba(var(--green), 1) 0%,
      rgba(var(--green), 0.8) 36%,
      rgba(var(--green), 0.3) 64%,
      rgba(var(--green), 0) 84%
    ),
    radial-gradient(
      132% 70% at 92% -20%,
      rgba(var(--green), 1) 0%,
      rgba(var(--green), 0.88) 26%,
      rgba(var(--green), 0.4) 54%,
      rgba(var(--green), 0) 76%
    ),
    linear-gradient(
      to bottom,
      rgba(var(--green), 1) 0%,
      rgba(var(--green), 0.62) 4%,
      rgba(var(--green), 0.22) 11%,
      rgba(var(--green), 0) 20%
    );
}

/* ---- Base ------------------------------------------------------------ */
* {
  box-sizing: border-box;
}
html,
body {
  /* Scroll anchoring fights this page: stage releases collapse track heights
     mid-scroll and the anchor correction reads as a judder (Chrome/Android).
     The page manages its own scroll semantics. */
  overflow-anchor: none;
  margin: 0;
  padding: 0;
  /* No pull-to-refresh and no chained rubber-band at the ends. On a page that
     is one long pinned scroll, both read as the page fighting the finger. */
  overscroll-behavior-y: none;
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-sans);
  letter-spacing: -0.02em;
}
html {
  scrollbar-width: none;
}
body::-webkit-scrollbar {
  display: none;
}

/* ==========================================================================
   Header
   ========================================================================== */
.site-header {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 20;
  height: 72px;
  padding: 0 24px;
  opacity: 0; /* driven by JS */
  pointer-events: none;
  will-change: opacity, translate;
}
.site-header.is-live {
  pointer-events: auto;
}
.site-header__logo {
  position: absolute;
  left: 50%;
  top: 24px;
  transform: translateX(-50%);
  display: block;
}
.site-header__logo img {
  display: block;
  height: 21px; /* the glow bleed is gone, so this is the mark itself */
  width: auto;
}
/* Button/Primary in the header — Figma 6127:11760, 160×48 at x 1248 / y 12 */
.site-header__cta {
  position: absolute;
  right: 32px;
  top: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 160px;
  height: 48px;
  background: #fff;
  color: #000;
  text-decoration: none;
  font-size: 18px;
  font-weight: 400;
  line-height: 22px;
  letter-spacing: -0.02em;
  transition: transform 0.25s cubic-bezier(0.32, 0.72, 0, 1);
}
.site-header__cta:hover {
  transform: scale(1.02);
}
.site-header__cta:active {
  transform: scale(0.98);
}
/* On the light stages the white button would vanish, so it inverts */
.is-light-chrome .site-header__cta {
  background: var(--color-ink, #121212);
  color: #fff;
  transition: transform 0.25s cubic-bezier(0.32, 0.72, 0, 1),
    background 0.3s ease, color 0.3s ease;
}

/* The logo is white for the dark stages; on the light ones its marks flip to
   dark ink (the Figma frames for those stages carry no header at all). */
.site-header__logo img {
  transition: filter 0.3s ease;
}
.is-light-chrome .site-header__logo img {
  filter: brightness(0);
}

/* Narrow screens: keep the button clear of the centred logo */
@media (max-width: 640px) {
  .site-header__logo img {
    height: 18px;
  }
  /* Figma 6358:7260: the logo sits LEFT-aligned on mobile (x 12), not centred */
  .site-header__logo {
    left: 12px;
    transform: none;
    top: 25px;
  }
  .site-header__cta {
    right: 12px;
    top: 14px;
    width: 116px;
    height: 40px;
    font-size: 15px;
  }
}

/* ==========================================================================
   Scroll rail — a tick per slice of the page, grouped by stage
   ========================================================================== */
.rail {
  position: fixed;
  right: 26px;
  top: 50%;
  transform: translateY(-50%);
  z-index: 30;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 7px;
  /* the marks inherit this, so one rule flips the whole rail on light stages */
  color: #fff;
  opacity: 0; /* faded in by JS once the ride has started */
  transition: opacity 0.5s ease, color 0.3s ease;
}
.is-light-chrome .rail {
  color: var(--color-ink, #121212);
}
.rail__tick {
  position: relative;
  display: block;
  width: 22px;
  height: 2px;
  padding: 0;
  border: 0;
  background: currentColor;
  opacity: 0.3;
  cursor: pointer;
  transition: width 0.25s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.25s ease;
}
/* the slice the page is currently in */
.rail__tick.is-active {
  width: 34px;
  opacity: 1;
}
/* the rest of the stage the cursor is over */
.rail__tick.is-peer {
  opacity: 0.65;
  width: 28px;
}
.rail__tick:hover {
  opacity: 1;
  width: 34px;
}
/* Hit area: the marks are 2px tall, which is far too fine to aim at, so each
   one carries an invisible band that makes the whole rail hoverable. */
.rail__tick::before {
  content: "";
  position: absolute;
  right: -8px;
  left: -10px;
  top: -4px;
  bottom: -4px;
}
.rail__label {
  position: absolute;
  right: 40px;
  top: 50%;
  transform: translate(6px, -50%);
  padding: 5px 10px;
  border-radius: 4px;
  background: rgba(18, 18, 18, 0.86);
  color: #fff;
  font-size: 12px;
  line-height: 1.2;
  letter-spacing: 0.01em;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s ease, transform 0.2s ease;
}
.is-light-chrome .rail__label {
  background: rgba(18, 18, 18, 0.9);
}
.rail__tick:hover .rail__label {
  opacity: 1;
  transform: translate(0, -50%);
}

@media (max-width: 640px) {
  .rail {
    right: 12px;
    gap: 5px;
  }
  .rail__tick {
    width: 14px;
  }
  .rail__tick.is-active,
  .rail__tick:hover {
    width: 22px;
  }
}
@media (prefers-reduced-motion: reduce) {
  .rail__tick,
  .rail__label {
    transition: none;
  }
}

/* ==========================================================================
   Hero — scroll track + sticky stage
   ========================================================================== */
.hero {
  position: relative;
  z-index: 6; /* each stage dissolves on top of the next one */
  height: var(--track-length);
  /* the dissolve's scale(1.03) must not widen the document. `clip` (not
     `hidden`) keeps this from becoming a scroll container, so the sticky
     children still pin to the viewport. */
  overflow-x: clip;
}
.hero__sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100lvh; /* LARGE viewport: dvh resized all six stickies on every frame of the phone URL-bar animation — the whole-screen judder on flicks */
  overflow: hidden;
  background: var(--color-bg);
  /* At full zoom the scene inside is scaled ~14.6x — 21024 × 10950 CSS px, or
     ~920 megapixels of raster at dpr 2, far more than the compositor can paint
     in a frame, which is what shredded the plate into bands on a refresh.
     `contain: paint` bounds that work. It belongs HERE and not on .scene: this
     box is exactly the viewport, so clipping to it can never remove anything
     visible, whereas .scene's own box is exactly what the camera fills at the
     end of the zoom — clipping there took a black strip off the bottom. */
  contain: paint;
  /* dissolved into the product stage by JS (opacity/scale/blur) */
  will-change: opacity, transform, filter;
}

/* ---- Scene (1440×750 design-space canvas, transformed by JS) --------- */
.scene {
  position: absolute;
  left: 0;
  top: 0;
  width: 1440px;
  height: 750px;
  transform-origin: 0 0;
  /* Deliberately NOT promoted with will-change. Promoting this element makes
     the compositor own a layer the size of the scaled scene — up to
     21024 × 10950 CSS px at full zoom — and tile it, which it cannot finish in
     a frame, so it hands back half-painted tiles and the plate appears shredded
     into horizontal bands (worst right after a window resize, when every tile
     is invalidated at once). Left unpromoted, the scene paints into the
     sticky's layer instead, which `contain: paint` bounds to the viewport.
     Measured: p50 16.7ms → 8.5ms, frames over 25ms 17/74 → 4/74. */
  /* --rpx: length of 1 rendered (viewport) pixel in scene units.
     Set by JS every frame; keeps hairlines/scanlines constant on screen. */
  --rpx: 1px;
}

/* Background plate + the ambient loop that sits on it.

   Placement is EXACT COVER for a 1280x720 source on the 1440x750 canvas:
   scale 1.125 -> 1440 x 810 at (0, 0), cropping 60px off the bottom. It is not
   free-chosen — 1.125 is the smallest scale that leaves no gap at the canvas
   edges, and any larger scale would move the TV further from where the
   previous render had it. The glass rect this implies lives in SCREEN
   (js/main.js); the two are one calibration and must be changed together.

   The two layers MUST share pixel-identical geometry, which is why the height
   is written out (810px) instead of left to `auto` on one and not the other. */
.scene__bg,
.scene__amb {
  position: absolute;
  left: 0;
  top: 0;
  width: 1440px;
  height: 810px;
  user-select: none;
  -webkit-user-drag: none;
}
.scene__amb {
  /* driven by JS (see FADE.amb); object-fit guards against a source whose
     aspect is not exactly 16:9 rather than letterboxing it */
  object-fit: cover;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

/* ---- TV glass --------------------------------------------------------- */
/* Geometry (left/top/width/height + --tv-scale) is applied by js/main.js */
.tv-screen {
  position: absolute;
  border-radius: 6px 6px 7px 7px; /* CRT corner curvature, scene px */
  overflow: hidden;
  background: #000;
}
/* Night-time spill of the screen light onto the scene. Toggled by JS: these
   blurs are in scene units, so at full zoom they cost ~320px of blur for
   something entirely off-screen. */
.tv-screen.has-spill {
  box-shadow: 0 0 22px 4px rgba(130, 185, 255, 0.2),
    0 0 7px 1px rgba(170, 205, 255, 0.16);
}

/* 1280×720 content plane, uniformly scaled to cover the 4:3 glass.
   Working at footage resolution keeps text/videos crisp at full zoom. */
.tv-screen__inner {
  position: absolute;
  left: 50%;
  top: 50%;
  /* Sized from the chosen video (js sets --tv-w/--tv-h), because the desktop
     and mobile cuts have different shapes. Working at the file's own
     resolution is what keeps it sharp at the fullscreen moment. */
  width: var(--tv-w, 1280px);
  height: var(--tv-h, 720px);
  margin: calc(var(--tv-h, 720px) / -2) 0 0 calc(var(--tv-w, 1280px) / -2);
  transform: scale(var(--tv-scale, 0.112));
}
.tv-screen__video {
  display: none;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* YouTube embed. It lives on the same 1280×720 plane as the local video, so
   the camera transform scales it exactly like everything else on the TV.
   pointer-events: none is not optional — at p=0 the iframe covers the whole
   viewport and would otherwise swallow every wheel and drag event, leaving
   the page unscrollable. */
.tv-screen__yt {
  display: none;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  pointer-events: none;
  /* With controls=0 the player is clean WHILE PLAYING; its chrome only appears
     on the poster and when paused, and both of those are hidden behind the
     LADEN… placeholder until the `playing` state arrives (see yt-live). So the
     crop only has to cover edge artefacts — kept small on purpose, because a
     bigger one eats burned-in subtitles along the bottom of the footage.
     Scale, never a larger width/height: sizing the iframe up changes its
     aspect ratio and the player letterboxes the video instead. */
  transform: scale(1.06);
  transform-origin: center;
  opacity: 0;
  transition: opacity 0.45s ease;
}
.tv-screen.yt-live .tv-screen__yt {
  opacity: 1;
}
.tv-screen.has-yt .tv-screen__yt {
  display: block;
}
.tv-screen.has-yt .tv-screen__video {
  display: none;
}
/* the placeholder stays until the player reports it is actually playing, so
   its poster frame and chrome are never on screen */
.tv-screen.yt-live .tv-loading {
  display: none;
}
.tv-screen.has-video .tv-screen__video {
  display: block;
}
.tv-screen.has-video .tv-loading {
  display: none;
}

/* ---- LOADING placeholder (recreates the footage's first frame) ------- */
.tv-loading {
  position: absolute;
  inset: 0;
  background: radial-gradient(120% 120% at 50% 45%, #060606 0%, #000 70%);
}
.tv-loading__label {
  position: absolute;
  left: 283px;
  top: 297px;
  font-size: 21px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.06em;
  color: #e9e9e9;
}
.tv-loading__bar {
  position: absolute;
  left: 284px;
  top: 324px;
  width: 715px;
  height: 37px;
  border: 3px solid #e2e2e2;
  padding: 4px;
}
.tv-loading__fill {
  height: 100%;
  width: 100%;
  background: #f4f4f4;
  /* scaleX, not width: this sits inside the TV, which the camera blows up
     ~15x, and animating width would relayout + repaint that whole enlarged
     layer every frame. A transform is composited instead. */
  transform-origin: left center;
  transform: scaleX(0);
  animation: tv-load 9s cubic-bezier(0.3, 0.5, 0.35, 1) infinite;
}
@keyframes tv-load {
  0% {
    transform: scaleX(0);
  }
  86% {
    transform: scaleX(0.97);
  }
  100% {
    transform: scaleX(0.97);
  }
}

/* ---- CRT glass: scanlines + glare + vignette -------------------------- */
.tv-screen__glass {
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  background: radial-gradient(
      90% 55% at 76% 10%,
      rgba(255, 255, 255, 0.09),
      transparent 55%
    ),
    radial-gradient(
      145% 135% at 50% 50%,
      transparent 54%,
      rgba(2, 4, 12, 0.55) 100%
    );
}
.tv-screen__glass::before {
  content: "";
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    to bottom,
    rgba(6, 8, 16, 0.25) 0,
    rgba(6, 8, 16, 0.25) calc(var(--rpx) * 1.2),
    transparent calc(var(--rpx) * 1.2),
    transparent calc(var(--rpx) * 3.6)
  );
  opacity: 0.65;
}

/* ---- End-state scrim -------------------------------------------------- */
/* Wei's spec. Sits above the scene and below .hero__ui (z 10) and the header
   (z 30), so it darkens the street without touching the copy.

   The BOX STOPS AT 60.1% of the viewport, and every stop below is Wei's
   percentage divided by 0.601. The spec's own gradients reach zero alpha at
   60.1%, so the bottom 39.9% was a fully transparent full-width layer being
   composited over the scene for no visible result — and that composite is not
   free: measured over the settle sweep (p 0.45→0.80, 3 interleaved rounds),
   full-height ran p90 14.6-18.8ms with 4-5 frames over 25ms against a 10.0-10.4
   baseline, while this clipped box runs 10.6-11.1 with 2-3. Verified identical
   to the full-height version to within 1/255, zero pixels differing by more
   than 1. Keep the box and the stops in sync if the spec ever changes.

   Worth knowing before optimising further: a FLAT rgba(0,0,0,.5) measured the
   same as these two gradients, so the cost is the extra full-viewport composite
   itself, not the gradient fills — merging the two layers into one would buy
   nothing. (`will-change: opacity` also did nothing here.) Note too that this
   is a software-rendered headless browser, where a translucent composite is
   far dearer than on a real GPU, so treat the win as free-but-modest.

   The `rgba(102,102,102,0)` end stops are Figma's export idiom. They are safe
   because CSS interpolates gradients in PREMULTIPLIED alpha, so a zero-alpha
   grey is indistinguishable from transparent. */
.hero__scrim {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  bottom: 39.9%; /* spec is transparent past 60.1% — nothing below to paint */
  z-index: 5;
  pointer-events: none;
  opacity: 0; /* driven by JS, see FADE.scrim */
  background:
    linear-gradient(
      180deg,
      rgba(0, 0, 0, 0.6) 40.45%,
      rgba(0, 0, 0, 0.6) 70.22%,
      rgba(102, 102, 102, 0) 91.78%
    ),
    linear-gradient(180deg, rgba(0, 0, 0, 0.4) 0%, rgba(102, 102, 102, 0) 100%);
}

/* ==========================================================================
   End-state UI (title / subtitle / CTA) — Figma node 5858:13493
   ========================================================================== */
.hero__ui {
  position: absolute;
  inset: 0;
  z-index: 10;
  pointer-events: none;
}
.hero__copy {
  position: absolute;
  left: 50%;
  top: 17.2%; /* 129px @ 750 design height */
  transform: translateX(-50%);
  width: min(94vw, 1200px);
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}

/* H1 — Blender Bold 83.33px / 0.8 / −2%, stacked white bloom */
.hero__title {
  margin: 0;
  font-size: clamp(44px, 5.787vw, 120px);
  font-weight: 700;
  line-height: 0.8;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  color: #fff;
  /* Figma stacks six white shadows out to 2.5em. Blur cost grows with the
     square of the radius, and the outer three overlapped almost exactly, so
     they are folded into one — same bloom, a fraction of the raster. */
  text-shadow: 0 0 0.099em #fff, 0 0 0.197em #fff, 0 0 0.691em #fff,
    0 0 1.5em #fff, 0 0 2.2em #fff;
  opacity: 0; /* driven by JS */
  /* own compositor layer — fading the stacked glow re-rasters ~380ms/frame
     without it */
  will-change: opacity, translate;
}

/* B1 — Blender Medium 26px / 30px / −2% */
.hero__subtitle {
  margin: 2px 0 0;
  font-size: clamp(16px, 1.806vw, 32px);
  font-weight: 500;
  line-height: 1.154;
  letter-spacing: -0.02em;
  color: #fff;
  opacity: 0; /* driven by JS */
  will-change: opacity, translate;
}

/* Button/Primary — 160×48 white, Blender Book 18px */
.hero__cta {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: clamp(16px, 1.65vw, 28px);
  width: 160px;
  height: 48px;
  background: #fff;
  color: #000;
  text-decoration: none;
  font-size: 18px;
  font-weight: 400;
  line-height: 22px;
  letter-spacing: -0.02em;
  opacity: 0; /* driven by JS */
  pointer-events: none;
  transition: transform 0.25s cubic-bezier(0.32, 0.72, 0, 1);
  will-change: opacity, translate;
}
.hero__cta.is-live {
  pointer-events: auto;
}
.hero__cta:hover {
  transform: scale(1.02);
}
.hero__cta:active {
  transform: scale(0.98);
}

/* ---- Scroll hint (visible only at the very start) --------------------- */
.hero__hint {
  position: absolute;
  left: 50%;
  bottom: 26px;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  font-size: 11px;
  font-weight: 400;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.55);
}
.hero__hint i {
  display: block;
  width: 1px;
  height: 26px;
  background: linear-gradient(to bottom, rgba(255, 255, 255, 0.6), transparent);
  animation: hint-drop 1.8s ease-in-out infinite;
}
@keyframes hint-drop {
  0% {
    transform: scaleY(0);
    transform-origin: top;
  }
  45% {
    transform: scaleY(1);
    transform-origin: top;
  }
  55% {
    transform: scaleY(1);
    transform-origin: bottom;
  }
  100% {
    transform: scaleY(0);
    transform-origin: bottom;
  }
}

/* ==========================================================================
   Product — Figma nodes 5858:13622 (tilted can) → 5858:13510 (detail)
   ========================================================================== */
.product {
  position: relative;
  z-index: 5;
  /* Start 200vh early so this stage pins during the hero's LAST 100vh of
     pinned scroll (a sticky unpins at track-height − 100vh, hence 200):
     both stages are pinned there, the street dissolves on top while this
     stage is already alive beneath — no hard section edge. */
  margin-top: -200vh;
  /* dissolve-in + can intro/stand-up + copy + lineup + 3-product carousel
     steps + dissolve-out */
  height: 550vh;
  overflow-x: clip; /* see .hero */
  /* NO background on the section: it would keep occluding the matcha stage
     beneath after the sticky has faded out. The base colour lives on the
     sticky, which is the thing that dissolves. */
}
.product__sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100lvh; /* LARGE viewport: dvh resized all six stickies on every frame of the phone URL-bar animation — the whole-screen judder on flicks */
  overflow: hidden;
  background: #0a1410; /* dark green base behind the grid texture */
  /* dissolved into the matcha stage by JS, the same way the hero dissolves
     into this one */
  will-change: opacity, transform, filter;
}

/* ---- Background: dark green + hidden grid ----------------------------- */
.product__bg {
  position: absolute;
  inset: 0;
}
.product__grid {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  user-select: none;
  -webkit-user-drag: none;
}
.product__shade {
  position: absolute;
  inset: 0;
}
.product__shade--intro {
  background: linear-gradient(
    to bottom,
    rgba(128, 128, 128, 0.3) 2.885%,
    rgba(0, 0, 0, 0.3) 63.462%
  );
}
.product__shade--detail {
  background: linear-gradient(
    to bottom,
    rgba(128, 128, 128, 0.06) 0%,
    rgba(0, 0, 0, 0.2) 93.75%
  );
  opacity: 0; /* crossfaded by JS */
}

/* ---- Product items (transforms driven by JS every frame) --------------- */
/* ---- The fx prototype's 3D stage, lifted whole ---------------------------
   Everything below is the arrangement Wei tuned in fx.html. The parts are load
   bearing and must stay separate:
     .fan-stage    owns the perspective. perspective-origin FOLLOWS the cluster
                   (--shift), because with the origin left at the stage centre a
                   shifted cluster projects asymmetrically — measured 1px of
                   overlap on one side against 97px on the other.
     .fan-cluster  the preserve-3d root the pointer parallax rotates.
     .fan-item     placed by JS (translate3d + scale), NO z-index anywhere:
                   preserve-3d depth-sorts by translateZ and stays correct all
                   the way through a swap, where a z-index flip always pops.
     __tilt        the in-plane angle, and it CANCELS the cluster's own rotation
                   so the group parallax moves each product without shearing it.
                   These are cylinders: turning a flat cut-out about a vertical
                   axis only shears it (at 21deg one edge rendered 9.4% taller).
     __float       the idle bob, on its own element so it cannot clobber the
                   placement. */
.fan-stage {
  position: absolute;
  inset: 0;
  perspective: 1600px;
  perspective-origin: calc(50% + var(--shift, 0px)) calc(46% + var(--shift-y, 0px));
  z-index: 4;
  touch-action: pan-y;
}
.fan-cluster {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  /* No translate here: slots() already places each item at its absolute spot
     (its cxMid folds the sideways offset in). Only the parallax rotation. */
  transform: rotateY(calc(var(--mx, 0) * 3deg))
             rotateX(calc(var(--my, 0) * -2deg));
  transition: transform 0.5s cubic-bezier(0.22, 0.61, 0.36, 1);
}
/* while the story is scrubbing, the scroll IS the animation — a transition here
   would make the artwork chase the scroll instead of tracking it */
.is-scrubbing .fan-cluster,
.is-scrubbing .fan-item,
.is-scrubbing .fan-item__tilt { transition: none; }

.product__item,
.matcha__item {
  position: absolute;
  left: 50%;
  top: 50%;
  transform-style: preserve-3d;
  transform: translate3d(calc(-50% + var(--tx, 0px)), calc(-50% + var(--ty, 0px)), var(--tz, 0px))
             scale(var(--sc, 1));
  opacity: var(--op, 1);
  pointer-events: none;
}
.fan-item__tilt {
  transform-style: preserve-3d;
  transform: rotateY(calc(0deg - var(--mx, 0) * 3deg))
             rotateX(calc(var(--my, 0) * 2deg))
             rotateZ(var(--rz, 0deg));
}
/* The 680ms glide belongs to the CAROUSEL only. slots() steps between whole
   arrangements there, and this is what animates the step. During the story the
   scroll IS the animation — placeItem writes every frame, and a transition
   would make the artwork trail its own scroll position (that is also why the
   fx prototype had is-scrubbing to switch this off). */
.is-carousel .product__item,
.is-carousel .matcha__item,
.is-carousel .fan-item__tilt {
  transition: transform var(--swap, 680ms) cubic-bezier(0.32, 0.72, 0, 1);
}
.fan-item__float {
  transform-style: preserve-3d;
  animation: fanBob var(--bob, 7s) var(--bob-delay, 0s) ease-in-out infinite;
  animation-play-state: paused;   /* only breathes once settled */
}
.is-carousel .fan-item__float { animation-play-state: running; }
.product__item img,
.matcha__item img {
  display: block;
  height: var(--h, 300px);
  width: auto;
  /* The shadow SNAPS between states rather than transitioning: animating
     `filter` re-rasters the drop-shadow every frame and cost p50 13.5 -> 8.3ms
     in the prototype. */
  filter: var(--ds, drop-shadow(0 14px 22px rgba(0, 0, 0, 0.45)));
  user-select: none;
  -webkit-user-drag: none;
}

/* Settled lineup becomes a carousel: click a product, swipe/drag sideways,
   or keep scrolling — each steps to the next product. `pan-y` keeps vertical
   scrolling with the page while horizontal gestures come to the carousel. */
.is-carousel {
  touch-action: pan-y;
}
.is-carousel .product__item,
.is-carousel .matcha__item {
  pointer-events: auto;
  cursor: pointer;
  /* No transform transition. The slot line is driven from a continuous phase in
     JS every frame, so a transition here would only make the row trail its own
     position. It also used to be what stretched the old rotate-and-fly reshuffle
     out to a visible 0.75s. */
}
.is-carousel .product__item.is-front,
.is-carousel .matcha__item.is-front {
  cursor: default;
}
.is-carousel .product__title,
.is-carousel .product__desc,
.is-carousel .matcha__title,
.is-carousel .matcha__desc {
  transition: opacity 0.22s ease;
}

/* ---- Copy (design 1440×750: x 211, title c260, desc 306, cta 442) ------ */
/* ---- Copy block: the fx prototype's layout, lifted as-is -----------------
   A flow column at Figma's left 14.65%, vertically centred, width 378 (the
   longest title measures 376.9px in Blender Medium 36). Rhythm 24 / 16 / 44 /
   60 from Figma 5858:13510. Absolute per-element offsets were dropped when the
   tags and pager arrived: the Figma copy runs longer than the placeholder text
   those offsets were tuned for, and fixed tops collide instead of flowing. */
.product__info,
.matcha__info {
  position: absolute;
  left: 14.653%;
  /* Figma 6366:7494 places this block at top 210 in a 1440x750 frame = 28%,
     NOT centred. Centring it looked right in the abstract but the block is
     taller here than in Figma (the real copy wraps further than the frame's),
     so half of that extra height pushed downward and the pager ended up on the
     bottom edge. Anchoring the TOP the way Figma does keeps the title where the
     design puts it and lets any extra length grow downward into the space that
     is actually free. */
  top: 28%;
  z-index: 5;
  width: min(378px, 40vw);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  pointer-events: none;
}
/* H4 — Blender Medium 36/40, −2%, stacked white glow */
.product__title,
.matcha__title {
  margin: 0 0 24px;
  font-size: clamp(26px, 2.5vw, 36px);
  font-weight: 500;
  line-height: 1.111;
  letter-spacing: -0.02em;
  color: #fff;
  text-shadow: 0 0 0.814em #fff, 0 0 1.628em #fff, 0 0 3.2em #fff,
    0 0 5em #fff;
  opacity: 0; /* driven by JS */
  will-change: opacity, translate;
}
/* B4 — Blender Book 18/22, −2% */
.product__desc,
.matcha__desc {
  margin: 0 0 16px;
  max-width: 361px;
  font-size: 18px;
  font-weight: 400;
  line-height: 1.222;
  letter-spacing: -0.02em;
  color: #fff;
  opacity: 0; /* driven by JS */
  will-change: opacity, translate;
}
/* Bordered boxes (not pills): Border/Border-2, 4px padding, 8/20 gap, and
   Figma wraps them inside the 364px group (Pump's three doses need two rows) */
.fan-tags {
  margin: 0 0 44px;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  /* the reserved min-height must NOT stretch the tags themselves (a one-row
     set turned into 72px-tall boxes) — pack rows at the top, spare space
     stays below as spacing */
  align-content: flex-start;
  align-items: flex-start;
  max-width: 364px;
  gap: 8px 20px;
  opacity: 0; /* driven by JS */
  will-change: opacity, translate;
}
.fan-tags li {
  padding: 4px;
  border: 1px solid #d0d0d0;
  font-size: 18px;
  line-height: 22px;
  letter-spacing: -0.02em;
  color: #fff;
  white-space: nowrap;
}
/* Figma 6366:7494: white tag borders on the pale matcha field */
.fan-tags--light li { border-color: #fff; }
/* Button/Primary — plain white box; the newer frames carry no glow */
.product__cta,
.matcha__cta {
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 160px;
  height: 48px;
  padding: 6px 8px;
  background: #fff;
  color: #000;
  text-decoration: none;
  font-size: 18px;
  font-weight: 400;
  line-height: 22px;
  letter-spacing: -0.02em;
  white-space: nowrap;
  opacity: 0; /* driven by JS */
  pointer-events: none;
  transition: transform 0.25s cubic-bezier(0.32, 0.72, 0, 1);
  will-change: opacity, translate;
}
.product__cta.is-live,
.matcha__cta.is-live { pointer-events: auto; }
.product__cta:hover,
.matcha__cta:hover { transform: scale(1.02); }
.product__cta:active,
.matcha__cta:active { transform: scale(0.98); }

/* ---- Pager (Figma's arrows + dash indicators) --------------------------- */
.pager {
  margin-top: 60px;
  display: flex;
  align-items: center;
  gap: 4px;
  opacity: 0; /* driven by JS */
  will-change: opacity, translate;
}
.pager__arrow {
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  padding: 0;
  border: 0;
  background: none;
  color: #fff;
  cursor: pointer;
  transition: opacity 0.2s ease, scale 0.15s ease;
}
.pager__arrow svg { width: 20px; height: 20px; display: block; }
.pager__arrow:hover { opacity: 0.65; }
.pager__arrow:active { scale: 0.9; }
.pager__dashes { display: flex; align-items: center; gap: 5px; padding: 10px 12px; }
.pager__dash {
  height: 2px;
  width: 12px;
  padding: 0;
  border: 0;
  background: #575757;
  cursor: pointer;
  transition: width 0.3s cubic-bezier(0.32, 0.72, 0, 1), background 0.3s ease;
}
.pager__dash[aria-selected="true"] { width: 33px; background: #fff; }
/* pale field: #575757 vanishes there (Figma switches to Fill/Fill-2) */
.pager--light .pager__dash { background: #e3e3e3; }
.pager--light .pager__dash[aria-selected="true"] { background: #fff; }
/* the pager is interactive even though the info block is inert-managed */
.product__info .pager,
.matcha__info .pager { pointer-events: auto; }

/* The CTA and pager sit at a FIXED height (Wei): each copy part reserves its
   tallest variant's box (measured at the design column width, 378px), so a
   shorter title/desc/tag-set leaves a gap instead of pulling everything up.
   Desktop only — the mobile layout centres the copy and parks the pager first,
   where it is already position-stable. */
@media (min-width: 641px) {
  .product__desc { min-height: 88px; }              /* 4 lines */
  .product__info .fan-tags { min-height: 72px; }    /* 2 rows */
  .matcha__title { min-height: 80px; }              /* 2 lines */
  .matcha__desc { min-height: 110px; }              /* 5 lines */
  .matcha__info .fan-tags { min-height: 72px; }     /* 2 rows */
}

/* Narrow screens — the fx prototype's mobile arrangement (Figma 6358:7260):
   copy centre-aligned in a bottom band, headline at H5 30px, rhythm tightened
   to 24/16/24, pager between the products and the text. */
@media (max-width: 640px) {
  .product__info,
  .matcha__info {
    left: 12px;
    right: 12px;
    width: auto;
    top: 46%;
    translate: 0 0;
    align-items: center;
    text-align: center;
  }
  .product__title,
  .matcha__title {
    font-size: 30px;
    line-height: normal;
    white-space: normal;
    margin-bottom: 24px;
  }
  .product__desc,
  .matcha__desc { max-width: none; margin-bottom: 16px; font-size: 16px; line-height: 20px; }
  .fan-tags { max-width: none; justify-content: center; margin-bottom: 24px; gap: 8px 10px; }
  .fan-tags li { font-size: 14px; line-height: 18px; }
  .pager { order: -1; margin-top: 0; margin-bottom: 28px; }
}

/* ==========================================================================
   Matcha — Figma node 6008:9472 (flat light green, no grid)
   ========================================================================== */
.matcha {
  position: relative;
  z-index: 4; /* paints beneath the product stage during the dissolve */
  margin-top: -200vh; /* overlap the product stage's last pinned 100vh */
  /* dissolve-in + pouch intro/stand-up + copy + lineup + carousel dwell */
  height: 450vh;
  /* NO background on the section — it would keep occluding the Alles stage
     after the sticky has faded (the same trap as .product). The green lives on
     .matcha__bg inside the sticky, which is what morphs and fades. */
  overflow-x: clip; /* see .hero */
}
.matcha__sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100lvh; /* LARGE viewport: dvh resized all six stickies on every frame of the phone URL-bar animation — the whole-screen judder on flicks */
  overflow: hidden;
}
.matcha__bg {
  position: absolute;
  inset: 0;
  background: linear-gradient(
      to bottom,
      rgba(128, 128, 128, 0.2) 2.404%,
      rgba(0, 0, 0, 0.2) 99.038%
    ),
    #c5d5ab;
}
/* The Alles background, cross-faded in over the green one. Ending on exactly
   the next stage's background is what makes the hand-off edgeless: by the time
   this stage fades out, it already looks like the one underneath. */
.matcha__wash {
  position: absolute;
  inset: 0;
  opacity: 0; /* driven by JS */
  background: #ffffff;
  will-change: opacity;
}
/* Soft light blooming up through the wash so the colour change reads as a
   fluid wash instead of a flat dip. Transform + opacity only. */
.matcha__bloom {
  position: absolute;
  left: -35%;
  right: -35%;
  top: -35%;
  bottom: -35%;
  opacity: 0; /* driven by JS */
  /* Out-of-focus cloud field, after rayraylab.com: a handful of very large
     soft blobs, blurred past recognition and drifting slowly, so the colour
     change arrives as weather rather than as a fade. The blur is what sells
     it — without it these read as gradients. */
  background: radial-gradient(
      42% 38% at 22% 28%,
      rgba(255, 255, 255, 0.95),
      rgba(255, 255, 255, 0) 70%
    ),
    radial-gradient(
      50% 44% at 74% 18%,
      rgba(255, 255, 255, 0.88),
      rgba(255, 255, 255, 0) 72%
    ),
    radial-gradient(
      46% 40% at 58% 76%,
      rgba(255, 255, 255, 0.92),
      rgba(255, 255, 255, 0) 68%
    ),
    radial-gradient(
      38% 34% at 12% 82%,
      rgba(255, 255, 255, 0.8),
      rgba(255, 255, 255, 0) 70%
    );
  filter: blur(70px);
  will-change: opacity, transform;
  animation: cloud-drift 26s ease-in-out infinite alternate;
}
@keyframes cloud-drift {
  from {
    transform: translate3d(-3%, 2%, 0) scale(1.04) rotate(-2deg);
  }
  to {
    transform: translate3d(4%, -3%, 0) scale(1.12) rotate(3deg);
  }
}
/* element layout widths = tight design px of each still in the lineup */
.matcha__item--bar {
  width: 181.47px;
}
.matcha__item--pouch {
  width: 560px; /* its largest state: the tilted intro */
}

/* ==========================================================================
   Alle producten — Figma node 5858:13651
   ========================================================================== */
.alles {
  position: relative;
  z-index: 3;
  /* No cross-fade with the matcha stage — that reads as one page dissolving
     under another. The matcha stage washes itself to white and scrolls away
     completely under its own scroll, carrying its products out with it, and
     this stage is revealed behind them. Both sides of the seam are the same
     white, so the boundary itself stays invisible. */
  height: 360vh;
  /* Starts one viewport before the matcha track ends, so the matcha stage's
     scroll-away and this stage's arrival are the same motion instead of two
     separated beats with a blank white screen between them. Matcha still
     leaves completely under its own scroll — this stage is revealed behind it,
     it does not cover it. */
  margin-top: -100vh;
  /* NO background on the section — it would occlude the Achter de schermen
     stage beneath once this sticky fades (the same trap as .product/.matcha). */
  overflow-x: clip;
  /* Skip rendering this stage's subtree while it is off screen. With five
     pinned stages the raster memory otherwise starves the hero's zoom, which
     scales a large plate up ~15x. Verified not to disturb the sticky. */
  content-visibility: auto;
}
.alles__sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100lvh; /* LARGE viewport: dvh resized all six stickies on every frame of the phone URL-bar animation — the whole-screen judder on flicks */
  overflow: hidden;
  /* The green sliver at the top is what is left of the matcha stage; the body
     colour is a variable so the next hand-off can darken it into the grey of
     the Achter de schermen field without a seam. */
  /* Pure white: the product row reads against nothing. The green belongs to
     the stage above, which disposes of it before scrolling out. */
  background: var(--alles-field, #ffffff);
}
/* No green band at the top any more (Wei): the Alles stage is pure white;
   the matcha stage above simply scrolls away over it. */

/* ---- Copy (design 1440×750) -------------------------------------------- */
.alles__info {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
}
/* H3-S — Blender Bold 83.33px / 0.8 / −2%, dark ink with a white bloom that
   only shows against the green band */
.alles__title {
  position: absolute;
  left: 51.458%;
  top: 15.333%;
  transform: translateX(-50%);
  margin: 0;
  width: min(94vw, 1200px);
  font-size: clamp(40px, 5.787vw, 120px);
  font-weight: 700;
  line-height: 0.8;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  text-align: center;
  color: var(--color-ink, #121212);
  text-shadow: 0 0 0.099em #fff, 0 0 0.197em #fff, 0 0 0.691em #fff,
    0 0 1.5em #fff, 0 0 2.2em #fff;
  opacity: 0; /* driven by JS */
  will-change: opacity, translate;
}
/* B1 — Blender Medium 26/30, −2% */
.alles__sub {
  position: absolute;
  left: 51.49%;
  top: 25.6%;
  transform: translateX(-50%);
  margin: 0;
  font-size: clamp(16px, 1.806vw, 32px);
  font-weight: 500;
  line-height: 1.154;
  letter-spacing: -0.02em;
  white-space: nowrap;
  color: var(--color-ink, #121212);
  opacity: 0; /* driven by JS */
  will-change: opacity, translate;
}
/* Button/Primary, inverted for the light stage */
.alles__cta {
  position: absolute;
  left: 45.2%;
  top: 32.756%;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 160px;
  height: 48px;
  background: var(--color-ink, #121212);
  color: #fff;
  text-decoration: none;
  font-size: 18px;
  font-weight: 400;
  line-height: 22px;
  letter-spacing: -0.02em;
  opacity: 0; /* driven by JS */
  pointer-events: none;
  transition: transform 0.25s cubic-bezier(0.32, 0.72, 0, 1);
  will-change: opacity, translate;
}
.alles__cta.is-live {
  pointer-events: auto;
}
.alles__cta:hover {
  transform: scale(1.02);
}
.alles__cta:active {
  transform: scale(0.98);
}

/* ---- Slow endless marquee ---------------------------------------------- */
.alles__marquee {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 8.1355%; /* row bottom at design y 689 of 750 */
  height: 24.9306vw; /* tallest item: the pouch, 359 design px (Figma 6362:7394) */
}
.alles__track {
  position: absolute;
  left: 0;
  top: 0;
  /* Two tiles. The period is the row's right edge (99.5834vw) plus one gap,
     so the clone starts exactly one gap after the original ends — anything else
     shows a seam or a stutter once per cycle. Recomputed when the row changed:
     it is NOT a free number. */
  width: 203.3334vw;
  height: 100%;
  /* one tile per cycle, drifting at roughly 25 design px per second. Parked
     until the stage is on screen so it costs nothing for the rest of the page. */
  animation: alles-drift 66s linear infinite paused;
}
.alles.is-drifting .alles__track {
  animation-play-state: running;
}
.alles__row {
  position: absolute;
  left: 0;
  top: 0;
  width: 101.6667vw; /* one tile — see .alles__track */
  height: 100%;
}
.alles__prod {
  position: absolute;
  left: var(--x);
  /* BOTTOM-anchored, not top. Figma's row is bottom-aligned, and heights here
     come from each still's real aspect — which differs from Figma's drawn boxes
     (the re-cut Banaan bar is 0.370 against a drawn 0.448), so computing a top
     offset from the drawn heights left every re-cut item hanging below the
     line. Anchoring the bottom makes alignment independent of the aspect. */
  bottom: 0;
  width: var(--w);
  height: auto;
  /* no cast shadow — see .product__item img */
}
/* Exactly ONE TILE per cycle — must equal .alles__row's width or the loop
   jumps once per period. */
@keyframes alles-drift {
  to {
    transform: translateX(-101.6667vw);
  }
}

@media (max-width: 640px) {
  /* Figma 6390:7600 — H4 Medium 36/40 −2%, sub B3 Book 20/24 */
  .alles__title {
    font-size: 36px;
    font-weight: 500;
    line-height: 40px;
  }
  .alles__sub {
    font-size: 20px;
    font-weight: 400;
    line-height: 24px;
    white-space: normal;
    width: 86vw;
    text-align: center;
  }
  .alles__cta {
    left: 50%;
    margin-left: -80px;
  }
  /* The row is sized off vw, so a phone renders it at ~27%. JS scales it back
     up (MARQUEE_SCALE); it grows from its own bottom edge so it stays anchored
     where the design puts it. */
  .alles__marquee {
    bottom: 14%;
    transform-origin: 50% 100%;
  }
  /* scaled 2.5x, so the same duration would drift 2.5x faster on screen */
  .alles__track {
    animation-duration: 165s;
  }
}

/* ==========================================================================
   Achter de schermen — Figma node 6084:9537
   Stage treatment borrowed from melius.com's hero: a flat grey field printed
   with a fine dot grid that lights up around the cursor.
   ========================================================================== */
.bts {
  position: relative;
  z-index: 2; /* Alles morphs into this one; it hands off to .sale */
  margin-top: -200vh; /* overlap the Alles stage's last pinned 100vh */
  /* 760vh: the launch walks NINE photos (Figma 6084:9537). Lengthened twice —
     450 -> 640 -> 760 — because they still went by faster than they could be
     read; with the wider dolly window in applyBts this is about 2x slower than
     the first pass. */
  height: 760vh;
  /* NO background on the section — it would occlude the stage beneath once
     this sticky fades. Fourth time this bit: base colours belong on the
     .*__sticky, never on the section. */
  overflow-x: clip;
  /* Skip rendering this stage's subtree while it is off screen. With five
     pinned stages the raster memory otherwise starves the hero's zoom, which
     scales a large plate up ~15x. Verified not to disturb the sticky. */
  content-visibility: auto;
}
.bts__sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100lvh; /* LARGE viewport: dvh resized all six stickies on every frame of the phone URL-bar animation — the whole-screen judder on flicks */
  overflow: hidden;
  background: var(--bts-field, #e3e3e3);
}

/* ---- Dot field ---------------------------------------------------------- */
.bts__field {
  position: absolute;
  inset: 0;
  opacity: 0; /* driven by JS */
}
/* melius.com's exact tile: 15px grid, 2px rounded dot, black at 8% */
.bts__dots {
  position: absolute;
  inset: 0;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Crect x='6.5' y='6.5' width='2' height='2' rx='1' ry='1' fill='rgb(0,0,0)' fill-opacity='0.08'/%3E%3C/svg%3E");
  background-size: 15px 15px;
}
/* Same grid, printed harder, shown only inside a disc that follows the
   pointer — so the dots themselves light up rather than a blob sliding over
   them. --mx/--my are the pointer position, set on pointermove. */
.bts__dots--hot {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Crect x='6' y='6' width='3' height='3' rx='1.5' ry='1.5' fill='rgb(0,0,0)' fill-opacity='0.3'/%3E%3C/svg%3E");
  mask-image: radial-gradient(
    circle 220px at var(--mx, -300px) var(--my, -300px),
    #000 0%,
    rgba(0, 0, 0, 0.55) 45%,
    transparent 78%
  );
  opacity: 0; /* raised once the pointer is over the stage */
  transition: opacity 0.35s ease;
}
.bts__sticky.is-lit .bts__dots--hot {
  opacity: 1;
}
/* soft light bloom under the cursor, moved by transform only */
.bts__glow {
  position: absolute;
  left: 0;
  top: 0;
  width: 620px;
  height: 620px;
  margin: -310px 0 0 -310px;
  border-radius: 50%;
  background: radial-gradient(
    circle,
    rgba(255, 255, 255, 0.75) 0%,
    rgba(255, 255, 255, 0.35) 40%,
    rgba(255, 255, 255, 0) 70%
  );
  opacity: 0;
  transition: opacity 0.4s ease;
  will-change: transform;
}
.bts__sticky.is-lit .bts__glow {
  opacity: 1;
}

/* ---- Fan of panels (melius.com's hero form) -----------------------------
   Eight panels sit in a tight strip at the centre and fan outward as you
   scroll: each one grows and swings toward the viewer, so the row reads as a
   deck opening. JS writes the transforms; positions are cumulative so the
   panels always touch, at any opening amount. */
/* ---- Photo launch (after charlesleclerc.com) -----------------------------
   A z-tunnel: the container owns the perspective, each photo is a plane that
   JS pushes along translateZ. Depth SORTING is static — photo 0 is always the
   nearest in world space, so its z-index is set once at build and never
   flipped mid-flight. The pass-by fade is opacity only; anything past the
   camera is visibility-hidden so a viewport-filling plane is never rastered
   for nothing. */
.bts__tunnel {
  position: absolute;
  inset: 0;
  perspective: 1200px;
  perspective-origin: 50% 46%;
  z-index: 8;
  /* entrance drop + shared exit travel ride the container, opacity too */
  will-change: transform, opacity;
}
.bts__photo {
  position: absolute;
  left: 50%;
  top: 50%;
  /* the reference's featured photo owns most of the frame's height */
  height: 72vh;
  margin: 0;
  will-change: transform, opacity;
}
.bts__photo img {
  display: block;
  height: 100%;
  width: auto;
  border-radius: 4px;
  user-select: none;
  -webkit-user-drag: none;
  /* the idle breath, same measured amplitude family as the product fans;
     tiny, because a photo is a document rather than an object */
  animation: fanBob 8s ease-in-out infinite paused;
}
.bts__photo:nth-child(2n) img { animation-duration: 9.4s; animation-delay: -3.1s; }
.bts__photo:nth-child(3n) img { animation-duration: 7.2s; animation-delay: -5.2s; }
.is-live .bts__photo img { animation-play-state: running; }

/* Featured location — fixed at the centre like the reference (Wei's call
   after seeing the ticker: the streaming text read as clutter, the reference
   simply holds it centred). Figma 6084:9537's type: Blender Medium 24/28, -2%.
   The FLIP is driven by JS on the same scrub value that swaps the text: it
   tips away (rotateX) as the dolly leaves one photo and tips back in with the
   next photo's name — scrub-safe, no timers, reverses cleanly. */
.bts__loc {
  position: absolute;
  left: 50%;
  bottom: 7vh;
  translate: -50% 0;
  margin: 0;
  z-index: 9;
  font-size: 24px;
  font-weight: 500;
  line-height: 28px;
  letter-spacing: -0.02em;
  color: #121212;
  white-space: nowrap;
  transform-origin: 50% 100%;
  opacity: 0; /* opacity + rotateX driven by JS */
  will-change: opacity, transform;
}

.bts__title {
  position: absolute;
  left: 50%;
  top: 24%;
  transform: translate(-50%, -50%);
  margin: 0;
  width: min(94vw, 1200px);
  font-size: clamp(40px, 5.787vw, 120px);
  font-weight: 700;
  line-height: 0.8;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  text-align: center;
  color: var(--color-ink, #121212);
  text-shadow: 0 0 8.224px #fff, 0 0 16.447px #fff, 0 0 57.566px #fff,
    0 0 115.132px #fff, 0 0 197.37px #fff;
  pointer-events: none;
  opacity: 0; /* driven by JS */
  will-change: opacity, translate;
  z-index: 5;
}

@media (max-width: 640px) {
  /* the tunnel photos scale with vh, so only the caption needs a nudge */
  .bts__photo { height: 52vh; }
  .bts__loc { bottom: 5vh; font-size: 18px; line-height: 22px; }
  .bts__title {
    top: 22%;
    font-size: 30px;
  }
}

/* ==========================================================================
   Laatste kans — Figma node 6024:9517
   A draggable 2D product map on the same grey dot field as the photo wall,
   after ciggies.app: grab anywhere, pan, and a flick keeps going.
   ========================================================================== */
.sale {
  position: relative;
  z-index: 1; /* last stage — nothing dissolves over it */
  margin-top: -200vh; /* overlap the photo wall's last pinned 100vh */
  /* 400vh keeps the hand-off exactly 100vh (enter = vh/(H − vh) = 1/3) */
  height: 340vh;
  overflow-x: clip;
  content-visibility: auto;
}
.sale__sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100lvh; /* LARGE viewport: dvh resized all six stickies on every frame of the phone URL-bar animation — the whole-screen judder on flicks */
  overflow: hidden;
  background: #e3e3e3;
  cursor: grab;
  /* vertical stays page scroll on touch; horizontal drag comes to the board */
  touch-action: pan-y;
}
.sale__sticky.is-dragging {
  cursor: grabbing;
}
.sale__field {
  position: absolute;
  inset: 0;
  opacity: 0; /* driven by JS */
}

/* ---- The board ---------------------------------------------------------- */
.sale__board {
  position: absolute;
  left: 0;
  top: 0;
  width: 2756px; /* 9 columns since the Kokoswater card (299px pitch) */
  height: 1171px;
  transform-origin: 0 0; /* JS scales the board on narrow screens */
  will-change: transform;
  /* Without these, a press that lands on a card selects its text and starts a
     native link/image drag, which steals the gesture and leaves a ghost stuck
     to the cursor — the board would only be grabbable in the gaps. */
  user-select: none;
  -webkit-user-select: none;
  -webkit-user-drag: none;
  /* The stills are shot on white. Multiplying the WHOLE board into the grey
     dot field drops that white away, so the products sit on the field instead
     of arriving as tiles that hide it. It has to sit here rather than on the
     images: an ancestor with its own opacity or will-change forms an isolated
     blending group, and inside one the images would only multiply against the
     board's own transparent backdrop — i.e. not at all. That is also why this
     stage reveals by sliding rather than fading. */
  mix-blend-mode: multiply;
}
.sale__card {
  position: absolute;
  left: var(--x);
  top: var(--y);
  width: 299px;
  text-decoration: none;
  text-align: center;
  transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
}
.sale__card img {
  display: block;
  width: 299px;
  height: 299px;
  object-fit: cover;
  -webkit-user-drag: none;
  user-select: none;
  -webkit-user-drag: none;
}
.sale__name {
  display: block;
  margin-top: 6px;
  font-size: 18px;
  font-weight: 500;
  line-height: 22px;
  letter-spacing: -0.02em;
  color: #000;
}
.sale__price {
  display: flex;
  gap: 8px;
  justify-content: center;
  margin-top: 6px;
  font-size: 16px;
  line-height: 20px;
  letter-spacing: -0.02em;
}
.sale__price s {
  color: #8d8d8d;
}
.sale__price b {
  font-weight: 400;
  color: #ff5a00; /* Brand/Brand-1 */
}
.is-live .sale__card:hover {
  transform: scale(1.03);
}

/* ---- Copy sitting in the gap in the middle of the grid ------------------ */
.sale__copy {
  position: absolute;
  left: 1226.5px;
  top: 488px;
  width: 455px;
  transform: translateX(-50%);
  text-align: center;
}
.sale__lead {
  margin: 0;
  font-size: 36px;
  font-weight: 500;
  line-height: 40px;
  letter-spacing: -0.02em;
  color: var(--color-ink, #121212);
  text-shadow: 0 0 8.224px #fff, 0 0 16.447px #fff, 0 0 57.566px #fff,
    0 0 115.132px #fff, 0 0 197.37px #fff;
}
.sale__cta {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 160px;
  height: 48px;
  margin: 28px auto 0;
  background: var(--color-ink, #121212);
  color: #fff;
  text-decoration: none;
  font-size: 18px;
  line-height: 22px;
  letter-spacing: -0.02em;
  transition: transform 0.25s cubic-bezier(0.32, 0.72, 0, 1);
}
.sale__cta:hover {
  transform: scale(1.02);
}
.sale__cta:active {
  transform: scale(0.98);
}
/* Phone board — Figma 6391:4187: 162px cards on a 178/233 grid, 9 cols x 3
   rows with the copy in a 4-cell hole, drawn 1:1 (no zoom) so a 390px screen
   shows ~2.2 columns exactly as the frame does. Desktop keeps the approved
   299px layout above; each card carries both coordinate sets. */
@media (max-width: 640px) {
  .sale__board {
    width: 1666px;
    height: 762px;
  }
  .sale__card {
    left: var(--mx);
    top: var(--my);
    width: 162px;
  }
  .sale__card img {
    width: 162px;
    height: 162px;
  }
  .sale__copy {
    left: 922px;
    top: 283px;
  }
}

/* ---- Drag affordance --------------------------------------------------- */
.sale__hint {
  position: absolute;
  left: 50%;
  bottom: 34px;
  transform: translateX(-50%);
  margin: 0;
  padding: 8px 16px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.72);
  backdrop-filter: blur(6px);
  font-size: 13px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: #4a4a4a;
  pointer-events: none;
  opacity: 0; /* driven by JS */
  transition: opacity 0.5s ease;
}

@media (max-width: 640px) {
  .sale__lead {
    font-size: 24px;
    line-height: 28px;
  }
}

/* ==========================================================================
   Reduced motion: show the settled composition, skip the ride
   ========================================================================== */
@media (prefers-reduced-motion: reduce) {
  .hero,
  .product,
  .matcha,
  .alles,
  .bts,
  .sale {
    height: 100vh;
    height: 100lvh; /* LARGE viewport: dvh resized all six stickies on every frame of the phone URL-bar animation — the whole-screen judder on flicks */
  }
  /* no dissolve overlap — keep every screen reachable */
  .product,
  .matcha,
  .alles,
  .bts,
  .sale {
    margin-top: 0;
  }
  .alles__track {
    animation: none;
  }
  .hero__hint i {
    animation: none;
  }
  .tv-loading__fill {
    animation: none;
    width: 40%;
  }
}

/* ==========================================================================
   Touch profile — a phone is scrolling on the same GPU that draws all this,
   so anything it cannot use is removed rather than merely hidden.
   Gated on `.is-touch` (no hover + coarse pointer), NOT on width: a narrow
   window on a laptop still has a cursor and keeps the full treatment.
   ========================================================================== */

/* Cursor-only effects. These can never fire without a pointer, yet the hot dot
   layer carries a full-viewport radial mask and the glow a 620px gradient —
   two composited layers and two paints for nothing. */
.is-touch .bts__dots--hot,
.is-touch .bts__glow {
  display: none;
}

/* The scanline overlay is a repeating gradient over a plane the camera scales
   ~15x, and it re-rasters whenever --rpx changes. The glare and vignette are
   cheap and stay; only the stripes go. */
.is-touch .tv-screen__glass::before {
  display: none;
}

/* backdrop-filter is one of the most expensive things you can ask a mobile
   compositor for; a solid pill reads the same. */
.is-touch .sale__hint {
  backdrop-filter: none;
  background: rgba(255, 255, 255, 0.9);
}

/* Long-lived promotions are what make a phone run out of layer memory. The
   fading copy blocks are promoted for a fade that lasts under a second, so on
   touch they are left to the normal paint path. */
.is-touch .hero__title,
.is-touch .hero__subtitle,
.is-touch .hero__cta,
.is-touch .product__title,
.is-touch .product__desc,
.is-touch .product__cta,
.is-touch .matcha__title,
.is-touch .matcha__desc,
.is-touch .matcha__cta,
.is-touch .alles__title,
.is-touch .alles__sub,
.is-touch .alles__cta,
.is-touch .bts__title,
.is-touch .site-header {
  will-change: auto;
}
/* While a stage dissolves it carries a full-viewport blur; anything still
   animating beneath it forces a fresh full-stage raster every frame. Freeze
   the lot for the hand-off — the stage is fading out anyway. */
.is-dissolving * {
  animation-play-state: paused !important;
}
/* Inside a dissolving (blurred) stage the item drop-shadows are nested filter
   surfaces — every blur radius step re-renders the whole chain. Behind the
   fade they contribute nothing visible. */
.is-dissolving .product__item img,
.is-dissolving .matcha__item img {
  filter: none !important;
}
/* Phones: same policy as the rest of this block — no long-lived promotions.
   Nine promoted 800x1200 photo layers is exactly the thing that empties a
   mobile GPU's layer budget, and the launch was the worst band in the mobile
   profile (p90 25.4, 10 frames over 25). The photos' idle breath goes too:
   six large planes animating forever is a constant composite tax there. */
.is-touch .bts__tunnel,
.is-touch .bts__photo {
  will-change: auto;
}
.is-touch .bts__photo img {
  animation: none;
}
/* One small static shadow instead of the layered pair: blur cost is quadratic
   in radius and mobile GPUs pay it dearest. Overrides the per-seat --ds. */
.is-touch .product__item img,
.is-touch .matcha__item img {
  filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.4));
}

/* Softer dissolve blur: the radius is what costs, and it is applied to a
   full-viewport stage. */
.is-touch .hero__sticky,
.is-touch .product__sticky {
  will-change: opacity, transform;
}

/* Shorter tracks on a phone. Each stage keeps at least 330vh, because the
   hand-off math needs H >= 3.22 × vh for the dissolve zone to be a full
   viewport (enter = vh/(H − vh) must stay under its 0.45 clamp); below that the
   dissolve silently desyncs from the actual overlap. */
@media (max-width: 640px) {
  :root {
    --track-length: 480vh;
  }
  .product {
    height: 450vh;
  }
  .matcha {
    height: 400vh;
  }
  .bts {
    height: 660vh;
  }
  .alles,
  .sale {
    height: 330vh;
  }
}

/* ---- Idle float, amplitude from the tuned prototype ----------------------
   5px / 0.28deg — set by MEASURING the reference site, not by taste (its idle
   motion is mean 0.89-1.90/255 between frames ~1.2s apart; 22px/1.1deg measured
   5.95-7.16 here, four times too much and read as a bounce).
   --bob-y is published per item by placeItem as 5/k, because this element sits
   inside that scale(k) and a literal 5px would arrive shrunk and uneven. */
/* Amplitude set by MEASURING the reference site, not by taste: its idle motion
   is mean 0.89-1.90/255 between frames ~1.2s apart, and 22px/1.1deg measured
   5.95-7.16 here — four times too much, reading as a bounce. This element is
   OUTSIDE the item's scale(), so 5px means 5px on screen for every product. */
@keyframes fanBob {
  0%, 100% { transform: translate3d(0, 0, 0) rotate(0deg); }
  50%      { transform: translate3d(0, -5px, 0) rotate(0.28deg); }
}
#prodCan .fan-item__float { --bob: 6.4s; --bob-delay: 0s; }
#prodPre .fan-item__float { --bob: 7.6s; --bob-delay: -2.4s; }
#prodPump .fan-item__float { --bob: 8.4s; --bob-delay: -4.8s; }
#matchaPouch .fan-item__float { --bob: 7s; --bob-delay: 0s; }
#matchaBar .fan-item__float { --bob: 8.2s; --bob-delay: -2.4s; }
@media (prefers-reduced-motion: reduce) {
  .fan-item__float { animation: none; }
  .fan-cluster, .fan-item, .fan-item__tilt { transition: none; }
}


/* One-beat ease on the settle-drift when a stage is released early: the story
   progress jumps to its end, which moves the whole fan by its remaining drift
   (~50px) in a single frame; this turns that into a short glide. Removed again
   by JS after ~520ms so the per-frame scroll drift stays untransitioned. */
.fan-drift-ease .product__item,
.fan-drift-ease .matcha__item {
  transition:
    translate 0.45s cubic-bezier(0.22, 0.61, 0.36, 1),
    transform var(--swap, 680ms) cubic-bezier(0.32, 0.72, 0, 1);
}
