:root {
  --bg: #0a0a0c;
  --card: #16151a;
  --border: #26242c;
  --text: #f4f2f6;
  --muted: #9b96a3;
  --accent: #ff3d77; /* brand pink */
  --accent-alt: #ff8a00; /* brand orange */
  --gradient: linear-gradient(90deg, var(--accent-alt), var(--accent) 70%, #ff2fa0);
  --danger: #e0574c;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  /* Brian's call (24 Jul): the whole UI reads better a bit bigger — about
     what 125% browser zoom looked like. `zoom` scales layout, text and
     controls together exactly like real browser zoom does, without having
     to hand-adjust every px value across the stylesheet. Well supported in
     Chrome/Edge (both Chromium); easy to tune this one number if it needs
     nudging once seen live. */
  zoom: 1.25;
}

.hidden { display: none !important; }

.screen {
  min-height: 100vh;
}

/* ---------- Logo ---------- */

.logo-mark { height: 34px; width: auto; display: block; }
.logo-mark-lg { height: 36px; margin: 0 auto 12px; }

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

/* ---------- Login screen ---------- */

#logged-out-view {
  display: flex;
  align-items: center;
  justify-content: center;
}

.login-card {
  text-align: center;
  padding: 48px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 16px;
  max-width: 380px;
}

.login-logo {
  width: min(440px, 92%);
  height: auto;
  display: block;
  margin: 0 auto 20px;
}
.login-card p { color: var(--muted); }

.btn {
  display: inline-block;
  padding: 10px 18px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--card);
  color: var(--text);
  cursor: pointer;
  font-size: 14px;
  text-decoration: none;
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  /* The actual cause of the "opposite colour" mark on the play/skip
     buttons (confirmed 24 Jul via DevTools — a user-agent stylesheet block
     with !important flags, which is Windows forced-colors/high-contrast
     mode, not a normal browser default): forced-colors intentionally
     overrides author styles on interactive elements for accessibility, at
     a level even !important in our own CSS can't out-rank. This property
     is the standards-defined way to opt a specific element out of that
     override and trust our custom styling instead. Same reason
     appearance: none above didn't fix it on its own — that resets native
     control chrome, this is a separate, OS-level accessibility mode. */
  forced-color-adjust: none;
}

.btn:hover { border-color: var(--accent); }

/* Browsers draw the default focus outline as a rectangle that ignores
   border-radius — on a circular button (border-radius: 999px) that leaves
   a stray straight-edge sliver poking out past the curve, especially
   visible near the top-right corner against a dark page. That's almost
   certainly the "white mark" on the play/skip buttons — replacing it with
   a box-shadow ring instead, which does follow border-radius properly. */
.btn:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-alt);
}

.btn-primary {
  background: var(--gradient);
  color: #14030b;
  border-color: transparent;
  font-weight: 700;
}

/* ---------- App shell ---------- */

/* 28 Jul, Brian: "can we do it, so that the controls stay on the screen...
   when you scroll the songs" — then, once that first version landed but
   before it froze anything usefully: "i wanted more of the songs in view
   when you scroll... like on excel when you 'freeze pane'... that way we
   get a bit more space for [songs]." The first pass wrapped .topbar too,
   which — since .topbar sits at the very top of the page with nothing
   above it — meant the WHOLE group (including the tall logo bar) was
   already at top:0 and froze immediately, never actually scrolling away
   and gaining nothing. Now this wrapper only covers .now-playing + .tabs
   (see index.html — .topbar is back to plain document flow just above it).
   `position: sticky` (not `fixed`) means it behaves like normal document
   flow until you scroll far enough that it WOULD leave the top of the
   viewport, at which point it sticks: .topbar scrolls away first exactly
   like ordinary content, then this smaller group catches and freezes,
   leaving the topbar's height back to the track list underneath. Plays
   correctly with `body { zoom: 1.25 }` above, unlike a hand-rolled
   fixed+padding version would need extra math for. Solid background so the
   track list scrolling underneath doesn't show through; z-index high
   enough to sit above ordinary content but below the higher-priority
   floating bits (.btn-settings-menu / .musicae-status-right on mobile at
   z-index 41, .chart-graph-overlay at 50) so those still layer correctly
   on top of it. */
.sticky-header {
  position: sticky;
  top: 0;
  z-index: 30;
  background: var(--bg);
}

/* Hidden by default/on mobile — the frozen band there is already tight on
   space (see the max-height fight below), and re-shown only inside the
   desktop media query below once .sticky-header has .is-stuck. Declared
   here, BEFORE that media query, so the query's own display:flex actually
   wins the cascade on desktop widths instead of being overridden by this
   unconditional rule. */
.sticky-logo {
  display: none;
}

/* 28 Jul, Brian (desktop browser screenshot): "do the same on the webbrowser
   with the scroll, just cap it at this height" — the frozen band can grow
   taller than usual when e.g. a long rate-limit error wraps the status line
   to several lines (exactly what was in that screenshot), eating further
   into the track list's space. Capping it means it never grows past this
   regardless of how long that text gets; overflow-y:auto is the safety
   valve so a genuinely long message scrolls inside its own small area
   instead of being clipped outright.
   28 Jul, take 2: this was originally on the base .sticky-header rule with
   no width guard, which broke mobile — the phone's now-playing layout is a
   taller stacked column (art above title above controls, not a compact
   side-by-side row like desktop), so 300px clipped the control buttons and
   hid the tab bar entirely there. Scoped to desktop-width screens only, to
   match what Brian actually asked for ("on the webbrowser"); mobile keeps
   its own uncapped height (its version of "cap it at this height" is the
   separate compact-column layout already in the max-width:700px block
   below, not this number).
   28 Jul, take 3: same screen also now hosts .sticky-header-top/.sticky-
   logo (see further down) — "on the web version when the scroll down
   happens, we lose the logo... move it so it sits on the right side...
   opposite the now playing and controls." All desktop-only freeze-pane
   refinements live together in this one query. */
