/* ================================================================
   Music Stream — landing page styles
   ================================================================ */

:root {
  --panel-bg: rgba(0, 0, 0, 0.55);
  --panel-blur: blur(12px);
  --accent: #6ee7ff;
  /* Background cover blur: a soft resting blur is ALWAYS on (the image never
     runs razor-sharp), and kiosk mode deepens it to 2× (--bg-blur-kiosk is
     exactly twice --bg-blur-rest, keeping the "twice as blurred in kiosk"). */
  --bg-blur-rest: blur(3px);
  --bg-blur-kiosk: blur(6px);
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: #000;
  color: #fff;
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  overflow: hidden;
}

/* ---------- z-index 0: background cover ---------- */
#bg-cover {
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  background: #000;
}
/* Both cover layers share the SAME animation in sync, so the 600 ms opacity
   crossfade between them stays seamless (the incoming layer is at the exact
   same transform as the outgoing one). `transform` (animation) and `opacity`
   (transition) are independent, so crossfades and the drift don't conflict.
   Clock + easing match the ti-card's 18 s zoom (both start at page load, so
   they stay locked): the background RECESSES as the card grows — card at
   scale(1) → bg at its nearest (1.09), card at scale(1.2) → bg at its
   farthest (1.00) with a small positional drift — adding depth to the
   counter-breathing scene. Both the card and this drift are smooth over the
   full 18 s (no mid-cycle bursts), and the vignette (.ti-bg) is static —
   nothing on the breath clock changes brightness. This breath is SIZE ONLY — no blur, no
   brightness/saturation changes anywhere in the cycle. The one other
   background change is tied to the kiosk state, not the breath clock: when
   the UI auto-hides (body.ui-hidden, after UI_IDLE_MS of no input) the
   background softens — 25% darker (brightness 0.75) and blurred to 2× the
   resting blur (6 px vs 3 px) — as a flat state change (no filter
   transition), and any activity (pokeUI → body loses ui-hidden) restores the
   resting blur + full brightness. Note the resting state already carries a
   soft 3 px blur (--bg-blur-rest) — the cover never runs razor-sharp. The static dim overlay
   (#bg-cover::after) is trimmed to 0.1 so it can't clip the image. Edge
   safety comes from the layer being oversized (inset: -300px) so the
   parallax drift never shows a transparent fringe at the screen edges. */
/* Smooth drift over the WHOLE cycle (3 keyframes + per-segment ease-in-out =
   a sine-like swell): the background eases from its nearest (1.09) to its
   farthest (1.00) over 9 s and back over 9 s. No mid-cycle slam — the scale
   change is always gradual, so there is never a quick 1–2 s zoom burst. */
@keyframes bg-breathe {
  0%   { transform: scale(1.09) translate(0%, 0%); }
  50%  { transform: scale(1.00) translate(-0.9%, 0.8%); }
  100% { transform: scale(1.09) translate(0%, 0%); }
}
.bg-layer {
  position: absolute;
  /* Oversized 300px past the viewport on every side: well past both the
     parallax translate and the 6 px kiosk blur, so no transparent fringe
     appears at the screen edges. */
  inset: -300px;
  background-size: cover;
  background-position: center;
  /* A soft resting blur is always on (the image never runs razor-sharp while
     the UI is showing); full natural brightness — the kiosk state below adds
     a dim on top of the deeper blur. NB: the visualizers don't sample this
     layer — they build their own undimmed inverted copy offscreen (see
     app.js, "inverted-cover color source"). */
  filter: var(--bg-blur-rest);
  transform: scale(1.06); /* static fallback (reduced-motion / pre-animation) */
  animation: bg-breathe 18s ease-in-out infinite; /* locked to the ti-card clock (transform only) */
  opacity: 0;
  transition: opacity 600ms ease; /* the cover crossfade */
}
/* Kiosk idle: once the UI auto-hides, the background deepens — 25% dimmer and
   blurred to 2× the resting blur (--bg-blur-kiosk vs --bg-blur-rest) — then any
   activity (pokeUI → body loses ui-hidden) eases it back to the resting blur.
   Applied as a flat state change (no filter transition) so a fresh cover's
   crossfade never blurs in. */
body.ui-hidden .bg-layer {
  filter: var(--bg-blur-kiosk) brightness(0.75);
}
#bg-cover.snap .bg-layer { transition: none; }
@media (prefers-reduced-motion: reduce) {
  .bg-layer { animation: none; } /* keep the static scale(1.06) */
  .ti-card { animation: none; } /* no breathing zoom */
  #track-intro.show .ti-cover,
  #track-intro.show .ti-text,
  #track-intro.show .ti-seekrow { animation: none; } /* no slide-in */
}
#bg-cover::after {
  content: "";
  position: absolute;
  inset: 0;
  /* Trimmed from 0.25: this static dim used to clip the bright end of the
     bg-breathe swing — the cover now gets pushed above full brightness at
     the near end, and this overlay can't afford to eat that back. */
  background: rgba(0, 0, 0, 0.1);
  pointer-events: none;
}

/* ---------- z-index 2: oscilloscope ---------- */
#viz {
  position: fixed;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  /* Stroke is painted with inverted-cover colors sampled offscreen (see
     "inverted-cover color source" in app.js) — no blend mode needed; the
     loudness pulse still rides on per-frame globalAlpha. */
  /* The old counter-breath (viz-breathe, a 18 s scaleY against the ti-card's
     zoom) is gone: the wave now holds its cover-locked size. The canvas
     stays full-bleed at all times; the TIMEBASE zoom (waveform showing 80%
     of its data at the card's max, still stretched to full page width) is
     still done in drawWave, which reads the card's live scale and shrinks
     the sample span accordingly. */
}

/* (The folders panel retired: folder selection now lives in the ti-card
   drawer stage — #genre-wrap, see the track-intro section below.) */
#panels {
  position: fixed;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  transition: opacity 400ms ease;
}
#panels > * { pointer-events: auto; }
/* Kiosk auto-hide: after UI_IDLE_MS of inactivity the whole UI fades out,
   leaving only the cover background (#bg-cover) and the oscilloscope (#viz).
   Any key/mouse input removes the class and resets the timer (see app.js). */
body.ui-hidden #panels {
  opacity: 0;
}
/* The card's text menu + drawer live OUTSIDE #panels (they belong to the
   card, which stays as the idle hero) — but their controls hide with the
   rest of the UI, fading over the same 400 ms. */
