/* ==========================================================================
   SPINE BACKGROUND — the charged column behind everything below the hero.
   Loaded by index.html AND about.html. Pairs with js/spine-bg.js.

   ARTWORK: assets/hero/spine-column-mirror-4k.webp — 3072x8890, WebP q84,
   381 KB. Added 2026-08-05, and it is a derivative of the file described below,
   not a new drawing: rows 491..4936 of spine-column-moonlight-4k.webp plus a
   vertical mirror of that crop, so the layer tiles with no seam and no gap at
   the mirror point. Full reasoning and the measurements are at .spine-bg__art —
   read the "WHY 491 AND 4936" paragraph before recutting it, because the first
   attempt cropped 200 rows too far out and that is exactly what the gap was.
   A 2048-wide cut, spine-column-mirror.webp (2048x5927, 248 KB), sits beside it
   for the mobile breakpoint and is NOT wired up yet.

   THE SOURCE: assets/hero/spine-column-moonlight-4k.webp — 3072x5504, WebP q84,
   212 KB. Replaced the 1536x2752 original on 2026-08-04: same image, twice the
   linear resolution, with Metatron's Cube and all the background geometry
   removed. Made by feeding the old file back through nano_banana as an EDIT,
   not by generating a new one — the previous artwork took a whole session to get
   the colour right and a fresh generation is exactly how the first one came back
   orange. spine-column-moonlight.webp is kept beside it for comparison.

   WHY 4K MATTERS HERE: the column is a quarter of the file width, so the old
   file held only 394px of actual spine and rendered it at --spine-w: 640px CSS
   = 1280 device px on a 2x display. A 3.25x upscale, which is what read as soft.
   The new file holds 788px, so the same render is a 1.62x upscale.

   MEASURED off the SHIPPED webp, not the PNG it was encoded from, and not
   guessed. Re-measure all of these if it is ever replaced or the column will not
   sit where this CSS thinks it does:
     - column extent 0.3327 of the width, centred at 0.4998
       (the old file measured 0.3346 / 0.5003 by the same method, and its
       half-maximum width 0.2142 against this one's 0.2122 — the geometry is
       UNCHANGED within 0.6%, which is why --spine-frac below did not move)
     - field reads (0.2, 0.1, 0.4) — effectively true black, see the clamp note
     - encode error 0.429/255 mean absolute difference against the source PNG,
       against 0.888 for the file it replaces

   THE BLACK CLAMP. The generated file had a faint asymmetric wash where the cube
   used to be: the right margin measured mean L 2.08 against the left's 0.20,
   with a soft rectangle still readable in the top right. Under screen blending a
   wash like that can only ADD light, so it would have shown up as a soft vertical
   band lifting the page — the exact defect --mask-sides was invented for. Fixed
   before encoding by subtracting 6 levels and clipping at zero: margins went to
   0.000 and 0.016, and the strokes lost 1% of their brightness. A linear
   subtract, not a threshold, so nothing bands at the clip point.

   COLOUR: matched to messengers-hero-video.mp4 frame 3, the spine close-up.
   The hero is essentially colourless — stroke core #CDCCC6 at 7% saturation,
   body #A1A39D at 3%, halo #5E5E5A at 2%. The FIRST artwork was prompted
   "warm bone-white and pale amber" and came back orange, which is what read as
   not matching. Do not re-warm it.

   The 4K file measures (240,240,243) at 1.7% saturation in the strokes — cooler
   and cleaner than the 1536px file it replaces. But its HALO came back at 20.8%
   saturation, distinctly blue-violet, against roughly 13% before. It did not go
   warm, so the standing rule holds, but it is more coloured than anything else
   on this page. The dim layer already carries saturate(0.55), which lands that
   at about 11%; THE LIT LAYER CARRIES NO saturate() AT ALL, so the charged
   column shows the full 20.8%. If the glow ever reads too blue, that is where it
   is coming from and a saturate() on .spine-bg__art--lit is the lever — check it
   against the star field, which is the other blue thing on the page.

   HOW IT WORKS: two copies of the artwork, stacked and pixel-identical. DIM is
   always on. LIT is revealed by a gradient mask whose stop tracks scroll. Only
   the mask moves, so a scroll frame writes one custom property and triggers no
   layout; both filters are static and each layer rasterises once.
   ========================================================================== */

