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
This commit is contained in:
2026-08-22 01:41:21 -04:00
parent 4e667759c4
commit 29feb4b94b
11 changed files with 1402 additions and 291 deletions
+50
View File
@@ -1506,6 +1506,56 @@ rest of the press (a scroll that curves is still a scroll), and a
gesture that is not *strictly* more horizontal than vertical is the
scroller's.
**And what a finger *means* on a row is the inversion of what a mouse
means, decided per event** (#63). A click selects and a double-click
plays; a tap **plays** and a hold enters **selection mode**, in which a
tap toggles. The predicate is `pointerType`, never a viewport width and
never a platform flag — #64's rule, and with #64's warning: keyed on a
width, an Android tablet over 600px gets desktop semantics on a
touchscreen, a touchscreen laptop cannot be described at all, and a
narrow desktop window gets phone semantics with a mouse.
There is deliberately **no double-tap**, which #63 asked for. The first
tap of one is indistinguishable from a single tap until the interval
expires, so tapping would have to wait `DOUBLE_CLICK_GRACE_MS` before
acting — 250ms on top of a measured ~100ms play, 3.5x the app's primary
interaction, to reach a menu a hold already reaches. So the menu and
the action bar are the same surface: `components/selection-bar/` is
presentational (a count and a list of actions, no store, no selection),
`SelectionController` carries the mode for all four selecting surfaces,
and #60's bottom sheet is the overflow behind "More" — so
`contextMenuStyles`, `MenuKeyboard` and `menu-surface` are reused
rather than reimplemented.
Three things about it are load-bearing. **A control inside a row keeps
its own tap**: the gesture is simply not claimed there, so the click
behind it falls through, which is what stops the 44px favourite target
(#56) becoming a 44px play target — the queue row's × is the second
instance. **A swipe right queues**, and its affordance is
`utils/swipe-to-queue.ts` once rather than in each of the three lists
that draw it: the row does not move, its *children* do (a row here is
`contain: strict` with `overflow: hidden`, so a pane held at the row's
original position while the row translates is at a negative offset
inside a clipping box and is not painted), the travel is written to the
row's own style rather than rendered, and the threshold is a fraction
of the row because the row is 424x52 on the reference device. **The
queue panel takes the tap and the hold and refuses the swipe**, because
a right swipe means *add to the queue* everywhere it exists and a queue
row is already in it — the only thing it could mean there is *remove*,
which is the same gesture with the opposite effect one screen away.
A tap there plays that *position*, too: setting the queue to the queue
reads as a no-op and discards its source, its shuffle order and
anything inserted by hand.
Escape leaves the mode, from `selection-bar` rather than from each
host, since that element exists only while the mode does — the same
exception the overlaid queue's Escape is, *a dismissal, not a
shortcut*. The platform's back gesture deliberately does **not** reach
it: the shell owns the history stack and four lists each reaching for
`history` is four stacks, which is the fault `navStack` was deleted
for. That wants one shell-owned register of dismissible surfaces, which
is #200.
**A control revealed by `:hover` is gated on the device having hover,
and which way round depends on whether it is the only route to its
action.** The gate itself is not optional: a touch long-press