now playing: the drag tracks the pointer, and the panel stops fighting its width

The resize grip. The panel already left the 0.28s ease for a drag, but the
grid column it lives in did not -- and an eased track list cannot follow a
pointer, because the grip moves the target every frame and each move restarts
the ease from wherever it had crawled to. Measured on the real gesture: the
column ran 122.6px behind the pointer and the panel overran the window on 29
of 30 frames, which is why the release grid was sliced off at the right edge.
With the column leaving the ease alongside the panel: 0px lag, 0 frames
clipped, 4.6ms worst frame. onGripUp is bound to lostpointercapture as well,
because a release the grip never sees would leave the app root pinned at
transition:none and kill every collapse animation for the session.

The header status re-ran its 300ms entry slide on every progress event. It was
keyed on the text, and the text carries the percentage, rate and ETA -- so it
replayed several times a second and the header flashed. Keyed on the transfer
PHASE now: 2 rebuilds across a real transfer instead of one per tick.

The waveform lane was unmounted while peaks were absent. player.waveform
empties the instant a track changes and a partial FLAC decode can take
seconds, so the times, the chips and the whole pane below jumped 54px up and
then back on every track change. It holds its space as a flat baseline, with
the same bar COUNT either way so peaks arriving restyle the existing elements
rather than replacing them. The bar count is derived from the panel width at a
6px pitch as well -- a fixed 78 bars was a 310px block that overflowed the
272px panel and left a third of the 560px one empty. Now 41 bars at 272 and 89
at 560, never more than 4px of the lane unused.

Fans also like was a fixed row of four circles spaced evenly: every name
truncated to "The Sma..." however wide the panel got, because the circles
stayed 64px and only the gaps grew. Name-sized pills that wrap use whatever
width there is, carry eight artists in less height, and truncate nothing at
any width.

Show all releases dumped 68 records in one flat date-sorted grid. It is filed
by the artist page's own ten categories now, empty ones dropped, editions
folded into their base, as dense rows with the year printed only where it
changes. NOTE: this panel classifies with less evidence than the artist page
-- no description hints, no MusicBrainz type map -- so a record whose kind is
only knowable from its description will file differently here until those are
routed through. Commented at the source.

Full-screen artwork from the panel opened playerArtSource, a string rewrite of
the provider URL to 1000x1000, with no coverUpgrades lookup at all -- while
the same record opened from a page gave the master. On the Red Album that is
1000x1000 against Apple's 3000x3000. It opens on the best it has and swaps the
master in when it resolves, guarded on the viewer still showing what it
opened.

