/* soyoki-sound.css — the SOUND surface: seventeen mixtapes, two views.
   Loaded only by pages that carry .page[data-page="sound"].

   THIS IS THE ARTIFACT, NOT A DESCRIPTION OF IT (operator 2026-09-17). The panel
   held three paragraphs of prose about the tapes for one build; the tapes
   themselves are the content and the prose was invented. P8: the artifact is the
   unit of work.

   IT IS WIDER THAN THE OTHER FOUR UTIL PANELS, deliberately. They are reading
   measures — min(58em,64vw) at --measure:250px, sized for text. This one carries
   a shelf column, the player (--snd-player-w, 480px) and a track index side by
   side, which the
   reading measure cannot hold. The departure is Principle 4: the content
   requires it, and nothing else on the surface changes register.
   ══════════════════════════════════════════════════════════════════════════ */

/* THE PANEL STOPS BEFORE THE INDEX. The operator's instruction is that the right
   rail stays OPEN under the frost — but open and covered is not open. .index is
   340px at right:34px (soyoki-shared.css:30), so its left edge is 374px in from
   the right; 48px of air past that is 422. Measured before: panel 96–1544 against
   an index at 1123–1566, overlapping it by 421px. Derived from the index's own
   numbers so it moves if the index does. */
/* .page.page--sound, NOT .page--sound, AND THE DOUBLE CLASS IS LOAD-BEARING.
   The family base is `.page{width:min(58em,64vw)}` and this is the earned
   departure from it (P4) — but at equal specificity the departure wins only while
   it is declared LAST, and on the research page it is not: that surface does not
   load soyoki-shared.css, it inlines its own copy of the panel base in a <style>
   block that follows this sheet's <link>. So `width:auto` lost to `min(58em,64vw)`
   and the panel rendered 928px wide inside 1478px of room it had correctly been
   given — measured, the grid then solved FOUR columns where every other shell
   solves five, and the cause was invisible in both files because each is right on
   its own. Specificity, not order, is what an earned departure should rest on. */
/* SUPERSEDED (operator 2026-09-18, seeing both side by side: "the grid is too
   large… sk-00, 01, 02, 03 and aexis all need to have the same layout as sk-04").
   The width:auto full-span above WAS the earned departure, and the 928px/4-column
   render the note below calls a bug is the layout the operator has now ruled
   canonical — sk-04 showed it all along because its scope-wrapped fork out-ranked
   this rule. The family base width returns, with the index clearance folded in as
   a width cap so narrow viewports still stop before the index (the old right:422
   did that job; right is ignored once left+width are set). */
.page.page--sound{width:min(58em, calc(100vw - 96px - 422px));right:422px;top:11vh}
.page--sound .pg-head{margin-bottom:22px}
/* ONE PANEL, SEVEN DOCUMENTS (operator 2026-09-18: "implemented on every page...
   it needs to look and function exactly the same"). This file and soyoki-sound.js
   are the single source; the shells fork only the <article> markup and these two
   includes. Nothing sound-shaped is written per-shell.
   --snd-ink IS THE ONE SEAM THE INVERTED SHELL NEEDS. SK-02 is cream ground, dark
   ink (its own head restates the tint in cream the same way), so every TYPE ink
   here reads through this token — components, not a colour, because the file
   needs the alphas (.34 to .92) against one base. OBJECT MATERIALS ARE NOT
   TOKENIZED, deliberately: the hub ink (#B4B4B4, sampled from the cassettes), the
   empty case's glass, the contact shadows — an object keeps its material on any
   ground, exactly as the B&W photographs themselves do on SK-02's cream. */
.page--sound{--snd-ink:236,231,221}
/* title and controls on one BASELINE — a 16px control centred on a display face
   reads as debris beside it */
/* THE HEADER MIRRORS THE SHELF'S COLUMNS (operator 2026-09-18). The template is
   written by fitShelf — the SAME string it gives .snd-shelf — so the right cell
   begins exactly where the main column begins and the mix title sits over the
   player it names. The 48px gap must match .snd-shelf's or the two disagree by
   that much; it is stated in both places and nowhere else.
   `auto 1fr` is the state before fitShelf has ever run (first paint, and grid view
   on a session that never opens the shelf). Cell one starts at 0 under either
   template, so SOUND and the icons do not move when the template changes. */
.snd-titlerow{display:grid;grid-template-columns:auto 1fr;gap:48px;align-items:baseline}
.snd-titlehead{display:flex;align-items:baseline;gap:20px}
.snd-titlerow .pg-title{margin-bottom:0}
/* nothing is selected in grid view, so there is no mix to name — hidden, not
   emptied (P11: the interface shows only what is present) */
.page--sound[data-view="grid"] .snd-meta{visibility:hidden}   /* IT HOLDS ITS BOX (operator 2026-09-19: "same size and position"). display:none made the header 56px tall in grid against 58 in shelf, so the content below started 2px apart and the first tape could never sit in the same place. visibility keeps P11 honest — nothing is rendered, no name invented, no slot furnished; only the height is held so the composition does not move between views. (Tried and reverted once on a mid-settle measurement, 1.8s, which read 224 vs 226 — the artifact, not the geometry.) */
/* (.snd-note retired 2026-09-17 with the standfirst it styled — the line was
   removed rather than reworded, because seventeen labelled cassettes already say
   what it said. Deleted rather than left unused; the reasoning is at the markup,
   where the element used to be.) */

/* the two views — a control, so no underline and no box. Two states, so no
   label: the shapes ARE the views, four tiles against four stacked cases. */
.snd-views{display:flex;gap:10px}
.snd-vbtn{appearance:none;background:none;border:0;padding:3px;cursor:pointer;line-height:0;
  color:rgba(var(--snd-ink,236,231,221),.34);transition:color .3s cubic-bezier(.2,.7,.2,1)}
.snd-vbtn svg{display:block;fill:currentColor}
.snd-vbtn:hover{color:rgba(var(--snd-ink,236,231,221),.72)}
.snd-vbtn[aria-current="true"]{color:var(--ink);cursor:default}

