/* ---------------------------------------------------------------- tokens */
:root {
  --paper:  #f2efe9;   /* warm paper — a listening room, not a newsroom */
  --card:   #fbf9f6;
  --ink:    #17130f;
  --dim:    #7a6f64;
  --rule:   #ddd5c9;
  --glow:   #b06d00;   /* VU-meter amber — interaction and intensity */
  --clean:  #0e7c86;   /* teal — reserved for lossless/quality signals */

  /* Source families. Each source_type keeps one hue everywhere it appears,
     so a chip in the list and a bar in the sidebar read as the same thing. */
  --radio:     #b8541f;
  --streaming: #0e7c86;
  --podcast:   #6b4bab;
  --vinyl:     #a33055;
  --shazam:    #6e7c86;
  /* Not a source family either: an EP can be streamed or played off the
     shelf, so it cuts across the hues above the way activity does. Blue is
     the one part of the wheel none of them occupy. */
  --ep:        #2f5fa8;
  /* What the listener was doing. Not a source family -- it cuts across all of
     them -- so it gets a hue none of them use, green for the outdoors that
     prompted it. */
  --activity:  #3f7d47;

  --display: 'Bricolage Grotesque', system-ui, sans-serif;
  --body:    'Archivo', system-ui, sans-serif;
  --mono:    'IBM Plex Mono', ui-monospace, monospace;

  --gap: clamp(1rem, 3vw, 2rem);
}

@media (prefers-color-scheme: dark) {
  :root {
    --paper: #120f0c;
    --card:  #1c1814;
    --ink:   #ece5db;
    --dim:   #9a8d7f;
    --rule:  #2f2822;
    --glow:  #f0a828;
    --clean: #4fc7d4;

    --radio:     #e8834a;
    --streaming: #4fc7d4;
    --podcast:   #a58cf0;
    --vinyl:     #ea6d92;
    --shazam:    #93a3ad;
    --ep:        #7aa8ea;
    --activity:  #63b872;
  }
}

* { box-sizing: border-box; }

/* The `hidden` attribute only carries `display: none` from the UA stylesheet,
   which any explicit `display` rule outranks -- so an element toggled with
   `.hidden = true` stays visible if it also has one. Everything here that
   hides itself does, so make the attribute win outright. */
[hidden] { display: none !important; }

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font: 400 clamp(0.95rem, 0.9rem + 0.2vw, 1.0625rem)/1.55 var(--body);
  -webkit-font-smoothing: antialiased;
}

a { color: var(--glow); text-decoration-thickness: 1px; text-underline-offset: 2px; }
:focus-visible { outline: 2px solid var(--glow); outline-offset: 2px; border-radius: 2px; }

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.skip { position: absolute; left: -9999px; top: 0; background: var(--ink); color: var(--paper); padding: .6rem 1rem; z-index: 20; }
.skip:focus { left: 0; }

/* ------------------------------------------------------------- masthead */
.masthead {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 1rem 2rem; flex-wrap: wrap;
  /* Vertical padding is deliberately much tighter than the horizontal: the
     now-playing card is 3.7rem of the header's height on its own, so 1.1rem
     above and below it made the masthead nearly 6rem tall and pushed the
     stats themselves under the fold on a laptop. The card brings its own
     visual padding; the header doesn't need to restate it. */
  padding: .6rem var(--gap);
  border-bottom: 1px solid var(--rule);
  position: sticky; top: 0; z-index: 10;
  background: color-mix(in srgb, var(--paper) 88%, transparent);
  backdrop-filter: blur(8px);
}
.wordmark { display: flex; align-items: center; gap: .6rem; text-decoration: none; color: inherit; }
.wordmark-name { font: 800 1.35rem/1 var(--display); letter-spacing: -.02em; }

/* Four bars at rest — a level meter that isn't animating at you. */
.wordmark-mark {
  width: 1.4rem; height: 1.1rem; flex: none;
  background:
    linear-gradient(var(--glow) 0 0) 0    100% / 3px 40% no-repeat,
    linear-gradient(var(--glow) 0 0) 35%  100% / 3px 90% no-repeat,
    linear-gradient(var(--glow) 0 0) 68%  100% / 3px 65% no-repeat,
    linear-gradient(var(--glow) 0 0) 100% 100% / 3px 25% no-repeat;
}
/* ---------------------------------------------------------- now playing */
.now-playing {
  display: flex; align-items: center; gap: .6rem;
  /* The vertical padding still totals .6rem, exactly what it was before the
     progress rail existed -- it is only distributed unevenly. The rail is
     absolutely positioned along the bottom and would otherwise run into the
     source line, so the bottom keeps enough room for it (2px rail + its 4px
     offset + 2px of clearance) and the top gives up the same amount. The
     content ends up centred in the space above the rail rather than in the
     card, and the card is the height it always was: growing it instead would
     have pushed the masthead around, which is what all the fixed sizing below
     exists to prevent.

     The left inset is a variable because the rail has to share it: the rail
     and the artwork line up on the same edge, and that only stays true if
     there is one number rather than two that have to be kept in step. */
  --np-pad-l: calc(.3rem + 4px);
  --np-rail-space: 8px;
  padding: calc(.6rem - var(--np-rail-space)) .5rem var(--np-rail-space) var(--np-pad-l);
  border: 1px solid var(--rule); border-radius: 10px;
  background: var(--card);
  position: relative; /* anchors .np-expanded below it */

  /* One footprint for every state. The widget sits in the masthead beside the
     wordmark, where anything that changes size shoves the header around -- and
     it has four states that naturally want different sizes: playing with a
     source line, playing without one, stale (no transport), and idle. Sizing
     the two flexible children explicitly means the card is the same box in all
     of them, so the header holds still through a track change, a pause, and
     the end of a session alike.

     --np-controls-w was once exact: three 1.9rem buttons and two .15rem gaps,
     which is 6rem. The flag row above them is now the wider of the two, so the
     column is sized to hold "NOW PLAYING" and its bars instead, and the
     buttons right-align inside it. The slot is reserved even when the buttons
     are withheld, and in every state. */
  --np-body-w: 13rem;
  --np-body-h: 3rem;      /* three text lines -- title, artist, source */
  --np-controls-w: 8.7rem;
}
.np-art {
  width: 2.6rem; height: 2.6rem; border-radius: 6px; object-fit: cover;
  background: var(--paper); flex: none; cursor: pointer;
}

/* The idle placeholder. Shares .np-art's box so the header keeps its exact
   height when playback stops -- that steadiness is the whole point -- but it
   is a div, not an img, and nothing here is interactive. */
.np-art-idle {
  cursor: default;
  display: flex; align-items: center; justify-content: center;
  color: var(--dim); font-size: 1.05rem;
}
.np-idle { color: var(--dim); font-weight: 500; }

.np-expanded {
  /* Match the compact bar's width exactly, by measuring against it rather
     than restating its size. An absolutely positioned box resolves left/right
     against its containing block, which is the bar's *padding* box -- inset by
     the bar's 1px border on each side -- so pulling both edges out by that 1px
     lands this border box flush with the bar's. Anything that changes the
     bar's width from now on carries over here for free. */
  position: absolute; top: calc(100% + .5rem); left: -1px; right: -1px;
  width: auto;

  /* One knob for the contents. The width comes from the bar above (23.6rem
     against the 16rem this panel used to be), but scaling the type by that
     full 1.475 to match set it too large -- at panel width the artwork is
     already carrying the panel, and the text only has to label it. So the
     type is scaled by less than the box was, deliberately. Children are sized
     in em, so they all follow from this one number. */
  --np-zoom: 1.22;
  font-size: calc(1rem * var(--np-zoom));

  padding: 1em;
  display: flex; flex-direction: column; align-items: center; text-align: center; gap: .15em;
  border: 1px solid var(--rule); border-radius: 12px;
  background: var(--card);
  box-shadow: 0 12px 32px rgba(0,0,0,.45);
  z-index: 20;

  /* The artwork is a square at full panel width, so widening the panel made
     it roughly half again as tall -- enough to run past the bottom of a short
     window, where the controls would be unreachable. Cap it against the
     viewport and let it scroll instead.

     The subtrahend is where this panel STARTS, not a round number: 1rem of
     page inset above the masthead, the 3.7rem the now-playing card is fixed
     at, and the .5rem gap in `top` -- 5.2rem before the panel's first pixel.
     The old 5rem ignored all of that, so the cap resolved about 1.2rem taller
     than the space actually below it and the panel ran off the bottom edge
     with the server picker unreachable. 6.5rem leaves a little clearance.
     dvh rather than vh so a mobile browser's collapsing toolbar is measured
     as it actually is, not as it is when scrolled. */
  max-height: calc(100dvh - 6.5rem); overflow-y: auto;
}
.np-expanded-art {
  /* Square, so its width IS the panel's height budget -- at full panel width
     it alone is ~385px and it pushed the transport and the server picker past
     the fold on a laptop-height window. Bounded by viewport height as well as
     panel width so it gives way on a short screen and nothing else has to.
     min() rather than a media query because the thing being reacted to is a
     continuous quantity: on a tall display 34vh exceeds the panel width and
     100% wins, so a large monitor renders exactly as it did before. */
  width: min(100%, 32vh); aspect-ratio: 1; border-radius: 8px; object-fit: cover;
  background: var(--paper); margin-bottom: .6em;
}
.np-expanded-title { font: 700 1.05em/1.3 var(--display); }
.np-expanded-artist { color: var(--dim); }
.np-expanded-album { color: var(--dim); font-size: .82em; }
.np-expanded-source { color: var(--glow); font: 500 .74em/1.6 var(--mono); margin-top: .3em; }
.np-expanded-quality { color: var(--dim); font: 500 .72em/1.6 var(--mono); }
/* Only present when the widget is showing a server you did not choose --
   see fallbackServerLabel() in app.js. */
