Files
SoulStream/src/views/Library.svelte
T
KeelrLeek 547cee6649 dark glass gets a surface ladder, and the 200-750ms stalls are dead - two sessions of uncommitted work landing together
SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands
on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a
translucent black card nets 0.81 - darker than its own pane and inside the
~0.85 band already rejected once on the artist page. Solid mode's ladder is
measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new
--glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass
(0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary
.btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are
NEVER redefined - they are consumed inside color-mix() and gradient stops where
an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible
chip; the album row's text-overflow fade works by BEING opaque), and they paint
modal chrome that has to stay solid. 129 additive :global(:root.glass)
declarations across 18 files, all 129 verified inside a glass block by walking
the diff - solid mode byte-identical, confirmed structurally, by computed style
with the class off, and visually.

Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING
the last ~19px of the listeners bar and nothing translucent can occlude, so
.tcount masks out just before the buttons instead; Playlist's banner scrim and
the discography's pinned ambience both landed on opaque --bg-base and now
dissolve like the album banner; and the 0.9 black hero tint on
Home/CatalogSearch/Discography paints straight onto the pane when there is no
artwork, so :first-child (the {#if} renders no element) flips it to white.
Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and
the Settings copy for the mode now says so instead of claiming cards do.
Measured with a known ground substituted behind the transparent window, since
CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a
white fill AND its backdrop-filter dropped, +30 for the bare pane beside it.

RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live
resize and the window stays transparent through it, so the desktop punches
through sharp - measured by toggling window_glass alone, desktop icon labels
readable through the titlebar. The blur cannot be kept (documented since Win10
2004, and the modern DWM backdrop was already rejected for the
overlay-promotion blink), so armGlassResizeGuard grounds the canvas to
--bg-sidebar for the length of the gesture.

STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not
frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin
bake moved to display resolution and off the ready task (was a same-task 4K
WebGL readback plus software blur, 100-170ms per rotation AND per artist
entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the
whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded;
one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so
all three periscopes stop cloning mid-gesture on ANY pane rather than only
their own; bounded reclone with the querySelectorAll census deleted, the gate
widened 250->1000ms, and the build/attach split across an idle callback and a
rAF behind a generation guard; the hero rotation tick gated on scroll recency
and its payload cache sized to the working set (2->4 entries, 24->32MiB,
32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED.
Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were
measured innocent - do not re-chase them.

98/98 tests throughout.
2026-08-27 17:24:20 -05:00

3656 lines
157 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
// The Library — the power user's side of the app, where a collection gets
// kept, searched and played. Rebuilt 2026-08-05 from the approved prototype
// (research/library/, HANDOFF-2026-08-05-library-redesign.md §3-4d) with
// Kyle's picked settings baked in rather than offered as variants:
//
// uncatalogued Equal — a record is a record; nothing marks provenance
// no artwork Generated — deterministic art from the record's own name
// gaps Summary — one line per shelf, opening into full dimmed cards
// within artist Split by type — Albums/EPs/Singles under their own heads
// toolbar Card — a floating rounded card, pinned under the glass bar
// index rail On — every grouping, every view, gutter always reserved
// list header Joined to the bar — the card's own last row, one pin
// entrance Wave — the records arrive; the chrome never moves
// cards 150px — the artist page's own card width
//
// Every shape here is the app's: the release card and grid are the artist
// page's, the tab row and sort menu the discography's, the inline expander
// is CatalogArtist's class for class with its animation numbers unchanged.
// Styles are copied because Svelte scopes them per component — the same
// trade CatalogDiscography made, with provenance comments at each block.
import { tick } from "svelte";
import { fade } from "svelte/transition";
import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
import * as api from "../lib/api.js";
import {
libraryStore,
refreshLibrary,
playAlbum,
player,
ui,
getArt,
getCatalogArt,
getCatalogArtistPage,
openCatalogArtist,
openCatalogAlbum,
openAlbumByName,
RECENTS_FLOOR_SECS,
recentsStore,
importResume,
goToView,
} from "../lib/state.svelte.js";
import {
fileRelease,
CAT_ORDER,
CAT_LABEL,
CAT_ONE,
} from "../lib/localReleaseType.js";
import { accentFromImage } from "../lib/color.js";
// The same splitter the album page, the Now Playing panel and the artist
// page's expander use. A fourth copy is how they would start disagreeing.
import { splitDescription } from "../lib/editions.js";
// ================================================================ state
// The page's shape survives a restart — the same courtesy the player bar
// extends to its expanded state. View state only; never data.
const VIEW_KEY = "library.view.v1";
const savedView = (() => {
try { return JSON.parse(localStorage.getItem(VIEW_KEY)) ?? {}; } catch { return {}; }
})();
let view = $state(savedView.view ?? "shelves"); // shelves | grid | list
let group = $state(savedView.group ?? "artist"); // artist|decade|format|rtype|added|none
let sortKey = $state(savedView.sort ?? "artist");
let desc = $state(savedView.desc ?? true);
$effect(() => {
const snapshot = JSON.stringify({ view, group, sort: sortKey, desc });
try { localStorage.setItem(VIEW_KEY, snapshot); } catch { /* full/blocked */ }
});
let q = $state("");
let menuOpen = $state(false);
let expandedShelves = $state({}); // shelf name -> true (past the 18-card cap)
let rescanning = $state(false);
// Per-album aggregates of what the files carry — length, bytes, bitrate,
// formats, last played. One backend call for the whole library.
let statsById = $state({});
let statsLoaded = $state(false);
async function loadStats() {
try {
const rows = await api.libraryAlbumStats(RECENTS_FLOOR_SECS);
statsById = Object.fromEntries(rows.map((s) => [s.album_id, s]));
statsLoaded = true;
} catch {
// Backend unavailable (dev in a browser): the page still renders,
// the stat row and file columns just stay quiet.
statsLoaded = true;
}
}
// ---- the catalogue side of Summary -----------------------------------
// "N of M released" and the gap line exist only where the app knows the
// artist's catalogue by an ANCHORED id — a followed artist. The join from
// a shelf's tag-derived name to that id is by normalized name, which is
// the same join the artist page's own "in your library" ticks already
// make (catalogTrackOwnership); identity of RECORDS is never guessed.
let followsByName = $state({}); // normKey(name) -> { artistId, name }
/** "Same artist" for filing purposes, and it is not a new rule: the library
* already groups artist portraits with `GROUP BY artist COLLATE NOCASE`
* (crates/library/src/lib.rs:321), so the shelves fold exactly as wide as
* the backend does. Measured on this library, case alone splits 5 artists
* (King Gizzard "& The"/"& the", Lorn/LORN, Mac DeMarco/Demarco, Boards
* Of/of Canada, My Bloody Valentine) and the stricter punctuation-stripping
* normKey merges nothing beyond those — so the wider key would buy no shelf
* and only risk fusing two artists whose names differ by a mark. */
const foldKey = (s) => (s ?? "").toLowerCase();
// foldKey(artist) -> { total } — persisted so counts survive a restart and
// the shelf head can say "9 of 29" without a fetch. Rows are session-only.
const GAPS_KEY = "library.gaps.v1";
let gapTotals = $state((() => {
try {
const saved = JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {};
// Written under the raw shelf name before shelves folded by case,
// so a total banked as "King Gizzard & the Lizard Wizard" is still
// found now the shelf is titled "& The". Folding on load rather
// than at every read keeps one shape in memory.
return Object.fromEntries(Object.entries(saved).map(([k, v]) => [foldKey(k), v]));
} catch { return {}; }
})());
let gapRows = $state({}); // foldKey(artist) -> catalog rows not owned
let gapOpen = $state({}); // foldKey(artist) -> line expanded
let gapLoading = $state({});
const normKey = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
async function loadFollowNames() {
try {
const list = await api.followedArtists();
followsByName = Object.fromEntries(
list.map((f) => [normKey(f.name), { artistId: f.artist_id, name: f.name }]),
);
} catch { /* backend unavailable */ }
}
// Artist ids resolved by clicking an unfollowed shelf head, kept so the
// resolution happens once per artist and the counts survive a restart.
// Deliberately separate from follows: resolving is not following.
const IDS_KEY = "library.artistIds.v1";
let resolvedByName = $state((() => {
try { return JSON.parse(localStorage.getItem(IDS_KEY)) ?? {}; } catch { return {}; }
})());
function rememberArtistId(name, artistId) {
resolvedByName = { ...resolvedByName, [normKey(name)]: { artistId, name } };
try { localStorage.setItem(IDS_KEY, JSON.stringify(resolvedByName)); } catch { /* ignore */ }
}
/** The anchored provider id for a shelf, or null. A follow is one source;
* a click-resolved id is the other. Both are anchors the USER established
* — following, or picking. Neither is a silent name→provider guess, which
* is what fuses namesakes (one Wand id holding seven different bands). */
function followFor(artistName) {
return followsByName[normKey(artistName)]
?? resolvedByName[normKey(artistName)]
?? null;
}
async function toggleGaps(artistName) {
// Keyed by the FOLD, not by the shelf's title: the total was the one
// thing still filed under whichever spelling happened to be clicked,
// which is why only one of two King Gizzard shelves read "8 of 42".
const key = foldKey(artistName);
if (gapOpen[key]) {
gapOpen = { ...gapOpen, [key]: false };
return;
}
const follow = followFor(artistName);
if (!follow) return;
if (!gapRows[key] && !gapLoading[key]) {
gapLoading = { ...gapLoading, [key]: true };
try {
const page = await getCatalogArtistPage(follow.artistId);
const released = (page?.albums ?? []).filter((a) => !isUpcomingRow(a));
const ownedTitles = new Set(
libraryStore.albums
.filter((a) => normKey(a.artist) === normKey(artistName))
.map((a) => normKey(a.title)),
);
gapRows = {
...gapRows,
[key]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
};
gapTotals = { ...gapTotals, [key]: { total: released.length } };
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
} catch { /* the line stays collapsed; nothing is invented */ }
gapLoading = { ...gapLoading, [key]: false };
}
if (gapRows[key]?.length || gapLoading[key] === false) {
gapOpen = { ...gapOpen, [key]: true };
}
}
// ---- opening a shelf's artist ----------------------------------------
// Every artist shelf is clickable. Where an id is already anchored (a
// follow, or an earlier resolution) it opens immediately; otherwise the
// click — and only the click — searches the catalogue for the name.
//
// "Confident" is deliberately NOT "take the top hit" — picking the most
// popular same-named artist is how a library ends up filed under the wrong
// band. But it is not "exactly one exact match" either: measured against
// this catalogue, GUM returns 3 exact matches at 3707 / 90 / 20 fans, Ween
// 3 at 18808 / 32 / 1, Panda Bear 3 at 27536 / 6 / 1. Those are Deezer's
// near-empty duplicate entries, and asking which Ween you meant when the
// alternative has ONE fan is noise, not a question.
//
// So the tie-break is the app's OWN namesake rule, not a new one: metadata's
// `deezer_exact_name_state` (crates/metadata/src/lib.rs:11626) calls a name
// owned when the leader has BING_NAME_OWNER_FAN_RATIO times the top rival
// and clears BING_NAME_OWNER_MIN_FANS itself. Those constants were tuned on
// exactly this shape — "exact-name rows are dominated by dead duplicates and
// typo entries (crazy_p, Pond x6)". Anything short of that asks.
//
// Corroboration on real data: this picks 13456 for Panda Bear, which is the
// id the existing follow already holds, and 1156937 for GUM, whose 90-fan
// rival is the empty-md5 placeholder entry.
const AMBIGUITY_RATIO = 4; // BING_NAME_OWNER_FAN_RATIO
const AMBIGUITY_FLOOR = 25; // BING_NAME_OWNER_MIN_FANS, on the LEADER
function chooseArtist(name, artists) {
const exact = artists.filter((a) => normKey(a.name) === normKey(name));
if (exact.length === 1) return { pick: exact[0] };
if (exact.length > 1) {
// Search already arrives ranked by fans; sort defensively anyway.
const byFans = [...exact].sort((x, y) => (y.fans ?? 0) - (x.fans ?? 0));
const lead = byFans[0].fans ?? 0;
const rival = byFans[1].fans ?? 0;
const ownsTheName = lead > 0
&& (rival === 0
|| (lead >= rival * AMBIGUITY_RATIO && lead >= AMBIGUITY_FLOOR));
if (ownsTheName) return { pick: byFans[0] };
return { options: byFans };
}
// Nothing matched the name exactly. The near-misses are worth offering
// (a tag that reads "POLYSICS!"), but never auto-selected.
return { options: artists };
}
let picker = $state(null); // { name, options: [...], searching } | null
let resolving = $state({}); // artist name -> true while searching
function openArtistPage(artistId, name) {
openCatalogArtist(artistId, { id: artistId, name });
}
async function openShelfArtist(name) {
const anchored = followFor(name);
if (anchored) { openArtistPage(anchored.artistId, name); return; }
if (resolving[name]) return;
resolving = { ...resolving, [name]: true };
try {
const res = await api.catalogSearch(name);
const verdict = chooseArtist(name, res?.artists ?? []);
if (verdict.pick) {
rememberArtistId(name, verdict.pick.id);
openArtistPage(verdict.pick.id, name);
} else {
picker = { name, options: verdict.options.slice(0, 8) };
}
} catch (err) {
// Never swallowed silently: a swallowed network failure is what
// made "descriptions take minutes" look like slowness for a week.
console.warn("[library] artist resolve failed for", name, err);
// A failed search is NOT "no such artist" — the catalogue never
// answered. Saying "no match" here is the same mistake as reading
// `0 groups` as "this artist has no releases" when the request
// simply died: it blames the data for a transport problem, and it
// invites the user to conclude their tag is wrong. Offer a retry.
picker = { name, options: [], failed: true };
}
resolving = { ...resolving, [name]: false };
}
// CatalogSearch's own fans formatter, verbatim — the picker's rows should
// read the way search results read.
const fmtFans = (n) => {
if (n == null) return null;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
return String(n);
};
function pickArtist(option) {
const name = picker?.name;
picker = null;
if (!name || !option) return;
rememberArtistId(name, option.id);
openArtistPage(option.id, name);
}
// A future or unresolved release date is not "released" — same rule as the
// artist page's Upcoming tab (CatalogArtist `isFutureOrUnresolvedDate`).
const todayKey = (() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
})();
function isUpcomingRow(a) {
const value = a.release_date;
if (!value) return false;
if (value.length >= 10) return value.slice(0, 10) > todayKey;
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;
}
// ================================================================ rows
const rows = $derived.by(() => {
return libraryStore.albums.map((a) => {
const s = statsById[a.id];
const row = {
...a,
secs: s?.secs ?? 0,
bytes: s?.bytes ?? 0,
avgKbps: s?.avg_kbps ?? null,
depth: s?.bit_depth && s?.sample_rate
? `${s.bit_depth}/${s.sample_rate % 1000 === 0 ? s.sample_rate / 1000 : (s.sample_rate / 1000).toFixed(1)}`
: null,
formats: s?.formats ?? [],
lastPlayedAt: s?.last_played_at ?? null,
sortName: (a.artist || "").replace(/^(the|a|an)\s+/i, ""),
};
// The app's own filing (localReleaseType.js: CatalogArtist's title
// heuristics + the approved track-count/length floor).
row.cat = fileRelease(row);
return row;
});
});
const corpus = $derived.by(() => {
if (!q) return rows;
const needle = q.toLowerCase();
return rows.filter((a) => `${a.artist} ${a.title}`.toLowerCase().includes(needle));
});
// ---- groupings --------------------------------------------------------
const GROUPS = {
// `of` names the shelf, `keyOf` decides which shelf a record lands on.
// They differ for Artist alone: two spellings of one name are one
// artist, so the key folds and shelfName() picks the title.
artist: { label: "Artist", of: (a) => a.artist || "Unknown Artist", keyOf: (a) => foldKey(a.artist || "Unknown Artist") },
decade: { label: "Decade", of: (a) => (a.year ? `${Math.floor(a.year / 10) * 10}s` : "No year") },
format: {
label: "Format",
of: (a) => ((a.formats[0] || "—").toUpperCase() + (a.depth ? ` ${a.depth}` : "")),
},
rtype: { label: "Type", of: (a) => CAT_LABEL[a.cat] },
added: {
label: "Added",
of: (a) => (a.added_at
? new Date(a.added_at * 1000).toLocaleDateString(undefined, { year: "numeric", month: "long" })
: "—"),
},
none: { label: "None", of: () => "" },
};
// ---- sorts ------------------------------------------------------------
const cmpName = (x, y) => x.sortName.localeCompare(y.sortName) || (x.title ?? "").localeCompare(y.title ?? "");
const SORTS = {
artist: { label: "Artist", cmp: cmpName, desc: false },
title: {
label: "Title", desc: false,
cmp: (x, y) => (x.title ?? "").replace(/^(the|a|an)\s+/i, "").localeCompare((y.title ?? "").replace(/^(the|a|an)\s+/i, "")),
},
year: { label: "Release year", cmp: (x, y) => (y.year ?? -1) - (x.year ?? -1) || cmpName(x, y) },
added: { label: "Recently added", cmp: (x, y) => (y.added_at ?? 0) - (x.added_at ?? 0) },
played: { label: "Last played", cmp: (x, y) => (y.lastPlayedAt ?? 0) - (x.lastPlayedAt ?? 0) },
neglect: {
label: "Never played first",
cmp: (x, y) => (x.lastPlayedAt ?? 0) - (y.lastPlayedAt ?? 0) || cmpName(x, y),
desc: false,
},
length: { label: "Length", cmp: (x, y) => y.secs - x.secs },
tracks: { label: "Track count", cmp: (x, y) => (y.track_count ?? 0) - (x.track_count ?? 0) },
size: { label: "Size on disk", cmp: (x, y) => y.bytes - x.bytes },
bitrate: { label: "Bitrate", cmp: (x, y) => (y.avgKbps ?? 0) - (x.avgKbps ?? 0) },
format: {
label: "Format", desc: false,
cmp: (x, y) => (x.formats[0] ?? "~").localeCompare(y.formats[0] ?? "~") || cmpName(x, y),
},
rtype: {
label: "Release type", desc: false,
cmp: (x, y) => CAT_ORDER.indexOf(x.cat) - CAT_ORDER.indexOf(y.cat) || cmpName(x, y),
},
complete: {
label: "Least complete", desc: false,
cmp: (x, y) => completeness(x.artist) - completeness(y.artist) || cmpName(x, y),
},
};
function completeness(artist) {
const total = gapTotals[foldKey(artist)]?.total || 0;
if (!total) return 1;
const own = rows.filter((a) => foldKey(a.artist) === foldKey(artist)).length;
return Math.min(1, own / total);
}
/** The one spelling a folded shelf is filed under.
*
* An anchored follow's own name wins, because that is the catalogue's
* spelling and the same anchor the rest of the page already trusts. With
* no anchor there is nothing authoritative to appeal to, so the records
* vote: the spelling on the most albums, ties broken by sort order so a
* shelf never renames itself between loads.
*
* A shelf with ONE spelling keeps it untouched — including when a follow
* disagrees. Renaming a shelf that was never split is a change nobody
* asked for, and this only exists to reunite the ones that were. */
function shelfName(list) {
const counts = new Map();
for (const a of list) {
const n = a.artist || "Unknown Artist";
counts.set(n, (counts.get(n) || 0) + 1);
}
const spellings = [...counts.keys()];
if (spellings.length === 1) return spellings[0];
const follow = followFor(spellings[0]);
if (follow?.name) return follow.name;
return [...counts.entries()]
.sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))[0][0];
}
function sortList(list) {
const out = [...list].sort(SORTS[sortKey].cmp);
return desc ? out : out.reverse();
}
const grouped = $derived.by(() => {
const g = GROUPS[group];
if (group === "none") return [["", sortList(corpus)]];
const by = new Map();
for (const a of corpus) {
const k = (g.keyOf ?? g.of)(a);
if (!by.has(k)) by.set(k, []);
by.get(k).push(a);
}
// An artist shelf is keyed by the FOLD, which is not a name, so it takes
// the title shelfName() picks. Every other grouping's key already IS its
// heading. Two folds can still title alike — followFor() joins on the
// coarser normKey, so "AC/DC" and "ACDC" would both take one follow's
// name — and that is the same artist by the app's own rule, so the
// shelves join rather than one silently replacing the other.
const named = new Map();
for (const [k, list] of by) {
const title = group === "artist" ? shelfName(list) : k;
const prev = named.get(title);
if (prev) prev.push(...list); else named.set(title, list);
}
// Added is a DATE grouping: newest month first, by the month the key
// names — a string sort put April 2025 above August 2024.
const keys = [...named.keys()].sort(group === "rtype"
? (x, y) => CAT_ORDER.findIndex((c) => CAT_LABEL[c] === x)
- CAT_ORDER.findIndex((c) => CAT_LABEL[c] === y)
: group === "artist"
? (x, y) => x.replace(/^(the|a|an)\s+/i, "").localeCompare(y.replace(/^(the|a|an)\s+/i, ""))
: group === "decade" ? (x, y) => String(y).localeCompare(String(x))
: group === "added"
? (x, y) => (Date.parse(`1 ${y}`) || -Infinity) - (Date.parse(`1 ${x}`) || -Infinity)
: (x, y) => String(x).localeCompare(String(y)));
if (group === "artist" && sortKey === "complete") {
keys.sort((x, y) => completeness(x) - completeness(y));
}
return keys.map((k) => [k, sortList(named.get(k))]);
});
// Albums, EPs and singles under their own headings INSIDE an artist — the
// separation the artist page makes with tabs, made here with rules. Only
// categories the artist has appear, in TAB_DEFS order; a shelf holding one
// category gets no headings at all.
function splitByCategory(list) {
const by = new Map();
for (const a of list) {
if (!by.has(a.cat)) by.set(a.cat, []);
by.get(a.cat).push(a);
}
if (by.size < 2) return [[null, list]];
return CAT_ORDER.filter((c) => by.has(c)).map((c) => [CAT_LABEL[c], by.get(c)]);
}
// ---- the derived stat row --------------------------------------------
const totals = $derived.by(() => ({
albums: corpus.length,
artists: new Set(corpus.map((a) => foldKey(a.artist || "Unknown Artist"))).size,
tracks: corpus.reduce((n, a) => n + (a.track_count || 0), 0),
secs: corpus.reduce((n, a) => n + a.secs, 0),
bytes: corpus.reduce((n, a) => n + a.bytes, 0),
never: statsLoaded ? corpus.filter((a) => !a.lastPlayedAt).length : 0,
}));
// ================================================================ units
const pad2 = (n) => String(n).padStart(2, "0");
const dur = (s) => (!s ? "" : s >= 3600
? `${Math.floor(s / 3600)}:${pad2(Math.floor((s % 3600) / 60))}:${pad2(s % 60)}`
: `${Math.floor(s / 60)}:${pad2(s % 60)}`);
function longDur(s) {
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
if (d) return `${d}d ${h}h`;
const m = Math.round((s % 3600) / 60);
return h ? `${h}h ${m}m` : `${m}m`;
}
function fmtBytes(n) {
if (!n) return "—";
if (n >= 1073741824) return `${(n / 1073741824).toFixed(n >= 10737418240 ? 0 : 1)} GB`;
if (n >= 1048576) return `${Math.round(n / 1048576)} MB`;
return `${Math.round(n / 1024)} KB`;
}
function since(ts) {
if (!ts) return "never";
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
if (d <= 0) return "today";
if (d === 1) return "yesterday";
if (d < 7) return `${d}d ago`;
if (d < 31) return `${Math.round(d / 7)}w ago`;
if (d < 365) return `${Math.round(d / 30.4)}mo ago`;
return `${(d / 365).toFixed(1)}y ago`;
}
function subFor(a) {
if (sortKey === "added") return since(a.added_at);
if (sortKey === "played" || sortKey === "neglect") return since(a.lastPlayedAt);
if (sortKey === "size") return fmtBytes(a.bytes);
if (sortKey === "length") return dur(a.secs);
if (sortKey === "tracks") return `${a.track_count} tracks`;
if (sortKey === "rtype") return CAT_ONE[a.cat] + (a.year ? ` · ${a.year}` : "");
if (sortKey === "bitrate" || sortKey === "format") {
return (a.formats[0] || "").toUpperCase() + (a.avgKbps ? ` · ${a.avgKbps}k` : "");
}
return a.artist + (a.year ? ` · ${a.year}` : "");
}
const chipFor = (a) => (group === "artist" && a.year ? String(a.year) : null);
// ====================================================== generated artwork
// A record no provider will ever know has no cover and never will. Built
// from the record's own name — same name, same art, always — with a texture
// that follows what the thing IS (approved on the prototype; §3 of the
// handoff). `>>>` everywhere: a signed shift of an unsigned hash goes
// negative, and a negative index/hue was two real bugs.
function hash(s) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
function genFor(a) {
const h = hash(`${a.artist} — ${a.title}`);
const hue = h % 360;
const hue2 = (hue + 24 + ((h >>> 9) % 40)) % 360;
const pat = a.cat === "live" ? "rings" : a.cat === "other" ? "tape" : "plain";
return { hue, hue2, pat, icon: GEN_ICON[a.cat] ?? "disc" };
}
const GEN_ICON = {
live: "users", compilation: "layers", soundtrack: "preview-reel", remix: "shuffle",
other: "history", mixtape: "playlist", album: "disc", ep: "disc", single: "disc",
};
// A generated cover knows its own hue, so its card carries the accent.
const genAccent = (a) => {
const { hue } = genFor(a);
return `--accent:hsl(${hue} 48% 54%);--accent-hover:hsl(${hue} 54% 66%)`;
};
// ---- per-record accents -----------------------------------------------
// A record's chrome tints from its OWN cover rather than the global accent
// — CatalogArtist's scoped-accent pattern, and the same `--accent` /
// `--accent-hover` override string it writes. What differs is the SOURCE:
// its covers come off a CDN, so it needs a two-slot queue with timeouts to
// avoid 100 proxy round trips. Local art is already a decoded data URL in
// coverUrls by the time a card is on screen, so extraction hangs off that
// instead — no extra fetches, and it inherits loadCover's viewport gate.
//
// The GLOBAL accent is untouched: it keeps following the playing track, so
// the sidebar and playbar do not move when you point at a record.
let albumAccents = $state({}); // album id -> override string
const accentInFlight = new Set();
function extractAlbumAccent(id, dataUrl) {
if (!dataUrl || albumAccents[id] !== undefined || accentInFlight.has(id)) return;
accentInFlight.add(id);
accentFromImage(dataUrl).then((colors) => {
accentInFlight.delete(id);
// Never store a null palette: extraction fails transiently (a
// decode that lost its race with a re-render), and caching the
// failure would leave that record on the global accent forever.
if (colors) {
albumAccents[id] = `--accent:${colors.accent};--accent-hover:${colors.hover}`;
}
}).catch(() => accentInFlight.delete(id));
}
/** A record's own palette as a scoped override — extracted from its cover,
* or the generated cover's hue when it has no art. `undefined` means stay
* on the global accent, which is only the moment before extraction lands. */
const accentFor = (a) =>
!a ? undefined : (albumAccents[a.id] ?? (!a.art_path ? genAccent(a) : undefined));
// The record the eye is on. Drives the page's ambience glow only —
// everything else is scoped to its own card. (`highlightAccent` is derived
// down with openId, which it also reads.)
let hoverId = $state(null);
// ================================================================ covers
// Local art loads through getArt (budgeted data-URL cache in state), only
// for cards near the viewport — the pattern the old page proved out,
// reduced: the shared cache in state does the byte budgeting.
let coverUrls = $state({}); // album id -> data url
const coverInFlight = new Set();
function loadCover(node, album) {
let current = album;
let alive = true;
const load = () => {
const { id, art_path } = current;
if (!art_path) return;
// A cover already in the map still needs its palette: coverUrls
// outlives the cards (it is the page's cache), so a remount — a
// view or grouping change — takes this path with no fetch at all.
if (coverUrls[id] !== undefined) { extractAlbumAccent(id, coverUrls[id]); return; }
if (coverInFlight.has(id)) return;
coverInFlight.add(id);
getArt(art_path).then((url) => {
coverInFlight.delete(id);
if (alive && url) {
coverUrls[current.id] = url;
extractAlbumAccent(current.id, url);
}
}).catch(() => coverInFlight.delete(id));
};
const observer = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) load();
}, { root: null, rootMargin: "600px" });
observer.observe(node);
return {
update(next) { current = next; },
destroy() { alive = false; observer.disconnect(); },
};
}
// Shelf heads carry the artist's portrait, through the same budgeted getArt
// path as the covers. Keyed by PATH, not by album id: every album on a
// shelf carries the same artist_art_path and only one of them should fetch.
let artistUrls = $state({}); // artist_art_path -> data url
const artistInFlight = new Set();
function loadShelfArt(node, path) {
let current = path;
let alive = true;
const load = () => {
const p = current;
if (!p || artistUrls[p] !== undefined || artistInFlight.has(p)) return;
artistInFlight.add(p);
getArt(p).then((url) => {
artistInFlight.delete(p);
if (alive && url) artistUrls[p] = url;
}).catch(() => artistInFlight.delete(p));
};
const observer = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) load();
}, { root: null, rootMargin: "600px" });
observer.observe(node);
return {
update(next) { current = next; },
destroy() { alive = false; observer.disconnect(); },
};
}
// ---- ambience: the banner's colour is what you are LISTENING to -------
// The wash is the playing record's artwork, blurred — the page's tie to
// the music, which is what the approved mockup shows. Its rule there is
// "owned albums with a cover, sorted by last_played_at, take the first",
// which on the mockup's frozen data happens to land on the playing record;
// in a live app the playing record is simply known, so it is read directly.
//
// Only when NOTHING is playing does it fall back to the collection itself
// (the most recent library play, then the newest record with art), so the
// banner is never bare on a cold start.
let ambUrl = $state(null);
$effect(() => {
const item = player.queue[player.queueIndex] ?? null;
// A local track carries a file path, a streamed one a provider URL —
// the same split NowPlaying makes for this very artwork.
const live = item?.art
? { src: item.art, get: getArt }
: item?.artUrl
? { src: item.artUrl, get: getCatalogArt }
: null;
const fallback = live ? null : (
recentsStore.albums.find((r) => r.kind === "library" && r.art_path)?.art_path
?? libraryStore.albums.find((a) => a.art_path)?.art_path
?? null
);
const src = live?.src ?? fallback;
if (!src) { ambUrl = null; return; }
const get = live?.get ?? getArt;
let alive = true;
// Deliberately not cleared first: the outgoing wash holds until the
// incoming one has decoded, so a track change crossfades (below)
// instead of dropping to bare page for a frame.
get(src).then((url) => { if (alive && url) ambUrl = url; });
return () => { alive = false; };
});
// ============================================================== expander
// CatalogArtist's inline album expander, exactly — same classes, same
// numbers. What differs is the CONTENT (a library knows size on disk and
// bitrate, not Last.fm plays) and the reveal line (read off the pinned
// toolbar live, because its height depends on wrap + the joined header).
const SWAP_MS = 400; // CatalogArtist's own
const SWAP_EASE = "cubic-bezier(0.22, 1, 0.36, 1)"; // quintOut
const quintOut = (t) => 1 - Math.pow(1 - t, 5);
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-stats", delay: 105, lift: true },
{ sel: ".rail-cta", delay: 120, lift: true },
{ sel: ".xp-tracks", delay: 90, lift: true },
// The editorial is deliberately NOT here — it uses `use:partAnim`
// instead. See the note on that function: measured, neither this list
// nor an `in:` transition ever fired for it.
];
const LOADING_GRACE = 160;
let openId = $state(null);
let openTracks = $state([]);
let openLoading = $state(false);
let loadingVisible = $state(false);
let loadGraceTimer;
const openAlbum = $derived(rows.find((a) => a.id === openId) ?? null);
// ---- the open record's editorial ---------------------------------------
// Description, genre and label, exactly as the artist page's expander shows
// them (`loadAlbumInfo` there). The artist id is passed when one is
// anchored — a follow, or a click-resolved id — and null otherwise, which
// is what `catalog_album_info` expects: its own identity gate decides
// whether a name-only lookup is safe, and that gate is the thing that keeps
// a namesake's prose off this record.
let openInfo = $state(null); // { description, genre, label } | null
let descExpanded = $state(false);
const albumInfoCache = new Map(); // "artistId|title" -> Promise<info|null>
const INFO_CACHE_MAX = 64;
/** Lede + body for the open record, from the shared splitter. */
const railDesc = $derived(splitDescription(openInfo?.description));
function fetchAlbumInfo(artistId, artist, title) {
const key = `${artistId ?? ""}|${normKey(title)}`;
if (!albumInfoCache.has(key)) {
albumInfoCache.set(key, api.catalogAlbumInfo(artistId, artist, title).catch(() => null));
while (albumInfoCache.size > INFO_CACHE_MAX) {
albumInfoCache.delete(albumInfoCache.keys().next().value);
}
}
return { key, promise: albumInfoCache.get(key) };
}
async function loadAlbumInfo(album) {
openInfo = null;
descExpanded = false;
if (!album?.title) return;
const anchored = followFor(album.artist);
const wanted = album.id;
const { key, promise } = fetchAlbumInfo(anchored?.artistId ?? null, album.artist, album.title);
const info = await promise;
// A load-time network burst can time one source out. Show what arrived,
// but do not pin a partial result for the session — the next open
// retries, and the backend caches each source so a retry is cheap.
if (!info || !info.description || (!info.genre && !info.label)) {
albumInfoCache.delete(key);
}
if (openId === wanted) openInfo = info;
}
// ---- the record's own page ---------------------------------------------
// A library row has no catalogue id — it is a folder of files. The join to
// one is by normalized title WITHIN an anchored artist's own catalogue,
// which is the same join `toggleGaps` already makes to decide what is
// missing, and it reuses that page's warm cache. Without an anchor there is
// nothing to join against, so the title simply stays plain text.
let openCatalogId = $state(null);
async function resolveCatalogAlbum(album) {
openCatalogId = null;
const anchored = album ? followFor(album.artist) : null;
if (!anchored || !album?.title) return;
const wanted = album.id;
try {
const page = await getCatalogArtistPage(anchored.artistId);
// Same-titled records collapse to one normalized key (American
// Football × 3, Weezer × 7). The artist page's rows are dated, so
// the local folder's year picks the right one; only then does the
// first row stand in.
const same = (page?.albums ?? []).filter(
(r) => normKey(r.title) === normKey(album.title),
);
const match =
(album.year != null
&& same.find((r) =>
String(r.release_date ?? "").startsWith(String(album.year)),
))
|| same[0];
if (openId === wanted && match) openCatalogId = match.id;
} catch { /* no link; the title stays plain */ }
}
/** This record's artwork in the shared full-window viewer — the same one
* the Now Playing cover and the artist page's expander rail open. The
* canonical FILE path, not the data URL the rail paints: getArt's cache is
* byte-budgeted for thumbnails and the point of the viewer is the master
* resolution (3000px-class for library albums). A generated cover has no
* file behind it, so there is nothing to open. */
function openArtwork() {
if (!openAlbum?.art_path) return;
ui.artwork = {
src: openAlbum.art_path,
local: true,
title: openAlbum.title ?? "",
subtitle: openAlbum.artist ?? "",
};
}
// The pointer wins while it is over a record; it settles back to the open
// one when it leaves, and to the global accent when neither applies.
const highlightAccent = $derived(
albumAccents[hoverId] ?? albumAccents[openId] ?? undefined,
);
function toggleExpander(album) {
if (openId === album.id) { closeExpander(); return; }
openId = album.id;
openTracks = [];
openLoading = true;
loadingVisible = false;
clearTimeout(loadGraceTimer);
// Nothing at all until the load has outlasted LOADING_GRACE: a warm
// open resolves in tens of ms and used to flash a skeleton on its way
// past (CatalogArtist's own rule).
loadGraceTimer = setTimeout(() => { if (openLoading) loadingVisible = true; }, LOADING_GRACE);
const id = album.id;
api.albumTracks(id).then((tracks) => {
if (openId !== id) return;
openTracks = tracks;
openLoading = false;
}).catch(() => {
if (openId !== id) return;
openTracks = [];
openLoading = false;
});
// Both off the tracklist's critical path: the panel opens on the local
// files it already has, and the editorial lands into it.
void loadAlbumInfo(album);
void resolveCatalogAlbum(album);
}
function closeExpander() {
openId = null;
openTracks = [];
openLoading = false;
openInfo = null;
openCatalogId = null;
descExpanded = false;
}
// A view or grouping change closes the panel — CatalogArtist's own rule
// for a tab change: the open expander lives inside the grid being
// replaced, and its row means nothing in the new arrangement.
function setView(v) { if (view !== v) { view = v; closeExpander(); bumpDeal(); } }
function setGroup(g) {
if (group !== g) { group = g; expandedShelves = {}; gapOpen = {}; closeExpander(); bumpDeal(); }
}
// ---- grid columns, for the expander's row arithmetic ------------------
// Fixed tracks (150px cards, 16px column gap) exactly as the artist page.
const CARD_W = 150;
const GRID_GAP = 16;
let gridColsV = $state(0);
const gridColsMap = new Map(); // section key -> column count
function measureCols(node, key) {
let currentKey = key;
const measure = () => {
const w = node.clientWidth;
const cols = Math.max(1, Math.floor((w + GRID_GAP) / (CARD_W + GRID_GAP)));
if (gridColsMap.get(currentKey) !== cols) {
gridColsMap.set(currentKey, cols);
gridColsV++;
}
};
const ro = new ResizeObserver(measure);
ro.observe(node);
measure();
return {
update(nextKey) { currentKey = nextKey; measure(); },
destroy() { ro.disconnect(); },
};
}
/** Index in `items` after which the expander renders (-1 = not here): the
* last card of the open album's row — CatalogArtist `expanderIndex`. */
function expanderIndex(items, sectionKey) {
void gridColsV;
const idx = items.findIndex((a) => a.id === openId);
if (idx < 0) return -1;
const cols = gridColsMap.get(sectionKey) || 1;
return Math.min(Math.floor(idx / cols) * cols + cols - 1, items.length - 1);
}
/** Notch x: centred under the open card — CatalogArtist `notchLeft`. */
function notchLeft(items, sectionKey) {
void gridColsV;
const idx = items.findIndex((a) => a.id === openId);
const cols = gridColsMap.get(sectionKey) || 1;
const col = idx >= 0 ? idx % cols : 0;
return `${col * (CARD_W + GRID_GAP) + CARD_W / 2 - 6}px`;
}
// ---- the expander's motion, CatalogArtist's numbers -------------------
const reducedMotion = () =>
typeof matchMedia === "function"
&& matchMedia("(prefers-reduced-motion: reduce)").matches;
/** Open/close: the height is read LIVE per frame and kept monotonic —
* the tracklist lands mid-flight, and a captured height animates to a
* stale number (the 348→465px snap the app's comment records). The
* 16px margin skirt retires in step so a collapsed panel moves no row. */
function expandSlide(node, { duration = 460 } = {}) {
if (reducedMotion()) return { duration: 1 };
let full = node.scrollHeight;
const skirt = parseFloat(getComputedStyle(node).marginBottom) || 0;
return {
duration,
easing: quintOut,
tick: (t, u) => {
full = Math.max(full, node.scrollHeight);
if (t === 1) {
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)`;
node.style.marginBottom = `${skirt * t}px`;
},
};
}
/** The waterfall, as an action so it runs however the panel came to exist
* (CatalogArtist `staggerIn` — an `in:` transition misses first renders).
* To each element's OWN resting opacity: .rail-glow rests at 0.32, and
* animating it to 1 is the bright-then-dark bleed. */
function staggerIn(node) {
if (reducedMotion()) return;
for (const { sel, delay, lift } of SWAP_PARTS) {
for (const el of node.querySelectorAll(sel)) {
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",
},
);
}
}
}
/** Fade+lift ONE element from its own mount, with the same WAAPI call
* `staggerIn` makes.
*
* The description needs this because neither of the other two mechanisms
* ever fired for it, and that was measured, not assumed:
*
* - `staggerIn` is an action on .xp-panel and queries for its parts when
* that element is created. On a warm open the description block is
* created microseconds LATER — same flush, same frame — so the query
* finds nothing.
* - Svelte plays an `in:` only for an element added by a later UPDATE.
* Landing in the same flush is not a later update, so the intro is
* skipped. Audited on a warm open: every other part reached opacity 0
* on its way in; .rail-lede/.rail-desc/.rail-pills sat at 1 for all
* 168 sampled frames, with no animation of any kind.
*
* An action always runs when its own element is created, whichever flush
* that happens to be, so this covers the warm and cold paths identically.
* Delays are quoted at the mock's 360ms baseline and scaled like the rest.
*/
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",
},
);
}
/** One part of the swap, per track row — CatalogArtist `partIn`. */
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);`,
};
}
/** The outgoing panel of a same-row switch: out of flow so the incoming
* one has the stage to itself — CatalogArtist `panelLeave`. */
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}`,
};
}
/** Hold the stage at an eased height that TRACKS the panel rather than
* animating once to a snapshot — CatalogArtist `followStageHeight`. */
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;
stage.style.transition = `height ${SWAP_MS}ms ${SWAP_EASE}`;
let settleTimer = 0;
let ro = null;
const finish = () => {
if (stage.dataset.swapSeq !== seq) return;
ro?.disconnect();
clearTimeout(settleTimer);
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`;
clearTimeout(settleTimer);
settleTimer = setTimeout(finish, SWAP_MS + 60);
};
ro = new ResizeObserver(apply);
ro.observe(panel);
apply();
setTimeout(finish, 4000);
}
/** The incoming panel of a same-row switch — CatalogArtist `panelEnter`:
* Svelte only times this; staggerIn carries the visible motion. */
function panelEnter(node) {
const quick = reducedMotion();
const stage = node.parentElement;
const leaving = stage?.querySelector(".xp-panel[style*='absolute']");
if (stage && leaving && !quick) {
followStageHeight(stage, node, leaving.getBoundingClientRect().height);
}
return { duration: quick ? 1 : SWAP_MS, css: () => "" };
}
/** Lift the open row to the line under the pinned toolbar, 420ms. The
* line is read off the BAR live each frame — its height depends on
* whether the controls wrapped and whether the column header is joined —
* and the scroll reserve is stood up before the first frame so the range
* can hold the target (the prototype's xpReveal).
*
* The reserve is ONE number on the page root (`--xp-reserve`, spent as end
* padding), not a min-height on the grid or section holding the panel.
* Two reasons, both measured:
*
* - On the grid it was computed as if the grid were the last thing on the
* page, so it ignored the shelves below and reserved a whole viewport
* that was already there: 1102px of floor under a 626px grid, a 476px
* empty band between the open panel and the shelf's gap line.
* - Per-host, a switch reads the OTHER host's in-flight reserve as real
* content — the range it is about to give back — and under-reserves by
* exactly that. Switching up a section parked the row 166px below the
* bar with nothing left to correct with.
*
* One writer at the very end of the page has neither problem, and what it
* adds reads as room to scroll rather than a hole inside a shelf. */
function revealIntoView(node) {
if (reducedMotion()) return;
const pane = node.closest(".content");
const row = node.previousElementSibling;
const page = node.closest(".lib-page");
if (!pane || !row || !page) return;
const line = () => (toolbarEl
? toolbarEl.getBoundingClientRect().bottom + 10
: pane.getBoundingClientRect().top + 100);
const standing = () => parseFloat(page.style.getPropertyValue("--xp-reserve")) || 0;
/** Height outgoing expanders are still holding through their 460ms
* collapse. Both terms below have to discount it, or each is computed
* against a page mid-switch: `above` because the row is drifting up
* under us, `all` because the page is about to be shorter. Zero on a
* plain open, and on a same-row switch (that one keeps its
* .album-expander and swaps the panel inside it). */
const vanishing = (aboveRowOnly) => {
const rowTop = row.getBoundingClientRect().top;
let h = 0;
for (const el of page.querySelectorAll(".album-expander")) {
if (el === node) continue;
const r = el.getBoundingClientRect();
if (aboveRowOnly && r.bottom > rowTop + 1) continue;
h += r.height + (parseFloat(getComputedStyle(el).marginBottom) || 0);
}
return h;
};
/** Where the row is going, in the pane's scroll coordinates. */
const rowTarget = () => row.getBoundingClientRect().top - line()
+ pane.scrollTop - vanishing(true);
/** The page's settled height: what it measures now, less the reserve it
* is already carrying and less everything mid-collapse. Taken from the
* page's own box rather than pane.scrollHeight, which is PINNED at
* clientHeight while the content is shorter than the viewport and so
* cannot tell "short page" from "exactly one viewport". */
const settledContent = () => page.getBoundingClientRect().bottom
- (pane.getBoundingClientRect().top + pane.clientTop - pane.scrollTop)
+ (parseFloat(getComputedStyle(pane).paddingBottom) || 0)
- standing() - vanishing(false);
const reserve = () => Math.max(0, Math.round(
rowTarget() + pane.clientHeight - settledContent(),
));
// Re-stood every frame, RAISING only: 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. Falls belong to
// destroy(), which hands the whole reserve back at once.
const stand = () => {
const need = reserve();
if (standing() < need) page.style.setProperty("--xp-reserve", `${need}px`);
};
/** The one safe moment to LOWER it: everything has settled, so switching
* from a deep record to a shallow one need not keep the deep one's
* reserve as dead scroll at the end of the page (measured 521px). It
* falls only as far as the pane can afford — dropping past the point
* where the clamp overtakes where the pane is parked is taken in a
* single frame, and that is the yank raise-only exists to prevent. */
const relax = () => {
const need = reserve();
const held = standing();
if (need >= held) return;
const afford = Math.max(0, Math.floor(
pane.scrollHeight - pane.clientHeight - pane.scrollTop,
));
const to = Math.max(need, held - afford);
if (to < held) page.style.setProperty("--xp-reserve", `${to}px`);
};
stand();
const from = pane.scrollTop;
const t0 = performance.now();
// The user's own scrolling wins immediately.
let cancelled = false;
const cancel = () => { cancelled = true; };
pane.addEventListener("wheel", cancel, { once: true, passive: true });
let raf = requestAnimationFrame(function step(now) {
raf = null;
if (cancelled || !node.isConnected) return;
stand();
const t = Math.min(1, (now - t0) / 420);
// Clamped to what the range can actually hold: easing at a target it
// cannot reach is what makes the open judder.
const target = Math.max(
0, Math.min(rowTarget(), pane.scrollHeight - pane.clientHeight),
);
if (t < 1) {
pane.scrollTop = from + (target - from) * (1 - (1 - t) ** 3);
raf = requestAnimationFrame(step);
return;
}
// A switch's outgoing panel collapses for 460ms — longer than this
// 420ms ease — so its last frames move the row after the ease has
// stopped: measured 6px, the row parked 4 under the bar instead of
// 10. Track the target until nothing is vanishing any more. The
// target moves ~1px a frame by then, so this reads as the tail of
// the same motion rather than a correction.
pane.scrollTop = target;
if (now - t0 < 1000 && vanishing(false) > 0) {
raf = requestAnimationFrame(step);
return;
}
relax();
});
return {
destroy() {
if (raf) cancelAnimationFrame(raf);
pane.removeEventListener("wheel", cancel);
// A switch mounts the incoming panel — and stands its reserve —
// before this one's outro ends. Clearing unconditionally would
// drop the reserve the new reveal is relying on, in the frame
// before anything puts it back. Only the last panel out pays.
const others = [...page.querySelectorAll(".album-expander")]
.filter((el) => el !== node);
if (!others.length) page.style.removeProperty("--xp-reserve");
},
};
}
// ---- playback ---------------------------------------------------------
function playOpen(startIndex = 0) {
if (!openAlbum || !openTracks.length) return;
playAlbum(openTracks, `${openAlbum.artist} — ${openAlbum.title}`, startIndex, openAlbum.art_path);
}
async function playWholeAlbum(album) {
try {
const tracks = album.id === openId && openTracks.length
? openTracks
: await api.albumTracks(album.id);
if (tracks.length) playAlbum(tracks, `${album.artist} — ${album.title}`, 0, album.art_path);
} catch { /* backend unavailable */ }
}
// Disc-aware view of the open tracklist — the artist page's own treatment
// (CatalogArtist's `openDiscs`), so a multi-disc record reads the same
// wherever you meet it: quiet disc rules, and each disc numbering from 1.
//
// `i` is the track's index in `openTracks`, kept alongside because that is
// what playback counts from: grouping the rows must not renumber the queue.
const openDiscs = $derived.by(() => {
const by = new Map();
openTracks.forEach((t, i) => {
const disc = t.disc_no ?? 1;
if (!by.has(disc)) by.set(disc, []);
by.get(disc).push({ t, i });
});
return [...by.entries()]
.sort((a, b) => a[0] - b[0])
.map(([disc, rows]) => ({ disc, rows }));
});
const multiDisc = $derived(openDiscs.length > 1);
const isPlayingTrack = (t) =>
player.track?.source === "local" && player.track?.path === t.path;
function folderOf(tracks) {
return tracks[0]?.path ?? null;
}
// ================================================================ chrome
// fit(), as reactive measurements: the toolbar's wrap is measured (a
// hard-coded breakpoint goes stale), --tb-h is MEASURED off the bar (a
// typed 50px parked the joined header behind a two-row bar at 1152), and
// the table drops columns by tier so a record's name is never squeezed out.
let pageEl = $state(null);
let toolbarEl = $state(null);
let narrow = $state(false);
let tbH = $state(50);
let paneH = $state(760);
let colsTier = $state("all");
const COL_TIERS = [
{ name: "all", min: 960 },
{ name: "b", min: 842 },
{ name: "c", min: 686 },
{ name: "d", min: 0 },
];
function measureChrome() {
if (!pageEl || !toolbarEl) return;
const pane = pageEl.closest(".content");
if (pane) paneH = pane.clientHeight;
// Every child on the bar's first row; the column-tier decision first,
// because it changes the bar's height when the header is joined.
const room = pageEl.clientWidth;
colsTier = (COL_TIERS.find((t) => room >= t.min) ?? COL_TIERS[COL_TIERS.length - 1]).name;
const kids = [...toolbarEl.children].filter((el) => !el.classList.contains("lb-th"));
const gap = parseFloat(getComputedStyle(toolbarEl).columnGap) || 14;
const need = kids.reduce((n, el) => n + el.scrollWidth, 0) + gap * (kids.length - 1);
narrow = need > toolbarEl.clientWidth;
tbH = Math.round(toolbarEl.getBoundingClientRect().height);
}
$effect(() => {
if (!pageEl || !toolbarEl) return;
const ro = new ResizeObserver(measureChrome);
ro.observe(pageEl);
ro.observe(toolbarEl);
measureChrome();
return () => ro.disconnect();
});
$effect(() => {
// Structural knobs that change what the bar holds or the page's width.
void view; void group; void q; void narrow;
tick().then(measureChrome);
});
// ---- the index rail ---------------------------------------------------
// Every grouping gets a label; the 34px gutter is reserved by the page
// whether or not this grouping has anything to index, so no control click
// ever changes the width of the content column (handoff §4b.1).
const RAIL_LABEL = {
artist: (k) => k.replace(/^(the|a|an)\s+/i, "").charAt(0).toUpperCase(),
decade: (k) => (/^\d/.test(k) ? String(k).slice(0, 4) : "—"),
format: (k) => {
const [fmt, depth] = String(k).split(" ");
if (!/^[A-Z0-9]/.test(fmt)) return "—";
return depth ? fmt.slice(0, 2) + depth.slice(0, 2) : fmt.slice(0, 4);
},
rtype: (k) => String(k).slice(0, 3),
added: (k) => ((/\d{4}/.exec(k) || ["—"])[0]),
};
// Group = None indexes the SORT instead — the initial of the artist or
// title, the decade of the year, the year of a date.
const FLAT_INDEX = {
artist: (a) => (a.sortName || "?").charAt(0).toUpperCase(),
complete: (a) => (a.sortName || "?").charAt(0).toUpperCase(),
title: (a) => (a.title || "?").replace(/^(the|a|an)\s+/i, "").charAt(0).toUpperCase(),
year: (a) => (a.year ? String(Math.floor(a.year / 10) * 10) : "—"),
added: (a) => (a.added_at ? String(new Date(a.added_at * 1000).getFullYear()) : "—"),
played: (a) => (a.lastPlayedAt ? String(new Date(a.lastPlayedAt * 1000).getFullYear()) : "—"),
neglect: (a) => (a.lastPlayedAt ? String(new Date(a.lastPlayedAt * 1000).getFullYear()) : "never"),
format: (a) => ((a.formats[0] || "—").toUpperCase().slice(0, 4)),
rtype: (a) => String(CAT_ONE[a.cat] || "").slice(0, 3),
};
const flatAnchors = $derived.by(() => {
if (group !== "none") return null;
const fn = FLAT_INDEX[sortKey];
if (!fn || !grouped.length) return null;
const at = new Map();
const items = [];
const seen = new Set();
for (const a of grouped[0][1]) {
const l = fn(a);
if (!l || seen.has(l)) continue;
seen.add(l);
items.push(l);
at.set(a.id, l);
}
return items.length > 1 ? { items, at } : null;
});
const railItems = $derived.by(() => {
if (group === "none") {
return flatAnchors ? flatAnchors.items.map((l) => [l, l]) : [];
}
const label = RAIL_LABEL[group] ?? ((k) => String(k).slice(0, 4));
const seen = new Set();
const items = [];
for (const [k] of grouped) {
const l = label(k);
if (!l || seen.has(l)) continue;
seen.add(l);
items.push([l, k]);
}
return items.length > 1 ? items : [];
});
const anchorId = (k) => `libg-${String(k).replace(/[^a-zA-Z0-9]/g, "") || "x"}`;
/** A rail click LANDS: `content-visibility` makes unpainted heights an
* estimate, so one scroll overshoots by up to ~1100px — correct until the
* target stops moving, bail on the user's wheel (handoff §4b.2). */
function jumpTo(key) {
const pane = pageEl?.closest(".content");
if (!pane) return;
const id = anchorId(key);
const delta = () => {
const el = document.getElementById(id);
if (!el) return null;
const clear = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
const top = pane.getBoundingClientRect().top
+ parseFloat(getComputedStyle(pane).paddingTop || 0);
return el.getBoundingClientRect().top - top - clear;
};
const first = delta();
if (first == null) return;
pane.scrollTo({ top: pane.scrollTop + first, behavior: "smooth" });
let quiet = 0;
let frames = 0;
let cancelled = false;
const stop = () => { cancelled = true; };
pane.addEventListener("wheel", stop, { passive: true });
const settle = () => {
if (cancelled) return pane.removeEventListener("wheel", stop);
const d = delta();
if (d == null) return pane.removeEventListener("wheel", stop);
if (Math.abs(d) > 1) { pane.scrollTop += d; quiet = 0; } else quiet++;
if (quiet < 8 && frames++ < 90) requestAnimationFrame(settle);
else pane.removeEventListener("wheel", stop);
};
setTimeout(settle, 420);
}
// ---- the entrance: Wave -----------------------------------------------
// The records arrive in a sweep down (and slightly across) the screen the
// reader is looking at; the chrome — hero, toolbar, column header — never
// moves. Delay comes from POSITION, normalized to the topmost thing that
// is both moving and on screen; delays are pulled forward over the next
// few frames as content-visibility geometry lands (handoff §4b.5b-5d).
const WAVE = { rise: 14, dur: 300, span: 420, skew: 0.18 };
const DEAL_SEL = ".lib-shelf-head, .lib-subhead, .lib-group-rule, .release,"
+ " .lb-tr:not(.lb-th), .album-expander, .lib-gapline, .lib-more";
let dealSeq = $state(1);
let dealGen = 0;
function bumpDeal() { dealSeq++; }
$effect(() => {
void dealSeq;
if (!pageEl || reducedMotion()) return;
const gen = ++dealGen;
tick().then(() => { if (gen === dealGen) dealEntrance(); });
});
function dealEntrance() {
const pane = pageEl?.closest(".content");
if (!pane) return;
const gen = dealGen;
const anims = runDeal(pane, null);
let n = 0;
const retime = () => {
if (gen !== dealGen) return;
runDeal(pane, anims);
if (++n < 5) requestAnimationFrame(retime);
};
requestAnimationFrame(retime);
}
function runDeal(pane, existing) {
const box = pane.getBoundingClientRect();
const slack = box.height;
const items = [];
for (const el of pageEl.querySelectorAll(DEAL_SEL)) {
const r = el.getBoundingClientRect();
if (r.bottom <= box.top - slack || r.top >= box.bottom + slack) continue;
const dy = Math.max(0, Math.min(1, (r.top - box.top) / Math.max(1, box.height)));
const dx = Math.max(0, Math.min(1, (r.left - box.left) / Math.max(1, box.width)));
items.push({
el,
score: dy * (1 - WAVE.skew) + dx * WAVE.skew,
inView: r.bottom > box.top && r.top < box.bottom,
});
}
const out = existing || new Map();
if (!items.length) return out;
const seen = items.filter((it) => it.inView);
const first = Math.min(...(seen.length ? seen : items).map((it) => it.score));
for (const { el, score } of items) {
const delay = Math.round(Math.max(0, score - first) * WAVE.span);
const prev = out.get(el);
if (!prev) {
// On a retime pass, never START one: an element outside the
// slack a frame ago is at full strength now, and animating it
// from zero would be a flash rather than an entrance.
if (existing) continue;
out.set(el, el.animate(
[
{ opacity: 0, transform: `translateY(${WAVE.rise}px)` },
{ opacity: 1, transform: "none" },
],
{ duration: WAVE.dur, delay, easing: "cubic-bezier(.2,.7,.3,1)", fill: "backwards" },
));
} else {
const was = prev.effect.getTiming().delay;
// Closer is a skip-ahead; back is a rewind — allowed only while
// the thing has not arrived yet, where it is invisible.
const hidden = (prev.currentTime || 0) < was;
if (delay < was - 8 || (hidden && delay > was + 8)) {
prev.effect.updateTiming({ delay });
}
}
}
return out;
}
// ---- header actions ---------------------------------------------------
async function rescan() {
if (rescanning) return;
rescanning = true;
try {
// Prune deletions + index additions on the backend FIRST — the
// refetch below otherwise re-reads the same stale rows, which is
// exactly what this button used to do.
await api.rescanLibrary().catch((e) => console.warn("rescan:", e));
await refreshLibrary();
await loadStats();
} finally {
rescanning = false;
}
}
// ---- boot -------------------------------------------------------------
$effect(() => {
loadStats();
loadFollowNames();
});
// Close popovers on outside interaction.
function onWindowClick(e) {
if (menuOpen && !e.target.closest(".sortwrap")) menuOpen = false;
}
const PER_SHELF = 18;
</script>
<svelte:window onclick={onWindowClick} />
{#snippet cover(a, size)}
{#if a.art_path}
{#if coverUrls[a.id]}
<img class="cover" src={coverUrls[a.id]} alt="" loading="lazy" decoding="async" draggable="false" />
{:else}
<div class="cover gen-pending"></div>
{/if}
{:else}
{@const g = genFor(a)}
<div class="cover lib-gen" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}">
<span class="lib-gen-glyph"><Icon name={g.icon} size={Math.round(size * 0.3)} /></span>
</div>
{/if}
{/snippet}
{#snippet releaseCard(a, sectionKey)}
<div
class="release"
class:active={openId === a.id}
style={accentFor(a)}
id={flatAnchors?.at.get(a.id) ? anchorId(flatAnchors.at.get(a.id)) : undefined}
role="button"
tabindex="0"
use:loadCover={a}
title={openId === a.id ? "Close" : `Open ${a.title}`}
onpointerenter={() => (hoverId = a.id)}
onpointerleave={() => { if (hoverId === a.id) hoverId = null; }}
onclick={() => toggleExpander(a)}
onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)}
>
<div class="cover-wrap">
{#if chipFor(a)}<span class="ed-badge">{chipFor(a)}</span>{/if}
{@render cover(a, CARD_W)}
<button
class="hoverplay"
title="Play"
onclick={(e) => { e.stopPropagation(); playWholeAlbum(a); }}
><Icon name="play" size={13} /></button>
</div>
<div class="title" title={a.title}>{a.title}</div>
<div class="sub">{subFor(a)}</div>
</div>
{/snippet}
{#snippet gapCard(r)}
<!-- A record you do NOT own, at full size and dimmed. It is filed by the
same classifier as everything else, off the CatalogAlbum payload's own
fields (`record_type`/`track_count`/`release_date`, plus the is_live and
is_soundtrack flags Deezer has no record type for) — "other" means the
classifier had nothing to go on, so it is left unsaid rather than
printed under every card. -->
{@const cat = fileRelease({
title: r.title,
record_type: r.record_type,
track_count: r.track_count,
is_live: r.is_live,
is_soundtrack: r.is_soundtrack,
})}
<div class="release gap" role="listitem" title={`${r.title} — not in your library`}>
<div class="cover-wrap">
{#if r.release_date?.slice(0, 4)}
<span class="ed-badge">{r.release_date.slice(0, 4)}</span>
{/if}
{#if r.cover}
<img class="cover" src={r.cover} alt="" loading="lazy" decoding="async" draggable="false" />
{:else}
<div class="cover placeholder"><Icon name="disc" size={30} /></div>
{/if}
</div>
<div class="title" title={r.title}>{r.title}</div>
<div class="sub">{cat === "other" ? "" : CAT_ONE[cat]}</div>
</div>
{/snippet}
{#snippet expander(items, sectionKey)}
<div
class="album-expander"
style={accentFor(openAlbum)}
transition:expandSlide={{ duration: 460 }}
use:revealIntoView
>
<!-- In List a row spans the pane, so the notch sits under the row's
ARTWORK (10px inset + half the 26px thumb − half the notch). -->
<span class="notch" style="left: {sectionKey === 'list' ? '17px' : notchLeft(items, sectionKey)}"></span>
<div class="xp-stage">
{#key openId}
<div class="xp-panel" use:staggerIn in:panelEnter out:panelLeave>
<div class="xp-body">
<div class="xp-rail">
{#if openAlbum?.art_path && coverUrls[openAlbum.id]}
<img class="rail-glow" src={coverUrls[openAlbum.id]} alt="" aria-hidden="true" draggable="false" />
{:else if openAlbum && !openAlbum.art_path}
{@const g = genFor(openAlbum)}
<div class="rail-glow lib-gen" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}" aria-hidden="true"></div>
{/if}
<div class="rail-shade"></div>
<!-- The artwork is a button: it opens the shared
full-window viewer, the same one the Now Playing
cover and the artist page's rail open. Disabled
for a generated cover — there is no file to
show, so it offers no affordance either. -->
<button
class="rail-art-stage"
title="View artwork"
aria-label="View artwork full screen"
disabled={!openAlbum?.art_path}
onclick={openArtwork}
>
{#if openAlbum?.art_path && coverUrls[openAlbum.id]}
<img class="rail-art" src={coverUrls[openAlbum.id]} alt="" draggable="false" />
{:else if openAlbum}
{@const g = genFor(openAlbum)}
<div class="rail-art lib-gen" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}">
<span class="lib-gen-glyph"><Icon name={g.icon} size={62} /></span>
</div>
{/if}
{#if openAlbum?.art_path}
<span class="rail-art-mark" aria-hidden="true"><Icon name="expand" size={18} /></span>
{/if}
</button>
<!-- The name opens the record's own page, as it does
on the artist page. An anchored (followed)
artist's join gives the id up front; otherwise
the click resolves through openAlbumByName —
the now-playing links' guarded resolver, which
verifies artist AND title (never guesses) and
stays put on a settled miss. Plain text only
when the row has no artist+title to resolve. -->
<h3 class="rail-title">
{#if openCatalogId != null || (openAlbum?.artist && openAlbum?.title)}
<button
class="rail-title-link"
title="Open album"
onclick={() =>
openCatalogId != null
? openCatalogAlbum(openCatalogId)
: openAlbumByName(openAlbum.artist, openAlbum.title, {
year: openAlbum.year ?? null,
})}
>{openAlbum?.title ?? ""}</button>
{:else}
<span class="rail-title-text">{openAlbum?.title ?? ""}</span>
{/if}
</h3>
<p class="rail-meta">
{openAlbum?.year ?? "no year"}{openTracks.length
? ` · ${openTracks.length} tracks`
: openAlbum?.track_count
? ` · ${openAlbum.track_count} tracks`
: ""}{openAlbum?.secs ? ` · ${dur(openAlbum.secs)}` : ""}
</p>
{#if openAlbum && (openAlbum.bytes || openAlbum.avgKbps)}
<!-- The artist page's stat pair, carrying what a
FILE knows instead of what Last.fm knows. -->
<div class="rail-stats">
{#if openAlbum.bytes}
<span class="rail-stat"><b>{fmtBytes(openAlbum.bytes)}</b><i>on disk</i></span>
{/if}
{#if openAlbum.bytes && openAlbum.avgKbps}<span class="rail-stat-div"></span>{/if}
{#if openAlbum.avgKbps}
<span class="rail-stat"><b>{openAlbum.avgKbps}k</b><i>bitrate</i></span>
{/if}
{#if openAlbum.formats[0]}
<span class="rail-stat-note">{openAlbum.formats[0].toUpperCase()}{openAlbum.depth ? ` ${openAlbum.depth}` : ""}</span>
{/if}
</div>
{/if}
<div class="rail-cta">
<button class="pill grow" title="Play" onclick={() => playOpen(0)} disabled={!openTracks.length}>
<Icon name="play" size={12} /> Play
</button>
{#if openTracks.length}
<PlaylistMenu
compact
iconSize={14}
title="Add album to a playlist"
tracks={() =>
openTracks.map((t) => ({
title: t.title,
artist: t.artist,
album: openAlbum?.title ?? null,
duration_secs: t.duration_secs,
path: t.path,
art_path: openAlbum?.art_path ?? null,
}))}
/>
{/if}
<button
class="pill ghost"
title="Show in folder"
disabled={!openTracks.length}
onclick={() => { const p = folderOf(openTracks); if (p) api.showInFolder(p); }}
><Icon name="folder-open" size={13} /></button>
</div>
{#if openInfo?.description}
<!-- The description's own first sentence,
promoted, then the body under a 4-line clamp
with a mask fade — the artist page's
treatment, class for class. Each part
carries its own `in:partIn` because the
description resolves after the panel is
built, so the staggered morph has already
run by the time it exists. -->
{#if railDesc.lede}
<p class="rail-lede" use:partAnim={{ delay: 135 }}>{railDesc.lede}</p>
{/if}
{#if railDesc.rest}
<p class="rail-desc" class:clamped={!descExpanded} use:partAnim={{ delay: 145 }}>{railDesc.rest}</p>
<button
class="rail-more"
class:open={descExpanded}
use:partAnim={{ delay: 160 }}
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: 170 }}>
{#if openInfo.genre}<span class="gpill accent">{openInfo.genre}</span>{/if}
{#if openInfo.label}<span class="gpill">{openInfo.label}</span>{/if}
</div>
{/if}
{/if}
</div>
<div class="xp-tracks">
{#if openLoading}
{#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>
</div>
{/if}
{:else if openTracks.length}
<div class="tl-cols" in:partIn={{ delay: 30 }}>
<span class="tno">#</span>
<span>Title</span>
<span class="tdur-head"><Icon name="clock" size={13} /></span>
</div>
{#each openDiscs as d (d.disc)}
{#if multiDisc}
<div class="disc-head" in:partIn={{ delay: 40 }}>Disc {d.disc} · {d.rows.length} tracks</div>
{/if}
{#each d.rows as { t, i: ti } (t.id)}
<div
class="trow"
class:now={isPlayingTrack(t)}
in:partIn={{ delay: Math.min(ti * 16, 180) }}
role="button"
tabindex="0"
title="Play"
onclick={() => playOpen(ti)}
onkeydown={(e) => e.key === "Enter" && playOpen(ti)}
>
<span class="tno">{isPlayingTrack(t) ? "♫" : (t.track_no ?? "·")}</span>
<span class="ttitle">
{t.title}
{#if t.bitrate_kbps}
<span class="tk-rate">{(t.path.split(".").pop() || "").toUpperCase()} {t.bitrate_kbps}k{t.bit_depth ? ` · ${t.bit_depth}-bit` : ""}</span>
{/if}
</span>
<span class="tdur">{t.duration_secs ? dur(t.duration_secs) : "—"}</span>
<button
class="mini-btn play"
title="Play"
onclick={(e) => { e.stopPropagation(); playOpen(ti); }}
><Icon name="play" size={13} /></button>
<button
class="mini-get"
title="Show in folder"
onclick={(e) => { e.stopPropagation(); api.showInFolder(t.path); }}
><Icon name="folder-open" size={13} /></button>
</div>
{/each}
{/each}
{:else}
<div class="tracks-loading" role="status">
<div class="tl-head">No tracks found for this record</div>
</div>
{/if}
</div>
</div>
</div>
{/key}
</div>
</div>
{/snippet}
{#snippet cardGrid(items, sectionKey)}
<div class="grid" use:measureCols={sectionKey}>
{#each items as a, i (a.id)}
{@render releaseCard(a, sectionKey)}
{#if expanderIndex(items, sectionKey) === i}
{@render expander(items, sectionKey)}
{/if}
{/each}
</div>
{/snippet}
{#snippet gapSection(name, list)}
{@const follow = followFor(name)}
{#if follow}
{@const key = foldKey(name)}
{@const missing = gapRows[key]}
{@const total = gapTotals[key]?.total}
{@const count = missing ? missing.length : (total ? Math.max(0, total - list.length) : null)}
{#if count !== 0}
<button class="lib-gapline" onclick={() => toggleGaps(name)}>
{#if gapLoading[key]}
<span class="tl-ring" aria-hidden="true"></span> Checking the catalogue
{:else if count != null}
<b>{count}</b> more released
{:else}
See what's missing
{/if}
<span class="lib-gapline-cta">
{gapOpen[key] ? "hide" : "show"}
<Icon name={gapOpen[key] ? "chevron-down" : "chevron-right"} size={12} />
</span>
</button>
{/if}
{#if gapOpen[key] && missing?.length}
<!-- Full-size cards, dimmed — the same card as everything else on
the page, readable, full strength on hover (handoff §4d). -->
<div class="lib-gaps" role="list">
<div class="grid">
{#each missing as r (r.id)}
{@render gapCard(r)}
{/each}
</div>
</div>
{/if}
{/if}
{/snippet}
{#snippet artistPicker()}
<!-- Only when the name is genuinely ambiguous. Built from the page's own
.menu, the same surface the Sort control uses, rather than a new
dialog: this is a short list of choices, which is what a menu is. -->
<div
class="lib-picker-veil"
role="button"
tabindex="-1"
aria-label="Cancel"
onclick={() => (picker = null)}
onkeydown={(e) => e.key === "Escape" && (picker = null)}
></div>
<div class="menu lib-picker" role="menu">
<div class="mgroup">
{#if picker.options.length}
Which <b>{picker.name}</b>?
{:else if picker.failed}
Couldn't reach the catalogue
{:else}
No catalogue match for <b>{picker.name}</b>
{/if}
</div>
{#each picker.options as o (o.id)}
<button role="menuitem" class="lib-pick" onclick={() => pickArtist(o)}>
{#if o.picture}
<img class="lib-pick-art" src={o.picture} alt="" draggable="false" />
{:else}
<span class="lib-pick-art lib-pick-none"><Icon name="users" size={13} /></span>
{/if}
<span class="lib-pick-name">{o.name}</span>
{#if o.fans}<span class="lib-pick-fans">{fmtFans(o.fans)} fans</span>{/if}
</button>
{/each}
{#if !picker.options.length}
{#if picker.failed}
<div class="lib-pick-empty">
The search for <b>{picker.name}</b> didn't come back — the
catalogue may be rate-limiting. Nothing has been decided
about this artist.
</div>
<button role="menuitem" class="lib-pick" onclick={() => {
const again = picker.name;
picker = null;
openShelfArtist(again);
}}>
<span class="lib-pick-art lib-pick-none"><Icon name="history" size={13} /></span>
<span class="lib-pick-name">Try again</span>
</button>
{:else}
<div class="lib-pick-empty">
Nothing in the catalogue matches this folder's artist tag. The
shelf keeps its records; only the catalogue link is unavailable.
</div>
{/if}
{/if}
</div>
{/snippet}
{#snippet shelfHead(name, list)}
{@const follow = followFor(name)}
{@const total = group === "artist" ? gapTotals[foldKey(name)]?.total : null}
{@const secs = list.reduce((n, a) => n + a.secs, 0)}
<!-- Every ARTIST shelf opens its page. Where no id is anchored yet the
click resolves one first (openShelfArtist), so an unfollowed shelf is
no longer a dead end — it just costs one search the first time. -->
{@const actionable = group === "artist"}
<!-- Only an ARTIST shelf has a portrait to show; every other grouping is a
Decade or a Format, and its icon is the right mark. The first row with
art wins — they all carry the same artist_art_path. -->
{@const portrait = group === "artist"
? (list.find((a) => a.artist_art_path)?.artist_art_path ?? null)
: null}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -- role="button" and
tabindex are set together, by the same `actionable` condition; the
analyzer cannot see that they travel as a pair. -->
<div
class="lib-shelf-head"
id={anchorId(name)}
role={actionable ? "button" : undefined}
tabindex={actionable ? 0 : undefined}
title={actionable ? `Open ${name}` : undefined}
aria-busy={resolving[name] ? "true" : undefined}
onclick={actionable ? () => openShelfArtist(name) : undefined}
onkeydown={actionable
? (e) => (e.key === "Enter" || e.key === " ") && openShelfArtist(name)
: undefined}
>
<span class="lib-shelf-dot" use:loadShelfArt={portrait}>
{#if portrait && artistUrls[portrait]}
<img src={artistUrls[portrait]} alt="" draggable="false" />
{:else}
<Icon name={group === "artist" ? "users" : "layers"} size={17} />
{/if}
</span>
<div>
<div class="n">
{name}
{#if actionable}
<span class="lib-shelf-go">
{#if resolving[name]}
<span class="tl-ring" aria-hidden="true"></span>
{:else}
<Icon name="chevron-right" size={15} />
{/if}
</span>
{/if}
</div>
<div class="note">
{#if total}{list.length} of {total} released{:else}{list.length} {list.length === 1 ? "album" : "albums"}{/if}{#if secs}&nbsp;· {longDur(secs)}{/if}
</div>
{#if total}
<span class="lib-own"><i style="width:{Math.min(100, (list.length / total) * 100).toFixed(1)}%"></i></span>
{/if}
</div>
</div>
{/snippet}
<div
class="lib-page"
bind:this={pageEl}
data-cols={colsTier}
style="--tb-h:{tbH}px; --pane-h:{paneH}px"
>
{#if picker}{@render artistPicker()}{/if}
{#if ambUrl}
<!-- Keyed so a track change CROSSFADES: background-image is not an
animatable property, so the outgoing wash has to survive as its own
element while the incoming one fades up over it. Both are absolute
and identically placed, so they overlap exactly. Svelte's `fade`
reads the element's own resting opacity (0.2), so this fades to the
wash's strength, not to 1. -->
{#key ambUrl}
<div class="lib-ambience" style="background-image:url({ambUrl})" transition:fade={{ duration: 600 }}></div>
{/key}
<!-- The glow is the ONLY page-wide surface that follows the highlighted
record. The override lands on this element rather than on .lib-page
so it cannot inherit into the toolbar, tabs and search ring — that
would recolour the whole page on hover, which is the global
retarget this design deliberately does not do. -->
<div class="lib-ambience-glow" style={highlightAccent}></div>
{/if}
<!-- There is no separate "nothing here" screen. An empty library is the
library page with nothing in it: same hero, same toolbar, same Import
button in the place it always lives. The takeover this replaced
REMOVED the header, so the one moment importing matters most was the
one moment there was no way to do it. -->
<header class="lib-hero">
<div class="lib-hero-top">
<h1 class="lib-h1">Library</h1>
<div class="lib-tools">
<label class="lib-search">
<Icon name="search" size={15} />
<input type="text" placeholder="Filter your library" bind:value={q} />
{#if q}
<button class="lib-clear" title="Clear" onclick={() => (q = "")}><Icon name="x" size={13} /></button>
{/if}
</label>
<button class="lib-tool" title="Rescan the library" onclick={rescan} disabled={rescanning}>
<Icon name="refresh" size={15} />
</button>
<!-- Importing is not a first-run event: another folder or
someone else's drive can arrive any day, so the way in
lives here, beside the library it feeds. -->
<button class="lib-tool"
title={importResume.active
? `An import is in progress — ${importResume.pct}% reviewed. Click to continue.`
: "Import music from a folder or drive"}
onclick={() => (ui.importOpen = importResume.active ? "continue" : true)}>
<Icon name="plus" size={15} />
Import
{#if importResume.active}
<span class="lib-import-pct">{importResume.pct}%</span>
{/if}
</button>
</div>
</div>
<div class="lib-stats">
<span class="lib-stat"><b>{totals.albums.toLocaleString()}</b><i>{totals.albums === 1 ? "album" : "albums"}</i></span>
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.artists}</b><i>{totals.artists === 1 ? "artist" : "artists"}</i></span>
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.tracks.toLocaleString()}</b><i>tracks</i></span>
{#if totals.secs}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{longDur(totals.secs)}</b><i>of music</i></span>
{/if}
{#if totals.bytes}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{fmtBytes(totals.bytes)}</b><i>on disk</i></span>
{/if}
{#if totals.never}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.never}</b><i>never played</i></span>
{/if}
</div>
</header>
<div class="lib-toolbar" bind:this={toolbarEl} data-narrow={narrow ? "1" : "0"}>
<div class="disco-tabs" role="tablist">
{#each [["shelves", "Shelves"], ["grid", "Grid"], ["list", "List"]] as [k, l] (k)}
<button
class="disco-tab"
class:on={view === k}
role="tab"
aria-selected={view === k}
onclick={() => setView(k)}
>{l}</button>
{/each}
</div>
<div class="lib-controls">
<span class="lib-ctl-lab">Group</span>
<div class="lib-seg">
{#each Object.entries(GROUPS) as [k, g] (k)}
<button class:on={group === k} onclick={() => setGroup(k)}>{g.label}</button>
{/each}
</div>
<span class="lib-ctl-lab">Sort</span>
<div class="sortwrap">
<button class="sortbtn" onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}>
{SORTS[sortKey].label} <Icon name="playlist" size={15} />
</button>
{#if menuOpen}
<div class="menu" role="menu">
<div class="mgroup">Sort by</div>
{#each Object.entries(SORTS) as [k, s] (k)}
<button
role="menuitem"
onclick={() => {
if (sortKey === k) desc = !desc;
else { sortKey = k; desc = s.desc !== false; }
menuOpen = false;
}}
>
{s.label}
{#if sortKey === k}
<span class="mcheck" class:up={!desc}><Icon name="chevron-down" size={13} /></span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<button class="lib-dir" class:up={!desc} title="Reverse order" onclick={() => (desc = !desc)}>
<Icon name="chevron-down" size={14} />
</button>
</div>
{#if view === "list"}
<!-- The column header is the bar's own last row: one box, one
pin — it cannot drift from the bar or hide behind it at
any width (handoff §4b.3, Kyle's pick). -->
<div class="lb-tr lb-th">
<span></span><span>Record</span><span>Artist</span><span>Type</span><span>Year</span><span>#</span>
<span>Length</span><span>Format</span><span>Rate</span><span>Size</span><span>Added</span>
</div>
{/if}
</div>
{#if railItems.length}
<div class="year-rail"><div class="yr-col">
{#each railItems as [l, k] (k)}
<button onclick={() => jumpTo(k)}>{l}</button>
{/each}
</div></div>
{/if}
{#if corpus.length === 0}
<div class="lib-empty">
{#if q}
<span class="icon"><Icon name="search" size={26} /></span>
<p>Nothing matches <b>{q}</b></p>
{:else}
<!-- Nothing in the library at all. Both ways in sit here, but
the toolbar's Import is still up there too — this is an
invitation, not the only door. -->
<span class="icon"><Icon name="library" size={26} /></span>
<p>Your music will live here</p>
<small>Bring in a folder you already have, or download an album from
Search — either way it lands here, tagged with art.</small>
<div class="lib-empty-acts">
<button class="lib-tool primary"
title={importResume.active
? `An import is in progress — ${importResume.pct}% reviewed. Click to continue.`
: "Import music from a folder or drive"}
onclick={() => (ui.importOpen = importResume.active ? "continue" : true)}>
<Icon name="plus" size={15} />
{importResume.active ? "Continue importing" : "Import from a folder"}
{#if importResume.active}
<span class="lib-import-pct">{importResume.pct}%</span>
{/if}
</button>
<button class="lib-tool" onclick={() => goToView("search")}>
<Icon name="search" size={15} />
Find music to download
</button>
</div>
{/if}
</div>
{:else if view === "shelves"}
{#each grouped as [name, list] (name)}
{@const shown = expandedShelves[name] ? list : list.slice(0, PER_SHELF)}
<section class="lib-shelf">
{#if name}{@render shelfHead(name, list)}{/if}
{#if group === "artist"}
{#each splitByCategory(shown) as [label, part] (label ?? "all")}
{#if label}
<div class="lib-subhead">{label}<span>{part.length}</span></div>
{/if}
{@render cardGrid(part, `${name}:${label ?? "all"}`)}
{/each}
{:else}
{@render cardGrid(shown, name || "flat")}
{/if}
{#if list.length > shown.length}
<button class="lib-more" onclick={() => (expandedShelves = { ...expandedShelves, [name]: true })}>
Show {list.length - shown.length} more <Icon name="chevron-down" size={13} />
</button>
{/if}
{#if group === "artist"}
{@render gapSection(name, list)}
{/if}
</section>
{/each}
{:else if view === "grid"}
{#each grouped as [name, list] (name)}
<section class="lib-sec">
{#if name}
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
{/if}
{@render cardGrid(list, name || "flat")}
</section>
{/each}
{:else}
<div class="lib-table">
{#each grouped as [name, list] (name)}
<section class="lib-sec">
{#if name}
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
{/if}
{#each list as a (a.id)}
<div
class="lb-tr"
class:active={openId === a.id}
style={accentFor(a)}
id={flatAnchors?.at.get(a.id) ? anchorId(flatAnchors.at.get(a.id)) : undefined}
role="button"
tabindex="0"
use:loadCover={a}
onpointerenter={() => (hoverId = a.id)}
onpointerleave={() => { if (hoverId === a.id) hoverId = null; }}
onclick={() => toggleExpander(a)}
onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)}
>
<span class="lb-cell lb-thumb">
{#if a.art_path && coverUrls[a.id]}
<img src={coverUrls[a.id]} alt="" loading="lazy" decoding="async" draggable="false" />
{:else if !a.art_path}
{@const g = genFor(a)}
<span class="lib-gen mini" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}"></span>
{:else}
<Icon name="disc" size={15} />
{/if}
</span>
<span class="lb-cell lb-strong">{a.title}</span>
<span class="lb-cell">{a.artist}</span>
<span class="lb-cell">{CAT_ONE[a.cat]}</span>
<span class="lb-cell num">{a.year ?? "—"}</span>
<span class="lb-cell num">{a.track_count || "—"}</span>
<span class="lb-cell num">{a.secs ? dur(a.secs) : "—"}</span>
<span class="lb-cell">
{#if a.formats[0]}
<span class="lb-fmt" class:hi={a.depth?.startsWith("24")}>
{a.formats[0].toUpperCase()}{#if a.depth}<i>{a.depth}</i>{/if}
</span>
{:else}—{/if}
</span>
<span class="lb-cell num">{a.avgKbps ? `${a.avgKbps}k` : "—"}</span>
<span class="lb-cell num">{fmtBytes(a.bytes)}</span>
<span class="lb-cell num">{since(a.added_at)}</span>
</div>
{#if openId === a.id}
{@render expander([a], "list")}
{/if}
{/each}
</section>
{/each}
</div>
{/if}
</div>
<style>
/* ===================================================== the page frame
A "start BELOW the bar" page whose AMBIENCE runs to the very top behind
the frost — the discography's arrangement, so like it this page takes NO
pane padding-top (see App.svelte BAR_VIEWS) and carries its own inset. */
.lib-page {
position: relative;
/* Same reason .artist-page carries it (CatalogArtist.svelte:5455): the
ambience layers sit at z-index:-1, and a negative z-index only stays
inside its parent if that parent is a STACKING CONTEXT. `position:
relative` alone is not one at `z-index: auto`, so the wash escaped
past .lib-page and .content — both transparent — and painted behind
.app's opaque black. Verified by forcing it solid magenta at opacity
1 with no blur: the banner did not change colour at all. */
isolation: isolate;
--pad-top: calc(var(--bar-h, 46px) + 14px);
/* --xp-reserve is the open expander's scroll reserve (revealIntoView):
the range the pane needs to park the open row under the bar, spent
at the very END of the page so it reads as room to scroll instead of
a hole punched in the middle of a shelf. 0 whenever the page is
already long enough, which on a library of any size is most of it. */
padding: var(--pad-top) 0 calc(56px + var(--xp-reserve, 0px));
min-width: 0;
/* The toolbar pins 10px under the glass bar — two glass surfaces
reading as two objects. --tb-h is MEASURED by the page (a typed
height parked the joined header behind a two-row bar); --tb-clear is
what everything below the bar scrolls to. */
--tb-top: calc(var(--bar-h, 46px) - var(--pane-pad, 24px) + 10px);
--tb-clear: calc(var(--tb-top) + var(--tb-h, 50px) + 8px);
/* The index rail's gutter is reserved ALWAYS — the rail draws or does
not draw inside a gutter that never changes width, so no control
click moves the content column (handoff §4b.1). */
--rail-gutter: 34px;
padding-right: var(--rail-gutter);
}
/* ------------------------------------------------------------ ambience
The blurred most-recently-played cover behind the frost, faded out
before the shelves — the discography's recipe at the library's colour. */
.lib-ambience {
position: absolute;
left: -28px;
right: -28px;
top: calc(-1 * var(--pad-top));
height: 430px;
/* 0, not -1 — this is what the approved mockup actually renders
(library.css declares -1 on line 199 but each layer's own block sets
0 later, and the later equal-specificity rule wins). At 0 a
positioned layer paints ABOVE the page's in-flow content, so the
wash lies over the title and stat row rather than only showing in
the gaps between them. That is the look on the mockup, and it is why
.lib-h1 carries a text-shadow. Requires the isolation on .lib-page
either way: at -1 it escaped the page entirely. */
z-index: 0;
pointer-events: none;
background-size: cover;
background-position: center 30%;
filter: blur(90px) saturate(1.5);
opacity: 0.2;
transform: translateZ(0);
-webkit-mask-image: linear-gradient(180deg, #000 35%, transparent);
mask-image: linear-gradient(180deg, #000 35%, transparent);
}
.lib-ambience-glow {
position: absolute;
left: -28px;
right: -28px;
top: calc(-1 * var(--pad-top));
height: 300px;
z-index: 0; /* same as .lib-ambience above — the mockup's real order */
pointer-events: none;
background: radial-gradient(640px 260px at 14% 0%,
color-mix(in srgb, var(--accent) 13%, transparent), transparent 70%);
/* --accent is a registered @property (<color>), so the scoped override
this element carries CROSSFADES instead of cutting — which is what
makes a pointer sweeping a shelf read as one drifting wash rather
than a strobe. One element, so the per-frame color-mix is cheap. */
transition: --accent 420ms ease, --accent-hover 420ms ease;
}
@media (prefers-reduced-motion: reduce) {
.lib-ambience-glow { transition: none; }
}
/* ---------------------------------------------------------------- hero */
.lib-hero { padding-top: 4px; }
.lib-hero-top { display: flex; align-items: center; gap: 16px; }
.lib-h1 {
font-size: 31px;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.4);
}
.lib-tools { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.lib-search {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
width: 250px;
padding: 0 11px;
background: color-mix(in srgb, var(--bg-elevated) 74%, transparent);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
backdrop-filter: blur(6px);
transition: border-color 140ms ease, color 140ms ease;
}
.lib-search:focus-within { border-color: var(--accent); color: var(--text-secondary); }
.lib-search input {
flex: 1;
min-width: 0;
background: none;
border: 0;
outline: none;
color: var(--text-primary);
font: inherit;
font-size: 12.5px;
font-weight: 600;
}
.lib-search input::placeholder { color: var(--text-muted); font-weight: 500; }
.lib-clear { display: flex; color: var(--text-muted); cursor: pointer; }
.lib-clear:hover { color: var(--text-primary); }
/* The ongoing-import badge on the Import tool: quiet accent pill, the
percentage the review has covered. */
.lib-import-pct {
font-size: 10px;
font-weight: 800;
font-variant-numeric: tabular-nums;
padding: 1px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 26%, transparent);
color: var(--accent-hover, var(--accent));
}
.lib-tool {
display: inline-flex;
align-items: center;
gap: 7px;
height: 32px;
padding: 0 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--bg-elevated) 74%, transparent);
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
cursor: pointer;
backdrop-filter: blur(6px);
transition: color 140ms ease, border-color 140ms ease;
}
.lib-tool:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
.lib-tool:disabled { opacity: 0.5; cursor: default; }
/* The stat row — the album page's value/label idiom for the collection.
Derived numbers; the filter field moves every one of them. */
.lib-stats {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
margin: 15px 0 20px;
}
.lib-stat { display: inline-flex; align-items: baseline; gap: 7px; white-space: nowrap; }
.lib-stat b {
font-size: 15.5px;
font-weight: 800;
letter-spacing: -0.01em;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.lib-stat i {
font-style: normal;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-muted);
}
.lib-stat-div {
width: 1px;
height: 17px;
flex: none;
background: linear-gradient(rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.03));
}
/* ------------------------------------------------------------- toolbar
A FLOATING ROUNDED CARD (the search hero's own words), pinned 10px
under the glass bar. It bleeds out by exactly its own padding so its
CONTENT sits on the page column while the card floats 14px wider. Its
minimum height is a CONSTANT — never --tb-h, which is measured off this
bar; feeding a measurement back into the property that decides it was a
ratchet (handoff trap #9). */
.lib-toolbar {
position: sticky;
top: var(--tb-top);
z-index: 6;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
min-height: 50px;
margin: 0 -14px 26px;
padding: 9px 14px;
border-radius: 14px;
background: color-mix(in srgb, var(--bg-elevated) 82%, transparent);
backdrop-filter: blur(16px) saturate(1.2);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
0 14px 34px rgba(0, 0, 0, 0.5);
}
/* ===== the discography's tab row (CatalogDiscography.svelte) ===== */
.disco-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
.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);
}
.lib-toolbar .disco-tabs { flex: 0 0 auto; margin-bottom: 0; }
.lib-controls {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
min-width: 0;
}
/* Two rows when one will not do — MEASURED, not a breakpoint. The controls
take the full width so a wrapped bar reads as two deliberate rows. */
.lib-toolbar[data-narrow="1"] { flex-wrap: wrap; }
.lib-toolbar[data-narrow="1"] .lib-controls {
margin-left: 0;
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
row-gap: 9px;
padding-top: 2px;
}
.lib-toolbar[data-narrow="1"] .lib-seg {
overflow-x: auto;
scrollbar-width: none;
max-width: 100%;
}
.lib-toolbar[data-narrow="1"] .lib-seg::-webkit-scrollbar { height: 0; }
.lib-ctl-lab {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
margin-left: 2px;
}
.lib-seg {
display: flex;
gap: 3px;
padding: 3px;
min-width: 0;
background: rgba(255, 255, 255, 0.05);
border-radius: 9px;
}
.lib-seg button {
height: 26px;
padding: 0 10px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 700;
white-space: nowrap;
color: var(--text-muted);
cursor: pointer;
transition: color 120ms ease, background 120ms ease;
}
.lib-seg button:hover { color: var(--text-primary); }
.lib-seg button.on { color: var(--text-primary); background: rgba(255, 255, 255, 0.12); }
.lib-dir {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 7px;
color: var(--text-secondary);
cursor: pointer;
transition: transform 160ms ease, color 120ms ease;
}
.lib-dir:hover { color: var(--text-primary); background: var(--bg-highlight); }
.lib-dir.up :global(svg) { transform: rotate(180deg); }
/* ===== the sort menu (CatalogDiscography ← PlayerBar's popover) ===== */
.sortwrap { position: relative; flex: none; }
.sortbtn {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 700;
color: var(--text-secondary);
padding: 5px 8px;
border-radius: 6px;
}
.sortbtn:hover { color: var(--text-primary); background: var(--bg-highlight); }
.menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 40;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 186px;
padding: 7px;
border-radius: 9px;
background: #0d0d0d;
border: 1px solid var(--border);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.7);
}
.menu button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
}
.menu button:hover { background: var(--bg-highlight); color: var(--text-primary); }
.menu .mgroup {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-muted);
padding: 6px 9px 3px;
}
.menu button .mcheck { margin-left: auto; display: inline-flex; color: var(--accent-hover); }
/* ---- the "which artist?" picker: the page's own .menu, centred --------
Only ever shown when a name is genuinely ambiguous, so it is a short
list of choices — which is what a menu is. The veil exists to make the
next click a dismissal rather than a mis-click on the page behind. */
.lib-picker-veil {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(8, 8, 10, 0.5);
backdrop-filter: blur(2px);
border: 0;
}
.lib-picker {
position: fixed;
z-index: 41;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
min-width: 288px;
max-width: 380px;
max-height: 70vh;
overflow-y: auto;
}
.lib-picker .mgroup b { color: var(--text-primary); font-weight: 700; }
.lib-pick { display: flex; align-items: center; gap: 9px; }
.lib-pick-art {
width: 26px;
height: 26px;
border-radius: 50%;
object-fit: cover;
flex: none;
}
.lib-pick-none {
display: grid;
place-items: center;
background: var(--bg-highlight);
color: var(--text-muted);
}
.lib-pick-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lib-pick-fans { margin-left: auto; color: var(--text-muted); font-size: 11px; flex: none; }
.lib-pick-empty {
padding: 6px 11px 10px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.45;
}
.menu button .mcheck.up { transform: rotate(180deg); }
/* ===== the index rail (CatalogDiscography.svelte), pointed at whatever
the page is grouped by ===== */
.year-rail {
position: sticky;
top: calc(var(--pane-h, 760px) / 2 - var(--pane-pad, 24px));
height: 0;
z-index: 4;
}
.year-rail .yr-col {
position: absolute;
right: calc(-1 * (16px + var(--rail-gutter)));
top: 0;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 1px;
align-items: flex-end;
}
.year-rail button {
font-size: 10.5px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
padding: 1px 6px;
border-radius: 4px;
line-height: 1.6;
cursor: pointer;
}
.year-rail button:hover { color: var(--text-primary); background: var(--bg-highlight); }
/* ============================================================= shelves */
.lib-shelf { margin-top: 40px; content-visibility: auto; contain-intrinsic-size: auto 360px; }
.lib-shelf:first-of-type { margin-top: 2px; }
.lib-shelf-head {
display: flex;
align-items: center;
gap: 13px;
margin: 0 0 14px;
scroll-margin-top: var(--tb-clear);
}
.lib-shelf-head[role="button"] { cursor: pointer; }
.lib-shelf-dot {
width: 48px;
height: 48px;
border-radius: 50%;
flex: none;
display: grid;
place-items: center;
background: var(--bg-elevated);
color: var(--text-muted);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
overflow: hidden;
}
/* The portrait lands after the head has drawn, so it fades rather than
pops — the same rule the rest of the page follows for late art. */
.lib-shelf-dot img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
animation: lib-portrait-in 220ms ease both;
}
@keyframes lib-portrait-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.lib-shelf-dot img { animation: none; }
}
.lib-shelf-head .n {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 17px;
font-weight: 800;
letter-spacing: -0.01em;
}
.lib-shelf-go { display: inline-flex; color: var(--text-muted); opacity: 0; transition: opacity 140ms ease, transform 140ms ease; }
.lib-shelf-head[role="button"]:hover .lib-shelf-go { opacity: 1; transform: translateX(2px); }
.lib-shelf-head[role="button"]:hover .n { text-decoration: underline; text-underline-offset: 3px; }
.lib-shelf-head .note { font-size: 12px; color: var(--text-muted); margin-top: 1px; }
/* The completeness the note states in words, shown: a quiet 3px bar. */
.lib-own {
display: block;
width: 148px;
height: 3px;
margin-top: 7px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.09);
overflow: hidden;
}
.lib-own i {
display: block;
height: 100%;
border-radius: 2px;
background: color-mix(in srgb, var(--accent) 75%, #fff 0%);
}
.lib-subhead {
display: flex;
align-items: center;
gap: 9px;
margin: 20px 0 10px;
padding-bottom: 6px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-secondary);
border-bottom: 1px solid transparent;
border-image: linear-gradient(90deg, var(--border), transparent 72%) 1;
}
.lib-subhead span { color: var(--text-muted); font-weight: 700; letter-spacing: 0; }
.lib-more {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 2px;
padding: 6px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.05);
cursor: pointer;
}
.lib-more:hover { color: var(--text-primary); background: rgba(255, 255, 255, 0.09); }
.lib-group-rule {
display: flex;
align-items: center;
gap: 10px;
margin: 22px 0 8px;
padding: 0 10px 6px;
border-bottom: 1px solid var(--border);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
scroll-margin-top: var(--tb-clear);
}
.lib-group-rule span { color: var(--text-muted); font-weight: 700; letter-spacing: 0; }
.lib-sec { content-visibility: auto; contain-intrinsic-size: auto 360px; }
/* ===== the release grid (CatalogArtist.svelte): fixed 150px tracks,
margins carry the vertical rhythm so a collapsing expander leaves no
orphan gap ===== */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, 150px);
column-gap: 16px;
row-gap: 0;
align-items: start;
align-content: start;
overflow-anchor: none;
}
/* ===== the release card (CatalogArtist.svelte), class for class ===== */
.release {
display: flex;
flex-direction: column;
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;
scroll-margin-top: var(--tb-clear);
}
.release:hover,
.release:focus-visible { background: var(--bg-elevated); }
.release.active {
background: var(--bg-elevated);
box-shadow: inset 0 0 0 1.5px var(--accent);
}
.cover-wrap { position: relative; }
.cover {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
}
.cover.placeholder,
.cover.gen-pending {
display: grid;
place-items: center;
background: var(--bg-elevated);
color: var(--text-muted);
}
.title {
font-size: 13px;
font-weight: 700;
margin-top: 9px;
line-height: 1.3;
overflow-wrap: anywhere;
}
.sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.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);
}
.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; }
/* ================================================== generated artwork
Deterministic from the record's own name; texture follows what the
thing IS (groove rings live, tape stripes bonus material, dot grain
otherwise). Approved on the prototype, ported whole. */
.lib-gen {
position: relative;
overflow: hidden;
display: grid;
place-items: center;
background:
radial-gradient(130% 100% at 18% 0%, hsl(var(--h1) 48% 37% / 0.85), transparent 56%),
radial-gradient(120% 130% at 100% 104%, hsl(var(--h2) 44% 19%), transparent 68%),
linear-gradient(152deg, hsl(var(--h1) 37% 24%), hsl(var(--h2) 33% 10%));
}
.lib-gen::before {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 3px 3px;
}
.lib-gen[data-pat="rings"]::before {
background: repeating-radial-gradient(circle at 50% 50%,
rgba(255, 255, 255, 0.055) 0 1px, transparent 1px 5px);
}
.lib-gen[data-pat="tape"]::before {
background: repeating-linear-gradient(-38deg,
rgba(255, 255, 255, 0.05) 0 2px, transparent 2px 10px);
}
.lib-gen::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.07),
inset 0 26px 46px -30px rgba(255, 255, 255, 0.09);
}
.lib-gen-glyph { color: rgba(255, 255, 255, 0.3); display: flex; position: relative; z-index: 1; }
.lib-gen.mini { width: 100%; height: 100%; border-radius: 4px; }
/* ===================================================== the gap summary
Records you do not own: one line at the end of the shelf, opening into
FULL-SIZE cards dimmed to readable — full strength on hover. */
.lib-gapline {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
margin-top: 4px;
padding: 8px 10px;
border-radius: 8px;
font-size: 12.5px;
color: var(--text-secondary);
cursor: pointer;
}
.lib-gapline b { font-weight: 800; color: var(--text-primary); }
.lib-gapline::after {
content: "";
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--border), transparent 85%);
}
.lib-gapline:hover { color: var(--text-primary); background: rgba(255, 255, 255, 0.04); }
.lib-gapline-cta {
display: inline-flex;
align-items: center;
gap: 4px;
order: 2;
font-size: 11.5px;
font-weight: 700;
color: var(--text-muted);
}
.lib-gapline:hover .lib-gapline-cta { color: var(--text-primary); }
.lib-gaps { margin-top: 12px; }
.lib-gaps .release.gap { opacity: 0.44; transition: opacity 150ms ease; cursor: default; }
.lib-gaps .release.gap:hover { opacity: 1; background: none; }
/* ============================================================ the table
The power-user surface: every column is something the files carry. The
template lives on the PAGE (data-cols), above both the joined header
and the rows, so the two can never be laid out differently — a 1fr
column floors at min-content and RECORD is wider than an ellipsized
title (handoff §4b.4). Columns in order:
1 art · 2 Record · 3 Artist · 4 Type · 5 Year · 6 # · 7 Length
8 Format · 9 Rate · 10 Size · 11 Added */
.lib-table { margin-top: 2px; }
.lb-tr {
display: grid;
gap: 10px;
align-items: center;
height: 36px;
padding: 0 10px;
border-radius: 7px;
font-size: 12.5px;
cursor: pointer;
/* 800+ rows: offscreen ones skip render entirely. The intrinsic
hint matches the fixed 36px height exactly, so scroll geometry
(and every offset-based jump) is unchanged — this took the
library's mount task from ~112ms and its cold-scroll raster
spikes down to screen-sized work (measured 2026-08-26). */
content-visibility: auto;
contain-intrinsic-size: auto 36px;
}
.lib-page[data-cols="all"] .lb-tr {
grid-template-columns: 34px minmax(150px, 1fr) 150px 70px 46px 40px 56px 96px 58px 66px 74px;
}
.lib-page[data-cols="b"] .lb-tr {
grid-template-columns: 34px minmax(150px, 1fr) 150px 70px 46px 56px 96px 66px 74px;
}
.lib-page[data-cols="c"] .lb-tr {
grid-template-columns: 34px minmax(150px, 1fr) 150px 46px 56px 96px 74px;
}
.lib-page[data-cols="d"] .lb-tr {
grid-template-columns: 34px minmax(120px, 1fr) 150px 46px 96px;
}
.lib-page[data-cols="b"] .lb-tr > :nth-child(6),
.lib-page[data-cols="b"] .lb-tr > :nth-child(9),
.lib-page[data-cols="c"] .lb-tr > :nth-child(4),
.lib-page[data-cols="c"] .lb-tr > :nth-child(6),
.lib-page[data-cols="c"] .lb-tr > :nth-child(9),
.lib-page[data-cols="c"] .lb-tr > :nth-child(10),
.lib-page[data-cols="d"] .lb-tr > :nth-child(4),
.lib-page[data-cols="d"] .lb-tr > :nth-child(6),
.lib-page[data-cols="d"] .lb-tr > :nth-child(7),
.lib-page[data-cols="d"] .lb-tr > :nth-child(9),
.lib-page[data-cols="d"] .lb-tr > :nth-child(10),
.lib-page[data-cols="d"] .lb-tr > :nth-child(11) { display: none; }
.lb-tr:not(.lb-th) {
content-visibility: auto;
contain-intrinsic-size: auto 36px;
scroll-margin-top: var(--tb-clear);
}
.lb-tr:not(.lb-th):hover { background: var(--bg-highlight); }
.lb-tr.active {
background: var(--bg-elevated);
box-shadow: inset 0 0 0 1.5px var(--accent);
}
/* The joined column header: the bar's own last row, bleeding to the
card's edges; 24px left padding puts its columns back on the rows' x
(the card starts at -14, the rows pad 10). */
.lib-toolbar .lb-th {
order: 9;
flex: 0 0 calc(100% + 28px);
height: auto;
margin: 0 -14px -9px;
padding: 8px 24px 9px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
color: var(--text-muted);
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
cursor: default;
}
.lb-th > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb-th > :nth-child(5), .lb-th > :nth-child(6), .lb-th > :nth-child(7),
.lb-th > :nth-child(9), .lb-th > :nth-child(10), .lb-th > :nth-child(11) { text-align: right; }
.lb-cell {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-secondary);
}
.lb-cell.num { text-align: right; font-variant-numeric: tabular-nums; color: var(--text-muted); }
.lb-strong { color: var(--text-primary); font-weight: 650; }
.lb-thumb {
width: 26px;
height: 26px;
border-radius: 4px;
overflow: hidden;
position: relative;
color: var(--text-muted);
display: flex;
}
.lb-thumb img { width: 100%; height: 100%; object-fit: cover; }
.lb-fmt {
display: inline-flex;
align-items: baseline;
gap: 5px;
padding: 2px 8px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.06);
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
.lb-fmt i {
font-style: normal;
font-weight: 700;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.lb-fmt.hi {
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--accent-hover);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 34%, transparent);
}
.lb-fmt.hi i { color: color-mix(in srgb, var(--accent-hover) 70%, var(--text-secondary)); }
/* A panel that opens under a table row has no grid rhythm around it. */
.lib-table .album-expander { margin: 6px 0 16px; }
/* ====================================== the inline expander
CatalogArtist.svelte's, class for class — same numbers, same notch,
same rail recipe. Only the content differs (file facts, not Last.fm). */
.album-expander {
grid-column: 1 / -1;
position: relative;
margin-bottom: 16px;
}
.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);
animation: notch-fade 200ms ease 300ms backwards;
}
@keyframes notch-fade {
from { opacity: 0; }
to { opacity: 1; }
}
.xp-stage { position: relative; }
.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;
}
.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 {
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;
}
/* CatalogArtist's .rail-art-stage / .rail-art-mark, rule for rule — the
shared --art-grow / --art-motion / --art-wash tokens in global.css are
what make artwork behave the same wherever it opens the viewer. The
whole element scales rather than the picture inside it, because
`transform` does not reflow. */
.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;
}
/* A generated cover has no file to open, so it offers nothing: no pointer,
no lift, no wash. (The mark is not rendered at all in that case.) */
.rail-art-stage:disabled { cursor: default; }
.rail-art-stage:disabled:hover { transform: none; }
.rail-art-stage:disabled:hover::after { opacity: 0; }
@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;
}
}
.rail-art {
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);
}
div.rail-art { display: grid; place-items: center; }
.rail-title {
margin: 0;
font-size: 16.5px;
font-weight: 800;
line-height: 1.25;
}
/* The name as a link to the record's own page — CatalogArtist's
.rail-title-link: 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-meta {
margin: -7px 0 0;
font-size: 12.5px;
color: var(--text-secondary);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
/* ---- the record's editorial, CatalogArtist's rules verbatim ---------- */
.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);
}
.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); }
/* Genre and label only — the date is already in .rail-meta directly above,
and repeating it costs 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-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); }
.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 {
background: var(--accent);
color: #000;
font-size: 11px;
font-weight: 700;
padding: 6px 14px;
border-radius: 14px;
white-space: nowrap;
cursor: pointer;
}
.pill:hover { background: var(--accent-hover); }
.pill.ghost { background: var(--bg-highlight); color: var(--text-primary); }
.pill.ghost:hover { background: var(--bg-press, #333); }
/* The add-to-playlist trigger wears the ghost-pill look so the CTA row
reads as one family; the component itself stays unstyled chrome. */
.rail-cta :global(.plm-trigger) {
background: var(--bg-highlight);
color: var(--text-primary);
padding: 6px 11px;
border-radius: 14px;
}
.rail-cta :global(.plm-trigger:hover) { background: var(--bg-press, #333); }
.pill.grow { flex: 1; }
.pill:disabled {
background: var(--bg-highlight);
color: var(--text-muted);
cursor: default;
opacity: 0.6;
}
/* ---- the tracklist ---- */
.xp-tracks { padding: 14px 16px; min-width: 0; }
.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; }
/* CatalogArtist's `.disc-head`, verbatim — a multi-disc record has to read
the same here as it does on the artist page. */
.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);
}
.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;
}
.trow:hover { background: var(--bg-highlight); }
.trow.now .tno,
.trow.now .ttitle { color: var(--accent-hover); }
.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;
}
/* What a FILE knows, on the row that names the track — muted, tabular,
last; the title is still the row. */
.tk-rate {
margin-left: 9px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.tdur {
color: var(--text-muted);
font-size: 12.5px;
font-variant-numeric: tabular-nums;
transition: opacity 0.12s ease;
}
.trow:hover .tdur,
.trow:focus-within .tdur { opacity: 0; }
.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;
color: var(--text-secondary);
cursor: pointer;
}
.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; }
.mini-btn:hover,
.mini-get:hover { color: var(--accent-hover); }
.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); }
}
@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);
}
}
@media (prefers-reduced-motion: reduce) {
.notch { animation-duration: 1ms; animation-delay: 0ms; }
.hoverplay,
.lib-shelf-go { transition-duration: 1ms; }
}
/* ---------------------------------------------------------- empty states */
.lib-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 13vh 0;
color: var(--text-muted);
text-align: center;
}
.lib-empty .icon {
width: 52px;
height: 52px;
padding: 13px;
border-radius: 50%;
background: var(--bg-elevated);
display: grid;
place-items: center;
}
.lib-empty b { color: var(--text-primary); }
.lib-empty small { margin-top: -4px; font-size: 13px; max-width: 52ch; line-height: 1.5; }
.lib-empty-acts {
margin-top: 8px;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
}
/* The one action that starts something. Same object as the toolbar's
tools, wearing the accent — nothing new invented for an empty page. */
.lib-tool.primary {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.lib-tool.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #000; }
.lib-tool.primary .lib-import-pct { background: rgba(0, 0, 0, 0.22); color: #000; }
/* ── dark glass ────────────────────────────────────────────────────────
The ladder lives in global.css. Solid mode sees none of this.
The toolbar was the loudest surface on the page and it was loud for two
compounding reasons, both measured on 2026-08-27 with a known-colour
ground substituted behind the window (research/scroll-diag/glassshot.mjs):
its own fill nets 0.82 + 0.7075x0.18 = 0.947 — a slab by alpha alone —
AND its backdrop-filter samples the see-through pane, which the
compositor flattens against black (the measurement lives in
Sidebar.svelte). Chroma of the shipped bar, blue-minus-red over a
#6b3fa8 ground: +2, i.e. dead. Fill alone got it to +12; dropping the
filter as well got it to +16, against +30 for the bare pane beside it.
Both halves were needed, so both are here. */
:global(:root.glass) .lib-toolbar {
background: rgba(255, 255, 255, 0.06);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Same story, smaller: the field and the tool buttons sit in that bar. */
:global(:root.glass) .lib-search,
:global(:root.glass) .lib-tool {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:global(:root.glass) .lib-search {
background: var(--glass-well);
border-color: var(--glass-hairline);
}
/* .lib-tool.primary — the Import button — keeps its accent fill: a filled
accent button is meant to read as solid. :not(.primary) is what makes
that true. `:root.glass .lib-tool` carries Svelte's scope hash and
scores (0,4,0), which outranks `.lib-tool.primary` at (0,3,0), so
without the exclusion this rule quietly repaints the accent button as a
3%-white ghost. Verified by reading the computed style back. */
:global(:root.glass) .lib-tool:not(.primary) {
background: var(--glass-surface);
border-color: var(--glass-hairline);
}
/* Rows and cards on the pane. */
:global(:root.glass) .lb-tr:not(.lb-th):hover,
:global(:root.glass) .release:hover,
:global(:root.glass) .release:focus-visible,
:global(:root.glass) .lib-dir:hover,
:global(:root.glass) .sortbtn:hover {
background: var(--glass-hover);
}
/* Selected keeps its accent ring doing the work — only the fill under the
ring changes, so the two states stay as far apart as they were. */
:global(:root.glass) .release.active,
:global(:root.glass) .lb-tr.active {
background: var(--glass-hover);
}
/* Artwork wells and the shelf avatars. */
:global(:root.glass) .cover.placeholder,
:global(:root.glass) .cover.gen-pending,
:global(:root.glass) .lib-shelf-dot,
:global(:root.glass) .lib-pick-none {
background: var(--glass-surface);
}
/* The album expander and the notch that points at its card. These two MUST
carry the same value: the notch is a rotated square half-buried under the
panel's top edge, so any difference between them draws a triangle. */
:global(:root.glass) .xp-panel,
:global(:root.glass) .notch {
background: var(--glass-surface);
}
/* The rail's shade is a legibility scrim over the blurred cover behind the
CTA column. Its solid-mode tail is rgba(24,24,24,0.92), which on a now
translucent panel turns the rail's lower half into a near-opaque block
inside an otherwise glass card — the stacked-tint failure the artist
page already paid for once. Same shape, same colour, thinned so the
panel keeps ONE value top to bottom; the glow it is shading rests at
0.32 opacity, so this is still more than enough to read over. */
: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%
);
}
:global(:root.glass) .pill.ghost,
:global(:root.glass) .pill:disabled,
:global(:root.glass) .rail-cta :global(.plm-trigger) {
background: var(--glass-surface);
}
:global(:root.glass) .pill.ghost:hover,
:global(:root.glass) .rail-cta :global(.plm-trigger:hover) {
background: var(--glass-hover);
}
</style>