/* ── VIEW 1 · THE GRID ───────────────────────────────────────────────────── */
/* FEWER, LARGER (operator 2026-09-17: "this grid needs to be better and more
   premium"). It was six columns of 178x132 inside a 1180px cap, in a panel that
   measures 1472 at 1990 — 292px of its own width unused, and seventeen objects
   small enough that the handwritten label on each cassette, which is the whole
   character of these, could not be read. Seventeen thumbnails you can count is a
   contact sheet; four across you can read is a collection.
   THE COLUMN COUNT IS NOT SET HERE. fitGrid() in soyoki-sound.js solves it
   against the measured height budget and writes gridTemplateColumns inline,
   because this page does not scroll (shared CSS l.3) and the panel opens at the
   level of its nav word — so the room the grid has is a measurement, not a
   breakpoint. A first pass DID set it here, to four columns, and the grid came
   out 1209px inside a 969px budget: the panel ran 216px past the viewport and
   two rows of tapes could not be reached.
   WHAT IS DECLARED HERE IS THE FALLBACK, and it is deliberately the SMALL end.
   If the script never runs, auto-fill at a 220px floor gives six or seven small
   columns that fit rather than four large ones that are cut off — failure lands
   on a composition that is complete and modest, never on one that is handsome
   and truncated.
   THE RAGGED ROW IS ACCEPTED whatever the solve picks: 17 is prime, so every
   column count leaves a remainder. A chronology that ends mid-row is what a real
   shelf does; padding it to be even would be the artificial move. */
