Commit Graph
100 Commits
Author SHA1 Message Date
KeelrLeek c1a2608bdd android: three the audit found — bars translucent over nothing, and two insets the WebView does not report
A pass over every screen the app can reach, at rest and scrolled, shot from the
DEVICE so the Android status bar is in the frame. Three of what it found are
nothing to do with the page's top edge and predate it; they are here on their
own.

**The bars are translucent over nothing.** Glass — the backdrop blur behind the
chrome — is off by default on the phone, deliberately: perf.mjs measured three
blurred bands taking a 60 Hz scroll to 30. But the bars kept the alphas that
were chosen to sit ON that blur, so what shipped was the middle state, 92% and
94% over no backdrop at all. The audit reads "Random Access Memories", "Alive
2007", "Latest release" and "Touch (feat. Paul Williams) 8:17" straight through
the navigation bar. The base values are the no-glass case —
`html[data-glass="blur"]` overrides them — so the base is what had to be
opaque; the blurred variants keep their alphas, because there they have
something to be translucent against. The device now answers `rgb(16, 16, 19)`
for the nav and `rgb(28, 28, 32)` for the mini bar, no alpha on either.

**The bottom inset is reported as zero, and the gesture pill is drawn anyway.**
The WebView answers 51px for the top and 0 for the bottom, while the page runs
the full height under Android's gesture handle — the nav measured y 855→915 of
a 915 viewport — so its labels sat exactly where the pill lands, straight
through the word "Library".

**And in landscape the top goes to zero too.** The cutout's inset correctly
moves to the side (portrait top=51 left=0; landscape top=0 left=51), but the
status bar is still drawn across the top there, so the clock sat on "MONDAY,
SEPTEMBER 14" and the battery icons sat on the floating ⋯.

Both are floors rather than replacements: `max(env(...), 18px)` and
`max(env(...), 24px)`, 24 being Android's own status bar height. A device that
reports its insets properly is untouched — portrait's 51 wins on its own — and
only one that reports nothing gets the floor, which every Android with a
gesture pill or a status bar needs. Everything along the bottom is already
anchored to `--inset-bottom`, so the mini bar, `.endpad`, the composer, the
sheets and the player frame all move in step with it.

What the pass did NOT find, having looked: the chip rows clipped at the right
edge of the sleep-timer and sort sheets are not broken — `scrollWidth 458` in a
`clientWidth 412` with `overflow-x: auto`, so they are meant to be swiped,
though nothing on screen says so. The player's three faces, the artwork viewer,
the track sheet, the page overflow and Community's four tabs all render
correctly.

The harness is in research/ (gitignored): shots.mjs walks every reachable
screen and shoots the device frame at several offsets, contact.mjs tiles a pass
into one sheet, clip.mjs records a GIF of the app scrolling, fadetry.mjs
previews a CSS value live without a build, and selectors.mjs checks in one
minute what the full walk takes twenty to cover. Two things it learned the hard
way are written into it: every overlay sits on top of whatever page is behind
it, and a pass once came back as twenty-five photographs of the same player
sheet; and every tab keeps its own stack, so go(tab) shows that stack's top and
only resets when the tab is already active — a pass shot Discography and
labelled it You.
2026-09-14 15:31:31 -05:00
KeelrLeek 1f9b30c51f android: the page ends at the clock — the bars go, the hero keeps the top edge
The bar was anonymous until you scrolled, then faded in carrying the title the
page already had in 27px type two centimetres below it. Kyle's rule for what
replaces it: the page is cut off where it meets the Android status bar and
dissolves there, and — the part that decides the whole shape of this —
**backgrounds may pass under the clock, words may not**.

A mask on the scroller cannot tell a photograph from a sentence. It takes both.
So the artist hero is painted twice: the page's own copy inside the masked
scroller, cut away in that band with everything else, and a second copy behind
the scroller (heroart.svelte.js), unmasked, showing through exactly where the
mask took the first one away. Same image, same box, same focal point, same
scale, offset by the same scroll — one picture to the eye, and only the name
dissolves. The second copy carries `overflow: hidden` for the same reason .hero
does: without it the 1.04 scale renders 268px in a 258px box and five pixels of
photograph appear under the hero's foot, below the black the scrim fades to.

That also retires an attempt that did not survive contact. The mask first
stepped aside for the hero on a scroll-driven `--fade-on`, which meant
rebuilding a mask-image on the scroller every frame; measured at 60 Hz median
and p90 either way, but it is work for nothing, and the flip that replaced it
put the artist's name out of existence in one step. With the photograph
protected by being OUTSIDE the mask, neither is needed: the mask is a constant
and nothing recomputes it.

No page has a bar now. A tab page's ⋯ — which is the app's own menu, the queue
and the sleep timer and Settings — moved beside that page's own large title in
`.phead`; Search and You grew the row they did not have, Community kept the one
it had. A pushed page floats a back chevron and a ⋯ over the page instead, on
flat discs with no backdrop blur, because the bar's own note records what three
blurred bands did to a 60 Hz scroll on the Pixel 6a and these would have been a
fourth.

Three numbers now come from one place. `--chrome-d` and `--chrome-gap` are the
disc and its margin; `.screen.has-chrome` reserves exactly that much at the top
of a pushed page, and `--fade-top` reaches exactly the chrome's foot. They were
independent for one build and it showed immediately: the page began underneath
its own buttons, so "Transfers" sat behind the back chevron before you had
scrolled at all, and the fade stopped 17px short of the discs so they stood on
crisp text and a record's floating ⋯ landed on a track row's own ⋯.

The walk followed. Nineteen steps reached a page's overflow through
`.gbar .iconbtn`, which now matches nothing: the four on a tab page go to
`.phead .iconbtn.muted`, the rest to `.fchrome.more`, and the album's back
chevron to `.fchrome.back`. The `title` field read the bar's own text, which is
a worse source than it looks — it only existed when chrome happened to be
drawn. The screen carries its meta as data attributes now and the three probes
read those, spelled exactly as `.gb-t`'s textContent was (the title, then the
kicker when it differs) so every expectation written against it still holds.

Checked on the device rather than argued: no `.gbar` and no `.gb-t` anywhere,
`.phead .iconbtn.muted` present on all four tab pages and opening "SoulStream ·
Queue · Sleep timer · Settings", both `.fchrome` controls present on a record,
and the title probe answering "Random Access MemoriesDaft Punk".

One thing this leaves, and it is a real cost rather than an oversight: a tab
page's ⋯ scrolls away with its header. The bar used to carry it the whole way
down, so on Home the sleep timer now wants a scroll back to the top.
2026-09-14 15:30:55 -05:00
KeelrLeek cdb861fcbd android: no bar arrives over the page — the tab pages lose their top banner
The glass bar was anonymous until you scrolled, then faded in carrying the
title the page already had in 27px type two centimetres below it. On Home that
meant "Good morning" over "Good morning", half-transparent, while the content
it covered kept moving underneath. Kyle's word: remove them.

So a tab page has no bar at all. `barOn`/`titled` and the scroll trigger they
read are gone; `showBar` is `!root`, and the element is not rendered on Home,
Search, Library, Community or You. Nothing fades in over what you are reading.

The bar was not only decoration, which is why this is more than a deletion: its
⋯ is the page overflow, and on a tab page that overflow is the app's own menu —
the queue, the sleep timer, Settings. That moves to where the page's title
already is, in `.phead`, beside Home's identicon and at the end of Library's
existing row of actions. Search and You grow the row they did not have.
Community already carried its own ⋯ and keeps it.

