Commit Graph
18 Commits
Author SHA1 Message Date
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 f55052506a library: three views of the collection, and what every file actually is
Kyle picked this off an interactive prototype (research/library/) over a
long evening of passes, and the settings he landed on are baked in rather
than left as choices: records no catalogue knows about are ordinary
citizens with no marks at all, artwork is generated from a record's own
name when there is none, the albums you do not have are one collapsed
line that opens into full-size dimmed cards, an artist's shelf splits by
release type, the toolbar is a floating card, the index rail is always
on, the List view's column header is welded to that card, the entrance is
a wave of records, and cards are the artist page's own 150px.

The artists accordion is gone. In its place: three views (Shelves, Grid,
List), six groupings (Artist, Decade, Format, Type, Added, None), thirteen
sorts that are independent of the grouping, a live filter that moves every
number in the header, and a stat row that is derived rather than written
down.

A library page has to say what the files ARE, and library_albums only
carries id/artist/title/year/art/count/added. So the library crate gained
album_stats: one pass over tracks per call, aggregating length, bytes,
average bitrate, bit depth, sample rate and the dominant file extension
per album, plus the newest play that met a seconds floor the caller
passes (the same 45s the recents list uses — under it a play is a skip,
and a page that sorts by "never played first" must not count skips as
listening). Aggregated in Rust rather than SQL because the dominant
format needs the path's extension counted per album, which SQLite cannot
do without string gymnastics, and a library is a few thousand rows.

Filing a record as an album, an EP, a single, a live set, a compilation,
a soundtrack, a remix record or bonus material is not a new opinion:
localReleaseType.js carries CatalogArtist's own title heuristics verbatim
— the regexes are the tuned artifact — reduced to the branches whose
evidence a LOCAL record can carry. A file on disk has no MusicBrainz
release group, no editorial lead sentence and usually no record_type, so
the MB and description-hint branches are absent because their inputs
cannot exist. Under it sits the floor the prototype settled on: when the
classifier lands on "other" and the row carries no provider type, the two
facts a file always has decide — three tracks or under ten minutes is a
single, seven or under twenty-five an EP — with the session/demo check
re-run inside it so a five-track practice tape stays bonus material. It
is a copy, and it is marked as one at every function; unifying it with
the artist page is follow-up work, not something to smuggle into this.

Clicking a record opens CatalogArtist's inline expander, class for class,
with its numbers unchanged: the 460ms slide whose height is read live per
frame and kept monotonic (a captured height animates to a stale number
once the tracklist lands), the staggered waterfall that fades each part
to its OWN resting opacity, per-row entrances at min(i*16, 180), and a
same-row switch that keeps both panels alive while the stage's height
eases between them. What differs is the content, because a library knows
different facts: where the artist page shows Last.fm plays and listeners,
the rail shows size on disk and bitrate, where it offers Save an owned
record offers its folder, and each track carries what its file is.

Three things about the chrome are measured rather than typed, and each
one is a bug the prototype found first. The bar's height is read off the
bar, because a typed 50px parks anything pinned below it behind a bar
whose controls have wrapped; the bar's own minimum is a constant, because
publishing a measurement into the property that decides it is a ratchet.
The index rail's 34px gutter is reserved whenever the rail is on, drawn
or not, so no click on Group can change the width of the content column.
And the table's column template lives on the page rather than on the rows,
because a 1fr column floors at min-content and RECORD is wider than an
ellipsized title, so identical declarations on two elements resolve to
different widths; columns drop by tier from the page's measured width so
a record's name is never squeezed out.

The entrance is timed by POSITION, not by index. Indexing the document
spends the whole stagger on sections nobody is looking at — 38 shelves
means every visible one hits the cap and they all arrive in the same
frame, which has no relative motion in it and reads as no animation at
all. Delay comes from how far down and slightly across the pane a thing
sits, normalized to the topmost thing that is both moving and on screen,
and the delays are pulled forward over the next few frames as
content-visibility geometry lands (only forward, except while a thing is
still hidden, where a correction is invisible). The hero, the toolbar and
the column header never move: they are the page's furniture, and dealing
them in animated the whole window instead of the part that changed.

A rail click corrects until its target stops moving. content-visibility
makes the height of everything unpainted an estimate, so one scroll to a
section overshoots — measured on the prototype at up to 1143px, with the
page's own height moving 2260px during the jump.

Two bugs fixed on the way, both the same mistake: `>>` on an unsigned
32-bit hash goes negative above 2^31, so a generated track name indexed
past its word list and a generated cover's second hue produced an invalid
gradient. Both are `>>>`.

Deliberately not here. There is no Import button because there is no
backend command to import a folder — Rescan re-reads the library and its
stats, and a real importer is a backend feature. The "N of M released"
count and the missing-albums line appear only for artists you FOLLOW: a
follow carries an anchored artist id, and matching a local artist to a
provider by name is exactly what the identity rules forbid, so an
unfollowed shelf says what it holds and nothing more.

