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