.np-expanded-server { color: var(--dim); font: 500 .72em/1.6 var(--mono); }
.np-expanded-meta { color: var(--dim); font: 500 .72em/1.6 var(--mono); }
/* Close to the text above it. The transport belongs to the track being
   described, and .7em of margin on top of the column's own gap read as a
   separate section rather than the same one. */
.np-expanded-controls { margin-top: .2em; justify-content: center; }
/* The box is scaled from the panel's factor rather than written in em,
   because an em width resolves against the element's *own* font-size -- and
   this button sets one. 2.3em of a 1.1em glyph would compound the two and
   overshoot. The glyph itself is em, since it has nothing of its own to
   compound against. */
.np-expanded-controls .np-btn {
  width: calc(2.3rem * var(--np-zoom)); height: calc(2.3rem * var(--np-zoom));
  font-size: 1.1em;
}

/* Which Music Assistant the widget believes. Only rendered when more than one
   is configured, so a single-server setup never grows this section.

   Ruled off along the top because it is genuinely a different subject from
   everything above it: those rows describe a track, this one picks the
   machine the track is being read from. */
.np-servers {
  display: flex; flex-direction: column; gap: .3em;
  margin-top: .55em; padding-top: .55em;
  border-top: 1px solid var(--rule);
  /* The panel is a centering column, so a flex item in it does NOT stretch --
     it sizes to its content. Without this the longest track title decided how
     wide this section was, and a Godspeed title took it to twice the panel's
     width and out the side. Stated the same way .np-expanded-art states it,
     since a percentage on a flex item resolves against the panel's content
     box either way. */
  width: 100%; min-width: 0;
}
.np-server-pick {
  display: flex; flex-direction: column; gap: .1em;
  width: 100%; min-width: 0; text-align: left;
  padding: .4em .55em;
  border: 1px solid var(--rule); border-radius: 6px;
  background: none; color: inherit; font: inherit; cursor: pointer;
}
.np-server-pick:hover { border-color: var(--glow); }
/* The selected one carries the amber the rest of the widget uses for "this is
   the live thing", so the answer to "which am I on" is readable at a glance
   without reading either label. */
.np-server-pick[aria-pressed="true"] {
  border-color: var(--glow);
  background: color-mix(in srgb, var(--glow) 10%, transparent);
}
.np-server-pick[aria-pressed="true"] .np-server-pick-name { color: var(--glow); }
.np-server-pick:disabled { opacity: .5; cursor: default; }
.np-server-pick-name {
  font: 600 .72em/1.4 var(--mono); text-transform: uppercase; letter-spacing: .04em;
}
/* The status line is the long one and the panel is narrow, so it truncates
   rather than wrapping a track title onto three lines and moving the buttons
   below it every time the music changes. */
