/* deep-field-bg.css — the home page's background clip.
   --df-build: 12;

   WHAT THIS IS
   The 12.042s deep-field-2 clip painted full-bleed behind the whole home
   document. js/deep-field-bg.js owns the sequence; this file owns the layers,
   the scrim, the sky crossfade and the switch that holds the star field down.

   LINKED FROM index.html since Aug 18 2026, and from home-deepfield-lab.html,
   which is where it was built and where it is still iterated. index.html is
   the live copy — change it there.

   THE CLIP IS NOT SCRUBBED, AND EVERY DESCRIPTION OF A SCROLL-TO-FRAME MAP IN
   THIS PROJECT IS HISTORICAL (rewritten Aug 18 2026). Landing on a section
   plays the clip forward at 1.0x to that section's CUE FRAME and stops it
   there. Sections carry a cue, not a frame span; scroll distance no longer
   decides which frame is showing, so there is nothing here to keep linear or
   section-anchored. The marks are assets/lab/deep-field-2-marks.json, and the
   boundaries in that file are scroll landings that say nothing about frames.

   WHAT THAT REPLACED, so the reasoning is not lost: frame spans used to be
   anchored per section rather than run linearly across the document, because
   .ksd-section is min-height 92vh — the page's sections are roughly EQUAL
   while the clip's movements ran from 32.5% (About) down to 7.9% (Merch), so a
   linear map fired every flash dissolve in the gaps between sections instead
   of at their turns. Play-and-park makes the question moot: a movement now
   plays where the reader is standing. */

