Commit Graph
52 Commits
Author SHA1 Message Date
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 fa3cbc64ca loudness: a prefix may lower a track's volume but never raise it
A track that opens on a lone guitar and swells into a full chorus played that
chorus far too loud. The head of an unmeasured stream was being treated as a
measurement of the whole song: at ten decoded seconds the analyzer's running
integrated value became the gain for the entire play, and a quiet opening
under-reads, so the estimator asked to turn the track UP and the song then
arrived at its real level already amplified. `PRELIM_MAX_BOOST_DB = 3.0`
bounded that mistake without preventing it, and the three decibels land
exactly on the loudest part of the song.

Measured on the 69 complete tracks in the stream cache, with the app's own
meter and its own gain policy, comparing what the 10 s head asks for against
what the finished track asks for:

    prefix error in applied gain (+ = plays too loud)
      1s   min -4.6  p50 +7.7  p95 +15.8  max +17.0   too loud: 61/69
      3s   min -0.9  p50 +5.2  p95 +15.7  max +17.0   too loud: 57/69
     10s   min -1.5  p50 +2.9  p95 +14.6  max +17.0   too loud: 57/69

The head is wrong in one direction and wrong by a lot. So it may only ever
cut: `PRELIM_MAX_BOOST_DB` is 0.0, and a boost has to be earned by a
measurement that reached the end of the media.