@media (min-width: 701px) {
  .sticky-header {
    max-height: 300px;
    overflow-y: auto;
    /* 28 Jul: stray horizontal scrollbar showed up under the tabs — per the
       CSS spec, setting overflow-y to anything other than visible while
       overflow-x is left at its default 'visible' makes the browser treat
       overflow-x as 'auto' too, and the sticky-logo row (even fully
       transparent pre-.is-stuck, it's still display:flex and occupying
       real width) was just barely wide enough at this zoom level to trip
       it. Pinning overflow-x explicitly stops that implicit auto. */
    overflow-x: hidden;
  }

  .sticky-header-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 20px;
  }

  /* Brian: "make it a bit bigger as well" — noticeably larger than the
     topbar's own logo (34px mark / 18px text below), since this one has to
     read clearly at a glance without the full-width topbar context around
     it. Starts invisible/offset and fades+slides in only once
     .sticky-header actually has .is-stuck on it (see
     wireStickyHeaderObserver() in app.js) — "hopefully there is a way to
     do a cool transition" — rather than just popping in the instant the
     header freezes. */
  .sticky-logo {
    display: flex;
    align-items: center;
    gap: 12px;
    padding-right: 24px;
    opacity: 0;
    transform: translateX(16px);
    pointer-events: none;
    transition: opacity 0.35s ease, transform 0.35s ease;
  }

  .sticky-header.is-stuck .sticky-logo {
    opacity: 1;
    transform: translateX(0);
    pointer-events: auto;
  }

  .sticky-logo-mark {
    height: 46px;
    width: auto;
    display: block;
  }

  .sticky-logo-text {
    font-size: 24px;
    font-weight: 600;
    text-transform: lowercase;
    letter-spacing: -0.5px;
    color: var(--text);
  }
}

/* 28 Jul, Brian: "on the phone scroll, when it comes down you lose the
   logo, but keep the artwork... could we instead swap them around... no
   artwork, but we have the logo in view all the time" — take 2. The first
   version (order:-1 + display:none on .np-art) "sort of flickered up, not
   working properly": hiding .np-art removed 120px of height from the
   frozen panel, and browsers try to preserve the reading position across a
   layout shift like that (scroll anchoring) by nudging scrollTop to
   compensate — which was just enough to push #sticky-header-sentinel back
   into view, un-stick, show the art again, shift again, re-stick... a
   feedback loop, which is exactly what "flickers" looks like.
   Fix: never change any box's SIZE at all. .np-art keeps its normal
   dimensions and stays in the document at all times — this cover is
   absolutely positioned INSIDE it (.np-art already has position:relative
   for its rank badge) and toggling its display is purely a paint change,
   not a layout one, so there's nothing for scroll anchoring to react to. */
.np-art-logo-cover {
  display: none;
}

/* 29 Jul, Brian: "on mobile, i really don't like that artwork box staying
   and filling with the logo... shrink the header, no art" — take 3 tried
   shrinking .np-art down to a small badge with the logo mark inside it
   (see git history), but Brian's next call: "i wanted the logo to remain,
   but if your having issues sorting, backburner it" — simplified to just
   collapsing the box away entirely, no logo replacement for now (that's
   parked in BACKLOG.md if it's worth revisiting later). Still the same
   class of change that caused take 1's flicker (a resize right at the
   scroll position a toggle fires at can retrigger scroll anchoring,
   un-stick, re-trigger...), and still fixed the same way — see
   .is-shrunk's own comment in wireStickyHeaderObserver() in app.js for how
   the hysteresis gap works; collapsing all the way to 0 is a BIGGER resize
   than the badge version was, but still comfortably inside that same
   200px buffer. */
@media (max-width: 700px) {
  .np-art {
    transition: width 0.3s ease, height 0.3s ease, opacity 0.3s ease;
  }

  .sticky-header.is-shrunk .np-art {
    width: 0;
    height: 0;
    opacity: 0;
    box-shadow: none;
    overflow: hidden;
  }
}

/* 28 Jul: 1px sentinel .sticky-header sits right after — see the
   IntersectionObserver comment in index.html and
   wireStickyHeaderObserver() in app.js for the full mechanism. Not
   display:none since IntersectionObserver can't watch a non-rendered
   element; 1px is close enough to invisible not to nudge layout. */
#sticky-header-sentinel {
  height: 1px;
}

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 24px;
  border-bottom: 1px solid var(--border);
}

.topbar h1 { font-size: 18px; margin: 0; text-transform: lowercase; letter-spacing: -0.5px; }

/* Recreates the brand mark's orange dot on the "i" in the live text
   wordmark — the login screen's logo is a raster image so it already has
   this baked in, but the topbar heading is real text with a plain system
   dot. Swaps in the dotless-i character (ı) and layers a coloured circle
   over it in the same spot instead. */
.brand-i { position: relative; display: inline-block; line-height: 1; }
.brand-i-dot {
  position: absolute;
  top: -0.06em;
  left: 50%;
  width: 0.16em;
  height: 0.16em;
  border-radius: 50%;
  background: var(--accent-alt); /* logo orange */
  transform: translateX(-50%);
}

/* 25 Jul, Brian: "make the dot pulse when you hit favourite" — the logo's
   dot glows/pops briefly as a reactive echo whenever Favourite fires,
   without the logo itself becoming the click target (see the chat
   discussion on why that stayed a separate control). JS just toggles this
   class on and back off — see wireControls() in app.js. */
@keyframes brandDotPulse {
  0% { transform: translateX(-50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 138, 0, 0.7); }
  35% { transform: translateX(-50%) scale(2.2); box-shadow: 0 0 10px 6px rgba(255, 138, 0, 0.55); }
  100% { transform: translateX(-50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 138, 0, 0); }
}
.brand-i-dot.pulse {
  animation: brandDotPulse 0.7s ease-out;
}

/* 26 Jul, Brian: "animate the i's dot to bounce in time to it [bpm]" — a
   quick, subtle hop rather than the bigger Favourite pulse above, since
   this repeats every beat rather than firing once. translateX(-50%) has
   to stay in every step (same as brandDotPulse) — it's the dot's actual
   centering, not decorative, and dropping it would make the dot jump
   sideways for the animation's duration. See startBrandDotBeat() in
   app.js for what schedules this. */
@keyframes brandDotBeat {
  0% { transform: translateX(-50%) translateY(0) scale(1); }
  30% { transform: translateX(-50%) translateY(-0.05em) scale(1.15); }
  100% { transform: translateX(-50%) translateY(0) scale(1); }
}
.brand-i-dot.beat-bounce {
  animation: brandDotBeat 0.18s ease-out;
}

/* 26 Jul: topbar now hosts two pickers (source + device) side by side —
   wrapping both in one flex container keeps the topbar itself a simple
   2-child space-between (brand, controls) instead of unevenly spreading 3
   items across the full width. */
.topbar-controls {
  display: flex;
  align-items: center;
  gap: 20px;
}

/* 26 Jul, Brian: "can get rid of the playlist selector for now as well,
   we'll revisit that later, as that wasn't working when we tried it before
   anyway." Hidden rather than removed — wireSourcePicker() in app.js and
   the server-side test-playlist plumbing are untouched and still work,
   this just takes the control out of the topbar (and off the phone layout,
   which had no room for two pickers anyway) until it's revisited. */
