:root {
  color-scheme: dark;
  --bg: #121314;
  --card: #1c1e1f;
  --border: #2c2f31;
  --text: #f2f2f2;
  --muted: #9a9d9f;
  --accent: #63b2ff;
  --preorder: #e8a33d;
  /* Refined triad for Like/Skip/Dislike -- desaturated ~20-30% off
     fully-saturated brand-style greens/reds for a near-black
     background (avoids the "vibrating" look a punchy hue gets there),
     jewel-toned rather than primary-color, kept far enough apart in
     hue from each other and from --play-this/--gem/--accent to stay
     instantly distinguishable at a glance. */
  --like: #4fbf8c;
  --skip: #d6a25c;
  --dislike: #d97490;
  --play-this: #b886ff;
  --new-artist: #40d0d8;
  --gem: #b18cff;
  --hidden-gem: #ffd166;
  /* Chartreuse -- reads as "toxic/mutation" (the 🧬 tab), and distinct
     from --like's muted sea-green (this is a totally different, much
     more saturated hue, not a shade of it) so the two never blur
     together even though both mean "you'd probably like this". */
  --mutant: #b6ff3c;
  /* Bandcamp's own brand blue -- the one colour here that isn't ours to
     pick. Used only on the player bar's "open on Bandcamp" link. */
  --bandcamp: #1da0c3;
  /* Same hue family as --accent (#63b2ff, a light sky blue already used
     for Discovery/Taste Buddy) rather than a totally different blue, so
     it reads as an intentional deeper shade of the same family, not a
     clashing one -- just dense/rich enough to still be its own signal. */
  --recent-release: #2f7dd6;
  /* Cover-chip sizing fallback for chips rendered OUTSIDE a cover
     (e.g. the standalone label badge on cover-less cards) -- inside
     .cover-wrap these are overridden with container-query units so
     every chip scales with the actual card width (browser resizes and
     the card-size setting both just work). */
  --chip: 34px;
  --chip-font: 1rem;
  /* The fixed centered shell (researched live: SoundCloud runs a 1240px
     centered column with a full-bleed player bar whose controls align
     to it; Bandcamp constrains content to ~1020px). Content never
     stretches wider -- a bigger display gets symmetric margins, and the
     cards-per-row counts stay FIXED per size instead of drifting with
     the window. */
  --shell-max: 1240px;
  /* The player bar is full-bleed CHROME (like SoundCloud's, whose bar
     content spans the whole window, NOT the narrow page column) -- it
     gets its own, much wider content cap so all its controls fit on one
     row. Capped only so an ultrawide display doesn't fling the cover and
     the close button to opposite edges. */
  --bar-max: 1720px;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  padding: 0 24px 48px;
  background: var(--bg);
  color: var(--text);
  /* "Helvetica Neue" leads the stack to match the embedded Bandcamp
     player's own font (confirmed by inspecting its live computed
     styles) -- the system-font stack stays as the fallback for
     platforms without it. */
  font-family: "Helvetica Neue", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}

/* Form controls do NOT inherit font-family by default -- without this,
   every button/input still renders the system UI font instead of the
   embed-matching stack above, which is exactly the mismatch the "match
   the embed font" pass kept finding one element at a time. */
button, input, select, textarea {
  font-family: inherit;
}

/* Pjax tab switching: while the next page is being fetched, the current
   one dims slightly instead of white-flashing away, then the swapped-in
   content fades back to full. */
#app-root {
  transition: opacity 0.18s ease;
  /* The centered shell: everything (header, tabs, banners, grid) lives
     in one fixed-max column that stays centered as the display grows --
     see --shell-max. */
  max-width: var(--shell-max);
  margin-left: auto;
  margin-right: auto;
}
#app-root.page-swapping {
  opacity: 0.4;
}

header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 28px 0 12px;
}

.header-actions {
  display: flex;
  align-items: center;
  gap: 10px;
}

.email-range {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  color: var(--muted);
  font-size: 0.75rem;
  margin: -6px 0 16px;
}

.scan-earlier-button {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--accent);
  font-weight: 500;
  padding: 3px 10px;
  font-size: 0.72rem;
  border-radius: 999px;
}

.scan-earlier-button:hover { border-color: var(--accent); opacity: 1; }
.scan-earlier-button:disabled { opacity: 0.5; }

.hidden { display: none !important; }

/* Same pill language as .info-toggle-btn ("About this tab") -- these
   two sit near each other on several tabs and used to differ in shape
   (6px rect vs 999px pill), font size, and text color for no reason. */
.discover-button {
  display: inline-block;
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text);
  font-weight: 500;
  padding: 6px 14px;
  font-size: 0.8rem;
  border-radius: 999px;
  text-decoration: none;
  cursor: pointer;
}

.discover-button:hover { border-color: var(--accent); color: var(--accent); opacity: 1; }
.discover-button:disabled { opacity: 0.5; cursor: not-allowed; }

/* Header action pair: "New releases" is THE button of the app (filled
   accent), "Rescan all" its quiet sibling. Shared geometry so the
   header never shifts when a label swaps to Scanning…/Stop. */
.header-btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text);
  font-family: inherit;
  font-weight: 600;
  font-size: 0.82rem;
  line-height: 1;
  padding: 9px 16px;
  border-radius: 999px;
  cursor: pointer;
  white-space: nowrap;
  transition: border-color 0.15s ease, filter 0.15s ease, color 0.15s ease,
              transform 0.06s ease;
}
.header-btn .ui-icon.inline { margin-right: 0; vertical-align: middle; }
.header-btn:hover { border-color: var(--accent); color: var(--accent); }
.header-btn:active { transform: scale(0.97); }
.header-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.header-btn-primary {
  background: var(--accent);
  border-color: var(--accent);
  color: #0b0b0c;
}
.header-btn-primary:hover {
  filter: brightness(1.1);
  color: #0b0b0c;
  border-color: var(--accent);
}

/* Refresh-cap gate text (You may like this) -- sits right of the
   Refresh button, only visible while the cap actually blocks it. */
.refresh-gate-text {
  font-size: 0.82rem;
  color: var(--muted);
}

.gems-tab-header {
  margin-bottom: 16px;
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
}

.gems-tab-header .discover-button {
  border-color: rgba(177, 140, 255, 0.4);
  color: var(--gem);
  font-weight: 600;
  padding: 10px 18px;
  font-size: 0.9rem;
}

.gems-tab-header .discover-button:hover {
  border-color: var(--gem);
  color: var(--gem);
}

/* "Mutant For You" tab's own button, inside the same .gems-tab-header
   layout as Gems' -- chartreuse instead of gem-violet so the two tabs'
   headers don't read as the same feature. */
.gems-tab-header .discover-button#embed-btn {
  border-color: rgba(182, 255, 60, 0.4);
  color: var(--mutant);
}
.gems-tab-header .discover-button#embed-btn:hover {
  border-color: var(--mutant);
  color: var(--mutant);
}
.mutant-auc-readout {
  font-size: 0.82rem;
  color: var(--muted);
  cursor: help;
}

.refresh-progress {
  margin: 0 0 16px;
}

.refresh-progress-track {
  height: 6px;
  border-radius: 999px;
  background: var(--card);
  border: 1px solid var(--border);
  overflow: hidden;
  position: relative;
}

.refresh-progress-bar {
  height: 100%;
  width: 0%;
  background: var(--accent);
  border-radius: 999px;
  transition: width 0.3s ease;
}

.refresh-progress.indeterminate .refresh-progress-bar {
  width: 30%;
  animation: refresh-indeterminate 1.1s ease-in-out infinite;
}

@keyframes refresh-indeterminate {
  0% { margin-left: -30%; }
  100% { margin-left: 100%; }
}

.refresh-progress-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 6px;
}

.refresh-progress-label {
  font-size: 0.78rem;
  color: var(--muted);
}

.refresh-progress-reason {
  font-size: 0.72rem;
  color: var(--muted);
  opacity: 0.75;
  margin-top: 3px;
  font-style: italic;
}

.refresh-progress-reason.hidden { display: none; }

.cancel-scan-button {
  background: transparent;
  border: 1px solid rgba(255, 156, 156, 0.4);
  color: #ff9c9c;
  font-size: 0.75rem;
  padding: 3px 10px;
  border-radius: 999px;
  flex-shrink: 0;
}

.cancel-scan-button:hover {
  border-color: #ff9c9c;
  opacity: 1;
}

button:disabled { opacity: 0.6; cursor: default; }

.settings {
  position: relative;
}

.icon-button {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text);
  font-size: 1rem;
  line-height: 1;
  padding: 8px 10px;
  border-radius: 6px;
}

.icon-button:hover { border-color: var(--accent); color: var(--accent); opacity: 1; }

.settings-panel {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 12px 14px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-width: 190px;
  z-index: 10;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}

.settings-panel.hidden { display: none; }

.settings-panel label {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.82rem;
  color: var(--text);
  white-space: nowrap;
}

.settings-divider {
  height: 1px;
  background: var(--border);
  margin: 4px 0;
}

.settings-group-label {
  font-size: 0.7rem;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

/* Account menu (top-right avatar + dropdown). The avatar is the user's
   Bandcamp profile photo, or a monogram fallback for an account that
   hasn't connected Bandcamp yet (e.g. the migrated owner). */
.user-menu {
  position: relative;
  margin-left: 2px;
}

.user-avatar-btn {
  background: transparent;
  border: none;
  padding: 0;
  cursor: pointer;
  border-radius: 50%;
  line-height: 0;
}

.user-avatar {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  object-fit: cover;
  border: 1px solid var(--border);
  display: inline-block;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.user-avatar-btn:hover .user-avatar,
.user-menu-panel:not(.hidden) ~ .user-avatar-btn .user-avatar {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent);
}

.user-avatar-fallback {
  background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 55%, #000));
  color: #0b0b0c;
  font-weight: 700;
  font-size: 0.95rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  text-transform: uppercase;
}

.user-menu-panel {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px;
  min-width: 220px;
  z-index: 20;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}

.user-menu-panel.hidden { display: none; }

.user-menu-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 8px 10px;
}

.user-menu-avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  object-fit: cover;
  border: 1px solid var(--border);
  flex: 0 0 auto;
}

.user-menu-id {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.user-menu-welcome {
  font-size: 0.72rem;
  color: var(--muted);
}

.user-menu-name {
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.user-menu-divider {
  height: 1px;
  background: var(--border);
  margin: 2px 4px 6px;
}

/* "New releases from" row: label text + its select on one line. The
   select echoes the panel inputs (quiet border, accent on focus). */
.user-menu-item.user-menu-scan-sources {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  cursor: default;
}
.user-menu-scan-sources select {
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-family: inherit;
  font-size: 0.8rem;
  padding: 3px 6px;
  cursor: pointer;
}
.user-menu-scan-sources select:focus { outline: 1px solid var(--accent); }

.user-menu-item {
  display: block;
  width: 100%;
  text-align: left;
  background: transparent;
  border: none;
  color: var(--text);
  font-family: inherit;
  font-size: 0.85rem;
  padding: 9px 10px;
  border-radius: 6px;
  cursor: pointer;
  text-decoration: none;
  box-sizing: border-box;
}

.user-menu-item:hover { background: var(--bg); color: var(--accent); }

/* Two small buttons under the collection-info line, not two more
   full-width text-styled rows -- see #collection-section. */
.collection-actions {
  display: flex;
  gap: 6px;
  padding: 2px 10px 6px;
}

.collection-action-btn {
  flex: 0 0 auto;
  background: transparent;
  border: 1px solid var(--border);
  color: var(--muted);
  font-family: inherit;
  font-size: 0.76rem;
  font-weight: 600;
  padding: 5px 12px;
  border-radius: 999px;
  cursor: pointer;
}

.collection-action-btn { white-space: nowrap; }
.collection-action-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.collection-action-btn:disabled { opacity: 0.5; cursor: default; }

.user-menu-highlight {
  color: var(--accent);
  font-weight: 600;
}

.user-menu-logout { margin: 0; }

/* "Verify your Bandcamp" -- the modal shell comes from game.css
   (.game-overlay/.game-modal, always linked); only the bits unique to
   the verify flow live here. */
.user-menu-verified { color: var(--muted); cursor: default; }
.user-menu-verified:hover { background: transparent; color: var(--muted); }

.verify-steps {
  margin: 0;
  padding-left: 20px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  font-size: 0.9rem;
  line-height: 1.5;
  text-align: left;
}

.verify-code {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
}
.verify-code.hidden { display: none; }
.verify-code code {
  font-size: 1.05rem;
  font-weight: 700;
  letter-spacing: 1.5px;
  background: var(--bg);
  border: 1px solid var(--border);
  padding: 6px 12px;
  border-radius: 8px;
  user-select: all;
}

.verify-copy {
  border: 1px solid var(--border);
  background: transparent;
  color: var(--muted);
  font-family: inherit;
  font-size: 0.8rem;
  padding: 6px 10px;
  border-radius: 7px;
  cursor: pointer;
}
.verify-copy:hover { border-color: var(--accent); color: var(--accent); }

.verify-status { min-height: 1.3em; }
/* The success state: the "you can delete the code now" reminder, stood up
   so it reads as a result, not a passing status line. */
.verify-status-success { color: var(--accent); font-weight: 600; }

/* Tiny "RC" mark next to a taste buddy who is themselves a VERIFIED
   ReleaseCatcher user (see app.py's _tag_rc_users) -- ReleaseCatcher
   blue on purpose, that's the tell. Details live in its data-tooltip. */
.rc-user-badge {
  display: inline-block;
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.5px;
  line-height: 1;
  color: var(--accent);
  border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
  border-radius: 4px;
  padding: 2px 4px;
  margin-left: 4px;
  vertical-align: 1px;
  cursor: default;
}

/* Taste Buddies dropdown -- same toggle-panel shape as .settings/
   .settings-panel just above, wider to fit a buddy list (photo + name
   + counts per row, matching the "design like the labels" ask --
   compare .label-badge). */
.taste-buddies {
  position: relative;
}

.taste-buddy-panel {
  position: absolute;
  top: calc(100% + 8px);
  right: 0;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 12px 14px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: 320px;
  max-width: calc(100vw - 32px);
  z-index: 10;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}

.taste-buddy-panel.hidden { display: none; }

.taste-buddy-panel-label {
  font-weight: 600;
  font-size: 0.9rem;
}

.taste-buddy-panel-hint {
  font-size: 0.76rem;
  color: var(--muted);
  line-height: 1.4;
}

.taste-buddy-add-form {
  display: flex;
  gap: 6px;
}

.taste-buddy-add-form input {
  flex: 1;
  min-width: 0;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  padding: 6px 8px;
  font-size: 0.8rem;
  font-family: inherit;
}

.taste-buddy-add-form button {
  background: transparent;
  border: 1px solid rgba(184, 134, 255, 0.5);
  color: var(--play-this);
  border-radius: 6px;
  padding: 6px 12px;
  font-size: 0.8rem;
  font-weight: 600;
  cursor: pointer;
  white-space: nowrap;
}

.taste-buddy-add-form button:hover { background: rgba(184, 134, 255, 0.15); }

.taste-buddy-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-height: 260px;
  overflow-y: auto;
}

.taste-buddy-row {
  display: flex;
  align-items: center;
  gap: 8px;
}

.taste-buddy-row.taste-buddy-inactive { opacity: 0.5; }

.taste-buddy-photo {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  object-fit: cover;
  flex-shrink: 0;
}

.taste-buddy-photo-fallback {
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg);
  border: 1px solid var(--border);
  font-size: 0.8rem;
}

.taste-buddy-text {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.taste-buddy-name {
  font-size: 0.82rem;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.taste-buddy-counts {
  font-size: 0.68rem;
  color: var(--muted);
}

.taste-buddy-error { color: var(--dislike); }

.taste-buddy-rescan,
.taste-buddy-toggle-active,
.taste-buddy-delete {
  flex-shrink: 0;
  background: transparent;
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--muted);
  font-size: 0.75rem;
  padding: 4px 6px;
  cursor: pointer;
  line-height: 1;
  white-space: nowrap;
}

.taste-buddy-rescan:hover,
.taste-buddy-toggle-active:hover,
.taste-buddy-delete:hover { border-color: var(--accent); color: var(--accent); }

/* Live "Scanning…" state (hover flips it to "⏹ Stop" via JS) while
   this row's buddy is the one being scanned. */
.taste-buddy-rescan.scanning {
  border-color: rgba(184, 134, 255, 0.5);
  color: var(--play-this);
  font-size: 0.68rem;
}

.taste-buddy-delete:hover { border-color: var(--dislike); color: var(--dislike); }

.taste-buddy-self-info {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  font-size: 0.76rem;
  color: var(--muted);
}

body.hide-tags .optional-tags { display: none; }
body.hide-release-date .optional-release-date { display: none; }
body.hide-supporters .optional-supporters { display: none; }

h1 {
  font-size: 1.4rem;
  font-weight: 600;
  margin: 0;
}

button {
  background: var(--accent);
  color: #0a0a0a;
  border: none;
  border-radius: 6px;
  padding: 8px 14px;
  font-size: 0.85rem;
  font-weight: 600;
  cursor: pointer;
}

button:hover { opacity: 0.85; }

.banner {
  padding: 10px 14px;
  border-radius: 6px;
  margin-bottom: 16px;
  font-size: 0.9rem;
}

.banner.ok { background: #16321f; color: #8ee6a5; }
.banner.error { background: #3a1c1c; color: #ff9c9c; }

.dismissible-banner {
  position: relative;
  padding-right: 34px;
}

.banner-dismiss-btn {
  position: absolute;
  top: 6px;
  right: 8px;
  background: none;
  border: none;
  color: inherit;
  opacity: 0.6;
  cursor: pointer;
  font-size: 0.85rem;
  padding: 4px 6px;
  line-height: 1;
}

.banner-dismiss-btn:hover { opacity: 1; }

.banner-detail {
  margin-top: 4px;
  font-size: 0.8rem;
  opacity: 0.85;
}

.banner.recommend-banner {
  background: #1b2430;
  color: #9cc2e8;
  line-height: 1.5;
}

.banner.wishlist-banner {
  background: #332813;
  color: var(--preorder);
  line-height: 1.5;
}

/* Collapsible "about this tab" banner. Always server-rendered visible;
   JS hides it on load if previously dismissed (see the info-banner IIFE)
   -- the "Help" button stays put either way so it's never lost. */
.info-banner-wrap {
  margin-bottom: 16px;
}

/* An OPEN banner is the only thing the wrap ever shows now (the toggle
   button lives in the tabs-row) -- a dismissed banner must not leave a
   16px ghost margin behind. */
.info-banner-wrap:has(.info-hidden) {
  margin-bottom: 0;
}

/* Lives in the tabs-row, top right beside search -- a help control,
   not a filter, so it sits with the other global controls instead of
   floating alone on its own row under the filter bar. Fixed height =
   the search input's 34px, with flex centering: line-box sizing let
   the ℹ️ emoji inflate this pill to 35px while the plain-glyph Reset
   next to it sat at 29px -- visibly mismatched heights and centers. */
.info-toggle-btn {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--muted);
  font-size: 0.8rem;
  height: 34px;
  display: inline-flex;
  align-items: center;
  padding: 0 14px;
  border-radius: 999px;
  white-space: nowrap;
  margin-bottom: 8px;
}

.info-toggle-btn:hover { color: var(--text); border-color: var(--accent); opacity: 1; }

.info-banner-body {
  position: relative;
  padding-right: 92px;
  margin-bottom: 0;
}

.info-banner-body.info-hidden { display: none; }

.info-dismiss-btn {
  position: absolute;
  top: 10px;
  right: 12px;
  background: transparent;
  border: 1px solid var(--border);
  color: var(--muted);
  font-size: 0.72rem;
  padding: 3px 10px;
  border-radius: 999px;
  flex-shrink: 0;
}

.info-dismiss-btn:hover { color: var(--text); opacity: 1; }

.banner.hidden-gem-banner {
  background: #332a10;
  color: var(--hidden-gem);
  line-height: 1.5;
  border: 1px solid rgba(255, 209, 102, 0.4);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  flex-wrap: wrap;
}

.show-hidden-gem-button {
  background: transparent;
  border: 1px solid rgba(255, 209, 102, 0.5);
  color: var(--hidden-gem);
  font-weight: 600;
  padding: 6px 14px;
  border-radius: 999px;
  flex-shrink: 0;
}

.show-hidden-gem-button:hover { border-color: var(--hidden-gem); opacity: 1; }

/* 3.2s, not a blink: scrolling down a long grid takes a moment, and the
   outline must still be pulsing when the card finally arrives in view. */
.card.flash-highlight {
  animation: flash-highlight-pulse 3.2s ease;
}

@keyframes flash-highlight-pulse {
  0%, 100% { box-shadow: none; }
  10%, 70% { box-shadow: 0 0 0 3px var(--hidden-gem); }
}

/* Same flash, but blue -- used when jumping back to the now-playing
   card, so the highlight color itself matches the bar you clicked. */
.card.flash-highlight-playing {
  animation: flash-highlight-pulse-playing 3.2s ease;
}

@keyframes flash-highlight-pulse-playing {
  0%, 100% { box-shadow: none; }
  10%, 70% { box-shadow: 0 0 0 3px var(--accent); }
}

/* The live "something is playing" dot. Its only consumer is the native
   player's tab indicator (.np-live-tab) -- it outlived the "Last Clicked"
   island that originally defined it, hence the rename. */
@keyframes np-live-pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.4; transform: scale(0.7); }
}

/* Horizontal, no-embed card marking which release a "Find similar" search
   was run from -- deliberately not shaped like the result grid below it,
   so it can't be mistaken for one of the similar results itself. */
.seed-card {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 12px;
  margin-bottom: 12px;
  background: var(--card);
  border: 1px solid var(--gem);
  border-radius: 10px;
  text-decoration: none;
  color: inherit;
}

.seed-cover {
  width: 64px;
  height: 64px;
  border-radius: 6px;
  object-fit: cover;
  flex-shrink: 0;
}

.seed-info {
  min-width: 0;
}