A pushed page still has a bar, because it carries the back chevron and a title
that is not repeated anywhere else on the screen. It is static now: rendered
only when there is somewhere to go back to, on from the first frame and titled
from the first frame. The opacity-0 default, the 180ms cross-fade, and the
`.gb-t` rise the title used to arrive on all come out of the stylesheet —
nothing is left that can catch the eye mid-scroll.

The walk reached that overflow through `.gbar .iconbtn` on a tab page, which is
now a selector that matches nothing there: `home-more` on both step lists and
the two sleep-timer steps go to `.phead .iconbtn.muted` instead. The other
`.gbar` steps are untouched on purpose — the playlist export sheet is a PUSHED
page and still has its bar, and `library-ambience` is the album page's back
chevron, not an overflow.

Checked on the device rather than argued: on Home `.gbar` is absent, the ⋯ is
present, and tapping it opens the half sheet on "SoulStream · Queue · Sleep
timer off · Settings"; on a pushed record the bar is there with its two
buttons, its title, and a computed opacity of exactly 1.

One thing this leaves: the bar was also what backed the OS status bar once a
page scrolled under it. The clock and the battery now sit on the content. That
is fine on the ground and on dark artwork, and it will not be over a white
sleeve — a short scrim with no title and no buttons would fix it without
bringing a banner back, and is deliberately not in this commit.
2026-09-14 11:09:34 -05:00
KeelrLeek 07dad65ca5 android: the keep rules, and the release build that proves them
Every APK this port has produced is `assembleArm64Debug`, and the generated
project turns R8 on for `release` and only for `release` — while tauri finds a
plugin class by string, files every `@Command` under `commands[method.name]`,
and lets Jackson fill every `@InvokeArg` field by field. Minification breaks
exactly that, and it breaks it without a word: the app compiles, installs,
launches, shows Home, and then a tap on Library folder answers "No command
pickFolder found for plugin ..." in logcat and nothing on screen. So each
plugin module now carries its own `consumer-rules.pro` naming its own
reflected surface — the shape tauri-android and tauri-plugin-dialog already
use in this build, and one that travels with the plugin into a regenerated
`gen/android`, which a rule written into that gitignored tree would not.

What belongs to no single plugin is in `src-tauri/android/proguard-soulstream.pro`,
copied into the generated project by the build script because the app's own
Gradle file is regenerated: the runtime annotation attributes, a `-dontwarn`
pair for the two `java.beans` classes jackson-databind references and Android
does not have (R8 turns a missing class into a build error, so without them
there may be no APK at all), and a keep for the four `@Command` methods
declared on `app.tauri.plugin.Plugin` itself. Tauri's own rule matches classes
annotated `@TauriPlugin` and the members they declare; the base class carries
no such annotation, so nothing names `registerListener` — which is what the
lock-screen card's buttons and the download notice's Cancel and Open ride on.

`build.sh` grows `--release`: the release profile, `--features tauri/devtools`
so the walk can still attach at all, `assembleArm64Release`, and a zipalign and
sign with the same debug key the phone already trusts, so the APK installs over
the app rather than forcing an uninstall that would take Kyle's settings with
it. With no argument the script is what it has always been, cargo line for
cargo line, so deploy.sh and ship.sh keep the path they run every twenty
minutes. And it has been built. Not on the phone and not as arm64 — as **x86_64**, on an
emulator, because that is what this machine can run — but the flavour is the
only difference: the same Gradle, the same R8, the same rules, the same
zipalign and apksigner. `minifyX86_64ReleaseWithR8` succeeded and wrote **no**
`missing_rules.txt` at all, which is the `-dontwarn` pair doing its job;
`lintVitalX86_64Release` passed, so the `-x lintVital` advice is for a failure
that has not happened. The signed APK went over the debug app with
`adb install -r` and left its data alone, exactly as the same-key argument
above says it must.

Then the minified app was walked: Home, the record, playback, the queue, the
sheets, the lyrics, the drags, the export — **131 of 131, no exceptions, no
console errors, every step as expected**, and nothing in logcat about a missing
command or class. That is the failure this round exists to prevent, looked for
and not found.

What it does not cover is the hands-only list in ANDROID.md — the pickers, the
notification buttons, the share sheet — which no walk reaches, and the four
`@Command` methods on `Plugin` itself remain a precaution rather than a
diagnosed bug. The document says so, command by command.
2026-09-14 11:09:33 -05:00
KeelrLeek 9c3273e6af android: the waits the walk could not see, and why they are off by default
The adaptive waits went in unmeasured — the comparison was still running when
work stopped. Measured against round 42's 255 step verdicts they are not green:
six steps that were `ok` at baseline fail — `artist-lanes-page`, `net-playing`,
`net-sheet`, `np-more`, `np-copied`, `np-saved`.

They were not six faults. `net-playing` threw `Cannot read properties of null
(reading 'click')` because the network row it wanted had not arrived, and every
step after it then worked the wrong track: `np-copied` and `np-saved` were
hunting for `Copy to a folder` on a LOCAL track's sheet, which does not have it.

The reading asked for three things and got one. `resolver.active` is real.
**`net.busy` and `lib.loading` are not fields on those stores** — `net` publishes
`searching`, `responses`, `files` and `groups`, `lib` publishes `loaded` — so
both read `undefined` on every poll, joined a constant into the fingerprint, and
left a search in flight indistinguishable from a settled page. The catalog was
not read at all, which is `artist-lanes-page`: an artist lookup sits in
`catalog.artists[name].status` until it turns `ready`, and the mock holds that
page for four seconds on purpose.

The names are corrected, the catalog's loading entries are counted, and the
counters that move as results land are in the reading, so partial progress
registers as progress.

The second half matters more than the names. **A stable fingerprint is not a
settled page.** A search that has sent nothing back yet holds every counter
still, so "two identical readings 90 ms apart" was satisfied in the middle of
the work it existed to wait for. Work in flight is its own leading field now,
and while the resolver is active, a search is running or any catalog entry is
loading, a still page does not count as a settled one. An unreadable page reads
as busy for the same reason. The declared wait was always the ceiling and still
is.

That fixed four of the six. What it did not fix is the reason this is now
**off by default**: across three full walks the catalog and network cluster
fails a *different subset* each time — one run lost `net-arrived`, `np-copied`
and `np-saved`; the next got all three back and lost `net-playing`,
`cat-album-row` and the three that hang off `net-playing`. A gate that is green
at random is worse than a slow one, and every expectation in this file was
written against the declared waits. So `settle()` sleeps the wait it was given
unless **`SS_ADAPTIVE=1`** asks otherwise, and the walk prints which mode it
ran in so no log is ambiguous about it. Turning that variable on and off is
also the A/B the comparison needed and never had.

With it on the saving is real — 162 of 414 declared seconds on the mock walk,
95 of 224 on a device, where the walk went 131 of 131 with no exceptions and no
console errors. It can have the default when it is green twice running.

