:root {
    /* Going for a 'dusk/space' colour scheme.
       Maybe we could make the colour scheme switchable in future? 
       */
    --bg: #0f0f11; /* body (exc. header and footer/player) */
    --bg-elev: #1e2742; /* header, recent show blocks, player vol bar */
    --bg-elev-2: #374469; /* player bg and minimize arrow bg */
    --border-light: #8f9cb0;
    --border-dark: #000000;
    --text: #c7d4f7;
    --text-dim: #9fb0df;
    --button-primary: #30498f;
    --accent: #008ddf; /* was 2dd4bf */
    --accent-strong: #20adff; /* was 14b8a6 */
    --heading-col: #74c0eb;
    /* The footer's REAL height, in both live and show mode. Kept separate from --player-h
       because player.js zeroes that one while the footer is slide-hidden (and pins a measured
       value on mobile) — anything that needs the true height regardless reads the base. */
    --player-h-base: 100px; /* was 76px */
    --player-h: var(--player-h-base);
    /* How much of the bottom edge iOS Safari's own floating toolbar currently covers — 0 by
       default (and everywhere but iOS Safari), tracked live by player.js's
       initIosBottomInset() via window.visualViewport. See .ss-player-hide's own comment for why
       this exists. */
    --ss-bottom-inset: 0px;
}

* { box-sizing: border-box; }

html, body {
    margin: 0;
    padding: 0;
    background: var(--bg);
    color: var(--text);
    font-family: SceneSat, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    line-height: 1.6;
}

body::before {
    /* Two layered backgrounds, kept still against the VIEWPORT via this pseudo-element's own
       position:fixed rather than background-attachment:fixed on html/body (which used to carry
       just the starfield here). That property is unreliable on iOS WebKit — Safari AND
       Chrome-for-iOS alike, since Apple requires every iOS browser to use Safari's own engine —
       where it commonly just behaves as background-attachment:scroll, so the starfield visibly
       scrolled along with the page instead of staying put as a backdrop (reported 2026-08-31).
       position:fixed is reliably supported there (this site already leans on it elsewhere: the
       header, the footer player, the corner clock's old home before it moved into the status
       bar) so both layers move here instead. CSS paints EARLIER-listed background-images on TOP,
       so the warm corner gradient (unchanged) still sits over the starfield.

       The starfield itself: a seamless 2048x2048 tile generated by scripts/make-starfield.py
       (see its header for why the tile is square, and why the asset is lossless WebP); `auto`
       keeps it at native size, since scaling it would soften the stars and, worse, shrink the
       repeat distance.

       z-index MUST be negative, not 0 — this cost a real regression 2026-08-31, caught only
       after shipping: per the CSS stacking spec, non-positioned in-flow content (#content,
       site-footer — everything here is plain position:static) paints in an EARLIER phase than
       ANY positioned element carrying a z-index of 0, even explicitly. So at z-index:0 this
       pseudo-element painted ON TOP of the entire page, not behind it — invisible for years with
       just the near-transparent gradient (16% opacity, fading to nothing), but the starfield
       tile is opaque enough to visibly blank out every real content section once it joined this
       layer. Negative z-index moves it into the phase that paints BEFORE non-positioned content,
       restoring "backdrop behind everything" without needing position:relative on #content and
       every other static content wrapper on the site. */
    content: "";
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: -1;
    background-image:
        radial-gradient(ellipse 1200px 600px at 10% -10%, rgba(210, 110, 90, .16), transparent 90%),
        url('../img/starfield.webp');
    background-repeat: no-repeat, repeat;
    background-size: auto, auto;
}

body {
    /* Leave room for the fixed footer player so content is never hidden behind it — and for the
       zoomed scrubber strip when it is open (an overlay above the footer, not part of its box:
       the footer's height must stay --player-h, so the strip gets its own reservation). */
    padding-bottom: calc(var(--player-h) + var(--player-zoom-h, 0px) + 1rem);
}
html.ss-show.ss-zoom { --player-zoom-h: 58px; }
html.ss-player-hidden { --player-zoom-h: 0px; }

h1, h2, h3, h4, h5 {
    margin: 0 0 0.25rem;
    display: flex; align-items: baseline; /* in case headings ever wrap onto 2 lines, */
    /* display and align-items should keep any images added to headings aligned. */
    /* Unitless, not a fixed rem: it has to scale with EACH heading's own font-size (h1 alone
       spans this rule's 2rem up to .hero h1's 2.5rem/1.9rem), not pin every level to one
       absolute pixel value. The previous 1.6rem (25.6px) was already shorter than .hero h1's own
       font-size, so its two wrapped lines on mobile rendered almost touching — caught 2026-08-31
       once the hero greeting started wrapping there instead of clipping (see the "welcome text
       greeting" section above). */
    line-height: 1.2;
}

h1 { font-size: 2rem; }

/* Variants of --heading-col applied to descending heading sizes, for variety */
/* Uses 'oklch' because this is more supported across latest browsers */
h1 { color: var(--heading-col); }
h2 { color: oklch(from var(--heading-col) l calc(c - 0.05) h); }
h3 { color: oklch(from var(--heading-col) l calc(c - 0.08) h); }
h4 { color: oklch(from var(--heading-col) l calc(c - 0.10) h); }
h5 { color: oklch(from var(--heading-col) l calc(c - 0.12) h); }

h1.star::before,h2.star::before,h3.star::before,h4.star::before,h5.star::before {
  content: "✦ ";
  color: var(--heading-col);
  margin-right: 4px;
}

h2 > img,
h2 > a > img { /* second form: CMS card headings wrap icon + title in one link */
    margin-right:0.5rem;
    height: 32px;
    width: auto;
    display: inline-block;
}

h2 > img.lower {
    transform: translateY(6px);
}

/* CMS card headings are links (whole title clickable); keep the heading colour — the global
   `a` rule would paint them accent-blue — and only hint the link on hover. */
.feature h2 a { color: inherit; }
.feature h2 a:hover { color: var(--accent); }

p { margin: 0 0 0.5rem; }

a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: none; }


/* ---- BEGIN fonts -------------------------------------------------------- */

@font-face {
  font-family: 'SceneSat';
  src: url('../font/TASA_Orbiter/TASAOrbiter-Regular.ttf') format('truetype');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'SceneSat';
  src: url('../font/TASA_Orbiter/TASAOrbiter-Bold.ttf') format('truetype');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

/* ---- END fonts ---------------------------------------------------------- */


/* ---- BEGIN welcome text greeting ---------------------------------------- */

.fade-text {
  opacity: 1;
  display: inline-block;
  vertical-align: bottom;
  white-space: nowrap; /* Prevents text from breaking into lines during compression */
  overflow: hidden;    /* Hides the text if the bounding box becomes too small */
  padding: 0.5rem 0;
  
  /* Smoothly handles both the fading visual and the physical canvas width layout */
  transition: opacity 0.4s ease-in-out, width 0.4s cubic-bezier(0.25, 1, 0.5, 1);
}

.fade-out {
  opacity: 0;
}

/* The width-transition crossfade above needs white-space:nowrap to animate smoothly between
   greetings of different lengths — fine on desktop, where every greeting fits on one line, but
   on a narrow phone even the shortest one can outrun the viewport: "Welcome to SceneSat" clipped
   mid-word to "Welcome to Scene" (2026-08-30), because nowrap forbids the wrap that would
   otherwise have kept it on screen. Mobile drops the nowrap/width-transition trick entirely and
   just wraps like ordinary text — the opacity crossfade still runs, it just no longer animates a
   width change alongside it. */
@media (max-width: 640px) {
  .fade-text { white-space: normal; overflow: visible; }
  .hero h1 { font-size: 1.9rem; }
}

/* ---- END welcome text greeting ------------------------------------------ */


/* ---- BEGIN action buttons ----------------------------------------------- */

/* 1. Container Flexbox Matrix */
.button-row {
  display: flex;
  flex-direction: row;      /* Arranges children (buttons!) horizontally */
  justify-content: center;  /* Centers the entire block of buttons on the screen */
  align-items: center;      /* Vertically aligns them if text lengths differ */
  gap: 20px;
  width: 100%;
  padding: 0 0 2rem 0;
  box-sizing: border-box;
}

/* 2. Individual Rectangular Button Styling */
.action-btn,
.button-row a {
  /* Dynamic width footprint calculation */
  flex: 1;                  /* Forces all 4 buttons to be exactly equal in width */
  max-width: 220px;         /* Prevents them from stretching too wide on massive screens */
  min-width: 120px;         /* Prevents them from shrinking too small */
  height: 60px;
  background-color: hsl(from var(--bg-elev) h s l / 70%);
  color: var(--text);
  font-family: inherit;
  font-size: 1rem;
  font-weight: 700;
  border: none;
  border-radius: 0;
  cursor: pointer;
  transition: background-color 0.2s ease, transform 0.1s ease;
}

/* 3. Interactive State Feedback */
.action-btn:hover,
.button-row a:hover {
  background-color: hsl(from var(--bg-elev-2) h s l / 70%);
}

.action-btn:active,
.button-row a:active {
  transform: scale(0.98);    /* Subtle mechanical click physical response */
}

/* 4. Responsive Handset Override */
@media (max-width: 640px) {
  .button-row {
    flex-direction: column; /* Stacks buttons into a vertical column on phones */
    align-items: stretch;   /* Forces buttons to fill the screen width nicely */
  }
  .action-btn,
  .button-row a {
    max-width: 100%;        /* Allows buttons to use the full (narrower) display width */
  }
}

/* 5. CMS `:::button-row` component: authors write markdown links, which arrive wrapped in a
   <p>; display:contents lifts them into the flex row so each link IS an action button. */
.button-row p {
  display: contents;
}

.button-row a,
.md-content .button-row a { /* out-specifies .md-content's link color on CMS pages */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  text-decoration: none;
  color: var(--text);
}

/* ---- END action buttons ------------------------------------------------- */

/* ---- Stereo width (guest station pages) ---------------------------------- */

.stereo-width {
  margin: 0.75rem 0 1rem;
}

.stereo-width__label {
  display: block;
  font-weight: 700;
  margin-bottom: 0.4rem;
}

.stereo-width__options {
  display: inline-flex;
  gap: 2px;
  background: hsl(from var(--bg-elev) h s l / 70%);
  border-radius: 8px;
  padding: 3px;
}

.stereo-width__options button {
  font-family: inherit;
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--text);
  background: transparent;
  border: none;
  border-radius: 6px;
  padding: 0.35rem 0.9rem;
  cursor: pointer;
  transition: background-color 0.15s ease;
}

.stereo-width__options button:hover {
  background: hsl(from var(--bg-elev-2) h s l / 70%);
}

.stereo-width__options button.is-active {
  background: var(--accent);
  color: var(--bg, #0d1117);
}

.stereo-width__hint {
  margin-top: 0.4rem;
  font-size: 0.85rem;
  max-width: 46rem;
}

/* The audio graph (and thus the width DSP) deliberately never runs on narrow screens —
   don't show a control that can't act; the mobile apps carry this feature there.
   Must match player.js isNarrow() / the foldable-player breakpoint (900px). */
@media (max-width: 900px) {
  .stereo-width { display: none; }
}


/* ---- BEGIN featured section --------------------------------------------- */

.featured {
  display: flex;
  flex-direction: row;      /* Arranges children (features!) horizontally */
  flex-wrap: wrap;
  justify-content: center;  /* Centers the block of features */
  align-items: padding-top;
  gap: 20px;
  width: 100%;
  padding: 0;
  box-sizing: border-box;
}

.featured > .heading { flex-basis: 100%; margin:0 0 -15px 0; }

.feature > h2 { margin-top:10px; }

.feature {
  /* Dynamic width footprint calculation */
  flex: 1;                  /* Forces features to be exactly equal in width */
  max-width: 600px;         /* Prevents them from stretching too wide on massive screens */
  min-width: 120px;         /* Prevents them from shrinking too small */
  background-color: hsl(from var(--bg-elev) h s l / 70%);
  color: var(--text);
  font-family: inherit;
  font-size: 1rem;
  font-weight: 400;
  border: none;
  border-radius: 0;
  cursor: pointer;
  transition: background-color 0.2s ease, transform 0.1s ease;
  padding: 1.5rem 1.5rem 1rem;
}

/* 3. Interactive state feedback */
.feature:hover {
  background-color: hsl(from var(--bg-elev-2) h s l / 70%);
}

.feature:active {
  transform: scale(0.98);    /* Subtle mechanical click physical response */
}

/* 4. Responsive Handset Override */
@media (max-width: 640px) {
  .features {
    flex-direction: column; /* Stacks buttons into a vertical column on phones */
    align-items: stretch;   /* Forces buttons to fill the screen width nicely */
  }
  .feature {
    max-width: 100%;        /* Allows buttons to use the full (narrower) display width */
  }
}

/* ---- END featured section ----------------------------------------------- */


/* ---- BEGIN media thumbnail placeholders --------------------------------- */

/* Global aspect ratio container settings */
.media-container {
  position: relative;
  width: 100%;
  border-radius: 8px;
  overflow: hidden;
  
  /* Read the custom property hue from the HTML, defaulting to 240 if missing */
  --hue: var(--thumbnail-hue, 240);
  
  /* Construct your palette using HSL (Hue, Saturation, Lightness) */
  background-color: hsl(var(--hue), 35%, 15%); 
}

.layout-16x9 {
  aspect-ratio: 16 / 9;
}

/* Layer the stylized placeholder background (the box) */
.media-container::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  background-image: 
    linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
  background-size: 20px 20px;
  
  /* ADD THIS LINE: Read the variable passed from JS, defaulting to 0s */
  animation-delay: var(--animation-delay, 0s); 
}

/* Layering a Centralized Icon Vector */
.media-container::after {
  content: '✦'; /* Can be an emoji, text string, or an SVG code background */
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 2;
  font-size: 2rem;
  color: rgba(255, 255, 255, 0.15);
  text-shadow: 0 0 10px rgba(255, 255, 255, 0.1);
  transition: opacity 0.6s ease-out;
}

/* Real Image Behavior */
.media-container img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  z-index: 3; /* Sits directly on top of the placeholder graphics */
  opacity: 0; /* Hidden by default until fully fetched */
  transition: opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Smooth Fade-In Engine Once Downloaded */
.media-container img[src]:not([src=""]) {
  opacity: 1;
}

/* When the image is active, fade out the background icons to save system processing */
.media-container img[src]:not([src=""]):~::before,
.media-container img[src]:not([src=""]):~::after {
  opacity: 0;
}

/* Subtle ambient animation Loop */
@keyframes placeholderPulse {
  0%, 100% { background-color: hsl(var(--hue), 35%, 15%); }
  50% { background-color: hsl(var(--hue), 40%, 22%); }
}

/* LAZY LOADING OF THUMBNAILS... */

/* Only run the pulsing and tech-grids while this active state class exists */
.media-container.placeholder-active::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  background-image: 
    linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
  background-size: 20px 20px;
  animation: placeholderPulse 4s ease-in-out infinite;
  animation-delay: var(--animation-delay, 0s);
}

/* The real image starts completely hidden */
.lazy-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  z-index: 2;
  opacity: 0;
  transition: opacity 0.6s cubic-bezier(0.4, 0, 0.2, 1); /* Smooth filmic fade-in */
}

/* The loaded state class applied by JavaScript */
.lazy-image.loaded {
  opacity: 1;
}

/* ---- END media placeholders --------------------------------------------- */


/* ---- Header ---- */
.site-header {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    min-height: 56px;
    /* No vertical padding: min-height + align-items:center is the sole vertical authority,
       so the logo, nav and account links all share one centre line. */
    padding: 0 1.5rem;
    border-bottom: 0;
    background: var(--bg-elev);
    position: sticky;
    top: 0;
    z-index: 20;
}
.site-brand { display: inline-flex; align-items: center; }
.site-brand img { height: 24px; width: auto; display: block; }
/* nav + account share one row on desktop; this wrapper becomes the mobile dropdown. */
.site-menu { display: flex; flex: 1 1 auto; align-items: center; gap: 1.5rem; }
.site-nav { display: flex; gap: 1.25rem; }
/* border-bottom + padding-bottom are reserved on EVERY link, not just .is-active (which only
   swaps border-bottom-color below) — measured 2026-08-30: a page whose route matches no nav
   item at all (a track, an artist — anything outside every data-nav-match prefix) used to have
   NO link carrying that 4px, making the whole .site-nav ~4px shorter than on a page with an
   active tab. .site-menu centers .site-nav's BOX either way, but centering a shorter/taller box
   moves where the TEXT inside it sits (the extra 4px only exists at the bottom), so the nav
   labels visibly shifted ~2px up on every page that had an active tab — and since .now-audience
   and .site-account never had this asymmetry, that's also what made "Right now: N listening"
   read as misaligned against the nav on those pages. Reserving it unconditionally keeps every
   route's box (and thus its text) at the identical position. */