body.ui-hidden .ti-menu,
body.ui-hidden .ti-vol,
body.ui-hidden .ti-transport,
body.ui-hidden #ti-drawer {
  opacity: 0;
  pointer-events: none;
}
/* Also kill hit-testing on the children (they re-enable pointer-events via
   `#panels > *`) so the invisible bar can't capture the cursor or hover. */
body.ui-hidden #panels,
body.ui-hidden #panels * {
  pointer-events: none;
}
/* Kiosk mode: with the UI gone, hide the cursor too. Any movement/click
   removes `ui-hidden` (see app.js pokeUI), which restores the default cursor.
   The `*` form is needed because interactive elements set their own
   `cursor: pointer`, which would otherwise beat the inherited `none`. */
body.ui-hidden,
body.ui-hidden * { cursor: none; }

.panel {
  background: var(--panel-bg);
  backdrop-filter: var(--panel-blur);
  -webkit-backdrop-filter: var(--panel-blur);
  border-radius: 14px;
  border: 1px solid rgba(255, 255, 255, 0.08);
}

/* ---------- Folder selection (top-right, collapsible) ---------- */
#folders-tools {
  display: flex;
  gap: 8px;
  margin-bottom: 8px;
}
#folders-tools button {
  flex: 1;
  background: rgba(255, 255, 255, 0.1);
  color: #fff;
  border: 1px solid rgba(255, 255, 255, 0.15);
  border-radius: 8px;
  padding: 5px 8px;
  font-size: 0.8rem;
  cursor: pointer;
}
#folders-tools button:hover { background: rgba(255, 255, 255, 0.2); }
#folder-list label {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 2px;
  font-size: 0.95rem;
  cursor: pointer;
  border-radius: 6px;
}
#folder-list label:hover { background: rgba(255, 255, 255, 0.08); }
#folder-list input[type="checkbox"] {
  width: 18px;
  height: 18px;
  accent-color: var(--accent);
  cursor: pointer;
}
#folder-list .count { margin-left: auto; opacity: 0.55; font-size: 0.8rem; }

/* Transport buttons: prev/play/next are bare icons, play is a white disc. */
.tbtn {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: none;
  background: transparent;
  color: #fff;
  font-size: 1.15rem;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0.85;
  transition: transform 80ms ease, color 80ms ease, opacity 80ms ease, background 80ms ease;
}
.tbtn:hover { opacity: 1; color: var(--accent); }
.tbtn:active { transform: scale(0.92); }
.tbtn.play {
  width: 54px;
  height: 54px;
  font-size: 1.5rem;
  background: #fff;
  color: #000;
  opacity: 1;
}
.tbtn.play:hover { transform: scale(1.05); color: #000; }
.tbtn.play:active { transform: scale(0.97); }
/* The U+2016 pause glyph spans the full font height, so the two bars tower
   over the ⏮/⏭ icons. Squeeze them vertically (about the height of the
   prev/next icons); the white disc itself is untouched. */
.tbtn.play .pp-pause {
  display: inline-block;
  transform: scaleY(0.6);
}

/* Seek row (lives in the track-intro card, between the cover and the
   artist/title): elapsed ——— bar ——— total. The bar is exactly as wide as
   the cover art; the time labels flank it. */
.ti-seekrow {
  display: flex;
  align-items: center;
  gap: 10px;
  justify-content: space-between;
  /* The seek row (waveform + two time labels) is the widest card child. Cap it
     to --fit-width so, even at the card's max 1.2x breath, it stays on screen
     instead of clipping the times (and the waveform edges) off both sides. */
  max-width: var(--fit-width);
}
/* The waveform canvas + range live in a fixed-height row; the range's track is
   transparent so the canvas (drawn behind it) shows through, and its thumb is
   the visible playhead. */
.seek-wrap {
  position: relative;
  /* EXPLICIT width = the cover, so on desktop the waveform always spans
     exactly the cover's width (never squeezed narrower). flex-grow:0 keeps it
     from stretching PAST the cover; flex-shrink:1 + min-width:0 let it shrink
     below the cover only when the row is capped to --fit-width on a narrow
     screen, so the two time labels keep their width and nothing clips. */
  width: var(--cover-size);
  flex: 0 1 auto;
  min-width: 0;
  height: 44px;
  display: flex;
  align-items: center;
  border-radius: 6px;
  /* Keep the absolutely-positioned waveform canvas clipped to this row. At high
     browser zoom the canvas can otherwise paint larger than its CSS box and
     bleed over the UI; overflow:hidden makes that impossible. */
  overflow: hidden;
}
.ti-seekrow .time-time {
  font-size: calc(0.8rem * var(--ti-scale, 1));
  opacity: 0.85;
  flex: 0 0 auto; /* the times keep their width; the waveform is what flexes */
}
#wave {
  position: absolute;
  left: 0; top: 0;            /* explicit offsets (not just the inset shorthand) */
  width: 100%;
  height: 100%;
  pointer-events: none;
}
#seek {
  -webkit-appearance: none;
  appearance: none;
  position: relative;
  z-index: 2;
  width: 100%;
  height: 100%;
  margin: 0;
  background: transparent;
  cursor: pointer;
  /* The slider sits inside #track-intro, which is pointer-events: none so it
     never blocks the UI. Re-enable only the slider itself, and only while the
     intro is on screen — otherwise a hidden (opacity:0) slider would still
     swallow clicks in the middle of the page. */
  pointer-events: none;
}
#track-intro.show #seek { pointer-events: auto; }
#seek:focus { outline: none; }
/* Track: transparent (the canvas is the visual). Thumb: a thin white bar = playhead. */
#seek::-webkit-slider-runnable-track { -webkit-appearance: none; height: 100%; background: transparent; }
#seek::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 6px;
  height: 100%;
  margin-top: 0;
  border-radius: 3px;
  background: #fff;
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
  transition: width 120ms ease;
}
#seek:active::-webkit-slider-thumb, #seek:focus-visible::-webkit-slider-thumb { width: 8px; }
#seek::-moz-range-track { height: 100%; background: transparent; }
#seek::-moz-range-thumb {
  width: 6px;
  height: 100%;
  border: none;
  border-radius: 3px;
  background: #fff;
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.6);
}
.time-time {
  font-size: 0.72rem;
  opacity: 0.7;
  font-variant-numeric: tabular-nums;
  min-width: 38px;
  text-align: center;
}


/* Icon utility buttons (repeat, viz, search, explore, queue). */
.bb-btn {
  position: relative;
  width: 40px;
  height: 40px;
  border-radius: 10px;
  border: none;
  background: transparent;
  font-size: 1.15rem;
  line-height: 1;
  cursor: pointer;
  color: #fff;
  opacity: 0.85;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background 90ms ease, color 90ms ease, opacity 90ms ease;
}
/* Inline SVG button icon (e.g. the sign-out power symbol): stroke inherits
   the button's text color, and it's sized to roughly match the glyph buttons
   beside it. An SVG is used instead of a power glyph (U+23FB) because most
   mobile system fonts lack that code point (it renders as a missing-glyph
   box). */
