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.
217 lines
6.3 KiB
CSS
217 lines
6.3 KiB
CSS
/* SoulStream — Spotify-style dark theme */
|
|
|
|
/* Bundled fonts (same set as the TriSign app) */
|
|
@font-face {
|
|
font-family: "Nunito";
|
|
font-style: normal;
|
|
font-weight: 400;
|
|
font-display: swap;
|
|
src: url("/assets/fonts/nunito-400.woff2") format("woff2");
|
|
}
|
|
@font-face {
|
|
font-family: "Nunito";
|
|
font-style: normal;
|
|
font-weight: 600;
|
|
font-display: swap;
|
|
src: url("/assets/fonts/nunito-600.woff2") format("woff2");
|
|
}
|
|
@font-face {
|
|
font-family: "Nunito";
|
|
font-style: normal;
|
|
font-weight: 700;
|
|
font-display: swap;
|
|
src: url("/assets/fonts/nunito-700.woff2") format("woff2");
|
|
}
|
|
@font-face {
|
|
font-family: "Inter";
|
|
font-style: normal;
|
|
font-weight: 400;
|
|
font-display: swap;
|
|
src: url("/assets/fonts/inter-400.woff2") format("woff2");
|
|
}
|
|
@font-face {
|
|
font-family: "Inter";
|
|
font-style: normal;
|
|
font-weight: 600;
|
|
font-display: swap;
|
|
src: url("/assets/fonts/inter-600.woff2") format("woff2");
|
|
}
|
|
@font-face {
|
|
font-family: "Inter";
|
|
font-style: normal;
|
|
font-weight: 700;
|
|
font-display: swap;
|
|
src: url("/assets/fonts/inter-700.woff2") format("woff2");
|
|
}
|
|
|
|
/* Typed accent registrations let scoped overrides (the artist page's
|
|
browse accent) CSS-transition; the global rAF crossfade is unaffected. */
|
|
@property --accent {
|
|
syntax: "<color>";
|
|
inherits: true;
|
|
initial-value: #1f9ad6;
|
|
}
|
|
@property --accent-hover {
|
|
syntax: "<color>";
|
|
inherits: true;
|
|
initial-value: #41b7ee;
|
|
}
|
|
|
|
:root {
|
|
--app-font: "Nunito", -apple-system, "Segoe UI", sans-serif;
|
|
--bg-base: #121212;
|
|
--bg-sidebar: #000000;
|
|
--bg-elevated: #181818;
|
|
--bg-highlight: #242424;
|
|
--bg-press: #2a2a2a;
|
|
--bg-player: #181818;
|
|
--text-primary: #ffffff;
|
|
--text-secondary: #b3b3b3;
|
|
--text-muted: #7a7a7a;
|
|
--accent: #1f9ad6;
|
|
--accent-hover: #41b7ee;
|
|
--accent-soft: color-mix(in srgb, var(--accent) 15%, transparent);
|
|
--border: #2a2a2a;
|
|
--radius: 8px;
|
|
/* Artwork that opens the full-window viewer, wherever it appears — the
|
|
album hero, the artist page's expander rail, the Now Playing cover.
|
|
The whole element scales rather than the picture inside it, because
|
|
`transform` does not reflow: the artwork grows and nothing beside it
|
|
moves. Scaling the image within a fixed frame reads as the picture
|
|
filling its box, not as the album lifting off the page.
|
|
Tuned on the round-2 hover mock, 2026-07-28. */
|
|
--art-grow: 1.035;
|
|
--art-motion: 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
--art-wash: rgba(0, 0, 0, 0.18);
|
|
--sidebar-width: 232px;
|
|
--player-height: 88px;
|
|
--nowplaying-width: 340px;
|
|
}
|
|
|
|
/* Floating overlay scrollbar (see src/lib/scrollbar.js). The native bar is
|
|
hidden so it never reserves width or reflows content; a thin thumb floats
|
|
in the container's right padding, fading in on scroll/hover, out when idle. */
|
|
.float-scroll {
|
|
scrollbar-width: none;
|
|
-ms-overflow-style: none;
|
|
}
|
|
.float-scroll::-webkit-scrollbar {
|
|
width: 0;
|
|
height: 0;
|
|
display: none;
|
|
}
|
|
/* Zero-height sticky rail: the compositor keeps it pinned to the scrollport
|
|
top, so the thumb inside never needs JS counter-scrolling (which jitters on
|
|
composited-scrolling panes — see scrollbar.js). In flex/grid scrollers its
|
|
row-gap contribution is cancelled by an inline negative margin-bottom. */
|
|
.float-rail {
|
|
position: sticky;
|
|
top: 0;
|
|
height: 0;
|
|
z-index: 60;
|
|
pointer-events: none;
|
|
}
|
|
.float-thumb {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 3px; /* refined inline: 3px inside the scroller's padding box */
|
|
width: 5px;
|
|
border-radius: 2.5px;
|
|
/* Song-name text tone (--text-secondary), nudged a little lighter. */
|
|
background: color-mix(in srgb, var(--text-secondary) 75%, #ffffff);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
/* Slides along the track via translateY (composited — no layout/paint),
|
|
so give it its own layer. Only opacity is animated. */
|
|
will-change: transform;
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
/* Order matters (equal specificity, last wins): a faint hint on hover, more
|
|
present while scrolling, solid while dragging. hover < show < dragging. */
|
|
.float-thumb.hover {
|
|
opacity: 0.35;
|
|
pointer-events: auto;
|
|
}
|
|
.float-thumb.show {
|
|
opacity: 0.8;
|
|
pointer-events: auto;
|
|
}
|
|
.float-thumb.dragging {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
/* The thumb's slide as a scroll-driven animation (scrollbar.js sets it up
|
|
where animation-timeline is supported). Endpoints are written by measure():
|
|
--ft-y0 = topInset - padTop, --ft-y1 = --ft-y0 + (trackH - thumbH). The
|
|
default animation range spans the scroller's whole scroll range, so linear
|
|
progress IS scrollTop / maxScroll — the exact ratio the JS fallback
|
|
computes — and the compositor tracks the scroll with zero main-thread
|
|
work (and none of the fallback's one-frame lag under threaded scrolling). */
|
|
@keyframes float-thumb-ride {
|
|
from {
|
|
transform: translateY(var(--ft-y0, 0px));
|
|
}
|
|
to {
|
|
transform: translateY(var(--ft-y1, 0px));
|
|
}
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
height: 100%;
|
|
overflow: hidden;
|
|
/* Kill WebView2's document-level elastic overscroll (it visibly shifts the
|
|
whole view). Scoped to the root + the real scroll panes below — NOT `*`:
|
|
applying it to every element turns each `overflow: hidden`/`auto` box
|
|
(cards, panels, popovers, the album expander) into a scroll container
|
|
that swallows the wheel instead of chaining it to the page, so hovering
|
|
one froze scrolling. Inner regions now default to `auto` and chain. */
|
|
overscroll-behavior: none;
|
|
background: var(--bg-base);
|
|
color: var(--text-primary);
|
|
font-family: var(--app-font);
|
|
font-size: 14px;
|
|
-webkit-font-smoothing: antialiased;
|
|
user-select: none;
|
|
}
|
|
|
|
#app {
|
|
height: 100%;
|
|
}
|
|
|
|
button {
|
|
font: inherit;
|
|
color: inherit;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
input {
|
|
font: inherit;
|
|
}
|
|
|
|
::-webkit-scrollbar {
|
|
width: 12px;
|
|
}
|
|
::-webkit-scrollbar-thumb {
|
|
background: hsla(0, 0%, 100%, 0.3);
|
|
border-radius: 6px;
|
|
background-clip: padding-box;
|
|
border: 3px solid transparent;
|
|
}
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: hsla(0, 0%, 100%, 0.5);
|
|
background-clip: padding-box;
|
|
border: 3px solid transparent;
|
|
}
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|