From a6b585303ec8a587f95af47bc818625542120730 Mon Sep 17 00:00:00 2001
From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com>
Date: Wed, 29 Jul 2026 18:46:16 -0500
Subject: [PATCH] album page: the record's own shape while it loads, and prose
that waits its turn
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src/views/CatalogAlbum.svelte | 370 ++++++++++++++++++++++++++++++----
1 file changed, 330 insertions(+), 40 deletions(-)
diff --git a/src/views/CatalogAlbum.svelte b/src/views/CatalogAlbum.svelte
index e2e08fa..63f3411 100644
--- a/src/views/CatalogAlbum.svelte
+++ b/src/views/CatalogAlbum.svelte
@@ -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 `` 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 @@
Loading album…
+ + {@const seed = catalog.albumSeed} +{catalog.error || "Album not found."}
{:else} @@ -520,7 +679,7 @@{info.description}
- - {/if} -{descParts.lede}
+ {/if} + {#if descParts.rest} +{descParts.rest}
+ + {/if} + {/if} + {#if releaseDate(album) || info?.label || info?.genre} -+ {#if info?.description}Words Wikipedia · {/if} + {#if albumPlays || albumListeners}Counts Last.fm · {/if} + Catalogue Deezer + MusicBrainz +
{/if} {/if}