Commit Graph
100 Commits
Author SHA1 Message Date
KeelrLeek 8093adc89e a room-key proposal signs the hash the verifier checks, and the ping's pill shortens a raw id 2026-09-03 13:52:04 -05:00
KeelrLeek 6f7452c803 a second instance on one machine: its own data, its own peer port, and a join that says itself again when it was never seated 2026-09-03 13:39:06 -05:00
KeelrLeek 67e52697b2 spaces: a member wears a key minted for that room alone, so a roster stops naming personas 2026-09-03 13:06:32 -05:00
KeelrLeek b3c121fa94 community: the inbox check belongs to the app, not to the page that happened to be open 2026-09-03 12:38:57 -05:00
KeelrLeek fd216ecd3d community: a proposal on your board arrives as a toast, carried sealed through the inbox 2026-09-03 12:16:33 -05:00
KeelrLeek 4d03cf849b community: every ledger takes its write lock, so the inbox check can no longer land on top of a friend accept 2026-09-03 11:51:35 -05:00
KeelrLeek 07227a90a7 the freenet layer: the app supervises its own peer, so a new release no longer quietly ends it 2026-09-03 11:12:41 -05:00
KeelrLeek 733e3b57a8 community: one writer at a time per ledger, and an inbox publish that bumps from what the network really holds 2026-09-03 11:12:41 -05:00
KeelrLeek 0846b39d45 community: the library overlap shelf, their share folders against your albums on this device after a browse 2026-09-02 17:08:25 -05:00
KeelrLeek d7bc92fda7 release: 1.7.0-alpha 2026-09-02 16:38:43 -05:00
KeelrLeek bf81792c49 the proposal board: a Space's pinned playlist takes signed proposals, and an accept republishes it 2026-09-02 16:33:38 -05:00
KeelrLeek 8b511b20e7 community: spaces, an owner-signed roster with epochs, a bounded window of signed lines, bodies sealed to a rotating key when private 2026-09-02 15:50:18 -05:00
KeelrLeek 4378c4c0cc community: the encrypted inbox, friend requests and shares travel sealed to a box key when the other side has published one 2026-09-02 15:26:30 -05:00
KeelrLeek 13c44f731a community: shared playlists, a signed revision and a pointer on the network, a reference over soulseek, an import through the normal door 2026-09-02 15:02:06 -05:00
KeelrLeek 984738135a community: friends, two signed answers on one request carried over soulseek, one answer is only ever pending 2026-09-02 14:40:33 -05:00
KeelrLeek 6ed6f5b8f8 community: the soulseek link proof, one message to yourself links the account and one question links anyone who answers 2026-09-02 14:27:54 -05:00
KeelrLeek cf9558cf26 release: 1.6.0-alpha 2026-09-02 14:03:28 -05:00
KeelrLeek 5160d97a5f community: the drawer asks a peer for its card and the server for interests, and discover gets the similar-users shelf 2026-09-02 14:03:26 -05:00
KeelrLeek 64a7561750 community absorbs the hub: search, transfers and browse ride the same pills, one entry in the sidebar 2026-09-02 13:46:57 -05:00
KeelrLeek 8c25f93afe community: one area for the people on both networks, and the hub grows search tabs, a folder column and a queue column 2026-09-02 13:43:16 -05:00
KeelrLeek 6dce4e2ac7 the freenet layer: adapter, persona and profile services, the contracts bundled as commitments 2026-09-02 13:43:16 -05:00
KeelrLeek 63a0357651 the soulseek hub: the whole Qt client as one area, wired to the wire
Kyle: "this is going to be a soulseek menu now — essentially the soulseek
app in our app." Approved against research/soulseek-search/hub2.html, a
one-to-one mock in the real frame, then built in three layers.

THE AREA. Search and Downloads leave the sidebar; one Soulseek entry
(globe) opens src/views/Soulseek.svelte, whose tab row is SoulseekQt's own
anatomy: Transfers, Search, Browse, Rooms, Chat, Users. The old view ids
stay routable — App.svelte aliases "search" and "downloads" to the hub and
the component syncs its tab off them — so the title bar's download ring,
"get on soulseek" hand-offs, history entries and restored sessions all
land on the right tab. The glass bar rides the hub with the connection
line on its right (online as X · sharing N files), the page takes Home's
below-the-bar inset, and the bar arrows join the chrome spec (16px glyphs)
alongside the sidebar toggle's disc. The replaced Search.svelte and
Downloads.svelte are deleted; Transfers carries their data forward in Qt's
own columns (User/File/Progress/Status/ETA/Speed/Size/Place, cancel and
play/reveal/add on the rows, clear-complete and open-folder in the
toolbar, live speed totals in the footer) and Search re-skins the peer
search as the Qt table: results regrouped user -> folder -> file,
slot/queue chips, format filter chips that actually filter, fold-out
files with stream-and-download on hover.

THE WIRE. soulseek-proto learns the rest of the protocol: rooms 13-17
(say/join/leave/user-joined/user-left), private messages 22/23, the
watch/status/stats family 5/6/7/36, the room list 64, and the peer-side
browse pair 4/5 with Directory::read to decode what our own responder
already encoded. Parsers are tolerant where the wire is baroque — roster
sections consume their own declared counts even when they disagree with
the name list, RoomList ignores the private-room tail, truncated stats
default instead of failing the frame. Codec tests ride along, including a
browse round-trip against our own encoder.

soulseek-client turns those into Events (RoomList/RoomJoined/RoomMessage/
PrivateMessage/UserWatched/...), auto-acks every private message the
moment it lands — unacked DMs are redelivered on every login — and grows
a spawn-shaped browse(): the returned future owns an Arc of the internals
so the caller drops the client lock before awaiting the 45s worst case.

THE APP. The event pump forwards everything as soulseek:* events; new
commands cover join/leave/say/message/room-list/browse plus a buddy list
persisted in config and re-watched on every login so status and stats
stream while connected. The frontend slsk store drains it all into the
four tabs: Rooms (joined + popular lists, live roster column, per-user
colored feed, say box — the server echoes our own lines so nothing is
appended locally), Chat (recency-sorted conversations, unread badges, DM
bubbles — our half appended locally since DMs are NOT echoed), Users
(add/remove buddies, status chips, one-click Browse/Chat cross-wiring),
and Browse (type a name, walk their tree, stream or download any file).

