Commit Graph
23 Commits
Author SHA1 Message Date
KeelrLeek 606250c361 resize: reads before writes everywhere, and an open album drags like a closed one 2026-07-31 16:58:48 -05:00
KeelrLeek 3f4f5c305a now playing: the transfer moves into the bar, and the panel speaks the app's headings
The hover popover is deleted. It showed the peer, the rate, a percentage and
a second progress bar — every one of which the status line and the hairline
beside it already carry — so it was a duplicate you had to hover for. The two
things unique to it move up: the save action into the bar, and nothing else.
Deleted with it, the reveal action, its 250ms close grace and the
`.np-statuswrap` hit area, all of which existed only to make a popover
reachable across the 25px gap between the status line and itself.

Release sections were the one heading in the app that was 13px, Title Case,
full white on an opaque sticky bar; every other one — `.lab` here,
`.disc-head` and `.rail-lab` on the catalog pages — is 11px uppercase muted.
They now match, with the hairline rule the catalog pages use, and they no
longer pin: the bar existed only to hide rows sliding under a sticky heading.

Those headings sit on the blurred record, not a flat page, so `--text-muted`
and #5c5c5c were chosen against a background that is not there. Each carries
a dark plate now, years get the album page's over-artwork treatment, and the
hairline goes 0.07 -> 0.14, which over a bright cover was the difference
between a faint line and no line.

"From the album" rendered `player.queue`, which the album fill grows ONE
TRACK AT A TIME as each Soulseek source resolves — a nine-track record read
"5 tracks" while you watched the rest arrive. It renders the record's own
tracklist now, matched to queue items by provider id, and a row with no
source yet resolves on demand when clicked.

The About pane gains a record head, a lede, stat tiles, pills and a source
line, all re-laid-out from data already in hand. The artist page's expander
gets the same lede and pills — no record head or tiles there, because its
rail already carries them.
2026-07-29 18:46:31 -05:00
KeelrLeek 9dc3eaf939 artist hero: the blurred layers ride with the photograph
Two layers were left behind when the hero rides up on scroll, both of them
blurred copies of it, so both read as "the blur does not line up".

The load seed and its mono twin were never in the squeeze-ride rule -- they
got a filter on scroll and no transform at all, so at scrollTop 600 they sat
132px below where the photograph had gone. They were left out because
hero-squeeze-ride sets transform outright and would drop the scale(1.06) they
are built on; hero-seed-ride is the same translate composed AHEAD of that
scale, so the offset stays in unscaled pixels and matches the banner's rather
than being 1.06x it. Measured 0px drift at scrollTop 0, 300, 600 and 900.

The mono twin is a BAKED copy of the hero canvas and snapshotGpuTwins only
ever ran on a hero READY. So after any resize the live hero repainted at the
new size while the twin went on stretching its old frame into the new box --
an overlay visibly off the photograph, and permanent until the next hero
happened to re-bake it. It re-bakes when the measured box changes now,
debounced past the renderer's own settle so it copies the canvas already
repainted at the new size, and dimension-guarded so it costs nothing during
the moving part of a drag. Verified: live store and baked twin agree at
3186x1629, then both at 2856x1607, then both back, with one frame throughout.
2026-07-29 08:11:36 -05:00
KeelrLeek 35729c1f9e navigation: state the bar's geometry instead of collapsing a margin for it
Hoisting the glass bar out of the pages (8017c0d) dropped the artist page's
hero 24px, so the artwork began below the bar instead of running under it.

The bar used to be each page's FIRST CHILD, and its `margin-top: -24px`
collapsed through the page root -- pulling `.artist-page` itself up to the
top of the pane, where the hero could sit behind the bar. As a sibling it
cannot do that, so the root fell back to the pane's padding edge and took
the artwork with it: `.artist-page` is `position: relative` and the whole
`.hero-bg` stack is positioned against its box.

Diffing every element in the page against the previous build, 16 moved and
all by exactly +24: .hero-bg, .hero-seed, .gpu-hero, canvas.banner (the
visible one), the twins, .seam-clip/.seam-tail and .sheetclip. The other 94
did not move at all -- the flow content was never the problem, which is why
this did not show up as content shifting.

