:root{
    color-scheme: dark;
    /* ---- tokens: dark (default) ---- */
    --surface-0:#14100f;
    --surface-1:#1c1615;
    --surface-2:#241c1a;
    --surface-3:#2c2320;
    --surface-hover: rgba(243,236,228,0.05);
    --outline: rgba(243,236,228,0.10);
    --outline-strong: rgba(243,236,228,0.18);
    --ink:#f3ece4;
    --ink-dim:#b7a99e;
    --ink-faint:#7d7168;
    --accent:#e14a2e;        /* on-air red */
    --accent-ink:#fff5f2;
    --accent-2:#4fb6ac;      /* tuning teal */
    --accent-2-ink:#06201d;
    --good:#6fae7c;
    --good-bg: rgba(111,174,124,0.14);
    --warn:#e0a63a;
    --warn-bg: rgba(224,166,58,0.14);
    --danger:#e8604a;
    --danger-bg: rgba(232,96,74,0.14);
    --shadow: 0 1px 2px rgba(0,0,0,0.5), 0 4px 16px rgba(0,0,0,0.35);
    /* raised-card elevation: top highlight rim + contact shadow + soft ambient */
    --elev-1: inset 0 1px 0 rgba(255,255,255,0.05), 0 1px 2px rgba(0,0,0,0.45), 0 6px 16px -4px rgba(0,0,0,0.55);
    --elev-2: inset 0 1px 0 rgba(255,255,255,0.07), 0 2px 4px rgba(0,0,0,0.45), 0 14px 30px -8px rgba(0,0,0,0.6);
    --font-display: 'Arial Narrow', 'Helvetica Neue Condensed', 'Roboto Condensed', sans-serif;
    --font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    --font-mono: ui-monospace, 'SF Mono', 'Cascadia Code', 'Roboto Mono', monospace;
    /* How far below the top edge the phone dialogs stop, and the corner radius
       that goes with it. Shared by the info sheet, the live player and the
       donate modal so all three sit at exactly the same height — they swap
       places often enough that any difference reads as a jump. Not
       theme-dependent, so declared once. */
    --phone-sheet-gap: 2.25rem;
    --phone-sheet-radius: 20px;
    /* ---- overlay motion ----
       Every dialog in here (info sheet, live player, donate, schedule, on-air
       chooser) shares one recipe, and it is shared through these tokens rather
       than by copying values — the five of them swap places often enough that
       any difference in feel reads as a bug. Change them here, all five move.

       Arrival and dismissal are deliberately NOT symmetric. An arrival is
       something you watch, so it is slower and lands on a spring; a dismissal
       is something you want out of the way, so it is faster and accelerates
       out. They used to be the same .22s ease in both directions, which is why
       opening read as a snap rather than a present.

       --ease-panel is a real damped spring (k=180, c=21, m=1 → ζ≈0.78,
       ~1.4% overshoot), sampled to 22 points. That tiny overshoot is the whole
       difference between "CSS eased" and "native": the panel arrives, passes
       its mark by a hair, and settles. Guarded by @supports below because a
       linear() the browser cannot parse would poison the whole `transition`
       shorthand — an unparseable var() in a shorthand unsets *every* longhand,
       duration included, so the fallback has to be a real value and not a
       hope. Old engines get a stiff ease-out curve and lose only the overshoot. */
    --ease-panel: cubic-bezier(.22, 1, .36, 1);
    --ease-exit: cubic-bezier(.4, 0, 1, 1);
    /* The desktop panels start at translate(…,-43%) scale(.95) rather than the
       -46%/.97 they used to: measured, the old travel was 4% of the panel's
       height and 3% of its scale, and a 1.4% overshoot of a 3% range is 0.04%
       of scale — about 0.4px on a 700px card. The spring was genuinely running
       and genuinely invisible. Curve quality cannot rescue a distance that
       small; the arrival needed something to travel before any of this could be
       felt. Phones already travelled the full viewport and are unaffected.

       Panel in/out. Note the spring spends its last third settling, so .4s
       *feels* like about .25s — do not read these as the perceived length. */
    --ov-in: .4s;
    --ov-out: .2s;
    /* The scrim leads in and lags out: it reaches full dim before the panel has
       landed and is still there after the panel has gone, so the layer never
       flashes bare content at either end. */
    --ov-scrim-in: .26s;
    --ov-scrim-out: .24s;
    --ov-scrim-out-delay: .05s;
    /* The four transitions themselves, composed once. A custom property holds
       an arbitrary token stream, so `transition: var(--ov-panel-tr-in)` is a
       legal whole-shorthand substitution — which means the five dialogs cannot
       drift apart the way five hand-copied declarations quietly did.
       `visibility` is stepped at the END of the exit (0s delayed by the
       duration) so the panel stays hittable-but-leaving rather than vanishing
       on frame one; that part is inherited from the original and is load-
       bearing, not decoration. */
    --ov-panel-tr-in:
      transform var(--ov-in) var(--ease-panel),
      opacity var(--ov-in) ease;
    --ov-panel-tr-out:
      transform var(--ov-out) var(--ease-exit),
      opacity var(--ov-out) ease,
      visibility 0s linear var(--ov-out);
    --ov-scrim-tr-in:
      opacity var(--ov-scrim-in) ease,
      -webkit-backdrop-filter var(--ov-scrim-in) ease,
      backdrop-filter var(--ov-scrim-in) ease;
    --ov-scrim-tr-out:
      opacity var(--ov-scrim-out) ease var(--ov-scrim-out-delay),
      -webkit-backdrop-filter var(--ov-scrim-out) ease var(--ov-scrim-out-delay),
      backdrop-filter var(--ov-scrim-out) ease var(--ov-scrim-out-delay),
      visibility 0s linear calc(var(--ov-scrim-out) + var(--ov-scrim-out-delay));
    /* Content stagger: the panel lands, then its contents settle in behind it.
       The single cheapest thing in here that reads as "app" rather than "web". */
    --ov-stagger: .34s;
  }
  /* See the note above: only swap in the spring where linear() actually parses. */
  @supports (animation-timing-function: linear(0 0%, 1 100%)){
    :root{
      --ease-panel: linear(0.0058 0%, 0.1855 4.76%, 0.4467 9.52%, 0.6983 14.29%,
        0.8506 19.05%, 0.9417 23.81%, 0.9923 28.57%, 1.0096 33.33%, 1.0136 38.1%,
        1.0113 42.86%, 1.0078 47.62%, 1.0046 52.38%, 1.0022 57.14%, 1.0009 61.9%,
        1.0002 66.67%, 0.9999 71.43%, 0.9998 76.19%, 0.9998 80.95%, 0.9999 85.71%,
        0.9999 90.48%, 1 95.24%, 1 100%);
    }
  }
  /* Phones present these as full-height bottom sheets: the same box that moved
     4% of its height on desktop now travels the whole viewport, so it needs
     longer or it reads as a snap. Duration follows distance. Declared on :root
     so all five inherit it at once — this is the one that was actually wrong. */
  @media (max-width:560px){
    :root{ --ov-in:.46s; --ov-out:.26s; }
  }
  :root{
    /* The page's side gutter, shared by the hero, the sticky search bar, the
       result strip and the listing so their left and right edges line up all
       the way down. It is ONE token because the alignment is the whole point:
       thinning the listing on its own (which is where the pressure is, since
       the card grid pays this twice — once here, once in #rows) pushed the
       bordered listing box 7px wider than the search field above it, and a
       ragged edge is more noticeable than a wide one. */
    --page-gutter: 1rem;
    /* Which face the theme toggle is showing: 0 = moon (dark), 1 = sun (light).
       It is declared in all four theme blocks below, next to the colours, for
       one reason — the icon then resolves from the SAME cascade the palette
       does, on the very first paint. Deriving it from a JS-set class instead
       would mean the icon is briefly wrong on every load (app.js runs at the
       end of <body>) and wrong forever if JS fails. Ratios, not pixels, so the
       transforms below can interpolate straight off it. */
    --sun: 0;
  }

  @media (prefers-color-scheme: light){
    :root{
      color-scheme: light;
      --surface-0:#f6f1ea;
      --surface-1:#ffffff;
      --surface-2:#efe6da;
      --surface-3:#e6dacb;
      --surface-hover: rgba(30,20,15,0.04);
      --outline: rgba(30,20,15,0.10);
      --outline-strong: rgba(30,20,15,0.20);
      --ink:#231a15;
      --ink-dim:#5b4c42;
      --ink-faint:#8a7b6f;
      --accent:#c8371e;
      --accent-ink:#fff5f2;
      --accent-2:#0d7d72;
      --accent-2-ink:#eafffb;
      --good:#2f7a41;
      --good-bg: rgba(47,122,65,0.12);
      --warn:#a8730f;
      --warn-bg: rgba(168,115,15,0.14);
      --danger:#c8371e;
      --danger-bg: rgba(200,55,30,0.10);
      --shadow: 0 1px 2px rgba(30,20,10,0.08), 0 6px 20px rgba(30,20,10,0.08);
      --elev-1: inset 0 1px 0 rgba(255,255,255,0.55), 0 1px 2px rgba(30,20,10,0.10), 0 6px 16px -4px rgba(30,20,10,0.16);
      --elev-2: inset 0 1px 0 rgba(255,255,255,0.65), 0 2px 4px rgba(30,20,10,0.12), 0 14px 30px -8px rgba(30,20,10,0.22);
      --sun: 1;
    }
  }
  :root[data-theme="dark"]{
    color-scheme: dark;
    --surface-0:#14100f; --surface-1:#1c1615; --surface-2:#241c1a; --surface-3:#2c2320;
    --surface-hover: rgba(243,236,228,0.05);
    --outline: rgba(243,236,228,0.10); --outline-strong: rgba(243,236,228,0.18);
    --ink:#f3ece4; --ink-dim:#b7a99e; --ink-faint:#7d7168;
    --accent:#e14a2e; --accent-ink:#fff5f2; --accent-2:#4fb6ac; --accent-2-ink:#06201d;
    --good:#6fae7c; --good-bg: rgba(111,174,124,0.14);
    --warn:#e0a63a; --warn-bg: rgba(224,166,58,0.14);
    --danger:#e8604a; --danger-bg: rgba(232,96,74,0.14);
    --shadow: 0 1px 2px rgba(0,0,0,0.5), 0 4px 16px rgba(0,0,0,0.35);
    /* raised-card elevation: top highlight rim + contact shadow + soft ambient */
    --elev-1: inset 0 1px 0 rgba(255,255,255,0.05), 0 1px 2px rgba(0,0,0,0.45), 0 6px 16px -4px rgba(0,0,0,0.55);
    --elev-2: inset 0 1px 0 rgba(255,255,255,0.07), 0 2px 4px rgba(0,0,0,0.45), 0 14px 30px -8px rgba(0,0,0,0.6);
    --sun: 0;
  }
  :root[data-theme="light"]{
    color-scheme: light;
    --surface-0:#f6f1ea; --surface-1:#ffffff; --surface-2:#efe6da; --surface-3:#e6dacb;
    --surface-hover: rgba(30,20,15,0.04);
    --outline: rgba(30,20,15,0.10); --outline-strong: rgba(30,20,15,0.20);
    --ink:#231a15; --ink-dim:#5b4c42; --ink-faint:#8a7b6f;
    --accent:#c8371e; --accent-ink:#fff5f2; --accent-2:#0d7d72; --accent-2-ink:#eafffb;
    --good:#2f7a41; --good-bg: rgba(47,122,65,0.12);
    --warn:#a8730f; --warn-bg: rgba(168,115,15,0.14);
    --danger:#c8371e; --danger-bg: rgba(200,55,30,0.10);
    --shadow: 0 1px 2px rgba(30,20,10,0.08), 0 6px 20px rgba(30,20,10,0.08);
      --elev-1: inset 0 1px 0 rgba(255,255,255,0.55), 0 1px 2px rgba(30,20,10,0.10), 0 6px 16px -4px rgba(30,20,10,0.16);
      --elev-2: inset 0 1px 0 rgba(255,255,255,0.65), 0 2px 4px rgba(30,20,10,0.12), 0 14px 30px -8px rgba(30,20,10,0.22);
    --sun: 1;
  }

  /* Phones only — tablet and desktop keep the full 1rem, where the generous
     gutter is what makes the layout feel considered rather than cramped.
     ONE phone value, not a second tier under 400px: that tier was worth 6.4px
     against this 8.8px, and 2.4px of card width is not worth content sitting
     that close to the bezel. The narrow-phone tightening that does earn its
     keep is on the card grid's own padding and gap — see body.view-grid #rows
     near the end of this file. */
  @media (max-width:560px){ :root{ --page-gutter:.55rem; } }

  *{box-sizing:border-box;}
  html,body{margin:0;padding:0;}
  /* Contain horizontal overflow at the root. The off-canvas menu drawer is
     position:fixed and parked off the right edge (translateX(100%)); iOS Safari
     does NOT clip a fixed off-canvas box with body{overflow-x:hidden} alone, so
     the whole page became horizontally pannable ("content slightly off to the
     right"). `clip` (not `hidden`) contains it WITHOUT making html/body a scroll
     container, so the sticky appbar and normal vertical scroll are unaffected. */
  html{overflow-x:clip;}

  /* ---- Overlay scroll lock ----
     TRAP, and the reason `body{overflow:hidden}` alone was silently dead here:
     the viewport only inherits its overflow from <body> when the ROOT's overflow
     computes to `visible`. The `overflow-x:clip` above makes it not-visible (and
     per spec the other axis then computes from `visible` to `auto`), so <html>
     is the scroll container and body's `hidden` clips nothing — body's height is
     its content, so there is nothing to clip. Every overlay's "scroll lock" was
     a no-op and the listing scrolled behind all six of them.

     So lock the element that actually scrolls. `overflow:hidden` on the scroller
     preserves scrollTop, so nothing jumps to the top on open/close, and
     `overscroll-behavior:none` kills the chained rubber-band on iOS.

     Toggled by refreshOverlayState() in app.js, which reads the real .show state
     so nested overlays (lightbox over sheet, sheet handing off to the live
     player) never release the lock early. */
  html.scroll-lock{ overflow:hidden; overscroll-behavior:none; }
  /* Classic (non-overlay) scrollbars would otherwise vanish with the lock and
     shift the whole page ~15px sideways under the scrim. No-op on macOS/iOS. */
  html{ scrollbar-gutter:stable; }

  body{
    background:var(--surface-0);
    color:var(--ink);
    font-family:var(--font-body);
    -webkit-font-smoothing:antialiased;
    min-height:100dvh;   /* 100vh ignores the collapsing mobile URL bar */
    overflow-x:clip;
  }
  a{color:var(--accent-2);}
  button{font-family:inherit;}

  .skip-link{position:absolute;left:-9999px;top:0;background:var(--accent);color:var(--accent-ink);padding:.6rem 1rem;border-radius:0 0 8px 0;z-index:200;}
  .skip-link:focus{left:0;}

  /* off-canvas for sighted users, still read by assistive tech */
  .visually-hidden{
    position:absolute; width:1px; height:1px; margin:-1px; padding:0;
    overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0;
  }

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

  /* ================= Touch base layer =================
     See docs/touch-dev.md. This block is the interaction baseline for coarse
     pointers, and it is why every :hover rule below lives inside
     @media (hover:hover) and (pointer:fine) — on touch, hover is synthesized on
     tap and never cleared, so an unguarded hover rule stays stuck on whatever
     was last tapped. .play-btn and .card-play are the ones that actually lied:
     their hover fill is the same orange as the .playing state. */

  html{
    /* The UA paints an opaque rect over the border box on tap — a *square*,
       ignoring border-radius, which is why round buttons flashed square. */
    -webkit-tap-highlight-color: transparent;
    /* iOS inflates text on portrait -> landscape rotation without this. */
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;
  }

  /* manipulation = keep pan and pinch-zoom, drop double-tap-to-zoom. Two fast
     taps on ±15s or play/pause used to zoom the page instead of firing twice. */
  button, a, [role="button"], summary, label{ touch-action: manipulation; }
  /* The list row is a control too — the whole thing opens the info sheet (see
     the rows click handler in app.js) — so it needs the same double-tap-to-zoom
     suppression its buttons get, or two quick taps on the date line zoom. */
  .row.body{ touch-action: manipulation; }
  /* Sliders are dragged, so the page must not steal the gesture to pan. */
  .player-range, .lp-volume-range{ touch-action: none; }

  /* Long-press on a control should activate it, not start a text selection or
     pop the iOS callout. Scoped to controls — sheet copy stays selectable. */
  button, [role="button"], .show-open, .card-overlay, .player-info,
  /* .row.body for the same reason: its date line and retention pill are now
     part of the tap target, and a long-press there was selecting the date. */
  .row.body{
    -webkit-user-select: none; user-select: none;
    -webkit-touch-callout: none;
  }
  /* ...and long-press on artwork should not offer "Save Image". The lightbox is
     the intended way to view art; thumbnails are buttons. */
  .show-thumb img, .player-art img, .card-art img, .card-art{
    -webkit-touch-callout: none; -webkit-user-drag: none; user-select: none;
  }

  /* The @media (pointer:coarse) half of this layer lives at the END of the file.
     Media queries add no specificity, so source order is what decides — placed
     here, `.menu-btn{width:44px}` would lose to the plain `.menu-btn{width:42px}`
     declared 700 lines later. Overrides go last. */

  /* ---------------- App bar ---------------- */
  .appbar{
    position:sticky; top:0; z-index:50;
    /* apple-mobile-web-app-status-bar-style=black-translucent puts content
       under the status bar when installed; without this the bar sits in it. */
    padding-top:env(safe-area-inset-top, 0px);
    background:color-mix(in srgb, var(--surface-1) 92%, transparent);
    backdrop-filter:blur(10px);
    border-bottom:1px solid var(--outline);
    box-shadow:var(--shadow);
  }
  .appbar-inner{
    max-width:1180px; margin:0 auto;
    display:flex; align-items:center; gap:.9rem;
    padding:.7rem 1rem;
  }
  .brand{display:flex; align-items:center; gap:.6rem; text-decoration:none; color:var(--ink); flex:0 0 auto;}
  .brand-text{display:flex; flex-direction:column; align-items:flex-start; gap:.25rem; line-height:1.05;}
  /* Station wordmark: fixed height, width follows the 1024x248 source aspect. */
  .brand-logo{display:block; height:24px; width:auto; border-radius:3px;}
  .brand-name{font-family:var(--font-mono); font-variant-numeric:tabular-nums; text-transform:uppercase; letter-spacing:.06em; font-size:.62rem; color:var(--ink-dim);}

  /* ---------------- Hero logo lockup ---------------- */
  .hero-lockup{display:flex; flex-direction:column; align-items:flex-start; gap:.55rem;}
  .hero-logo{
    display:block; width:auto; border-radius:5px;
    height:clamp(46px, 3.4vw + 34px, 68px);
    box-shadow:var(--shadow);
  }
  .hero-sub{font-family:var(--font-mono); font-variant-numeric:tabular-nums; text-transform:uppercase; letter-spacing:.08em; font-size:.72rem; color:var(--accent-2);}

  @keyframes spin{ to{ transform:rotate(360deg); } }
  /* on-air pulse: reused by the appbar button dot and the modal badge dot */
  @keyframes pulse{
    0%{box-shadow:0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);}
    70%{box-shadow:0 0 0 8px color-mix(in srgb, var(--accent) 0%, transparent);}
    100%{box-shadow:0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);}
  }

  /* brand left, actions right — no more centred strip */
  .appbar-actions{display:flex; align-items:center; gap:.8rem; margin-left:auto;}

  /* ---- Theme switch ----
     Geometry AND surface are copied from .menu-btn deliberately: the two square
     icon buttons sit directly next to each other at the end of the action row,
     where any difference between them reads as a bug rather than a decision.
     They are identical at rest, on hover and on press — the hover/active rule
     below is shared by both, and the coarse-pointer block at the end of this
     file sizes them together. Change one, change the other. ---- */
  .theme-btn{
    width:42px; height:42px; border-radius:12px; flex:none;
    display:inline-flex; align-items:center; justify-content:center;
    background:var(--surface-3); border:1px solid var(--outline-strong);
    color:var(--ink); cursor:pointer; -webkit-tap-highlight-color:transparent;
    transition:background .15s ease, border-color .15s ease, color .15s ease,
               box-shadow .18s ease, transform .18s ease;
  }
  /* Shared hover for both icon chips. Declared here, next to .theme-btn, even
     though .menu-btn is defined ~700 lines down: they have to move together, and
     splitting the rule is how they drift apart.

     The effect is a colour SHIFT rather than the old brightness bump — the chip
     and its glyph both pick up the tuning teal, and it lifts on the same -1px /
     elev-2 the Listen Live and Donate pills use, so the whole action row
     responds as one system instead of four unrelated buttons. */
  @media (hover:hover) and (pointer:fine){
    .theme-btn:hover, .menu-btn:hover{
      background:color-mix(in srgb, var(--accent-2) 12%, var(--surface-3));
      border-color:var(--accent-2);
      color:var(--accent-2);
      box-shadow:var(--elev-2);
      transform:translateY(-1px);
    }
  }
  /* translateY(0) is load-bearing, not redundant: :active fires while :hover is
     still matching, so without it the lift stays applied and the press reads as
     a shrink in mid-air rather than a button going down. Same reason
     .on-air-btn:active spells it out. */
  .theme-btn:active, .menu-btn:active{transform:translateY(0) scale(.94);}
  .theme-btn:focus-visible{outline:2px solid var(--accent-2); outline-offset:2px;}
  /* overflow:visible so the rays aren't clipped by the 24-unit box mid-animation */
  .theme-icon{width:22px; height:22px; display:block; overflow:visible;}

  /* The three moving parts, all interpolating off --sun (0 = moon, 1 = sun).
     var() resolves at computed-value time, so `transform` genuinely transitions
     when the token flips — the custom property itself is not what animates. */
  .theme-icon-orb{
    fill:currentColor;
    transform-box:fill-box; transform-origin:center;
    /* full disc for the moon, shrunk to a core for the sun so the rays have room */
    transform:scale(calc(1 - var(--sun) * .4));
    transition:transform .45s cubic-bezier(.34,1.4,.5,1);
  }
  /* the mask circle that carves the crescent — parked clear of the disc for the
     sun (the disc spans x 3-21; +12 puts this one's left edge at 22) */
  .theme-icon-bite{
    transform:translateX(calc(var(--sun) * 12px));
    transition:transform .45s cubic-bezier(.4,0,.2,1);
  }
  .theme-icon-rays{
    stroke:currentColor; stroke-width:2; stroke-linecap:round; fill:none;
    transform-box:fill-box; transform-origin:center;
    transform:scale(var(--sun)) rotate(calc((1 - var(--sun)) * -50deg));
    opacity:var(--sun);
    transition:transform .45s cubic-bezier(.34,1.4,.5,1), opacity .3s ease;
  }
  @media (prefers-reduced-motion:reduce){
    .theme-btn, .menu-btn{transition:none;}
    .theme-btn:active, .menu-btn:active{transform:none;}
    @media (hover:hover) and (pointer:fine){
      /* keep the colour shift, drop the lift */
      .theme-btn:hover, .menu-btn:hover{transform:none;}
    }
    .theme-icon-orb, .theme-icon-bite, .theme-icon-rays{transition:none;}
  }

  /* ---- On Air button: a compact live badge that opens the modal player.
     Built to sit as a peer of the Donate pill (same geometry + sheen), but in a
     dark on-air-family surface so Donate stays the one loud filled button. ---- */
  .on-air-btn{
    position:relative; overflow:hidden; isolation:isolate;
    display:inline-flex; align-items:center; gap:.5rem;
    padding:.4rem .85rem; cursor:pointer;
    font-family:inherit; color:var(--ink);
    border:1px solid var(--outline-strong); border-radius:999px;
    background:linear-gradient(180deg, color-mix(in srgb, var(--ink) 6%, var(--surface-3)), var(--surface-3));
    box-shadow:var(--elev-1), inset 0 1px 0 rgba(255,255,255,.06);
    transition:background .15s ease, box-shadow .18s ease, transform .18s ease, border-color .15s ease;
  }
  .on-air-btn::after{
    content:""; position:absolute; inset:0; z-index:-1;
    background:linear-gradient(105deg, transparent 35%, rgba(255,255,255,.16) 50%, transparent 65%);
    transform:translateX(-130%); transition:transform .55s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .on-air-btn:hover{background:linear-gradient(180deg, color-mix(in srgb, var(--ink) 11%, var(--surface-3)), color-mix(in srgb, var(--ink) 4%, var(--surface-3)));
    border-color:color-mix(in srgb, var(--accent) 40%, var(--outline-strong));
    box-shadow:var(--elev-2), inset 0 1px 0 rgba(255,255,255,.08);
    transform:translateY(-1px);}
  }
  @media (hover:hover) and (pointer:fine){
    .on-air-btn:hover::after{transform:translateX(130%);}
  }
  .on-air-btn:active{transform:translateY(0) scale(.98);}
  .on-air-dot{
    width:8px;height:8px;border-radius:50%;flex:none;background:var(--accent);
    box-shadow:0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
    animation:pulse 1.8s infinite;
  }
  .on-air-label{
    font-family:var(--font-display); font-weight:800; font-size:.72rem;
    text-transform:uppercase; letter-spacing:.09em; line-height:1;
  }
  /* Schedule's stand-in for the dot — a calendar glyph, not a status light.
     The negative block margin is load-bearing, not spacing tinkering: at 15px
     the glyph is taller than the label's 11.5px line box, so as the tallest
     flex child it was setting the button's height and Schedule stood 29.8px
     against Listen Live and Donate at 26.3px. Pulling 2px off each side lets
     the LABEL govern the height here, exactly as it does in every other chip
     in this row, without shrinking the glyph to do it. The dot (8px) and the
     eq bars (12px) never hit this — they already fit inside the line box.
     Well within the 6.4px padding, so nothing clips. */
  .sched-open-ico{ width:15px; height:15px; flex:none; margin-block:-2px; }
  /* three-bar equaliser: shows only while the stream is playing */
  .on-air-eq{display:none; align-items:flex-end; gap:2px; height:12px; margin-left:.1rem;}
  .on-air-eq i{width:2.5px; background:var(--accent); border-radius:1px; height:40%; animation:eqbar .9s ease-in-out infinite;}
  .on-air-eq i:nth-child(2){animation-delay:.25s;}
  .on-air-eq i:nth-child(3){animation-delay:.5s;}
  .on-air-btn.playing .on-air-eq{display:inline-flex;}
  @keyframes eqbar{ 0%,100%{height:35%;} 50%{height:100%;} }
  @media (prefers-reduced-motion:reduce){
    .on-air-btn{transform:none;}
    @media (hover:hover) and (pointer:fine){
      .on-air-btn:hover{transform:none;}
    }
    .on-air-btn::after{display:none;}
    .on-air-dot{animation:none;}
    .on-air-eq i{animation:none; height:70%;}
  }
  @media (max-width:480px){
    .on-air-btn{padding:.38rem .7rem;}
    .on-air-label{font-size:.68rem; letter-spacing:.07em;}
    /* An icon-only Schedule pill here tested unreadable — a bare glyph in the
       header gives a first-time visitor nothing to go on, and this row is
       about to gain more entries than one calendar icon can carry anyway.
       Schedule stays reachable from the drawer only, on phones, until there's
       real navigation design for the rest of what's coming. */
    .sched-open-btn{display:none;}
  }

  /* ================= Live player modal ================= */
  /* Blur starts at 0 and animates up with the dim. At full strength from frame
     one (which is what it used to be) the background snapped to blurred and
     *then* darkened — two separate events for one gesture, and the layer read
     as flat. The var() resolves at computed-value time, so the two states are
     genuinely blur(0px) and blur(6px) and `backdrop-filter` really does
     interpolate; same mechanism the theme icon relies on further up. */
  .live-scrim{
    position:fixed; inset:0; z-index:170;
    background:color-mix(in srgb, #000 66%, transparent);
    -webkit-backdrop-filter:blur(0px); backdrop-filter:blur(0px);
    opacity:0; visibility:hidden; pointer-events:none;
    transition:var(--ov-scrim-tr-out);
  }
  .live-scrim.show{
    opacity:1; visibility:visible; pointer-events:auto;
    -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px);
    transition:var(--ov-scrim-tr-in);
  }

  .live-player{
    position:fixed; z-index:180; left:50%; top:50%;
    overscroll-behavior:contain;
    width:min(460px, 92vw); max-height:min(88dvh, 720px);
    display:flex; flex-direction:column; align-items:center; gap:1rem; overflow-y:auto;
    padding:2.2rem 1.8rem 1.6rem;
    background:
      radial-gradient(120% 60% at 50% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 60%),
      var(--surface-1);
    color:var(--ink);
    border:1px solid var(--outline-strong); border-radius:20px;
    box-shadow:0 30px 70px -20px rgba(0,0,0,.7), var(--elev-2);
    transform:translate(-50%, -43%) scale(.95); opacity:0; visibility:hidden;
    transition:var(--ov-panel-tr-out);
  }
  .live-player.show{
    transform:translate(-50%, -50%) scale(1); opacity:1; visibility:visible;
    transition:var(--ov-panel-tr-in);
  }
  /* Minimize: closing while the stream is running does not stop it, it hands it
     to the docked player bar. So the card collapses *toward* the bar instead of
     fading in place — the eye follows the audio to where it now lives. app.js
     measures the gap between the two centres into --min-dy before adding the
     class, and strips the class once the run is over. Must sit after .show so it
     wins at equal specificity while both classes are briefly on the element.
     The info sheet reuses the same run — see .sheet.minimizing. */
  .live-player.minimizing{
    transform:translate(-50%, calc(-50% + var(--min-dy, 40vh))) scale(.28);
    opacity:0; visibility:visible; pointer-events:none;   /* in flight, not a target */
    transition:transform .34s cubic-bezier(.4,0,.75,.4), opacity .28s ease .06s;
  }
  @media (prefers-reduced-motion:reduce){
    .live-scrim, .live-player{ transition-duration:0s; transition-delay:0s; }
    .live-player{ transform:translate(-50%,-50%) scale(1); }
    .live-player.minimizing{ transition-duration:0s; }
  }

  .lp-badge{
    display:flex; align-items:center; gap:.45rem;
    font-family:var(--font-display); text-transform:uppercase; letter-spacing:.1em;
    font-size:.68rem; font-weight:800; color:var(--accent);
  }
  /* Live identifies the source at rest; motion identifies actual playback. */
  .lp-badge .on-air-dot{ animation:none; }
  .lp-badge.is-playing .on-air-dot{ animation:pulse 1.8s infinite; }
  @media (prefers-reduced-motion:reduce){ .lp-badge.is-playing .on-air-dot{ animation:none; } }
  .lp-art{
    position:relative;
    width:min(300px, 64vw); aspect-ratio:1; border-radius:14px; overflow:hidden; flex:none;
    background:url("/assets/kpfk-artwork-placeholder.svg") center/cover no-repeat;
    box-shadow:0 18px 44px -16px color-mix(in srgb, var(--accent) 40%, rgba(0,0,0,.7));
  }
  .lp-art img{width:100%;height:100%;object-fit:cover; display:none;}
  .lp-art img.loaded{display:block;}
  /* The tile is a <button> (it opens the show's description), so undo the parts
     of that a button brings with it. Nothing about its resting appearance
     changes — the centred stack is the design, and a control that announces
     itself in the corner of the art was the thing that broke it. */
  button.lp-art{
    padding:0; border:none; display:block; cursor:pointer;
    -webkit-appearance:none; appearance:none;
    font:inherit; color:inherit; text-align:inherit;
  }
  button.lp-art:disabled{cursor:default;}      /* nothing to show for this one */
  button.lp-art:focus-visible{outline:2px solid var(--accent); outline-offset:3px;}
  /* The hint: a small glass pill that names the affordance, and only where there
     is a pointer to name it to. Quick in (.14s), so it reads as a response to
     the cursor rather than an animation. */
  .lp-art-hint{
    position:absolute; left:50%; bottom:10px; z-index:1;
    display:inline-flex; align-items:center; gap:.32rem; white-space:nowrap;
    padding:.32rem .62rem; border-radius:999px;
    background:color-mix(in srgb, #000 62%, transparent);
    -webkit-backdrop-filter:blur(4px); backdrop-filter:blur(4px);
    border:1px solid rgba(255,255,255,.24); color:#fff;
    font-family:var(--font-display); text-transform:uppercase;
    letter-spacing:.08em; font-size:.62rem; font-weight:800;
    opacity:0; pointer-events:none;
    transform:translate(-50%, 6px);
    transition:opacity .14s ease, transform .14s ease;
  }
  .lp-art-hint svg{width:13px; height:13px;}
  @media (hover:hover) and (pointer:fine){
    button.lp-art:hover:not(:disabled) .lp-art-hint{opacity:1; transform:translate(-50%, 0);}
    /* a touch of dim under the pill, so the whole tile reads as the target */
    button.lp-art:hover:not(:disabled) img{filter:brightness(.88);}
  }
  /* Keyboard reaches it on every device, hover or not. */
  button.lp-art:focus-visible:not(:disabled) .lp-art-hint{opacity:1; transform:translate(-50%, 0);}
  @media (prefers-reduced-motion:reduce){
    .lp-art-hint{transition-duration:.01s; transform:translate(-50%, 0);}
  }
  /* The column that holds everything except the badge and the artwork. At this
     width it is a pass-through: a centred flex column carrying the parent's own
     1rem rhythm, so the phone sheet renders exactly the stack it always did.
     The two-column card at 561px up is what it exists for. */
  .lp-main{
    display:flex; flex-direction:column; align-items:center; gap:1rem;
    width:100%; min-width:0;
  }
  .lp-meta{text-align:center; min-width:0; width:100%;}
  /* Show names, hosts and track titles are written by other people and some of
     them are long — "with Ken Gale, Donna Stein, Sally Gellert" is a real one.
     Each of these wraps to a stated number of lines and then ellipsises, rather
     than either truncating at one line or growing the card without limit. */
  .lp-title{
    font-family:var(--font-display); font-weight:800; font-size:1.5rem; line-height:1.15; margin:0;
    overflow-wrap:break-word;
    display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:3; overflow:hidden;
  }
  .lp-host{
    margin:.3rem 0 0; font-size:.9rem; color:var(--ink-dim); overflow-wrap:break-word;
    display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden;
  }
  .lp-times{margin:.25rem 0 0; font-family:var(--font-mono); font-variant-numeric:tabular-nums; font-size:.78rem; color:var(--ink-faint); letter-spacing:.02em;}
  .lp-host[hidden], .lp-times[hidden], .lp-archive[hidden], .lp-upnext[hidden]{display:none;}
  .lp-archive{
    min-height:36px; margin:.55rem auto 0; padding:.35rem .7rem;
    display:inline-flex; align-items:center; gap:.35rem;
    border:1px solid var(--outline); border-radius:999px;
    background:var(--surface-2); color:var(--ink-dim); cursor:pointer;
    font:700 .76rem/1 var(--font-body);
  }
  .lp-archive-count{
    min-width:1.3rem; padding:.12rem .38rem; border-radius:999px;
    background:var(--surface-3); color:var(--ink); font-family:var(--font-mono); font-size:.68rem;
  }
  .lp-archive svg{ width:14px; height:14px; color:var(--accent-2); }
  @media (hover:hover) and (pointer:fine){
    .lp-archive:hover{ border-color:var(--outline-strong); color:var(--ink); background:var(--surface-hover); }
  }

  .lp-transport{display:flex; align-items:center; gap:1.1rem; width:100%; max-width:320px; justify-content:center;}
  .lp-toggle{
    position:relative; width:64px; height:64px; border-radius:50%; flex:none; cursor:pointer;
    border:none; background:var(--accent); color:var(--accent-ink);
    display:flex; align-items:center; justify-content:center;
    box-shadow:0 6px 18px color-mix(in srgb, var(--accent) 45%, transparent);
    transition:transform .12s ease, background .15s ease, box-shadow .18s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .lp-toggle:hover{background:color-mix(in srgb, var(--accent) 90%, white); transform:scale(1.05);}
  }
  .lp-toggle:active{ transform:scale(.98); }
  .lp-toggle svg{fill:currentColor; margin-left:2px;}
  .lp-toggle.playing svg{margin-left:0;}
  .lp-toggle.loading svg{display:none;}
  .lp-toggle .spinner{display:none;}
  .lp-toggle.loading .spinner{
    display:block; width:26px;height:26px; border-radius:50%;
    border:3px solid rgba(255,255,255,.35); border-top-color:#fff;
    animation:spin .7s linear infinite;
  }
  @media (prefers-reduced-motion:reduce){
    .lp-toggle{transform:none;}
    @media (hover:hover) and (pointer:fine){
      .lp-toggle:hover{transform:none;}
    }
    .lp-toggle.loading .spinner{animation-duration:1.4s;}
  }
  /* muted, slim volume — neutral track + light thumb, no loud accent fill */
  .lp-volume{display:flex; align-items:center; gap:.55rem; color:var(--ink-faint); flex:1 1 auto; max-width:140px;}
  .lp-volume svg{width:15px; height:15px; flex:none;}
  .lp-volume-range{
    -webkit-appearance:none; appearance:none;
    flex:1 1 auto; min-width:0; height:14px; margin:0; background:transparent; cursor:pointer;
  }
  .lp-volume-range::-webkit-slider-runnable-track{
    height:3px; border-radius:99px;
    background:linear-gradient(to right,
      var(--ink-dim) 0 calc(var(--pct,100) * 1%),
      var(--outline-strong) calc(var(--pct,100) * 1%) 100%);
  }
  .lp-volume-range::-moz-range-track{height:3px; border-radius:99px; background:var(--outline-strong);}
  .lp-volume-range::-moz-range-progress{height:3px; border-radius:99px; background:var(--ink-dim);}
  .lp-volume-range::-webkit-slider-thumb{
    -webkit-appearance:none; appearance:none;
    width:11px; height:11px; margin-top:-4px; border-radius:50%;
    background:var(--ink); box-shadow:0 1px 2px rgba(0,0,0,.4);
    transition:transform .12s ease;
  }
  .lp-volume-range::-moz-range-thumb{width:11px; height:11px; border:none; border-radius:50%; background:var(--ink);}
  @media (hover:hover) and (pointer:fine){
    .lp-volume-range:hover::-webkit-slider-thumb{transform:scale(1.15);}
  }
  .lp-volume-range:focus-visible{outline:none;}
  .lp-volume-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 4px color-mix(in srgb, var(--ink) 22%, transparent);}
  .lp-volume[hidden]{display:none;}

  .lp-upnext{
    min-height:44px; margin:0; font-size:.8rem; color:var(--ink-dim); text-align:center;
    padding:.4rem .8rem; border-radius:999px; background:var(--surface-2); border:1px solid var(--outline);
    display:inline-flex; align-items:center; justify-content:center; gap:.35rem; font-family:inherit;
  }
  .lp-upnext:not(:disabled){ cursor:pointer; }
  .lp-upnext:disabled{ opacity:1; cursor:default; }
  .lp-upnext-label{font-family:var(--font-display); text-transform:uppercase; letter-spacing:.08em; font-size:.66rem; font-weight:800; color:var(--accent-2); flex:none;}
  .lp-upnext-text{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
  .lp-upnext-arrow{ width:14px; height:14px; color:var(--accent-2); }
  .lp-upnext:disabled .lp-upnext-arrow{ display:none; }
  @media (hover:hover) and (pointer:fine){
    .lp-upnext:not(:disabled):hover{ border-color:var(--outline-strong); color:var(--ink); background:var(--surface-hover); }
  }

  /* now-playing track: a plain line under Up next, present only while the feed
     carries a song/artist */
  /* A flex line, not inline text: the clamped title below needs to be a block
     to clamp at all, and as plain inline content that would drop the NOW PLAYING
     label onto a line of its own. `[hidden]` still wins — class+attribute
     outranks the class alone — so hiding it keeps working. */
  .lp-song{
    margin:0; font-size:.86rem; color:var(--ink); line-height:1.3; min-width:0;
    display:flex; align-items:baseline; justify-content:center; gap:.4rem;
  }
  .lp-song[hidden]{display:none;}
  /* Track titles run long more often than show names do. Two lines, then stop. */
  .lp-song-text{
    min-width:0; overflow-wrap:break-word;
    display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden;
  }
  .lp-song-label{
    font-family:var(--font-display); text-transform:uppercase; letter-spacing:.08em;
    font-size:.64rem; font-weight:800; color:var(--accent); flex:none;
  }

  .lp-note{margin:0; font-size:.8rem; color:var(--ink-faint); text-align:center; min-height:1.1em;}
  .live-player.errored .lp-volume{display:none;}

  /* ---- stream-failure alert ----------------------------------------------
     Its own dialog layer over the live player, not a card inside it: the player
     is a fixed, transformed, scrolling box, so a child gets clipped at its edge
     and the retry buttons ended up below the fold at every window size. Danger-
     tinted so it reads as a problem at a glance — one-line title, a plain-
     language cause, and two ways forward. */
  .lp-alert-scrim{
    position:fixed; inset:0; z-index:185;
    background:color-mix(in srgb, #000 46%, transparent);
    animation:lpScrimIn .2s ease both;
  }
  .lp-alert-scrim[hidden]{display:none;}
  @keyframes lpScrimIn{ from{opacity:0;} to{opacity:1;} }
  .lp-alert{
    position:fixed; z-index:190; left:50%; top:50%;
    overscroll-behavior:contain;
    width:min(420px, 92vw); max-height:min(86dvh, 560px); overflow-y:auto;
    display:flex; align-items:flex-start; gap:.8rem;
    padding:1.15rem 1.2rem; border-radius:16px; text-align:left;
    color:var(--ink);
    background:
      linear-gradient(180deg, color-mix(in srgb, var(--danger) 12%, transparent), transparent 70%),
      var(--surface-2);
    border:1px solid color-mix(in srgb, var(--danger) 38%, var(--outline-strong));
    box-shadow:0 30px 70px -20px rgba(0,0,0,.7), var(--elev-2);
    animation:lpAlertIn .34s var(--ease-panel) both;
  }
  .lp-alert[hidden]{display:none;}
  /* the centring transform has to be carried through the keyframes, or the
     end state (transform:none) would fling the dialog off-centre */
  /* Travel widened from 8px/.98 for the same reason the big panels were: a
     spring needs distance or its character never reaches the screen. */
  @keyframes lpAlertIn{
    from{opacity:0; transform:translate(-50%,-50%) translateY(13px) scale(.96);}
    to{opacity:1; transform:translate(-50%,-50%) scale(1);}
  }
  .lp-alert-icon{
    flex:none; width:32px; height:32px; border-radius:50%;
    display:flex; align-items:center; justify-content:center;
    color:var(--danger); background:var(--danger-bg);
  }
  .lp-alert-icon svg{width:18px; height:18px;}
  .lp-alert-body{flex:1 1 auto; min-width:0;}
  .lp-alert-title{
    margin:0; font-family:var(--font-display); font-weight:800;
    font-size:.98rem; letter-spacing:.01em; line-height:1.2;
    color:color-mix(in srgb, var(--danger) 78%, var(--ink));
  }
  .lp-alert-text{margin:.28rem 0 0; font-size:.82rem; line-height:1.42; color:var(--ink-dim);}
  .lp-alert-text:empty{display:none;}
  .lp-alert-actions{display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-top:.75rem;}
  .lp-alert-btn{
    display:inline-flex; align-items:center; gap:.35rem; cursor:pointer;
    padding:.42rem .8rem; border-radius:999px; text-decoration:none; white-space:nowrap;
    font-family:var(--font-display); font-size:.78rem; font-weight:800;
    letter-spacing:.03em; text-transform:uppercase;
    border:1px solid var(--outline-strong); background:transparent; color:var(--ink-dim);
    transition:background .15s ease, color .15s ease, border-color .15s ease, transform .12s ease;
  }
  .lp-alert-btn svg{width:14px; height:14px; flex:none;}
  @media (hover:hover) and (pointer:fine){
    .lp-alert-btn:hover{background:var(--surface-hover); color:var(--ink); border-color:var(--ink-faint);}
  }
  .lp-alert-btn:active{transform:scale(.97);}
  .lp-alert-btn-primary{
    background:var(--danger); border-color:transparent; color:var(--accent-ink);
    box-shadow:0 4px 12px -4px color-mix(in srgb, var(--danger) 60%, transparent);
  }
  @media (hover:hover) and (pointer:fine){
    .lp-alert-btn-primary:hover{background:color-mix(in srgb, var(--danger) 88%, white);
    color:var(--accent-ink); border-color:transparent;}
  }
  .lp-alert-dismiss{
    flex:none; width:26px; height:26px; margin:-.15rem -.25rem 0 0; padding:0;
    display:flex; align-items:center; justify-content:center;
    border:none; border-radius:50%; cursor:pointer;
    background:transparent; color:var(--ink-faint);
    transition:background .15s ease, color .15s ease;
  }
  .lp-alert-dismiss svg{width:13px; height:13px;}
  @media (hover:hover) and (pointer:fine){
    .lp-alert-dismiss:hover{background:var(--surface-hover); color:var(--ink);}
  }
  /* the alert carries the whole message when it is up — no duplicate note line */
  .live-player.errored .lp-note{display:none;}
  @media (prefers-reduced-motion:reduce){
    .lp-alert, .lp-alert-scrim{animation-duration:.01s;}
    .lp-alert-btn:active{transform:none;}
  }

  /* ---- "About this show" panel ------------------------------------------
     Same layered-dialog construction as the alert (own scrim, own focus trap,
     lives outside the player so nothing clips it), but neutral rather than
     danger-tinted, and it owns its own scroll: a description can run to a dozen
     paragraphs. It sits UNDER the alert's z-index on purpose — if the stream
     fails while this is open, the failure is the more urgent thing. */
  .lp-info-scrim{
    position:fixed; inset:0; z-index:182;
    background:color-mix(in srgb, #000 50%, transparent);
    animation:lpScrimIn .2s ease both;
  }
  .lp-info-scrim[hidden]{display:none;}
  .lp-info{
    position:fixed; z-index:183; left:50%; top:50%;
    overscroll-behavior:contain;
    width:min(440px, 92vw); max-height:min(82dvh, 620px);
    /* stretch, not flex-start: the body has to inherit the height this box gets
       clamped to, or `overflow-y:auto` on it has nothing to overflow and a long
       description spills out of the panel instead of scrolling inside it. The
       close button opts out with align-self. */
    display:flex; align-items:stretch; gap:.5rem;
    padding:1.3rem 1.35rem; border-radius:18px; text-align:left;
    color:var(--ink);
    background:
      radial-gradient(120% 60% at 100% -10%, color-mix(in srgb, var(--accent-2) 14%, transparent), transparent 62%),
      var(--surface-2);
    border:1px solid var(--outline-strong);
    box-shadow:0 30px 70px -20px rgba(0,0,0,.7), var(--elev-2);
    animation:lpAlertIn .34s var(--ease-panel) both;
  }
  .lp-info[hidden]{display:none;}
  /* The scroll lives on the body, not the dialog, so the close button stays put
     while a long description moves under it. */
  .lp-info-body{flex:1 1 auto; min-width:0; min-height:0; overflow-y:auto; overscroll-behavior:contain;}
  .lp-info-eyebrow{
    display:block; font-family:var(--font-display); text-transform:uppercase;
    letter-spacing:.1em; font-size:.62rem; font-weight:800; color:var(--accent-2);
  }
  .lp-info-title{
    margin:.3rem 0 0; font-family:var(--font-display); font-weight:800;
    font-size:1.16rem; line-height:1.2;
  }
  .lp-info-host{margin:.25rem 0 0; font-size:.84rem; color:var(--ink-dim);}
  .lp-info-desc{margin:.7rem 0 0; font-size:.88rem; line-height:1.6; color:var(--ink-dim); white-space:pre-line;}
  .lp-info-note{margin:.7rem 0 0; font-size:.84rem; color:var(--ink-faint);}
  .lp-info-host[hidden], .lp-info-desc[hidden], .lp-info-note[hidden]{display:none;}
  .lp-info-links{display:flex; align-items:center; gap:.4rem; flex-wrap:wrap; margin-top:.9rem;}
  .lp-info-links:empty{display:none;}
  .lp-info-close{
    flex:none; align-self:flex-start;
    width:28px; height:28px; margin:-.15rem -.3rem 0 0; padding:0;
    display:flex; align-items:center; justify-content:center;
    border:none; border-radius:50%; cursor:pointer;
    background:transparent; color:var(--ink-faint);
    transition:background .15s ease, color .15s ease;
  }
  .lp-info-close svg{width:14px; height:14px;}
  @media (hover:hover) and (pointer:fine){
    .lp-info-close:hover{background:var(--surface-hover); color:var(--ink);}
  }
  @media (prefers-reduced-motion:reduce){
    .lp-info, .lp-info-scrim{animation-duration:.01s;}
  }
  /* Phones: a bottom sheet, like every other panel this app opens there. */
  @media (max-width:560px){
    .lp-info{
      left:0; right:0; top:auto; bottom:0; width:100%;
      max-height:82dvh;
      border-radius:var(--phone-sheet-radius) var(--phone-sheet-radius) 0 0;
      border-bottom:none;
      padding:1.2rem 1.25rem calc(1.2rem + env(safe-area-inset-bottom,0px));
      animation:lpInfoSheetIn .38s var(--ease-panel) both;
    }
    @keyframes lpInfoSheetIn{ from{opacity:.4; transform:translateY(14%);} to{opacity:1; transform:none;} }
  }

  /* Phones: a tall sheet from the bottom edge, stopping short of the top. It
     used to be a 92dvh box that left the art, transport and status text
     fighting over one cramped space; this keeps nearly all of that reclaimed
     height while --phone-sheet-gap holds back a strip of blurred scrim at the
     top. That strip is the whole point: an edge-to-edge panel reads as a *page*
     you navigated to, and a page has no reason to be sent downward. Showing the
     listing still there behind it says "this is on top of something", which is
     what makes the chevron's downward gesture mean anything. */
  @media (max-width:560px){
    .live-player{
      left:0; bottom:0; width:100%;
      top:max(var(--phone-sheet-gap), env(safe-area-inset-top,0px));
      height:auto; max-height:none;
      border-radius:var(--phone-sheet-radius) var(--phone-sheet-radius) 0 0;
      border-bottom:none;
      padding:1.8rem 1.4rem calc(1.5rem + env(safe-area-inset-bottom,0px));
      transform:translate(0, 100%) scale(1);
      /* Centre the stack in the taller box. `safe` matters because this is also
         a scroll container: plain `center` would push the top of an overflowing
         stack out of reach. Engines that don't know the keyword drop the whole
         declaration and top-align, which is the old behaviour — no worse. */
      justify-content:safe center;
    }
    .live-player.show{ transform:translate(0, 0) scale(1); }
    /* Already a bottom sheet: it slides straight back down onto the bar, which
       *is* the collapse. Scaling toward a measured point would fight the dock. */
    .live-player.minimizing{ transform:translate(0, 100%) scale(1); }
    /* .lp-close carries .sheet-close, so its full-screen sizing comes from the
       shared close-button block up in the sheet section. */
    /* The square art is what pushes the transport off a short screen, so cap it
       against the viewport height as well as its width. */
    .lp-art{ width:min(300px, 64vw, 34dvh); }
  }
  /* ---- the card, from 561px up: artwork beside the content, not above it ----
     Everything wider than the phone sheet gets one layout. It used to be a
     460px single column with a 300px square in it, which stacked the art, the
     metadata, the transport and two status lines nose-to-tail; the natural
     height ran past the card's own max-height and it quietly turned into a
     scroller, so on a laptop the transport and the now-playing line sat below
     the fold with nothing to say they were there.

     Two columns fixes the cause rather than the symptom: the square stops being
     a full-width block, so the card is about 400px tall instead of 720px and
     the extra width goes to the text, which is the part that actually varies in
     length. `minmax(0,1fr)` on the text column is what lets the clamped titles
     above shrink instead of forcing the grid wider.

     This supersedes a former `max-height:560px` landscape-only grid. Two
     definitions of the same two-column card is exactly how they drift apart —
     short screens now just tighten this one. */
  @media (min-width:561px){
    .live-player{
      display:grid; grid-template-columns:auto minmax(0, 1fr);
      align-content:start; justify-items:stretch;
      gap:1.15rem 1.9rem;
      width:min(760px, 94vw);
      padding:1.9rem 2.1rem 1.7rem;
    }
    .lp-badge{ grid-column:1 / -1; }
    /* align-self:start so the art sits under the badge rather than floating in
       the middle of a taller text column. */
    .lp-art{ grid-column:1; align-self:start; width:min(232px, 26vw); }
    .lp-main{ grid-column:2; align-items:flex-start; gap:.9rem; }
    .lp-meta{ text-align:left; }
    .lp-archive{ margin:.6rem auto 0 0; }
    .lp-transport{ max-width:none; justify-content:flex-start; }
    .lp-upnext{ max-width:100%; }
    .lp-song{ justify-content:flex-start; }
    .lp-note{ text-align:left; }
  }
  /* Short / landscape: the same card, tightened. The art is capped against the
     viewport height as well as its width so the controls stay above the fold. */
  @media (max-height:560px) and (min-width:561px){
    .live-player{ gap:.75rem 1.5rem; padding:1.4rem 1.6rem; }
    .lp-art{ width:min(168px, 30vh); }
    .lp-main{ gap:.7rem; }
  }
  /* compact donate button in the appbar */
  .appbar-actions .btn{padding:.36rem .8rem; font-size:.76rem;}
  .appbar-actions .btn svg{width:14px;height:14px;}
  /* wordmark donate pill: deepened accent, soft sheen on hover */
  .appbar-actions .btn-donate{
    --donate:color-mix(in srgb, var(--accent) 86%, #3d0f06);
    position:relative; overflow:hidden; isolation:isolate;
    padding:.4rem .95rem; text-decoration:none;
    background:linear-gradient(160deg,
      color-mix(in srgb, var(--donate) 92%, white) 0%,
      var(--donate) 55%,
      color-mix(in srgb, var(--donate) 88%, black) 100%);
    box-shadow:
      0 1px 2px color-mix(in srgb, var(--donate) 40%, transparent),
      0 4px 12px color-mix(in srgb, var(--donate) 26%, transparent),
      inset 0 1px 0 rgba(255,255,255,.22);
  }
  /* diagonal highlight that sweeps across on hover */
  .appbar-actions .btn-donate::after{
    content:""; position:absolute; inset:0; z-index:-1;
    background:linear-gradient(105deg, transparent 35%, rgba(255,255,255,.28) 50%, transparent 65%);
    transform:translateX(-130%);
    transition:transform .55s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .appbar-actions .btn-donate:hover{background:linear-gradient(160deg,
      color-mix(in srgb, var(--donate) 86%, white) 0%,
      color-mix(in srgb, var(--donate) 96%, white) 55%,
      var(--donate) 100%);
    transform:translateY(-1px);
    box-shadow:
      0 2px 4px color-mix(in srgb, var(--donate) 42%, transparent),
      0 8px 20px color-mix(in srgb, var(--donate) 32%, transparent),
      inset 0 1px 0 rgba(255,255,255,.28);}
  }
  @media (hover:hover) and (pointer:fine){
    .appbar-actions .btn-donate:hover::after{transform:translateX(130%);}
  }
  .appbar-actions .btn-donate:active{transform:translateY(0) scale(.97);}
  @media (prefers-reduced-motion:reduce){
    .appbar-actions .btn-donate{transform:none;}
    @media (hover:hover) and (pointer:fine){
      .appbar-actions .btn-donate:hover{transform:none;}
    }
    .appbar-actions .btn-donate::after{transition:none; display:none;}
  }
  .btn-donate .donate-label{
    font-family:var(--font-display); font-weight:800; font-size:.72rem;
    text-transform:uppercase; letter-spacing:.09em; line-height:1; text-decoration:none;
  }
  .btn{
    display:inline-flex; align-items:center; gap:.4rem; justify-content:center;
    border-radius:999px; border:1px solid transparent; cursor:pointer;
    font-size:.82rem; font-weight:600; padding:.5rem 1rem; white-space:nowrap;
    transition:transform .15s ease, background .15s ease, box-shadow .15s ease;
  }
  .btn:active{transform:scale(.97);}
  .btn-filled{background:var(--accent); color:var(--accent-ink); box-shadow:var(--shadow);}
  @media (hover:hover) and (pointer:fine){
    .btn-filled:hover{background:color-mix(in srgb, var(--accent) 88%, black);}
  }
  .btn-tonal{background:color-mix(in srgb, var(--accent-2) 18%, var(--surface-1)); color:var(--accent-2);}
  @media (hover:hover) and (pointer:fine){
    .btn-tonal:hover{background:color-mix(in srgb, var(--accent-2) 28%, var(--surface-1));}
  }
  .btn-icon{width:36px;height:36px;padding:0;border-radius:50%;background:transparent;color:var(--ink-dim);}
  @media (hover:hover) and (pointer:fine){
    .btn-icon:hover{background:var(--surface-hover); color:var(--ink);}
  }
  .btn svg{width:16px;height:16px;flex:none;}

  .brand-text-label{display:none;}

  /* ---------------- Hero ---------------- */
  .hero{
    max-width:1180px; margin:0 auto; padding:1.6rem var(--page-gutter) 1.8rem;
    display:flex; align-items:center; gap:2rem;
  }
  .hero-lockup{flex:none;}
  .hero-copy{flex:1 1 auto; min-width:0;}
  .hero p{color:var(--ink-dim); margin:0; max-width:none; line-height:1.55; font-size:.95rem;}

  /* ---- Hero copy: full on desktop, two lines on phones ----
     Wide screens have the room, so nothing is hidden there and the button is
     display:none — everything below lives inside the 720px query. The paragraph
     is always fully present in the DOM either way, only visually clipped, so
     screen readers get the whole thing regardless of the clamp. */
  .hero-desc-wrap{position:relative;}
  .hero-more{display:none;}

  /* stack the lockup above the copy on narrow screens */
  @media (max-width:720px){
    .hero{flex-direction:column; align-items:flex-start; gap:1rem; padding-bottom:1.4rem;}

    .hero-desc-wrap:not(.expanded) .hero-desc{
      display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2; overflow:hidden;
    }
    /* "more" sits ON the end of line two rather than under the paragraph, which
       is the whole point — a button on its own line costs the line it saves.
       Absolute + bottom:0 pins it to the last visible line; the gradient fades
       the clipped text out beneath it so the join doesn't read as an overlap.
       The fade is a flat --surface-0 because that IS the page behind the hero
       (body's background, no gradient of its own) — it stays exact in both
       themes for the same reason the rest of the palette does. */
    .hero-desc-wrap:not(.expanded) .hero-more{
      display:inline-block; position:absolute; right:0; bottom:0;
      padding:0 0 0 3rem;
      background:linear-gradient(to right, transparent 0, var(--surface-0) 2.2rem);
    }
    .hero-more{
      border:0; font-family:inherit; cursor:pointer;
      /* matched to .hero p so the label sits on the paragraph's own baseline */
      font-size:.95rem; line-height:1.55;
      font-weight:700; color:var(--accent-2);
    }
    /* expanded, it becomes an ordinary block under the text — there is no last
       line to hang off any more, and overlaying it would cover real content */
    .hero-desc-wrap.expanded .hero-more{
      display:inline-block; position:static; background:none;
      padding:0; margin-top:.45rem;
    }
    @media (hover:hover) and (pointer:fine){
      .hero-more:hover{color:color-mix(in srgb, var(--accent-2) 60%, var(--ink));}
    }
    /* The global :focus-visible ring boxes the button's whole border-box — which
       here includes the 3rem of transparent gradient padding, so it drew a wide
       rectangle across the end of the paragraph and read as a rendering fault.
       Ring the word instead: no padding to enclose, and it lands where the eye
       already is. */
    .hero-desc-wrap:not(.expanded) .hero-more:focus-visible{
      outline:none;
      text-decoration:underline; text-decoration-thickness:2px;
      text-underline-offset:3px;
      color:color-mix(in srgb, var(--accent-2) 70%, var(--ink));
    }
  }

  /* ---------------- Controls ---------------- */
  .controls{
    max-width:1180px; margin:0 auto; padding:.85rem var(--page-gutter) 1rem;
    display:flex; flex-direction:column; gap:.75rem;
  }
  /* Sticky search + category bar. It's a direct child of <main> (a tall
     scroll parent) — if it lived inside the short .controls box it would
     un-stick the moment that box scrolled past. top:57px parks it flush
     under the app bar; the table header (.row.head) sticks below it. */
  .controls-row{
    position:sticky; top:57px; z-index:40;
    max-width:1180px; margin:0 auto; padding:.7rem var(--page-gutter);
    background:var(--surface-0);
    border-bottom:1px solid var(--outline);
    display:flex; align-items:stretch; gap:.6rem;
  }
  .search-field{
    flex:1 1 auto; min-width:0;
    display:flex; align-items:center; gap:.55rem;
    background:var(--surface-1); border:1px solid var(--outline-strong);
    border-radius:14px; padding:.65rem .9rem;
  }
  /* .has-query keeps the accent while a filter is in force even after focus has
     moved on — closing a show modal used to drop the field back to its resting
     grey with the list still filtered underneath. */
  .search-field:focus-within,
  .search-field.has-query{border-color:var(--accent-2); box-shadow:0 0 0 3px color-mix(in srgb, var(--accent-2) 20%, transparent);}
  .search-field svg{width:18px;height:18px;color:var(--ink-faint); flex:none;}
  .search-field.has-query > svg{color:var(--accent-2);}
  .search-field input{
    border:none; outline:none; background:transparent; color:var(--ink);
    font-size:.95rem; width:100%; font-family:var(--font-body);
  }
  .search-field input::placeholder{color:var(--ink-faint);}
  /* Suppress the native X — it only shows while focused, and two clear buttons
     side by side is worse than either alone. */
  .search-field input::-webkit-search-cancel-button{-webkit-appearance:none; appearance:none;}
  .search-clear{
    flex:none; display:flex; align-items:center; justify-content:center;
    width:22px; height:22px; padding:0; margin:-2px -2px -2px 0;
    border:none; border-radius:50%; cursor:pointer;
    background:var(--outline-strong); color:var(--surface-0);
  }
  .search-clear[hidden]{display:none;}
  .search-clear svg{width:13px; height:13px; color:inherit;}
  .search-clear:hover{background:var(--accent-2);}
  .search-clear:focus-visible{outline:2px solid var(--accent-2); outline-offset:2px;}

  /* ---- Category dropdown ---- */
  .cat-select{position:relative; flex:none;}
  .cat-trigger{
    height:100%; min-width:170px;
    display:flex; align-items:center; gap:.55rem;
    background:var(--surface-1); border:1px solid var(--outline-strong);
    border-radius:14px; padding:.65rem .8rem .65rem .95rem;
    color:var(--ink); font-size:.9rem; font-weight:600; cursor:pointer;
    font-family:var(--font-body); white-space:nowrap;
    transition:border-color .15s ease, box-shadow .15s ease, background .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .cat-trigger:hover{border-color:var(--ink-faint);}
  }
  .cat-select.open .cat-trigger,
  .cat-trigger:focus-visible{
    outline:none; border-color:var(--accent-2);
    box-shadow:0 0 0 3px color-mix(in srgb, var(--accent-2) 20%, transparent);
  }
  .cat-trigger.is-filtered{
    background:color-mix(in srgb, var(--accent-2) 14%, var(--surface-1));
    border-color:color-mix(in srgb, var(--accent-2) 55%, var(--outline-strong));
  }
  .cat-trigger-icon{display:inline-flex; flex:none; color:var(--ink-dim);}
  .cat-trigger-icon .cat-icon{width:18px;height:18px;}
  .cat-trigger.is-filtered .cat-trigger-icon{color:var(--accent-2);}
  .cat-trigger-value{flex:1 1 auto; text-align:left; overflow:hidden; text-overflow:ellipsis;}
  .cat-caret{width:18px;height:18px;flex:none; color:var(--ink-faint); transition:transform .18s ease;}
  .cat-select.open .cat-caret{transform:rotate(180deg);}

  .cat-menu{
    position:absolute; top:calc(100% + 6px); right:0; z-index:60;
    min-width:220px; padding:.35rem;
    background:var(--surface-1); border:1px solid var(--outline-strong);
    border-radius:14px; box-shadow:var(--shadow-lg, 0 12px 32px rgba(0,0,0,.28));
    display:flex; flex-direction:column; gap:2px;
    animation:cat-menu-in .14s ease;
  }
  .cat-menu[hidden]{display:none;}
  @keyframes cat-menu-in{from{opacity:0; transform:translateY(-4px);} to{opacity:1; transform:none;}}
  .cat-option{
    display:flex; align-items:center; gap:.55rem;
    width:100%; padding:.5rem .6rem; border:none; border-radius:9px;
    background:transparent; color:var(--ink-dim); cursor:pointer;
    font-size:.875rem; font-weight:500; font-family:var(--font-body); text-align:left;
    transition:background .12s ease, color .12s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .cat-option:hover{background:var(--surface-hover); color:var(--ink);}
  }
  .cat-option .cat-icon{width:18px;height:18px; flex:none; color:var(--ink-faint);}
  .cat-option[aria-selected="true"] .cat-icon{color:var(--ink-dim);}
  @media (hover:hover) and (pointer:fine){
    .cat-option:hover .cat-icon{color:var(--ink-dim);}
  }
  .cat-option-label{flex:1 1 auto;}
  .cat-check{width:16px;height:16px;flex:none; color:var(--accent-2); opacity:0;}
  .cat-option[aria-selected="true"]{color:var(--ink); font-weight:600;}
  .cat-option[aria-selected="true"] .cat-check{opacity:1;}
  @media (max-width:520px){
    .cat-trigger{min-width:0; padding-left:.75rem; padding-right:.65rem;}
    .cat-trigger-value{max-width:8.5rem;}
    .cat-menu{min-width:min(220px, 78vw);}
  }

  .result-meta{display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap;}
  .result-meta-right{display:flex; align-items:center; gap:.85rem;}
  .result-count{font-size:.8rem; color:var(--ink-faint); font-family:var(--font-mono); font-variant-numeric:tabular-nums;}
  /* Wide screens show the full date; phones swap in the one-line form. Both are
     always written by setClock(), so neither can go stale behind the other. */
  .clock-short{display:none;}

  /* The freshness label is a button: it holds two facts (newest broadcast, and
     when the server last checked) and a tap swaps them. It used to keep the
     second one in `title` alone, which a phone can never show — there is no
     hover to trigger it.
     Styled as text, because it sits in a quiet meta strip and a real button
     would shout. The dotted underline is the whole affordance — without it the
     second fact is undiscoverable, which is the state we are fixing. */
  .clock-btn{
    border:0; background:none; padding:0; text-align:left; cursor:pointer;
    text-decoration:underline dotted;
    text-decoration-color:color-mix(in srgb, var(--ink-faint) 55%, transparent);
    text-underline-offset:4px;
    transition:color .15s ease;
  }
  /* No second fact to swap to (the shipped fallback snapshot has no timestamp),
     so it drops back to being plain text rather than a dead control. */
  .clock-btn:disabled{cursor:default; text-decoration:none; color:var(--ink-faint);}
  @media (hover:hover) and (pointer:fine){
    .clock-btn:not(:disabled):hover{color:var(--accent-2);}
  }
  .clock-btn:focus-visible{outline:2px solid var(--accent-2); outline-offset:3px; border-radius:3px;}

  /* "New shows" pill — hidden until a freshness poll finds upstream has moved */
  .refresh-pill{
    display:inline-flex; align-items:center; gap:.4rem; flex:none;
    padding:.3rem .7rem .3rem .55rem; border-radius:999px; cursor:pointer;
    border:1px solid color-mix(in srgb, var(--accent-2) 55%, transparent);
    background:color-mix(in srgb, var(--accent-2) 16%, var(--surface-1));
    color:var(--ink); font-family:var(--font-body); font-size:.75rem; font-weight:600;
    letter-spacing:.01em; white-space:nowrap;
    animation:refresh-pill-in .25s ease both;
    transition:background .15s ease, border-color .15s ease, transform .15s ease;
  }
  .refresh-pill[hidden]{display:none;}
  .refresh-pill svg{width:14px; height:14px; color:var(--accent-2);}
  @media (hover:hover) and (pointer:fine){
    .refresh-pill:hover{background:color-mix(in srgb, var(--accent-2) 26%, var(--surface-1));}
  }
  .refresh-pill:active{transform:scale(.97);}
  .refresh-pill:disabled{cursor:default; opacity:.75;}
  .refresh-pill.busy svg{animation:spin .9s linear infinite;}
  @keyframes refresh-pill-in{from{opacity:0; transform:translateY(-3px) scale(.94);} to{opacity:1; transform:none;}}
  @media (prefers-reduced-motion:reduce){
    .refresh-pill{animation:none;}
    .refresh-pill.busy svg{animation-duration:1.6s;}
  }

  /* Segmented list / gallery view switch */
  /* gap:0 on purpose — a gap between two adjacent segments is a dead strip that
     belongs to neither button, and it was swallowing taps aimed at the seam. */
  .view-toggle{display:inline-flex; gap:0; padding:3px; border:1px solid var(--outline-strong); border-radius:999px; background:var(--surface-1); flex:none;}
  .view-btn{
    width:46px; height:32px; padding:0; border:none; border-radius:999px;
    background:transparent; color:var(--ink-faint); cursor:pointer;
    display:inline-flex; align-items:center; justify-content:center;
    transition:background .15s ease, color .15s ease;
  }
  .view-btn svg{width:17px; height:17px;}
  @media (hover:hover) and (pointer:fine){
    .view-btn:hover{color:var(--ink);}
  }
  .view-btn[aria-pressed="true"]{background:color-mix(in srgb, var(--accent-2) 22%, var(--surface-1)); color:var(--ink);}

  /* ---------------- Data list / table ---------------- */
  .listing{max-width:1180px; margin:0 auto; padding:0 var(--page-gutter) 5rem;}
  .grid{
    display:flex; flex-direction:column;
    /* clip (not hidden): hidden would make this a scroll container and strand
       the sticky header row 57px down from the top of the table. */
    border:1px solid var(--outline); border-radius:16px; overflow:clip;
    background:var(--surface-1); box-shadow:var(--shadow);
    /* reserve vertical space so the footer sits below the fold while the
       archive fetch is in flight — no jump when rows arrive. */
    min-height:min(70vh, 640px);
  }
  .row{
    display:grid;
    /* The title is the only column whose content is open-ended, so it takes all
       the slack: the other three are sized to what they actually hold (measured
       widest — date 176px, duration 58px, retention pill 108px) with `auto` to
       grow if a longer date or a new retention label ever needs it. As fr units
       they were handing Duration 158px for 58px of text. */
    grid-template-columns:
      minmax(0, 1fr)          /* show    — absorbs everything left over */
      minmax(11rem, auto)     /* aired   */
      minmax(4.5rem, auto)    /* duration — floor set by the column header */
      minmax(7rem, auto)      /* retention */
      44px;                   /* play    */
    align-items:center;
    gap: .75rem 1rem;
    /* Vertical padding is deliberately tighter than the horizontal: the 60px
       artwork already sets the row height, so this is the only part of it that
       buys back space. Don't go below ~.5rem — the hover band stops reading as
       a row and starts reading as a stripe between them. */
    padding:.6rem 1.1rem;
    border-bottom:1px solid var(--outline);
  }
  .row:last-child{border-bottom:none;}
  .row.head{
    /* Parks below the sticky app bar (57px) + the sticky search bar (~64px). */
    background:var(--surface-2); position:sticky; top:121px; z-index:10;
    font-family:var(--font-display); text-transform:uppercase; letter-spacing:.07em;
    font-size:.68rem; color:var(--ink-dim); font-weight:700;
  }
  /* The whole row (minus the play column) opens the info sheet — see the rows
     click handler in app.js. The cursor is what tells a mouse that, and the
     press tint is what tells a finger. */
  .row.body{cursor:pointer;}
  .row.body .row-actions{cursor:default;}
  @media (hover:hover) and (pointer:fine){
    .row.body:hover{background:var(--surface-hover);}
  }
  @media (hover:none){
    .row.body:active{background:var(--surface-hover);}
  }
  .sortbtn{
    display:inline-flex; align-items:center; gap:.3rem; background:none; border:none;
    color:inherit; font:inherit; text-transform:inherit; letter-spacing:inherit; cursor:pointer; padding:0;
  }
  .sortbtn svg{width:11px;height:11px; opacity:.55;}
  .sortbtn[data-active="true"] svg{opacity:1; color:var(--accent-2);}

  .show-cell{display:flex; align-items:center; gap:.85rem; min-width:0; padding-right:.5rem;}
  /* The title + category block is itself the control that opens the info sheet,
     so the whole text column is a target, not just the little More link. */
  .show-text{ min-width:0; display:flex; flex-direction:column; align-items:flex-start; }
  .show-open{
    display:block; width:100%; min-width:0; text-align:left;
    border:0; padding:0; margin:0; background:none; color:inherit; font:inherit;
    cursor:pointer;
  }
  /* Artwork, text, date and retention all open the sheet, so the row lights up
     as one block rather than per-element — colour, never an underline. Keyed on
     .row.body:hover so the cue matches the real target; the play column is the
     only part of the row that doesn't open the sheet, and it has its own hover.
     (.show-cell is kept as a second selector so the gallery card, which has no
     .row wrapper, still highlights.) */
  .show-open:focus-visible .show-title{ color:var(--accent-2); }
  @media (hover:hover) and (pointer:fine){
    .row.body:hover .show-title,.show-cell:hover .show-title{color:var(--accent-2);}
  }
  @media (hover:hover) and (pointer:fine){
    .row.body:hover .more-link,.show-cell:hover .more-link,.more-link:hover{color:color-mix(in srgb, var(--accent-2) 70%, var(--ink));}
  }
  .show-thumb{ transition:box-shadow .15s ease; }
  @media (hover:hover) and (pointer:fine){
    .row.body:hover .show-thumb,.show-cell:hover .show-thumb{box-shadow:inset 0 0 0 1px var(--outline-strong), var(--elev-1);}
  }
  .show-thumb:focus-visible{ outline:2px solid var(--accent-2); outline-offset:2px; }

  /* plain text link under the title, the visible cue for what the block does */
  .more-link{
    display:inline-block; margin-top:.25rem; padding:0; border:0; background:none;
    font-family:inherit; font-size:.74rem; font-weight:700; letter-spacing:.02em;
    color:var(--accent-2); cursor:pointer;
    transition:color .15s ease;
  }
  .show-thumb{
    position:relative; width:60px; height:60px; border-radius:11px; flex:none; overflow:hidden;
    border:0; padding:0; cursor:pointer;
    /* station icon shows when a show has no photo (or its photo fails to load) */
    background:url("/assets/kpfk-artwork-placeholder.svg") center/cover no-repeat, var(--surface-2);
    box-shadow:inset 0 0 0 1px var(--outline);
  }
  .show-thumb img{position:absolute; inset:0; width:100%; height:100%; object-fit:cover;}
  .show-thumb img.failed{display:none;}
  /* display:block is load-bearing, not cosmetic: overflow/text-overflow are
     ignored on an inline box, so as a bare <span> this clipped nothing and a
     long title painted straight over the Aired column. */
  .show-title{
    display:block; max-width:100%; font-weight:600; font-size:.92rem;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  /* Ellipsised like .show-title. Without this a long "Category · with Host"
     line has nothing to clip it and paints straight into the Aired column. */
  .show-cat{
    display:block; max-width:100%; font-size:.74rem; color:var(--ink-faint); margin-top:.1rem;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  /* Dormant, not dead: nothing renders .rss-badge while SHOW_RSS is false in
     app.js. Kept so re-enabling the flag is the only change needed. */
  .rss-badge{
    flex:none; width:20px;height:20px;border-radius:6px;
    background:color-mix(in srgb, var(--warn) 20%, transparent); color:var(--warn);
    display:flex; align-items:center; justify-content:center;
  }
  .rss-badge svg{width:12px;height:12px;}

  .cell-mono{font-family:var(--font-mono); font-variant-numeric:tabular-nums; font-size:.84rem; color:var(--ink-dim);}
  .cell-date b{color:var(--ink); font-weight:600; display:block; font-size:.86rem;}
  .cell-date span{color:var(--ink-faint); font-size:.76rem;}

  .retention{
    display:inline-flex; align-items:center; gap:.35rem;
    padding:.3rem .6rem; border-radius:999px; font-size:.76rem; font-weight:700;
    font-family:var(--font-mono); font-variant-numeric:tabular-nums; white-space:nowrap;
  }
  .retention.good{background:var(--good-bg); color:var(--good);}
  .retention.warn{background:var(--warn-bg); color:var(--warn);}
  .retention.danger{background:var(--danger-bg); color:var(--danger);}
  /* Not a countdown — see retentionBadge() in app.js — so deliberately neutral
     rather than any of the three urgency colors above. */
  .retention.off{background:var(--surface-2); color:var(--ink-faint);}

  .play-btn{
    width:36px;height:36px;border-radius:50%;border:1px solid var(--outline-strong);
    background:var(--surface-2); color:var(--ink); display:flex; align-items:center; justify-content:center;
    cursor:pointer; transition:background .15s ease, color .15s ease, border-color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .play-btn:hover{background:var(--accent); border-color:var(--accent); color:var(--accent-ink);}
  }
  .play-btn.loading{background:var(--surface-2); border-color:var(--outline-strong); cursor:default;}
  .play-btn svg{width:14px;height:14px;}
  .play-glyph{display:inline-flex; align-items:center; justify-content:center;}
  .btn-spin{
    display:inline-block; width:15px;height:15px; border-radius:50%;
    border:2px solid color-mix(in srgb, var(--ink) 22%, transparent); border-top-color:var(--accent-2);
    animation:spin .7s linear infinite;
  }
  @media (prefers-reduced-motion:reduce){ .btn-spin{animation-duration:1.4s;} }

  .empty{padding:3rem 1rem; text-align:center; color:var(--ink-faint);}

  /* Centered load state while the archive fetch resolves. Grows to fill the
     grid's reserved min-height so the spinner sits mid-panel. */
  /* NOTE: keep this scoped to .loading-panel — a bare `.loading` selector also
     matches the play buttons, which toggle a `loading` class while buffering. */
  .loading-panel{
    flex:1 1 auto;
    display:flex; flex-direction:column; align-items:center; justify-content:center;
    gap:1rem; padding:4rem 1rem; color:var(--ink-faint);
  }
  .loading-panel[hidden]{display:none;}
  .loading-spinner{
    width:40px; height:40px; border-radius:50%;
    border:3px solid color-mix(in srgb, var(--ink) 12%, transparent);
    border-top-color:var(--accent-2);
    animation:spin .7s linear infinite;
  }
  .loading-text{
    font-family:var(--font-display); font-size:.72rem; font-weight:700;
    text-transform:uppercase; letter-spacing:.12em; color:var(--ink-dim);
  }
  @media (prefers-reduced-motion:reduce){ .loading-spinner{animation-duration:1.4s;} }

  .scroll-sentinel{height:1px;}

  /* ---------------- Hamburger button ---------------- */
  .menu-btn{
    width:42px; height:42px; border-radius:12px; flex:none;
    display:inline-flex; align-items:center; justify-content:center;
    background:var(--surface-3); border:1px solid var(--outline-strong);
    color:var(--ink); cursor:pointer;
    transition:background .15s ease, border-color .15s ease, color .15s ease,
               box-shadow .18s ease, transform .18s ease;
  }
  /* :hover and :active live with .theme-btn near the top of this file — the two
     icon chips share one rule so they cannot drift. Do not re-declare a
     .menu-btn:hover here: it would sit ~800 lines later at equal specificity and
     silently win, which is exactly how the pair came apart the first time. */
  /* wider, slightly heavier bars — the gap stays at 4px. Line pitch is
     height + gap (2.5 + 4), and the close-X translate below must equal it. */
  .menu-btn-lines{ display:flex; flex-direction:column; gap:4px; width:24px; }
  .menu-btn-lines span{ display:block; height:2.5px; border-radius:2px; background:currentColor; transition:transform .28s cubic-bezier(.4,0,.2,1), opacity .18s ease; }
  .menu-btn[aria-expanded="true"] .menu-btn-lines span:nth-child(1){ transform:translateY(6.5px) rotate(45deg); }
  .menu-btn[aria-expanded="true"] .menu-btn-lines span:nth-child(2){ opacity:0; }
  .menu-btn[aria-expanded="true"] .menu-btn-lines span:nth-child(3){ transform:translateY(-6.5px) rotate(-45deg); }
  @media (prefers-reduced-motion:reduce){ .menu-btn-lines span{transition:none;} }

  /* ---------------- Slide-out menu drawer ---------------- */
  .menu-scrim{
    position:fixed; inset:0; z-index:150;
    background:color-mix(in srgb, #000 55%, transparent);
    -webkit-backdrop-filter:blur(0px); backdrop-filter:blur(0px);
    opacity:0; visibility:hidden; pointer-events:none;
    transition:var(--ov-scrim-tr-out);
  }
  .menu-scrim.show{
    opacity:1; visibility:visible; pointer-events:auto;
    -webkit-backdrop-filter:blur(4px); backdrop-filter:blur(4px);
    transition:var(--ov-scrim-tr-in);
  }

  .menu-panel{
    position:fixed; top:0; right:0; z-index:160;
    height:100dvh; width:min(400px, 92vw);
    padding-right:env(safe-area-inset-right, 0px);   /* landscape notch */
    display:flex; flex-direction:column;
    background:var(--surface-1); border-left:1px solid var(--outline);
    box-shadow:-24px 0 60px rgba(0,0,0,.45);
    transform:translateX(100%); visibility:hidden;
    /* The drawer is on the same recipe as the five dialogs — it is another
       "you are in a layer" signal, and one overlay left on its own curve is how
       a set of them stops feeling like one app. It travels the full width of
       the panel, though, which is a long way, so it opts out of the shared
       durations rather than the shared curve. */
    --ov-in:.44s; --ov-out:.26s;
    transition:transform var(--ov-out) var(--ease-exit), visibility 0s linear var(--ov-out);
    will-change:transform;
  }
  .menu-panel.show{
    transform:translateX(0); visibility:visible;
    transition:transform var(--ov-in) var(--ease-panel);
  }
  @media (prefers-reduced-motion:reduce){
    .menu-scrim, .menu-panel{ transition-duration:0s; transition-delay:0s; }
  }
  /* The real lock is `html.scroll-lock` up by the root rules — see the long
     comment there for why these body rules can't do the job on their own. They
     stay as the second line of defence: if `html{overflow-x:clip}` is ever
     dropped, body's overflow starts propagating to the viewport again and these
     become live. Harmless either way.
     (A drag over the lightbox or the donate modal scrolled the listing behind
     it; the donate one is worst, since it wraps an iframe and the drag could
     move the page, the iframe, or neither.) */
  body.menu-open, body.lightbox-open, body.donate-open, body.sched-open{ overflow:hidden; }
  /* Belt-and-braces for iOS, where a root scroll lock has historically leaked:
     a backdrop has nothing to pan, so tell the compositor that up front. Not
     `none` — that would also kill the pinch-zoom index.html deliberately keeps. */
  .menu-scrim, .sheet-scrim, .donate-scrim, .live-scrim, .lp-alert-scrim, .sched-scrim{
    touch-action:pinch-zoom;
  }

  .menu-head{
    flex:none; display:flex; align-items:center; justify-content:space-between; gap:1rem;
    padding:1rem 1.1rem 1rem 1.3rem; border-bottom:1px solid var(--outline);
  }
  .menu-logo{ height:22px; }
  .menu-close{
    width:38px; height:38px; flex:none; border-radius:50%;
    border:1px solid var(--outline-strong); background:var(--surface-2); color:var(--ink);
    display:flex; align-items:center; justify-content:center; cursor:pointer;
    transition:background .15s ease, color .15s ease, border-color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .menu-close:hover{background:var(--accent); border-color:var(--accent); color:var(--accent-ink);}
  }
  .menu-close svg{ width:16px; height:16px; }

  .menu-body{
    flex:1 1 auto; overflow-y:auto; -webkit-overflow-scrolling:touch;
    overscroll-behavior:contain;   /* don't chain the bounce to the page */
    padding:1.4rem 1.3rem 2rem; display:flex; flex-direction:column; gap:1.7rem;
  }
  .menu-brand{ display:flex; flex-direction:column; gap:.9rem; }
  .menu-brand p{ margin:0; font-size:.84rem; color:var(--ink-dim); line-height:1.55; }

  .menu-nav{ display:flex; flex-direction:column; gap:1.5rem; }
  .menu-group h2{
    font-family:var(--font-display); text-transform:uppercase; letter-spacing:.08em;
    font-size:.66rem; font-weight:700; color:var(--ink-faint); margin:0 0 .55rem;
  }
  .menu-group ul{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:.1rem; }
  /* Rows are icon + label. */
  .menu-group a{
    display:flex; align-items:center; gap:.72rem;
    padding:.55rem .65rem; margin:0 -.65rem; border-radius:9px;
    color:var(--ink); text-decoration:none; font-size:.92rem;
    transition:background .15s ease, color .15s ease;
  }
  /* The glyph sits one step back from the label so the words lead the eye;
     hover pulls both to the accent together. */
  .menu-ico{ width:18px; height:18px; flex:none; color:var(--ink-faint); transition:color .15s ease; }
  @media (hover:hover) and (pointer:fine){
    .menu-group a:hover{background:var(--surface-hover); color:var(--accent-2);}
    .menu-group a:hover .menu-ico{ color:var(--accent-2); }
  }

  .social-row{display:flex; gap:.5rem; flex-wrap:wrap;}
  .social-btn{
    width:36px;height:36px;border-radius:50%; border:1px solid var(--outline-strong);
    display:flex; align-items:center; justify-content:center; color:var(--ink-dim);
    text-decoration:none; transition:background .15s ease, color .15s ease, border-color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .social-btn:hover{background:var(--accent-2); border-color:var(--accent-2); color:var(--accent-2-ink);}
  }
  .social-btn svg{width:16px;height:16px;}

  .menu-foot{
    border-top:1px solid var(--outline); padding-top:1.2rem;
    display:flex; flex-direction:column; gap:.5rem;
  }
  .menu-foot p{ margin:0; font-size:.78rem; color:var(--ink-faint); }
  .menu-note{ line-height:1.5; }
  .menu-note a{ color:var(--ink-faint); text-decoration:underline; }

  /* ---------------- Show info sheet ---------------- */
  /* Same scrim treatment as the drawer, but the panel is a centered card. */
  .sheet-scrim{
    position:fixed; inset:0; z-index:170;
    background:color-mix(in srgb, #000 62%, transparent);
    -webkit-backdrop-filter:blur(0px); backdrop-filter:blur(0px);
    opacity:0; visibility:hidden; pointer-events:none;
    transition:var(--ov-scrim-tr-out);
  }
  .sheet-scrim.show{
    opacity:1; visibility:visible; pointer-events:auto;
    -webkit-backdrop-filter:blur(5px); backdrop-filter:blur(5px);
    transition:var(--ov-scrim-tr-in);
  }

  .sheet{
    position:fixed; z-index:180; left:50%; top:50%;
    width:min(800px, 92vw); max-height:min(88dvh, 800px);
    display:flex; flex-direction:column; overflow:hidden;
    background:var(--surface-1); color:var(--ink);
    border:1px solid var(--outline-strong); border-radius:18px;
    box-shadow:0 30px 70px -20px rgba(0,0,0,.7), var(--elev-2);
    transform:translate(-50%, -43%) scale(.95); opacity:0; visibility:hidden;
    transition:var(--ov-panel-tr-out);
  }
  .sheet.show{
    transform:translate(-50%, -50%) scale(1); opacity:1; visibility:visible;
    transition:var(--ov-panel-tr-in);
  }
  /* Closing the sheet while the bar holds this episode is a handoff, not a stop,
     so the card collapses toward the bar exactly as the live player does. Same
     --min-dy, same run in app.js (runMinimize). After .show for the same reason. */
  .sheet.minimizing{
    transform:translate(-50%, calc(-50% + var(--min-dy, 40vh))) scale(.28);
    opacity:0; visibility:visible; pointer-events:none;
    transition:transform .34s cubic-bezier(.4,0,.75,.4), opacity .28s ease .06s;
  }
  @media (prefers-reduced-motion:reduce){
    .sheet-scrim, .sheet{ transition-duration:0s; transition-delay:0s; }
    .sheet{ transform:translate(-50%,-50%) scale(1); }
    .sheet.minimizing{ transition-duration:0s; }
  }
  /* ---- content stagger ----
     The panel lands, then its contents settle in behind it — header, then the
     description, then the facts, then the footer, ~35ms apart over 9px. This is
     the cheapest thing in this file that reads as "app" rather than "page": the
     dialog stops being a picture that appeared and becomes a surface that
     assembled itself.

     Gated on a transient `entering` class rather than on `.show`, for two
     reasons, both of which are invisible in a screenshot — which is exactly why
     they are written down here:

       1. `paintSheet()` replaces the body's innerHTML on EVERY episode-chip tap
          (and again when the programs directory lands, a beat after opening).
          Fresh elements restart a CSS animation, so keyed on `.show` this would
          re-stagger on every tap and picking a date would make the header
          flutter. app.js drops `entering` whenever it repaints an already-open
          sheet, so the rail swaps rows dead still — which is the rule the
          episode rail was built on: choosing a chip is not an event.
       2. `animation-fill-mode:both` keeps the final `transform:none` applied
          for as long as the selector matches, and an animation outranks a
          transition on the same property. Keyed on `.show`, these four rules
          would hold a permanent claim on `transform` over half the contents of
          both dialogs — so any `:active`/`:hover` transform added to one of
          them later would simply not work, with nothing to say why. None of
          the current targets has one, which is precisely what makes this the
          kind of trap you walk into a year from now. Transient class, no
          lingering fill, no claim to inherit. */
  @keyframes ovRise{
    from{ opacity:0; transform:translateY(9px); }
    to{ opacity:1; transform:none; }
  }
  .sheet.entering .sheet-head     { animation:ovRise var(--ov-stagger) var(--ease-panel) .04s  both; }
  .sheet.entering .sheet-desc-wrap{ animation:ovRise var(--ov-stagger) var(--ease-panel) .075s both; }
  .sheet.entering .sheet-selected { animation:ovRise var(--ov-stagger) var(--ease-panel) .11s  both; }
  .sheet.entering .sheet-foot     { animation:ovRise var(--ov-stagger) var(--ease-panel) .145s both; }
  .live-player.entering .lp-badge    { animation:ovRise var(--ov-stagger) var(--ease-panel) .04s  both; }
  .live-player.entering .lp-art      { animation:ovRise var(--ov-stagger) var(--ease-panel) .075s both; }
  .live-player.entering .lp-meta     { animation:ovRise var(--ov-stagger) var(--ease-panel) .11s  both; }
  .live-player.entering .lp-transport{ animation:ovRise var(--ov-stagger) var(--ease-panel) .145s both; }
  /* Same selectors at equal specificity, later in the cascade, so `none` wins
     without reaching for !important. */
  @media (prefers-reduced-motion:reduce){
    .sheet.entering .sheet-head, .sheet.entering .sheet-desc-wrap,
    .sheet.entering .sheet-selected, .sheet.entering .sheet-foot,
    .live-player.entering .lp-badge, .live-player.entering .lp-art,
    .live-player.entering .lp-meta, .live-player.entering .lp-transport{ animation:none; }
  }

  body.sheet-open{ overflow:hidden; }
  /* leave room for the docked player bar so the card never sits on top of it */
  /* The page player is covered while this dialog is open; its replacement dock
     is inside the sheet. Starting audio must not resize the whole modal. */
  body.has-player .sheet{ max-height:min(88dvh, 800px); }

  /* The only way out of a dialog, so it has to read as a control at a glance.
     A dimmed glyph on surface-2 was one step off the card behind it and all but
     vanished over the live player's artwork and accent glow — full ink, the next
     surface up, a defined edge and a shadow to lift it off whatever it covers. */
  .sheet-close{
    position:absolute; top:.85rem; right:.85rem; z-index:2;
    width:36px; height:36px; border-radius:50%;
    border:1px solid color-mix(in srgb, var(--ink) 30%, transparent);
    background:var(--surface-3); color:var(--ink);
    box-shadow:0 2px 10px rgba(0,0,0,.32);
    display:flex; align-items:center; justify-content:center; cursor:pointer;
    transition:background .15s ease, color .15s ease, border-color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .sheet-close:hover{background:var(--accent); border-color:var(--accent); color:var(--accent-ink);}
  }
  .sheet-close svg{ width:15px; height:15px; }

  /* Two glyphs, one button: ✕ at rest, chevron-down whenever closing hands the
     audio to the docked bar instead of ending it. Shared by the live player and
     the show info sheet — both close onto that same bar. Only one is ever shown.
     Keyed off .sheet-close, which .lp-close also carries. */
  .sheet-close .close-min{ display:none; width:17px; height:17px; }
  .sheet-close.minimize .close-x{ display:none; }
  .sheet-close.minimize .close-min{ display:block; }

  /* The archive is a route inside this dialog, not content appended to the show
     profile. Back returns to the profile; the close control remains independently
     visible at the right and keeps its close/minimize promise. */
  .sheet-routebar{
    flex:none; min-height:58px; padding:.7rem 4.2rem .7rem 1.35rem;
    display:grid; grid-template-columns:1fr auto 1fr; align-items:center;
    border-bottom:1px solid var(--outline); background:var(--surface-1);
  }
  .sheet-routebar[hidden]{ display:none; }
  .sheet-routebar strong{
    grid-column:2; font-family:var(--font-display); font-size:.92rem;
    letter-spacing:.02em; white-space:nowrap;
  }
  .sheet-route-back{
    grid-column:1; justify-self:start; display:inline-flex; align-items:center; gap:.4rem;
    min-height:44px; padding:.4rem .55rem; border:0; border-radius:9px;
    background:transparent; color:var(--ink-dim); font:600 .78rem/1 var(--font-body);
    cursor:pointer; transition:background .15s ease, color .15s ease;
  }
  .sheet-route-back svg{ width:17px; height:17px; color:var(--ink); }
  @media (hover:hover) and (pointer:fine){
    .sheet-route-back:hover{ background:var(--surface-hover); color:var(--ink); }
  }

  /* Phones: both dialogs are full-screen, so the only way out of one needs more
     presence than a 36px chip. The 44px touch target around it is unchanged. */
  @media (max-width:560px){
    /* No safe-area term here: the dialogs already start below it (their `top` is
       max(--phone-sheet-gap, safe-area-inset-top)), so adding it again would
       double-count and push the button into the middle of the header. */
    .sheet-close{
      top:.9rem; right:.9rem;
      width:42px; height:42px;
    }
    .sheet-close svg{ width:17px; height:17px; }
    .sheet-close .close-min{ width:19px; height:19px; }
  }

  .sheet-body{
    flex:1 1 auto; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch;
    overscroll-behavior:contain;   /* don't chain the bounce to the page */
    padding:1.4rem 1.6rem 1.2rem;
    display:flex; flex-direction:column; gap:1.05rem;
  }
  /* Scroll hint. With the controls pinned below, a long description leaves the
     facts row and the retention badge just under the fold, where they read as
     missing rather than as scrolled-away. The mask sits on the SCROLL BOX, so
     it stays at the box's edges while the content moves under it, and each edge
     is faded only while there is really something past it (syncSheetFade). */
  .sheet-body.fade-bottom{
    -webkit-mask-image:linear-gradient(#000 calc(100% - 34px), transparent);
    mask-image:linear-gradient(#000 calc(100% - 34px), transparent);
  }
  .sheet-body.fade-top{
    -webkit-mask-image:linear-gradient(transparent, #000 26px);
    mask-image:linear-gradient(transparent, #000 26px);
  }
  .sheet-body.fade-top.fade-bottom{
    -webkit-mask-image:linear-gradient(transparent, #000 26px, #000 calc(100% - 34px), transparent);
    mask-image:linear-gradient(transparent, #000 26px, #000 calc(100% - 34px), transparent);
  }
  /* A fade can still look like decoration. This measured cue is shown only
     while the scroll box has content below the fold, and follows its real edge
     above whatever footer/player happens to be present. */
  .sheet-scroll-cue{
    position:absolute; z-index:8; left:50%; bottom:var(--sheet-cue-bottom, 8px);
    transform:translateX(-50%); min-height:44px;
    display:inline-flex; align-items:center; gap:.35rem;
    padding:.45rem .7rem; border:1px solid var(--outline-strong); border-radius:999px;
    background:color-mix(in srgb, var(--surface-3) 92%, transparent); color:var(--ink-dim);
    box-shadow:var(--elev-1); font:700 .68rem/1 var(--font-body); white-space:nowrap;
    cursor:pointer; -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px);
  }
  .sheet-scroll-cue[hidden]{ display:none; }
  .sheet-scroll-cue svg{ width:15px; height:15px; color:var(--accent-2); }
  .sheet-scroll-cue:focus-visible{ outline:2px solid var(--accent-2); outline-offset:2px; }
  /* Controls live outside the scroll area: however long the description runs,
     Play, the links and the scrubber stay put at the bottom of the card. */
  .sheet-foot{
    flex:none; display:flex; flex-direction:column; gap:.85rem;
    padding:1.1rem 1.6rem 1.3rem; border-top:1px solid var(--outline);
    background:var(--surface-1); border-radius:0 0 17px 17px;
  }
  .sheet-foot:empty{ display:none; }

  /* A permanent transport region inside the sheet. The footer can change from
     profile actions to empty archive chrome without moving or covering playback. */
  .sheet-player-dock{
    flex:none; padding:.8rem 1.6rem .9rem; border-top:1px solid var(--outline);
    background:color-mix(in srgb, var(--surface-2) 72%, var(--surface-1));
  }
  .sheet-player-dock[hidden]{ display:none; }
  .sheet-player-row{ display:flex; align-items:center; gap:.65rem; min-width:0; }
  .sheet-player-art{
    position:relative; flex:none; width:44px; height:44px; overflow:hidden; border-radius:9px;
    background:url("/assets/kpfk-artwork-placeholder.svg") center/cover no-repeat, var(--surface-3);
    box-shadow:inset 0 0 0 1px var(--outline);
  }
  .sheet-player-art img{ width:100%; height:100%; object-fit:cover; display:block; }
  .sheet-player-art img.failed, .sheet-player-art img:not([src]){ display:none; }
  .sheet-player-toggle{
    flex:none; width:44px; height:44px; padding:0; border-radius:50%;
    display:inline-flex; align-items:center; justify-content:center;
    border:1px solid var(--accent); background:var(--accent); color:var(--accent-ink);
    box-shadow:var(--shadow); cursor:pointer;
    transition:background .15s ease, transform .12s ease;
  }
  .sheet-player-toggle:active{ transform:scale(.95); }
  .sheet-player-toggle:disabled{ cursor:default; opacity:.72; }
  .sheet-player-toggle svg{ width:16px; height:16px; }
  @media (hover:hover) and (pointer:fine){
    .sheet-player-toggle:hover{ background:color-mix(in srgb, var(--accent) 88%, black); }
  }
  .sheet-player-open{
    flex:1 1 auto; min-width:0; display:flex; flex-direction:column;
    align-items:flex-start; justify-content:center; gap:.15rem;
    min-height:44px; padding:.35rem .4rem; border:0; border-radius:8px; background:transparent;
    color:var(--ink); font-family:inherit; text-align:left; cursor:pointer;
  }
  .sheet-player-state{
    flex:none; display:inline-flex; align-items:center; gap:.35rem;
    font-family:var(--font-display); text-transform:uppercase;
    letter-spacing:.08em; font-size:.65rem; font-weight:800; color:var(--accent-2);
  }
  .sheet-player-eq{ display:none; align-items:flex-end; gap:2px; width:12px; height:11px; }
  .sheet-player-eq i{
    width:2.5px; height:45%; border-radius:1px; background:var(--accent-2);
    animation:eqbar .85s ease-in-out infinite;
  }
  .sheet-player-eq i:nth-child(2){ animation-delay:.16s; }
  .sheet-player-eq i:nth-child(3){ animation-delay:.32s; }
  .sheet-player-dock.is-playing .sheet-player-eq{ display:inline-flex; }
  @media (prefers-reduced-motion:reduce){
    .sheet-player-eq i{ animation:none; height:70%; }
    .sheet-player-eq i:nth-child(2){ height:100%; }
    .sheet-player-eq i:nth-child(3){ height:45%; }
  }
  .sheet-player-copy{ display:flex; gap:.4rem; min-width:0; align-items:baseline; }
  .sheet-player-live{
    display:inline-flex; align-items:center; gap:.3rem; color:var(--accent);
  }
  .sheet-player-live[hidden]{ display:none; }
  .sheet-player-live i{ width:6px; height:6px; border-radius:50%; background:var(--accent); }
  .sheet-player-dock.live .sheet-player-state{ color:var(--ink-dim); }
  .sheet-player-dock.live.is-playing .sheet-player-live i{ animation:pulse 1.8s infinite; }
  .sheet-player-dock.live .sheet-player-scrub{ display:none; }
  @media (prefers-reduced-motion:reduce){
    .sheet-player-dock.live.is-playing .sheet-player-live i{ animation:none; }
  }
  .sheet-player-title{
    min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
    font-size:.82rem; font-weight:700;
  }
  .sheet-player-episode{
    flex:none; color:var(--ink-dim); font-family:var(--font-mono);
    font-size:.72rem; white-space:nowrap;
  }
  .sheet-player-scrub{ margin-top:.5rem; }

  /* header: large artwork beside the title block */
  /* centred so a title with no host or description doesn't float against the
     top of the much taller artwork */
  .sheet-head{ display:flex; gap:1.3rem; align-items:center; padding-right:2.6rem; }
  .sheet-art{
    position:relative; flex:none; width:148px; height:148px; border-radius:14px; overflow:hidden;
    background:url("/assets/kpfk-artwork-placeholder.svg") center/cover no-repeat, var(--surface-2);
    box-shadow:var(--elev-1);
  }
  .sheet-art img{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
  .sheet-art img.failed{ display:none; }
  /* the artwork tile is a button when it can open the lightbox */
  button.sheet-art{ padding:0; border:0; cursor:zoom-in; -webkit-appearance:none; appearance:none; }
  button.sheet-art:focus-visible{ outline:2px solid var(--accent); outline-offset:3px; }
  .sheet-art-zoom-badge{
    position:absolute; right:6px; bottom:6px; z-index:1;
    width:26px; height:26px; border-radius:50%;
    display:flex; align-items:center; justify-content:center;
    background:color-mix(in srgb, #000 55%, transparent); color:#fff;
    -webkit-backdrop-filter:blur(3px); backdrop-filter:blur(3px);
    opacity:0; transform:translateY(3px);
    transition:opacity .16s ease, transform .16s ease;
  }
  .sheet-art-zoom-badge svg{ width:15px; height:15px; }
  .sheet-art-zoom:focus-visible .sheet-art-zoom-badge{ opacity:1; transform:none; }
  @media (hover:hover) and (pointer:fine){
    .sheet-art-zoom:hover .sheet-art-zoom-badge{opacity:1; transform:none;}
  }
  /* touch devices have no hover — keep the affordance visible there */
  @media (hover:none){ .sheet-art-zoom-badge{ opacity:1; transform:none; } }

  /* ---------------- Artwork lightbox ---------------- */
  .lightbox{
    position:fixed; inset:0; z-index:200;
    display:flex; align-items:center; justify-content:center;
    padding:clamp(1rem, 5vw, 3rem);
    background:color-mix(in srgb, #000 82%, transparent);
    -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px);
    opacity:0; visibility:hidden; pointer-events:none; cursor:zoom-out;
    transition:opacity .2s ease, visibility 0s linear .2s;
  }
  .lightbox.show{
    opacity:1; visibility:visible; pointer-events:auto;
    transition:opacity .2s ease;
  }
  .lightbox img{
    max-width:min(760px, 100%); max-height:100%;
    width:auto; height:auto; object-fit:contain;
    border-radius:14px; box-shadow:0 30px 80px -20px rgba(0,0,0,.8);
    cursor:default;
  }
  .lightbox-close{
    position:absolute; top:clamp(.85rem, 3vw, 1.4rem); right:clamp(.85rem, 3vw, 1.4rem);
    z-index:1; width:40px; height:40px; border-radius:50%;
    border:1px solid var(--outline-strong); background:var(--surface-2); color:var(--ink);
    display:flex; align-items:center; justify-content:center; cursor:pointer;
    transition:background .15s ease, color .15s ease, border-color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .lightbox-close:hover{background:var(--accent); border-color:var(--accent); color:var(--accent-ink);}
  }
  .lightbox-close svg{ width:16px; height:16px; }
  @media (prefers-reduced-motion:reduce){ .lightbox{ transition-duration:0s; } }

  /* ---------------- Donate modal ---------------- */
  /* A framed embed of WBAI's real donate page. Tall + wide on desktop/tablet,
     near full-screen on phones, always with breathing room around the card. */
  /* Scrim and motion are deliberately the info sheet's, to the value: same 62%
     black, same 5px blur, and the same shared --ov-* motion tokens (it no
     longer names its own durations at all, which is the point — see the token
     block up top). It used to be darker (74%) and blurrier (6px) on an `ease` curve,
     which made the one dialog that frames someone else's page also the one
     dialog that opened differently — two unrelated signals for "you are in a
     layer", when the sheet already had a good one. Keep these in step. */
  .donate-scrim{
    position:fixed; inset:0; z-index:290;
    background:color-mix(in srgb, #000 62%, transparent);
    -webkit-backdrop-filter:blur(0px); backdrop-filter:blur(0px);
    opacity:0; visibility:hidden; pointer-events:none;
    transition:var(--ov-scrim-tr-out);
  }
  .donate-scrim.show{
    opacity:1; visibility:visible; pointer-events:auto;
    -webkit-backdrop-filter:blur(5px); backdrop-filter:blur(5px);
    transition:var(--ov-scrim-tr-in);
  }
  .donate-modal{
    position:fixed; z-index:300;
    top:50%; left:50%;
    width:min(940px, 94vw);
    /* `height`, not the sheet's `max-height`: the body is an iframe, and a
       flex child with nothing but an intrinsic 150px to go on needs the box to
       be definite or the donate page opens as a letterbox slot. Same 88dvh cap
       as the sheet, so both stop short of the top edge by the same amount. */
    height:min(88dvh, 860px);
    transform:translate(-50%, -43%) scale(.95);
    display:flex; flex-direction:column;
    background:#fff; border-radius:18px; overflow:hidden;
    box-shadow:0 30px 70px -20px rgba(0,0,0,.7), var(--elev-2);
    opacity:0; visibility:hidden; pointer-events:none;
    transition:var(--ov-panel-tr-out);
  }
  .donate-modal.show{
    opacity:1; visibility:visible; pointer-events:auto;
    transform:translate(-50%, -50%) scale(1);
    transition:var(--ov-panel-tr-in);
  }
  /* Leave room for the docked bar, exactly as the sheet does — donating while
     the live stream plays is the fund-drive case, not an edge case. */
  body.has-player .donate-modal{ height:min(76dvh, 800px); }
  .donate-frame{ flex:1 1 auto; width:100%; border:0; background:#fff; }
  /* Close control tuned for the white donate page: light chip, dark glyph. */
  .donate-close{
    position:absolute; top:12px; right:12px; z-index:2;
    width:40px; height:40px; border-radius:50%;
    display:flex; align-items:center; justify-content:center; cursor:pointer;
    border:1px solid rgba(0,0,0,.12);
    background:rgba(255,255,255,.86);
    -webkit-backdrop-filter:blur(4px); backdrop-filter:blur(4px);
    color:#222;
    box-shadow:0 2px 10px rgba(0,0,0,.18);
    transition:background .15s ease, color .15s ease, border-color .15s ease, transform .12s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .donate-close:hover{background:var(--accent); border-color:var(--accent); color:var(--accent-ink);}
  }
  .donate-close:active{ transform:scale(.94); }
  .donate-close svg{ width:16px; height:16px; }
  /* Phones: the same tall bottom sheet as the info sheet and the live player,
     sharing --phone-sheet-gap so all three sit at exactly the same height. See
     the long note on the live player for why that held-back strip of blurred
     scrim matters — an edge-to-edge panel reads as a *page you navigated to*,
     and this one wraps a real third-party page, so it was the worst offender:
     100vw x 100dvh with square corners was indistinguishable from having left
     the app entirely. The strip and the 20px top corners are what say "this is
     on top of something you can get back to". */
  @media (max-width:560px){
    .donate-modal{
      left:0; bottom:0; width:100%;
      top:max(var(--phone-sheet-gap), env(safe-area-inset-top,0px));
      height:auto; max-height:none;
      border-radius:var(--phone-sheet-radius) var(--phone-sheet-radius) 0 0;
      transform:translate(0, 100%) scale(1);
    }
    .donate-modal.show{ transform:translate(0, 0) scale(1); }
    /* It covers the docked bar, so don't also reserve room for it. Needed
       explicitly: `body.has-player .donate-modal` outranks `.donate-modal` on
       specificity, and a media query adds none — without this the desktop
       76dvh cap would win here and strand the sheet mid-screen. */
    body.has-player .donate-modal{ height:auto; }
    /* Match the sheet's phone close chip: bigger presence, and clear of the
       20px corner radius the 12px inset now sits inside. */
    .donate-close{ top:.9rem; right:.9rem; width:42px; height:42px; }
    .donate-close svg{ width:17px; height:17px; }
  }
  @media (prefers-reduced-motion:reduce){
    .donate-scrim, .donate-modal{ transition-duration:0s; transition-delay:0s; }
  }

  /* ---------------- Weekly schedule modal ---------------- */
  /* Derived client-side from the archive rows (docs/schedule-dev.md). Scrim and
     motion are the sheet's, to the value, like the donate modal — one signal
     for "you are in a layer". z-index is the deliberate part: 163/165 sits
     ABOVE the drawer (160) it opens from but BELOW the info sheet (170/180),
     so tapping a show stacks the ordinary sheet on top and closing it lands
     back on the schedule. */
  .sched-scrim{
    position:fixed; inset:0; z-index:163;
    background:color-mix(in srgb, #000 62%, transparent);
    -webkit-backdrop-filter:blur(0px); backdrop-filter:blur(0px);
    opacity:0; visibility:hidden; pointer-events:none;
    transition:var(--ov-scrim-tr-out);
  }
  .sched-scrim.show{
    opacity:1; visibility:visible; pointer-events:auto;
    -webkit-backdrop-filter:blur(5px); backdrop-filter:blur(5px);
    transition:var(--ov-scrim-tr-in);
  }
  .sched-modal{
    position:fixed; z-index:165;
    top:50%; left:50%;
    width:min(680px, 94vw);
    height:min(88dvh, 820px);
    transform:translate(-50%, -43%) scale(.95);
    display:flex; flex-direction:column;
    background:var(--surface-1); color:var(--ink);
    border-radius:18px; overflow:hidden;
    box-shadow:0 30px 70px -20px rgba(0,0,0,.7), var(--elev-2);
    opacity:0; visibility:hidden; pointer-events:none;
    transition:var(--ov-panel-tr-out);
  }
  .sched-modal.show{
    opacity:1; visibility:visible; pointer-events:auto;
    transform:translate(-50%, -50%) scale(1);
    transition:var(--ov-panel-tr-in);
  }
  body.has-player .sched-modal{ height:min(76dvh, 760px); }
  /* THE DOCKED PLAYER BAR STAYS USABLE UNDER THIS ONE.
     Every other overlay here is a task you finish and leave; the schedule is a
     thing you browse while something is playing, so burying the transport under
     it strands the listener — "minimize" in the live player or the info sheet
     hands you back a bar you cannot see or reach.
     The shortened height above was already reaching for this on desktop (it
     leaves the bar's strip clear), but the scrim is inset:0 and swallowed the
     clicks anyway — measured: the play/pause hit-test returned .sched-scrim.
     So lift the bar over BOTH scrim and modal while the schedule is up: 166 is
     above the modal (165) and still below the info sheet (170/180), which is
     opened FROM the schedule and should keep covering it.
     Only while the schedule is open — nothing else wants the bar above it. */
  body.sched-open .player-bar{ z-index:166; }
  .sched-head{
    flex:none; position:relative;
    padding:1.15rem 3.6rem 0.85rem 1.3rem;
    border-bottom:1px solid var(--outline);
  }
  .sched-title{
    margin:0; font-family:var(--font-display);
    font-size:1.25rem; font-weight:800; letter-spacing:.01em;
  }
  .sched-note{ margin:.3rem 0 0; font-size:.78rem; color:var(--ink-dim); }
  .sched-close{
    position:absolute; top:.9rem; right:.9rem; z-index:2;
    width:40px; height:40px; border-radius:50%;
    display:flex; align-items:center; justify-content:center; cursor:pointer;
    border:1px solid var(--outline-strong);
    background:var(--surface-2); color:var(--ink);
    transition:background .15s ease, color .15s ease, border-color .15s ease, transform .12s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .sched-close:hover{ background:var(--accent); border-color:var(--accent); color:var(--accent-ink); }
  }
  .sched-close:active{ transform:scale(.94); }
  .sched-close svg{ width:16px; height:16px; }
  /* Day tabs: one row, always all seven, equal widths — the whole point of the
     view is that a week fits a phone as seven taps, not seven columns. */
  .sched-tabs{
    flex:none; display:flex; gap:.3rem;
    padding:.7rem 1rem;
    border-bottom:1px solid var(--outline);
  }
  .sched-tab{
    flex:1 1 0; min-width:0;
    padding:.45rem .2rem;
    border:1px solid transparent; border-radius:999px;
    background:none; color:var(--ink-dim); cursor:pointer;
    font-family:var(--font-display); font-weight:700; font-size:.8rem;
    letter-spacing:.03em; text-align:center;
    transition:background .15s ease, color .15s ease, border-color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .sched-tab:hover{ background:var(--surface-hover); color:var(--ink); }
  }
  .sched-tab.selected{
    background:var(--accent); border-color:var(--accent); color:var(--accent-ink);
  }
  .sched-tab-today:not(.selected){
    border-color:color-mix(in srgb, var(--accent) 55%, var(--outline-strong));
    color:var(--accent);
  }
  /* Three-letter day names normally; single letters only when even those
     can't fit. The cut used to be 420px, which was far too early — it landed
     between the two current iPhone Pro sizes, so a 402px Pro got "M T W" while
     a 440px Pro Max got "Mon Tue Wed" for no reason the user could see.
     Measured: the widest label is "Today" at 32px, and a tab has 35px of inner
     room at 360px wide, 41px at 402px. Three letters fit everywhere down to
     360; only ~320px genuinely can't take them. */
  .sched-tab-min{ display:none; }
  @media (max-width:430px){
    /* Buys ~6px per tab so the 360-402px range clears "Today" with real
       margin rather than the 3px it would otherwise have. */
    .sched-tab{ padding-inline:.1rem; letter-spacing:.01em; font-size:.76rem; }
  }
  @media (max-width:344px){
    .sched-tab-full{ display:none; }
    .sched-tab-min{ display:inline; }
  }
  .sched-body{
    flex:1 1 auto; overflow-y:auto;
    padding:.6rem 1rem 1.2rem;
    overscroll-behavior:contain;   /* the page behind is locked; don't hand it the leftover scroll */
  }
  .sched-empty{ padding:2rem 0; text-align:center; color:var(--ink-dim); }
  .sched-slot{
    /* The day tabs are their own navigation row; the daily show list does not
       need to inherit the width of the first (Today) tab. Keep only a small
       gutter after the widest time label so the cards use the available row. */
    display:flex; gap:.5rem;
    padding:.55rem 0;
  }
  .sched-slot + .sched-slot{ border-top:1px solid var(--outline); }
  .sched-time{
    flex:none; width:2.9rem; padding-top:.45rem;
    display:flex; flex-direction:column; gap:.15rem;
    font-family:var(--font-display); font-weight:700; font-size:.85rem;
    font-variant-numeric:tabular-nums;
  }
  .sched-dur{ font-weight:400; font-size:.72rem; color:var(--ink-faint); }
  .sched-shows{ flex:1 1 auto; min-width:0; display:flex; flex-direction:column; gap:.35rem; }
  /* The wrap carries the card look — border, cat-colour edge, background —
     so the Listen Live pill can sit inside that same card, to the right of
     the .sched-show button, without nesting a button inside a button. */
  /* The left edge is a HANDLE, not a legend. It used to be the category's
     colour, which asked the reader to carry a key this dialog never showed —
     and the category is already spelled out in words on the line under the
     title. So it is one colour for every row now (2026-08-08), earning its
     keep as structure: it gives the row a leading edge to scan down and a
     place for the on-air state to land.

     ONLY the colour changed — the bar itself is the same flush 3px left border
     it always was, following the card's corner radius. An inset pill-shaped
     handle was tried first and read as a thin floating line that lost the
     card's weight; the chunky flush edge is the design. Tinted from --accent so
     it belongs to the app's warmth, but kept muted so it never competes with
     the on-air row, whose border-color takes the accent across all four sides
     and makes its left edge the brightest on the day. */
  .sched-show-wrap{
    --sched-handle: color-mix(in srgb, var(--accent) 30%, var(--outline-strong));
    display:flex; align-items:center; gap:.5rem;
    padding:.45rem .6rem .45rem .75rem;
    border:1px solid var(--outline); border-left:3px solid var(--sched-handle);
    border-radius:12px;
    background:var(--surface-2);
    transition:background .15s ease, border-color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .sched-show-wrap:hover{
      background:var(--surface-3); border-color:var(--outline-strong);
      border-left-color:color-mix(in srgb, var(--accent) 48%, var(--outline-strong));
    }
  }
  /* The show itself is now a bare tap target — no border/background of its
     own, both live one level up on the wrap. */
  .sched-show{
    display:flex; align-items:center; gap:.7rem;
    flex:1 1 auto; min-width:0; text-align:left;
    border:none; background:none; color:inherit; cursor:pointer;
  }
  /* On-air right now, toggled in place by schedApplyLiveHighlight() as the
     on-air feed moves show to show. The class lands on the wrap, so the whole
     row reads as "this one" and it can gate the Listen Live pill.
     There is deliberately no separate LIVE badge: the pill's own pulsing dot
     already says on-air, and running both put two competing live markers on
     one card. */
  .sched-show-wrap.sched-show-live{
    background:color-mix(in srgb, var(--accent) 10%, var(--surface-2));
    border-color:color-mix(in srgb, var(--accent) 45%, var(--outline-strong));
  }

  @media (hover:hover) and (pointer:fine){
    .sched-show-wrap.sched-show-live:hover{ background:color-mix(in srgb, var(--accent) 16%, var(--surface-3)); }
  }
  /* A badge, not a control: the whole card is the tap target on the on-air row,
     and a second clickable thing inside it would misdescribe that boundary.
     Shown only on the live wrap; every other row has one destination and needs
     no marking. */
  .sched-live-badge{
    display:none; flex:none; align-items:center; gap:.35rem; margin-left:auto;
    padding:.28rem .55rem;
    border:1px solid color-mix(in srgb, var(--accent) 45%, var(--outline-strong));
    border-radius:999px; background:color-mix(in srgb, var(--accent) 14%, transparent);
    color:var(--ink);
  }
  .sched-show-wrap.sched-show-live .sched-live-badge{ display:inline-flex; }
  .sched-live-dot{
    width:6px; height:6px; border-radius:50%; flex:none; background:var(--accent);
    box-shadow:0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
    animation:pulse 1.8s infinite;
  }
  .sched-live-word{
    font-family:var(--font-display); font-weight:800; font-size:.62rem;
    text-transform:uppercase; letter-spacing:.08em; line-height:1; white-space:nowrap;
  }
  /* Already listening: the badge stops advertising and starts reporting, so the
     row answers "am I on the live stream or in the archive?" without being read. */
  .sched-show-wrap.sched-live-playing .sched-live-badge{
    background:var(--accent); border-color:var(--accent); color:var(--accent-ink);
  }
  .sched-show-wrap.sched-live-playing .sched-live-dot{
    background:var(--accent-ink); box-shadow:none; animation:none;
  }
  @media (prefers-reduced-motion:reduce){
    .sched-live-dot, .live-choice-dot, .live-choice-btn-dot{ animation:none; }
  }
  .sched-thumb{
    flex:none; width:42px; height:42px; border-radius:9px; overflow:hidden;
    /* station icon shows when a show has no photo (or its photo fails to
       load) — same fallback layering as .show-thumb / .card-art */
    background:url("/assets/kpfk-artwork-placeholder.svg") center/cover no-repeat, var(--surface-2);
    box-shadow:inset 0 0 0 1px var(--outline);
  }
  .sched-thumb img{ width:100%; height:100%; object-fit:cover; display:block; }
  .sched-thumb img.failed{ display:none; }
  .sched-text{ min-width:0; display:flex; flex-direction:column; gap:.12rem; }
  .sched-show-title{
    font-weight:600; font-size:.92rem;
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  .sched-show-meta{
    font-size:.75rem; color:var(--ink-dim);
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  }
  .sched-alt-note{
    align-self:flex-start; padding-left:.2rem;
    font-size:.7rem; font-style:italic; color:var(--ink-faint);
  }
  /* Phones: the same tall bottom sheet as the info sheet / live player / donate,
     sharing --phone-sheet-gap so all of them sit at exactly the same height. */
  @media (max-width:560px){
    .sched-modal{
      left:0; width:100%;
      /* Stops where the docked bar starts instead of covering it — see the
         z-index note above. 0px when no bar is up. */
      bottom:var(--player-h, 0px);
      top:max(var(--phone-sheet-gap), env(safe-area-inset-top,0px));
      height:auto; max-height:none;
      border-radius:var(--phone-sheet-radius) var(--phone-sheet-radius) 0 0;
      transform:translate(0, 100%) scale(1);
    }
    .sched-modal.show{ transform:translate(0, 0) scale(1); }
    /* Unlike the info sheet / live player / donate, this one does NOT cover the
       docked bar — see the z-index note above. The sheet ends where the bar
       begins, so the transport stays visible and tappable while you browse the
       week (the bottom:--player-h above does it). Height stays auto: top and
       bottom already pin it. */
    body.has-player .sched-modal{ height:auto; }
    .sched-close{ width:42px; height:42px; }
    .sched-close svg{ width:17px; height:17px; }
    /* Tighten the small gutter a little further on phones. The time column is
       already sized to "12 PM", its widest possible label, at every width. */
    .sched-slot{ gap:.35rem; }
    /* The live row can't fit thumb + title + pill on one line at phone widths
       — the title had nothing left and collapsed to a single letter. Drop the
       pill to its own line below, sized to its own content and aligned left,
       not stretched across the card. */
    /* The badge is small enough to share the row with the title, so the live
       card no longer has to break onto a second line the way the old pill
       forced it to. */
    .sched-live-word{ font-size:.58rem; }
  }
  @media (prefers-reduced-motion:reduce){
    .sched-scrim, .sched-modal{ transition-duration:0s; transition-delay:0s; }
  }
  .sheet-titles{ display:flex; flex-direction:column; gap:.5rem; min-width:0; padding-top:.15rem; }
  .sheet-eyebrow{
    display:inline-flex; align-items:center; gap:.4rem; align-self:flex-start;
    font-family:var(--font-display); text-transform:uppercase; letter-spacing:.08em;
    font-size:.66rem; font-weight:700; color:var(--ink-dim);
  }
  .sheet-eyebrow .cat-icon{ width:15px; height:15px; flex:none; color:var(--ink-dim); }
  #sheetTitle{
    font-family:var(--font-display); font-weight:700; letter-spacing:.01em;
    font-size:clamp(1.2rem, 1rem + .95vw, 1.5rem); line-height:1.18;
    margin:0; text-wrap:balance;
  }
  .sheet-host{ font-size:.9rem; color:var(--accent-2); }

  .sheet-desc-wrap{ display:flex; flex-direction:column; align-items:flex-start; gap:.4rem; }
  .sheet-desc{
    margin:0; color:var(--ink-dim); font-size:.94rem; line-height:1.6;
    /* clamped until the reader asks for the rest */
    display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:5; overflow:hidden;
  }
  .sheet-desc-wrap.expanded .sheet-desc{ -webkit-line-clamp:none; overflow:visible; }
  .desc-toggle{
    padding:0; border:0; background:none; font-family:inherit;
    font-size:.78rem; font-weight:700; color:var(--accent-2); cursor:pointer;
    transition:color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .desc-toggle:hover{color:color-mix(in srgb, var(--accent-2) 60%, var(--ink));}
  }

  /* ---- selected broadcast: the profile's one stable episode action ---- */
  .sheet-selected{ display:flex; flex-direction:column; gap:.45rem; }
  .sheet-selected-k{
    font-family:var(--font-display); text-transform:uppercase; letter-spacing:.08em;
    font-size:.64rem; font-weight:800; color:var(--ink-faint);
  }
  .sheet-selected-line{ display:flex; align-items:baseline; gap:.55rem; flex-wrap:wrap; }
  .sheet-selected-date{ font-size:.9rem; font-weight:700; color:var(--ink); }
  .sheet-selected-time, .sheet-selected-length{
    font-family:var(--font-mono); font-size:.78rem; color:var(--ink-dim);
  }
  .sheet-listen{
    display:flex; align-items:center; gap:.65rem; min-height:18px;
    color:var(--accent-2); font-size:.75rem; font-weight:650;
  }
  .sheet-listen[hidden]{ display:none; }
  .sheet-listen-check{ width:14px; height:14px; flex:none; }
  .listen-progress{
    position:relative; flex:1 1 130px; max-width:230px; height:3px;
    overflow:hidden; border-radius:99px;
    background:color-mix(in srgb, var(--ink) 18%, transparent);
  }
  .listen-progress::after{
    content:''; position:absolute; inset:0 auto 0 0; width:var(--pct,0%);
    border-radius:inherit; background:var(--accent-2);
  }
  .sheet-profile-links{ margin:0; padding-top:.8rem; border-top:1px solid var(--outline); }
  .sheet-archive-open{
    width:auto; max-width:100%; min-height:44px; align-self:flex-start;
    display:flex; align-items:center; justify-content:center;
    padding:.5rem .8rem; border:1px solid var(--outline-strong); border-radius:999px;
    background:var(--surface-2); color:var(--ink-dim); font-family:inherit; text-align:center;
    cursor:pointer; transition:background .15s ease, color .15s ease, border-color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .sheet-archive-open:hover{ background:var(--surface-3); color:var(--ink); border-color:var(--outline-strong); }
  }
  .sheet-archive-primary{ display:inline-flex; align-items:center; gap:.55rem; flex:0 1 auto; min-width:0; }
  .sheet-archive-label{ font-size:.83rem; font-weight:750; }
  .sheet-archive-count{
    padding:.12rem .42rem; border-radius:99px; background:var(--surface-3);
    color:var(--ink-dim); font-family:var(--font-mono); font-size:.68rem;
  }
  .sheet-archive-history{ display:inline-flex; align-items:center; gap:.55rem; min-width:0; }
  .sheet-archive-history[hidden]{ display:none; }
  .sheet-archive-sep, .sheet-archive-summary{ color:var(--ink-faint); font-size:.7rem; white-space:nowrap; }
  .sheet-archive-summary{ overflow:hidden; text-overflow:ellipsis; }
  .sheet-archive-arrow{ flex:none; width:16px; height:16px; margin-left:-.1rem; color:var(--ink-dim); }

  /* Preserve the route instead of squeezing it: on the narrowest phones the
     profile already reports selected-episode progress immediately above. */
  @media (max-width:360px){ .sheet-archive-history{ display:none; } }

  /* ---- past episodes route ----
     Rows have enough room to say date, listening state, retention and action in
     words. Untouched episodes carry no status decoration. */
  .sheet.archive-view .sheet-body{ padding:0; gap:0; }
  .sheet-archive-head{
    display:flex; align-items:center; gap:.8rem; padding:1rem 1.35rem;
    border-bottom:1px solid var(--outline); background:var(--surface-1);
  }
  .sheet-archive-art{
    flex:none; width:48px; height:48px; border-radius:10px; overflow:hidden;
    background:url("/assets/kpfk-artwork-placeholder.svg") center/cover no-repeat, var(--surface-2);
  }
  .sheet-archive-art img{ width:100%; height:100%; object-fit:cover; display:block; }
  .sheet-archive-identity{ min-width:0; display:flex; flex-direction:column; gap:.2rem; }
  .sheet-archive-title{
    overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
    font-size:.92rem; font-weight:750;
  }
  .sheet-archive-tally{ font-size:.72rem; color:var(--ink-dim); }
  .sheet-episode-list{ display:flex; flex-direction:column; }
  .sheet-episode{
    position:relative; display:grid; grid-template-columns:minmax(0,1fr) auto;
    align-items:stretch; border-bottom:1px solid var(--outline);
    background:var(--surface-1); transition:background .15s ease;
  }
  .sheet-episode.is-playing{
    /* A restrained lift from the neutral rows: enough to find the audio at a
       glance, while orange remains the only transport/action emphasis. */
    background:color-mix(in srgb, var(--accent-2) 11%, var(--surface-1));
    box-shadow:inset 3px 0 var(--accent-2);
  }
  .sheet-episode-open{
    min-width:0; display:flex; flex-direction:column; gap:.32rem;
    padding:.85rem .7rem .8rem 1.35rem; border:0; background:transparent;
    color:var(--ink); font-family:inherit; text-align:left; cursor:pointer;
  }
  .sheet-episode-date-line{ display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; }
  .sheet-episode-date{ font-size:.86rem; font-weight:750; }
  .sheet-episode-time{ font-family:var(--font-mono); font-size:.72rem; color:var(--ink-dim); }
  .sheet-episode-meta{
    display:flex; align-items:center; gap:.42rem; flex-wrap:wrap;
    color:var(--ink-faint); font-size:.69rem;
  }
  .sheet-episode-meta .mono{ font-family:var(--font-mono); }
  .sheet-episode-status{ color:var(--accent-2); font-weight:700; }
  .sheet-episode-progress{ width:min(210px, 58%); flex:none; }
  .sheet-episode-play{
    align-self:stretch; width:58px; min-height:66px; border:0; border-left:1px solid var(--outline);
    border-radius:0; background:transparent; color:var(--accent); cursor:pointer;
    display:flex; flex-direction:column; gap:.18rem; align-items:center; justify-content:center;
    transition:background .15s ease, color .15s ease;
  }
  /* These are always icon transports. Global .play-btn states use a filled
     treatment for card buttons, so outrank that rule here and keep the archive
     glyph orange whether it means play, pause or loading. */
  .sheet-episode-play.play-btn.playing,
  .sheet-episode-play.play-btn.loading{
    background:transparent; color:var(--accent); border-color:var(--outline);
  }
  .sheet-episode-play .play-glyph{ display:inline-flex; align-items:center; justify-content:center; }
  .sheet-episode-instead{
    font:800 .56rem/1 var(--font-display); letter-spacing:.05em; text-transform:uppercase;
  }
  .sheet-episode-instead[hidden]{ display:none; }
  .sheet-episode-play svg{ width:18px; height:18px; }
  @media (hover:hover) and (pointer:fine){
    .sheet-episode-open:hover, .sheet-episode-play:hover{ background:var(--surface-hover); }
  }
  .sheet-episode-open:focus-visible, .sheet-episode-play:focus-visible,
  .sheet-archive-open:focus-visible, .sheet-player-open:focus-visible,
  .sheet-player-toggle:focus-visible, .sheet-route-back:focus-visible{
    outline:2px solid var(--accent-2); outline-offset:-3px;
  }

  /* ---- on-air chooser ----
     Above the schedule (z 200 vs its 180) the way the lightbox sits above the
     sheet. Small and centred: it asks one question and offers exactly three
     answers, so it should never look like another page. */
  /* Same recipe as the four big dialogs, deliberately quicker: this one asks a
     question and travels almost no distance, and a .4s spring on a small box
     that barely moves reads as sluggish rather than considered. Rather than
     hand-write a second set of transitions (which is how the five of them
     drifted apart last time), it overrides the duration tokens and inherits the
     identical curve — so it still *feels* like the rest of the app. */
  .live-choice-scrim, .live-choice{ --ov-in:.28s; --ov-out:.16s; --ov-scrim-in:.18s; --ov-scrim-out:.16s; }
  .live-choice-scrim{
    position:fixed; inset:0; z-index:195;
    background:rgba(0,0,0,.55);
    backdrop-filter:blur(0px); -webkit-backdrop-filter:blur(0px);
    opacity:0; visibility:hidden; pointer-events:none;
    transition:var(--ov-scrim-tr-out);
  }
  .live-choice-scrim.show{
    opacity:1; visibility:visible; pointer-events:auto;
    backdrop-filter:blur(2px); -webkit-backdrop-filter:blur(2px);
    transition:var(--ov-scrim-tr-in);
  }
  .live-choice{
    position:fixed; z-index:200; left:50%; top:50%;
    width:min(400px, calc(100vw - 2.2rem));
    display:flex; flex-direction:column; gap:.55rem;
    padding:1.5rem 1.4rem 1.2rem;
    background:var(--surface-1); color:var(--ink);
    border:1px solid var(--outline-strong); border-radius:18px;
    box-shadow:0 30px 70px -20px rgba(0,0,0,.7), var(--elev-2);
    transform:translate(-50%, -48%) scale(.96); opacity:0; visibility:hidden;
    transition:var(--ov-panel-tr-out);
  }
  .live-choice.show{
    transform:translate(-50%, -50%) scale(1); opacity:1; visibility:visible;
    transition:var(--ov-panel-tr-in);
  }
  @media (prefers-reduced-motion:reduce){
    .live-choice-scrim, .live-choice{ transition-duration:0s; transition-delay:0s; }
    .live-choice{ transform:translate(-50%,-50%) scale(1); }
  }
  .live-choice-eyebrow{
    display:flex; align-items:center; gap:.4rem; margin:0;
    font-family:var(--font-display); font-weight:800; font-size:.66rem;
    text-transform:uppercase; letter-spacing:.09em; color:var(--accent);
  }
  .live-choice-dot{
    width:7px; height:7px; border-radius:50%; flex:none; background:var(--accent);
    box-shadow:0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent);
    animation:pulse 1.8s infinite;
  }
  .live-choice-title{ margin:0; font-size:1.25rem; line-height:1.25; }
  .live-choice-ask{ margin:0 0 .45rem; font-size:.88rem; color:var(--ink-dim); }
  .live-choice-actions{ display:flex; flex-direction:column; gap:.5rem; }
  .live-choice-btn{
    display:flex; align-items:center; justify-content:center; gap:.5rem;
    padding:.8rem 1rem; border-radius:999px; border:1px solid transparent;
    font-family:inherit; font-size:.92rem; font-weight:700; cursor:pointer;
    transition:background .15s ease, color .15s ease, border-color .15s ease, transform .12s ease;
  }
  .live-choice-btn:active{ transform:scale(.98); }
  .live-choice-btn svg{ width:16px; height:16px; }
  /* Live is the reason this dialog appeared, so it leads. */
  .live-choice-live{ background:var(--accent); color:var(--accent-ink); box-shadow:var(--shadow); }
  @media (hover:hover) and (pointer:fine){
    .live-choice-live:hover{ background:color-mix(in srgb, var(--accent) 88%, black); }
  }
  .live-choice-btn-dot{
    width:8px; height:8px; border-radius:50%; flex:none; background:var(--accent-ink);
    box-shadow:0 0 0 0 color-mix(in srgb, var(--accent-ink) 55%, transparent);
    animation:pulse 1.8s infinite;
  }
  /* Already listening: this button stops being an offer and becomes a way back,
     so it drops the filled treatment that says "start me". */
  .live-choice-live.is-playing{
    background:transparent; color:var(--ink); border-color:var(--accent); box-shadow:none;
  }
  .live-choice-live.is-playing .live-choice-btn-dot{ background:var(--accent); animation:none; }
  .live-choice-archive{ background:var(--surface-2); color:var(--ink); border-color:var(--outline-strong); }
  @media (hover:hover) and (pointer:fine){
    .live-choice-archive:hover{ background:var(--surface-3); border-color:var(--accent-2); color:var(--ink); }
  }
  .live-choice-cancel{
    align-self:center; margin-top:.15rem; padding:.5rem 1rem;
    border:0; background:transparent; color:var(--ink-dim);
    font-family:inherit; font-size:.84rem; font-weight:600; cursor:pointer;
  }
  @media (hover:hover) and (pointer:fine){ .live-choice-cancel:hover{ color:var(--ink); } }

  .sheet-actions{ display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; }
  /* secondary row: smaller pills, above the primary transport controls */
  .sheet-links{ display:flex; align-items:center; gap:.4rem; flex-wrap:wrap; margin-bottom:.7rem; }
  /* also carries .play-btn so updatePlayButtons() keeps its glyph and label in
     step — width/height undo the round 36px icon button that class implies */
  .sheet-play{
    display:inline-flex; align-items:center; gap:.5rem; cursor:pointer;
    width:auto; height:auto;
    border:1px solid transparent; border-radius:999px; padding:.6rem 1.2rem;
    background:var(--accent); color:var(--accent-ink); font-family:inherit;
    font-size:.88rem; font-weight:700; box-shadow:var(--shadow);
    transition:background .15s ease, transform .12s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .sheet-play:hover{background:color-mix(in srgb, var(--accent) 88%, black);}
  }
  .sheet-play:active{ transform:scale(.97); }
  .sheet-play svg{ width:15px; height:15px; }
  .sheet-play[hidden]{ display:none; }
  /* The primary action stays solid orange whether or not other audio is loaded.
     It was an outline in that case, to mark it as a replacement choice rather
     than the current transport. Two problems with that, both found by looking
     at it: an outlined pill on a dark ground reads as secondary-or-destructive
     — it was reported as a "danger red" button, though the colour was always
     --accent — and it de-emphasised the one control the sheet is there to
     offer. The distinction it was drawing is carried better by the dock below,
     which names the loaded episode and shows a pause glyph, and by this
     button's own dated label. `.is-alternate` is still applied (the archive
     rows' own `instead` badge keys off the same state); it just no longer
     restyles this button. */
  .sheet-link{
    display:inline-flex; align-items:center; gap:.35rem;
    border:1px solid var(--outline-strong); border-radius:999px; padding:.32rem .7rem;
    color:var(--ink-dim); text-decoration:none; font-size:.75rem; font-weight:600;
    transition:background .15s ease, color .15s ease, border-color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .sheet-link:hover{background:var(--surface-hover); color:var(--accent-2); border-color:var(--accent-2);}
  }
  .sheet-link svg{ width:12px; height:12px; }
  /* Two wordings for one pill; the phone block below swaps which one shows. */
  .sheet-link .link-narrow{ display:none; }
  /* sits beside Play, and only while that Play button reads "Resume …" */
  .sheet-restart{
    border:1px solid var(--outline-strong); border-radius:999px; padding:.55rem 1rem;
    background:transparent; color:var(--ink-dim); font-family:inherit;
    font-size:.84rem; font-weight:600; cursor:pointer;
    transition:background .15s ease, color .15s ease, border-color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .sheet-restart:hover{background:var(--surface-hover); color:var(--ink); border-color:var(--accent-2);}
  }
  .sheet-restart[hidden]{ display:none; }
  /* carries .sheet-link, so it only needs the bits a <button> doesn't inherit */
  .sheet-share{ background:transparent; font-family:inherit; cursor:pointer; }

  /* ---- rotated-out deep link ---- */
  .link-notice{
    display:flex; align-items:flex-start; gap:.75rem;
    max-width:1180px; margin:0 auto 1rem; padding:.85rem 1rem;
    border:1px solid var(--outline-strong); border-left:3px solid var(--warn);
    border-radius:12px; background:var(--warn-bg); color:var(--ink);
    font-size:.86rem; line-height:1.45;
  }
  .link-notice[hidden]{ display:none; }
  .link-notice-close{
    flex:none; width:26px; height:26px; margin-left:auto; border:none; border-radius:50%;
    background:transparent; color:var(--ink-dim); cursor:pointer;
    display:flex; align-items:center; justify-content:center;
    transition:background .15s ease, color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .link-notice-close:hover{background:var(--surface-hover); color:var(--ink);}
  }
  .link-notice-close svg{ width:13px; height:13px; }
  @media (max-width:900px){
    .link-notice{ margin-inline:1rem; }
  }

  /* Phones: same tall bottom sheet as the live player — see the note there for
     why it stops short of the top. Previously capped at 90dvh, and at 76dvh
     whenever the player bar was up, which on a 360x640 handset left ~430px for
     art, title, description and controls; the gap costs a fraction of what that
     did. Both dialogs share --phone-sheet-gap so they sit at the same height. */
  @media (max-width:560px){
    .sheet{
      left:0; bottom:0; width:100%;
      top:auto; height:auto;
      max-height:calc(100dvh - max(var(--phone-sheet-gap), env(safe-area-inset-top,0px)));
      border-radius:var(--phone-sheet-radius) var(--phone-sheet-radius) 0 0;
      border-bottom:none;
      transform:translate(0, 100%) scale(1);
    }
    .sheet.show{ transform:translate(0, 0) scale(1); }
    /* Already a bottom sheet: it slides straight back down onto the bar, which
       *is* the collapse. Scaling toward a measured point would fight the dock. */
    .sheet.minimizing{ transform:translate(0, 100%) scale(1); }
    /* It covers the docked bar, so don't also reserve room for it. */
    body.has-player .sheet{
      max-height:calc(100dvh - max(var(--phone-sheet-gap), env(safe-area-inset-top,0px)));
    }
    .sheet-body{ padding:1.3rem 1.1rem 1.1rem; }
    .sheet-foot{ padding:.9rem 1.1rem calc(1.1rem + env(safe-area-inset-bottom,0px)); border-radius:0; }
    .sheet.has-sheet-player .sheet-foot{ padding-bottom:.9rem; }
    .sheet-routebar{ min-height:62px; padding:.65rem 4.2rem .65rem .75rem; }
    .sheet-routebar strong{ font-size:.86rem; }
    .sheet-route-back{ padding-inline:.4rem; }
    .sheet-player-dock{
      padding:.72rem 1rem calc(.8rem + env(safe-area-inset-bottom,0px));
      border-radius:0;
    }
    .sheet-player-copy{ flex-direction:column; align-items:flex-start; gap:.05rem; width:100%; }
    .sheet-player-title{ max-width:100%; }
    .sheet-player-scrub{ margin-top:.4rem; }
    .sheet-head{ flex-direction:column; align-items:center; text-align:center; padding-right:0; gap:1rem; }
    .sheet-art{ width:min(184px, 48vw, 22dvh); height:min(184px, 48vw, 22dvh); }
    .sheet-titles{ align-items:center; }
    .sheet-archive-open{ align-self:center; }
    .sheet-actions{ justify-content:flex-start; }
    .sheet-links{ justify-content:center; }
    /* .sheet-titles is shrink-to-fit inside a centred column, so flex-start
       parks the eyebrow against the title's left edge — visibly off-centre
       above a centred heading. Centre it with the rest of the block. */
    .sheet-eyebrow{ align-self:center; }
    /* Two lines, then "Show more": the sheet has to fit Play above the fold on
       a phone, and five clamped lines was most of the viewport. */
    .sheet-desc{ -webkit-line-clamp:2; }
    /* The toggle keeps its 44px touch box (see the pointer:coarse block); this
       only pulls the visible word back up under the text it belongs to. */
    .sheet-desc-wrap{ gap:0; }
    .sheet-desc-wrap .desc-toggle{ margin-top:-.45rem; }
    .sheet-link .link-wide{ display:none; }
    .sheet-link .link-narrow{ display:inline; }
    /* Four pills on one row is the whole point of the narrow wordings above, and
       at ~400px they were still ~10px over — Share wrapped to a row of its own.
       Buy the room back twice: shave the pill's side padding and the row gap,
       and let Share go icon-only (its .link-wide span is hidden by the rule
       above, so nothing extra is needed here but the shape). */
    .sheet-links{ gap:.35rem; }
    .sheet-link{ padding-inline:.55rem; }
    .sheet-share{ justify-content:center; }
    .sheet-share svg{ width:14px; height:14px; }
    .sheet-selected-line{ gap:.35rem .5rem; }
    .sheet-archive-summary{ display:none; }
    .sheet.archive-view .sheet-body{ padding:0; }
    .sheet-archive-head{ padding:.85rem 1rem; }
    .sheet-episode-open{ padding:.78rem .65rem .75rem 1rem; }
    .sheet-episode-play{ width:54px; }
    .sheet-episode-progress{ width:min(180px, 72%); }
  }

  /* ================= Responsive ================= */
  @media (max-width: 900px){
    .row{grid-template-columns: 1fr auto;}
    .row.head{display:none;}
    .row.body{
      grid-template-columns: 1fr auto;
      grid-template-areas: "title play" "meta meta" "retention retention";
      row-gap:.5rem;
    }
    .show-cell{grid-area:title;}
    .cell-date{grid-area:meta; display:flex; gap:1rem; align-items:baseline;}
    .cell-duration{display:none;}
    .cell-duration.inline-meta{display:inline; grid-area:unset;}
    .retention{grid-area:retention; justify-self:start;}
    .appbar-actions .btn-label{display:none;}
    .btn-icon-label{display:none;}
    .appbar-actions .btn-donate{padding:.38rem .75rem;}
    .btn-donate .donate-label{font-size:.68rem; letter-spacing:.07em;}
    .listing{padding-bottom:6rem;}
  }
  @media (min-width: 901px){
    .cell-duration.inline-meta{display:none;}
  }
  @media (max-width:560px){
    .appbar .brand-name{display:none;}
    .row.body{padding:.75rem .85rem;}
  }

  /* 480px, not 560px, and the difference is visible: the split below spreads
     the row to both edges, and the gap it opens grows with the viewport —
     ~26px at 320px, ~118px at 412px, but ~255px at 560px, where the bar stops
     reading as two groups and starts reading as two strays. 480px is the top
     of phone-portrait; above it the wordmark comes back and the old
     right-aligned cluster is the better answer anyway. */
  @media (max-width:480px){
    /* ---- Phones: drop the appbar wordmark ----
       It is the one genuinely redundant thing in the header here: the hero
       repeats the identical logo four times the size, ~40px lower, above the
       fold on every load. Removing it returns ~74px to a row that was being
       shaved from five directions to fit — so the gaps, the pill padding and
       the appbar's own gutter all go back to their comfortable values below,
       and "Listen Live" stops wrapping to two lines even at 320px.

       NOTE the rule that used to live here — a bare `.brand-logo{height:20px}`.
       .brand-logo is worn by BOTH the appbar wordmark and the drawer's, and at
       equal specificity this sat ~440 lines after `.menu-logo{height:22px}` and
       quietly won, so the drawer logo has been shrinking on phones for reasons
       nobody intended. With the appbar copy gone the rule had no legitimate
       target left, so it is deleted rather than scoped. */
    .appbar .brand{display:none;}
    /* Nothing on the left any more, so the row spans the bar and splits into
       its two natural halves — the station's actions at the leading edge, the
       utility chips at the trailing one. Right-aligning the lot instead would
       leave ~70px of dead space where the wordmark used to be. */
    .appbar-actions{margin-left:0; flex:1 1 auto; gap:.55rem;}
    .appbar-actions .theme-btn{margin-left:auto;}
  }

  /* ---- Phones: give the meta strip back to the listing ----
     The tally line costs a full row of vertical space to report a number the
     listing itself is already showing. It goes clipped-but-spoken (see the
     .is-count note in app.js — it is a live region, so display:none would
     silence it too), the date collapses to its short form, and the two survivors
     split the row: date hard left, layout toggle hard right. */
  @media (max-width:720px){
    .result-count.is-count{
      position:absolute; width:1px; height:1px; margin:-1px; padding:0;
      overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0;
    }
    .clock-long{display:none;}
    .clock-short{display:inline;}
    /* fill the row the hidden tally left behind, then push everything but the
       date to the right edge */
    .result-meta-right{flex:1 1 auto; justify-content:flex-end;}
    #clock{margin-right:auto;}

    /* With the tally gone this strip is one 44px control and a short date, and
       it was carrying 30px of vertical padding around them — .85rem/1rem sized
       for the two-line block it used to be. The row keeps its own height from
       the toggle's tap target, which is untouched; only the air around it goes.
       Slightly more below than above so the strip reads as belonging to the
       listing it labels rather than floating between the two. */
    .controls{padding-top:.4rem; padding-bottom:.55rem;}
  }

  /* Phones: brand left, On Air + Donate + menu right, all on one row.
     The side padding tracks --page-gutter so the hamburger's right edge lines
     up with the search field, the view toggle and the listing below it — the
     app bar was the last thing still sitting on its own margin. */
  @media (max-width:760px){
    .appbar-inner{padding:.6rem var(--page-gutter);}
    .brand{order:1;}
    .appbar-actions{order:2; margin-left:auto; gap:.55rem;}
  }

  /* The header used to be squeezed from five directions here — tighter gutter,
     .3rem gaps, an 18px wordmark, less padding inside both pills — purely to
     fit the theme switch at 360px. Dropping the appbar wordmark (see the 560px
     block above) returned ~74px, which is far more than all of that bought, so
     every one of those overrides is gone and the row runs at its natural size.
     Kept deliberately: `.on-air-btn{padding:.38rem .7rem}` and the smaller
     .on-air-label further up this file, which predate all of it. */

  /* ---------------- Player bar ---------------- */
  body.has-player .listing{padding-bottom:11rem;}
  .player-bar{
    position:fixed; left:0; right:0; bottom:0; z-index:80;
    display:flex; align-items:center; gap:.85rem;
    background:color-mix(in srgb, var(--surface-2) 96%, transparent);
    backdrop-filter:blur(12px);
    border-top:1px solid var(--outline-strong);
    box-shadow:0 -6px 24px rgba(0,0,0,.35);
    padding:.6rem 1rem calc(.6rem + env(safe-area-inset-bottom,0px));
  }
  .player-bar[hidden]{display:none;}
  /* The other half of the minimize gesture: the card collapses toward the bar,
     and the bar lights up as it lands so the handoff reads as one movement
     rather than a disappearance. Added by app.js for the length of the run. */
  .player-bar.arrived{ animation:player-bar-arrive .6s ease-out; }
  @keyframes player-bar-arrive{
    0%   { border-top-color:var(--outline-strong); box-shadow:0 -6px 24px rgba(0,0,0,.35); }
    30%  { border-top-color:var(--accent);
           box-shadow:0 -6px 24px rgba(0,0,0,.35), 0 -2px 22px -4px color-mix(in srgb, var(--accent) 70%, transparent); }
    100% { border-top-color:var(--outline-strong); box-shadow:0 -6px 24px rgba(0,0,0,.35); }
  }
  @media (prefers-reduced-motion:reduce){ .player-bar.arrived{ animation:none; } }
  .player-transport{display:flex; align-items:center; gap:.35rem; flex:none;}
  .player-skip{
    width:34px; height:34px; border-radius:50%; border:none; flex:none;
    background:transparent; color:var(--ink-dim); cursor:pointer;
    display:flex; align-items:center; justify-content:center;
    transition:background .15s ease, color .15s ease, transform .12s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .player-skip:hover{background:var(--surface-hover); color:var(--ink);}
  }
  .player-skip:active{transform:scale(.92);}
  .player-skip:focus-visible{outline:none; box-shadow:0 0 0 3px color-mix(in srgb, var(--accent-2) 35%, transparent);}
  .player-skip svg{width:21px; height:21px;}
  .player-skip svg text{font-family:var(--font-body); letter-spacing:-.02em;}
  /* Hidden on phones and small tablets. 420px was the old cutoff and it was too
     low to be the phone rule it was meant to be — an iPhone Pro Max is 430px, so
     the ±15s pair survived on the largest phones, where they are also the most
     awkward: on a phone the transport is thumb-work at the bottom edge, and two
     34px satellites 6px from the play button are the two things you hit when you
     meant to hit pause. 700px rather than a new number of its own because that
     is already where this bar changes shape — below it the status text goes, the
     scrubber takes the full width and Close leaves the flex row (see the blocks
     further down). Scrubbing to a spot is the gesture that replaces them, and it
     is the one that got the width. ← / → still skip on any keyboard (app.js);
     nothing here removes the capability, only the buttons. */
  @media (max-width:700px){
    .player-skip{display:none;}
  }
  /* A phone turned sideways is still a phone, and a width rule stops seeing it:
     a Pro Max in landscape is 932px wide. Height is what stays phone-sized, so
     ask that instead — every landscape phone is under 500px tall, every tablet
     is comfortably over it (iPad landscape is 768+). Paired with pointer:coarse
     so a short desktop window, which has a mouse and no thumb problem, keeps
     its buttons. */
  @media (max-height:500px) and (pointer:coarse){
    .player-skip{display:none;}
  }
  .player-toggle{
    width:42px;height:42px;border-radius:50%;border:none; flex:none;
    background:var(--accent); color:var(--accent-ink); display:flex; align-items:center; justify-content:center;
    cursor:pointer;
  }
  .player-toggle svg{width:18px;height:18px;}
  /* Connect state, same contract as .lp-toggle: spinner replaces the glyph
     while we are waiting for audio, so the bar never shows a pause icon over
     silence. */
  .player-toggle.loading svg{display:none;}
  .player-toggle .spinner{display:none;}
  .player-toggle.loading .spinner{
    display:block; width:18px;height:18px; border-radius:50%;
    border:2px solid rgba(255,255,255,.35); border-top-color:#fff;
    animation:spin .7s linear infinite;
  }
  @media (prefers-reduced-motion:reduce){ .player-toggle.loading .spinner{animation-duration:1.4s;} }
  .player-art{
    width:44px; height:44px; border-radius:9px; flex:none; overflow:hidden;
    /* station icon shows if the playing show has no artwork */
    background:url("/assets/kpfk-artwork-placeholder.svg") center/cover no-repeat, var(--surface-3);
    box-shadow:inset 0 0 0 1px var(--outline);
  }
  .player-art img{width:100%; height:100%; object-fit:cover; display:block;}
  .player-art img.failed,
  .player-art img:not([src]){display:none;}
  /* Phones: the artwork is the largest thing in the bar, so the air around it is
     the most visible thing in the bar too — a 44px thumb floating in .85rem of
     gap on either side plus a 1rem gutter. Tighten all three and hand the pixels
     to the image, which is what people actually look at.

     Height is deliberately near-neutral. Archive mode doesn't move at all: the
     title/sub/scrubber column is already taller than 52px, so it, not the art,
     sets the bar height. Live mode has no scrubber and IS art-driven, so it
     trims its own vertical padding to pay for most of the 8px. The padding is
     scoped to .live for a reason — the touch-target rules further down grow the
     scrubber with an -8px bottom margin that eats into the bar's bottom padding,
     and there is no room left to take from it in archive mode. */
  @media (max-width:560px){
    .player-bar{ gap:.55rem; padding-inline:.7rem; }
    .player-bar.live{
      padding-top:.45rem;
      padding-bottom:calc(.45rem + env(safe-area-inset-bottom,0px));
    }
    .player-art{ width:52px; height:52px; border-radius:10px; }
  }
  .player-main{display:flex; flex-direction:column; justify-content:center; gap:.35rem; min-width:0; flex:1 1 auto;}
  /* art + title double as the trigger for the info modal */
  .player-open{border:0; background:none; padding:0; margin:0; font:inherit; color:inherit; text-align:left; cursor:pointer;}
  .player-art.player-open{ background:url("/assets/kpfk-artwork-placeholder.svg") center/cover no-repeat, var(--surface-3); transition:box-shadow .15s ease; }
  @media (hover:hover) and (pointer:fine){
    .player-art.player-open:hover{box-shadow:inset 0 0 0 1px var(--accent-2);}
  }
  .player-info{display:flex; flex-direction:column; min-width:0; line-height:1.3;}
  .player-title-line{ display:flex; align-items:center; gap:.4rem; min-width:0; }
  .player-live-source{
    flex:none; display:inline-flex; align-items:center; gap:.28rem;
    color:var(--accent); font:800 .62rem/1 var(--font-display);
    text-transform:uppercase; letter-spacing:.08em;
  }
  .player-live-source[hidden]{ display:none; }
  .player-live-source i{ width:6px; height:6px; border-radius:50%; background:var(--accent); }
  .player-bar.live.is-playing .player-live-source i{ animation:pulse 1.8s infinite; }
  @media (prefers-reduced-motion:reduce){
    .player-bar.live.is-playing .player-live-source i{ animation:none; }
  }
  .player-info.player-open:focus-visible .player-title{ color:var(--accent-2); }
  @media (hover:hover) and (pointer:fine){
    .player-info.player-open:hover .player-title,.player-art.player-open:hover ~ .player-main .player-title{color:var(--accent-2);}
  }
  .player-title{ transition:color .15s ease; }
  .player-title{font-weight:700; font-size:.88rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
  .player-sub{font-size:.74rem; color:var(--ink-dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}

  /* ---- scrubber ---- */
  .player-scrub{display:flex; align-items:center; gap:.6rem;}
  .player-time{
    font-family:var(--font-mono); font-variant-numeric:tabular-nums;
    font-size:.72rem; color:var(--ink-dim); flex:none; min-width:5ch;
  }
  .player-time:last-child{text-align:right;}
  .player-range{
    -webkit-appearance:none; appearance:none;
    flex:1 1 auto; min-width:0; height:16px; margin:0;
    background:transparent; cursor:pointer;
  }
  .player-range:disabled{cursor:default; opacity:.5;}
  /* filled portion via a JS-set --pct (0–100) painted onto the track */
  .player-range::-webkit-slider-runnable-track{
    height:4px; border-radius:99px;
    background:linear-gradient(to right,
      var(--accent) 0 calc(var(--pct,0) * 1%),
      var(--outline-strong) calc(var(--pct,0) * 1%) 100%);
  }
  .player-range::-moz-range-track{height:4px; border-radius:99px; background:var(--outline-strong);}
  .player-range::-moz-range-progress{height:4px; border-radius:99px; background:var(--accent);}
  .player-range::-webkit-slider-thumb{
    -webkit-appearance:none; appearance:none;
    width:12px; height:12px; margin-top:-4px; border-radius:50%;
    background:var(--accent); box-shadow:0 1px 3px rgba(0,0,0,.4);
    transition:transform .12s ease;
  }
  .player-range::-moz-range-thumb{
    width:12px; height:12px; border:none; border-radius:50%;
    background:var(--accent); box-shadow:0 1px 3px rgba(0,0,0,.4);
  }
  @media (hover:hover) and (pointer:fine){
    .player-range:hover::-webkit-slider-thumb{transform:scale(1.2);}
  }
  .player-range:focus-visible{outline:none;}
  .player-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 4px color-mix(in srgb, var(--accent) 35%, transparent);}
  .player-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 4px color-mix(in srgb, var(--accent) 35%, transparent);}
  /* ---- resume toast ----
     Anchored to the bar's top edge (the bar is position:fixed) so it floats
     clear of the controls instead of adding a row to them. */
  .resume-toast{
    position:absolute; left:1rem; bottom:100%; margin-bottom:.55rem;
    display:flex; align-items:center; gap:.5rem;
    max-width:calc(100% - 2rem);
    padding:.4rem .45rem .4rem .9rem;
    border-radius:999px; font-size:.8rem;
    background:var(--surface-3); color:var(--ink);
    border:1px solid var(--outline-strong); box-shadow:var(--elev-2);
    animation:resume-rise .22s ease both;
  }
  .resume-toast[hidden]{display:none;}
  .resume-text{white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
  .resume-toast b{font-family:var(--font-mono); font-variant-numeric:tabular-nums;}
  .resume-restart{
    flex:none; border:1px solid var(--outline-strong); border-radius:999px;
    padding:.3rem .8rem; background:transparent; color:var(--accent-2);
    font-family:inherit; font-size:.78rem; font-weight:700; cursor:pointer;
    transition:background .15s ease, border-color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .resume-restart:hover{background:var(--surface-hover); border-color:var(--accent-2);}
  }
  .resume-dismiss{
    flex:none; width:26px; height:26px; border-radius:50%; border:none;
    background:transparent; color:var(--ink-faint); cursor:pointer;
    display:flex; align-items:center; justify-content:center;
    transition:background .15s ease, color .15s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .resume-dismiss:hover{background:var(--surface-hover); color:var(--ink);}
  }
  .resume-dismiss svg{width:13px; height:13px;}
  @keyframes resume-rise{
    from{opacity:0; transform:translateY(6px);}
    to{opacity:1; transform:none;}
  }
  @media (prefers-reduced-motion: reduce){
    .resume-toast{animation:none;}
  }
  @media (max-width:560px){
    .resume-toast{left:.75rem; max-width:calc(100% - 1.5rem);}
  }

  .player-status{font-size:.74rem; color:var(--ink-faint); flex:0 1 auto; text-align:right; max-width:34ch;}
  .player-status a{color:var(--accent-2);}

  /* ---- live mode: no timeline to scrub, no ±15s to skip ---- */
  .player-bar.live .player-scrub{display:none;}
  .player-bar.live .player-skip{display:none;}
  .player-live{
    display:inline-flex; align-items:center; gap:.4rem;
    font-family:var(--font-display); text-transform:uppercase; letter-spacing:.08em;
    font-size:.72rem; font-weight:800; color:var(--accent);
  }
  .player-live-dot{width:7px; height:7px; border-radius:50%; background:var(--accent); animation:pulse 1.8s infinite;}
  @media (prefers-reduced-motion:reduce){ .player-live-dot{animation:none;} }
  .player-close{
    /* Deliberately an ✕ and not the modals' chevron-down: this one really does
       end playback and drop the bar (app.js), and the bar is already the
       minimized state — there is nothing below it to hand off to. Promising a
       minimize here would be exactly the lie the chevron exists to remove.
       It does share their contrast though: the chip is 1.09:1 against the bar,
       so the glyph and the edge are the only things that say "button".
       Glyph only at every width — the word "Close" was dropped, so the ✕ carries
       the whole meaning and gets full ink. Still shorter than the art beside it,
       so the bar's height is unchanged.
       The CHIP is 28px; the TARGET is 44px. Ending playback by accident is the
       expensive mistake here, so the visible affordance stays small and quiet
       while the ::before below keeps a full finger's worth of hit area around
       it — which is why `position:relative` is in the base rule and not only in
       the coarse-pointer block: the ::before needs this element as its
       containing block at every width.
       IT ENDS AT THE CONTENT COLUMN, like everything else in the bar. It used
       to be pulled out past the column into the right gutter — in flow with a
       negative margin, absolutely positioned above 1400 — which left ~87px of
       empty bar between the status text and a lone circle sitting alone
       outside the 1180 column, the only element on the page to do so. A 28px
       chip is smaller than the ±15s buttons; it read as heavy because nothing
       was near it, and isolation inflates weight. Aligned right in flow it is
       the same size and stops drawing the eye. Centred vertically for the same
       reason: pinned to the title's line it also floated above the transport
       row it belongs to. (Phones keep the old top-right position — see the
       max-width:700 block, where it is out of flow so the scrubber can run the
       full width.) */
    flex:none; cursor:pointer; position:relative;
    border:1px solid color-mix(in srgb, var(--ink) 26%, transparent);
    width:28px; height:28px; border-radius:999px; padding:0;
    display:inline-flex; align-items:center; justify-content:center;
    background:var(--surface-3); color:var(--ink);
    margin-left:auto; align-self:center;
    transition:background .15s ease, color .15s ease, border-color .15s ease, transform .12s ease;
  }
  @media (hover:hover) and (pointer:fine){
    .player-close:hover{background:var(--surface-hover); color:var(--ink); border-color:var(--ink-faint);}
  }
  .player-close:active{transform:scale(.95);}
  .player-close:focus-visible{outline:none; box-shadow:0 0 0 3px color-mix(in srgb, var(--accent-2) 35%, transparent);}
  .player-close svg{width:13px; height:13px; flex:none;}
  /* The tap area, at every pointer type — the coarse-pointer block near the end
     of this file expands a long list of controls to 44px, but this one is small
     by design rather than by accident, so it carries its own. Centred on the
     chip and pointer-events inherited, so the 8px of slop on each side hits the
     button; nothing sits within 8px of it (see .player-info padding below and
     the to-top gap test), so it reaches over nothing. */
  .player-close::before{
    content:""; position:absolute;
    top:50%; left:50%; transform:translate(-50%,-50%);
    width:44px; height:44px;
  }
  @media (max-width:700px){
    .player-status{display:none;}
  }
  /* Phones: the scrubber runs the full width of the bar.
     Close already sat top-right (align-self:flex-start, pulled 8px past the
     padding), on the same line as the title — but as a flex item it also
     reserved that width down the WHOLE height of the bar, so the timeline
     underneath stopped short of it for no reason. Taking it out of flow leaves
     it visually where it was and hands .player-main the full width, so the
     track and the duration end flush with the bar's right edge. The title,
     which really does share that line, gets the reserved space back as padding
     (close + its 8px overhang + the flex gap). Live mode has no scrubber, so
     there is nothing to gain there and the ✕ stays in flow. */
  @media (max-width:700px){
    .player-bar:not(.live) .player-close{
      position:absolute; right:8px; top:calc(.6rem - 3px); margin:0;
    }
    /* glyph only — 28px chip + its 8px overhang, plus the 8px the 44px tap area
       reaches back over the title on this side */
    .player-bar:not(.live) .player-info{ padding-right:2.75rem; }
  }
  /* Tablet and up: the bar chrome still spans the viewport, but its controls sit in
     the same centered 1180px column as the listing instead of sprawling edge to edge. */
  @media (min-width:700px){
    .player-bar{
      padding-inline:max(1rem, calc((100% - 1180px) / 2 + 1rem));
    }
  }
  /* There was a min-width:1400 block here that pinned Close to the true right
     edge, out in the gutter, so the status text could end flush with the content
     column. That trade is the wrong way round: a consistent right edge for the
     whole bar is worth more than ~36px of extra room for "Paused". Close is back
     in flow at every width above 700 and the status text now ends where the ✕
     begins. See the note in the .player-close rule above. */

  .play-btn.playing{background:var(--accent); border-color:var(--accent); color:var(--accent-ink);}
  .row-link{color:inherit; text-decoration:none;}
  @media (hover:hover) and (pointer:fine){
    .row-link:hover{text-decoration:underline;}
  }
  .row-actions{display:flex; align-items:center; justify-content:flex-end; gap:.25rem;}
  @media (max-width:900px){
    .row-actions{grid-area:play;}
  }

  /* ---------------- Back to top ----------------
     A 40px circle that appears once you are deep in the listing, ducks out of
     the way while you are moving, and comes back when you stop. app.js owns the
     when; this owns the where, and the where is decided by three things that
     were already fixed elsewhere in this file:

     RIGHT GUTTER on desktop. This is now the ONLY thing out here: .player-close
     used to be pulled into the same margin, and the min-width:1360 block below
     lifts this to 2rem above the bar rather than the .9rem used everywhere else
     so the two circles never read as one cluster — of which the wrong one stops
     the audio. The ✕ has since moved back inside the content column, so that
     lift is now air rather than a clearance, but keep it: 2rem is where this
     button has always sat on desktop and .9rem would put it back on the bar's
     own shadow. §8 of test/to-top measures the real gap either way.

     1360px IS THE SWITCH, not 1180. The free gutter beside the content column is
     (100vw - 1180)/2, so 1180 is where it becomes 0 and 1360 is where it reaches
     ~90px — the first width that holds a 40px button with real air on both sides.
     Below that there is no margin to sit in and it has to overlay content,
     centred (see that block).

     BOTTOM-ANCHORED TO THE BAR. --player-h is the bar's measured height, kept
     current by app.js because it is not a constant: phones stack the scrubber,
     live mode drops it, and the bar's own bottom padding already carries the
     safe-area inset. max() with the inset gives the no-player case a floor
     without double-counting the inset when the bar IS up. */
  .to-top{
    /* Above the sticky listing chrome (10/40), below the player bar (80) so it
       can never sit on the bar's own shadow, and nowhere near the overlays
       (150-300) — which it leaves entirely, see html.scroll-lock below. */
    position:fixed; z-index:70;
    --to-top-lift: max(var(--player-h, 0px), env(safe-area-inset-bottom, 0px));
    bottom: calc(var(--to-top-lift) + .9rem);
    /* Centred over the content by default; the desktop block below takes it out
       to the gutter. left/right:0 + margin-inline:auto centres it without
       knowing its width, which matters because the touch block grows it to 44px
       and a translateX(-50%) would then fight the enter/exit transform. */
    left:0; right:0; margin-inline:auto;
    width:40px; height:40px; border-radius:999px; padding:0;
    display:flex; align-items:center; justify-content:center;
    cursor:pointer;
    /* Quiet at rest, on purpose and by the same logic as .player-close: the
       accent-filled circle in the corner is the play button and it stays the
       only one. Accent shows up on hover and press, never before. */
    background:color-mix(in srgb, var(--surface-2) 92%, transparent);
    backdrop-filter:blur(12px);
    border:1px solid var(--outline-strong);
    color:var(--ink-dim);
    box-shadow:var(--shadow);
    transition:opacity .2s ease, transform .2s ease, visibility .2s,
               background .15s ease, color .15s ease, border-color .15s ease,
               bottom .35s cubic-bezier(.22,1,.36,1);
  }
  /* The `bottom` transition is what makes the bar arriving read as one movement
     rather than two: the button rides up with it, on the same curve the bar's
     own .arrived animation runs on. */
  .to-top svg{ width:18px; height:18px; }

  .to-top[data-show="false"]{
    /* visibility, not just opacity, and this is the whole bug class this feature
       could have shipped: below 1360px the button sits ON the listing, so an
       opacity:0 button would go on eating row taps and catching Tab forever.
       visibility:hidden removes it from the focus order and the hit test, and it
       transitions discretely — flipping at the END of a fade-out and the START
       of a fade-in, which is exactly the timing wanted.
       Exit runs at .12s against the .2s entrance: a slow fade-out is most of
       what makes an interface feel like it is lagging behind you. */
    opacity:0; visibility:hidden; pointer-events:none;
    transform:translateY(6px) scale(.96);
    transition-duration:.12s;
  }

  @media (hover:hover) and (pointer:fine){
    .to-top:hover{
      background:var(--surface-3); color:var(--ink);
      border-color:color-mix(in srgb, var(--accent) 45%, var(--outline-strong));
      transform:translateY(-2px);
    }
  }
  .to-top:active{ transform:translateY(0) scale(.94); }
  .to-top:focus-visible{ outline:none; box-shadow:0 0 0 3px color-mix(in srgb, var(--accent-2) 35%, transparent); }

  /* Desktop: out into the right gutter. Anchored by its LEFT edge to the content
     column's right edge (50% + 590px) rather than centred in the margin, so the
     gap to the listing is a constant 1.4rem at every width. Gutter-centring looks
     identical at 1360 and abandons the button in the middle of a 370px margin at
     1920.
     The 2rem bottom (against .9rem elsewhere) dates from when .player-close
     shared this margin — see the ✕ note at the top of this section. */
  @media (min-width:1360px){
    .to-top{
      left:calc(50% + 590px + 1.4rem);
      right:auto; margin-inline:0;
      bottom:calc(var(--to-top-lift) + 2rem);
    }
  }

  /* Centred mode only: the resume toast floats above the bar's left edge, inside
     the content column, and on a phone it runs most of the bar's width — so it
     and the button want the same strip. Lift the button over it rather than
     hiding it; the toast is transient and the button's job is not. Never needed
     in gutter mode, where the two are never on the same horizontal run.

     --resume-h is the strip the toast occupies (its height PLUS its own
     margin-bottom), measured by app.js, and 0px whenever the toast is down — so
     this one expression covers both states and no :has() is needed. It has to be
     measured for the same reason --player-h does: a guessed constant was 3.6rem
     here and the toast is 58.8px on a 390px phone, where the text wraps to two
     lines. That overlapped by 9px, which is exactly the kind of miss a fixed
     value makes and a measurement cannot. */
  @media (max-width:1359.98px){
    .to-top{ bottom:calc(var(--to-top-lift) + var(--resume-h, 0px) + .9rem); }
  }

  /* An overlay is open. The scrim covers z-index 70 anyway, so this is not about
     what you can see — it is about the button still being in the tab order and
     still being a hit target under a scrim. This file's history is mostly the
     story of things leaking through those six overlays; do not make this the
     seventh. */
  html.scroll-lock .to-top{ opacity:0; visibility:hidden; pointer-events:none; }

  @media (prefers-reduced-motion:reduce){
    .to-top{
      transition:opacity .18s ease, visibility .18s, background .15s ease,
                 color .15s ease, border-color .15s ease;
    }
    .to-top[data-show="false"]{ transform:none; }
    .to-top:active{ transform:none; }
    @media (hover:hover) and (pointer:fine){
      .to-top:hover{ transform:none; }
    }
  }

  /* ================= Gallery (grid) view ================= */
  /* In grid mode the sticky column-header row is meaningless, and #rows becomes a
     responsive card grid instead of a stack of table rows. */
  body.view-grid .row.head{display:none;}
  body.view-grid #rows{
    display:grid;
    grid-template-columns:repeat(5, 1fr);
    gap:1.4rem 1.1rem;
    padding:1.4rem;
  }
  @media (max-width:1040px){ body.view-grid #rows{grid-template-columns:repeat(4, 1fr);} }
  @media (max-width:820px){ body.view-grid #rows{grid-template-columns:repeat(3, 1fr);} }
  /* ---- Phones: stop spending the screen on gutters ----
     Three separate paddings stack up between a card and the screen edge, and
     none of them is obviously the culprit on its own: .listing's 1rem side
     padding, .grid's 1px border, and #rows' own 1rem. At 360px that is 66px of
     the 360 — 18% of the display — before a single card is drawn, leaving two
     columns of ~140px.

     So the phone tiers thin all three together rather than one heavily. Tablet
     and desktop are untouched: they have the room, and the generous gutter is
     what makes the grid feel considered there. */
  @media (max-width:560px){ body.view-grid #rows{grid-template-columns:repeat(2, 1fr); gap:1rem .7rem; padding:.7rem;} }
  /* Very small phones have no gutter left to give — this is where two columns
     were genuinely cramped. Still two columns: at 500+ shows, density is the
     point, and one column would turn a scan into a scroll. */
  @media (max-width:400px){ body.view-grid #rows{gap:.85rem .55rem; padding:.5rem;} }

  /* The card button can't contain another button, so the More link is a sibling
     inside this wrapper, sitting in the overlay just under the card title. */
  .card-wrap{position:relative; display:block;}
  .card-more{
    position:absolute; left:.7rem; bottom:.6rem; z-index:5; margin-top:0;
    color:color-mix(in srgb, var(--accent-2) 45%, white);
    text-shadow:0 1px 3px rgba(0,0,0,.7);
  }
  @media (hover:hover) and (pointer:fine){
    .card-wrap:hover .card-more,.card-more:hover{color:#fff;}
  }

  /* The whole square is the play button; the image sits on top of the category
     placeholder, with the title, a play glyph, and a retention badge layered over
     it. No text sits below the image — the card is just the artwork. */
  .card.card-art.play-btn{
    position:relative; display:block; width:100%; height:auto; aspect-ratio:1/1;
    padding:0; border-radius:14px; overflow:hidden; cursor:pointer; text-align:left;
    border:1px solid var(--outline);
    background:url("/assets/kpfk-artwork-placeholder.svg") center/cover no-repeat, var(--surface-2);
    box-shadow:inset 0 0 0 1px var(--outline);
    transition:transform .18s ease, box-shadow .18s ease;
  }
  /* hover is tracked on the wrapper, so the card still lifts when the pointer is
     over the title block sitting on top of it */
  @media (hover:hover) and (pointer:fine){
    .card.card-art.play-btn:hover,.card-wrap:hover .card.card-art.play-btn{background:url("/assets/kpfk-artwork-placeholder.svg") center/cover no-repeat, var(--surface-2);
    border-color:var(--outline); color:var(--ink);
    transform:translateY(-2px);
    box-shadow:inset 0 0 0 1px var(--outline-strong), 0 8px 22px rgba(0,0,0,.35);}
  }
  .card-art img{position:absolute; inset:0; width:100%; height:100%; object-fit:cover;}
  .card-art img.failed{display:none;}

  /* Permanent gradient so the overlaid title stays legible over any artwork; it
     darkens further on hover to make the centered play glyph pop. */
  .card-fade{
    position:absolute; left:0; right:0; bottom:0; top:0; pointer-events:none;
    background:linear-gradient(to top, rgba(0,0,0,.86) 0%, rgba(0,0,0,.36) 38%, rgba(0,0,0,0) 62%);
    transition:background .18s ease;
  }
  .card-art.play-btn:focus-visible .card-fade,.card-art.play-btn.playing .card-fade,.card-art.play-btn.loading .card-fade{
    background:linear-gradient(to top, rgba(0,0,0,.86) 0%, rgba(0,0,0,.5) 45%, rgba(0,0,0,.32) 100%);
  }
  @media (hover:hover) and (pointer:fine){
    .card-wrap:hover .card-fade{background:linear-gradient(to top, rgba(0,0,0,.86) 0%, rgba(0,0,0,.5) 45%, rgba(0,0,0,.32) 100%);}
  }

  /* a sibling of the card button, laid over its lower half: clicking the words
     opens the info sheet while the artwork behind them still plays */
  .card-overlay{
    position:absolute; left:0; right:0; bottom:0; z-index:4;
    display:flex; flex-direction:column; align-items:flex-start; gap:.15rem;
    border:0; background:none; font:inherit; text-align:left; cursor:pointer;
    /* bottom padding leaves the row for the More link below the title */
    padding:.6rem .7rem 2rem;
  }
  /* over artwork the teal is mixed toward white so it stays legible */
  .card-overlay:focus-visible .card-title{
    color:color-mix(in srgb, var(--accent-2) 55%, white);
  }
  @media (hover:hover) and (pointer:fine){
    .card-overlay:hover .card-title{color:color-mix(in srgb, var(--accent-2) 55%, white);}
  }
  .card-title{ transition:color .15s ease; }
  /* The category is spelled out here in words, so the artwork carries no colour
     coding — a coloured ring with no legend beside it just raises questions. The
     category dropdown at the top of the page remains the place colours are explained. */
  .card-eyebrow{
    font-family:var(--font-display); text-transform:uppercase; letter-spacing:.07em;
    font-size:.62rem; font-weight:700; line-height:1;
    color:#cdd6d3;
    text-shadow:0 1px 2px rgba(0,0,0,.6);
  }
  .card-title{
    color:#fff; font-weight:700; font-size:.9rem; line-height:1.24;
    display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
    text-shadow:0 1px 3px rgba(0,0,0,.6);
  }

  .card-play{
    position:absolute; top:50%; left:50%; z-index:3;
    width:52px; height:52px; border-radius:50%;
    background:var(--accent); color:#fff;
    display:flex; align-items:center; justify-content:center;
    box-shadow:0 4px 16px rgba(0,0,0,.5);
    opacity:0; transform:translate(-50%,-50%) scale(.82);
    transition:opacity .18s ease, transform .18s ease, background .15s ease;
  }
  .card-play svg{width:19px; height:19px;}
  .card-art.play-btn:focus-visible .card-play,.card-art.play-btn.playing .card-play,.card-art.play-btn.loading .card-play{opacity:1; transform:translate(-50%,-50%) scale(1);}
  @media (hover:hover) and (pointer:fine){
    .card-wrap:hover .card-play{opacity:1; transform:translate(-50%,-50%) scale(1);}
  }
  .card-art.play-btn.loading .card-play{background:var(--surface-3);}
  .card-art.play-btn .btn-spin{border-top-color:#fff;}
  /* Desktop/laptop only: the orange circle is a hover affordance. On touch
     devices (no hover) it stays hidden so it doesn't blanket every card — the
     whole card is still tappable, and the .playing/.loading states above still
     surface the circle when a card is actually playing. */
  @media (prefers-reduced-motion:reduce){
    .card-art.play-btn, .card-play, .card-fade{transition:opacity .18s ease;}
    @media (hover:hover) and (pointer:fine){
      .card-art.play-btn:hover{transform:none;}
    }
  }

  /* bottom-right, sharing the row with the More link on the left. Raised above
     the title overlay and click-through, so it never eats a tap. */
  .card-date{
    position:absolute; right:.65rem; bottom:.6rem; z-index:6; pointer-events:none;
    display:inline-flex; align-items:center; white-space:nowrap;
    background:rgba(0,0,0,.55); -webkit-backdrop-filter:blur(4px); backdrop-filter:blur(4px);
    padding:.22rem .5rem; border-radius:999px;
    font-family:var(--font-mono); font-variant-numeric:tabular-nums;
    font-size:.7rem; font-weight:600; color:#fff;
    box-shadow:0 1px 4px rgba(0,0,0,.4);
  }

  /* ================= Touch overrides (coarse pointers) =================
     Last in the file on purpose — see the note in the touch base layer. */
  @media (pointer: coarse){
    /* Overlays move focus to their close control (good a11y). Safari has
       historically painted a ring for that programmatic focus even after a tap;
       the keyboard ring (:focus-visible) is untouched. */
    :focus:not(:focus-visible){ outline: none; }
    /* Mobile Safari zooms the viewport on focus of any input under 16px and
       leaves it zoomed, which misaligns both sticky bars. */
    #q, .search-field input{ font-size: 16px; }
  }

  /* Removing the tap highlight removes the only press feedback touch had, so it
     is replaced here rather than simply deleted. */
  @media (hover: none){
    button:active, a:active, [role="button"]:active{
      transform: scale(.96);
      transition: transform .06s ease;
    }
    /* the bottom bar and its transport are already small; don't shrink further */
    .player-bar:active, .player-info:active, .player-art:active{ transform: none; }
  }
  @media (hover: none) and (prefers-reduced-motion: reduce){
    button:active, a:active, [role="button"]:active{ transform: none; opacity: .7; }
  }

  /* ---- Hit targets, coarse pointers only ----
     44x44 is the Apple HIG floor and WCAG 2.5.8. Two techniques, chosen per
     control rather than applied uniformly:

       * Controls that stand alone grow via a transparent ::before, so the
         *ink* is untouched and desktop layout cannot shift.
       * Controls that sit shoulder to shoulder grow their real box instead —
         overlapping invisible pseudo-elements would steal each other's taps,
         and the later sibling always wins the hit test.

     .more-link is deliberately NOT expanded: it is tabindex="-1" and fires the
     same handler as the row itself / .card-overlay (app.js), which already wrap
     it in a target the size of the whole row or card. In list view that is
     literal — the rows click handler treats everything left of .row-actions as
     one target, so .more-link is a label for the row, not the only way in. */
  @media (pointer: coarse){
    /* Deliberately WITHOUT .sheet-close / .lightbox-close / .donate-close.
       Those are already position:absolute, pinned to a corner of their dialog,
       and absolute is just as good a containing block for the ::before below.
       Forcing them to relative dropped them back into normal flow, where their
       `top`/`right` became flow offsets: the info sheet, lightbox and donate
       close buttons all landed half off the LEFT edge, and the live player's
       — a flex child of a centre-aligned column — sat in the middle of the
       header. Touch devices only, so a fine-pointer browser never showed it.

       .player-close is absent for the opposite reason: it is small on purpose
       and already carries its own 44px ::before at every pointer type, up in
       the player-bar section. Listing it here again would be a second rule
       claiming to own the same box. */
    .play-btn:not(.card-art), .menu-close,
    .link-notice-close, .lp-alert-dismiss, .resume-dismiss,
    .social-btn{ position: relative; }

    .play-btn:not(.card-art)::before,
    .sheet-close::before, .menu-close::before, .lightbox-close::before,
    .donate-close::before, .sched-close::before, .link-notice-close::before,
    .lp-alert-dismiss::before, .resume-dismiss::before,
    .social-btn::before{
      content: ""; position: absolute;
      top: 50%; left: 50%; transform: translate(-50%, -50%);
      width: 100%; height: 100%;
      min-width: 44px; min-height: 44px;
    }

    /* The freshness label is a ~15px line of text; as a button it needs a real
       target. min-height rather than a ::before, because it has an inline
       neighbour it must not reach over, and rather than padding, because the
       strip it sits in is already 52px tall (the view toggle sets that) — so
       44px centres inside the existing row and costs no vertical space at all. */
    .clock-btn{ min-height: 44px; display: inline-flex; align-items: center; }

    /* side by side — grow the box.
       The theme chip is here rather than in the ::before list above precisely
       BECAUSE it is now adjacent to the hamburger: two 44px pseudo-elements on
       two 38px boxes would overhang 3px each into a 6.4px gap and very nearly
       touch, which is the exact failure the note above warns about. Real boxes
       cannot overlap, and it keeps the pair visually identical. */
    .menu-btn, .theme-btn{ width: 44px; height: 44px; }
    /* the pill grows to ~52px tall on touch; that is the cost of a real target
       for the two most-mistapped buttons in the toolbar. Wider than tall so the
       two segments span a comfortable thumb width with no seam between them. */
    .view-btn{ width: 58px; height: 44px; }
    .view-btn svg{ width: 20px; height: 20px; }
    /* Back to top is standalone, so the ::before technique above would fit — but
       it is position:fixed and centred with margin-inline:auto, so growing the
       real box cannot shift any layout and cannot overhang a neighbour. Bigger
       ink is also the right call for a control you tap with a thumb. */
    .to-top{ width: 44px; height: 44px; }
    .to-top svg{ width: 19px; height: 19px; }
    .player-transport{ gap: .5rem; }
    .player-skip{ width: 44px; height: 44px; }
    .player-toggle{ width: 48px; height: 48px; }
    .player-toggle svg{ width: 20px; height: 20px; }
    .player-toggle.loading .spinner{ width: 20px; height: 20px; }

    /* pills and text buttons — give them a real vertical target */
    .on-air-btn, .appbar-actions .btn-donate, .refresh-pill, .lp-alert-btn,
    .lp-archive, .lp-upnext,
    .sheet-play, .sheet-link, .sheet-restart, .sheet-route-back, .sheet-scroll-cue,
    /* the on-air chooser's answers are the whole point of the dialog */
    .live-choice-btn, .live-choice-cancel{ min-height: 44px; }
    .sheet-player-toggle{ width:44px; height:44px; }
    /* Icon-only on phones (see the max-width:560px block), so min-height alone
       would leave a 30px-wide target. Width has to be asked for separately —
       it's the one pill with no label to give it width. */
    .sheet-share{ min-width: 44px; }
    .resume-restart, .desc-toggle{
      min-height: 44px; display: inline-flex; align-items: center;
    }

    /* Sliders are dragged, not tapped, so they get the most help — but the two
       are in very different places and must not be treated the same.

       The scrubber lives in the FIXED bottom bar. A plain height:44px there
       measured 88px of bar = 10.4% of a 390x844 phone, permanently, while
       playing (Spotify and Apple Music mini-players sit around 64px). So the
       hit band is grown with negative margins instead: 30px to press, 16px of
       layout, bar back to ~60px. The margins eat only dead space — the 6px gap
       above and the bar's own bottom padding — never .player-info above it,
       which is a button that opens the info sheet.

       This works because a range input only needs the tall band for the INITIAL
       press: once the drag starts it tracks horizontally no matter where the
       finger wanders vertically. */
    .player-range{ height: 30px; margin-top: -6px; margin-bottom: -8px; }
    /* the volume slider is inside a centred modal with room to spare, so it
       gets the full target with no layout cost to pay for it */
    .lp-volume-range{ height: 44px; }
    .player-range::-webkit-slider-thumb{ width: 20px; height: 20px; margin-top: -8px; }
    .player-range::-moz-range-thumb{ width: 20px; height: 20px; }
    .lp-volume-range::-webkit-slider-thumb{ width: 18px; height: 18px; margin-top: -7px; }
    .lp-volume-range::-moz-range-thumb{ width: 18px; height: 18px; }
  }

/* Published Pacifica schedule */
/* A published program with no recordings: the card expands in place instead of
   opening a sheet it has nothing to fill with. */
.sched-program-info { padding:.5rem .75rem .25rem; color:var(--ink-dim); font-size:.9rem; line-height:1.45; }
.sched-program-info p { margin:0 0 .35rem; }
.sched-program-info[hidden] { display:none; }
.sched-stale { margin:0 0 .75rem; color:var(--ink-dim); font-size:.85rem; }
#liveChoiceArchive[hidden] { display:none; }
#donateBtn[hidden] { display:none; }