And the sharing side finally taps the shoulder: UploadToast announces the
first event of every new upload in the shared toast slot, folding a
same-user burst into one counting pill ("nikac is taking 12 files")
instead of twelve entrances.

Live-verified against the real network: the server handed 703 rooms after
login, and a browse of a live peer returned 3,796 directories / 34,773
files in 929ms with full paths, sizes and attributes. Join/say and DMs
are wired but deliberately unexercised — the first hello in a real room
is Kyle's to say, not a probe's.
2026-08-31 15:13:53 -05:00
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 0670b8654a hero commits wait for scroll calm, and a dev tripwire watches the rides
Kyle photographed the artist page interleaved with itself: the identity
block parked over the Popular rows with every scroll-driven paint effect
at its rest pose — wordmark unshrunk at full size, no fade on the rows
passing under it — while the sticky geometry carried on correctly. It
healed before it could be probed (no remount: a late measurement or
observer delivery snapped it back), and a controlled reload-restore came
up perfectly measured under a 14s watch, so the trigger was never pinned.

Two answers shipped:

THE GATE (backlog #3). The rotation TICK already skipped while the app
scrolled recently, but that gate was start-only: a cache-miss payload —
multi-MB raw-RGBA IPC + decode + texture/twin bake, a measured ~150ms
main-thread task — used to COMMIT whenever its promise resolved, an
arbitrary moment relative to input. All four commit sites (GPU ready,
preview paint, 4K finish crossfade, rotation/final) now route through
deferHeroCommit, which waits for the same 600ms calm the tick waits for.
The OLD banner and its twin stay mounted for the whole wait, so the mono
overlay never sits empty mid-scroll; first paints are exempt (an empty
hero is worse than a busy frame); a newer render supersedes a parked older
one so it paints once; switching artists drops any parked commit; and the
expensive snapshotGpuTwins bake rides INSIDE the deferred commit — it is
the very task the gate exists to keep out of a scroll. gpuHeroReady clears
heroLoadInFlight immediately (the load IS done; only the paint waits), so
a superseded unpainted commit can never wedge the rotation machinery.

THE TRIPWIRE (dev builds, dark glass only). Once a second, at scroll calm
so a mid-flick frame gap never false-trips: compare the gfade ride's clock
against scrollTop, and check --travel for the pre-measure sentinel plus
dead or missing timelines. Faults land in globalThis.__heroTripwire and
console.error as one latched snapshot per episode. Self-tested by
injecting the sentinel with a 120px scroll: tripped exactly
travel-sentinel within one tick while correctly NOT flagging the healthy
ride (ct 4.29% = 120/2797). Next time the page breaks in someone's hands
it hands over the culprit instead of a screenshot mystery.

Verified live: a natural rotation completed through the calm path
(heroGpuMs restamped 10.70 -> 29.60 during a fronted 26s watch).
2026-08-31 11:46:17 -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 75a95405a8 the acrylic rides move drags: only a real resize parks the glass at blur
Kyle: "when clicking and dragging the window the window gets light." It
did, by design that no longer had a reason: WM_ENTERSIZEMOVE parked the
accent at plain BLURBEHIND for EVERY modal loop, move drags included, on
the old assumption that a suspended acrylic punches through on moves the
same way it does on resizes. Plain blur ignores the gradient tint and has
none of the acrylic's material dimming — measured over a striped backdrop,
every glass surface stepped ~15% brighter (band luma 45.5 -> 52.2) for the
whole of every titlebar drag. And no compensation was possible: move loops
emit no resize-drag event, the frontend never hears about them.

Measured on this machine (research/glass-drag harness, SC_MOVE posted so
no synthetic mouse goes near the UI, GUI_INMOVESIZE as ground truth): DWM
composites the acrylic straight through a move loop. Re-asserted mid-loop
it restored rest luma EXACTLY (45.5/31.8/29.4), stationary and through 28
frames of real mouse-speed motion — no punch-through, no trailing.

So the swap is now gated on SIZING_LATCH: a titlebar move keeps the
acrylic, a latched resize parks at Blur exactly as before (DWM genuinely
suspends acrylic in live resizes — that half is load-bearing and stays).
The WM_SIZING belt-and-braces arm swaps the accent too, so a resize the
hit-test latch misses still degrades one mouse-move late instead of never.

Verified after: entering a move loop changes nothing to the decimal;
entering a resize loop still swaps (+6.7 band luma, the intended trade)
and restores on release.
2026-08-31 11:45:46 -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 15746893a9 restoreSession rebuilds the waveform and the cache badge from the backend, and a direct album arrival gets its artist portrait
Two bugs Kyle reported, both in what a session restore does NOT bring back.

THE LANE AND THE STREAM BADGE. Kyle: after the machine sleeps, "the playbar
waveform goes away and the track says streming even though its cashed". The
trigger is not sleep — it is the frontend restarting with a track loaded, which
WebView2 does on its own under memory pressure (and which a page reload does
too). Neither survives it: the waveform is measured out of the audio bytes and
was never re-requested, so the lane rested as dots while ALSO flagged complete;
and streamReceived/streamTotal reset to 0, which is what the Now Playing header
picks between "transferring", "Cached · size" and "Streaming from <peer>" with —
both at zero fell through to the last, for a file that finished downloading
sessions ago. His read was exactly right: it WAS cached and the label was lying.

Both are now rebuilt from the BACKEND's truth rather than the snapshot. The
restore fires the same final-quality waveform request the stream-completion path
uses (it answers from whatever the cache holds — full, partial or nothing, each
rendering honestly) and asks stream_cache_len for the real cached byte count,
setting the counters only when the cache is COMPLETE so "Cached" is never
claimed for a prefix. Local tracks get their waveform re-requested too; they had
the same dotted-lane bug. Note the guard compares path+peer, not object
identity: player.queue is a $state proxy, so the item read back is never === the
snapshot object the closure holds.

THE BREADCRUMB'S ARTIST PORTRAIT. Reaching an album directly (search -> album,
skipping the artist page) left a bare glyph where the artist's picture belongs:
trail() synthesises that crumb from the record's own artist NAME, and
metadata::CatalogAlbum carries only the name — no id, no picture. openCatalogAlbum
now resolves the artist seed after the page lands and re-records the crumb in
place (same key), so trail() reads artistArt/artistId out of it. The resolver
shares the album header's own cache and in-flight dedupe, so this is the lookup
the page mounting already pays for. It also heals the crumb's CLICK, which had
artistId null on every direct arrival.