:root {
  /* Bumped on every change so the tuner can report which stylesheet the browser
     actually loaded. python -m http.server serves Last-Modified only, and a
     browser sitting on a cached copy looks identical to a change that did not
     work — this makes the difference visible instead of arguable.
     43 = the snare stops missing half the strikes: the 200Hz body window is
     two-sided now and lives on a slider (--snare-coinc), and the owner's ear
     set the whole channel afterwards — sens 2.2 -> 1.95, freq 2500 -> 1600,
     coinc 85, and --snare-all promoted 0 -> 0.12 so one strike in eight forks
     on the live site. Measured 15.6 strikes per 20s against 9.0 before any of
     it; both tables are in the snare block.
     42 = the palette reverted to Signal Red, which in this file is the one
     hardcoded scan-line core, back to #C8302E; everything else here takes
     the accent through var(--color-crimson-lit) and needed no edit. 41 was the
     violet swap itself, the same single stop in the other direction.
     40 = --snare-all, the chance one strike lights every lightning pattern at
     once; ships at 0, so no page changed.
     39 = --snare-decay 410 on the home page (html.page-home), the second value
     ever to land in that block. The Copy CSS it came from carried all 26
     sliders and the other 24 matched :root exactly, so only this one was
     taken; see the block itself. 38 was the merged /?tune panel and the
     film-row atmosphere, neither of which changed a value in this file.
     37 = the mobile block re-derived against today's desktop values — the
     stale set HANDOFF 14 records (width 390 -> 73px, lit 0.3 -> 0 restoring
     the flash-out-of-black mechanism, band pair rescaled) — plus the first
     page-scoped rule INSIDE the media block: html.page-about keeps its
     inverted polarity on phones (lit 0.84 above dim 0.08), which the shared
     phone values would otherwise silently reverse. Calibrated at a real
     390px viewport; not yet seen on the owner's phone.
     36 = the mobile selector list below widened with page-archive /
     page-transmissions. Those pages do NOT link this file — they got the sky
     only (see css/star-bg.css build 21) — but the "add any new page class to
     both lists in the same commit" rule is kept anyway, so the day one of
     them links the spine the phone values already win. No spine value changed.
     35 = --spine-on, the whole-column toggle, and the home page turning its
     spine OFF via the first value ever to land in html.page-home.
     34 = the snare detector (--snare envelope + listening controls below) and
     the lightning rewired onto it — the lightning answers the SNARE, not the
     kick. 33 = the lightning layer wired in (js injection + tuner rows; the
     layer itself lives in css/star-bg.css) and the stale "loaded by
     index.html only" header line above corrected. */
  --spine-build: 43;

  --charge: 0;          /* 0..1, written by js/spine-bg.js every scroll frame */

  /* THE WHOLE COLUMN, ON OR OFF. 1 = the layer as tuned; 0 = no spine at all —
     art, bloom and scan all go to opacity 0. A page turns its spine off by
     setting this in its page block (html.page-home does, below), which is the
     point of the control: the layer, the script, the detectors and the tuner
     all keep running, so the kick still reaches the nebula and the snare still
     reaches the lightning on a page whose column is off.
     CONSUMED ON THE CHILDREN, NEVER ON .spine-bg ITSELF. Opacity below 1 on
     the parent creates a stacking context, and the children's screen blend
     would then resolve against the group's transparent backdrop instead of
     the page — the same grey-rectangle trap as the z-index note at .spine-bg,
     arriving through a different property. On the children it is just a
     multiplier on each blended layer and intermediate values merely dim, so
     the slider's 0/1 step is a choice of meaning ("a toggle"), not a safety
     rail. The section scrims are deliberately NOT included: they attenuate
     content backdrops rather than painting the column, they ship at 0, and
     --spine-scrim is already their own control. */
  --spine-on: 1;

  --spine-w: 120px;     /* the width the COLUMN renders at, not the artwork */
  --spine-frac: 0.2565; /* measured: column width / artwork width */
  /* THESE TWO NAMES NOW DESCRIBE THE OPPOSITE OF WHAT THEY DO. Read this before
     touching either. As of build 29 the CHARGED column is 0 — fully black — and
     the UNCHARGED column is 0.22. So the charge front DARKENS the spine as it
     travels rather than lighting it, and `lit` is the dark state.

     That is deliberate and it pairs with --kick-flash, which went 0.34 -> 0.72
     in the same pass. --kick-flash adds brightness to the LIT region, so the
     charged column is invisible until a drum hit and then flashes hard out of
     black. The column is no longer a thing that glows; it is a thing that only
     exists on the beat. Setting --spine-lit back above --spine-dim restores the
     old reading and breaks that effect.

     The names are kept rather than swapped because every mask, every coefficient
     and five handoffs of measurements refer to them, and renaming would silently
     invert all of it. */
  --spine-dim: 0.22;    /* brightness of the UNCHARGED column — the visible one */
  --spine-lit: 0;       /* brightness of the CHARGED column — black, see above */
  --spine-beam: 0;      /* crimson scan line, 0..1.5 — tuned off 2026-08-04 */
  --spine-scrim: 0;     /* multiplier on every scrim below, 0 = none */

  /* How far the charge front's transition reaches — the length of the ramp
     between fully lit and fully dark, in px. Small values give a hard edge
     travelling down the column; large values light a long stretch of spine at
     once and the front reads as a swell rather than a line. Everything below is
     expressed as a fraction of this, so the ramp keeps its shape at any length.
     The shipped 220px is a swell rather than a line, tuned by eye. It replaced
     10px, which was effectively a hard travelling edge; at that end --spine-bias
     becomes almost inert, because there is no ramp left for it to move. */
  --spine-feather: 430px;

  /* WHERE THE LAYER ITSELF STARTS, in px from its anchor element, negative
     being higher up the page. This is the one to reach for when the column
     begins too far down — it moves the REGION, so the charge front is remapped
     over the new span and still finishes when the document bottoms out.

     Read by measure() in js/spine-bg.js, not by any rule in this file: the
     layer's top and height are inline px, because CSS cannot see where the
     anchor element sits. Declaring it here is still correct — it is where the
     tuner reads its starting value from, and an undeclared custom property
     reads back as empty, which measure() would take as 0 without complaint.

     Do not confuse it with --spine-offset directly below. This moves the region
     AND the front; that moves only the artwork inside a region that stays put.
     Both are useful and they are not substitutes. */
  --spine-from: 0px;

  /* Vertical offset of the artwork inside the layer. Negative moves the column
     UP, so the spine can start level with the MUSIC eyebrow instead of below
     it. The layer itself does not move — it is still pinned to its anchor and
     still runs to the bottom of the document — only the artwork slides within
     it, so the charge front, the scan line and the section scrims all stay
     where they are. With repeat-y this shifts the whole tiled run together. */
  --spine-offset: 140px;

  /* Where the ramp sits RELATIVE to the charge front, -2 .. 5.
     --spine-feather is the ramp's LENGTH; this is its position. At 0 the ramp
     trails above the front, which is the original behaviour. At 0.5 it straddles
     the front. At 1 it sits entirely below it. Negative pushes it further above.
     This is what lets the glow be centred behind the hero card rather than
     always ending at the front line. The range runs well past 1 because the
     useful travel is measured in CARD HEIGHTS, not ramp lengths: the hero card
     is 490px, so at a 420px reach a bias of 1 only carries the ramp's bottom to
     about +487px and the glow still stops around the card's lower edge. Values
     of 2-3 are what put it fully behind the card.
     It is a separate control rather than negative --spine-feather because a
     negative length would put the gradient stops out of order, and CSS clamps
     out-of-order stops into a hard edge instead of mirroring the ramp. Every
     coefficient below stays monotonically increasing at any bias, so the ramp
     keeps its shape wherever it is placed. */
  --spine-bias: 1.3;

  /* ---- THE GLOW BAND — the lit zone's upper edge --------------------------
     The lit region used to be a HALF-PLANE: everything above the charge front
     was lit, with no top edge. That is why the hero card always sat at the EDGE
     of the glow rather than inside it, and why raising --spine-bias past ~2 did
     nothing — once the ramp cleared the viewport, everything visible was simply
     lit. --spine-band gives the zone a top edge, which turns it into a band that
     --spine-bias can park on the card.

     --spine-band is the band's HEIGHT in px, measured UP from the bottom of the
     charge front's ramp. --spine-band-feather is the softness of its top edge,
     independent of --spine-feather, so the band's top edge and the charge front
     below it can be ramps of completely different lengths.

       --spine-band: 3000px   = OFF. The top edge sits above the layer at every
                                desktop layout, so the mask is identical to the
                                old half-plane. That, not 0, is the off switch.
       --spine-band: 0        = the band collapses onto the front and NOTHING is
                                lit. Legitimate ("glow off"), not a bug.

     WHY A SEPARATE MASK LAYER. The previous attempt added two more stops to
     --mask-charge below, guarded with max(). It parsed (verified in computed
     style) and had no measurable effect on the rendered mask at any value from
     4000px down to 80px. Adding stops to that gradient means every stop has to
     stay monotonic against the front's own three, at every value of --charge,
     --spine-feather and --spine-bias — and CSS silently clamps out-of-order
     stops rather than failing, so a wrong guard reads as "no effect" instead of
     as an error. The band is its own mask layer, intersected with the charge
     mask, so its two stops only ever have to be ordered against EACH OTHER.
     That holds for any --spine-band-feather >= 0, unconditionally.

     SHIPPED AT 800px WITH A FEATHER OF 600px, build 29. THE BAND IS ON.
     It shipped OFF (3000px) from build 25 through 28, so anything written about
     this layer in HANDOFF 8 through 12 describes a mask that was not doing
     anything. The 800/600 pair is close to fully soft — the feather is three
     quarters of the band's own height — so the top of the lit zone is a long
     ramp rather than the hard travelling edge the paragraph below was written
     about. Both edges are now swells.

     HISTORICAL, for the earlier setting: 1260px with a feather of 0 put the
     band's two stops at the same position, which is a legal hard step and not an
     ordering violation — that is the property the separate-mask-layer design
     guarantees for any feather >= 0. That gave a HARD EDGE travelling down the
     column against a soft charge front below it, deliberately asymmetric: a line
     arriving, a swell leaving. Drop --spine-band-feather back toward 0 to get it.
     Anything at or above 3000px switches the band off outright.

     NOT RE-DERIVED FOR MOBILE. 3000px was only the off value at desktop layout
     heights, and now that the band is on at 800px the phone needs its own number
     for a different reason: the mobile document is roughly twice as tall, so the
     same band covers half as much of it. Filed with the rest of the stale
     overrides at the foot of css/star-bg.css.

     MEASURED at 1440x900 with the LIT LAYER IN ISOLATION — dim 0, page content
     hidden, and the artwork swapped for a flat white column so the profile reads
     the MASK and not the vertebrae (the tuner's "mask only" view mode does all
     three). Scrolled to 40% of the range: charge front at viewport y 607, hero
     card occupying y 133-623. Taken at --spine-feather: 10px, before the front
     was retuned to a 340px swell — the band's own geometry is independent of it,
     but the numbers below will not reproduce exactly at the shipped feather.

       band      top edge   top edge   lit band
                 predicted  measured   on screen
       3000px       -2400      —       off: lit from the top of the viewport to
                                       607, pixel-for-pixel the old half-plane
        600px           7      —       ramp sits above the viewport; also off in
                                       practice at this scroll position
        400px         207      ~190    ramp visible, top edge above the card
        300px         307      ~305    ramp 67..307, plateau down to 600
        200px         407      ~410
          0px         607      ~600    band collapsed onto the front, nothing lit

     "Measured" is the 50% crossing of the top ramp, so it lands at t1 minus half
     of --spine-band-feather; every value is within ~20px of prediction, which is
     the sampling step. The band's bottom edge measured 600 against a front at
     607 in every run — it is the charge mask doing that, unchanged.

     Do not re-measure this from a composite screenshot. A luminance profile of
     the real page is dominated by artwork content: the previous attempt was
     judged that way and a change that did nothing looked like it was working.
     Note also that the plateau brightness varies about +/-15% run to run in
     headless Chromium at identical settings — that noise predates this and is
     not the band. Judge the EDGE POSITIONS, which are stable. */
  --spine-band: 800px;
  /* NOTE 2026-08-05: the band is at its OFF value above, so its top edge sits
     off-layer at every desktop layout and this feather has no edge to soften.
     Inert, not wrong — it is carried so the number survives if the band is
     switched back on. The mobile override below still runs the band at 880px,
     where its own feather DOES apply. */
  --spine-band-feather: 600px;


  /* While a sample is playing the charged column breathes between these two
     brightnesses instead of sitting at --spine-lit. Driven by the is-playing
     class track-experience.js already sets, mirrored onto <html> by
     js/spine-bg.js — no change to the player itself. */
  --spine-pulse-lo: 0;
  --spine-pulse-hi: 0.2;
  --spine-pulse-ms: 4100ms;

  /* ---- THE KICK ---------------------------------------------------------
     The breathing above is a fixed loop that only knows whether audio is
     playing. These drive the column from the actual low end of the sample.
     js/spine-bg.js writes --kick and --kick-sign once per frame while a sample
     is playing and the analyser is running; everything else here is a control.

     --kick is a 0..1 ENVELOPE, not a level: it is set on each detected onset
     and decays exponentially, so it reads as a hit rather than as a VU meter.
     --kick-sign flips +1/-1 on every onset so consecutive hits throw the column
     to opposite sides and it reads as a shake rather than as a lean.

     NOTHING BELOW APPLIES unless <html> carries .is-spine-kicking, which
     js/spine-bg.js only adds once the Web Audio graph is actually running. If
     Web Audio fails, or the browser blocks it, or reduced motion is on, the
     class never appears and the page renders exactly as build 22 did. That is
     the intended fallback, not a leftover — the breathing pulse stays as the
     "audio is playing" cue whenever the kick cannot be read. */
  --kick: 0;
  --kick-sign: 1;

  /* HOW IT LOOKS. Both are per-unit-of-envelope, so at --kick: 1 you get
     exactly these. */
  --kick-flash: 0.72;   /* brightness ADDED to the lit column on a hit */
  --kick-shake: 15;      /* px the artwork throws sideways on a hit */

  /* HOW IT LISTENS — read by js/spine-bg.js, not by any CSS rule. They live
     here so they are in the tuner with everything else and Copy CSS emits them
     into this file's :root along the way.
       gain   multiplier on the envelope before it is written. 0 is off at the
              source and stops the rAF loop entirely, so it is also the "cost
              me nothing" setting.
       decay  time constant in ms. The envelope falls to ~37% after this long,
              so the visible tail is roughly 2-3x it. Below the refractory
              window (150ms) hits read as separate ticks; well above it they
              smear into each other and the column just sits bright.
       sens   how far above the running average counts as a hit, as a MULTIPLE
              of it. A ratio of real amplitude, not of decibels, so 1.8 means
              1.8x. MEASURED against a hand-built reference across all 28
              samples: 1.8 gives precision 0.76 / recall 0.79; 1.5 trades
              precision for recall at 0.70 / 0.81; past ~2.5 the quiet tracks
              drop out. Reach here first when it fires too much or too little.
       freq   the lowpass cutoff of the analysis side chain, in Hz. THIS is the
              "isolate the kick drum" control. 90 measured best. Toward 150
              lets more of the bass line trigger it; below ~60 the drum's own
              fundamental starts getting cut on the lighter tracks.

     WHY THERE IS NO lo/hi PAIR ANY MORE. Build 23 summed FFT bins between two
     frequencies. At fftSize 2048 a bin is 21.5Hz, so 40-120Hz was FOUR BINS,
     and every band setting tried resolved to the same four and scored
     identically — those two sliders were close to inert. The detector now uses
     a real BiquadFilterNode instead of bins, so there is one honest control
     (the cutoff) rather than two that did not do what they said. */
  --kick-gain: 1;
  /* 260 -> 320 on 2026-08-05, for the nebula rather than for the spine. The
     cloud layer is blurred 22px and has no detail left in it, so a fall as fast
     as the column's reads there as a flicker rather than as a swell. 320 still
     clears the beat: at 140bpm the envelope is down to 0.14 by the next hit, so
     hits stay separate rather than smearing into a sustain.
     ONE ENVELOPE FEEDS EVERYTHING KICK-DRIVEN — this moves the spine flash,
     the shake and the nebula together, and those three cannot be given
     different decays without another envelope in js/spine-bg.js. The snare
     envelope below IS another envelope, with its own decay — but it drives
     only the lightning, so this coupling note still stands for the kick's
     three consumers. */
  --kick-decay: 260;
  --kick-sens: 1.8;
  --kick-freq: 90;

  /* ---- THE SNARE, added 2026-08-06 --------------------------------------
     A SECOND envelope, same shape as --kick — 0..1, set on each detected
     onset, exponential decay, written by js/spine-bg.js once per frame under
     the same .is-spine-kicking class (the class means "the detector loop is
     running", and one loop runs both machines) — driving exactly ONE thing:
     the lightning layer, .star-bolt in css/star-bg.css. The kick and the
     snare are two separate instruments and two separate channels: the kick
     moves the column, the shake and the nebula; the snare strikes the
     lightning. Do not wire anything else to this without meaning to.

     HOW IT LISTENS, proven OFFLINE FIRST against all 28 samples in
     scripts/snare-tuning.py — HANDOFF 13's sequencing rule, and the proof
     earned its keep: the obvious "both bands rising" gate measured 26 false
     onsets per 20s and phase concentration 0.30, and was thrown away before
     any of it reached this file. What shipped instead:

       - a 2.5kHz HIGHPASS x3 side chain (the snare's noise burst) with the
         same base/peak/floor threshold machine as the kick,
       - gated on a COINCIDENT ONSET in a 200Hz body band (its own machine,
         fixed sens 1.5 in the JS) within --snare-coinc, 45ms by default — a
         snare is the one thing in these mixes that hits both bands at once;
         hats have no body, bass has no noise. The window was backward-only
         until 2026-08-23 and is two-sided now; that block is below, and it
         is the one thing to reach for when strikes go missing,
       - and a SYMMETRIC ±45ms kick veto, paid for by holding each candidate
         45ms (~3 frames) before the flash — the kick's own beater click
         crosses 2.5kHz a few ms before the low band trips, so a one-sided
         veto still fires on the kick itself. The 45ms lands well inside the
         window where sound and light read as simultaneous.

     MEASURED. Two configurations matter and this file quotes both, so read
     the label before comparing any number below:

       THE SWEPT CONFIG, hz 2500 / sens 2.2 — what the offline sweep picks.
         10.9 strikes per 20s (3-15), concentration 0.62 mean / 0.31 worst,
         >= 0.5 on 20 of 28. Was 9.0 and 0.66 / 0.42 / 25 of 28 with the
         one-sided body window this replaced.
       THE SHIPPED CONFIG, hz 1600 / sens 1.95 / coinc 85 — what the owner
         dialled in by ear on 2026-08-23, and what is actually set above.
         15.6 strikes per 20s (10-21) against 16-31 kicks, concentration
         0.45 mean / 0.16 worst, >= 0.5 on 12 of 28.

     So the live page answers the snare about 70% more often than the swept
     numbers describe, and it is less consistent about where in the bar it
     does it. Concentration measures CONSISTENCY, not correctness — there is
     no hand-labelled snare reference — so the owner's ear is the last word on
     whether it is landing on the snare, and it has now had it. What the
     figure does say is that the shipped setting is answering more than one
     event per bar; some of that is the second real snare the two-sided window
     found, and some of it, at 1600Hz and 1.95, will be hats.

       decay  ms; the strike's visible tail is roughly 2-3x this.
       sens   threshold as a MULTIPLE of the high band's running average.
              2.2 is deliberately stricter than the kick's 1.8: the high band
              is busier (hats, sibilance, vocal noise), and the sweep showed
              everything below ~2.0 bleeding onto the hats. Fire-on-
              everything: raise. MISSING STRIKES: NOT THIS ONE — go to coinc.
              Measured, this was never the bottleneck: at 2.2 only 17 of 609
              onsets were lost to anything the threshold could fix, while
              dropping to 1.5 tripled the onsets for a third more strikes and
              took phase concentration from 0.62 to 0.32. The tuner floor was
              lowered to 1.2 on 2026-08-23 at the owner's request; the bottom
              half of that range is the hat detector, and it is theirs to
              judge by ear. SHIPS AT 1.95, which is on the wrong side of that
              2.0 line on purpose — see the note on the declaration.
       freq   highpass cutoff of the noise side chain, Hz. 2500 measured
              best; 2000-3000 all workable, below that the body bleeds in —
              i.e. the noise band starts hearing what the body band hears, the
              two stop being independent evidence, and the coincidence gate
              gets easier to satisfy by accident. SHIPS AT 1600, below that
              line, also on purpose. At the shipped sens and window, moving
              the cutoff back up to 2500 lifts concentration 0.45 -> 0.49 but
              drops the quietest track from 10 strikes per 20s to 5; the owner
              took the floor over the consistency.
              The 200Hz body band is NOT this and is not a slider.
       coinc  how far apart the noise burst and the 200Hz body thump may land
              and still be read as one snare, ms. See the block below — this
              is the control that decides HOW MANY snares are caught, and
              sens is not. */
  --snare: 0;
  --snare-decay: 260;
  /* SENS AND FREQ ARE THE OWNER'S EAR, 2026-08-23, not the sweep's pick. The
     sweep's best pair is sens 2.2 / freq 2500 and both are still written up
     below, because they are the measurement and it did not change; these are
     the numbers the owner dialled in on the live page afterwards, listening to
     the lightning against the record rather than to a concentration score.
     Both sit outside what the offline run recommends — 1.95 is under the 2.0
     the sweep called the hat line, 1600 is under the 2000 where the body
     starts bleeding into the noise band. What that costs, measured the same
     way as everything else here, is in the block below. Do not "restore" them
     to the swept values: they are a decision, not a drift. */
  --snare-sens: 1.95;
  --snare-freq: 1600;

  /* ---- THE BODY WINDOW, and why it is the recall control, 2026-08-23 -----
     The owner's report was that the lightning answered about half the snares
     they could hear. It was not the threshold. Re-measured over all 28
     samples at what was then shipped (hz 2500, sens 2.2), counting why each
     noise-band onset was turned away:

       609 noise onsets, 260 accepted.
       293 (48%) rejected by the BODY GATE.
       43 by everything else combined — refractory 17, pending-busy 19,
       kick veto 7.

     And the gate was one-sided: it asked whether the body band had ALREADY
     fired. Of those 293, the nearest body onset arrived 0-45ms AFTER the
     noise onset 63 times and 45-100ms after another 66. Which of the two
     bands crosses its threshold first is a property of the mix, so a
     backward-only window threw away every snare whose body was the slower
     half of it.

     js/spine-bg.js now tests the coincidence when the candidate RELEASES
     rather than when it starts — the 45ms hold the kick veto already pays
     for — so the window is `coinc` ms backward and up to 45ms forward at no
     latency cost. Measured strikes per 20s, mean over the 28 samples, AT THAT
     SAME hz 2500 / sens 2.2:

       one-sided 45 (before)   9.0     phase concentration 0.66 / 0.42 worst
       two-sided 45           10.9     0.62 / 0.31
       two-sided 100          12.0     0.59 / 0.31
       two-sided 150          12.8     0.57 / 0.31

     AND AT WHAT ACTUALLY SHIPS, hz 1600 / sens 1.95 — the same slider over
     the same 28 samples, so this is the row to read when judging the live
     page:

       two-sided 30           11.9     0.58 / 0.31 worst, >= 0.5 on 21 of 28
       two-sided 45           13.6     0.53 / 0.25, 16 of 28
       two-sided 85 (ships)   15.6     0.45 / 0.16, 12 of 28
       two-sided 150          17.5     0.42 / 0.16, 7 of 28

     A lower cutoff and a looser threshold hand the window more candidates to
     work with, so the same millisecond figure buys more strikes here and
     costs more consistency. 85 is where the owner's ear put it.

     The concentration drop is NOT the sens sweep's kind of loss. It measures
     how tightly strikes cluster at one phase between kicks, so catching the
     second real snare in a bar lowers it by construction. What it cannot do
     is tell a recovered snare from a recovered hat — which is why this is a
     slider and the owner's ear is still the last word.

     RAISE THIS, NOT sens, IF STRIKES ARE MISSING. Compare, at the swept
     config's 45ms window: sens 2.0 buys 13.2 strikes for 0.57 concentration,
     1.5 buys 19.1 for 0.32, 1.2 buys 22.6 for 0.20 — that is the detector
     firing on hats, the exact failure scripts/snare-tuning.py was written to
     catch. The shipped 1.95 is one step down that road and no further; if the
     lightning ever reads as jittery rather than sparse, this is the first
     number to put back before touching the window. */
  --snare-coinc: 85;

  /* THE FORK STRIKE: chance that one strike lights EVERY pattern at once
     instead of one. Found by accident — a cascade bug in css/deep-field-bg.css
     lifted the idle-zero off all five bolts, so every strike fired all of them,
     and the owner liked the look enough to keep it as a rare event.

     PROMOTED TO THE LIVE SITE 2026-08-23, the owner's call, at the 0.12
     css/deep-field-bg.css had already been running — this block used to say
     "ships at 0, promote it here only if the owner wants it on the live site",
     and that is what just happened. Roughly one strike in eight forks now.

     Keep it LOW. The whole point of the five patterns is that no two strikes
     look alike; a fork that happens often is just the bug again, at which
     point there is no variety to interrupt. 0.12 is about the top of what
     still reads as an event — at 11 strikes per 20s it lands a little over
     once every two samples. */
  --snare-all: 0.12;

  --spine-bloom: 0;     /* the flare riding the charge front, 0 = off */
  --spine-glow: 0;      /* 1 = the bloom as generated, 0 = crushed to hard lines */

  /* The glow is BAKED INTO the artwork — a soft halo painted around each bright
     line, not something CSS added — so it cannot be switched off. It can be
     crushed: contrast() pulls the broad low-luminance bloom down toward black
     while the strokes, which are already near-clipping, stay put. Static
     filter, rasterises once, costs nothing per frame.

     MEASURED at 1440px on the moonlight artwork, counting screen pixels by
     luminance band — bloom = L 18..70, stroke = L >= 200:

       glow  contrast   stroke px   bloom px   bloom vs glow 1
       1.0     1.04        7,760      37,423        100%   (as generated)
       0.8     1.30        7,959      29,832         80%
       0.6     1.56        8,092      27,074         72%
       0.4     1.82        8,218      25,131         67%
       0.2     2.08        8,300      23,613         63%
       0.0     2.34        8,415      22,503         60%

     Two things that table says and intuition does not:
     1. MOST OF THE AVAILABLE EFFECT IS IN THE FIRST NOTCH. 1.0 -> 0.8 removes
        20% of the bloom; the whole rest of the range removes another 20%. If
        the glow is the annoying part, try 0.8 before you try 0.
     2. The strokes get very slightly FATTER as glow drops (7,760 -> 8,415),
        not thinner, because crushing the halo lifts its brightest edge into
        the stroke band. So this hardens the lines, it does not thin them.

     If 0.0 still is not tight enough, the lever has run out and the fix is a
     new generation with a tighter bloom prompted in — not more CSS. */
  --spine-contrast: calc(1.04 + (1 - var(--spine-glow)) * 1.3);

  /* The band's two stop positions, factored out so the lit mask and the dim
     layer's complement of it cannot drift apart. Both are in the same space as
     the charge stops below: --charge * 100% of the layer, offset in px.
     --band-t1 is the top edge; --band-t0 is where its ramp begins, above it. */
  --band-t1: calc(var(--charge) * 100% + var(--spine-feather) * (var(--spine-bias) + 0.16)
             - var(--spine-band));
  /* max(1px, ...) is a FLOOR ON THE RAMP, not a floor on the slider.
     MEASURED: at --spine-band-feather: 0 the band's top stop and the dim layer's
     complementary stop land on the same position, and the rasteriser rounds them
     onto the same pixel row — so that one row is painted by BOTH layers, which
     under screen blending ADD. It showed up as a bright hairline sitting on top
     of the hard edge: a 200px-wide sample read 20 (dark) / 192 (the hairline) /
     138 (the lit plateau) on consecutive rows. This is load-bearing note 3 in
     miniature, one pixel tall instead of a whole section.
     Forcing a 1px minimum ramp gives the two masks a single row to cross over
     in, so they sum to 1 instead of to 2. A 1px ramp is still a hard edge to
     look at — --spine-band-feather: 0 keeps meaning what the owner tuned it to
     mean — it just stops the seam. */
  --band-t0: calc(var(--charge) * 100% + var(--spine-feather) * (var(--spine-bias) + 0.16)
             - var(--spine-band) - max(1px, var(--spine-band-feather)));
}