.source-picker {
  display: none;
  align-items: center;
  gap: 8px;
}

/* 26 Jul, Brian: "is there anyway we can run it on a phone" — same look as
   the source picker, just a separate control since it drives a different
   thing (which device commands go to, not which tracks are loaded). */
.device-picker {
  display: flex;
  align-items: center;
  gap: 8px;
}

.device-picker select {
  background: var(--card);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 10px;
}

/* 26 Jul, Brian: "the album art/show exact numbers should have a cog icon
   to reduce the clutter... top right of the screen" — on desktop this is
   just a plain inline flyout (cog hidden, .musicae-status-right shows as
   normal), same visual weight as when these toggles lived in the Top
   Listened tab. The cog + fixed dropdown panel only kick in on mobile —
   see the @media block below. */
.settings-menu {
  display: flex;
  align-items: center;
  gap: 8px;
}

.btn-settings-menu {
  display: none;
}

.source-picker select {
  background: var(--card);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 10px;
}

.source-picker input {
  background: var(--card);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 6px 10px;
  width: 200px;
  font-size: 13px;
}

/* ---------- Now playing ---------- */

.now-playing {
  display: flex;
  align-items: flex-start;
  gap: 20px;
  padding: 24px;
  border-bottom: 1px solid var(--border);
  max-width: 560px;
}

.np-art {
  width: 140px;
  height: 140px;
  border-radius: 14px;
  background: var(--card);
  flex-shrink: 0;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
  position: relative;
  overflow: hidden;
}

/* Corner badge over the cover art itself for a top-3 track — see comment
   near the old .track-list rule above for the full backstory. Take 2:
   corner chip instead of a full-height side strip. Hidden by default; JS
   adds .visible + a .rank-1/2/3 colour class when the playing track is in
   the top 3. Colours are the logo's own gradient stops (start/middle/end
   of var(--gradient)), not an arbitrary yellow/orange/pink pick. */
.np-art-rank {
  display: none;
  position: absolute;
  top: 0;
  left: 0;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  border-radius: 14px 0 12px 0;
  font-weight: 700;
  font-size: 11px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
}
.np-art-rank.visible { display: flex; }
/* 25 Jul, Brian: "2 looks like 3 colour-wise, adjust so you can see the
   difference" — var(--accent) and the gradient's #ff2fa0 end are both
   pinks, too close together as flat swatches even though they read fine
   blended in a gradient. Re-spaced rank-2 to an even blend of the start
   and end colours (orange + magenta) instead of the literal middle
   gradient stop, so all three are clearly distinct at a glance. */
.np-art-rank.rank-1 { background: var(--accent-alt); color: #2a1400; }
.np-art-rank.rank-2 { background: #ff5d50; color: #fff; }
.np-art-rank.rank-3 { background: #ff2fa0; color: #fff; }

.np-body {
  display: flex;
  flex-direction: column;
  min-width: 0;
  padding-top: 4px;
  /* Positioning context for .btn-favourite below — floats top-right of
     this column rather than sitting inline in the text flow. */
  position: relative;
}

/* 25 Jul: clearance tracks .btn-favourite's current footprint — right:0 +
   width:64 spans the full 0-64px from the panel's right edge, so
   padding-right needs to clear at least that much or long titles run
   underneath it. */
.np-title { font-weight: 600; font-size: 16px; padding-right: 74px; }

/* 25 Jul, Brian: "still sitting too tight to skip harder when no song
   loaded" — root cause wasn't the star's own offset, it was this line.
   With no track loaded, np-artist is empty text with no reserved height,
   so the whole block above .np-controls-row quietly collapses shorter
   than it is once a track (and its artist name) is actually loaded —
   which pulls the controls row up closer to the fixed-position star.
   min-height keeps that space reserved in both states, so the star sits
   the same distance from the buttons whether something's loaded or not. */
/* 26 Jul, Brian: favourite button overlapping text again — .np-title got
   padding-right to clear it (see comment above) but .np-artist never did,
   so a long artist string (e.g. "Fenix Flexin, Purps On The Beat") still
   ran underneath the star. Same 74px clearance as the title. */
.np-artist { color: var(--muted); font-size: 13px; margin-bottom: 10px; min-height: 16px; padding-right: 74px; }

.np-status {
  font-size: 12px;
  color: var(--muted);
  min-height: 14px;
  margin-bottom: 6px;
}
.np-status.np-status-error { color: var(--danger); }

.np-progress {
  width: 180px;
  height: 4px;
  background: var(--border);
  border-radius: 2px;
  overflow: hidden;
  cursor: pointer;
}

.np-progress-fill {
  height: 100%;
  width: 0%;
  background: var(--gradient);
}

.np-controls-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 12px;
}

/* 29 Jul, Brian: "another thing that would be good for the desktop version -
   volume control?" — desktop only, hidden on mobile below (phones already
   have hardware volume buttons + the OS media session, a second on-screen
   slider there is redundant clutter, not a missing feature). Styled as a
   plain custom range input rather than pulling in a slider component —
   thumb/track colours reuse the app's existing gradient stops so it matches
   the play/skip buttons instead of looking like a bare browser default. */
.np-volume-row {
  display: flex;
  align-items: center;
  gap: 8px;
  /* 29 Jul (2nd pass), Brian: "volume a bit too close to the buttons,
     secret skip text, almost overlaps" — .secret-skip-caption sits
     position:absolute below its button (top:100% + 4px margin, ~18px of
     text) specifically so it doesn't affect .np-controls-row's own height/
     centring — but that also means it hangs BELOW the row's visible box
     edge, into space the next element's margin-top has no idea it needs to
     clear. 10px wasn't enough; bumped to give the caption real breathing
     room underneath it. */
  margin-top: 26px;
  max-width: 200px;
}

.np-volume-icon {
  color: var(--muted);
  flex-shrink: 0;
}

.np-volume-slider {
  -webkit-appearance: none;
  appearance: none;
  flex: 1;
  height: 4px;
  border-radius: 2px;
  background: var(--border);
  cursor: pointer;
}

.np-volume-slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: linear-gradient(135deg, #ff8a00, #ff3d77 70%, #ff2fa0);
  border: none;
  cursor: pointer;
}

.np-volume-slider::-moz-range-thumb {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: linear-gradient(135deg, #ff8a00, #ff3d77 70%, #ff2fa0);
  border: none;
  cursor: pointer;
}

.btn-icon {
  min-width: 40px;
  text-align: center;
  padding: 8px 14px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* Play/pause is the primary now-playing action — permanent gradient fill,
   same treatment as the login screen's primary CTA. Skip gets the same
   gradient only while pressed, a tactile "action taken" flash rather than
   a second permanent accent, since it's a secondary control.

   24 Jul: the permanent gradient here was the button showing a persistent
   colour-fringe artifact on the pill's rounded cap (confirmed via DevTools
   to come from `background: var(--gradient)` + `border-radius: 999px`
   together — a rendering seam, not app.js/markup). Fixed by drawing the
   whole pill (background gradient + icon) as one SVG in app.js/index.html
   instead, so this element is now just a plain, invisible hit-target
   sized to match that SVG — no CSS background, border, or padding of its
   own to fight with. */
#btn-play-pause {
  background: none;
  border: none;
  padding: 0;
  min-width: 0;
}

/* 25 Jul: hovering/pressing skip showed the exact same colour-fringe seam
   the play button had — same root cause (`background: var(--gradient)`
   clipped by `border-radius: 999px`), just triggered transiently instead of
   permanently. Same fix as play: both states are drawn as one self-contained
   SVG (a plain pill for rest, a gradient pill for hover/press) rather than a
   CSS background+border-radius pill, so there's no gradient-clip seam
   possible at all. #btn-skip drops all its own CSS background/border/padding
   permanently (like #btn-play-pause above) — the SVGs own 100% of the
   visual now, in both states. (Briefly tried a plain-text "Skip" button
   instead — didn't land, reverted back to this.) */
#btn-skip {
  background: none;
  border: none;
  padding: 0;
  min-width: 0;
}
/* 25 Jul, Brian: "everything below jogs up when I hover the skip button" —
   confirmed real via two side-by-side screenshots, and this was the cause.
   .btn-skip-gradient explicitly got display:inline-flex on hover, but
   .btn-skip-plain (the rest state) never got any explicit display at all —
   it defaulted to a plain inline span, which reserves a few px of
   baseline/line-height space below its SVG that inline-flex doesn't. So
   the rest state was quietly TALLER than the hover state, and the whole
   page below .np-controls-row shifted up by that difference every time the
   gradient span (tight, no baseline gap) swapped in. Giving BOTH spans the
   same base display:inline-flex makes their box heights identical
   regardless of which one is currently shown. */
#btn-skip .btn-skip-plain,
#btn-skip .btn-skip-gradient {
  display: inline-flex;
  align-items: center;
}
#btn-skip .btn-skip-gradient { display: none; }
#btn-skip:hover .btn-skip-plain,
#btn-skip:active .btn-skip-plain {
  display: none;
}
#btn-skip:hover .btn-skip-gradient,
#btn-skip:active .btn-skip-gradient {
  display: inline-flex;
}

