Files
SoulStream/src/views/CatalogArtist.svelte
T

5816 lines
253 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onDestroy, tick, untrack } from "svelte";
import { fade, fly } from "svelte/transition";
import { quintOut } from "svelte/easing";
import {
catalog,
libraryStore,
ui,
streamSong,
saveSong,
streamCatalogAlbum,
saveCatalogAlbum,
resolver,
player,
getCatalogArt,
getCatalogHeroPreview,
getCachedCatalogHeroArt,
getCatalogHeroArt,
clearCatalogHeroArtCache,
invalidateCatalogArtist,
openCatalogArtist,
artBroken,
markArtBroken,
coverUpgrades,
resolveCovers,
coverLoadError,
leaveCoverUpgradeScope,
libraryHasAlbum,
libraryLocalAlbum,
gridCover,
tinyCover,
albumDisambiguator,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import { accentFromImage } from "../lib/color.js";
import { buildArtistManifest } from "../lib/preview/manifest.js";
import { openPreview } from "../lib/preview/previewSession.svelte.js";
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
import Icon from "../components/Icon.svelte";
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
const nowPlaying = (kind, id) =>
player.status !== "idle" && player.catalogRef[kind] === id;
let saved = $state({});
// A failed save flashes ✗ on the button (the resolver banner up top is
// easy to miss) before returning to the idle icon.
function flagSaveError(key) {
saved[key] = "err";
setTimeout(() => {
if (saved[key] === "err") saved[key] = "";
}, 3000);
}
// The self-titled colour ("Red Album") that identifies which record a
// release belongs to. A folded edition can carry a colour-less title
// ("Weezer (Deluxe Edition)"), so borrow the colour from any sibling in its
// family — otherwise streaming can't reject a wrong-colour peer folder and
// pulls, say, a Green Album track for a Red Album deluxe.
function familyVariant(family) {
if (!family) return "";
for (const rel of [family, ...(family.editions ?? [])]) {
const d = albumDisambiguator(rel?.title ?? "");
if (d) return d;
}
return "";
}
// Stream/save a release using its own fields plus the family colour, so a
// colour-less edition title still resolves to the right album.
function streamRelease(release, family) {
streamCatalogAlbum({ ...release, artist: page.artist.name, variant: familyVariant(family) });
}
async function saveAlbumAction(al, family = null) {
if (saved[`a${al.id}`]) return;
saved[`a${al.id}`] = "…";
if (await saveCatalogAlbum({ ...al, variant: familyVariant(family) })) saved[`a${al.id}`] = "ok";
else flagSaveError(`a${al.id}`);
}
async function saveTrackAction(artist, t) {
if (saved[`t${t.id}`]) return;
saved[`t${t.id}`] = "…";
if (await saveSong(artist, t.title)) saved[`t${t.id}`] = "ok";
else flagSaveError(`t${t.id}`);
}
const page = $derived(catalog.artistPage);
const pageLoadStage = $derived.by(() => {
if (!page) return "empty";
if (!page.art_preview) return "complete";
if (page.albums?.length || page.popular_tracks?.length) return "overview";
if (page.hero_seed_banner || page.banners?.length || page.logos?.length) return "art";
return "shell";
});
// The catalog lands in two stages: the Deezer overview paints the grid at
// ~150ms, then the exhaustive/MusicBrainz pass replaces the release list
// ~1.8-3.6s later — usually a one or two card correction (merged editions,
// a newly classified live/compilation tab). Card motion is armed only
// AFTER that first paint, so the initial grid still appears instantly and
// without animation, and only the late correction animates: cards FLIP to
// their new positions and genuinely new ones fade in, instead of the whole
// grid snapping. Re-armed per artist.
const artistIdentity = $derived(page?.artist?.id ?? null);
let coverScopeArtistId = null;
$effect(() => {
if (artistIdentity != null) coverScopeArtistId = artistIdentity;
});
let gridAnimated = $state(false);
$effect(() => {
void artistIdentity;
gridAnimated = false;
const t = setTimeout(() => { gridAnimated = true; }, 400);
return () => clearTimeout(t);
});
const HERO_ROTATE_MS = 20_000;
const HERO_FADE_MS = 1_400;
// The preview→finished 4K swap gets its own, shorter dissolve: long enough
// to hide the LUT/vintage-fade/grain landing, short enough to feel quiet.
const HERO_FINISH_FADE_MS = 650;
let heroSelection = $state({ artistId: null, fingerprint: "", index: 0 });
let failedBannerSources = $state([]);
let displayedBanner = $state({ source: "", url: null, key: "" });
let previousBanner = $state(null);
let initialBannerPaint = $state(true);
let finishBannerPaint = $state(false);
let bannerTransitionTimer;
let heroLoadGeneration = 0;
let heroLoadInFlight = false;
let heroUpscaler = $state(null);
let gpuFallbackSources = $state([]);
api.getSettings().then(
(settings) => (heroUpscaler = settings?.artist_hero_upscaler ?? "live_gpu"),
() => (heroUpscaler = "live_gpu"),
);
// Recreating the interval on visibility return gives the current photo a
// fresh full dwell instead of rotating immediately.
const documentVisible = $derived(ui.windowActive);
// Metadata supplies only the highest-quality provider tier that produced
// art (Fanart, else TheAudioDB, else hardened Bing). De-duplicate defensively so
// a provider repeating a URL never makes the carousel appear to stall.
const heroSources = $derived.by(() => {
const finalCandidates = page?.banners?.length
? page.banners
: page?.banner
? [page.banner]
: [];
const candidates = finalCandidates.length
? finalCandidates
: page?.hero_seed_banner
? [page.hero_seed_banner]
: [];
return [...new Set(candidates.filter(Boolean))];
});
const heroSetProvisional = $derived(
Boolean(page?.hero_seed_banner && !page?.banners?.length && !page?.banner_custom),
);
function heroSourceKey(source) {
if (!source?.startsWith("data:")) return source ?? "";
// Custom heroes can be tens of MiB as base64. Never concatenate that
// payload into multiple fingerprint/transition strings. A fixed sample
// across the full value distinguishes replacements without rescanning
// or copying the entire source on every staged page update.
let hash = 2166136261;
const stride = Math.max(1, Math.floor(source.length / 128));
for (let i = 0; i < source.length; i += stride) {
hash ^= source.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return `data:${source.length}:${(hash >>> 0).toString(36)}`;
}
const heroFingerprint = $derived(
`${page?.artist.id ?? ""}:${heroSources.map(heroSourceKey).join("|")}`,
);
const selectedHeroSource = $derived(heroSources[heroSelection.index] ?? "");
const selectedUsesGpu = $derived(
heroUpscaler === "live_gpu"
&& selectedHeroSource.startsWith("https://")
&& !gpuFallbackSources.includes(selectedHeroSource),
);
const gpuRendererMounted = $derived(
heroUpscaler === "live_gpu"
&& !page?.banner_custom
// Compile during the staged art search so Bing latency still overlaps
// shader setup, then release the context if the completed artist truly
// has no hero.
&& (Boolean(page?.art_preview) || heroSources.length > 0),
);
// A fresh artist visit starts on a random photo. Rotation then walks the
// quality-ranked list from that point, without repeating the current one.
$effect(() => {
const artistId = page?.artist.id ?? null;
const fingerprint = heroFingerprint;
if (
heroSelection.artistId !== artistId
|| heroSelection.fingerprint !== fingerprint
) {
const artistChanged = heroSelection.artistId !== artistId;
const displayedIndex = artistChanged
? -1
: heroSources.indexOf(untrack(() => displayedBanner.source));
failedBannerSources = [];
gpuFallbackSources = [];
heroSelection = {
artistId,
fingerprint,
// A provisional Bing seed frequently belongs to the final set.
// Keep that already-painted source instead of randomizing and
// decoding another image when exhaustive discovery completes.
index: displayedIndex >= 0
? displayedIndex
: heroSources.length
? Math.floor(Math.random() * heroSources.length)
: 0,
};
if (artistChanged) {
clearTimeout(bannerTransitionTimer);
displayedBanner = { source: "", url: null, key: "" };
previousBanner = null;
initialBannerPaint = true;
finishBannerPaint = false;
}
}
});
function advanceHero(fromSource = displayedBanner.source) {
if (heroSources.length < 2 || heroLoadInFlight) return;
const displayedIndex = heroSources.indexOf(fromSource);
const currentIndex = displayedIndex >= 0 ? displayedIndex : heroSelection.index;
for (let offset = 1; offset <= heroSources.length; offset++) {
const index = (currentIndex + offset) % heroSources.length;
if (!failedBannerSources.includes(heroSources[index])) {
heroSelection = { ...heroSelection, index };
return;
}
}
}
$effect(() => {
const fingerprint = heroFingerprint;
const count = heroSources.length;
const custom = page?.banner_custom;
const visible = documentVisible;
if (!visible || count < 2 || custom) return;
const timer = setInterval(advanceHero, HERO_ROTATE_MS);
return () => clearInterval(timer);
});
function gpuHeroLoading(source) {
if (source === selectedHeroSource) heroLoadInFlight = true;
}
function gpuHeroReady(source, result) {
if (source !== selectedHeroSource || !selectedUsesGpu) return;
const prior = displayedBanner;
previousBanner = prior.url && prior.kind !== "gpu" ? prior : null;
displayedBanner = {
source,
url: `gpu:${heroSourceKey(source)}`,
key: `${heroSourceKey(source)}#gpu`,
kind: "gpu",
// Keep the preview's checksum-bound crop plan stable. Dropping it
// changed the child prop to null and caused a second render (and,
// for large payloads, a second backend decode/raw IPC) of the same
// hero immediately after the first one completed.
plan: prior.source === source ? prior.plan ?? null : null,
};
heroLoadInFlight = false;
initialBannerPaint = !prior.url;
finishBannerPaint = false;
clearTimeout(bannerTransitionTimer);
if (previousBanner) {
bannerTransitionTimer = setTimeout(() => {
previousBanner = null;
}, 650);
}
document.documentElement.dataset.heroGpuMs = result.renderMs.toFixed(2);
}
function gpuHeroError(source) {
if (source !== selectedHeroSource) return;
if (!gpuFallbackSources.includes(source)) {
gpuFallbackSources = [...gpuFallbackSources, source];
}
heroLoadInFlight = false;
}
// Remote heroes use a dedicated Rust proxy: it avoids Fanart CDN stalls in
// WebView2 and finishes accepted 720p fallbacks at 4K through the selected
// local upscaler. The old photo remains visible until the next is decoded.
// User-imported data URLs remain direct and incur no copy.
$effect(() => {
// Depend on the stable primitive index/fingerprint, not the derived
// array object. The fast art preview and full page contain the same URLs
// but replace the page object; tracking the array used to restart this
// download despite an identical selected source.
const selectedIndex = heroSelection.index;
const fingerprint = heroFingerprint;
const availableSources = untrack(() => heroSources);
const source = availableSources[selectedIndex] ?? "";
const sourceKey = heroSourceKey(source);
const provisionalSet = heroSetProvisional;
const upscaler = heroUpscaler;
const gpuFallbackOnly =
upscaler === "live_gpu"
&& source.startsWith("https://")
&& gpuFallbackSources.includes(source);
if (!upscaler) return;
if (
upscaler === "live_gpu"
&& source.startsWith("https://")
&& !gpuFallbackOnly
) {
// Live GPU owns the first visible sharp frame, including a fast
// Bing seed. The blurred colour field remains underneath while the
// payload is prepared, but never paint the ungraded HD preview.
heroLoadInFlight = true;
return;
}
const loadGeneration = ++heroLoadGeneration;
heroLoadInFlight = true;
let active = true;
if (!source) {
heroLoadInFlight = false;
displayedBanner = { source: "", url: null, key: "" };
previousBanner = null;
return;
}
(async () => {
try {
const readyImage = async (promise, kind, imageSource = source) => {
const url = await promise;
if (!url) throw new Error("hero image unavailable");
await loadImage(url);
return { kind, url, source: imageSource };
};
// Keep the expensive 4K finisher lazy on first paint. Once the
// shared raw transfer has produced and decoded the HD preview,
// begin finishing on the next frame; this avoids CPU/base64
// pressure during the exact frame the user is waiting to see.
// A provisional preview may be promoted after the final tier
// confirms the same source. Reuse its checksum-bound crop plan
// so preview and 4K never reframe or rerun detection.
let cropPlan = untrack(() =>
displayedBanner.source === source ? displayedBanner.plan ?? null : null,
);
let previewGenerated = false;
let finishedReady;
const finish = () =>
(finishedReady ??= readyImage(
source.startsWith("data:")
? Promise.resolve(source)
: getCatalogHeroArt(source, cropPlan),
"finished",
));
let ready;
// On first paint, show the fast pre-cropped provider preview.
// Rotations keep the old finished hero visible and can go
// straight to the final render without a preview.
// Reading displayedBanner reactively here used to invalidate
// this effect the instant the preview arrived, canceling the
// original finisher and launching the same hero work again.
const firstPaint = !untrack(() => displayedBanner.url);
if (gpuFallbackOnly) {
const preview = await getCatalogHeroPreview(source);
if (!preview?.data_url) {
throw new Error("hero preview unavailable");
}
cropPlan = preview.plan ?? null;
ready = await readyImage(
Promise.resolve(preview.data_url),
"preview",
);
} else if (!source.startsWith("data:") && firstPaint) {
const previewReady = (async () => {
const preview = await getCatalogHeroPreview(source);
if (!preview?.data_url) {
throw new Error("hero preview unavailable");
}
previewGenerated = true;
cropPlan = preview.plan ?? null;
return readyImage(
Promise.resolve(preview.data_url),
"preview",
);
})();
const cachedReady = (async () => {
// Start from the randomized selection, then scan this
// same provider tier sequentially. Only one large data
// URL is read/decoded, while any already-finished image
// can make a repeat visit independent of CDN latency.
for (let offset = 0; offset < availableSources.length; offset++) {
const index = (selectedIndex + offset) % availableSources.length;
const candidate = availableSources[index];
const cached = await getCachedCatalogHeroArt(candidate);
if (cached) {
return readyImage(
Promise.resolve(cached),
"finished",
candidate,
);
}
}
throw new Error("no cached hero");
})();
try {
// A repeat visit can paint the finished 4K disk cache
// before the provider CDN returns. A miss fails this
// branch immediately and leaves the preview untouched.
ready = await new Promise((resolve, reject) => {
let failures = 0;
const failed = () => {
failures += 1;
if (failures === 2) {
reject(new Error("hero image unavailable"));
}
};
previewReady.then(resolve, failed);
cachedReady.then(resolve, failed);
});
} catch {
if (!previewGenerated) {
// The backend could not fetch/decode the source.
// Retrying the same dead URL through the expensive
// 4K command only adds another transfer timeout.
throw new Error("hero preview source unavailable");
}
// The finished path is still a useful fallback if the
// browser alone could not decode a generated preview.
ready = await finish();
}
} else {
ready = await finish();
}
if (!active || heroFingerprint !== fingerprint) return;
if (ready.kind === "preview") {
initialBannerPaint = firstPaint;
finishBannerPaint = false;
displayedBanner = {
source,
url: ready.url,
key: `${sourceKey}#preview`,
plan: cropPlan,
};
if (gpuFallbackOnly || provisionalSet) {
// This source won the speed race, not the final provider
// tier, or its GPU render genuinely failed. Keep its HD
// fallback visible without starting a second finisher.
return;
}
// Preserve the quick preview if finishing fails. The proxy
// is already a native HD image, so a processing problem
// should not blank an otherwise valid artist hero.
try {
await new Promise(requestAnimationFrame);
if (!active || heroFingerprint !== fingerprint) return;
const finished = await finish();
if (!active || heroFingerprint !== fingerprint) return;
// Crossfade the graded 4K hero OVER the preview rather
// than hard-swapping the src: the Ruby Chrome LUT,
// vintage fade and grain then dissolve in over a short
// window instead of popping in a single frame. The
// preview is held underneath until the fade completes.
previousBanner = displayedBanner.url ? displayedBanner : null;
initialBannerPaint = false;
finishBannerPaint = true;
displayedBanner = {
source,
url: finished.url,
key: `${sourceKey}#finished`,
};
clearTimeout(bannerTransitionTimer);
bannerTransitionTimer = setTimeout(() => {
previousBanner = null;
}, HERO_FINISH_FADE_MS);
} catch {
// Pre-cropped preview remains visible.
}
return;
}
previousBanner = displayedBanner.url ? displayedBanner : null;
initialBannerPaint = firstPaint;
finishBannerPaint = false;
displayedBanner = {
source: ready.source,
url: ready.url,
key: `${heroSourceKey(ready.source)}#${loadGeneration}`,
};
clearTimeout(bannerTransitionTimer);
bannerTransitionTimer = setTimeout(() => {
previousBanner = null;
}, HERO_FADE_MS);
} catch {
if (!active || heroFingerprint !== fingerprint) return;
if (!failedBannerSources.includes(source)) {
failedBannerSources = [...failedBannerSources, source];
}
if (heroLoadGeneration === loadGeneration) {
heroLoadInFlight = false;
}
advanceHero(source);
} finally {
if (heroLoadGeneration === loadGeneration) {
heroLoadInFlight = false;
}
}
})();
return () => (active = false);
});
const banner = $derived(displayedBanner.kind === "gpu" ? null : displayedBanner.url);
const heroPainted = $derived(Boolean(displayedBanner.url));
// Popular songs from Deezer's play-ranked /top. Show five, Spotify-style,
// with a "Show more" that reveals the rest (up to ten). Collapse again when
// the artist changes so a new page never opens pre-expanded.
let showAllPopular = $state(false);
const allPopular = $derived(page?.popular_tracks?.slice(0, 10) ?? []);
const popularTracks = $derived(showAllPopular ? allPopular : allPopular.slice(0, 5));
$effect(() => {
void artistIdentity;
showAllPopular = false;
});
// Preview-tracks launcher. Capability is read straight off the ranked set
// the page already holds — Deezer ships each track's 30-second clip URL in
// the same payload as the track itself, so there is no separate probe and
// no window where the count is knowable but wrong. The doc's `unknown`
// state cannot occur here: the whole action row is gated on
// popularTracks.length, so by the time this control exists the tracks (and
// therefore their preview fields) have landed.
const previewableCount = $derived(allPopular.filter((track) => track.preview).length);
const previewState = $derived(
previewableCount === 0
? "none"
: previewableCount < allPopular.length
? "partial"
: "available",
);
const previewLabel = $derived(
previewState === "none"
? "No previews available for this artist"
: previewState === "partial"
? `Preview tracks — ${previewableCount} of ${allPopular.length} available`
: "Preview tracks",
);
/** Open the preview modal over the COMPLETE ranked top-track payload —
* never just the rendered rows (design §13, artist adapter). The manifest
* is a snapshot: later staged-page rebuilds cannot rewrite an open
* session. Play-full hands the active item to the ordinary resolver
* pipeline via the same path the Popular rows use. */
function openPreviews() {
if (!page?.popular_tracks?.length) return;
const snapshotTracks = page.popular_tracks.slice(0, 10);
const byProviderId = new Map(snapshotTracks.map((t) => [String(t.id), t]));
const manifest = buildArtistManifest({
artist: {
id: page.artist.id,
name: page.artist.name,
picture: page.artist.picture ?? null,
},
popularTracks: snapshotTracks,
createdAtMs: Date.now(),
});
void openPreview(manifest, {
playFull: (item) => {
const track = byProviderId.get(item.sources[0]?.providerTrackId);
if (track) playPopular(track);
},
});
}
// Instant-shell seed: the search portrait the user clicked, shown as a
// heavily-blurred colour field behind the hero until the real photo
// decodes, then faded out (never blanked). It is presentation-only — never
// a hero source. When there's no portrait, a deterministic muted gradient
// (hue from the artist) fills the same geometry as a no-photo fallback.
const seedUrl = $derived(page?.placeholder_picture ?? null);
const seedHue = $derived(hueFromKey(page?.artist?.id ?? page?.artist?.name ?? ""));
// ---- artist logo: pick the cleanest "monochrome wordmark" and tint it ----
// Fanart gives several logo candidates; we score each by how single-coloured
// it is (the share of opaque pixels in one dominant colour). A clean wordmark
// masks perfectly to the accent; a multi-colour "art" logo would flatten to a
// blob, so if none score well we show the most-liked one in full colour.
// (Both the mask and the canvas sampling need same-origin pixels, so the PNGs
// are proxied through Rust to data: URLs — Fanart sends no CORS headers.)
//
// The winner is then TRACED to vector (see lib/trace.js). Fanart's logos are
// a fixed 800x310 canvas, so a short wordmark (Weezer's ink is 131px tall)
// used to be pinned at its native height to avoid upscaling blur — which
// made it read far smaller than a tall logo. The trace removes that ceiling:
// the silhouette is crisp at any size, so every logo can reach one height.
// The tracer recovers smooth sub-pixel edges FROM a source's anti-aliasing.
// Some providers (TheAudioDB) deliver a hard 1-bit alpha logo — no AA at all
// — so every edge lands on a pixel boundary and the trace staircases. When a
// logo has almost no partial-alpha pixels, synthesise the missing AA:
// supersample to LOGO_TRACE_EDGE and blur, turning hard steps into gradients
// the trace reads as smooth curves. Logos that already carry AA (Fanart)
// fail this test and trace natively, exactly as before.
const LOGO_AA_MIN = 0.02; // partial-alpha share below which a source is "hard"
const LOGO_TRACE_EDGE = 2400; // supersample target long edge for hard sources
const LOGO_TRACE_MAX_SCALE = 4;
const LOGO_TRACE_BLUR = 1.0; // blur px per unit of supersample scale
const LOGO_MONO_MIN = 0.55; // dominant-colour share to accept for tinting
// A logo can trade this much cleanliness for a wider, more title-like shape.
// It must still pass LOGO_MONO_MIN; this only ranks already-good candidates.
const LOGO_MONO_TOLERANCE = 0.08;
const LOGO_MAX = 8; // candidates to consider across all providers
// Sizing: match logos by AREA, not by height. Wordmarks differ wildly in
// proportion (Radiohead is 7.9:1, Aphex Twin is 1:1), so a common height
// makes the wide ones enormous — same letter height, five times the ink on
// screen. Holding width*height constant instead gives every artist the same
// visual weight; height falls out as sqrt(AREA / aspect), clamped so a very
// wide logo can't get spindly nor a square one tower over the hero.
const LOGO_AREA = 120_000; // px^2 of box the logo should occupy
const LOGO_MIN_H = 110;
const LOGO_MAX_H = 200;
// The equal-area values above describe the approved full-size hero. Scale
// both logo dimensions with the hero width so its area shrinks at the same
// rate as the banner's visible area when the content pane gets narrower.
const LOGO_REFERENCE_W = 2000;
const LOGO_MIN_SCALE = 0.22;
const COMPACT_LOGO_MAX_ASPECT = 2.25;
const COMPACT_LOGO_MIN_LONG_EDGE = 160;
let heroWidth = $state(LOGO_REFERENCE_W);
const logoScale = $derived(
Math.min(1, Math.max(LOGO_MIN_SCALE, heroWidth / LOGO_REFERENCE_W)),
);
const logoHeight = (aspect) =>
Math.round(
Math.min(LOGO_MAX_H, Math.max(LOGO_MIN_H, Math.sqrt(LOGO_AREA / aspect))),
);
// logo.mode: "none" (text title) | "mask" (traced, accent-tinted) | "color"
// (the most-liked PNG, full colour). `aspect` is the ink's true aspect ratio,
// which drives the box so nothing is distorted or padded.
let logo = $state({ mode: "none", url: null, aspect: 3, height: LOGO_MAX_H });
// Internal request key only; making this reactive would invalidate and
// cancel the very async logo pass it is meant to de-duplicate.
let logoFingerprint = "";
let logoRequest = 0;
onDestroy(() => {
// Stop an off-screen candidate pass from launching more downloads or
// tracing work after the artist view has been left.
logoRequest += 1;
clearTimeout(bannerTransitionTimer);
});
const logoRenderHeight = $derived.by(() => {
const responsiveHeight = logo.height * logoScale;
// Compact marks become visually tiny much sooner than wordmarks. Give
// only <=2.25:1 logos a minimum long edge; wide marks never hit this
// branch and continue to follow the hero-width scale unchanged.
const compactFloor =
logo.aspect <= COMPACT_LOGO_MAX_ASPECT
? COMPACT_LOGO_MIN_LONG_EDGE / logo.aspect
: 0;
return Math.round(Math.max(responsiveHeight, compactFloor));
});
function measureHero(node) {
const update = () => {
if (node.clientWidth > 0) heroWidth = node.clientWidth;
};
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return { destroy: () => observer.disconnect() };
}
/* ── Hero → banner ─────────────────────────────────────────────────────
The hero does not scroll away and is not covered: it transitions the
whole way down and what is left of it at full scroll IS the banner.
There is no separate banner element.
ONE number drives all of it — `--p`, the ride-up progress, written to
.artist-page on scroll and rAF-throttled. Every offset, scale, fade and
gradient stop in the CSS below is a calc() off it, so there are no
keyframes to fall out of sync and no second source of truth. `--p` is 0
at the top of the page and every layer scroll adds is switched off
there, so the resting page is provably today's page rather than an
identity transform of it. */
// Where the glass bar's bottom edge sits once stuck, in .artist-page's own
// coordinates — the SAME frame BAND_TOP is expressed in, which is the only
// thing that makes the subtraction below meaningful. The bar pins at
// top:-24px and is 46px tall, but a sticky offset is measured from the
// pane's PADDING box, and that padding is the 24px the -24 cancels: the
// bar therefore lands flush at the top of .artist-page and its bottom edge
// is simply its own height. Measured at every scroll position to confirm.
//
// Using the scrollport figure (22) instead reads as a plausible constant
// and is wrong by exactly that 24px: it shortens --strip, lifts the whole
// banner, and parks the wordmark 14px BEHIND the glass bar.
const BAR_BOTTOM = 46;
// .hero-bg's own offset inside the pin — byte-for-byte the offset it has
// today, which is what makes --p: 0 pixel-identical to the current page.
// Keep in sync with .hero-bg's `top` and --band-top.
const BAND_TOP = -24;
// ROUND 9, option 1 "Softer". Nothing here is a fixed pixel count any more:
// the banner is a fraction of the hero (which itself tracks the pane), the
// wordmark a fraction of the banner, and the three gaps fractions of the
// banner. A fixed 124px banner and 26px wordmark turned into a strip with a
// speck of a logo on a full-size window, because they were the only things
// in the design that did not scale.
// The banner fraction now sizes only the parked wordmark INK (via
// MARK_OF_BANNER) — the parked layout itself is derived top-down from the
// glass bar in measure() (round 12 glue).
const BANNER_OF_HERO = 0.28, BANNER_MIN = 176, BANNER_MAX = 240;
const MARK_OF_BANNER = 0.28, MARK_MIN = 34, MARK_MAX = 62;
// The parked wordmark's clearance below the glass bar — the user asked
// for the same 28px the artist page keeps at its sides.
const PAD_TOP_PARKED = 28;
const clampPx = (lo, v, hi) => Math.round(Math.max(lo, Math.min(hi, v)));
// Narrow panes SHED chips rather than letting the parked row overflow: the
// row has to stay one line to fit a 124px banner, so something has to
// give and it should be the least important stat. Thresholds are the
// mock's window presets measured at the content width.
const chipTier = $derived(heroWidth < 460 ? "xs" : heroWidth < 640 ? "s" : "l");
// Re-measure hook, published by the action below so the reactive graph can
// poke it when the wordmark changes identity or size.
let remeasureHero = () => {};
// Off at rest, on the moment the page moves. Reactive rather than a raw
// dataset write so Svelte can see the [data-scroll] selectors are live and
// keeps them; it flips at most twice over the whole travel.
let heroScrolled = $state(false);
function heroScroll(pin) {
const page = pin.parentElement;
const band = pin.querySelector(".hero-bg");
let scroller = page?.parentElement ?? null;
while (scroller) {
const overflowY = getComputedStyle(scroller).overflowY;
if (overflowY === "auto" || overflowY === "scroll") break;
scroller = scroller.parentElement;
}
if (!page || !band || !scroller) return;
// Compositor-driven fade line where supported (Chromium 115+). When
// it is, apply() never touches --flow-cut: the mask is a CONSTANT on
// the sticky .scrollbody and a composited scroll-driven transform on
// .fade-flow carries the counter-motion — so a fast scroll can't
// outrun the mask. Must match the CSS @supports gate exactly.
const scrollDriven =
typeof CSS !== "undefined" && CSS.supports("animation-timeline", "scroll()");
// Infinity until measured, and again if there is somehow no room to
// ride up in: --p then stays 0 and the page is simply the page it is
// today, which is the right way for this to fail.
let travel = Infinity;
let previous = -1;
let frame = 0;
// The constant part of the content's flow-local fade line — written
// by measure(), consumed every frame by apply().
let stripCut = -99999;
function measure() {
const zone = page.querySelector(".hero-zone");
if (!zone) return;
// NEVER measure the pins themselves. They are `position: sticky`,
// and Chromium reports a stuck element's shifted position through
// offsetTop as well as through its rect — so deriving a pin's own
// sticky `top` from its own offsetTop is a feedback loop. An
// earlier cut of this did exactly that and the pins walked down the
// page a few hundred pixels per reflow (measured: top: 548px,
// --strip: -94px, mask stops going negative and blanking the hero).
//
// Nothing here needs it. The pins are the first children of
// .artist-page, so their flow offset is 0 and their sticky offset
// is the constant 0 in CSS; the band therefore lands on exactly the
// pixels .hero-bg occupies today and is held there. Everything else
// is measured from .hero-zone, which is `position: relative` and
// cannot lie.
//
// Measured from the TOP OF THE BAND, so these compare directly
// against band-local gradient stops.
const span = zone.offsetTop + zone.offsetHeight - BAND_TOP;
// Constant by construction: the band's top sits at scrollport
// -24px (BAND_TOP) and the glass bar's stuck bottom at +22px
// (BAR_BOTTOM), so the bar clears the band's top by 46px and the
// banner ends 124px below that.
const banner = clampPx(BANNER_MIN, span * BANNER_OF_HERO, BANNER_MAX);
// markPx, NOT mark: the block below already binds `mark` to the
// logo ELEMENT, and shadowing it there made `markHeight > mark`
// compare a number with a DOM node - silently false, so
// --mark-end stayed 1 and the wordmark never scaled at all.
const markPx = clampPx(MARK_MIN, banner * MARK_OF_BANNER, MARK_MAX);
// ROUND 12 GLUE (user request): the identity never moves relative
// to the content. The wordmark shrinks about its own resting
// bottom, the Play button shrinks inside its resting slot, and
// identity + content ride as ONE rigid block until the park; only
// past 100% do the rows scroll on and fade out under the parked
// identity. The parked layout is therefore derived TOP-DOWN from
// the glass bar — padTop equal to the pane's 28px side padding
// ("make the logo padding equal from the top the same as it is
// from the sides") — instead of bottom-up from a banner fraction,
// which dumped its slack into a 63px gap under the bar.
// The wordmark's resting bottom and parked ink first: the park
// point (--strip) is built from them. Measured, not assumed — the
// narrow-width media query changes .hero-copy's bottom padding.
const copy = page.querySelector(".hero-copy");
const slot = page.querySelector(".logo-slot");
let markRest = 16;
let inkPark = markPx;
if (copy && slot) {
markRest = copy.offsetHeight - slot.offsetTop - slot.offsetHeight;
page.style.setProperty("--mark-rest", `${markRest}px`);
// offsetHeight, not a rect: the slot carries the ride-up scale
// while scrolled and a rect would feed that back into itself.
const mark = slot.querySelector(".logo-wrap") ?? slot.querySelector(".name-title");
const markHeight = mark?.offsetHeight ?? 0;
page.style.setProperty(
"--mark-end",
markHeight > markPx ? (markPx / markHeight).toFixed(4) : "1",
);
inkPark = markHeight > markPx ? markPx : markHeight || markPx;
}
// The Play/chips row's resting height, from the hole it leaves in
// .popular, so the pinned row and its hole can never drift apart.
const gap = page.querySelector(".actions-gap");
const rowH = gap?.offsetHeight ?? 76;
page.style.setProperty("--row-h", `${rowH}px`);
// The park point: the content edge stops exactly where the shrunk
// wordmark's top lands PAD_TOP_PARKED below the glass bar with the
// identity still in its resting slots.
const strip =
BAR_BOTTOM - BAND_TOP + PAD_TOP_PARKED + inkPark + markRest;
page.style.setProperty("--banner-h", `${banner}px`);
// Glue, not travel. --lift cancels .hero-copy's parked term
// exactly, so its bottom stays 0 at every p — the wordmark
// shrinks in place about its resting baseline. The row needs no
// travel var at all any more: its box is pinned to its resting
// slot in CSS.
page.style.setProperty("--lift", `${banner - 14 - markRest}px`);
// Where the pins sit in flow. .results-head's net flow height is
// zero (-24 + 46 - 22), so the hero zone starts at exactly the
// same offset the pins do — and it is `position: relative`, so
// asking it cannot feed a sticky offset back into the measurement
// the way asking a pin would.
page.style.setProperty("--pin-origin", `${zone.offsetTop}px`);
page.style.setProperty("--hero-span", `${span}px`);
page.style.setProperty("--strip", `${strip}px`);
travel = span - strip > 1 ? span - strip : Infinity;
// ROUND 12 — the sheet's two poses, both in band coordinates.
// At rest its gradient sits on the VISIBLE hero (onset at 60% of
// the zone, spanning 42% of it — the live page's look; anchoring
// to the artwork box instead slid the dissolve below the fold,
// the bug the user caught against live screenshots). Parked, its
// top rides fogAbove px above the content edge — the pose the
// user approved. measure() only sizes the poses; the glide
// between them is CSS calc on --p, a rigid translation.
const fogSpan = Math.round(span * 0.42);
const fogAbove = Math.round(fogSpan * 0.294);
const fogRest = Math.round(span * 0.6);
page.style.setProperty("--fog-span", `${fogSpan}px`);
page.style.setProperty("--fog-above", `${fogAbove}px`);
page.style.setProperty("--fog-rest", `${fogRest}px`);
// The content's fade line, precomputed so apply() is arithmetic
// only: flow-local cut = stripCut + scrollTop. The zone must sit
// in the ONLY gap the parked layout leaves: between the pills'
// real bottom edge and the parked Popular heading (~24px). So the
// anchor is the MEASURED Play button bottom, not a derivation —
// three earlier zones each missed by construction (row-box
// bottom: covers half-visible behind the pills; row-box top: rows
// fully visible IN the pills' band; rowH + 2: the button hangs
// 14px below its box, 12px of rows still showing). The button's
// bottom offset below --edge is constant at every p (that is what
// the glue means), so measuring it during any scroll state is
// safe. Rows dissolve over [bottom + 2, bottom + 20], gone two
// pixels under the pills, parked heading ~4px clear.
const body = page.querySelector(".scrollbody");
const playEl = page.querySelector(".hero-play");
let rowBottom = rowH + 14;
if (playEl && band) {
const pNow = Math.min(
1,
Math.max(0, scroller.scrollTop / Math.max(1, span - strip)),
);
const edgeNow = span - pNow * (span - strip);
rowBottom =
playEl.getBoundingClientRect().bottom -
band.getBoundingClientRect().top -
edgeNow;
}
// The cut line in scrollport coordinates — scroll-invariant by
// construction (BAND_TOP and strip are scrollport-frame
// constants, rowBottom is measured relative to --edge).
const cutScreen = BAND_TOP + strip + Math.round(rowBottom) + 2;
stripCut = cutScreen - (body?.offsetTop ?? 0);
// The compositor-driven fade line's constants (see the CSS):
// --cut-top parks .fade-host's box top at the cut line,
// --fade-s is the scrollTop at which that happens, and
// --frame-h sizes .fade-frame to a pane height (from the parked
// cut it must reach past the viewport bottom; a pane is that
// with slack to spare). Both sides of the park equation live in
// the same scrollport frame (the pane's padding cancels), so S
// is simply the host's flow top minus the cut. The host's flow
// top comes from .hero-zone — never from the host itself:
// measuring a sticky element to position that same element is
// the exact feedback loop the warning at the top of measure()
// exists for. The host sits at .scrollbody's top (= the zone's
// bottom) with a -18px margin hanging it 18px above the
// content top.
if (scrollDriven && body) {
// A sticky inset resolves from inside the pane's padding
// (the same 24px BAR_BOTTOM exists to cancel), so `top: X`
// parks the box at scrollport X + padding — measured live:
// top 217 parked at 241, a fade line one full slot too low
// AND a 24px content jump at the stick moment, since S below
// is derived in scrollport coordinates. Subtracting the
// padding parks the box on the exact line the mask fallback
// draws, and makes the stick moment land precisely at S.
const padTop =
parseFloat(getComputedStyle(scroller).paddingTop) || 0;
page.style.setProperty(
"--cut-top",
`${(cutScreen - padTop).toFixed(1)}px`,
);
const hostFlowTop = zone.offsetTop + zone.offsetHeight - 18;
const fadeS = Math.max(0, hostFlowTop - cutScreen);
page.style.setProperty("--fade-s", `${fadeS.toFixed(1)}px`);
page.style.setProperty("--frame-h", `${scroller.clientHeight}px`);
}
}
function apply() {
frame = 0;
const scrollTop = scroller.scrollTop;
// FALLBACK ONLY (no scroll-driven animations): the fade line
// rides EVERY frame — including past the park, where --p is
// pinned at 1 but the rows keep scrolling under the banner. It
// must not sit behind the p-guard below, or the mask freezes at
// the park and rows climb over the banner. On the scrollDriven
// path the sticky .scrollbody + composited .fade-flow transform
// own the fade line and nothing here updates per frame.
if (!scrollDriven) {
page.style.setProperty("--flow-cut", `${(stripCut + scrollTop).toFixed(1)}px`);
}
const p = Math.min(1, Math.max(0, scrollTop / travel));
if (p === previous) return;
previous = p;
page.style.setProperty("--p", p.toFixed(4));
// Faded-out extras must stop taking clicks; opacity alone doesn't.
page.style.setProperty("--extra-hit", p > 0.42 ? "none" : "auto");
// The mask, the veil, the monochrome treatment and the wordmark's
// scale are all gated on [data-scroll], so at the top of the page
// they are absent rather than merely neutral. Every one of them is
// continuous across the switch (a zero-length feather, opacity 0,
// an identity filter, scale(1)), so nothing pops on the first
// pixel of scroll.
heroScrolled = p > 0;
}
function onScroll() {
if (!frame) frame = requestAnimationFrame(apply);
}
function reflow() {
measure();
previous = -1;
apply();
}
// scrollDriven only: .fade-frame is absolutely positioned, so the
// content no longer sizes .scrollbody in flow — sync its height to
// .fade-flow's or the page below the hero collapses. offsetHeight
// ignores .fade-flow's scroll-driven transform, so observing it
// cannot feed back into itself. (The fallback path leaves the fade
// divs unstyled and .scrollbody sizes itself as it always did.)
let flowObserver = null;
if (scrollDriven) {
const bodyEl = page.querySelector(".scrollbody");
const flowEl = page.querySelector(".fade-flow");
if (bodyEl && flowEl) {
const syncHeight = () => {
bodyEl.style.height = `${flowEl.offsetHeight}px`;
};
syncHeight();
flowObserver = new ResizeObserver(syncHeight);
flowObserver.observe(flowEl);
}
}
reflow();
remeasureHero = reflow;
scroller.addEventListener("scroll", onScroll, { passive: true });
const observer = new ResizeObserver(reflow);
observer.observe(band);
const zone = page.querySelector(".hero-zone");
if (zone) observer.observe(zone);
return {
destroy() {
remeasureHero = () => {};
scroller.removeEventListener("scroll", onScroll);
observer.disconnect();
flowObserver?.disconnect();
if (frame) cancelAnimationFrame(frame);
},
};
}
$effect(() => {
// The parked wordmark scale is derived from the wordmark's RENDERED
// height, and the row's resting height from the spacer's — both change
// out from under the ResizeObserver above (the logo crossfades in, the
// chip set fills out) without either the band or the zone resizing.
void logo.mode;
void logoRenderHeight;
void popularTracks.length;
remeasureHero();
});
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.decoding = "async";
img.onload = () => {
const decoded = typeof img.decode === "function"
? img.decode().catch(() => {})
: Promise.resolve();
decoded.then(() => resolve(img));
};
img.onerror = reject;
img.src = src;
});
}
/** RGBA pixels of `img` at width `w`, optionally Gaussian-blurred by `blur`
* source px — the blur synthesises anti-aliasing on hard-edged sources. */
function rgba(img, w = img.naturalWidth, blur = 0) {
const h = Math.max(1, Math.round((w * img.naturalHeight) / img.naturalWidth));
const cv = document.createElement("canvas");
cv.width = w;
cv.height = h;
const ctx = cv.getContext("2d", { willReadFrequently: true });
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
if (blur) ctx.filter = `blur(${blur}px)`;
ctx.drawImage(img, 0, 0, w, h);
return { px: ctx.getImageData(0, 0, w, h).data, w, h };
}
/** Share of ink pixels that are partially transparent — i.e. anti-aliased.
* A hard 1-bit alpha mask scores ~0; a normal AA logo scores several %. */
function aaFraction({ px, w, h }) {
let partial = 0;
let opaque = 0;
for (let p = 0; p < w * h; p++) {
const a = px[p * 4 + 3];
if (a > 8) opaque++;
if (a > 16 && a < 240) partial++;
}
return opaque ? partial / opaque : 0;
}
/** Share of opaque pixels sharing one dominant colour: 1 = flat wordmark. */
function monoScore({ px, w, h }) {
const buckets = new Map();
let opaque = 0;
for (let p = 0; p < w * h; p++) {
const i = p * 4;
if (px[i + 3] < 128) continue;
opaque++;
// Coarse 3-bit-per-channel quantisation (512 buckets).
const key =
((px[i] >> 5) << 6) | ((px[i + 1] >> 5) << 3) | (px[i + 2] >> 5);
buckets.set(key, (buckets.get(key) ?? 0) + 1);
}
if (opaque < 40) return 0;
let top = 0;
for (const v of buckets.values()) if (v > top) top = v;
return top / opaque;
}
$effect(() => {
// Provider-logo discovery is its own identity-checked stage. Once that
// complete candidate set settles, score it immediately without waiting
// for exhaustive album details. Cover-derived marks still require the
// hardened final cover list.
const providerLogos = page?.logos ?? [];
const providerCandidatesFinal = page?.provider_logos_settled === true;
const coverCandidatesFinal = !page?.art_preview;
const usingCoverLogo =
coverCandidatesFinal
&& !providerLogos.length
&& !page?.cover_logo_pending
&& !!page?.cover_logo;
const urls = providerCandidatesFinal && providerLogos.length
? providerLogos
: usingCoverLogo
? [page.cover_logo]
: [];
const candidateStage = providerLogos.length
? (providerCandidatesFinal ? "provider" : "pending")
: (usingCoverLogo ? "cover" : "pending");
const fingerprint = `${page?.artist.id ?? ""}:${candidateStage}:${urls.join("|")}`;
// The fast artwork response is later replaced by the full artist page.
// Identical logo candidates must not flash back to text or be retraced.
if (fingerprint === logoFingerprint) return;
logoFingerprint = fingerprint;
const request = ++logoRequest;
logo = { mode: "none", url: null, aspect: 3, height: LOGO_MAX_H };
if (!urls.length) return;
(async () => {
// Score small — the dominant-colour share doesn't need full res, and
// decoding six 800x310 PNGs at native size would be wasteful.
const loadCandidate = async (url) => {
try {
const dataUrl = await getCatalogArt(url);
if (request !== logoRequest || !dataUrl) return null;
const img = await loadImage(dataUrl);
if (request !== logoRequest) return null;
return { dataUrl, img, score: monoScore(rgba(img, 200)) };
} catch {
return null;
}
};
const selectedUrls = urls.slice(0, LOGO_MAX);
// Evaluate the final candidate set with bounded concurrency. There
// is no provisional paint or idle delay: text remains stable while
// two small scoring downloads at a time find the curated winner.
const cands = [];
for (let index = 0; index < selectedUrls.length; index += 2) {
const batch = await Promise.all(
selectedUrls.slice(index, index + 2).map(loadCandidate),
);
if (request !== logoRequest) return;
cands.push(...batch.filter(Boolean));
await new Promise(requestAnimationFrame);
}
if (request !== logoRequest) return;
if (!cands.length) return;
const cleanest = Math.max(...cands.map((candidate) => candidate.score));
const eligible = cands.filter(
(candidate) =>
candidate.score >= LOGO_MONO_MIN &&
candidate.score >= cleanest - LOGO_MONO_TOLERANCE,
);
const tracedCandidates = [];
for (const candidate of eligible) {
// True width is only known after tracing because every source
// PNG uses the same padded 800x310 canvas. Trace the small set
// near the top quality score, then prefer the widest result.
let source, eps, minArea;
if (usingCoverLogo) {
// Cover-derived wordmark (recipe D): the Rust side already
// despeckled the alpha; upscale to a fixed 1400px width and
// blur so the low-res letterforms trace as smooth curves
// rather than a staircase, then simplify at eps 0.5.
//
// The blur MUST scale with the enlargement: a fixed 1px did
// almost nothing on a 29px-tall source (a ~7x upscale to
// 1400px), so its pixel-steps survived into the trace as
// aliasing. ~0.6px of blur per unit of scale synthesises the
// anti-aliasing the tracer needs — hi-res marks (scale≈1) stay
// crisp, tiny marks get proportionally more smoothing.
const scale = 1400 / candidate.img.naturalWidth;
source = rgba(candidate.img, 1400, Math.max(1, scale * 0.6));
eps = 0.5;
minArea = 24;
} else if (aaFraction(rgba(candidate.img)) < LOGO_AA_MIN) {
// Hard-edged 1-bit source: supersample and blur to synthesise
// anti-aliasing, so the tracer resolves smooth sub-pixel
// edges instead of a pixel staircase. Genuine corners survive
// because a blurred corner is still a corner in the field.
const long = Math.max(candidate.img.naturalWidth, candidate.img.naturalHeight);
const scale = Math.min(LOGO_TRACE_MAX_SCALE, Math.max(2, LOGO_TRACE_EDGE / long));
source = rgba(
candidate.img,
Math.round(candidate.img.naturalWidth * scale),
LOGO_TRACE_BLUR * scale,
);
eps = 0.5;
minArea = 6 * scale * scale;
} else {
// Already anti-aliased (Fanart): trace natively, unchanged.
source = rgba(candidate.img);
eps = 0.22;
minArea = 6;
}
const traced = traceToSvg(source.px, source.w, source.h, { eps, minArea });
if (request !== logoRequest) return;
if (traced) tracedCandidates.push(traced);
// Avoid one long main-thread burst when several provider
// candidates are all clean enough to trace. A cover-derived
// mark is the only candidate, so apply it in this frame.
if (!usingCoverLogo) await new Promise(requestAnimationFrame);
}
if (request !== logoRequest) return;
if (tracedCandidates.length) {
const traced = tracedCandidates.reduce((widest, candidate) =>
candidate.aspect > widest.aspect ? candidate : widest,
);
logo = {
mode: "mask",
url: svgDataUrl(traced.svg),
aspect: traced.aspect,
height: logoHeight(traced.aspect),
};
return;
}
// Nothing clean enough to tint (or the trace found no shape) — show
// the most-liked candidate in full colour. This one keeps the simple
// max-height rule: the PNG still carries its transparent padding, so
// its raw proportions aren't the ink's and the area rule would be
// sizing against the wrong box.
logo = { mode: "color", url: cands[0].dataUrl, aspect: 3, height: LOGO_MAX_H };
})();
});
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
// MusicBrainz release-group type hints from the deferred identity pass,
// keyed by normalized title. MB's secondary types are the authoritative
// signal for the releases Deezer/Apple mislabel a plain "album" — a
// compilation ("KID A MNESIA"), soundtrack or remix album. Declared above
// `categoryFor`/`tabs` so it is initialized before they first read it (the
// same before-initialization trap the FLIP effects avoid).
const typeHintMap = $derived.by(() => {
const map = new Map();
for (const hint of page?.typeHints ?? []) {
if (hint?.title) map.set(norm(hint.title), hint);
}
return map;
});
// Release-type evidence harvested from description LEADS ("…is a
// compilation album by the Killers…"), keyed by the family's normalized
// base title so every edition inherits it. Direct editorial evidence —
// it outranks the title heuristics in categoryFor, which is how a
// compilation with an unhinting name ("Direct Hits") leaves the Albums
// tab. Filled by expander opens and by a bounded background harvest.
// Declared above `categoryFor`/`tabs` for the same before-initialization
// reason as `typeHintMap`.
let descHints = $state({});
let descHintsFor = null;
// Harvest bookkeeping (see the harvest effect below): the queue of titles
// awaiting a description fetch, the set of every key ever enqueued for
// this artist (the loop-breaker — hint arrivals recompute `tabs`, which
// re-runs the effect), and the batching buffer that coalesces hint
// arrivals into one filing pass instead of one FLIP re-flow per album.
let hintHarvestFor = null;
let harvestQueue = [];
let harvestQueued = new Set();
let harvestRunning = 0;
let harvestDestroyed = false;
let pendingHints = {};
let hintFlushTimer = 0;
$effect(() => {
const id = page?.artist?.id ?? null;
if (descHintsFor !== id) {
descHintsFor = id;
descHints = {};
pendingHints = {};
clearTimeout(hintFlushTimer);
hintFlushTimer = 0;
clearTimeout(descPrefetchTimer);
hintHarvestFor = id;
harvestQueue = [];
harvestQueued = new Set();
}
});
onDestroy(() => {
harvestDestroyed = true;
clearTimeout(hintFlushTimer);
});
// Map a MusicBrainz hint to one of our tab categories, or null when its
// secondary types say nothing we file separately. Order mirrors
// `categoryFor`'s own precedence so the two agree.
function mbCategory(hint) {
const secondary = (hint?.secondary_types ?? []).map((s) => s.toLowerCase());
if (secondary.includes("soundtrack")) return "soundtrack";
if (secondary.includes("live")) return "live";
if (secondary.includes("compilation")) return "compilation";
if (secondary.includes("remix") || secondary.includes("dj-mix")) return "remix";
if (secondary.includes("demo")) return "other";
return null;
}
// Stable hue in [0,360) from the artist id/name, for the no-photo gradient.
function hueFromKey(key) {
const s = String(key);
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h % 360;
}
// Which of this artist's releases are already in the local library. Uses
// the shared evidence-based matcher (variant name + release year for
// self-titled records), so the ✓ badge and library-first playback agree.
const inLibrary = (album) => !!page && libraryHasAlbum(page.artist.name, album);
// Distinct owned titles for this artist — drives the library progress chip.
const libraryOwnedCount = $derived.by(() => {
if (!page) return 0;
const artist = norm(page.artist.name);
return new Set(
libraryStore.albums
.filter((a) => norm(a.artist || "") === artist)
.map((a) => norm(a.title || "")),
).size;
});
// Release category and edition are deliberately separate dimensions. A
// deluxe live album remains a live album, while its edition suffix is used
// only to fold it under the same card as the original release.
// "mixes" catches the "(Dance Mixes, Vol. I)" / "(Urban Mixes)" family;
// "dj mix" files continuous mix releases ("fabric presents …") with the
// other rework material instead of leaving them among studio albums.
const isRemix = (a) => /\b(?:remix(?:es|ed)?|rework(?:s|ed)?|reconfigur(?:e|ed|ation)|rmx|mixes|dj\s+mix)\b/i.test(a.title);
// `a.is_live` is the backend's track-level verdict (a majority of the
// release's tracks are marked live) — it catches live albums whose titles
// give no hint at all ("Ballet Slippers", "FEELSLive 04/05"). The title
// regex remains as a cheap fallback for albums whose tracks weren't fetched.
// Audited additions: "live <year>" ("Queen Rock Montreal (Live 1981)"),
// a standalone "concert(s)" word ("The Berlin Concert", "Legendary
// Concerts" — "Concerto" is safe, the word boundary fails inside it),
// "live" followed by punctuation ("David Live (2005 Mix)", "(Live - 2022
// Remaster)"), and the common non-English forms. Bare "live" + word stays
// unmatched so "Live Through This" remains a studio album.
const isLive = (a) => a.is_live || /\b(?:live\s+(?:at|from|in|on|around)|in\s+concert|unplugged|concert\s+recording|live\s+(?:album|recording)|bbc\s+sessions?|peel\s+sessions?|alive\s+(?:19|20)\d{2}|live\s+(?:19|20)\d{2}|concerts?\b|en\s+vivo|ao\s+vivo|en\s+directo|dal\s+vivo|live\s+aus)\b|(?:^|[\s(\[–—-])live(?:\s*[()\[\],:–—-]|$)/i.test(a.title);
// Titles that name a genuine repackage of already-released material:
// greatest-hits/best-of sets, anthologies, "collections", box sets. Kept
// deliberately narrow — an album of previously-unreleased *original*
// material (e.g. Weezer's "Death to False Metal") is a studio album, not a
// compilation, even when a provider tags it one, so bare "b-sides" and
// "rarities" are intentionally NOT matched here.
// Audited additions: a standalone "collection" ("The Platinum Collection",
// "Christmas Collection"), "the hits", numbered-singles box series
// (Depeche Mode's «Violator | The 12" Singles»), "complete … recordings"
// with a label word in the middle, and archival "bootleg series" sets.
// Bare "collection" is safe against edition packages ("… + Acoustic
// Collection") because edition-grouped rows inherit their base album's
// category before this regex is consulted.
const COMPILATION_TITLE = /\b(?:greatest\s+hits|best\s+of|very\s+best|anthology|singles\s+collection|the\s+singles|the\s+complete\s+recordings|complete\s+collection|collected\s+works|selected\s+works|retrospective|b[-\s]?sides\s+collection|box\s?set|\d+\s+album\s+set|the\s+essential|collection|the\s+hits|\d+["″]?\s+singles|complete\s+(?:\w+\s+)?recordings|bootleg\s+series)\b/i;
// A slash-joined title of short single-word parts, carrying a full album's
// worth of tracks, is a box set of separate records (Weezer's
// "Blue/Green/Red" = the Blue + Green + Red albums, 30 tracks), not a split
// single. Gated to album record_type with a real track count so
// "A-Side / B-Side" singles and ordinary titles that happen to contain a
// slash are left alone.
const isBoxSetTitle = (a) => {
if (a.record_type?.toLowerCase() !== "album") return false;
if ((a.track_count ?? 0) < 15) return false;
const parts = a.title.split("/").map((part) => part.trim());
return parts.length >= 2 && parts.every((part) => /^[\p{L}\p{N}]{1,14}$/u.test(part));
};
const isCompilation = (a) => {
const type = a.record_type?.toLowerCase();
return type === "compile"
|| type === "compilation"
|| COMPILATION_TITLE.test(a.title)
|| isBoxSetTitle(a);
};
// Does this release's title contain another (non-compilation) album's title?
// A genuine repackage is usually named after what it collects — "KID A
// MNESIA" contains "Kid A". This is the corroboration MusicBrainz's noisy
// "Compilation" tag needs before it may override the local classification, so
// an original-material set MB mislabels (Weezer's "Death to False Metal",
// "SZNZ: Autumn") stays a studio album. No per-album tracklists exist
// client-side, so title containment is the available evidence.
const titleContainsAnotherAlbum = (album) => {
const self = norm(album.title);
return (page?.albums ?? []).some((other) => {
if (other.id === album.id) return false;
if (isCompilation(other)) return false;
const otherNorm = norm(other.title);
return otherNorm.length >= 4 && otherNorm !== self && self.includes(otherNorm);
});
};
// A "song" description hint reports that the only editorial article for
// this title describes a TRACK, not a record — so an album-typed release
// carrying the name is a package built around that song ("Run For Cover
// (Workout Mix)": eleven earlier Killers recordings under a single's
// name), never a studio album of its own. That is the corroboration
// `titleContainsAnotherAlbum` cannot supply when the collected material
// shares no title with the package. An original-material set MusicBrainz
// over-tags ("Death to False Metal") is unaffected: it has an ALBUM
// article, so its hint is never "song".
const namesASongNotAnAlbum = (a) =>
descHints[norm(editionBaseTitle(a.title))] === "song";
// MusicBrainz "Compilation" is applied only when a local signal agrees it is
// a real repackage. Its Live/Soundtrack/Remix types are trusted outright
// (see `categoryFor`); only Compilation is gated.
const compilationCorroborated = (album) =>
isCompilation(album) || titleContainsAnotherAlbum(album) || namesASongNotAnAlbum(album);
// There is no soundtrack record_type. Prefer Deezer's Films/Games genre
// signal from album details, then cover the common distributor title forms.
// `soundtracks?` — the plural mattered: composer catalogues are full of
// "Iconic Soundtracks" score compilations and the singular-only word
// boundary missed every one. "motion picture" standing alone covers the
// "(Music From and Inspired by the Motion Picture)" phrasing, and the
// "music from the …" net now reaches films/movies/trilogy/documentary
// forms ("Music from the Batman Trilogy", "Music from the Disney+
// Documentary").
const isSoundtrack = (a) => a.is_soundtrack || /\b(?:soundtracks?|original\s+(?:motion\s+picture\s+)?score|film\s+score|motion\s+picture|music\s+from\s+(?:and\s+inspired\s+by\s+)?(?:the\s+)?(?:original\s+)?(?:films?|movies|television(?:\s+series)?|tv\s+(?:series|show)|video\s+game|\S+\s+(?:trilogy|documentary|saga)))\b/i.test(a.title);
// Practice takes, rehearsals, demos, outtakes and radio-session odds —
// bonus material bundled with reissues (e.g. Weezer 30's "Superman (Garage
// Practice) / The BBC Tracks / Undone (Third Practice)"). Not studio albums;
// routed to Other releases. Checked AFTER isLive so a genuine "BBC Sessions"
// / "Peel Sessions" live album is kept in Live Albums — only "BBC Tracks" /
// "BBC Recordings" (which isLive does not match) fall through to here.
const isSessionOrDemo = (a) => /\b(?:practice|rehearsals?|demos?|outtakes?|bbc\s+(?:recordings?|tracks?)|radio\s+sessions?|karaoke)\b/i.test(a.title);
const todayKey = (() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
})();
function isFutureOrUnresolvedDate(value) {
if (!value) return false;
if (value.length >= 10) return value.slice(0, 10) > todayKey;
// A provider that knows only the current year/month has not established
// that the release already happened. Keep it Upcoming until a precise
// past date arrives rather than silently treating Jan 1 as the date.
if (value.length >= 7) return value.slice(0, 7) >= todayKey.slice(0, 7);
if (value.length >= 4) return value.slice(0, 4) >= todayKey.slice(0, 4);
return false;
}
const isUpcomingAlbum = (a) => a.record_type?.toLowerCase() === "album"
&& isFutureOrUnresolvedDate(a.release_date);
const EDITION_MARKER = /\b(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|legacy\s+edition|special\s+edition|collector'?s\s+edition|bonus\s+tracks?|complete\s+edition|definitive\s+edition|reissue|redux|mono\s+edition|stereo\s+edition|\w+(?:['’]s)?\s+edition|half[-\s]speed|track\s+by\s+track)\b/i;
const EDITION_SUFFIX = /\s*(?:[-–—:]\s*)?(?:the\s+)?(?:(?:\d{4}|\d+(?:st|nd|rd|th))\s+)?(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|legacy\s+edition|special\s+edition|collector'?s\s+edition|bonus\s+tracks?|complete\s+edition|definitive\s+edition|reissue|redux|mono\s+edition|stereo\s+edition|\w+(?:['’]s)?\s+edition|half[-\s]speed|track\s+by\s+track)(?:\s+(?:edition|version))?.*$/i;
// A bracketed tail naming an alternate cut of the same work — "(Acoustic
// Version)", "(2010 Mono Version)", "(Urban Mixes)", "(Sampler)". These
// fold into the base release's family. "<Name>'s Version" is exempt: a
// re-recorded catalogue (Taylor's Version) is its own work and must keep
// its own card.
const VARIANT_SEGMENT = /\b(?:versions?|mix(?:es)?|edits?|acoustic|instrumentals?|sampler|extras|mono|stereo|demos?|extended|live)\b/i;
const OWN_WORK_SEGMENT = /^\S+['’]s\s+version$/i;
const isEdition = (a) => EDITION_MARKER.test(a.title)
|| /[\[(][^\])]*\bedition\b[^\])]*[\])]/i.test(a.title);
function editionBaseTitle(title) {
// Keep a useful qualifier that shares an edition bracket. For example,
// "Weezer (Deluxe Edition / Red Album)" belongs to "Weezer (Red
// Album)", not to every self-titled Weezer record.
const bracketSafe = title.replace(/\s*[\[(]([^\])]+)[\])]/g, (whole, inner) => {
const variantInner = VARIANT_SEGMENT.test(inner) && !OWN_WORK_SEGMENT.test(inner.trim());
if (!EDITION_MARKER.test(inner) && !/\bedition\b/i.test(inner) && !variantInner) return whole;
const qualifiers = inner
.split(/\s*[/|;]\s*/)
// A segment can carry a real qualifier AND a marker tail —
// "1973-1976 2011 Remaster" must keep "1973-1976" so the
// remaster lands in the same family as "Deep Cuts
// (1973-1976)". Strip the marker tail first, then judge.
.map((part) => part
.replace(/\s*(?:\d{4}\s+)?(?:super\s+deluxe|deluxe|expanded|remaster(?:ed)?|anniversary|reissue|redux)(?:\s+(?:edition|version))?\s*$/i, "")
.trim())
.filter((part) => part
&& !EDITION_MARKER.test(part)
&& !/\bedition\b/i.test(part)
&& !(VARIANT_SEGMENT.test(part) && !OWN_WORK_SEGMENT.test(part)));
return qualifiers.length ? ` (${qualifiers.join(" / ")})` : "";
});
const stripped = bracketSafe
.replace(EDITION_SUFFIX, "")
.replace(/\s+/g, " ")
.trim()
.replace(/\s*[-–—:]\s*$/, "");
return stripped || title;
}
function editionLabel(edition, family) {
// The expander rail header already names the album, so each edition
// row shows only what differs: "Original" for the canonical release,
// otherwise the qualifier ("Deluxe Edition", "2025 Remaster"). The
// full title stays available as the row tooltip.
if (edition.id === family.id) return "Original";
const title = edition.title;
const base = family.baseTitle ?? "";
// "Weezer (Deluxe Edition / Red Album)" shares its bracket with the
// family qualifier — drop segments the family already carries so the
// label is just "Deluxe Edition". Test against the canonical title,
// not baseTitle: edition_group families derive baseTitle from
// whichever release was processed first, so it can be bare "Weezer".
const known = `${family.title ?? ""} ${base}`.toLowerCase();
const dropBaseParts = (raw) => {
const parts = raw.split(/\s*[/|;]\s*/).map((p) => p.trim()).filter(Boolean);
const kept = parts.filter((p) => !known.includes(p.toLowerCase()));
return (kept.length ? kept : parts).join(" / ");
};
const trim = (raw) => raw
.replace(/^[\s\-–—:([]+/, "")
.replace(/[)\]]+$/, "")
.trim();
if (base && title.toLowerCase().startsWith(base.toLowerCase())) {
const rest = trim(title.slice(base.length));
if (rest) return dropBaseParts(rest);
}
const suffix = title.match(EDITION_SUFFIX)?.[0];
return (suffix && dropBaseParts(trim(suffix))) || title;
}
function categoryFor(album) {
// A backend-linked edition files WITH its base release: "The Life of a
// Showgirl + Acoustic Collection" belongs beside the album it extends,
// not wherever its own title's keywords would send it (bare
// "collection" reads as a compilation). One hop only — the base is the
// row whose id IS the group id, and it classifies by its own title.
if (album.edition_group != null && album.id !== album.edition_group) {
const base = (page?.albums ?? []).find((other) => other.id === album.edition_group);
if (base) return categoryFor(base);
}
// Announced full-lengths stay visible without being presented as part
// of the released discography. They move automatically on release day.
if (isUpcomingAlbum(album)) return "upcoming";
// Editorial description evidence outranks every heuristic below: a
// lead sentence that says "…is a compilation album by the Killers…"
// is the record declaring its own kind, seen even when the title
// ("Direct Hits") hints at nothing. "album"/"ep" hints are POSITIVE
// studio confirmations — they bypass the title regexes so an
// innocently-worded studio record can't be misfiled by one.
//
// "song" is deliberately NOT filed here. It says the article found was
// about a track, so it is evidence about the DESCRIPTION, not a
// category — the record kinds such a lead mentions belong to the album
// the song appears on. It is consumed by `compilationCorroborated`.
const descHint = descHints[norm(editionBaseTitle(album.title))];
if (descHint === "compilation" || descHint === "live"
|| descHint === "soundtrack" || descHint === "remix") {
return descHint;
}
if (descHint === "ep") return "ep";
if (descHint === "album") {
const declared = (album.record_type ?? "album").toLowerCase();
return ["album", "ep", "single"].includes(declared) ? declared : "album";
}
// MusicBrainz's verdict catches bespoke names the title heuristics
// below can't (a compilation named "KID A MNESIA", a soundtrack with no
// "soundtrack" in its title). Live/Soundtrack/Remix are trusted outright;
// MB's "Compilation" is noisy (it also tags original-material sets), so
// it must be corroborated locally, else fall through to the heuristics.
const hinted = typeHintMap.get(norm(album.title));
if (hinted) {
const category = mbCategory(hinted);
if (category === "compilation") {
if (compilationCorroborated(album)) return "compilation";
} else if (category) {
return category;
}
}
if (isSoundtrack(album)) return "soundtrack";
if (isLive(album)) return "live";
if (isCompilation(album)) return "compilation";
if (isRemix(album)) return "remix";
// Practice/demo/session bonus material is not a studio album, EP or
// single — send it to the Other releases catch-all regardless of the
// record_type Deezer assigned it.
if (isSessionOrDemo(album)) return "other";
const type = album.record_type?.toLowerCase() ?? "other";
return ["album", "ep", "single"].includes(type) ? type : "other";
}
function releaseFamilies(items, category) {
// A work whose rows are only PARTLY edition_group-linked must not
// split into a work-keyed family and a title-keyed family showing two
// cards ("Nevermind" linked to its deluxe, "Nevermind (Remastered)"
// unlinked). Rows without a group adopt the group of a linked row
// that shares their base title.
const groupByBase = new Map();
for (const album of items) {
if (album.edition_group == null) continue;
const base = norm(editionBaseTitle(album.title));
if (base && !groupByBase.has(base)) groupByBase.set(base, album.edition_group);
}
const families = new Map();
for (const album of items) {
const baseTitle = editionBaseTitle(album.title);
const group = album.edition_group ?? groupByBase.get(norm(baseTitle));
const key = group != null
? `${category}:work:${group}`
: `${category}:${norm(baseTitle) || album.id}`;
if (!families.has(key)) {
families.set(key, { key, baseTitle, editions: [] });
}
const family = families.get(key);
// Deezer can return the same regional/digital release under more
// than one catalog id, while MusicBrainz supplements can carry the
// same title/year without a track count. Those are duplicate source
// records, not editions. Different known track counts remain
// separate because they can represent a real expanded edition.
const releaseYear = year(album);
const duplicate = releaseYear && family.editions.some((edition) =>
norm(edition.title) === norm(album.title)
&& year(edition) === releaseYear
&& (edition.track_count == null
|| album.track_count == null
|| edition.track_count === album.track_count),
);
if (duplicate) continue;
family.editions.push(album);
}
return [...families.values()]
.map((family) => {
const byDate = family.editions
.slice()
.sort((a, b) => (a.release_date ?? "9999").localeCompare(b.release_date ?? "9999"));
const canonical = byDate.find((album) => !isEdition(album)) ?? byDate[0];
const editions = [canonical, ...byDate.filter((album) => album.id !== canonical.id)];
return { ...canonical, familyKey: family.key, baseTitle: family.baseTitle, editions };
})
// Studio chronology should follow the original album, not the date
// of its latest anniversary reissue.
.sort((a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? ""));
}
// Discography tabs (Spotify-style). Each tab merges one or more of the
// nine fine-grained categories into a single date-sorted grid, so every
// release stays reachable and the per-grid expander machinery works
// unchanged (keyed by the tab key). Only non-empty tabs are shown.
// One tab per release category — nothing is folded into a neighbour, so
// a soundtrack is never filed under Albums and a remix EP is never filed
// under Compilations. Empty tabs are dropped below, so a typical artist
// still shows only three or four.
const TAB_DEFS = [
{ key: "upcoming", label: "Upcoming", cats: ["upcoming"] },
{ key: "album", label: "Albums", cats: ["album"] },
{ key: "ep", label: "EPs", cats: ["ep"] },
{ key: "single", label: "Singles", cats: ["single"] },
{ key: "live", label: "Live", cats: ["live"] },
{ key: "compilation", label: "Compilations", cats: ["compilation"] },
{ key: "soundtrack", label: "Soundtracks", cats: ["soundtrack"] },
{ key: "remix", label: "Remixes", cats: ["remix"] },
{ key: "other", label: "Other", cats: ["other"] },
];
const tabs = $derived.by(() => {
if (!page) return [];
const by = {
upcoming: [], album: [], live: [], soundtrack: [], compilation: [],
ep: [], single: [], remix: [], other: [],
};
for (const album of page.albums) by[categoryFor(album)].push(album);
return TAB_DEFS
.map((def) => ({
key: def.key,
label: def.label,
items: releaseFamilies(def.cats.flatMap((c) => by[c]), def.key),
}))
.filter((tab) => tab.items.length > 0);
});
// Albums is the default landing (like Spotify's "Popular releases"); if
// an artist has no albums, fall back to the first non-empty tab. The
// effect also re-validates as the staged catalog rebuilds `tabs`. Reset
// per artist so a niche tab selected on the previous page never hides the
// new artist's album grid (and its near-viewport cover prefetch).
let activeTabKey = $state("album");
let activeTabArtistId = null;
$effect(() => {
if (!tabs.length) return;
if (artistIdentity !== activeTabArtistId) {
activeTabArtistId = artistIdentity;
activeTabKey = tabs.find((tab) => tab.key === "album")?.key ?? tabs[0].key;
} else if (!tabs.some((tab) => tab.key === activeTabKey)) {
activeTabKey = tabs.find((tab) => tab.key === "album")?.key ?? tabs[0].key;
}
});
const visibleTabs = $derived(tabs.filter((tab) => tab.key === activeTabKey));
// FLIP for the stage-2 correction. Svelte's `animate:` directive can't be
// used here — it requires the animated element to be the ONLY child of the
// keyed each, and this each also renders the inline expander — so the same
// effect is done by hand: snapshot every card's position BEFORE the DOM
// updates ($effect.pre), then after the update play each card from its old
// position back to its new one. Cards glide to their corrected places
// instead of snapping. Only on-screen cards are animated.
// MUST live below `tabs`: $effect.pre also runs during setup, so reading a
// `const` declared further down throws a TDZ ReferenceError that blanks
// the whole page.
let cardRects = new Map();
$effect.pre(() => {
void tabs;
if (!untrack(() => gridAnimated)) return;
cardRects = new Map();
for (const el of document.querySelectorAll(".release[data-fkey]")) {
cardRects.set(el.dataset.fkey, el.getBoundingClientRect());
}
});
$effect(() => {
void tabs;
if (!untrack(() => gridAnimated) || !cardRects.size) return;
const rects = cardRects;
cardRects = new Map();
for (const el of document.querySelectorAll(".release[data-fkey]")) {
const prev = rects.get(el.dataset.fkey);
if (!prev) continue; // newly added — its in:fade covers it
const next = el.getBoundingClientRect();
if (next.bottom < 0 || next.top > innerHeight) continue; // off-screen
const dx = prev.left - next.left;
const dy = prev.top - next.top;
if (!dx && !dy) continue;
el.animate(
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }],
{ duration: 340, easing: "cubic-bezier(0.2, 0.8, 0.3, 1)" },
);
}
});
async function selectTab(key) {
if (key === activeTabKey) return;
// The open expander lives inside the outgoing tab's grid; switching
// tabs would orphan it, so close first.
if (openFamilyKey) closeExpander();
// Switching must read as a lateral change, so the tab bar has to stay
// exactly where it is. The grid's min-height reserve keeps the target
// scroll position reachable, but the grid is keyed by tab — Svelte
// destroys and recreates it, and that swap still shifts the scroll
// (measured 381px) via Chromium re-anchoring on the new subtree. So
// pin the bar's viewport offset explicitly across the swap.
const bar = document.querySelector(".disco-tabs");
const pane = bar?.closest(".content");
const barOffset = bar && pane
? bar.getBoundingClientRect().top - pane.getBoundingClientRect().top
: null;
activeTabKey = key;
if (barOffset == null) return;
const restore = () => {
const now = bar.getBoundingClientRect().top - pane.getBoundingClientRect().top;
if (now !== barOffset) pane.scrollTop += now - barOffset;
};
await tick();
restore();
// content-visibility/intrinsic-size settle a frame later.
requestAnimationFrame(restore);
}
const releasedCount = $derived(page?.albums.filter((album) => !isUpcomingAlbum(album)).length ?? 0);
const familyNowPlaying = (family) => family.editions.some((album) => nowPlaying("albumId", album.id));
const familyInLibrary = (family) => family.editions.some(inLibrary);
// ---- inline album expander ----
// One expander at a time, rendered INSIDE the grid as a full-width item
// directly after the last card of the open family's row, so it always
// opens under the album you clicked and pushes later rows down.
let openFamilyKey = $state(null);
const openFamily = $derived(
tabs.flatMap((tab) => tab.items).find((family) => family.familyKey === openFamilyKey) ?? null,
);
let openAlbumId = $state(null);
let railArtAlbumId = $state(null);
let previousRailArtAlbumId = $state(null);
let railArtTransitionKey = $state(0);
let railArtTransitionTimer;
let openTracks = $state([]);
let openLoading = $state(false);
let openInfo = $state(null); // AlbumInfo (description/genre/label) for the open family
let descExpanded = $state(false);
let libraryTicks = $state(new Set()); // normalized titles of locally saved tracks
let gridCols = $state({}); // group key -> measured column count
const albumInfoCache = new Map(); // artist id + canonical title -> Promise<AlbumInfo|null>
const ALBUM_INFO_CACHE_MAX = 64;
let editionLoadGeneration = 0;
let libraryTickGeneration = 0;
/** Owns a catalog cover's `src` so the high-res upgrade never blanks it.
*
* `resolveCovers` publishes an upgrade URL the moment the BACKEND returns
* it — before the browser has fetched a byte of that image. Binding it
* straight to `src` tears down the painted cover and leaves the element
* empty for the whole download: the visible "flash".
*
* So: if the img is already showing a frame (i.e. the user can see it),
* fetch + decode the next URL off-DOM first and only then swap, which
* paints from the memory cache in a single frame. An img that hasn't
* painted yet takes the URL directly — nothing to flash from, and that
* keeps `loading="lazy"` intact for off-screen cards so this never turns
* into an eager download of every cover on the page.
*
* A decode failure simply never swaps, so a dead upgrade URL leaves the
* working cover in place instead of flashing to a placeholder.
*/
/** Cover Art Archive 302-redirects into archive.org, which routinely
* stalls or times out under load. MusicBrainz-supplemented cards (Hurley,
* the Gold Album…) are the only ones served from there — every other cover
* comes from Deezer's fast CDN — which is why the SAME albums keep losing
* their art. A browser <img> gets one shot at such a URL and no retry, so
* route these through the Rust proxy instead: it has a 10s timeout,
* single-flight, an in-memory cache, and it never caches a failure. */
const PROXIED_ART_HOST = /(^|\.)(coverartarchive\.org|archive\.org)$/i;
function needsArtProxy(url) {
try {
return PROXIED_ART_HOST.test(new URL(url).hostname);
} catch {
return false;
}
}
function handleCoverError(node, originalCover, title = "") {
const fallback = coverLoadError(
originalCover,
node?.currentSrc || node?.src || null,
page?.artist?.name ?? "",
title,
page?.artist?.id ?? null,
);
if (fallback && node?.src !== fallback) node.src = fallback;
}
// Every release card shares one observer. Large discographies previously
// allocated two IntersectionObserver instances per card (one for image
// proxying and one for backend cover verification); this single gate owns
// both jobs and keeps off-screen/hidden work dormant.
let coverViewportObserver;
const coverViewportCallbacks = new Map();
function watchCoverViewport(node, callback) {
if (!coverViewportObserver) {
coverViewportObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const ready = coverViewportCallbacks.get(entry.target);
coverViewportCallbacks.delete(entry.target);
coverViewportObserver.unobserve(entry.target);
ready?.();
}
},
{ root: node.closest(".content"), rootMargin: "500px" },
);
}
coverViewportCallbacks.set(node, callback);
coverViewportObserver.observe(node);
return () => {
coverViewportCallbacks.delete(node);
coverViewportObserver?.unobserve(node);
};
}
function smoothSrc(node, options) {
let seq = 0;
let current = options;
let pendingUrl = options?.url ?? "";
let nearViewport = typeof IntersectionObserver === "undefined";
let stopWatching;
let scheduledKey = "";
let thumbSettleTimer;
// Tracks the LOGICAL url applied. Can't compare against node.src: a
// proxied cover ends up as a data: URL that never equals its source.
let currentKey = null;
// First-paint reveal: the img sits at opacity 0 until it has fully
// arrived, so the browser's top-down raster fill happens invisibly and
// only the finished picture animates in over the blurred thumb.
const onLoad = () => {
if (!node.naturalWidth) return;
node.classList.add("is-loaded");
const stack = node.parentElement;
stack?.classList.add("cover-ready");
clearTimeout(thumbSettleTimer);
thumbSettleTimer = setTimeout(
() => stack?.classList.add("cover-settled"),
320,
);
};
node.addEventListener("load", onLoad);
if (node.complete && node.naturalWidth) onLoad();
const load = async (next) => {
if (!next || currentKey === next) return;
const mine = ++seq;
const requestOptions = current;
currentKey = next;
let resolved = next;
if (needsArtProxy(next)) {
const data = await getCatalogArt(next);
if (mine !== seq) return;
if (data) resolved = data;
}
if (!node.naturalWidth) {
node.src = resolved;
return;
}
try {
const probe = new Image();
probe.decoding = "async";
probe.src = resolved;
await probe.decode();
} catch {
if (mine !== seq) return;
// The off-DOM probe intentionally shields the painted card from
// a flash, but it is still proof that this upgrade is unusable.
// Evict both memory and persistent resolution entries here;
// otherwise no real <img> error event ever gets a chance to.
coverLoadError(
requestOptions?.album?.cover,
next,
requestOptions?.artist ?? "",
requestOptions?.album?.title ?? "",
requestOptions?.artistId ?? null,
);
return; // dead upgrade — keep the cover that works
}
if (mine === seq && node.isConnected) node.src = resolved;
};
const scheduleUpgrade = () => {
const album = current?.album;
const key = `${album?.title ?? ""}\u{1f}${album?.cover ?? ""}`;
if (!nearViewport || !current?.ready || !album?.cover || key === scheduledKey) return;
scheduledKey = key;
void resolveCovers([album], current.artist ?? "", {
artistId: current.artistId ?? null,
});
};
const enterViewport = () => {
nearViewport = true;
void load(pendingUrl);
scheduleUpgrade();
if (current?.ready) queueCardAccent(current?.album?.cover);
};
const apply = (next) => {
current = next;
pendingUrl = next?.url ?? "";
if (nearViewport) {
void load(pendingUrl);
scheduleUpgrade();
if (current?.ready) queueCardAccent(current?.album?.cover);
}
};
if (!nearViewport) {
stopWatching = watchCoverViewport(node, enterViewport);
}
apply(options);
return {
update: apply,
destroy: () => {
seq += 1;
stopWatching?.();
clearTimeout(thumbSettleTimer);
node.removeEventListener("load", onLoad);
},
};
}
onDestroy(() => {
leaveCoverUpgradeScope(coverScopeArtistId);
coverViewportObserver?.disconnect();
coverViewportCallbacks.clear();
});
// Measures a grid's real column count (the tracks are fixed-width
// auto-fill, so it changes with the pane) AND reserves a minimum grid
// height. The reserve is what makes tab switching read as a lateral
// change: without it, moving from a tall tab (17 albums) to a short one
// (2 live) shrinks the page below the current scrollTop, the browser
// clamps the scroll, and the whole view yanks upward. Reserving
// "one pane-height below the tab bar" keeps every scroll position where
// the bar is visible achievable, so the bar never moves. It is a
// min-height, so tabs with plenty of releases are unaffected.
function measureCols(node, groupKey) {
const measure = () => {
const n = getComputedStyle(node).gridTemplateColumns.split(" ").length;
if (gridCols[groupKey] !== n) gridCols = { ...gridCols, [groupKey]: n };
const pane = scrollPaneFor(node);
// Anchor on the TAB BAR, not the heading: the bar is what
// `selectTab` pins, and the worst case is the bar sitting flush
// at the pane's top. Everything below it (the bar itself + this
// grid) must then still fill a pane-height, or the scroll clamps.
const bar = node.closest(".pane")?.querySelector(".disco-tabs")
?? document.querySelector(".disco-tabs");
if (!pane || !bar) return;
const chrome = node.getBoundingClientRect().top - bar.getBoundingClientRect().top;
const reserve = Math.max(0, Math.round(pane.clientHeight - chrome));
if (node.style.minHeight !== `${reserve}px`) node.style.minHeight = `${reserve}px`;
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(node);
return { destroy: () => ro.disconnect() };
}
/** Index in group.items after which the expander renders (-1 = not here):
* the last card of the open family's row. Same-row switches therefore keep
* the same insertion point and morph in place; cross-row switches move the
* block, which plays the slide out/in. */
function expanderIndex(group) {
const idx = group.items.findIndex((f) => f.familyKey === openFamilyKey);
if (idx < 0) return -1;
const cols = gridCols[group.key] || 1;
return Math.min(Math.floor(idx / cols) * cols + cols - 1, group.items.length - 1);
}
/** Notch x-position: centered under the open card. Tracks are fixed
* (--card-w) with a fixed gap, so this is exact. */
function notchLeft(group) {
const idx = group.items.findIndex((f) => f.familyKey === openFamilyKey);
const cols = gridCols[group.key] || 1;
const col = idx >= 0 ? idx % cols : 0;
return `${col * (CARD_W + GRID_GAP) + CARD_W / 2 - 6}px`;
}
const CARD_W = 150;
const GRID_GAP = 16;
function closeExpander() {
clearTimeout(railArtTransitionTimer);
editionLoadGeneration += 1;
libraryTickGeneration += 1;
openFamilyKey = null;
openAlbumId = null;
openLoading = false;
railArtAlbumId = null;
previousRailArtAlbumId = null;
openTracks = [];
openInfo = null;
}
function toggleExpander(family) {
if (openFamilyKey === family.familyKey) {
closeExpander();
return;
}
openFamilyKey = family.familyKey;
// A newly opened family already has its card artwork loaded. Edition
// switches below preload independently so the existing art never
// disappears while the next cover is still decoding.
setRailArt(family.id, true);
descExpanded = false;
showEdition(family);
loadAlbumInfo(family);
loadLibraryTicks(family);
}
async function showEdition(album) {
const generation = ++editionLoadGeneration;
openAlbumId = album.id;
void resolveCovers([album], page?.artist?.name ?? "", {
artistId: page?.artist?.id ?? null,
});
void preloadEditionArt(album);
openTracks = [];
openLoading = true;
try {
const details = await api.catalogAlbum(album.id);
if (openAlbumId === album.id) openTracks = details.tracks;
} catch {
if (openAlbumId === album.id) openTracks = [];
} finally {
if (generation === editionLoadGeneration && openAlbumId === album.id) {
openLoading = false;
}
}
}
async function preloadEditionArt(album) {
if (railArtAlbumId === album.id) return;
const source = album.cover
? (coverUpgrades[album.cover] ?? album.cover)
: null;
if (!source || artBroken[album.cover]) {
if (openAlbumId === album.id) setRailArt(album.id);
return;
}
const image = new Image();
image.src = source;
try {
await image.decode();
} catch {
// A failed decode still advances to the normal image element,
// whose error handler supplies the existing placeholder behavior.
}
if (openAlbumId === album.id) setRailArt(album.id);
}
function setRailArt(albumId, immediate = false) {
if (railArtAlbumId === albumId) return;
clearTimeout(railArtTransitionTimer);
previousRailArtAlbumId = immediate ? null : railArtAlbumId;
railArtAlbumId = albumId;
railArtTransitionKey += 1;
if (!immediate && previousRailArtAlbumId != null) {
const transition = railArtTransitionKey;
railArtTransitionTimer = setTimeout(() => {
if (railArtTransitionKey === transition) previousRailArtAlbumId = null;
}, 520);
}
}
onDestroy(() => clearTimeout(railArtTransitionTimer));
async function loadAlbumInfo(family) {
openInfo = null;
// The CANONICAL title, not baseTitle: baseTitle comes from whichever
// edition the newest-first list iterated first ("Weezer 30" for the
// Blue Album family), while the canonical is the original release —
// the record the sources actually catalogue.
const title = family.title || family.baseTitle;
const artistId = page?.artist?.id ?? null;
const artistName = page?.artist?.name ?? "";
const familyKey = family.familyKey;
if (artistId == null || !artistName || !title) return;
const { key, promise } = fetchAlbumInfo(artistId, artistName, title);
const info = await promise;
// A page-load network burst can time one source out; show whatever
// arrived but don't pin a partial result for the session — the next
// open retries (the backend caches each source, so retries are cheap).
if (!info || !info.description || (!info.genre && !info.label)) {
albumInfoCache.delete(key);
}
harvestDescHint(artistId, title, info);
if (
String(page?.artist?.id ?? "") === String(artistId)
&& openFamilyKey === familyKey
) openInfo = info;
}
/** Hover-intent description prefetch: 150ms of dwell on a card warms its
* album info, so the expander's description is a cache hit by click time
* (~3ms instead of ~400ms cold). One timer — moving between cards
* retargets it; leaving cancels; `fetchAlbumInfo` dedups repeats. */
let descPrefetchTimer = 0;
function queueDescPrefetch(family) {
clearTimeout(descPrefetchTimer);
descPrefetchTimer = setTimeout(() => {
const artistId = page?.artist?.id ?? null;
const artistName = page?.artist?.name ?? "";
const title = family.title || family.baseTitle;
if (artistId == null || !artistName || !title) return;
fetchAlbumInfo(artistId, artistName, title);
}, 150);
}
function cancelDescPrefetch() {
clearTimeout(descPrefetchTimer);
}
onDestroy(() => clearTimeout(descPrefetchTimer));
function fetchAlbumInfo(artistId, artistName, title) {
const key = `${String(artistId)}:${title}`;
if (!albumInfoCache.has(key)) {
albumInfoCache.set(
key,
api.catalogAlbumInfo(artistId, artistName, title).catch(() => null),
);
while (albumInfoCache.size > ALBUM_INFO_CACHE_MAX) {
albumInfoCache.delete(albumInfoCache.keys().next().value);
}
}
return { key, promise: albumInfoCache.get(key) };
}
/** Buffered hint application: each write used to reassign a `descHints`
* key immediately, so a 12-album harvest re-ran filing and animated a
* FLIP re-flow a dozen times in a row. Arrivals now pool for 250ms and
* land as ONE update — same final filing, one visible correction. */
function flushDescHints() {
hintFlushTimer = 0;
const entries = Object.entries(pendingHints);
if (!entries.length) return;
pendingHints = {};
descHints = { ...descHints, ...Object.fromEntries(entries) };
}
function harvestDescHint(artistId, title, info) {
if (!info?.type_hint || !title) return;
if (String(page?.artist?.id ?? "") !== String(artistId)) return;
const key = norm(editionBaseTitle(title));
pendingHints[key] = info.type_hint;
if (import.meta.env.DEV) {
(globalThis.__descHints ??= {})[key] = info.type_hint;
}
if (!hintFlushTimer) hintFlushTimer = setTimeout(flushDescHints, 250);
}
// Background hint harvest: the sorting evidence must not depend on the
// user opening every card. It starts as soon as the OVERVIEW's albums
// exist (~0.6s into a cold page) instead of waiting for the exhaustive
// page — the harvest touches TheAudioDB/Wikipedia, which the page burst
// (Deezer/Apple) barely uses, and its one MusicBrainz URL is the same
// one the identity warmup already has in flight, so overlapping them
// adds no provider pressure. When the full page lands and re-shuffles
// the tabs, the effect re-runs and TOPS UP the queue with any new
// first-14 Albums-tab titles, so final coverage is what it always was.
// `harvestQueued` is the loop-breaker: hint arrivals recompute `tabs`,
// which re-runs this effect, and only never-enqueued titles are added.
// Four workers, not an unbounded fan-out: the backend paces its own
// providers, but a 14-wide burst still serializes behind those gates
// while a strictly sequential walk paid full round-trip latency per
// title (measured 5.2x slower than fanning out).
const HARVEST_SLICE = 14;
const HARVEST_ENQUEUE_CAP = 28;
const HARVEST_WORKERS = 4;
$effect(() => {
const artistId = page?.artist?.id ?? null;
const artistName = page?.artist?.name ?? "";
if (!artistId || !artistName || page?.loading_shell) return;
// The reset effect above rebinds the harvest state on artist change.
// It is declared before this effect, and Svelte runs sibling effects
// in creation order within a flush, so by the time this body runs for
// a new artist the rebind has happened; the guard is belt-and-braces
// (an early-returned run would NOT be re-triggered by the reset's
// writes, since it never read `descHints`/`tabs`).
if (hintHarvestFor !== artistId) return;
const albumTab = tabs.find((tab) => tab.key === "album");
if (!albumTab) return;
let added = 0;
for (const family of albumTab.items.slice(0, HARVEST_SLICE)) {
const title = family.title || family.baseTitle;
if (!title) continue;
const key = norm(editionBaseTitle(title));
if (harvestQueued.has(key) || descHints[key] || pendingHints[key]) continue;
if (harvestQueued.size >= HARVEST_ENQUEUE_CAP) break;
harvestQueued.add(key);
harvestQueue.push(title);
added += 1;
}
if (import.meta.env.DEV && added && !globalThis.__harvestStarted) {
globalThis.__harvestStarted = `${artistId}:${albumTab.items.length}`;
}
// Kick whenever work is QUEUED, not only when this run added some:
// after an artist switch the previous artist's workers can still hold
// all the slots for a moment, so the run that enqueues the new titles
// may start zero workers — a later run must be able to start them.
if (harvestQueue.length) startHarvestWorkers();
});
function startHarvestWorkers() {
while (harvestRunning < HARVEST_WORKERS && harvestRunning < harvestQueue.length) {
harvestRunning += 1;
void (async () => {
try {
// Brief settle so the overview's own paint wins the frame.
await new Promise((resolve) => setTimeout(resolve, 300));
// Workers are artist-agnostic drainers: they re-read the
// CURRENT artist every iteration (the reset effect rebinds
// the queue on artist change, and this closure sees the
// new binding), so a worker that outlives a navigation
// simply continues on the new artist's queue instead of
// dying and stranding it.
while (harvestQueue.length) {
if (harvestDestroyed) return;
const artistId = page?.artist?.id ?? null;
const artistName = page?.artist?.name ?? "";
if (
artistId == null
|| !artistName
|| hintHarvestFor !== artistId
) return;
const title = harvestQueue.shift();
const key = norm(editionBaseTitle(title));
if (descHints[key] || pendingHints[key]) continue;
const { promise } = fetchAlbumInfo(artistId, artistName, title);
harvestDescHint(artistId, title, await promise);
}
} finally {
harvestRunning -= 1;
}
})();
}
}
/** Saved-track ticks: when an edition of this family is in the library,
* mark the expander's rows that exist in that local copy. */
async function loadLibraryTicks(family) {
const generation = ++libraryTickGeneration;
libraryTicks = new Set();
const artistId = page?.artist?.id ?? null;
const artist = page?.artist?.name ?? "";
const familyKey = family.familyKey;
if (artistId == null || !artist) return;
const local = family.editions
.map((edition) => libraryLocalAlbum(artist, edition))
.find(Boolean);
if (!local) return;
try {
const tracks = await api.albumTracks(local.id);
if (
generation === libraryTickGeneration
&& String(page?.artist?.id ?? "") === String(artistId)
&& openFamilyKey === familyKey
) {
libraryTicks = new Set(tracks.map((t) => norm(t.title || "")));
}
} catch {
// library unavailable — ticks simply stay off
}
}
// The staged catalog can rebuild `tabs` (MB supplement, exhaustive
// pass) and re-key the open family — e.g. an `edition_group` id landing
// switches its key form. Re-attach the open expander to the successor
// family (following it to whichever tab now holds it) instead of letting
// it silently vanish under the user's click.
$effect(() => {
if (!openFamilyKey) return;
const families = tabs.flatMap((tab) => tab.items);
const current = families.find((f) => f.familyKey === openFamilyKey);
if (current) return;
const successor = families.find((f) =>
f.editions.some((e) => e.id === openAlbumId) || f.id === openAlbumId);
if (successor) {
openFamilyKey = successor.familyKey;
const owner = tabs.find((tab) => tab.items.some((f) => f.familyKey === successor.familyKey));
if (owner) activeTabKey = owner.key;
} else {
closeExpander();
}
});
// Esc, and clicking away from the album WITHIN the artist page, close the
// panel. Deliberately scoped to `.artist-page`: reaching for the player
// bar, the sidebar, the Now Playing panel or the title bar is not
// "browsing away from this album", so those clicks leave it open.
// Clicking another card is excluded here — its own handler switches the
// expander over to it.
$effect(() => {
if (!openFamilyKey) return;
const onKey = (e) => {
if (e.key === "Escape") closeExpander();
};
const onDown = (e) => {
if (!e.target.closest(".artist-page")) return;
if (e.target.closest(".release, .album-expander")) return;
closeExpander();
};
window.addEventListener("keydown", onKey);
window.addEventListener("mousedown", onDown);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("mousedown", onDown);
};
});
// `.content` is the app's scroll pane. Ask for it by name and only walk as
// a fallback — and when walking, test whether the box actually SCROLLS.
// "Taller than its client box" is not the same question: round 12 put a
// 300vh sheet inside `.artist-page`, so that box overflows while staying
// `overflow: visible`, and the old walk stopped there. Assigning scrollTop
// to it is a silent no-op, which is exactly how the album reveal died.
function scrollPaneFor(node) {
const named = node.closest(".content");
if (named) return named;
let pane = node.parentElement;
while (pane) {
const overflow = getComputedStyle(pane).overflowY;
if (
(overflow === "auto" || overflow === "scroll")
&& pane.scrollHeight > pane.clientHeight + 4
) {
return pane;
}
pane = pane.parentElement;
}
return null;
}
// Opening (or moving) the expander scrolls the clicked album's row to
// the top of the pane, so the row + its expander read like the album's
// own page — a low click never leaves the panel off-screen.
// Hand-driven scroll, NOT scrollIntoView({behavior:"smooth"}): smooth
// scrollIntoView computes its destination once, and the expander's
// height animation + Chromium scroll anchoring yank that destination
// mid-flight (measured settling ~500px short). Recomputing the target
// every frame and setting scrollTop directly is immune to both, and
// also tracks an old expander collapsing above during row switches.
function revealIntoView(node) {
const row = node.previousElementSibling; // last card of the open row
const pane = scrollPaneFor(node);
if (!row || !pane) return;
// Clears the sticky results-head AND the parked banner under it, plus
// breathing room. READ, not hard-coded: --banner-h is a fraction of the
// hero now, so a constant tuned to the old 124px banner would drop a
// clicked row underneath a taller one. Matches .release's
// scroll-margin-top, which is the same expression in CSS.
const page = node.closest(".artist-page");
const banner = page
? parseFloat(getComputedStyle(page).getPropertyValue("--banner-h")) || 176
: 176;
const MARGIN = Math.round(22 + banner + 18);
const DURATION = 420;
const from = pane.scrollTop;
const start = performance.now();
let raf = requestAnimationFrame(function step(now) {
const t = Math.min(1, (now - start) / DURATION);
const ease = 1 - (1 - t) ** 3;
const target = row.getBoundingClientRect().top
- pane.getBoundingClientRect().top + pane.scrollTop - MARGIN;
pane.scrollTop = from + (target - from) * ease;
raf = t < 1 ? requestAnimationFrame(step) : null;
});
const cancel = () => raf && cancelAnimationFrame(raf);
// The user's own scrolling wins immediately.
pane.addEventListener("wheel", cancel, { once: true, passive: true });
return {
destroy: () => {
cancel();
pane.removeEventListener("wheel", cancel);
},
};
}
// Mock-exact open/close motion: the row's height collapses while the
// content lifts 8px and fades, on a fast-out/long-settle curve (quintOut
// tracks the mock's cubic-bezier(0.33, 1, 0.4, 1) within a percent).
function expandSlide(node, { duration = 460 } = {}) {
const height = node.offsetHeight;
return {
duration,
easing: quintOut,
css: (t, u) =>
`overflow: hidden; height: ${t * height}px; min-height: 0;` +
`opacity: ${Math.min(1, t / 0.74)}; transform: translateY(${-8 * u}px);`,
};
}
const openAlbum = $derived(
page?.albums.find((a) => a.id === openAlbumId) ?? null,
);
// Scoped browse accent: the open album's own palette, applied as local
// --accent/--accent-hover overrides on the open card and expander only.
// The global accent keeps following the playing track everywhere else.
let browseAccent = $state(null); // { accent, hover } | null
const accentColorCache = new Map(); // art source url -> Promise<colors|null>
const ACCENT_CACHE_MAX = 96;
function extractAccent(source) {
if (!accentColorCache.has(source)) {
// Palette extraction needs only color distribution, not canonical
// cover resolution. Sampling the CDN's tiny variant avoids a full
// image proxy/base64/decode pass for every card.
const sampleSource = tinyCover(source) ?? gridCover(source);
const pending = (async () => {
try {
const dataUrl = await getCatalogArt(sampleSource);
return dataUrl ? await accentFromImage(dataUrl) : null;
} catch {
return null;
}
})();
accentColorCache.set(source, pending);
while (accentColorCache.size > ACCENT_CACHE_MAX) {
accentColorCache.delete(accentColorCache.keys().next().value);
}
// Failures are transient (load-burst contention, CDN hiccups,
// the art proxy's 10s timeout) — never cache a null palette, so
// a later attempt retries instead of staying colorless forever.
pending.then((colors) => {
if (!colors && accentColorCache.get(source) === pending) {
accentColorCache.delete(source);
}
});
}
return accentColorCache.get(source);
}
const accentStyle = (colors) =>
`--accent: ${colors.accent}; --accent-hover: ${colors.hover};`;
$effect(() => {
const cover = openAlbum?.cover ?? openFamily?.cover ?? null;
if (!openFamilyKey || !cover || artBroken[cover]) {
browseAccent = null;
return;
}
let stale = false;
extractAccent(coverUpgrades[cover] ?? cover).then((colors) => {
if (!stale) browseAccent = colors;
});
return () => { stale = true; };
});
const browseAccentStyle = $derived(browseAccent ? accentStyle(browseAccent) : undefined);
// Per-card scoped accents: a card's chrome (edition badge, Playing pill,
// hover play disc) tints from its OWN cover, not the global accent. The
// shared viewport gate queues only nearby cards; a 100-release Singles tab
// no longer performs 100 Rust proxies, base64 allocations and canvas reads
// just because its metadata exists.
let cardAccentStyles = $state({}); // canonical cover url -> style string
const accentQueue = [];
const accentQueued = new Set();
let accentQueueActive = 0;
let accentArtistId = null;
let accentGeneration = 0;
function queueCardAccent(cover, prioritize = false) {
if (!cover || artBroken[cover]) return;
if (untrack(() => cardAccentStyles[cover] !== undefined)) return;
if (accentQueued.has(cover)) {
if (prioritize) {
const index = accentQueue.indexOf(cover);
if (index > 0) {
accentQueue.splice(index, 1);
accentQueue.unshift(cover);
}
}
return;
}
accentQueued.add(cover);
if (prioritize) accentQueue.unshift(cover);
else accentQueue.push(cover);
pumpAccentQueue();
}
function pumpAccentQueue() {
while (accentQueueActive < 2 && accentQueue.length) {
const cover = accentQueue.shift();
accentQueueActive += 1;
const generation = accentGeneration;
const extraction = extractAccent(coverUpgrades[cover] ?? cover);
extraction.then((colors) => {
// Stored even if the slot below already moved on — a slow
// fetch still delivers its palette late instead of never.
if (colors && generation === accentGeneration) {
cardAccentStyles[cover] = accentStyle(colors);
}
});
// Free the slot after 12s regardless (the art proxy settles in
// ≤10s) so one slow transfer can't clog the whole queue.
Promise.race([
extraction,
new Promise((resolve) => setTimeout(resolve, 12000)),
]).then(() => {
accentQueueActive = Math.max(0, accentQueueActive - 1);
if (generation === accentGeneration) accentQueued.delete(cover);
pumpAccentQueue();
});
}
}
$effect(() => {
const artistId = page?.artist?.id ?? null;
if (artistId !== accentArtistId) {
accentArtistId = artistId;
accentGeneration += 1;
accentQueue.length = 0;
accentQueued.clear();
cardAccentStyles = {};
}
});
onDestroy(() => {
accentGeneration += 1;
accentQueue.length = 0;
accentQueued.clear();
});
const railArtAlbum = $derived(
page?.albums.find((a) => a.id === railArtAlbumId) ?? openAlbum,
);
const previousRailArtAlbum = $derived(
page?.albums.find((a) => a.id === previousRailArtAlbumId) ?? null,
);
// Disc-aware view of the open tracklist: single downward column with
// quiet disc rules and per-disc numbering when there is more than one.
const openDiscs = $derived.by(() => {
const by = new Map();
for (const t of openTracks) {
const disc = t.disk_number ?? 1;
if (!by.has(disc)) by.set(disc, []);
by.get(disc).push(t);
}
return [...by.entries()]
.sort((a, b) => a[0] - b[0])
.map(([disc, tracks]) => ({ disc, tracks }));
});
const multiDisc = $derived(openDiscs.length > 1);
const openReleaseLocked = $derived(
isUpcomingAlbum(openAlbum ?? openFamily ?? {})
|| openTracks.some((track) => track.unreleased),
);
function fullDate(album) {
const value = album?.release_date;
if (!value || value.length < 10) return year(album ?? {});
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
return date.toLocaleDateString(undefined, {
month: "numeric", day: "numeric", year: "numeric",
});
}
function fmtRuntime(tracks) {
const secs = tracks.reduce((sum, t) => sum + (t.duration_secs || 0), 0);
if (!secs) return "";
const mins = Math.round(secs / 60);
return mins >= 60 ? `${Math.floor(mins / 60)} hr ${mins % 60} min` : `${mins} min`;
}
function year(album) {
return album.release_date?.slice(0, 4) ?? "";
}
function upcomingDate(album) {
const value = album.release_date;
if (!value) return "Upcoming · Date TBA";
if (value.length === 4) return `Expected ${value} · Date TBA`;
if (value.length < 10) {
const month = new Date(`${value.slice(0, 7)}-01T00:00:00`).toLocaleDateString(undefined, {
month: "short", year: "numeric",
});
return `Expected ${month} · Date TBA`;
}
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
return `Releases ${date.toLocaleDateString(undefined, {
month: "short", day: "numeric", year: "numeric",
})}`;
}
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
function fmtFans(n) {
if (n == null) return "";
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M fans`;
if (n >= 1e3) return `${Math.round(n / 1e3)}k fans`;
return `${n} fans`;
}
function fmtBytes(n) {
if (!n) return "";
if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`;
if (n >= 1e6) return `${Math.round(n / 1e6)} MB`;
return `${Math.round(n / 1e3)} kB`;
}
// Disk space + audio quality of this artist's saved library files (the
// "on disk" and quality chips). Track sizes/formats are already in the
// library DB, so this is local-only work — no provider calls — but it does
// need one album_tracks fetch per matching library album, so it's keyed to
// skip recomputation until the artist or their library albums change.
let artistDisk = $state({ key: "", bytes: 0, quality: "", perAlbum: [] });
let artistDiskRequest = 0;
const LOSSLESS_EXT = new Set(["flac", "wav", "ape", "aiff", "alac"]);
$effect(() => {
const artistName = page?.artist.name ?? "";
const a = norm(artistName);
const mine = libraryStore.albums.filter((al) => norm(al.artist || "") === a);
const key = `${a}:${mine.map((al) => al.id).join(",")}`;
const request = ++artistDiskRequest;
if (!a || artistDisk.key === key) return;
let active = true;
(async () => {
let bytes = 0;
const all = [];
const perAlbum = [];
for (const al of mine) {
if (!active || request !== artistDiskRequest) return;
try {
const tracks = await api.albumTracks(al.id);
if (!active || request !== artistDiskRequest) return;
let albumBytes = 0;
for (const t of tracks) {
albumBytes += t.size ?? 0;
all.push(t);
}
bytes += albumBytes;
if (albumBytes > 0) perAlbum.push({ title: al.title, bytes: albumBytes });
} catch {
// A missing album is just excluded from the total.
}
}
perAlbum.sort((x, y) => y.bytes - x.bytes);
// Another artist may have loaded while sizes were being fetched.
if (
active
&& request === artistDiskRequest
&& norm(catalog.artistPage?.artist.name ?? "") === a
) {
artistDisk = { key, bytes, quality: qualityLabel(all), perAlbum };
}
})();
return () => (active = false);
});
/** Dominant format of the saved tracks, as "FLAC · 24-bit" / "MP3 · 320".
* Majority wins so one stray MP3 in a FLAC library doesn't flip the label;
* the detail (bit depth / bitrate) is the majority among that format. */
function qualityLabel(tracks) {
const byExt = new Map();
for (const t of tracks) {
const ext = (/\.(\w+)$/.exec(t.path ?? "")?.[1] ?? "").toLowerCase();
if (!ext) continue;
if (!byExt.has(ext)) byExt.set(ext, []);
byExt.get(ext).push(t);
}
if (!byExt.size) return "";
const [ext, group] = [...byExt.entries()].reduce((top, entry) =>
entry[1].length > top[1].length ? entry : top,
);
const majority = (values) => {
const counts = new Map();
for (const v of values) {
if (v == null) continue;
counts.set(v, (counts.get(v) ?? 0) + 1);
}
let best = null;
for (const [v, n] of counts) {
if (best == null || n > counts.get(best)) best = v;
}
return best;
};
const label = ext === "aiff" ? "AIFF" : ext.toUpperCase();
if (LOSSLESS_EXT.has(ext)) {
const depth = majority(group.map((t) => t.bit_depth));
return depth ? `${label} · ${depth}-bit` : label;
}
const rate = majority(group.map((t) => t.bitrate_kbps));
return rate ? `${label} · ${rate}` : label;
}
// Listening history for the play-time / last-played chips. Keyed by the
// artist name so navigation resets immediately (no stale stats flash)
// and a same-artist page replacement (art_preview → full) doesn't refetch.
let playStats = $state({ key: "", seconds: 0, last: null, topTitle: null, topCount: 0 });
$effect(() => {
const name = page?.artist.name ?? "";
if (playStats.key === name) return;
playStats = { key: name, seconds: 0, last: null, topTitle: null, topCount: 0 };
if (!name) return;
(async () => {
try {
const stats = await api.artistPlayStats(name);
if ((catalog.artistPage?.artist.name ?? "") === name) {
playStats = {
key: name,
seconds: stats.seconds ?? 0,
last: stats.last_played_at ?? null,
topTitle: stats.top_title ?? null,
topCount: stats.top_count ?? 0,
};
}
} catch {
// Backend unavailable — the chips simply stay hidden.
}
})();
});
function fmtPlayTime(secs) {
if (!secs || secs < 60) return "";
if (secs < 3600) return `${Math.round(secs / 60)} min`;
return `${(secs / 3600).toFixed(1)} hours`;
}
function fmtLastPlayed(ts) {
if (!ts) return "";
const d = new Date(ts * 1000);
const now = new Date();
if (d.toDateString() === now.toDateString()) return "Today";
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
const opts = { month: "short", day: "numeric" };
if (d.getFullYear() !== now.getFullYear()) opts.year = "numeric";
return d.toLocaleDateString(undefined, opts);
}
// Social-link display: icon + pale desaturated brand tint per kind (the
// approved "ghost icons" treatment — full brand colours would fight the
// adaptive accent theme). `rgb` feeds the hover bloom via rgba().
const SOCIAL_META = {
website: { icon: "globe", label: "Website", rgb: "160,168,177" },
instagram: { icon: "brand-instagram", label: "Instagram", rgb: "213,154,171" },
x: { icon: "brand-x", label: "X", rgb: "213,217,221", solid: true },
facebook: { icon: "brand-facebook", label: "Facebook", rgb: "143,168,205" },
tiktok: { icon: "brand-tiktok", label: "TikTok", rgb: "159,196,204" },
youtube: { icon: "brand-youtube", label: "YouTube", rgb: "201,143,143" },
bandcamp: { icon: "brand-bandcamp", label: "Bandcamp", rgb: "127,173,189", solid: true },
soundcloud: { icon: "brand-soundcloud", label: "SoundCloud", rgb: "208,162,132" },
};
// TODO(follow): visual-only for now — no persistence. Resets when the
// artist changes so a heart never carries over to the next page.
let followed = $state(false);
let followedArtistId = null;
$effect(() => {
const id = page?.artist.id ?? null;
if (id !== followedArtistId) {
followedArtistId = id;
followed = false;
}
});
// First→latest release year of the released discography ("years active").
// A release within the last two calendar years reads as "present".
const activeYears = $derived.by(() => {
if (!page) return null;
const years = page.albums
.filter((album) => !isUpcomingAlbum(album))
.map((album) => Number(album.release_date?.slice(0, 4)))
.filter((y) => Number.isFinite(y) && y > 1900);
if (!years.length) return null;
const first = Math.min(...years);
const last = Math.max(...years);
const nowYear = new Date().getFullYear();
return last >= nowYear - 1 ? `${first}–present` : `${first}–${last}`;
});
function playPopular(track) {
if (!page) return;
streamSong(page.artist.name, track.title, {
cover: track.cover,
trackId: track.id,
albumId: track.album_id,
album: track.album || null,
expectSecs: track.duration_secs ?? null,
});
}
let heroBusy = $state(false);
let heroNotice = $state("");
let heroNoticeTimer;
onDestroy(() => clearTimeout(heroNoticeTimer));
function showHeroNotice(message) {
heroNotice = message;
clearTimeout(heroNoticeTimer);
heroNoticeTimer = setTimeout(() => (heroNotice = ""), 5000);
}
async function chooseHeroPhoto() {
if (!page || heroBusy) return;
heroBusy = true;
try {
const imported = await api.pickArtistHero(page.artist.id);
if (imported) {
const seed = page.artist;
invalidateCatalogArtist(page.artist.id);
clearCatalogHeroArtCache();
await openCatalogArtist(page.artist.id, seed);
showHeroNotice(`Custom hero saved · ${imported.width}×${imported.height}`);
}
} catch (error) {
showHeroNotice(String(error).replace(/^Error:\s*/, ""));
} finally {
heroBusy = false;
}
}
async function clearHeroPhoto() {
if (!page || heroBusy) return;
heroBusy = true;
try {
await api.clearArtistHero(page.artist.id);
const seed = page.artist;
invalidateCatalogArtist(page.artist.id);
clearCatalogHeroArtCache();
await openCatalogArtist(page.artist.id, seed);
showHeroNotice("Custom hero removed");
} catch (error) {
showHeroNotice(String(error).replace(/^Error:\s*/, ""));
} finally {
heroBusy = false;
}
}
</script>
<div class="artist-page" data-load-stage={pageLoadStage} data-scroll={heroScrolled ? "1" : "0"}>
{#if catalog.artistLoading && !page}
<button class="back flow" onclick={() => (ui.view = "catalog")}>
<Icon name="chevron-left" size={16} /> Results
</button>
{#if resolver.active}
<p class="resolving"><span class="pulse">●</span> {resolver.label} — finding the best source…</p>
{:else if resolver.error}
<p class="resolving error">{resolver.error}</p>
{/if}
<p class="hint">Loading artist…</p>
{:else if !page}
<button class="back flow" onclick={() => (ui.view = "catalog")}>
<Icon name="chevron-left" size={16} /> Results
</button>
{#if resolver.error}
<p class="resolving error">{resolver.error}</p>
{/if}
<p class="hint">{catalog.error || "Artist not found."}</p>
{:else}
<!-- ROUND 12, "three layers" (the user's own model, mocked and
approved as research/artist-header-scroll/v12-three-layers.html
build 5). Content on top (z 3) → the page background SHEET with
its gradient edge dissolving into the hero (z 2) → the hero at
the bottom (z 1), pinned, NEVER cut or masked. The seams of
rounds 7-10 came from recomputing gradient stops against the
scroll — here every gradient is baked and only whole layers
translate, so there is no boundary a line can form on.
LAYER 1 — THE HERO, PINNED, AT THE BOTTOM. It never moves and is
never cut; the sheet and the content slide over it. Only filters
and the round-9 uniform squeeze (rigid translations of an
unmasked layer) ride --p. -->
<div class="heropin" use:heroScroll>
<!-- Banner-as-background: the full artwork at native aspect, bled to
the pane edges, extending down behind the content and dissolving
into the page. A blurred bloom fills the strip behind the glass bar
(behind the sharp photo, so the artwork itself is untouched); the
vertical scrim finishes the lower dissolve. During rotation,
the outgoing pair stays underneath until the new pair fades in. -->
<div class="hero-bg" aria-hidden="true">
<div class="hero-fallback" style="--seed-hue: {seedHue}"></div>
{#if seedUrl}
<div
class="hero-seed"
class:hidden={heroPainted}
style="background-image: url('{seedUrl}')"
></div>
{/if}
{#if previousBanner?.url}
<img class="bloom previous" src={previousBanner.url} alt="" draggable="false" />
<img class="banner previous" src={previousBanner.url} alt="" draggable="false" />
{/if}
{#if selectedUsesGpu && banner}
<img class="bloom gpu-underlay" src={banner} alt="" draggable="false" />
<img class="banner gpu-underlay" src={banner} alt="" draggable="false" />
{/if}
{#if gpuRendererMounted}
<HeroGpuCanvas
source={selectedUsesGpu ? selectedHeroSource : ""}
scope={page?.artist.id ?? ""}
plan={displayedBanner.source === selectedHeroSource
? displayedBanner.plan ?? null
: null}
active={documentVisible}
onloading={gpuHeroLoading}
onready={gpuHeroReady}
onerror={gpuHeroError}
/>
{/if}
{#if banner && !selectedUsesGpu}
{#key displayedBanner.key}
<img class="bloom current" class:initial={initialBannerPaint} class:finish={finishBannerPaint} src={banner} alt="" draggable="false" />
<img
class="banner current"
class:initial={initialBannerPaint}
class:finish={finishBannerPaint}
src={banner}
alt=""
draggable="false"
onerror={() => {
const source = displayedBanner.source;
if (source && !failedBannerSources.includes(source)) {
failedBannerSources = [...failedBannerSources, source];
}
displayedBanner = previousBanner ?? { source: "", url: null, key: "" };
previousBanner = null;
advanceHero(source);
}}
/>
{/key}
{:else if !selectedUsesGpu}
<div class="banner base"></div>
{/if}
</div>
</div>
<!-- LAYER 2 — THE SHEET. The page background itself: its top edge is
a BAKED gradient dissolving into the hero (the live scrim's own
easing), and below that it IS the page. It translates RIGIDLY
between two poses on --p — at rest the dissolve sits on the
hero's foot exactly as the old .hero-scrim did; parked, its edge
rides just above the content edge (the pose the user approved).
This one gradient replaces the scrim, the veil, the eraser and
the wash of rounds 7-10: fewer layers, nothing to hand over
between them, and a baked gradient moved as a whole cannot
seam. -->
<div class="sheetpin" aria-hidden="true"><div class="sheet"></div></div>
<!-- LAYER 30 — THE IDENTITY. The block's bottom IS --edge at every
scroll position, so the wordmark and the Play/chips row are
anchored to the same moving line the artwork is cut at and can
never drift from it. Both children are absolutely positioned
and the pin is height:0, so the layer adds nothing to the flow;
the room they occupied at rest is reserved by .hero-zone and
.actions-gap below. -->
<div class="idpin" data-w={chipTier}>
<div class="idblock">
<div class="hero-copy">
<!-- Reserved-height title slot: the text name and the logo share
one bottom-left origin and crossfade, so the wordmark fades
in over the name without a size/position pop or reflow. -->
<div class="logo-slot" style="--slot-h: {logoRenderHeight}px">
{#if page.artist.name}
<h1 class="name-title" class:gone={logo.mode !== "none"}>{page.artist.name}</h1>
{:else}
<div class="name-title loading-name" aria-label="Loading artist"></div>
{/if}
{#if logo.mode === "mask"}
<div class="logo-wrap">
<div
class="artist-logo mask"
style="--logo: url('{logo.url}'); --logo-aspect: {logo.aspect}; --logo-h: {logoRenderHeight}px"
role="img"
aria-label={page.artist.name}
></div>
</div>
{:else if logo.mode === "color"}
<div class="logo-wrap">
<img
class="artist-logo"
src={logo.url}
style="--logo-h: {logoRenderHeight}px"
alt={page.artist.name}
draggable="false"
/>
</div>
{/if}
</div>
</div>
{#if popularTracks.length}
<div class="artist-actions">
<button class="hero-play" title="Play popular songs" onclick={() => playPopular(popularTracks[0])}>
<Icon name="play" size={21} />
</button>
<!-- Preview tracks: a secondary circle in the transport
slot, never inside .stat-chips — the chip row sheds
its tail on narrow panes and an action must not be
what disappears. The slot is unconditional so the
chips beside it cannot shift as staged data lands;
only the button's own state changes. -->
<button
class="hero-preview"
disabled={previewState === "none"}
title={previewLabel}
aria-label={previewLabel}
onclick={openPreviews}
>
<Icon name="preview-reel" size={18} strokeWidth={1.8} />
{#if previewState === "partial"}
<span class="preview-count" aria-hidden="true">{previewableCount}</span>
{/if}
</button>
<!-- The hero's old meta line, repurposed as floating stat
chips beside Play (Steam-style, but free-floating —
deliberately no containing bar). Each chip hides when
its stat is empty rather than showing a zero. -->
{#if !page.art_preview}
<div class="stat-chips">
{#if releasedCount > 0}
<span class="chip"><Icon name="disc" size={13} /> {releasedCount} releases</span>
{/if}
{#if page.artist.fans}
<span class="chip"><Icon name="users" size={13} /> {fmtFans(page.artist.fans)}</span>
{/if}
{#if activeYears}
<span class="chip" title="Years active"><Icon name="calendar" size={13} /> {activeYears}</span>
{/if}
{#if libraryOwnedCount > 0 && releasedCount > 0}
<span class="chip" title="{libraryOwnedCount} of {releasedCount} releases in your library">
<Icon name="library" size={13} /> {libraryOwnedCount}/{releasedCount}
<span class="chip-bar"><span
class="chip-fill"
style="width: {Math.min(100, Math.round((libraryOwnedCount / releasedCount) * 100))}%"
></span></span>
</span>
{/if}
{#if artistDisk.bytes > 0}
<span class="chip has-pop">
<Icon name="hard-drive" size={13} /> {fmtBytes(artistDisk.bytes)}
{#if artistDisk.perAlbum.length}
<span class="chip-pop" role="tooltip">
{#each artistDisk.perAlbum as row (row.title)}
<span class="pop-row">
<span class="pop-title">{row.title}</span>
<span class="pop-val">{fmtBytes(row.bytes)}</span>
</span>
{/each}
</span>
{/if}
</span>
{/if}
{#if artistDisk.quality}
<span class="chip" title="Dominant format of your saved tracks">
<Icon name="music" size={13} /> {artistDisk.quality}
</span>
{/if}
{#if playStats.seconds >= 60}
<span class="chip" title="Total listening time">
<Icon name="clock" size={13} /> {fmtPlayTime(playStats.seconds)}
</span>
{/if}
{#if playStats.last}
<span class="chip" title="Last played">
<Icon name="history" size={13} /> {fmtLastPlayed(playStats.last)}
</span>
{/if}
{#if playStats.topTitle && playStats.topCount >= 2}
<span class="chip" title="Your most played track">
<Icon name="trophy" size={13} /> {playStats.topTitle} · {playStats.topCount}×
</span>
{/if}
{#if page.top_genre}
<span class="chip" title="Top genre">
<Icon name="tag" size={13} /> {page.top_genre}
</span>
{/if}
<!-- TODO(follow): visual toggle only — persistence
and the followed-artists feature come later. -->
<button
class="chip follow"
class:on={followed}
title={followed ? "Unfollow artist" : "Follow artist"}
aria-pressed={followed}
onclick={() => (followed = !followed)}
><Icon name="heart" size={14} fill={followed} /></button>
{#if page.socials?.length}
<span class="chip-divider" aria-hidden="true"></span>
{#each page.socials as link (link.kind)}
{@const meta = SOCIAL_META[link.kind] ?? SOCIAL_META.website}
<a
class="social-ghost"
href={link.url}
target="_blank"
rel="noreferrer"
title={meta.label}
style="--soc: {meta.rgb}"
onclick={(e) => { e.preventDefault(); api.openExternal(link.url); }}
><Icon name={meta.icon} size={16} fill={meta.solid ?? false} /></a>
{/each}
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Frosted-glass header in parity with the Now Playing panel's
.np-head; it floats over the banner's bloom strip at the top.
data-scrollhead keeps the overlay scrollbar below this bar. -->
<div class="results-head" data-scrollhead>
<button class="back" onclick={() => (ui.view = "catalog")}>
<Icon name="chevron-left" size={16} /> Results
</button>
{#if resolver.active}
<span class="rstatus"><span class="pulse">●</span> {resolver.label} — finding the best source…</span>
{:else if resolver.error}
<span class="rstatus error">{resolver.error}</span>
{/if}
<div class="hero-art-actions">
{#if heroNotice}
<span class:error={heroNotice.includes("Choose") || heroNotice.includes("must")} class="hero-art-notice">{heroNotice}</span>
{/if}
<button
class:active={page.banner_custom}
class="hero-art-btn"
title="Choose a high-resolution hero photo (minimum 1920×800)"
disabled={heroBusy || page.artist.id == null}
onclick={chooseHeroPhoto}
>
<Icon name="image" size={15} />
<span>{page.banner_custom ? "Change hero" : "Choose hero"}</span>
</button>
{#if page.banner_custom}
<button
class="hero-art-btn icon-only"
title="Remove custom hero"
disabled={heroBusy}
onclick={clearHeroPhoto}
><Icon name="x" size={14} /></button>
{/if}
</div>
</div>
<!-- Pure spacer now: it still reserves the room the artwork needs and
still sets where the content begins (both from its aspect-ratio,
which never depended on its children), but the wordmark it used to
hold has moved into the pinned identity layer above. -->
<div class="hero-zone" use:measureHero></div>
<!-- LAYER 3 — THE CONTENT, on top of the sheet and the hero. Its one
special behaviour: rows fade out over 18px as they slide under
the banner line. Where scroll-driven animations exist, the
height-0 sticky .fade-host parks at the cut line, .fade-frame
(a real box hung off it) carries a CONSTANT clip + mask, and
.fade-flow counter-translates the content past it with a
compositor-driven scroll animation — nothing updates per frame
on the main thread, so a
fast flick can never outrun the mask (the old per-frame
--flow-cut rewrite lagged the compositor scroll and rows poked
above the cut; a scroll-driven CUSTOM PROPERTY animation lagged
identically — only transform/opacity/filter composite). Without
the mask, the top layer would climb over the banner past the
park — the exact problem that made round 7 flip the hero on top
and start cutting it. Mask gated on data-scroll: the resting
page carries none at all. Full mechanism notes at the CSS. -->
<div class="scrollbody">
<div class="fade-host">
<div class="fade-frame">
<div class="fade-flow">
{#if page.partial_error}
<p class="resolving error">{page.partial_error}</p>
{:else if page.art_preview}
<p class="resolving stage-status"><span class="pulse">●</span>
{page.albums.length || popularTracks.length
? "Refining release details…"
: "Loading songs and releases…"}
</p>
{/if}
{#if page.art_preview && !popularTracks.length && !page.albums.length}
<section class="catalog-skeleton" aria-label="Loading artist catalog">
<div class="skeleton-heading"></div>
<div class="skeleton-tracks">
{#each [0, 1, 2, 3, 4] as row (row)}
<div class="skeleton-track">
<span class="skeleton-rank"></span>
<span class="skeleton-cover"></span>
<span class="skeleton-copy"></span>
</div>
{/each}
</div>
<div class="skeleton-heading releases"></div>
<div class="skeleton-cards">
{#each [0, 1, 2, 3, 4] as card (card)}
<span class="skeleton-card"></span>
{/each}
</div>
</section>
{/if}
{#if popularTracks.length}
<section class="popular">
<!-- Reserves exactly the flow space the Play/chips row used
to occupy: the row itself now rides in the pinned identity
layer above. Its measured height is what --row-h is read
from, so the two can never drift apart. -->
<div class="actions-gap" aria-hidden="true"></div>
<h2>Popular</h2>
<div class="popular-list">
{#each popularTracks as track, index (track.id)}
<div
class="popular-row"
class:now={nowPlaying("trackId", track.id)}
role="button"
tabindex="0"
title="Play — streams from the best source"
onclick={() => playPopular(track)}
onkeydown={(e) => e.key === "Enter" && playPopular(track)}
>
<span class="rank">{nowPlaying("trackId", track.id) ? "♫" : index + 1}</span>
{#if track.cover && !artBroken[track.cover]}
<img class="track-cover" src={track.cover} alt="" loading="lazy" decoding="async" draggable="false" onerror={() => markArtBroken(track.cover)} />
{:else}
<div class="track-cover placeholder"><Icon name="disc" size={16} /></div>
{/if}
<span class="track-name">{track.title}</span>
<span class="track-album">{track.album}</span>
<span class="tdur">{fmtDuration(track.duration_secs)}</span>
<button
class="mini-get"
title="Save to library"
onclick={(e) => { e.stopPropagation(); saveTrackAction(page.artist.name, track); }}
><Icon name={saved[`t${track.id}`] === "ok" ? "check" : saved[`t${track.id}`] === "err" ? "x" : "download"} size={13} /></button>
</div>
{/each}
</div>
{#if allPopular.length > 5}
<button class="popular-more" onclick={() => (showAllPopular = !showAllPopular)}>
{showAllPopular ? "Show less" : "Show more"}
</button>
{/if}
</section>
{/if}
{#if tabs.length}
<div class="disco-head">
<h2 class="disco-title">Discography</h2>
</div>
<div class="disco-tabs" role="tablist">
{#each tabs as tab (tab.key)}
<button
class="disco-tab"
class:on={activeTabKey === tab.key}
role="tab"
aria-selected={activeTabKey === tab.key}
onclick={() => selectTab(tab.key)}
>{tab.label}</button>
{/each}
</div>
{/if}
{#each visibleTabs as group (group.key)}
<div class="grid" use:measureCols={group.key}>
{#each group.items as al, cardIndex (al.familyKey)}
<div
class="release"
class:active={openFamilyKey === al.familyKey}
class:now={familyNowPlaying(al)}
style={(openFamilyKey === al.familyKey ? browseAccentStyle : undefined) ?? cardAccentStyles[al.cover]}
data-fkey={al.familyKey}
in:fade={{ duration: gridAnimated ? 260 : 0 }}
role="button"
tabindex="0"
title={openFamilyKey === al.familyKey ? "Close" : `Open ${al.title}`}
onclick={() => toggleExpander(al)}
onkeydown={(e) => e.key === "Enter" && toggleExpander(al)}
onmouseenter={() => {
queueCardAccent(al.cover, true);
queueDescPrefetch(al);
}}
onmouseleave={cancelDescPrefetch}
>
<div class="cover-wrap">
{#if familyNowPlaying(al)}
<span class="now-badge" class:live={player.status === "playing" && documentVisible}>
<span class="eqb"><b></b><b></b><b></b></span>Playing
</span>
{/if}
{#if al.editions.length > 1}
<span class="ed-badge" title="{al.editions.length} editions">
<Icon name="layers" size={10} /> {al.editions.length}
</span>
{/if}
{#if al.cover && !artBroken[al.cover]}
<div class="cover-stack">
{#if tinyCover(al.cover)}
<img class="cover-thumb" src={tinyCover(al.cover)} alt="" aria-hidden="true" loading="lazy" decoding="async" draggable="false" />
{/if}
<img
class="cover"
use:smoothSrc={{
url: gridCover(coverUpgrades[al.cover] ?? al.cover),
album: al,
artist: page.artist.name,
artistId: page.artist.id,
ready: !page.art_preview && !page.cover_logo_pending,
}}
alt=""
loading="lazy"
decoding="async"
draggable="false"
onerror={(event) => handleCoverError(event.currentTarget, al.cover, al.title)}
/>
</div>
{:else}
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
{/if}
{#if group.key !== "upcoming"}
<button
class="hoverplay"
title="Play — streams from the best source"
onclick={(e) => { e.stopPropagation(); streamRelease(al, al); }}
><Icon name="play" size={13} /></button>
{/if}
</div>
<div class="title" title={al.title}>
{al.title}
{#if familyInLibrary(al)}<span class="owned" title="In your library"><Icon name="check" size={11} /></span>{/if}
</div>
<div class="sub">
{group.key === "upcoming" ? upcomingDate(al) : year(al)}{al.track_count ? ` · ${al.track_count} tracks` : ""}
</div>
</div>
{#if expanderIndex(group) === cardIndex}
<div
class="album-expander"
style={browseAccentStyle ?? (openFamily ? cardAccentStyles[openFamily.cover] : undefined)}
transition:expandSlide={{ duration: 460 }}
use:revealIntoView
>
<span class="notch" style="left: {notchLeft(group)}"></span>
{#key openFamilyKey}
<div class="xp-panel" in:fly={{ y: 6, duration: 150 }}>
<div class="xp-body">
<div class="xp-rail">
{#if previousRailArtAlbum?.cover && !artBroken[previousRailArtAlbum.cover]}
<img class="rail-glow rail-art-outgoing" src={coverUpgrades[previousRailArtAlbum.cover] ?? previousRailArtAlbum.cover} alt="" aria-hidden="true" draggable="false" />
{/if}
{#key railArtTransitionKey}
{#if railArtAlbum?.cover && !artBroken[railArtAlbum.cover]}
<img class="rail-glow" class:rail-art-incoming={previousRailArtAlbumId != null} src={coverUpgrades[railArtAlbum.cover] ?? railArtAlbum.cover} alt="" aria-hidden="true" draggable="false" />
{/if}
{/key}
<div class="rail-shade"></div>
<div class="rail-art-stage">
{#if previousRailArtAlbum?.cover && !artBroken[previousRailArtAlbum.cover]}
<img class="rail-art rail-art-outgoing" src={coverUpgrades[previousRailArtAlbum.cover] ?? previousRailArtAlbum.cover} alt="" draggable="false" />
{/if}
{#key railArtTransitionKey}
{#if railArtAlbum?.cover && !artBroken[railArtAlbum.cover]}
<img class="rail-art" class:rail-art-incoming={previousRailArtAlbumId != null} src={coverUpgrades[railArtAlbum.cover] ?? railArtAlbum.cover} alt="" draggable="false" onerror={(event) => handleCoverError(event.currentTarget, railArtAlbum.cover, railArtAlbum.title)} />
{:else}
<div class="rail-art placeholder" class:rail-art-incoming={previousRailArtAlbumId != null}><Icon name="disc" size={40} /></div>
{/if}
{/key}
</div>
<h3 class="rail-title">{openAlbum?.title ?? openFamily?.baseTitle ?? ""}</h3>
<p class="rail-meta">
{group.key === "upcoming" ? upcomingDate(openAlbum ?? openFamily) : fullDate(openAlbum ?? openFamily)}{openTracks.length
? ` · ${openTracks.length} tracks`
: openAlbum?.track_count
? ` · ${openAlbum.track_count} tracks`
: ""}{fmtRuntime(openTracks) ? ` · ${fmtRuntime(openTracks)}` : ""}{openFamily && familyInLibrary(openFamily) ? " · in your library" : ""}
</p>
{#if !openReleaseLocked}
<div class="rail-cta">
<button
class="pill grow"
title="Play — streams from the best source"
onclick={() => streamRelease(openAlbum ?? openFamily, openFamily)}
><Icon name="play" size={12} /> Play</button>
<button
class="pill ghost"
title="Save to library"
onclick={() => saveAlbumAction({ ...(openAlbum ?? openFamily), artist: page.artist.name }, openFamily)}
><Icon name={saved[`a${openAlbumId}`] === "ok" ? "check" : saved[`a${openAlbumId}`] === "err" ? "x" : "download"} size={13} /></button>
</div>
{/if}
{#if openInfo?.description}
<p class="rail-desc" class:clamped={!descExpanded}>{openInfo.description}</p>
<button
class="rail-more"
class:open={descExpanded}
onclick={() => (descExpanded = !descExpanded)}
>{descExpanded ? "Show less" : "Show more"} <Icon name="chevron-down" size={13} /></button>
{/if}
{#if openFamily && openFamily.editions.length > 1}
<div class="rail-eds">
<span class="rail-lab">Editions</span>
{#each openFamily.editions as edition (edition.id)}
<button
class="rail-ed"
class:on={openAlbumId === edition.id}
title={edition.title}
onclick={() => showEdition(edition)}
>
{#if edition.cover && !artBroken[edition.cover]}
<img class="rail-ed-art" src={coverUpgrades[edition.cover] ?? edition.cover} alt="" loading="lazy" decoding="async" draggable="false" onerror={(event) => handleCoverError(event.currentTarget, edition.cover, edition.title)} />
{:else}
<span class="rail-ed-art placeholder"><Icon name="disc" size={14} /></span>
{/if}
<span class="rail-ed-copy">
<span class="n">{editionLabel(edition, openFamily)}</span>
<span class="y">{year(edition)}{edition.track_count ? ` · ${edition.track_count} tracks` : ""}</span>
</span>
{#if inLibrary(edition)}<span class="own" title="In your library"><Icon name="check" size={12} /></span>{/if}
</button>
{/each}
</div>
{/if}
</div>
<div class="xp-tracks">
{#if openLoading}
<p class="hint small">Loading tracks…</p>
{:else}
{#each openDiscs as d (d.disc)}
{#if multiDisc}
<div class="disc-head">Disc {d.disc} · {d.tracks.length} tracks</div>
{/if}
{#each d.tracks as t (t.id)}
{@const locked = t.unreleased}
{@const playTrack = () => {
if (locked) return;
streamSong(page.artist.name, t.title, {
cover: t.cover ?? openAlbum?.cover,
trackId: t.id,
albumId: openAlbumId,
album: openAlbum?.title || null,
expectSecs: t.duration_secs ?? null,
});
}}
<div
class="trow"
class:now={nowPlaying("trackId", t.id)}
class:locked
role="button"
aria-disabled={locked}
tabindex={locked ? -1 : 0}
title={locked ? "Not released yet" : "Play — streams from the best source"}
onclick={playTrack}
onkeydown={(e) => e.key === "Enter" && playTrack()}
>
<span class="tno">{nowPlaying("trackId", t.id) ? "♫" : (t.track_no ?? "·")}</span>
<span class="ttitle">
{t.title}
{#if libraryTicks.has(norm(t.title || ""))}<span class="tick" title="Saved in your library"><Icon name="check" size={11} /></span>{/if}
</span>
{#if locked}
<span class="tag-unreleased">Unreleased</span>
{:else}
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
{/if}
{#if !locked}
<button
class="mini-btn play"
title="Play"
onclick={(e) => { e.stopPropagation(); playTrack(); }}
><Icon name="play" size={13} /></button>
<button
class="mini-get"
title="Save to library"
onclick={(e) => { e.stopPropagation(); saveTrackAction(page.artist.name, t); }}
><Icon name={saved[`t${t.id}`] === "ok" ? "check" : saved[`t${t.id}`] === "err" ? "x" : "download"} size={13} /></button>
{/if}
</div>
{/each}
{/each}
{/if}
</div>
</div>
</div>
{/key}
</div>
{/if}
{/each}
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
</div>
<style>
.hint {
color: var(--text-secondary);
padding-top: 10vh;
text-align: center;
}
.hint.small {
padding: 12px 0;
text-align: left;
}
/* Back control for the loading / error states, shown before the hero
exists so a back affordance is always present (the glass header below
only exists once the artist page has loaded). */
.back.flow {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
font-weight: 600;
margin-bottom: 16px;
}
.back.flow:hover {
color: var(--text-primary);
}
/* The page is the positioning + stacking context for the full-bleed banner
background. isolation keeps the banner's z-index:-1 layer above the
content pane's own background instead of disappearing behind it.
It also carries the whole hero → banner transition, which is one number
and a pile of calc() off it. Layer order, top to bottom:
content (auto) → eraser 15 → hero 20 → identity 30 → glass bar 40. */
.artist-page {
position: relative;
isolation: isolate;
/* Written by heroScroll(). --p is the ride-up progress, 0 at the top
of the page; the three lengths are measured from the DOM and are all
relative to the TOP OF THE HERO BAND. */
--p: 0;
--hero-span: 0px; /* where the content's top edge rests */
--strip: 0px; /* where that edge parks */
--row-h: 76px; /* the Play/chips row's resting height */
--mark-rest: 16px; /* the wordmark's resting bottom, above --edge */
--mark-end: 1; /* the wordmark's parked scale */
/* The band's top in .artist-page's own coordinates — .hero-bg's
historical `top`, and the origin every band-relative length below is
measured from. Keep in sync with BAND_TOP. */
--band-top: -24px;
/* Where the three pins actually sit in flow, written by heroScroll().
NOT zero: the pane's top padding puts them a little down the page,
while .results-head's -24px margin pulls the bar itself back over it.
Everything hung off a pin has to subtract this to get back into band
coordinates — leaving it out is what put the banner 24px low and
dropped the artwork onto the glass bar instead of behind it. */
--pin-origin: 24px;
/* Band coordinates, expressed from a pin's origin. */
--band-from-pin: calc(var(--band-top) - var(--pin-origin));
/* All three are written by heroScroll() as fractions of the hero, so
the banner, the wordmark and the vertical rhythm keep their
proportions from a small window to a full-screen one. The values
here are only the pre-measurement fallback. */
--banner-h: 176px;
/* ROUND 12 glue: --lift is computed to CANCEL the wordmark's parked
travel (its bottom stays put at every p; only the scale animates),
and the chip row needs no travel var at all — its box is pinned to
its resting slot. --play-shrink is the one animation the row keeps. */
--lift: 59px;
--play-shrink: 22px;
/* Where the content's top edge currently is. At rest it is the bottom
of the hero zone; parked it is the bottom of the banner. Everything
in the three pinned layers is anchored to this one number, which is
why nothing can drift out of register with anything else. It floors
itself at --strip because --p clamps at 1, so it needs no guard. */
--edge: calc(var(--hero-span) - var(--p) * (var(--hero-span) - var(--strip)));
/* ROUND 12 — the sheet's geometry, written by measure(). The
dissolve's full length (42% of the hero zone), how much of it sits
above the content edge when parked, and where its top rests at
--p: 0 (60% of the zone — the live look). */
--fog-span: 170px;
--fog-above: 50px;
--fog-rest: 280px;
/* FALLBACK ONLY (no scroll-driven animations): the content's fade
line, in .scrollbody-local coordinates — rewritten every frame by
heroScroll's apply(). The supported path uses --cut-top/--fade-s
(written by measure()) instead; see the sticky .scrollbody and
.fade-flow rules below. */
--flow-cut: -99999px;
/* MONOCHROME — the chosen treatment. Every hero lands in the same
register and the accent is the only colour left on screen. The blur
is 8px (round 9, "Softer"): by the time the banner has formed the
picture should read as texture and colour rather than as a
photograph. */
--mono: grayscale(var(--p)) contrast(calc(1 + var(--p) * 0.14))
brightness(calc(1 - var(--p) * 0.34)) blur(calc(var(--p) * 8px));
}
/* The three pinned layers. Zero flow height by construction — a sticky,
height:0 pin with an absolutely positioned child — so nothing in the
document changes height while scrolling and Chromium's scroll anchoring
has nothing to fight (the same trap documented around the album expander
further down). They stick at exactly the offset they already occupy in
flow, so sticky only holds them against the scroll: the hero never moves
or resizes by a single pixel, at any scroll position. */
.heropin,
.sheetpin,
.idpin {
position: sticky;
/* A literal 0, and it must stay one. A sticky offset is measured from
the scroll pane's PADDING box, which already starts at the pins' own
flow position, so 0 is exactly "stay where you are" — the band does
not shift by a pixel on the first pixel of scroll. Feeding
--pin-origin in here instead double-counts that padding and walks
the pins down the page (measured: 24 → 48). --pin-origin is for
converting the pins' CHILDREN into band coordinates, nothing else.
And never derive this from the pins themselves: see the
feedback-loop note in heroScroll(). */
top: 0;
height: 0;
}
/* ROUND 12 layer order, the user's own model: hero 1 → sheet 2 →
content 3 → identity 30 → glass bar 40. The hero is the BOTTOM layer,
never cut or masked; the sheet and the content slide over it. */
.heropin {
z-index: 1;
}
.sheetpin {
z-index: 2;
pointer-events: none;
}
/* THE SHEET — the page background itself, and the design's heart. Its
top --fog-span px are a baked gradient (the live scrim's own easing:
.3 / .65 / .9 at .294 / .588 / .824 of the span) dissolving into the
hero; below that it is the opaque page. It translates RIGIDLY between
two poses on --p — rest: the dissolve on the visible hero's foot,
exactly the look the old .hero-scrim gave the resting page; parked:
its edge riding --fog-above px above the content edge, the pose the
user approved. A baked gradient moved as a whole cannot seam; the
seams of rounds 7-10 all came from recomputing stops against the
scroll. This one element replaces the scrim, the veil, the eraser and
the wash. */
.sheet {
position: absolute;
top: var(--band-from-pin);
left: -28px;
right: -28px;
/* Far past any scrollport bottom; the gradient's tail is opaque page
colour, so extra height is just more page. */
height: 300vh;
pointer-events: none;
/* The glide is EASED (p²), not linear — the user could see the
gradient's edge crossing the still-bright photograph mid-scroll.
Squared, the sheet stays down at the hero's foot through the first
half of the travel and only climbs to the parked pose late, when
the monochrome ramp has already turned the hero into dim texture
where an onset cannot be seen. Still a rigid translation with the
same two exact endpoints. */
transform: translateY(calc(
var(--fog-rest)
+ var(--p) * var(--p)
* (var(--strip) - var(--fog-above) - var(--fog-rest))
));
/* SMOOTHSTEP, densely sampled — no findable line anywhere in the
curve. The previous piecewise-linear stops (.3/.65/.9 skeleton)
had large slope changes at every stop, and those kinks are Mach
bands: the user kept seeing "the gradient" on the hero however the
poses moved, because the curve itself had visible corners. These
thirteen stops sample 3t²-2t³ closely enough that adjacent
segments differ by a few percent of slope — a dissolve with no
start, no knees and no end, at any pose, over any photograph. */
background: linear-gradient(
180deg,
rgba(18, 18, 18, 0) 0px,
rgba(18, 18, 18, 0.019) calc(var(--fog-span) * 0.08),
rgba(18, 18, 18, 0.081) calc(var(--fog-span) * 0.17),
rgba(18, 18, 18, 0.156) calc(var(--fog-span) * 0.25),
rgba(18, 18, 18, 0.259) calc(var(--fog-span) * 0.33),
rgba(18, 18, 18, 0.397) calc(var(--fog-span) * 0.42),
rgba(18, 18, 18, 0.5) calc(var(--fog-span) * 0.5),
rgba(18, 18, 18, 0.603) calc(var(--fog-span) * 0.58),
rgba(18, 18, 18, 0.741) calc(var(--fog-span) * 0.67),
rgba(18, 18, 18, 0.844) calc(var(--fog-span) * 0.75),
rgba(18, 18, 18, 0.919) calc(var(--fog-span) * 0.83),
rgba(18, 18, 18, 0.981) calc(var(--fog-span) * 0.92),
var(--bg-base) var(--fog-span),
var(--bg-base) 100%
);
}
/* LAYER 3 — the content, over the sheet and the hero. Its one special
behaviour: rows fade out over 18px as they slide under the banner
line, with zero per-frame main-thread work where scroll-driven
animations exist (the whole block below); the rAF fallback follows. */
.scrollbody {
position: relative;
z-index: 3;
}
/* Positioned in BOTH branches: absolutely-positioned content descendants
used to resolve their containing block at .scrollbody; the scroll-
driven transform (a containing block by itself) would otherwise
capture them mid-branch. .fade-flow's box coincides exactly with
.scrollbody's content box, so anchoring is unchanged. */
.fade-flow {
position: relative;
}
/* THE FADE LINE, with zero per-frame main-thread work. The old design's
mask stop had to be rewritten every frame (rAF) because .scrollbody's
box scrolls, and a fast flick outran it: the compositor scrolls ahead
of the next main-thread commit and rows poked above the cut
(user-reported). A scroll-driven animation of a registered
--flow-cut lagged the SAME way — custom-property animations are
main-thread; Chromium composites only transform/opacity/filter.
Three pieces, nothing per-frame:
.fade-host — a HEIGHT-0 sticky box (the pins' own trick) placed 18px
above the content top, parking at the cut line (--cut-top, a screen
constant from measure()). Height 0 is load-bearing twice over: it is
what gives sticky its travel (a box consumes its own height from the
room its containing block offers — the pins at height 0 ride the
whole page), and by construction the travel budget works out EXACTLY:
available = contentBottom - hostTop = content height + 18 = travel
needed for the park to hold until the last content pixel passes the
cut. Past that point sticky exhausts and the line drifts up with the
flow — harmless, nothing is left below it to fade.
.fade-frame — the clip-and-mask carrier, an absolutely positioned
REAL box hung off the host (top 0 = the host's box top = the cut
when parked), one pane tall (--frame-h, from measure(); reaches
comfortably past the viewport bottom from the cut at every scroll
position). Content the transform lifts above the frame top is
removed by `overflow: visible clip` — a paint-time clip at the
padding-box edge, no scroll container created, horizontal overflow
(hover shadows, focus rings) untouched. The mask is CONSTANT: an
18px feather from the frame top over the frame's own default-sized
gradient box. The frame having a real box that CONTAINS every
visible content pixel is the whole reason it exists: this engine's
handling of mask pixels OUTSIDE an element's border box sank two
earlier builds (a mask on the height-0 host itself composited ALL
box-overflowing content — the entire page — to alpha 0; mask-clip:
no-clip re-anchored the tile to the moving overflow rect and the
fade line drifted off-screen). Inside the box, no out-of-box
semantics are ever exercised. The frame's 18px top padding keeps the
feather over empty space at rest.
.fade-flow — the counter-motion: a scroll-driven TRANSFORM animation
(composited — pixel-locked to the native scroll at any speed or
jank) translates the content by -(scrollTop - S), where S (--fade-s,
from measure()) is the scrollTop at which the host parks. Frame top
parks at the cut, content sits at frame-local 18 - (scrollTop - S) —
which equals its normal flow position at every scrollTop, stuck or
not; the handoff is seamless by construction. The keyframe delta and
the animation range are the SAME oversized constant, so translate =
-(scrollTop - S) exactly, independent of the scroller's total range
(which varies as this page scrolls — the tab-grid space reserve).
Before S the fill-mode holds translate at 0 and the page is
pixel-identical to today's.
The frame takes the content out of flow, so .scrollbody's height is
synced to .fade-flow's by heroScroll (offsetHeight ignores the
transform — no feedback loop).
The 18px zone, not longer: it lives in the 24px slot between the
pills' bottom edge and the parked Popular heading — the only place it
can satisfy "fully visible parked" AND "gone before the pills" at
once. Mask gated on data-scroll for rest parity (safe: pre-park the
content never reaches the line, so late gating cannot matter). The
STICKY, the CLIP and the ANIMATION are deliberately ungated — they
must be live the frame scrollTop crosses S even if the main thread
is stalled. One deliberate behaviour change: rows clipped above the
cut no longer hit-test (the old mask left invisible rows clickable
under the banner — the clip removing those ghost hits is a fix, not
a regression). */
@supports (animation-timeline: scroll()) {
.fade-host {
position: sticky;
/* Pre-measure fallback keeps sticky unreachable, not at 0 —
parking the content sheet over the hero on the first scrolled
frame of a fresh page would be quite a look. */
top: var(--cut-top, 99999px);
height: 0;
margin-top: -18px;
}
.fade-frame {
position: absolute;
top: 0;
left: 0;
right: 0;
height: var(--frame-h, 200vh);
padding-top: 18px;
overflow: visible clip;
}
.artist-page[data-scroll="1"] .fade-frame {
-webkit-mask-image: linear-gradient(
180deg,
transparent 0px,
rgba(0, 0, 0, 0.55) 9px,
#000 18px
);
mask-image: linear-gradient(
180deg,
transparent 0px,
rgba(0, 0, 0, 0.55) 9px,
#000 18px
);
}
.fade-flow {
animation-name: fade-flow-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: scroll(nearest block);
animation-range: var(--fade-s, 0px)
calc(var(--fade-s, 0px) + 100000px);
}
@keyframes fade-flow-ride {
from {
transform: translateY(0);
}
to {
/* Delta must equal the animation-range span above. */
transform: translateY(-100000px);
}
}
}
/* Fallback (no scroll-driven animations): the old design — mask on
.scrollbody, its stop (--flow-cut) rewritten every frame by
heroScroll's apply(). Can lag a fast flick; correct when settled.
.fade-host/.fade-frame/.fade-flow stay plain unstyled divs here —
any one piece of the trio without the others would freeze, clip or
double-move the content. */
@supports not (animation-timeline: scroll()) {
.artist-page[data-scroll="1"] .scrollbody {
-webkit-mask-image: linear-gradient(
180deg,
transparent 0px,
transparent var(--flow-cut),
rgba(0, 0, 0, 0.55) calc(var(--flow-cut) + 9px),
#000 calc(var(--flow-cut) + 18px)
);
mask-image: linear-gradient(
180deg,
transparent 0px,
transparent var(--flow-cut),
rgba(0, 0, 0, 0.55) calc(var(--flow-cut) + 9px),
#000 calc(var(--flow-cut) + 18px)
);
}
}
/* Nothing but the controls themselves may take clicks: the block spans the
pane and floats over the track list once it has parked. */
.idpin {
z-index: 30;
pointer-events: none;
}
/* Height 0 with its children hung off its bottom edge, so the block's
bottom IS --edge at every scroll position. */
.idblock {
position: absolute;
left: 0;
right: 0;
top: calc(var(--band-from-pin) + var(--edge));
height: 0;
}
/* Banner-as-background: bled to the pane edges (cancels its 24/28px
padding), sized by the artwork's own height, extending down behind the
content. The pane's border-radius + overflow round the outer corners.
The band still lands on precisely the pixels it always did, but it is
now hung off .heropin rather than off .artist-page, and the pin does not
sit at the page's own origin — so the offset has to come back through
--band-from-pin. Hard-coding the old -24px here put the artwork 24px low
and dropped it onto the glass bar instead of bleeding up behind it.
(z-index -1 is inert: .heropin's own z-index is the stacking context
this lives in.) NEVER masked or cut — round 12's whole point. */
.hero-bg {
position: absolute;
top: var(--band-from-pin);
left: -28px;
right: -28px;
z-index: -1;
overflow: hidden;
isolation: isolate;
pointer-events: none;
}
/* The monochrome treatment, applied to the PICTURE layers only. The
existing filters are re-stated because filter replaces rather than
composes. */
.artist-page[data-scroll="1"] .banner,
.artist-page[data-scroll="1"] :global(.gpu-hero) {
filter: var(--mono);
/* THE SQUEEZE, biased to keep FACES. At rest --edge IS --hero-span,
so this is exactly 0 and the artwork sits on the pixels it does
today — top-anchored, nothing tucked up behind the glass bar. As
the crop closes, only 22% of each lost pixel comes off the TOP and
78% off the bottom: round 9's centre-crop (50/50) pushed the
artwork up by half the travel and cut heads off at the park (the
user: "most faces are cut off") — faces live in a photo's upper
middle, so the banner keeps it. Still a rigid translation of an
unmasked layer: zero at rest, seam-proof throughout. */
transform: translateY(calc(-0.22 * (var(--hero-span) - var(--edge))));
}
/* The accent ground desaturates with everything else. It was the one layer
left out of the monochrome, and its brightest, reddest point sits
directly behind the glass bar; the bloom over it is only 95% opaque, so
that red bled through and the bar's own backdrop saturate(1.7) amplified
it into a visibly warm bar on a black-and-white banner. */
.artist-page[data-scroll="1"] .hero-fallback {
filter: grayscale(var(--p)) brightness(calc(1 - var(--p) * 0.34));
}
.artist-page[data-scroll="1"] .bloom {
filter: blur(34px) saturate(1.6) var(--mono);
}
.artist-page[data-scroll="1"] .hero-seed {
filter: blur(46px) saturate(1.3) var(--mono);
}
/* Deterministic muted-accent ground: sits under every hero as the base, and
IS the no-photo fallback (same reserved geometry, hue seeded from the
artist). No edge vignette — just a soft top-lit gradient into the page. */
.hero-fallback {
position: absolute;
inset: 0;
z-index: -2;
background: radial-gradient(
120% 88% at 28% 4%,
hsl(var(--seed-hue, 210) 30% 24%) 0%,
hsl(var(--seed-hue, 210) 26% 13%) 46%,
var(--bg-base) 100%
);
}
/* Blurred search-portrait seed: an instant colour field while the real hero
loads. Oversized + blurred so a blurred edge never shows, and the square
source can't change hero height (the 16:9 base / native banner sets it).
It dissolves out UNDER the sharp photo once it decodes, never blanking. */
.hero-seed {
position: absolute;
inset: -12% -8%;
z-index: -1;
background-position: center 32%;
background-repeat: no-repeat;
background-size: cover;
filter: blur(46px) saturate(1.3);
transform: scale(1.06);
opacity: 1;
visibility: visible;
transition: opacity 520ms ease, visibility 0s linear;
}
.hero-seed.hidden {
opacity: 0;
visibility: hidden;
transition: opacity 520ms ease, visibility 0s linear 520ms;
}
/* Transparent 16:9 spacer that gives .hero-bg its height before a sharp
banner exists, so fallback/seed occupy the geometry the real hero will. */
.banner.base {
margin-top: 70px;
width: 100%;
aspect-ratio: 16 / 9;
background: transparent;
}
/* Bloom: a blurred copy that only shows in the ~46px strip behind the glass
bar, artificially extending the image up into it. It sits BEHIND the
sharp photo (lower z-index), so it never touches the artwork itself. */
.bloom {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 106px;
object-fit: cover;
object-position: center top;
filter: blur(34px) saturate(1.6);
transform: scale(1.15);
opacity: 0.95;
z-index: 0;
}
.bloom.current {
animation: hero-bloom-in 1.4s ease backwards;
}
/* ROUND 12: no .hero-scrim and no .hero-veil. Both jobs — the resting
dissolve and the parked wordmark's backing — now belong to the SHEET,
whose gradient sits on the hero's foot at rest and rides its darkest
region up under the parked identity. One gradient, nothing to hand
over between layers. */
/* Transparent spacer that seats the artist name at the bottom of the
visible hero and sets where the content below begins. Its height tracks
the window WIDTH (via aspect-ratio) the same way the banner does, so the
content shifts DOWN as the pane widens — staying off the artwork on a
tall 16:9 banner, yet compact when the pane is squarer. Capped a notch
below the banner's own 78vh so the content always meets the dissolved
tail, never the crisp subject.
It is a pure spacer now — the wordmark it used to hold rides in .idpin —
but its height is unchanged: aspect-ratio with a definite width and an
explicit min-height never depended on its children. It is the real
equivalent of the mock's fixed hero band: its bottom is --hero-span. */
.hero-zone {
position: relative;
display: flex;
align-items: flex-end;
/* width must be DEFINITE: with an auto width, `aspect-ratio` transfers
`min-height` through the ratio into an implied min-width (390 * 1.85 =
722px), which overflows a narrower pane and drags the hero copy --
and anything sized as a % of it -- off the right edge. */
width: 100%;
/* A taller zone seats the bottom-anchored copy LOWER over the banner, so
the wordmark sits toward the dissolved lower third of the hero instead
of across the crisp subject. Lower aspect number = taller zone; the
vh cap stays a notch below the banner's 78vh so the copy still meets
the dissolved tail. Tune all three together to raise/lower the logo. */
aspect-ratio: 1.85;
min-height: 390px;
max-height: 76vh;
}
/* Frosted-glass header floating on the banner — the same recipe as the
Now Playing panel's .np-head, so the two headers read as siblings: a
translucent tint over a backdrop blur, with a hairline light edge. */
.results-head {
/* Sticky (was absolute) so the bar pins to the top of the scroll pane
and stays put as the page scrolls, in parity with the Now Playing
header. The negative margins reproduce the old absolute bleed while
keeping the bar in normal flow: -24/-28 bleed it up over the pane's
24px top padding and out to the L/R pane edges; margin-bottom -22
makes its net flow height zero (-24 + 46 - 22 = 0) so the hero still
sits directly beneath it instead of being pushed down. `top: -24px`
pins it flush against the very top of the pane (over that padding). */
position: sticky;
top: -24px;
margin: -24px -28px -22px;
/* Above the identity (30) and the hero (20): the bar keeps the top of
the pane to itself and the parked wordmark slides under it. */
z-index: 40;
height: 46px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 28px;
background: rgba(20, 20, 24, 0.28);
backdrop-filter: blur(20px) saturate(1.7);
-webkit-backdrop-filter: blur(20px) saturate(1.7);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* Matches .np-title: white label legible on the glass, with a soft shadow
for bright banners. */
.results-head .back {
display: inline-flex;
align-items: center;
gap: 5px;
color: #fff;
font-size: 13px;
font-weight: 600;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
.results-head .back:hover {
color: var(--text-secondary);
}
/* Resolver status rides the right side of the glass bar (was a separate
line above the hero before the header became the glass strip). */
.rstatus {
margin-left: auto;
max-width: 55%;
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rstatus .pulse {
color: var(--accent);
}
.rstatus.error {
color: #ff8a94;
}
.hero-art-actions {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.rstatus + .hero-art-actions {
margin-left: 0;
}
.hero-art-notice {
max-width: 260px;
color: rgba(255, 255, 255, 0.78);
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hero-art-notice.error {
color: #ff8a94;
}
.hero-art-btn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 28px;
padding: 0 9px;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 7px;
background: rgba(12, 12, 14, 0.35);
color: rgba(255, 255, 255, 0.76);
font-size: 10px;
font-weight: 600;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.hero-art-btn:hover,
.hero-art-btn.active {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
color: #fff;
}
.hero-art-btn:disabled {
opacity: 0.45;
cursor: wait;
}
.hero-art-btn.icon-only {
width: 28px;
padding: 0;
justify-content: center;
}
/* The sharp artwork: native aspect ratio (no crop), offset down so it
begins right at the bottom of the glass bar. It sits ABOVE the bloom.
max-height caps the very-wide / non-16:9 case so the hero can't swallow
the viewport — object-fit then crops from the top, keeping faces.
The 70px offset = the 46px bar height + the 24px top padding of the
scroll pane (.content). The bar is `position: sticky`, so it pins at the
content-area top and cannot bleed up over that padding the way an
absolute bar could — it therefore sits 24px lower, and the artwork must
clear it by the same 24px or the bar overlaps the subject (heads) at
narrower widths. Keep this in sync with .content's padding-top. */
.banner {
position: relative;
display: block;
width: 100%;
height: auto;
max-height: 78vh;
margin-top: 70px;
object-fit: cover;
object-position: center top;
z-index: 1;
}
/* The outgoing image is taken out of layout and held under the incoming
one. This keeps the hero's height stable while both full-resolution
images overlap for the dissolve. */
.banner.previous {
position: absolute;
top: 70px;
left: 0;
margin-top: 0;
}
/* Hold the last decoded image beneath the transparent GPU canvas until its
first fixed-4K frame is ready. It is out of flow, so hero geometry still
comes from the canvas rather than doubling in height. */
.banner.gpu-underlay {
position: absolute;
top: 70px;
left: 0;
margin-top: 0;
}
.banner.current {
animation: hero-banner-in 1.4s ease backwards;
}
/* The first useful frame should feel immediate; the long dissolve is for
the periodic photo rotation where an outgoing image exists underneath. */
.bloom.current.initial,
.banner.current.initial {
animation-duration: 180ms;
}
/* The preview→finished 4K swap dissolves faster than a rotation but slower
than the first paint — enough to mask the grade/grain without dawdling. */
.bloom.current.finish,
.banner.current.finish {
animation-duration: 650ms;
}
@keyframes hero-banner-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes hero-bloom-in {
from { opacity: 0; }
to { opacity: 0.95; }
}
@media (prefers-reduced-motion: reduce) {
.banner.current,
.bloom.current,
.banner.current.initial,
.bloom.current.initial,
.banner.current.finish,
.bloom.current.finish {
animation-duration: 1ms;
}
.hero-seed,
.name-title,
.artist-logo.mask {
transition-duration: 1ms;
}
.logo-wrap {
animation-duration: 1ms;
}
}
/* Full width so its children (esp. the logo) size against the hero, not the
shrink-to-fit width of the meta text.
Hung off .idblock's bottom edge, i.e. off --edge. Its own padding puts
the wordmark --mark-rest above its bottom edge, so this offset is what
the wordmark's resting gap has to GROW by: 0 at rest, where the block's
bottom is the hero zone's bottom and the wordmark therefore lands on
exactly the pixels it lands on today, and --banner-h - 14 - --lift minus
that resting gap when parked, which seats the wordmark 10px under the
top of the banner with the chip row clear beneath it. */
.hero-copy {
position: absolute;
left: 0;
right: 0;
bottom: calc(
var(--p) * (var(--banner-h) - 14px - var(--lift) - var(--mark-rest))
);
width: 100%;
padding: 0 0 10px;
color: #fff;
pointer-events: none;
}
.placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-muted);
}
h1 {
font-size: clamp(44px, 6vw, 84px);
font-weight: 800;
letter-spacing: -0.045em;
line-height: 1.02;
margin: 6px 0 14px;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.35);
}
/* The logo's drop shadow lives on this WRAPPER, not on the logo itself.
CSS applies `mask` after `filter`, so a drop-shadow set on the masked
element would be generated from its un-masked rectangle and then clipped
away by the mask — invisible. Filtering the parent shadows the already-
masked result, i.e. the letterforms. (Our source logos bake a shadow into
the PNG; the trace strips it, so this is the one we control — it scales
with the logo and can't blur, unlike a 800px raster shadow.) */
/* Reserved-height slot so text→logo never reflows the meta/content below.
Both the name and the logo are absolutely anchored to the slot's
bottom-left, sharing one origin, and crossfade. */
.logo-slot {
position: relative;
min-height: var(--slot-h, 200px);
margin: 6px 0 6px;
}
/* The wordmark scales down as it parks. transform, not a font/height
change: the slot's reserved height must not move while it shrinks, and
the origin is the slot's bottom-left — the one point the name and the
logo already share — so the mark shrinks onto its own anchor. --mark-end
is written from the mark's rendered height so the PARKED size is a
constant ~26px at every pane width: a narrow window gets a smaller hero
wordmark and the same readable banner. Gated, so at rest there is no
transform on the type at all. */
.artist-page[data-scroll="1"] .logo-slot {
transform-origin: left bottom;
transform: scale(calc(1 - var(--p) * (1 - var(--mark-end))));
}
.name-title {
position: absolute;
left: 0;
bottom: 0;
margin: 0;
transition: opacity 300ms ease;
}
.loading-name {
bottom: 8px;
width: min(430px, 64%);
height: clamp(48px, 6vw, 76px);
border-radius: 8px;
background: linear-gradient(
100deg,
rgba(255, 255, 255, 0.08) 20%,
rgba(255, 255, 255, 0.17) 42%,
rgba(255, 255, 255, 0.08) 64%
);
background-size: 220% 100%;
animation: artist-skeleton-shimmer 1.35s ease-in-out infinite;
}
/* Fades out under the logo once one decodes, instead of hard-swapping. */
.name-title.gone {
opacity: 0;
}
.logo-wrap {
position: absolute;
left: 0;
/* right:0 gives a DEFINITE width so the mask's `92%` cap resolves
against the slot, not a shrink-to-fit 0 (which collapses the logo). */
right: 0;
bottom: 0;
/* Two shadows, as with any lifted UI surface: a tight contact shadow
that separates the glyphs from a busy banner, plus a wide ambient one
for depth. Tune here — this is the whole knob. */
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.55))
drop-shadow(0 10px 24px rgba(0, 0, 0, 0.6));
/* Fade the final curated wordmark in over the name (which fades out).
Candidate scoring never mounts an intermediate logo. */
animation: logo-fade-in 380ms ease both;
}
@keyframes logo-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Band ClearLOGO as the title. Full-colour path (.artist-logo, an <img>):
a transparent HD PNG bounded to hero size — scaling DOWN stays crisp, no
vectorising needed. Bounded by BOTH max dimensions so aspect is preserved
and it reads big. */
.artist-logo {
display: block;
width: auto;
height: auto;
max-width: 90%;
max-height: var(--logo-h, 200px);
}
/* Accent-tinted path (.mask, a <div>): the TRACED silhouette masks a solid
accent fill, so the wordmark renders in the app accent. The SVG's viewBox
hugs the ink, so `contain` fills the box exactly — no sprite-cropping and
no dead space.
Sizing: --logo-h comes from the equal-area rule, then scales with the
measured hero width. It is written as a WIDTH here so aspect-ratio drives
the height and nothing distorts. Being vector there is no resolution
ceiling; 92% remains the final safety bound for extreme wordmarks. */
.artist-logo.mask {
width: min(calc(var(--logo-aspect, 3) * var(--logo-h, 200px)), 92%);
aspect-ratio: var(--logo-aspect, 3);
max-height: none;
background: var(--accent);
-webkit-mask: var(--logo) center / contain no-repeat;
mask: var(--logo) center / contain no-repeat;
}
/* Play + chips ride in the identity layer, hung off .idblock's bottom
edge like the wordmark is — and, round 12 glue, they NEVER leave their
resting slot: the box's bottom sits a full --row-h below --edge at
every p, exactly the hole .actions-gap holds open in the flow. The box
does not collapse either — an earlier cut shrank min-height/padding
with --p and the Play's bottom wandered ~10px against the content
mid-ride, breaking the glue. Only the BUTTON animates (its size, via
--play-shrink), shrinking about its own centre inside a static box:
"attach it to the chips that are resizing and transforming", verbatim.
overflow-x is CLIPPED, not hidden: the row is nowrap (see .stat-chips)
so a very long chip set can overrun the pane, and clip stops that from
reaching the scroll pane as horizontal overflow while leaving the
chips' hover popovers free to escape vertically. */
.artist-actions {
position: absolute;
left: 0;
right: 0;
bottom: calc(-1 * var(--row-h));
min-height: var(--row-h);
padding: 8px 0;
display: flex;
align-items: center;
gap: 16px;
overflow-x: clip;
overflow-y: visible;
pointer-events: none;
}
.hero-play,
.hero-preview,
.stat-chips {
pointer-events: auto;
}
/* The hole the row left behind in .popular. It carries the row's resting
height rules, and heroScroll() reads --row-h back off it, so the spacer
and the pinned row can never disagree about how tall the row is. */
.actions-gap {
min-height: 76px;
}
/* Floating stat chips beside Play — individually rounded translucent
pills (the .hero-art-btn glass recipe), deliberately NOT a joined bar.
They sit over the hero's dissolved tail, so the backdrop blur keeps
them legible on any artwork. */
.stat-chips {
display: flex;
align-items: center;
/* nowrap by design: the row has to stay ONE line to fit a 124px
banner, and a second row would grow up into the wordmark once the
block is anchored to its bottom edge. Something therefore has to
give at narrow widths, and it should be the least important stat —
see the shedding rules below. */
flex-wrap: nowrap;
gap: 8px;
min-width: 0;
}
/* Narrow panes SHED chips rather than letting the row overflow. */
.idpin[data-w="s"] .stat-chips > :nth-child(n + 3),
.idpin[data-w="xs"] .stat-chips > :nth-child(n + 2) {
display: none;
}
/* Only the first three stats survive into the banner; the rest fade out on
the way up. They keep their layout width — the row is left-aligned, so
nothing shifts — but they must stop taking clicks once invisible, which
opacity alone does not do. */
.stat-chips > :nth-child(n + 4) {
opacity: calc(1 - var(--p) * 2.4);
pointer-events: var(--extra-hit, auto);
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 27px;
padding: 0 11px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
background: rgba(14, 15, 18, 0.4);
color: rgba(255, 255, 255, 0.8);
font-size: 11.5px;
font-weight: 600;
white-space: nowrap;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.chip :global(.icon) {
color: rgba(255, 255, 255, 0.45);
}
/* Mini collection meter inside the library chip — the Steam-achievements
analog: how much of the discography is saved locally. */
.chip-bar {
width: 44px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.16);
overflow: hidden;
}
.chip-fill {
display: block;
height: 100%;
border-radius: 2px;
background: var(--accent);
}
/* Hover popover on the disk chip: per-album storage breakdown. Pure CSS
(opens on hover/focus), anchored above the chip so it never collides
with the Popular list below. */
.chip.has-pop {
position: relative;
}
.chip-pop {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
z-index: 30;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 240px;
max-width: 340px;
max-height: 300px;
overflow-y: auto;
padding: 9px 11px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background: rgba(14, 15, 18, 0.92);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: opacity 140ms ease, visibility 140ms;
pointer-events: none;
}
.chip.has-pop:hover .chip-pop,
.chip.has-pop:focus-within .chip-pop {
opacity: 1;
visibility: visible;
}
.pop-row {
display: flex;
justify-content: space-between;
gap: 14px;
font-size: 11px;
font-weight: 500;
line-height: 1.5;
}
.pop-title {
color: rgba(255, 255, 255, 0.82);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.pop-val {
color: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
/* Follow heart: a square chip-styled button closing the row. Fills with
the accent when active. Visual-only until the follow feature lands. */
.chip.follow {
width: 27px;
padding: 0;
justify-content: center;
cursor: pointer;
}
.chip.follow:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
color: #fff;
}
.chip.follow.on {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
}
/* Socials: ghost icons after the heart — no chip outline, just the pale
brand-tinted glyph. Hover blooms a soft disc in the same brand colour
(--soc is an "r,g,b" triple so the wash can be alpha'd). */
.chip-divider {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.14);
margin: 0 4px;
}
.social-ghost {
display: grid;
place-items: center;
width: 29px;
height: 29px;
border-radius: 50%;
color: rgb(var(--soc, 160, 168, 177));
transition: background 160ms ease, transform 160ms ease;
}
.social-ghost:hover {
background: rgba(var(--soc, 160, 168, 177), 0.13);
transform: translateY(-1px);
}
@media (prefers-reduced-motion: reduce) {
.social-ghost {
transition-duration: 1ms;
}
}
/* Shrunk by real width/height, not transform: with a transform the row
would keep its 50px layout box, the chips would stay centred in that
band and the parked wordmark would collide with them. */
.hero-play {
display: grid;
place-items: center;
width: calc(50px - var(--p) * var(--play-shrink));
height: calc(50px - var(--p) * var(--play-shrink));
/* A flex item may be squeezed narrower than its width by the wrapping
chip row, turning the circle into an oval. Never shrink. */
flex: 0 0 auto;
padding-left: 3px;
border-radius: 50%;
background: var(--accent);
color: #000;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.25);
}
/* The glyph shrinks with its button. At --p: 0 this resolves to the 21px
the <Icon> already renders at, so nothing changes at rest. */
.hero-play :global(.icon) {
width: calc(21px - var(--p) * 6px);
height: calc(21px - var(--p) * 6px);
}
.hero-play:hover {
background: var(--accent-hover);
transform: scale(1.04);
}
/* Preview tracks. Glass, not accent: Play stays the row's one filled
control. It shrinks on the same --p curve as .hero-play, but the travel
is chosen by where it LANDS, not by proportion: 40px at rest down to
27px parked, which is the chips' fixed height (they never scale — only
Play and this button animate). Matching the chips is the whole point of
the number; scaling it by 40/50 of --play-shrink instead parked it at
22px, a visibly undersized dot against a 27px chip row. Parked Play is
28px, so the order across the row reads 28 / 27 / 27. */
.hero-preview {
position: relative;
display: grid;
place-items: center;
width: calc(40px - var(--p) * 13px);
height: calc(40px - var(--p) * 13px);
flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 50%;
background: rgba(14, 15, 18, 0.4);
color: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
/* The glyph lands at 14px parked — the size the chips' own icons run at,
so the button reads as one of them rather than a shrunken transport. */
.hero-preview :global(.icon) {
width: calc(18px - var(--p) * 4px);
height: calc(18px - var(--p) * 4px);
}
.hero-preview:hover:not(:disabled) {
color: #fff;
border-color: rgba(255, 255, 255, 0.75);
transform: scale(1.04);
}
.hero-preview:disabled {
opacity: 0.4;
cursor: default;
}
/* Availability count, shown only when some tracks have no clip. It fades
on the same curve the shed chips use (see .stat-chips > :nth-child(n+4)),
so the row loses its secondary detail all at once on the way up rather
than the badge keeping its own schedule. The tooltip still carries the
count once it is gone. Offsets stay fixed: the badge is small enough at
a 27px parked circle, and animating them slid it off the edge. */
.preview-count {
position: absolute;
top: -3px;
right: -5px;
min-width: 16px;
height: 16px;
padding: 0 4px;
/* Dark ring, not --bg-base: the badge sits over hero artwork, where a
flat page-colour halo would read as a hole punched in the photo. */
border: 2px solid rgba(10, 11, 14, 0.85);
border-radius: 8px;
background: var(--accent);
color: #000;
font-size: 9.5px;
font-weight: 800;
line-height: 12px;
text-align: center;
opacity: calc(1 - var(--p) * 2.4);
}
.popular h2 {
margin-top: 0;
}
.popular-list {
max-width: 760px;
}
.popular-more {
margin: 6px 0 0 8px;
padding: 4px 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
}
.popular-more:hover {
color: var(--text-primary);
}
.popular-row {
display: grid;
grid-template-columns: 26px 40px minmax(130px, 1fr) minmax(100px, 0.75fr) 40px 24px;
align-items: center;
gap: 11px;
min-height: 54px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
}
.popular-row:hover {
background: var(--bg-highlight);
}
.popular-row.now .rank,
.popular-row.now .track-name {
color: var(--accent-hover);
}
.rank {
color: var(--text-muted);
font-size: 12px;
text-align: center;
}
.track-cover {
width: 40px;
height: 40px;
border-radius: 3px;
object-fit: cover;
}
.track-name,
.track-album {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track-name {
font-size: 13px;
font-weight: 600;
}
.track-album {
color: var(--text-secondary);
font-size: 12px;
}
h2 {
font-size: 15px;
font-weight: 700;
margin: 22px 0 10px;
}
.disco-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 26px 0 12px;
}
.disco-title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: var(--text-primary);
}
/* Discography filter tabs. The selected tab uses the app's "accent glass"
recipe (same as .ed-badge / .now-badge), so it tints to the artist's
scoped accent and reads as one system with the cards below. */
.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);
}
.grid {
display: grid;
/* Fixed tracks — fluid minmax(...,1fr) sawtooths card widths while
the window or side panels resize (see Home.svelte). 150px = the old
132px cover plus the card's new 9px padding on each side, so covers
render at the same size they always did. */
grid-template-columns: repeat(auto-fill, 150px);
gap: 16px;
/* Cards hug their content — the ROW is as tall as its tallest title,
but a short-titled neighbor must not stretch (its hover/active
surface would wrap a box of empty space). */
align-items: start;
/* Load-bearing with the min-height reserve set in `measureCols`:
grid's default align-content behaves as `stretch`, which would
distribute the reserved space BETWEEN the auto-sized rows and
spread the cards apart. Pin the rows to the top so the reserve
stays as empty space below them. */
align-content: start;
/* Swapping tabs destroys/recreates this grid; scroll anchoring would
"helpfully" adjust scrollTop to chase the vanishing content and
fight the pin in `selectTab`. */
overflow-anchor: none;
content-visibility: auto;
contain-intrinsic-size: auto 360px;
}
.stage-status {
min-height: 20px;
}
.catalog-skeleton {
padding-top: 2px;
opacity: 0.72;
}
.skeleton-heading,
.skeleton-track > span,
.skeleton-card {
background: linear-gradient(
100deg,
rgba(255, 255, 255, 0.045) 20%,
rgba(255, 255, 255, 0.1) 42%,
rgba(255, 255, 255, 0.045) 64%
);
background-size: 220% 100%;
animation: artist-skeleton-shimmer 1.35s ease-in-out infinite;
}
.skeleton-heading {
width: 92px;
height: 15px;
margin: 22px 0 10px;
border-radius: 4px;
}
.skeleton-heading.releases {
width: 68px;
}
.skeleton-tracks {
display: grid;
}
.skeleton-track {
display: grid;
grid-template-columns: 24px 40px minmax(120px, 360px);
align-items: center;
gap: 12px;
min-height: 52px;
border-bottom: 1px solid rgba(255, 255, 255, 0.035);
}
.skeleton-rank {
width: 12px;
height: 10px;
border-radius: 3px;
justify-self: center;
}
.skeleton-cover {
width: 40px;
height: 40px;
border-radius: 3px;
}
.skeleton-copy {
width: min(300px, 75%);
height: 12px;
border-radius: 4px;
}
.skeleton-cards {
display: grid;
grid-template-columns: repeat(auto-fill, 132px);
gap: 16px;
}
.skeleton-card {
display: block;
width: 132px;
aspect-ratio: 1;
border-radius: 6px;
}
@keyframes artist-skeleton-shimmer {
from { background-position: 100% 0; }
to { background-position: -120% 0; }
}
@media (prefers-reduced-motion: reduce) {
.loading-name,
.skeleton-heading,
.skeleton-track > span,
.skeleton-card {
animation: none;
}
}
.cover-wrap {
position: relative;
}
.cover {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
}
/* ---- blur-up first load ----
A ~3KB Deezer thumb paints almost immediately as a blurred bed, then
the full-resolution art crossfades over it and settles from 1.04 → 1.
The real art is held at opacity 0 until its `load` fires (see
`smoothSrc`), so the browser's progressive top-down raster fill happens
invisibly instead of reading as a half-drawn cover. */
.cover-stack {
position: relative;
border-radius: 6px;
overflow: hidden;
/* Own stacking context so the blurred bed can never paint over the
badges/hover-play that share .cover-wrap. */
isolation: isolate;
}
.cover-thumb {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
/* Scaled up so the blur's soft edge is pushed outside the tile. */
transform: scale(1.14);
filter: blur(10px) saturate(1.1);
transition: opacity 300ms ease;
}
.cover-stack:global(.cover-ready) .cover-thumb { opacity: 0; }
.cover-stack:global(.cover-settled) .cover-thumb { display: none; }
.cover-stack .cover {
position: relative;
opacity: 0;
transform: scale(1.04);
transition: opacity 300ms ease, transform 300ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
/* `is-loaded` is applied at runtime by the `smoothSrc` action, so Svelte's
compiler cannot see it in the markup and prunes the rule as unused —
:global keeps it in the build while `.cover-stack .cover` stays scoped. */
.cover-stack .cover:global(.is-loaded) {
opacity: 1;
transform: scale(1);
}
@media (prefers-reduced-motion: reduce) {
.cover-stack .cover {
transform: none;
transition: opacity 120ms ease;
}
.cover-stack .cover:global(.is-loaded) { transform: none; }
}
/* Edition-count badge, "accent glass": translucent accent over a backdrop
blur with an accent hairline, so the artwork glows through tinted. */
.ed-badge {
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 4px;
line-height: 1;
padding: 4px 7px;
border-radius: 6px;
font-size: 10.5px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #fff;
background: color-mix(in srgb, var(--accent) 40%, rgba(10, 10, 12, 0.42));
backdrop-filter: blur(6px) saturate(1.6);
-webkit-backdrop-filter: blur(6px) saturate(1.6);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent),
0 2px 8px rgba(0, 0, 0, 0.35);
}
.ed-badge :global(svg) {
display: block;
}
/* Hover play: an accent disc pinned inside the art's bottom-right. */
.hoverplay {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 2;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--accent);
color: #000;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.55);
opacity: 0;
transform: translateY(4px);
transition: opacity 0.18s ease, transform 0.18s ease, background 0.18s ease;
}
.hoverplay:hover {
background: var(--accent-hover);
}
.release:hover .hoverplay,
.release:focus-visible .hoverplay {
opacity: 1;
transform: none;
}
/* Now-playing pill, same "accent glass" language as .ed-badge. The
playing card is marked by the pill's motion alone — no outline on the
art, so the selected card's ring (.release.active) stays the only
rectangle. Bars animate only while audio is actually playing and the
window is visible (the shared activity gate); paused freezes them. */
.now-badge {
position: absolute;
left: 6px;
bottom: 6px;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 5px;
line-height: 1;
padding: 4px 7px;
border-radius: 6px;
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #fff;
background: color-mix(in srgb, var(--accent) 40%, rgba(10, 10, 12, 0.42));
backdrop-filter: blur(6px) saturate(1.6);
-webkit-backdrop-filter: blur(6px) saturate(1.6);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent),
0 2px 8px rgba(0, 0, 0, 0.35);
}
/* Stepped equalizer, same recipe as Now Playing's queue cue: steps(1)
keeps the playing signal alive without a repaint every display frame. */
.now-badge .eqb {
display: inline-flex;
align-items: flex-end;
gap: 1.5px;
width: 11px;
height: 10px;
}
.now-badge .eqb b { width: 2.5px; background: currentColor; border-radius: 1px; }
.now-badge.live .eqb b { animation: npeq 1.2s steps(1, end) infinite; }
.now-badge .eqb b:nth-child(1) { height: 40%; animation-delay: -0.2s; }
.now-badge .eqb b:nth-child(2) { height: 90%; }
.now-badge .eqb b:nth-child(3) { height: 60%; animation-delay: -0.4s; }
@keyframes npeq {
0%, 100% { transform: scaleY(0.4); }
50% { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
.now-badge.live .eqb b { animation: none; }
}
.trow.now .ttitle,
.trow.now .tno {
color: var(--accent-hover);
}
.resolving {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 12px;
}
.resolving .pulse {
color: var(--accent);
}
.resolving.error {
color: #f15e6c;
}
.pill {
background: var(--accent);
color: #000;
font-size: 11px;
font-weight: 700;
padding: 6px 14px;
border-radius: 14px;
white-space: nowrap;
}
.pill:hover {
background: var(--accent-hover);
}
.pill.ghost {
background: var(--bg-highlight);
color: var(--text-primary);
}
.pill.ghost:hover {
background: var(--bg-press, #333);
}
.pill:disabled {
background: var(--bg-highlight);
color: var(--text-muted);
cursor: default;
opacity: 0.6;
}
.pill:disabled:hover {
background: var(--bg-highlight);
}
/* Policy 1: the full title always shows — grid rows grow to the tallest
card; nothing is ever ellipsized away. */
.title {
font-size: 13px;
font-weight: 700;
margin-top: 9px;
line-height: 1.3;
overflow-wrap: anywhere;
}
.owned {
display: inline-block;
vertical-align: -1px;
color: var(--accent-hover);
margin-left: 3px;
}
.sub {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
/* The card is one clickable surface that opens the inline expander. */
.release {
display: flex;
flex-direction: column;
padding: 9px;
border-radius: var(--radius);
cursor: pointer;
user-select: none;
transition: background 0.15s ease;
content-visibility: auto;
contain-intrinsic-size: 150px 210px;
/* Anchor for the open-expander scroll: clears the sticky results-head
AND the parked banner beneath it plus breathing room, so a clicked
row lands just below the banner instead of underneath it. Tracks
--banner-h rather than a constant, because the banner now scales
with the window. Same expression as revealIntoView's MARGIN. */
scroll-margin-top: calc(22px + var(--banner-h) + 18px);
}
.release:hover,
.release:focus-visible {
background: var(--bg-elevated);
}
/* INSET ring: a spread shadow paints outside the border box and the
grid's paint containment clips it at the first row's top edge. */
.release.active {
background: var(--bg-elevated);
box-shadow: inset 0 0 0 1.5px var(--accent);
}
/* ---- inline album expander ---- */
/* Full grid row directly below the open card's row. The slide transition
animates its height on open/close; same-row switches keep this element
and only crossfade the panel content. */
.album-expander {
grid-column: 1 / -1;
position: relative;
}
/* Mock-exact notch: a rotated square whose BOTTOM half is hidden by the
panel (which paints above it — no z-index, panel is positioned and
later in the DOM), so only a clean bordered tip pokes over the panel's
top edge, whatever the rail glow tints the panel beneath it. */
.notch {
position: absolute;
top: -6px;
width: 12px;
height: 12px;
background: var(--bg-elevated);
border-left: 1px solid rgba(255, 255, 255, 0.08);
border-top: 1px solid rgba(255, 255, 255, 0.08);
transform: rotate(45deg);
transition: left 240ms cubic-bezier(0.33, 1, 0.4, 1);
/* The open animation clips the expander's overflow (the tip lives
above its top edge), so fade the notch in as the height settles —
the same 180ms appearance the mock used. */
animation: notch-fade 200ms ease 300ms backwards;
}
@keyframes notch-fade {
from { opacity: 0; }
to { opacity: 1; }
}
.xp-panel {
position: relative;
background: var(--bg-elevated);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
overflow: hidden;
}
.xp-body {
display: grid;
grid-template-columns: 250px 1fr;
}
/* Rail ambience: the Now Playing "glow" recipe — a blurred copy of the
SAME cover behind the content (blur(38px) saturate(1.4)) plus a
legibility shade, so the two panels read as siblings. */
.xp-rail {
position: relative;
padding: 20px;
border-right: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
gap: 13px;
overflow: hidden;
}
.rail-glow {
--rail-art-opacity: 0.32;
position: absolute;
inset: -10%;
z-index: 0;
width: 120%;
height: 120%;
object-fit: cover;
filter: blur(38px) saturate(1.4);
opacity: 0.32;
pointer-events: none;
}
.rail-shade {
position: absolute;
inset: 0;
z-index: 0;
background: linear-gradient(
180deg,
transparent 0%,
rgba(24, 24, 24, 0.55) 55%,
rgba(24, 24, 24, 0.92) 100%
);
pointer-events: none;
}
.xp-rail > :not(.rail-glow):not(.rail-shade) {
position: relative;
z-index: 1;
}
.rail-art {
--rail-art-opacity: 1;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: 8px;
object-fit: cover;
display: block;
box-shadow: 0 18px 38px -18px rgba(0, 0, 0, 0.7);
}
.rail-art-incoming {
animation: rail-art-fade-in 500ms ease both;
}
.rail-art-outgoing {
animation: rail-art-fade-out 500ms ease both;
pointer-events: none;
}
@keyframes rail-art-fade-in {
from { opacity: 0; }
to { opacity: var(--rail-art-opacity, 1); }
}
@keyframes rail-art-fade-out {
from { opacity: var(--rail-art-opacity, 1); }
to { opacity: 0; }
}
.rail-art-stage {
width: 100%;
aspect-ratio: 1;
}
.rail-art.placeholder {
display: grid;
place-items: center;
}
.rail-title {
margin: 0;
font-size: 16.5px;
font-weight: 800;
line-height: 1.25;
}
.rail-meta {
margin: -7px 0 0;
font-size: 12.5px;
color: var(--text-secondary);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
.rail-cta {
display: flex;
gap: 8px;
}
.rail-cta .pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.pill.grow {
flex: 1;
}
/* Clamped description dissolves at its bottom edge; the quiet text button
below expands it, its chevron rotating. */
.rail-desc {
margin: 0;
font-size: 12.5px;
color: var(--text-secondary);
line-height: 1.55;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.rail-desc.clamped {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-mask-image: linear-gradient(180deg, #000 62%, transparent 100%);
mask-image: linear-gradient(180deg, #000 62%, transparent 100%);
}
.rail-more {
display: inline-flex;
align-items: center;
gap: 5px;
align-self: flex-start;
margin-top: -6px;
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
padding: 2px 0;
}
.rail-more:hover {
color: var(--text-primary);
}
.rail-more :global(svg) {
transition: transform 200ms ease;
}
.rail-more.open :global(svg) {
transform: rotate(180deg);
}
.rail-eds {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 2px;
}
.rail-lab {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 4px;
}
.rail-ed {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 8px;
border-radius: 6px;
border: 1px solid transparent;
text-align: left;
}
.rail-ed:hover {
background: rgba(255, 255, 255, 0.06);
}
.rail-ed.on {
background: color-mix(in srgb, var(--accent) 15%, transparent);
border-color: var(--accent);
}
.rail-ed-art {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
flex: none;
}
.rail-ed-art.placeholder {
display: grid;
place-items: center;
background: var(--bg-highlight);
color: var(--text-muted);
}
.rail-ed-copy {
min-width: 0;
flex: 1;
}
.rail-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;
}
.rail-ed-copy .y {
display: block;
margin-top: 1px;
font-size: 11.5px;
color: var(--text-muted);
}
.rail-ed .own {
color: var(--accent);
flex: none;
}
.xp-tracks {
padding: 14px 16px;
min-width: 0;
}
.disc-head {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 10px 6px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.disc-head::after {
content: "";
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.07);
}
.tick {
display: inline-block;
vertical-align: -1px;
color: var(--accent);
margin-left: 6px;
}
@media (prefers-reduced-motion: reduce) {
.notch,
.rail-more :global(svg),
.hoverplay {
transition-duration: 1ms;
}
.notch {
animation-duration: 1ms;
animation-delay: 0ms;
}
}
@media (max-width: 900px) {
.xp-body {
grid-template-columns: 1fr;
}
.xp-rail {
border-right: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
}
/* Mock-exact rows: number / title / duration, with the per-track play and
save controls revealed on hover in place of the duration. */
.trow {
position: relative;
display: grid;
grid-template-columns: 26px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 7px 10px;
border-radius: 6px;
font-size: 13.5px;
cursor: pointer;
}
.trow:hover {
background: var(--bg-highlight);
}
.mini-btn,
.mini-get {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
opacity: 0;
transition: opacity 0.12s ease;
}
.mini-btn.play {
right: 36px;
color: var(--accent-hover);
}
.mini-get {
right: 8px;
}
.trow:hover .mini-btn,
.trow:hover .mini-get,
.trow:focus-within .mini-btn,
.trow:focus-within .mini-get {
opacity: 1;
}
.trow:hover .tdur,
.trow:focus-within .tdur {
opacity: 0;
}
.trow.locked {
cursor: default;
}
.trow.locked:hover {
background: transparent;
}
.trow.locked .ttitle {
color: var(--text-muted);
}
.tag-unreleased {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-muted);
border: 1px solid var(--border, #3a3a3a);
border-radius: 4px;
padding: 1px 6px;
}
.tno {
text-align: right;
color: var(--text-muted);
font-size: 12.5px;
font-variant-numeric: tabular-nums;
}
.ttitle {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tdur {
color: var(--text-muted);
font-size: 12.5px;
font-variant-numeric: tabular-nums;
transition: opacity 0.12s ease;
}
.mini-btn,
.mini-get {
color: var(--text-secondary);
}
.mini-btn:hover,
.mini-get:hover {
color: var(--accent-hover);
}
.mini-btn:disabled,
.mini-get:disabled {
color: var(--text-muted);
opacity: 0.4;
cursor: default;
}
.mini-btn:disabled:hover,
.mini-get:disabled:hover {
color: var(--text-muted);
}
@media (max-width: 720px) {
.hero-zone {
aspect-ratio: 2;
min-height: 240px;
max-height: 52vh;
}
.hero-copy {
padding: 0 0 18px;
}
.artist-logo {
max-height: 150px;
}
.artist-logo.mask {
width: min(calc(var(--logo-aspect, 3) * var(--logo-h, 200px) * 0.75), 92%);
}
.results-head {
padding: 0 20px;
}
.hero-art-btn span,
.hero-art-notice {
display: none;
}
.hero-art-btn {
width: 28px;
padding: 0;
justify-content: center;
}
/* The spacer is the source of truth for the row's resting height —
heroScroll() reads --row-h off it, and the pinned row follows. */
.actions-gap {
height: 66px;
}
.popular-row {
grid-template-columns: 24px 36px minmax(0, 1fr) 38px 22px;
gap: 8px;
}
.track-cover {
width: 36px;
height: 36px;
}
.track-album {
display: none;
}
}
</style>