/* ---- PER-PAGE OVERRIDES -------------------------------------------------
   :root above is the SHARED BASELINE and both pages read it. These two blocks
   exist so one page can be retuned without retuning the other, which a single
   :root made impossible: index.html and about.html carry the same column but
   not the same content behind it, and a brightness that reads as background on
   one page can read as competition on the other.

   THEY ARE EMPTY ON PURPOSE. A variable appears here only when that page should
   actually differ; anything absent inherits the baseline, so the resting state
   is "both pages agree" and every line that ever lands in one of these blocks
   is a deliberate divergence somebody can account for. Do not seed them with
   copies of the baseline numbers. A copy is drift waiting to happen — this
   project has already lost sessions to overrides that were scaled off a value
   which then moved underneath them, and a page block full of duplicates is that
   same trap with two more places to forget.

   THE CLASS IS ON <html>, NOT ON <body>, AND MOVING IT IS A REGRESSION. This
   looks like a tidy-up waiting to happen — a page class conventionally lives on
   body — so here is the measurement that says otherwise, taken on the live page
   rather than reasoned about:

     body.style.setProperty('--star-cloud-bright', '9')
       -> getComputedStyle(body) reads 9            (body sees it)
       -> html::after filter stays brightness(2.2)  (the cloud layer does NOT)

   Custom properties inherit DOWNWARD. `html::before` and `html::after` are
   pseudo-elements OF html, so they resolve their variables against html and can
   never see anything declared on body. In this codebase html::after IS the
   nebula/cloud layer and html::before is one of the four star bands. Anchored on
   body, a page block would therefore HALF work: the three bands that hang off
   body and main would follow it and the cloud layer and the fourth band would
   not. A sky where some layers moved and some did not is a far nastier thing to
   debug than one that plainly ignored you. Anchored on html, the same test gives
   html::after filter brightness(9) — it reaches everything.

   THE ANCHOR ALSO KEEPS THE TUNER WORKING. js/spine-bg.js writes slider values,
   and --charge and --kick every frame, as INLINE STYLE ON document.documentElement
   — that is, on html. Inline style beats a class selector on the same element,
   so these blocks never shadow a slider and no JS change is needed; verified
   live, a tuner write of 4.4 overrode the class value. Had the class sat on body
   instead, the body declaration would have beaten the value inherited from html
   and every variable named in a page block would have gone dead to its slider,
   --charge included, which stops the charge front outright.

   SPECIFICITY, AND WHY THE MEDIA QUERY AT THE BOTTOM OF THIS FILE LOOKS THE WAY
   IT DOES. `:root` is (0,1,0). `html.page-home` is (0,2,0) — the same element,
   one class higher — and a media query adds NOTHING to specificity. So the
   moment a variable lands in one of these blocks it would outrank the
   @media (max-width: 600px) block and the phone value would silently stop
   applying: the desktop number would ride all the way down to 390px with no
   error and nothing in the console. That is why the mobile block's selector is
   `:root, html.page-home, html.page-about` rather than a bare `:root`. Naming
   the page classes there puts them at equal specificity and later in source
   order, so the phone values win the way they read as if they do. IF A THIRD
   PAGE CLASS IS EVER ADDED, ADD IT TO THAT SELECTOR LIST IN THE SAME COMMIT —
   and to the matching list in css/star-bg.css, which carries the identical
   mobile block for the sky.
   -------------------------------------------------------------------------- */
