navigation: one glass bar for every page, with a history and a path in it
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.
This commit is contained in:
1 parent
ea9a040d09
commit
7a517dca59
11 files changed
+1154
-403
No files matched your search
+160
-199
@@ -18,13 +18,16 @@
|
||||
getCatalogArtistPage,
|
||||
openCatalogAlbum,
|
||||
toggleShuffle,
|
||||
ui,
|
||||
} from "../lib/state.svelte.js";
|
||||
import { openPreview } from "../lib/preview/previewSession.svelte.js";
|
||||
import { buildAlbumManifest } from "../lib/preview/manifest.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import { accentFromImage } from "../lib/color.js";
|
||||
import { editionLabel, releaseFamilies } from "../lib/editions.js";
|
||||
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import GlassBar from "../components/GlassBar.svelte";
|
||||
|
||||
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
|
||||
const album = $derived(page?.album ?? null);
|
||||
@@ -168,17 +171,25 @@
|
||||
const albumListeners = $derived(fmtCompact(page?.album_listeners ?? null));
|
||||
const totalsSummed = $derived(Boolean(page?.totals_summed));
|
||||
|
||||
// The album's own most-played track is the yardstick. A bar is a
|
||||
// comparison, so a one- or two-track release gets the plain list instead:
|
||||
// the longest bar there is only "the bigger of these two", which reads as
|
||||
// a rating it is not.
|
||||
const playsPeak = $derived(tracks.reduce((peak, t) => Math.max(peak, t.plays ?? 0), 0));
|
||||
const showPlays = $derived(playsPeak > 0 && tracks.length >= 3);
|
||||
const playShare = (t) =>
|
||||
!t.plays || !playsPeak ? 0 : Math.max(2, Math.round((t.plays / playsPeak) * 100));
|
||||
const playHeat = (t) => (!t.plays || !playsPeak ? 0 : t.plays / playsPeak).toFixed(3);
|
||||
// The album's most-heard track is the yardstick. A bar is a comparison, so
|
||||
// a one- or two-track release gets the plain list instead: the longest bar
|
||||
// there is only "the bigger of these two", which reads as a rating it is
|
||||
// not.
|
||||
//
|
||||
// LISTENERS, not scrobbles. Both come from the same Last.fm table, but the
|
||||
// play counts are the noisier of the two — distinct listeners is the figure
|
||||
// the Popular list is already ordered by, so the album and the artist page
|
||||
// now rank a tracklist the same way.
|
||||
const countPeak = $derived(tracks.reduce((peak, t) => Math.max(peak, t.listeners ?? 0), 0));
|
||||
const showCount = $derived(countPeak > 0 && tracks.length >= 3);
|
||||
const countShare = (t) =>
|
||||
!t.listeners || !countPeak
|
||||
? 0
|
||||
: Math.max(2, Math.round((t.listeners / countPeak) * 100));
|
||||
const countHeat = (t) =>
|
||||
(!t.listeners || !countPeak ? 0 : t.listeners / countPeak).toFixed(3);
|
||||
/** Unknown is a dash, never a zero — same rule as the artist page. */
|
||||
const fmtPlays = (plays) => (plays == null ? "—" : plays.toLocaleString());
|
||||
const fmtCount = (n) => (n == null ? "—" : n.toLocaleString());
|
||||
|
||||
// ---- editions ---------------------------------------------------------
|
||||
// The album payload has no siblings, so the family is rebuilt from the
|
||||
@@ -251,6 +262,20 @@
|
||||
return [y, n ? `${n} ${n === 1 ? "track" : "tracks"}` : null].filter(Boolean).join(" · ");
|
||||
};
|
||||
|
||||
/** This record's artwork in the shared full-window viewer — the same one
|
||||
* the Now Playing cover and the artist page's expander open. The UPGRADED
|
||||
* cover, not the sized variant the hero paints: the point of the viewer is
|
||||
* the master resolution. */
|
||||
function openArtwork() {
|
||||
if (!coverOk || !album) return;
|
||||
ui.artwork = {
|
||||
src: cover,
|
||||
local: false,
|
||||
title: album.title ?? "",
|
||||
subtitle: album.artist ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// ---- shuffle -----------------------------------------------------------
|
||||
/** Play the record with shuffle on. Not a toggle: the button is an action
|
||||
* on THIS album, so it always turns shuffle on rather than flipping a
|
||||
@@ -443,28 +468,23 @@
|
||||
tracks.length ? `${tracks.length} ${tracks.length === 1 ? "track" : "tracks"}` : "",
|
||||
);
|
||||
|
||||
// ---- the glass bar's title takeover -----------------------------------
|
||||
// The record's name rides in the bar once its own title has gone under it,
|
||||
// so the bar always says where you are. An observer against the scroll
|
||||
// pane — no per-frame work, and nothing to keep in sync with a scroll
|
||||
// position.
|
||||
//
|
||||
// It watches the TITLE, not the whole identity block: the bar is standing
|
||||
// in for the title, so it should arrive as the title leaves. Watching the
|
||||
// block meant waiting for the artwork to clear too, which on a ten-track
|
||||
// record is further than the page can even scroll.
|
||||
let titlePassed = $state(false);
|
||||
function watchTitle(node) {
|
||||
const root = node.closest(".content");
|
||||
if (!root) return;
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => (titlePassed = !entry.isIntersecting),
|
||||
// The bar's stuck bottom sits 22px into the pane's content box.
|
||||
{ root, rootMargin: "-22px 0px 0px 0px", threshold: 0 },
|
||||
);
|
||||
io.observe(node);
|
||||
return { destroy: () => io.disconnect() };
|
||||
}
|
||||
// A new record starts at its top; coming BACK to one returns the reader to
|
||||
// where they were. The pane outlives the view swap, so without this a
|
||||
// record opened from half-way down an artist page began half-way down.
|
||||
let scrolledFor = null;
|
||||
$effect(() => {
|
||||
const id = album?.id ?? null;
|
||||
if (id == null || id === scrolledFor) return;
|
||||
scrolledFor = id;
|
||||
const pane = document.querySelector(".content");
|
||||
if (!pane) return;
|
||||
const target = consumeScroll();
|
||||
if (target == null) {
|
||||
pane.scrollTop = 0;
|
||||
} else {
|
||||
restoreScroll(pane, target);
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll to (and briefly flash) the track the now-playing song link targets.
|
||||
let flashId = $state(null);
|
||||
@@ -482,37 +502,7 @@
|
||||
</script>
|
||||
|
||||
<div class="album-page" style={accentStyle} bind:this={pageEl}>
|
||||
<!-- Frosted-glass header, in parity with the artist page's .results-head
|
||||
and the Now Playing panel: the same tint over the same backdrop blur,
|
||||
pinned to the top of the pane. data-scrollhead keeps the overlay
|
||||
scrollbar below it. -->
|
||||
<div class="glassbar" class:passed={titlePassed} data-scrollhead>
|
||||
<button class="back" onclick={() => openArtistByName(album?.artist)}>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
{album?.artist ?? "Back"}
|
||||
</button>
|
||||
{#if album}
|
||||
<div class="where">
|
||||
{#if coverOk}
|
||||
<img src={cover} alt="" draggable="false" />
|
||||
{/if}
|
||||
<span>{album.title}</span>
|
||||
<button
|
||||
class="mini-play"
|
||||
disabled={hasUnreleased}
|
||||
title={hasUnreleased ? "Not released yet" : "Play"}
|
||||
onclick={() => streamCatalogAlbum(album)}
|
||||
>
|
||||
<Icon name="play" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if resolver.active}
|
||||
<span class="rstatus"><span class="pulse">●</span> {resolver.label} — finding the best source…</span>
|
||||
{:else if resolver.error}
|
||||
<span class="rstatus error">{resolver.error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<GlassBar />
|
||||
|
||||
{#if catalog.albumLoading}
|
||||
<p class="hint">Loading album…</p>
|
||||
@@ -531,19 +521,26 @@
|
||||
</div>
|
||||
<div class="identity">
|
||||
{#if coverOk}
|
||||
<img
|
||||
<button
|
||||
class="cover"
|
||||
src={cover}
|
||||
alt=""
|
||||
draggable="false"
|
||||
onerror={(event) => handleCoverError(event.currentTarget)}
|
||||
/>
|
||||
title="View artwork"
|
||||
aria-label="View artwork full screen"
|
||||
onclick={openArtwork}
|
||||
>
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
draggable="false"
|
||||
onerror={(event) => handleCoverError(event.currentTarget)}
|
||||
/>
|
||||
<span class="mark" aria-hidden="true"><Icon name="expand" size={22} /></span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="cover placeholder"><Icon name="disc" size={44} /></div>
|
||||
{/if}
|
||||
<div class="copy">
|
||||
<span class="kind">{(album.record_type ?? "album").toUpperCase()}</span>
|
||||
<h1 class:long={album.title.length > 22} use:watchTitle>{album.title}</h1>
|
||||
<h1 class:long={album.title.length > 22}>{album.title}</h1>
|
||||
<p class="meta">
|
||||
<button class="who" onclick={() => openArtistByName(album.artist)}>
|
||||
{#if artistPicture}
|
||||
@@ -656,10 +653,10 @@
|
||||
|
||||
<div class="tracklist">
|
||||
{#if tracks.length}
|
||||
<div class="tcols" class:has-plays={showPlays}>
|
||||
<div class="tcols" class:has-count={showCount}>
|
||||
<span class="tno">#</span>
|
||||
<span>Title</span>
|
||||
{#if showPlays}<span>Plays</span>{/if}
|
||||
{#if showCount}<span>Listeners</span>{/if}
|
||||
<span class="tdur-head"><Icon name="clock" size={13} /></span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -674,11 +671,11 @@
|
||||
<div
|
||||
id="catrack-{t.id}"
|
||||
class="trow"
|
||||
class:has-plays={showPlays}
|
||||
class:has-count={showCount}
|
||||
class:now={nowPlaying("trackId", t.id)}
|
||||
class:flash={flashId === t.id}
|
||||
class:locked
|
||||
style={showPlays ? `--t:${playHeat(t)}` : undefined}
|
||||
style={showCount ? `--t:${countHeat(t)}` : undefined}
|
||||
role="button"
|
||||
aria-disabled={locked}
|
||||
tabindex={locked ? -1 : 0}
|
||||
@@ -693,15 +690,15 @@
|
||||
<span class="tick" title="Saved in your library"><Icon name="check" size={11} /></span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if showPlays}
|
||||
{#if showCount}
|
||||
<span
|
||||
class="tplays"
|
||||
title={t.plays == null
|
||||
? "No Last.fm play count for this track"
|
||||
: `${t.plays.toLocaleString()} Last.fm plays`}
|
||||
class="tcount"
|
||||
title={t.listeners == null
|
||||
? "No Last.fm listener count for this track"
|
||||
: `${t.listeners.toLocaleString()} Last.fm listeners`}
|
||||
>
|
||||
<span class="tplays-fill" style={`width:${playShare(t)}%`}></span>
|
||||
<span class="tplays-val">{fmtPlays(t.plays)}</span>
|
||||
<span class="tcount-fill" style={`width:${countShare(t)}%`}></span>
|
||||
<span class="tcount-val">{fmtCount(t.listeners)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if locked}
|
||||
@@ -761,116 +758,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ glass bar ---- */
|
||||
/* The artist page's .results-head, unchanged: sticky at the top of the
|
||||
pane, bled -24/-28 so it spans the pane's full width and takes NO flow
|
||||
height (-24 + 46 - 22 = 0), a translucent tint over a 12px backdrop
|
||||
blur with a hairline light edge. The two headers must read as one
|
||||
object, so any change here belongs in both. */
|
||||
.glassbar {
|
||||
position: sticky;
|
||||
top: -24px;
|
||||
margin: -24px -28px -22px;
|
||||
z-index: 40;
|
||||
height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 28px;
|
||||
background: rgba(20, 20, 24, 0.28);
|
||||
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);
|
||||
}
|
||||
|
||||
.glassbar .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);
|
||||
flex: none;
|
||||
}
|
||||
.glassbar .back:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* The record's name takes over the bar once its own title has gone under
|
||||
it. Hidden — not just transparent — while the title is on screen, so it
|
||||
never eats clicks meant for the back button. */
|
||||
.where {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 180ms ease,
|
||||
visibility 0s linear 180ms;
|
||||
}
|
||||
.glassbar.passed .where {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
.where img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
}
|
||||
.where span {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.mini-play {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #08080a;
|
||||
flex: none;
|
||||
}
|
||||
.mini-play:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.mini-play:disabled {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Resolver status rides the right side, as on the artist page. */
|
||||
.rstatus {
|
||||
margin-left: auto;
|
||||
max-width: 45%;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- hero banner ---- */
|
||||
.hero {
|
||||
/* The one number to tune. 1 = a 240px cover; everything in the hero is
|
||||
@@ -934,13 +821,83 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Hovering the artwork offers the full-window viewer: the whole element
|
||||
lifts (see --art-grow in global.css) under a light wash, with the expand
|
||||
mark in the middle. `transform` does not reflow, so the copy column
|
||||
beside it — which is sized to end level with this artwork — cannot be
|
||||
nudged by the hover. */
|
||||
.cover {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: var(--cover);
|
||||
height: var(--cover);
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 22px 46px -18px rgba(0, 0, 0, 0.8);
|
||||
transition:
|
||||
transform var(--art-motion),
|
||||
box-shadow var(--art-motion);
|
||||
}
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.cover:hover,
|
||||
.cover:focus-visible {
|
||||
transform: scale(var(--art-grow));
|
||||
box-shadow: 0 30px 62px -20px rgba(0, 0, 0, 0.86);
|
||||
}
|
||||
.cover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--art-wash);
|
||||
opacity: 0;
|
||||
transition: opacity var(--art-motion);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cover:hover::after,
|
||||
.cover:focus-visible::after {
|
||||
opacity: 1;
|
||||
}
|
||||
.mark {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: calc(54px * var(--cover-scale));
|
||||
height: calc(54px * var(--cover-scale));
|
||||
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:hover .mark,
|
||||
.cover:focus-visible .mark {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cover,
|
||||
.mark {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
.cover:hover,
|
||||
.cover:focus-visible {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.cover.placeholder {
|
||||
display: grid;
|
||||
@@ -1074,12 +1031,16 @@
|
||||
align-items: center;
|
||||
gap: 0.714em;
|
||||
}
|
||||
/* 2em is the Play/Save pill's own height (0.571em of padding either side
|
||||
of its line box). Matching it keeps the action row one height — a taller
|
||||
icon button both sat proud of the pills and pushed the copy column past
|
||||
the artwork it is supposed to end level with. */
|
||||
.iconbtn {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.429em;
|
||||
height: 2.429em;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
border-radius: 50%;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
@@ -1270,8 +1231,8 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.tcols.has-plays,
|
||||
.trow.has-plays {
|
||||
.tcols.has-count,
|
||||
.trow.has-count {
|
||||
grid-template-columns: 26px minmax(0, 1fr) 168px 48px;
|
||||
}
|
||||
|
||||
@@ -1374,7 +1335,7 @@
|
||||
|
||||
/* Plays: length AND colour both carry the count, so a deep cut reads short
|
||||
and cool while the record's biggest song burns the album's own accent. */
|
||||
.tplays {
|
||||
.tcount {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1384,7 +1345,7 @@
|
||||
font-size: 12.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tplays-fill {
|
||||
.tcount-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
border-radius: 4px;
|
||||
@@ -1395,7 +1356,7 @@
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.tplays-val {
|
||||
.tcount-val {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 8px;
|
||||
|
||||
Reference in new issue
Block a user