Phase 1 of #63. The Direction asks for the interaction model to be
designed as one piece before it is built — because it reassigns an
existing gesture rather than adding one — so that is .planning/plans/active/019-android-touch-model.md, and this PR is its
first phase.
commit
issue
6cf19b9 feat(android): tap to play, hold to select
Two divergences from the report, both argued rather than assumed
There is no double-tap, and the number is why. The report asks for
single tap → play and double tap → 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 — play command to TrackChanged:
155, 123, 85, 56, 91 ms median ~100
The app's own DOUBLE_CLICK_GRACE_MS is 250. So the discriminator is 3.5× the primary interaction, 250ms of it spent deliberately doing
nothing, on every track anyone plays, to reach a menu the hold already
reaches. Instead the menu and the selection action bar are the same
surface — which is Android's own convention and removes a concept
rather than adding one.
The predicate is the pointer, not the platform.pointerType === 'touch', per event. This is #64's rule (name it after the capability)
and it carries #64's warning: keyed on a width, an Android tablet at
600px gets click-selects on a touchscreen, and a touchscreen laptop
cannot be described at all. Per event, a mouse keeps desktop semantics
on the very same row and nothing needs a "platform-aware interaction
layer" as a concept.
What keeps the other thirteen menus safe
The layer announcesyj-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.
An unclaimed tap does nothing at all and the click follows
normally, which leaves every button in the app alone. Only a claimed
tap has its click swallowed, or playing a track would also select it.
The device found the 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 — 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 on the device, holding a track row, before the fix:
yj-long-press was never announced, the menu opened — and all 26
component tests passed, because 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 — the same "browser wins" outcome, reached by
asking instead of assuming.
The tier could not find it and can hold it: a test cannot dispatch
a trusted event, but this module has always told its own apart by
identity rather than isTrusted, so an untrusted one from a test takes
exactly the browser's path. Both new guards fail on the reverted rule.
A tier note
adb shell input tap|swipe reaches the WebView as real pointer events,
so for the first time the Android tier can perform the thing under
test rather than describe the page afterwards. The pixel mapping is in
the plan. This does not make it a spec tier — it does not run in CI —
but "does this gesture fire on Chrome 113" is now a question with a
ten-second answer.
Verification
Device, performing the gestures: tap plays; a hold raises the bar
with 1 selected and no menu; taps toggle to 2, back to 1, and the
mode ends with the last row; an album card still opens its context
menu. Screenshot read for the bar's placement above the mini player.
make ui-test — 1063 pass, 29 new. The 4 behaviour tests and the
2 native-contextmenu guards each fail on the un-wired build.
make e2e — 237 pass. long-press.spec.ts is rewritten as touch-gestures.spec.ts asserting both halves: the row selects, and a card elsewhere still opens the real menu. Without the second
half a build that broke the other thirteen menus would pass the
suite.
make lint, make test, make css-check, make skill-check, tsc --noEmit in both packages — clean, all formalities (no Go changed).
make ui-visual — the two pre-existing failures from #196, unchanged.
Not in this PR
Phases 2–4, in the plan: swipe-right-to-queue (with the measured touch-action: pan-y finding — the rows are auto today, which is how
a swipe comes to work in Chromium and not on the phone), the other
three selection surfaces, and what #67 inherits from the inline links
inside a row whose tap now plays.
Two open questions are written down there rather than guessed: whether
selection mode should push a history entry (back is the platform's
escape, and #55 settled the shape for the overlaid queue — but a mode
is not a place), and it is already answered for the favourite icon,
which keeps its own tap by the same rule the shortcut service uses for
a focused control that owns a key.
Phase 1 of #63. The Direction asks for the interaction model to be
designed as one piece before it is built — because it *reassigns* an
existing gesture rather than adding one — so that is
`.planning/plans/active/019-android-touch-model.md`, and this PR is its
first phase.
| commit | issue |
|---|---|
| `6cf19b9` feat(android): tap to play, hold to select | #63 |
## The mapping
| gesture | finger | mouse |
|---|---|---|
| single tap / click | **play the row** | select the row |
| double | — | play the row |
| long press | **selection mode** | — |
| right-click | — | context menu |
## Two divergences from the report, both argued rather than assumed
**There is no double-tap, and the number is why.** The report asks for
single tap → play *and* double tap → 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 — play command to `TrackChanged`:
```
155, 123, 85, 56, 91 ms median ~100
```
The app's own `DOUBLE_CLICK_GRACE_MS` is 250. So the discriminator is
**3.5× the primary interaction**, 250ms of it spent deliberately doing
nothing, on every track anyone plays, to reach a menu the hold already
reaches. Instead the menu and the selection action bar are the same
surface — which is Android's own convention and removes a concept
rather than adding one.
**The predicate is the pointer, not the platform.** `pointerType ===
'touch'`, per event. This is #64's rule (name it after the capability)
and it carries #64's warning: keyed on a width, an Android *tablet* at
600px gets click-selects on a touchscreen, and a touchscreen laptop
cannot be described at all. Per event, a mouse keeps desktop semantics
on the very same row and nothing needs a "platform-aware interaction
layer" as a concept.
## What keeps the other thirteen menus safe
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.
- An **unclaimed tap does nothing at all** and the click follows
normally, which leaves every button in the app alone. Only a claimed
tap has its click swallowed, or playing a track would also select it.
## The device found the 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 — 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 on the device, holding a track row, before the fix:
```
{"log":["contextmenu isTrusted=true"],
"state":{"bar":null,"menuActive":true,"selected":1}}
```
`yj-long-press` was never announced, the menu opened — and **all 26
component tests passed**, because 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 — the same "browser wins" outcome, reached by
asking instead of assuming.
The tier could not *find* it and can *hold* it: a test cannot dispatch
a trusted event, but this module has always told its own apart by
identity rather than `isTrusted`, so an untrusted one from a test takes
exactly the browser's path. Both new guards fail on the reverted rule.
## A tier note
`adb shell input tap|swipe` reaches the WebView as real pointer events,
so for the first time the Android tier can **perform** the thing under
test rather than describe the page afterwards. The pixel mapping is in
the plan. This does not make it a spec tier — it does not run in CI —
but "does this gesture fire on Chrome 113" is now a question with a
ten-second answer.
## Verification
- **Device**, performing the gestures: tap plays; a hold raises the bar
with 1 selected and no menu; taps toggle to 2, back to 1, and the
mode ends with the last row; an album card still opens its context
menu. Screenshot read for the bar's placement above the mini player.
- **`make ui-test`** — 1063 pass, 29 new. The 4 behaviour tests and the
2 native-`contextmenu` guards each fail on the un-wired build.
- **`make e2e`** — 237 pass. `long-press.spec.ts` is rewritten as
`touch-gestures.spec.ts` asserting **both** halves: the row selects,
*and* a card elsewhere still opens the real menu. Without the second
half a build that broke the other thirteen menus would pass the
suite.
- `make lint`, `make test`, `make css-check`, `make skill-check`, `tsc
--noEmit` in both packages — clean, all formalities (no Go changed).
- `make ui-visual` — the two pre-existing failures from #196, unchanged.
## Not in this PR
Phases 2–4, in the plan: swipe-right-to-queue (with the measured
`touch-action: pan-y` finding — the rows are `auto` today, which is how
a swipe comes to work in Chromium and not on the phone), the other
three selection surfaces, and what #67 inherits from the inline links
inside a row whose tap now plays.
Two open questions are written down there rather than guessed: whether
selection mode should push a history entry (back is the platform's
escape, and #55 settled the shape for the overlaid queue — but a mode
is not a place), and it is already answered for the favourite icon,
which keeps its own tap by the same rule the shortcut service uses for
a focused control that owns a key.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Phase 1 of #63. The Direction asks for the interaction model to be
designed as one piece before it is built — because it reassigns an
existing gesture rather than adding one — so that is
.planning/plans/active/019-android-touch-model.md, and this PR is itsfirst phase.
6cf19b9feat(android): tap to play, hold to selectThe mapping
Two divergences from the report, both argued rather than assumed
There is no double-tap, and the number is why. The report asks for
single tap → play and double tap → 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 — play command to
TrackChanged:The app's own
DOUBLE_CLICK_GRACE_MSis 250. So the discriminator is3.5× the primary interaction, 250ms of it spent deliberately doing
nothing, on every track anyone plays, to reach a menu the hold already
reaches. Instead the menu and the selection action bar are the same
surface — which is Android's own convention and removes a concept
rather than adding one.
The predicate is the pointer, not the platform.
pointerType === 'touch', per event. This is #64's rule (name it after the capability)and it carries #64's warning: keyed on a width, an Android tablet at
600px gets click-selects on a touchscreen, and a touchscreen laptop
cannot be described at all. Per event, a mouse keeps desktop semantics
on the very same row and nothing needs a "platform-aware interaction
layer" as a concept.
What keeps the other thirteen menus safe
The layer announces
yj-tap/yj-long-press(composed,cancelable) and acts on nothing. A component claims one with
preventDefault.contextmenu— so thecard grids, Explore, the playlist rows and every other menu behave
exactly as they did.
normally, which leaves every button in the app alone. Only a claimed
tap has its click swallowed, or playing a track would also select it.
The device found the thing no browser tier can see
Chrome 113's Android WebView fires its own
contextmenuon a longpress.
long-press.tsstood down when a trusted one arrived — rightwhile both paths ended in a context menu. They no longer do, so
standing down means the gesture silently does the old thing.
Measured on the device, holding a track row, before the fix:
yj-long-presswas never announced, the menu opened — and all 26component tests passed, because 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 — the same "browser wins" outcome, reached by
asking instead of assuming.
The tier could not find it and can hold it: a test cannot dispatch
a trusted event, but this module has always told its own apart by
identity rather than
isTrusted, so an untrusted one from a test takesexactly the browser's path. Both new guards fail on the reverted rule.
A tier note
adb shell input tap|swipereaches the WebView as real pointer events,so for the first time the Android tier can perform the thing under
test rather than describe the page afterwards. The pixel mapping is in
the plan. This does not make it a spec tier — it does not run in CI —
but "does this gesture fire on Chrome 113" is now a question with a
ten-second answer.
Verification
with 1 selected and no menu; taps toggle to 2, back to 1, and the
mode ends with the last row; an album card still opens its context
menu. Screenshot read for the bar's placement above the mini player.
make ui-test— 1063 pass, 29 new. The 4 behaviour tests and the2 native-
contextmenuguards each fail on the un-wired build.make e2e— 237 pass.long-press.spec.tsis rewritten astouch-gestures.spec.tsasserting both halves: the row selects,and a card elsewhere still opens the real menu. Without the second
half a build that broke the other thirteen menus would pass the
suite.
make lint,make test,make css-check,make skill-check,tsc --noEmitin both packages — clean, all formalities (no Go changed).make ui-visual— the two pre-existing failures from #196, unchanged.Not in this PR
Phases 2–4, in the plan: swipe-right-to-queue (with the measured
touch-action: pan-yfinding — the rows areautotoday, which is howa swipe comes to work in Chromium and not on the phone), the other
three selection surfaces, and what #67 inherits from the inline links
inside a row whose tap now plays.
Two open questions are written down there rather than guessed: whether
selection mode should push a history entry (back is the platform's
escape, and #55 settled the shape for the overlaid queue — but a mode
is not a place), and it is already answered for the favourite icon,
which keeps its own tap by the same rule the shortcut service uses for
a focused control that owns a key.
6cf19b92e1to76e1c444cc