.seed-label {
  font-size: 0.75rem;
  color: var(--gem);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

.seed-title {
  font-size: 1.05rem;
  font-weight: 600;
  margin-top: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.seed-link {
  font-size: 0.8rem;
  color: var(--muted);
  margin-top: 2px;
}

.seed-card:hover .seed-link {
  color: var(--accent);
}

/* Delete button on a saved-search accordion (Similars tab). The Sets/
   Radios management panel this used to sit beside was retired; this rule
   stays because the saved-search delete button reuses it. */
.search-delete-btn {
  font-size: 0.78rem;
  padding: 5px 8px;
  background: transparent;
  border: 1px solid rgba(255, 156, 156, 0.35);
  color: #ff9c9c;
}

.search-delete-btn:hover { border-color: #ff9c9c; opacity: 1; }

.empty-small {
  color: var(--muted);
  font-size: 0.85rem;
}

/* Similars toolbar: Dig on the left, Sort on the right, one aligned row. */
.similar-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}
.similar-toolbar .dig-btn { margin: 0; }
.similar-sort-form {
  display: flex;
  justify-content: flex-end;
  margin: 0;
}

.leaderboard-columns {
  display: grid;
  /* three equal columns now (artists / labels / most-listened tracks),
     collapsing to one on phones below */
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 24px;
}

@media (max-width: 720px) {
  .leaderboard-columns { grid-template-columns: 1fr; }
}

.leaderboard-col {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 16px 20px;
}

.leaderboard-col h3 {
  margin: 0 0 12px;
  font-size: 1rem;
}

.leaderboard-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.leaderboard-list li {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 4px;
  border-bottom: 1px solid var(--border);
}

.leaderboard-list li:last-child { border-bottom: none; }

.leaderboard-rank {
  color: var(--muted);
  font-size: 0.8rem;
  width: 2.2em;
  flex-shrink: 0;
}

.leaderboard-name {
  flex: 1;
  color: var(--text);
  text-decoration: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.leaderboard-name:hover { text-decoration: underline; }

.leaderboard-stats {
  display: flex;
  gap: 10px;
  font-size: 0.78rem;
  color: var(--muted);
  flex-shrink: 0;
}

/* Saved similar-searches accordion */
.searches-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.search-accordion {
  border-radius: 10px;
}

.search-accordion .seed-card {
  margin-bottom: 0;
  cursor: pointer;
  list-style: none;
}

.search-accordion .seed-card::-webkit-details-marker {
  display: none;
}

.search-accordion[open] .seed-card {
  border-radius: 10px 10px 0 0;
}

.search-accordion .search-delete-btn {
  margin-left: auto;
  flex-shrink: 0;
}

.search-results-grid {
  border: 1px solid var(--border);
  border-top: none;
  border-radius: 0 0 10px 10px;
  padding: 16px;
  margin: 0;
}

/* Discover tab: the latest refresh is shown as a normal open grid (not
   an accordion -- it's the "current" view, always visible), older
   refreshes collapse into the history list below using the same
   accordion styling as Similars. */
.discover-latest {
  margin-bottom: 24px;
}

.discover-latest-header {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}

.discover-latest-header .seed-label {
  font-size: 0.75rem;
  color: var(--gem);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

.discover-latest-header .seed-title {
  font-size: 1.05rem;
  font-weight: 600;
}

.discover-latest-header .search-delete-btn {
  margin-left: auto;
}

.discover-history-heading {
  font-size: 0.8rem;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.03em;
  margin: 4px 0 2px;
}

/* Shared tooltip for any badge with a data-tooltip attribute (match
   quality, gem, hidden gem, discovery, new artist/label, ...). Our own
   (data-tooltip + ::after/::before) rather than a native title attribute
   -- native tooltips take ~a second to appear, render tiny, and can't be
   styled. */
[data-tooltip] {
  position: relative;
  cursor: help;
}

[data-tooltip]::after {
  content: attr(data-tooltip);
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%) translateY(4px);
  background: #0a0b0c;
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 9px;
  font-size: 0.72rem;
  font-weight: 500;
  line-height: 1.3;
  white-space: normal;
  width: max-content;
  max-width: 200px;
  /* Texts just over the cap clamp to a full-width box with a ragged
     orphan second line -- reads as "too big for no reason". Balancing
     the wrap splits those into even lines instead. */
  text-wrap: balance;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.15s ease, transform 0.15s ease;
  pointer-events: none;
  z-index: 30;
}

[data-tooltip]::before {
  content: "";
  position: absolute;
  bottom: calc(100% + 3px);
  left: 50%;
  transform: translateX(-50%);
  border: 5px solid transparent;
  border-top-color: var(--border);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.15s ease;
  pointer-events: none;
  z-index: 30;
}

[data-tooltip]:hover::after,
[data-tooltip].tooltip-open::after {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
}

[data-tooltip]:hover::before,
[data-tooltip].tooltip-open::before {
  opacity: 1;
  visibility: visible;
}

.match-badge {
  background: rgba(99, 178, 255, 0.16);
  color: var(--accent);
  outline: 1px solid rgba(99, 178, 255, 0.4);
}

.match-badge-low-confidence {
  background: rgba(255, 255, 255, 0.06);
  color: var(--text-dim, #9aa1ac);
  outline: 1px dashed rgba(255, 255, 255, 0.25);
}

.low-sample-badge {
  background: rgba(255, 176, 32, 0.14);
  color: #ffb020;
  outline: 1px solid rgba(255, 176, 32, 0.4);
}

.tabs-row {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  border-bottom: 1px solid var(--border);
  margin-bottom: 16px;
}

.tabs {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
  /* Pushes the top-right cluster (Reset, About this tab, search) to
     the row's right edge. */
  margin-right: auto;
}

.tabs a {
  color: var(--muted);
  text-decoration: none;
  padding: 8px 14px;
  font-size: 0.88rem;
  border-bottom: 2px solid transparent;
}

.tabs a.active {
  color: var(--text);
  border-bottom-color: var(--accent);
}

/* Live release counts on the countable tabs -- updated in place from
   every /mark response, so they tick along as you decide. */
.tab-count {
  display: inline-block;
  margin-left: 6px;
  padding: 1px 7px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.07);
  color: var(--muted);
  font-size: 0.68rem;
  font-variant-numeric: tabular-nums;
  vertical-align: middle;
}
.tabs a.active .tab-count,
.library-subtabs a.active .tab-count {
  color: var(--text);
  background: rgba(99, 178, 255, 0.18);
}

.library-subtabs {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
  margin: -8px 0 16px;
  padding-left: 8px;
  border-left: 2px solid var(--border);
}

.library-subtabs a {
  color: var(--muted);
  text-decoration: none;
  padding: 6px 12px;
  font-size: 0.8rem;
  border-radius: 999px;
}

.library-subtabs a:hover { color: var(--text); }

.library-subtabs a.active {
  color: var(--text);
  background: var(--card);
  border: 1px solid var(--border);
}

/* Two deliberate rows instead of one crowded wrap: the top row is
   everything about WHEN (presets, custom range, month/year, which date
   they mean), the bottom row holds the tab-specific view options with
   Sort ALWAYS pinned to the far right (margin-left:auto). */
.filters {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-bottom: 28px;
}

.filters-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
}

.filters-dates {
  padding-bottom: 10px;
  border-bottom: 1px solid var(--border);
}

/* Sort lives on the WHEN row now, pinned to the far right so it lines up
   on the same x-axis as the 30/60/90/All-time presets. */
.filters-dates .sort-form {
  margin-left: auto;
}

.search-form {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
  position: relative;
}

.search-suggestions {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  width: 320px;
  max-width: 90vw;
  max-height: 360px;
  overflow-y: auto;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
  z-index: 20;
  padding: 4px;
}

.search-suggestion-category {
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  padding: 6px 8px 2px;
}

.search-suggestion-item {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  color: var(--text);
  padding: 7px 8px;
  border-radius: 6px;
  font-size: 0.83rem;
  cursor: pointer;
}

.search-suggestion-item:hover,
.search-suggestion-item.highlighted {
  background: rgba(99, 178, 255, 0.14);
}

.search-suggestion-icon {
  flex-shrink: 0;
}

.search-suggestion-text {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.search-form input[type="search"] {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  padding: 8px 12px;
  font-size: 0.85rem;
  width: 280px;
  max-width: 100%;
}

.search-form input[type="search"]:focus {
  outline: none;
  border-color: var(--accent);
}

.search-form .clear-search {
  color: var(--muted);
  text-decoration: none;
  font-size: 0.85rem;
  padding: 4px 8px;
}

.search-form .clear-search:hover {
  color: var(--text);
}

.presets {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
}

/* Lives in the tabs-row beside About-this-tab/search -- same pill
   language AND the same fixed 34px height/centering as its neighbors
   (see .info-toggle-btn for why line-box sizing wasn't enough). */
.reset-filters-link {
  color: var(--muted);
  text-decoration: none;
  font-size: 0.8rem;
  height: 34px;
  display: inline-flex;
  align-items: center;
  padding: 0 14px;
  border: 1px solid var(--border);
  border-radius: 999px;
  white-space: nowrap;
  margin-bottom: 8px;
}

.reset-filters-link:hover {
  color: var(--text);
  background: var(--card);
}

.presets a {
  color: var(--muted);
  text-decoration: none;
  padding: 6px 12px;
  font-size: 0.8rem;
  border: 1px solid var(--border);
  border-radius: 999px;
}

.presets a.active {
  color: #0a0a0a;
  background: var(--accent);
  border-color: var(--accent);
}

.range-form {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}

.range-form label {
  color: var(--muted);
  font-size: 0.8rem;
  display: flex;
  align-items: center;
  gap: 6px;
}

.range-form input[type="date"] {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  padding: 5px 8px;
  font-size: 0.8rem;
  color-scheme: dark;
}

.range-form button {
  /* Same 29px rendered height as every select/pill sharing its row. */
  height: 29px;
  padding: 0 12px;
  font-size: 0.8rem;
}

.sort-form label,
.date-field-form label,
.play-this-filter-form label,
.buddy-filter-form label {
  color: var(--muted);
  font-size: 0.8rem;
  display: flex;
  align-items: center;
  gap: 6px;
}

.sort-form select,
.date-field-form select,
.month-form select,
.play-this-filter-form select,
.buddy-filter-form select {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  padding: 5px 8px;
  font-size: 0.8rem;
}

.month-form {
  display: flex;
  align-items: center;
  gap: 8px;
}

.show-listened-form label {
  display: flex;
  align-items: center;
  gap: 6px;
  color: var(--muted);
  font-size: 0.8rem;
  white-space: nowrap;
}

.empty {
  color: var(--muted);
  padding: 40px 0;
  text-align: center;
}

/* Fixed cards-per-row inside the 1240px shell: medium = 3-up, small =
   4-up, big = 2-up. The minmax floors are tuned so the shell width
   yields EXACTLY those counts (and narrower windows degrade gracefully)
   -- with the shell capped, the layout is identical on every display. */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr));
  gap: 32px;
  transition: gap 0.3s ease;
}

/* Card size (Settings panel): base rules above are the "medium" default.
   Column count itself can't animate smoothly (CSS Grid has no in-between
   state for a track-count change), but padding/gap/heights do. */
body.size-small .grid { grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: 18px; }
body.size-small .card { padding: 12px; gap: 8px; }
body.size-small .meta .title { font-size: 0.85rem; }
/* No per-size chip overrides anymore: every cover chip (badges,
   buttons, label logo/text) sizes via container-query units on
   .cover-wrap, so card-size changes AND browser resizes scale them
   automatically -- one mechanism instead of three hand-tuned sets. */

body.size-big .grid { grid-template-columns: repeat(auto-fill, minmax(min(520px, 100%), 1fr)); gap: 40px; }
body.size-big .card { padding: 22px; gap: 16px; }
body.size-big .meta .title { font-size: 1.1rem; }


.card {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 16px;
  transition: padding 0.3s ease, gap 0.3s ease;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* Any of the three decisions dims the cover -- "reviewed" either way,
   distinguished from an undecided/New release. Which decision it was
   is what the colored outline below is for, not the dimming itself. */
.card.status-liked .cover,
.card.status-skipped .cover,
.card.status-disliked .cover {
  filter: grayscale(1) brightness(0.75);
  transition: filter 0.3s ease;
}

/* Same-colored outline around the artwork as whichever of Like/Skip/
   Dislike is active, so the card's status reads at a glance without
   needing to check the buttons or hover the status icon. */
.cover-wrap {
  position: relative;
  outline: 3px solid transparent;
  outline-offset: 2px;
  border-radius: 8px;
  transition: outline-color 0.3s ease;
  /* Every cover chip (badges, status buttons, play-this icon, label
     logo/text) sizes off these container-relative values, so they
     scale with the REAL card width -- browser resizes, grid reflows,
     and the small/medium/big setting all covered by one mechanism. */
  container-type: inline-size;
  --chip: clamp(24px, 9.6cqw, 44px);
  --chip-font: clamp(0.72rem, 4.5cqw, 1.35rem);
}

/* Dedicated glow layer, sized to start exactly at the outline's own
   outer edge (outline-offset 2px + outline width 3px = 5px out from
   the cover) -- a box-shadow animated on .cover-wrap itself would
   start at the cover's bare edge instead, bleeding through the small
   gap between the cover and the outline, and through the outline's
   own thickness. This pseudo-element exists purely so the glow
   keyframes below have something positioned at the right starting
   radius to animate. */
.cover-wrap::before {
  content: '';
  position: absolute;
  inset: -5px;
  /* NOT border-radius: inherit -- this box sits 5px outside the cover,
     so matching the outline's corner arc needs the cover's 8px radius
     GROWN by that offset (concentric rounding); inheriting 8px directly
     made the glow's curve cut across the outline's corners, reading as
     dark notches at each corner while glowing. */
  border-radius: 13px;
  pointer-events: none;
}

.card.status-liked .cover-wrap { outline-color: var(--like); }
.card.status-skipped .cover-wrap { outline-color: var(--skip); }
.card.status-disliked .cover-wrap { outline-color: var(--dislike); }

/* The greyed-out cover keeps a faint tint of whichever decision it got
   -- mix-blend-mode: color recolors the (already grayscale) image
   toward the overlay hue while preserving its own luminance, so it
   reads as "grey, but green/orange/pink" rather than flat grey for
   all three. */
.cover-wrap::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
  mix-blend-mode: color;
}

.card.status-liked .cover-wrap::after { background: var(--like); opacity: 0.35; }
.card.status-skipped .cover-wrap::after { background: var(--skip); opacity: 0.35; }
.card.status-disliked .cover-wrap::after { background: var(--dislike); opacity: 0.35; }

/* Tagging Play This (always alongside Liked -- see applyMarkResponse)
   layers purple into the wash rather than replacing the green: a
   diagonal two-hue gradient recolors the same grayscaled cover, so it
   reads as "liked AND play-this" instead of one overwriting the
   other. Wins over the plain .status-liked rule above on specificity
   (two classes vs one), regardless of source order. */
.card.status-liked.play-this-active .cover-wrap::after {
  background: linear-gradient(135deg, var(--like), var(--play-this));
  opacity: 0.4;
}

.card.status-liked.play-this-active .cover-wrap { outline-color: var(--play-this); }

/* Settings -> "Color cover art after deciding" turned OFF: no
   grayscale, no tint wash -- the artwork stays untouched and the only
   decision cue is a deliberately subtle outline (thinner, translucent
   versions of the same status colors). */
body.no-cover-tint .card.status-liked .cover,
body.no-cover-tint .card.status-skipped .cover,
body.no-cover-tint .card.status-disliked .cover {
  filter: none;
}

body.no-cover-tint .cover-wrap::after {
  opacity: 0 !important;
}

body.no-cover-tint .cover-wrap {
  outline-width: 2px;
}

body.no-cover-tint .card.status-liked .cover-wrap { outline-color: rgba(79, 191, 140, 0.45); }
body.no-cover-tint .card.status-skipped .cover-wrap { outline-color: rgba(214, 162, 92, 0.45); }
body.no-cover-tint .card.status-disliked .cover-wrap { outline-color: rgba(217, 116, 144, 0.45); }
body.no-cover-tint .card.status-liked.play-this-active .cover-wrap { outline-color: rgba(184, 134, 255, 0.5); }

.card.search-hidden { display: none; }

.card .cover {
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: 6px;
  display: block;
}

/* Frosted glass with a light hairline instead of the dark outline
   (which read like cheap clip-art): heavier blur + saturation does
   the separating-from-the-art work, the subtle white hairline gives
   it the same crisp edge weight as the badges, and a soft layered
   shadow lifts it. */
/* Admin-only "why" pill -- sits just under the label island (top-right,
   clear of the top-left badge stack). Reads only "why"; the full reason
   (every signal + its numbers) lives in the hover tooltip. */
.admin-why-badge {
  position: absolute;
  right: 8px;
  top: 44px;
  z-index: 3;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 0.56rem;
  color: #14161a;
  background: #ffd98a;
  border-radius: 6px;
  padding: 2px 7px;
  cursor: help;
}

.label-badge {
  position: absolute;
  right: 8px;
  top: 8px;
  display: flex;
  align-items: center;
  gap: 8px;
  background: rgba(16, 17, 19, 0.55);
  backdrop-filter: blur(12px) saturate(1.3);
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: 14px;
  padding: 5px 12px 5px 5px;
  max-width: calc(100% - 16px);
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.06);
  /* Same stacking bucket as .badge-stack (z-index 3): without this, the
     decision-color wash (.cover-wrap::after, mix-blend-mode: color)
     paints after it in source order and blends right through the label
     photo, tinting it along with the cover art it's supposed to stay
     independent of. */
  z-index: 3;
}

/* Quality/context badges (discovery, gem tiers, You May Play This,
   new artist/label, liked artist/label, supporter-overlap match) --
   relocated here from inline text next to the title. Top-left,
   stacked in priority order (top = highest), one per row rather than
   side by side. */
.badge-stack {
  position: absolute;
  left: 8px;
  top: 8px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  z-index: 3;
}

/* Shared shape/backdrop for every badge above -- each badge's own
   class (.gem-badge, .liked-badge, etc.) still supplies just its
   distinguishing background/color/border, same as before the move. */
.cover-badge {
  width: var(--chip);
  height: var(--chip);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  /* gap stays 0: the collapsed (max-width:0) label must leave no
     phantom flex gap next to the emoji or the circle looks off-center;
     the revealed label spaces itself with its own margin-left. */
  gap: 0;
  font-size: var(--chip-font);
  font-family: inherit;
  line-height: 1;
  padding: 0;
  backdrop-filter: blur(6px);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
  cursor: help;
  flex-shrink: 0;
  white-space: nowrap;
  /* NO overflow:hidden here -- the hover tooltip is an absolutely-
     positioned ::after INSIDE this element (its containing block), so
     clipping overflow silently kills every badge tooltip. Nothing
     needs the clip anyway: the icon-label span is display:none in
     icon-only mode, and the pill mode sizes to its content. */
}

/* Solid frosted base instead of each badge's own translucent tint --
   the tints sat directly on the cover art, so badge visibility swung
   wildly with whatever colors the art happened to have. Same dark
   treatment as the status buttons, so every corner control reads
   consistently on any cover; the badge classes' tints survive as
   border + glyph color only. (.badge-stack .cover-badge out-specifies
   the single-class tint rules' background shorthand.) */
.cover-wrap .badge-stack .cover-badge {
  background-color: rgba(10, 10, 11, 0.8);
}

button.cover-badge {
  cursor: pointer;
  transition: filter 0.15s ease, transform 0.1s ease;
}

/* Badge/button names: a stacked "kicker" lockup -- small qualifier
   line over the big emphasized noun ("NEW" / "Artist", "You may" /
   "Play"). Hidden by default (icon-only circles); HOVER slides the
   name out of any badge (max-width tween on the label itself, so the
   badge needs no overflow clipping that would eat its tooltip); the
   Settings "Show text labels on icons" toggle pins them open. Back to
   an ordinary in-flow flex child (not the absolutely-positioned overlay
   tried briefly this morning) -- see the .badge-icon comment below for
   how the icon-move bug is actually fixed instead. */
.icon-label {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  gap: 1px;
  line-height: 1;
  max-width: 0;
  opacity: 0;
  overflow: hidden;
  white-space: nowrap;
  transition: max-width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
              opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1),
              margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

/* NOT text-transform: uppercase -- forcing the kicker to caps while the
   noun line stayed as-typed read as two mismatched labels stuck together
   ("TASTE" over "Buddy"). Same sentence case on both lines, differing
   only in size/weight, reads as one deliberate lockup. */
.icon-label-top {
  font-size: clamp(0.42rem, 2.1cqw, 0.58rem);
  font-weight: 600;
  letter-spacing: 0.07em;
  opacity: 0.75;
}

.icon-label-main {
  font-size: clamp(0.62rem, 3.1cqw, 0.9rem);
  font-weight: 700;
  letter-spacing: 0.01em;
}

/* Hover slide-out ("cool animation"): the circle grows into a pill as
   the stacked name extends from behind the icon. margin-left is small
   (not the old 5px) because .badge-icon's slot is chip-wide but the
   glyph inside it is centered and narrower than that -- there's already
   real breathing room between the glyph's own right edge and the slot's
   right edge before this margin even starts, so a smaller value here
   lands close to the ~8px gap .label-badge uses between its logo and
   name (measured: 5px on top of the slot's own slack was ~13px total,
   visibly wider than the label island's gap for the same kind of
   icon+name pairing). */
.cover-badge:hover .icon-label,
.cover-badge.tooltip-open .icon-label,
body.icon-labels .icon-label {
  max-width: 140px;
  opacity: 1;
  margin-left: 0;
}

/* Taste Buddy is the one badge whose .badge-icon is a photo, not an
   emoji glyph -- the photo (78% of its slot, see .tb-photo) leaves less
   slack space to its right than an emoji's own bearing does, so it needs
   a small explicit margin to land at the same ~8.5px visual gap every
   other badge's name gets. (Was 8px back when the photo filled the slot
   edge to edge; the shrink to 78% added ~4px of its own slack, and
   keeping the full margin measured ~12px -- visibly wider than the rest.) */
.taste-buddy-badge:hover .icon-label,
.taste-buddy-badge.tooltip-open .icon-label,
body.icon-labels .taste-buddy-badge .icon-label {
  margin-left: 4px;
}

/* The icon's own fixed, self-centering slot -- exactly var(--chip) wide
   (matching the collapsed circle's own diameter) and flex-shrink:0, so
   it always occupies the full row by itself when the label is collapsed
   and stays pinned there once the label starts taking space, no matter
   what the surrounding badge is doing. This (paired with .badge-stack
   .cover-badge's justify-content below being permanently flex-start, not
   toggled) is what actually stops the emoji moving -- NOT giving the
   icon its own transform/animation, which is what caused the drift
   originally: the badge's box grows via width:auto (can't transition
   to/from that), so on mouse-out it snapped back to its collapsed size
   instantly while the still-open label hadn't caught up, and whatever
   free space existed got redistributed by justify-content, shoving the
   icon sideways for a beat. With the icon's own box fixed and
   justify-content never flipping, there's no free space to redistribute
   in the first place. No transform here -- the icon does not animate. */
.badge-icon {
  width: var(--chip);
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  /* Emoji carry irregular vertical metrics -- an inherited line-height
     let some glyphs (💿, ⭐) ride visibly high or low of center, worse
     as the card-size setting changed the chip font. line-height:1 pins
     every glyph to its own em-box so flex centering actually centers. */
  line-height: 1;
}

.cover-badge:hover,
body.icon-labels .cover-badge,
body.icon-labels .status-btn,
body.icon-labels .play-this-icon {
  width: auto;
  border-radius: 999px;
  padding: 0 12px 0 9px;
  gap: 0;
}

/* Like/Skip/Dislike sit side by side, so letting each pill hug just its
   own text (the default everywhere else) left "Dislike" visibly wider
   than its two siblings -- same min-width for all three, in chip units
   so it scales with card size exactly like everything else here rather
   than a fixed px that'd be too cramped at Big or too roomy at Small.
   "Dislike"'s own natural width isn't a constant multiple of the chip
   size (font-size and chip use different clamp() curves, so the ratio
   drifts across sizes) -- measured up to ~2.7x at Small, so 3x clears
   it everywhere with room to spare. */
body.icon-labels .status-btn {
  min-width: calc(var(--chip) * 3);
}

/* ...but NOT when the cluster's collapsed (decided, inactive buttons) --
   that relies on shrinking to 0, which a min-width would block outright
   (min-width wins over max-width when they conflict). */
body.icon-labels .status-cluster.decided .status-btn:not(.active) {
  min-width: 0;
}

/* Permanently flex-start (not just on hover/labels-mode) -- see the
   .badge-icon comment above for why toggling this was the other half
   of the icon-drift bug. Visually identical to justify-content:center
   whenever the label is collapsed anyway, since .badge-icon alone then
   fills the row exactly -- padding stays conditional below (this rule
   only ever changes justify-content, never the box's resting size). */
.badge-stack .cover-badge {
  justify-content: flex-start;
}

/* Stacked badge pills: each pill ends where its own text ends (the
   stack aligns flex-start below, overriding the flex default that
   stretched every pill to the widest one's width), while left-aligned
   content keeps all the emojis in one clean vertical column at the
   left edge. Smaller label/emoji sizes rein in the long names.
   LEFT padding is 0 here (not the 9-10px status buttons/play-this-icon
   use) and stays 0 collapsed or expanded -- any nonzero left padding
   that only appears on reveal shifts the icon's own on-screen position
   by that amount the instant the state changes, even with the icon's
   box otherwise fixed. Keeping it constant at 0 is what makes the
   emoji's position genuinely never move, not just "stop drifting". */
body.icon-labels .badge-stack .cover-badge,
.badge-stack .cover-badge:hover {
  padding: 0 12px 0 0;
}

body.icon-labels .badge-stack {
  align-items: flex-start;
}



/* The decided-cluster collapse animates width to a fixed 0 either way;
   with labels on, the re-expand targets can't be a fixed 26/34/42px --
   let the expanded buttons size themselves and only keep the collapse
   at zero. max-width does the animatable clamping instead of width. */
/* width:auto can't animate (the expand JUMPED with labels on) --
   max-width can, so the collapse tweens max-width between 0 and a
   roomy cap while width stays auto. overflow:hidden is safe here
   because labels-mode status buttons have no tooltip to clip (it's
   suppressed as pure duplication of the visible label). */
body.icon-labels .status-cluster.decided .status-btn:not(.active) {
  width: auto;
  max-width: 0;
  padding: 0;
  overflow: hidden;
  transition: max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
              opacity 0.28s cubic-bezier(0.4, 0, 0.2, 1),
              margin-left 0.28s cubic-bezier(0.4, 0, 0.2, 1),
              border-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
              padding 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}

body.icon-labels .status-cluster.decided:hover .status-btn:not(.active) {
  width: auto;
  max-width: 140px;
  padding: 0 12px 0 9px;
}

