/* ==========================================================================
   SPINE DOCUMENT — the left-axis home. Ported Aug 13 2026 from the owner's
   design drop (design_handoff_full_system/reference/ui_kits/website/
   spine-doc.css + SpineDocScreen.jsx). The prose comments are the designer's;
   the token block and the .ksd-doc/.ksd-head/.ksd-body classes replace the
   reference's inline JSX styles. js/spine-doc.js is the mechanism.

   The axis is FIXED at the hard left edge and the document scrolls past it, so
   the spine is a permanent instrument rather than a thing you arrive at.
   Everything reaches RIGHT: with the axis on the edge there is no second side
   to serve, which is why the one-sided connector logic collapses to a single
   direction here.
   ========================================================================== */

:root {
  /* The spine tokens this page consumes. MIRRORS css/spine-ui.css (the
     navigator lab's file, not loaded here) — if a value is retuned there,
     retune it here or the document and the lab drift apart. */
  --node-color:         228, 232, 235;  /* --spine-glow as an RGB triplet — cold white light */
  --node-size:          10.5px;
  --node-idle-opacity:  0.88;
  --node-glow:          10px;
  --node-glow-a:        0.68;
  --node-mark-size:     200%;
  --node-mark-a:        0.5;
  --cord-opacity:       0.7;
  --connector-draw-ms:  520ms;
  --connector-ease:     cubic-bezier(0.4, 0, 0.2, 1);

  /* Ripple rings — ported from the lab Aug 14 2026 (owner: "emit light as it
     did with our older version"). Same names and values as css/spine-ui.css;
     the mirror rule above applies to these too. */
  --node-ring:          26px;    /* ring resting size */
  --node-ring-active:   48px;    /* ring size while the node is focused */
  --node-pulse-ms:      1350ms;
  --ring-scale:         1.55;    /* how far a ripple travels before dissolving */
  --ring-peak-a:        0.9;     /* brightness at the start of its travel */
  /* DOC-ONLY, not mirrored: the lab's rings read --node-color directly. Hoisted
     here so the ripple can take a different palette colour with a one-line
     change (moonlight is 157, 178, 192; the accent is 161, 51, 62 — but mind
     the ~2-uses-per-page accent budget). Cold white was the default the owner was
     offered with the port. */
  --ring-color:         var(--node-color);

  /* The vertebra field's falloff radius, each side of the viewport-centre
     point. js/spine-doc.js reads this every scroll frame (it owns the per-tick
     distance math), so retune it HERE, not in the script. Ticks step ~30px, so
     160px lights ~9 of them on a smooth falloff — the owner asked for more
     than the old hard ±26px window, which lit two.
     ON :root, NOT ON .ksd-doc, AND THAT IS LOAD-BEARING: the ?tune sliders
     write inline styles on <html>, which only reach the rail's read by
     INHERITANCE — a local declaration on .ksd-doc would beat the inherited
     value and leave the panel's field slider dead. Same trap as the
     html-vs-body scoping note in js/spine-bg.js. */
  --ksd-field:          160px;

  /* THE SECTION HEADLINE'S TYPE SIZE, HOISTED OUT OF .ksd-head (Aug 17 2026).
     Same clamp it always was; nothing renders differently for this line
     existing. It is a token now because a SECOND rule needs the number: the
     merch render aligns its top edge to the cap of the "O" in "Objects That
     Carry It", and that offset is a fraction of the headline's font-size. Two
     copies of the clamp would drift the moment either is retuned, and the
     failure is silent — the spine just stops meeting the letterform.
     DOC-ONLY, not mirrored into css/spine-ui.css. */
  --ksd-head-fs:        clamp(2.4rem, 6vw, 5rem);

  /* HOW FAST THE MERCH SPINE TURNS (Aug 17 2026). js/spine-doc.js reads this
     off :root and writes it to the video's playbackRate; it stopped being
     scroll-scrubbed on the owner's call and now turns by itself and loops.

     One revolution is 7.9085s of the 8.042s file — js/spine-doc.js loops the
     measured 360 rather than the whole encode, and the note there says why.

     THIS IS A JUDDER BUDGET, NOT A TASTE DIAL, and it is why the first value
     shipped here (0.4) came back as "it looks like its hitching". The encode is
     24fps, so slowing playback does not add intermediate poses — it just holds
     each of the 193 frames longer, and below roughly 18 unique frames a second
     the turn stops reading as rotation and starts reading as a slideshow.

     MEASURED Aug 17 2026 via requestVideoFrameCallback at 0.4x: frames arrived
     every 104.2ms, stdev 1.2ms, zero dropped and zero late — so the hitch was
     never dropped frames or a decode stall. It was 9.6 unique frames a second,
     each one held for 6.3 refreshes of a 60Hz screen, stepping the spine about
     1.9 degrees at a time.

         rate   frame hold   refreshes@60Hz   unique fps   turn
         1.00     41.7ms          2.5            24.0      7.9s   native, smoothest possible
         0.85     49.0ms          2.9            20.4      9.3s
         0.75     55.6ms          3.3            18.0     10.5s   SHIPPED
         0.60     69.4ms          4.2            14.4     13.2s   stepping becomes visible
         0.50     83.3ms          5.0            12.0     15.8s
         0.40    104.2ms          6.3             9.6     19.8s   the reported hitch

     0.75 is the slowest setting that still holds ~18 unique fps. GOING SLOWER
     THAN THIS NEEDS FRAMES, NOT A SMALLER NUMBER: a motion-interpolated
     re-export from the owner's ProRes source. It cannot be done from the
     shipped webm — ffmpeg reads that file as plain yuv420p because it does not
     surface VP9's alpha side-track, so anything re-encoded from it loses the
     matte. Chrome also mutes audio outside 0.5-4x, which costs nothing here:
     the render has no audio track at all.

     NOT A TUNER DIAL, deliberately, and the same call --ksd-field made: adding
     one would mean standing up a whole KSTunePanel tab for js/spine-doc.js,
     which registers none today. Devtools reaches it in one line. */
  --ksd-spine-rate:     0.75;
}
@media (prefers-reduced-motion: reduce) {
  :root { --connector-draw-ms: 1ms; }
}

