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:
KeelrLeek committed 2026-07-28 16:40:31 -05:00
1 parent ea9a040d09
commit 7a517dca59
11 files changed
+1154 -403

No files matched your search

+156 -118
View File
@@ -28,6 +28,7 @@
gridCover,
tinyCover,
albumDisambiguator,
openCatalogAlbum,
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import { accentFromImage } from "../lib/color.js";
@@ -41,6 +42,8 @@
import { openPreview } from "../lib/preview/previewSession.svelte.js";
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
import Icon from "../components/Icon.svelte";
import GlassBar from "../components/GlassBar.svelte";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
const nowPlaying = (kind, id) =>
@@ -157,10 +160,20 @@
// reader back to the top mid-read. The seq bumps once per open and never
// per stage. Ask for the pane BY NAME — the same rule scrollPaneFor()
// follows, and for the same reason.
// …UNLESS the reader is coming BACK to this page, in which case they are
// returning to something they had already found and the top is the one
// place they do not want. A history move leaves its remembered offset for
// exactly one landing; a fresh open leaves none and still starts at zero.
$effect(() => {
void catalog.artistOpenSeq;
const pane = document.querySelector(".content");
if (pane) pane.scrollTop = 0;
if (!pane) return;
const target = consumeScroll();
if (target == null) {
pane.scrollTop = 0;
} else {
restoreScroll(pane, target);
}
});
// THE HERO PIPELINE (Kyle, 2026-07-24 — the approved configuration,
@@ -1156,7 +1169,7 @@
// slot in CSS.
page.style.setProperty("--lift", `${banner - 14 - markRest}px`);
// Where the pins sit in flow. .results-head's net flow height is
// Where the pins sit in flow. the glass bar's net flow height is
// zero (-24 + 46 - 22), so the hero zone starts at exactly the
// same offset the pins do — and it is `position: relative`, so
// asking it cannot feed a sticky offset back into the measurement
@@ -2807,6 +2820,21 @@
openInfo = null;
}
/** The open edition's artwork, in the shared full-window viewer. Uses the
* UPGRADED cover, not the 500px grid variant the rail paints: the point of
* the viewer is the master resolution. */
function openRailArtwork() {
const album = railArtAlbum ?? openAlbum ?? openFamily;
const cover = album?.cover;
if (!cover || artBroken[cover]) return;
ui.artwork = {
src: coverUpgrades[cover] ?? cover,
local: false,
title: album.title ?? "",
subtitle: page?.artist?.name ?? "",
};
}
function toggleExpander(family) {
if (openFamilyKey === family.familyKey) {
closeExpander();
@@ -3166,7 +3194,7 @@
// mid-flight (measured settling ~500px short). Recomputing the target
// every frame and setting scrollTop directly is immune to both, and
// also tracks an old expander collapsing above during row switches.
/** The line the reveal lands a row on: clears the sticky results-head AND
/** The line the reveal lands a row on: clears the sticky glass bar AND
* the parked banner under it, plus breathing room. READ, not hard-coded:
* --banner-h is a fraction of the hero now, so a constant tuned to the old
* 124px banner would drop a clicked row underneath a taller one. Matches
@@ -3758,17 +3786,19 @@
// artist's biggest hit would leave every track on a quiet record flat. Zero
// means Last.fm knew none of this tracklist, and the whole column (header
// included) stays out of the layout rather than printing a row of dashes.
const playsPeak = $derived(
openTracks.reduce((peak, t) => Math.max(peak, t.plays ?? 0), 0),
const countPeak = $derived(
openTracks.reduce((peak, t) => Math.max(peak, t.listeners ?? 0), 0),
);
/** Share of the album's peak, as a bar width. */
const playShare = (track) =>
!track.plays || !playsPeak ? 0 : Math.max(2, Math.round((track.plays / playsPeak) * 100));
const countShare = (track) =>
!track.listeners || !countPeak
? 0
: Math.max(2, Math.round((track.listeners / countPeak) * 100));
/** Same share as a 0..1 temperature, driving the fill's colour. */
const playHeat = (track) =>
(!track.plays || !playsPeak ? 0 : track.plays / playsPeak).toFixed(3);
const countHeat = (track) =>
(!track.listeners || !countPeak ? 0 : track.listeners / countPeak).toFixed(3);
/** Unknown is a dash, never a zero — same rule as the Popular column. */
const fmtPlays = (plays) => (plays == null ? "—" : plays.toLocaleString());
const fmtCount = (n) => (n == null ? "—" : n.toLocaleString());
const openAlbumPlays = $derived(openTotals?.plays ?? null);
const openAlbumListeners = $derived(openTotals?.listeners ?? null);
@@ -4428,19 +4458,10 @@
</div>
</div>
<!-- Frosted-glass header in parity with the Now Playing panel's
.np-head; it floats over the seam tail's mirror at the top.
data-scrollhead keeps the overlay scrollbar below this bar. -->
<div class="results-head" data-scrollhead>
<button class="back" onclick={() => (ui.view = "catalog")}>
<Icon name="chevron-left" size={16} /> Results
</button>
{#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>
<!-- The shared frosted header (src/components/GlassBar.svelte); it
floats over the seam tail's mirror at the top. Its -24px margin is
what the pin geometry below is measured against. -->
<GlassBar />
<!-- Pure spacer now: it still reserves the room the artwork needs and
still sets where the content begins (both from its aspect-ratio,
@@ -4657,7 +4678,15 @@
{/if}
{/key}
<div class="rail-shade"></div>
<div class="rail-art-stage">
<!-- The artwork is a button: it opens the
shared full-window viewer, the same one the
Now Playing cover opens. -->
<button
class="rail-art-stage"
title="View artwork"
aria-label="View artwork full screen"
onclick={() => openRailArtwork()}
>
{#if previousRailArtAlbum?.cover && !artBroken[previousRailArtAlbum.cover]}
<img class="rail-art rail-art-outgoing" src={railCover(previousRailArtAlbum.cover)} alt="" draggable="false" />
{/if}
@@ -4675,8 +4704,16 @@
<div class="rail-art placeholder" class:rail-art-incoming={previousRailArtAlbumId != null}><Icon name="disc" size={40} /></div>
{/if}
{/key}
</div>
<h3 class="rail-title">{openAlbum?.title ?? openFamily?.baseTitle ?? ""}</h3>
<span class="rail-art-mark" aria-hidden="true"><Icon name="expand" size={18} /></span>
</button>
<!-- and the name opens the record's own page -->
<h3 class="rail-title">
<button
class="rail-title-link"
title="Open album"
onclick={() => openAlbumId != null && openCatalogAlbum(openAlbumId)}
>{openAlbum?.title ?? openFamily?.baseTitle ?? ""}</button>
</h3>
<p class="rail-meta">
{group.key === "upcoming" ? upcomingDate(openAlbum ?? openFamily) : fullDate(openAlbum ?? openFamily)}{openTracks.length
? ` · ${openTracks.length} tracks`
@@ -4773,10 +4810,10 @@
{/if}
{:else}
{#if openTracks.length}
<div class="tl-cols" class:has-plays={playsPeak > 0} in:partIn={{ delay: 30 }}>
<div class="tl-cols" class:has-count={countPeak > 0} in:partIn={{ delay: 30 }}>
<span class="tno">#</span>
<span>Title</span>
{#if playsPeak > 0}<span class="tplays-head">Plays</span>{/if}
{#if countPeak > 0}<span class="tcount-head">Listeners</span>{/if}
<span class="tdur-head"><Icon name="clock" size={13} /></span>
</div>
{/if}
@@ -4798,8 +4835,8 @@
}}
<div
class="trow"
class:has-plays={playsPeak > 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 || ""))}<span class="tick" title="Saved in your library"><Icon name="check" size={11} /></span>{/if}
</span>
{#if playsPeak > 0}
<span class="tplays" title={t.plays == null ? "No Last.fm play count for this track" : `${t.plays.toLocaleString()} Last.fm plays`}>
<span class="tplays-fill" style={`width:${playShare(t)}%`}></span>
<span class="tplays-val">{fmtPlays(t.plays)}</span>
{#if countPeak > 0}
<span class="tcount" title={t.listeners == null ? "No Last.fm listener count for this track" : `${t.listeners.toLocaleString()} Last.fm listeners`}>
<span class="tcount-fill" style={`width:${countShare(t)}%`}></span>
<span class="tcount-val">{fmtCount(t.listeners)}</span>
</span>
{/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