Commit Graph
100 Commits
Author SHA1 Message Date
logan 49445ded77 test(download): write the yt-dlp stub under ForkLock
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m48s
CI / e2e (pull_request) Successful in 10m21s
The kernel refuses to exec a file that is open for writing anywhere in
the process, and these tests are parallel: a sibling's fork duplicates
stubYtDlp's write descriptor in the moment it is open and carries it
past our close, so the exec a moment later fails with ETXTBSY. That is
the flake seen once locally and once in CI, both times on a tree with
no Go in its diff.

Closing sooner is not available -- os.WriteFile has already closed the
file before anything execs it -- and O_CLOEXEC does not help, because
the window is between another goroutine's fork and its own exec.
syscall.ForkLock is the lock forkExec takes across that fork, so
holding it over the write means no child can exist while the
descriptor does.

Measured on the helper itself under 12 concurrent writers: 176-189 of
2400 execs refused before, 0 of 2400 after, three runs each.

Closes #146
2026-08-30 07:40:32 -04:00
logan 5e25e14994 Merge pull request 'Split the README into a user-facing landing page and CONTRIBUTING.md' (#221) from docs/50-readme-landing-page into main
CI / check (push) Skipped
CI / e2e (push) Skipped
Build & publish the Android APK / apk (push) Successful in 1m50s
Build & publish Arch package / arch-package (push) Successful in 2m44s
Attach the desktop build to the release / linux (push) Successful in 1m17s
Sync Homebrew formula / sync-formula (push) Successful in 10s
2026-08-26 16:03:22 +00:00
logan 94ccea185c Merge pull request 'fix(ui): the phone's nav sheet says when it scrolls' (#222) from fix/210-nav-sheet-scroll-affordance into main
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-08-26 16:03:10 +00:00
logan d21b842d86 Merge pull request 'fix(queue): name the queue header's two older actions' (#223) from fix/170-queue-header-action-names into main
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-08-26 16:03:02 +00:00
logan f79249dfba Merge pull request 'test(e2e): name the fixture tracks that really have no album' (#226) from test/217-fixture-names-in-queue-selection into main
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-08-26 16:02:53 +00:00
logan f8c8d374d1 Merge pull request 'fix(riff): grow a chunk buffer with what arrives' (#224) from fix/216-riff-parse-allocation into main
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-08-26 16:02:50 +00:00
logan ec4961ae50 test(e2e): name the fixture tracks that really have no album
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m47s
CI / e2e (pull_request) Successful in 10m17s
`queueSixAndOpen` filters the queue down to tracks that have an album,
because `explore-link` renders a name it cannot route as plain text and
one test clicks that name. The filter is right and unchanged; the
comment explaining it named the wrong two files.

Since #104 read a WAV's `id3 ` chunk, the two tracks under `Field
Recordings/Test Tones` are tagged, scanned and ordinary. Asked of a
seeded app rather than of the comment, exactly two tracks in the
fixture library have no album: `unsorted/no-tags-at-all.mp3` and
`unsorted/title-only.mp3`.

The clause saying which change made the old names wrong is there so the
next reader does not restore them.

Closes #217
2026-08-26 07:42:11 -04:00
logan a113b7bd62 fix(riff): grow a chunk buffer with what arrives
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 6m23s
CI / e2e (pull_request) Successful in 10m8s
Parse sized its buffer from the chunk header, which is four bytes read
off the file, so a truncated or malformed WAV declaring a 4 GB data
chunk in a 2 kB file got 4 GB from the allocator before the read
discovered there was nothing to put in it. The error was always right;
the allocation happened first.

io.CopyN into a bytes.Buffer is what ID3Chunk beside it has done since
#104, and needs nothing new: the reader stays an io.Reader and the
buffer grows with what actually arrives.

The regression test measures rather than asserts the error, because the
error is identical on a build that allocates the gigabyte. Measured on
the pre-fix build: 1,073,750,920 bytes of TotalAlloc for a 42-byte
container whose data chunk claimed 1 GiB.

Closes #216
2026-08-26 06:35:04 -04:00
logan b5bdba2f38 fix(queue): name the queue header's two older actions
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m44s
CI / e2e (pull_request) Successful in 10m13s
Clear queue and Add queue to playlist were named by a `title` attribute
and nothing else, while the close button beside them has carried an
`aria-label` since #24. They get one too.

`title` is a name, so this is the weak-name case rather than the missing
one: it is the *last* fallback in the accname order, so any content put
inside the button later silently outranks it, and a phone has no hover
to show it. The `title`s stay — on a desktop they are also the tooltip
for an icon-only control, which is a different job.

The assertion is the part worth reading. The obvious spec — `getByRole`
by name, which is what `queue-overlay.spec.ts` already does for the
close button — is **green on the broken build**: measured against the
running pre-fix app, both buttons matched. So the second test states the
property as what it is, that the name is not the tooltip: it removes the
`title` attributes and asks again, which was 0 and 0 on main and is 1
and 1 now.

Closes #170
2026-08-26 05:39:42 -04:00
logan 20c337651f fix(ui): the phone's nav sheet says when it scrolls
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m1s
CI / e2e (pull_request) Successful in 9m56s
Since #71 the phone's "More" is a bottom sheet, and at the reference
viewport it does not fit: measured at 424x439 with the seed's eight
destinations, the body is scrollHeight 412 against clientHeight 373, so
39px is below a fold nothing announces. Where the cut lands on a row
boundary the sheet ends in a clean edge that reads as the end of the
list, which is what #207 fixed one sheet over.

The rule is that sheet's, not a second answer to the same question:
#207's two background layers move into styles/sheet-scroll.css.ts and
both sheets adopt them, with the colour left to each host as
--yj-sheet-surface. The nav sheet paints the sidebar's --yj-bg-surface
and the context sheet the menus' --yj-bg-elevated, so a shared rule that
hard-coded either would draw that seam across the other one.

The half that makes it visible is that nothing inside the sheet may
repaint the surface. These are layers on the scroller, and app-sidebar's
host carries the same grey -- in the shell its own background, in the
sheet a second opaque copy of the sheet's, over the fade. With the
fragment adopted and that rule missing, the running app measured a flat
52,58,64 to the bottom edge with 39px still below: the defect unchanged,
with every assertion about background-attachment passing. menu-surface
already meets it from the other side, where the sheet's panel is
background-color: transparent.

Closes #210
2026-08-26 04:44:02 -04:00
logan 1c08d8db90 docs: split the README into a landing page and CONTRIBUTING
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m58s
CI / e2e (pull_request) Successful in 10m7s
The README was two documents in one, and neither reader was served by
the other's half. It opened on a feature list, then spent its second
half on Go versions, WebKitGTK packages and `make` targets — while its
install table named a `darwin-universal.app.zip` and a
`windows-amd64.exe` that nothing has ever produced, and its header
claimed Windows and never mentioned Android, which is the one platform
with a published, self-updating channel.

So this is a correctness pass as much as a friendliness one. The README
now answers a user's questions only: what the app is, three screenshots
from the seeded fixture library so anyone can retake them, the four
formats, one install section per channel that names what is actually
published, first run, where the data lives, and pointers out. The
version-restart note is linked to the two documents that own it rather
than copied, because a copy is a second thing to keep true.

CONTRIBUTING.md takes the technical half: prerequisites, the system
libraries, the build and codegen commands, which verification tier a
change demands, the tracker workflow, the commit grammar and the style
rules. CLAUDE.md is unchanged apart from one paragraph naming the split
— it was already the deep reference both of the others point at, and
stays the only one of the three that explains why a shape is what it is.

Closes #50
2026-08-26 03:37:13 -04:00
logan 245647f12b Merge pull request 'feat(ui): warm album art ahead of the scroll' (#215) from feat/65-art-prefetch-ahead into main
CI / check (push) Successful in 2m43s
CI / e2e (push) Successful in 10m14s
2026-08-25 17:58:42 +00:00
logan 3479ae8d39 feat(ui): warm album art ahead of the scroll
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m46s
CI / e2e (pull_request) Successful in 10m13s
Scrolling the albums grid pops art in: the cards already draw the
smallest adequate tier and are already lazy, so what was left is *when*
the request happens. The grids are virtualized, so the `<img>` — and
therefore the fetch — does not exist until the virtualizer renders its
card, which is about 1000px past the viewport, or two screens on the
reference device.

The issue asks for a larger overscan and that is not available:
`_overhang` is a hard-coded `protected` field on `BaseLayout` with no
configuration surface. So the request is issued ahead of the element
instead. `utils/image-prefetch.ts` warms a bounded window either side
of the rendered range, from `rangeChanged` rather than
`visibilityChanged` — the two report different ranges, and a window
measured from what is *visible* is spent on cards that already exist.

Cover and artist URLs are served under `Cache-Control: immutable`
(content-hashed filenames), so a prefetched image is a cache hit by the
time its card is drawn. The bytes are the browser's; what this holds is
the set of URLs asked for, capped and reported to `__yjCacheStats()`.

Measured on the bulk seed (4 988 albums), ten 2 400px jumps, covers in
the viewport with `naturalWidth === 0`: 254 of 258 blank one frame
after the jump and 214 two frames after, against 117 and 77 with the
prefetch.

Closes #65
2026-08-25 13:55:43 -04:00
logan e23e6f9a54 Merge pull request 'feat(android): the phone's "More" is a bottom sheet' (#211) from feat/71-more-as-a-bottom-sheet into main
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-08-25 17:55:31 +00:00
logan 52d095e3c6 feat(android): the phone's "More" is a bottom sheet
CI / e2e (push) Skipped
CI / check (push) Skipped
CI / check (pull_request) Successful in 2m47s
CI / e2e (pull_request) Successful in 10m14s
The tab bar's fifth item opened `<app-sidebar>` in a `wa-drawer`
sliding in from the side, which is a desktop shape put on a phone: a
200px column of a 424px screen, opening away from the thumb that asked
for it, with the rest of its 400px band empty. It also had three nested
scrollers in it -- the dialog, its body, and the sidebar's own
`overflow-y: auto` host -- so which box a drag moved depended on where
the finger landed, which is the "only part of the screen scrolls under
my finger" in the report.

It is the same element with `placement="bottom"` and `without-header`,
so the surface is the sheet #60 already built rather than a second
pattern: a `wa-drawer` is a native `<dialog>` opened with `showModal()`,
which is exactly the top layer that finding rests on, so the focus
trap, Escape, tap-outside and `wa-after-hide` come along unchanged and
nothing new has to be proved about paint containment.

The sidebar is still mounted rather than re-listed as data, because the
shell's own copy is `display: none` below 600px rather than removed --
a second list drawing `nav-*` handles is the duplicate-testid failure
this component already renders conditionally to avoid. What `expanded`
means had to grow to say the host owns the *box*: `app-sidebar` writes
an inline width and caps itself at 400px, which beats any rule the host
could write, so the width, the scrolling and the mouse-only resize
handle now follow that attribute. The rows are 48px below 600px, stated
in the sidebar's own stylesheet since that is the only place it renders
there.

Measured in the running app at 424x439: the sheet is 424 wide, 373 tall
(85vh, so there is an outside to tap), rows 48px, one scroller with
`overscroll-behavior: contain`, and Settings' row reachable at the end
of it. Desktop and Compact are untouched.

Closes #71
2026-08-25 13:48:51 -04:00
logan 939915b1fa Merge pull request 'feat(android): the tap highlight goes, a press state replaces it' (#214) from feat/54-native-touch-feel into main
CI / check (push) Successful in 2m58s
CI / e2e (push) Canceled after 0s
2026-08-25 17:48:45 +00:00
logan 3aa2a434b4 feat(android): the tap highlight goes, a press state replaces it
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m44s
CI / e2e (pull_request) Successful in 10m10s
The phone drew a grey box over the bounding rect of whatever was
tapped, which is the web view saying what it is. It is gone in one
declaration: `-webkit-tap-highlight-color` is inherited and an
inherited property crosses a shadow boundary, so `html` in index.css
reaches every shadow root in the app. Measured three roots deep,
rgba(0, 0, 0, 0.18) before and rgba(0, 0, 0, 0) after.

Removing it removes the only touch feedback several surfaces had, so
the press state is part of the same change rather than a later polish
item — with the highlight gone a held row measured the *hover* tint,
which on a phone is synthesised by the hold itself and outlives it.
The four lists' rows, the tab bar, the sidebar's destinations and the
shared context-menu item take --yj-press-overlay on :active; the cards
already had scale(0.97). The press selector carries a state class
because a row is .track-row.selected.active, so a bare :active shows
nothing on the row a phone is most likely to press. And those
surfaces' hover tints move behind (hover: hover) and (pointer: fine),
which is #68's gate applied to a tint rather than a revealed control.

user-select, the other half of the Findings, was already done: the
first rule in index.css covers the shadow roots for the same reason.
touch-action: manipulation is declined — the 300ms delay it is offered
for is already absent on a width=device-width viewport, and what it
would really change is the gesture stack tuned by measurement on a
device this session cannot measure.

Closes #54
2026-08-25 12:51:28 -04:00
logan 944995dc3c Merge pull request 'feat(android): a name is not a link on a phone, the menu carries it' (#208) from feat/67-entity-links-into-menus into main
CI / check (push) Successful in 2m40s
CI / e2e (push) Successful in 10m18s
2026-08-25 16:51:16 +00:00
logan 8de412cf36 feat(android): a name is not a link on a phone, the menu carries it
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m5s
CI / e2e (pull_request) Successful in 10m3s
Every track, album and artist name in the app navigates through
`utils/explore-link.ts`, and every sentence of how it does that is a
desktop compromise: the navigation is held for one double-click
interval so double-clicking the row can still play it, and the target
is a few characters of text inside a row. On touch that is a delay on
an ambiguous target, and since #63 the row's own tap claims the click
anyway -- so the link was unreachable as well as fiddly.

So below the phone breakpoint a name renders as plain text and the
row's context menu carries the destination instead: `go-to-menu.ts`
draws "Go to Artist" / "Go to Album" under exactly the condition the
link is not, using `explore-link`'s own exported routing so an untagged
entity reaches the library page by the same lookup.

Three things this leans on. Suppressing a link with no menu behind it
is not a smaller affordance but a destination the phone cannot reach,
so `keepOnPhone` is the exception for the three surfaces with no row
menu. The items are drawn for a single selection only, which is the
Play item's rule one step on. And there is no "Go to Genre", because
no row renders a genre link to lose -- that would be new navigation
rather than a replacement.

Closes #67
2026-08-25 12:40:10 -04:00
logan aeb173c684 Merge pull request 'fix(ui): the phone's context sheet says when it scrolls' (#209) from fix/207-sheet-scroll-affordance into main
CI / e2e (push) Canceled after 0s
CI / check (push) Successful in 2m52s
2026-08-25 16:39:44 +00:00
logan 025ed59480 Merge pull request 'test(ui): refresh two stale baselines and settle whether they gate' (#205) from test/196-visual-tier-gates into main
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-08-25 16:39:32 +00:00
logan a82d29abd7 docs(agent): drop #204's workaround from the baseline rule
CI / e2e (push) Skipped
CI / check (push) Skipped
CI / check (pull_request) Successful in 2m43s
CI / e2e (pull_request) Successful in 10m1s
#204 landed first, so the ui-tier rule can name `make ui-visual-update
UI_ARGS=<path>` rather than the raw vitest invocation it needed while
the recipe swallowed its filter.
2026-08-25 12:38:17 -04:00
logan d365850321 test(ui): refresh two stale baselines and settle whether they gate
`make ui-visual` had been red on main since #27, and nothing runs it,
so four references had drifted across three unrelated merges. Two were
refreshed with #186; these are the other two.

Each recorded two changes, not one. `app-sidebar` lost Jobs (#27,
shipped) and moved its highlight from Home to Tracks; `now-playing`
gained the source line (shipped) and was playing from "a dynamic mix".
Both are singleton stores read by a case that sets nothing, so the shot
photographs whatever the case above it left behind — blessing that
would have pinned the file's own ordering into a PNG. Both cases state
their world now, and only then are the references re-recorded.

The second half of the issue asks whether this tier should gate, and
the answer is measured rather than preferred: replayed in a bare
ubuntu:24.04 container — CI's `check` image — three of the ten
baselines fail on rendering alone (`track-info` and one `page-header`
shot at ratio 0.03 against a 0.02 allowance, `seek-bar` one pixel
shorter), and the two stale ones disagree about their new height
between the machines. So CI cannot run this suite without a second,
container-recorded baseline set that every local run would then fail
against, and a pre-push hook is the same fault with the machines
swapped. It stays local and opt-in; what replaces the gate is the rule
that a change moving a component's geometry refreshes that component's
baseline in the same commit, having read the image, and never one it
did not cause. Written where a person meets it: the skill's tier doc
has the table, SKILL.md has the obligation, CLAUDE.md has the
constraint.

Deleting the baselines was the third option and is declined: this tier
has caught one thing no other could, the `<span>` that lost the UA
stylesheet's `box-sizing` and grew a badge 36→38px.

Closes #196
2026-08-25 12:38:06 -04:00
logan 3a2d3e8ef8 Merge pull request 'fix(metadata): read a WAV's tags out of its RIFF id3 chunk' (#218) from fix/104-wav-tags-read into main
CI / check (push) Canceled after 1m18s
CI / e2e (push) Canceled after 0s
2026-08-25 16:37:56 +00:00
logan 871a3b7aac Merge pull request 'test(ui): make ui-visual-update honour its file filter' (#206) from fix/204-ui-visual-update-filter into main
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-08-25 16:37:53 +00:00
logan f3207e8bf9 Merge pull request 'test(ui): clear localStorage between component tests' (#219) from fix/138-ui-test-storage-leak into main
CI / check (push) Canceled after 3s
CI / e2e (push) Canceled after 0s
2026-08-25 16:37:42 +00:00
logan 772c71c49f test(ui): clear localStorage between component tests
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 9m41s
A test file does not get its own origin. `@vitest/browser-playwright`
opens one BrowserContext per session and runs several files in it, one
after another, so everything a component persists survives from file to
file. `track-list` restores its sort in `connectedCallback` and
`aria-tail.test.ts` activates the Title column header, so any file that
mounts a track list later in that tab opens sorted by title — where
`track-11` precedes `track-3`, which is #138's failure exactly.

Nothing about it is specific to that pair: a probe that throws when a
test starts with a non-empty `localStorage` failed 24 test-starts in one
full run (11 with the two sort keys, 8 with `cover-grid-size`, 5 with
`track-list-column-widths`) and cascaded into 248 failures. Which files
share a tab, and in what order, changes run to run, which is the whole
of why this reads as a 1-in-3 flake and passes in isolation.

The clear belongs in `setup.ts` rather than in the specs that write,
because the spec that reads is never the one that knows — and it is
safe for the same reason the leak exists: files within a session are
sequential, so it cannot wipe storage a concurrent file is using.

The spec now also states the order it asserts rather than inheriting a
default, and checks the row it is about to double-click carries the path
it expects, so a stray sort fails by naming itself instead of as an
off-by-eight file path.

Closes #138
2026-08-24 06:47:05 -04:00
logan c56eae2959 fix(metadata): read a WAV's tags out of its RIFF id3 chunk
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 9m46s
tagwriter has always written a WAV's tags into a RIFF "id3 " chunk
correctly, and dhowden/tag -- which metadata.ExtractTags is built on --
has no RIFF reader at all.  So the app could not see tags it had just
written: editing tags on a WAV, autotagging a WAV folder or importing a
WAV download all appeared to succeed and changed nothing the library
could show, while the file on disk really was tagged and other players
read it.

backend/riff is a new package rather than a move into either half,
because tagwriter already imports metadata: reaching back for parseRIFF
is an import cycle, not merely the wrong direction.  backend/tagtotals
is the precedent.

Its two readers are deliberately different.  Parse holds every chunk in
memory, which is what rewriting a file needs -- and a WAV's audio *is*
a chunk, so doing that on the scan path would read every WAV in the
library in full.  ID3Chunk seeks over what it is not looking for.

The container is asked before tag.ReadFrom rather than after it fails,
because that library's last resort is an ID3v1 trailer and a WAV
carrying both would otherwise be read by the wrong one.  An untagged
WAV -- no chunk, an RF64 container, a tag with every frame cleared --
reads as empty metadata with no TagReadWarning: the scanner's filename
fallback is the right answer there, and a warning would report a fault
on a healthy file.

The gap was pinned by TestWAVTagsAreNotReadableYet, which failed the
moment the reader learned and said in its own comment what to update.
So it goes, TestFixturesMatchManifest no longer skips wav, and
totals_test.go's WAV case reads through metadata.ExtractTags like the
other three formats -- a round trip asserted through the writer's own
parser was a test of the writer, which is why nothing caught this.

Closes #104
2026-08-24 05:44:39 -04:00
logan 8c85db8968 docs(ui): quote the shipped fade's own measurement
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 9m33s
The comment on `wa-dialog::part(body)` carried bottom-edge pixels from
an intermediate probe (29,33,36) while `.planning/NOTES.md` recorded
the final sample against the shipped rule (22,24,27) — the same
gradient, read a few pixels higher up the box. A measurement written in
two places has to agree, or neither can be trusted.
2026-08-23 07:04:04 -04:00
logan 02e2251bb2 fix(ui): the phone's context sheet says when it scrolls
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 9m40s
The bottom sheet's body has scrolled since #60 and said nothing about
it. Measured at 424x439, the track list's menu ended at y=470 with the
fold at 439 — reachable, since the body is `overflow-y: auto`, but with
no affordance saying so, and worst where the cut lands on a row
boundary and the sheet ends in a clean edge that reads as the end of
the list.

The cap stays: `menu-surface`'s own comment says a surface covering the
whole screen is a page, not a sheet. What changes is that the body
draws a fade, from two background layers whose *attachments* are the
feature — a shadow pinned to the box (`scroll`) under a cover of the
sheet's own colour painted at the end of the content (`local`), which
scrolls up over the shadow exactly when there is nothing more to see.
So the fade is absent on a menu that fits, present the moment one does
not, and gone again at the end of the list, with no scroll listener and
nothing reaching into `wa-dialog`'s shadow root for the scroller.
`background-attachment` is Chrome 4; the reference device is Chrome 113.

The other two options in the report — a shortened last row, or a max
height that makes the cut obvious — both need `height mod 48`, which
CSS cannot express, and the observed case is exactly the one where the
cut already lands on a row boundary.

The curve is steep rather than linear because the rows under it stay
live: a scrim over a menu item is that item's text surface, so the
4.5:1 rule reaches it, and the light ramp is what makes that real.
A 48px linear scrim at 0.8 greyed the last label to 5.0:1; 32px already
down to a quarter strength at 14px measures 9.9:1 there and spends its
weight on the strip below it.

The test asserts the pair of attachments rather than the pixels, on
this file's existing grounds that no tier here renders like the device
— it fails on the pre-fix stylesheet with `expected 'scroll' to be
'local, scroll'`. The rendered result was measured in the harness and
is recorded in `.planning/NOTES.md`.

Closes #207
2026-08-23 06:45:46 -04:00
logan e5d0f2714b test(ui): make ui-visual-update honour its file filter
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 9m27s
vitest parses a bare `--update` as taking the next positional as its
value, so `make ui-visual-update UI_ARGS=<path>` handed the path to the
flag and ran with no filter at all: 99 files, every baseline in the repo
re-recorded, any stale one blessed in silence. Two paths were worse
still — the first was eaten and only the second ran.

That is #196's own hazard living in the tool meant to resolve it: the
rule is "refresh the reference your change moved and never one you did
not cause", and the documented way to refresh one refreshed the set.

`--update=true` is the whole fix, with the reason beside it because
`=true` reads like something to tidy away. `make ui-visual` and
`make ui-test` are unaffected — their `$(UI_ARGS)` follows `run`, with
no flag to swallow it — and no other target interpolates a variable
after a boolean flag.

Closes #204
2026-08-23 04:36:46 -04:00
logan ee1d8b3179 Merge pull request 'Android touch model, phases 2-4: swipe to queue, and the other three lists' (#201) from 63-touch-model-phase-2 into main
CI / e2e (push) Successful in 9m28s
CI / check (push) Successful in 2m31s
Build & publish the Android APK / apk (push) Successful in 1m27s
Build & publish Arch package / arch-package (push) Successful in 2m39s
Attach the desktop build to the release / linux (push) Successful in 58s
Sync Homebrew formula / sync-formula (push) Successful in 6s
2026-08-22 05:54:47 +00:00
logan 29feb4b94b feat(android): the touch model reaches the other three lists
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m33s
CI / e2e (pull_request) Successful in 9m20s
Plan 019 phases 3 and 4, which finish #63. The queue panel and both
playlist detail views get tap-to-play and hold-to-select; the playlist
views get swipe-to-queue as well.

Phase 3 was not the pure wiring the plan expected, in two places.

A tap on a queue row plays that position. Copying track-list's tap --
which sets the queue to the list the row is in -- would rebuild the
queue from the queue, discarding its source, its shuffle order and
anything inserted by hand. It reads as a no-op and is not one.

And the queue panel has no swipe, deliberately. A right swipe means add
to the queue everywhere else it exists, and a queue row is already in
the queue; the only thing it could mean there is remove, which is the
same gesture with the opposite effect one screen away. Removing a queue
row is on the row, on its sheet since #60, and now on its selection
bar. The assertion is that its rows do not opt in.

The reveal became utils/swipe-to-queue.ts rather than being copied into
three lists, keyed on a data-swipe attribute so one stylesheet carries
the touch-action half of the device fix to rows that are called two
different things.

Phase 4 was already true and is now asserted: a claimed tap has its
click swallowed, so an explore-link inside a row never sees one and
tap-to-play wins with no rule of its own. Its test was vacuous when
written -- the tap helper sent no click, so there was nothing to
swallow -- which also weakened phase 1's. It sends one now.

Escape leaves selection mode, from selection-bar rather than from each
of the four hosts, since that element exists only while the mode does.
The platform's back gesture deliberately does not reach it: the shell
owns the history stack and four lists reaching for history is four
stacks. That is #200.

Verified on the reference phone: a queue row taps to its own index and
refuses a swipe, a playlist row queues on a swipe and plays its
playlist on a tap, and a hold raises the bar without the menu.

Closes #63
2026-08-22 01:41:21 -04:00
logan 4e667759c4 feat(android): swipe a track row right to queue it
Plan 019 phase 2. A finger on a track row now drags a reveal out from
under it and queues the track on release, with the affordance saying
what it will do before it does it.

Two things the device said that the plan did not predict, and both
change the implementation rather than decorate it.

The gesture runs on touch events, not pointer events. Chrome 113's
WebView cancels the pointer stream ~16px into any drag whatever
touch-action says -- measured at auto, pan-y and none alike -- while
touchmove keeps firing. So touch-action: pan-y is half the fix and a
non-passive touchmove calling preventDefault is the other half, and
neither works alone: with the preventDefault in place and touch-action
back at auto the gesture died after one move. Both are correct in
Chromium either way, which is why the module's header carries the
measurement and the component tier asserts the stylesheet.

And a phase 1 defect the device found on the way past: the native
contextmenu arrives in either order and only one was handled. Our
500ms timer firing first, a component claiming it, and Chrome
delivering its own menu 50-70ms later was suppressed by nothing -- so
the context menu opened over the selection bar, two holds in four, on
the one surface this issue exists to have changed. Six holds clean
after.

draggable="true" is not a competitor: no dragstart fires from a touch
drag on this WebView at all.
2026-08-22 01:23:19 -04:00
logan ff3875b55d Merge pull request 'Android touch model, phase 1: tap to play, hold to select' (#199) from 63-android-touch-model into main
CI / check (push) Successful in 2m36s
CI / e2e (push) Successful in 9m29s
2026-08-22 04:36:37 +00:00
logan 76e1c444cc feat(android): tap to play, hold to select
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 9m41s
Phase 1 of #63, and the design the issue asks for as one piece is
.planning/plans/active/019-android-touch-model.md.

**A finger has no second button and no modifier keys**, so the primary
action has to be the primary gesture: tap plays the row, and the hold
that opened a context menu now enters selection mode with that row
selected.

Three decisions in it, and two diverge from the report.

**The predicate is the pointer, not the platform or the viewport.**
`pointerType === 'touch'`, per event, which is already how long-press.ts
decided and is the only such test in the frontend. This is #64's rule --
named after the capability -- and it carries #64's warning: keyed on a
width, an Android *tablet* at 600px gets click-selects/double-click-plays
on a touchscreen, which is the inversion this issue exists to fix, on
the platform it exists for. A touchscreen laptop cannot be described by
a width at all. Per event, a mouse keeps desktop semantics on the very
same row, and there is no second declaration of what a phone does.

**There is no double-tap, and the number is why.** The report asks for
single tap to play *and* double tap for the menu. Those cannot both be
honoured: the first tap of a double tap is indistinguishable from a
single tap until the interval expires, so "tap plays" becomes "tap
waits". Measured on the device, the play command to TrackChanged is
155/123/85/56/91 ms -- median ~100 -- and the app's own
DOUBLE_CLICK_GRACE_MS is 250. That is 3.5x the primary interaction,
250ms of it spent deliberately doing nothing, on every track anyone
plays, to reach a menu the hold already reaches. So the menu and the
selection action bar are the same surface, which is also the platform's
convention and removes a concept rather than adding one.

**Tap-to-play and selection mode ship together**, because splitting
them is a regression dressed as an increment: a touch user selects by
tapping today and acts through the long-press menu, so moving tap to
play on its own would leave a window with no way to select forty tracks
at all.

**What lets this reassign the hold without touching one of the fourteen
context menus**: the layer announces `yj-tap` / `yj-long-press`
(composed, cancelable) and acts on nothing. A component claims one with
preventDefault. An **unclaimed long press still becomes a
`contextmenu`**, so the card grids, Explore, the playlist rows and
every other menu behave exactly as they did, and only lists that opt in
get selection mode. An unclaimed *tap* does nothing at all and the
click follows normally, which is what leaves every button in the app
alone -- only a claimed tap has its click swallowed, or playing a track
would also select it.

**And the device found the one thing no browser tier can see.**
Chrome 113's Android WebView fires its own `contextmenu` on a long
press. long-press.ts stood down when a trusted one arrived, which was
right while both paths ended in a context menu; they no longer do, so
standing down means the gesture silently does the *old* thing.
Measured, before the fix, holding a track row:

    {"log":["contextmenu isTrusted=true"],
     "state":{"bar":null,"menuActive":true,"selected":1}}

`yj-long-press` was never announced, the menu opened, and all 26 tests
passed -- dispatched pointer events do not make a browser synthesise
one. So the native event is a **trigger, not a competitor**: the
gesture is announced from it and only a claim suppresses it. Unclaimed
it propagates untouched, which is the same "browser wins" outcome
reached by asking instead of assuming.

The tier could not find that and can hold it, because this module has
always told its own events apart by identity rather than isTrusted, so
an untrusted one from a test takes exactly the browser's path.

Verified on the device by *performing* the gestures rather than
describing the page -- `adb shell input tap` and `input swipe x y x y
700` reach the WebView as real pointer events, which is new here and is
written down in the plan with the pixel mapping. Tap plays; a hold
raises the bar with one selected and no menu; a tap toggles to two,
back to one, and the mode ends with the last row; an album card still
opens its context menu.

29 new tests. The e2e spec is rewritten to assert **both** halves --
the row selects, and a card elsewhere still opens the real menu --
because a spec that only checked the row would pass on a build that had
silently broken the other thirteen.

Phases 2-4 (swipe to queue, the other three surfaces, and what #67
inherits) are in the plan and not in this commit.
2026-08-22 00:23:33 -04:00
logan 4f32d4e13c Merge pull request 'Android: raise every remaining control to the 44px touch floor' (#198) from 186-touch-targets-settings into main
CI / check (push) Successful in 2m32s
CI / e2e (push) Successful in 9m18s
Closes #186
2026-08-22 03:22:16 +00:00
logan 4f628b1f52 fix(ui): raise the last controls below the touch floor
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 9m36s
The rest of #186's second table, and one thing it could not have said.

    .section-toggle          187x15  autotag
    .folders-menu-trigger     32x18  autotag
    .back-button              32x32  artist-details
    Requests / Downloads tabs 85x34, 96x34
    .search-mode-tab          89x26, 79x26  explore
    explore search input     325x18  in a 36px box

**back-button was six controls, not one.** The issue names it in
artist-details because that is the view the sweep opened; the same
declaration is byte-identical in artist-details, genre-details,
playlist-details, smart-playlist-details, explore-artist-details and
explore-album-details, 32px in all six. So it is styles/back-button.
css.ts now, adopted by each, and a source sweep fails on a seventh
copy -- because the failure this invites is not a size changing, it is
somebody adding a detail view and writing `.back-button` out again,
which no device sweep would catch for the same reason this one did
not. That is icon-language.test.ts's shape, and the argument for it
here is the inverse of the column arrows': one declaration covering
36 controls is cheap to fix, and six declarations of one control are
six chances to miss five.

It is a real 44px box rather than padding with the width handed back:
a detail header runs no fit pass, and this button has a visible
background, so a hit area larger than the circle would be a control
bigger than it looks. The size is #55's, reached there for the same
reason -- "the way out is 44px on a phone".

**The explore search box was two faults.** The row was 36px *and* the
input inside it was 18, so eight pixels at each edge were not a target
at all: a tap near the top of the box landed on the container and did
nothing. The container is 44 and the input stretches to it.

**The Downloads tabs take padding rather than a min-size**, because
the mark for the selected tab is its bottom border -- a min-size
centres the label and leaves the underline 10px beneath it.

page-action-check-now (113x29) is in that table and is not here: it is
a PageAction, so #195 raised it with the rest of the header's actions
and touch-targets.test.ts already covers it.

**The Downloads tabs needed a min-size as well as the padding, and CI
is what said so.** Padding alone made them 44px on this machine and
**43px in the container**: the total is 13 + 13 + 2 + whatever line box
the font gives 13px text, and ubuntu:24.04's is a pixel shorter than
Arch's. A height computed from a font's line box is not a height you
control -- which is #195's "stated as a property on the strength of one
engine" one layer down, in the same PR that recorded it. The padding
stays, because it is what keeps the underline against the label; the
min-size is the floor.

Caught by the new test rather than by a person, which is the half of
this that worked.

Verified on the device, sweeping each view the way the issue was
filed: explore, downloads, autotag and artist-details now report
**one** control under the floor apiece, and it is the skip link, which
#186 already ruled out as keyboard-only. .search-mode-tab 89x44 and
79x44, the search input 325x44, the Downloads tabs 85x44 and 96x44,
.section-toggle 174x44, .folders-menu-trigger 44x44, .back-button
44x44.

All 12 new tests fail on main, the source sweep naming all six copies.
make ui-test 1041 pass; make e2e 236 pass on chromium, which is half
an answer -- CI had the other half, and used it.

Closes #186
2026-08-21 23:08:25 -04:00
logan 2100f0022f fix(settings): raise every Settings control to the touch floor
#56 named 44px and #195 took the page header there. Settings is the
other half of #186 and much the larger one: swept on the reference
device (TLP301, 424x439) with all eleven config-sections expanded,
**120 controls** were under the floor -- not the 93 the issue's table
implies, and config-field is eight of them.

The bulk is behind the disclosures, which is why nobody had counted it:

    36  .column-arrow-btn          16x14   <- smallest in the app
    29  .column-toggle             16x16
    26  shortcut-capture button    80x25
     8  download format checkbox   16x16
     7  config-field select        335x30
     6  wa-input / wa-button       204x20, 185x21

**The density argument, measured rather than guessed, and it is
smaller than it looks.** The rows were already near the floor --
.column-item is 335x36 and .shortcut-row 335x37; it is the controls
*inside* them that were 14-25px. So a control grows into the row it
already occupies and the row goes 36 to 44. Measured after: the two
column lists went 373->447 and 690->850, +234px over the whole page.
Half a screen of extra scroll on a page that already scrolls, against
36 targets of 16x14.

**Settings is cheaper than the header was, and for a stated reason.**
There is no overflow fit on this page, so the header's "only width is
contested" rule does not bind at all and nothing here needs padding
with a negative margin. Height is a min-size, and the two square
controls can simply be square.

Three shapes, because one rule does not fit three kinds of control:

**A native checkbox is targeted through its label.** It cannot grow
its hit area without growing its paint, and a 44px checkbox is not
what anyone wants -- so .column-label is a real <label for> now and
the column's *name* is the target, 70x44 rather than 16x16. That is
the argument config-field already makes one file over ("a real label
association also makes the label text a click target, which is
behaviour, not annotation"), and here it is the whole fix. The
download formats already had the label; they only needed the height.

**The arrows take padding, which is invisible.** They carry
background: none and a transparent border, so 16x14 -> 44x44 changes
nothing anyone can see until hover -- #186's Direction exactly.

**Web Awesome's controls come from the library's own API.** Their
height is decided inside somebody else's shadow root, and
--wa-form-control-height is the variable that decides it. A custom
property inherits through a shadow boundary, so a :host declaration
reaches them; styles/wa-touch-floor.css.ts is that, once, adopted
rather than written at :root in index.css -- a :root rule would be
invisible to the component tier, which renders a component and no page
stylesheet.

**Two controls no sweep can see are fixed by name**, and they are the
trap this issue keeps setting. config-field's toggle has an <input>
that is opacity: 0; width: 0; height: 0, so a walk of every input
skips it as a zero-sized node -- what a finger hits is the <label>,
which measured **34x19**, smaller than anything in either of #186's
tables and absent from both. It is 44x44 with the pill still painted
at 2.5em x 1.4em and negative inline margins keeping it flush with the
inputs above. And shortcut-capture's reset button renders only for a
shortcut somebody has rebound, so a sweep of a fresh install never
meets it.

Verified on the device, same method as the sweep that filed it:
120 controls under the floor before, 42 after. All 42 are accounted
for -- 37 are checkboxes whose labels measure 70x44 and 57x44, four
are wa-input's inner input at 204x**42**, which is the control
measured *inside* its own 1px border (part=base is 238x44), and one is
the skip link, which #186 already ruled out as keyboard-only.

The e2e suite passes, top-bar-fit and header-action-overflow included
-- but that is **chromium**, which is half an answer, and saying so is
the whole of what #195's second commit was about. What can be argued
rather than run: library-filter is the only thing here in a container
that measures itself, and its width did not change. The fit measures
inline size.

Two page-header screenshots are refreshed because they are this
issue's own debris -- #195's taller sort control, merged last session,
with its references never re-recorded. app-sidebar's and
now-playing's are deliberately left: they are unrelated drift, and
blessing an unrelated screenshot is how the sidebar reference came to
still list a destination #27 retired. That is #196.
2026-08-21 22:55:54 -04:00
logan 52038dc5ae Merge pull request 'Android: raise the page header and the phone search button to the touch floor' (#195) from 186-touch-targets-page-header into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 9m21s
2026-08-22 00:32:08 +00:00
logan 0d331666d6 fix(shell): make the header's touch targets cost no width
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 9m28s
The first pass grew the two square controls to 44px as boxes, which
added 22px to the header. That fit at every width Chromium was checked
at and **clipped the overflow trigger at 320x600 in WebKit** -- the
engine closest to what ships, and the one no machine here can run:

    every action is reachable at 320x600 (400% zoom)
    - Array []
    + Array [ "more" ]

Two things were wrong, and only one of them was the code.

**The claim was checked on one engine and stated as a property.** The
previous commit said #69's fit "does not move ... the check rather than
the assumption", on the strength of running that spec against chromium
alone. CI runs both browsers precisely because they are not the same
answer.

**And the box was the wrong thing to grow**, which the issue already
said: "reached by growing the *hit* area rather than the visual weight
where the two can differ -- padding on the control, not size on the
icon". #69's pass measures inline size, so a taller control is free and
a wider one is not.

So height stays a box -- the header has the room and nothing measures
it -- and width is padding with a negative margin handing the space
back, which is the seek bar's shape from #187. Measured in the
component tier at 320px: the arrow's rect is 45x44 and it occupies 29,
the overflow trigger 44x44 occupying 38, the search button 44x44
occupying 40. Those three occupancies are what they were before any of
this, so the fit pass sees a header identical to main's and the
320px case cannot regress.

The arrow's target is lopsided for #187's reason: the select is 6px to
its left and there is open space to its right, so it takes the side
with nothing to steal from. The overflow trigger's can be symmetric,
the actions row having an 8px gap.

`search-trigger` is border-box, so its 44px min-width is the whole
target and the margin alone gives the four pixels back.

The new assertion is the one that would have caught this: every grown
control must carry negative inline margins, because that is what keeps
the box out of the fit. The rect assertions stay -- getBoundingClientRect
includes padding, so the target is still measured directly rather than
inferred.
2026-08-21 20:12:51 -04:00
logan 6a5a3c33dc fix(shell): raise the page header's controls to the touch floor
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Failing after 9m43s
#56 sized the playback transport for a thumb and named 44px; the queue
header keeps it. Nothing else was resized, so the controls a user meets
on *every* screen sat between a third and two thirds of the app's own
floor. Measured on the reference device at 424x439: page-sort 99x23,
page-sort-direction **28x21**, page-actions-more 38x27, and
search-trigger 40x40.

**Both questions the issue left open are answered by one measurement.**
The header is 63px tall and its controls are 20-23px, so the vertical
room was already there; the select and its direction arrow are 6px
apart, so the horizontal room was not.

That makes this min-size rather than padding with a negative margin,
which is what the seek bar needed (#187), and the difference decides
everything else. There the painted track had to stay thin, so the
target was grown past its own box and had to be checked against its
neighbours. Here the control *is* the target: the boxes are flex items,
so the gap keeps them apart and **no two targets can overlap by
construction**.

From which:

**There is no phone branch.** A 44px control on a desktop is merely
large, and a second declaration of what a phone shows is a second thing
to keep in step -- which is why this component has never had one. It
also avoids a media query no tier here renders, which is exactly how
the seek bar's phone rule came to be dead for months.

**#69's overflow fit does not move.** That pass measures inline size,
so the height costs it nothing, and only the two square controls grow
the header's content -- by 22px in total. header-action-overflow.spec.ts
passes unchanged at all four of its widths, which was the check rather
than the assumption. Verified on the device that the count is still
shown at 424px, so nothing has started yielding.

search-trigger is the sharpest case and is fixed in the same pass: #57
created it as the phone's replacement for the header search box, so it
exists *only* where there is a thumb, and it shipped at 40x40 under a
comment calling that "the smallest a touch target should be". That was
the floor restated four pixels short rather than a second opinion about
it, and the comment now says so.

Unlike #187 this can be measured rather than inferred: the controls are
plain elements and the rule is a min-size, so it holds at every width
and a real Chromium rendering a real page-header gives the actual
answer. The tests fail with the device's own numbers -- 29x21, 38, 40.

Verified on the device: every control in the header is now at least
44x44, and so is the phone's search button.

**This is the Direction's first step, not all of it.** config-field's
93 Settings controls and explore-view's search row are the second pass;
Settings is a form with one shape for every row and wants its own
argument. #186 stays open for them.
2026-08-21 19:45:20 -04:00
logan 1668b9e0d2 Merge pull request 'Android: a seek bar you can actually hit, and the phone rule that never applied' (#193) from 187-seek-bar-hit-area into main
CI / check (push) Successful in 2m31s
CI / e2e (push) Successful in 9m29s
2026-08-21 22:25:52 +00:00
logan ec64dbded0 fix(player): give the seek bar a thumb-sized hit area
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Successful in 9m28s
On now-playing-view -- the screen that exists so a phone has somewhere
to seek from -- the slider measured 261x6 on the reference device. Six
pixels is the whole of the drag target on the app's primary seeking
affordance, against the 44px floor the app set for itself in #56 and
holds to in the queue panel.

**The phone rule had never applied**, which is why the issue read as
"the thickening stops short" rather than "there is no thickening".
seek-bar's stylesheet asked for a 12px track below 599px and then set
6px in a plain `wa-slider` rule *written after it*. A media query adds
no specificity, so the plain rule won at every width: the source said
12 and the device said 6. That is index.css's documented rule -- "the
phone section is last on purpose" -- met inside a component's own
stylesheet, where nothing in any tier renders differently to say so.
The block is last now, and the 12px track it always asked for is real.

**And 12px is still under the floor**, so the target is built around
the painted track rather than by thickening it. The two are allowed to
differ and a slider is the clearest case where they should: a 44px
progress bar would be wrong-looking and would cost the album art the
vertical space #51 spent an issue recovering.

Two things about how it is built, both settled by measurement on the
device rather than by choosing a number.

**The padding goes on ::part(slider), not on the host.** That is the
issue's untested claim, and the answer is the pessimistic one: the
inner div is what carries the gesture -- it holds the listener and the
touch-action: none -- and it is exactly the host's size, so padding the
host would grow a box that does not take the press.

**The padding is asymmetric and the margins cancel it**, so the row does
not grow by the difference. The seek row is 19px -- its clocks, not the
track, decide that -- and the play button's top edge is 8px below it,
while `.art` above is a non-interactive div. A symmetric 44px target
reaches into the play button, and growing the row instead cost the art
25px of 143 when it was tried. So the target takes the space above.

Verified on the device at 424x439: hit area 261x44 where it was 261x6,
painted track 12px, seek row still 19px, album art still 143px, 7px of
clearance left under the play button, a press 26px above the track
seeks, and a hit test on the play button's top edge still reaches the
play button.

The desktop bottom bar is untouched: the rule is inside the phone query
and that instance is display:none below 600px anyway.

The test asserts the parsed stylesheet, on hover-affordance.test.ts's
precedent and with the same limitation stated -- no tier here lays out a
real wa-slider at a phone width, and a number measured on a phone is
not a number CI can assert. What it holds is the shape: that the phone
block is last, that padding plus track clears 44, that the margins
cancel the padding, and that the growth is upward. All four are
invisible on a desktop, and the first is exactly what a tidy-up undoes.

Closes #187
2026-08-21 18:12:29 -04:00
logan dad852a8a0 Merge pull request 'Explore: two things that have not worked since plan 013, and the temp directory Android never had' (#192) from 189-190-explore-correctness into main
CI / check (push) Successful in 2m32s
CI / e2e (push) Successful in 9m33s
2026-08-21 21:43:38 +00:00
logan 30c6b665f1 fix(system): give the process a temp directory that exists
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m0s
CI / e2e (pull_request) Successful in 9m37s
Android has no /tmp and hands an app no TMPDIR. Go's os.TempDir() falls
back to "/tmp" when the variable is unset, so every library in this
process that wants scratch space was being handed a path that has never
existed.

SQLite is the one that noticed, and it said so precisely:

    W/yellowjacket: msg="champion index rebuild failed"
      explore.search-index.error="populate champion fts: disk I/O error (6410)"

6410 is not a generic I/O error. `6410 & 0xff` is 10, SQLITE_IOERR, and
`6410 >> 8` is 25 -- SQLITE_IOERR_GETTEMPPATH. SQLite could not work out
where to put a temporary file. Two measurements on the device say why:
`ls -d /tmp` does not exist, and the app process's environment carries
no TMPDIR. A shell's does (/data/local/tmp), which is why this is easy
to miss from `adb shell`.

The cost was a silent performance cliff on the slowest device this app
runs on: `championReady` stayed false, so every Explore search took the
generic path over the whole 1,079,667-row index instead of the champion
subset, and the rebuild was re-attempted on every launch.

**The class is fixed rather than the statement.** The trigger is the
*size* of the work, not that query -- anything that spills fails the
same way there, so large sorts, large joins and VACUUM were all waiting
their turn. The repair belongs at the process's one answer to "where do
temporary files go".

`PRAGMA temp_store = MEMORY` was the alternative: cheaper, more local,
and a promise that every future spill fits in RAM on a phone. The
catalog is the largest thing in this app and that is not a promise
worth making silently.

UseTempDir sits beside UseHomeOverride and carries its two rules for
the same reasons. **An empty base is a no-op**, because that is what
application.Mobile.StoragePath() returns on desktop -- so this needs no
build tag and changes nothing off mobile, where /tmp is real. And **an
explicit TMPDIR wins**, so anyone who set one deliberately gets it;
nothing sets it on the platform this exists for. It needs no new Wails
API and no Java change: StoragePath() is already what YJ_HOME is
pointed at, and the directory goes under it.

Two things beyond the rename of a variable.

**Writability is probed, not assumed.** MkdirAll on an existing
unwritable directory succeeds, so without the probe this could set
TMPDIR to a directory nothing can use -- which is the same bug one
directory over, and just as quiet.

**It returns its error, and main logs it.** A temp directory that could
not be created is the same silent failure one step earlier. A failure
is not fatal: it leaves the platform's answer in place, which is what
every release before this one ran with. That log line is readable on
the platform only because of #160.

Verified on the reference device, where the same launch that used to
print the failure now prints:

    I/yellowjacket: msg="champion index rebuilt"
      explore.search-index.elapsed=6.496s

Closes #190
2026-08-21 17:23:23 -04:00
logan d034d6e571 fix(explore): resolve pending release MBIDs against the real table
The release-group MBID backfill queried `release_groups`, which plan 013
renamed to `albums`. It failed on its first statement on every launch
since e7748f1 and the pass returned quietly having done nothing:

    W/yellowjacket: msg="release-group mbid backfill: query failed"
      explore.error="SQL logic error: no such table: release_groups (1)"

What it does is resolve a release-level MBID (MUSICBRAINZ_ALBUMID, which
many taggers write instead of MUSICBRAINZ_RELEASEGROUPID) into the
release-group MBID everything else on the album page is keyed by. A scan
cannot afford a live MusicBrainz call, so `library.updateMBIDs` stashes
the release MBID in `pending_release_mbid` and defers to this. With this
broken the marker was written by every scan and resolved by nothing, so
those albums were untagged as far as the catalog is concerned,
permanently.

**The fix is to call the queries plan 013 already wrote.**
`GetAlbumsWithPendingReleaseMBID` and `ResolveAlbumPendingReleaseMBID`
have been in sql/queries/albums.sql since that change, generated and
never called -- the writer of the marker was repointed at `albums` and
the reader was not. So this is not a missed rename so much as a call
site left behind, and thirty lines of raw SQL and hand-rolled scanning
become three.

That is also the durable half. These two were the last raw-SQL
references to a schema table in the tree, and being raw is exactly why
013 missed them: sqlc reads sql/schemas/ and cannot generate against a
table that is not declared, which is what made every other statement in
the repo immune to the same rename.

Three smaller things.

**The LIMIT came back.** The raw statement bounded a run at
releaseGroupMBIDBackfillMaxPerRun and 013's sqlc replacement had no
LIMIT at all, so switching over as-written would have swapped a dead
pass for an unbounded one -- each row is a live MusicBrainz lookup on a
1 req/s limiter shared with every page the user can open.

**The UPDATE goes through the writer.** `ReadQueries` is a query-only
pool and an UPDATE issued on it fails at runtime with "attempt to write
a readonly database".

**The query is its own method so its failure is assertable.**
A test of the pass as a whole cannot see this bug, because a query
error and an empty library are the same early return -- which is the
whole reason it survived. `pendingReleaseMBIDs` returns the error, and
the test reproduces the device's exact message against the old
statement.

Verified on the reference device: the warning is gone from logcat.

Closes #189
2026-08-21 17:23:05 -04:00
logan 25ea1f3511 Merge pull request 'Android: make the app say what it is doing, then count what the audio path misses' (#191) from 135-android-underrun-instrumentation into main
CI / check (push) Successful in 2m36s
CI / e2e (push) Successful in 9m21s
2026-08-21 20:47:45 +00:00
logan 842fe47e9e feat(player): count what the ring buffer misses
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m38s
CI / e2e (pull_request) Successful in 9m36s
An underrun is audible and nothing counted it. When the ring is empty
BufferedStreamer.Stream zeroes the caller's buffer and returns ok, so a
run of zeros is spliced into the waveform and the step discontinuity at
each edge is a click; a series of short ones is static. That is the one
candidate in #135 whose audible signature matches the report.

`starved` and `starvedSince` already existed, from #122's stall fix,
and could not answer this: they are a *stall* detector, reset by every
arriving sample, because their job is to end a track whose source has
died and a merely slow source must not be cut short. That reset is
exactly what made the audible case invisible -- a hundred 20ms
underruns a minute never approach the give-up threshold, so they were
invisible to the log, to the UI and to every tier.

UnderrunStats counts runs, calls and samples for the life of the
streamer. Runs is the number that means something audible: one episode
is one pop however many callbacks it spans, and the ratio of runs to
calls is what separates clicking from dropping out.

Three things about the reporting are deliberate.

**Counting is in Stream and reporting is not.** Stream runs on the
speaker callback's real-time deadline, so a log line there would
allocate, format and write on the exact path whose missed deadline is
the defect -- measuring by making it worse. The count is two increments
under a lock that was already held; the report is on the 1 Hz position
ticker, which only runs while playing.

**An unchanged count is not logged**, which is emitStatus' rule one
package over. A healthy player is silent, so anything in the log is
news and the line appears exactly while it is popping.

**It is Info rather than Debug.** The default level is Info and a phone
has no convenient way to set YJ_LOG_LEVEL, so a debug line here would
be a counter nobody on the affected platform could read -- which is the
shape of the bug that made #160 necessary.

underrunDelta clamps at zero because the counter belongs to the
streamer and the streamer is replaced on every track: a baseline
carried across that boundary is the previous track's total subtracted
from a fresh zero. The baseline is reset at the load as well; a
negative count in a log line reads as a broken instrument and would
discredit the measurement this exists to make.

This is the instrument, not a fix. What it measures is on the issue.
2026-08-21 16:30:32 -04:00
logan 168e588387 feat(android): route slog to logcat
Every slog line the app wrote on Android went to /dev/null, including
the one naming the error it was about to os.Exit on. #52 is what that
cost: a process that vanished with no tombstone, no AndroidRuntime
stack and nothing in `logcat -b crash`, at Priority/Critical for
months, whose entire diagnosis was one sLogger.Error main.go was
already writing.

backend/androidlog is a slog.Handler over __android_log_write, chosen
in main() by build tag rather than by a runtime check so that a desktop
binary links no cgo for a platform it cannot run on.

**Everything except the write itself is untagged.** That is
androidpayload.go's discipline pushed as far as it goes: the only
toolchain that compiles the android tag is a cross-compiler and the
only thing that runs it is a phone, so the priority mapping, the
formatting, the chunking and the handler's own attr and group
bookkeeping are ordinary Go that `go test` exercises everywhere, and
android.go is fifteen lines that hand a string to liblog.

Four things in it are load-bearing.

**The tag is a fixed string, not the application id.** The debug build
carries `applicationIdSuffix ".dev"` so it can be installed beside the
release app, and it is the only build whose WebView can be inspected --
so a tag derived from the id is a different tag on the one build
anybody debugging this app is running, and the filter meant to show
these lines would hide them exactly where they were being looked for.

**The priorities are android/log.h's own values, asserted twice.**
android.go carries constant expressions that do not compile as uint if
the header renumbers; the untagged test writes the six numbers out
longhand, because comparing a constant to itself passes on any
renumbering. A wrong priority is the failure that hides rather than
breaks -- logcat prints whatever number it is handed, so an Error filed
as Info is present, correct, and invisible to every filter.

**Formatting is delegated to slog's TextHandler.** WithAttrs and
WithGroup are the half of slog.Handler that is easy to get subtly
wrong, and a logger whose groups are wrong is a logger nobody reads.
The derived handlers share the parent's buffer *and its mutex*: a
second mutex would guard nothing, and two loggers derived from one
would splice their bytes into a single line under load.

**A line is chunked, because liblog drops what does not fit.** The
kernel logger's entry is 4068 bytes for tag and message together and
the remainder goes without comment, so a long record would be truncated
in the middle of the thing worth reading.

Time and level are dropped from the formatted line, since logcat stamps
every entry with both -- and dropping them by *key* also ate a caller's
own "level" attribute, which the on-device probe caught and
TestACallersOwnLevelAttrSurvives now holds. ReplaceAttr sees an empty
group path for the built-ins and for every top-level attribute alike,
so the kinds are what separate them.

Verified on the reference device (TLP301, Android 14): a debug build
logs I/W/E under the `yellowjacket` tag at the right priorities, and
the first thing it surfaced was a real warning nobody could previously
see -- `champion index rebuild failed ... disk I/O error (6410)`.

Closes #160
2026-08-21 16:30:32 -04:00
logan 7eb55bd378 Merge pull request 'Android: a Now Playing that survives a 439px screen, and the audit behind it' (#188) from 51-android-small-screens into main
CI / check (push) Successful in 2m31s
CI / e2e (push) Successful in 9m41s
2026-08-21 20:19:46 +00:00
logan f31331c83b docs(skill): what a fresh install is doing before you measure it
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 9m32s
Three things about a fresh install cost a measurement each, and none of
them was written down: it downloads the real catalog, so job-band is
103px of a 439px screen and every vertical number is wrong; stopping
that build returns cleanly and it **starts again within seconds**, so it
has to be stopped immediately before a measurement rather than once at
the start; and a library added over the bridge does not dismiss the
first-run wizard, which then sits over whatever you are looking at with
a correctly disabled button, reading exactly like a swallowed tap.

Also the scoped-storage path that works, the appops grant whose absence
sends the app to the system "All files access" screen on launch, and
why EXPR='...' cannot carry an apostrophe -- a file path with one in it
fails as a JavaScript error. The positional form takes a file.
2026-08-21 15:58:48 -04:00
logan 6a22601af7 docs(player): a device number is not a number CI can assert
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Canceled after 7m48s
The spec's floor on the art's height passed locally at 114 and failed in
CI at 64. Both honest: the e2e app is long-lived so an earlier spec's
job is still on screen, and volume-control renders in a browser where it
does not on Android. Same trap as the staged-job entry above, arriving
as a measurement rather than as a stuck job.
2026-08-21 15:55:56 -04:00
logan ce9951b93a test(player): assert the mechanism, not the room CI happened to have
The floor on the art's height passed locally at 114 and failed in CI at
64. Both numbers are honest and neither is about this change: the e2e
app is long-lived, so a job staged by an earlier spec is still on
screen, and the volume control renders here where it does not on
Android. Both are chrome above and below the view, and both move the
leftover.

So the claim is stated as what the reflow does rather than as what it
measures -- in a row the art is bounded by the row's height and fills
it, where in a column it is the leftover after the names. That is the
mechanism behind 53px to 143px, and it fails on the old build with
"there is no row to fill". The device numbers stay on #51, which is the
only tier that can honestly produce them.

This is the second draft of that assertion to be thrown away; the first
compared the art against the column's leftover and passed on the defect,
because the subtraction goes negative exactly when the names are taller
than the art.

Also stops the arrangement wait from requiring the row to exist, so
reverting the component to check that these tests bite still produces
the crop measurements -- 263x39, 358x315, 300x36 -- rather than eight
timeouts. 5 of 8 fail on the build before this change.
2026-08-21 15:55:43 -04:00
logan 99a45401c7 docs(player): correct the audit's scope, and the probe's false positives
The sweep covered the detail views, Downloads and Autotag as well as
the ten primary views; the note said "ten primary views plus the
queue". The null result is unchanged and now covers more.

Also records the two false positives the probe produced before it was
right, since the next audit will write the same two checks: "painted
outside the viewport" flags a horizontally scrolling carousel, so the
question is whether a scrollable ancestor can bring it back; and a hit
test at a control's centre flags everything below the fold in a scroll
container.
2026-08-21 15:46:50 -04:00
logan dd76bd2fa7 docs(player): record the crop, the reflow, and the audit's null result
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m39s
CI / e2e (pull_request) Failing after 9m47s
The audit #51 asks for, at 424x439 on the reference device with a real
1,577-track library: all ten primary views plus the queue. What it did
*not* find is worth recording, because it is the promise plan 018 makes
-- the shell does not overflow on any view, nothing is stranded outside
a scrollable ancestor, and a hit test at each control's centre reaches
the control. The width work of #57, #62, #55 and #59 holds; what was
left was vertical.

What it found is filed rather than fixed here: #186, every control that
is not the transport is under the 44px floor, and #187, the seek bar's
drag target is 6px.

Also the two device traps that cost time despite being written down --
a fresh install downloads the real catalog and the job band then eats
103px of a 439px screen, and it *restarts* after being stopped; and the
first-run wizard does not re-check for a library it did not create, so
adding one over the bridge leaves it up with a correctly disabled
button.

Closes #51
2026-08-21 15:42:15 -04:00
logan dee176c0f7 test(player): pin the art's shape and the short-screen arrangement
Four of these eight fail on the build before the fix, with the numbers
the issue is about: 263x39 at 424x439, 358x315 at 390x700, 300x36 at
900x500, and no row at all below 500px. The fifth viewport, 412x869,
passes on both -- which is the boundary landing exactly where the
arithmetic says it should, since the leftover only exceeds the width
above ~843.

Three of them cannot fail on the old build and are said to be guards
rather than evidence: that the transport does not scroll off (the old
build shrank the art instead, so it did not scroll either), that a tall
phone keeps its column, and -- after a first draft that passed on the
defect because the subtraction went negative -- a floor on the art at
the device's own viewport instead of a comparison with a layout that is
no longer there.

The wait is on the arrangement rather than on a non-zero box: a
previous test leaves the other layout on screen and a stale column
satisfies "has a size" perfectly, which showed up as one test passing
alone and failing in file order.

What this tier cannot see is the device's engine. Nothing here depends
on Chrome 113 behaviour -- the sizing rules were chosen by measuring
that engine directly, and the numbers are on the issue.
2026-08-21 15:41:59 -04:00
logan 75a24f98b6 fix(player): give Now Playing a layout that survives a short screen
Two things, and the first was a defect underneath the design question
rather than an answer to it.

**The album art was never square.** aspect-ratio is specified not to
re-derive the width when max-height clamps the height, unlike an
intrinsic ratio, which is preserved under both bounds. So a definite
`width: min(100%, 60vh)` kept its width while the height was clipped
and object-fit: cover cropped a square cover into the band -- 264x53
on the reference device, which is what #172's "39px of art" actually
looked like. It is not only the phone either: the leftover exceeds the
width only above ~843px of viewport, so every height from ~500 to ~843
drew a crop. Both maxes with auto sizes is the fix, chosen by measuring
four candidate rules against Chrome 113 itself at five column heights.

The placeholder cannot use that rule -- with no intrinsic size it
collapses to its icon, 13x58 -- so it is driven from the height, with
min-width: 0 because a flex item's automatic minimum is its content,
and max-height: calc(100vw - 2rem) because a non-replaced box cannot
express "the largest square that fits" and went 380x484 on a tall
phone without it.

**Then the reflow.** The stacked budget is fixed, so the art gets
`height - 386` and that is 53px at 424x439. #172 named two ways out;
a floor on the art scrolls the transport off the bottom, and controls
never scrolling off is #51's own Direction and plan 018's promise --
so below 500px the art and the names share a row, where the art is
bounded by the row's height rather than the column's leftover. 53px to
143px on the device, nothing scrolling, the transport untouched.

500 is where the two layouts cross rather than a round number, and it
is keyed on height alone because it answers vertical room: a 900x450
window has the same problem and the same fix.
2026-08-21 15:41:47 -04:00
logan 327785e5ec Merge pull request 'fix(queue): draw the scrim only where it can be tapped' (#182) from fix/171-phone-queue-scrim into main
CI / check (push) Skipped
CI / e2e (push) Skipped
Build & publish the Android APK / apk (push) Successful in 1m29s
Build & publish Arch package / arch-package (push) Successful in 2m37s
Attach the desktop build to the release / linux (push) Successful in 56s
Sync Homebrew formula / sync-formula (push) Successful in 8s
2026-08-21 16:47:46 +00:00
logan 7ba5d321f6 test(queue): pin the breakpoint listener the scrim rule rests on
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 9m18s
The scrim's existence comes from matchMedia rather than a stylesheet, which only holds if the query is listened to — and the stub's addEventListener was a no-op, so deleting the listener left all 986 tests green. The stub records its listeners now and the new case carries a panel across the breakpoint in both directions. Watched failing with the listener removed.
2026-08-21 16:24:04 +00:00
logan f126dd7397 fix(queue): draw the scrim only where it can be tapped
Below 600px `.panel-content` is `width: 100%`, so the scrim sat
entirely underneath an opaque panel -- measured at 424x439, host,
panel and scrim all 424x318. It dimmed nothing and dismissed nothing
there while wearing `cursor: pointer`, so #24's tap-outside-to-close
did not exist on the device it was drawn for.

Of the issue's two directions this takes the second. A gutter is the
drawer pattern and buys the affordance by taking width off a
full-screen surface on a 424px viewport; #55 already made the queue a
*screen* at that width, whose ways out are back and a 44px close
button. So there is no scrim there rather than an unreachable one.

Existence is `matchMedia` rather than `display: none`, on `job-band`'s
rule: a hidden scrim is still an element carrying the handler. The
600-899 band, where the panel is a 320px column of a wider content
area and the scrim has real uncovered pixels, is untouched.

The e2e half asserts *absence* at 424x439 rather than clicking,
because a phone-width case that clicks the scrim's centre hits the
panel and passes on the broken build -- which the issue anticipates.

Closes #171
2026-08-21 16:24:04 +00:00
logan 510d3470f9 Merge pull request 'fix(ui): keep a touch-only affordance reachable, or absent' (#181) from fix/137-touch-only-affordances into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 9m28s
2026-08-21 16:23:43 +00:00
logan d78830aa52 fix(ui): make the touch pen a corner chip, not a scrim over the art
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 9m19s
Always-visible is not the same as always-in-the-way: the overlay is inset:0 at 50% black, so gating it on hover left every touch device with the artwork it is editing permanently darkened. It is only a hint — .cover-art-edit carries the click, so tapping the art always worked — while the × really is the only route to its action and stays. The chip borrows the remove button's size, disc and alpha.

Also corrects the claim that no tier can render as a touch device: no committed one does, which is a choice about projects rather than a limit.
2026-08-21 15:59:45 +00:00
logan a72d1f68ed fix(ui): keep a touch-only affordance reachable, or absent
Three controls are revealed by :hover and are the only route to their
action on a device that has none. #68 hid the home card's play button on
touch, which was right because tapping the card does the same thing;
these are the opposite case, so hiding them removes the action outright
and leaving them costs the same long-press flash #68 was filed for --
they are visibility:hidden / opacity:0, so on touch they are invisible
controls that still take taps.

track-details' cover-art overlay and remove, and shortcut-capture's
reset, are always visible under `@media not all and (hover: hover)`.

The queue row's remove is the third case the report names and takes the
other treatment, because #60 has since landed: the row's context menu is
a bottom sheet carrying "Remove from Queue", so the action is one
long-press away and an always-visible X would spend part of a 424px row
on something already reachable. It is display:none outside
`(hover: hover) and (pointer: fine)` rather than visibility:hidden,
which would leave a button holding its hit area and its place in the
accessibility tree -- the trap this issue is about.

The rule is not extracted into styles/ yet: that leaves two call sites
of the always-visible form, under the four the report names.

No tier here can render as a touch device, so the tests read the parsed
stylesheet the way #68's does and say so; the touch and hover renderings
were measured against the running app in a hasTouch context instead.

Closes #137
2026-08-21 15:59:45 +00:00
logan 60f1c5a6b2 Merge pull request 'build(frontend): fail css-check on a nested rule the phone drops' (#180) from fix/154-nested-css-check into main
CI / check (push) Successful in 2m33s
CI / e2e (push) Successful in 9m31s
2026-08-21 15:59:25 +00:00
logan 11ba7b3180 build(frontend): sweep every stylesheet, not index.css by name
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 9m16s
The hook fires on frontend/**/*.{ts,css} while the script read one hardcoded path, so a second stylesheet would have been silently unswept while the hook still went green over it. There is only index.css today, which is exactly when this is cheap to fix. Watched catching a planted nested rule in a second file.
2026-08-21 15:16:47 +00:00
logan 7f8e185d7c build(frontend): fail css-check on a nested rule the phone drops
The device renders in Chrome 113, which predates relaxed CSS nesting, so
a nested rule whose selector starts with an element name is not a parse
error anyone would notice -- the rule simply does not exist, there and
nowhere else. Three were live in `index.css`, and the one that mattered
was the `text-overflow: ellipsis` on the bottom bar's title and artist,
which had therefore never truncated on the device. No tier here can see
the class at all: the component tier, the e2e tier and `make ui-visual`
all run a current engine, where the rule applies normally.

So `make css-check` carries a second script. It reads `index.css` and
the `css` literals in `src/**/*.ts` alike, since a shadow-root
stylesheet is parsed by the same engine, and it names the file, the line
and the fix -- a leading `&`, which is valid in both syntaxes.

The detection walks blocks rather than matching lines, and both things
it has to get right fall out of one rule: a rule is nested when a
*style* rule is somewhere above it, not when its immediate parent is a
block. That leaves `@media (...) { bottom-nav { ... } }` at the top
level alone, which is the majority of what a regex over the file would
report, and still flags the same rule inside an at-rule that is itself
inside a style rule. Strings and comments are read through, so a brace
in a `url()` is not a block.

The tree has no violation left, so the check would pass just as happily
over an empty glob: it refuses one, and `test/utils/css-nesting.test.ts`
pins the semantics that make the sweep mean something. The literal
scanner the two checks share is lifted into `css-literals.mjs`
unchanged, except that a `${}` substitution is now blanked keeping its
newlines so a line number survives it.

Closes #154
2026-08-21 15:16:47 +00:00
logan 42483c4b61 Merge pull request 'feat(player): show progress on the phone's bar border' (#178) from feat/58-mini-player-progress-line into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 9m2s
2026-08-21 15:16:26 +00:00
logan deea6ad06d test(player): pin the desktop timer gate, drop a leaked queue
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 9m14s
Two gaps a review found. The this.phone gate on the interpolation interval is what CLAUDE.md says earns the matchMedia call, and every test passed without it — so it is asserted on the timer count now, since a desktop render is empty either way and cannot tell the two apart. Watched failing with the gate removed.

The e2e spec left LONG_TRACK playing in a workers: 1 suite against one long-lived app, immediately before four other phone-* specs. Nine specs clear the queue in afterEach for that reason and phone-transport.spec.ts records the flake it caused.
2026-08-21 10:45:15 -04:00
logan fba608fdbd docs(player): attribute the phone seek bar's removal correctly
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Canceled after 0s
The paragraph said #59 took the seek bar off the phone's transport.
It was plan 016 B2 — audio-player.ts says so in the comment above the
rule that does it, and CLAUDE.md's own #59 paragraph says #59 removed
shuffle, repeat and the queue button. Wrong provenance in the file
whose whole value is being right about which change did what.

Also stop tracking .pi/journal.md. It is a scheduled run's scratch log,
and this repo's memory is CLAUDE.md and .planning/ — a session log
arriving inside a feature PR is a new convention landing sideways.
2026-08-21 14:38:12 +00:00
logan f59490b113 feat(player): show progress on the phone's bar border
#59 took the seek bar off the phone's transport, so the one thing a
mini player is expected to say without being opened -- how far through
the song it is -- had nowhere left to be said.

It is the shell's element and its own 2px grid row between `bottom-bar`
and `bottom-nav`, because those two are separate components and either
one drawing the line means reaching into the other's box. The fill is
`scaleX()` off the same `PlaybackPositionChanged` the seek bar renders,
with the same `trackChangeId`/`seq` guards and an interval that only
interpolates *between* reports -- never its own clock, which is the
rule that exists because a local counter drifted 30 s away from the
backend across four keyboard seeks.

It is `aria-hidden` and takes no pointer events at any depth: Now
Playing's seek bar is what announces the position, and a 2px strip on
the top edge of the tab bar is exactly where a thumb aiming at a tab
lands. It renders nothing above 600px, from `matchMedia` rather than a
media query, because a stylesheet cannot stop a 1 Hz interval running
for the life of every desktop session about a line nobody can see.

Its phone rule is at the foot of index.css beside `job-band`'s, not in
the phone block above: a media query adds no specificity, so a
`display: block` written before the `display: none` that takes it out
of the desktop grid loses to it and the line never appears at all.

Closes #58
2026-08-21 14:38:12 +00:00
logan 6cca57f229 Merge pull request 'fix(explore): scroll the album page as one on a phone' (#179) from fix/66-album-page-scrolls-as-one into main
CI / check (push) Successful in 2m26s
CI / e2e (push) Successful in 9m23s
2026-08-21 14:36:20 +00:00
logan ea3edde697 fix(explore): scroll the album page as one on a phone
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 9m8s
`explore-album-details` was a fixed header over a scrolling tracklist,
which is the desktop arrangement. At the reference device's 424x439 the
header owned 253 of the panel's 318px and the list scrolled inside the
64 that were left, and the header's flex row squeezed `.album-info` to
112px beside a 200px cover -- so the title drew as one ellipsised glyph
and two of the album's three primary actions were clipped by the
component's own `overflow: hidden`: "Shuffle album" ended at x=443 in a
424px box, reachable by no gesture.

Below 600px the host is the scroller and `.content` stops being one, so
the header scrolls away and the page moves together; the header stacks
art over info, so the info column has the row's whole width. The
tracklist is plain DOM rather than a virtualizer, so nothing inside
wants a scroll window of its own.

Another `min-width: 0` was not the fix and the issue's own measurement
says so: `.album-info` carries one and was shrinking as asked. Nor
could `layout-overflow.spec.ts` see any of this -- `body.scrollWidth`
equalled the viewport throughout, because the overflow was inside a
component -- so the new spec measures each header control against the
host's own box, which is `top-bar-fit.spec.ts`'s shape for the same
reason.

The phone block is last in the stylesheet on `index.css`'s rule: a
media query adds no specificity, so above the rules it overrides every
declaration in it would be silently dead.

Closes #66
2026-08-21 04:40:35 -04:00
logan 14e3ab574c Merge pull request #176: context menus are a bottom sheet on a phone
CI / check (push) Successful in 2m27s
CI / e2e (push) Successful in 9m9s
2026-08-21 07:25:55 +00:00
logan 4b2eec5703 Merge remote-tracking branch 'origin/main' into 60-context-menu-action-sheet
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 8m51s
2026-08-21 02:39:29 -04:00
logan 3871d37fdb Merge pull request 'chore(agent): add the scheduled backlog-issue prompt' (#177) from pi-agent-backlog-automation into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 8m54s
Reviewed-on: #177
2026-08-21 06:29:30 +00:00
logan 09b005557c chore(agent): add the scheduled backlog-issue prompt
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m25s
CI / e2e (pull_request) Successful in 9m2s
A scheduled pi session reads this file and works one open issue end to
end: pick, claim, branch, implement, verify on the tier the change
demands, open a PR, stop. It declines rather than improvises where it
cannot verify itself — a busy :34115 means another worktree is running
the app, and a green e2e run against someone else's build is worse than
no run at all.
2026-08-21 02:28:55 -04:00
logan ef5574d18b docs(shell): record the clip, and the four things only a device showed
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 8m57s
CLAUDE.md gains the surface beside the keyboard model it shares, and
NOTES.md the measurements: the 83px clip with its screenshot, the probe
that established a top-layer dialog escapes paint containment from
inside a view, the UA stylesheet's 354px, the focus steal a longer
retry cannot beat, and the submenu this change pushed off-screen before
it pulled it back.

The last of those is also a note about scope: the issue was claimed
saying the submenu would be measured and filed, and the measurement
said fix it.
2026-08-21 02:24:04 -04:00
logan 31dafb0ce0 test(shell): assert the surface, and sweep for a menu that skipped it
No tier here can reproduce the defect: this runner's Chromium and CI's
WebKit both have the Popover API, so the popup is top-layered and looks
perfectly correct, and a spec asserting "the menu is not clipped" would
pass on the broken build.  So these assert the mechanism -- that the
surface is a native <dialog> at phone width -- which is the same move
queue-as-a-screen.spec.ts makes about containment, for the same reason.

The sweep is the more valuable half.  A thirteenth menu written as a
bare <wa-popup> would work in every tier here and be clipped on the
device, so this reads every source file and fails on one outside a
three-file allowlist, each entry carrying why.  It found two call sites
the by-hand conversion had missed.

Four of the six behavioural tests fail on the build before this change;
the two asserting the desktop popup cannot, because that behaviour was
already there.

Closes #60
2026-08-21 02:24:02 -04:00
logan 9e7e7ce5a1 feat(shell): put every menu in the app through the one surface
Fourteen call sites, one tag name each and nothing else -- which is what
menu-surface's shape buys: the host's panel is slotted into whichever
presentation is up, so no item model, no keyboard model and no styling
moved.  The 48px rows come from contextMenuStyles, the one stylesheet
every one of these hosts already includes, because the panel is the
host's own light DOM and only the host's stylesheet can reach it.

Two of the fourteen were found by the source sweep rather than by the
conversion: queue-panel's add-to-playlist popup, which is a real menu.
now-playing's cover preview is allowlisted instead -- it is a hover
affordance in the bottom bar, so a touch device never opens it and
nothing clips it.

The playlist submenu had to come too, and that is the one place this
change made something worse before it made it better.  It is a
placement="right-start" flyout anchored to its row, and making the menu
full-width moved that anchor to x=0 -- so the flip put the picker at
x -182 to 0, entirely off-screen, and "Add to Playlist" led nowhere at
all.  Before the change the row started at x~245 and the same flip
landed on screen.  It is a sheet now and stacks over the first, which
is also why menu-shown does not re-assert focus while it is open.

The three hosts that do not use ContextMenuController -- page-header's
overflow menu, playlist-view's hand-rolled menu, queue-panel's picker
-- bind menu-dismiss themselves, or Escape would close the sheet and
leave their own open flag set.

page-header is included deliberately: the clipping does not bite there,
since it opens downward from the top of a full-height view, but on a
phone every action of an overflowing page lives in that menu at
wa-dropdown-item defaults.  One surface, so there is no second answer
to what a menu looks like.
2026-08-21 02:23:48 -04:00
logan 9aaa8beb99 feat(shell): draw a context menu where it fits, not where it is anchored
On the reference device every context menu in the app is clipped, and
the two halves of that are structural rather than incidental.  Chrome
113 has no Popover API, so wa-popup takes its own documented fallback
and positions with strategy: "fixed"; .main-panel carries
contain: layout style paint, and paint containment clips fixed
descendants.  Measured at 424x439 before any of this: the main panel
spans 0-318, the open menu spanned 191-401, and three of its seven
items were cut off with no way to reach them.  Rows were 29px against
a 44px floor.

menu-surface is one element with two presentations -- a wa-popup above
600px, a wa-dialog bottom sheet below it -- so the host keeps rendering
the panel it always rendered and ContextMenuController keeps driving
.active and .anchor as though it were talking to a popup.  showModal()
is Chrome 37 and uses the real top layer, so the sheet is immune by
construction rather than by styling.

Four things needed measuring on the hardware rather than reading.

"A dialog escapes containment" was the premise and was untested here:
every other dialog in this app is mounted in index.html, outside
.main-panel.  A probe dialog appended to track-list's shadow root
paints to y=439, over the mini player and the tab bar.

A native dialog's UA stylesheet centres it and caps its width, which
drew a 354px panel in the middle of a 424px screen -- so four
declarations in this component are pure undoing.

wa-dialog focuses [autofocus] or itself on the frame after
showModal(), and it cannot see our first menu item to prefer it: the
panel is slotted, so its own querySelector stops at the <slot>.  A
longer retry budget does not fix that, because the first attempt
succeeds and is then overwritten -- hence menu-shown and
MenuKeyboard.refocus().  The budget became time-based anyway, since
what is being waited for is another component's animation.

And a dismissal has to travel back: wa-dialog closes itself on Escape,
which would leave the controller believing the menu is open.  The
failure mode there is not a stuck sheet but the *next* long-press
doing nothing, which reads as the gesture breaking.
2026-08-21 02:23:33 -04:00
logan 2e29e67664 Merge pull request #174: Android: leave the volume to the system
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 8m56s
2026-08-21 05:50:06 +00:00
logan f26b44db08 docs(player): close three of the four gaps with a real device
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 9m5s
A Light Phone III (Android 14, SDK 34, arm64, Chrome 113 at 424x439)
was attached after the PR was opened, so what it listed as
unverifiable was checked rather than left as a caveat.

SystemOwnsVolume answers true on the device -- the build tag, the
constant, the field and the generated binding, end to end, which is the
one thing a source sweep only approximates and which nothing else here
compiles at all.  The control is absent in both mount points on the
real engine, and the transport measures 143px, exactly what the
desktop-headless "after" predicted.  A stored volume of 37 survives a
session that demonstrably rewrote the row.

The duck is the one that stays open, and now for a stated reason rather
than for want of hardware: the foreground service omits
setWillPauseWhenDucked from Oreo, so the framework attenuates us itself
and never sends AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -- the device logs
`requestAudioFocus() ... flags=0x0` saying so.  minSdk is 21, so that
path is live code on Android 5.0 to 7.1 and unreachable above it.
Asking for a modern phone will not test it.
2026-08-21 01:34:44 -04:00
logan b43172a60c docs(player): record who owns the volume, and what it gave back
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Successful in 8m47s
CLAUDE.md's volume paragraph ended "#64 asks for it to be gone on
Android outright, which is a platform question the frontend cannot
currently ask", which is no longer true -- it can, and the paragraph
now says why the answer is a capability rather than a viewport and what
that costs.  The mediacontrols entry gains the corollary: on that
platform "the user's level" is a constant, and the duck is the one
thing that may still move the output.

NOTES.md carries the measurements: the per-element budget at 424x439
before and after, the :host([hidden]) specificity trap, the fact that
the bar's centring survives the control going away, and what no tier
here could check.
2026-08-21 00:53:44 -04:00
logan 2be6fb3066 feat(player): draw no volume control where there is no volume
volume-control asks the player whether there is a volume of ours to
control, and renders nothing when there is not.  The decision is in the
control rather than at either mount point because there are two, and
one of them -- the bottom bar's -- lives in index.html, which has no
module scope to make it conditional.

It could not have been a width, and that is the whole design decision.
Every other stand-down rule in this app is keyed on a viewport, because
a width is what a browser can answer and what every tier can test.
This one is a property of the build: keyed on width, an Android tablet
at 600px or more draws the bar's slider over a level the backend has
pinned -- a control that cannot act, on exactly the platform the rule
exists for, which library-status-indicator settled is worse than none.
The same rule is wrong the other way below 600px, where a narrow
desktop window has no hardware keys to fall back on.  index.css keeps
its phone rule, which is now about room and says so.

Rendering nothing and hiding the host are both needed and are separate
assertions: an empty shadow root is what stops a by-role or positional
query finding a button that cannot act, and :host([hidden]) is what
stops the element taking a flex item's worth of the transport.  The
host rule has to be written down, since :host { display: inline-flex }
outranks the UA's [hidden].

Measured at 424x439 by flipping the constant and rebuilding: the album
art goes 39px to 68px and the transport 172px to 143px -- 29px, being
the 21px control plus the 8px gap a hidden box stops drawing.  The
bar's centring is unaffected, since #23's outer columns are the same
min() expression rather than content-sized.

volume-ownership.test.ts is the tier that can exercise the Android
rendering, on an ordinary Linux runner, because the predicate is a
stubbable backend answer.  Both of its tests were confirmed to fail on
the build before this.

Closes #64
Closes #172
2026-08-21 00:53:36 -04:00
logan 867ced8c81 feat(player): leave the volume to the system where the system owns it
On Android the hardware keys are the volume control and the framework
mixes our stream against the device level, so a second control inside
the app moves something the user already moved.  Where that is true the
player's level sits at maximum, SetVolume / ChangeVolume / MuteToggle
are refused, and nothing persists a level nobody chose: restore
remembers the stored value instead of applying it, and saveState writes
that same value back rather than recording the synthetic maximum.

Mute is in that list because it is a level of zero by another name --
and because with no control rendered it would be the one state on such
a platform the user could not get out of.

The predicate is named after the capability rather than the platform,
because that is what makes it testable.  Only platformOwnsVolume is
behind a build tag, in two files that declare nothing else; everything
else is decided against Player.systemVolume, a field a test sets either
way.  That is mediacontrols' split, with androidpayload.go's reasoning
for keeping the contract out of a tagged file, and the tagged pair is
covered by a source sweep since no tier here compiles both halves.

SetDuck is deliberately untouched: it applies its attenuation by
re-applying the *user's* level through setVolumeLocked, so pinning that
level to maximum leaves the offset arithmetic exactly as it was.  It is
the only thing that may still move the output on such a platform, and
TestSystemVolumeStillDucks is that property rather than a comment.
2026-08-21 00:53:21 -04:00
logan fd71ef53c5 Merge pull request 'The phone transport: three controls, sized for a thumb' (#173) from 59-slim-the-mini-player into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 9m5s
2026-08-21 04:14:39 +00:00
logan e3b64f9255 test(player): assert the desktop bar's size by mechanism, not by pixels
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m24s
CI / e2e (pull_request) Successful in 8m56s
WebKit draws the same button 36x24 where Chromium draws 33x21, so the
literal this pinned failed in CI on a build where nothing was wrong. A
button's box comes from the UA stylesheet when the author sets nothing,
and what each UA sets is its own business.

What must not happen is that *we* set something. So: `min-width` and
`min-height` compute to 0px, the font-size still equals that of a bare
button probed in the same page, and all five boxes are identical --
which is what says the desktop is neither sized context. Checked by
re-introducing the `font-size: inherit` regression, which it catches in
Chromium; the literal form could only be checked by hand.
2026-08-21 00:02:27 -04:00
logan c7e5a4f086 docs(player): record the phone transport, and four silent failures
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Failing after 9m38s
The model in CLAUDE.md beside the volume rule it qualifies; the
measurements and the four things that cost a cycle each in NOTES.md,
dated. Three of the four are invisible to every assertion in the repo:
a button not inheriting its font, a nested rule out-specifying a later
one, and art whose height is bounded by nothing.
2026-08-20 23:42:36 -04:00
logan f65822c4b2 test(player): pin the phone transport, and the desktop bar not moving
Ten tests, five of which fail on the build before this. The desktop
guard is meant to pass there -- that is its job, and it is the one that
caught a three-pixel regression nothing else could see.

`openTheQueue` moves to the fixtures, because hiding one button failed
ten tests in four files about the back stack and about layout: every
one of them opened the queue by clicking `#queue-button`, and so was
quietly asserting *which* route exists as well as what the queue does.
The route differs by width now and that is the feature.

Two smaller things. The play button is named for its action, so an
exact 'Play' waits out a fixture track -- 11.1s per test, passing by
luck, and it would have failed outright against LONG_TRACK. And the
"nothing playing" case clears the queue itself rather than trusting the
app not to have played anything: `make e2e` runs one long-lived app
across every spec (#168), which is how a deterministic bug first showed
up as a flake.
2026-08-20 23:42:35 -04:00
logan 32d4dc2c82 feat(player): slim the phone's mini player to three controls
Shuffle, repeat and the queue button leave the phone's bottom bar.
They are not gone: all three are on the full-screen Now Playing view,
one tap away through the mini player's art, which is the "reachable
only from Now Playing" this issue asks for. #55 is what makes the queue
half safe -- it is a screen with an entry in the back stack now, rather
than a panel with no way out but the button being removed here.

Removing a control is only allowed because it is still reachable, which
is plan 018's matrix promise, so that is what the spec walks rather
than counting buttons. It found that the route did not exist in the
state that matters: `now-playing` renders two branches and the no-track
one had no `.expand` button on its placeholder, so with nothing loaded
there was no way to the full-screen view at all -- and once the queue
button left the bar, no way to the queue. The queue is persisted across
restarts, so "tracks queued, nothing playing" is a state the app
launches into, not a corner.

The favourite stays on the bar and was 18x14px, the smallest control in
the app, against the 48x48 art beside it.

One CSS trap, because it failed silently. The phone block is last in
index.css on purpose -- a media query adds no specificity -- but the
rule it overrides here is written *nested* inside `.bottom-bar`, so it
builds to a descendant selector one class more specific and a bare
`#queue-button` lost to it. Being last is not enough when the thing
above is more specific.

Closes #59
2026-08-20 23:42:34 -04:00
logan 218e4f5e99 feat(player): give the transport a context, and thumb-sized controls
Measured at the reference device's 424x439, every button here was
33x21px -- in the bottom bar and on the full-screen view alike. #56
reports them as "the most important thing in the mobile app and they
are tiny", and that is the number behind it.

The context is a **property, not a media query**, and that is the whole
design. Everywhere else in this app a component states what it drops at
phone width itself, because a media query inside a shadow root is
answered by the viewport and that is the honest signal. Here the two
hosts want different answers at the *same* viewport: on a phone the bar
wants three controls sized for a thumb and now-playing-view wants five,
larger still. So the host says which context and the viewport says
which size band, and neither alone can express it.

Play/pause alone goes above the 44px floor. A row of five identical
squares says every action is equally likely, which is not true of play
-- "large play/pause, adequate prev/next" is the Direction, and a spec
caught that the first version had sized all three the same.

Two things that fail silently:

The desktop bar must not move, and a `<button>` does not inherit its
font from its parent -- the UA stylesheet gives it one. So a generic
`font-size: inherit` is not the no-op it reads as: it took every
desktop control from 33x21 to 36x24. The box rules take a zero fallback
and the font-size rules are scoped to the two contexts that set one.

And the art on now-playing-view overflowed its own box, drawing over
the header above and the title below, because `aspect-ratio: 1` with a
definite width derives a height that nothing bounds -- 60vh bounds the
viewport, not the room left over. `max-height: 100%`. Pre-existing;
found by reading a screenshot, which is the only tier that can see it.

What is left is #172: with the transport at 172px of a 439px screen the
art is a 39px sliver.

Closes #56
2026-08-20 23:42:15 -04:00
logan 56a5ff99fe Merge pull request 'The queue is a place while it covers the content' (#169) from 55-queue-as-a-screen into main
CI / check (push) Successful in 2m35s
CI / e2e (push) Successful in 8m41s
2026-08-21 03:00:45 +00:00
logan af4b28b0d7 docs(queue): record why the queue is not a detail view
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 8m49s
The measurement that decided it, dated, in NOTES.md -- the overlay's
rect against the main panel's, the three things that were genuinely
missing, and the computed containment of both candidate mounts. The
model itself goes in CLAUDE.md beside the overlay rule it extends.
2026-08-20 22:14:42 -04:00
logan 4ee5b4b473 test(queue): pin the back stack and the mount that was not taken
Nine tests, and the header says which of them reproduce the defect:
three do, and the other six cannot. "The entry is not orphaned" and "a
docked column is not in the stack" are both vacuously true of a build
that pushes no entry at all. That was established by reverting the
source and re-running, not assumed.

The containment assertion is the one worth reading twice. It asks
where the panel *is* rather than whether a menu is clipped, because
CI's Chromium and WebKit both have the Popover API -- so the symptom is
invisible here and a spec asserting "not clipped" is green on the
broken build. `.planning/NOTES.md` states the mechanism.

The rest assert the entry rather than `aria-expanded`, which is the
shell's own bookkeeping and was right throughout the defect: what has
to be true is that one back press closes the queue and the *next* one
navigates.
2026-08-20 22:14:42 -04:00
logan a70a7ed9eb fix(queue): size the queue screen's way out for a thumb
Measured at 424x439: the three header actions were 25x21px. That
matters more than it looks, because with the panel spanning the whole
width the scrim underneath it has no uncovered pixels at all -- so the
close button is the only pointer route out of a full-screen surface,
and it was below the 24x24 floor in one dimension.

Sized only in overlay mode. Inline these sit in a 320px column beside
the content, where a mouse is what reaches them and 44px of header is
44px the queue does not get.
2026-08-20 22:14:41 -04:00
logan de2cb2693a feat(queue): give an overlaid queue a place in the back stack
The queue's pixels were already right. Measured at the reference
device's 424x439, #24's overlay is 424x318 -- `.main-panel`'s rect
exactly -- so the `DETAIL_LOADERS` mount the issue's Direction asks for
would draw the same rectangle in the same place. What was missing was
the navigation model: opening the queue on Artists and pressing back
moved the page *underneath* to Albums and left the queue up, which is a
press that changes something the user cannot see and costs them their
place.

So the queue is a *place* exactly while it is an overlay, and a
*control* while it is a column. A column is a thing the user docked --
back must not undock it and a navigation must not take it away -- and
that reuses #24's computed mode rather than adding a breakpoint, so the
drag-resizable panel width keeps deciding it.

It is in neither `VIEW_TAGS` nor `DETAIL_LOADERS`, because there is
nothing to mount and moving it would cost something. `.main-panel > *`
computes `contain: content` under a `.main-panel` that does too, and
paint containment clips the `position: fixed` a `wa-popup` falls back
to on Chrome 113 (#60) -- so the detail-view mount would have broken
`queue-panel`'s working context menu on the one device this is about.
The panel's ancestry today is paint-free to `body`.

Two details that fail silently otherwise. The entry is unwound from the
panel's `open` attribute in the observer that already ran for
`aria-expanded`, not at each of the four ways out -- without that the
entry is orphaned and the *next* back press is the one that closes the
queue, which is this defect moved one press later. And the navigation
writes neither `dataset.activeView` nor `searchStore.setCurrentView`,
because both describe what is *in* the main panel and the queue covers
that panel without replacing it.

`now-playing-view`'s copy of the button went through the helper too: it
set `open` directly, so on a phone it produced exactly the queue with no
entry behind it that this removes.

Closes #55
2026-08-20 22:14:27 -04:00
logan 880adff12c Merge pull request 'Drop the phone's top bar; search becomes a button and a modal' (#167) from feat/57-drop-the-android-top-bar into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 8m17s
2026-08-21 00:18:33 +00:00
logan d6f7412e9d docs(shell): the phone has no top bar, and why the modal is a dialog
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 8m19s
CLAUDE.md's shell prose said the phone's header "controls shrink or
stand down"; there is no header there now. The search box's section
gains the modal and the four rules behind it, page-header gains the
count as the last thing to yield, and the top-bar-fit section gains
what happens below its own band.

NOTES.md gets the three measured facts, dated: `contain: paint` is why
a Web Awesome popup is clipped on Chrome 113 and why no tier here can
reproduce it, the arithmetic that cost the page header its count at
320px, and the shared long-lived e2e app that makes an absolute
coordinate a hidden assertion about background jobs.
2026-08-20 20:03:28 -04:00
logan 1ab767a317 feat(shell): take the top bar out of the phone's layout
The row is deleted from the grid template below 600px, not the header
hidden. That is 3.25em of a 439 CSS px viewport -- the single biggest
vertical win the reference device has to give, and the reason the issue
asks for the row rather than for a smaller bar.

Each of the five things the bar held has somewhere else to be there:
nav-history is the platform's own back gesture and was already gone from
899 down, the job indicator is <job-band> (#62, which is what this was
blocked on), the search box is a modal opened from the view's own
header, the library filter is Settings -> Libraries, and the wordmark
stays where it is.

Three things are load-bearing.

**The header is visually hidden rather than display: none**, because
that h1 is the document's top-level heading and several pages have no
other one -- page-header renders no h1 when its heading is empty, and
Settings has no page-header at all. Its four controls are display: none
*inside* it, which is what keeps them out of the tab order: a
visually-hidden container is still focusable, and tabbing into a search
box nobody can see is worse than not having one.

**The fit pass stands down**, from the bar's computed position rather
than from a width. With the bar out of flow there is no content box to
measure children against, and a pass that ran would collapse the
wordmark on every resize and report success about a 1px box.

**top-bar-fit.spec.ts keeps 390 and asserts the stronger property.**
"Nothing hangs out of the bar" is trivially true of a bar with no row
and would pass on a build that merely broke it, so what that width asks
now is that the content starts where the row above it ends. Measuring
against the window instead would have been asserting "and no background
job is running", which that spec is not about and cannot arrange.

Closes #57
2026-08-20 20:03:20 -04:00