html.page-home {
  /* index.html, tuned Aug 17 2026 at /?tune on the real page. First page block
     this file has carried; everything absent inherits the baseline above.
     Only the values that DIFFER are here — the Copy CSS printed seven and
     --ring-scale was byte-identical to :root, so it was dropped on the way in.
     Same rule the page blocks in css/spine-bg.css and css/star-bg.css follow,
     and for the same reason: a repeated baseline is a stale number in waiting.

     Scoping to html.page-home keeps the --ksd-field note above intact rather
     than breaking it. That note's requirement is that the declaration sit on
     <html> and reach the rail by INHERITANCE, so the panel's inline styles can
     still win; html.page-home IS <html>, one class more specific than :root,
     so the inheritance path and the tuner both keep working. Putting these on
     .ksd-doc instead would kill the field slider, exactly as it warns.

     This file has no max-width: 600px block and its four 900px blocks set none
     of these six, so unlike the sky file there is no source-order trap here —
     checked, not assumed. */

  /* THE RESTING RING IS NOW BIGGER THAN THE FOCUSED ONE — 38px idle against
     28px active, where the baseline had 26 idle / 48 active. That is an
     INVERSION of the original port, it is the owner's tuning, and it is not a
     transposition: focusing a node now contracts the ring instead of expanding
     it. Leave it. If it ever gets "fixed" back to 26/48 the interaction reads
     as a different gesture, so change it only on the owner's say-so. */
  --node-ring:          38px;
  --node-ring-active:   28px;

  /* Slower and brighter: a full ripple cycle goes 1350ms -> 3350ms and the
     peak sits at full alpha rather than 0.9. */
  --node-pulse-ms:      3350ms;
  --ring-peak-a:        1;

  /* The accent, which is one of the two options the baseline's own note offers
     (the other being moonlight). MIND THE ACCENT BUDGET the same note names —
     roughly two uses per page — because this spends one of them on the home
     page's rail.

     CRIMSON AGAIN SINCE AUG 17 2026 — the palette went violet and has been
     reverted to Signal Red, and this line goes back with it. This is a raw RGB
     TRIPLET rather than a var(), because the three rules that consume it wrap
     it in rgba() at three different alphas — so it cannot follow
     --color-crimson through the cascade and has to be swapped by hand. That is
     exactly why it was the one live usage the first pass at the violet swap
     missed: the two hex values and the tuner choices were all found by grepping
     for the hex, and this spells the same colour in decimal. GREP FOR THE
     DECIMAL TOO on the next palette move. 161, 51, 62 is #A1333E; the violet it
     replaces was #7A39B3. */
  --ring-color:         161, 51, 62;

  /* The vertebra falloff radius, 160px -> 400px. Ticks step ~30px, so this
     lights ~26 of them on the smooth falloff where the baseline lit ~9. */
  --ksd-field:          400px;
}

/* The document wrapper owns the axis geometry. Every layer reads these two. */
.ksd-doc {
  --ksd-axis: 6vw;
  --ksd-throw: 32px;
  position: relative;
}
/* Headline and body share one left edge — the throw's landing point — so every
   section reads as hung off the same axis. */
.ksd-doc__col {
  margin-left: calc(var(--ksd-axis) + var(--ksd-throw) + 18px);
  padding-right: clamp(24px, 6vw, 120px);
  /* --nav-h-max, not --nav-h: node positions are measured once per layout, so a
     scroll-varying padding would slide every section out from under them — the
     exact misalignment the measured-node placement exists to prevent. */
  padding-top: var(--nav-h-max, 92px);
}

.ksd-section {
  min-height: 92vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding-block: clamp(64px, 10vh, 140px);
}

/* ONE LANDING POSITION PER SECTION, READ BY ALL THREE ROUTES TO IT
   (owner's call, Aug 17 2026: "it should always land on the position where if
   i click on the spine node or top navigation its assigned to land at").

   There were THREE different answers before this line existed, measured at
   1440x900 on the deep-field home:

     route                        offset from section top      About landed
     the section stepper          --nav-h-max          92px          808
     a spine rail node, jump()    --nav-h  (live)      47px          853
     a nav link / anchor          scroll-margin-top     0px          900

   So stepping to About and then clicking About moved the page 45px, and the
   nav link moved it 92px and put the headline under the masthead — which is
   the exact fault js/spine-doc.js jump() was written to avoid, except the
   browser never consulted jump(). Merch, Transmissions, Archive and Stay
   Connected all did the same. Music was the ONLY section that agreed with
   itself, and only because js/deep-field-bg.js gives it an inline
   scroll-margin-top for the carousel rest point — which is precisely the
   mechanism generalised here.

   WHY THE BROWSER'S OWN PROPERTY RATHER THAN A SHARED CONSTANT IN JS: anchor
   navigation is the one route no script controls. The nav links are plain
   hrefs and the browser applies scroll-margin-top itself, so any answer that
   lived only in code would leave that route out and the three would drift
   again. jump() already prefers a declared scroll-margin-top over --nav-h,
   and js/deep-field-bg.js now reads the same property for its boundaries, so
   this declaration is the single source all three consult.

   --nav-h-max AND NOT THE LIVE --nav-h, which is the other real candidate and
   would land 45px lower. Three reasons: the live value depends on WHERE YOU
   CLICKED FROM (92 at rest at the top, 47 once scrolled), so it cannot give a
   stable landing at all; the deep-field frame boundaries are already built on
   it, so the marked frame for each section keeps landing exactly (f41 About,
   f134 Music, f157 Merch, f196 Transmissions, f265 Archive); and it clears the
   bar in BOTH of its states rather than only the collapsed one. The cost is
   45px of air above a headline while the bar is collapsed. If that reads as
   too much, this one token is the place to change it — everything follows. */
.ksd-section[data-ksd-section] { scroll-margin-top: var(--nav-h-max, 92px); }

/* ABOUT IS THE EXCEPTION, BECAUSE THE 92px ABOVE IT IS THE HERO VIDEO.

   For every other section the band the offset leaves on screen is the previous
   section's own background, so it costs nothing to look at. About is the only
   one preceded by a different element entirely, and MEASURED at 1440x900 the
   landing showed the hero's last 92px with the masthead collapsed to 47px — so
   45px of hero video sat under the bar and the hero's sound toggle was fully in
   shot at y=34-68. Reported by the owner Aug 18 2026, and it is V2HANDOFF 39's
   open item 4 ("judge the 92px landing by eye") being judged.

   Landing on About's own top instead is safe: the section carries 139px of
   padding above its h2, which clears the masthead in BOTH of its states, so the
   reason the 92px exists does not apply here.

   WHY 0.02px AND NOT 0, WHICH IS WHAT THIS MEANS. Both readers of this property
   treat exactly 0 as "nothing declared" and substitute a nav height —
   js/spine-doc.js jump() does `smt !== 0 ? smt : navH` and js/deep-field-bg.js
   landOffset() does `smt > 0 ? smt : navMax`. Neither guard can be relaxed:
   getComputedStyle cannot tell a declared 0 from the initial 0, and on
   index.html most sections declare nothing and depend on that fallback. The
   browser applies this value to anchor navigation directly, so a plain 0 would
   send the three routes back to disagreeing — which is the exact fault the
   block above was written to fix.

   Verified against Chrome: sub-pixel values round-trip through computed style
   intact (0.02px reads back as 0.02px), so this passes both guards and lands
   within a rounded pixel of the section top. DO NOT "TIDY" THIS TO 0. */