**Cuts-only rather than the flat unity the design called for.** The design
doc's stated first-release policy is that an unknown partial stream stays at
0 dB, with cuts-only left as a later experiment. Measured against this
corpus, unity is the worse of the two in the direction being complained
about — first-play loudness relative to the −14 LUFS target:

    unity (doc's policy)     p50 +5.6  p95 +8.5  LOUDEST +10.6  >3dB over: 57/69
    cuts-only                p50 +2.7  p95 +6.7  LOUDEST  +8.0  >3dB over: 31/69
    old policy, capped +3    p50 +2.7  p95 +9.5  LOUDEST +11.0  >3dB over: 32/69
    verified (later plays)   p50 +0.0  p95 +0.0  LOUDEST  +0.0  >3dB over:  0/69

Unity would hand back the cut that loud-opening tracks already get today:
"Spread Eagle Cross the Block" reads −3.4 LUFS over its head and −3.4 over
the whole track, and is cut 10.6 dB — under unity its first play would be
10.6 dB louder than it is now, which is the reported bug wearing a different
hat. The tail risk that argues for unity is a loud opening over a quiet body,
where the head over-cuts; on this corpus that case tops out at 1.5 dB (My
Bloody Valentine, "Only Shallow": head −11.3, track −12.8) and the existing
−15 dB floor bounds it. The corpus is one library and contains no fortissimo
opening over a pianissimo body, so it bounds the error it can see, not the
error that exists. Revisit if a cut ever sounds wrong.

**Only a whole programme may be cached.** `analyze_loudness` returned the
same `Option<(f64, f32)>` whether it reached the end of the media or the
bytes simply stopped, and `on_track_started` stored it either way — with a
comment saying it was valid "whatever happened to this one". It is not:
`GrowingFile::read` maps an aborted transfer to `Ok(0)`, a graceful EOF, so
skipping a song mid-download recorded whatever its intro measured, filed
under the full declared remote size, and every later play boosted the whole
track off it by up to `MAX_GAIN_DB` — the same bug as above, but permanent
and three times larger. It now returns `Analysis::Complete | Incomplete`,
where completeness is decided by asking the transfer how it ended
(`StreamProgress::terminal`) rather than by asking a reader that cannot tell,
and only `Complete` reaches the cache, the preload boundary gain, or a
preview clip's gain.

That path was reachable but had not fired. Re-measuring every cached track
against its stored value found no disagreement, and of the ten interrupted
transfers still sitting in the stream cache, none had left a loudness entry
at all — the analyzer normally wins its race with the download and either
finishes properly or is cancelled before the abort reaches it. So the
existing cache is not quarantined and not wiped: of 141 entries only 6 ask
for any boost above 0.5 dB, four of those are present on disk and verified
correct by a fresh scan, and the other two ask +1.4 and +1.0 dB. Discarding
141 good measurements to protect against a hazard that measurement says is
not there would cost more than it saves, and any record that is wrong is
overwritten by the next complete play.

**The audible start is now bounded from the play request.** Both existing
windows are measured from the first measurement, and a track opening below
the −70 LUFS gate never produces one, so silence or a long fade-in held
playback until the analyzer found audio loud enough to measure — on a slow
stream, a wait with no upper bound. `PRELIM_HARD_DEADLINE` (2.5 s) releases
the start regardless, at the best cut the evidence so far justifies. This
costs nothing: the player blocks on the growing file anyway, so releasing
early hands it the start rather than making it wait. Verified against a
223 MB FLAC whose first bytes had not arrived at 2.2 s.

**Stop now cancels the measurement window.** Found while verifying the
deadline: `player_control("stop")` never touched the loudness generation, so
a track stopped during its window could be handed its deferred start a moment
later and begin playing after the user had stopped it. Pre-existing, but the
deadline makes it reliably reachable, so `on_playback_stopped` orphans the
analyzer and the held start.

Not done, and deliberately: true peak and the −1.0 dBTP ceiling (the meter
measures sample peak, so `PEAK_CEILING` is still 0.98 sample), the versioned
measurement cache with real asset identity, ReplayGain/R128 metadata, the
output limiter, track-bound gapless gain, and queue-ahead analysis. Those are
later phases and none of them is needed to stop the over-boost.

Verified live, driving the running app:

    quiet intro, first play    head −27.4 LUFS → 0.0 dB     (was +3.0 dB)
    quiet intro, second play   track −6.0 LUFS → −8.01 dB   from the cache
    loud intro, first play     head −3.4 LUFS  → −10.64 dB  cut still applied
    Soulseek stream, cold      head −18.8 LUFS → 0.0 dB, track measured −9.0
                               and cached for the next play without touching
                               the gain in force
    stop mid-window            no start behind the stop, no cache entry written

The debug overlay now names what the gain was decided from — "whole-track
measurement (cache)", "prefix (cut only)", "unity (nothing measured yet)" —
because the difference is invisible in the numbers alone, which is how this
went unnoticed. Settings no longer claims "boosts never clip", which nothing
in the output path enforces; it describes the actual guarantee instead. The
module header's claims that the gain glides mid-track and that previews are
not normalized were both false and are gone.

150 Rust tests and 85 JS tests pass.
2026-07-28 13:00:09 -05:00
KeelrLeek 4d7fb7f72e attribution: a reissue is not the record it was built from
Radiohead's "No Surprises", "Karma Police" and "Let Down" attributed to
"OK Computer OKNOTOK 1997 2017" rather than to OK Computer. Deezer carries both
and dates BOTH 1997-05-21, so the EP test, the edition-keyword test and the date
all tied, and the rule that breaks that tie — an original album over a truncated
sampler of it, by track count — then picked the 23-track reissue over the
12-track record. `is_edition_title` could not catch it because "OKNOTOK 1997
2017" is a bespoke name carrying none of the edition keywords.

So a candidate whose title is another candidate's title plus something loses to
the one it was built from, ranked above the track-count rule and below the date
so the measured Weezer ordering is untouched.

Safe because it only ever fires when BOTH records contain the song. A sequel
does not share its predecessor's tracklist, so "Greatest Hits II" is never in
the running against "Greatest Hits" for the same track — and the numeral guard
in `expands_on` refuses the relationship anyway, along with volume and part
markers. Pinned both ways.

Also: the recordings browse is filtered to `type=album&status=official`.
Unfiltered, Radiohead has 1141 releases and the two-page window saw 18% of
them, mostly singles and bootleg pressings; filtered it is 274, so the same two
pages cover 73% of the releases that could actually own a popular song. Same
cost, better sample.

The tail is accepted rather than paged around. Queen still has 1119 official
album pressings, but this map exists to place songs Deezer does NOT carry, and
an artist with a thousand pressings is one Deezer carries exhaustively —
measured, the relabel pass reports 0 for both Queen and Radiohead. Reaching
them would cost eleven requests behind the 1 req/s gate to change nothing.

Verified live: Radiohead's three rows move to OK Computer; Queen, My Bloody
Valentine and Death Grips are unchanged. 77 Rust tests and 85 JS tests pass.
2026-07-28 12:08:42 -05:00
KeelrLeek 4cb2c4c1b3 previews: fall back to Apple for the clips Deezer has no pressing of
Popular is Last.fm's list now, so it routinely holds songs Deezer does not
sell, and a row built from MusicBrainz has no 30-second clip at all. Apple
carries some of them.

The fill is BY ALBUM rather than by track, and that is the whole design. iTunes
Search is documented at roughly 20 calls a minute; asking per track would spend
nineteen of them on one artist, while a single collection lookup returns the
whole tracklist with a preview on nearly every entry. The album is found in the
Apple catalogue the page has ALREADY fetched for release ownership, so the
search half costs nothing either. A page with no gaps makes no request at all,
and a page with gaps makes at most four.

Honest measurement, because my first one was wrong. I originally measured this
at 63% -> 87% coverage with Neutral Milk Hotel going 1/20 -> 19/20 and Slowdive
13/20 -> 20/20. That probe reimplemented `track_title_identity` and did it
worse, so it failed to match Deezer rows the app matches perfectly well. Read
back off the running app by preview HOSTNAME, the truth is that Deezer already
covers almost everything:

    Slowdive             20 deezer   0 apple   0 none
    Neutral Milk Hotel   19 deezer   0 apple   1 none
    Queen                20 deezer   0 apple   0 none
    Pond                 20 deezer   0 apple   0 none
    Crazy P              18 deezer   0 apple   2 none
    Camping In Alaska    19 deezer   1 apple   0 none
    My Bloody Valentine   5 deezer   0 apple  15 none

So this earns one row across seven artists, not a third of the catalogue. It is
kept because it is close to free rather than because it is transformative, and
because the gap it addresses widens as Deezer's catalogue thins.

It cannot help the case that prompted it. My Bloody Valentine's fifteen empty
rows are Loveless and Isn't Anything, and Loveless is absent from the iTunes
catalogue too — verified by both album search and track search. Nothing with a
public preview API carries it. The only source that could is Soulseek, which is
what the app streams from anyway, and that is a different piece of work: a
search and a partial transfer rather than fetching a CDN clip.

No other provider is worth adding. Probed against the residual gap, Audius, the
Internet Archive and Bandcamp returned nothing for any of it. Spotify needs
OAuth client credentials and has returned a null preview for much of its
catalogue since 2024, SoundCloud closed app registration years ago, Jamendo is
Creative-Commons only, and Genius, Discogs and MusicBrainz have no audio at all.

The previews button already reported partial availability off `track.preview`,
so it needed no change and still reads honestly — "5 of 20 available" on My
Bloody Valentine.
2026-07-28 11:57:26 -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 ddf2840b78 musicbrainz: tell "no answer" apart from "nothing to say", and pay the plays forward
Two failures that looked like nothing at all.

**A failed MusicBrainz page was indistinguishable from an empty one.**
`musicbrainz_first_release_dates` `break`s out of its paging loop when a
request fails and returns the map it has, so one transient blip produced an
empty map — exactly what an artist with no release groups produces. Everything
downstream then no-ops in silence: the album dates keep Deezer's reissue
values, every popular row keeps the pressing Deezer licensed, and the traces
read "0/47 corrected" and "0/10 re-attributed", which is also what a page looks
like when it was already perfect.

That is not hypothetical. It cost an hour last session chasing a regression in
Eagles' dates that turned out to be this machine having throttled its own IP
with probes, and it produced a false bug report that outlived it: My Bloody
Valentine was written up as "the supplement is failing to inject Loveless" when
the supplement was fine and MusicBrainz had simply not replied that once —
verified today, both Loveless and Isn't Anything inject correctly and always
did.

The third state is cheap to know, because a `None` here can only ever be a
failure: the mbid was resolved FROM MusicBrainz moments earlier, so the artist
provably exists there, and an artist it holds nothing for answers with an empty
`release-groups` ARRAY rather than with nothing. So the first page is now
retried twice behind a widening backoff — the failure this guards against is
overwhelmingly transient, and the pass is deferred, so nothing on screen is
waiting on it — and only then is the verdict recorded and carried to the
callers. Fault-injected against an unroutable host, the traces now read:

    musicbrainz.first_release_dates  4430ms  UNAVAILABLE
    page.original_dates    0/47 corrected — MUSICBRAINZ UNAVAILABLE, dates are unverified
    page.attribution       0/10 re-attributed — MUSICBRAINZ UNAVAILABLE, rows keep Deezer's attribution

A later page failing stays partial rather than unavailable: the dates already
collected are real, and the discography is date-sorted so the tail matters
least.

**Supplemented albums had no play counts, for a stale reason.** The comment
said a MusicBrainz or Apple album "simply carries no play counts" because the
scrobble pass resolved its anchor from a DEEZER artist id, which a record
Deezer never carried does not have. But the scrobble table is keyed on an
artist MBID, and a MusicBrainz-supplemented album was fetched BY that mbid — it
was in hand at registration and simply not kept. So the records the supplement
exists to rescue opened with no Plays column at all: Loveless, Isn't Anything,
Exmilitary.

Apple needs the same thing by the other route. It has no MusicBrainz id of its
own, but it does know which Deezer artist it was supplementing, so keeping that
id lets it reach the table the ordinary way. Weezer's "Hurley (Deluxe Edition)"
is an Apple row and had no Plays column while the plain Deezer edition beside
it had one.

Measured live: Loveless 79M album plays / 1.8M listeners with Only Shallow at
10,801,602; Hurley (Deluxe Edition) 1.9M / 93K with Memories at 1,219,036;
Exmilitary showing Guillotine at 3,363,409. 75 Rust tests and 85 JS tests pass.
2026-07-28 11:02:29 -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 c0fa892eee catalog: what the artist page can know once it asks somebody other than Deezer
One supplemental fetch behind the artist page -- the linked MBID, then Last.fm
and MusicBrainz in parallel -- and four separate things Deezer had been getting
wrong stop being wrong. They ship together because they are the same fetch:
`ArtistSupplements` carries scrobble counts and first-release dates out of one
`tokio::join!`, `ReleaseFacts` serves dates, disambiguation and attribution
alike, and `TrackStats` is read by both the Popular ordering and the album
expander. Splitting them would mean inventing intermediate states that never
ran.

**Popular is ordered by scrobbles, not by recency.** Deezer's `/top` is
recency-weighted, so a week-old single outranks the songs an artist is actually
known for -- Taylor Swift's Deezer top three are all from the current release
where Last.fm's are Blank Space, Cruel Summer, Shake It Off. Measured across 18
artists, Last.fm's canonical top ten is only 43% present in Deezer's top 10 but
77% by its top 50, and the curve flattens after that (79% at 100), so the pool
is 50 and the ten that survive are reordered rather than replaced. Nothing is
ever admitted that Deezer did not supply: Last.fm ranks rows, it does not add
them, so a track with no stream stays off the page.

**Albums are dated by their first release, not their latest pressing.** Deezer
dates a row from whatever pressing it licensed, so remasters scatter a
discography across decades. MusicBrainz release groups supply the real date over
three 100-item pages, which covers a very prolific catalogue (Queen has ~100
album groups alone) without walking a discography forever behind the mandatory
1 req/s gate.

**MusicBrainz separates seven self-titled Weezer albums by `disambiguation`,
and ignoring that field collapsed all of them.** Every one is titled plain
"Weezer"; only the disambiguator ("Blue Album" … "Gold Album") tells them apart.
Keyed on title alone, all seven folded onto one entry and kept the earliest
date, which is how the 2026 Gold Album came out dated 1994-05-10 and filed as an
edition of the Red Album. Groups are now indexed under their disambiguated name
as well, which is an exact match rather than a fuzzy one because Deezer prints
the same disambiguator in its own parenthetical. Three things fall out of that:
a key that collapses groups with DIFFERENT dates is marked `ambiguous` and dates
nothing (equal dates are a reissue duplicate and still fine); "Weezer (Deluxe
Edition / Red Album)" keeps the colour instead of losing the whole bracket; and
a pressing takes its work's date through the tracklist-verified `edition_group`,
which knows what titles cannot. Verified: Gold → 2026-08-21 in Upcoming and
standalone, Red → 2008 with its three editions, Green → 2001-05-07, every deluxe
sitting with its own record.