html.page-home {
  /* index.html. Anything absent inherits the baseline above, unchanged.

     THE SPINE IS OFF ON THE HOME PAGE — the owner's call, 2026-08-06, made
     after the lightning went in: the sky carries the motion there now (nebula
     on the kick, lightning on the snare) and the column had become the third
     thing moving. Everything else about the spine keeps running on this page
     — js/spine-bg.js still builds the layer, still writes --charge, --kick
     and --snare, and the tuner still works; drag `on` to 1 in /?tune to see
     the column exactly as it was tuned. about.html keeps its spine: its own
     block below does not set this, so it inherits the baseline 1. */
  --spine-on: 0;

  /* Snare tail, Aug 17 2026. 410ms against the baseline 260. The Copy CSS this
     came from printed all 26 spine sliders; 24 of them were byte-identical to
     :root and were dropped on the way in, per the rule spelled out in the
     html.page-about block below — a page block that repeats the baseline is a
     copy of an old number waiting to go stale. This and --spine-on are the only
     two that actually differ. */
  --snare-decay: 410;
}

html.page-about {
  /* about.html, tuned Aug 5 2026 against the live page.

     ONLY THE VALUES THAT DIFFER ARE HERE. The tuner's Copy CSS emits all 34
     sliders; 25 of those were byte-identical to the baseline above and were
     deliberately left out, so that a later improvement to the baseline — a
     retuned nebula, a different kick envelope — reaches this page too instead
     of being silently overridden by a copy of an old number. If you paste a
     fresh Copy CSS block over this one, diff it against :root first and drop
     the lines that match. That check is the whole reason this block is short.

     Nothing sky-side changed: star-bg.css and base.css deliberately have no
     html.page-about block at all. If you ever add one, put it ABOVE that
     file's @media (max-width: 600px) block or it will outrank the phone
     values by source order. */

  /* THE COLUMN IS OFF ON THIS PAGE — the owner's call, Aug 11 2026, made the
     same way and for the same reason the home page turned it off on 2026-08-06:
     something else on the page had become the thing carrying the motion, and
     the column was the third element competing for it. Here it is the upgraded
     footer, whose wordmark, Seed of Life and instrument band all sit on the
     centre line the column runs down.

     SAME MECHANISM AS html.page-home, deliberately: the toggle, not a deletion.
     js/spine-bg.js still builds the layer and still writes --charge, --kick and
     --snare, the tuner still works, and dragging `on` to 1 in /?tune shows the
     column exactly as it was tuned below. Every value in this block stays
     correct and stays live the day anyone turns it back on. */
  --spine-on: 0;

  /* The column is thinner and much dimmer than the home page, and the ramp is
     nearly hard-edged at 10px against the home page's 430px. */
  --spine-dim: 0.13;

  /* POLARITY IS INVERTED RELATIVE TO THE HOME PAGE, ON PURPOSE. On index.html
     --spine-lit is 0, which is DARKER than --spine-dim: the charge front
     darkens the column so the charged region sits invisible until a drum hit
     flashes it out of black at --kick-flash: 0.72. There is no drum on
     about.html — no .track-experience, so the detector never attaches and
     --kick is never written — which means a flash-out-of-black design can
     never fire here and the charged region would simply stay dark forever.
     So this page runs the front the other way: 1.48 is well above dim, and
     the front BRIGHTENS as it passes. Do not "restore" this to 0 to match the
     home page. The two pages want opposite polarities because only one of
     them has a kick. */
  --spine-lit: 1.48;
  --spine-glow: 0.58;
  --spine-feather: 10px;

  /* Starts 690px down, below the h1 and its opening paragraph, and the artwork
     then sits 680px further down inside that region. See the notes on
     --spine-from and --spine-offset in the baseline block: the first moves the
     region and remaps the charge front, the second moves only the artwork. */
  --spine-from: 690px;
  --spine-offset: 680px;
  --spine-bias: -0.7;

  /* Band length 0 with an 800px feather is all falloff and no plateau — the
     inverse of the home page's 800px band against a 600px feather. */
  --spine-band: 0px;
  --spine-band-feather: 800px;
}

