album page: the record's own shape while it loads, and prose that waits its turn

The loading state was one line of grey text, then the whole page at once.
It is now the page with its words removed — the same hero, identity, cover
and copy elements, so the artwork does not move when the fetch lands. The
cover is real from the first frame: `albumSeed` carries what the card you
clicked already knew, painted in 10ms, and the skeleton row count comes from
the seed's track count so the tracklist grows 370 -> 377px instead of
296 -> 377.

Three bugs found by measuring the handoff frame by frame:

- Every dealt part rendered once at full opacity and then snapped to zero.
  `element.animate()` returns a PENDING animation whose start time is not
  resolved until the next frame, so `fill: backwards` never covered the first
  paint. Hiding each part inline is synchronous and closes it.
- The deal was animating the SKELETON. `openCatalogAlbum` assigns `albumPage`
  and clears `albumLoading` only in its `finally`, so for one flush the new
  record was live while the placeholder was still on screen — and it has a
  `.tracklist` of its own.
- The artwork could blink on a cold cache: the skeleton seeds the 500px grid
  cover and the page asks for the master. The seed now sits underneath as a
  background until the master decodes, on the banner too, where the blur
  applies to it and the colour wash survives the swap.

The deal also only ran once, against a snapshot, so `.about`, `.eds`,
`.stats` and `.credits` — all gated on network data — were never animated at
all. Parts now record which generation dealt them and a later pass picks up
whatever was missing.