.np-server-pick-state {
  color: var(--dim); font: 500 .7em/1.4 var(--mono);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* A server that did not answer is still worth showing -- and still worth
   being able to select, since "switch to the one that is down" is how you
   find out it came back. */
.np-server-down .np-server-pick-name { text-decoration: line-through; }

@media (max-width: 40rem) {
  .np-expanded { display: none; } /* the compact bar itself is already hidden here (see below) */
}
.np-body {
  min-width: 0; flex: none;
  width: var(--np-body-w); min-height: var(--np-body-h);
  /* Centred rather than top-aligned so a one-line idle message and a
     three-line track sit on the same optical line. */
  display: flex; flex-direction: column; justify-content: center;
}
.np-title, .np-artist, .np-source {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.np-title { font-weight: 600; font-size: .85rem; }
.np-artist { color: var(--dim); font-size: .76rem; }
.np-source { color: var(--glow); font: 500 .68rem/1.5 var(--mono); }
/* The source line carries things of unequal priority: the name, which can be
   long and is fine to cut, and the position and stream quality, which are
   short and must stay whole -- a bitrate ellipsized down to "12…" is worse
   than no bitrate, and a half-cut clock worse still. min-width:0 is what
   actually lets the name shrink: a flex item defaults to min-width:auto, which
   would size it to its full text and shove the other two out of the box. */
.np-source { display: flex; align-items: baseline; gap: .4rem; }
.np-source-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.np-quality, .np-time { flex: none; color: var(--dim); }
/* The clock sits next to the name it belongs with, leaving the bitrate on the
   far edge where it has always been -- so the line reads source, position,
   quality without the number moving as a track's runtime changes width.
   The mono face makes the digits tabular already, so a ticking clock doesn't
   shuffle anything beside it. */
/* Pushed to the far edge only when there's something to sit opposite -- alone
   on the line it reads better where every other line starts. */
.np-quality:not(:first-child) { margin-left: auto; }
/* Which Music Assistant this is coming from, shown only when it is not the
   one you chose -- see the guard in renderNowPlaying(). Reads as an aside
   rather than an alert: it is information about where the music is coming
   from, not a fault. Sits before .np-quality, which holds the right edge. */
.np-server {
  flex: none;
  font-size: .58rem; letter-spacing: .06em; text-transform: uppercase;
  padding: .1rem .3rem; border-radius: 3px;
  color: var(--dim);
  border: 1px solid color-mix(in srgb, var(--dim) 35%, transparent);
}
/* The progress rail, laid along the bottom edge inside the card's own padding
   -- it takes the same --np-pad-l the content does, so it starts on the
   artwork's edge, and stops clear of the rounded corners without the card
   needing overflow:hidden, which would have clipped the expanded panel
   hanging below it. Absolute positioning is the point: it takes part in no
   layout, so a progress bar costs the card no height and the masthead holds
   still, same as every other rule here. */
.np-progress {
  position: absolute; left: var(--np-pad-l); right: .5rem; bottom: 4px;
  height: 2px; border-radius: 999px; background: var(--rule);
}
.np-progress-fill {
  height: 100%; width: 0; border-radius: inherit; background: var(--glow);
}
/* Lives in the transport slot, not the body -- see renderNowPlaying(). Right
   aligned and wrapping, because that slot is 6rem and the marker is the only
   thing in it. */
.np-stale {
    align-self: center; text-align: right; white-space: normal;
    color: var(--dim);
    font: 500 .6rem/1.4 var(--mono);
    letter-spacing: .06em;
    text-transform: uppercase;
}
/* The transport column: visualizer stacked over the buttons. It owns the
   reserved width now, so an idle or stale card still holds the slot open even
   with nothing in it. The stack is deliberately shorter than --np-body-h
   (.7 + .2 + 1.9 = 2.8rem against 3rem), which is what lets the visualizer be
   added without the card -- and so the masthead -- getting any taller. */
.np-transport {
  flex: none; width: var(--np-controls-w);
  display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: .2rem;
}
.np-controls {
  display: flex; gap: .15rem; flex: none;
  width: var(--np-controls-w); justify-content: flex-end;
}

/* The flag: what the player is doing, said once in words and once in motion.
   "NOW PLAYING" is the longer of the two labels and so sets the column width;
   falling back to "PAUSED" therefore cannot shrink the card underneath the
   masthead, which is the same steadiness the fixed sizing above is for. */
/* Full width and right-aligned, like the buttons below it, so the bars sit on
   the same edge in both states. Centred, the shorter "PAUSED" flag pulled the
   meter sideways -- and a meter that moves when you press pause reads as the
   widget re-laying-out rather than the music stopping. */
.np-live {
  display: flex; align-items: center; justify-content: flex-end;
  gap: .4rem; width: var(--np-controls-w);
}
.np-live-label {
  /* Sentence case in the markup, uppercased here -- see renderNowPlayingFlag().
     letter-spacing because small type set in capitals closes up otherwise. */
  text-transform: uppercase;
  font: 600 .56rem/1 var(--mono); letter-spacing: .07em;
  padding: .3rem .45rem; border-radius: 999px; white-space: nowrap;
  color: var(--glow);
  background: color-mix(in srgb, var(--glow) 14%, transparent);
}
/* Paused reads in the dim grey the rest of the card uses for what is not
   happening, so the accent colour keeps meaning "live". */
.np-live-still .np-live-label {
  color: var(--dim);
  background: color-mix(in srgb, var(--dim) 13%, transparent);
}

/* Decorative only -- see renderEqualizer() in app.js. The bars are
   choreographed, not measured: the audio is playing on a speaker elsewhere on
   the network and this page never sees the signal. Animated with transform
   alone so it runs on the compositor and can never trigger layout in the
   masthead it sits in. */
.np-eq {
  display: flex; align-items: flex-end; gap: 3px;
  /* .82 + .2 gap + 1.9 buttons = 2.92rem, still inside --np-body-h's 3rem,
     so a taller visualizer still costs the card no height. */
  height: .82rem;
}
.np-eq-bar {
  width: 3px; height: 100%; border-radius: 1px;
  background: var(--glow);
  transform-origin: bottom;
  --amp: 1;
  /* Name/duration/delay all come from the per-bar rules below. Easing is
     shared: ease-out lets the attack land hard and the decay trail. */
  animation: 1.8s ease-out infinite;
}
/* Four envelopes, not one. A single shared keyframe was the whole problem:
   identical envelopes at staggered phases *is* a travelling wave, so the eye
   assembled a crest moving left to right instead of eight independent bands.
   These differ in shape, not just in timing, and adjacent bars never share one.

   Each is attack-fast, decay-slow -- the peak lands in the first fifth of the
   cycle and falls away over the rest. That asymmetry is what a spectrum
   actually does: a transient hits the band and bleeds off. The old symmetric
   ease-in-out rose exactly as gently as it fell, which reads as breathing.

   Peaks are scaled by --amp so the left bars travel further than the right
   ones, the way bass carries more energy than treble. */
/* The bass envelope: four hits on an even grid, 0/25/50/75% of a 1.92s cycle,
   which lands around 125bpm. Nothing between the beats but decay.

   An earlier version put a small off-grid fill in the last fifth, meaning to
   keep it from sounding metronomic. It didn't read that way at all -- three
   extra stops landing right behind beat four came across as "1, 2, 3, 4...
   5-6-7", a stutter rather than a groove. The variation now comes from beat
   four being lighter (.78 against 1) instead of from extra hits: same "not
   quite a metronome" intent, carried by dynamics rather than by subdivision.

   Bars 1-3 all run this at the same duration, deliberately breaking the
   never-share-an-envelope rule that governs the rest of the bank. Sharing a
   shape only produced the travelling-wave illusion because those bars were
   staggered *out* of phase; in phase, the same shape is what makes three bars
   land on a beat together. The offsets below are a few hundredths of a second
   -- enough to flam the attack so it isn't rigid, far too small to march. */
@keyframes np-eq-kick {
  0%   { transform: scaleY(calc(var(--amp) * 1)); }
  6%   { transform: scaleY(calc(var(--amp) * .5)); }
  14%  { transform: scaleY(calc(var(--amp) * .36)); }
  25%  { transform: scaleY(calc(var(--amp) * .95)); }
  31%  { transform: scaleY(calc(var(--amp) * .46)); }
  40%  { transform: scaleY(calc(var(--amp) * .34)); }
  50%  { transform: scaleY(calc(var(--amp) * 1)); }
  56%  { transform: scaleY(calc(var(--amp) * .48)); }
  66%  { transform: scaleY(calc(var(--amp) * .37)); }
  75%  { transform: scaleY(calc(var(--amp) * .78)); }
  82%  { transform: scaleY(calc(var(--amp) * .44)); }
  92%  { transform: scaleY(calc(var(--amp) * .35)); }
  100% { transform: scaleY(calc(var(--amp) * 1)); }
}
/* The free-running envelopes. -a and -b drive the middle pair; -c and -d are
   unreferenced at the moment, held rather than deleted because this repo has
   no version control and widening the loose section back out is one line per
   bar (swap animation-name, give it its own duration and delay). */
@keyframes np-eq-a {
  0%   { transform: scaleY(calc(var(--amp) * .34)); }
  9%   { transform: scaleY(calc(var(--amp) * 1)); }
  34%  { transform: scaleY(calc(var(--amp) * .48)); }
  48%  { transform: scaleY(calc(var(--amp) * .8)); }
  76%  { transform: scaleY(calc(var(--amp) * .42)); }
  100% { transform: scaleY(calc(var(--amp) * .34)); }
}
@keyframes np-eq-b {
  0%   { transform: scaleY(calc(var(--amp) * .4)); }
  16%  { transform: scaleY(calc(var(--amp) * .9)); }
  27%  { transform: scaleY(calc(var(--amp) * .58)); }
  55%  { transform: scaleY(calc(var(--amp) * 1)); }
  72%  { transform: scaleY(calc(var(--amp) * .5)); }
  100% { transform: scaleY(calc(var(--amp) * .4)); }
}
@keyframes np-eq-c {
  0%   { transform: scaleY(calc(var(--amp) * .38)); }
  6%   { transform: scaleY(calc(var(--amp) * .72)); }
  22%  { transform: scaleY(calc(var(--amp) * .46)); }
  41%  { transform: scaleY(calc(var(--amp) * .88)); }
  63%  { transform: scaleY(calc(var(--amp) * .5)); }
  86%  { transform: scaleY(calc(var(--amp) * .64)); }
  100% { transform: scaleY(calc(var(--amp) * .38)); }
}
@keyframes np-eq-d {
  0%   { transform: scaleY(calc(var(--amp) * .44)); }
  12%  { transform: scaleY(calc(var(--amp) * .96)); }
  30%  { transform: scaleY(calc(var(--amp) * .56)); }
  44%  { transform: scaleY(calc(var(--amp) * .74)); }
  67%  { transform: scaleY(calc(var(--amp) * .44)); }
  100% { transform: scaleY(calc(var(--amp) * .44)); }
}
/* Two beat-locked trios with a loose middle.

   Bars 1-3 lead the beat; bars 6-8 are that trio mirrored (8 answers 1, 7
   answers 2, 6 answers 3) and pushed back .18s, a little over a third of a
   beat, so the right hand lands as an answer rather than an echo. Both trios
   share the one 1.92s period -- locked to the same beat, phase the only thing
   separating them, since any difference in duration would pull them apart
   within a few bars.

   Bars 4-5 answer on the off-beat. They run the beat's 1.92s period so the
   relationship is fixed, but keep their own envelopes (-a and -d, not the
   kick) so the centre never becomes a third copy of the same shape -- eight
   bars on one envelope was too uniform to read as a spectrum however the
   phases were arranged.

   They run at half the beat's period, not the whole of it. Each envelope
   fires one dominant peak per cycle, so at 1.92s the centre answered once
   every four beats -- correct placement, half the pulse of everything beside
   it. .96s divides 1.92s evenly, so the lock holds and the answer comes twice
   as often, big peak alternating with each envelope's smaller mid-cycle bump.

   The delays put those peaks in the hole. The trios don't hit together -- the
   right one leads the left by .18s -- so their hits occupy t=.24s to .48s of
   each beat and the quiet stretches are centred on t=.12s, .60s, 1.08s and
   1.56s. -.45s and -.51s land each envelope's own peak (9% and 12% of its
   cycle) on those centres. Negative on purpose: phase repeats every cycle, so
   the sign costs nothing, and it keeps every bar mid-animation from the first
   frame rather than holding still through an initial delay.

   --amp is a mirrored curve (1 .93 .84 .88 / .88 .84 .93 1) -- symmetric,
   since a reflection is what the two trios are. The middle pair sits at .88
   rather than at the bottom of the scoop: they are meant to spike into the
   gap, and a bar scaled to .76 could not visibly out-reach its neighbours no
   matter when it fired. So the smile now dips at bars 3 and 6 instead of dead
   centre. */
.np-eq-bar:nth-child(1) { --amp: 1;    animation-name: np-eq-kick; animation-duration: 1.92s; animation-delay: 0s; }
.np-eq-bar:nth-child(2) { --amp: .93;  animation-name: np-eq-kick; animation-duration: 1.92s; animation-delay: -.03s; }
.np-eq-bar:nth-child(3) { --amp: .84;  animation-name: np-eq-kick; animation-duration: 1.92s; animation-delay: -.06s; }
.np-eq-bar:nth-child(4) { --amp: .88;  animation-name: np-eq-a; animation-duration: .96s; animation-delay: -.45s; }
.np-eq-bar:nth-child(5) { --amp: .88;  animation-name: np-eq-d; animation-duration: .96s; animation-delay: -.51s; }
.np-eq-bar:nth-child(6) { --amp: .84;   animation-name: np-eq-kick; animation-duration: 1.92s; animation-delay: -.24s; }
.np-eq-bar:nth-child(7) { --amp: .93;  animation-name: np-eq-kick; animation-duration: 1.92s; animation-delay: -.21s; }
.np-eq-bar:nth-child(8) { --amp: 1;  animation-name: np-eq-kick; animation-duration: 1.92s; animation-delay: -.18s; }
/* Paused: hold where they are and dim, rather than dropping flat. Frozen
   mid-motion is what actually happened -- the song stopped part-way. */
.np-eq-still .np-eq-bar { animation-play-state: paused; opacity: .4; }

@media (prefers-reduced-motion: reduce) {
  /* Left visible but still -- it is part of the card's layout, and removing it
     here would make the widget a different height for these users.

     :nth-child(n) matches every bar and exists purely to carry weight. The
     per-bar rules above are (0,2,0) because of their :nth-child, a plain
     .np-eq-bar is (0,1,0), and a media query contributes no specificity at
     all -- so the bare selector lost to them no matter that it comes later,
     the animation-name survived, and the running animation went on to
     override this transform too. Reduced motion was being ignored here. */
  .np-eq-bar:nth-child(n) {
    animation: none;
    transform: scaleY(calc(var(--amp) * .58));
  }
}
/* The expanded panel is its own box and sets its own width, so it must not
   take the compact bar's reserved slot -- and since this rule now follows
   .np-controls, it has to restate the centring it declares above, which
   equal specificity would otherwise let .np-controls win. */
.np-expanded-controls { width: auto; justify-content: center; }
.np-btn {
  width: 1.9rem; height: 1.9rem; border-radius: 6px;
  background: none; border: 1px solid transparent; color: var(--ink);
  font-size: .95rem; line-height: 1; cursor: pointer;
}
.np-btn:hover { border-color: var(--rule); background: var(--paper); }
.np-btn:disabled { opacity: .4; cursor: default; }
.np-btn:disabled:hover { border-color: transparent; background: none; }

@media (max-width: 40rem) {
  .now-playing { display: none; } /* no room for it next to the wordmark on a phone */
}

/* -------------------------------------------------------------- summary */
.summary {
  display: grid; gap: 1px;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  background: var(--rule);
  border-bottom: 1px solid var(--rule);
}
.stat { background: var(--paper); padding: .9rem var(--gap); }
.stat-value { display: block; font: 800 clamp(1.4rem, 1.1rem + 1.2vw, 2rem)/1 var(--display); letter-spacing: -.02em; }
.stat-label { display: block; margin-top: .3rem; color: var(--dim); font: 500 .7rem/1.3 var(--mono); text-transform: uppercase; letter-spacing: .06em; }
.stat-value.is-clean { color: var(--clean); }
.stat-value.is-tinted { color: var(--tint); }

/* -------------------------------------------------------------- filters */
/* Sixteen selects is a lot of furniture for controls that are mostly not in
   use -- on a phone they were most of the first screen. They live behind a
   disclosure now; what is actually *applied* stays out here as chips, so the
   panel being shut never hides the fact that the list is narrowed. */
.filters {
  display: flex; flex-direction: column; gap: .6rem;
  padding: .9rem var(--gap);
  border-bottom: 1px solid var(--rule);
}
.filters-bar { display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; }
.filters-search { flex: 1 1 16rem; }
.filters-search input {
  width: 100%; padding: .55rem .8rem;
  background: var(--card); color: inherit;
  border: 1px solid var(--rule); border-radius: 999px;
  font: 400 .95rem var(--body);
}

.filters-toggle {
  display: inline-flex; align-items: center; gap: .4rem;
  padding: .5rem .8rem; border: 1px solid var(--rule); border-radius: 8px;
  background: var(--card); color: inherit; font: 500 .8rem var(--mono); cursor: pointer;
}
.filters-toggle:hover { border-color: var(--glow); color: var(--glow); }
.filters-toggle.is-active { border-color: var(--glow); color: var(--glow); }
.filters-count {
  min-width: 1.15rem; padding: 0 .25rem; border-radius: 999px;
  background: var(--glow); color: var(--paper);
  font: 600 .68rem/1.15rem var(--mono); text-align: center;
}
.filters-caret { font-size: .7em; transition: transform .12s; }
.filters-toggle.is-open .filters-caret { transform: rotate(180deg); }

.filters-active { display: flex; flex-wrap: wrap; gap: .4rem; }
.active-filter {
  display: inline-flex; align-items: center;
  border: 1px solid color-mix(in srgb, var(--glow) 40%, transparent);
  background: color-mix(in srgb, var(--glow) 10%, transparent);
  color: var(--glow); border-radius: 999px;
  font: 500 .72rem/1.6 var(--mono);
}
.active-filter-label { padding: .1rem .2rem .1rem .6rem; }
.active-filter-remove {
  padding: .1rem .5rem .1rem .3rem; border: 0; background: none;
  color: inherit; font: inherit; cursor: pointer; line-height: 1.6;
}
.active-filter-remove:hover { color: var(--ink); }

/* The panel itself: a grid rather than a wrapping row, so the controls line up
   in columns instead of ragging by label length. */
.filters-facets {
  display: grid; gap: .5rem;
  grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
  padding: .8rem; border: 1px solid var(--rule); border-radius: 10px;
  background: var(--card);
}
.filters select {
  width: 100%; min-width: 0; padding: .5rem .65rem;
  background: var(--paper); color: inherit;
  border: 1px solid var(--rule); border-radius: 8px;
  font: 500 .82rem var(--mono);
}
.filters select.is-active { border-color: var(--glow); color: var(--glow); }
.clear {
  padding: .5rem .8rem; border: 1px solid var(--rule); border-radius: 8px;
  background: none; color: var(--dim); font: 500 .8rem var(--mono); cursor: pointer;
}
.clear:hover { color: var(--glow); border-color: var(--glow); }

/* ----------------------------------------------------------------- main */
#main {
  display: grid; gap: var(--gap);
  grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
  padding: var(--gap);
  align-items: start;
}
@media (max-width: 60rem) { #main { grid-template-columns: 1fr; } }

/* ---------------------------------------------------------------- rows */
.listens { list-style: none; margin: 0; padding: 0; }
.listen {
  display: grid;
  /* art, body, timestamp, heart, delete */
  grid-template-columns: 3.4rem minmax(0, 1fr) auto auto auto;
  gap: .9rem; align-items: center;
  padding: .7rem 0;
  border-bottom: 1px solid var(--rule);
}
.listen-delete {
  width: 1.6rem; height: 1.6rem; border-radius: 6px;
  background: none; border: 1px solid transparent; color: var(--dim);
  font: 400 1.1rem/1 var(--mono); cursor: pointer;
  opacity: 0; transition: opacity .1s;
}
.listen:hover .listen-delete { opacity: .6; }
.listen-delete:hover { opacity: 1; border-color: var(--rule); color: #e8604c; background: var(--card); }
.listen-art {
  width: 3.4rem; height: 3.4rem; border-radius: 6px; object-fit: cover;
  background: var(--card); border: 1px solid var(--rule);
}
.listen-body { min-width: 0; }
.listen-title { font-weight: 600; }
.listen-artist { color: var(--dim); }
/* Long titles truncate rather than reflowing the row height. */
.listen-title, .listen-artist { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.listen-when { text-align: right; color: var(--dim); font: 500 .78rem/1.4 var(--mono); white-space: nowrap; }
.listen-when time { display: block; color: var(--ink); font-weight: 600; }

.chips { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .3rem; }
.chip {
  padding: .12rem .45rem; border-radius: 4px;
  font: 500 .68rem/1.5 var(--mono);
  border: 1px solid color-mix(in srgb, var(--chip, var(--dim)) 40%, transparent);
  color: var(--chip, var(--dim));
  background: color-mix(in srgb, var(--chip, var(--dim)) 8%, transparent);
}
.chip.is-quiet { --chip: var(--dim); }

/* One chip, several controls. Padding moves onto the parts so each has a real
   hit area, and the separator stays inert between them. */
.chip-split { padding: 0; display: inline-flex; align-items: center; }
.chip-part {
  padding: .12rem .45rem;
  background: none; border: 0; color: inherit; cursor: pointer;
  font: inherit; border-radius: 4px;
}
.chip-part:hover { background: color-mix(in srgb, var(--chip, var(--dim)) 22%, transparent); }
.chip-sep { opacity: .45; pointer-events: none; }
.chip.is-clean { --chip: var(--clean); }
.chip button, .chip.is-action { cursor: pointer; }
button.chip { cursor: pointer; }

.empty { color: var(--dim); padding: 2rem 0; text-align: center; }
.more {
  display: block; width: 100%; margin-top: 1rem; padding: .7rem;
  background: var(--card); color: inherit;
  border: 1px solid var(--rule); border-radius: 8px;
  font: 600 .85rem var(--mono); cursor: pointer;
}
.more:hover { border-color: var(--glow); color: var(--glow); }

/* -------------------------------------------------------------- sidebar */
.side { display: grid; gap: var(--gap); }
.card {
  background: var(--card); border: 1px solid var(--rule);
  border-radius: 10px; padding: 1rem;
}
.card h2 { margin: 0 0 .8rem; font: 600 .72rem/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--dim); }

.bars { list-style: none; margin: 0; padding: 0; display: grid; gap: .4rem; }
.bar { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem; align-items: center; }
.bar-label {
  position: relative; min-width: 0; padding: .2rem .4rem; border-radius: 4px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: .85rem; background: none; border: 0; color: inherit;
  text-align: left; cursor: pointer; font-family: inherit;
}
/* The fill sits behind the label rather than beside it, so a long artist
   name never gets squeezed into a sliver by its own bar. */
.bar-label::before {
  content: ''; position: absolute; inset: 0 auto 0 0;
  width: var(--pct, 0%);
  background: color-mix(in srgb, var(--tint, var(--glow)) 22%, transparent);
  border-radius: 4px;
}
.bar-label span { position: relative; }
.bar-label:hover { color: var(--glow); }
.bar-count { color: var(--dim); font: 500 .75rem var(--mono); }

/* Hour histogram: 24 columns, tallest = busiest hour. */
.hours { display: grid; grid-template-columns: repeat(24, 1fr); gap: 2px; align-items: end; height: 4.5rem; }
.hour { background: color-mix(in srgb, var(--glow) 30%, transparent); border-radius: 2px 2px 0 0; min-height: 2px; }
.hour.is-peak { background: var(--glow); }
.hours-axis { display: flex; justify-content: space-between; margin-top: .35rem; color: var(--dim); font: 500 .65rem var(--mono); }

/* --------------------------------------------------------------- detail */
/* One entity's own page: artist, album or song. Shares the list view's
   vocabulary throughout -- the same .stat tiles, .card sidebar, .bars and
   .listen rows -- so arriving here reads as the same site asking a narrower
   question, not a second one bolted on. */
.detail { padding: var(--gap); }

.detail-bar { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: 1.2rem; }
.detail-back, .detail-filter {
  padding: .35rem .7rem; border-radius: 6px; cursor: pointer;
  background: none; border: 1px solid var(--rule); color: var(--dim);
  font: 500 .75rem var(--mono);
}
.detail-back:hover, .detail-filter:hover { color: var(--glow); border-color: var(--glow); }
.detail-filter { margin-left: auto; }

.detail-head { display: flex; align-items: center; gap: clamp(.9rem, 2vw, 1.4rem); }
.detail-art {
  width: clamp(4.5rem, 12vw, 7.5rem); height: clamp(4.5rem, 12vw, 7.5rem);
  flex: none; border-radius: 10px; object-fit: cover;
  background: var(--card); border: 1px solid var(--rule);
}
.detail-id { min-width: 0; }
.detail-kind { color: var(--dim); font: 600 .68rem/1 var(--mono); text-transform: uppercase; letter-spacing: .1em; }
.detail-name {
  margin: .3rem 0 0;
  font: 800 clamp(1.5rem, 1.1rem + 2.2vw, 2.6rem)/1.1 var(--display);
  letter-spacing: -.02em; overflow-wrap: anywhere;
}
.detail-sub { margin: .35rem 0 0; color: var(--dim); font-size: .9rem; }
.detail-span { margin: 1rem 0 1.2rem; color: var(--dim); font: 500 .78rem var(--mono); }

/* The tiles keep the list view's look but not its full-bleed edges -- here
   they sit inside the page's own padding rather than spanning it.
   Flex rather than the list view's auto-fit grid because this row has an odd
   number of tiles: a grid leaves the last one short of the edge with a bare
   strip of container beside it, which reads as an empty sixth tile. Flex items
   grow into whatever the row has left, so the block always squares off. */
.detail-stats {
  display: flex; flex-wrap: wrap;
  border: 1px solid var(--rule); border-radius: 10px; overflow: hidden;
}
.detail-stats .stat { flex: 1 1 9rem; background: var(--card); }
/* How the plays divide by medium, under the total they divide. Stacked and
   larger than a footnote, because with the per-medium tiles gone this is the
   only place the split appears -- and each line carries its own source-family
   hue, the same one that medium wears on every chip and bar elsewhere. */
.medium-split {
  display: flex; flex-direction: column; margin-top: .45rem;
  font: 600 .78rem/1.55 var(--mono);
}
.medium-split > span { color: var(--tint, var(--dim)); white-space: nowrap; }

.stat-value.is-clean { color: var(--clean); }
.stat-value.is-tinted { color: var(--tint); }

/* -------------------------------------------------------------- filters */
/* Sixteen selects is a lot of furniture for controls that are mostly not in
   use -- on a phone they were most of the first screen. They live behind a
   disclosure now; what is actually *applied* stays out here as chips, so the
   panel being shut never hides the fact that the list is narrowed. */
.filters {
  display: flex; flex-direction: column; gap: .6rem;
  padding: .9rem var(--gap);
  border-bottom: 1px solid var(--rule);
}
.filters-bar { display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; }
.filters-search { flex: 1 1 16rem; }
.filters-search input {
  width: 100%; padding: .55rem .8rem;
  background: var(--card); color: inherit;
  border: 1px solid var(--rule); border-radius: 999px;
  font: 400 .95rem var(--body);
}

.filters-toggle {
  display: inline-flex; align-items: center; gap: .4rem;
  padding: .5rem .8rem; border: 1px solid var(--rule); border-radius: 8px;
  background: var(--card); color: inherit; font: 500 .8rem var(--mono); cursor: pointer;
}
.filters-toggle:hover { border-color: var(--glow); color: var(--glow); }
.filters-toggle.is-active { border-color: var(--glow); color: var(--glow); }
.filters-count {
  min-width: 1.15rem; padding: 0 .25rem; border-radius: 999px;
  background: var(--glow); color: var(--paper);
  font: 600 .68rem/1.15rem var(--mono); text-align: center;
}
.filters-caret { font-size: .7em; transition: transform .12s; }
.filters-toggle.is-open .filters-caret { transform: rotate(180deg); }

.filters-active { display: flex; flex-wrap: wrap; gap: .4rem; }
.active-filter {
  display: inline-flex; align-items: center;
  border: 1px solid color-mix(in srgb, var(--glow) 40%, transparent);
  background: color-mix(in srgb, var(--glow) 10%, transparent);
  color: var(--glow); border-radius: 999px;
  font: 500 .72rem/1.6 var(--mono);
}
.active-filter-label { padding: .1rem .2rem .1rem .6rem; }
.active-filter-remove {
  padding: .1rem .5rem .1rem .3rem; border: 0; background: none;
  color: inherit; font: inherit; cursor: pointer; line-height: 1.6;
}
.active-filter-remove:hover { color: var(--ink); }

/* The panel itself: a grid rather than a wrapping row, so the controls line up
   in columns instead of ragging by label length. */
.filters-facets {
  display: grid; gap: .5rem;
  grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
  padding: .8rem; border: 1px solid var(--rule); border-radius: 10px;
  background: var(--card);
}
.filters select {
  width: 100%; min-width: 0; padding: .5rem .65rem;
  background: var(--paper); color: inherit;
  border: 1px solid var(--rule); border-radius: 8px;
  font: 500 .82rem var(--mono);
}
.filters select.is-active { border-color: var(--glow); color: var(--glow); }
.clear {
  padding: .5rem .8rem; border: 1px solid var(--rule); border-radius: 8px;
  background: none; color: var(--dim); font: 500 .8rem var(--mono); cursor: pointer;
}
.clear:hover { color: var(--glow); border-color: var(--glow); }

/* ----------------------------------------------------------------- main */
#main {
  display: grid; gap: var(--gap);
  grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
  padding: var(--gap);
  align-items: start;
}
@media (max-width: 60rem) { #main { grid-template-columns: 1fr; } }

/* ---------------------------------------------------------------- rows */
.listens { list-style: none; margin: 0; padding: 0; }
.listen {
  display: grid;
  /* art, body, timestamp, heart, delete */
  grid-template-columns: 3.4rem minmax(0, 1fr) auto auto auto;
  gap: .9rem; align-items: center;
  padding: .7rem 0;
  border-bottom: 1px solid var(--rule);
}
.listen-delete {
  width: 1.6rem; height: 1.6rem; border-radius: 6px;
  background: none; border: 1px solid transparent; color: var(--dim);
  font: 400 1.1rem/1 var(--mono); cursor: pointer;
  opacity: 0; transition: opacity .1s;
}
.listen:hover .listen-delete { opacity: .6; }
.listen-delete:hover { opacity: 1; border-color: var(--rule); color: #e8604c; background: var(--card); }
.listen-art {
  width: 3.4rem; height: 3.4rem; border-radius: 6px; object-fit: cover;
  background: var(--card); border: 1px solid var(--rule);
}
.listen-body { min-width: 0; }
.listen-title { font-weight: 600; }
.listen-artist { color: var(--dim); }
/* Long titles truncate rather than reflowing the row height. */
.listen-title, .listen-artist { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.listen-when { text-align: right; color: var(--dim); font: 500 .78rem/1.4 var(--mono); white-space: nowrap; }
.listen-when time { display: block; color: var(--ink); font-weight: 600; }

.chips { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .3rem; }
.chip {
  padding: .12rem .45rem; border-radius: 4px;
  font: 500 .68rem/1.5 var(--mono);
  border: 1px solid color-mix(in srgb, var(--chip, var(--dim)) 40%, transparent);
  color: var(--chip, var(--dim));
  background: color-mix(in srgb, var(--chip, var(--dim)) 8%, transparent);
}
.chip.is-quiet { --chip: var(--dim); }

/* One chip, several controls. Padding moves onto the parts so each has a real
   hit area, and the separator stays inert between them. */
.chip-split { padding: 0; display: inline-flex; align-items: center; }
.chip-part {
  padding: .12rem .45rem;
  background: none; border: 0; color: inherit; cursor: pointer;
  font: inherit; border-radius: 4px;
}
.chip-part:hover { background: color-mix(in srgb, var(--chip, var(--dim)) 22%, transparent); }
.chip-sep { opacity: .45; pointer-events: none; }
/* Faint, not hidden. The row's delete button reveals itself on hover, which
   is fine for a mouse and useless on the phone where a wrong location is
   actually noticed -- so this one is always on screen, just quiet enough not
   to compete with the label it belongs to. */
.chip-drop { opacity: .5; padding-left: .3rem; padding-right: .38rem; }
.chip-drop:hover, .chip-drop:focus-visible {
  opacity: 1; color: #e8604c;
  background: color-mix(in srgb, #e8604c 16%, transparent);
}
.chip-drop:disabled { opacity: .25; cursor: default; }
.chip.is-clean { --chip: var(--clean); }
.chip button, .chip.is-action { cursor: pointer; }
button.chip { cursor: pointer; }

.empty { color: var(--dim); padding: 2rem 0; text-align: center; }
.more {
  display: block; width: 100%; margin-top: 1rem; padding: .7rem;
  background: var(--card); color: inherit;
  border: 1px solid var(--rule); border-radius: 8px;
  font: 600 .85rem var(--mono); cursor: pointer;
}
.more:hover { border-color: var(--glow); color: var(--glow); }

/* -------------------------------------------------------------- sidebar */
.side { display: grid; gap: var(--gap); }
.card {
  background: var(--card); border: 1px solid var(--rule);
  border-radius: 10px; padding: 1rem;
}
.card h2 { margin: 0 0 .8rem; font: 600 .72rem/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--dim); }

.bars { list-style: none; margin: 0; padding: 0; display: grid; gap: .4rem; }
.bar { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem; align-items: center; }
.bar-label {
  position: relative; min-width: 0; padding: .2rem .4rem; border-radius: 4px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: .85rem; background: none; border: 0; color: inherit;
  text-align: left; cursor: pointer; font-family: inherit;
}
/* The fill sits behind the label rather than beside it, so a long artist
   name never gets squeezed into a sliver by its own bar. */
.bar-label::before {
  content: ''; position: absolute; inset: 0 auto 0 0;
  width: var(--pct, 0%);
  background: color-mix(in srgb, var(--tint, var(--glow)) 22%, transparent);
  border-radius: 4px;
}
.bar-label span { position: relative; }
.bar-label:hover { color: var(--glow); }
.bar-count { color: var(--dim); font: 500 .75rem var(--mono); }

/* Hour histogram: 24 columns, tallest = busiest hour. */
.hours { display: grid; grid-template-columns: repeat(24, 1fr); gap: 2px; align-items: end; height: 4.5rem; }
.hour { background: color-mix(in srgb, var(--glow) 30%, transparent); border-radius: 2px 2px 0 0; min-height: 2px; }
.hour.is-peak { background: var(--glow); }
.hours-axis { display: flex; justify-content: space-between; margin-top: .35rem; color: var(--dim); font: 500 .65rem var(--mono); }

/* --------------------------------------------------------------- detail */
/* One entity's own page: artist, album or song. Shares the list view's
   vocabulary throughout -- the same .stat tiles, .card sidebar, .bars and
   .listen rows -- so arriving here reads as the same site asking a narrower
   question, not a second one bolted on. */
.detail { padding: var(--gap); }

.detail-bar { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: 1.2rem; }
.detail-back, .detail-filter {
  padding: .35rem .7rem; border-radius: 6px; cursor: pointer;
  background: none; border: 1px solid var(--rule); color: var(--dim);
  font: 500 .75rem var(--mono);
}
.detail-back:hover, .detail-filter:hover { color: var(--glow); border-color: var(--glow); }
.detail-filter { margin-left: auto; }

.detail-head { display: flex; align-items: center; gap: clamp(.9rem, 2vw, 1.4rem); }
.detail-art {
  width: clamp(4.5rem, 12vw, 7.5rem); height: clamp(4.5rem, 12vw, 7.5rem);
  flex: none; border-radius: 10px; object-fit: cover;
  background: var(--card); border: 1px solid var(--rule);
}
.detail-id { min-width: 0; }
.detail-kind { color: var(--dim); font: 600 .68rem/1 var(--mono); text-transform: uppercase; letter-spacing: .1em; }
.detail-name {
  margin: .3rem 0 0;
  font: 800 clamp(1.5rem, 1.1rem + 2.2vw, 2.6rem)/1.1 var(--display);
  letter-spacing: -.02em; overflow-wrap: anywhere;
}
.detail-sub { margin: .35rem 0 0; color: var(--dim); font-size: .9rem; }
.detail-span { margin: 1rem 0 1.2rem; color: var(--dim); font: 500 .78rem var(--mono); }

/* The tiles keep the list view's look but not its full-bleed edges -- here
   they sit inside the page's own padding rather than spanning it.
   Flex rather than the list view's auto-fit grid because this row has an odd
   number of tiles: a grid leaves the last one short of the edge with a bare
   strip of container beside it, which reads as an empty sixth tile. Flex items
   grow into whatever the row has left, so the block always squares off. */
.detail-stats {
  display: flex; flex-wrap: wrap;
  border: 1px solid var(--rule); border-radius: 10px; overflow: hidden;
}
.detail-stats .stat { flex: 1 1 9rem; background: var(--card); }
/* The quiet sub-line under a detail tile -- "1 album stream" beneath the
   album-play count. Distinct from .medium-split above, which is a headline. */
.stat-note { display: block; margin-top: .25rem; color: var(--dim); font: 500 .68rem/1.4 var(--mono); }

.detail-grid {
  display: grid; gap: var(--gap); margin-top: var(--gap);
  grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
  align-items: start;
}
@media (max-width: 60rem) { .detail-grid { grid-template-columns: 1fr; } }
.detail-heading { margin: 0 0 .4rem; font: 600 .72rem/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--dim); }

/* Artist, song and album names in a row -- plain text until you hover, so a
   list of a hundred plays doesn't read as a wall of links. */
.collection-note { display: grid; gap: .2rem; }
.collection-line { margin: 0; font: 600 .82rem/1.4 var(--body); }
.collection-line.is-owned { color: var(--clean); }
.collection-sub { margin: 0; color: var(--dim); font: 500 .7rem/1.5 var(--mono); }
.collection-link {
  margin-top: .35rem; font: 500 .72rem var(--mono);
  color: var(--glow); text-decoration: none;
}
.collection-link:hover { text-decoration: underline; }
.collection-add {
  margin-top: .5rem; align-self: start;
  font: 600 .72rem var(--mono); color: var(--ink);
  background: var(--paper); border: 1px solid var(--rule); border-radius: 999px;
  padding: .3rem .7rem; cursor: pointer;
}
.collection-add:hover { border-color: var(--glow); color: var(--glow); }
.collection-add:disabled { opacity: .5; cursor: default; }

/* The "Not an EP" correction, under the completion bar on an album page.
   Borrows the collection card's button and sub-line rather than inventing a
   second look for the same gesture -- both are the site offering to change a
   fact about a record, and they should read as one affordance, not two. */
.release-fix {
  display: grid;
  gap: .45rem;
  justify-items: start;
  margin-top: .85rem;
  padding-top: .75rem;
  border-top: 1px solid var(--rule);
}
.token-paste { display: grid; gap: .35rem; margin-top: .6rem; }
.token-field {
  width: 100%; padding: .35rem .5rem;
  font: 500 .72rem var(--mono); color: var(--ink);
  background: var(--paper); border: 1px solid var(--rule); border-radius: 6px;
}
.token-field:focus { outline: 2px solid var(--glow); outline-offset: 1px; }

/* A play nobody heard: the record must have played it, but the evidence is
   its neighbours rather than the track itself. Dimmed and dashed so it never
   passes for an observation at a glance. */
.listen.is-inferred .listen-art,
.listen.is-inferred .listen-title,
.listen.is-inferred .listen-sub { opacity: .55; }
.inferred-strip {
  display: flex; align-items: center; gap: .6rem; flex-wrap: wrap;
  margin-top: .45rem; padding: .4rem .55rem;
  border: 1px dashed var(--rule); border-radius: 8px;
}
.inferred-note { color: var(--dim); font: 500 .7rem/1.4 var(--mono); }
.inferred-btn {
  font: 600 .7rem var(--mono); color: var(--ink);
  background: var(--paper); border: 1px solid var(--rule); border-radius: 999px;
  padding: .2rem .6rem; cursor: pointer;
}
.inferred-btn:hover { border-color: var(--glow); color: var(--glow); }
.inferred-btn:disabled { opacity: .5; cursor: default; }

/* The record playing right now, on the newest row. Takes the colour of
   whatever medium it is playing on, set inline by the caller. */
/* No max-width on the block itself: it used to cap both the label and the
   bar at 22rem, which wrapped "album listened in full" onto a second line the
   moment a record was finished. The bar keeps that width; the label is free to
   use the row. */
.side-progress { margin-top: .45rem; }
.side-progress .progress { max-width: 26rem; }
.side-progress-label.is-complete { font-weight: 700; }
.side-progress-label {
  color: var(--tint, var(--glow)); font: 500 .68rem/1.5 var(--mono);
  margin-bottom: .25rem;
}
/* Kept on one line where there is room for it. Narrow screens still wrap --
   an overflowing line would be worse than a wrapped one. */
@media (min-width: 40rem) {
  .side-progress-label { white-space: nowrap; }
}
.side-progress .progress-fill { background: var(--tint, var(--glow)); }

/* How much of a record has been heard. */
.progress {
  position: relative;
  height: .3rem; border-radius: 999px; overflow: hidden;
  background: color-mix(in srgb, var(--dim) 22%, transparent);
}
.progress-fill { height: 100%; background: var(--glow); border-radius: 999px; }
/* Where one side of a record ends and the next begins. Drawn in the page
   background rather than a colour of its own, so it reads as a notch cut out
   of the bar instead of a third thing competing with it. */
.progress-tick {
  position: absolute; top: 0; bottom: 0; width: 2px;
  background: var(--paper);
}
.progress-caption { margin: .5rem 0 0; color: var(--dim); font: 500 .72rem var(--mono); }
.tracklist {
  list-style: none; margin: .6rem 0 0; padding: 0;
  display: grid; gap: .25rem;
  color: var(--dim); font-size: .82rem;
}
.tracklist li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.entity { color: inherit; text-decoration: none; cursor: pointer; }
.entity:hover { color: var(--glow); text-decoration: underline; text-underline-offset: 2px; }
.entity-sep { color: var(--dim); margin: 0 .35em; }

/* ------------------------------------------------------------- colophon */
.colophon {
  border-top: 1px solid var(--rule);
  padding: 1.5rem var(--gap); margin-top: var(--gap);
  color: var(--dim); font-size: .85rem; max-width: 46rem;
}
.colophon p { margin: 0; }
.colophon-stats { margin-top: .7rem !important; font: 500 .72rem/1.6 var(--mono); }

/* ----------------------------------------------------------- milestones */
/* Two surfaces for one idea. The banner on the log interrupts, once, and is
   dismissed; /milestones is where the same rung lives afterwards, alongside
   the ones still ahead of it. Both take their hue from --tint, set per ladder
   from the source families, so a vinyl milestone is the same pink as a vinyl
   chip. Shared vocabulary in web/milestones-ui.js keeps them agreeing. */

.celebrations {
  display: grid; gap: .5rem;
  padding: .75rem var(--gap);
  border-bottom: 1px solid var(--rule);
}
.celebrate {
  display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
  gap: .75rem; align-items: center;
  padding: .7rem .9rem;
  background: var(--card);
  /* Tint only at the edge and behind the glyph. A whole bar in the ladder's
     colour would out-shout the now-playing widget above it, which is the one
     thing on the page that genuinely changes minute to minute. */
  border: 1px solid var(--rule);
  border-left: 3px solid var(--tint);
  border-radius: 8px;
  animation: celebrate-in .45s cubic-bezier(.2, .8, .3, 1) both;
}
.celebrate-mark {
  color: var(--tint); font-size: 1.15rem; line-height: 1;
  animation: celebrate-twinkle 2.4s ease-in-out 3;
}
.celebrate-text { min-width: 0; display: grid; gap: .15rem; }
.celebrate-head {
  font: 800 1.05rem/1.2 var(--display); letter-spacing: -.01em;
  color: var(--tint);
}
.celebrate-detail { color: var(--dim); font-size: .82rem; }
.celebrate-close {
  width: 1.8rem; height: 1.8rem; border-radius: 6px;
  background: none; border: 1px solid transparent; color: var(--dim);
  font-size: 1.1rem; line-height: 1; cursor: pointer;
}
.celebrate-close:hover { border-color: var(--rule); background: var(--paper); color: var(--ink); }

@keyframes celebrate-in {
  from { opacity: 0; transform: translateY(-.4rem); }
  to   { opacity: 1; transform: none; }
}
@keyframes celebrate-twinkle {
  0%, 100% { opacity: 1; transform: none; }
  50%      { opacity: .55; transform: scale(1.18); }
}
@media (prefers-reduced-motion: reduce) {
  /* Announced, not performed. The banner still arrives -- it just stops
     moving to do it. */
  .celebrate, .celebrate-mark { animation: none; }
}

.celebrate-more {
  /* The badge is a doorway as well as an announcement, but dismissing it is
     the common case -- so this is a quiet link on the sentence rather than a
     second button competing with the one that makes it go away. */
  justify-self: start; margin-top: .1rem;
  color: var(--tint); text-decoration: none;
  font: 500 .74rem var(--mono);
  border-bottom: 1px solid transparent;
}
.celebrate-more:hover { border-bottom-color: currentColor; }

/* ------------------------------------------------------ milestones page */
/* Wider than the sidebar card this replaced, which is the whole point: with
   room for every rung, a ladder shows the climb ahead instead of just the
   rungs behind. */

/* margin-right:auto rather than relying on the masthead's space-between --
   three flex children would strand the nav in the middle of the header,
   reading as a title rather than as navigation next to the wordmark. The
   phone drops it, because there the nav is meant to be in the far corner. */
.mast-nav {
  position: relative; align-self: center; margin-right: auto;
  display: flex; align-items: center;
}
.mast-menu { display: flex; align-items: center; gap: .35rem; }

.masthead-link {
  color: var(--dim); text-decoration: none;
  font: 500 .78rem var(--mono);
  padding: .2rem .5rem; border-radius: 6px;
  border: 1px solid transparent;
  white-space: nowrap;
}
.masthead-link:hover { color: var(--ink); border-color: var(--rule); background: var(--card); }
/* The page you are on stays listed rather than being dropped, so the menu is
   the same three items everywhere and you learn where things are. */
.masthead-link[aria-current="page"] { color: var(--ink); background: var(--card); }

/* Hidden on a wide screen, where the links speak for themselves. */
.mast-toggle {
  display: none;
  background: none; border: 1px solid transparent; border-radius: 8px;
  color: var(--dim); cursor: pointer; padding: .4rem;
}
.mast-toggle:hover { color: var(--ink); border-color: var(--rule); background: var(--card); }
/* Three bars drawn the way the wordmark's four are -- gradients rather than an
   icon file, so they take their color from the button and cost no request. */
.mast-bars {
  display: block; width: 1.15rem; height: .8rem;
  background:
    linear-gradient(currentColor 0 0) 50% 0    / 100% 2px no-repeat,
    linear-gradient(currentColor 0 0) 50% 50%  / 100% 2px no-repeat,
    linear-gradient(currentColor 0 0) 50% 100% / 100% 2px no-repeat;
}

@media (max-width: 40rem) {
  /* now-playing is already gone at this width, so the masthead is down to two
     children and space-between puts the button in the corner on its own. */
  .mast-nav { margin-right: 0; }
  .mast-toggle { display: block; }
  .mast-menu {
    display: none;
    position: absolute; top: calc(100% + .55rem); right: 0;
    flex-direction: column; align-items: stretch; gap: .1rem;
    min-width: 9.5rem; padding: .3rem;
    background: var(--paper);
    border: 1px solid var(--rule); border-radius: 10px;
    box-shadow: 0 10px 30px rgb(0 0 0 / .18);
  }
  .mast-nav.is-open .mast-menu { display: flex; }
  .mast-nav.is-open .mast-toggle { color: var(--ink); border-color: var(--rule); background: var(--card); }
  /* A row you can actually hit with a thumb, rather than the inline link's
     padding, which was sized to sit quietly beside the wordmark. */
  .masthead-link { padding: .55rem .6rem; font-size: .82rem; border-radius: 6px; }
}

/* #main carries the log's two-column shape, and the skip link on every page
   points at #main -- so this page keeps the id and undoes the layout, rather
   than renaming the landmark and leaving one page's skip link odd. */
#main.milestones-page { grid-template-columns: minmax(0, 1fr); gap: 1.5rem; }
.page-head { display: grid; gap: .5rem; max-width: 46rem; }
.page-head h1 { margin: 0; font: 800 clamp(1.6rem, 4vw, 2.2rem)/1.1 var(--display); letter-spacing: -.02em; }
.page-head p { margin: 0; color: var(--dim); font-size: .88rem; line-height: 1.55; }

/* auto-fit, not a fixed three: the ladder count is data, and a column rule
   that hard-codes it breaks the day a fourth one is added. */
.ladders { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); align-items: start; }

.ladder-card {
  display: grid; gap: .5rem; align-content: start;
  padding: 1rem 1.1rem 1.1rem;
  background: var(--card); border: 1px solid var(--rule); border-radius: 10px;
  border-top: 3px solid var(--tint);
}
.ladder-head { display: grid; gap: .2rem; }
.ladder-head h2 { margin: 0; font: 700 1.05rem/1.2 var(--display); letter-spacing: -.01em; }
.ladder-blurb { margin: 0; color: var(--dim); font-size: .78rem; line-height: 1.45; }
.ladder-total { margin: .3rem 0 0; }
.ladder-total-value {
  font: 700 1.8rem/1 var(--mono); color: var(--tint);
  /* Tabular figures so the three cards' counts sit on the same rhythm even
     though they differ by three orders of magnitude. */
  font-variant-numeric: tabular-nums;
}

.ladder-track { height: 4px; border-radius: 2px; overflow: hidden; background: var(--rule); }
.ladder-fill {
  display: block; height: 100%; width: var(--pct);
  background: var(--tint);
  /* Long odysseys, most of them. A visible sliver at 1% says "started" where
     a hairline says "broken". */
  min-width: 2px;
  transition: width .6s ease;
}
.ladder-next { margin: 0; color: var(--dim); font: 500 .7rem/1.4 var(--mono); }

/* The one guess on the page, and drawn as one: no tint, no tabular figures,
   set back a step from the count above it. Everything else in this card is
   something that happened. */
.ladder-eta { margin: .1rem 0 0; color: var(--dim); opacity: .8; font: 400 .68rem/1.4 var(--body); font-style: italic; }
.ladder-eta.is-unknown { opacity: .55; }

.rungs { list-style: none; margin: .4rem 0 0; padding: 0; display: grid; gap: .1rem; }
.rung {
  display: grid; grid-template-columns: 4.2rem minmax(0, 1fr) auto;
  gap: .5rem; align-items: baseline;
  padding: .35rem .4rem; border-radius: 6px;
  font-size: .78rem;
  border-top: 1px solid var(--rule);
}
.rung:first-child { border-top: none; }
.rung-target {
  font: 600 .72rem var(--mono); color: var(--tint);
  padding: .05rem .35rem; border-radius: 4px;
  /* colour-mix keeps one hue per ladder honest: the badge is the ladder's own
     colour at 12%, so it stays legible on paper and on the dark card without
     a second variable per family. */
  background: color-mix(in srgb, var(--tint) 12%, transparent);
  white-space: nowrap; text-align: center;
  font-variant-numeric: tabular-nums;
}
.rung-body { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dim); }
.rung-date { color: var(--dim); font: 500 .68rem var(--mono); white-space: nowrap; }

/* The next rung is the only one on the ladder that is a question rather than
   a record, so it is the only one drawn as active. */
.rung.is-next { background: color-mix(in srgb, var(--tint) 8%, transparent); border-top-color: transparent; }
.rung.is-next .rung-body { color: var(--ink); }
/* Not yet climbed, and not pretending otherwise -- dimmed as a set so the
   earned rungs read as a block at the top rather than as scattered hits. */
.rung.is-ahead { opacity: .45; }
.rung.is-ahead .rung-target { background: none; color: var(--dim); }
.rung.is-earned .rung-body { color: var(--ink); }

@media (prefers-reduced-motion: reduce) {
  .ladder-fill { transition: none; }
}

@media (max-width: 40rem) {
  /* The song that earned a rung is the first thing to go when there is no
     room for it; the number and the date are the record. */
  .rung { grid-template-columns: 3.6rem minmax(0, 1fr); }
  .rung-date { grid-column: 2; justify-self: end; }
}


/* ---------------------------------------------------------- favorites */
/* The heart is state, not an action, so it does not hide the way the delete
   button does. A hearted song has to read as hearted while you scroll past
   it -- a control that only appears on hover cannot tell you anything about
   a row you are not pointing at. Unhearted it sits back at low contrast so a
   page of them does not look like a page of buttons. */
.listen-heart {
  width: 1.6rem; height: 1.6rem; border-radius: 6px;
  background: none; border: 1px solid transparent; color: var(--dim);
  font: 400 1.05rem/1 var(--display); cursor: pointer;
  opacity: .25; transition: opacity .1s, color .1s;
}
.listen:hover .listen-heart { opacity: .7; }
.listen-heart:hover { opacity: 1; border-color: var(--rule); background: var(--card); }
.listen-heart.is-on,
.listen:hover .listen-heart.is-on { opacity: 1; color: var(--heart); }
.listen-heart:focus-visible { opacity: 1; }

/* Borrowed from the source families rather than invented, same rule the
   milestone tints follow: a heart should look like it belongs to this site.
   Falls back to the interaction amber where --heart is not defined. */
:root { --heart: #e8604c; }

#main.favorites-page { grid-template-columns: minmax(0, 1fr); gap: 1.5rem; }
.favorites { list-style: none; margin: 0; padding: 0; }
.favorite {
  display: grid;
  /* art, body, heart -- no timestamp column: a favorite is not dated by a
     play, and the play line below the artist says when it was last heard. */
  grid-template-columns: 3.4rem minmax(0, 1fr) auto;
  gap: .9rem; align-items: center;
  padding: .7rem 0;
  border-bottom: 1px solid var(--rule);
}
.favorite .listen-title { display: block; color: inherit; text-decoration: none; }
.favorite .listen-title:hover { text-decoration: underline; }
.favorite-plays { color: var(--dim); font: 500 .78rem/1.5 var(--mono); }
/* Always visible here: on a page that is nothing but favorites, a heart
   that faded out would leave every row looking unhearted. */
.favorite .listen-heart { opacity: 1; color: var(--heart); }

@media (max-width: 40rem) {
  /* The timestamp is the first thing to go; the heart is not, since it is
     the only control on the row that carries state. */
  .listen { grid-template-columns: 3.4rem minmax(0, 1fr) auto auto; }
  .listen-when { grid-column: 2; justify-self: start; }
}


/* A vinyl run counted against the digital release. Not an error state -- the
   record may simply not be on Discogs -- so it is dimmed and dashed rather
   than coloured like a warning: the bar is still true about how far through
   the listening is, just not about which object it is measuring. */
.side-progress.is-stand-in .side-progress-label { opacity: .7; }
.side-progress.is-stand-in .progress { border: 1px dashed var(--rule); background: none; }
.side-progress.is-stand-in .progress-fill { opacity: .55; }