**A popular track is shown on the album it came from, not the one Deezer
licensed.** This is the big one. Bohemian Rhapsody came back attributed to the
2018 film soundtrack, wearing that poster as its cover, and 10 of Queen's top 10
were wrong the same way. The rule takes the earliest album-typed release that
actually contains the song, and swaps `album`, `album_id`, `cover`, `id`,
`preview` and `duration_secs` together with a new `origin_track_id`. Live:
Queen 0/10 → 10/10, Fleetwood Mac 10/10, Radiohead 10/10, Green Day 9/10 -- the
documented Kerplunk! re-recording being the only miss. It costs 0 ms, because
the album details it reads are already fetched for the discography.

Three things that look like details and are not:

- The MusicBrainz secondary-type exclusion is load-bearing: 79/80 with it,
  63/80 without. Compilations are dated EARLIER than the works they compile, so
  "earliest wins" on its own actively SELECTS hits packages.
- "Already correct" has to be an OUTPUT test, not a candidate filter. Removing
  the track's current album from the ranking means a correct row can only be
  handed to the runner-up, which moved Dreams off plain Rumours onto Rumours
  (Super Deluxe). 57 of the 80 measured tracks are in that already-correct
  state, so this is the common case, not the edge one.
- A lead single always predates its own album, and EPs are admitted as
  candidates, so Creep moved off Pablo Honey onto a "Creep EP" five months
  earlier. Album now outranks EP above the date tiebreak -- it cannot affect a
  row whose winner is already an album, and an EP still wins when no album
  carries the song. Radiohead 9/10 → 10/10.

The details cap becomes two-sided for this: take 60, then top up to 80 with the
OLDEST album-typed rows. A bare 60 silently dropped Bob Dylan's canon --
Highway 61, Freewheelin', Blonde on Blonde -- which is exactly the material the
rule exists to find.

Known residual, seen in the wild: Blowin' in the Wind lands on The Witmark
Demos. MusicBrainz DOES type it Compilation, but calls it "The Bootleg Series,
Vol. 9: The Witmark Demos: 1962–1964" where Deezer calls it "The Witmark Demos:
1962-1964 (The Bootleg Series Vol. 9)". Different word order, different
title-join key, so the exclusion cannot see it. Not worth a title heuristic.

**Per-track play counts, for zero extra requests.** `artist.getTopTracks` was
already being fetched for the ordering above and carries `playcount` beside
`listeners`. The depth goes 100 → 1000, which is the same one request: at 100 it
reached 2 of Van Weezer's 10 tracks, at 1000 it covered 56 of 57 album tracks
measured across Johnny Cash, the Grateful Dead, Miles Davis, Dylan and Queen.
That works because the long tail is bootlegs and mislabelled scrobbles -- a real
album track is by definition one of the popular ones. Cost is body size, 77 KB →
~775 KB, disk cached for a day.

Album totals need a guard the track counts do not. Last.fm's album `playcount`
counts scrobbles tagged to that release page while track counts span every
release, so Weezer's Blue deluxe reported 89k album plays above a first track
showing 7.2M. A provider total that cannot cover its own biggest track is
rejected as an edition artifact and the tracklist is summed instead.
2026-07-28 09:42:22 -05:00
KeelrLeek 6d6968e9b1 artist art: a provider's API being up is no promise its CDN is
On 2026-07-28 `webservice.fanart.tv` answered every request normally while
`assets.fanart.tv` refused TCP on both :443 and :80. The two are different
machines, and the hero chain only ever asked the first one anything.

Every gate in that chain tests whether a provider RETURNED URLS, never whether
those URLs load. So Fanart.tv handed back its usual eight banners and four
logos, `banners.is_empty()` was false, and the two fallbacks that exist for
exactly this situation -- TheAudioDB, then Bing -- were both skipped. The page
then fell back to a blown-up square portrait. The tell that this was a provider
outage rather than a regression in the hero code is that it was artist-shaped:
every artist Fanart.tv covers lost its hero, and the ones it does not cover
(Tame Impala) were untouched. Worth recording that browsing fanart.tv proves
nothing here -- that is a third host, on Cloudflare, and it was up throughout.

The check is a one-byte ranged GET, so it is proof the host serves image bytes
without pulling a 4K background to find out. Any HTTP answer counts as alive: a
404 on one image is not an outage, so only a transport failure or a 5xx marks a
host dead. Cached per host for 5 minutes, which is long enough that a dead CDN
costs one probe rather than one per artist, and short enough that the hero comes
back within minutes of the host recovering -- no restart, no cache clearing. The
probe races the page, hence the 3s ceiling.

It has to be applied in three places, and the third is the one that is easy to
miss:

- Discovery, after Fanart and again after TheAudioDB, so an unreachable CDN
  reads downstream as "this provider had nothing" -- precisely the state the
  fallback chain already knows how to handle.
- The persisted manifest, because a manifest outlives the CDN behind it. A
  stored answer short-circuits discovery entirely, so replaying one produces a
  hero that never loads AND no path to the fallback that would have worked.
- The "keep previous banners as a floor" clause. Restoring unreachable banners
  would undo the whole point of falling through: discovery correctly dropped a
  dead CDN, and the floor would put it straight back.

Note for the next time this looks like a renderer bug: a 1x1 hero canvas is a
SYMPTOM, not the cause. It means no image ever decoded, so the GPU renderer
never sized itself.
2026-07-28 09:39:14 -05:00
KeelrLeek 0cc01328a4 cover logo: fit the traced outline to lines and curves, not a decimated polyline
traceToSvg emitted polylines. Marching squares produced a sub-pixel contour and
RDP threw points away at eps=0.2 until what was left could be joined with "L",
so a curve arrived as facets and a straight edge arrived as a ripple. The ripple
is the one that reads at hero size, and the reason is a measurement rather than a
matter of taste: on these masks a mathematically straight edge still wanders
through the alpha field -- print grain, JPEG ringing, and the shadow un-blend
being a two-colour model of a soft gradient. Measured over the acceptance set
that wander peaks around 0.15-0.3px across a 40px run on a clean source, so
eps=0.2 sits BELOW the noise floor and RDP was faithfully reproducing the grain.
Tightening it makes that worse; loosening it far enough to clear the noise starts
cutting real curvature.

Fitting cubics under a looser tolerance is the obvious answer and it does not
work. Schneider pins each segment's endpoints to actual sample points, so every
join inherits that one point's share of the noise; measured, the straight edges
came out no better than RDP left them (ripple 0.56 -> 0.64px). Raising the
tolerance does not help, because the endpoints are pinned regardless.

What removes the noise is fitting a LINE to the whole run and throwing the
samples away. A total-least-squares line over a few hundred points has an error
of noise/sqrt(n) -- hundredths of a pixel, not tenths. Vertices then become the
INTERSECTIONS of neighbouring primitives rather than sample points, which has a
second payoff: the trace blur rounds every real corner into a small arc, and
intersecting the two adjacent lines puts the sharp corner back. So the pass is
corners, cut at them, maximal straight runs, cubics for the gaps, then resolve
the joints. Prefix moments make a line fit over any index range O(1), which is
what keeps growing a run near-linear rather than quadratic on exactly the long
straight edges it exists for.

Three things a straightness test cannot decide on its own, each found by looking
at where the lines actually landed rather than at an aggregate.

