/* Vibe Vault FM — stylesheet.
   Split out of index.html. Freshness is handled by version.txt via the
   service worker, plus a no-cache revalidation rule in .htaccess — this file
   deliberately carries no "?v=" query string. */

:root{
  --base: #0a0b0d;
  --ink: #f4f5f7;
  --muted: rgba(244,245,247,0.56);
  --faint: rgba(244,245,247,0.34);

  /* Accent is rewritten at runtime from the dominant color of the current
     album artwork — but only while the stream is actually audible. Before
     playback starts, after it stops, and for any track whose artwork yields
     no usable hue, the page falls back to these values: the gold sampled
     from the wordmark, matching the logo the art card is resting on in
     exactly those states. Also what the first paint uses, before app.js has
     run. Keep in sync with DEFAULT_ACCENT there. */
  --accent: rgb(249,182,60);
  --accent-rgb: 249,182,60;

  --glass: rgba(255,255,255,0.055);
  --glass-line: rgba(255,255,255,0.10);
  --radius: 20px;

  /* =====================================================================
     LIQUID GLASS SYSTEM
     Every panel on the page is built from the same four ingredients, so
     they read as one material rather than a pile of blurred boxes:

       1. FILL   — a directional gradient, brightest at the top-left, that
                   fakes a thin sheet catching light from above.
       2. TINT   — a wash of the current album art's dominant colour. This
                   is what makes the glass feel like it's actually in front
                   of the photo instead of sitting on top of it.
       3. BLUR   — backdrop-filter, with saturation and brightness pushed
                   up. Blur alone reads grey and dead; the saturate() is
                   what makes it look like real frosted glass.
       4. EDGE   — a 1px gradient rim (bright at the top, dim at the
                   bottom) plus inset highlights. This is the single
                   biggest tell — flat borders read as "div with opacity",
                   gradient rims read as a physical edge.

     The tint tokens reference --accent-rgb, which app.js rewrites from the
     artwork on every track change, so the whole UI re-tints with the music.
     ===================================================================== */

  /* Blur strengths. Small controls get less — heavy blur on a 26px pill
     just smears, and costs the same to composite as it does on a panel. */
  --blur-sm: blur(16px) saturate(180%) brightness(1.06);
  --blur-md: blur(30px) saturate(190%) brightness(1.08);
  --blur-lg: blur(44px) saturate(200%) brightness(1.06);

  /* Directional fills. 158deg puts the highlight at the top-left corner,
     matching the light direction implied by the drop shadows. */
  --fill-thin: linear-gradient(158deg,
                 rgba(255,255,255,0.16) 0%,
                 rgba(255,255,255,0.06) 44%,
                 rgba(255,255,255,0.025) 100%);
  --fill-panel: linear-gradient(158deg,
                 rgba(255,255,255,0.13) 0%,
                 rgba(255,255,255,0.05) 40%,
                 rgba(255,255,255,0.02) 100%);
  --fill-card: linear-gradient(158deg,
                 rgba(255,255,255,0.14) 0%,
                 rgba(255,255,255,0.055) 38%,
                 rgba(255,255,255,0.03) 100%);

  /* Accent wash. Deliberately weak — past about 0.10 the glass stops
     looking like glass and starts looking like coloured plastic. */
  --tint: linear-gradient(180deg,
            rgba(var(--accent-rgb),0.10) 0%,
            rgba(var(--accent-rgb),0.03) 100%);
  --tint-strong: linear-gradient(180deg,
            rgba(var(--accent-rgb),0.16) 0%,
            rgba(var(--accent-rgb),0.05) 100%);

  /* Base colour under the glass. Keeps panels legible when the artwork
     behind them happens to be blown-out white. */
  --glass-base: rgba(14,16,20,0.34);
  --glass-base-card: rgba(12,14,18,0.5);

  /* The gradient rim, drawn by the .glass-edge pseudo rule further down.
     Bright top edge → nearly nothing at the bottom, like a real bevel. */
  --rim: linear-gradient(160deg,
           rgba(255,255,255,0.55) 0%,
           rgba(255,255,255,0.16) 26%,
           rgba(255,255,255,0.05) 52%,
           rgba(var(--accent-rgb),0.22) 82%,
           rgba(255,255,255,0.14) 100%);

  /* Stronger version of --rim, for the art card only. That card sits over
     album covers that can be as bright and busy as the UI panels this rim
     was tuned for are dark and calm — the same opacities that read as a
     crisp edge on a frosted panel disappear entirely against a light,
     high-contrast photo. Card-specific rather than raising --rim itself, so
     the rest of the glass system (which looks right as-is) is untouched. */
  --rim-card: linear-gradient(160deg,
           rgba(255,255,255,0.88) 0%,
           rgba(255,255,255,0.39) 24%,
           rgba(255,255,255,0.14) 50%,
           rgba(var(--accent-rgb),0.49) 80%,
           rgba(255,255,255,0.32) 100%);

  /* Inset highlights: a hot line along the top inner edge and a faint one
     underneath, which together give the sheet apparent thickness. */
  --inner: inset 0 1px 0 rgba(255,255,255,0.26),
           inset 0 -1px 0 rgba(255,255,255,0.055);
  --inner-soft: inset 0 1px 0 rgba(255,255,255,0.18);

  /* Drop shadows. Two layers — a wide soft one for lift, a tight dark one
     to anchor the element so it doesn't float free of the background. */
  --lift: 0 26px 54px -20px rgba(0,0,0,0.78), 0 3px 10px -4px rgba(0,0,0,0.5);
  --lift-lg: 0 44px 90px -28px rgba(0,0,0,0.88), 0 6px 18px -8px rgba(0,0,0,0.55);

  /* Very fine grain, tiled. Real frosted glass is never perfectly smooth,
     and without this large panels betray themselves as flat gradients on
     high-DPI screens. Kept at a low opacity — visible as texture, not noise. */
  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.3'/%3E%3C/svg%3E");
}

*{ box-sizing:border-box; margin:0; padding:0; }

/* ---------- APP SHELL ----------
   The document itself never scrolls. Only the track list does, and it
   contains its own overscroll — so there's no rubber-banding, no
   pull-to-refresh, and no chance of the whole page sliding under a thumb. */
html, body{
  height: 100%;
  overflow: hidden;
  overscroll-behavior: none;
  -webkit-text-size-adjust: 100%;
}

body{
  font-family:'Inter', system-ui, -apple-system, sans-serif;
  background: var(--base);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;

  /* Native-app input feel: nothing is selectable, long-press doesn't raise
     the iOS callout, taps don't flash a grey box, and touch-action kills the
     double-tap-to-zoom delay while leaving pinch-to-zoom available. */
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}

img{
  -webkit-user-drag: none;
  user-drag: none;
  pointer-events: none;
}

/* =========================================================
   FULL-BLEED BACKGROUND — the artist photo IS the page.
   Two layers so one can fade in behind the other on change.
   ========================================================= */
.bg-layer{
  position: fixed;
  inset: -6%;
  z-index: 0;
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
  opacity: 0;
  /* Meant to be looked at, not just a wallpaper — the scrim below handles text
     legibility instead of crushing the image. Blur is deliberately light enough
     that the portrait still reads as a person; much past this it competes with
     the album art, much under it and the upscaling artifacts start showing. */
  filter: blur(14px) saturate(1.3) brightness(0.64);
  transform: scale(1.06);
  transition: opacity 2.4s ease-in-out, background-position 1.6s ease-in-out;
  pointer-events: none;
  will-change: opacity, transform;
}
.bg-layer.active{ opacity: 1; }

/* Artist headshots/portraits are tall and face-forward — "cover" fit on a
   wide viewport zooms in and crops vertically, and a plain center anchor
   lands on the chest/neck instead of the face. Bias the crop upward so the
   face stays in frame. Square album covers (the fallback when no portrait
   is found) have no such bias and keep the default center anchor. */
.bg-layer.bg-portrait{ background-position: center 30%; }

/* Five slow Ken Burns variants, picked at random per track change. */
.bg-anim-0{ animation: kb-drift 64s ease-in-out infinite alternate; }
.bg-anim-1{ animation: kb-zoom 58s ease-in-out infinite alternate; }
.bg-anim-2{ animation: kb-sweep 72s ease-in-out infinite alternate; }
.bg-anim-3{ animation: kb-orbit 80s ease-in-out infinite alternate; }
.bg-anim-4{ animation: kb-rise 68s ease-in-out infinite alternate; }

@keyframes kb-drift{
  0%{ transform: scale(1.05) translate(0,0); }
  100%{ transform: scale(1.16) translate(-2.5%, -2%); }
}
@keyframes kb-zoom{
  0%{ transform: scale(1.04); }
  100%{ transform: scale(1.20); }
}
@keyframes kb-sweep{
  0%{ transform: scale(1.12) translateX(-3%); }
  100%{ transform: scale(1.12) translateX(3%); }
}
@keyframes kb-orbit{
  0%{ transform: scale(1.08) translate(2%, 1.5%); }
  50%{ transform: scale(1.15) translate(-1.5%, -1%); }
  100%{ transform: scale(1.08) translate(1%, -2%); }
}
@keyframes kb-rise{
  0%{ transform: scale(1.06) translateY(3%); }
  100%{ transform: scale(1.18) translateY(-3%); }
}

/* Legibility scrim + vignette. Sits above the photo, below everything else. */
.scrim{
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background:
    linear-gradient(180deg,
      rgba(10,11,13,0.72) 0%,
      rgba(10,11,13,0.30) 30%,
      rgba(10,11,13,0.42) 62%,
      rgba(10,11,13,0.88) 100%),
    radial-gradient(120% 100% at 50% 50%, transparent 25%, rgba(10,11,13,0.62) 100%);
}

/* Idle state (nothing loaded yet) gets a soft accent wash so the page is
   never a flat black rectangle. */
.scrim::after{
  content:'';
  position:absolute; inset:0;
  background: radial-gradient(80% 60% at 50% 110%, rgba(var(--accent-rgb),0.14), transparent 70%);
  transition: background 1.2s ease;
}

/* ============================ SHELL ============================ */
.app{
  position: relative;
  z-index: 2;
  height: 100vh;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  padding-top: env(safe-area-inset-top, 0px);
  padding-bottom: env(safe-area-inset-bottom, 0px);
}

.topbar{
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 18px clamp(16px, 4vw, 40px);
  padding-left: max(clamp(16px, 4vw, 40px), env(safe-area-inset-left, 0px));
  padding-right: max(clamp(16px, 4vw, 40px), env(safe-area-inset-right, 0px));
  flex: 0 0 auto;
}

/* ---------- LOGO ----------
   Pure type. The wordmark and the tagline are tracked to land on roughly the
   same width, so the two lines stack as one justified block rather than a
   heading with a caption under it. FM keeps the accent fill, which ties the
   lockup to the play button and re-tints with whatever is playing.
   It's a <button> now (opens the site menu) rather than a link, so the usual
   button chrome — background, border, padding, font — is stripped back to
   how the anchor used to look. */
.brand{
  display: flex; align-items: center;
  text-decoration: none; color: var(--ink);
  background: none; border: none; padding: 0; margin: 0;
  font: inherit; text-align: left; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.brand-text{
  display: flex; flex-direction: column; gap: 6px; line-height: 1;
  text-shadow: 0 2px 12px rgba(0,0,0,0.5);
}
.brand-name{
  display: flex; align-items: center; gap: 7px;
  font-size: 22px; font-weight: 800; letter-spacing: 0.04em;
  text-transform: uppercase; white-space: nowrap;
}
/* The one solid-accent chip in the header. It gets a gloss overlay rather
   than a glass fill — a badge this small would lose all legibility if the
   artwork showed through it, so it stays opaque and just picks up a
   highlight along its top edge to sit in the same light as everything else. */
.brand-fm{
  position: relative;
  font-size: 11px; font-weight: 800; letter-spacing: 0.08em;
  color: #0a0b0d;
  background: var(--accent);
  background-image: linear-gradient(170deg, rgba(255,255,255,0.55), rgba(255,255,255,0) 58%);
  border-radius: 6px; padding: 4px 5px 3px;
  text-shadow: none;
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.6),
    inset 0 -1px 0 rgba(0,0,0,0.14),
    0 3px 10px -3px rgba(var(--accent-rgb),0.55);
  transition: background-color 1.2s ease, box-shadow 1.2s ease;
}
.brand-tag{
  font-family:'Space Mono', monospace;
  font-size: 10px; letter-spacing: 0.5em; text-transform: uppercase;
  color: rgba(244,245,247,0.5); white-space: nowrap;
}