.site-nav a { color: var(--text-dim); border-bottom: 2px solid transparent; padding-bottom: 2px; }
.site-nav a:hover { color: var(--text); text-decoration: none; }
.site-nav-toggle {
    display: none; /* desktop: hidden; shown on mobile */
    margin-left: auto;
    background: none;
    border: 0;
    padding: 0.3rem;
    color: var(--text-dim);
    cursor: pointer;
    align-items: center;
}
.site-nav-toggle:hover { color: var(--text); }

.site-account { margin-left: auto; display: flex; align-items: center; gap: 0.75rem; font-size: 0.9rem; }
.site-account__name { color: var(--text-dim); }
a.site-account__name:hover { color: var(--text); text-decoration: none; }
.site-account__action {
    color: var(--text);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    padding: 0.35rem 0.8rem;
}
.site-account__action:hover { background: var(--bg-elev-2); text-decoration: none; }

/* The shared <scenesat-app-menu> (starport's /client/app-menu.js). Its internals sit in a
   shadow root, so the only things that reach them are these custom properties and the parts
   the component exposes. Set nothing and it keeps its own neutral-dark palette — a grey panel
   with a stock blue accent, hanging off the navy header and reading as a foreign object.
   The trigger deliberately copies .site-account__action above: the two stand side by side in
   the header, and until now only one of them looked like a control. */
.site-account__apps {
    --scenesat-menu-bg: var(--bg-elev);
    --scenesat-menu-fg: var(--text);
    --scenesat-menu-accent: var(--accent-strong);
    --scenesat-menu-border: var(--border-light);
    --scenesat-menu-hover: var(--bg-elev-2);
}
.site-account__apps::part(trigger) {
    border: 1px solid var(--border-light);
    border-radius: 8px;
    padding: 0.35rem 0.8rem;
}
.site-account__apps::part(panel) { border-radius: 8px; }
.site-account__apps::part(item) { border-radius: 6px; }

/* The status row under the header (layout.php): the audience widget on the left, the corner
   clock on the right — see the long comment at the include site for why they share a row (both
   moved out of independent positioning the same day, for opposite-but-related reasons).
   Sticky, stacked directly under the sticky header (top: 56px = the header's own min-height) so
   both stay pinned together while scrolling — the corner clock always was (position:fixed); the
   audience widget picked up the same behavior when it moved in here. An explicit opaque
   background is required once it's pinned (page content now scrolls BEHIND it, not above the
   fold), matching the header's own; the border-bottom marks where the pinned area ends. z-index
   one below the header's (20) so the mobile dropdown menu — a child of the header's own z:20
   stacking context, opened via .site-header.is-nav-open — still renders on top of this row. */
.status-bar {
    display: flex;
    /* baseline, not flex-start: the two sides sit at different font-sizes (.now-audience 0.8rem,
       .ss-clock 0.72rem), and flex-start only lines up their BOXES' top edges — a shorter font's
       ascent is smaller, so its text still rendered visibly higher than the taller one's despite
       equal box tops (reported 2026-08-31). baseline aligns the actual TEXT, and still pins the
       clock to the audience side's FIRST line specifically (not its vertical center) when that
       side wraps onto more — a flex container's contributed baseline is its first line's,
       verified 2026-08-31: wrapping .now-audience onto 3 lines left the clock's own box exactly
       where it was on 1 line. */
    align-items: baseline;
    gap: 1rem;
    padding: 0.6rem 1.5rem; /* same left edge as the header's own 1.5rem gutter */
    position: sticky;
    top: 56px;
    z-index: 19;
    background: var(--bg-elev);
    border-bottom: 1px solid var(--border-light);
}

/* "Right now: N listening · M watching" (partials/now-audience.php). Fills whatever width the
   clock doesn't need (flex:1 1 auto) and WRAPS ITS OWN TEXT onto a second line rather than
   truncating or overflowing when that's not enough room — mobile, with several segments live at
   once, is the case this is for. [hidden] when there's nothing worth showing (the wrapper stays
   in the DOM for the JS to fill). Server-rendered from cache only; player.js keeps it live over
   SSE. One instance now, on every breakpoint — see the include-site comment for why (it used to
   be two, switched by media query). */
.now-audience {
    display: flex;
    flex: 1 1 auto;
    min-width: 0;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.4rem;
    row-gap: 0.2rem;
    font-size: 0.8rem;
}
.now-audience[hidden] { display: none; }

/* Sticky/emergency notice banner (partials/site-notice.php + assets/js/notice.js). Sits between
   the header and #content, outside the hx-boost swap target — persists across boosted navigation.
   Not itself position:sticky: it scrolls away with the page below the pinned header, which is
   enough for "front-and-center" without stacking two sticky elements at guessed offsets.
   Server-rendered from a live DB read on the hard load, then kept live by notice.js (SSE + poll).
   [hidden] is set server-side when there is nothing to show, and toggled client-side after that. */
.site-notice { border-bottom: 1px solid var(--border-light); }
.site-notice[hidden] { display: none; }
.site-notice__inner {
    display: flex;
    align-items: flex-start;
    gap: 0.75rem;
    max-width: 1100px;
    margin: 0 auto;
    padding: 0.7rem 1.5rem;
}
.site-notice__body { flex: 1 1 auto; min-width: 0; }
.site-notice__title { display: block; margin-bottom: 0.15rem; }
.site-notice__text { color: var(--text-dim); }
.site-notice__text p:first-child { margin-top: 0; }
.site-notice__text p:last-child { margin-bottom: 0; }
.site-notice__dismiss {
    flex: 0 0 auto;
    background: none;
    border: 0;
    font-size: 1.3rem;
    line-height: 1;
    color: inherit;
    cursor: pointer;
    padding: 0 0.2rem;
}
.site-notice__dismiss[hidden] { display: none; }
.site-notice--info { background: var(--bg-elev); color: var(--text); }
.site-notice--warning { background: #4a3b12; color: #ffe7a3; border-bottom-color: #caa53d; }
.site-notice--warning .site-notice__text { color: #ffe7a3; opacity: 0.85; }
.site-notice--critical { background: #4a1414; color: #ffd7d7; border-bottom-color: #ef4444; }
.site-notice--critical .site-notice__text { color: #ffd7d7; opacity: 0.9; }
.now-audience .live-card__dot { position: static; flex-shrink: 0; }
/* min-width:0 so the counts phrase can shrink below its own text width and wrap internally
   (word-by-word) once the row above has already wrapped the prefix away and it's STILL too wide
   for one line — flex items default to min-width:auto (never shrink below content), which would
   otherwise force it to overflow instead. */
.now-audience__counts { min-width: 0; }

/* ---- Content ---- */
#content { max-width: 980px; margin: 0 auto; padding: 1.5rem; }

.hero { padding: 2rem 0 1.5rem; }
.hero h1 { font-size: 2.5rem; margin: 0 0 0.25rem; font-weight: 300; }
.hero__lead { color: var(--text); max-width: 60ch; font-size:1.4rem; }

.panel { margin: 1.5rem 0; }
.panel__title { font-size: 1.5rem; border-bottom: 0; padding-bottom: 0; }
.panel--narrow { max-width: 460px; }
.muted { color: var(--text-dim); }

/* Forms (account / connect) */
.form { display: flex; flex-direction: column; gap: 0.9rem; margin-top: 1.2rem; }
.form__row { display: flex; flex-direction: column; gap: 0.3rem; }
.form__row span { font-size: 0.85rem; color: var(--text-dim); }
.form__row input {
    background: var(--bg-elev);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    padding: 0.6rem 0.7rem;
    color: var(--text);
    font-size: 1rem;
}
.form__row input:focus { outline: none; border-color: var(--accent); }
.form__select {
    background: var(--bg-elev);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    padding: 0.6rem 0.7rem;
    color: var(--text);
    font-size: 1rem;
}
.form__select:focus { outline: none; border-color: var(--accent); }
.btn {
    align-self: flex-start;
    background: var(--accent-strong);
    color: #04201c;
    border: none;
    border-radius: 8px;
    padding: 0.6rem 1.1rem;
    font-size: 1rem;
    font-weight: 500;
    cursor: pointer;
}
.btn:hover { background: var(--accent); }
.notice { border-radius: 8px; padding: 0.7rem 0.9rem; margin-top: 1rem; }
.notice--ok { background: rgba(45, 212, 191, 0.12); border: 1px solid var(--accent-strong); color: var(--text); }
.notice--error { background: rgba(239, 68, 68, 0.12); border: 1px solid #ef4444; color: var(--text); }
.notice--info { background: var(--bg-elev); border: 1px solid var(--border-light); color: var(--text-dim); }

.btn--ghost { background: transparent; border: 1px solid var(--border-light); color: var(--text-dim); }
.btn--ghost:hover { background: var(--bg-elev); color: var(--text); border-color: var(--text-dim); }
.btn--small { padding: 0.35rem 0.7rem; font-size: 0.85rem; }

/* Your lists (/account/lists) */
.list-card { background: var(--bg-elev); border: 1px solid var(--border-light); border-radius: 10px; padding: 1.1rem 1.3rem; margin: 1.2rem 0; }
/* The whole card is a <details> (foldable, state remembered in localStorage — see
   account-lists.php's inline script); .list-card__summary is its <summary>, keeping the
   browser's native disclosure marker rather than overriding display on the summary itself. */
/* list-style/::-webkit-details-marker together suppress the native disclosure triangle across
   browsers (Firefox honors list-style on a `display: list-item` <summary>; Chrome/Safari render
   their own marker via ::-webkit-details-marker, which list-style alone does not touch). Needed
   because both summaries here wrap a BLOCK-level child (an <h2>, or a flex .list-card__head) —
   the native marker sits on its own line above a block child instead of beside it in every
   engine, so a manual, inline .disclosure-caret is what actually keeps it next to the title. */
.list-card__summary, .panel__summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 0.5rem; }
.list-card__summary::-webkit-details-marker, .panel__summary::-webkit-details-marker { display: none; }
.list-card__summary .list-card__head, .panel__summary .panel__title { flex: 1; min-width: 0; }
.disclosure-caret { display: inline-block; flex-shrink: 0; color: var(--text-dim); transition: transform 0.15s ease; }
details[open] > summary .disclosure-caret { transform: rotate(90deg); }
.list-card[open] > .list-card__summary, details.panel[open] > .panel__summary { margin-bottom: 0.6rem; }
.list-card__head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.list-card__head .panel__title { margin: 0; }
.list-card__title-group, .list-card__controls { display: flex; align-items: center; gap: 0.5rem; }
.list-card__controls form { margin: 0; }
.list-card__count { color: var(--text-dim); font-weight: 400; font-size: 0.85em; white-space: nowrap; }
.list-card__details { margin: 0.2rem 0 0; }
/* Drag-and-drop reordering (list-reorder.js): a handle on a list card or an entry row starts a
   drag; the ▲/▼ nudge buttons rendered next to it are the always-available keyboard/no-JS
   equivalent (same server endpoint, same shape — see that file's header comment). touch-action:
   none stops the browser's own touch-scroll from competing with the drag gesture; cursor: grab
   is enough of an affordance on its own without a title tooltip duplicating aria-hidden text. */
.list-entry__handle { cursor: grab; touch-action: none; color: var(--text-dim); user-select: none; flex-shrink: 0; }
.list-entry__handle:active { cursor: grabbing; }
.is-dragging { opacity: 0.5; }
.list-card__manage { margin-top: 0.9rem; }
.list-card__manage[open] summary { margin-bottom: 0.6rem; }
.list-card__manage summary { cursor: pointer; color: var(--text-dim); font-size: 0.9rem; }
.list-card__sharing { padding-bottom: 0.9rem; margin-bottom: 0.9rem; border-bottom: 1px solid var(--border-light); }
.list-card__sharing form { margin-top: 0.5rem; }
.list-entries { list-style: none; margin: 0.9rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.list-entry { display: flex; align-items: center; gap: 0.7rem; padding: 0.5rem 0.6rem; border-radius: 8px; background: var(--bg); flex-wrap: wrap; }
.list-entry__type {
    font-size: 0.72rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    /* Was --text-dim on --border-light — two mid-tone blue-grays, close enough in
       lightness to read as low-contrast (reported directly: "the labels are a bit
       hard to read"). --text on --bg-elev-2 is the same light-on-dark-navy pairing
       used for legible chips elsewhere in this palette. */
    color: var(--text);
    background: var(--bg-elev-2);
    border-radius: 6px;
    padding: 0.15rem 0.45rem;
    flex-shrink: 0;
}
.list-entry__name { flex: 1; min-width: 12rem; overflow-wrap: anywhere; }
.list-entry__actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; margin-left: auto; }
.list-entry__actions form { margin: 0; }
/* object-fit: cover crops toward the center rather than squashing a non-square source; the
   placeholder (App::placeholderArtwork(), served whenever a track has no artwork set or the
   entry is a creative/user — see account-list-entries.php/public-list.php) is itself already
   square, so cover is a no-op there and only matters for real, non-square track artwork. */
.list-entry__art { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: var(--border-light); }

.show-grid {
    list-style: none;
    padding: 0;
    margin: 0.5rem 0 0;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: 0.9rem;
}
.show-card {
    /* 45%, down from 70% (2026-08-31, "should the chips be somewhat transparent?") — lets more
       of the starfield backdrop read through the card itself, not just the gaps between cards,
       which are too thin (grid gap 0.9rem) and the stars too sparse for the backdrop to be
       visible at all through gaps alone on a dense grid. Still readable: both --bg-elev and the
       page's own --bg sit at the dark end, so blending them further only helps contrast against
       the light --text/--accent card content, never hurts it. */
    background-color: hsl(from var(--bg-elev) h s l / 45%);
    border: 0; /* 2px solid var(--border-light) */
    border-radius: 0;
    transition: background-color 0.2s ease, transform 0.1s ease;
    clip-path: polygon(
      0% 0%,       /* Top-Left anchor point */
      100% 0%,     /* Top-Right anchor point */
      100% calc(100% - 30px), /* Start of the angle cut on the right wall */
      calc(100% - 30px) 100%, /* End of the angle cut on the bottom floor */
      0% 100%      /* Bottom-Left anchor point */
    );
}
.show-card:hover { background-color: hsl(from var(--bg-elev-2) h s l / 45%); }
.show-card__link { display: block; padding: 0.9rem 1rem; color: var(--text); text-decoration: none; }
.show-card__title { font-weight: 600; margin-top: 0.5rem; }
.show-card__host { color: var(--text-dim); font-size: 0.9rem; }
.show-card__when { color: var(--text-dim); font-size: 0.8rem; margin-top: 0.35rem; }
/* "hidden"/"disabled" chip on a station card. Only rendered for viewers whose access scope
   includes the station (admins, its team, grantees) — the public never receives these rows —
   so it reads as the same amber standing-state as the staged-show notice, not as an error. */
.show-card__notpublic {
    display: inline-block; margin-left: 0.4rem; padding: 0.1rem 0.45rem; border-radius: 999px;
    background: rgba(245, 158, 11, 0.15); color: #f59e0b;
    font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
    vertical-align: 0.1em;
}

.show-list { list-style: none; padding: 0; margin: 1rem 0 0; }
/* Flex so the mark button can sit BESIDE the row link rather than inside it (invalid, and it
   would swallow the navigation). The link keeps every bit of slack it had before. */
.show-row { display: flex; align-items: baseline; border-bottom: 1px solid var(--border-light); }
.show-row__link {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    gap: 1rem;
    align-items: baseline;
    padding: 0.65rem 0.25rem;
    color: var(--text);
    text-decoration: none;
}
/* Title block absorbs the slack so the markers + duration + date cluster on the right edge
   (space-between used to strand the duration mid-row, where it read like a timestamp). */
.show-row__main { flex: 1 1 auto; min-width: 0; }
.show-row__link:hover .show-row__title { color: var(--accent); }
.show-row__title { font-weight: 600; }
.show-row__host { color: var(--text-dim); margin-left: 0.5rem; font-size: 0.9rem; }
.show-row__when { color: var(--text-dim); font-size: 0.85rem; white-space: nowrap; }
.show-row__dur { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; font-variant-numeric: tabular-nums; opacity: 0.8; }
/* "Now playing" marker — revealed by the player (decorateNowPlaying) on the playing show's row. */
.show-row__now { display: none; align-items: center; gap: 0.4rem; margin-left: auto; color: var(--accent); font-size: 0.8rem; white-space: nowrap; }
.show-row.is-playing .show-row__now { display: inline-flex; }
.show-row.is-playing .show-row__title { color: var(--accent); }
/* "Chat" badge — guest shows with an imported chat replay (server-rendered, always visible). */
.show-row__chat { color: var(--text-dim); font-size: 0.75rem; border: 1px solid var(--border-light); border-radius: 999px; padding: 0.05rem 0.5rem; white-space: nowrap; }
/* "Resume · 27:34" marker — revealed for partially-heard shows (position memory); playing badge wins. */
.show-row__resume { display: none; color: var(--accent); opacity: 0.75; font-size: 0.8rem; white-space: nowrap; font-variant-numeric: tabular-nums; }
.show-row.has-resume .show-row__resume { display: inline; }
/* "Listened" marker — played through to the end (here or, when logged in, on another device). */
.show-row__listened { display: none; align-items: center; gap: 0.3rem; color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; }
.show-row.is-listened .show-row__listened { display: inline-flex; }
.show-row.is-listened .show-row__listened svg { color: var(--accent); opacity: 0.8; }
/* A row that carries the toggle says "listened" with the button's own lit state, so the text
   marker beside it would be the same fact twice. The toggle is unconditional now, so in practice
   this always wins — the marker stays as the fallback for any row rendered without one. */
.show-row:has(.show-row__mark) .show-row__listened { display: none; }

/* Manual listened toggle on a listing row: icon only — the row is already dense, and the button
   IS the marker here. Dim and outline-less until it means something. */
.show-row__mark {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 30px; height: 30px;
    margin-left: 0.4rem;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: none;
    color: var(--text-dim);
    opacity: 0.35;
    cursor: pointer;
    transition: opacity 0.15s ease, color 0.15s ease, background-color 0.15s ease;
}
.show-row__mark:hover { opacity: 1; color: var(--text); background: var(--bg-elev); }
.show-row__mark:focus-visible { opacity: 1; outline: 2px solid var(--accent); outline-offset: 2px; }
.show-row__mark[aria-pressed="true"] { opacity: 1; color: var(--accent); }

/* Phone widths: the badge/duration/date columns are all nowrap, so side by side they squeeze the
   title to a word per line (and its overflow collides with the Now-playing badge). Stack instead:
   title + host across the full width, the badge/duration/date strip on its own line beneath. */
@media (max-width: 640px) {
    .show-row__link { flex-wrap: wrap; gap: 0.1rem 0.75rem; }
    .show-row__main { flex-basis: 100%; }
    .show-row__now { margin-left: 0; } /* left-cluster the meta strip; auto would split it */
    .show-row__mark { align-self: center; } /* the row is two lines tall now — baseline looks stray */
}

/* Show page: the same toggle, but labelled — it sits in the actions row next to Download, where
   there is room for words. It states the fact itself (lit + "Listened"), so the static badge
   beside it stands down, exactly as the listing rows' marker does. */
.entity__actions:has(.btn-mark-listened) .entity__listened { display: none; }
.btn-mark-listened {
    display: inline-flex;
    align-items: center;
    gap: 0.45rem;
    padding: 0.45rem 0.85rem;
    border: 1px solid var(--border-light);
    border-radius: 999px;
    background: none;
    color: var(--text-dim);
    font: inherit;
    font-size: 0.85rem;
    cursor: pointer;
    transition: color 0.15s ease, border-color 0.15s ease;
}
.btn-mark-listened:hover { color: var(--text); border-color: var(--text-dim); }
.btn-mark-listened:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn-mark-listened[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); }
.show-row__eq { display: inline-flex; align-items: flex-end; gap: 2px; height: 12px; }
.show-row__eq i { width: 3px; height: 100%; background: var(--accent); transform-origin: bottom; animation: ss-eq 0.9s ease-in-out infinite; }
.show-row__eq i:nth-child(1) { animation-delay: -0.2s; }
.show-row__eq i:nth-child(2) { animation-delay: -0.5s; }
.show-row__eq i:nth-child(3) { animation-delay: -0.1s; }
@keyframes ss-eq { 0%, 100% { transform: scaleY(0.35); } 50% { transform: scaleY(1); } }

/* Show-page tracklist: an offset marker plus a linked title/artist (reuses .track-list). */
.track-list__at { flex: 0 0 auto; min-width: 3.4em; color: var(--text-dim); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
a.track-list__title { color: var(--text); text-decoration: none; }
a.track-list__title:hover { color: var(--accent); }
.track-list__sub a { color: var(--text-dim); text-decoration: none; }
.track-list__sub a:hover { color: var(--text); text-decoration: underline; }
.appearance__show a { color: var(--text); text-decoration: none; }
.appearance__show a:hover { text-decoration: underline; }

/* Pagination */
.panel__count { color: var(--text-dim); font-weight: 400; font-size: 0.85em; display: none; opacity: .6; padding-left: 2em; }
/* Two copies per listing (views/partials/pager.php, above and below the rows). Deliberately NO
   border here: a bordered band sitting a fixed distance below the list's own last-row
   border-bottom reads as a stray empty row (each edge draws its own line with blank space between
   them, the same height as a real row) — that's what a reader flagged 2026-08-28. Plain vertical
   spacing groups it with the list it belongs to without drawing a second box under the first. */
.pager {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem 1rem;
    margin: 1rem 0;
}
.pager__links { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
.pager__link {
    color: var(--text);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    padding: 0.4rem 0.9rem;
    text-decoration: none;
    white-space: nowrap;
}
.pager__link:hover { background: var(--bg-elev-2); text-decoration: none; }
.pager__link.is-disabled { color: var(--text-dim); opacity: 0.5; pointer-events: none; }
/* The windowed page-number run — a couple of clicks from any page instead of stepping through
   every one of them. */
.pager__nums { display: flex; align-items: center; gap: 0.15rem; font-variant-numeric: tabular-nums; }
.pager__num {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 2rem;
    height: 2rem;
    padding: 0 0.3rem;
    border-radius: 8px;
    color: var(--text-dim);
    text-decoration: none;
}
a.pager__num:hover { background: var(--bg-elev-2); color: var(--text); text-decoration: none; }
.pager__num.is-current { color: var(--text); background: var(--bg-elev-2); font-weight: 600; }
.pager__gap { color: var(--text-dim); padding: 0 0.15rem; }
/* Quick-jump — for an archive long enough that even the windowed run above isn't quite enough. */
.pager__jump { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--text-dim); }
.pager__jump input[type="number"] {
    width: 3.6rem;
    background: var(--bg-elev);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    padding: 0.3rem 0.4rem;
    color: var(--text);
    font-variant-numeric: tabular-nums;
}
.pager__jump input[type="number"]:focus { outline: none; border-color: var(--accent); }
.pager__jump button {
    background: var(--bg-elev);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    padding: 0.3rem 0.7rem;
    color: var(--text);
    cursor: pointer;
}
.pager__jump button:hover { background: var(--bg-elev-2); }

/* ---- Footer player ---- */
.ss-player {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    height: var(--player-h);
    z-index: 50;
    display: flex;
    align-items: center;
    gap: 0.9rem;
    padding: 0 1rem;
    background-color: var(--bg-elev-2);
    border-top: 0;
    box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.35);
}

/* VU meter — Web Audio frequency spectrum drawn behind the controls, bars rising from the bottom. */
.ss-player__vu {
    position: absolute;
    left: 0;
    bottom: 0;
    width: 100%;
    height: 100%;
    z-index: 0;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.4s ease;
}
.ss-player.is-playing .ss-player__vu {
    opacity: 0.55;
}
/* Keep every control above the meter. */
.ss-player > :not(.ss-player__vu) {
    position: relative;
    z-index: 1;
}

.ss-player__toggle {
    flex: 0 0 auto;
    width: 48px;
    height: 48px;
    border-radius: 50%;
    border: 2px solid var(--accent);
    background: var(--button-primary);
    color: #04201c;
    font-size: 1.1rem;
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    transition: background 0.15s ease, transform 0.05s ease;
}
.ss-player__toggle:hover { background: var(--accent); border-color: var(--accent-strong); }
.ss-player__toggle:active { transform: scale(0.95); }

.ss-player__icon { display: inline-flex; align-items: center; justify-content: center; }
.ss-player__icon--play svg { margin-left: 2px; } /* optical centering of the triangle */
.ss-player__icon--pause,
.ss-player__spinner { display: none; }
.ss-player.is-playing .ss-player__icon--play { display: none; }
.ss-player.is-playing .ss-player__icon--pause { display: inline-flex; }

/* Loading spinner while the stream connects. */
.ss-player.is-loading .ss-player__icon--play,
.ss-player.is-loading .ss-player__icon--pause { display: none; }
.ss-player.is-loading .ss-player__spinner {
    display: inline-block;
    width: 18px;
    height: 18px;
    border: 2px solid rgba(4, 32, 28, 0.4);
    border-top-color: #04201c;
    border-radius: 50%;
    animation: ss-spin 0.7s linear infinite;
}
@keyframes ss-spin { to { transform: rotate(360deg); } }

.ss-player__art { flex: 0 0 auto; }
.ss-player__art img {
    width: 48px;
    height: 48px;
    border-radius: 6px;
    object-fit: cover;
    background: var(--bg-elev);
    display: block;
}

.ss-player__meta { flex: 1 1 auto; min-width: 0; }
.ss-player__title {
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.ss-player__artist {
    color: var(--text-dim);
    font-size: 0.9rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
/* Now-playing title/artist links: look like plain text, underline only when actually linked. */
.ss-player__title-link,
.ss-player__artist-link { color: inherit; text-decoration: none; }
.ss-player__title-link[href]:hover,
.ss-player__artist-link[href]:hover { text-decoration: underline; cursor: pointer; }

.ss-player__time {
    flex: 0 0 auto;
    color: var(--text-dim);
    font-variant-numeric: tabular-nums;
    font-size: 0.85rem;
}
.ss-player__time .ss-player__duration:empty + .ss-player__sep,
.ss-player__sep:has(+ .ss-player__duration:empty) { display: none; }

.ss-player__channel {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    color: var(--text-dim);
    font-size: 0.8rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
.ss-player__live-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #6b7785;
}
.ss-player.is-playing .ss-player__live-dot {
    background: #ef4444;
    box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6);
    animation: ss-pulse 1.6s ease-out infinite;
}
@keyframes ss-pulse {
    0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6); }
    100% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); }
}