Three steps carry `hold: true` either way, because they wait on the mock's own
clock rather than on the app: a streamed file handed over on a seven-second
timer, and the two writes whose only evidence is the notice that follows them.
2026-09-14 10:11:53 -05:00
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43
to 48 (each patch adversarially verified, 43's transient-notice expectation
fixed), a partial round 49, the pipeline scripts, the Android Auto design and
the icon comparison mock. Also the two harness changes that were never A/B'd —
adaptive waits, which may only shorten a declared wait and never lengthen it,
and a ship that runs the mock walk and the deploy at the same time. The A/B and
its 255-step baseline are in handoff/tools; main stays at what the phone has
actually walked.
2026-09-14 07:57:41 -05:00
KeelrLeek 867fd42972 android: turned over, for real — the walk rotates the phone, and puts it back
Round twenty-seven wrote the landscape layout and proved it at 915×411 in
headless Chrome; no real device had ever been turned, and the phone walk
refuses a `{ view: [...] }` step because a device's orientation is not the
step list's to set. That refusal stays. The turn goes in the phone tail
instead, right after `background` has proved playback is live with the app
off screen, and it asserts the four things the mock physically cannot show:
`screen.orientation.angle` is 90 or 270 — an override without
`screenOrientation` leaves the angle at 0, so this one is only ever a real
phone — the WebView RESIZED as well (a separate clause, because a WebView
that never got the configuration change looks exactly like a display that
never rotated), `--nav-h` computed to 48px so the phone-sized landscape query
really fired, and the activity was not recreated: the same window token, the
same `performance.timeOrigin`, the same pid, no `AndroidRuntime` fatal — and
those last two are read tolerantly, because `pidof` exits 1 when the process
is GONE and `adb()` throws on a non-zero exit, so the plain read threw the
whole report away at exactly the moment it mattered. Then the stack, the
track still playing and past where it was (a track that rolled over during
the turn is still playing, which is all the clause claims), nothing clipped,
and the player sheet sideways in two columns with every control on screen.

The safe-area insets are printed, not asserted. Nobody had ever measured
them here — `MainActivity` calls androidx's `enableEdgeToEdge()` and nothing
in the generated Kotlin touches insets, so `env()` comes straight out of
Chromium's window-inset mapping — and asserting a number nobody has measured
is how a round fails on a correct build. What is asserted is the plumbing, on
both edges, because a hole-punch is only on one of them: whatever `env()`
says, the glass bar, the mini bar, the screen and the sheet spend it.

The phone is Kyle's, so the two rotation settings are read before either is
written and put back from four places — the block's own finally, main's
finally, `process.on("exit")` and SIGINT/SIGTERM/SIGBREAK — because a failed
clause only walks on but a WebView that stops answering throws clean out of
the loop, and Ctrl-C runs no exit handler at all. The latch clears itself
before it writes, a failed restore hands it back for the next caller, and the
skip paths disarm rather than restore, so a phone that could not be read, one
already sideways and `--no-rotate` all leave it untouched. Nothing else on the
device is written.

One thing beyond the turn: the `{ adb }` step kept its stdout. `am start`
prints "Error: Activity not started, unable to resolve Intent" on stdout and
exits 0, so `share-import` — the step that exists to prove the share intent
resolves — could not fail for its own reason. It can now.
2026-09-14 07:27:40 -05:00
KeelrLeek 33f32cb4c9 android: the long session — what the app is still holding after an hour
Every walk this project has ever run is minutes long, and nothing in the port
had been looked at for a leak. Driving the real bundle lap after lap says two
useful things. The first is that the obvious answer is wrong: the naive
add-minus-remove listener counter climbs about twelve a lap, and every one of
those is a `.screen` element that `{#key nav.seq}` threw away — count only
what is still attached to window, document or a connected element and it is
flat, lap for lap, and so are the intervals. The second is that exactly two
things grow without a limit. `art.svelte.js` kept every cover it had ever
fetched for the WebView's lifetime — one browse of a 114-cover library takes
it from 5 keys to 114 and gives nothing back, at roughly 63 KB of base64
each — and `pls.covers` is keyed `id:revision`, so every playlist edit
stranded a 360 px data URL under a key nothing could ever read again.

Both are bounded at the desktop's own numbers rather than at invented ones:
`ART_THUMB_CACHE_MAX` 512 and `ART_THUMB_CACHE_MAX_CHARS` 16 MiB out of
`state.svelte.js`, oldest first. Two rules the desktop does not need are
added, because its callers ask through `getArt(path)` while the phone's
templates index the map: the entry that just landed is never dropped, and
neither is one a connected `<img>` is painting — `Cover`'s `visible` action
re-arms only on a path prop change, so an evicted cover that is still mounted
would stay a disc for good. Built with the cap forced to 8 and scrolled
through eighteen screens of a 140-card Library grid, the map sat at 61 —
spared, every one of them, for being painted — and not one cover on screen
lost its image; at the shipped 512 a whole library walk runs the trim zero
times. The playlist covers need no policy at all: the new revision drops the
old ones, which are unreachable by construction.

The verification is the round. `artTally()` on the handle reports the map and
its side tables, which are module-private; seventeen probe fields carry that,
the catalog's ten session maps, the queue, the transfers and the DOM on every
step of both walks; and seven mock steps install a live listener and timer
registry in the page — by a step, not by `addScriptToEvaluateOnNewDocument`,
which the first-run block's reloads would throw away — drive two warm laps, a
baseline and nine more, assert the census, and take the registry back out so
nothing appended after this block inherits a patched platform. Installed that
late it can only see what is registered from that moment on, so its absolute
counts are small and are not asserted as a census; what is asserted is the
delta across the nine laps, and that it fires was proved by planting a
listener and an interval stranded once a lap: `grewEars` 0 → 9, `grewTicks`
0 → 9, and the step fails. As it ships, over those nine laps, the listeners
and the intervals moved by 0, the art map grew 17 against a headroom of 80
and the catalog 5 against 24, and the DOM ended smaller than it started.
`tallyTimers` is a ceiling and not a delta, and says so: every track start
schedules a 47-second recents refresh, so a walk this fast keeps a handful
pending, and making it sharp would cost a fifty-second quiet period. The
catalog maps, `tracksCache` and that recents timeout are named in ANDROID.md
as left alone on purpose — evicting a catalog entry is not safe while
`Artist.svelte` looks one up inside `untrack`. Nothing new on the phone leg:
the registry is never installed there, those three fields read −1, and
`artTally()` is a pure read.
2026-09-14 07:05:55 -05:00
KeelrLeek 9bfbcfc96c android: the phone walk waits for the app, not just for the WebView
A walk always follows a fresh install, so the app is always cold: the shell
mounts, the library loads, Soulseek logs in and the share index is built.
Attaching only proves the WebView answers, and the walk used to begin its
first step there — against a page that had not finished mounting, which
reads as a broken feature rather than a slow boot. It now waits for the
handle it drives, and the attach itself gets two more tries when the
devtools socket outlasts its own forty seconds of patience.
2026-09-14 06:43:53 -05:00
KeelrLeek 0ce8c63cb6 android: what the device has actually been tested for — the map, and the steps it could close
Most of this port is proven against an object in memory and only some of it
against the phone, and nobody had written down which was which. Writing it
down found four phone steps — `library-ambience`, `queue`, `lyrics` and
`community` — named and screenshotted with no entry in `PHONE_EXPECT` at all,
and the driver reads a missing key as a pass, so all four had been green since
the rounds that wrote them; they have assertions now, tolerant of Kyle's own
library and of nothing else.

Thirty-three more steps move onto the device: everything that can be proven
there without downloading, joining, sending, signing in or writing to his
library. The library's shape and its sort (nav state, not config), a local
track's sheet off a real path, Search's recents out of this WebView's own
storage, a queue row's sheet opened and not tapped, the walk's own playlist
with a row removed and put back by Undo — the first round trip that stash has
made through real SQLite — the import review's Keep switch restored before the
draft is created, the sleep timer set and put out by the next step, the
stream-cache chip and the Wi-Fi-only guard read first and restored last, and a
port the core has to refuse.