/* ---------- LOGO MENU ----------
   Small caret beside the wordmark opens a glass dropdown (Email Us / FAQ).
   The logo link itself is untouched — still just a link to "/". */
.brand-wrap{
  position: relative;
  display: flex; align-items: center; gap: 4px;
}

/* Starts as a bare glyph and only materialises into glass once it's hovered
   or open — a permanent glass pill next to the wordmark competes with it. */
.menu-toggle{
  position: relative;
  display: flex; align-items: center; justify-content: center;
  width: 26px; height: 26px; border-radius: 50%;
  background: transparent; border: none; color: var(--muted);
  cursor: pointer; flex: 0 0 auto;
  transition: color 0.2s ease, background 0.25s ease, box-shadow 0.25s ease;
  -webkit-tap-highlight-color: transparent;
}
.menu-toggle:hover,
.menu-toggle[aria-expanded="true"]{
  color: var(--ink);
  background-color: var(--glass-base);
  background-image: var(--fill-thin), var(--tint);
  backdrop-filter: var(--blur-sm);
  -webkit-backdrop-filter: var(--blur-sm);
  box-shadow: var(--inner-soft), 0 4px 12px -4px rgba(0,0,0,0.6);
}
.menu-toggle i{ font-size: 0.65rem; transition: transform 0.25s ease; }
.menu-toggle[aria-expanded="true"] i{ transform: rotate(180deg); }

.site-menu{
  position: absolute;
  top: calc(100% + 12px);
  left: 0;
  min-width: 190px;
  background-color: rgba(12,14,18,0.42);
  background-image: var(--fill-card), var(--tint);
  border: none;
  border-radius: 18px;
  backdrop-filter: var(--blur-lg);
  -webkit-backdrop-filter: var(--blur-lg);
  box-shadow: var(--inner), var(--lift);
  padding: 7px;
  display: flex; flex-direction: column; gap: 2px;
  z-index: 60;
  /* Scaling from the caret it hangs off, so it unfolds out of the control
     that opened it rather than just appearing near it. */
  transform-origin: top left;
  opacity: 0; visibility: hidden; transform: translateY(-8px) scale(0.94);
  transition: opacity 0.22s ease,
              transform 0.34s cubic-bezier(0.22,1.4,0.36,1),
              visibility 0.22s;
}
.site-menu.open{ opacity: 1; visibility: visible; transform: none; }

/* .site-menu-item below sets display:flex unconditionally, which — at equal
   (0,1,0) specificity — would beat the browser's own [hidden]{display:none}
   UA-stylesheet rule on source order alone and leave "Install App"
   visible before app.js has decided whether there's anything to install.
   #menu-install-link's ID selector outranks both, so [hidden] actually hides
   it. */
#menu-install-link[hidden]{ display: none; }
.site-menu-item{
  position: relative;
  display: flex; align-items: center; gap: 11px;
  width: 100%; padding: 10px 12px; border-radius: 12px;
  background: none; border: none;
  color: var(--ink); font-family: 'Inter', system-ui, sans-serif;
  font-size: 0.82rem; font-weight: 500; text-decoration: none;
  text-align: left; cursor: pointer;
  transition: background 0.18s ease, box-shadow 0.18s ease;
  -webkit-tap-highlight-color: transparent;
  overflow: hidden;
}
/* Hover is its own little pane of glass inside the bigger one, tinted with
   the accent so the highlight belongs to the current track. */
.site-menu-item:hover{
  background-image: var(--fill-thin), var(--tint-strong);
  box-shadow: var(--inner-soft);
}
.site-menu-item:active{ background-image: var(--fill-thin), var(--tint); }
.site-menu-item:focus-visible{ outline: 2px solid rgba(var(--accent-rgb),0.7); outline-offset: -2px; }
.site-menu-item i{
  width: 16px; text-align: center; font-size: 0.85rem;
  color: rgba(var(--accent-rgb),0.85);
  transition: color 1.2s ease;
}
/* Install App's icon is the actual app icon (apple-touch-icon.png) rather
   than a font glyph — it's already a flat-colour circular badge on a
   transparent square, so it just needs sizing to match the FA glyphs
   flanking it, not any tinting or masking of its own. */
.site-menu-item-icon{
  width: 16px; height: 16px; flex: 0 0 auto;
  object-fit: contain;
}
/* Small disclosure caret on the Settings row only, pinned to the far edge
   (the item's own flex layout would otherwise leave it hugging the label
   text). Rotates to point down once the submenu beneath it is open, same
   language as .menu-toggle's own caret for the outer menu. */
.settings-chevron{
  margin-left: auto; width: auto !important; font-size: 0.7rem !important;
  color: var(--muted) !important;
  transition: transform 0.25s ease;
}
#menu-settings-btn[aria-expanded="true"] .settings-chevron{ transform: rotate(90deg); }

/* ---------- SETTINGS SUBMENU ----------
   Expands beneath its own menu item inside the same glass panel rather than
   flying out as a second dropdown — avoids a second panel to position and
   dismiss, and sidesteps the narrow-viewport case where there's no room
   beside the first one anyway.
   grid-template-rows: 0fr -> 1fr (rather than max-height) is what makes the
   collapse/expand animate smoothly to the content's real height without
   guessing a max-height that's either clipped or leaves dead space — the
   inner wrapper's overflow:hidden is what actually keeps the content
   invisible while the row is sized at 0fr. */
.settings-panel{
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}
.settings-panel-inner{
  overflow: hidden;
  min-height: 0;
}
.settings-panel.open{ grid-template-rows: 1fr; }

.settings-row{
  display: flex; align-items: center; justify-content: space-between;
  gap: 14px; padding: 9px 12px;
}
/* Divider between the Settings row and its own submenu, and again between
   the two rows inside it — thin enough to read as a seam in the glass
   rather than a hard rule. */
.settings-panel-inner .settings-row + .settings-row{
  border-top: 1px solid rgba(255,255,255,0.08);
}
.settings-panel-inner{ border-top: 1px solid rgba(255,255,255,0.08); margin-top: 2px; }

.settings-row-label{
  display: flex; align-items: center; gap: 10px;
  font-size: 0.82rem; font-weight: 600; color: var(--ink);
  letter-spacing: 0.02em;
  white-space: nowrap;
}
.settings-row-label i{
  width: 16px; text-align: center; font-size: 0.85rem;
  color: rgba(var(--accent-rgb),0.85);
}

/* Both Glow and Bounce stack label-over-slider rather than sitting beside
   it in a single row — a range input needs real width to be usable, which
   sharing a row with a label wouldn't leave it in a 190px-wide panel. */
.settings-row-slider{
  flex-direction: column; align-items: stretch; gap: 7px;
}

/* Visualizer style picker — same stacked label-over-control shape as the
   sliders above, for the same reason (no room to sit side-by-side in a
   190px panel). Native <select>, restyled to match the glass system rather
   than swapped for a custom listbox — this is a 6-item, infrequently-used
   picker, not worth reimplementing keyboard nav and a11y for. */
.settings-row-select{
  flex-direction: column; align-items: stretch; gap: 7px;
}
.settings-select{
  -webkit-appearance: none; -moz-appearance: none; appearance: none;
  width: 100%;
  font: inherit; font-size: 0.8rem; font-weight: 600; color: var(--ink);
  padding: 8px 30px 8px 11px;
  border-radius: 10px; border: none; outline: none; cursor: pointer;
  background-color: rgba(0,0,0,0.24);
  background-image:
    linear-gradient(rgba(255,255,255,0.09), rgba(255,255,255,0.02)),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%23f4f5f7' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: 0 0, right 9px center;
  background-size: 100% 100%, 12px 12px;
  box-shadow: inset 0 1px 1px rgba(0,0,0,0.35), 0 1px 0 rgba(255,255,255,0.10);
  transition: box-shadow 0.15s ease;
}
.settings-select:hover{ box-shadow: inset 0 1px 1px rgba(0,0,0,0.35), 0 1px 0 rgba(255,255,255,0.10), 0 0 0 1px rgba(var(--accent-rgb),0.35); }
.settings-select:focus-visible{ box-shadow: 0 0 0 2px rgba(var(--accent-rgb),0.65); }
.settings-select option{
  background-color: #14161a; color: var(--ink);
}

.settings-slider-scale{
  display: flex; justify-content: space-between;
  font-family:'Space Mono', monospace; font-size: 0.56rem; letter-spacing: 0.04em;
  text-transform: uppercase; color: var(--faint);
  padding: 0 1px;
}

/* Same glass-groove-with-glowing-fill language as .vol's volume slider
   further down, just driven by --range-pct (set per-slider in app.js)
   instead of --vol-pct, and stepped to 4 discrete positions (Off/Low/Med/
   High) instead of continuous. Shared by both #art-glow-slider and
   #art-bounce-slider — one class rather than duplicating the whole block
   per slider, since they're visually identical dials with different
   targets. */