/* Volume control */
.ss-player__volume { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 0.5rem; }
.ss-player__mute {
    background: none;
    border: 0;
    padding: 0;
    color: var(--text-dim);
    cursor: pointer;
    display: inline-flex;
    align-items: center;
}
.ss-player__mute:hover { color: var(--text); }
.ss-player__vol-icon { display: inline-flex; }
.ss-player__vol-icon--off { display: none; }
.ss-player__volume.is-muted .ss-player__vol-icon--on { display: none; }
.ss-player__volume.is-muted .ss-player__vol-icon--off { display: inline-flex; }

.ss-player__vol-slider {
    -webkit-appearance: none;
    appearance: none;
    width: 84px;
    height: 4px;
    border-radius: 999px;
    background: var(--bg-elev);
    outline: none;
    cursor: pointer;
}
.ss-player__vol-slider::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: var(--accent);
    border: none;
    cursor: pointer;
}
.ss-player__vol-slider::-moz-range-thumb {
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: var(--accent);
    border: none;
    cursor: pointer;
}
.ss-player__volume.is-muted .ss-player__vol-slider::-webkit-slider-thumb { background: var(--text-dim); }
.ss-player__volume.is-muted .ss-player__vol-slider::-moz-range-thumb { background: var(--text-dim); }

/* Live-stream quality picker (in the volume cluster; player.js fills the menu, hides it when
   there's nothing to choose). Hidden in show mode — recordings are single-file.
   The [hidden] rule is load-bearing, not decorative: without it this display:inline-flex beats
   the UA's [hidden] { display: none } default (same specificity, author sheet wins), so setting
   els.quality.hidden in JS did nothing and the picker stayed visible — showing the PREVIOUS
   channel's quality menu — on every channel with fewer than two variants: a guest station (0)
   or a real channel publishing a single tier, like compobg (1). Same idiom as .ss-chat-panel
   above. */
.ss-player__quality { position: relative; display: inline-flex; }
.ss-player__quality[hidden] { display: none; }
.ss-player.is-show .ss-player__quality { display: none; }
.ss-player__quality-btn {
    background: none;
    border: 1px solid var(--border-light);
    border-radius: 4px;
    padding: 1px 6px;
    color: var(--text-dim);
    font-size: 0.68rem;
    font-weight: 600;
    letter-spacing: 0.05em;
    cursor: pointer;
}
.ss-player__quality-btn:hover,
.ss-player__quality-btn[aria-expanded="true"] { color: var(--text); border-color: var(--text-dim); }
.ss-player__quality-menu {
    position: absolute;
    bottom: calc(100% + 12px);
    right: 0;
    min-width: 170px;
    padding: 0.25rem;
    background: var(--bg-elev-2);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
    z-index: 40;
}
.ss-player__quality-menu button {
    display: block;
    width: 100%;
    padding: 0.4rem 0.6rem;
    background: none;
    border: 0;
    border-radius: 6px;
    color: var(--text);
    font: inherit;
    font-size: 0.85rem;
    text-align: left;
    cursor: pointer;
    white-space: nowrap;
}
.ss-player__quality-menu button:hover { background: rgba(255, 255, 255, 0.06); }
.ss-player__quality-menu button.is-active { color: var(--accent); }
.ss-player__quality-menu button.is-active::after { content: " ✓"; }

/* Stereo width: headphone icon + dropdown, quality-menu's twin (shown in show mode only). */
.ss-player__width { position: relative; }

.ss-player__width-btn {
    display: inline-flex;
    align-items: center;
    background: none;
    border: 1px solid var(--border-light);
    border-radius: 4px;
    padding: 2px 5px;
    color: var(--text-dim);
    cursor: pointer;
}

.ss-player__width-btn:hover,
.ss-player__width-btn[aria-expanded="true"] { color: var(--text); }

.ss-player__width-menu {
    position: absolute;
    bottom: calc(100% + 12px);
    right: 0;
    min-width: 170px;
    padding: 0.25rem;
    background: var(--bg-elev-2);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    z-index: 30;
}

.ss-player__width-menu button {
    display: block;
    width: 100%;
    padding: 0.4rem 0.6rem;
    background: none;
    border: 0;
    border-radius: 6px;
    color: var(--text);
    text-align: left;
    cursor: pointer;
}

.ss-player__width-menu button:hover { background: rgba(255, 255, 255, 0.06); }
.ss-player__width-menu button.is-active { color: var(--accent); }
.ss-player__width-menu button.is-active::after { content: " ✓"; }

.ss-player__width-caption {
    border-top: 1px solid var(--border-light);
    margin-top: 0.25rem;
    padding: 0.4rem 0.6rem 0.2rem;
    font-size: 0.8rem;
    color: var(--text-dim);
}

/* ---- Footer player: show playback (recorded shows) ---- */
/* Show mode is the SAME height as live mode. It once grew (76px -> 96px) to fit the show title
   and the whole-show counter; when the base was redesigned to 100px that override survived and
   silently inverted into a 4px SHRINK — the mode with MORE content got the smaller box. The
   extra line fits inside 100px with room to spare, so there is no second height to keep in step:
   don't reintroduce one without re-measuring `.ss-player__meta` (~70px in show mode). */

/* Transport: prev/next (and ±30s + the zoom toggle) flank the play button, show mode only. */
.ss-player__transport { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 0.4rem; }
.ss-player__prev,
.ss-player__next,
.ss-player__back30,
.ss-player__fwd30,
.ss-player__zoombtn {
    display: none;
    width: 34px; height: 34px;
    border-radius: 50%;
    border: 1px solid var(--border-light);
    background: var(--bg-elev);
    color: var(--text-dim);
    cursor: pointer;
    align-items: center; justify-content: center;
}
.ss-player.is-show .ss-player__prev,
.ss-player.is-show .ss-player__next,
.ss-player.is-show .ss-player__back30,
.ss-player.is-show .ss-player__fwd30,
.ss-player.is-show .ss-player__zoombtn { display: inline-flex; }
.ss-player__prev:hover,
.ss-player__next:hover,
.ss-player__back30:hover,
.ss-player__fwd30:hover,
.ss-player__zoombtn:hover { color: var(--text); border-color: var(--accent); }
.ss-player.is-zoom .ss-player__zoombtn { color: var(--accent); border-color: var(--accent); }

