/* ears.pearcache.com
 *
 * Printed matter with pixel GIFs on it (29 Sep 2026). Flat inks on a warm
 * black ground, keylines instead of bevels, a halftone wash instead of
 * scanlines, pane titles as solid ink labels. It replaced the late-90s
 * bevel-and-monospace look; the layout, the reservations and every measured
 * target size are unchanged.
 *
 * Contrast: body text and dim text are both kept at or above 7:1 against their
 * actual composited background, matching the standard pearcache.com is held to.
 * Panels are --panel, not --void, so measure against --panel when changing
 * anything here — measuring against the body colour hides real failures.
 */

:root {
  --void:      #0f0d0b;
  --panel:     #181512;
  --inset:     #110f0c;
  /* Both "bevel" tokens are the one keyline colour now. They keep their names
     because ~60 rules use them; giving them the same value is what flattens
     every bevel in the file at once. */
  --bevel-hi:  #5a5044;
  --bevel-lo:  #5a5044;

  --text:      #f0e8d8;  /* paper, 14.9:1 on --panel */
  --dim:       #b3a894;  /* 7.75:1 on --panel — do not darken below this */

  /* The three inks. Pane labels and the wordmark are printed in these, with
     --void text on top (7.7:1 or better on each). */
  --ink-1:     #ff7e62;  /* vermilion  7.28 on --panel */
  --ink-2:     #ffc233;  /* sun       11.27 */
  --ink-3:     #80a1ff;  /* cobalt     7.33 */

  --accent:    var(--ink-1);
  --amber:     var(--ink-2);
  --link:      #8fb3ff;
  --hot:       #ff75b1;

  /*
    The masthead's two live-state colours and the tagline. All three are
    measured against --panel (#17131c), which is what they are actually
    drawn on -- not against --void, which would flatter every one of them
    by about half a point.

    Each was requested as a specific hex and each fell short of AAA (7:1)
    at 12px, so each shipped as the nearest colour of the SAME hue and
    saturation that clears it. The requested values are recorded here
    because "we asked for #BF00FF and got #DD77FF" is a question someone
    will have later:

      degraded  asked #BF00FF  4.06:1  fails even AA  ->  #DD77FF  7.12:1
      online    asked #03A062  5.42:1  AA only        ->  #03B971  7.14:1
      tagline   asked #FF5C00  5.92:1  AA only        ->  #FF7C32  7.14:1

    Violet moved furthest because #BF00FF has no green in it at all, and
    green is 72% of the luminance a colour can carry -- a saturated violet
    simply cannot reach 7:1 on a dark panel without being lightened.
  */
  --status-online:   #03B971;
  --status-degraded: #DD77FF;
  --tagline:         #FF7C32;

  /*
    Colour roles for side-by-side metadata (the .r-* rules at the end of
    this file, applied by roles() in ears.js). Two neighbours never share a
    role, so they never share a colour; the " · " between them stays --dim.
    Ratios measured 10 Oct 2026 on --panel #181512, the lower of the two
    backgrounds (tabs and the input sit on --inset, ~0.4 higher):

      src     --amber (ink-2)        #ffc233  11.27
      time    --time                 #35d0c0   9.47
      who     --byline               #ff75b1   7.30
      count   --count                #5cc8f0   9.50
      extent  --extent               #b491ff   7.30
      place   --place                #ff9a3c   8.61
      genre   --genre                #ff7e62   7.28
      label   --label                #ff6cda   7.28
      ok      --status-online        #03B971   7.08
      warn    --status-degraded      #DD77FF   7.06

    Tabs (on --inset), --cat-*:
      all          #ff6cda   7.66
      electronic   #5cc8f0  10.00
      indie        #3fd6a8  10.38
      hiphop       #ffc233  11.86
      heavy        #ff7e62   7.67
      oregon       #ff9a3c   9.06
      aggregators  #b491ff   7.69

    Also: --link 8.71, --text 14.93, --dim 7.75,
    --sub-dig 9.50, --placeholder-dig 7.69 (on --inset), --sub-dir 9.47.
    --void on every ink and --cat-* value is 7.7 or better.
  */
  --time:        #35d0c0;
  --byline:      #ff75b1;
  --cat-all:          #ff6cda;
  --cat-electronic:   #5cc8f0;
  --cat-indie:        #3fd6a8;
  --cat-hiphop:       #ffc233;
  --cat-heavy:        #ff7e62;
  --cat-oregon:       #ff9a3c;
  --cat-aggregators:  #b491ff;
  --sub-dig:         #5cc8f0;
  --placeholder-dig: #b491ff;
  --sub-dir:         #35d0c0;
  --count:  #5cc8f0;
  --extent: #b491ff;
  --place:  #ff9a3c;
  --genre:  #ff7e62;
  --label:  #ff6cda;

  /* Chosen 10 Oct 2026: Syne for the wordmark, Bricolage for every other
     heading, Bricolage condensed for the tabs. @font-face is above :root. */
  --font-wordmark: "Syne", "Arial Black", sans-serif;
  --font-display:  "Bricolage Grotesque", "Helvetica Neue", Arial, sans-serif;
  --font-mono: ui-monospace, "SF Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
  --font-ui:   system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
}

/* Two faces, both SIL OFL, both served from this origin (font-src 'self').
   Syne is the wordmark only. Bricolage Grotesque is every other heading, and
   the tabs use its condensed width (the wdth axis, 75%).

   Both are Latin subsets of the google/fonts TTFs (pyftsubset, 10 Oct 2026):
   Syne is a static instance at wght 800 (14 KB); Bricolage keeps its wght
   700-800 and wdth 75-100 axes with opsz pinned at 14 (67 KB). Together 81 KB
   against 555 KB for the full files. Ask for a weight or width outside those
   ranges and the browser clamps to the nearest edge rather than failing. */
@font-face { font-family: "Syne"; src: url('../fonts/syne-800.woff2') format('woff2'); font-weight: 800; font-display: swap; }
@font-face { font-family: "Bricolage Grotesque"; src: url('../fonts/bricolage.woff2') format('woff2'); font-weight: 700 800; font-stretch: 75% 100%; font-display: swap; }

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

body {
  margin: 0;
  background: var(--void);
  /* Halftone wash: one paper-coloured dot every 5px. Pure CSS, no request. */
  background-image: radial-gradient(circle, rgba(240,232,216,0.10) 0.9px, transparent 1.4px);
  background-size: 5px 5px;
  color: var(--text);
  font: 15px/1.55 var(--font-ui);
  -webkit-text-size-adjust: 100%;
}

a { color: var(--link); }
/* Hovered links go hyperlink blue. With motion allowed (OS setting AND the
   site's gifs switch) the text instead fills with the masthead strip's RGB
   sweep; the two pane-title rules further down do the same to the label. */
a:hover { color: var(--link); }
/* One slow phase through the inks every 28s. --rgb-seed (0-1, set per hover by
   ears.js) starts the cycle somewhere random and shifts the hue up to ±30°,
   so no two hovers look the same. The gradient repeats its first stop at the
   end so the loop has no seam. */
:root { --rgb-sweep: linear-gradient(90deg, var(--ink-1), var(--ink-2), var(--ink-3), var(--hot), var(--ink-1)); }
@keyframes link-rgb { to { background-position: -400% 0; } }
@media (prefers-reduced-motion: no-preference) {
  html:not([data-motion="off"]) a:not(:has(img, picture)):hover {
    color: transparent;
    background: var(--rgb-sweep) 0 0 / 400% 100%;
    -webkit-background-clip: text;
    background-clip: text;
    text-decoration-color: var(--ink-3);
    animation: link-rgb 28s linear infinite;
    animation-delay: calc(var(--rgb-seed, 0) * -28s);
    filter: hue-rotate(calc((var(--rgb-seed, 0.5) - 0.5) * 60deg));
  }
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.skip {
  position: absolute;
  left: -9999px;
}
.skip:focus {
  left: 8px; top: 8px;
  z-index: 10;
  /* 12px, not 8: the skip link is the first thing a keyboard or switch user
     lands on and it was a 23px-tall target. Nothing is below it to collide
     with, so this is plain padding rather than the negative-margin trick. */
  padding: 12px 14px;
  background: var(--panel);
  border: 1px solid var(--accent);
  font-family: var(--font-mono);
}

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ── 2.5.5 Target Size (AAA), 44×44 ───────────────────────────────────────
   Two different techniques below, and which one a control gets is decided by
   whether it draws a box.

   A control with its own visible chrome — a tab, a button, an input — is grown
   with real padding. Making it bigger is the honest fix there, and on a site
   whose whole idiom is chunky 1995 widgets it reads as more period-correct,
   not less.

   A control with NO box of its own — a nav word, a badge image pinned to its
   native pixel size — is grown with padding cancelled by an equal negative
   margin. The hit area reaches 44px while the glyphs and the layout around
   them do not move at all, which is what .masthead__archive was already doing
   for the aphex1 link before any of this. WCAG measures the region that
   accepts the pointer, not the ink, so this counts.

   Every number here was measured in the browser rather than derived, because
   line-height and border widths both feed into the final box. If you change a
   font-size in this file, re-measure the control that uses it.
   ───────────────────────────────────────────────────────────────────────── */

/* Third 2.5.5 technique, for controls where neither of the other two works: an
   absolutely-positioned ::after that overlays the link and reaches past it.

   Padding was wrong for all of these. The two banner links and the venue link
   sit in flex rows with align-items other than stretch, where a negative margin
   does not cancel the padding — it repositions the item and visibly moves the
   image. The overlay has no layout effect whatsoever, so it cannot.

   Nothing is drawn: content is empty and there is no background. It is purely a
   hit region, and it is part of the anchor, so it activates the anchor.

   Overlap is the thing to watch with this technique — two overlapping targets
   are their own 2.5.5 problem. Each use below is spaced far enough apart that
   the extensions cannot meet; where that needed help, the gap was widened
   (see .foot's row-gap and .sandbox__aphex's row-gap). */
.pane__out a,
.show__venue,
.foot__contact a,
.trivia-row__gifs a,
.sandbox__aphex a {
  position: relative;
}
.pane__out a::after,
.show__venue::after,
.foot__contact a::after,
.trivia-row__gifs a::after,
.sandbox__aphex a::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
}
/* 14px text in a 12px-mono paragraph, and 17px for the mailto: +15 either side
   clears 44 on both with room to spare. */
.pane__out a::after,
.foot__contact a::after {
  top: -15px;
  bottom: -15px;
}
/* 20px venue heading, 31px banner images: +12 and +7. */
.show__venue::after {
  top: -12px;
  bottom: -12px;
}
.trivia-row__gifs a::after,
.sandbox__aphex a::after {
  top: -7px;
  bottom: -7px;
}

/* ── masthead ─────────────────────────────────────────────────────────── */

.masthead {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 20px;
  align-items: baseline;
  justify-content: space-between;
  padding: 14px 18px;
  /* 1px, not the panes' 2px: the masthead's 88px is what .console's height
     constant tracks. */
  border-bottom: 1px solid var(--bevel-hi);
  background: var(--panel);
  font-family: var(--font-mono);
}

/* The mark is two blocks side by side: the name stacked over its tagline, and
   the icon row. It used to be a plain block of inline content -- an h1, a
   span and five images all in one flow -- which is what split the icons across
   two ragged lines on a phone: there is no way to keep five inline images
   together when the tagline ahead of them has already used most of the width.
   Two flex children wrap as units instead, so the icon row drops below the
   name intact rather than tearing in half. */
.masthead__mark {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 16px;
}

/* "// hear here" sits UNDER "EARS", not beside it, on every width. Beside it
   the 0.22em letter-spacing on the wordmark plus the tagline's own 10px margin
   left a gap that read as a mistake, and on a phone it ate the width the icon
   row needed. Stacking closes the gap and frees ~85px. */
.masthead__name {
  /* Two rows: the wordmark with the tagline nestled beside it on its
     baseline, then the icon row across both columns (owner, 11 Oct). The
     first row is the wordmark's 30px line; the baseline-aligned 13px tagline
     fits inside it, so the column is still 30 + 28 = 58. */
  display: grid;
  grid-template-columns: max-content 1fr;
  align-items: baseline;
  column-gap: 10px;
  justify-items: start;
}
.masthead__name > .masthead__icons { grid-column: 1 / -1; }

/* ONE gap for the whole icon row, which is the fix for the uneven spacing.
   The three slot types each used to carry their own padding cancelled by their
   own negative margin -- 8px of visible gap after music4, 8px after earth,
   16px between the archives -- so the row read as unevenly spaced because it
   WAS. Now every slot gets identical 10px side padding (see .masthead__icon)
   and the flex gap is 0, which makes every visible gap the same 20px without
   any per-icon arithmetic to keep in sync.

   nowrap is deliberate: if the row will not fit, .masthead__mark wraps it
   below the name as one piece, which is better than five icons splitting
   across two lines. */
.masthead__icons {
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  gap: 0;
}

/* The wordmark is the page's <h1> — see the note in index.html. display:inline
   keeps it in the masthead's baseline row exactly as the <span> it replaced,
   and font/margin resets stop the UA's 2em-and-a-block-margin default from
   redrawing the header. Purely a semantic change; nothing here moves. */
.masthead__h1 {
  /* Block, as is the link and the span inside it: the wordmark is the top of
     a column now (icons under it), and an inline h1 put the 30px span on a
     line with the body's 15px strut, which made a 40px line box and took the
     masthead to 100px. Three blocks make the wordmark exactly its 30px line. */
  display: block;
  margin: 0;
  font: inherit;
}
.masthead__home, .masthead__title { display: block; }

/* The wordmark, set in the display face. 30px line, the same box the 26px
   mono wordmark made, so the masthead stays 88px. */