- A residual test admits CHORDS OF A BOWL. On a circle of radius R a chord of arc
  length L departs from its own best-fit line by only about 0.3 L^2/(8R), so at
  any tolerance loose enough to absorb grain a big enough bowl accepts straight
  pieces -- which is how the counters of Hot Toddy's O's came out as polygons,
  73% of that outline drawn straight. The evidence lies OUTSIDE the run: fit a
  circle to a window extended past both ends, and if that window is a clean arc
  whose sagitta across the run exceeds arcSagitta, the run is a chord. On a loop
  with no corners the window has to WRAP, because the seam goes at the tightest
  bend and clipping there would exempt the one place that most needs the test.

- The ends OVERRUN into the bend. RMS is an average, so a run absorbs the first
  few px of a curve and still passes; the line then chords the start of the bend
  and the cubic is left too little room, which is the kink at a stem-to-shoulder
  junction. Each end is trimmed back to where a window-fitted direction still
  agrees with the run's line. It has to be a WINDOW fit -- a point tangent over a
  few px of grainy edge carries several degrees of noise by itself and trims at
  random, which put the ripple back up from 0.19 to 0.48px -- and the threshold
  has to rise with the run's own measured noise, or it means something different
  on a clean print than on a rough one. An end sitting on a detected corner is
  not trimmed: the contour runs straight into a corner by definition, and that
  direction is what the corner is reconstructed from.

- Extrapolating to an intersection is unbounded. As a wedge closes the legitimate
  apex offset grows without limit, and so does the error in it, because two
  nearly anti-parallel lines cross at a point a fraction of a degree can move a
  long way. The inside of Mildlife's M reconstructed 11px past its real apex and
  cut a white spike into the letterform. The shift is now bounded by what the
  rounding can explain -- r*(1/sin(theta/2) - 1) for a wedge of interior angle
  theta -- and CLAMPED rather than discarded, since a corner pulled back to the
  limit is still sharper than the blurred contour whereas falling back to the
  contour point throws the reconstruction away. Corner sharpness measures
  identical anywhere from a 2px to a 12px ceiling, so the ceiling costs nothing
  and only removes the failure.

Acceptance over the eight-artist set, against the polyline it replaces: ripple on
edges that are straight beyond argument 0.228 -> 0.015px, with 637 -> 8 interior
vertices spent on them; curvature reversals 6.7 -> 1.5 per 100px; corner turn-arc
8.03 -> 5.48px; 1557 -> 276 segments and 21.2 -> 9.5KB of path. Hot Toddy, the
mark whose terminals must not soften, goes 3.32 -> 0.25px with 0 of 34 corners
softened. Men I Trust improves least (9.7 -> 6.1 reversals) because its print
genuinely is crenellated and the path tracks it.

Cost is 18-33ms in the app's own engine, against contours() at 90-140ms. The old
polyline remains behind traceToSvg's `fit: false` for A/B.
2026-07-27 06:37:08 -05:00
KeelrLeek 4c0c4a1ea5 cover logo: supersample the winner, and scale to the target not past it
Five changes to the mask the tracer receives, plus the diagnostic needed to
tell them apart.

The alpha ramp's bottom now scales with the measured ink distance instead of
sitting at a fixed 46. On a faint mark that constant ate the outer half of
every edge gradient -- Wand's "Machine Man / M.E." inks at distance 97, so the
pixel carrying ~20% coverage was forced fully transparent and the boundary
quantised to whole source pixels. The finished alpha is then rescaled so the
glyph body reaches opacity, bounded at 4x; narrowing the ramp onto the ink was
tried instead and let paper grain bite the edges.

A BLIND OCR read (zero lines, not "no match") retries once at 512px. Windows
OCR is scale-sensitive on large display type and reads that cover at 512 and
at no other size; gating on blindness rather than on no-match kept a 21-artist
regeneration at 1m47s where the looser gate took ~6min.

The winning cover is re-cut with the crop resampled up BEFORE the alpha ramp,
so smoothstep evaluates interpolated colour and places the edge between source
pixels. Selection still runs at native scale: extract_wordmark feeds MIN_MARK_H
and the aa/coverage/bleed score, so supersampling during scoring changes which
cover wins.

That scale is fractional. A floored integer factor is 1 for every crop wider
than half the target, so at 2400 a crop of 1201px was supersampled by nothing
at all while 1200px got 2x -- and the covers on the wrong side of that cliff
are the large printed wordmarks, where the staircase was worst. Measured over
eight artists, Hot Toddy's zigzag share fell 0.312 -> 0.048 and Crazy P's
0.220 -> 0.021, with every other artist unchanged. 2400 itself is confirmed by
sweeping 1200/3600/4800: past 2400 the mask overshoots the tracer's raster so
the browser downscales what Rust just built, every shape measure goes flat and
the data URL doubles.

catalog_cover_logo_debug reports the rejection reason and the mask itself, and
its cover cap follows COVER_LOGO_MAX_COVERS instead of undercutting it at 14 --
where it hid exactly the covers production relies on. The artist page also
re-runs the logo pass when ownership changes the covers on offer; that pass
races the identity merge and had been reading the unfiltered list, which is how
Wand wore a wordmark lifted from a co-tenant's single.
2026-07-26 09:33:11 -05:00
KeelrLeek 4d6e69e324 release ownership: one Deezer id can be seven artists
There is exactly one Deezer artist called "Wand" (1213178) and it holds the
LA psych band, a Brazilian funk MC, an Indonesian act, an Indian one, a 2020
house project and whoever released Hard Knox in 2009 — 46 of its 72 rows are
somebody else's. Ranking search results cannot help (there is one candidate)
and neither can credit role (every row is primary-credited to it).

The Apple namesake filter that should have caught this switched itself off:
APPLE_EXACT_ARTIST_CANDIDATE_LIMIT is 8 and Apple has TEN artists named Wand,
so the candidate fetch failed closed and every catalogue came back empty. It
fails closed for a good reason at page-load time — it picks the winner by
overlap with the requested artist's own Deezer releases, which on a fused
record are partly the co-tenants'. Circular evidence has to fail closed.

So the answer is not to raise that cap but to stop asking the fused list.
artist_release_ownership runs in the DEFERRED identity pass, where the linked
MBID has already named the artist, and picks the Apple performer that
reproduces THAT catalogue. Non-circular, so 20 candidates is safe. Evidence is
MB release groups of every type, the verified Apple catalogue, and the label
sets of both (MB at one sighting, Apple at two — Apple files two rafftheraff
singles under the LA band). Pruning only runs where a row is provably carried
by a different same-named Apple artist, so the gate opens on 4 of the 55
artists measured and stays shut on Wednesday's twenty namesakes, Jungle's
sixteen and Roosevelt's fourteen.

The same evidence repairs the opposite failure. The guest guard keeps an
appears_on row only if MB credits it to the artist's own MBID, which assumes
MB mirrors collaborations. It does for Watch the Throne, whose artist CREDIT
names both. It does not when the collaboration is its own entity: VULTURES 1,
VULTURES 2 and KIDS SEE GHOSTS appear nowhere in Ye's 315 release groups, and
all three were being erased from his page. Apple's artistName credit, read as
a list of principals, restores them without restoring guest spots — Estelle's
"American Boy (feat. Kanye West)" is still hers.

A fused record's PORTRAIT is wrong for the same reason its releases are: it is
whichever co-tenant uploaded last, a real photograph of the wrong person, so
deezerPortraitMissing cannot see it. The verdict is persisted, because the
search dropdown is typed against per keystroke and can never afford the
lookups that produced it. Its store is deliberately not in net-cache-v1 —
prune_disk_cache deletes every json there by mtime, and a verdict is not a
cached response.

