Commit Graph
50 Commits
Author SHA1 Message Date
KeelrLeek 913289a297 the panel tops speak one language: 28px discs, white glyphs, one veil
Kyle put four crops side by side and asked why the top chrome disagrees
with itself. The audit found five variants across three panel tops: the
glass bar's arrows (28px circle, white, 17px glyph, 0.12 white-veil
hover), the sidebar toggle and both Now Playing chevrons (28px 6px-radius
squares, grey, 16px, opaque --bg-highlight hover that slabs under glass
and a --glass-hover override that reads as nothing up here), the
add-to-playlist trigger (grey square, 15px glyph), and the save button
(34px 8px-radius square, 18px glyph).

One spec now, the arrows' language: 28x28 circle, 16px glyph, #fff at
rest, rgba(255,255,255,.12) hover in BOTH modes. This commit carries the
Now Playing header set — np-icon (head collapse + rail expand), the
plm-trigger override, and a new np-head override pulling SaveMenu's
trigger down from its 34px playbar size (SaveMenu grows an iconSize prop;
the playbar keeps 18, the head passes 16). The glass-hover overrides for
these buttons are gone — the base veil is mode-independent — except one
glass-scoped rule kept solely to outrank SaveMenu's own glass hover on
specificity. The bar arrows' 17->16 and the sidebar toggle's disc ride the
hub commit; their files carry that rework and split poorly.

Verified by computed style across all five controls: identical to the
pixel, and the disabled arrows stay dimmed white rather than grey so even
the off state is the same hue family.
2026-08-31 15:13:24 -05:00
KeelrLeek 2d8c385a2e the sourcing notifier is a toast: the glass bar hands rstatus to the player-bar slot
The auto-sourcing resolver used to narrate from the right side of the
glass bar — a pulsing line while it searched, and an error that then sat
in the header indefinitely. It is now ResolverToast, in the exact slot,
pill and motion FollowToast and ImportToast already share: bottom-center
above the player bar, dark glass pill, toast-in/out, reduced-motion
respected, pointer-events none.

Three moments: while resolving, an accent dot pulses beside
"{label} — finding the best source…" for as long as the search runs; on
success the pill just leaves — the playbar coming alive IS the
confirmation, the same rule the bar version followed by disappearing; on
failure it swaps to the error in the bar's own red, holds 3.8s so it can
actually be read, then leaves. The label is copied locally so the success
exit never flashes an empty "— finding the best source…" while animating
out (the resolver clears its label in the same breath as active).

GlassBar no longer renders or imports any of it; the rstatus markup and
styles are gone with a pointer comment left in their place.

Verified by driving the resolver state through all four phases after a
clean reload: searching shows, success dismisses, a re-search re-shows,
an error holds then auto-dismisses — with the bar confirmed empty.
2026-08-31 11:46:27 -05:00
KeelrLeek e4a2bb6075 the search panel detaches and frosts: 46px down, blur(40), and the rest of the acrylic recipe
Three rounds with Kyle's eyes on it, one rule each:

DETACH. The dropdown's top edge used to ride the exact line where the
content pane begins ("completely lined up with the main window") — and the
first fix, 38px, left the two edges within 4px, a near-miss that read
sloppier than either aligned or apart. 46px, tuned live over the album
page (38/42/46, OS captures), lands the panel ~9px below the pane line
with a 15px seam of bar under the pill.

FROST, not smoke. Plain rgba(0,0,0,0.72) ghosted the page's display-size
heading through razor-sharp; blur(18) blurred it but left it READABLE — a
blur only defeats text once its radius reaches the glyph scale, and page
headings here are ~40px tall. Now blur(40px) saturate(1.3) contrast(0.85)
over 0.55 smoke: the contrast compression is the other half of why real
acrylic is unreadable.

Why backdrop-filter is legal on THIS surface when the bars had to grow
periscopes: the filter black-flattens only the frost fraction of its
backdrop, and this panel is deliberately dark — where page content sits
behind it the content blurs into proper fog, and where bare frost shows
the flatten lands within a couple points of the plain-smoke look it
replaced. The bars failed because they wanted to SHOW the frost; this
panel wants to bury it.

NOISE + SHEEN. Over the hero's dark side the blur had nothing to re-emit
and the panel sat flat dead next to its lively half ("only half of it
seems fine"). Real acrylic never dies over black because blur is only half
its recipe — a 4.5% monochrome turbulence and a faint top sheen finish it,
so both halves read as one brushed material whatever is behind.

Interior chrome joins the glass vocabulary (hover rows, count pill,
chips, hairlines, SAVED tag, cover placeholders), and the old
keep-menus-opaque call is overridden for this menu alone —
Playlists/Playlist keep theirs.
2026-08-31 11:46:01 -05:00
KeelrLeek 5e2ad18269 the periscopes never built on this machine: bounded idle gate, and the fog rides the scroller's timeline instead of chasing it
THE HEADLINE, and it invalidates a lot of earlier reasoning about the glass
bar: all three periscopes were gated on an idle callback granting a >=15ms
slice, and with any frame continuously pending this 60Hz laptop caps
timeRemaining at ~13.5ms. The gate NEVER passed — traced hundreds of
consecutive refusals — so the fog never built, on any pane, all session. What
the bar had been showing all along was no fog at all: a thin tint over the raw
page. That is why Kyle read the top bar as "so light compared to the left and
right sidebar" (on album pages the raw page there is the banner, deliberately
bled up bright), and why the Now Playing blur "goes away whenever the next
track plays and its glitchy" — every track change clears that pane's fog for
rebuild, and the rebuild starved forever. The skip is now bounded: after eight
starved retries (~1-2s of honest waiting) it builds anyway. Same three lines in
GlassBar, Sidebar and NowPlaying — the pattern is copied, so a fix to one is
not a fix.

THE BAND'S TINT, once there was a fog to judge. The bar wore
rgba(20,20,24,0.28): a non-black colour that adds ~6 luma of its own light, at
a net 0.676 against the panes' 0.7075, so it read lighter than the panes by
construction. It now wears the panes' exact frost — pure black 0.35 over the
0.55 canvas — applied as .peri::after, because the bar's own background paints
UNDER .peri and cannot tint the fog (measured: an 0.85 background moved the
band 34.6 -> 33.5). Same material as the sidebar and Now Playing bands;
brightness then follows the wallpaper for all of them alike, which is why they
measure 20-36 across one screen and no absolute number could have matched.

