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 @@
{#if catalog.albumLoading} -

Loading album…

+ + {@const seed = catalog.albumSeed} +
+ +
+ {#if seed?.cover} +
+ {:else} +
+ {/if} +
+ + + + +
+ + + +
+
+
+
+
+ {#each SKELETON_ROWS as width, i (i)} + + {/each} +
{:else if !page}

{catalog.error || "Album not found."}

{:else} @@ -520,7 +679,7 @@
@@ -530,6 +689,7 @@ title="View artwork" aria-label="View artwork full screen" onclick={openArtwork} + style={seedBg} > {/if} - {#if info?.description} -

{info.description}

- - {/if} -
{#if tracks.length}
@@ -743,16 +895,52 @@ {/each}
+ + {#if info?.description} +
About this record
+ {#if descParts.lede} +

{descParts.lede}

+ {/if} + {#if descParts.rest} +

{descParts.rest}

+ + {/if} + {/if} + {#if releaseDate(album) || info?.label || info?.genre} -
- Released - {#if releaseDate(album)}{releaseDate(album)}{/if} - {#if nTracks}· {nTracks}{/if} - {#if runtime}· {runtime}{/if} - {#if info?.label || info?.genre} -
{info.label ?? ""}{info.label && info.genre ? " · " : ""}{info.genre ?? ""} - {/if} + +
Release
+
+ {#if info?.genre}{info.genre}{/if} + {#if info?.label}{info.label}{/if} + {#if releaseDate(album)}{releaseDate(album)}{/if} + {#if nTracks}{nTracks}{/if} + {#if runtime}{runtime}{/if}
+

+ {#if info?.description}Words Wikipedia · {/if} + {#if albumPlays || albumListeners}Counts Last.fm · {/if} + Catalogue Deezer + MusicBrainz +

{/if} {/if}
@@ -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;