ANDROID.md carries the map beside Verifying: what the device walk proves (121
named steps, up from 88, plus the seven things after the loop that no mock can
do), what stays mock-only and why — needs `__ssMock`, needs a fresh install,
needs a viewport the phone walk refuses to set, needs the dev flavour, or
would download, join, or destroy his data — which six of the new steps are
conditional on what his own library happens to hold, and what still needs a
person. No product code in this round: four inserts in the phone half of
smoke.mjs, one in the doc.
2026-09-14 05:20:02 -05:00
KeelrLeek a8adce087f android: the phone walk waits for the WebView instead of giving up on it
A launch leaves a window where the app is up, focused and has a pid while
the devtools socket the walk attaches to does not exist yet — on a loaded
machine it has been as long as ten seconds. attachPhone tried once and
reported "is SoulStream on screen?" about an app that plainly was. It now
tries for fifteen seconds, re-reading the pid each time in case the app
restarted underneath it.
2026-09-14 04:55:17 -05:00
KeelrLeek 537bfdd5af android: the faces the walk had never seen — six failure states, read back in their own words
Round thirty-one gave the phone honest error states and shipped six of them
unobserved, because neither `loadHome` nor `loadLyrics` was reachable from the
`__ss` handle and both answer a second ask with nothing. The handle carries the
two stores and their two loaders now, and a block of mock steps arms each
failure with the mock's own weather, drives the face, and asserts the sentence
a person reads as well as the absence of the Rust string behind it: Home's
catalog line, the Gaps line and its second tap, a catalog record's stub, the
artist page's two different answers, a playlist that would not read, the lyrics
lookup that failed, a refused transfer, and the library toast's Try again,
pressed for the first time.

It sits before the first-run block, which reloads and empties the mock's
follows; every failure is disarmed by the step that proves the face heals, and
a last step asserts nothing was left armed. The phone walk gets a safe step of
its own — a terminal transfer row written into the store and taken away,
nothing fetched and nothing reaching the shade. ANDROID.md now names what the
walk still cannot see: three screens that draw a raw backend string, six error
faces with no expectation, and the history mirror printed on every step and
asserted on none.
2026-09-14 03:59:09 -05:00
KeelrLeek f2cde4ec7c android: the whole discography, on a page of its own
The artist page already filed every release the desktop's way, but it only
ever showed one category at a time and it showed all of it: no slice anywhere
in that grid, so an artist with 140 releases drew 140 cards. There is a page
now — All in front of the categories, a sort, a grouping by year, and the
editions opened in place under their own record, which is the part a flat
list loses — and the grid behind it stops at twelve with the way down under
it. It is the same classifier twice over the same cached albums, which the
desktop cannot do because its filing waits on a background description
harvest; the three things its own page has and this one does not — a
tracklist per record, an accent sampled per cover, the scroll-led banner —
are 140 calls and 140 decodes over mobile data for rows that are one tap
away, and ANDROID.md says so rather than leaving them looking forgotten.

The list caps at sixty with the rest a button away, before the grouping, so
the last year on screen can be a part of itself; under a name order By year
is not offered, because the years would repeat out of sequence. The mock
gains a 140-record artist so both caps are walked rather than argued about.
2026-09-14 03:41:16 -05:00
KeelrLeek 1258b2b78d android: the rows go where you put them — a real drag on the playlist and the queue
The phone stood in for the desktop's drag with Move up / Move down / Move to
the top on a row's sheet, which is fine but is not what a finger is for. Press
and hold a row for 320ms now and it lifts, the rows below make room, and
letting go lands it — on the playlist page and on the player sheet's queue,
through one action in src/mobile/reorder.js. The Move rows stay: they are the
accessible path round 28 named to a screen reader, and they are the only way
to reorder while the filter is on. Release a press without having moved it and
the row's sheet opens exactly as a long press always opened it, which is what
keeps the queue's sheet reachable at all — a queue row has no ⋯, so
`oncontextmenu` is its only door to Play now, Move to next and Remove from
queue.

Most of the work is in not fighting the page. Nothing carries
`touch-action: none`, because a flick that starts on a row is how the list is
scrolled and `touch-action` is latched at touchstart anyway; the scroll is
refused instead by a non-passive `touchmove` listener added at the lift, which
works precisely because the finger has been still for 320ms and the browser
has not started one. A press that moves more than 8px before then is a scroll
and the gesture lets go of it. Click and contextmenu guards sit on the
container in the capture phase while a lift is live, or a released drag would
fire the row's own click and play the track. Rows are measured once at the
lift in the scroller's content space, so the edge pull — within 64px of either
edge — leaves the snapshot true, and each displaced row moves by the exact
difference between its top and its neighbour's rather than by one assumed row
height, because the queue face puts album separators between rows.

No new mutation and no new command: `moveItem` and `moveInQueue` each take a
numeric target now as well as "up" / "down" / "top", so there is still exactly
one call to `playlist_set_order` carrying the whole id order, and the existing
queue guards were already the right guards. The drop is optimistic and snaps
back with the error toast if the backend says no. Two refusals cover the
middle of a gesture — the playlist drops nothing if a refetch has replaced its
rows, and the queue drops nothing if `player.qi` has moved — and dragging is
off entirely while the playlist's filter has anything in it, because a
filtered list's visible order is not the stored order. Both walks drive it
with real pointer events and assert the order out of the store: a drag, a drag
back, a refusal under the filter, a press that still opens the sheet, and a
drop the backend is armed to reject.
2026-09-14 03:24:26 -05:00
KeelrLeek defd8c5e6d android: a playlist file, picked
The storage plugin grew `pick_file` beside `pick_folder` and `pick_image`:
the document sheet with the caller's own filter, answered with a path Rust
can open. A document already on this phone hands its path over as it lies —
the document sibling of the same volume:relative id the folder picker
resolves, plus the Downloads provider's raw: — so a 300 MB pick copies
nothing; one that has no path is copied into cacheDir/picked under its own
name, and only as far as the caller allows, because a playlist is kilobytes
and a DJ set is not. Those copies are swept at the start of every pick,
which pick_image's never were.

The import sheet has its fifth door, on round 24's screen, wait card and
review, and the note that apologised for its absence says the one true thing
about it instead. `playlist_import_probe` took a path and a name the way
`playlist_import_folder` took a path, and the name is the load-bearing half:
the parser picks its format off the extension and titles the draft from the
stem, so a cache copy under an invented name would have read every playlist
as plain text. The mock walk drives the door and both of the picker's
refusals through round 31's fail door; the phone walk opens the real sheet
and dismisses it with BACK, reading nothing and writing nothing.

The brief asked for a second consumer — sending a file in a chat — and there
is nothing behind it: `community_pick_file` is the persona layer's three
native dialogs, `soulseek-client` has `say_room` and `message_user` and
nothing that sends a file, and both frontends' thread composers are text
only. Nothing was invented for it. The plugin command is general enough — a
caller's own filter, a zero-copy path, a bounded copy — that the one reader
still waiting, the local set listener the phone puts off by design, needs no
further plugin change. Playlist export is not on that list and never was:
writing a document out is the share plugin's errand, not a picker's. The
Kotlin is unbuilt — nothing on this machine compiles it.
2026-09-14 02:55:56 -05:00
KeelrLeek 04c87e0db1 android: a playlist, out of the phone
The desktop has had Export since playlists v2 and the phone had no door onto
it for one honest reason: `tauri-plugin-dialog` does open Android's
create-document sheet, but it answers with a `content://` URI and
`playlist_export` wrote with `std::fs::write`, which cannot take one. The
storage plugin grew a `create_document` whose Kotlin writes the bytes through
the ContentResolver — a plugin function called from Rust, not a command, so
the file never passes through the WebView and nothing was added to the ACL —
and `playlist_export` now builds the file above the platform fork with the
same `playlist_formats` writers, so the phone rebuilds no format and the two
exports cannot drift. The door is the playlist's own sheet, on the page and
on the shelf card, with the desktop's five profiles in the desktop's order; a
dismissed sheet says nothing, because nothing happened. The mock walk drives
it three ways — a file written where the sheet pointed, a sheet dismissed, a
refusal read back as a sentence — and the phone walk opens the real sheet and
dismisses it with BACK, writing nothing to the phone. The Kotlin is unbuilt:
nothing in this tree compiles it, though it is written against
tauri-android's own `PluginHandle`, `Invoke` and `JSObject` rather than
against a guess at them.
2026-09-14 02:38:44 -05:00
KeelrLeek 95c2bfeeba android: the first run — a folder to keep music in, and an account to reach the network
Nobody had seen this app on a phone that has just installed it. On Android
the library root is never missing — config.rs falls back to the app's own
sandbox, a real folder nothing else can see — so "no folder yet" cannot be
a null check, and setup_done has never been written by the phone, so every
phone in existence reads as unstamped, Kyle's included. The tour therefore
opens on three clauses, not one: no flag, no saved account, and no records,
read only after the library and the credentials have both answered. Two
cards, skippable, and no password anywhere in it — the Soulseek step goes
to the card on Community and stops.