:root {
  /* THE STYLESHEET'S OWN VERSION, and it is a real property so the browser can
     be asked. It lived in this file's header comment for eleven builds while
     css/spine-bg.css and css/star-bg.css both declared theirs for real, which
     meant the one check the convention exists for - "is the browser running the
     file I just edited, or a cached copy?" - could not be run against the deep
     field at all. Declared Aug 26 2026. Keep it in step with the header. */
  --df-build: 12;

  /* 0 = sky fully suppressed and the clip is the only background.
     1 = the real star field at its normal strength.
     js/deep-field-bg.js writes it every frame: 0 for most of the page, rising
     to 1 across the Music section and again from the top of Stay Connected.
     It also STARTS at 1, before the clip has a frame to show, so the first
     thing painted is the sky the visitor already knows.
     IT IS AN OPACITY MULTIPLIER AND MUST STAY ONE. Do not "improve" the
     handoff by driving --star-dim, --star-black, --star-sat or
     --star-cloud-bright instead: all four sit inside filter: chains, and
     html::after carries a 22px full-viewport blur, so animating any of them
     re-runs a full-screen gaussian on every frame of the transition.
     css/star-bg.css:328 sets this rule for the whole system.

     THE INITIAL VALUE HERE IS 1, AND IT IS LOAD-BEARING TWICE OVER. It shipped
     as 0 for one build and the boot dissolve silently did not exist: the JS
     starts its own state at 1, but nothing writes the property until the first
     rAF tick, so the page painted with the sky already down and simply stayed
     there. Measured on a cold load: --df-sky read 0.000 at 900ms.

     The second reason matters more. If js/deep-field-bg.js never runs at all —
     a fetch that fails, a JS error, a browser that bails out — a 0 here leaves
     the page on bare #03040F with no sky and no clip. At 1 the same failure
     lands on the site exactly as it looks today. Fail towards the sky. */
  --df-sky: 1;

  /* The scrim. --df-lum is WRITTEN BY JS every frame — never set it by hand.
     It is the clip's own measured mean luminance for the frame on screen,
     normalised 0..1 across the clip's real range (YAVG 96.7 to 545.7,
     measured per frame with ffmpeg signalstats, stored in
     assets/lab/deep-field-lum.json).

     The two dials below are the mapping, and they live in CSS rather than JS
     on purpose: JS writes one measured number, CSS decides what it means, so
     both dials can be turned without the module re-reading anything. Same
     split as --kick / --kick-cloud in css/star-bg.css. */
  --df-lum: 0;
  --df-scrim-base: 0.35;

  /* 0.30 SINCE Aug 18 2026, DOWN FROM 0.5, because Merch read as dark to the
     owner and the reason was this dial doing its job too well. The scrim is
     base + gain * lum, so it darkens IN PROPORTION to the clip, and Merch is
     parked on --df-lum 1.000 - the clip is luminance peak. Picking the
     brightest frame was therefore what drove the scrim to its ceiling: the
     brightest frame and the brightest thing on screen were opposites.

     MEASURED at every stop, the painted background as it ships against the
     same frozen frame with the scrim hidden. It only ever bit in two places -
     everywhere else the clip is near black and the scrim has nothing to take:

         Merch (lum 1.000)   19.0 against 98.0    80.6% taken
         Archive (0.512)     49.0 against 118.5   58.6% taken
         every other stop                         0 to 4%

     THE GAIN IS THE RIGHT DIAL FOR THIS and --df-scrim-base is not: base lifts
     every stop uniformly, including the dark ones that do not need it, while
     the gain only gives back what it took from the bright frames.

     V2HANDOFF 40 RECORDS MERCH AS HAVING NO SCRIM HEADROOM LEFT. THAT IS
     WRONG, and it was wrong when it was written - it was asserted rather than
     measured. Sweeping the gain at Merch against the copy real colour:

         gain 0.50 (was)  background 14.1   contrast 8.94:1
         gain 0.30 (now)  background 21.9   contrast 8.40:1
         gain 0.00        background 33.2   contrast 7.49:1

     Even with the gain at ZERO the copy clears WCAG AAA (7:1), let alone AA
     4.5. So there was never a readability wall anywhere near where this sat;
     0.30 is the owner asking for a little rather than all of it, and there is
     room to go further if it still reads dark. */
  --df-scrim-gain: 0.30;

  --df-on: 1;

  /* Handoff and reveals. All three are read by js/deep-field-bg.js at 5Hz, so
     the tuner tab and devtools turn the same knobs and neither has to tell the
     other. */
  /* How much of Archive the sky takes to come back up, as a fraction of that
     section. 1 spends the whole span on the swell and reaches full exactly at
     Stay Connected, so the sign-up lands on the bright nebula. The Music
     arrival is NOT this one - it is derived from where the carousel frames. */
  --df-tail: 1;
  /* THE TWO SIDES OF THE CROSSFADE, AND THEY ARE NOT THE SAME EVENT.
     Per-frame lerp rates, not durations. sky-out clears the nebula as a leg
     begins; sky-in raises it over the frame the clip has already settled on.

     THEY ARE EQUAL AGAIN, AT 0.06, and that is the owner's call rather than a
     value nobody revisited (Aug 18 2026). They shipped asymmetric for half a
     day - 0.10 out against 0.055 in - on the argument that the exit should
     barely be noticed while the entrance is the transition the reader is meant
     to watch. Judged on the page, the fast exit was the part that read wrong.

     WHAT THE SLOWER EXIT COSTS, MEASURED on the Music -> Merch leg, which is
     the one that leaves the sky:

         sky-out 0.10   nebula clear 15 frames into the leg (604ms)   21% of it
         sky-out 0.06   nebula clear 27 frames into the leg (1117ms)  39% of it

     So the clip has the screen to itself for 61% of that leg rather than 79%.
     That is the trade being made: less clean footage, a gentler handover. If
     the clip ever needs that screen time back, this is the number to raise -
     and the separate rates still exist, so it can be raised without touching
     the entrance.

     Both were a single 0.18 until Aug 18 2026, when the sky still followed
     scroll position. */
  --df-sky-in: 0.06;
  --df-sky-out: 0.06;

  /* THE PLAYBACK HOLD, build 12. 1 = a playing sample holds the sky up wherever
     the reader scrolls to; 0 = the sky follows scroll position alone, which is
     how every build before this one behaved.

     WHAT IT FIXES. The handover to the reactive sky was pure geometry: skyAt()
     raises it across Music and drops it the moment you leave, and because the
     player lives in Music the swap LOOKED like it was answering playback. It
     never was. MEASURED Aug 26 2026 at 1440x900, one wheel gesture out of Music
     with a sample still running: --df-sky went 1.0000 -> 0.0104 at 900ms ->
     0.0000, while .is-playing stayed true and js/spine-bg.js kept writing
     --kick and --snare every frame. The home page runs --spine-on: 0
     (css/spine-bg.css:664), so the sky layers are the ONLY consumers of those
     two envelopes - the detector was computing a full kick and snare response
     that was then multiplied to nothing.

     V2HANDOFF 37 asked for "the stop-and-swap to the reactive background when a
     carousel card plays music, resuming the scrub on scroll out" and that much
     the geometry already did. This is 38's stronger reading - "a playing track
     holds the sky" - which the owner picked on Aug 26 2026 with both described.

     THE CLIP IS DELIBERATELY NOT PAUSED WHILE THE SKY IS HELD, even though it
     is fully invisible at --df-sky 1 and still decoding. The legs own the video
     element: a leg plays forward to a cue and arrive() fires at the end of it.
     Pausing mid-leg means that arrival never lands and the sequence wedges with
     skyLock still set. Letting it run costs decode work nobody sees and buys a
     seamless release - when the sample ends, the clip is already parked on the
     frame the reader's scroll position calls for, so the sky drops onto the
     right picture instead of onto a seek. If that decode cost ever matters, the
     fix is a leg-aware pause, not a bare vid.pause() here.

     WHAT IT COSTS BODY COPY, measured Aug 26 2026 on the Merch paragraph at
     1440x900, sampling the actual painted pixels behind the text with the text
     itself hidden:

       behind the copy        under 4.5:1   under 3:1   1st pct   mean
       nebula (hold ON)          18.0%         8.9%      1.24:1   6.85:1
       clip   (hold OFF)          9.8%         0.0%      3.59:1   7.89:1

     The mean is comfortably AA either way; the failures are sparse point-stars
     sitting behind glyph strokes, not a bright wash. The reason the nebula is
     the worse of the two is stated 200 lines below and is deliberate: the scrim
     lives INSIDE .df-bg, so `opacity: 1 - --df-sky` takes it away exactly when
     the sky arrives, because the scrim exists to sit over the footage.

     THIS IS NOT A CONDITION THIS BUILD INVENTED. Copy over an unscrimmed sky
     already ships at Music and at Stay Connected, where --df-sky is 1 by
     geometry. What build 12 does is extend it to Merch, Transmissions and
     Archive for as long as a sample runs. If it ever wants fixing, the fix is a
     sky-side scrim and it changes those two existing surfaces too, so it is the
     owner's call and not a tidy-up. --df-hold-play 0 sidesteps it entirely.

     THE OWNER MADE THAT CALL ON AUG 26 2026, the day after the table above was
     measured: they played a sample on the home page, scrolled out through Merch
     with the nebula held, read the paragraph, and said it reads fine. So the
     sky-side scrim is NOT wanted, and the numbers above are a record of a cost
     that was accepted - not an open defect waiting for someone to fix it. The
     18.0% is still true; it was judged and it lost to the look. Reopening this
     needs the owner, exactly as it did before. */
  --df-hold-play: 1;
  /* How long the Music title card holds the screen on its own before it hands
     over to the cards. The owner asked for "about a second"; it is a dial
     because it is a piece of timing that can only be judged by watching it -
     and it was, on Aug 18 2026, which is how it came down from 1000 to 800.
     The card's own fade is 520ms either side and is NOT part of this number,
     so the beat the reader actually sees is longer than the value here. */
  --df-title-ms: 800;
  --df-stagger: 90;   /* ms between reveals inside one section */

  /* THE SECTION STEPPER (owner's call, Aug 17 2026). One wheel gesture moves
     exactly one section and lands it framed. --df-step 0 turns it off and
     gives the page back to js/scroll-weight.js entirely, which is the state
     everything before build 4 shipped in.

     THE STOPS ARE THE WHOLE DOCUMENT: the six sections, then Stay Connected,
     then the foot of the page. Build 4 first stepped only the sections and
     handed everything below Archive back to scroll weight, which put a seam
     between two scroll authorities in the middle of the page - a flick could
     not come to rest on Archive, and escaping it downward was swallowed. The
     seam is gone rather than patched. */
  --df-step: 1;
  --df-step-ms: 620;  /* the glide between two ordinary landings. Slower than
                         the 520 the Music settle uses, because a step crosses
                         a whole section rather than correcting inside one. */
  --df-gap: 180;      /* ms of wheel silence that ends a gesture. A trackpad
                         emits inertia for up to a second after the fingers
                         lift, and every one of those events has to be
                         swallowed by the step it belongs to or one flick walks
                         three sections. Raise it if a flick still overshoots;
                         lower it if a deliberate second scroll feels ignored. */

  /* THE MUSIC STOP. Music lands on its rest point rather than on its boundary
     — the position where the card and its controls are framed and the sky is
     at full. --df-snap 0 gives Music its boundary back and makes it an
     ordinary stop like every other section, with the sky handoff and the park
     both unchanged, so it isolates the framing from everything else if it
     needs judging separately.

     --df-catch AND --df-release WERE REMOVED HERE ON AUG 18 2026, with the
     scrub and the Music race that were the only things reading them. Both are
     gone from the tuner too. Do not reintroduce either as a "speed" dial: the
     brief for play-and-park is one speed in both directions, and a rate dial
     is the first thing that would break it. */
  --df-snap: 1;

  /* Extra clearance under the masthead before the Music card, on top of
     --nav-h-max. Ships at 0 and changes nothing: an escape hatch, not a dial.
     It exists because the card once read as clipped on the owner's machine in
     a state that could not be reproduced here, which turned out to be a stale
     refresh — but a viewport or a display scale this was never measured at
     could do it for real, and this is the one-value fix if it ever does. */
  --df-card-gap: 0;

  /* HOW MUCH CLOUD COVER SURVIVES WHERE THE CLIP OWNS THE SCREEN.
     1 = the cover is always there, which is what ships. 0 = the pre-Aug 18
     behaviour, where the clouds existed only at Music and Stay Connected.
     Anything between is a floor: the layer never drops below it and still
     rises to full wherever the sky does. See the .ks-cloud-sky rule below for
     the measured cost to Merch, which is the worst case on the page. */
  --df-cloud: 1;

  /* The fork strike. css/spine-bg.css declares it 0, so this turns it on for
     THE HOME PAGE ONLY — every other page still links spine-bg.css without
     this file and is unchanged. It read as "the lab only" until Aug 18 2026;
     landing the background on index.html is what put it on the live home page,
     and it ships on deliberately because the lab the owner judged had it on.
     Set it to 0 here to take it back off without touching any other page.
     0.12 is about one strike in eight — often enough to be a thing that
     happens, rare enough that the five-pattern rotation is still what you
     mostly see. Tunable live as `s fork` in the Spine tab.

     SEEN ON A PHONE AT LAST, 2026-08-26. It had been shipping since Aug 23 with
     nobody having watched it below 768px. Measured at 390x844, Chrome for
     Testing, at the owner's live config - hz 1600 / s sns 1.95 / s coin 85 /
     s ms 410 - over six 20s samples: 9 forks in 76 strikes, an observed rate of
     0.118 against the 0.12 asked for. The struck-count histogram is clean, 67
     ones and 9 fives and never a number in between, so the clear-all-then-add
     in js/spine-bg.js holds on mobile exactly as it does on the desktop.

     IT IS NOT WASTED ON A SMALL SCREEN, which was the open question. A fork
     repaints 14.9% of a 390x844 frame at a mean luminance delta of 35, against
     13.3% and delta 48 at 1440x900 (medians; the desktop spread was wide
     because a capture can land anywhere on the decay). The phone gives up some
     brightness and none of the coverage, and two gates at the foot of this file
     are why: .df-bg is display:none up here so no clip competes for the frame,
     and --df-sky is pinned to 1, which holds the bolt at full strength in the
     calc that multiplies by it.

     Do not read that coverage figure as a single-strike figure. It is the fork,
     all five patterns at once; a lone pattern lights far less. */
  --snare-all: 0.12;
}