.snd-grid{display:grid;gap:22px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
/* THE SWITCH IS A CROSSFADE, NOT A DISPLAY SWAP (operator 2026-09-17: "it should
   feel more considered, graceful and premium. it just jumps right now"). It was
   display:none against display:grid — one frame, everything gone, everything
   arrived. Both views now hold their box and fade, and the outgoing one stops
   taking clicks while it leaves.
   THE PACE IS THE PAGE'S, not a number invented here: .45s out and .55s in on
   cubic-bezier(.2,.7,.2,1), which is this surface's own settle curve — the same
   one the spines, the sheen and the tape's own arrival already use. Out is quicker
   than in because a thing leaving should not detain you; a thing arriving should
   take its time (P3).
   The tape itself does not cross-fade at all: the rail's tape is sized to the
   grid's first tile, so it is the one object that stays put while the two
   compositions exchange around it. */
/* BOTH VIEWS ARE ABSOLUTE AT ALL TIMES, and that is what makes the fade run at
   all. The first pass left the active view in flow and made the outgoing one
   absolute at the same moment its opacity changed — measured, the grid read 1 at
   150ms and 400ms of a 450ms fade and then 0: changing `position` in the same
   style recalculation re-lays the element out and the transition never starts.
   Only opacity and pointer-events change now; the boxes never move. They share
   one origin (the panel's content top) and the panel is itself absolutely
   positioned with its own height, so nothing depended on either being in flow. */
/* THE TRANSITION PROPERTY IS GONE FROM HERE, AND THESE RULES ARE NOW THE REST
   STATE ONLY (operator 2026-09-18: "the transition between grid and shelf still
   isn't great"). It declared `transition:opacity .45s` and never once ran: open()
   flips data-view and then, in the SAME synchronous task, rebuilds both subtrees
   and forces layout twice (fitTracks measures rendered height, the FLIP reads
   getBoundingClientRect). That is the same failure the paragraph above records for
   `position` — a declared value and a forced reflow in one style recalculation, and
   "the transition never starts". Traced on the running page: grid at 1.00 for
   ~270ms, then 0.00 with the shelf at 1.00 in a single sample, fourteen samples
   over 1.17s and no intermediate value anywhere.
   A declaration that never fires is a false record of how the surface behaves, so
   it is removed rather than left as documentation of an intention. The crossing is
   driven by WAAPI in crossIn()/crossOut(), which is what the rest of this surface
   already uses and which needs no resolved start style. The reduced-motion branch
   moves with it: reduced() gates those functions in JS. */
.snd-views-box{position:relative}
.snd-grid,.snd-shelf{position:absolute;left:0;right:0;top:0}
.page--sound[data-view="shelf"] .snd-grid,
.page--sound[data-view="grid"] .snd-shelf{opacity:0;pointer-events:none}
.page--sound[data-view="grid"] .snd-grid,
.page--sound[data-view="shelf"] .snd-shelf{opacity:1;pointer-events:auto}
/* THE APPLE EMBED WAITS ITS TURN. The one element in the crossing whose timing is
   not ours — it paints at full strength the moment it loads, and lands later on a
   cold cache, so it breaks the composition worst on a first look. Held at 0 and
   released by .is-ready once the column has arrived (paintApple). A transition is
   correct HERE and wrong for the crossing: one property, a static element, nothing
   rebuilding around it. */
#sndEmbed{opacity:0;transition:opacity .42s cubic-bezier(.2,.7,.2,1)}
#sndEmbed.is-ready{opacity:1}
@media (prefers-reduced-motion:reduce){ #sndEmbed{transition:none} }
/* ── THE TAPES SIT ON THE PAGE RATHER THAN ON TOP OF IT ────────────────────────
   Operator 2026-09-17: "they look like bad photoshop". The diagnosis was four
   things, and mix-blend-mode — the obvious answer — is not one of the fixes,
   because it is not reachable here. Measured: applying luminosity changed 85px
   against a 486px noise floor, i.e. nothing. Two isolation boundaries sit between
   a tape and the nebula — the drop-shadow on the img itself (SJL: "a filter is an
   isolation boundary; grade the group, not the blended element") and, the one
   that cannot be removed, .page.active's transform:translateY(0), which is the
   panel's arrival on all six shells. Any non-none transform establishes a
   stacking context; the independent `translate` property would too. So the tapes
   physically cannot blend with what is behind them, and the four treatments below
   do the same job without needing the backdrop.

   WHAT EACH ONE IS FOR
   1 GRAIN. The page carries paper grain at alpha .035 and the comp shader adds
     per-frame noise; the cut PNGs are clean. One layer with noise over a ground
     with noise is the classic pasted-on tell. The grain is MASKED BY THE TAPE'S
     OWN CUT (--cut, set per figure in paintGrid) so it lands on the object and
     not in the transparent corners, and the figure isolates so the grain blends
     with the tape rather than with the page.
   2 VARIANCE. Seventeen objects at identical scale and zero rotation is a
     spreadsheet, not a collection. Each figure takes a small seeded rotation and
     offset — seeded, never random, for the reason the shelf's own STAG comment
     gives: "a hand-stacked pile is uneven; one that reshuffles on every repaint
     is a glitch."
   3 WARMTH. Neutral grey photographs on a warm ground. This is what the blend
     would have taken from behind; taken directly instead.
   4 SHADOW INSIDE THE GUTTER. It was 26px of blur at rest and 34 on hover across
     a 22px gap, so every tape shadowed its neighbours and the grid sat in a pool
     of its own shade — which is what read as "they darken multiple when
     hovering". 11px and 16px stay inside the gutter. Lighter, too.
     And an ambient shadow alone floats: the tight elliptical CONTACT shadow is
     the shelf's own .snd-tape::after, forked here for the same reason it exists
     there.

   HOVER IS THE OBJECT STEPPING FORWARD (operator): full opacity, the grain and
   the warmth come off, the shadow grows within its gutter, and the lift stays.
   The seeded rotation does NOT unwind — returning to square would read as the
   object snapping to a grid it was never on.
   Dials: --tape-rest (held-back opacity) · --tape-grain · --tape-warm */
/* The seeded tilt is gone (operator: "make sure the grid is perfectly aligned").
   No transform here at all — the figures sit exactly on the grid's own tracks. */
.snd-grid figure{margin:0;position:relative;isolation:isolate}
/* THE DRAWN HUBS, BEHIND THE TAPE. The cut has the whole cog punched out, so
   this shows through the two openings. Its viewBox is the cut's own 620x394 and
   it is laid over the same box, so the hub registers with the hole at any size
   without a single measured pixel. Flat off-white, one fill, no stroke — the
   photograph's teeth could not be kept by any tone threshold, and drawn they are
   identical on all seventeen.
   Dials: --hub-ink, --hub-alpha. */
/* the one element that moves on hover — both the tape and its teeth ride it, so
   they cannot separate. Nothing else about the figure travels. */
.snd-grid .snd-lift{display:block;position:relative;
  transition:transform .5s cubic-bezier(.2,.7,.2,1)}
.snd-grid .snd-hubs{position:absolute;inset:0;width:100%;height:100%;z-index:0;
  pointer-events:none;overflow:visible}
/* THE SHELF'S STANDING TAPE GETS THE SAME HUBS. .snd-tape is a flex slot sized by
   --T and taller than the image it holds, so an SVG laid over the SLOT would not
   register with the openings. This box carries the cut's own aspect and the image
   fills it, so the two share one geometry — the same reason the grid's figure
   works without a measured pixel. */
/* SHRINK-WRAPPED TO THE IMAGE, not given an aspect of its own. A first pass gave
   this box aspect-ratio:620/394 and let the image object-fit inside it; measured,
   the SVG then sat 7px above the image — four of the seventeen cuts are 477x306
   (1.559) against the others' 1.573, so the box and the picture letterboxed
   differently and the openings drifted.
   inline-block at height:100% takes its height from .snd-tape's --T budget and
   its WIDTH from the image, so the box IS the picture and an absolutely-positioned
   SVG over it registers by construction rather than by matching numbers. */
/* NO max-width HERE, and that is the whole of the 7px. With one, the box wanted
   height*1.573 wide, got clamped to the line, and resolved to a height the image
   inside did not share — measured, a 166px box holding a 159px picture, so the
   SVG sat 7px above the openings it is supposed to register with.
   It does not need one: fitShelf solves --T as w/TAPE_ASPECT against the rail's
   own column w, so height*1.573 IS w. The box is exactly the column and cannot
   overflow it. */
/* THE BOX TAKES ITS SIZE FROM THE IMAGE, not from a percentage. Two passes tried
   to give this wrapper a height — height:100% of the slot, with and without an
   aspect-ratio — and both left the SVG 7px above the picture: the percentage
   resolved against one box and the image ended up in another. Sizing the IMAGE
   from --T (which fitShelf already solves, and which inherits down from the rail)
   makes its height definite with no percentage anywhere, and an inline-block that
   shrink-wraps it IS the picture's box. The SVG at inset:0 then registers by
   construction rather than by two numbers agreeing. */
.snd-tape-in{position:relative;display:inline-block;line-height:0}
.snd-tape-in .snd-hubs{position:absolute;inset:0;width:100%;height:100%;
  pointer-events:none;overflow:visible}
.snd-tape-in .snd-hubs path{fill:var(--hub-ink,#B4B4B4);fill-opacity:var(--hub-alpha,1)}
/* THE SELECTED TAPE'S SPOOLS TURN (operator 2026-09-17: "on this shelf, spinning
   gently would be great. since its selected"). Only here — the grid's seventeen
   are a catalogue at rest, and seventeen turning spools would be a screen full of
   movement asking for attention it has not earned (P3, P10). One tape is playing;
   one tape turns.
   GENTLY IS A NUMBER: 22s per revolution, linear. A spool under tape moves slowly
   and at a constant rate — an ease would read as a hand turning it. Both turn the
   same way because both are driven by the same transport.
   transform-box:view-box is what makes each group's inline transform-origin mean
   viewBox units, so each spool turns about its own centre rather than the
   artwork's. */
.snd-tape-in .snd-hubs g{transform-box:view-box;
  animation:sndspin var(--hub-spin,22s) linear infinite}
@keyframes sndspin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){ .snd-tape-in .snd-hubs g{animation:none} }
/* rgb(180,180,180) is the cassette's OWN spool-face white, sampled off all
   seventeen untouched originals — identical on every one, since they are one
   mockup. The page's --ink (#ECE7DD) was the first value here and it is both
   lighter and warmer than any plastic on the tape, which read as a sticker laid
   on rather than a part seen through the opening. */
.snd-grid .snd-hubs path{fill:var(--hub-ink,#B4B4B4);fill-opacity:var(--hub-alpha,1)}
.snd-grid figure > img{position:relative;z-index:1}
/* CUT OUT, NOT TILED. drop-shadow() follows the ALPHA rather than the box, which
   is the whole reason to cut them: a box-shadow would draw a rectangle around a
   shape that is no longer rectangular. */
/* GHOSTED AT REST, WHOLE ON HOVER (operator 2026-09-19: "ghosted as a blend
   taking in the background or at a certain level of transparency. 100% opaque on
   hover"). Two changes and one dial:
   · THE WARMTH GOES. sepia(.20) was the brown the operator asked about — a tint
     is not a ghost, and it was also the one thing making these B&W photographs
     carry a hue their own material does not have.
   · REST OPACITY drops .60 -> .42. Provisional, not chosen: the operator names
     the level on his own screen (--tape-rest below).
   · --tape-blend ships `normal`. A blend genuinely "takes in the background",
     but WHICH blend is a property of the ground, and this one sheet serves seven
     of them — the dark shells and SK-02's cream would need opposite modes, the
     same lesson the wave plates recorded tonight. So the mode is a dial rather
     than a guess: try it on the page in front of you.
       --tape-blend: luminosity   the ground's hue, the tape's light
       --tape-blend: multiply     sinks into a light ground (SK-02)
       --tape-blend: screen       lifts out of a dark one (the shells)
     CAVEAT for whoever sets it: this element also carries a filter, and a filter
     makes an element its own stacking context — if a mode reads as doing nothing,
     that is why, and the fix is to move the drop-shadow to the figure rather than
     to spend opacity chasing it (SJL P5's deposited worked example). */
.snd-grid img{display:block;width:100%;height:auto;cursor:pointer;
  opacity:var(--tape-rest,.42);
  mix-blend-mode:var(--tape-blend,normal);
  filter:drop-shadow(0 6px 11px rgba(0,0,0,.40))
         sepia(var(--tape-warm,0)) saturate(.94) brightness(.97);
  transition:transform .5s cubic-bezier(.2,.7,.2,1),
             filter .5s cubic-bezier(.2,.7,.2,1),
             opacity .5s cubic-bezier(.2,.7,.2,1)}
/* the CONTACT shadow — forked from .snd-tape::after, which carries the reason:
   drop-shadow alone floats. Narrower and lighter than the shelf's, because these
   are smaller objects seen from further away. */
.snd-grid figure::before{content:'';position:absolute;left:14%;right:14%;bottom:1px;height:7px;
  border-radius:50%;background:radial-gradient(ellipse at center,rgba(0,0,0,.50),transparent 72%);
  pointer-events:none;z-index:-1;
  transition:opacity .5s cubic-bezier(.2,.7,.2,1)}
/* the GRAIN, masked to the tape's own silhouette so it never lands on empty
   ground. Same turbulence tile the page's sheet uses (head block, body::before). */
.snd-grid figure::after{content:'';position:absolute;inset:0;pointer-events:none;
  opacity:var(--tape-grain,.55);mix-blend-mode:overlay;
  background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/><feColorMatrix type='matrix' values='0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 0.5 0'/></filter><rect width='160' height='160' filter='url(%23n)'/></svg>");
  -webkit-mask-image:var(--cut);mask-image:var(--cut);
  -webkit-mask-size:100% 100%;mask-size:100% 100%;
  -webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;
  transition:opacity .5s cubic-bezier(.2,.7,.2,1)}
/* THE HUB LIFTS WITH THE TAPE (operator: "doesn't move with hover"). The lift was
   on the img alone, so the tape rose 3px and its teeth stayed behind — the one
   thing that would give the whole trick away. Same transform, same curve, on both.
   The CONTACT SHADOW deliberately does not move: it belongs to the ground, and an
   object rising away from its own shadow is what makes a lift read as a lift. */
.snd-grid figure:hover .snd-lift{transform:translateY(-3px)}
.snd-grid figure:hover img{opacity:1;mix-blend-mode:normal;   /* 100% opaque AND out of any blend — the hovered tape is the photograph itself, not a ghost of it */
  filter:drop-shadow(0 9px 16px rgba(0,0,0,.46)) sepia(0) saturate(1) brightness(1)}
.snd-grid figure:hover::after{opacity:0}
.snd-grid figure:hover::before{opacity:.72}
/* (.snd-grid figcaption retired 2026-09-17 with the caption itself — the tape's
   own handwritten label names it at this size. Deleted rather than left unused;
   an unreferenced rule is the next session's puzzle. The reasoning lives at
   paintGrid() in soyoki-sound.js, where the element stopped being emitted.) */

/* ── VIEW 2 · THE SHELF ──────────────────────────────────────────────────── */
.snd-shelf{display:grid;gap:48px}

.snd-rail{border-right:1px solid var(--hair);padding-right:24px;min-height:0;overflow:hidden}
.snd-main{display:flex;flex-direction:column;min-height:0}
.snd-meta{display:flex;align-items:baseline;gap:14px;margin:0 0 2px}
.snd-meta h2{margin:0;font-family:var(--serif);font-weight:400;font-size:22px;line-height:1;color:var(--ink)}
.snd-meta .c{font-family:var(--grotesk);font-size:9px;letter-spacing:.2em;text-transform:uppercase;
  color:rgba(var(--snd-ink,236,231,221),.5);text-decoration:none;white-space:nowrap;
  transition:color .3s cubic-bezier(.2,.7,.2,1)}
/* it is a LINK now, so it needs a hover and a focus ring — but no underline and
   no border: at 9px on .2em tracking a rule under it reads as a second hairline
   beside the one the track index already draws. Colour carries the state. */
a.snd-meta .c,.snd-meta a.c{cursor:pointer}
.snd-meta a.c:hover{color:rgba(var(--snd-ink,236,231,221),.92)}
.snd-meta a.c:focus-visible{outline:1px solid var(--ink-dim);outline-offset:3px}
/* (.snd-link retired 2026-09-17 — "Listen on Apple Music ↗" on its own line said
   what the APPLE MUSIC label beside the title already said, and cost the track
   index a line. The label took the arrow and the href. Deleted rather than left
   unused; the reasoning is at the markup.) */

/* APPLE'S SURFACE, UNRESTYLED — nothing outside a cross-origin frame reaches in.
   450px is not a choice: measured at 110/130/150/175 the playlist embed draws ONE
   layout and crops it. 175 is Apple's documented height for a SONG. theme=dark is
   stated rather than inherited — the default follows the VIEWER's OS setting. */
/* TIGHTENED 18 -> 10 (operator 2026-09-17: "maybe tighten the padding with the
   title and player"). With the standalone link gone, the title row and the player
   are the only two things above the index, and 18px of air between a one-line
   heading and a 450px frame read as a gap rather than a relationship. The 8px
   recovered goes to the track index along with the retired link's whole line. */
/* margin-top was 10px — the gap under .snd-meta, which no longer sits above it.
   Zero is what puts the player's top edge on the hero cassette's (operator
   2026-09-18); any value here is the two columns disagreeing by that much. */
.snd-apple{margin:0;position:relative;width:100%;max-width:var(--snd-player-w,480px)}   /* THE PLAYER'S WIDTH IS A DIAL (operator 2026-09-19: "can the apple music player be shrunk a bit"). Was a flat 660px cap. 560 is a provisional -15%, NOT a chosen value — the operator names it on his own screen: document.documentElement.style.setProperty('--snd-player-w','480px'). The iframe below stays width:100% of this box, so one number moves the whole player; its 450px height is Apple's own widget geometry and is left alone. Nothing in soyoki-sound.js writes either dimension (checked: the script sets src + a ready class only), so this cap is the single writer. */
.snd-apple iframe{width:100%;height:450px;border:0;display:block;overflow:hidden;
  background:transparent;border-radius:12px;position:relative;z-index:1}
/* ── THE PLAYER'S PLACE IS HELD WHILE IT LOADS (operator 2026-09-19: "should
   there be a skeleton loader as the player is loading? even just the same shape
   but darker"). Yes — and this is the form the codification allows:
   · P11 PERMITS IT. "Only what genuinely awaits I/O may defer, and only that may
     show an empty state." The embed is third-party I/O over the network; the
     450px void it leaves is the one placeholder on this surface that is honest.
     (Every other placeholder here has been retired for the opposite reason.)
   · P3 SETS ITS FORM. "Loading states that perform liveliness to mask waiting"
     are forbidden, so this is a STILL block — no shimmer, no pulse, no sweep.
     A waiting state should read as waiting, not as activity.
   · IT IS THE PLAYER'S MATERIAL, NOT THE PAGE'S. Dark on every surface, because
     Apple's panel is dark on every surface — the file's own rule that an object
     keeps its material on any ground, the same reason the hub ink and the case
     glass are not tokenized.
   IT CANNOT OUTLIVE ITS PURPOSE: it sits BEHIND the iframe (z0 against z1), so a
   dropped fade, a blocked embed, or a browser without :has() still ends with the
   player painting over it. The failure lands on the intended state rather than on
   a dark rectangle covering the player — SJL P4's deposited worked example. The
   fade is a nicety, not load-bearing. Tone dial: --snd-skeleton. */
.snd-apple::before{content:'';position:absolute;inset:0;z-index:0;pointer-events:none;
  border-radius:12px;background:var(--snd-skeleton,rgba(14,14,16,.5));
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.05);
  transition:opacity .42s cubic-bezier(.2,.7,.2,1)}
.snd-apple:has(#sndEmbed.is-ready)::before{opacity:0}
@media (prefers-reduced-motion:reduce){ .snd-apple::before{transition:none} }

/* THE INDEX, UNDER THE PLAYER AND NO WIDER THAN IT. Apple's frame shows six of
   fourteen behind an internal scroll — it is the TRANSPORT. This is the INDEX,
   every track at once in our type. Not the same object, which is why both exist.
   NO PLAY BUTTON: ours cannot play, and P11 says the interface shows only what is
   present. */
/* max-width is now written by fitTracks per mix — the player's 660 for the
   fifteen that fit under it, wider for the two that cannot. 14px not 22: the
   retired link's line and the tightened player gap both go to the index.
   min-height:0 so it can be capped: a flex child will not shrink below its
   content without it, which would leave the cap declared and inert. */
.snd-tracks{margin-top:22px;border-top:1px solid var(--hair);width:100%;   /* 14 -> 22 (operator 2026-09-19: "the padding between the player and the song titles needs to match the padding above the player"). MEASURED, not chosen: the gap above the player is .page--sound .pg-head{margin-bottom:22px} — the same number this file already sets. The player now breathes equally on both sides. */
  flex:0 1 auto;min-height:0}
/* THE SCROLL FADES AT ITS OWN EDGE, and only when there is a scroll. A row cut
   flat across the middle reads as a bug; a row fading out reads as more below.
   32px is two row-heights, enough to resolve as a fade rather than a smudge.
   The class is written by fitTracks off its own MEASURED height, so this can
   never appear on a list that ends by itself. */
.snd-tracks.is-scrolling{
  -webkit-mask-image:linear-gradient(180deg,#000 calc(100% - 32px),transparent);
  mask-image:linear-gradient(180deg,#000 calc(100% - 32px),transparent)}
/* THE SHELF'S FLOOR — one rule, every shell (moved here from home's index.html,
   2026-09-18, when the panel went family-wide).
   NOT INSIDE THE PANEL. Two attempts lived there and both were boxes: an ::before
   on .snd-shelf stopped dead at the shelf's own bottom edge, and position:fixed
   resolved against the PANEL because .page.active carries a transform — a
   transformed ancestor is the containing block for fixed descendants — painting a
   515px band across the top of the page. A page-wide floor has to live outside
   every transform.
   .pages::after IS THAT PLACE, ON EVERY SHELL. .pages is fixed, inset:0, untransformed
   on all six (shared CSS l.99), and ::after was unused everywhere — checked, not
   assumed. Home's first family-wide construction was a second background layer on
   .pages::before, which worked but could not travel: each shell's ::before carries
   its OWN tint (home adds a right ramp; SK-02 restates it in cream), so a shared
   rule would have had to restate every tint or lose the fight to them.
   z-index:-1 seats it under the tint, the frost and every panel — order against
   the tint is immaterial (both are black-alpha ramps; compositing commutes) and
   the frost blurring a smooth gradient is invisible.
   OPACITY, NOT EXISTENCE: the old construction snapped in and out with the
   data-view flip. This one eases — the same grace the rest of the crossing got.
   SK-02 note: the floor stays BLACK there too. It is a shadow term (legibility
   under the track index), not a tint term; a shadow is dark on cream exactly as
   the tapes' own contact shadows are.
   Dials: --snd-floor depth · --snd-floor-from where it starts */
.pages::after{content:'';position:absolute;inset:0;pointer-events:none;z-index:-1;
  opacity:0;transition:opacity .8s cubic-bezier(.2,.7,.2,1);
  background:linear-gradient(180deg,
    rgba(0,0,0,0) var(--snd-floor-from,54%),
    rgba(0,0,0,var(--snd-floor,.62)) 100%)}
.pages:has(.page--sound.active[data-view="shelf"])::after{opacity:1}
/* A GRID, NOT column-count — AND THIS IS WHY THE SCROLL WAS GONE.
   A multi-column box inside a height-constrained container does not overflow
   DOWNWARD. It fills each column to the available height and then fragments
   SIDEWAYS into another column. So the list's scrollHeight equals its clientHeight
   — measured on 2021m1 as 351 against 351 — there is nothing to scroll vertically,
   no scrollbar appears, and the twenty-one tracks that did not fit are off to the
   right, clipped. Capping the wrapper instead of the list did not help, because
   the list is still inside a constrained box and still fragments.
   Two columns that scroll have to be a grid. The list is then an ordinary block
   that grows as tall as its content and the wrapper scrolls it.
   READING ORDER: COLUMN-MAJOR (operator 2026-09-19: "the columns should read left
   column 1, 2, 3... sequentially, and 8-14 are in the right column"). This
   SUPERSEDES the row-major ruling that stood here, whose reasoning is kept
   because it names the live consequence: on a mix long enough to scroll (2021m1
   is 42 tracks) column-major reads to the bottom of the left column, then back
   up to the top of the right. The operator's order governs; the scroll case is
   the known cost of it, surfaced rather than silently re-decided.
   IT IS STILL A GRID, not CSS multicolumn — that part of the old note stands:
   multicolumn fragments INLINE when its height is capped, which is how the
   scroll was lost once (351 against 351, half the tracks off to the right and no
   scrollbar). grid-auto-flow:column with an explicit row count fills down the
   first column and across, keeps the list an ordinary block that grows to its
   content, and leaves the wrapper to clip and scroll it. --trows is written by
   fitTracks as ceil(tracks / columns), so the split is as equal as the count
   allows (14 -> 7 and 7; an odd count leaves the extra row on the left). */
.snd-tracks ol{margin:0;padding:0;display:grid;grid-auto-flow:column;
  grid-template-rows:repeat(var(--trows,7),auto);
  grid-template-columns:repeat(var(--tcols,2),minmax(0,1fr));column-gap:38px}
.snd-tracks li{list-style:none;display:grid;grid-template-columns:20px 1fr auto;gap:10px;
  align-items:baseline;padding:5px 0;border-bottom:1px solid rgba(var(--snd-ink,236,231,221),.07);
  font-family:var(--serif);font-size:10.5px;line-height:1.35;color:rgba(var(--snd-ink,236,231,221),.88);
  break-inside:avoid}
/* ── ONE LINE PER TRACK, THE REST ON HOVER (operator 2026-09-19: "can the song
   titles not go down past one line? perhaps they scroll when hovered"). At rest
   the row is exactly one line and a too-long title ends in an ellipsis; on hover
   it slides far enough to show its tail and comes back.
   THE TRAVEL IS SELF-LIMITING AND NEEDS NO JS: .n is a container, so 100cqw is
   its own width and 100% is the sliding element's — min(0px, 100cqw - 100%) is
   zero for any title that already fits and exactly the overflow for one that
   does not. Nothing measures, nothing can go stale, and a mix whose titles all
   fit has no moving parts at all.
   min-width:0 is load-bearing: a grid item's default min-width is auto, so the
   cell would refuse to shrink and the ellipsis would never appear.
   .35s delay so dragging the cursor down the list does not set every row moving;
   7s linear, which is a read, not a performance (P3). Reduced motion keeps the
   ellipsis and drops the travel — the full title stays reachable in the tooltip
   the markup already carries via its own text. */
.snd-tracks .n{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;container-type:inline-size}
.snd-tracks .nn{display:inline-block;will-change:transform}
.snd-tracks li:hover .n{text-overflow:clip}
.snd-tracks li:hover .nn{animation:snd-marq 7s .35s linear infinite alternate}
@keyframes snd-marq{to{transform:translateX(min(0px,calc(100cqw - 100%)))}}
@media (prefers-reduced-motion:reduce){ .snd-tracks li:hover .nn{animation:none} }
.snd-tracks .i,.snd-tracks .t{font-family:var(--grotesk);font-size:7.5px;line-height:1;
  letter-spacing:.16em;color:rgba(var(--snd-ink,236,231,221),.4)}
.snd-tracks .t{text-align:right}
.snd-tracks .a{color:rgba(var(--snd-ink,236,231,221),.55);font-style:italic}

/* the tape, lifted OUT of the shelf rather than wedged into it. The slot is the
   budget and the image fits inside it — four of the seventeen cut-outs do not
   share the others' aspect, and letting each set its own height ran the stack
   past the rail on some selections and not others. */
.snd-tape{display:flex;align-items:flex-end;justify-content:center;position:relative;
  height:var(--T,150px);margin:0 0 var(--air,14px)}
/* THE IMAGE FILLS ITS BOX NOW, rather than sizing itself inside the slot. The
   hub SVG has to register with the punched openings, and it can only do that
   against a box that holds the tape at the cut's own aspect — which .snd-tape-in
   is. Changed HERE, at the rule that owns the image, rather than by adding a
   competing .snd-tape-in img: that one scored the same (0,1,1) and sat earlier in
   the sheet, so this rule won on order and the image stayed auto-sized.
   The slot's job is unchanged: --T is still the budget and the aspect box still
   fits inside it, so the four odd-sized cuts behave exactly as before. */
.snd-tape img{display:block;height:var(--T,150px);width:auto;
  filter:drop-shadow(0 16px 30px rgba(0,0,0,.66))}
/* a CONTACT shadow as well as the ambient one — drop-shadow alone floats */
.snd-tape::after{content:'';position:absolute;left:12%;right:12%;bottom:-3px;height:9px;
  border-radius:50%;background:radial-gradient(ellipse at center,rgba(0,0,0,.62),transparent 72%);
  pointer-events:none}

/* --part IS THE NEIGHBOURS MOVING ASIDE — what fingers do to find one in a pile.
   Honest with flat photographs in a way a rotation is not: translation never
   reveals that these are planes. Two cases deep, then nothing. */
.snd-sp{position:relative;display:block;width:100%;height:var(--sph,38px);appearance:none;
  background:none;border:0;padding:0;cursor:pointer;text-align:left;
  transform:translateX(var(--stag,0px)) translateY(var(--part,0px));
  transition:transform .46s cubic-bezier(.2,.7,.2,1)}
.snd-sp + .snd-sp{margin-top:calc(var(--pitch,33px) - var(--sph,38px))}
.snd-sp:hover{z-index:40}
.snd-sp.is-empty{cursor:default}

/* SPINES ARE PHOTOGRAPHS — the same object at a second view, a 1:6.29 band centred
   on the marker label, cropped from the same cut-out the tape uses so the
   silhouettes agree at the edge. It also means the marker face never ships: the
   label is in the image.
   THE RECESSION IS IN THE ASSET, not an opacity. A uniform dim is governed by the
   worst spine (6.72:1 against a median of 11.3:1) and could not go below .74
   without taking five labels under the 4.5:1 floor. The photo is multiplied by
   0.42 in the crop and the label held at 1.0 — worst measured 10.60:1. */
.snd-photo{position:absolute;inset:0;border-radius:2px;overflow:hidden;
  box-shadow:0 5px 13px rgba(0,0,0,.62);
  transition:box-shadow .5s cubic-bezier(.2,.7,.2,1)}
.snd-photo img{width:100%;height:100%;object-fit:cover;display:block}
/* HOVER IS A CROSS-FADE TO A SECOND PLATE, not a brightness(). The label is
   already at full strength in the resting plate, so a 2.4x recovery of the photo
   would clip it to white. */
.snd-photo .full{position:absolute;inset:0;opacity:0;
  transition:opacity .38s cubic-bezier(.2,.7,.2,1)}
.snd-sp:hover .snd-photo .full{opacity:1}
.snd-sp:hover .snd-photo{box-shadow:0 12px 26px rgba(0,0,0,.66)}
/* THE SHEEN TRACKS THE POINTER — a band that animates on its own is the cheap
   button trick; one that follows the cursor is an object catching light. */
.snd-sheen{position:absolute;inset:0;opacity:0;pointer-events:none;mix-blend-mode:screen;
  background:linear-gradient(104deg,transparent calc(var(--mx,50%) - 15%),
    rgba(255,252,246,.20) var(--mx,50%),transparent calc(var(--mx,50%) + 15%));
  transition:opacity .34s cubic-bezier(.2,.7,.2,1)}
.snd-sp:hover .snd-sheen{opacity:1}

/* THE EMPTY CASE IS DRAWN, because there is nothing to photograph — and it is
   CLEAR, not white: white would make the case you have just emptied the brightest
   object on the shelf. Clear plastic is not dark, it is EDGES; the fill is almost
   nothing and the rim, shoulder and hollow do the work. */
.snd-shell{position:absolute;inset:0;border-radius:2.5px;
  background:linear-gradient(180deg,rgba(236,231,221,.12),rgba(236,231,221,.022) 26%,
    rgba(236,231,221,.016) 74%,rgba(236,231,221,.075));
  box-shadow:inset 0 0 0 1px rgba(236,231,221,.30),inset 0 1px 0 rgba(255,252,246,.36),
    inset 0 -1px 0 rgba(255,252,246,.13),inset 0 7px 15px -9px rgba(0,0,0,.85),
    0 3px 9px rgba(0,0,0,.44)}
.snd-shell::after{content:'';position:absolute;left:3%;right:3%;top:2px;height:1px;
  background:linear-gradient(90deg,transparent,rgba(255,252,246,.55) 14%,
    rgba(255,252,246,.20) 46%,rgba(255,252,246,.48) 84%,transparent)}
.snd-shell::before{content:'';position:absolute;left:0;top:0;bottom:0;width:13%;
  border-radius:2.5px 0 0 2.5px;
  background:linear-gradient(90deg,rgba(255,252,246,.16),transparent)}

@media (prefers-reduced-motion: reduce){
  .snd-grid img,.snd-sp,.snd-photo,.snd-photo .full,.snd-sheen{transition:none}
}

/* ── MOBILE (operator 2026-09-21: "on mobile, perhaps the shelf with listing and
   link to the apple music playlist") — the phone gets its own composition of the
   same artifact, not a squeezed desktop.
   THE WIDTH SOLVE WAS NEVER MOBILE'S. The desktop rule up top —
   min(58em, calc(100vw - 96px - 422px)) — clears a works-index that is
   display:none below 980, and the calc goes NEGATIVE under 518px (−128px at
   390). This block takes the family's own mobile inset instead (shared 980
   block: 56 rail-side, 22 edge).
   THE PLAYER LEAVES, THE LINK STAYS, and the reasons are recorded so the call
   is not re-litigated: Apple's frame is a fixed 450px — 53% of a 390×844
   viewport before a single tape paints; its arrival is the one timing this
   surface cannot own (its own comment above); and on a phone the APPLE MUSIC
   link opens the native Music app, which is a better player than an embed,
   not a lesser one. paintApple() skips the src on this branch, so the hidden
   frame never fetches either.
   THE SPINE STACK is the desktop's browsing affordance; the grid does that job
   here. The mobile shelf is the operator's list: title + link above (the
   header row already carries them), the selected tape, the tracks below.
   THE VIEWS JOIN THE FLOW. The desktop crossfade needs both boxes absolute at
   all times; a phone needs the panel to grow with its content so _showPage's
   own overflow cap can scroll it. The crossing keeps its logic and loses its
   fade — WAAPI opacity on a display:none box paints nothing and errors
   nothing. display:none beats the block's display at (0,2,0) vs (0,1,0).
   The inline writes fitShelf/fitGrid/fitTracks make on desktop are cleared by
   their own mobile branches in soyoki-sound.js — CSS alone cannot beat an
   inline style, so the JS half of this block lives there. */
@media (max-width:980px){
  .page.page--sound{width:auto;left:56px;right:22px}
  /* THE PANEL CARRIES ITS OWN CAP, BECAUSE THE MEASURED ONE CANNOT SEE THIS
     CONTENT (operator 2026-09-22: "sound opens but doesn't work the page behind
     scrolls not the sound page").
     The comment above says the phone "needs the panel to grow with its content
     so _showPage's own overflow cap can scroll it". That cap (index.html
     l.1082) is the ONLY writer of this panel's scroll and it runs once,
     synchronously, at open — and this panel's content is not there yet:
     tracks.json is fetched after first paint (soyoki-sound.js l.964) and
     fitTracks then re-lays the list at full length with no cap, which is this
     block's own mobile design. So the measurement reads a short panel, takes
     the else branch, CLEARS maxHeight and overflowY, and when the tracks land
     the panel is taller than the screen with overflow:visible. No scroll
     container — so the touch goes to the document, which is the single page
     under mandatory snap. The panel behaved; the page behind it moved.
     STATED AS ARITHMETIC RATHER THAN RE-MEASURED. The seat is known on a phone:
     the rail's util link is display:none here, so its rect is zero and
     index.html l.1080's Math.max floor pins the top at 24; the JS reserves 12
     at the foot. 24+12 = 36. A second measurement at a better moment would be a
     better guess; a closed form cannot be taken at the wrong moment at all.
     dvh, NOT svh: 100dvh is the number window.innerHeight reports, so this rule
     and the JS branch state the same height instead of disagreeing by Safari's
     bars. (If it jitters as the bars collapse, svh is the conservative swap and
     costs a little dead space at the foot.)
     IT DOES NOT FIGHT THE JS. The else branch sets style.maxHeight='' — which
     REMOVES the inline declaration rather than winning with it — so this rule
     applies. The if branch writes innerHeight-12-24, which is this same number.
     The two paths agree by construction.
     overscroll-behavior IS THE SECOND HALF and has no JS writer at all: without
     it, reaching the end of the track list chains the scroll into the document
     and the mandatory snap pulls the next sheet out from under the panel. */
  /* -webkit-overflow-scrolling:touch REMOVED the same day it was added
     (2026-09-22, operator: "why do i need to tap on a tape spine two or three
     times to open"). It was never load-bearing — overflow-y:auto and
     overscroll-behavior do the whole job, and momentum scrolling has been the
     iOS default since 13, which makes this property both deprecated and a known
     eater of taps inside the legacy scrolling layer it creates. It is the
     newest change on this surface and therefore the suspect (A5), and removing
     it is free: it can lose nothing, and it moves exactly ONE variable, so
     whether the spines come right is a located cause rather than a fix that
     happened to work. If they still take two taps, the cause is the OTHER
     candidate and it is not here — see the hover note below. */
  .page--sound.active{max-height:calc(100dvh - 36px);overflow-y:auto;
    overscroll-behavior:contain}
  /* THE HOVER AFFORDANCE RETIRES ON TOUCH (operator 2026-09-22: "i have to
     press twice", after removing -webkit-overflow-scrolling changed nothing —
     so the cause is located by elimination, not by this fix appearing to work).
     A spine is a hover object on a surface with no hover. It carries four
     hover-driven rendering changes — z-index:40 (l.529), the .full plate
     cross-fading 0->1 over .38s (l.549), a heavier box-shadow (l.550), the
     sheen 0->1 (l.557) — and the stack overlaps (pitch 33 under height 38), so
     :hover also RE-STACKS what the finger is over. That is the documented
     iOS case: the first tap is spent delivering a hover state and the click is
     withheld because the content under the finger changed.
     NEUTRALISED, NOT DELETED — the desktop rules are untouched and still own
     the affordance above 980; these restate the RESTING values at equal
     specificity, later in source, so the hover branch resolves to the rest
     branch and there is nothing for a tap to reveal. The box-shadow is l.541's
     own value, not a new one.
     THE OTHER HALF IS IN THE JS: part() now returns on mob() (soyoki-sound.js
     l.653), because neighbours sliding 6px under the finger is the same trigger
     by a different route. Both are wrong on a touch device independently, so
     both go; the cost is that if this works I cannot say which of the two
     carried it, and that is a trade taken deliberately over a third round of
     tapping.
     KNOWN AND NOT FIXED HERE: with hover retired, a tap has no acknowledgement
     until the stack rebuilds. Moving this same treatment onto :active is one
     rule and the family's own values — not done, because it was not asked for. */
  .snd-sp:hover{z-index:auto}
  .snd-sp:hover .snd-photo .full{opacity:0}
  .snd-sp:hover .snd-photo{box-shadow:0 5px 13px rgba(0,0,0,.62)}
  .snd-sp:hover .snd-sheen{opacity:0}
  .snd-apple{display:none}
  .snd-grid,.snd-shelf{position:static}
  .page--sound[data-view="grid"] .snd-shelf{display:none}
  .page--sound[data-view="shelf"] .snd-grid{display:none}
  .snd-shelf{display:block}
  /* THE STACK IS THE VIEW (operator 2026-09-21: "i wouldn't have both the grid
     and individual selectors... it's the cassette stack and when one spine is
     selected it turns to the tape with the track listing inline. then it's
     just scroll"). The grid and the two view toggles retire on mobile; the
     seventeen spines stand full-width, and the selected chronology slot holds
     the tape and its tracks INLINE — soyoki-sound.js's mobile paint moves the
     real .snd-main into that slot, so #sndTracks stays the one node every fit
     and fill already targets. */
  .snd-views{display:none}
  .snd-sp{display:block}
  .snd-rail{border-right:0;padding-right:0;overflow:visible}
  .snd-inline{margin:10px 0 22px}
  .snd-inline .snd-main{display:block}
  .snd-tape{justify-content:flex-start;height:auto;margin:0 0 14px}
  .snd-tracks{max-width:none;margin-top:0}
}