Releases grid: cards align on their artwork again (a button centres its
content, so beside a four-line title the short card's whole stack sat lower),
titles clamp to two reserved lines, the hover outline samples the record's own
colour, and clicking one opens the album page instead of streaming it. The
artist chip's name was centred because a button centres text while the stats
row below is flex-aligned; it reads as left-aligned with them now. waveDragging
is $state, so class:scrubbing actually applies. The pane never scrolls
sideways.
This commit is contained in:
KeelrLeek committed 2026-07-29 08:12:31 -05:00
1 parent 9cb19f42af
commit 0ea4da7146
2 files changed
+449 -89

No files matched your search

+437 -89
View File
@@ -10,7 +10,7 @@
discovery,
loadDiscovery,
openCatalogArtist,
streamCatalogAlbum,
openCatalogAlbum,
openCurrentArtist,
openCurrentAlbum,
openCurrentSong,
@@ -18,6 +18,8 @@
artBroken,
markArtBroken,
playerArtSource,
coverUpgrades,
resolveCovers,
setNowPlayingWidth,
NP_WIDTH_DEFAULT,
NP_WIDTH_MIN,
@@ -27,23 +29,48 @@
import SaveMenu from "./SaveMenu.svelte";
import * as api from "../lib/api.js";
import { floatScroll } from "../lib/scrollbar.js";
import { accentFromImage } from "../lib/color.js";
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
/** Hand the playing cover to the shared artwork lightbox. It takes an
* explicit descriptor now rather than reaching into the queue itself, so
* the same viewer serves an album's artwork anywhere else in the app. */
function openArtwork() {
async function openArtwork() {
const item = currentItem;
if (!item) return;
const src = item.art ? item.art : item.artUrl ? playerArtSource(item) : null;
if (!src) return;
ui.artwork = {
const shot = (src, local) => ({
src,
local: Boolean(item.art),
local,
title: item.title ?? "",
subtitle: item.sub ?? "",
};
});
if (item.art) {
ui.artwork = shot(item.art, true);
return;
}
const url = item.artUrl;
if (!url) return;
// The MASTER, the same thing the artist and album pages open — not the
// provider URL rewritten to 1000x1000, which is all playerArtSource
// can do on its own. This panel never ran the cover-upgrade pass those
// pages run over their grids, so the playing record's master had simply
// never been looked up and the viewer topped out at 1000px here while
// the very same record opened at full resolution from a page.
const known = coverUpgrades[url];
const opened = known ?? playerArtSource(item);
ui.artwork = shot(opened, false);
if (known) return;
// Resolve, then swap in place. Guarded on the viewer still showing
// exactly what we opened, so closing it — or opening a different
// record's art meanwhile — is never overwritten from under the user.
await resolveCovers([{ cover: url, title: albumTitle }], artistName);
const upgraded = coverUpgrades[url];
if (upgraded && upgraded !== opened && ui.artwork?.src === opened) {
ui.artwork = shot(upgraded, false);
}
}
// Gate stream-only sections (quality, source, Save) on the displayed
// item's own stream field — the source of truth for what's on screen.
@@ -130,7 +157,10 @@
.slice(0, 6);
});
const relatedArtists = $derived(discovery.related.slice(0, 4));
// Eight, not four: as name-sized pills that wrap they cost a fraction of
// the height the fixed row of circles did, so the section can carry twice
// the artists in less space.
const relatedArtists = $derived(discovery.related.slice(0, 8));
// How many of this artist's albums are already in the local library.
const inLibraryCount = $derived.by(() => {
@@ -142,6 +172,86 @@
function albumYear(a) {
return a.release_date ? a.release_date.slice(0, 4) : "";
}
/* ── The full discography, filed ──────────────────────────────────────
"Show all releases" used to be one flat date-sorted grid, so a
sixty-eight release artist arrived as an undifferentiated wall. These
are the artist page's own ten categories in its own order, empty ones
dropped, exactly as its TAB_DEFS does.
⚠ The page classifies with evidence this panel does not have: the album
description hints and the MusicBrainz type map, both built by the artist
page's background harvest. Here it is the flags the release itself
carries. A record whose kind is only knowable from its description (a
compilation innocently titled "Death to False Metal") will file as an
album here and as a compilation there until those hints are routed
through — a known, deliberate gap, not a bug to rediscover. */
const RELEASE_CATEGORIES = [
["upcoming", "Upcoming"],
["album", "Albums"],
["mixtape", "Mixtapes"],
["ep", "EPs"],
["single", "Singles"],
["live", "Live"],
["compilation", "Compilations"],
["soundtrack", "Soundtracks"],
["remix", "Remixes"],
["other", "Other"],
];
const isFutureRelease = (date) => {
if (!date) return true; // undated announcements are not yet released
const at = Date.parse(date);
return Number.isNaN(at) || at > Date.now();
};
function releaseCategory(album, byId, depth = 0) {
// An edition files WITH its base release, one hop, like the page.
if (depth < 2 && album.edition_group != null && album.id !== album.edition_group) {
const base = byId.get(album.edition_group);
if (base) return releaseCategory(base, byId, depth + 1);
}
const type = (album.record_type ?? "").toLowerCase();
if (type === "album" && isFutureRelease(album.release_date)) return "upcoming";
if (album.is_mixtape) return "mixtape";
if (album.is_soundtrack) return "soundtrack";
if (album.is_live) return "live";
const title = (album.title ?? "").toLowerCase();
if (/\bremix(es)?\b/.test(title)) return "remix";
if (/\bgreatest hits\b|\bbest of\b|\banthology\b/.test(title)) return "compilation";
if (/\bsessions?\b|\bdemos?\b|\bpractice\b|\brehearsal\b/.test(title)) return "other";
return ["album", "ep", "single"].includes(type) ? type : "other";
}
/** Sections of dense rows, each row flagged with the year label it should
* carry. The label is printed only where the year CHANGES going down, so a
* run of singles from one year reads as one dated block instead of
* repeating itself on every line. */
const releaseSections = $derived.by(() => {
const all = discovery.albums ?? [];
if (!all.length) return [];
const byId = new Map(all.map((a) => [a.id, a]));
// Editions fold into their base, so a record's pressings are one row.
const folded = all.filter((a) => a.edition_group == null || a.id === a.edition_group);
const buckets = new Map(RELEASE_CATEGORIES.map(([key]) => [key, []]));
for (const album of folded) {
buckets.get(releaseCategory(album, byId))?.push(album);
}
return RELEASE_CATEGORIES.flatMap(([key, label]) => {
const items = (buckets.get(key) ?? []).sort((a, b) =>
(b.release_date ?? "").localeCompare(a.release_date ?? ""),
);
if (!items.length) return [];
let previous = null;
const rows = items.map((album) => {
const year = key === "upcoming" ? "Upcoming" : albumYear(album) || "—";
const heading = year === previous ? null : year;
previous = year;
return { album, heading };
});
return [{ key, label, count: items.length, rows }];
});
});
function fmtFans(n) {
if (!n) return "—";
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
@@ -176,13 +286,23 @@
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
);
// Average dense source peaks into fixed, equally sized display bars. Using
// an average (rather than picking one peak) stops isolated transients from
// Average dense source peaks into equally sized display bars. Using an
// average (rather than picking one peak) stops isolated transients from
// making random bars hit the ceiling.
// 78 × 2px bars plus 77 × 2px gaps = 310px. The sidebar's 312px
// waveform lane can center that exact integer footprint, avoiding the
// fractional gap rounding that made some neighboring bars look closer.
const BARS = 78;
//
// The COUNT is derived from the panel's width so the lane reads at one
// density everywhere. It used to be a flat 78 bars on a 2px pitch — a
// 310px block that overflowed the 272px panel and left a third of the
// 560px one empty, which is precisely what looked wrong at both ends.
// One bar per BAR_PITCH px instead: a 2px bar on a 4px gap.
// Pitch 6 chosen off research/nowplaying-fans-waveform/mockup.html (W1).
const BAR_PITCH = 6;
const BAR_W = 2;
// .np-scroll's horizontal padding, both sides — the lane is what is left.
const LANE_PAD = 28;
const BARS = $derived(
Math.max(24, Math.floor((ui.nowPlayingWidth - LANE_PAD + BAR_W) / BAR_PITCH)),
);
const bars = $derived.by(() => {
const w = player.waveform;
if (!w || w.length === 0) return [];
@@ -198,6 +318,19 @@
});
const playedBars = $derived((playedPct / 100) * bars.length);
/** The lane is 38px of the panel's vertical rhythm whether or not peaks
* have arrived yet. `player.waveform` is emptied the instant a track
* changes, and a partial-FLAC stream decode can take seconds to refill it,
* so unmounting the lane collapsed the times, the chips and the whole
* segmented control below it and then shoved them back down — a jolt on
* every track change. It holds its space as a flat baseline instead.
*
* Same bar COUNT either way, so peaks arriving restyle the existing
* elements rather than replacing them. */
const FLAT_BARS = $derived(Array(BARS).fill(0));
const wavePending = $derived(bars.length === 0);
const waveBars = $derived(wavePending ? FLAT_BARS : bars);
function barHeight(value, index) {
const capped = Math.pow(Math.min(0.88, value * 1.18), 0.8);
// Stable per-bar grain keeps a dense loud passage from flattening
@@ -265,6 +398,21 @@
}
return peerName ? `Streaming from ${peerName}` : null;
});
/** What the status line is ABOUT, as opposed to what it currently reads.
* The header's entry animation is keyed on this rather than on the text:
* while transferring, the text carries a percentage, a rate and an ETA, so
* keying on it replayed the 300ms slide on every progress event — several
* times a second — and the header flashed. Peer and transfer state are what
* an arrival should announce, and they change a handful of times a track. */
const statusPhase = $derived(
!isStream
? "none"
: cached
? "cached"
: transferring
? `transfer:${peerName ?? ""}`
: `open:${peerName ?? ""}`,
);
/** The hairline has no percentage to show until bytes start arriving. */
const hairlineSeeking = $derived(isStream && player.streamTotal === 0);
@@ -273,7 +421,9 @@
// delivering moves after it leaves the 9px grip, which a mousemove
// listener on the element alone would not.
let gripEl = $state(null);
let dragging = $state(false);
// On `ui` rather than local, because App.svelte's grid column has to leave
// the ease at the same instant this panel does.
const dragging = $derived(ui.nowPlayingDragging);
let dragRaf = 0;
let dragStartX = 0;
let dragStartW = 0;
@@ -282,7 +432,7 @@
const atDefault = $derived(ui.nowPlayingWidth === NP_WIDTH_DEFAULT);
function onGripDown(e) {
dragging = true;
ui.nowPlayingDragging = true;
dragStartX = e.clientX;
dragStartW = ui.nowPlayingWidth;
gripEl?.setPointerCapture?.(e.pointerId);
@@ -303,15 +453,25 @@
setNowPlayingWidth(pendingWidth);
});
}
/** Ends the drag. Bound to lostpointercapture as well as pointerup and
* pointercancel: a release the grip never sees — let go outside the window,
* the panel re-rendering mid-drag, the webview cancelling the gesture —
* would otherwise leave the app root pinned at `transition: none` and the
* collapse animation dead for the rest of the session. However capture
* ends, lostpointercapture fires, so the flag cannot outlive the gesture. */
function onGripUp(e) {
if (!dragging) return;
dragging = false;
ui.nowPlayingDragging = false;
if (dragRaf) {
cancelAnimationFrame(dragRaf);
dragRaf = 0;
setNowPlayingWidth(pendingWidth);
}
gripEl?.releasePointerCapture?.(e.pointerId);
// Guarded: releasing a pointer this element does not hold throws, and
// lostpointercapture arrives when it has already been given up.
if (e?.pointerId != null && gripEl?.hasPointerCapture?.(e.pointerId)) {
gripEl.releasePointerCapture(e.pointerId);
}
}
function resetWidth() {
setNowPlayingWidth(NP_WIDTH_DEFAULT);
@@ -468,6 +628,40 @@
});
const releaseList = $derived(showAllReleases ? discovery.albums : moreAlbums);
/* Hover highlight in the RECORD's own colour rather than the playing
* track's. Sampling is lazy — only the card actually under the pointer —
* and memoised per cover URL, so a grid of sixty-eight releases costs
* nothing until you point at one. Until a colour lands the card falls back
* to the global accent, so there is no flash of "no highlight". */
const albumAccents = new Map(); // cover URL -> hex, or null once sampled dry
const accentPending = new Set();
async function tintFromCover(event, album) {
const cover = album?.cover;
if (!cover) return;
const el = event.currentTarget;
if (albumAccents.has(cover)) {
const known = albumAccents.get(cover);
if (known) el.style.setProperty("--album-accent", known);
return;
}
if (accentPending.has(cover)) return;
accentPending.add(cover);
try {
const dataUrl = await getCatalogArt(cover);
const colors = dataUrl ? await accentFromImage(dataUrl) : null;
// The brighter of the pair: this is a 2px line over artwork, and
// the resting accent disappears against a dark cover.
const hex = colors?.hover ?? colors?.accent ?? null;
albumAccents.set(cover, hex);
if (hex) el.style.setProperty("--album-accent", hex);
} catch {
albumAccents.set(cover, null);
} finally {
accentPending.delete(cover);
}
}
const upNext = $derived(player.queue[player.queueIndex + 1] ?? null);
const fmtListeners = (n) => (n == null ? null : n.toLocaleString());
@@ -478,7 +672,10 @@
// that one (75ms throttle while dragging, exact seek on release) so the two
// cannot disagree about where a click lands.
let waveEl = $state(null);
let waveDragging = false;
// $state, not a plain let: it drives `class:scrubbing` in the markup, and
// without it that class never applied while dragging (the compiler warns
// on every build — non_reactive_update).
let waveDragging = $state(false);
let lastWaveSeekAt = 0;
function waveTarget(e) {
if (!player.duration || !waveEl) return null;
@@ -641,7 +838,7 @@
</button>
<div class="np-headmid">
{#if statusLine}
{#key statusLine}
{#key statusPhase}
<span class="np-status">{statusLine}</span>
{/key}
{/if}
@@ -680,6 +877,7 @@
{/if}
</div>
{/if}
</div>
<div class="np-scroll" use:floatScroll>
@@ -722,30 +920,30 @@
</div>
</div>
<!-- mini waveform + times -->
{#if bars.length}
<div
class="wave"
class:scrubbing={waveDragging}
role="slider"
tabindex="0"
aria-label="Seek"
aria-valuemin="0"
aria-valuemax={Math.round(player.duration ?? 0)}
aria-valuenow={Math.round(player.position ?? 0)}
bind:this={waveEl}
onpointerdown={waveDown}
onpointermove={waveMove}
onpointerup={waveUp}
onpointercancel={waveUp}
>
{#each bars as v, i}
<i
style="height:{barHeight(v, i)}%; --fill:{barFill(i)}"
></i>
{/each}
</div>
{/if}
<!-- mini waveform + times. Always mounted — see `wavePending`. -->
<div
class="wave"
class:scrubbing={waveDragging}
class:pending={wavePending}
style="--bar-gap: {BAR_PITCH - BAR_W}px"
role="slider"
tabindex="0"
aria-label="Seek"
aria-valuemin="0"
aria-valuemax={Math.round(player.duration ?? 0)}
aria-valuenow={Math.round(player.position ?? 0)}
bind:this={waveEl}
onpointerdown={waveDown}
onpointermove={waveMove}
onpointerup={waveUp}
onpointercancel={waveUp}
>
{#each waveBars as v, i}
<i
style="height:{wavePending ? 0 : barHeight(v, i)}%; --fill:{wavePending ? 0 : barFill(i)}"
></i>
{/each}
</div>
<div class="times">
<span>{fmtTime(player.position)}</span>
<!-- Remaining, not total: the total is already implied by the
@@ -774,10 +972,9 @@
{/if}
<!-- Context below the record: shallow panes instead of one long
scroll, so the queue is never four sections away. The control
is sticky under the glass, because keeping your scroll position
across a switch is worthless if the switch itself scrolls out
of reach. -->
scroll, so the queue is never four sections away. An ordinary
element in the flow — it is painted where it sits and scrolls
away with everything else. -->
<div class="segwrap">
<div class="seg" role="tablist">
<span
@@ -877,19 +1074,54 @@
<span>Releases</span>
{#if discovery.albumCount}<span class="cnt">{discovery.albumCount} total</span>{/if}
</div>
<div class="grid2" style="--cols: {releaseCols}">
{#each releaseList as al (al.id)}
<button class="mini" title="Play {al.title}" onclick={() => streamCatalogAlbum(al)}>
<div class="art">
{#if al.cover && !artBroken[al.cover]}
<img src={al.cover} alt="" loading="lazy" draggable="false" onerror={() => markArtBroken(al.cover)} />
{#if showAllReleases}
<!-- The whole discography, filed by category and
dated only where the year changes. -->
{#each releaseSections as section (section.key)}
<div class="rel-sec">
<span>{section.label}</span>
<span class="n">{section.count}</span>
</div>
<div class="rel-rows">
{#each section.rows as row (row.album.id)}
{#if row.heading}
<div class="rel-year">{row.heading}</div>
{/if}
</div>
<div class="mt">{al.title}</div>
<div class="my">{albumYear(al)}{al.record_type && al.record_type !== "album" ? " · " + al.record_type : ""}</div>
</button>
<button
class="rel-row"
title={row.album.title}
onclick={() => openCatalogAlbum(row.album.id)}
>
<span class="rel-th">
{#if row.album.cover && !artBroken[row.album.cover]}
<img src={row.album.cover} alt="" loading="lazy" draggable="false" onerror={() => markArtBroken(row.album.cover)} />
{/if}
</span>
<span class="rel-t">{row.album.title}</span>
</button>
{/each}
</div>
{/each}
</div>
{:else}
<div class="grid2" style="--cols: {releaseCols}">
{#each releaseList as al (al.id)}
<button
class="mini"
title={al.title}
onclick={() => openCatalogAlbum(al.id)}
onpointerenter={(e) => tintFromCover(e, al)}
>
<div class="art">
{#if al.cover && !artBroken[al.cover]}
<img src={al.cover} alt="" loading="lazy" draggable="false" onerror={() => markArtBroken(al.cover)} />
{/if}
</div>
<div class="mt">{al.title}</div>
<div class="my">{albumYear(al)}{al.record_type && al.record_type !== "album" ? " · " + al.record_type : ""}</div>
</button>
{/each}
</div>
{/if}
{#if discovery.albums.length > moreAlbums.length}
<button class="showall" onclick={() => (showAllReleases = !showAllReleases)}>
{showAllReleases ? "Show fewer" : "Show all " + discovery.albums.length + " releases"}
@@ -901,10 +1133,10 @@
<div class="fans">
{#each relatedArtists as ar}
<button class="fan" title={ar.name} onclick={() => openCatalogArtist(ar.id, ar)}>
<div class="av2">
<span class="av2">
{#if ar.picture}<img src={ar.picture} alt="" loading="lazy" draggable="false" />{/if}
</div>
<div class="fn">{ar.name}</div>
</span>
<span class="fn">{ar.name}</span>
</button>
{/each}
</div>
@@ -968,6 +1200,7 @@
onpointermove={onGripMove}
onpointerup={onGripUp}
onpointercancel={onGripUp}
onlostpointercapture={onGripUp}
ondblclick={resetWidth}
></div>
{#if dragging}
@@ -1283,8 +1516,9 @@
overflow: hidden;
}
/* The status swaps with the glass bar's own breadcrumb motion, so a change
of peer or state reads as an arrival rather than a flicker. Keyed on the
text, so it only replays when the line actually changes. */
of peer or state reads as an arrival rather than a flicker. Keyed on
`statusPhase`, NOT on the text: the text carries the live percentage, so
keying on it replayed this animation on every progress event. */
.np-status {
display: block;
font-size: 10.5px;
@@ -1331,6 +1565,10 @@
flex: 1;
min-height: 0;
overflow-y: auto;
/* Never sideways. Nothing in this pane is meant to be reached by
scrolling horizontally — a stray wide row is a bug, and a scrollbar
along the bottom of the panel is not the way to find out about it. */
overflow-x: hidden;
overflow-anchor: none;
overscroll-behavior: none;
/* Clear the 46px glass header so the art isn't overlapped at rest; the
@@ -1488,7 +1726,9 @@
display: flex;
justify-content: center;
align-items: end;
gap: 2px;
/* Written from BAR_PITCH - BAR_W so the gap cannot drift out of step
with the bar count derived from the same pitch. */
gap: var(--bar-gap, 4px);
height: 38px;
/* It is a control now, not a picture of one. */
cursor: pointer;
@@ -1502,6 +1742,11 @@
.wave.scrubbing {
cursor: grabbing;
}
/* Waiting on peaks: the bars rest on their 2px floor, so the lane reads as
a quiet baseline holding its place rather than as a hole in the panel. */
.wave.pending i {
opacity: 0.4;
}
.wave i {
flex: 0 0 2px;
width: 2px;
@@ -1565,11 +1810,11 @@
/* ---- segmented control ---- */
.segwrap {
/* Sticky under the 46px glass header: keeping the scroll offset across
a pane switch is worthless if the control you just used has scrolled
out of reach. The gradient hides rows sliding under it. */
position: sticky;
top: 46px;
/* Deliberately NOT sticky. Sticky does not mean "pinned": it scrolls
normally until it reaches its stick point, so sitting below the art,
title, waveform and chips it rode 530px up the panel before pinning,
which read as the bar sliding around. It is painted where it sits
and scrolls away like any other element. */
z-index: 4;
margin: 0 -14px;
padding: 7px 14px 8px;
@@ -1699,6 +1944,10 @@
/* Block-level, so it tracks the panel width instead of shrinking to
its own content at every size. */
width: 100%;
/* A <button> centres its text. The stats row underneath is a flex row
and ignores that, so the artist's name alone sat centred over the
figures — reading as if it had drifted off to the right. */
text-align: left;
gap: 11px;
align-items: center;
padding: 10px;
@@ -1807,25 +2056,37 @@
background: rgba(0, 0, 0, 0.3);
}
/* Fans-also-like avatars, sized for the panel rather than the artist page. */
/* Fans also like: pills sized to the name, wrapping like tags.
(V4 of research/nowplaying-fans-waveform/mockup.html.)
It replaced a fixed row of four circles spaced `space-evenly`, which had
no good width: every name truncated to "The Sma…" however wide the panel
got, because the circles stayed 64px and only the GAPS between them grew.
Sized to their content and wrapped, the pills use whatever width there
is at any panel size, and carry twice the artists in less height. */
.av2 {
width: 64px;
height: 64px;
width: 22px;
height: 22px;
border-radius: 50%;
overflow: hidden;
flex: none;
background: var(--bg-highlight);
}
.av2 img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform var(--art-motion);
}
.fan:hover .av2 { transform: scale(var(--art-grow)); }
.av2 img { width: 100%; height: 100%; object-fit: cover; }
.fan:hover .av2 img { transform: scale(var(--art-grow)); }
.fn {
font-size: 10.5px;
margin-top: 5px;
text-align: center;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* The album page's credits block, verbatim, so a record reads the same in
@@ -1993,7 +2254,11 @@
}
/* min-width:0 lets both columns stay an equal 1fr; without it a long
(nowrap) title forces its column wider and shrinks the other. */
.mini { text-align: left; min-width: 0; }
/* align-self, because a <button> centres its content vertically: in a row
where one card's title wrapped to four lines, the short card's whole
stack — artwork included — was centred against it and sat visibly lower.
Not stretching the card removes the taller box that centring needs. */
.mini { text-align: left; min-width: 0; align-self: start; }
.mini .art {
width: 100%;
aspect-ratio: 1;
@@ -2002,7 +2267,12 @@
background: var(--bg-highlight);
}
.mini .art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.mini:hover .art { outline: 2px solid var(--accent); outline-offset: -2px; }
/* The record's own colour, sampled on hover; the global accent until one
arrives, and for a cover we could not read. */
.mini:hover .art {
outline: 2px solid var(--album-accent, var(--accent));
outline-offset: -2px;
}
.mini .mt {
font-size: 12.5px;
font-weight: 600;
@@ -2011,7 +2281,12 @@
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 6;
-webkit-line-clamp: 2;
/* Two lines ALWAYS — clamped down from six, and reserved even for a
one-word title. The card's height stops depending on its title, so
every row starts at the same offset and the artwork lines up down the
whole grid. The full title is on the button's tooltip. */
min-height: calc(2 * 1.25em);
}
.mini .my { font-size: 11.5px; color: var(--text-muted); text-transform: capitalize; }
@@ -2019,15 +2294,88 @@
which always left one avatar half-cut at the edge). Explicit square
avatars (aspect-ratio inside a flex row gets squished to an oval);
space-between fits all five within the panel with no cutoff. */
.fans { display: flex; justify-content: space-evenly; padding: 4px 0; }
.fan { width: 52px; text-align: center; }
.fan .fn {
font-size: 11px;
margin-top: 7px;
color: var(--text-secondary);
/* ---- the full discography, expanded ----
Dense rows rather than a grid: at the panel's widths a row fits a whole
title where a card clamps it to two lines, and twice as many releases
fit on a screen. E4 of research/nowplaying-releases/dividers.html. */
.rel-sec {
position: sticky;
top: 46px;
z-index: 3;
margin: 14px -14px 4px;
padding: 10px 14px 6px;
background: linear-gradient(var(--bg-base) 74%, transparent);
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 13px;
font-weight: 800;
color: var(--text-primary);
}
.rel-sec .n { font-size: 11px; font-weight: 700; color: var(--text-muted); }
/* Deliberately quieter than the category above it, so the two never read
as the same level of heading. */
.rel-year {
margin: 12px 2px 3px;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.12em;
color: var(--text-muted);
opacity: 0.75;
font-variant-numeric: tabular-nums;
}
.rel-rows { display: flex; flex-direction: column; gap: 2px; }
.rel-row {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
/* min-width:0 the whole way down, so a long title ellipsises instead
of widening the pane into a horizontal scrollbar. */
min-width: 0;
padding: 6px;
border-radius: 6px;
text-align: left;
transition: background 140ms ease;
}
.rel-row:hover { background: rgba(255, 255, 255, 0.06); }
.rel-th {
width: 40px;
height: 40px;
flex: none;
border-radius: 5px;
overflow: hidden;
background: var(--bg-highlight);
}
.rel-th img { width: 100%; height: 100%; object-fit: cover; display: block; }
.rel-t {
min-width: 0;
flex: 1;
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fans { display: flex; flex-wrap: wrap; gap: 7px; }
.fan {
display: flex;
align-items: center;
gap: 7px;
max-width: 100%;
min-width: 0;
padding: 4px 12px 4px 4px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
border: 1px solid var(--border);
text-align: left;
transition: border-color 160ms ease, background 160ms ease;
}
.fan:hover {
background: rgba(0, 0, 0, 0.36);
border-color: color-mix(in srgb, var(--accent-hover) 45%, var(--border));
}
/* discovery: about the artist */