That geometry is now stated rather than emergent. The bar's margin-bottom
is -bar-h, so its net flow contribution is -pane-pad: it consumes no height
of its own AND cancels the pane's top padding, leaving the page's box flush
with the top of the pane. Each page under the bar hands that inset back with
`padding-top: var(--pane-pad)`, which is a no-op for the hero (padding does
not move a positioned ancestor's padding-box top) and restores every flow
child. The pane's 24px is now a named custom property rather than a number
repeated in four rules with a comment warning they are tied.

Verified against the pre-hoist build element by element: 87 artist-page
elements compared, ZERO moved, including canvas.banner back at 84 and the
Fanart wordmark at 883. Album page root 38, hero 62, tracklist 514 --
identical. The bar still paints above the hero (elementFromPoint at its
centre returns the bar), arrival still animates only the arriving crumb,
Back still animates nothing, and a page with no bar is untouched at the
pane's padding edge. 85 JS tests pass.

The verification that let this through checked the page's top against my own
model of the layout rather than against the previous build, and the model
omitted margin collapsing. The before/after element diff above is the check
that would have caught it.
2026-07-28 17:40:22 -05:00
KeelrLeek 8017c0d877 navigation: one glass bar for the app's life, not one per page
The bar was rendered by each view, so `<CurrentView />` swapping on every
navigation destroyed it and built a new one. Measured: the `.glassbar`
element is not the same node on either side of an artist -> album move, and
the old one is disconnected.

That broke the crumb entrance animation, which is what this was noticed
from. The `seen` set exists so only a genuinely ARRIVING crumb slides in,
and it is component state -- so it was empty on every mount and every crumb
looked new. Instrumenting Element.animate across one navigation:

  AC/DC -> Highway to Hell   should animate: the album
                             actually animated: all three crumbs
  Back to AC/DC              should animate: nothing
                             actually animated: both crumbs

So the whole trail re-flew in from the right on every page change, and Back
animated crumbs that had never left -- the exact opposite of the
direction-of-travel cue the animation was written for.

The bar is app chrome, not part of a page, so App.svelte now mounts it once
as the scroll pane's first child and pages no longer carry it. Views that
do not show it yet hide it with `display: none` rather than an {#if}: the
element leaves the layout and measures zero -- scrollbar.js reads this
bar's height to start the thumb below it, and correctly gets 0 -- while the
component stays alive and keeps its crumb memory across the round trip.

Geometry is unchanged, which the negative-margin bleed made easy to get
wrong: the bar's net flow height is zero either way, so moving it from
first-child-of-the-page to previous-sibling-of-the-page leaves the page's
top exactly where it was. Verified against the live pane -- bar top/left/
width flush with the pane and 46 high, page top at pane + 24, z-index 40
and elementFromPoint at the bar's centre still returning the bar rather
than the hero underneath.

After: arriving at an album animates only the album's own crumb, Back
animates nothing, and a trip out to Library and back animates nothing. The
backdrop-filter layer is no longer recomposited on every page change, and a
bar element can now transition between pages at all, which was impossible
when each side was a different node.

Two consequences worth stating. The resolver pulse takes the GLOBAL accent
everywhere now; on the album page it used to inherit that page's extracted
palette, because the bar sat inside `.album-page`. The artist page always
gave it the global accent, so the two now agree rather than disagreeing.
And putting the bar on the remaining pages is now widening BAR_VIEWS plus
deciding how it sits against each page's existing header -- the mounting
question is settled.

85 JS tests pass.
2026-07-28 17:31:01 -05:00
KeelrLeek 98d11a2228 previews: the album button opens the record the previewed track came from
The third action in the preview modal's control row has been a disabled
stub since the modal landed -- there was nowhere for it to go until the
album page existed. It goes there now.

The target is the item's own provider `albumId`, never a title match, so a
top-tracks preview lands on the exact record the modal's metadata line is
already naming: Elephant Gym's "Light" opens Angle, and AC/DC's "Highway
to Hell" the album rather than any of the compilations carrying it. The
manifest has carried `albumId` and an `actions.openAlbum` flag from the
start; all 20 rows of a Deezer top-track payload declare one.

The navigation lands on the previewed TRACK, not just the record -- the
album page's existing pending-track flash does the scrolling and the
highlight, the same landing the now-playing song link already gets.

Three details that are not obvious:

- The overlay must not import state.svelte.js (the module comment on
  previewSession explains the cycle), so the navigator is injected by the
  page exactly as `playFull` already is.
- Both ids are read out of previewUi and the snapshot BEFORE the close, not
  after: closePreview() nulls the callback and drops the snapshot the
  active item came from. And the close happens first, because the session
  holds the audio focus and navigating under an open dialog would land the
  new page behind the scrim.
- contracts.js freezes every manifest id as a String, while `catalog_album`
  takes a u64 -- a JSON string fails to deserialize -- and the album page's
  flash compares track ids with ===. Both cross back to numbers in
  openAlbumFromPreviewItem, once, rather than at each call site.

An album session is already standing on the record it would open, so the
button greys out there with "You're already on this album" rather than
re-navigating to the page underneath. The decision is made from the
manifest's own contextKey, so it holds for any future surface that opens an
album-kind session. The album page still supplies the navigator, so a row
that ever declares a different album goes somewhere instead of being
silently dead.

Verified in the running app over CDP: artist top-tracks preview -> click ->
lands on Highway to Hell (id 9410086), pendingTrackId 92719900 as a Number,
matching track found on the page, .trow.flash present, modal gone, and the
glass bar trail reading Search -> AC/DC -> Highway to Hell. From an album
session the button is disabled and a click changes nothing. 85 JS tests
pass.
2026-07-28 17:13:54 -05:00
KeelrLeek 122157e061 listener bars: a bar is never narrower than the number standing on it
Two faults in the tracklist's listener column, both visible on any record
with one runaway hit.

The bar was a LINEAR share of the album's biggest song, so a single
outlier took the whole column and pressed everything else into a sliver.
Weezer's Green Album is the case: Island In The Sun has 11.9x the
listeners of that record's quietest track, so nine of the ten bars lived
inside the bottom 8% of a 168px column and stopped being tellable apart.

And the fill was absolutely positioned with the number floating on top of
it, so a short bar left its own digits hanging off the end onto the row
background. Six of the ten rows on the Blue Album did this.

The share is now raised to a power below 1 (0.42). That is strictly
monotonic and still anchored to the PEAK, so the ordering survives and a
genuinely flat record still reads flat -- a min/max normalisation against
the album's own quietest track was rejected for exactly that reason, since
it would stretch Maladroit to look as varied as Green Album. Measured in
the running app, shortest bar of a 168px column:

  album                  top:bottom     before      after
  Weezer (Green Album)        11.9x        8%        38%
  Make Believe                 6.7x       15%        45%
  Weezer (Blue Album)          3.9x       26%        56%
  Weezer (White Album)         2.3x       43%        70%
  Maladroit                    2.2x       46%        72%

The flat records move least, which is the point: the curve spends its lift
on the albums that were squashed and barely touches the ones that were not.

For the second fault the number moves INSIDE the fill, and the fill gets
`min-width: max-content`. The floor is then per-row and measured by the
browser in the real font -- nothing in JS could do that honestly -- and the
width handed over is only ever a lower bound. Across the four albums above,
numbers hanging off their bar went from 6 to 0 and every bar stayed
monotonic in its count. Green Album lands at 38% rather than the 35% the
curve alone gives, because the text floor takes over on its shortest row.

A track Last.fm has no count for keeps its dash and now gets no paint at
all; a floor-width pill behind an em dash would read as a measured tiny
value rather than an absent one.

The scale moves to src/lib/listenerBars.js because the album page and the
artist page's open-in-place album panel draw this identical column, and a
second copy would quietly disagree with the first about how big a record's
hit was -- the same reason editions.js was lifted out of the artist page.
Both call sites verified over CDP; 85 JS tests pass.
2026-07-28 17:00:18 -05:00
KeelrLeek 7a517dca59 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.
2026-07-28 16:40:31 -05:00
KeelrLeek ea9a040d09 album page: the record gets a page of its own, under the same glass bar
The album page was a back link, a 150px cover and a list of titles — a
placeholder beside the artist page it is reached from. It is now built from the
same parts: the frosted glass header, a hero carrying the record's own art and
palette, the play-count bars, and the edition cards.

**The glass bar is the artist page's, not a copy of it.** Same 46px, same
rgba(20,20,24,0.28) over blur(12px) saturate(1.7), same sticky top:-24px with
the -24/-28/-22 bleed that spans the pane and leaves zero flow height. It is
meant to be the header every page navigates by, so it had to be the same
object rather than something that resembles it.

It gains one thing here: the record's cover, name and a play button fade into
the bar once the title itself has gone under it, so the bar always says where
you are. That watches the TITLE, not the identity block — watching the block
meant waiting for the artwork to clear too, about 240px of scroll on a page
that only has 199px of it, so it would never have fired on a ten-track album.

**The hero is one proportional block.** `--cover-scale` sizes the artwork
(282px) and everything beside it is `em` off that one root, so type, gaps, the
artist portrait, the pills and the stat divider all move by the same factor.
Two earlier attempts are worth naming because they both looked wrong:

  - damping the type (0.6 + 0.4·s) made the words visibly lag the artwork;
  - `space-between` filled the height but opened the gaps faster than the type
    grew, so the block came apart as it got bigger.

The gap is 1.8em, which is what the five rows need to fill the cover exactly,
and being an em it keeps that fit at any size. Measured live: the label's top
and the buttons' bottom both sit 0px from the artwork's edges. The hero's own
height derives from the artwork (`--cover + 68px`), so the 24px under the glass
bar holds at every scale instead of being a second number that drifts.

**Editions come from src/lib/editions.js — the artist page's own grouping.**
`releaseFamilies`, `editionBaseTitle`, `isEdition` and `editionLabel` moved out
of CatalogArtist.svelte (143 lines deleted there, not duplicated) so both pages
fold a record's pressings the same way. Two implementations would have drifted
invisibly: each page would show a plausible edition list and disagree about
which pressings are one album.

The album payload has no siblings, so the family is rebuilt from the artist's
discography. It reads the FULL page rather than the overview — the overview is
the artist page's first-paint subset and both dropped the anniversary edition
and carried no track counts, so the rows read "1994" where the discography
reads "1994 · 50 tracks". Cached per artist, and already warm whenever this
page was reached from theirs. Live, the Blue Album now lists Original 10 /
Deluxe 24 / Anniversary Super Deluxe 50, matching the discography exactly.

One deliberate narrowing: the artist page buckets releases by category before
grouping, and that categorisation leans on page state this view does not load
(harvested description hints, MusicBrainz type hints). So a sibling is accepted
only when the backend LINKED it (`edition_group`, a verified ordered core-track
match) or it declares the same record type. That can only ever show fewer
editions than the discography, never promote a separate live album or
compilation into this record's edition list.

**Switching edition is a swap, not a navigation.** `openCatalogAlbum` grew a
`keepCurrent` option that holds the page already on screen instead of clearing
to the loading state; the incoming cover is decoded before the swap so the
artwork crossfades rather than blinking out; and the parts are re-dealt on the
same clock as the expander — 400ms quintOut, 7px lift, banner → cover → kind →
title → meta → stats → actions → tracklist → description → editions → credits.
Polled at 16ms through a swap: 0 blank frames. The blurred banner fades without
lifting, because sliding a 42px blur reads as a wobble, and every part animates
to its OWN resting opacity — the banner rests at 0.62 and animating it to 1
would have run the colour wash half again too bright for the whole swap. A
failed load under `keepCurrent` clears anyway, so a dead edition surfaces the
error instead of looking like the click did nothing.

**The byline shows the artist, not the album cover again.** The album payload
carries the artist's NAME and nothing else, so `resolveArtistSeedByName` reads
and fills the same cache `openArtistByName` uses: the click that navigates
there has usually already paid for it, in-flight lookups are deduped so the
page and its own back button cannot fire the same search twice, and a transport
failure stays retryable rather than being cached as "no artist". A neutral dot
holds the space until it lands, so the line never reflows.

Shuffle and Preview join Play and Save. Shuffle is an action on this record,
not a toggle — it always turns shuffle on rather than flipping a global and
silently starting the album in order — and stays lit while it is on so that
state is visible from here. Preview opens the same modal the artist page does;
`buildAlbumManifest` already existed in the preview layer with no caller, which
is what this surface was for. It reports partial availability the same way, and
the manifest is a snapshot so an edition swap cannot rewrite an open session.

Also: plays bars, the whole-track/listener totals, the editorial description
and a released/label line, all off data `catalog_album` was already returning
and the page was throwing away. The plays column hides below three tracks — a
bar measured against itself is not a comparison, and on a single it read as a
rating. Track counts are pluralised, so a single says "1 track".

No add-to-playlist button. `add_to_playlist` requires a file path per track, so
a catalog album can only be added once it is in the library; that is a playlist
decision, not something to slip in behind an icon.

150 Rust tests and 85 JS tests pass.
2026-07-28 15:16:58 -05:00
KeelrLeek 84f050fe3b popular: let Last.fm decide what is popular, and Deezer only how to play it
Popular took Deezer's `/top` fifty and re-sorted it by scrobbles, so the LIST
could never be better than Deezer's catalogue. For most artists that is
invisible — measured against Last.fm's own top ten, Queen, Radiohead, Weezer,
Green Day, Fleetwood Mac and Tame Impala agreed on 56 of 60 rows. For an artist
Deezer carries badly it is the whole page. Deezer has no Loveless at all: 8 of
My Bloody Valentine's Last.fm top ten are absent from its catalogue outright,
so Popular filled up with b-sides, a compilation cut and a soundtrack cut, and
"When You Sleep" — the song with 1.28M listeners, more than any other they
recorded — could not appear at any position.

So Last.fm's ranking IS the list now, and each entry is resolved in preference
order: a Deezer track of the same `track_title_identity` when one exists, since
that brings a real album, cover, preview and duration and stays eligible for
attribution; otherwise a row built from the MusicBrainz recording, which
supplies the record it belongs to and the track length. Anything neither
provider can place is dropped, and Deezer rows Last.fm never ranked still
backfill from underneath so a thin Last.fm artist keeps a full list.

A synthesized row has no 30-second preview and that is the whole cost, because
playback never needed one: streaming resolves over Soulseek by artist and
title, and the Deezer id only ever marked the now-playing row. The previews
button already reports partial availability, so it reads "7 of 20 available"
rather than pretending.

Their albums come from ONE request. `release?artist={mbid}&inc=recordings+
release-groups` returns every release WITH its tracklist and names the work each
pressing belongs to — measured on My Bloody Valentine, 208 KB covering 64
releases that fold to 27 works. The alternative was a lookup per supplemented
album behind the 1 req/s gate. Only the full page pays for it; the overview
still just orders Deezer's pool and is replaced wholesale a moment later.

**And a relabel for songs whose record Deezer does not sell.**
`apply_original_album_attribution` can only move a track onto another DEEZER
album, because it swaps the row onto that album's real track — so when the
original is missing from Deezer entirely there is nothing to swap to and the row
keeps the pressing Deezer licensed, which is exactly the kind of record the rule
exists to remove. Deezer carries precisely two Loveless songs, "Only Shallow" on
the compilation *Amateur* and "Sometimes" on the *Lost In Translation*
soundtrack, so those two sat under a compilation and a soundtrack while the
eighteen tracks Deezer does NOT carry already showed Loveless.

That pass is a relabel, not a swap: album, album_id and cover follow
MusicBrainz while id, preview and duration stay on the Deezer pressing, which is
still the playable one. It may only take a row AWAY from a record that is not a
studio album, or it would overrule a correct attribution and trade a real album
card for a synthetic id with no card behind it — verified, Queen's "Bohemian
Rhapsody" stays on *A Night At The Opera* and the pass reports 0 relabelled for
Queen and Radiohead.

Mixtape/Street counts as a studio record for this, for the same reason it earns
its own tab: excluding it left "Guillotine" attributed to the SINGLE named after
it rather than to Exmilitary, which is the album it opens. Every other secondary
type still disqualifies.

POPULAR_KEEP 10 → 20, and the frontend's own `slice(0, 10)` is gone rather than
raised to match. There were two caps for one number and they disagreed the
moment either moved; the backend owns it now.

Measured live: My Bloody Valentine's Popular is Last.fm's ranking exactly —
When You Sleep, Only Shallow, Sometimes, Loomer, To Here Knows When, Soon,
Touched — all twenty on Loveless or Isn't Anything. Queen gains "We Will Rock
You" on *News of the World*, which the Deezer-pool list omitted because Deezer
sells it only as a Movie Mix. Death Grips' "Guillotine" moves to Exmilitary.
75 Rust tests and 85 JS tests pass.

Known, and older than this change: Radiohead's "No Surprises", "Karma Police"
and "Let Down" attribute to *OK Computer OKNOTOK 1997 2017* rather than *OK
Computer*. Deezer carries both and dates BOTH 1997-05-21, so the rule's date
tiebreak cannot separate them and the 23-track reissue wins; `is_edition_title`
does not recognise "OKNOTOK 1997 2017" as an edition marker. The relabel pass
reports 0 for Radiohead, so this is the existing attribution rule, not the new
one.
2026-07-28 11:41:17 -05:00
KeelrLeek 644a0192a5 discography: give mixtapes a tab, and stop the provider spelling it twice
Death Grips' "Exmilitary" was missing from the app entirely. Deezer never
carried it, and MusicBrainz has it but types the release group Mixtape/Street,
which the studio-album supplement excluded along with every other secondary
type. The same exclusion hid Chance the Rapper's "Acid Rap" and "Coloring
Book" — records that outscrobble everything he has released as an album.

They cannot simply join Albums. MusicBrainz files 118 Mixtape/Street groups for
Gucci Mane and 51 for Lil Wayne, which buries a 15-record discography, and no
significance gate separates the canon from the ephemera: measured on Last.fm,
Exmilitary sits at 19% of Death Grips' biggest record while Gucci Mane's
"Chicken Talk" sits at 20% of his. The two are indistinguishable by weight
because both are real. So the answer is filing, not filtering — a tab of their
own, where volume costs nothing and nothing has to be hidden. It sits beside
Albums rather than down with the archival tabs because for the artists who have
any, the mixtape is usually the canon. Empty tabs are already dropped, so
Queen and Radiohead never see it.

A SEPARATE query rather than a relaxed filter, and the page cap is the reason.
MusicBrainz returns 100 items, and asking one query for both — via
`-secondarytype:live AND -secondarytype:compilation AND …`, the only form its
Lucene actually honours, since a bare
`(-secondarytype:* OR secondarytype:"Mixtape/Street")` returns either nothing
or mixtapes only — came back with Gucci Mane's 87 mixtapes and just 13 of his
15 studio albums. The mixtapes ate the page and pushed two real records off it.
Split, the album supplement is byte-for-byte what it was, and the cost is one
request on the DEFERRED identity pass, disk cached for a day like every other
MusicBrainz call.

The tab then exposed a dedup hole that had been there all along.

`normText` DELETES bracket contents, which is correct for the peer file
matching it was written for and wrong for release identity. MusicBrainz writes
a qualifier after a colon where Deezer puts it in parentheses, so
"World War 3 (Lean)" reduced to "worldwar3" while MB's "World War 3: Lean"
reduced to "worldwar3lean". They could never collide, so the supplement
injected a second card for a record already on the page — and with mixtapes now
filed apart, the same work appeared in Albums AND Mixtapes. Ten such pairs on
Gucci Mane, among them "Wilt Chamberlain (Pt. 5)" / "Wilt Chamberlain, Part
Five", "Bird Flu 2" / "Bird Flu, Part Two" and "Trap House 4" / "Trap House
Four".

The identity merge gets its own key, which keeps the qualifier and folds the
three things the providers genuinely disagree about: the separator, the words
introducing a number, and whether that number is a digit, a word or a roman
numeral. Single-letter romans are deliberately NOT folded — "X", "V" and "I"
are real album titles and reading them as 10, 5 and 1 would merge unrelated
records.

And a matched mixtape contributes its TYPE, not a card. The Deezer row keeps
the slot because it is the playable one — real album id, real tracklist — and
MusicBrainz's verdict re-files it, exactly as `type_hints` already re-files a
mislabelled compilation. That is what moves "World War 3 (Lean)" out of Albums
instead of leaving one copy in each tab. It is also why Gucci Mane's Albums
count falls 146 -> 73: those rows were ALREADY being swallowed by the old
title check and left sitting in Albums untyped, so this does not hide them, it
finally classifies them.

Every row sharing a key adopts together, not just the first. A provider
sometimes carries one record twice under its own spellings ("Brick Factory 2"
beside "Brick Factory, Vol. 2"); typing one of them and not the other put one
copy in Mixtapes and left the other in Albums, which is a worse page than
leaving both alone.

Measured live: Gucci Mane 252 -> 241 rows with cross-tab duplicates 10 -> 0,
Death Grips one mixtape and it is Exmilitary, Queen and Radiohead unchanged
with no Mixtapes tab rendered. 75 Rust tests and 85 JS tests pass.

Still open, and older than this change: Deezer carries "Brick Factory 2" and
"Brick Factory, Vol. 2" as two rows, and "Trap House 3" beside "Trap House
III". This key would collapse them, but that means deleting a row the page
currently shows, which wants measuring across a range of artists first.
2026-07-28 10:28:34 -05:00
KeelrLeek 388ad7d910 artist page: open an album in place, and show what the numbers are worth
The discography grid gains an expander: click a record and it opens between the
rows, with a rail carrying the art, the totals and the editions, and a track
list beside it. Three strands landed on this file across as many sessions and
they share the same DOM -- the row template the plays column widens is the one
the expander draws, and the keying that attribution needs is what makes the
cover crossfade possible -- so they arrive together.

**Keeping the grid still while a panel grows inside it.** An expander opening
mid-grid moves every row below it, and the naive version fights its own
animation: the reserve is measured against a layout that is still collapsing.
So each expander other than the one being kept is treated as mid-outro and
discounted from the measurement, the grid's `min-height` glides to the new
reserve rather than snapping, and a same-row switch keeps BOTH panels alive --
`panelLeave` takes the outgoing one out of flow so the two overlap instead of
stacking, and the stage eases between their heights so the box never collapses
to nothing on the way. `.xp-stage` exists to be the containing block for that.

**The loading state only exists if the load is slow.** A warm open resolves in
tens of milliseconds and used to flash a line of text on its way past, so
nothing renders until the load has outlasted a 250ms grace -- and when it does
render it is placeholder rows drawn in the track grid's exact geometry, so the
real list replaces them in place rather than reflowing.

**Cover tier follows DPR, not window size.** The rail art is a FIXED 209px --
the rail column is 250px regardless of monitor -- so a 4K display does not make
the box bigger; only device pixel ratio does. 209px needs 418 device pixels at
DPR 2, which the 500 rendition covers, and side by side at DPR 2 the 500 and
1000 sources are indistinguishable at actual size. At DPR 3 the 500 is visibly
soft. So 500 below that line and 1000 above it, rather than paying 3.6x the
bytes (148KB -> 501KB per cover) on every display to fix the top one. Coverart
Archive has no 1000 rendition; its next step is 1200.

**Popular rows are keyed on `origin_track_id ?? id`.** This is what lets the
backend hand a row its original album without destroying it: the corrected row
is REUSED rather than torn down and rebuilt, which keeps its save state and is
the whole reason the cover can crossfade instead of popping. `nowPlayingTrack`
matches either id, so a track that is playing stays marked as playing across the
correction. The crossfade itself needs the proxied art SYNCHRONOUSLY -- one
microtask is still a frame with the raw CDN url in `src`, which is a network
request for a file already held -- hence `peekCatalogArt`, which returns the
resolved data URL or null and never a promise.

The replay recipe goes to v4 for the same reason it went to v3: a v3 replay
carries Deezer's licensed attribution on every popular row -- Bohemian Rhapsody
on the 2018 movie soundtrack, wearing that poster -- and painting a known-wrong
album name instantly is worse than one ordinary load.

**Plays, as a heat block rather than a bare number.** Bar length and colour both
scale with the count, tinted with the OPEN album's accent so the column reads as
this record's own. The header and the rows share one grid template, so the
columns line up whether or not Last.fm knew the record -- and when it knew
nothing the whole column, header included, stays out of the layout entirely. An
unknown track is a dash, never a zero. Album totals sit between the meta line
and the Play row, badged when they were summed from the tracklist because the
provider's own figure could not cover its biggest track.

The listener count in the Popular list needed the row wider: break-even was
833px for the column plus its gap, up from 760px, and the rest is air, so title
and album each end up ~38px wider than before rather than narrower. That column
is muted and deliberately NOT accent -- the row already spends the accent on
`.now`, and a coloured column on every row drowns the one that means "playing".
2026-07-28 09:44:05 -05:00
KeelrLeek 8e623e3d8d artist page: put the load status in the chip row it hands over to
The floating pill worked but sat in the wrong place and at the wrong scale: top
right of the pane, far from the content it describes, 30px tall with 12px text
against a row of 27px chips at 11.5px.

The stat chips are gated on `!page.art_preview` -- they do not exist yet while
the page resolves. So the status simply takes their slot and hands the row over
when they arrive: same container, same `.chip` metrics, with only a spinning
accent ring and a softer label colour of its own. Measured across the handover,
the status chip and the first real chip occupy the identical box (x 230, y 1145,
height 27), and neither the row top nor the play button moves.

That also keeps the property the floating version was chosen for. Nothing
reflows when the status goes, because it is the only thing in the row and the
chips that replace it start at the same origin -- no absolute positioning, no
z-index fight with the sticky .results-head, and no element parked outside the
layout it belongs to.
2026-07-25 20:20:17 -05:00
KeelrLeek d67df6f55e artist page: float the load status instead of shoving the page down
"Refining release details…" rendered as a paragraph in the content flow, above
the action row. It cost about 32px of layout, so the play button and everything
under it visibly jumped up the instant the page finished resolving -- a shift
announcing nothing more than that a background stage had ended. It also read as
debug output: 12px secondary text and a bullet, in a page that otherwise speaks
in pills and chips.

It is now an absolutely positioned pill at the pane's top-right with a spinning
accent ring. Measured: the play button and the Popular heading sit at identical
offsets whether it is showing or not, so appearing and resolving move nothing.

Two placement constraints, both found the hard way:

- It must NOT live in .fade-flow with the rest of the body. That carries a
  transform, so the pill would anchor to it rather than the page, and it sits
  inside the clipped .fade-frame, which would mask it.
- z-index has to clear 40, not merely .scrollbody's 3. The sticky .results-head
  shares this top band, and at any lower value it paints straight over the pill
  -- which looks exactly like the pill failing to render, with no other symptom.

.artist-page was already position:relative, so this introduces no new containing
block into a scroll pipeline that is sensitive to exactly that.

The pill is pointer-events:none since it is never a target, carries
role="status" aria-live="polite" so the stage change is announced rather than
silently repainted, and holds the spinner still under prefers-reduced-motion
rather than dropping the only sign that work is happening.
2026-07-25 20:10:44 -05:00
KeelrLeek 21ef73770c cover logo: keep the source's anti-aliasing, then trace it lightly
The wordmark segmentation mapped alpha with smoothstep(46, 120) over distance
from the background colour. That ceiling is fixed, but the ink distance is not:
white text on a black band sits at ~438, so every edge pixel brighter than about
27% grey saturated to fully opaque. A hard threshold wearing a smoothstep's
clothing. Measured on Camping in Alaska's "Needlemouth" cover, the extracted
mask's edges transitioned over 0.10px against the source's 0.88px -- nine times
harder than the artwork it came from.

The ramp's top now tracks the measured ink distance (95th percentile of
distance-from-background, so it reads the glyph body rather than the brightest
speck), with the old constant kept as a floor so low-contrast marks are
untouched. Retained anti-aliasing on that cover goes from 10.4% of inked pixels
to 22.2%, and the glyphs thin back toward the source instead of being dilated by
the saturation.

That unlocks the trace side, which is the visible half. The blur there was never
a goal -- it exists to synthesise anti-aliasing a mask lacks, and it melts serif
terminals into blobs. With real gradients in the alpha, the cover-logo branch
drops from ~0.93px of blur to a 0.5px floor and from eps 0.5 to 0.2, at a 2400px
raster instead of 1400. Lowering eps ALONE was tried and rejected: against the
old near-binary mask it just captured the pixel staircase in finer detail.

Camping in Alaska's traced path goes 900 -> ~2500 vertices with no staircasing,
and the serif terminals keep their shape. Cost is 35ms -> 65ms of main-thread
work, once per cover-derived logo at page load, not per frame.
2026-07-25 18:12:46 -05:00
KeelrLeek 52b1b8e622 artist page: keep the album reveal's target reachable
Opening an album juddered: the page ran at the clicked row, then stopped
dead just short of it.

revealIntoView lifts the row to the banner line, which needs a pane-height of
content beneath that row. Until the previous commit the pane's ~2000px of dead
scroll always supplied it. With the range ending at the content, a row in the
last screenful no longer has it, so the reveal eased toward a scrollTop the
range could not hold and slammed into the clamp — measured on a 29-album artist:
target 1846 against a max of 1594, 272px in a single frame, then 39 frames
pinned, and the row parked 598px below the line instead of on it. Not a race
with the expander either; it finishes growing at ~500ms and the target is still
252px out of reach.

Reserve the row's own pane-height, the same way the grid already reserves one
for the tab bar so a tab switch cannot clamp the scroll — extended to the open
expander's row and released when it closes.

- On the grid's min-height, because that is what moves this page: .scrollbody's
  height does not answer to padding at all (border-box against a fixed height —
  0px, 200px and 600px of padding-bottom all measured the same 1787px box).
- Reserved, not predicted. The expander's height is not the page's growth: the
  slack in this very reserve swallowed 435 of a 486px panel, so a 486px expander
  grew the page by 66.
- Stood up synchronously by the reveal as well as by measureCols, which only
  recomputes off a ResizeObserver — that fires after the 420ms animation has
  already parked against the old clamp.
- Released on the expander's destroy. measureCols cannot notice the panel
  leaving: the raised min-height is exactly what stops the grid's box from
  changing, so the observer sees nothing and the reserve would stick for the
  rest of the visit, leaving a screenful of empty page under the grid.
- Re-measured across the settle after an open. What moves the target row is the
  card art this very click asked for; those images land over the following few
  hundred ms and rows above the row grow as they do (measured: the row sat 488px
  into the grid when the reveal read it, 555px once settled). Only the first
  open of a page pays this.
- The reveal clamps its target regardless and idles on it for up to a second, so
  a corrected reserve still completes the travel instead of ending short.

Verified on a 29-album artist and an 11-album one: every open lands the row at
exactly the 280px line, first open of a fresh page included, with no direction
reversals and no frames against the clamp. Frame timing improves on both —
cold 13 frames over 16ms (worst 79.6) to 2 (worst 60.6, the album fetch and
decode, which predates this), warm 2 (worst 18.4) to 0 (worst 9). The pane's
scroll range still ends exactly at the content with no expander open, and
returns to it on close.
2026-07-24 18:25:58 -05:00
KeelrLeek e58410d9f8 artist page: always open at the top
.content is the app's one scroll pane and it outlives the view swap, so its
scrollTop was inherited by whatever loaded next: leave one artist 900px down
their discography, open another, and you arrived 900px down the new one —
mid-grid, hero already squeezed into the bar, with no hint the page had a top.

Reset it on catalog.artistOpenSeq, a counter bumped once per openCatalogArtist
call, rather than on the artist's id. The id cannot distinguish a re-open of the
artist already on screen — clicking their name from Now Playing while parked
deep in their own page, where nothing moved and the click read as dead — from no
navigation at all. Nor can this key on the page object: the cold path replaces
it stage by stage under the same id, which would haul a reader back to the top
mid-read. The counter bumps once per open and never per stage.

The bump sits after openCatalogArtist's invalid-id bail-out, so a request that
never opens anything cannot scroll the page the reader is still on.

Verified live: three artist-to-artist hops parked at 1222 each land at 0 and
stay 0 through every cold stage; re-opening the artist already on screen from
1070 lands at 0; mounting from another view with the pane genuinely parked at
1500 mounts at 0; and a reader parked at 600 on the artist they are already on
holds at 600 through six seconds of stage churn.
2026-07-24 17:51:41 -05:00
KeelrLeek 0878659f6a artist page: end the pane's scroll at the content (clip the 300vh sheet out of the scroll range)
The pane could scroll ~850px (1971px at a tall window) past the last content
pixel: at max scroll ZERO of 11 release rows were on screen, the whole viewport
empty page.

The sheet is 300vh of absolutely positioned decoration, and an abs-positioned
descendant extends its scroller's scrollable overflow. .content's scrollHeight
tracked the sheet's box — pin flow origin + the sheet's live translate + 300vh —
instead of the page. Because that translate is scroll-driven, the range also
breathed with scroll position and depended on scroll history: the same scrollTop
measured 4167, 4329 and 4368 depending on the route taken. Hiding the sheet gave
exactly 1797, the page plus the pane's padding-top.

Wrap the sheet in a .sheetclip box with overflow: clip, sized to the pane's
client box, so the sheet paints exactly the region the pane can show and
contributes nothing to the scroll range.

- The clip box is ABSOLUTE and the pin stays height: 0. Sizing the pin itself
  and cancelling with a negative margin-bottom leaves +24px behind — the margin
  collapses against the neighbouring height-0 pins and walks the hero zone from
  24 to 48. Out of flow there is no margin to collapse, and the sticky
  constraint is untouched.
- overflow-clip-margin is not an option for the sheet's 28px bleed: it
  re-expands the overflow region right back to 2440. The bleed moves onto the
  clip box instead.
- The sheet's offsets are now clip-box relative. --band-from-pin is
  pin-relative and the box sits --pin-origin above the pin, so the two cancel:
  --band-from-pin + --pin-origin is --band-top.
- --frame-h (measure()'s scroller.clientHeight) becomes unconditional. It fed
  the scroll-driven-only .fade-frame; .sheetclip needs it on both branches.
  Setting it on the fallback path is inert — .fade-frame's rule lives inside
  the @supports block and never matches there.

Verified live against the pre-change rendering at identical scrollTop, each
comparison bracketed by two baseline captures so the hero's 20s rotation could
not be mistaken for a difference: 0 differing pixels at four of five stops (the
fifth is one chip's hover ring, 83px), hero zone and scrollbody unmoved at every
stop. Scroll range is now content-exact with the clip box flush to the pane at
1280x800, 1600x1100, 1100x620 and 1920x1400, and across artists with 4, 10, 11
and 22 rows. At max scroll all 11 rows are on screen instead of none.
2026-07-24 17:51:24 -05:00
KeelrLeek 2eb1cf57e3 artist page: compositor scroll pipeline + final hero design (crossfade twins, mirror seam, bloom retired)
Scroll-driven animation conversion
- apply() writes nothing per frame on the supported path; the hero
  squeeze, fade line and header curves all ride scroll-driven animations
  on the compositor.
- Section boundaries flip via IntersectionObserver sentinels; the scroll
  listener is gone from the hot path.
- The scrollbar thumb rides its own scroll-driven animation.
- @supports-not fallback keeps the previous rAF --p path verbatim, so
  unsupported engines render identically.
- Curves verified exact to 1e-6 against the old path, 0.000px continuity
  across boundary flips, correct retarget on resize.

Perf fixes
- Forced-layout reads pulled out of both scroll handlers.
- Resize settle-reflow: a second reflow 200ms after resize quiesces, and
  the pane is observed too. This was the root cause of the fullscreen
  fade-wedge and of the long-standing intermittent where rows stayed
  hidden after a resize.

Glass
- backdrop-filter removed from chips and preview surfaces.
- Bar glass blur 20 -> 12.
- Choose-hero pill removed from the UI; pickArtistHero/clearArtistHero
  backend is untouched and the control can be re-homed later.

Hero design (final)
- Crossfade twins are permanent. Twin blur is baked via bakeBlur()
  (clamp-to-edge pad -> blur -> crop) so the frames stay pixel-aligned
  with no edge fringe and no geometry scaling.
- Bloom strip retired everywhere; HeroGpuCanvas's internal bloom canvases
  are hidden by one scoped rule and the component is unchanged.
- Seam is a pure mirror: the artwork's top strip flipped inside the glass
  band, clipped at the bar's top line, riding the squeeze with the hero
  and dissolving in lockstep on rotation.
- GPU twin overlay crossfades on rotation instead of snapping — the
  incoming baked frame fades in on hero-banner-in with a 2-frame cap.
- ~930 lines of experimental variants and the perf-flag system deleted;
  the surviving path is a pixel no-op against the approved configuration.

Debug overlay
- PERF/SEAM rows dropped; the overlay keeps stats plus an FPS/drops/worst
  counter (Ctrl+Shift+D).
2026-07-24 17:02:21 -05:00
KeelrLeek 790a9c60c0 artist page: compositor-locked fade line (sticky host + clipped frame + scroll-driven counter-translate) 2026-07-24 09:15:56 -05:00
KeelrLeek 67d98dfe79 fix: artist page fade line no longer lags fast scroll (compositor-driven)
The content fade mask on .scrollbody lives in the scrolling element's local
box, so its cut stop (--flow-cut) must ride scroll to stay at a fixed screen
line. Driving that from heroScroll's rAF lagged the native compositor scroll
by a frame on fast flicks, so the cut slid up and rows briefly showed above
where they should clip (user-reported).

Move --flow-cut to a CSS scroll-driven animation (@property + @keyframes +
animation-timeline: scroll(nearest block), @supports-gated), so it's a pure
function of scroll position evaluated on the compositor and can't lag. The
keyframe delta and animation-range share one oversized constant (100000px)
so the value is base + scrollTop for ANY scroll distance — necessary because
the artist page changes its own content height as it scrolls, making the
total range non-constant. apply() keeps the per-frame path only as the
unsupported-engine fallback.

Verified live: screen cut constant at 217px across scrollTop 300..2500,
--flow-cut == base+scrollTop exactly.
2026-07-23 17:46:04 -05:00
KeelrLeek d051f0db28 Phase 2C WIP: PreviewOverlay modal + root integration (reel centering bug)
Working:
- PreviewOverlay.svelte mounted at app root; opens over a fixture manifest
  (window.__previewFixture(n) in dev), inert background, player bar hidden,
  focus trap, keyboard nav, F3 rail, T3 swaps, R1b capsule, accent extraction.
- previewSession.svelte.js: store + service wiring (production + fixture).
- Root integration in App.svelte; revision-gateway bumps + notePlaybackGeneration
  threaded through state.svelte.js player entry points; api.win guarded so plain
  vite browser mounts.
- Launcher on the artist page builds a real artist manifest and opens the modal
  with a play-full starter.
- 70 JS tests still green.

Two bugs found and fixed during bring-up:
- effect_update_depth_exceeded: bleed-crossfade and bump effects read AND wrote
  the same $state; wrapped writes in untrack(). This loop was breaking Svelte's
  scheduler globally (even ui.* stopped reacting).
- accentFromImage now sets crossOrigin for remote covers.

KNOWN BROKEN (handoff): reel vertical centering. The active card stays centred
at index 0 but drifts by one stride per step. Root cause isolated — see
TODO-Feature-Docs/scrollable-track-previews/HANDOFF-PHASE2C.md.
2026-07-22 19:26:41 -05:00
KeelrLeek a667b4b28e Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
2026-07-22 17:57:00 -05:00