/* ---------------------------------------------------------------- the clip */

/* z-index -2, NOT -1. Every sky layer is at -1, and among equal z-index the
   paint order falls out of tree order — which spine-bg.js and clouds-sky.js
   both already depend on (bolts inserted first, cloud sky appended last).
   Dropping the video to -2 takes it out of that argument entirely and
   guarantees the sky composites ABOVE the footage, which is what the Music
   crossfade needs. The page floor still shows through: body's background
   propagates to the root canvas, so it paints behind even this. */
/* THE CROSSFADE LIVES HERE, ON THE WRAPPER, NOT ON THE VIDEO.

   Fading the whole layer takes the scrim with it, which is the correct
   behaviour and not merely the convenient one: the scrim exists to keep body
   copy readable over moving footage, so once the footage is gone it has no job.
   Left on its own it would sit as a 35% black wash over the real sky at Music —
   the one moment the page is supposed to look like the site always has.

   AND IT MUST BE A TRUE CROSSFADE, not the video staying lit underneath. Every
   sky layer is mix-blend-mode: screen and can only ADD light, so a video held
   at full opacity under an arriving sky makes Music brighter than either image,
   and brightest exactly along the diagonal band the two share — which is the
   band that made f134 the match frame in the first place. */
.df-bg {
  position: fixed;
  inset: 0;
  z-index: -2;
  pointer-events: none;
  /* THE FALLBACKS ARE THE FAIL-TOWARDS-THE-SKY RULE ACTUALLY BEING KEPT.

     The :root block above explains why --df-sky ships at 1: if this module
     never runs, the page should land on the sky it already has rather than on
     a bare floor. But a var() with no fallback does not survive the property
     being UNRESOLVED, which is a different case from it being 0 — and it is
     unresolved for as long as this stylesheet has not arrived. This file is
     linked LAST by design, so on a cold cache it is the last thing to land.

     MEASURED on a throttled cold load: at 133ms --df-sky read as unset, the
     calc was invalid, and .df-bg fell back to opacity 1 — the clip and its
     poster fully exposed over the page — and stayed there until 4164ms.
     Reported by the owner as the old background flashing up on a hard refresh.

     With the fallback the same moment resolves to opacity 0. Every --df-sky
     read in this file now defaults to 1 for the same reason: unresolved must
     mean sky, never clip. */
  opacity: calc(var(--df-on, 1) * (1 - var(--df-sky, 1)));
}