Verified live: reloading the app in its broken state restored a 2000-peak
waveform and flipped the header to "Cached · 14.6 MB"; Daft Punk's portrait
shows on a direct-from-search album arrival. 98/98 tests.
2026-08-31 05:53:18 -05:00
KeelrLeek 73a677d35c one listener bar, five surfaces: the search page's pill wins and the heat wash is retired
Kyle asked for the popularity bars to match the search page "to keep a
consistent design across the board". They were two designs, and one of them had
quietly drifted into a third.

THE SEARCH PAGE'S PILL IS NOW THE APP-WIDE DESIGN: a visible
rgba(255,255,255,0.07) track, a solid var(--accent) fill scaled by the shared
curve, the full comma-formatted count standing centred on top, 17px tall with
a 4px radius. The album page, the artist page's open-in-place album panel, the
discography's record sections and the artist page's Popular list all draw it.

WHAT WENT AWAY IS THE HEAT WASH. Those three surfaces drove the fill's COLOUR
and ALPHA from the count (--t on the row, alpha ramping 0.30-0.85 via a double
color-mix, digits ramping with it), so length and colour told one story twice
and every page read as a different design. Length is the only encoding now, and
listenerHeat is deleted from listenerBars.js — nothing computes a temperature
any more. The dark-glass CONTRACT's deliberate exclusion for these bars ("their
alpha IS the data encoding") is moot and marked so.

THE DISCOGRAPHY HAD DRIFTED FURTHEST and is the reason this mattered: it
computed peak/heat inline with a LINEAR share, a 6% floor and abbreviated
labels ("12K"), against everyone else's COUNT_GAMMA 0.42 curve, browser-measured
max-content floor and full figures. It now imports listenerBars.js like the
rest, so no two surfaces can disagree about how big a hit is — the reason the
module exists.

THE POPULAR LIST joins them: it was plain right-aligned muted text, deliberately
not accent so the row's own `.now` accent stayed distinct (the ♫ rank and title
still carry that). Its column widened 62px -> 96px, the pill's width, and its
peak is taken over the WHOLE list rather than the visible five so the bars do
not re-scale on Show more.

Kept throughout: unknown counts render an em dash with NO bar (a floor-width
pill behind a dash reads as a measured tiny value), and a column drops entirely
when Last.fm knew nothing.

Verified live against the running app — computed styles on the album and artist
pages match the search bar value-for-value. 98/98 tests.
2026-08-31 05:53:01 -05:00
KeelrLeek 385b598170 glass survives the window gestures: the accent rides the modal loop and the maximize animation gets a bracket
Kyle reported the blur "being masked by just going opeque" on maximize, and
separately the desktop punching through a maximized window. Both were the same
transition, and the old guard covered neither honestly.

THE GUARD'S SIGNAL IS NOW THE MODAL LOOP ITSELF. thumbbar.rs already
subclasses the main window for the taskbar transport, so the size loop is three
arms on that existing proc: WM_NCLBUTTONDOWN carrying a sizing hit-test (or
SC_SIZE, the keyboard path) latches "this loop is a RESIZE", WM_ENTERSIZEMOVE
arms, WM_EXITSIZEMOVE releases, and WM_SIZING arms as a second net because this
window is undecorated and tao does its own NCHITTEST. Emitted as
soulseek:resize-drag, POSTED to ourselves rather than emitted inline: emitting
from inside DefWindowProc's modal loop reenters the wry runtime's window store
while that loop holds it.

The signal and the failure are then the same object, which is what the two
previous attempts were not. The first grounded on any DOM `resize` — one event
fires for a maximize, so the settled window sat flat black for ~70ms hiding
glass that was already back. The second discriminated on event CADENCE, and
that is the one worth remembering: on this 4-core laptop, re-rasterising every
backdrop-filter at each new size pushes `resize` deliveries far apart, and
measured 2026-08-28 events 150ms apart never grounded AT ALL — a real drag
stayed see-through, shipping the exact artifact the guard exists to prevent.
Cadence is not a proxy for the loop.

DWM SUSPENDS THE ACRYLIC INSIDE THE LOOP but keeps compositing the plain
BLURBEHIND, so the accent swaps down to Blur for the gesture and back on
release (window_glass refactored into apply_accent + an Accent enum; GLASS_ON
tracks the main window so the subclass can re-assert). The window stays glassy
through a drag instead of going black — which is what Kyle actually wanted, and
GROUND_DRAG is off on his verification by hand.

THE MAXIMIZE ANIMATION SCALES A SNAPSHOT the accent cannot ride: measured at
43ms/frame, desktop icons read sharply THROUGH the maximized window until
~613ms after the call. Disabling the DWM zoom animation removed the
punch-through outright and every frame of a ground-suppressed maximize showed
the blur soft and present — but that also removed the animation, and Kyle chose
the animation back with the canvas grounded across the recomposition. So
transitions are forced ON, the subclass brackets the jump (active at WM_SIZE, a
re-assert immediately and again at 650ms, release with the second), and
GROUND_DISCRETE paints the canvas for exactly that bracket. Both policies are
one-line JS constants in glass.js — flipping between "animation + dark bracket"
and "instant snap + live glass" needs no rebuild.

The DOM resize listener survives as the fallback for a build with no subclass,
reverting to the original behaviour; it stands down for good the moment one
native event is heard. Measured clean: 3 maximize/restore gestures, 3 brackets,
canvas never opaque outside them; a 9-second modal loop held with zero resize
events in it.