.settings-range{
  -webkit-appearance: none; -moz-appearance: none; appearance: none;
  width: 100%; height: 24px;
  background: transparent; outline: none; border: none;
  touch-action: none; cursor: pointer;
}
.settings-range::-webkit-slider-runnable-track{
  height: 5px; border-radius: 99px;
  background-image:
    linear-gradient(rgba(var(--accent-rgb),1), rgba(var(--accent-rgb),0.82)),
    linear-gradient(rgba(0,0,0,0.32), rgba(255,255,255,0.10));
  background-size: var(--range-pct, 66%) 100%, 100% 100%;
  background-repeat: no-repeat;
  border: none;
  box-shadow: inset 0 1px 1px rgba(0,0,0,0.35), 0 1px 0 rgba(255,255,255,0.10);
}
.settings-range::-moz-range-track{
  height: 5px; border-radius: 99px;
  background-image:
    linear-gradient(rgba(var(--accent-rgb),1), rgba(var(--accent-rgb),0.82)),
    linear-gradient(rgba(0,0,0,0.32), rgba(255,255,255,0.10));
  background-size: var(--range-pct, 66%) 100%, 100% 100%;
  background-repeat: no-repeat;
  border: none;
  box-shadow: inset 0 1px 1px rgba(0,0,0,0.35), 0 1px 0 rgba(255,255,255,0.10);
}
.settings-range::-webkit-slider-thumb{
  -webkit-appearance: none;
  width: 15px; height: 15px; border-radius: 50%;
  background: radial-gradient(circle at 34% 28%, #fff 0%, #f2f3f6 46%, #cfd2da 100%);
  border: none;
  margin-top: -5px;
  box-shadow:
    0 1px 3px rgba(0,0,0,0.55),
    0 3px 10px -2px rgba(0,0,0,0.5),
    0 0 12px -2px rgba(var(--accent-rgb),0.65),
    inset 0 -1px 1px rgba(0,0,0,0.18);
  cursor: pointer;
  transition: transform 0.15s ease;
}
.settings-range::-moz-range-thumb{
  width: 15px; height: 15px; border-radius: 50%;
  background: radial-gradient(circle at 34% 28%, #fff 0%, #f2f3f6 46%, #cfd2da 100%);
  border: none;
  box-shadow:
    0 1px 3px rgba(0,0,0,0.55),
    0 3px 10px -2px rgba(0,0,0,0.5),
    0 0 12px -2px rgba(var(--accent-rgb),0.65);
  cursor: pointer;
}
.settings-range:active::-webkit-slider-thumb{ transform: scale(1.15); }
.settings-range:focus-visible::-webkit-slider-thumb{ box-shadow: 0 0 0 3px rgba(var(--accent-rgb),0.55); }

.live{
  position: relative;
  display: inline-flex; align-items: center; gap: 7px;
  font-family:'Space Mono', monospace;
  font-size: 0.6rem; letter-spacing: 0.2em; text-transform: uppercase;
  color: rgba(244,245,247,0.72);
  padding: 7px 13px; border-radius: 99px;
  background-color: var(--glass-base);
  background-image: var(--fill-thin), var(--tint);
  border: none;
  backdrop-filter: var(--blur-md);
  -webkit-backdrop-filter: var(--blur-md);
  box-shadow: var(--inner-soft), 0 6px 18px -8px rgba(0,0,0,0.7);
  white-space: nowrap;
}
.live .dot{
  width: 6px; height: 6px; border-radius: 50%;
  background: #ff4b4b;
  animation: livePulse 2.4s ease-out infinite;
}
@keyframes livePulse{
  0%{ box-shadow: 0 0 0 0 rgba(255,75,75,0.55); }
  70%{ box-shadow: 0 0 0 8px rgba(255,75,75,0); }
  100%{ box-shadow: 0 0 0 0 rgba(255,75,75,0); }
}
/* Feed unreachable: the dot desaturates and stops pulsing, but the label still
   reads "Live" on purpose — a dead metadata feed is not proof the stream
   stopped, and claiming otherwise would be worse than saying nothing. */
.live.stale{ opacity: 0.55; }
.live.stale .dot{ background: var(--faint); animation: none; }

.topbar-actions{ margin-left: auto; display: flex; align-items: center; gap: 10px; }

/* ============================ STAGE ============================ */
.stage{
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(22px, 3.5dvh, 40px);
  padding: 0 clamp(16px, 4vw, 40px) clamp(20px, 3dvh, 40px);
  padding-left: max(clamp(16px, 4vw, 40px), env(safe-area-inset-left, 0px));
  padding-right: max(clamp(16px, 4vw, 40px), env(safe-area-inset-right, 0px));
  min-height: 0;
}

.now{
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(20px, 3.5dvh, 34px);
  text-align: center;
  width: 100%;
  min-width: 0;
}

/* ---------- ARTWORK ---------- */
/* .art is now just the scene: it holds the size, the perspective, and the
   card's shadow on the page. Everything you actually look at lives on the
   two faces inside it. */
.art{
  position: relative;
  /* Negative so it stacks behind the rest of .app's content (header, menus,
     transport controls, drawer — all non-positioned, so they paint above any
     negative-z-index box regardless of DOM order). aspect-ratio:1/1 with
     flex:0 0 auto means this box doesn't shrink to fit a squeezed viewport,
     so on short/narrow screens it can end up overlapping its siblings; when
     that happens it should lose, not cover the play button or menu. */
  z-index: -1;
  width: min(100%, 44dvh, 400px);
  aspect-ratio: 1 / 1;
  flex: 0 0 auto;
  border-radius: 26px;

  /* Distance from viewer to the card. Lower numbers exaggerate the turn —
     the near edge lunges forward and the far edge races away. This is set
     wide enough to read as a physical object being turned rather than a
     fisheye effect, but close enough that you can tell it's 3D at all. */
  perspective: 1600px;

  /* .art itself carries a transform (the bass-scale pulse below), which
     makes IT a 3D-transformed element too, not just a passive perspective
     parent — and transform-style defaults to flat. Chrome/Safari still
     preserve .art-flipper's own nested rotateY through that default;
     Firefox takes "flat" literally and collapses the flipper's 3D rotation
     into .art's own 2D plane before applying it, which looks exactly like
     a mirrored flip: at rotateY(180deg) you see the SAME face, horizontally
     flipped, instead of backface-visibility ever hiding it to reveal the
     other one. Declaring preserve-3d here too — not just on .art-flipper —
     is what keeps every level of this chain in one real 3D space in
     Firefox instead of only Chrome/Safari's more forgiving implicit one. */
  transform-style: preserve-3d;

  /* Speaker-cone pulse. --bass-scale is written every frame in app.js off
     the same bass pop value that drives --bass-glow on .art-flipper::before
     (see updateArtGlow) — one beat-matched signal, two effects: the glow
     blooms and the whole card punches outward, like a woofer diaphragm on a
     hit. Lives here rather than on .art-flipper on purpose: the flipper's
     own `transform` is claimed by the 180/360 flip on a slow 1.05s
     easing, so a fast per-frame scale on that element would mean the two
     motions fighting over the same property. .art has no transform of its
     own, so the pulse gets a clean, independent transition — the card can
     be mid-flip and mid-pulse at the same time without either one winning. */
  transform: translateZ(0) scale(var(--bass-scale, 1));
  transition: transform 0.11s cubic-bezier(0.22, 0.85, 0.32, 1);
  will-change: transform;

  /* translateZ(0) forces this onto its own GPU layer instead of letting the
     browser rasterize the card fresh at every intermediate scale value.
     Without it, the rounded-corner clip on .art-clip and the masked rim on
     .art-face::before were both being resampled against a new geometry on
     every frame the pulse moved through — the mask/clip edges are exactly
     where floating-point scale factors show up as visible shimmer/aliasing.
     Promoting the layer up front means the browser scales one already-drawn
     bitmap instead of redrawing edges 60 times a second. backface-visibility
     is the matching Safari-specific nudge for the same problem. */
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  -moz-backface-visibility: hidden;
}

/* The thing that actually rotates. app.js writes an ever-increasing angle
   into --flip rather than toggling between 0 and 180, so the card always
   turns the same way — a card being turned over, not a hinge rocking back
   and forth. The angle mod 360 is the whole state: 0 is the logo facing
   you, 180 is the cover.
   Turns come in two sizes. A half turn (180) switches which face you're
   looking at — logo to cover when playback starts, cover to logo when it
   stops. A full turn (360) is a track change: it leaves you on the cover
   side you started on, having passed the logo on the way, with new art
   waiting when you come back round. app.js sets the duration per turn, so
   a full turn doesn't spin at double speed.
   The easing is asymmetric on purpose: slow to break away, quick through
   the edge-on middle (where there's nothing to look at), then settling
   into the new face. A symmetric ease makes the card look weightless. */
.art-flipper{
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform: rotateY(var(--flip, 0deg));
  transition: transform 1.05s cubic-bezier(0.62, 0.02, 0.24, 1);
  will-change: transform;
}

/* Bass-reactive glow. This lives here, on the element that actually turns,
   rather than on the static .art wrapper — a glow parented to .art sat as a
   flat rectangle behind the card the whole time, visibly detached from the
   card's own edge the instant it turned into perspective mid-flip. As a
   child of .art-flipper this inherits the exact same rotateY the card is
   mid-turn, no transform of its own needed — the card's edge and the glow's
   edge stay the same edge, in perspective, throughout the turn.
   This still isn't a box-shadow written every frame, which is what was
   actually causing an unrelated ghosting artifact before (see git history /
   prior discussion): the shadow's shape/size/color is static, pre-baked
   right here. app.js only ever touches --bass-glow, a plain 0–1 opacity,
   which the compositor can animate on this element's own layer without
   forcing a repaint of the shadow geometry itself, transform or no transform. */
.art-flipper::before{
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 26px;
  /* Layered to read as a neon tube laid along the card's own edge rather
     than a halo glowing from behind it: a hot near-white filament right at
     the boundary, a tight saturated core just outside it, a soft mid bloom,
     the original wide ambient spread (toned down so it colors the air
     around the card without washing the ring itself out), and an inset
     layer that throws the same light back onto the inside edge — a tube has
     two sides, and without this the ring only ever lit the outside of the
     card. All of it traces this element's own rounded-rect edge (inset:0,
     matching border-radius), which is what makes it hug the artwork's
     silhouette instead of sitting behind it as a blob. */
  box-shadow:
    0 0 3px 1px rgba(255, 255, 255, 0.95),
    0 0 14px 2px rgba(var(--accent-rgb), 1),
    0 0 42px 6px rgba(var(--accent-rgb), 0.85),
    0 0 120px rgba(var(--accent-rgb), 0.7),
    inset 0 0 16px 1px rgba(var(--accent-rgb), 0.55);
  /* Never fully dark — a neon sign is lit at idle and surges brighter on a
     hit, it doesn't switch on and off with each beat. The old opacity:
     var(--bass-glow, 0) sat at 0 between hits, which read as the glow
     turning off rather than a light responding to music.
     --glow-enabled (0 or 1, written once in app.js by the Settings > Glow
     slider, not per-frame) gates that idle baseline too — otherwise
     dragging Glow to Off would still leave this dimly lit instead of
     genuinely off. Independent of --bass-scale/Bounce below, which is set
     directly on .art and has no idle baseline of its own to gate. */
  opacity: calc(var(--glow-enabled, 1) * (0.16 + var(--bass-glow, 0) * 0.84));
  transition: opacity 0.1s ease-out;
  pointer-events: none;
}

.art-face{
  position: absolute;
  inset: 0;
  border-radius: 26px;
  /* Each face is opaque to its own back. Without this you'd see the other
     face's image showing through, mirrored, for the whole rotation. */
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  -moz-backface-visibility: hidden;
  background: rgba(14,16,20,0.9);
  /* Bevel, drop shadow and accent bloom all live on the face rather than on
     the scene. Putting the shadow on .art instead seems tidier — one static
     shadow, no matter what the card does — but it isn't: the faces are
     opaque, so at rest they cover it, and the moment the card turns edge-on
     that shadow is revealed as a rounded rectangle hanging in space behind
     the animation. Riding along with the face means the shadow narrows to a
     line as the card goes edge-on, which is what a real card's shadow does. */
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.30),
    /* Faint inset shadow on the opposite corner from the highlight — a flat
       sheet has light hitting one edge and nothing else; a sheet with actual
       thickness has a little shade pooling where the light doesn't reach.
       This is what tips the read from "glowing panel" to "glass with depth". */
    inset -12px -16px 37px -18px rgba(0,0,0,0.55),
    0 0 0 1px rgba(255,255,255,0.13),
    0 0 0 6px rgba(255,255,255,0.035),
    0 48px 90px -26px rgba(0,0,0,0.88),
    0 0 90px -26px rgba(var(--accent-rgb), 0.60);
  transition: box-shadow 1s ease;
}
/* Crisp edge rim, same masked-gradient technique as .transport/.drawer-container/
   .site-menu/.toast use — see the LIQUID GLASS SYSTEM comment near the top of
   this file. Those panels rely on box-shadow alone for their outer glow but
   still get this rim for the sharp glass-to-content seam; the art card had
   only the softer box-shadow rings, which read as a glow around the card
   rather than an edge where a physical pane of glass actually ends. */
@supports ((-webkit-mask-composite: xor) or (mask-composite: exclude)){
  .art-face::before{
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 3.5px;
    background: var(--rim-card);
    -webkit-mask:
      linear-gradient(#fff 0 0) content-box,
      linear-gradient(#fff 0 0);
    mask:
      linear-gradient(#fff 0 0) content-box,
      linear-gradient(#fff 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
    z-index: 5;
    transition: background 1.2s ease;
  }
}
@supports not ((-webkit-mask-composite: xor) or (mask-composite: exclude)){
  .art-face{ border: 1px solid rgba(255,255,255,0.16); }
}
/* The cover face is pre-rotated, so that at every odd multiple of 180deg
   it's the one squarely facing the viewer — and the logo face, which needs
   no VISUAL transform, is what you get at rest and on every even multiple.
   That's why the card loads showing the logo without any starting rotation. */
.art-face-cover{ transform: rotateY(180deg); }
/* ...but it still gets an explicit identity transform, because Firefox's
   backface-visibility implementation is spec-literal in a way Chrome/Safari
   aren't (see Mozilla bug 1201471): an element with NO transform of its own
   "doesn't participate in the 3D rendering context," so Firefox ignores
   backface-visibility on it entirely and lets its back show through —
   mirrored — instead of hiding it. That's exactly what was showing on
   Firefox: the logo face has no transform anywhere else in this file, so it
   was the one face backface-visibility never actually applied to.
   rotateY(0deg) is visually a no-op but gives Firefox a transform to hang
   the 3D participation on, so backface-visibility finally takes hold here
   too — same fix documented against that bug and others like it (e.g.
   Mozilla bug 1306107). */
.art-face-logo{ transform: rotateY(0deg); }

/* Rounded clipping is done on an inner box rather than on .art-face itself.
   The Safari mask below is the only reliable way to clip an animating child
   to rounded corners there, but a mask also creates a compositing context —
   and putting one on the same element as backface-visibility is exactly the
   combination WebKit has historically mishandled, showing both faces at
   once. Separating the two concerns onto two elements avoids the question. */
.art-clip{
  position: absolute;
  inset: 0;
  border-radius: inherit;
  overflow: hidden;
  /* Own layer, same reasoning as the translateZ(0) on .art above: this is
     the element actually doing the rounded-corner clipping (and, on Safari,
     carrying the mask below), so it's the one whose edge was visibly
     shimmering as .art scaled through the bass pulse every frame. Giving it
     a stable layer up front means that clip gets rasterized once and just
     scales cleanly with its ancestor instead of being recomputed. */
  transform: translateZ(0);
}
/* The value uses a legacy prefixed gradient, so it's fenced off from engines
   that don't implement it rather than left to fail open — a mask that
   doesn't resolve hides the element entirely. */
@supports (-webkit-touch-callout: none){
  .art-clip{ -webkit-mask-image: -webkit-radial-gradient(white, black); }
}

/* Plain wrapper, no motion of its own — kept as a hook in case a future
   container-level effect (something that moves .art-face as a whole, not
   the photo inside it) wants somewhere to live without touching .art-img
   directly. The old "breathing" scale animation lived here and, before that,
   on .art-img itself; either way it visibly zoomed the artwork, which is
   exactly what wasn't wanted, so it's gone rather than just relocated. No
   bleed either — that inset was only ever there to hide the real edge of
   the image during that zoom, and was cropping actual cover content (text,
   logos) near the edge with nothing left to justify it. */
.art-img-wrap{
  position: absolute;
  inset: 0;
}
.art-img{
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
}

/* ---------- METADATA ---------- */
.meta{
  display: flex; flex-direction: column; align-items: center;
  gap: 12px; width: 100%; max-width: 520px; min-width: 0;
}

.eyebrow{
  font-family:'Space Mono', monospace;
  font-size: 0.62rem; letter-spacing: 0.26em; text-transform: uppercase;
  color: var(--faint);
}

/* Readout crossfade: setReadoutText() in app.js toggles this class before
   swapping textContent, so track/eyebrow changes dissolve instead of
   snapping. Shared across all three readout elements rather than baked into
   each one's own transition list, so they fade in lockstep. */
.eyebrow, #np-title, #np-artist{
  transition: opacity 0.18s ease;
}
.eyebrow.fade-swap, #np-title.fade-swap, #np-artist.fade-swap{
  opacity: 0;
}

#np-title{
  /* --title-scale defaults to 1 (full headline size) and is only ever
     turned down by fitTitleText() in app.js, for the rare title long enough
     to overflow the 2-line clamp below at full size. Short/normal titles
     never touch this and stay at headline scale. */
  font-size: calc(clamp(1.6rem, 5.4vw, 2.9rem) * var(--title-scale, 1));
  font-weight: 700;
  letter-spacing: -0.035em;
  line-height: 1.06;
  width: 100%;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  text-wrap: balance;
  overflow-wrap: anywhere; /* guards against absurdly long single-word titles */
}

#np-artist{
  font-size: clamp(0.78rem, 1.5vw, 0.95rem);
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent);
  transition: color 1.2s ease, opacity 0.18s ease;
  max-width: 100%;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* ---------- VISUALIZER ---------- */
/* --viz-max is the single source of truth for the visualizer's vertical
   range, and it is genuinely single now: the box's own height comes from it,
   AND app.js reads it back off this element (readVizMax) to clamp the bar
   heights it writes. Change it in one place — here, or in any of the
   responsive overrides further down — and both halves follow.
   This used to be a plain `height: 60px` mirrored by a hardcoded
   VIZ_MAX_PX = 60 constant in app.js. The two drifted the moment the
   responsive overrides below were added: the box shrank to 46px/34px on
   short and narrow screens while the script kept drawing to 60, and since
   the bars are flex items aligned to flex-end with nothing clipping them,
   the excess grew straight up out of the box and through the artist name
   above it. Reading the value instead of duplicating it is what stops that
   from happening again — an override that only changes one of the two is no
   longer expressible. */
.viz{
  --viz-max: 60px;
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: 3px;
  height: var(--viz-max); width: 100%; max-width: 420px;
  opacity: 1;
  /* Deliberately NOT overflow:hidden. Clipping here is the obvious safety
     net against a bar exceeding the box, and it was tried — but every bar
     carries a box-shadow bloom that reaches ~8px past its own edge, so a
     clip cuts the glow off the top of any bar near full height and leaves a
     flat, hard-edged row instead of lit rods. The clamp in app.js is what
     keeps the bars inside the box; it reads the same --viz-max declared
     above, so it can't disagree with the height, and stopVisualizer parks
     everything at 3px. Containment is handled where it costs nothing. */
}
/* Bars are lit rods rather than flat fills: a white core at the tip, accent
   through the body, and a matching glow so they bloom against the glass. */
.viz .bar{
  flex: 1; min-height: 3px; border-radius: 99px;
  background: linear-gradient(to top,
    rgba(var(--accent-rgb), 0.55) 0%,
    rgba(var(--accent-rgb), 1) 60%,
    rgba(255,255,255,1) 100%);
  /* Toned down from an earlier pass that overshot this — three glow layers
     at this size/alpha were bleeding into a wash behind the bars rather
     than reading as individually lit rods. Same layered shape, lower
     blur/alpha on each. */
  box-shadow:
    0 0 11px 0px rgba(var(--accent-rgb), 0.6),
    0 0 20px 1px rgba(var(--accent-rgb), 0.3),
    0 0 4px 0px rgba(255,255,255,0.5);
  transition: background 1.2s ease, box-shadow 1.2s ease;
}

/* ---------- VISUALIZER STYLE VARIANTS ----------
   Settings > Visualizer picks one of six looks (buildVizDom/setVizStyle in
   app.js), persisted in localStorage. LED Blocks is the default. app.js
   tags .viz with a matching modifier class (.viz--led, .viz--mirror, etc.)
   and builds whatever child markup that style needs; every rule below is
   scoped under one of those modifiers so switching styles can't leak
   markup or CSS from one into another. All six read the same underlying
   audio data and share the .viz box's own sizing above — only what's drawn
   inside it changes. Classic Bars needs no modifier rules of its own: the
   plain .viz .bar rule above already IS the Classic look, and is the
   fallback for any style that doesn't override it. */

/* Mirror Bars — same bar markup as Classic, centered instead of
   floor-anchored so growth reads as symmetric around a midline, with the
   gradient flipped to match (bright core in the middle, fading toward both
   tips rather than running bottom-to-top). */
.viz--mirror{ align-items: center; }
.viz--mirror .bar{
  background: linear-gradient(to top,
    rgba(255,255,255,1) 0%, rgba(var(--accent-rgb),1) 50%, rgba(255,255,255,1) 100%);
  box-shadow:
    0 0 10px 0px rgba(var(--accent-rgb),0.55),
    0 0 18px 1px rgba(var(--accent-rgb),0.28);
}

/* Chunky Pills — fewer, wider bars (16 vs Classic's 48, built by app.js)
   with a bolder glossy treatment so they read as "keys" rather than a
   dense spectrum. */
.viz--chunky{ gap: 6px; }
.viz--chunky .bar{
  min-height: 5px;
  background: linear-gradient(to top,
    rgba(var(--accent-rgb),0.5) 0%, rgba(var(--accent-rgb),1) 55%, rgba(255,255,255,1) 100%);
  box-shadow:
    0 0 14px 1px rgba(var(--accent-rgb),0.65),
    0 0 26px 3px rgba(var(--accent-rgb),0.32),
    inset 0 1px 1px rgba(255,255,255,0.5);
}

/* LED Blocks (default) — each column is a stack of discrete segments lit
   bottom-up rather than one continuous bar, for a retro stereo-equalizer
   read. Needs align-items:stretch (overriding .viz's own flex-end) so each
   .led-col actually fills the box's full height for its segments to divide
   up — flex-end would size it to its own content instead. */
.viz--led{ align-items: stretch; }
.led-col{
  flex: 1; height: 100%;
  display: flex; flex-direction: column-reverse; gap: 3px;
}
.led-seg{
  flex: 1; min-height: 0; border-radius: 2px;
  background: rgba(255,255,255,0.08);
  transition: background .08s linear, box-shadow .08s linear;
}
.led-seg.lit{
  background: rgba(var(--accent-rgb),1);
  box-shadow: 0 0 6px 0px rgba(var(--accent-rgb),0.85);
}
/* The single topmost lit segment, only when the column is maxed out —
   same "white-hot tip" idea as the gradient cap on Classic's bars. */
.led-seg.lit.top{
  background: #fff;
  box-shadow: 0 0 8px 1px rgba(255,255,255,0.85);
}

/* Smooth Wave + Dot Line — SVG rather than DOM bars. Both draw into a
   viewBox="0 0 420 60" stretched with preserveAspectRatio="none" to fill
   whatever the box's actual current size is (420x60 at the desktop
   breakpoint, shorter at the compact/small ones), so the path math in
   app.js never has to know the real pixel dimensions. */
.viz--wave svg, .viz--dots svg{ width: 100%; height: 100%; display: block; overflow: visible; }
.wave-line{
  fill: none; stroke: var(--accent); stroke-width: 2.4;
  stroke-linecap: round; stroke-linejoin: round;
  filter:
    drop-shadow(0 0 6px rgba(var(--accent-rgb),0.9))
    drop-shadow(0 0 14px rgba(var(--accent-rgb),0.5));
  transition: stroke 1.2s ease;
}
.dot-thread{ fill: none; stroke: rgba(var(--accent-rgb),0.55); stroke-width: 1.4; }
.dot{ fill: #fff; filter: drop-shadow(0 0 5px rgba(var(--accent-rgb),0.9)); }

/* ---------- TRANSPORT ---------- */
.transport{
  position: relative;
  display: flex; align-items: center; gap: 14px;
  width: 100%; max-width: 420px;
  padding: 9px 22px 9px 9px;
  border-radius: 100px;
  background-color: var(--glass-base);
  background-image: var(--fill-panel), var(--tint);
  border: none;
  backdrop-filter: var(--blur-lg);
  -webkit-backdrop-filter: var(--blur-lg);
  box-shadow: var(--inner), var(--lift);
  transition: background-image 1.2s ease, box-shadow 1.2s ease;
}

/* The play button is the one place the design gives up transparency on
   purpose. It's the only control that must be found instantly, so it stays
   a solid accent puck — the glass treatment is applied as gloss on top
   (bright inner top edge, dark inner bottom) plus a coloured cast onto the
   panel it sits in, which is what sells it as a physical object resting in
   the glass rather than a circle drawn on it. */
#play-btn{
  position: relative;
  width: 56px; height: 56px; border-radius: 50%; border: none; flex: 0 0 auto;
  background-color: var(--accent);
  background-image: linear-gradient(165deg,
    rgba(255,255,255,0.52) 0%,
    rgba(255,255,255,0.10) 42%,
    rgba(0,0,0,0.10) 100%);
  color: #0a0b0d;
  font-size: 1.1rem; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  box-shadow:
    inset 0 1.5px 0 rgba(255,255,255,0.65),
    inset 0 -1.5px 2px rgba(0,0,0,0.18),
    0 8px 22px -6px rgba(var(--accent-rgb), 0.75),
    0 2px 6px -2px rgba(0,0,0,0.5);
  transition: transform 0.18s cubic-bezier(0.34,1.56,0.64,1),
              background-color 1.2s ease, box-shadow 1.2s ease;
  -webkit-tap-highlight-color: transparent;
  z-index: 2;
}
#play-btn i{ position: relative; z-index: 2; }
#play-btn:hover{
  transform: scale(1.06);
  box-shadow:
    inset 0 1.5px 0 rgba(255,255,255,0.7),
    inset 0 -1.5px 2px rgba(0,0,0,0.18),
    0 12px 30px -6px rgba(var(--accent-rgb), 0.9),
    0 2px 6px -2px rgba(0,0,0,0.5);
}
#play-btn:active{ transform: scale(0.94); }
#play-btn:focus-visible{ outline: 2px solid var(--ink); outline-offset: 3px; }