/* 25 Jul: Favourite — a single persistent "up" action, giving a track a
   strong tier-3 boost so it jumps hard up the chart. Solid orange fill
   (var(--accent-alt), the same colour ratingArrowHtml() already uses for
   "up"), not a gradient — same border-radius+gradient seam bug avoidance
   as Play/Skip, just via the cheaper route since this one never needed a
   gradient in the first place. */
.btn-favourite {
  position: absolute;
  /* 25 Jul, Brian's 6th markup pass — too close to the panel's top edge at
     90px/top:-18px (crowded the border, looked odd when nothing's loaded
     and there's little else on screen to balance it against). Smaller and
     pulled fully inside the panel — top is no longer negative, so it can't
     poke above .now-playing's own padding regardless of what's playing. */
  top: 4px;
  right: 0;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  border: none;
  background: var(--accent-alt);
  color: #2a1400;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}

.btn-favourite:hover {
  box-shadow: 0 0 0 3px rgba(255, 138, 0, 0.25);
}

.btn-favourite.is-pressed {
  transform: scale(1.25);
}

.np-rating {
  margin-top: 10px;
}

.np-rating-current-row {
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  gap: 6px;
  margin-top: 4px;
}

.np-rating-current {
  color: var(--muted);
  font-size: 11px;
  min-height: 18px;
  display: flex;
  align-items: center;
}

.np-rating-current.has-rating {
  color: var(--accent);
  font-weight: 700;
}

.rating-arrow-up,
.rating-arrow-down {
  display: inline-flex;
  align-items: center;
  gap: 2px;
}

.rating-arrow-up { color: var(--accent-alt); } /* logo orange */
.rating-arrow-down { color: var(--accent); } /* logo pink */

/* 29 Jul, Brian: "if one hasn't moved up or down, should be a grey - not an
   arrow" — see chartSameHtml() in app.js. Deliberately not an arrow shape at
   all (not even a grey one) so "no movement" can't be misread as a weak
   up/down signal — just a flat neutral dash. */
.chart-same-dash {
  color: var(--muted);
  font-weight: 700;
}

.rating-chevron {
  display: block;
}

/* 26 Jul, Brian: label was pinned far left with the switch itself all the
   way over on the right, on its own row below the sync status — moved the
   whole thing (label + switch) up onto the sync status row, right-aligned,
   and shrunk the switch itself down a size (see .toggle-switch below). */
.np-rating-toggle-row {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}

.np-rating-toggle-label {
  color: var(--muted);
  font-size: 11px;
}

.toggle-switch {
  position: relative;
  width: 32px;
  height: 18px;
  flex-shrink: 0;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--card);
  padding: 0;
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease;
}

.toggle-switch .toggle-knob {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--muted);
  transition: transform 0.15s ease, background 0.15s ease;
}

.toggle-switch:hover { border-color: var(--accent); }

.toggle-switch.is-active {
  background: var(--accent);
  border-color: transparent;
}

.toggle-switch.is-active .toggle-knob {
  transform: translateX(14px);
  background: #fff;
}

/* 25 Jul: replaces the old .btn-tier/.btn-rate-down/.tier-chevrons pair
   (text-labelled "Skip Hard"/"Skip Harder" buttons) — collapsed to one
   button, same plain/fill SVG-swap pattern as #btn-skip above (see the
   comment on that rule for why it's two full self-contained SVGs rather
   than a CSS background/border pill — same gradient-clip-seam avoidance).
   Also carries forward the #btn-skip hover-shift bug fix from the same
   day: both spans get the same base display:inline-flex so their box
   heights match regardless of which is showing. */
#btn-skip-extra {
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
}
#btn-skip-extra .btn-skip-extra-plain,
#btn-skip-extra .btn-skip-extra-fill {
  display: inline-flex;
  align-items: center;
}
#btn-skip-extra .btn-skip-extra-fill { display: none; }
#btn-skip-extra:hover .btn-skip-extra-plain,
#btn-skip-extra:active .btn-skip-extra-plain,
#btn-skip-extra.is-pressed .btn-skip-extra-plain {
  display: none;
}
#btn-skip-extra:hover .btn-skip-extra-fill,
#btn-skip-extra:active .btn-skip-extra-fill,
#btn-skip-extra.is-pressed .btn-skip-extra-fill {
  display: inline-flex;
}

