diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs index c55b310..92c4ac4 100644 --- a/crates/library/src/lib.rs +++ b/crates/library/src/lib.rs @@ -288,6 +288,21 @@ impl Library { Ok(()) } + /// One row per distinct album artist, with whatever portrait path is + /// stored against it. The caller decides what counts as missing — the + /// library has no view of the filesystem, and a path that is present in the + /// database but gone from disk is exactly the case worth repairing. + pub fn artist_portraits(&self) -> Result)>> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT artist, MAX(artist_art_path) FROM albums GROUP BY artist COLLATE NOCASE", + )?; + let rows = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::, _>>()?; + Ok(rows) + } + pub fn set_artist_art(&self, artist: &str, art_path: &str) -> Result { let conn = self.conn.lock().unwrap(); Ok(conn.execute( diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index c2596f3..b454ff8 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -8843,18 +8843,25 @@ impl MetadataClient { /// Best-effort artist portrait from Deezer's public catalog. The image is /// cached locally by the app and is only used for the library UI. pub async fn fetch_artist_art(&self, artist: &str) -> Option> { + // `limit=1` + `/data/0` took whatever Deezer happened to rank first, + // with no name check and no prominence check. For a shared name that is + // routinely a dead duplicate row: "GUM" resolved to the 90-fan entry + // whose portrait is the empty-md5 placeholder, so the library shelf drew + // a grey silhouette while the real 3707-fan GUM has a photo. + // + // Ask for a handful instead and break the tie the way the rest of the + // crate does — exact name first, then followers (see + // `deezer_exact_name_state`) — and never accept the placeholder. let url = format!( - "https://api.deezer.com/search/artist?q={}&limit=1", + "https://api.deezer.com/search/artist?q={}&limit=10", urlencode(artist) ); // Library imports call this once per artist, so it shares the same // cache, single-flight and request-budget gate as every other Deezer // read instead of spending an unpaced request beside them. let body = self.deezer(&url).await?; - let image = body - .pointer("/data/0/picture_xl") - .and_then(|v| v.as_str())?; - self.get_image(image).await + let image = pick_artist_portrait(&body, artist)?.to_string(); + self.get_image(&image).await } /// Fetch an image and return it as a data URL. Catalog covers live on @@ -11515,6 +11522,13 @@ fn deezer_search_allows_bing( /// (`crazy_p`, `Pond` x6) that no search engine ranks for. Requiring a decisive /// margin keeps the real collisions — the several genuinely followed Ponds — /// on the strict catalogue-anchored path. +/// Deezer's "this artist has no photo" portrait is the md5 of the EMPTY STRING. +/// The frontend carries the same constant for the sidebar's portrait fallback +/// (`DEEZER_EMPTY_PICTURE_HASH` in src/lib/state.svelte.js) — there the hash is +/// only reachable through the CDN's redirect, but a search row's `picture_*` +/// often carries it literally, which is enough to reject the row outright. +const DEEZER_EMPTY_PICTURE_HASH: &str = "d41d8cd98f00b204e9800998ecf8427e"; + const BING_NAME_OWNER_FAN_RATIO: u64 = 4; /// Floor so two obscure rows (5 fans against 1) cannot manufacture a ratio. const BING_NAME_OWNER_MIN_FANS: u64 = 25; @@ -11623,6 +11637,38 @@ fn deezer_quota_error(body: &serde_json::Value) -> bool { .is_some_and(|message| message.to_lowercase().contains("quota")) } +/// Choose which row of a Deezer artist search owns the portrait for `artist`. +/// +/// Exact name outranks any follower count — a bigger artist whose name merely +/// CONTAINS the query is the wrong answer, not a better one — and rows carrying +/// the empty-md5 placeholder are dropped entirely. Returning `None` (every +/// candidate placeholder-only) is deliberate: the shelf head's fallback icon is +/// honest, where a stored grey silhouette reads as a broken fetch. +fn pick_artist_portrait<'a>(body: &'a serde_json::Value, artist: &str) -> Option<&'a str> { + let results = body.get("data").and_then(|data| data.as_array())?; + let wanted = normalize(artist); + let mut ranked: Vec<(bool, u64, &str)> = results + .iter() + .filter_map(|row| { + let name = row.get("name").and_then(|name| name.as_str())?; + let picture = row + .get("picture_xl") + .or_else(|| row.get("picture_big")) + .and_then(|picture| picture.as_str())?; + if picture.contains(DEEZER_EMPTY_PICTURE_HASH) { + return None; + } + let fans = row + .get("nb_fan") + .and_then(|fans| fans.as_u64()) + .unwrap_or_default(); + Some((normalize(name) == wanted, fans, picture)) + }) + .collect(); + ranked.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1))); + ranked.first().map(|(_, _, picture)| *picture) +} + fn deezer_exact_name_state( body: &serde_json::Value, requested_name: &str, @@ -15024,6 +15070,46 @@ mod tests { )); } + #[test] + fn artist_portrait_skips_the_placeholder_duplicate() { + // The real GUM search, reduced: the first row Deezer returned had the + // empty-md5 portrait, which `limit=1` stored as the library's shelf art. + let gum = json!({ "data": [ + { "name": "GUM", "nb_fan": 90, + "picture_xl": "https://cdn-images.dzcdn.net/images/artist/d41d8cd98f00b204e9800998ecf8427e/1000x1000.jpg" }, + { "name": "GUM", "nb_fan": 3707, + "picture_xl": "https://cdn-images.dzcdn.net/images/artist/0728a4530cd8ee40f1212845423/1000x1000.jpg" }, + { "name": "Gumball", "nb_fan": 900_000, + "picture_xl": "https://cdn-images.dzcdn.net/images/artist/aaaa/1000x1000.jpg" }, + ]}); + assert_eq!( + pick_artist_portrait(&gum, "GUM"), + Some("https://cdn-images.dzcdn.net/images/artist/0728a4530cd8ee40f1212845423/1000x1000.jpg"), + ); + } + + #[test] + fn artist_portrait_prefers_the_exact_name_over_a_bigger_namesake() { + // A far more popular artist whose name merely contains the query must + // never win — that is how a shelf ends up wearing the wrong face. + let body = json!({ "data": [ + { "name": "Weezer Rascal", "nb_fan": 5_000_000, "picture_xl": "https://x/rascal.jpg" }, + { "name": "Weezer", "nb_fan": 725_694, "picture_xl": "https://x/weezer.jpg" }, + ]}); + assert_eq!(pick_artist_portrait(&body, "Weezer"), Some("https://x/weezer.jpg")); + } + + #[test] + fn artist_portrait_is_none_when_every_candidate_is_a_placeholder() { + // Nothing beats the fallback icon here; a stored silhouette would read + // as a failed fetch forever. + let body = json!({ "data": [ + { "name": "Nobody", "nb_fan": 3, + "picture_xl": "https://cdn-images.dzcdn.net/images/artist/d41d8cd98f00b204e9800998ecf8427e/1000x1000.jpg" }, + ]}); + assert_eq!(pick_artist_portrait(&body, "Nobody"), None); + } + #[test] fn bing_fallback_requires_an_unambiguous_exact_name() { let pond = json!({ "data": [ diff --git a/src-tauri/src/pipeline.rs b/src-tauri/src/pipeline.rs index 52d81e1..e86aa5a 100644 --- a/src-tauri/src/pipeline.rs +++ b/src-tauri/src/pipeline.rs @@ -280,9 +280,56 @@ async fn scan_downloads(app: &tauri::AppHandle) { process_file(app, &path, None).await; } } + repair_artist_portraits(app).await; let _ = app.emit("soulseek:library-changed", ()); } +/// Artist portraits are written ONCE, by `process_file` at import. This scan +/// skips every file it already knows, so a portrait that later goes missing +/// from disk — a cleared art directory, a half-copied profile — leaves the +/// database pointing at a file that will never come back, and the shelf head +/// falls back to its icon forever. +/// +/// Repair exactly that: an artist whose stored path is NULL or no longer on +/// disk. Untouched portraits cost nothing, and an affected artist costs one +/// paced Deezer read (shared budget, same as every other read here). +/// +/// Deliberately NOT a re-resolve of portraits that DO exist. That would repair +/// the other half of this problem — entries stored before `fetch_artist_art` +/// learned to rank candidates and reject Deezer's empty-md5 placeholder — but +/// it is a request per artist on every scan, and it would overwrite a portrait +/// the user is already looking at. Dropping the `needs` condition below is all +/// it would take, if that is ever wanted. +async fn repair_artist_portraits(app: &tauri::AppHandle) { + let state = app.state::(); + let stored = { + let lib = state.library.clone(); + match tauri::async_runtime::spawn_blocking(move || lib.artist_portraits()).await { + Ok(Ok(rows)) => rows, + _ => return, + } + }; + for (artist, path) in stored { + let needs = match path.as_deref() { + None => true, + Some(p) => !Path::new(p).exists(), + }; + if !needs { + continue; + } + if let Some(fresh) = fetch_artist_art_cached(&state, &artist).await { + let lib = state.library.clone(); + let name = artist.clone(); + let updated = + tauri::async_runtime::spawn_blocking(move || lib.set_artist_art(&name, &fresh)) + .await; + if let Ok(Ok(n)) = updated { + log::info!("artist portrait repaired for {artist} ({n} albums)"); + } + } + } +} + fn collect_audio_files(dir: &Path, out: &mut Vec, depth: u8) { if depth > 6 { return; diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index 77f3736..9889662 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -3816,6 +3816,39 @@ }; } + /** Fade+lift ONE element from its own mount, with the same WAAPI call + * `staggerIn` makes. + * + * The description needs this because `in:partIn` above never actually ran + * for it, and that was measured on this page: opening a Weezer record and + * sampling `.rail-lede` every frame gave opacity 1 on all 1389 frames cold + * and all 711 warm, with no transform at any point. + * + * Both of the other mechanisms miss it by construction. `staggerIn` is an + * action on .xp-panel and queries for its parts when that element is + * created, but the description block is created microseconds later — same + * flush — so the query finds nothing, which is why .rail-desc was left out + * of SWAP_PARTS in the first place. And Svelte plays an `in:` only for an + * element added by a later UPDATE; landing in the same flush is not one, so + * the intro is skipped too. An action always runs when its own element is + * created, whichever flush that is, so it covers every arrival time. */ + 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", + }, + ); + } + /** Hold the stage at an eased height that TRACKS the panel, rather than * animating once to a height measured up front. The panel is at its * smallest when the swap begins — no description, no track rows — so a @@ -5269,19 +5302,19 @@ keeps its 4-line clamp and its mask fade. --> {#if railDesc.lede} -