.vol{ display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0; }
.vol i{
  color: rgba(255,255,255,0.58); font-size: 0.85rem; width: 16px;
  cursor: pointer; transition: color 0.2s;
  text-align: center; flex: 0 0 auto;
}
.vol i:hover{ color: var(--ink); }

.vol input[type=range]{
  -webkit-appearance: none; -moz-appearance: none; appearance: none;
  flex: 1; min-width: 0;
  height: 28px;               /* generous touch target over a 4px visual track */
  background: transparent;
  outline: none; border: none;
  touch-action: none; cursor: pointer;
}
/* The unfilled part of the track is a groove cut into the glass — dark,
   with a light line under it — rather than a lighter bar drawn on top.
   The filled part glows in the accent colour. Two background layers, sized
   by --vol-pct which app.js keeps in sync with the slider value. */
.vol input[type=range]::-webkit-slider-runnable-track{
  height: 5px; border-radius: 99px;
  background-image:
    linear-gradient(rgba(var(--accent-rgb),1), rgba(var(--accent-rgb),0.82)),
    linear-gradient(rgba(0,0,0,0.32), rgba(255,255,255,0.10));
  background-size: var(--vol-pct, 80%) 100%, 100% 100%;
  background-repeat: no-repeat;
  border: none;
  box-shadow: inset 0 1px 1px rgba(0,0,0,0.35), 0 1px 0 rgba(255,255,255,0.10);
}
.vol input[type=range]::-moz-range-track{
  height: 5px; border-radius: 99px;
  background-image:
    linear-gradient(rgba(var(--accent-rgb),1), rgba(var(--accent-rgb),0.82)),
    linear-gradient(rgba(0,0,0,0.32), rgba(255,255,255,0.10));
  background-size: var(--vol-pct, 80%) 100%, 100% 100%;
  background-repeat: no-repeat;
  border: none;
  box-shadow: inset 0 1px 1px rgba(0,0,0,0.35), 0 1px 0 rgba(255,255,255,0.10);
}
/* Glass bead: not flat white, but a tiny sphere with a highlight on top
   and an accent halo the same colour as the track it's riding on. */