.ksd-section[data-ksd-section="about"] { scroll-margin-top: 0.02px; }
.ksd-head {
  font-family: var(--font-display);
  font-weight: var(--display-wght);
  font-stretch: var(--display-stretch);
  font-variation-settings: "wdth" var(--display-wdth), "wght" var(--display-wght);
  text-transform: uppercase;
  letter-spacing: var(--tracking-display);
  line-height: var(--lh-display);
  color: var(--color-white);
  /* --space-6, not an invented --space-5: the scale is 1,2,3,4,6,8,12,16,24,32
     and a missing token silently drops the whole declaration. With no card edge
     doing the separating, this gap is the only thing holding the headline off
     the body. */
  font-size: var(--ksd-head-fs);
  margin: 0 0 var(--space-6);
  max-width: 18ch;
}
.ksd-hero .ksd-head { max-width: 22ch; }
.ksd-body {
  color: var(--text-secondary);
  font-size: var(--fs-body-lg);
  max-width: 62ch;
  margin: 0 0 var(--space-4);
}
/* Music bleeds RIGHT only. Pulling it left as well ran the cards over the
   axis, and the spine is the one thing nothing is allowed to cover.

   THE HEADROOM IS LOAD-BEARING. The focused card scales up and rises ~122px
   ABOVE the arc viewport's top (measured Aug 13 2026, 1440×900) — in the old
   layout the intro block inside .track-arc-wrap absorbed that; here the clip
   box must be grown upward instead. padding-top makes the room inside the
   clip. overflow-x alone is not the fix: an unpaired overflow-x forces
   overflow-y to auto, and the overshooting card then grows a phantom
   scrollbar.

   THERE IS DELIBERATELY NO NEGATIVE MARGIN PULLING THE ARCH BACK UP. The
   first cut had margin-top:-140px so the layout stayed where it was designed —
   and the risen card then sat ON the platform note, hiding its middle two
   lines (overlap measured 121.7px, identical at 1440×900 and 1920×1080,
   Aug 14 2026; the owner called it). Letting the headroom BE the gap parks
   the card's risen top ~18px below the note instead. Restoring the negative
   margin restores the collision. */
.ksd-bleed {
  position: relative;
  margin-right: calc(-1 * clamp(24px, 6vw, 120px));
  overflow: hidden;
  padding-top: 140px;
}

/* The music head centres over the carousel, not on the axis — the owner's
   call (Aug 13 2026), and the kit's own note: centre on the ARCH'S OWN
   wrapper (same right bleed), so the two share a centre by construction
   instead of by approximation. The axis still owns the section's node — the
   throw is measured from the headline wherever it sits. */
.ksd-music-head {
  position: relative;
  margin-right: calc(-1 * clamp(24px, 6vw, 120px));
  text-align: center;
}
.ksd-music-head .ksd-head,
.ksd-music-head .ksd-body { margin-inline: auto; }

/* THE RAIL RUNS THE DOCUMENT'S FULL LENGTH AND SCROLLS WITH IT. It is absolute
   inside the document wrapper, not fixed to the viewport: a fixed rail is a
   different design — its nodes sit at viewport percentages that have no
   relationship to where the sections actually are, so the throw ends up
   pointing at empty space. Here each node is placed at its own section's
   measured offset, which makes the throw land on the headline by construction
   rather than by coincidence. */
/* top: 0 is only the pre-script fallback — js/spine-doc.js overwrites it with
   the hero video's measured bottom edge so the rail BEGINS below the footage
   instead of crossing it (owner's call, Aug 15 2026). bottom: 0 is left to the
   stylesheet, so the cord always ends where the document does.

   AND THE RAIL DOES NOT PAINT UNTIL THAT MEASUREMENT HAS HAPPENED. `top: 0` is
   a real position, not a neutral one: it runs the cord down the full height of
   the hero video, which is precisely what the Aug 15 call took it off. Left
   visible, the fallback shipped as a visible artefact on every load — measured
   Aug 17 2026 at 1440x900, 874ms and 255 frames of cord across the footage
   before js/spine-doc.js reached its first measure() and moved it.

   HIDING IT COSTS NOTHING WHEN THE SCRIPT NEVER ARRIVES, which is why this is
   `visibility` and not a JS-only concern. The markup ships ONE child, the
   .ksd-cord line; every vertebra and every node is appended by the script (190
   children after measure(), 1 before). So a scriptless rail was never the
   design in miniature — it was a bare 1px gradient crossing the hero with no
   nodes on it. Showing nothing is the better of those two, and the rail is
   aria-hidden decoration, so nothing is lost to a reader either way. */
.ksd-rail { position: absolute; left: var(--ksd-axis); top: 0; bottom: 0; width: 0; z-index: 40; pointer-events: none;
  visibility: hidden; }
.ksd-rail.is-placed { visibility: visible; }
.ksd-cord { position: absolute; top: 0; bottom: 0; left: 0; width: 1px; transform: translateX(-50%);
  background: linear-gradient(180deg, transparent, rgba(228,232,235,.5) 10%, rgba(228,232,235,.5) 90%, transparent);
  filter: drop-shadow(0 0 4px rgba(228,232,235,.45)); opacity: var(--cord-opacity); }

.ksd-node { position: absolute; left: 0; transform: translate(-50%,-50%); width: var(--node-size); height: var(--node-size);
  z-index: 1;  /* under .ksd-label's 2 — the ripple stays beneath the labels */
  border-radius: 50%; background: rgba(228,232,235,1); pointer-events: auto; cursor: pointer; border: 0; padding: 0;
  opacity: var(--node-idle-opacity); box-shadow: 0 0 var(--node-glow) rgba(var(--node-color), var(--node-glow-a));
  transition: opacity 260ms var(--ease-standard), transform 260ms var(--ease-standard), box-shadow 260ms var(--ease-standard); }
.ksd-node::after { content: ''; position: absolute; top: 50%; left: 50%; width: var(--node-mark-size); height: var(--node-mark-size);
  transform: translate(-50%,-50%); border-radius: 50%; border: 1px solid rgba(228,232,235, var(--node-mark-a)); }
.ksd-node:hover { opacity: 1; transform: translate(-50%,-50%) scale(1.15); box-shadow: 0 0 18px rgba(var(--node-color), .85); }
.ksd-node.is-active { opacity: 1; transform: translate(-50%,-50%) scale(1.3); box-shadow: 0 0 20px rgba(var(--node-color), .9);
  --node-ring: var(--node-ring-active); }

/* THE RIPPLE — the focused node emits light, ported from the lab's
   .spine-node__ring (css/spine-ui.css) Aug 14 2026 at the owner's ask. Two
   rings, radar train: ring 2 rides half a cycle behind so a ripple is always
   travelling out. js/spine-doc.js appends both spans inside each node button. */