{railDesc.lede}

+

{railDesc.lede}

{/if} {#if railDesc.rest} -

{railDesc.rest}

+

{railDesc.rest}

{/if} {#if openInfo.genre || openInfo.label} -
+
{#if openInfo.genre}{openInfo.genre}{/if} {#if openInfo.label}{openInfo.label}{/if}
diff --git a/src/views/Library.svelte b/src/views/Library.svelte index 4008645..6bfca60 100644 --- a/src/views/Library.svelte +++ b/src/views/Library.svelte @@ -20,6 +20,7 @@ // 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 * as api from "../lib/api.js"; import { @@ -27,9 +28,12 @@ refreshLibrary, playAlbum, player, + ui, getArt, + getCatalogArt, getCatalogArtistPage, openCatalogArtist, + openCatalogAlbum, RECENTS_FLOOR_SECS, recentsStore, } from "../lib/state.svelte.js"; @@ -39,6 +43,10 @@ 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 @@ -105,8 +113,26 @@ } 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)] ?? null; + return followsByName[normKey(artistName)] + ?? resolvedByName[normKey(artistName)] + ?? null; } async function toggleGaps(artistName) { @@ -140,6 +166,101 @@ } } + // ---- 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 = (() => { @@ -369,6 +490,43 @@ 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, @@ -380,11 +538,19 @@ let alive = true; const load = () => { const { id, art_path } = current; - if (!art_path || coverUrls[id] !== undefined || coverInFlight.has(id)) return; + 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; + if (alive && url) { + coverUrls[current.id] = url; + extractAlbumAccent(current.id, url); + } }).catch(() => coverInFlight.delete(id)); }; const observer = new IntersectionObserver((entries) => { @@ -397,15 +563,66 @@ }; } - // ---- ambience: the page's colour comes from the collection ------------ + // 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 recent = recentsStore.albums.find((r) => r.kind === "library" && r.art_path); - const newest = libraryStore.albums.find((a) => a.art_path); - const path = recent?.art_path ?? newest?.art_path ?? null; - if (!path) { ambUrl = null; return; } + 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; - getArt(path).then((url) => { if (alive && url) ambUrl = url; }); + // 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; }; }); @@ -425,6 +642,9 @@ { 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; @@ -436,6 +656,92 @@ 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 + 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); + const match = (page?.albums ?? []).find( + (r) => normKey(r.title) === normKey(album.title), + ); + 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; @@ -457,11 +763,18 @@ 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 @@ -573,6 +886,43 @@ } } + /** 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 }; @@ -648,36 +998,142 @@ /** 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 min-height reserve is stood up before the first frame so the - * scroll range can hold the target (the prototype's xpReveal). */ + * 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 host = node.parentElement; - if (!pane || !row) return; + const page = node.closest(".lib-page"); + if (!pane || !row || !page) return; const line = () => (toolbarEl ? toolbarEl.getBoundingClientRect().bottom + 10 : pane.getBoundingClientRect().top + 100); - if (host) { - const need = Math.round( - row.getBoundingClientRect().top - host.getBoundingClientRect().top - + pane.clientHeight - (line() - pane.getBoundingClientRect().top), - ); - if ((parseFloat(host.style.minHeight) || 0) < need) host.style.minHeight = `${need}px`; - } + 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 (!node.isConnected) { if (host) host.style.minHeight = ""; return; } + if (cancelled || !node.isConnected) return; + stand(); const t = Math.min(1, (now - t0) / 420); - const ease = 1 - (1 - t) ** 3; - const want = pane.scrollTop + (row.getBoundingClientRect().top - line()); - pane.scrollTop = from + (want - from) * ease; - if (t < 1) raf = requestAnimationFrame(step); + // 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); if (host) host.style.minHeight = ""; } }; + 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 --------------------------------------------------------- @@ -964,12 +1420,14 @@
(hoverId = a.id)} + onpointerleave={() => { if (hoverId === a.id) hoverId = null; }} onclick={() => toggleExpander(a)} onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)} > @@ -1020,6 +1478,7 @@ {#snippet expander(items, sectionKey)}
@@ -1038,7 +1497,18 @@ {/if}
-
+ +
{/if} -
+ {#if openAlbum?.art_path} + + {/if} + +

- {openAlbum?.title ?? ""} + {#if openCatalogId != null} + + {:else} + {openAlbum?.title ?? ""} + {/if}

{openAlbum?.year ?? "no year"}{openTracks.length @@ -1085,6 +1572,34 @@ onclick={() => { const p = folderOf(openTracks); if (p) api.showInFolder(p); }} >

+ {#if openInfo?.description} + + {#if railDesc.lede} +

{railDesc.lede}

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

{railDesc.rest}

+ + {/if} + {#if openInfo.genre || openInfo.label} +
+ {#if openInfo.genre}{openInfo.genre}{/if} + {#if openInfo.label}{openInfo.label}{/if} +
+ {/if} + {/if}
{#if openLoading} @@ -1191,11 +1706,78 @@ {/if} {/snippet} +{#snippet artistPicker()} + +
(picker = null)} + onkeydown={(e) => e.key === "Escape" && (picker = null)} + >
+ +{/snippet} + {#snippet shelfHead(name, list)} {@const follow = followFor(name)} {@const total = group === "artist" ? gapTotals[name]?.total : null} {@const secs = list.reduce((n, a) => n + a.secs, 0)} - {@const actionable = group === "artist" && !!follow} + + {@const actionable = group === "artist"} + + {@const portrait = group === "artist" + ? (list.find((a) => a.artist_art_path)?.artist_art_path ?? null) + : null} @@ -1205,16 +1787,31 @@ role={actionable ? "button" : undefined} tabindex={actionable ? 0 : undefined} title={actionable ? `Open ${name}` : undefined} - onclick={actionable ? () => openCatalogArtist(follow.artistId, { id: follow.artistId, name }) : undefined} + aria-busy={resolving[name] ? "true" : undefined} + onclick={actionable ? () => openShelfArtist(name) : undefined} onkeydown={actionable - ? (e) => (e.key === "Enter" || e.key === " ") && openCatalogArtist(follow.artistId, { id: follow.artistId, name }) + ? (e) => (e.key === "Enter" || e.key === " ") && openShelfArtist(name) : undefined} > - + + {#if portrait && artistUrls[portrait]} + + {:else} + + {/if} +
{name} - {#if group === "artist" && follow}{/if} + {#if actionable} + + {#if resolving[name]} + + {:else} + + {/if} + + {/if}
{#if total}{list.length} of {total} released{:else}{list.length} {list.length === 1 ? "album" : "albums"}{/if}{#if secs} · {longDur(secs)}{/if} @@ -1232,9 +1829,24 @@ data-cols={colsTier} style="--tb-h:{tbH}px; --pane-h:{paneH}px" > + {#if picker}{@render artistPicker()}{/if} + {#if ambUrl} -
-
+ + {#key ambUrl} +
+ {/key} + +
{/if} {#if libraryStore.albums.length === 0} @@ -1398,10 +2010,13 @@
(hoverId = a.id)} + onpointerleave={() => { if (hoverId === a.id) hoverId = null; }} onclick={() => toggleExpander(a)} onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)} > @@ -1450,8 +2065,21 @@ 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); - padding: var(--pad-top) 0 56px; + /* --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 @@ -1475,7 +2103,15 @@ right: -28px; top: calc(-1 * var(--pad-top)); height: 430px; - z-index: -1; + /* 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%; @@ -1491,10 +2127,18 @@ right: -28px; top: calc(-1 * var(--pad-top)); height: 300px; - z-index: -1; + 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 (), 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 */ @@ -1755,6 +2399,53 @@ 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 @@ -1808,6 +2499,20 @@ 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; @@ -2256,6 +2961,11 @@ 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; @@ -2263,6 +2973,67 @@ 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; @@ -2281,12 +3052,79 @@ 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;