.vol input[type=range]::-webkit-slider-thumb{
  -webkit-appearance: none;
  width: 16px; height: 16px; border-radius: 50%;
  background: radial-gradient(circle at 34% 28%, #fff 0%, #f2f3f6 46%, #cfd2da 100%);
  border: none;
  margin-top: -5.5px;
  box-shadow:
    0 1px 3px rgba(0,0,0,0.55),
    0 3px 10px -2px rgba(0,0,0,0.5),
    0 0 12px -2px rgba(var(--accent-rgb),0.65),
    inset 0 -1px 1px rgba(0,0,0,0.18);
  cursor: pointer;
  transition: transform 0.15s ease;
}
.vol input[type=range]::-moz-range-thumb{
  width: 16px; height: 16px; border-radius: 50%;
  background: radial-gradient(circle at 34% 28%, #fff 0%, #f2f3f6 46%, #cfd2da 100%);
  border: none;
  box-shadow:
    0 1px 3px rgba(0,0,0,0.55),
    0 3px 10px -2px rgba(0,0,0,0.5),
    0 0 12px -2px rgba(var(--accent-rgb),0.65);
  cursor: pointer;
}
.vol input[type=range]:active::-webkit-slider-thumb{ transform: scale(1.15); }
.vol input[type=range]:focus-visible::-webkit-slider-thumb{ box-shadow: 0 0 0 3px rgba(var(--accent-rgb),0.55); }

/* ---------- CAST BUTTON ---------- */
#castBtn{
  position: relative;
  width: 44px; height: 44px; border-radius: 50%;
  background-color: var(--glass-base);
  background-image: var(--fill-thin), var(--tint);
  border: none;
  backdrop-filter: var(--blur-md);
  -webkit-backdrop-filter: var(--blur-md);
  color: var(--ink);
  display: flex; align-items: center; justify-content: center;
  cursor: pointer; flex: 0 0 auto;
  box-shadow: var(--inner), 0 10px 24px -12px rgba(0,0,0,0.8);
  transition: transform 0.22s cubic-bezier(0.34,1.56,0.64,1),
              background-color 0.4s, color 0.4s, box-shadow 0.4s;
  -webkit-tap-highlight-color: transparent;
}
#castBtn:hover{
  transform: scale(1.07);
  background-color: rgba(255,255,255,0.12);
  box-shadow: var(--inner), 0 14px 30px -12px rgba(0,0,0,0.85);
}
#castBtn svg{ width: 22px; height: 22px; fill: currentColor; position: relative; z-index: 2; }
/* Active cast: the glass fills with accent and the rim goes bright, so the
   button reads as "lit from inside" rather than merely recoloured. */
#castBtn.casting{
  background-color: var(--accent);
  background-image: linear-gradient(165deg, rgba(255,255,255,0.5), rgba(255,255,255,0) 60%);
  color: #0a0b0d;
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.6), inset 0 -1px 0 rgba(0,0,0,0.15);
  animation: pulseCast 2.2s infinite;
}
@keyframes pulseCast{
  0%{ box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.6); }
  70%{ box-shadow: 0 0 0 16px rgba(var(--accent-rgb), 0); }
  100%{ box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); }
}

/* Connecting: the window between tapping the button and the device
   picker/session actually resolving (or timing out — see the
   castRequestTimeout comment in cast.js). Previously totally silent, which
   is what made a hung requestSession() read as a dead button rather than a
   slow one. A spinning ring rather than pulseCast's steady glow so it can't
   be mistaken for "already casting" — this means "working on it", not "on". */
#castBtn.connecting::after{
  content: '';
  position: absolute;
  inset: -4px;
  border-radius: 50%;
  border: 2.5px solid transparent;
  border-top-color: var(--accent);
  border-right-color: var(--accent);
  animation: spinCastConnecting 0.85s linear infinite;
  pointer-events: none;
  z-index: 1;
}
@keyframes spinCastConnecting{
  to{ transform: rotate(360deg); }
}

/* ---------- RECENTLY PLAYED ---------- */
.drawer-container{
  position: relative;
  width: 100%;
  max-width: 460px;
  background-color: var(--glass-base);
  background-image: var(--fill-panel), var(--tint);
  border: none;
  border-radius: 26px;
  backdrop-filter: var(--blur-lg);
  -webkit-backdrop-filter: var(--blur-lg);
  overflow: hidden;
  display: flex;
  flex-direction: column-reverse; /* handle on top, list expands beneath it */
  flex: 0 0 auto;
  box-shadow: var(--inner), var(--lift);
  transition: background-image 1.2s ease;
}

.drawer-content{
  position: relative;   /* so it can be lifted above the panel's sheen layer */
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows 0.42s cubic-bezier(0.4,0,0.2,1);
}
.drawer-content.closed{ grid-template-rows: 0fr; }
.drawer-inner{ overflow: hidden; min-height: 0; }

.history-list{
  list-style: none; margin: 0;
  padding: 2px 20px 14px;
  display: flex; flex-direction: column;
}

/* Etched divider rather than a plain line: a dark hairline with a light one
   directly beneath it, which is how a groove in glass actually catches
   light. Uses a background image instead of border-bottom so both lines can
   live in the same 2px. */
.history-item{
  position: relative;
  display: flex; justify-content: space-between; align-items: center; gap: 14px;
  padding: 11px 0;
  border-bottom: none;
  background-image: linear-gradient(rgba(0,0,0,0.28), rgba(0,0,0,0.28)),
                    linear-gradient(rgba(255,255,255,0.09), rgba(255,255,255,0.09));
  background-size: 100% 1px, 100% 1px;
  background-position: 0 calc(100% - 1px), 0 100%;
  background-repeat: no-repeat;
  font-size: 0.85rem; line-height: 1.3;
}
.history-item:last-child{ background-image: none; padding-bottom: 2px; }
.history-item .track-details{ display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.history-item .track-title{
  color: var(--ink); font-weight: 600;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.history-item .track-artist{
  color: var(--muted); font-size: 0.72rem; letter-spacing: 0.02em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.history-item .track-time{
  font-family:'Space Mono', monospace; font-size: 0.66rem;
  color: var(--faint); flex-shrink: 0;
}
.history-item.loading{
  color: var(--faint); font-family:'Space Mono', monospace;
  font-size: 0.72rem; justify-content: center;
  background-image: none; padding: 10px 0;
}

/* Sits above the list during an outage so cached entries can't be mistaken
   for live ones. */
.history-item.stale-note{
  color: var(--faint); font-family:'Space Mono', monospace;
  font-size: 0.66rem; letter-spacing: 0.06em;
  justify-content: center; text-align: center;
  background-image: none;
  padding: 6px 0 10px;
}

/* Handle sits slightly brighter than the panel it caps, so the drawer reads
   as two stacked sheets of glass rather than one box with a label in it. */
.drawer-toggle{
  position: relative;
  display: flex; align-items: center; gap: 10px;
  padding: 15px 20px; cursor: pointer; user-select: none;
  font-family:'Space Mono', monospace;
  font-size: 0.66rem; letter-spacing: 0.2em; text-transform: uppercase;
  color: rgba(244,245,247,0.66);
  background-image: linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0));
  transition: color 0.2s, background-color 0.2s;
  -webkit-tap-highlight-color: transparent;
}
.drawer-toggle:hover{ color: var(--ink); background-color: rgba(255,255,255,0.06); }
.drawer-toggle i{ color: rgba(var(--accent-rgb),0.8); transition: color 1.2s ease; }
.drawer-icon-wrap{ margin-left: auto; display: inline-flex; transition: transform 0.3s ease; }

/* Which way the chevron points is a CSS concern, not a JS one, because the
   answer depends on the layout: this is a sheet that rises out of the bottom
   of the screen on a phone and a rail that opens sideways on a desktop, and
   "closed" points somewhere different in each. app.js used to write one
   fixed pair of angles as an inline transform, which could only ever suit
   one of them — on mobile it had the arrow pointing DOWN on a closed sheet
   that opens upward, and UP on an open one that closes downward.
   Driving it from the handle's own aria-expanded (which app.js already
   maintains for screen readers) lets each layout state its own answer, and
   keeps the visual and the announced state incapable of disagreeing.

   Mobile, the base case: the sheet is anchored to the bottom edge and grows
   upward, so closed points up ("pull this up") and open points down. */
.drawer-icon{ font-size: 0.8rem; transform: rotate(180deg); transition: transform 0.3s ease; }
.drawer-toggle[aria-expanded="true"] .drawer-icon{ transform: rotate(0deg); }

/* =========================================================
   DESKTOP — art and metadata sit side by side, recently
   played becomes a full-height rail pinned to the right.
   Gated on orientation as well as width: a wide-in-pixels window is only
   "desktop" here if it's also landscape. A vertical monitor can easily be
   1080px+ across while being much taller than it is wide (e.g. a 1080x1920
   panel), and the side-by-side layout below is wrong for that shape no
   matter how many pixels it has horizontally — it belongs in the default
   single-column layout instead, same as a narrow phone gets.
   ========================================================= */
@media (min-width: 1000px) and (orientation: landscape){
  .stage{
    flex-direction: row;
    align-items: stretch;
    gap: clamp(24px, 3vw, 48px);
    padding-bottom: clamp(24px, 4dvh, 44px);
  }

  .now{
    flex: 1 1 auto;
    width: auto;              /* let flex do the sizing, not a 100% basis */
    flex-direction: row;
    align-items: center;
    justify-content: center;
    text-align: left;
    gap: clamp(32px, 4vw, 60px);
  }

  .art{ width: min(36vw, 46dvh, 440px); }

  .meta{ align-items: flex-start; max-width: 460px; }
  #np-title{ font-size: calc(clamp(2.3rem, 3.8vw, 3.8rem) * var(--title-scale, 1)); }
  .viz{ max-width: 100%; }
  .transport{ max-width: 100%; }

  /* Rail: the handle stays pinned at the right edge, the list opens
     inward toward the player — horizontal version of the mobile tray. */
  .drawer-container{
    display: flex;
    flex-direction: row;
    align-items: stretch;
    width: auto; max-width: none;
    align-self: center;
    margin-block: auto;   /* centres the rail even if align-self is ignored */
    max-height: 560px;                 /* plain value first, as a floor */
    max-height: min(560px, 72dvh);
    border-radius: 22px;
  }

  .drawer-content{
    grid-template-rows: 1fr;
    grid-template-columns: 1fr;
    transition: grid-template-columns 0.42s cubic-bezier(0.4,0,0.2,1);
  }
  .drawer-content.closed{ grid-template-rows: 1fr; grid-template-columns: 0fr; }
  .drawer-inner{ min-width: 0; height: 100%; }

  .history-list{
    width: 300px; height: 100%;
    overflow-y: auto;
    padding: 16px 20px;
    scrollbar-width: thin;
    scrollbar-color: rgba(255,255,255,0.18) transparent;
  }
  .history-list::-webkit-scrollbar{ width: 5px; }
  .history-list::-webkit-scrollbar-thumb{ background: rgba(255,255,255,0.18); border-radius: 99px; }

  .drawer-toggle{
    flex: 0 0 52px;
    /* Explicit, not just a flex-basis. A basis only sizes the main axis, so
       if the row direction is ever lost the tab collapses to the width of
       its icons — about 18px — and the label has nowhere to sit. A real
       width holds the tab open regardless of which axis flex is using. */
    width: 52px;
    min-width: 52px;
    position: relative;
    flex-direction: column;
    justify-content: space-between;
    align-items: center;
    gap: 0;
    padding: 20px 0;
    /* The rail reserves its own height. It can't be inferred from the label
       (see below), and when the drawer is closed the list contributes none. */
    min-height: 248px;
    /* Etched seam between the handle and the list, matching the history
       dividers: dark line, light line, drawn as a background so it doesn't
       add a border box to a flex item that's already width-pinned. */
    border-left: none;
    background-image:
      linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0)),
      linear-gradient(rgba(0,0,0,0.3), rgba(0,0,0,0.3)),
      linear-gradient(rgba(255,255,255,0.10), rgba(255,255,255,0.10));
    background-size: 100% 100%, 1px 100%, 1px 100%;
    background-position: 0 0, 0 0, 1px 0;
    background-repeat: no-repeat;
  }

  /* The label is turned with a transform rather than by switching writing
     mode. A vertical writing mode inside a flex container is an orthogonal
     flow, which is the one case where engines genuinely disagree about
     intrinsic sizing: Firefox resolved this item to zero and the overflow
     clip on the rail hid it, so no label appeared until the drawer was
     opened and the list gave the container a definite size. A transform
     never participates in layout, so there is nothing left to disagree on. */
  .drawer-label{
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%) rotate(90deg);
    white-space: nowrap;
    line-height: 1;
  }
  .drawer-icon-wrap{
    margin-left: 0;
    transform: rotate(90deg); /* chevron points sideways toward the panel */
  }
  /* The rail opens inward, toward the player on its left, so the directions
     invert relative to the mobile sheet above: closed points left (into the
     space the list will fill), open points right (back into the handle).
     These compose with the wrap's 90deg — 0 reads as left, 180 as right. */
  .drawer-icon{ transform: rotate(0deg); }
  .drawer-toggle[aria-expanded="true"] .drawer-icon{ transform: rotate(180deg); }
}