Measured live: Wand 72 -> 26, Hot Toddy 36 -> 16, Crazy P 68 -> 51,
Turnover 30 -> 25, Kanye 50 -> 64; Weezer, Pond, Billy Cobb, JPEGMAFIA and
Daft Punk untouched. The page's "wand." wordmark went with the co-tenants and
should have: it was traced from the Indonesian single's cover.
2026-07-26 05:58:17 -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 0955dc02ce cover logo: trim the wordmark horizontally, and stop rewarding bleed
The extractor trimmed the crop VERTICALLY to the wordmark's true edge, walking
rows until a transparent gap. Horizontally it did nothing: left and right were
the OCR box plus a pad of 5% of the word's width. Three defects fell out of that
asymmetry, all measurable as ink touching the finished mask's boundary.

- Glyphs were sliced when the OCR box under-covered them. Snail Mail's italic
  "L" was cut down the middle: 87 of 163 rows inked at the right edge. Padding
  now scales with TYPE SIZE rather than word length, because an overhang is
  proportional to letter height and a long wordmark was getting the least room.
- Cover frames were swallowed when the box sat near an edge. Knocked Loose's
  mask had ink down its entire left column and across its entire top -- a
  border, not letters.
- Artwork BESIDE the text bridged the row walk. A decorative shape down the left
  of "Lamb's Wool (with Poolside)" kept every row between the two text lines
  non-empty, so the walk sailed through the gap and took the line above with it:
  the mask read "WITH / POOLSIDE". Row ink is now tallied over the artist name's
  own columns, where that gap is genuinely empty.

The column walk mirrors the row one exactly: outward from the name's columns
while ink stays contiguous, stopping at the first transparent column (so the
wider pad can never reach a neighbouring word) and at a near-fully-inked one
(a rule or frame, the same boundary `solid` already recognises for rows).

Candidate scoring gains a bleed penalty, because trimming alone made selection
WORSE. A clean mark ends with empty borders, so ink still touching one means
cover art is continuous with the text -- and that art is soft grey, which the
`aa` term reads as superb edge quality: Folamour's smeared 246x89 candidate
scores 0.44 against the clean 647x124's 0.03, and won. Penalising boundary ink
puts the clean one back in front.

Measured over the fixture roster: Knocked Loose 920x151 -> 872x88 with no frame,
Snail Mail's L intact, Poolside a clean "POOLSIDE" instead of "WITH / POOLSIDE",
Folamour back to 647x124. Camping in Alaska, Crazy P, Mildlife, Session Victim,
Turnover, Alvvays, Motor City Drum Ensemble, Spiritbox and Todd Terje all still
extract, each a little tighter now that the slack is trimmed.
2026-07-25 19:47:15 -05:00
KeelrLeek b16fc340b4 provider art: a duplicate MusicBrainz entry is not a namesake
TheAudioDB records are held to a cross-provider identity test because a shared
name is not evidence -- namesakes are the bug it protects against. But it
demanded that TheAudioDB's MBID EQUAL the MB artist Deezer links to, and treated
any other id as a different act.

MusicBrainz carries duplicate entries for the same artist more often than that
assumes. Roosevelt has two -- 84825fb6 and d7eecee2, both "Roosevelt", Person,
DE, "German producer & DJ from Cologne". Deezer links the first, TheAudioDB
references the second, so his logo was discarded as belonging to someone else.

The rescue keeps names untrusted, which is the entire point of the gate: when
the ids disagree it asks whether TheAudioDB's OWN MusicBrainz entry links back
to the Deezer artist being resolved. d7eecee2 lists
https://www.deezer.com/artist/85901 among its url relations -- two MB entries
pointing at one Deezer id are the same act whatever their ids say. It only runs
when the cheap checks fail and it rides the shared cached artist lookup, so the
hot path is unchanged.

A linked MBID still outranks TheAudioDB's own strDeezerID, which can be stale
(`linked_mbid_outranks_stale_audiodb_deezer_id` covers that and still passes) --
the doc comment's "either ... or" describes a precedence the code deliberately
does not have.

Manifest recipe bumped to v5, or artists cached under v4 keep serving the
logo-less result this build would now fill. Safe now that a stale-recipe
manifest survives as a floor rather than being deleted on read.

Roosevelt: 0 logos -> 1 (his TheAudioDB logo). Verified unchanged: Fishmans 5,
King Gizzard 7, Wednesday 4, Pond 1, Ross from Friends 1, Camping in Alaska 0
(genuinely has none).
2026-07-25 19:13:29 -05:00
KeelrLeek 613ae18e87 cover logo: fix the two ways artists silently lost their wordmark
Both regressions were invisible because the pipeline's failure mode is a shrug:
no logo, no error, fall back to plain text.

1. The dominance guard was resolution-dependent. It bins the crop into a coarse
   16-cubed RGB histogram and rejects when the dominant colour holds under half
   the pixels ("text over a photo"). A BIGGER source resolves more grain and
   JPEG texture, which spreads pixels across bins and shrinks that share -- so
   the identical crop of Alvvays' "Blue Rev" measures dominance 0.746 at 1000px
   and 0.226 at 3000px. Once the upgrade path started supplying hi-res artwork,
   covers that had segmented cleanly for months began failing a guard that had
   not changed; its input grew. The histogram and guard now read a copy
   normalised to the 1000px they were calibrated at, while the alpha ramp still
   runs at full resolution -- which is the whole reason for fetching the larger
   cover.

2. Only the top 14 covers were ever offered. The rank puts albums first and
   penalises remix/mix/edit/live titles, but a prolific artist's wordmark is
   often on exactly those: Poolside has 74 releases and SEVEN covers that OCR
   reads as "POOLSIDE", every one of them outside a 14-cover window. The cap
   goes to 40 on both sides (the frontend list must not undercut the backend's,
   since whatever it trims the extractor can never see). This is free for
   artists that hit early -- the locate loop stops at COVER_LOGO_MAX_HITS -- and
   is paid once, then cached, by artists with no wordmark at all.

Recovered: Alvvays (nothing -> 871x173), Poolside (nothing -> 528x225),
Men I Trust, and Motor City Drum Ensemble now extracts too. Verified no
regression across the rest of the research roster: Camping in Alaska, Crazy P,
Folamour, Hot Toddy, Mildlife, Session Victim, Turnover, Knocked Loose,
Snail Mail, Spiritbox and Todd Terje all still extract at their previous sizes.
2026-07-25 18:51:30 -05:00
KeelrLeek 88e0d98ddd cover logo: bump the cache recipe for the ink-scaled alpha ramp
Every persisted mask predates the anti-aliasing the trace path now relies on,
so without this bump a cached artist keeps rendering from a near-binary mask
while the tracer runs at the lighter blur that assumes real gradients -- the
worst of both. The key already hashes the recipe, so old entries simply miss.
2026-07-25 18:31:43 -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 e536e96180 discography: drop guest appearances, but only where MusicBrainz can arbitrate
Deezer's /artist/{id}/albums returns records the artist merely contributed to,
alongside their own. Mid-Air Thief's page carried four Rooby Jeantal lo-fi EPs
that way -- his real discography is two albums.

This is not the namesake bug that was hardened in July. Every identity step here
is correct: MusicBrainz links the artist to Deezer 51082452, that is the id we
open, and the MB supplement injects both real albums. The leak is credit ROLE,
which is why an identity fix never touched it.

Deezer cannot answer it alone. Album details expose the primary artist, so a
guest spot is visible -- but every contributor is marked role "Main", so Kanye
West on "Watch the Throne" (primary artist JAY-Z) is byte-for-byte the same
shape as a guest EP. A "not primary, therefore drop" rule deletes a real album.