/* Show title above the per-track meta; the "Show" badge replaces the live dot. */
.ss-player__show-title {
    display: none;
    font-size: 0.78rem;
    color: var(--text-dim);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    margin-bottom: 0.1rem;
}
.ss-player__show-title[href]:hover { color: var(--accent); text-decoration: none; }
.ss-player.is-show .ss-player__show-title { display: block; }

.ss-player__show-badge {
    display: none;
    font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em;
    color: #04201c; background: var(--accent);
    border-radius: 3px; padding: 0.05rem 0.35rem;
}
.ss-player.is-show .ss-player__show-badge { display: inline-block; }
.ss-player.is-show .ss-player__live-dot { display: none; }

/* Same badge language as .ss-player__show-badge, reused for "this is a guest station's own
   stream, not a SceneSat channel" — see player.js's isGuestStation handling in playChannel(). */
.ss-player__kind-badge {
    display: none;
    font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em;
    color: var(--text-dim); background: transparent; border: 1px solid var(--border-light);
    border-radius: 3px; padding: 0.05rem 0.35rem;
}
.ss-player.is-guest .ss-player__kind-badge { display: inline-block; }

.ss-player__live {
    display: none;
    background: none; border: 1px solid var(--border-light); border-radius: 999px;
    color: var(--text-dim); cursor: pointer;
    font: inherit; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;
    padding: 0.1rem 0.5rem;
}
.ss-player.is-show .ss-player__live { display: inline-block; }
.ss-player__live:hover { color: var(--text); border-color: var(--accent); }

/* Whole-show position/length, e.g. "1:34:43 / 2:26:27" — show mode only. */
.ss-player__show-time { display: none; margin-left: 0.5rem; color: var(--text-dim); }
.ss-player.is-show .ss-player__show-time { display: inline; }

/* Full-show timeline pinned to the footer's top edge; striped per track, click/drag to seek.
   Selector is `.ss-player .ss-player__timeline` so its absolute positioning beats the base
   `.ss-player > :not(.ss-player__vu)` rule (which would otherwise force position:relative). */
.ss-player .ss-player__timeline {
    display: none;
    position: absolute;
    left: 0; right: 0;
    /* Anchor the bottom edge 7px below the footer top so hover grows the bar UPWARD (into the page
       above), not downward into the controls. */
    top: auto;
    bottom: calc(100% - 7px);
    height: 7px;
    cursor: pointer;
    background: var(--bg-elev);
    touch-action: none;
    transition: height 0.12s ease;
}
.ss-player.is-show .ss-player__timeline { display: block; }
/* `.is-hover` is toggled by player.js from a document-level pointermove hit-test; `:hover` is kept
   as a fallback. See bindTimelineHover() — a plain :hover can go stale on the fixed footer overlay
   after an hx-boost swap of #content until the window is refocused. */
.ss-player .ss-player__timeline:hover,
.ss-player .ss-player__timeline.is-hover { height: 12px; }
.ss-player__segments { position: absolute; inset: 0; display: flex; }
.ss-player__seg { height: 100%; border-right: 1px solid rgba(8, 10, 13, 0.9); }
.ss-player__seg:last-child { border-right: 0; }
.ss-player__seg.is-current { box-shadow: inset 0 0 0 9999px rgba(45, 212, 191, 0.22); }

/* Variant fills — music (the majority) is a clean solid; narration is hatched so spoken-word segments
   stand out (à la /relive). Alternating shades are a full lightness step apart (not a subtle tint) so
   adjacent same-type tracks read as separate blocks even under the played-overlay wash. */