.btn-ico {
  display: block;
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}
/* Mute button: two icons stacked, .muted swaps the visible one (JS
   toggles the class, see renderMuteButton). The speaker body is filled so
   it reads at a glance like the other solid glyphs. */
/* Only the mute speaker body is filled (a solid speaker reads at a glance);
   other .btn-ico glyphs (sign-out, queue shuffle) stay pure strokes. */
#btn-mute .btn-ico path:first-child { fill: currentColor; }
.bb-btn .ico-off { display: none; }
.bb-btn.muted .ico-on { display: none; }
.bb-btn.muted .ico-off { display: block; }
.bb-btn:hover { opacity: 1; background: rgba(255, 255, 255, 0.10); }
.bb-btn[aria-pressed="true"] { opacity: 1; color: var(--accent); }
/* A bottom-bar drawer is OPEN: a clearly filled accent pill so it's obvious
   the button is a toggle you can press again — or click outside / press Esc —
   to close the panel. Defined after :hover so it wins the background. */
.bb-btn.popup-open { opacity: 1; color: var(--accent); background: rgba(110, 231, 255, 0.22); }
.bb-btn.popup-open:hover { background: rgba(110, 231, 255, 0.32); }
/* Sleep timer is ARMED (a timer is set): an accent tint that persists whether
   or not its drawer is open. Kept separate from .popup-open so they don't clash. */
.bb-btn.sleep-armed { opacity: 1; color: var(--accent); }
input[type="range"] { accent-color: var(--accent); cursor: pointer; }

/* ---------- Search ---------- */
#search-wrap {
  position: absolute;
  right: 18px;
  bottom: 112px;
  width: min(420px, calc(100vw - 36px));
}
#search-input {
  width: 100%;
  padding: 10px 14px;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.2);
  background: var(--panel-bg);
  backdrop-filter: var(--panel-blur);
  -webkit-backdrop-filter: var(--panel-blur);
  color: #fff;
  font-size: 1rem;
  outline: none;
}
#search-results {
  list-style: none;
  margin: 6px 0 0;
  padding: 6px;
  background: var(--panel-bg);
  backdrop-filter: var(--panel-blur);
  -webkit-backdrop-filter: var(--panel-blur);
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.12);
  max-height: 320px;
  overflow-y: auto;
}
#search-results li {
  padding: 8px 10px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 0.95rem;
}
#search-results li:hover { background: rgba(255, 255, 255, 0.15); }
#search-results li .sr-sub { opacity: 0.6; font-size: 0.8rem; }

/* ---------- Explorer / Queue popups + shared browse rows ---------- */
#explore-wrap, #queue-wrap {
  position: absolute;
  right: 18px;
  bottom: 112px;
  width: min(420px, calc(100vw - 36px));
}
/* (The sleep timer drawer now lives inside #ti-drawer over the cover art —
   see the card drawer stage block below; it shares the .queue-bar header.) */
.explore-bar, .queue-bar {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  background: var(--panel-bg);
  backdrop-filter: var(--panel-blur);
  -webkit-backdrop-filter: var(--panel-blur);
  border-radius: 10px 10px 0 0;
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-bottom: none;
}
.explore-path { flex: 1; font-size: 0.85rem; opacity: 0.85; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.queue-title { flex: 1; font-weight: 600; font-size: 0.9rem; }
.exp-btn {
  flex: none;
  padding: 4px 9px;
  border-radius: 7px;
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: rgba(255, 255, 255, 0.08);
  color: #fff;
  cursor: pointer;
  font-size: 0.8rem;
}
.exp-btn:hover { background: rgba(255, 255, 255, 0.18); }
/* Toggle-able exp-btn (queue shuffle): accent when on. */
.exp-btn[aria-pressed="true"] {
  color: var(--accent);
  border-color: var(--accent);
  background: rgba(110, 231, 255, 0.22);
}
.exp-btn[aria-pressed="true"]:hover { background: rgba(110, 231, 255, 0.32); }
.browse-list {
  list-style: none;
  margin: 0;
  padding: 6px;
  background: var(--panel-bg);
  backdrop-filter: var(--panel-blur);
  -webkit-backdrop-filter: var(--panel-blur);
  border-radius: 0 0 10px 10px;
  border: 1px solid rgba(255, 255, 255, 0.12);
  max-height: 340px;
  overflow-y: auto;
}
.browse-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 7px 10px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 0.92rem;
}
.browse-row:hover { background: rgba(255, 255, 255, 0.15); }
.browse-main { flex: 1; min-width: 0; }
.browse-row.is-active { background: rgba(110, 231, 255, 0.12); }
/* Queue drawer: the row for the track playing right now. */
#queue-list li.queue-current {
  background: rgba(110, 231, 255, 0.12);
}
#queue-list li.queue-current .browse-title { color: var(--accent); }
.sleep-active { flex: none; color: var(--accent); font-weight: 700; }
.browse-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.browse-sub { opacity: 0.6; font-size: 0.76rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.browse-empty { padding: 10px; opacity: 0.6; font-size: 0.85rem; cursor: default; }
.mini-act {
  flex: none;
  width: 26px; height: 26px;
  border-radius: 7px;
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: rgba(255, 255, 255, 0.08);
  color: #fff;
  cursor: pointer;
  font-size: 0.9rem;
  display: flex; align-items: center; justify-content: center;
  line-height: 1;
}
.mini-act:hover { background: rgba(110, 231, 255, 0.25); border-color: var(--accent); }

/* Repeat button: 3-state badge + dimmed-off */
.bb-btn { position: relative; }
#btn-repeat[data-mode="0"] { opacity: 0.55; }
#btn-repeat::after {
  position: absolute;
  right: 3px; bottom: 2px;
  font-size: 0.55rem;
  font-weight: 700;
  line-height: 1;
  color: var(--accent);
  pointer-events: none;
}
#btn-repeat[data-mode="1"]::after { content: "1"; }
#btn-repeat[data-mode="2"]::after { content: "Q"; }
/* ---------- Status ---------- */
.status {
  position: absolute;
  top: 18px;
  left: 50%;
  transform: translateX(-50%);
  padding: 8px 18px;
  font-size: 0.9rem;
  pointer-events: none;
}