.ksd-node__ring { position: absolute; top: 50%; left: 50%;
  width: var(--node-ring); height: var(--node-ring);
  transform: translate(-50%,-50%) scale(0.4);
  border-radius: 50%;
  /* the glow reaches out and fades — colour gradient, not a hard line */
  border: 1px solid rgba(var(--ring-color), 0.55);
  background: radial-gradient(circle, transparent 58%, rgba(var(--ring-color), 0.14) 100%);
  box-shadow: 0 0 14px rgba(var(--ring-color), 0.35);
  opacity: 0; pointer-events: none; }
/* LONGHANDS, deliberately — do not collapse these into the `animation`
   shorthand. The lab paid for this once (measured Aug 9 2026): the shorthand
   also sets animation-delay:0s and outranked ring 2's delay rule, so both
   rings ran in phase, drawn exactly on top of each other, and the radar train
   had never once rendered. Longhands leave animation-delay alone. */
.ksd-node.is-active .ksd-node__ring {
  animation-name: ksd-node-pulse;
  animation-duration: var(--node-pulse-ms);
  animation-timing-function: cubic-bezier(0, 0.4, 0.3, 1);
  animation-iteration-count: infinite;
}
.ksd-node .ksd-node__ring--2 { animation-delay: calc(var(--node-pulse-ms) * -0.5); }
@keyframes ksd-node-pulse {
  0%   { transform: translate(-50%,-50%) scale(0.3); opacity: 0; }
  12%  { opacity: var(--ring-peak-a); }
  100% { transform: translate(-50%,-50%) scale(var(--ring-scale)); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .ksd-node__ring { display: none; }
}

/* THE THROW. 32px, and it is the whole connector — at this axis position there
   is no distance to cross, so the line reads as a tick that pins the text to
   the spine rather than as a cable delivering it. It draws from the node
   outward. */
.ksd-throw { position: absolute; left: 0; height: 1px;
  transform-origin: left center; width: var(--ksd-throw); background: rgba(228,232,235,.9);
  box-shadow: 0 0 3px rgba(228,232,235,.55), 0 0 9px rgba(228,232,235,.28);
  transform: translateY(-50%) scaleX(0); transition: transform var(--connector-draw-ms) var(--connector-ease); }
.ksd-throw.is-lit { transform: translateY(-50%) scaleX(1); }

/* VERTEBRAE. The rail stays the thin architectural line it always was; the
   segments are bilateral extensions off it, so the rail reads as the vertebral
   BODY and the arms as transverse processes. They are drawn with the same 1px
   hairline as every other structural line in the system — the anatomy comes
   from the rhythm and the downward sweep of the arms, not from thicker or
   softer strokes.

   Each segment is —┤│├—: two arms, each ending in a short vertical descender,
   which is what stops them reading as ruler ticks. Spacing carries a small
   deterministic variation so the column feels grown rather than stamped. */
.ksd-vert { position: absolute; left: 0; width: 0; height: 0; pointer-events: none; }
/* THE FIELD IS GRADED, NOT BINARY — rebuilt Aug 14 2026 at the owner's ask
   ("more of them light up, more ticks react"). js/spine-doc.js writes --vt
   (0..1, smoothstepped) per tick from its distance to the viewport-centre
   point inside the --ksd-field radius; every visual value below interpolates
   from it, so a wave of reach and brightness travels the rail instead of one
   segment snapping on. The old .is-active rules (hard ±26px window, two lit
   ticks) are gone — a binary class cannot express a falloff.

   The transition is 200ms, down from the binary era's 520ms: --vt retargets
   every scroll frame, so the transition acts as a low-pass filter on a moving
   value. At 520ms the wave trailed the scroll by half a second and read as
   lag; 200ms keeps a soft settle without the disconnect. The endpoints match
   the old states exactly: idle .16 → full .82, arm +5px, descender 3.5→5px. */
.ksd-vert i { position: absolute; top: 0; height: 1px;
  width: calc(var(--ksd-arm) + var(--vt, 0) * 5px);
  background: rgba(228,232,235, calc(0.16 + var(--vt, 0) * 0.66));
  transition: background 200ms var(--ease-standard), width 200ms var(--ease-standard); }
.ksd-vert i.l { left: 0; transform: translateX(calc(-100% - 1.5px)); }
.ksd-vert i.r { left: 1.5px; }
/* the descender at the outer end of each arm — the ┤ and ├ of the segment */
.ksd-vert i::after { content: ''; position: absolute; top: 0; width: 1px;
  height: calc(3.5px + var(--vt, 0) * 1.5px);
  background: inherit; }
.ksd-vert i.l::after { left: 0; }
.ksd-vert i.r::after { right: 0; }

/* Passed segments stay faintly energised — the column remembers where you have
   been. The field's grade rides on top of the raised base. */
.ksd-vert.is-passed i { background: rgba(228,232,235, calc(0.34 + var(--vt, 0) * 0.48)); }

/* An ANCHOR segment sits where a section node does. It is the same vertebra
   with a longer reach and a small square joint on the axis — hierarchy without
   adding a second circle next to the node. Same graded endpoints as before:
   +4→+9px reach, .42→.9 line, .5→.95 joint. */
.ksd-vert.is-anchor i { width: calc(var(--ksd-arm) + 4px + var(--vt, 0) * 5px);
  background: rgba(228,232,235, calc(0.42 + var(--vt, 0) * 0.48)); }
.ksd-vert.is-anchor b { position: absolute; top: 0; left: 0; width: 3px; height: 3px;
  transform: translate(-50%,-50%) rotate(45deg);
  background: rgba(228,232,235, calc(0.5 + var(--vt, 0) * 0.45));
  transition: background 200ms var(--ease-standard); }

/* Labels are hover-only, as on the navigator: the axis stays an instrument,
   not a menu. */
.ksd-label { position: absolute; right: 14px; top: 0; transform: translateY(-50%) rotate(180deg);
  /* ABOVE the ripple, owner's call Aug 14 2026: the active ring's travel
     (48px × 1.55, × the node's 1.3 scale ≈ a 97px halo) reaches the label
     column, and the ring must land UNDER the vertical labels — the label is
     interface, the ripple is light. DOM order alone already paints it so
     (label follows node), but that is one reorder away from breaking; the
     explicit z-index pins it. The node carries z-index:1 below for the same
     reason. */
  z-index: 2;
  writing-mode: vertical-rl; white-space: nowrap;
  font-family: var(--font-label); font-weight: 600; font-size: var(--fs-label);
  letter-spacing: var(--tracking-label); text-transform: uppercase; color: rgba(228,232,235,.82);
  text-shadow: 0 0 3px rgba(3,4,15,.98), 0 1px 2px rgba(3,4,15,.92);
  opacity: 0; transition: opacity 200ms var(--ease-standard); pointer-events: none; }
/* The active label stays up; the rest surface on hover. */
.ksd-node.is-active + .ksd-label, .ksd-node:hover + .ksd-label { opacity: 1; }

@media (max-width: 900px) {
  /* Mobile recomposes rather than scales: the axis moves to the very edge and
     the text runs the full width beneath it instead of beside it. */
  .ksd-rail { left: 22px; }
  .ksd-doc { --ksd-axis: 22px; }
}

/* ---- The hero section ----
   Home IS the hero: the rail starts at the video and its first node points at
   the hero's own title. The footage breaks out of the body's left inset so it
   runs to the page edges, while the type stays on the axis with every other
   section. The hero ESCAPES the body box and re-pads itself, rather than
   having its media reach outward. Same result for the footage — edge to edge,
   and up behind the bar — but the type keeps the axis as its left edge through
   ordinary padding, so it lines up with every other section without a second
   measurement. */
.ksd-hero {
  position: relative; min-height: 100svh; justify-content: flex-end;
  margin-top: calc(-1 * var(--nav-h-max, 92px));
  margin-left: calc(-1 * (var(--ksd-axis) + var(--ksd-throw) + 18px));
  margin-right: calc(-1 * clamp(24px, 6vw, 120px));
  padding-left: calc(var(--ksd-axis) + var(--ksd-throw) + 18px);
  padding-right: clamp(24px, 6vw, 120px);
  padding-top: calc(var(--nav-h-max, 92px) + clamp(64px, 10vh, 140px));
}
.ksd-hero__media { position: absolute; inset: 0; z-index: 0; overflow: hidden; }
.ksd-hero__media img, .ksd-hero__media video { width: 100%; height: 100%; object-fit: cover; object-position: center 20%; }
.ksd-hero__scrim { position: absolute; inset: 0; z-index: 1; background: linear-gradient(180deg, rgba(3,4,15,.35) 0%, rgba(3,4,15,.62) 55%, rgba(3,4,15,.96) 100%); }
/* :not(.ksd-hero__media) is load-bearing. A bare .ksd-hero > * sits at the same
   specificity as .ksd-hero__media and comes later, so it overrode
   position:absolute and dropped the footage into flow — it rendered as a block
   above the type instead of behind it. */
.ksd-hero > :not(.ksd-hero__media) { position: relative; z-index: 2; }
/* The sound toggle is the one hero child that must STAY absolute — the blanket
   rule above outranks its own (0,1,0) rule in components.css, which silently
   dropped it into the flex flow as a full-width bar. Same specificity, later,
   so absolute wins again; right/bottom still come from components.css. */
.ksd-hero > .hero__sound-toggle { position: absolute; align-self: flex-end; }

/* Two-column prose — the one magazine device Home borrows, and only where a
   section has a real passage to split. */
.ksd-cols { columns: 2; column-gap: clamp(28px, 4vw, 56px); column-rule: 1px solid var(--color-gray-700); max-width: 78ch; }
.ksd-cols p { break-inside: avoid-column; max-width: none; }
@media (max-width: 900px) { .ksd-cols { columns: 1; column-rule: none; } }

/* ---- CONNECT: the one section that centres ----------------------------
   Owner's call, Aug 17 2026: Stay Connected is where the page wants attention,
   so the sign-up sits in the middle of the column instead of hanging off the
   axis. It also lands on the fully lit nebula now (the deep-field background
   spends the whole of Archive bringing the sky back up), and a centred block
   breathes in that space where a left-hung one did not.

   THIS DOES NOT OVERTURN THE NOTE IT REPLACES. That note said the section
   deliberately uses .ksd-connect and NOT the .newsletter class, "whose centring
   belongs to connect.html where the block runs as a page" — and it is still
   true and still enforced. The objection was CLASS REUSE and the geometric
   coupling it creates between two pages, not the visual result. Nothing below
   adopts .newsletter; connect.html has neither this attribute nor this
   stylesheet, so it cannot be reached from here.

   IT IS THE .ksd-music-head PATTERN, AND NOT align-items, ON PURPOSE.
   text-align plus margin-inline: auto preserves every child's USED WIDTH.
   `align-items: center` on the flex section would shrink-wrap the headline
   instead, changing where it wraps and therefore its height — and
   js/spine-doc.js:76 places the rail node at `r.top + r.height / frac`, so the
   node would slide vertically for no visible reason. That is very likely why
   the Music head was built this way rather than with flex alignment.

   .ksd-connect needs its own margin-inline because it carries a max-width;
   text-align alone centres its text and leaves the 520px box on the left. The
   form inside needs nothing — it is already justify-content: center with a
   `flex: 1 1 260px` field and no fixed widths anywhere.

   The rail tick does not move and does not need to: it is 32px of fixed width
   at the axis and never reached the headline in the first place. Music has had
   exactly this condition since Aug 13 2026 — its node sits at x86 against a
   headline starting at x450. */
.ksd-connect { max-width: 520px; }

/* The negative right margin is the .ksd-music-head bleed, and it is here for
   the reason that note gives: it reclaims .ksd-doc__col's own padding-right so
   this block centres on the SAME axis Music does, by construction rather than
   by approximation. Measured at 1440 wide: without it the connect block centres
   at x745 against Music at x788, and two centred blocks on one page that miss
   each other by 43px is the kind of thing nobody can unsee once noticed.
   KEEP IN STEP with .ksd-doc__col's padding-right if that ever changes -
   css/astral-scrim.css mirrors the same value and carries the same warning. */
[data-ksd-section="connect"] {
  text-align: center;
  margin-right: calc(-1 * clamp(24px, 6vw, 120px));
}
[data-ksd-section="connect"] .ksd-head,
[data-ksd-section="connect"] .ksd-body,
/* width: 100% is NOT redundant next to max-width here. .ksd-connect is a flex
   item, and an auto margin on the cross axis cancels the default stretch — so
   with margin-inline alone it fell back to shrink-to-fit and measured 428px
   instead of its 520px cap. The form inside is `flex: 1 1 260px` plus a
   173px button and a 12px gap, which needs 445px, so at 428 the button wrapped
   onto its own line. Centring is not supposed to restructure the form. */
[data-ksd-section="connect"] .ksd-connect { margin-inline: auto; width: 100%; }

/* ---- FILM ROW: a section's copy and its footage, as one block ----
   Shared by About (the black tide) and Transmissions (spine frequency), and
   built to take the rest of the owner's clips without another copy of this
   block. Merch is deliberately NOT on it: that render is portrait, transparent
   and sized by height, which is a different problem wearing the same shape.

   The headline lives inside the copy column with the prose, so headline, copy
   and film read as a single unit in the scroll rather than as a heading with
   two things under it (owner's call, Aug 15 2026).

   The media column sits RIGHT of the copy only — the axis rule: nothing covers
   the spine rail. That is also why the film never swaps sides: the copy hangs
   off the axis, so a film on the left would be the thing crowding it.

   GRID, NOT FLEX, and the two columns STRETCH. Flex was the first cut and the
   split barely moved however the bases were set — with equal flex-grow the free
   space divides evenly and the ratio stays near 1:1 whatever the basis says.
   Grid states the proportion outright. minmax(0, …) on both tracks is what
   stops a long unbroken word from pushing a column past its share.

   THE SPLIT IS 1.03 / 0.97, AND THE FILM IS THE SMALLER TRACK NOW (Aug 17
   2026). It was 0.88 / 1.12, film larger, which measured 650 x 542 at 1440x900
   on the deep-field home. The owner called that too big NEXT TO THE MUSIC
   CAROUSEL — the largest track card there is 490 x 490 — and chose the film's
   new size BY AREA rather than by matching a width or a height:

     track card    490 x 490          = 240,100 px^2
     +10% by area  (owner's basis)    = 264,110 px^2
     at aspect 1.2                    => ~563 x 469

   The arithmetic back to a track: at 1440 the row's container is 1217px
   (1440 - .ksd-doc__col's 136px margin-left - its 86px padding-right) and the
   gap is 56px (the clamp is capped there; 4vw would be 57.6), so 1161px of
   free space divides by the fr shares. 563 / 1161 = 0.485, doubled to keep the
   pair summing to 2 => 0.97fr for the film, 1.03fr for the copy. Renders
   ~563 x 469: about 13% off the old width, ~10% more area than the card.

   THE NUMBER IS A RATIO, NOT A PIXEL WIDTH, ON PURPOSE. A width or max-width
   of 563px would hold that size while the viewport shrank and hand the whole
   loss to the copy; the fr keeps the film at 48.5% of the row's free space at
   every width down to the 900px stack, where grid-template-columns is
   overridden anyway and the film goes full-bleed. Both rows share this rule
   and neither section overrides it — checked css/filmrow-atmos.css, which
   touches .ksd-filmrow__media but sets no sizing property at all.

   THE BOX HAS A FIXED ASPECT RATIO, and that is what keeps the crop honest.
   The film is 3:2 and stands beside a text column, which will always be the
   taller of the two — so filling the height means cropping the width, and the
   only question is by how much. Letting the box simply stretch to the copy's
   height was measured and rejected: the crop came out at 22% at 1440 but 44%
   at 1100, because the copy grows taller as it narrows while the film grows
   thinner. A crop that swings that far with the viewport is a composition
   nobody chose.

   aspect-ratio 1.2 pins it at a constant crop at every width — taller than any
   of the sources so the block reads full, shallow enough that the frame
   survives. It is ONE value for every clip on purpose: the sources differ
   (black tide is 3:2, spine frequency 1.34:1), so the crop differs with them —
   20% and 10% respectively — while the BOX stays identical and the rows go on
   looking like one system. Checked per clip that a centred crop keeps the
   subjects whole: black tide's pair sit between 25% and 72% across, spine
   frequency's between 15% and 80%. A future clip with subjects nearer the
   edges is the case that would force a per-section override. */
.ksd-filmrow {
  display: grid;
  grid-template-columns: minmax(0, 1.03fr) minmax(0, 0.97fr);
  gap: clamp(28px, 4vw, 56px);
  align-items: center;
}
.ksd-filmrow__copy { min-width: 0; }

/* THE MIRRORED ROW — film left, copy right. Archive only (Aug 18 2026).
   The proportions are SWAPPED, not just reordered: the base row gives the copy
   1.03 against the film's 0.97, and the mirrored figure sits in the first
   column, so without this the film would take the wider share and the copy the
   narrower one. Swapping the track sizes keeps every row on the page giving
   the copy the same width, whichever side it is on. */
.ksd-filmrow--mirror { grid-template-columns: minmax(0, 0.97fr) minmax(0, 1.03fr); }
.ksd-filmrow__media { min-width: 0; margin: 0; aspect-ratio: 1.2; }
.ksd-filmrow__media video { display: block; width: 100%; height: 100%; object-fit: cover; }
@media (max-width: 900px) {
  /* Stacked, there is no second column to match, so the film goes back to its
     own proportions — cropping to fill a height nothing is standing beside is
     throwing frame away for nothing. */
  .ksd-filmrow { grid-template-columns: minmax(0, 1fr); }
  .ksd-filmrow__media { aspect-ratio: auto; }
  .ksd-filmrow__media video { height: auto; object-fit: fill; }

  /* Stacked, the mirror has nothing to mirror — and its figure is FIRST in
     source order, so without this Archive alone would read film-then-copy
     while every other row reads copy-then-film. Same fix, and the same reason,
     as .ksd-merch__video's `order: 1` below. */
  .ksd-filmrow--mirror .ksd-filmrow__media { order: 1; }
}

/* ---- The merch video ----
   The spine render stands beside the merch copy — the object that carries it,
   next to "Objects That Carry It". The element is TALL on purpose: the render
   is a vertical column cropped to portrait at encode time, so its height is
   its size (the earlier 3D-model pass measured the same lesson: a short stage
   shrinks a spine to a sliver).

   THE RENDER MOVED TO THE LEFT AND THE COPY CENTRED, Aug 17 2026, the owner's
   call. The line that stood here said the video column "flexes RIGHT of the
   copy only — nothing covers the axis". THE AXIS RULE IS NOT WHAT CHANGED and
   it is not broken by this: the rail lives outside .ksd-doc__col, which is
   held clear of it by margin-left: calc(--ksd-axis + --ksd-throw + 18px).
   Measured at 1440x900 after the move — cord at x88, video's left edge at
   x136, i.e. the render starts exactly where every other block in the column
   starts and covers nothing. What it must never do is bleed LEFT of the
   column, so there is deliberately no negative margin on this figure. The
   .ksd-filmrow note still says its film never swaps sides; that is still true
   of the film rows, whose copy is left-aligned and hangs off the axis.

   THE VIDEO'S TOP EDGE MEETS THE CAP OF THE "O", NOT THE HEADLINE'S BOX.
   .ksd-head is set at line-height 0.9 — TIGHTER than the face's own
   ascent+descent — so the half-leading is negative and the cap sits only just
   under the line box's top. Only just is still not zero, and align-items:
   flex-start on its own lands the spine visibly above the letterform.
   MEASURED in Chromium at 1440x900, Aug 17 2026, by screenshotting the
   headline and scanning for the first painted row inside the leading glyph:
   font-size 80px, line-height 72px, the painted apex of the "O" 6.0px below
   the h2's border-box top => 0.075em. The face's own metrics agree and say
   why: (0.9 - (0.875 + 0.2125)) / 2 + 0.875 - 0.70 = 0.08125em down to the
   NOMINAL cap line, and a round O overshoots ~0.006em above it. 0.075em is
   the number that was painted, and painted is what the owner is looking at.

   IT IS A FRACTION OF --ksd-head-fs, NOT A PIXEL CONSTANT. The headline is
   clamp(2.4rem, 6vw, 5rem), so a hard 6px would be correct at 1440 and wrong
   everywhere else; the same fraction gives 3.4px at 760.
   IT IS margin-top, NOT a transform. .ksd-reveal already owns this element's
   transform and clears it to translateY(0) on reveal, which would wipe a
   translate put here.

   WHAT THIS ALIGNS IS THE ELEMENT'S TOP EDGE, AND THE SPINE PAINTS BELOW IT.
   The encode's crop carries a margin above the union bounding box of all 241
   frames, and the spine then turns inside that frame as the scroll scrubs it,
   so its painted top MOVES. Measured Aug 17 2026 at 1440x900 by drawing the
   <video> into a canvas at its native 264x1296 and scanning for the first row
   with alpha: the spine's top sits between row 20 (t=0, its highest reach) and
   row 39 (t≈4.9), i.e. 10.8px to 21.1px below the element's top edge at the
   702px display height. There is no one frame to align to, so the ELEMENT is
   the target and the residual is that 10.8–21.1px.
   IF THE OWNER WANTS THE SPINE'S HIGHEST REACH ON THE CAP INSTEAD, it is one
   subtraction — 20/1296 of the video's height — and it needs the height
   hoisted to a token the same way --ksd-head-fs was, because the height is a
   clamp() too. That parks the tallest frame exactly on the cap and every other
   frame 0–10.3px under it, never above. It was NOT done here because the
   instruction being built to was the element's top edge on the cap; this note
   is the number to change it with, not a suggestion that it is wrong.

   THE FIGURE IS `flex: 0 0 auto`, NOT A FLEX SHARE. It was `1 1 300px`, which
   handed it half the row while the video inside it was 143px wide — that is
   what used to park the render in the middle of a 556px column, and on the
   left it would have left the spine floating in dead space instead of on the
   column's edge. Shrink-to-fit puts the figure's box on the video, and the
   copy takes everything else and centres inside it. */
.ksd-merch {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(28px, 4vw, 64px);
  /* flex-start, not center — centre is what held the render level with the
     PARAGRAPHS' midpoint and made it impossible to reach the headline.
     This is align-items on a ROW, so it is the VERTICAL alignment of the two
     columns. It is NOT the align-items:center the do-not list forbids: that
     one goes on .ksd-section, which is a flex COLUMN, where it shrink-wraps
     the headline, changes its height and slides the rail node — js/spine-doc
     .js places the node at r.top + r.height / frac off .ksd-head. */
  align-items: flex-start;

  /* THE PAIR IS CENTRED AS A GROUP (owner's call, Aug 17 2026: "move the spine
     closer to the right, to the centred text").

     MEASURED before this at 1440x900: the render's painted right edge sat at
     x270 and the headline began at x507 — a 237px hole. It was not the flex
     gap, which is only 57.6px. It was that .ksd-merch__copy was flex: 1 1 34ch
     and therefore GREW to the full 1017px remaining, while the text inside it
     kept its own 62ch max-width and centred in that column. All the slack
     landed between the spine and the first word.

     So the copy no longer grows: it takes its content width and the row
     centres the two columns together. The text is still centred - it now
     centres within a column that fits it, which also brings the block back
     towards the section's own centre line (its centre was 100px right of it). */
  justify-content: center;
}
/* flex-basis 62ch matches the .ksd-body max-width this column is sized by, so
   the column is the width of the prose rather than the width of whatever is
   left over. 0 1 not 1 1: it may shrink on a narrow window, never grow. */
.ksd-merch__copy { flex: 0 1 62ch; min-width: 0; text-align: center; }
/* margin-inline:auto AS WELL AS text-align, and neither is redundant: both
   elements keep their own max-width (18ch and 62ch), and a max-width'd block
   with margin 0 stays hard left however the text inside it is aligned. Same
   pair .ksd-music-head uses. Scoped under .ksd-merch__copy so the film rows'
   and the hero's headlines are untouched. */
.ksd-merch__copy .ksd-head,
.ksd-merch__copy .ksd-body { margin-inline: auto; }
.ksd-merch__video {
  flex: 0 0 auto;
  min-width: 0;

  /* THE SPINE MEETS THE CAP, NOT THE VIDEO BOX (owner's call, Aug 17 2026).

     Two separate offsets are stacked in this one margin, and they pull in
     opposite directions:

     + 0.075em of --ksd-head-fs pushes DOWN from the h2's border-box top to the
       painted apex of the "O". Measured by screenshotting the headline and
       scanning for the first painted row inside the leading: 6.0px at a
       font-size of 80px. Font metrics agree — (0.9 - (0.875 + 0.2125))/2 +
       0.875 - 0.70 = 0.08125em to the nominal cap line, and a round O
       overshoots roughly 0.006em above that.

     - the clip's own transparent lead pulls back UP. The encode is a 264x1296
       crop taken as the union bounding box of all 241 frames, so on any given
       frame the spine starts some rows BELOW the top of the video element and
       the element's top edge is empty pixels. Aligning the ELEMENT to the cap
       therefore left the visible spine hanging ~20px under the O, which is the
       thing the eye actually measures.

     MEASURED Aug 17 2026, 49 frames sampled across the full 8.042s, reading
     the first row with alpha > 12 off a canvas draw: the lead runs row 19 at
     its tallest (t 0.17-0.67 and t 8.04) to row 39 at its shortest. So the
     compensation is the TALLEST reach, 19/1296 = 0.0146605 of the displayed
     height: that parks the highest frame exactly on the cap and leaves every
     other frame between 0 and 10.8px under it. Never above — a spine that
     rises past the headline as it turns would read as a layout bug, and
     choosing the tallest frame is what makes that impossible.

     BOTH TERMS ARE FRACTIONS, NOT PIXELS, because both things they track are
     clamps: --ksd-head-fs is clamp(2.4rem, 6vw, 5rem) and the render height is
     the token below. A pixel constant would be correct at exactly one viewport
     width. Net at 1440x900: 6.0 - 10.29 = -4.29px. */
  --ksd-merch-vid-h: clamp(540px, 78vh, 780px);
  --ksd-merch-vid-lead: 0.0146605;   /* 19 / 1296, the tallest frame */
  margin: calc(0.075 * var(--ksd-head-fs)
               - var(--ksd-merch-vid-lead) * var(--ksd-merch-vid-h)) 0 0;

  display: flex;
  flex-direction: column;
  align-items: center;
}
/* Height comes off the token so the margin above can do arithmetic with it.
   Changing the render's size now means changing --ksd-merch-vid-h alone, and
   the cap alignment follows it. */
.ksd-merch__video video { display: block; height: var(--ksd-merch-vid-h); width: auto; max-width: 100%; }
/* is-flat = the browser refused the alpha WebM and is playing the mp4, which
   still has its black backdrop. Screen blend zeroes black against any
   background — the poor browser's transparency. Not applied to the WebM:
   blending REAL alpha would let bright nebula bleed through the spine's dark
   body. js/spine-doc.js owns the tagging. */
.ksd-merch__video video.is-flat { mix-blend-mode: screen; }
/* .ksd-merch__video-note is gone (Aug 15 2026) — the figure's caption was
   removed on the owner's call and this rule had no other user. The figure
   keeps its column flex so the video stays centred in the column on its own. */
@media (max-width: 900px) {
  /* Stacked under the copy the render keeps portrait height for the same
     reason as above — a squat stage on a phone is a thumbnail.

     STACKING IS NOW EXPLICIT rather than left to flex-wrap, because source
     order changed: the figure comes FIRST in the HTML so it lands left on a
     wide screen, and wrapping alone would therefore have put a 620px spine
     ABOVE the headline on a phone — a wall you scroll past before you know
     what the section is. order:1 sends it back under the copy, where it has
     always been narrow.
     align-items:stretch is the other half of it. The base rule's flex-start
     is a ROW cross-axis value; the moment the direction is column it becomes
     HORIZONTAL and pins the figure — and the render inside it — hard left
     against the copy's centred text. Stretch gives the figure the full column
     width and its own align-items:center puts the video back in the middle.
     And the cap-height nudge comes off: stacked there is no "O" beside the
     render to meet, so the compensation is just 3–6px of dead space. */
  /* BOTH OF THE DESKTOP GROUPING RULES COME OFF HERE, and neither is cosmetic.
     Once the direction is column, flex-basis and justify-content both swap to
     the VERTICAL axis: flex: 0 1 62ch would set the copy's HEIGHT to 62ch, and
     justify-content would distribute the columns down the section instead of
     across it. */
  .ksd-merch { flex-direction: column; align-items: stretch; justify-content: flex-start; }
  .ksd-merch__copy { flex: 0 1 auto; }
  /* The height moves as a TOKEN now, not as a height on the video, because the
     base margin does arithmetic with it — overriding the video's height alone
     would leave the cap compensation computing against the desktop figure. It
     is zeroed here anyway, but a stale term in a calc that is only ever
     overridden by accident is the kind of thing that comes back. */
  .ksd-merch__video { order: 1; margin-top: 0; --ksd-merch-vid-h: min(130vw, 620px); }

  /* THE MERCH COPY STOPS BEING CENTRED once the render is no longer beside it.
     text-align: center is set at the base rule and the reason given there is
     entirely about the horizontal relationship to the spine render - which
     `order: 1` above has just dissolved by stacking them. Left standing, the
     phone reads left-aligned About, then a centred column, then left-aligned
     Transmissions, all in one narrow stack.
     MEASURED at 390px: the copy column is 294px and the longest paragraph is
     ~340 characters, so it wraps to ten lines ragged on BOTH edges. Centring
     is a shape for a short block beside something; it is not a way to set ten
     lines of body copy 294px wide. */
  .ksd-merch__copy { text-align: left; }

  /* THE RAIL LABEL COMES INBOARD, because there is no room for it outside.
     It is positioned `right: 14px` off the axis, and the axis is 22px here
     against 6vw on desktop - so a 21px-wide vertical label lands at x = -13
     and MEASURED 62% of it was off the left edge of the screen. Every section
     label on the page was a sliver.
     Flipping it to the inboard side costs nothing: the gutter between the axis
     and the content column is 72 - 22 = 50px, the label occupies 36-57, and
     the vertebrae ticks stop around x=33. Clear of both.
     THE ALTERNATIVE WAS MOVING THE AXIS, and it was measured too: --ksd-axis
     22 -> 40 fits the label outboard and keeps the desktop handedness, but it
     narrows the copy column from 294px to 276px on EVERY page that uses the
     rail, which is a site-wide layout change to buy a decorative label. This
     side of the axis is the cheaper trade. */
  .ksd-label { right: auto; left: 14px; }

  /* THE CAROUSEL'S HEADROOM IS SCALED TO THE CARD, and on a phone the card is
     much smaller. 140px is derived from a focused card rising ~122px MEASURED
     AT 1440x900; below 768px css/track-experience.css shrinks the card to
     min(42vw, 190px) and the arc to 420px, so the rise scales down and the
     padding did not. MEASURED at 390px: 341px between the heading and the
     first card, on a screen with ~700px of usable height. 56px keeps the same
     proportion to the smaller card and returns 84px of it. */
  .ksd-bleed { padding-top: 56px; }

  /* THE MUSIC BLOCK STOPS BLEEDING RIGHT ON A PHONE, because the thing it
     bleeds past clips it. .ksd-section is overflow:hidden and 294px wide here
     (72..366), while both of these reclaim the column padding to reach 390 -
     so the last 24px of both boxes is painted into a clip.
     MEASURED at 390px: the heading Enter the Tracks has ink from 91 to 371
     against a clip edge at 366, so its final glyph was cut. It survives at
     320px only because the type wraps to a narrower line first, which is why
     this reads as a defect on the commonest phone and not on the smallest.
     It also cost the panel its right gutter: body copy ran to within 3px of
     the screen edge with 72px on the left. Not clipped - MEASURED per word,
     max ink 387 against a 390 viewport - but flush, and lopsided against the
     rail side.
     BOTH, TOGETHER, NEVER ONE. The note above .ksd-music-head says the head
     centres on the arch wrapper WITH THE SAME RIGHT BLEED so the two share a
     centre by construction. Dropping the bleed from one of them would move
     that shared centre by 12px and quietly break the thing that note is
     protecting. Dropping it from both keeps them on a common centre of 219. */
  .ksd-bleed, .ksd-music-head { margin-right: 0; }

  /* Stay Connected carries the same right bleed and loses its gutter the same
     way: the section is text-align:center inside a box reaching the viewport
     edge, so centred copy ends up hard against it. MEASURED at 430px, the body
     copy came within 1px of the edge; at 320 the privacy line came within 6.
     Dropping the bleed gives it the column padding back and centres it on the
     column instead of 12px right of it.
     THE FORM DOES NOT RESTRUCTURE. The note on .ksd-connect warns that the row
     needs 445px before the button wraps onto its own line - at every width in
     this block the box is already far below that (318px at 390), so the button
     is on its own line before and after, and this only moves it 24px. */
  [data-ksd-section="connect"] { margin-right: 0; }
}

/* ---- Headline reveal ----
   Headlines rise and clarify once, the first time they cross into view —
   mechanical rather than bouncy: a short upward settle and a fade, on the
   system's own easing. They do not re-hide on the way back up; a headline that
   vanishes when you scroll past it and reappears when you scroll back reads as
   glitchy, not deliberate. */
.ksd-reveal { opacity: 0; transform: translateY(18px); transition: opacity 640ms var(--ease-standard), transform 640ms var(--ease-standard); }
.ksd-reveal.is-in { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) { .ksd-reveal { transition: none; } }