.masthead__title {
  font-family: var(--font-wordmark);
  font-size: 28px;
  font-weight: 800;
  line-height: 30px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-1);
  /* Puts the E's ink on the first sticker's left edge. The plate sits 4px
     inside its slot ((44 - 36) / 2) and Syne's E has a 0.07em side bearing,
     so the gap is 4px less the bearing. In em because the size drops to 22px
     on a phone. Measured 11 Oct: ink 19.96 vs plate 22 at 28px before. */
  padding-left: calc(4px - 0.07em);
}
/* Syne is wide: 133px at 28px. At 22px with no tracking it is ~100px, which
   is what lets the five 44px icon slots stay on the wordmark's line at 375px
   (100 + 16 + 220 = 336 of the 339 inside the masthead's padding). Measured:
   22px WITH the tracking is 104, and 104 + 16 + 220 = 340 wraps the row. */
@media (max-width: 420px) {
  .masthead__title { font-size: 22px; letter-spacing: 0; }
}

/* The wordmark is a link to the front page. This <a> is a bare wrapper: the
   26px type and --accent colour stay on .masthead__title above, so the only
   thing the link state changes is an underline, which appears on hover and on
   keyboard focus rather than sitting under the masthead permanently. A logo
   link is not "a link within a block of text", so 1.4.1 does not ask for the
   underline to be there at rest.

   The font-size and padding are the 2.5.5 (AAA) target size, not decoration,
   and they have to be read together. Padding on a non-replaced INLINE element
   does not contribute to the line box, so it grows the hit box without moving
   the masthead by a pixel — but the inline box it pads is sized by the <a>'s
   OWN font, and left to inherit that is the 15px body size, which padded out
   to 35px rather than the 44 it looked like. Restating the wordmark's 26px
   here matches the box to the glyphs it wraps (30px), and 7px top and bottom
   takes it to exactly 44. Measured in the browser, not eyeballed: the two
   numbers are load-bearing, so if .masthead__title's size changes this one
   has to change with it. */
.masthead__home {
  font-size: 26px;
  /* 7px each way makes the 30px line a 44px target; the equal negative
     margin stops that padding adding height now the link is a block. */
  padding: 7px 0;
  margin: -7px 0;
  text-decoration: none;
}
.masthead__home:hover .masthead__title,
.masthead__home:focus-visible .masthead__title {
  color: var(--ink-2);
}

/* The note phases through the inks on its own slow cycle. Gated on the gifs
   switch being ON (not merely "not off"): 404.html has no script to set it,
   so there it stays a still colour. */
@media (prefers-reduced-motion: no-preference) {
  html[data-motion="on"] .masthead__note {
    color: transparent;
    background: var(--rgb-sweep) 0 0 / 400% 100%;
    -webkit-background-clip: text;
    background-clip: text;
    animation: link-rgb 28s linear infinite;
  }
}
.masthead__sub {
  white-space: nowrap; /* three words beside the name column; never a stack of three */
  color: var(--tagline);
  font-family: var(--font-display);
  font-stretch: 75%;
  font-size: 13px;
  line-height: 18.6px; /* the 12px mono line it replaced; keeps the masthead at 88px */
  letter-spacing: 0.04em;
}

/* One slot shape for all five icons, replacing three of them.

   Every icon in this row is a fixed box -- width AND height in CSS as well as
   in the HTML attributes -- so a slow-loading GIF cannot shove the row around
   as it arrives.

   The 10px side padding is the same on every slot, which is what makes the
   visible gaps identical (0 flex gap + 10 + 10 = 20px, everywhere). Before
   this, music4 carried `margin-left: 8px`, the earth link carried `4px margin
   + 4px padding`, and the three archive links carried `6px margin + 10px
   padding`, each cancelling its own padding with its own negative margin. The
   result was 8px, 8px, then 16px, 16px, 16px across the row -- uneven, and
   uneven in a way that took three separate rules to keep in sync.

   10 and not 8 is load-bearing: aphex1's image is 25px wide, so 10px either
   side lands its target at exactly 45px and keeps the 2.5.5 AAA width the
   comment here used to spend a paragraph defending. 8px would give 41 and
   quietly lose it.

   Vertical is the hit target and costs nothing: 12px either side takes the
   20px icons to 44px, and `margin-block: -12px` hands that height back to the
   layout so the masthead row stays exactly as tall as its tallest IMAGE
   (aphex1 at 28px) rather than as tall as the padded boxes. The two numbers
   move together -- change one and change the other.

   `display: block` on each image kills the inline descender space that would
   otherwise make the boxes ~3px taller than the negative margin accounts for
   and push the row down. */
.masthead__icon {
  display: flex;
  align-items: center;
  padding: 12px 10px;
  margin-block: -12px;
}
.masthead__icon img {
  display: block;
}
/* One slot width and one 36x32 plate for all five, so icons of five different
   shapes read as a set. The plate is absolutely placed: it draws behind the
   icon and takes no space, so the masthead's 88px does not move.

   The plate is a sticker: paper, a 1px keyline and a 2px offset, both in the
   ink of the pane the icon links to (the two outbound links take inks 1 and
   2). Was a --void plate with a grey keyline, which read as five dark squares. */