Every screen that is empty because of those two things now says so with the
row that fixes it: Home (whose copy predated the folder picker), Library →
Albums, Library → Artists, which had no empty state at all, and Search,
where a flat sentence about signing in became the row that does it. A row
goes away because the config moved, so the two doors onto the same folder
tell each other — You re-reads when the tour moves it, the tour's store is
told when You's own picker does — and the all-files row re-reads the grant
when the system screen hands the app back, the way You already did.

You → This phone opens the tour again as a preview that reads the config
and writes nothing, which is the door the phone walk drives on Kyle's own
device. The mock can answer as a fresh install now — an empty database, no
account, no permission, the sandbox root — so the whole thing is walked end
to end, and two commands that had no mock at all (player_control,
open_external) got one on the way past. It is the mock walk's first reload,
which turned up two things a reload does not clear by itself: the player
writes the session back on pagehide, so the fresh block clears it from a
pagehide listener of its own, and the viewport emulation lives on the
DevTools session, so the harness hands it back after the re-attach.
2026-09-14 02:21:37 -05:00
KeelrLeek 46cf6a98fe android: the sweep — what thirty rounds left behind, and the byte that hid a file from grep
Nothing here changes what the phone does, and the walks are untouched on
purpose: they are the guard, not the subject. Out go nine imports no file
ever names again, two things nothing in src, tests, crates or src-tauri
has ever referenced — `forgetAccount` in the Soulseek store, whose Sign
out row calls `disconnect`, and `isLossless` in util.js — and two CSS
rules no markup can reach, `.cardpad` and `.iconbtn.liked`; `stamp` and
`fmtCount` had each been written twice word for word, so both bodies move
into util.js unchanged and four screens gain a name on an import line they
already had. The byte is in player.svelte.js, where the play-history key
joins artist and title on a NUL that was written into the source as the
character itself rather than as the escape catalog.svelte.js already uses
— that made the file non-text, so every grep of it since has answered
"Binary file matches" and shown nothing. One line is added rather than
removed: `.mini .mini-lane { width: auto }` reads exactly like the strays
and is the only thing keeping the lane from coming out 32px wider than the
strip it was cut for, so it now says so — and `.feat`, declared twice in
shell.css with different values, is left alone, because untangling that
moves pixels and this round may not. A tenth dead import is missing from
that first list on purpose: Sheet.svelte's `import * as api` is a namespace
import, the one shape Rollup cannot shake, so its reason was the bundle
rather than drift and it went out with the round before.
2026-09-14 01:55:00 -05:00
KeelrLeek 01bab007f7 android: the boot chain goes out in one round, and a census that holds it there
Thirty rounds of onMount had never been read end to end, and read end to end
it was a queue: two dozen commands with a critical path ten deep, because
Tauri's `listen` is an invoke like any other. loadLibrary asked for five
independent SQLite reads one behind the other, bindSlsk registered eight
listeners one behind the other, and watchLibrary and the player's bindEvents
did two each. None of them depended on anything before them. They go out in
one round now — the same commands, critical path 10 to 3, and ten of them
used to leave in the shell's own first turn where twenty-three do now — and
in a rig that runs the real store modules with the bridge instrumented,
lib.loaded lands at 53 ms instead of 85 at a 6 ms round trip and 62 instead
of 148 at 20 ms. Those are a desktop rig's milliseconds with the round trip
swept rather than measured; the phone's own numbers are unproven until the
walk prints bootMs and bootLibMs.

The behaviour is meant to be identical. loadLibrary's only movable part was
lib.error, which has two writers and used to be decided by the order they were
awaited in; the two messages are now carried out of the round and applied in
that same order, so a failing library still shows the library's message. Every
combination of which of the five reads fails, over six latency shapes, comes
out identical to the old code. bindSlsk still does not return until every
handler is up, so connectStored cannot let a fast connected state land with
nobody listening, and bindEvents still hands back the transport's unlisten and
still rejects only when the transport is the one that failed.

src/lib/boot.js is the project's first launch instrument — one counter every
invoke and every listen passes, sealed at the first frame the shell paints and
never re-opened, so a probe landing a second and a half later still reads the
launch. perf.mjs measures frames; nothing measured the launch. The mock walk's
Home step now holds a budget against it and both walks print bootCalls,
bootSync, bootMs and bootLibMs on every step. bootSync is the clause with no
clock in it: a call that waits on the one before it can only issue its first
in the shell's own turn, so a chain put back in a queue falls under the floor
whatever the machine is doing. A bootCalls of -2 means __ss.boot is not there
at all, which is the census itself being broken rather than the app.

One import line goes with it: Sheet.svelte's `import * as api`, the only one
of the ten dead imports this round turned up whose reason is the bundle at
all. Rollup already drops an unreferenced NAMED import, so a dead name on an
import line is hygiene rather than bytes; a namespace import is the one shape
tree-shaking cannot see through, and this one was dead. It frees nothing on
its own — twenty-nine other files in the phone's graph hold the same module
the same way, so api.js is pinned into the chunk whatever Sheet.svelte does —
but it is the only one of the ten that belongs to a round about weight. The
nine that are drift go out with the sweep in the round after this one.

Two things were looked at and left alone, both written down in ANDROID.md
rather than half-done. Home's per-artist catalog walk is the deepest chain in
the app, and that serial walk is the Deezer and MusicBrainz pacing, not an
oversight. And the follows really are read twice at launch, but the obvious
guard does not work: Home's effect calls loadHome with force the first time
they land, so the guard would never fire. One small local read, left for a
round that can do it properly.
2026-09-14 01:38:15 -05:00
KeelrLeek 052e15302b android: what the phone says when things go wrong
Fifty-nine places put a raw Rust string into a field a person reads, and
the red toast drew it verbatim — a phone in a lift showed "could not reach
server: io: failed to lookup address information" under the password box.
One translator, src/mobile/trouble.js, called where an error is DRAWN
rather than where it is caught: the stores keep what Rust said and only
the sentence on screen is ours, so every sink is fixed at once. Five
screens that drew a failed read as an empty answer stop lying — Tracks no
longer reports a broken database as an empty library, a playlist that
could not be read is not an empty playlist, the lyrics face says a lookup
failed instead of "no lyrics" and finally carries the ⋯ that reaches Paste
lyrics, Gaps says why it could not reach the catalogue, and Search signed
out says so where it used to render nothing at all. The room list, which
is fire-and-forget over the wire, gets a twelve-second end and a Try
again.

rescan_library ran prune_missing() first, which deletes every track row
whose file does not exist — so one tap on You → Rescan library with the
folder unreachable emptied the whole index and returned Ok(()). It now
makes the root if it is merely not there yet and refuses if it cannot be
read, and You draws the path with what to do about it.