/* ---------- Queue toast (flash on add) ----------
   A brief top-centre pill that flashes in when one or more tracks land in
   the queue, so the change is noticeable even when the queue drawer isn't the
   open pane (JS in app.js). pointer-events: none so it never blocks taps. */
#queue-toast {
  position: fixed;
  top: 22px;
  left: 50%;
  transform: translateX(-50%) translateY(-10px) scale(0.96);
  z-index: 60;
  padding: 9px 20px;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.5);
  backdrop-filter: var(--panel-blur);
  -webkit-backdrop-filter: var(--panel-blur);
  border: 1px solid var(--accent);
  color: #fff;
  font-size: 0.9rem;
  font-weight: 600;
  letter-spacing: 0.03em;
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6);
  opacity: 0;
  pointer-events: none;
  transition: opacity 180ms ease, transform 180ms ease;
}
#queue-toast.show {
  opacity: 1;
  transform: translateX(-50%) translateY(0) scale(1);
}
/* Queue-bar pulse: a quick accent flash on the queue drawer's header when
   tracks are added while it's the open pane (re-triggered by re-adding the
   .flash class in JS). */
@keyframes queue-bar-flash {
  0%   { box-shadow: inset 0 0 0 2px var(--accent); background-color: rgba(110, 231, 255, 0.18); }
  100% { box-shadow: inset 0 0 0 0 transparent;    background-color: transparent; }
}
.queue-bar.flash { animation: queue-bar-flash 0.7s ease; }

/* ---------- Loading indicator ---------- */
#loader {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 40;
  pointer-events: none;
}
/* The `hidden` attribute's default `display:none` is overridden by the
   `display:flex` above, so re-assert it explicitly — otherwise the loader
   never disappears. */
#loader[hidden] { display: none; }
.loader-card {
  display: flex;
  align-items: center;
  gap: 18px;
  min-width: 280px;
  padding: 22px 26px;
  background: var(--panel-bg);
  backdrop-filter: var(--panel-blur);
  -webkit-backdrop-filter: var(--panel-blur);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 16px;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.45);
  animation: loader-in 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.loader-spinner {
  flex: 0 0 auto;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: 3px solid rgba(255, 255, 255, 0.18);
  border-top-color: #7fd8ff;
  animation: loader-spin 0.85s linear infinite;
}
.loader-text { display: flex; flex-direction: column; gap: 4px; min-width: 150px; }
#loader-label { font-size: 1.02rem; font-weight: 600; letter-spacing: 0.01em; }
.loader-sub { font-size: 0.8rem; opacity: 0.7; font-variant-numeric: tabular-nums; }
.loader-bar {
  height: 4px;
  width: 100%;
  margin-top: 4px;
  background: rgba(255, 255, 255, 0.14);
  border-radius: 3px;
  overflow: hidden;
}
.loader-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, #3fb6e6, #8fe3ff);
  border-radius: 3px;
  transition: width 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Compact: after the folder list has appeared, tuck a small pill low so it
   doesn't cover the UI while metadata keeps indexing in the background. */
#loader.compact { align-items: flex-end; padding-bottom: 150px; }
#loader.compact .loader-card {
  min-width: 230px;
  padding: 12px 18px;
  gap: 12px;
  align-items: center;
}
#loader.compact .loader-spinner { width: 24px; height: 24px; border-width: 2px; }
#loader.compact #loader-label { font-size: 0.9rem; }
#loader.compact .loader-bar { margin-top: 2px; }

@keyframes loader-spin { to { transform: rotate(360deg); } }
@keyframes loader-in {
  from { opacity: 0; transform: translateY(10px) scale(0.97); }
  to   { opacity: 1; transform: none; }
}

/* Short viewports (landscape phones): the card now carries menu + cover +
   volume + transport + seek + text, so give it a smaller cover to fit. */
@media (max-height: 620px) {
  /* Smaller cover so the whole card fits in a short height — every card row
     (menu, cover, seek, volume, transport) tracks --cover-size, so setting it
     once keeps them all matched. */
  /* Smaller cover (the --fit-width formula above re-solves itself: on a short,
     wide window the max() side is still roomy, so (b) — the 1.2x-fit cap —
     keeps the widest rows on screen automatically).
     The -4vh gives the card 2vh MORE free space top and bottom of the screen
     (the card is centred, so a 4vh shorter card adds 2vh to each margin);
     only vertical breathing room changes — horizontal padding is untouched. */
  .ti-card { --cover-size: calc(min(36vh, 260px) - 4vh); }
  .ti-menu { max-width: 92vw; } /* never let the 4 buttons overflow the viewport */
  /* Short viewports (landscape phones / small windows): the card is a stack
     of rows that must fit in a small HEIGHT, so pull the rows in tighter and
     use a smaller base text size (fitCardText() shrinks long text further if
     needed — it's always one line, so it can't push the card off-screen). */
  .ti-card { gap: clamp(8px, 1.5vmin, 14px); }
  .ti-artist-row { margin-bottom: 8px; }
  .ti-artist { font-size: clamp(0.65rem, 1.4vmin, 0.9rem); }
  .ti-title { font-size: clamp(1.3rem, 5vmin, 2.9rem); }
}

/* ---------- Mobile (below 600px) ---------- */
@media (max-width: 600px) {
  .time-time { min-width: calc(32px * var(--ti-scale, 1)); font-size: calc(0.7rem * var(--ti-scale, 1)); }
  .ti-seekrow .time-time { font-size: calc(0.75rem * var(--ti-scale, 1)); }
  /* Card volume row: keep the slider a comfortable thumb width on phones.
     (Transport button sizes are percentage-based — see .ti-transport above. */
  .ti-vol #volume { min-width: 0; }
}

/* ================================================================
   Track intro — big centred presentation of the incoming track
   (cover + artist + title). Persists (no timer fade-out; see
   showTrackIntro in app.js) and re-presents on every new track: the
   old card softly fades out (1.5 s) and the new one slides in over 1 s
   — cover from off-screen left, artist/title from off-screen right —
   while the card continuously "breathes" (slow zoom in/out). The
   backdrop eases with kiosk mode. pointer-events: none so it never
   blocks the UI; z-index 20 puts it above all panels but below the
   loader.
   ================================================================ */
#track-intro {
  position: fixed;
  inset: 0;
  /* iOS (viewport-fit=cover): the fixed box extends UNDER the notch/Dynamic
     Island and the home indicator, so centring in the full box eats the
     top/bottom margins. Insetting the flex content box by the safe-area
     insets makes the card centre in the actually-visible region instead
     (tad more breathing room on iPhones; Android's insets are 0, so its
     margins are unchanged). The .ti-bg darkening is absolute inset:0 and is
     NOT affected by padding, so it still covers the whole screen. */
  padding: env(safe-area-inset-top, 0) 0 env(safe-area-inset-bottom, 0) 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  opacity: 0;
  /* quick fade-in */
  transition: opacity 400ms ease;
}
/* long, soft fade-out when the class is removed (gentle decelerating tail so
   the end of a track's card dissolves rather than cuts) */
