discography: the whole catalogue on a page of its own

"Show all" beside the artist page's Discography head opens every release
as a stack of album-page heroes with their tracklists — the shapes Kyle
picked off the mock: the search page's top-result card as a compact
header, the album hero at --cover-scale 0.45 per record, a per-record
accent sampled from its own cover, the year rail and the tab summary. The
tab row does not stick and there is no Playing badge. Records the artist
page has not fetched are shown rather than hidden, and the entrance is
the W1 stagger — 300ms in, 55ms per row, 28px of lift, eight rows.

It does not re-file the releases. catalog.discoTabs is the artist page's
OWN computed tab list, published as it derives it and republished as the
evidence improves. It has to work this way: the filing depends on the
background description harvest that runs while you look at the artist
page and on the MusicBrainz type hints from the deferred identity pass,
neither of which is derivable from the payload — so a second classifier
would read neither and would quietly file some releases differently from
the page that linked to it. One list, computed once, handed over. If the
tabs in hand belong to somebody else, opening a discography lands on the
artist page instead, which republishes them and puts "Show all" one click
away; an empty discography would be a lie about the catalogue.

The page's CSS was extracted from src/ rather than retyped — CatalogAlbum's
record section, CatalogSearch's header card and PlayerBar's menu, pulled
through the same rule-matcher the mockup generator uses — so it inherits
the app's measurements instead of approximating them. Verified against
the running app: artwork 108px, record hero 114.3, header 147.5, header
top 78, bar offset 0, ambience banner 0, rail centring error 0 and 12.8px
of clearance at every scroll depth and pane width. Pond files 12+16+2+2 =
32 families on both pages.

It is the one page exempt from BAR_VIEWS' padding-top rule, deliberately:
its blurred ambience layer has to begin at the very top of the pane, and
a padding there would start it halfway through the glass bar. It takes
the inset on its header's margin instead, which lands in the same place.
This commit is contained in:
KeelrLeek committed 2026-08-04 14:44:13 -05:00
1 parent 819a2757bc
commit 34313e6035
5 files changed
+1709 -3

No files matched your search

+15 -2
View File
@@ -19,6 +19,7 @@
import CatalogSearch from "./views/CatalogSearch.svelte";
import CatalogArtist from "./views/CatalogArtist.svelte";
import CatalogAlbum from "./views/CatalogAlbum.svelte";
import CatalogDiscography from "./views/CatalogDiscography.svelte";
import FollowToast from "./components/FollowToast.svelte";
import {
ui,
@@ -42,6 +43,7 @@
catalog: CatalogSearch,
catalogArtist: CatalogArtist,
catalogAlbum: CatalogAlbum,
catalogDiscography: CatalogDiscography,
};
const CurrentView = $derived(views[ui.view] ?? Home);
@@ -54,8 +56,19 @@
A page added here MUST also take `padding-top: var(--pane-pad)`: the bar
cancels the pane's top padding so a page's artwork can run underneath it,
and the page restores that inset for its own content. Without it the page
renders 24px too high; with it but not listed here, 24px too low. */
const BAR_VIEWS = new Set(["catalogArtist", "catalogAlbum", "catalog"]);
renders 24px too high; with it but not listed here, 24px too low.
`catalogDiscography` is the one exception to the padding rule above, and
deliberately: it pins a blurred ambience layer behind the glass, and that
layer has to begin at the very top of the pane. A padding-top here would
start it a --pane-pad down — halfway through the bar. It takes the inset
on its header's margin instead, which comes to the same place. */
const BAR_VIEWS = new Set([
"catalogArtist",
"catalogAlbum",
"catalog",
"catalogDiscography",
]);
initEvents();
autoConnect();
+7 -1
View File
@@ -35,6 +35,8 @@ function entryKey(e) {
switch (e.view) {
case "catalogArtist":
return `artist:${e.artistId}`;
case "catalogDiscography":
return `disco:${e.artistId}`;
case "catalogAlbum":
return `album:${e.albumId}`;
case "catalog":
@@ -227,7 +229,11 @@ export function trail() {
// artist crumb is taken from history when it is there, so it carries the
// portrait and id already resolved, and is synthesised from the record's
// own artist name when the album was reached directly.
if (here.view === "catalogAlbum" && here.artist) {
// A discography sits under its artist too, so both branches want the same
// crumb — found in history when it is there (it carries the portrait and id
// already resolved) and synthesised from the record's own artist name when
// the album was reached directly.
if ((here.view === "catalogAlbum" || here.view === "catalogDiscography") && here.artist) {
const known = [...nav.stack]
.reverse()
.find((e) => e.view === "catalogArtist" && e.label === here.artist);
+40
View File
@@ -966,6 +966,17 @@ export const catalog = $state({
// artistPage (same id, new object, several times a second) from a real one
// — keying on that would drag a reader back to the top mid-read.
artistOpenSeq: 0,
// The artist page's OWN computed discography tabs, published by it as it
// derives them: `{ artistId, tabs }`. The discography page renders these
// rather than classifying the release list a second time.
//
// It has to work this way. The filing depends on `descHints` — editorial
// release-type evidence the artist page harvests in the background while
// you look at it — and on the MusicBrainz type hints from the deferred
// identity pass. Neither is derivable from the payload alone, so a second
// classifier would read neither and would file some releases differently
// from the page that linked to it. One list, computed once, handed over.
discoTabs: null,
albumLoading: false,
albumPage: null, // { album, tracks }
// What the thing you clicked already knew about the record — cover, title,
@@ -1426,6 +1437,10 @@ export async function openEntry(entry) {
switch (entry.view) {
case "catalogArtist":
return openCatalogArtist(entry.artistId);
case "catalogDiscography":
// Falls back to the artist page by itself when the tabs it needs
// are not the ones in hand — see openCatalogDiscography.
return openCatalogDiscography(entry.artistId);
case "catalogAlbum":
// A local record lives on this same view but is composed from the
// library rather than fetched — the back arrow has to route on the
@@ -2661,6 +2676,31 @@ export async function openCatalogArtist(artistId, seedArtist = null) {
}
}
/**
* Open the artist's whole discography on a page of its own.
*
* Reached from the artist page's Discography section, and only from there —
* the tabs it renders are the ones that page published (see `discoTabs`). If
* they are missing or belong to somebody else, this lands on the artist page
* instead, which republishes them and puts "Show all" one click away. That is
* the honest failure: an empty discography would be a lie about the catalogue.
*/
export async function openCatalogDiscography(artistId, seedArtist = null) {
const artist = seedArtist ?? catalog.artistPage?.artist ?? null;
if (!catalog.discoTabs || catalog.discoTabs.artistId !== artistId) {
return openCatalogArtist(artistId, artist);
}
captureScroll();
ui.view = "catalogDiscography";
recordVisit({
view: "catalogDiscography",
label: "Discography",
artistId,
artist: artist?.name ?? null,
glyph: "library",
});
}
/** Open an album's catalog page (cover + tracklist). `opts.trackId` flags a
* track to highlight/scroll to (used by the now-playing song link). */
export async function openCatalogAlbum(albumId, opts = {}) {
+37
View File
@@ -31,6 +31,7 @@
albumDisambiguator,
openCatalogAlbum,
openAlbumFromPreviewItem,
openCatalogDiscography,
loadFollows,
isFollowed,
setFollowed,
@@ -2326,6 +2327,16 @@
.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
@@ -5052,6 +5063,15 @@
{#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)}
@@ -7697,6 +7717,23 @@
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. */
File diff suppressed because it is too large. Load diff