/* 25 Jul, Brian: "secret skip" — a skip that doesn't affect the song's
   rating, only excludes it from the rest of this session. Deliberately
   set apart from Play/Skip/Extra with a divider.
   26 Jul, Brian (after two dot-based passes): "maybe we don't use dots at
   all and just have a line like the others, but it fills like we have it
   doing at the moment...unlike the others" — #btn-skip/#btn-skip-extra
   swap their whole plain→gradient SVG instantly on hover; this one instead
   grows a gradient stroke in along the pill's outline via
   stroke-dashoffset (see .secret-skip-fill and the two mirrored <path>s in
   index.html, pathLength="100" normalises each so the dashoffset maths
   doesn't care about the actual arc geometry). Both paths start at the
   pill's bottom-centre point and run to its top-centre point — one via
   the right cap, one via the left — so they grow in from both sides at
   once, meeting at the top, same motion as the old dot sweep. */
.secret-skip-divider {
  width: 1px;
  height: 26px;
  background: var(--border);
  margin: 0 2px;
}

.secret-skip-col {
  position: relative;
}

.btn-secret-skip {
  width: 38px;
  height: 28px;
  position: relative;
  border: none;
  background: none;
  cursor: pointer;
  padding: 0;
}

.secret-skip-pill {
  position: absolute;
  top: 0;
  left: 0;
}

.secret-skip-outline {
  opacity: 0.55;
}

.secret-skip-fill {
  stroke-dasharray: 100;
  stroke-dashoffset: 100;
  /* 26 Jul, Brian: "just got a little pixel of pink when resting" — the
     round linecap rounds slightly past its own dash boundary, and that
     boundary sits exactly on the shared bottom-centre start point of both
     paths at rest, so a sliver of the gradient stroke poked through no
     matter how the dash/gap maths were tuned. Gating on opacity too (0 at
     rest, 1 on hover/active) removes it outright — nothing paints at all
     until hover, regardless of any dash-boundary rounding. */
  opacity: 0;
  transition: stroke-dashoffset 0.4s ease, opacity 0.05s ease;
}

/* 27 Jul, Brian: "secret skip on the phone when used stays filled" — this
   used to be a plain :hover/:active pair. Touch browsers (iOS Safari in
   particular) commonly apply :hover styles on tap and don't clear them
   again until you tap something else — there's no real pointer to move
   away and trigger :hover leaving. `disabled` also gets set synchronously
   right in the click handler (see wireControls()), which can stop :active
   from clearing properly too. @media (hover: hover) means touch devices
   (which report hover: none) never match this rule from a tap at all —
   mouse users still get the hover preview. The actual tap feedback comes
   from .is-pressed instead, an explicit class app.js adds and removes on
   a timeout — same "flash" pattern Mega Skip already uses — so it's
   guaranteed to clear regardless of whatever the browser's own
   hover/active tracking does. */
@media (hover: hover) {
  .btn-secret-skip:hover .secret-skip-fill {
    stroke-dashoffset: 0;
    opacity: 1;
  }
}

.btn-secret-skip:active .secret-skip-fill,
.btn-secret-skip.is-pressed .secret-skip-fill {
  stroke-dashoffset: 0;
  opacity: 1;
}

.secret-skip-arrow {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: var(--muted);
  opacity: 0.3;
  transition: color 0.2s ease, opacity 0.2s ease;
}

@media (hover: hover) {
  .btn-secret-skip:hover .secret-skip-arrow {
    color: var(--accent);
    opacity: 1;
  }
}

.btn-secret-skip:active .secret-skip-arrow,
.btn-secret-skip.is-pressed .secret-skip-arrow {
  color: var(--accent);
  opacity: 1;
}

.secret-skip-caption {
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-top: 4px;
  white-space: nowrap;
  font-size: 11px;
  color: var(--muted);
  font-weight: 600;
  letter-spacing: 0.2px;
}

/* ---------- Tabs ---------- */

.tabs {
  display: flex;
  gap: 4px;
  padding: 0 24px;
  border-bottom: 1px solid var(--border);
}

.tab-btn {
  padding: 12px 16px;
  background: none;
  border: none;
  color: var(--muted);
  font-size: 14px;
  cursor: pointer;
  border-bottom: 2px solid transparent;
}

.tab-btn.active {
  color: var(--text);
  border-bottom-color: var(--accent);
}

/* Group the tabs by what they're for, in the brand's two colours: Top
   Listened + Skipped both deal with your current library (logo orange),
   Suggested + Search are both about finding something new (logo pink).
   Tint shows even at rest so the grouping reads at a glance, not just on
   the active tab. */
.tab-btn.tab-group-library { color: rgba(255, 138, 0, 0.6); }
.tab-btn.tab-group-discover { color: rgba(255, 61, 119, 0.6); }

.tab-btn.tab-group-library.active {
  color: var(--accent-alt);
  border-bottom-color: var(--accent-alt);
}

.tab-btn.tab-group-discover.active {
  color: var(--accent);
  border-bottom-color: var(--accent);
}

.tab-panel {
  display: none;
  padding: 20px 24px;
}

.tab-panel.active { display: block; }

.hint { color: var(--muted); font-size: 13px; margin-top: 0; }

.musicae-status-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-top: -4px;
  margin-bottom: 12px;
}

.musicae-status-left {
  display: flex;
  align-items: center;
  gap: 10px;
}

/* 26 Jul: groups the album art + "show exact numbers" toggles together on
   the right of the sync status row. */
.musicae-status-right {
  display: flex;
  align-items: center;
  gap: 16px;
}

.btn-musicae-sync {
  font-size: 12px;
  padding: 3px 10px;
  border-radius: 999px;
  border: 1px solid var(--accent-alt);
  background: transparent;
  color: var(--accent-alt);
  cursor: pointer;
  flex-shrink: 0;
}

