Files
SoulStream/src/views/CatalogDiscography.svelte
T
KeelrLeek 73a677d35c one listener bar, five surfaces: the search page's pill wins and the heat wash is retired
Kyle asked for the popularity bars to match the search page "to keep a
consistent design across the board". They were two designs, and one of them had
quietly drifted into a third.

THE SEARCH PAGE'S PILL IS NOW THE APP-WIDE DESIGN: a visible
rgba(255,255,255,0.07) track, a solid var(--accent) fill scaled by the shared
curve, the full comma-formatted count standing centred on top, 17px tall with
a 4px radius. The album page, the artist page's open-in-place album panel, the
discography's record sections and the artist page's Popular list all draw it.

WHAT WENT AWAY IS THE HEAT WASH. Those three surfaces drove the fill's COLOUR
and ALPHA from the count (--t on the row, alpha ramping 0.30-0.85 via a double
color-mix, digits ramping with it), so length and colour told one story twice
and every page read as a different design. Length is the only encoding now, and
listenerHeat is deleted from listenerBars.js — nothing computes a temperature
any more. The dark-glass CONTRACT's deliberate exclusion for these bars ("their
alpha IS the data encoding") is moot and marked so.

THE DISCOGRAPHY HAD DRIFTED FURTHEST and is the reason this mattered: it
computed peak/heat inline with a LINEAR share, a 6% floor and abbreviated
labels ("12K"), against everyone else's COUNT_GAMMA 0.42 curve, browser-measured
max-content floor and full figures. It now imports listenerBars.js like the
rest, so no two surfaces can disagree about how big a hit is — the reason the
module exists.

THE POPULAR LIST joins them: it was plain right-aligned muted text, deliberately
not accent so the row's own `.now` accent stayed distinct (the ♫ rank and title
still carry that). Its column widened 62px -> 96px, the pill's width, and its
peak is taken over the WHOLE list rather than the visible five so the bars do
not re-scale on Show more.

Kept throughout: unknown counts render an em dash with NO bar (a floor-width
pill behind a dash reads as a measured tiny value), and a column drops entirely
when Last.fm knew nothing.

Verified live against the running app — computed styles on the album and artist
pages match the search bar value-for-value. 98/98 tests.
2026-08-31 05:53:01 -05:00

1677 lines
60 KiB
Svelte