/* The layer is a direct child of body and is positioned in DOCUMENT space, so
   body has to be the containing block. Without this it resolves against the
   initial containing block and the column starts at the top of the viewport
   instead of the top of #tracks. */
body { position: relative; }

.spine-bg {
  position: absolute;
  left: 0;
  right: 0;
  overflow: hidden;
  pointer-events: none;   /* never steals a drag from the carousel */
  /* top and height are set inline by js/spine-bg.js */

  /* NO z-index HERE, deliberately. z-index:0 on a positioned element creates a
     stacking context, and mix-blend-mode on the children below would then blend
     only against this box's own (transparent) backdrop instead of against the
     page — which silently defeats the whole thing and puts the grey rectangle
     back. z-index:auto keeps the children blending with the real page
     background. Content is lifted above with z-index:1 further down. */
}

.spine-bg__art {
  position: absolute;
  inset: 0;
  /* MIRROR-TILED, 2026-08-05. Revert by putting spine-column-moonlight-4k.webp
     back on this one line; nothing else changed.

     The moonlight artwork could not tile. Two separate reasons, both MEASURED:
       1. It fades to black at both ends — rows 0..160 and 5368..5503 carry no
          lit pixel at all, with a ~200-row ramp inside each. At --spine-w: 640px
          the artwork renders at scale 0.812, so those two dead ends stack into a
          ~570px black band at every seam. That is the gap.
       2. The ends do not match in SHAPE either, so cropping the fade off would
          not have been enough: across the top 400 rows of content the lit column
          is 919px wide, across the bottom 400 it is 1361px. The artwork is a
          tapered spine, so a seam butts a lumbar vertebra against a cervical one
          and reads as a size step even with zero gap.

     THE TILE: rows 491..4936 of the 4K artwork, then a vertical mirror of that
     crop with the two shared rows dropped. Both seams therefore join a row to
     its own neighbour, and MEASURED max |delta| across each is 31/255 against a
     median of 60 for adjacent rows elsewhere in the artwork — the seams are
     smoother than the artwork's own texture. 3072x8890, 381 KB, 27.3 MP against
     the source's 16.9 MP.

     WHY 491 AND 4936 SPECIFICALLY, because the first cut got this wrong.
     It used 451..5162, chosen as inter-vertebral minima, and left a visible
     black rectangle at the mirror point: 5162 sits 226 rows past the last STRONG
     vertebra, inside the bottom fade, so mirroring there doubled that dead run
     into a ~450-row gap. MEASURED on the resulting tile, the mirror point held a
     12-row band below 60% of the median profile where no other gap in the tile
     did. 491 and 4936 are the exact edges of the strong content run — the
     vertebra touches its own reflection and the seam gap is zero rows. The
     profile reads 441 at the mirror point against a tile median of 630 and a
     minimum of 186, so the join is now denser than the artwork's own quietest
     inter-vertebral space. Cutting anywhere further out re-opens the gap; the
     natural spacing between vertebrae here is only 28 rows, which is why a crop
     that misses by 200 is so obvious.

     THE COLUMN REVERSES DIRECTION at the mirror point. That is a reflection, not
     more vertebrae. It is the price of a seamless tile from a tapered drawing.

     --spine-frac AND --spine-w: the tighter crop drops the widest genuinely LIT
     row from 1125px to 1098px, so the column renders 2.4% narrower for the same
     --spine-w — about 3px at the tuned 130px. Left alone as below the threshold
     of anything; nudge --spine-w to 133px if it ever matters.

     --spine-offset IS affected. The tile renders 7225px tall at --spine-w: 640px
     against the source's 4470, so any previously tuned shift lands at a
     different phase. It is a pure phase control now, with no seam to expose. */
  background-image: url("../assets/hero/spine-column-mirror-4k.webp");
  background-position: 50% var(--spine-offset);
  /* repeat-y is LOAD-BEARING on narrow screens. At 1440x900 one copy is taller
     than the region so it never tiles and this looks like a no-op. At 390x844
     the region grows while the artwork shrinks — measured — and the bottom
     third of the page had no spine on it at all. Do not set no-repeat. */
  background-repeat: repeat-y;
  /* Derived from the measured column fraction, so --spine-w means what it says
     whatever margins the artwork happens to carry. */
  background-size: calc(var(--spine-w) / var(--spine-frac)) auto;

  /* The page's spine toggle. On the child, not on .spine-bg — see --spine-on
     in :root for why the parent must never carry an opacity. */
  opacity: var(--spine-on);

  /* THE FIX for the visible lighter panel.
     The artwork is glow-on-black, and no encode of "black" is ever exactly the
     page's #03040F — the artwork's field measured (1,2,2) at the corners but
     (13,11,9) in the top and bottom bands, and its bloom lifts a wide area
     besides. Painted normally that whole 1316px-wide rectangle reads as a
     slightly different black with visible vertical edges, which is exactly the
     seam you can see.
     screen blending makes that structurally impossible: screen(page, black)
     leaves the page untouched, so the artwork can only ever ADD light. There is
     no rectangle to see because the dark parts of the file stop existing.
     It also means the artwork's own black no longer has to match anything. */
  mix-blend-mode: screen;
}

/* Fade the artwork's own top and bottom to true black. Those bands measure
   (13,11,9) against a (3,4,15) page, so at a tile seam they read as a warm
   smudge. This costs nothing and does not touch the file. */