#track-intro:not(.show) { transition: opacity 4000ms cubic-bezier(0.33, 1, 0.68, 1); }
#track-intro.show { opacity: 1; }

.ti-bg {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 1500ms cubic-bezier(0.33, 1, 0.68, 1);
}
/* The darkening gradient lives on ::before (kept as its own element from
   earlier iterations). It is now STATIC — no opacity animation — so the
   screen never pulses brighter/darker on the breath clock. Only the parent
   .ti-bg opacity changes, and only with the kiosk state (below). */
.ti-bg::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(ellipse at center,
             rgba(0, 0, 0, 0.30) 0%, rgba(0, 0, 0, 0.72) 100%);
}
#track-intro.show .ti-bg { opacity: 0.45; }
/* Deepen the hero backdrop once the UI auto-hides (kiosk idle); the card
   itself stays up at all times — only the darkening behind it eases with the
   kiosk state, so the controls stay readable while you're interacting.
   Halved from opacity 1 so the kiosk state doesn't crush the background. */
body.ui-hidden #track-intro.show .ti-bg { opacity: 0.5; }

.ti-card {
  /* Single source of truth for the cover's size. The menu / seek / volume /
     transport rows all use this too, so they ALWAYS span exactly the cover's
     width. Media queries override this one variable to resize everything
     together (previously the rows and the cover could drift apart on mobile). */
  /* Desktop vertical padding: the card is centred, so making it shorter adds
     equal margin above and below. We want 4vh per side (8vh total) WITHOUT
     shrinking the cover much, so the 8vh comes from two sources:
       - cover:  -4vh (this line) — the visible "card size" shrinks by half
         of the desktop total;
       - gaps:   -0.8vh per gap x 5 gaps = -4vh (see the gap rule below).
     (The mobile queries below keep their own cover value and don't use this
     gap rule, so mobile stays at the 2% it was approved at.) */
  --cover-size: calc(min(46vmin, 380px) - 4vh);
  --card-pad: 6vw; /* horizontal padding of the card, shared with the fit math */
  /* Widest any card row is allowed to be. Two competing requirements:
     (a) never narrower than the seek row's natural width (cover + both time
         labels + gaps) — otherwise the waveform flexes SMALLER than the cover
         art and the row looks squeezed (the time labels take ~88px, so we
         allow cover + 170px with headroom);
     (b) no wider than what stays on screen at the card's max scale(1.2) breath:
         (row + 2*padding) * 1.2 <= 100vw  →  row <= (100vw - 2*pad) / 1.2.
     min() of the two gives "as wide as needed, capped to fit at max breath".
     On phones requirement (b) wins (rows get trimmed); on desktop (a) wins
     (rows keep their natural cover-matched width). fitCardText() reads the
     resolved value of this cap (via .ti-text's computed max-width). */
  --fit-width: min(
    calc(100vw / 1.2 - 2 * var(--card-pad)), /* (row + 2*pad) * 1.2 <= 100vw */
    max(var(--cover-size) + 170px, 60vw)
  );
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Tighter than the old 20–40 px: the card now carries the volume row,
     transport row, seek row and text below the cover, so keep them snug
     (especially on a small screen).
     The extra -0.8vh (x 5 gaps = 4vh) shortens the desktop card so it gains
     2vh more margin top & bottom, letting the cover stay bigger (see the
     --cover-size comment). The 8px floor keeps rows from touching. */
  gap: max(8px, calc(clamp(10px, 2.2vmin, 20px) - 0.8vh));
  text-align: center;
  padding: 0 var(--card-pad);
  opacity: 0;
  /* Base of the breathing zoom below (the start & end of each cycle). */
  transform: scale(1);
  /* Slight drop shadow over the whole card (cover + artist + title). */
  filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.45));
  transition: opacity 650ms ease; /* fade-in */
  /* The breathing zoom runs even while the card is hidden, so removing
     .show can never snap the transform mid-breath — the fade-out is pure
     opacity. Slowly zooms in to +20%, eases back out, repeats (≈9 s in,
     ≈9 s out, the same pace as the original 10 s zoom-in). */
  animation: ti-card-zoom 18s ease-in-out infinite;
}
/* soft fade-out only when the card is cleared for a NEW track */
#track-intro:not(.show) .ti-card { transition: opacity 4000ms cubic-bezier(0.33, 1, 0.68, 1); }
#track-intro.show .ti-card { opacity: 1; }
@keyframes ti-card-zoom {
  0%   { transform: scale(1); }
  50%  { transform: scale(1.2); }
  100% { transform: scale(1); }
}
/* Activity hold (see app.js pokeUI/hideUI): while the UI is showing (i.e. the
   user is active), the breathing zoom is dropped and the card eases to its
   largest breathe size (scale 1.2) within 1 s, then stays perfectly still —
   so exiting kiosk mode brings the card up to max size in under a second.
   JS pins the live scale first (no visible jump) and drives the 1 s ease via
   the inline transform. On idle, JS holds the card at its max breathe size
   (scale 1.2) and re-arms the animation at ti-card-zoom's 50% keyframe via
   a negative animation-delay, so kiosk mode starts breathing from the
   largest size with no visible jump. */
.ti-card.zoom-held {
  animation: none;
  transition: transform 1s ease;
}
/* 1 s entrance (restarts each time .show is (re-)added, i.e. each new track):
   the cover glides in from off-screen LEFT, the artist/title from off-screen
   RIGHT, both arriving at centre at the same moment. The base transform is
   the resting position (translateX(0)), so dropping the animation on fade-out
   can never snap anything. */
#track-intro.show .ti-cover {
  animation: ti-cover-in 1s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
#track-intro.show .ti-text {
  animation: ti-text-in 1s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
@keyframes ti-cover-in {
  0%   { transform: translateX(-110vw); opacity: 0; }
  25%  { opacity: 1; }
  100% { transform: translateX(0); opacity: 1; }
}
@keyframes ti-text-in {
  0%   { transform: translateX(110vw); opacity: 0; }
  25%  { opacity: 1; }
  100% { transform: translateX(0); opacity: 1; }
}
/* The seek row slides in from off-screen RIGHT, exactly like the
   artist/title (shares the ti-text-in keyframes). */