THE FOG RIDES THE COMPOSITOR NOW. It chased scroll events on the main thread
and landed frames late ("the content behind the glass bar is delayed with
scrolling"). .pane-shell publishes the scroller as a named timeline
(--pane-scroll) and the clone animates against it, so the ride equals -scrollTop
with the main thread nowhere in the loop. Verified as a live ScrollTimeline
animation tracking scrollTop exactly. The rules live in global.css on purpose:
Svelte's -global- keyframes prefix stripping desynced animation-name from
@keyframes when they lived in the component, and getAnimations() showed it dead.
The old event sync stays as the no-timeline-support fallback.

THE SEARCH WASH reaches the top of the bar again. Its contract is that "the bar
frosts over colour rather than over black", but the frost veil dimmed the whole
fog and left a dark step where bright wash met the band; and the blur kernel at
the band's top edge sampled past the wash's own top edge into nothing, fading
the colour out over the first ~20px — the dark strip Kyle photographed twice.
The cloned wash is boosted to compensate for the veil exactly, and bled into the
fog's 48px overscan so the kernel always has colour to sample. The album banner
never showed this because its box already bleeds -70px above the page.

Judged on OS-level captures throughout (CopyFromScreen): CDP screenshots cannot
see the acrylic and led every wrong turn in this file's history. 98/98 tests.
2026-08-31 05:53:42 -05:00
KeelrLeek 547cee6649 dark glass gets a surface ladder, and the 200-750ms stalls are dead - two sessions of uncommitted work landing together
SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands
on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a
translucent black card nets 0.81 - darker than its own pane and inside the
~0.85 band already rejected once on the artist page. Solid mode's ladder is
measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new
--glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass
(0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary
.btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are
NEVER redefined - they are consumed inside color-mix() and gradient stops where
an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible
chip; the album row's text-overflow fade works by BEING opaque), and they paint
modal chrome that has to stay solid. 129 additive :global(:root.glass)
declarations across 18 files, all 129 verified inside a glass block by walking
the diff - solid mode byte-identical, confirmed structurally, by computed style
with the class off, and visually.

Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING
the last ~19px of the listeners bar and nothing translucent can occlude, so
.tcount masks out just before the buttons instead; Playlist's banner scrim and
the discography's pinned ambience both landed on opaque --bg-base and now
dissolve like the album banner; and the 0.9 black hero tint on
Home/CatalogSearch/Discography paints straight onto the pane when there is no
artwork, so :first-child (the {#if} renders no element) flips it to white.
Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and
the Settings copy for the mode now says so instead of claiming cards do.
Measured with a known ground substituted behind the transparent window, since
CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a
white fill AND its backdrop-filter dropped, +30 for the bare pane beside it.

RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live
resize and the window stays transparent through it, so the desktop punches
through sharp - measured by toggling window_glass alone, desktop icon labels
readable through the titlebar. The blur cannot be kept (documented since Win10
2004, and the modern DWM backdrop was already rejected for the
overlay-promotion blink), so armGlassResizeGuard grounds the canvas to
--bg-sidebar for the length of the gesture.

STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not
frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin
bake moved to display resolution and off the ready task (was a same-task 4K
WebGL readback plus software blur, 100-170ms per rotation AND per artist
entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the
whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded;
one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so
all three periscopes stop cloning mid-gesture on ANY pane rather than only
their own; bounded reclone with the querySelectorAll census deleted, the gate
widened 250->1000ms, and the build/attach split across an idle callback and a
rAF behind a generation guard; the hero rotation tick gated on scroll recency
and its payload cache sized to the working set (2->4 entries, 24->32MiB,
32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED.
Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were
measured innocent - do not re-chase them.

98/98 tests throughout.
2026-08-27 17:24:20 -05:00
KeelrLeek 6ba1864498 dark glass, the whole app: frost on every pane, and the periscope pattern for every glass bar the frost sits under - backdrop-filter black-flattens over see-through ground (measured 3-vs-40), so bars over frost blur a scroll-synced clone behind a static band cut instead; the bar moved out of the scroller (pane shell, inner .content keeping the closest-contract, the old negative-margin flow replayed by a spacer after the artist page's sticky physics named the scrollport padding sacred); painted pages declare data-painted and keep the real backdrop bar, which is how the artist hero runs under the glass again; np wash rides the scroll away and dissolves to frost like the album banner; perf measured to vsync - 142px blur wraps (27.9ms frames down to 6.9), idle scroll-gated reclones, clip-path band, content-visibility on library rows, async cover decode; mini frosts over the mini:glass channel and the rest-frame flags ride window config because the env-var lane dies on some webview2 runtimes 2026-08-26 14:55:25 -05:00
KeelrLeek dc7ea16b22 mini player + dark glass: eight shapes run by the hidden main as the one brain, dissolving covers and accents, drag from anywhere, and the space between the panes turned to real acrylic - with the webview flags that keep rest frames honest 2026-08-26 07:56:00 -05:00
KeelrLeek 9b84b30aef now playing: flatten the bleed so the corner arc antialiases once, not per layer 2026-08-26 07:56:00 -05:00
KeelrLeek 6a7a5c49c0 overlay + library: the preloader working on glass, album names that land on the right self-titled record, and covers warmed before you scroll to them 2026-08-25 08:05:07 -05:00
KeelrLeek 50414dae5c playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises 2026-08-24 18:10:34 -05:00
KeelrLeek 1dd8573808 dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice 2026-08-24 10:49:10 -05:00
KeelrLeek f4f26d6d34 player bar: the overflow menu out from under the bar's own clip, and Lyrics wired for real 2026-08-17 18:05:36 -05:00
KeelrLeek df3d15553a playlists + lyrics: setlists with pages of their own, and the words timed to the voice 2026-08-17 18:05:31 -05:00
KeelrLeek c4ff6269a3 import: records named by their own tags, and covers resampled where the pixels are
Two threads from one session, both of them the same shape: something was
being decided from the wrong source.

THE PROVIDERS WERE ASKED ABOUT A FOLDER. The picker sent the directory name
to Deezer and MusicBrainz — the ripper's naming, carrying catalogue numbers,
years and initials. "[WARP CD 156] Battles - Mirrored - 2007" matches
nothing anywhere; "Mirrored" matches instantly. Across the reference library
501 of 515 records carry a tag album and 315 disagree with their folder, and
no amount of cleaning turns "ACPP" into "Prospect Park, Brooklyn NY". The
record's own claim now leads and the folder is the fallback, in one place
(src/lib/import-title.js) that apply and the picker both read — apply
already resolved it this way, and only the picker did not. The verdict judge
had the same split: it compared candidates against the folder too, so an
exact answer scored as mere containment and a clean match wore a warning.

Identification also stops searching when searching cannot work. A folder
named "1989 Pauls Boutique" is a hopeless query and an easy comparison, so
after Deezer and MusicBrainz come back empty the artist's own catalogue is
ranked by title similarity. Scoring rather than stripping is the point: 1989,
1984 and 2001 are real albums, and any cleaner aggressive enough to rescue
Paul's Boutique would have destroyed them. Words split on whitespace with
punctuation folded inside them, so "Paul's" stays one token; pressing
vocabulary is ignored for scoring only; coverage is measured from both sides.
15 assertions pin the cases that would break it.

Cached verdicts now carry the engine that produced them. A cached match is
good forever, a cached "nothing found" only as good as the thing that
looked — and nothing ever re-asked. Negatives from an older engine, or from
a query that has since changed, are dropped and asked again.

Soulseek was asked the same wrong question, with the artist said twice:
"battles battles - mirrored - 2007". The server matches on ALL terms, so
every leftover token narrows the search — that copy was only found because
the peer happened to have 2007 in its path. The ladder now leads with the
record's name and keeps the folder as the narrower rung behind it.

AVAILABILITY OUTRANKS ENCODING. Candidate copies were ranked with avgBitrate
above hasFreeSlot, and queue depth — captured on every candidate — was never
read at all. A peer with no free slot and 277 people ahead beat a free peer
with a queue of two, because its files reported a bitrate and the others
reported none, and `?? 0` scores "didn't say" as the worst possible answer.
Bitrate is now ignored between two fully-lossless copies, where it describes
the material rather than the quality.

And nothing ever gave up. Track 1 is requested alone so it is not queued
behind eleven of our own files, the rest follow once the peer proves it will
serve us, and a copy that makes no progress for 45s is abandoned for the next
ranked one — cancelling the transfers it leaves, which used to trickle on
forever. A refusal falls through to the next peer instead of ending the
record. "Try another peer" is reachable while one is loading, which it never
was.

RE-IMPORTING BUILT A DUPLICATE FACTORY. Apply decided a track was already
filed by comparing the destination's size to its source — but apply itself
rewrites that file, embedding the chosen cover, so it is 582 KB larger from
the moment it is written and the sizes can never match again. Every run laid
down another " (n)" copy with its own library row: three runs left three of
everything, 2.16 GB. A file at the destination that this run did not write is
now simply an earlier import; within one run a repeated name is still a
collision, which is what `kept` is for.

ARTWORK FOLLOWS THE RECORD, ONLY THE AUDIO FOLLOWS THE CHOICE. Apply read
art from whichever folder the audio came from, which for a kept found copy is
the pen — and a downloaded album often carries no picture, so three records
imported with no cover while their own files held a 1500px one. It also only
ever looked at tags, never at the Folder.jpg the review had just offered as
"yours".

COVERS ARE RESAMPLED BY RUST, NOT BY THE COMPOSITOR. The webview was handed
2000px masters to paint 132px tiles — a 15x single-step reduction that reads
as aliasing on photographic covers, and carries megabytes of base64 to draw a
thumbnail. Artist portraits already had this exact fix; album covers never
got it. getArt is now bounded by default and getArtMaster is the deliberate
act, because ~60 places paint a cover and only two want real pixels: with the
polarity the other way round, every surface anyone forgets is silently wrong.
The fullscreen viewer also framed non-square art inside a square element, so
its rounded corners and shadow wrapped 96px of nothing; it takes the
artwork's own ratio now, bounded on both axes.

Smaller, from the same passes: the preview play button is a real transport
rather than a restart, and leaving a record silences its own preview without
touching music started elsewhere; bucket chips navigate with per-lens memory
instead of overwriting the saved resume position; Next counts from the
record's real place in a queue that re-sorts under it, which was skipping one
record for every one reviewed under a filter; a cover URL with nothing behind
it is shown as the empty answer it is rather than a broken image in a
pickable frame; write_tags returns early when there is nothing to change,
instead of rewriting the whole file to write values it already had; and the
import window has a Start over, which says what it costs before it costs it.
2026-08-10 18:08:58 -05:00
KeelrLeek e7d0971de2 import: the whole feature, and releases put back together
Everything the import grew over the last week, landing in one go — the
window, the picker, the audition pen, apply, and the review that stands
between them.

The fold is the new part. A release filed as a container of parts arrived
as N albums, because the scan groups audio by its immediate parent and the
container holds none of its own: Analord's eleven volumes were eleven
records, and six more releases split the same way. Records now fold into
the release above them, and apply is handed an explicit part list — never a
recursive walk, which is what let one stray file swallow 288 descendants
and duplicate 9 GB.

Where releases live is read off the collection rather than assumed, so a
genre-nested library moves the answer instead of breaking; below a dominant
majority at one depth the pass refuses entirely, because no folding beats
wrong folding. Three vetoes independent of depth catch a misread level.
Every fold is listed in the review with a way out.

research/import/fold-audit.mjs imports the shipping module, so the audited
code is the running code: 9 folds, 0 swallows across four library shapes,
and none of the 148 artist folders touched. It was that harness that killed
the tempting version of this rule — matching a part against its container
folded 40 artist folders, Mac Demarco's 8 albums into one, because album
folders carry the artist's name.

The regression test found a live bug on the way: parts number from 01 each,
so flattening two discs collided, and "same name, same size" read the
second file as already-imported and dropped it while still counting it.
That reuse now also requires that this run has not already written the
path.

Also here: the picker's accent crossfades between records instead of
cutting through the app's blue (ownership moved to the window, which
survives the per-record re-key), the fullscreen viewer wears the album's
accent instead of the one it left behind in the portal, Skip defers a
record without deciding it, and Keep mine is gone — with the cards picked
field by field it was Next under another name.
2026-08-10 07:55:59 -05:00
KeelrLeek f6eb9ff02f media card: Windows knows SoulStream is a music player now
Registering with the System Media Transport Controls is what makes an app
a media app to the OS rather than a window that happens to make noise.
The card that the media-key overlay, the taskbar media flyout and the
lock screen all draw now carries our cover, title, artist and album; the
hardware media keys reach us with the window unfocused; and the transport
buttons on that card drive this app. Verified against the OS's own
session list: title/artist/album/albumArtist, type Music, a live
scrubber, a present thumbnail, and play disabled while playing.

Where each half of it comes from follows where the truth already lives.
Status and position are pushed by the backend's player event pump, which
already sees every transport change and every position tick — the card
is driven by the same events the UI is, so it cannot drift from the
window, and it costs no IPC at all. Metadata goes the other way, once per
track, published by the player bar: the queue lives in the frontend and
so does the derivation the bar displays, and publishing exactly what the
bar shows means the two can never disagree. Deriving it a second time in
Rust would guarantee that one day they do.

Button presses are forwarded as `soulseek:media-button` rather than
handled in Rust, because nextTrack knows about shuffle, repeat, the album
fill and re-sourcing a track restored from the last session, and none of
that is knowable from that side. They run the same functions the on-screen
transport runs — the play/pause branch moved into togglePlayPause() so
there is one implementation, not two that drift. Proven by driving the
app from the OS session: next advanced the queue, pause and play moved
the status, previous restarted the track on the >3s rule the app already
had.

Stop is deliberately disabled: this transport has no stop, only pause,
and a dead button on the card is worse than a missing one. Buffering maps
to Changing, which is what "finding best source…" honestly is. The
thumbnail is the bar's own data URL decoded into an in-memory stream —
no temp files — and it is explicitly cleared when a track has no cover,
because the previous track's art outliving it is worse than none.

The debug handle gains the local playback entry points beside the
streaming ones it already had. That is not only convenience: verifying
this end to end needs a track playing, and every run that reaches for
Soulseek to get one costs a login the server starts ignoring after a
burst.
2026-08-04 16:08:10 -05:00
KeelrLeek 819a2757bc artist page: five sections after the hero, and nothing moved to fit them
Latest release, Releases by year, Fans also like, In your library and
About slot into the page between what is already there — hero, Popular,
Discography grid and all — and CatalogArtist gains three lines: an import
and two placements. They live in ArtistExtras so Svelte scopes their
styles away from this file, which matters more than usual here: both
files name a .hero-bg and a .card, for completely different things.

Every section is fed by a call the app already makes. catalog_related is
Deezer's related-artists field, literally commented "Fans also like" and
simply never rendered on this page; the bio and profile calls already
run; latest and the timeline come out of page.albums. Nothing here needs
a provider that did not exist before. Latest release is CatalogSearch's
top-result hero — the app's only surface for presenting one item
prominently — with min-height 0 (there is no stat row to hold space for)
and a 6px artwork radius so it rounds like a record; it tints from its
OWN cover, scoped, so it never wears the playing track's colour. Fans
also like reuses this page's .grid/.release markup, so its columns are
the same 150px tracks as the discography above it, drifting 0px on all
six.

The release grid's min-height reserve had to learn about them. That
reserve is the tab-pinning contract — everything under the pinned tab bar
must span a pane-height — and when the grid was the last thing on the
page the grid had to supply all of it. It is not last any more, so both
reserves now discount the real content below it: without that the grid
stood on 206px of empty min-height under its final card, a hole nobody
could see until something rendered after it. The discount is
parent-bottom minus grid-bottom, which is invariant to the grid's own
min-height because both edges move with it, so the reserve converges
instead of chasing its own write; when nothing follows the grid it is 0
and the figure is exactly the old one. The grid's parent joins the
ResizeObserver, because the sections land asynchronously and grow it
without touching the grid's box or the pane's.

An earlier attempt pulled the sections up by a measured slack instead.
The grid reflowed after the measurement and they landed on top of the
release cards. Reverted: prefer a fix that can only be a few px wrong
over one that can be catastrophically wrong. Verified: expander open and
close leaves scrollTop at 1571, and the tab bar sits at 231 across
switches.
2026-08-04 14:43:52 -05:00
KeelrLeek 520e2ad4e4 follow: the artist page's heart becomes a real thing, stored and celebrated
Following an artist now writes a row: followed_artists (artist_id, name,
portrait, followed_at) beside the playlists in the library db, reached
through follow_artist / unfollow_artist / followed_artists, each emitting
soulseek:follows-changed. Re-following refreshes the name and portrait —
identity improves as pages are visited and a later portrait is usually
the better one — but keeps the original followed_at, so "following since"
stays honest. Name and portrait are stored on purpose rather than looked
up: the home page's "new releases by artists you follow" can then draw a
card from the row alone, with no catalog request per artist.

The store is the UI truth and the chip never keeps its own copy. It is
optimistic — the chip flips at the click and reverts if the write fails,
rather than leaving the screen telling a lie.

The chip itself (FB4) leads the stat row and wears the accent as ground
with dark text, the Play pill's own trick, so its on-state is legible
across the room. The lead slot is a fixed 14x14 in which the +, the
equalizer bars and the check swap places, so nothing about the chip's box
moves except the word — and the word morphs between its measured widths
instead of snapping. Clicking runs the Encore: the + collapses, three
bars dance three beats with a note rising per beat, they bow, and the
check springs in as "Following" grows into place. The db write and the
toast fire at the CLICK; the ceremony is presentation only, so
navigating away mid-show still follows. Unfollow is an undo — the shrink
morphs, nothing celebrates, and the toast says so.

Every effect is template-owned with scoped keyframes. The first build
spawned the glow and the notes imperatively and referenced -global-
keyframes, which silently did not resolve: the glow ran no animation and
sat at full opacity, the notes held opacity 0 and never appeared. The
bars worked throughout for exactly the reason the others did not.

The row around it is shorter too, at Kyle's call: the library and
listening chips (progress meter, the disk popover, format, hours, last
played, top track, genre) come out, leaving the identity trio and the
socials. With nothing left to shed, the socials' scroll-driven fade and
their layout drop at the park boundary come out with them, and the
divider's clear space moves from the follow heart by name to whichever
chip now closes the run. The JS that fed the removed chips stays — it is
cheap, local, and the numbers still have a home coming.
2026-08-04 14:43:27 -05:00
KeelrLeek 5786269592 player bar: it grows upward, the cursor is marked by the wave itself, and the shape you leave it in survives a restart
Expanding (mock E1+T1, cubic-bezier(.22,1,.36,1) over 350ms to 90px):
`.app.player-expanded` sets --player-height beside the sidebar's own rule
and grid-template-rows joins the transition, so the bar grows into the
content and its bottom edge is pinned without a rule saying so — the
grid's height is the window, the bar's track takes 28px and 1fr gives up
exactly that. Inside the bar four blocks are absolutely placed so the two
layouts can interpolate; flex cannot, because one row to a centred
two-row stack means changing flex-direction, which does not animate. Name
and extras sit at top 50% with a -50% translate and are centred at every
height in both states. --btn-w and --extras-w are measured once rather
than assumed: extras is 180px, not the 170 the layout was drafted with,
and it differs between a stream and a library track. The toggle takes the
download button's seat (the sidebar's .gicon, verbatim) and Save to
library moves into the ... menu under the same isStream gate. Measured:
name and extras vertical error 0 in both states, transport and lane
horizontal error 0 expanded, lane exactly 10px under the transport.

The 1px playhead tick is gone from all three painters. The bar under the
cursor lifts while its neighbours dip (A4): growing alone reads as
nothing, because the median bar is 21.3px of a 24px lane and 87% of bars
have no headroom left to grow into — the dip is what carries the
silhouette. The wash is enter/leave only, one shared presence ramp with
hard span membership, so the edge cannot trail the pointer: 0-bar lag
across five jumps in both directions, and moving inside the lane
schedules no frames at all. The settled painter keeps its cached-Path2D
grouped fills whenever no pointer is on the lane.

The clipped-name edge fade from 2a83615 had never once rendered. Its
classes are applied by classList.toggle inside an action, which the
compiler cannot see, so Svelte deleted the rules as unused — that warning
is a deletion notice, not advice. :global() on the mask class only, with
the anchor left scoped, and all four states now verify against a
narrowed box.

Panel shapes persist in one key, soulstream.panels.v1, holding
nowPlaying, sidebarCollapsed and playerExpanded together so the three
cannot end up half-migrated against each other; it is read per-key so an
older blob contributes what it has. showNowPlaying() exists because
openQueue assigned ui.nowPlaying directly and would have skipped the
write.
2026-08-04 14:42:54 -05:00
KeelrLeek 2a83615958 player bar: one fixed name box — the transport never moves again
The content-hugging cluster made the whole bar jump between songs; the
capped-and-scrolling mockups read as either dead space or perpetual
motion. What stuck (namehover.html, 2026-08-03): the name box is a
CONSTANT 240px — meta width and NAME_BOX_PX, paired — so the transport,
the lane, everything right of the cover sits at the same pixel whatever
plays. Verified across a long library title and a short cached one:
meta 240, cluster 291, play button x 387, identical every time.

A name that fits simply sits in the box. A longer one rests under an
edge fade — a gradient mask, never an ellipsis; every truncation rule
the old layout needed (the 55% artist ceiling, the album-gives-way flex,
the ellipsis trio) is deleted, because the box owns clipping now. While
the pointer is over a clipped name it glides out at 40px/s, holds 1.1s,
glides back, repeats; it eases home the moment you leave, and the
gradient only ever sits on the side actually hiding text. Both lines —
title and artist · album — glide independently, and the tooltips still
carry the full words.
2026-08-03 18:03:13 -05:00
KeelrLeek 8cd56a433e player bar: the album line survives library plays, and lands with the title
Two halves of one defect family. The meta line preferred the queue item's
artist/album fields as a PAIR whenever either existed — but a library play
(playAlbum) writes only `artist`, with the record's name living inside
`sub` ("artist — album"), so `album: null` was taken at face value and
every library album lost its name from the bar (and the morning comment
claiming local rows carry neither field was simply wrong). Each half now
falls back to the `sub` split on its own, here and in the rail's byline —
which also restores the album for continuation items whose `sub` is a
bare artist.

And the line no longer trickles in: the buffering branch used to render
the artist alone, so the album flashed in a beat after the title when
playback started. The status gate is replaced by a REAL distinction — a
resolver-phase track has no `path` (playQueueIndex always writes one), so
"finding best source…" still shows as a bare label, and the moment a real
queue item owns the transport the full clickable line renders, buffering
or not. Measured: the first frame carrying the new title already carries
artist · album; no artist-only frame exists.
2026-08-03 17:18:25 -05:00
KeelrLeek e2cfed081c player bar: the track-change morph stops being a coin flip
Two independent fragilities each collapsed the shape→shape morph back to
a grow-from-dots, which is why it only sometimes worked.

The hold was 600ms against an arrival whose timing is DECODE time: the
same track costs ~5ms once the session holds its waveform in memory but
hundreds of ms to over a second on the first cold decode. 2000ms covers
the cold case with room; fresh-stream searches take well past that, so
the loading story still gets the lane.

And the hold bailed on a bar-count mismatch — but the lane's width follows
the song TITLE now, so switching between a long and a short name resizes
the lane mid-gap and the guard threw the held shape away. Held and morphed
heights are refitted to the new count instead (nearest neighbour — it is a
moving start point, on screen for under a second).

Four consecutive cached switches measured after the fix: minimum lane
height 15.1 on every one (dots are ~2, the old dip hit ~5). Gapless
playback is untouched — the hold and morph are pure painting, and the
sink-append path never waits on the lane.
2026-08-03 17:07:33 -05:00
KeelrLeek 0bbdb1bb0e player bar: track changes morph shape into shape, not up from the dots
The arrival morph honestly started from whatever the lane showed — and on
a track change the lane always showed dots, because the waveform empties
the moment the queue moves. Every switch therefore played the same
grow-from-zero, when the picked motion was one waveform transforming into
another.

A track change now HOLDS the outgoing shape for ARRIVE_HOLD_MS (600ms),
painted in unplayed grey since the new track owns the transport. A quick
arrival — a local file, a cached stream — lands inside the hold and the
morph runs directly from the old bars into the new ones; a slow stream
load falls to the dots-and-swell story when the hold expires, exactly as
before. The rail mirrors the hold with the same shared constant, and
suppresses the loading swell while holding so the keyframes cannot fight
the held heights.

Measured across a cached track switch: the lane's mean height never left
16.9-17.4 (the dots sit at 2, the old dip hit 5.2) while the per-column
fingerprint changed — the shape transformed without ever falling.
2026-08-03 17:01:59 -05:00
KeelrLeek 1765de65cf player bar: a complete waveform arrives as one motion instead of popping
Chosen on waveform-arrive.html (M2, direct morph, 1000ms): when a track
has its whole waveform at once — a local file, a fully-cached stream, or
the final decode replacing a stream's progressive peaks — every bar eases
from whatever the lane currently shows straight into the new shape, all
together. From the resting dots that reads as a uniform grow; from a
mid-sweep swell or the previous track's bars it is literally one waveform
transforming into another. The streaming rise keeps its left-to-right
story untouched; the morph fires only on a peaks swap at coverage 1, and
never on a resize, which re-lays the same shape and stays instant.

The canvas keeps a record of what it actually painted last frame — dots,
swell, rise, morph or settled — so the morph always starts from the truth
on screen, and the fade colours (accent, boundary blend, hover wash) stay
live through it. The rail mirrors the motion in CSS: an `arriving` class
carries the same 1000ms house-ease height transition, placed after the
rise rule so a stream's final decode arrives on this ease too.

Measured live on a cached start: bar heights walked 5.2 → 8.2 → 10.6 →
12.3 → 13.6 → 14.6 → 15.0 and settled at 15.1 at the 1000ms mark — the
ease-out curve, frame by frame.
2026-08-03 16:46:37 -05:00
KeelrLeek b33d403634 player bar: the name sets the width, and the waveform stops flattering the clamp
The source chips are gone — the rail already tells that story — and with
them the measured slot, its effects, and the bar's only container query.
The now-playing cluster hugs the song name with no cap, giving ground only
once the scrubber hits its 260px floor; the artist's 55% ceiling applies
only when an album sits beside it, so a lone loading artist no longer
renders as two letters in a twelve-pixel box.

The waveform is rebuilt on honesty. WAVE_BUCKETS rises 400 → 2000 (~128ms
per bucket — at 400, every display bar was ~2s of audio averaged twice),
and one shared shaping in waveshape.js replaces both lanes' gain-into-clamp
rule, which flat-topped 53.8% of the bars on a real decode and was propped
up by per-bar noise in the rail — deleted; the lane shows the record, not a
hash of the bar index. Bars fold with a mean+peak blend and stretch across
the track's own p05–p97, so a loud master stops pinning to the ceiling and
a quiet one stops vanishing.

The playbar copies the rail's fade verbatim: whole bars blend accent→grey
with cuts landing in the 4px gap, and the one bar under the playhead
crossfades toward whatever its neighbours are — the wash while hovering,
the muted grey otherwise. The redraw gate watches that blend instead of a
0.75px playhead move, so both lanes now step on every position event.

Loading is drawn, not implied: the solid fallback bar is gone and the lane
is always the bars, always seekable. Empty, it rests as the rail's dots; a
swell crosses them while a waveform is computed (2s sweep, 0.5s rest); a
streaming track's bars stand up left→right on a 40ms cadence, each rising
over 2s. The rise frontier is the waveform's OWN coverage, reported by
stream_waveform beside the peaks — the old contract zero-padded partial
peaks to full length and let the UI fold them against a fresher byte
count, which stretched the whole shape between refreshes. Where the bars
end is how much of the track has actually loaded; cached and local tracks
arrive at coverage 1 and pop in complete. The first partial is also
requested eagerly (fresh refresh clock per track, 700ms retries until
peaks exist), closing most of the audio-before-lane gap.
2026-08-03 16:08:26 -05:00
KeelrLeekandClaude Opus 5 9aeb6c39c7 player bar: one row, aligned to the sidebar's column, and it says who is sending
88px becomes 62px and the two stacked rows become one. The 26px goes back to
the content area on every screen, and the scrubber stops capping at 560px while
the flanks stretch — a 1900px window used to put a small waveform adrift in the
middle of an empty bar.

The cover is 40x40 at 12px of padding, which lands its centre on x=40: the
column every sidebar cover and nav icon already shares, and which the sidebar
holds through its entire collapse. It was 56px at 16px, centre x=52 — twelve
pixels out of a column the app is otherwise strict about.

The waveform is drawn as discrete bars on the Now Playing rail's exact pitch, a
2px bar on a 4px gap with a 1px radius, both deriving their count from that one
pitch so neither can drift from the other. Mirrored here, where the lane is
short and wide; the rail stands its bars on the floor because it sits above a
caption. The standing white playhead went with it — at this pitch it read as a
seam through the bars, and the accent's own leading edge marks the position.

Artist and album share one line as "Artist · Album", the `.who` idiom from the
album page rather than a new one. They are read from the queue item's OWN
artist/album fields now: `sub` is a display convenience that the album
continuation fill writes as a bare artist while carrying the album right
alongside, so a record played straight through lost its album name from the
second track on. When the line must shrink the ALBUM shrinks — proportional
shrinking turned a short artist into "Po…", which saves nothing and reads as
nothing.

New: the source line. Who is sending this track, how much has arrived, how fast
and how long is left, then the format. Four states, derived exactly as
NowPlaying derives its status line — two surfaces disagreeing about whether a
track is cached would be worse than either being wrong. A library file reports
NOTHING, because it has no peer and no transfer and zeroes would read as a
stalled one. It sits in its own slot rather than inside the meta cluster:
sharing that budget let a live transfer readout crowd the artist and album down
to "Po… · Terrestria…". Its width is measured per STATE and never per value, so
a ticking percentage and a counting-down ETA move nothing.

The now-playing panel toggle is gone from the shelf — the panel carries its own
collapse control, and two ways to do one thing is one too many. What replaces it
is a single overflow menu, so landing lyrics, a mini player and output switching
later costs no width; those three are present and disabled, marked SOON, rather
than wired to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:04:11 -05:00
KeelrLeekandClaude Opus 5 b173f60085 search: the results page, the typeahead, and a session that survives a reload
The page is the signed-off prototype built out: floating rounded hero, inline
top-tracks panel, filter chips, two-column Songs|Albums beside Artists, and all
seven states — results, searching, no results, one-kind-only, library-empty,
provider-unreachable, first visit. MusicBrainz records are merged in silently
behind the Deezer rows.

The top result is ranked by POPULARITY, not by kind: each kind puts up its most
popular exact match and the winner is whichever holds the largest share of its
own kind's leader. A raw fans-vs-rank comparison was tried first and handed
"panda bear" to an Owl City song.

Two provenance traps are paid for here. Deezer's /search/album returns no
release_date at all, so sorting newest-first ordered by PROVENANCE rather than
by date; dates are backfilled from the artist overview, resolved PER ALBUM
ARTIST because Deezer's artist search answers nothing for "weezer blue album".
And the typeahead holds its keyboard selection as the row's KEY rather than its
index, because the MusicBrainz supplement lands ~600ms later and re-orders the
album group under the cursor.

The hero's middle third is hidden on the width of the PANE, not the window. The
sidebar and the now-playing rail take ~490px between them, so a 1504px window
can leave the hero 1012px — a window-width media query cannot see that, and the
"latest release" card ended up sheared down to 26px of itself. The budget is
written out at the @container rule.

Session restore rounds it off: reload or restart returns to the same place with
the same track, everything but the transport coming from localStorage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:03:45 -05:00
KeelrLeek dde8c7510b albums: one page for every record, and streamed history rows that actually open
Three things were wrong with opening an album, and only one of them was the
one that got reported.

The reported half: clicking a local record in the sidebar's history went to
the old in-library album view. Local albums carry no catalog identity of any
kind -- `albums.mbid` is null and is not even in the DTO -- so there was
nothing to route with. openAlbumByName already existed for exactly this
(the now-playing links use it, and it requires BOTH artist and title to match
normalized, which is the namesake guard); it now reports whether it resolved
and caches settled answers, so a second click is instant.

The unreported half, and the reason "half of them are not working": every
STREAMED row was dead. catalog_album_id is TEXT in SQLite, so it arrives as a
string, and `catalog_album` takes a u64 -- the invoke was rejected outright
with "invalid type: string ... expected u64". Terrestrials, OK Human and 9 all
failed silently on click. The id is coerced in checkedAlbumPayload now, where
both other string sources (page seeds) pass through too.

And the part that made the first half unfixable: a record no catalog carries
had nowhere to go. Panda Bear's "A ? of WHEN" is not on Deezer, so no amount
of resolving would ever open it. Local albums now render on the SAME page,
composed from the library's own data -- its tracks, durations, bitrates and
the cover already on disk -- under a namespaced `lib:` id that can never
collide with a Deezer one. Playback stays local: Play and any row queue the
files you already have rather than sending a record you own back through the
resolver. Save is not offered (it is already saved), previews are absent
(there is nothing to preview), and the attribution footer credits your
library rather than claiming Deezer + MusicBrainz.

With that, the old view has no callers and is deleted: the album-page block,
its CSS, and the members that only it used. Nothing anywhere routes to it.
Verified live: all six history rows open the new page, a Home card opens it,
Play queues 14 local FLACs without streaming, and the back arrow returns.
2026-08-01 07:43:50 -05:00
KeelrLeek 3c5bccf1b6 recents: a streamed album has no library id, so the key must not pretend it does
The sidebar history looked completely dead -- albums never appeared, the
list never updated, and often nothing was there at all. The backend was
fine the whole time: 460 play rows, recent_albums returning every album
correctly.

The list is a keyed each on `a:${album_id}`, but catalog-anchored rows --
every streamed play -- carry album_id NULL by design and are identified by
catalog_album_id. One streamed album in history keyed `a:null` harmlessly.
The second one collided with it, the keyed each threw, and the whole list
unmounted; every later refresh fed a dead component. It demoed clean
yesterday only because history had just been wiped and exactly one
streamed album existed.

Keys now carry the id realm, the same two-realm split isPlaying and
openRecent already used.
2026-08-01 07:11:25 -05:00
KeelrLeek 69f64c11eb previews: the glow goes, the eq steps, and every clip lands before you scroll to it
The track preview modal could hold a machine at 25% GPU. Three separate
causes, each measured in isolation with a kill-style ladder and, once the
GPU-engine counter proved to be clock-relative (a lighter workload read
HIGHER), with clock-immune process CPU-seconds.

Idle drain. The scrim's backdrop-filter is re-executed on every presented
frame -- a backdrop filter is never cached -- so its cost is presents/sec
times window area. Two infinite animations kept the modal presenting at the
display's refresh rate forever: the eq capsule's bars animated `height` (a
layout property, so style/layout/paint on the main thread every vsync) and
the cover glow's halo-breathe kept a blur(48px) copy recompositing. Either
one alone kept the pipeline hot, which is why killing one changed nothing.
The glow is deleted outright at Kyle's request; the eq bars now scale with
a stepped transform whose three delays are multiples of the step interval,
so all three jump on the same instants and one present serves them all.
Playing steady state: 7.2-10.5% of a core down to 1.4-2.2%. Paused: 0.2%.

Scrolling. The animations turned out to be innocent here -- reverting them
moved the number not at all. The cost was per-step work: re-decoding the
full artwork for the background wash, a main-thread accent scan, a new
cover mount, three times a second. The wash is now a 96x58 canvas with the
blur/saturate/brightness baked in at draw time and cached per artwork, and
an overlay preloader walks the manifest once at open -- nearest to the
active card first, capped at 60 -- filling the decoded-image, wash and
accent caches ahead of the user. A rapid-nav jump-cut was tried and
rejected on sight; the animations stay full at every rate.

Backend. preview_resolve swept the clip directory on every call, and any
landing outside the +/-1 prefetch window paid a full Deezer mint, download,
validate, write and loudness measure. The fetch-store-measure core is
factored out and a new preview_prewarm walks the session's track ids at
open, paced 250ms on top of the existing Deezer throttle and starting 1.5s
in so the first clip owns the network, halted by a generation guard on
dispose. A burst landing on a never-visited track is now audible in 154ms
off local disk. The sweep is throttled to ten minutes, and dispose deletes
prewarmed files instead of orphaning them until the hourly reap.

The debug overlay's GPU row was summing the counters machine-wide, so it
read roughly double the app's share with Discord and DWM folded in. It now
reports `app X% - sys Y%`, the app figure restricted to our process tree,
matching Task Manager's per-process rows.
2026-08-01 07:11:17 -05:00
KeelrLeek eba29824c9 sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane 2026-07-31 18:21:04 -05:00
KeelrLeek 52f5878297 loudness: a first play starts three quarters of a decibel under the prefix
The prefix estimator is BIASED, not merely noisy, and the sign is known: a
quiet opening under-reads the track, so the head asks for too much gain far
more often than too little. Subtracting a constant moves the whole
distribution toward the truth, so a first play begins nearer its real level
and the correction that follows has less ground to cover.

Measured by a new `head_start_offset_sweep` over 1046 tracks sampled across
182 artists — at most six per artist, spread across each discography rather
than taken from the front, since openers are where intros are least typical
and intros are the whole source of the bias. The cached corpus it replaces
was a dozen artists and put the median at +3.48 dB. Sampled diversely it is
+1.55, so the old number was overstating the bias by more than double.

The size comes from the MARGINAL trade, not the smallest mean error:

  step         loud fixed   quiet created   ratio
  0.0 -> 0.5          -89             +41   2.2 : 1
  0.5 -> 1.0          -83             +85   1.0 : 1
  1.0 -> 1.5          -61            +132   0.5 : 1

Only the first step is clearly worth taking. mean|err| bottoms out at 1.5 dB
instead and is the wrong objective: it weights an error that begins decaying
at once the same as one that waits for Analysis::Complete, which on the
streamed first plays this path exists for is download-bound and therefore
late.

A constant is deliberately a weak lever. The per-artist median error runs
from -4.58 dB (Boards Of Canada) to +7.03 dB (Ponytail); no single number
fits everyone, and an offset shifts that spread without narrowing it. The
levers that would are letting the follow recover UPWARD, and predicting the
offset per track from the prefix. Both still open.

The overlay reports it as a MOVE — what the prefix asked for, where the
track actually started, and a ghost tick on the dB scale at the prefix
position, so the gap to the applied gain IS the head start. Gated on
`analyzing`: at Analysis::Complete the player releases to the whole-track
gain while loudness.gainDb goes on reporting the prefix value, which it
keeps for the next play. Without that condition the line advertised a
correction that had already been released.
2026-07-30 07:39:44 -05:00
KeelrLeek 3f4f5c305a now playing: the transfer moves into the bar, and the panel speaks the app's headings
The hover popover is deleted. It showed the peer, the rate, a percentage and
a second progress bar — every one of which the status line and the hairline
beside it already carry — so it was a duplicate you had to hover for. The two
things unique to it move up: the save action into the bar, and nothing else.
Deleted with it, the reveal action, its 250ms close grace and the
`.np-statuswrap` hit area, all of which existed only to make a popover
reachable across the 25px gap between the status line and itself.

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

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

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

The About pane gains a record head, a lede, stat tiles, pills and a source
line, all re-laid-out from data already in hand. The artist page's expander
gets the same lede and pills — no record head or tiles there, because its
rail already carries them.
2026-07-29 18:46:31 -05:00
KeelrLeek 1e35c5e039 loudness: correct the gain during the track instead of only guessing at its start
A streamed first play is gained from a prefix; every later play is gained
from the whole track. Measured across the 89 cached tracks, a 10s head asks
for a median 3.4 dB too much gain and is more than 1 dB hot on 68 of them —
that gap is the "first track is so much louder" complaint. The curve never
flattens either: even a 60s head is still ~1 dB out with a 6 dB worst case,
so no window size fixes it.

Live traces then showed something worse. The hard deadline was anchored to
the play REQUEST, so on a peer that took a moment to start sending, the 2.5s
ran out before a single gating block existed and the track began at unity —
no normalization at all, three plays out of four on one run. Releasing early
bought nothing in exchange: playback is blocked on the same bytes, so the
gate was discarding the measurement without making sound arrive sooner.

- The deadline now starts at the first byte, with an absolute 4s ceiling from
  the request so a slow peer cannot hold audio that is ready to play.
- The audio thread's slew drops 2.5 -> 0.25 dB/s. Mid-track correction was
  switched off in July because the glide was audible; at a tenth the rate it
  is not, and being 3 dB wrong for a whole track is the larger error.
- The correction follows the running measurement from its first publish and
  may only ratchet DOWN until the track completes. A convergence test lived
  here and was deleted: an integrated average over 30s cannot move much when
  one more second is added, so "3 publishes within 0.3 dB" was measuring the
  inertia of a long average, fired at its floor every time, and declared
  settled while 1.5 and 2.1 dB from truth. The protection was never the gate
  — it is the slew rate and the ratchet.
- Seeking lands the pending correction immediately. The slew is slow only
  because a gain change is audible against continuous music, and a seek is
  already a discontinuity.

Observability: SOULSTREAM_LOUDNESS_TRACE=1 reports every commit with the
seconds analyzed, which limit fired, the hold, and the bytes received; the
debug overlay gains a SLEW line showing applied gain, target, distance and
ETA, because one number cannot distinguish "settled" from "in flight".

Also adds an #[ignore] corpus harness that sweeps head-window bias in 30s.
2026-07-29 18:45:43 -05:00
KeelrLeek 0ea4da7146 now playing: the drag tracks the pointer, and the panel stops fighting its width
The resize grip. The panel already left the 0.28s ease for a drag, but the
grid column it lives in did not -- and an eased track list cannot follow a
pointer, because the grip moves the target every frame and each move restarts
the ease from wherever it had crawled to. Measured on the real gesture: the
column ran 122.6px behind the pointer and the panel overran the window on 29
of 30 frames, which is why the release grid was sliced off at the right edge.
With the column leaving the ease alongside the panel: 0px lag, 0 frames
clipped, 4.6ms worst frame. onGripUp is bound to lostpointercapture as well,
because a release the grip never sees would leave the app root pinned at
transition:none and kill every collapse animation for the session.

The header status re-ran its 300ms entry slide on every progress event. It was
keyed on the text, and the text carries the percentage, rate and ETA -- so it
replayed several times a second and the header flashed. Keyed on the transfer
PHASE now: 2 rebuilds across a real transfer instead of one per tick.

The waveform lane was unmounted while peaks were absent. player.waveform
empties the instant a track changes and a partial FLAC decode can take
seconds, so the times, the chips and the whole pane below jumped 54px up and
then back on every track change. It holds its space as a flat baseline, with
the same bar COUNT either way so peaks arriving restyle the existing elements
rather than replacing them. The bar count is derived from the panel width at a
6px pitch as well -- a fixed 78 bars was a 310px block that overflowed the
272px panel and left a third of the 560px one empty. Now 41 bars at 272 and 89
at 560, never more than 4px of the lane unused.

Fans also like was a fixed row of four circles spaced evenly: every name
truncated to "The Sma..." however wide the panel got, because the circles
stayed 64px and only the gaps grew. Name-sized pills that wrap use whatever
width there is, carry eight artists in less height, and truncate nothing at
any width.

Show all releases dumped 68 records in one flat date-sorted grid. It is filed
by the artist page's own ten categories now, empty ones dropped, editions
folded into their base, as dense rows with the year printed only where it
changes. NOTE: this panel classifies with less evidence than the artist page
-- no description hints, no MusicBrainz type map -- so a record whose kind is
only knowable from its description will file differently here until those are
routed through. Commented at the source.

Full-screen artwork from the panel opened playerArtSource, a string rewrite of
the provider URL to 1000x1000, with no coverUpgrades lookup at all -- while
the same record opened from a page gave the master. On the Red Album that is
1000x1000 against Apple's 3000x3000. It opens on the best it has and swaps the
master in when it resolves, guarded on the viewer still showing what it
opened.

Releases grid: cards align on their artwork again (a button centres its
content, so beside a four-line title the short card's whole stack sat lower),
titles clamp to two reserved lines, the hover outline samples the record's own
colour, and clicking one opens the album page instead of streaming it. The
artist chip's name was centred because a button centres text while the stats
row below is flex-aligned; it reads as left-aligned with them now. waveDragging
is $state, so class:scrubbing actually applies. The pane never scrolls
sideways.
2026-07-29 08:12:31 -05:00
KeelrLeek ae99757ddd hero: repaint on a resize instead of rebuilding the canvas for it
Collapsing the sidebar dropped frames only on the artist page. The cause was
one observer: the app grid animates its columns for 280ms, a ResizeObserver
fires on every frame of that, and this canvas answered each one with a full
resize() -- 171-224ms of main thread per toggle, 10-12 frames over 16ms, worst
frame 54ms. Muting that single observer took the worst frame to 4.3ms; muting
every other observer changed nothing.

Two independent per-frame stalls inside it. Reallocating the ~3200x1630
backing store was 115-205ms of the total, and the box moved about a pixel per
frame, so every one of those reallocations bought an invisible sharpness
change. gl.getError() after every draw is a synchronous round trip to the GPU
process, and it stalls hardest exactly when the GPU is busy compositing the
animation that provoked it.

reflow() is the observer's path now: it repaints into the CURRENT store and
matches the store to the box once, 140ms after the box settles. What makes
that correct is taking the cover crop from the DISPLAYED box while outSize
normalizes by the store -- the two may then disagree, because a crop chosen
for a 1.80 box drawn into a 1.96 store is stretched by exactly the ratio the
compositor undoes painting store into box. Geometrically exact at every
intermediate width, no reallocation. The error check is skipped on that path
only; the settle pass that follows is checked, so a real failure still
surfaces.

After: 1.5-2.4ms per toggle across 46 calls, max 0.2ms each. No frame over
33ms, 0-2 over 16ms. The Now Playing collapse rides the same transition and
improved with it.

resize is no longer exposed: every caller outside this file is reacting to a
box that may still be moving, and that is reflow's job.
2026-07-29 08:11:20 -05:00
KeelrLeek e334cd51c2 now playing: collapse to a rail, and rebuild the panel around the record
The right-hand panel disappeared when you closed it. It collapses to a rail
now, exactly as the left nav does -- same 0.28s curve, same crossfade clock
for its contents -- so the record, its progress and its transfer stay on
screen at 64px instead of sliding off.

THE RAIL carries the album as a full-height bleed behind a real glass bar
(the app's own recipe: rgba(20,20,24,.28), blur(12px) saturate(1.7), 46px),
with the expand chevron in that bar, a left-edge progress line, the cover,
an equaliser while playing, stacked icons badged with what is behind them
-- tracks left, releases -- and the title set vertically.

THE PANEL stops saying "Now playing", which was the only permanently
visible line it had and told you nothing. It names what you are playing
FROM, and carries the transfer as a hairline along the glass bar's bottom
edge -- the same object in both states, so a download reads identically
whether the panel is open or collapsed. The hairline fills linearly (it
tracks progress rather than easing past it), sweeps indeterminately while
there is no byte count to be honest about, and fades out when cached rather
than sitting full forever. Hovering the header drops the whole detail:
peer, transferred, rate, and saving what you are streaming. A library track
has no peer, so none of it appears -- zeroes would read as a stalled
transfer.

Below the record, one long undifferentiated scroll becomes four shallow
panes -- Queue, Lyrics, Artist, About -- behind a sticky segmented control.
Sticky because keeping your scroll position across a switch is worthless if
the control you just used has scrolled out of reach. Queue gains an up-next
card, library ticks, and per-track Last.fm listeners past 396px; Artist has
the artist as one full-width pill whose figures spread once there is room,
the same branded social circles the artist page draws, releases with Show
all, and fans-also-like; About carries the record's description and the
album page's RELEASED block verbatim, so a record reads the same in both
places. Lyrics is a placeholder -- there is no source in the app and
pretending otherwise would be worse than saying so.

The panel is drag-resizable from its left edge, 272-560, with a 10px magnet
at the default and a width tag while dragging; the width is remembered,
because one you set by hand and lost every launch is worse than not being
able to set it.

Five things this fixed on the way, four of them mine:

- `playerArtSource` resolved a "better" cover by ARTIST + TITLE. A name
  cannot identify a record: every self-titled Weezer album is "Weezer", the
  resolver returned whichever one the provider answered with, and all of
  them played back showing the Green Album's art. The substitution is gone;
  the record's own asset is rewritten to its 1000x1000 variant instead --
  a real quality win, no identity risk, and more than the panel needs even
  at its widest. Verified: Blue and Green now resolve to their own hashes.
- The panel body was torn down 300ms after closing, an optimisation from
  when it slid off-screen. The rail shows that artwork, so it blanked the
  rail's cover and its bleed.
- An inline --nowplaying-width would have outranked the collapse rule and
  made the panel un-collapsible; the dragged width goes through a
  --np-open-width indirection instead.
- The segmented control was an opaque strip painted straight over the album
  bleed, reading as a grey band across the artwork. It is glass now.
- The waveform was a picture of a scrubber with no way to seek, while the
  player bar carried the only one. It seeks now, on the same semantics
  (75ms throttle dragging, exact seek on release), and the second time
  reads remaining rather than total -- the total is already implied by the
  bar's length.

`soulseek:stream` reports {received, total, done} and no rate, unlike the
download path's speed_bps, so the speed behind the hairline is measured
here: a rolling 1.5s window, because a per-event delta reads as a number
flickering between 0 and several MB/s rather than a speed. There is
deliberately no "queued at peer" state -- the streaming path reports no
queue place, and the panel will not claim to know one.

`discovery` keeps the artist page's socials rather than discarding them;
they were already being fetched. 29 CSS rules for the removed sections are
gone with them.

Verified in the running app over CDP: collapse to exactly 64px and back,
rail contents and bleed at blur(17px), all four panes, sticky segments,
social chips carrying the artist page's own brand tints, the release grid
going to three columns past 430px and the pill's stats spreading past
372px, and About reading "Released May 9, 1994 - 10 tracks - 41 min / DGC -
Alternative Rock". 85 JS tests pass, production build clean with no unused
selectors. The drag gesture itself is not CDP-drivable and wants a
hands-on check.
2026-07-28 19:15:15 -05:00
KeelrLeek 35729c1f9e navigation: state the bar's geometry instead of collapsing a margin for it
Hoisting the glass bar out of the pages (8017c0d) dropped the artist page's
hero 24px, so the artwork began below the bar instead of running under it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three details that are not obvious:

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

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

Verified in the running app over CDP: artist top-tracks preview -> click ->
lands on Highway to Hell (id 9410086), pendingTrackId 92719900 as a Number,
matching track found on the page, .trow.flash present, modal gone, and the
glass bar trail reading Search -> AC/DC -> Highway to Hell. From an album
session the button is disabled and a click changes nothing. 85 JS tests
pass.
2026-07-28 17:13:54 -05:00
KeelrLeek 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 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 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 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 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 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 d051f0db28 Phase 2C WIP: PreviewOverlay modal + root integration (reel centering bug)
Working:
- PreviewOverlay.svelte mounted at app root; opens over a fixture manifest
  (window.__previewFixture(n) in dev), inert background, player bar hidden,
  focus trap, keyboard nav, F3 rail, T3 swaps, R1b capsule, accent extraction.
- previewSession.svelte.js: store + service wiring (production + fixture).
- Root integration in App.svelte; revision-gateway bumps + notePlaybackGeneration
  threaded through state.svelte.js player entry points; api.win guarded so plain
  vite browser mounts.
- Launcher on the artist page builds a real artist manifest and opens the modal
  with a play-full starter.
- 70 JS tests still green.

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

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