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.
"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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
.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.
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.
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).
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.
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.
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.
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.
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.
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.
.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.
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.
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.
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.
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.
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.
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.
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.
The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.