From 7a517dca59fe6e63a6685376de203799fffec705 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:40:31 -0500 Subject: [PATCH] navigation: one glass bar for every page, with a history and a path in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glass bar was copied into two views and each carried its own contextual back link — "Results" on the artist page, the artist's name on the album page. It is a shared component now (src/components/GlassBar.svelte), and it carries the app's navigation: back/forward arrows, and the trail to where you are. **History and the trail are different things, and the file keeps them apart.** History is a SEQUENCE — every place visited, in order — and it drives the arrows. The trail is a HIERARCHY, derived from the current place, never from history. That is why switching between two editions of a record adds a history step but does not add a crumb: a breadcrumb built out of history would read `Search › Weezer › Blue Album › Deluxe Edition`, which says the deluxe sits inside the original. It does not. Crumbs improve in place. An artist page opens on an id and learns its name and portrait a moment later, so the crumb starts as a placeholder and is refreshed through ONE call site (`rememberArtistSeed`), guarded so remembering a seed for a row in some list can never rewrite where you are. Search results are the journey's root rather than a bare "Search" — the crumb reads “weezer”, which is the thing you actually want to get back to. Arriving crumbs slide in from the right; only genuinely new ones animate, so an ancestor that was already there does not re-run its entrance when the tail changes. Chosen from four motions mocked against a real navigation stack. Every navigation now goes through a recorder. A bare `ui.view = "…"` is a destination the back arrow can never return to, so the sidebar routes through `goToView` and the catalog openers record on arrival. **Scroll position is remembered.** Going back returns you to the paragraph you left; arriving somewhere new starts at its top. The difference is exactly whether the move came from the arrows/trail or from a fresh click. Two details that mattered: the offset is captured at the START of a navigation, because the scroll pane outlives the view swap and by the time a page has been replaced its scrollTop already belongs to its successor; and restoring is not one assignment — the artist page paints a skeleton, then an overview, then the full discography, so a single `scrollTop = 900` clamps to whatever height exists in that frame and lands near the top. It re-applies until the target is reachable and gives up the instant the reader touches wheel, key or trackpad. The album page never reset its scroll at all, so a record opened from half-way down an artist page began half-way down; it does now. **The artwork lightbox serves any cover.** It was hardwired to the playing queue item; `ui.fullscreenArt` (a boolean) is now `ui.artwork` (a descriptor), so Now Playing, the artist page's expander rail and the album hero all open the same viewer with their own `{src, local, title, subtitle}`. Each passes the UPGRADED cover rather than the sized variant it paints — the point of the viewer is the master resolution. Hovering artwork offers it: the whole element lifts under a light wash with the expand mark centred. `transform`, not a width change — on the album page the copy column is sized to end level with the artwork, so a reflowing hover would twitch the entire hero. The three numbers (--art-grow 1.035, --art-motion 160ms, --art-wash) live in global.css rather than being typed into three files. Now Playing's corner expand disc is gone: one gesture for "open this artwork" everywhere instead of a disc in one place and a lift in the others. On the artist page the expander's album NAME now opens that record's page. Also removed: the album page's scroll-triggered title takeover. The breadcrumb names the record permanently, which made it redundant — the build was already flagging its CSS as dead. **And the fix this session actually turned on.** Kyle reported the album tracklist's counts as "heavily glitched": Pinkerton showed El Scorcho at 636 listeners beside Tired Of Sex at 606,324. Switching the column from plays to listeners changed nothing, because both were wrong for the same reason. `artist.gettoptracks` returns a popular song many times over — once as itself, then again for every "(Album Version)", live cut and remaster — listeners descending. The fold meant to keep the first entry, and its comment said so, but it was written `if by_key.insert(k, v).is_some() { continue }`. `HashMap::insert` returns the previous value AND STILL OVERWRITES, so the guard correctly skipped the ranked push while letting every later duplicate win the map the album tracklist reads. The last re-listing — the most obscure pressing — is what reached the screen. Measured against the live API for Weezer: Buddy Holly is rank 1 at 2,351,642 listeners and rank 679 as "(Album Version)" at 1,919; the page printed 1,919. My Name Is Jonas 1,100,251 → 870. El Scorcho 654,071 → 636. Pink Triangle 810,659 → 6,070. Only songs Last.fm lists exactly once — "Tired of Sex", "Across the Sea" — were ever right, which is why the column looked plausible in places and absurd in others. Twenty-four entries for "The Good Life", twenty for "El Scorcho". The fold is now `fold_lastfm_duplicates`, pinned by two tests using those real figures. It also repairs Popular's plays and the album totals, which read the same map. The tracklist column shows listeners in both places it appears — the album page and the artist page's expander — because leaving one on plays would have let one record disagree with itself. 152 Rust tests and 85 JS tests pass. --- crates/metadata/src/lib.rs | 116 +++++++-- src/components/ArtworkViewer.svelte | 42 ++-- src/components/GlassBar.svelte | 268 +++++++++++++++++++++ src/components/Icon.svelte | 1 + src/components/NowPlaying.svelte | 132 ++++++---- src/components/Sidebar.svelte | 15 +- src/lib/navigation.svelte.js | 247 +++++++++++++++++++ src/lib/state.svelte.js | 93 ++++++- src/styles/global.css | 10 + src/views/CatalogAlbum.svelte | 359 +++++++++++++--------------- src/views/CatalogArtist.svelte | 274 ++++++++++++--------- 11 files changed, 1154 insertions(+), 403 deletions(-) create mode 100644 src/components/GlassBar.svelte create mode 100644 src/lib/navigation.svelte.js diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index 7713b73..5aed63a 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -6220,20 +6220,9 @@ impl MetadataClient { }) .collect(); - let mut by_key: HashMap = HashMap::new(); - let mut ranked: Vec = Vec::new(); - for (key, title, stats) in entries { - // Last.fm's list is already listeners-descending, so an earlier - // entry wins a key collision (e.g. a remaster listed twice). - if by_key.insert(key.clone(), stats).is_some() { - continue; - } - if ranked.len() < LASTFM_RANKED_KEEP { - ranked.push(LastfmTrack { key, title, stats }); - } - } - stage_trace("lastfm.ranks", started, &format!("{} tracks", by_key.len())); - (!by_key.is_empty()).then_some(LastfmRanks { by_key, ranked }) + let ranks = fold_lastfm_duplicates(entries); + stage_trace("lastfm.ranks", started, &format!("{} tracks", ranks.by_key.len())); + (!ranks.by_key.is_empty()).then_some(ranks) } /// The `last.fm` URL MusicBrainz declares for this artist, if any. Shares @@ -7646,6 +7635,39 @@ fn title_identity_stripping(title: &str, is_marker: fn(&str) -> bool) -> String normalize(&work) } +/// Fold Last.fm's top-tracks list, which arrives listeners-descending, into +/// one entry per song. +/// +/// The FIRST entry for a title is the canonical song; every later one is a +/// re-listing of it — an "(Album Version)", a festival recording, a remaster — +/// carrying that pressing's own much smaller count. Keeping the first is the +/// whole point, and it has to be done with a vacancy check: `HashMap::insert` +/// returns the previous value but STILL overwrites, so a guard written as +/// `if map.insert(..).is_some() { continue }` lets the duplicate win the map +/// while correctly skipping the ranked list. +/// +/// That was the bug behind the album tracklist's counts. Last.fm lists Buddy +/// Holly at rank 1 with 2,351,642 listeners and again at rank 679 as +/// "(Album Version)" with 1,919 — and 1,919 is the number the album page +/// printed. Every song popular enough to be re-listed showed its most obscure +/// pressing; only songs Last.fm happens to list exactly once (Weezer's "Tired +/// of Sex", "Across the Sea") were ever right, which is why the column looked +/// plausible in places and absurd in others. +fn fold_lastfm_duplicates(entries: Vec<(String, String, TrackStats)>) -> LastfmRanks { + let mut by_key: HashMap = HashMap::new(); + let mut ranked: Vec = Vec::new(); + for (key, title, stats) in entries { + let std::collections::hash_map::Entry::Vacant(slot) = by_key.entry(key.clone()) else { + continue; + }; + slot.insert(stats); + if ranked.len() < LASTFM_RANKED_KEEP { + ranked.push(LastfmTrack { key, title, stats }); + } + } + LastfmRanks { by_key, ranked } +} + fn track_title_identity(title: &str) -> String { let mut work = title.trim().to_lowercase(); // A featured-artist clause is Deezer packaging, never part of the scrobble. @@ -12178,6 +12200,72 @@ fn urlencode(s: &str) -> String { #[cfg(test)] mod tests { + + /// Last.fm returns a popular song many times over — once as itself and + /// again for every "(Album Version)", live cut and remaster — listeners + /// descending. The canonical first entry must win. + /// + /// Real figures, `artist.gettoptracks` for Weezer: Buddy Holly at rank 1 + /// with 2,351,642 listeners, and again at rank 679 as "(Album Version)" + /// with 1,919. The album page printed 1,919 until this was fixed — the + /// map was being overwritten by every later duplicate. + #[test] + fn a_songs_canonical_entry_beats_its_re_listings() { + let stats = |listeners: u64, plays: u64| TrackStats { plays, listeners }; + let entries = vec![ + ( + track_title_identity("Buddy Holly"), + "Buddy Holly".to_string(), + stats(2_351_642, 24_083_192), + ), + ( + track_title_identity("El Scorcho"), + "El Scorcho".to_string(), + stats(654_071, 5_368_072), + ), + // …hundreds of ranks later, the same songs re-listed. + ( + track_title_identity("Buddy Holly (Album Version)"), + "Buddy Holly (Album Version)".to_string(), + stats(1_919, 7_043), + ), + ( + track_title_identity("El Scorcho (Album Version)"), + "El Scorcho (Album Version)".to_string(), + stats(636, 2_051), + ), + ]; + let folded = fold_lastfm_duplicates(entries); + + let buddy = folded.by_key[&track_title_identity("Buddy Holly")]; + assert_eq!(buddy.listeners, 2_351_642, "the re-listing won the map"); + assert_eq!(buddy.plays, 24_083_192); + let scorcho = folded.by_key[&track_title_identity("El Scorcho")]; + assert_eq!(scorcho.listeners, 654_071); + + // And a duplicate never takes a second seat in the ranked list, which + // is what Popular renders. + assert_eq!(folded.ranked.len(), 2); + assert_eq!(folded.ranked[0].title, "Buddy Holly"); + assert_eq!(folded.ranked[1].title, "El Scorcho"); + } + + /// A song Last.fm lists exactly once is unaffected — these were the only + /// rows that were ever correct. + #[test] + fn a_song_listed_once_is_unchanged() { + let entries = vec![( + track_title_identity("Tired of Sex"), + "Tired of Sex".to_string(), + TrackStats { plays: 4_511_406, listeners: 606_324 }, + )]; + let folded = fold_lastfm_duplicates(entries); + assert_eq!( + folded.by_key[&track_title_identity("Tired of Sex")].listeners, + 606_324 + ); + } + use super::*; use serde_json::json; diff --git a/src/components/ArtworkViewer.svelte b/src/components/ArtworkViewer.svelte index 8d25b24..d6ddbcf 100644 --- a/src/components/ArtworkViewer.svelte +++ b/src/components/ArtworkViewer.svelte @@ -1,14 +1,18 @@ -{#if ui.fullscreenArt && currentItem} +{#if art}
(ui.fullscreenArt = false)} + onclick={close} onkeydown={(e) => { - if (e.key === "Enter" || e.key === " ") ui.fullscreenArt = false; + if (e.key === "Enter" || e.key === " ") close(); }} > {#if ambientUrl} @@ -120,14 +114,14 @@ title="Close (Esc)" onclick={(e) => { e.stopPropagation(); - ui.fullscreenArt = false; + close(); }} > {#if artUrl} - Album artwork + {art.title {:else}
{/if} diff --git a/src/components/GlassBar.svelte b/src/components/GlassBar.svelte new file mode 100644 index 0000000..f63b760 --- /dev/null +++ b/src/components/GlassBar.svelte @@ -0,0 +1,268 @@ + + + +
+
+ + +
+ {#if crumbs.length} + + + {/if} + {@render children?.()} + {#if resolver.active} + ● {resolver.label} — finding the best source… + {:else if resolver.error} + {resolver.error} + {/if} +
+ + diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index 2f058fd..42d82e1 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -34,6 +34,7 @@ expand: '', shrink: '', "chevron-left": '', + "chevron-right": '', "chevron-down": '', // Lucide layers-2: its ink fills the viewBox symmetrically, so the // glyph centers correctly inside small pills (the 3-layer variant diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte index 3575a4d..ee8c6e7 100644 --- a/src/components/NowPlaying.svelte +++ b/src/components/NowPlaying.svelte @@ -24,6 +24,22 @@ import { floatScroll } from "../lib/scrollbar.js"; const currentItem = $derived(player.queue[player.queueIndex] ?? null); + + /** Hand the playing cover to the shared artwork lightbox. It takes an + * explicit descriptor now rather than reaching into the queue itself, so + * the same viewer serves an album's artwork anywhere else in the app. */ + function openArtwork() { + const item = currentItem; + if (!item) return; + const src = item.art ? item.art : item.artUrl ? playerArtSource(item) : null; + if (!src) return; + ui.artwork = { + src, + local: Boolean(item.art), + title: item.title ?? "", + subtitle: item.sub ?? "", + }; + } // Gate stream-only sections (quality, source, Save) on the displayed // item's own stream field — the source of truth for what's on screen. // Library tracks never carry one, so they never show "Save to Library" @@ -231,17 +247,15 @@
{#if artUrl} -
+ -
+ + {:else}
{/if} @@ -526,7 +540,74 @@ transform: scale(1.02); pointer-events: none; } - .cover-wrap { position: relative; z-index: 1; } + /* The whole cover is the control now, with the artwork-hover treatment the + album page and the artist rail use (--art-* in global.css) — one gesture + for "open this artwork" wherever it appears, instead of a corner disc + here and a lift everywhere else. */ + .cover-wrap { + position: relative; + z-index: 1; + display: block; + width: 100%; + padding: 0; + border-radius: 12px; + cursor: pointer; + transition: transform var(--art-motion); + } + .cover-wrap:hover, + .cover-wrap:focus-visible { + transform: scale(var(--art-grow)); + } + .cover-wrap::after { + content: ""; + position: absolute; + inset: 0; + z-index: 2; + border-radius: 12px; + background: var(--art-wash); + opacity: 0; + transition: opacity var(--art-motion); + pointer-events: none; + } + .cover-wrap:hover::after, + .cover-wrap:focus-visible::after { + opacity: 1; + } + .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); + } + .cover-wrap:hover .mark, + .cover-wrap:focus-visible .mark { + opacity: 1; + transform: none; + } + @media (prefers-reduced-motion: reduce) { + .cover-wrap, + .mark { + transition-duration: 1ms; + } + .cover-wrap:hover, + .cover-wrap:focus-visible { + transform: none; + } + } .cover { position: relative; z-index: 1; @@ -538,35 +619,6 @@ box-shadow: 0 18px 38px -18px rgba(0, 0, 0, 0.7); display: block; } - /* Corner expand control, Spotify-style: a dark glass disc pinned inside - the art's top-right corner, revealed on hover (or keyboard focus). */ - .expand { - position: absolute; - top: 10px; - right: 10px; - z-index: 2; - width: 30px; - height: 30px; - border-radius: 50%; - display: grid; - place-items: center; - color: #fff; - background: rgba(10, 10, 14, 0.6); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - opacity: 0; - transform: translateY(-3px) scale(0.9); - transition: opacity 0.16s ease, transform 0.16s ease, background 0.16s ease; - } - .cover-wrap:hover .expand, - .expand:focus-visible { - opacity: 1; - transform: none; - } - .expand:hover { background: rgba(10, 10, 14, 0.85); } - @media (prefers-reduced-motion: reduce) { - .expand { transition: opacity 0.16s ease; transform: none; } - } .cover.placeholder { display: grid; place-items: center; diff --git a/src/components/Sidebar.svelte b/src/components/Sidebar.svelte index b0c4563..5e6b13b 100644 --- a/src/components/Sidebar.svelte +++ b/src/components/Sidebar.svelte @@ -1,24 +1,25 @@
- -
- - {#if album} -
- {#if coverOk} - - {/if} - {album.title} - -
- {/if} - {#if resolver.active} - ● {resolver.label} — finding the best source… - {:else if resolver.error} - {resolver.error} - {/if} -
+ {#if catalog.albumLoading}

Loading album…

@@ -531,19 +521,26 @@
{#if coverOk} - handleCoverError(event.currentTarget)} - /> + title="View artwork" + aria-label="View artwork full screen" + onclick={openArtwork} + > + handleCoverError(event.currentTarget)} + /> + + {:else}
{/if}
{(album.record_type ?? "album").toUpperCase()} -

22} use:watchTitle>{album.title}

+

22}>{album.title}

- {#if resolver.active} - ● {resolver.label} — finding the best source… - {:else if resolver.error} - {resolver.error} - {/if} -

+ + +
-

{openAlbum?.title ?? openFamily?.baseTitle ?? ""}

+ + + +

+ +

{group.key === "upcoming" ? upcomingDate(openAlbum ?? openFamily) : fullDate(openAlbum ?? openFamily)}{openTracks.length ? ` · ${openTracks.length} tracks` @@ -4773,10 +4810,10 @@ {/if} {:else} {#if openTracks.length} -

0} in:partIn={{ delay: 30 }}> +
0} in:partIn={{ delay: 30 }}> # Title - {#if playsPeak > 0}Plays{/if} + {#if countPeak > 0}Listeners{/if}
{/if} @@ -4798,8 +4835,8 @@ }}
0} - style={playsPeak > 0 ? `--t:${playHeat(t)}` : undefined} + class:has-count={countPeak > 0} + style={countPeak > 0 ? `--t:${countHeat(t)}` : undefined} in:partIn={{ delay: Math.min(ti * 16, 180) }} class:now={nowPlaying("trackId", t.id)} class:locked @@ -4815,10 +4852,10 @@ {t.title} {#if libraryTicks.has(norm(t.title || ""))}{/if} - {#if playsPeak > 0} - - - {fmtPlays(t.plays)} + {#if countPeak > 0} + + + {fmtCount(t.listeners)} {/if} {#if locked} @@ -4915,7 +4952,7 @@ --band-top: -24px; /* Where the three pins actually sit in flow, written by heroScroll(). NOT zero: the pane's top padding puts them a little down the page, - while .results-head's -24px margin pulls the bar itself back over it. + while the glass bar's own -24px margin pulls it back over it. Everything hung off a pin has to subtract this to get back into band coordinates — leaving it out is what put the banner 24px low and dropped the artwork onto the glass bar instead of behind it. */ @@ -6189,77 +6226,6 @@ max-height: 76vh; } - /* Frosted-glass header floating on the banner — the same recipe as the - Now Playing panel's .np-head, so the two headers read as siblings: a - translucent tint over a backdrop blur, with a hairline light edge. */ - .results-head { - /* Sticky (was absolute) so the bar pins to the top of the scroll pane - and stays put as the page scrolls, in parity with the Now Playing - header. The negative margins reproduce the old absolute bleed while - keeping the bar in normal flow: -24/-28 bleed it up over the pane's - 24px top padding and out to the L/R pane edges; margin-bottom -22 - makes its net flow height zero (-24 + 46 - 22 = 0) so the hero still - sits directly beneath it instead of being pushed down. `top: -24px` - pins it flush against the very top of the pane (over that padding). */ - position: sticky; - top: -24px; - margin: -24px -28px -22px; - /* Above the identity (30) and the hero (20): the bar keeps the top of - the pane to itself and the parked wordmark slides under it. */ - z-index: 40; - height: 46px; - display: flex; - align-items: center; - gap: 12px; - padding: 0 28px; - background: rgba(20, 20, 24, 0.28); - /* 12px, down from 20: pixel-identical over every real captured - state (2026-07-24 backdrop evidence pack, two passes) — the - backdrop re-sample, not the kernel, is the GPU cost, but the - smaller radius still shaves ~0.4 CPU-s/6s of scroll. 8px was - one visible step too tight over a sharp hero. */ - backdrop-filter: blur(12px) saturate(1.7); - -webkit-backdrop-filter: blur(12px) saturate(1.7); - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05); - } - - /* Matches .np-title: white label legible on the glass, with a soft shadow - for bright banners. */ - .results-head .back { - display: inline-flex; - align-items: center; - gap: 5px; - color: #fff; - font-size: 13px; - font-weight: 600; - text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); - } - - .results-head .back:hover { - color: var(--text-secondary); - } - - /* Resolver status rides the right side of the glass bar (was a separate - line above the hero before the header became the glass strip). */ - .rstatus { - margin-left: auto; - max-width: 55%; - font-size: 12px; - color: rgba(255, 255, 255, 0.85); - text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .rstatus .pulse { - color: var(--accent); - } - - .rstatus.error { - color: #ff8a94; - } - /* The sharp artwork: native aspect ratio (no crop), offset down so it begins right at the bottom of the glass bar. max-height caps the very-wide / non-16:9 case so the hero can't swallow @@ -7540,7 +7506,7 @@ transition: background 0.15s ease; content-visibility: auto; contain-intrinsic-size: 150px 210px; - /* Anchor for the open-expander scroll: clears the sticky results-head + /* Anchor for the open-expander scroll: clears the sticky glass bar AND the parked banner beneath it plus breathing room, so a clicked row lands just below the banner instead of underneath it. Tracks --banner-h rather than a constant, because the banner now scales @@ -7692,9 +7658,85 @@ to { opacity: 0; } } + /* Same artwork-hover treatment as the album page's hero (--art-* tokens in + global.css): the whole element lifts under a light wash with the expand + mark centred. The rail's own crossfade lives on the images inside, and + is untouched by a transform on their container. */ .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; + } + @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; + } + } + + /* The name is the link to the record's own page. 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-art.placeholder { @@ -8070,8 +8112,8 @@ /* The header and the rows share one template, so the columns line up whether or not Last.fm knew this record. */ - .trow.has-plays, - .tl-cols.has-plays { + .trow.has-count, + .tl-cols.has-count { grid-template-columns: 26px minmax(0, 1fr) 168px 48px; } @@ -8099,7 +8141,7 @@ and cool while the record's biggest song burns the album's own accent. The floor keeps the quietest track legible instead of letting it sink into the row. */ - .tplays { + .tcount { position: relative; display: flex; align-items: center; @@ -8110,7 +8152,7 @@ font-variant-numeric: tabular-nums; } - .tplays-fill { + .tcount-fill { position: absolute; inset: 0 auto 0 0; border-radius: 4px; @@ -8122,7 +8164,7 @@ ); } - .tplays-val { + .tcount-val { position: relative; z-index: 1; padding: 0 8px; @@ -8131,8 +8173,8 @@ /* The hover reveal slides the play/save buttons over the right end of the row, so the duration gets out of the way — the count stays put. */ - .trow:hover .tplays, - .trow:focus-within .tplays { + .trow:hover .tcount, + .trow:focus-within .tcount { opacity: 1; } @@ -8256,10 +8298,6 @@ width: min(calc(var(--logo-aspect, 3) * var(--logo-h, 200px) * 0.75), 92%); } - .results-head { - padding: 0 20px; - } - /* Tighter one-line floor here. It must be set as --row-min on the page, NOT as a height on the spacer: the spacer mirrors the row's measured height now, and a hard height would pin the reserve to one