.btn-musicae-sync:hover { background: var(--accent-alt); color: #2a1400; }
.btn-musicae-sync:disabled { opacity: 0.5; cursor: default; background: transparent; color: var(--muted); border-color: var(--border); }

.suggested-controls {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 16px;
  font-size: 13px;
  color: var(--muted);
}

.suggested-controls select {
  background: var(--card);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 4px 8px;
}

.search-controls {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
}

.search-controls input {
  /* 29 Jul (2nd pass), Brian: "searchbox on mobile doesn't fit the screen,
     it runs off the side" — this input used to be a direct flex child of
     .search-controls (flex: 1 here did real work). Wrapping it in
     .search-box-wrap-flex for the clear button (below) made THAT wrapper
     the actual flex child instead, leaving flex:1 on the input itself a
     no-op and the input sized to the browser's default intrinsic input
     width instead of filling its wrapper — width:100% replaces it now
     that the wrapper is what's flexing. */
  width: 100%;
  padding: 8px 32px 8px 12px; /* right padding clears btn-search-clear */
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--card);
  color: var(--text);
  font-size: 14px;
}

/* 29 Jul, Brian: "search box doesn't have an x to clear the text" — shared
   wrapper for both search-like inputs (Search tab + Top Listened filter) so
   the clear button can sit inside the input's own right edge. Search's own
   input already sits inside .search-controls' flex row, so this wrapper
   also needs to grow to fill the remaining space there. */
.search-box-wrap {
  position: relative;
}

/* 29 Jul (2nd pass): min-width:0 is the fix for "runs off the side" above —
   a flex item's min-width defaults to auto, which for a box containing a
   form control resolves to that control's own intrinsic width (its
   browser-default size), not 0. That silently floors how far this wrapper
   could ever shrink, however narrow the phone screen, pushing the Search
   button (and the new clear button inside this wrapper) off the right edge
   of the viewport. Explicit 0 removes that floor so flex-shrink can
   actually do its job. */
.search-box-wrap-flex {
  flex: 1;
  min-width: 0;
}

.search-clear-btn {
  position: absolute;
  right: 4px;
  top: 50%;
  transform: translateY(-50%);
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  color: var(--muted);
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  border-radius: 50%;
}

.search-clear-btn:hover {
  color: var(--text);
  background: var(--border);
}

.search-controls input:focus {
  outline: none;
  border-color: var(--accent);
}

/* 26 Jul, Brian: "the search/filter on top listened" — same input look as
   Search's own box, just full-width with no button next to it since
   filtering happens live against tracks already on screen. */
.top-search-input {
  display: block;
  width: 100%;
  box-sizing: border-box;
  padding: 8px 32px 8px 12px; /* right padding clears btn-top-search-clear */
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--card);
  color: var(--text);
  font-size: 14px;
  margin-bottom: 12px;
}

.top-search-input:focus {
  outline: none;
  border-color: var(--accent);
}

/* 29 Jul (3rd pass), Brian: "search box still going off screen on tap" —
   not a layout bug this time (width:100%/min-width:0 above already hold up
   fine at rest). iOS Safari auto-zooms the whole viewport when you focus
   any input whose font-size is under 16px, to keep the text legible — both
   search boxes were 14px, so tapping either one zoomed the page in around
   it, which reads as "the box ran off screen" even though nothing in the
   DOM actually moved. Scoped to mobile only so desktop's 14px look is
   unchanged; the standard fix is bumping focused-input font-size to >=16px,
   not disabling pinch-zoom via the viewport meta tag (that would cost
   everyone the ability to zoom the whole page, not just this one quirk). */
@media (max-width: 700px) {
  .top-search-input,
  .search-controls input {
    font-size: 16px;
  }
}

/* ---------- Track list ---------- */

.track-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.track-list li {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border);
  font-size: 14px;
}

/* 26 Jul, Brian: album art toggle — leading thumbnail, only present in the
   DOM at all when the toggle's on (see albumArtHtml() in app.js). Placeholder
   variant (no art URL for this track) gets the same box/radius so rows still
   line up instead of the row collapsing narrower where art's missing. */
.t-art {
  width: 32px;
  height: 32px;
  border-radius: 4px;
  flex-shrink: 0;
  object-fit: cover;
}
.t-art-placeholder {
  background: var(--card);
  border: 1px solid var(--border);
}

/* Second-to-last child is always the name/artist block, whether or not this
   particular row has a leading .t-rank (only Top Listened rows do) — this
   selector works for both without needing separate CSS per list. Letting it
   grow (instead of the old justify-content: space-between) is what still
   pushes .t-meta to the row's end. */
.track-list li > span:nth-last-child(2) {
  flex: 1;
  min-width: 0;
}

.track-list li.clickable {
  cursor: pointer;
  border-radius: 8px;
  padding-left: 8px;
  padding-right: 8px;
  margin: 0 -8px;
}

/* 25 Jul, Brian: "top 3 get a little decoration... even a strip like that
   [pointed at an album cover with a coloured spine strip down the left
   edge], with a #1 or something." Take 1 put this strip on the Top
   Listened row instead — Brian's clarification: he meant it over the
   cover art itself, like the reference album covers. Moved to a strip on
   .np-art below (see .np-art-rank). Colour split per Brian: rank 1
   yellow-orange, rank 2 orange, rank 3 pink — the same warm-to-pink sweep
   var(--gradient) already uses elsewhere, so it reads as on-brand rather
   than a literal gold/silver/bronze medal set. */

.track-list li.clickable:hover {
  background: rgba(255, 255, 255, 0.04);
}

.track-list .t-name { font-weight: 500; }
.track-list .t-artist { color: var(--muted); font-size: 12px; }
.track-list .t-meta { color: var(--muted); font-size: 12px; }

/* Chart position + movement (25 Jul, Top Listened only) — deliberately NOT
   the brand orange/pink used for rating arrows (see .rating-arrow-up/-down
   above): this is a different signal (where a track ranks vs. before, not
   how much you like it) and needed its own, cooler visual language so the
   two don't blur together at a glance. Bright text for "climbing" (the
   interesting case), muted for "falling"/"new" — weight instead of hue. */