The mock grew a weather system so the walk can drive all of this: fail,
answer, offline and clear, checked before dispatch so a plugin command
arms like any other. Nine mock steps and one on the phone — the first
steps in thirty rounds that ever made a command reject or saw the client
signed out — and the last of them puts the weather away and leaves the app
as it found it.
2026-09-14 01:23:26 -05:00
KeelrLeek 44eda8dff5 android: the room can measure the song, and a cold artist page stops waiting on one call
The tap-to-sync room draws the recording's own vocal band
(lyrics_vocal_activity, the desktop's coarse envelope) as a strip under the
head, and one button lays every line across the stretches it says are sung —
the desktop's own generator, shared and unit-tested, which refuses rather
than guesses when there is too little voice to read. The result is labelled
an estimate everywhere it shows, the cursor goes back to the top so the taps
correct it, and one more tap drops the whole thing back to an empty set,
because a rough fill stamps every line at once and leaves Undo nothing to
take back. The artist page now fires the hero seed and the overview beside
the full page, the way the desktop stages them: a hero Kyle picked on this
phone comes back off the file with no network at all, the discography paints
from the overview's first page until the exhaustive pass lands with the
classified one, and both lanes are dropped with the page when a hero
changes. ANDROID.md's "Not done yet" is rewritten as the true map of the
port — what is absent by design and why, what is waiting on Rust rather than
on a file picker, and one bullet about the artwork viewer that had been done
for rounds.
2026-09-14 00:58:14 -05:00
KeelrLeek 5738c2d7bf android: the downloads say so in the shade
A Save still running with the app in the background now posts a progress
notice — how many files, the one it is on, a bar, Stop all and Open — and
takes it down when the queue empties. It sits on its own low-importance
channel so it can be silenced without silencing the player's card, and
rides its own dataSync foreground service rather than the media one: a
mediaPlayback service stops itself when the bar empties, a download is not
playback, and nothing else was keeping the process alive for a Save with
nothing playing — which is how the first real Save died, four tracks in.
Stop all goes through the same cancel(id) a row's tap takes, and Transfers
grew the matching button beside Clear finished. The walk pushes two rows
straight into the transfer store and reads the real notification back out
of `dumpsys notification`, without fetching anything.
2026-09-14 00:44:11 -05:00
KeelrLeek 4fc6e9c630 android: what a screen reader says — names, roles, and the sheets out of the tree when they are down
Every control on the phone has a name now, in the app's own words, and the
desktop's word where it already has one; the nav is a tab set whose badge
count is part of the tab's name — "You, 3 transfers" rather than a stray 3
read out before it. The two sheets are dialogs with somewhere sensible for
the focus to land, and the half sheet's grab handle is finally a real
Close — the first dismissal a screen reader could reach, because the scrim
never was one. A sheet that is down is inert rather than sitting
off-screen in the accessibility tree with the hidden mini bar and the nav
under it, and one live region is mounted for the life of the app so that
an undo offer is actually announced; switches say on or off, chips say
pressed, and the waveform is a slider where it can seek. Nothing visible
changed. The walk counts the controls with no name, with a junk name, in a
hidden layer, or with unsaid state — all four held at zero on ten screens,
and the nameless count on a room and on both prompts besides, which is
where the six fields named here actually live.
2026-09-14 00:28:28 -05:00
KeelrLeek 9df162360e android: the phone turned sideways — the player beside itself, the chrome cut down
The phone UI was drawn portrait-first and had never been looked at in
landscape. The player sheet was the casualty: its cover is a square sized by
width, so at 915×411 it came out 879px tall and the title, the scrubber, the
transport and the face buttons all sat below the bottom of a sheet that does
not scroll — a corner of the artwork and no way to pause. Sideways the sheet
is two columns now, the cover sized by height beside the controls, with a
lyrics or queue face taking the cover's column; the nav and the mini bar come
down to 48 and 60dp, the artist hero becomes the banner the desktop's hero has
always been rather than 300px of a 411px screen, the record head puts the
cover beside the title, and the shell reads the side insets the cutout and the
gesture pill take when the phone is on its side. The walk turns the phone over
and back on its own: `{ view: [w, h] }` is a step kind now, and the steps
assert what used to be off the screen.
2026-09-14 00:14:23 -05:00
KeelrLeek aff29aba5c android: a streamed file copied into a folder, the listening port, the dev build's diagnostics
The now-playing sheet's streamed track can be copied into a folder picked
on the phone, as the file it is — the cache written out, nothing downloaded
again, no library row (copy_stream_to_folder, which the desktop has had a
command for and no UI). You → Sharing carries the listening port, what
peers dial when they download from you: unset is the client's own 50300,
the app asks the router to forward it over UPnP and falls back to the
server relaying, and a change applies on the next connect. The dev flavour
gets the desktop's debug overlay as a screen under This phone — CPU,
memory, the network, the stream and its loudness, what the pre-buffer is
fetching, the size of the play history — behind devBuild in util.js, which
is Vite's own DEV flag, so the embedded build the phone keeps has no row
to tap.
2026-09-13 23:59:47 -05:00
KeelrLeek 1737c5e1b6 android: the catalog's numbers — Popular's own lane, a count per row, the artist's photo
Popular is asked on its own as well as with the page, so the section can
paint before the full page is built and an artist whose page came back
with none still gets one. A catalog record's rows that the album's own
artist anchor found no count for — a feature's guest, a compilation's
stranger — get the deferred Last.fm pass by their own artist id. The
artist page's ⋯ is its own sheet now: choose a hero photo from this phone,
or put the catalog's own back; set_artist_hero is pick_artist_hero's
install step split out and shared, so the desktop's dialog keeps its door.
You gains the desktop's Refresh artist portraits, and a data: URL is the
image itself in the art gate — a chosen hero comes back from Rust as one,
and the thumbnail fetch used to throw it away.
2026-09-13 23:46:07 -05:00
KeelrLeek c737809325 android: a folder of audio files, and a paste of track links
The paste door now reads itself the way the desktop's does: one track link
in the paste and every line is looked up on its own public page
(playlist_import_track_links), which is the way past Spotify's 100-track
public ceiling — the wait screen counts them as they arrive. A fourth door
imports a folder of audio files off this phone, picked with the storage
plugin's own document-tree picker; playlist_import_folder grew a path
argument for it, because on Android it had no dialog to open and always
answered nothing. A YouTube playlist link needed no door at all — the link
one already dispatches it.
2026-09-13 23:32:00 -05:00
KeelrLeek 24c6857847 android: tap-to-sync, the per-track auto-sync switch, the vocal-reduced copy
The editor the desktop drives with a mouse is better under a thumb: the
song plays, the line to time sits large in the middle of the screen with
the one behind and the one ahead faint around it, and a tap anywhere
stamps it. One layer the back gesture peels, and a set left mid-tap is
offered back by the row that opened it; Save writes the track's own
document, lines never tapped left out of it and the button saying how
many are kept. The lyrics sheet also gains the per-track auto-sync switch
and the desktop's reduced-vocals copy — the same title, artist and
duration, so the words keep following it, and one row puts the original
back.
2026-09-13 23:04:49 -05:00
KeelrLeek e693dfb868 android: your own Soulseek interests, and who the server says is like you
Community's fourth tab is Taste: the likes and hates this client
publishes to the Soulseek server (slsk_interests_mine, slsk_interest_set),
added and taken back as chips, and the people the server ranks as close to
them (slsk_similar_users) — a row each into the peer sheet the app already
has. Every declaration asks the server again and the chips stay quiet
until it has answered, the way the desktop does, because the ranking only
moves when its input does. The lists live in taste.svelte.js, read once
per account when the tab is opened; the mock walk declares a like, watches
the match grow, declares a hate and takes the like back.
2026-09-13 22:52:03 -05:00
KeelrLeek 7eae0ee945 android: a name of your own for anyone on the network
The desktop's local contact metadata on the phone: a peer's sheet ends in
Name and notes — a petname, a group, a private note and the Favorite and
Trusted marks, kept in the app's own config and published nowhere. The
petname stands in for the username on the buddy list, in the chat list, on
a thread's bar, in the peer sheet, in a room's roster and on a room line,
and the real username stays with it — on the line beneath, or beside the
name in a room — so a stranger is never quietly turned into someone you
know. Favourites sit at the top of the buddy list, and clearing a person's
details drops the entry rather than storing an empty one — the backend's
own rule.
2026-09-13 22:40:17 -05:00
KeelrLeek f792d7955d android: a peer in full — their card, their interests, and what they have that you don't
The peer sheet's new "About this user" row opens a screen of its own: the
card their client serves (their own words, their picture, the upload slot
and the queue), the likes and hates they declared, and the comparison this
phone makes of their share against the library. The comparison is the
point of opening a stranger — every record they have by an artist in your
library that you don't, each row opening the search that finds it, and a
way to forget the whole comparison again. Nothing is uploaded to work it
out. Their picture opens in the artwork viewer, which now takes a data:
key beside a library path and an https URL.
2026-09-13 22:28:38 -05:00
KeelrLeek 694a29eb0d android: downloads on Wi-Fi only
One switch the desktop never needed, off by default: on mobile data every
Save the phone can start — a record, a track, a folder, a file, a
playlist's bulk, the now-playing Save — waits, with the reason on the bar;
streaming is never held back. The walk flips the switch, makes the
connection cellular and sees a download refused.
2026-09-13 22:15:07 -05:00
KeelrLeek 3c8989dfa6 android: the hub's filters on the network results, pasted lyrics
The raw results take the Soulseek hub's filters — a free slot only, FLAC
or MP3 320+ — as chips above the folders. The lyrics sheet has the
desktop's paste door: plain text, or LRC with timestamps, kept for the
track.
2026-09-13 22:03:57 -05:00
KeelrLeek 59be9f6c3e android: the nav's badges — transfers in flight on You, unread chats on Community
The desktop's title-bar download ring and its unread indicator, on the
phone's nav: a count on You while Save is fetching, a count on Community
when a chat has unread lines.
2026-09-13 21:52:53 -05:00
KeelrLeek f8c3b90665 android: hours on the library's count line, By format, Jump back in on the empty search
The desktop Library summary's hours on the phone's count line; a By format
order from the file stats (lossless by depth and rate first, then by
bitrate, the badge as the card's line); the empty Search page's Jump back
in — the last records played — as the desktop's empty search shows them.
2026-09-13 21:31:16 -05:00
KeelrLeek b50577fc81 android: a playlist's cover from a photo on the phone
The storage plugin gains pick_image — the document picker limited to
images, the picked content copied into the app's cache so the Rust side
gets a plain path. playlist_cover_set(id, path) is the desktop pick's
install step split out and shared (squared, the master cover, the row's
revision moved). A playlist's sheet offers Choose cover photo and Remove
custom cover; the mock walk exercises both.
2026-09-13 21:20:11 -05:00
KeelrLeek d95c3f48d1 android: the room list filtered by name, the rest on demand
Soulseek lists hundreds of rooms; the phone showed the first 80 and no way
to the rest. Community → Rooms now has a filter by name and extends the
list 200 at a time.
2026-09-13 21:07:11 -05:00
KeelrLeek 60303c8a95 android: reorder from the sheet — a playlist row up, down or to the top; a queue row up or down
Without a drag: a playlist row's sheet moves it up, down or to the top and
persists the whole order in one call (playlist_set_order, as the desktop's
drop does); a queue row's sheet moves it up or down, never past the track
playing. Also src-tauri/android/ship.sh — one round end to end: the mock
walk, the embedded build installed, the phone walk, the commit, each step
gated on the one before.
2026-09-13 20:55:50 -05:00
KeelrLeek 991d52d0cc android: the library's file stats on the cards, the artist's own plays, the thumbnail prewarm
The desktop Library's per-record file stats on the phone (library_album_stats,
one call for the whole library): FLAC 24/96 or MP3 320k badges on the cards,
the size on the list rows and the record page. The artist page's own
listening (artist_play_stats): the time played as a chip, the most played
track under "On this phone". The whole-library thumbnail warm a moment after
boot, at the cards' edge, one decode at a time behind the tiles' gate.
2026-09-13 20:37:51 -05:00
KeelrLeek 26928b706d android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest
The playlist repair (Find a local match: the library searched by the
row's name, a pick relinks it through playlist_relink_item; an unknown
row takes the track's identity). A peer's sheet on the raw results (play
or save the folder, browse their files, message, buddy) and the Browse
screen — the hub's Browse tab: a user's whole share with sizes and totals,
a folder played or saved whole; a share can hold thousands of folders, so
it filters and renders 80 at a time. "Look up a user" on Community →
Buddies. The sheets the phone lacked: a queue row's (Play now, Move to
next, Remove from queue), a catalog song's, a catalog card's and the
record page's own, a raw file's (in the results and in Browse), a room
name's, a buddy's, the now-playing Save (the desktop's Save menu for a
streamed track or record). The artwork viewer with pinch zoom, one layer
the back gesture peels. Search's recent searches and its spelling
suggestion from the names this phone knows. You → Streaming, Downloads,
Sharing, Lyrics, Album artwork, and Clear play history; a record's
Re-fetch the cover. The lyrics tools on the sheet's face: the offset
nudged and kept per track, another LRCLIB candidate chosen, the lookup
forgotten and done again (lyrics.svelte.js, shared by the face and the
lyrics sheet).

The harness: every CDP call times out and the reload step re-attaches
(a WebView that stopped answering hung a walk for ten minutes); the
audio check counts SoulStream's own tracks' underruns from the mixer dump
instead of the thread-wide counters that any app's notification moves —
the afternoon's "dropouts" were other apps' sounds, and the browse of a
49,544-file share measured at zero; __settle resets every tab to its root
through the nav; an adb step kind; the walks cover all of the above on
the mock and on the phone.
2026-09-13 19:56:20 -05:00
KeelrLeek 6e39dc43f3 android: playlists, the import doors, the share sheet, You → Sources — and three bugs the walks found
The desktop's two playlist pages on the phone: the shelf (pinned first,
mosaic covers, badges, New playlist, Import) and the page (kind, Pinned
chip, description, the meta line's warnings, Play / Shuffle / more, a
filter, rows tagged unresolved / set audio / file missing). Sheets for a
card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm;
Add to playlist on every track and record sheet; a removal undoable for
seven seconds from the neutral toast (notice.svelte.js); stream rows play
through the resolver as path-less entries, a set slice starts and ends at
its cues; Add to library / Download all tracks with the desktop's bulk
rules; nav.replace for a review that became the thing it made.