Verified against the running app on real files: 7 albums, 72 tracks,
1.8 GB on disk off the new command, and Maladroit opening to 13 tracks,
33:37, 279 MB, 1019k, FLAC 16/44.1 with per-track rates, its notch under
the card it belongs to and its row landing 10px under the pinned bar.
Verified headless against the mock library at scale: all eighteen
view-by-grouping combinations at one content width, the toolbar pinned
with zero drift, the joined header's columns matching the rows on both
edges, the expander opening from a card and from a row, morphing in place
within a row and closing without leaving its reserve behind, and the wave
moving 74 things with 34 distinct arrival times across 391ms while the
chrome stayed still.
2026-08-05 18:02:40 -05:00
KeelrLeek 034eac9569 home: a landing page built from the app's own parts
Approved on research/home-redesign after four passes: the Desk layout,
the Showcase polish, the welcome card, a 6-month feed window, news with
reviews filtered, and all three of the sections I proposed.

Nothing here is a new design. The release cards, their grid, the date
chip and its accent-glass recipe, the upcoming wording and the missing
play button on an unreleased record are the artist page's. The section
heads, the featured card, the artist cards and the list rows are
ArtistExtras'. The entrance is CatalogDiscography's edition deal with the
same four approved numbers, and the arrival rule with it — a null from
consumeScroll means a fresh arrival, which is the only thing that deals;
coming Back is a return to something already read. Only the rails, the
aside panels and the news list are new vocabulary, and those came off the
mock verbatim. The styles are copies rather than imports for the same
reason the discography page carries copies of the album page's: a page
that reaches into another page's stylesheet couples them forever.

The feed is what "new" honestly means. Deezer's release list merged with
the MusicBrainz identity supplement — A ? of WHEN is a Panda Bear record
that exists only in the supplement, and Weezer's upcoming album only
carries its real name (Gold Album) there — then deduped per artist by
cover and by base title, which collapses Weezer's BBC triplet and
Röyksopp's four same-day remix drops to one card each. Upcoming first,
then newest, with the age riding the artwork so the reader never has to
work out whether "new" means this week or last spring. Jump back in is
deliberately NOT deduped against it: a record can be new and also what
you were just listening to, and it belongs in both.

Everything reads caches. Assembling this feed cold costs one
catalog_artist call per followed artist — 48 seconds for nine, measured —
so home_warm walks the follows in the background through the same entry
points the pages use and emits per artist as it goes. The page fills in
progressively instead of waiting for the last one, and a follow change
re-walks it.

Three sections were mine to propose and are now in: the back catalogue
(albums older than the window, one per artist first so a deep discography
cannot own the row), the library rail, and Complete the set — you own two
of sixteen Weezer albums and a new one lands in seventeen days.
2026-08-04 19:08:58 -05:00
KeelrLeek 34313e6035 discography: the whole catalogue on a page of its own
"Show all" beside the artist page's Discography head opens every release
as a stack of album-page heroes with their tracklists — the shapes Kyle
picked off the mock: the search page's top-result card as a compact
header, the album hero at --cover-scale 0.45 per record, a per-record
accent sampled from its own cover, the year rail and the tab summary. The
tab row does not stick and there is no Playing badge. Records the artist
page has not fetched are shown rather than hidden, and the entrance is
the W1 stagger — 300ms in, 55ms per row, 28px of lift, eight rows.

It does not re-file the releases. catalog.discoTabs is the artist page's
OWN computed tab list, published as it derives it and republished as the
evidence improves. It has to work this way: the filing depends on the
background description harvest that runs while you look at the artist
page and on the MusicBrainz type hints from the deferred identity pass,
neither of which is derivable from the payload — so a second classifier
would read neither and would quietly file some releases differently from
the page that linked to it. One list, computed once, handed over. If the
tabs in hand belong to somebody else, opening a discography lands on the
artist page instead, which republishes them and puts "Show all" one click
away; an empty discography would be a lie about the catalogue.

The page's CSS was extracted from src/ rather than retyped — CatalogAlbum's
record section, CatalogSearch's header card and PlayerBar's menu, pulled
through the same rule-matcher the mockup generator uses — so it inherits
the app's measurements instead of approximating them. Verified against
the running app: artwork 108px, record hero 114.3, header 147.5, header
top 78, bar offset 0, ambience banner 0, rail centring error 0 and 12.8px
of clearance at every scroll depth and pane width. Pond files 12+16+2+2 =
32 families on both pages.

It is the one page exempt from BAR_VIEWS' padding-top rule, deliberately:
its blurred ambience layer has to begin at the very top of the pane, and
a padding there would start it halfway through the glass bar. It takes
the inset on its header's margin instead, which lands in the same place.
2026-08-04 14:44:13 -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
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 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 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 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 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