The description moves BELOW the tracklist. Above it, it pushed the tracks
~180px down and re-flowed them on arrival, measured at 81.5px; nothing above
that point depends on `info` now, so the tracklist cannot move (measured 0).
It gains the page's own `.disc-head` idiom, a promoted lede, and the bottom
credits block becomes pills plus who said so.
This commit is contained in:
KeelrLeek committed 2026-07-29 18:46:16 -05:00
1 parent 35a18d9ca0
commit a6b585303e
1 file changed
+330 -40
+330 -40
View File
@@ -25,7 +25,7 @@
import { buildAlbumManifest } from "../lib/preview/manifest.js";
import * as api from "../lib/api.js";
import { accentFromImage } from "../lib/color.js";
import { editionLabel, releaseFamilies } from "../lib/editions.js";
import { editionLabel, releaseFamilies, splitDescription } from "../lib/editions.js";
import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import Icon from "../components/Icon.svelte";
@@ -42,6 +42,9 @@
}
});
/** Lede + body, from the shared splitter — see `splitDescription`. */
const descParts = $derived(splitDescription(info?.description));
const cover = $derived(album?.cover ? (coverUpgrades[album.cover] ?? album.cover) : null);
const coverOk = $derived(Boolean(cover) && !artBroken[album?.cover]);
@@ -348,23 +351,91 @@
typeof matchMedia === "function"
&& matchMedia("(prefers-reduced-motion: reduce)").matches;
/* Uneven on purpose: a column of identical bars reads as a table that
failed to load, while a ragged one reads as track titles. */
const SKELETON_WIDTHS = [88, 72, 94, 61, 80, 68, 90, 57, 84, 65, 92, 74];
/** One bar per track where the seed knew the count — the card that was
* clicked carries `track_count` — so the tracklist is close to its real
* height and whatever sits under it barely moves. Measured at 296px of
* skeleton against 377px of record before this. Clamped: a 40-track
* anthology should not paint 40 bars into a page nobody has scrolled. */
const SKELETON_ROWS = $derived.by(() => {
const n = catalog.albumSeed?.tracks;
const count = Math.min(14, Math.max(5, Number.isFinite(n) && n > 0 ? n : 8));
return Array.from({ length: count }, (_, i) => SKELETON_WIDTHS[i % SKELETON_WIDTHS.length]);
});
/** True when the skeleton already painted THIS record's artwork from the
* seed. The deal must then leave `.banner` and `.cover` alone: fading in
* the very picture that is already on screen is not an entrance, it is a
* blink. Everything else still deals normally, so the words arrive over
* artwork that never flickered. */
let seedShown = false;
/** The seed's artwork, kept after `catalog.albumSeed` clears. The loaded
* page asks for the MASTER cover — a different URL from the seed's
* grid-sized one — so on a cold cache its `<img>` has nothing to paint for
* a moment and the artwork blinks out. This stays underneath as a
* background until the master decodes, which turns a blink into an
* upgrade. On `.banner img` the element's own `filter: blur(42px)` applies
* to its background too, so the wash survives the same gap. */
let seedCover = $state(null);
$effect(() => {
if (!catalog.albumLoading) return;
seedShown = !!catalog.albumSeed?.cover;
seedCover = catalog.albumSeed?.cover ?? null;
});
const seedBg = $derived(seedCover ? `background-image: url("${seedCover}")` : "");
let pageEl = $state(null);
// Bumped once per record. Every element remembers the generation it was
// dealt in, so a second pass over the same record can tell "already
// animated" from "only just arrived" without re-animating the page.
let dealGeneration = 0;
const dealtEls = new WeakMap();
function dealParts() {
if (!pageEl || reducedMotion()) return;
for (const { sel, delay, lift } of SWAP_PARTS) {
// Marked as dealt rather than skipped, so a later pass picking up
// the network-gated parts cannot animate the artwork after all.
if (seedShown && (sel === ".banner" || sel === ".cover")) {
for (const el of pageEl.querySelectorAll(sel)) {
dealtEls.set(el, dealGeneration);
}
continue;
}
for (const el of pageEl.querySelectorAll(sel)) {
if (dealtEls.get(el) === dealGeneration) continue;
dealtEls.set(el, dealGeneration);
// To the element's OWN resting opacity, never to 1: the banner
// rests at 0.62, and animating it to 1 would run the colour
// wash at half again its brightness for the whole swap and then
// drop it the instant the animation released.
const rest = getComputedStyle(el).opacity || "1";
el.animate(
// Read `rest` first, then hide inline. A WAAPI animation is
// PENDING until the next frame resolves its start time, and a
// pending animation applies nothing — so `fill: backwards`
// never covered the very first paint. Every part rendered once
// at full opacity and then snapped to 0: the flash. An inline
// style takes effect in this same recalc, which closes it.
el.style.opacity = "0";
const anim = el.animate(
[
{ opacity: 0, transform: lift ? "translateY(7px)" : "none" },
{ opacity: rest, transform: "none" },
],
{ duration: SWAP_MS * 0.7, delay, easing: SWAP_EASE, fill: "backwards" },
// `both`, not `backwards`: the animation must keep holding
// the resting value at the end, or clearing the inline
// style below would race the last frame and flash again.
{ duration: SWAP_MS * 0.7, delay, easing: SWAP_EASE, fill: "both" },
);
// Hand the element back to the stylesheet: drop the inline
// hide first, and only then release the animation that is
// still holding the value it settles on.
const release = () => {
el.style.opacity = "";
anim.cancel();
};
anim.finished.then(release, release);
}
}
}
@@ -389,6 +460,10 @@
async function switchEdition(edition) {
if (edition.on || swapping) return;
swapping = true;
// A swap holds the page rather than showing a skeleton, so no seed is
// painted and this pressing's artwork is genuinely new. Clearing the
// flag lets the cover deal in, as it did before any of this existed.
seedShown = false;
try {
await preloadCover(coverUpgrades[edition.cover] ?? edition.cover);
await openCatalogAlbum(edition.id, { keepCurrent: true });
@@ -402,8 +477,24 @@
let dealtFor = null;
$effect(() => {
const id = album?.id ?? null;
if (id == null || id === dealtFor) return;
// `albumLoading` is part of the condition, not incidental to it.
// `openCatalogAlbum` assigns `albumPage` and only clears the loading
// flag in its `finally`, so for one flush `album.id` is the new record
// while the SKELETON is still the branch on screen — and the skeleton
// has a `.tracklist` of its own. Dealing then animated markup about to
// be thrown away, marked it done, and left the real page to the rAF a
// frame later: the blink this was supposed to remove.
if (catalog.albumLoading || id == null || id === dealtFor) return;
dealtFor = id;
dealGeneration++;
// Synchronously, NOT in a rAF. An effect already runs after the DOM is
// updated, so waiting a frame let the browser paint the new page at
// full opacity once before the animation's `fill: backwards` pulled it
// to zero — content appeared, blinked out, then faded in. Measured at
// one frame of `.tracklist` at opacity 1 followed by 110ms at 0.
// The rAF behind it is insurance for the case where a part is not
// mounted yet; anything the synchronous pass dealt is skipped.
dealParts();
requestAnimationFrame(dealParts);
});
@@ -429,6 +520,34 @@
};
});
/** Four parts are gated on data that arrives over the network: `.eds` on
* the sibling pressings, `.about`/`.more` on the editorial description,
* `.stats` on Last.fm, `.credits` partly on that same description. None of
* them are in the DOM one frame after the record changes, so the deal's
* `querySelectorAll` never saw them and they appeared at full opacity with
* no entrance at all. The description was the certain case rather than the
* unlucky one — `catalogAlbumInfo` is always a round trip, so it could
* never beat a single rAF.
*
* Deal again whenever one of those lands. `dealParts` skips anything this
* generation already animated, so a later pass can only ever pick up the
* parts that were missing from the first — expanding the description or
* re-rendering a row cannot replay the page. */
$effect(() => {
// Read the gates, so this re-runs as each one resolves.
void info;
void editions.length;
void albumPlays;
void albumListeners;
// Same skeleton guard as above: nothing here is worth animating while
// the placeholder is still the thing on screen.
if (dealtFor == null || catalog.albumLoading) return;
// Same reason as the deal above: a frame's wait is a frame of the part
// painted at full opacity before it is pulled to zero.
dealParts();
requestAnimationFrame(dealParts);
});
let saved = $state({});
function flagSaveError(key) {
saved[key] = "err";
@@ -509,7 +628,47 @@
<div class="album-page" style={accentStyle} bind:this={pageEl}>
{#if catalog.albumLoading}
<p class="hint">Loading album…</p>
<!-- The record's own shape, not a word about loading. It is built from
the SAME `.hero`/`.identity`/`.cover`/`.copy` elements the loaded
page uses, so the artwork does not move by a pixel when the fetch
lands — the skeleton is the page with its words removed.
`albumSeed` is what the card you clicked already knew, so the
cover and its blurred banner are on screen from the first frame
and the page has its colour before the request returns. Without a
seed (a history restore that never stored art) the artwork is a
shimmer block like everything else. -->
{@const seed = catalog.albumSeed}
<header class="hero" aria-busy="true" aria-label="Loading album">
<div class="banner" aria-hidden="true">
{#if seed?.cover}
<img src={seed.cover} alt="" draggable="false" />
{/if}
</div>
<div class="identity">
{#if seed?.cover}
<div class="cover"><img src={seed.cover} alt="" draggable="false" /></div>
{:else}
<div class="cover sk"></div>
{/if}
<div class="copy">
<span class="sk sk-kind"></span>
<span class="sk sk-title"></span>
<span class="sk sk-meta"></span>
<span class="sk sk-stat"></span>
<div class="actions">
<span class="sk sk-play"></span>
<span class="sk sk-round"></span>
<span class="sk sk-round"></span>
</div>
</div>
</div>
</header>
<div class="tracklist">
{#each SKELETON_ROWS as width, i (i)}
<span class="sk sk-trow" style="width: {width}%"></span>
{/each}
</div>
{:else if !page}
<p class="hint">{catalog.error || "Album not found."}</p>
{:else}
@@ -520,7 +679,7 @@
<header class="hero">
<div class="banner" aria-hidden="true">
{#if coverOk}
<img src={cover} alt="" draggable="false" />
<img src={cover} alt="" draggable="false" style={seedBg} />
{/if}
</div>
<div class="identity">
@@ -530,6 +689,7 @@
title="View artwork"
aria-label="View artwork full screen"
onclick={openArtwork}
style={seedBg}
>
<img
src={cover}
@@ -647,14 +807,6 @@
</div>
{/if}
{#if info?.description}
<p class="about" class:clamped={!descExpanded}>{info.description}</p>
<button class="more" class:open={descExpanded} onclick={() => (descExpanded = !descExpanded)}>
{descExpanded ? "Show less" : "Show more"}
<Icon name="chevron-down" size={13} />
</button>
{/if}
<div class="tracklist">
{#if tracks.length}
<div class="tcols" class:has-count={showCount}>
@@ -743,16 +895,52 @@
{/each}
</div>
<!-- BELOW the tracklist, deliberately. Above it, the About block pushed
the tracks ~180px down: you landed on a record and read prose before
you could see what was on it. Down here the tracklist starts
immediately and the prose is there for whoever scrolls.
It also removes a real bug rather than only a preference. The
description is a network round trip, so arriving ABOVE the tracks
re-flowed them on landing — measured at 81.5px. Nothing above this
point depends on `info`, so the tracklist can no longer move.
Headed in this page's OWN `.disc-head` idiom, and the lede is the
description's first sentence promoted by the shared splitter — the
same one the artist page's expander and the Now Playing panel use.
Nothing here is written. -->
{#if info?.description}
<div class="about-head"><span>About this record</span></div>
{#if descParts.lede}
<p class="lede">{descParts.lede}</p>
{/if}
{#if descParts.rest}
<p class="about" class:clamped={!descExpanded}>{descParts.rest}</p>
<button class="more" class:open={descExpanded} onclick={() => (descExpanded = !descExpanded)}>
{descExpanded ? "Show less" : "Show more"}
<Icon name="chevron-down" size={13} />
</button>
{/if}
{/if}
{#if releaseDate(album) || info?.label || info?.genre}
<div class="credits">
<span class="lab">Released</span>
{#if releaseDate(album)}{releaseDate(album)}{/if}
{#if nTracks}· {nTracks}{/if}
{#if runtime}· {runtime}{/if}
{#if info?.label || info?.genre}
<br />{info.label ?? ""}{info.label && info.genre ? " · " : ""}{info.genre ?? ""}
{/if}
<!-- The grey label/value block becomes the same pills the panel and
the expander use, plus who said so — the page asserts a date, a
label and counts from three different providers and used to
name none of them. -->
<div class="about-head"><span>Release</span></div>
<div class="pills">
{#if info?.genre}<span class="gpill accent">{info.genre}</span>{/if}
{#if info?.label}<span class="gpill">{info.label}</span>{/if}
{#if releaseDate(album)}<span class="gpill">{releaseDate(album)}</span>{/if}
{#if nTracks}<span class="gpill">{nTracks}</span>{/if}
{#if runtime}<span class="gpill">{runtime}</span>{/if}
</div>
<p class="srcs">
{#if info?.description}<b>Words</b> Wikipedia · {/if}
{#if albumPlays || albumListeners}<b>Counts</b> Last.fm · {/if}
<b>Catalogue</b> Deezer + MusicBrainz
</p>
{/if}
{/if}
</div>
@@ -772,6 +960,61 @@
text-align: center;
}
/* ------------------------------------------------------- skeleton ---- */
/* Sized in `em` inside `.copy`, whose font-size is 14px * --cover-scale,
so every bar tracks the artwork the way the real type does. */
.sk {
display: block;
/* Translucent, not the flat token: in the hero these blocks sit over
`.banner`, so the record tints them and the placeholder belongs to
the album rather than sitting on top of it. Expressed against
--bg-highlight rather than a literal rgba so it still tracks the
theme. Below the hero there is nothing behind them but --bg-base,
where the same alpha simply reads as a quieter block. */
background: color-mix(in srgb, var(--bg-highlight) 45%, transparent);
border-radius: 6px;
position: relative;
overflow: hidden;
}
.sk::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.07),
transparent
);
animation: skeleton-sweep 1649ms infinite;
}
@keyframes skeleton-sweep {
100% { transform: translateX(100%); }
}
/* A sweep on a dozen blocks is motion for its own sake when the reason to
reduce it is motion sensitivity. The blocks stay; only the sweep goes. */
@media (prefers-reduced-motion: reduce) {
.sk::after { animation: none; }
}
/* Only the artwork placeholder — a seeded cover is a real `.cover`. */
.cover.sk { border-radius: 10px; }
/* These heights are not taste, they are the measured heights of the rows
they stand in for. `.identity` is bottom-anchored and its height is set
by whichever is taller, the artwork or this column — and the column wins
by a fraction. So any error here moves the ENTIRE identity block when the
words replace the bars. The bars summed 164.46 against the real 163.58
and the hero dropped 0.87px on every load; `.sk-play` alone was 14px too
tall, against a pill the stylesheet already documents as 2em.
If a row's real height changes, re-measure rather than eyeball. */
.sk-kind { width: 5.4em; height: 0.973em; border-radius: 3px; } /* 16.00px */
.sk-title { width: min(62%, 20em); height: 3.18em; border-radius: 8px; } /* 52.30 */
.sk-meta { width: min(48%, 15em); height: 1.571em; } /* 25.84px */
.sk-stat { width: 9.2em; height: 2.222em; } /* 36.55px */
.sk-play { width: 6.4em; height: 2em; border-radius: 999px; } /* 32.89px */
.sk-round { width: 2em; height: 2em; border-radius: 50%; }
.sk-trow { height: 33px; border-radius: 6px; margin-bottom: 4px; }
/* ---------------------------------------------------- hero banner ---- */
.hero {
/* The one number to tune. 1 = a 240px cover; everything in the hero is
@@ -806,6 +1049,11 @@
width: 100%;
height: 100%;
object-fit: cover;
/* The seed's copy, showing only until the master decodes — see
`seedBg`. The filter below applies to it as well, so the wash never
drops out mid-swap. */
background-size: cover;
background-position: center;
filter: blur(42px) saturate(1.45);
transform: scale(1.25);
opacity: 0.62;
@@ -848,6 +1096,10 @@
padding: 0;
border-radius: 10px;
overflow: hidden;
/* The seed's copy underneath, so swapping to the master resolution is
an upgrade rather than a gap. Painted over the moment it decodes. */
background-size: cover;
background-position: center;
flex: none;
cursor: pointer;
box-shadow: 0 22px 46px -18px rgba(0, 0, 0, 0.8);
@@ -1197,6 +1449,62 @@
}
/* --------------------------------------------------- description ---- */
/* `.disc-head` verbatim, minus its left padding: that one is indented to
line up with the tracklist's rows, and these blocks are full-bleed. */
.about-head {
display: flex;
align-items: center;
gap: 8px;
padding: 20px 0 6px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.about-head::after {
content: "";
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.07);
}
/* The description's own first sentence, promoted — see `splitDescription`. */
.lede {
margin: 4px 0 0;
max-width: 820px;
font-size: 15px;
font-weight: 600;
line-height: 1.5;
color: rgba(255, 255, 255, 0.9);
}
.pills {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 4px;
}
.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;
}
.srcs {
margin-top: 14px;
font-size: 9.5px;
line-height: 1.6;
color: #5e5e5e;
}
.srcs b { color: #7a7a7a; font-weight: 700; }
.about {
margin: 20px 0 0;
max-width: 820px;
@@ -1451,24 +1759,6 @@
color: var(--accent-hover);
}
/* -------------------------------------------------------- credits ---- */
.credits {
margin-top: 26px;
padding: 0 10px;
color: var(--text-muted);
font-size: 11.5px;
line-height: 1.7;
}
.credits .lab {
display: block;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #5e5e5e;
margin-bottom: 4px;
}
@media (max-width: 820px) {
.hero {
--cover-scale: 0.62;