#track-intro.show .ti-seekrow {
  animation: ti-text-in 1s cubic-bezier(0.22, 0.61, 0.36, 1) both;
}

/* --ti-scale: mobile-only multiplier (set in the max-width:700px block). It
   bumps every card element EXCEPT the waveform 5% on phones — the waveform
   (.seek-wrap / #wave) stays cover-locked to the raw --cover-size. Desktop
   never sets it, so it defaults to 1 everywhere. */
.ti-cover {
  width: calc(var(--cover-size) * var(--ti-scale, 1));
  height: calc(var(--cover-size) * var(--ti-scale, 1));
  border-radius: 14px;
  background-color: rgba(22, 22, 24, 0.92);
  background-size: cover;
  background-position: center;
  box-shadow: 0 30px 90px rgba(0, 0, 0, 0.65);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
.ti-note {
  font-size: clamp(60px * var(--ti-scale, 1), 12vmin * var(--ti-scale, 1), 130px * var(--ti-scale, 1));
  color: rgba(255, 255, 255, 0.25);
}
.ti-cover.has-art .ti-note { display: none; }

/* ---------- Card text menu (above the cover art) ----------
   Genre / Browse Files / Queue / Search. Lives in the card (not #panels),
   so it inherits the card's size, centre position and breathing zoom —
   and it is the persistent control surface: it stays up when the rest of
   the UI auto-hides after UI_IDLE_MS of inactivity (kiosk idle). */
.ti-menu {
  transition: opacity 400ms ease;
  /* A little breathing room from the cover: the card's flex gap
     (clamp(10px, 2.2vmin, 20px)) applies between every child; this trims it
     in half (effective gap ≈ 5–10 px) so the row sits just off the cover. */
  margin-bottom: calc(-1 * clamp(5px, 1.1vmin, 10px));
  display: flex;
  /* Never wrap: the buttons stay on ONE line above the cover at every zoom
     level (shrinking via the clamp below rather than spilling under it). */
  flex-wrap: nowrap;
  /* Width matches the cover art; height hugs the buttons — the bar is a
     compact strip, not a square. The four buttons fill it edge-to-edge
     with NO gap, so they read as one joined panel. */
  width: calc(var(--cover-size) * var(--ti-scale, 1)); /* matches .ti-cover */
  border-radius: 9px;
  overflow: hidden; /* trims each button's corners to the bar's outline */
  pointer-events: auto;
}
.ti-menu-btn { display: flex; align-items: center; justify-content: center; } /* label centred; bar height comes from the buttons */
.ti-menu-btn:first-child { border-radius: 0; }
.ti-menu-btn:last-child { border-radius: 0; }
.ti-menu-btn {
  flex: 1; /* all four share the bar equally — no gaps, no ragged edges */
  background: rgba(0, 0, 0, 0.25); /* black @ 25% */
  border: none;
  color: rgba(255, 255, 255, 0.72);
  font: inherit;
  font-weight: 600;
  font-size: clamp(0.55rem * var(--ti-scale, 1), 1.6vmin * var(--ti-scale, 1), 0.9rem * var(--ti-scale, 1));
  letter-spacing: 0.1em;
  text-transform: uppercase;
  white-space: nowrap;
  padding: 9px 4px; /* padding sets the bar's height (just room for the labels) */
  cursor: pointer;
  transition: color 150ms ease, background 150ms ease;
}
.ti-menu-btn:hover { color: #fff; background: rgba(0, 0, 0, 0.4); }
.ti-menu-btn.popup-open {
  color: var(--accent);
  background: rgba(110, 231, 255, 0.16);
}

/* ---------- Card drawer stage (the cover's footprint) ----------
   #ti-drawer overlays exactly the cover area. One of its panes is
   unhidden at a time (openPopup/closePopup in app.js), so the chosen
   drawer expands in the place where the cover art is. */
.ti-cover-box { position: relative; display: flex; }
#ti-drawer {
  position: absolute;
  top: 0;
  left: 0;
  right: 0; /* width stays exactly the cover art's width */
  /* Height: at least the cover art's size (min-height: 100% of .ti-cover-box),
     growing with its content, capped (by JS, see fitTiDrawerHeight) so its
     bottom edge never passes the bottom of the page — long lists then
     scroll inside. */
  min-height: 100%;
  display: flex;
  flex-direction: column;
  text-align: left; /* .ti-card is text-align: center — don't inherit it */
  transition: opacity 260ms ease; /* fade when a drawer closes (see .closing) */
  border-radius: 14px;
  background: #000; /* solid — no opacity, no blur behind it */
  border: 1px solid rgba(255, 255, 255, 0.12);
  box-shadow: 0 30px 90px rgba(0, 0, 0, 0.65);
  overflow: hidden;
  pointer-events: auto;
  /* Paints above the seek bar (#seek, z-index 2) and the animated
     title/artist, which would otherwise draw over the drawer's top rows. */
  z-index: 5;
}
#ti-drawer[hidden] { display: none; }
/* JS adds .closing, waits for the fade, then sets [hidden] (see
   setTiDrawerVisible in app.js). */
#ti-drawer.closing { opacity: 0; pointer-events: none; }
/* The panes re-enable display (flex) below, which would beat the UA
   [hidden] rule — keep hidden panes actually hidden. */
#ti-drawer > [hidden] { display: none !important; }
/* Re-home the moved popups: static flow filling the drawer. */
#ti-drawer #genre-wrap,
#ti-drawer #search-wrap,
#ti-drawer #explore-wrap,
#ti-drawer #queue-wrap,
#ti-drawer #sleep-wrap {
  position: static;
  width: auto;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
#ti-drawer .pane-bar,
#ti-drawer .explore-bar,
#ti-drawer .queue-bar {
  flex: none;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  border: none;
  border-bottom: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 0;
  background: transparent;
}
/* Close (✕) button in each drawer's header row: pinned to the right edge. */
#ti-drawer .pane-close { flex: none; margin-left: auto; }
#ti-drawer #search-input {
  flex: 1;   /* fill the space left by the Clear button */
  min-width: 0;
  border: none;
  border-bottom: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 0;
  background: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
/* Textbox + Clear button share one row (Clear pinned to the right). */
#ti-drawer .search-row {
  flex: none;
  display: flex;
  align-items: center;
  gap: 8px;
}
#ti-drawer #search-clear {
  flex: none;
  font-size: 0.72rem;
  padding: 4px 8px;
}
/* The `hidden` attribute is toggled by JS (hidden while the box is empty);
   re-assert display:none because .exp-btn's display would beat the UA rule. */