.masthead__icon                   { --icon-ink: var(--ink-1); }
.masthead__icon--time             { --icon-ink: var(--ink-2); }
.masthead__archive                { --icon-ink: #5cc8f0; }
.masthead__archive--jdilla        { --icon-ink: #ff9a3c; }
.masthead__archive--datpiff       { --icon-ink: #b491ff; }
.masthead__icon {
  position: relative;
  justify-content: center;
  /* 44: the 2.5.5 width, and five of them plus the wordmark still fit one
     line at 375px (220 + 16 + 100 = 336 of 339). */
  width: 44px;
  padding-inline: 0;
}
.masthead__icon::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 36px;
  height: 32px;
  translate: -50% -50%;
  background: var(--text);
  border: 1px solid var(--icon-ink);
  border-radius: 3px;
  box-shadow: 2px 2px 0 var(--icon-ink);
}

.masthead__icon > * { position: relative; }

/* Each icon keeps its own source aspect ratio rather than being forced to a
   shared width and distorted. The three flourishes sit at 20px tall; the three
   archive links use their own pane-title heights. Sources: music4 192x144,
   earth 80x80, jazz 60x50, aphex1 179x197 (the one portrait source),
   hiphop 160x120, donut2 22x22. */
.masthead__gif--music4 { width: 27px; height: 20px; }
.masthead__gif--earth  { width: 20px; height: 20px; }
.masthead__gif--jazz   { width: 24px; height: 20px; }

.masthead__archive img          { width: 25px; height: 28px; }
.masthead__archive--datpiff img { width: 24px; height: 18px; }
.masthead__archive--jdilla img  { width: 18px; height: 18px; }

/* Measured targets, all clear of 2.5.8 AA (24x24): aphex1 45x52, music4 47x44
   (decorative, no target needed), earth 40x44, hiphop 44x42, donut2 38x42.
   Only aphex1 and hiphop reach 2.5.5 AAA on width, which is the same
   icon-ratio-wins-over-one-shared-box trade-off this row has always made --
   closing it for the rest means widening the row itself, and that is a layout
   decision rather than a padding one. */

/* The "listening" strip in the gap between the icon row and the nav.

   One empty element. Its background is a hue sweep through the site's own
   palette; two masks intersect over it: fixed 2px bars, and a symmetric
   waveform (assets/img/eq-wave.svg) that scrolls sideways under them. The
   bars stay put while the wave moves, so each bar rises and falls: an
   equaliser with no bar-by-bar markup and no script. The hue drifts on a
   slower cycle and the whole thing breathes between 0.55 and 0.8 opacity,
   so it phases rather than flashes (nothing here comes near 2.3.1's three
   flashes a second).

   A third mask, a static horizontal gradient, fades each end to nothing
   over 22% of the width, so the strip seems to come out of the icon row and
   run into the nav rather than stopping at two hard edges (23 Sep 2026). It
   is the last layer, so the keyframes carry a third position for it that
   never moves.

   `flex: 1 1 0` with `min-width: 0` means it only ever takes spare space and
   never makes the masthead wrap. The masthead's 88px height is what
   .console's calc(100vh - 102px) tracks, and 30px centred inside it moves
   nothing (measured). Hidden below 700px, where the icon row and the nav
   already fill the width.

   Motion: animates only under prefers-reduced-motion: no-preference AND when
   the site's "gifs" switch is not off (html[data-motion="off"], written by
   applyMotion() in ears.js). Frozen, it is a still waveform in the same
   colours. That switch is what satisfies 2.2.2 (Pause, Stop, Hide) for it. */
.masthead__eq {
  /* 520 when there is room, shrinking first when there is not. The two side
     blocks take equal shares of the rest (flex-basis 0, in the ≥701px rule
     below), so the strip is centred on the page rather than on the gap
     between two unequal blocks, until a side block hits its min-content. */
  flex: 0 1 520px;
  min-width: 0;
  max-width: 520px;
  height: 30px;
  align-self: center;
  margin-inline: auto;
  background: linear-gradient(90deg,
    var(--ink-1), var(--ink-2), var(--ink-3), var(--ink-1));
  background-size: 200% 100%;
  opacity: 0.7;
  -webkit-mask-image: url('../img/eq-wave.svg'), repeating-linear-gradient(90deg, #000 0 2px, transparent 2px 5px),
                      linear-gradient(90deg, transparent, #000 22%, #000 78%, transparent);
          mask-image: url('../img/eq-wave.svg'), repeating-linear-gradient(90deg, #000 0 2px, transparent 2px 5px),
                      linear-gradient(90deg, transparent, #000 22%, #000 78%, transparent);
  -webkit-mask-size: 480px 100%, auto, 100% 100%;
          mask-size: 480px 100%, auto, 100% 100%;
  -webkit-mask-repeat: repeat-x, repeat, no-repeat;
          mask-repeat: repeat-x, repeat, no-repeat;
  -webkit-mask-composite: source-in, source-in;
          mask-composite: intersect, intersect;
}
@keyframes eq-wave {
  to { -webkit-mask-position: -480px 0, 0 0, 0 0; mask-position: -480px 0, 0 0, 0 0; }
}
@keyframes eq-hue {
  to { background-position: 200% 0; }
}
@keyframes eq-breathe {
  from { opacity: 0.55; }
  to   { opacity: 0.8; }
}
@media (prefers-reduced-motion: no-preference) {
  html:not([data-motion="off"]) .masthead__eq {
    animation:
      eq-wave 14s linear infinite,
      eq-hue 24s linear infinite,
      eq-breathe 5s ease-in-out infinite alternate;
  }
  /* While KMHD is playing (html[data-playing], set in ears.js) the strip runs
     three times as fast and stops breathing: it is a level meter now. */
  html:not([data-motion="off"])[data-playing] .masthead__eq {
    animation: eq-wave 4.5s linear infinite, eq-hue 24s linear infinite;
    /* --eq-level (0-1) is written per frame by the radio's analyser; with no
       meter (no CORS, no Web Audio) it is unset and these resolve to a steady
       middle. transform only, so the masthead never changes height. */
    opacity: calc(0.6 + var(--eq-level, 0.6) * 0.4);
    transform: scaleY(calc(0.35 + var(--eq-level, 0.5) * 1.5));
    /* No transition: the analyser already smooths, and a transition on top of
       a per-frame value is what tore. will-change keeps the strip on its own
       layer so the scale never re-rasterises the mask. */
    will-change: transform, opacity;
  }
}
@media (max-width: 700px) {
  .masthead__eq { display: none; }
}

@media (min-width: 701px) {
  .masthead__mark, .masthead__nav { flex: 1 1 0; }
  /* No wrapping while the strip is shown: flex-wrap decides on the strip's
     520px basis before it is allowed to shrink, and at 800px that put the
     nav on a second row. With nowrap the strip shrinks instead, and the
     side blocks only ever hit their min-content (~321 + ~200) at 701. */
  .masthead { flex-wrap: nowrap; align-items: flex-start; }
  .masthead__mark { flex-wrap: nowrap; } /* one child now; kept so a second never wraps */
}
.masthead__nav {
  /* A right-aligned stack in the corner: the pearcache mark, the status word,
     then the directory link (owner, 11 Oct). 20 + 18 + 18 = 56px, inside the
     masthead's ~60px content box, so the masthead stays 88px. Pinned to the
     top of the band rather than baseline-aligned: a column has no useful
     baseline to share with the wordmark. */
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  align-self: flex-start;
  gap: 0;
  font-size: 13px;
}
/* The pearcache mark: 20px, the top of the stack. */
.masthead__nav-mark { align-self: flex-end; }
.masthead__nav-mark img { display: block; width: 20px; height: 20px; }
/* The directory link is set like the status beside it (12px mono, --dim, no
   underline until hovered) rather than as a display-face headline: as a 15px
   bold caps link it was the loudest thing in a corner whose other two items
   are a 12px status word and a 20px mark (owner, 11 Oct). The 20px line is
   kept so the masthead stays 88px. */
.masthead__nav a {
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 400;
  line-height: 12px;
  color: var(--dim);
  text-decoration: none;
}
/* The stack's spacing is these two margins, not line boxes: 12px lines set
   tight, 9px under the mark (which has no descender to lend) and 6px under
   the status, so the three read evenly spaced. 20 + 9 + 12 + 6 + 12 = 59. */
.masthead__nav .masthead__status { margin-top: 9px; }
.masthead__nav .masthead__nav-dir { margin-top: -6px; } /* its 12px top padding (the 44px-target trick below), less the 6 */
/* Dusty light blue: 9.35:1 on --void, 8.8:1 on --panel. Two selectors, to
   beat the `.masthead__nav a` colour above. */
.masthead__nav .masthead__nav-dir { color: #9fb7cc; }
.masthead__nav a:hover,
.masthead__nav a:focus-visible { text-decoration: underline; }

/* "pearcache" and "tools" were 70×20 and 39×20. As flex items they are
   blockified, so padding here WOULD move the masthead — the equal negative
   margin is what stops it. Inline padding is held to 3px a side: the flex gap
   is 16px, so the two grown boxes stay 10px apart and cannot overlap into one
   ambiguous target. Vertical padding has nothing to collide with; the masthead
   row is taller than the text either way. */
.masthead__nav a {
  padding: 12px 3px;
  margin: -12px -3px;
}

/* The motion switch, styled as one more line of footer text rather than a
   widget: a chrome-bearing button would read as the most important thing in
   a row of build stamps and a mailto, which it is not.

   It lived in .masthead__nav until 29 Aug 2026, then sat on its own
   flex-basis: 100% row under "feed built …" until 6 Sep 2026, then was a
   flex item placed by .foot's space-between. Since 23 Sep 2026 .foot is a
   grid and this button has its own named area, `motion`.

   Same padding/negative-margin trick as .masthead__nav a, and for the same
   reason: 44px of target without moving the row by a pixel. */
.foot__motion {
  flex: 0 0 auto;
  padding: 12px 3px;
  margin: -12px -3px;
  border: none;
  background: none;
  color: var(--link);
  font-family: inherit;
  font-size: inherit;
  line-height: inherit;
  text-align: left;
  cursor: pointer;
}
.foot__motion:hover { color: var(--accent); }

/* The state word carries the meaning, so it gets the emphasis the label does
   not. Amber is "what the site is doing right now" rather than somewhere to
   go -- the same role it plays for a status word, which is why it stays
   amber here even though the status words beside the masthead now colour
   themselves by state. 12.15:1 on --void, the footer's own background. */
.foot__motion-state {
  color: var(--amber);
}

/*
  The status word. Amber is the resting colour and still covers every state
  that is neither healthy nor degraded -- "booting…", "feed building…",
  "signed out", "offline", "display error" -- so a state nobody has coloured
  yet stays legible rather than inheriting something arbitrary.

  online and degraded get their own colours because they are the two words
  the site sits on for hours at a time, and they were previously the same
  amber as each other: the masthead said the same thing whether every source
  was healthy or half of them were down.
*/
.masthead__status {
  color: var(--amber);
  font-size: 12px;
  line-height: 12px;
}

.masthead__status[data-state='online'] { color: var(--status-online); }
.masthead__status[data-state='degraded'] { color: var(--status-degraded); }

/* ── layout ───────────────────────────────────────────────────────────── */

/* The page is a plain vertical stack. Only the first band is columnar, so a
   grid over the whole page bought nothing but a template that had to be
   rewritten every time a pane moved. */
.page {
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding: 14px;
}

/*
  The console: feed on the left, rail of narrow panes on the right.

  THIS IS THE FIX FOR THE SCREEN-TALL HOLE IN THE MIDDLE OF THE PAGE. The
  previous layout let Pane A span three grid rows while its list was capped at
  68vh. The rail beside it kept growing, so the left column always ran out
  first and left dead space under it, and the rail's own height pushed Bandcamp
  most of a screen below the fold.

  Locking the band to one viewport and stretching both columns into it means
  neither column can be shorter than the other, so there is nothing left to
  leave a hole. Whichever column has more content scrolls internally instead.

  `min-height: 0` on the children is load-bearing: flex and grid items default
  to `min-height: auto`, which refuses to shrink below content size and would
  let the list push the band taller than the cap regardless of overflow. */
.console {
  display: grid;
  grid-template-columns: minmax(0, 1.9fr) minmax(0, 1fr);
  /*
    `minmax(0, 1fr)` and `height`, NOT `max-height` and an implicit row.

    A grid row defaults to `auto`, which is max-content, and `max-height` on the
    container clips the container's own box without ever shrinking that row —
    the panes simply overflow it. With 500 items in the list that produced a
    59,000px document that looked correct only in the first screenful. The `0`
    floor in minmax is what permits the row to be smaller than its content;
    without it `1fr` still refuses to go below min-content.
  */
  grid-template-rows: minmax(0, 1fr);
  gap: 14px;
  align-items: stretch;
  /* The masthead (88px) plus the page's own 14px padding, so the band ends at
     the fold rather than a scrollbar's distance past it.

     This number tracks .masthead and nothing else, so it has to move when the
     masthead does — and it did not, twice. It read 80 while the header was 69,
     which was 3px optimistic and invisible; stacking "// hear here" under the
     wordmark then took the header to 88 and left the band hanging 22px past
     the fold, which is a scrollbar on a layout whose entire point is that it
     ends exactly at the fold. Measured, not estimated: if you change the
     masthead's type sizes or its stacking, measure it again. */
  height: calc(100vh - 102px);
  min-height: 420px;
}

/* Same reason as the `0` above: flex and grid items default to
   `min-height: auto` and will not shrink below their content, which defeats
   every `overflow-y: auto` further down. */
.console > * { min-height: 0; }

.rail {
  display: flex;
  flex-direction: column;
  gap: 14px;
  min-height: 0;
}

/* The last pane in the rail absorbs whatever height is left over. Without
   this the rail stretches to the band height but its panes do not, and the
   hole simply moves from under Pane A to under Pane C. */
.rail > .pane:last-child {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.rail > .pane:last-child > .pane__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

/* Below the console (rearranged 22-23 Sep 2026, re-cut 11 Oct 2026):

       minis | E   E      <- E stays directly under Pane B
       C     C     C
       F     F     F

   Markup order is minis (I, G, H), E, C, F, which is also this reading order,
   so Tab and screen-reader order match the screen. A phone dissolves the
   grid (display: contents) and orders the panes itself. */
.split {
  display: grid;
  /* 1:2, not 1:1: the minis are one line of facts each and lose nothing at
     ~430px, and Bandcamp's cards go three across at ~880. Sandbox and Pitch
     each take a full row. Pitch beside Sandbox (23 Sep) was one button in an
     1,100px box; as a strip it is ~90px closed and a full-width table open. */
  grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
  grid-template-areas:
    "minis   bandcamp"
    "sandbox sandbox"
    "pitch   pitch";
  gap: 14px;
}
.minis          { grid-area: minis; }
.pane--bandcamp { grid-area: bandcamp; }
.pane--sandbox  { grid-area: sandbox; }
.pane--pitch    { grid-area: pitch; }

.minis {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

/* A stretched pane is a column whose body takes the slack, and the body's
   own last line (the "→" out-link) is pushed to the bottom of it. Scoped to
   the split: the console band has its own flex rules, and a phone dissolves
   the split, where none of this applies. */
@media (min-width: 901px) {
  .minis > .pane { flex: 1 1 auto; }
  .split .pane { display: flex; flex-direction: column; }
  .split .pane > .pane__body {
    flex: 1 1 auto;
    display: flex;
    flex-direction: column;
  }
  .split .pane > .pane__body > .pane__out:last-child { margin-top: auto; }
}

/* The pane titles here are as long as the full-width panes' ("G :: USER18081971")
   in a third of the width, so the chrome is allowed to wrap onto a second line
   rather than pushing the meta count out of the box. */
.pane--mini .pane__chrome {
  flex-wrap: wrap;
  gap: 4px 10px;
}
.pane--mini .pane__body { padding: 10px; }
.pane--mini .spot { margin-bottom: 0; }
.pane--mini .pane__out { margin-top: 9px; }

/* The card's two text lines are clamped, and the detail one is not optional.
   Pane G's `notes` field is free text off a spreadsheet and runs to whole
   paragraphs — one track's note is its entire equipment list, fifteen lines of
   synths, 2,380 characters — which in an unclamped card is a 360px pane
   sitting beside two 130px ones. The clamp belongs here rather than in a
   substring in ears.js: the full pane on /user18081971 wants that text whole,
   and truncating at the data would take it from both. */
.pane--mini .spot__title,
.pane--mini .spot__excerpt {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  overflow: hidden;
  -webkit-line-clamp: 2;
  line-clamp: 2;
}

/* The hard details: year, running time, album, where it plays from. Mono and
   dim so it reads as metadata rather than as more of the title, and one line
   only — it is a list of short facts, and a facts line that wraps is a facts
   line with too much in it. */
.spot__facts {
  margin: 5px 0 0;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.5;
  color: var(--amber);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.spot__controls {
  display: flex;
  gap: 7px;
  align-items: center;
  flex: 0 0 auto;
}

/* 24px square: 2.5.8 AA, the same target size already accepted for the sandbox
   pane's fold toggle and the masthead icons. Not 44px (2.5.5 AAA) — that would
   put a third of a row of height back into a pane whose whole reason for
   existing is that the space under Bandcamp was empty. */
.spot__play {
  min-width: 24px;
  min-height: 24px;
  padding: 0 5px;
  background: none;
  border: 1px solid var(--bevel-lo);
  color: var(--accent);
  font-size: 11px;
  line-height: 1;
  cursor: pointer;
}
.spot__play:hover { border-color: var(--accent); }
.spot__play[aria-pressed="true"] { border-color: var(--accent); color: var(--amber); }

/* The player mount. Both sources land here — SoundCloud's 20px iframe strip
   and the Internet Archive's native <audio> — so it sizes to whichever it is
   handed rather than reserving a box for the taller one. */
.mini__player {
  margin-top: 8px;
}
.mini__player .archive__audio { width: 100%; height: 32px; display: block; }
.mini__player .archive__iframe { display: block; border: 0; }

/* On index every pane title links to its own page (the generator unwraps the
   link again on that page, where it would point at itself). The colour is
   already .pane__title's, so the only job here is to keep the underline off a
   line that reads as a heading and to make the whole run — icon, letter and
   name — one target. */
.pane__title-link {
  color: inherit;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
}
/* The label, not the text, takes the hover: hyperlink blue, or the RGB strip
   when motion is allowed. The text stays --void on both (7.7:1 or better on
   every stop; on --link 9.9), which is why the strip is on the label rather
   than clipped to the letters as on ordinary links. */
.pane__title:has(> .pane__title-link:hover) { background: var(--link); }
@media (prefers-reduced-motion: no-preference) {
  html:not([data-motion="off"]) .pane__title:has(> .pane__title-link:hover) {
    background: var(--rgb-sweep) 0 0 / 400% 100%;
    animation: link-rgb 28s linear infinite;
    animation-delay: calc(var(--rgb-seed, 0) * -28s);
  }
  html:not([data-motion="off"]) .pane__title-link:hover {
    color: var(--void);
    background: none;
    animation: none;
    filter: none;
  }
}

/* Pane A is a column so its list can take the slack between the chrome, the
   tabs and the source-status drawer. */
.pane--feed {
  display: flex;
  flex-direction: column;
}
.pane--feed > .pane__body {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

@media (max-width: 900px) {
  /*
    One column, natural height — locking the band to the viewport on a phone
    would stack three independent scroll regions on one screen.

    `display: contents` dissolves every wrapper so each pane becomes a direct
    flex child of .page, which is what lets `order` below put them in the
    phone order without touching the markup (arranged for the desktop grid).
    A pane added later with no entry here gets order 0 and lands ABOVE all
    of these — give it a number. */
  .console,
  .rail,
  .split,
  .minis {
    display: contents;
  }

  /* The owner's phone order, 22 Sep 2026: the three archive minis (I, G, H
     in the markup already), then radio and listings, the feed, Bandcamp,
     the sandbox, and pitch last. */
  .pane--mini     { order: 1; }
  .pane--live     { order: 2; }
  .pane--feed     { order: 3; }
  .pane--bandcamp { order: 4; }
  .pane--sandbox  { order: 5; }
  .pane--pitch    { order: 6; }

  .rail > .pane:last-child,
  .rail > .pane:last-child > .pane__body,
  .pane--feed > .pane__body {
    flex: 0 0 auto;
    overflow-y: visible;
    min-height: 0;
  }

  /* ── CLS: reserve the height of everything /api/feed fills ──────────────

     Measured 19 Sep 2026 at 412x823, Slow 4G, 4x CPU: CLS was **0.44-0.52**
     on a phone and **0.00** on desktop. The asymmetry is the whole
     explanation, and it is caused by this media query: on desktop `.console`
     is a fixed `calc(100vh - 102px)` band, so its panes cannot grow and there
     is nothing to shift. Here `display: contents` puts every pane in normal
     flow at its natural height, and five boxes go from 0 to their full size
     in ONE synchronous batch when the feed resolves:

         #calendar  0 -> 550    #shows  0 -> 370
         .tabs      0 -> 186    .stream 0 -> 576      (~1,700px total)

     `.pane--live` is `order: -1`, so the biggest grower renders ABOVE the
     feed and pushes everything visible down the page.

     These reservations are measured, not guessed, and the failure mode of a
     wrong one is visible rather than silent: too small leaves a residual
     shift, too large leaves whitespace. Re-measure by loading the page with
     the feed blocked and comparing each box against its filled height.

     Do NOT "simplify" these away because desktop looks fine — desktop is
     fine for a structural reason that does not apply below 900px. */

  /* The list keeps an internal scroll on a phone — 460 items in the page flow
     would make the panes below it unreachable.

     `height`, not `max-height`: with `max-height` the box is 0 until the feed
     lands and then jumps to 70vh. It is the one reservation here that needs
     no measured number, because the feed always overflows it. */
  .stream { height: 70vh; }

  /* Both are `ul.shows`, filled by renderCalendar/renderShows. Unlike .stream
     above these are content-dependent — six calendar entries and three shows,
     whose rows wrap to different heights — so these are the measured numbers
     and the ones most likely to need re-measuring. A quiet week renders
     shorter than the reservation and shows whitespace; that is the deliberate
     direction to be wrong in.

     The desktop pair lives in its own min-width block below, NOT as a base
     rule these override. Both selectors are bare ids, so specificity is a tie
     and source order would decide — and this block sits ~100 lines ABOVE the
     base rules, so a base `#shows` would silently win here. That is the exact
     cascade trap the old `.pane--feed .tabs` comment described. Two explicit
     ranges cannot tie. */
  #calendar { min-height: 550px; }

  /* #shows had a 370px reservation here until 22 Sep 2026, and it was the
     blank space under pc-pdx's listings the owner reported: two shows fill
     192px, so 178px of every quiet night was empty box. It was removed, not
     re-measured, because the reorder above moved it below the fold — #shows
     now starts ~1,700px down a 412x823 phone, under the minis and all of
     Pane B's calendar, so no shift it could cause is visible on load.

     The three minis are what sits at the top now, and they DO need a
     reservation: each is an 83px chrome-and-link until its JSON lands and
     unhides the spot, and three of them growing at once pushed everything
     below down 339px — CLS 0.257 measured, from 0 before the reorder.
     196px is the filled height on 88 of 100 sampled loads at widths 360-900
     (range 178-213: a one-line title is shorter, a two-line detail taller).
     The residual is a 17px shift on the rare tall pick. */
  .pane--mini { min-height: 196px; }
}

/*
  The same reservation problem, above 900px — and the measurement that settles
  it, 22 Sep 2026, taken live at desktop widths.

  The 19 Sep note in this file claimed desktop needed none of this "for a
  structural reason": `.console` is a fixed `calc(100vh - 102px)` band, so a
  pane inside it cannot grow the page. That is TRUE and it is not the whole
  story — they still shift EACH OTHER inside the band, above the fold, and
  nobody had measured it. Empty -> filled at 1350x940:

      .tabs      1 -> 92    pushing .stream (652px, on screen) down 91px
      #calendar  0 -> 474   pushing #shows and its heading down 482px

  Two shifts, both above the fold, which is where the audit's 0.309 came from.
  (.split below the band grows 1,012px with nothing reserved, but it starts at
  y=954 against a 940px viewport, so it scores only for someone who scrolls.
  Left alone deliberately: reserving 1,012px of empty boxes to protect a shift
  nobody sees on load is a worse page.)

  #shows measures a flat 192px at every width from 901 to 1920 — it is three
  rows that do not wrap — so that number is exact rather than generous.

  #calendar is not: it measured 592 / 513 / 474 / 456 at 901 / 1000-1100 /
  1280-1600 / 1920, because the event titles wrap differently as the rail
  narrows. 550 is deliberately on the large side of that range, per the rule
  above — too small shifts, too large is only whitespace, and this rail already
  scrolls. It is the same 550 the phone uses, which is not a coincidence worth
  reading into: both were measured, and they landed in the same place.
*/
@media (min-width: 901px) {
  #calendar { min-height: 550px; }
}
/* #shows' 192px reservation was removed 22 Sep 2026 for the same reason as
   the phone one: it is the last list in the rail and starts at y~1,050, past
   the rail's bottom edge at 1350x940, so it can only shift the one link
   under it, and a quiet night showed its unfilled rows as whitespace. */

/* ── pane furniture ───────────────────────────────────────────────────── */

.pane {
  /* Which ink this pane's label and offset edge are printed in. */
  --pane-ink: var(--ink-1);
  background: var(--panel);
  /* The frame is the pane's own ink, and the offset edge is paper: one ink
     printed slightly out of register over a paper plate. The grey keyline
     this replaced was most of why the panes still read as dark panels. The
     offset is a shadow, so it takes no layout space; the 14px gap has room. */
  border: 2px solid var(--pane-ink);
  box-shadow: 3px 3px 0 var(--text);
  min-width: 0; /* lets long feed titles wrap instead of blowing out the grid */
}

/* One ink per pane, nine panes, nine inks. The three minis carry the same
   modifier as their own full page, so a pane is the same colour everywhere it
   appears. --void on every one of these is 7.7:1 or better. */
.pane--feed     { --pane-ink: var(--ink-1); }  /* vermilion */
.pane--live     { --pane-ink: var(--ink-2); }  /* sun       */
.pane--bandcamp { --pane-ink: var(--ink-3); }  /* cobalt    */
.pane--sandbox  { --pane-ink: #3fd6a8; }       /* mint  10.5 */
.pane--pitch    { --pane-ink: #ff6cda; }       /* magenta 7.8 */
.pane--jdilla   { --pane-ink: #ff9a3c; }       /* orange 9.2 */
.pane--archive  { --pane-ink: #5cc8f0; }       /* sky   10.1 */
.pane--datpiff  { --pane-ink: #b491ff; }       /* violet 7.8 */
.pane--dir      { --pane-ink: #35d0c0; }       /* teal  10.1 */

.pane__chrome {
  display: flex;
  gap: 12px;
  align-items: center;
  justify-content: space-between;
  /* No left or block padding: the title label below is flush with the pane's
     top-left corner and carries the chrome's height itself. */
  padding: 0 11px 0 0;
  /* Plain. With an ink keyline and an ink label, a textured bar here was a
     third surface stacked on one header. */
  background: var(--inset);
  border-bottom: 2px solid var(--pane-ink);
  font-family: var(--font-mono);
}

/* The title takes the width it needs and the meta gives way, rather than both
   shrinking until each wraps.

   `.pane__chrome` is `space-between` with two flex items, and flex items
   default to `flex-shrink: 1`. So when the pair did not fit, BOTH shrank and
   BOTH wrapped to two lines. Measured at 412px: only pane E ever trips it, and
   it trips it by ONE pixel — title 183 + gap 12 + meta 166 = 361 into 360 —
   for which it was paying two wrapped lines and a 52px chrome against every
   other pane's 34px.

   Nothing here decides that pane E is special, because it is not: pane A has
   46px of slack and pane C 71px, and either could cross the line on a longer
   meta string or a narrower phone. This says which item wins when that
   happens, for all of them. */
.pane__title {
  /* A folder tab: the label rides 6px up, over the pane's 2px keyline and
     4px past it, and 2px left so it is flush with the outer edge. Negative
     margins rather than relative offsets, so the chrome shrinks around it
     instead of leaving a hole where the label was. Nothing clips it: no
     .pane rule sets overflow, and the page's 14px gaps absorb the 4px. */
  margin: -6px 0 0 -2px;
  align-self: flex-start;
  /* A solid ink label with --void type on it, like the date band on a gig
     poster. Flex, so the plate and the text centre on one line and the
     label is exactly 22 + 5 + 5 tall. */
  display: flex;
  align-items: center;
  padding: 5px 12px 5px 5px;
  background: var(--pane-ink);
  color: var(--void);
  font-family: var(--font-display);
  font-size: 15px;
  font-weight: 800;
  line-height: 22px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  /* Take the space needed, never surrender it, never wrap. The pane letter is
     the pane's name — breaking "BANDCAMP DAILY" across two lines reads as a
     layout fault. */
  flex: 0 0 auto;
  white-space: nowrap;
}

.pane__meta {
  font-size: 11px;
  color: var(--dim);
  /* The one that gives way. It still wraps when a title genuinely leaves it too
     little, and `text-align: right` keeps those wrapped lines against the right
     edge where `space-between` already puts the box. */
  flex: 0 1 auto;
  text-align: right;
}

/* 4px back, which is all pane E needs: 183 + 8 + 166 = 357 into 360, so title
   and meta each hold one line and the chrome stays 34px like every other pane.
   Desktop keeps 12px — it has hundreds of pixels of slack and does not need it.

   This block sits HERE, immediately after the rule it overrides, and not in the
   `max-width: 900px` block ~100 lines above, which is where it belongs by
   topic. That block precedes `.pane__chrome` in source order and a media query
   adds no specificity, so a `.pane__chrome` rule written there loses the
   cascade and silently does nothing. Same trap that nearly shipped a broken
   `.tabs` reservation; see the note on `.pane--feed .tabs`. */
@media (max-width: 900px) {
  .pane__chrome { gap: 8px; }
}

/* Eight pane-title icons (A, B, C, E, F, G, H, I — D is skipped, see the note in
   index.html above pane C), plus a ninth for the directory page's own
   ":: DIRECTORY" title (radar1.gif) — that one has no pane or letter of its
   own, since the directory page IS the thing being titled rather than a link
   to it, but reuses this exact same class and iconFor() helper rather than a
   second icon system. 18px tall: .pane__title inherits the body's 1.55 line
   height at its own 12px font-size, which already boxes out to ~18.6px, so
   an 18px-tall inline image sits inside a line the text was going to occupy
   anyway instead of stretching .pane__chrome taller on five panes and not
   the rest. Width AND height are set here in CSS, not just as HTML
   attributes, per the same anti-reflow reasoning as the masthead icons
   above — this is belt-and-braces against .pane__title ever picking up a
   font change that would otherwise leave only the HTML attributes holding
   the box size. */
.pane__title-gif {
  display: block;
  height: 18px;
}
/* Every title icon sits in the same 30x22 box, so nine icons with nine aspect
   ratios read as one set. */
.pane__title picture {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 30px;
  height: 22px;
  margin-right: 8px;
  /* No fill: the label's ink shows behind the icon (owner's call, 11 Oct). */
  vertical-align: middle;
}
/* The GIF files themselves are pre-sized to twice these boxes by
   scripts/build-gifs.sh (lanczos, or hqx for the 22px donut), so the browser
   only halves them. cd.gif is smaller than its 2x target and is scaled up
   hard-edged. */
.pane__title-gif--cd { image-rendering: pixelated; }
/* One modifier per icon because none of these share an aspect ratio:
   aphex2 99×83, rocknrollcd 160×120, radio3 120×58, computer 100×128,
   cd 32×26, donut2 22×22, radar1 66×67. Widths below are each icon's own
   ratio resolved at the shared 18px height — note computer.gif is the only
   PORTRAIT source here, so its box is narrower than it is tall rather than
   wider. */
.pane__title-gif--aphex       { width: 21px; } /* Pane G — aphex2.gif */
.pane__title-gif--rocknrollcd { width: 24px; } /* Pane A — rocknrollcd.gif */
/* Pane B — radio3.gif, 120x58: the one source wider than the 30px plate at
   18px tall, so it is fitted to the plate's WIDTH instead (30x15). */
.pane__title-gif--radio3      { width: 30px; height: 15px; }
.pane__title-gif--computer    { width: 14px; } /* Pane C — computer.gif */
.pane__title-gif--cd          { width: 22px; } /* Pane F — cd.gif */
.pane__title-gif--bandcamp    { width: 18px; } /* Pane E — bandcamp.gif */
.pane__title-gif--hiphop      { width: 24px; } /* Pane H — hiphop.gif, same 160x120 ratio as rocknrollcd */
.pane__title-gif--donut2      { width: 18px; } /* Pane I — donut2.gif */
.pane__title-gif--radar1      { width: 18px; } /* Directory — radar1.gif */

.pane__body { padding: 12px; }

.pane__out {
  margin: 10px 0 0;
  font-family: var(--font-mono);
  font-size: 12px;
}

.sub {
  margin: 18px 0 8px;
  font-family: var(--font-display);
  font-size: 14px;
  font-weight: 700;
  line-height: 17px; /* the 11px mono it replaced boxed out to 17px */
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--dim);
  border-bottom: 2px solid var(--bevel-hi);
  padding-bottom: 5px;
}
.pane__body > .sub:first-child { margin-top: 0; }

/* ── tabs ─────────────────────────────────────────────────────────────── */

/*
  One scrolling row, at every width. This rule used to say `flex-wrap: wrap`
  and be overridden by a `.pane--feed .tabs` copy inside the phone block; that
  copy is gone and this is now the only rule, because the argument for it turned
  out not to be about phones at all.

  Wrapped tabs cannot be reserved correctly: the height is a function of
  viewport width and label text. Measured 186px below 600px and 92px above it
  on a phone, and — the part that was missing until 22 Sep 2026 — 139 / 92 / 45
  at desktop widths of 901 / 1000-1600 / 1920. So any single `min-height` is
  wrong at two thirds of the range, and a wrong reservation still shifts.

  Worse, it rots: the tab list is built from `feed.categories`, which the feed
  worker owns and which grew by one as recently as 9 Sep. One row is the same
  height whatever is in it.

  It also gives a phone back ~115px, 14% of the viewport, and takes 47px off
  the desktop feed pane's chrome.
*/
/* Since 11 Oct 2026 the tab list sits INSIDE pane A's chrome, between the
   title (reduced to its icon plate; the words are visually hidden) and the
   meta. It is the chrome's middle flex item and takes the slack. */
.tabs {
  display: flex;
  flex: 1 1 0;
  min-width: 0;
  align-self: stretch;
  flex-wrap: nowrap;
  overflow-x: auto;
  /* Reserved: one row. Matches the filled height because .tab below cannot
     wrap, so this does not move when a category is added. */
  min-height: 45px;
  gap: 3px;
  /* padding-top was 8px and is now 0 — the taller .tab below carries that
     breathing room inside its own hit box instead of outside it, so the space
     is clickable rather than dead. */
  padding: 0 8px;
}
/* Fades the right edge while there are tabs off-screen (ears.js sets
   data-more from scrollLeft). Never on the last tab, and never on phones,
   where the chips wrap. */
.tabs[data-more] {
  -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 36px), transparent);
  mask-image: linear-gradient(90deg, #000 calc(100% - 36px), transparent);
}
/* Pane A's label is the CD alone, no text, and as an overhanging tab it read
   as a loose square (owner, 11 Oct). It sits inside the chrome instead, the
   full 45px height of the tab row, as the row's head. */
.pane--feed .pane__title {
  margin: 0;
  align-self: stretch;
  padding: 0 7px;
}
.pane--feed .pane__title picture { margin-right: 0; }
/* No bare inset between the label and the first tab: the chrome's 12px gap
   and the row's 8px left padding both go, so the ink block meets "All".
   Desktop only; on a phone the chips wrap under the label and keep their own
   padding from the block below. */
@media (min-width: 901px) {
  .pane--feed .pane__chrome { gap: 0; }
  .pane--feed .tabs { padding-left: 0; }
}

.tab {
  /* 15px block padding, not 5: an 11px font in a 25px-tall box was the single
     worst target on the page, and these are the pane's primary control. This
     is real padding rather than the negative-margin trick used elsewhere in
     this file, because a tab that reached 44px only in its invisible hit area
     would put a 10px-deep dead strip over the top of the feed below it.

     It costs height. It used to cost a great deal more: with six categories
     the bar wrapped to three rows on a phone and grew from 89px to 139px.
     `.tabs` does not wrap at any width any more, so that is now one 45px row
     however many categories the feed worker adds. The 8px that .tabs used to
     carry as padding-top still comes off — the same breathing space lives
     inside the tabs, where it is clickable. */
  padding: 15px 11px;
  /* Load-bearing for the min-height above: a tab that wrapped its own label
     would make the row taller than the 45px reserved for it. */
  white-space: nowrap;
  background: var(--inset);
  border: 1px solid var(--bevel-lo);
  border-bottom: none;
  color: var(--dim);
  font-family: var(--font-display);
  font-stretch: 75%; /* Bricolage's condensed width, for the tabs only */
  font-size: 14px;
  font-weight: 700;
  line-height: 13px; /* the line the 11px mono made; keeps the 45px row */
  letter-spacing: 0.04em;
  cursor: pointer;
}

.tab:hover { color: var(--text); }

.tab[aria-selected="true"] {
  background: var(--panel);
  border-color: var(--bevel-hi);
  color: var(--accent);
}

/* Was `opacity: 0.65`, which composited --dim down to 4.09:1 on the unselected
   tabs — a real AAA failure that measuring the token alone would never show,
   because the token itself passes. The brackets around the number already
   subordinate it to the tab label; it does not need dimming as well. */
.tab__count {
  margin-left: 6px;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 400;
}

/* Each genre tab in its own colour (data-cat is set in renderTabs). A
   category added by the feed worker with no entry here keeps the plain
   --dim tab rather than an arbitrary colour.

   The selected tab is not marked by colour alone (1.4.1): it keeps the
   panel background and joined edge above, and gains a 1px bar along its top
   in its own colour (2px total with the border), a shape that reads the same
   to anyone who cannot tell the hues apart. Was a 2px bar (3px total) until
   23 Sep 2026; the owner found it too heavy. */
.tab[data-cat="all"]         { --tab-c: var(--cat-all); }
.tab[data-cat="electronic"]  { --tab-c: var(--cat-electronic); }
.tab[data-cat="indie"]       { --tab-c: var(--cat-indie); }
.tab[data-cat="hiphop"]      { --tab-c: var(--cat-hiphop); }
.tab[data-cat="heavy"]       { --tab-c: var(--cat-heavy); }
.tab[data-cat="oregon"]      { --tab-c: var(--cat-oregon); }
.tab[data-cat="aggregators"] { --tab-c: var(--cat-aggregators); }
.tab[data-cat] { color: var(--tab-c, var(--dim)); }
.tab[data-cat]:hover { color: var(--tab-c, var(--text)); border-color: var(--tab-c, var(--bevel-hi)); }
/* Selected is FILLED with the tab's ink and the others are outlined, so
   selection is a difference of shape, not of colour alone (1.4.1). --void on
   every --cat-* ink is 7.7:1 or better. */
.tab[data-cat][aria-selected="true"] {
  background: var(--tab-c, var(--accent));
  color: var(--void);
  border-color: var(--tab-c, var(--bevel-hi));
}

/* Phones: the tabs WRAP, as bordered chips, instead of one sideways-scrolling
   row. The owner asked for the feed pane's sideways scroll gone (23 Sep 2026).

   The nowrap row above exists for CLS: wrapped tabs change height with the
   viewport and the category count, so no single min-height could reserve
   them. That mattered while the feed was the first pane on a phone. Since
   the 22 Sep reorder it is the fourth, starting ~1,950px down a 412x823
   screen, so whatever height the wrapped row grows to on load is off screen
   and scores nothing. Desktop keeps the nowrap row: there the feed is at the
   top of the console band and the reservation still earns its keep.

   Placed AFTER the base .tabs/.tab rules on purpose. A media query adds no
   specificity, and the big phone block ~300 lines up would lose to these base
   rules on source order -- the cascade trap recorded in CLAUDE.md.

   Each chip keeps .tab's 15px block padding, so every tab is still a 44px
   target (2.5.5 AAA). The folder-tab trick (no bottom border, -1px overlap
   onto the tablist's border) only reads right on a single row, so on phones
   every chip is fully bordered and the selected one is marked by the accent
   border and colour instead. */
@media (max-width: 900px) {
  /* Under 901px the masthead wraps the nav onto its own row below the mark,
     where a stack would only add height; it stays one line there. */
  .masthead__nav { flex-direction: row; align-items: center; justify-content: flex-end; gap: 16px; }
  .masthead__nav .masthead__status { margin-top: 0; }
  .masthead__nav .masthead__nav-dir { margin-top: -12px; } /* the trick's own value */
  /* The chrome wraps so the chips take a full row under the plate and meta. */
  .pane--feed .pane__chrome { flex-wrap: wrap; }
  .tabs {
    flex: 1 1 100%;
    order: 1;
    -webkit-mask-image: none;
    mask-image: none;
    flex-wrap: wrap;
    overflow-x: visible;
    min-height: 0;
    gap: 6px;
    padding: 8px 11px;
  }
  .tab {
    border-bottom: 1px solid var(--bevel-lo);
  }
  .tab[aria-selected="true"] {
    border-color: var(--accent);
    margin-bottom: 0;
    padding-bottom: 15px;
  }
}

/* ── the feed list ────────────────────────────────────────────────────── */

/* No fixed cap: the list takes exactly the height the console band leaves it
   and scrolls inside that. A cap here is what used to strand the bottom of the
   left column when the rail beside it grew taller. */

/*
  `position: relative` IS THE FIX FOR THE 100,000-PIXEL SCROLL PAST THE END OF
  THE PAGE, and it is load-bearing on every scroll container in this file.

  `overflow-y: auto` does NOT clip an absolutely-positioned descendant unless
  this element is also that descendant's containing block. Without `position`
  here, an `position: absolute` child resolves its containing block all the way
  up at the initial containing block — so it is not clipped by this box, and its
  static position (which can be 106,000px down inside a 461-item list) is added
  to the DOCUMENT's scrollable overflow instead of this list's.

  What that produced: `documentElement.scrollHeight` 107,067px against 4,994px
  of real content. The page ended at the footer and then scrolled for another
  125 empty screens. Measured on a phone, reproduced at 375x812 and at desktop
  width, and it appeared the moment `.visually-hidden` — which is
  `position: absolute` — started being written into every feed item as the
  "(opens in a new tab)" announcement.

  So: any element in this file that gets `overflow-x/y: auto|scroll` gets
  `position: relative` in the same rule. It costs nothing, it creates no
  stacking context surprises here (nothing inside these lists is positioned
  against a further ancestor on purpose), and leaving it off is silent — the
  page looks perfect for the first screenful.
*/
.stream {
  position: relative;
  margin: 0;
  padding: 0;
  list-style: none;
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

.item {
  display: grid;
  grid-template-columns: 56px minmax(0, 1fr);
  gap: 11px;
  padding: 11px 4px;
  border-bottom: 1px dotted var(--bevel-hi);
  /* Feed text is third-party, and some of it is one unbreakable run: Neon
     Music puts bare URLs in its excerpts. Without this such a run overflows
     its column and .stream scrolls sideways (measured 496px of content in a
     358px list, 23 Sep 2026). `anywhere` rather than `break-word` because it
     also lowers the column's min-content width, which is what a grid track
     sizes from. */
  overflow-wrap: anywhere;
}
.item:last-child { border-bottom: none; }

.item__thumb {
  width: 56px;
  height: 56px;
  object-fit: cover;
  background: var(--inset);
  border: 1px solid var(--bevel-lo);
}

/* The stand-in shown when an item carries no artwork, or its artwork failed.
   `contain`, not the `cover` above: these are small transparent GIFs, not
   photographs, and cropping them to fill a 56px square would cut the subject
   in half. Padding keeps them off the border so they read as an icon sitting
   in the slot rather than a thumbnail that failed to load. */
.item__thumb--stand-in {
  object-fit: contain;
  padding: 7px;
}

.item__head {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 9px;
  align-items: baseline;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--dim);
}

.item__source { color: var(--amber); }
.item__time   { color: var(--time); }
.item__author { color: var(--byline); }

.item__title {
  display: block;
  margin: 3px 0 4px;
  font-size: 15px;
  font-weight: 600;
  line-height: 1.35;
  /* Paper, not link blue: the title is the row's subject and the metadata
     around it carries the colour. a:hover still takes it (blue or the sweep). */
  color: var(--text);
  text-decoration: none;
}

.item__excerpt {
  margin: 0;
  font-size: 13px;
  line-height: 1.5;
  color: var(--dim);
}

.empty, .shows__pending {
  color: var(--dim);
  font-family: var(--font-mono);
  font-size: 12px;
}

/* Distinct from .shows__pending on purpose — "nothing scheduled" and "the
   scrape broke" need to read differently at a glance, not just in the DOM. */
.shows__error {
  color: var(--hot);
  font-family: var(--font-mono);
  font-size: 12px;
}

/* ── single-pane pages and the directory ──────────────────────────────── */

/*
  The per-pane pages (/feed, /live, /sandbox, /bandcamp, /pitch) and
  /directory, all generated from index.html by
  scripts/build-pane-pages.mjs.

  .page--single exists because .page's children are the console/split
  wrappers, and a lone pane outside those has no width constraint at all --
  on a wide monitor Pane F ran the full 2560px and its table cells drifted
  apart. A max-width here rather than on .pane itself: the front page's
  panes take their width from the grid they sit in, and giving .pane a cap
  would fight that.
*/
.page--single {
  max-width: 1100px;
  margin: 0 auto;
}

/*
  A pane on the front page has the masthead directly above it. On its own
  page it does not, so it gets a way back -- to the site and to the list of
  its siblings. Two links and no separator glyph: a "/" between them reads
  as a path the second link does not lead to.
*/
.crumb {
  display: flex;
  gap: 18px;
  margin-bottom: 10px;
  font-family: var(--font-mono);
  font-size: 12px;
}

.crumb a {
  color: var(--link);
  /* Same 44px-without-moving-the-row trick as .masthead__nav a. */
  padding: 12px 3px;
  margin: -12px -3px;
}

.crumb a:hover { color: var(--accent); }

.dir__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  /* auto-fit rather than a fixed count: there are six panes today and the
     letters deliberately skip D, so a hard-coded 3x2 would be wrong the
     moment a pane is added or removed. */
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 14px;
}

.dir__item {
  padding: 12px 14px;
  background: var(--inset);
  border: 1px solid var(--bevel-hi);
}

.dir__link {
  display: flex;
  align-items: baseline;
  gap: 10px;
  font-family: var(--font-mono);
  font-size: 14px;
  color: var(--link);
  text-decoration: none;
}

.dir__link:hover .dir__name { text-decoration: underline; }

.dir__blurb {
  margin: 6px 0 0;
  color: var(--dim);
  font-size: 12px;
  line-height: 1.5;
}

/* ── source status ────────────────────────────────────────────────────── */

.sources {
  border-top: 1px solid var(--bevel-hi);
  padding: 8px 12px;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--dim);
}
.sources summary {
  cursor: pointer;
  letter-spacing: 0.1em;
  list-style: none;
}
.sources summary::-webkit-details-marker { display: none; }
/* The disclosure triangle, drawn with borders rather than a glyph character,
   so it is the same shape in every font and turns to point down when open. */
.sources summary::before {
  content: '';
  display: inline-block;
  width: 0;
  height: 0;
  margin-right: 7px;
  border-left: 6px solid currentColor;
  border-block: 4px solid transparent;
  vertical-align: 1px;
  transition: transform 160ms var(--ease-out);
}
.sources[open] summary::before { transform: rotate(90deg); }
.sources ul { margin: 8px 0 0; padding: 0; list-style: none; }
.sources li { display: flex; gap: 8px; padding: 2px 0; }
.sources .ok   { color: var(--accent); }
.sources .fail { color: var(--hot); }

/* ── radio ────────────────────────────────────────────────────────────── */

.radio {
  padding: 12px;
  background: var(--inset);
  border: 1px solid var(--bevel-lo);
  border-top-color: var(--bevel-hi);
  text-align: center;
}

.radio__dial {
  display: block;
  font-family: var(--font-mono);
  font-size: 30px;
  font-weight: 700;
  color: var(--amber);
  letter-spacing: 0.04em;
}

.radio__call {
  font-family: var(--font-mono);
  font-size: 15px;
  letter-spacing: 0.24em;
  color: var(--text);
}

.radio__desc {
  display: block;
  margin-top: 3px;
  font-size: 11px;
  color: var(--dim);
}

/* On-air programme. Amber rather than --dim because this is station identity,
   not incidental metadata — it reads as the marquee above the transport. */
.radio__show {
  margin: 10px 0 0;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.06em;
  color: var(--amber);
}

.radio__btn {
  margin-top: 12px;
  width: 100%;
  /* 14px block, from 9: a full-width button was still only 34px tall. */
  padding: 14px 9px;
  background: var(--panel);
  border: 1px solid var(--bevel-hi);
  border-bottom-color: var(--bevel-lo);
  border-right-color: var(--bevel-lo);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.14em;
  cursor: pointer;
}
.radio__btn:hover { background: #221e19; }
/* Play triangle / stop square, drawn with borders, not ▶ and ■ characters. */
.radio__btn::before {
  content: '';
  display: inline-block;
  width: 0;
  height: 0;
  margin-right: 9px;
  border-left: 9px solid currentColor;
  border-block: 6px solid transparent;
  vertical-align: -1px;
}
.radio__btn[aria-pressed="true"]::before {
  width: 10px;
  height: 10px;
  border: 0;
  background: currentColor;
}
.radio__btn:active { border-top-color: var(--bevel-lo); border-left-color: var(--bevel-lo); }
.radio__btn[aria-pressed="true"] { color: var(--hot); }

.radio__now {
  margin: 10px 0 0;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--dim);
}

/* ── shows ────────────────────────────────────────────────────────────── */

/* smiley.gif, riding inline at the end of the "upcoming · pc-pdx" heading
   just above. Fixed width and height for the usual reflow reason; sized a
   touch larger than the pane-title icons (100×75 source, so 4:3 like
   cdspinner/music above) because it sits against 11px uppercase heading
   text rather than 12px pane-title text and reads as cramped any smaller. */
/* Wraps a .sub__gif that has a destination -- jazz.gif on the KMHD heading,
   smiley.gif on pc-pdx.
   
   The padding is what turns a 32px image into a real tap target, and the
   equal negative margin-block cancels it again so the heading's line box does
   not grow and the dotted rule under .sub stays exactly where it was. Same
   trick as .masthead__link, for the same reason. */
.sub__link {
  display: inline-flex;
  align-items: center;
  padding: 10px 4px;
  margin-block: -10px;
  margin-right: -4px;
  vertical-align: middle;
}
.sub__link:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.sub__gif {
  display: inline-block;
  /* Enlarged from 24×18. It reads as an emoji-scale punctuation mark on the
     end of the heading rather than an icon in a row of icons, so it can
     afford to be bigger than the pane-title set.

     HEIGHT-DRIVEN, and that is load-bearing. This rule used to hardcode
     32×24 because it was written for smiley.gif alone (100×75, 4:3). jazz.gif
     is 60×50 (6:5) and inherited it, so it rendered squashed -- caught by
     Lighthouse's image-aspect-ratio audit on 19 Sep 2026, "displayed 32x24
     (1.33) | actual 60x50 (1.20)". Setting only the height lets each source
     keep its own ratio while both sit at the same optical size, and the
     width/height attributes on the <img> still give the browser the intrinsic
     ratio before load, so nothing shifts. Do not put a fixed width back. */
  height: 24px;
  width: auto;
  margin-left: 6px;
  vertical-align: middle;
}

.shows { margin: 0; padding: 0; list-style: none; }
.shows li {
  padding: 6px 0;
  border-bottom: 1px dotted var(--bevel-hi);
  font-size: 13px;
}
.shows li:last-child { border-bottom: none; }

.show__head {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 9px;
  align-items: baseline;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--dim);
}

.show__date { color: var(--time); }

.show__age {
  padding: 0 5px;
  border: 1px solid var(--bevel-hi);
  letter-spacing: 0.06em;
}

.show__venue {
  display: block;
  margin: 3px 0 2px;
  font-weight: 600;
  text-decoration: none;
}
a.show__venue:hover { color: var(--accent); }

.show__lineup {
  margin: 0 0 2px;
  color: var(--text);
}

.show__meta {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--dim);
}

/* KMHD marks its own picks with a star in the calendar SUMMARY. The worker
   strips the emoji from the title and hands over a boolean instead, so the
   marker is styled here rather than being whatever glyph the publisher typed
   — and it is aria-hidden in the markup, with the accessible name carried by
   the row's own text. */
.show__star {
  color: var(--amber);
  font-size: 11px;
}

.show__notes {
  margin: 3px 0 0;
  font-size: 12px;
  color: var(--dim);
  font-style: italic;
}

/* ── discogs ──────────────────────────────────────────────────────────── */

.dig { display: flex; gap: 6px; }

.dig input {
  flex: 1;
  min-width: 0;
  /* Grown with the DIG button beside it — a 30px input next to a 44px button
     looks like a mistake, and both are targets. */
  padding: 14px 9px;
  background: var(--inset);
  border: 1px solid var(--bevel-lo);
  border-top-color: #000;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 12px;
}
.dig input::placeholder { color: var(--placeholder-dig); }

/* Pane C's two sub-headings, told apart from each other and from the
   placeholder between them. Same cool family, three neighbouring hues. */
.sub--dig { color: var(--sub-dig); }
.sub--dir { color: var(--sub-dir); }

.dig button {
  padding: 14px 13px;
  background: var(--panel);
  border: 1px solid var(--bevel-hi);
  border-bottom-color: var(--bevel-lo);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.1em;
  cursor: pointer;
}

/* position: relative for the same reason as .stream above — this one holds
   release rows whose links carry .visually-hidden new-tab announcements. */
.dig__results { position: relative; margin-top: 10px; max-height: 340px; overflow-y: auto; }

.rel {
  display: grid;
  grid-template-columns: 42px minmax(0, 1fr);
  gap: 9px;
  padding: 7px 0;
  border-bottom: 1px dotted var(--bevel-hi);
}
.rel img { width: 42px; height: 42px; object-fit: cover; background: var(--inset); }
.rel__title { font-size: 13px; line-height: 1.35; text-decoration: none; }
.rel__meta { font-family: var(--font-mono); font-size: 11px; color: var(--dim); }

/* ── trivia ───────────────────────────────────────────────────────────── */

/* Deliberately the quietest thing on the page: a dim line above the footer
   rather than an amber-barred blockquote inside a pane. No panel chrome, no
   accent colour, no border on the left — it should read as a footnote and lose
   every contest for attention against the panes above it.

   Still --dim rather than anything darker: quiet is a matter of weight and
   placement here, not of dropping below the 7:1 floor. */
/* Wraps .trivia and haring.gif so the two sit at opposite ends of one row —
   trivia at the left where it always was, the gif at the bottom right of
   the page as mapped. This wrapper carries none of .trivia's own padding;
   .trivia keeps its original 18px left padding so the trivia TEXT lines up
   exactly where it always did, and the gif gets its own matching margin
   below instead. flex-wrap so a long trivia sentence on a narrow phone can
   drop the icon to its own line rather than forcing a horizontal scroll. */
.trivia-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px 24px;
  flex-wrap: wrap;
  /* The row carries the page gutter (18px) and its own vertical breathing
     room. It used to be split across children -- .trivia's 10px 18px 0
     padding, the GIF group's right margin -- so when the GIFs wrapped under
     the text on a phone they lost their left gutter and sat flush against
     the screen edge and the footer's top border (23 Sep 2026). */
  padding: 16px 18px;
}
.trivia-row .trivia {
  flex: 1 1 320px;
  min-width: 0;
}

.trivia {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 12px;
  align-items: baseline;
  margin: 0;
  padding: 0;
  color: var(--dim);
}

.trivia__text {
  margin: 0;
  font-size: 12px;
  line-height: 1.5;
  /* Keeps it to a footnote's worth of line length on a wide screen instead of
     stretching one sentence across the whole viewport. */
  max-width: 78ch;
}

/* The source chip beside the trivia line.

   It used to be bare 10px --dim text sitting directly on the page background
   with nothing behind it, which is why it read as a label with a transparent
   pill rather than as a label: at that size, on the scanline wash, against a
   --void backdrop rather than the --panel everything else here sits on, it
   simply disappeared.

   Now it is an actual chip — its own --inset ground, a bevel border and
   padding — and the text steps up from --dim to --text. --text on --inset is
   ~14:1, well clear of the 7:1 floor, so making it legible costs nothing and
   the surrounding trivia sentence stays the quieter of the two, which is the
   right way round: the sentence is the garnish, the citation is the fact.

   Still no `opacity` anywhere near it. --dim sits just above 7:1 on --panel
   with almost no headroom and any opacity composites it straight through the
   AAA floor — 0.85 measured 6.31:1. */
/* The chip's link is styled as the chip, not as a link: --text like the plain
   version, so the border and ground stay the thing that marks it out and the
   colour does not change depending on whether a given fact happens to have a
   source. Underline on hover is what signals it is clickable. */
.trivia__cite a { color: inherit; text-decoration: none; }
.trivia__cite a:hover { color: var(--accent); text-decoration: underline; }

.trivia__cite {
  font-family: var(--font-mono);
  /* 11px, not 10px. One pixel is the difference between a legible mono
     caption and a smudge, and nothing here is tight enough to need the pixel
     back. */
  font-size: 11px;
  font-style: normal;
  color: var(--text);
  padding: 3px 8px;
  background: var(--inset);
  border: 1px solid var(--bevel-hi);
  border-radius: 2px;
  white-space: nowrap;
}

/* haring.gif, at the right end of .trivia-row. 10px top margin mirrors
   .trivia's own 10px top padding above so the two align visually despite
   being independent flex items; 18px right margin mirrors the page's own
   right-hand gutter (.trivia's 18px right padding, .foot's 18px right
   padding) so the icon lines up with everything else pinned to that edge. */
/* Two of them now, both links, so they are grouped in .trivia-row__gifs and
   that wrapper carries the gutter margin instead of each image. Bottom-aligned
   so a 44px-tall haring and a 31px-tall goblins sit on one line rather than
   floating at different heights. */
.trivia-row__gifs {
  flex: 0 0 auto;
  display: flex;
  align-items: flex-end;
  gap: 10px;
  margin: 0;
}
.trivia-row__gifs a { display: block; line-height: 0; }
.trivia-row__gif {
  flex: 0 0 auto;
  display: block;
}

/* ── sandbox directory ────────────────────────────────────────────────── */

/* Multi-column, NOT grid. A row-based grid sizes every row to its tallest
   item, so Making at thirteen entries left Field Recordings and Portland &
   Oregon sitting beside a column of dead space, and Misc stranded alone on a
   row of its own. Columns flow instead: a group starts wherever the previous
   one ended, so the pane packs to roughly even height with no gaps to fill.

   No column-count, matching the old auto-fit behaviour -- the browser fits as
   many 230px columns as the container allows, so this stays one rule for both
   the front page's pane and the wider /sandbox page. */
.sandbox {
  columns: 230px;
  column-gap: 14px;
}

/* Two jobs. `break-inside` stops a column boundary cutting a group between its
   heading and its links, which is the one thing multi-column will do that grid
   never did. The margin replaces grid's `gap` for vertical separation, since
   column-gap only spaces the columns. */
.sandbox__group {
  break-inside: avoid;
  margin: 0 0 14px;
}

.sandbox__group h3 {
  margin: 0 0 7px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--amber);
}

.sandbox__group ul { margin: 0; padding: 0; list-style: none; }

.sandbox__group li {
  padding: 3px 0 3px 13px;
  position: relative;
  font-size: 13px;
}
/* Directory-listing bullet, in the spirit of a 90s index page. */
.sandbox__group li::before {
  content: "›";
  position: absolute;
  left: 0;
  color: var(--dim);
}

.sandbox__note {
  display: block;
  font-size: 11px;
  color: var(--dim);
}

/* One level of nesting, for a vault entry whose contents are the links — see
   entry() in ears.js. Indented from the parent's own text rather than from the
   list edge, so the "›" bullets form two clear columns instead of one ragged
   one, and given a rule down the left so the grouping survives when a parent's
   note wraps to two lines and pushes its children away from it. */
/* Selector is .sandbox__group .sandbox__sub, not .sandbox__sub alone: the
   `.sandbox__group ul` rule above zeroes the padding on every descendant list
   including this one, and one class cannot outrank a class plus a type. */
.sandbox__group .sandbox__sub {
  margin-top: 2px;
  /* Re-indents from the parent's text edge rather than the list edge. */
  margin-left: -3px;
  padding-left: 9px;
  border-left: 1px solid var(--bevel-hi);
}
/* Marks the children as subordinate to the row above without shrinking the
   link text, which stays 13px and fully legible — the muted bullet carries the
   hierarchy, not a smaller font. */
.sandbox__sub li::before { color: var(--bevel-hi); }

/* The "show more" toggle on an over-long group. A <details>, so the open and
   closed state, the keyboard handling and the ARIA are the element's own and
   nothing here reimplements them -- the label text is rewritten in ears.js on
   toggle because generated CSS content is not reliably read out.

   Styled as a row of the list rather than as a button: same 13px left indent
   and same bullet position as the entries above it, so it reads as the end of
   the list rather than a control bolted under it. */
.sandbox__more summary {
  position: relative;
  padding: 6px 0 6px 13px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--dim);
  cursor: pointer;
  /* Kills the default disclosure triangle in both engines, so the +/- below is
     the only marker. */
  list-style: none;
}
.sandbox__more summary::-webkit-details-marker { display: none; }

/* Sits where the entries' "›" sits, so the left edge stays one column. */
.sandbox__more summary::before {
  content: "+";
  position: absolute;
  left: 0;
}
.sandbox__more[open] summary::before { content: "\2212"; }

.sandbox__more summary:hover { color: var(--fg); }

/* 23px of target (11px text + 12px padding): short of 2.5.5 AAA (44px) but
   clear of 2.5.8 AA (24px) once the line-box is counted -- the same trade-off
   recorded for the masthead icons above, and for the same reason. Giving this
   44px would add a third of a row of height to the one pane whose whole
   problem is height. */

/* clickhereformoreaphex.gif, at the foot of the directory. This is the one
   GIF on the page that is NOT decoration — it is the entire content of the
   link to Warp's Aphex portal, replacing the old plain-text "Aphex Twin —
   Warp" row (see the HTML comment above it). Fixed 187×51 at native size:
   it is a period web banner and shrinking it would make its own baked-in
   text illegible, which none of the pane-title icons above needed to worry
   about since theirs is decorative down to any size.

   Because this GIF carries real information, the reduced-motion problem
   below cannot be solved by hiding it the way the ten decorative GIFs are
   hidden (see the @media block near the bottom of this file) — an
   invisible link is worse than a still one. Instead the <picture> in the
   HTML swaps in clickhereformoreaphex-static.png, a single extracted frame
   at the same 187×51, under prefers-reduced-motion. No JS needed: <source
   media> inside <picture> already understands prefers-reduced-motion as a
   plain media feature. */
.sandbox__aphex {
  margin: 14px 0 0;
  /* Flex rather than two inline-blocks in a paragraph, purely so the row gap is
     controllable: both banners carry a 7px hit overlay, and wrapped onto two
     lines with no row-gap those overlays would touch. */
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 10px;
  row-gap: 16px;
}
.sandbox__aphex a {
  display: inline-block;
  line-height: 0; /* kills the few px of inline-image whitespace under the banner */
}
.sandbox__aphex img {
  display: block;
}
/* Sized per banner, NOT on `.sandbox__aphex img` as a group — that is what
   this used to be, and it silently stretched its neighbour. Both banners have
   their text baked into the pixels, so each is pinned to its own source size
   and used at native scale; sharing one rule scaled the 88×31 Free Geek button
   up to the 187×51 of the Warp banner and blurred its lettering. */
.sandbox__aphex .sandbox__banner--warp    { width: 187px; height: 51px; }
.sandbox__aphex .sandbox__banner--freegeek { width: 88px;  height: 31px; }

/* ── foot ─────────────────────────────────────────────────────────────── */

/* A grid with named areas, not flex space-between (23 Sep 2026). With
   space-between the four items landed wherever their widths happened to
   leave them: spread evenly across 1,900px on desktop, and wrapped into
   unrelated rows on a phone. Named areas put each one in a fixed place:

     desktop   badge  address+frog  ·········  gifs: on   feed built …
     phone     badge                           gifs: on
               address
               frog
               feed built …

   The markup order (badge, address, gifs switch, stamp) is the same as the
   visual order in both, so Tab and screen-reader order match what is seen.
   404.html drops the switch and the stamp; their areas just stay empty. */
.foot {
  display: grid;
  grid-template-columns: auto auto 1fr auto auto;
  grid-template-areas: "badge contact . motion built";
  align-items: center;
  column-gap: 24px;
  /* 24px, not less, between rows once it stacks on a phone: the badge
     reaches 7px past its image and the mailto 15px past its text, so a
     smaller gap would put two hit targets on top of each other. */
  row-gap: 24px;
  padding: 14px 18px;
  border-top: 1px solid var(--bevel-hi);
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--dim);
}
/* justify-self on every item: a grid item stretches to its column by
   default, and on a phone the badge's column is the full row, so the link's
   hit area ran 300px past the image into empty space (measured). */
.foot > *         { justify-self: start; }
.foot__badge-link { grid-area: badge; }
.foot__contact    { grid-area: contact; }
.foot__motion     { grid-area: motion; justify-self: end; }
.foot > #built    { grid-area: built; }

/* admin@pearcache.com plus frogemail.gif, as one unit in .foot's `contact`
   area: side by side on desktop, stacked on a phone (see the max-width block
   below), where the frog's fixed 284px would not fit beside the address. */
.foot__contact {
  display: flex;
  align-items: center;
  gap: 10px;
}

/* frogemail.gif is a 585×33 source — a wide, thin strip built to be read at
   width, not shrunk to icon size like the pane-title GIFs. 16px height
   roughly matches this footer's own 11px/1.55 line box; width is that same
   source ratio (17.7:1) resolved at 16px tall. Fixed in CSS as well as in
   the HTML attributes for the usual reflow reason. */
.foot__frog {
  display: block;
  width: 284px;
  height: 16px;
}

/* nb-bbbut.gif, in place of the "ears.pearcache.com" text that used to open
   the footer. 88×31 is the classic button size and it is used at native
   scale — the text is baked into the image, so scaling it makes it
   unreadable. `display: block` kills the inline descender gap that would
   otherwise make the footer 4px taller than its own padding implies. */
/* 88×31 is baked into the image and cannot be scaled without blurring the
   text inside it, so the target grows around it and the negative margin puts
   the footer's own spacing back. */
.foot__badge-link {
  display: block;
  line-height: 0;
  flex: 0 0 auto;
  padding-block: 7px;
  margin-block: -7px;
}
.foot__badge {
  display: block;
  flex: 0 0 auto;
  width: 88px;
  height: 31px;
}

/* Phones and narrow windows. After the base .foot rules on purpose: a media
   query adds no specificity, so an earlier block would lose on source order.
   The frog strip is 284px, which still fits a 320px phone inside the 18px
   gutters, so it goes on its own line under the address rather than beside
   it. */
@media (max-width: 900px) {
  .foot {
    grid-template-columns: 1fr auto;
    grid-template-areas:
      "badge   motion"
      "contact contact"
      "built   built";
  }
  .foot__contact {
    flex-direction: column;
    align-items: flex-start;
    gap: 12px;
  }
}

/* ── motion ──────────────────────────────────────────────────────────────
   Small and fast, on transform and opacity only. Tab switching and feed rows
   deliberately do not animate: they are the most-used parts of the page. */
:root {
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
}

/* The reroll glyph turns half a circle while pressed. */
.spot__next { transition: transform 300ms var(--ease-out), color 160ms; }
.spot__next:active { transform: rotate(180deg) scale(0.97); }

/* Source status and the pitch reveal open rather than snap, where the
   browser can interpolate to height: auto. Elsewhere they still snap. */
@media (prefers-reduced-motion: no-preference) {
  .sources, .pitch__reveal { interpolate-size: allow-keywords; }
  .sources::details-content,
  .pitch__reveal::details-content {
    height: 0;
    opacity: 0;
    overflow: clip;
    transition: height 250ms var(--ease-in-out), opacity 250ms, content-visibility 250ms allow-discrete;
  }
  .sources[open]::details-content,
  .pitch__reveal[open]::details-content { height: auto; opacity: 1; }
}

/* Press feedback on everything that is pressed. */
:where(button, summary, .tab) { transition: transform 160ms var(--ease-out); }
:where(button, summary, .tab):active { transform: scale(0.97); }

/* A player or a detail row arrives rather than appearing. @starting-style
   applies on the change from display: none, which is what [hidden] is. */
.archive__player:not([hidden]),
.mini__player:not([hidden]),
.archive__detail-row:not([hidden]) {
  transition: opacity 200ms var(--ease-out);
  @starting-style { opacity: 0; }
}

/* A re-rolled spotlight fades in rather than swapping: ears.js re-adds
   .spot--in after each draw (reflash()). 150ms, opacity only, no movement. */
@keyframes spot-in { from { opacity: 0; } }
.spot--in { animation: spot-in 150ms var(--ease-out); }

/* The directory grid rises in, 40ms apart. Rare page; the delight budget. */
@keyframes rise { from { opacity: 0; transform: translateY(6px); } }
.dir__item { animation: rise 250ms var(--ease-out) both; }
.dir__item:nth-child(2) { animation-delay: 40ms; }
.dir__item:nth-child(3) { animation-delay: 80ms; }
.dir__item:nth-child(4) { animation-delay: 120ms; }
.dir__item:nth-child(5) { animation-delay: 160ms; }
.dir__item:nth-child(6) { animation-delay: 200ms; }
.dir__item:nth-child(7) { animation-delay: 240ms; }
.dir__item:nth-child(8) { animation-delay: 280ms; }
.dir__item:nth-child(9) { animation-delay: 320ms; }
.dir__item:nth-child(10) { animation-delay: 360ms; }

/* Holographic sheen on the icon plates when pointed at: a diagonal RGB band
   sweeps across the plate (a ::after over it, screen-blended) and the icon
   gets a one-pixel red/cyan split. Both are transitions, not keyframes, so
   leaving early reverses from wherever it is. Mouse only: a touch has no
   hover. The plate ::after is the sheen; .masthead__icon's ::before is its
   plate (drawn above), so the sheen there is the ::after. */
@media (hover: hover) and (pointer: fine) {
  .pane__title picture,
  .masthead__icon,
  .sub__link { position: relative; }
  /* The band is masked to a soft ellipse and blurred, and reaches 4px past
     the plate, so it floats over the icon instead of filling a box. */
  .pane__title picture::after,
  .masthead__icon::after,
  .sub__link::after {
    content: '';
    position: absolute;
    inset: -4px;
    pointer-events: none;
    background: linear-gradient(115deg,
      transparent 20%,
      rgba(255, 80, 80, 0.5) 38%,
      rgba(80, 255, 120, 0.5) 50%,
      rgba(80, 140, 255, 0.5) 62%,
      transparent 80%);
    background-size: 300% 100%;
    background-position: 100% 0;
    -webkit-mask-image: radial-gradient(ellipse 60% 60% at 50% 50%, #000 20%, transparent 70%);
    mask-image: radial-gradient(ellipse 60% 60% at 50% 50%, #000 20%, transparent 70%);
    filter: blur(1.5px);
    mix-blend-mode: screen;
    opacity: 0;
    transition: background-position 650ms var(--ease-out), opacity 200ms;
  }
  .pane__title-link:hover picture::after,
  .masthead__icon:hover::after,
  .sub__link:hover::after {
    background-position: 0 0;
    opacity: 1;
  }
  .pane__title-gif,
  .masthead__icon img,
  .sub__gif { transition: filter 200ms; }
  .pane__title-link:hover .pane__title-gif,
  .masthead__icon:hover img,
  .sub__link:hover .sub__gif {
    filter: drop-shadow(1px 0 0 rgba(255, 60, 60, 0.6)) drop-shadow(-1px 0 0 rgba(60, 220, 255, 0.6));
  }
}

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
}

/*
  GIF frame-advance is decoded by the image format itself, not by CSS — the
  `animation: none` above stops CSS animations and transitions, and has no
  effect whatsoever on an animated GIF. There is no CSS property that can
  pause one. That leaves two honest options for satisfying 2.2.2 (Pause,
  Stop, Hide) and 2.3.1 (Three Flashes) once someone has asked the OS for
  reduced motion: remove the moving image outright, or replace it with a
  still frame.

  THIS FILE USED TO DO THE FIRST, and that was the bug. A rule here hid
  .masthead__gif, .pane__title-gif, .sub__gif and .foot__frog under
  prefers-reduced-motion, on the reasoning that a decorative image loses
  nothing by being removed. What that reasoning missed is WHO ends up
  matching the query. Android Chrome reports `prefers-reduced-motion: reduce`
  whenever the phone is in battery saver, and iOS does the same for Reduce
  Motion — neither of which is a request to strip a site of its decoration.
  The reported symptom was "GIFs do not load on mobile": every icon in the
  masthead and every pane-title icon simply absent, while the linked banners
  (which already had the <picture> treatment) still showed. Nothing was
  failing to load. They were being hidden, on a phone, by an accessibility
  query the user never knowingly opted into.

  So every animated GIF on the page now gets the SAME treatment, decorative
  or not: a <picture>/<source media="(prefers-reduced-motion: reduce)"> swap
  to an extracted first frame, declared next to each image in the HTML. No
  CSS rule is needed here at all — <source media> already understands
  prefers-reduced-motion as a plain media feature — which is why this is a
  comment where a rule used to be. The still frames are the -static.png files
  beside each .gif in assets/gifs/.

  Keep it that way. If you add a GIF, add its static frame and wrap it in a
  <picture>; do not add a class back to a hide list here.
*/

/* ── Pane E: Bandcamp Daily ───────────────────────────────────────────── */

/* The rotating best-of column. Amber-keyed like the radio marquee, because it
   plays the same role — one changing thing above a static list. */
.spot {
  padding: 10px 12px;
  margin-bottom: 4px;
  background: var(--inset);
  border: 1px solid var(--bevel-lo);
  border-left: 2px solid var(--amber);
}

.spot__head {
  display: flex;
  gap: 8px;
  align-items: baseline;
  justify-content: space-between;
}

.spot__label {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--amber);
}

/* 24px square, which it was not: this button was 12x13, well under 2.5.8 AA,
   on Pane E and on the spotlights on /datpiff and /jdilla as well as the
   front-page minis. Fixed on the shared rule rather than only in .pane--mini,
   since every one of those callers had the same undersized target. The glyph
   is unchanged — only the box around it grew — and inline-flex centring is
   what keeps it centred now that the box is bigger than the character.

   24 and not 44 (2.5.5 AAA) for the same reason recorded for the sandbox
   pane's fold toggle and the masthead icons: the extra height costs more here
   than it buys. */
.spot__next {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 24px;
  min-height: 24px;
  background: none;
  border: none;
  padding: 0 2px;
  color: var(--dim);
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
}
.spot__next:hover { color: var(--accent); }

.spot__title {
  display: block;
  margin-top: 5px;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.35;
  text-decoration: none;
}

.spot__excerpt {
  margin: 5px 0 0;
  font-size: 12px;
  line-height: 1.45;
  color: var(--dim);
}

/* One card per Bandcamp column, laid out across the full page width.

   This was a single stacked list inside the narrow rail, which is what pushed
   the pane off to the side of the layout and made a two-dozen-post digest
   unreadable. It is now one post per section, so the list is short enough that
   cards beat rows: auto-fit means the same markup is a four-across row on a
   desktop and a single column on a phone, with no breakpoint of its own. */
.bc {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 12px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.bc__item {
  display: flex;
  flex-direction: column;
  padding: 10px 12px;
  background: var(--inset);
  border: 1px solid var(--bevel-lo);
  border-top-color: var(--bevel-hi);
  border-left-color: var(--bevel-hi);
}

/* --dim is specified against --panel; --inset is darker still, so every muted
   colour in here clears the 7:1 floor by more than it does elsewhere. */
.bc__excerpt {
  margin: 6px 0 0;
  font-size: 12px;
  line-height: 1.45;
  color: var(--dim);
  /* Clamped rather than truncated in JS: publishers' excerpt lengths vary by
     an order of magnitude, and one long one would otherwise set the height of
     every card in the row. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 4;
  line-clamp: 4;
  overflow: hidden;
}

/* Pushes the timestamp to the bottom of every card so a row of cards of
   different heights still has its metadata on one line. */
.bc__foot {
  margin-top: auto;
  padding-top: 8px;
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--dim);
}

.bc__head {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 8px;
  align-items: baseline;
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--dim);
}

/* Section name carries the colour so the three streams stay tellable apart at
   a glance without needing separate headings per group. */
.bc__label {
  color: var(--accent);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.bc__title {
  display: block;
  margin-top: 3px;
  font-size: 13px;
  line-height: 1.4;
  text-decoration: none;
}

/* ── Pane F: how to pitch ─────────────────────────────────────────────── */

.pitch__intro {
  margin: 0 0 10px;
  font-size: 12px;
  color: var(--dim);
}

/* The reveal button keeps .archive__csv's inline-block summary, so it is
   button-sized on desktop. The label is ~55 characters of letter-spaced
   mono, wider than a phone's pane, so it must wrap (two lines at 360-412px,
   measured) rather than overflow; centred so the wrapped lines read as one
   label. */
.pitch__reveal { margin: 0; }
/* 12px vertical padding, not .archive__csv's 7px: 12 + 18px line + 12 + 2px
   border is 44px, the 2.5.5 AAA target size. This button is the only way
   into the pane's content, so it gets the AAA size rather than the AA 24px
   the site's secondary controls settle for.

   `.archive__csv.pitch__reveal`, two classes: `.archive__csv summary` sets
   padding too, with the same specificity, ~300 lines further down, so a
   one-class selector here loses the cascade and the button silently stays
   34px (measured). */
.archive__csv.pitch__reveal summary {
  padding: 12px 16px;
  text-align: center;
  line-height: 1.5;
  overflow-wrap: anywhere;
}
.pitch__reveal-open,
.pitch__reveal[open] .pitch__reveal-closed { display: none; }
.pitch__reveal[open] .pitch__reveal-open { display: inline; }

/* The table must scroll inside its own box rather than pushing the page wide —
   four columns of prose does not fit a phone at any font size. */
/* tabindex="0" in the HTML makes this reachable; the outline needs an inset
   offset or it would be drawn outside the pane and clipped. The default
   :focus-visible rule uses outline-offset: 2px, which on a full-width scroller
   sits half under the pane border. */
/* position: relative for the same reason as .stream above: both tables are full
   of outbound links, every one of which carries an absolutely-positioned
   .visually-hidden span, and an unpositioned scroller does not clip those. */
.pitch__scroll,
.archive__scroll {
  position: relative;
  overflow-x: auto;
  border: 1px solid var(--bevel-lo);
}
.pitch__scroll:focus-visible,
.archive__scroll:focus-visible {
  outline-offset: -2px;
}

.pitch {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
  min-width: 620px;
}

.pitch th {
  position: sticky;
  top: 0;
  padding: 7px 10px;
  background: #221e19;
  border-bottom: 1px solid var(--bevel-hi);
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  text-align: left;
  color: var(--accent);
}

.pitch td {
  padding: 8px 10px;
  border-bottom: 1px dotted var(--bevel-hi);
  vertical-align: top;
}
.pitch tr:last-child td { border-bottom: none; }

.pitch__name { font-weight: 600; text-decoration: none; }

/* Derived from the live feed, never from a stored date — see initPitch(). */
.pitch__live {
  display: block;
  margin-top: 2px;
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--accent);
}

.pitch__warn {
  display: block;
  margin-top: 2px;
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--hot);
}

.pitch__based, .pitch__covers { font-size: 12px; }
.pitch__based { color: var(--place); }
.pitch__covers { color: var(--genre); }

.pitch__tag {
  display: inline-block;
  margin-bottom: 3px;
  padding: 1px 6px;
  border: 1px solid var(--bevel-hi);
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
/* Colour-coded by effort: a direct address is worth more than a submission
   queue, and the tag is what people scan for. */
.pitch__tag--email     { color: var(--accent); border-color: var(--accent); }
.pitch__tag--submithub { color: var(--amber);  border-color: var(--amber); }
.pitch__tag--website   { color: var(--dim); }

.pitch__detail {
  display: block;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text);
  word-break: break-word;
}

/* ── Pane G: user18081971 ── */

.archive__quote {
  margin: 0 0 14px;
  padding: 11px 13px;
  background: var(--inset);
  border-left: 2px solid var(--amber);
}
.archive__quote p { margin: 0; font-size: 13px; line-height: 1.55; }
.archive__quote cite {
  display: block;
  margin-top: 7px;
  font-family: var(--font-mono);
  font-size: 11px;
  font-style: normal;
  color: var(--dim);
}

/* Credit for the annotations. --dim, not --text: it is a standing citation
   rather than something to be read once per visit, and --dim still clears 7:1
   on --panel so quieting it costs no contrast. */
.archive__credit {
  margin: 0 0 10px;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.5;
  color: var(--dim);
}

.archive__controls {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 8px;
}

.archive__search { flex: 1 1 220px; min-width: 160px; }

.archive__search input,
.archive__controls select {
  width: 100%;
  padding: 14px 9px;
  background: var(--inset);
  border: 1px solid var(--bevel-lo);
  border-top-color: #000;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 12px;
}
.archive__controls select { width: auto; max-width: 100%; }
.archive__search input::placeholder { color: var(--dim); }

.archive__count {
  margin: 0 0 8px;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--dim);
}

/* The DatPiff pane's "also search the wider archive" toggle. Boxed like the
   search/sort inputs above it (--inset fill, --bevel-lo border, black top
   edge — same treatment as .jdilla__trivia) rather than sitting as plain
   text: the request was for a conscious, visible choice, not something as
   easy to miss as one more <option> in the sort dropdown. */
.archive__widetoggle {
  margin: 0 0 8px;
  padding: 8px 10px;
  background: var(--inset);
  border: 1px solid var(--bevel-lo);
  border-top-color: #000;
}
.archive__widetoggle label {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  cursor: pointer;
}
.archive__widetoggle input { flex: 0 0 auto; margin-top: 3px; }
.archive__widetoggle span {
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.5;
  color: var(--dim);
}

/* The table scrolls inside its own box rather than pushing the page wide —
   five columns plus an expand toggle does not fit a phone at any font size. */
table.archive {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
  min-width: 480px;
}

.archive th {
  position: sticky;
  top: 0;
  padding: 7px 10px;
  background: #221e19;
  border-bottom: 1px solid var(--bevel-hi);
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  text-align: left;
  color: var(--accent);
}

.archive td {
  padding: 7px 10px;
  border-bottom: 1px dotted var(--bevel-hi);
  vertical-align: top;
}

.archive__toggle {
  width: 22px;
  height: 22px;
  padding: 0;
  background: var(--panel);
  border: 1px solid var(--bevel-hi);
  border-bottom-color: var(--bevel-lo);
  border-right-color: var(--bevel-lo);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
}
.archive__toggle:hover { background: #221e19; }
/* Hot rather than accent while open — it is the one row currently expanded,
   the same "this is the active one" role --hot plays on the radio button. */
.archive__toggle[aria-expanded="true"] { color: var(--hot); }

.archive__len, .archive__era {
  font-family: var(--font-mono);
  font-size: 11px;
  white-space: nowrap;
}

.archive__title { font-weight: 600; }

.archive__detail-row td {
  background: var(--inset);
  padding: 10px 14px;
}

.archive__detail {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 4px 12px;
  margin: 0;
}
.archive__detail dt {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--dim);
  white-space: nowrap;
}
.archive__detail dd {
  margin: 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--text);
  /* Notes and comments carry the sheet maintainer's own line breaks — keep
     them, this is the annotation the pane exists to surface. */
  white-space: pre-line;
  word-break: break-word;
}

.archive__art {
  display: block;
  margin-top: 10px;
  width: 96px;
  height: 96px;
  object-fit: cover;
  background: var(--panel);
  border: 1px solid var(--bevel-lo);
}

/* The pager owns the margin so that a row with only one visible button sits
   exactly where the lone "show more" used to. */
.archive__pager {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 10px;
}

.archive__more {
  padding: 7px 13px;
  background: var(--panel);
  border: 1px solid var(--bevel-hi);
  border-bottom-color: var(--bevel-lo);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.1em;
  cursor: pointer;
}
.archive__more:hover { background: #221e19; }

/* The same .archive__pager / .archive__more pair, reused verbatim for the
   KMHD calendar in Pane B -- the point is that the two collapse controls on
   this site look and behave identically, so they share the rules rather than
   getting a parallel set that can drift.

   The one difference is width. Pane G's pager sits under a full-width table
   and the buttons look right at their natural size; Pane B is a narrow
   column, where two small buttons floating under a list read as debris.
   Letting them share the row makes them look deliberate. */
.calendar__more { flex: 1 1 auto; }

/* The J Dilla pane's full-cheat-sheet <details>, styled to match
   .archive__more's button language (same border/bevel/accent) so the two
   "reveal more of this list" affordances on the site read as one pattern.
   list-style: none plus the marker rules below hide the browser's own
   disclosure triangle -- the summary text already ends in "▾", and a
   native marker beside it would be a second, redundant triangle. */
.archive__csv { margin: 0 0 10px; }
.archive__csv summary {
  display: inline-block;
  list-style: none;
  padding: 7px 13px;
  background: var(--panel);
  border: 1px solid var(--bevel-hi);
  border-bottom-color: var(--bevel-lo);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.1em;
  cursor: pointer;
}
.archive__csv summary::-webkit-details-marker { display: none; }
.archive__csv summary::marker { content: ''; }
.archive__csv summary:hover { background: #221e19; }
.archive__csv[open] summary { margin-bottom: 8px; }

@media (max-width: 900px) {
  .archive__controls { flex-direction: column; align-items: stretch; }
  .archive__controls select { width: 100%; }

  /* Resets the `flex: 1 1 220px` above, and this is the whole fix for the
     190px of dead space that used to sit between the search box and the era
     select on every phone and narrow window.

     flex-basis is along the MAIN axis, and the rule above turns that axis
     vertical. So `1 1 220px` — written for the row layout, where it means
     "220px wide, allowed to grow" — silently became "220px TALL" the moment
     the container went to column, giving a 30px input a 220px box to sit in.
     `flex-grow: 1` then kept it there. Nothing looked wrong in the row
     layout, which is why it survived: the bug only exists below 900px.

     Sizing off content is what the selects beside it already do. */
  .archive__search { flex: 0 0 auto; }
}

/* ── Pane G: SoundCloud playback ──────────────────────────────────────── */

/* Row transport. Sized and coloured like the tab strip rather than like a
   primary button: there are up to sixty of these on screen at once, and at
   button weight they would out-shout the titles they belong to. */
.archive__play {
  padding: 1px 6px;
  background: var(--inset);
  border: 1px solid var(--bevel-hi);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.4;
  cursor: pointer;
}
.archive__play:hover { background: var(--panel); color: var(--text); }

/* Rows that play from the Internet Archive rather than from SoundCloud. Amber
   rather than green, and that is the whole treatment — colour is never the
   only carrier here: the button's own title says "Play from the Internet
   Archive" and its aria-label names the source too, so nothing is lost to
   anyone who cannot see the difference. */
.archive__play--ia { color: var(--amber); }

/* The playing row. A left border and a background lift rather than a colour
   change on the text, so the row stays exactly as readable as its neighbours
   and the 7:1 floor is untouched. */
.archive__body tr[aria-current="true"],
tr[aria-current="true"] {
  background: var(--inset);
  box-shadow: inset 2px 0 0 var(--accent);
}

.archive__title-link {
  text-decoration: none;
}
.archive__title-link:hover { text-decoration: underline; }

/* The player strip, pinned under the table. Sticky so it stays reachable
   while scrolling a sixty-row page — stopping playback should never require
   hunting for the control that started it. */
.archive__player {
  position: sticky;
  bottom: 0;
  margin-top: 10px;
  padding: 8px 10px;
  background: var(--inset);
  border: 1px solid var(--bevel-lo);
  border-top-color: var(--bevel-hi);
  border-left: 2px solid var(--accent);
}

.archive__player-head {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 10px;
  align-items: baseline;
}

.archive__player-label {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  color: var(--accent);
}

.archive__player-title {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 12px;
  color: var(--text);
  overflow-wrap: anywhere;
}

.archive__player-out {
  font-family: var(--font-mono);
  font-size: 10px;
}

.archive__player-stop {
  padding: 2px 8px;
  background: var(--panel);
  border: 1px solid var(--bevel-hi);
  color: var(--hot);
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.1em;
  cursor: pointer;
}

/* The widget is a fixed 20px transport bar. Given its own box so the iframe
   cannot inherit a border or padding that would clip it. */
.archive__player-frame { margin-top: 7px; }
.archive__iframe {
  display: block;
  width: 100%;
  height: 20px;
  border: 0;
}

/* The Internet Archive fallback: a native <audio> rather than a second framed
   origin. Its transport is drawn by the browser and cannot be restyled to
   match the pane, so it is deliberately left alone rather than half-skinned —
   a native control the user's platform already renders accessibly beats a
   convincing-looking one that is not. Only the box around it is ours. */
.archive__audio {
  display: block;
  width: 100%;
  /* Matches the SoundCloud strip's height so the pane does not jump by 34px
     depending on which source the clicked row happened to use. */
  height: 32px;
  /* Without this the browser draws its native transport in LIGHT mode — a
     near-white bar dropped into a pane that is otherwise #100d14 — because
     nothing else on this page declares a scheme and the default is `normal`.
     This is the supported way to ask for the dark rendering: an invert()
     filter would also darken it, but by mangling the icons, the focus ring
     and the progress fill along with it. */
  color-scheme: dark;
}

.archive__player-error {
  margin: 7px 0 0;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.5;
  color: var(--hot);
}

/* Pane I: the two Nujabes-to-Dilla tribute-magazine scans, shown side by side
   and capped well under their native size (1080x1397 and 640x797) rather than
   at full bleed. flex-basis lets the narrower second scan sit at a smaller
   box than the first instead of both being stretched to match. */
.jdilla__images {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px;
  margin: 0 0 10px;
}
.jdilla__images img {
  flex: 1 1 160px;
  max-width: 220px;
  height: auto;
  border: 1px solid var(--bevel-lo);
}

/* One-off trivia callout, boxed like the search/sort inputs above it
   (--inset fill, --bevel-lo border, black top edge) rather than the quiet
   dim-text .archive__credit treatment — this is the "fun fact" the request
   asked to stand out, not standing citation text. */
.jdilla__trivia {
  margin: 0 0 10px;
  padding: 8px 10px;
  background: var(--inset);
  border: 1px solid var(--bevel-lo);
  border-top-color: #000;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.5;
  color: var(--dim);
}
.jdilla__trivia strong { color: var(--text); }

/* ── colour roles ─────────────────────────────────────────────────────────
   Side-by-side metadata ("476 items · 32/32 sources", "1994 · 4:12 ·
   soundcloud") used to be one --dim string. Each part now carries its role;
   see the table in :root for what each role means and its measured ratio.
   Kept last in the file on purpose: these share one-class specificity with
   the containers they sit in (.show__meta, .archive__len, ...) and have to
   win on source order. */
.r-src    { color: var(--amber); }
.r-time   { color: var(--time); }
.r-who    { color: var(--byline); }
.r-count  { color: var(--count); }
.r-extent { color: var(--extent); }
.r-place  { color: var(--place); }
.r-genre  { color: var(--genre); }
.r-label  { color: var(--label); }
.r-ok     { color: var(--status-online); }
.r-warn   { color: var(--status-degraded); }
.r-sep    { color: var(--dim); }
.archive__len { color: var(--extent); }
.archive__era { color: var(--time); }
