Commit Graph
180 Commits
Author SHA1 Message Date
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