/* Very short desktop windows: drop the art down a notch so nothing clips. */
@media (min-width: 1000px) and (max-height: 700px){
  .art{ width: min(30vw, 44dvh, 340px); }
  #np-title{ font-size: calc(clamp(1.7rem, 2.6vw, 2.4rem) * var(--title-scale, 1)); }
  .viz{ --viz-max: 46px; }
}

/* ---------- SMALL PHONES ---------- */
@media (max-width: 400px){
  .topbar{ padding: 14px 16px; gap: 10px; }
  .brand-text{ gap: 5px; }
  .brand-name{ font-size: 17px; gap: 6px; }
  .brand-fm{ font-size: 9px; padding: 3px 4px 2px; border-radius: 4px; }
  .brand-tag{ font-size: 8px; letter-spacing: 0.42em; }
  .live{ font-size: 0.55rem; padding: 5px 10px; }
  #castBtn{ width: 40px; height: 40px; }
  #castBtn svg{ width: 20px; height: 20px; }
  .art{ width: min(100%, 38dvh, 340px); }
  #play-btn{ width: 50px; height: 50px; font-size: 1rem; }
  .transport{ padding: 8px 16px 8px 8px; gap: 12px; }
  .viz{ --viz-max: 46px; }
  .drawer-toggle{ padding: 13px 16px; font-size: 0.6rem; letter-spacing: 0.16em; }
  .history-list{ padding: 2px 16px 12px; }
}

/* ---------- MOBILE: RECENTLY PLAYED AS A BOTTOM SHEET ----------
   Below the desktop breakpoint the list lifts out of the layout and floats
   over it, anchored to the bottom edge. Because the shell can't scroll, an
   in-flow list would either squash the player or push it off-screen when
   opened; as a sheet it overlays instead, and scrolls internally — which is
   what a native app does with this exact pattern. */
/* 999.98 rather than 999: at fractional viewport widths (common with display
   scaling — a 1.25x screen readily produces widths like 999.5px) a 999/1000
   split leaves a gap where NEITHER query matches and the drawer falls back to
   its base styles, which belong to neither layout.
   Also matches on orientation: portrait, comma-OR'd in alongside the width
   check, for the same reason — the desktop rail layout requires landscape
   (see the DESKTOP block above, for vertical monitors), so anything portrait
   needs to land in *some* explicitly-styled layout rather than the bare,
   unscrolled, uncapped base styles. Without this, a wide-but-portrait window
   (e.g. a 1080-wide vertical monitor) matches neither this query nor the
   desktop one, and the drawer's height becomes whatever its unconstrained
   content happens to need — which is exactly the "jumbles around in size"
   behavior this is fixing. */
@media (max-width: 999.98px), (orientation: portrait){
  .stage{
    position: relative;
    padding-bottom: calc(66px + env(safe-area-inset-bottom, 0px));
  }

  /* Dim the player behind an open sheet. Without this the transport ghosts
     through the frosted panel and reads as broken — it's underneath, so it
     isn't tappable, and it shouldn't look like it is. The handle stays above
     the dim, so dismissing is always one tap away. */
  .stage::after{
    content: '';
    position: absolute;
    /* Top edge flush with the stage, bottom deliberately overhanging.
       This was `inset: -24px 0` — symmetric — which pushed the overlay 24px
       up into the topbar. The stage starts immediately below that bar, and
       the brand tagline sits in exactly that band, so opening the sheet drew
       a hard horizontal edge straight through "Variety Unlocked" and clipped
       the bottom of the Live pill and the cast button. The header is not
       behind the sheet and must not be dimmed with it.
       The 24px is kept on the bottom, where it does the job it was there
       for: the stage's own box ends above the safe-area padding, and without
       the overhang a sliver of undimmed player showed beneath the overlay on
       devices with a home indicator. .app clips it, so it can't spill. */
    inset: 0 0 -24px;
    background: rgba(10,11,13,0.62);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.35s ease;
    z-index: 25;
  }
  body.sheet-open .stage::after{ opacity: 1; }

  .drawer-container{
    position: absolute;
    left: max(12px, env(safe-area-inset-left, 0px));
    right: max(12px, env(safe-area-inset-right, 0px));
    bottom: max(12px, env(safe-area-inset-bottom, 0px));
    width: auto;
    max-width: 460px;
    margin-inline: auto;
    max-height: 58dvh;
    z-index: 30;
    /* Denser than the desktop rail on purpose. As a sheet it floats over the
       player rather than beside it, so it has to stay readable with artwork
       *and* UI behind it — but it's still glass, not the near-solid slab it
       used to be. */
    background-color: rgba(12,14,18,0.52);
    background-image: var(--fill-card), var(--tint);
    box-shadow:
      var(--inner),
      0 -12px 60px -14px rgba(0,0,0,0.92),
      0 2px 10px -4px rgba(0,0,0,0.6);
  }

  .history-list{
    max-height: calc(58dvh - 54px);
    overflow-y: auto;
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }
  .history-list::-webkit-scrollbar{ display: none; }
}

/* Shed the lockup's least essential parts before the bar ever wraps. */
@media (max-width: 360px){ .brand-tag{ display: none; } }
@media (max-width: 320px){ .live{ display: none; } }

/* ---------- VERY SHORT VIEWPORTS (single-column layout) ----------
   Below the desktop breakpoint the stage is one column, so art, title, viz
   and the drawer toggle all compete for the same vertical space. Art doesn't
   shrink on its own (flex: 0 0 auto), so a short enough window makes it
   overlap the controls beneath it. Two-stage response: shrink it hard first,
   then drop it entirely once there's genuinely no room left. Track info and
   the play button are what the player needs to keep working; the art is the
   one piece it can afford to lose. */
@media (max-width: 999.98px) and (max-height: 560px){
  .now{ gap: clamp(8px, 2dvh, 18px); }
  .art{ width: min(100%, 22dvh, 180px); }
  .viz{ --viz-max: 34px; } /* modest bump only — this breakpoint exists specifically to conserve vertical space */
}

@media (max-width: 999.98px) and (max-height: 400px){
  .art{ display: none; }
}

/* ---------- PRESS FEEDBACK ----------
   Every tappable surface answers immediately. Without this a touch UI feels
   dead in the gap before the audio actually starts. */
#castBtn:active{ transform: scale(0.92); }
.drawer-toggle:active{ background: rgba(255,255,255,0.08); }
.history-item:active{ opacity: 0.55; }
.brand:active{ opacity: 0.7; }
.vol i:active{ transform: scale(0.88); }

/* ---------- TOASTS ---------- */
#toast-container{
  position: fixed;
  top: calc(16px + env(safe-area-inset-top, 0px));
  left: 50%; transform: translateX(-50%);
  z-index: 9999;
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  pointer-events: none;
  width: max-content; max-width: calc(100vw - 32px);
}
.toast{
  position: relative;
  background-color: rgba(12,14,18,0.55);
  background-image: var(--fill-card), var(--tint);
  backdrop-filter: var(--blur-lg);
  -webkit-backdrop-filter: var(--blur-lg);
  border: none;
  border-radius: 18px;
  color: var(--ink);
  padding: 13px 22px;
  font-size: 0.85rem; font-weight: 500;
  text-align: center;
  box-shadow: var(--inner), var(--lift);
  animation: toastIn 0.4s cubic-bezier(0.175,0.885,0.32,1.275) forwards;
}
.toast.hiding{ animation: toastOut 0.3s ease forwards; }
/* #toast-container is pointer-events:none so normal (non-interactive) toasts
   never block clicks on the page underneath — this toast is the one
   exception, so it has to explicitly opt back in. */
.toast.update{ cursor: pointer; pointer-events: auto; }
@keyframes toastIn{ from{ opacity:0; transform: translateY(-14px) scale(0.94);} to{ opacity:1; transform:none;} }
@keyframes toastOut{ from{ opacity:1; transform:none;} to{ opacity:0; transform: translateY(-14px) scale(0.94);} }

/* The install toast carries its own buttons rather than being tap-anywhere
   like .toast.update, so it needs pointer-events back on and a row layout —
   message on the left, Install + dismiss on the right. */