#ti-drawer #search-clear[hidden] { display: none; }
/* The Queue "Clear" button is hidden while the queue is empty (JS toggles it). */
#queue-clear[hidden] { display: none; }
/* Queue shuffle/repeat icons sit inside small exp-btn pills; size to the text. */
#queue-shuffle .btn-ico, #queue-repeat .btn-ico { width: 16px; height: 16px; }
/* "Add all results to queue" bar: sits under the search box, above the
   results, and is only shown (JS removes `hidden`) when there are results. */
#ti-drawer #search-tools {
  flex: none;
  display: flex;
  justify-content: flex-end;
  padding: 4px 6px 0;
}
/* `display:flex` above would otherwise beat the UA `[hidden]` display:none. */
#ti-drawer #search-tools[hidden] { display: none; }
#ti-drawer #search-add-all {
  font-size: 0.72rem;
  padding: 3px 8px;
}
/* Result-count label: sits on the left, the add-all button on the right. */
.search-count {
  flex: 1;
  opacity: 0.6;
  font-size: 0.72rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
#ti-drawer #search-results {
  flex: 1;
  min-height: 0;
  max-height: none;
  margin: 0;
  padding: 6px;
  border: none;
  border-radius: 0;
  background: transparent; /* the drawer is solid black — no frosted sub-panels */
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  /* Search can return every match in the library, so scroll the list rather
     than letting it grow past the drawer. */
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
#ti-drawer #folders-tools { flex: none; padding: 6px; }
#ti-drawer #folder-list {
  flex: 1;
  min-height: 0;
  padding: 4px 6px 6px;
  overflow-y: auto;
}
#ti-drawer .browse-list {
  flex: 1;
  min-height: 0;
  max-height: none;
  border: none;
  border-radius: 0;
  background: transparent; /* the drawer is solid black — no frosted sub-panels */
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
/* Never intercept touches while the card is faded out (before the first
   track is presented) — the invisible buttons must not ghost-click. */
#track-intro:not(.show) .ti-menu,
#track-intro:not(.show) .ti-vol,
#track-intro:not(.show) .ti-transport,
#track-intro:not(.show) #ti-drawer { pointer-events: none; }

/* ---------- Card volume row + transport row (moved from the bottom bar) ----------
   Both sit under the artist/title. The volume row is exactly the cover
   art's width; both fade with the other card controls in kiosk mode and
   never ghost-click while the card is faded out. */
.ti-vol {
  display: flex;
  align-items: center;
  gap: 10px;
  width: calc(var(--cover-size) * var(--ti-scale, 1)); /* matches .ti-cover */
  pointer-events: auto;
  transition: opacity 400ms ease;
}
.ti-vol #volume { flex: 1; width: auto; min-width: 0; }
.ti-transport {
  display: flex;
  align-items: center;
  /* Exactly the cover art's width: repeat sits at the cover's left edge,
     sleep at its right edge, and the five buttons space out between. */
  width: calc(var(--cover-size) * var(--ti-scale, 1)); /* matches .ti-cover */
  justify-content: space-between;
  pointer-events: auto;
  transition: opacity 400ms ease;
}
/* The five buttons size as a percentage of the row (which tracks the cover
   art), so they always fit on one line at any screen size — the default
   flex-shrink would otherwise squash them into ellipses and push them past
   the edges. The px caps keep the previous fixed sizes on large screens,
   where the row is wide enough for them. */
.ti-transport .tbtn,
.ti-transport .bb-btn {
  flex: 0 0 auto;
  height: auto;
  aspect-ratio: 1; /* keep them perfectly circular at any size */
}
/* Percentages track the (mobile-scaled) row width; the px caps scale too so
   they don't clamp the buttons early on larger phones. */
.ti-transport .tbtn { width: min(19%, calc(44px * var(--ti-scale, 1))); }
.ti-transport .tbtn.play { width: min(25%, calc(56px * var(--ti-scale, 1))); }
.ti-transport .bb-btn { width: min(15%, calc(40px * var(--ti-scale, 1))); }

.ti-text { max-width: var(--fit-width); }
/* Single-line artist/title: the font sizes below are the BASE size; if the
   text is too long for the card's width, fitCardText() in app.js shrinks it
   (inline font-size) just enough to keep it on ONE line — so a long title can
   never wrap, grow the card, or push the controls off the screen on mobile. */
/* Artist row: [folder] • ARTIST • [codec]. The two 1fr side cells are always
   equal, so the artist column stays dead-centre no matter how long the sides
   are. Each side cell is a flex row stretched to the artist's height: the
   label hugs the outer edge, and the bullet's auto margins split the
   remaining space evenly — so the bullet sits exactly in the middle of the
   gap between the label and the artist, vertically centred on the row. A dot
   whose neighbour is empty is visibility:hidden (not display:none) so its
   space is preserved and the artist stays centred. fitCardText() shrinks long
   artists (ARTIST_FRAC). */
.ti-artist-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  min-width: 0;
  margin-bottom: calc(12px * var(--ti-scale, 1));
}
/* Each side cell is pinned to the artist: a fixed gap between the bullet and
   the artist, a fixed gap between the bullet and its label. (The cells still
   stretch to fill the 1fr column, so the 1fr|auto|1fr grid keeps the artist
   dead-centre — but the content hugs the artist instead of floating in the
   leftover space, so the layout is the same width for every track. */
.ti-side-cell {
  display: flex;
  align-items: center;   /* bullet + label share the row's vertical centre */
  gap: clamp(10px, 2vmin, 22px);  /* constant bullet↔label gap */
  min-width: 0;
  padding: 0 clamp(4px, 0.9vmin, 10px); /* constant bullet↔artist gap */
}
.ti-folder-cell { justify-content: flex-end; }   /* folder + bullet hug the artist */
.ti-codec-cell { justify-content: flex-start; }  /* bullet + codec hug the artist */
.ti-dot {
  min-width: 0;
  white-space: nowrap;
  flex: none; /* the bullet never shrinks; the label ellipsizes first */
  /* A round bullet (•) reads bigger than the old middle dot, so size it a
     touch smaller to stay a quiet separator. */
  font-size: clamp(0.44rem * var(--ti-scale, 1), 0.95vmin * var(--ti-scale, 1), 0.58rem * var(--ti-scale, 1));
  /* A touch dimmer than the side labels so the dots read as quiet separators. */
  color: rgba(226, 238, 250, 0.26);
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6);
}
.ti-side {
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  text-transform: uppercase;
  letter-spacing: 0.18em;
  font-size: clamp(0.52rem * var(--ti-scale, 1), 1.1vmin * var(--ti-scale, 1), 0.68rem * var(--ti-scale, 1));
  /* Muted neutral, well under the accent artist, so it reads as quiet
     context rather than competing for attention. */
  color: rgba(226, 238, 250, 0.34);
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6);
}
/* Labels hug their outer edge and shrink (ellipsis) before squeezing the
   bullet; the bullet never shrinks. */
