Android touch model, phases 2-4: swipe to queue, and the other three lists #201

Merged
logan merged 2 commits from 63-touch-model-phase-2 into main 2026-08-22 05:54:48 +00:00
Collaborator

Finishes plan 019 (#63): phases 2, 3 and 4 of the Android touch model.
Phase 1 (tap to play, hold to select) landed in #199.

commit issue what
4e66775 #63 (phase 2) swipe a track row right to queue it
29feb4b #63 (phases 3, 4) the same model on the queue and both playlist views; Escape leaves the mode

What the device said, which is most of why this took the shape it did

The plan predicted the class of failure for phase 2 — "works in
Chromium and not on the phone" — and named touch-action: pan-y as the
fix. It is half of it. Driving a real finger with
adb shell input swipe across a track row:

touch-action: auto    pointerdown, 1 move,  pointercancel
touch-action: pan-y   pointerdown, 2 moves, pointercancel
touch-action: none    pointerdown, 2 moves, pointercancel

touchmove kept firing in all three. Chrome 113's WebView cancels the
pointer stream ~16px into any drag whatever touch-action says, so
a swipe recognised from pointermove dies 16px in. The other half is a
non-passive touchmove calling preventDefault(), with which the same
swipe ran to 12 moves and a pointerup at full travel.

Both are required, which was measured rather than assumed: with the
preventDefault in place and touch-action back at auto, the gesture
died after one move. none is the value to avoid — it takes the
list's vertical scrolling with it. All of this is correct in Chromium
either way, which is why the module's header carries the measurement and
the component tier asserts the stylesheet.

draggable="true" turned out not to be a competitor: no dragstart
fires from a touch drag on this WebView at all.

And it found a phase 1 defect no tier can see

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:

hold 1  yj-long-press, then contextmenu isTrusted=true   menu over the bar
hold 2  yj-long-press                                    clean
hold 3  yj-long-press, then contextmenu isTrusted=true   menu over the bar
hold 4  yj-long-press                                    clean

Two in four, on the one surface #63 exists to have changed. Six holds
clean after the fix. Neither browser tier can see it — neither
synthesises a contextmenu from a dispatched press — so the regression
test stands in for the browser with an untrusted one, which the module
already tells from its own by identity.

Decisions worth reading

  • A swipe is not a selection. It queues the row it was made on,
    unless that row is one of several explicitly selected — the context
    menu's rule, because a bar reading "40 selected" beside a gesture that
    quietly queues one of them is two answers to one question. It never
    changes the selection.
  • A tap on a queue row plays that position, not the list: setting
    the queue to the queue reads as a no-op and discards its source, its
    shuffle order and anything inserted by hand.
  • The queue panel has no swipe. A right swipe means add to the
    queue
    everywhere it exists; the only thing it could mean on a queue
    row is remove, which is the same gesture with the opposite effect
    one screen away. Its rows do not carry data-swipe, and a test says
    so.
  • The affordance is utils/swipe-to-queue.ts, once. Three lists
    draw it; three copies of "how far is far enough" is three chances to
    disagree.
  • Escape leaves the mode, back does not. The shell owns the history
    stack and four lists reaching for history is four stacks — the fault
    navStack was deleted for. Filed as #200.

Verification

make lint, make test, make bindings-check, make skill-check,
make css-check, and tsc --noEmit in both frontend/ and e2e/:
all clean. The Go half is unchanged, so lint and test are
formalities here.

  • make ui-test1092 pass (99 files), up from 1071. New:
    touch-swipe.test.ts (8), touch-surfaces.test.ts (8), plus 9 in
    touch-gestures.test.ts and 3 in touch-selection.test.ts.
  • make e2e239 pass, whole suite, chromium. Two new tests in
    touch-gestures.spec.ts; they skip on WebKit with the reason stated,
    since desktop Safari has no TouchEvent constructor to build the
    gesture from.
  • make ui-visual — untouched; its two failures are #196 and
    pre-date this.
  • The device, a Light Phone III on Chrome 113: a track row queues on
    a swipe and does not on a short one; a vertical drag still scrolls the
    virtualizer 81px; the reveal tracks the finger exactly and flips its
    words and colour at the threshold; 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; six holds raise the bar with no menu behind it.

Every new rule was checked by reverting it: the vertical-veto latch,
the commit threshold, the selection rule, the data-swipe opt-in on
each side, the click swallow and the Escape dismissal each fail a named
test when removed. Two turned out to be vacuous first and are
called out in the plan — one python replacement that silently did not
match, and a tap helper that dispatched no click, so there was nothing
for the swallow to swallow.

Closes #63

Finishes plan 019 (#63): phases 2, 3 and 4 of the Android touch model. Phase 1 (tap to play, hold to select) landed in #199. | commit | issue | what | |---|---|---| | `4e66775` | #63 (phase 2) | swipe a track row right to queue it | | `29feb4b` | #63 (phases 3, 4) | the same model on the queue and both playlist views; Escape leaves the mode | ## What the device said, which is most of why this took the shape it did The plan predicted the *class* of failure for phase 2 — "works in Chromium and not on the phone" — and named `touch-action: pan-y` as the fix. It is **half** of it. Driving a real finger with `adb shell input swipe` across a track row: ``` touch-action: auto pointerdown, 1 move, pointercancel touch-action: pan-y pointerdown, 2 moves, pointercancel touch-action: none pointerdown, 2 moves, pointercancel ``` `touchmove` kept firing in all three. Chrome 113's WebView cancels the **pointer** stream ~16px into any drag whatever `touch-action` says, so a swipe recognised from `pointermove` dies 16px in. The other half is a non-passive `touchmove` calling `preventDefault()`, with which the same swipe ran to 12 moves and a `pointerup` at full travel. **Both are required**, which was measured rather than assumed: with the `preventDefault` in place and `touch-action` back at `auto`, the gesture died after **one** move. `none` is the value to avoid — it takes the list's vertical scrolling with it. All of this is correct in Chromium either way, which is why the module's header carries the measurement and the component tier asserts the stylesheet. `draggable="true"` turned out **not** to be a competitor: no `dragstart` fires from a touch drag on this WebView at all. ## And it found a phase 1 defect no tier can see 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: ``` hold 1 yj-long-press, then contextmenu isTrusted=true menu over the bar hold 2 yj-long-press clean hold 3 yj-long-press, then contextmenu isTrusted=true menu over the bar hold 4 yj-long-press clean ``` Two in four, on the one surface #63 exists to have changed. Six holds clean after the fix. Neither browser tier can see it — neither synthesises a `contextmenu` from a dispatched press — so the regression test stands in for the browser with an untrusted one, which the module already tells from its own by identity. ## Decisions worth reading - **A swipe is not a selection.** It queues the row it was made on, unless that row is one of several *explicitly* selected — the context menu's rule, because a bar reading "40 selected" beside a gesture that quietly queues one of them is two answers to one question. It never changes the selection. - **A tap on a queue row plays that position**, not the list: setting the queue to the queue reads as a no-op and discards its source, its shuffle order and anything inserted by hand. - **The queue panel has no swipe.** A right swipe means *add to the queue* everywhere it exists; the only thing it could mean on a queue row is *remove*, which is the same gesture with the opposite effect one screen away. Its rows do not carry `data-swipe`, and a test says so. - **The affordance is `utils/swipe-to-queue.ts`, once.** Three lists draw it; three copies of "how far is far enough" is three chances to disagree. - **Escape leaves the mode, back does not.** The shell owns the history stack and four lists reaching for `history` is four stacks — the fault `navStack` was deleted for. Filed as **#200**. ## Verification `make lint`, `make test`, `make bindings-check`, `make skill-check`, `make css-check`, and `tsc --noEmit` in both `frontend/` and `e2e/`: all clean. The Go half is unchanged, so `lint` and `test` are formalities here. - `make ui-test` — **1092 pass** (99 files), up from 1071. New: `touch-swipe.test.ts` (8), `touch-surfaces.test.ts` (8), plus 9 in `touch-gestures.test.ts` and 3 in `touch-selection.test.ts`. - `make e2e` — **239 pass**, whole suite, chromium. Two new tests in `touch-gestures.spec.ts`; they skip on WebKit with the reason stated, since desktop Safari has no `TouchEvent` constructor to build the gesture from. - `make ui-visual` — untouched; its two failures are **#196** and pre-date this. - **The device**, a Light Phone III on Chrome 113: a track row queues on a swipe and does not on a short one; a vertical drag still scrolls the virtualizer 81px; the reveal tracks the finger exactly and flips its words and colour at the threshold; 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; six holds raise the bar with no menu behind it. Every new rule was checked by reverting it: the vertical-veto latch, the commit threshold, the selection rule, the `data-swipe` opt-in on each side, the click swallow and the Escape dismissal each fail a named test when removed. Two turned out to be **vacuous** first and are called out in the plan — one `python` replacement that silently did not match, and a tap helper that dispatched no click, so there was nothing for the swallow to swallow. Closes #63
logan added 2 commits 2026-08-22 05:42:19 +00:00
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.
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
29feb4b94b
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
logan merged commit ee1d8b3179 into main 2026-08-22 05:54:48 +00:00
Sign in to join this conversation.