.ss-player__seg--music { background: #38404f; }
.ss-player__seg--music.is-alt { background: #64738f; }
.ss-player__seg--narration { background: repeating-linear-gradient(135deg, #7d8a9d 0 6px, #414a58 6px 12px); }
.ss-player__seg--narration.is-alt { background: repeating-linear-gradient(135deg, #96a2b5 0 6px, #525d6e 6px 12px); }
.ss-player__seg--talk { background: #48538a; }
.ss-player__seg--talk.is-alt { background: #7180bd; }
.ss-player__seg--jingle { background: #74532f; }
.ss-player__seg--jingle.is-alt { background: #a57a49; }
.ss-player__seg--unknown { background: #2e3540; }
.ss-player__seg--unknown.is-alt { background: #4d5868; }

/* Intermission — the gap between two sectioned plays. Hatched like narration, and the ANGLE is what
   separates them: narration leans 135deg, an intermission leans 45deg, so the two read as different
   kinds of thing at a glance even at 7px tall and even for a viewer who cannot tell the hues apart.
   Tone only reinforces it — a step quieter than narration, because narration is content and an
   intermission is the absence of it — so the distinction never RESTS on colour.
   With "Show intermissions" off there is no intermission segment to style: player.js absorbs it
   into the segment before it and the bar rearranges, so 3 tracks and 2 gaps become 3 blocks rather
   than 5 with two blanked out. The absorbed span keeps its width, which is what keeps click-to-seek
   honest — the timeline is a time axis. */
.ss-player__seg--intermission { background: repeating-linear-gradient(45deg, #4a5260 0 5px, #2b313b 5px 10px); }
.ss-player__seg--intermission.is-alt { background: repeating-linear-gradient(45deg, #5c6675 0 5px, #373f4b 5px 10px); }


/* Played-so-far overlay — light enough that the segment colours stay legible under it. */
.ss-player__progress {
    position: absolute; left: 0; top: 0; bottom: 0; width: 0;
    background: rgba(45, 212, 191, 0.18);
    pointer-events: none;
}
/* Now-playing playhead at the played edge — a few px taller than the thin strip (top and bottom)
   so the current position stands out instead of blending into the bar. */
.ss-player__progress::after {
    content: "";
    position: absolute;
    top: -3px; bottom: -3px;
    right: -1px;
    width: 2px;
    border-radius: 1px;
    background: var(--accent);
}

/* Zoom-window marker on the thin timeline — outlines what the zoomed strip shows, making the
   full-show bar its minimap (the iOS scrubber's arrangement). Positioned by player.js. */
.ss-player__zoom-marker {
    position: absolute; top: 0; bottom: 0;
    border: 1px solid rgba(255, 255, 255, 0.65);
    border-radius: 3px;
    pointer-events: none;
}

/* The zoomed scrubber strip: an overlay ABOVE the footer (its own box — the footer keeps
   --player-h; body padding reserves the strip via --player-zoom-h). The playhead is locked at
   the center; player.js scrolls the segments strip under it with a transform.
   `.ss-player .ss-player__zoomstrip` for the same reason as the timeline above it: the base
   `.ss-player > :not(.ss-player__vu)` rule outranks a single class and would silently flip
   this back to position:relative — a zero-width flex item instead of a full-width overlay. */
.ss-player .ss-player__zoomstrip {
    display: none;
    position: absolute;
    left: 0; right: 0;
    bottom: calc(100% + 6px);
    height: 46px;
}
.ss-player.is-show.is-zoom .ss-player__zoomstrip { display: block; }
.ss-player__zoom-view {
    position: absolute; inset: 0;
    overflow: hidden;
    background: var(--bg-elev);
    border-top: 1px solid var(--border-light);
    border-bottom: 1px solid var(--border-light);
    cursor: grab;
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
}
.ss-player__zoom-view.is-dragging { cursor: grabbing; }
.ss-player__zoom-segments {
    position: absolute; top: 0; bottom: 0; left: 0;
    display: flex;
    will-change: transform;
}
.ss-player__zoom-playhead {
    position: absolute; left: 50%; top: -3px; bottom: -3px;
    width: 2px; margin-left: -1px;
    border-radius: 1px;
    background: var(--accent);
    pointer-events: none;
}
/* Left edge, not right: the floating "Live Chat" launcher sits over the strip's right corner
   (user report — the ± buttons were unreachable behind it). */
.ss-player__zoom-tools {
    position: absolute; top: 5px; left: 8px;
    display: flex; gap: 3px;
}
.ss-player__zoom-in,
.ss-player__zoom-out {
    width: 22px; height: 22px;
    border-radius: 5px;
    border: 1px solid var(--border-light);
    background: rgba(8, 10, 13, 0.7);
    color: var(--text-dim);
    font-size: 14px; line-height: 1;
    cursor: pointer;
    display: inline-flex; align-items: center; justify-content: center;
}
.ss-player__zoom-in:hover,
.ss-player__zoom-out:hover { color: var(--text); border-color: var(--accent); }

/* Mobile fold chevron — desktop never shows it (the full bar always fits there). */
.ss-player__fold {
    display: none;
    background: none;
    border: 0;
    padding: 0.3rem;
    color: var(--text-dim);
    cursor: pointer;
    align-items: center;
    justify-content: center;
}
.ss-player__fold svg { transition: transform 0.15s ease; }
.ss-player.is-open .ss-player__fold svg { transform: rotate(180deg); }
.ss-player__fold:hover { color: var(--text); }

/* ---- Slide-hide footer player ----
   #ss-player-hide lives OUTSIDE the footer, riding on --player-h, so it stays at the bottom edge
   when html.ss-player-hidden slides the footer away (and player.js publishes --player-h: 0 —
   which also drops page padding, the corner clock and the mini-player's bottom corners). Hiding
   is visual only; playback continues. */
.ss-player { transition: transform 0.25s ease; }
/* The slide distance is a % of the footer's OWN box — but its height is var(--player-h), which
   the hidden state zeroes for the page's benefit. Left alone, that collapses the footer to 0
   and translateY(%) moves it nowhere (content bleeds back into view) — so pin the real desktop
   height here (mobile's JS-pinned inline height wins over this anyway). --player-h-base, not a
   literal: this rule spent a redesign pinning 76px after the footer had become 100px. */
html.ss-player-hidden .ss-player { transform: translateY(110%); height: var(--player-h-base); }
.ss-player-hide {
    position: fixed;
    left: 1rem;
    /* --ss-bottom-inset (player.js, visualViewport-tracked): keeps the tab clear of iOS Safari's
       own floating toolbar, which can otherwise sit directly on top of bottom:0 fixed content —
       0px everywhere else, so this is a no-op off iOS. See initIosBottomInset()'s own comment. */
    bottom: calc(var(--player-h) + var(--ss-bottom-inset, 0px));
    z-index: 51; /* just above the footer (50), below the video dock (60) */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0.25rem 0.7rem;
    background: transparent;
    border: 0;
    border-radius: 0;
    color: var(--text-dim);
    cursor: pointer;
    transition: bottom 0.25s ease;
}
.ss-player-hide:hover { color: var(--text); }
.ss-player-hide svg { transition: transform 0.25s ease; }
html.ss-player-hidden .ss-player-hide svg { transform: rotate(180deg); }

/* ---- Corner clock (viewer-local time, Manager-style) ----
   Right side of .status-bar, under the header (see that rule + layout.php's comment at the
   include site) — shares the row with the audience widget instead of independently guessing a
   pixel offset: flex:0 0 auto + nowrap means it never shrinks or wraps regardless of how much the
   audience side needs, and margin-left:auto keeps it flush right whether or not the audience
   side is even showing (its wrapper stays in the DOM but collapses via [hidden]). */
.ss-clock {
    flex: 0 0 auto;
    margin-left: auto;
    white-space: nowrap;
    color: var(--text-dim);
    font-size: 0.72rem;
    font-variant-numeric: tabular-nums;
    opacity: 0.7;
    pointer-events: none;
}
.ss-clock:empty { display: none; }

/* ---- Lightbox (lightbox.js: img[data-lightbox] reopens full-size in a native <dialog>) ---- */
img[data-lightbox] { cursor: zoom-in; }
.ss-lightbox {
    padding: 0;
    border: 0;
    background: transparent;
    overflow: visible; /* the close button pokes out above the image's corner */
    cursor: zoom-out;
}
.ss-lightbox::backdrop { background: rgba(4, 6, 9, 0.8); }
.ss-lightbox__img {
    display: block;
    max-width: min(92vw, 1600px);
    max-height: 88vh;
    width: auto;
    height: auto;
    border-radius: 10px;
    box-shadow: 0 12px 60px rgba(0, 0, 0, 0.6);
}
.ss-lightbox__close {
    position: absolute;
    top: -14px;
    right: -14px;
    width: 36px;
    height: 36px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    border: 1px solid var(--border-light);
    background: var(--bg-elev-2);
    color: var(--text);
    cursor: pointer;
}
.ss-lightbox__close:hover { border-color: var(--accent); }
/* Caption + "View original" line under CMS page images (hidden for plain artwork). */
.ss-lightbox__bar {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
    margin-top: 0.5rem;
    padding: 0 0.25rem;
    font-size: 0.85rem;
    cursor: default;
}
.ss-lightbox__caption { color: var(--text-dim); }
.ss-lightbox__original { color: var(--accent); white-space: nowrap; }

/* ---- Entity pages (artist / track) ---- */
.entity__head { margin-bottom: 1rem; }
.entity__head--track { display: flex; gap: 1.25rem; align-items: flex-start; }
.entity__art { flex: 0 0 auto; }
.entity__art img {
    width: 160px;
    height: 160px;
    border-radius: 12px;
    object-fit: cover;
    background: var(--bg-elev);
    display: block;
}
.entity__headtext { min-width: 0; }
.entity__kind {
    text-transform: uppercase;
    letter-spacing: 0.08em;
    font-size: 0.75rem;
    color: var(--text-dim);
    margin: 0 0 0.25rem;
}
.entity__title { font-size: 1.9rem; margin: 0 0 0.35rem; line-height: 1.15; }
.entity__meta, .entity__artist { color: var(--text-dim); margin: 0; }
/* Staged-show notice. Only ever rendered for a viewer who has preview access, so it should read as
   a standing state ("what you see is not what the public sees"), not as an error. */
.entity__preview {
    margin: 0.5rem 0 0;
    padding: 0.5rem 0.7rem;
    border-left: 3px solid #f59e0b;
    background: rgba(245, 158, 11, 0.08);
    color: var(--text);
    font-size: 0.9rem;
    max-width: 70ch;
}
.entity__preview strong { color: #f59e0b; letter-spacing: 0.04em; text-transform: uppercase; font-size: 0.8rem; }
.entity__artist a { color: var(--text); }
.entity__members { color: var(--text); margin: 0.4rem 0 0; font-size: 0.9rem; }
.entity__members-label { color: var(--text-dim); }
.entity__members a { color: var(--text); }
.entity__members a:hover { color: var(--accent); }
.entity__bio { color: var(--text-dim); max-width: 70ch; margin-top: 0.85rem; }

.track-list { list-style: none; margin: 1rem 0 0; padding: 0; }
.track-list__row {
    display: flex;
    align-items: baseline;
    gap: 1rem;
    /* Roomier left inset so the time offset doesn't hug the row edge (or the is-current
       accent bar); a touch on the right for symmetry. */
    padding: 0.55rem 0.5rem 0.55rem 0.85rem;
    border-bottom: 1px solid var(--border-light);
    /* Per-track playback progress paints as a left-anchored tint (see ::before). */
    position: relative;
}
/* Rows with the stacked play control (show page) top-align; text-only rows keep baseline. */
.track-list__row:has(.track-list__play) { align-items: flex-start; }
/* Playback progress within this track: 100% once played past, proportional while current,
   0 upcoming — width driven per tick by player.js via --ss-row-progress. */
.track-list__row::before {
    content: "";
    position: absolute;
    left: 0; top: 0; bottom: 0;
    width: var(--ss-row-progress, 0%);
    background: rgba(45, 212, 191, 0.08);
    pointer-events: none;
}
.track-list__main {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    color: var(--text);
    text-decoration: none;
}
.track-list__main:hover .track-list__title { color: var(--accent); }
.track-list__title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.track-list__sub { color: var(--text-dim); font-size: 0.85rem; }
.track-list__rating,
.track-list__plays,
.track-list__dur {
    flex: 0 0 auto;
    color: var(--text-dim);
    font-size: 0.85rem;
    font-variant-numeric: tabular-nums;
}
.track-list__rating { color: var(--accent); }

/* Show-page "Play show" button. */
.btn-play-show {
    margin-top: 0.75rem;
    display: inline-flex; align-items: center; gap: 0.5rem;
    background: var(--accent-strong); color: #04201c;
    border: 1px solid var(--border-light); border-radius: 999px;
    padding: 0.4rem 0.95rem; font: inherit; font-weight: 600; cursor: pointer;
}
.btn-play-show:hover { background: var(--accent); }

/* Play + Download action row on show/track pages. */
.entity__actions { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.75rem; flex-wrap: wrap; }
.entity__actions .btn-play-show { margin-top: 0; }
/* Show-page played-through marker (revealed by player.js, mirrors .show-row__listened). */
.entity__listened { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--text-dim); font-size: 0.85rem; white-space: nowrap; }
.entity__listened svg { color: var(--accent); opacity: 0.8; }
.entity__listened[hidden] { display: none; }
.btn-download {
    display: inline-flex; align-items: center; gap: 0.4rem;
    border: 1px solid var(--border-light); border-radius: 999px;
    padding: 0.4rem 0.85rem;
    color: var(--text-dim); background: var(--bg-elev);
    font-weight: 600; text-decoration: none;
}
.btn-download:hover { color: var(--text); border-color: var(--accent); text-decoration: none; }
/* File type + size hint inside the download button, e.g. "MP3 · 128.7 MB". */
.btn-download__meta { color: var(--text-dim); font-size: 0.78rem; white-space: nowrap; }
/* .save-to-list is a <form> wrapping one button; display:contents keeps it out of the
   .entity__actions flex layout entirely, so the button sits exactly where a bare button would. */
.save-to-list { display: contents; }
.btn-save {
    display: inline-flex; align-items: center; gap: 0.4rem;
    border: 1px solid var(--border-light); border-radius: 999px;
    padding: 0.4rem 0.85rem;
    color: var(--text-dim); background: var(--bg-elev);
    font-weight: 600; font-size: 1rem; cursor: pointer;
}
.btn-save:hover { color: var(--text); border-color: var(--accent); }
.btn-save.is-saved { color: var(--accent); border-color: var(--accent); }
.btn-save.is-saved:hover { color: #ef4444; border-color: #ef4444; }
.btn-play-show__icon { display: inline-flex; }
.btn-play-show__icon--pause { display: none; }
.btn-play-show.is-playing .btn-play-show__icon--play { display: none; }
.btn-play-show.is-playing .btn-play-show__icon--pause { display: inline-flex; }

/* Tracklist row play affordance (wraps the timecode) + current-row highlight. */
/* Stacked play control: the time offset with an always-visible ▶ centered beneath it (flips to
   ⏸ while that track is the one playing) — the obvious "click here to play" affordance. */
.track-list__play {
    flex: 0 0 auto;
    display: inline-flex; flex-direction: column; align-items: center; gap: 0.2rem;
    background: none; border: 0; padding: 0; margin: 0;
    color: var(--text-dim); cursor: pointer; font: inherit;
    font-size: 0.85rem; font-variant-numeric: tabular-nums;
}
.track-list__play .track-list__at { min-width: 3.4em; color: inherit; text-align: center; }
.track-list__play:hover { color: var(--text); }
.track-list__icon { display: none; font-size: 0.7rem; line-height: 1; }
.track-list__icon--play { display: inline; }
.track-list__row:hover .track-list__icon--play { color: var(--accent); }
.track-list__row.is-current { background: rgba(45, 212, 191, 0.07); box-shadow: inset 3px 0 0 var(--accent); }
.track-list__row.is-current .track-list__play { color: var(--accent); }
.track-list__row.is-current.is-playing .track-list__icon--play { display: none; }
.track-list__row.is-current.is-playing .track-list__icon--pause { display: inline; }

.stamp-list {
    list-style: none;
    margin: 1rem 0 0;
    padding: 0;
    color: var(--text-dim);
    font-variant-numeric: tabular-nums;
}
.stamp-list li { padding: 0.3rem 0.25rem; border-bottom: 1px solid var(--border-light); }

.appearance-list { list-style: none; margin: 1rem 0 0; padding: 0; }
.appearance {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    gap: 0.25rem 1rem;
    padding: 0.5rem 0.25rem;
    border-bottom: 1px solid var(--border-light);
}
.appearance__show { font-weight: 600; }
.appearance__host { color: var(--text-dim); font-weight: 400; }
.appearance__meta { color: var(--text-dim); font-size: 0.85rem; font-variant-numeric: tabular-nums; }

.kv { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1.25rem; margin: 1rem 0 0; }
.kv dt { color: var(--text-dim); }
.kv dd { margin: 0; font-variant-numeric: tabular-nums; }

/* ---- Foldable footer player (mirrors Manager's mobile footer) ----
   Applies up to 900px — wider than the page's 640px mobile breakpoint on purpose, so LANDSCAPE
   phones (~700–900px wide) get the foldable bar too; the desktop bar needs more width than that
   once show mode adds prev/next + two counters + the show title.
   Collapsed: a compact grid row — play, artwork, what's-playing, fold chevron. Expanded (.is-open):
   stacked rows. Height is content-driven; player.js measures it, sets it as an explicit px height
   (so the fold ANIMATES via the height transition) and publishes it into --player-h so the page's
   bottom padding tracks it. The VU canvas is hidden — which also means player.js never routes the
   <audio> through Web Audio here, so playback survives screen-off/backgrounding. */
@media (max-width: 900px) {
    .ss-player {
        display: grid;
        grid-template-columns: auto auto minmax(0, 1fr) auto;
        grid-template-areas: "transport art meta fold";
        align-items: center;
        column-gap: 0.6rem;
        row-gap: 0.55rem;
        height: auto; /* until player.js pins the measured px height */
        align-content: start;
        padding: 0.55rem 0.75rem;
        overflow: hidden; /* clip rows mid-animation (only trims the timeline's hover-growth) */
        transition: height 0.22s ease;
    }
    body { transition: padding-bottom 0.22s ease; }

    /* Expanded: stacked rows so nothing fights for width —
       row 1: artwork + what's-playing (wide) + chevron (minimal)
       row 2: time counters (track left, whole-show right)
       row 3: volume left, channel/Live right
       row 4: transport, centered, big touch targets
       Rows 3–4 are line-placed spanning ALL columns (not named areas): grid columns are shared
       across rows, so parking e.g. the channel cluster in the chevron's column would inflate the
       chevron cell in row 1 to 50% of the bar. Full-width spans keep column 3 = just the chevron. */
    .ss-player.is-open {
        grid-template-columns: auto minmax(0, 1fr) max-content;
        grid-template-areas:
            "art meta fold"
            "time time time";
    }
    .ss-player.is-open .ss-player__volume { display: inline-flex; grid-area: 3 / 1 / 4 / -1; justify-self: start; }
    .ss-player.is-open .ss-player__channel { display: inline-flex; grid-area: 3 / 1 / 4 / -1; justify-self: end; }
    .ss-player.is-open .ss-player__transport { grid-area: 4 / 1 / 5 / -1; justify-self: center; gap: 1.1rem; }
    .ss-player.is-open .ss-player__toggle { width: 52px; height: 52px; }
    .ss-player__vu { display: none; }
    .ss-player__transport { grid-area: transport; }
    .ss-player__art { grid-area: art; }
    .ss-player__meta { grid-area: meta; min-width: 0; }
    .ss-player__fold { grid-area: fold; display: inline-flex; }
    .ss-player__toggle { width: 42px; height: 42px; }
    .ss-player__art img { width: 42px; height: 42px; }

    /* Collapsed: essentials only. (The prev/next hide must outrank the desktop
       `.ss-player.is-show .ss-player__prev` reveal, hence the .is-show variants.)
       The zoom strip + toggle stay desktop-only: the phones have the apps' own scrubber,
       and the strip's wheel/drag idiom needs a pointer. */
    .ss-player__time,
    .ss-player__volume,
    .ss-player__channel { display: none; }
    .ss-player.is-show .ss-player__prev,
    .ss-player.is-show .ss-player__next,
    .ss-player.is-show .ss-player__back30,
    .ss-player.is-show .ss-player__fwd30,
    .ss-player.is-show .ss-player__zoombtn { display: none; }
    .ss-player.is-show.is-zoom .ss-player__zoomstrip { display: none; }
    html.ss-show.ss-zoom { --player-zoom-h: 0px; }

    /* Expanded rows. Time: the two counters sit apart (track left, whole-show pushed right). */
    .ss-player.is-open .ss-player__time {
        display: flex;
        align-items: baseline;
        gap: 0.25rem;
        grid-area: time;
        font-size: 0.9rem;
    }
    .ss-player.is-open .ss-player__show-time { margin-left: auto; }
    .ss-player.is-open.is-show .ss-player__prev,
    .ss-player.is-open.is-show .ss-player__next,
    .ss-player.is-open.is-show .ss-player__back30,
    .ss-player.is-open.is-show .ss-player__fwd30 { display: inline-flex; width: 44px; height: 44px; }
}

/* Landscape phones / mid widths: enough room to merge fold rows 3+4 into one —
   volume | transport (centered in the otherwise-empty middle) | channel. All three are full-width
   spans in the same row, separated by justify-self; ~450px of content, so safe from 560px up.
   Below 560px (portrait) the transport keeps its own row. */
@media (min-width: 560px) and (max-width: 900px) {
    .ss-player.is-open .ss-player__transport { grid-area: 3 / 1 / 4 / -1; justify-self: center; }
}

@media (max-width: 640px) {
    /* Folded mobile menu: hamburger toggles a dropdown panel under the header. Its own
       margin-left:auto (base rule, above) is enough to hug the right edge — the audience widget
       used to share this row too (hence the order/margin dance this block once needed), but it
       moved to its own row below the header entirely, so there's nothing left to coordinate with. */
    .site-nav-toggle { display: inline-flex; flex-shrink: 0; }
    .site-menu {
        display: none;
        position: absolute;
        top: 100%;
        left: 0;
        right: 0;
        flex-direction: column;
        align-items: stretch;
        gap: 0.85rem;
        padding: 0.85rem 1.5rem 1.1rem;
        background: var(--bg-elev);
        border-bottom: 1px solid var(--border-light);
        box-shadow: 0 8px 22px rgba(0, 0, 0, 0.4);
    }
    .site-header.is-nav-open .site-menu { display: flex; }
    .site-nav { flex-direction: column; gap: 0.1rem; }
    .site-nav a { padding: 0.4rem 0; }
    .site-account { margin-left: 0; flex-direction: column; align-items: flex-start; gap: 0.6rem; }
    .entity__head--track { gap: 1rem; }
    .entity__art img { width: 96px; height: 96px; }
    .entity__title { font-size: 1.4rem; }
    .track-list__plays { display: none; }
}

/* ---- Community chat (panel + /chat page; markup in views/partials/chat-box.php) ---- */
.ss-chat { display: flex; flex-direction: column; min-height: 0; }
.ss-chat__list {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    padding: 0.6rem 0.75rem;
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    overscroll-behavior: contain;
}
.ss-chat__msg { font-size: 0.88rem; line-height: 1.35; overflow-wrap: anywhere; }
.ss-chat__msg--bot { opacity: 0.75; }
.ss-chat__badge {
    display: inline-block;
    margin-right: 0.4rem;
    padding: 0 0.35rem;
    border-radius: 3px;
    font-size: 0.62rem;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    vertical-align: 1px;
    color: #fff;
    background: var(--border-light);
}
.ss-chat__badge--discord { background: #5865f2; }
.ss-chat__badge--slack { background: #a1315e; }
.ss-chat__badge--irc { background: #5c6773; }
/* Twitch is deliberately the LIGHT chip (Twitch's own brand light purple, dark text) while
   Discord stays a dark chip with white text. Their standard brand colors #9146ff and #5865f2
   have IDENTICAL relative luminance (0.177 vs 0.178) — they differ only in hue, which is
   exactly the axis degraded color vision cannot use, so side by side they read as the same
   badge (reported 2026-08-22). Opposite-polarity chips separate even in grayscale. This block
   is the fleet's badge source of truth — mirror any change into the five app clients
   (grep scenesat-mobile for #5865F2). */
.ss-chat__badge--twitch { background: #bf94ff; color: #1f1147; }
.ss-chat__badge--youtube { background: #d33; }
.ss-chat__badge--web { background: var(--accent-strong); color: #04211d; }
/* The apps. "app" ships now; "ios"/"android" are reserved for a later per-OS split (bridgebot
   already passes those slugs through, so they need no further backend change). */
.ss-chat__badge--app { background: #e0782f; }
.ss-chat__badge--ios { background: #8e8e93; }
.ss-chat__badge--android { background: #3ddc84; color: #06251a; }
.ss-chat__meta { margin-right: 0.4rem; white-space: nowrap; }
.ss-chat__author { font-weight: 600; }
.ss-chat__time { margin-left: 0.35rem; color: var(--text-dim); font-size: 0.72rem; }
/* "Off" timestamp style leaves the span empty — collapse it so there's no stray gap. */
.ss-chat__time:empty { display: none; margin-left: 0; }
.ss-chat__text { color: var(--text); }
.ss-chat__text a { color: var(--accent-strong); overflow-wrap: anywhere; }
/* Inline image posts: capped thumbnail, click-through to the original. */
.ss-chat__imglink { display: block; margin-top: 0.25rem; }
.ss-chat__img {
    display: block;
    max-width: 100%;
    max-height: 180px;
    border-radius: 6px;
    border: 1px solid var(--border-light);
}
/* Custom emotes, substituted INTO the text run (chat.js renderText) rather than appended
   under it — an emote is a word in the sentence, not an attachment to it. Sized to the line so
   a run of them cannot push the row height around, and text-bottom keeps them on the baseline
   instead of riding above it the way a default inline image would. */
.ss-chat__emote {
    display: inline-block;
    height: 1.5em;
    width: auto;
    max-width: 8em;
    vertical-align: text-bottom;
    object-fit: contain;
}
/* Zond-mirrored attachments (chat-attachments.js): thumbnails on their own line under the text,
   reusing the inline-image sizing above so a mirrored image and a pasted one look the same. */
.ss-chat__attachments { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.25rem; }
/* The wrapper owns the spacing now, so the shared link rule's own margin would double it. */
.ss-chat__attachments .ss-chat__imglink { margin-top: 0; }
.ss-chat__attachments .ss-chat__img { max-height: 140px; }
/* A mirrored clip in the same wrapper. The wrapper owns the spacing, and deliberately NO
   object-fit: cropping a portrait phone clip to a letterbox takes the control bar with it.
   Taller than the thumbnail cap on purpose — native controls need the room to stay usable. */
.ss-chat__attachments .ss-chat__video { margin-top: 0; max-height: 180px; }
/* A dead URL (the message was redacted, so its files were unpublished) degrades to this. */
.ss-chat__filelink {
    display: inline-block;
    align-self: center;
    color: var(--accent-strong);
    font-size: 0.8rem;
    overflow-wrap: anywhere;
}
/* Inline video posts (bridgebot's mirrored Discord clips). Same cap as the image thumbnail so
   one clip can't shove the chat column around. Deliberately NOT wrapped in an .ss-chat__imglink
   — a click on <video controls> inside an <a> navigates away instead of playing.
   Separate from .ss-chat__attachments above: that wrapper is Zond's structured payload path,
   this one comes from a URL substituted into the message text by bridgebot. */
.ss-chat__video {
    display: block;
    margin-top: 0.25rem;
    max-width: 100%;
    max-height: 180px;
    border-radius: 6px;
    border: 1px solid var(--border-light);
    background: #000;
}
.chat-replay__text a { color: var(--accent-strong); overflow-wrap: anywhere; }
.ss-chat__form {
    display: flex;
    gap: 0.5rem;
    padding: 0.55rem 0.75rem;
    border-top: 1px solid var(--border-light);
}
.ss-chat__input {
    flex: 1 1 auto;
    min-width: 0;
    padding: 0.45rem 0.6rem;
    border: 1px solid var(--border-light);
    border-radius: 6px;
    background: var(--bg);
    color: var(--text);
    font: inherit;
    font-size: 0.9rem;
}
.ss-chat__input:focus { outline: none; border-color: var(--accent-strong); }
.ss-chat__send {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 38px;
    border: 0;
    border-radius: 6px;
    background: var(--accent-strong);
    color: #04211d;
    cursor: pointer;
}
.ss-chat__send:hover { background: var(--accent); }
.ss-chat__notice {
    padding: 0.35rem 0.75rem;
    border-top: 1px solid var(--border-light);
    color: #fbbf24;
    font-size: 0.8rem;
}
.ss-chat__login {
    padding: 0.6rem 0.75rem;
    border-top: 1px solid var(--border-light);
    color: var(--text-dim);
    font-size: 0.88rem;
}

/* The display:flex rules below would otherwise defeat the hidden attribute chat.js toggles
   (author display rules beat the UA's [hidden] { display: none }). */
.ss-chat-panel[hidden],
.ss-chat-panel__body[hidden] { display: none; }

/* Persistent panel: fixed above the footer player, collapsed to just the tab by default. */
.ss-chat-panel {
    position: fixed;
    right: 1rem;
    bottom: calc(var(--player-h) + 1rem);
    z-index: 70; /* above the video mini-player (60) — an opened chat must never hide under it */
    transition: bottom 0.25s ease;
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 0.5rem;
}
/* The video mini-player's bottom-right corner is the chat's home too — when the dock floats
   there, step the chat up above it (dock height = 16:9 media at its width + the title bar).
   #ss-vdock precedes the chat panel in layout.php, so a sibling selector can see its state. */
#ss-vdock.ss-vdock--floating.ss-vdock--br:not([hidden]) ~ .ss-chat-panel {
    bottom: calc(var(--player-h) + 1rem + (min(400px, 100vw - 2rem) * 9 / 16) + 2.1rem + 0.75rem);
}
.ss-chat-panel__tab {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.45rem 0.9rem;
    border: 1px solid var(--border-light);
    border-radius: 999px;
    background: var(--bg-elev);
    color: var(--text-dim);
    font: inherit;
    font-size: 0.88rem;
    cursor: pointer;
    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
.ss-chat-panel__tab:hover, .ss-chat-panel.is-open .ss-chat-panel__tab { color: var(--text); border-color: var(--accent-strong); }
.ss-chat-panel__body {
    display: flex;
    flex-direction: column;
    width: min(340px, calc(100vw - 2rem));
    height: min(60vh, 540px);
    border: 1px solid var(--border-light);
    border-radius: 10px;
    background: var(--bg-elev);
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
    overflow: hidden;
}
.ss-chat-panel__body .ss-chat { flex: 1 1 auto; min-height: 0; }
/* The panel holds one view at a time (community chat / the tuned stream's recorded log). Each
   view has to carry the flex chain down to its scroller, or the body's fixed height has nothing
   to give its space to — same reasoning as .show-companion__view on the show pages. */
.ss-chat-panel__view {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
}
.ss-chat-panel__view[hidden] { display: none; }
/* The tab strip reuses .show-companion__switch wholesale for its look; this only places it inside
   the panel, which has no padding of its own. Hidden (by the attribute) until a recorded chat is
   actually playing — with nothing tuned, the panel is the community chat alone, exactly as before. */
.ss-chat-panel__switch { margin: 0.55rem 0.55rem 0; }
.ss-chat-panel__switch[hidden] { display: none; }
/* The panel's recorded view: unlike the watch page's .ss-chat--recorded, it needs NO frame of its
   own — the panel body already is one. Only the notice's inset is declared here. */
.ss-chat-panel__view .live-chat__notice { margin: 0.55rem 0.75rem 0; flex: 0 0 auto; }
.ss-chat-panel__view .live-chat__notice[hidden] { display: none; }

/* /chat page variant: the chat fills the viewport between header and player. */
.chat-page .ss-chat--page {
    height: calc(100vh - var(--player-h) - 230px);
    min-height: 320px;
    border: 1px solid var(--border-light);
    border-radius: 10px;
    background: var(--bg-elev);
    overflow: hidden;
}

/* ---- New-version toast (assets/js/version.js) ---- */
/* Offers a reload after a deploy. Sits above the fixed footer player; a reload the user is merely
   OFFERED is never taken for them, because it stops playback.
   Two consumers, so restyle with both in view: version.js's own toast, and player.js's
   #ss-resume-toast, which borrows these classes to say playback could not be resumed after the
   one reload that IS forced (see its "Deploy handoff" section). */
.version-toast {
    position: fixed;
    right: 1rem;
    bottom: calc(var(--player-h) + 1rem);
    z-index: 100; /* above EVERYTHING — player (50), video dock (60), chat (70) */
    display: flex;
    align-items: center;
    gap: 0.75rem;
    max-width: min(26rem, calc(100vw - 2rem));
    padding: 0.6rem 0.6rem 0.6rem 1rem;
    background: var(--bg-elev-2);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
}
.version-toast .btn { align-self: center; padding: 0.35rem 0.8rem; font-size: 0.9rem; }
.version-toast__close {
    background: none;
    border: none;
    color: var(--text-dim);
    font-size: 1.2rem;
    line-height: 1;
    padding: 0.2rem 0.4rem;
    cursor: pointer;
}
.version-toast__close:hover { color: var(--text); }

/* ---- Nav active state (server-rendered on hard loads, kept in sync by live.js) ---- */
/* Width/color only — .site-nav a already reserves the border-bottom + padding-bottom above, so
   this must not redeclare them (a shorthand redeclaration here is how the box-height-changes-
   with-route bug happened the first time). */
.site-nav a.is-active { color: var(--text); border-bottom-color: var(--accent); }

/* ---- Archive sub-nav (views/partials/archive-nav.php) ---- */
.tab-nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
.tab-nav__link {
    color: var(--text-dim);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    padding: 0.4rem 0.9rem;
    font-size: 0.95rem;
}
.tab-nav__link:hover { color: var(--text); border-color: var(--accent-strong); text-decoration: none; }
.tab-nav__link.is-active { color: var(--text); border-color: var(--accent-strong); background: rgba(45, 212, 191, 0.12); }

/* ---- Event filter chips (views/video-recordings.php) ----
   An event narrows an archive; it is not an archive of its own, so these are chips beside the
   search rather than a tab. Lighter than .tab-nav__link on purpose: a filter, not a section. */
.event-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0 0 1rem; }
.event-filter__label { color: var(--text-dim); font-size: 0.85rem; margin-right: 0.15rem; }
.event-filter__chip {
    color: var(--text-dim);
    border: 1px solid var(--border-light);
    border-radius: 999px;
    padding: 0.2rem 0.7rem;
    font-size: 0.85rem;
    line-height: 1.6;
}
.event-filter__chip:hover { color: var(--text); border-color: var(--accent-strong); text-decoration: none; }
.event-filter__chip.is-active { color: var(--text); border-color: var(--accent-strong); background: rgba(45, 212, 191, 0.12); }
.event-filter__count { color: var(--text-dim); font-size: 0.78rem; margin-left: 0.25rem; }

/* ---- Panel section heading (between the title and a sub-listing, e.g. Live's Audio/Video) ---- */
.panel__subtitle { font-size: 1rem; color: var(--text-dim); margin: 1.25rem 0 0.6rem; font-weight: 600; }

/* ---- Live tab cards (views/live.php) ---- */
.live-grid {
    list-style: none;
    padding: 0;
    margin: 0;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
    gap: 0.9rem;
}
/* Uniform card heights: the li stretches to the grid row, the card fills the li, and the text
   bits below reserve fixed line counts — so a long track title can't make one card taller. */
.live-grid > li { display: flex; }
/* Audio cards only: the button (a full card) plus a trailing "Direct link" affordance stacked
   below it, still stretching to the grid row's height like every other card. */
.live-grid > li.live-card-slot { flex-direction: column; align-items: stretch; gap: 0.35rem; }
.live-card-slot .live-card { flex: 1 1 auto; }
.live-card__direct-link {
    align-self: flex-start;
    font-size: 0.78rem;
    color: var(--text-dim);
    text-decoration: none;
    padding: 0 0.2rem;
}
.live-card__direct-link:hover { color: var(--accent); text-decoration: underline; }
.live-card {
    display: block;
    flex: 1 1 auto;
    width: 100%;
    text-align: left;
    background: var(--bg-elev);
    border: 1px solid var(--border-light);
    border-radius: 10px;
    color: var(--text);
    font: inherit;
    padding: 0;
    overflow: hidden;
    cursor: pointer;
    transition: border-color 0.15s ease;
}
.live-card:hover { border-color: var(--accent-strong); text-decoration: none; }
/* The channel the footer player is on: accent border + tint + glow, and the "▶ Listen" action is
   swapped for an animated Playing badge (below). Class comes from decorateNowPlaying(). */
.live-card.is-playing {
    border-color: var(--accent);
    background: linear-gradient(0deg, rgba(45, 212, 191, 0.08), rgba(45, 212, 191, 0.08)) var(--bg-elev);
    box-shadow: 0 0 16px rgba(45, 212, 191, 0.18);
}
.live-card__body { display: block; padding: 0.75rem 1rem; position: relative; }
.live-card__name {
    display: block;
    font-weight: 600;
    padding-right: 1.2rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.live-card__np {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    overflow: hidden;
    color: var(--text-dim);
    font-size: 0.9rem;
    line-height: 1.5;
    margin-top: 0.15rem;
    /* Exactly two lines, always — rendered even when nothing is playing. */
    min-height: 3em;
}
.live-card__action { display: block; color: var(--accent); font-size: 0.85rem; margin-top: 0.4rem; }
.live-card.is-playing .live-card__action { display: none; } /* the footer player owns the controls now */

/* Now-playing badge on the /live audio card — animated EQ bars + "Playing". Occupies the action's
   slot (same font-size/margin) so the card height doesn't shift when playback starts/stops. */
.live-card__playing {
    display: none;
    align-items: center;
    gap: 0.45rem;
    color: var(--accent);
    font-size: 0.85rem;
    font-weight: 600;
    margin-top: 0.4rem;
}
.live-card.is-playing .live-card__playing { display: flex; }
.live-card__eq { display: inline-flex; align-items: flex-end; gap: 2px; height: 12px; }
.live-card__eq i {
    width: 3px;
    height: 100%;
    border-radius: 1px;
    background: var(--accent);
    transform-origin: bottom;
    animation: eq-bounce 1.1s ease-in-out infinite;
}
.live-card__eq i:nth-child(2) { animation-delay: 0.25s; }
.live-card__eq i:nth-child(3) { animation-delay: 0.5s; }
@keyframes eq-bounce {
    0%, 100% { transform: scaleY(0.3); }
    50% { transform: scaleY(1); }
}
.live-card__thumb {
    display: block;
    width: 100%;
    aspect-ratio: 16 / 9;
    object-fit: cover;
    background: var(--bg-elev-2);
    border-bottom: 1px solid var(--border-light);
}
.live-card__thumb.is-offline { opacity: 0.35; } /* stream just dropped; next list poll removes the card */
.live-card__dot {
    position: absolute;
    top: 0.9rem;
    right: 0.9rem;
    width: 9px;
    height: 9px;
    border-radius: 50%;
    background: #ef4444;
    animation: live-pulse 2s ease-in-out infinite;
}
.live-card--audio .live-card__dot { position: absolute; }
@keyframes live-pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.35; }
}
.live-badge {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.8rem;
    color: var(--text-dim);
    vertical-align: middle;
    margin-left: 0.5rem;
}
.live-badge .live-card__dot { position: static; }
.watch-back { margin-top: 1rem; }

/* ---- 16:9 media frame (the /watch iframe and the show page's <video>) ---- */
.video-frame {
    position: relative;
    aspect-ratio: 16 / 9;
    background: #000;
    border: 1px solid var(--border-light);
    border-radius: 10px;
    overflow: hidden;
    margin: 1rem 0;
}
.video-frame iframe,
.video-frame video {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    border: 0;
}

/* ---- Archive listing tools (views/partials/archive-tools.php) ---- */
.archive-tools {
    display: flex;
    flex-wrap: wrap;
    gap: 0.6rem;
    margin-bottom: 1rem;
}
.archive-tools__field { position: relative; display: flex; flex: 1 1 14rem; }
.archive-tools__search {
    width: 100%;
    background: var(--bg-elev);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    padding: 0.5rem 2rem 0.5rem 0.8rem;
    color: var(--text);
    font-size: 0.95rem;
}
.archive-tools__search:focus { outline: none; border-color: var(--accent); }
/* Hide WebKit's own ✕ so there's exactly one clear affordance. */
.archive-tools__search::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
.archive-tools__clear {
    display: none;
    position: absolute;
    right: 0.35rem;
    top: 50%;
    transform: translateY(-50%);
    background: none;
    border: none;
    color: var(--text-dim);
    font-size: 1.2rem;
    line-height: 1;
    padding: 0.2rem 0.4rem;
    cursor: pointer;
}
.archive-tools__clear:hover { color: var(--text); }
.archive-tools__field:has(.archive-tools__search:not(:placeholder-shown)) .archive-tools__clear { display: block; }
.archive-tools__sort {
    background: var(--bg-elev);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    padding: 0.5rem 0.6rem;
    color: var(--text);
    font-size: 0.95rem;
}
.archive-tools__sort:focus { outline: none; border-color: var(--accent); }

/* ---- Breadcrumbs (views/partials/breadcrumbs.php) ---- */
.breadcrumbs {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.85rem;
    color: var(--text-dim);
    margin-bottom: 0.75rem;
}
.breadcrumbs__link { color: var(--text-dim); }
.breadcrumbs__link:hover { color: var(--text); text-decoration: none; }
.breadcrumbs__sep { opacity: 0.6; }
.breadcrumbs__current {
    color: var(--text);
    max-width: 24rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ---- Guest station header (logo + title/description) ---- */
.station-head { display: flex; align-items: center; gap: 1rem; }
.station-head__logo {
    width: 72px;
    height: 72px;
    flex: none;
    object-fit: contain;
    border-radius: 10px;
    background: var(--bg-elev-2);
    border: 1px solid var(--border-light);
}

/* ---- Persistent video dock (#ss-vdock, layout.php + assets/js/vod-player.js) ---- */
/* Inline: JS gives the dock the placeholder's exact box (absolute, in document flow, so it
   scrolls naturally). Floating: a fixed mini-player above the footer player. */
.ss-vdock { position: absolute; z-index: 60; background: #000; overflow: hidden; }
.ss-vdock[hidden] { display: none; }
.ss-vdock--inline { border: 1px solid var(--border-light); border-radius: 10px; }
.ss-vdock__media { position: absolute; inset: 0; }
.ss-vdock__media video,
.ss-vdock__media iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
.ss-vdock--floating {
    position: fixed;
    width: min(400px, calc(100vw - 2rem));
    border: 1px solid var(--border-light);
    border-radius: 10px;
    box-shadow: 0 10px 32px rgba(0, 0, 0, 0.55);
}
/* Mini-player corner (draggable by the title bar; the choice persists as a preference).
   Bottom corners clear the footer player, top corners the site header. */
.ss-vdock--br { right: 1rem; bottom: calc(var(--player-h) + 1rem); }
.ss-vdock--bl { left: 1rem; bottom: calc(var(--player-h) + 1rem); }
.ss-vdock--tr { right: 1rem; top: calc(56px + 2.5rem); } /* header + the corner clock (top: 60px) */
.ss-vdock--tl { left: 1rem; top: calc(56px + 2.5rem); }
.ss-vdock--floating .ss-vdock__media { position: relative; inset: auto; aspect-ratio: 16 / 9; }
.ss-vdock__bar { display: none; }
.ss-vdock--floating .ss-vdock__bar {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.35rem 0.6rem;
    background: var(--bg-elev-2);
    border-top: 1px solid var(--border-light);
}
.ss-vdock__nudge {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: none;
    border: none;
    color: var(--text-dim);
    padding: 0.15rem 0.3rem;
    cursor: pointer;
}
.ss-vdock__nudge:hover { color: var(--text); }
.ss-vdock__meta { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.ss-vdock__title {
    color: var(--text);
    font-size: 0.85rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.ss-vdock__title:hover { color: var(--accent); text-decoration: none; }
/* The mini-player's live track line (the stream's Now Playing attachment, vod-player.js). */
.ss-vdock__np {
    color: var(--text-dim);
    font-size: 0.75rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.ss-vdock__close {
    background: none;
    border: none;
    color: var(--text-dim);
    font-size: 1.1rem;
    line-height: 1;
    padding: 0.15rem 0.35rem;
    cursor: pointer;
}
.ss-vdock__close:hover { color: var(--text); }

/* Deferred takeover (vod-player.js): another video keeps playing in the mini-player, so this
   watch page shows its poster + an explicit play affordance instead of hijacking playback. */
.vdock-defer {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.75rem;
    border: 0;
    background-color: #000;
    background-size: cover;
    background-position: center;
    color: var(--text);
    font: inherit;
    cursor: pointer;
}
/* Dim the poster so the affordance reads on any artwork. */
.vdock-defer::before { content: ""; position: absolute; inset: 0; background: rgba(0, 0, 0, 0.55); }
.vdock-defer > * { position: relative; }
.vdock-defer__hint {
    position: absolute;
    top: 0.75rem;
    left: 50%;
    transform: translateX(-50%);
    max-width: calc(100% - 2rem);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    padding: 0.25rem 0.75rem;
    border-radius: 999px;
    background: rgba(0, 0, 0, 0.6);
    color: var(--text-dim);
    font-size: 0.8rem;
}
.vdock-defer__play {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 64px;
    height: 64px;
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.6);
    border: 2px solid var(--text);
    transition: transform 0.15s ease, border-color 0.15s ease;
}
/* CSS triangle instead of a text glyph — same reason as the nudge icons (font-size drift). */
.vdock-defer__play::before {
    content: "";
    border-style: solid;
    border-width: 12px 0 12px 20px;
    border-color: transparent transparent transparent var(--text);
    margin-left: 5px;
}
.vdock-defer:hover .vdock-defer__play { transform: scale(1.08); border-color: var(--accent); }
.vdock-defer__label { font-size: 0.95rem; color: var(--text); }

/* ---- Live now-playing strip (views/watch.php, [data-live-np]) ----
   Seeded server-side from the /api/live snapshot; vod-player.js keeps it fresh (and toggles
   [hidden]) while the dock plays this stream's Now Playing attachment. */
.live-np { display: flex; align-items: center; gap: 0.65rem; margin-top: 0.75rem; min-width: 0; }
.live-np__art {
    width: 44px;
    height: 44px;
    border-radius: 6px;
    object-fit: cover;
    background: var(--bg-elev-2);
    flex-shrink: 0;
}
.live-np__text { min-width: 0; display: flex; flex-direction: column; }
.live-np__kicker {
    color: var(--accent);
    font-size: 0.72rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
}
.live-np__track {
    color: var(--text);
    font-size: 0.95rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ---- Watch-page actions, quick jump + theatre mode (views/video-watch.php) ---- */
.watch-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.75rem 0; }
button.btn-download { background: none; font: inherit; cursor: pointer; }
.watch-actions .btn-download.is-active { color: var(--text); border-color: var(--accent); }
.jump-bar { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0 0 0.75rem; }
.jump-bar__stop {
    background: var(--bg-elev);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    color: var(--text-dim);
    font-size: 0.85rem;
    font-variant-numeric: tabular-nums;
    padding: 0.3rem 0.6rem;
    cursor: pointer;
}
.jump-bar__stop:hover { color: var(--text); border-color: var(--accent-strong); }
/* Theatre mode: the frame breaks out of the content column into a viewport-sized 16:9 box,
   capped so it clears the header and the footer player. Applies to /watch (live) too.
   The 60vw floor keeps the height-derived term from collapsing the frame (it goes negative on
   short viewports); phones effectively get "as wide as fits". */
html.ss-theatre .video-frame {
    width: min(calc(100vw - 3rem), max(60vw, calc((100vh - var(--player-h) - 200px) * 16 / 9)));
    max-width: none;
    margin-left: 50%;
    transform: translateX(-50%);
}

/* ---- Watch-page cuepoints (views/video-watch.php) ---- */
.cue-list { list-style: none; padding: 0; margin: 0; }
.cue-list__row { border-bottom: 1px solid var(--border-light); }
.cue-list__row:last-child { border-bottom: none; }
/* The playing cue, like the audio tracklist: accent marker + a progress tint (vod-player.js
   drives --cue-progress) filling the row as the cue plays. */
.cue-list__row.is-current {
    background: linear-gradient(90deg, rgba(45, 212, 191, 0.14) var(--cue-progress, 0%), transparent var(--cue-progress, 0%));
    box-shadow: inset 2px 0 0 var(--accent);
}
.cue-list__row.is-current .cue-list__label { color: var(--text); font-weight: 600; }
.cue-list__row.is-current .cue-list__time { color: var(--accent); }
.cue-list__seek {
    display: flex;
    align-items: baseline;
    gap: 0.9rem;
    width: 100%;
    text-align: left;
    background: none;
    border: none;
    color: var(--text);
    font: inherit;
    /* Same roomier left inset as .track-list__row — clear of the is-current accent bar. */
    padding: 0.45rem 0.5rem 0.45rem 0.85rem;
    cursor: pointer;
}
.cue-list__seek:hover .cue-list__label { color: var(--accent); }
.cue-list__time { color: var(--text-dim); font-size: 0.85rem; font-variant-numeric: tabular-nums; flex: none; }
/* Injected "Intermission" rows between sectioned cues (cues carrying an explicit end) — dimmed,
   and CSS-hidden wholesale when the viewer turns the preference off (vod-player.js toggles the
   html class from localStorage, default on). */
.cue-list__row--intermission .cue-list__label { color: var(--text-dim); font-style: italic; }
html.ss-no-intermissions .cue-list__row--intermission { display: none; }
/* Same treatment for the audio show tracklist (show_plays.end_offset sections), plus the same
   45deg lean the timeline uses for an intermission — a thin edge stripe, so a row and its segment
   in the bar are recognisably the same thing. Narration leans the other way; see the timeline
   variant fills above. */
.track-list__row--intermission .track-list__title { color: var(--text-dim); font-style: italic; font-weight: 400; }
.track-list__row--intermission {
    border-left: 4px solid transparent;
    background: repeating-linear-gradient(45deg, rgba(120, 134, 156, 0.16) 0 5px, transparent 5px 10px) left / 4px 100% no-repeat;
}
html.ss-no-intermissions .track-list__row--intermission { display: none; }
/* Dropouts are real cues, not injected ones, so they are NOT dimmed — only filterable. On a long
   broadcast there can be dozens, and they bury the cues somebody actually named. */
html.ss-no-dropouts .cue-list__row--dropout { display: none; }
/* Narration rows are real plays, not injected ones, so they are hidden but never dimmed — the
   preference is "I am reading this for the music", not "these are lesser". */
html.ss-no-narration .track-list__row--narration { display: none; }
.cue-toggles { display: flex; flex-wrap: wrap; gap: 0 1rem; }
.cue-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    margin: 0 0 0.5rem;
    color: var(--text-dim);
    font-size: 0.85rem;
    cursor: pointer;
    user-select: none;
}
.cue-toggle input { accent-color: var(--accent); cursor: pointer; }

/* ---- "Hide listened" archive filter ----
   .is-listened is put on the row by decorateNowPlaying(); the html class is the switch. The
   currently-playing row is never hidden — .is-playing wins — so a show can't vanish underfoot
   the moment it finishes, which is the same rule the apps' filter follows. */
html.ss-hide-listened .show-row.is-listened:not(.is-playing) { display: none; }
.archive-tools__filter {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.88rem;
    color: var(--text-dim);
    cursor: pointer;
    white-space: nowrap;
}
.archive-tools__filter input { cursor: pointer; }

/* ---- Show panes: tracklist + chat replay side by side (show pages with a chat log) ----
   Each pane is viewport-height and scrolls on its own; the page itself stays put once you're
   there. player.js auto-follows the current track inside the tracklist pane and the chat tail
   inside the log. Stacked with capped heights on narrow screens. */
.show-panes {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 1.25rem;
    align-items: stretch;
    margin-top: 1.25rem;
}
.show-panes > .panel {
    display: flex;
    flex-direction: column;
    height: calc(100vh - var(--player-h) - 56px - 3.5rem); /* viewport minus footer player + header + breathing room */
    min-height: 320px;
    margin: 0;
}
.show-panes .track-list,
.show-panes .chat-replay__log,
.show-panes .transcript__body {
    flex: 1 1 auto;
    min-height: 0;
    max-height: none; /* the pane's height rules, not the standalone 380px cap */
    overflow-y: auto;
}
/* The companion pane holds one view at a time (chat replay / transcript). The view has to carry
   the flex chain down to the scroller, or the pane's fixed height has nothing to give its space
   to and the log scrolls the whole page instead of itself. */
.show-companion__view {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
}
.show-companion__view[hidden] { display: none; }
.show-companion__switch {
    display: flex;
    gap: 2px;
    background: hsl(from var(--bg-elev) h s l / 70%);
    border-radius: 8px;
    padding: 3px;
    margin: 0 0 0.75rem;
    flex: 0 0 auto;
}
.show-companion__switch [role="tab"] {
    flex: 1;
    font-family: inherit;
    font-size: 0.88rem;
    font-weight: 600;
    color: var(--text-dim);
    background: transparent;
    border: none;
    border-radius: 6px;
    padding: 0.4rem 0.8rem;
    cursor: pointer;
}
.show-companion__switch [role="tab"][aria-selected="true"] {
    background: hsl(from var(--bg-elev-2) h s l / 90%);
    color: var(--text);
}
/* The pane tab bar exists only for narrow screens; desktop shows both panes side by side. */
.show-panes__tabs { display: none; }

@media (max-width: 900px) {
    /* minmax(0,…), same as desktop: bare 1fr min-sizes to content, and the nowrap track titles
       then push the single column wider than the phone — page-wide horizontal overflow with the
       tab bar and every row running off the right edge. Zero floor = titles ellipsize instead. */
    .show-panes { grid-template-columns: minmax(0, 1fr); }
    .show-panes > .panel { height: auto; }
    .show-panes .track-list,
    .show-panes .chat-replay__log,
    .show-panes .transcript__body { max-height: 60vh; }

    .show-panes__tabs {
        display: flex;
        gap: 2px;
        background: hsl(from var(--bg-elev) h s l / 70%);
        border-radius: 8px;
        padding: 3px;
        margin-top: 1rem;
    }
    .show-panes__tabs [role="tab"] {
        flex: 1;
        font-family: inherit;
        font-size: 0.9rem;
        font-weight: 600;
        color: var(--text-dim);
        background: transparent;
        border: none;
        border-radius: 6px;
        padding: 0.45rem 0.9rem;
        cursor: pointer;
    }
    .show-panes__tabs [role="tab"][aria-selected="true"] {
        background: hsl(from var(--bg-elev-2) h s l / 90%);
        color: var(--text);
    }
    /* One pane at a time: default = Tracklist, .is-tab-companion flips to the companion. */
    .show-panes:not(.is-tab-companion) > .show-companion { display: none; }
    .show-panes.is-tab-companion > .panel:not(.show-companion) { display: none; }
}

/* ---- Transcript (show pages with an imported SRT/VTT transcript sidecar) ----
   Generous line height, the offset as a quiet seek affordance in the margin, and the speaker
   inline so a two-voice show stays followable. Playable shows grow this like a log, cue by cue,
   as player.js reveals them (see transcriptReplayTick); the rare unplayable-legacy-show branch
   still renders the whole thing at once, so it still reads as prose there. */
.transcript__body { display: flex; flex-direction: column; gap: 0.9rem; padding-right: 0.25rem; }
.transcript__cue { display: grid; grid-template-columns: 3.6rem minmax(0, 1fr); gap: 0.6rem; align-items: baseline; }
.transcript__at {
    font-variant-numeric: tabular-nums;
    font-size: 0.82rem;
    color: var(--text-dim);
    background: transparent;
    border: none;
    padding: 0;
    text-align: right;
    font-family: inherit;
}
button.transcript__at { cursor: pointer; }
button.transcript__at:hover { color: var(--text); text-decoration: underline; }
.transcript__text { margin: 0; line-height: 1.6; }
.transcript__speaker { font-weight: 600; color: var(--accent, var(--text)); margin-right: 0.35rem; }
.transcript__cue.is-current .transcript__text { color: var(--text); }
.transcript__cue.is-current .transcript__at { color: var(--text); font-weight: 600; }
.transcript__lang { font-size: 0.72rem; font-weight: 600; color: var(--text-dim); margin-left: 0.4rem; }
/* The cue a transcript-search deep link (?t=) landed on — a quiet, persistent "you are here". */
.transcript__cue.is-search-hit {
    background: hsl(from var(--accent, #6ea8fe) h s l / 12%);
    border-left: 2px solid var(--accent, #6ea8fe);
    border-radius: 4px;
    padding: 0.35rem 0.4rem 0.35rem 0.5rem;
    margin: -0.35rem -0.4rem -0.35rem -0.5rem;
}

/* ---- Transcript search (the archive's fourth tab) ----
   Grouped per show: the show is the headline, its matched moments are quiet rows beneath —
   the same visual weight order as the show lists, so the page reads as "shows that said this". */
.transcript-hits { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1.4rem; }
.transcript-hits__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; margin-bottom: 0.4rem; }
.transcript-hits__title { font-weight: 600; }
.transcript-hits__meta { font-size: 0.85rem; color: var(--text-dim); }
.transcript-hits__matches { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.45rem; }
.transcript-hits__match { display: grid; grid-template-columns: 3.6rem minmax(0, 1fr); gap: 0.6rem; align-items: baseline; }
.transcript-hits__at {
    font-variant-numeric: tabular-nums;
    font-size: 0.82rem;
    color: var(--text-dim);
    text-align: right;
}
.transcript-hits__at:hover { color: var(--text); }
.transcript-hits__snippet { line-height: 1.5; }
.transcript-hits__snippet mark { background: hsl(from var(--accent, #6ea8fe) h s l / 25%); color: inherit; border-radius: 2px; padding: 0 0.1em; }
.transcript-hits__speaker { font-weight: 600; color: var(--accent, var(--text)); margin-right: 0.35rem; }
.transcript-hits__more { font-size: 0.82rem; padding-left: 4.2rem; }
.reveal-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.82rem;
    color: var(--text-dim);
    cursor: pointer;
    margin: 0 0 0.6rem;
    flex: 0 0 auto;
}
.reveal-toggle input { cursor: pointer; }
/* An intermission whose commentary hasn't been reached keeps the plain label it had before the
   fold: the note is the row's headline, so it is swapped out rather than blanked. */
html.ss-reveal .track-list__row--intermission.is-unheard .track-list__title { display: none; }
html.ss-reveal .track-list__row--intermission.is-unheard .track-list__sub {
    color: var(--text);
    font-size: inherit;
}

/* ---- Chat replay (show pages with an imported broadcast chat log) ---- */
.chat-replay__tabs { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0 0 0.6rem; }
.chat-replay__tab {
    padding: 0.25rem 0.75rem;
    border: 1px solid var(--border-light);
    border-radius: 999px;
    background: none;
    color: var(--text-dim);
    font: inherit;
    font-size: 0.82rem;
    cursor: pointer;
}
.chat-replay__tab:hover { color: var(--text); }
.chat-replay__tab.is-active { color: var(--text); border-color: var(--accent-strong); }
.chat-replay__log {
    max-height: 380px;
    overflow-y: auto;
    font-size: 0.88rem;
    overflow-anchor: none; /* the player controls tail-follow; browser anchoring fights it */
}
.chat-replay__row { display: flex; gap: 0.55rem; align-items: baseline; padding: 0.12rem 0; }
.chat-replay__time { color: var(--text-dim); font-size: 0.72rem; font-variant-numeric: tabular-nums; flex: none; min-width: 3.6rem; text-align: right; }
.chat-replay__author { flex: none; font-weight: 600; max-width: 11rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chat-replay__text { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
/* Joins/parts/etc — present for completeness, visually receded. */
.chat-replay__row--event { color: var(--text-dim); font-style: italic; }
.chat-replay__row--event .chat-replay__author { font-weight: 400; }

/* ---- Live recorded chat (watch page; Zond-recorded Discord/Twitch log, read-only) ----
   Deliberately the SAME widget as the community chat: it reuses .ss-chat/.ss-chat__list wholesale
   (rows, typography, scroll, pin-to-bottom), so only the frame is declared here — mirroring
   .ss-chat--page above. A recorded log should feel like every other chat on the site; the one
   difference is that there is no composer, and none is styled because none exists. */
.live-chat__notice { margin: 0 0 0.6rem; font-size: 0.82rem; }
.ss-chat--recorded {
    height: min(58vh, 520px);
    min-height: 260px;
    border: 1px solid var(--border-light);
    border-radius: 10px;
    background: var(--bg-elev);
    overflow: hidden;
}
.live-chat__empty { margin: 0; padding: 0.6rem 0.75rem; font-size: 0.85rem; }

/* Watch page, player + chat side by side (views/watch.php wraps the two .panel sections in
   .watch-panes ONLY when the stream has a recorded chat — a chat-less stream keeps the plain
   stacked markup). Modelled on .show-panes above, with two deliberate differences:

   - 2fr/1fr, not the equal split .show-panes uses. This pane holds a 16:9 video frame, and an even
     split shrinks it for a rail that only ever needs to be legible; the video stays dominant.
   - the rail sticks rather than matching the player pane's height: the chat is a companion to
     playback, so it should stay in view while you scroll the page rather than becoming its own
     viewport-height scroller.

   minmax(0, …) on both tracks is load-bearing for the same reason it is on .show-panes — a bare
   fr min-sizes to content, and one unbreakable chat line (a long URL, a pasted path) would then
   push the grid wider than the viewport and give the whole page a horizontal scrollbar. */
.watch-panes {
    display: grid;
    grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
    gap: 1.25rem;
    align-items: start;
}
.watch-panes > .live-chat { position: sticky; top: 1rem; }

@media (max-width: 900px) {
    /* Same collapse point and the same minmax(0,1fr) single column as .show-panes. */
    .watch-panes { grid-template-columns: minmax(0, 1fr); }
    .watch-panes > .live-chat { position: static; }
}

/* Theatre mode WITH a chat rail: widen the whole pane GROUP, not the frame on its own.

   html.ss-theatre .video-frame above breaks the frame out with margin-left:50% + translateX —
   inside this grid that centres it on its own TRACK rather than on the viewport, so it lands
   half a track to the left and overlaps the rail. That is why theatre used to stack the two
   panes; but a live chat pushed below a viewport-tall video is a chat nobody reads, which is the
   same reason .watch-panes exists at all. So neutralise the frame's breakout here and break the
   grid out instead: the frame simply fills a much wider track, and the chat keeps its place
   beside it.

   Width = what a 16:9 frame needs to fill the viewport height (the same
   100vh − player − chrome term the frame's own rule uses), plus the rail and the gap, capped at
   the viewport. The 48vw floor keeps the height term from collapsing the frame on short
   viewports, where it goes negative — same guard, lower than the frame-alone rule's 60vw
   because the rail is taking a slice of the width here.

   Full-bleed via margin-left, NOT translateX: a transformed ancestor becomes the containing
   block for its descendants, and the rail below is position:sticky. */
@media (min-width: 901px) {
    html.ss-theatre-wide .watch-panes {
        --theatre-chat-w: 22rem;
        --theatre-gap: 1.25rem; /* keep in step with .watch-panes gap */
        --theatre-video-w: max(48vw, calc((100vh - var(--player-h) - 200px) * 16 / 9));
        --theatre-w: min(
            calc(100vw - 3rem),
            calc(var(--theatre-video-w) + var(--theatre-chat-w) + var(--theatre-gap))
        );
        width: var(--theatre-w);
        margin-left: calc(50% - var(--theatre-w) / 2);
        grid-template-columns: minmax(0, 1fr) var(--theatre-chat-w);
    }
    html.ss-theatre-wide .watch-panes .video-frame {
        width: auto;
        max-width: 100%;
        margin-left: 0;
        transform: none;
    }
    /* Match the rail to the frame it now sits beside — a 520px box against a viewport-tall video
       reads as a stub. Same viewport term, less this pane's own title + notice. */
    html.ss-theatre-wide .watch-panes .ss-chat--recorded {
        height: max(320px, calc(100vh - var(--player-h) - 230px));
    }
}

/* Full theatre (the cycle's third state) is the behaviour theatre always had: the frame alone
   breaks out, so it no longer respects its grid track and would overlap a rail beside it. Stack.
   Below the 900px collapse point the grid is already one column and this is a no-op. */
html.ss-theatre:not(.ss-theatre-wide) .watch-panes {
    grid-template-columns: minmax(0, 1fr);
}
html.ss-theatre:not(.ss-theatre-wide) .watch-panes > .live-chat { position: static; }

/* ---- Archive cards with artwork (video recordings) ---- */
.show-card--media .show-card__link { padding: 0; }
.show-card--media .show-card__body { display: block; padding: 0.75rem 1rem; }
.show-card__thumb {
    display: block;
    width: 100%;
    aspect-ratio: 16 / 9;
    object-fit: cover;
    background: var(--bg-elev-2);
    border-bottom: 1px solid var(--border-light);
}
/* Square logos (guest stations) letterbox instead of cropping. */
.show-card__thumb--logo { object-fit: contain; padding: 0.75rem; box-sizing: border-box; }

/* ---- CMS pages (Manager-authored markdown; see docs/pages.md) ---- */
.cms-page__head h1 { margin-bottom: 0.5rem; }
.md-content { max-width: 72ch; }
.md-content::after { content: ""; display: block; clear: both; } /* contain figure floats */
.md-content a { color: var(--accent); }
.md-content blockquote {
    margin: 1rem 0;
    padding: 0.25rem 1rem;
    border-left: 3px solid var(--border-light);
    color: var(--text-dim);
}
.md-content pre {
    background: var(--bg-elev);
    border: 1px solid var(--border-light);
    border-radius: 6px;
    padding: 0.75rem 1rem;
    overflow-x: auto;
}
.md-content code { background: var(--bg-elev); border-radius: 4px; padding: 0.1em 0.35em; }
.md-content pre code { background: none; padding: 0; }
.md-content table { border-collapse: collapse; margin: 1rem 0; }
.md-content th, .md-content td { border: 1px solid var(--border-light); padding: 0.4rem 0.7rem; }
.md-content th { background: var(--bg-elev); }
.md-content hr { border: 0; border-top: 1px solid var(--border-light); margin: 1.5rem 0; }

/* Figures (standalone images). Alignment classes come from the markdown attribute whitelist. */
.md-figure { margin: 0 0 1rem; }
.md-figure img {
    max-width: 100%;
    height: auto;
    display: block;
    border-radius: 6px;
    cursor: zoom-in;
}
.md-figure figcaption {
    margin-top: 0.35rem;
    font-size: 0.8rem;
    color: var(--text-dim);
    text-align: center;
}
.md-figure--left { float: left; margin: 0.25rem 1.25rem 0.75rem 0; }
.md-figure--right { float: right; margin: 0.25rem 0 0.75rem 1.25rem; }
.md-figure--center { margin-left: auto; margin-right: auto; max-width: 100%; }
.md-figure--full { width: 100%; }
.md-img-inline { max-height: 1.5em; vertical-align: middle; }
@media (max-width: 600px) {
    /* Floats collapse to centered blocks on narrow screens. */
    .md-figure--left, .md-figure--right { float: none; margin: 0 auto 1rem; width: auto !important; }
}

/* Module placeholders: empty until substituted server-side; unknown names stay visible. */
.md-module-unknown {
    border: 1px dashed var(--border-light);
    border-radius: 6px;
    padding: 0.5rem 0.75rem;
    color: var(--text-dim);
    font-style: italic;
}

/* ---- ::patrons module (CMS pages) ---- */
.patrons__tier { margin-bottom: 0.85rem; }
.patrons__tier-name { margin: 0 0 0.15rem; font-size: 1rem; color: var(--accent); }
.patrons__names { margin: 0; color: var(--text); }
.patrons__anon { color: var(--text-dim); font-style: italic; }
.patrons__thanks { margin-top: 1rem; color: var(--text-dim); }

/* ---- ::fundraiser module (CMS pages) ---- */
.fundraiser__title { margin: 0 0 0.5rem; }
.fundraiser__bar {
    position: relative;
    height: 18px;
    background: var(--bg-elev-2);
    border: 1px solid var(--border-light);
    border-radius: 9px;
    overflow: hidden;
}
.fundraiser__fill {
    height: 100%;
    background: linear-gradient(90deg, var(--accent-strong), var(--accent));
    border-radius: 9px 0 0 9px;
    transition: width 0.6s ease;
}
.fundraiser__marker {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 2px;
    background: var(--text-dim);
    opacity: 0.7;
}
.fundraiser__marker.is-reached { background: var(--text); }
.fundraiser__meta { margin: 0.5rem 0 0; color: var(--text-dim); }
.fundraiser__meta strong { color: var(--text); }
.fundraiser__goals { margin: 0.5rem 0 0; padding-left: 1.25rem; color: var(--text-dim); }
.fundraiser__goals .is-reached { color: var(--accent); }
.fundraiser__heroes { margin: 0.75rem 0 0; }
.fundraiser__heroes-label { color: var(--text-dim); margin-right: 0.25rem; }
.fundraiser__anon { color: var(--text-dim); font-style: italic; }

/* ---- Site footer -------------------------------------------------------------------------
   Sits in normal flow beneath #content and above the fixed player. body already carries
   padding-bottom: calc(var(--player-h) + 1rem), so the page scrolls the footer clear of the
   player instead of parking it underneath — if --player-h or that padding ever changes, check
   the footer is still fully reachable at the bottom of a short page. */
.site-footer {
    max-width: 980px;
    margin: 2.5rem auto 0;
    padding: 2rem 1.5rem 1rem;
    border-top: 1px solid var(--bg-elev);
    color: var(--text-dim);
    font-size: 0.9rem;
}
.site-footer__inner { display: flex; flex-wrap: wrap; gap: 2rem 3rem; align-items: flex-start; }
.site-footer__brand { flex: 1 1 16rem; min-width: 0; }
.site-footer__logo { display: block; height: 1.5rem; width: auto; opacity: 0.85; }
.site-footer__tagline { margin: 0.6rem 0 0; max-width: 28rem; }
.site-footer__nav { display: flex; flex-wrap: wrap; gap: 2rem 3rem; }
.site-footer__col ul { list-style: none; margin: 0.5rem 0 0; padding: 0; }
.site-footer__col li + li { margin-top: 0.35rem; }
.site-footer__heading {
    margin: 0;
    font-size: 0.78rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text);
}
.site-footer a { color: var(--text-dim); text-decoration: none; }
.site-footer a:hover { color: var(--accent-strong); text-decoration: underline; }
.site-footer__legal { margin: 1.75rem 0 0; font-size: 0.8rem; opacity: 0.7; }

@media (max-width: 640px) {
    .site-footer { padding: 1.5rem 1rem 0.5rem; }
    .site-footer__inner { gap: 1.5rem; }
    .site-footer__nav { gap: 1.5rem 2.5rem; }
}

/* Upcoming schedule (partials/upcoming-schedule.php + views/schedule.php + the home-page
   "On air & coming up" panel). Rows are a two-column grid — time rail on the left, content on
   the right — collapsing to a stacked layout on narrow screens. Only live rows are links, so
   `.sched-row` styles a <div> and an <a> identically and the hover affordance is scoped to
   `a.sched-row`; a non-link row must not look clickable. */
.panel__header {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
    flex-wrap: wrap;
}
.panel__more { font-size: 0.9rem; white-space: nowrap; }

.sched-group { margin-bottom: 1.25rem; }
.sched-group:last-child { margin-bottom: 0; }
.sched-group__label {
    margin: 0 0 0.5rem;
    font-size: 0.8rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-dim);
}
.sched-group--live .sched-group__label { color: var(--accent-strong); }

.sched-row {
    display: grid;
    grid-template-columns: 8.5rem 1fr;
    gap: 0.25rem 1rem;
    padding: 0.7rem 0.9rem;
    border: 1px solid var(--border-dark);
    border-left: 3px solid var(--bg-elev-2);
    border-radius: 6px;
    background: var(--bg-elev);
    margin-bottom: 0.4rem;
    color: inherit;
    text-decoration: none;
}
a.sched-row:hover,
a.sched-row:focus-visible { border-color: var(--accent); background: var(--bg-elev-2); }
.sched-row.is-live { border-left-color: var(--accent-strong); }

.sched-row__time {
    display: flex;
    flex-direction: column;
    font-variant-numeric: tabular-nums;
}
.sched-row__time time { font-weight: 600; }
.sched-row__when { font-size: 0.8rem; color: var(--text-dim); }
.sched-row__title { font-weight: 600; color: var(--heading-col); }
.sched-row__meta { font-size: 0.88rem; color: var(--text-dim); }

.sched-live-dot {
    display: inline-block;
    width: 0.55rem;
    height: 0.55rem;
    margin-right: 0.35rem;
    border-radius: 50%;
    background: var(--accent-strong);
    animation: sched-pulse 2s ease-in-out infinite;
}
@keyframes sched-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) { .sched-live-dot { animation: none; } }

.sched-badge {
    display: inline-block;
    margin-left: 0.4rem;
    padding: 0.05rem 0.4rem;
    border-radius: 3px;
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    vertical-align: middle;
}
.sched-badge--event { background: var(--button-primary); color: #dce6ff; }
.sched-badge--video { background: var(--bg-elev-2); color: var(--text-dim); }

/* An event's own shows, nested inside its row. Flattened visually (no card-in-a-card) so the
   event stays the unit and its programme reads as a list under it. */
.sched-nested { list-style: none; margin: 0.6rem 0 0; padding: 0; }
.sched-nested .sched-row {
    background: transparent;
    border: 0;
    border-left: 2px solid var(--border-dark);
    border-radius: 0;
    padding: 0.35rem 0 0.35rem 0.75rem;
    margin-bottom: 0;
}
.sched-nested a.sched-row:hover { background: transparent; border-left-color: var(--accent); }

.sched-more { margin: 0.75rem 0 0; font-size: 0.9rem; }
.sched--compact .sched-row { padding: 0.55rem 0.9rem; }

@media (max-width: 560px) {
    .sched-row { grid-template-columns: 1fr; gap: 0.15rem; }
    .sched-row__time { flex-direction: row; gap: 0.5rem; align-items: baseline; }
}

/* Header About dropdown (layout.php): the Korolev-curated nav pages — same list as the footer's
   About column — hanging off a nav trigger. Pure CSS reveal: :hover for mouse, :focus-within for
   click/keyboard (the button takes focus; Esc or clicking elsewhere blurs it). On the collapsed
   mobile menu the panel renders inline as plain rows instead of floating. */
/* align-items: flex-start keeps the trigger's line box top-aligned exactly like the sibling
   anchors' (kept even now that .site-nav a's border/padding is unconditional — see below — so
   every item's natural height already matches and stretch has nothing to do; flex-start is
   simply the cheaper guarantee that a future change to one item's box can't silently tilt this
   one again). */
.site-nav__drop { position: relative; display: flex; align-items: flex-start; }
.site-nav__drop-trigger {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    padding: 0;
    /* Same reservation as .site-nav a — see that rule's comment. Without it this trigger would be
       4px shorter than its sibling links whenever none of the pages behind it is the active one. */
    padding-bottom: 2px;
    border: 0;
    border-bottom: 2px solid transparent;
    background: none;
    font: inherit;
    color: var(--text-dim);
    cursor: pointer;
}
.site-nav__drop-trigger:hover { color: var(--text); }
.site-nav__drop-trigger.is-active { color: var(--text); border-bottom-color: var(--accent); }
.site-nav__drop-panel {
    display: none;
    position: absolute;
    top: 100%;
    left: 50%;
    transform: translateX(-50%);
    min-width: 11rem;
    padding: 0.4rem 0;
    background: var(--bg-elev);
    border: 1px solid var(--border-light);
    border-radius: 6px;
    box-shadow: 0 8px 22px rgba(0, 0, 0, 0.4);
    z-index: 40;
}
.site-nav__drop:hover .site-nav__drop-panel,
.site-nav__drop:focus-within .site-nav__drop-panel { display: block; }
.site-nav__drop-panel a {
    display: block;
    padding: 0.4rem 1rem;
    color: var(--text-dim);
    white-space: nowrap;
}
.site-nav__drop-panel a:hover { color: var(--text); background: var(--bg-elev-2); text-decoration: none; }

@media (max-width: 640px) {
    /* Inside the collapsed hamburger menu (same breakpoint as .site-nav-toggle) the dropdown
       flattens to inline rows. */
    .site-nav__drop { flex-direction: column; }
    .site-nav__drop-panel {
        display: block;
        position: static;
        transform: none;
        min-width: 0;
        border: 0;
        box-shadow: none;
        background: none;
        padding: 0 0 0 1rem;
    }
    .site-nav__drop-trigger { padding: 0.4rem 0; }
}

/* The Korolev-authored front page (views/frontpage.php): the body IS the page, so give its
   markdown children the same rhythm the built-in sections had, and let the module/component
   panels keep their own spacing. */
/* Regular CMS pages clamp .md-content to 72ch for readability; the front page is a full-width
   layout (panels, card grids), so the clamp moves off the container onto its prose children —
   bare paragraphs, headings, and panel text stay readable while grids span the content area. */
.md-content--frontpage { max-width: none; }
.md-content--frontpage > p,
.md-content--frontpage > h2,
.md-content--frontpage > h3,
.md-content--frontpage .md-panel p { max-width: 72ch; }
.md-panel { margin: 1.5rem 0; padding: 1rem 1.25rem; background: var(--bg-elev); border: 1px solid var(--border-dark); border-radius: 8px; }
.md-panel .panel__title { margin-top: 0; }

/* Authored heroes (:::hero in CMS markdown) can't carry the .hero__lead class, so the first
   paragraph gets the lead treatment structurally — matching the built-in hero's rhythm. */
.md-hero > p:first-of-type { font-size: 1.4rem; color: var(--text); max-width: 60ch; }

/* Section rhythm on the authored front page: module grids arrive bare (no .panel wrapper to
   space them the way the built-in home sections had), so the grid itself carries the bottom
   margin and the section headings breathe above. */
.md-content--frontpage .show-grid { margin-bottom: 2.5rem; }
.md-content--frontpage > h2 { margin-top: 2.25rem; margin-bottom: 0.75rem; }