The import doors that need no file — a link (YouTube · Spotify · Apple
Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the
review screen and the desktop's catalog verification pass. A share plugin
crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps
the text in a ShareActivity and the WebView hands it to the import when it
comes back to the front. You → Sources: the source-quality window and the
priority per mode.

Found by the walks and fixed: set_settings takes camelCase arguments, so
the phone's normalization and library-folder saves never landed;
Cover.svelte's non-eager wrapper has no layout box, so its intersection
observer never fired and row thumbnails never loaded (the Artists tab too);
a history push while a history.go was still in flight left the nav mirror
one entry ahead and the page blank after a confirmed delete — the mirror
now applies once per microtask and never pushes during a pending traversal.

The harness names unmocked commands, carries console warnings' frames (the
smoke bundle is built unminified), probes the history mirror on every step,
mocks app_status / play_local / waveform / lyrics_get / player_state and
applies settings patches, runs adb steps on the phone (the share intent),
and walks playlists, the imports, the share and the settings on both the
mock and the phone. ANDROID.md documents all of it.
2026-09-13 17:56:52 -05:00
KeelrLeek bb828ff2dc android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour
The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.

Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.

The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.

The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
2026-09-13 09:30:06 -05:00
KeelrLeek 972dd103cb release: 1.8.2-alpha
release / windows (push) Canceled after 0s
2026-09-10 07:19:18 -05:00
KeelrLeek 12fff9f6ad setup, updater: the installer window was a dev build with nothing inside it, and latest never matched a prerelease
Tauri decides dev-vs-production from the `custom-protocol` feature, not from
--release, and both workflows build the setup exe with a plain `cargo build`.
So cfg(dev) stayed on, the frontend was never embedded, and the window a
tester opened loaded devUrl: "localhost refused to connect" on a blank
800x580. The feature is a default of the crate now, with a build.rs guard
that fails a release build without it, and build.rs tracks setup/dist so a
changed window cannot be left out of the binary it is supposed to be in.

