Files
SoulStream/src/views/CatalogArtist.svelte
T
KeelrLeek 0670b8654a hero commits wait for scroll calm, and a dev tripwire watches the rides
Kyle photographed the artist page interleaved with itself: the identity
block parked over the Popular rows with every scroll-driven paint effect
at its rest pose — wordmark unshrunk at full size, no fade on the rows
passing under it — while the sticky geometry carried on correctly. It
healed before it could be probed (no remount: a late measurement or
observer delivery snapped it back), and a controlled reload-restore came
up perfectly measured under a 14s watch, so the trigger was never pinned.

Two answers shipped:

THE GATE (backlog #3). The rotation TICK already skipped while the app
scrolled recently, but that gate was start-only: a cache-miss payload —
multi-MB raw-RGBA IPC + decode + texture/twin bake, a measured ~150ms
main-thread task — used to COMMIT whenever its promise resolved, an
arbitrary moment relative to input. All four commit sites (GPU ready,
preview paint, 4K finish crossfade, rotation/final) now route through
deferHeroCommit, which waits for the same 600ms calm the tick waits for.
The OLD banner and its twin stay mounted for the whole wait, so the mono
overlay never sits empty mid-scroll; first paints are exempt (an empty
hero is worse than a busy frame); a newer render supersedes a parked older
one so it paints once; switching artists drops any parked commit; and the
expensive snapshotGpuTwins bake rides INSIDE the deferred commit — it is
the very task the gate exists to keep out of a scroll. gpuHeroReady clears
heroLoadInFlight immediately (the load IS done; only the paint waits), so
a superseded unpainted commit can never wedge the rotation machinery.

THE TRIPWIRE (dev builds, dark glass only). Once a second, at scroll calm
so a mid-flick frame gap never false-trips: compare the gfade ride's clock
against scrollTop, and check --travel for the pre-measure sentinel plus
dead or missing timelines. Faults land in globalThis.__heroTripwire and
console.error as one latched snapshot per episode. Self-tested by
injecting the sentinel with a 120px scroll: tripped exactly
travel-sentinel within one tick while correctly NOT flagging the healthy
ride (ct 4.29% = 120/2797). Next time the page breaks in someone's hands
it hands over the culprit instead of a screenshot mystery.

Verified live: a natural rotation completed through the calm path
(heroGpuMs restamped 10.70 -> 29.60 during a fronted 26s watch).
2026-08-31 11:46:17 -05:00

9606 lines
444 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 ArtistExtras from "../components/ArtistExtras.svelte";
import { fade, fly } from "svelte/transition";
import { quintOut } from "svelte/easing";
import {
catalog,
libraryStore,
ui,
streamSong,
saveSong,
streamCatalogAlbum,
saveCatalogAlbum,
resolver,
player,
getCatalogArt,
peekCatalogArt,
getCatalogHeroPreview,
getCachedCatalogHeroArt,
getCatalogHeroArt,
artBroken,
markArtBroken,
coverUpgrades,
resolveCovers,
coverLoadError,
leaveCoverUpgradeScope,
libraryHasAlbum,
libraryLocalAlbum,
gridCover,
tinyCover,
albumDisambiguator,
openCatalogAlbum,
openAlbumFromPreviewItem,
openCatalogDiscography,
loadFollows,
isFollowed,
setFollowed,
announceFollow,
} from "../lib/state.svelte.js";
import { appScrollIdleFor } from "../lib/scrollbar.js";
import * as api from "../lib/api.js";
import { accentFromImage } from "../lib/color.js";
import {
editionBaseTitle,
editionLabel,
isEdition,
releaseFamilies,
splitDescription,
} from "../lib/editions.js";
import { listenerPeak, listenerShare } from "../lib/listenerBars.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 PlaylistMenu from "../components/PlaylistMenu.svelte";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
const nowPlaying = (kind, id) =>
player.status !== "idle" && player.catalogRef[kind] === id;
// A popular row re-attributed onto the song's original album carries that
// album's Deezer track id, not the one Deezer licensed. Search, the
// suggest dropdown and the album page all start a track by the LICENSED
// id, so matching on `track.id` alone silently drops the highlight off the
// row it belongs to. `origin_track_id` is the id we moved away from.
const nowPlayingTrack = (track) =>
nowPlaying("trackId", track.id) ||
(track.origin_track_id != null &&
nowPlaying("trackId", track.origin_track_id));
// Identity that survives the re-attribution. The overview paints Deezer's
// licensed row and the full page replaces it 2-4s later with the same song
// on its real album — a different `track.id`. Keying the list on that id
// would destroy and recreate every corrected row, throwing away the save
// state and making a cover crossfade impossible (a fresh <img> has nothing
// to fade FROM). Both stages agree on this value, and a track that was
// never moved has no `origin_track_id`, so it is just the id as before.
const trackKey = (track) => track.origin_track_id ?? track.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) {
// Keyed on the identity that survives re-attribution, not on the raw
// Deezer id: a corrected popular row keeps the ✓ it earned before the
// full page landed. An expander track has no `origin_track_id`, so
// this is its own id, unchanged.
const key = `t${trackKey(t)}`;
if (saved[key]) return;
saved[key] = "…";
if (await saveSong(artist, t.title)) saved[key] = "ok";
else flagSaveError(key);
}
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);
});
// Every artist page opens at the top. `.content` is the app's ONE scroll
// pane and it outlives the view swap, so its scrollTop carried over: leave
// one artist 900px down its discography, open another, and you landed 900px
// down the new one — mid-grid, hero already squeezed into the bar, with no
// hint the page had a top.
// Keyed on catalog.artistOpenSeq, NOT the artist's id. The id cannot tell a
// re-open of the artist already on screen (clicking their name from Now
// Playing while parked deep in their own page — nothing moved) from no
// navigation at all. It also cannot be keyed on `page`: the cold path
// replaces that object stage by stage under the same id, which would haul a
// reader back to the top mid-read. The seq bumps once per open and never
// per stage. Ask for the pane BY NAME — the same rule scrollPaneFor()
// follows, and for the same reason.
// …UNLESS the reader is coming BACK to this page, in which case they are
// returning to something they had already found and the top is the one
// place they do not want. A history move leaves its remembered offset for
// exactly one landing; a fresh open leaves none and still starts at zero.
$effect(() => {
void catalog.artistOpenSeq;
const pane = document.querySelector(".content");
if (!pane) return;
const target = consumeScroll();
if (target == null) {
pane.scrollTop = 0;
} else {
restoreScroll(pane, target);
}
});
// THE HERO PIPELINE (Kyle, 2026-07-24 — the approved configuration,
// now unconditional):
// - Crossfade twins: the main-thread blur filter rides on the hero
// layers are retired for static-filtered twin layers cross-faded
// by a composited opacity scroll-driven animation (see the twin
// CSS in the @supports block; the @supports-not fallback keeps
// the --p filter rides on the originals and simply never shows
// the twins).
// - No bloom strip: the blurred hero copy behind the glass bar is
// retired; the seam tail alone carries the band.
// - The seam tail (Kyle's design, round 3): a TAIL rising from
// the HERO'S OWN TOP EDGE — a mirrored strip of the artwork
// itself, colour-continuous with the hero by construction — that
// RIDES WITH the hero (same squeeze, same gating), so the edge
// carries its own soft lead-in and a hard line never exists, at
// rest or mid-scroll. Nothing is anchored to the bar and nothing
// extends below the hero's edge. Zero per-frame statics.
// GPU-hero twin: a live canvas has no static twin, so on every 'ready'
// the finished frame is copied once into a plain canvas that the
// pre-filtered twin displays under a STATIC CSS filter. drawImage must
// happen in the same task as renderer.render() (a WebGL drawing buffer
// does not survive the compositor pass), which gpuHeroReady is — it is
// called synchronously after render.
let gpuTwinBox = $state(null);
// Clamped-edge blur bake: pad the source with edge-replicated pixels,
// blur, crop back — a blurred copy that is pixel-aligned with its
// source (same box, same aspect) and has NO transparent fringe at any
// edge. This is the underlying fix for the twin-edge bleed: a CSS
// filter blur samples transparency outside the element and fades the
// border; baking with clamp-to-edge sampling never does. Runs once
// per artwork (load/rotation), never per frame.
function bakeBlur(src, w, h, radius) {
const pad = Math.ceil(radius * 3);
const padded = document.createElement("canvas");
padded.width = w + pad * 2;
padded.height = h + pad * 2;
const g = padded.getContext("2d");
// Centre, four edge strips stretched from 1px slices, four corners.
g.drawImage(src, 0, 0, w, h, pad, pad, w, h);
g.drawImage(src, 0, 0, 1, h, 0, pad, pad, h); // left
g.drawImage(src, w - 1, 0, 1, h, w + pad, pad, pad, h); // right
g.drawImage(src, 0, 0, w, 1, pad, 0, w, pad); // top
g.drawImage(src, 0, h - 1, w, 1, pad, h + pad, w, pad); // bottom
g.drawImage(src, 0, 0, 1, 1, 0, 0, pad, pad); // corners
g.drawImage(src, w - 1, 0, 1, 1, w + pad, 0, pad, pad);
g.drawImage(src, 0, h - 1, 1, 1, 0, h + pad, pad, pad);
g.drawImage(src, w - 1, h - 1, 1, 1, w + pad, h + pad, pad, pad);
const out = document.createElement("canvas");
out.width = w;
out.height = h;
const og = out.getContext("2d");
og.filter = `blur(${radius}px)`;
og.drawImage(padded, -pad, -pad);
return out;
}
// Action for the img-path banner twin: a <canvas> that renders the
// clamp-baked blur of the given url. Display-only (no pixel readback),
// so remote artwork without CORS headers still works.
function bakedBlur(canvas, url) {
let alive = true;
const render = (u) => {
if (!u) return;
const im = new Image();
im.onload = () => {
if (!alive) return;
// Display resolution, not naturalWidth: same reasoning as
// twinScale — a 4K source baked at full res is a 100ms-class
// blur for pixels CSS then throws away. Pre-scale into a
// scratch canvas (bakeBlur's edge-strip slices read source
// coordinates, so it must receive an already-scaled source)
// and scale the radius to keep the displayed softness.
const scale = Math.min(
1,
((heroWidth || window.innerWidth) * devicePixelRatio) / im.naturalWidth,
);
const sw = Math.max(1, Math.round(im.naturalWidth * scale));
const sh = Math.max(1, Math.round(im.naturalHeight * scale));
const scaled = document.createElement("canvas");
scaled.width = sw;
scaled.height = sh;
scaled.getContext("2d").drawImage(im, 0, 0, sw, sh);
const baked = bakeBlur(scaled, sw, sh, 8 * scale);
canvas.width = baked.width;
canvas.height = baked.height;
canvas.getContext("2d").drawImage(baked, 0, 0);
};
im.src = u;
};
render(url);
return {
update: render,
destroy() {
alive = false;
},
};
}
// The seam tail's mirror needs the same snapshot on the GPU path (a
// live canvas has no url for the tail's <img>).
let tailMirrorBox = $state(null);
/** The twins' backing resolution: the hero box's device pixels, never the
* source's. Both twins are CSS-stretched to the box, so source pixels
* beyond box×dpr are invisible — and baking the 4K source at full res
* made snapshotGpuTwins a measured 100-170ms main-thread stall on a
* 4-core iGPU (every rotation, every artist entry). The baked blur
* radius scales by the same factor, so the displayed softness is
* unchanged. */
const twinScale = (src) =>
Math.min(1, ((heroWidth || window.innerWidth) * devicePixelRatio) / src.width);
let twinBakeGeneration = 0;
function snapshotGpuTwins() {
if (!gpuTwinBox && !tailMirrorBox) return;
const gpu = document.querySelector(".artist-page .gpu-hero");
const bannerSrc = gpu?.querySelector("canvas.banner.current");
if (!bannerSrc || !bannerSrc.width) return;
// SAME-TASK BOUNDARY: only this one scaled copy reads the WebGL
// drawing buffer (it does not survive the compositor pass — the
// reason this function is called synchronously from gpuHeroReady).
// Everything below works from the copy and is free to run later.
const scale = twinScale(bannerSrc);
const snap = document.createElement("canvas");
snap.width = Math.max(1, Math.round(bannerSrc.width * scale));
snap.height = Math.max(1, Math.round(bannerSrc.height * scale));
snap.getContext("2d").drawImage(bannerSrc, 0, 0, snap.width, snap.height);
const swapIn = (box, frame) => {
const old = [...box.children];
if (old.length) frame.classList.add("incoming");
box.append(frame);
// Cap at two frames so rapid re-readies can never stack.
while (box.children.length > 2) {
box.firstChild.remove();
}
if (old.length) {
setTimeout(() => {
for (const el of old) el.remove();
}, 1450);
}
};
// Tail mirror: the banner frame only, styled like the img variant.
// Rotation dissolve: the old frame stays underneath while the new
// one fades in on the banner's own curve, then the old is dropped.
if (tailMirrorBox) {
const t = document.createElement("canvas");
t.width = snap.width;
t.height = snap.height;
t.getContext("2d").drawImage(snap, 0, 0);
t.className = "tail-mirror-frame";
swapIn(tailMirrorBox, t);
}
// The twin's banner carries its 8px blur BAKED with clamped edges
// (see bakeBlur) — aligned, fringe-free. Rotation dissolve: the old
// baked frame stays underneath while the new one fades in on the
// banner's own curve (an instant replaceChildren here made the mono
// overlay SNAP to the next photo while the real hero was still
// mid-dissolve — Kyle caught it). The bake runs off the ready task:
// it needs only `snap`, and the outgoing baked frame covering the
// box for one more beat of a 1400ms dissolve is invisible, while the
// blur landing inside the ready task was the biggest slice of the
// stall. The generation guard drops a pending bake when a newer
// ready (or unmount) has superseded it.
if (gpuTwinBox) {
const generation = ++twinBakeGeneration;
const bake = () => {
if (generation !== twinBakeGeneration || !gpuTwinBox) return;
// Same discipline as the periscope reclones: never bake
// within 250ms of app-wide scroll, re-check INSIDE the idle
// callback, and no forcing timeout — the outgoing baked
// frame covering one more beat of the 1400ms dissolve is
// invisible, while a bake forced mid-scroll is a dropped
// frame. The generation guard bounds requeues: the next
// ready supersedes a still-waiting bake.
if (!appScrollIdleFor(250)) {
setTimeout(bake, 250);
return;
}
const bakedBanner = bakeBlur(snap, snap.width, snap.height, 8 * scale);
bakedBanner.className = "gt-banner";
swapIn(gpuTwinBox, bakedBanner);
};
if ("requestIdleCallback" in window) {
requestIdleCallback(bake);
} else {
setTimeout(bake, 50);
}
}
}
/** Re-bake the twins when the hero's box has changed size under them.
*
* The twin is a BAKED copy of the hero canvas, so it keeps the dimensions
* it was baked at. snapshotGpuTwins only ever ran on a hero READY, so after
* a window or panel resize the live hero repainted at the new size while
* the twin went on stretching its old frame into the new box — the mono
* overlay sat visibly off the photograph, and stayed there until the next
* hero happened to re-bake it. (Kyle: "it happens on resize and it's
* permanent until the hero changes.")
*
* Dimension-guarded rather than fired on every resize tick, so it costs
* nothing when the box moves without the backing store following — which
* is most of a drag, since the renderer only re-sizes its store once the
* box settles. */
function rebakeTwinsIfStale() {
const source = document.querySelector(".artist-page .gpu-hero canvas.banner.current");
const baked = gpuTwinBox?.lastElementChild;
if (!source || !source.width || !baked) return;
// Compare against the SCALED target, not the source: the twins bake
// at display resolution (see twinScale), so source dims would always
// mismatch and re-bake on every resize-effect tick.
const scale = twinScale(source);
if (
baked.width === Math.max(1, Math.round(source.width * scale))
&& baked.height === Math.max(1, Math.round(source.height * scale))
) return;
snapshotGpuTwins();
}
let twinRebakeTimer = 0;
$effect(() => {
// Re-runs whenever the hero's measured box changes.
const width = heroWidth;
if (!width) return;
clearTimeout(twinRebakeTimer);
// Past the renderer's own 140ms settle, so the canvas being copied is
// the one already repainted at the new size.
twinRebakeTimer = setTimeout(rebakeTwinsIfStale, 260);
return () => clearTimeout(twinRebakeTimer);
});
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);
// A commit parked for scroll calm belongs to the OLD artist;
// running it now would paint the wrong hero over the new one.
heroCommitParked = null;
clearTimeout(heroCommitTimer);
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(() => {
// A rotation is pure decoration, but starting its pipeline mid-
// gesture lands the cost mid-gesture: a cache-miss source is a
// multi-MB raw-RGBA IPC arrival + full-res texture upload (a
// measured ~150ms main-thread task on a 4-core iGPU) whose
// promise resolves at an arbitrary moment relative to input.
// Skip the tick while the app scrolled recently — the current
// photo simply dwells one cycle longer.
if (!appScrollIdleFor(600)) return;
advanceHero();
}, HERO_ROTATE_MS);
return () => clearInterval(timer);
});
// ── hero commits wait for scroll calm (backlog #3, 2026-08-31) ──────
// The 20s tick above already skips while the app scrolled recently, but
// that gate is start-only: a cache-miss payload (multi-MB raw-RGBA IPC +
// decode + texture/twin bake, a measured ~150ms main-thread task) used
// to COMMIT whenever its promise resolved — an arbitrary moment relative
// to input, ~150ms after the user resumed scrolling in the case that
// motivated the backlog entry. Every banner commit now waits for the
// same calm the tick waits for. The OLD banner and its twin stay mounted
// for the whole wait — nothing is cleared until the deferred commit
// runs — so the mono overlay never sits empty mid-scroll. First paints
// are exempt (an empty hero is worse than a busy frame) via the
// displayedBanner.url check. Latest wins: a finished render superseding
// a parked preview paints once, and every commit re-validates its own
// staleness inside itself, at commit time.
const HERO_COMMIT_IDLE_MS = 600;
let heroCommitParked = null;
let heroCommitTimer = 0;
function deferHeroCommit(commit) {
if (!displayedBanner.url || appScrollIdleFor(HERO_COMMIT_IDLE_MS)) {
heroCommitParked = null;
clearTimeout(heroCommitTimer);
commit();
return;
}
heroCommitParked = commit;
clearTimeout(heroCommitTimer);
const poll = () => {
if (!heroCommitParked) return;
if (!appScrollIdleFor(HERO_COMMIT_IDLE_MS)) {
heroCommitTimer = setTimeout(poll, 200);
return;
}
const run = heroCommitParked;
heroCommitParked = null;
run();
};
heroCommitTimer = setTimeout(poll, 200);
}
$effect(() => () => {
heroCommitParked = null;
clearTimeout(heroCommitTimer);
});
// ── dev tripwire: the hero's rides vs its scroller (2026-08-31) ─────
// Kyle photographed the identity block parked over the Popular rows with
// every scroll-driven paint effect at its rest pose — a transient that
// healed before it could be probed. If it recurs, catch it in the act:
// once a second (and only at scroll calm, so a mid-flick frame gap never
// false-trips), compare the gfade ride's clock against scrollTop and
// check --travel for the pre-measure sentinel. One forced layout per
// second, dev builds and dark glass only (solid mode has no rides).
// Snapshots land in globalThis.__heroTripwire and console.error, latched
// once per episode.
$effect(() => {
if (!import.meta.env.DEV) return;
globalThis.__heroTripwire ??= [];
let tripped = false;
const timer = setInterval(() => {
if (!document.documentElement.classList.contains("glass")) return;
if (!appScrollIdleFor(300)) return;
const view = document.querySelector(".pane-shell > .content");
const pin = view?.querySelector(".heropin");
if (!view || !pin) return;
const st = view.scrollTop;
const range = Math.max(1, view.scrollHeight - view.clientHeight);
const travel = parseFloat(getComputedStyle(pin).getPropertyValue("--travel"));
const anim = view.querySelector(".gfade-frame")?.getAnimations()[0];
const ct = anim
? anim.currentTime === null
? null
: parseFloat(anim.currentTime)
: undefined;
const faults = [];
if (st > 4 && travel > 900000) faults.push("travel-sentinel");
if (st > 4 && anim && ct === null) faults.push("timeline-dead");
if (st > 4 && !anim) faults.push("no-anim");
if (typeof ct === "number" && Math.abs(ct - (st / range) * 100) > 3) {
faults.push("ride-desync");
}
if (faults.length) {
if (!tripped) {
tripped = true;
const snap = { t: Date.now(), faults, scrollTop: st, range, travel, ct };
globalThis.__heroTripwire.push(snap);
if (globalThis.__heroTripwire.length > 20) globalThis.__heroTripwire.shift();
console.error("[hero-tripwire]", JSON.stringify(snap));
}
} else {
tripped = false;
}
}, 1000);
return () => clearInterval(timer);
});
function gpuHeroLoading(source) {
if (source === selectedHeroSource) heroLoadInFlight = true;
}
function gpuHeroReady(source, result) {
if (source !== selectedHeroSource || !selectedUsesGpu) return;
// The LOAD is done now — only the PAINT may wait for calm. Clearing
// in-flight here (not inside the deferred commit) keeps the rotation
// machinery live even if this commit is later superseded unpainted.
heroLoadInFlight = false;
deferHeroCommit(() => {
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,
};
initialBannerPaint = !prior.url;
finishBannerPaint = false;
clearTimeout(bannerTransitionTimer);
if (previousBanner) {
bannerTransitionTimer = setTimeout(() => {
previousBanner = null;
}, 650);
}
// Same-task snapshot for the twin + the seam tail's mirror — see
// snapshotGpuTwins above. Deferred along with the commit: the twin
// bake is the expensive half of exactly the task this gate exists
// to keep out of a scroll.
snapshotGpuTwins();
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") {
deferHeroCommit(() => {
if (!active || heroFingerprint !== fingerprint) return;
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.
deferHeroCommit(() => {
if (!active || heroFingerprint !== fingerprint) return;
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;
}
deferHeroCommit(() => {
if (!active || heroFingerprint !== fingerprint) 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, ranked by Last.fm listeners. Show five, Spotify-style,
// with a "Show more" that reveals the rest. Collapse again when the artist
// changes so a new page never opens pre-expanded.
//
// The length is the BACKEND's to decide (`POPULAR_KEEP`) rather than a
// second cap here: the two were both 10 and silently disagreed the moment
// one moved.
let showAllPopular = $state(false);
const allPopular = $derived(page?.popular_tracks ?? []);
/** The Popular pill's yardstick: the artist's most-heard track, over the
WHOLE list so the widths hold steady across Show more/less. */
const popularPeak = $derived(listenerPeak(allPopular));
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;
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);
},
openAlbum: (item) => void openAlbumFromPreviewItem(item),
});
}
// 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
// Cover-derived marks trace at a larger raster with far less blur than they
// used to: their alpha now carries the source's real anti-aliasing (see
// cover_logo.rs ALPHA_INK_SPAN), so detail survives instead of needing to be
// smoothed away. Measured on Camping in Alaska: 900 → ~2200 path vertices
// with no staircasing, and serif terminals that keep their shape.
const LOGO_COVER_TRACE_EDGE = 2400;
const LOGO_COVER_TRACE_BLUR = 0.5; // floor; scales up only for tiny sources
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) {
if (node.clientWidth > 0) heroWidth = node.clientWidth;
// Width from the ENTRY, not clientWidth: an entry is a post-layout
// fact, while a DOM read in a delivery forces layout whenever an
// earlier callback in the same round has written — and the hero
// reflow writes on every frame of a drag. .hero-zone carries no
// border or padding, so the border box IS the client width.
const observer = new ResizeObserver((entries) => {
const box = entries[entries.length - 1];
const w = Math.round(
box.borderBoxSize?.[0]?.inlineSize ?? box.contentRect.width,
);
if (w > 0) heroWidth = w;
});
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)));
// The row WRAPS, so shedding is the last resort rather than the first: it
// survives only below the width where even a wrapped row cannot fit the
// parked banner. The old middle tier ("s", under 640) is what deleted nine
// of thirteen items on a merely 1280px window — heroWidth is the CONTENT
// width, some 660px less than the window once the sidebar and Now Playing
// are open, so that threshold fired far wider than it reads. Still measured
// at the content width.
const chipTier = $derived(heroWidth < 380 ? "xs" : "l");
// The divider only earns its place while the social block is trailing
// something on its own line; alone at the head of a wrapped line it
// separates nothing and reads as a stray tick.
// This toggle is safe ONLY because the rule is out of flow (see
// .chip-divider in the CSS). Were it an in-flow item, hiding it would give
// back the very width that caused the wrap, the group would fit again, the
// divider would return, and the row would flip between one line and two
// forever. Nothing here may change what wraps.
// Bound through state and `class:wrapped` rather than written with
// classList: Svelte scopes this component's CSS and prunes any selector it
// cannot see used in the markup, so a class added imperatively compiles the
// rule away entirely ("Unused CSS selector") and the divider never hides.
let socialWrapped = $state(false);
function syncSocialDivider(page) {
const set = page.querySelector(".social-set");
if (!set) return;
// Against the PREVIOUS SIBLING, not the row's first chip: what the
// divider means is "these are separate from the thing on my left", so
// it should survive a wrap that carries the heart down with it and
// only go when the group genuinely starts a line.
const prev = set.previousElementSibling;
// Tops rather than a line count: .social-set is 29px and a chip 27px,
// so same-line items differ by a pixel under align-items: center.
const wrapped =
!!prev &&
set.getBoundingClientRect().top - prev.getBoundingClientRect().top > 4;
// Guarded: measure() runs from a ResizeObserver, and an unconditional
// write would dirty the graph on every reflow for no change.
if (wrapped !== socialWrapped) socialWrapped = wrapped;
}
// 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);
// Past the fade boundary the social block leaves LAYOUT, not just paint.
// Fading it alone would keep its LINE, and the row would stay two lines
// tall with nothing in the lower one — which is exactly what left the Play
// button stranded below the only chips still visible. Reactive for the same
// reason heroScrolled is: Svelte prunes CSS whose selectors it cannot see
// used, and [data-park] is no exception. Flips at most twice over the
// travel, on the same 0.42 boundary --extra-hit already uses.
let extrasParked = $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;
// Supported path only: the last value written to --extra-hit, so
// the sentinel observer below writes it at its 0.42 boundary
// crossings and never else — the one style write that survives on
// that path (pointer-events is not animatable, so it cannot become
// a scroll-driven animation).
let lastHit = null;
// Supported path only: the two 1px threshold sentinels and their
// IntersectionObservers (created in the scrollDriven block below),
// plus the degenerate-page flag measure() keeps for them. With
// these, the supported path has NO scroll listener at all: reading
// scroller.scrollTop while the scroll-driven animations have style
// dirty forces a full style+layout pass per frame (measured: 93ms
// forced UpdateStyleAndLayout per 6.9s of continuous flicks just
// from apply()), and both of apply()'s remaining jobs are threshold
// crossings the compositor can report instead.
let s1 = null;
let s2 = null;
let io1 = null;
let io2 = null;
let travelFinite = false;
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;
// measure()'s write block, deferred one rAF (latest wins). Written
// inside the ResizeObserver delivery, the custom properties dirty
// style mid-round and every later callback's first read — the grid
// reserve's — pays a full forced layout, every frame of a drag.
// Deferred, the writes ride the next frame's one natural layout.
// Every consumer already tolerates the unwritten state (the
// var(--travel, 999999px) sentinels exist for the pre-measure first
// paint), so a one-frame lag is the same situation, briefer.
let commit = null;
let commitRaf = 0;
// The chips' un-parked height, reused on parked non-settle passes so
// a drag deep in the page skips the data-park toggle (see below).
let chipsHCache = -1;
function measure(settled = true) {
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.
//
// READS FIRST, WRITES LAST — this runs on every frame of a window
// drag (the ResizeObserver below), and every style write here
// dirties the very geometry the next read then forces back up to
// date. Interleaved, one pass through this function cost up to six
// forced layouts; batched, it costs one (two while parked, for the
// data-park toggle). Nothing may read layout after the write block
// starts.
//
// Measured from the TOP OF THE BAND, so these compare directly
// against band-local gradient stops.
const zoneTop = zone.offsetTop;
const zoneH = zone.offsetHeight;
const span = zoneTop + zoneH - 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;
// Written in the write block below, and only when measured here —
// absent copy/slot the previous values must stand, as they always
// have.
let markEnd = null;
if (copy && slot) {
markRest = copy.offsetHeight - slot.offsetTop - slot.offsetHeight;
// 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;
markEnd = markHeight > markPx ? (markPx / markHeight).toFixed(4) : "1";
inkPark = markHeight > markPx ? markPx : markHeight || markPx;
}
// The Play/chips row's resting height, MEASURED off the row itself;
// the hole it leaves in .popular then follows it (min-height:
// var(--row-h)), so the two still cannot drift apart. Read the
// other way round — spacer to row, as it was — --row-h is a
// constant wearing a measurement's clothes: .actions-gap is empty,
// so its height is only ever what the stylesheet says. That was
// survivable while the row was locked to one line and fatal the
// moment it wraps, because the box is anchored by its BOTTOM: with
// --row-h too small the extra line grows the only way left to it,
// up into the wordmark. With the real height here the box's top
// pins to --edge at every line count and the growth goes down into
// the reserve instead.
// The row's own min-height reads --row-min, never --row-h, or this
// would ratchet — a tall measurement would hold the floor up and
// the row could never come back down.
// Derived from the CHIP BLOCK, not read off the row's own box: the
// box takes its height FROM --row-h (min-height), so measuring the
// box and feeding it back would ratchet — the row could grow and
// never shrink again. The chips are never stretched (the row is
// align-items: flex-start), so their height is pure content.
// Measured with the parked collapse forced OFF, because --row-h
// also sizes the hole in .popular: let the hole follow the
// collapsed row and the whole track list would slide up under the
// reader halfway through a scroll. The toggle is synchronous, so
// no paint can land between the two writes.
const chipsEl = page.querySelector(".stat-chips");
const gapEl = page.querySelector(".actions-gap");
// The one-line floor, per breakpoint, and the top padding derived
// from it (see --row-pad-top). The row is padTop + the chips'
// offset against the 50px lead row + the chips + 8px. Both reads
// sit BEFORE the park toggle: taken after the attribute flip they
// pay the style pass the flip dirties.
const floor =
parseFloat(getComputedStyle(page).getPropertyValue("--row-min")) || 76;
const padTop = (floor - 66) / 2 + 8;
const gapH = chipsEl ? 0 : gapEl?.offsetHeight ?? floor;
// Guarded three ways. At rest data-park is already "0", and even
// a same-value attribute write is a style invalidation a drag
// frame cannot afford. Parked — scrolled deep, which is where the
// discography drags happen — the toggle costs a forced layout to
// un-collapse the chips, then a second when the next read lands
// after the flip back; so mid-drag (settled = false) the last
// measured height is reused and only the trailing settle pass,
// the mount and the data-change remeasures pay the exact read.
// Stale by at most one wrap boundary, above the viewport, for the
// 200ms until the settle pass — and scroll anchoring absorbs the
// correction.
const parkAttr = page.dataset.park ?? "0";
let chipsH;
if (chipsEl && parkAttr !== "0" && !settled && chipsHCache >= 0) {
chipsH = chipsHCache;
} else {
if (parkAttr !== "0") page.dataset.park = "0";
chipsH = chipsEl?.getBoundingClientRect().height ?? 0;
if (parkAttr !== "0") page.dataset.park = parkAttr;
chipsHCache = chipsEl ? chipsH : -1;
}
const rowH = chipsEl
? Math.max(floor, Math.ceil(padTop + 11.5 + chipsH + 8))
: gapH;
syncSocialDivider(page);
// 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;
travel = span - strip > 1 ? span - strip : Infinity;
const rideValid = span - strip > 1;
// Probed with the other reads, consumed by the --row-slide write
// below.
const hasPlay = !!page.querySelector(".hero-play");
const hasPreview = !!page.querySelector(".hero-preview");
// 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);
// 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.
// The anchor is the LOWEST ink in the row, which is the Play button
// only while the row is one line: once the chips wrap they hang
// below it, and a cut still anchored on the button would dissolve
// the list rows above the chips' real bottom edge. .stat-chips
// rides a translateX (chips-slide-ride), never a translateY, so its
// measured bottom is as scroll-invariant as the button's.
// Measured here, ahead of the write block, under the PREVIOUS
// pass's custom properties: the quantity is the ink's offset below
// --edge, which the glue keeps constant at every p and every
// span/strip, so a one-pass-stale pose moves it by at most the
// frame's own drift — and the trailing settle pass reads it clean.
const body = page.querySelector(".scrollbody");
const rowInk = [
page.querySelector(".hero-play"),
page.querySelector(".stat-chips"),
].filter(Boolean);
let rowBottom = rowH + 14;
if (rowInk.length && band) {
const pNow = Math.min(
1,
Math.max(0, scroller.scrollTop / Math.max(1, span - strip)),
);
const edgeNow = span - pNow * (span - strip);
const bandTop = band.getBoundingClientRect().top;
rowBottom =
Math.max(
...rowInk.map((el) => el.getBoundingClientRect().bottom),
) -
bandTop -
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.
// The last two geometry reads, then the write block.
const scrollerPadTop = scrollDriven
? parseFloat(getComputedStyle(scroller).paddingTop) || 0
: 0;
const frameH = scroller.clientHeight;
// JS state, not style — apply() and the sentinel observers read
// these synchronously, so they are set with the reads, not
// deferred with the writes.
travelFinite = Number.isFinite(travel);
// ── the write block — geometry above, styles below, applied at
// the next rAF (latest measurement wins; see `commit`) ──
commit = () => {
if (markEnd != null) {
page.style.setProperty("--mark-rest", `${markRest}px`);
page.style.setProperty("--mark-end", markEnd);
}
page.style.setProperty("--row-h", `${rowH}px`);
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. the glass bar'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", `${zoneTop}px`);
page.style.setProperty("--hero-span", `${span}px`);
page.style.setProperty("--strip", `${strip}px`);
// The scroll-driven animations' constants (see the second
// @supports (animation-timeline: scroll()) block in the CSS).
// --travel is the shared animation-range span — the p
// denominator — so linear easing over 0..--travel gives
// animation progress ≡ p at every scrollTop. The 999999px
// sentinel mirrors travel = Infinity above (p pinned ~0: no
// animation progresses measurably), and every consumer reads
// var(--travel, 999999px) so the pre-measure first paint is
// inert too. --ride is the SAME delta as a length for
// to-keyframes, and must be a separate variable: tied to the
// range sentinel, a degenerate page's to-value would cancel
// into translate = -scrollTop; the 0px sentinel keeps it
// motionless exactly as p = 0 does today. --fade-span is the
// extras/badge fade's own range (travel / 2.4, where their
// opacity ramp clamps): dividing the 999999px sentinel instead
// would let a deep scroll on a degenerate page ghost-fade chips
// that today hold opacity 1 at any scrollTop.
page.style.setProperty(
"--travel",
`${rideValid ? (span - strip).toFixed(1) : 999999}px`,
);
page.style.setProperty(
"--ride",
`${rideValid ? (span - strip).toFixed(1) : 0}px`,
);
page.style.setProperty(
"--fade-span",
`${rideValid ? ((span - strip) / 2.4).toFixed(1) : 999999}px`,
);
// The chips row's total leftward slide for the scroll-driven
// path: the sum of the layout width both buttons ahead of it in
// the flex row shed (22 mirrors --play-shrink; 13 is
// .hero-preview's 40→27). On that path the buttons' REAL widths
// are frozen and shrink by transform instead, so the flex row no
// longer supplies this motion. Presence-aware so a
// conditionally-rendered preview button cannot leave a phantom
// 13px slide, and validity-gated like --ride: on a degenerate
// page the range sentinel would otherwise creep the chips
// ~1.75px at extreme scrollTop where today's pinned p holds
// them motionless. (The button/glyph scale rides keep constant
// to-poses — their same-class residual is ≤1.1px on geometry
// the 390px hero-zone minimum makes unreachable; accepted.)
page.style.setProperty(
"--row-slide",
`${rideValid ? (hasPlay ? 22 : 0) + (hasPreview ? 13 : 0) : 0}px`,
);
page.style.setProperty("--fog-span", `${fogSpan}px`);
page.style.setProperty("--fog-above", `${fogAbove}px`);
page.style.setProperty("--fog-rest", `${fogRest}px`);
// The sheet's pose delta for its scroll-driven glide (usually
// negative): from-pose translateY(--fog-rest), to-pose
// translateY(--fog-rest + --fog-ride) ≡ translateY(strip -
// fog-above) — the same two exact endpoints the fallback calc
// has. 0px sentinel, same reasoning as --ride above.
page.style.setProperty(
"--fog-ride",
`${rideValid ? (strip - fogAbove - fogRest).toFixed(1) : 0}px`,
);
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.
page.style.setProperty(
"--cut-top",
`${(cutScreen - scrollerPadTop).toFixed(1)}px`,
);
const hostFlowTop = zoneTop + zoneH - 18;
const fadeS = Math.max(0, hostFlowTop - cutScreen);
page.style.setProperty("--fade-s", `${fadeS.toFixed(1)}px`);
}
// Unconditional: .fade-frame is scroll-driven-only, but .sheetclip
// sizes itself from --frame-h on BOTH branches (it is what keeps the
// 300vh sheet out of the pane's scroll range). Setting it on the
// fallback path is otherwise inert — .fade-frame's rule lives inside
// the @supports block and never matches there.
page.style.setProperty("--frame-h", `${frameH}px`);
// The extras' hit-gate sentinel parks at the scrollTop where
// p crosses 0.42. Probed live (2026-07-24, this WebView2): the
// observers' rootBounds top coincides with the page's box top
// at scrollTop 0 — the pane's 24px padding does NOT offset the
// crossing — but the observer reports integer-snapped rects,
// so the -1 below folds the sentinel's own 1px height into the
// placement: the snapped bottom then reaches the root's top at
// the same integer scrollTop the old `p > 0.42` first exceeded
// (probed flip: auto at 377, none at 378, boundary 377.58).
// Off-screen sentinel on a degenerate page, mirroring travel =
// Infinity pinning p at 0 (travelFinite keeps the observers
// honest about that case too).
if (s2) {
s2.style.top = travelFinite
? `${(0.42 * travel - 1).toFixed(1)}px`
: "-99999px";
}
};
if (!commitRaf) {
commitRaf = requestAnimationFrame(() => {
commitRaf = 0;
const c = commit;
commit = null;
c?.();
});
}
}
// FALLBACK ONLY (no scroll-driven animations). On the supported
// path apply() NEVER runs — there is no scroll listener at all: the
// scroll-driven animations own every per-frame style, and the two
// threshold flips apply() used to perform (data-scroll at p > 0,
// the extras' hit gate at p > 0.42) come from the IntersectionObserver
// sentinels in the scrollDriven block below, which the compositor
// reports without a single forced style+layout read.
function apply() {
frame = 0;
const scrollTop = scroller.scrollTop;
// 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.
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;
// Every consumer in the @supports not branch still rides the
// --p custom property, rewritten on every p change exactly as
// it always was.
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");
// Same boundary, and by it the block is already at opacity 0, so
// taking it out of layout here pops nothing.
extrasParked = p > 0.42;
// 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(settled = true) {
measure(settled);
// Supported path: nothing per-frame is left to apply — the
// observers re-deliver on their own once measure() has moved
// the sentinels, and running apply() here would pay the exact
// forced-layout scrollTop read this path exists to avoid.
if (scrollDriven) return;
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) {
// The named timeline every scroll-driven animation below the
// fade trio binds to (animation-timeline: --artist-pane), set
// once on the EXACT element apply() reads scrollTop from — the
// timeline and p can never disagree. It must be NAMED:
// scroll(nearest) is a trap here, because .hero-bg's
// overflow: hidden makes it a scroll container per css-overflow,
// so nearest-lookup from the hero layers would bind to a
// never-scrolling box and freeze them at their from pose.
// (.fade-flow alone keeps scroll(nearest block): its ancestor
// chain has no scroll container in the way.) A named timeline is
// visible to all descendants of the declaring element, and
// .artist-page is a descendant of the pane. Safe as an inline
// write: the only other inline-style writer on the pane
// (scrollbar.js floatScroll) sets individual properties, never
// cssText.
scroller.style.setProperty("scroll-timeline", "--artist-pane block");
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);
}
// The threshold sentinels: two 1px absolutely-positioned divs
// whose crossings of the scrollport's top edge reproduce
// apply()'s two boundary flips without any scrollTop read.
// Appended as the LAST children of the page — never before the
// pins (measure()'s "pins are first children" invariant) and
// never inside the fade trio. contain: strict keeps them out of
// every layout/paint pass.
const mkSentinel = (top) => {
const d = document.createElement("div");
d.setAttribute("aria-hidden", "true");
d.style.cssText =
"position:absolute;left:0;top:" +
top +
";width:1px;height:1px;pointer-events:none;contain:strict;";
page.appendChild(d);
return d;
};
// s1 sits at the page's box top: fully visible exactly at
// scrollTop 0 (probed: rootBounds.top == the page's box top in
// this engine), so intersectionRatio < 1 ≡ p > 0. s2 parks off-
// screen until measure() places it at the 0.42 crossing.
s1 = mkSentinel("0px");
s2 = mkSentinel("-99999px");
io1 = new IntersectionObserver(
(entries) => {
// Crossings can batch under a stalled main thread —
// only the LAST entry is the current state.
const en = entries[entries.length - 1];
// travelFinite mirrors the degenerate-page guard:
// travel = Infinity pins p at 0, so data-scroll must
// never flip no matter how deep the scroll goes.
heroScrolled = travelFinite && en.intersectionRatio < 1;
},
{ root: scroller, threshold: [1] },
);
io1.observe(s1);
io2 = new IntersectionObserver(
(entries) => {
const en = entries[entries.length - 1];
// Past the 0.42 boundary means the sentinel left the
// scrollport upward — its (snapped) bottom at or above
// the root's top. NOT isIntersecting: the observer
// counts edge-contact as intersecting, which would hold
// the flip a pixel past the boundary. travelFinite
// keeps the parked off-screen sentinel of a degenerate
// page reading "auto", exactly as pinned p = 0 does.
const past =
travelFinite &&
en.boundingClientRect.bottom <=
(en.rootBounds ? en.rootBounds.top : 0);
const hit = past ? "none" : "auto";
if (hit !== lastHit) {
lastHit = hit;
page.style.setProperty("--extra-hit", hit);
// The social block's layout collapse rides the same
// boundary crossing — see extrasParked.
extrasParked = past;
}
},
// Both edges: the 1 guarantees a notification for the
// full-visibility -> edge-contact transition (ratio 1 -> 0
// with isIntersecting unchanged), where the "none" flip now
// lands.
{ root: scroller, threshold: [0, 1] },
);
io2.observe(s2);
}
reflow();
// Not `reflow` bare: .stat-chips may be a DIFFERENT element than the
// one currently observed (the loading branch and the real chip set are
// two nodes in two arms of an {#if}), so re-point before measuring.
// Safe to reference observeChips here — the body runs long after the
// const below is initialised, unlike the reflow() call above it.
remeasureHero = () => {
observeChips();
reflow();
};
// Supported path: NO scroll listener. Even an empty per-frame
// handler would keep the main thread in the scroll loop, and
// apply()'s scrollTop read forces style+layout while the
// scroll-driven animations have style dirty. The sentinels above
// carry both of its jobs.
if (!scrollDriven) {
scroller.addEventListener("scroll", onScroll, { passive: true });
}
// A window resize (maximize especially) settles across SEVERAL
// layout passes, and a reflow fired mid-transition measures torn
// geometry — one such measure wedged the fade line 280px low at
// fullscreen (rows vanishing below the cut, Kyle's repro of the
// long-standing rows-stay-hidden intermittent) and NOTHING re-fired
// afterward to heal it. So: measure immediately for responsiveness,
// then once more after layout quiesces. The trailing pass never
// reschedules itself, so there is no loop.
let settleTimer = 0;
const reflowAndSettle = () => {
// Mid-drag pass: parked chips reuse their cached height. The
// trailing pass (bare reflow, settled = true) measures exactly.
reflow(false);
clearTimeout(settleTimer);
settleTimer = setTimeout(reflow, 200);
};
const observer = new ResizeObserver(reflowAndSettle);
// The chip block is what --row-h is derived from, and it changes height
// on its own — the set fills out as staged data lands, the pane narrows
// and it gains a line, the artist changes and it loses one — with
// neither band, zone nor pane resizing at that instant.
// It has to be the chips and not .artist-actions: the row's box is
// pinned to --row-h by min-height, so a chip set that gets SHORTER
// leaves the box exactly as tall as it was and the observer never
// fires. Growth alone did fire, which is precisely what made observing
// the row look like it worked.
// No loop: the callback writes --row-h, which moves the row's `bottom`
// (a position, not a size), sets its min-height, and grows
// .actions-gap — none of which changes the chips' own height, since
// they are never stretched. The gap sits below .hero-zone in flow, so
// it cannot feed back into --hero-span, --strip or --travel either.
let observedChips = null;
const observeChips = () => {
const el = page.querySelector(".stat-chips");
if (!el || el === observedChips) return;
if (observedChips) observer.unobserve(observedChips);
observer.observe(el);
observedChips = el;
};
observer.observe(band);
const zone = page.querySelector(".hero-zone");
if (zone) observer.observe(zone);
// The pane itself too: a height-only window change (vertical
// maximize) moves the vh-capped banner/zone limits without
// necessarily resizing band or zone at their measured instant.
observer.observe(scroller);
// And the CHIP BLOCK, which is what --row-h is derived from. It
// changes height on its own — the set fills out as staged data lands,
// and it gains and loses lines as the pane narrows — without band,
// zone or pane resizing at that instant.
// It must be the chips and not .artist-actions: the row's box is
// pinned to --row-h by min-height, so a chip set that gets SHORTER
// leaves the box exactly as tall as it was, the observer never fires,
// and --row-h stays stuck at the taller value for good. (Growth alone
// did fire, which is what made this look like it worked.) The chips
// are never stretched, so their height tracks their content both ways.
// No loop: the callback writes --row-h, which moves the row's `bottom`
// (a position, not a size), sets its min-height, and grows
// .actions-gap — and none of those change the chips' own height. The
// gap sits below .hero-zone in flow, so it cannot feed back into
// --hero-span, --strip or --travel either.
// ...but .stat-chips is not one element for the page's lifetime: the
// loading branch and the real chip set are two different nodes in two
// arms of an {#if}, so observing whichever exists at mount silently
// leaves the OTHER one unwatched for good. Re-point on every measure.
observeChips();
return {
destroy() {
remeasureHero = () => {};
if (!scrollDriven) {
scroller.removeEventListener("scroll", onScroll);
}
// The pane outlives this page — take the named timeline back
// off it. (Nothing else references --artist-pane, and the
// {#if} teardown/mount is sequential, so two owners never
// interleave.)
scroller.style.removeProperty("scroll-timeline");
clearTimeout(settleTimer);
observer.disconnect();
flowObserver?.disconnect();
io1?.disconnect();
io2?.disconnect();
s1?.remove();
s2?.remove();
if (frame) cancelAnimationFrame(frame);
if (commitRaf) cancelAnimationFrame(commitRaf);
commit = null;
},
};
}
$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;
// The chip row swaps between the loading status and the real set on
// this flag, and the two are different elements — the remeasure is
// also what re-points the ResizeObserver at whichever one now exists.
void page?.art_preview;
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 blur here is
// COMPENSATION, not a goal: it exists to synthesise the
// anti-aliasing a mask lacks, and it costs serif detail —
// terminals melt into blobs at ~1px of blur.
//
// The Rust segmentation now scales its alpha ramp to the
// measured ink distance (see cover_logo.rs ALPHA_INK_SPAN),
// so the mask arrives with the source's REAL anti-aliasing
// instead of a near-binary edge. That is what makes a lighter
// touch safe: less blur and a tighter simplify tolerance now
// resolve genuine sub-pixel curvature, where before they only
// captured the pixel staircase in finer detail.
//
// A tiny mark still needs proportionally more smoothing, so
// the blur keeps scaling with the enlargement — just from a
// much lower base.
const scale = LOGO_COVER_TRACE_EDGE / candidate.img.naturalWidth;
source = rgba(
candidate.img,
LOGO_COVER_TRACE_EDGE,
Math.max(LOGO_COVER_TRACE_BLUR, scale * 0.3),
);
eps = 0.2;
// Speck floor scales with the raster, or a 2400px trace keeps
// dust the 1400px one dropped.
minArea = 24 * (LOGO_COVER_TRACE_EDGE / 1400) ** 2;
} 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, "");
// Drop the word introducing a volume number so "Volume 2", "Vol. 2" and a
// bare "2" all reduce alike. Deezer and MusicBrainz number a series
// differently — measured on Queen, Deezer sells "Deep Cuts 2 (1977-1982)"
// while MB files "Deep Cuts, Volume 2 (1977–1982)". Only when a digit
// actually follows, so a record called "Volume" keeps its name.
const foldVolumes = (s) => s.replace(/\b(?:vol\.?|volume)\s*(?=\d)/gi, "");
// 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).
// Both sides of this map go through `hintKey`, and that is the whole point:
// it used to key on the RAW title at both ends, so a hint only landed when
// Deezer and MusicBrainz happened to name a release identically. Measured
// on Queen, 52 of 90 releases found no hint — "Deep Cuts 2 (1977-1982)"
// never met "Deep Cuts, Volume 2 (1977–1982)", so the series kept Deezer's
// wrong date and sat in Albums while Volume 1 sat in Compilations.
// Volume folding ONLY — deliberately not `editionBaseTitle`. Stripping the
// edition/variant tail as well does match more rows, but it makes distinct
// releases collide: "We Will Rock You (Raw Sessions Version)" reduces to
// "We Will Rock You" and inherits a live release's verdict, which moved a
// single into Live Albums. A hint that is merely absent falls through to the
// heuristics; a hint taken from the WRONG release is silently authoritative.
const hintKey = (title) => norm(foldVolumes(title));
const typeHintMap = $derived.by(() => {
const map = new Map();
for (const hint of page?.typeHints ?? []) {
if (!hint?.title) continue;
const key = hintKey(hint.title);
// An artist can carry several groups whose titles reduce alike (a
// work and its live record). First wins, matching how MB returns
// them; a later duplicate must not overwrite a good verdict.
if (key && !map.has(key)) map.set(key, 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);
// Edition identity (isEdition / editionBaseTitle / editionLabel /
// releaseFamilies) lives in src/lib/editions.js so the album page folds a
// record's pressings exactly the way this page does.
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";
// MusicBrainz's own Mixtape/Street typing, and the only thing that ever
// sets this flag. It outranks every heuristic below because the row
// exists for no other reason — the supplement fetched it BY that type,
// so there is nothing left for a title regex to decide. Above the
// description hint too: a mixtape's lead sentence routinely calls it a
// "compilation album", which would file Acid Rap under Compilations.
if (album.is_mixtape) return "mixtape";
// 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(hintKey(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";
}
// 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"] },
// Sits beside Albums rather than down with the archival tabs: for the
// artists who have any, the mixtape is usually canon (Exmilitary is
// Death Grips' debut) rather than a footnote. Empty tabs are dropped,
// so it appears for nobody else.
{ key: "mixtape", label: "Mixtapes", cats: ["mixtape"] },
{ 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: [], mixtape: [], 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);
});
// Hand the finished filing to the discography page, which renders THIS
// list rather than classifying the releases a second time. It re-publishes
// as the background description harvest and the MB identity pass land, so
// the two pages can never show a release in different tabs — see
// `catalog.discoTabs`.
$effect(() => {
const id = page?.artist?.id;
if (id != null && tabs.length) catalog.discoTabs = { artistId: id, tabs };
});
// 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([]);
/** Last.fm album totals for the open edition: {plays, listeners, summed}. */
let openTotals = $state(null);
let openLoading = $state(false);
// openLoading is set SYNCHRONOUSLY in showEdition, before its first await,
// so the loading DOM is committed on every open no matter how fast the data
// is — measured 7-31ms on a warm open, which is a flash and nothing more.
// This gate is what is actually rendered: a load has to last long enough to
// be worth reporting before anything appears.
const LOADING_GRACE = 250;
let loadingVisible = $state(false);
$effect(() => {
if (!openLoading) {
loadingVisible = false;
return;
}
const timer = setTimeout(() => (loadingVisible = true), LOADING_GRACE);
return () => clearTimeout(timer);
});
// Placeholder rows stand in the real .trow geometry at the album's own track
// count, so the real list lands exactly where they sat and the panel does
// not change height when they are replaced. Clamped: track_count is
// optional metadata and can disagree with the fetched list, so this is a
// plausible shape, never a promise about the count.
const SKELETON_WIDTHS = [62, 45, 78, 53, 68, 39, 71, 49];
const skeletonRows = $derived(
Array.from(
{ length: Math.min(12, Math.max(4, openAlbum?.track_count ?? 8)) },
(_, i) => SKELETON_WIDTHS[i % SKELETON_WIDTHS.length],
),
);
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;
}
}
/* Coverart Archive covers are fetched through Rust, not by the <img>: they
* have no CORS headers and the host redirects on to archive.org, so a
* direct load is both slow and unreliable (PROXIED_ART_HOST above). The
* GRID does this already — smoothSrc swaps in the proxied data URL and
* getCatalogArt caches it — but the expander's rail art was a plain <img>
* pointed straight at the CDN, so for a CAA-hosted album it refetched over
* the network something the card beside it already held decoded. That is
* the "still loading" on Made in Timeland and Alien Metal; every other
* album on the page is Apple- or Deezer-hosted and needs no proxy, which
* is why only those two showed it.
* Resolved covers land here so the markup can read them synchronously. */
let proxiedArt = $state({});
function railCover(url) {
if (!url) return url;
const sized = gridCover(coverUpgrades[url] ?? url);
if (!needsArtProxy(sized)) return sized;
// Synchronous cache hit first — going through the promise costs a
// microtask, and one frame with the raw URL in src is a real network
// request for a file already in memory.
const ready = proxiedArt[sized] ?? peekCatalogArt(sized);
if (ready) return ready;
void getCatalogArt(sized).then((data) => {
if (data) proxiedArt[sized] = data;
});
return sized;
}
/** Lede + body for the open record, from the shared splitter. */
const railDesc = $derived(splitDescription(openInfo?.description));
/** What the expander is already showing, handed to the album page so its
* skeleton opens holding this record's artwork instead of a grey block.
* `railCover` on purpose: that exact URL is the one already decoded and in
* memory for the rail, so the seeded cover costs no request and cannot
* flash on its way in. */
function albumSeed(album) {
if (!album?.cover || artBroken[album.cover]) return null;
return {
cover: railCover(album.cover),
title: album.title ?? null,
artist: album.artist ?? page?.artist?.name ?? null,
kind: album.record_type ?? null,
// The open expander has already fetched the real tracks; its count
// beats the card's metadata, which is missing on some rows.
tracks: openTracks.length || album.track_count || null,
};
}
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);
},
};
}
// Crossfade a popular row's thumbnail when the album underneath it is
// corrected.
//
// The overview paints Deezer's licensed attribution ~150ms in and the full
// page replaces it 2-4s later with the song's real album. That is a
// content swap, not a layout shift — the row is a fixed grid at
// min-height 54px, so nothing moves — but a bare `src` reassignment is a
// hard cut the instant the new file decodes, and with five rows fetching
// uncached CDN images independently it lands as a ragged flicker. Worse,
// the browser keeps painting the OLD picture until the new one decodes, so
// there is a guaranteed window showing the new album's name beside the old
// album's cover. That reads as a glitch rather than as the page resolving.
//
// So the action owns `src` (the markup deliberately does not bind it):
// decode the replacement off-DOM first, then park the outgoing frame in a
// ghost layer and fade it out over the new one. First paint is untouched —
// there is nothing to fade from, and adding a fade there would slow down
// the stage this page is measured on.
function resolvedCover(node, url) {
let applied = url ?? "";
let ghost;
let fadeTimer;
const dropGhost = () => {
clearTimeout(fadeTimer);
ghost?.remove();
ghost = null;
};
node.src = applied;
const swap = async (next) => {
next = next ?? "";
if (next === applied) return;
const outgoing = applied;
applied = next;
// Nothing painted yet, or nothing to paint: this is first paint or
// a fall back to the placeholder, neither of which is a crossfade.
if (!next || !outgoing || !node.naturalWidth) {
node.src = next;
return;
}
try {
const probe = new Image();
probe.decoding = "async";
probe.src = next;
await probe.decode();
} catch {
// Undecodable. Hand it to the live <img> anyway so its own
// onerror routes through markArtBroken and the row falls back
// to the disc placeholder, exactly as it does today.
if (applied === next) node.src = next;
return;
}
// A newer correction overtook this one mid-decode.
if (applied !== next || !node.isConnected) return;
dropGhost();
ghost = node.cloneNode(false);
ghost.className = "track-cover cover-ghost";
ghost.src = outgoing;
ghost.removeAttribute("loading");
node.parentElement?.appendChild(ghost);
node.src = next;
requestAnimationFrame(() => ghost?.classList.add("faded"));
fadeTimer = setTimeout(dropGhost, 320);
};
return {
update: (next) => void swap(next),
destroy: dropGhost,
};
}
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.
// Every mounted grid's own measure(), so the expander can force a recompute.
// A ResizeObserver cannot cover this: the reserve is applied as the grid's
// min-height, which is precisely what stops the grid's box from changing
// when the panel comes and goes — the observer sees nothing and the reserve
// sticks at whatever the page happened to measure mid-settle.
const gridMeasures = new Set();
// Non-zero while a revealIntoView is driving the pane. See setReserve.
let revealActive = 0;
function measureCols(node, groupKey) {
// The reserve RISES instantly and FALLS on a glide — UNLESS a reveal is
// running, in which case a fall is instant too. A reveal eases toward a
// target clamped by the scroll range, so a range gliding down underneath
// it drags the page: measured on a switch to the last row, 299px out,
// a reversal, then 254px more. The reveal owns the scroll and will land
// it; the glide exists only for teardown, where nothing else will.
// A fall is always the same situation: the content shrank, and the
// reveal parks the pane exactly ON the clamp by construction — so the
// browser clamps scrollTop by the whole difference in one frame and the
// page hops. Spread over ~260ms it clamps a couple of px per frame and
// reads as the page settling instead. A rise can never clamp anything,
// so it stays immediate; and a rise may interrupt a glide, which is
// what keeps the reveal's per-frame re-stand authoritative.
let glide = 0;
let glideTo = null;
// "Instant" writes land in the NEXT rAF, never inside the
// ResizeObserver delivery that asked for them. A write during
// delivery makes the observer loop re-run style and layout in the
// SAME frame to check for further changes — a whole extra layout
// pass per frame, paid on every frame of a drag while the reserve
// tracks the moving margin. Deferred one rAF, the write rides the
// frame's single natural layout, and the echo delivery it provokes
// is filtered by the entry guard below without a DOM read. A frame
// of lag on a min-height floor is invisible — and the reveal never
// waits on this path: it stands its own reserve synchronously.
let pendingTo = null;
let flushRaf = 0;
const flushReserve = () => {
flushRaf = 0;
if (pendingTo == null || !node.isConnected) {
pendingTo = null;
return;
}
const standing = parseFloat(node.style.minHeight) || 0;
const to = pendingTo;
pendingTo = null;
// Never lower under a running reveal: it may have raised the
// standing value past this queued figure since the queueing pass,
// and dropping beneath it would clamp the pane mid-ease.
if (revealActive && to < standing) return;
if (to !== standing) node.style.minHeight = `${to}px`;
};
const setReserve = (to) => {
const standing = parseFloat(node.style.minHeight) || 0;
const from = pendingTo ?? standing;
if (glide) {
if (to <= glideTo) return; // already heading there or lower
cancelAnimationFrame(glide);
glide = 0;
glideTo = null;
}
// While a reveal owns the pane the reserve may RISE but never fall.
// A fall shrinks the range under a running ease, and the pane is
// parked on the clamp by construction, so the browser takes the
// whole difference in one frame: measured 256px on a switch to a
// higher row, front-loaded onto an ease that was about to travel
// there anyway. Held high, the ease does all of the motion and the
// fall happens afterwards, from retire(), where it can glide.
if (revealActive && to < from) return;
if (to >= from - 2 || revealActive) {
if (pendingTo === to || (pendingTo == null && standing === to)) return;
pendingTo = to;
if (!flushRaf) flushRaf = requestAnimationFrame(flushReserve);
return;
}
pendingTo = null;
glideTo = to;
const start = performance.now();
const DUR = 260;
glide = requestAnimationFrame(function step(now) {
if (!node.isConnected) { glide = 0; glideTo = null; return; }
const t = Math.min(1, (now - start) / DUR);
const ease = 1 - (1 - t) ** 3;
node.style.minHeight = `${Math.round(standing + (to - standing) * ease)}px`;
if (t < 1) { glide = requestAnimationFrame(step); return; }
glide = 0;
glideTo = null;
});
};
// Found once, re-queried only after a teardown replaced them: the
// pane and the tab bar cannot move while this grid is mounted.
let cachedPane = null;
let cachedBar = null;
// The reveal reserve's expensive half — the open row's offset inside
// the grid. Width alone cannot move it (tracks are fixed 150px): it
// moves when the expander changes ROW — a column-count change, a
// different album, art landing above it. Recomputing it on every
// frame of a drag paid rects plus a subtree sweep 48 times for a
// handful of distinct answers, and was most of the difference between
// ~20ms and ~60ms p95 frames with a panel open. Keyed on exactly what
// invalidates it; forced() clears the key for the DOM-driven cases
// (open, row change, close, art landing).
let rowOffCols = -1;
let rowOffXp = null;
let rowOffRow = null;
let rowOff = 0;
// A column-count change moves the expander only on Svelte's NEXT
// flush, so an offset read in the same pass sees the old layout.
let colsSettling = false;
const measure = () => {
const n = getComputedStyle(node).gridTemplateColumns.split(" ").length;
if (gridCols[groupKey] !== n) {
gridCols = { ...gridCols, [groupKey]: n };
// Re-measure once the expander has landed on its new row —
// the offset cached this pass is against a layout that is
// about to change. (Today's per-frame recompute healed on the
// next drag frame; a drag that ENDS on a column change has no
// next frame, so this settle pass also closes that hole.)
if (!colsSettling && node.querySelector(".album-expander")) {
colsSettling = true;
void tick().then(() => {
colsSettling = false;
if (node.isConnected) forced();
});
}
}
if (!cachedPane?.isConnected) cachedPane = 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.
if (!cachedBar?.isConnected) {
cachedBar = node.closest(".pane")?.querySelector(".disco-tabs")
?? document.querySelector(".disco-tabs");
}
const pane = cachedPane;
const bar = cachedBar;
if (!pane || !bar) return;
const chrome = node.getBoundingClientRect().top - bar.getBoundingClientRect().top;
// Real content BELOW the grid fills the pane just as well as the
// grid itself does. This reserve exists so everything under the
// pinned tab bar spans a pane-height; when the grid was the last
// thing on the page, the grid had to supply all of it. It no longer
// is — the added sections (ArtistExtras) follow it — and without
// this discount the grid stood on 206px of empty min-height below
// its last card, a hole that only became visible once something
// rendered after it. When nothing follows the grid, belowGrid is 0
// and the figure is exactly the old one.
const below = belowGrid(node);
let reserve = Math.max(0, Math.round(pane.clientHeight - chrome - below));
// Same argument one level down, for the open expander's own row.
// revealIntoView lifts that row to the banner line, and a row in the
// last screenful has less beneath it than the pane is tall — so the
// reveal eased toward a scrollTop the range could not hold, ran the
// curve on toward it and stopped dead against the clamp (measured on
// a 29-album artist: wanted 1846, max 1594, 272px in one frame then
// 39 frames pinned). Reserving the row's own pane-height fixes the
// target instead of chasing it, and it belongs HERE rather than only
// in the reveal: the expander's height is not the page's growth (the
// slack in this very reserve swallowed 435 of a 486px panel), so it
// cannot be predicted from the panel — only reserved against. The
// reveal stands the same figure up synchronously; this keeps it
// true across resizes and hands it back on close.
// The INCOMING expander during the 460ms overlap, not whichever is
// first in DOM order: Svelte marks an element inert while it plays
// its outro, so the one without that attribute is the one that is
// staying. Reserving for the one that is leaving sizes the grid for
// a layout that is about to stop existing.
const xp = node.querySelector(".album-expander:not([inert])")
?? node.querySelector(".album-expander");
const openRow = xp?.previousElementSibling;
if (openRow) {
if (n !== rowOffCols || xp !== rowOffXp || openRow !== rowOffRow) {
rowOff = revealRowOffset(node, openRow, xp);
rowOffCols = n;
rowOffXp = xp;
rowOffRow = openRow;
}
// The cheap half stays live every frame: the pane's height and
// the banner margin move with the window, the row offset does
// not. Discounted by the content below the grid for the same
// reason as the plain term — the range the reveal needs under
// the open row is supplied by the sections after the grid just
// as well as by empty min-height, and without the discount the
// open panel sat on the same empty band the closed page used
// to. Keep in sync with revealReserve, which the reveal itself
// stands synchronously.
reserve = Math.max(
reserve,
Math.round(
rowOff + pane.clientHeight - revealMargin(node) - belowGrid(node),
),
);
}
setReserve(reserve);
};
// The resize path still runs EVERY frame — the grid keeps scaling live.
// What it stops doing is repeating work whose inputs have not moved.
//
// The reserve is a pure function of the grid's width and the pane's
// height. When neither has changed since the last pass — which is every
// observer callback provoked by our own minHeight write, and every
// vertical-only resize — recomputing it cannot produce a different
// answer, so the guard is exact rather than approximate.
//
// Sizes come from the ENTRIES, never from clientWidth/clientHeight: an
// entry is a post-layout fact, but a DOM read here forces layout
// whenever anything earlier in the same delivery has written — and the
// reserve's own write provokes an echo delivery every time it moves.
// The pane is observed alongside the grid for the same reason; its
// height rides in with the entries and the echo path never touches
// the DOM at all.
let lastW = -1;
let lastPaneH = -1;
// The grid's parent is observed too: the sections after the grid land
// asynchronously (fetches, images), growing the parent without touching
// the grid's or the pane's box — and `below` above is measured against
// exactly that growth. Tracked as its own input so the guard still
// filters pure echoes; the delivery our own min-height write provokes
// does change the parent's height, but measure() then computes an
// identical reserve (below is invariant to the grid's inflation) and
// setReserve no-ops, so it costs one pass and stops.
let lastParentH = -1;
const onResize = (entries) => {
let w = lastW;
let paneH = lastPaneH;
let parentH = lastParentH;
for (const entry of entries) {
const box = entry.borderBoxSize?.[0];
if (entry.target === node) {
w = Math.round(box ? box.inlineSize : entry.contentRect.width);
} else if (entry.target === node.parentElement) {
parentH = Math.round(box ? box.blockSize : entry.contentRect.height);
} else {
paneH = Math.round(box ? box.blockSize : entry.contentRect.height);
}
}
if (w === lastW && paneH === lastPaneH && parentH === lastParentH) return;
lastW = w;
lastPaneH = paneH;
lastParentH = parentH;
measure();
};
// Anything that changes the DOM rather than the box — an expander
// opening, a row change, a close — must not be filtered by that guard:
// its inputs are unchanged but its answer is not. The row-offset cache
// is keyed on the same DOM, so it drops here too.
const forced = () => {
lastW = -1;
lastPaneH = -1;
lastParentH = -1;
rowOffCols = -1;
rowOffXp = null;
rowOffRow = null;
measure();
};
measure();
gridMeasures.add(forced);
const ro = new ResizeObserver(onResize);
ro.observe(node);
if (node.parentElement) ro.observe(node.parentElement);
const paneEl = cachedPane ?? scrollPaneFor(node);
if (paneEl) ro.observe(paneEl);
return {
destroy: () => {
ro.disconnect();
gridMeasures.delete(forced);
cancelAnimationFrame(glide);
cancelAnimationFrame(flushRaf);
},
};
}
// Re-measure when the expander opens, moves row or closes. On the FIRST open
// of a freshly loaded page the grid is still settling — card art and wrapped
// titles land and push the target row down (measured: 488px into the grid
// when the reveal read it, 555px once settled) — so the reserve taken at
// click time comes up short and the row parks below the line. The reveal
// idles on its target for a beat, so a corrected reserve arriving here is
// enough for it to finish the travel.
$effect(() => {
void openFamilyKey;
let done = false;
const remeasure = () => {
if (!done) gridMeasures.forEach((m) => m());
};
void tick().then(remeasure);
// Spread across the settle, because what moves the row is the card art
// this very click asked for: opening an album resolves and preloads its
// covers, those images land over the next few hundred ms, and rows above
// the target grow as they do. One measurement at click time is taken
// before any of that. Only the first open of a page pays it — afterwards
// the art is cached and the first measurement is already right.
const timers = [80, 250, 550, 850].map((ms) => setTimeout(remeasure, ms));
return () => {
done = true;
timers.forEach(clearTimeout);
};
});
/** 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 = [];
openTotals = null;
openInfo = null;
}
/** The open edition's artwork, in the shared full-window viewer. Uses the
* UPGRADED cover, not the 500px grid variant the rail paints: the point of
* the viewer is the master resolution. */
function openRailArtwork() {
const album = railArtAlbum ?? openAlbum ?? openFamily;
const cover = album?.cover;
if (!cover || artBroken[cover]) return;
ui.artwork = {
src: coverUpgrades[cover] ?? cover,
local: false,
title: album.title ?? "",
subtitle: page?.artist?.name ?? "",
};
}
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 = [];
openTotals = null;
openLoading = true;
try {
const details = await api.catalogAlbum(album.id);
if (openAlbumId === album.id) {
openTracks = details.tracks;
openTotals = {
plays: details.album_plays ?? null,
listeners: details.album_listeners ?? null,
summed: Boolean(details.totals_summed),
};
}
} catch {
if (openAlbumId === album.id) openTracks = [];
} finally {
if (generation === editionLoadGeneration && openAlbumId === album.id) {
openLoading = false;
}
}
}
async function preloadEditionArt(album) {
if (railArtAlbumId === album.id) return;
// railCover, matching what the rail actually renders — including the
// proxy hop for Coverart Archive. Preloading anything else warms a URL
// the panel does not ask for: the decode this awaits would finish
// against one file while the <img> fetched another, so the gate meant
// to hide the swap would be gating on the wrong picture.
const source = album.cover ? railCover(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.
/** The line the reveal lands a row on: clears the sticky glass bar 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. */
function revealMargin(node) {
const artistPage = node.closest(".artist-page");
const banner = artistPage
? parseFloat(getComputedStyle(artistPage).getPropertyValue("--banner-h")) || 176
: 176;
return Math.round(22 + banner + 18);
}
/** How tall the grid must be for revealIntoView to lift `row` to that line:
* the row's own offset inside the grid, plus a pane-height below it. Shared,
* because both users need the SAME number for different reasons — the reveal
* must have it standing before its first frame or it eases at a target the
* range cannot hold, and measureCols must maintain it across resizes and
* hand it back when the expander closes. */
/** Height standing above `row` that is on its way out. Every expander other
* than `keep` is mid-outro and collapses to nothing over 460ms, so a row
* measured while they are still up is measured against a layout that will
* not exist by the time anything lands there. On a switch to a row BELOW
* the one being closed this is the whole defect: the reserve came out 826px
* too big, the scroll range ballooned with it, the ease set off for a point
* that then walked back toward it, and the page moved three times. */
function vanishingAbove(grid, row, keep) {
if (!grid) return 0;
// Gather before measuring: with a single expander standing — every
// frame outside the 460ms of a row switch — there is nothing on its
// way out, and the rect and style reads below would be paid for an
// answer that is always zero. This runs on every frame of a reveal
// ease, so the empty case must cost only the subtree query.
let others = null;
for (const other of grid.querySelectorAll(".album-expander")) {
if (other !== keep) (others ??= []).push(other);
}
if (!others) return 0;
const rowTop = row.getBoundingClientRect().top;
// The expander is a GRID ITEM on a row of its own, so the row-gap that
// separates it from its neighbour goes with it. Counting only its
// height left the estimate exactly one gap — 16px — too generous, and
// that was the bump at the end: the reveal held the reserve at 1541,
// retired, the recompute came back with the true 1525, and the pane,
// parked on the clamp, was dragged 10px about 120ms after the page had
// stopped. Small, late, and unattached to anything the user did, which
// is exactly what makes a 10px move read as a bump.
const gap = parseFloat(getComputedStyle(grid).rowGap) || 0;
let px = 0;
for (const other of others) {
const r = other.getBoundingClientRect();
// Its whole outer contribution: height, its own collapse margin,
// and the row-gap only if the grid still uses one. expandSlide
// retires the margin in step with the height, so reading both keeps
// this exact at every point of the outro rather than drifting.
const mb = parseFloat(getComputedStyle(other).marginBottom) || 0;
if (r.top < rowTop) px += r.height + mb + gap;
}
return px;
}
/** The open row's offset inside the grid, net of anything mid-outro above
* it — the half of the reserve that costs layout reads. Split from
* revealReserve so measureCols can cache it across a drag: width alone
* cannot move it (tracks are fixed 150px), only a row change can. */
function revealRowOffset(grid, row, keep) {
return row.getBoundingClientRect().top
- grid.getBoundingClientRect().top
- vanishingAbove(grid, row, keep);
}
/** Real content following the grid inside the same scroll flow — the added
* sections (ArtistExtras) and their end spacer. Both reserves exist to
* guarantee scroll RANGE, and range below the grid counts toward it
* wherever it lives; without this discount each reserve inflated the grid
* as if it were still the last thing on the page, which is exactly the
* empty band that showed under the tab bar (closed) and under an open
* panel (revealed). Parent-bottom minus grid-bottom is invariant to the
* grid's own min-height (both edges move together), so a reserve computed
* from it converges instead of chasing its own write. Floored, not
* rounded: over-discounting by half a pixel would leave the reveal's ease
* idling against a target the range misses by that half-pixel. */
function belowGrid(grid) {
const parent = grid?.parentElement;
if (!parent) return 0;
return Math.max(
0,
Math.floor(
parent.getBoundingClientRect().bottom
- grid.getBoundingClientRect().bottom,
),
);
}
function revealReserve(grid, row, pane, keep) {
return Math.round(
revealRowOffset(grid, row, keep) + pane.clientHeight - revealMargin(grid)
- belowGrid(grid),
);
}
function revealIntoView(node) {
const row = node.previousElementSibling; // last card of the open row
const pane = scrollPaneFor(node);
if (!row || !pane) return;
const MARGIN = revealMargin(node);
const DURATION = 420;
const from = pane.scrollTop;
const start = performance.now();
// Stand the reserve up NOW, before the first frame. measureCols owns it
// and recomputes the identical figure, but only off a ResizeObserver —
// which fires after this animation has already run its 420ms and parked
// against the old clamp (measured: the reserve landed, raising the range
// to 1852, with the page long since stopped at 1528).
// On the grid's min-height because that is what moves this page:
// .scrollbody's height does not answer to padding at all — 0px, 200px
// and 600px of padding-bottom all measured the same 1787px box.
const grid = row.parentElement;
// Non-zero while this reveal owns the scroll — see setReserve, which
// must not glide a falling reserve underneath a running ease.
revealActive += 1;
let live = true;
const retire = () => {
if (!live) return;
live = false;
revealActive -= 1;
// The reserve was held against falling for the duration; nothing
// else will notice it can come down now, so ask. Deferred so the
// final scrollTop of the ease is committed first, and only when the
// LAST reveal retires — a switch overlaps two of them.
if (!revealActive) {
queueMicrotask(() => {
if (!revealActive) gridMeasures.forEach((m) => m());
});
}
};
if (grid?.classList.contains("grid")) {
const need = revealReserve(grid, row, pane, node);
if ((parseFloat(grid.style.minHeight) || 0) < need) {
grid.style.minHeight = `${need}px`;
}
}
// Long enough to absorb a grid that is still settling, short enough that
// nothing can creep for a noticeable time after the panel is open.
const SETTLE = 1000;
let raf = requestAnimationFrame(function step(now) {
const elapsed = now - start;
const t = Math.min(1, elapsed / DURATION);
const ease = 1 - (1 - t) ** 3;
// Re-stand the reserve EVERY frame, raising only. The figure taken
// above is computed while the OUTGOING expander is still holding
// its 460ms of layout open, so on a switch it is short by exactly
// that height and the ease parks against a clamp the range cannot
// hold. It then sat there until the outro ended and the range grew,
// at which point the closer below moved the page a second time —
// measured 72px starting 120ms after it had visibly stopped, which
// is the "settles then jumps at the end". Recomputed per frame the
// range simply keeps up with the row, and there is nothing left to
// correct afterwards. Raising only: a fall here would clamp the
// pane mid-ease, and falls belong to measureCols on teardown.
if (grid?.classList.contains("grid")) {
const now2 = revealReserve(grid, row, pane, node);
if ((parseFloat(grid.style.minHeight) || 0) < now2) {
grid.style.minHeight = `${now2}px`;
}
}
// The row's FINAL position, not its current one. On a switch the
// outgoing panel above it is still collapsing, so the row is drifting
// upward under us for 460ms; easing at where it is now means chasing
// it, and the ease ends before the drift does — which is the second
// movement, 254px, that arrives after the page has visibly stopped.
// Discounting what is about to vanish makes the target the place the
// row is going, so the collapse and the scroll resolve together.
const want = row.getBoundingClientRect().top
- pane.getBoundingClientRect().top + pane.scrollTop - MARGIN
- vanishingAbove(grid, row, node);
// Clamped regardless of the reserve: easing at a target the range
// cannot hold is exactly what made the open judder.
const target = Math.max(
0,
Math.min(want, pane.scrollHeight - pane.clientHeight),
);
if (t < 1) {
pane.scrollTop = from + (target - from) * ease;
raf = requestAnimationFrame(step);
return;
}
// The ease can finish against a target that has since moved: on the
// FIRST open of a freshly loaded page the grid is still settling —
// card art and wrapped titles land, the row lifts, and the reserve
// measured a moment ago comes up short (measured 1458 against the
// 1525 the settled page wants, landing the row 61px below the line).
// Close the remainder exponentially rather than ending short or
// snapping; it is imperceptible when the gap is already zero, which
// is the normal case.
// Waiting on `want`, not on the clamped target: while the reserve is
// still short the two are equal the moment the page reaches the
// clamp, and quitting there is exactly how the row gets left below
// the line. Idle here until the reserve grows or SETTLE expires.
const gap = target - pane.scrollTop;
if (Math.abs(want - pane.scrollTop) < 0.5 || elapsed > SETTLE) {
if (Math.abs(gap) < 1) pane.scrollTop = target;
raf = null;
retire();
return;
}
if (Math.abs(gap) > 0.5) pane.scrollTop += gap * 0.2;
raf = requestAnimationFrame(step);
});
const cancel = () => {
if (raf) cancelAnimationFrame(raf);
raf = null;
retire();
};
// The user's own scrolling wins immediately.
pane.addEventListener("wheel", cancel, { once: true, passive: true });
return {
destroy: () => {
cancel();
pane.removeEventListener("wheel", cancel);
// Hand the reveal's reserve back — by RECOMPUTING it, never by
// clearing it. measureCols still cannot notice on its own (its
// ResizeObserver only fires when the grid's BOX changes, and
// the raised min-height is exactly what stops it changing when
// the panel leaves), so it has to be driven from here. But
// `minHeight = ""` drops the plain tab-bar reserve as well as
// the reveal's, for the one frame before measureCols reinstates
// it — and the pane is parked ON the clamp by construction, so
// the browser clamps scrollTop into the gap and the page yanks.
// Measured on close: range 1608 -> 1093 -> 1528, scrollTop
// 1602 -> 1093. A 509px jump, 120ms after the collapse had
// visibly finished. measure() with no expander present computes
// the same plain reserve the clear was reaching for, so this
// gets there in ONE assignment with no hole in between.
// Deferred by a microtask so the outgoing expander is out of
// the DOM before measure() looks for it, and re-checked because
// selectTab closes the expander and then swaps the keyed grid —
// the outro can be racing that teardown.
if (grid?.isConnected && grid.classList.contains("grid")) {
queueMicrotask(() => {
if (grid.isConnected) gridMeasures.forEach((m) => m());
});
}
},
};
}
/* SAME-ROW ALBUM SWITCH — the panel morphs into the next album rather than
* closing and reopening. Only this path reaches it: expanderIndex keeps the
* same insertion point for a same-row switch, so .album-expander never
* remounts and only the keyed .xp-panel inside it changes. It used to be
* destroyed and rebuilt with a 150ms fly-in, which is the close-then-open
* the user was seeing.
* Settings are the ones approved from the mockup: staggered morph, 400ms,
* quintOut. `research/.../mockups/album-switch.html` is that mockup. */
const SWAP_MS = 400;
const SWAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; // quintOut
// Parts, in the order they arrive. The backdrop layers do NOT lift: sliding
// a 38px-blurred wash reads as a wobble rather than a reveal. Delays are
// quoted at the mockup's 360ms baseline and scaled to SWAP_MS.
// .rail-desc is deliberately absent: toggleExpander nulls openInfo
// synchronously, so the description does not exist yet when this runs and
// querying for it here found nothing — it popped in later, unanimated,
// which is what the user saw. It carries its own `in:partIn` instead, and
// so do the track rows, which can land hundreds of ms after the swap.
// .xp-tracks stays, and is the reason the rows are covered when they are
// already cached: Svelte plays an element's `in:` only when it is added by
// a later update, not when it is part of its block's first render, so a
// cached list has no intro of its own and rides the column's instead.
const SWAP_PARTS = [
{ sel: ".rail-glow, .rail-shade", delay: 0, lift: false },
{ sel: ".rail-art-stage", delay: 0, lift: true },
{ sel: ".rail-title", delay: 60, lift: true },
{ sel: ".rail-meta", delay: 90, lift: true },
{ sel: ".rail-cta", delay: 120, lift: true },
{ sel: ".rail-eds", delay: 175, lift: true },
{ sel: ".xp-tracks", delay: 90, lift: true },
];
const reducedMotion = () =>
typeof matchMedia === "function"
&& matchMedia("(prefers-reduced-motion: reduce)").matches;
/** One part of the staggered morph, as a transition so it belongs to the
* element rather than to a snapshot of the DOM. The description resolves a
* microtask after the panel is built and the track rows hundreds of ms
* later; anything driven from panelEnter simply misses them. Fades to the
* element's OWN resting opacity, never to 1. */
function partIn(node, { delay = 0, duration = SWAP_MS * 0.7 } = {}) {
if (reducedMotion()) return { duration: 1 };
const rest = parseFloat(getComputedStyle(node).opacity) || 1;
return {
delay,
duration,
easing: quintOut,
css: (t, u) => `opacity: ${t * rest}; transform: translateY(${7 * u}px);`,
};
}
/** Fade+lift ONE element from its own mount, with the same WAAPI call
* `staggerIn` makes.
*
* The description needs this because `in:partIn` above never actually ran
* for it, and that was measured on this page: opening a Weezer record and
* sampling `.rail-lede` every frame gave opacity 1 on all 1389 frames cold
* and all 711 warm, with no transform at any point.
*
* Both of the other mechanisms miss it by construction. `staggerIn` is an
* action on .xp-panel and queries for its parts when that element is
* created, but the description block is created microseconds later — same
* flush — so the query finds nothing, which is why .rail-desc was left out
* of SWAP_PARTS in the first place. And Svelte plays an `in:` only for an
* element added by a later UPDATE; landing in the same flush is not one, so
* the intro is skipped too. An action always runs when its own element is
* created, whichever flush that is, so it covers every arrival time. */
function partAnim(node, { delay = 0 } = {}) {
if (reducedMotion()) return;
const rest = getComputedStyle(node).opacity || "1";
node.animate(
[
{ opacity: 0, transform: "translateY(7px)" },
{ opacity: rest, transform: "none" },
],
{
duration: SWAP_MS * 0.7,
delay: delay * (SWAP_MS / 360),
easing: SWAP_EASE,
fill: "backwards",
},
);
}
/** Hold the stage at an eased height that TRACKS the panel, rather than
* animating once to a height measured up front. The panel is at its
* smallest when the swap begins — no description, no track rows — so a
* snapshot target is the wrong number for any album whose list is longer
* than its rail: measured switching a 2-track album to a 16-track one, the
* stage eased to 441 and then jumped 117px in a single frame at 418ms when
* the rows landed. Following the panel means late content is eased to, not
* jumped to, and the clip stays on until the height has actually settled. */
function followStageHeight(stage, panel, fromH) {
const seq = String((+(stage.dataset.swapSeq || 0)) + 1);
stage.dataset.swapSeq = seq;
stage.style.overflow = "hidden";
stage.style.height = `${fromH}px`;
void stage.offsetHeight; // start value for the transition
stage.style.transition = `height ${SWAP_MS}ms ${SWAP_EASE}`;
let settleTimer = 0;
let capTimer = 0;
let ro = null;
const finish = () => {
if (stage.dataset.swapSeq !== seq) return;
ro?.disconnect();
clearTimeout(settleTimer);
clearTimeout(capTimer);
stage.style.transition = "";
stage.style.height = "";
stage.style.overflow = "";
delete stage.dataset.swapSeq;
};
const apply = () => {
if (stage.dataset.swapSeq !== seq) return;
const h = panel.getBoundingClientRect().height;
if (h > 0 && stage.style.height !== `${h}px`) stage.style.height = `${h}px`;
// Let go once the panel has stopped changing size — not on a timer
// tied to the swap, because the tracks can land after it.
clearTimeout(settleTimer);
settleTimer = setTimeout(finish, SWAP_MS + 60);
};
// No feedback loop: the panel is content-sized, so the height written
// to the stage cannot change the panel's own height.
ro = new ResizeObserver(apply);
ro.observe(panel);
apply();
capTimer = setTimeout(finish, 4000);
}
/** The outgoing panel: out of flow so the incoming one has the stage to
* itself, and faded early so the two are never both legible. */
function panelLeave(node) {
node.style.position = "absolute";
node.style.left = "0";
node.style.right = "0";
node.style.top = "0";
node.style.pointerEvents = "none";
return {
duration: reducedMotion() ? 1 : SWAP_MS * 0.4,
easing: quintOut,
css: (t) => `opacity: ${t}`,
};
}
/** The incoming panel. Svelte only times this; the visible motion is the
* per-part WAAPI stagger below, because a transition's css() applies to the
* node as a whole and this has to move its pieces separately. */
/** The waterfall itself, as an ACTION so it runs however the panel came to
* exist. It used to live inside panelEnter, which is an `in:` transition —
* and Svelte plays those only for elements added by a later update, not for
* elements that are part of a newly-created block's first render. A
* same-row switch updates the keyed block, so it ran there; opening from
* nothing and opening a different row both CREATE the whole expander, so
* the parts had no animations at all (measured: 0 on every one of them) and
* the panel simply appeared. An action has no such rule. */
function staggerIn(node) {
if (reducedMotion()) return;
for (const { sel, delay, lift } of SWAP_PARTS) {
for (const el of node.querySelectorAll(sel)) {
// To the element's OWN resting opacity, never to 1:
// .rail-glow rests at 0.32, and animating it to 1 ran the
// colour wash at three times its brightness for the whole
// transition, then dropped it the instant the animation was
// released. That is the bright-then-dark bleed.
const rest = getComputedStyle(el).opacity || "1";
el.animate(
[
{ opacity: 0, transform: lift ? "translateY(7px)" : "none" },
{ opacity: rest, transform: "none" },
],
{
duration: SWAP_MS * 0.7,
delay: delay * (SWAP_MS / 360),
easing: SWAP_EASE,
fill: "backwards",
},
);
}
}
}
function panelEnter(node) {
const quick = reducedMotion();
// The stage only needs its height eased when there IS an outgoing panel
// to ease FROM. On a first open the expander itself is animating open
// (expandSlide) and a second height animation would fight it.
const stage = node.parentElement;
const leaving = stage?.querySelector(".xp-panel[style*='absolute']");
if (stage && leaving && !quick) {
// Clipped for as long as the height is being driven: the stage is
// held at an eased height while a panel of a different height sits
// inside it, and without this the taller one hangs out of the box
// and the album row below is drawn over it — measured up to 127px
// of spill between panels whose track lists differ. Released by
// followStageHeight once the height settles, so at rest the row's
// hover controls and tooltips can still escape.
followStageHeight(stage, node, leaving.getBoundingClientRect().height);
}
// The parts are staggered by use:staggerIn, which has already run by
// now — it fires on mount, for every way a panel can appear, where this
// transition only fires on a same-row switch.
return { duration: quick ? 1 : SWAP_MS, css: () => "" };
}
// 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 } = {}) {
// The height is read LIVE, per frame, and never captured. Svelte turns
// a `css` transition into a keyframe list computed ONCE at the start
// and played with fill: forwards, so `const height = node.offsetHeight`
// measured the panel at click time — when it is still an empty
// skeleton, because showEdition sets openTracks = [] / openLoading =
// true and loadAlbumInfo sets openInfo = null synchronously, all before
// Svelte's flush. The panel therefore animated to the WRONG height, and
// when the animation was cancelled at the end it reverted to its real
// one in a single frame: measured 348px -> 465px at 476ms, on every
// open, warm or cold. That snap is what reads as "the instant it loads
// it expands the box bigger" — it is not the description arriving (on
// a warm open the description is in at 7ms and the tracks at 46ms), it
// is this animation finishing against a stale number.
// `tick` costs a style write per frame, but height was never a
// compositable property anyway: both forms relayout every frame, so
// this buys correctness for nothing.
let full = node.scrollHeight;
// The spacing below the box travels WITH it. The grid's row-gap is 0
// and this margin is the rhythm (see .grid / .album-expander), so
// retiring it in step with the height means the collapsed element
// contributes nothing at all and its removal moves no row. Read off the
// element so the CSS stays the single source of the number.
const skirt = parseFloat(getComputedStyle(node).marginBottom) || 0;
return {
duration,
easing: quintOut,
tick: (t, u) => {
// scrollHeight reports the CONTENT's height and ignores the one
// being imposed here, so it keeps telling the truth as tracks
// and description land underneath. Monotonic, so a row that
// arrives mid-flight cannot make the box lurch backwards.
full = Math.max(full, node.scrollHeight);
if (t === 1) {
// Hand every property back rather than leaving the final
// frame's inline values pinned on the element.
node.style.cssText = node.style.cssText.replace(
/(?:overflow|height|min-height|opacity|transform|margin-bottom):[^;]*;?/g,
"",
);
return;
}
node.style.overflow = "hidden";
node.style.minHeight = "0px";
node.style.height = `${t * full}px`;
node.style.opacity = `${Math.min(1, t / 0.74)}`;
node.style.transform = `translateY(${-8 * u}px)`;
// 0 at t=0, its full value at t=1.
node.style.marginBottom = `${skirt * t}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);
// The album's own most-played track is the yardstick — a bar scaled to the
// artist's biggest hit would leave every track on a quiet record flat. Zero
// means Last.fm knew none of this tracklist, and the whole column (header
// included) stays out of the layout rather than printing a row of dashes.
//
// The scale is curved rather than linear so a runaway hit cannot squash the
// rest of the record; it lives in src/lib/listenerBars.js because the album
// page draws this identical column and the two must agree.
const countPeak = $derived(listenerPeak(openTracks));
/** Curved share of the album's peak, as a bar width. */
const countShare = (track) => listenerShare(track.listeners, countPeak);
/** Unknown is a dash, never a zero — same rule as the Popular column. */
const fmtCount = (n) => (n == null ? "—" : n.toLocaleString());
const openAlbumPlays = $derived(openTotals?.plays ?? null);
const openAlbumListeners = $derived(openTotals?.listeners ?? null);
const openTotalsSummed = $derived(Boolean(openTotals?.summed));
/** Album totals are headline figures, so they abbreviate. */
const fmtCompact = (n) => {
if (n == null) return "—";
if (n >= 1e6) return `${(n / 1e6).toFixed(n < 1e7 ? 1 : 0)}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}K`;
return String(n);
};
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`;
}
// Last.fm listeners, grouped in full rather than abbreviated like fmtFans:
// the column exists to show what the list is sorted BY, and 1.7M appears
// three times running on a big artist where the real figures differ by
// eighty thousand. A dash means Last.fm has no count for the track — either
// too new to have been scrobbled, or this artist had no usable anchor at
// all. Unknown is not zero, so it must not render as one.
function fmtListeners(n) {
return n == null ? "—" : n.toLocaleString();
}
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" },
};
/* ---- the follow chip (approved 2026-08-04: FB4, leading the row, the
* Encore choreography, the portrait toast) --------------------------------
*
* Truth lives in the `follows` store — persisted in the library db, read
* by the future home page — and this page keeps only the VISUAL state,
* which deliberately lags it: on follow the db write and the toast fire at
* the click, but the chip walks through the ceremony (+ collapses → three
* bars dance → the check springs in with the label) and only wears its
* on-state at the check's arrival. Unfollow is an undo and skips the show.
*
* fbShown/fbStage drive the markup through Svelte; the ceremony flips them
* on timers. Only the floating notes and the glow are imperative spawns
* (they are transient siblings the template must not own), which is why
* their selectors are :global below — Svelte prunes classes it cannot see
* in the markup, and a pruned rule simply never renders (the .namebox
* lesson).
*/
let fbEl = $state(null);
let fbShown = $state(false);
let fbStage = $state("idle"); // "idle" | "bars"
let fbJustLanded = $state(false); // gates the check/label entrances to the ceremony
// $state because the template reads it (the + wears lead-out while the
// ceremony runs) — a plain let never re-renders in runes mode.
let fbCeremony = $state(false);
let fbTimers = [];
$effect(() => {
void loadFollows();
const id = page?.artist?.id;
const on = id != null && isFollowed(id);
if (!fbCeremony) {
fbShown = on;
fbStage = "idle";
fbJustLanded = false;
}
});
// A navigation mid-ceremony must not leak timers into the next artist.
$effect(() => {
void page?.artist?.id;
return () => {
fbTimers.forEach(clearTimeout);
fbTimers = [];
fbCeremony = false;
};
});
/** Follow/Following width change played, never snapped: the label's width
* is animated between its measured sizes and the chip grows with it. */
function fbMorphLabel(mutate) {
const label = fbEl?.querySelector(".fb-label");
const w0 = label ? label.getBoundingClientRect().width : null;
mutate();
if (label == null || w0 == null || reducedMotion()) return;
void tick().then(() => {
const now = fbEl?.querySelector(".fb-label");
if (!now) return;
const w1 = now.getBoundingClientRect().width;
if (Math.abs(w1 - w0) > 1) {
now.animate(
[{ width: `${w0}px` }, { width: `${w1}px` }],
{ duration: 240, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
);
}
});
}
/** The ceremony's floating notes — rendered by the template while
* fbCeremony holds, positioned from the lead slot at click time. Their
* animations end holding opacity 0 (fill: both) well before the 1800ms
* teardown unmounts them, so nothing ever pops off mid-fade. */
let fbNotes = $state([]);
function toggleFollow() {
const artist = page?.artist;
if (!artist || fbCeremony) return;
if (fbShown) {
// Unfollow: an undo. The shrink still morphs; nothing celebrates.
void setFollowed(artist, false);
announceFollow(false, artist);
fbMorphLabel(() => {
fbShown = false;
fbJustLanded = false;
});
return;
}
// Persist and toast at the CLICK — the ceremony is presentation, and a
// user who navigates away mid-show must still have followed.
void setFollowed(artist, true);
announceFollow(true, artist);
if (reducedMotion()) {
fbMorphLabel(() => (fbShown = true));
return;
}
fbCeremony = true;
// One note per beat, rising from the bars: positions taken from the
// lead slot NOW, rendered by the template for as long as the ceremony
// holds. Their flights end at 1710ms (delay 760 + 950) holding opacity
// 0, so the 1800ms teardown only ever removes what has already faded —
// the mock paid twice to learn that removing a note any earlier
// cancels its animation mid-fade.
const lead = fbEl.querySelector(".fb-lead");
const lr = lead.getBoundingClientRect();
const br = fbEl.getBoundingClientRect();
const lx = lr.left - br.left + lr.width / 2;
fbNotes = [0, 1, 2].map((i) => ({
cx: lx + (i - 1) * 4,
sway: i % 2 ? -7 : 7,
delay: 240 + i * 260,
glyph: i === 1 ? "♫" : "♪",
}));
fbTimers = [
setTimeout(() => (fbStage = "bars"), 170),
setTimeout(() => {
fbJustLanded = true;
fbMorphLabel(() => {
fbStage = "idle";
fbShown = true;
});
}, 1070),
setTimeout(() => {
fbNotes = [];
fbJustLanded = false;
fbCeremony = false;
}, 1800),
];
}
// 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,
});
}
// The hero-chooser UI (Choose/Change hero pill + notice) lived in the
// glass bar until 2026-07-24; it is being re-homed elsewhere. The
// backend survives it: api.pickArtistHero / api.clearArtistHero and the
// Rust commands are intact — resurrect the old pill's handlers from git
// (defe225) when the new location lands.
</script>
<!-- data-painted: this page paints its ground floor-to-ceiling (hero image
+ the .sheet's opaque tail), so under dark glass the GlassBar keeps its
REAL backdrop-filter here — no band cut, no periscope fog. What the
contract actually needs is the BAR BAND: the gfade mask keeps the photo
full under the bar at every pose, so the bar always samples paint
(below the fade the glass page is deliberately bare pane frost now),
and the fog was replacing the designed hero-under-glass continuity
with a blurred imitation. -->
<div
class="artist-page"
data-painted
data-load-stage={pageLoadStage}
data-scroll={heroScrolled ? "1" : "0"}
data-park={extrasParked ? "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}
<!-- Loading status, OUT of the document flow on purpose.
In flow it cost ~32px above the action row, so the play button
visibly jumped up the moment the page finished resolving. Absolute
inside .artist-page (already position:relative, so this adds no new
containing block) means it can appear and vanish without moving a
single pixel of content. It must NOT live in .fade-flow with the
rest of the body: that carries a transform and sits inside the
clipped .fade-frame, which would both re-anchor and mask it. -->
<!-- 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. The seam tail carries the band behind the glass
bar; 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">
<!-- Dark-glass fade frame + counter (inert in solid mode:
two absolute inset-0 pass-through boxes, no transform,
no mask). Under glass the FRAME carries a static mask
whose band mirrors the sheet's smoothstep and rides
the sheet's own glide; the COUNTER rides the exact
inverse, keeping every photo layer visually pinned —
both transforms compositor-side, the mask never
animates. The seam-clip lives OUTSIDE the pair: the
bar's mirror must never fade. -->
<div class="gfade-frame" aria-hidden="true">
<div class="gfade-counter">
<div class="hero-fallback" style="--seed-hue: {seedHue}"></div>
{#if seedUrl}
<div
class="hero-seed"
class:hidden={heroPainted}
style="background-image: url('{seedUrl}')"
></div>
<!-- Crossfade twin: static seed mono to-pose, cross-faded
in by the composited mono-fade ride on the wrapper.
The inner div keeps the original's opacity dissolve
(.hidden), which multiplies with the wrapper's ride
exactly like the original pair. -->
<div class="seed-twin-wrap" aria-hidden="true">
<div
class="seed-mono-twin"
class:hidden={heroPainted}
style="background-image: url('{seedUrl}')"
></div>
</div>
{/if}
{#if previousBanner?.url}
<img class="banner previous" src={previousBanner.url} alt="" draggable="false" />
<div class="banner-twin-wrap" aria-hidden="true">
<canvas class="banner-mono-twin" use:bakedBlur={previousBanner.url} aria-hidden="true"></canvas>
</div>
{/if}
{#if selectedUsesGpu && banner}
<img class="banner gpu-underlay" src={banner} alt="" draggable="false" />
<div class="banner-twin-wrap" aria-hidden="true">
<canvas class="banner-mono-twin" use:bakedBlur={banner} aria-hidden="true"></canvas>
</div>
{/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}
/>
<!-- Crossfade twin of the live canvas: snapshot pixels
(filled by snapshotGpuTwins on 'ready') under a
STATIC endpoint filter on .gpu-twin — the same
composite order as the original. -->
<div
class="gpu-twin-wrap"
class:live={displayedBanner.kind === "gpu"}
aria-hidden="true"
>
<div class="gpu-twin" bind:this={gpuTwinBox}></div>
</div>
{/if}
{#if banner && !selectedUsesGpu}
{#key displayedBanner.key}
<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);
}}
/>
<!-- Wrapper carries the composited rides
(mono-fade + squeeze); the inner canvas carries
the rotation dissolve — two opacity animations
do not compose on one element, but opacity
multiplies across nesting, which IS the
original composition. -->
<div class="banner-twin-wrap" aria-hidden="true">
<canvas
class="banner-mono-twin entry"
class:initial={initialBannerPaint}
class:finish={finishBannerPaint}
use:bakedBlur={banner}
aria-hidden="true"
></canvas>
</div>
{/key}
{:else if !selectedUsesGpu}
<div class="banner base"></div>
{/if}
</div>
</div>
<!-- THE SEAM TAIL (last child on purpose: paints over every
hero layer). The clip pins at the bar's TOP line and
does not ride: the mirror inside rides with the hero,
and whatever slides past the bar's top edge is cut —
the reflection exists strictly inside the glass. -->
<div class="seam-clip" aria-hidden="true">
<div class="seam-tail mirror t2" class:painted={heroPainted}>
<!-- scaleY(-1) about center: the artwork's top row
renders at this box's bottom = the hero's top
edge, so the seam is colour-continuous by
construction at every horizontal position.
GPU heroes have no url — their frame arrives
via snapshotGpuTwins into the bound box.
Rotation dissolve mirrors the banner's own:
previous held underneath, incoming fades in on
the same hero-banner-in curve/durations. -->
{#if selectedUsesGpu}
<div class="tail-mirror-src" bind:this={tailMirrorBox}></div>
{:else if banner}
{#if previousBanner?.url}
<img class="tail-prev" src={previousBanner.url} alt="" draggable="false" />
{/if}
{#key displayedBanner.key}
<img
class="tail-cur"
class:initial={initialBannerPaint}
class:finish={finishBannerPaint}
src={banner}
alt=""
draggable="false"
decoding="async"
/>
{/key}
{/if}
</div>
</div>
</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="sheetclip"><div class="sheet"></div></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">
<!-- LEADS the row (Kyle's seat pick). The lead
slot is fixed 14x14 so the + , the bars and
the check swap inside it without the chip
ever changing size for any reason except
the word itself — and that change morphs
(fbMorphLabel), never snaps. -->
<button
class="chip fb"
class:on={fbShown}
bind:this={fbEl}
title={fbShown ? "Unfollow artist" : "Follow artist"}
aria-pressed={fbShown}
onclick={toggleFollow}
>
<span class="fb-lead">
{#if fbStage === "bars"}
<span class="lead-eq" aria-hidden="true">
<b style="--delay:0ms"></b><b style="--delay:90ms"
></b><b style="--delay:180ms"></b>
</span>
{:else if fbShown}
<span class="lead-glyph" class:lead-in={fbJustLanded}>
<Icon name="check" size={13} strokeWidth={3} />
</span>
{:else}
<span class="lead-glyph" class:lead-out={fbCeremony}>
<Icon name="plus" size={13} strokeWidth={3} />
</span>
{/if}
</span>
<span class="fb-label" class:label-in={fbJustLanded}>
{fbShown ? "Following" : "Follow"}
</span>
<!-- Template-owned, like the bars — NOT
spawned. The spawned version routed
through -global- keyframes and the
reference never resolved: the glow sat
at full opacity and the notes at zero.
Svelte's fine-grained updates leave
these siblings alone when the label
and lead swap at the check moment, so
the detach-restart trap does not
apply here either. -->
{#if fbCeremony}
<span class="fb-glow" aria-hidden="true"></span>
{#each fbNotes as n (n.delay)}
<span
class="fb-note"
aria-hidden="true"
style="--cx:{n.cx}px; --sway:{n.sway}px; --delay:{n.delay}ms"
>{n.glyph}</span>
{/each}
{/if}
</button>
{#if releasedCount > 0}
<span class="chip"><Icon name="disc" size={13} strokeWidth={3} /> {releasedCount} releases</span>
{/if}
{#if page.artist.fans}
<span class="chip"><Icon name="users" size={13} strokeWidth={3} /> {fmtFans(page.artist.fans)}</span>
{/if}
{#if activeYears}
<span class="chip" title="Years active"><Icon name="calendar" size={13} strokeWidth={3} /> {activeYears}</span>
{/if}
<!-- The library/listening chips that used to
follow (progress, disk + popover, format,
hours, last played, top track, genre) and
the follow heart were removed 2026-08-04 at
Kyle's request — the identity trio above
and the socials below are the row now. The
JS that fed them (playStats, artistDisk,
libraryOwnedCount, followed) is left in
place: it is cheap, local, and the follow
feature is still planned. -->
{#if page.socials?.length}
<!-- ONE flex item, not a run of siblings:
the whole social block travels to the
next line together instead of shedding
icons off the end one at a time. -->
<span class="social-set" class:wrapped={socialWrapped}>
<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}
strokeWidth={2.6}
fill={meta.solid ?? false}
/></a>
{/each}
</span>
{/if}
</div>
{:else}
<!-- The stat chips do not exist yet while the page is
resolving, so the load status simply occupies
their slot and hands the row over when they
arrive. Same container, same chip metrics, so
the handover costs at most the line the real
chips need and the reserve below follows it —
not the free-for-all the old in-flow paragraph
above the actions caused. (It used to promise
the height NEVER changed. That was only ever
true while the row could not wrap; a status
line is one line and a full chip set may be
two.) -->
<div class="stat-chips">
<span class="chip stage-chip" role="status" aria-live="polite">
<span class="ring" aria-hidden="true"></span>
{page.albums.length || popularTracks.length
? "Refining release details"
: "Loading songs and releases"}
</span>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- The shared frosted header (src/components/GlassBar.svelte) floats
over the seam tail's mirror at the top. It is mounted by App.svelte
as the scroll pane's first child, not here — but its -24px bleed is
still what the pin geometry below is measured against, and its net
flow height is zero either way, so this spacer's top is unmoved. -->
<!-- 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>
{/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 (trackKey(track))}
<div
class="popular-row"
class:now={nowPlayingTrack(track)}
role="button"
tabindex="0"
title="Play — streams from the best source"
onclick={() => playPopular(track)}
onkeydown={(e) => e.key === "Enter" && playPopular(track)}
>
<span class="rank">{nowPlayingTrack(track) ? "♫" : index + 1}</span>
<span class="cover-slot">
{#if track.cover && !artBroken[track.cover]}
<!-- `src` is owned by the action, not bound here: it holds the
outgoing frame until the replacement has decoded. -->
<img class="track-cover" alt="" loading="lazy" decoding="async" draggable="false" use:resolvedCover={track.cover} onerror={() => markArtBroken(track.cover)} />
{:else}
<div class="track-cover placeholder"><Icon name="disc" size={16} /></div>
{/if}
</span>
<span class="track-name">{track.title}</span>
<span class="track-album">{track.album}</span>
<span class="tcount" class:empty={!track.listeners} title={track.listeners == null ? "No Last.fm listener data for this track" : `${track.listeners.toLocaleString()} Last.fm listeners`}>
<i style={`transform:scaleX(${listenerShare(track.listeners, popularPeak) / 100})`}></i>
<span class="tcount-val">{fmtListeners(track.listeners)}</span>
</span>
<span class="tdur">{fmtDuration(track.duration_secs)}</span>
<PlaylistMenu
compact
iconSize={13}
align="right"
tracks={() => [{
title: track.title,
artist: page.artist.name,
album: track.album ?? null,
duration_secs: track.duration_secs ?? null,
trackId: track.id ?? null,
artUrl: track.cover ?? null,
}]}
/>
<button
class="mini-get"
title="Save to library"
onclick={(e) => { e.stopPropagation(); saveTrackAction(page.artist.name, track); }}
><Icon name={saved[`t${trackKey(track)}`] === "ok" ? "check" : saved[`t${trackKey(track)}`] === "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}
<!-- ADDED, not substituted: everything above and below is untouched.
The sections themselves live in ArtistExtras so their styles are
scoped away from this file — it names a .hero-bg and a .card of its
own, and so does this page, for different things. -->
<ArtistExtras {page} part="latest" />
{#if tabs.length}
<div class="disco-head">
<h2 class="disco-title">Discography</h2>
<!-- The whole catalogue on a page of its own. The tabs it shows
are the ones derived just above, handed over rather than
recomputed. -->
<button
class="disco-all"
onclick={() => openCatalogDiscography(page.artist.id, page.artist)}
>
Show all <Icon name="chevron-right" size={14} />
</button>
</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>
<!-- Both panels are alive through a same-row switch:
the outgoing one is taken out of flow by
panelLeave and the stage's height eases between
them, so the box never collapses. -->
<div class="xp-stage">
{#key openFamilyKey}
<div class="xp-panel" use:staggerIn in:panelEnter out:panelLeave>
<div class="xp-body">
<div class="xp-rail">
{#if previousRailArtAlbum?.cover && !artBroken[previousRailArtAlbum.cover]}
<img class="rail-glow rail-art-outgoing" src={railCover(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={railCover(railArtAlbum.cover)} alt="" aria-hidden="true" draggable="false" />
{/if}
{/key}
<div class="rail-shade"></div>
<!-- The artwork is a button: it opens the
shared full-window viewer, the same one the
Now Playing cover opens. -->
<button
class="rail-art-stage"
title="View artwork"
aria-label="View artwork full screen"
onclick={() => openRailArtwork()}
>
{#if previousRailArtAlbum?.cover && !artBroken[previousRailArtAlbum.cover]}
<img class="rail-art rail-art-outgoing" src={railCover(previousRailArtAlbum.cover)} alt="" draggable="false" />
{/if}
{#key railArtTransitionKey}
{#if railArtAlbum?.cover && !artBroken[railArtAlbum.cover]}
<!-- gridCover, not the raw upgrade: the upgraded URL is the
MASTER (3000x3000 on Apple) and this slot is 209px, so the
panel was pulling 1.9-3.2MB to fill a thumbnail — measured
150-231ms of fetch on a fast link, and the delay the user
saw on opening. gridCover is the SAME 500x500 the card in
the grid already painted, so it is a cache hit and appears
immediately. Still oversampled for a 209px box at 2x DPR. -->
<img class="rail-art" class:rail-art-incoming={previousRailArtAlbumId != null} src={railCover(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}
<span class="rail-art-mark" aria-hidden="true"><Icon name="expand" size={18} /></span>
</button>
<!-- and the name opens the record's own page -->
<h3 class="rail-title">
<button
class="rail-title-link"
title="Open album"
onclick={() => openAlbumId != null
&& openCatalogAlbum(openAlbumId, {
seed: albumSeed(railArtAlbum ?? openAlbum),
})}
>{openAlbum?.title ?? openFamily?.baseTitle ?? ""}</button>
</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 openAlbumPlays != null}
<div
class="rail-stats"
in:partIn={{ delay: 45 }}
title={openTotalsSummed
? "Summed from this tracklist — Last.fm has no entry for this edition"
: "Last.fm album totals"}
>
<span class="rail-stat"><b>{fmtCompact(openAlbumPlays)}</b><i>plays</i></span>
{#if openAlbumListeners != null}
<span class="rail-stat-div"></span>
<span class="rail-stat"><b>{fmtCompact(openAlbumListeners)}</b><i>listeners</i></span>
{/if}
{#if openTotalsSummed}<span class="rail-stat-note">summed</span>{/if}
</div>
{/if}
{#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}
<!-- The description's own first
sentence, promoted. Same
splitter the album page and
the Now Playing panel use;
nothing is written. The body
keeps its 4-line clamp and
its mask fade. -->
{#if railDesc.lede}
<p class="rail-lede" use:partAnim={{ delay: 55 }}>{railDesc.lede}</p>
{/if}
{#if railDesc.rest}
<p class="rail-desc" class:clamped={!descExpanded} use:partAnim={{ delay: 60 }}>{railDesc.rest}</p>
<button
class="rail-more"
class:open={descExpanded}
use:partAnim={{ delay: 85 }}
onclick={() => (descExpanded = !descExpanded)}
>{descExpanded ? "Show less" : "Show more"} <Icon name="chevron-down" size={13} /></button>
{/if}
{#if openInfo.genre || openInfo.label}
<div class="rail-pills" use:partAnim={{ delay: 100 }}>
{#if openInfo.genre}<span class="gpill accent">{openInfo.genre}</span>{/if}
{#if openInfo.label}<span class="gpill">{openInfo.label}</span>{/if}
</div>
{/if}
{/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]}
<!-- 32px on screen; the thumb the grid already has is
plenty and, again, already cached. -->
<img class="rail-ed-art" src={tinyCover(coverUpgrades[edition.cover] ?? edition.cover) ?? gridCover(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}
<!-- Nothing at all until the load
has outlasted LOADING_GRACE:
a warm open resolves in tens
of ms and used to flash a
line of text on its way past. -->
{#if loadingVisible}
<div class="tracks-loading" role="status" aria-busy="true">
<div class="tl-head">
<span class="tl-ring" aria-hidden="true"></span> Loading tracks
</div>
{#each skeletonRows as w, i (i)}
<div class="tl-row" aria-hidden="true">
<span class="tl-bar tl-num"></span>
<span class="tl-bar tl-title" style="width: {w}%"></span>
<span class="tl-bar tl-dur"></span>
</div>
{/each}
</div>
{/if}
{:else}
{#if openTracks.length}
<div class="tl-cols" class:has-count={countPeak > 0} in:partIn={{ delay: 30 }}>
<span class="tno">#</span>
<span>Title</span>
{#if countPeak > 0}<span class="tcount-head">Listeners</span>{/if}
<span class="tdur-head"><Icon name="clock" size={13} /></span>
</div>
{/if}
{#each openDiscs as d (d.disc)}
{#if multiDisc}
<div class="disc-head" in:partIn={{ delay: 40 }}>Disc {d.disc} · {d.tracks.length} tracks</div>
{/if}
{#each d.tracks as t, ti (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:has-count={countPeak > 0}
in:partIn={{ delay: Math.min(ti * 16, 180) }}
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 countPeak > 0}
<span class="tcount" class:empty={!t.listeners} title={t.listeners == null ? "No Last.fm listener count for this track" : `${t.listeners.toLocaleString()} Last.fm listeners`}>
<i style={`transform:scaleX(${countShare(t) / 100})`}></i>
<span class="tcount-val">{fmtCount(t.listeners)}</span>
</span>
{/if}
{#if locked}
<span class="tag-unreleased">Unreleased</span>
{:else}
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
{/if}
{#if !locked}
<PlaylistMenu
compact
iconSize={13}
align="right"
tracks={() => [{
title: t.title,
artist: page.artist.name,
album: openAlbum?.title ?? null,
duration_secs: t.duration_secs ?? null,
trackId: t.id ?? null,
artUrl: t.cover ?? openAlbum?.cover ?? null,
}]}
/>
<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>
</div>
{/if}
{/each}
</div>
{/each}
<!-- ADDED after the release grid: releases-by-year, fans also like,
what you own, and about. Nothing above was moved to fit them. -->
<ArtistExtras {page} />
</div>
</div>
</div>
</div>
{/if}
</div>
<style>
.hint {
color: var(--text-secondary);
padding-top: 10vh;
text-align: center;
}
/* 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;
/* The glass bar cancels the pane's top padding so this page's box
starts flush with the top of the pane — that is what lets the hero
run UNDER the bar, and the `.hero-bg` stack is positioned against
this box. The inset the pane would have given us comes back here, so
the flow content below the hero is unmoved. */
padding-top: var(--pane-pad);
/* 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 MEASURED resting height */
--row-min: 76px; /* and its one-line floor, which must stay a constant */
/* Where the row's FIRST line sits. Derived so a one-line row lands
exactly where centring used to put it at either breakpoint:
--row-min 76 gives 13px, --row-min 66 gives 8px. */
--row-pad-top: calc((var(--row-min) - 66px) / 2 + 8px);
--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 the glass bar's own -24px margin pulls it 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. */
/* THE SHEET'S CLIP BOX — the reason the pane's scroll range equals its
content. The sheet is 300vh of absolutely-positioned decoration, and an
abs-positioned descendant extends its scroller's SCROLLABLE OVERFLOW:
measured, .content's scrollHeight tracked the sheet's box (pin flow
origin + the sheet's live translate + 300vh) instead of the page, so the
pane scrolled ~850px past the last content pixel — at max scroll ZERO of
11 release rows were on screen, the whole viewport empty page. Worse, the
sheet's translate is scroll-driven, so the range BREATHED with scroll
position and depended on scroll history (same scrollTop measured 4167,
4329 and 4368 by route; hidden .sheet gave exactly 1797 = content).
`overflow: clip` here stops that propagation at the clip box's own
geometry — the region the pane can actually show — so the sheet paints
identically and contributes nothing.
ABSOLUTE, and the pin stays height:0: sizing the PIN itself and cancelling
with margin-bottom:-100% leaves +24px behind (the negative margin
collapses against the neighbouring height-0 pins and walks the hero zone
24 → 48). Out of flow, there is no margin to collapse and the sticky
constraint is untouched.
The box IS the pane's client box: --pin-origin up to undo the pane's
padding-top (the pins stick that far below the pane's visible top), and
one pane tall. --frame-h is measure()'s scroller.clientHeight; the 100vh
fallback only covers the pre-measure first paint, where a too-tall clip
is harmless (it just clips less). */
.sheetclip {
position: absolute;
top: calc(-1 * var(--pin-origin));
/* The sheet's full-bleed lives here now — the clip edge has to sit on
the pane's padding edge, or it would shave the bleed off instead. */
left: -28px;
right: -28px;
height: var(--frame-h, 100vh);
overflow: clip;
pointer-events: none;
}
.sheet {
position: absolute;
/* In clip-box coordinates. --band-from-pin is pin-relative and the box
sits --pin-origin above the pin, so the two terms cancel to the
band's own offset: --band-from-pin + --pin-origin ≡ --band-top. */
top: var(--band-top);
left: 0;
right: 0;
/* Far past any scrollport bottom; the gradient's tail is opaque page
colour, so extra height is just more page. Free now — the clip box
keeps it out of the scrollable overflow however tall it is. */
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. The transform itself lives per-branch
below: the fallback keeps the live p² calc, the scroll-driven
branch a static rest pose plus the sheet-glide-ride animation
easing the identical curve. */
/* 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%
);
}
/* ── DARK GLASS: the hero fades INTO the frost ─────────────────────
The burial moves from the sheet's paint to the photo's own alpha.
The .gfade-frame (wrapping every photo layer; seam-clip excluded)
carries a STATIC mask whose band is the sheet's exact smoothstep
INVERTED (mask alpha ≡ 1 − sheet alpha), parked at var(--fog-rest)
— which IS the band's hero-local rest offset: the sheetclip sits at
−pin-origin, the sheet at --band-top, and hero-bg at
--band-from-pin = --band-top − pin-origin, so the offsets cancel to
fog-rest exactly. The frame rides the sheet's glide (same sampler,
same range) carrying the mask; the counter rides the inverse so the
photo stays pinned. Photo-visible-fraction across the band therefore
equals the base fade at every pixel BY CONSTRUCTION — the only
intended difference is what the fade lands ON: the sheet paints
nothing under glass (the photo fades itself), so the photo dissolves
straight into the pane frost, the app-wide ground. Above the
band the mask is solid — the photo stays full under the bar at every
pose, so the data-painted contract holds with no stand-in strip.
Pre-measure, an unset --fog-rest resolves the mask solid via the
999999px sentinel: a no-op, base look. */
/* The frame/counter are IN-FLOW pass-throughs, deliberately not
positioned: the in-flow banner img is what gives .hero-bg its
height, and an absolute wrapper collapses that chain to zero (the
whole hero then clips away — measured the hard way). Their boxes
coincide with hero-bg's content box, so when the glass transform
makes the frame a containing block, absolute hero layers re-anchor
to an identical box. No rule needed in solid mode at all. */
:global(:root.glass) .gfade-frame {
mask-image: linear-gradient(
to bottom,
#000 0,
#000 var(--fog-rest, 999999px),
rgba(0, 0, 0, 0.981) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.08),
rgba(0, 0, 0, 0.919) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.17),
rgba(0, 0, 0, 0.844) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.25),
rgba(0, 0, 0, 0.741) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.33),
rgba(0, 0, 0, 0.603) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.42),
rgba(0, 0, 0, 0.5) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.5),
rgba(0, 0, 0, 0.397) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.58),
rgba(0, 0, 0, 0.259) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.67),
rgba(0, 0, 0, 0.156) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.75),
rgba(0, 0, 0, 0.081) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.83),
rgba(0, 0, 0, 0.019) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.92),
transparent calc(var(--fog-rest, 999999px) + var(--fog-span, 170px)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
#000 0,
#000 var(--fog-rest, 999999px),
rgba(0, 0, 0, 0.981) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.08),
rgba(0, 0, 0, 0.919) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.17),
rgba(0, 0, 0, 0.844) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.25),
rgba(0, 0, 0, 0.741) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.33),
rgba(0, 0, 0, 0.603) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.42),
rgba(0, 0, 0, 0.5) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.5),
rgba(0, 0, 0, 0.397) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.58),
rgba(0, 0, 0, 0.259) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.67),
rgba(0, 0, 0, 0.156) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.75),
rgba(0, 0, 0, 0.081) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.83),
rgba(0, 0, 0, 0.019) calc(var(--fog-rest, 999999px) + var(--fog-span, 170px) * 0.92),
transparent calc(var(--fog-rest, 999999px) + var(--fog-span, 170px)),
transparent 100%
);
}
/* Under glass the sheet no longer buries the photo (the mask above
does) — the whole pin subtree is REMOVED from rendering: the fade
lands directly on the pane frost (.content 0.35 over the .app canvas
0.55), the one ground every other page sits on. The first cut gave
the sheet a rgba(18,18,18,0.5) tail instead, and that read as two
defects at once (Kyle, 2026-08-26): the 15%-of-span onset ramp was a
visible line under the hero, and the stacked tint made the whole
floor darker and grayer than the rest of the app (net ~0.85 gray vs
the app-wide ~0.71 black — profile-rows measured the step). display
on the PIN, not background on the sheet: a merely-unpainted sheet
still kept its 300vh box composited and riding sheet-glide-ride (or
the fallback p² calc) on every scrolled frame for nothing. Nothing
reads this subtree from JS (audited exhaustively — every 'sheet' in
the script is a comment) and the pins are height:0 marginless boxes,
so removing the box moves no flow and shrinks no scroll range the
sheetclip hadn't already fenced. Solid mode keeps the original
gradient and glide untouched. */
:global(:root.glass) .sheetpin {
display: none;
}
/* 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)
);
}
/* THE DISPLACED --p RIDERS. Every declaration below is the original
per-frame mechanism, verbatim, under its original selector: on
this path apply() still writes --p and --extra-hit on every p
change exactly as it always did, and these calcs consume them.
The design rationale for each stays with its element's base rule;
the scroll-driven equivalents live in the second @supports block
below. */
.sheet {
transform: translateY(calc(
var(--fog-rest)
+ var(--p) * var(--p)
* (var(--strip) - var(--fog-above) - var(--fog-rest))
));
}
/* Dark glass on this path: the fade frame/counter track the sheet's
p² delta with the same live calc (delta only — the frame's rest
pose is 0, its mask already parks at --fog-rest). */
:global(:root.glass) .gfade-frame {
transform: translateY(calc(
var(--p, 0) * var(--p, 0)
* (var(--strip) - var(--fog-above) - var(--fog-rest))
));
}
:global(:root.glass) .gfade-counter {
transform: translateY(calc(
-1 * var(--p, 0) * var(--p, 0)
* (var(--strip) - var(--fog-above) - var(--fog-rest))
));
}
.artist-page[data-scroll="1"] .banner,
.artist-page[data-scroll="1"] :global(.gpu-hero) {
filter: var(--mono);
transform: translateY(calc(-0.22 * (var(--hero-span) - var(--edge))));
}
/* The seam tail follows the banner's squeeze on this path too —
same calc, no mono (see the tail's design comment). */
.artist-page[data-scroll="1"] .seam-tail {
transform: translateY(calc(-0.22 * (var(--hero-span) - var(--edge))));
}
.artist-page[data-scroll="1"] .hero-fallback {
filter: grayscale(var(--p)) brightness(calc(1 - var(--p) * 0.34));
}
.artist-page[data-scroll="1"] .hero-seed {
filter: blur(46px) saturate(1.3) var(--mono);
}
.artist-page[data-scroll="1"] .logo-slot {
transform: scale(calc(1 - var(--p) * (1 - var(--mark-end))));
}
/* The social block used to fade here (1 - 2.4p) and leave at the park
boundary. It STAYS now — Kyle's call, 2026-08-04: the stat chips
around it went instead, and the socials ride the parked row exactly
as they sit at rest. */
.preview-count {
opacity: calc(1 - var(--p) * 2.4);
}
}
/* THE SCROLL-DRIVEN CONVERSION (Chromium 115+). Every --p rider above
the fallback branch becomes a scroll-driven animation here, and
apply() stops writing --p entirely — the per-frame custom-property
invalidation fan-out across the whole .artist-page subtree is gone;
only the handful of animated elements restyle.
The timeline is NAMED (--artist-pane), set by heroScroll() on the
exact pane apply() reads scrollTop from. scroll(nearest) is a trap
for everything inside the hero: .hero-bg { overflow: hidden } is a
scroll container per css-overflow, so nearest-lookup from the hero
layers would bind to a never-scrolling box and freeze them at their
from pose. (.fade-flow alone keeps scroll(nearest block) — its
ancestor chain has no scroll container in the way, and the fade trio
is its own sealed mechanism.)
Every ride shares: duration auto, linear easing, fill-mode both,
range 0px..--travel. --travel is the p denominator, so animation
progress ≡ p at every scrollTop, clamped [0,1] by fill-both exactly
as apply() clamps p — value-exact to the 0.1px quantum the
measure()-written constants use (toFixed(1); ≤~0.06px pose
deviation), not an approximation. Where the
easing is NOT linear it is a linear() sampler of the exact original
curve (error bound ≤ |f''|·h²/8 = 0.000625 of the pose delta for the
sheet's p², ~0.25px on a 400px ride — sub-pixel; the glyph samplers
are under 0.01px).
If the named timeline ever fails to attach, an animation bound to a
nonexistent timeline has an UNRESOLVED current time and applies NO
effect at all — fill-both from-poses do NOT apply. Rest parity then
holds only because every base declaration in this branch IS the rest
pose; the .sheet's static translateY below is load-bearing for that,
not belt-and-braces.
Compositing: transform- and opacity-only rides composite fully.
Blink refuses compositor filter animations whose keyframes contain
pixel-moving functions, which is why this branch runs NO blur-
bearing filter ride at all: the mono look comes from the crossfade
twins (static endpoint filters faded in by a composited opacity
ride), and the squeeze stays its own separate animation so the
rigid translation stays compositor-side. The var()-bearing
transform keyframes are also subject to Blink's var()-in-keyframes
compositor limitations — the CDP pass must confirm they actually
run composited and that measure() rewrites of
--ride/--fog-ride/--mark-end/--row-slide re-target them live. */
@supports (animation-timeline: scroll()) {
/* The squeeze. The mono ramp is NOT here: the originals stay
unfiltered (no main-thread blur filter ride) and the crossfade
twins below carry the monochrome look, faded in by a composited
opacity ride. Gate preserved: when [data-scroll] flips at p>0
the timeline places the animation at the correct offset
instantly, continuous across the flip, same as the calc it
replaces. */
.artist-page[data-scroll="1"] .banner,
.artist-page[data-scroll="1"] :global(.gpu-hero) {
animation-name: hero-squeeze-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* The blurred load seed and its mono twin ride with the photograph.
They were left out of the rule above because hero-squeeze-ride sets
`transform` outright and would drop the scale(1.06) they are built
on; hero-seed-ride is the same translate composed AHEAD of that
scale, so the offset stays in unscaled pixels and matches the
banner's exactly.
Without it the seed held its rest position while the hero rode up —
measured 132px adrift at scrollTop 600 — so whenever the seed was on
screen at a non-zero scroll the blur sat off the photograph. It
appeared to "heal itself" only because a new hero hides the seed. */
.artist-page[data-scroll="1"] .hero-seed,
.artist-page[data-scroll="1"] .seed-mono-twin {
animation-name: hero-seed-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* The seam tail is glued to the banner's edge: SAME squeeze ride,
SAME gate, so they attach on the same flip and move as one. (No
mono ride — the tail is the artwork's own colour by design and
fades out under the frost before the mono ramp matters.) */
.artist-page[data-scroll="1"] .seam-tail {
animation-name: hero-squeeze-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* The rotation dissolve must SURVIVE the ride rules: .banner.current's
`animation: hero-banner-in ...` shorthand sets the animation-name
longhand too, and whichever rule loses the cascade loses its
animations entirely — the gated ride rule above outranks it, which
would (a) hard-cut a mid-scroll banner swap, (b) cancel a running
fade on the first scrolled pixel and, worst, (c) RESTART a fresh
1.4s fade from opacity 0 on every bounce off scrollTop 0 when the
gate reverts the name. So while scrolled, the dissolve and the
rides ride ONE combined list (names are matched by list position
and name across recalcs, so neither direction of the data-scroll
flip restarts anything; the single animation-range value repeats
across the list and is ignored by the document-timeline entry). */
.artist-page[data-scroll="1"] .banner.current {
animation-name: hero-squeeze-ride, hero-banner-in;
animation-duration: auto, 1.4s;
animation-timing-function: linear, ease;
animation-fill-mode: both, backwards;
animation-timeline: --artist-pane, auto;
animation-range: 0px var(--travel, 999999px);
}
/* Gated variants of the .initial/.finish duration overrides: the
base single-value versions further down would otherwise clobber
the whole multi-animation list's durations while scrolled. */
.artist-page[data-scroll="1"] .banner.current.initial {
animation-duration: auto, 180ms;
}
.artist-page[data-scroll="1"] .banner.current.finish {
animation-duration: auto, 650ms;
}
/* Same story for the reduced-motion 1ms overrides. */
@media (prefers-reduced-motion: reduce) {
.artist-page[data-scroll="1"] .banner.current,
.artist-page[data-scroll="1"] .banner.current.initial,
.artist-page[data-scroll="1"] .banner.current.finish {
animation-duration: auto, 1ms;
}
}
/* Accent ground: no pixel-moving function in the list, so this one
DOES composite. */
.artist-page[data-scroll="1"] .hero-fallback {
animation-name: ground-mono-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* (.hero-seed's MONO look rides nothing here: that is the seed twin's
crossfade below, exactly like the banner's. Its squeeze ride is the
rule above — see hero-seed-ride.) */
/* The sheet, ungated like today's calc — it must track mid-scroll
even under main-thread stall. The static transform is the REST
pose and it is load-bearing: if the timeline never attaches, this
is what keeps rest parity (see the block comment above). The
easing is the exact p² glide, sampled at 5% steps — 21 points of
t², exact at every sample. */
.sheet {
transform: translateY(var(--fog-rest));
animation-name: sheet-glide-ride;
animation-duration: auto;
animation-timing-function: linear(
0, 0.0025 5%, 0.01 10%, 0.0225 15%, 0.04 20%, 0.0625 25%,
0.09 30%, 0.1225 35%, 0.16 40%, 0.2025 45%, 0.25 50%,
0.3025 55%, 0.36 60%, 0.4225 65%, 0.49 70%, 0.5625 75%,
0.64 80%, 0.7225 85%, 0.81 90%, 0.9025 95%, 1
);
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* Dark glass: the fade frame rides the sheet's glide (same p²
sampler, same range — the two must never disagree by a pixel)
carrying its static mask; the counter rides the exact inverse so
the photo layers stay pinned. Both are pure compositor
transforms; the mask itself never animates. */
:global(:root.glass) .gfade-frame {
animation-name: gfade-frame-ride;
animation-duration: auto;
animation-timing-function: linear(
0, 0.0025 5%, 0.01 10%, 0.0225 15%, 0.04 20%, 0.0625 25%,
0.09 30%, 0.1225 35%, 0.16 40%, 0.2025 45%, 0.25 50%,
0.3025 55%, 0.36 60%, 0.4225 65%, 0.49 70%, 0.5625 75%,
0.64 80%, 0.7225 85%, 0.81 90%, 0.9025 95%, 1
);
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
:global(:root.glass) .gfade-counter {
animation-name: gfade-counter-ride;
animation-duration: auto;
animation-timing-function: linear(
0, 0.0025 5%, 0.01 10%, 0.0225 15%, 0.04 20%, 0.0625 25%,
0.09 30%, 0.1225 35%, 0.16 40%, 0.2025 45%, 0.25 50%,
0.3025 55%, 0.36 60%, 0.4225 65%, 0.49 70%, 0.5625 75%,
0.64 80%, 0.7225 85%, 0.81 90%, 0.9025 95%, 1
);
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* The identity block, ungated for the same reason. Today its top
sheds p·travel via --edge (a per-frame LAYOUT of the whole idpin
subtree); translateY(-p·travel) is the identical rigid motion of
the identical subtree, composited. The fill-both identity
transform at rest is the .fade-flow precedent. */
.idblock {
animation-name: idblock-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* The wordmark's park scale: 1 - p·(1 - mark-end) is linear in p
between 1 and mark-end, so linear easing reproduces the calc
exactly (the var() re-resolves when measure() rewrites
--mark-end). transform-origin stays on the base gated rule — it
serves both branches. */
.artist-page[data-scroll="1"] .logo-slot {
animation-name: mark-park-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* The social block's scroll-driven fade lived here, over --fade-span
(still written by measure() — the badge below rides the same
window). Removed 2026-08-04: the socials now stay through the park,
as they sit at rest. */
/* The badge: same fade window, plus a counter-scale — the parent
.hero-preview now shrinks by transform, which the badge (16px at
every p today) would inherit. The linear counter-ramp holds its
size to ~0.1px through the visible window; its -3/-5px corner
offsets do drift ≤0.7px with the parent's transform (they are
exactly constant today) — accepted: invisible, and the badge is
gone past p = 0.4167 anyway. */
.preview-count {
transform-origin: center;
animation-name: badge-fade-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--fade-span, 999999px);
}
/* Play: the real width shrink is frozen at 50px here (--p stays 0),
and the button shrinks by transform instead. Centre-origin scale
plus a compensating translate pins the left edge — mathematically
identical to a left-center-origin scale, but it keeps the default
centre origin so the hover `scale: 1.04` pops symmetrically on
both branches. Visual width = 50·(1 - 0.44p) = 50 - 22p, the
layout shrink exactly; hit-testing follows transforms, so the
click target matches the visual as today. Known deviations, by
design decision if they read wrong: the box-shadow scales with
the button, and the 1px border reads thinner parked. */
.hero-play {
animation-name: play-shrink-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* The glyph is NOT proportional to its button (21→15 vs 50→28), so
it rides its own counter-scale g(p) = (21-6p)/(21·(1-0.44p)),
sampled exactly at 5% steps — net rendered glyph = 21 - 6p px at
every sample (a plain linear ramp would be ~0.6px wrong
mid-ride). Default centre origin: the glyph is grid-centred and
must stay on its centre. */
.hero-play :global(.icon) {
animation-name: play-glyph-ride;
animation-duration: auto;
animation-timing-function: linear(
0, 0.0286 5%, 0.0586 10%, 0.0899 15%, 0.1228 20%,
0.1573 25%, 0.1935 30%, 0.2317 35%, 0.2718 40%,
0.3142 45%, 0.359 50%, 0.4063 55%, 0.4565 60%,
0.5098 65%, 0.5665 70%, 0.6269 75%, 0.6914 80%,
0.7604 85%, 0.8344 90%, 0.9141 95%, 1
);
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* Preview: frozen at 40px, shrunk by transform on the same recipe,
plus the row-slide share its keyframes carry (Play's frozen box
no longer pulls it leftward — see the keyframes). Its former
backdrop-filter was removed with the chips' (2026-07-24 evidence
pack), which also retired the backdrop-under-transform Chromium
soft spot this ride would otherwise have to dodge. */
.hero-preview {
animation-name: preview-shrink-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* Same counter-scale story as the Play glyph: g(p) =
(18-4p)/(18·(1-0.325p)), net glyph = 18 - 4p px at every sample.
Dead-centred in its button, so this one is horizontally exact. */
.hero-preview :global(.icon) {
animation-name: preview-glyph-ride;
animation-duration: auto;
animation-timing-function: linear(
0, 0.0343 5%, 0.0698 10%, 0.1064 15%, 0.1444 20%,
0.1837 25%, 0.2244 30%, 0.2666 35%, 0.3103 40%,
0.3558 45%, 0.403 50%, 0.4521 55%, 0.5031 60%,
0.5563 65%, 0.6117 70%, 0.6694 75%, 0.7297 80%,
0.7927 85%, 0.8587 90%, 0.9277 95%, 1
);
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* The chips row's slide — a NEW consumer: today this motion is
supplied implicitly by the flex relayout as both buttons ahead
of it shrink their real widths (22p + 13p px, constant 16px
gaps, left-aligned row). With frozen button widths the chips
would stand still; the transform reproduces the slide rigidly,
composited. --row-slide is presence-aware (measure()), so a
missing preview button cannot leave a phantom 13px. Chip hover
popovers are absolutely positioned inside the row and ride
along. Nothing is needed on the fallback path — flex relayout
still supplies the motion there. */
.stat-chips {
animation-name: chips-slide-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
/* ================= THE CROSSFADE TWINS ===========================
User-tuned constants (mono endpoints, the 46px seed blur, the
8px banner bake, the 0.22 squeeze) appear verbatim — nothing is
retuned, only re-plumbed.
Wrappers carry ONLY composited rides
(mono-fade opacity; plus the squeeze for banner/gpu twins, the
same wrapper-level squeeze the originals ride); inner elements
carry the STATIC endpoint filter of the ride they replace plus
whatever entry/dissolve their original runs — opacity multiplies
across the nesting, so dissolve × crossfade composes exactly
like the original animation pair. Base opacity 0 (top level,
with the twins' static geometry — see the twin base rules after
this @supports block) is the timeline-failure safety: an
animation bound to a missing timeline applies NOTHING
(fill-both does not hold), and the twins must then disappear
rather than sit at full mono over the resting page. Twins are
ungated (no [data-scroll]): their rides are pure composited
opacity/transform, and at p = 0 the fade holds them at
exactly 0. */
.artist-page .seed-twin-wrap,
.artist-page .banner-twin-wrap,
.artist-page .gpu-twin-wrap {
animation-name: mono-fade-ride;
animation-duration: auto;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
.artist-page .banner-twin-wrap,
.artist-page .gpu-twin-wrap {
animation-name: mono-fade-ride, hero-squeeze-ride;
animation-duration: auto, auto;
animation-timing-function: linear, linear;
animation-fill-mode: both, both;
animation-timeline: --artist-pane, --artist-pane;
animation-range: 0px var(--travel, 999999px);
}
.artist-page .seed-mono-twin {
position: absolute;
inset: -12% -8%;
background-position: center 32%;
background-repeat: no-repeat;
background-size: cover;
transform: scale(1.06);
/* The seed's mono to-pose, verbatim. */
filter: blur(46px) saturate(1.3) grayscale(1) contrast(1.14)
brightness(0.66) blur(8px);
opacity: 1;
visibility: visible;
transition: opacity 520ms ease, visibility 0s linear;
}
.artist-page .seed-mono-twin.hidden {
opacity: 0;
visibility: hidden;
transition: opacity 520ms ease, visibility 0s linear 520ms;
}
.artist-page .banner-mono-twin {
position: absolute;
top: 70px;
left: 0;
display: block;
width: 100%;
height: auto;
max-height: 78vh;
object-fit: cover;
object-position: center top;
/* Point-op mono components only — the blur is BAKED into the
canvas copy with clamped (edge-replicated) sampling, so the
copy stays pixel-aligned with the original (no scale hacks,
no ghosting in the crossfade) and has no transparent fringe
at the pane edges. grayscale/contrast/brightness are
per-pixel operations: they cannot bleed. */
filter: grayscale(1) contrast(1.14) brightness(0.66);
}
.artist-page .banner-mono-twin.entry {
animation: hero-banner-in 1.4s ease backwards;
}
.artist-page .banner-mono-twin.entry.initial {
animation-duration: 180ms;
}
.artist-page .banner-mono-twin.entry.finish {
animation-duration: 650ms;
}
.artist-page .gpu-twin {
position: absolute;
inset: 0;
/* Point-op mono only at the wrapper — the 8px blur is baked
into the gt-banner snapshot with clamped edges (no fringe,
no alignment hacks). Point ops cannot bleed at edges. */
filter: grayscale(1) contrast(1.14) brightness(0.66);
}
/* The gpu twin's snapshot canvas — the .gpu-hero's internal banner
geometry, verbatim (the canvas is created by snapshotGpuTwins,
hence :global). */
.artist-page .gpu-twin :global(.gt-banner) {
position: absolute;
top: 70px;
left: 0;
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
max-height: 78vh;
z-index: 1;
}
/* Rotation dissolve for the mono overlay — the incoming baked
frame fades in over the outgoing one on the banner's own curve,
in lockstep with the real hero and the tail mirror (same
pattern as .tail-mirror-frame.incoming). */
.artist-page .gpu-twin :global(.gt-banner.incoming) {
animation: hero-banner-in 1.4s ease backwards;
}
/* Reduced-motion parity for the twins' entry dissolves (the stock
1ms rule reaches only .banner). */
@media (prefers-reduced-motion: reduce) {
.artist-page .banner-mono-twin.entry,
.artist-page .banner-mono-twin.entry.initial,
.artist-page .banner-mono-twin.entry.finish {
animation-duration: 1ms;
}
.artist-page .seed-mono-twin,
.artist-page .seed-mono-twin.hidden {
transition-duration: 1ms;
}
}
}
/* Twin base geometry + hidden-at-rest, at top level (NOT inside the
@supports gate) on purpose: the twin DOM always renders, and on an
engine without scroll-driven animations these keep the twins out of
flow and invisible — the fallback branch keeps the --p filter rides
on the originals instead and never shows a twin. */
.seed-twin-wrap,
.banner-twin-wrap,
.gpu-twin-wrap {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.seed-twin-wrap {
z-index: -1;
}
.banner-twin-wrap,
.gpu-twin-wrap {
z-index: 1;
}
.gpu-twin-wrap:not(.live) {
display: none;
}
/* The rides' keyframes, at top level. NAMING: the one referenced from
the mixed scoped/:global .banner + :global(.gpu-hero) rule carries
the -global- prefix — Svelte strips it and leaves the matching
animation-name values un-scoped, so the compiled name and the
keyframes name cannot diverge. (With svelte 5.56.4 plain names would
also compile — the rewrite covers :global-containing rules — so the
prefix is a namespace choice; the glyph keyframes below use plain
scoped names on the fade-flow-ride precedent.) Every from-pose is
the identity/rest value, so the fill-both hold at scrollTop 0 is
pixel-identical to today's rest page. */
@keyframes -global-hero-squeeze-ride {
from {
transform: translateY(0);
}
to {
/* -0.22 · travel; linear easing gives -0.22·p·travel at every
scrollTop — identical to the --edge calc. The 0px sentinel
(NOT --travel) keeps degenerate pages motionless. */
transform: translateY(calc(-0.22 * var(--ride, 0px)));
}
}
/* hero-squeeze-ride for the layers that carry their own scale. Translate
FIRST, so it is applied in unscaled space and the seed moves the same
number of screen pixels as the banner rather than 1.06x that. */
@keyframes -global-hero-seed-ride {
from {
transform: translateY(0) scale(1.06);
}
to {
transform: translateY(calc(-0.22 * var(--ride, 0px))) scale(1.06);
}
}
@keyframes ground-mono-ride {
from {
filter: grayscale(0) brightness(1);
}
to {
filter: grayscale(1) brightness(0.66);
}
}
@keyframes sheet-glide-ride {
from {
transform: translateY(var(--fog-rest));
}
to {
/* ≡ translateY(strip - fog-above): the same two exact endpoints
as the fallback calc (--fog-ride from measure()). */
transform: translateY(calc(var(--fog-rest) + var(--fog-ride, 0px)));
}
}
/* Dark glass: the fade frame carries the sheet's exact delta (its rest
pose is 0 — the mask parks at --fog-rest in its own gradient), the
counter the exact inverse. Same endpoints as sheet-glide-ride. */
@keyframes gfade-frame-ride {
from {
transform: translateY(0);
}
to {
transform: translateY(var(--fog-ride, 0px));
}
}
@keyframes gfade-counter-ride {
from {
transform: translateY(0);
}
to {
transform: translateY(calc(-1 * var(--fog-ride, 0px)));
}
}
@keyframes idblock-ride {
from {
transform: translateY(0);
}
to {
transform: translateY(calc(-1 * var(--ride, 0px)));
}
}
@keyframes mark-park-ride {
from {
transform: scale(1);
}
to {
transform: scale(var(--mark-end, 1));
}
}
/* The twins' crossfade — the ride that stands in for the retired mono
filter rides: linear over the same 0..--travel range, so twin
opacity ≡ p. */
@keyframes mono-fade-ride {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes badge-fade-ride {
from {
opacity: 1;
transform: scale(1);
}
to {
/* Inverse of the parent's scale at the fade end (p = 1/2.4):
1 / (1 - 0.325/2.4) = 1.1566 — holds the badge at 16px to
~0.1px through its visible window. */
opacity: 0;
transform: scale(1.1566);
}
}
@keyframes play-shrink-ride {
from {
transform: translateX(0) scale(1);
}
to {
/* 0.56 = (50 - 22)/50 — update if --play-shrink changes. The
centre-origin scale sheds 11px each side; the -11px translate
pins the left edge (the row is left-aligned flex, so the real
shrink's left edge never moves; align-items: center holds the
vertical centre for free). */
transform: translateX(calc(-1 * var(--play-shrink) / 2)) scale(0.56);
}
}
@keyframes play-glyph-ride {
from {
transform: scale(1);
}
to {
/* g(1) = 15/11.76 — net 21·(1-0.44p)·g(p) = 21 - 6p px. Update
with the 0.56 pair if --play-shrink changes. */
transform: scale(1.2755);
}
}
@keyframes preview-shrink-ride {
from {
transform: translateX(0) scale(1);
}
to {
/* 0.675 = 27/40. The translate carries Play's 22px row slide
(the frozen Play box no longer pulls the row leftward) PLUS
this button's own 6.5px half-shrink, pinning its visual left
edge exactly where layout puts it today. */
transform: translateX(calc(-1 * var(--play-shrink) - 6.5px))
scale(0.675);
}
}
@keyframes preview-glyph-ride {
from {
transform: scale(1);
}
to {
/* g(1) = 14/12.15. */
transform: scale(1.1523);
}
}
@keyframes chips-slide-ride {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-1 * var(--row-slide, 35px)));
}
}
/* 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. The calc stays here in
BOTH branches, deliberately: on the fallback path it is the live
per-frame mechanism (a relayout of the idpin subtree), while on the
scroll-driven path --p is never written, --edge is pinned at
--hero-span and this is a static rest pose for free — the identical
rigid motion then comes from idblock-ride, a composited transform of
the same subtree (children are absolutely positioned against this
block, so a translate moves them all identically, no relayout). */
.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 SEAM TAIL (Kyle's design, round 3). A soft colour lead-in
rising from the HERO'S OWN top edge, coloured like the hero itself,
so the edge carries its own dissolve and the hard line never exists
— at rest or sliding under the glass. Geometry: the banner's top
edge sits at 70px in this box (its margin-top), so the tail's
BOTTOM is pinned there. It RIDES WITH the hero: the gated squeeze
ride + fallback calc below mirror the banner's own, same selector
shape, same gating, so tail and edge move as one thing. Hidden
until the sharp artwork has painted (a tail for a hero that is not
on screen would colour the fallback wrong). z-index 1 outranks the
sibling hero layers' auto-level stacking contexts. Zero per-frame
cost: static images, composited once. */
.seam-tail {
position: absolute;
left: 0;
right: 0;
z-index: 1;
opacity: 0;
transition: opacity 400ms ease;
}
.seam-tail.painted {
opacity: 1;
}
/* The mirrored-artwork tail — the artwork's own top strip, flipped
about the seam. Colour-continuous with the hero at every horizontal
position by construction. It lives inside .seam-clip, which pins
at the bar's TOP line (24px = the pane padding above the stuck bar
in this box) and never rides: the tail inside rides with the hero,
and anything sliding past the bar's top edge is cut — the
reflection exists strictly inside the glass. Tail coordinates below
are clip-local (the clip's top sits 24px into this box). */
.seam-clip {
position: absolute;
top: 24px;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
overflow: clip;
pointer-events: none;
}
.seam-tail.mirror {
overflow: hidden;
}
/* The GPU path's snapshot canvas gets the identical treatment (it is
created unscoped by snapshotGpuTwins, hence :global; its wrapper is
display: contents so the canvas positions against the tail box). */
.seam-tail.mirror :global(.tail-mirror-src) {
display: contents;
}
.seam-tail.mirror img,
.seam-tail.mirror :global(.tail-mirror-frame) {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: auto;
transform: scaleY(-1);
}
/* Rotation dissolve — the banner's own curve and duration variants,
so tail and hero always fade as one. (.tail-prev sits underneath
with no animation, exactly like .banner.previous.) */
.seam-tail.mirror .tail-cur,
.seam-tail.mirror :global(.tail-mirror-frame.incoming) {
animation: hero-banner-in 1.4s ease backwards;
}
.seam-tail.mirror .tail-cur.initial {
animation-duration: 180ms;
}
.seam-tail.mirror .tail-cur.finish {
animation-duration: 650ms;
}
/* The bloom strip is retired (2026-07-24): the blurred hero copy that
used to fill the band behind the glass bar is gone — the seam tail
alone carries the band. HeroGpuCanvas still renders its INTERNAL
bloom canvases; they are hidden here rather than un-rendered to
keep the GPU component untouched (visual parity with the tested
configuration, which hid them the same way). Out-of-flow overlay,
so hiding it shifts no layout. */
.artist-page :global(.gpu-hero canvas.bloom) {
display: none;
}
/* THE seam design (Kyle, 2026-07-24): the PURE mirror — no fade,
no gradient, full strength. Exactly the bar band tall, so at rest
the flipped strip fills the glass edge-to-edge and stops cleanly at
the bar's top border. Clip-local: the hero's edge sits at
70 − 24 = 46px here, so top 0 + height 46 pins the mirror's bottom
to the edge and its top to the bar's top line. */
.seam-tail.t2 {
top: 0;
height: 46px;
}
/* The monochrome treatment, applied to the PICTURE layers only. The
existing filters are re-stated (in the keyframes and in the fallback
rules alike) because filter replaces rather than composes.
THE SQUEEZE, biased to keep FACES. At rest --edge IS --hero-span, so
the translation 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.
The accent ground (.hero-fallback) 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, where the bar's
own backdrop saturate(1.7) amplified any bleed-through into a
visibly warm bar on a black-and-white banner.
Both mechanisms live per-branch below: the fallback branch keeps the
live var(--p)/--mono/--edge calcs verbatim; the scroll-driven branch
rides the same curves as animations on the --artist-pane timeline. */
/* 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;
}
/* 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;
}
/* The sharp artwork: native aspect ratio (no crop), offset down so it
begins right at the bottom of the glass bar.
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. */
.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. */
.banner.current.finish {
animation-duration: 650ms;
}
@keyframes hero-banner-in {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.banner.current,
.banner.current.initial,
.banner.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;
/* Identically 0px at EVERY p by construction — measure() writes
--lift = banner - 14 - markRest, cancelling the parked term — so
this needs no scroll-driven conversion: it is the live
(identically-zero) glue on the fallback path and a frozen rest
pose on the supported path, where --p is never written. */
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. The origin serves BOTH branches; the
scale itself lives per-branch (fallback: the live calc; scroll-driven:
the mark-park-ride animation over the identical linear ramp). */
.artist-page[data-scroll="1"] .logo-slot {
transform-origin: left bottom;
}
.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 wraps, so it no longer
overruns on a normal chip set, but a single unbreakable chip (a long
top_genre — .chip is white-space: nowrap) can still be wider than the
pane, and clip stops that from reaching the scroll pane as horizontal
overflow while leaving the chips' hover popovers free to escape
vertically. `clip`/`visible` is also the only legal mixed pair.
min-height is --row-h, and safely so: --row-h is derived from the CHIP
BLOCK's height plus constants (see measure()), never from this box, so
there is no path by which the box's own height feeds its floor.
flex-START, not centre. Centring makes every item's position depend on
the block's height, so when the social line leaves at the park the
survivors re-centre — a measured 18.5px drop, at one instant, mid-scroll.
Anchored to the top, the row's height decides nothing and the collapse
moves not one pixel. Each item is instead centred against a 50px lead
row (Play's resting size) by its own margin below. */
.artist-actions {
position: absolute;
left: 0;
right: 0;
bottom: calc(-1 * var(--row-h));
min-height: var(--row-h, 76px);
padding: var(--row-pad-top, 13px) 0 8px;
display: flex;
align-items: flex-start;
gap: 16px;
overflow-x: clip;
overflow-y: visible;
pointer-events: none;
}
/* Each item centred against that 50px lead row. The --p terms are the
FALLBACK path only, where the buttons shrink by real width and height:
half of what they shed, added back as margin, holds each centre still.
On the scroll-driven path --p stays 0 and the shrink is a transform,
which does not move a layout box — so both terms vanish there, exactly
as the sibling calcs on .hero-play/.hero-preview do. */
.hero-play {
margin-top: calc(var(--p) * var(--play-shrink) / 2);
}
.hero-preview {
/* (50 - 40) / 2 at rest; 13px is this button's own 40 -> 27 shed. */
margin-top: calc(5px + var(--p) * 6.5px);
}
.stat-chips {
/* (50 - 27) / 2 — a chip line is 27px and never scales. */
margin-top: 11.5px;
}
.hero-play,
.hero-preview,
.stat-chips {
pointer-events: auto;
}
/* The hole the row left behind in .popular. It FOLLOWS the row now —
heroScroll() measures the row and writes --row-h, this reads it back —
so the spacer and the pinned row still cannot disagree about how tall
the row is, and a wrapped row pushes Popular down instead of climbing
into the wordmark. The literal is only the pre-measure first paint. */
.actions-gap {
min-height: var(--row-h, 76px);
}
/* Floating stat chips beside Play — individually rounded translucent
pills, deliberately NOT a joined bar. They sit over the hero's
dissolved tail — dark by design — so their rgba backgrounds alone
carry legibility: the backdrop blur they used to run was measured
invisible there in every captured state and cost ~30% of the
scroll-time GPU bill (2026-07-24 evidence pack), so it is gone. */
.stat-chips {
display: flex;
align-items: center;
/* Wraps. The old nowrap was defended as "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" — true only
while --row-h was a constant. It is measured off the row now
(heroScroll's measure()), so the box's top stays pinned to --edge
and the extra line grows DOWN into the reserve. Nothing has to give
any more, which is why the shedding below now starts three tiers
lower than it did. */
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
/* Only below the width where even a wrapped row cannot fit the parked
banner does the tail still shed. .social-set is named as well as
counted: the wrapper collapses eight sibling indices into one, so on a
sparse artist the socials can sit at index 2 and would otherwise
outlive every stat beside them. */
.idpin[data-w="xs"] .stat-chips > :nth-child(n + 2),
.idpin[data-w="xs"] .stat-chips > .social-set {
display: none;
}
/* THE WHOLE ROW survives into the banner now — the identity chips AND the
social links. The socials used to fade (per-branch ramps) and leave
layout at the 0.42 park boundary; all four of those mechanisms came out
on 2026-08-04 when the library/listening chips were removed instead —
with the row down to three short chips plus the socials, there is
nothing left that needs shedding. apply() still writes --extra-hit and
data-park; they are simply unconsumed by the socials now (the badge
keeps its own ride). */
.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;
}
/* 0.45 was most of why these read as "thin": a 13px glyph at 45% white on
the hero's dark tail has almost no edge left to see, whatever its stroke
is. Lifted to 0.72 — still quieter than the chip's own 0.8 text, so the
icon stays subordinate to the number beside it. */
.chip :global(.icon) {
color: rgba(255, 255, 255, 0.72);
}
/* The library-progress meter, the disk chip's per-album popover and the
follow heart's rules lived here; their chips were removed 2026-08-04
(Kyle's call) and Svelte treats an unused selector as deleted anyway —
a warning per rule, and a wall of those is where a real one hides. */
/* ---- the follow chip (FB4 · Encore · leads the row) -------------------
Chip metrics come from .chip; only the action-ness is added here. The
on-state is the accent as ground with dark text — the Play pill's own
trick — which is what made FB4 the pick: ON is visible from across the
room. */
.chip.fb {
cursor: pointer;
position: relative;
}
.chip.fb:hover {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
color: #fff;
}
.chip.fb.on {
background: var(--accent);
border-color: transparent;
color: #0a0a0c;
font-weight: 700;
}
.chip.fb.on:hover {
filter: brightness(1.08);
color: #0a0a0c;
}
/* The fixed slot the whole ceremony happens in: the +, the bars and the
check swap INSIDE it, so nothing about the chip's box ever moves except
the word — and the word morphs (fbMorphLabel), never snaps. */
.fb-lead {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex: none;
}
.lead-glyph {
display: inline-flex;
}
.lead-glyph.lead-out {
animation: lead-plus-out 180ms cubic-bezier(0.5, 0, 0.8, 0.5) both;
}
@keyframes lead-plus-out {
to {
transform: rotate(100deg) scale(0.15);
opacity: 0;
}
}
.lead-eq {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 13px;
}
/* height: 100% is load-bearing — a bare b has no intrinsic height, and
without it the bars render 3px wide and zero tall: present, invisible
(the mock shipped exactly that bug). */
.lead-eq b {
width: 3px;
height: 100%;
border-radius: 1px;
background: var(--accent);
transform-origin: bottom;
animation: lead-eq-dance 880ms ease-in-out var(--delay) both;
}
/* Rise, three uneven beats, and a bow — the bars END low, handing the
slot to the check instead of being cut off mid-jump. */
@keyframes lead-eq-dance {
0% {
transform: scaleY(0.12);
}
12% {
transform: scaleY(1);
}
26% {
transform: scaleY(0.35);
}
40% {
transform: scaleY(0.92);
}
54% {
transform: scaleY(0.28);
}
68% {
transform: scaleY(0.82);
}
82% {
transform: scaleY(0.3);
}
100% {
transform: scaleY(0.14);
}
}
.lead-glyph.lead-in {
animation: lead-check-in 340ms cubic-bezier(0.34, 1.5, 0.6, 1) both;
}
@keyframes lead-check-in {
0% {
transform: scale(0) rotate(-45deg);
opacity: 0;
}
55% {
transform: scale(1.28) rotate(6deg);
opacity: 1;
}
100% {
transform: none;
opacity: 1;
}
}
/* The label is the chip's only variable-width part; overflow hidden lets
fbMorphLabel reveal the longer word by animating THIS box's width. */
.fb-label {
display: inline-block;
overflow: hidden;
white-space: nowrap;
}
.fb-label.label-in {
animation: label-in 260ms ease 40ms both;
}
@keyframes label-in {
from {
opacity: 0.25;
transform: translateY(3px);
}
to {
opacity: 1;
transform: none;
}
}
/* The + and the check follow the CHIP's colour, exactly as the mock's raw
SVGs did by inheriting currentColor: 0.8 white at rest, #0a0a0c on the
FB4 fill. Without this the page's own `.chip .icon` wash (0.45 white)
applied — a pale check floating on the accent, which is not the card
Kyle approved. */
.chip.fb :global(.icon) {
color: inherit;
}
/* The glow and the notes are TEMPLATE-owned, like the bars — plain scoped
classes, plain scoped keyframes. The first build spawned them
imperatively and routed through -global- keyframes whose reference
never resolved: the glow ran no animation and sat at FULL opacity (the
"random glow"), the notes held their base opacity 0 and never appeared.
The bars worked the whole time for exactly this reason — they were
template + scoped. */
.fb-glow {
position: absolute;
inset: -10px -16px;
border-radius: 99px;
z-index: -1;
pointer-events: none;
background: radial-gradient(
closest-side,
color-mix(in srgb, var(--accent) 55%, transparent),
transparent 72%
);
animation: fb-glow-pulse 1500ms ease both;
}
/* Two pulses: a swell while the bars dance, a brighter beat as the check
lands. */
@keyframes fb-glow-pulse {
0% {
opacity: 0;
}
20% {
opacity: 0.32;
}
55% {
opacity: 0.12;
}
76% {
opacity: 0.42;
}
100% {
opacity: 0;
}
}
.fb-note {
position: absolute;
left: var(--cx);
top: -4px;
color: var(--accent);
font-size: 12px;
line-height: 1;
transform: translate(-50%, 0);
opacity: 0;
pointer-events: none;
animation: fb-note-float 950ms ease-out var(--delay) both;
}
@keyframes fb-note-float {
0% {
transform: translate(-50%, 0) rotate(0deg);
opacity: 0;
}
15% {
opacity: 0.95;
}
40% {
transform: translate(calc(-50% + var(--sway)), -14px) rotate(9deg);
}
70% {
transform: translate(calc(-50% - var(--sway)), -26px) rotate(-8deg);
opacity: 0.7;
}
100% {
transform: translate(calc(-50% + var(--sway)), -38px) rotate(6deg);
opacity: 0;
}
}
/* Socials: ghost icons closing the row — 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).
The whole block is one flex item so it wraps as a unit. No margin of its
own, deliberately: a leading margin here would indent the wrapped line
away from the chips above it. The clear space the divider needs is put
on the chip BEFORE the group instead (see .chip.follow below), where a
wrap leaves it harmlessly at the end of the previous line. */
.social-set {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
/* The 25px the divider used to occupy in flow (8px gap + 4px margin + 1px
+ 4px margin + 8px gap), rebuilt as 17px here plus the row's own 8px
gap. It is a CONSTANT and must stay one: make it depend on whether the
group has wrapped and hiding the divider would hand back the width that
caused the wrap, the group would fit again, the divider would return,
and the row would flip between one line and two forever. */
/* Any chip, not the follow heart by name: the heart was removed with the
stat set (2026-08-04), so whichever chip now closes the run — years
active, today — carries the divider's clear space instead. */
.stat-chips > .chip:has(+ .social-set) {
margin-right: 17px;
}
/* OUT OF FLOW for the same reason — hiding it has to be pure paint, never
a relayout. Centred in the 25px it no longer occupies. */
.chip-divider {
position: absolute;
left: -13px;
top: 50%;
margin-top: -9px;
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.14);
}
/* Alone at the head of a wrapped line it separates nothing. The class is
bound in the markup (class:wrapped) from syncSocialDivider()'s state. */
.social-set.wrapped .chip-divider {
display: none;
}
.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;
}
}
/* FALLBACK: shrunk by real width/height, not transform — with a
transform alone the row would keep its 50px layout box, the chips
would stay centred in that band and the parked wordmark would collide
with them. On the scroll-driven path --p stays 0, so these calcs
freeze at 50px and play-shrink-ride shrinks the button by transform
instead — with chips-slide-ride supplying the row slide the frozen
layout box no longer provides, which is what makes the transform
route safe there. */
.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);
}
/* The individual `scale` property, NOT `transform: scale(1.04)`: on the
scroll-driven path a running transform animation (play-shrink-ride)
silently overrides any transform declaration and the hover pop would
die. `scale` COMPOSES with the animation's transform — identical at
rest, a multiplicative pop mid-ride — and stays symmetric about the
centre on both branches (no transform-origin is set on the button).
Known deviation: composing outside the ride's transform also
multiplies its translateX by 1.04, so a hovered mid-ride/parked
button sits up to ~0.4px (Play) / ~1.1px (preview) further left
than the fallback's fixed-centre pop — transient, hover-only,
accepted. Any future hover transform on an animated element must use
individual properties too. */
.hero-play:hover {
background: var(--accent-hover);
scale: 1.04;
}
/* Preview tracks. Translucent, not accent: Play stays the row's one
filled control. (The pill's backdrop blur went with the chips' —
same 2026-07-24 measurement; the rgba fill carries the look.)
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);
}
/* 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);
}
/* `scale`, not `transform` — same reasoning as .hero-play's hover: it
must compose with preview-shrink-ride instead of being overridden. */
.hero-preview:hover:not(:disabled) {
color: #fff;
border-color: rgba(255, 255, 255, 0.75);
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 on the fallback branch: the
badge is small enough at a 27px parked circle, and animating them slid
it off the edge. (On the scroll-driven branch the parent's ride
transform drifts the corner offsets ≤0.7px — see the note at the
badge-fade-ride rule.) */
.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;
/* The fade ramp itself lives per-branch (fallback: the live calc;
scroll-driven: badge-fade-ride, which also counter-scales the
badge against its now-transform-shrunk parent). */
}
.popular h2 {
margin-top: 0;
}
/* Widened from 760px to make room for the listener count WITHOUT taking it
out of the title and album. Break-even was 833px (the column plus its
gap); the rest is air, so title and album each end up ~38px wider than
they were before the column existed. Still well under the ~2068px the
section has — a 54px row stretched that far puts the duration a very long
way from the title, which is why the discography grid gets the full width
and this list does not. */
.popular-list {
max-width: 900px;
}
.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 {
/* The anchor the absolute row actions were always written against —
without it the save button resolved against the page and its
reveal rules never existed, so it was dead UI. The 24px last
column is the space the overlaid actions land on. */
position: relative;
display: grid;
/* 96px listener column — the search page's .lbar width, which the
pill here mirrors (the 62px text column predates the bars). */
grid-template-columns: 26px 40px minmax(130px, 1fr) minmax(100px, 0.75fr) 96px 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);
}
/* Hover slides the actions over the right end and the duration gets out
of the way — the album page's treatment. The listener count stays: the
list is ordered by it. */
.popular-row :global(.plm) {
position: absolute;
top: 50%;
right: 36px;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.12s ease;
}
.popular-row :global(.plm-trigger) {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: var(--text-secondary);
}
.popular-row :global(.plm-trigger:hover) {
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.08);
}
.popular-row:hover .mini-get,
.popular-row:focus-within .mini-get,
.popular-row:hover :global(.plm),
.popular-row:focus-within :global(.plm) {
opacity: 1;
}
.popular-row:hover .tdur,
.popular-row:focus-within .tdur {
/* pointer-events too: the faded duration is a stacking context and
must never eat clicks meant for the buttons over it. */
opacity: 0;
pointer-events: none;
}
/* While its menu is open the pointer is off in the portaled dropdown, so
hover alone would drop the cluster mid-interaction. */
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) .mini-get {
opacity: 1;
}
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
opacity: 0;
pointer-events: none;
}
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) {
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;
}
/* Exactly the 40px grid column the bare <img> used to occupy, so the row
geometry is unchanged — it only gains a positioning context for the
outgoing cover to fade out in. */
.cover-slot {
position: relative;
display: block;
width: 40px;
height: 40px;
}
.track-cover {
display: block;
width: 40px;
height: 40px;
border-radius: 3px;
object-fit: cover;
}
/* The ghost is built at runtime by `resolvedCover`, so the compiler cannot
see either class in the markup and prunes them as unused — :global keeps
them in the build, the same way `.is-loaded` is kept above. */
.cover-slot :global(.cover-ghost) {
position: absolute;
inset: 0;
opacity: 1;
transition: opacity 260ms ease;
pointer-events: none;
}
.cover-slot :global(.cover-ghost.faded) {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.cover-slot :global(.cover-ghost) {
transition: opacity 90ms linear;
}
}
.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;
}
/* All-time Last.fm listeners — what the list is ordered by. Drawn as the
app-wide pill (.tcount above, the search page's .lbar): this used to be
muted text so the accent stayed on `.now`, but one bar design across
every count won out (Kyle, 2026-08-30) — the ♫ rank and title still
mark the playing row. */
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);
}
/* Sits at the right end of the head, which is already space-between. The
"Show more" control under Popular is the shape being matched. */
.disco-all {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12.5px;
font-weight: 800;
color: var(--text-secondary);
padding: 5px 8px;
border-radius: 6px;
}
.disco-all:hover {
color: var(--text-primary);
background: var(--bg-highlight);
}
/* 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);
/* Column gap only. The vertical rhythm is carried by margins on the
items instead, because a GAP cannot be animated away with the item
it surrounds: a collapsing expander reaches height 0 with its two
16px gaps still standing, and the instant Svelte removes it the grid
loses one of them and every row below steps up 16px in a single
frame. Measured at the removal instant, 577.9 -> 561.9, on both a
close and a switch — with scrollTop unchanged, which is why it never
showed up in a scroll trace. A margin belongs to the box, shrinks
with it, and leaves nothing behind. (Negative margins do NOT work
here: a grid row track floors at zero, so -16px on a 0-height item
moves nothing — verified by sweeping it.) */
column-gap: 16px;
row-gap: 0;
/* 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;
}
/* Load status wearing the stat chip's own metrics — it stands in for those
chips while they resolve, so it must measure exactly like them or the row
changes height as it hands over. Inherits .chip for everything but the
spinner and the slightly softer label colour. */
.stage-chip {
gap: 8px;
color: rgba(255, 255, 255, 0.62);
pointer-events: none;
}
.stage-chip .ring {
width: 11px;
height: 11px;
border-radius: 50%;
border: 2px solid var(--accent);
border-right-color: transparent;
animation: stage-chip-spin 0.9s linear infinite;
}
@keyframes stage-chip-spin {
to {
transform: rotate(360deg);
}
}
/* The spinner is decoration; honour a reduced-motion preference by holding
it still rather than removing the only signal that work is happening. */
@media (prefers-reduced-motion: reduce) {
.stage-chip .ring {
animation: none;
}
}
.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;
/* The grid's vertical rhythm — see .grid's row-gap: 0. */
margin-bottom: 16px;
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 glass bar
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. */
/* Containing block for the outgoing panel, which panelLeave takes out of
flow so the two overlap instead of stacking. */
.xp-stage {
position: relative;
}
.album-expander {
grid-column: 1 / -1;
position: relative;
/* Its share of the rhythm, and expandSlide animates it to 0 alongside
the height so the box contributes exactly nothing by the time it is
removed. */
margin-bottom: 16px;
}
/* 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; }
}
/* Same artwork-hover treatment as the album page's hero (--art-* tokens in
global.css): the whole element lifts under a light wash with the expand
mark centred. The rail's own crossfade lives on the images inside, and
is untouched by a transform on their container. */
.rail-art-stage {
position: relative;
display: block;
width: 100%;
aspect-ratio: 1;
padding: 0;
border-radius: 8px;
cursor: pointer;
transition: transform var(--art-motion);
}
.rail-art-stage:hover,
.rail-art-stage:focus-visible {
transform: scale(var(--art-grow));
}
.rail-art-stage::after {
content: "";
position: absolute;
inset: 0;
z-index: 2;
border-radius: 8px;
background: var(--art-wash);
opacity: 0;
transition: opacity var(--art-motion);
pointer-events: none;
}
.rail-art-stage:hover::after,
.rail-art-stage:focus-visible::after {
opacity: 1;
}
.rail-art-mark {
position: absolute;
inset: 0;
margin: auto;
z-index: 3;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(12, 12, 14, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: #fff;
opacity: 0;
transform: scale(0.82);
transition:
opacity var(--art-motion),
transform var(--art-motion);
}
.rail-art-stage:hover .rail-art-mark,
.rail-art-stage:focus-visible .rail-art-mark {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.rail-art-stage,
.rail-art-mark {
transition-duration: 1ms;
}
.rail-art-stage:hover,
.rail-art-stage:focus-visible {
transform: none;
}
}
/* The name is the link to the record's own page. It inherits the heading's
type so only the pointer and the underline mark it as one. */
.rail-title-link {
font: inherit;
color: inherit;
text-align: left;
padding: 0;
}
.rail-title-link:hover {
text-decoration: underline;
}
.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);
}
/* Album totals, sitting between the meta line and the Play row. The
numerals carry the accent so the rail reads as this record's own. */
.rail-stats {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: -4px;
}
.rail-stat {
display: flex;
flex-direction: column;
line-height: 1.12;
}
.rail-stat b {
font-size: 16px;
font-weight: 800;
color: var(--accent-hover);
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
.rail-stat i {
font-style: normal;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 700;
margin-top: 2px;
}
.rail-stat-div {
width: 1px;
height: 22px;
background: rgba(255, 255, 255, 0.12);
}
/* An approximation is labelled rather than passed off as Last.fm's figure. */
.rail-stat-note {
font-size: 9.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
font-weight: 700;
}
.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. */
/* The promoted first sentence. Only a little larger than the body: the rail
is 250px wide and the title above it is already 17px, so a bigger lede
would compete with the record's own name. */
.rail-lede {
margin: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.5;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
/* Genre and label only — the date is already in `.rail-meta` directly
above, and repeating it cost a second wrapped row in a 210px column. */
.rail-pills {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.gpill {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 3px 9px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(255, 255, 255, 0.07);
}
.gpill.accent {
background: color-mix(in srgb, var(--accent) 30%, rgba(10, 10, 12, 0.4));
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
color: #fff;
}
.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);
}
}
/* The loading state: the app's own ring over placeholder rows drawn in the
track grid's exact geometry, so the real list replaces them in place.
Only ever rendered once a load has outlasted LOADING_GRACE — the flash it
replaces was 7-31ms of grey text on a warm open. */
.tracks-loading {
display: flex;
flex-direction: column;
}
.tl-head {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px 13px;
color: var(--text-secondary);
font-size: 12.5px;
}
.tl-ring {
width: 13px;
height: 13px;
border-radius: 50%;
border: 1.6px solid rgba(255, 255, 255, 0.16);
border-top-color: rgba(255, 255, 255, 0.62);
animation: tl-spin 0.72s linear infinite;
}
@keyframes tl-spin {
to {
transform: rotate(360deg);
}
}
/* .trow's own columns, gap, padding and height — see the rule below. A
placeholder that does not match it moves the real rows when it goes. */
.tl-row {
display: grid;
grid-template-columns: 26px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 7px 10px;
height: 33px;
box-sizing: border-box;
}
.tl-bar {
position: relative;
height: 9px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.07);
overflow: hidden;
}
.tl-num {
width: 12px;
justify-self: center;
}
.tl-dur {
width: 30px;
}
/* One sweep per bar on a shared clock, not a per-row pulse: independently
breathing bars read as noise rather than as one thing arriving. */
.tl-bar::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.11),
transparent
);
transform: translateX(-100%);
animation: tl-sweep 1.4s ease-in-out infinite;
}
@keyframes tl-sweep {
to {
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.tl-bar::after {
animation: none;
}
.tl-ring {
animation-duration: 2.4s;
}
}
/* 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;
}
/* The header and the rows share one template, so the columns line up
whether or not Last.fm knew this record. */
.trow.has-count,
.tl-cols.has-count {
grid-template-columns: 26px minmax(0, 1fr) 168px 48px;
}
.tl-cols {
display: grid;
grid-template-columns: 26px minmax(0, 1fr) 48px;
align-items: center;
gap: 12px;
padding: 0 10px 7px;
margin-bottom: 3px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
}
.tdur-head {
display: flex;
justify-content: flex-end;
}
/* Listener weight, the app-wide design: the search page's pill — a visible
track, a solid accent fill scaled by the shared curve, the number
standing centred on top (src/lib/listenerBars.js). Length is the ONLY
encoding: the old heat wash here drove colour AND alpha from the count
(--t on the row), which made every page's bars read as a different
design — retired for the one pill (Kyle, 2026-08-30). Keep these rules
in step with .lbar in CatalogSearch.svelte — one dial, four files.
align-self centres the 17px pill in the taller grid row. */
.tcount {
position: relative;
height: 17px;
align-self: center;
border-radius: 4px;
overflow: hidden;
background: rgba(255, 255, 255, 0.07);
}
.tcount i {
position: absolute;
inset: 0;
border-radius: 4px;
background: var(--accent);
transform-origin: left;
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
/* A track Last.fm has no count for gets the dash and no paint at all — a
floor-width bar behind an em dash would read as a measured tiny value. */
.tcount.empty i {
display: none;
}
.tcount-val {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 10.5px;
font-weight: 800;
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.65);
}
/* The hover reveal slides the play/save buttons over the right end of the
row, so the duration gets out of the way — the count stays put. */
.trow:hover .tcount,
.trow:focus-within .tcount {
opacity: 1;
}
.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;
pointer-events: none;
}
/* Add-to-playlist joins the expander row's overlay pair, one slot left of
Play; its menu is portaled, so hover alone would drop the cluster while
the menu is open — the aria-expanded rules hold it. */
.trow :global(.plm) {
position: absolute;
top: 50%;
right: 64px;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.12s ease;
}
.trow :global(.plm-trigger) {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: var(--text-secondary);
}
.trow :global(.plm-trigger:hover) {
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.08);
}
.trow:hover :global(.plm),
.trow:focus-within :global(.plm),
.trow:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .mini-btn,
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .mini-get {
opacity: 1;
}
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
opacity: 0;
pointer-events: none;
}
.trow:has(:global(.plm-trigger[aria-expanded="true"])) {
background: var(--bg-highlight);
}
.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%);
}
/* Tighter one-line floor here. It must be set as --row-min on the
page, NOT as a height on the spacer: the spacer mirrors the row's
measured height now, and a hard height would pin the reserve to one
line again — the exact failure a wrapped row cannot survive, and the
worst place for it, since 66px is 50px of button plus 16px of
padding with no slack at all. */
.artist-page {
--row-min: 66px;
}
.popular-row {
grid-template-columns: 24px 36px minmax(0, 1fr) 52px 38px 22px;
gap: 8px;
}
.cover-slot,
.track-cover {
width: 36px;
height: 36px;
}
.track-album {
display: none;
}
}
/* ── dark glass: the surfaces standing on the frost ─────────────────────
This page already carries the most glass work in the app, but all of it
is about the HERO — the photo's dissolve, the sheet, the periscope band.
Below the fade the page is bare pane frost (the note at the top of that
section says so), and everything sitting on it was still opaque. Ladder
in global.css; solid mode untouched.
No .rowbtns companion rule here, unlike CatalogAlbum: this page's rows
have no fade shim to keep in step with the hover. */
:global(:root.glass) .popular-row:hover,
:global(:root.glass) .popular-row:has(:global(.plm-trigger[aria-expanded="true"])),
:global(:root.glass) .trow:hover,
:global(:root.glass) .trow:has(:global(.plm-trigger[aria-expanded="true"])),
:global(:root.glass) .disco-all:hover,
:global(:root.glass) .release:hover,
:global(:root.glass) .release:focus-visible,
:global(:root.glass) .release.active {
background: var(--glass-hover);
}
:global(:root.glass) .pill.ghost,
:global(:root.glass) .pill:disabled,
:global(:root.glass) .pill:disabled:hover {
background: var(--glass-surface);
}
:global(:root.glass) .pill.ghost:hover {
background: var(--glass-hover);
}
:global(:root.glass) .rail-ed-art.placeholder {
background: var(--glass-surface);
}
:global(:root.glass) .placeholder {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.11));
}
/* Hairlines: --border is opaque #2a2a2a and reads as a drawn grey line. */
:global(:root.glass) .rail-stat-note,
:global(:root.glass) .tag-unreleased {
border-color: var(--glass-hairline);
}
/* The expander rail's legibility scrim, thinned for the same reason as the
library's twin of it: a 0.92 tail on a now-translucent panel turns the
rail's lower half into a near-opaque block inside a glass card. The glow
it shades rests at 0.32 opacity, so this is still ample. */
:global(:root.glass) .rail-shade {
background: linear-gradient(
180deg,
transparent 0%,
rgba(24, 24, 24, 0.3) 55%,
rgba(24, 24, 24, 0.55) 100%
);
}
/* The expander and its pointer must carry the SAME value — the notch is a
rotated square half-buried under the panel's edge, so any difference
between them draws a triangle. (Same pairing as Library.svelte.) */
:global(:root.glass) .xp-panel,
:global(:root.glass) .notch {
background: var(--glass-surface);
}
</style>