.df-bg__video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* No object-position shift. The clip is composed full-frame and its
     movements are centre-weighted; the hero's "center 20%" was about keeping
     two faces out of the bottom crop and does not apply here. */
}

/* The scrim sits over the footage and under everything else, including the
   sky. Its colour is the page floor, so at opacity 1 the background is exactly
   what the site looks like with no clip at all — which makes it a safe
   ceiling rather than a grey haze. */
.df-bg__scrim {
  position: absolute;
  inset: 0;
  background: var(--bg-primary, #03040F);
  opacity: calc(var(--df-scrim-base) + var(--df-scrim-gain) * var(--df-lum));
}

/* --------------------------------------------------------- holding the sky */

/* Six of the seven sky layers are pseudo-elements of html, body and main, and
   they are all mix-blend-mode: screen at z-index -1. Under screen, opacity 0
   is a true no-op composite — css/star-bg.css:108 records that a fully dimmed
   layer is "exactly the same as not being there" — so this is the cheapest
   possible off switch and it costs no repaint.

   The base sky has no opacity of its own, so it takes --df-sky directly. */
body::before { opacity: var(--df-sky, 1); }

/* THE TWINKLE BANDS AND THE NEBULA ARE NOT SUPPRESSED FROM HERE.

   They are animated on opacity and read their amplitude through
   --star-twinkle-amp / --star-cloud-amp, so the obvious move is to re-declare
   those two here. IT WAS DONE THAT WAY FOR ONE BUILD AND IT BROKE THE MUSIC
   RESPONSE: this file loads after css/star-bg.css at the same specificity, so
   it won the cascade and replaced the playing-state formulas wholesale —
   taking the 1.8 swell, the --star-twinkle-hi lift and the
   `+ var(--kick) * var(--kick-cloud)` term with it. The nebula stopped bumping
   to the kick and the sky still looked correct in every still frame.

   --df-sky is now multiplied INTO those formulas in css/star-bg.css, where it
   defaults to 1 so no other page changes. Do not re-declare the amps here. */

/* LIGHTNING: OVERRIDE THE STRUCK RULE, NEVER THE BASE ONE.

   This shipped for one build as a bare `.star-bolt { opacity: ... }` and it
   destroyed the strike variety. css/star-bg.css:886 sets `.star-bolt { opacity:
   0 }` at (0,1,0); a bare .star-bolt selector here has exactly the same
   specificity and this file loads later, so it won the cascade and lifted the
   idle-zero off ALL FIVE divs. Every pattern then lit on every strike instead
   of only the one carrying .is-struck. The machine was still rotating the class
   correctly — five superimposed patterns simply read as one flat flash, so the
   rotation was invisible and the effect looked like a single repeating bolt.

   Matching the struck rule's own (0,3,0) and relying on load order to win is
   the correct shape: the two-and-a-half divs without .is-struck keep the base
   rule's opacity 0, exactly as star-bg.css intends. */
:root.is-spine-kicking .star-bolt.is-struck {
  opacity: calc(var(--snare) * var(--snare-bolt) * var(--df-sky, 1));
}

/* The WebGL cloud sky is the one layer with a real teardown (window.__cloudSky
   .destroy()), and it is deliberately NOT used. The instance stays alive and
   only its wrapper is hidden, because destroy() is not reversible without a
   rebuild and the layer has to come back at Music. It self-throttles when its
   canvas is not visible (js/clouds.js:536), so an opacity-0 wrapper still
   costs a little. If the frame budget ever needs it, destroy() is the lever —
   but then Music needs a rebuild path first. */

/* THE CLOUDS DO NOT FOLLOW THE SKY DOWN ANY MORE (owner's call, Aug 18 2026).

   Tying this layer to --df-sky meant the cloud cover existed on exactly two
   stops - Music and Stay Connected, the two places the sky comes up - and was
   composited away on About, Merch, Transmissions and Archive, which is every
   section the clip owns. The owner reported it as the cover having gone
   missing from those sections, and that is exactly what it was.

   --df-cloud IS A FLOOR, NOT A REPLACEMENT. max() keeps the old behaviour
   available and keeps the Music seam intact: at 0 this rule is byte-for-byte
   the old one, and at any value the layer still rises to 1 wherever --df-sky
   does, so the crossfade into Music is unchanged at its destination. It ships
   at 1, which is the owner's ask - the cover is simply always there.

   WHAT IT COSTS THE COPY, MEASURED rather than assumed, because the layer
   sits at z-index -1 while the scrim lives INSIDE the clip's -2 stacking
   context - so cloud light lands ON TOP of the scrim rather than under it, and
   V2HANDOFF 40 records Merch as having no scrim headroom left - which was
   measured on Aug 18 2026 and is NOT true; see the note on --df-scrim-gain,
   where the sweep is. Captured with
   the text hidden so the crop is the true background, at 1440x900, each
   section on its own cue frame:

       section         background mean        WCAG ratio
       About           84.1 -> 84.3 /255      3.57 -> 3.55 : 1
       Merch           16.3 -> 17.5 /255      8.81 -> 8.74 : 1
       Transmissions   37.4 -> 37.9 /255      7.16 -> 7.11 : 1
       Archive         20.1 -> 21.3 /255      8.60 -> 8.51 : 1

   The worst case is 1.3 of 255 and nine hundredths of a ratio point. The scrim
   having no headroom turns out not to matter here: the cover is broad and low
   in amplitude, not a flash. NOTE the About row is under AA at 3.55 and was
   under it at 3.57 BEFORE this change - that section parks on the clip's
   flattest horizon, which is also one of its brightest bands behind the copy.
   Pre-existing and untouched by the clouds; recorded here because this is the
   measurement that found it.

   FAILS TOWARDS THE SKY, like every other --df-sky read in this file: with
   neither property resolving, max() lands on 1 and the cover stays up. */
.ks-cloud-sky { opacity: max(var(--df-cloud, 0), var(--df-sky, 1)); }

/* ------------------------------------------------------------ the hero */

/* NOTHING HERE ANY MORE, AND THAT IS THE POINT.

   This file briefly owned a .ksd-title screen and a .ksd-title__measure shell,
   built to stand in for the messengers hero while it was parked. The hero is
   back (owner's call, Aug 17 2026) as the production markup it always was, so
   css/spine-doc.css owns that section again, js/hero-video.js drives it again,
   and js/spine-doc.js:72 finds a real .ksd-hero__media for railTop instead of
   an empty div wearing its class.

   Kept as a note rather than deleted silently: the shell existed to avoid
   touching shared JS, and if the hero is ever parked a second time the honest
   fix is the one that note carried — widen the railTop selector in
   js/spine-doc.js rather than dress a div up as a video. */

/* -------------------------------------------------------- reduced / narrow */

/* No video on phones or under reduced motion (owner's call, Aug 17 2026): the
   webm is 2.8MB and 265 frames of scrub is a lot of decode for a background,
   and no mobile pass has ever been run on this clip. The sky comes back up in
   its place, which is what the page does today. js/deep-field-bg.js checks the
   same two conditions and never attaches, so the clip is never fetched. */
@media (prefers-reduced-motion: reduce), (max-width: 768px) {
  :root { --df-sky: 1; }
  .df-bg { display: none; }
}


/* ------------------------------------------------------- cued reveals

   PLAY-AND-PARK holds a section's reveal down until the clip reaches that
   section's cue frame. js/deep-field-bg.js adds .df-cued to every
   [data-ksd-section] as soon as it knows it will run, and removes it one
   section at a time as the clip parks.

   WHY IT IS DONE THIS WAY ROUND, holding the reveal down rather than
   withholding .is-in: js/spine-doc.js calls watchReveals() synchronously at
   parse, and it is loaded BEFORE this module, so there is no flag this file
   could set in time to stop that observer being registered. Suppressing it
   would have meant editing spine-doc.js and duplicating this module's
   mobile/reduced-motion gate in a second file, where the two could drift.

   So the observer still runs and still adds .is-in — it remains the floor
   exactly as it is today — and this simply out-orders it until the cue lands.
   On a phone or under reduced motion .df-cued is never added at all, because
   the module returns before it can add it, and the observer alone reveals
   everything. THE CONTENT CAN NEVER BE STRANDED BY A VIDEO THAT DID NOT LOAD:
   the module strips every .df-cued on a video error and on scrolling well past
   a section, and those backstops are the reason this is safe to do at all.

   THIS RULE MUST OUT-ORDER .ksd-reveal.is-in, NOT OUT-SPECIFY IT. Both
   selectors score (0,2,0), so the tie is broken by source order and this file
   is linked last — the same property the sky rules at the top of this file
   already depend on. Moving this stylesheet earlier in the <head> silently
   stops the cues working and reveals everything on landing. */
/* THE CAROUSEL IS PART OF MUSIC'S REVEAL, and it is not a .ksd-reveal.

   Music's section contains exactly ONE .ksd-reveal — its h2. The track cards,
   the focus panel, the sample player and the platform buttons are all built by
   js/track-experience.js and were never in the reveal system, so they sat fully
   visible while the clip was still playing towards the cue that is supposed to
   introduce them. Reported by the owner Aug 18 2026: "the track cards and
   controls are already there".

   OPACITY AND TRANSFORM ONLY — NEVER display OR visibility. js/deep-field-bg.js
   derives the Music rest point from .track-arc-wrap's measured height and top,
   and js/track-experience.js lays the arc out from its own geometry. Taking the
   element out of layout would break the landing this section is built around. */
[data-ksd-section].df-cued .track-arc-wrap,
[data-ksd-section].df-cued .ksd-reveal {
  /* Exactly the values css/spine-doc.css:803 gives .ksd-reveal at rest, quoted
     rather than approximated: the cue removes .df-cued and the element must
     start its transition from where it was already sitting, or the reveal
     jumps a few pixels before it moves. */
  opacity: 0;
  transform: translateY(18px);
}

/* .ksd-reveal brings its own transition from css/spine-doc.css:803; the
   carousel wrapper has none of its own, so it gets a matching one here. */
/* SCOPED TO html.df-live, and that scope is load-bearing. Unscoped, this is
   (0,1,0) in a file linked last, so it beat css/track-experience.css:96's
   `transition: none` inside ITS reduced-motion query - media queries add no
   specificity - and quietly reinstated a transition on the one class of user
   who asked for none. Scoping it to a class that is never set below the gate
   means the reduced-motion rule keeps the last word where it matters. */
html.df-live .track-arc-wrap {
  transition: opacity 640ms var(--ease-standard), transform 640ms var(--ease-standard);
}

/* ------------------------------------------------------- the Music title card

   "Enter the Tracks" holds the screen alone for --df-title-ms once the clip
   parks on Music's cue, then fades out and the cards come up in its place.
   js/deep-field-bg.js runs the sequence; this file only describes the states.

   IT OVERLAYS .ksd-bleed, so it is centred on exactly the box the carousel is
   about to fill. .ksd-bleed is given a positioning context HERE rather than in
   css/spine-doc.css, and that is still right now the card ships on index.html:
   spine-doc.css is shared with about.html, merch.html and the rest, and none
   of them should pick up a positioning context they have no card for. This
   file is only ever linked by a page that has one.

   pointer-events: none THROUGHOUT. The card sits over the carousel's whole
   area, and the carousel is dragged and clicked — a transparent overlay that
   still took the pointer would make the cards feel dead for as long as it
   lingered, which is the kind of fault that gets blamed on the carousel.

   NOT display OR visibility, for the same reason the cued reveals are not:
   js/deep-field-bg.js derives the Music rest point from .track-arc-wrap's
   measured box, and this element shares its containing block. Opacity only. */
.ksd-bleed { position: relative; }

.ksd-music-title {
  position: absolute;
  left: 0;
  right: 0;

  /* ON THE RAIL NODE'S LINE, not in the middle of the bleed. --df-title-y is
     written by js/deep-field-bg.js from the very number the Music node is
     placed from, as an offset inside .ksd-bleed.

     HEIGHT ZERO IS THE MECHANISM. A flex box of no height with align-items:
     center puts the text's optical centre exactly on `top` and lets it
     overflow equally either way, so the line lands ON the number rather than
     inside a box that happens to be centred near it. .ksd-bleed clips with
     overflow: hidden, but the node's line is ~263px into a 791px box, so
     there is room on both sides.

     THE 50% FALLBACK IS THE OLD BEHAVIOUR EXACTLY. Before the carousel has
     been measured the variable is unset, and `top: 50%` on a zero-height box
     centres the type in the bleed - which is what `inset: 0` did. So the card
     is never mispositioned while it waits for a number. */
  top: var(--df-title-y, 50%);
  height: 0;

  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  pointer-events: none;

  font-family: var(--font-display);
  font-weight: var(--display-wght, 800);
  font-size: clamp(38px, 6vw, 92px);
  line-height: 0.94;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  color: var(--color-white);
  text-shadow: 0 2px 40px rgba(0, 0, 0, 0.6);

  opacity: 0;
  transform: translateY(14px);
  transition: opacity 520ms var(--ease-standard), transform 520ms var(--ease-standard);
}
.ksd-music-title.is-on {
  opacity: 1;
  transform: translateY(0);
}

/* No card at all where the module never runs: on a phone and under reduced
   motion nothing sequences it, so it would sit permanently on top of the
   carousel. The real <h2> is the heading there, exactly as it is today. */
@media (prefers-reduced-motion: reduce), (max-width: 768px) {
  .ksd-music-title { display: none; }
}

/* THE REAL HEADING STOPS BEING THE VISIBLE ONE, but stays exactly where it is.

   Removing the "Rise Up isn't on streaming platforms yet" paragraph shortened
   the Music head by 99px and moved the <h2> from top -96 — safely above the
   viewport — to top 3, so it sat at the top of the screen while the title card
   animated below it and the reader saw "Enter the Tracks" twice.

   IT IS NOT DELETED, AND IT MUST NOT BE. js/spine-doc.js:94 reads
   `if (!sec.head) return;` and places the Music rail node from .ksd-head's box.
   With no .ksd-head the section gets no entry in the node table at all, so the
   node does not move — it disappears. The heading is also the section's only
   real heading; the title card is aria-hidden precisely so it is not announced
   twice, which only works while this one still exists.

   OPACITY, NOT display OR visibility OR height. The box has to keep its size or
   the node slides and every landing below Music shifts again. This costs 72px
   of empty space at the top of the section, which musicRest absorbs because it
   frames the carousel rather than the section top.

   SCOPED TO html.df-live, which js/deep-field-bg.js sets only after its
   mobile and reduced-motion gate. Where the module never runs there is no
   title card, and this heading is the only one there is.

   AND SCOPED TO .df-titled, WHICH IS THE HALF THAT WAS MISSING — the class
   js/deep-field-bg.js puts on the section at the moment the card actually
   takes the screen. html.df-live alone means "the module is running", which is
   NOT the same as "the card ran": a clip that errors or never decodes leaves
   df-live set forever, the reveal backstops hand the content back, and the
   card is never released because no cue ever fires. Hiding on df-live alone
   therefore shipped a Music section with NO VISIBLE TITLE AT ALL on any
   failure of the clip — measured Aug 18 2026 by aborting the clip request:
   h2 opacity 0, card opacity 0, 28 track cards up and nothing naming them.
   Keyed to .df-titled the fallback is automatic: no card, no hiding.

   THERE IS NO FLASH IN THE GOOD PATH. The section is held by .df-cued until
   260ms after the card's hold ends, and .df-titled goes on in the same
   statement that turns the card on — so the heading passes straight from
   "hidden by the reveal" to "hidden by the card" with nothing in between. */
html.df-live [data-ksd-section="music"].df-titled .ksd-music-head .ksd-head { opacity: 0; }

/* AND HANDED STRAIGHT BACK BELOW THE GATE, which is not belt-and-braces - it
   closes a real hole. Both classes above are set ONCE, at init
   (js/deep-field-bg.js:120 is evaluated a single time and there is no
   matchMedia change listener), so they survive a resize that the card does
   not: .ksd-music-title is display:none below 767px, but the rule above is in
   no media query at all. Load at >=768 and narrow to <=767 and the heading is
   hidden with nothing to replace it.

   THAT IS NOT AN EXOTIC PATH. iPhone 14 Pro landscape is 852 CSS px and 15 Pro
   Max landscape is 932, so "open in landscape, rotate to portrait" lands in it
   exactly, and what the reader gets is the Music section with 28 track cards
   and nothing naming them - the same failure the note above records for the
   dead-clip path, arriving by a different route.

   IT HAS TO SIT AFTER the rule it undoes. Media queries add no specificity and
   both selectors are (0,5,1), so this wins on source order alone. Moving it
   above, or into either earlier gate block in this file, silently stops it
   working. */
@media (prefers-reduced-motion: reduce), (max-width: 768px) {
  html.df-live [data-ksd-section="music"].df-titled .ksd-music-head .ksd-head { opacity: 1; }
}
