b8e6fb73961c230000464682e227356b4df674c9
66
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b8e6fb7396 | who has this: a gap card says who holds it, the sheet and the open album name them, a Space grows a Holdings tab, and Get it is the hub's own grab or a wait for their presence | ||
|
|
08ca09586a | community: Discover ranks the shelves it already has into one, and every card still says where it came from | ||
|
|
f67a0a30b4 |
shared playlists: importing a share follows it, so the owner's next revision reads as "2 new tracks since you imported"
Importing a shared playlist was a one-time snapshot: the owner's next
revision could only arrive as a whole second copy. A copy now remembers
WHICH revision it was made from, and the app reads the pointer for it
every ten minutes — no new contract, the same pointer and revision reads
the share card already does.
The ledger gains `following`, keyed `owner:uuid` — the one key a database
cannot hand out twice. A follow records the local playlist, the synced
revision (hash + seq) and what the last check saw: the current revision,
the entries it has that the synced one never had (in the owner's order),
and how many synced entries are gone. The diff is by occurrence id, the
proposal board's rule: a reorder is not news, a removal is counted, a
duplicate stays its own row. `import` stages the fetched revision so
`follow` records exactly what the copy was made from — a pointer read of
its own could already name a newer one.
Update appends the new rows through the same resolve path an import uses
(library first, Soulseek stream otherwise) and never removes or reorders:
the copy is the follower's to edit, and a track the owner dropped is
reported, not taken away. It runs under the ledger lock end to end, so a
check landing meanwhile merges against the new synced revision or not at
all. Checks read the network WITHOUT the lock — a pointer read is a ring
search — and merge each result under it afterwards, dropped if the follow
moved on.
Deleting the local copy ends the follow, in `forget_playlist` and the
startup reconcile both: the row id goes back to SQLite, and the next
playlist created must not start taking someone else's updates. Seen live:
the re-import after a delete took row id 1 again and followed cleanly.
Surfaces: an Inbox card when a check finds news ("2 new tracks since you
imported it · 1 removed", Add 2 tracks / stop following), and a Following
row in the playlist's Share sheet (up to date / N new / withdrawn by the
owner, Check now, Stop following). The poll rides the inbox watch: its own
ten-minute interval, plus one pass when the adapter connects.
Live, two instances on this machine: import + follow 582 ms; the owner's
revision 2 (one dropped, two added) read back as exactly "2 new, 1 removed"
in 2.7 s; Add appended two stream rows in 46 ms and left the dropped track
in place; a withdraw read as withdrawn in 57 ms; delete took the follow
with it; re-follow and unfollow both left the copy alone. One pointer read
after a republish timed out at 120 s and arrived a second later — the same
first-read-after-a-change lag the board has.
Tests: the diff (order, removal, a duplicate occurrence) and the ledger
reading files written before follows existed. App 152 · 6 ignored.
|
||
|
|
df06f849e0 | spaces: a room carries what its members are playing, sealed in the window its chat already uses | ||
|
|
7b7f48ebc6 | community: declare what you like on soulseek, so the similar-users shelf finally has something to rank on | ||
|
|
67e52697b2 | spaces: a member wears a key minted for that room alone, so a roster stops naming personas | ||
|
|
b3c121fa94 | community: the inbox check belongs to the app, not to the page that happened to be open | ||
|
|
0846b39d45 | community: the library overlap shelf, their share folders against your albums on this device after a browse | ||
|
|
bf81792c49 | the proposal board: a Space's pinned playlist takes signed proposals, and an accept republishes it | ||
|
|
8b511b20e7 | community: spaces, an owner-signed roster with epochs, a bounded window of signed lines, bodies sealed to a rotating key when private | ||
|
|
4378c4c0cc | community: the encrypted inbox, friend requests and shares travel sealed to a box key when the other side has published one | ||
|
|
13c44f731a | community: shared playlists, a signed revision and a pointer on the network, a reference over soulseek, an import through the normal door | ||
|
|
984738135a | community: friends, two signed answers on one request carried over soulseek, one answer is only ever pending | ||
|
|
6ed6f5b8f8 | community: the soulseek link proof, one message to yourself links the account and one question links anyone who answers | ||
|
|
5160d97a5f | community: the drawer asks a peer for its card and the server for interests, and discover gets the similar-users shelf | ||
|
|
64a7561750 | community absorbs the hub: search, transfers and browse ride the same pills, one entry in the sidebar | ||
|
|
8c25f93afe | community: one area for the people on both networks, and the hub grows search tabs, a folder column and a queue column | ||
|
|
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.
|
||
|
|
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). |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
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 | ||
|
|
50414dae5c | playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises | ||
|
|
1dd8573808 | dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice | ||
|
|
df3d15553a | playlists + lyrics: setlists with pages of their own, and the words timed to the voice | ||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
eba29824c9 | sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane | ||
|
|
606250c361 | resize: reads before writes everywhere, and an open album drags like a closed one | ||
|
|
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. |
||
|
|
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. |
||
|
|
9dc3eaf939 |
artist hero: the blurred layers ride with the photograph
Two layers were left behind when the hero rides up on scroll, both of them blurred copies of it, so both read as "the blur does not line up". The load seed and its mono twin were never in the squeeze-ride rule -- they got a filter on scroll and no transform at all, so at scrollTop 600 they sat 132px below where the photograph had gone. They were left out because hero-squeeze-ride sets transform outright and would drop the scale(1.06) they are built on; hero-seed-ride is the same translate composed AHEAD of that scale, so the offset stays in unscaled pixels and matches the banner's rather than being 1.06x it. Measured 0px drift at scrollTop 0, 300, 600 and 900. The mono twin is a BAKED copy of the hero canvas and snapshotGpuTwins only ever ran on a hero READY. So after any resize the live hero repainted at the new size while the twin went on stretching its old frame into the new box -- an overlay visibly off the photograph, and permanent until the next hero happened to re-bake it. It re-bakes when the measured box changes now, debounced past the renderer's own settle so it copies the canvas already repainted at the new size, and dimension-guarded so it costs nothing during the moving part of a drag. Verified: live store and baked twin agree at 3186x1629, then both at 2856x1607, then both back, with one frame throughout. |
||
|
|
35729c1f9e |
navigation: state the bar's geometry instead of collapsing a margin for it
Hoisting the glass bar out of the pages (
|
||
|
|
8017c0d877 |
navigation: one glass bar for the app's life, not one per page
The bar was rendered by each view, so `<CurrentView />` swapping on every
navigation destroyed it and built a new one. Measured: the `.glassbar`
element is not the same node on either side of an artist -> album move, and
the old one is disconnected.
That broke the crumb entrance animation, which is what this was noticed
from. The `seen` set exists so only a genuinely ARRIVING crumb slides in,
and it is component state -- so it was empty on every mount and every crumb
looked new. Instrumenting Element.animate across one navigation:
AC/DC -> Highway to Hell should animate: the album
actually animated: all three crumbs
Back to AC/DC should animate: nothing
actually animated: both crumbs
So the whole trail re-flew in from the right on every page change, and Back
animated crumbs that had never left -- the exact opposite of the
direction-of-travel cue the animation was written for.
The bar is app chrome, not part of a page, so App.svelte now mounts it once
as the scroll pane's first child and pages no longer carry it. Views that
do not show it yet hide it with `display: none` rather than an {#if}: the
element leaves the layout and measures zero -- scrollbar.js reads this
bar's height to start the thumb below it, and correctly gets 0 -- while the
component stays alive and keeps its crumb memory across the round trip.
Geometry is unchanged, which the negative-margin bleed made easy to get
wrong: the bar's net flow height is zero either way, so moving it from
first-child-of-the-page to previous-sibling-of-the-page leaves the page's
top exactly where it was. Verified against the live pane -- bar top/left/
width flush with the pane and 46 high, page top at pane + 24, z-index 40
and elementFromPoint at the bar's centre still returning the bar rather
than the hero underneath.
After: arriving at an album animates only the album's own crumb, Back
animates nothing, and a trip out to Library and back animates nothing. The
backdrop-filter layer is no longer recomposited on every page change, and a
bar element can now transition between pages at all, which was impossible
when each side was a different node.
Two consequences worth stating. The resolver pulse takes the GLOBAL accent
everywhere now; on the album page it used to inherit that page's extracted
palette, because the bar sat inside `.album-page`. The artist page always
gave it the global accent, so the two now agree rather than disagreeing.
And putting the bar on the remaining pages is now widening BAR_VIEWS plus
deciding how it sits against each page's existing header -- the mounting
question is settled.
85 JS tests pass.
|
||
|
|
98d11a2228 |
previews: the album button opens the record the previewed track came from
The third action in the preview modal's control row has been a disabled stub since the modal landed -- there was nowhere for it to go until the album page existed. It goes there now. The target is the item's own provider `albumId`, never a title match, so a top-tracks preview lands on the exact record the modal's metadata line is already naming: Elephant Gym's "Light" opens Angle, and AC/DC's "Highway to Hell" the album rather than any of the compilations carrying it. The manifest has carried `albumId` and an `actions.openAlbum` flag from the start; all 20 rows of a Deezer top-track payload declare one. The navigation lands on the previewed TRACK, not just the record -- the album page's existing pending-track flash does the scrolling and the highlight, the same landing the now-playing song link already gets. Three details that are not obvious: - The overlay must not import state.svelte.js (the module comment on previewSession explains the cycle), so the navigator is injected by the page exactly as `playFull` already is. - Both ids are read out of previewUi and the snapshot BEFORE the close, not after: closePreview() nulls the callback and drops the snapshot the active item came from. And the close happens first, because the session holds the audio focus and navigating under an open dialog would land the new page behind the scrim. - contracts.js freezes every manifest id as a String, while `catalog_album` takes a u64 -- a JSON string fails to deserialize -- and the album page's flash compares track ids with ===. Both cross back to numbers in openAlbumFromPreviewItem, once, rather than at each call site. An album session is already standing on the record it would open, so the button greys out there with "You're already on this album" rather than re-navigating to the page underneath. The decision is made from the manifest's own contextKey, so it holds for any future surface that opens an album-kind session. The album page still supplies the navigator, so a row that ever declares a different album goes somewhere instead of being silently dead. Verified in the running app over CDP: artist top-tracks preview -> click -> lands on Highway to Hell (id 9410086), pendingTrackId 92719900 as a Number, matching track found on the page, .trow.flash present, modal gone, and the glass bar trail reading Search -> AC/DC -> Highway to Hell. From an album session the button is disabled and a click changes nothing. 85 JS tests pass. |
||
|
|
122157e061 |
listener bars: a bar is never narrower than the number standing on it
Two faults in the tracklist's listener column, both visible on any record with one runaway hit. The bar was a LINEAR share of the album's biggest song, so a single outlier took the whole column and pressed everything else into a sliver. Weezer's Green Album is the case: Island In The Sun has 11.9x the listeners of that record's quietest track, so nine of the ten bars lived inside the bottom 8% of a 168px column and stopped being tellable apart. And the fill was absolutely positioned with the number floating on top of it, so a short bar left its own digits hanging off the end onto the row background. Six of the ten rows on the Blue Album did this. The share is now raised to a power below 1 (0.42). That is strictly monotonic and still anchored to the PEAK, so the ordering survives and a genuinely flat record still reads flat -- a min/max normalisation against the album's own quietest track was rejected for exactly that reason, since it would stretch Maladroit to look as varied as Green Album. Measured in the running app, shortest bar of a 168px column: album top:bottom before after Weezer (Green Album) 11.9x 8% 38% Make Believe 6.7x 15% 45% Weezer (Blue Album) 3.9x 26% 56% Weezer (White Album) 2.3x 43% 70% Maladroit 2.2x 46% 72% The flat records move least, which is the point: the curve spends its lift on the albums that were squashed and barely touches the ones that were not. For the second fault the number moves INSIDE the fill, and the fill gets `min-width: max-content`. The floor is then per-row and measured by the browser in the real font -- nothing in JS could do that honestly -- and the width handed over is only ever a lower bound. Across the four albums above, numbers hanging off their bar went from 6 to 0 and every bar stayed monotonic in its count. Green Album lands at 38% rather than the 35% the curve alone gives, because the text floor takes over on its shortest row. A track Last.fm has no count for keeps its dash and now gets no paint at all; a floor-width pill behind an em dash would read as a measured tiny value rather than an absent one. The scale moves to src/lib/listenerBars.js because the album page and the artist page's open-in-place album panel draw this identical column, and a second copy would quietly disagree with the first about how big a record's hit was -- the same reason editions.js was lifted out of the artist page. Both call sites verified over CDP; 85 JS tests pass. |
||
|
|
7a517dca59 |
navigation: one glass bar for every page, with a history and a path in it
The glass bar was copied into two views and each carried its own contextual
back link — "Results" on the artist page, the artist's name on the album page.
It is a shared component now (src/components/GlassBar.svelte), and it carries
the app's navigation: back/forward arrows, and the trail to where you are.
**History and the trail are different things, and the file keeps them apart.**
History is a SEQUENCE — every place visited, in order — and it drives the
arrows. The trail is a HIERARCHY, derived from the current place, never from
history. That is why switching between two editions of a record adds a history
step but does not add a crumb: a breadcrumb built out of history would read
`Search › Weezer › Blue Album › Deluxe Edition`, which says the deluxe sits
inside the original. It does not.
Crumbs improve in place. An artist page opens on an id and learns its name and
portrait a moment later, so the crumb starts as a placeholder and is refreshed
through ONE call site (`rememberArtistSeed`), guarded so remembering a seed for
a row in some list can never rewrite where you are. Search results are the
journey's root rather than a bare "Search" — the crumb reads “weezer”, which
is the thing you actually want to get back to.
Arriving crumbs slide in from the right; only genuinely new ones animate, so an
ancestor that was already there does not re-run its entrance when the tail
changes. Chosen from four motions mocked against a real navigation stack.
Every navigation now goes through a recorder. A bare `ui.view = "…"` is a
destination the back arrow can never return to, so the sidebar routes through
`goToView` and the catalog openers record on arrival.
**Scroll position is remembered.** Going back returns you to the paragraph you
left; arriving somewhere new starts at its top. The difference is exactly
whether the move came from the arrows/trail or from a fresh click. Two details
that mattered: the offset is captured at the START of a navigation, because the
scroll pane outlives the view swap and by the time a page has been replaced its
scrollTop already belongs to its successor; and restoring is not one assignment
— the artist page paints a skeleton, then an overview, then the full
discography, so a single `scrollTop = 900` clamps to whatever height exists in
that frame and lands near the top. It re-applies until the target is reachable
and gives up the instant the reader touches wheel, key or trackpad. The album
page never reset its scroll at all, so a record opened from half-way down an
artist page began half-way down; it does now.
**The artwork lightbox serves any cover.** It was hardwired to the playing
queue item; `ui.fullscreenArt` (a boolean) is now `ui.artwork` (a descriptor),
so Now Playing, the artist page's expander rail and the album hero all open the
same viewer with their own `{src, local, title, subtitle}`. Each passes the
UPGRADED cover rather than the sized variant it paints — the point of the
viewer is the master resolution.
Hovering artwork offers it: the whole element lifts under a light wash with the
expand mark centred. `transform`, not a width change — on the album page the
copy column is sized to end level with the artwork, so a reflowing hover would
twitch the entire hero. The three numbers (--art-grow 1.035, --art-motion
160ms, --art-wash) live in global.css rather than being typed into three files.
Now Playing's corner expand disc is gone: one gesture for "open this artwork"
everywhere instead of a disc in one place and a lift in the others.
On the artist page the expander's album NAME now opens that record's page.
Also removed: the album page's scroll-triggered title takeover. The breadcrumb
names the record permanently, which made it redundant — the build was already
flagging its CSS as dead.
**And the fix this session actually turned on.** Kyle reported the album
tracklist's counts as "heavily glitched": Pinkerton showed El Scorcho at 636
listeners beside Tired Of Sex at 606,324. Switching the column from plays to
listeners changed nothing, because both were wrong for the same reason.
`artist.gettoptracks` returns a popular song many times over — once as itself,
then again for every "(Album Version)", live cut and remaster — listeners
descending. The fold meant to keep the first entry, and its comment said so,
but it was written `if by_key.insert(k, v).is_some() { continue }`.
`HashMap::insert` returns the previous value AND STILL OVERWRITES, so the guard
correctly skipped the ranked push while letting every later duplicate win the
map the album tracklist reads. The last re-listing — the most obscure pressing
— is what reached the screen.
Measured against the live API for Weezer: Buddy Holly is rank 1 at 2,351,642
listeners and rank 679 as "(Album Version)" at 1,919; the page printed 1,919.
My Name Is Jonas 1,100,251 → 870. El Scorcho 654,071 → 636. Pink Triangle
810,659 → 6,070. Only songs Last.fm lists exactly once — "Tired of Sex",
"Across the Sea" — were ever right, which is why the column looked plausible in
places and absurd in others. Twenty-four entries for "The Good Life", twenty
for "El Scorcho".
The fold is now `fold_lastfm_duplicates`, pinned by two tests using those real
figures. It also repairs Popular's plays and the album totals, which read the
same map. The tracklist column shows listeners in both places it appears — the
album page and the artist page's expander — because leaving one on plays would
have let one record disagree with itself.
152 Rust tests and 85 JS tests pass.
|
||
|
|
ea9a040d09 |
album page: the record gets a page of its own, under the same glass bar
The album page was a back link, a 150px cover and a list of titles — a
placeholder beside the artist page it is reached from. It is now built from the
same parts: the frosted glass header, a hero carrying the record's own art and
palette, the play-count bars, and the edition cards.
**The glass bar is the artist page's, not a copy of it.** Same 46px, same
rgba(20,20,24,0.28) over blur(12px) saturate(1.7), same sticky top:-24px with
the -24/-28/-22 bleed that spans the pane and leaves zero flow height. It is
meant to be the header every page navigates by, so it had to be the same
object rather than something that resembles it.
It gains one thing here: the record's cover, name and a play button fade into
the bar once the title itself has gone under it, so the bar always says where
you are. That watches the TITLE, not the identity block — watching the block
meant waiting for the artwork to clear too, about 240px of scroll on a page
that only has 199px of it, so it would never have fired on a ten-track album.
**The hero is one proportional block.** `--cover-scale` sizes the artwork
(282px) and everything beside it is `em` off that one root, so type, gaps, the
artist portrait, the pills and the stat divider all move by the same factor.
Two earlier attempts are worth naming because they both looked wrong:
- damping the type (0.6 + 0.4·s) made the words visibly lag the artwork;
- `space-between` filled the height but opened the gaps faster than the type
grew, so the block came apart as it got bigger.
The gap is 1.8em, which is what the five rows need to fill the cover exactly,
and being an em it keeps that fit at any size. Measured live: the label's top
and the buttons' bottom both sit 0px from the artwork's edges. The hero's own
height derives from the artwork (`--cover + 68px`), so the 24px under the glass
bar holds at every scale instead of being a second number that drifts.
**Editions come from src/lib/editions.js — the artist page's own grouping.**
`releaseFamilies`, `editionBaseTitle`, `isEdition` and `editionLabel` moved out
of CatalogArtist.svelte (143 lines deleted there, not duplicated) so both pages
fold a record's pressings the same way. Two implementations would have drifted
invisibly: each page would show a plausible edition list and disagree about
which pressings are one album.
The album payload has no siblings, so the family is rebuilt from the artist's
discography. It reads the FULL page rather than the overview — the overview is
the artist page's first-paint subset and both dropped the anniversary edition
and carried no track counts, so the rows read "1994" where the discography
reads "1994 · 50 tracks". Cached per artist, and already warm whenever this
page was reached from theirs. Live, the Blue Album now lists Original 10 /
Deluxe 24 / Anniversary Super Deluxe 50, matching the discography exactly.
One deliberate narrowing: the artist page buckets releases by category before
grouping, and that categorisation leans on page state this view does not load
(harvested description hints, MusicBrainz type hints). So a sibling is accepted
only when the backend LINKED it (`edition_group`, a verified ordered core-track
match) or it declares the same record type. That can only ever show fewer
editions than the discography, never promote a separate live album or
compilation into this record's edition list.
**Switching edition is a swap, not a navigation.** `openCatalogAlbum` grew a
`keepCurrent` option that holds the page already on screen instead of clearing
to the loading state; the incoming cover is decoded before the swap so the
artwork crossfades rather than blinking out; and the parts are re-dealt on the
same clock as the expander — 400ms quintOut, 7px lift, banner → cover → kind →
title → meta → stats → actions → tracklist → description → editions → credits.
Polled at 16ms through a swap: 0 blank frames. The blurred banner fades without
lifting, because sliding a 42px blur reads as a wobble, and every part animates
to its OWN resting opacity — the banner rests at 0.62 and animating it to 1
would have run the colour wash half again too bright for the whole swap. A
failed load under `keepCurrent` clears anyway, so a dead edition surfaces the
error instead of looking like the click did nothing.
**The byline shows the artist, not the album cover again.** The album payload
carries the artist's NAME and nothing else, so `resolveArtistSeedByName` reads
and fills the same cache `openArtistByName` uses: the click that navigates
there has usually already paid for it, in-flight lookups are deduped so the
page and its own back button cannot fire the same search twice, and a transport
failure stays retryable rather than being cached as "no artist". A neutral dot
holds the space until it lands, so the line never reflows.
Shuffle and Preview join Play and Save. Shuffle is an action on this record,
not a toggle — it always turns shuffle on rather than flipping a global and
silently starting the album in order — and stays lit while it is on so that
state is visible from here. Preview opens the same modal the artist page does;
`buildAlbumManifest` already existed in the preview layer with no caller, which
is what this surface was for. It reports partial availability the same way, and
the manifest is a snapshot so an edition swap cannot rewrite an open session.
Also: plays bars, the whole-track/listener totals, the editorial description
and a released/label line, all off data `catalog_album` was already returning
and the page was throwing away. The plays column hides below three tracks — a
bar measured against itself is not a comparison, and on a single it read as a
rating. Track counts are pluralised, so a single says "1 track".
No add-to-playlist button. `add_to_playlist` requires a file path per track, so
a catalog album can only be added once it is in the library; that is a playlist
decision, not something to slip in behind an icon.
150 Rust tests and 85 JS tests pass.
|