button.cover-badge:hover { filter: brightness(1.25); }
button.cover-badge:active { transform: scale(0.94); }

/* The default [data-tooltip] direction (centered, opening upward)
   would clip above the card for a badge already at the very top, and
   a centered tooltip on a badge flush against the left edge would
   overflow off-screen to the left -- opens downward and left-aligned
   here instead. */
.badge-stack [data-tooltip]::after {
  left: 0;
  bottom: auto;
  top: calc(100% + 8px);
  transform: translateY(-4px);
}

.badge-stack [data-tooltip]::before {
  left: 10px;
  bottom: auto;
  top: calc(100% + 3px);
  transform: none;
  border-top-color: transparent;
  border-bottom-color: var(--border);
}

.badge-stack [data-tooltip]:hover::after,
.badge-stack [data-tooltip].tooltip-open::after {
  transform: translateY(0);
}

/* Badge tooltips open DOWNWARD over the badges stacked below them, and
   backdrop-filter gives every badge its own stacking context -- without
   this lift, an upper badge's tooltip paints UNDER its lower siblings. */
.badge-stack .cover-badge:hover,
.badge-stack .cover-badge.tooltip-open {
  z-index: 40;
}

/* Rich (HTML) tooltip -- a real child element for the badges whose
   tooltip needs markup (the 🫂 badge colors buddy names), styled and
   positioned identically to the attr()-based badge-stack tooltips
   above (downward, left-aligned). The parent badge has no
   [data-tooltip] attr, so the two mechanisms never double-render. */
.rich-tooltip {
  position: absolute;
  left: 0;
  top: calc(100% + 8px);
  transform: translateY(-4px);
  background: #0a0b0c;
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 9px;
  font-size: 0.72rem;
  font-weight: 500;
  line-height: 1.3;
  white-space: normal;
  width: max-content;
  max-width: 200px;
  text-wrap: balance;
  text-align: left;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.15s ease, transform 0.15s ease;
  pointer-events: none;
  z-index: 30;
}

.cover-badge:hover > .rich-tooltip,
.cover-badge.tooltip-open > .rich-tooltip {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}

/* Buddy names inside the 🫂 tooltip, in the badge's own blue. */
.rich-tooltip .tb-name {
  color: #5dadec;
  font-weight: 700;
}

/* Track titles the buddy actually bought, named inside the Buddy
   Discovery tooltip ("Emitter would suggest you Stranger and High Grade").
   A distinct warm color (vs. the blue buddy name) is what sets them apart
   -- see _tracks_phrase; no quotes on purpose. */
.rich-tooltip .tb-track {
  color: #f5c451;
  font-weight: 700;
}

/* "You own" badge's own track mentions -- the badge's silver, so the
   tooltip visibly belongs to it (same no-quotes, color-does-the-work
   rule as tb-track). */
.rich-tooltip .so-track {
  color: #e2e7f0;
  font-weight: 700;
}

/* The emphasized *recently* in buddy tooltips ("Katter has recently
   wishlisted this") -- italic + soft coral, reads as a highlight
   without shouting. */
.rich-tooltip .tb-recent {
  font-style: italic;
  color: #ff8a7a;
  font-weight: 600;
}

/* Status buttons' tooltips ARE only their title (Like/Skip/Dislike) --
   pure duplication of the visible label whenever the labels are pinned
   open, so they're suppressed in that mode. */
body.icon-labels .status-btn::after,
body.icon-labels .status-btn::before {
  display: none;
}

/* Like/Skip/Dislike, relocated onto the cover from the old
   bottom-of-card button row -- see .status-cluster in the template.
   Undecided (no .decided): all three always shown in a row. Decided:
   only .active stays, flush in the corner via justify-content:
   flex-end (works regardless of which of the three it is, since
   flex-end always packs the last visible box against the container's
   own edge); the other two collapse to zero-width and re-expand on
   hover so the decision can be changed. */

/* Reappeared-preorder warning, stacked directly above the status
   cluster -- same frosted-glass pill treatment as .label-badge
   ("just like label name appears"), not the small circular language
   the buttons/badges elsewhere use, since this needs to carry a short
   word of text ("Skip ?" etc), not just an icon. */
.prior-decision-badge {
  position: absolute;
  right: 8px;
  bottom: calc(var(--chip) + 12px);
  background: rgba(10, 10, 11, 0.75);
  backdrop-filter: blur(6px);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 999px;
  padding: 4px 10px;
  font-size: 0.7rem;
  font-weight: 600;
  color: var(--muted);
  cursor: help;
  z-index: 3;
  white-space: nowrap;
}

.status-cluster {
  position: absolute;
  right: 8px;
  bottom: 8px;
  display: flex;
  justify-content: flex-end;
  align-items: center;
  z-index: 3;
}

.status-btn {
  width: var(--chip);
  height: var(--chip);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: calc(var(--chip-font) * 1.12);
  font-family: inherit;
  line-height: 1;
  padding: 0;
  margin-left: 6px;
  background: rgba(10, 10, 11, 0.8);
  backdrop-filter: blur(6px);
  border: 2px solid transparent;
  color: var(--muted);
  cursor: pointer;
  flex-shrink: 0;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
  /* width/margin-left animate (not just opacity/transform) because the
     collapse relies on real layout space vacating so justify-content:
     flex-end repacks the survivor flush into the corner -- a transform-
     only collapse would leave the invisible buttons still occupying
     their flex slot. Three small circles is a trivial reflow cost, so
     this trades a little of the "always transform" performance advice
     for the layout behavior the component actually needs. */
  transition: opacity 0.28s cubic-bezier(0.4, 0, 0.2, 1),
              width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
              margin-left 0.28s cubic-bezier(0.4, 0, 0.2, 1),
              border-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
              border-color 0.15s ease, color 0.15s ease, filter 0.15s ease;
}

.status-btn:first-child { margin-left: 0; }

/* The dislike icon is an inline SVG, not the ✕ font glyph -- how much
   ink a dingbat gets inside its em box is a per-FONT decision, and it
   differed wildly per engine: a 1.15x font-size bump that balanced it
   against ♥/✓ in Chromium still measured only 0.64x the heart's width
   in WebKit (= iOS Safari, where the user actually noticed it). An SVG
   sized in em renders identically everywhere and still scales with the
   chip/labels-mode font sizes. currentColor keeps every state color
   (active pink, muted default) working unchanged. */
.x-icon {
  width: 0.82em;
  height: 0.82em;
  display: block;
}

.x-icon path {
  stroke: currentColor;
  stroke-width: 3;
  stroke-linecap: round;
  fill: none;
}

.status-btn:hover { filter: brightness(1.2); }

.status-btn.like-btn.active { border-color: var(--like); color: var(--like); }
.status-btn.skip-btn.active { border-color: var(--skip); color: var(--skip); }
.status-btn.dislike-btn.active { border-color: var(--dislike); color: var(--dislike); }

.status-cluster.decided .status-btn:not(.active) {
  width: 0;
  margin-left: 0;
  opacity: 0;
  border-width: 0;
  pointer-events: none;
}

/* Hovering re-expands every option so a different decision can be
   picked; the active one stays visually distinguished (colored
   border) among the two neutral alternatives. Mouse-only (not
   :focus-within) -- a clicked button keeps browser focus afterward,
   which would otherwise hold the cluster open even after the mouse
   moves away. */
.status-cluster.decided:hover .status-btn:not(.active) {
  width: var(--chip);
  margin-left: 6px;
  opacity: 1;
  border-width: 2px;
  pointer-events: auto;
}

/* Wraps the headphone icon + its panel, bottom-left of the cover --
   a flex column (reversed, so the icon -- first in DOM -- ends up at
   the bottom, panel stacking upward above it) rather than two
   independently absolute-positioned siblings, specifically so
   hovering from the icon into the panel never crosses a dead gap
   that would falsely trigger a close (both are just always-adjacent
   flex children of the same box now). Sized/positioned here; icon
   and panel below no longer carry their own position/left/bottom.
   pointer-events:none + auto on children so this invisible box
   (it has no background of its own) never intercepts clicks meant
   for the cover art beneath it. */
/* Positioning slot for the headphone icon at the cover's bottom-left.
   It used to also stack the hover-open "Play This" panel above the icon
   (hence the old .play-this-hover-zone name) -- that panel is gone; the
   per-track picks live in the inline tracklist now. */
.play-this-slot {
  position: absolute;
  left: 8px;
  bottom: 8px;
  width: calc(100% - 16px);
  max-width: 240px;
  display: flex;
  flex-direction: column-reverse;
  align-items: flex-start;
  gap: 8px;
  z-index: 3;
  pointer-events: none;
}

.play-this-slot > * {
  pointer-events: auto;
}

/* Same badge, mirrored to the bottom-left. Three states: hidden
   (not liked, see .hidden), this base style = visible but
   grey/inactive (liked, not yet tagged Play This -- an invitation,
   not a decision), .active = tagged (vivid purple, below). A real
   <button> (clicking only ever toggles the tag itself -- see the
   click handler -- the panel's visibility is hover-driven, below),
   so it needs a form-control reset on top of the visual styling. */
/* Positioned by its parent .play-this-slot, not independently --
   width/height/font/colors stay here, left/bottom/z-index live there. */
.play-this-icon {
  flex-shrink: 0;
  width: var(--chip);
  height: var(--chip);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: var(--chip-font);
  font-family: inherit;
  line-height: 1;
  padding: 0;
  background: rgba(10, 10, 11, 0.8);
  backdrop-filter: blur(6px);
  border: 2px solid rgba(255, 255, 255, 0.22);
  color: rgba(255, 255, 255, 0.45);
  cursor: pointer;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
  transition: filter 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}

.play-this-icon:hover { filter: brightness(1.3); }

/* Pops in when it appears (right after liking) so a genuinely new
   control announces itself instead of just being there suddenly. */
@keyframes control-pop-in {
  0% { opacity: 0; transform: scale(0.4); }
  70% { transform: scale(1.08); }
  100% { opacity: 1; transform: scale(1); }
}