.toast.install{
  pointer-events: auto;
  display: flex; align-items: center; gap: 14px;
  padding: 11px 12px 11px 20px;
  text-align: left;
}
.toast-install-msg{
  display: flex; align-items: center; gap: 10px;
  font-size: 0.85rem; font-weight: 500;
}
.toast-install-msg i{ color: var(--accent); font-size: 0.95rem; }
.toast-install-actions{ display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
.toast-install-btn{
  padding: 9px 18px; border-radius: 100px;
  background-color: var(--accent);
  background-image: linear-gradient(165deg, rgba(255,255,255,0.5), rgba(255,255,255,0.02) 60%);
  color: #0a0b0d; border: none;
  font-size: 0.8rem; font-weight: 600; cursor: pointer; white-space: nowrap;
  box-shadow:
    inset 0 1.5px 0 rgba(255,255,255,0.6),
    inset 0 -1.5px 2px rgba(0,0,0,0.16),
    0 8px 22px -8px rgba(var(--accent-rgb),0.8);
  transition: transform 0.18s cubic-bezier(0.34,1.56,0.64,1);
  -webkit-tap-highlight-color: transparent;
}
.toast-install-btn:hover{ transform: scale(1.05); }
.toast-install-btn:active{ transform: scale(0.94); }
.toast-install-btn:focus-visible{ outline: 2px solid var(--ink); outline-offset: 2px; }
.toast-install-dismiss{
  width: 30px; height: 30px; flex: 0 0 auto;
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%;
  background-color: rgba(255,255,255,0.07);
  background-image: var(--fill-thin);
  border: none; color: var(--muted); font-size: 0.78rem; cursor: pointer;
  transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
  -webkit-tap-highlight-color: transparent;
}
.toast-install-dismiss:hover{ background-color: rgba(255,255,255,0.16); color: var(--ink); transform: scale(1.08); }
.toast-install-dismiss:active{ transform: scale(0.92); }
.toast-install-dismiss:focus-visible{ outline: 2px solid var(--ink); outline-offset: 2px; }

@media (max-width: 479.98px){
  .toast.install{
    flex-direction: column; align-items: stretch; gap: 12px;
    padding: 16px 18px; text-align: center;
  }
  .toast-install-msg{ justify-content: center; text-align: center; }
  .toast-install-actions{ justify-content: center; }
}

/* ---------- STREAM OFFLINE MODAL ----------
   Shown once tryPlay()/reconnectStream() have exhausted their retries.
   Sits above everything (including toasts) and blocks interaction with
   the page behind it until the user dismisses it via Refresh. */
#stream-offline-modal{
  position: fixed; inset: 0; z-index: 10000;
  display: flex; align-items: center; justify-content: center;
  padding: 24px;
  /* The backdrop is part of the material, not just a dimmer: it blurs the
     whole page behind the dialog so the card looks like it's floating in
     front of frosted glass rather than over a dark rectangle. */
  background: rgba(6,7,9,0.52);
  backdrop-filter: blur(22px) saturate(140%) brightness(0.82);
  -webkit-backdrop-filter: blur(22px) saturate(140%) brightness(0.82);
  opacity: 0; pointer-events: none;
  /* visibility (not just opacity) keeps the dismissed dialog out of the tab
     order — otherwise its buttons stay focusable behind the page. */
  visibility: hidden;
  transition: opacity 0.3s ease, visibility 0.3s ease;
}
#stream-offline-modal.visible{ opacity: 1; pointer-events: auto; visibility: visible; }

/* Dialog cards scroll internally when their content is tall, so their edge
   treatment is done entirely with inset box-shadows. An absolutely
   positioned rim pseudo-element would scroll away with the content —
   inset shadows are painted on the element's own box and stay put. */
.offline-card{
  position: relative;
  width: 100%; max-width: 340px; max-height: min(520px, 82dvh);
  overflow-y: auto;
  background-color: var(--glass-base-card);
  background-image: var(--fill-card), var(--tint);
  border: none;
  border-radius: 28px;
  backdrop-filter: var(--blur-lg);
  -webkit-backdrop-filter: var(--blur-lg);
  padding: 34px 28px 28px;
  text-align: center;
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.34),
    inset 0 0 0 1px rgba(255,255,255,0.10),
    inset 0 -1px 0 rgba(255,255,255,0.05),
    var(--lift-lg);
  transform: translateY(14px) scale(0.94);
  transition: transform 0.42s cubic-bezier(0.22,1.4,0.36,1);
}
#stream-offline-modal.visible .offline-card{ transform: none; }
/* Dismiss control. Sized to a comfortable touch target and pulled into the
   card's own padding so it doesn't push the centred content off-axis. */
.offline-close{
  position: absolute; top: 10px; right: 10px;
  width: 34px; height: 34px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%;
  background-color: rgba(255,255,255,0.07);
  background-image: var(--fill-thin);
  border: none;
  backdrop-filter: var(--blur-sm);
  -webkit-backdrop-filter: var(--blur-sm);
  color: var(--muted);
  font-size: 0.85rem; line-height: 1; cursor: pointer;
  box-shadow: var(--inner-soft), 0 3px 10px -4px rgba(0,0,0,0.6);
  transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
  -webkit-tap-highlight-color: transparent;
  z-index: 3;
}
.offline-close:hover{
  background-color: rgba(255,255,255,0.16); color: var(--ink); transform: scale(1.08);
}
.offline-close:active{ transform: scale(0.92); }
.offline-close:focus-visible{ outline: 2px solid var(--ink); outline-offset: 2px; }

.offline-card i.offline-icon{
  font-size: 1.8rem; color: var(--muted); margin-bottom: 14px; display: block;
}
.offline-card h2{ font-size: 1.05rem; font-weight: 600; margin-bottom: 8px; }
.offline-card p{ font-size: 0.85rem; color: var(--muted); line-height: 1.5; margin-bottom: 22px; }
#offline-refresh-btn{
  display: inline-flex; align-items: center; gap: 8px;
  padding: 12px 26px; border-radius: 100px;
  background-color: var(--accent);
  background-image: linear-gradient(165deg, rgba(255,255,255,0.5), rgba(255,255,255,0.02) 60%);
  color: #0a0b0d; border: none;
  font-size: 0.85rem; font-weight: 600; cursor: pointer;
  box-shadow:
    inset 0 1.5px 0 rgba(255,255,255,0.6),
    inset 0 -1.5px 2px rgba(0,0,0,0.16),
    0 8px 22px -8px rgba(var(--accent-rgb),0.8);
  transition: transform 0.18s cubic-bezier(0.34,1.56,0.64,1);
  -webkit-tap-highlight-color: transparent;
}
#offline-refresh-btn:hover{ transform: scale(1.05); }
#offline-refresh-btn:active{ transform: scale(0.94); }
#offline-refresh-btn:focus-visible{ outline: 2px solid var(--ink); outline-offset: 3px; }
.offline-countdown{
  margin: 16px 0 0; font-size: 0.75rem; color: var(--faint); font-weight: 500;
}

/* ---------- FAQ MODAL ----------
   Same overlay/card treatment as the stream-offline dialog (glass card,
   blurred backdrop, .offline-close button) so it reads as part of the same
   system rather than a bolted-on widget. */
.faq-modal{
  position: fixed; inset: 0; z-index: 10000;
  display: flex; align-items: center; justify-content: center;
  padding: 24px;
  background: rgba(6,7,9,0.52);
  backdrop-filter: blur(22px) saturate(140%) brightness(0.82);
  -webkit-backdrop-filter: blur(22px) saturate(140%) brightness(0.82);
  opacity: 0; pointer-events: none; visibility: hidden;
  transition: opacity 0.3s ease, visibility 0.3s ease;
}
.faq-modal.visible{ opacity: 1; pointer-events: auto; visibility: visible; }

.faq-card{
  position: relative;
  width: 100%; max-width: 440px; max-height: min(560px, 78dvh);
  /* NOT the scroll container — .faq-list below is. A rounded box that
     scrolls itself draws its native/webkit scrollbar as a flat vertical bar
     running the full height of its own box, ignoring the curvature of its
     own border-radius corners, so it visibly pokes past the top/bottom-right
     corners (and can pin content past the viewport edge on short screens).
     Scoping the scroll to .faq-list — a plain rectangular child inset by
     this card's own side padding — keeps the scrollbar entirely clear of
     the rounded edge instead. */
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background-color: var(--glass-base-card);
  background-image: var(--fill-card), var(--tint);
  border: none;
  border-radius: 28px;
  backdrop-filter: var(--blur-lg);
  -webkit-backdrop-filter: var(--blur-lg);
  padding: 34px 28px 28px;
  text-align: left;
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.34),
    inset 0 0 0 1px rgba(255,255,255,0.10),
    inset 0 -1px 0 rgba(255,255,255,0.05),
    var(--lift-lg);
  transform: translateY(14px) scale(0.94);
  transition: transform 0.42s cubic-bezier(0.22,1.4,0.36,1);
}
.faq-modal.visible .faq-card{ transform: none; }

/* Icon + title + subtitle, echoing the accent colour (var(--accent),
   re-tinted at runtime from the current album art) so the dialog feels tied
   to the rest of the player instead of a generic popup. */
.faq-header{
  flex: 0 0 auto; /* stays put — .faq-list is what scrolls, not this */
  display: flex; align-items: flex-start; gap: 14px;
  padding-right: 28px; /* clears the close button in the corner */
  margin-bottom: 24px;
}
.faq-header-icon{
  flex: 0 0 auto;
  width: 40px; height: 40px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background-color: rgba(var(--accent-rgb), 0.18);
  background-image: var(--fill-thin);
  color: var(--accent);
  font-size: 1rem;
  box-shadow: var(--inner-soft), 0 0 18px -6px rgba(var(--accent-rgb),0.7);
  transition: background-color 1.2s ease, color 1.2s ease, box-shadow 1.2s ease;
}
.faq-card h2{ font-size: 1.12rem; font-weight: 700; margin-bottom: 2px; }
.faq-subtitle{ font-size: 0.78rem; color: var(--muted); }

/* Each answer sits on its own small pane, so the list reads as stacked
   cards inside the dialog rather than paragraphs with rules beside them.
   No backdrop-filter here — these are nested inside a surface that's
   already blurred, and stacking a second one gains nothing while costing a
   whole extra composite pass per item. A translucent fill is enough. */
.faq-list{
  display: flex; flex-direction: column; gap: 12px;
  overflow-y: auto;
  /* Setting overflow-y alone implicitly forces overflow-x to auto too (per
     spec, it can't stay visible once the other axis scrolls) — and
     .faq-item:hover's translateX(2px) nudge is a real 2px of visual
     overflow past this box's right edge, so without the line below that
     implicit auto was flashing a horizontal scrollbar under the list on
     every hover. Pinned to hidden since horizontal scrolling was never
     wanted here in the first place. */
  overflow-x: hidden;
  min-height: 0; /* lets this flex child actually shrink and scroll instead of stretching .faq-card past its max-height */
  /* A little breathing room on every side so hovered items — which nudge
     2px on translateX — have room to move without their edge/shadow
     touching the scrollport, and so the list doesn't sit flush against the
     card's rounded corners. */
  padding: 4px 10px 4px 4px;
}
.faq-item{
  padding: 13px 15px 14px 16px;
  border: none;
  border-radius: 16px;
  background-color: rgba(255,255,255,0.035);
  background-image: linear-gradient(158deg, rgba(255,255,255,0.07), rgba(255,255,255,0.01));
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.13),
    inset 3px 0 0 rgba(var(--accent-rgb), 0.55);
  transition: background-color 0.25s ease, box-shadow 1.2s ease, transform 0.25s ease;
}
.faq-item:hover{
  background-color: rgba(255,255,255,0.065);
  transform: translateX(2px);
}
.faq-item h3{ font-size: 0.88rem; font-weight: 600; margin-bottom: 5px; }
.faq-item p{ font-size: 0.82rem; color: var(--muted); line-height: 1.55; }
.faq-item p a{ color: var(--accent); font-weight: 600; text-decoration: underline; text-underline-offset: 2px; }
.faq-item p a:hover{ text-decoration: none; }

/* Link out to the standalone /faq/ page — same Q&As, but real page text
   instead of markup inside a JS-driven dialog, and something an outside
   link (or search result) can point straight at. flex:0 0 auto like
   .faq-header above, so it stays pinned below the scrolling list rather
   than getting lost inside it. */
.faq-footer-link{
  flex: 0 0 auto;
  margin-top: 16px; padding-top: 14px;
  border-top: 1px solid rgba(255,255,255,0.08);
  text-align: center;
}
.faq-footer-link a{
  font-size: 0.78rem; font-weight: 600; color: var(--muted);
  text-decoration: none;
}
.faq-footer-link a:hover{ color: var(--accent); }
.faq-footer-link a i{ font-size: 0.68rem; margin-left: 4px; }