<script>
// The whole catalogue on one page: every release the artist page files,
// rendered as a stack of album-page heroes with their tracklists.
//
// Reached from the artist page's Discography section ("Show all"), and only
// from there — see `catalog.discoTabs` below for why that matters.
//
// The shapes here are the ones approved on research/discography (2026-08-04):
// the search page's top-result card as the header (compact), the album
// page's hero at --cover-scale 0.45 for each record, per-record accent, the
// year rail and the tab summary. The tab row does not stick, and there is
// no Playing badge.
import Icon from "../components/Icon.svelte";
import {
catalog,
openCatalogAlbum,
openCatalogArtist,
streamCatalogAlbum,
saveCatalogAlbum,
player,
} from "../lib/state.svelte.js";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import { editionLabel } from "../lib/editions.js";
import { accentFromImage } from "../lib/color.js";
import { catalogAlbum } from "../lib/api.js";
import { listenerPeak, listenerShare } from "../lib/listenerBars.js";
/* ---------------------------------------------------------- the releases
* NOT re-filed here. `catalog.discoTabs` is the artist page's OWN computed
* tab list, published by it as it derives it — the same array object, not a
* second implementation of the same rules.
*
* That is deliberate and it is the whole design of this page. The filing
* depends on `descHints`, which the artist page fills from a background
* description harvest that runs while you are looking at it, and on MB type
* hints from the deferred identity pass. A classifier re-run here would read
* neither and would quietly file some releases differently than the page
* that linked to it. Taking the finished list means the two can never
* disagree, because there is only one of them.
*/
const artist = $derived(catalog.artistPage?.artist ?? null);
const tabs = $derived(
catalog.discoTabs && catalog.discoTabs.artistId === artist?.id
? catalog.discoTabs.tabs
: [],
);
// "All" is the landing tab and holds every release — built by CONCATENATING
// the per-category families, never by re-folding the whole set: a single
// sharing an album's base title (Pond has "Terrestrials" as both a 2026
// album and a 2026 single) would fold into it as a phantom edition.
const allTabs = $derived(
tabs.length
? [{ key: "all", label: "All", items: tabs.flatMap((t) => t.items) }, ...tabs]
: [],
);
let activeTab = $state("all");
const tab = $derived(allTabs.find((t) => t.key === activeTab) ?? allTabs[0] ?? null);
/* ------------------------------------------------------------- the sort
* Spotify's arrangement, the app's parts: re-picking the active sort flips
* its direction, picking the other resets to its natural one (dates newest
* first, names A→Z).
*/
let sortBy = $state("date");
let sortDesc = $state(true);
let menuOpen = $state(false);
const year = (r) => (r?.release_date ?? "").slice(0, 4);
const items = $derived.by(() => {
const list = [...(tab?.items ?? [])];
list.sort((a, b) =>
sortBy === "date"
? (a.release_date ?? "").localeCompare(b.release_date ?? "")
: (a.title ?? "").localeCompare(b.title ?? ""),
);
return sortDesc ? list.reverse() : list;
});
function pickSort(which) {
if (sortBy === which) sortDesc = !sortDesc;
else {
sortBy = which;
sortDesc = which === "date";
}
menuOpen = false;
}
const known = $derived((items ?? []).reduce((a, r) => a + (r.track_count || 0), 0));
const someUnknown = $derived((items ?? []).some((r) => r.track_count == null));
/* --------------------------------------------------------- the tracklists
* The artist payload ships the release list free but carries no tracks
* (`hasTracksInline: false`), so every record costs one `catalog_album`
* call. Firing 32 of them on arrival is what the mock's un-fetched state
* exists to avoid: a record fetches when it scrolls near, and until it does
* it shows its header over a skeleton.
*/
let tracksById = $state({});
const inFlight = new Set();
async function fetchTracks(id) {
if (id == null || tracksById[id] || inFlight.has(id)) return;
inFlight.add(id);
try {
const payload = await catalogAlbum(id);
const rows = payload?.tracks ?? [];
tracksById = { ...tracksById, [id]: rows };
} catch {
// A record whose tracklist will not load keeps its header and drops
// the skeleton — an empty stack of shimmer bars that never resolve
// reads as a hung page.
tracksById = { ...tracksById, [id]: [] };
} finally {
inFlight.delete(id);
}
}
/** Fetch the tracklist, and sample the accent, when the section comes
* within a screen of the viewport. Both are per-record costs and neither
* is worth paying for a record nobody has scrolled to yet. */
function nearView(node, rec) {
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
fetchTracks(rec.id);
queueAccent(rec.cover);
io.disconnect();
}
},
{ root: node.closest(".content") ?? null, rootMargin: "600px 0px" },
);
io.observe(node);
return { destroy: () => io.disconnect() };
}
/* ------------------------------------------------------- per-record accent
* Each record tints its own pills, meta and heat bars from its OWN cover —
* what the album page does with its browse accent, applied per section.
* One at a time, in scroll order, and only for records actually on screen.
*/
let accents = $state({});
const accentQueue = [];
let accentBusy = false;
function queueAccent(cover) {
if (!cover || accents[cover] || accentQueue.includes(cover)) return;
accentQueue.push(cover);
void runAccents();
}
async function runAccents() {
if (accentBusy) return;
accentBusy = true;
try {
while (accentQueue.length) {
const cover = accentQueue.shift();
if (accents[cover]) continue;
try {
const colors = await accentFromImage(cover);
if (colors) accents = { ...accents, [cover]: colors };
} catch {
/* a cover that will not sample keeps the global accent */
}
}
} finally {
accentBusy = false;
}
}
const accentStyle = (cover) => {
const c = accents[cover];
return c ? `--accent: ${c.accent}; --accent-hover: ${c.hover};` : undefined;
};
/* ------------------------------------------------------------- the entrance
* Not a new gesture: this is CatalogAlbum's edition "deal" applied to a
* list. Its easing is the app's, parts animate to their OWN resting opacity
* rather than to 1, and the resting value is read BEFORE the inline hide —
* a WAAPI animation is PENDING until the next frame resolves its start time,
* and a pending animation applies nothing, so `fill` never covers the first
* paint and every part would render once at full opacity before snapping to
* 0. An inline style lands in the same recalc, which closes it.
*
* The four numbers are the ones approved on research/discography (W1).
*/
const ENTER_MS = 300;
const ENTER_STEP = 55; // between records
const ENTER_LIFT = 28;
const ENTER_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
/* The ladder cannot keep climbing: at 55ms the 32nd record would arrive 1.8s
after the first. Everything below the fold is invisible on arrival anyway,
so only this many rows are dealt and the rest are simply present. */
const ENTER_ROWS = 8;
/** The album page's opening delays, for this page's chrome. The rail FADES
* — it is sticky and its column centres itself with translateY(-50%), and
* a transform does not add to a resting one, it replaces it. Dealing it
* with a lift dropped the column 128px and snapped it back on release. */
const ENTER_CHROME = [
{ sel: ".shero", delay: 0, lift: true },
{ sel: ".tabrow", delay: 40, lift: true },
{ sel: ".tab-sum", delay: 60, lift: true },
{ sel: ".year-rail", delay: 80, lift: false },
];
const reducedMotion = () =>
typeof matchMedia === "function"
&& matchMedia("(prefers-reduced-motion: reduce)").matches;
function dealOne(el, delay, lift) {
const cs = getComputedStyle(el);
const rest = cs.opacity || "1";
// Never lift a box that already carries a transform — see the rail note.
const canLift = lift && cs.transform === "none";
el.style.opacity = "0";
// Promoted for the duration only. Everything below the header sits on a
// fractional y (the compact card is 147.52px tall, so the column
// inherits the .52), and transform-animating text at a fractional offset
// re-rasterizes it every frame — visible as a shimmer. A promoted layer
// rasterizes once and only composites after. Released at the end: a
// permanent layer per record is not free.
el.style.willChange = canLift ? "transform, opacity" : "opacity";
const anim = el.animate(
[
{ opacity: 0, transform: canLift ? `translateY(${ENTER_LIFT}px)` : "none" },
{ opacity: rest, transform: "none" },
],
{ duration: ENTER_MS, delay, easing: ENTER_EASE, fill: "both" },
);
const release = () => {
el.style.opacity = "";
el.style.willChange = "";
anim.cancel();
};
anim.finished.then(release, release);
}
function playEntrance() {
if (!pageEl || reducedMotion()) return;
for (const { sel, delay, lift } of ENTER_CHROME) {
const el = pageEl.querySelector(sel);
if (el) dealOne(el, delay, lift);
}
const recs = pageEl.querySelectorAll(".rec");
for (let i = 0; i < Math.min(recs.length, ENTER_ROWS); i++) {
dealOne(recs[i], 80 + i * ENTER_STEP, true);
}
}
/* A fresh arrival starts at the top; coming BACK returns the reader to where
* they were. The pane outlives the view swap, so without this the page began
* wherever the last one had been left — and the entry point makes that
* certain rather than occasional: "Show all" lives in the artist page's
* Discography section, which is most of the way down it, so every arrival
* inherited a scrollTop of roughly a screenful or two and opened mid-page.
*
* `consumeScroll()` returning null IS the signal for "fresh arrival"; a
* number only comes from the arrows or a trail jump, and that one is
* re-applied rather than assigned because this page loads in stages — its
* records are skeletons until their tracklists land, so the page is short
* for the first frames and a single assignment would clamp to that height.
*/
let scrolledFor = null;
$effect(() => {
const id = artist?.id ?? null;
if (id == null || id === scrolledFor) return;
scrolledFor = id;
const el = document.querySelector(".content");
if (!el) return;
const target = consumeScroll();
if (target == null) {
el.scrollTop = 0;
// The same null that means "fresh arrival" for the scroll decides
// the entrance: coming BACK is a return to something already read,
// and re-dealing it there would fight the scroll restore for the
// same pixels.
playEntrance();
} else {
restoreScroll(el, target);
}
});
// The header card tints from the ARTIST's own portrait, by the same rule the
// records follow. Left alone it inherits the global accent, which tracks
// whatever is PLAYING — so opening a discography while something unrelated
// was on gave the page a header in one artist's colour over records in
// another's (measured: a blue-violet header card above orange records).
$effect(() => {
if (artist?.picture) queueAccent(artist.picture);
});
/* ------------------------------------------------- the scroll-led ambience
* The album page's banner recipe, pinned behind the glass and crossfaded to
* whichever record the scroll sits over. Two layers so the outgoing cover is
* still fading while the incoming one rises.
*/
let bannerA = $state(null);
let bannerB = $state(null);
let bannerFront = $state("a");
let activeYear = $state("");
let pane = null;
let pageEl = $state(null);
function setBanner(cover) {
if (!cover) return;
const front = bannerFront === "a" ? bannerA : bannerB;
if (front === cover) return;
if (bannerFront === "a") {
bannerB = cover;
bannerFront = "b";
} else {
bannerA = cover;
bannerFront = "a";
}
}
let scrollRaf = 0;
function onScroll() {
if (scrollRaf) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0;
updateActive();
});
}
function updateActive() {
if (!pane || !pageEl) return;
const sections = pageEl.querySelectorAll("[data-rid]");
if (!sections.length) return;
const mark = pane.getBoundingClientRect().top + 200;
let cur = sections[0];
for (const el of sections) {
if (el.getBoundingClientRect().top <= mark) cur = el;
else break;
}
setBanner(cur.dataset.cover || "");
activeYear = cur.dataset.year || "";
}
/** The rail centres on the pane, and CSS cannot read a scroll container's
* height — a percentage top offset on a sticky box resolves against the
* containing block, which here is the whole page. So the pane publishes it.
* Set once on attach as well as from the observer: an observer's first
* callback arrives with the rendering loop, which is one frame of the rail
* in the wrong place. */
function paneWatch(node) {
pane = node.closest(".content");
if (!pane) return {};
const publish = () => pane.style.setProperty("--pane-h", pane.clientHeight + "px");
publish();
const ro = new ResizeObserver(publish);
ro.observe(pane);
pane.addEventListener("scroll", onScroll, { passive: true });
updateActive();
return {
destroy() {
ro.disconnect();
pane.removeEventListener("scroll", onScroll);
pane.style.removeProperty("--pane-h");
},
};
}
const years = $derived([...new Set((items ?? []).map(year).filter(Boolean))]);
function jumpToYear(y) {
const target = pageEl?.querySelector(`[data-year="${y}"]`);
if (target && pane) {
pane.scrollTo({ top: target.offsetTop - 120, behavior: "smooth" });
}
}
const fmtCount = (n) =>
n == null
? "—"
: n >= 1e6
? (n / 1e6).toFixed(1) + "M"
: n >= 1000
? Math.round(n / 1000) + "K"
: String(n);
/** The listener bar prints the full figure like every other tracklist —
the abbreviated fmtCount above stays for the fans chip. */
const fmtCountFull = (n) => (n == null ? "—" : n.toLocaleString());
const fmtDur = (s) =>
s == null ? "—" : Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
const runtimeOf = (rows) => {
const secs = (rows ?? []).reduce((a, x) => a + (x.duration_secs || 0), 0);
if (!secs) return "";
return secs >= 3600
? Math.floor(secs / 3600) + " hr " + Math.round((secs % 3600) / 60) + " min"
: Math.round(secs / 60) + " min";
};
</script>
<svelte:window onclick={() => (menuOpen = false)} />
<div class="disco-page" bind:this={pageEl} use:paneWatch>
<!-- The ambience, pinned behind the glass. FIRST child and margin-free: the
glass bar has already cancelled the pane's top padding for whatever
follows it, so this box starts at the very top of the pane, and the
sticky offset holds it there. `.disco-page` therefore carries NO top
padding — the header's own margin supplies the inset instead, which is
what keeps this box from having to fight it. -->
<div class="pane-banner" aria-hidden="true">
<div class="clip">
{#if bannerA}<img src={bannerA} alt="" class:on={bannerFront === "a"} />{/if}
{#if bannerB}<img src={bannerB} alt="" class:on={bannerFront === "b"} />{/if}
<div class="bn-shade"></div>
</div>
</div>
{#if !artist || !allTabs.length}
<p class="hint">This artist's discography isn't loaded. Open the artist page first.</p>
{:else}
<!-- The header: the search page's top-result card, compact. -->
<div class="shero compact" style={accentStyle(artist.picture)}>
<div class="hero">
{#if artist.picture}
<div class="hero-bg" style="background-image:url('{artist.picture}')"></div>
{/if}
<div class="hero-tint"></div>
<div class="hero-grain"></div>
<div class="hero-in">
<button
class="card"
title="Open artist"
onclick={() => openCatalogArtist(artist.id, artist)}
>
{#if artist.picture}
<img src={artist.picture} alt="" draggable="false" />
{:else}
<span class="placeholder"><Icon name="music" size={34} /></span>
{/if}
</button>
<div class="hero-text">
<div class="kicker">Discography</div>
<div class="hero-name">{artist.name}</div>
<div class="stats">
{#if artist.fans != null}
<span><b>{fmtCount(artist.fans)}</b><i>fans</i></span>
{/if}
<span><b>{items.length}</b><i>releases</i></span>
<span><b>{someUnknown ? "≥" : ""}{known}</b><i>tracks</i></span>
</div>
<div class="actions">
<button
class="btn-primary"
onclick={() => items[0] && streamCatalogAlbum(items[0])}
>
<Icon name="play" size={13} /> Play
</button>
<button
class="btn-ghost"
onclick={() => openCatalogArtist(artist.id, artist)}
>
Open artist
</button>
</div>
</div>
</div>
</div>
</div>
<!-- The tab row: the artist page's own chips, with the sort control at
the right end. Deliberately NOT sticky. -->
<div class="tabrow">
<div class="disco-tabs" role="tablist">
{#each allTabs as t (t.key)}
<button
class="disco-tab"
class:on={activeTab === t.key}
role="tab"
aria-selected={activeTab === t.key}
onclick={() => (activeTab = t.key)}>{t.label}</button
>
{/each}
</div>
<div class="sortwrap">
<button
class="sortbtn"
onclick={(e) => {
e.stopPropagation();
menuOpen = !menuOpen;
}}
>
{sortBy === "date" ? "Release date" : "Name"}
<Icon name="playlist" size={15} />
</button>
{#if menuOpen}
<!-- The click handler exists only to stop the window
listener that closes this from seeing clicks inside it,
so there is nothing for a keyboard handler to do; the
items are real buttons and reachable on their own. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="menu" role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<div class="mgroup">Sort by</div>
<button role="menuitem" onclick={() => pickSort("date")}>
Release date
{#if sortBy === "date"}
<span class="mcheck" class:up={!sortDesc}
><Icon name="chevron-down" size={13} /></span
>
{/if}
</button>
<button role="menuitem" onclick={() => pickSort("name")}>
Name
{#if sortBy === "name"}
<span class="mcheck" class:up={!sortDesc}
><Icon name="chevron-down" size={13} /></span
>
{/if}
</button>
</div>
{/if}
</div>
</div>
<div class="tab-sum">
{items.length} releases · {someUnknown ? "≥" : ""}{known} tracks
</div>
<!-- The year rail. Sticky and zero-height, and FIRST among the things
that follow the tab row for a reason: sticky only ever pushes a box
DOWN toward its pin, so a rail rendered further down sits wherever
flow puts it until you scroll past. From here the pin always wins
and it is centred from the first frame. -->
{#if years.length > 2}
<div class="year-rail">
<div class="yr-col">
{#each years as y (y)}
<button class:on={activeYear === y} onclick={() => jumpToYear(y)}>{y}</button>
{/each}
</div>
</div>
{/if}
{#each items as r (r.familyKey ?? r.id)}
{@const rows = tracksById[r.id]}
{@const eds = r.editions && r.editions.length > 1 ? r.editions : null}
<section
class="rec"
data-rid={r.id}
data-year={year(r)}
data-cover={r.cover}
style={accentStyle(r.cover)}
use:nearView={r}
>
<header class="hero">
<div class="identity">
<button class="cover" title="Open album" onclick={() => openCatalogAlbum(r.id)}>
{#if r.cover}<img src={r.cover} alt="" loading="lazy" draggable="false" />{/if}
<span class="mark" aria-hidden="true"><Icon name="expand" size={22} /></span>
</button>
<div class="copy">
<span class="kind">{(r.record_type ?? "album").toUpperCase()}</span>
<h1 class:long={(r.title ?? "").length > 22}>{r.title}</h1>
<p class="meta">
<button class="who" onclick={() => openCatalogArtist(artist.id, artist)}>
{#if artist.picture}<img src={artist.picture} alt="" draggable="false" />{/if}
{artist.name}
</button>
{#if year(r)}<span class="dot">·</span> {year(r)}{/if}
{#if rows?.length ?? r.track_count}
<span class="dot">·</span>
{rows?.length ?? r.track_count} tracks
{/if}
{#if runtimeOf(rows)}<span class="dot">·</span> {runtimeOf(rows)}{/if}
</p>
<div class="actions">
<button class="pill" onclick={() => streamCatalogAlbum(r)}>
<Icon name="play" size={12} /> Play
</button>
<button class="pill ghost" onclick={() => saveCatalogAlbum(r)}>
<Icon name="download" size={13} /> Save
</button>
<button
class="iconbtn"
class:on={player.shuffle}
title="Shuffle this record"
aria-label="Shuffle this record"
onclick={() => streamCatalogAlbum(r)}
>
<Icon name="shuffle" size={17} />
</button>
</div>
</div>
</div>
</header>
{#if eds}
<div class="eds">
<span class="lab">Editions</span>
<div class="edrow">
{#each eds as e, i (e.id)}
<button
class="ed"
class:on={i === 0}
onclick={() => openCatalogAlbum(e.id)}
>
<img class="ed-art" src={e.cover} alt="" loading="lazy" />
<span class="ed-copy">
<span class="n">{i === 0 ? "Original" : editionLabel(e, r)}</span>
<span class="y">{year(e)}</span>
</span>
</button>
{/each}
</div>
</div>
{/if}
{#if rows?.length}
{@const peak = listenerPeak(rows)}
{@const hasCount = rows.some((x) => x.listeners != null)}
<div class="tracklist">
<div class="tcols" class:has-count={hasCount}>
<span class="tno">#</span><span>Title</span>
{#if hasCount}<span>Listeners</span>{/if}
<span class="tdur-head"><Icon name="clock" size={13} /></span>
</div>
{#each rows as t (t.id)}
<div class="trow" class:has-count={hasCount}>
<span class="tno">{t.track_no ?? "·"}</span>
<span class="ttitle">{t.title}</span>
{#if hasCount}
<span
class="tcount"
class:empty={!t.listeners}
title={t.listeners == null
? "No Last.fm listener count for this track"
: `${t.listeners.toLocaleString()} Last.fm listeners`}
>
<i style="transform:scaleX({listenerShare(t.listeners, peak) / 100})"></i>
<span class="tcount-val">{fmtCountFull(t.listeners)}</span>
</span>
{/if}
<span class="tdur">{fmtDur(t.duration_secs)}</span>
<span class="rowbtns">
<button class="play" title="Play" onclick={() => streamCatalogAlbum(r)}>
<Icon name="play" size={13} />
</button>
</span>
</div>
{/each}
</div>
{:else if rows}
<p class="skelnote">No tracklist for this record.</p>
{:else}
<div class="skel" aria-hidden="true">
{#each Array(Math.min(8, r.track_count || 5)) as _, i}
<i style="width:{[92, 74, 86, 63, 80, 70, 88, 66][i % 8]}%"></i>
{/each}
</div>
{/if}
</section>
{/each}
{/if}
</div>
<style>
/* ORDER MATTERS. The app's own rules come FIRST and this page's
* overrides after them. Several are the SAME selector at the same
* specificity — `.rec .hero` sets --cover-scale in both — so nothing but
* the cascade separates them, and with this block last the album page's
* 1.175 beat the 0.45 that makes a record section a record section.
* Measured before the fix: 282px artwork where 108px was intended.
*/
/* ================= LIFTED FROM THE APP, NOT RETYPED =================
* Extracted from the components named below by the same rule-matcher the
* research/discography generator uses, then scoped: the album page owns a
* .hero, .actions and .stats and so does the search page, and unscoped
* either would restyle the other. Re-extract after touching those files.
*/
/* ===== src/views/CatalogAlbum.svelte — the record section ===== */
.rec .hero {
/* The one number to tune. 1 = a 240px cover; everything in the hero is
sized against it, so the block scales as one piece. */
--cover-scale: 1.175; /* 282px */
--cover: calc(240px * var(--cover-scale));
position: relative;
margin: 0 -28px;
padding: 0 28px;
/* The identity block is bottom-anchored, so the hero's height IS the
gap above it: 22px of it is under the glass bar's stuck bottom, the
block itself is --cover + 22px of foot, and the remaining 24px is
the breathing room approved on the mock. Deriving it keeps that 24px
exact at any artwork size — the two used to be independent numbers
that drifted the moment either moved. */
min-height: calc(var(--cover) + 68px);
display: flex;
align-items: flex-end;
isolation: isolate;
}
.rec .identity {
display: flex;
align-items: stretch;
gap: calc(22px * var(--cover-scale));
/* Fixed: the hero's derived height counts on this foot. */
padding-bottom: 22px;
width: 100%;
}
.rec .cover {
position: relative;
display: block;
width: var(--cover);
height: var(--cover);
padding: 0;
border-radius: 10px;
overflow: hidden;
/* The seed's copy underneath, so swapping to the master resolution is
an upgrade rather than a gap. Painted over the moment it decodes. */
background-size: cover;
background-position: center;
flex: none;
cursor: pointer;
box-shadow: 0 22px 46px -18px rgba(0, 0, 0, 0.8);
transition:
transform var(--art-motion),
box-shadow var(--art-motion);
}
.rec .cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.rec .cover:hover,
.rec .cover:focus-visible {
transform: scale(var(--art-grow));
box-shadow: 0 30px 62px -20px rgba(0, 0, 0, 0.86);
}
.rec .cover::after {
content: "";
position: absolute;
inset: 0;
background: var(--art-wash);
opacity: 0;
transition: opacity var(--art-motion);
pointer-events: none;
}
.rec .cover:hover::after,
.rec .cover:focus-visible::after {
opacity: 1;
}
.rec .cover:hover .mark,
.rec .cover:focus-visible .mark {
opacity: 1;
transform: none;
}
.rec .mark {
position: absolute;
inset: 0;
margin: auto;
display: grid;
place-items: center;
width: calc(54px * var(--cover-scale));
height: calc(54px * var(--cover-scale));
border-radius: 50%;
background: rgba(12, 12, 14, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: #fff;
opacity: 0;
transform: scale(0.82);
transition:
opacity var(--art-motion),
transform var(--art-motion);
}
.rec .copy {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
font-size: calc(14px * var(--cover-scale));
gap: 1.8em;
}
.rec .kind {
font-size: 0.714em;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
}
.rec .copy h1 {
font-size: 3em;
font-weight: 800;
line-height: 1.06;
letter-spacing: -0.015em;
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
}
.rec .copy h1.long {
font-size: 2.286em;
}
.rec .meta {
display: flex;
align-items: center;
gap: 0.5em;
flex-wrap: wrap;
font-size: 0.893em;
color: rgba(255, 255, 255, 0.82);
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.5);
}
.rec .meta .dot {
opacity: 0.5;
}
.rec .who {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 800;
color: #fff;
}
.rec .who img {
width: 1.76em;
height: 1.76em;
border-radius: 50%;
object-fit: cover;
flex: none;
}
.rec .who:hover {
text-decoration: underline;
}
.rec .actions {
display: flex;
align-items: center;
gap: 0.714em;
}
.rec .iconbtn {
position: relative;
display: grid;
place-items: center;
width: 2em;
height: 2em;
border-radius: 50%;
color: rgba(255, 255, 255, 0.85);
}
.rec .iconbtn:hover:not(:disabled) {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
.rec .iconbtn.on {
color: var(--accent-hover);
}
.rec .iconbtn:disabled {
color: rgba(255, 255, 255, 0.28);
cursor: default;
}
.rec .pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: var(--accent);
color: #08080a;
font-size: 0.786em;
font-weight: 800;
padding: 0.571em 1.286em;
border-radius: 999px;
white-space: nowrap;
}
.rec .pill:hover {
background: var(--accent-hover);
}
.rec .pill.ghost {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.rec .pill.ghost:hover {
background: rgba(255, 255, 255, 0.16);
}
.rec .pill:disabled {
background: var(--bg-highlight);
color: var(--text-muted);
cursor: default;
opacity: 0.6;
}
.rec .pill:disabled:hover {
background: var(--bg-highlight);
}
.rec .eds {
margin: 20px 0 16px;
}
.rec .eds .lab {
display: block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
}
.rec .edrow {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.rec .ed {
display: flex;
align-items: center;
gap: 10px;
width: 216px;
padding: 7px 8px;
border-radius: 6px;
border: 1px solid transparent;
text-align: left;
}
.rec .ed:hover {
background: rgba(255, 255, 255, 0.06);
}
.rec .ed.on {
background: color-mix(in srgb, var(--accent) 15%, transparent);
border-color: var(--accent);
cursor: default;
}
.rec .ed-art {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
flex: none;
}
.rec .ed-copy {
min-width: 0;
flex: 1;
}
.rec .ed-copy .n {
display: block;
font-size: 12.5px;
font-weight: 700;
line-height: 1.2;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rec .ed-copy .y {
display: block;
margin-top: 1px;
font-size: 11.5px;
color: var(--text-muted);
}
.rec .tracklist {
margin-top: 20px;
display: flex;
flex-direction: column;
}
.rec .tcols,
.rec .trow {
display: grid;
grid-template-columns: 26px minmax(0, 1fr) 48px;
align-items: center;
gap: 12px;
}
.rec .tcols.has-count,
.rec .trow.has-count {
grid-template-columns: 26px minmax(0, 1fr) 168px 48px;
}
.rec .tcols {
padding: 0 10px 7px;
margin-bottom: 3px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
}
.rec .tdur-head {
display: flex;
justify-content: flex-end;
}
.rec .trow {
display: grid;
grid-template-columns: 26px minmax(0, 1fr) 48px;
align-items: center;
gap: 12px;
}
.rec .trow.has-count {
grid-template-columns: 26px minmax(0, 1fr) 168px 48px;
}
.rec .trow {
position: relative;
padding: 7px 10px;
border-radius: 6px;
font-size: 13.5px;
cursor: pointer;
}
.rec .trow:hover {
background: var(--bg-highlight);
}
.rec .trow:hover .tdur,
.rec .trow:focus-within .tdur {
opacity: 0;
}
.rec .trow:hover .rowbtns,
.rec .trow:focus-within .rowbtns {
display: flex;
}
.rec .tno {
width: 26px;
text-align: center;
color: var(--text-muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.rec .ttitle {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Listener weight, the app-wide design: the search page's pill — a visible
track, a solid accent fill scaled by the shared curve, the number
standing centred on top (src/lib/listenerBars.js). Length is the ONLY
encoding: the old heat wash here drove colour AND alpha from the count
(--t on the row), which made every page's bars read as a different
design — retired for the one pill (Kyle, 2026-08-30). Keep these rules
in step with .lbar in CatalogSearch.svelte — one dial, four files.
align-self centres the 17px pill in the taller grid row. */
.rec .tcount {
position: relative;
height: 17px;
align-self: center;
border-radius: 4px;
overflow: hidden;
background: rgba(255, 255, 255, 0.07);
}
.rec .tcount i {
position: absolute;
inset: 0;
border-radius: 4px;
background: var(--accent);
transform-origin: left;
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
/* A track Last.fm has no count for gets the dash and no paint at all — a
floor-width bar behind an em dash would read as a measured tiny value. */
.rec .tcount.empty i {
display: none;
}
.rec .tcount-val {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 10.5px;
font-weight: 800;
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.65);
}
.rec .tdur {
text-align: right;
color: var(--text-muted);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
.rec .rowbtns {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: none;
gap: 2px;
}
.rec .rowbtns button {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: var(--text-secondary);
}
.rec .rowbtns button:hover {
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.08);
}
.rec .rowbtns button:disabled {
color: var(--text-muted);
opacity: 0.4;
cursor: default;
}
.rec .rowbtns .play {
color: var(--accent-hover);
}
/* ===== src/views/CatalogSearch.svelte — the page header card ===== */
.shero .hero {
position: relative;
overflow: hidden;
border-radius: 18px;
min-height: 230px;
display: flex;
align-items: center;
padding: 22px 24px;
box-shadow: 0 22px 54px rgba(0, 0, 0, 0.5);
}
.shero .hero-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
filter: blur(30px) saturate(1.6) brightness(0.5);
/* Overscaled so the blur's soft edge never reaches the card's border. */
transform: scale(1.25);
}
.shero .hero-tint {
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
rgba(8, 8, 10, 0.9) 28%,
rgba(8, 8, 10, 0.4) 72%,
rgba(8, 8, 10, 0.78)
);
}
.shero .hero-grain {
position: absolute;
inset: 0;
opacity: 0.055;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>");
}
.shero .hero-in {
position: relative;
display: flex;
align-items: center;
gap: 24px;
width: 100%;
}
.shero .card {
flex: none;
display: block;
border-radius: 12px;
overflow: hidden;
width: 150px;
height: 150px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.6);
transition: transform var(--art-motion);
}
.shero .card:hover {
transform: scale(1.02);
}
.shero .card img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.shero .hero-text {
min-width: 0;
}
.shero .kicker {
font-size: 10px;
font-weight: 900;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--accent);
}
.shero .hero-name {
font-weight: 900;
letter-spacing: -1.2px;
line-height: 1.02;
font-size: 34px;
}
.shero .stats {
display: flex;
gap: 15px;
margin-top: 8px;
}
.shero .stats b {
font-size: 14px;
font-weight: 900;
}
.shero .stats i {
font-style: normal;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.1em;
color: var(--text-secondary);
text-transform: uppercase;
margin-left: 4px;
}
.shero .actions {
display: flex;
gap: 8px;
margin-top: 13px;
flex-wrap: wrap;
}
.shero .btn-primary {
display: inline-flex;
align-items: center;
gap: 7px;
height: 34px;
padding: 0 18px;
border-radius: 99px;
font-size: 12.5px;
font-weight: 800;
background: var(--accent);
color: #08080a;
transition: transform var(--art-motion);
}
.shero .btn-primary:hover {
transform: scale(1.04);
}
.shero .btn-ghost {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 15px;
border-radius: 99px;
font-size: 12px;
font-weight: 800;
border: 1px solid rgba(255, 255, 255, 0.28);
transition: 0.18s;
}
.shero .btn-ghost:hover {
border-color: #fff;
background: rgba(255, 255, 255, 0.08);
}
/* ===== src/views/CatalogArtist.svelte — the tab chips ===== */
.disco-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 18px;
}
.disco-tab {
font-family: inherit;
font-size: 13px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
padding: 8px 15px;
border-radius: 8px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.disco-tab:hover {
background: rgba(255, 255, 255, 0.09);
color: var(--text-primary);
}
.disco-tab:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.disco-tab.on {
color: #fff;
background: color-mix(in srgb, var(--accent) 34%, rgba(10, 10, 12, 0.5));
border-color: color-mix(in srgb, var(--accent) 60%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
}
/* ===== src/components/PlayerBar.svelte — the sort popover ===== */
.menu {
position: absolute;
bottom: 30px;
right: -6px;
z-index: 40;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 186px;
padding: 7px;
border-radius: 9px;
background: #0d0d0d;
border: 1px solid var(--border);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.7);
}
.menu button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
}
.menu button:hover:not(:disabled) {
background: var(--bg-highlight);
color: var(--text-primary);
}
.menu button:disabled {
opacity: 0.45;
cursor: default;
}
/* ============ THIS PAGE'S OWN — everything above is the app ===============
*
* A record section IS the album page's hero, at a smaller artwork scale,
* with two context changes:
*
* 1. --cover-scale drives the ARTWORK down. It is the album hero's own
* one-knob scaler, so the whole block scales as one piece.
* 2. It also drives the TYPE, because `.copy`'s font-size is
* `14px * var(--cover-scale)` — at 0.45 that is 6.3px meta text. So
* `.copy` is re-pinned to the 14px body base and only the title keeps
* a scaled size. Readable type is the whole reason the re-pin exists.
*
* No banner behind a list row, so the hero's banner-driven min-height and
* foot go with it.
*/
.disco-page {
/* NO top padding. The glass bar cancels the pane's for whatever follows
it, and the ambience box below has to start at the pane's very top —
a padding here would push it down behind the bar's own middle. The
header's margin carries the inset instead. */
padding: 0 0 60px;
position: relative;
/* Over the ambience, under the glass (z 40). */
z-index: 1;
}
.hint {
color: var(--text-muted);
font-size: 13px;
margin-top: 78px;
}
.pane-banner {
position: sticky;
/* A sticky box pins to the scroll container's CONTENT box, so top:0
would park this --pane-pad below the pane's visible top — halfway
down the glass bar. The bar cancels the same offset the same way. */
top: calc(-1 * var(--pane-pad));
height: 0;
/* NEGATIVE, like the album page's own `.banner`. At z-index 0 this box
is a POSITIONED descendant, and positioned z-index-0 elements paint
ABOVE in-flow non-positioned ones — so the shade was drawn over the
tab row and the summary line and washed them out, while the header
card escaped only because `.hero` is `position: relative` and later
in the DOM, and the records escaped only by starting below the
shade's 440px. `.disco-page` is a stacking context (position
relative, z-index 1), so -1 stays inside this page and still paints
above the pane's own background. */
z-index: -1;
/* -28px is the pane's own side padding, bled back so the wash runs to
the window edge instead of stopping at the text column. */
margin: 0 -28px;
pointer-events: none;
}
/* The year rail's lane is padding on THIS page, and the wash is a child of
it — so the gutter was shrinking the ambience too, and the blur stopped
34px short of the right edge while reaching the left one exactly. The
rail has no business affecting the artwork behind the glass, so the
banner cancels the lane on top of the pane padding. */
.disco-page:has(.year-rail) .pane-banner {
margin-right: calc(-28px - var(--rail-gutter));
}
.pane-banner .clip {
position: absolute;
inset: 0 0 auto;
height: 440px;
overflow: hidden;
}
.pane-banner img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(42px) saturate(1.45);
transform: scale(1.25);
opacity: 0;
transition: opacity 750ms ease;
}
.pane-banner img.on {
opacity: 0.62;
}
.pane-banner .bn-shade {
position: absolute;
inset: 0;
background: linear-gradient(
180deg,
rgba(10, 10, 12, 0.5) 0%,
rgba(14, 14, 16, 0.42) 42%,
var(--bg-base) 100%
);
}
@media (prefers-reduced-motion: reduce) {
.pane-banner img {
transition-duration: 1ms;
}
}
/* 78px = the pane's own 24px inset plus the 54px that clears the 46px glass
floating over the pane top. Stated here rather than as page padding for
the reason given on `.disco-page`. */
/* PADDING, not margin, and that is the whole point. This is the ambience
box's next sibling, and that box is height:0 with no padding or border —
it SELF-COLLAPSES, so a margin-top here collapses through it and is
applied BEFORE it, carrying the wash 78px down the page with the header.
Measured: the banner pinned at 78 instead of 0, starting halfway down the
glass bar. Padding does not collapse, so the gap lands where it is
written and the box above it stays at the top of the pane.
78 = the pane's own 24px inset plus the 54px that clears the 46px glass
floating over the pane top. */
.shero {
padding-top: 78px;
margin-bottom: 20px;
}
/* The compact card. The search hero's sizes are absolute rather than
em-based off a scale knob, so a smaller one has to restate the four that
carry the size — tint, grain, blur, radius, shadow, kicker, stats and
buttons are all untouched, which is what keeps it the same card. */
.shero.compact .hero {
min-height: 0;
padding: 16px 18px;
}
.shero.compact .hero-in {
gap: 18px;
}
.shero.compact .card {
width: 104px;
height: 104px;
}
.shero.compact .hero-name {
font-size: 26px;
letter-spacing: -0.8px;
}
.tabrow {
display: flex;
align-items: center;
gap: 12px;
}
.tabrow .disco-tabs {
flex: 1 1 auto;
min-width: 0;
}
.sortwrap {
position: relative;
flex: none;
}
.sortbtn {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 700;
color: var(--text-secondary);
padding: 5px 8px;
border-radius: 6px;
}
.sortbtn:hover {
color: var(--text-primary);
background: var(--bg-highlight);
}
/* PlayerBar's popover drops UP from the player; this one drops DOWN from
the corner it is triggered at. */
.sortwrap .menu {
bottom: auto;
top: calc(100% + 8px);
right: 0;
}
/* The app's menu has no section labels yet — this is the one style the real
PlayerBar menu would gain if it ever needs them. */
.menu .mgroup {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
padding: 6px 9px 3px;
}
.menu button .mcheck {
margin-left: auto;
display: inline-flex;
color: var(--accent-hover);
}
/* The icon set has chevron-down but no chevron-up — ascending is the same
glyph turned over, which is also how the mock drew it. */
.menu button .mcheck.up {
transform: rotate(180deg);
}
.tab-sum {
margin-top: 9px;
font-size: 12px;
color: var(--text-muted);
}
/* The year rail — 30-odd releases is a lot of wheel, and the years are the
natural index. It gets a LANE of its own: at a bare negative offset the
column overhangs the page's content box and runs straight through the
tracklist's duration column, so the page gives up a gutter and the column
takes a compensating offset of the same size. The rail does not move; the
content stops short of it.
gutter = the 21.2px overhang + the 12.8px gap wanted = 34px
The column is content-fixed at 37.2px (always four digits plus its own
6px padding), so that arithmetic holds at every pane width. */
.disco-page {
--rail-gutter: 34px;
}
.disco-page:has(.year-rail) {
padding-right: var(--rail-gutter);
}
.year-rail {
position: sticky;
/* Centred on the pane: half its height, less the --pane-pad that a
sticky box is already inset by, less half the column (the translate
below, since the column's height depends on how many years the tab
holds). --pane-h is published by `paneWatch`. */
top: calc(var(--pane-h, 760px) / 2 - var(--pane-pad));
height: 0;
z-index: 4;
}
.year-rail .yr-col {
position: absolute;
right: calc(-1 * (16px + var(--rail-gutter)));
top: 0;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 1px;
align-items: flex-end;
}
.year-rail button {
font-size: 10.5px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
padding: 1px 6px;
border-radius: 4px;
line-height: 1.6;
}
.year-rail button:hover {
color: var(--text-primary);
background: var(--bg-highlight);
}
.year-rail button.on {
color: var(--accent-hover);
}
.rec {
margin-top: 44px;
}
/* The two context changes named at the top of this block. */
.rec .hero {
--cover-scale: 0.45; /* 108px cover */
min-height: 0;
margin: 0;
padding: 0;
}
.rec .identity {
padding-bottom: 0;
}
.rec .copy {
font-size: 14px;
gap: 8px;
justify-content: center;
}
.rec .copy h1 {
font-size: calc(24px + 16px * (var(--cover-scale) - 0.4));
}
.rec .copy h1.long {
font-size: calc(19px + 12px * (var(--cover-scale) - 0.4));
}
/* Flat page background, no banner: the over-artwork text shadows have
nothing to sit on and only soften the glyph edges. */
.rec .kind,
.rec .copy h1,
.rec .meta {
text-shadow: none;
}
.rec .tracklist {
margin-top: 14px;
}
/* The un-fetched state. Every record costs one catalog_album call, so on
arrival most of this page is this — it is part of the design, not a
placeholder. */
.skel {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 22px;
padding: 0 10px;
}
.skel i {
display: block;
height: 12px;
border-radius: 4px;
background: linear-gradient(90deg, #1c1c1c, #262626, #1c1c1c);
background-size: 200% 100%;
animation: skelsheen 1.5s linear infinite;
}
@keyframes skelsheen {
to {
background-position: -200% 0;
}
}
.skelnote {
color: var(--text-muted);
font-size: 11px;
margin-top: 12px;
padding: 0 10px;
opacity: 0.7;
}
@media (prefers-reduced-motion: reduce) {
.skel i {
animation: none;
}
}
/* ── dark glass ────────────────────────────────────────────────────────
Ladder in global.css; solid mode untouched.
The pinned ambience shade lands on opaque --bg-base, which keeps the top
440px of the page solid over the frost. Same answer as the album and
playlist banners: dissolve the whole clipped layer — img and shade
together — so it ends on frost at frost's own value. Repainting the
landing colour thinner cannot work; a translucent tail never matches
frost and draws a line at the box edge (Kyle, 2026-08-26). */
:global(:root.glass) .pane-banner .clip {
mask-image: linear-gradient(to bottom, #000 55%, transparent 98%);
-webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 98%);
}
/* The header card's tint, and ONLY when the artist has no picture — then
.hero-tint is the first element child ({#if artist.picture}) and that
0.9 black gradient is painting onto the pane instead of shading a photo.
Same test and same inversion as Home and CatalogSearch. */
:global(:root.glass) .shero .hero-tint:first-child {
background: linear-gradient(
100deg,
rgba(255, 255, 255, 0.055) 28%,
rgba(255, 255, 255, 0.02) 72%,
rgba(255, 255, 255, 0.045)
);
}
:global(:root.glass) .rec .trow:hover,
:global(:root.glass) .sortbtn:hover,
:global(:root.glass) .year-rail button:hover {
background: var(--glass-hover);
}
/* Unlike the album page, .rec .rowbtns has no fade shim to match — the
cluster is absolutely positioned and simply appears — so the hover
change needs no companion here. */
/* Its backdrop reaches the see-through pane, where the sample flattens to
black (Sidebar.svelte holds the measurement). The white fill already
carries the look. */
:global(:root.glass) .rec .pill.ghost {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:global(:root.glass) .skel i {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.04),
rgba(255, 255, 255, 0.1),
rgba(255, 255, 255, 0.04)
);
background-size: 200% 100%;
}
/* Menus stay solid on purpose — see Playlists.svelte. */
</style>