.play-this-icon:not(.hidden) {
  animation: control-pop-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.play-this-icon.active {
  border-color: var(--play-this);
  color: var(--play-this);
  box-shadow: 0 0 8px rgba(184, 134, 255, 0.5);
}
/* I'd-play headphones is a designed SVG now (was the 🎧 emoji), so it
   inherits the control's colour states -- muted, then play-this purple
   when tagged -- like the drawn Dislike X, not a stray colour emoji. */
.play-this-icon .badge-icon { display: inline-flex; }
.play-this-icon .badge-icon .ui-icon { width: 1.15em; height: 1.15em; }


.label-badge-standalone {
  position: static;
  background: none;
  backdrop-filter: none;
  border: none;
  padding: 0;
  margin-top: -4px;
}

/* Same diameter as the corner status buttons/badges at every card size
   -- all the cover's circular avatars read as one family. */
.label-logo {
  width: var(--chip);
  height: var(--chip);
  border-radius: 50%;
  object-fit: cover;
  flex-shrink: 0;
}

.label-text {
  display: flex;
  flex-direction: column;
  line-height: 1.25;
  overflow: hidden;
}

.label-name {
  color: #f2f2f2;
  font-size: clamp(0.6rem, 3.2cqw, 0.95rem);
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.label-location {
  color: rgba(255, 255, 255, 0.6);
  font-size: clamp(0.52rem, 2.7cqw, 0.8rem);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.label-badge-standalone .label-name {
  color: var(--muted);
  font-weight: 500;
}

.label-badge-standalone .label-location {
  color: var(--muted);
  opacity: 0.7;
}

.meta .title {
  font-weight: 600;
  font-size: 0.95rem;
  line-height: 1.3;
  /* Exactly one line, always: a long album name used to wrap and push
     everything under it (date, tags, the tracklist) lower than the
     neighboring cards', breaking the row's symmetry. Ellipsize instead;
     the full name sits in the title attribute for hover. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.meta .artist {
  color: var(--muted);
  font-size: 0.85rem;
  margin-top: 2px;
  /* same one-line rule as .title, same reason */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.release-date {
  color: var(--muted);
  font-size: 0.72rem;
  margin-top: 5px;
  opacity: 0.7;
}

.release-date.upcoming {
  color: var(--preorder);
  opacity: 1;
  font-weight: 600;
}

/* Pre-order (.upcoming) always wins when both would apply -- see the
   template's if/elif, this rule never actually needs to out-specify
   .upcoming since a release is never both, but the ordering documents
   the intended hierarchy regardless. */
.release-date.recent {
  color: var(--recent-release);
  opacity: 1;
  font-weight: 600;
}


.discovery-badge {
  background: rgba(99, 178, 255, 0.16);
  color: var(--accent);
  outline: 1px solid rgba(99, 178, 255, 0.4);
}

/* "Mutant For You" tab only -- how much the audio classifier likes this
   release's SOUND, not its tags. Flat acid-green tint + outline, the
   same shape as every other tint badge (see .new-artist-badge) -- the
   gradient sweep and glow it used to have made it read as a different,
   heavier control than the rest, and on the player bar the glow spilled
   past the circle so it looked bigger than the mark buttons next to it
   even though the box is the same 32px. Covers Sound Match, Second
   Chance and Set sound (all share this class). */
.mutant-badge {
  background: rgba(182, 255, 60, 0.14);
  color: var(--mutant);
  outline: 1px solid rgba(182, 255, 60, 0.45);
}

/* Playlist match -- fuchsia, a hue no other badge uses, tied to the ≣＋
   add-to-playlist language. Marks a feed card that SOUNDS like a playlist
   you built. */
.playlist-match-badge {
  background: rgba(232, 121, 201, 0.14);
  color: #e879c9;
  outline: 1px solid rgba(232, 121, 201, 0.5);
}
/* Colored (inherits the badge's fuchsia), not a monochrome emoji. */
.playlist-match-badge .badge-icon { display: inline-flex; }
.playlist-match-badge .badge-icon .ui-icon { width: 1em; height: 1em; }

/* Aqua -- a hue no other signal uses (green=Like, amber=Skip,
   rose=Dislike, purple=Play This, violet=Gem, gold=Hidden Gem,
   blue=accent/Taste Buddy, silver=You Own). */
.new-artist-badge {
  background: rgba(64, 208, 216, 0.14);
  color: var(--new-artist);
  outline: 1px solid rgba(64, 208, 216, 0.45);
}

/* Hand-marked "already know them": the badge stays, greyed, and a
   re-click reverses it -- a visible toggle, not a one-way dismissal. */
.new-artist-badge.known {
  color: var(--muted);
  outline-color: rgba(255, 255, 255, 0.18);
  background: rgba(255, 255, 255, 0.05);
  filter: grayscale(1);
  opacity: 0.75;
}

/* Matches the badge's aqua. */
@keyframes known-glow-pulse {
  0% { box-shadow: 0 0 0 0 rgba(64, 208, 216, 0); }
  30% { box-shadow: 0 0 24px 4px rgba(64, 208, 216, 0.55); }
  100% { box-shadow: 0 0 0 0 rgba(64, 208, 216, 0); }
}

.card.known-glow {
  animation: known-glow-pulse 1s ease;
}

.liked-badge {
  background: rgba(255, 105, 135, 0.16);
  color: #ff6987;
  outline: 1px solid rgba(255, 105, 135, 0.4);
}

/* outline, not border -- box-sizing:border-box means a real border eats
   into the circle's content area, which the emoji's own fixed-size
   wrapper (.badge-icon) doesn't (and, since it has to stay a constant
   size to never move, can't) compensate for per badge -- it showed up
   as the icon sitting visibly off-center. outline draws outside the box
   model entirely, so it can't affect the content area at all. */
.gem-badge {
  background: rgba(177, 140, 255, 0.16);
  color: var(--gem);
  outline: 1px solid rgba(177, 140, 255, 0.4);
}

.hidden-gem-badge {
  background: rgba(255, 209, 102, 0.16);
  color: var(--hidden-gem);
  outline: 1px solid rgba(255, 209, 102, 0.4);
}

/* Gem score tiers -- each has its own emoji (💎/💠/🌟) plus a flat,
   distinct pill color per tier (no glow/shadow -- just a plain tinted
   background and border, same visual weight as any other badge). */
.gem-badge.gem-tier-legendary {
  background: rgba(255, 179, 71, 0.22);
  color: #ffb347;
  outline-color: rgba(255, 179, 71, 0.5);
}

/* Silver/white rather than purple -- too close to the Play This purple
   (var(--play-this)) at a glance otherwise. */
.gem-badge.gem-tier-rare {
  background: rgba(226, 231, 240, 0.22);
  color: #e2e7f0;
  outline-color: rgba(226, 231, 240, 0.55);
}

.gem-badge.gem-tier-gem {
  background: rgba(110, 201, 227, 0.22);
  color: #6ec9e3;
  outline-color: rgba(110, 201, 227, 0.5);
}

/* "You May Play This" on the recommend tab -- Play This purple,
   marking recommendations that especially match the play-this-tagged
   releases specifically. A real <button>: clicking it likes + tags
   the release in one go (see the click handler) -- 🔮 rather than the
   🎧 the real Play This button/icon already uses, so the two don't
   read as the same control. */
.play-match-badge {
  background: rgba(184, 134, 255, 0.22);
  color: var(--play-this);
  outline: 1px solid rgba(184, 134, 255, 0.5);
}

/* Greyed once clicked -- same treatment as .new-artist-badge.known, and
   for the same reason: a visible "this is decided" state rather than
   the badge just vanishing. Driven by .card.play-this-active (already
   set by applyMarkResponse for the wash/outline), not a separate flag --
   a second click's unwishlist clears play_this server-side too, so the
   card really is back to undecided, not just untagged. */
.card.play-this-active .play-match-badge {
  color: var(--muted);
  outline-color: rgba(255, 255, 255, 0.18);
  background: rgba(255, 255, 255, 0.05);
  filter: grayscale(1);
  opacity: 0.75;
}

/* 🫂 Taste Buddy -- a warm coral, distinct from every other badge/
   status color already in use (like-green, skip-amber, dislike-rose,
   play-this-purple, gem-violet, hidden-gem-gold, accent-blue). */
/* The buddy's own profile photo IS the badge now; outline color says
   what their relationship to the release is -- blue = owns it, red =
   wishlisted only (plus a red wash on the photo). Falls back to the
   🫂 glyph when a buddy has no photo. */
.taste-buddy-badge {
  background: rgba(93, 173, 236, 0.18);
  color: #5dadec;
  outline: 1.5px solid #5dadec;
  padding: 0;
}

/* Fresh buddy pick -- a buddy just bought it. Green "just in" accent to
   read as new/recent, distinct from the plain (blue) taste-buddy badge. */
.fresh-buddy-badge {
  background: rgba(80, 210, 140, 0.18);
  color: #50d28c;
  outline: 1.5px solid #50d28c;
  padding: 0;
}
.fresh-buddy-badge .tb-emoji { color: #50d28c; }

/* Lives inside the same .badge-icon fixed-size slot every other badge's
   glyph does -- same chip diameter, so the photo reads as one of the
   same family of circular avatars as the label logo/status buttons. */
/* Smaller than its slot (not 100%) -- filling the circle edge to edge
   looked odd next to the emoji badges, which all have natural breathing
   room around the glyph inside the same circle (an emoji never fills
   its own em-box). Leaves a visible ring of the outline-colored
   background showing, matching that same proportions. */
.tb-photo {
  width: 78%;
  height: 78%;
  border-radius: 50%;
  object-fit: cover;
  flex-shrink: 0;
}

.taste-buddy-badge.tb-wishes {
  outline-color: #e05f6d;
  background: rgba(224, 95, 109, 0.18);
  color: #e05f6d;
}

/* Red wash over the photo for wishlist-only -- reads at a glance as
   "wants, doesn't own". */
.taste-buddy-badge.tb-wishes .tb-photo {
  filter: saturate(0.7) sepia(0.25) hue-rotate(-25deg) brightness(0.95);
}

/* Buddy Discovery pick -- a magenta/orchid, deliberately far from both
   the owns-blue and wishes-red above (and from every other badge color
   in the app: green/amber/pink/purple/copper/blue/gold/silver/cyan are
   all already spoken for) so this one is unmistakably a DIFFERENT kind
   of signal at a glance: not "you'd like this and a buddy happens to
   own it" (the plain badge above), but "a buddy suggests this,
   regardless of whether it resembles your usual taste at all". */
.taste-buddy-badge.tb-discovery {
  outline-color: #e04fd6;
  background: rgba(224, 79, 214, 0.18);
  color: #e04fd6;
}

.taste-buddy-badge.tb-discovery .tb-photo {
  filter: saturate(0.85) hue-rotate(15deg) brightness(1.02);
}

/* 💿 You Own This -- from the user's own scanned collection: the whole
   release, or specific tracks named in the tooltip ("You own
   "2. X" and "6. Y" from this release."). Neutral silver -- it's a
   fact about your library, not a signal color. */
.self-owned-badge {
  background: rgba(226, 231, 240, 0.18);
  color: #e2e7f0;
  outline: 1px solid rgba(226, 231, 240, 0.5);
}

/* min-height reserves room for 2 wrapped lines regardless of how many
   tags a release actually has -- without it, a release with enough
   tags to wrap pushes the embed player further down than a release
   with only one short line, so cards in the same grid row end up
   uneven heights. A release needing a 3rd line is a rare edge case
   left to grow past this rather than clipping/hiding real tag text. */
.tags {
  color: var(--muted);
  font-size: 0.72rem;
  line-height: 1.3;
  /* Exactly two lines, always: min-height reserves them when there's only
     one tag, the clamp on .tags-text stops a long list from taking a third
     and pushing everything below it out of line with the cards beside it.
     The clipped list comes back as a tooltip (see initPage) -- which is why
     the overflow sits on the span and not here, since hiding overflow on
     this element would clip the tooltip along with the tags. */
  min-height: 2.6em;
  margin-top: 5px;
  opacity: 0.8;
}

.tags-text {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.supporters {
  color: var(--muted);
  font-size: 0.72rem;
  margin-top: 3px;
  opacity: 0.7;
}

/* ===================== NATIVE PLAYER ===================== */

/* ---- Cover overlay: centered transport, revealed on hover ----
   The cluster is centered over the cover at z-index 4, which is ABOVE
   the decision color wash (.cover-wrap::after has no z-index), so the
   wash never tints the buttons. Hidden by default; fades in on hover of
   any card, and stays visible on the now-playing card so its play state
   is always shown. Uses per-button margins (not flex gap) so the
   collapsed transport buttons leave no phantom spacing. */
.np-overlay {
  display: flex;
  align-items: center;
  justify-content: center;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 4;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s ease;
}
.cover-wrap:hover .np-overlay,
.card.np-current .np-overlay {
  opacity: 1;
  pointer-events: auto;
}

.np-ov-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin: 0 5px;
  border-radius: 50%;
  border: none;
  background: rgba(10, 10, 11, 0.72);
  backdrop-filter: blur(6px);
  color: #fff;
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
  box-shadow: 0 4px 18px rgba(0, 0, 0, 0.5);
  transition: width 0.18s ease, margin 0.18s ease, transform 0.12s ease,
    background 0.15s ease, opacity 0.15s ease;
}
.np-ov-btn:hover { background: var(--accent); color: #0b0b0c; }
.np-ov-btn:active { transform: scale(0.92); }

/* Play/pause is the anchor: larger, and its glyph lives in ::before so
   it can swap to a pause on the playing card with no layout shift. */
.np-ov-play {
  width: 60px;
  height: 60px;
  font-size: 1.5rem;
}
/* Center glyph is a real SVG pair now (play/pause children toggled by
   .np-playing -- see the ov-play-i/ov-pause-i rules in the icon set
   block); the old unicode ::before glyphs are gone. */
.np-ov-play .ui-icon.ov-play-i { margin-left: 2px; }   /* optical center */
.card.np-playing .np-ov-play { background: var(--accent); color: #0b0b0c; }

/* Prev / next only apply to the release actually playing, and only while
   you're pointing at it -- collapsed to zero width elsewhere so the
   resting state is a single centered play/pause. They expand equally on
   both sides, so the play button stays dead-center in both states.
   (Stop lives in the always-visible bottom bar, not here.) */
.np-ov-prev,
.np-ov-next {
  width: 0;
  /* Flex items default to min-width:auto, which refuses to shrink below
     the button's own content (icon + the generic `button` rule's own
     8px/14px padding) -- so width:0 alone settled at ~28px, not 0. That
     residual sliver was invisible while opacity correctly reached 0, but
     became a visible "stuck half-open" ghost the moment the button was
     ALSO disabled (see the opacity note below). min-width/padding:0 make
     the collapse genuinely reach zero. */
  min-width: 0;
  padding: 0;
  margin: 0;
  opacity: 0;
  pointer-events: none;
  transform: scale(0.6);
}
/* A disabled <button> is hit by the app-wide `button:disabled { opacity:
   .6 }` rule (style.css's global reset) -- pseudo-classes count as much
   as classes in specificity, so that generic rule (0,0,1,1) actually
   OUTRANKS the plain collapse selector above (0,0,1,0) and pinned a
   disabled prev/next visibly at 0.6 opacity forever, never fading to 0.
   This selector matches the same disabled buttons but as two classes
   (0,0,2,0), which wins outright. */
.np-ov-prev:disabled,
.np-ov-next:disabled {
  opacity: 0;
}
.card.np-current .cover-wrap:hover .np-ov-prev,
.card.np-current .cover-wrap:hover .np-ov-next {
  width: 44px;
  margin: 0 5px;
  opacity: 1;
  pointer-events: auto;
  transform: scale(1);
}
/* Nothing in that direction -> greyed out and genuinely unclickable,
   on the island and the bar's transport alike (JS sets disabled from
   the queue's playable range on every track load). */
.card.np-current .cover-wrap:hover .np-ov-prev:disabled,
.card.np-current .cover-wrap:hover .np-ov-next:disabled {
  opacity: 0.22;
  pointer-events: none;
  cursor: default;
}
.np-btn:disabled {
  opacity: 0.22;
  pointer-events: none;
  cursor: default;
}

/* The now-playing card (in the tab playback started from) gets the
   whole-card active treatment -- the same visual language as the Last
   Clicked card, since in native mode "what's playing" IS the live
   equivalent of "what you last clicked". */
.card.np-current {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}

/* The same release encountered on any OTHER tab while it plays: greyed
   out under a "currently being played" note -- the live card is in the
   origin tab (the bar's cover/title click takes you there). Its overlay
   transport is disabled here so the note is unambiguous. */
.np-elsewhere-note { display: none; }
.card.np-elsewhere .np-overlay { display: none; }
.card.np-elsewhere .cover {
  filter: grayscale(1) brightness(0.5);
}
.card.np-elsewhere .np-elsewhere-note {
  display: flex;
  align-items: center;
  justify-content: center;
  position: absolute;
  inset: 0;
  z-index: 4;
  padding: 12px;
  text-align: center;
  color: #fff;
  font-size: 0.82rem;
  font-weight: 600;
  line-height: 1.4;
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
  background: rgba(10, 10, 11, 0.35);
  border-radius: inherit;
  pointer-events: none;
}

/* Pulsating live dot on the nav name of the tab the playing track lives
   in (only when that's NOT the tab you're on) -- same pulse as the Last
   Clicked island's. Absolutely positioned inside the link's own right
   padding so it takes ZERO layout space: appearing/disappearing never
   moves any tab name. */
.tabs a,
.library-subtabs a { position: relative; }
.tabs a.np-live-tab::after,
.library-subtabs a.np-live-tab::after {
  content: '';
  position: absolute;
  right: 3px;
  top: 50%;
  transform: translateY(-50%);
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 6px 1px var(--accent);
  /* own keyframes: the shared pulse animates bare transform:scale,
     which would wipe the translateY centering above */
  animation: np-tab-dot-pulse 1.4s ease-in-out infinite;
}
.library-subtabs a.np-live-tab::after { right: 2px; }

@keyframes np-tab-dot-pulse {
  0%, 100% { opacity: 1; transform: translateY(-50%) scale(1); }
  50% { opacity: 0.4; transform: translateY(-50%) scale(0.7); }
}


/* Touch devices have no hover, so idle covers show no overlay at all --
   you start a release by tapping a track row below (Bandcamp-style),
   which keeps mobile covers clean. The now-playing card still needs its
   transport reachable, so reveal it there without requiring a hover. */
@media (hover: none) {
  .card.np-current .np-ov-prev,
  .card.np-current .np-ov-next {
    width: 44px;
    margin: 0 5px;
    opacity: 1;
    pointer-events: auto;
    transform: scale(1);
  }
}

/* ---- Inline tracklist ----
   Clickable and scrollable. FIXED height per card size so every card in
   the grid stays the same height no matter how many tracks a release
   has -- long lists scroll inside the box. */
.np-inline { display: block; }

.np-inline-tracks {
  list-style: none;
  margin: 0;
  padding: 4px;
  /* Grow to fit the tracklist, but cap at 5 rows (a row is 32px + the
     box's 8px vertical padding) and scroll past that -- a 20-track VA
     comp no longer makes the card a mile tall, and a 3-track EP gets no
     dead space. One cap for every card size, on purpose. */
  /* FIXED, not max: every card's tracklist box is the same height, a
     1-track single doesn't shrink the card (user-requested symmetry).
     Long lists scroll inside; short lists get breathing room. */
  height: var(--npi-max-h, 168px);
  overflow-y: auto;
  border-radius: 6px;
  background: #181a1b;
  border: 1px solid var(--border);
}

.np-inline-track {
  display: flex;
  align-items: center;
  gap: 8px;
  /* tighter on the left: the number column right-aligns, so its own
     empty lead already reads as spacing -- a wide row pad doubled it */
  padding: 7px 10px 7px 6px;
  border-radius: 5px;
  cursor: pointer;
  color: var(--text);
  transition: background 0.12s ease, color 0.12s ease;
}
.np-inline-track:hover { background: rgba(255, 255, 255, 0.06); }
.np-inline-track:hover .npi-num { color: var(--text); }

/* Unstreamable rows (preorder tracks not yet dropped, private tracks)
   -- listed like the embed lists them, greyed out, not clickable. Same
   font/row box as playable rows so nothing shifts. */
.np-inline-track.unavailable { opacity: 0.4; cursor: default; }
.np-inline-track.unavailable:hover { background: none; }
.np-inline-track.unavailable:hover .npi-num { color: var(--muted); }

.npi-num {
  flex-shrink: 0;
  width: 1.2em;
  text-align: right;
  color: var(--muted);
  font-size: 0.78rem;
  font-variant-numeric: tabular-nums;
}
.npi-title {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 0.85rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.npi-dur {
  flex-shrink: 0;
  color: var(--muted);
  font-size: 0.75rem;
  font-variant-numeric: tabular-nums;
}
/* Detected BPM, left of the duration -- appears once a track has been
   analyzed (playing it once does that). Empty slot collapses. */
.npi-bpm {
  flex-shrink: 0;
  color: var(--muted);
  opacity: 0.75;
  font-size: 0.68rem;
  font-variant-numeric: tabular-nums;
}
.npi-bpm:not(:empty)::after { content: ' BPM'; font-size: 0.85em; }
.npi-bpm.measuring::after { content: ''; }
.npi-bpm.measuring { animation: np-bpm-pulse 1.1s ease-in-out infinite; }
body.no-tempo-bpm .npi-bpm { display: none; }
/* Quiet "detect BPMs" control pinned over the tracklist's bottom-right
   corner -- batch-analyze the whole release without playing it. Nearly
   invisible until hovered; gone once every track is measured. */
.np-inline { position: relative; }
.npi-detect-all {
  position: absolute;
  right: 10px;
  bottom: 6px;
  background: rgba(24, 26, 27, 0.85);
  border: none;
  border-radius: 4px;
  color: var(--muted);
  opacity: 0.5;
  font-size: 0.68rem;
  padding: 3px 7px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.15s ease, opacity 0.15s ease;
}
.npi-detect-all:hover:not(:disabled) { color: var(--accent); opacity: 1; }
.npi-detect-all:disabled { cursor: default; opacity: 0.7; animation: np-bpm-pulse 1.1s ease-in-out infinite; }
.npi-detect-all.hidden { display: none; }
body.no-tempo-bpm .npi-detect-all { display: none; }
.np-inline-loading {
  padding: 10px 12px;
  color: var(--muted);
  font-size: 0.8rem;
  list-style: none;
}

/* Per-track "I would play this" headphone -- the old hover panel's job,
   inline. The slot is ALWAYS in the layout (visibility, not display) so
   rows never shift on the x-axis when liking reveals the buttons; grey
   until the track is picked, purple (with a purple title) once it is.
   Purple only reads while the card actually carries the Play This tag,
   so an untagging Skip/Dislike greys the picks without losing them. */
.npi-pt {
  visibility: hidden;
  flex-shrink: 0;
  width: 24px;
  height: 22px;
  padding: 0;
  border: none;
  border-radius: 5px;
  background: transparent;
  font-size: 0.78rem;
  line-height: 1;
  cursor: pointer;
  filter: grayscale(1);
  opacity: 0.45;
  transition: filter 0.15s ease, opacity 0.15s ease, transform 0.1s ease;
}
.card.status-liked .npi-pt { visibility: visible; }
.npi-pt:hover { opacity: 0.9; transform: scale(1.15); }

/* Track's own Bandcamp page, left of the duration -- same ↗ language
   as "Listen in Bandcamp app". Muted until hovered; fixed slot so rows
   with and without a link stay aligned. */
.npi-open {
  flex-shrink: 0;
  width: 18px;
  text-align: center;
  color: var(--muted);
  opacity: 0.55;
  font-size: 0.8rem;
  line-height: 1;
  text-decoration: none;
  transition: color 0.12s ease, opacity 0.12s ease, transform 0.1s ease;
}
.npi-open:hover { color: var(--accent); opacity: 1; transform: scale(1.2); }

/* Add-this-track-to-a-playlist button in each inline track row. */
.npi-addpl {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  padding: 0;
  border: none;
  background: none;
  color: var(--muted);
  opacity: 0.55;
  cursor: pointer;
  line-height: 1;
  transition: color 0.12s ease, opacity 0.12s ease, transform 0.1s ease;
}
.npi-addpl .ui-icon { width: 15px; height: 15px; }
.npi-addpl:hover { color: var(--accent); opacity: 1; transform: scale(1.15); }
/* Already in a playlist: fuchsia check that holds on without a hover. */
.npi-addpl.added, .npi-addpl.added:hover { color: #e879c9; opacity: 1; }
/* The tracklist box scrolls (overflow-y:auto), which CLIPPED this button's
   tooltip on the FIRST row -- it rendered above, outside the box's top
   edge, and vanished. Render it BELOW instead, right-anchored so it also
   clears the box's right edge, and lift it above the rows. */
.npi-addpl[data-tooltip]::after {
  bottom: auto;
  top: calc(100% + 6px);
  left: auto;
  right: 0;
  transform: translateY(-4px);
  z-index: 40;
}
.npi-addpl[data-tooltip]:hover::after,
.npi-addpl[data-tooltip].tooltip-open::after { transform: translateY(0); }
.npi-addpl[data-tooltip]::before { display: none; }   /* the up-arrow no longer fits */

/* The playlist picker popover + its "just added" toast. */
.playlist-picker {
  position: absolute;
  /* above the dig overlay (950) and the in-dig player bar (960) so the
     popover shows when opened from a bar/dig + button, not behind them */
  z-index: 980;
  width: 232px;
  max-height: 320px;
  overflow-y: auto;
  background: var(--card, #16181c);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 10px 34px rgba(0, 0, 0, 0.55);
  padding: 8px;
  font-size: 0.85rem;
}
.playlist-picker .pp-head {
  color: var(--muted);
  font-size: 0.75rem;
  padding: 2px 6px 6px;
  border-bottom: 1px solid var(--border);
  margin-bottom: 6px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.playlist-picker .pp-list { display: flex; flex-direction: column; gap: 2px; margin-bottom: 6px; }
.playlist-picker .pp-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  text-align: left;
  background: none;
  border: none;
  color: var(--text);
  font: inherit;
  padding: 6px 8px;
  border-radius: 7px;
  cursor: pointer;
}
.playlist-picker .pp-item:hover { background: rgba(255, 255, 255, 0.06); }
.playlist-picker .pp-count { color: var(--muted); font-size: 0.72rem; flex: none; }
.playlist-picker .pp-new { display: flex; gap: 6px; }
.playlist-picker .pp-new input {
  flex: 1 1 auto;
  min-width: 0;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--border);
  border-radius: 7px;
  color: var(--text);
  font: inherit;
  padding: 5px 8px;
}
.playlist-picker .pp-new input:focus { outline: 1px solid var(--accent); }
.playlist-picker .pp-new button {
  flex: none;
  background: var(--accent);
  color: #0b0c0e;
  border: none;
  border-radius: 7px;
  font-weight: 800;
  padding: 0 10px;
  cursor: pointer;
}
.pl-toast {
  position: fixed;
  left: 50%;
  bottom: 96px;
  transform: translateX(-50%) translateY(10px);
  z-index: 200;
  background: var(--accent);
  color: #0b0c0e;
  font-weight: 600;
  font-size: 0.85rem;
  padding: 9px 16px;
  border-radius: 999px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
  opacity: 0;
  transition: opacity 0.25s ease, transform 0.25s ease;
  pointer-events: none;
}
.pl-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }

/* Playlists view (show=playlists). */
.playlists-view { display: flex; flex-direction: column; gap: 18px; padding: 4px 4px 100px; }
.playlist-block { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
.playlist-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.playlist-name { font-weight: 700; font-size: 1.05rem; }
.playlist-count { color: var(--muted); font-size: 0.78rem; }
.playlist-head-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.playlist-delete-btn { background: none; border: none; color: var(--muted); cursor: pointer; padding: 4px; line-height: 0; }
.playlist-delete-btn:hover { color: var(--dislike, #e66a6a); }
.playlist-tracks { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.playlist-track { display: flex; align-items: center; gap: 10px; padding: 5px 6px; border-radius: 8px; }
.playlist-track:hover { background: rgba(255, 255, 255, 0.04); }
/* Closed-taste suggestions shown right under their playlist (moved here from
   the Similars tab). Separated from the tracklist by a hairline rule. */
.playlist-similar { margin-top: 16px; border-top: 1px solid var(--border); padding-top: 14px; }
.playlist-similar-head { display: flex; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--muted); margin-bottom: 12px; }
.playlist-similar-date { margin-left: auto; font-size: 0.72rem; opacity: 0.7; }
.playlist-similar-empty { margin-top: 14px; }
/* Suggestion-run history under a playlist (earlier Suggest runs, collapsed). */
.playlist-similar-history { margin-top: 16px; }
.playlist-history-heading { display: flex; align-items: center; gap: 6px; font-size: 0.76rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; margin: 0 0 8px; }
/* Track number before the cover art; tabular so digits line up down the list. */
.pt-num { flex: none; width: 1.6em; text-align: right; color: var(--muted); font-size: 0.78rem; font-variant-numeric: tabular-nums; }
/* Tracklist capped at 10 rows until "Show all" (pt-capped hides the overflow). */
.playlist-tracks.pt-capped .pt-extra { display: none; }
.playlist-showall-btn { margin-top: 8px; background: none; border: none; color: var(--accent); font-size: 0.8rem; cursor: pointer; padding: 4px 2px; }
.playlist-showall-btn:hover { text-decoration: underline; }
.pt-cover { width: 34px; height: 34px; border-radius: 5px; object-fit: cover; flex: none; }
.pt-cover-empty { background: rgba(255, 255, 255, 0.06); }
.pt-meta { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
.pt-title { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pt-sub { color: var(--muted); font-size: 0.74rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* BPM cell filled by the playlist's Detect BPM button. Empty until then. */
.pt-bpm { flex: none; font-size: 0.72rem; color: var(--accent); font-variant-numeric: tabular-nums; white-space: nowrap; }
.pt-bpm:empty { display: none; }
.pt-play, .pt-open, .pt-remove {
  flex: none; background: none; border: none; color: var(--muted);
  cursor: pointer; padding: 4px; line-height: 0; text-decoration: none; font-size: 0.85rem;
}
.pt-play:hover, .pt-open:hover { color: var(--accent); }
.pt-remove:hover { color: var(--dislike, #e66a6a); }
.card.play-this-active .np-inline-track.pt-selected .npi-pt,
.dig-card.play-this-active .np-inline-track.pt-selected .npi-pt {
  filter: none;
  opacity: 1;
}
.card.play-this-active .np-inline-track.pt-selected .npi-title,
.dig-card.play-this-active .np-inline-track.pt-selected .npi-title {
  color: var(--play-this);
}

/* The row whose track is currently playing: blue highlight, nothing
   more -- the number stays put and no marker swaps in, so the row's
   layout never moves. Playing-blue wins over the purple pick color on
   the title while the track is actually live. */
.np-inline-track.playing { background: rgba(99, 178, 255, 0.14); color: var(--accent); }
.np-inline-track.playing .npi-num,
.np-inline-track.playing .npi-dur { color: var(--accent); }
.card.play-this-active .np-inline-track.pt-selected.playing .npi-title,
.dig-card.play-this-active .np-inline-track.pt-selected.playing .npi-title { color: var(--accent); }

/* Persistent bottom bar. Fixed, above everything, safe-area aware. */
.np-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 80;
  display: flex;
  align-items: center;
  gap: 12px;
  /* NEVER wraps -- one fixed-height row always (like SoundCloud). The
     seek bar is the sole flexible element (it grows/shrinks with the
     window); everything else is fixed-width, and non-essentials hide at
     narrow widths (see the media queries) rather than wrapping to a 2nd
     row or shoving the right-hand controls off-screen. Full-bleed chrome
     with content capped at the generous --bar-max. */
  flex-wrap: nowrap;
  padding: 8px max(16px, calc((100% - var(--bar-max)) / 2)) calc(8px + env(safe-area-inset-bottom));
  background: rgba(18, 19, 20, 0.96);
  backdrop-filter: blur(14px) saturate(1.2);
  border-top: 1px solid var(--border);
  box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.45);
}
.np-bar.hidden { display: none; }
/* One-row bar is ~63px; clear it so the last card's controls aren't
   hidden behind the fixed bar (mobile blocks override for the taller
   mini-bar + tab-bar stack). */
body .grid { padding-bottom: 92px; }

.np-cover {
  width: 46px;
  height: 46px;
  border-radius: 6px;
  object-fit: cover;
  flex-shrink: 0;
  background: #181a1b;
}
/* Fixed basis, NOT content-sized: a long or short track name must never
   move the buttons/seek around -- the name truncates inside its fixed
   slot instead (responsive via vw, stable via clamp). */
.np-meta { min-width: 0; flex: 0 0 clamp(140px, 15vw, 230px); }
.np-track {
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.np-artist {
  font-size: 0.75rem;
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Cover + meta double as the jump-back-to-the-card button. */
.np-jump { cursor: pointer; }
.np-meta.np-jump:hover .np-track { color: var(--accent); }
.np-cover.np-jump:hover { box-shadow: 0 0 0 2px var(--accent); }

/* The playing release's cover badges, cloned from its card -- fixed
   small circles here (the card's badges size via container-query units
   that don't exist in the bar), informational only. */
.np-badges {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-shrink: 0;
}
.np-badges:empty { display: none; }
/* Same size as the mark buttons next door -- one visual rhythm. */
.np-badges .cover-badge {
  width: 32px;
  height: 32px;
  font-size: 0.9rem;
  pointer-events: none;
}
.np-badges .badge-icon { font-size: 0.9rem; }
.np-badges .icon-label { display: none !important; }

/* Like/Skip/Dislike (+ always-present 🎧 Play This) for the playing
   release -- the card buttons' visual language at a fixed bar-friendly
   size. Per-button margins instead of flex gap so the decided-collapse
   below leaves no phantom spacing. */
.np-marks {
  display: flex;
  align-items: center;
  flex-shrink: 0;
}
.np-mark-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  margin: 0 3px;
  /* explicit: a global button rule pads 8px 14px, which would floor the
     decided-collapse (width 0) at 28px instead of vanishing */
  padding: 0;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: rgba(255, 255, 255, 0.04);
  color: var(--muted);
  font-size: 0.9rem;
  line-height: 1;
  cursor: pointer;
  /* NOT overflow:hidden -- the data-tooltip renders as this button's own
     ::after, and hidden overflow silently clipped every bar tooltip
     (Like/Skip/Dislike/🎧 hover texts never showed). The decided-collapse
     hides folded buttons via opacity+visibility below instead. */
  overflow: visible;
  flex-shrink: 0;
  transition: border-color 0.15s ease, color 0.15s ease, transform 0.1s ease,
    width 0.18s ease, margin 0.18s ease, opacity 0.15s ease,
    visibility 0s linear 0.18s;
}
.np-mark-btn:hover { transform: scale(1.08); }
.np-mark-btn .x-icon { width: 0.82em; height: 0.82em; flex-shrink: 0; }
.np-mark-like:hover, .np-mark-like.active { border-color: var(--like); color: var(--like); }
.np-mark-skip:hover, .np-mark-skip.active { border-color: var(--skip); color: var(--skip); }
.np-mark-dislike:hover, .np-mark-dislike.active { border-color: var(--dislike); color: var(--dislike); }
.np-mark-pt:hover, .np-mark-pt.active { border-color: var(--play-this); color: var(--play-this); }
.np-mark-pt .ui-icon { width: 17px; height: 17px; }
/* Add-to-playlist: not a verdict -- accent, not a decision colour. */
.np-mark-addpl:hover { border-color: var(--accent); color: var(--accent); }
.np-mark-addpl .ui-icon { width: 17px; height: 17px; }
/* Playing cut already in a playlist: fuchsia check + tinted border. */
.np-mark-addpl.added { color: #e879c9; border-color: color-mix(in srgb, #e879c9 55%, transparent); }
.np-mark-addpl.added:hover { color: #e879c9; border-color: #e879c9; }
.np-mark-pt.active { background: rgba(184, 134, 255, 0.18); }
.np-mark-btn.active { background: rgba(255, 255, 255, 0.08); }

/* Decided -> the two inactive status buttons fold away (same move as
   the card's cluster). Bringing them back is scoped to hovering THE
   ACTIVE decision itself (:has), not the whole cluster: hovering the
   purple 🎧 or the ≣＋ playlist button -- which sit in the same row but
   aren't verdicts -- must NOT re-reveal Skip/Dislike. Both of those
   never collapse either. */
.np-marks.decided .np-mark-btn:not(.active):not(.np-mark-pt):not(.np-mark-addpl) {
  width: 0;
  margin: 0;
  opacity: 0;
  border-width: 0;
  pointer-events: none;
  /* stands in for the removed overflow:hidden -- a folded button's icon
     must not paint over its neighbors (visibility waits out the fold
     animation via the transition-delay above) */
  visibility: hidden;
}
.np-marks.decided:has(.np-mark-btn.active:hover) .np-mark-btn:not(.active):not(.np-mark-pt):not(.np-mark-addpl) {
  width: 32px;
  margin: 0 3px;
  opacity: 1;
  border-width: 1px;
  pointer-events: auto;
  visibility: visible;
  transition-delay: 0s;
}

/* ---- The bar wears the decision's color ----
   Marking from the bar (or any card copy -- state syncs both ways)
   tints the whole player: colored top edge with a soft glow bleeding
   above it, the seek bar, a ring on the cover, and a faint wash rising
   from the bottom. Play This layers its purple over Liked's green,
   exactly like the card's two-hue cover wash. A quick brightness pop
   marks the moment of change. */
.np-bar {
  transition: border-color 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
}
.np-bar.np-liked {
  border-top-color: var(--like);
  box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.45), 0 -2px 18px -6px var(--like);
  background:
    linear-gradient(0deg, rgba(79, 191, 140, 0.10), rgba(79, 191, 140, 0) 70%),
    rgba(18, 19, 20, 0.96);
}
.np-bar.np-liked .np-scrub { accent-color: var(--like); }
.np-bar.np-liked .np-cover { box-shadow: 0 0 0 2px var(--like); }
.np-bar.np-skipped {
  border-top-color: var(--skip);
  box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.45), 0 -2px 18px -6px var(--skip);
  background:
    linear-gradient(0deg, rgba(214, 162, 92, 0.10), rgba(214, 162, 92, 0) 70%),
    rgba(18, 19, 20, 0.96);
}
.np-bar.np-skipped .np-scrub { accent-color: var(--skip); }
.np-bar.np-skipped .np-cover { box-shadow: 0 0 0 2px var(--skip); }
.np-bar.np-disliked {
  border-top-color: var(--dislike);
  box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.45), 0 -2px 18px -6px var(--dislike);
  background:
    linear-gradient(0deg, rgba(217, 116, 144, 0.10), rgba(217, 116, 144, 0) 70%),
    rgba(18, 19, 20, 0.96);
}
.np-bar.np-disliked .np-scrub { accent-color: var(--dislike); }
.np-bar.np-disliked .np-cover { box-shadow: 0 0 0 2px var(--dislike); }
/* Play This (always alongside Liked): green->purple sweep, purple edge. */
.np-bar.np-pt {
  border-top-color: var(--play-this);
  box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.45), 0 -2px 18px -6px var(--play-this);
}
.np-bar.np-pt .np-scrub { accent-color: var(--play-this); }
.np-bar.np-liked.np-pt {
  background:
    linear-gradient(105deg, rgba(79, 191, 140, 0.12), rgba(184, 134, 255, 0.12)),
    rgba(18, 19, 20, 0.96);
}
.np-bar.np-liked.np-pt .np-cover {
  box-shadow: 0 0 0 2px var(--play-this);
}
.np-bar.np-state-pop { animation: np-state-pop 0.6s ease; }
@keyframes np-state-pop {
  0% { filter: brightness(1.7) saturate(1.4); }
  100% { filter: brightness(1) saturate(1); }
}
.np-controls { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.np-btn {
  background: transparent;
  border: none;
  color: var(--text);
  font-size: 1.05rem;
  cursor: pointer;
  padding: 6px;
  line-height: 1;
  border-radius: 6px;
}
.np-btn:hover { color: var(--accent); }
.np-playpause { font-size: 1.35rem; }
.np-seek {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 2 1 260px;
  min-width: 120px;
}
.np-time { font-size: 0.72rem; color: var(--muted); font-variant-numeric: tabular-nums; flex-shrink: 0; }
.np-scrub { flex: 1 1 auto; accent-color: var(--accent); cursor: pointer; }
/* Opt-in waveform seekbar. Sits in the same slot as #np-scrub (only one
   is shown at a time, swapped by JS); a fixed CSS height keeps the canvas
   from taking its 150px intrinsic size, while JS sets the backing-store
   pixels for devicePixelRatio crispness. touch-action:none so a drag on it
   seeks instead of scrolling the page. */
.np-wave {
  display: none;
  flex: 1 1 auto;
  width: 100%;
  min-width: 0;
  height: 30px;
  border-radius: 5px;
  cursor: pointer;
  touch-action: none;
}
/* With the waveform ON the seek area is the bar's centrepiece -- claim
   real width for it (the plain scrub never needs this much). The times
   move INTO the canvas (drawWaveOverlay paints them in the corners), so
   their flanking spans hide and every freed pixel goes to the wave; the
   title block gives up its fixed width and shrinks with ellipsis. The
   class is stamped by swapSeekUI alongside the canvas/scrub swap. */
body.waveform-on .np-seek { flex: 6 1 420px; }
body.waveform-on .np-seek .np-time { display: none; }
body.waveform-on .np-meta.np-jump { flex: 0 1 210px; min-width: 90px; }
/* Bandcamp jump. The bar's cover/meta jumps to the CARD; this leaves the
   app entirely for the track's own Bandcamp page -- the one place you can
   actually buy it. Brand blue only on hover, so it reads as a link and not
   as a fourth transport button. */
.np-bandcamp { color: var(--muted); display: inline-flex; align-items: center; }
.np-bandcamp:hover { color: var(--bandcamp); }
.np-bandcamp[hidden] { display: none; }

/* ---- Tempo + BPM cluster (Bandcamp Tempo Adjust, natively) ----
   BPM chip / ±20% slider / % readout / ♪ key lock. The whole cluster
   obeys the "Tempo adjust + BPM detection" setting. */
body.no-tempo-bpm .np-tempo { display: none; }
.np-tempo { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.np-bpm {
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--border);
  border-radius: 999px;
  color: var(--text);
  font-size: 0.72rem;
  font-variant-numeric: tabular-nums;
  padding: 5px 9px;
  line-height: 1;
  /* FIXED width, not min-width: the readout flips between "128 BPM" and
     "128.1 BPM" on every tempo tick, and a content-sized chip made the
     whole right half of the bar (incl. the seek) wobble while sliding. */
  width: 84px;
  text-align: center;
  white-space: nowrap;
  cursor: pointer;
  transition: border-color 0.15s ease, color 0.15s ease;
}
.np-bpm:hover { border-color: var(--accent); }
/* Double-click the chip to type a BPM: an inline field sized exactly like the
   chip it replaces, so the bar never shifts while you edit. */
.np-bpm-input {
  width: 84px;
  box-sizing: border-box;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--accent);
  border-radius: 999px;
  color: var(--text);
  font-size: 0.72rem;
  font-variant-numeric: tabular-nums;
  padding: 5px 9px;
  line-height: 1;
  text-align: center;
  outline: none;
}
.np-bpm.detecting {
  color: var(--muted);
  animation: np-bpm-pulse 1.1s ease-in-out infinite;
}
@keyframes np-bpm-pulse {
  0%, 100% { opacity: 0.45; }
  50% { opacity: 1; }
}
/* ↻ re-analyse the playing track's BPM -- borderless and quiet next to
   the chip; a soft pulse (no rotation) while a run is going. */
.np-reanalyse {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: none;
  background: transparent;
  color: var(--muted);
  opacity: 0.55;
  font-size: 0.85rem;
  line-height: 1;
  cursor: pointer;
  flex-shrink: 0;
  transition: color 0.15s ease, opacity 0.15s ease;
}
.np-reanalyse:hover:not(:disabled) { color: var(--accent); opacity: 1; }
.np-reanalyse:disabled { opacity: 0.2; cursor: default; }
.np-reanalyse.busy { color: var(--accent); animation: np-bpm-pulse 1.1s ease-in-out infinite; }

/* Hot cue toggle -- when lit, every track that loads jumps straight to
   its drop (the same seek dig mode's Fast mode uses). A compact pill
   sized like its .np-bpm neighbour, with the dig toggle's lit-dot
   treatment for the engaged state. */
.np-hotcue {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--border);
  border-radius: 999px;
  color: var(--muted);
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  padding: 5px 9px;
  line-height: 1;
  white-space: nowrap;
  cursor: pointer;
  flex-shrink: 0;
  transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.np-hotcue-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--muted);
  opacity: 0.5;
  transition: background 0.15s, opacity 0.15s, box-shadow 0.15s;
}
.np-hotcue:hover { color: var(--text); border-color: var(--accent); }
.np-hotcue.on {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.np-hotcue.on .np-hotcue-dot {
  background: var(--accent);
  opacity: 1;
  box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 70%, transparent);
}
.np-hotcue:focus:not(:focus-visible) { outline: none; }

/* Tempo range chip -- click cycles ±8/±16/±20/±50 (CDJ-style: a
   tighter range over the same fader travel gives finer control). Small
   and quiet, matching the % readout next door rather than drawing
   attention to itself. */
.np-tempo-range {
  background: transparent;
  border: none;
  color: var(--muted);
  opacity: 0.6;
  font-size: 0.68rem;
  font-variant-numeric: tabular-nums;
  padding: 4px 2px;
  min-width: 26px;
  text-align: right;
  cursor: pointer;
  line-height: 1;
  transition: color 0.15s ease, opacity 0.15s ease;
}
.np-tempo-range:hover { color: var(--accent); opacity: 1; }
/* The pitch fader, styled after a Technics SL-1200's: a recessed slot
   with a tick scale, a chunky brushed-metal knob with a center index
   line, and the quartz-lock light -- the center tick glows green only
   while the fader sits exactly on 0 (JS flips .off-center otherwise).
   Deliberately nothing like the seek scrub next door. */
.np-tempo-slider {
  -webkit-appearance: none;
  appearance: none;
  width: 132px;
  height: 26px;
  margin: 0;
  padding: 0;
  border: none;
  --detent: #4ade80;                       /* quartz lock: on 0 */
  background:
    /* center (0) tick, over the scale */
    linear-gradient(var(--detent) 0 0) no-repeat center / 2px 14px,
    /* tick scale: one every 12.5%, plus the right edge the repeat misses */
    linear-gradient(90deg, rgba(255, 255, 255, 0.3) 0 1px, transparent 1px) repeat-x left center / 12.5% 9px,
    linear-gradient(rgba(255, 255, 255, 0.3) 0 0) no-repeat right center / 1px 9px;
  cursor: ew-resize;
}
.np-tempo-slider.off-center { --detent: rgba(255, 255, 255, 0.55); }
.np-tempo-slider::-webkit-slider-runnable-track {
  height: 5px;
  border-radius: 3px;
  background: #050607;
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.9), 0 1px 0 rgba(255, 255, 255, 0.05);
}
.np-tempo-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 14px;
  height: 24px;
  margin-top: -9.5px;
  border-radius: 3px;
  border: 1px solid rgba(0, 0, 0, 0.9);
  background:
    /* index line across the knob, perpendicular to travel */
    linear-gradient(90deg, transparent 0 calc(50% - 1px), #e6e6e6 calc(50% - 1px) calc(50% + 1px), transparent calc(50% + 1px)),
    /* brushed-metal face */
    linear-gradient(180deg, #43474b, #26282b 50%, #1a1c1e);
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.18);
  cursor: ew-resize;
}
.np-tempo-slider::-moz-range-track {
  height: 5px;
  border-radius: 3px;
  background: #050607;
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.9), 0 1px 0 rgba(255, 255, 255, 0.05);
}
.np-tempo-slider::-moz-range-thumb {
  width: 14px;
  height: 24px;
  border-radius: 3px;
  border: 1px solid rgba(0, 0, 0, 0.9);
  background:
    linear-gradient(90deg, transparent 0 calc(50% - 1px), #e6e6e6 calc(50% - 1px) calc(50% + 1px), transparent calc(50% + 1px)),
    linear-gradient(180deg, #43474b, #26282b 50%, #1a1c1e);
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.18);
  cursor: ew-resize;
}
.np-tempo-slider:hover::-webkit-slider-thumb { border-color: rgba(255, 255, 255, 0.35); }
.np-tempo-slider:hover::-moz-range-thumb { border-color: rgba(255, 255, 255, 0.35); }
.np-tempo-val {
  background: transparent;
  border: none;
  color: var(--muted);
  font-size: 0.72rem;
  font-variant-numeric: tabular-nums;
  padding: 4px 2px;
  /* fixed for the same reason as .np-bpm: "+10.0%" vs "0%" must not
     re-solve the bar's flex layout on every slider tick */
  width: 46px;
  text-align: right;
  cursor: pointer;
  line-height: 1;
}
.np-tempo-val.active { color: var(--accent); font-weight: 600; }
.np-pitch {
  display: flex;
  align-items: center;
  justify-content: center;
  /* The generic button rule's 14px side padding is wider than this box, and
     border-box floors the element at padding+border -- that stretched the
     circle into a 30x26 ellipse. Reset it so 26x26 is really 26x26. */
  padding: 0;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: rgba(255, 255, 255, 0.04);
  color: var(--muted);
  font-size: 0.85rem;
  line-height: 1;
  cursor: pointer;
  flex-shrink: 0;
  transition: border-color 0.15s ease, color 0.15s ease;
}
.np-pitch:hover { border-color: var(--accent); }
.np-pitch.active {
  border-color: var(--accent);
  color: var(--accent);
  background: rgba(99, 178, 255, 0.14);
}

/* Volume: quiet icon + slim slider; icon doubles as mute toggle. */
.np-vol {
  display: flex;
  align-items: center;
  gap: 4px;
  flex-shrink: 0;
}
.np-vol-icon { font-size: 0.85rem; padding: 4px; }
/* Always visible -- the slider stays put, no hover-to-reveal. It already
   reserves its width (the fade never changed layout), so keeping it shown
   costs no space; the seek bar's flex claim is unaffected. It just brightens
   from muted to accent on hover/focus/keyboard as a touch of feedback. */
.np-vol-slider {
  width: 74px;
  accent-color: var(--muted);
  cursor: pointer;
  transition: accent-color 0.15s ease;
}
.np-vol:hover .np-vol-slider,
.np-vol:focus-within .np-vol-slider {
  accent-color: var(--accent);
}


/* Responsive bar: everything on one row when there's room; as the
   window narrows the seek drops to its own full-width second row first,
   then the informational badges bow out, then (very narrow -- the real
   mobile design is a separate pass) the mark buttons too. The
   essentials (cover, title, transport, close) never leave row one. */
/* Progressive disclosure -- ONE row always. As the window narrows the
   least-essential things drop out in order, so the essentials (cover,
   title, transport, seek, close) are always present and never overflow.
   No wrapping, no disappearing right-hand buttons. Verified fit down to
   ~760px (below that the mobile mini-bar takes over). */
@media (max-width: 1360px) {
  .np-badges { display: none; }            /* informational only */
}
@media (max-width: 1280px) {
  /* the tempo FADER (+ range/%/keylock) yields its ~200px; the BPM
     readout chip stays so you can still SEE the tempo, just not drag it
     -- exactly the reduction the mobile bar already uses */
  .np-tempo-range, .np-tempo-slider, .np-tempo-val, .np-pitch { display: none; }
}
@media (max-width: 1160px) {
  .np-vol-slider { display: none; }        /* mute icon stays */
}
@media (max-width: 960px) {
  .np-tempo { display: none; }             /* even the BPM chip bows out */
}
@media (max-width: 880px) {
  .np-marks { display: none; }             /* like/skip/dislike move to the card */
}
@media (max-width: 700px) {
  /* narrow: keep the BPM chip, drop the adjust controls (the real
     mobile design is a separate pass) */
  .np-tempo-range, .np-tempo-slider, .np-tempo-val, .np-pitch { display: none; }
}
@media (max-width: 560px) {
  .np-marks { display: none; }
}
@media (max-width: 700px) {
  /* leave room so the fixed bar never hides the last card's controls */
  body .grid { padding-bottom: 130px; }
}

.pending {
  color: var(--muted);
  font-size: 0.8rem;
  font-style: italic;
  display: flex;
  flex-direction: column;
  gap: 6px;
  align-items: flex-start;
}

.rescrape-btn {
  font-style: normal;
  font-size: 0.72rem;
  font-weight: 600;
  color: var(--accent);
  background: transparent;
  border: 1px solid rgba(99, 178, 255, 0.4);
  border-radius: 999px;
  padding: 3px 10px;
  cursor: pointer;
}

.rescrape-btn:hover { border-color: var(--accent); }
.rescrape-btn:disabled { opacity: 0.6; cursor: default; }

#load-more-sentinel {
  text-align: center;
  color: var(--muted);
  font-size: 0.8rem;
  padding: 24px 0;
}

/* On the Gems page only, an UNDECIDED Hidden Gem card glows like
   there's a light source behind it. Server-side gated to releases
   with no like/skip/dislike yet (see release_card's is_hidden_gem
   condition) -- the moment a decision is made it's a one-time find,
   not a permanent decoration, and the JS below also strips the class
   immediately so it doesn't wait for a reload to stop. */
.card.hidden-gem-card {
  border-color: rgba(255, 209, 102, 0.65);
  animation: hidden-gem-breathe 3.5s ease-in-out infinite;
}

@keyframes hidden-gem-breathe {
  0%, 100% {
    box-shadow:
      0 0 6px rgba(255, 209, 102, 0.35),
      0 0 22px rgba(255, 209, 102, 0.22),
      0 0 48px rgba(255, 209, 102, 0.12);
  }
  50% {
    box-shadow:
      0 0 8px rgba(255, 209, 102, 0.5),
      0 0 30px rgba(255, 209, 102, 0.32),
      0 0 64px rgba(255, 209, 102, 0.18);
  }
}

/* Instant colored flash on the cover art outline the moment ANY card
   is liked/skipped/disliked -- same "light source" language as the
   Hidden Gem glow above, generalized to every mark decision and
   colored to match it (--mark-glow-color set inline by the mark
   handler in the script block). Layered on top of the cover-wrap's
   own persistent colored outline below, not instead of it. */
@keyframes cover-glow-pop {
  0% { box-shadow: 0 0 0 0 var(--mark-glow-color, transparent); }
  40% { box-shadow: 0 0 18px 3px var(--mark-glow-color, transparent); }
  100% { box-shadow: 0 0 0 0 var(--mark-glow-color, transparent); }
}

.cover-wrap.mark-glow-pop::before {
  animation: cover-glow-pop 0.8s ease-out;
}

.bc-open {
  /* The card stretches to its row's height, so anything above this link
     (a two-line title, a tracklist, a missing release date) used to decide
     where the footer landed and no two cards in a row agreed. Pinning it to
     the bottom collects the slack above instead, so this button and the
     actions under it line up across the row whatever the card holds. */
  margin-top: auto;
  display: block;
  text-align: center;
  background: var(--accent);
  color: #0a0a0a;
  text-decoration: none;
  font-weight: 600;
  font-size: 0.85rem;
  padding: 10px;
  border-radius: 6px;
}

.bc-open:active { opacity: 0.8; }

.actions {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.actions form.mark-form {
  display: none;
}

.secondary-actions {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  flex-wrap: wrap;
}

.actions button {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text);
  font-weight: 500;
  padding: 6px 10px;
  font-size: 0.78rem;
}

.actions button:hover { border-color: var(--accent); color: var(--accent); opacity: 1; }

/* One-off pop when a control is FIRST pressed into its active state
   (added/removed by JS right after the click) -- momentary "you just
   did that" feedback. Unscoped (not just .actions button) since it's
   also used by the cover-anchored status-cluster/play-this-icon. */
@keyframes btn-pop {
  0% { transform: scale(1); }
  45% { transform: scale(1.12); }
  100% { transform: scale(1); }
}

.just-activated {
  animation: btn-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.actions button.find-similar-btn:hover {
  border-color: var(--gem);
  color: var(--gem);
  opacity: 1;
}

/* ============================== MOBILE ==============================
   One narrow-screen block; the JS side (touch two-tap, forced medium
   size, app deep link) keys off matchMedia('(hover: none)') instead --
   see the touch-mode IIFE in the template. Layout goals: everything
   stacks, one card column, >=44px touch targets, nothing crowds or
   overflows the header row. */
@media (max-width: 700px) {
  body { padding: 0 12px 40px; }

  /* On a phone the decision buttons are always visible (no hover), so
     their desktop home -- floating in the cover's bottom-RIGHT corner,
     half over the art -- reads as scattered. Give them a tidy, deliberate
     control bar instead: a centred row across the bottom of the cover on
     a soft gradient scrim, like an app's overlay controls. Still
     collapses to the chosen verdict after a decision (margin-left kept so
     that animation works). */
  .cover-wrap .status-cluster {
    left: 0;
    right: 0;
    bottom: 0;
    justify-content: center;
    padding: 22px 0 12px;
    background: linear-gradient(to top,
      rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.12) 62%, transparent);
    border-radius: 0 0 10px 10px;
  }
  .cover-wrap .status-cluster .status-btn { margin-left: 16px; }
  .cover-wrap .status-cluster .status-btn:first-child { margin-left: 0; }

  header {
    flex-wrap: wrap;
    gap: 10px;
  }

  h1 { font-size: 1.15rem; width: 100%; }

  .header-actions {
    flex-wrap: wrap;
    gap: 8px;
    width: 100%;
  }

  .header-actions button { min-height: 44px; }

  /* Tab nav: one horizontally scrollable pill row instead of a
     wrapping block -- the standard mobile pattern for 7 tabs. */
  .tabs-row { overflow-x: auto; -webkit-overflow-scrolling: touch; }
  .tabs {
    display: flex;
    flex-wrap: nowrap;
    white-space: nowrap;
    gap: 4px;
  }
  .tabs a { padding: 10px 12px; }

  .search-form { width: 100%; }
  .search-form input[type="search"] { width: 100%; min-height: 44px; }

  /* Single column regardless of the (desktop-only) card-size setting;
     the touch JS also forces the medium embed metrics. */
  .grid,
  body.size-small .grid,
  body.size-big .grid {
    grid-template-columns: 1fr;
    gap: 20px;
  }

  .actions button,
  .bc-open,
  .find-similar-btn {
    min-height: 44px;
  }

  .taste-buddy-panel { width: calc(100vw - 24px); right: -60px; }
  .settings-panel { max-width: calc(100vw - 24px); }

  /* One card = one phone screen. min-height 100dvh alone (the first
     attempt) just guaranteed a card is AT LEAST a screen -- the content
     stack (full-width square cover + 440px player + meta + buttons) was
     ~1.6 screens tall, so cards still scrolled. The big two pieces now
     size off the screen HEIGHT itself: the square cover caps at 42dvh
     (width = height, so capping width does it) and the player takes
     ~30dvh, leaving ~28dvh for meta + the two button rows -- the sum
     lands at roughly exactly one screen on real phones. min-height (not
     height) so an unusually long tag list still shows rather than
     clipping; justify-content centers the content when it comes in a
     hair under. */
  .card {
    min-height: 100dvh;
    justify-content: center;
    padding: 10px;
    gap: 8px;
  }

  .cover-wrap {
    width: min(100%, 42dvh);
    align-self: center;
    /* Pin every cover chip (badges, status buttons, label logo/text) to
       the DESKTOP-medium reference values instead of the container-query
       cqw sizing. On desktop the cqw units exist so chips track the
       card-size setting and window resizes; a phone has neither (size is
       forced to medium, the viewport never resizes), and since the
       one-screen cover cap makes the mobile cover narrower than the
       desktop reference (~344 vs ~395px), cqw made every badge ~13%
       smaller than the web design. Same fixed values = identical badge
       size and typography on both. 38px/1.112rem are exactly what
       9.6cqw/4.5cqw produce at the desktop medium grid's ~395px cover. */
    --chip: 38px;
    --chip-font: 1.112rem;
  }


  .meta .title { font-size: 0.9rem; }

  /* Long tag lists are the one thing that can blow a card well past a
     screen -- clamp to two lines on the phone (full list still lives in
     the desktop view and the data). */
  .tags {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }

  /* Filters live inline in normal document flow on desktop; on mobile
     that means scrolling all the way back to the top to change one --
     hidden here, only reachable through the sticky ☰ below (which
     force-shows it as a fixed overlay instead). */
  .filters { display: none; }
}

/* Sticky mobile filters/settings access -- a fixed ☰ that follows the
   scroll (unlike the header controls it stands in for), so changing a
   filter or a setting never requires scrolling back to the top. Reuses
   the real .filters block and #settings-panel as fixed overlays instead
   of duplicating their markup/state; toggled by one shared body class.
   Desktop-hidden; see .mobile-nav-toggle click handler + the
   pjaxNavigate reset (a tab change closes it, same as the now-playing
   island). */
.mobile-nav-toggle { display: none; }

@media (max-width: 700px) {
  .mobile-nav-toggle {
    display: flex;
    align-items: center;
    justify-content: center;
    position: fixed;
    right: 16px;
    bottom: 20px;
    width: 52px;
    height: 52px;
    border-radius: 50%;
    border: none;
    background: var(--accent);
    color: #0b0b0c;
    font-size: 1.4rem;
    line-height: 1;
    box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
    z-index: 70;
    transition: transform 0.15s ease;
  }

  .mobile-nav-toggle:active { transform: scale(0.92); }
  body.mobile-nav-open .mobile-nav-toggle { background: var(--muted); }

  /* Both overlays slide in when the ☰ opens them -- display:none->flex
     can't be *transitioned*, but a keyframe ANIMATION does fire the
     moment the element first becomes displayed, which is exactly this
     toggle. Filters drop in from the top edge, settings rise from the
     bottom, matching where each one lives on screen. */
  body.mobile-nav-open .filters {
    display: flex !important;
    position: fixed;
    top: 12px;
    left: 12px;
    right: 12px;
    bottom: auto;
    max-height: 48vh;
    overflow-y: auto;
    margin: 0;
    z-index: 65;
    background: var(--card);
    border: 1px solid var(--border);
    border-radius: 14px;
    padding: 16px;
    box-shadow: 0 10px 34px rgba(0, 0, 0, 0.6);
    animation: mobile-drawer-drop 0.25s cubic-bezier(0.4, 0, 0.2, 1);
  }

  body.mobile-nav-open #settings-panel {
    display: flex !important;
    position: fixed;
    top: auto;
    left: 12px;
    right: 12px;
    bottom: 84px;
    max-height: 42vh;
    overflow-y: auto;
    min-width: 0;
    z-index: 65;
    animation: mobile-drawer-rise 0.25s cubic-bezier(0.4, 0, 0.2, 1);
  }
}

@keyframes mobile-drawer-drop {
  from { opacity: 0; transform: translateY(-16px); }
  to { opacity: 1; transform: translateY(0); }
}

@keyframes mobile-drawer-rise {
  from { opacity: 0; transform: translateY(16px); }
  to { opacity: 1; transform: translateY(0); }
}

/* ==================== v1.8 DESIGN PASS ==================== */
/* Appended late on purpose: these override earlier rules for the same
   elements, keeping the redesign in one block. */

/* ---- "ReleaseCatcher" logotype, Technics-deck style ----
   Platter with rib pattern + center spindle + red power dot; chrome
   italic wordmark with the accent underline stroke. The platter spins
   only while the native player is actually playing (body class). */
.logo {
  display: flex;
  align-items: center;
  gap: 13px;
  margin: 0;
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
}
/* A RECORD on the platter now, not just the platter: pressed-vinyl
   groove rings around a small accent label sticker (the spindle dots
   its center hole). The old red power-LED dot is gone. */
.logo-platter {
  position: relative;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  flex-shrink: 0;
  background:
    /* label sticker */
    radial-gradient(circle at center, transparent 0 4.6%, #2f7dd6 5.4% 25.4%, #101214 26.6% 27.4%, transparent 28.6% 100%),
    /* groove rings */
    /* Grooves as TEXTURE, not rings: finer pitch and a third of the
       old contrast, so they read as light catching the vinyl rather
       than drawn white lines. */
    repeating-radial-gradient(circle at center, rgba(255, 255, 255, 0.028) 0 0.4px, rgba(0, 0, 0, 0) 1.1px 2px),
    /* light sheen sweeping the vinyl */
    conic-gradient(from 210deg, rgba(255, 255, 255, 0.055) 0deg 40deg, rgba(255, 255, 255, 0) 90deg 280deg, rgba(255, 255, 255, 0.04) 330deg 360deg),
    radial-gradient(circle at 32% 28%, #24272b, #0d0e10 70%);
  box-shadow:
    inset 0 0 0 2px #2b2e32,
    inset 0 0 0 3px rgba(255, 255, 255, 0.05),
    0 2px 10px rgba(0, 0, 0, 0.6);
  /* Smooth spin: own compositor layer, so each frame is resampled from
     one rasterisation instead of being re-rasterised (which is what made
     the rings crawl and look pixelated while turning). */
  will-change: transform;
  backface-visibility: hidden;
}
.logo-spindle {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 3.5px;
  height: 3.5px;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  /* The spindle HOLE, not a pin: a real record shows the deck through
     it, so it reads black with a faint lit rim rather than a white dot. */
  background: #08090a;
  box-shadow: 0 0 0 0.5px rgba(255, 255, 255, 0.22);
}
/* Spins at the PLAYING TRACK's tempo: BPM/4 revolutions per minute --
   four beats per turn is precisely how a 12" turns on a real Technics
   (133 BPM techno at 33 1/3 RPM, 174 DnB ~ 45 RPM). --logo-rev is set
   from the effective BPM (incl. the tempo fader) by renderBpm(); the
   1.81s fallback covers tracks with no detected BPM. */
/* The platter ALWAYS carries the animation and is simply paused when
   nothing plays: a real record slows to rest where it is, it doesn't
   snap back to the top. Removing the animation on pause (what this used
   to do) reset the transform to 0deg every time the music stopped.
   Resuming picks up from the frozen angle. */
.logo-platter {
  animation: logo-spin var(--logo-rev, 1.81s) linear infinite;
  animation-play-state: paused;
}
body.np-audio-playing .logo-platter { animation-play-state: running; }
@keyframes logo-spin { to { transform: rotate(360deg); } }
.logo-word {
  position: relative;
  font-size: 1.42rem;
  font-weight: 800;
  font-style: italic;
  letter-spacing: -0.015em;
  line-height: 1.1;
  padding-right: 3px;
  background: linear-gradient(180deg, #f4f6f8 0%, #c3c8ce 42%, #878d94 55%, #dde1e5 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.logo-word-b { font-weight: 300; }
.logo-word::after {
  content: '';
  position: absolute;
  /* Flush with the R: measured off the rendered pixels, the wordmark's
     leftmost ink sits exactly at the text box's left edge, so any inset
     here starts the line inside the letter instead of under it. */
  left: 0;
  right: 6px;
  bottom: -5px;
  height: 2px;
  border-radius: 2px;
  background: linear-gradient(90deg, var(--accent), rgba(99, 178, 255, 0));
  transition: right 0.25s ease, box-shadow 0.25s ease;
}
.logo:hover .logo-word::after { right: 0; box-shadow: 0 0 8px rgba(99, 178, 255, 0.7); }

/* ---- Top-right controls: quiet circular chips, frosted like the rest
   of the app's icon language; their labels moved into tooltips. ---- */
.top-chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: rgba(255, 255, 255, 0.04);
  color: var(--muted);
  font-size: 0.95rem;
  line-height: 1;
  cursor: pointer;
  text-decoration: none;
  opacity: 0.9;
  flex-shrink: 0;
  transition: border-color 0.15s ease, color 0.15s ease, transform 0.1s ease;
}
.top-chip:hover { border-color: var(--accent); color: var(--accent); opacity: 1; transform: scale(1.06); }
a.reset-filters-link.top-chip { font-size: 1.05rem; }

/* ---- Gem tier separation: Legendary goes BRIGHT yellow, Hidden Gem a
   livelier warm amber -- "hidden gem color was too dull, legendary
   should lean bright yellow". ---- */
:root { --hidden-gem: #ffb25e; }
.gem-badge.gem-tier-legendary {
  background: rgba(255, 225, 77, 0.22);
  color: #ffe14d;
  outline-color: rgba(255, 225, 77, 0.6);
}
.hidden-gem-badge {
  background: rgba(255, 178, 94, 0.18);
  color: var(--hidden-gem);
  outline: 1px solid rgba(255, 178, 94, 0.55);
}

/* ---- Tracklist: numbers x-aligned with the card's own text column.
   The framed box lost its side padding and left inset; the number
   column is left-aligned at the same x as title/artist/tags above.
   Row padding is SYMMETRIC (6px both sides) so the playing-row blue
   fills equal space before the number and after the duration -- the
   container's -6px margins cancel the left pad, keeping the numbers on
   the card's text column. ---- */
.np-inline-tracks {
  padding: 4px 0;
  margin-left: -6px;
  margin-right: -6px;
  /* Scrollbar exists but only shows WHILE scrolling (the .scrolling
     class comes from a document-level scroll listener with a short
     decay) -- visible feedback without a permanent bar down the card. */
  scrollbar-width: thin;
  scrollbar-color: transparent transparent;
  background: transparent;
  border: none;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
  border-radius: 0;
}
.np-inline-tracks::-webkit-scrollbar { width: 6px; height: 0; }
.np-inline-tracks::-webkit-scrollbar-thumb { background: transparent; border-radius: 3px; }
.np-inline-tracks.scrolling { scrollbar-color: var(--border) transparent; }
.np-inline-tracks.scrolling::-webkit-scrollbar-thumb { background: var(--border); }
/* The mobile now-playing sheet's list, same treatment. */
#np-sheet-tracks { scrollbar-width: none; }
#np-sheet-tracks::-webkit-scrollbar { width: 0; height: 0; }
.np-inline-track { padding: 7px 6px; }
.npi-num {
  width: 1.5em;
  text-align: left;
}

/* ---- Per-track "I'd play this in a set" toggle, redesigned: a quiet
   circular toggle that appears once the card is liked -- hollow ring
   with a + when open, filled Play-This purple with a ✓ once picked.
   The picked ROW carries a slim purple edge + faint wash; the title
   keeps its normal color (readability first). ---- */
.npi-pt {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 1.5px solid rgba(184, 134, 255, 0.55);
  background: transparent;
  filter: none;
  opacity: 0.75;
  font-size: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease, opacity 0.15s ease;
}
.npi-pt::before {
  content: '+';
  font-size: 12px;
  line-height: 1;
  color: rgba(184, 134, 255, 0.9);
  font-weight: 700;
}
.npi-pt:hover { opacity: 1; transform: scale(1.15); background: rgba(184, 134, 255, 0.15); }
.card.play-this-active .np-inline-track.pt-selected .npi-pt,
.dig-card.play-this-active .np-inline-track.pt-selected .npi-pt {
  background: var(--play-this);
  border-color: var(--play-this);
  opacity: 1;
}
.card.play-this-active .np-inline-track.pt-selected .npi-pt::before,
.dig-card.play-this-active .np-inline-track.pt-selected .npi-pt::before {
  content: '✓';
  color: #14161a;
  font-size: 11px;
}
.card.play-this-active .np-inline-track.pt-selected,
.dig-card.play-this-active .np-inline-track.pt-selected {
  background: linear-gradient(90deg, rgba(184, 134, 255, 0.14), rgba(184, 134, 255, 0.03));
  box-shadow: inset 2px 0 0 var(--play-this);
}
.card.play-this-active .np-inline-track.pt-selected .npi-title,
.dig-card.play-this-active .np-inline-track.pt-selected .npi-title { color: var(--text); }
/* playing highlight still wins visually when both apply */
.np-inline-track.playing { background: rgba(99, 178, 255, 0.14); }
.card.play-this-active .np-inline-track.pt-selected.playing {
  background: rgba(99, 178, 255, 0.14);
  box-shadow: inset 2px 0 0 var(--play-this);
}
/* no pick toggle on tracks that can't even be previewed */
.np-inline-track.unavailable .npi-pt { visibility: hidden !important; }

/* ---- Bar layout: the SEEK is the one and only flexible element. It
   grows to fill whatever room the fixed-width clusters leave and shrinks
   (to a floor) when the window tightens, so window resizing, badge count
   and track-name length all flow into the seek's length -- never into
   the position of the transport or the right-hand controls, and never
   off the edge of the screen. The old constant-width-seek + spacer
   scheme overflowed the moment the fixed clusters outgrew the row. ---- */
.np-flex-spacer { display: none; }
.np-seek { flex: 1 1 200px; min-width: 150px; }
/* badge tooltips need to escape the box -- no overflow clipping; the
   clone count is capped in JS instead so the cluster can't sprawl */
.np-badges .cover-badge { pointer-events: auto; cursor: default; position: relative; }
/* The bar's badges are informational circles -- the generic
   .cover-badge:hover pill-expansion (width:auto + 9px left padding)
   still reached these clones and shoved the emoji sideways the moment
   the hover text appeared. Pin them: hover changes NOTHING about the
   box, only the tooltip shows. */
.np-badges .cover-badge:hover {
  width: 32px;
  padding: 0;
  border-radius: 50%;
  gap: 0;
}

/* ---- Taste-buddy photo toggle ---- */
.taste-buddy-photo-toggle {
  display: flex;
  align-items: center;
  gap: 7px;
  margin: 8px 0 2px;
  font-size: 0.78rem;
  color: var(--muted);
  cursor: pointer;
}
.tb-emoji { display: none; }
body.no-buddy-photos .tb-photo { display: none; }
body.no-buddy-photos .tb-emoji { display: inline; }

/* ==================== MOBILE (iPhone-first) ==================== */
/* The app pattern: compact header, bottom tab bar, mini player docked
   above it, and a full-screen Now Playing sheet. Everything fixed pads
   itself with the iPhone safe areas (viewport-fit=cover in the meta). */

.mobile-tabbar { display: none; }
.np-sheet-close, .np-sheet-tracks, .np-sheet-goto { display: none; }

@media (max-width: 700px) {
  /* ---- Bottom tab bar ---- */
  .mobile-tabbar {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 90;
    display: flex;
    align-items: stretch;
    background: rgba(14, 15, 17, 0.97);
    backdrop-filter: blur(16px) saturate(1.2);
    border-top: 1px solid var(--border);
    padding-bottom: env(safe-area-inset-bottom);
  }
  .mobile-tabbar a {
    position: relative;
    flex: 1 1 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    min-height: 54px;
    text-decoration: none;
    color: var(--muted);
    font-size: 0.62rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    -webkit-tap-highlight-color: transparent;
  }
  .mobile-tabbar a:active { background: rgba(255, 255, 255, 0.05); }
  .mobile-tabbar a.active { color: var(--accent); }
  .mobile-tabbar .mtb-icon { font-size: 1.25rem; line-height: 1; }
  .mobile-tabbar .mtb-count {
    position: absolute;
    top: 5px;
    left: calc(50% + 8px);
    margin: 0;
    padding: 0 5px;
    min-width: 16px;
    height: 16px;
    line-height: 16px;
    font-size: 0.58rem;
    text-align: center;
    background: var(--accent);
    color: #0b0b0c;
    font-weight: 700;
  }
  /* live-dot on a tab bar item (playing lives in that tab) */
  .mobile-tabbar a.np-live-tab::after {
    right: auto;
    left: calc(50% - 16px);
    top: 7px;
    transform: none;
    animation-name: np-live-pulse;
  }

  /* the top tab strip is redundant next to the bottom bar; the Library
     SUBTABS stay (pill row) since the bar only carries the four mains */
  .tabs { display: none; }

  /* content must clear tabbar (+ mini player when shown) */
  body { padding-bottom: calc(70px + env(safe-area-inset-bottom)); }
  body .grid { padding-bottom: calc(140px + env(safe-area-inset-bottom)); }

  /* header: logo left, chips right, search below -- one tight block */
  header { align-items: center; }
  .logo { gap: 9px; }
  .logo-word { font-size: 1.15rem; }
  .logo-platter { width: 28px; height: 28px; }

  /* ---- Mini player bar (docked above the tab bar) ---- */
  body .np-bar {
    bottom: calc(54px + env(safe-area-inset-bottom));
    padding: 6px 10px;
    gap: 10px;
    flex-wrap: nowrap;
  }
  /* mini state hides everything but the essentials */
  .np-bar .np-badges,
  .np-bar .np-marks,
  .np-bar .np-prev-wrap,
  .np-bar #np-prev,
  .np-bar .np-seek,
  .np-bar .np-tempo,
  .np-bar .np-vol,
  .np-bar .np-flex-spacer { display: none; }
  .np-bar .np-cover { width: 40px; height: 40px; }
  .np-bar .np-meta { flex: 1 1 auto; }
  .np-bar .np-controls .np-btn { padding: 10px; font-size: 1.15rem; }

  /* a slim progress line along the mini bar's top edge, driven by the
     same scrub input (readonly look, no thumb) */
  /* (kept simple: the full seek lives in the sheet) */

  /* ---- Now Playing sheet ---- */
  body.np-sheet-open { overflow: hidden; }
  body.np-sheet-open .mobile-tabbar { display: none; }
  body.np-sheet-open .np-bar {
    z-index: 96;
    top: 0;
    bottom: 0;
    flex-direction: column;
    flex-wrap: nowrap;
    align-items: center;
    justify-content: flex-start;
    gap: 8px;
    padding: calc(10px + env(safe-area-inset-top)) 20px calc(10px + env(safe-area-inset-bottom));
    overflow: hidden;
    background:
      linear-gradient(180deg, rgba(99, 178, 255, 0.06), rgba(0, 0, 0, 0) 30%),
      rgba(11, 11, 12, 0.99);
  }
  body.np-sheet-open .np-sheet-close {
    display: flex;
    align-items: center;
    justify-content: center;
    align-self: center;
    width: 56px;
    height: 30px;
    border: none;
    border-radius: 999px;
    background: rgba(255, 255, 255, 0.07);
    color: var(--muted);
    font-size: 1.25rem;
    line-height: 0.6;
    cursor: pointer;
    flex-shrink: 0;
  }
  body.np-sheet-open .np-bar .np-cover {
    /* height-aware: scales down on short phones (SE) so the tracklist
       and the goto button always fit */
    width: min(64vw, 34dvh);
    height: min(64vw, 34dvh);
    border-radius: 10px;
    box-shadow: 0 18px 50px rgba(0, 0, 0, 0.65);
    margin-top: 6px;
  }
  body.np-sheet-open .np-bar .np-meta {
    flex: 0 0 auto;
    width: 100%;
    text-align: center;
  }
  body.np-sheet-open .np-track { font-size: 1.05rem; white-space: normal; }
  body.np-sheet-open .np-artist { font-size: 0.85rem; }
  body.np-sheet-open .np-bar .np-badges {
    display: flex;
    justify-content: center;
    flex-wrap: wrap;
  }
  body.np-sheet-open .np-bar .np-marks { display: flex; justify-content: center; }
  body.np-sheet-open .np-mark-btn { width: 44px; height: 44px; margin: 0 6px; font-size: 1.1rem; }
  /* the decided-collapse is a mini-bar space trick; the sheet has room,
     all marks stay out */
  body.np-sheet-open .np-marks.decided .np-mark-btn:not(.active):not(.np-mark-pt):not(.np-mark-addpl) {
    width: 44px;
    margin: 0 6px;
    opacity: 1;
    border-width: 1px;
    pointer-events: auto;
  }
  body.np-sheet-open .np-bar .np-seek {
    display: flex;
    flex: 0 0 auto;
    width: 100%;
    order: 0;
  }
  body.np-sheet-open .np-bar .np-controls { gap: 22px; }
  body.np-sheet-open .np-bar .np-controls .np-btn { font-size: 1.5rem; padding: 8px 12px; }
  body.np-sheet-open .np-bar #np-prev { display: block; }
  body.np-sheet-open .np-playpause { font-size: 2rem !important; }
  body.np-sheet-open .np-bar .np-tempo {
    display: flex;
    justify-content: center;
    width: 100%;
  }
  body.np-sheet-open .np-sheet-tracks {
    display: block;
    width: 100%;
    flex: 1 1 0;
    min-height: 90px;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    border-top: 1px solid var(--border);
    padding-top: 6px;
  }
  .np-sheet-row {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 12px 4px;
    color: var(--text);
    font-size: 0.9rem;
    border-radius: 8px;
    -webkit-tap-highlight-color: transparent;
  }
  .np-sheet-row:active { background: rgba(255, 255, 255, 0.06); }
  .np-sheet-row.playing { background: rgba(99, 178, 255, 0.14); color: var(--accent); }
  .np-sheet-row.unavailable { opacity: 0.4; }
  .npsr-num { flex-shrink: 0; width: 1.5em; color: var(--muted); font-size: 0.8rem; }
  .np-sheet-row.playing .npsr-num { color: var(--accent); }
  .npsr-title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .npsr-dur { flex-shrink: 0; color: var(--muted); font-size: 0.8rem; font-variant-numeric: tabular-nums; }
  body.np-sheet-open .np-sheet-goto {
    display: block;
    flex-shrink: 0;
    width: 100%;
    min-height: 46px;
    border: 1px solid var(--border);
    border-radius: 12px;
    background: rgba(255, 255, 255, 0.05);
    color: var(--text);
    font-size: 0.9rem;
    font-weight: 600;
    cursor: pointer;
  }
  body.np-sheet-open .np-sheet-goto:active { background: rgba(99, 178, 255, 0.15); }
  /* state tints carry into the sheet automatically (same element) */

  /* Phone cards: same 5-row cap as desktop so one card still ≈ one
     screen, and a 2-track single doesn't reserve empty space below it. */
  .np-inline-tracks,
  body.size-small .np-inline-tracks,
  body.size-big .np-inline-tracks { height: auto; max-height: var(--npi-max-h, 168px); }
}

/* volume is hardware-only on iOS -- hide the software control on any
   touch device (the sheet stays clean) */
@media (hover: none) {
  .np-vol { display: none !important; }
}

/* the floating ☰ predates the tab bar -- lift it clear of the bar, and
   clear of the mini player too whenever that's showing */
@media (max-width: 700px) {
  .mobile-nav-toggle {
    bottom: calc(66px + env(safe-area-inset-bottom)) !important;
  }
  body:has(#native-player:not(.hidden)) .mobile-nav-toggle {
    bottom: calc(128px + env(safe-area-inset-bottom)) !important;
  }
  body.np-sheet-open .mobile-nav-toggle { display: none; }
}

/* ==== Scan-done toast (no-reload completions) ==== */
/* Slides in where the old full-page-reload banner used to appear --
   same green .banner.ok language, but live: the page (and the music)
   never went anywhere. */
@keyframes toast-in {
  0% { opacity: 0; transform: translateY(-10px) scale(0.985); }
  60% { opacity: 1; }
  100% { opacity: 1; transform: none; }
}
.done-toast {
  animation: toast-in 0.4s cubic-bezier(0.2, 0.9, 0.3, 1.15);
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
}
.toast-apply-btn {
  border: 1px solid currentColor;
  background: transparent;
  color: inherit;
  border-radius: 999px;
  padding: 4px 14px;
  font-size: 0.8rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s ease, transform 0.1s ease;
}
.toast-apply-btn:hover { background: rgba(255, 255, 255, 0.08); transform: scale(1.03); }

/* ==== Like-button nudge (new user, or >50% of a track played) ==== */
/* A "try this" tutorial glow, NOT the pressed look. The old nudge reused
   the pressed styling exactly (solid like-border + like-coloured icon),
   so the moment its 0.6s intro finished it was indistinguishable from an
   already-liked button -- it read as "done", the opposite of the hint.
   This rings the button with a like-green HALO instead: a few expanding
   pings pull the eye on arrival, then a soft steady halo holds for as
   long as the nudge is up (the "still waiting for you" signal, without
   the endless throb that was tried here before and removed as annoying).
   Pressed (.active) has a solid border and NO halo, so the two never
   look alike. Cleared the instant they decide (__npClearLikeNudge).
   The glow lives ENTIRELY on the button's outline (border + halo ring):
   the icon inside is left its normal neutral colour on purpose, so the
   hint reads as "a ring drawing your eye to this button", never as an
   already-coloured/pressed glyph. */
@keyframes like-nudge-ping {
  0% {
    box-shadow:
      0 0 12px 1px color-mix(in srgb, var(--like) 32%, transparent),
      0 0 0 0 color-mix(in srgb, var(--like) 52%, transparent);
  }
  100% {
    box-shadow:
      0 0 12px 1px color-mix(in srgb, var(--like) 32%, transparent),
      0 0 0 11px color-mix(in srgb, var(--like) 0%, transparent);
  }
}
.np-mark-btn.like-nudge,
.status-btn.like-nudge {
  /* half-committed border, clearly not the solid pressed one -- and NO
     icon colour, so only the outline circle glows (not the glyph). */
  border-color: color-mix(in srgb, var(--like) 50%, var(--border));
  /* steady halo that outlives the pings (pressed has none) */
  box-shadow: 0 0 12px 1px color-mix(in srgb, var(--like) 32%, transparent);
  animation: like-nudge-ping 1.5s ease-out 3;
}

/* ==== "Suggested track(s)" badge ==== */
/* The card is the ALBUM; the badge says WHY it's here -- someone
   specifically recommended/bought these tracks off it. Gold like the
   .tb-track names inside the tooltips, one coherent "track" color. */
.suggested-track-badge {
  color: #f5c451;
  background: rgba(245, 196, 81, 0.14);
  outline: 1px solid rgba(245, 196, 81, 0.45);
  cursor: pointer;   /* click it to play that cut */
}
/* Clicking it when it's ALREADY the track playing does nothing but pulse
   the badge's OUTLINE (a ring, never the icon) as an acknowledgement. */
.suggested-track-badge.st-pulse { animation: st-glow-in 0.55s ease-out; }
/* Playing THE suggested track right now -> one brief settle-in glow
   (~0.6s), then a quiet static brighter outline for as long as it's
   playing -- a continuously looping pulse read as distracting. */
@keyframes st-glow-in {
  0% { box-shadow: 0 0 0 0 rgba(245, 196, 81, 0); }
  40% { box-shadow: 0 0 0 6px rgba(245, 196, 81, 0.28); }
  100% { box-shadow: 0 0 0 0 rgba(245, 196, 81, 0); }
}
.suggested-track-badge.st-glow,
.taste-buddy-badge.st-glow {
  animation: st-glow-in 0.6s ease-out;
  outline-color: rgba(245, 196, 81, 0.9);
}

/* VA compilations: the real artists, mined from the track titles --
   quiet second line under the "Various Artists" placeholder. */
.va-featuring {
  font-size: 0.74rem;
  color: var(--muted);
  font-style: italic;
  margin-top: 1px;
  /* one line, like .artist -- a long "featuring … +16 more" used to wrap
     and shove the tracklist box down, breaking alignment across cards. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ==== The Three Buddy Problem ==== */
/* Yellow pill in the buddy panel while the Settings cap is armed and
   the orbit is full; shakes when an add/resume bounces off the limit.
   The icon: three bodies with crossing orbital trails. */
.three-buddy-warning {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin: 6px 0 2px;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: 0.74rem;
  font-weight: 600;
  color: #f7c948;
  background: rgba(247, 201, 72, 0.12);
  outline: 1px solid rgba(247, 201, 72, 0.45);
  cursor: help;
}
.three-buddy-warning.hidden { display: none; }
.tbp-icon { flex-shrink: 0; }
@keyframes tbp-shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-3px); }
  40% { transform: translateX(3px); }
  60% { transform: translateX(-2px); }
  80% { transform: translateX(2px); }
}
.three-buddy-warning.tbp-shake { animation: tbp-shake 0.4s ease; }

/* ==== Global search results (show=search) ==== */
.search-results-note {
  font-size: 0.82rem;
  color: var(--muted);
  margin: 4px 0 14px;
}
/* Where a global-search hit actually lives -- small linked chip above
   the title; per-home tints echo the tabs' own language. */
.found-in-chip {
  display: inline-block;
  align-self: flex-start;
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 2px 9px;
  border-radius: 999px;
  margin-bottom: 4px;
  text-decoration: none;
  color: var(--muted);
  background: rgba(255, 255, 255, 0.06);
  outline: 1px solid var(--border);
}
.found-in-chip:hover { color: var(--text); outline-color: var(--accent); }
.found-in-liked { color: var(--like); outline-color: rgba(79, 191, 140, 0.5); background: rgba(79, 191, 140, 0.1); }
.found-in-skipped { color: var(--skip); }
.found-in-disliked { color: var(--dislike); }
.found-in-gems { color: var(--gem); outline-color: rgba(177, 140, 255, 0.5); background: rgba(177, 140, 255, 0.1); }
.found-in-new { color: var(--accent); outline-color: rgba(99, 178, 255, 0.5); background: rgba(99, 178, 255, 0.1); }

/* ==== UI-chrome icon set (see the icon() macro) ==== */
.ui-icon { width: 1.08em; height: 1.08em; display: block; flex-shrink: 0; }
.ui-icon.inline { display: inline-block; vertical-align: -0.18em; margin-right: 6px; }
/* icon-only circular buttons center their glyph via flex */
.icon-button, .top-chip { display: inline-flex; align-items: center; justify-content: center; }
.icon-button .ui-icon, .top-chip .ui-icon { width: 1.1em; height: 1.1em; }
/* island prev/next + center: SVG pair toggled by playing state */
.np-ov-play .ov-pause-i { display: none; }
.card.np-playing .np-ov-play .ov-play-i { display: none; }
.card.np-playing .np-ov-play .ov-pause-i { display: block; }
.np-ov-btn .ui-icon { width: 1.15em; height: 1.15em; margin: 0 auto; }
.np-btn .ui-icon { margin: 0 auto; }
.taste-buddy-rescan .ui-icon, .taste-buddy-delete .ui-icon,
.taste-buddy-toggle-active .ui-icon { width: 1em; height: 1em; margin: 0 auto; }
.mtb-icon .ui-icon { width: 1.25em; height: 1.25em; margin: 0 auto; }

/* Clicking/dragging a bar slider leaves it DOM-focused; the UA focus
   ring then read as the "play-state bar highlighting itself" (worse:
   it re-lit on every spacebar). Only KEYBOARD focus (:focus-visible)
   draws the ring now -- pointer interactions stay visually quiet. */
.np-bar input[type="range"]:focus:not(:focus-visible) {
  outline: none;
}

/* Buddy avatar links to their Bandcamp page without disturbing the row layout. */
.taste-buddy-photo-link { display: contents; }

/* ---- Mobile polish pass (overnight run): no hover semantics on touch,
   44px+ tap targets, and a tight header with no orphan rows. Appended
   last so it wins the cascade over the earlier mobile blocks. ---- */
@media (hover: none) {
  /* Touch has no hover: the play overlay lives on the artwork
     permanently, app-style, instead of appearing on a phantom hover. */
  .card .np-overlay { opacity: 1; pointer-events: auto; }
  /* Like/skip/dislike chips reach comfortable thumb size. */
  .status-btn { width: 46px; height: 46px; }
}

@media (max-width: 700px) {
  /* Command area, exactly two tight rows: pills (New releases, Rescan)
     on the first, the icon cluster (buddies, settings, avatar) packed
     right on the second. The ::after break forces the split; before
     this, the avatar and the info chip each wasted a whole empty row. */
  .header-actions { align-items: center; row-gap: 8px; }
  .header-actions::after { content: ''; order: 1; flex-basis: 100%; height: 0; }
  .header-actions .taste-buddies,
  .header-actions .settings,
  .header-actions .user-menu { order: 2; }
  .header-actions .taste-buddies { margin-left: auto; }
  .user-avatar-btn { min-width: 44px; min-height: 44px; }
  /* The desktop tab strip is hidden here (bottom bar instead): the info
     chip shares one row with the search field instead of sitting alone
     in desktop-sized chrome. */
  .tabs-row { border-bottom: none; margin-bottom: 8px; gap: 8px; align-items: center; flex-wrap: nowrap; }
  .tabs-row .search-form { width: auto; flex: 1 1 auto; }
  .tabs-row .search-form input[type="search"] { width: 100%; }
  .top-chip { min-width: 44px; min-height: 44px; }
}

/* Detect BPM's preferred home is the supporter-count row: same line,
   right side, static flow (the absolute corner position only applies in
   its tracklist fallback home). */
.supporters { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.npi-detect-all.in-meta-row { position: static; opacity: 0.75; }
/* Deck (dig) card: the Detect-BPM control sits on its own line under the
   facts, matching the card's centred meta. */
.dig-bpm-slot { margin-top: 4px; display: flex; justify-content: center; }
.dig-bpm-slot:empty { display: none; }

/* The player's own artwork wears the decision wash too: liking from the
   bar (or the mobile sheet, where this cover IS the screen) paints the
   cover you're looking at, same hue language as the cards. The wrap
   exists because the <img> itself can't host an ::after. */
.np-cover-wrap { position: relative; flex-shrink: 0; display: block; border-radius: 6px; }
.np-cover-wrap::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 6px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
  mix-blend-mode: color;
}
.np-bar.np-liked .np-cover-wrap::after { background: var(--like); opacity: 0.55; }
.np-bar.np-skipped .np-cover-wrap::after { background: var(--skip); opacity: 0.55; }
.np-bar.np-disliked .np-cover-wrap::after { background: var(--dislike); opacity: 0.55; }

/* ---- Fast digging mode: a stacked deck, one screen, never scrolls. ----
   The deck renders its OWN compact cards (cover + badges + a one-line
   summary) rather than borrowing the grid's full card the way the first
   cut did -- a real card carries a fixed-height tracklist, a supporter
   row and a meta block, which is exactly why dig mode used to run taller
   than the viewport. Nothing loses its wiring in the trade: the badge
   stack is CLONED (its handlers are delegated on document, and the
   .cover-wrap class below hands the clones the same --chip container
   sizing the grid gives them), and the play island reuses .np-overlay
   markup, which the delegated .np-ov-btn handler already drives.
   Decisions POST to /mark and propagate to the real cards underneath via
   __applyCardStateAll.

   Sizing: the card is art (square) + a fixed summary, and --dig-w is
   capped by the height LEFT OVER once chrome, actions, hint and the
   player bar have taken their cut -- so a short window shrinks the
   artwork instead of pushing the buttons off-screen. */
.dig-overlay {
  --dig-info-h: 104px;
  /* Three rows plus a deliberate SLIVER of a fourth (rows run ~22px, the
     box pads 3px each side: 6 + 66 + 10). Sized to three exactly, every
     record looked like a 3-track EP; the half-row cut off at the bottom
     is what says "there is more down here" without spending more of the
     artwork on it. The real number goes in the facts line. */
  --dig-tracks-h: 82px;
  /* --dig-bar-h is measured and set by the script (bar height PLUS
     whatever sits under it, which on phones is the tab bar) -- a fixed
     guess here put the hint underneath the bar on mobile. */
  --dig-bar-h: 0px;
  /* 236px = chrome + verdict row + hint + the deck's own peek clearance
     + padding. The peek MUST be part of this budget: it lives inside the
     deck's grid row, so a short window would otherwise shove the cards
     behind straight through the buttons. The hint is costed for TWO
     lines: on a phone it wraps, and it wraps again on any narrow
     desktop. Anything added below the deck has to be paid for here. */
  --dig-avail: calc(100dvh - 236px - var(--dig-bar-h));
  --dig-w: max(160px, min(380px, calc(100vw - 40px),
                calc(var(--dig-avail) - var(--dig-info-h) - var(--dig-tracks-h))));
  --dig-h: calc(var(--dig-w) + var(--dig-info-h) + var(--dig-tracks-h));
  position: fixed;
  inset: 0;
  z-index: 950;
  /* Blurred rather than merely dark: at plain 0.97 the grid's covers
     still bled through hard enough to read as clutter behind the deck. */
  background: rgba(8, 9, 10, 0.93);
  backdrop-filter: blur(18px) saturate(0.7);
  display: grid;
  grid-template-rows: auto 1fr auto auto;
  grid-template-columns: 52px auto 52px;
  justify-content: center;
  padding: 8px 8px calc(var(--dig-bar-h) + 10px);
  overflow-y: auto;
}
.dig-overlay.hidden { display: none; }
/* Hearing the record IS the deciding, so the bar has to stay visible and
   usable THROUGH the overlay. It normally sits at z-index 80, far under
   this overlay, whose opaque background painted straight over it -- the
   old padding-bottom only ever reserved an empty gap where the bar was.
   Only lifted while digging, so nothing else re-stacks. */
body.dig-open .np-bar { z-index: 960; }
.dig-chrome {
  grid-row: 1;
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 4px 6px;
}
.dig-title { display: flex; flex-direction: column; line-height: 1.08; }
.dig-title-view { font-weight: 700; font-size: 1.05rem; letter-spacing: 0.01em; color: var(--text); }
.dig-title-sub { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.16em; color: var(--muted); }
.dig-pos { color: var(--muted); font-size: 0.85rem; }
.dig-sort {
  margin-left: auto;
  background: var(--card);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 8px 10px;
  font-family: inherit;
}
.dig-close {
  display: flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text);
  border-radius: 50%;
  width: 40px;
  height: 40px;
  cursor: pointer;
  font-size: 1rem;
}
.dig-close .x-icon { width: 0.95em; height: 0.95em; }
.dig-close:hover { border-color: var(--accent); color: var(--accent); }
/* "Drops" audition toggle: a pill with a status dot. Off reads muted and
   hollow; on lights the dot and tints the pill so the state is obvious at
   a glance without a label change. */
.dig-drops {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  background: transparent;
  border: 1px solid var(--border);
  color: var(--muted);
  border-radius: 999px;
  padding: 7px 13px 7px 11px;
  height: 40px;
  font: inherit;
  font-size: 0.82rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.dig-drops-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--muted);
  opacity: 0.5;
  transition: background 0.15s, opacity 0.15s, box-shadow 0.15s;
}
.dig-drops:hover { color: var(--text); border-color: var(--accent); }
.dig-drops.on {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.dig-drops.on .dig-drops-dot {
  background: var(--accent);
  opacity: 1;
  box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 70%, transparent);
}
.dig-drops:focus:not(:focus-visible) { outline: none; }
/* Mouse clicks shouldn't leave a focus ring on the deck controls; a
   keyboard tab still does (:focus-visible). Same treatment the player
   bar's range inputs already use. */
.dig-act:focus:not(:focus-visible),
.dig-close:focus:not(:focus-visible),
.dig-sort:focus:not(:focus-visible),
.dig-nav:focus:not(:focus-visible) { outline: none; }

/* The deck: three cards at most, the two behind peeking out below the
   top one so the next record is always latently there. */
.dig-deck {
  grid-row: 2;
  grid-column: 2;
  position: relative;
  align-self: center;
  /* Horizontally too, and not redundantly: the middle column is sized by
     the WIDEST thing in it, which is the key-hint row, not the deck. The
     deck is narrower than that column, so without this it sat at the
     column's start -- 87px left of screen centre, hard against the left
     arrow while the right one had 191px of air. The arrows were never
     the problem; they are symmetric and always were. */
  justify-self: center;
  width: var(--dig-w);
  height: var(--dig-h);
  margin-bottom: 52px;   /* the peek lives here; budgeted in --dig-avail */
}
.dig-card {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 14px;
  overflow: hidden;
  box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
  transition: transform 0.3s cubic-bezier(0.2, 0.7, 0.3, 1), opacity 0.3s ease;
  will-change: transform;
}
/* Each peek = the gap we actually want to see PLUS the height scaling
   takes off the bottom edge, which for a centre origin is (1 - scale)/2
   of the card. Expressed against --dig-h so the peek stays constant when
   a short window shrinks the deck. The slight fan is what makes the next
   record legible rather than merely present: straight-stacked, all that
   showed under the top card was a sliver of the NEXT card's info block,
   which is its dimmest part. Tilted, its artwork catches the edge. */
/* A peek exposes the BOTTOM edge of the card behind, so what shows is
   whatever ends that card: since the tracklist strip landed, that is a
   row of the next record rather than its genre tags. Still a hint at
   what is coming rather than a bare "something is". */
.dig-card[data-depth="0"] { z-index: 3; transform: none; touch-action: none; cursor: grab; }
.dig-card[data-depth="1"] { z-index: 2; transform: translateY(calc(30px + var(--dig-h) * 0.025)) scale(0.95) rotate(2.6deg); }
.dig-card[data-depth="2"] { z-index: 1; transform: translateY(calc(46px + var(--dig-h) * 0.05)) scale(0.9) rotate(-2.4deg); }
.dig-card[data-depth="1"] .dig-info { opacity: 0.6; }
.dig-card[data-depth="2"] .dig-info { opacity: 0.28; }
/* Only the top card is grabbable; the ones behind must not eat a drag. */
.dig-card:not([data-depth="0"]) { pointer-events: none; }
.dig-card.dragging { transition: none; cursor: grabbing; }
/* Outranks the [data-depth] transforms above on class count alone, so
   the fly-out needs no !important. */
.dig-card.dig-gone.dig-fly-left { transform: translateX(-140%) rotate(-16deg); opacity: 0; }
.dig-card.dig-gone.dig-fly-right { transform: translateX(140%) rotate(16deg); opacity: 0; }
.dig-card.dig-gone.dig-fly-up { transform: translateY(-128%) scale(0.92); opacity: 0; }
.dig-card.dig-in { animation: dig-in 0.3s cubic-bezier(0.2, 0.7, 0.3, 1); }
@keyframes dig-in { from { transform: translateX(-128%) rotate(-14deg); } }
/* Re-sorting throws the whole deck: each card leaves to its own side, at
   its own angle, on its own beat, so it reads as a shuffle rather than
   three cards walking off in step. Outranks the depth transforms on
   class count, same as the fly-outs. */
.dig-card.dig-gone.dig-shuffling {
  transform: translateX(var(--dig-shuffle-x, 130%)) rotate(var(--dig-shuffle-r, 16deg));
  opacity: 0;
  transition-delay: var(--dig-shuffle-delay, 0ms);
}

/* .dig-art doubles as .cover-wrap: that class is what defines --chip /
   --chip-font off the container width, so the cloned badges size
   themselves to the dig card exactly like they do on a grid card. */
.dig-art {
  position: relative;
  flex: 0 0 auto;
  width: 100%;
  height: var(--dig-w);
  background: #101112;
  border-radius: 0;
}
.dig-cover { display: block; width: 100%; height: 100%; object-fit: cover; }
/* The depth dimmer is a real element, not ::after: .dig-art IS a
   .cover-wrap, and BOTH of that class's pseudo-elements are already
   spoken for -- ::after is the decision wash, ::before the glow layer.
   Taking ::after for dimming is what cost the deck its wash. */
.dig-dim {
  position: absolute;
  inset: 0;
  z-index: 2;
  background: rgba(8, 9, 10, 0.55);
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}
.dig-card[data-depth="1"] .dig-dim { opacity: 1; }
.dig-card[data-depth="2"] .dig-dim { opacity: 1; background: rgba(8, 9, 10, 0.72); }

/* The decided cover, exactly as a grid card shows it: greyed, washed
   with the decision's colour through .cover-wrap::after (whose base
   rule -- inset, mix-blend-mode: color, the transition -- .dig-art
   already inherits by being a .cover-wrap). Only the `.card` ancestor
   those rules hang off is missing here, so the selectors are mirrored.
   body.no-cover-tint's own kill-switch is NOT scoped to .card, so it
   already reaches the deck; only the grayscale needs mirroring. */
.dig-card.status-liked .dig-cover,
.dig-card.status-skipped .dig-cover,
.dig-card.status-disliked .dig-cover {
  filter: grayscale(1) brightness(0.75);
  transition: filter 0.3s ease;
}
body.no-cover-tint .dig-card.status-liked .dig-cover,
body.no-cover-tint .dig-card.status-skipped .dig-cover,
body.no-cover-tint .dig-card.status-disliked .dig-cover { filter: none; }
.dig-card.status-liked .dig-art::after { background: var(--like); opacity: 0.35; }
.dig-card.status-skipped .dig-art::after { background: var(--skip); opacity: 0.35; }
.dig-card.status-disliked .dig-art::after { background: var(--dislike); opacity: 0.35; }
/* "I'd play this" layers purple into the green rather than replacing it
   -- it IS a like, plus something. Same two-hue wash the grid card uses,
   and it out-specifies the plain liked rule above the same way. */
.dig-card.status-liked.play-this-active .dig-art::after {
  background: linear-gradient(135deg, var(--like), var(--play-this));
  opacity: 0.4;
}
.dig-card.status-liked.play-this-active .dig-art { border-bottom-color: var(--play-this); }
.dig-card.status-liked.play-this-active .dig-art {
  box-shadow: inset 0 -22px 22px -20px var(--play-this);
}
/* The play island is the whole point of the top card -- always up, not
   hover-gated the way it is on a grid card. */
.dig-card[data-depth="0"] .np-overlay { opacity: 1; pointer-events: auto; }
.dig-card.np-playing .np-ov-play { background: var(--accent); color: #0b0b0c; }
.dig-card.np-playing .np-ov-play .ov-play-i { display: none; }
.dig-card.np-playing .np-ov-play .ov-pause-i { display: block; }
/* The verdict, drawn along the bottom edge of the artwork: green liked,
   amber skipped, red disliked -- the same three colours the status
   buttons and the player bar already use, so the mapping needs no
   learning. It reads on top of the wash above, which says the same thing
   more quietly; on a full-bleed deck card the outline a grid card would
   use cannot show (it sits 5px outside the cover, where the card's own
   overflow clips it), so this edge takes its job.

   A real border, not a ::before. .dig-art is a .cover-wrap, and BOTH of
   that class's pseudo-elements are taken: ::after is the wash, ::before
   the glow layer at inset:-5px. A ::before here inherited that -5px top
   and, over-constrained against bottom:0, painted a perfect green 5px
   ABOVE the artwork -- correct in getComputedStyle the whole time, which
   is why only a screenshot caught it. A border needs no such luck, and
   box-sizing is border-box globally, so it costs the cover 5px rather
   than moving anything. Always present, transparent until there is a
   verdict, so nothing shifts when one lands. */
.dig-art { border-bottom: 5px solid transparent; }
.dig-card .dig-art {
  transition: border-bottom-color 0.25s ease, box-shadow 0.25s ease;
}
.dig-card.status-liked .dig-art {
  border-bottom-color: var(--like);
  box-shadow: inset 0 -22px 22px -20px var(--like);
}
.dig-card.status-skipped .dig-art {
  border-bottom-color: var(--skip);
  box-shadow: inset 0 -22px 22px -20px var(--skip);
}
.dig-card.status-disliked .dig-art {
  border-bottom-color: var(--dislike);
  box-shadow: inset 0 -22px 22px -20px var(--dislike);
}

/* flex:1 rather than a fixed height so it soaks up the tracklist's slot
   on a release that has no tralbum id to stream (and so no strip),
   instead of leaving a hole at the bottom of the card. With a strip
   present the arithmetic hands it exactly --dig-info-h back. */
.dig-info {
  flex: 1 1 auto;
  min-height: 0;
  padding: 10px 12px;
  overflow: hidden;
  transition: opacity 0.3s ease;
}
.dig-info .dig-name {
  font-weight: 700;
  font-size: 0.98rem;
  line-height: 1.25;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.dig-info .dig-artist {
  color: var(--muted);
  font-size: 0.86rem;
  margin-top: 2px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Flex so the track count can be pinned: it is the one fact the
   three-row strip cannot tell you, so a long label or a wordy date must
   not be what pushes it out. Everything else ellipses instead. */
.dig-info .dig-facts {
  margin-top: 6px;
  font-size: 0.72rem;
  color: var(--muted);
  display: flex;
  min-width: 0;
  overflow: hidden;
}
.dig-info .dig-facts > span {
  flex: 0 1 auto;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* The label goes first and is far and away the most likely to be long,
   so it absorbs most of the squeeze: shrinking every field evenly gave
   three ellipses in a row and a date reading "Released July 15, ...". */
.dig-info .dig-facts > span:first-child { flex-shrink: 8; }
.dig-info .dig-facts span + span::before { content: ' · '; opacity: 0.6; }
.dig-info .dig-facts .dig-trackcount { flex: 0 0 auto; color: var(--text); }
.dig-info .dig-tags {
  margin-top: 3px;
  font-size: 0.7rem;
  color: var(--muted);
  opacity: 0.75;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* What else is on the record. This IS the grid card's tracklist -- the
   same .np-inline the observer lazy-loads, the same rows that highlight
   while playing and play their track on click -- just compacted, the way
   the library's list view re-lays out the same cards. --npi-max-h is the
   height hook the tracklist box already exposes. Everything a row
   carries that is about curating rather than deciding (set tagging, BPM,
   the Bandcamp link) stays on the full card: here it would be noise
   against a 72px budget. */
.dig-tracks {
  --npi-max-h: var(--dig-tracks-h);
  flex: 0 0 auto;
  padding: 0 8px 8px;
  transition: opacity 0.3s ease;
}
/* Fixed, overriding the phone rule that lets a grid card's tracklist
   shrink to its content: in the deck every card is the same height and
   the peek exposes exactly this strip, so a 1-track single collapsing to
   31px would make the peek jump about from card to card. Long lists
   scroll inside it, as everywhere else. */
.dig-tracks .np-inline-tracks {
  height: var(--dig-tracks-h);
  max-height: none;
  padding: 3px;
  background: rgba(0, 0, 0, 0.28);
}
.dig-tracks .np-inline-track { padding: 4px 6px; gap: 6px; border-radius: 4px; }
.dig-tracks .npi-title { font-size: 0.76rem; }
.dig-tracks .npi-num { font-size: 0.7rem; }
.dig-tracks .npi-dur { font-size: 0.68rem; }
.dig-tracks .npi-pt,
.dig-tracks .npi-open,
.dig-tracks .npi-bpm,
.dig-tracks .npi-detect-all { display: none; }
.dig-tracks .np-inline-loading { padding: 6px; font-size: 0.72rem; color: var(--muted); }
/* The playing row, in the deck's own language. */
.dig-tracks .np-inline-track.playing { background: rgba(99, 178, 255, 0.16); }
.dig-tracks .np-inline-track.playing .npi-title { color: var(--accent); }
.dig-card[data-depth="1"] .dig-tracks { opacity: 0.5; }
.dig-card[data-depth="2"] .dig-tracks { opacity: 0.22; }

/* Desktop side rails. The deck itself is the mobile control (drag). */
.dig-nav {
  grid-row: 2;
  background: transparent;
  border: none;
  color: var(--muted);
  font-size: 3rem;
  line-height: 1;
  cursor: pointer;
  padding: 20px 8px;
  align-self: center;
}
.dig-prev { grid-column: 1; }
.dig-next { grid-column: 3; }
.dig-nav:hover { color: var(--accent); }
.dig-nav:disabled { opacity: 0.25; cursor: default; }
.dig-nav:disabled:hover { color: var(--muted); }

/* The verdict row: fixed under the deck, so it never rides along with a
   swipe and always sits in the same place for the thumb. */
.dig-actions {
  grid-row: 3;
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: start;
  padding: 2px 0 0;
}
/* The three verdicts, centred in the middle column so they stay under
   the card whatever width "I'd play" (in the right column) takes. */
.dig-verdicts {
  grid-column: 2;
  display: flex;
  justify-content: center;
  gap: 22px;
}
.dig-act {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  color: var(--muted);
  font-family: inherit;
}
.dig-act .dig-act-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 54px;
  height: 54px;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: rgba(24, 26, 27, 0.92);
  color: var(--text);
  font-size: 1.4rem;
  transition: transform 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.dig-act .dig-act-label { font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase; }
.dig-act:hover .dig-act-icon { transform: translateY(-2px); }
.dig-act:active .dig-act-icon { transform: scale(0.92); }
.dig-act-like:hover .dig-act-icon { border-color: var(--like); color: var(--like); }
.dig-act-skip:hover .dig-act-icon { border-color: var(--skip); color: var(--skip); }
.dig-act-dislike:hover .dig-act-icon { border-color: var(--dislike); color: var(--dislike); }
.dig-act-playthis:hover .dig-act-icon { border-color: var(--play-this); color: var(--play-this); }
.dig-act-playthis .dig-act-icon .ui-icon { width: 24px; height: 24px; }
.dig-act .x-icon { width: 1.1em; height: 1.1em; }
/* Reflects a verdict already on the record (revisited via Back). */
.dig-act.active .dig-act-icon { border-width: 2px; }
.dig-act-like.active .dig-act-icon { border-color: var(--like); color: var(--like); }
.dig-act-skip.active .dig-act-icon { border-color: var(--skip); color: var(--skip); }
.dig-act-dislike.active .dig-act-icon { border-color: var(--dislike); color: var(--dislike); }
.dig-act-playthis.active .dig-act-icon {
  border-color: var(--play-this);
  background: color-mix(in srgb, var(--play-this) 22%, rgba(24, 26, 27, 0.92));
}
/* The grid's right column: "I'd play" + add-to-playlist, both held off
   the centred verdicts behind the one separator "I'd play" used to own
   (it is not a verdict either -- same separation the card makes). */
.dig-act-right {
  grid-column: 3;
  justify-self: start;
  display: flex;
  align-items: start;
  gap: 18px;
  margin-left: 10px;
  padding-left: 20px;
  border-left: 1px solid var(--border);
}
.dig-act-addpl:hover .dig-act-icon { border-color: var(--accent); color: var(--accent); }
.dig-act-addpl .dig-act-icon .ui-icon { width: 24px; height: 24px; }
/* Deck's playing cut already in a playlist: fuchsia check + tinted ring. */
.dig-act-addpl.added .dig-act-icon { color: #e879c9; border-color: color-mix(in srgb, #e879c9 55%, transparent); }
/* Auto jump: previews the track's loud highlights in sequence. Not a
   verdict -- it mirrors "I'd play" on the opposite side of the centred
   verdicts (the grid's left column, hard against them), held off by its
   own rule. A live run adds .on: the icon pulses and the label counts the
   hops. min-width on the label keeps the button from twitching as its text
   swaps between "Auto jump" and "2 / 4". */
.dig-act-autojump {
  grid-column: 1;
  justify-self: end;
  margin-right: 10px;
  padding-right: 20px;
  border-right: 1px solid var(--border);
}
.dig-act-autojump .dig-act-label { min-width: 4.4em; text-align: center; }
.dig-act-autojump:hover .dig-act-icon { border-color: var(--accent); color: var(--accent); }
.dig-act-autojump.on { color: var(--accent); }
.dig-act-autojump.on .dig-act-icon {
  border-color: var(--accent);
  color: var(--accent);
  background: color-mix(in srgb, var(--accent) 22%, rgba(24, 26, 27, 0.92));
  animation: digAutojumpPulse 1.3s ease-in-out infinite;
}
@keyframes digAutojumpPulse {
  0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
  70% { box-shadow: 0 0 0 9px color-mix(in srgb, var(--accent) 0%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
  .dig-act-autojump.on .dig-act-icon { animation: none; }
}

.dig-hint {
  grid-row: 4;
  grid-column: 1 / -1;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 6px 14px;
  text-align: center;
  color: var(--muted);
  font-size: 0.75rem;
  padding: 8px 0 2px;
}
.dig-keys { display: inline-flex; flex-wrap: wrap; justify-content: center; gap: 4px 12px; }
.dig-key-hint { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.dig-hint kbd {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 20px;
  padding: 0 5px;
  border: 1px solid var(--border);
  border-bottom-width: 2px;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.06);
  color: var(--text);
  font-family: inherit;
  font-size: 0.7rem;
  line-height: 1;
}
.dig-hint-rule { opacity: 0.75; }
/* Tell each input about itself and nothing else: keys are noise on a
   phone, and "drag the record" is noise to someone holding a keyboard. */
.dig-drag-hint { display: none; }
@media (hover: none) {
  .dig-keys { display: none; }
  .dig-drag-hint { display: inline; }
}
/* Ran out of deck. */
.dig-end {
  position: absolute;
  inset: 0;
  z-index: 4;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  text-align: center;
  padding: 20px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 14px;
}
.dig-end p { color: var(--muted); margin: 0; }
body.dig-open { overflow: hidden; }
/* Below roughly this, --dig-w hits its 160px floor and the card stops
   being able to pay for the tracklist out of the artwork -- the deck
   would overflow instead of shrinking. The artwork is what you decide
   on, so the tracklist is what yields. (Threshold covers both the
   desktop bar and the taller phone bar+tabbar; no media query can read
   the measured --dig-bar-h.) */
@media (max-height: 680px) {
  .dig-overlay { --dig-tracks-h: 0px; }
  .dig-tracks { display: none; }
}
@media (max-width: 700px) {
  .dig-overlay { grid-template-columns: 0 auto 0; }
  .dig-nav { display: none; }
  .dig-hint { font-size: 0.68rem; padding: 6px 0 2px; }
  /* Four targets have to fit a phone's width without shrinking below the
     thumb size, so the air between them gives instead. */
  .dig-actions { gap: 12px; }
  .dig-act-right { margin-left: 2px; padding-left: 10px; gap: 12px; }
  .dig-act-autojump { margin-right: 2px; padding-right: 10px; }
}

/* Feature nudge text IS the call to action. */
/* The whole box is clickable (see the shell click handler), so the whole
   box shows the pointer -- not just the text line. */
#feature-nudge { cursor: pointer; }
#feature-nudge:hover .game-nudge-text { color: var(--accent); }

/* Phone-only Dig entry: bottom-left twin of the nav toggle. */
.dig-fab { display: none; }
@media (max-width: 700px) {
  .dig-fab {
    display: flex;
    align-items: center;
    justify-content: center;
    position: fixed;
    left: 16px;
    bottom: calc(74px + env(safe-area-inset-bottom));
    z-index: 60;
    height: 44px;
    padding: 0 18px;
    border-radius: 999px;
    border: 1px solid var(--border);
    background: rgba(24, 26, 27, 0.92);
    backdrop-filter: blur(8px);
    color: var(--text);
    font-family: inherit;
    font-weight: 700;
    font-size: 0.9rem;
    letter-spacing: 0.03em;
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
    cursor: pointer;
  }
  .dig-fab:active { transform: scale(0.95); }
  /* ride above the mini player when it's docked */
  body:has(.np-bar:not(.hidden)) .dig-fab {
    bottom: calc(128px + env(safe-area-inset-bottom));
  }
  body.dig-open .dig-fab { display: none; }
}

/* Most-listened rows name their release under the track title. */
.leaderboard-sub { display: block; font-size: 0.72rem; color: var(--muted); }

/* ---- Library list view -------------------------------------------
   The same cards as dense scannable rows: cover, title/artist, badges,
   decisions, Bandcamp link. The ORGANIZING mode for a long Liked list
   (a collection, not a gallery); the grid stays one click away.
   CSS-only, so every card keeps its own wiring. The card's four direct
   children are placed explicitly -- an implicit 4th column would wrap
   the decisions onto their own row. ---- */
.view-switch { display: inline-flex; gap: 2px; align-items: center; }
.view-switch-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 32px;
  background: transparent;
  border: 1px solid var(--border);
  color: var(--muted);
  cursor: pointer;
}
.view-switch-btn:first-child { border-radius: 8px 0 0 8px; }
.view-switch-btn:last-child { border-radius: 0 8px 8px 0; margin-left: -1px; }
.view-switch-btn:hover { color: var(--text); border-color: var(--accent); }
.view-switch-btn.active { color: var(--accent); border-color: var(--accent); background: rgba(99, 178, 255, 0.12); }

body.view-list .grid,
body.view-list.size-small .grid,
body.view-list.size-big .grid {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
body.view-list .card {
  display: grid;
  grid-template-columns: 64px minmax(0, 1fr) auto auto;
  align-items: center;
  gap: 14px;
  padding: 8px 12px;
  min-height: 0;
}
body.view-list .cover-wrap {
  grid-column: 1;
  width: 64px;
  height: 64px;
  border-radius: 6px;
  margin: 0;
  aspect-ratio: auto;
}
body.view-list .meta { grid-column: 2; min-width: 0; padding: 0; gap: 1px; }
body.view-list .actions { grid-column: 3; }
body.view-list .bc-open { grid-column: 4; }
body.view-list .title { font-size: 0.95rem; margin: 0; }
body.view-list .artist { font-size: 0.82rem; }
/* Density: identity + decisions stay, browsing furniture goes. */
body.view-list .np-inline,
body.view-list .tags,
body.view-list .release-date,
body.view-list .supporters,
body.view-list .va-featuring,
body.view-list .label-badge,
body.view-list .pending,
body.view-list .secondary-actions,
body.view-list .np-ov-prev,
body.view-list .np-ov-next { display: none; }
/* Badges live inside .cover-wrap, so they can only ever paint over the
   art -- at 64px that means small circles in a tight 2-up block, no
   slide-out labels. Hover still explains each one. */
body.view-list .badge-stack {
  left: 3px;
  top: 3px;
  display: grid;
  grid-template-columns: repeat(2, auto);
  gap: 2px;
  max-width: 58px;
}
body.view-list .badge-stack .cover-badge {
  max-width: none;
  width: 17px;
  height: 17px;
  padding: 0;
  font-size: 0.6rem;
}
body.view-list .badge-stack .icon-label,
body.view-list .badge-stack .cover-badge:hover .icon-label { display: none; }
/* Decisions live in .status-cluster INSIDE .cover-wrap (they normally
   sit on the artwork). At 64px they'd bury the cover, so in list view
   the cover stops being their positioning context and they anchor to
   the ROW's right edge instead, left of the Bandcamp link. */
body.view-list .cover-wrap { position: static; }
body.view-list .card { position: relative; }
body.view-list .status-cluster {
  right: 62px;
  bottom: 50%;
  transform: translateY(50%);
  gap: 6px;
}
body.view-list .actions { display: none; }
/* In a list you scan rows, not covers: hovering anywhere on the row
   expands the decided cluster's other options (on a card you have to
   find the cluster itself -- correct there, needless friction here). */
body.view-list .card:hover .status-cluster.decided .status-btn:not(.active) {
  width: var(--chip);
  margin-left: 6px;
  opacity: 1;
  border-width: 2px;
  pointer-events: auto;
}
/* The Bandcamp link earns an icon's worth of space here, not a sentence. */
body.view-list .bc-open {
  font-size: 0;
  padding: 8px 10px;
  border-radius: 8px;
}
body.view-list .bc-open::after { content: '↗'; font-size: 0.95rem; }
body.view-list .np-overlay { transform: translate(-50%, -50%) scale(0.62); }
@media (max-width: 700px) {
  body.view-list .card { grid-template-columns: 52px minmax(0, 1fr) auto; gap: 10px; padding: 8px; }
  body.view-list .cover-wrap { width: 52px; height: 52px; }
  body.view-list .actions { grid-column: 3; }
  body.view-list .bc-open { display: none; }
}

/* Set Builder: the badge for a find BOTH engines agree on -- it sounds
   like the set AND the set's own supporters bought it. Flat like every
   other badge (no gradient/glow), just its own orange hue. */
.bang-badge {
  background: rgba(255, 138, 76, 0.14);
  color: #ff9b5e;
  outline: 1px solid rgba(255, 138, 76, 0.5);
}

/* ---- Phone: the Now Playing sheet is the app ----------------------
   A full-screen player with no chrome leaves you lost, so the sheet
   names the page you're flicking through (top-left) and your position
   in that run (top-right), and swipes get a short shove of motion so
   the gesture feels answered. ---- */
.np-sheet-context { display: none; }
@media (max-width: 700px) {
  body.np-sheet-open .np-sheet-context {
    display: flex;
    justify-content: space-between;
    align-items: center;
    position: absolute;
    top: calc(14px + env(safe-area-inset-top));
    left: 20px;
    right: 64px;
    font-size: 0.78rem;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--muted);
    pointer-events: none;
    z-index: 2;
  }
  #np-sheet-where { font-weight: 700; color: var(--accent); }
  body.np-sheet-open.np-swipe-left .np-bar,
  body.np-sheet-open .np-bar.np-swipe-left { animation: np-shove-left 0.22s ease; }
  body.np-sheet-open .np-bar.np-swipe-right { animation: np-shove-right 0.22s ease; }
}
@keyframes np-shove-left {
  50% { transform: translateX(-14px); opacity: 0.75; }
  100% { transform: translateX(0); opacity: 1; }
}
@keyframes np-shove-right {
  50% { transform: translateX(14px); opacity: 0.75; }
  100% { transform: translateX(0); opacity: 1; }
}

/* ---- Card menu: the quiet "not this, ever" control ----------------
   Anchored to ITSELF, in flow at the top-right of the meta block (right
   under the cover art, where it was asked for). NOT absolute against the
   card: .card has no position, so every card's menu resolved against a
   far ancestor and they all stacked in one spot, intercepting each
   other's clicks. */
/* Title and its ⋯ menu share one line: the title takes the room and
   wraps within it (min-width:0 lets a flex item shrink below its content
   so a long title doesn't shove the menu off the card), the menu hugs
   the right edge. Replaces an old align-self:flex-end that never did
   anything -- .meta is a plain block, so the menu just sat as its own
   row above the title. */
.meta .title-row {
  display: flex;
  align-items: flex-start;
  gap: 6px;
}
.meta .title-row .title { flex: 1 1 auto; min-width: 0; }
/* Negative margins pull the 30x26 hit area tight to the card's edge and
   level the glyph with the title's first line; position:relative anchors
   the dropdown panel to the toggle. */
.card-menu { position: relative; flex: 0 0 auto; margin: -3px -6px 0 0; }
.card-menu-toggle {
  background: transparent;
  border: 1px solid transparent;
  color: var(--muted);
  border-radius: 8px;
  width: 30px;
  height: 26px;
  /* Centre the ⋯ IN the box. The box already levels with the title's first
     line (via .card-menu's negative top margin), but the glyph's 16px line
     box floated low in the 26px inline-block button, dropping the dots ~3.5px
     below the title. Flex-centering pins the glyph to the box centre so the
     two line up; padding:0 drops the UA button padding that skewed it. */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  line-height: 1;
  font-size: 1rem;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s ease, color 0.15s ease;
}
.card:hover .card-menu-toggle,
.card-menu-toggle:focus-visible { opacity: 1; }
.card-menu-toggle:hover { color: var(--text); border-color: var(--accent); }
/* Touch has no hover: the menu is always reachable there. */
@media (hover: none) { .card-menu-toggle { opacity: 1; } }
.card-menu-panel {
  position: absolute;
  right: 0;
  top: 28px;
  z-index: 20;
  min-width: 210px;
  max-width: 260px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: 0 12px 34px rgba(0, 0, 0, 0.5);
  padding: 5px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.card-menu-panel.hidden { display: none; }
.card-menu-item {
  background: transparent;
  border: none;
  color: var(--text);
  font-family: inherit;
  font-size: 0.82rem;
  text-align: left;
  text-decoration: none;
  padding: 8px 9px;
  border-radius: 6px;
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-height: 36px;
  display: flex;
  align-items: center;
}
.card-menu-item:hover { background: var(--bg); color: var(--accent); }
.card-menu-item.mute-btn:hover { color: var(--dislike); }

/* Settings: what's hidden, so a mute is always undoable. */
.muted-list { display: flex; flex-wrap: wrap; gap: 5px; padding: 2px 0 4px; }
.muted-chip {
  background: var(--bg);
  border: 1px solid var(--border);
  color: var(--muted);
  border-radius: 999px;
  padding: 4px 9px;
  font-size: 0.75rem;
  font-family: inherit;
  cursor: pointer;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.muted-chip:hover { border-color: var(--accent); color: var(--text); }

/* The "Open on Bandcamp" button sits against the window edge, so a
   centered tooltip runs off it and gets clipped. Anchor it to the right
   instead: it opens inward and stays fully readable. (The volume control
   used to be covered by this too, but its only tooltip was on the mute
   icon -- fixing the clipping made that previously-invisible popup start
   appearing on hover, which read as new clutter on the volume bar, so
   the mute tooltip was dropped and this no longer needs to cover it.) */
.np-bar .np-bandcamp[data-tooltip]::after {
  left: auto;
  right: 0;
  transform: translateX(0) translateY(4px);
}
.np-bar .np-bandcamp[data-tooltip]:hover::after {
  transform: translateX(0) translateY(0);
}

/* Gems tab notification: a scan the user never saw run has finished and
   has something for them. Breathes rather than blinks -- this is good
   news sitting patiently, not an alarm -- and stops the moment the tab
   is opened, because opening it IS reading the message (the server
   clears the flag on that render). */
.tabs a.tab-has-news,
.mobile-tabbar a.tab-has-news {
  color: var(--text);
  position: relative;
}
.tabs a.tab-has-news { animation: tab-news-breathe 2.6s ease-in-out infinite; }

@keyframes tab-news-breathe {
  0%, 100% { text-shadow: none; }
  50% { text-shadow: 0 0 10px rgba(255, 178, 94, 0.55); }
}

.tab-news-dot {
  position: absolute;
  top: -2px;
  right: -7px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  /* Hidden Gem's amber, so the dot says WHICH tab is calling rather than
     just that something is. */
  background: #ffb25e;
  box-shadow: 0 0 6px rgba(255, 178, 94, 0.9);
  animation: tab-news-pulse 2.6s ease-in-out infinite;
}
.mobile-tabbar .tab-news-dot { top: -3px; right: -8px; }

@keyframes tab-news-pulse {
  0%, 100% { transform: scale(1); opacity: 0.85; }
  50% { transform: scale(1.25); opacity: 1; }
}

/* One rule for everyone who asked not to be moved at. */
@media (prefers-reduced-motion: reduce) {
  .tabs a.tab-has-news,
  .tab-news-dot { animation: none; }
}

/* The "gems runs itself" rule, under its own button. */
.gems-auto-note {
  margin: 8px 0 0;
  color: var(--muted);
  font-size: 0.78rem;
  line-height: 1.45;
  max-width: 60ch;
}
.gems-auto-why {
  color: var(--accent);
  cursor: help;
  border-bottom: 1px dotted currentColor;
}

/* ---- Deactivated screen ------------------------------------------
   Someone else proved they own the Bandcamp this account was syncing.
   The person reading it might still be in the right, so it is calm, not
   an error page: no red, no siren, and the appeal is the main event
   rather than a footnote under an apology. */
.deactivated-body {
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px 16px;
  background:
    radial-gradient(1200px 600px at 50% -10%, rgba(47, 125, 214, 0.16), transparent 70%),
    var(--bg, #0b0c0d);
}
.deact-shell { width: 100%; max-width: 560px; }
.deact-card {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 32px 30px;
  box-shadow: 0 24px 70px rgba(0, 0, 0, 0.5);
}
.deact-logo { display: block; margin-bottom: 22px; }
.deact-title { margin: 0 0 12px; font-size: 1.3rem; font-weight: 700; }
.deact-lead { margin: 0 0 10px; color: var(--text); line-height: 1.5; font-size: 0.92rem; }
.deact-sub { margin: 0; color: var(--muted); line-height: 1.5; font-size: 0.85rem; }

.deact-appeal { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--border); }
.deact-appeal-lead { margin: 0 0 14px; font-weight: 600; font-size: 0.95rem; }

.deact-sent { margin-bottom: 18px; }
.deact-sent-head { margin: 0 0 8px; color: var(--muted); font-size: 0.78rem; }
.deact-thread { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
.deact-msg {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 8px 10px;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--border);
  font-size: 0.82rem;
}
.deact-msg-admin { border-color: rgba(47, 125, 214, 0.5); background: rgba(47, 125, 214, 0.1); }
.deact-msg-who { color: var(--muted); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; }
.deact-msg-body { white-space: pre-wrap; line-height: 1.45; }

.deact-form { display: flex; flex-direction: column; }
.deact-label { font-size: 0.75rem; color: var(--muted); margin-bottom: 5px; }
.deact-input, .deact-textarea {
  width: 100%;
  padding: 10px 12px;
  margin-bottom: 14px;
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  font: inherit;
  font-size: 0.88rem;
  resize: vertical;
}
.deact-input:focus, .deact-textarea:focus { outline: none; border-color: var(--accent); }
.deact-note {
  margin: 0 0 16px;
  color: var(--muted);
  font-size: 0.76rem;
  line-height: 1.5;
  padding: 9px 11px;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--border);
}
.deact-actions { display: flex; align-items: center; gap: 14px; }
.deact-send {
  background: var(--accent);
  color: #08090a;
  border: none;
  border-radius: 8px;
  padding: 10px 20px;
  font-weight: 600;
  font-size: 0.88rem;
  cursor: pointer;
}
.deact-send:disabled { opacity: 0.6; cursor: default; }
.deact-logout { color: var(--muted); font-size: 0.82rem; }
.deact-status { margin: 12px 0 0; color: var(--muted); font-size: 0.8rem; min-height: 1em; }

/* ---- Contact panel ------------------------------------------------
   Says something to us, and shows what we said back. Sits in the header
   next to the account menu; the dot means an answer is waiting. */
.contact-menu { position: relative; }
.contact-btn { position: relative; }
.contact-btn.has-reply { color: var(--text); border-color: rgba(255, 178, 94, 0.55); }

.contact-dot {
  position: absolute;
  top: -3px;
  right: -3px;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #ffb25e;
  box-shadow: 0 0 7px rgba(255, 178, 94, 0.95);
  animation: tab-news-pulse 2.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) { .contact-dot { animation: none; } }

.contact-panel {
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  z-index: 970;
  width: 340px;
  max-height: min(72vh, 620px);
  overflow-y: auto;
  padding: 14px;
  background: rgba(16, 18, 20, 0.98);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 22px 60px rgba(0, 0, 0, 0.6);
  backdrop-filter: blur(10px);
}
.contact-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.contact-title { font-weight: 600; font-size: 0.9rem; }
.contact-close {
  display: flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; padding: 0;
  background: none; border: none; color: var(--muted); cursor: pointer;
}
.contact-close:hover { color: var(--text); }
.contact-close .ui-icon { width: 14px; height: 14px; }

.contact-subjects { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
.contact-chip {
  padding: 5px 10px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: rgba(255, 255, 255, 0.03);
  color: var(--muted);
  font-size: 0.74rem;
  cursor: pointer;
  transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.contact-chip:hover { color: var(--text); }
.contact-chip.active {
  color: #08090a;
  background: var(--accent);
  border-color: var(--accent);
  font-weight: 600;
}

.contact-textarea {
  width: 100%;
  padding: 9px 10px;
  background: rgba(0, 0, 0, 0.35);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text);
  font: inherit;
  font-size: 0.82rem;
  resize: vertical;
}
.contact-textarea:focus { outline: none; border-color: var(--accent); }
.contact-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; }
.contact-status { color: var(--muted); font-size: 0.74rem; }
.contact-send {
  background: var(--accent);
  color: #08090a;
  border: none;
  border-radius: 7px;
  padding: 7px 16px;
  font-weight: 600;
  font-size: 0.8rem;
  cursor: pointer;
}

.contact-threads { margin-top: 14px; }
.contact-threads-head {
  color: var(--muted);
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  padding-top: 12px;
  border-top: 1px solid var(--border);
  margin-bottom: 8px;
}
.contact-thread { margin-bottom: 12px; }
.contact-thread-subject { font-size: 0.76rem; font-weight: 600; margin-bottom: 5px; }
.contact-msg {
  display: flex; flex-direction: column; gap: 2px;
  padding: 7px 9px; margin-bottom: 4px;
  border-radius: 7px;
  background: rgba(255, 255, 255, 0.035);
  border: 1px solid var(--border);
  font-size: 0.78rem;
}
/* An answer from us should be findable at a glance in a long thread. */
.contact-msg-admin { border-color: rgba(47, 125, 214, 0.55); background: rgba(47, 125, 214, 0.12); }
.contact-msg-who { color: var(--muted); font-size: 0.64rem; text-transform: uppercase; letter-spacing: 0.04em; }
.contact-msg-body { white-space: pre-wrap; line-height: 1.45; }
.contact-reply-btn {
  background: none; border: none; padding: 2px 0;
  color: var(--accent); font-size: 0.74rem; cursor: pointer;
}