.spine-bg__art {
  -webkit-mask-image: var(--mask-ends), var(--mask-sides);
          mask-image: var(--mask-ends), var(--mask-sides);
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
  --mask-ends: linear-gradient(to bottom, transparent 0, #000 90px,
                #000 calc(100% - 90px), transparent 100%);
  /* And fade the LEFT and RIGHT edges too.
     screen blending stops the artwork's field from darkening the page, but it
     cannot stop it LIGHTENING it: the field measures (6,7,7), so screened over
     (3,4,15) the artwork's rectangle sits about 1-2 levels above the page. That
     is invisible on most screens and clearly visible on a good one in a dark
     room — a faint vertical edge running the height of the page. This removes
     it outright. The fade is wide (18% of the artwork width each side) because
     the sacred geometry reached nearly to the frame edge and a narrow fade
     would have clipped it into a hard line instead.

     THAT JUSTIFICATION NO LONGER APPLIES. The geometry is gone from the 4K
     artwork and its field is clamped to true black, so there is no longer a
     field to lift the page and nothing near the edges to clip. This is kept as
     insurance, not because it is load-bearing any more, and it can be narrowed
     or dropped by a future session. Narrowing it buys no column width, though —
     the column spans 0.335..0.665 of the frame and the fade ends at 0.18, so the
     two never meet. All it would recover is a little outer halo. */
  --mask-sides: linear-gradient(to right, transparent 0, #000 18%,
                #000 82%, transparent 100%);
}

/* The DIM layer is masked with the COMPLEMENT of the charge front, so it only
   paints BELOW it. Under normal blending the lit copy simply covered the dim
   one and this was unnecessary; under screen they ADD, and the charged section
   came out roughly twice as bright as tuned. Cutting the dim layer away where
   the lit one takes over keeps the two from ever overlapping, so the tuned
   --spine-dim / brightness values still mean what they meant. */
/* The band's complement is UNIONED with the charge complement, not intersected:
   the dim layer has to paint wherever the lit one does not, and that is now two
   disjoint regions — below the charge front AND above the band's top edge.
   Intersecting them would leave the column dark above the band instead of
   returning it to --spine-dim, which is what makes the band read as a lit
   stretch travelling through a continuous column rather than as the column
   itself ending.
   Layer order is top-first, so compositing runs upward from the bottom entry:
   ends INTERSECT (sides INTERSECT (charge-inv ADD band-inv)). Three operators
   for four layers; the bottom layer's operator is not used. */
.spine-bg__art--dim {
  filter: brightness(var(--spine-dim)) saturate(0.55) contrast(var(--spine-contrast));
  -webkit-mask-image: var(--mask-ends), var(--mask-sides), var(--mask-charge-inv), var(--mask-band-inv);
          mask-image: var(--mask-ends), var(--mask-sides), var(--mask-charge-inv), var(--mask-band-inv);
  -webkit-mask-composite: source-in, source-in, source-over;
          mask-composite: intersect, intersect, add;
  /* Opaque above the band's top edge, transparent inside the band. Exactly two
     stops, so it can never go out of order however --spine-band is set. */
  --mask-band-inv: linear-gradient(to bottom,
    #000 0,
    #000 var(--band-t0),
    rgba(0, 0, 0, 0) var(--band-t1),
    rgba(0, 0, 0, 0) 100%);
  --mask-charge-inv: linear-gradient(to bottom,
    rgba(0, 0, 0, 0) 0,
    rgba(0, 0, 0, 0) calc(var(--charge) * 100% + var(--spine-feather) * (var(--spine-bias) - 1)),
    rgba(0, 0, 0, 0.45) calc(var(--charge) * 100% + var(--spine-feather) * (var(--spine-bias) - 0.22)),
    #000 calc(var(--charge) * 100% + var(--spine-feather) * (var(--spine-bias) + 0.16)),
    #000 100%);
}

/* The pulse animates FILTER, not the custom property. A bare custom property
   cannot be interpolated without @property registration, and registering it
   would mean the whole tuner writes through a typed property; animating filter
   is universally supported, GPU-friendly, and leaves --spine-lit as the plain
   number the sliders and the paste box expect.
   The transition is what makes the stop graceful: when the class is removed the
   animation disappears and filter would otherwise snap straight back to
   --spine-lit. */
@keyframes spine-pulse {
  0%, 100% { filter: brightness(var(--spine-pulse-lo)) contrast(var(--spine-contrast)); }
  50%      { filter: brightness(var(--spine-pulse-hi)) contrast(var(--spine-contrast)); }
}
:root.is-spine-pulsing .spine-bg__art--lit {
  animation: spine-pulse var(--spine-pulse-ms) ease-in-out infinite;
}

/* ==========================================================================
   KICK-REACTIVE — replaces the breathing while the analyser is running.
   Both classes are present while a sample plays, and both rules have the same
   specificity, so THIS BLOCK MUST STAY BELOW the pulse rule above. Moving it
   up silently gives the breathing loop back and the kick stops being visible.

   The breathing is switched off rather than added to, deliberately. It is a
   5.3s loop that says "audio is playing"; once the column is answering the
   actual kick that message is already being sent, better, and the two together
   read as the spine drifting under the hits.

   The 700ms filter transition is also killed here. It exists to make the STOP
   graceful when the class is removed; left in place it would smear a 200ms hit
   across 700ms and turn every kick into a slow swell. The graceful stop still
   happens — .is-spine-kicking is removed one frame AFTER --kick is zeroed, so
   the column is already back at --spine-lit when the transition returns.
   ========================================================================== */
:root.is-spine-kicking .spine-bg__art--lit {
  animation: none;
  transition: none;
  filter: brightness(calc(var(--spine-lit) + var(--kick) * var(--kick-flash)))
          contrast(var(--spine-contrast));
}

/* THE SHAKE, and the one compromise in this feature.
   It moves BOTH art layers, not just the lit one, and it has to.
   The two layers are the same image under complementary masks: the lit one is
   shown inside the band, the dim one everywhere else. Shifting only the lit
   layer moves the artwork on one side of the seam and not the other — at the
   band's top edge that is a visible tear — written when --spine-band-feather
   was 0 and the edge was hard; it is 800px now, which softens the tear but does
   not remove the reason — and at the charge front, where the two cross-fade
   over the full length of --spine-feather, it is worse: the same vertebrae
   painted twice, a few px apart, reading as a double image rather than as
   movement.
   So the shake is whole-column by construction. The FLASH above is lit-only,
   which is where the "only the lit section reacts" intent actually lives — the
   dim layer sits at --spine-dim brightness and a 6px sideways move on it is close to
   invisible next to the lit band doing the same thing at 0.52 plus the flash.
   If the whole-column movement reads wrong, --kick-shake: 0 removes it and
   leaves the flash, which is a complete feature on its own.

   background-position, not transform. A transform on this layer is the one
   thing HANDOFF 9 says to measure before trusting: the same shape of object —
   a full-screen screen-blended layer — cost 28-40% of the page's frame rate
   for a transform: scale(1.06) on html::after. background-position repaints
   and triggers no layout, and is the path --spine-offset already uses. */
:root.is-spine-kicking .spine-bg__art {
  background-position:
    calc(50% + var(--kick) * var(--kick-sign) * var(--kick-shake) * 1px)
    var(--spine-offset);
}

.spine-bg__art--lit {
  transition: filter 700ms ease;
  filter: brightness(var(--spine-lit)) contrast(var(--spine-contrast));
  /* Four masks intersected: the end-fade, the side-fade, the travelling charge
     front (the band's BOTTOM edge) and the band's TOP edge. intersect means a
     row is only painted where all four allow it, so the lit zone is the strip
     between the band's top edge and the front. */
  -webkit-mask-image: var(--mask-ends), var(--mask-sides), var(--mask-charge), var(--mask-band);
          mask-image: var(--mask-ends), var(--mask-sides), var(--mask-charge), var(--mask-band);
  -webkit-mask-composite: source-in, source-in, source-in;
          mask-composite: intersect, intersect, intersect;
  /* Transparent above the band's top edge, opaque from there down; the charge
     mask cuts it off again at the front. Two stops only — see the note on
     --spine-band in :root for why this is a separate layer and not two more
     stops in --mask-charge. */
  --mask-band: linear-gradient(to bottom,
    rgba(0, 0, 0, 0) 0,
    rgba(0, 0, 0, 0) var(--band-t0),
    #000 var(--band-t1),
    #000 100%);
  --mask-charge: linear-gradient(to bottom,
    #000 0,
    #000 calc(var(--charge) * 100% + var(--spine-feather) * (var(--spine-bias) - 1)),
    rgba(0, 0, 0, 0.55) calc(var(--charge) * 100% + var(--spine-feather) * (var(--spine-bias) - 0.22)),
    rgba(0, 0, 0, 0) calc(var(--charge) * 100% + var(--spine-feather) * (var(--spine-bias) + 0.16)),
    rgba(0, 0, 0, 0) 100%);
}

/* A bloom riding the front, so it reads as a travelling event rather than as
   the edge of a mask. */
/* The travelling flare that rides the charge front — the thing that reads as a
   lens flare rolling down the spine. Distinct from --spine-glow, which is the
   static halo baked into the artwork: this one MOVES.

   MEASURED at the charge front, 1440x900, flare 0 -> flare 1:

     on the column      (x 620-820)   L 70.6 -> 85.1   (+14.5)
     where copy sits    (x 900-1300)  L  7.3 ->  8.5   (+1.2)
     far edge           (x 120-320)   L  6.3 ->  6.3   (+0.0)

   READ THAT BEFORE REACHING FOR THIS SLIDER. The flare is much tighter than it
   looks: it is a 30% x 14% ellipse, so it barely touches anything off the
   column and does nothing at all at the page edges. If text is washing out as
   the front passes, this is almost certainly NOT the cause — the backdrop under
   off-column copy only moves 1.2 levels. What washes text out is the LIT COLUMN
   itself, which is already at L 70 before the flare is added; the levers for
   that are --spine-lit and --spine-scrim. Turning the flare down to fix washed
   text will cost the travelling cue and fix almost nothing.

   Likewise it is not what makes the artwork's rectangle visible — the far edge
   does not move at all. That was the field being screened over the page, and
   --mask-sides above deals with it.

   Driving opacity rather than the colour stops keeps the core, the falloff and
   the outer edge in step, so turning it down reads as the flare receding rather
   than as a hard disc getting fainter. 0 removes it entirely; the charge front
   still works, it just loses the "something is travelling" cue and reads more
   as a mask edge. */
.spine-bg__bloom {
  position: absolute;
  inset: 0;
  /* x --spine-on: the page toggle folds into this layer's own control. */
  opacity: calc(var(--spine-bloom) * var(--spine-on));
  /* Cool, to match the moonlight artwork. This was a warm amber bloom for the
     first artwork; left warm it puts back exactly the orange cast the new
     colour grade was generated to remove. */
  /* The flare's vertical radius tracks --spine-feather so the two stay in
     step: a long ramp with a small flare reads as two separate events. */
  background: radial-gradient(30% calc(var(--spine-feather) * 1.4) at 50% calc(var(--charge) * 100%),
    rgba(206, 224, 242, 0.24) 0%, rgba(120, 156, 196, 0.07) 45%, transparent 72%);
}

/* The crimson scan line — the hero video's laser, echoed. Back to red Aug 18
   2026 with the rest of the palette, after one day as violet (commit e0fa910
   holds that value; it is kept out of this file so a grep for a violet here
   finds a live one or nothing); the
   hardcoded core below is the one colour in this gradient that does NOT follow
   --color-crimson-lit, so it has to be swapped BY HAND on every palette move or
   the line ends up one colour with another colour's heart. A gradient stop
   cannot take a var() and still be a stop the tuner can reason about, which is
   why it is spelled out. It is derived, not guessed: the core sits +7 hue, +11
   saturation and +7 lightness from the lit value, and #C8302E is that offset
   from #A1333E — the same offset that produced the violet core from the violet
   lit value. Re-derive it, do not eyeball it.

   NOTE FOR ANYONE CHECKING THIS ON SCREEN: it renders nowhere today.
   --spine-beam is 0 (tuned off 2026-08-04) and html.page-home sets
   --spine-on: 0, so this gradient has no live consumer on any page. Swapped
   for consistency, and it was never once seen in violet.
   opacity cannot exceed 1, so above 1 --spine-beam drives brightness instead;
   that way the line, its falloff and its halo all scale together. */
.spine-bg__scan {
  position: absolute;
  left: 0;
  right: 0;
  height: 2px;
  top: calc(var(--charge) * 100%);
  /* x --spine-on: the page toggle folds into this layer's own control. */
  opacity: calc(min(1, var(--spine-beam)) * var(--spine-on));
  filter: brightness(max(1, var(--spine-beam)));
  background: linear-gradient(to right, transparent 0%, rgba(138, 20, 20, 0) 12%,
    var(--color-crimson-lit) 42%, #C8302E 50%, var(--color-crimson-lit) 58%,
    rgba(138, 20, 20, 0) 88%, transparent 100%);
  box-shadow: 0 0 14px 2px rgba(200, 48, 46, 0.45);
}

/* ==========================================================================
   LIFTING THE CONTENT ABOVE THE LAYER
   The layer is positioned, so it paints above statically-positioned siblings.
   Everything from the carousel down has to be lifted explicitly.
   ========================================================================== */
.track-experience,
.newsletter,
main > .section,
.footer {
  position: relative;
  z-index: 1;
}

/* .track-experience paints an opaque black background of its own, which would
   hide the column entirely behind the carousel. The column passing BEHIND the
   cards is the chosen design — see the session notes — so this must stay
   transparent. The page background is already --color-black underneath. */
.track-experience { background: transparent; }

/* ---- Legibility: ATTENUATE the backdrop, never paint over it -------------
   Earlier passes painted a soft rgba(3,4,15,...) radial over the artwork. The
   colour was already an exact match for the page — #03040F — and it STILL read
   as a dark halo, because matching the page is the wrong target. Over flat page
   black the scrim is invisible (black on black), but over a column sitting at
   L 70 the same scrim is far darker than its surroundings, so it shows up as a
   dark ellipse floating on the spine. Any opaque overlay has this problem, at
   any colour: paint something darker than the column onto the column and you
   see the shape of what you painted.

   So these do not paint. backdrop-filter: brightness() SCALES whatever is
   behind them, which means the effect is proportional rather than absolute:
   page black is already near the floor so it barely moves, while the lit column
   is pulled down hard. Nothing is added, so there is no shape to see.

   MEASURED at 1440x900 with the front over the section (see the session notes):
                             over page black    over the lit column
     no scrim                    L 5.0               L 70.6
     old rgba(3,4,15) overlay     L 5.0               L 41.2   <- 36-level step
                                                                against the page,
                                                                visible as a disc
     backdrop-filter version     L 4.6               L 21.4   <- 0.4 off the page,
                                                                nothing to see

   The radial now lives in mask-image, not in the colour, so the attenuation
   itself feathers out and there is no edge anywhere. */
.newsletter::before,
main > .section::before,
.track-experience::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: var(--spine-scrim);
  -webkit-backdrop-filter: brightness(0.30);
          backdrop-filter: brightness(0.30);
  /* A BAND, not an ellipse. A radial mask makes the attenuation a shape, and a
     shape is visible as a shape even when it has no hard edge anywhere — that
     is the halo. Measured, the radial version added no detectable step (its
     steepest 20px gradient was 14.6 against 18.8 for no scrim at all, so it was
     actually SMOOTHING the artwork), which is exactly why it could not be fixed
     by softening it further. Nothing was wrong with the edge; the problem was
     that an ellipse is an object.
     Fading only top and bottom, edge to edge horizontally, leaves nothing to
     perceive: the section simply sits slightly darker, with no boundary
     anywhere in view. */
  -webkit-mask-image: linear-gradient(to bottom,
    transparent 0%, #000 26%, #000 74%, transparent 100%);
          mask-image: linear-gradient(to bottom,
    transparent 0%, #000 26%, #000 74%, transparent 100%);
}


/* The focus panel carries the most small type over the brightest part of the
   column — description, sample player, stream/download buttons, all 0.95rem or
   under — so it attenuates harder. It is only ~760px wide, so this costs very
   little of the column. */
.track-focus-panel { position: relative; }
/* NO separate scrim on the focus panel, deliberately.
   It used to have its own, and that was the blob behind the track cards. The
   panel carries margin-top: calc(--card-w * 1.425 - --arc-h - 4px), which is
   -288px at the shipped card size — its box already overlaps the carousel — and
   the -7rem feather overhang then reached a further 112px up, putting a soft
   dark oval directly behind the cards you are trying to look at.
   The whole section gets ONE band instead (see .track-experience::before above).
   Uniform attenuation across the carousel and the panel together means there is
   no second shape to notice, and the section is tall enough that its feather has
   hundreds of pixels to happen in. */

/* backdrop-filter needs something to sample. Safari in particular gives up if
   the element has no paint of its own, so a fully transparent background is
   kept explicitly rather than omitted. */
.newsletter::before,
main > .section::before,
.track-experience::before { background: transparent; }

/* Fallback where backdrop-filter is unsupported: go back to the painted scrim.
   It shows the halo, but unreadable text is the worse failure. */
@supports not ((backdrop-filter: brightness(1)) or (-webkit-backdrop-filter: brightness(1))) {
  .newsletter::before,
  main > .section::before,
  .track-experience::before { background: rgba(3, 4, 15, 0.42); }
}

/* EVERY shadow colour here is rgba(3,4,15,...) — the page background — and never
   rgba(0,0,0,...). MEASURED: pure-black shadows on a #03040F page paint BELOW
   the background. With the whole layer dialled to zero there was still a strip
   at L 3.0 against the page's 5.0, running the width of the track copy: a soft
   dark haze around every glyph that read as a blob under the cards. It was not
   the artwork, not the scrims, not a box-shadow — it was the text shadows
   themselves, and it survived turning every slider off because nothing controls
   them.
   At rgba(3,4,15) a shadow can darken the lit column but can never go below the
   page, so it is invisible wherever there is nothing to hide. The contrast cost
   against a bright vertebra is a floor of 5 instead of 0 — nothing.

   Two tight shadows, not one wide one. The 1px pass gives the glyph a hard
   edge against a bright line behind it; the 5px pass kills the halo. Neither
   is wide enough to be seen as a cloud. */
.newsletter__inner,
.track-experience__intro,
.bio__body,
.track-focus-panel {
  text-shadow: 0 1px 2px rgba(3, 4, 15, 0.92), 0 0 5px rgba(3, 4, 15, 0.75);
}

/* Lift the copy that sits over the column. --text-secondary (#D6D5D0) is a
   warm mid-tone that loses against a lit vertebra; these read at full or near
   full white instead, which is what actually makes them stand out. */
.track-focus-panel__desc,
.track-experience__note,
.bio__body p { color: var(--color-white); }
.newsletter p { color: #E8E4DA; }
.track-focus-panel__actions-note,
.newsletter__privacy { color: var(--text-secondary); }

/* ---- The horizontal rules -----------------------------------------------
   components.css draws a hairline above and below .newsletter and above
   .footer. Against a flat black page they read as quiet section dividers.
   With a continuous column running through the page they read as PAGE BREAKS
   — hard lines cutting the spine into segments, which is the opposite of what
   the layer is for. Removed only on the page that has the column; the other
   pages still load components.css unchanged and keep their rules. */
.newsletter { border-top: 0; border-bottom: 0; }
.footer { border-top: 0; }

/* ---- Track data over a lit column ---------------------------------------
   The eyebrow (RISE UP · 2026) and the counter index take --track-accent, the
   per-track colour sampled from the cover by accentFromImage(). That value is
   clamped to L 55-66% — chosen against FLAT BLACK, where it reads fine. Over a
   lit vertebra there is no longer a black backdrop to read against, and the
   mid-tone accents disappear.
   Fixed here rather than in accentFromImage(), deliberately: that algorithm is
   documented and load-bearing, it feeds the play button and the sample bar too,
   and changing its clamp would shift the accent on all 28 tracks to solve a
   problem that only exists on this one page. A shadow and a weight bump buy the
   contrast back without touching the colour system at all. */
.track-focus-panel__eyebrow,
.track-focus-nav__counter,
.track-focus-nav__index {
  font-weight: 500;
  text-shadow:
    0 0 3px rgba(3, 4, 15, 0.98),
    0 1px 2px rgba(3, 4, 15, 0.95),
    0 0 10px rgba(3, 4, 15, 0.85);
}
/* A shadow alone was not enough, and it was never going to be: the accent is a
   MID-TONE, so the type was low-contrast even where the backdrop was black.
   Blue Pills resolves to hsl(218 76% 55%) = #3575E3, which is about 4.3:1 on
   black — borderline for 13px tracked-out mono, and worse over a lit vertebra.
   Mixing toward white raises the luminance while keeping each track's hue, so
   the accent system still reads as per-track without going illegible. The
   plain colour above stays as the fallback for anything without color-mix. */
@supports (color: color-mix(in srgb, red 50%, white)) {
  .track-focus-panel__eyebrow,
  .track-focus-nav__index {
    color: color-mix(in srgb, var(--track-accent, #FFFFFF) 42%, var(--color-white));
  }
}
/* The counter's own text was --text-muted (#8F8F8F), dim even before the
   column existed. */
.track-focus-nav__counter { color: var(--text-secondary); }

/* Every section eyebrow — MUSIC, STAY CONNECTED, ABOUT — is .label at
   --text-muted #8F8F8F. On flat black that is a deliberate quiet grey. Sitting
   on a lit vertebra it is the single least readable thing on the page: 13px,
   tracked out to 0.12em, mid-grey, over the brightest part of the artwork.
   Caught only once the real moonlight file was in place — the desaturated
   stand-in was not bright enough at the top of the column to show it. */
.track-experience .label,
.newsletter .label,
main > .section .label {
  color: var(--text-secondary);
  text-shadow: 0 0 3px rgba(3, 4, 15, 0.98), 0 1px 2px rgba(3, 4, 15, 0.92);
}

/* Headings sit over the brightest band of the column and are large enough that
   a wide shadow would be visible as a cloud, so they get a tight one only. */
.newsletter h2,
.bio__body h2,
.track-experience__intro h2 {
  text-shadow: 0 1px 3px rgba(3, 4, 15, 0.9), 0 0 12px rgba(3, 4, 15, 0.6);
}

/* ---- Buttons over a lit column ------------------------------------------
   .btn--ghost is a transparent box with a 1px white border. Over flat black
   that is crisp; over a bright vertebra the fill shows straight through and
   the label competes with the glow behind it. These get their own black so
   they read as objects sitting ON the column rather than cut out of it. */
.track-focus-panel__actions .btn,
.newsletter-form .btn {
  text-shadow: 0 1px 2px rgba(3, 4, 15, 0.85);
  box-shadow: 0 2px 14px rgba(3, 4, 15, 0.7);
}
.track-focus-panel__actions .btn--ghost,
.track-experience .btn--ghost {
  background: rgba(3, 4, 15, 0.72);
  backdrop-filter: blur(2px);
}
.track-focus-panel__actions .btn--ghost:hover {
  background: var(--color-white);   /* unchanged hover, per components.css */
}
.btn--primary { text-shadow: none; }   /* black on white needs no shadow */

/* The placeholder/disabled state was 0.45 opacity — legible on flat black,
   not over a glow. 0.62 still reads as clearly unavailable. */
.track-focus-panel__actions .btn[disabled],
.track-focus-panel__actions .btn.is-placeholder { opacity: 0.62; }

/* ---- The footer sits over the column too -------------------------------
   Missed on the first pass because at 1440px the footer is wide and the column
   only crosses its middle third. At 390px the footer stacks into one narrow
   column directly on top of the spine and the social links were unreadable. */
.footer__brand,
.footer__tagline,
.footer__copyright,
.footer__social a,
.footer__nav a {
  text-shadow: 0 0 3px rgba(3, 4, 15, 0.98), 0 1px 2px rgba(3, 4, 15, 0.92);
}
.footer__social a,
.footer__nav a { color: var(--text-secondary); }
.footer__tagline,
.footer__copyright { color: var(--text-secondary); }

/* The nav arrows and close sit over the column too. */
.track-focus-nav__btn,
.track-focus-panel__close,
.track-sample-player__status {
  text-shadow: 0 0 3px rgba(3, 4, 15, 0.95), 0 1px 2px rgba(3, 4, 15, 0.9);
}

/* 600, not 768: at a 768px viewport the desktop 330px column still reads well
   (the artwork simply overflows and stays centred, and the layer clips it). At
   390px, 330px is 85% of the screen and swamps the content.

   Brightness drops too, and that is the more important half. A column tuned at
   1440px sits behind wide, sparse content; the SAME column at 390px sits behind
   a single stacked text column with nothing either side of it, so it
   reading as a background and starts competing with the copy. Narrowing alone
   did not fix that — the footer links were still unreadable over a lit
   vertebra. Do not raise these back to the desktop values. */
@media (max-width: 600px) {
  /* THE SELECTOR LIST IS LOAD-BEARING — do not shorten it back to `:root`.
     `:root` IS html, so the per-page blocks near the top of this file select the
     very same element with one class more: (0,2,0) against (0,1,0). Media
     queries add nothing to specificity. With a bare `:root` here, the first
     variable anyone put in html.page-home or html.page-about would outrank its
     phone value and the desktop number would apply at 390px, with balanced
     braces and nothing in the console to say so. Naming the page classes here
     puts them at equal specificity and later in source order, so the phone
     values win the way they read as if they do. Add any new page class to this
     list at the same time you add its block above. page-archive and
     page-transmissions are named here even though those pages do not link this
     file yet — sky only, see css/star-bg.css build 21 — so the rule holds by
     construction rather than by memory. */
  :root, html.page-home, html.page-about, html.page-archive, html.page-transmissions {
    /* RE-DERIVED 2026-08-07 against the build-37 desktop baseline — the stale
       set HANDOFF 14 records, every value rescaled at once rather than edited
       in place. The ratios are the first pass's own (0.61x width, 0.57x lit,
       0.61x dim; card ratio ~0.7 for the band pair). The previous block was
       scaled off desktop numbers that had since moved: 390px width against a
       desktop that had come down to 120px (phone column 3.25x WIDER than the
       desktop's), and lit 0.3 against a desktop lit of 0 — which is not
       drift but the charge-front mechanism REVERSED: lit 0 is what lets the
       charged region sit darker than dim and flash out of black. 0.57 x 0 = 0,
       and 0 it is. Calibrated at a real 390x844 viewport this time (computed
       values + pixel diff); not yet seen on the owner's actual phone.
       Note these baseline-derived values are, today, tuning for a column
       nobody sees: the only page whose spine renders is about.html, which
       gets its own phone rule BELOW this one, and the home page ships
       --spine-on: 0. They stay correct anyway, so the day the home spine
       comes back on, its phone is already right. */
    --spine-w: 73px;      /* 0.61 x 120px = 73.2 */
    --spine-lit: 0;       /* 0.57 x 0 — the mechanism, restored */
    --spine-dim: 0.13;    /* 0.61 x 0.22 = 0.134 */
    /* The band is a length in DOCUMENT space, and at 390px the same content
       stacks into a document roughly twice as tall, so a desktop-height band
       covers half as much of the page and reads as a much shorter flash. Scaled
       by the card ratio rather than the document ratio: the point of the band is
       to sit on the hero card, and that card is ~0.7x its desktop height here.
       Re-derive whenever --spine-band changes on desktop.
       0.7 x 800px = 560px, 0.7 x 600px = 420px — the previous 880px / 0px pair
       was the same card ratio applied to a desktop pair that no longer ships. */
    --spine-band: 560px;
    --spine-band-feather: 420px;
  }

  /* ABOUT.HTML'S PHONE VALUES — this rule must sit AFTER the shared one above.
     Same specificity (0,2,0) on that page, so source order decides, exactly
     the mechanism the selector-list note above describes. Without this rule
     the shared phone values land on about.html and quietly REVERSE its
     deliberate polarity: about runs the charge front the bright way round
     (lit 1.48 ABOVE dim 0.13 — no kick on that page, nothing to flash out of
     black; the long note at html.page-about explains it). The shared rule's
     lit 0 / dim 0.13 would put its front back to dark-forever — the exact
     failure that polarity exists to avoid — with balanced braces and nothing
     in the console. Ratios are the same ones the shared rule uses, applied to
     THAT page's block, not to :root: */
  html.page-about {
    --spine-lit: 0.84;          /* 0.57 x 1.48 — still ABOVE dim: polarity kept */
    --spine-dim: 0.08;          /* 0.61 x 0.13 */
    --spine-band: 0px;          /* 0 is the page's design (all falloff, no
                                   plateau) — a ratio times 0 stays 0 */
    --spine-band-feather: 560px;/* 0.7 x 800px */
    /* --spine-w is NOT re-stated: the page inherits the shared 73px, the same
       ratio its desktop inherits the baseline 120px by. --spine-feather 10px,
       --spine-from 690px, --spine-offset 680px and --spine-bias -0.7 ride in
       unchanged from the page block above — document-space geometry the
       shared rule does not touch. Whether 690px still clears the h1 on a
       phone-height document is an OPEN question for the owner's eye; it is
       geometry, not brightness, so it was left for a judged pass rather than
       scaled blind. */
  }
}

@media (prefers-reduced-motion: reduce) {
  /* No travelling front. The column is simply lit, all of it, statically —
     the page still looks composed rather than half-dark. The dim layer is
     hidden outright: under screen blending it would add to the lit one and
     make the static state brighter than the charged state ever gets. */
  .spine-bg__art--dim { display: none; }
  /* No breathing under reduced motion — it is a decorative loop with no
     information in it. The column just holds at --spine-lit. */
  :root.is-spine-pulsing .spine-bg__art--lit { animation: none; }
  .spine-bg__art--lit { transition: none; }
  /* THE KICK IS OFF HERE TOO. js/spine-bg.js checks the same media query and
     returns before it ever builds the audio graph, so in practice neither of
     these rules is reached — they exist so that toggling the OS setting
     mid-session cannot leave a shake running against a stale class, and so
     that this block reads as the complete statement of what reduced motion
     turns off rather than as a list that quietly went out of date. */
  :root.is-spine-kicking .spine-bg__art--lit {
    animation: none;
    filter: brightness(var(--spine-lit)) contrast(var(--spine-contrast));
  }
  :root.is-spine-kicking .spine-bg__art {
    background-position: 50% var(--spine-offset);
  }
  .spine-bg__art--lit {
    -webkit-mask-image: var(--mask-ends), var(--mask-sides);
            mask-image: var(--mask-ends), var(--mask-sides);
  }
  .spine-bg__scan,
  .spine-bg__bloom { display: none; }
}
