/* ---- Archivo, self-hosted ----
   Vendored for the same reason three.js is: the site should not depend on a
   third party to render its own headings, and a font that arrives late is
   visible. The headings are weight 800 at 336px, and Arial has no 800 - the
   browser fakes it, rendering about 15% narrower than Archivo (measured: 302
   against 261 at weight 900). So on a first visit the mark painted in fake-bold
   Arial and then snapped 15% wider when the webfont landed. That was the
   "fonts at the wrong size".

   `font-display: swap` is kept deliberately. With the file self-hosted and
   preloaded it wins the race in almost every case, and swap is the right
   behaviour for the rare time it does not - the text is readable throughout. */
@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 100 900;
  font-stretch: 62% 125%;
  font-display: swap;
  src: url('../assets/fonts/archivo-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
                 U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 100 900;
  font-stretch: 62% 125%;
  font-display: swap;
  src: url('../assets/fonts/archivo-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
                 U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* ============================================================
   Ibiza — background videos only. No chrome, no overlays.
   ============================================================ */

:root{
  /* One grotesque, worked hard: near-black and very tight for the display
     sizes, light and widely tracked for everything small. The gap between
     those two is the whole look. */
  --font: "Archivo", "Helvetica Neue", Helvetica, Arial, sans-serif;
  --ink: #fff;
  --ground: #43414F;

  /* Riso palette: yellow paper stock, vivid orange-red ink. Renamed off the
     old green/yellow so the names survive the next palette change. */
  --flow-ink: #F5451C;
  --flow-paper: #F2DA52;
  --flow-cut: #E1524A;     /* the softer cut-paper red, spare */
  --ink-dark: #1A0E07;     /* type on the riso ground — black on yellow, as the print does */
  --flow-cycle: 9.8s;
  --grain: .65;
  --grain-rate: .72s;      /* ~11 re-grains a second */

  /* 0 = chapter hidden, 1 = fully arrived. Driven from JS on scroll. */
  --flow-reveal: 0;
  --grain-reveal: 0;
  /* Feather widths. Generous on the colour: it is crossing a big chroma jump
     from the still's grey to the green, and a narrow edge reads as a line. */
  --flow-soft: 78vh;
  --grain-soft: 56vh;
  --flow-P: calc(var(--flow-reveal) * (100vh + var(--flow-soft)));
  --grain-P: calc(var(--grain-reveal) * (100vh + var(--grain-soft)));

  /* One screen, for the boxes that must line up with each other: the panel and
     the sticky layers pinned over it (ground, foot, sculpture, waves). On a
     phone 100vh is the LARGE viewport - toolbars hidden - so with Safari's bars
     showing, a 100vh panel runs under them and the pinned layers stop matching
     it. dvh follows the bars. On desktop the two are the same number. */
  --screen: 100vh;
}
@supports (height:100dvh){
  :root{ --screen: 100dvh; }
}

*,*::before,*::after{ box-sizing:border-box; }

html,body{
  margin:0;
  padding:0;
  background:var(--ground);    /* the tone the still settles into */
}

body{
  font-family:var(--font);
  color:var(--ink);
  -webkit-font-smoothing:antialiased;
  -moz-osx-font-smoothing:grayscale;
}

/* Scroll is a trigger, not a scrollbar — until the still is up, at which
   point the page becomes an ordinary scrolling document. */
html{
  /* Locked by overflow until the still lands (html.is-open unlocks it). For a
     while this was done by cancelling every wheel event instead, on a theory
     about Safari gesture latching that measurement disproved - the real cause
     of "Safari will not scroll into the playlist" was overscroll-behavior, see
     below. It was reverted because cancelling every event of a real trackpad
     fling coincided with Safari nearly stopping the video: 9 frames decoded
     across whole fast passes. */
  overflow:hidden;
  touch-action:none;
  /* NO overscroll-behavior here. `overscroll-behavior:none` on the root stops
     Safari from wheel-scrolling the page at all - measured in Safari 18.3 with
     real trackpad input: hundreds of downward wheel events on an unlocked,
     scrollable root, none cancelled, zero pixels moved, while scrollBy() from
     script worked fine. Switching this one rule to auto, with nothing else
     changed, and the same gesture scrolled into the playlist. Chrome ignores
     the problem entirely, which is why it only ever appeared in Safari.

     The cost is the elastic bounce at the very top when rewinding. Everywhere
     else the site already cancels the wheel itself (the lock before the still,
     the rail at the foot), so there is nothing left for a bounce to show on. */

  /* No scrollbar, ever. The page is locked until the still lands and then
     becomes scrollable — and a scrollbar appearing at that moment takes 15px
     off the layout width, which slides every centred thing 7.5px to the left.
     It read as the chapter title drifting once its animation finished. The
     stage is full-bleed, so reserving a gutter instead would leave a strip
     down the right where the video stops. Scrolling still works by wheel,
     touch and keyboard. */
  scrollbar-width:none;              /* Firefox */
}

html::-webkit-scrollbar{ width:0; height:0; }   /* Chromium, WebKit */
html.is-open{
  overflow-y:auto;
  touch-action:pan-y;
}

.stage{
  position:fixed;
  inset:0;
  background:#000;
  overflow:hidden;
}

/* While the still is up, the videos behind the page are covered in black.
   The still is what the reader sees; the stage only shows where iOS lets the
   screen run past the page - bouncing off the top, or behind its collapsed
   toolbar at the foot - and there the paused last frame read as a strip of
   misplaced rock. Black matches Safari's own bars. Covered, not hidden: the
   rewind lifts it in the same frame the still starts to fade. */
.stage::after{
  content:'';
  position:absolute;
  inset:0;
  background:#000;
  opacity:0;
  pointer-events:none;
}
html.is-still .stage::after{ opacity:1; }

.stage__video{
  position:absolute;
  inset:0;
  width:100%;
  height:100%;
  object-fit:cover;
  transform:translateZ(0);     /* keep both layers on the GPU */
  pointer-events:none;
}

/* Underneath: always fully opaque, never animated. */
.stage__video--under{ opacity:1; }

/* On top: the only layer that fades. Cross-dissolving a single layer over
   an opaque one below gives a·top + (1-a)·bottom — always 100% coverage.
   Fading BOTH layers over the stage would dip to 75% mid-transition and
   flash the black background through. */
.stage__video--over{
  opacity:1;
  will-change:opacity;
  transition:opacity 180ms linear;
}

.stage__video--over.is-hidden{ opacity:0; }


/* ============================================================
   masked reveal — text rises out from behind its own edge
   ============================================================ */

/* .rv is the clip box; .rv__i is what moves inside it.

   It needs room on every side. Vertically the display line-height of 0.82 is
   tighter than the glyphs, so a box sized to the line shears the bottoms off.
   Horizontally the -0.045em tracking shrinks each inline-block by that much,
   and `A` is the one letter whose ink reaches its full advance — its bottom
   right vertex landed exactly on the clip edge. Padding opens the box out and
   the matching negative margins keep layout where it was. */
.rv{
  display:inline-block;
  overflow:hidden;
  vertical-align:bottom;
  padding:.26em .12em .18em;
  margin:-.26em -.12em -.18em;
}

.rv__i{
  display:inline-block;
  --rvY:calc(100% + .5em);     /* far enough to clear the padded box */
  transform:translateY(var(--rvY));
  will-change:transform;
  /* leaving: quick and all at once */
  transition:transform 360ms cubic-bezier(.4,0,.2,1);
  transition-delay:0ms;
}

/* arriving: slow, eased long, and staggered word by word */
.is-revealed .rv__i{
  transform:translateY(0);
  transition:transform 1150ms cubic-bezier(.16,1,.3,1);
  transition-delay:var(--rvd, 0ms);
}

/* The wordmark lifts rather than launches: a short travel carried by a fade,
   so the letters still arrive one by one without the long throw. */
.hero__word .rv__i{
  --rvY:34%;
  opacity:0;
  transition:transform 300ms cubic-bezier(.4,0,.2,1),
             opacity   240ms linear;
}

.is-revealed .hero__word .rv__i{
  opacity:1;
  transition:transform 900ms cubic-bezier(.16,1,.3,1) var(--rvd, 0ms),
             opacity   520ms ease var(--rvd, 0ms);
}

/* ============================================================
   hero
   ============================================================ */

.hero{
  position:fixed;
  inset:0;
  z-index:2;                   /* over the videos, under the still */
  display:flex;
  flex-direction:column;
  justify-content:center;
  align-items:flex-start;
  padding:0 clamp(1.5rem, 6vw, 7rem);
  pointer-events:none;         /* never in the way of the sound button */
  transition:opacity 420ms ease, transform 420ms ease;
}

/* Pinned to the viewport, not to the hero. It used to live inside .hero,
   which carries a translateY on its way out — that dragged the scrim up and
   left a pale band along the bottom of the screen. */
.scrim{
  position:fixed;
  left:0; right:0; bottom:0;
  height:30vh;
  z-index:1;
  pointer-events:none;
  background:linear-gradient(to bottom,
    rgba(4,13,24,0)    0%,
    rgba(4,13,24,.30) 42%,
    rgba(4,13,24,.66) 74%,
    rgba(4,13,24,.90) 100%);

  /* grows up out of the bottom edge rather than fading in flat */
  transform:scaleY(0);
  transform-origin:bottom;
  opacity:0;
  transition:transform 760ms cubic-bezier(.16,1,.3,1), opacity 420ms ease;
}

.scrim.is-on{ transform:scaleY(1); opacity:1; }

/* lifted off centre so the weight sits high and the space falls below it */
.hero__word{
  position:relative;           /* joins the painted-above group, over the scrim */
  /* Archivo's heavy "I" carries a 0.075em left side bearing against 0.019em on
     the strapline's "T", so flush boxes still look misaligned. Pull each back
     by its own bearing and the painted edges line up. */
  margin:0 0 0 -0.075em;
  transform:translateY(-11vh);
  font-size:clamp(4rem, 20vw, 21rem);
  font-weight:800;
  font-stretch:105%;
  letter-spacing:-0.045em;
  line-height:0.82;
  text-transform:uppercase;
}

/* dropped to the bottom corner, opposite the scroll cue */
.hero__line{
  position:absolute;
  left:clamp(1.5rem, 6vw, 7rem);
  bottom:clamp(2rem, 7vh, 4.5rem);
  margin:0 0 0 -0.019em;
  max-width:min(78vw, 46rem);
  font-size:clamp(0.66rem, 1.05vw, 0.95rem);
  font-weight:400;
  font-stretch:100%;
  letter-spacing:0.3em;
  text-transform:uppercase;
}

.hero__accent{ color:#EDFF4C; }

/* ---- scroll cue, shared by the hero and the chapter head ---- */
.cue{
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:1.75rem;
}

.cue__text{
  font-size:0.6rem;
  font-weight:400;
  letter-spacing:0.42em;
  text-indent:0.42em;          /* optical centring against the tracking */
  text-transform:uppercase;
}

.cue__arrow{
  animation:arrowBob 2.2s cubic-bezier(.7,0,.3,1) infinite;
}

@keyframes arrowBob{
  0%   { transform:translateY(-30%); opacity:0; }
  35%  { transform:translateY(0);    opacity:1; }
  70%  { transform:translateY(38%);  opacity:1; }
  100% { transform:translateY(60%);  opacity:0; }
}

/* the hero keeps its own quieter cue — a line that runs down and fades, with
   the asymmetry against the wordmark doing the work of breaking the grid */
.hero__cue{
  position:absolute;
  right:clamp(1.5rem, 6vw, 7rem);
  bottom:clamp(2rem, 7vh, 4.5rem);
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:0.85rem;
  opacity:.9;
}

.hero__cueText{
  font-size:0.6rem;
  font-weight:400;
  letter-spacing:0.42em;
  text-indent:0.42em;          /* optical centring against the tracking */
  text-transform:uppercase;
}

.hero__cueLine{
  width:1px;
  height:52px;
  background:linear-gradient(to bottom, rgba(255,255,255,.9), rgba(255,255,255,0));
  transform-origin:top;
  animation:cueLine 2.4s cubic-bezier(.7,0,.3,1) infinite;
}

@keyframes cueLine{
  0%   { transform:scaleY(0); opacity:0; }
  40%  { transform:scaleY(1); opacity:1; }
  100% { transform:scaleY(1) translateY(52px); opacity:0; }
}

/* out of the way the moment the second video takes over */
.hero.is-out{
  opacity:0;
  transform:translateY(-3vh);
}

/* ============================================================
   the still, and the page below it
   ============================================================ */

.after{
  position:relative;           /* normal flow, so it gives the page its height */
  z-index:3;
  background:#43414F;
  opacity:0;
  pointer-events:none;
  transition:opacity 180ms linear;   /* JS reads this back, don't hardcode it there */
}

.after.is-on{ opacity:1; pointer-events:auto; }

/* the window the still is drawn into — height comes from JS */
.after__still{
  position:relative;
  overflow:hidden;             /* clips the still's offsets */
}

/* ============================================================
   the chapter below the still
   ============================================================ */

.chapter{
  position:relative;
  /* Pulled up over the tail of the still so the arrival can happen *during*
     the still rather than as a cut at its edge. Nothing here paints a
     background — the masked band is the only thing that does, which is what
     lets the still show through until the reveal covers it. */
  margin-top:-160vh;
  background:transparent;
  /* clip, never hidden. `overflow: hidden` makes this a scroll container, which
     silently disables position:sticky on every descendant - that is what once
     left the band parked at the top of the chapter instead of pinned to the
     viewport. `overflow: clip` clips without becoming a scroller, so the band
     still sticks.

     It has to clip something, though: the band is sticky and a full viewport
     tall, so through the last screenful it hangs past the chapter's own bottom.
     That overflow was adding ~159px of scroll - just enough to carry the panel
     off the foot of the screen and unstick the meta line. Clipped, the document
     now ends exactly where the section does. */
  overflow:clip;
}

/* ---- the flowing gradient ----
   Measured off the reference: a pure vertical green<->yellow ramp with no
   horizontal component (left and right edges match at every sampled frame),
   scrolling upward on a 9.8s cycle (autocorrelation 0.988).

   The band is sticky to the viewport rather than stretched down the whole
   chapter, so the motion reads as time passing rather than as a reaction to
   scrolling — which is how it behaves in the reference. */
.chapter__flow{
  position:sticky;
  top:0;
  height:var(--screen);
  pointer-events:none;
  margin-bottom:calc(-1 * var(--screen));   /* keeps the sticky box out of the height sum */
}

/* The mask lives here rather than on the band: the band is 600vh and is being
   translated, so a mask on it would slide along with it. This box holds still. */
.chapter__flowWrap{
  position:absolute;
  inset:0;
  overflow:hidden;
  -webkit-mask-image:linear-gradient(to top,
      rgba(0,0,0,1.000) calc(var(--flow-P) - 1.00 * var(--flow-soft)),
      rgba(0,0,0,0.947) calc(var(--flow-P) - 0.86 * var(--flow-soft)),
      rgba(0,0,0,0.809) calc(var(--flow-P) - 0.72 * var(--flow-soft)),
      rgba(0,0,0,0.619) calc(var(--flow-P) - 0.58 * var(--flow-soft)),
      rgba(0,0,0,0.425) calc(var(--flow-P) - 0.45 * var(--flow-soft)),
      rgba(0,0,0,0.255) calc(var(--flow-P) - 0.33 * var(--flow-soft)),
      rgba(0,0,0,0.124) calc(var(--flow-P) - 0.22 * var(--flow-soft)),
      rgba(0,0,0,0.046) calc(var(--flow-P) - 0.13 * var(--flow-soft)),
      rgba(0,0,0,0.010) calc(var(--flow-P) - 0.06 * var(--flow-soft)),
      rgba(0,0,0,0.000) var(--flow-P));
  mask-image:linear-gradient(to top,
      rgba(0,0,0,1.000) calc(var(--flow-P) - 1.00 * var(--flow-soft)),
      rgba(0,0,0,0.947) calc(var(--flow-P) - 0.86 * var(--flow-soft)),
      rgba(0,0,0,0.809) calc(var(--flow-P) - 0.72 * var(--flow-soft)),
      rgba(0,0,0,0.619) calc(var(--flow-P) - 0.58 * var(--flow-soft)),
      rgba(0,0,0,0.425) calc(var(--flow-P) - 0.45 * var(--flow-soft)),
      rgba(0,0,0,0.255) calc(var(--flow-P) - 0.33 * var(--flow-soft)),
      rgba(0,0,0,0.124) calc(var(--flow-P) - 0.22 * var(--flow-soft)),
      rgba(0,0,0,0.046) calc(var(--flow-P) - 0.13 * var(--flow-soft)),
      rgba(0,0,0,0.010) calc(var(--flow-P) - 0.06 * var(--flow-soft)),
      rgba(0,0,0,0.000) var(--flow-P));
}

.chapter__flowBand{
  position:absolute;
  display:block;
  left:0;
  top:-200vh;
  width:100%;                  /* an <img>: left+right alone would leave it at its bitmap's width */
  height:600vh;                /* tall enough that the travel never runs out */
  /* A bitmap, not a CSS gradient. As a gradient this was a 600vh layer drawn
     at full Retina resolution, twice over - about 120MB of backing store per
     band - and under load Safari would drop it for a frame, which read as the
     whole ground flashing to black (caught at 60fps: one frame, both bands,
     nothing else on the page touched). main.js now paints the palette's ramp
     into a 2px-wide bitmap once and this <img> shows it; WebKit composites a
     plain image as a texture the GPU stretches, so the layer costs kilobytes
     and there are no tiles to lose. The ramp is linear between stops and so is
     the GPU's filtering, so the stretch is exact.

     And moved by main.js, not by a CSS animation. One period is 200vh, so a
     200vh translate loops seamlessly - but Safari blanked both bands for 5-7
     frames every time that animation restarted its iteration. Caught with a
     frame counter on a screen recording: two flashes exactly --flow-cycle
     apart, each on the loop point, with the page's own code idle. The bitmap
     alone did not help; the restart itself was the trigger. */
  will-change:transform;
}

/* The source of truth for the grounds, never displayed: main.js reads each
   palette's computed gradient off a hidden probe and bakes it into the bitmap
   the band above shows. */
.chapter__flowRamp{
  display:none;
  /* Not a straight ramp. Measured on the reference, the yellow sits on a broad
     plateau (~0.98 for nearly 40% of the frame) with eased shoulders either
     side; a plain green->yellow->green ramp is a triangular wave whose sharp
     turn reads as a hard line across the screen. These stops are a smoothstep
     trapezoid, proportioned from the reference's own timing: each plateau ~22%
     of the period, each shoulder ~28%. */
  /* Not a straight ramp, and not just two colours.

     The wave itself is a smoothstep trapezoid measured off the reference: the
     bright tone sits on a broad plateau with eased shoulders, where a plain
     A->B->A ramp is a triangle whose sharp turn reads as a hard line.

     Riding that plateau is a bloom — the reference brightens *and* desaturates
     there (sat 0.86 -> 0.75), a sheen washing the colour toward white rather
     than simply a lighter hue. It travels with the gradient, so it is baked
     into the repeating stops rather than overlaid. Measured desaturation at
     the peak here is 21%, against 15-24% in the reference. */
  background:repeating-linear-gradient(
    180deg,
    var(--flow-ink) 0vh,
    var(--flow-ink) 20vh,
    #F5471D 25vh,
    #F54E1F 30vh,
    #F55A24 35vh,
    #F46A29 40vh,
    #F47D31 45vh,
    #F4913A 50vh,
    #F3A644 55vh,
    #F3B94E 60vh,
    #F3C958 65vh,
    #F3D660 70vh,
    #F4DD67 75vh,
    #F4DF6C 80vh,
    #F4E06F 85vh,
    #F4E172 90vh,
    #F5E174 95vh,
    #F5E175 100vh,
    #F5E174 105vh,
    #F4E172 110vh,
    #F4E06F 115vh,
    #F4DF6C 120vh,
    #F4DD67 125vh,
    #F3D660 130vh,
    #F3C958 135vh,
    #F3B94E 140vh,
    #F3A644 145vh,
    #F4913A 150vh,
    #F47D31 155vh,
    #F46A29 160vh,
    #F55A24 165vh,
    #F54E1F 170vh,
    #F5471D 175vh,
    var(--flow-ink) 180vh,
    var(--flow-ink) 200vh);
}


/* ---- grain ----
   Two complementary speckle layers rather than one grey noise: RGB is forced
   flat and the turbulence drives *alpha*, so the layers perturb luminance in
   both directions without dragging the hue toward grey.

   This is not the obvious approach, and the obvious ones do not work here.
   Measured on this exact green at opacity 0.38: `overlay` yields sigma 1.8 and
   `soft-light` 1.6 — invisible — because on a saturated base (R=0.03, B=0.16)
   both take their multiply branch and crush the noise. Plain `source-over`
   with grey noise does perturb, but washes the colour toward grey.

   The two layers are symmetric about mid-noise (one above, one below, same
   seed), which is what keeps the mean where it started. At --grain 0.65 the
   measured result is sigma 11.2 on the green and 11.4 on the yellow, with the
   base colour drifting by only single-digit levels. */
/* The mask lives on this box, which holds still. The texture lives on a child
   that moves. That split is the whole point: when the noise and its mask sit on
   the same element, a transform shifts both together — the pattern never
   changes relative to the mask, so the grain looks frozen no matter how fast it
   is animating. Moving the texture *inside* a static mask is what makes it
   actually re-grain. */
.chapter__grain{
  position:absolute;
  inset:0;
  pointer-events:none;
  z-index:1;
  opacity:var(--grain);
  overflow:hidden;

  /* runs ahead of the colour, so the texture is what you notice first */
  -webkit-mask-image:linear-gradient(to top,
      rgba(0,0,0,1.000) calc(var(--grain-P) - 1.00 * var(--grain-soft)),
      rgba(0,0,0,0.947) calc(var(--grain-P) - 0.86 * var(--grain-soft)),
      rgba(0,0,0,0.809) calc(var(--grain-P) - 0.72 * var(--grain-soft)),
      rgba(0,0,0,0.619) calc(var(--grain-P) - 0.58 * var(--grain-soft)),
      rgba(0,0,0,0.425) calc(var(--grain-P) - 0.45 * var(--grain-soft)),
      rgba(0,0,0,0.255) calc(var(--grain-P) - 0.33 * var(--grain-soft)),
      rgba(0,0,0,0.124) calc(var(--grain-P) - 0.22 * var(--grain-soft)),
      rgba(0,0,0,0.046) calc(var(--grain-P) - 0.13 * var(--grain-soft)),
      rgba(0,0,0,0.010) calc(var(--grain-P) - 0.06 * var(--grain-soft)),
      rgba(0,0,0,0.000) var(--grain-P));
  mask-image:linear-gradient(to top,
      rgba(0,0,0,1.000) calc(var(--grain-P) - 1.00 * var(--grain-soft)),
      rgba(0,0,0,0.947) calc(var(--grain-P) - 0.86 * var(--grain-soft)),
      rgba(0,0,0,0.809) calc(var(--grain-P) - 0.72 * var(--grain-soft)),
      rgba(0,0,0,0.619) calc(var(--grain-P) - 0.58 * var(--grain-soft)),
      rgba(0,0,0,0.425) calc(var(--grain-P) - 0.45 * var(--grain-soft)),
      rgba(0,0,0,0.255) calc(var(--grain-P) - 0.33 * var(--grain-soft)),
      rgba(0,0,0,0.124) calc(var(--grain-P) - 0.22 * var(--grain-soft)),
      rgba(0,0,0,0.046) calc(var(--grain-P) - 0.13 * var(--grain-soft)),
      rgba(0,0,0,0.010) calc(var(--grain-P) - 0.06 * var(--grain-soft)),
      rgba(0,0,0,0.000) var(--grain-P));;
}

.chapter__grainTex{
  position:absolute;
  inset:-90px;                 /* oversized so the jumps never expose an edge */
  background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n' color-interpolation-filters='sRGB'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch' seed='5'/><feColorMatrix type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 -1 0 0 0 0.5'/></filter><rect width='220' height='220' filter='url(%23n)'/></svg>"), url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n' color-interpolation-filters='sRGB'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch' seed='5'/><feColorMatrix type='matrix' values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 -0.5'/></filter><rect width='220' height='220' filter='url(%23n)'/></svg>");
  background-size:220px 220px;
  animation:grainShimmer var(--grain-rate) steps(1) infinite;
  will-change:transform;
}

@keyframes grainShimmer{
  0%   { transform:translate(0,0); }
  12.5%{ transform:translate(-47px,29px); }
  25%  { transform:translate(33px,-61px); }
  37.5%{ transform:translate(-19px,-38px); }
  50%  { transform:translate(58px,44px); }
  62.5%{ transform:translate(-66px,-13px); }
  75%  { transform:translate(15px,67px); }
  87.5%{ transform:translate(41px,-49px); }
}

/* Paid back against .chapter's -160vh margin, so the band starts early but the
   content does not. Keep the two numbers in step. */
/* Pays back part of .chapter's -160vh. Deliberately *less* than the margin:
   the colour reveal finishes after one viewport of scrolling, and the rest of
   the still after that is dead scroll. Ending the lead early lets the track
   arrive right as the ground settles. */
.chapter__lead{ height:95vh; pointer-events:none; }

/* blank ground, ready for real content */
.panel{
  position:relative;
  z-index:2;                   /* above the band and its grain */
  min-height:var(--screen);
}

/* ---- the chapter head ----
   A child of <body> rather than of .after, so it can show while the second
   video is still running — inside .after it would be held at opacity 0 until
   the still faded in. No positioned ancestor, so top is measured from the top
   of the document. */
.chapterHead{
  position:absolute;
  z-index:4;
  left:50%;
  top:54vh;
  transform:translate(-50%,-50%);
  width:min(74vw, 58rem);
  display:flex;
  flex-direction:column;
  align-items:center;
  gap:clamp(3.5rem, 11vh, 7rem);
  pointer-events:none;
}

.chapter__title{
  margin:0;
  text-align:center;
  font-size:clamp(1.9rem, 5.4vw, 5.4rem);
  font-weight:800;
  font-stretch:105%;
  letter-spacing:-0.035em;
  line-height:0.94;
  text-transform:uppercase;
}

.chapterHead__cue{
  opacity:0;
  transition:opacity 500ms ease;
}

.is-revealed .chapterHead__cue{
  opacity:.85;
  transition:opacity 700ms ease 900ms;   /* lands after the words have settled */
}

/* sized and positioned from JS so its top lines up with the video's last
   frame under the same object-fit: cover geometry */
.after__rock{
  position:absolute;
  display:block;
  max-width:none;
}

/* ============================================================
   sound toggle — the only piece of interface on the page
   ============================================================ */

.sound{
  position:fixed;
  z-index:10;
  top:calc(2.4rem + env(safe-area-inset-top));
  right:clamp(1.5rem, 6vw, 7rem);   /* same axis as the scroll cue */
  width:38px;
  height:38px;
  display:grid;
  place-items:center;
  padding:0;
  border:0;
  border-radius:50%;
  background:rgba(0,0,0,.22);
  -webkit-backdrop-filter:blur(8px);
  backdrop-filter:blur(8px);
  color:#fff;
  opacity:.6;
  cursor:pointer;
  touch-action:manipulation;
  -webkit-tap-highlight-color:transparent;
  transition:opacity 200ms ease, background 200ms ease;
}

.sound:hover,
.sound:focus-visible{ opacity:1; background:rgba(0,0,0,.36); }
.sound:focus-visible{ outline:1px solid rgba(255,255,255,.7); outline-offset:3px; }
.sound:active{ transform:scale(.94); }

.sound svg{ display:block; overflow:visible; }
.sound__waves,
.sound__slash{ transition:opacity 160ms ease; }

/* sound on: waves, no cross */
.sound__slash{ opacity:0; }

/* muted: cross, no waves */
.sound.is-muted .sound__waves{ opacity:0; }
.sound.is-muted .sound__slash{ opacity:1; }

/* until the browser lets us make noise, quietly ask for a click */
.sound.is-blocked{ animation:soundPulse 2.4s ease-in-out infinite; }
@keyframes soundPulse{
  0%,100%{ opacity:.55; }
  50%    { opacity:1; }
}

@media (prefers-reduced-motion:reduce){
  .sound.is-blocked{ animation:none; opacity:1; }
}

/* ---- the nudge under it ----
   While the sound is off - blocked by the browser or muted by the reader - a
   small note hangs under the button saying what it is for. A narrow block,
   centred on the button (main.js places it on the button's axis, and keeps it
   off the screen edge where the button sits close to it), with a caret
   pointing up at the button's centre. Tapping it does what tapping the button
   does. */
.soundHint{
  position:fixed;
  z-index:10;
  top:calc(2.4rem + env(safe-area-inset-top) + 38px + 12px);
  right:clamp(1.5rem, 6vw, 7rem);
  --caret:50%;                        /* from the right edge: the button's centre */
  width:8.4rem;                       /* two lines: "Turn on sound for / the full experience" */
  padding:.7rem .8rem;
  border:0;
  border-radius:0;
  background:rgba(0,0,0,.42);
  -webkit-backdrop-filter:blur(10px);
  backdrop-filter:blur(10px);
  color:#fff;
  font:500 .72rem/1.35 var(--font);
  letter-spacing:.02em;
  text-align:center;
  cursor:pointer;
  touch-action:manipulation;
  -webkit-tap-highlight-color:transparent;
  opacity:0;
  transform:translateY(-4px);
  pointer-events:none;
  transition:opacity 260ms ease, transform 260ms ease;
}
.soundHint::before{
  content:'';
  position:absolute;
  top:-5px;
  right:calc(var(--caret) - 5px);
  border:5px solid transparent;
  border-top:0;
  border-bottom-color:rgba(0,0,0,.42);
}
.soundHint.is-on{
  opacity:1;
  transform:none;
  pointer-events:auto;
  transition-delay:400ms;             /* lets the page land before it asks */
}
/* never over the loading screen's fade */
html.booting .soundHint{ opacity:0; pointer-events:none; }
@media (pointer:coarse){
  .soundHint{ top:calc(2.4rem + env(safe-area-inset-top) + 44px + 12px); }
}
/* On a phone the button sits ~57px from the edge, so the note can be at most
   ~114px wide and still centre on it. Two lines fit in 105px at 10.5px type. */
@media (max-width:600px){
  .soundHint{ width:6.55rem; padding:.65rem .45rem; font-size:.66rem; letter-spacing:0; }
}
@media (prefers-reduced-motion:reduce){
  .soundHint{ transform:none; }
}


@media (prefers-reduced-motion:reduce){
  .hero{ transition:opacity 120ms linear; }
  .rv__i{ transform:none !important; transition:none; }
  .cue__arrow, .hero__cueLine{ animation:none; }
  .chapter__grain{ animation:none; }   /* main.js leaves the bands still */
}


/* ============================================================
   the track panel
   ============================================================ */

.track{
  /* One proportional unit. Everything in this section is a multiple of it, so
     the whole composition keeps its shape at any desktop size instead of
     drifting as individual clamp() caps bite at different widths.

     min(1vw, 1.599vh) resolves to 1vw at the reference aspect (1717x1074) and
     falls back to height on anything shorter - the section is anchored to the
     foot of the screen, so height is the binding constraint, not width. */
  --pu:min(1vw, 1.599vh);
  /* Height the 1.6:1 composition does not use. --pu follows the width up to
     that aspect, so on a squarer screen (4:3, say) the whole design sat in
     the top of the frame and the remainder fell between the player and the
     foot line - 300px of nothing at 1600x1200. That remainder is measured
     here and shared out: most to the cover (and with it the player and the
     sculpture's row), some to the title-to-cover gap, a little to the title.
     It is zero at 1.6:1 and wider, so those screens are exactly as before. */
  --slack:max(0px, 100vh - 62.54vw);
  --track-drop:calc(5.004 * var(--pu) + 0.26 * var(--slack));   /* title -> cover / object gap */
  --cover-w:calc(28.888 * var(--pu) + 0.42 * var(--slack));
  position:relative;
  display:grid;
  grid-template-columns:minmax(0,1fr) minmax(0,0.86fr);
  /* head / cover / player / slack / meta. The sculpture is placed into the
     cover's row so the two line up by construction rather than by nudging. */
  grid-template-rows:auto auto auto 1fr;
  column-gap:calc(4.659 * var(--pu));
  align-content:start;
  padding:calc(5.630 * var(--pu)) calc(6 * var(--pu)) calc(2.502 * var(--pu));
  color:var(--txt, var(--ink-dark));
}

/* The flip itself is a snap (see paintTint) but it animates over a fixed
   340ms. That is the distinction that matters: tying the colour continuously
   to the ground meant sitting in mid-grey for seconds while the slow gradient
   crossed, which is where the contrast vanished. Deciding instantly and then
   easing over a fixed short time keeps the change soft while the window where
   the type blends into the ground stays around a tenth of a second — read as a
   soft hand-off rather than a dropout. Lengthen this and that window grows
   proportionally. */
.track__title,
.track__artists,
.player,
.track__meta,
.player__play svg{
  transition:color 240ms cubic-bezier(.4,0,.2,1);
}

.track__head{ grid-column:1; grid-row:1; max-width:34rem; }
.cover      { grid-column:1; grid-row:2; }
.player     { grid-column:1; grid-row:3; }
.track__object{ grid-column:2; grid-row:2; align-self:stretch; }
/* Deliberately a bare grid item. Chrome constrains a grid item's sticky range
   to the grid container rather than to its grid area, so the line pins to the
   foot of the screen as soon as the panel's top edge appears - it arrives
   ahead of the title, which is the intent. */
/* Both are grid items in the same cell, both stick to the same offset, so they
   travel together - but they sit on opposite sides of the section's content in
   paint order. Chrome constrains a grid item's sticky range to the grid
   container rather than to its grid area, which is what lets the line pin as
   soon as the panel's top edge appears: it arrives ahead of the title, and the
   content then rises past it. */
.track__veil,
.track__meta{ grid-column:1 / -1; grid-row:4; align-self:end; }


.track__title{
  margin:0;
  white-space:nowrap;          /* one line on desktop; phones let it wrap */
  font-size:calc(5.405 * var(--pu) + 0.035 * var(--slack));
  font-weight:800;
  font-stretch:105%;
  letter-spacing:-.042em;
  line-height:.92;
  text-transform:uppercase;
  color:var(--txt-title, inherit);
}

.track__artists{
  margin:calc(0.839 * var(--pu)) 0 0;
  font-size:max(0.95rem, calc(0.978 * var(--pu)));
  font-weight:500;
  letter-spacing:.02em;
  color:var(--txt-artists, inherit);
}

/* ---- cover ---- */
.cover{
  /* identical to .track__object's, which is what keeps the two aligned */
  margin:var(--track-drop) 0 0;
  width:var(--cover-w);
}
.cover__art{
  display:block;
  width:100%;
  height:auto;
  aspect-ratio:1;
  object-fit:cover;
}

/* ---- player ----
   Play sits at the head of the bar; no transport cluster. */
.player{
  display:flex;
  align-items:center;
  gap:calc(0.93 * var(--pu));
  width:var(--cover-w);
  margin-top:calc(1.491 * var(--pu));
  color:var(--txt-player, inherit);
}

.player__play{
  flex:0 0 auto;
  display:grid;
  place-items:center;
  width:calc(3.145 * var(--pu)); height:calc(3.145 * var(--pu));
  padding:0;
  border:0;
  border-radius:50%;
  /* `color:inherit` is not optional. A <button> takes `color: buttontext` from
     the UA sheet and does not inherit, so `currentColor` here stayed black
     forever and the circle never followed the tint. */
  color:inherit;
  background:currentColor;
  cursor:pointer;
  transition:transform 160ms ease;
}
/* Sized as a fraction of the button rather than the 18px the markup carries,
   so it tracks the button at every viewport instead of shrinking away from it.
   Was 40% of the disc; 50% reads as an icon rather than a dot. */
.player__play svg{
  width:50%;
  height:50%;
}
/* the glyph is the inverse of the disc, set alongside the tint */
.player__play svg{ color:var(--player-icon, var(--flow-paper)); }
.player__play:hover{ transform:scale(1.06); }
.player__play:focus-visible{ outline:2px solid currentColor; outline-offset:3px; }

.player__icon--pause{ display:none; }
.player__play[aria-pressed="true"] .player__icon--play{ display:none; }
.player__play[aria-pressed="true"] .player__icon--pause{ display:inline; }

.player__time{
  flex:0 0 auto;
  font-size:max(0.75rem, calc(0.7 * var(--pu)));
  font-weight:500;
  letter-spacing:.1em;
  font-variant-numeric:tabular-nums;
  opacity:.7;
}

.player__track{
  position:relative;
  cursor:pointer;              /* it accepts a seek, so say so */
  flex:1 1 auto;
  height:calc(0.466 * var(--pu));
  background:color-mix(in srgb, currentColor 24%, transparent);
  overflow:hidden;
}
.player__fill{
  position:absolute;
  inset:0 auto 0 0;
  width:0%;
  background:currentColor;
}

/* ---- meta, one line along the foot ---- */
/* Sticky to the foot of the screen: the row it sits in absorbs the panel's
   slack, so the line pins to the viewport bottom as soon as the panel is in
   view rather than waiting to be scrolled to. */
.track__meta{
  --foot-lift:calc(2.502 * var(--pu));
  position:sticky;
  bottom:var(--foot-lift);
  z-index:2;                  /* over the frosted band; the only sharp thing in it */
  margin:clamp(2rem, 5vh, 3rem) 0 0;
  display:flex;
  flex-wrap:wrap;
  align-items:baseline;
  gap:calc(2.796 * var(--pu));
  color:var(--txt-meta, inherit);
}

/* ---- the veil behind the meta ----
   Blur ramps in downward: sharp where the line's top edge sits, fully soft by
   the foot of the screen. The point is the grain, not the gradient - blurring a
   smooth ramp changes almost nothing, but it flattens the speckle that
   otherwise competes with .82rem type. No tint: the text flips white/black with
   the wave, so any fixed wash would fight one of the two states.

   The four layers stack instead of cross-fading. One masked blur layer over a
   sharp backdrop leaves the sharp copy showing through the mask's soft end and
   ghosts; stacked layers each take the previous layer's output as their own
   backdrop, so blur accumulates and the untouched top stays untouched. */
.track__veil{
  --foot-lift:calc(2.502 * var(--pu));
  /* Measured from the line's *bottom* edge, since this box has no height of its
     own to hang from - hence 9.45 where the ramp itself is 8.386. */
  --veil-rise:calc(9.45 * var(--pu));

  /* Above the content, not below it. backdrop-filter samples whatever painted
     before it, so this is what decides *what* gets frosted - and the answer is
     everything: the cover art, the sculpture, the player, the gradient. The
     band is a piece of frosted glass lying across the foot of the screen that
     the whole composition slides under as it scrolls.

     An earlier version painted this underneath the content instead, which
     blurred only the gradient. Where the cover art sat there was simply
     nothing behind it to blur, which is why the effect looked absent exactly
     where you were looking for it. Only the meta line, at 2, stays sharp. */
  z-index:1;
  position:sticky;
  bottom:var(--foot-lift);
  height:0;
  pointer-events:none;

  /* Ramps in absolute lengths off --veil-rise, not percentages of the box:
     the box grows by however tall the meta line wraps, and percentage stops
     would slide around with it. */
  --r1a:calc(0.62 * var(--veil-rise));
  --r2a:calc(0.33 * var(--veil-rise));
  --r2b:calc(0.82 * var(--veil-rise));
  --r3a:calc(0.60 * var(--veil-rise));
  --r3b:calc(0.99 * var(--veil-rise));
  --r4a:calc(0.82 * var(--veil-rise));
  --r4b:calc(1.13 * var(--veil-rise));
  /* No fade-out at the foot: every layer runs to the very bottom of the screen.
     The layers end exactly ON the screen's bottom edge (see the spans), and
     that is what matters: when they used to hang below it, the blur sampled
     the empty area past the edge and drew a dark gradient along the foot.
     Ending on the edge, it sees only the page. Fading the layers out above
     the edge instead was tried and left a strip of unfrosted grain. */
  --t1a:100%; --t1b:100%;
  --t2a:100%; --t2b:100%;
  --t3a:100%; --t3b:100%;
  --t4a:100%; --t4b:100%;
}
/* The geometry lives on the layers, not on the box. The box is a zero-height
   sticky marker pinned to the foot of the meta line; each layer hangs off it,
   so top and bottom are measured from that same line and simply span outwards.

   Full-bleed without a transform: a transform would make the layer a containing
   block for fixed descendants, which also makes it a backdrop root - it would
   then sample an empty backdrop and blur nothing. */
/* The mask and the backdrop-filter have to sit on the same element. Moving the
   mask to a wrapper looks like the tidier arrangement and kills the effect
   outright: mask-image creates a backdrop root, so a blur nested inside one
   samples an empty backdrop and blurs nothing. Measured - grain retention goes
   from 0% to 100% the moment they are split. */
.track__veil span{
  position:absolute;
  display:block;
  left:50%;
  margin-left:-50vw;
  width:100vw;
  top:calc(-1 * var(--veil-rise));
  /* Ends exactly at the bottom of the screen. It used to hang a tail below
     the fold, so the blur met the screen edge at full strength - see the
     fade-out note on .track__veil for what that did in Safari. */
  bottom:calc(-1 * var(--foot-lift));
}
.track__veil span:nth-child(1){
  -webkit-backdrop-filter:blur(1.5px); backdrop-filter:blur(1.5px);
  -webkit-mask-image:linear-gradient(to bottom, transparent 0, #000 var(--r1a), #000 var(--t1a), transparent var(--t1b));
          mask-image:linear-gradient(to bottom, transparent 0, #000 var(--r1a), #000 var(--t1a), transparent var(--t1b));
}
.track__veil span:nth-child(2){
  -webkit-backdrop-filter:blur(4px); backdrop-filter:blur(4px);
  -webkit-mask-image:linear-gradient(to bottom, transparent var(--r2a), #000 var(--r2b), #000 var(--t2a), transparent var(--t2b));
          mask-image:linear-gradient(to bottom, transparent var(--r2a), #000 var(--r2b), #000 var(--t2a), transparent var(--t2b));
}
.track__veil span:nth-child(3){
  -webkit-backdrop-filter:blur(9px); backdrop-filter:blur(9px);
  -webkit-mask-image:linear-gradient(to bottom, transparent var(--r3a), #000 var(--r3b), #000 var(--t3a), transparent var(--t3b));
          mask-image:linear-gradient(to bottom, transparent var(--r3a), #000 var(--r3b), #000 var(--t3a), transparent var(--t3b));
}
.track__veil span:nth-child(4){
  -webkit-backdrop-filter:blur(18px); backdrop-filter:blur(18px);
  -webkit-mask-image:linear-gradient(to bottom, transparent var(--r4a), #000 var(--r4b), #000 var(--t4a), transparent var(--t4b));
          mask-image:linear-gradient(to bottom, transparent var(--r4a), #000 var(--r4b), #000 var(--t4a), transparent var(--t4b));
}
@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){
  .track__veil{ display:none; }
}

.track__meta > div{ display:flex; align-items:baseline; gap:.6rem; }
.track__meta dt{
  margin:0;
  font-size:max(0.6875rem, calc(0.522 * var(--pu)));
  font-weight:500;
  letter-spacing:.3em;
  text-transform:uppercase;
  opacity:.6;
}
.track__meta dd{
  margin:0;
  font-size:max(0.8125rem, calc(0.764 * var(--pu)));
  font-weight:500;
  letter-spacing:.02em;
}

/* ---- the background waves ----
   Right-hand side, full height of the panel, bleeding off the right edge.

   z-index:-1 puts it in the panel's negative layer, below every in-flow thing
   in the section - so it sits behind the title, cover, player and sculpture,
   and the veil at z-index 1 still frosts it along with everything else at the
   foot of the screen. The shader's own radial falloff is what hides its edges;
   there is deliberately no mask here. */
.track__waves{
  position:absolute;
  top:0;
  right:0;
  bottom:0;
  left:38%;
  z-index:-1;
  pointer-events:none;
}
.track__waves canvas{
  display:block;
  width:100%;
  height:100%;
  opacity:0;                    /* until waves.js has drawn a real frame */
  transition:opacity 1200ms ease;
}
.track__waves canvas.is-live{ opacity:1; }

/* ---- the sculpture ---- */
.track__object{
  position:relative;
  margin:var(--track-drop) 0 0;          /* identical to the cover's, so they align */
  min-height:calc(16 * var(--pu));
  overflow:hidden;
}
.sculptureCanvas{
  /* Absolute, not in flow: a canvas at height:100% inside a stretched,
     auto-height parent is circular and inflates the row. */
  position:absolute;
  inset:0;
  display:block;
  width:100%;
  height:100%;
  /* No cursor change. It still turns under the pointer, but the grab hand read
     as "this is a control" - and it is scenery that happens to respond, not
     something with a state to click into. */
}

/* ---- the section's own arrival ----
   Each piece surfaces out of a blurred, dimmed state a little below where it
   belongs. The curve is an exponential-out: most of the distance is covered in
   the first third and the rest is a long settle. A linear ramp reads as
   machinery because constant velocity is the one thing nothing physical does.

   `color` has to stay in this list. It is a shorthand, so a bare rise
   transition would silently drop the 240ms colour transition these same
   elements rely on to cross-fade white/black with the wave. */
.rise{
  opacity:0;
  filter:blur(calc(0.7 * var(--pu)));
  transform:translateY(calc(1.6 * var(--pu)));
  transition:
    opacity    820ms cubic-bezier(.16,1,.3,1) var(--rise-d, 0ms),
    filter     820ms cubic-bezier(.16,1,.3,1) var(--rise-d, 0ms),
    transform 1050ms cubic-bezier(.16,1,.3,1) var(--rise-d, 0ms),
    color      240ms cubic-bezier(.4,0,.2,1);
  will-change:opacity, filter, transform;
}
.rise.is-up{
  opacity:1;
  filter:blur(0);
  transform:none;
}
/* Once it has landed, drop the filter outright. blur(0) is not free - it still
   costs a rasterisation pass every frame, which the live canvas would go on
   paying for the rest of the session. */
.rise.is-done{
  filter:none;
  will-change:auto;
}
/* ---- the meta line lifts in ----
   One movement, not five. The whole line rides up from below the foot of the
   page as a single object; the words do not arrive individually.

   No clip of its own is needed. The groups are translated far enough down that
   they clear the bottom of the document, and `.chapter`'s `overflow: clip` is
   what hides them - the page's own bottom edge is the mask. 100% here is a
   group's own height, so the offset carries it past its sticky inset too.

   The transform is on the groups and NOT on `.track__meta` itself, which looks
   like the tidier place for it and deadlocks: the line's arrival is triggered
   by an observer watching the line, so moving the line off-screen means it
   never intersects, never gets `is-up`, and never comes back. Keeping its box
   pinned where it belongs is what makes the cue work at all.

   Leaving the colour transition alone for the same reason - it lives on
   `.track__meta` and would be lost to a transform shorthand here. */
.meta-lift > div{
  transform:translateY(calc(100% + var(--foot-lift) + 0.4rem));
  transition:transform 820ms cubic-bezier(.16,1,.3,1);
}
.meta-lift.is-up > div{
  transform:none;
}
@media (prefers-reduced-motion:reduce){
  .meta-lift > div, .meta-lift.is-up > div{
    transform:none;
    transition:none;
  }
}

@media (prefers-reduced-motion:reduce){
  .rise, .rise.is-up{
    opacity:1;
    filter:none;
    transform:none;
    transition:color 240ms cubic-bezier(.4,0,.2,1);
  }
}


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

/* ============================================================
   The rail — a second track, sideways
   ------------------------------------------------------------
   The page scrolls down to the first slide as it always did; carrying on past
   the bottom drives this sideways instead. --rail runs 0..1 across the slides
   and is written by main.js.
   ============================================================ */
.rail{
  position:relative;
  z-index:2;                   /* above the band and its grain, as .panel was */
  display:flex;
  width:100%;
  /* translate3d, not translateX: this moves ~90,000 particles' worth of
     compositing behind it and the 3D form keeps it on its own layer. */
  transform:translate3d(calc(var(--rail, 0) * -100%), 0, 0);
}
.rail > .panel{
  flex:0 0 100%;
  min-width:0;                 /* or the grid children refuse to shrink */
}

/* The wave field sits OUTSIDE the rail, so one field serves both slides. The
   panel is the last 100vh of the chapter (the lead gives back exactly the
   160vh the negative margin borrowed), so anchoring to the bottom lines it up
   with the panel by construction rather than by nudging. */
.track__waves{
  position:absolute;
  top:auto;
  bottom:0;
  height:var(--screen);
  left:38%;
  right:0;
  z-index:1;                   /* under the rail, over the band */
}



/* ---- the foot layer ----
   Modelled on .chapter__flow: a sticky, viewport-tall box with its own height
   subtracted back out, so it pins to the screen for the whole chapter without
   adding to the scroll. That reproduces what the meta line's own sticky did
   inside the panel - it pins as soon as the section's top edge appears and
   arrives ahead of the title - now that it no longer lives there.

   z-index 3 puts it over the rail (2). It comes BEFORE the rail in the DOM so
   it is not inside the transform; the z-index is what keeps it on top. */
.track__foot{
  --pu:min(1vw, 1.599vh);
  --foot-lift:calc(2.502 * var(--pu));
  position:sticky;
  top:0;
  height:var(--screen);
  margin-bottom:calc(-1 * var(--screen));
  z-index:3;
  pointer-events:none;
}
.track__foot .track__veil{
  position:absolute;
  left:0;
  right:0;
  bottom:var(--foot-lift);
  height:0;
}
/* Same inset the panel's own horizontal padding gave it, so the line lands
   exactly where it did as a grid item. */
.track__foot .track__meta{
  position:absolute;
  left:calc(6 * var(--pu));
  right:calc(6 * var(--pu));
  bottom:var(--foot-lift);
  margin:0;
}

/* The swap. --m0 and --m1 are each slide's distance from the active one, 0..1,
   written by the rail's frame loop. At the midpoint both sit at 0.5: both are
   half faded and half blurred, which is the moment the line is at its softest
   before the next track's resolves out of it.

   blur() on the LINES only, never on the veil or a shared ancestor - a filter
   would create a backdrop root and take the frosted band with it. */
/* The lines' blur and fade are written straight onto the elements by the rail's
   frame loop rather than through custom properties. With ten slides that would
   be ten pairs of variables and ten rules for something two lines of JavaScript
   can set on the two elements that are actually moving. */
.track__foot .track__meta{ transition:none; }



/* ============================================================
   The ten grounds
   ------------------------------------------------------------
   One class per track, applied to whichever of the two recycled bands is
   standing in for that slide. Each is the reference ramp remapped: every stop's
   perceptual LUMINANCE - which is what actually varies, the original being
   nearly constant in HSV value and moving in hue and saturation instead -
   becomes the progress from that cover's dark tone to its bright one, with the
   bloom's saturation dip preserved. Same trapezoid, same shoulders, same sheen.

   pal-0 is the reference ramp itself, verbatim.
   ============================================================ */
/* The upper band starts hidden; main.js writes both opacities as the rail moves. */
#bandB{ opacity:0; }

.pal-talk-to-you .chapter__flowRamp{
  background:repeating-linear-gradient(
    180deg,
    var(--flow-ink) 0vh,
    var(--flow-ink) 20vh,
    #F5471D 25vh,
    #F54E1F 30vh,
    #F55A24 35vh,
    #F46A29 40vh,
    #F47D31 45vh,
    #F4913A 50vh,
    #F3A644 55vh,
    #F3B94E 60vh,
    #F3C958 65vh,
    #F3D660 70vh,
    #F4DD67 75vh,
    #F4DF6C 80vh,
    #F4E06F 85vh,
    #F4E172 90vh,
    #F5E174 95vh,
    #F5E175 100vh,
    #F5E174 105vh,
    #F4E172 110vh,
    #F4E06F 115vh,
    #F4DF6C 120vh,
    #F4DD67 125vh,
    #F3D660 130vh,
    #F3C958 135vh,
    #F3B94E 140vh,
    #F3A644 145vh,
    #F4913A 150vh,
    #F47D31 155vh,
    #F46A29 160vh,
    #F55A24 165vh,
    #F54E1F 170vh,
    #F5471D 175vh,
    var(--flow-ink) 180vh,
    var(--flow-ink) 200vh);
}
.pal-free-your-mind .chapter__flowRamp{
  background:repeating-linear-gradient(
    180deg,
    #010000 0vh,
    #010000 20vh,
    #040000 25vh,
    #0D0000 30vh,
    #1C0101 35vh,
    #310202 40vh,
    #4A0505 45vh,
    #64090A 50vh,
    #7F0F10 55vh,
    #981617 60vh,
    #AD1E1E 65vh,
    #BF2425 70vh,
    #C92929 75vh,
    #CC2B2C 80vh,
    #CE2C2D 85vh,
    #CF2D2E 90vh,
    #D02E2F 95vh,
    #D02E2F 100vh,
    #D02E2F 105vh,
    #CF2D2E 110vh,
    #CE2C2D 115vh,
    #CC2B2C 120vh,
    #C92929 125vh,
    #BF2425 130vh,
    #AD1E1E 135vh,
    #981617 140vh,
    #7F0F10 145vh,
    #64090A 150vh,
    #4A0505 155vh,
    #310202 160vh,
    #1C0101 165vh,
    #0D0000 170vh,
    #040000 175vh,
    #010000 180vh,
    #010000 200vh);
}
.pal-edge-of-desire .chapter__flowRamp{
  background:repeating-linear-gradient(
    180deg,
    #080101 0vh,
    #080101 20vh,
    #0B0102 25vh,
    #150304 30vh,
    #270608 35vh,
    #3D0A0E 40vh,
    #591116 45vh,
    #771920 50vh,
    #95232B 55vh,
    #B12E37 60vh,
    #C93843 65vh,
    #DD414C 70vh,
    #E84753 75vh,
    #EB4A55 80vh,
    #ED4C57 85vh,
    #EF4D59 90vh,
    #F04E59 95vh,
    #F04E5A 100vh,
    #F04E59 105vh,
    #EF4D59 110vh,
    #ED4C57 115vh,
    #EB4A55 120vh,
    #E84753 125vh,
    #DD414C 130vh,
    #C93843 135vh,
    #B12E37 140vh,
    #95232B 145vh,
    #771920 150vh,
    #591116 155vh,
    #3D0A0E 160vh,
    #270608 165vh,
    #150304 170vh,
    #0B0102 175vh,
    #080101 180vh,
    #080101 200vh);
}
.pal-hands-up .chapter__flowRamp{
  background:repeating-linear-gradient(
    180deg,
    #086A9D 0vh,
    #086A9D 20vh,
    #0A6B9E 25vh,
    #1170A2 30vh,
    #1E79A8 35vh,
    #2F84B0 40vh,
    #4793BA 45vh,
    #61A3C5 50vh,
    #7FB4D0 55vh,
    #9CC5DA 60vh,
    #B6D3E2 65vh,
    #CCDFE9 70vh,
    #D9E6ED 75vh,
    #DDE9EE 80vh,
    #E0EAEF 85vh,
    #E2EBF0 90vh,
    #E3EBF0 95vh,
    #E3EBF0 100vh,
    #E3EBF0 105vh,
    #E2EBF0 110vh,
    #E0EAEF 115vh,
    #DDE9EE 120vh,
    #D9E6ED 125vh,
    #CCDFE9 130vh,
    #B6D3E2 135vh,
    #9CC5DA 140vh,
    #7FB4D0 145vh,
    #61A3C5 150vh,
    #4793BA 155vh,
    #2F84B0 160vh,
    #1E79A8 165vh,
    #1170A2 170vh,
    #0A6B9E 175vh,
    #086A9D 180vh,
    #086A9D 200vh);
}
.pal-jamaican-bam-bam .chapter__flowRamp{
  background:repeating-linear-gradient(
    180deg,
    #D52441 0vh,
    #D52441 20vh,
    #D52540 25vh,
    #D6263A 30vh,
    #D82A32 35vh,
    #DB352E 40vh,
    #DD4D34 45vh,
    #E0653A 50vh,
    #E47E40 55vh,
    #E79447 60vh,
    #E9A74C 65vh,
    #EBB651 70vh,
    #ECBE54 75vh,
    #EDC156 80vh,
    #EDC257 85vh,
    #EDC458 90vh,
    #EDC458 95vh,
    #EDC558 100vh,
    #EDC458 105vh,
    #EDC458 110vh,
    #EDC257 115vh,
    #EDC156 120vh,
    #ECBE54 125vh,
    #EBB651 130vh,
    #E9A74C 135vh,
    #E79447 140vh,
    #E47E40 145vh,
    #E0653A 150vh,
    #DD4D34 155vh,
    #DB352E 160vh,
    #D82A32 165vh,
    #D6263A 170vh,
    #D52540 175vh,
    #D52441 180vh,
    #D52441 200vh);
}
.pal-movin-to-the-sun .chapter__flowRamp{
  background:repeating-linear-gradient(
    180deg,
    #552406 0vh,
    #552406 20vh,
    #572507 25vh,
    #5D2909 30vh,
    #68310E 35vh,
    #763B15 40vh,
    #87491F 45vh,
    #99592D 50vh,
    #AC6B3C 55vh,
    #BD7C4D 60vh,
    #CC8C5C 65vh,
    #D8996A 70vh,
    #DFA272 75vh,
    #E1A475 80vh,
    #E2A677 85vh,
    #E3A779 90vh,
    #E4A879 95vh,
    #E4A87A 100vh,
    #E4A879 105vh,
    #E3A779 110vh,
    #E2A677 115vh,
    #E1A475 120vh,
    #DFA272 125vh,
    #D8996A 130vh,
    #CC8C5C 135vh,
    #BD7C4D 140vh,
    #AC6B3C 145vh,
    #99592D 150vh,
    #87491F 155vh,
    #763B15 160vh,
    #68310E 165vh,
    #5D2909 170vh,
    #572507 175vh,
    #552406 180vh,
    #552406 200vh);
}
.pal-positive .chapter__flowRamp{
  background:repeating-linear-gradient(
    180deg,
    #010101 0vh,
    #010101 20vh,
    #040404 25vh,
    #0D0C0C 30vh,
    #1C1919 35vh,
    #302727 40vh,
    #493433 45vh,
    #633C3B 50vh,
    #7E403E 55vh,
    #973F3C 60vh,
    #AD3B37 65vh,
    #BE3631 70vh,
    #C8332E 75vh,
    #CB322D 80vh,
    #CD322D 85vh,
    #CE322D 90vh,
    #CF322D 95vh,
    #CF322D 100vh,
    #CF322D 105vh,
    #CE322D 110vh,
    #CD322D 115vh,
    #CB322D 120vh,
    #C8332E 125vh,
    #BE3631 130vh,
    #AD3B37 135vh,
    #973F3C 140vh,
    #7E403E 145vh,
    #633C3B 150vh,
    #493433 155vh,
    #302727 160vh,
    #1C1919 165vh,
    #0D0C0C 170vh,
    #040404 175vh,
    #010101 180vh,
    #010101 200vh);
}
.pal-samba .chapter__flowRamp{
  background:repeating-linear-gradient(
    180deg,
    #427549 0vh,
    #427549 20vh,
    #437749 25vh,
    #447C47 30vh,
    #4C8547 35vh,
    #5A914A 40vh,
    #70A04D 45vh,
    #8CAF4F 50vh,
    #A0BB55 55vh,
    #B1C65B 60vh,
    #C0CF61 65vh,
    #CDD666 70vh,
    #D4DA69 75vh,
    #D6DB6B 80vh,
    #D8DC6C 85vh,
    #D9DD6D 90vh,
    #D9DD6D 95vh,
    #D9DD6D 100vh,
    #D9DD6D 105vh,
    #D9DD6D 110vh,
    #D8DC6C 115vh,
    #D6DB6B 120vh,
    #D4DA69 125vh,
    #CDD666 130vh,
    #C0CF61 135vh,
    #B1C65B 140vh,
    #A0BB55 145vh,
    #8CAF4F 150vh,
    #70A04D 155vh,
    #5A914A 160vh,
    #4C8547 165vh,
    #447C47 170vh,
    #437749 175vh,
    #427549 180vh,
    #427549 200vh);
}
.pal-shinjuku .chapter__flowRamp{
  background:repeating-linear-gradient(
    180deg,
    #3C515C 0vh,
    #3C515C 20vh,
    #43535D 25vh,
    #55575F 30vh,
    #6F5D61 35vh,
    #926665 40vh,
    #BC706A 45vh,
    #EE7C6F 50vh,
    #EE9E8A 55vh,
    #EFB9A2 60vh,
    #EFCCB5 65vh,
    #F0D9C5 70vh,
    #F0E0CE 75vh,
    #F0E1D1 80vh,
    #F0E2D2 85vh,
    #F0E3D4 90vh,
    #F0E4D5 95vh,
    #F0E4D5 100vh,
    #F0E4D5 105vh,
    #F0E3D4 110vh,
    #F0E2D2 115vh,
    #F0E1D1 120vh,
    #F0E0CE 125vh,
    #F0D9C5 130vh,
    #EFCCB5 135vh,
    #EFB9A2 140vh,
    #EE9E8A 145vh,
    #EE7C6F 150vh,
    #BC706A 155vh,
    #926665 160vh,
    #6F5D61 165vh,
    #55575F 170vh,
    #43535D 175vh,
    #3C515C 180vh,
    #3C515C 200vh);
}
.pal-whisper-to-me .chapter__flowRamp{
  background:repeating-linear-gradient(
    180deg,
    #2A2E13 0vh,
    #2A2E13 20vh,
    #2B2F14 25vh,
    #303417 30vh,
    #383D1D 35vh,
    #434826 40vh,
    #505533 45vh,
    #5E6342 50vh,
    #6E7253 55vh,
    #7C8064 60vh,
    #888B74 65vh,
    #929582 70vh,
    #989A8A 75vh,
    #9A9C8C 80vh,
    #9B9D8E 85vh,
    #9B9E8F 90vh,
    #9C9E90 95vh,
    #9C9E90 100vh,
    #9C9E90 105vh,
    #9B9E8F 110vh,
    #9B9D8E 115vh,
    #9A9C8C 120vh,
    #989A8A 125vh,
    #929582 130vh,
    #888B74 135vh,
    #7C8064 140vh,
    #6E7253 145vh,
    #5E6342 150vh,
    #505533 155vh,
    #434826 160vh,
    #383D1D 165vh,
    #303417 170vh,
    #2B2F14 175vh,
    #2A2E13 180vh,
    #2A2E13 200vh);
}

/* A cover that has not arrived yet should read as a plate, not as a broken
   image: a missing file otherwise leaves a torn-page icon and a collapsed grid
   row. Deliberately a neutral wash rather than the ground colour - --flow-ink
   is only ever the FIRST palette's, so using it painted every placeholder in
   the wrong track's orange. */
.cover{ background:rgba(0,0,0,0.28); }
.cover__art{ display:block; }

/* The shared sculpture. Same sticky-viewport trick as the foot: a box pinned to
   the screen for the whole chapter with its own height taken back out, so it
   adds nothing to the scroll. main.js measures the first panel's object cell and
   writes the four insets, which is exact and survives any change to the grid.

   z-index 3 with the foot, not 2 with the rail: at equal z-index the later
   element in the document wins, and the rail comes after this - which left the
   object painting behind ten panels and invisible. */
.track__solo{
  position:sticky;
  top:0;
  height:var(--screen);
  margin-bottom:calc(-1 * var(--screen));
  z-index:3;
  pointer-events:none;
  /* Clips, because the two canvases sit a full panel apart while one is
     travelling and the outgoing one would otherwise hang past the composition. */
  overflow:clip;
}
/* While its model is on the wire. Hidden at once (no transition on the way
   out), faded in when it lands - see show() in sculpture.js. */
.track__solo .sculptureCanvas.is-loading{ opacity:0; transition:none; }
.track__solo .sculptureCanvas{
  position:absolute;
  left:var(--solo-l, 0px);
  top:var(--solo-t, 0px);
  width:var(--solo-w, 0px);
  height:var(--solo-h, 0px);
  /* translate3d, not left: this moves every frame of a slide change and must
     not relayout. */
  transform:translate3d(var(--dx, 0px), 0, 0);
  transition:opacity 700ms ease;
  /* The LAYER is pointer-events:none so it does not sit over the panels like a
     sheet of glass - but the object itself has to be reachable, or it stops
     answering the cursor. Nothing interactive sits under it. */
  pointer-events:auto;
}

/* ---- scrolled before the second clip was in ----
   The forward video is held until it has downloaded (main.js, toForward), so a
   scroll never starts a clip that then freezes mid-play. While it is held this
   says so: the loading screen's hairline, filling with --second-buffered. It
   only ever shows on a first visit on a slow connection. */
html.is-buffering .hero::after{
  content:'';
  position:fixed;
  left:50%;
  bottom:56px;
  width:132px;
  height:1px;
  margin-left:-66px;
  background:linear-gradient(90deg,
    #fff calc(var(--second-buffered, 0) * 100%),
    rgba(255,255,255,.16) calc(var(--second-buffered, 0) * 100%));
  pointer-events:none;
}

/* ============================================================
   Phones and portrait tablets
   ------------------------------------------------------------
   Portrait only. A phone on its side is short, not narrow - and the desktop
   composition is sized by --pu, which already falls back to height, so it
   holds together at 812x375 without help. What breaks in portrait is the
   two-column grid, so this is where it is rebuilt.

   Each song is still exactly one screen, as on desktop, and has to be: the
   ground, the foot line and the sculpture are sticky layers pinned over the
   panel, and they only line up with it when the panel is one screen tall.

       TITLE                            (sound)
       artists
       ( the sculpture, large )
       LABEL       GENRE        BPM
       KEY         RELEASED
       (>) 0:00 ------------------- 3:09
   ============================================================ */
@media (max-width:900px) and (orientation:portrait){
  @supports (height:100svh){
    :root{ --screen:100svh; }
  }
  :root{
    --m-pad:clamp(1.25rem, 6vw, 2.75rem);
    /* The first line sits level with the sound button. */
    --m-top:calc(env(safe-area-inset-top) + 2.4rem);
    --m-bottom:calc(env(safe-area-inset-bottom) + clamp(1.4rem, 5.5vw, 2.5rem));
    --m-play:clamp(2.9rem, 12.5vw, 4rem);
    --m-gap:clamp(1rem, 4.5vw, 1.9rem);
    /* Room kept above the player for the details, which live in the foot
       layer rather than the grid: two rows of label-over-value. */
    --m-meta:clamp(5.25rem, 21vw, 7.75rem);
  }

  /* ---- hero ---- the wordmark takes the width a phone gives it */
  .hero__word{ font-size:29vw; }
  /* and so does the section's heading */
  .chapterHead{ width:calc(100% - 2 * var(--m-pad)); }
  .chapter__title{ font-size:clamp(2.3rem, 11vw, 4.8rem); line-height:.92; }
  /* leaves the scroll cue its own corner instead of running into it */
  .hero__line{ max-width:calc(100% - 2 * clamp(1.5rem, 6vw, 7rem) - 4.5rem); line-height:1.6; }

  /* ---- the panel ----
     One screen, but the SMALL screen - the height with Safari's toolbars
     showing - and it never changes. `dvh` followed the toolbars, and they
     collapse exactly as the reader scrolls into this section, so the panel
     and everything laid over it re-laid out mid-scroll: a dark band flashed at
     the foot of the screen and the title and sculpture jumped into place.
     With the toolbars hidden the screen is simply taller than the panel, and
     the extra is the ground above it. */
  .rail > .panel{ height:var(--screen); }
  /* The layers laid over the panel are not sticky here. On desktop the foot
     pins early by design; on a phone its details ride with the song anyway,
     and pinned to the viewport top a layer only matches the panel while the
     two are the same height - which, as above, the toolbars did not allow.
     In normal flow they share the panel's top by construction. */
  .track__solo, .track__foot{ position:relative; top:auto; }
  /* The ground is the one layer that should cover the whole screen whatever
     the toolbars are doing: the large height, still pinned. */
  .chapter__flow{ height:100vh; margin-bottom:-100vh; }
  @supports (height:100lvh){
    .chapter__flow{ height:100lvh; margin-bottom:-100lvh; }
  }
  .track{
    grid-template-columns:minmax(0, 1fr);
    grid-template-rows:auto minmax(0, 1fr) auto;
    row-gap:0;
    padding:var(--m-top) var(--m-pad) var(--m-bottom);
  }

  /* No cover on a phone: the screen goes to the sculpture and the details. */
  .cover{ display:none; }

  .track__head{ grid-column:1 / -1; grid-row:1; max-width:none; min-width:0; }
  .track__title{
    white-space:normal;
    max-width:calc(100% - 3.2rem);   /* clear of the sound button */
    font-size:clamp(2.35rem, 11.6vw, 5rem);
    line-height:.9;
  }
  .track__artists{
    max-width:calc(100% - 3.2rem);   /* clear of the sound button */
    margin-top:.7rem;
    font-size:clamp(.95rem, 4.3vw, 1.4rem);
  }


  /* The sculpture's cell. Empty - the shared canvases are measured onto it. */
  .track__object{
    grid-column:1 / -1;
    grid-row:2;
    align-self:stretch;
    min-height:0;
    margin:clamp(.75rem, 3vh, 1.75rem) 0 clamp(.5rem, 2vh, 1.25rem);
  }

  .player{
    grid-column:1 / -1;
    grid-row:3;
    width:auto;
    margin-top:calc(var(--m-meta) + var(--m-gap));
    gap:clamp(.7rem, 3vw, 1.1rem);
  }
  .player__play{ width:var(--m-play); height:var(--m-play); }
  .player__time{ font-size:.75rem; }
  .player__track{ height:6px; overflow:visible; }
  /* a 6px bar is a thin thing to hit with a thumb; the hit area is taller */
  .player__track::before{ content:''; position:absolute; left:0; right:0; top:-14px; bottom:-14px; }

  /* ---- the details, above the player ----
     Still in the foot layer, only placed differently; main.js swaps them with
     a plain fade here rather than the desktop blur. Three columns, label over
     value. */
  .track__foot .track__meta{
    left:var(--m-pad);
    right:var(--m-pad);
    bottom:calc(var(--m-bottom) + var(--m-play) + var(--m-gap));
    display:grid;
    grid-template-columns:repeat(3, minmax(0, 1fr));
    column-gap:clamp(.9rem, 4vw, 2rem);
    row-gap:clamp(.65rem, 2.8vw, 1.1rem);
    align-items:start;
  }
  .track__meta > div{
    display:flex;
    flex-direction:column;
    align-items:flex-start;
    gap:.3rem;
    min-width:0;
  }
  .track__meta dt{ font-size:clamp(.5rem, 1.4vw, .64rem); letter-spacing:.26em; }
  .track__meta dd{ font-size:clamp(.78rem, 3.3vw, 1.02rem); line-height:1.2; }
  /* The frosted band is a desktop detail. */
  .track__veil{ display:none; }
  /* The desktop lift carries the line up from below the page edge; here the
     details sit above the player, and that path ran them across it. A short
     rise with a fade instead. */
  .meta-lift > div{
    opacity:0;
    transform:translateY(.9rem);
    transition:opacity 700ms ease, transform 820ms cubic-bezier(.16,1,.3,1);
  }
  .meta-lift.is-up > div{ opacity:1; transform:none; }

  /* The wave field spans the width: there is no right-hand column to sit in. */
  .track__waves{ left:0; }
}

/* ---- fingers ----
   44px is the smallest thing a thumb hits reliably. The desktop composition
   sizes the play button off --pu, which on a phone held sideways (height-bound,
   ~6px) makes it 19px - fine for a cursor, a miss for a finger. */
@media (pointer:coarse){
  .player__play{ min-width:44px; min-height:44px; }
  .sound{ width:44px; height:44px; }
  /* No live blur behind the button and its note on a phone: they sit over a
     playing video, so the blur is recomputed every frame for a 44px circle.
     A slightly deeper tint reads the same at that size. */
  .sound{ -webkit-backdrop-filter:none; backdrop-filter:none; background:rgba(0,0,0,.34); }
  .soundHint{ -webkit-backdrop-filter:none; backdrop-filter:none; background:rgba(0,0,0,.58); }
  .soundHint::before{ border-bottom-color:rgba(0,0,0,.58); }
}
