9635 lines
447 KiB
Svelte
9635 lines
447 KiB
Svelte
<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 WhoHas from "../components/WhoHas.svelte";
|
||
import { openWhoHas, holdersFor, pairKey, holderCount } from "../lib/whoHas.svelte.js";
|
||
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));
|
||
// Who in your rooms holds a release you lack: asked for the visible tab's
|
||
// families at once, filed by pairKey; the count rides the card, and the
|
||
// open album shows the people.
|
||
let heldBy = $state({});
|
||
const heldFor = (title) => (page ? heldBy[pairKey(page.artist.name, title)] : null);
|
||
$effect(() => {
|
||
const name = page?.artist?.name;
|
||
const items = visibleTabs.flatMap((g) => g.items).filter((al) => !inLibrary(al)).map((al) => ({ artist: name, album: al.title }));
|
||
if (!name || !items.length) return;
|
||
untrack(() => void holdersFor(items).then((m) => { for (const [k, v] of m) heldBy[k] = v; }));
|
||
});
|
||
|
||
// 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}
|
||
{#if heldFor(al.title)?.length}
|
||
<button class="have" title="In your rooms — who has it?" onclick={(e) => { e.stopPropagation(); openWhoHas({ artist: page.artist.name, album: al.title, cover: al.cover ?? null }, e.currentTarget.closest(".release")); }}>{holderCount(heldFor(al.title))} {holderCount(heldFor(al.title)) === 1 ? "has it" : "have it"}</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 openAlbum && !inLibrary(openAlbum) && heldFor(openAlbum.title)?.length}
|
||
<div class="xp-whohas">
|
||
<div class="xp-whohas-head"><span class="t">Who has this</span><span class="p">from your rooms' holdings · names compared, never files</span></div>
|
||
{#key openAlbum.id}<WhoHas artist={page.artist.name} album={openAlbum.title} compact />{/key}
|
||
</div>
|
||
{/if}
|
||
{#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);
|
||
}
|
||
/* who in your rooms holds a release you lack — the count rides the card */
|
||
.have { position: absolute; right: 6px; bottom: 6px; z-index: 2; display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 7px; border-radius: 99px; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); color: #fff; font-size: 10.5px; font-weight: 900; border: 1px solid rgba(255, 255, 255, 0.14); cursor: pointer; white-space: nowrap; }
|
||
.have:hover { background: rgba(0, 0, 0, 0.82); border-color: rgba(255, 255, 255, 0.34); }
|
||
.xp-whohas { margin: 0 0 12px; padding: 10px 12px; border-radius: 10px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.07); }
|
||
.xp-whohas-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; flex-wrap: wrap; }
|
||
.xp-whohas-head .t { font-size: 12.5px; font-weight: 800; white-space: nowrap; }
|
||
.xp-whohas-head .p { font-size: 11px; color: var(--text-muted); }
|
||
</style>
|