diff --git a/.pi/skills/yellowjacket-dev/references/android-tier.md b/.pi/skills/yellowjacket-dev/references/android-tier.md index 0b5e99f..24588cc 100644 --- a/.pi/skills/yellowjacket-dev/references/android-tier.md +++ b/.pi/skills/yellowjacket-dev/references/android-tier.md @@ -286,3 +286,27 @@ Related, and it will bite once: the launcher activity is resolves the leading dot against the *applicationId* and fails with a class-not-found that reads like a broken build. Always the fully-qualified form. + +## What only a device can answer + +The emulator cannot run this app (three separate reasons, none of them +ours — see plan 016), so the phone in someone's pocket is a tier, and +asking for it is cheap. The first run of it, on 2026-08-17, confirmed +the whole of A4 and found two faults **no other tier can see**: + +- **The back gesture.** `MainActivity.onBackPressed` asks + `webView.canGoBack()`. Nothing in a desktop shell has a back gesture, + so no spec had ever called `page.goBack()` and the app had never + pushed a history entry — back quit from any depth. It is a history + entry per navigation now, which is also what made it assertable in the + browser tier (`e2e/specs/back-navigation.spec.ts`). +- **The safe area.** `targetSdk 35` forces edge-to-edge, so the + transport and the tab bar sat under the gesture bar. **A browser + viewport has no system bars**: `phone-shell.spec.ts` at 390x844 will + keep passing on a build the device is clipping 48dp off. Insets are + handled in `applyWindowInsets()`. + +So when asking for a device run, ask about what the platform *adds* — +system bars, the back gesture, focus and audio interruptions, +permission dialogs, the keyboard — not about what the app draws. The +drawing is what the other five tiers already cover. diff --git a/.planning/NOTES.md b/.planning/NOTES.md index bf85c00..03730d9 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -3034,3 +3034,107 @@ reason this was caught is that the assertion about the probe ran before the assertion about the export. A fixture built by string surgery on a formatted constant needs to be whitespace-independent; it filters the list now. + +## Long-press is one document listener, and the header row is a row (2026-08-17) + +Plan 016 B2 phase 3. A phone has no right-click, and every context menu +in this app opens from a `contextmenu` event — six components' worth, +bound three different ways (delegated on a virtualizer, per row, per +card). `frontend/src/utils/long-press.ts` is one document-capture +listener installed once from `index.ts`: a touch that holds still for +500 ms dispatches a synthetic `contextmenu` at the touch point, and +**every existing handler runs unchanged**. No component opted in, and +none can forget to. + +Four things it has to get right, and each is a way the obvious version +fails: + +- **The target is `composedPath()[0]`, not `elementFromPoint`**, which + stops at the outermost shadow host. Every menu here is bound inside + one, so a host-targeted event reaches a delegated listener and no + per-row one. +- **A browser that fires its own must win.** Chromium already dispatches + `contextmenu` on long-press; WebKit and the WebView vary. One arriving + during the press cancels ours; one arriving after ours is swallowed at + document capture. +- **Ours is told from theirs by identity** (a `WeakSet`), not by + `isTrusted`. `isTrusted` would work in the app and is untestable — no + test can dispatch a trusted event — so the suppression path would have + been the one thing with no coverage. +- **The click ending the gesture is swallowed**, keyed on the gesture + (cleared by the next `pointerdown`) rather than a time window, or a + quick tap on the menu that just opened is eaten too. + +**What cost the time was the assertion, not the code.** The e2e spec +pressed `[role="row"]` — which is the *column header*, and it is the +first one. The gesture fired correctly, the header correctly ignored it, +and the failure looked exactly like a menu that would not open. Found by +probing the running app (`playwright-cli eval`, dispatching the same +pointer events and logging what saw the `contextmenu`), which showed the +event reaching the row's own listener with no menu behind it — i.e. the +handler was refusing it, not missing it. `.track-row` is the selector. + +Verified by execution: 8 component tests (real browser, real shadow +boundary, real timings) and 2 e2e specs against the running app, twice +in a row. Not verified: any of it under a real finger on a real +WebView — the pointer events are dispatched, because neither Desktop +Chrome nor Desktop Safari has touch and there is no device tier. + +## The first device run: A4 works, and two things only a phone could say (2026-08-17) + +The published v1.5.0 APK, on a real phone, owner-reported. **This is the +first runtime evidence any of the Android work has ever had** — A4 +shipped entirely reasoned from source. + +**What holds.** Playback survives the screen locking. The MediaSession +notification appears in the status pane *with album art* — which +answers, in one observation, four of the open questions from plan 016: +the foreground service starts, POST_NOTIFICATIONS was granted and the +notification is visible, the session is picked up, and **cover art +decoded from a `MANAGE_EXTERNAL_STORAGE` path by a service is +readable**. The last was the one nobody could argue from documentation. + +**Two bugs, and neither is visible from any tier we have.** + +*Back did not navigate back.* The scaffold's +`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the +activity otherwise — and this app had never touched `history`, so that +was false at every depth and back quit from anywhere. The fix is in the +frontend, not in Java: a navigation is a `history` entry now +(`recordNavigation` in `index.ts`, same URL, the destination in the +entry's state) and `popstate` replays it with `_isBack`. The Java half +needs no change, because the mechanism it already uses is the one we +were failing to feed. + +Two rules keep it honest. The **first** navigation replaces the launch +entry rather than pushing one, or every launch costs a back press before +the app will close. And the in-app back buttons go through +`history.back()` rather than popping a stack of their own — `navStack` +is **deleted**, not kept alongside, because two stacks is exactly how +the detail view's own button and the phone's gesture come to disagree +about how far back one press goes. `back-navigation.spec.ts` pins that +invariant. + +*The transport was off screen.* **`targetSdk 35` is Android 15, which +lays every app out edge-to-edge**, ignores the deprecated +`statusBarColor`/`navigationBarColor` the theme still sets, and hands +the app a window the size of the screen. The WebView is `match_parent`, +so the page's bottom band — the transport, and on a phone the tab bar — +was drawn underneath the gesture bar. `applyWindowInsets()` pads the +container by `systemBars | displayCutout | ime` and returns the insets +rather than consuming them. The window background goes black to match +the app's own ramp, or the padding shows as a blue-grey band. + +**Neither is findable in the browser tier, and that is the lesson worth +keeping**: a viewport has no system bars, so `phone-shell.spec.ts` at +390x844 renders a shell that fits perfectly while the device cuts 48dp +off the bottom — and `page.goBack()` was never called because nothing in +a desktop shell has a back gesture. The Android tier's own note says +failure there is invisible; this is the milder version, where the app +works and is simply wrong in ways only the platform can show you. + +Verified by execution: the APK builds with the Java change; 3 e2e specs +cover the history behaviour, on Chromium locally and WebKit in CI. +Not verified: the insets themselves, which need the next APK on the +owner's phone. What to look for is one thing — the transport and the tab +bar clear of the gesture bar, and the header clear of the status bar. diff --git a/.planning/plans/pending/016-android-feature-parity.md b/.planning/plans/pending/016-android-feature-parity.md index 59f1733..3a6ad2f 100644 --- a/.planning/plans/pending/016-android-feature-parity.md +++ b/.planning/plans/pending/016-android-feature-parity.md @@ -315,8 +315,8 @@ places had to agree — `abiFilters`, the Makefile's `android:package` anchor is what stops it also matching the fat APK's line. Adding the ABI back, if modernc ever fixes `Xlstat64`, is those same three edits. -**B2, the desktop shell.** Scope decided (below); **phases 1 and 2 are -done.** +**B2, the desktop shell.** Scope decided (below); **phases 1, 2 and 3 +are done.** - *Phase 1, the shell.* Below 600px the sidebar column is gone, `` is the primary navigation, and the shell fits 320px @@ -326,15 +326,47 @@ done.** composes the real transport components rather than copying them; and it hides the bottom bar while it is up, so it carries its own queue button. +- *Phase 3, long-press.* `utils/long-press.ts`: one document-capture + listener, installed once from `index.ts`, which turns a 500 ms + stationary touch into a synthetic `contextmenu` at the touch point. + Every menu in the app opens from that event, so all six components + gained the gesture without one of them changing — which is the same + argument `ContextMenuController` rests on, one layer lower. The + details that are not obvious are in `NOTES.md` (2026-08-17); the one + worth repeating is that ours is told from the browser's own + long-press event by **identity**, not `isTrusted`, because a test + cannot dispatch a trusted event and that path would otherwise be the + only uncovered one. -What is left is the rest of the *interactions*: long-press for the -context menus that are right-click today, and the track list's -resizable columns, which are a pointer feature with no touch -equivalent. Neither is started. +What is left of B2 is the track list, whose resizable columns are a +pointer feature with no touch equivalent. Not started. **B3/B4** are unchanged, and B3 is now *possible* where it was not: with all-files access, `tagwriter` can write in place. +### What the first device run answered (2026-08-17) + +A4 **works**: playback survives the screen locking, and the transport +notification appears with cover art — which also settles the service's +access to a `MANAGE_EXTERNAL_STORAGE` path, the permission grant and +the lock-screen session in one observation. Everything below in "what +none of section A answered" was written before this and is now answered +except the OEM permission-flow variance. + +It also found two faults no browser tier can see, both fixed and both +awaiting the next APK for confirmation (`NOTES.md`, same date): + +- **Back quit the app from any depth.** The scaffold asks + `webView.canGoBack()`; the frontend had never used `history`. A + navigation is a history entry now, and `navStack` is gone rather than + kept beside it. +- **The transport was under the gesture bar.** `targetSdk 35` is + edge-to-edge by force; `applyWindowInsets()` in `MainActivity` pads + by `systemBars | displayCutout | ime`. + +The standing item is unchanged in kind: **B3 (tag writing) and the +permission flow still need a device**, and so does confirming these two. + ### What none of section A answered Nothing here has been observed on a device. The permission flow in diff --git a/CLAUDE.md b/CLAUDE.md index 1d2455a..0702d34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -683,6 +683,27 @@ moment it is most needed is the likeliest moment loading one fails. `first-run-wizard` and the startup chrome are eager for the ordinary reason — they are the first paint. +**A navigation is a history entry, and that is the whole back stack.** +`index.ts` records each navigation with `pushState` (same URL — the app +has no routes, and a path a reload cannot resolve is worse than none) +and replays `popstate` with `_isBack`. It exists for Android, whose back +button is not a key the page can bind: the scaffold's +`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the +activity otherwise, so an app that never touched `history` quit from any +depth — which is what a device reported. Hooking the platform's own +mechanism rather than adding a JNI callback is also what makes it +testable in a browser (`page.goBack()`), and the Java half needed no +change at all. + +Two rules hold it up. The **first** navigation *replaces* the launch +entry rather than pushing one, or every launch costs a back press before +the app will close. And the in-app back buttons (`navigate-back`, fired +by the detail views and `now-playing-view`) go through `history.back()` +rather than a stack of their own: the old `navStack` is **deleted**, not +kept beside it, because two stacks is precisely how a view's own back +button and the phone's gesture come to disagree about what one press +means. + **A primary view is cached, not unmounted.** `index.ts` keeps every primary view in the DOM and toggles a `.view-hidden` class, because that is what preserves `scrollTop` across navigation — so @@ -838,6 +859,20 @@ against the real components: moving focus without setting it leaves the highlight on whichever item the mouse last touched. +**And a menu opens from a finger, through the event it already has.** +`utils/long-press.ts` is one document-capture listener installed once +from `index.ts`: a touch that holds still for 500 ms dispatches a +synthetic `contextmenu` at the touch point, so all six components that +bind one — delegated on a virtualizer, per row, per card — gained the +gesture without changing. The target is `composedPath()[0]` rather than +`elementFromPoint`, which stops at the outermost shadow host and so +reaches a delegated listener and no per-row one; a browser that fires +its own long-press `contextmenu` (Chromium does, WebKit and the WebView +vary) wins, ours being told from theirs by **identity** rather than +`isTrusted`, since no test can dispatch a trusted event; and the click +that ends the gesture is swallowed, keyed on the gesture rather than on +a time window so the first tap on the menu it opened is not eaten too. + Three lists had no focused row to open a menu *from* — the queue panel and both playlist detail views — and gained a roving tab stop through `utils/roving-rows.ts`. **`track-list` deliberately does not use it**: @@ -1960,6 +1995,18 @@ like source** — it was generated once into a scratch directory and copied across (plan 015), it carries one deliberate edit to its `Taskfile.yml`, and only its output is gitignored. `build/ios/` is still not carried and its `includes:` entry is still dropped. +**Its `MainActivity` owns the safe area, because `targetSdk 35` does +not leave that to the theme.** Android 15 lays every app out +edge-to-edge and ignores the `statusBarColor`/`navigationBarColor` the +scaffold's theme sets, and the WebView is `match_parent` — so the page's +bottom band, which on a phone is the transport *and* the tab bar, was +drawn under the gesture bar. `applyWindowInsets()` pads the container by +`systemBars | displayCutout | ime` and returns the insets rather than +consuming them; the window background is black to match the app's own +ramp, since that padding is what shows through. **No browser tier can +see this class of fault** — a viewport has no system bars, so the phone +specs render a shell that fits at the moment the device is clipping it. + `build/config.yml`'s `version` is the *metadata* version and is not what the app reports — `main.version` is stamped at link time from the packaging recipe's git-derived version.