Behind that, the next wall: /releases/latest answers only with full
releases, and every release here is an alpha, so it 404s. The setup app read
that as a refused key and the app's Stable channel read it as a token that
cannot see the repository — both have been broken since the first alpha.
Each now lists /releases and takes the newest that is neither a draft nor
the rolling nightly, which the list really does put first.

And check_saved_key threw the saved key away on any failure at all, a
dropped connection included. The entry it deletes is the app's own updater
token, so only a refusal clears it now.

The release notes name the file to hand a tester: six assets on the page and
nothing saying which, while the bare NSIS installer is the biggest of them
and installs with no key gate and no signature check.
2026-09-10 07:19:15 -05:00
KeelrLeek 3cfff93036 release: 1.8.1-alpha
release / windows (push) Canceled after 0s
2026-09-09 18:10:29 -05:00
KeelrLeek f8508da081 setup: the installer is our own window — the alpha key is the gate, the release comes down with it and is verified before the silent install, and the in-app tour asks for the key too 2026-09-09 18:00:02 -05:00
KeelrLeek 8093835482 release workflow: the version step is a block scalar, so its quotes reach bash whole
release / windows (push) Canceled after 0s
2026-09-09 16:20:13 -05:00
KeelrLeek fac6fc33dd release: 1.8.0-alpha
release / windows (push) Canceled after 0s
2026-09-09 16:16:22 -05:00
KeelrLeek cd2391198e first run, updates, installer: a setup tour stands in front of a fresh install, releases arrive through an in-app updater with stable and nightly channels behind a read-only token, and the branded installer builds again 2026-09-09 16:16:19 -05:00
KeelrLeek 07810f7543 release: 1.7.1-alpha 2026-09-09 12:13:10 -05:00
KeelrLeek 4ea1582afc library: the toolbar is a solid card under dark glass, because a backdrop blur over the frost lets titles through 2026-09-09 12:12:56 -05:00
KeelrLeek aaacfc9ff5 holdings: a dash-led edition tail normalises like a bracketed one, so the catalogue's "Pinkerton - Deluxe Edition" finds the member who holds Pinkerton 2026-09-09 12:12:55 -05:00
KeelrLeek 1c66602b89 who has: the sheet closes when the page scrolls under it, instead of floating beside a card that has moved 2026-09-09 12:12:55 -05:00
KeelrLeek 67b7444f82 settings: each section heading carries the same icon as its index entry 2026-09-08 16:28:18 -05:00
KeelrLeek 48ae01c753 settings: Appearance gets a real icon — "display" was a CSS property that had leaked into my list of icon names 2026-09-08 16:16:40 -05:00
KeelrLeek a0528c6d3f settings: each index entry carries its section's icon, lit in the accent when it is the one on screen 2026-09-08 15:45:42 -05:00
KeelrLeek 07ac3d3631 settings: the long lists use the app's own chooser — the sort menu's popover under a pill — instead of native selects the webview draws with the OS's text 2026-09-08 15:43:13 -05:00
KeelrLeek 7e291cfccc settings: one long page of section cards behind a sticky index, segments and switches for the choices, and the Networks card moved in from Community 2026-09-08 15:39:19 -05:00
KeelrLeek 6c44f3e2ab community: a chat thread stretches with the pane too — no inner width cap remains 2026-09-08 14:53:05 -05:00
KeelrLeek f006c1cd5c community: the page fills the pane edge to edge instead of sitting in a centred 1520px column 2026-09-08 12:55:10 -05:00
KeelrLeek 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 2026-09-08 12:10:49 -05:00
KeelrLeek a98c750908 holdings: a member of a Space publishes what they hold as room-salted album keys, sealed to the room, so "who has this" is answered while they are offline 2026-09-08 12:10:49 -05:00
KeelrLeek 08ca09586a community: Discover ranks the shelves it already has into one, and every card still says where it came from 2026-09-08 09:48:07 -05:00
KeelrLeek 8285b97cbe shared playlists: a follow with news says so through a toast, and a follower's rows keep the owner's catalog ids 2026-09-08 09:48:06 -05:00
KeelrLeek 594a33a302 freenet node: the update exit is watched from a thread for as long as the check takes, not for two seconds inside the start call
The supervisor that applies the node's update-wanted exit (code 42)
watched a fresh child for two seconds and then let go. The exit is the
result of a network check the node makes at startup, and it takes as long
as the network takes: on a fresh install today a pinned 0.2.132 exited
several seconds after launch, after the supervisor had already returned,
and the layer read "node absent" with only node.log saying why — the
exact failure the supervisor was written against, back again.

`start_node_inner` now puts the child in `freenet_node` itself and leaves
`watch_for_update` polling it from its own thread for 90 s, under the
lock rather than owning the child — the child belongs to the state, and
the Stop button takes it. An update is applied and the peer restarted
while the lock is held, so nothing reads the dead child as "stopped" and
starts a second peer meanwhile; any other exit is logged and the slot
emptied; an emptied slot ends the watch. The first boot is the one launch
the watcher cannot cover (its child never reaches the state), so that loop
handles code 42 itself, once, with its clock restarted.

Seen live on this machine: the other-exit branch caught a spawn that died
on a busy port (code 43) within half a second and cleared the slot; a peer
killed by hand and started again from the app was connected two seconds
later. The exit-42 branch itself is not reproducible here any more — the
binary is on the latest release — and stands on the same code path.
2026-09-06 08:17:55 -05:00
KeelrLeek 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.
2026-09-06 08:16:45 -05:00
KeelrLeek df06f849e0 spaces: a room carries what its members are playing, sealed in the window its chat already uses 2026-09-04 12:20:27 -05:00
KeelrLeek 4ca9ab043b shared playlists: a deleted playlist takes its share with it, so the next one does not go out wearing its name 2026-09-04 11:06:34 -05:00
KeelrLeek 0ff65fd9b5 community: the board counts your pending proposals under the key you wear there 2026-09-04 09:56:39 -05:00
KeelrLeek 06baa5a805 spaces: leaving a room says so, so an owner stops publishing a seat nobody sits in 2026-09-04 09:56:39 -05:00
KeelrLeek d6102101af spaces: a rotated key goes to the person behind the room key, and the epoch it belongs to is found by number 2026-09-03 16:01:40 -05:00
KeelrLeek 7b7f48ebc6 community: declare what you like on soulseek, so the similar-users shelf finally has something to rank on 2026-09-03 15:05:23 -05:00
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