98/98 tests.
2026-08-31 05:52:43 -05:00
KeelrLeek 93271aceec release: 1.5.0-alpha 2026-08-27 17:25:19 -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 7ab678fd22 artist page glass: the hero fades INTO the frost - the burial moves from the sheet's paint to the photo's own alpha (gfade frame+counter: in-flow pass-throughs wrapping every photo layer, seam mirror excluded; a STATIC inverted-smoothstep mask parked at --fog-rest rides the sheet's glide on the frame while the counter rides the exact inverse, so the photo stays pinned, the mask never animates, and photo-visible-fraction equals the base fade at every pixel by construction); under glass the sheet's band goes transparent and its tail lands on the frost tint, so the same dissolve now lands on desktop glow - solid mode byte-identical (wrappers inert, from-poses no-ops); the wrappers must NEVER be positioned: the in-flow banner is what sizes hero-bg and absolute wrappers collapse the chain to zero 2026-08-26 16:26:59 -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 225c9139aa release: 1.4.0-alpha 2026-08-26 07:57:03 -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 7dfba58ea7 release: 1.3.0-alpha 2026-08-25 08:05:07 -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 ad2c912b71 release: 1.2.0-alpha 2026-08-24 18:12:02 -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 30eb6fe025 release: 1.1.0-alpha 2026-08-24 10:49:11 -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 4efe85b6bf release: 0.1.0-alpha.4 2026-08-17 18:06:04 -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 4b30c5e573 vite: bind the dev server to 127.0.0.1, where the app's WebView looks
Vite 6 on modern Node binds [::1] alone when no host is set, while WebView2
resolves the devUrl's "localhost" to IPv4 — the window opens on
ERR_CONNECTION_REFUSED while curl and every browser say the server is fine.
2026-08-06 17:21:24 -05:00
KeelrLeek 185c29fc33 library: the page lit by its own records, and portraits that prove out
The expander reserves once on the page root instead of standing min-heights
on the grid; shelf heads carry artist portraits keyed by path; every record
tints its own card, expander and glow from its cover; the artwork opens the
shared viewer; the banner wash reads the playing record and actually paints
(isolation, the artist page's own fix); descriptions animate on both pages
via partAnim. Portrait resolution rejects placeholder rows and never stores
a silhouette (pick_artist_portrait, 3 tests); repair re-resolves the missing.
2026-08-06 17:21:23 -05:00
KeelrLeek 741c4ae6dc library: file a record you don't own off the payload's own field names
The gap cards were reading `nb_tracks` and `date`, which are the shapes the
prototype's generated collection.json used, not the ones CatalogAlbum
actually carries — `track_count` and `release_date`. The year chip survived
on a fallback; the track count reached the classifier as undefined, so the
floor under it had nothing to weigh and every gap card leaned entirely on
Deezer's record_type.

They also now pass `is_live` and `is_soundtrack` through. Deezer has no
record type for either, and the backend sets those flags from the tracklist
and the Films/Games genre precisely so a live album that omits "Live" from
its title can still be recognised — the artist page reads them, and there
is no reason the same record filed on this page should not.
2026-08-05 18:04:26 -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 10e4c75b33 home: rows that fill the window, and news across the whole of it
Kyle picked this off an interactive prototype after four passes: news as
a band rather than a sidebar, cards at 150px, rows filling to width, the
banner capped at 1400, and no "show all".

The news sidebar and the desk grid are gone. News is a full-width band
whose column count follows the window — two at 1152, six at 2560 — and
the 312px aside stops taking 338px off every row at every width.

Rows are wrapping grids now, repeat(auto-fill, 150px), which is the
artist page's own release grid. The paging rails went with them, and with
the rails went their arrows, their per-rail ResizeObservers and the
edge-fade mask. Row caps follow the window instead of being constants:
perRow is floor((pageW + 16) / 166) and every row is exactly two lines at
any width, which replaced GRID_MAX = 12 and the 6/6/10/6 breaks. Back
catalogue goes from 8 cards at 1152 to 24 at 2560 off the same rule.

Out soon is dropped on Kyle's instruction — the feed already leads with
the same upcoming record as its featured card, so the panel repeated the
top of the page. Complete the set and Following come back as bands; the
three sections share one snippet, and the news-* classes are band-*
because the shape now holds artists as well as headlines.

Two bugs fixed on the way. .ax-card is a button with display: block, and
a button's content box is vertically centred by the UA, so as a grid item
stretched to the row height the slack split above and below the artwork —
three-line sub-lines put the image at 9px and one-line at 25px. It is a
flex column now. And the entrance dealt a hardcoded eight cards while
fill-to-width puts 8 to 24 on screen, so everything past the eighth sat
at full opacity while its neighbours faded in; it walks the sections and
deals the ones actually on screen, shortening the stagger as the count
grows.

The news row stays newest-first with no per-artist quota. A round-robin
was tried and rejected: it is a news row, and if nothing is happening
with an artist there is nothing to say about them. Repetition of one
story is bounded in Rust, where it belongs.
2026-08-05 09:47:07 -05:00
KeelrLeek d397b78887 news: about the artist, and each story only once
Kyle reported duplicate articles and a few unrelated ones. Measuring the
live feed found it far worse than the handful he saw: 84 items across
seven follows, hand-labelled, 26 junk — and 24 of the 26 came from two
artists. toe returned 0 of 12 genuine, Pond 0 of 12. Round Pond, Goose
Pond Colony, Inlet Pond County Park, "Amanda M. Pond", peep-toe mules, a
funeral-home obituary. Not one item about the Australian band.

The largest single cause was not the query. parse_news_rss kept the first
twelve items and sorted by date afterwards, so it discarded ranks 13-100
of Google's relevance order — and every genuine toe item sits at rank
15-88. Nothing downstream could recover what was thrown away there. All
100 are parsed now; the cap is 40 and applies after filtering and dedup,
because the frontend's band asks for bandCols * 3 and twelve starved a
wide window.

news_relevance is the precision mechanism. Hard gates first: the name
must appear as a whole token run, every occurrence must not abut a hyphen
(peep-toe, head-to-toe, Chorn-Pond), and a canonically capitalised name
in a sentence-case headline must appear cased. Then document-level
signals, then occurrence-level signals computed for every occurrence with
the maximum kept — and the decision is a total AND a separate floor on
the occurrence subscore. That second clause is the whole filter: at a
single combined threshold every false positive scored at threshold from
document signals alone. "Every song on the 'Tip Toe' soundtrack" is a
music-press host plus a quoted title plus a music noun and carries zero
evidence at the name. Publisher reputation must never admit an item on
its own.

Tiers come from a short explicit dictionary, not from name length and not
from a frequency table. Length demoted Weezer to the strict path and cost
it four genuine items; corpus rank sent Wand from 16 kept to 45 because
the feed floods with "Wistoria: Wand and Sword", and took toe's precision
from 1.00 to 0.70.

dedup_news collapses several outlets' wording of one report — containment
rather than Jaccard, because headline lengths are wildly asymmetric and
Jaccard's union denominator punishes exactly that; artist tokens retained,
because stripping them starves a six-token headline to two; leader
clustering, because union-find chained 68 of 93 King Gizzard items into
one "story"; a 2-day window with unparseable dates non-mergeable, since
rfc2822_secs returns 0 on failure and two zeroes always satisfy a window.
The survivor carries an others count for the outlets it stands for.

That still left the other kind of repetition: many different reports about
one record, similar to each other but not to the leader that absorbed the
rest. Six of the visible twelve were the same Alien Metal announcement.
The fix is not a lower dedup threshold — lexical similarity merges
unrelated stories long before it catches rewrites — but a ceiling:
news_story_key names the album or single a headline is about, and
news_story_quota keeps at most two per work, applied after the date sort
so the survivors are a story's newest. Headlines naming no work are never
dropped, so a feed can shrink but can never be emptied.

Two things that were quietly broken. News URLs never qualified in
disk_cache_path, so the entire disk branch in artist_news was unreachable
and every restart refetched every follow against a rate-limited endpoint.
And rfind on a set of dashes plus one lands mid-character on a 3-byte
en-dash: the slice panics, and a panic inside an async Tauri command never
resolves its promise, so the News band hung forever — but only for artists
whose name sits mid-headline.

The host now comes from the source element's url attribute; its text is a
display name ("GamesRadar+") and is not a host. Both new fields are
serde(default), because cached items are read back through an .ok() that
swallows a schema mismatch: a required field would silently invalidate
every cached entry and fire a cold refetch for every follow at once.

Measured live: toe 12 items (0 real) to 5 (5 genuine), Pond 12 (0 real) to
7 (7 genuine), 32 duplicate clusters collapsed covering 71 redundant
reports, and no story takes more than two of the twelve visible slots.
Derivation, the falsified alternatives and a what-not-to-do section are in
research/news/PLAN.md.
2026-08-05 09:46:54 -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 8ef730848a news: headlines about the artists you follow, anchored to the artist
No per-artist music news API exists — every product that does this
aggregates and filters, so this does too. Google News RSS is the source:
free, keyless, no pagination, and no contract at all, which is why it
gets the most conservative pacer in the file (4 requests per 10 seconds)
and a 6-hour cache on disk as well as in memory. Nothing user-facing
waits on it; the home page asks after it is already drawn.

The query is the QUOTED artist name plus the word "music", and the
quoting is the whole design. Measured 2026-08-04 against the real feed: a
bare ambiguous name returned 0 of 10 relevant items — searching "Girls"
gives Indigo Girls, The Deli Girls and a school production of Mean Girls
— while the same name with a corroborating token returned 8 of 8, and an
unambiguous one 10 of 10. That is the identity-anchor rule the rest of
the app already lives by, applied to a provider that only speaks in
names: never trust the name alone.

The parser is deliberately not an XML library. One provider, four flat
tags inside <item> blocks, exactly the judgment the Bing scrape made.
What it does handle is what Google actually emits: entity-encoded titles
including numeric forms, a " - Publication" suffix on every headline
(stripped when the tail matches the <source> tag, so the title reads as a
headline rather than a citation), and RFC 2822 pubDates converted to unix
seconds by a hand-rolled civil-date conversion — nothing else in the
workspace needs a date crate for one field.

Freshness and relevance stay OUT of here. The 45-day window and the
review filter are display policy and belong to the surface that shows
them; this returns what the feed said, newest first, capped at a dozen.
2026-08-04 19:08:35 -05:00
KeelrLeek 50afa6a6a9 branding: the falcon replaces the waveform everywhere the app is seen
Kyle's pick — the deep-navy-glow rounded icon. Regenerated from the 1024
master through Tauri's own generator, so every surface comes off the same
art rather than a hand-resized copy per platform: icon.ico (16/24/32/48/
64/256 frames), icon.icns, the Square*Logo tiles Windows uses for the
Start menu and taskbar jump lists, and the Android and iOS sets. icon.svg
is the source SVG itself, so the vector beside the rasters is the one
they were cut from and not a stale ancestor.

public/logo.png is the app's own titlebar mark, drawn at 20px. It gets
the full-resolution master rather than a downscale — it is one asset, the
size costs nothing next to the artwork this app already holds, and there
is then no second file to keep in step when the icon changes again.

Verified in the running app: the titlebar draws the falcon, and the icon
extracted back out of the built exe is the new one, not a cached
resource.
2026-08-04 16:42:38 -05:00
KeelrLeek 1e6f3f2250 release: package-lock follows the alpha.3 bump
It carries the version in two places of its own, so the three files the
release commit touches are really four.
2026-08-04 16:42:28 -05:00
KeelrLeek f0c2bb5ed3 taskbar: transport buttons under the window preview
Hovering the taskbar button pops a preview, and an app may put buttons
under it. Every media player uses them for the transport, and now so do
we: previous, play/pause, next, with the middle one swapping its glyph as
playback starts and stops.

Clicks are emitted as `soulseek:media-button` — the same event the OS
media card sends — so the frontend's existing dispatch handles them with
no new code and the two surfaces cannot drift apart. Verified by posting
the exact WM_COMMAND/THBN_CLICKED the shell posts: next advanced the
queue, toggle paused, toggle resumed.

Three Win32 facts shaped the implementation. The buttons can only be
added ONCE per window and only after the shell has created the taskbar
button, which it announces with a registered message — but Tauri shows
the window before the setup hook runs, so that announcement is usually
already gone by the time we could subclass, and the first build put no
buttons up at all. They are added directly at init now, with the first
playback as a retry for the case where the taskbar button genuinely does
not exist yet. Clicks arrive as WM_COMMAND, so hearing them means
subclassing Tauri's window — SetWindowSubclass chains rather than
replacing the window procedure, so wry still receives everything it
expects. And ITaskbarList3 is apartment-threaded, so it lives in a
thread-local touched only on the UI thread, with updates from the
player's event thread marshalled through run_on_main_thread.

The glyphs are drawn at runtime rather than shipped: they are four flat
shapes, and a .ico per glyph per theme would be files to keep in step
with a transport that already draws itself in the app. Coverage is
sampled on a 4x4 grid per pixel, which is what stops a 16px triangle
looking like a staircase. They follow SystemUsesLightTheme — the
thumbnail flyout rides the system theme, not the app's — and are rebuilt
on WM_SETTINGCHANGE when that flips, so they do not end up dark-on-dark.
2026-08-04 16:08:26 -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 f72b12a892 release: 0.1.0-alpha.3 2026-08-04 14:49:28 -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 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 e976fb88ba ignore branding/ — the logo kits are worked on elsewhere 2026-08-04 14:41:50 -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 a949c24677 playback: streams try peers that can actually deliver, and give up on the
dead ones faster

"Da Funk" — hundreds of sources — took over a minute to start. The
quality-first score ranks a format tier at 1000 points and a free upload
slot at 600, so busy lossless peers owned the entire failover list; each
one queued the request and never sent a byte, and the flat 15s-per-
candidate walk burned the minute before a live peer got its turn.

For STREAMING, candidates are now stable-partitioned: peers with a free
upload slot first, best quality within each half. Quality still decides
among peers that can deliver immediately; a busy peer's better file is a
save-time choice, and downloads keep the pure ranking. The album path and
the background fill inherit the same order.

The failover budget drops from 15s flat to 12s for the first candidate
(an indirect handshake can honestly take ~10) and 8s for the rest — a
peer that has not sent a byte in eight seconds is not about to.

Worst case falls from ~60s of dead peers to ~36s, and the common case —
an available peer ranked first — starts in seconds.
2026-08-03 17:48:16 -05:00
KeelrLeek cef1363acd playback: stop flooding the Soulseek server from the album fill
Clicking rapidly through a record's tracks kept dropping the server
session and arming its reconnect throttle — a fresh login died inside
~90 seconds of a click-storm. Cached plays themselves were innocent
(play_stream's cached branch never touches the network, preloads
cache-check first); the traffic was the album FILL, which every play
restarted immediately and which fires a server search for every remaining
track the source memory does not know.

Three protections, layered:

The fill now starts only after the queue has sat still for four seconds,
and a new play cancels the pending start — stress-clicking sends nothing
at all, while normal listening fills the album four seconds into a track,
hours before it matters.

Searches are globally paced: never two inside 1.5 seconds, whoever asks.
A user-triggered resolve still feels instant — the first search never
waits — only bursts are spread.

And a failed AUTOMATIC connect arms a three-minute backoff. Every
dev-reload of the page runs autoConnect, and an auto-dial during an
outage only extends the server's cooldown; the user's own login button
still dials whenever pressed.
2026-08-03 17:36:27 -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 4c9a6e312a library: one record, one recents row, however it was played
A saved album that also streams showed up twice in the sidebar's
recently-played — once as the library album, once as the catalog album —
because a play row carries album_id only when the played FILE was a
library track, so streamed plays of the same record grouped separately
under their catalog id.

The plays table already holds the bridge: any play that carried BOTH ids
(a local track played with the catalog page on screen) pins that catalog
album to that library album, by exact ids, never by name. recent_albums
now folds linked catalog plays into the library row — which also keeps the
row's timestamp honest when the newest listen was the streamed one — and
drops them from the catalog arm. Deleting the album from the library
dissolves the link, and its catalog plays stand alone again.

Regression test reproduces the exact two-Origins shape: dual-id play plus
a newer streamed play must come back as one library row wearing the
streamed play's timestamp, sorted above an older unrelated record.
2026-08-03 16:29:19 -05:00
KeelrLeek 5ac456102f playback: the album button and the background fill learn the cache too
The cached-source skip only covered single track clicks; pressing Play on
an already-streamed album still resolved from scratch, and the background
album fill searched for every remaining track it appended. Three closures:

Play-album first asks the memory for every track of the record, and when
all of them are still byte-complete in the cache it queues the whole album
from memory — no search, no popup, playable offline. One unknown track
falls through to the resolver as before.

The resolver's own queue build now remembers EVERY track of the folder it
picked, not just the one that plays — one album resolve teaches the whole
record, so the next press of Play skips straight to the cache once the
files have finished arriving.

The background fill checks the memory before searching for each appended
track, and remembers what it appends, so a record that has streamed before
extends its queue instantly instead of running one 7-second search per
remaining track.

Verified live: the remembered track went from click to playing in 114ms
with the resolver never activating.
2026-08-03 16:16:31 -05:00
KeelrLeek f55562f0ab playback: a cached track plays without asking Soulseek where it lives
Hopping between songs of an already-cached album still ran the full
"finding best source…" resolve for audio sitting complete on disk — a
second of popup between every click. The player now remembers the source
each stream item was queued from (localStorage, LRU-capped at 400) and,
before any search, asks the backend whether that exact file is
byte-complete in the stream cache. If it is, the track plays those bytes
immediately: no resolver, no popup, and no online requirement — cached
audio is playable offline, so the check sits before requireOnline.

The memory is a hint, never a promise. It is written eagerly when an item
is queued to PLAY, because a source that never delivers leaves no complete
cache file and the byte-complete probe rejects it later at no cost;
eviction and half-finished transfers fall back to the normal resolver the
same way. First play of a track records it, every play after that is
instant.
2026-08-03 16:08:51 -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
KeelrLeek 3aaec30dc6 metadata: a lost probe race no longer writes an image host off for five minutes
The image-host liveness probe has a 3-second budget so a page never waits
on it — but losing that race was recorded as "the host is down" and cached
for the full five-minute TTL, turning one bad sample into square portraits
on every artist page in the window. Measured on the machine that reported
it: all 213 cached assets.fanart.tv urls answered in ~130ms while the
machine's own first DNS resolver ate 1-3.6s of the budget before a byte
was sent — and the A record's TTL is 300s, exactly the probe TTL, so every
probe replayed that lottery on an expired name.

The fast verdict still stands for the page that paid for it — it falls
through to the next provider immediately, as before — but a background
confirmation with a 12-second budget now decides whether the verdict
persists, and the warning reports what was measured (timeout, transport
error chain, or 5xx) instead of asserting an outage it cannot see. A
regression test stalls a local socket exactly once and proves the host
comes back without waiting out the TTL; the fanart_probe_live harness
prints the reqwest error chain four ways for the next time a provider is
suspected on this machine.
2026-08-03 16:08:10 -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
KeelrLeekandClaude Opus 5 7547fed56d playback: remember the last transport so a reload lands where you left off
PlayerState carries last_transport, mirrored as events go past, and player_state
reports it. The frontend restores everything else from localStorage, but the
transport cannot come from there — the audio lives in Rust, and a reload that
trusted the browser's copy would show a position the engine had never heard of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:03:20 -05:00
KeelrLeekandClaude Opus 5 dc918c98ef metadata: per-track listeners, and an artist profile the search hero can ask for
CatalogTrack gains artist_id — Deezer sends it on every search row and it was
simply unparsed — and rank, its 0-1,000,000 popularity figure. Together they
let a listener count be resolved for the PERFORMING artist of each row rather
than for the query's top artist, so a "panda bear" search shows Daft Punk's
"Doin' it Right (feat. Panda Bear)" at its real 464,046.

Resolution rides the existing deezer_linked_mbid -> lastfm_listener_ranks chain
behind a new catalog_track_listeners command, deliberately kept separate from
catalog_search: that one is typed against per keystroke and must never wait on
MusicBrainz's 1 req/s gate.

catalog_artist_profile backs the hero's middle third — MusicBrainz tags off the
cached artist document plus one Last.fm artist.getinfo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:03:03 -05:00
KeelrLeek f8f56f4398 albums: ask the artist's own discography before deciding a record does not exist
"A ? of WHEN" opened as a bare local page — no plays, no listeners, no real
release date — while the artist page's expander showed all three for the same
record. The artist page was right and the resolve was wrong.

Deezer's SEARCH index is not the discography. The artist page merges a
MusicBrainz supplement on top of it, and MB-only records get a real id, a real
release date and full Last.fm figures there; the Deezer search that
openAlbumByName ran simply cannot see them. So the resolve now falls through
to the artist's own album list before giving up.

That list has to be the SUPPLEMENTED one. getCatalogArtistPage returns the raw
Deezer page until the artist page has been visited and written the merged
version back into the cache, so a cold click still missed. It now runs
mergeArtistIdentity itself — the same pure merge the artist page and the Now
Playing panel already share — instead of concluding the record is nowhere.

Verified against the artist page's own numbers: id 5831190465731370, released
2026-07-10, 4,521 plays, 236 listeners, per-track 66/131/113/108 — identical
to the expander. The local-composition path stays as the last resort for a
record genuinely in neither catalogue, where it is still the only thing that
can render at all.
2026-08-01 07:53:57 -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 606250c361 resize: reads before writes everywhere, and an open album drags like a closed one 2026-07-31 16:58:48 -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 4179247203 build: strip the UTF-8 BOM that stopped both halves of the app starting
0.1.0-alpha.2 bumped three version strings with a tool that writes UTF-8
WITH a BOM, and committed it. Neither build survives that:

  package.json      vitefu JSON.parses the raw string, so `npm run dev`
                    exited instantly — "Unable to read package.json"
  tauri.conf.json   the Tauri build script failed with "expected value at
                    line 1 column 1"; cargo exit 101
  Cargo.toml        tolerated, but wrong

`require()` hides this. Node strips a BOM on require and JSON.parse does
not, so reading package.json from a REPL reports a perfectly good file
while both builds refuse it. Only the raw bytes show it: EF BB BF.

All 191 tracked files scanned. These three were the only ones affected,
and they are exactly the three 5682dfc touched. Stripped with Node rather
than PowerShell, whose default Out-File/Set-Content encoding is what wrote
them in the first place — the files are byte-identical but for the three
leading bytes.

Cargo.lock carries the 0.1.0-alpha.1 -> alpha.2 propagation the release
commit left behind in the working tree.
2026-07-30 07:39:30 -05:00
KeelrLeek 5682dfc22f release: 0.1.0-alpha.2 2026-07-29 18:46:38 -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 a6b585303e album page: the record's own shape while it loads, and prose that waits its turn
The loading state was one line of grey text, then the whole page at once.
It is now the page with its words removed — the same hero, identity, cover
and copy elements, so the artwork does not move when the fetch lands. The
cover is real from the first frame: `albumSeed` carries what the card you
clicked already knew, painted in 10ms, and the skeleton row count comes from
the seed's track count so the tracklist grows 370 -> 377px instead of
296 -> 377.

Three bugs found by measuring the handoff frame by frame:

- Every dealt part rendered once at full opacity and then snapped to zero.
  `element.animate()` returns a PENDING animation whose start time is not
  resolved until the next frame, so `fill: backwards` never covered the first
  paint. Hiding each part inline is synchronous and closes it.
- The deal was animating the SKELETON. `openCatalogAlbum` assigns `albumPage`
  and clears `albumLoading` only in its `finally`, so for one flush the new
  record was live while the placeholder was still on screen — and it has a
  `.tracklist` of its own.
- The artwork could blink on a cold cache: the skeleton seeds the 500px grid
  cover and the page asks for the master. The seed now sits underneath as a
  background until the master decodes, on the banner too, where the blur
  applies to it and the colour wash survives the swap.

The deal also only ran once, against a snapshot, so `.about`, `.eds`,
`.stats` and `.credits` — all gated on network data — were never animated at
all. Parts now record which generation dealt them and a later pass picks up
whatever was missing.

The description moves BELOW the tracklist. Above it, it pushed the tracks
~180px down and re-flowed them on arrival, measured at 81.5px; nothing above
that point depends on `info` now, so the tracklist cannot move (measured 0).
It gains the page's own `.disc-head` idiom, a promoted lede, and the bottom
credits block becomes pills plus who said so.
2026-07-29 18:46:16 -05:00
KeelrLeek 35a18d9ca0 state: one identity merge, one description splitter, and a record named at click time
Three separate things the panel and the pages were doing differently.

The MusicBrainz merge lived inside `applyArtistIdentity`, which only the
artist page calls, so the Now Playing panel showed the raw provider list: My
Bloody Valentine appeared with four releases because Loveless and Isn't
Anything exist only as supplements, and Weezer filed differently because the
panel never received `type_hints`. The merge is now `mergeArtistIdentity`, a
pure function both surfaces run. `applyDiscoveryIdentity` deliberately does
not write `catalog.artistPage` — that would overwrite whatever page the user
is looking at — and is guarded on the normalized artist name so a track
change mid-flight cannot land one artist's supplement on another's list.

`streamSong` knew the record all along and never said so. Every caller passes
`album` and `albumId`; the queue item was built with `sub: artist`, and the
panel splits `sub` on the dash to find its album line, so a single track
could never name the record it came from. `catalogRef` was also assigned
AFTER the Soulseek resolve, so the panel could not start loading the record
until a peer had been picked — that wait was the "why does it take time to
show the rest of the album" delay, with the id in hand the whole time. It is
now set before the resolve, and handed back if no source is found.

`splitDescription` promotes a description's own first sentence to a lede. It
lives here beside the other release-identity helpers because three surfaces
need it and a copy in each is how they start disagreeing. The abbreviation
guard is worth keeping: written as `Nov\.` the lookbehind consumed the very
period it was meant to protect, and "released 4 Nov. 1991" split after "Nov.".
2026-07-29 18:45:58 -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 4f485b6f9a backend: a supplemented album remembers what it is between runs
A release Deezer's regional catalog omits is injected under a synthetic id,
and the only thing that can turn that id back into a release group is
`mb_catalog` / `apple_catalog` — two in-memory maps written only while an
artist page is built. Reaching such a record any other way fell through to
`api.deezer.com/album/{synthetic}`, which cannot resolve it, and the user was
told "album lookup failed".

Measured: King Gizzard's "The Silver Cord" (6660585962991362) failed cold in
237ms while the real Deezer single beside it (489399625) returned 14 tracks.
Three routes hit it — the sidebar's recent places, a replayed artist page
clicked before the fresh one lands, and the registries' 1000-entry clear.
That last one is why it looked intermittent.

Both registries now persist beside the MusicBrainz and Wikipedia bodies in
net-cache-v1, and the lookup reads memory, then disk, then Deezer. After the
fix the same cold lookup returns the record in 150ms.

The two structs deserialize as each other's type only if their required
fields happen to match, and they do not, so one entry can never be read as
the other kind.
2026-07-29 18:45:23 -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 9cb19f42af player: fill the album, report a cached track honestly, and stop double-appending
The album continuation appended exactly ONE track and then waited for the next
track start to append the one after, so a song started from search or an
artist page showed a queue of one or two entries however long the record was.
It still resolves one at a time -- a track cannot be queued before a peer with
it is found -- but it no longer stops after the first. My Name Is Jonas cold:
1 to all 10 in under 12 seconds, in album order. The resume anchor moved to
the LAST queued track so a fill cut short by going offline picks up where it
stopped instead of being locked out by the "next already queued" guard.

An adversarial review of that change found the hole it opened, and it is
fixed here rather than shipped: dropping the second clause of the bail-out was
wrong. streamSong installs autoAlbum only AFTER waitForStreamStart, which
polls the GLOBAL player status, so a second play issued during that buffering
window nulls autoAlbum and streamSong then re-installs its own context over
the top -- and the fill would go on appending one album's tracks to the other
album's live queue. The anchor must also still BE in the queue, which survives
our own appends and disappears exactly when the queue is replaced; and
streamSong no longer installs a context it no longer owns.

advanceGaplessly reported a pre-buffered track as zero-of-zero. streamTotal
=== 0 is the UI's "no byte counts yet" state, so a finished track showed the
indeterminate "Streaming from <peer>" sweep with no further event ever coming
to correct it. Gapless is only offered for a fully cached file, so its size IS
its received count. The buffering handler mirrors the store rather than
zeroing it for the same reason -- those counters are what a window restore
copies back, and zeroing them turned a complete transfer back into the sweep
on minimize and restore.

tryAppendNext set its appendedNextPath guard AFTER its await -- check then
act. Harmless with one caller; the moment the cached-play event gave it a
second same-tick caller both passed the guard and appended the same source to
the sink twice, which puts the panel a whole track ahead of the audio from the
next boundary on. It claims the slot before awaiting and gives it back on
failure.

ui.nowPlayingDragging is here because App.svelte's grid column has to leave
the 0.28s ease at the same instant the panel does.
2026-07-29 08:12:07 -05:00
KeelrLeek d460352973 backend: a cached track says so, and fallback hero art expires in a day
play_stream's fully-cached fast path plays the file and returns early -- no
StreamProgress, no download task, so the loop that emits soulseek:stream never
runs and not one event is ever emitted for that track. The frontend had
already set received=0/total=size when it asked to play, so a record that was
100% on disk sat pinned at "peer - 0%" for its whole length. It emits one
completed event now; done also lets the frontend take its finished-track path
(final waveform, pre-buffer the next).

The artist-art manifest TTL was a flat 30 days. Fanart.tv's CDN refused
connections on 2026-07-27, the chain correctly degraded to Bing -- and then
those Bing-tier heroes counted as fresh for a month, which is why low quality
art stayed put after the provider came back. The TTL is tier-aware now: art
whose banner came from fanart.tv keeps the 30 days, anything from below it is
re-checked daily. Tier is read back off the URL host because the manifest
never recorded which provider won, and banners only -- logos legitimately come
from TheAudioDB even when fanart covered the artist, and treating that as a
fallback would put every artist on a daily re-check.

Recipe bumped to v6 so every stored manifest re-resolves once against the
providers that are up today. Weezer went from Bing-tier art to 8 banners, all
assets.fanart.tv.
2026-07-29 08:11:49 -05:00