MusicBrainz separates them, because it credits a genuine collaboration under
BOTH artists: Kanye's own MBID returns "Watch the Throne", Mid-Air Thief's
returns only his two albums. So the backend only FLAGS (`appears_on`, set from
the details pass where the release's primary artist is finally visible) and the
frontend drops a flagged row at the identity merge only when MB answered for
this artist and does not credit the row. No MB albums means no arbiter and
every row is kept -- thin-coverage artists never silently lose real releases.

EPs now enter the album-details pass, which is what made this invisible: the
pass was album-only, and all four guest releases are typed "ep". They already
sort ahead of singles for the details cap.

Replay recipe bumped to v2: pages cached before this are stamped
identity_merged and would skip the pass that drops the rows.

Verified live -- Mid-Air Thief 6 albums -> 2 (무너지기/Crumbling, 공중도덕);
Kanye West keeps "Watch The Throne (Deluxe)", flagged appears_on but MB-credited,
with all 58 rows intact.
2026-07-25 17:15:39 -05:00
KeelrLeek 9cd03a6ecc hero: tell a square press crop from a sleeve by hashing the artist's releases
The aspect floor rejected everything below 1.15 because a square raster is
usually album art, and album art clears every other gate. That proxy also threw
away square PRESS photography -- Nikki Nair's entire usable corpus is 1:1 crops
from Rinse FM, Corsica Studios and 529 Atlanta, so he had no route to a hero at
all.

Shape cannot separate those two cases, but the catalogue can: a sleeve IS one
of the artist's covers. A square candidate is now perceptually hashed against
up to 25 of the artist's own releases and admitted only when it matches none,
reusing the dhash and distance threshold already used for cover resolution.
With no hashes available -- no Deezer id, or the lookup failed -- it falls back
to the old blanket rejection rather than guessing.

The covers are fetched alongside Bing discovery rather than after it, at 250px
and mostly from the shared image cache, so it costs no wall clock next to the
multi-megabyte hero candidates. The six-second seed path passes no hashes: it
exists to paint something safe quickly, and a square candidate can wait for the
exhaustive pass to adjudicate it.

Nikki Nair goes from BANNER NONE to 2 -- a withguitars promo shot and a 529
Atlanta press photo, verified rendering correctly. Camping in Alaska is
unchanged with its three banners, so the guard that this gate was originally
added for still holds: its square "lost and found" sleeve stays out.

Tests cover the fork directly: a square matching a release is rejected, a
square matching none is admitted, a square with no catalogue knowledge is
rejected, portrait is admitted, and a portrait too narrow to crop is not.
2026-07-25 16:46:07 -05:00
KeelrLeek d79fe2f28a hero: admit portrait sources, the crop was built for them
The aspect floor is really an ALBUM-ART guard -- its own comment says so: a
square sleeve passes every other gate, and Camping in Alaska's square "lost and
found" artwork once reached a live hero slot that way. But it was written as a
blanket "wide only" rule, so it also discarded portrait press photography one
crate before the machinery built to handle it.

The app already crops tall sources to 16:9 around detected faces: hero_crop.rs
keeps full source width, slides the window vertically, and applies headroom
rules above the topmost face (recipe vertical-yoloxnano-...-faces-first).
maximal_hero_rect exists specifically for sources taller than 16:9. Meanwhile
the selection gate in the metadata crate rejected everything below 1.15, so
those images never arrived.

Sleeves are square; press portraits are not. Admitting ratio <= 0.92 with at
least 1000px of width keeps the guard exactly where release artwork lives while
opening the one route to a hero for artists whose entire press corpus is
portrait. Posters and flyers cannot enter this way either: the text, flat and
collage gates all run before this one.

Hana Vu goes from BANNER NONE to 2 -- the LA Times 2000x2500 portrait and an
Our Culture press shot -- and renders correctly, face placed with headroom.
Camping in Alaska is unchanged, same three banners. Square 1:1 press crops
(Nikki Nair's Rinse FM and Corsica shots) stay rejected, which is the guard
still doing its job.
2026-07-25 16:35:39 -05:00
KeelrLeek 9536ff23a9 hero search: stop reading " x " as a co-bill, and reject explicit results
Two follow-ups to reading both Bing buckets.

The multi-artist guard treated " x " as a co-bill between two acts. In practice
it far more often joins a venue and a festival: "Nikki Nair | Boiler Room x
Bass Coast 2022". Because every YouTube thumbnail is named maxresdefault.jpg,
image_names_artist is structurally false for that whole class, so the rule was
rejecting Boiler Room and festival set photography -- the richest source of
hero-shaped images that exists for DJs. Genuine collaboration graphics are
still caught by named_collaboration, which requires the image itself to name
the other subject, so nothing is lost. Nikki Nair's multi= counter drops from 1
to 0 with his identity accepts intact.

And explicit results are now rejected before the identity gate. Reading the
unfiltered bucket is necessary for coverage but it puts pornography in the
candidate pool: one artist's unfiltered wave returned 24 hardcore cards. An
audit found zero of ~67 such cards could clear bing_identity_confidence,
because porn pages do not name obscure musicians -- but that is an empirical
accident rather than a guarantee, and reading only the strict bucket used to
provide an independent second layer. This restores one. The vocabulary is
deliberately narrow so "adult contemporary", XXX as a roman numeral, and band
names like Nude Beach or Pornography stay eligible.

Canary unchanged: Fishmans still accepted=12 with the same three banners.
2026-07-25 16:25:17 -05:00
KeelrLeek 8b2aadce2b hero search: read both Bing safesearch buckets, and the bare artist name
Bing's safesearch preference selects which cached result set answers a query,
and either bucket can be serving a DIFFERENT query's results. Measured on one
machine minutes apart:

  Fishmans     strict 32/35 relevant | unsignalled  0/35 (porn, a chord page,
                                                          a printable calendar)
  Nikki Nair   strict  0/35          | unsignalled 35/35 (Resident Advisor,
                                                          Rinse FM, Boiler Room)

So there is no correct global setting. Shipping strict alone in ee25095 fixed
Fishmans, Partiboi69 and Camping in Alaska while silently hiding artists Bing
can see perfectly well. Every wave now reads both buckets and pools the cards,
deduped by media url, and the identity gate picks the winner -- it rejected
~3,000 cards in the audit behind this with no observed false rejects.

The query ladder also gains the bare artist name. Decorated queries collapse a
rare artist onto a popular entity sharing a word: every "Hana Vu ... photo"
phrasing returns the Japanese group HANA, quoting included, while the bare name
returns her real NME and LA Times press shots. Every accept on the three newly
fixed artists came from this query.

A 1-2 card page now counts as a degraded answer rather than only an empty one
-- a single junk card was enough to blank an artist for CATALOG_TTL, and that
was 15 of 30 waves for MSPAINT and Billy Cobb in one audit. The richer of the
two reads wins so a thin retry cannot lose a good first answer.

And fanart.tv publishes newer uploads under artist4kbackground, not the legacy
artistbackground the code asked for. MSPAINT's ONLY background is a 3840x2160
band photo under the new key -- fetched and discarded on every page load while
the artist showed no hero at all.

Measured across 21 artists, every winning image inspected: 8 -> 10 with a
correct hero, no wrong-artist hero anywhere, canaries (Fishmans, Partiboi69,
Camping in Alaska) unchanged. Newly working: Jane Remover, They Are Gutting a
Body of Water, MSPAINT.
2026-07-25 16:22:08 -05:00
KeelrLeek 7ce9fd0efc artist art: keep a known-good hero instead of rediscovering it daily
An artist's hero was re-discovered every 24 hours through a tier that fails
per-name, and the stale manifest was deleted on the way in -- before the
replacement lookup had run. So one bad discovery day did not merely fail to
refresh a hero, it destroyed the good one first and left the artist blank.
On this machine that took the persisted store from 26 artists down to 3;
Crazy P, MSPAINT, Sword II, Hermeth, Billy Cobb, Pond, Weezer, Daft Punk and
King Krule all had correct banners on disk that nothing would ever read again.

Three changes:

- The manifest TTL goes from 24 hours to 30 days, matching the cover
  resolution horizon. A press photo is still the artist's press photo a month
  later; it does not go stale the way metadata does.

- Reading a manifest no longer destroys it. Stale and older-recipe manifests
  are retained as a floor and only replaced once a lookup returns something
  better, which also migrates them forward to the current recipe. Only files
  that are unusable -- oversized, unparseable, or belonging to another artist
  -- are removed.

- A failed hero discovery is detected by an EMPTY BANNER LIST, not a None:
  catalog_artist_art returns Some as soon as the Deezer profile resolves, so
  the previous None check could never fire, and an empty result would also
  overwrite good banners whenever logos were present.

Also widens discovery so fewer artists need the floor at all. The query ladder
gains the bare artist name: decorated queries can collapse a rare artist onto a
popular entity sharing a word -- every "Hana Vu ... photo" phrasing returns the
Japanese group HANA (525 of 525 cards, quoting included), while the bare name
returns her real press photos with 16 of 17 passing the identity gate as
Strong. The gate, not the query, is what filters.

And a near-empty Bing page now counts as degraded. The retry test was
is_empty(), which let the commonest degraded shape through: a one-card page of
something unrelated, cached as that artist's verdict for a quarter hour.
Measured 15 of 30 waves for MSPAINT and Billy Cobb. The richer of the two
reads is kept so a thin retry cannot lose a good first answer.

Verified live: Crazy P, MSPAINT, Sword II and Hermeth all render heroes again.
2026-07-25 14:50:55 -05:00
KeelrLeek ee2509538a metadata: send safesearch=strict on Bing image searches
Our client keeps no cookie jar, so every Bing read arrived with the
adult-content preference undefined -- and in that state Bing answers with
wholly unrelated cached result sets. Four identical back-to-back reads of
"Fishmans band photo" returned porn, an Indonesian guitar-chord page, porn
again, and a printable 2026 calendar, each internally coherent with honest
titles and page urls.

Measured 27/27 reads with zero relevant cards without the flag and 27/27
correct with it, across three artists, three waves and three repeats. Only
strict works; moderate and off behave exactly like sending nothing. Strict
costs no relevance (Partiboi69 scores 34/35) and it also keeps explicit
imagery out of the candidate pool, which until now only the identity gate
was stopping.

This failure was misdiagnosed twice -- first as a poisoned IP, then as Bing
rate limiting -- because with no cookie our requests are byte-identical
every time, so all the variability lived in Bing's cache. That is why it
appeared to break and heal on its own with no code change.

Camping in Alaska, Fishmans and Partiboi69 now resolve real heroes again.
2026-07-25 12:49:25 -05:00
KeelrLeek 3718dad667 metadata: say why the Bing hero tier found nothing
The Bing hero tier currently returns no banner for artists it used to cover.
Every gate in the candidate loop is a silent `continue`, so a tier that throws
away all 525 parsed results looks identical to Bing returning nothing at all —
which is why this has now read as a fresh regression four times without anything
being able to confirm or deny it.

Emit HERO_GATES alongside HERO_WAVE: what was parsed, what was accepted, and the
count discarded by each gate, plus the first few identity rejections in full.
That immediately separates "our filter broke" from "Bing sent us junk".

It says the second one. For Camping in Alaska: parsed=525, accepted=0,
identity=510 — and the rejected cards are campground photos from recreation.gov
and thedyrt.com. Partiboi69 returns Bruce Lee wallpapers, Fishmans returns Tommy
Fury boxing coverage, Hermeth returns aesthetic quote wallpapers. The results are
unrelated to the query and to each other, so the identity gate is doing exactly
its job: refusing to hang another person's face on the artist.

Confirmed outside the app, straight from curl. Zero query-relevant result cards
on any variant — with and without qft/form/first/cc/setlang/tsc, with full
browser client-hint and sec-fetch headers, and against the /images/async grid
endpoint the page itself uses. The async endpoint answers "Fishmans band photo"
with AI-course ads and "Partiboi69 band photo" with celebrity photoshoots. Our
url is well formed and the parser is fine: `q` is properly encoded, and cards
still parse (252-525 of them). Bing is simply no longer honouring the query for
this client.

Also stop one bad answer from sticking. Bing serves the SAME url a card-less
page on one read and 25-80 cards on the next, and browser_html caches whatever
came back for CATALOG_TTL — so a single degraded response left an artist
heroless for fifteen minutes. A wave that parses to nothing now drops the cached
page and asks once more. This does not address the wrong-results failure above;
it removes a compounding one found while chasing it.

No hero behaviour is changed by this commit.
2026-07-24 18:38:01 -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 86e0d86604 fix: preview crossfade no longer strands the main volume (silent playback)
Clicking a track could play silently: the store showed status=playing with
the position advancing and no error, but the sink was at volume 0. The
open/close crossfade ramped the main player's USER VOLUME as its fade
channel and left it at 0 on an ack timeout (or when a track change
suppressed the close resume). User volume persists across tracks and a new
Play doesn't reset it, so every later click played at 0 while the slider
still read 0.7.

Give the main player a separate fade_gain channel (Cmd::SetFadeGain, sink =
volume * norm * fade_gain), reset to 1 by every Play/Stop. The open/close
fades ramp fade_gain instead of set_volume (their volume arg is gone, JS
callers too), so the user volume is never touched -- store and backend can't
desync -- and a clicked track always resets fade_gain to 1, so it can never
be stranded silent. fade_resume lands exactly at 1. Player::fade_gain()
mirrors it into a report atomic; debug overlay shows main fade + this proved
the fix live (open 1->0, close 0->1, new track 1.0, volume steady at 0.7).

39 app + 12 player + 85 JS tests pass.
2026-07-23 17:31:06 -05:00
KeelrLeek 28aba3a3b3 feat: normalize preview clips to -14 LUFS to match the library
Deezer preview clips are cut from the raw master (not pre-normalized), so
they played several dB hotter than the user's normalized -14 LUFS files —
measured across one artist the previews ranged -0.1 to -8.8 dB from target,
an 8.7 dB spread, so a flat offset would have been wrong either way.

Each clip is now measured with the same BS.1770 meter the main player uses
(preview.rs measure_clip_gain -> loudness::normalization_gain_db) right after
it is written in preview_resolve -- off the async worker, and fully off the
playback path on a prefetched neighbour. The linear gain is stored in the
lease and the reuse cache, and rides UNDER the app-volume mirror per voice
(effective = target_vol * clip_gain), so it composes with the slider, mute
and the crossfade (each clip crosses in at its own matched level). Gated on
the volume_normalization setting; reset on dispose. Debug overlay PREVIEW row
now shows the active clip's norm dB.

Verified live: per-clip gains -0.1..-8.8 dB applied; mute still zeroes the
preview; 39 app + 12 player tests pass.
2026-07-23 17:12:04 -05:00
KeelrLeek abc959ca2f fix: defer the open crossfade so main no longer fades into a gap
Opening the preview faded the main track out at focus-acquire time, but the
first preview clip only becomes audible after resolve/load/decode — so on
the way IN the 340 ms main fade completed into silence before the preview
arrived (a gap the user heard; the exit was already fine).

The main fade is now deferred: focusBridge.requestSilence only arms the
commitment and grants; coordinator.beginMainSilence performs the actual
fade-pause, and the service fires it from afterChange on the opening
session's first audible clip (or ITEM_FAILED). Main-cos-out then overlaps
preview-sin-in — a true cross. resumeMain skips the fade-resume when the
fade never fired (close before audible), and beginMainSilence re-checks the
main is sounding before fading. The focus coordinator's acquisition logic
and tests are untouched.

Verified live: preview audible at 275 ms, main played until then, faded
~343 ms, paused at 618 ms and held frozen; close resumed from the exact
position in 15 ms.
2026-07-23 16:54:09 -05:00
KeelrLeek 691fdcdc9a fix: previews follow the app volume; volume bar fill always accent
The preview backend never received a volume — clips played at the crate
default (0.7) regardless of the slider, even muted. The session now sets
the preview volume from the app store (mute → 0) before the first load and
mirrors it live while the modal is open; the debug overlay's PREVIEW row
exposes the backend's resting volume as proof.

The player-bar volume (and fallback seek) fill is now always the accent
color — matching the waveform scrubber's played region — stepping up to
the hover shade on hover instead of white-until-hover.

Also syncs package-lock to the soulstream name + alpha version.
2026-07-23 16:25:42 -05:00
KeelrLeek 793f78370e feat: crossfade preview suite, loudness normalization, debug overlay
Preview modal audio, five rounds:
- True track-change crossfade: scheduleSettle no longer silences the
  outgoing clip at t=0; preview_load's equal-power fade owns both ramps,
  gated on the incoming voice's first Playing event (audible_marks), with
  failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's
  card-slide so audio and visuals land together.
- Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache
  (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits
  never re-download.
- Adaptive settle (isolated steps fire immediately, bursts coalesce) and
  anticipatory auto-advance (step at clip end minus 400 ms) so auto-play
  crossfades over the tail instead of gapping.
- Session resume memory: revisited tracks continue where they left off
  (decoder-level start offset via play_local_from/resume_at); finished
  tracks restart from the top.
- Open/close crossfades with the MAIN player: fade-pause with the
  desired-paused latch landing at ramp end, fade-resume from the paused
  position; MAIN_FADE_GEN guards; focus handshake relaxation documented
  in focusBridge.

Volume normalization (Settings > Playback, default on):
- Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting,
  400 ms gated blocks, incremental) + analyzer that reads the same growing
  file playback streams from — first plays are measured at download speed.
- Gain layered under user volume; audible start GATED on a preliminary
  measurement (10 s of audio or 800 ms budget) so the level is right from
  the first sample and never changes mid-track; prelim boosts capped 3 dB,
  peak-aware clipping guard, persistent loudness-cache.json.
- Gapless boundaries get their own sample-accurate gain via
  SetNextNormGainDb, fed by preload analysis.

Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set +
private-commit RAM / network rates / FPS / current-track download progress
read from the backend's own StreamProgress / live normalization state /
preview session phase. Polls only while visible.

131 JS + player + app tests green; every path verified live over CDP.
2026-07-23 16:05:14 -05:00
KeelrLeek 5e877c0b0a chore: ignore dev-note artifacts, start alpha versioning at 0.1.0-alpha.1
.gitignore now excludes everything that is not part of the app itself:
memory/, research/, TODO-Feature-Docs/, HANDOFF*/GAMEPLAN notes, .codex/,
.claude/ and logs. Version begins the alpha line at 0.1.0-alpha.1 across
the cargo workspace, package.json and tauri.conf.json.
2026-07-23 16:04:54 -05:00
KeelrLeek df75433795 Handoff: mark Phase 2C verification done, log Kyle's TBD open issues
Centering fix confirmed working in the native Tauri app. Wheel, a11y,
dev-handle removal, and real-app checks all closed out. Added a section
for the bugs/glitches/changes Kyle reported but did not yet specify —
placeholders to fill in, plus non-confirmed places to look.
2026-07-22 19:46:56 -05:00
KeelrLeek 56036216f8 Phase 2C: fix reel centering via window-relative card positioning
The transitioned track translateY collapsed to 0 in Chromium (percent-
translated zero-height track), leaving the active cover one stride low
per step. The track is now a fixed anchor at the reel centre; each card
positions itself at (index - activeIndex) * stride and animates its own
small `top`, so no coordinate ever exceeds +/-RENDER_RADIUS * stride.

Verified with the transition ON: centre offset 0 at every index in the
12- and 500-item fixtures, 5 cards in the DOM at scale, wheel gates,
dialog semantics, Tab containment, focus restore, and polite announce
all pass. Dev window.__preview*/__ui handles removed from App.svelte.
2026-07-22 19:38:10 -05:00
KeelrLeek e6252b9bb3 Handoff doc for Phase 2C reel-centering bug 2026-07-22 19:27:37 -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 8cb3cd593b Phase 2A+2B: native preview backend and renderer glue
Rust (2A):
- metadata: deezer_track_uncached (fresh mint, cache-updating, paced) and
  fetch_preview_media — dedicated client whose custom redirect policy
  re-validates every hop against the media allowlist; bounded body read
  (cap enforced on received bytes, not Content-Length's promise); Date
  header captured for provider-clock lease math. httpdate dep added.
- src-tauri/preview.rs: second Player instance + soulstream:preview event
  channel; preview_resolve (fresh mint → host gate → MIME gate → ID3v2
  shell strip → MPEG sync check → clip file + opaque lease id → safe_for_ms
  mirroring lease.js); preview_load with start_paused via the
  desired-paused latch; preview_control/dispose; player_pause_acked
  exposing the Phase 0.4 ack on the MAIN player. 9 unit tests.

JS (2B):
- api.js: preview command surface (opaque lease ids only).
- nativeTransport.js: stamps {sessionId, generation, sessionKey} onto
  backend events; cleared context makes stragglers dispatch nothing.
- productionResolver.js: DTO → MediaLease, failure classes preserved.
- focusBridge.js: production coordinator binding with the documented
  renderer-side generation invariant (buffering counter + gateway).
- service.js: the orchestrator — settle window, resolve→load→play under
  focus, single capped retry, one forward lease prefetch with safe-window
  expiry, stale re-checks before every transport command, close/handoff.
  The service constructs its transport around its own dispatch — the
  factory signature exists because a pre-built transport silently loses
  the event wiring (caught by test).

70 JS tests passing (npm test); metadata 46 + app preview 9 in cargo.
2026-07-22 18:45:29 -05:00
KeelrLeek 8ede6a8adf Phase 1: preview contracts and pure session core, 61 tests
src/lib/preview/ — framework-free ESM modules, per design §12–14:
- contracts.js: versioned manifest/item/source-ref factories; signed
  URLs structurally excluded from manifests; Apple identity first-class.
- lease.js: discriminated MediaLease union; computeSafeForMs derives
  lifetimes provider-clock-first with fallback/contradiction handling
  (margins are ADRs backed by Phase 0 soak evidence).
- redact.js: URL redaction (host+hash+exp only) and the HTTPS + exact
  host allowlist gate applied to initial URLs and every redirect.
- manifest.js: artist (rank order, exact-id dedupe), album (disc/track
  sort, context inheritance) and fixture builders — pure, no fetching.
- session.js: reducer with session/generation guards on every async
  action; keyed active item with derived playable indices/cursor;
  auto-advance under fresh generations; close is terminal.
- resolver.js: PreviewResolver contract + scripted fixture resolver
  with classified failures (stale_lease/not_found/network/…).
- transport.js: transport seam + deterministic fake (manual ticks).
- focus.js: revision gateway (all main-player entry points; focus-
  internal tag) and the coordinator — acquisition only via a
  generation-bound silence ack, restore only with revisions unchanged.

tests/preview/*.test.mjs run via `npm test` (node --test): 61 passing,
covering the full Phase 1 list in IMPLEMENTATION-PLAN §6 including the
exit gate: stale lease/play events are inert, no restoration after any
simulated main command, and no component renders anywhere.
2026-07-22 18:29:03 -05:00
KeelrLeek 6dcc62a2fd Phase 0.4: silence-ack primitive — pause_acked + desired-paused latch
Player::pause_acked() returns a generation-bound PauseAck applied on the
audio thread; a desired-paused latch makes an in-flight decoder come up
paused instead of auto-starting, closing the window where a pause lands
during decode setup. Proven live by examples/focus_ack_probe.rs:
ack-while-playing freezes position; ack-while-buffering never becomes
audible; resume clears the latch; plain pause() during buffering is
demonstrated to auto-start (pre-existing hazard, filed separately).

Transport ADR recorded (native), Phase 0 marked complete in the plan.
2026-07-22 18:17:37 -05:00
KeelrLeek 58918cf79d Phase 0.2 complete: lease soak results, ID3-shell universality, long tracks
Soak: signed URLs serve 206 through their parsed exp (+14.96 min batch)
and return 403 with an Akamai HTML error page by +17.5 min. Resolver
must refresh ahead of exp, treat 403 as stale-lease (re-mint by track
id), and validate audio/mpeg before decoding. ID3v2.4 empty shell
verified byte-identical on 7/7 extra samples; 18- and 26-minute tracks
carry the same uniform 30 s / 480 KB / 128 kbps preview artifact.
2026-07-22 18:07:35 -05:00