/* ---------- DARK SCROLLBARS (modal cards) ----------
   Both dialogs cap their height and scroll if content runs long; this keeps
   that scrollbar in the site's own dark palette instead of the OS default.
   .faq-list rather than .faq-card here on purpose — .faq-list is the actual
   scroll container (see the comment on .faq-card above), and theming the
   card itself would leave these rules dead now that it no longer scrolls. */
.faq-list,
.offline-card{
  scrollbar-width: thin;
  scrollbar-color: rgba(255,255,255,0.24) transparent;
}
.faq-list::-webkit-scrollbar,
.offline-card::-webkit-scrollbar{
  width: 6px;
}
.faq-list::-webkit-scrollbar-track,
.offline-card::-webkit-scrollbar-track{
  background: transparent;
}
.faq-list::-webkit-scrollbar-thumb,
.offline-card::-webkit-scrollbar-thumb{
  background: rgba(255,255,255,0.24);
  border-radius: 99px;
}
.faq-list::-webkit-scrollbar-thumb:hover,
.offline-card::-webkit-scrollbar-thumb:hover{
  background: rgba(255,255,255,0.36);
}

/* =========================================================
   GLASS EDGES & SPECULAR HIGHLIGHTS
   The part that turns "translucent box" into "sheet of glass".

   Every panel gets two absolutely-positioned pseudo-elements:

     ::before — a 1px gradient rim. Drawn as a filled box with 1px of
                padding, then masked so only the padding ring survives.
                This is the only way to get a *gradient* border with a
                border-radius; border-image can't follow rounded corners.
     ::after  — the specular highlight: a soft bloom in the top-left
                corner where the implied light hits, plus a fine grain so
                the surface isn't a mathematically perfect gradient.

   Both are position:absolute, which matters: .transport, .drawer-container
   and .site-menu are all flex containers, and a static pseudo-element
   would become a flex ITEM and wreck their layout. Absolutely positioned
   children are excluded from flex layout, so these are inert.
   ========================================================= */

/* The masking trick fails open — without mask-composite the two mask layers
   simply add together, leaving the full rectangle painted, which would drop
   an opaque gradient slab over each panel. So it's gated behind support for
   the composite property rather than support for masks in general. */
@supports ((-webkit-mask-composite: xor) or (mask-composite: exclude)){
  .live::before,
  #castBtn::before,
  .transport::before,
  .drawer-container::before,
  .site-menu::before,
  .toast::before{
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    padding: 1px;
    background: var(--rim);
    -webkit-mask:
      linear-gradient(#fff 0 0) content-box,
      linear-gradient(#fff 0 0);
    mask:
      linear-gradient(#fff 0 0) content-box,
      linear-gradient(#fff 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    pointer-events: none;
    z-index: 1;
    transition: background 1.2s ease;
  }
}

/* Fallback rim for engines without mask-composite: a plain hairline. Less
   convincing than the gradient, but the panel still has a defined edge. */
@supports not ((-webkit-mask-composite: xor) or (mask-composite: exclude)){
  .live, #castBtn, .transport, .drawer-container, .site-menu, .toast{
    border: 1px solid rgba(255,255,255,0.16);
  }
}

/* Specular bloom + grain. Blend modes do the work: soft-light lets the
   highlight brighten what's behind it without washing it to flat white,
   and keeps the grain reading as texture rather than dirt. */
.transport::after,
.drawer-container::after,
.site-menu::after,
.toast::after{
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background-image:
    radial-gradient(120% 150% at 8% -20%, rgba(255,255,255,0.30), rgba(255,255,255,0) 55%),
    var(--grain);
  background-size: auto, 140px 140px;
  opacity: 0.45;
  mix-blend-mode: soft-light;
  pointer-events: none;
  z-index: 0;
}

/* Artwork gets the glare but not the grain — texture over a photograph just
   looks like a dirty screen. It's on the face rather than the scene so it
   turns with the card, which is what sells the flip.
   Two layers now, both static in themselves:
     1. A soft diagonal wash — the ambient "this is glass" character that's
        present all the time, at low enough opacity not to fight the art.
        (A tight radial hotspot used to sit here too, as the dominant layer.
        It read as a lens flare — a camera artifact — rather than a surface
        catching light, especially parked permanently over the logo where
        there's no motion to sell it as reflective rather than printed-on.
        Cut rather than dimmed: the swipe below is a better carrier for
        "glass" than a second static bright patch ever was.)
     2. The actual shimmer band. Rather than looping on a fixed timer, its
        position is driven by the --shimmer-pos custom property, which
        app.js steps through on a randomised schedule (scheduleArtShimmer):
        parked off past the edge and invisible, swiped in to rest visibly
        across the card for a bit — a real "catching the light" presence
        rather than a single flash — then continues in the SAME direction
        on out past the far edge, rather than reversing back the way it
        came in. It's snapped back to the start position with --shimmer-dur
        set to 0s (invisible either way, since both ends are off-frame) so
        the next swipe always enters from the same side. transition on a
        custom property, not animation/keyframes, is what lets three JS-set
        target values drive one continuous-feeling motion instead of a
        there-and-back loop.
   screen, not soft-light: this used to be soft-light, chosen specifically to
   avoid blowing out bright covers. That was the wrong trade — soft-light's
   strength depends on the backdrop's own luminance, so on the plain, dark,
   uniform logo it read as a clean, obvious highlight, but on a busy,
   variably-lit photograph it fragmented into the art's own texture instead
   of reading as one continuous sheet of glass sitting on top. That's what
   "the art looks flat" was: not too dim, just too content-dependent.
   screen only ever brightens (never the soft-light darkening some pixels
   get), and how MUCH it brightens is controlled purely by this layer's own
   alpha stops rather than by what's underneath — same visible presence on a
   near-black cover as on the navy logo. It also has no hard threshold the
   way overlay does (overlay maps every backdrop pixel over 50% luminance to
   the same blown-white result); screen's falloff is smooth, so a light
   cover dims gracefully instead of clipping. The alpha values below were
   tuned by ear against soft-light, so this reads a bit stronger now — turn
   the wash/band alphas down first if it starts to feel too strong, rather
   than reaching back for soft-light. */
.art-face::after{
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background-image:
    linear-gradient(146deg,
      rgba(255,255,255,0.70) 0%,
      rgba(255,255,255,0.27) 20%,
      rgba(255,255,255,0) 42%,
      rgba(255,255,255,0) 74%,
      rgba(255,255,255,0.22) 100%),
    linear-gradient(115deg,
      rgba(255,255,255,0) 37%,
      rgba(255,255,255,0.36) 50%,
      rgba(255,255,255,0) 63%);
  background-repeat: no-repeat;
  background-size: 100% 100%, 220% 220%;
  /* Not 0%/0% at rest. The band layer is sized to 220% 220% so it can be
     parked fully outside the box at -140%/140%, and CSS background-position
     percentages are resolved against (container − image) size, not the box
     itself — at this oversize, position 0% puts the image's own top-left
     corner (not its bright midpoint) at the box's top-left corner, which
     shoves the actual bright stop in the gradient off past the bottom-right
     edge entirely. 50% is the value that centers the oversized image (and
     therefore its bright stop) over the visible box; 140% carries that same
     bright stop on out past the opposite edge, continuing the same motion
     rather than reversing it. Verified by eye against the rendered card —
     the arithmetic is easy to get backwards here, so don't re-derive it from
     "0 felt like the obvious middle". */
  background-position: 0 0, var(--shimmer-pos, -140% -140%);
  mix-blend-mode: screen;
  pointer-events: none;
  z-index: 4;
  transition: background-position var(--shimmer-dur, 1.3s) cubic-bezier(0.4, 0, 0.2, 1);
}

/* Same glare on the dialog cards, as a fixed-position layer so it can't be
   dragged around by the card's internal scrolling. */
.faq-card::after,
.offline-card::after{
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: radial-gradient(120% 90% at 10% -10%, rgba(255,255,255,0.22), rgba(255,255,255,0) 52%);
  mix-blend-mode: soft-light;
  pointer-events: none;
  z-index: 0;
}
/* …which means the card's real content has to be lifted above it.
   .offline-close is deliberately NOT in this list: it's already
   position:absolute with its own z-index, and blanket-setting
   position:relative here would knock it out of the card's top-right corner
   and back into the content flow, on top of the header. */
.faq-header, .faq-list,
.offline-card > h2, .offline-card > p, .offline-card > i, #offline-refresh-btn{
  position: relative;
  z-index: 1;
}

/* Drawer handle and history rows sit above the panel's own highlight layer,
   otherwise soft-light would wash out the text. */
.drawer-toggle, .drawer-content{ z-index: 2; }
.site-menu-item{ z-index: 2; }

/* Backdrop dim behind the mobile sheet gets its own blur, so the player
   doesn't just darken — it recedes out of focus. */
@media (max-width: 999.98px), (orientation: portrait){
  .stage::after{
    background: rgba(10,11,13,0.5);
    backdrop-filter: blur(10px) saturate(120%);
    -webkit-backdrop-filter: blur(10px) saturate(120%);
  }
}

/* ---------- NO BACKDROP-FILTER ----------
   Firefox on some platforms, older Chromium, anything with GPU
   acceleration disabled. Without the blur, a 34%-opaque panel over a busy
   photo is unreadable — so the fills go dense enough to carry text on
   their own. The design degrades to flat dark cards, which is the correct
   thing for it to fall back to. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))){
  .live, #castBtn, .menu-toggle:hover, .menu-toggle[aria-expanded="true"]{
    background-color: rgba(20,22,27,0.88);
  }
  .transport, .drawer-container, .site-menu, .toast{
    background-color: rgba(16,18,23,0.92);
  }
  .faq-card, .offline-card{
    background-color: rgba(16,18,23,0.96);
  }
  .faq-modal, #stream-offline-modal{
    background: rgba(6,7,9,0.88);
  }
}

/* ---------- REDUCED TRANSPARENCY ----------
   An accessibility setting, not a preference about looks: users who turn
   this on often do so because translucent UI is genuinely hard for them to
   read. Blur stays (it helps contrast) but the panels go nearly solid. */
@media (prefers-reduced-transparency: reduce){
  .transport, .drawer-container, .site-menu, .toast{
    background-color: rgba(14,16,20,0.93);
  }
  .faq-card, .offline-card{
    background-color: rgba(14,16,20,0.97);
  }
  .live, #castBtn{ background-color: rgba(20,22,27,0.9); }
  /* .art-face::after, not .art::after — the art card's glare moved onto the
     face when the card became two-sided (so it turns with the card), and
     this list kept pointing at the old element. The selector matched nothing
     for as long as that was true, which meant the one surface with the
     strongest highlight on the page was also the only one that ignored the
     setting. */
  .transport::after, .drawer-container::after,
  .site-menu::after, .toast::after, .art-face::after{ display: none; }
}

/* ---------- MOTION PREFERENCES ---------- */
@media (prefers-reduced-motion: reduce){
  .bg-layer{ animation: none !important; }

  /* The three infinite loops that aren't the background. Each is small, but
     "small and endless" is precisely what this setting is for — a pulsing
     dot in the corner of the screen is the classic trigger. Killing the
     animation only removes the motion: the Live dot keeps its red fill, the
     casting button keeps its accent fill, so every state stays just as
     readable standing still.
     The connecting spinner is the exception that still needs to say
     something, since it means "working on it" rather than a status — so it
     holds a static ring instead of vanishing into an idle-looking button. */
  .live .dot{ animation: none !important; }
  #castBtn.casting{ animation: none !important; }
  #castBtn.connecting::after{
    animation: none !important;
    border-color: rgba(var(--accent-rgb), 0.55);
  }
  /* The shimmer band itself is a plain transition now (see .art-face::after
     above), so it's already flattened by the blanket transition-duration
     override below — nothing art-specific needed here. app.js also checks
     reducedMotion before ever scheduling a swipe, so --shimmer-pos never
     gets set in the first place. */
  *{ transition-duration: 0.01ms !important; }
}
