Compare commits

...
Author SHA1 Message Date
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 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
61 changed files with 6625 additions and 909 deletions
+8 -1
View File
@@ -158,7 +158,7 @@ only climb when it cannot.
| You changed | Run | Cost | | You changed | Run | Cost |
|---|---|---| |---|---|---|
| A Lit component, a store, the shortcut service | `make ui-test` | ~2 s, no app | | A Lit component, a store, the shortcut service | `make ui-test` | ~2 s, no app |
| …and it renders differently | `make ui-visual` | + 6 baselines, opt-in | | …and it renders differently | `make ui-visual` | + 10 baselines, opt-in, never gates |
| Any Go code | `make test` | 3 passes, ~2 min | | Any Go code | `make test` | 3 passes, ~2 min |
| A service that emits events | `make test` — assert on the payload, see `backend/queue/emit_test.go` | in-process, no app | | A service that emits events | `make test` — assert on the payload, see `backend/queue/emit_test.go` | in-process, no app |
| A bound method or a bound struct field | `make bindings` then `make ui-test` | ~1.5 s + 2 s | | A bound method or a bound struct field | `make bindings` then `make ui-test` | ~1.5 s + 2 s |
@@ -180,6 +180,13 @@ less than it looks.)
Two rules about climbing: Two rules about climbing:
- **If you moved a component's geometry, run `make ui-visual` and
refresh that component's baseline in the same commit.** Nothing else
will: it is the one tier in this repo no hook and no CI job runs, and
it cannot be one — its references are machine-specific, measured in
[references/ui-tier.md](references/ui-tier.md). Four of them drifted
across three merges before anyone noticed (#196). Read the image;
never bless a reference you did not cause.
- **A component test passing is not the app rendering.** If you touched - **A component test passing is not the app rendering.** If you touched
anything in `frontend/src`, verify it in the real app too — start it anything in `frontend/src`, verify it in the real app too — start it
headless, `screenshot --filename=/tmp/shot.png`, and *read the PNG*. headless, `screenshot --filename=/tmp/shot.png`, and *read the PNG*.
@@ -78,9 +78,58 @@ synchronously.
Microtasks and not a timer, deliberately: a timer hangs forever under Microtasks and not a timer, deliberately: a timer hangs forever under
the suites that install fake ones. the suites that install fake ones.
Visual baselines are font-hinting and compositing sensitive, which is ## The visual tier does not gate, and that is measured (#196)
why they are opt-in: they only mean anything on the machine that
recorded them. `make ui-visual` is the same suite with nine `toMatchScreenshot`
baselines switched on. **Nothing runs it but a person**, deliberately,
and the reason is a number rather than a preference: the committed
baselines were recorded on Arch, and replayed in a bare `ubuntu:24.04`
container — CI's `check` image — three of them fail for reasons that
have nothing to do with any component.
| baseline | Arch | ubuntu:24.04 |
|---|---|---|
| `page-header` filtered-by-search | passes | ratio 0.03 differ, against a 0.02 allowance |
| `track-info` | passes | ratio 0.03 differ |
| `seek-bar` | 1152×18 | 1152×17 |
The two references that were genuinely stale did not even agree about
their *new* size — `now-playing` renders 1152×65 on Arch and 1152×64 in
the container. So moving CI's `check` job from `make ui-test` to
`make ui-visual` is not a one-line change: it needs a second,
container-recorded baseline set, which every local run would then fail
against. That is the same trap the other way round, and a pre-push hook
is the same fault again — one machine's baselines against everybody
else's renderer.
So the tier stays local and opt-in, and the rule that replaces the gate
is:
- **A change that moves a component's geometry refreshes that
component's reference in the same commit, having read the image.**
Look at the PNG; the dimensions in the failure message are the cheap
half of the answer.
- **Never refresh a reference you did not cause.** #196 exists because
four of them drifted across three unrelated merges, and every red run
made the next person likelier to stop running the tier than to read
it.
- **State the world the shot is taken in.** The stores are singletons,
so a visual case that sets nothing photographs whatever the previous
case left behind — which is how the sidebar's baseline came to have
Tracks lit and `now-playing`'s to be playing from a dynamic mix.
- **Record one file with `make ui-visual-update UI_ARGS=<path>`**, and
check `git status` before committing either way. That filter is only
honoured since #204: the recipe was a bare `--update`, and vitest
takes the following positional as the flag's value, so the path was
swallowed and *every* baseline was re-recorded — blessing any stale
one in silence.
What the tier is worth, for the record: it is a *layout* check, blind to
colour (the component tier has no `:root`, so it renders the fallbacks —
`make ui-visual` passed unchanged through a whole palette rewrite,
twice), and it has caught one thing nothing else could — swapping
`library-status-indicator`'s `<button>` for a `<span>` lost the UA
stylesheet's `box-sizing` and grew the badge 36→38px.
## Bindings ## Bindings
@@ -0,0 +1,408 @@
# 019 — The Android touch model
**Issue:** #63 (`Area/Library-UI`, `Kind/Feature`, `Priority/High`)
**Depends on:** #60 (bottom-sheet menus) — closed, merged as PR #176
**Relates:** #67 (inline links into the menu), #71 ("More" nav), #54
(native feel), #5/#8 (selection, drag to queue — the desktop semantics
being diverged from)
**Status:** shipped (phases 1-4). Its one deliberate remainder is #200.
#73 puts #60 first in Phase 4 because it is "the presentation every
other item needs", and this is the next one. The Direction on #63 asks
for the interaction model to be designed as one piece before any of it
is built, because it *reassigns an existing gesture* rather than adding
one — `utils/long-press.ts` currently owns the 500ms hold, and every
context menu in the app is downstream of it.
This document is that design. Everything below is a measurement, or an
argument for one of the choices #63 leaves open.
---
## The mapping
| gesture | pointer is a finger | pointer is a mouse |
|---|---|---|
| single tap / click | **play the row** | select the row |
| double | — | play the row |
| long press (500ms) | **enter selection mode** | — |
| right-click | — | context menu |
| swipe right | **add to queue** | — |
| drag | reorder / drag to playlist | reorder / drag to playlist |
Three of those are #63's report unchanged. Two are decisions it left
open, and one is a deliberate divergence.
---
## Decision 1 — the predicate is the pointer, not the platform
#63 says "the row component needs a platform-aware interaction layer
rather than shared handlers". It needs an interaction layer; it should
not be platform-aware.
**The question a row has to answer is not "am I on Android" or "is the
viewport under 600px" but "what made this event".** `pointerType ===
'touch'`, read off the event that is being handled, which is already
how `long-press.ts` decides (`if (e.pointerType !== 'touch') return`)
and is the only such test in the frontend today.
This is #64's rule — the predicate is named after the capability, not
the platform — and it carries #64's warning with it. Keyed on a width:
- an Android **tablet** at 600px or more gets click-selects /
double-click-plays on a touchscreen, which is the exact inversion
this issue exists to fix, on the platform it exists for;
- a **touchscreen laptop** cannot be described at all, because both
pointers are live in the same session on the same row;
- and a narrow desktop window gets phone semantics with a mouse.
Per event, all three are right for free, and there is no second
declaration of what a phone does — the thing CLAUDE.md declines to add
every time it comes up.
**Measured, so this is not an assumption about the WebView.** On the
reference device (TLP301, Android 14, WebView Chrome 113, 424x439),
driving a real tap with `adb shell input tap`:
```
[["down","touch",78,94],["touchstart","touchstart",0,0],["up","touch",78,94]]
```
`PointerEvent` exists, `pointerType` is `"touch"`, `maxTouchPoints` is
5, and `(pointer: coarse)` / `(hover: none)` both match.
---
## Decision 2 — there is no double-tap, and the number is why
#63 asks for *single tap → play* **and** *double tap → context menu*.
Those two cannot both be honoured. The first tap of a double tap is
indistinguishable from a single tap until the interval expires, so
"tap plays" necessarily becomes "tap waits to find out whether you
meant something else, then plays". The app already owns that constant:
`utils/explore-link.ts` holds a navigation for `DOUBLE_CLICK_GRACE_MS
= 250` for precisely this reason.
**What it would be added to, measured on the device.** Six runs, from
the play command to the backend's `TrackChanged`:
```
155, 123, 85, 56, 91 ms median ~100
```
So the app's primary interaction is ~100ms, and a double-tap
discriminator makes it ~350 — **3.5x, of which 250ms is spent
deliberately doing nothing** — paid on every track anyone ever plays,
in order to reach a menu.
It is also against the platform's convention, which counts for more
than usual here because this is the phone build and nothing else:
long-press is *how you select* on Android (Gmail, Files, Photos),
double-tap is zoom or nothing, and a list's menu is either the
long-press sheet or a per-row overflow.
**So the menu and the selection action bar become the same surface**,
which is the convention and removes a concept rather than adding one.
Long-press selects the row it was made on and raises the action bar;
the bar's actions *are* the context menu's actions, contextualised to
whatever is selected — one row or forty. #60's bottom sheet stays
behind it as the overflow, so `contextMenuStyles`, `MenuKeyboard` and
`menu-surface` are reused rather than reimplemented.
---
## Decision 3 — tap-to-play and selection mode ship together
The obvious phase order is "tap plays first, it is the smallest
change". It is wrong, and the reason is a capability that exists today
and is easy to miss.
**A touch user can already multi-select**: tap selects (the desktop
semantics, which a finger currently gets), and the long-press menu then
acts on the selection. Move tap to play without shipping selection mode
in the same change and there is a window — a release, if it lands — in
which selecting forty tracks to add to a playlist is impossible on a
phone. That is a regression dressed as an increment.
So phase 1 is both, or neither.
---
## What the code looks like now
| surface | how it binds | selection |
|---|---|---|
| `track-list` | delegated on the virtualizer: `click`, `dblclick`, `contextmenu`, `dragstart` | `SelectionController` |
| `queue-panel` | delegated, same shape | `SelectionController` |
| `playlist-details` | per row | `SelectionController` |
| `smart-playlist-details` | per row | `SelectionController` |
All four already share `SelectionController`, and all four resolve a
row from an event by `data-index` / `data-file-path` on the row. So the
gesture layer has one shape to talk to, and "selection mode" is a flag
on the controller they already have rather than a fifth concept.
`utils/long-press.ts` is one document-capture listener that synthesises
a `contextmenu` — the seam that needed no component to opt in. **This
plan keeps that shape and changes what the gesture means**, which is
why it is a rewrite of that file rather than a second listener set: two
document listeners both claiming the 500ms hold is the fault the file's
own header warns about.
---
## Two measurements that decide the implementation
**`touch-action` is `auto` on both the virtualizer and the rows.** With
`auto` the browser owns panning on both axes, so a horizontal drag can
be claimed as a scroll and our gesture ends in `pointercancel`
mid-swipe. A row that wants a horizontal swipe has to declare
`touch-action: pan-y`: the browser keeps the vertical pan (which is the
virtualizer's scroll, and must stay native or the list stutters) and
hands us the horizontal axis. This is the single most likely way for
swipe-to-queue to "work in Chromium and not on the phone".
**The row is 424x52 on the device**, so a swipe threshold in px is a
fraction of a row height, not of a screen.
**And the third one was found by building phase 1 and then running it**
— it is not something any browser tier can report. 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 the same place; once a hold can mean selection mode they end
in different places, and standing down means the gesture silently does
the *old* thing. Measured, before the fix:
```
{"log":["contextmenu isTrusted=true"],
"state":{"bar":null,"menuActive":true,"selected":1}}
```
`yj-long-press` was never announced at all, the context menu opened,
and all 26 tests in the component tier passed — dispatched pointer
events do not make a browser synthesise a `contextmenu`.
So the browser's event is a **trigger, not a competitor**: the gesture
is announced from it, and only a component that claims it suppresses
the native menu. Unclaimed, it propagates untouched. That is the same
"browser wins" outcome, reached by asking instead of assuming — and
verified both ways on the device, a track row entering selection mode
and an album card still opening its menu.
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.
---
## A tier note: this one can be driven, not only measured
`adb shell input tap|swipe` reaches the WebView as real pointer events,
which the log above is evidence of. So for the first time the Android
tier can *perform* the thing under test rather than describe the page
afterwards — a long press is `input swipe X Y X Y 600`, a swipe right
is `input swipe X Y X+N Y 120`.
Device CSS pixels from device pixels, on this phone:
`css = (device - 59) / 2.564` vertically, `css = device / 2.564`
horizontally (measured from the tap above: 200,300 arrived as 78,94).
This does not make the device a spec tier — it does not run in CI and
`make ui-test` still has to carry the assertions. It makes "does the
gesture actually fire on Chrome 113" answerable in seconds.
---
## Phases
**Phase 1 — the seam, tap-to-play, selection mode.** `utils/
touch-gestures.ts` replacing `long-press.ts`: pointer-typed
recognition of tap / long-press / horizontal swipe, dispatched as
composed custom events so a delegated listener in any shadow root
still works. `SelectionController` gains a mode. `track-list` acts on
tap and enters the mode on long press. The action bar.
**Phase 2 — swipe right to queue**, with the `touch-action: pan-y`
finding above and a reveal-and-snap affordance. **Shipped**; what the
device said about it is the section below.
**Phase 3 — the other three surfaces**, which is mostly wiring, since
they already share the controller. **Shipped**, and it was not entirely
wiring — see below.
**Phase 4 — what this leaves behind.** The inline `explore-link`s in a
row are a single-click target inside a row whose single tap now plays;
that conflict is #67's, and this plan should not pre-empt its answer
beyond making tap-to-play win on touch. **Shipped.**
## Phase 3 was not symmetric, in two places
**A tap on a queue row plays that position**, not the list. 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 everything a user had inserted by hand. It reads as a
no-op and is not one.
**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 —
the fault `utils/icon-language.ts` exists to have fixed for glyphs.
Removing a queue row is on the row itself (the ×), on its bottom sheet
since #60, and on the selection bar this phase gave it. The assertion
is that its rows do **not** carry `data-swipe`, so a swipe there cannot
silently become a second meaning for the app's one horizontal gesture.
And the affordance became `utils/swipe-to-queue.ts` rather than being
copied twice. Three lists want it; three copies of "how far is far
enough" is three chances for them to disagree, which is what
`utils/library-status.ts` and `utils/ownership.ts` each exist to have
stopped happening. The shared stylesheet is keyed on `[data-swipe]`
rather than on a class name, because the three lists call their rows
two different things and the `touch-action` half of the device fix has
to reach all of them.
## Phase 4 was already true, which is why it is asserted
A claimed tap has its click swallowed at document capture, so an
`explore-link` inside the row never sees one and tap-to-play wins with
no rule of its own. Nothing in the suite would have failed if that
stopped covering the link, and the symptom — tapping a track's *title*
navigating to its album instead of playing it — is one a phone user
meets constantly and a mouse user never does.
**Its test was vacuous when written**, in the way this file keeps
finding: the tap helper dispatched `pointerdown` and `pointerup` and no
`click`, so there was nothing to swallow and the assertion held on any
build. It sends the trailing click now, which also strengthened phase
1's "a tap plays and does not also select". The fixture needed an MBID
for the same reason — without one the link asks the backend for a local
album first and gives up when nothing answers, so "it did not navigate"
was true of a working build and a broken one alike.
---
## What phase 2 measured, which was not what phase 2 predicted
The `touch-action` finding above is **half** of the answer, and
shipping only that half would have been the exact failure it warns
about. Driving a real finger with `adb shell input swipe` across a
track row, three values, all three on the device:
```
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. So **Chrome 113's WebView
cancels the pointer stream ~16px into any drag whatever `touch-action`
says**, and a swipe recognised from `pointermove` — which is what the
rest of this module is built on — is a swipe that dies 16px in.
The other half is a **non-passive `touchmove` calling
`preventDefault()`**: with it, the same swipe ran to 12 moves and a
`pointerup` at full travel. And both halves 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.
The reading is that `auto` lets the browser commit to a horizontal pan
on the first move past slop, before any threshold of ours can have
been crossed, while `pan-y` leaves it undecided long enough for the
second move to claim it.
`none` is the one value to avoid: the list stopped scrolling at all.
With the shipped pair, a vertical drag still scrolls the virtualizer
81px on the same run that a horizontal one survives.
**`draggable="true"` is not a competitor**, which is the other thing
the device was asked. No `dragstart` fires from a touch drag on this
WebView at all, so the drag-to-playlist attribute on every row needs no
pointer-type gate.
### And it found a phase 1 defect that no tier can see
The native `contextmenu` arrives in **either** order, and phase 1 only
handled one of them. `nativeSeen` covers the browser's menu arriving
*during* the hold. The reverse — our 500ms timer firing first, a
component claiming it, and Chrome delivering its own `contextmenu`
5070ms *later* — was suppressed by nothing, so the context menu
opened on top of the selection bar. Measured over four holds:
```
hold 1 yj-long-press, then contextmenu isTrusted=true menu open
hold 2 yj-long-press clean
hold 3 yj-long-press, then contextmenu isTrusted=true menu open
hold 4 yj-long-press clean
```
Two in four, on the one surface #63 exists to have changed, and
invisible to both browser tiers because neither synthesises a
`contextmenu` from a dispatched press. A press that has produced its
outcome now suppresses a late one whichever branch it took; six holds
on the fixed build, six clean.
### The rules phase 2 settled
- **A swipe is not a selection.** It queues the row it was made on,
unless that row is one of several *explicitly* selected — the same
rule the context menu answers with, 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, which is
where it differs from a right-click.
- **Rightward only.** Nothing is bound to a leftward swipe and
claiming one would take a gesture away to do nothing with it.
- **The commit threshold is a fraction of the row** (0.3, floor 72px),
because the row is 424x52 on this device and a bare pixel count is a
fraction of a row height on one screen and a third of the width on
the next.
- **The affordance is not only a colour** (WCAG 1.4.1, the rule the
playing-row marker exists for): the pane carries the queue icon and
words, the words change at the threshold ("Add to queue" → "Release
to add" → "Added"), and the outcome is announced in a live region.
- **The row does not move; its cells do.** `.track-row` is
`contain: strict` with `overflow: hidden`, so a pane held at the
row's original position while the row translates is a pane at a
negative offset inside a clipping box and is simply not painted.
Sliding the cells needs no wrapper element in a row that is already
a grid.
- **The travel is written to the row's own style, not rendered.** One
render at the start, one at the threshold, one at the end; a
virtualizer re-rendering every visible row per frame of one finger's
travel is the thing `perf.m1` is about.
## Open questions
1. **Does selection mode have an escape other than the bar's own
close?** *Settled: Escape, here; back, not here.*
Escape leaves the mode, from `selection-bar` rather than from each
of the four hosts — that element exists only while the mode does, so
it is the one place a dismissal can be attached and detached with
the thing it dismisses. It is the same documented exception the
overlaid queue's Escape is: **a dismissal, not a shortcut**, so it
is not a panel-scoped binding.
The back gesture is the half that is *not* done, and deliberately.
The obvious version — `selection-bar` pushing a history entry — is
precisely the fault `navStack` was deleted for: the shell owns the
stack (#6/#55) and is the only thing that calls `pushState`, so that
two stacks cannot disagree about what one press means. Four lists
each reaching for `history` is four stacks. It is also wrong on its
own terms, since a mode is per-component and a user who enters one,
navigates away and returns has an entry for a mode that no longer
exists. #55 settled the shape for a *place*; a mode is not one,
which is why it could not simply inherit that answer.
What it wants is one shell-owned register of dismissible surfaces,
which would retro-fit the queue overlay, the dialogs and this alike
rather than adding a fourth private answer. **#200.**
2. **Does a tap on a row's favourite icon still toggle it in normal
mode?** *Settled in phase 1: yes.* A control inside the row keeps
its own tap — the gesture is simply not claimed there, so the click
behind it falls through untouched. It is the same rule the shortcut
service has for a focused control that owns a key, and it is what
keeps the 44px favourite target (#56) from becoming a 44px play
target. The queue row's × is the second instance of it.
+176 -16
View File
@@ -262,6 +262,26 @@ real store code. A binding carries an **ID**, not a name
`yellowjacket/backend/home.Service.GetShelves`), so the fake derives `yellowjacket/backend/home.Service.GetShelves`), so the fake derives
that map from the generated tree rather than writing it down. that map from the generated tree rather than writing it down.
**A test file does not get its own origin, so `setup.ts` clears
`localStorage` between tests.** `@vitest/browser-playwright` opens one
BrowserContext per session and runs several files in it one after
another, so everything a component persists — the track list's sort and
column widths, the cover size, `now-playing`'s scroll mode — is still
there when the next file mounts the same component. Which files share a
tab, and in what order, changes run to run, so the symptom is a spec
that fails about one test in three and passes every time it is run on
its own: #138 cost three scheduled runs, one of them a PR whose diff
held no frontend code at all. Measured on the build before the fix, a
single full run started **24** tests with storage already set. Two
things follow. The clear is safe precisely because the leak is
sequential — files in a session do not overlap, so it cannot wipe
storage a concurrently-running file is in the middle of using — and it
belongs in `setup.ts` rather than in the specs that write, because the
spec that *reads* is never the one that knows. And a spec whose
assertion depends on an order still **states that order** rather than
inheriting a default, or the next change to a default is the same
mystery again.
**`frontend/bindings/` is generated by `wails3`, not `go generate`**, so **`frontend/bindings/` is generated by `wails3`, not `go generate`**, so
the pre-commit codegen check does not cover it. `make bindings-check` the pre-commit codegen check does not cover it. `make bindings-check`
(~3.5 s warm, ~20 s on a cold build cache, also a pre-commit hook) (~3.5 s warm, ~20 s on a cold build cache, also a pre-commit hook)
@@ -1449,18 +1469,112 @@ never opens). **The sweep found two of the fourteen**; twelve were
converted by hand. converted by hand.
**And a menu opens from a finger, through the event it already has.** **And a menu opens from a finger, through the event it already has.**
`utils/long-press.ts` is one document-capture listener installed once `utils/touch-gestures.ts` is one document-capture listener installed
from `index.ts`: a touch that holds still for 500 ms dispatches a once from `index.ts``utils/long-press.ts` until #63 replaced it,
synthetic `contextmenu` at the touch point, so all six components that rather than adding a second listener claiming the same 500 ms hold. It
bind one — delegated on a virtualizer, per row, per card — gained the **announces** rather than acts: `yj-tap`, `yj-long-press` and
gesture without changing. The target is `composedPath()[0]` rather than `yj-swipe-start` are composed and cancelable, and a component claims
one with `preventDefault()`. That is what let #63 reassign the hold
without touching one of the fourteen context menus: an *unclaimed*
`yj-long-press` still becomes a synthetic `contextmenu`, so all six
components that bind one — delegated on a virtualizer, per row, per
card — behave exactly as they did, and only the lists that opt in get
selection mode. The target is `composedPath()[0]` rather than
`elementFromPoint`, which stops at the outermost shadow host and so `elementFromPoint`, which stops at the outermost shadow host and so
reaches a delegated listener and no per-row one; a browser that fires reaches a delegated listener and no per-row one; and the click that
its own long-press `contextmenu` (Chromium does, WebKit and the WebView ends a *claimed* gesture is swallowed, keyed on the gesture rather than
vary) wins, ours being told from theirs by **identity** rather than on a time window so the first tap on the menu it opened is not eaten
`isTrusted`, since no test can dispatch a trusted event; and the click too.
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 things about it are load-bearing, and all three were found on the
device rather than in a tier.
**A browser that fires its own long-press `contextmenu` is a trigger,
not a competitor.** Chromium does, WebKit and the WebView vary. The old
rule was to stand down when a trusted one arrived, which was right
while both paths ended in a context menu and is wrong the moment a hold
can mean something else — standing down silently does the *old* thing.
So the gesture is announced from the native event, and only a component
that claims it suppresses that event. Ours and the browser's are told
apart by **identity** rather than `isTrusted`, since no test can
dispatch a trusted event.
**That arrives in either order, and both have to be handled.** The
native `contextmenu` mid-hold is one case; the other is our own 500 ms
timer firing first and Chrome delivering its menu **5070 ms later**,
which nothing suppressed — measured over four holds on the reference
phone, two took that order, so the context menu opened over the
selection bar intermittently, on the one surface #63 changed. A press
that has produced its outcome therefore suppresses a late
`contextmenu` whichever branch it took.
**A horizontal swipe runs on touch events, and needs two things that
look like one.** Chrome 113's WebView cancels the *pointer* stream
~16 px into any drag — measured at `auto`, `pan-y` and `none` alike,
one or two `pointermove`s and then `pointercancel`, while `touchmove`
kept firing throughout. So the recogniser is `touchmove`, the surface
declares **`touch-action: pan-y`** *and* a claimed swipe calls
**`preventDefault()`** on a non-passive listener. Neither works alone:
with the `preventDefault` in place but `touch-action` back at `auto`
the gesture died after one move, because `auto` lets the browser commit
to a horizontal pan before any threshold can be crossed. `none` is the
value to avoid — it takes the list's own vertical scrolling with it.
**Both are correct in Chromium either way**, which is why this is
written down rather than tested. The tie breaks toward scrolling, in
that order: vertical drift past the tolerance vetoes the swipe for the
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, **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 and which way round depends on whether it is the only route to its
@@ -2455,11 +2569,38 @@ Five things about it are load-bearing, and four of them fail silently:
correctly. Confidently wrong is worse than absent here, which is the correctly. Confidently wrong is worse than absent here, which is the
same rule `Known` exists for. same rule `Known` exists for.
One gap this did not close, and it is older: **`dhowden/tag` has no One gap this did not close and #104 did: **`dhowden/tag` has no RIFF
RIFF reader**, so nothing the tag writer puts in a WAV's `id3 ` chunk reader**, so nothing the tag writer put in a WAV's `id3 ` chunk was
is visible to `metadata.ExtractTags` — not the totals and not the title visible to `metadata.ExtractTags` — not the totals and not the title
either. `wav_test.go` reads that chunk itself, which is why no test either, on files the app itself had just tagged. `wav_test.go` read
ever noticed. that chunk itself, which is why no test noticed: a round trip asserted
through the writer's own parser is a test of the writer.
`backend/riff` is where the container is now read, and it is its own
package because the alternative is an import cycle — `tagwriter`
imports `metadata`, so `metadata` cannot reach back for `parseRIFF`.
`backend/tagtotals` is the precedent.
Three things about it are load-bearing. **The 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 the
scan path uses `ID3Chunk`, which seeks over what it is not looking for.
**The container decides, 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. And **an
untagged WAV is a file with no tags, not a file with a problem**: no
chunk, an RF64 container or a tag holding no frames all read as empty
metadata with no `TagReadWarning`, since the scanner's filename
fallback is the right answer and a warning would put a fault on a file
that has none.
The gap was pinned by a test that said so, which failed the moment the
reader learned and carried the instructions for what to update in its
own comment. So it is deleted, `TestFixturesMatchManifest` no longer
skips `wav`, and `totals_test.go`'s WAV case goes through
`metadata.ExtractTags` like the other three formats. The fixture
library's two WAV tracks scan with their tags and their cover now,
which is a change to what every seeded tier sees.
**The absence is what gets marked, not the presence.** The tracklist **The absence is what gets marked, not the presence.** The tracklist
put a green tick against every owned track and a legend underneath put a green tick against every owned track and a legend underneath
@@ -3330,6 +3471,25 @@ Pre-commit hooks verify generated code is fresh — always run `make generate` a
Tests use `database.NewTestDB(t)` for in-memory SQLite, built by the same Tests use `database.NewTestDB(t)` for in-memory SQLite, built by the same
`applySchema` production uses so the two cannot diverge. Test audio fixtures live in `test_data/music_library_test/`. Table-driven tests are the norm. `applySchema` production uses so the two cannot diverge. Test audio fixtures live in `test_data/music_library_test/`. Table-driven tests are the norm.
**`make ui-visual` is the one tier nothing but a person runs, and it
cannot become one.** Its ten `toMatchScreenshot` baselines were recorded
on a developer's Arch box; replayed in a bare `ubuntu:24.04` container
— CI's `check` image — three of them fail on font metrics and
compositing alone (`track-info` and one `page-header` shot at a 0.03
mismatch ratio against a 0.02 allowance, `seek-bar` one pixel shorter),
and two components disagree about their own height between the two
machines. So CI keeps running `make ui-test`, which is the same suite
with the comparisons off, and a pre-push hook would be the same fault
with the machines swapped. What replaces the gate is a rule, in
`.pi/skills/yellowjacket-dev/references/ui-tier.md`: **a change that
moves a component's geometry refreshes that component's baseline in the
same commit, having read the image, and never one it did not cause**.
That is #196, which was four stale references accumulated across three
unrelated merges — a red tier nobody could read, which is how it stayed
red. A visual case must also **state the world it photographs**, since
the stores are singletons and a case that sets nothing records whatever
the previous one left in them.
## Git Workflow ## Git Workflow
Feature branches and PRs are the only way in: **`main` is a protected Feature branches and PRs are the only way in: **`main` is a protected
+5 -2
View File
@@ -160,8 +160,11 @@ ui-watch: ## Same suite, in watch mode
ui-visual: ## Run the suite including screenshot comparisons ui-visual: ## Run the suite including screenshot comparisons
@cd frontend && YJ_VISUAL=1 npx vitest run $(UI_ARGS) @cd frontend && YJ_VISUAL=1 npx vitest run $(UI_ARGS)
ui-visual-update: ## Re-record the screenshot baselines # `--update=true`, never a bare `--update`: vitest takes the following
@cd frontend && YJ_VISUAL=1 npx vitest run --update $(UI_ARGS) # positional as the flag's value, so `--update <path>` swallows the path
# and re-records every baseline in the repo instead of the one named.
ui-visual-update: ## Re-record the screenshot baselines (UI_ARGS=<path> to filter)
@cd frontend && YJ_VISUAL=1 npx vitest run --update=true $(UI_ARGS)
ui-setup: ## Install the Vitest browser provider's own Chromium (once) ui-setup: ## Install the Vitest browser provider's own Chromium (once)
@cd frontend && pnpm install && npx playwright install chromium @cd frontend && pnpm install && npx playwright install chromium
+8
View File
@@ -74,6 +74,14 @@ func ExtractTags(path string) (*TrackMetadata, error) {
// ExtractTagsFromReader reads metadata from an io.ReadSeeker. // ExtractTagsFromReader reads metadata from an io.ReadSeeker.
func ExtractTagsFromReader(r io.ReadSeeker) (*TrackMetadata, error) { func ExtractTagsFromReader(r io.ReadSeeker) (*TrackMetadata, error) {
// The container decides, so this is asked before tag.ReadFrom and
// not after its failure: a WAV's tags live in a RIFF chunk that
// dhowden/tag cannot see, and its fallback -- an ID3v1 trailer --
// would otherwise outrank them.
if meta, ok := wavTags(r); ok {
return meta, nil
}
m, err := tag.ReadFrom(r) m, err := tag.ReadFrom(r)
if err != nil { if err != nil {
// No tags found is not necessarily an error - return empty metadata // No tags found is not necessarily an error - return empty metadata
+68
View File
@@ -0,0 +1,68 @@
package metadata
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"yellowjacket/backend/riff"
)
// wavTags reads the ID3v2 tag a WAV carries in its RIFF "id3 " chunk,
// which is where backend/tagwriter puts it and where dhowden/tag --
// having no RIFF reader at all -- cannot look. Without this a WAV
// scans as an untagged file however carefully it was tagged.
//
// ok is false when r is not a RIFF/WAVE container, and the read
// position is restored either way so the caller can carry on.
func wavTags(r io.ReadSeeker) (*TrackMetadata, bool) {
start, err := r.Seek(0, io.SeekCurrent)
if err != nil {
return nil, false
}
id3Data, chunkErr := riff.ID3Chunk(r)
if _, err := r.Seek(start, io.SeekStart); err != nil {
return nil, false
}
switch {
case chunkErr == nil:
return wavTagsFrom(id3Data), true
// Not ours to read: let the ordinary dispatch have the file.
case errors.Is(chunkErr, riff.ErrNotRIFF), errors.Is(chunkErr, riff.ErrNotWAVE):
return nil, false
// A RIFF container we cannot get a tag out of -- no chunk, an RF64
// file, a truncated header. That is a file with no readable tags,
// which is what the scanner's filename fallback is for.
default:
return &TrackMetadata{}, true
}
}
// wavTagsFrom parses the bytes of a WAV's ID3v2 chunk.
func wavTagsFrom(id3Data []byte) *TrackMetadata {
meta, err := extractID3v2Lenient(bytes.NewReader(id3Data))
if err != nil {
// A tag holding no frames is not a damaged tag: writing every
// field back out empty leaves one, and warning about it would
// put a fault on a file that has none.
if errors.Is(err, ErrTagsUnreadable) {
return &TrackMetadata{}
}
return &TrackMetadata{
TagReadWarning: fmt.Errorf("%w: %w", ErrTagsUnreadable, err),
}
}
// extractID3v2Lenient names MP3, being the recovery path for one.
meta.FileFormat = strings.ToUpper(strings.TrimPrefix(string(WAV), "."))
return meta
}
+183
View File
@@ -0,0 +1,183 @@
// Package riff reads the chunk layout of a RIFF/WAVE container.
//
// It exists because both halves of WAV tagging need it and neither can
// import the other: backend/tagwriter writes a WAV's tags into a RIFF
// "id3 " chunk and already imports backend/metadata, which is what has
// to read them back out. backend/tagtotals is the precedent.
//
// The two readers here are deliberately different. Parse holds every
// chunk's data in memory, which is what rewriting a file needs; a WAV's
// audio *is* the "data" chunk, so doing that on the scan path would
// read every library file in full. ID3Chunk seeks over what it is not
// looking for instead. Both walk the same headers.
package riff
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
)
// Sentinel errors describing a container this package will not read.
var (
ErrRF64NotSupported = errors.New("RF64 files are not yet supported")
ErrNotRIFF = errors.New("not a RIFF file")
ErrNotWAVE = errors.New("not a WAVE file")
ErrNoID3Chunk = errors.New("no ID3 chunk in RIFF file")
)
// Chunk holds a single RIFF sub-chunk (ID + raw data).
type Chunk struct {
ID [4]byte
Data []byte
}
// IsID3 reports whether id is that of an ID3v2 RIFF chunk. Both
// lowercase "id3 " and uppercase "ID3 " are accepted.
func IsID3(id [4]byte) bool {
return strings.ToLower(string(id[:3])) == "id3"
}
// Parse reads every RIFF sub-chunk from r, in order, starting at the
// reader's current position. It rejects RF64 files and non-WAVE
// containers with descriptive errors. The parser is lenient: it
// tolerates a missing final padding byte and ignores the declared
// RIFF size.
func Parse(r io.Reader) ([]Chunk, error) {
if err := readContainer(r); err != nil {
return nil, err
}
var chunks []Chunk
for {
id, size, err := nextHeader(r)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
data := make([]byte, size)
if _, err := io.ReadFull(r, data); err != nil {
return nil, fmt.Errorf("read chunk data for %q: %w", id, err)
}
chunks = append(chunks, Chunk{ID: id, Data: data})
// Odd-length chunks have a padding byte. Lenient: if the
// read fails (e.g. EOF), just break rather than error.
if size%2 != 0 {
var pad [1]byte
if _, err := r.Read(pad[:]); err != nil {
break
}
}
}
return chunks, nil
}
// ID3Chunk returns the payload of the ID3v2 chunk of the RIFF/WAVE
// container at the reader's current position, seeking over every other
// chunk rather than reading it. It returns ErrNoID3Chunk when the
// container carries no such chunk, and leaves the read position
// unspecified either way.
func ID3Chunk(r io.ReadSeeker) ([]byte, error) {
if err := readContainer(r); err != nil {
return nil, err
}
for {
id, size, err := nextHeader(r)
if errors.Is(err, io.EOF) {
return nil, ErrNoID3Chunk
}
if err != nil {
return nil, err
}
if !IsID3(id) {
// Odd-length chunks carry a padding byte. Seeking past
// the end of the file is not an error; the next header
// read is what reports the end.
if _, err := r.Seek(int64(size)+int64(size%2), io.SeekCurrent); err != nil {
return nil, fmt.Errorf("skip chunk %q: %w", id, err)
}
continue
}
// Copied rather than allocated up front: a truncated file is
// free to declare a chunk larger than the whole of itself.
var data bytes.Buffer
if _, err := io.CopyN(&data, r, int64(size)); err != nil {
return nil, fmt.Errorf("read chunk data for %q: %w", id, err)
}
return data.Bytes(), nil
}
}
// readContainer consumes the 12-byte RIFF/WAVE header at the reader's
// current position.
func readContainer(r io.Reader) error {
var magic [4]byte
if _, err := io.ReadFull(r, magic[:]); err != nil {
return fmt.Errorf("read RIFF magic: %w", err)
}
if string(magic[:]) == "RF64" {
return ErrRF64NotSupported
}
if string(magic[:]) != "RIFF" {
return fmt.Errorf("%w: got %q", ErrNotRIFF, magic)
}
// Read (and discard) RIFF size — lenient, do not enforce.
var riffSize uint32
if err := binary.Read(r, binary.LittleEndian, &riffSize); err != nil {
return fmt.Errorf("read RIFF size: %w", err)
}
var form [4]byte
if _, err := io.ReadFull(r, form[:]); err != nil {
return fmt.Errorf("read WAVE form type: %w", err)
}
if string(form[:]) != "WAVE" {
return fmt.Errorf("%w: got %q", ErrNotWAVE, form)
}
return nil
}
// nextHeader reads one sub-chunk header. It returns io.EOF once the
// chunks are exhausted, including for a header cut short.
func nextHeader(r io.Reader) ([4]byte, uint32, error) {
var id [4]byte
_, err := io.ReadFull(r, id[:])
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return id, 0, io.EOF
}
if err != nil {
return id, 0, fmt.Errorf("read chunk ID: %w", err)
}
var size uint32
if err := binary.Read(r, binary.LittleEndian, &size); err != nil {
return id, 0, fmt.Errorf("read chunk size for %q: %w", id, err)
}
return id, size, nil
}
+211
View File
@@ -0,0 +1,211 @@
package riff_test
import (
"bytes"
"encoding/binary"
"errors"
"testing"
"yellowjacket/backend/riff"
)
// chunk is one sub-chunk to put in a test container.
type chunk struct {
id string
data []byte
}
// buildRIFF assembles a container from magic, form type and chunks,
// padding odd-length chunks the way a writer must.
func buildRIFF(magic, form string, chunks []chunk) []byte {
var body bytes.Buffer
body.WriteString(form)
for _, c := range chunks {
body.WriteString(c.id)
_ = binary.Write(&body, binary.LittleEndian, uint32(len(c.data)))
body.Write(c.data)
if len(c.data)%2 != 0 {
body.WriteByte(0)
}
}
var out bytes.Buffer
out.WriteString(magic)
_ = binary.Write(&out, binary.LittleEndian, uint32(body.Len()))
out.Write(body.Bytes())
return out.Bytes()
}
func TestID3Chunk_FindsTheTagPastTheAudio(t *testing.T) {
t.Parallel()
tests := []struct {
name string
chunks []chunk
want string
}{
{
name: "after an odd-length chunk",
chunks: []chunk{
{id: "fmt ", data: make([]byte, 16)},
{id: "LIST", data: []byte("INFOodd")},
{id: "data", data: make([]byte, 200)},
{id: "id3 ", data: []byte("ID3vTAG")},
},
want: "ID3vTAG",
},
{
// The chunk ID is written both ways in the wild, and the
// writer accepts either, so the reader must too.
name: "uppercase ID3",
chunks: []chunk{
{id: "data", data: make([]byte, 8)},
{id: "ID3 ", data: []byte("upper")},
},
want: "upper",
},
{
name: "first chunk",
chunks: []chunk{
{id: "id3 ", data: []byte("first")},
{id: "data", data: make([]byte, 8)},
},
want: "first",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
r := bytes.NewReader(buildRIFF("RIFF", "WAVE", tc.chunks))
got, err := riff.ID3Chunk(r)
if err != nil {
t.Fatalf("ID3Chunk: %v", err)
}
if string(got) != tc.want {
t.Errorf("chunk data: got %q, want %q", got, tc.want)
}
})
}
}
func TestID3Chunk_RejectsWhatItCannotRead(t *testing.T) {
t.Parallel()
tests := []struct {
name string
bytes []byte
want error
}{
{
name: "no ID3 chunk",
bytes: buildRIFF("RIFF", "WAVE", []chunk{{id: "data", data: []byte{1, 2}}}),
want: riff.ErrNoID3Chunk,
},
{
name: "no chunks at all",
bytes: buildRIFF("RIFF", "WAVE", nil),
want: riff.ErrNoID3Chunk,
},
{
name: "not RIFF",
bytes: []byte("ID3\x03\x00\x00\x00\x00\x00\x00\x00\x00"),
want: riff.ErrNotRIFF,
},
{
name: "not WAVE",
bytes: buildRIFF("RIFF", "AVI ", []chunk{{id: "id3 ", data: []byte("x")}}),
want: riff.ErrNotWAVE,
},
{
name: "RF64",
bytes: buildRIFF("RF64", "WAVE", []chunk{{id: "id3 ", data: []byte("x")}}),
want: riff.ErrRF64NotSupported,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := riff.ID3Chunk(bytes.NewReader(tc.bytes))
if !errors.Is(err, tc.want) {
t.Errorf("ID3Chunk error: got %v, want %v", err, tc.want)
}
})
}
}
// A file cut short mid-chunk is a file with no tag, not a reason to
// allocate the size it claims: the declared size is four bytes any
// truncation can leave saying 4 GB.
func TestID3Chunk_ToleratesATruncatedFile(t *testing.T) {
t.Parallel()
full := buildRIFF("RIFF", "WAVE", []chunk{
{id: "data", data: make([]byte, 64)},
{id: "id3 ", data: []byte("tag")},
})
t.Run("cut inside the audio", func(t *testing.T) {
t.Parallel()
_, err := riff.ID3Chunk(bytes.NewReader(full[:32]))
if !errors.Is(err, riff.ErrNoID3Chunk) {
t.Errorf("ID3Chunk error: got %v, want %v", err, riff.ErrNoID3Chunk)
}
})
t.Run("cut inside the tag", func(t *testing.T) {
t.Parallel()
if _, err := riff.ID3Chunk(bytes.NewReader(full[:len(full)-2])); err == nil {
t.Error("ID3Chunk: got nil error for a truncated tag chunk")
}
})
}
// Parse is the writer's half and reads every chunk into memory, which
// is what preserving them needs.
func TestParse_ReadsEveryChunkInOrder(t *testing.T) {
t.Parallel()
raw := buildRIFF("RIFF", "WAVE", []chunk{
{id: "fmt ", data: make([]byte, 16)},
{id: "LIST", data: []byte("INFOodd")},
{id: "id3 ", data: []byte("tag")},
})
chunks, err := riff.Parse(bytes.NewReader(raw))
if err != nil {
t.Fatalf("Parse: %v", err)
}
want := []string{"fmt ", "LIST", "id3 "}
if len(chunks) != len(want) {
t.Fatalf("chunk count: got %d, want %d", len(chunks), len(want))
}
for i, id := range want {
if got := string(chunks[i].ID[:]); got != id {
t.Errorf("chunk %d: got %q, want %q", i, got, id)
}
}
if !riff.IsID3(chunks[2].ID) || string(chunks[2].Data) != "tag" {
t.Errorf("id3 chunk: got %q", chunks[2].Data)
}
// The padding byte after an odd chunk is not part of its data.
if string(chunks[1].Data) != "INFOodd" {
t.Errorf("odd chunk data: got %q, want %q", chunks[1].Data, "INFOodd")
}
}
+5 -4
View File
@@ -13,9 +13,10 @@ import (
// indistinguishable from never having written one. So these assert the // indistinguishable from never having written one. So these assert the
// round trip through the *reader the scan uses*, not the bytes. // round trip through the *reader the scan uses*, not the bytes.
// //
// WAV is the exception and it is not this change's: dhowden/tag has no // WAV was the exception until #104 -- dhowden/tag has no RIFF reader,
// RIFF reader at all, so metadata.ExtractTags cannot see a WAV's ID3 // so metadata.ExtractTags could not see a WAV's ID3 chunk and this
// chunk -- which is why every other test here reads that chunk itself. // case read the chunk itself, which is a test of the writer wearing
// the shape of a round trip. All four go through the scanner now.
func TestWriteTotals_RoundTripsInEveryFormat(t *testing.T) { func TestWriteTotals_RoundTripsInEveryFormat(t *testing.T) {
t.Parallel() t.Parallel()
@@ -91,7 +92,7 @@ func TestWriteTotals_RoundTripsInEveryFormat(t *testing.T) {
}, },
{ {
name: "wav", name: "wav",
read: readWavID3Tags, read: viaScanner,
write: func(t *testing.T, dir string) string { write: func(t *testing.T, dir string) string {
t.Helper() t.Helper()
+11 -105
View File
@@ -8,116 +8,22 @@ import (
"io" "io"
"log/slog" "log/slog"
"os" "os"
"strings"
id3v2 "github.com/bogem/id3v2/v2" id3v2 "github.com/bogem/id3v2/v2"
"yellowjacket/backend/fileutil" "yellowjacket/backend/fileutil"
"yellowjacket/backend/riff"
) )
// Sentinel errors for WAV RIFF operations. // errFileTooLargeForWAV is the one RIFF error that belongs to the
var ( // writer; reading rejects a container in backend/riff.
errRF64NotSupported = errors.New("RF64 files are not yet supported") var errFileTooLargeForWAV = errors.New("file too large for WAV format (>4GB)")
errNotRIFF = errors.New("not a RIFF file")
errNotWAVE = errors.New("not a WAVE file")
errFileTooLargeForWAV = errors.New("file too large for WAV format (>4GB)")
)
// riffChunk holds a single RIFF sub-chunk (ID + raw data).
type riffChunk struct {
id [4]byte
data []byte
}
// parseRIFF reads all RIFF sub-chunks from r. It rejects RF64 files
// and non-WAVE containers with descriptive errors. The parser is
// lenient on read: it tolerates missing padding bytes and ignores
// the declared RIFF size.
func parseRIFF(r io.ReadSeeker) ([]riffChunk, error) {
// Read 4-byte container magic.
var magic [4]byte
if _, err := io.ReadFull(r, magic[:]); err != nil {
return nil, fmt.Errorf("read RIFF magic: %w", err)
}
if string(magic[:]) == "RF64" {
return nil, errRF64NotSupported
}
if string(magic[:]) != "RIFF" {
return nil, fmt.Errorf("%w: got %q", errNotRIFF, magic)
}
// Read (and discard) RIFF size — lenient, do not enforce.
var riffSize uint32
if err := binary.Read(r, binary.LittleEndian, &riffSize); err != nil {
return nil, fmt.Errorf("read RIFF size: %w", err)
}
// Read 4-byte form type.
var form [4]byte
if _, err := io.ReadFull(r, form[:]); err != nil {
return nil, fmt.Errorf("read WAVE form type: %w", err)
}
if string(form[:]) != "WAVE" {
return nil, fmt.Errorf("%w: got %q", errNotWAVE, form)
}
// Read sub-chunks until EOF.
var chunks []riffChunk
for {
var chunkID [4]byte
_, err := io.ReadFull(r, chunkID[:])
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
break
}
if err != nil {
return nil, fmt.Errorf("read chunk ID: %w", err)
}
var chunkSize uint32
if err := binary.Read(r, binary.LittleEndian, &chunkSize); err != nil {
return nil, fmt.Errorf("read chunk size for %q: %w", chunkID, err)
}
data := make([]byte, chunkSize)
if _, err := io.ReadFull(r, data); err != nil {
return nil, fmt.Errorf("read chunk data for %q: %w", chunkID, err)
}
chunks = append(chunks, riffChunk{id: chunkID, data: data})
// Odd-length chunks have a padding byte. Lenient: if the
// read fails (e.g. EOF), just break rather than error.
if chunkSize%2 != 0 {
var pad [1]byte
if _, err := r.Read(pad[:]); err != nil {
break
}
}
}
return chunks, nil
}
// isID3ChunkID returns true if id represents an ID3v2 RIFF chunk.
// Both lowercase "id3 " and uppercase "ID3 " are accepted.
func isID3ChunkID(id [4]byte) bool {
s := strings.ToLower(string(id[:3]))
return s == "id3"
}
// writeRIFF writes a complete RIFF/WAVE container to w, preserving // writeRIFF writes a complete RIFF/WAVE container to w, preserving
// the given chunks in order and appending the id3Data as the final // the given chunks in order and appending the id3Data as the final
// "id3 " chunk. Returns errFileTooLargeForWAV if the result would // "id3 " chunk. Returns errFileTooLargeForWAV if the result would
// exceed the 4 GB RIFF limit. // exceed the 4 GB RIFF limit.
func writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error { func writeRIFF(w io.Writer, chunks []riff.Chunk, id3Data []byte) error {
// Calculate total RIFF payload size: // Calculate total RIFF payload size:
// 4 bytes (WAVE form type) // 4 bytes (WAVE form type)
// + for each preserved chunk: 8 (header) + len(data) + padding // + for each preserved chunk: 8 (header) + len(data) + padding
@@ -125,7 +31,7 @@ func writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error {
riffPayload := uint64(4) riffPayload := uint64(4)
for _, c := range chunks { for _, c := range chunks {
sz := uint64(len(c.data)) sz := uint64(len(c.Data))
riffPayload += 8 + sz riffPayload += 8 + sz
if sz%2 != 0 { if sz%2 != 0 {
@@ -162,7 +68,7 @@ func writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error {
// Write each preserved chunk. // Write each preserved chunk.
for _, c := range chunks { for _, c := range chunks {
if err := writeChunk(w, c.id, c.data); err != nil { if err := writeChunk(w, c.ID, c.Data); err != nil {
return err return err
} }
} }
@@ -225,7 +131,7 @@ func writeWavTags(
return fmt.Errorf("open wav for reading: %w", err) return fmt.Errorf("open wav for reading: %w", err)
} }
allChunks, err := parseRIFF(f) allChunks, err := riff.Parse(f)
// Close immediately — we need the handle released before // Close immediately — we need the handle released before
// AtomicWrite creates the replacement file. // AtomicWrite creates the replacement file.
@@ -237,13 +143,13 @@ func writeWavTags(
// Separate preserved chunks from existing ID3 data. // Separate preserved chunks from existing ID3 data.
var ( var (
preserved []riffChunk preserved []riff.Chunk
existingID3 []byte existingID3 []byte
) )
for _, c := range allChunks { for _, c := range allChunks {
if isID3ChunkID(c.id) { if riff.IsID3(c.ID) {
existingID3 = c.data existingID3 = c.Data
} else { } else {
preserved = append(preserved, c) preserved = append(preserved, c)
} }
+103 -17
View File
@@ -12,6 +12,7 @@ import (
id3v2 "github.com/bogem/id3v2/v2" id3v2 "github.com/bogem/id3v2/v2"
"yellowjacket/backend/metadata" "yellowjacket/backend/metadata"
"yellowjacket/backend/riff"
) )
// createTestWAV builds a minimal valid WAV file with an optional // createTestWAV builds a minimal valid WAV file with an optional
@@ -270,6 +271,88 @@ func TestWriteWavTags_PartialUpdate(t *testing.T) {
assertStrField(t, "Composer", meta.Composer, "Original Composer") assertStrField(t, "Composer", meta.Composer, "Original Composer")
} }
// The writer has always been correct and the reader could not see it:
// a WAV tagged by this app scanned as an untagged file, so editing
// tags, autotagging a folder or importing a WAV download all appeared
// to work and changed nothing the library could show (#104). So this
// asserts the write through metadata.ExtractTags -- the reader the
// scan uses -- rather than through the id3 chunk.
func TestWriteWavTags_ReadBackByTheScanner(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := createTestWAV(t, dir, "scanner.wav", nil)
art := tinyJPEG(t)
changes := TagChanges{
FieldTitle: "Some Song",
FieldArtist: "Some Artist",
FieldAlbum: "Some Album",
FieldAlbumArtist: "Some Album Artist",
FieldGenre: "Rock",
FieldYear: 2024,
FieldTrackNumber: 3,
FieldComposer: "Some Composer",
FieldCoverArt: art,
}
if err := writeWavTags(testLogger(), path, changes); err != nil {
t.Fatalf("writeWavTags: %v", err)
}
meta, err := metadata.ExtractTags(path)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
if meta.TagReadWarning != nil {
t.Errorf("TagReadWarning: %v", meta.TagReadWarning)
}
assertStrField(t, "Title", meta.Title, "Some Song")
assertStrField(t, "Artist", meta.Artist, "Some Artist")
assertStrField(t, "Album", meta.Album, "Some Album")
assertStrField(t, "AlbumArtist", meta.AlbumArtist, "Some Album Artist")
assertStrField(t, "Genre", meta.Genre, "Rock")
assertStrField(t, "Composer", meta.Composer, "Some Composer")
assertStrField(t, "FileFormat", meta.FileFormat, "WAV")
assertIntField(t, "Year", meta.Year, 2024)
assertIntField(t, "TrackNumber", meta.TrackNumber, 3)
if !strings.HasPrefix(meta.TagFormat, "ID3v2") {
t.Errorf("TagFormat: got %q, want an ID3v2 version", meta.TagFormat)
}
if meta.Picture == nil {
t.Fatal("expected cover art, got nil")
}
if !bytes.Equal(meta.Picture.Data, art) {
t.Errorf("picture data mismatch: got %d bytes, want %d",
len(meta.Picture.Data), len(art))
}
}
// An untagged WAV is a file with no tags, not a file with a problem:
// the scanner falls back to the filename and must not be handed a
// warning to surface about it.
func TestUntaggedWav_ReadsAsEmptyWithoutAWarning(t *testing.T) {
t.Parallel()
path := createTestWAV(t, t.TempDir(), "bare.wav", nil)
meta, err := metadata.ExtractTags(path)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
if meta.TagReadWarning != nil {
t.Errorf("TagReadWarning: %v", meta.TagReadWarning)
}
assertStrField(t, "Title", meta.Title, "")
}
func TestWriteWavTags_ChunkPreservation(t *testing.T) { func TestWriteWavTags_ChunkPreservation(t *testing.T) {
t.Parallel() t.Parallel()
@@ -282,7 +365,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
t.Fatalf("open original: %v", err) t.Fatalf("open original: %v", err)
} }
origChunks, err := parseRIFF(origFile) origChunks, err := riff.Parse(origFile)
_ = origFile.Close() _ = origFile.Close()
if err != nil { if err != nil {
@@ -292,7 +375,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
// Record original chunk data by ID string. // Record original chunk data by ID string.
origData := map[string][]byte{} origData := map[string][]byte{}
for _, c := range origChunks { for _, c := range origChunks {
origData[string(c.id[:])] = c.data origData[string(c.ID[:])] = c.Data
} }
// Write a tag to trigger RIFF rewrite. // Write a tag to trigger RIFF rewrite.
@@ -309,7 +392,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
t.Fatalf("open after write: %v", err) t.Fatalf("open after write: %v", err)
} }
newChunks, err := parseRIFF(newFile) newChunks, err := riff.Parse(newFile)
_ = newFile.Close() _ = newFile.Close()
if err != nil { if err != nil {
@@ -320,7 +403,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
origNonID3 := 0 origNonID3 := 0
for _, c := range origChunks { for _, c := range origChunks {
if !isID3ChunkID(c.id) { if !riff.IsID3(c.ID) {
origNonID3++ origNonID3++
} }
} }
@@ -328,7 +411,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
newNonID3 := 0 newNonID3 := 0
for _, c := range newChunks { for _, c := range newChunks {
if !isID3ChunkID(c.id) { if !riff.IsID3(c.ID) {
newNonID3++ newNonID3++
} }
} }
@@ -359,17 +442,17 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
// in chunks and its data matches want byte-for-byte. // in chunks and its data matches want byte-for-byte.
func checkChunkPreserved( func checkChunkPreserved(
t *testing.T, t *testing.T,
chunks []riffChunk, chunks []riff.Chunk,
idStr string, idStr string,
want []byte, want []byte,
) { ) {
t.Helper() t.Helper()
for _, c := range chunks { for _, c := range chunks {
if string(c.id[:]) == idStr { if string(c.ID[:]) == idStr {
if !bytes.Equal(c.data, want) { if !bytes.Equal(c.Data, want) {
t.Errorf("chunk %q data changed: got %d bytes, want %d", t.Errorf("chunk %q data changed: got %d bytes, want %d",
idStr, len(c.data), len(want)) idStr, len(c.Data), len(want))
} }
return return
@@ -430,7 +513,7 @@ func TestWriteWavTags_RejectsRF64(t *testing.T) {
buf.WriteString("WAVE") buf.WriteString("WAVE")
// Minimal ds64 chunk (required for RF64 but we just need // Minimal ds64 chunk (required for RF64 but we just need
// enough bytes for parseRIFF to hit the RF64 rejection). // enough bytes for riff.Parse to hit the RF64 rejection).
buf.WriteString("ds64") buf.WriteString("ds64")
_ = binary.Write(&buf, binary.LittleEndian, uint32(28)) //nolint:mnd _ = binary.Write(&buf, binary.LittleEndian, uint32(28)) //nolint:mnd
buf.Write(make([]byte, 28)) //nolint:mnd buf.Write(make([]byte, 28)) //nolint:mnd
@@ -454,9 +537,12 @@ func TestWriteWavTags_RejectsRF64(t *testing.T) {
// readWavID3Tags extracts ID3v2 metadata from a WAV file by parsing // readWavID3Tags extracts ID3v2 metadata from a WAV file by parsing
// the RIFF structure and reading the id3 chunk with bogem/id3v2. // the RIFF structure and reading the id3 chunk with bogem/id3v2.
// dhowden/tag's ReadFrom does not support WAV files, and its //
// ReadID3v2Tags fails on empty tags (after clearing all frames). // metadata.ExtractTags reads a WAV since #104 and is what the round
// Using bogem/id3v2.ParseReader handles all cases correctly. // trips assert through. This stays for the two cases that are about
// the bytes rather than about the scan: a tag with every frame
// cleared, which no reader reports as anything, and the chunk
// preservation test, which is already parsing the container itself.
func readWavID3Tags( func readWavID3Tags(
t *testing.T, t *testing.T,
path string, path string,
@@ -470,17 +556,17 @@ func readWavID3Tags(
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
chunks, err := parseRIFF(f) chunks, err := riff.Parse(f)
if err != nil { if err != nil {
t.Fatalf("parseRIFF: %v", err) t.Fatalf("riff.Parse: %v", err)
} }
// Find the id3 chunk. // Find the id3 chunk.
var id3Data []byte var id3Data []byte
for _, c := range chunks { for _, c := range chunks {
if isID3ChunkID(c.id) { if riff.IsID3(c.ID) {
id3Data = c.data id3Data = c.Data
break break
} }
-127
View File
@@ -1,127 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* Long-press is the touch route to a context menu (plan 016 B2 phase 3).
*
* The component tier proves the gesture in isolation, against markup it
* built itself. What it cannot prove is the half that made this one
* listener instead of six: that the synthetic event reaches the handler
* a *real* component bound — `track-list` delegates its `contextmenu`
* on the `lit-virtualizer` rather than binding one per row — and that
* the real `wa-popup` menu opens from it, which is a path with its own
* history of opening and then refusing to work (see
* `menu-keyboard.spec.ts`).
*
* The pointer events are dispatched rather than performed: this project
* runs Desktop Chrome and Desktop Safari, neither of which has touch,
* and a device tier does not exist. So this is honest about what it
* checks — the app's own listeners, on the app's own DOM, from the
* events a touch would produce — and not about a real finger.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** The track list's menu panel, or null while it is not rendered. */
const panel = (page: Page) =>
page.evaluate(() => {
const el = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
});
/**
* Press the first track row, optionally dragging partway through — the
* shape of a scroll that begins on a row, which must not open a menu.
*/
async function pressFirstRow(
page: Page,
opts: { driftY?: number } = {},
): Promise<void> {
await page.evaluate((drift) => {
// `.track-row`, not `[role="row"]`: the column header is a row too,
// and it is the *first* one -- a press on it is correctly ignored,
// which reads exactly like the gesture not working.
const row = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.track-row');
if (!row) throw new Error('no track row to press');
const box = row.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
row.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
}, opts.driftY ?? 0);
}
test.describe('long-press opens the track menu', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
await expect.poll(() => panel(app)).toBeNull();
await pressFirstRow(app);
await expect
.poll(() => panel(app), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu', label: 'Track actions' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
expect((await panel(app))?.items).toBeGreaterThan(0);
});
test('does not open one for a press that turns into a scroll', async ({
app,
}) => {
await pressFirstRow(app, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await panel(app)).toBeNull();
});
});
+311
View File
@@ -0,0 +1,311 @@
import { test, expect, callBinding } from '../support/fixtures.js';
/**
* The touch gestures against the real app (plan 019, #63; long-press
* from plan 016 B2).
*
* The component tier proves the gestures in isolation, against markup
* it built itself. What it cannot prove is the half that made this one
* document listener instead of six: that the announced gesture reaches
* the handler a *real* component bound — `track-list` delegates on the
* `lit-virtualizer` rather than binding per row — and that the real
* menu opens from it, a path with its own history of opening and then
* refusing to work (see `menu-keyboard.spec.ts`).
*
* **Both halves of the reassignment are here, and the second is the
* one that matters.** #63 makes a hold on a *track row* mean selection
* mode; every other surface in the app keeps the context menu it has
* had, because an unclaimed `yj-long-press` still becomes a
* `contextmenu`. A spec that only checked the row would pass on a
* build that had silently broken the other thirteen menus.
*
* The pointer events are dispatched rather than performed: this
* project runs Desktop Chrome and Desktop Safari, neither of which has
* touch. So this is honest about what it checks — the app's own
* listeners, on the app's own DOM, from the events a touch would
* produce — and not about a real finger. The finger is the Android
* tier, and it found something this cannot see: Chrome 113's WebView
* fires its own `contextmenu` on a long press, which is why the module
* announces the gesture from a native event rather than standing down.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** A component's menu panel, or null while it is not rendered. */
const panel = (page: Page, host: string) =>
page.evaluate((tag) => {
const el = document
.querySelector(tag)
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
}, host);
/** How many tracks the selection bar says are selected, or null. */
const selectionCount = (page: Page) =>
page.evaluate(() => {
const bar = document
.querySelector('track-list')
?.shadowRoot?.querySelector('selection-bar');
return bar ? (bar as unknown as { count: number }).count : null;
});
/**
* Press an element, optionally dragging partway through — the shape of
* a scroll that begins on a row, which must be neither gesture — and
* optionally lifting, which is what makes it a tap rather than a hold.
*/
async function press(
page: Page,
selector: { host: string; inner: string },
opts: { driftY?: number; lift?: boolean } = {},
): Promise<void> {
await page.evaluate(
({ host, inner, drift, lift }) => {
const el = document
.querySelector(host)
?.shadowRoot?.querySelector(inner);
if (!el) throw new Error(`no ${inner} in ${host} to press`);
const box = el.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
if (lift) send('pointerup');
},
{
host: selector.host,
inner: selector.inner,
drift: opts.driftY ?? 0,
lift: opts.lift ?? false,
},
);
}
// `.track-row`, not `[role="row"]`: the column header is a row too, and
// it is the *first* one — a press on it is correctly ignored, which
// reads exactly like the gesture not working.
const TRACK_ROW = { host: 'track-list', inner: '.track-row' };
test.describe('a hold on a track row selects it', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('raises the selection bar rather than the context menu', async ({
app,
}) => {
await expect.poll(() => selectionCount(app)).toBeNull();
await press(app, TRACK_ROW);
await expect
.poll(() => selectionCount(app), { timeout: HELD + 2000 })
.toBe(1);
// The gesture is claimed, so the menu this hold used to open must
// not also be up -- on a phone that would be a sheet over the bar.
expect(await panel(app, 'track-list')).toBeNull();
});
test('is neither gesture when the press turns into a scroll', async ({
app,
}) => {
await press(app, TRACK_ROW, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await selectionCount(app)).toBeNull();
expect(await panel(app, 'track-list')).toBeNull();
});
});
test.describe('a hold anywhere else still opens the menu', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'albums',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
// The property that let #63 reassign the hold without touching one
// of the fourteen context menus: unclaimed, it is what it was.
// Without this half, breaking all of them passes the suite.
await expect.poll(() => panel(app, 'cover-grid')).toBeNull();
await press(app, { host: 'cover-grid', inner: '[role="option"]' });
await expect
.poll(() => panel(app, 'cover-grid'), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
expect((await panel(app, 'cover-grid'))?.items).toBeGreaterThan(0);
});
});
/**
* Swipe right on a track row to queue it (plan 019 phase 2, #63).
*
* The component tier has the rule this obeys — one row is a position,
* several are a choice — against a queue that is a fake. What is only
* true here is that the gesture reaches the *real* queue: `AddTracks`
* is a Go method, the queue is persisted, and "the row was added"
* is a question only the backend can answer.
*
* **It is Chromium-only, and that is a property of the browser rather
* than a gap.** The gesture runs on touch events, because Chrome 113's
* WebView cancels the pointer stream ~16px into any drag whatever
* `touch-action` says. Desktop WebKit implements no `TouchEvent`
* constructor at all — touch events are a mobile-Safari surface — so
* the events this needs cannot be built there. Skipping loudly is
* better than a spec that quietly asserts nothing on half the matrix,
* which is what `layout-overflow.spec.ts` and `back-navigation.spec.ts`
* were each doing when they were green on a broken build.
*/
test.describe('a swipe right on a track row queues it', () => {
test.beforeEach(async ({ app, browserName }) => {
test.skip(
browserName !== 'chromium',
'desktop WebKit has no TouchEvent constructor to build the gesture from',
);
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
/**
* Drag the first row sideways by a fraction of its own width and
* lift. `fraction` is against the row, because the commit threshold
* is — a number of pixels here would be a second declaration of it,
* right on one viewport and wrong on the next.
*/
const swipeFirstRow = (page: Page, fraction: number) =>
page.evaluate((f) => {
const row = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.track-row');
if (!row) throw new Error('no track row to swipe');
const box = row.getBoundingClientRect();
const y = box.top + box.height / 2;
const at = (x: number) =>
new Touch({
identifier: 1,
target: row,
clientX: box.left + x,
clientY: y,
});
const send = (type: string, points: Touch[]) =>
row.dispatchEvent(
new TouchEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
touches: points,
changedTouches: points.length > 0 ? points : [at(0)],
}),
);
send('touchstart', [at(0)]);
for (const step of [0.25, 0.5, 0.75, 1]) {
send('touchmove', [at(box.width * f * step)]);
}
send('touchend', []);
}, fraction);
/** How many tracks the backend says are in the queue. */
const queueLength = async (page: Page) => {
const state = await callBinding<{ tracks: unknown[] }>(
page,
'queue.Queue.GetState',
);
return state.tracks?.length ?? 0;
};
test('adds exactly one track to the real queue', async ({ app }) => {
const before = await queueLength(app);
await swipeFirstRow(app, 0.6);
await expect.poll(() => queueLength(app)).toBe(before + 1);
// Queued, not played: a swipe is not a tap, and the difference is
// what is on screen afterwards.
expect(
await app.getByTestId('main-content').getAttribute('data-active-view'),
).toBe('tracks');
});
test('does nothing when the finger did not get far enough', async ({
app,
}) => {
const before = await queueLength(app);
await swipeFirstRow(app, 0.1);
await app.waitForTimeout(400);
expect(await queueLength(app)).toBe(before);
});
});
+8 -5
View File
@@ -70,7 +70,7 @@ import '@store/theme-store';
// registers the document keydown listener for global shortcuts. // registers the document keydown listener for global shortcuts.
import './src/services/keyboard-shortcut-service'; import './src/services/keyboard-shortcut-service';
import { activateView, deactivateView } from '@utils/view-lifecycle'; import { activateView, deactivateView } from '@utils/view-lifecycle';
import { installLongPressContextMenu } from '@utils/long-press'; import { installTouchGestures } from '@utils/touch-gestures';
import { openQueue, queuePanelElement } from '@utils/open-queue'; import { openQueue, queuePanelElement } from '@utils/open-queue';
import { installTopBarFit } from './src/services/top-bar-fit'; import { installTopBarFit } from './src/services/top-bar-fit';
import { import {
@@ -87,10 +87,13 @@ setBasePath('/dist/webawesome');
// the session. // the session.
registerBundledIcons(); registerBundledIcons();
// The touch equivalent of a right-click, installed once for every menu // Every touch gesture in the app, installed once rather than per
// in the app rather than per component. Harmless on a desktop: it acts // component (plan 019). Harmless on a desktop: it acts on
// on `pointerType === 'touch'` only. // `pointerType === 'touch'` only, per event, so a mouse on a
installLongPressContextMenu(); // touchscreen keeps click-selects / double-click-plays on the very
// same row. An unclaimed long press still becomes a `contextmenu`,
// which is what leaves all fourteen menus untouched by #63.
installTouchGestures();
// The top bar decides what it can afford to show (#143). Here rather // The top bar decides what it can afford to show (#143). Here rather
// than in a component because the bar is light DOM in index.html and // than in a component because the bar is light DOM in index.html and
@@ -14,6 +14,7 @@ import {
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/cover-grid/cover-grid.js'; import '@components/cover-grid/cover-grid.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
@customElement('artist-details') @customElement('artist-details')
export class ArtistDetails extends LitElement { export class ArtistDetails extends LitElement {
@@ -40,7 +41,7 @@ export class ArtistDetails extends LitElement {
/** Tracks the store's cached array reference to detect refreshes. */ /** Tracks the store's cached array reference to detect refreshes. */
private lastAlbumsRef: library.Album[] | null = null; private lastAlbumsRef: library.Album[] | null = null;
static override styles = [designTokens, css` static override styles = [designTokens, backButton, css`
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -66,31 +67,6 @@ export class ArtistDetails extends LitElement {
); );
} }
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon { .back-button wa-icon {
font-size: 16px; /* back button — outside type scale */ font-size: 16px; /* back button — outside type scale */
} }
@@ -45,18 +45,6 @@ export class SeekBar extends LitElement {
private showRemaining: boolean = true; private showRemaining: boolean = true;
static override styles = [designTokens, waSliderLabel, css` static override styles = [designTokens, waSliderLabel, css`
/* 12px below the phone breakpoint. The bottom bar's seek bar is
display:none there (016 B2 phase 1), so the only instance a
viewport media query can reach at that width is the full-screen
now-playing view's -- which is exactly the one a thumb uses.
The track size lives on wa-slider inside this shadow root, so a
custom property set by the host would not reach it. */
@media (max-width: 599px) {
wa-slider {
--track-size: 12px;
}
}
wa-slider { wa-slider {
--track-size: 6px; --track-size: 6px;
flex: 1; flex: 1;
@@ -80,6 +68,57 @@ export class SeekBar extends LitElement {
background: var(--yj-bg-base, black); background: var(--yj-bg-base, black);
} }
/* The phone's seek bar, and this block is last on purpose.
A media query adds no specificity, so this lived above the plain
"wa-slider" rule and lost to it at every width: the 12px track it
asks for had never once applied, and the bar measured 261x6 on
the device while the source said 12. That is index.css's rule
("the phone section is last on purpose") met inside a component's
own stylesheet, and nothing renders differently in any tier here
to say so.
The bottom bar's seek bar is display:none below this width (016
B2 phase 1), so the only instance a viewport media query can
reach is the full-screen now-playing view's -- which is exactly
the one a thumb uses. The desktop bar keeps its 6px, where a
mouse is precise and the thickness is right.
The painted track and the thing you can hit are allowed to
differ, and a slider is the clearest case where they should: 12px
is a progress bar you can see, and 44px is the app's touch floor
(#56). A 44px-*thick* bar would be wrong-looking and would cost
the album art the vertical space #51 spent an issue recovering.
Two things about how the target is built.
The padding goes on ::part(slider) rather than on the host,
because that inner div is what carries the gesture -- it has 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. Both halves are measured: the
seek row is 19px (its clocks, not the track, decide that) and the
play button's top edge is 8px below it, so the target takes the
space *above*, where .art is a non-interactive div. Growing the
row instead cost the art 25px of 143. Verified on the device at
424x439: hit area 44px, painted track 12px, row still 19px, art
still 143px, 8px 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. */
@media (max-width: 599px) {
wa-slider {
--track-size: 12px;
}
wa-slider::part(slider) {
padding-block: 28px 4px;
margin-block: -28px -4px;
}
}
#seek-bar-container { #seek-bar-container {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -250,13 +250,20 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
} }
/* Collapsible-section toggle used in the Pending header — /* Collapsible-section toggle used in the Pending header —
transparent button that inherits the header's type. */ transparent button that inherits the header's type.
187x**15** before this (#186), which was the smallest
control measured anywhere in the app until the column
arrows were counted. It is transparent and full-width
already, so the floor costs it a height and nothing
else. */
.section-toggle { .section-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
min-block-size: 44px;
padding: 0; padding: 0;
background: transparent; background: transparent;
border: 0; border: 0;
@@ -274,6 +281,8 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-tertiary, #888); color: var(--yj-text-tertiary, #888);
} }
/* 32x18, and it has no background until hover -- so the
padding out to a square target is invisible (#186). */
.folders-menu-trigger { .folders-menu-trigger {
background: transparent; background: transparent;
border: 0; border: 0;
@@ -281,6 +290,8 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
font-size: 1.1rem; font-size: 1.1rem;
line-height: 1; line-height: 1;
padding: 0.1rem 0.4rem; padding: 0.1rem 0.4rem;
min-inline-size: 44px;
min-block-size: 44px;
border-radius: 3px; border-radius: 3px;
cursor: pointer; cursor: pointer;
} }
@@ -293,7 +304,10 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
.folders-refresh-trigger { .folders-refresh-trigger {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
font-size: 0.95rem; font-size: 0.95rem;
min-inline-size: 44px;
min-block-size: 44px;
} }
.folders-refresh-trigger:disabled { .folders-refresh-trigger:disabled {
@@ -85,6 +85,21 @@ export class ConfigField extends LitElement {
gap: 0.5em; gap: 0.5em;
} }
/* Every control here meets the app's 44px touch floor (#186).
This is the shape every row in Settings uses, so it is the
one rule that covers the most controls -- and it is the
*cheapest* place to reach the floor, because there is no
overflow fit on this page. The page header's had one (#69),
which is why that pass had to grow padding and hand the
width back with a negative margin; here the control is a
block in a column and a taller box costs nothing but the
height it takes.
Measured on the reference device before this: the select
335x30, the text and number inputs the same, the browse
button 30 tall, the colour swatch 33x33 and the toggle
**34x19**. */
input[type='text'], input[type='text'],
input[type='number'] { input[type='number'] {
background: var(--yj-bg-elevated, #343a40); background: var(--yj-bg-elevated, #343a40);
@@ -95,6 +110,7 @@ export class ConfigField extends LitElement {
font-size: 0.85em; font-size: 0.85em;
font-family: inherit; font-family: inherit;
min-width: 0; min-width: 0;
min-block-size: 44px;
flex: 1; flex: 1;
} }
@@ -117,6 +133,7 @@ export class ConfigField extends LitElement {
font-size: 0.85em; font-size: 0.85em;
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
min-block-size: 44px;
flex: 1; flex: 1;
} }
@@ -139,6 +156,7 @@ export class ConfigField extends LitElement {
font-size: 0.85em; font-size: 0.85em;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
min-block-size: 44px;
} }
button:hover { button:hover {
@@ -158,8 +176,12 @@ export class ConfigField extends LitElement {
} }
input[type='color'] { input[type='color'] {
width: 2.5em; /* border-box, or the 2px border makes this 48 and the
height: 2.5em; assertion below reads as passing by four pixels of
border rather than by the rule. */
box-sizing: border-box;
width: 44px;
height: 44px;
border: 2px solid var(--yj-border, #444); border: 2px solid var(--yj-border, #444);
border-radius: 4px; border-radius: 4px;
padding: 0; padding: 0;
@@ -187,12 +209,32 @@ export class ConfigField extends LitElement {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
min-block-size: 44px;
} }
/* The toggle is the one control here whose target and paint
must differ, and it is also the one no sweep can see.
Its <input> is opacity: 0; width: 0; height: 0, so a
walk of every input on the page skips it as a zero-sized
node -- the thing a finger actually hits is this <label>,
which measured **34x19**. That is smaller than anything in
#186's original table and it is absent from it for exactly
that reason.
A 44px pill is not what a switch should look like, so the
box is 44px and the paint is not: .toggle-slider is a
2.5em x 1.4em child centred in it rather than an absolute
fill. The negative inline margins hand the extra width back
to the layout, so the pill stays flush with the right edge
of the inputs in the rows above it -- the header pass's
shape, used here for alignment rather than for a fit. */
.toggle-switch { .toggle-switch {
position: relative; display: grid;
width: 2.5em; place-items: center;
height: 1.4em; inline-size: 44px;
block-size: 44px;
margin-inline: calc((2.5em - 44px) / 2);
} }
.toggle-switch input { .toggle-switch input {
@@ -202,9 +244,10 @@ export class ConfigField extends LitElement {
} }
.toggle-slider { .toggle-slider {
position: absolute; position: relative;
cursor: pointer; cursor: pointer;
inset: 0; inline-size: 2.5em;
block-size: 1.4em;
background: var(--yj-bg-overlay, #495057); background: var(--yj-bg-overlay, #495057);
border-radius: 1em; border-radius: 1em;
transition: background 0.2s; transition: background 0.2s;
@@ -176,6 +176,13 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
cursor: pointer; cursor: pointer;
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
white-space: nowrap; white-space: nowrap;
/* The app's 44px touch floor (#56, #186), stated once for
all 41 buttons this page renders rather than per class.
Height is free here: Settings has no overflow fit, so
the header's "only width is contested" rule does not
bind, and the two classes that need more than a height
say so below. */
min-block-size: 44px;
} }
button:disabled { button:disabled {
@@ -509,11 +516,24 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
margin: 0; margin: 0;
} }
/* The two column lists are the densest thing in the app, and
the density argument is why they are shaped the way they
are rather than simply grown (#186).
Measured on the reference device: the row was already
335x36 -- it is the controls *inside* it that were 16x16 and
**16x14**, the smallest anywhere in this app, 36 of them.
So the fix grows the controls into the row they already
occupy and only takes the row from 36 to 44, which over the
two lists (10 and 19 items) is 232px of extra scroll on a
439px screen. Growing each control to its own 44px row
instead would have cost four screens. */
.column-item { .column-item {
display: flex; display: flex;
align-items: center; align-items: stretch;
gap: 0.5em; gap: 0.5em;
padding: 0.5em 0.75em; padding: 0 0.75em;
min-block-size: 44px;
border-bottom: 1px solid border-bottom: 1px solid
var(--yj-border-subtle, #333); var(--yj-border-subtle, #333);
font-size: 0.85em; font-size: 0.85em;
@@ -531,8 +551,19 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-tertiary, #888); color: var(--yj-text-tertiary, #888);
} }
/* A native checkbox cannot grow its hit area without growing
its paint, and a 44px checkbox is not what anyone wants. So
the target is the label instead: .column-label is a real
<label for> now, which makes the column's *name* the thing
you tap -- ~250x44 rather than 16x16.
That is the argument config-field already makes one file
over for its own labels: "a real label association also
makes the label text a click target for the control, which
is behaviour, not annotation". Here it is the whole fix. */
.column-toggle { .column-toggle {
cursor: pointer; cursor: pointer;
align-self: center;
accent-color: var( accent-color: var(
--yj-accent, --yj-accent,
#ffd43b #ffd43b
@@ -541,20 +572,32 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
.column-label { .column-label {
flex: 1; flex: 1;
display: flex;
align-items: center;
cursor: pointer;
min-block-size: 44px;
} }
.view-note { .view-note {
color: var(--yj-text-tertiary, #888); color: var(--yj-text-tertiary, #888);
font-size: var(--yj-font-size-sm, 0.85rem); font-size: var(--yj-font-size-sm, 0.85rem);
margin-left: auto; margin-left: auto;
/* The row stretches its children so the label can be a
full-height target; this is text, not a target. */
align-self: center;
} }
.column-arrows { .column-arrows {
display: flex; display: flex;
align-items: stretch;
gap: 0.15em; gap: 0.15em;
margin-left: auto; margin-left: auto;
} }
/* 16x14 before this, and they carry background: none and a
transparent border -- so padding out to 44px grows the
target and changes nothing anyone can see until hover,
which is precisely what #186's Direction asks for. */
.column-arrow-btn { .column-arrow-btn {
background: none; background: none;
border: 1px solid transparent; border: 1px solid transparent;
@@ -564,6 +607,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
font-size: 0.65em; font-size: 0.65em;
line-height: 1; line-height: 1;
padding: 0.2em 0.35em; padding: 0.2em 0.35em;
min-inline-size: 44px;
min-block-size: 44px;
transition: transition:
color 0.15s, color 0.15s,
border-color 0.15s; border-color 0.15s;
@@ -711,6 +756,11 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
letter-spacing: 2px; letter-spacing: 2px;
border-radius: 4px; border-radius: 4px;
/* Square, so it needs the width too -- the shared rule
above only gives it a height. It was 31x31, and it is
the only route to "Remove library", which is the case
#55 settled one component over: the way out is 44px. */
min-inline-size: 44px;
} }
.overflow-btn:hover { .overflow-btn:hover {
@@ -2003,6 +2053,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
class="column-item ${checked ? 'enabled' : 'disabled'}" class="column-item ${checked ? 'enabled' : 'disabled'}"
> >
<input <input
id="view-${v.id}"
type="checkbox" type="checkbox"
class="column-toggle" class="column-toggle"
aria-label="Show ${v.label} in the navigation" aria-label="Show ${v.label} in the navigation"
@@ -2014,9 +2065,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
(e.target as HTMLInputElement).checked, (e.target as HTMLInputElement).checked,
)} )}
/> />
<span class="column-label"> <label class="column-label" for="view-${v.id}">
${v.label} ${v.label}
</span> </label>
${note ${note
? html`<span class="view-note">${note}</span>` ? html`<span class="view-note">${note}</span>`
: nothing} : nothing}
@@ -2212,6 +2263,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
class="column-item ${checked ? 'enabled' : 'disabled'}" class="column-item ${checked ? 'enabled' : 'disabled'}"
> >
<input <input
id="column-${id}"
type="checkbox" type="checkbox"
class="column-toggle" class="column-toggle"
aria-label="Show the ${columnLabel} column" aria-label="Show the ${columnLabel} column"
@@ -2222,11 +2274,12 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
id, id,
)} )}
/> />
<span <label
class="column-label" class="column-label"
for="column-${id}"
> >
${columnLabel} ${columnLabel}
</span> </label>
<span <span
class="column-arrows" class="column-arrows"
> >
@@ -8,6 +8,7 @@ import '@awesome.me/webawesome/dist/components/switch/switch.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js'; import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/callout/callout.js'; import '@awesome.me/webawesome/dist/components/callout/callout.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { waTouchFloor } from '../../styles/wa-touch-floor.css';
import type { import type {
DownloadDescriptor, DownloadDescriptor,
DownloadProvider, DownloadProvider,
@@ -136,6 +137,7 @@ export class DownloadClients extends LitElement {
static override styles = [ static override styles = [
designTokens, designTokens,
waTouchFloor,
css` css`
:host { :host {
display: block; display: block;
@@ -239,12 +241,17 @@ export class DownloadClients extends LitElement {
margin-top: 0.4em; margin-top: 0.4em;
} }
/* The checkbox is 16x16 and cannot grow without becoming
a 44px checkbox, but it is already wrapped in the label
that names it -- so the label is the target and only
needs the height (#186). Eight of them. */
.format-option { .format-option {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.4em; gap: 0.4em;
font-size: 0.9em; font-size: 0.9em;
cursor: pointer; cursor: pointer;
min-block-size: 44px;
} }
`, `,
]; ];
@@ -25,6 +25,11 @@ export class ShortcutCapture extends LitElement {
:host { :host {
display: inline-block; display: inline-block;
} }
/* 80x25, twenty-six of them -- the most numerous control on
the Settings page after the column lists (#186). The floor
is a height here and nothing else: the width was already
past it, and the type stays where it is so a shortcut still
reads as a key rather than as a button. */
button { button {
font-family: inherit; font-family: inherit;
font-size: var(--yj-text-sm, 13px); font-size: var(--yj-text-sm, 13px);
@@ -35,6 +40,7 @@ export class ShortcutCapture extends LitElement {
color: var(--yj-text-primary, #eee); color: var(--yj-text-primary, #eee);
cursor: pointer; cursor: pointer;
min-width: 80px; min-width: 80px;
min-height: 44px;
text-align: center; text-align: center;
transition: transition:
border-color 0.15s, border-color 0.15s,
@@ -61,6 +67,11 @@ export class ShortcutCapture extends LitElement {
opacity: 0.7; opacity: 0.7;
} }
} }
/* Reset renders only for a rebound shortcut, so a sweep of a
freshly-installed app never sees it -- it is not in #186's
tables for that reason, and it is a touch target the moment
anybody uses the feature. It also has no background, so the
padding out to 44px is invisible. */
.reset-btn { .reset-btn {
font-size: var(--yj-text-xs, 11px); font-size: var(--yj-text-xs, 11px);
padding: 2px 6px; padding: 2px 6px;
@@ -69,7 +80,8 @@ export class ShortcutCapture extends LitElement {
background: transparent; background: transparent;
color: var(--yj-text-tertiary, #888); color: var(--yj-text-tertiary, #888);
cursor: pointer; cursor: pointer;
min-width: auto; min-width: 44px;
min-height: 44px;
opacity: 0; opacity: 0;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
@@ -93,8 +93,26 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.08)); border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
} }
/* 85x34 and 96x34 before this (#186). A tab is the only
route to the panel it names, so it is the last control
that should be hard to hit -- and the underline that
marks the active one is drawn on the bottom border,
which a taller box moves further from the label. So the
height goes on *padding*, keeping the border against
the label rather than 10px below a centred one.
The min-size is the floor and is not redundant: padding
alone made this 44px here and **43px in CI**, because
the total is 13 + 13 + 2 + whatever line box the font
gives 13px text, and ubuntu:24.04's is a pixel shorter
than this machine's. A height computed from a font's
line box is not a height you control -- the same
mistake #195 made about a layout property measured on
one engine, one layer down, and caught here by the test
rather than by a person. */
.tab { .tab {
padding: 8px 14px; min-block-size: 44px;
padding: 13px 14px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--yj-text-secondary, #b3b3b3); color: var(--yj-text-secondary, #b3b3b3);
@@ -2,6 +2,7 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js'; import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { srOnly } from '../../styles/sr-only.css'; import { srOnly } from '../../styles/sr-only.css';
import { unownedLabel, unownedStyles } from '@utils/ownership'; import { unownedLabel, unownedStyles } from '@utils/ownership';
import { import {
@@ -355,6 +356,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
static override styles = [ static override styles = [
designTokens, designTokens,
backButton,
exploreLinkStyles, exploreLinkStyles,
contextMenuStyles, contextMenuStyles,
srOnly, srOnly,
@@ -379,25 +381,6 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
var(--yj-border-subtle, rgba(255, 255, 255, 0.06)); var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
} }
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
.back-button wa-icon { .back-button wa-icon {
font-size: 16px; font-size: 16px;
} }
@@ -3,6 +3,7 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js'; import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { import {
LookupArtist, LookupArtist,
BrowseReleaseGroups, BrowseReleaseGroups,
@@ -266,6 +267,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
static override styles = [ static override styles = [
designTokens, designTokens,
backButton,
exploreLinkStyles, exploreLinkStyles,
contextMenuStyles, contextMenuStyles,
unownedStyles, unownedStyles,
@@ -289,25 +291,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
var(--yj-border-subtle, rgba(255, 255, 255, 0.06)); var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
} }
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
.back-button wa-icon { .back-button wa-icon {
font-size: 16px; font-size: 16px;
} }
@@ -256,15 +256,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
margin-bottom: 10px; margin-bottom: 10px;
} }
/* 89x26 and 79x26 before this (#186). */
.search-mode-tab { .search-mode-tab {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
gap: 6px; gap: 6px;
background: none; background: none;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 6px; border-radius: 6px;
color: var(--yj-text-tertiary, #888); color: var(--yj-text-tertiary, #888);
cursor: pointer; cursor: pointer;
min-block-size: 44px;
padding: 5px 12px; padding: 5px 12px;
font-size: var(--yj-text-sm); font-size: var(--yj-text-sm);
font-family: inherit; font-family: inherit;
@@ -289,7 +292,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
border-radius: 6px; border-radius: 6px;
padding: 0 12px; padding: 0 12px;
gap: 8px; gap: 8px;
height: 36px; min-height: 44px;
max-width: 520px; max-width: 520px;
transition: border-color 0.15s ease; transition: border-color 0.15s ease;
} }
@@ -364,8 +367,15 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
flex-shrink: 0; flex-shrink: 0;
} }
/* The input measured 325x**18** and the box around it 36,
which is two faults rather than one (#186): the row was
under the floor, and the input did not fill it, so eight
of those pixels were not a target at all. The container
is 44 and the input stretches to it -- a tap anywhere in
the box now lands on the input rather than beside it. */
input { input {
flex: 1; flex: 1;
align-self: stretch;
background: none; background: none;
border: none; border: none;
outline: none; outline: none;
@@ -379,15 +389,21 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
color: var(--yj-text-tertiary, #888); color: var(--yj-text-tertiary, #888);
} }
/* No background until hover, so the target grows and the
glyph does not. It is inside a 44px box already, hence
the width alone. */
.clear-button { .clear-button {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
align-self: stretch;
background: none; background: none;
border: none; border: none;
color: var(--yj-text-tertiary, #888); color: var(--yj-text-tertiary, #888);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
min-inline-size: 44px;
margin-inline-end: -12px;
font-size: var(--yj-text-sm); font-size: var(--yj-text-sm);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -15,6 +15,7 @@ import { describeError } from '@utils/describe-error';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/track-list/track-list.js'; import '@components/track-list/track-list.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { list } from '@utils/binding'; import { list } from '@utils/binding';
@customElement('genre-details') @customElement('genre-details')
@@ -37,7 +38,7 @@ export class GenreDetails extends LitElement {
private scanCompleteCleanup: (() => void) | null = private scanCompleteCleanup: (() => void) | null =
null; null;
static override styles = [designTokens, css` static override styles = [designTokens, backButton, css`
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -77,31 +78,6 @@ export class GenreDetails extends LitElement {
); );
} }
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon { .back-button wa-icon {
font-size: 16px; /* back button — outside type scale */ font-size: 16px; /* back button — outside type scale */
} }
@@ -166,7 +166,7 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
* gated on the device having hover rather than on width. A * gated on the device having hover rather than on width. A
* touch long-press synthesises a hover state in the WebView, * touch long-press synthesises a hover state in the WebView,
* so on a phone it flashed into view during the 500ms hold * so on a phone it flashed into view during the 500ms hold
* that utils/long-press.ts is measuring for a context menu * that utils/touch-gestures.ts is measuring for a long press
* a control appearing because you were reaching for a * a control appearing because you were reaching for a
* different one. A phone user taps the album and plays from * different one. A phone user taps the album and plays from
* the detail view, so there is nothing to replace it with. * the detail view, so there is nothing to replace it with.
@@ -23,8 +23,14 @@ export class LibraryFilter extends LitElement {
align-items: center; align-items: center;
} }
/* 120x32 on the reference device (#186). This control has two
placements since #57 -- the desktop top bar and Settings ->
Libraries -- and it is the only route to setSelectedLibrary
in either, so it is one of the controls #148 argued must not
simply be taken away. It is one component, so it reaches the
floor in one place. */
select { select {
height: 32px; min-height: 44px;
padding: 0 8px; padding: 0 8px;
border-radius: 6px; border-radius: 6px;
border: 1px solid border: 1px solid
@@ -298,6 +298,47 @@ export class PageHeader extends LitElement {
flex-shrink: 0; flex-shrink: 0;
} }
/* Every control in this header meets the app's 44px touch
floor -- the number #56 set for the transport and the
queue header already keeps (#186).
It is min-size rather than padding with a negative
margin, which is what the seek bar needed (#187), and
the difference is worth stating because it decides
whether targets can collide. 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 can overlap by
construction.
There is no phone branch. With the target being the box,
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 the reason this
component has never had one. It also avoids a media
query that no tier here renders, which is exactly how
the seek bar's phone rule came to be dead for months.
**The height is the box and the width is not**, and that
asymmetry is the whole of what the overflow fit below
cares about. That pass measures inline size, so a taller
control costs it nothing and a wider one costs it
directly. Growing the two square controls to 44px wide
added 22px, which fits at every width Chromium was
checked at and clipped the overflow trigger at 320px in
**WebKit** -- the engine closest to what actually ships,
and the one no machine here can run. So the horizontal
half is padding with the margin cancelling it, which is
what the issue asked for in the first place: the target
grows and the layout does not.
The cost is that a horizontal target can now overlap a
neighbour, which the box version could not. The arrow's
is deliberately lopsided for the seek bar's reason
(#187): 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. */
.sort select { .sort select {
font: inherit; font: inherit;
color: inherit; color: inherit;
@@ -306,6 +347,7 @@ export class PageHeader extends LitElement {
border-radius: 4px; border-radius: 4px;
padding: 3px 6px; padding: 3px 6px;
cursor: pointer; cursor: pointer;
min-block-size: 44px;
} }
.sort-dir { .sort-dir {
@@ -318,6 +360,18 @@ export class PageHeader extends LitElement {
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
padding: 3px 5px; padding: 3px 5px;
/* 28x21 before this, the smallest control in the
header and the only one that failed the floor in
both directions.
Vertically the box grows, because the header has the
room and nothing measures it. Horizontally the box
must not: 28 + 2 + 14 is a 44px target over a 28px
layout box, weighted right because the select is 6px
to the left. */
min-block-size: 44px;
padding-inline: 5px 21px;
margin-inline: 0 -16px;
} }
.sort-dir:hover { .sort-dir:hover {
@@ -377,10 +431,20 @@ export class PageHeader extends LitElement {
gap: 6px; gap: 6px;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
justify-content: center;
min-block-size: 44px;
} }
.more-button { .more-button {
padding: 6px 10px; padding: 6px 10px;
/* 38x27, and it is the route to every collapsed
action, so it is the last control that should be
hard to hit -- and the one WebKit clipped at 320px
when this was 6px wider as a box. 38 + 3 + 3 is a
44px target over a 38px layout box; the actions row
has an 8px gap, so this one can be symmetric. */
padding-inline: 13px;
margin-inline: -3px;
} }
/* The display: flex above outranks the UA stylesheet's /* The display: flex above outranks the UA stylesheet's
@@ -38,6 +38,10 @@ import {
} from '@utils/context-menu-controller.js'; } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js'; import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows'; import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import type { GestureEvent } from '@utils/touch-gestures';
import { SwipeToQueue, swipeRevealStyles } from '@utils/swipe-to-queue';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { FavoritesController } from '@store/controllers/favorites-controller'; import { FavoritesController } from '@store/controllers/favorites-controller';
import { notificationStore } from '@store/notification-store'; import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error'; import { describeError } from '@utils/describe-error';
@@ -71,10 +75,14 @@ import {
exploreLinkStyles, exploreLinkStyles,
} from '@utils/explore-link'; } from '@utils/explore-link';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { backButton } from '../../styles/back-button.css';
import { list } from '@utils/binding'; import { list } from '@utils/binding';
import { import {
ICON_PLAY,
ICON_PLAYLIST, ICON_PLAYLIST,
ICON_QUEUE, ICON_QUEUE,
ICON_REMOVE,
} from '@utils/icon-language'; } from '@utils/icon-language';
/** One playlist row: the track and its position in the *playlist*, /** One playlist row: the track and its position in the *playlist*,
@@ -421,6 +429,127 @@ export class PlaylistDetails
queueStore.setQueue(filePaths, trackIndex, false, { type: 'playlist', id: this.playlistId, label: this.playlistName }); queueStore.setQueue(filePaths, trackIndex, false, { type: 'playlist', id: this.playlistId, label: this.playlistName });
} }
// =================================================================
// A finger on a playlist row (plan 019 phase 3, #63)
// =================================================================
/** The row an announced gesture is on, with its track. */
private rowFromGesture(
e: Event,
): { index: number; track: playlist.Track } | null {
const row = (e.target as HTMLElement).closest(
'.track-item',
) as HTMLElement | null;
if (!row) return null;
const index = Number(row.dataset.index);
const track = this.tracks[index];
if (Number.isNaN(index) || !track) return null;
return { index, track };
}
/**
* A tap plays the playlist from that row.
*
* The same thing a double-click does, which is the rule the whole
* app follows: activating one row plays the list the row is in,
* from that row, rather than a queue of one that stops when the
* song ends.
*/
private onRowTap = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
if (this.selection.selectionMode) {
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.toggleInMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
return;
}
// A missing file has nothing to play, so the tap is left
// unclaimed and falls through to the click that selects it --
// which is what a mouse does here and the only useful thing a
// phantom row can answer.
if (hit.track.Phantom) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.handleTrackDblClick(hit.index);
};
private onRowLongPress = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.enterSelectionMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
};
/**
* Swipe a row right to queue it.
*
* `track-list`'s rule, one list over: one row is a position and
* several rows are an explicit choice, and a swipe never changes
* the selection it reads.
*/
private swipe = new SwipeToQueue(this, {
resolve: (e) => {
const hit = this.rowFromGesture(e);
// A phantom has no file to queue, so there is nothing for
// the reveal to promise.
if (!hit || hit.track.Phantom) return null;
const selected = this.selection.getSelectedIndices();
const many =
selected.length > 1 && selected.includes(hit.index);
const filePaths = many
? this.getSelectedFilePaths()
: [hit.track.FilePath];
return { index: hit.index, filePaths, label: hit.track.Title };
},
repaint: () => this.virtualizer?.requestUpdate(),
});
/** The three worth a thumb; the sheet behind "More" is the rest. */
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'add-to-queue', label: 'Add to queue', icon: ICON_QUEUE },
{ id: 'remove', label: 'Remove', icon: ICON_REMOVE, danger: true },
];
private renderSelectionBar() {
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${PlaylistDetails.SELECTION_ACTIONS}
@selection-exit=${this.onSelectionExit}
@selection-action=${(e: CustomEvent<{ id: string }>) =>
this.onContextMenuAction(e.detail.id)}
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
></selection-bar>
`;
}
private onSelectionExit = () => {
this.selection.exitSelectionMode();
this.virtualizer?.requestUpdate();
};
private handleTrackContextMenu( private handleTrackContextMenu(
e: MouseEvent, e: MouseEvent,
trackIndex: number, trackIndex: number,
@@ -954,8 +1083,11 @@ export class PlaylistDetails
static override styles = [ static override styles = [
designTokens, designTokens,
srOnly,
backButton,
contextMenuStyles, contextMenuStyles,
exploreLinkStyles, exploreLinkStyles,
swipeRevealStyles,
css` css`
:host { :host {
display: flex; display: flex;
@@ -981,31 +1113,6 @@ export class PlaylistDetails
); );
} }
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon { .back-button wa-icon {
font-size: 16px; font-size: 16px;
} }
@@ -1159,6 +1266,9 @@ export class PlaylistDetails
.track-item { .track-item {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
/* The swipe reveal is absolute inside the row. */
position: relative;
overflow: hidden;
} }
.track-header { .track-header {
@@ -1456,6 +1566,9 @@ export class PlaylistDetails
<div class="header-cell col-album">Album</div> <div class="header-cell col-album">Album</div>
<div class="header-cell col-duration">Duration</div> <div class="header-cell col-duration">Duration</div>
</div> </div>
<div class="sr-only" role="status" aria-live="polite">
${this.swipe.announcement}
</div>
<lit-virtualizer <lit-virtualizer
class="track-scroller" class="track-scroller"
role="listbox" role="listbox"
@@ -1465,7 +1578,13 @@ export class PlaylistDetails
.renderItem=${this.renderRow} .renderItem=${this.renderRow}
.keyFunction=${this.rowKey} .keyFunction=${this.rowKey}
.layout=${this.flowLayout} .layout=${this.flowLayout}
@yj-tap=${this.onRowTap}
@yj-long-press=${this.onRowLongPress}
@yj-swipe-start=${this.swipe.onSwipeStart}
@yj-swipe-move=${this.swipe.onSwipeMove}
@yj-swipe-end=${this.swipe.onSwipeEnd}
></lit-virtualizer> ></lit-virtualizer>
${this.renderSelectionBar()}
`; `;
} }
@@ -1487,6 +1606,7 @@ export class PlaylistDetails
active ? 'active' : '', active ? 'active' : '',
selected ? 'selected' : '', selected ? 'selected' : '',
isPhantom ? 'phantom' : '', isPhantom ? 'phantom' : '',
this.swipe.isSwiping(trackIndex) ? 'swiping' : '',
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
@@ -1497,6 +1617,7 @@ export class PlaylistDetails
role="option" role="option"
aria-selected=${selected} aria-selected=${selected}
data-index=${trackIndex} data-index=${trackIndex}
data-swipe
tabindex=${trackIndex === this.focusedIndex ? 0 : -1} tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
@keydown=${(e: KeyboardEvent) => @keydown=${(e: KeyboardEvent) =>
this.onRowKeydown(e, trackIndex)} this.onRowKeydown(e, trackIndex)}
@@ -1543,6 +1664,7 @@ export class PlaylistDetails
? nothing ? nothing
: this.onTrackDragEnd} : this.onTrackDragEnd}
> >
${this.swipe.renderReveal(trackIndex)}
${isPhantom ${isPhantom
? html`<div ? html`<div
class="phantom-row" class="phantom-row"
@@ -64,9 +64,14 @@ import {
} from '@utils/explore-link'; } from '@utils/explore-link';
import { import {
ICON_NEW, ICON_NEW,
ICON_PLAY,
ICON_PLAYLIST, ICON_PLAYLIST,
ICON_QUEUE, ICON_QUEUE,
ICON_REMOVE,
} from '@utils/icon-language'; } from '@utils/icon-language';
import type { GestureEvent } from '@utils/touch-gestures';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
/** Above this many tracks, clearing the queue asks first. */ /** Above this many tracks, clearing the queue asks first. */
const CLEAR_CONFIRM_THRESHOLD = 20; const CLEAR_CONFIRM_THRESHOLD = 20;
@@ -844,6 +849,8 @@ export class QueuePanel
virtEl.addEventListener('dragstart', this.onDelegatedDragStart); virtEl.addEventListener('dragstart', this.onDelegatedDragStart);
virtEl.addEventListener('dragend', this.onTrackDragEnd); virtEl.addEventListener('dragend', this.onTrackDragEnd);
virtEl.addEventListener('keydown', this.onDelegatedKeydown); virtEl.addEventListener('keydown', this.onDelegatedKeydown);
virtEl.addEventListener('yj-tap', this.onRowTap);
virtEl.addEventListener('yj-long-press', this.onRowLongPress);
this.delegationAttached = true; this.delegationAttached = true;
} }
@@ -962,6 +969,8 @@ export class QueuePanel
virtEl.removeEventListener('dragstart', this.onDelegatedDragStart); virtEl.removeEventListener('dragstart', this.onDelegatedDragStart);
virtEl.removeEventListener('dragend', this.onTrackDragEnd); virtEl.removeEventListener('dragend', this.onTrackDragEnd);
virtEl.removeEventListener('keydown', this.onDelegatedKeydown); virtEl.removeEventListener('keydown', this.onDelegatedKeydown);
virtEl.removeEventListener('yj-tap', this.onRowTap);
virtEl.removeEventListener('yj-long-press', this.onRowLongPress);
} }
this.delegationAttached = false; this.delegationAttached = false;
} }
@@ -1247,6 +1256,92 @@ export class QueuePanel
this.queue.playAtIndex(index); this.queue.playAtIndex(index);
} }
// =================================================================
// A finger on a queue row (plan 019 phase 3, #63)
// =================================================================
/**
* A tap plays this position in the queue.
*
* `track-list`'s tap sets the queue to the list it was made in;
* copying that here would rebuild the queue from the queue, which
* is not the no-op it looks like -- it would discard the queue's
* source, its shuffle order and everything a user had inserted by
* hand. `playAtIndex` is what a double-click already does, and it
* is what a tap means.
*/
private onRowTap = (e: GestureEvent) => {
const idx = this.resolveTrackIndexFromEvent(e);
if (idx === null) return;
// A control inside the row owns its own tap -- the same rule
// the shortcut service has for a focused control that owns a
// key. The remove button is the one here.
if ((e.target as HTMLElement).closest('.remove-button')) return;
e.preventDefault();
// The roving tab stop follows the finger, or Tab returns to
// wherever the arrows last were rather than to the row that was
// just touched.
this.focusedIndex = idx;
if (this.selection.selectionMode) {
this.selection.toggleInMode(String(idx), idx);
this.virtualizer?.requestUpdate();
return;
}
this.selection.clear();
this.queue.playAtIndex(idx);
};
private onRowLongPress = (e: GestureEvent) => {
const idx = this.resolveTrackIndexFromEvent(e);
if (idx === null) return;
e.preventDefault();
this.focusedIndex = idx;
this.selection.enterSelectionMode(String(idx), idx);
this.virtualizer?.requestUpdate();
};
/**
* The two worth a thumb, and "More" for the rest.
*
* Remove is here rather than left to the overflow because it is
* what a selection in a *queue* is most often made for, and it is
* the action the row's own × offers one row at a time.
*/
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'remove', label: 'Remove', icon: ICON_REMOVE, danger: true },
];
private renderSelectionBar() {
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${QueuePanel.SELECTION_ACTIONS}
@selection-exit=${this.onSelectionExit}
@selection-action=${(e: CustomEvent<{ id: string }>) =>
this.onContextMenuAction(e.detail.id)}
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
></selection-bar>
`;
}
private onSelectionExit = () => {
this.selection.exitSelectionMode();
this.virtualizer?.requestUpdate();
};
private handleTrackContextMenu( private handleTrackContextMenu(
e: MouseEvent, e: MouseEvent,
index: number, index: number,
@@ -2157,6 +2252,7 @@ export class QueuePanel
></lit-virtualizer> ></lit-virtualizer>
`} `}
</div> </div>
${this.renderSelectionBar()}
</div> </div>
<menu-surface <menu-surface
@@ -61,11 +61,32 @@ export class SearchTrigger extends LitElement {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
/* The smallest a touch target should be. The header's /* The app's touch floor, from #56 -- and this is the
own action buttons are smaller because they carry a control that should least have to argue for it: #57
label; this one is a glyph. */ created it as the phone's replacement for the header
min-width: 40px; search box, so it exists *only* where there is a
min-height: 40px; thumb.
It shipped at 40px under a comment calling that "the
smallest a touch target should be", which was the
floor being restated four pixels short rather than a
second opinion about it (#186). The rest of that
comment said the header's own action buttons are
smaller because they carry a label; they are 44px
now too, so that no longer distinguishes anything.
The extra width is a target rather than a box, for
page-header's reason: this button sits in that
header, whose overflow fit (#69) measures inline
size, and four pixels there is four pixels the
trigger for every collapsed action does not get at
320px. Height is free -- nothing measures it. */
min-width: 44px;
min-height: 44px;
/* Border-box, so the 44 above is the whole target and
the margin is what hands the four extra pixels back
to the row. */
margin-inline: -2px;
padding: 0; padding: 0;
background: none; background: none;
border: 1px solid var(--yj-border-subtle, #555); border: 1px solid var(--yj-border-subtle, #555);
@@ -0,0 +1,218 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { ICON_MORE_ACTIONS } from '@utils/icon-language';
/**
* What a selection can have done to it, while a finger is holding one.
*
* This is the context menu (plan 019, #63). Not a second surface
* beside it the same actions, contextualised to whatever is
* selected, in the shape Android puts them in.
*
* #63 asked for a double tap to open the menu instead. That mapping
* costs the app's primary interaction 250ms on every play, because the
* first tap of a double tap is indistinguishable from a single tap
* until the interval expires, and playing a track is ~100ms end to end
* on the reference device. So there is no double tap: a long press
* selects, this says what can be done, and the sheet behind "More" is
* the same `menu-surface` every other menu in the app opens.
*
* Four things about it are load-bearing.
*
* **It is presentational.** It takes a count and a list of actions and
* emits `selection-action` / `selection-exit`; it holds no selection
* and calls no store. The host already owns a `SelectionController`
* and an action handler, and a bar that reached for either would be a
* second definition of what "play the selection" means the fault
* `utils/library-status.ts` exists to have fixed one feature over.
*
* **The bar is only what fits, and "More" is the rest.** A context
* menu can be nine items because it is a sheet; a bar is one row on a
* 424px screen. So the host passes the two or three worth a thumb and
* the overflow opens the menu it already renders, which is what keeps
* every action reachable at every size plan 018's promise, and the
* reason this cannot simply drop the long tail.
*
* **Its controls are 44px** (#56, #186), and the count is a live
* region: the number changes under the user's finger as they tap rows,
* and nothing else on screen announces it.
*
* **Escape leaves the mode, from here rather than from each host.**
* This element exists only while the mode does, so it is the one place
* a dismissal can be attached and detached with the thing it
* dismisses. It is the same exception the overlaid queue's Escape is.
*
* **It renders nothing at zero.** The mode ends when the last row is
* deselected `SelectionController.toggleInMode` is where that is
* decided so a bar with a count of none is a state this should never
* be asked to draw, and drawing it anyway would hide the fact that it
* has been.
*/
export interface SelectionAction {
id: string;
label: string;
icon: string;
danger?: boolean;
}
@customElement('selection-bar')
export class SelectionBar extends LitElement {
static override styles = [
designTokens,
css`
:host {
display: block;
}
.bar {
display: flex;
align-items: center;
gap: 0.25em;
padding: 0.25em 0.5em;
background: var(--yj-bg-elevated, #343a40);
border-top: 1px solid var(--yj-border-subtle, #333);
}
.count {
flex: 1;
min-width: 0;
font-size: var(--yj-text-md);
font-weight: 600;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 44px, from #56 and #186 -- this is a bar a thumb uses. */
button {
display: flex;
align-items: center;
justify-content: center;
min-inline-size: 44px;
min-block-size: 44px;
padding: 0 0.5em;
border: none;
border-radius: 4px;
background: none;
color: var(--yj-text-primary, #fff);
font-family: inherit;
font-size: var(--yj-text-md);
cursor: pointer;
}
button:hover {
background: var(--yj-bg-overlay, #495057);
}
button.danger {
color: var(--yj-error-text, #ff8787);
}
`,
];
/** How many items are selected. Zero renders nothing. */
@property({ type: Number }) count = 0;
/** The actions worth a thumb. The rest live behind "More". */
@property({ attribute: false }) actions: SelectionAction[] = [];
private emit(name: string, detail?: unknown) {
this.dispatchEvent(
new CustomEvent(name, { detail, bubbles: true, composed: true }),
);
}
/**
* Escape leaves the mode.
*
* A mode changes what a tap means, so it has to have an exit that
* is not "find the ×" -- and this is the documented exception to
* the app's one-keyboard-authority rule, on exactly the grounds
* the overlaid queue's Escape is: **it is a dismissal, not a
* shortcut**, so it is not a panel-scoped binding and it is
* attached only while there is something to dismiss. Putting it
* here rather than in each host is what gives all four surfaces
* the same answer, since this element exists only while the mode
* does.
*
* The platform's own back gesture is the other half of that and is
* deliberately *not* here: the shell owns the history stack
* (#6/#55), and a component reaching for `history` itself is how
* two stacks come to disagree about what one press means -- the
* fault that deleted `navStack`. See #200.
*/
private onKeydown = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || this.count <= 0) return;
e.preventDefault();
e.stopPropagation();
this.emit('selection-exit');
};
override connectedCallback() {
super.connectedCallback();
document.addEventListener('keydown', this.onKeydown, true);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('keydown', this.onKeydown, true);
}
override render() {
if (this.count <= 0) return nothing;
const noun = this.count === 1 ? 'track' : 'tracks';
return html`
<div class="bar" role="toolbar" aria-label="Selection actions">
<button
aria-label="Leave selection"
@click=${() => this.emit('selection-exit')}
>
<wa-icon name="xmark"></wa-icon>
</button>
<span class="count" role="status" aria-live="polite">
${this.count.toLocaleString()} ${noun} selected
</span>
${this.actions.map(
(action) => html`
<button
class=${action.danger ? 'danger' : ''}
aria-label=${action.label}
title=${action.label}
@click=${() =>
this.emit('selection-action', { id: action.id })}
>
<wa-icon name=${action.icon}></wa-icon>
</button>
`,
)}
<button
aria-label="More actions"
@click=${(e: MouseEvent) => {
const box = (
e.currentTarget as HTMLElement
).getBoundingClientRect();
this.emit('selection-more', {
x: box.left,
y: box.top,
});
}}
>
<wa-icon name=${ICON_MORE_ACTIONS}></wa-icon>
</button>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'selection-bar': SelectionBar;
}
}
@@ -28,6 +28,10 @@ import {
} from '@utils/context-menu-controller.js'; } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js'; import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows'; import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import type { GestureEvent } from '@utils/touch-gestures';
import { SwipeToQueue, swipeRevealStyles } from '@utils/swipe-to-queue';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { FavoritesController } from '@store/controllers/favorites-controller'; import { FavoritesController } from '@store/controllers/favorites-controller';
import { import {
setDragPayload, setDragPayload,
@@ -62,8 +66,12 @@ import {
} from '@utils/explore-link'; } from '@utils/explore-link';
import '@components/smart-playlist-editor/smart-playlist-editor.js'; import '@components/smart-playlist-editor/smart-playlist-editor.js';
import { designTokens } from '../../styles/tokens.css'; import { designTokens } from '../../styles/tokens.css';
import { backButton } from '../../styles/back-button.css';
import { srOnly } from '../../styles/sr-only.css';
import { list } from '@utils/binding'; import { list } from '@utils/binding';
import { import {
ICON_PLAY,
ICON_PLAY_NEXT,
ICON_PLAYLIST, ICON_PLAYLIST,
ICON_QUEUE, ICON_QUEUE,
ICON_SMART_PLAYLIST, ICON_SMART_PLAYLIST,
@@ -242,8 +250,11 @@ export class SmartPlaylistDetails
static override styles = [ static override styles = [
designTokens, designTokens,
srOnly,
backButton,
contextMenuStyles, contextMenuStyles,
exploreLinkStyles, exploreLinkStyles,
swipeRevealStyles,
css` css`
:host { :host {
display: flex; display: flex;
@@ -269,31 +280,6 @@ export class SmartPlaylistDetails
); );
} }
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(
--yj-bg-hover,
rgba(255, 255, 255, 0.12)
);
}
.back-button wa-icon { .back-button wa-icon {
font-size: 16px; font-size: 16px;
} }
@@ -467,6 +453,9 @@ export class SmartPlaylistDetails
.track-item { .track-item {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
/* The swipe reveal is absolute inside the row. */
position: relative;
overflow: hidden;
} }
.track-header { .track-header {
@@ -943,6 +932,110 @@ export class SmartPlaylistDetails
// Context menu actions // Context menu actions
// ================================================================= // =================================================================
// =================================================================
// A finger on a smart playlist row (plan 019 phase 3, #63)
// =================================================================
/** The row an announced gesture is on, with its track. */
private rowFromGesture(
e: Event,
): { index: number; track: playlist.Track } | null {
const row = (e.target as HTMLElement).closest(
'.track-item',
) as HTMLElement | null;
if (!row) return null;
const index = Number(row.dataset.index);
const track = this.tracks[index];
if (Number.isNaN(index) || !track) return null;
return { index, track };
}
/** A tap plays the playlist from that row -- `playlist-details`'
* rule, and the app's: activating a row plays the list it is in. */
private onRowTap = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
if (this.selection.selectionMode) {
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.toggleInMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
return;
}
// A missing file has nothing to play, so the tap falls through
// to the click that selects it.
if (hit.track.Phantom) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.handleTrackDblClick(hit.index);
};
private onRowLongPress = (e: GestureEvent) => {
const hit = this.rowFromGesture(e);
if (!hit) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.enterSelectionMode(String(hit.index), hit.index);
this.virtualizer?.requestUpdate();
};
private swipe = new SwipeToQueue(this, {
resolve: (e) => {
const hit = this.rowFromGesture(e);
if (!hit || hit.track.Phantom) return null;
const selected = this.selection.getSelectedIndices();
const many =
selected.length > 1 && selected.includes(hit.index);
const filePaths = many
? this.getSelectedFilePaths()
: [hit.track.FilePath];
return { index: hit.index, filePaths, label: hit.track.Title };
},
repaint: () => this.virtualizer?.requestUpdate(),
});
/** The three worth a thumb; the sheet behind "More" is the rest. */
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'add-to-queue', label: 'Add to queue', icon: ICON_QUEUE },
{ id: 'play-next', label: 'Play next', icon: ICON_PLAY_NEXT },
];
private renderSelectionBar() {
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${SmartPlaylistDetails.SELECTION_ACTIONS}
@selection-exit=${this.onSelectionExit}
@selection-action=${(e: CustomEvent<{ id: string }>) =>
this.onContextMenuAction(e.detail.id)}
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
></selection-bar>
`;
}
private onSelectionExit = () => {
this.selection.exitSelectionMode();
this.virtualizer?.requestUpdate();
};
private onContextMenuAction(action: string) { private onContextMenuAction(action: string) {
const filePaths = this.getSelectedFilePaths(); const filePaths = this.getSelectedFilePaths();
@@ -1357,6 +1450,9 @@ export class SmartPlaylistDetails
<div class="header-cell col-album">Album</div> <div class="header-cell col-album">Album</div>
<div class="header-cell col-duration">Duration</div> <div class="header-cell col-duration">Duration</div>
</div> </div>
<div class="sr-only" role="status" aria-live="polite">
${this.swipe.announcement}
</div>
<lit-virtualizer <lit-virtualizer
role="listbox" role="listbox"
aria-label="Smart playlist tracks" aria-label="Smart playlist tracks"
@@ -1365,7 +1461,13 @@ export class SmartPlaylistDetails
.renderItem=${this.renderRow} .renderItem=${this.renderRow}
.keyFunction=${this.rowKey} .keyFunction=${this.rowKey}
.layout=${this.flowLayout} .layout=${this.flowLayout}
@yj-tap=${this.onRowTap}
@yj-long-press=${this.onRowLongPress}
@yj-swipe-start=${this.swipe.onSwipeStart}
@yj-swipe-move=${this.swipe.onSwipeMove}
@yj-swipe-end=${this.swipe.onSwipeEnd}
></lit-virtualizer> ></lit-virtualizer>
${this.renderSelectionBar()}
`; `;
} }
@@ -1387,6 +1489,7 @@ export class SmartPlaylistDetails
active ? 'active' : '', active ? 'active' : '',
selected ? 'selected' : '', selected ? 'selected' : '',
isPhantom ? 'phantom' : '', isPhantom ? 'phantom' : '',
this.swipe.isSwiping(trackIndex) ? 'swiping' : '',
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
@@ -1397,6 +1500,7 @@ export class SmartPlaylistDetails
role="option" role="option"
aria-selected=${selected} aria-selected=${selected}
data-index=${trackIndex} data-index=${trackIndex}
data-swipe
tabindex=${trackIndex === this.focusedIndex ? 0 : -1} tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
@keydown=${(e: KeyboardEvent) => @keydown=${(e: KeyboardEvent) =>
this.onRowKeydown(e, trackIndex)} this.onRowKeydown(e, trackIndex)}
@@ -1431,6 +1535,7 @@ export class SmartPlaylistDetails
? nothing ? nothing
: this.onTrackDragEnd} : this.onTrackDragEnd}
> >
${this.swipe.renderReveal(trackIndex)}
${isPhantom ${isPhantom
? html`<div class="phantom-row"> ? html`<div class="phantom-row">
<wa-icon <wa-icon
@@ -10,6 +10,10 @@ import {
} from 'lit/decorators.js'; } from 'lit/decorators.js';
import { SelectionController } from '@utils/selection-controller'; import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller'; import type { SelectionHost } from '@utils/selection-controller';
import type { GestureEvent } from '@utils/touch-gestures';
import { SwipeToQueue, swipeRevealStyles } from '@utils/swipe-to-queue';
import '@components/selection-bar/selection-bar';
import type { SelectionAction } from '@components/selection-bar/selection-bar';
import { ViewLifecycleMixin } from '@utils/view-lifecycle'; import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { PHONE_QUERY } from '@utils/breakpoints'; import { PHONE_QUERY } from '@utils/breakpoints';
import { import {
@@ -77,7 +81,9 @@ import '@components/playlist-picker/playlist-picker.js';
import type { TrackDetails } from '@components/track-details/track-details.js'; import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js'; import type { CoverArtUrls } from '@components/track-details/track-details.js';
import { import {
ICON_PLAY,
ICON_PLAYLIST, ICON_PLAYLIST,
ICON_PLAY_NEXT,
ICON_QUEUE, ICON_QUEUE,
} from '@utils/icon-language'; } from '@utils/icon-language';
@@ -277,6 +283,29 @@ export class TrackList
* path into it at all (H-5). */ * path into it at all (H-5). */
@state() private focusedIndex = 0; @state() private focusedIndex = 0;
/**
* Swipe a row right to queue it (plan 019 phase 2, #63).
*
* The affordance and the arithmetic are `utils/swipe-to-queue.ts`,
* shared with both playlist detail views; what stays here is what
* only this list knows -- which row an event is on, and what a
* swipe on it means when other rows are selected.
*/
private swipe = new SwipeToQueue(this, {
resolve: (e) => {
const hit = this.resolveTrackFromEvent(e);
if (!hit) return null;
return {
index: hit.index,
filePaths: this.swipeTargetKeys(hit.track.FilePath),
label: hit.track.TrackName,
};
},
repaint: () => this.virtualizer?.requestUpdate(),
});
private handleSelectAll = (): void => { private handleSelectAll = (): void => {
this.selection.selectAll(); this.selection.selectAll();
}; };
@@ -1011,7 +1040,7 @@ export class TrackList
this.requestUpdate(); this.requestUpdate();
}; };
static override styles = [designTokens, srOnly, contextMenuStyles, exploreLinkStyles, css` static override styles = [designTokens, srOnly, contextMenuStyles, exploreLinkStyles, swipeRevealStyles, css`
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1208,6 +1237,7 @@ export class TrackList
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15)); background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
} }
.cell { .cell {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1304,6 +1334,11 @@ export class TrackList
virt.removeEventListener('click', this.onDelegatedClick); virt.removeEventListener('click', this.onDelegatedClick);
virt.removeEventListener('dblclick', this.onDelegatedDblClick); virt.removeEventListener('dblclick', this.onDelegatedDblClick);
virt.removeEventListener('contextmenu', this.onDelegatedContextMenu); virt.removeEventListener('contextmenu', this.onDelegatedContextMenu);
virt.removeEventListener('yj-tap', this.onRowTap);
virt.removeEventListener('yj-long-press', this.onRowLongPress);
virt.removeEventListener('yj-swipe-start', this.swipe.onSwipeStart);
virt.removeEventListener('yj-swipe-move', this.swipe.onSwipeMove);
virt.removeEventListener('yj-swipe-end', this.swipe.onSwipeEnd);
virt.removeEventListener('dragstart', this.onDelegatedDragStart); virt.removeEventListener('dragstart', this.onDelegatedDragStart);
virt.removeEventListener('dragend', this.onTrackDragEnd); virt.removeEventListener('dragend', this.onTrackDragEnd);
} }
@@ -1445,6 +1480,14 @@ export class TrackList
virt.addEventListener('contextmenu', this.onDelegatedContextMenu); virt.addEventListener('contextmenu', this.onDelegatedContextMenu);
virt.addEventListener('dragstart', this.onDelegatedDragStart); virt.addEventListener('dragstart', this.onDelegatedDragStart);
virt.addEventListener('dragend', this.onTrackDragEnd); virt.addEventListener('dragend', this.onTrackDragEnd);
// Delegated like the rest: the gesture layer dispatches on the
// element the finger landed on, composed, so it arrives here
// through the same path a real click takes (plan 019).
virt.addEventListener('yj-tap', this.onRowTap);
virt.addEventListener('yj-long-press', this.onRowLongPress);
virt.addEventListener('yj-swipe-start', this.swipe.onSwipeStart);
virt.addEventListener('yj-swipe-move', this.swipe.onSwipeMove);
virt.addEventListener('yj-swipe-end', this.swipe.onSwipeEnd);
this.delegationAttached = true; this.delegationAttached = true;
} }
@@ -1708,6 +1751,86 @@ export class TrackList
if (hit) this.onTrackContextMenu(e, hit.track); if (hit) this.onTrackContextMenu(e, hit.track);
}; };
/**
* A finger tapped a row (plan 019, #63).
*
* On a desktop a click selects and a double-click plays; a finger
* inverts that, because there is no second button and no modifier
* key, so the primary action has to be the primary gesture.
*
* Claiming the gesture (`preventDefault`) is what tells the layer
* to swallow the click behind it -- otherwise playing a track
* would also select it, and the row would end up in both states.
* A tap this does *not* claim falls through as an ordinary click,
* which is what keeps the favourite icon working.
*/
private onRowTap = (e: GestureEvent) => {
const hit = this.resolveTrackFromEvent(e);
if (!hit) return;
// A control inside the row owns its own tap. The same rule the
// shortcut service has for a focused control that owns a key,
// and without it the 44px favourite target (#56) becomes a
// 44px play target.
if ((e.target as HTMLElement).closest('.fav-icon')) return;
e.preventDefault();
this.focusedIndex = hit.index;
if (this.selection.selectionMode) {
this.selection.toggleInMode(hit.track.FilePath, hit.index);
this.virtualizer?.requestUpdate();
return;
}
this.playFromRow(hit.index);
};
/**
* A finger held a row still for half a second.
*
* Claiming this is what makes it *selection mode* rather than the
* context menu it has been since plan 016 -- an unclaimed
* `yj-long-press` still becomes a `contextmenu`, which is how the
* card grids and Explore keep the behaviour they have.
*/
private onRowLongPress = (e: GestureEvent) => {
const hit = this.resolveTrackFromEvent(e);
if (!hit) return;
e.preventDefault();
this.focusedIndex = hit.index;
this.selection.enterSelectionMode(hit.track.FilePath, hit.index);
this.virtualizer?.requestUpdate();
};
/**
* What a swipe on this row would queue.
*
* The same rule the context menu answers with, and it has to be:
* **one row is a position, several rows are an explicit choice.**
* A finger that swipes a row which is part of a selection of forty
* has not un-made that selection, and queueing the one row it
* touched would quietly contradict the bar above saying forty are
* selected. A swipe on a row *outside* the selection is a statement
* about that row, exactly as a right-click on one is -- and unlike
* a right-click it does not move the selection, because a swipe is
* not a way of selecting anything.
*/
private swipeTargetKeys(filePath: string): string[] {
if (
this.selection.selectionCount > 1 &&
this.selection.isSelected(filePath)
) {
return this.selection.getSelectedKeysOrdered();
}
return [filePath];
}
private onDelegatedDragStart = (e: DragEvent) => { private onDelegatedDragStart = (e: DragEvent) => {
const hit = this.resolveTrackFromEvent(e); const hit = this.resolveTrackFromEvent(e);
@@ -2129,6 +2252,7 @@ export class TrackList
'track-row': true, 'track-row': true,
active, active,
selected, selected,
swiping: this.swipe.isSwiping(index),
})} })}
role="row" role="row"
aria-rowindex=${index + 1} aria-rowindex=${index + 1}
@@ -2138,8 +2262,10 @@ export class TrackList
draggable="true" draggable="true"
data-index=${index} data-index=${index}
data-testid="track-row" data-testid="track-row"
data-swipe
data-file-path=${track.FilePath} data-file-path=${track.FilePath}
> >
${this.swipe.renderReveal(index)}
<div <div
role="gridcell" role="gridcell"
class=${classMap({ class=${classMap({
@@ -2238,6 +2364,45 @@ export class TrackList
this.saveSortPreferences(); this.saveSortPreferences();
}; };
/**
* The three worth a thumb. Everything else is behind "More",
* which opens the context menu this list already renders.
*
* A bar is one row on a 424px screen and the menu is nine items,
* so this is a subset by necessity rather than a second opinion
* about what matters -- and plan 018's promise that no action is
* unreachable is kept by the overflow, not by this list.
*/
private static readonly SELECTION_ACTIONS: SelectionAction[] = [
{ id: 'play', label: 'Play', icon: ICON_PLAY },
{ id: 'add-to-queue', label: 'Add to queue', icon: ICON_QUEUE },
{ id: 'play-next', label: 'Play next', icon: ICON_PLAY_NEXT },
];
private renderSelectionBar() {
// Only in selection mode: a mouse selection is modeless and
// shows its actions on right-click, which is where a desktop
// user looks for them.
if (!this.selection.selectionMode) return nothing;
return html`
<selection-bar
.count=${this.selection.selectionCount}
.actions=${TrackList.SELECTION_ACTIONS}
@selection-exit=${this.onSelectionExit}
@selection-action=${(e: CustomEvent<{ id: string }>) =>
this.onContextMenuAction(e.detail.id)}
@selection-more=${(e: CustomEvent<{ x: number; y: number }>) =>
this.ctxMenu.openAt(e.detail.x, e.detail.y)}
></selection-bar>
`;
}
private onSelectionExit = () => {
this.selection.exitSelectionMode();
this.virtualizer?.requestUpdate();
};
override render() { override render() {
const visibleTracks = this.cachedSortedTracks; const visibleTracks = this.cachedSortedTracks;
const cols = this.activeColumns; const cols = this.activeColumns;
@@ -2247,6 +2412,9 @@ export class TrackList
<div class="sr-only" role="status" aria-live="polite"> <div class="sr-only" role="status" aria-live="polite">
${this.liveStatus(visibleTracks.length)} ${this.liveStatus(visibleTracks.length)}
</div> </div>
<div class="sr-only" role="status" aria-live="polite">
${this.swipe.announcement}
</div>
${this.tracks.length === 0 ${this.tracks.length === 0
? this.renderPlaceholder() ? this.renderPlaceholder()
: html` : html`
@@ -2322,6 +2490,7 @@ export class TrackList
)} )}
</div> </div>
</div> </div>
${this.renderSelectionBar()}
`} `}
<menu-surface <menu-surface
+54
View File
@@ -0,0 +1,54 @@
import { css } from 'lit';
/**
* The way out of a detail view, at the app's 44px touch floor.
*
* #186's second table names `artist-details`' back button at
* **32x32**. It is the same declaration in **six** components
* `artist-details`, `genre-details`, `playlist-details`,
* `smart-playlist-details`, `explore-artist-details` and
* `explore-album-details` byte-identical, 32px in all six, and the
* sweep that filed the issue visited one of them.
*
* That is the argument for this file rather than six edits. A device
* sweep walks the views somebody thought to open, so six copies of a
* control is six chances for the next pass to miss five; the arrows
* and the toggles were each one declaration covering 36 and 29
* controls, and this is the same shape stated the other way round.
*
* **It is a real 44px box, not padding with the width handed back.**
* The header pass had to grow a hit area past its own layout box
* because `page-header` measures itself for #69's overflow fit; a
* detail view's header does not, so the control can simply be the
* target. It also *should* be this button has a visible background,
* so a hit area larger than the circle would be a control that is
* bigger than it looks, which is the thing #187 accepts only where a
* thin painted track is the point.
*
* The size is #55's, arrived at for the same reason one component
* over: "the way out is 44px on a phone", when the queue panel's close
* button was 25x21 and, at phone width, the only pointer route off a
* full-screen surface. A detail view has the platform's back gesture
* as well, so this is less severe than the queue was it is the same
* control wearing the same mistake.
*/
export const backButton = css`
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
border-radius: 50%;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.back-button:hover {
background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12));
}
`;
+48
View File
@@ -0,0 +1,48 @@
import { css } from 'lit';
/**
* A Web Awesome form control is at least the app's 44px touch floor.
*
* #56 named 44px and #186 found nothing but the transport had reached
* it. Web Awesome's form controls are the part of Settings this app
* does not draw: measured on the reference device (TLP301, 424x439),
* `wa-input`'s control is **204x20** and `wa-button` **185x21** the
* shortest controls on the page, and the only ones whose height is
* decided inside somebody else's shadow root.
*
* `--wa-form-control-height` is that decision, and it is the library's
* own theming variable rather than a part or an internal the default
* theme sets it at `:root` and every control that has a height reads
* it (button, input, select, radio). So this is `wa-slider-label`'s
* better half: the API first, and no reach into a shadow root at all.
*
* Three things about it are load-bearing.
*
* **A custom property inherits through a shadow boundary**, which is
* what lets a `:host` declaration reach a `wa-input` the host renders.
* That is also why it is a stylesheet a component adopts rather than a
* `:root` rule in `index.css`: a `:root` rule would cover every wa
* control in the app in one line and be invisible to the component
* tier, which renders a component and no page stylesheet. Here the
* floor is measurable where it is applied.
*
* **It is a flat 44px rather than a floor over the library's own
* expression.** The default is `round(calc(2 * padding-block + 1em *
* line-height), 1px)` — em-based, so `size="small"` is what produced
* the 20px above and a `max(44px, …)` would have to restate that
* formula here, which is a copy of somebody else's arithmetic that
* goes stale silently. A flat value is safe because this app uses
* exactly two sizes, `small` and the default, and both are under the
* floor; a `size="large"` added later would be pinned down to 44 and
* should take that as the prompt to revisit this.
*
* **Only the height is pinned.** The font size still comes from
* `size="small"`, so a control grows its hit area without growing its
* visual weight which is what #186's Direction asks for and what the
* page header's second pass had to be corrected to do.
*/
export const waTouchFloor = css`
:host {
--wa-form-control-height: 44px;
}
`;
-201
View File
@@ -1,201 +0,0 @@
/**
* Long-press as the touch equivalent of a right-click (plan 016 B2,
* phase 3).
*
* Every context menu in the app opens from a `contextmenu` event
* `track-list` and `queue-panel` delegate one on their virtualizer,
* the card grids and both playlist detail views bind one per row, and
* `explore-artist-details` binds three. A phone has no right-click, so
* a phone reached none of them.
*
* **This is one document listener, not six components' worth of touch
* handling.** A press that stays still for `LONG_PRESS_MS` dispatches a
* synthetic `contextmenu` at the touch point on the element the touch
* actually landed on, and every existing handler delegated or
* per-row, in any shadow root runs unchanged. Six implementations of
* a gesture is exactly the fault `ContextMenuController` exists to
* prevent, and a seam that needs no component to opt in cannot be
* forgotten by the next component.
*
* Three things about it are load-bearing.
*
* **The target comes from `composedPath()[0]`, not from
* `elementFromPoint`**, which stops at the outermost shadow host: every
* menu in this app is bound inside one, so a synthetic event dispatched
* on the host reaches a delegated listener and no per-row one.
*
* **A browser that already does this must win.** Chromium fires a
* `contextmenu` on long-press itself; WebKitGTK and the Android WebView
* vary. So one arriving during the press cancels ours, and one arriving
* just after ours is swallowed at document capture where nothing else
* has seen it yet. The two are told apart by **identity** (a `WeakSet`
* of the events this module made) rather than by `isTrusted`, so the
* suppressor cannot eat the event it exists to deliver, the rule holds
* for anything else in the app that synthesises one, and a test can
* stand in for a browser that fires its own.
*
* **The click that ends the gesture is swallowed.** A row's click
* selects, and a card's plays; without this, opening a menu also
* activates the thing under it. It is keyed on the gesture (cleared by
* the next `pointerdown`) rather than on a time window, so a quick tap
* on the menu that just opened is not eaten too.
*/
/** How long a press must hold still to mean "menu". */
export const LONG_PRESS_MS = 500;
/**
* How far a press may drift and still count. Below a finger's own
* jitter is a gesture nobody can perform; above ~12px it starts
* stealing the first frames of a scroll.
*/
export const MOVE_TOLERANCE_PX = 10;
/** The active installation, so a second call is a no-op rather than a
* second listener set. */
let uninstall: (() => void) | null = null;
/** The events this module dispatched. Identity, not `isTrusted`: see
* the note above. */
const ours = new WeakSet<Event>();
/**
* Install the gesture. Idempotent; returns the uninstaller (which the
* tests use the app installs once and never removes it).
*/
export function installLongPressContextMenu(): () => void {
if (uninstall) return uninstall;
let timer: ReturnType<typeof setTimeout> | null = null;
let originX = 0;
let originY = 0;
let target: EventTarget | null = null;
/** A trusted `contextmenu` arrived for this press: the browser has
* it covered. */
let nativeSeen = false;
/** We opened a menu, and the click ending that gesture is not a
* click on anything. */
let swallowClick = false;
/** We dispatched one, so a trusted one arriving now is a duplicate. */
let justFired = false;
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
target = null;
};
const fire = (): void => {
timer = null;
const el = target;
target = null;
if (nativeSeen || !el) return;
justFired = true;
swallowClick = true;
const menu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
// Or it stops at the shadow root the row lives in, and the
// delegated listeners never see it.
composed: true,
clientX: originX,
clientY: originY,
button: 2,
});
ours.add(menu);
el.dispatchEvent(menu);
};
const onPointerDown = (e: PointerEvent): void => {
// A new gesture: whatever the last one left behind is stale.
swallowClick = false;
justFired = false;
nativeSeen = false;
cancel();
if (e.pointerType !== 'touch' || !e.isPrimary) return;
originX = e.clientX;
originY = e.clientY;
target = e.composedPath()[0] ?? e.target;
timer = setTimeout(fire, LONG_PRESS_MS);
};
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
const drifted =
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
if (drifted) cancel();
};
const onContextMenu = (e: Event): void => {
// Ours. Everything below is about somebody else's.
if (ours.has(e)) return;
if (timer !== null) {
// The browser got there first, so stand down rather than
// opening the same menu twice.
nativeSeen = true;
cancel();
return;
}
if (justFired) {
justFired = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
const onClick = (e: Event): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
};
// Capture throughout: a component handler that stops propagation
// (every context-menu handler in the app does) must not be able to
// hide the gesture from this, and the suppressors have to run
// before anything that would act on the event.
const opts = { capture: true } as const;
document.addEventListener('pointerdown', onPointerDown, opts);
document.addEventListener('pointermove', onPointerMove, opts);
document.addEventListener('pointerup', cancel, opts);
document.addEventListener('pointercancel', cancel, opts);
document.addEventListener('contextmenu', onContextMenu, opts);
document.addEventListener('click', onClick, opts);
// A scroll started by something other than the finger (momentum, a
// programmatic reveal) still means the press was not a press.
document.addEventListener('scroll', cancel, { capture: true, passive: true });
uninstall = () => {
cancel();
document.removeEventListener('pointerdown', onPointerDown, opts);
document.removeEventListener('pointermove', onPointerMove, opts);
document.removeEventListener('pointerup', cancel, opts);
document.removeEventListener('pointercancel', cancel, opts);
document.removeEventListener('contextmenu', onContextMenu, opts);
document.removeEventListener('click', onClick, opts);
document.removeEventListener('scroll', cancel, opts);
uninstall = null;
};
return uninstall;
}
@@ -19,6 +19,29 @@ export class SelectionController implements ReactiveController {
private host: SelectionHost; private host: SelectionHost;
private _selectedItems: Set<string> = new Set(); private _selectedItems: Set<string> = new Set();
private lastSelectedIndex: number | null = null; private lastSelectedIndex: number | null = null;
private _mode = false;
/**
* Whether the list is in *selection mode* (plan 019, #63).
*
* A finger has no modifier keys, so the ctrl/shift semantics this
* controller was written for cannot be expressed by touch at all.
* Selection mode is the platform's answer: a long press enters it,
* and while it is on, a tap toggles a row instead of playing it.
*
* It is a flag *here* rather than a fifth concept beside the
* controller because all four surfaces that select
* (`track-list`, `queue-panel` and both playlist detail views)
* already share this class -- so "is this list selecting" has one
* answer per list, in the object that already owns the selection
* it would otherwise contradict.
*
* A mouse never sets it. Desktop selection is unchanged and stays
* modeless, which is what `handleItemClick` still implements.
*/
get selectionMode(): boolean {
return this._mode;
}
constructor(host: SelectionHost) { constructor(host: SelectionHost) {
this.host = host; this.host = host;
@@ -136,8 +159,64 @@ export class SelectionController implements ReactiveController {
return true; return true;
} }
/**
* Enter selection mode with `key` selected.
*
* The row the gesture was made on is selected, rather than the
* mode opening empty: a long press is a statement about *that*
* row, and an action bar with nothing in it is a mode the user has
* to make a second gesture to escape.
*/
enterSelectionMode(key: string, index: number): void {
this._mode = true;
this._selectedItems = new Set([key]);
this.lastSelectedIndex = index;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/**
* Toggle one row, and leave the mode when the last one goes.
*
* Deselecting everything is how Android's own list surfaces exit
* selection mode, and it matters more here than convention: the
* mode changes what a tap *means*, so a mode with an empty
* selection is a list where tapping does nothing and nothing on
* screen says why.
*/
toggleInMode(key: string, index: number): void {
const next = new Set(this._selectedItems);
if (next.has(key)) next.delete(key);
else next.add(key);
this._selectedItems = next;
this.lastSelectedIndex = index;
if (next.size === 0) this._mode = false;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/** Leave selection mode, dropping the selection with it. */
exitSelectionMode(): void {
if (!this._mode && this._selectedItems.size === 0) return;
this._mode = false;
this._selectedItems = new Set();
this.lastSelectedIndex = null;
this.host.requestUpdate();
this.host.onSelectionChanged?.();
}
/** Clear the entire selection. */ /** Clear the entire selection. */
clear(): void { clear(): void {
// The mode goes with it: every caller of this means "the
// selection is no longer meaningful", and a mode outliving the
// selection it was showing is the empty-mode trap above.
this._mode = false;
if (this._selectedItems.size === 0) return; if (this._selectedItems.size === 0) return;
this._selectedItems = new Set(); this._selectedItems = new Set();
@@ -176,6 +255,13 @@ export class SelectionController implements ReactiveController {
if (next.size === this._selectedItems.size) return; if (next.size === this._selectedItems.size) return;
this._selectedItems = next; this._selectedItems = next;
// A refetch that emptied the selection also ends the mode --
// otherwise removing the last selected track from the library
// leaves the list in a state where a tap selects and the bar
// is gone.
if (next.size === 0) this._mode = false;
this.host.requestUpdate(); this.host.requestUpdate();
this.host.onSelectionChanged?.(); this.host.onSelectionChanged?.();
} }
+362
View File
@@ -0,0 +1,362 @@
import { css, html, nothing } from 'lit';
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { queueStore } from '@store/queue-store';
import { ICON_QUEUE } from '@utils/icon-language';
import type { SwipeEvent } from '@utils/touch-gestures';
/**
* Swipe a row right to add it to the queue (plan 019, #63).
*
* This is the *affordance* and the arithmetic, written once, because
* three lists want it: `track-list` and both playlist detail views.
* It was `track-list`'s own for one phase and is here rather than
* copied twice, on the rule the rest of this app is built on three
* copies of "how far is far enough" is three chances for them to
* disagree, which is what `utils/library-status.ts` and
* `utils/ownership.ts` each exist to have stopped happening.
*
* The host keeps three things: what a row *is*, what a swipe on it
* would queue, and what to call it afterwards. Everything else
* the threshold, the reveal, the settle, the announcement, the
* repaint is here.
*
* **The queue panel deliberately does not use it.** A right swipe means
* *add to the queue* everywhere it exists, and a queue row is already
* in the queue; the only thing it could sensibly mean there is
* *remove*, which is the same gesture with the opposite effect one
* screen away. Removing a queue row is on its own row (the ×), on its
* bottom sheet since #60, and on the selection bar #63 gave it.
*
* Five things about it are load-bearing.
*
* **Both halves of the device fix are here or next door.**
* `swipeRevealStyles` carries `touch-action: pan-y` on `[data-swipe]`,
* and `utils/touch-gestures.ts` carries the non-passive
* `preventDefault`. Chrome 113's WebView cancels the pointer stream
* ~16px into any drag whatever `touch-action` says, and with the
* `preventDefault` alone but `touch-action` at `auto` the gesture dies
* after one move. Neither works without the other and **both are
* correct in Chromium either way**, which is why the component tier
* asserts the stylesheet rather than the rendering.
*
* **The row does not move; its cells do.** A row here is
* `contain: strict` with `overflow: hidden`, so translating the row
* and counter-translating a pane inside it puts that pane at a
* negative offset inside a clipping box, where it is simply not
* painted. Sliding the children instead leaves the pane where it was
* drawn, clips the cells off the right edge, and needs no wrapper
* element in a row that is already a grid.
*
* **The travel is written to the row's own style, never rendered.**
* One render when the gesture starts, one when it crosses the
* threshold, one when it ends a virtualizer re-rendering every
* visible row per frame of one finger's travel is exactly what audit
* `perf.m1` is about.
*
* **The threshold is a fraction of the row**, with a floor. The row is
* 424x52 on the reference device, so a threshold in bare pixels is a
* fraction of a row height on one screen and a third of the width on
* the next.
*
* **It is not only a colour** (WCAG 1.4.1, the rule the playing-row
* marker exists for). The pane carries the queue icon and words, the
* words change at the threshold, and the outcome goes to a live
* region one glyph throughout, because a tick is `ICON_IN_LIBRARY`
* and means *you own this*.
*/
/** How far along the row a swipe has to reach to mean it. */
export const SWIPE_COMMIT_FRACTION = 0.3;
/** … and a floor, for a narrow list embedded in a detail page. */
export const SWIPE_COMMIT_MIN_PX = 72;
/** How long the reveal holds its confirmation before snapping back. */
const CONFIRM_MS = 550;
/** The snap itself. `swipeRevealStyles` states the same number. */
const SETTLE_MS = 180;
/** What a swipe on one row would do, as the host understands it. */
export interface SwipeTarget {
/** Which row draws the reveal. */
index: number;
/** The file paths a commit queues, in the order they are shown. */
filePaths: string[];
/** What to call a single track when saying it was added. */
label: string;
}
export interface SwipeToQueueOptions {
/**
* The row the gesture is on, or null for anything that is not a
* swipeable row a header, a gap, a track with no file.
*/
resolve(e: SwipeEvent): SwipeTarget | null;
/**
* Repaint the rows. A `<lit-virtualizer>` renders through the
* `virtualize` directive and reacts to its *own* properties, so a
* host update alone leaves the rows exactly as they were.
*/
repaint(): void;
}
export class SwipeToQueue implements ReactiveController {
private host: ReactiveControllerHost;
private opts: SwipeToQueueOptions;
/** Which row is being swiped, and therefore draws a reveal. */
private index: number | null = null;
/** Past the commit threshold: the reveal says so, in words. */
private armed = false;
/** Committed, and holding its confirmation. */
private done = false;
private row: HTMLElement | null = null;
private keys: string[] = [];
private commitPx = 0;
private settleTimer = 0;
/** What the gesture did, for anyone not watching the row. */
announcement = '';
constructor(host: ReactiveControllerHost, opts: SwipeToQueueOptions) {
this.host = host;
this.opts = opts;
host.addController(this);
}
hostConnected(): void {
// No-op; state is component-local.
}
hostDisconnected(): void {
window.clearTimeout(this.settleTimer);
this.forget();
}
/** Whether this row is the one under the finger. */
isSwiping(index: number): boolean {
return this.index === index;
}
onSwipeStart = (e: SwipeEvent): void => {
// Rightward only. Nothing is bound to a leftward swipe, and
// claiming one would take a gesture away to do nothing with it.
if (e.detail.dx <= 0) return;
const target = this.opts.resolve(e);
if (!target || target.filePaths.length === 0) return;
const row = (e.target as HTMLElement).closest(
'[data-swipe]',
) as HTMLElement | null;
if (!row) return;
e.preventDefault();
this.row = row;
this.keys = target.filePaths;
this.trackLabel = target.label;
this.commitPx = Math.max(
SWIPE_COMMIT_MIN_PX,
row.getBoundingClientRect().width * SWIPE_COMMIT_FRACTION,
);
this.armed = false;
this.done = false;
this.index = target.index;
this.host.requestUpdate();
this.opts.repaint();
this.offset(0);
};
onSwipeMove = (e: SwipeEvent): void => {
if (this.index === null) return;
const dx = Math.min(Math.max(e.detail.dx, 0), this.commitPx * 2);
const armed = dx >= this.commitPx;
if (armed !== this.armed) {
this.armed = armed;
this.host.requestUpdate();
this.opts.repaint();
}
this.offset(dx);
};
onSwipeEnd = (e: SwipeEvent): void => {
if (this.index === null) return;
if (e.detail.canceled || e.detail.dx < this.commitPx) {
this.settle(0);
return;
}
queueStore.addTracksToQueue(this.keys);
const count = this.keys.length;
// The reveal is the only thing on screen that says this
// happened -- the queue panel may well be closed -- so it holds
// its confirmation for a moment rather than vanishing the
// instant the finger lifts.
this.done = true;
this.announcement =
count === 1
? `Added ${this.label()} to the queue.`
: `Added ${count} tracks to the queue.`;
this.host.requestUpdate();
this.opts.repaint();
this.settle(CONFIRM_MS);
};
/** What is revealed behind the row, in three states. */
renderReveal(index: number) {
if (this.index !== index) return nothing;
const count = this.keys.length;
const what = count === 1 ? 'to queue' : `${count} tracks to queue`;
const words = this.done
? 'Added'
: this.armed
? 'Release to add'
: `Add ${what}`;
return html`
<div
class=${classMap({ 'swipe-reveal': true, armed: this.armed })}
aria-hidden="true"
data-testid="swipe-reveal"
>
<wa-icon name=${ICON_QUEUE}></wa-icon>
<span>${words}</span>
</div>
`;
}
/**
* What to call a single track, taken when the gesture starts.
*
* Held rather than looked up at the end, because a swipe outlives
* a refetch: the store replaces its array when a play count
* changes, which is once a song.
*/
private trackLabel = '';
private label(): string {
return this.trackLabel === '' ? 'the track' : this.trackLabel;
}
/** Write the travel to the row itself, with no render. */
private offset(dx: number): void {
this.row?.style.setProperty('--yj-swipe-dx', `${dx}px`);
}
/**
* Put the row back, after `delay`, and forget the swipe.
*
* The row element is held rather than looked up again: a
* virtualizer recycles its rows, and by the time this runs the
* element may be drawing a different track. Clearing the property
* off whatever it holds now is right either way, since `index` is
* what decides who draws the reveal.
*/
private settle(delay: number): void {
const row = this.row;
window.clearTimeout(this.settleTimer);
this.settleTimer = window.setTimeout(() => {
row?.classList.add('settling');
this.offset(0);
this.settleTimer = window.setTimeout(() => {
row?.classList.remove('settling');
row?.style.removeProperty('--yj-swipe-dx');
this.forget();
this.host.requestUpdate();
this.opts.repaint();
}, SETTLE_MS);
}, delay);
}
private forget(): void {
this.row = null;
this.index = null;
this.armed = false;
this.done = false;
}
}
/**
* The reveal, and the `touch-action` half of what makes the gesture
* reach us on the device.
*
* Keyed on `[data-swipe]` rather than on a class name, so one
* stylesheet serves three lists whose rows are called three different
* things.
*/
export const swipeRevealStyles = css`
/* Half of what makes the gesture reach us on Chrome 113's WebView:
auto lets it commit to a horizontal pan on the first move past
slop, and the pointer stream is cancelled before any threshold
can be crossed. The other half is the non-passive preventDefault
in utils/touch-gestures.ts, and neither works alone -- both were
measured three ways on the phone. Never none: that takes the
list's own vertical scrolling with it. */
[data-swipe] {
touch-action: pan-y;
}
.swipe-reveal {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--yj-swipe-dx, 0px);
box-sizing: border-box;
display: flex;
align-items: center;
gap: 0.4em;
padding-left: 8px;
overflow: hidden;
white-space: nowrap;
pointer-events: none;
font-size: var(--yj-text-xs);
background-color: var(--yj-bg-elevated, #343a40);
color: var(--yj-text-secondary, #b3b3b3);
}
.swipe-reveal.armed {
background-color: var(--yj-success, #2f9e44);
color: var(--yj-success-fg, #fff);
}
/* The children move, not the row -- see the header. */
[data-swipe].swiping > :not(.swipe-reveal) {
transform: translateX(var(--yj-swipe-dx, 0px));
}
[data-swipe].settling > * {
transition:
transform 160ms ease-out,
width 160ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
[data-swipe].settling > * {
transition: none;
}
}
`;
+619
View File
@@ -0,0 +1,619 @@
/**
* The touch gestures, as one document listener (plan 019, #63).
*
* This replaces `utils/long-press.ts` rather than sitting beside it,
* and that is the point: two document listeners both claiming the
* 500ms hold is exactly the fault that file's own header warns about.
* What it did one capture listener, the target from
* `composedPath()[0]`, the browser's own gesture winning, the trailing
* click swallowed is kept whole. What changes is what the gesture
* *means*.
*
* **A gesture is announced, not acted on.** Two composed, cancelable
* events are dispatched on the element the finger actually landed on:
*
* `yj-tap` a short press that did not drift
* `yj-long-press` a press that held still for LONG_PRESS_MS
* `yj-swipe-start` a press that has travelled decisively sideways
*
* A claimed swipe is then followed by `yj-swipe-move` and one
* `yj-swipe-end`, which is guaranteed: a swipe that the browser or a
* second finger takes away still ends, with `canceled` set, so the
* affordance a component put on screen always has something to snap
* back from.
*
* A component that wants the gesture handles it and calls
* `preventDefault()`. Nothing else changes. That shape is what lets
* this reassign the hold without touching a single one of the fourteen
* context menus downstream of it: **an unclaimed `yj-long-press` still
* becomes a synthetic `contextmenu`**, so a card grid, an Explore
* result or a playlist row behaves exactly as it did, and only the
* lists that opt in get selection mode.
*
* The same rule keeps taps honest. An unclaimed `yj-tap` does nothing
* at all and the browser's click follows normally, so every button,
* link and checkbox in the app is untouched by this file. Only a
* claimed tap has its click swallowed otherwise playing a track
* would also select it.
*
* Five things are load-bearing.
*
* **The predicate is the pointer, not the platform** (plan 019,
* decision 1). `pointerType === 'touch'`, per event so an Android
* tablet over 600px, a touchscreen laptop with a mouse also plugged
* in, and a narrow desktop window are all right for free, and there is
* no second declaration of what a phone does. Keyed on a viewport
* width, the first of those three gets desktop semantics on a
* touchscreen, which is the inversion #63 exists to fix, on the
* platform it exists for.
*
* **There is no double-tap**, and it is not an omission see plan
* 019, decision 2. Measured on the reference device, the play command
* to `TrackChanged` is ~100ms; a double-tap discriminator has to hold
* every tap for the app's own `DOUBLE_CLICK_GRACE_MS` of 250 before it
* can act, which is 3.5x the primary interaction in the app to reach a
* menu that long-press already reaches.
*
* **The target comes from `composedPath()[0]`**, not
* `elementFromPoint`, which stops at the outermost shadow host: every
* list in this app delegates inside one, so an event dispatched on the
* host reaches a delegated listener and no per-row one.
*
* **A browser that fires its own `contextmenu` is a trigger, not a
* competitor**, and that is a change from `long-press.ts` rather than
* an inherited rule. It used to stand down when a trusted
* `contextmenu` arrived, because both paths ended in the same place: a
* context menu. They no longer do ours may end in selection mode
* so standing down means the gesture silently does the *old* thing.
*
* Measured on the reference device, which is the only tier that can
* see this: Chrome 113's WebView fires its own `contextmenu` on a long
* press, so a hold on a track row opened the context menu and
* `yj-long-press` was never announced at all. Every test in the
* component tier passed, because dispatched pointer events do not make
* a browser synthesise one.
*
* So a trusted `contextmenu` arriving mid-press *becomes* the long
* press: `yj-long-press` is announced from it, and only if a component
* claims it is the native event suppressed. Unclaimed, it propagates
* untouched and opens the menu it always did which is the same
* "browser wins" outcome, now reached by asking rather than assuming.
*
* Ours and the browser's are still told apart by identity rather than
* `isTrusted` a `WeakSet` of the events this module made so the
* suppressor cannot eat the event it exists to deliver, and a test can
* stand in for a browser that fires one.
*
* **The click swallow is keyed on the gesture**, cleared by the next
* `pointerdown` rather than by a time window, so the first tap on a
* sheet that just opened is not eaten too.
*
* ## The swipe runs on touch events, and that is not a style choice
*
* Everything above is Pointer Events. The swipe is not, and the reason
* is measured on the reference device rather than reasoned about:
* **Chrome 113's Android WebView cancels the pointer stream ~16px into
* any drag, whatever `touch-action` says.** Three values were tried on
* a track row, driving a real finger with `adb shell input swipe`:
*
* ```
* 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 throughout all three. So a swipe recognised
* from `pointermove` is a swipe that dies 16px in plan 019 predicted
* the class of failure ("works in Chromium and not on the phone") and
* named `touch-action: pan-y` as the fix; it is half of it.
*
* The other half is that **a non-passive `touchmove` that calls
* `preventDefault()` is what keeps the gesture ours**. With it, the
* same swipe ran to 12 moves and a `pointerup` at full travel.
*
* Both halves are required, and that was measured too: with the
* `preventDefault` in place but `touch-action` back at `auto`, the
* gesture died after **one** move. The reading is that `auto` lets the
* browser commit to a horizontal pan on the first move past slop
* before any threshold of ours can have been crossed while `pan-y`
* leaves it undecided long enough for the second move to claim it.
*
* So a surface that wants a horizontal swipe declares
* `touch-action: pan-y` (`track-list`'s `.track-row` does) *and* gets
* this module's `preventDefault`. Neither alone works on the device,
* and **both work in Chromium either way**, which is exactly why this
* paragraph exists rather than a test.
*
* `touch-action: none` is the one value to avoid: it also takes the
* list's vertical scrolling away, which was measured as a list that
* would not move.
*
* Two consequences of the touch listener worth knowing.
*
* **It is non-passive, which costs the compositor's scroll fast path**
* for the first touchmoves of every scroll, until the browser starts
* scrolling and stops waiting on us. That is the standard price of a
* horizontal gesture in a scroller and it is paid once per gesture,
* not per frame; a vertical drag on the device still scrolls the
* virtualizer 81px on the same measurement that the horizontal one
* survives.
*
* **The tie breaks toward scrolling**, deliberately and in that order:
* vertical drift past the tolerance vetoes the swipe outright, and a
* gesture that is not *strictly* more horizontal than vertical is the
* scroller's. A list that will not scroll is unusable; a swipe that
* needs a second try is not.
*/
/** How long a press must hold still to mean "long press". */
export const LONG_PRESS_MS = 500;
/**
* How far a press may drift and still count. Below a finger's own
* jitter is a gesture nobody can perform; above ~12px it starts
* stealing the first frames of a scroll.
*/
export const MOVE_TOLERANCE_PX = 10;
/**
* How far a press must travel sideways before it is a swipe.
*
* It has a ceiling the other constants do not: the browser's own
* decision is made a little past this, so a threshold much higher is a
* gesture the device never delivers. Measured, the second `touchmove`
* of an `adb input swipe` lands at ~19px and the pointer stream dies
* just after it, so 12 is inside that window with room for a slower
* finger.
*/
export const SWIPE_START_PX = 12;
/** Detail carried by both gesture events. */
export interface GestureDetail {
/** Where the finger was, in client coordinates — a menu opens here. */
x: number;
y: number;
}
/** Detail carried by the three swipe events. */
export interface SwipeDetail {
/** Travel from where the finger landed. Signed: right is positive. */
dx: number;
dy: number;
/**
* The gesture was taken away rather than finished a second
* finger, a `touchcancel`, a scroll underneath. Only ever true on
* `yj-swipe-end`, and it is the difference between "do the thing"
* and "put the row back".
*/
canceled: boolean;
}
export type GestureEvent = CustomEvent<GestureDetail>;
export type SwipeEvent = CustomEvent<SwipeDetail>;
declare global {
interface HTMLElementEventMap {
'yj-tap': GestureEvent;
'yj-long-press': GestureEvent;
'yj-swipe-start': SwipeEvent;
'yj-swipe-move': SwipeEvent;
'yj-swipe-end': SwipeEvent;
}
}
/** The active installation, so a second call is a no-op rather than a
* second listener set. */
let uninstall: (() => void) | null = null;
/** The events this module dispatched. Identity, not `isTrusted`. */
const ours = new WeakSet<Event>();
/**
* Install the gestures. Idempotent; returns the uninstaller (which the
* tests use the app installs once and never removes it).
*/
export function installTouchGestures(): () => void {
if (uninstall) return uninstall;
let timer: ReturnType<typeof setTimeout> | null = null;
let originX = 0;
let originY = 0;
let target: EventTarget | null = null;
/** The press is still a candidate for a tap: it has neither
* drifted nor become a long press. */
let tapCandidate = false;
/** A trusted `contextmenu` arrived for this press: the browser has
* it covered. */
let nativeSeen = false;
/** A gesture was claimed, and the click ending it is not a click on
* anything. */
let swallowClick = false;
/**
* This press has already produced its outcome, so a trusted
* `contextmenu` arriving now is a duplicate of it.
*
* It covers **both** outcomes, and that is a fix rather than a
* tidy-up. `nativeSeen` handles the browser's menu arriving
* *during* the hold; the reverse order was never handled, and it
* happens: measured on the reference device over four holds, two
* of them fired our 500ms timer and then delivered a trusted
* `contextmenu` 50-70ms later, which nothing suppressed so the
* context menu opened on top of the selection bar, intermittently,
* on exactly the surface #63 exists to have changed. Neither the
* component tier nor the e2e tier can see it: no browser they run
* in synthesises a `contextmenu` from a dispatched press at all.
*/
let justFired = false;
// --- the swipe, which runs on touch events; see the header ------
/** Where the finger landed, and what it landed on. */
let swipeTarget: EventTarget | null = null;
let swipeOriginX = 0;
let swipeOriginY = 0;
/** The last travel, kept so a `touchcancel` which carries no
* coordinates for a touch that is already gone can still say how
* far the row had moved. */
let lastDx = 0;
let lastDy = 0;
/** A component claimed the swipe: it is ours until the finger
* lifts, and every `touchmove` is prevented. */
let swiping = false;
/** This press can no longer become a swipe it went vertical, a
* second finger arrived, or nobody claimed it. */
let swipeVetoed = false;
const cancel = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
target = null;
tapCandidate = false;
};
/**
* Announce a gesture on the element the finger landed on.
* Returns whether a component claimed it.
*/
const announce = (name: 'yj-tap' | 'yj-long-press', el: EventTarget): boolean => {
const event: GestureEvent = new CustomEvent<GestureDetail>(name, {
bubbles: true,
cancelable: true,
// Or it stops at the shadow root the row lives in, and the
// delegated listeners never see it.
composed: true,
detail: { x: originX, y: originY },
});
ours.add(event);
el.dispatchEvent(event);
return event.defaultPrevented;
};
const fireContextMenu = (el: EventTarget): void => {
justFired = true;
const menu = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
composed: true,
clientX: originX,
clientY: originY,
button: 2,
});
ours.add(menu);
el.dispatchEvent(menu);
};
const onLongPress = (): void => {
timer = null;
tapCandidate = false;
const el = target;
target = null;
if (nativeSeen || !el) return;
// The gesture happened either way, so the click that ends it is
// never a click on anything -- whether a list claimed it for
// selection mode or a card grid let it fall through to a menu.
swallowClick = true;
// The press is answered, so a trusted `contextmenu` for it is
// late rather than new. `fireContextMenu` sets this too; it is
// set here as well so the *claimed* branch is covered, which
// is the branch that was showing a menu over the bar.
justFired = true;
// An unclaimed long press is what it has always been. This is
// the whole reason the fourteen context menus need no change.
if (!announce('yj-long-press', el)) fireContextMenu(el);
};
const onPointerDown = (e: PointerEvent): void => {
// A new gesture: whatever the last one left behind is stale.
swallowClick = false;
justFired = false;
nativeSeen = false;
cancel();
if (e.pointerType !== 'touch' || !e.isPrimary) return;
originX = e.clientX;
originY = e.clientY;
target = e.composedPath()[0] ?? e.target;
tapCandidate = true;
timer = setTimeout(onLongPress, LONG_PRESS_MS);
};
/**
* Announce a swipe on the element the finger landed on.
* Returns whether a component claimed it (only `start` asks).
*/
const announceSwipe = (
name: 'yj-swipe-start' | 'yj-swipe-move' | 'yj-swipe-end',
el: EventTarget,
canceled = false,
): boolean => {
const event: SwipeEvent = new CustomEvent<SwipeDetail>(name, {
bubbles: true,
cancelable: name === 'yj-swipe-start',
composed: true,
detail: { dx: lastDx, dy: lastDy, canceled },
});
ours.add(event);
el.dispatchEvent(event);
return event.defaultPrevented;
};
/**
* End a claimed swipe, once.
*
* Every exit from a swipe comes through here so that `yj-swipe-end`
* is guaranteed: a component that has put a reveal on screen and a
* row half off its own left edge has no other way to learn the
* gesture is over.
*/
const endSwipe = (canceled: boolean): void => {
const el = swipeTarget;
swipeTarget = null;
if (!swiping) return;
swiping = false;
if (!el) return;
// The gesture happened, so the click that ends it is not a
// click on the row it ended over.
swallowClick = true;
announceSwipe('yj-swipe-end', el, canceled);
};
const onTouchStart = (e: TouchEvent): void => {
endSwipe(true);
lastDx = 0;
lastDy = 0;
// A second finger is a pinch or a scroll, never one of ours.
swipeVetoed = e.touches.length !== 1;
if (swipeVetoed) return;
const touch = e.touches[0];
if (!touch) return;
swipeOriginX = touch.clientX;
swipeOriginY = touch.clientY;
// `composedPath()[0]` for the reason the press path uses it: a
// list delegates inside its own shadow root.
swipeTarget = e.composedPath()[0] ?? e.target;
};
const onTouchMove = (e: TouchEvent): void => {
if (swipeVetoed || !swipeTarget) return;
if (e.touches.length !== 1) {
endSwipe(true);
swipeVetoed = true;
return;
}
const touch = e.touches[0];
if (!touch) return;
lastDx = touch.clientX - swipeOriginX;
lastDy = touch.clientY - swipeOriginY;
if (swiping) {
// This is what keeps the stream alive on the device. It is
// only ever reached for a *claimed* swipe, so nothing that
// scrolls is ever prevented.
e.preventDefault();
announceSwipe('yj-swipe-move', swipeTarget);
return;
}
// Vertical first: past the tolerance the list has it, and a
// gesture that is exactly diagonal is the list's too.
if (
Math.abs(lastDy) > MOVE_TOLERANCE_PX &&
Math.abs(lastDy) >= Math.abs(lastDx)
) {
swipeVetoed = true;
swipeTarget = null;
return;
}
if (
Math.abs(lastDx) < SWIPE_START_PX ||
Math.abs(lastDx) <= Math.abs(lastDy)
) {
return;
}
if (!announceSwipe('yj-swipe-start', swipeTarget)) {
// Nobody wants it. Leave the gesture to the browser rather
// than holding it open for the rest of the press.
swipeVetoed = true;
swipeTarget = null;
return;
}
swiping = true;
// It is not a tap and it is not a hold.
cancel();
e.preventDefault();
};
const onTouchEnd = (): void => {
endSwipe(false);
};
const onTouchCancel = (): void => {
endSwipe(true);
};
const onPointerMove = (e: PointerEvent): void => {
if (timer === null) return;
const drifted =
Math.abs(e.clientX - originX) > MOVE_TOLERANCE_PX ||
Math.abs(e.clientY - originY) > MOVE_TOLERANCE_PX;
// A drifted press is neither gesture -- it is a scroll, and the
// virtualizer's, not ours.
if (drifted) cancel();
};
const onPointerUp = (): void => {
const el = target;
const wasTap = tapCandidate && timer !== null;
// Clears the long-press timer, so a tap cannot also become one.
cancel();
if (!wasTap || !el) return;
// Only a *claimed* tap swallows its click. An unclaimed one has
// to fall through untouched, or every button in the app stops
// working.
if (announce('yj-tap', el)) swallowClick = true;
};
const onContextMenu = (e: Event): void => {
// Ours. Everything below is about somebody else's.
if (ours.has(e)) return;
if (timer !== null) {
// The browser recognised the same hold this was timing.
// Use its event as the trigger rather than racing it --
// and rather than standing down, which is what the old
// rule did and which now silently means "do the thing this
// gesture used to do".
const el = e.composedPath()[0] ?? e.target;
nativeSeen = true;
cancel();
if (!el) return;
swallowClick = true;
// Claimed: the component wants selection mode, so the
// browser's menu must not also open. Unclaimed: let it
// through exactly as before.
if (announce('yj-long-press', el)) {
e.preventDefault();
e.stopImmediatePropagation();
}
return;
}
if (justFired) {
justFired = false;
e.preventDefault();
e.stopImmediatePropagation();
}
};
const onClick = (e: Event): void => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopImmediatePropagation();
};
// Capture throughout: a component handler that stops propagation
// (every context-menu handler in the app does) must not be able to
// hide the gesture from this, and the suppressors have to run
// before anything that would act on the event.
const opts = { capture: true } as const;
// Non-passive, because `onTouchMove` has to be able to prevent the
// default for a claimed swipe -- see the header. The other three
// are passive: they only read.
const blocking = { capture: true, passive: false } as const;
const listening = { capture: true, passive: true } as const;
/** A surface moved under the finger: neither gesture survives it. */
const abort = (): void => {
endSwipe(true);
cancel();
};
document.addEventListener('pointerdown', onPointerDown, opts);
document.addEventListener('pointermove', onPointerMove, opts);
document.addEventListener('pointerup', onPointerUp, opts);
document.addEventListener('pointercancel', cancel, opts);
document.addEventListener('contextmenu', onContextMenu, opts);
document.addEventListener('click', onClick, opts);
document.addEventListener('touchstart', onTouchStart, listening);
document.addEventListener('touchmove', onTouchMove, blocking);
document.addEventListener('touchend', onTouchEnd, listening);
document.addEventListener('touchcancel', onTouchCancel, listening);
// A scroll started by something other than the finger (momentum, a
// programmatic reveal) still means the press was not a press.
document.addEventListener('scroll', abort, listening);
uninstall = () => {
abort();
document.removeEventListener('pointerdown', onPointerDown, opts);
document.removeEventListener('pointermove', onPointerMove, opts);
document.removeEventListener('pointerup', onPointerUp, opts);
document.removeEventListener('pointercancel', cancel, opts);
document.removeEventListener('contextmenu', onContextMenu, opts);
document.removeEventListener('click', onClick, opts);
document.removeEventListener('touchstart', onTouchStart, opts);
document.removeEventListener('touchmove', onTouchMove, opts);
document.removeEventListener('touchend', onTouchEnd, opts);
document.removeEventListener('touchcancel', onTouchCancel, opts);
document.removeEventListener('scroll', abort, opts);
uninstall = null;
};
return uninstall;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

+5
View File
@@ -137,6 +137,11 @@ describe('<app-sidebar>', () => {
}); });
it('looks the way it did last time', async () => { it('looks the way it did last time', async () => {
// Stated rather than inherited: `activeViewStore` is a singleton, so
// without this the shot records whichever view the *previous* case
// left in it and the reference moves when the file is reordered.
activeViewStore.setView('home', true);
const el = await fixture('app-sidebar'); const el = await fixture('app-sidebar');
await visual(el, 'app-sidebar'); await visual(el, 'app-sidebar');
@@ -0,0 +1,230 @@
/**
* The controls #186's second table found, outside Settings.
*
* Six one-off controls across four surfaces, and the reason they are a
* test rather than six stylesheet edits is `back-button`. The issue
* names it in `artist-details` at **32x32**; it is the same
* declaration, byte-identical, in *six* components because a device
* sweep walks the views somebody thought to open, and five of them
* were not opened.
*
* So the assertion is over the whole set rather than over the one that
* was measured. That is `icon-language.test.ts`'s shape and it is here
* for the same reason: checking one call site checks one call site.
*
* | control | before | where |
* |---|---|---|
* | `.folders-menu-trigger` | **32x18** | autotag |
* | `.section-toggle` | 187x**15** | autotag |
* | `.back-button` | 32x32 | six detail views |
* | Requests / Downloads tabs | 85x**34**, 96x**34** | downloads |
* | `.search-mode-tab` | 89x**26**, 79x**26** | explore |
* | explore search input | 325x**18** in a 36px box | explore |
*
* `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
* page header's actions, and `touch-targets.test.ts` already covers
* it. Re-asserting it here would be a second statement of one rule.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import '@components/artist-details/artist-details';
import '@components/autotag-view/autotag-view';
import '@components/downloads-view/downloads-view';
import '@components/explore-album-details/explore-album-details';
import '@components/explore-artist-details/explore-artist-details';
import '@components/explore-view/explore-view';
import '@components/genre-details/genre-details';
import '@components/playlist-details/playlist-details';
import '@components/smart-playlist-details/smart-playlist-details';
import { flush, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
/** The app's touch floor, from #56. */
const FLOOR = 44;
/**
* Every component that draws a back button.
*
* The list is here rather than derived because deriving it means
* reading the source, and this tier renders instead but it is
* checked against the source by `the back button is one declaration`
* below, so a seventh view cannot join quietly.
*/
const BACK_BUTTON_VIEWS = [
'artist-details',
'genre-details',
'playlist-details',
'smart-playlist-details',
'explore-artist-details',
'explore-album-details',
] as const;
function boxOf(el: Element | null | undefined): { w: number; h: number } {
if (!el) return { w: 0, h: 0 };
const box = el.getBoundingClientRect();
return { w: Math.round(box.width), h: Math.round(box.height) };
}
describe('the way out of a detail view', () => {
beforeEach(() => {
for (const path of [
'library.Library.GetTracks',
'library.Library.GetAlbums',
'library.Library.GetArtists',
'library.Library.GetGenres',
'playlist.Service.GetAllPlaylists',
'playlist.Service.GetAllPlaylistsWithTracks',
]) {
stub(path, []);
}
});
it.each(BACK_BUTTON_VIEWS)('is 44px in <%s>', async (tag) => {
// #55 settled this one component over, when the queue panel's
// close button was 25x21 and, at phone width, the only pointer
// route off a full-screen surface: "the way out is 44px". A detail
// view has the platform's back gesture as well, so it is less
// severe -- and it is the same control wearing the same mistake.
const el = await fixture(tag);
await flush();
const back = shadow(el, '.back-button');
expect(back, `${tag} draws a back button`).toBeTruthy();
expect(boxOf(back)).toEqual({ w: FLOOR, h: FLOOR });
});
it('is one declaration, so a seventh view cannot miss it', async () => {
// The regression this exists for is not a size changing -- it is
// somebody adding a detail view and writing `.back-button` out
// again at 32px, which is exactly how there came to be six copies.
// A sweep of the running app would not catch it either, because a
// sweep visits the views you think to open.
const sources = import.meta.glob('../../src/components/**/*.ts', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>;
expect(Object.keys(sources).length, 'the glob read something').toBeGreaterThan(0);
const redeclared = Object.entries(sources)
.filter(([, src]) => /^\s*\.back-button\s*(?::[a-z-]+\s*)?\{/m.test(src))
.map(([path]) => path);
expect(redeclared).toEqual([]);
});
});
describe('autotag', () => {
it('raises the two smallest controls the sweep found', async () => {
// 187x15 and 32x18. The section toggle was the smallest control
// measured anywhere in the app until the column arrows were
// counted, and autotag is off by default (#25), which is
// presumably why nobody had met either.
const el = await fixture('autotag-view');
await flush();
for (const selector of ['.section-toggle', '.folders-menu-trigger']) {
const control = shadowAll<HTMLElement>(el, selector).find(
(c) => c.getBoundingClientRect().height > 0,
);
if (!control) continue;
expect(boxOf(control).h, `${selector} height`).toBeGreaterThanOrEqual(FLOOR);
}
// The stylesheet is the assertion for whichever of the two this
// fixture does not render -- both are behind state a bare mount
// does not reach, and a test that silently checked nothing is the
// trap icon-language.test.ts's first assertion exists for.
const sheet = (el.constructor as typeof HTMLElement & { styles?: unknown })
.styles;
expect(String(sheet)).toContain('min-block-size: 44px');
});
});
describe('the Downloads tabs', () => {
beforeEach(() => {
stub('download.Service.ListDownloads', []);
stub('download.Service.ListRequests', []);
stub('download.Service.ListProviders', []);
});
it('are the only route to their panels, and are 44px', async () => {
const el = await fixture('downloads-view');
await flush();
const tabs = shadowAll<HTMLElement>(el, '[role="tab"]');
expect(tabs).toHaveLength(2);
for (const tab of tabs) {
expect(boxOf(tab).h, tab.textContent?.trim()).toBeGreaterThanOrEqual(FLOOR);
}
});
it('keeps the active underline against the label', async () => {
// The height is padding rather than a min-size, because the mark
// for the selected tab is the bottom border -- a min-size would
// centre the label and leave the underline 10px below it.
const el = await fixture('downloads-view');
await flush();
const tab = shadowAll<HTMLElement>(el, '[role="tab"]')[0]!;
const style = getComputedStyle(tab);
expect(parseFloat(style.paddingBlockStart)).toBeGreaterThan(8);
expect(style.paddingBlockStart).toBe(style.paddingBlockEnd);
});
});
describe("Explore's own search row", () => {
beforeEach(() => {
stub('explore.Service.GetShelves', { State: 'ready', Shelves: [] });
stub('explore.Service.GetIndexStatus', {});
});
it('raises the mode tabs', async () => {
const el = await fixture('explore-view');
await flush();
const tabs = shadowAll<HTMLElement>(el, '.search-mode-tab');
expect(tabs.length).toBeGreaterThan(0);
for (const tab of tabs) {
expect(boxOf(tab).h, tab.textContent?.trim()).toBeGreaterThanOrEqual(FLOOR);
}
});
it('makes the whole search box the input, not the middle 18px of it', async () => {
// Two faults, not one: the row was 36px and the input inside it
// was **18**, so half the box was not a target at all -- a tap
// near the top or bottom edge landed on the container and did
// nothing. The container is 44 and the input stretches to fill it.
const el = await fixture('explore-view');
await flush();
const box = shadow(el, '.search-container');
const input = shadow(el, '.search-container input');
expect(box, 'the search row renders').toBeTruthy();
expect(input, 'it holds an input').toBeTruthy();
expect(boxOf(box).h).toBeGreaterThanOrEqual(FLOOR);
expect(boxOf(input).h).toBeGreaterThanOrEqual(FLOOR);
});
});
-212
View File
@@ -1,212 +0,0 @@
/**
* Long-press as the touch route to a context menu (plan 016 B2 phase 3).
*
* These run in a real browser with real event dispatch, which is the
* only place the two things that make this hard are true: the synthetic
* event has to cross a shadow boundary to reach the listener a
* component actually bound, and the suppressors have to tell a trusted
* event from ours at document capture without eating the one they exist
* to deliver.
*
* The timings are real rather than faked, because the thing under test
* *is* a timing, and 600 ms twice is cheaper than a fake-timer harness
* that would also have to fake the pointer events.
*/
import { describe, expect, it, afterEach, beforeEach } from 'vitest';
import {
installLongPressContextMenu,
LONG_PRESS_MS,
MOVE_TOLERANCE_PX,
} from '@utils/long-press';
/** A press that has certainly resolved, either way. */
const HELD = LONG_PRESS_MS + 120;
/** A press that has certainly not. */
const BRIEF = Math.round(LONG_PRESS_MS / 4);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
let host: HTMLElement;
let inner: HTMLElement;
/** A row inside a shadow root, which is where every menu in this app
* is bound an element in the light DOM would pass a weaker test. */
function mountRow(): { host: HTMLElement; inner: HTMLElement } {
const el = document.createElement('div');
const root = el.attachShadow({ mode: 'open' });
const row = document.createElement('div');
row.textContent = 'a track';
root.append(row);
document.body.append(el);
return { host: el, inner: row };
}
function press(
el: EventTarget,
type: string,
init: PointerEventInit = {},
): void {
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: 40,
clientY: 60,
...init,
}),
);
}
/**
* Record every `contextmenu` that reaches the listener, *as the
* listener sees it*.
*
* `target` is retargeted for the scope reading it, so an assertion made
* after dispatch has finished reports the shadow host however the event
* was dispatched - which is the same answer a broken implementation
* gives. It has to be read from inside the handler, where the component
* reads it.
*/
function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] {
const seen: { event: MouseEvent; target: EventTarget | null }[] = [];
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
// Every real handler does this; the gesture must work anyway.
e.stopPropagation();
seen.push({ event: e as MouseEvent, target: e.target });
});
return seen;
}
describe('long-press opens a context menu', () => {
beforeEach(() => {
uninstall = installLongPressContextMenu();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
it('dispatches one at the touch point, on the element touched', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(HELD);
expect(seen).toHaveLength(1);
expect(seen[0]?.event.clientX).toBe(40);
expect(seen[0]?.event.clientY).toBe(60);
// Dispatched on the row itself, not on its shadow host - which is
// the difference between a per-row handler firing and only a
// delegated one firing.
expect(seen[0]?.target).toBe(inner);
});
it('is cancelled by a press that moves', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', {
clientX: 40 + MOVE_TOLERANCE_PX + 5,
clientY: 60,
});
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('tolerates the jitter a finger cannot help', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', { clientX: 43, clientY: 62 });
await wait(HELD);
expect(seen).toHaveLength(1);
});
it('is cancelled by lifting early, and by a scroll', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
await wait(HELD);
expect(seen).toHaveLength(0);
press(inner, 'pointerdown');
press(inner, 'pointercancel');
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('ignores a mouse, which has a right button of its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('swallows the click that ends the gesture, and only that one', async () => {
let clicks = 0;
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(HELD);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(0);
// The next tap is a tap: on a phone that is the user choosing an
// item in the menu that just opened, so eating it would make the
// gesture useless.
press(inner, 'pointerdown');
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(1);
});
it('stands down where the browser fires its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
// Chromium does this itself on touch; WebKit and the Android
// WebView vary, which is the whole reason both halves exist. A
// test cannot dispatch a *trusted* event, which is why the module
// tells its own apart by identity rather than by `isTrusted`.
inner.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
cancelable: true,
}),
);
await wait(HELD);
// One menu: the browser's. Not two.
expect(seen).toHaveLength(1);
});
});
@@ -366,6 +366,16 @@ describe('<now-playing>', () => {
const el = await fixture('now-playing'); const el = await fixture('now-playing');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 }); emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 });
// Stated rather than inherited: the queue store is a singleton, so
// without this the shot records whichever source the *previous*
// case left in it and the reference moves when the file is
// reordered. Three lines is what the bar renders while playing
// from somewhere, which is the arrangement worth recording.
setQueue([queueTrack(1, 'Ashes to Ashes')], 0, {
type: 'album',
id: 7,
label: 'Scary Monsters',
});
await flush(); await flush();
await el.updateComplete; await el.updateComplete;
@@ -161,6 +161,15 @@ describe('double-clicking a row in the track list', () => {
localStorage.removeItem('track-list-column-widths'); localStorage.removeItem('track-list-column-widths');
el = await fixture<LitElement>('track-list', { externalTracks: LIST }); el = await fixture<LitElement>('track-list', { externalTracks: LIST });
// Say which order is being asserted rather than inheriting one.
// `restoreSortPreferences()` runs in `connectedCallback`, so the
// list opens in whatever sort was last persisted -- and
// `track-11` sorts before `track-3` by title, which is the shape
// of #138. The row below is the fixture's third track only while
// nothing is sorting the list.
(el as unknown as { sortField: string | null }).sortField = null;
el.style.display = 'block'; el.style.display = 'block';
el.style.height = '600px'; el.style.height = '600px';
await flush(); await flush();
@@ -172,6 +181,10 @@ describe('double-clicking a row in the track list', () => {
const rows = shadowAll(el, '.track-row'); const rows = shadowAll(el, '.track-row');
const row = rows.find((r) => r.getAttribute('data-index') === '3'); const row = rows.find((r) => r.getAttribute('data-index') === '3');
// Stated first, so a list that is not in the order this asserts
// fails by saying so rather than as an off-by-eight file path.
expect(row?.getAttribute('data-file-path')).toBe('/music/track-3.mp3');
dblclick(row!); dblclick(row!);
await flush(); await flush();
@@ -99,6 +99,25 @@ describe('<search-trigger>', () => {
} }
}); });
it('meets the touch floor it was shipped four pixels under', async () => {
stubPhone(true);
// #57 created this 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", which was the app's own 44px floor (#56)
// restated short rather than a second opinion about it. #186.
const el = await fixture('search-trigger');
const button = shadow<HTMLButtonElement>(el, '[data-testid="search-trigger"]');
expect(button).not.toBeNull();
const box = button!.getBoundingClientRect();
expect(Math.round(box.width)).toBeGreaterThanOrEqual(44);
expect(Math.round(box.height)).toBeGreaterThanOrEqual(44);
});
it('names what the button will search', async () => { it('names what the button will search', async () => {
stubPhone(true); stubPhone(true);
@@ -0,0 +1,211 @@
/**
* The seek bar's painted track and the thing you can hit are allowed to
* differ, and a slider is the clearest case where they should.
*
* On `now-playing-view` the screen that exists so a phone has
* somewhere to seek from the slider measured 261x6 on the reference
* device (#187). Six pixels is the whole of the drag target on the
* app's primary seeking affordance, against a 44px floor the app set
* for itself in #56 and holds to in the queue panel.
*
* Two separate faults, and the first is why the second was not obvious.
*
* **The phone rule had never applied.** `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 which is `index.css`'s documented
* rule ("the phone section is last on purpose") reproduced inside a
* component's own stylesheet. The source said 12 and the device said 6.
*
* **And 12px would still be under the floor**, so the target is built
* around the track rather than by thickening it: padding on the part
* that carries the gesture, with margins cancelling it so the row does
* not grow.
*
* This is asserted against the *parsed stylesheet*, on
* `hover-affordance.test.ts`'s precedent and with the same limitation
* stated rather than hidden: no tier here renders at a phone width with
* a real `wa-slider` laid out, so what can be checked is the shape the
* browser built from the css`` literal. The pixel measurements that
* chose these numbers were taken on the device and are recorded on
* #187 and in the stylesheet's own comment a number measured on a
* phone is not a number CI can assert.
*
* Which is the regression worth catching anyway. Both failures are
* invisible on a desktop: hoisting the block back above the plain rule
* renders identically at every width CI runs at, and it is exactly what
* a tidy-up does.
*/
import { describe, expect, it } from 'vitest';
import '@components/audio-player/seekbar/seek-bar';
import { fixture } from '@test/support/render';
/** The app's touch floor, from #56. */
const TOUCH_FLOOR = 44;
/** The width below which the phone's rules apply. */
const PHONE_QUERY = /max-width:\s*599px/;
type Rule = { text: string; condition: string | null };
/**
* Every rule in the element's own adopted stylesheets, flattened **in
* order**, which is the whole point here: the fault being guarded is a
* rule sitting in the wrong place, not a rule being absent.
*/
function rulesOf(host: Element): Rule[] {
const sheets = host.shadowRoot?.adoptedStyleSheets ?? [];
const out: Rule[] = [];
for (const sheet of sheets) {
for (const rule of Array.from(sheet.cssRules)) {
if (rule instanceof CSSMediaRule) {
for (const inner of Array.from(rule.cssRules)) {
out.push({ text: inner.cssText, condition: rule.conditionText });
}
continue;
}
out.push({ text: rule.cssText, condition: null });
}
}
return out;
}
/**
* The two px numbers of a `*-block` declaration, as [start, end].
*
* A symmetric pair is **serialised back as one value** `padding-block:
* 16px 16px` reads as `padding-block: 16px` — so a naive pair-reader
* fails on the shorthand rather than on the thing it is checking, and
* says the wrong thing about why. That is not hypothetical: it is what
* the symmetric-padding reversion did while this test was being
* proved.
*/
function blockPair(text: string, property: string): [number, number] | null {
const declaration = new RegExp(`${property}:\\s*([^;]+)`).exec(text)?.[1];
if (declaration === undefined) {
return null;
}
const values = [...declaration.matchAll(/(-?[\d.]+)px/g)].map((m) =>
Number(m[1]),
);
const [start, end] = values;
if (start === undefined) {
return null;
}
return [start, end ?? start];
}
describe("the seek bar's phone rules", () => {
it('are last, so they are not silently overridden', async () => {
const el = await fixture('seek-bar', {});
const rules = rulesOf(el);
// A sweep that read nothing passes vacuously — the same first
// assertion icon-language.test.ts makes, for the same reason.
expect(rules.length).toBeGreaterThan(0);
const declaresTrackSize = (r: Rule) => /--track-size:/.test(r.text);
const lastUnconditional = rules.findLastIndex(
(r) => r.condition === null && declaresTrackSize(r),
);
const phoneOverride = rules.findLastIndex(
(r) => r.condition !== null && PHONE_QUERY.test(r.condition)
&& declaresTrackSize(r),
);
expect(lastUnconditional).toBeGreaterThanOrEqual(0);
expect(phoneOverride).toBeGreaterThanOrEqual(0);
// A media query adds no specificity. Written first, it loses.
expect(phoneOverride).toBeGreaterThan(lastUnconditional);
});
it('give the slider a pointer target of at least the touch floor', async () => {
const el = await fixture('seek-bar', {});
const rules = rulesOf(el);
const track = rules.find(
(r) => r.condition !== null && PHONE_QUERY.test(r.condition)
&& /--track-size:/.test(r.text),
);
const target = rules.find(
(r) => r.condition !== null && PHONE_QUERY.test(r.condition)
&& r.text.includes('::part(slider)'),
);
expect(track).toBeDefined();
expect(target).toBeDefined();
const trackSize = Number(
/--track-size:\s*(-?[\d.]+)px/.exec(track!.text)?.[1],
);
const padding = blockPair(target!.text, 'padding-block');
expect(padding).not.toBeNull();
// The padding is on ::part(slider) rather than on the host because
// that inner div is what carries the gesture: it has the listener
// and the touch-action, and it is exactly the host's size, so
// padding the host grows a box that does not take the press.
const hitArea = trackSize + padding![0] + padding![1];
expect(hitArea).toBeGreaterThanOrEqual(TOUCH_FLOOR);
});
it('do not grow the row they sit in', async () => {
const el = await fixture('seek-bar', {});
const target = rulesOf(el).find(
(r) => r.condition !== null && PHONE_QUERY.test(r.condition)
&& r.text.includes('::part(slider)'),
);
expect(target).toBeDefined();
const padding = blockPair(target!.text, 'padding-block');
const margin = blockPair(target!.text, 'margin-block');
expect(padding).not.toBeNull();
expect(margin).not.toBeNull();
// now-playing-view's vertical budget is fixed and #51 measured
// every pixel of it: letting the row grow by the difference cost
// the album art 25px of 143 when it was tried on the device.
expect(margin![0]).toBe(-padding![0]);
expect(margin![1]).toBe(-padding![1]);
});
it('take the space above, because what is below is the transport', async () => {
const el = await fixture('seek-bar', {});
const target = rulesOf(el).find(
(r) => r.condition !== null && PHONE_QUERY.test(r.condition)
&& r.text.includes('::part(slider)'),
);
expect(target).toBeDefined();
const pair = blockPair(target!.text, 'padding-block');
expect(pair).not.toBeNull();
const [above, below] = pair!;
// Measured at 424x439: the seek row is 19px and the play button's
// top edge is 8px below it, while `.art` above is a non-interactive
// div. A symmetric target would reach into the play button — the
// most important control on the screen — so the growth is upward.
expect(above).toBeGreaterThan(below);
});
});
@@ -0,0 +1,425 @@
/**
* Every control in Settings is at least 44px (#186, second pass).
*
* The header pass covered the five controls a user meets on every
* screen. Settings is the other half and is much the larger one: swept
* on the reference device (TLP301, 424x439) with all eleven
* `config-section`s expanded, **120 controls** were under the floor,
* not the 93 the issue's first table implies, and `config-field` the
* row shape the issue names is eight of them. The bulk is behind the
* disclosures:
*
* | control | size | count |
* |---|---|---|
* | `.column-arrow-btn` | **16x14** | 36 |
* | `.column-toggle` | 16x16 | 29 |
* | `shortcut-capture` button | 80x**25** | 26 |
* | download format checkbox | 16x16 | 8 |
* | `config-field` select | 335x**30** | 7 |
* | `wa-input` / `wa-button` | 204x**20**, 185x**21** | 6 |
* | `library-filter` select | 120x**32** | 1 |
* | `.overflow-btn` | 31x31 | 1 |
*
* **This tier can measure it, unlike #187's seek bar**, for the reason
* the header pass gives: the controls are real elements and the rules
* are min-sizes, so a real Chromium rendering a real component gives
* the actual answer at any width. And unlike the header there is no
* overflow fit on this page, so nothing here needs the negative-margin
* treatment height is free and the two square controls can simply be
* square.
*
* **What the sweep cannot see is written down here as a test rather
* than as a comment**, because it is the trap this whole issue keeps
* setting. Two controls are invisible to a walk of `button, select,
* input`: `config-field`'s toggle, whose `<input>` is
* `opacity: 0; width: 0; height: 0` so the thing a finger hits is the
* `<label>` around it (34x19, smaller than anything in either of the
* issue's tables), and `shortcut-capture`'s reset button, which renders
* only for a shortcut somebody has already rebound. Both are asserted
* by name below.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import '@components/config-page/config-field';
import '@components/config-page/config-page';
import '@components/config-page/download-clients';
import '@components/config-page/shortcut-capture';
import '@components/library-filter/library-filter';
import { flush, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
/** The app's touch floor, from #56. */
const FLOOR = 44;
/**
* Everything a finger can hit, through every shadow root under `root`.
*
* It descends rather than querying one root because Settings is a tree
* of components `config-page` renders `config-section`s holding
* `config-field`s and `shortcut-capture`s and the defect was
* distributed across all of them. This is the device sweep, run here.
*/
function controlsUnder(root: Document | ShadowRoot | Element): { name: string; el: HTMLElement }[] {
const SELECTOR = 'button, select, input, [role="button"], [role="tab"], [role="switch"]';
const found: { name: string; el: HTMLElement }[] = [];
const seen = new Set<Element>();
const walk = (node: ParentNode, depth: number): void => {
if (depth > 20) return;
for (const el of Array.from(node.querySelectorAll('*'))) {
if (el.matches(SELECTOR) && !seen.has(el)) {
seen.add(el);
const box = el.getBoundingClientRect();
const style = getComputedStyle(el);
const rendered =
(box.width > 0 || box.height > 0) &&
style.visibility !== 'hidden' &&
style.display !== 'none';
if (rendered) {
const host = (el.getRootNode() as ShadowRoot).host;
found.push({
name: `${host ? host.tagName.toLowerCase() : 'root'} ${
(typeof el.className === 'string' && el.className) || el.tagName.toLowerCase()
}`,
el: el as HTMLElement,
});
}
}
if (el.shadowRoot) walk(el.shadowRoot, depth + 1);
}
};
walk(root as ParentNode, 0);
return found;
}
/**
* What a finger actually hits for `el`.
*
* For everything in this app that is one element, that is the element.
* A native checkbox is the exception and is why this function exists:
* it cannot grow its hit area without growing its paint, and a 44px
* checkbox is not what anyone wants so a checkbox that has a label
* is targeted *through* the label, which is the fix the column lists
* and the download formats both use.
*
* The fallback is the checkbox itself, deliberately: a checkbox with
* no label is a 16px target and this must still say so.
*/
function hitTarget(el: HTMLElement): HTMLElement {
const input = el as HTMLInputElement;
if (input.type !== 'checkbox' && input.type !== 'radio') return el;
const wrapping = el.closest('label');
const root = el.getRootNode() as ShadowRoot | Document;
const associated = input.id
? root.querySelector<HTMLLabelElement>(`label[for="${CSS.escape(input.id)}"]`)
: null;
return wrapping ?? associated ?? el;
}
/** The ones that miss the floor, reported with the numbers. */
function tooSmall(controls: { name: string; el: HTMLElement }[]): string[] {
return controls
.map(({ name, el }) => {
const box = hitTarget(el).getBoundingClientRect();
return { name, w: Math.round(box.width), h: Math.round(box.height) };
})
.filter((c) => c.w < FLOOR || c.h < FLOOR)
.map((c) => `${c.name} ${c.w}x${c.h}`);
}
/**
* Open every disclosure under `host`, so the sweep can see the page.
*
* A collapsed `config-section` renders its body with `hidden`, so its
* controls measure 0x0 which is exactly why the issue's first table
* lists seven Settings controls and the real count is 120.
*/
async function expandEverySection(host: HTMLElement & { updateComplete: Promise<unknown> }) {
const sections = shadowAll<HTMLElement>(host, 'config-section');
expect(sections.length, 'the page renders disclosures to open').toBeGreaterThan(0);
for (const section of sections) {
section.shadowRoot
?.querySelector<HTMLButtonElement>('button[aria-expanded="false"]')
?.click();
}
await flush();
await host.updateComplete;
for (const section of sections) {
await (section as HTMLElement & { updateComplete?: Promise<unknown> }).updateComplete;
}
return sections;
}
/** A control's own box, named so a failure says which and how small. */
function boxOf(el: Element | null | undefined): string {
if (!el) return 'missing';
const box = el.getBoundingClientRect();
return `${Math.round(box.width)}x${Math.round(box.height)}`;
}
function meetsFloor(el: Element | null | undefined): boolean {
if (!el) return false;
const box = el.getBoundingClientRect();
return Math.round(box.width) >= FLOOR && Math.round(box.height) >= FLOOR;
}
describe('a config field is the shape every Settings row uses', () => {
it.each(['text', 'number', 'select', 'directory', 'color'] as const)(
'a %s field meets the floor',
async (type) => {
const el = await fixture('config-field', {
schema: {
key: 'k',
label: 'Music folder',
type,
options: [{ value: 'dark', label: 'Dark' }],
},
value: type === 'color' ? '#ffd43b' : '',
});
const controls = controlsUnder(el.shadowRoot!);
// A sweep that found nothing passes vacuously.
expect(controls.length).toBeGreaterThan(0);
expect(tooSmall(controls)).toEqual([]);
},
);
it('grows the toggle, which no sweep of inputs can see', async () => {
// The `<input>` is opacity: 0; width: 0; height: 0, so the walk
// above skips it as a zero-sized node -- and the thing a finger
// hits is the styling <label> around it, which measured 34x19 on
// the device. It is absent from #186's tables for exactly that
// reason, and it is smaller than everything in them.
const el = await fixture('config-field', {
schema: { key: 'x', label: 'Scan on startup', type: 'toggle' },
value: true,
});
const target = shadow(el, '.toggle-switch');
expect(boxOf(target)).toBe('44x44');
});
it('keeps the toggle painted at its old size, in its old place', async () => {
// A 44px pill is not what a switch should look like. The box is
// 44px and the paint is not: the slider is a child centred in it,
// and negative inline margins hand the extra width back so the
// pill stays flush with the inputs in the rows above.
const el = await fixture('config-field', {
schema: { key: 'x', label: 'Scan on startup', type: 'toggle' },
value: true,
});
const target = shadow(el, '.toggle-switch') as HTMLElement;
const slider = shadow(el, '.toggle-slider');
expect(slider!.getBoundingClientRect().height).toBeLessThan(FLOOR);
const style = getComputedStyle(target);
const handedBack =
parseFloat(style.marginInlineStart) + parseFloat(style.marginInlineEnd);
expect(handedBack).toBeLessThan(0);
});
});
describe('the shortcut editor', () => {
it('meets the floor', async () => {
const el = await fixture('shortcut-capture', {
action: 'play.toggle',
label: 'Play/pause',
currentKey: 'Space',
defaultKey: 'Space',
});
expect(meetsFloor(shadow(el, 'button'))).toBe(true);
});
it('grows the reset button, which only a rebound shortcut renders', async () => {
// Not in either of #186's tables, and it cannot be: a sweep of a
// freshly-installed app never sees it. It appears the moment
// anybody uses the feature.
const el = await fixture('shortcut-capture', {
action: 'play.toggle',
label: 'Play/pause',
currentKey: 'K',
defaultKey: 'Space',
});
const reset = shadow(el, '.reset-btn');
expect(reset, 'a rebound shortcut renders a reset button').toBeTruthy();
expect(meetsFloor(reset)).toBe(true);
});
});
describe('the library filter', () => {
it('meets the floor in both of its placements', async () => {
// One component, two mount points since #57 -- the desktop top bar
// and Settings -> Libraries -- so it reaches the floor once.
const el = await fixture('library-filter');
const select = shadow(el, 'select');
expect(select, 'the filter renders a select').toBeTruthy();
expect(Math.round(select!.getBoundingClientRect().height)).toBeGreaterThanOrEqual(FLOOR);
});
});
describe('download clients', () => {
beforeEach(() => {
stub('download.Service.ListProviders', []);
stub('download.Service.ProviderKinds', []);
stub('config.Config.GetDownloadPreferences', {});
});
it('gives Web Awesome form controls the floor through the library API', async () => {
// wa-input's control is inside somebody else's shadow root, so the
// height comes from --wa-form-control-height rather than from a
// rule of ours reaching in. A custom property inherits through a
// shadow boundary, which is what makes a :host declaration reach
// it -- and what makes it measurable here.
const el = await fixture('download-clients');
await flush();
await expandEverySection(el);
expect(getComputedStyle(el).getPropertyValue('--wa-form-control-height').trim()).toBe(
'44px',
);
// And the outcome, not only the mechanism. A sweep of `input`
// reports a wa-input at 204x**42** even when this is right,
// because the inner input sits *inside* the control's own 1px
// border -- Web Awesome sizes it
// `calc(--wa-form-control-height - border-width * 2)`. What a
// finger hits is `part=base`, measured at 238x44 on the device.
//
// This reaches into another library's shadow root, which
// `name-dialog.ts` only permits where the failure is bounded. It
// is bounded the other way here: this is a test, so a renamed
// part fails loudly rather than silently passing, which is the
// direction that costs nobody a device session.
const input = shadowAll<HTMLElement>(el, 'wa-input').find(
(w) => w.getBoundingClientRect().height > 0,
);
expect(input, 'the add form renders a wa-input').toBeTruthy();
const base = input!.shadowRoot?.querySelector('[part~="base"]');
expect(base, 'wa-input still calls its control box "base"').toBeTruthy();
expect(Math.round(base!.getBoundingClientRect().height)).toBeGreaterThanOrEqual(FLOOR);
});
it('makes each allowed-format checkbox label a target', async () => {
const el = await fixture('download-clients');
await flush();
await el.updateComplete;
// Every section starts collapsed, and a collapsed body is `hidden`
// — so its controls measure 0x0 and a sweep of an unexpanded page
// reports them all as fine. That is how the issue's first table
// came to list seven Settings controls when there are 120.
await expandEverySection(el);
const options = shadowAll<HTMLElement>(el, '.format-option');
expect(options.length).toBeGreaterThan(0);
const short = options
.map((o) => ({ label: o.textContent?.trim(), h: Math.round(o.getBoundingClientRect().height) }))
.filter((o) => o.h < FLOOR);
expect(short).toEqual([]);
});
});
describe('the whole Settings page', () => {
beforeEach(() => {
for (const path of [
'library.Library.GetAllLibrariesWithTrackCounts',
'jobs.Service.GetJobs',
'download.Service.ListProviders',
'download.Service.ProviderKinds',
]) {
stub(path, []);
}
stub('config.Config.GetShortcuts', {});
stub('config.Config.GetDownloadPreferences', {});
stub('config.Config.GetThemeAccentColor', '#ffd43b');
stub('config.Config.GetThemeBackgroundShade', 'dark');
});
it('has no control under the floor with every section expanded', async () => {
// The device sweep, run here: eleven collapsed sections is what
// made the first table look like seven controls. The density is
// behind the disclosures.
const el = await fixture('config-page');
await flush();
await el.updateComplete;
await expandEverySection(el);
const controls = controlsUnder(el.shadowRoot!);
expect(controls.length).toBeGreaterThan(0);
expect(tooSmall(controls)).toEqual([]);
});
it('makes a column row a target by naming it, not by growing the checkbox', async () => {
// A native checkbox cannot grow its hit area without growing its
// paint. The label is the target instead -- which is also the
// argument config-field already makes for its own labels, and it
// is behaviour rather than annotation: the column's name is now a
// click target for its checkbox.
const el = await fixture('config-page');
await flush();
await el.updateComplete;
await expandEverySection(el);
const labels = shadowAll<HTMLLabelElement>(el, 'label.column-label');
expect(labels.length).toBeGreaterThan(0);
for (const label of labels) {
const target = label.htmlFor
? el.shadowRoot!.getElementById(label.htmlFor)
: null;
expect(
(target as HTMLInputElement | null)?.type,
`${label.textContent?.trim()} names its checkbox`,
).toBe('checkbox');
expect(
Math.round(label.getBoundingClientRect().height),
`${label.textContent?.trim()} is a target`,
).toBeGreaterThanOrEqual(FLOOR);
}
});
});
@@ -0,0 +1,681 @@
/**
* The touch gestures (plan 019, #63; long-press from plan 016 B2).
*
* These run in a real browser with real event dispatch, which is the
* only place the two things that make this hard are true: the synthetic
* event has to cross a shadow boundary to reach the listener a
* component actually bound, and the suppressors have to tell a trusted
* event from ours at document capture without eating the one they exist
* to deliver.
*
* The timings are real rather than faked, because the thing under test
* *is* a timing, and 600 ms twice is cheaper than a fake-timer harness
* that would also have to fake the pointer events.
*/
import { describe, expect, it, afterEach, beforeEach } from 'vitest';
import {
installTouchGestures,
LONG_PRESS_MS,
MOVE_TOLERANCE_PX,
} from '@utils/touch-gestures';
/** A press that has certainly resolved, either way. */
const HELD = LONG_PRESS_MS + 120;
/** A press that has certainly not. */
const BRIEF = Math.round(LONG_PRESS_MS / 4);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
let host: HTMLElement;
let inner: HTMLElement;
/** A row inside a shadow root, which is where every menu in this app
* is bound an element in the light DOM would pass a weaker test. */
function mountRow(): { host: HTMLElement; inner: HTMLElement } {
const el = document.createElement('div');
const root = el.attachShadow({ mode: 'open' });
const row = document.createElement('div');
row.textContent = 'a track';
root.append(row);
document.body.append(el);
return { host: el, inner: row };
}
function press(
el: EventTarget,
type: string,
init: PointerEventInit = {},
): void {
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: 40,
clientY: 60,
...init,
}),
);
}
/**
* Record every `contextmenu` that reaches the listener, *as the
* listener sees it*.
*
* `target` is retargeted for the scope reading it, so an assertion made
* after dispatch has finished reports the shadow host however the event
* was dispatched - which is the same answer a broken implementation
* gives. It has to be read from inside the handler, where the component
* reads it.
*/
function recordMenus(el: EventTarget): { event: MouseEvent; target: EventTarget | null }[] {
const seen: { event: MouseEvent; target: EventTarget | null }[] = [];
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
// Every real handler does this; the gesture must work anyway.
e.stopPropagation();
seen.push({ event: e as MouseEvent, target: e.target });
});
return seen;
}
describe('an unclaimed long press is still a context menu', () => {
beforeEach(() => {
uninstall = installTouchGestures();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
it('dispatches one at the touch point, on the element touched', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(HELD);
expect(seen).toHaveLength(1);
expect(seen[0]?.event.clientX).toBe(40);
expect(seen[0]?.event.clientY).toBe(60);
// Dispatched on the row itself, not on its shadow host - which is
// the difference between a per-row handler firing and only a
// delegated one firing.
expect(seen[0]?.target).toBe(inner);
});
it('is cancelled by a press that moves', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', {
clientX: 40 + MOVE_TOLERANCE_PX + 5,
clientY: 60,
});
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('tolerates the jitter a finger cannot help', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
press(inner, 'pointermove', { clientX: 43, clientY: 62 });
await wait(HELD);
expect(seen).toHaveLength(1);
});
it('is cancelled by lifting early, and by a scroll', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
await wait(HELD);
expect(seen).toHaveLength(0);
press(inner, 'pointerdown');
press(inner, 'pointercancel');
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('ignores a mouse, which has a right button of its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(HELD);
expect(seen).toHaveLength(0);
});
it('swallows the click that ends the gesture, and only that one', async () => {
let clicks = 0;
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(HELD);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(0);
// The next tap is a tap: on a phone that is the user choosing an
// item in the menu that just opened, so eating it would make the
// gesture useless.
press(inner, 'pointerdown');
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(1);
});
it('stands down where the browser fires its own', async () => {
const seen = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
// Chromium does this itself on touch; WebKit and the Android
// WebView vary, which is the whole reason both halves exist. A
// test cannot dispatch a *trusted* event, which is why the module
// tells its own apart by identity rather than by `isTrusted`.
inner.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
cancelable: true,
}),
);
await wait(HELD);
// One menu: the browser's. Not two.
expect(seen).toHaveLength(1);
});
});
/**
* What plan 019 adds on top, and the one property that protects
* everything downstream: a long press nobody claims is unchanged.
*/
describe('a gesture is announced before it is acted on', () => {
beforeEach(() => {
uninstall = installTouchGestures();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
it('does not synthesise a menu when the long press is claimed', async () => {
// This is the whole reason #63 could reassign the hold without
// touching one of the fourteen context menus: the lists that want
// selection mode claim it, and nothing else changes.
const menus = recordMenus(inner);
const presses: unknown[] = [];
inner.addEventListener('yj-long-press', (e) => {
presses.push(e);
e.preventDefault();
});
press(inner, 'pointerdown');
await wait(HELD);
expect(presses).toHaveLength(1);
expect(menus).toHaveLength(0);
});
it('announces a tap on the element touched, at the touch point', async () => {
const taps: { target: EventTarget | null; x: number; y: number }[] = [];
inner.addEventListener('yj-tap', (e) => {
taps.push({ target: e.target, x: e.detail.x, y: e.detail.y });
});
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
expect(taps).toHaveLength(1);
// The row, not its shadow host -- the difference between a
// delegated handler firing and a per-row one never firing.
expect(taps[0]?.target).toBe(inner);
expect([taps[0]?.x, taps[0]?.y]).toEqual([40, 60]);
});
it('lets an unclaimed tap through as an ordinary click', async () => {
// Every button, link and checkbox in the app depends on this. Only
// a *claimed* tap has its click swallowed.
let clicks = 0;
inner.addEventListener('yj-tap', () => {
/* seen, not claimed */
});
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(1);
});
it('swallows the click behind a claimed tap', async () => {
// Or playing a track would also select it, and the row would end
// up in both states at once.
let clicks = 0;
inner.addEventListener('yj-tap', (e) => e.preventDefault());
inner.addEventListener('click', () => {
clicks += 1;
});
press(inner, 'pointerdown');
await wait(BRIEF);
press(inner, 'pointerup');
inner.click();
expect(clicks).toBe(0);
});
it('does not announce a tap for a press that became a long press', async () => {
// A hold is one gesture, not a hold and then a tap on release.
const taps: unknown[] = [];
inner.addEventListener('yj-tap', (e) => taps.push(e));
press(inner, 'pointerdown');
await wait(HELD);
press(inner, 'pointerup');
expect(taps).toHaveLength(0);
});
it('does not announce a tap for a press that drifted', async () => {
// A drifted press is a scroll, and the virtualizer's -- not a tap
// that happened to move. This is the one that would make a list
// unscrollable if it were wrong.
const taps: unknown[] = [];
inner.addEventListener('yj-tap', (e) => taps.push(e));
press(inner, 'pointerdown');
press(inner, 'pointermove', {
clientX: 40,
clientY: 60 + MOVE_TOLERANCE_PX + 20,
});
press(inner, 'pointerup');
expect(taps).toHaveLength(0);
});
it('ignores a mouse entirely, for both gestures', async () => {
// plan 019 decision 1: the predicate is the pointer, per event. A
// mouse on a touchscreen keeps click-selects / double-click-plays
// on the very same row, which no viewport width can express.
const seen: unknown[] = [];
inner.addEventListener('yj-tap', (e) => seen.push(e));
inner.addEventListener('yj-long-press', (e) => seen.push(e));
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(BRIEF);
press(inner, 'pointerup', { pointerType: 'mouse' });
press(inner, 'pointerdown', { pointerType: 'mouse' });
await wait(HELD);
expect(seen).toHaveLength(0);
});
});
/**
* The browser's own long press is a trigger, not a competitor.
*
* This is a device-only defect made checkable here. `long-press.ts`
* stood down when a trusted `contextmenu` arrived mid-press, which was
* right while both paths ended in a context menu. Once a hold can mean
* *selection mode*, standing down means the gesture silently does the
* old thing and Chrome 113's Android WebView does fire its own, so
* on the reference device `yj-long-press` was never announced at all
* while every test in this tier passed.
*
* A test cannot dispatch a *trusted* event, which is exactly why the
* module tells its own apart by identity rather than by `isTrusted`:
* an untrusted one dispatched from here takes the same path the
* browser's does.
*/
describe("the browser's own long press", () => {
beforeEach(() => {
uninstall = installTouchGestures();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
/** Stand in for the browser recognising the hold itself. */
function browserContextMenu(el: EventTarget): MouseEvent {
const e = new MouseEvent('contextmenu', {
bubbles: true,
composed: true,
cancelable: true,
});
el.dispatchEvent(e);
return e;
}
it('announces the gesture rather than standing down', async () => {
const presses: unknown[] = [];
inner.addEventListener('yj-long-press', (e) => {
presses.push(e);
e.preventDefault();
});
press(inner, 'pointerdown');
await wait(BRIEF);
browserContextMenu(inner);
await wait(HELD);
expect(presses, 'the hold reached the component').toHaveLength(1);
});
it('suppresses its menu when a component claims the gesture', async () => {
const menus = recordMenus(inner);
inner.addEventListener('yj-long-press', (e) => e.preventDefault());
press(inner, 'pointerdown');
await wait(BRIEF);
browserContextMenu(inner);
await wait(HELD);
// The component wants selection mode, so the menu must not also
// open -- otherwise the device shows both at once.
expect(menus).toHaveLength(0);
});
it('suppresses a menu that arrives after the hold was claimed', async () => {
// The order the device actually produces, and the one that was
// missing: our 500ms timer fires first and a component claims it,
// then Chrome delivers its own `contextmenu` 50-70ms later.
// Measured over four holds on the reference phone, two took this
// order -- so the context menu opened over the selection bar,
// intermittently, on the one surface #63 changed.
const menus = recordMenus(inner);
inner.addEventListener('yj-long-press', (e) => e.preventDefault());
press(inner, 'pointerdown');
await wait(HELD);
browserContextMenu(inner);
expect(menus, 'the menu is late, not new').toHaveLength(0);
});
it('still opens exactly one menu when nobody claims it', async () => {
// The old behaviour, reached by asking instead of assuming. This
// is what leaves the card grids, Explore and the playlist rows
// untouched by #63.
const menus = recordMenus(inner);
press(inner, 'pointerdown');
await wait(BRIEF);
browserContextMenu(inner);
await wait(HELD);
expect(menus).toHaveLength(1);
});
});
/**
* The swipe half (plan 019 phase 2, #63).
*
* It runs on touch events rather than pointer events, and that is the
* one thing about it a browser tier cannot check. Measured on the
* reference device: Chrome 113's WebView cancels the *pointer* stream
* ~16px into any drag whatever `touch-action` says, while `touchmove`
* keeps firing so what these assert is the shape that survives it,
* not that it survives.
*
* What they can hold is everything else: the axis rule, that the tie
* breaks toward the scroller, that an unclaimed swipe is left entirely
* alone, that a claimed one prevents the default (which is the half of
* the device fix that lives in code), and that an end always arrives.
*/
describe('a finger dragged sideways', () => {
beforeEach(() => {
uninstall = installTouchGestures();
({ host, inner } = mountRow());
});
afterEach(() => {
uninstall?.();
uninstall = null;
host.remove();
});
/** One finger, at an offset from where it landed. */
function touch(el: EventTarget, type: string, dx = 0, dy = 0): TouchEvent {
const point = new Touch({
identifier: 1,
target: el as EventTarget as Element,
clientX: 40 + dx,
clientY: 60 + dy,
});
const event = new TouchEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
touches: type === 'touchend' || type === 'touchcancel' ? [] : [point],
changedTouches: [point],
});
el.dispatchEvent(event);
return event;
}
/** Record the swipe events a component would bind. */
function recordSwipes(
el: EventTarget,
opts: { claim?: boolean } = {},
): { type: string; dx: number; canceled: boolean }[] {
const seen: { type: string; dx: number; canceled: boolean }[] = [];
for (const name of ['yj-swipe-start', 'yj-swipe-move', 'yj-swipe-end']) {
el.addEventListener(name, (e) => {
const detail = (e as CustomEvent<{ dx: number; canceled: boolean }>)
.detail;
if (name === 'yj-swipe-start' && opts.claim !== false) {
e.preventDefault();
}
seen.push({ type: name, dx: detail.dx, canceled: detail.canceled });
});
}
return seen;
}
it('announces a swipe once it has travelled decisively sideways', () => {
const seen = recordSwipes(inner);
touch(inner, 'touchstart');
touch(inner, 'touchmove', 4);
expect(seen, 'a wobble is not a swipe').toHaveLength(0);
touch(inner, 'touchmove', 30);
touch(inner, 'touchmove', 60);
touch(inner, 'touchend');
expect(seen.map((s) => s.type)).toEqual([
'yj-swipe-start',
'yj-swipe-move',
'yj-swipe-end',
]);
expect(seen.at(-1)?.dx).toBe(60);
expect(seen.at(-1)?.canceled).toBe(false);
});
it('prevents the default only for a claimed swipe', () => {
// This is the half of the device fix that lives in code: a
// non-passive `touchmove` calling `preventDefault` is what keeps
// the gesture ours on Chrome 113. Preventing anything else would
// be taking the list's scrolling away.
recordSwipes(inner);
touch(inner, 'touchstart');
const early = touch(inner, 'touchmove', 4);
expect(early.defaultPrevented, 'a wobble scrolls').toBe(false);
const claimed = touch(inner, 'touchmove', 30);
const after = touch(inner, 'touchmove', 60);
expect(claimed.defaultPrevented).toBe(true);
expect(after.defaultPrevented).toBe(true);
});
it('leaves an unclaimed swipe entirely alone', () => {
const seen = recordSwipes(inner, { claim: false });
touch(inner, 'touchstart');
touch(inner, 'touchmove', 30);
const later = touch(inner, 'touchmove', 60);
touch(inner, 'touchend');
// Asked once, refused, and then not asked again for the rest of
// the press -- and nothing prevented, so the browser still owns it.
expect(seen.map((s) => s.type)).toEqual(['yj-swipe-start']);
expect(later.defaultPrevented).toBe(false);
});
it('gives a drag that went vertical to the scroller, and keeps it', () => {
// The veto is a *latch*, and that is the whole of it: a scroll
// that curves — which is what a thumb does — would otherwise
// become a swipe halfway down the list, snatching the list out
// from under itself. Without the latch the second move here is
// decisively horizontal and would claim the gesture.
const seen = recordSwipes(inner);
touch(inner, 'touchstart');
touch(inner, 'touchmove', 4, 30);
touch(inner, 'touchmove', 80, 35);
touch(inner, 'touchend');
expect(seen, 'the list has it').toHaveLength(0);
});
it('gives the scroller the tie as well', () => {
// Exactly diagonal is not "decisively sideways". A list that will
// not scroll is unusable; a swipe that needs a second try is not.
const seen = recordSwipes(inner);
touch(inner, 'touchstart');
touch(inner, 'touchmove', 60, 60);
touch(inner, 'touchend');
expect(seen).toHaveLength(0);
});
it('is not a tap and not a hold once it is a swipe', async () => {
const menus = recordMenus(inner);
const taps: Event[] = [];
inner.addEventListener('yj-tap', (e) => taps.push(e));
recordSwipes(inner);
press(inner, 'pointerdown');
touch(inner, 'touchstart');
touch(inner, 'touchmove', 40);
await wait(HELD);
touch(inner, 'touchend');
press(inner, 'pointerup');
expect(menus, 'the hold did not become a menu').toHaveLength(0);
expect(taps, 'the lift did not become a tap').toHaveLength(0);
});
it('always ends, even when the gesture is taken away', () => {
// A component that has a row half off its own left edge has no
// other way to learn the finger is gone -- so `touchcancel` is an
// end with `canceled` set, not a silence.
const seen = recordSwipes(inner);
touch(inner, 'touchstart');
touch(inner, 'touchmove', 40);
touch(inner, 'touchcancel');
expect(seen.at(-1)?.type).toBe('yj-swipe-end');
expect(seen.at(-1)?.canceled).toBe(true);
expect(seen.at(-1)?.dx).toBe(40);
});
it('is never one gesture when there are two fingers', () => {
const seen = recordSwipes(inner);
const two = new TouchEvent('touchstart', {
bubbles: true,
composed: true,
cancelable: true,
touches: [
new Touch({ identifier: 1, target: inner, clientX: 40, clientY: 60 }),
new Touch({ identifier: 2, target: inner, clientX: 90, clientY: 60 }),
],
});
inner.dispatchEvent(two);
touch(inner, 'touchmove', 60);
expect(seen, 'a pinch is not a swipe').toHaveLength(0);
});
it('swallows the click a claimed swipe ends on', () => {
const clicks: Event[] = [];
recordSwipes(inner);
inner.addEventListener('click', (e) => clicks.push(e));
touch(inner, 'touchstart');
touch(inner, 'touchmove', 40);
touch(inner, 'touchend');
inner.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
expect(clicks, 'the row was not also clicked').toHaveLength(0);
});
});
@@ -0,0 +1,396 @@
/**
* What a finger does to a track list (plan 019, #63).
*
* The desktop semantics being diverged from are real and stay: a click
* selects, a double-click plays. A finger has no second button and no
* modifier keys, so the primary action has to be the primary gesture
* and the inversion is decided **per event**, off `pointerType`, not
* off a viewport width or a platform flag (plan 019, decision 1).
*
* That is what these assert: the same row, in the same component, at
* the same width, answering a mouse one way and a finger the other.
*
* There is deliberately no double-tap. Measured on the reference
* device, playing a track is ~100ms end to end, and a double-tap
* discriminator has to hold every tap for the app's own
* `DOUBLE_CLICK_GRACE_MS` of 250 before it can act 3.5x the primary
* interaction, to reach a menu a long press already reaches.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import '@components/track-list/track-list';
import '@components/selection-bar/selection-bar';
import { calls, flush, resetHarness, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures';
const HELD = LONG_PRESS_MS + 120;
/** Comfortably past `explore-link`'s own DOUBLE_CLICK_GRACE_MS of 250. */
const EXPLORE_LINK_GRACE = 400;
const BRIEF = Math.round(LONG_PRESS_MS / 4);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
function track(n: number) {
return {
FilePath: `/music/track-${n}.mp3`,
TrackName: `Track ${n}`,
ArtistName: 'An Artist',
Album: 'An Album',
Duration: 100 + n,
ID: n,
// Tagged, so the title's `explore-link` can actually navigate.
// Without an MBID it asks the backend for a local album first and
// gives up when nothing answers -- which makes "the tap did not
// navigate" true of every build, working or not.
ReleaseGroupMBID: 'e8f4b1d2-0000-4000-8000-00000000000' + n,
RecordingMBID: 'a1b2c3d4-0000-4000-8000-00000000000' + n,
};
}
const TRACKS = [track(1), track(2), track(3), track(4)];
/** Dispatch a pointer event as a finger would produce it. */
function press(el: EventTarget, type: string, init: PointerEventInit = {}) {
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: 40,
clientY: 60,
...init,
}),
);
}
/**
* A whole finger tap: down, a moment, up, and **the click a browser
* fires afterwards**.
*
* That last event is not decoration. A tap the component claims has
* its click swallowed at document capture, and the click is the only
* thing that would otherwise select the row, follow the `explore-link`
* in its title, or press whatever the finger landed on. A helper that
* stops at `pointerup` asserts none of that and passes on a build with
* the swallow deleted.
*/
async function tap(el: EventTarget) {
press(el, 'pointerdown');
await wait(BRIEF);
press(el, 'pointerup');
el.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }),
);
await wait(0);
}
/** A finger held still until the gesture resolves. */
async function hold(el: EventTarget) {
press(el, 'pointerdown');
await wait(HELD);
press(el, 'pointerup');
await wait(0);
}
async function mountList() {
const el = await fixture('track-list');
(el as unknown as { tracks: unknown[] }).tracks = TRACKS;
await flush();
await el.updateComplete;
await wait(60);
await el.updateComplete;
return el;
}
function rows(el: HTMLElement): HTMLElement[] {
return shadowAll<HTMLElement>(el, '.track-row');
}
describe('a finger on a track row', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('config.Config.GetShortcuts', {});
stub('queue.Queue.SetQueue', null);
stub('queue.Queue.AddTracksToQueue', null);
uninstall = installTouchGestures();
});
afterEach(() => {
uninstall?.();
uninstall = null;
vi.restoreAllMocks();
});
it('plays the row it taps, rather than selecting it', async () => {
const el = await mountList();
const row = rows(el)[1];
expect(row, 'the list rendered rows').toBeTruthy();
await tap(row!);
await flush();
const queued = calls('queue.Queue.SetQueue');
expect(queued.length, 'a tap plays').toBe(1);
// From that row, in the list as displayed -- not a queue of one
// that stops when the song ends.
expect(queued[0]?.args[1]).toBe(1);
expect((queued[0]?.args[0] as string[]).length).toBe(TRACKS.length);
});
it('leaves the same row to a mouse, which still selects', async () => {
// The inversion is per event, so one component answers both
// pointers at the same width. A viewport rule cannot say this.
const el = await mountList();
const row = rows(el)[1];
row!.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
await el.updateComplete;
expect(calls('queue.Queue.SetQueue').length, 'a click does not play').toBe(0);
expect(rows(el)[1]?.getAttribute('aria-selected')).toBe('true');
});
it('enters selection mode on a long press, with that row selected', async () => {
const el = await mountList();
await hold(rows(el)[2]!);
await el.updateComplete;
const bar = shadow(el, 'selection-bar');
expect(bar, 'the action bar appears').toBeTruthy();
expect((bar as unknown as { count: number }).count).toBe(1);
expect(rows(el)[2]?.getAttribute('aria-selected')).toBe('true');
});
it('does not open a context menu when it enters the mode', async () => {
// The gesture is claimed, so the layer must not fall through to
// the synthetic `contextmenu` that every other surface still gets.
const el = await mountList();
await hold(rows(el)[0]!);
await el.updateComplete;
const menu = shadow(el, 'menu-surface');
expect((menu as unknown as { active?: boolean } | null)?.active ?? false).toBe(
false,
);
});
it('toggles rows while the mode is on, instead of playing them', async () => {
const el = await mountList();
await hold(rows(el)[0]!);
await el.updateComplete;
await tap(rows(el)[2]!);
await el.updateComplete;
expect(calls('queue.Queue.SetQueue').length, 'no track was played').toBe(0);
expect(
(shadow(el, 'selection-bar') as unknown as { count: number }).count,
).toBe(2);
});
it('leaves the mode when the last row is deselected', async () => {
// Android's own lists do this, and here it matters more than
// convention: the mode changes what a tap means, so a mode holding
// nothing is a list where tapping does nothing and the bar that
// would explain it is showing a count of zero.
const el = await mountList();
await hold(rows(el)[0]!);
await el.updateComplete;
await tap(rows(el)[0]!);
await el.updateComplete;
expect(shadow(el, 'selection-bar')).toBeFalsy();
});
it('keeps the favourite icon a favourite icon', async () => {
// It is inside a row whose tap now plays, and it has been a 44px
// target since #56 -- so without the "a control inside the row
// owns its own tap" rule, that target silently becomes a second
// play button.
const el = await mountList();
const fav = rows(el)[1]?.querySelector('.fav-icon');
expect(fav, 'a row renders a favourite control').toBeTruthy();
await tap(fav!);
await flush();
expect(calls('queue.Queue.SetQueue').length, 'tapping it does not play').toBe(
0,
);
});
it('does not play a row the finger scrolled from', async () => {
// The failure this exists for makes the list unusable rather than
// merely wrong: every flick to scroll would start a track.
const el = await mountList();
const row = rows(el)[1];
press(row!, 'pointerdown');
press(row!, 'pointermove', { clientX: 40, clientY: 200 });
press(row!, 'pointerup');
await flush();
expect(calls('queue.Queue.SetQueue').length).toBe(0);
});
});
describe('<selection-bar>', () => {
it('renders nothing with nothing selected', async () => {
const el = await fixture('selection-bar', { count: 0 });
expect(shadow(el, '.bar')).toBeFalsy();
});
it('announces the count, which changes under the finger', async () => {
const el = await fixture('selection-bar', { count: 3, actions: [] });
const live = shadow(el, '[aria-live="polite"]');
expect(live?.textContent?.trim()).toContain('3 tracks selected');
});
it('names one track in the singular', async () => {
const el = await fixture('selection-bar', { count: 1, actions: [] });
expect(shadow(el, '[aria-live="polite"]')?.textContent?.trim()).toContain(
'1 track selected',
);
});
it('keeps every control at the touch floor', async () => {
// #56 and #186. A bar a thumb uses, in the one mode that only a
// thumb can enter.
const el = await fixture('selection-bar', {
count: 2,
actions: [{ id: 'play', label: 'Play', icon: 'play' }],
});
const buttons = shadowAll<HTMLElement>(el, 'button');
expect(buttons.length).toBeGreaterThan(0);
for (const button of buttons) {
const box = button.getBoundingClientRect();
expect(
Math.min(Math.round(box.width), Math.round(box.height)),
button.getAttribute('aria-label') ?? '',
).toBeGreaterThanOrEqual(44);
}
});
});
/**
* What the gestures leave behind (plan 019 phase 4, #63).
*
* Every track, album and artist name in a row is an `explore-link`,
* which navigates on a genuine single click and a row's single
* *tap* now plays. That conflict is #67's to answer properly; what
* this plan committed to is the narrower half of it, that **tap-to-play
* wins on touch**, and it falls out of phase 1's design rather than
* needing a rule: a claimed tap has its click swallowed at document
* capture, so the link's own handler never runs.
*
* It falls out, which is exactly why it is asserted. Nothing else in
* the suite would fail if the swallow stopped covering the link, and
* the symptom a tap on a track's title navigating to its album
* instead of playing it is one a phone user meets constantly and a
* mouse user never does.
*/
describe('a tap on a name inside a row', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('config.Config.GetShortcuts', {});
stub('queue.Queue.SetQueue', null);
uninstall = installTouchGestures();
});
afterEach(() => {
uninstall?.();
uninstall = null;
vi.restoreAllMocks();
});
/** Where the row's title is rendered, which is a link. */
function titleLink(el: HTMLElement, row: number): HTMLElement {
const link = rows(el)[row]?.querySelector('.explore-link');
expect(link, 'the row renders its title as a link').toBeTruthy();
return link as HTMLElement;
}
it('plays the row rather than navigating', async () => {
const el = await mountList();
const navigations: Event[] = [];
document.addEventListener('navigate', (e) => navigations.push(e));
await tap(titleLink(el, 1));
await flush();
// `explore-link` holds a navigation for DOUBLE_CLICK_GRACE_MS, so
// asserting sooner passes on a build that is about to navigate.
await wait(EXPLORE_LINK_GRACE);
expect(calls('queue.Queue.SetQueue').length, 'the row played').toBe(1);
expect(navigations, 'and nothing navigated').toHaveLength(0);
});
it('toggles the row while selection mode is on', async () => {
const el = await mountList();
const navigations: Event[] = [];
document.addEventListener('navigate', (e) => navigations.push(e));
await hold(rows(el)[0]!);
await el.updateComplete;
await tap(titleLink(el, 2));
await el.updateComplete;
await wait(EXPLORE_LINK_GRACE);
expect(
(shadow(el, 'selection-bar') as unknown as { count: number }).count,
).toBe(2);
expect(navigations).toHaveLength(0);
});
it('leaves the mode on Escape', async () => {
// A mode changes what a tap means, so it needs an exit that is not
// "find the x". It is a dismissal rather than a shortcut, which is
// why it is not a panel-scoped binding.
const el = await mountList();
await hold(rows(el)[0]!);
await el.updateComplete;
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
await el.updateComplete;
expect(shadow(el, 'selection-bar')).toBeFalsy();
expect(rows(el)[0]?.getAttribute('aria-selected')).toBe('false');
});
});
@@ -0,0 +1,333 @@
/**
* The other three selecting surfaces (plan 019 phase 3, #63).
*
* `track-list` got the gestures in phases 1 and 2; the queue panel and
* both playlist detail views are the rest, and phase 3 was "mostly
* wiring" only in the sense that they already share
* `SelectionController`. Two of them are not symmetric with the track
* list at all, and those two asymmetries are what this file is for:
*
* **A tap on a queue row plays that position in the queue.** 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 everything inserted by hand along the way. It is
* not the no-op it reads as.
*
* **The queue panel has no swipe, deliberately.** A right swipe means
* *add to the queue* everywhere 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. So the
* assertion is that the rows do not opt in a swipe there must not
* silently become a second meaning for the app's one horizontal
* gesture.
*
* The playlist views are the symmetric half, and they are here because
* they bind their gestures **per template** rather than through the
* `firstUpdated` delegation the two virtualized lists use, so "the
* handler is attached at all" is a different question in each.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import '@components/queue-panel/queue-panel';
import '@components/playlist-details/playlist-details';
import { Events } from '../../src/events';
import { calls, emit, flush, resetHarness, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures';
import type { QueueTrack } from '@store/queue-store';
const HELD = LONG_PRESS_MS + 120;
const BRIEF = Math.round(LONG_PRESS_MS / 4);
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
afterEach(() => {
// The layer is one document listener set, so a suite that leaves it
// installed makes the next file's gestures fire twice.
uninstall?.();
uninstall = null;
});
function queueTrack(n: number): QueueTrack {
return {
id: n,
audioFileId: n,
filePath: `/music/${n}.mp3`,
position: n,
title: `Track ${n}`,
artist: 'Artist',
album: 'Album',
coverArtPath: '',
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
}
const QUEUE = [1, 2, 3, 4].map(queueTrack);
function playlistTrack(n: number) {
return {
ID: n,
FilePath: `/music/${n}.mp3`,
Title: `Track ${n}`,
Artist: 'Artist',
Album: 'Album',
Duration: 100 + n,
Position: n,
Phantom: false,
};
}
const PLAYLIST_TRACKS = [1, 2, 3, 4].map(playlistTrack);
function press(el: EventTarget, type: string, init: PointerEventInit = {}) {
el.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: 40,
clientY: 60,
...init,
}),
);
}
async function tap(el: EventTarget) {
press(el, 'pointerdown');
await wait(BRIEF);
press(el, 'pointerup');
await wait(0);
}
async function hold(el: EventTarget) {
press(el, 'pointerdown');
await wait(HELD);
press(el, 'pointerup');
await wait(0);
}
/** Drag a row sideways by `dx` and lift, as one finger. */
async function swipe(el: EventTarget, dx: number) {
const at = (x: number) =>
new Touch({
identifier: 1,
target: el as Element,
clientX: x,
clientY: 100,
});
const send = (type: string, points: Touch[]) =>
el.dispatchEvent(
new TouchEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
touches: points,
changedTouches: points.length > 0 ? points : [at(0)],
}),
);
send('touchstart', [at(0)]);
for (const step of [0.25, 0.5, 0.75, 1]) {
send('touchmove', [at(dx * step)]);
await Promise.resolve();
}
send('touchend', []);
await wait(0);
}
/** Past any commit threshold the row could compute. */
const FAR = 400;
describe('a finger on a queue row', () => {
beforeEach(() => {
resetHarness();
stub('config.Config.GetShortcuts', {});
stub('queue.Queue.PlayIndex', null);
stub('queue.Queue.SetQueue', null);
stub('queue.Queue.AddTracks', null);
uninstall = installTouchGestures();
});
async function panel() {
const el = await fixture('queue-panel', { open: true });
emit(Events.QueueChanged, {
tracks: QUEUE,
currentIndex: 0,
shuffleMode: false,
repeatMode: 'off',
sourcePlaylistId: 0,
});
await flush();
await el.updateComplete;
await new Promise((r) => {
requestAnimationFrame(() => r(null));
});
return el;
}
const rows = (el: HTMLElement) => shadowAll<HTMLElement>(el, '.track-item');
it('plays that position rather than rebuilding the queue', async () => {
const el = await panel();
await tap(rows(el)[2]!);
await flush();
expect(calls('queue.Queue.PlayIndex')[0]?.args[0]).toBe(2);
// The asymmetry with `track-list`: setting the queue here would
// discard its source, its shuffle order and anything inserted by
// hand, which is not the no-op it reads as.
expect(calls('queue.Queue.SetQueue').length).toBe(0);
});
it('enters selection mode on a hold, with that row selected', async () => {
const el = await panel();
await hold(rows(el)[1]!);
await el.updateComplete;
const bar = shadow(el, 'selection-bar');
expect(bar, 'the action bar appears').toBeTruthy();
expect((bar as unknown as { count: number }).count).toBe(1);
expect(calls('queue.Queue.PlayIndex').length, 'and nothing played').toBe(0);
});
it('toggles rows while the mode is on, instead of playing them', async () => {
const el = await panel();
await hold(rows(el)[0]!);
await el.updateComplete;
await tap(rows(el)[2]!);
await el.updateComplete;
expect(calls('queue.Queue.PlayIndex').length).toBe(0);
expect(
(shadow(el, 'selection-bar') as unknown as { count: number }).count,
).toBe(2);
});
it('has no swipe, which is a decision and not an omission', async () => {
const el = await panel();
expect(
rows(el)[1]?.hasAttribute('data-swipe'),
'the row does not opt into the shared rule',
).toBe(false);
await swipe(rows(el)[1]!, FAR);
await flush();
// 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.
expect(calls('queue.Queue.AddTracks').length).toBe(0);
expect(calls('queue.Queue.RemoveTracks').length).toBe(0);
});
});
describe('a finger on a playlist row', () => {
beforeEach(() => {
resetHarness();
stub('config.Config.GetShortcuts', {});
stub('playlist.Service.GetPlaylist', {
ID: 1,
Name: 'A Playlist',
TrackCount: PLAYLIST_TRACKS.length,
});
stub('playlist.Service.GetPlaylistTracks', PLAYLIST_TRACKS);
stub('queue.Queue.SetQueue', null);
stub('queue.Queue.AddTracks', null);
uninstall = installTouchGestures();
});
async function details() {
const el = await fixture('playlist-details', {
playlistId: 1,
playlistName: 'A Playlist',
});
await flush();
await el.updateComplete;
await wait(60);
await el.updateComplete;
return el;
}
const rows = (el: HTMLElement) => shadowAll<HTMLElement>(el, '.track-item');
it('plays the playlist from the row it taps', async () => {
const el = await details();
expect(rows(el).length, 'the list rendered rows').toBeGreaterThan(2);
await tap(rows(el)[2]!);
await flush();
const queued = calls('queue.Queue.SetQueue');
// The app's rule: activating one row plays the list that row is
// in, from that row -- not a queue of one that stops when the song
// ends.
expect(queued.length).toBe(1);
expect(queued[0]?.args[1]).toBe(2);
expect((queued[0]?.args[0] as string[]).length).toBe(
PLAYLIST_TRACKS.length,
);
});
it('enters selection mode on a hold', async () => {
const el = await details();
await hold(rows(el)[1]!);
await el.updateComplete;
expect(
(shadow(el, 'selection-bar') as unknown as { count: number } | null)
?.count,
).toBe(1);
expect(calls('queue.Queue.SetQueue').length, 'nothing played').toBe(0);
});
it('queues the row a swipe crosses', async () => {
const el = await details();
await swipe(rows(el)[1]!, FAR);
await flush();
expect(calls('queue.Queue.AddTracks')[0]?.args[0]).toEqual([
'/music/2.mp3',
]);
});
it('opts its rows into the shared touch-action rule', async () => {
// Half of what makes the gesture reach us on the device, and
// invisible in this browser either way -- the other half is the
// non-passive preventDefault in `utils/touch-gestures.ts`.
const el = await details();
expect(rows(el)[0]?.hasAttribute('data-swipe')).toBe(true);
const css = (
customElements.get('playlist-details') as unknown as {
styles: { cssText: string }[];
}
).styles
.map((s) => s.cssText)
.join('\n');
expect(css).toContain('touch-action: pan-y');
});
});
@@ -0,0 +1,299 @@
/**
* Swipe right on a track row to queue it (plan 019 phase 2, #63).
*
* `touch-gestures.test.ts` holds the recogniser the axis rule, the
* claim, the guaranteed end. What is here is what the *list* does with
* it, and the two things that are only true of a list:
*
* **A swipe is not a selection.** It acts on the row it was made on,
* unless that row is one of several the user has explicitly chosen, in
* which case it acts on all of them the same rule the context menu
* answers with, because a bar saying "40 selected" and a gesture that
* quietly queues one of them is two answers to the same question.
*
* **A short swipe is a no-op**, and that is the only thing standing
* between "add to queue" and a scroll that drifted sideways. The
* threshold is a fraction of the row, so it is measured from the row
* here rather than written down twice.
*
* What this tier cannot see is the device, and the reason is in the
* module's own header: Chrome 113's WebView cancels the pointer stream
* ~16px into any drag whatever `touch-action` says, so the gesture
* runs on touch events and needs `touch-action: pan-y` *and* a
* non-passive `preventDefault`. Both are correct in Chromium either
* way. The stylesheet half is asserted below for the same reason
* `hover-affordance.test.ts` reads a parsed stylesheet: the regression
* is someone tidying the declaration away, and nothing here renders
* differently when they do.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import '@components/track-list/track-list';
import { calls, flush, resetHarness, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
import { installTouchGestures } from '@utils/touch-gestures';
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
let uninstall: (() => void) | null = null;
function track(n: number) {
return {
FilePath: `/music/track-${n}.mp3`,
TrackName: `Track ${n}`,
ArtistName: 'An Artist',
Album: 'An Album',
Duration: 100 + n,
ID: n,
};
}
const TRACKS = [track(1), track(2), track(3), track(4)];
async function mountList() {
const el = await fixture('track-list');
// A definite width, because the commit threshold is a fraction of
// the row and a list that has not been given one is not a list.
el.style.display = 'block';
el.style.width = '400px';
(el as unknown as { tracks: unknown[] }).tracks = TRACKS;
await flush();
await el.updateComplete;
await wait(60);
await el.updateComplete;
return el;
}
function rows(el: HTMLElement): HTMLElement[] {
return shadowAll<HTMLElement>(el, '.track-row');
}
/**
* Drag a row sideways by `dx` and lift, as one finger.
*
* `onStep` runs after each move and is awaited, which is how the
* reveal is observed: the component writes the travel straight to the
* row's style but renders the pane through Lit, so it exists a frame
* after the move that asked for it, not during it.
*/
async function swipe(
el: EventTarget,
dx: number,
dy = 0,
onStep?: () => Promise<void> | void,
): Promise<void> {
const at = (x: number, y: number) =>
new Touch({
identifier: 1,
target: el as Element,
clientX: x,
clientY: y,
});
const send = (type: string, points: Touch[]) =>
el.dispatchEvent(
new TouchEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
touches: points,
changedTouches: points.length > 0 ? points : [at(0, 0)],
}),
);
send('touchstart', [at(0, 100)]);
// Several steps, because the recogniser claims the gesture on the
// move that crosses its threshold and the component reads every one
// after it.
for (const step of [0.25, 0.5, 0.75, 1]) {
send('touchmove', [at(dx * step, 100 + dy * step)]);
if (onStep) await onStep();
}
send('touchend', []);
}
/** Where the commit threshold falls for the row as rendered. */
function threshold(row: HTMLElement): number {
return Math.max(72, row.getBoundingClientRect().width * 0.3);
}
describe('a finger swiped right across a track row', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetTracks', TRACKS);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('config.Config.GetShortcuts', {});
stub('queue.Queue.SetQueue', null);
stub('queue.Queue.AddTracks', null);
uninstall = installTouchGestures();
});
afterEach(() => {
uninstall?.();
uninstall = null;
vi.restoreAllMocks();
});
it('adds that row to the queue', async () => {
const el = await mountList();
const row = rows(el)[1];
expect(row, 'the list rendered rows').toBeTruthy();
await swipe(row!, threshold(row!) + 40);
await flush();
const queued = calls('queue.Queue.AddTracks');
expect(queued.length, 'one swipe, one call').toBe(1);
expect(queued[0]?.args[0]).toEqual(['/music/track-2.mp3']);
// It queues; it does not play. The row that was playing keeps
// playing, which is the difference from a tap.
expect(calls('queue.Queue.SetQueue').length).toBe(0);
});
it('does nothing when the finger did not get far enough', async () => {
const el = await mountList();
const row = rows(el)[1];
await swipe(row!, Math.round(threshold(row!)) - 10);
await flush();
expect(calls('queue.Queue.AddTracks').length).toBe(0);
});
it('leaves a scroll that began on a row to the list', async () => {
// The same shape as `touch-selection.test.ts`'s "does not play a
// row the finger scrolled from", one gesture over: the failure
// this guards against makes the list unusable rather than wrong.
const el = await mountList();
const row = rows(el)[1];
await swipe(row!, 30, 200);
await flush();
expect(calls('queue.Queue.AddTracks').length).toBe(0);
});
it('reveals what it will do, in words, while the finger is down', async () => {
const el = await mountList();
const row = rows(el)[1];
const reveals: (string | undefined)[] = [];
await swipe(row!, threshold(row!) + 40, 0, async () => {
await el.updateComplete;
reveals.push(
shadow(el, '[data-testid="swipe-reveal"]')?.textContent?.trim(),
);
});
// Not only a colour (WCAG 1.4.1): the pane says what it is for,
// and says something different once the gesture would commit.
expect(reveals.some((t) => t?.includes('Add to queue'))).toBe(true);
expect(reveals.some((t) => t?.includes('Release to add'))).toBe(true);
});
it('says what it did, for anyone not watching the row', async () => {
const el = await mountList();
const row = rows(el)[1];
await swipe(row!, threshold(row!) + 40);
await flush();
await el.updateComplete;
const said = shadowAll(el, '[role="status"]')
.map((r) => r.textContent?.trim())
.join(' ');
expect(said).toContain('Track 2');
expect(said).toContain('queue');
});
it('queues the whole selection when the row is part of one', async () => {
// One row is a position; several rows are an explicit choice. A
// gesture that quietly queued the one row touched would contradict
// the bar above it saying how many are selected.
const el = await mountList();
rows(el)[1]?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
rows(el)[3]?.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
composed: true,
ctrlKey: true,
}),
);
await el.updateComplete;
const row = rows(el)[1];
await swipe(row!, threshold(row!) + 40);
await flush();
expect(calls('queue.Queue.AddTracks')[0]?.args[0]).toEqual([
'/music/track-2.mp3',
'/music/track-4.mp3',
]);
});
it('queues only the row it touched when that row is outside the selection', async () => {
const el = await mountList();
rows(el)[3]?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
await el.updateComplete;
const row = rows(el)[0];
await swipe(row!, threshold(row!) + 40);
await flush();
expect(calls('queue.Queue.AddTracks')[0]?.args[0]).toEqual([
'/music/track-1.mp3',
]);
// And it did not become a way of selecting anything.
expect(rows(el)[3]?.getAttribute('aria-selected')).toBe('true');
expect(rows(el)[0]?.getAttribute('aria-selected')).toBe('false');
});
it('declares pan-y on the row, which is half of what makes it work', async () => {
// The other half is the gesture module's non-passive
// `preventDefault`. Neither works alone on Chrome 113 and both are
// irrelevant here, so this reads the stylesheet and the attribute
// rather than the rendering — the regression is someone tidying
// one of them away, and nothing in this browser looks different
// when they do.
const el = await mountList();
expect(
rows(el)[0]?.hasAttribute('data-swipe'),
'the row opts into the shared rule',
).toBe(true);
const sheets = (
customElements.get('track-list') as unknown as {
styles: { cssText: string }[];
}
).styles;
const css = sheets.map((s) => s.cssText).join('\n');
const rule = css
.split('}')
.find((block) => /\[data-swipe\]\s*\{/.test(block));
expect(rule, 'the shared rule is in this component').toBeTruthy();
expect(rule).toContain('touch-action: pan-y');
expect(css, 'never none: it takes the scrolling too').not.toContain(
'touch-action: none',
);
});
});
@@ -0,0 +1,162 @@
/**
* Every control a finger meets is at least 44px (#186).
*
* #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 the sort control, its direction
* button, the page actions, the overflow trigger and the phone's search
* button sat between a third and two thirds of the app's own floor.
* Measured on the reference device (TLP301, 424x439): `page-sort` 99x23,
* `page-sort-direction` **28x21**, `page-actions-more` 38x27,
* `search-trigger` 40x40.
*
* Unlike the seek bar's target (#187), this one can be measured here
* rather than inferred from the stylesheet. There the painted track had
* to stay thin, so the hit area was grown past its own box and only a
* phone-width layout of a third-party slider could show it. Here the
* control *is* the target, so a real Chromium rendering a real
* `page-header` gives the actual answer and because it is a `min-size`
* rather than a media query, the answer is the same at every width,
* which is what makes it checkable in this tier at all.
*
* That is also why there is no phone branch to test: 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.
*/
import { describe, expect, it } from 'vitest';
import type { PageAction, PageHeader } from '@components/page-header/page-header';
import '@components/page-header/page-header';
import { fixture, shadowAll } from '@test/support/render';
/** The app's touch floor, from #56. */
const FLOOR = 44;
const SORTS = [
{ id: 'name', label: 'Name' },
{ id: 'tracks', label: 'Tracks' },
];
function actions(): PageAction[] {
return [
{ id: 'import', label: 'Import', icon: 'file-import', priority: 0, onSelect: () => {} },
{ id: 'new', label: 'New Playlist', icon: 'plus', priority: 2, onSelect: () => {} },
];
}
/** Every visible control in the header's own shadow root. */
function controlsOf(el: PageHeader): { name: string; el: HTMLElement }[] {
return shadowAll<HTMLElement>(el, 'button, select')
.filter((c) => !(c as HTMLButtonElement).hidden)
.map((c) => ({
name: c.dataset.testid ?? (c.className || c.tagName.toLowerCase()),
el: c,
}));
}
function tooSmall(controls: { name: string; el: HTMLElement }[]): string[] {
return controls
.map(({ name, el }) => {
const b = el.getBoundingClientRect();
return { name, w: Math.round(b.width), h: Math.round(b.height) };
})
.filter((c) => c.w < FLOOR || c.h < FLOOR)
.map((c) => `${c.name} ${c.w}x${c.h}`);
}
describe("the page header's controls", () => {
it('all meet the touch floor', async () => {
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 50,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
sortDirection: 'asc',
actions: actions(),
});
const controls = controlsOf(el);
// A sweep that found no controls passes vacuously — the same first
// assertion icon-language.test.ts makes, for the same reason.
expect(controls.length).toBeGreaterThan(0);
// The two that were smallest, named so a regression says which.
expect(controls.map((c) => c.name)).toContain('page-sort-direction');
expect(controls.map((c) => c.name)).toContain('page-sort');
expect(tooSmall(controls)).toEqual([]);
});
it('grows the target without growing the box, so the overflow fit is untouched', async () => {
// The regression this exists for, and it was a real one: growing
// the two square controls to 44px *wide* added 22px to the header,
// which 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. #69's fit pass
// measures inline size, so a taller control is free and a wider one
// is not.
//
// Negative inline margins are what keep the box out of it: the
// padding makes the target, and the margin gives the space back.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
sortOptions: SORTS,
sortField: 'name',
actions: actions(),
});
el.style.width = '320px';
for (let frame = 0; frame < 3; frame += 1) {
await new Promise((r) => requestAnimationFrame(r));
await el.updateComplete;
}
for (const selector of ['.sort-dir', '.more-button']) {
const control = shadowAll<HTMLElement>(el, selector).filter(
(c) => !(c as HTMLButtonElement).hidden,
)[0];
expect(control, selector).toBeTruthy();
const style = getComputedStyle(control!);
const added =
parseFloat(style.marginInlineStart) + parseFloat(style.marginInlineEnd);
expect(added, `${selector} gives its extra width back`).toBeLessThan(0);
}
});
it('includes the overflow trigger, which is the route to the rest', async () => {
// At 320px the fit pass collapses actions into the menu, so the
// trigger is rendered — and it is then the only way to reach them,
// which makes it the last control that should be hard to hit.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
sortOptions: SORTS,
sortField: 'name',
actions: actions(),
});
el.style.width = '320px';
for (let frame = 0; frame < 3; frame += 1) {
await new Promise((r) => requestAnimationFrame(r));
await el.updateComplete;
}
const more = shadowAll<HTMLButtonElement>(el, '.more-button').filter(
(b) => !b.hidden,
);
expect(more.length).toBe(1);
const box = more[0]!.getBoundingClientRect();
expect(Math.round(box.width)).toBeGreaterThanOrEqual(FLOOR);
expect(Math.round(box.height)).toBeGreaterThanOrEqual(FLOOR);
});
});
+17
View File
@@ -72,6 +72,23 @@ document.body.style.margin = '0';
beforeEach(() => { beforeEach(() => {
resetHarness(); resetHarness();
// 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 — the track
// list's sort and column widths, the cover size, `now-playing`'s
// scroll mode — is still there when the next file mounts the same
// component. That is invisible until it is intermittent, because
// which files share a tab and in what order changes run to run: it
// cost #138 three scheduled runs, one of them a PR with no frontend
// code in it at all.
//
// Clearing here rather than in the specs that write is deliberate —
// the spec that *reads* is never the one that knows. It is safe for
// the same reason the leak exists: files in a session are
// sequential, so this cannot wipe storage a concurrent file is in
// the middle of using.
localStorage.clear();
}); });
afterEach(() => { afterEach(() => {
-39
View File
@@ -30,12 +30,6 @@ func TestFixturesMatchManifest(t *testing.T) {
m := testfixtures.Load(t) m := testfixtures.Load(t)
for _, want := range m.Tracks { for _, want := range m.Tracks {
// WAV tags are write-only today; see
// TestWAVTagsAreNotReadableYet.
if want.Format == "wav" {
continue
}
t.Run(want.Path, func(t *testing.T) { t.Run(want.Path, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -161,39 +155,6 @@ func TestDuplicateFixturesAreIndistinguishable(t *testing.T) {
} }
} }
// TestWAVTagsAreNotReadableYet pins a known gap rather than hiding it.
//
// backend/tagwriter writes WAV tags into a RIFF "id3 " chunk, but
// backend/metadata reads through dhowden/tag, which recognises MP3,
// FLAC, OGG, MP4 and DSF and has no RIFF parser at all. So every tag
// the app writes to a WAV is invisible to the app that wrote it, and
// WAV tracks always scan in as untitled.
//
// The fixtures are tagged correctly on disk, so when the reader learns
// to unwrap the RIFF chunk this test starts failing — which is the
// point. Delete it then and drop the "wav" skip in
// TestFixturesMatchManifest.
func TestWAVTagsAreNotReadableYet(t *testing.T) {
t.Parallel()
m := testfixtures.Load(t)
for _, path := range m.Case(t, testfixtures.CaseWAVTracks) {
got, err := metadata.ExtractTags(path)
if err != nil {
t.Fatalf("extract tags from %s: %v", path, err)
}
if got.Title != "" {
t.Errorf(
"%s: WAV tags are now readable (%q) — good news; "+
"see this test's comment for what to update",
filepath.Base(path), got.Title,
)
}
}
}
func assertTag(t *testing.T, field string, want testfixtures.Track, got string) { func assertTag(t *testing.T, field string, want testfixtures.Track, got string) {
t.Helper() t.Helper()