.t-rank {
  flex-shrink: 0;
  width: 44px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.t-rank-num {
  color: var(--muted);
  font-size: 12px;
  font-weight: 600;
}

.t-rank-move {
  display: inline-flex;
  align-items: center;
  gap: 1px;
  font-size: 10px;
  font-weight: 700;
}

.t-rank-move .rating-chevron { width: 10px; height: 10px; }

.t-rank-move-up { color: var(--text); }
.t-rank-move-down { color: var(--muted); }
.t-rank-move-new { color: var(--muted); letter-spacing: 0.5px; }

/* 27 Jul, Brian: "can we somehow get a graph on each song that shows its
   chart rating... one that looks fucking cool". Small inline sparkline in
   every Top Listened row (see sparklineHtml() in app.js) — inserted
   BEFORE the name/artist span in the row markup rather than after, so
   .t-meta stays the last child and the name/artist block stays second-to-
   last (see .track-list li > span:nth-last-child(2) above, which that
   ordering depends on). Clickable — opens the bigger version, see
   .chart-graph-overlay below. Rows with under 2 recorded points render an
   empty <svg> (nothing to plot yet) rather than a flat/misleading line. */
.t-sparkline {
  width: 44px;
  height: 20px;
  flex-shrink: 0;
  cursor: pointer;
}
.t-sparkline polyline {
  fill: none;
  stroke: url(#sparkline-gradient);
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Click-to-expand overlay — populated by openChartGraph() in app.js. */
.chart-graph-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.65);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 50;
  padding: 20px;
}
.chart-graph-overlay.hidden { display: none; }
.chart-graph-panel {
  position: relative;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 20px;
  width: min(440px, 100%);
}
.chart-graph-close {
  position: absolute;
  top: 8px;
  right: 10px;
  background: none;
  border: none;
  color: var(--muted);
  font-size: 22px;
  line-height: 1;
  cursor: pointer;
  padding: 4px;
}
.chart-graph-close:hover { color: var(--text); }
.chart-graph-title {
  padding-right: 28px;
  margin-bottom: 14px;
}
.chart-graph-title span { display: block; }
.chart-graph-title span:first-child { font-weight: 600; font-size: 16px; }
.chart-graph-title span:last-child { color: var(--muted); font-size: 13px; margin-top: 2px; }
.chart-graph-svg {
  width: 100%;
  height: 160px;
  display: block;
}
.chart-graph-svg .chart-graph-area {
  fill: url(#sparkline-gradient);
  opacity: 0.12;
  stroke: none;
}
.chart-graph-svg .chart-graph-line {
  fill: none;
  stroke: url(#sparkline-gradient);
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.chart-graph-svg .chart-graph-point {
  fill: var(--bg);
  stroke: var(--accent);
  stroke-width: 1.6;
}
/* 28 Jul: invisible, larger hover target sitting over each visible dot
   (see renderChartGraphSvg() in app.js) — carries the native <title>
   tooltip that shows the point's date + rank. Transparent rather than
   `fill: none` so the whole disc (not just its outline) actually catches
   the hover/cursor, not only a stroke ring. */
.chart-graph-svg .chart-graph-point-hit {
  fill: rgba(0, 0, 0, 0.001);
  cursor: pointer;
}
/* 28 Jul, Brian: "not getting the popup for date atm" (mobile) — custom
   tooltip shown/positioned by showChartTooltip()/hideChartTooltip() in
   app.js on pointermove (desktop hover) and pointerdown (any device,
   including a tap on a phone). Positioned absolutely against
   .chart-graph-panel (position: relative), floated just above the
   cursor/finger so a thumb doesn't cover its own tooltip. */
.chart-graph-tooltip {
  position: absolute;
  transform: translate(-50%, -100%);
  background: var(--bg);
  border: 1px solid var(--accent);
  border-radius: 8px;
  padding: 4px 9px;
  font-size: 12px;
  white-space: nowrap;
  pointer-events: none;
  z-index: 5;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.chart-graph-tooltip.hidden { display: none; }
.chart-graph-caption {
  margin-top: 10px;
  font-size: 12px;
  color: var(--muted);
  display: flex;
  justify-content: space-between;
}
.chart-graph-empty {
  color: var(--muted);
  font-size: 13px;
  text-align: center;
  padding: 30px 10px;
}
.chart-graph-empty.hidden { display: none; }

.search-actions {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-shrink: 0;
}

.btn-play-search {
  font-size: 12px;
  font-weight: 700;
  padding: 4px 12px;
  border-radius: 999px;
  border: none;
  background: var(--accent-alt); /* logo orange, solid */
  color: #14030b; /* same dark used on the app's other solid-fill buttons */
  cursor: pointer;
  flex-shrink: 0;
}

.btn-play-search:hover { opacity: 0.88; }

.btn-like-search {
  font-size: 12px;
  font-weight: 700;
  padding: 4px 12px;
  border-radius: 999px;
  border: none;
  background: var(--accent); /* logo pink, solid */
  color: #14030b; /* same dark used on the app's other solid-fill buttons */
  cursor: pointer;
  flex-shrink: 0;
}

.btn-like-search:hover { opacity: 0.88; }
.btn-like-search:disabled { opacity: 0.6; cursor: default; }

/* 25 Jul, Brian: "we don't need the play button, as clicking the line
   plays it, but what would be good would be a button to get rid of that
   suggestion." Suggested-tab-only dismiss (×) — muted by default so it
   doesn't compete with the Liked Songs button, reads as a clear "remove"
   action on hover. */
.btn-dismiss-suggested {
  font-size: 16px;
  line-height: 1;
  font-weight: 700;
  width: 24px;
  height: 24px;
  border-radius: 999px;
  border: none;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
  flex-shrink: 0;
}

.btn-dismiss-suggested:hover { background: var(--danger); color: #14030b; }

.track-list li.recent-skip .t-name { color: var(--danger); }

/* 25 Jul, Brian: "a now playing highlight in the list". Solid low-opacity
   tint, not a gradient — .clickable's existing border-radius/negative
   margin already gives it the right bled shape, nothing extra needed here. */
.track-list li.now-playing-row { background: rgba(255, 61, 119, 0.1); }
.track-list li.now-playing-row .t-name { color: var(--accent); }

.btn-rescue {
  font-size: 12px;
  padding: 4px 10px;
  border-radius: 999px;
  border: 1px solid var(--accent);
  background: transparent;
  color: var(--accent);
  cursor: pointer;
  margin-left: 12px;
  flex-shrink: 0;
}

.btn-rescue:hover { background: var(--gradient); color: #14030b; border-color: transparent; }

/* 26 Jul: shown instead of .btn-rescue once a skip's already been rescued
   (see loadSkipped() in app.js) — same footprint, deliberately unstyled as
   a button (no border/cursor) so it doesn't invite another click. */
.rescued-label {
  font-size: 12px;
  padding: 4px 10px;
  border-radius: 999px;
  border: 1px solid var(--border);
  color: var(--muted);
  margin-left: 12px;
  flex-shrink: 0;
}

/* Floating "back to top" button — 25 Jul, Brian: scrolling back up a long
   Top Listened list by hand was painful. Hidden until you've scrolled down
   a bit (see app.js), then fades/slides in bottom-right. `position: fixed`
   stays anchored to the viewport regardless of the body's zoom: 1.25 above —
   zoom scales the whole viewport uniformly, fixed positioning still just
   works. */
/* Deliberately NOT toggled with the global .hidden class (that's
   `display: none !important`, which can't transition) — starts faded out
   and non-interactive by default, app.js adds .is-visible once you've
   scrolled down a bit. */
.btn-scroll-top {
  position: fixed;
  right: 24px;
  bottom: 24px;
  width: 44px;
  height: 44px;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  border-radius: 50%;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
  opacity: 0;
  transform: translateY(12px);
  pointer-events: none;
  transition: opacity 0.2s ease, transform 0.2s ease;
  z-index: 50;
}

.btn-scroll-top.is-visible {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}

.btn-scroll-top:hover svg circle { opacity: 0.9; }

/* ---------- Mobile layout ---------- */
/* 26 Jul, Brian: "test it on my phone... can barely see the play button,
   quite a lot of the interface is a mess." Root causes, in order of impact:
   (1) `body { zoom: 1.25 }` above was tuned for how Brian likes it on
   desktop and just makes an already-tight phone viewport worse — undone
   here, phones get a normal 1:1 scale (14px body text etc. is already a
   perfectly normal mobile size without the boost).
   (2) .now-playing, .topbar-controls, and .np-controls-row were all built
   as fixed-width single rows with no wrap/stacking behaviour for narrow
   screens, so content overlapped instead of reflowing.
   (3) The play/skip/secret-skip buttons intentionally carry zero CSS
   padding of their own (see the border-radius+gradient-seam comments
   further up) — their clickable area IS their SVG's rendered size, which
   is well under a comfortable touch target. Enlarging the SVGs' own
   width/height (not a CSS transform, which would only grow the visual and
   leave the old, smaller area clickable) grows both together, and doesn't
   reintroduce that seam bug since it never touches background/border-radius.
   Breakpoint is deliberately wide (700px) — a real desktop browser window
   is essentially never this narrow, so this only ever engages on phones/
   small tablets. */
@media (max-width: 700px) {
  body { zoom: 1; }

  .topbar {
    flex-direction: column;
    align-items: center;
    row-gap: 12px;
    padding: 14px 16px;
  }

  /* 26 Jul, Brian: "the logo seems to have disappeared on the phone... make
     it so the logo and text logo fill the length of the phone screen, i
     want to make it prominent" — brand becomes its own full-width, centred
     hero row instead of squeezing in next to the device picker.
     26 Jul, Brian (2nd pass): "logo still needs to be sorted" — the first
     pass's edge-to-edge clamp() sizing was too big and threw the rest of
     the topbar out. Picked from two mocked-up options (mark+wordmark side
     by side at a smaller size, vs. mark stacked above the wordmark) —
     Brian chose stacked. clamp() still scales both across different phone
     widths, just against a much smaller ceiling than the first pass. */
  .brand {
    width: 100%;
    flex-direction: column;
    justify-content: center;
    gap: 6px;
  }

  .logo-mark {
    height: clamp(32px, 9vw, 46px);
  }

  .topbar h1 {
    font-size: clamp(22px, 7vw, 30px);
  }

  .topbar-controls {
    width: 100%;
    flex-wrap: wrap;
  }

  /* 28 Jul: device picker moved from its own full-width topbar row into the
     settings-menu dropdown (see index.html) — it's just another row in that
     column now, alongside the two toggles, so it no longer needs to stretch
     to fill a whole topbar row. Select still gets to fill whatever width
     the dropdown column settles on, for an easy-to-hit tap target. */
  .device-picker {
    width: 100%;
  }

  .device-picker select {
    flex: 1;
    min-width: 0;
  }

  /* 26 Jul, Brian: "the album art/show exact numbers should have a cog
     icon to reduce the clutter and when pressed drop down to provide those
     options... top right of the screen" — cog becomes a fixed corner
     button (independent of the topbar's own stacked layout above), and
     the toggle panel becomes its dropdown: hidden/collapsed until
     wireSettingsMenu() (app.js) adds .menu-open on click, positioned right
     under the cog. */
  .btn-settings-menu {
    display: flex;
    position: fixed;
    top: 14px;
    right: 14px;
    z-index: 41;
    width: 40px;
    height: 40px;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    border: 1px solid var(--border);
    background: var(--card);
    color: var(--text);
    cursor: pointer;
    padding: 0;
  }

  .musicae-status-right {
    position: fixed;
    top: 60px;
    right: 14px;
    z-index: 41;
    flex-direction: column;
    align-items: flex-start;
    gap: 10px;
    background: var(--card);
    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 12px 14px;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
    opacity: 0;
    pointer-events: none;
    transform: translateY(-6px);
    transition: opacity 0.15s ease, transform 0.15s ease;
  }

  .musicae-status-right.menu-open {
    opacity: 1;
    pointer-events: auto;
    transform: translateY(0);
  }

  .now-playing {
    flex-direction: column;
    align-items: center;
    text-align: center;
    max-width: 100%;
    padding: 16px;
    gap: 10px;
  }

  .np-art {
    width: 120px;
    height: 120px;
  }

  .np-body {
    width: 100%;
    align-items: center;
  }

  /* Favourite floats absolutely over the title/artist on desktop, where
     there's clear room either side of the text — on a centred single
     column that just sits on top of it instead. Drops into normal flow,
     sized down slightly. */
  .btn-favourite {
    position: static;
    width: 52px;
    height: 52px;
    margin: 0 auto 4px;
  }

  .np-title,
  .np-artist {
    padding-right: 0;
  }

  .np-progress {
    width: 100%;
    max-width: 280px;
  }

  /* 29 Jul: desktop-only per Brian's own ask — phones already have hardware
     volume + OS media session controls. */
  .np-volume-row {
    display: none;
  }

  .np-controls-row {
    justify-content: center;
    flex-wrap: wrap;
  }

  /* Grows both the visual size AND the actual clickable area together —
     these buttons' own box is sized to exactly match their SVG (see the
     border-radius+gradient-seam comments above), so resizing the SVG
     itself resizes the real touch target too. */
  #btn-play-pause svg { width: 58px; height: 42px; }
  #btn-skip svg { width: 52px; height: 38px; }
  #btn-skip-extra svg { width: 56px; height: 38px; }
  .btn-secret-skip { width: 46px; height: 34px; }
  .secret-skip-pill { width: 46px; height: 34px; }

  .musicae-status-row {
    flex-direction: column;
    align-items: flex-start;
    gap: 8px;
  }

  .musicae-status-right {
    flex-wrap: wrap;
    gap: 12px;
  }

  /* Five tabs across a phone width wrapped mid-label — scrolls horizontally
     instead, a standard mobile tab-bar pattern. */
  .tabs {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    flex-wrap: nowrap;
    padding: 0 12px;
  }

  .tab-btn {
    flex-shrink: 0;
    padding: 12px 12px;
  }

  .tab-panel {
    padding: 16px;
  }
}