.ti-side { flex: 0 1 auto; }
.ti-artist {
  text-transform: uppercase;
  letter-spacing: 0.28em;
  font-size: clamp(0.8rem * var(--ti-scale, 1), 1.6vmin * var(--ti-scale, 1), 1.05rem * var(--ti-scale, 1));
  color: var(--accent);
  min-width: 0; /* let the grid shrink this column; fitCardText() keeps it one line */
  text-shadow: 0 2px 12px rgba(0, 0, 0, 0.7);
  white-space: nowrap;
}
.ti-title {
  font-size: clamp(1.9rem * var(--ti-scale, 1), 6vmin * var(--ti-scale, 1), 4.2rem * var(--ti-scale, 1));
  font-weight: 700;
  line-height: 1.08;
  text-shadow: 0 4px 24px rgba(0, 0, 0, 0.7);
  white-space: nowrap;
}
@media (max-width: 700px) {
  /* Narrow phones: size the cover (and, via --cover-size, every card row)
     off the viewport WIDTH so the menu / volume / transport span exactly to
     the cover's edges instead of staying narrower. Side padding tightens;
     the --fit-width formula above then caps the widest rows so
     (row + 2*padding) * 1.2 <= 100vw at max breath. */
  /* -4vh: adds 2vh more vertical padding between the card and each screen
     edge (the card is centred, so shrinking it by 4vh adds 2vh top AND
     bottom). Every row tracks --cover-size, so the whole card shrinks by
     4vh together. Horizontal padding (--card-pad) is unchanged. */
  .ti-card { --cover-size: calc(min(64vw, 260px) - 4vh); --card-pad: 3vw; }
  /* Mobile: bump every card element EXCEPT the waveform 5% (see the
     --ti-scale comment at .ti-cover). --cover-size itself is untouched, so
     the waveform keeps its approved size. */
  .ti-card { --ti-scale: 1.05; }
  /* The 4 menu buttons share that cover width 4-ways; trim their padding and
     letter-spacing so the longest labels (Browse / Search) always fit on one
     line with a little headroom, instead of the right edge getting clipped. */
  .ti-menu-btn { padding: calc(8px * var(--ti-scale)) calc(2px * var(--ti-scale)); letter-spacing: calc(0.02em * var(--ti-scale)); }
  /* Phones: hide the folder/codec side labels so the artist row spans the
     cover's FULL width (fitCardText() sees display:none cells → width 0 and
     gives the artist the whole row). */
  .ti-folder-cell, .ti-codec-cell { display: none; }
  /* Phones: the artist shrinks its font to fit its share of the row
     (fitCardText() in app.js) rather than scroll, so the row stays the cover's
     width with the artist dead-centre, the folder tucked to its right and the
     codec to its left — the same centred look as desktop. The folder/codec
     labels themselves keep their natural size (never dynamically shrunk). */
  .ti-side { font-size: clamp(0.46rem * var(--ti-scale), 1.5vw * var(--ti-scale), 0.62rem * var(--ti-scale)); }
}

/* ---------- Auth gate (login / first-visit password setup) ----------
   Only rendered by index.php when there is no valid session. It reuses the
   app's dark + accent look. body.auth-login overrides the app's overflow so
   the card can scroll on short screens. */
body.auth-login {
  overflow: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  padding: 24px;
  background: radial-gradient(120% 120% at 50% 0%, #10202a 0%, #05070a 60%, #000 100%);
}
.login-card {
  width: min(92vw, 380px);
  background: rgba(0, 0, 0, 0.6);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 16px;
  padding: 30px 26px 26px;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
}
.login-title {
  margin: 0 0 4px;
  font-size: 1.5rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  text-align: center;
}
.login-sub {
  margin: 0 0 22px;
  text-align: center;
  color: rgba(255, 255, 255, 0.55);
  font-size: 0.9rem;
}
.login-label {
  display: block;
  margin: 14px 0 6px;
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.6);
}
.login-input {
  width: 100%;
  padding: 11px 12px;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.14);
  background: rgba(255, 255, 255, 0.05);
  color: #fff;
  font: inherit;
  font-size: 1rem;
  outline: none;
  transition: border-color 150ms ease, background 150ms ease;
}
.login-input:focus {
  border-color: var(--accent);
  background: rgba(255, 255, 255, 0.08);
}
.login-btn {
  width: 100%;
  margin-top: 20px;
  padding: 12px 14px;
  border: none;
  border-radius: 10px;
  background: var(--accent);
  color: #04121a;
  font: inherit;
  font-weight: 700;
  font-size: 1rem;
  letter-spacing: 0.02em;
  cursor: pointer;
  transition: filter 150ms ease, transform 80ms ease;
}
.login-btn:hover { filter: brightness(1.08); }
.login-btn:active { transform: translateY(1px); }
.login-hint {
  margin: 20px 0 0;
  color: rgba(255, 255, 255, 0.7);
  font-size: 0.92rem;
  line-height: 1.4;
}
.login-rule {
  margin: 10px 0 0;
  color: rgba(255, 255, 255, 0.4);
  font-size: 0.8rem;
}
.login-error {
  margin: 0 0 4px;
  padding: 10px 12px;
  border-radius: 10px;
  background: rgba(255, 92, 92, 0.14);
  border: 1px solid rgba(255, 92, 92, 0.4);
  color: #ff9a9a;
  font-size: 0.9rem;
  line-height: 1.35;
}
.login-notice {
  margin: 0 0 4px;
  padding: 10px 12px;
  border-radius: 10px;
  background: rgba(110, 231, 255, 0.12);
  border: 1px solid rgba(110, 231, 255, 0.35);
  color: var(--accent);
  font-size: 0.9rem;
}
.login-back { margin-top: 18px; }
.login-linkbtn {
  width: 100%;
  padding: 8px;
  border: none;
  background: none;
  color: rgba(255, 255, 255, 0.5);
  font: inherit;
  font-size: 0.85rem;
  text-decoration: underline;
  cursor: pointer;
}
.login-linkbtn:hover { color: rgba(255, 255, 255, 0.8); }

/* Sign-out control in the card's volume row. display:contents makes the
   wrapping form invisible to the flex row so its button lines up with the
   other .bb-btn controls (mute) and stays on one line. */
.ti-signout { display: contents; }
#btn-signout { flex: 0 0 auto; }
