Compare commits

...
Author SHA1 Message Date
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
logan 30c6b665f1 fix(system): give the process a temp directory that exists
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m0s
CI / e2e (pull_request) Successful in 9m37s
Android has no /tmp and hands an app no TMPDIR. Go's os.TempDir() falls
back to "/tmp" when the variable is unset, so every library in this
process that wants scratch space was being handed a path that has never
existed.

SQLite is the one that noticed, and it said so precisely:

    W/yellowjacket: msg="champion index rebuild failed"
      explore.search-index.error="populate champion fts: disk I/O error (6410)"

6410 is not a generic I/O error. `6410 & 0xff` is 10, SQLITE_IOERR, and
`6410 >> 8` is 25 -- SQLITE_IOERR_GETTEMPPATH. SQLite could not work out
where to put a temporary file. Two measurements on the device say why:
`ls -d /tmp` does not exist, and the app process's environment carries
no TMPDIR. A shell's does (/data/local/tmp), which is why this is easy
to miss from `adb shell`.

The cost was a silent performance cliff on the slowest device this app
runs on: `championReady` stayed false, so every Explore search took the
generic path over the whole 1,079,667-row index instead of the champion
subset, and the rebuild was re-attempted on every launch.

**The class is fixed rather than the statement.** The trigger is the
*size* of the work, not that query -- anything that spills fails the
same way there, so large sorts, large joins and VACUUM were all waiting
their turn. The repair belongs at the process's one answer to "where do
temporary files go".

`PRAGMA temp_store = MEMORY` was the alternative: cheaper, more local,
and a promise that every future spill fits in RAM on a phone. The
catalog is the largest thing in this app and that is not a promise
worth making silently.

UseTempDir sits beside UseHomeOverride and carries its two rules for
the same reasons. **An empty base is a no-op**, because that is what
application.Mobile.StoragePath() returns on desktop -- so this needs no
build tag and changes nothing off mobile, where /tmp is real. And **an
explicit TMPDIR wins**, so anyone who set one deliberately gets it;
nothing sets it on the platform this exists for. It needs no new Wails
API and no Java change: StoragePath() is already what YJ_HOME is
pointed at, and the directory goes under it.

Two things beyond the rename of a variable.

**Writability is probed, not assumed.** MkdirAll on an existing
unwritable directory succeeds, so without the probe this could set
TMPDIR to a directory nothing can use -- which is the same bug one
directory over, and just as quiet.

**It returns its error, and main logs it.** A temp directory that could
not be created is the same silent failure one step earlier. A failure
is not fatal: it leaves the platform's answer in place, which is what
every release before this one ran with. That log line is readable on
the platform only because of #160.

Verified on the reference device, where the same launch that used to
print the failure now prints:

    I/yellowjacket: msg="champion index rebuilt"
      explore.search-index.elapsed=6.496s

Closes #190
2026-08-21 17:23:23 -04:00
logan d034d6e571 fix(explore): resolve pending release MBIDs against the real table
The release-group MBID backfill queried `release_groups`, which plan 013
renamed to `albums`. It failed on its first statement on every launch
since e7748f1 and the pass returned quietly having done nothing:

    W/yellowjacket: msg="release-group mbid backfill: query failed"
      explore.error="SQL logic error: no such table: release_groups (1)"

What it does is resolve a release-level MBID (MUSICBRAINZ_ALBUMID, which
many taggers write instead of MUSICBRAINZ_RELEASEGROUPID) into the
release-group MBID everything else on the album page is keyed by. A scan
cannot afford a live MusicBrainz call, so `library.updateMBIDs` stashes
the release MBID in `pending_release_mbid` and defers to this. With this
broken the marker was written by every scan and resolved by nothing, so
those albums were untagged as far as the catalog is concerned,
permanently.

**The fix is to call the queries plan 013 already wrote.**
`GetAlbumsWithPendingReleaseMBID` and `ResolveAlbumPendingReleaseMBID`
have been in sql/queries/albums.sql since that change, generated and
never called -- the writer of the marker was repointed at `albums` and
the reader was not. So this is not a missed rename so much as a call
site left behind, and thirty lines of raw SQL and hand-rolled scanning
become three.

That is also the durable half. These two were the last raw-SQL
references to a schema table in the tree, and being raw is exactly why
013 missed them: sqlc reads sql/schemas/ and cannot generate against a
table that is not declared, which is what made every other statement in
the repo immune to the same rename.

Three smaller things.

**The LIMIT came back.** The raw statement bounded a run at
releaseGroupMBIDBackfillMaxPerRun and 013's sqlc replacement had no
LIMIT at all, so switching over as-written would have swapped a dead
pass for an unbounded one -- each row is a live MusicBrainz lookup on a
1 req/s limiter shared with every page the user can open.

**The UPDATE goes through the writer.** `ReadQueries` is a query-only
pool and an UPDATE issued on it fails at runtime with "attempt to write
a readonly database".

**The query is its own method so its failure is assertable.**
A test of the pass as a whole cannot see this bug, because a query
error and an empty library are the same early return -- which is the
whole reason it survived. `pendingReleaseMBIDs` returns the error, and
the test reproduces the device's exact message against the old
statement.

Verified on the reference device: the warning is gone from logcat.

Closes #189
2026-08-21 17:23:05 -04:00
logan 25ea1f3511 Merge pull request 'Android: make the app say what it is doing, then count what the audio path misses' (#191) from 135-android-underrun-instrumentation into main
CI / check (push) Successful in 2m36s
CI / e2e (push) Successful in 9m21s
2026-08-21 20:47:45 +00:00
logan 842fe47e9e feat(player): count what the ring buffer misses
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m38s
CI / e2e (pull_request) Successful in 9m36s
An underrun is audible and nothing counted it. When the ring is empty
BufferedStreamer.Stream zeroes the caller's buffer and returns ok, so a
run of zeros is spliced into the waveform and the step discontinuity at
each edge is a click; a series of short ones is static. That is the one
candidate in #135 whose audible signature matches the report.

`starved` and `starvedSince` already existed, from #122's stall fix,
and could not answer this: they are a *stall* detector, reset by every
arriving sample, because their job is to end a track whose source has
died and a merely slow source must not be cut short. That reset is
exactly what made the audible case invisible -- a hundred 20ms
underruns a minute never approach the give-up threshold, so they were
invisible to the log, to the UI and to every tier.

UnderrunStats counts runs, calls and samples for the life of the
streamer. Runs is the number that means something audible: one episode
is one pop however many callbacks it spans, and the ratio of runs to
calls is what separates clicking from dropping out.

Three things about the reporting are deliberate.

**Counting is in Stream and reporting is not.** Stream runs on the
speaker callback's real-time deadline, so a log line there would
allocate, format and write on the exact path whose missed deadline is
the defect -- measuring by making it worse. The count is two increments
under a lock that was already held; the report is on the 1 Hz position
ticker, which only runs while playing.

**An unchanged count is not logged**, which is emitStatus' rule one
package over. A healthy player is silent, so anything in the log is
news and the line appears exactly while it is popping.

**It is Info rather than Debug.** The default level is Info and a phone
has no convenient way to set YJ_LOG_LEVEL, so a debug line here would
be a counter nobody on the affected platform could read -- which is the
shape of the bug that made #160 necessary.

underrunDelta clamps at zero because the counter belongs to the
streamer and the streamer is replaced on every track: a baseline
carried across that boundary is the previous track's total subtracted
from a fresh zero. The baseline is reset at the load as well; a
negative count in a log line reads as a broken instrument and would
discredit the measurement this exists to make.

This is the instrument, not a fix. What it measures is on the issue.
2026-08-21 16:30:32 -04:00
logan 168e588387 feat(android): route slog to logcat
Every slog line the app wrote on Android went to /dev/null, including
the one naming the error it was about to os.Exit on. #52 is what that
cost: a process that vanished with no tombstone, no AndroidRuntime
stack and nothing in `logcat -b crash`, at Priority/Critical for
months, whose entire diagnosis was one sLogger.Error main.go was
already writing.

backend/androidlog is a slog.Handler over __android_log_write, chosen
in main() by build tag rather than by a runtime check so that a desktop
binary links no cgo for a platform it cannot run on.

**Everything except the write itself is untagged.** That is
androidpayload.go's discipline pushed as far as it goes: the only
toolchain that compiles the android tag is a cross-compiler and the
only thing that runs it is a phone, so the priority mapping, the
formatting, the chunking and the handler's own attr and group
bookkeeping are ordinary Go that `go test` exercises everywhere, and
android.go is fifteen lines that hand a string to liblog.

Four things in it are load-bearing.

**The tag is a fixed string, not the application id.** The debug build
carries `applicationIdSuffix ".dev"` so it can be installed beside the
release app, and it is the only build whose WebView can be inspected --
so a tag derived from the id is a different tag on the one build
anybody debugging this app is running, and the filter meant to show
these lines would hide them exactly where they were being looked for.

**The priorities are android/log.h's own values, asserted twice.**
android.go carries constant expressions that do not compile as uint if
the header renumbers; the untagged test writes the six numbers out
longhand, because comparing a constant to itself passes on any
renumbering. A wrong priority is the failure that hides rather than
breaks -- logcat prints whatever number it is handed, so an Error filed
as Info is present, correct, and invisible to every filter.

**Formatting is delegated to slog's TextHandler.** WithAttrs and
WithGroup are the half of slog.Handler that is easy to get subtly
wrong, and a logger whose groups are wrong is a logger nobody reads.
The derived handlers share the parent's buffer *and its mutex*: a
second mutex would guard nothing, and two loggers derived from one
would splice their bytes into a single line under load.

**A line is chunked, because liblog drops what does not fit.** The
kernel logger's entry is 4068 bytes for tag and message together and
the remainder goes without comment, so a long record would be truncated
in the middle of the thing worth reading.

Time and level are dropped from the formatted line, since logcat stamps
every entry with both -- and dropping them by *key* also ate a caller's
own "level" attribute, which the on-device probe caught and
TestACallersOwnLevelAttrSurvives now holds. ReplaceAttr sees an empty
group path for the built-ins and for every top-level attribute alike,
so the kinds are what separate them.

Verified on the reference device (TLP301, Android 14): a debug build
logs I/W/E under the `yellowjacket` tag at the right priorities, and
the first thing it surfaced was a real warning nobody could previously
see -- `champion index rebuild failed ... disk I/O error (6410)`.

Closes #160
2026-08-21 16:30:32 -04:00
logan 7eb55bd378 Merge pull request 'Android: a Now Playing that survives a 439px screen, and the audit behind it' (#188) from 51-android-small-screens into main
CI / check (push) Successful in 2m31s
CI / e2e (push) Successful in 9m41s
2026-08-21 20:19:46 +00:00
logan f31331c83b docs(skill): what a fresh install is doing before you measure it
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 9m32s
Three things about a fresh install cost a measurement each, and none of
them was written down: it downloads the real catalog, so job-band is
103px of a 439px screen and every vertical number is wrong; stopping
that build returns cleanly and it **starts again within seconds**, so it
has to be stopped immediately before a measurement rather than once at
the start; and a library added over the bridge does not dismiss the
first-run wizard, which then sits over whatever you are looking at with
a correctly disabled button, reading exactly like a swallowed tap.

Also the scoped-storage path that works, the appops grant whose absence
sends the app to the system "All files access" screen on launch, and
why EXPR='...' cannot carry an apostrophe -- a file path with one in it
fails as a JavaScript error. The positional form takes a file.
2026-08-21 15:58:48 -04:00
logan 6a22601af7 docs(player): a device number is not a number CI can assert
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Canceled after 7m48s
The spec's floor on the art's height passed locally at 114 and failed in
CI at 64. Both honest: the e2e app is long-lived so an earlier spec's
job is still on screen, and volume-control renders in a browser where it
does not on Android. Same trap as the staged-job entry above, arriving
as a measurement rather than as a stuck job.
2026-08-21 15:55:56 -04:00
logan ce9951b93a test(player): assert the mechanism, not the room CI happened to have
The floor on the art's height passed locally at 114 and failed in CI at
64. Both numbers are honest and neither is about this change: the e2e
app is long-lived, so a job staged by an earlier spec is still on
screen, and the volume control renders here where it does not on
Android. Both are chrome above and below the view, and both move the
leftover.

So the claim is stated as what the reflow does rather than as what it
measures -- in a row the art is bounded by the row's height and fills
it, where in a column it is the leftover after the names. That is the
mechanism behind 53px to 143px, and it fails on the old build with
"there is no row to fill". The device numbers stay on #51, which is the
only tier that can honestly produce them.

This is the second draft of that assertion to be thrown away; the first
compared the art against the column's leftover and passed on the defect,
because the subtraction goes negative exactly when the names are taller
than the art.

Also stops the arrangement wait from requiring the row to exist, so
reverting the component to check that these tests bite still produces
the crop measurements -- 263x39, 358x315, 300x36 -- rather than eight
timeouts. 5 of 8 fail on the build before this change.
2026-08-21 15:55:43 -04:00
logan 99a45401c7 docs(player): correct the audit's scope, and the probe's false positives
The sweep covered the detail views, Downloads and Autotag as well as
the ten primary views; the note said "ten primary views plus the
queue". The null result is unchanged and now covers more.

Also records the two false positives the probe produced before it was
right, since the next audit will write the same two checks: "painted
outside the viewport" flags a horizontally scrolling carousel, so the
question is whether a scrollable ancestor can bring it back; and a hit
test at a control's centre flags everything below the fold in a scroll
container.
2026-08-21 15:46:50 -04:00
logan dd76bd2fa7 docs(player): record the crop, the reflow, and the audit's null result
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m39s
CI / e2e (pull_request) Failing after 9m47s
The audit #51 asks for, at 424x439 on the reference device with a real
1,577-track library: all ten primary views plus the queue. What it did
*not* find is worth recording, because it is the promise plan 018 makes
-- the shell does not overflow on any view, nothing is stranded outside
a scrollable ancestor, and a hit test at each control's centre reaches
the control. The width work of #57, #62, #55 and #59 holds; what was
left was vertical.

What it found is filed rather than fixed here: #186, every control that
is not the transport is under the 44px floor, and #187, the seek bar's
drag target is 6px.

Also the two device traps that cost time despite being written down --
a fresh install downloads the real catalog and the job band then eats
103px of a 439px screen, and it *restarts* after being stopped; and the
first-run wizard does not re-check for a library it did not create, so
adding one over the bridge leaves it up with a correctly disabled
button.

Closes #51
2026-08-21 15:42:15 -04:00
logan dee176c0f7 test(player): pin the art's shape and the short-screen arrangement
Four of these eight fail on the build before the fix, with the numbers
the issue is about: 263x39 at 424x439, 358x315 at 390x700, 300x36 at
900x500, and no row at all below 500px. The fifth viewport, 412x869,
passes on both -- which is the boundary landing exactly where the
arithmetic says it should, since the leftover only exceeds the width
above ~843.

Three of them cannot fail on the old build and are said to be guards
rather than evidence: that the transport does not scroll off (the old
build shrank the art instead, so it did not scroll either), that a tall
phone keeps its column, and -- after a first draft that passed on the
defect because the subtraction went negative -- a floor on the art at
the device's own viewport instead of a comparison with a layout that is
no longer there.

The wait is on the arrangement rather than on a non-zero box: a
previous test leaves the other layout on screen and a stale column
satisfies "has a size" perfectly, which showed up as one test passing
alone and failing in file order.

What this tier cannot see is the device's engine. Nothing here depends
on Chrome 113 behaviour -- the sizing rules were chosen by measuring
that engine directly, and the numbers are on the issue.
2026-08-21 15:41:59 -04:00
logan 75a24f98b6 fix(player): give Now Playing a layout that survives a short screen
Two things, and the first was a defect underneath the design question
rather than an answer to it.

**The album art was never square.** aspect-ratio is specified not to
re-derive the width when max-height clamps the height, unlike an
intrinsic ratio, which is preserved under both bounds. So a definite
`width: min(100%, 60vh)` kept its width while the height was clipped
and object-fit: cover cropped a square cover into the band -- 264x53
on the reference device, which is what #172's "39px of art" actually
looked like. It is not only the phone either: the leftover exceeds the
width only above ~843px of viewport, so every height from ~500 to ~843
drew a crop. Both maxes with auto sizes is the fix, chosen by measuring
four candidate rules against Chrome 113 itself at five column heights.

The placeholder cannot use that rule -- with no intrinsic size it
collapses to its icon, 13x58 -- so it is driven from the height, with
min-width: 0 because a flex item's automatic minimum is its content,
and max-height: calc(100vw - 2rem) because a non-replaced box cannot
express "the largest square that fits" and went 380x484 on a tall
phone without it.

**Then the reflow.** The stacked budget is fixed, so the art gets
`height - 386` and that is 53px at 424x439. #172 named two ways out;
a floor on the art scrolls the transport off the bottom, and controls
never scrolling off is #51's own Direction and plan 018's promise --
so below 500px the art and the names share a row, where the art is
bounded by the row's height rather than the column's leftover. 53px to
143px on the device, nothing scrolling, the transport untouched.

500 is where the two layouts cross rather than a round number, and it
is keyed on height alone because it answers vertical room: a 900x450
window has the same problem and the same fix.
2026-08-21 15:41:47 -04:00
logan 327785e5ec Merge pull request 'fix(queue): draw the scrim only where it can be tapped' (#182) from fix/171-phone-queue-scrim into main
CI / check (push) Skipped
CI / e2e (push) Skipped
Build & publish the Android APK / apk (push) Successful in 1m29s
Build & publish Arch package / arch-package (push) Successful in 2m37s
Attach the desktop build to the release / linux (push) Successful in 56s
Sync Homebrew formula / sync-formula (push) Successful in 8s
2026-08-21 16:47:46 +00:00
logan 7ba5d321f6 test(queue): pin the breakpoint listener the scrim rule rests on
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 9m18s
The scrim's existence comes from matchMedia rather than a stylesheet, which only holds if the query is listened to — and the stub's addEventListener was a no-op, so deleting the listener left all 986 tests green. The stub records its listeners now and the new case carries a panel across the breakpoint in both directions. Watched failing with the listener removed.
2026-08-21 16:24:04 +00:00
logan f126dd7397 fix(queue): draw the scrim only where it can be tapped
Below 600px `.panel-content` is `width: 100%`, so the scrim sat
entirely underneath an opaque panel -- measured at 424x439, host,
panel and scrim all 424x318. It dimmed nothing and dismissed nothing
there while wearing `cursor: pointer`, so #24's tap-outside-to-close
did not exist on the device it was drawn for.

Of the issue's two directions this takes the second. A gutter is the
drawer pattern and buys the affordance by taking width off a
full-screen surface on a 424px viewport; #55 already made the queue a
*screen* at that width, whose ways out are back and a 44px close
button. So there is no scrim there rather than an unreachable one.

Existence is `matchMedia` rather than `display: none`, on `job-band`'s
rule: a hidden scrim is still an element carrying the handler. The
600-899 band, where the panel is a 320px column of a wider content
area and the scrim has real uncovered pixels, is untouched.

The e2e half asserts *absence* at 424x439 rather than clicking,
because a phone-width case that clicks the scrim's centre hits the
panel and passes on the broken build -- which the issue anticipates.

Closes #171
2026-08-21 16:24:04 +00:00
logan 510d3470f9 Merge pull request 'fix(ui): keep a touch-only affordance reachable, or absent' (#181) from fix/137-touch-only-affordances into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 9m28s
2026-08-21 16:23:43 +00:00
logan d78830aa52 fix(ui): make the touch pen a corner chip, not a scrim over the art
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 9m19s
Always-visible is not the same as always-in-the-way: the overlay is inset:0 at 50% black, so gating it on hover left every touch device with the artwork it is editing permanently darkened. It is only a hint — .cover-art-edit carries the click, so tapping the art always worked — while the × really is the only route to its action and stays. The chip borrows the remove button's size, disc and alpha.

Also corrects the claim that no tier can render as a touch device: no committed one does, which is a choice about projects rather than a limit.
2026-08-21 15:59:45 +00:00
logan a72d1f68ed fix(ui): keep a touch-only affordance reachable, or absent
Three controls are revealed by :hover and are the only route to their
action on a device that has none. #68 hid the home card's play button on
touch, which was right because tapping the card does the same thing;
these are the opposite case, so hiding them removes the action outright
and leaving them costs the same long-press flash #68 was filed for --
they are visibility:hidden / opacity:0, so on touch they are invisible
controls that still take taps.

track-details' cover-art overlay and remove, and shortcut-capture's
reset, are always visible under `@media not all and (hover: hover)`.

The queue row's remove is the third case the report names and takes the
other treatment, because #60 has since landed: the row's context menu is
a bottom sheet carrying "Remove from Queue", so the action is one
long-press away and an always-visible X would spend part of a 424px row
on something already reachable. It is display:none outside
`(hover: hover) and (pointer: fine)` rather than visibility:hidden,
which would leave a button holding its hit area and its place in the
accessibility tree -- the trap this issue is about.

The rule is not extracted into styles/ yet: that leaves two call sites
of the always-visible form, under the four the report names.

No tier here can render as a touch device, so the tests read the parsed
stylesheet the way #68's does and say so; the touch and hover renderings
were measured against the running app in a hasTouch context instead.

Closes #137
2026-08-21 15:59:45 +00:00
logan 60f1c5a6b2 Merge pull request 'build(frontend): fail css-check on a nested rule the phone drops' (#180) from fix/154-nested-css-check into main
CI / check (push) Successful in 2m33s
CI / e2e (push) Successful in 9m31s
2026-08-21 15:59:25 +00:00
logan 11ba7b3180 build(frontend): sweep every stylesheet, not index.css by name
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 9m16s
The hook fires on frontend/**/*.{ts,css} while the script read one hardcoded path, so a second stylesheet would have been silently unswept while the hook still went green over it. There is only index.css today, which is exactly when this is cheap to fix. Watched catching a planted nested rule in a second file.
2026-08-21 15:16:47 +00:00
logan 7f8e185d7c build(frontend): fail css-check on a nested rule the phone drops
The device renders in Chrome 113, which predates relaxed CSS nesting, so
a nested rule whose selector starts with an element name is not a parse
error anyone would notice -- the rule simply does not exist, there and
nowhere else. Three were live in `index.css`, and the one that mattered
was the `text-overflow: ellipsis` on the bottom bar's title and artist,
which had therefore never truncated on the device. No tier here can see
the class at all: the component tier, the e2e tier and `make ui-visual`
all run a current engine, where the rule applies normally.

So `make css-check` carries a second script. It reads `index.css` and
the `css` literals in `src/**/*.ts` alike, since a shadow-root
stylesheet is parsed by the same engine, and it names the file, the line
and the fix -- a leading `&`, which is valid in both syntaxes.

The detection walks blocks rather than matching lines, and both things
it has to get right fall out of one rule: a rule is nested when a
*style* rule is somewhere above it, not when its immediate parent is a
block. That leaves `@media (...) { bottom-nav { ... } }` at the top
level alone, which is the majority of what a regex over the file would
report, and still flags the same rule inside an at-rule that is itself
inside a style rule. Strings and comments are read through, so a brace
in a `url()` is not a block.

The tree has no violation left, so the check would pass just as happily
over an empty glob: it refuses one, and `test/utils/css-nesting.test.ts`
pins the semantics that make the sweep mean something. The literal
scanner the two checks share is lifted into `css-literals.mjs`
unchanged, except that a `${}` substitution is now blanked keeping its
newlines so a line number survives it.

Closes #154
2026-08-21 15:16:47 +00:00
logan 42483c4b61 Merge pull request 'feat(player): show progress on the phone's bar border' (#178) from feat/58-mini-player-progress-line into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 9m2s
2026-08-21 15:16:26 +00:00
logan deea6ad06d test(player): pin the desktop timer gate, drop a leaked queue
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 9m14s
Two gaps a review found. The this.phone gate on the interpolation interval is what CLAUDE.md says earns the matchMedia call, and every test passed without it — so it is asserted on the timer count now, since a desktop render is empty either way and cannot tell the two apart. Watched failing with the gate removed.

The e2e spec left LONG_TRACK playing in a workers: 1 suite against one long-lived app, immediately before four other phone-* specs. Nine specs clear the queue in afterEach for that reason and phone-transport.spec.ts records the flake it caused.
2026-08-21 10:45:15 -04:00
logan fba608fdbd docs(player): attribute the phone seek bar's removal correctly
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Canceled after 0s
The paragraph said #59 took the seek bar off the phone's transport.
It was plan 016 B2 — audio-player.ts says so in the comment above the
rule that does it, and CLAUDE.md's own #59 paragraph says #59 removed
shuffle, repeat and the queue button. Wrong provenance in the file
whose whole value is being right about which change did what.

Also stop tracking .pi/journal.md. It is a scheduled run's scratch log,
and this repo's memory is CLAUDE.md and .planning/ — a session log
arriving inside a feature PR is a new convention landing sideways.
2026-08-21 14:38:12 +00:00
logan f59490b113 feat(player): show progress on the phone's bar border
#59 took the seek bar off the phone's transport, so the one thing a
mini player is expected to say without being opened -- how far through
the song it is -- had nowhere left to be said.

It is the shell's element and its own 2px grid row between `bottom-bar`
and `bottom-nav`, because those two are separate components and either
one drawing the line means reaching into the other's box. The fill is
`scaleX()` off the same `PlaybackPositionChanged` the seek bar renders,
with the same `trackChangeId`/`seq` guards and an interval that only
interpolates *between* reports -- never its own clock, which is the
rule that exists because a local counter drifted 30 s away from the
backend across four keyboard seeks.

It is `aria-hidden` and takes no pointer events at any depth: Now
Playing's seek bar is what announces the position, and a 2px strip on
the top edge of the tab bar is exactly where a thumb aiming at a tab
lands. It renders nothing above 600px, from `matchMedia` rather than a
media query, because a stylesheet cannot stop a 1 Hz interval running
for the life of every desktop session about a line nobody can see.

Its phone rule is at the foot of index.css beside `job-band`'s, not in
the phone block above: a media query adds no specificity, so a
`display: block` written before the `display: none` that takes it out
of the desktop grid loses to it and the line never appears at all.

Closes #58
2026-08-21 14:38:12 +00:00
logan 6cca57f229 Merge pull request 'fix(explore): scroll the album page as one on a phone' (#179) from fix/66-album-page-scrolls-as-one into main
CI / check (push) Successful in 2m26s
CI / e2e (push) Successful in 9m23s
2026-08-21 14:36:20 +00:00
logan ea3edde697 fix(explore): scroll the album page as one on a phone
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 9m8s
`explore-album-details` was a fixed header over a scrolling tracklist,
which is the desktop arrangement. At the reference device's 424x439 the
header owned 253 of the panel's 318px and the list scrolled inside the
64 that were left, and the header's flex row squeezed `.album-info` to
112px beside a 200px cover -- so the title drew as one ellipsised glyph
and two of the album's three primary actions were clipped by the
component's own `overflow: hidden`: "Shuffle album" ended at x=443 in a
424px box, reachable by no gesture.

Below 600px the host is the scroller and `.content` stops being one, so
the header scrolls away and the page moves together; the header stacks
art over info, so the info column has the row's whole width. The
tracklist is plain DOM rather than a virtualizer, so nothing inside
wants a scroll window of its own.

Another `min-width: 0` was not the fix and the issue's own measurement
says so: `.album-info` carries one and was shrinking as asked. Nor
could `layout-overflow.spec.ts` see any of this -- `body.scrollWidth`
equalled the viewport throughout, because the overflow was inside a
component -- so the new spec measures each header control against the
host's own box, which is `top-bar-fit.spec.ts`'s shape for the same
reason.

The phone block is last in the stylesheet on `index.css`'s rule: a
media query adds no specificity, so above the rules it overrides every
declaration in it would be silently dead.

Closes #66
2026-08-21 04:40:35 -04:00
logan 14e3ab574c Merge pull request #176: context menus are a bottom sheet on a phone
CI / check (push) Successful in 2m27s
CI / e2e (push) Successful in 9m9s
2026-08-21 07:25:55 +00:00
logan 4b2eec5703 Merge remote-tracking branch 'origin/main' into 60-context-menu-action-sheet
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 8m51s
2026-08-21 02:39:29 -04:00
logan 3871d37fdb Merge pull request 'chore(agent): add the scheduled backlog-issue prompt' (#177) from pi-agent-backlog-automation into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 8m54s
Reviewed-on: #177
2026-08-21 06:29:30 +00:00
logan 09b005557c chore(agent): add the scheduled backlog-issue prompt
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m25s
CI / e2e (pull_request) Successful in 9m2s
A scheduled pi session reads this file and works one open issue end to
end: pick, claim, branch, implement, verify on the tier the change
demands, open a PR, stop. It declines rather than improvises where it
cannot verify itself — a busy :34115 means another worktree is running
the app, and a green e2e run against someone else's build is worse than
no run at all.
2026-08-21 02:28:55 -04:00
logan ef5574d18b docs(shell): record the clip, and the four things only a device showed
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m32s
CI / e2e (pull_request) Successful in 8m57s
CLAUDE.md gains the surface beside the keyboard model it shares, and
NOTES.md the measurements: the 83px clip with its screenshot, the probe
that established a top-layer dialog escapes paint containment from
inside a view, the UA stylesheet's 354px, the focus steal a longer
retry cannot beat, and the submenu this change pushed off-screen before
it pulled it back.

The last of those is also a note about scope: the issue was claimed
saying the submenu would be measured and filed, and the measurement
said fix it.
2026-08-21 02:24:04 -04:00
logan 31dafb0ce0 test(shell): assert the surface, and sweep for a menu that skipped it
No tier here can reproduce the defect: this runner's Chromium and CI's
WebKit both have the Popover API, so the popup is top-layered and looks
perfectly correct, and a spec asserting "the menu is not clipped" would
pass on the broken build.  So these assert the mechanism -- that the
surface is a native <dialog> at phone width -- which is the same move
queue-as-a-screen.spec.ts makes about containment, for the same reason.

The sweep is the more valuable half.  A thirteenth menu written as a
bare <wa-popup> would work in every tier here and be clipped on the
device, so this reads every source file and fails on one outside a
three-file allowlist, each entry carrying why.  It found two call sites
the by-hand conversion had missed.

Four of the six behavioural tests fail on the build before this change;
the two asserting the desktop popup cannot, because that behaviour was
already there.

Closes #60
2026-08-21 02:24:02 -04:00
logan 9e7e7ce5a1 feat(shell): put every menu in the app through the one surface
Fourteen call sites, one tag name each and nothing else -- which is what
menu-surface's shape buys: the host's panel is slotted into whichever
presentation is up, so no item model, no keyboard model and no styling
moved.  The 48px rows come from contextMenuStyles, the one stylesheet
every one of these hosts already includes, because the panel is the
host's own light DOM and only the host's stylesheet can reach it.

Two of the fourteen were found by the source sweep rather than by the
conversion: queue-panel's add-to-playlist popup, which is a real menu.
now-playing's cover preview is allowlisted instead -- it is a hover
affordance in the bottom bar, so a touch device never opens it and
nothing clips it.

The playlist submenu had to come too, and that is the one place this
change made something worse before it made it better.  It is a
placement="right-start" flyout anchored to its row, and making the menu
full-width moved that anchor to x=0 -- so the flip put the picker at
x -182 to 0, entirely off-screen, and "Add to Playlist" led nowhere at
all.  Before the change the row started at x~245 and the same flip
landed on screen.  It is a sheet now and stacks over the first, which
is also why menu-shown does not re-assert focus while it is open.

The three hosts that do not use ContextMenuController -- page-header's
overflow menu, playlist-view's hand-rolled menu, queue-panel's picker
-- bind menu-dismiss themselves, or Escape would close the sheet and
leave their own open flag set.

page-header is included deliberately: the clipping does not bite there,
since it opens downward from the top of a full-height view, but on a
phone every action of an overflowing page lives in that menu at
wa-dropdown-item defaults.  One surface, so there is no second answer
to what a menu looks like.
2026-08-21 02:23:48 -04:00
logan 9aaa8beb99 feat(shell): draw a context menu where it fits, not where it is anchored
On the reference device every context menu in the app is clipped, and
the two halves of that are structural rather than incidental.  Chrome
113 has no Popover API, so wa-popup takes its own documented fallback
and positions with strategy: "fixed"; .main-panel carries
contain: layout style paint, and paint containment clips fixed
descendants.  Measured at 424x439 before any of this: the main panel
spans 0-318, the open menu spanned 191-401, and three of its seven
items were cut off with no way to reach them.  Rows were 29px against
a 44px floor.

menu-surface is one element with two presentations -- a wa-popup above
600px, a wa-dialog bottom sheet below it -- so the host keeps rendering
the panel it always rendered and ContextMenuController keeps driving
.active and .anchor as though it were talking to a popup.  showModal()
is Chrome 37 and uses the real top layer, so the sheet is immune by
construction rather than by styling.

Four things needed measuring on the hardware rather than reading.

"A dialog escapes containment" was the premise and was untested here:
every other dialog in this app is mounted in index.html, outside
.main-panel.  A probe dialog appended to track-list's shadow root
paints to y=439, over the mini player and the tab bar.

A native dialog's UA stylesheet centres it and caps its width, which
drew a 354px panel in the middle of a 424px screen -- so four
declarations in this component are pure undoing.

wa-dialog focuses [autofocus] or itself on the frame after
showModal(), and it cannot see our first menu item to prefer it: the
panel is slotted, so its own querySelector stops at the <slot>.  A
longer retry budget does not fix that, because the first attempt
succeeds and is then overwritten -- hence menu-shown and
MenuKeyboard.refocus().  The budget became time-based anyway, since
what is being waited for is another component's animation.

And a dismissal has to travel back: wa-dialog closes itself on Escape,
which would leave the controller believing the menu is open.  The
failure mode there is not a stuck sheet but the *next* long-press
doing nothing, which reads as the gesture breaking.
2026-08-21 02:23:33 -04:00
logan 2e29e67664 Merge pull request #174: Android: leave the volume to the system
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 8m56s
2026-08-21 05:50:06 +00:00
logan f26b44db08 docs(player): close three of the four gaps with a real device
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 9m5s
A Light Phone III (Android 14, SDK 34, arm64, Chrome 113 at 424x439)
was attached after the PR was opened, so what it listed as
unverifiable was checked rather than left as a caveat.

SystemOwnsVolume answers true on the device -- the build tag, the
constant, the field and the generated binding, end to end, which is the
one thing a source sweep only approximates and which nothing else here
compiles at all.  The control is absent in both mount points on the
real engine, and the transport measures 143px, exactly what the
desktop-headless "after" predicted.  A stored volume of 37 survives a
session that demonstrably rewrote the row.

The duck is the one that stays open, and now for a stated reason rather
than for want of hardware: the foreground service omits
setWillPauseWhenDucked from Oreo, so the framework attenuates us itself
and never sends AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -- the device logs
`requestAudioFocus() ... flags=0x0` saying so.  minSdk is 21, so that
path is live code on Android 5.0 to 7.1 and unreachable above it.
Asking for a modern phone will not test it.
2026-08-21 01:34:44 -04:00
logan b43172a60c docs(player): record who owns the volume, and what it gave back
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Successful in 8m47s
CLAUDE.md's volume paragraph ended "#64 asks for it to be gone on
Android outright, which is a platform question the frontend cannot
currently ask", which is no longer true -- it can, and the paragraph
now says why the answer is a capability rather than a viewport and what
that costs.  The mediacontrols entry gains the corollary: on that
platform "the user's level" is a constant, and the duck is the one
thing that may still move the output.

NOTES.md carries the measurements: the per-element budget at 424x439
before and after, the :host([hidden]) specificity trap, the fact that
the bar's centring survives the control going away, and what no tier
here could check.
2026-08-21 00:53:44 -04:00
logan 2be6fb3066 feat(player): draw no volume control where there is no volume
volume-control asks the player whether there is a volume of ours to
control, and renders nothing when there is not.  The decision is in the
control rather than at either mount point because there are two, and
one of them -- the bottom bar's -- lives in index.html, which has no
module scope to make it conditional.

It could not have been a width, and that is the whole design decision.
Every other stand-down rule in this app is keyed on a viewport, because
a width is what a browser can answer and what every tier can test.
This one is a property of the build: keyed on width, an Android tablet
at 600px or more draws the bar's slider over a level the backend has
pinned -- a control that cannot act, on exactly the platform the rule
exists for, which library-status-indicator settled is worse than none.
The same rule is wrong the other way below 600px, where a narrow
desktop window has no hardware keys to fall back on.  index.css keeps
its phone rule, which is now about room and says so.

Rendering nothing and hiding the host are both needed and are separate
assertions: an empty shadow root is what stops a by-role or positional
query finding a button that cannot act, and :host([hidden]) is what
stops the element taking a flex item's worth of the transport.  The
host rule has to be written down, since :host { display: inline-flex }
outranks the UA's [hidden].

Measured at 424x439 by flipping the constant and rebuilding: the album
art goes 39px to 68px and the transport 172px to 143px -- 29px, being
the 21px control plus the 8px gap a hidden box stops drawing.  The
bar's centring is unaffected, since #23's outer columns are the same
min() expression rather than content-sized.

volume-ownership.test.ts is the tier that can exercise the Android
rendering, on an ordinary Linux runner, because the predicate is a
stubbable backend answer.  Both of its tests were confirmed to fail on
the build before this.

Closes #64
Closes #172
2026-08-21 00:53:36 -04:00
logan 867ced8c81 feat(player): leave the volume to the system where the system owns it
On Android the hardware keys are the volume control and the framework
mixes our stream against the device level, so a second control inside
the app moves something the user already moved.  Where that is true the
player's level sits at maximum, SetVolume / ChangeVolume / MuteToggle
are refused, and nothing persists a level nobody chose: restore
remembers the stored value instead of applying it, and saveState writes
that same value back rather than recording the synthetic maximum.

Mute is in that list because it is a level of zero by another name --
and because with no control rendered it would be the one state on such
a platform the user could not get out of.

The predicate is named after the capability rather than the platform,
because that is what makes it testable.  Only platformOwnsVolume is
behind a build tag, in two files that declare nothing else; everything
else is decided against Player.systemVolume, a field a test sets either
way.  That is mediacontrols' split, with androidpayload.go's reasoning
for keeping the contract out of a tagged file, and the tagged pair is
covered by a source sweep since no tier here compiles both halves.

SetDuck is deliberately untouched: it applies its attenuation by
re-applying the *user's* level through setVolumeLocked, so pinning that
level to maximum leaves the offset arithmetic exactly as it was.  It is
the only thing that may still move the output on such a platform, and
TestSystemVolumeStillDucks is that property rather than a comment.
2026-08-21 00:53:21 -04:00
logan fd71ef53c5 Merge pull request 'The phone transport: three controls, sized for a thumb' (#173) from 59-slim-the-mini-player into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 9m5s
2026-08-21 04:14:39 +00:00
logan e3b64f9255 test(player): assert the desktop bar's size by mechanism, not by pixels
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m24s
CI / e2e (pull_request) Successful in 8m56s
WebKit draws the same button 36x24 where Chromium draws 33x21, so the
literal this pinned failed in CI on a build where nothing was wrong. A
button's box comes from the UA stylesheet when the author sets nothing,
and what each UA sets is its own business.

What must not happen is that *we* set something. So: `min-width` and
`min-height` compute to 0px, the font-size still equals that of a bare
button probed in the same page, and all five boxes are identical --
which is what says the desktop is neither sized context. Checked by
re-introducing the `font-size: inherit` regression, which it catches in
Chromium; the literal form could only be checked by hand.
2026-08-21 00:02:27 -04:00
logan c7e5a4f086 docs(player): record the phone transport, and four silent failures
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Failing after 9m38s
The model in CLAUDE.md beside the volume rule it qualifies; the
measurements and the four things that cost a cycle each in NOTES.md,
dated. Three of the four are invisible to every assertion in the repo:
a button not inheriting its font, a nested rule out-specifying a later
one, and art whose height is bounded by nothing.
2026-08-20 23:42:36 -04:00
logan f65822c4b2 test(player): pin the phone transport, and the desktop bar not moving
Ten tests, five of which fail on the build before this. The desktop
guard is meant to pass there -- that is its job, and it is the one that
caught a three-pixel regression nothing else could see.

`openTheQueue` moves to the fixtures, because hiding one button failed
ten tests in four files about the back stack and about layout: every
one of them opened the queue by clicking `#queue-button`, and so was
quietly asserting *which* route exists as well as what the queue does.
The route differs by width now and that is the feature.

Two smaller things. The play button is named for its action, so an
exact 'Play' waits out a fixture track -- 11.1s per test, passing by
luck, and it would have failed outright against LONG_TRACK. And the
"nothing playing" case clears the queue itself rather than trusting the
app not to have played anything: `make e2e` runs one long-lived app
across every spec (#168), which is how a deterministic bug first showed
up as a flake.
2026-08-20 23:42:35 -04:00
logan 32d4dc2c82 feat(player): slim the phone's mini player to three controls
Shuffle, repeat and the queue button leave the phone's bottom bar.
They are not gone: all three are on the full-screen Now Playing view,
one tap away through the mini player's art, which is the "reachable
only from Now Playing" this issue asks for. #55 is what makes the queue
half safe -- it is a screen with an entry in the back stack now, rather
than a panel with no way out but the button being removed here.

Removing a control is only allowed because it is still reachable, which
is plan 018's matrix promise, so that is what the spec walks rather
than counting buttons. It found that the route did not exist in the
state that matters: `now-playing` renders two branches and the no-track
one had no `.expand` button on its placeholder, so with nothing loaded
there was no way to the full-screen view at all -- and once the queue
button left the bar, no way to the queue. The queue is persisted across
restarts, so "tracks queued, nothing playing" is a state the app
launches into, not a corner.

The favourite stays on the bar and was 18x14px, the smallest control in
the app, against the 48x48 art beside it.

One CSS trap, because it failed silently. The phone block is last in
index.css on purpose -- a media query adds no specificity -- but the
rule it overrides here is written *nested* inside `.bottom-bar`, so it
builds to a descendant selector one class more specific and a bare
`#queue-button` lost to it. Being last is not enough when the thing
above is more specific.

Closes #59
2026-08-20 23:42:34 -04:00
logan 218e4f5e99 feat(player): give the transport a context, and thumb-sized controls
Measured at the reference device's 424x439, every button here was
33x21px -- in the bottom bar and on the full-screen view alike. #56
reports them as "the most important thing in the mobile app and they
are tiny", and that is the number behind it.

The context is a **property, not a media query**, and that is the whole
design. Everywhere else in this app a component states what it drops at
phone width itself, because a media query inside a shadow root is
answered by the viewport and that is the honest signal. Here the two
hosts want different answers at the *same* viewport: on a phone the bar
wants three controls sized for a thumb and now-playing-view wants five,
larger still. So the host says which context and the viewport says
which size band, and neither alone can express it.

Play/pause alone goes above the 44px floor. A row of five identical
squares says every action is equally likely, which is not true of play
-- "large play/pause, adequate prev/next" is the Direction, and a spec
caught that the first version had sized all three the same.

Two things that fail silently:

The desktop bar must not move, and a `<button>` does not inherit its
font from its parent -- the UA stylesheet gives it one. So a generic
`font-size: inherit` is not the no-op it reads as: it took every
desktop control from 33x21 to 36x24. The box rules take a zero fallback
and the font-size rules are scoped to the two contexts that set one.

And the art on now-playing-view overflowed its own box, drawing over
the header above and the title below, because `aspect-ratio: 1` with a
definite width derives a height that nothing bounds -- 60vh bounds the
viewport, not the room left over. `max-height: 100%`. Pre-existing;
found by reading a screenshot, which is the only tier that can see it.

What is left is #172: with the transport at 172px of a 439px screen the
art is a 39px sliver.

Closes #56
2026-08-20 23:42:15 -04:00
logan 56a5ff99fe Merge pull request 'The queue is a place while it covers the content' (#169) from 55-queue-as-a-screen into main
CI / check (push) Successful in 2m35s
CI / e2e (push) Successful in 8m41s
2026-08-21 03:00:45 +00:00
75 changed files with 7451 additions and 507 deletions
+5
View File
@@ -88,3 +88,8 @@ build/android/overlay.json
# Written by @semantic-release/changelog purely to carry the release notes
# into scripts/gitea-release.sh; the release page is the changelog.
.release-notes.md
# Agent session log: local scratch, not repo memory (that is CLAUDE.md
# and .planning/). Written by the scheduled backlog runs.
.pi/journal.md
.pi/schedule-prompts.json
+142
View File
@@ -0,0 +1,142 @@
---
description: Take on the next actionable backlog issue end to end, and stop
---
Take on exactly one issue from the YellowJacket backlog, end to end, and stop.
Repo: yonlu/yellowjacket at https://git.ljones.me — API base
https://git.ljones.me/api/v1/repos/yonlu/yellowjacket, auth with
`-H "Authorization: token $GITEA_TOKEN"`. Default branch is `main`.
## 1. Orient before you pick
Read, in this order: `CLAUDE.md` (the architecture and the reasons behind
it), `.pi/journal.md` (what happened last), `.planning/NOTES.md` (what was
already considered and rejected), and `.planning/plans/active/`. Do not skip
this because the issue looks small — most of this codebase's traps are
written down in exactly one of those four places, and the ones that bite are
the ones you didn't read.
## 2. Pick the issue
List open issues. Choose the single highest-value one that is *actionable
right now*:
- Order by `Priority/Critical``High``Medium``Low`. Within a tier,
prefer `Reviewed/Confirmed`, then `Kind/Bug` over `Kind/Enhancement` over
`Kind/Feature`.
- Consult issue #73 (the roadmap) — if it sequences the candidates, that
ordering wins over the label ordering.
- **Skip** anything labelled `Status/Blocked`, `Status/In Progress`,
`Status/Abandoned`, `Reviewed/Won't Fix`, `Reviewed/Duplicate`,
`Reviewed/Invalid`, or already carrying an open PR.
- **Skip anything someone else is already on.** The label is not the only
claim, because a concurrent session may not have applied it — several pi
sessions run against this repo from separate worktrees under
`~/.paseo/worktrees/`. Run `git ls-remote --heads origin` and skip any
issue whose number or slug matches an existing branch (`60-…`,
`fix/<slug>`). A duplicated fix costs more than a skipped issue.
- **Skip** anything that cannot be verified without hardware you do not
have: physical-device Android behaviour (audio output, on-device file
writes, real gesture input). A browser at 424px is not a phone — see the
Chrome 113 section of `CLAUDE.md`.
- **Skip** intermittent-failure issues unless you can reproduce the failure
on demand within a few minutes. Chasing a 1-in-3 flake is an unbounded
task and does not belong in a scheduled run.
- If nothing qualifies, say so, do nothing, and stop. An empty run is a
correct outcome.
## 3. Claim it
Add `Status/In Progress` to the issue and comment that you are picking it
up. Then branch:
```
git fetch origin && git checkout -b <type>/<short-slug> origin/main
```
`<type>` matches the issue's `Kind` (`fix/`, `feat/`, `refactor/`, `test/`,
`docs/`, `ci/`). Branch from `origin/main`, never by checking out `main`
itself — this repo is worked from several git worktrees at once and `main`
is checked out in one of them, so `git checkout main` fails outright.
## 4. Do the work
Fix the issue that was reported and nothing else. Match the surrounding
code's style. Follow the constraints in `CLAUDE.md` rather than reasoning
from first principles — where it explains why something is shaped the way it
is, that shape is load-bearing and there is usually a test pinning it.
**Anything else you discover becomes a new issue, not a bigger diff.** File
it with the right `Area/`, `Kind/`, `Priority/` labels, describe the
symptom before the theory, and link it from your PR. Scope creep is the
failure mode this instruction exists to prevent.
If the work turns out to be materially larger than the issue implied, stop:
comment on the issue with what you found and what it would actually take,
remove `Status/In Progress`, push nothing, and end the run.
## 5. Verify — the right tier, not the cheapest one
Run `make generate` if you touched `.sql` or `.templ`, and `make bindings`
if you changed a bound Go signature. Then run what the change actually
demands:
- Go change → `make lint` and `make test` (both cover all three build
configurations).
- Frontend component or store → `make ui-test`.
- User-visible flow → `make e2e` against `make dev-headless`. **Check the
port first**: `ss -ltn | grep 34115`. If it is occupied, another worktree
is already running the app — do not start a second one and do not run
`make e2e`. Attaching to someone else's build produces a green result
about code that is not yours, which is worse than no result. Either
choose an issue that does not need this tier, or stop and say why.
- Anything cosmetic or layout-related → look at a screenshot. Several bugs
in this repo's history were invisible to every assertion and obvious in an
image.
A tier you skipped is a claim you did not check. If a tier fails for reasons
unrelated to your change, say so explicitly rather than quietly moving on.
## 6. Keep the documentation true
If you changed structure, behaviour, or a constraint, update `CLAUDE.md` in
the same commit. That file is this project's memory; a change that leaves it
describing the old shape is worse than no change. Append a short entry to
`.pi/journal.md` covering what you did, what you verified, and what you left
open.
## 7. Commit and open the PR
Conventional Commits, imperative subject, ≤72 chars, scope optional. The
body explains *why*. Push the branch — never push to `main`, never
force-push.
Open the PR:
```
curl -sS -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
https://git.ljones.me/api/v1/repos/yonlu/yellowjacket/pulls \
-d '{"head":"<branch>","base":"main","title":"<subject>","body":"<body>"}'
```
The body states: what the issue was, what you changed and why, **which
verification tiers you ran and their results**, anything you deliberately
did not do, and `Closes #<n>`.
Then wait for CI (`ci.yml`, jobs `check` and `e2e`) and report the result on
the PR. If it fails, read the log — `gitea_ci`'s `job_logs` 404s on this
Gitea build, so use
`GET /api/v1/repos/yonlu/yellowjacket/actions/runs/<run>/jobs` for per-step
status and `GET /api/v1/repos/yonlu/yellowjacket/actions/jobs/<id>/logs` for
the log — and fix it. Two consecutive failed CI runs on the same cause: stop,
comment what you know on the PR, and leave it for a human.
**Do not merge.** Comment on the issue linking the PR, leave
`Status/In Progress` on, and end the run.
## Finally
Report in three lines: which issue you took, what state it is in
(PR open / CI green / stopped and why), and any issues you filed.
+7 -1
View File
@@ -130,7 +130,13 @@ reference, because you need them *before* the failure, not after.
what you otherwise get is `Property 'scroll' does not exist on type
'CSSResult'` pointing at a line of prose, or every test in the suite
failing to import. It went in after the trap cost a fourth session in
which its own warning had been read twice.
which its own warning had been read twice. **The same command carries
a second CSS check**: a nested rule whose selector starts with an
element name (`audio-player { … }` rather than `& audio-player { … }`)
is silently dropped by the device's Chrome 113 and by nothing else, so
every tier you can run renders it correctly. Run it after touching
`index.css` or any `css` literal; a rule directly inside a top-level
`@media` is not nested and is not flagged.
- **A failing CI job's log is reachable even when `gitea_ci job_logs`
says it is not.** That endpoint 404s on this Gitea build. The REST
API answers, with the `GITEA_TOKEN` already in the environment:
@@ -8,13 +8,29 @@ This tier answers "does the phone build run", nothing else. It is not a
spec tier, it does not run in CI, and the app is not a usable Android
player yet (plan 015 says why, at length).
## Three facts that make failure invisible
## Two facts that make failure invisible
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
`/dev/null`. Every `slog` line the app writes is discarded — including
the one naming the error it is about to exit on. `setprop
log.redirect-stdio true` does not help: it redirects the *Java*
runtime's `System.out`, and the Go code is a c-shared native library.
There were three. The first was that **Go's stdout does not reach
logcat** — an Android app's fd 1 and 2 go to `/dev/null`, so every
`slog` line the app wrote was discarded, including the one naming the
error it was about to exit on. That is fixed (#160):
`backend/androidlog` is a `slog.Handler` over `__android_log_write`,
selected in `main()` by build tag, and the app's whole diagnostic
stream now arrives under the `yellowjacket` tag, which `make
android-logs` filters for.
What remains true about it is the part that misleads: **`setprop
log.redirect-stdio true` still does not help**, because it redirects
the *Java* runtime's `System.out` and the Go code is a c-shared native
library. Nothing that reaches logcat here does so through stdout, so
anything printed with `fmt.Println` is still lost. Log with `slog`.
The tag is a fixed string rather than the application id, and that is
load-bearing rather than tidy: the debug build carries
`applicationIdSuffix ".dev"` so it can be installed beside the release
app, and it is the only build whose WebView can be inspected — so a tag
derived from the id would be filtered out on the one build anybody
debugging this app is running.
**`os.Exit` is a silent death.** `main()` ends several failure paths in
`os.Exit(1)`. From Android's side that is a process that vanished:
@@ -34,7 +50,10 @@ the wrong question. `make android-smoke` asks the right one — is it the
The tell, once you know it: `I/WailsBridge: Wails bridge initialized`
followed immediately by a new pid doing the same thing. That means the
native library loaded, the JNI bridge came up, Go's `main()` ran, and
`main()` left. Work backwards through its `os.Exit(1)` paths.
`main()` left. Work backwards through its `os.Exit(1)` paths — and
since #160, **read the `E/yellowjacket` line above it first**, because
every one of those paths logs the error before it exits. That line is
what #52 spent months without.
## What to run
@@ -524,6 +543,61 @@ Four things about it, each of which costs an hour if met cold:
app.yellowjacket.dev android.permission.READ_MEDIA_AUDIO` (and
`POST_NOTIFICATIONS`) ahead of the launch skips it.
### Getting the app into a state worth measuring
A fresh install is **not** a neutral starting point, and three things
about it will each cost you a measurement.
**It downloads the real catalog.** `YJ_CORE_INDEX_URL` is stubbed in
`dev-headless.sh` and in CI and is *real* here, so the app spends its
first minutes fetching ~0.6 GB and `job-band` is **103px of a 439px
screen** while it does. Every vertical number taken in that state is
wrong -- one #51 measurement had the album art at 0px and it was
entirely this.
`__yj.call("explore.Service.StopIndexBuild", [])` stops it and returns
cleanly. **It then starts again within seconds.** So stop it
*immediately before* the measurement rather than once at the beginning,
and check `jobs.Service.GetJobs` afterwards -- an empty array is the
only proof. `jobs.Service.ClearFinishedJobs` tidies the finished rows
that otherwise keep the band open.
**A library added over the bridge does not dismiss the first-run
wizard.** `library.Library.AddLibrary` works and scans, but the wizard
checks for an existing library once, on mount, and its "Get Started"
button gates on a directory chosen *in the wizard* -- so it stays up
with a correctly disabled button over everything you are trying to
measure. Nothing is broken; reload the page and it is gone. This reads
exactly like a tap being swallowed, which is the expensive part.
**Scoped storage decides where the music can be.** `/sdcard/Music/...`
plus `pm grant <pkg> android.permission.READ_MEDIA_AUDIO` works and
`AddLibrary` takes the plain path; a push into
`/sdcard/Android/data/<pkg>/files/` looks like it worked and then is not
there. Some builds additionally want
`appops set <pkg> MANAGE_EXTERNAL_STORAGE allow`, and until they have it
the app opens the *system* "All files access" screen on launch -- so
`dumpsys window | grep mCurrentFocus` naming `com.android.settings` is
that, not a crash.
### A note on quoting `make android-eval`
`EXPR='...'` is a single-quoted shell word, so anything with a quote or
an apostrophe in it -- a file path like `Blazo, 49'ers - ...`, or a
snippet containing a string literal -- breaks in a way that reads as a
JavaScript error. Put the expression in a file and pass it positionally:
```bash
node ./scripts/android-eval.mjs "$(cat /tmp/probe.js)"
```
That is the same script `make android-eval` wraps, so nothing is lost.
Two things worth knowing about it: it does **not** await a promise, so
an async call has to park its result (`window.__r = ...`) and be read
back in a second eval; and the shim from the section below is lost on
every reload and every app restart, along with the devtools socket,
whose name carries the pid.
### Calling a binding on the device
**The runtime call does not go over HTTP on Android**, and this is worth
+425
View File
@@ -4487,3 +4487,428 @@ is not orphaned" and "a docked column is not in the stack" are both
vacuously true of a build that pushes no entry at all. Reverting the
source and re-running is what established which were which, and the
file says so in its header rather than implying all nine reproduce.
## The phone's transport, and three things that only a screenshot or a stash could see (measured 2026-08-21)
#59 and #56 were done as one PR — argued on #73 first — because they are
the same row of pixels: one removes controls from the phone's bar and
the other enlarges what is left, and both are one property on
`player-controls`. Measured at 424x439 before:
| control | before | after |
|---|---|---|
| bar: shuffle / prev / play / next / repeat | 33x21 each | prev/next 44, play 56, shuffle+repeat moved |
| bar: favourite | **18x14** | 44x44 |
| bar: queue button | 33x29 | gone (#59) |
| Now Playing: all five | 33x21 each | 44, play 64 |
| desktop bar: all five | 33x21 | **33x21** |
Four things cost a cycle each and are worth keeping.
**A `<button>` does not inherit its font from its parent.** The UA
stylesheet gives it one, so `font-size: inherit` on a button is a
*change*, not a no-op: it took every desktop control from 33x21 to
36x24 by moving them from 13.3px to the shell's 16px. Nothing failed.
The only way it surfaced was measuring the baseline by stashing the file
and re-running.
**And the pixel it was first pinned with was the wrong assertion.** The
spec asserted the literal `'33x21'`, measured in Chromium — and WebKit
draws the same button **36x24**, so it failed in CI on a build where
nothing was wrong. A button's box comes from the UA stylesheet when the
author sets nothing, and what each UA sets is its own business. What
must not happen is that *we* set something, so that is what it asserts
now: `min-width` and `min-height` compute to `0px`, and the font-size
still equals that of a bare `<button>` probed in the same page. That
form catches the `font-size: inherit` regression in either engine —
checked by re-introducing it — and it is the same "assert the
mechanism" move `queue-as-a-screen.spec.ts` makes about containment.
It is also the second time in two sessions that **CI's WebKit was the
only tier that could see something**, which is the argument for checking
that step ran rather than trusting the run's conclusion.
**A rule at the bottom of `index.css` still loses to a nested rule
above it.** The phone block is last on purpose because a media query
adds no specificity — but `#queue-button` is written *nested* inside
`.bottom-bar`, so it builds to a descendant selector one class more
specific, and a bare `#queue-button { display: none }` in the phone
block did nothing at all. Silently: the button simply stayed. Nesting
adds specificity the source does not show.
**Removing a control moved the question of how you reach what is left,
and ten specs were quietly asserting the old answer.** Hiding the bar's
queue button failed ten tests in four files about the back stack and
about layout, every one of which opened the queue by clicking
`#queue-button`. `openTheQueue` in `e2e/support/fixtures.ts` is the
route *this viewport* offers, and the fix was to stop hard-coding one.
**And the route it takes did not exist in the state that matters.**
`now-playing` renders two branches, and the no-track one had no
`.expand` button — so with nothing loaded there was no way to Now
Playing, and once the queue button left the bar the queue was
unreachable outright. The queue is persisted across restarts, so this
is a state the app launches into, not a corner. It first appeared as a
*flake* (#168: the long-lived e2e app meant whether a track was loaded
depended on which spec ran first), which is worth remembering — a leak
made a deterministic bug look like a race.
## Now Playing does not fit a 439px screen, and #56 makes that visible (measured 2026-08-21)
Two separate things, and only the first is a defect.
**The art overflowed its own box and drew over the header and the
title.** It is `width: min(100%, 60vh); aspect-ratio: 1`, so its height
is derived from its width and bounded by nothing — 60vh bounds the
*viewport*, not the room left over, and those differ by all the chrome
above and below. `max-height: 100%` is the fix and shipped with #56.
Pre-existing: screenshotted on `main`. **Found by reading a screenshot,
which is the only tier that can see it** — nothing fails, the shell does
not overflow, and every control is still hittable.
**With that fixed, the art is a 39px sliver**, because the transport is
now 172px of a 439px screen. That is a consequence of #56 rather than a
fault in it, and it is filed as #172 with the per-element budget. #64
(no in-app volume on Android) is ~30px of pure gain there and #51 is the
umbrella; folding shuffle and repeat back onto the primary row was
considered and rejected — it buys 52px, leaves the art at 91px, and
costs a third arrangement of the same five buttons.
## The volume is not ours on Android, and the predicate could not be a width (measured 2026-08-21)
#64 asked for the in-app volume control to be absent on Android. Its
first Finding said `volume-control` "already stands down at narrow
widths", which was true of one of its two copies and is why the issue
had been read as nearly done. The bar's copy goes by width; the
full-screen view's copy was deliberately kept, with a comment saying a
slider does belong there.
**The crux was platform versus width, and three options were on the
issue.** What settled it is that the *backend* half of the same issue —
pin the level at 1.0 — makes a width rule wrong on the platform the
issue is about: an Android tablet at >=600px gets the bottom bar, and
the bar's slider would then move a level that is pinned. That is a
control that cannot act, which `library-status-indicator` already
settled is worse than none. The same rule is wrong the other way below
600px, where a narrow desktop window has no hardware keys.
So the frontend asks the player — `SystemOwnsVolume` — and the answer
is right at every width in both mount points. **The predicate is named
after the capability rather than the platform**, which is what makes it
testable: only `platformOwnsVolume` is behind a build tag, in two files
that declare nothing else, and everything else is decided against a
field a Go test sets either way. `frontend/test/components/
volume-ownership.test.ts` stubs the binding and so exercises the
*Android* rendering on an ordinary Linux runner; both of its tests were
confirmed to fail on the build before the change.
**Measured at 424x439, by flipping `platformOwnsVolume` to true in the
`!android` file and rebuilding** — the real binding, the real store, the
real component, everything except the tag:
| element | before | after |
|---|---|---|
| header | 48 | 48 |
| **album art** | **39** | **68** |
| title / artist / album | 63 | 63 |
| transport (seek + controls + volume) | **172** | **143** |
| — seek bar | 19 | 19 |
| — player-controls | 116 | 116 |
| — volume-control | 21 | **0** |
29px, which is the 21px control plus the 8px flex gap it stops drawing:
a gap is only painted between boxes, so `:host([hidden])` costs the
transport nothing rather than leaving a hole. That is #172's "~30px of
pure gain" confirmed, and the art is 74% larger. It is still the
second-smallest thing on the screen, which is #51's evidence.
Three smaller things worth keeping.
**`:host([hidden])` has to be written down.** The UA's `[hidden]`
rule is `display: none`, but `volume-control`'s own `:host` sets
`display: inline-flex` and outranks it — so setting `hidden` alone
hides nothing. Same family as the nested-`#queue-button` specificity
trap from the session before.
**Rendering `nothing` and hiding the host are two different
assertions**, and the component test makes both: an empty shadow root
is what stops a by-role or positional query finding a button that
cannot act, and `hidden` is what stops the host occupying space. Either
alone passes on a build that gets the other wrong.
**The bar's centring survives the control going away.** #23's outer
columns are the same `min()` expression rather than content-sized, so
at 900px with the volume gone the bar's centre, `audio-player`'s centre
and `player-controls`' centre are all 450 — checked, because "the
transport is centred with a slider bolted to one side" is the fault
that rule exists for and removing the slider is the obvious way to
re-break it.
**What no tier here can check**: the constant itself, and ducking
against a real audio-focus change. The first is a source sweep
(`TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform`), the second is
`TestSystemVolumeStillDucks` against the arithmetic. Neither is a
device, and no device was attached.
### The device answered three of the four (measured 2026-08-21, TLP301 / Android 14 / SDK 34 / arm64, Chrome 113 at 424x439)
A Light Phone III was attached after the PR was opened, so what that PR
listed as unverifiable was re-checked rather than left as a caveat.
**The whole chain resolves on the device.** `__yj.call("player.Player.
SystemOwnsVolume", [])` answers `true` — build tag, `platformOwnsVolume`,
`Player.systemVolume` and the generated binding, end to end. That is the
one thing the source sweep only approximates, and it took a real arm64
device because nothing else here compiles the `android` file at all.
(`GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...` with
the NDK's clang compiles it in ~40 s and is worth running first; it
catches a type error but not a wrong constant.)
**The control is absent in both mount points**, on the real engine:
`.bottom-bar volume-control` is `hidden` with an empty shadow root, and
so is `now-playing-view`'s. Measured on the device, transport **143px**,
which is the figure the desktop-headless "after" predicted exactly. The
art is 75px there rather than 68 because the fixture's `.names` block is
one line shorter, not because anything differs.
**Nothing persists a level nobody chose, and this is the measurement
that took some care.** The default (50) surviving proves nothing, since
50 is also what a fresh row holds. So: force-stop, pull `yj.db`, set
`player_state.volume = 37`, push it back through
`run-as … dd` (a `cp` from `/sdcard` is refused — the app sandbox
cannot read it), relaunch, and drive a queue change to make the row be
rewritten. Reading it back **the WAL has to be pulled with it** — the
main file still showed the old `last_track_path` and reads as a write
that never happened. With `yj.db-wal` beside it: `last_track_path` is
the new track, so `saveState` ran, and `volume` is still **37**.
**The duck cannot be verified on this device, and now for a stated
reason rather than for want of hardware.** `WailsForegroundService`
builds its `AudioFocusRequest` without `setWillPauseWhenDucked` on
API >= 26, so the framework attenuates the stream itself and never
delivers `AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK`. The device's own log
says so: `MediaFocusControl: requestAudioFocus() … AA=USAGE_MEDIA/
CONTENT_TYPE_MUSIC … req=1 flags=0x0` — no
`AUDIOFOCUS_FLAG_PAUSES_ON_DUCKABLE_LOSS`. **`minSdk` is 21**, so the
Go-side duck is not dead code; it is reachable on Android 5.0 to 7.1
and on nothing newer. Any future "verify ducking on a device" needs one
of those, and asking for a modern phone will not do it.
Two smaller things from the same session.
**A fresh install downloads the real catalog, and it is 209px of the
screen while it does.** `YJ_CORE_INDEX_URL` is stubbed in
`dev-headless.sh` and in CI but is real on a device, so the first
measurement taken was of a screen with `job-band` on it and the art at
**0px**. That is not a defect and not #172 — it is the environment.
`explore.Service.StopIndexBuild` and a relaunch is the clean state.
**The first-run wizard does not dismiss when a library appears by a
route other than its own** (#175) — it was still up, full-screen and
intercepting pointer events, after `AddLibrary` succeeded through the
binding, and was gone after a relaunch. Filed.
## The context menu was clipped on the device, and the fix needed four measurements nothing here could make (measured 2026-08-21, TLP301 / Chrome 113 / 424x439)
#60 had been diagnosed from the Web Awesome source and was right. What
the device added was the numbers, and three things the reading had not
reached.
**The clip, reproduced before any code was written.** Long-press on the
lowest visible track row at 424x439:
| | |
|---|---|
| viewport | 424x439 |
| `.main-panel` | 0 to **318**, computed `contain: content` |
| menu panel | 191 to **401**, 210px tall |
| clipped away | **83px, three of seven items** |
| `wa-popup` computed position | `fixed` |
| `HTMLElement.prototype.hasOwnProperty('popover')` | **false** |
| row height | **29px** (against a 44px floor and a 48px ask) |
A screenshot shows the menu sliced off flush with the mini player's top
edge. Both halves of the diagnosis are therefore measured, not inferred.
**"A dialog escapes containment" was the premise, and it was untested.**
Every dialog in this app is mounted in `index.html`, *outside*
`.main-panel` — so nothing here was evidence about a dialog opened from
inside a view, which is what this change needed. A probe `<dialog>`
appended to `track-list`'s shadow root and `showModal()`n paints to
y=439, over the mini player and the tab bar. A top-layer element's
containing block is the viewport, paint-contained ancestor or not.
Checking that first cost ten minutes and would have cost a rebuild.
**The UA stylesheet is the thing that makes a naive sheet look wrong.**
That same probe came out **354px wide on a 424px screen**, centred,
because a native `<dialog>` carries `max-width: calc(100% - 6px - 2em)`
and `margin: auto`. `max-width: none` and explicit margins are four
declarations that are pure undoing.
**A retry loop cannot win against a steal that happens later.**
`MenuKeyboard` focuses the first item and returns as soon as it lands;
`wa-dialog` then focuses `[autofocus]` or *itself* on the frame after
`showModal()`, and it cannot see our first item to prefer it — the
panel is slotted through `menu-surface`, so the dialog's own
`querySelector` stops at the `<slot>`. Measured: the sheet opened with
`document.activeElement` on the `<dialog>` and every arrow key went
nowhere. Lengthening the retry budget does not help, because the first
attempt *succeeds*. The surface announcing `menu-shown` after
`wa-after-show`, and the keyboard re-asserting, is the fix.
**The submenu was made worse before it was made better, and only a
measurement caught it.** `#playlist-submenu` is a
`placement="right-start"` flyout anchored to its row. Making the menu a
full-width sheet moved that anchor to x=0, so the flip put the playlist
picker at **x 182 to 0 — entirely off-screen**, and "Add to Playlist"
led nowhere at all. Before the change the anchor row started at x≈245
and the same flip landed it on screen. It is a `menu-surface` too now
and stacks as a second sheet. Two lessons: a change that moves an
anchor changes every flip decision downstream of it, and *the scope I
declared on the issue was wrong* — I had said I would measure the
submenu and file it, and the measurement said fix it.
**And the sweep found two call sites the conversion missed.** Twelve
were converted by hand; `menu-surface.test.ts` reads every source file
and fails on a `<wa-popup>` outside a three-file allowlist, which
immediately named `queue-panel`'s add-to-playlist popup (a real menu,
converted) and `now-playing`'s cover preview (a hover affordance in the
bottom bar — allowlisted, since a touch device never opens it and
nothing clips it). A thirteenth menu written as a bare popup would pass
every tier here and be clipped on the device, which is precisely why
the guard is a source sweep rather than a rendered assertion.
**What no tier here can see remains the clip itself.** This runner's
Chromium and CI's WebKit both have the Popover API, so the popup is
top-layered and correct and a "not clipped" assertion passes on the
broken build. The specs assert the *mechanism* — that the surface is a
native `<dialog>` at phone width — which is the same move
`queue-as-a-screen.spec.ts` makes about containment and for the same
reason.
## Now Playing was not drawing a small square, it was drawing a crop (measured 2026-08-21, TLP301 / Chrome 113 / 424x439)
#172 handed #51 a design question — whether the album art gets a floor
with the block scrolling, or whether the screen reflows below some
height. Measuring it first turned up a defect underneath the question,
and the defect is bigger than the phone.
**The art was never square.** `aspect-ratio` is specified not to
re-derive the width when `max-height` clamps the height — unlike an
intrinsic ratio, which CSS2.1 10.4 preserves under both bounds. So
`width: min(100%, 60vh)` made the width definite, the ratio derived a
height from it, `max-height: 100%` clipped that height, and the width
stayed where it was. `object-fit: cover` then cropped a square cover
into the band. On the device: **264x53**, a 5:1 strip. #172's own table
called it "39px of art" and the missing half is that those 39px were
263 wide.
**And it is not only the phone.** The leftover exceeds the width only
above ~843px of viewport, so every height from ~500 to ~843 drew a
crop too — most phones, and any short window. The e2e spec written for
this fails on the old build at 424x439 (263x39), 390x700 (358x315) and
900x500 (300x36), and *passes* at 412x869, which is the boundary
falling exactly where the arithmetic says it should.
**Both maxes with auto sizes is the whole fix**, and it was chosen by
asking Chrome 113 rather than by reasoning: a probe shadow root at
column heights of 288, 300, 451, 600 and 800 measured four candidate
rules. `max-width/max-height: 100%` with `width/height: auto` is square
at all five; the shipped rule cropped at four; `aspect-ratio` on the
box cropped at the tallest. A corollary that makes it free: `auto` will
not upscale past the natural size, and the largest tier `saveCoverArt`
keeps is 400px, so nothing is lost by never exceeding it.
**The placeholder cannot use that rule and needed its own**, which is
the part that would have shipped broken. It is not a replaced element,
so with no intrinsic size auto/auto collapses it to its icon —
measured at **13x58**, neither square nor the art's size. Three things
about the rule it did get:
- It is driven from the **height**, which is the axis that binds
everywhere this view is reached from.
- A flex item's automatic minimum is its content, so without
`min-width: 0` the icon's own width becomes a floor and the box goes
wider than it is tall the moment the row is shorter than the icon —
which is exactly the state a job band puts this screen in.
- **A non-replaced box cannot express "the largest square that fits" at
all**, because whichever max clamps does not re-derive the other. The
height-driven rule alone went **380x484** at 412x869 — a tall phone,
#51's other named device — and `max-height: calc(100vw - 2rem)` is
what closes it. That is sound here for the reason `60vh` was not: this
is a phone-width detail view, so its content box really is the
viewport less the host's own gutters, and it is a *max*, so if that
ever stopped being true the failure is a square bounded early rather
than a crop. `rem` and not `em` — the box sets `font-size: 3rem` for
the icon, so `2em` there is 96px.
**Then the design question, and the reflow is the answer.** The
stacked layout's budget is fixed — 48px of header, 143px of transport
since #64, 78px of names, 68px of padding and gaps — so the art gets
`height - 386`, which is 53px at 439. A floor on the art scrolls the
transport off the bottom, and "controls never scroll off" is #51's own
Direction and plan 018's promise. So below 500px the art and the names
share a row, where the art is bounded by the row's height rather than
by the column's leftover: **53px to 143px on the device**, measured on
the shipped build, with nothing scrolling and the transport untouched.
**500 is where the two layouts cross, not a round number.** In a row
the art is `height - 296` and the names get what is left of 392px, so
the names hold 176px at exactly 500 and less above it; stacked, the art
is `height - 386`, which passes 176px at 562. It is keyed on height
alone rather than on the phone's width because it is an answer to
vertical room — a 900x450 window has the same problem and the same fix.
Two things the audit found that are *not* this, and are filed:
**#186**, every control that is not the transport is under the 44px
floor (the sort direction arrow is 28x21, and `search-trigger` — which
exists only on a phone — is 40x40), and **#187**, the seek bar's drag
target is 6px tall.
**What the audit did not find is a reachability failure**, which is
worth recording because it is the promise plan 018 makes. At 424x439,
on every view -- the ten primary ones, the queue, `album-details`,
`artist-details`, Downloads and Autotag -- `documentElement.scrollWidth`
is 424 against a 424 viewport, no control sits outside a scrollable
ancestor, and a hit test at each control's centre reaches the control.
The width work of #57, #62, #55 and #59 holds; what was left was
vertical, and it was this screen.
**A number measured on the device is not a number CI can assert.** The
spec's floor on the art's height passed here at 114 and failed in CI at
**64**, and both are honest: this app is long-lived, so a job staged by
an earlier spec is still on screen, and `volume-control` renders in a
browser where it does not on Android. Both are chrome above and below
the view and both move the leftover. That is the same trap the entry
above about staged jobs describes, arriving as a *measurement* rather
than as a stuck job. The assertion is the mechanism now -- in a row the
art fills the row's height rather than being the leftover -- and the
53-to-143 stays on the issue, where it was measured.
The probe is worth keeping in mind for the next audit, because two of
its three checks needed a second pass to mean anything. "Painted
outside the viewport" flags a horizontally scrolling carousel -- the
home shelves -- so the real question is whether a *scrollable ancestor*
can bring the element back. And a hit test at a control's centre flags
everything below the fold in a scroll container, so it only says
something once the control is on screen. Both first drafts produced
long lists of nothing.
## Two traps that cost time on the device, both already written down (2026-08-21)
Recorded because both are in `android-tier.md` and I met them anyway.
**A fresh install downloads the real catalog**, so `job-band` is 103px
of a 439px screen and every vertical measurement is wrong. Worse, it
**restarts**: `explore.Service.StopIndexBuild` returns cleanly and the
job is `running` again within seconds, so it has to be stopped again
immediately before a measurement rather than once at the start.
`YJ_CORE_INDEX_URL` is stubbed in `dev-headless.sh` and in CI and is
real on a device.
**The first-run wizard does not re-check for a library it did not
create.** Adding one through `library.Library.AddLibrary` over the
bridge leaves the wizard up with its "Get Started" button correctly
disabled — it gates on a directory chosen *in the wizard*, and the
existing-library check runs once, on mount. A reload clears it. Nothing
is broken; it cost twenty minutes of believing a tap had been swallowed.
+363 -9
View File
@@ -646,7 +646,11 @@ rather than renaming them.
level rather than writing through to the volume, so it cannot
accumulate and nothing persists or emits a level the user did not
choose — and it only ever fires below API 26, where the framework
does not already duck the app itself.
does not already duck the app itself. On that platform "the user's
level" is a constant, since #64 pins it at maximum and refuses every
way to move it; the duck is the one thing that still may, and it
works unchanged because it was always an offset applied *to* that
level rather than a write of it.
- `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows).
- `explore` — Catalog search and browse over `explore_index`. See below.
Its **shelves** (`shelves.go`) are the page Explore shows before
@@ -1363,8 +1367,17 @@ against the real components:
`wa-dropdown-item` sets its `role` in its *own* first update, so a
`[role^="menuitem"]` query at `updateComplete` finds nothing — which
reads exactly like a menu that opened and refused to take focus.
- **`focus()` on a popup that has not positioned itself is a silent
no-op**, so the first focus is retried across a few frames.
- **`focus()` on a surface that has not shown itself is a silent
no-op**, so the first focus is retried on a *time* budget rather
than a frame count — the thing being waited for is another
component's animation. And a retry is not enough on its own for the
sheet below: `wa-dialog` focuses `[autofocus]` or *itself* on the
frame after `showModal()`, and it cannot see the first menu item to
prefer it, because the panel is slotted through `menu-surface` and
the dialog's own `querySelector` stops at the `<slot>`. The first
attempt therefore *succeeds* and is then overwritten, which no
amount of waiting fixes — so the surface announces `menu-shown` when
it has settled and `MenuKeyboard.refocus()` re-asserts.
- **Focus is only taken back if the menu had it.** A click elsewhere
closes the menu too, and pulling focus to the row the user
right-clicked a moment ago is worse than leaving it.
@@ -1372,6 +1385,69 @@ against the real components:
moving focus without setting it leaves the highlight on whichever
item the mouse last touched.
**And a menu is drawn where it fits: a popup on a desktop, a bottom
sheet on a phone** (#60). `components/menu-surface/` is that one
decision. The host renders the panel it always rendered and slots it
into whichever surface is up, so `ContextMenuController` still drives
`.active` and `.anchor` as though it were talking to a `wa-popup`, and
fourteen call sites changed one tag name each and nothing else.
**It is a correctness fix, not a taste one, and the failure was
measured on the device rather than inferred.** Chrome 113 has no
Popover API, so `wa-popup` takes its own documented fallback and
positions with `strategy: "fixed"`; `.main-panel` carries
`contain: layout style paint`, and paint containment *clips* fixed
descendants. On the reference device the main panel spans 0-318 of a
439px viewport while the open menu spanned 191-401 — three of its seven
items cut off, with no way to reach them. `showModal()` is Chrome 37
and uses the real top layer, so a dialog is immune by construction.
Six things about it are load-bearing.
**"Dialogs are fine" needed checking, because every other dialog in
this app is mounted in `index.html`** — outside `.main-panel` — so it
was not evidence about one opened from inside a view. A probe dialog
appended to `track-list`'s shadow root paints to y=439, over the mini
player and the tab bar, with the contained ancestor still in place. A
top-layer element's containing block is the viewport, contained
ancestor or not.
**The sheet has to un-do the UA stylesheet.** A native `<dialog>`
carries `max-width: calc(100% - 6px - 2em)` and `margin: auto`, which
drew a 354px panel floating in the middle of a 424px screen.
`max-width: none` plus explicit margins is what makes it a sheet.
**The row sizing lives in `contextMenuStyles`, not in the component.**
The panel is the *host's* light DOM — it stays in the host's shadow
root, so only the host's stylesheet can reach it. `menu-surface` puts
`data-sheet` on the panel and that shared stylesheet does the rest,
which is how fourteen menus went from 29px rows to 48px ones in one
edit.
**A dismissal has to travel back.** `wa-dialog` closes itself on
Escape, which would leave the controller believing the menu is open —
and the failure mode is not a stuck sheet but the *next* long-press
doing nothing, which reads as the gesture breaking. `menu-dismiss` is
that signal; the three surfaces that do not use `ContextMenuController`
bind it themselves.
**The playlist submenu is a sheet too, and it had to be.** It is a
`placement="right-start"` flyout, and making the menu full-width moved
its anchor — measured at x 182 to 0, entirely off-screen, so "Add to
Playlist" led nowhere. It stacks as a second sheet over the first,
which is also why `menu-shown` does not re-assert focus while the
submenu is open.
**And which call sites exist is swept, not remembered.** A thirteenth
menu written as a bare `<wa-popup>` works perfectly in every tier here
and is clipped on the device, so `menu-surface.test.ts` reads the
source and fails on one outside a three-file allowlist —
`menu-surface` itself, `job-indicator` (in `.top-bar`, which no
ancestor contains — the contrast that proved the diagnosis on #62) and
`now-playing`'s cover preview (a hover affordance, which a touch device
never opens). **The sweep found two of the fourteen**; twelve were
converted by hand.
**And a menu opens from a finger, through the event it already has.**
`utils/long-press.ts` is one document-capture listener installed once
from `index.ts`: a touch that holds still for 500 ms dispatches a
@@ -1386,6 +1462,42 @@ vary) wins, ours being told from theirs by **identity** rather than
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.
**A control revealed by `:hover` is gated on the device having hover,
and which way round depends on whether it is the only route to its
action.** The gate itself is not optional: a touch long-press
synthesises a hover state in the WebView, so every one of these flashed
into view during the 500 ms hold above — a control appearing because
the user was reaching for a different one. Where the action is reachable
another way the control is **absent** on a touch device (the home card's
play button, #68; the queue row's remove, which the row's bottom-sheet
menu carries since #60), and that is `display: none` outside
`(hover: hover) and (pointer: fine)` rather than `opacity: 0` or
`visibility: hidden`, both of which leave a button holding its hit area
and its place in the accessibility tree. Where the control is the
**only** route it is instead always visible under
`@media not all and (hover: hover)``track-details`'s cover-art
overlay and remove, `shortcut-capture`'s reset (#137) — because hiding
it takes the action away entirely.
**Always-visible is not the same as always-in-the-way.** The cover-art
overlay is `inset: 0` at 50% black, which is fine as a hover state and
is not fine as the permanent appearance of the artwork being edited —
and it is only a *hint*, since `.cover-art-edit` carries the click and
tapping the art always worked. Off hover it becomes a corner chip in
the remove button's own language. The × beside it stays full-size,
because that one really is the only route to its action.
One thing to know before checking either: **no *committed* tier renders
as a touch device.** CDP's `Emulation.setEmulatedMedia` does not reach
the component tier's iframe, and the e2e projects are Desktop Chrome
and Desktop Safari, neither of which has touch — a Playwright project
using a mobile descriptor would report `hover: none`, so this is a
choice not to carry one rather than a thing that cannot be done. So
`hover-affordance.test.ts` asserts the *parsed stylesheet* — which rule
sits inside which media query — and says so; the regression it exists
for is someone hoisting a rule out of its query as a tidy-up, which
nothing on a desktop renders differently.
Three lists had no focused row to open a menu *from* — the queue panel
and both playlist detail views — and gained a roving tab stop through
`utils/roving-rows.ts`. **`track-list` deliberately does not use it**:
@@ -1725,9 +1837,140 @@ where the zero value has to be the intended answer, so an existing
`config.toml` with no key gets the new default without a migration.
Inline, the icon becomes the mute toggle and is named after that action
rather than after the state, because with the slider beside it there is
nothing left to disclose. It stands down below 600px whatever the
setting says — that is about the platform rather than preference, and
is why `mediacontrols`' Android handler implements no volume callback.
nothing left to disclose. The bar's copy stands down below 600px, which
is about *room*: five controls and a slider do not fit a 360px bar, and
`now-playing-view` is where seeking and volume go on a phone.
**Whether there is a volume to control at all is a different question,
and it is asked of the player** (#64). On Android the hardware keys are
the volume control and the framework mixes our stream against the
device level, so `player`'s own level is pinned at maximum, `SetVolume`
/ `ChangeVolume` / `MuteToggle` are refused, and `volume-control`
renders `nothing` — in both of its mount points, at every width.
`mediacontrols`' Android handler implementing no volume callback is the
same fact one layer down.
Five things about it are load-bearing.
**It could not be a width, and that is not a preference.** Every other
stand-down rule in this app is keyed on a viewport, because a width is
what a browser can answer and what every tier can test. This one is a
property of the build: keyed on width, an Android *tablet* at 600px or
more draws the bottom bar's slider over a pinned level — a control that
cannot act, on exactly the platform the rule exists for, which
`library-status-indicator` already settled is worse than none. The
same rule is wrong in the other direction below 600px, where a narrow
desktop window has no hardware keys to fall back on.
**The predicate is named after the capability, not the platform.**
`SystemOwnsVolume` is what the frontend asks; `platformOwnsVolume` is
the one build-tagged constant behind it, in two files that declare
nothing else. That is `mediacontrols`' split with
`androidpayload.go`'s reasoning: a tagged file is compiled by nothing
`make lint` or `make test` runs, so everything decidable off a phone is
decided against `Player.systemVolume`, a field a test sets either way.
The frontend's absent branch is therefore testable in the component
tier with a stubbed binding, and the constant itself is covered by a
source sweep plus, once, a real arm64 device answering `true` — which
is the only tier that compiles the `android` file at all.
**Mute goes with it, because it is a level of zero by another name**
and because with no control rendered it is the one state on such a
platform the user could not get out of.
**Nothing persists a level nobody chose.** The maximum the player runs
at is synthetic, so `restoreStateLocked` *remembers* the stored volume
instead of applying it and `saveState` writes that same value back.
The alternative — a second query that omits the column — buys nothing
and is a second write path to keep in step.
**And ducking is untouched, which is what makes the pin safe.**
`SetDuck` applies its attenuation by re-applying the *user's* level
through `setVolumeLocked`, so pinning that level to maximum leaves the
offset arithmetic exactly as it was. It is the only thing that may move
the output on such a platform, and it is the one volume-shaped path
that is not refused.
One thing to know before anyone offers to test it on a phone: **the
duck is unreachable above API 25.** `WailsForegroundService` builds its
`AudioFocusRequest` without `setWillPauseWhenDucked` from Oreo, so the
framework attenuates us itself and never sends
`AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK` — a device confirms it by logging
`requestAudioFocus() … flags=0x0`. `minSdk` is 21, so this is live code
rather than dead, on Android 5.0 to 7.1 and nowhere else.
**And below 600px that bar carries three controls, not five** (#59).
Shuffle, repeat and the queue button leave it; what is left is art,
title/artist, favourite, and prev/play/next. `player-controls` is one
component in two places and **the context is a property rather than a
media query**, which is the exception to the rule two paragraphs down:
on a phone the bar wants three controls and `now-playing-view` wants
five, larger still, *at the same viewport* — so the host states the
context and the viewport states the size band, and neither alone can
express it. Sizes come from `--yj-control-*` custom properties set per
context; play/pause alone goes above the 44px floor, because a row of
identical squares says every action is equally likely and that is not
true of play. Measured before #56: every one of them was **33×21px**,
and the mini bar's favourite was **18×14**, the smallest control in the
app.
Four things about it are load-bearing.
**The phone draws three buttons rather than hiding two**, from
`matchMedia``job-band`'s pattern, and the rule that a decision about
whether an element *exists* is not a stylesheet's to make. A
`display: none` control is still in the shadow root and still something
a positional query finds, so "the phone has three controls" would have
been true of the pixels and false of the element.
**Removing a control is only allowed because it is still reachable.**
Plan 018's matrix promises no action is unreachable at any supported
size, and all three are on `now-playing-view`, one tap away through the
mini player's art. That promise is what `phone-transport.spec.ts`
asserts — it walks the route — rather than counting buttons.
**So the route to Now Playing must not depend on what is playing**, and
it did. `now-playing` renders two branches and the no-track one had no
`.expand` button on its placeholder, so with nothing loaded there was
no way to the full-screen view — which, once the queue button left the
bar, made the *queue* unreachable. The queue is persisted across
restarts, so "tracks queued, nothing playing" is a state the app
launches into.
**The desktop bar is untouched and a spec says so with a literal.**
Both issues are `Platform/Android`. The trap is that a `<button>` does
not inherit its font from its parent — the UA stylesheet gives it one —
so a generic `font-size: inherit` is not the no-op it reads as: it took
every desktop button from 33×21 to 36×24, silently. The sizes are
asserted as `'33x21'` rather than as a range, because the regression
was three pixels.
**What that bar lost is how far through the song it is, and
`<player-progress-line>` is where it went** (#58). Plan 016 B2 took the
seek bar off the phone's transport, so the one thing a mini player is
expected to say without being opened had nowhere left to be said. It is
a 2px line on the border between the mini player and the tab bar: the
**shell's** element and its own `auto` grid row between `bottom-bar`
and `bottom-nav`, because those two are separate components and either
one drawing it means reaching into the other's box for two pixels.
Four things about it are load-bearing. **It never counts** — the fill is
`scaleX()` off the same `PlaybackPositionChanged` the seek bar renders,
with the same `trackChangeId` and `seq` guards and an interval that is
stopped and restarted by every report, which is the rule that exists
because a local clock drifted 30 s away across four keyboard seeks.
**It is not a control and cannot become one**: `aria-hidden` on the host
and `pointer-events: none` throughout, because Now Playing's seek bar
is what announces the position and a 2px strip on the top edge of the
tab bar is exactly where a thumb aiming at a tab lands. **It renders
nothing above 600px**, from `matchMedia` rather than a media query, for
`job-band`'s reason plus one of its own — a stylesheet cannot stop a
1 Hz interval running for the life of every desktop session about a
line nobody can see. And **its phone rule sits at the foot of
`index.css`, beside `job-band`'s**, not in the phone block above: a
media query adds no specificity, so a `display: block` written before
the `display: none` that takes it out of the desktop grid loses to it
and the line never appears at any width, silently.
**900 is the worst desktop width, not the 800×600 minimum.** The
sidebar collapses to icons *below* 900, so the main panel is 843px at
@@ -1826,9 +2069,26 @@ one that closes the queue — the reported defect moved one press later,
which looks exactly like a press that did nothing.
**And the way out is 44px on a phone.** With the panel spanning the
whole width the scrim has no uncovered pixels at all, so the close
button is the only pointer route out of a full-screen surface; it was
**25×21px**.
whole width there is no scrim there at all, so the close button is the
only pointer route out of a full-screen surface; it was **25×21px**.
**The scrim is drawn only where it can be tapped** (#171). Below 600px
`.panel-content` is `width: 100%`, so the scrim sat entirely underneath
an opaque panel — measured at 424×439, host, panel and scrim all
424×318 — dimming nothing and dismissing nothing while wearing
`cursor: pointer`. #24's tap-outside-to-close cannot exist on a surface
with no outside, and the screen above is what answers it instead: back,
and a 44px close button. The alternative — a gutter, which is the
drawer pattern — was declined, because it buys the affordance by taking
width off a full-screen surface on a 424px viewport. Two things about
it are load-bearing. Its **existence** is `matchMedia`, not
`display: none`, on `job-band`'s rule: a hidden scrim is still an
element carrying the dismissal handler. And **the 600899 band is
untouched**, where the panel is a 320px column of a wider content area
and the scrim has real uncovered pixels — which is why the e2e half
asserts *absence* at 424×439 rather than clicking, since a phone-width
case that clicks the scrim's centre hits the panel and passes on the
broken build.
What this does **not** fix is `page-header` overflowing on its own:
at 900×600 "New Smart Playlist" is still clipped to 114 of 162px with
@@ -1860,6 +2120,54 @@ toggled from `index.ts` would be a second expression of the same fact.
The view therefore carries its own queue button, because that button
lives in the bar it hides.
**And below 500px of height its art and its names share a row** (#51).
The stacked arrangement's budget is fixed — 48px of header, 143px of
transport since #64, 78px of names, 68px of padding and gaps — so the
art gets `height - 386`, which at the reference device's 424x439 is
**53px**: the one thing a Now Playing screen exists to show, smallest
on it. #172 named the two ways out and this is the second, because the
first — a floor on the art with the block scrolling — scrolls the
transport off the bottom, and *controls never scroll off* is #51's own
Direction and plan 018's promise. Sideways the art is bounded by the
row's height instead of by the column's leftover: **53px to 143px**,
measured on the device, nothing scrolling, the transport untouched.
Three things about it are load-bearing.
**500 is where the two layouts cross rather than a round number.** In a
row the art is `height - 296` and the names get what is left of 392px,
so the names hold 176px at exactly 500 and less above it; stacked, the
art is `height - 386`, which passes 176px at 562. Below 500 the row is
the bigger art *and* the readable one — above it the column is, which
is why a tall phone keeps the arrangement it has. It is keyed on height
alone and not on the phone's width, because it answers vertical room: a
900x450 window has the same problem and the same fix.
**The art was not a small square, it was a crop, and that was never
only the phone.** `aspect-ratio` is specified not to re-derive the
width when `max-height` clamps the height — unlike an intrinsic ratio,
which is preserved under both bounds — so a definite `width: min(100%,
60vh)` kept its width while the height was clipped, and `object-fit:
cover` cropped a square cover into the band: **264x53** on the device.
The leftover exceeds the width only above ~843px of viewport, so every
height from ~500 to ~843 drew one too. `max-width`/`max-height: 100%`
with `width`/`height: auto` is the fix and is the replaced-element
path; it also never upscales past the natural size, and the largest
tier `saveCoverArt` keeps is 400px, so nothing is lost.
**The placeholder needs its own rule, and a non-replaced box cannot
express this one.** With no intrinsic size, auto/auto collapses it to
its icon (13x58, measured). It is driven from the height instead, with
`min-width: 0` because a flex item's automatic minimum is its content —
without it the icon's width becomes a floor the moment the row is
shorter than the icon, which is exactly what a job band does to this
screen. And since whichever max clamps does not re-derive the other, a
height-driven box goes **380x484** on a tall phone; `max-height:
calc(100vw - 2rem)` closes it, which is sound here for the reason
`60vh` was not — this is a phone-width detail view, so its content box
really is the viewport less the host's gutters, and it is a *max*, so
the failure mode is a square bounded early rather than a crop.
**The playing row is a shape, not a hue.** `track-list` and
`queue-panel` draw a `::before` triangle in each row's own left
padding, plus `aria-current` — before, both rows were a background tint
@@ -2309,6 +2617,31 @@ missing half; `catalogFailed` is the only route to `unavailable` now,
and the timer is a 60 s backstop for a genuine hang rather than the
verdict.
**On a phone that page is one scroll container, and the header is in
it** (#66). It was built as a fixed header over a scrolling tracklist,
which is the desktop arrangement: at the reference device's 424×439 the
header owned **253 of the panel's 318px** and the list scrolled inside
the 64 that were left. Below 600px the *host* is the scroller and
`.content` stops being one, so the whole page moves together — which is
only available because this tracklist is plain DOM rather than a
virtualizer, and because `.main-panel > *` already gives the host a
definite height.
Three things about it are load-bearing. **Another `min-width: 0` was
not the fix**: `.album-info` carries one and was shrinking exactly as
asked, to 112px beside a 200px cover — so the title drew as `G…` and
"Shuffle album" ended at x=443 inside a 424px box, clipped by the
component's own `overflow: hidden` and reachable by no gesture. A row
with a fixed-size sibling has to **stack** at that width, or the column
that must shrink has nothing to be wide with. **`layout-overflow.spec.ts`
cannot see any of this** — `body.scrollWidth` equalled the viewport
throughout, because the overflow was *inside* a component; the spec
measures each header control against the host's own box, which is
`top-bar-fit.spec.ts`'s shape for the same reason. And **the phone block
is last in the stylesheet**, on `index.css`'s rule: a media query adds
no specificity, so written above the plain rules it overrides every
declaration in it is silently dead.
**Activating a row plays the list the row is in, from that row.** A
double-click — and Play on a single row's context menu — queues the
list as *displayed* with `startIndex` on that row, not a queue of one
@@ -3264,6 +3597,27 @@ android-inspect` forwards the WebView's devtools socket and `make
android-eval` asks the real page — raw CDP, because `connectOverCDP`
calls `Browser.setDownloadBehavior` and a WebView refuses it.
**One of those gaps is checked rather than remembered.** A nested rule
whose selector starts with an element name is not a parse error anyone
would notice on 113 — the rule simply does not exist, there and nowhere
else, which is how the bottom bar's `text-overflow: ellipsis` came to
have never truncated on the device. `make css-check`
(`frontend/scripts/check-css-nesting.mjs`, a pre-commit hook and a CI
step) fails on one, over every `frontend/*.css` and the `css` literals
alike — a glob rather than `index.css` by name, because the hook fires
on `frontend/**/*.{ts,css}` and a sweep that names one file goes green
over a stylesheet it never opened — and
says the fix is a leading `&` — valid in both syntaxes, so no nested
rule here has a reason to omit it. Two things it has to get right, and
both follow from asking whether a *style* rule is anywhere above rather
than what the immediate parent is: `@media (…) { bottom-nav { … } }` at
the top level is an ordinary rule and is the majority of what a regex
over the file would report, while the same rule one level inside
`.bar { @media (…) { … } }` is nested and is flagged. The check is the
cheap version of the answer; a build-time downlevel (Lightning CSS
targeting 113) would fix the class permanently and is a dependency and
a build step rather than twenty lines.
`build/config.yml`'s `version` is the
*metadata* version and is not what the app reports — `main.version` is
stamped at link time from the packaging recipe's git-derived version.
+7 -2
View File
@@ -172,12 +172,17 @@ ui-setup: ## Install the Vitest browser provider's own Chromium (once)
bindings-check: ## Fail if the generated bindings are stale
@./scripts/bindings-check.sh
# Two CSS traps that report a long way from their cause, or not at all.
# A backtick inside a comment in a css`` literal ends the literal, and
# what you get back is a type error about CSSResult, or every test in
# the suite failing to import. Four sessions, three plans. Instant.
# the suite failing to import. Four sessions, three plans. And a nested
# rule starting with an element name is dropped by the device's
# Chrome 113 in silence -- no tier here runs an engine that can see it.
# Instant.
.PHONY: css-check
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
css-check: ## Fail on a css`` literal ended early by a backtick, or a nested rule needing an &
@cd frontend && node scripts/check-css-literals.mjs
@cd frontend && node scripts/check-css-nesting.mjs
# .pi/ and CLAUDE.md document commands, and a doc that documents a
# command wrongly is worse than no doc: an agent runs it confidently.
+55
View File
@@ -0,0 +1,55 @@
//go:build android
// The write itself, and nothing else. Everything decidable off a phone
// is in androidlog.go; see the package comment for why.
package androidlog
/*
#cgo LDFLAGS: -llog
#include <stdlib.h>
#include <android/log.h>
*/
import "C"
import (
"log/slog"
"unsafe"
)
// The priorities in androidlog.go are android/log.h's own values, and
// these are what says so. A constant expression that would be negative
// does not compile as a uint, so a renumbered header fails the build
// here rather than logging everything at the wrong severity -- which is
// the failure that would otherwise be invisible, since logcat would
// happily print whatever number it was handed.
const (
_ = uint(C.ANDROID_LOG_VERBOSE - PrioVerbose)
_ = uint(PrioVerbose - C.ANDROID_LOG_VERBOSE)
_ = uint(C.ANDROID_LOG_DEBUG - PrioDebug)
_ = uint(PrioDebug - C.ANDROID_LOG_DEBUG)
_ = uint(C.ANDROID_LOG_INFO - PrioInfo)
_ = uint(PrioInfo - C.ANDROID_LOG_INFO)
_ = uint(C.ANDROID_LOG_WARN - PrioWarn)
_ = uint(PrioWarn - C.ANDROID_LOG_WARN)
_ = uint(C.ANDROID_LOG_ERROR - PrioError)
_ = uint(PrioError - C.ANDROID_LOG_ERROR)
_ = uint(C.ANDROID_LOG_FATAL - PrioFatal)
_ = uint(PrioFatal - C.ANDROID_LOG_FATAL)
)
// New returns the handler main() installs on Android.
func New(opts *slog.HandlerOptions) slog.Handler {
return NewHandler(opts, write)
}
// write hands one line to liblog.
func write(prio int, tag, msg string) {
cTag := C.CString(tag)
defer C.free(unsafe.Pointer(cTag))
cMsg := C.CString(msg)
defer C.free(unsafe.Pointer(cMsg))
C.__android_log_write(C.int(prio), cTag, cMsg)
}
+250
View File
@@ -0,0 +1,250 @@
// Package androidlog routes slog to logcat.
//
// **An Android app's fd 1 and 2 go to /dev/null**, so every line this
// app writes with slog is discarded on that platform -- including the
// one naming the error it is about to os.Exit on. #52 is what that
// cost: a process that vanished with no tombstone, no AndroidRuntime
// stack and nothing in `logcat -b crash`, at Priority/Critical for
// months, whose entire diagnosis was one slog.Error main.go was
// already writing.
//
// The platform's own sink is __android_log_write, which is a handful
// of cgo -- and cgo compiled by nothing `make lint` or `make test`
// runs, since the only toolchain that builds the android tag is a
// cross-compiler and the only thing that runs it is a phone. So the
// split here is the one backend/mediacontrols/androidpayload.go makes,
// pushed as far as it will go: **everything except the write itself is
// in this file, untagged**. The priority mapping, the formatting, the
// chunking and the handler's own attr and group bookkeeping are
// ordinary Go that `go test` exercises on any platform; android.go is
// fifteen lines that hand a string to liblog.
package androidlog
import (
"bytes"
"context"
"log/slog"
"strconv"
"strings"
"sync"
)
// Tag is what logcat labels these lines with.
//
// It is a constant of ours rather than the application id, because the
// debug build carries `applicationIdSuffix ".dev"` so that it can be
// installed beside the release app -- so a tag derived from the package
// name is a *different* tag on the one build that can be inspected, and
// the filter that is supposed to show these lines would hide them on
// exactly the build used to look for them.
const Tag = "yellowjacket"
// Android's priorities, from android/log.h. These are the values
// __android_log_write takes; android.go asserts at compile time that
// they still match the header, so a renumbered platform is a build
// failure here rather than a warning silently logged as an error.
const (
PrioVerbose = 2
PrioDebug = 3
PrioInfo = 4
PrioWarn = 5
PrioError = 6
PrioFatal = 7
)
// maxPayload is how much of one line liblog will carry.
//
// The kernel logger's entry is 4068 bytes for the tag, the message and
// their two NULs together, and what does not fit is **dropped without
// comment** -- so a long line would be truncated in the middle of the
// thing worth reading. 3500 leaves room for the tag and for the "(N/M)"
// a continuation carries.
const maxPayload = 3500
// WriteFunc is the platform sink: one already-formatted line, at one
// priority, under one tag.
//
// It is a parameter rather than a package-level function so that the
// handler can be driven by a test on a machine with no liblog at all.
type WriteFunc func(prio int, tag, msg string)
// Priority maps a slog level onto an Android one.
//
// slog's levels are open -- a caller may define its own at any int --
// so this is a banding rather than a lookup: anything below Info is
// debug, anything at or above Error is error. A custom level between
// two of the standard ones lands in the band beneath it, which is what
// slog's own level naming does.
func Priority(level slog.Level) int {
switch {
case level < slog.LevelDebug:
return PrioVerbose
case level < slog.LevelInfo:
return PrioDebug
case level < slog.LevelWarn:
return PrioInfo
case level < slog.LevelError:
return PrioWarn
default:
return PrioError
}
}
// Handler formats records with slog's own TextHandler and hands each
// line to a WriteFunc.
//
// It delegates the formatting rather than doing it, because WithAttrs
// and WithGroup are the half of slog.Handler that is easy to get subtly
// wrong -- and a logger whose groups are wrong is a logger nobody reads.
// What it does own is what logcat needs and TextHandler does not know
// about: the priority, and the fact that a line has a maximum length.
type Handler struct {
write WriteFunc
// mu guards buf, which the delegate writes into. slog.Handler is
// documented as safe for concurrent use.
mu *sync.Mutex
buf *bytes.Buffer
delegate slog.Handler
}
// NewHandler builds a handler over an arbitrary sink.
//
// The time and the level are dropped from the formatted line: logcat
// stamps every entry with both, and repeating them costs a quarter of
// the width of a phone-sized terminal to say the same thing twice.
func NewHandler(opts *slog.HandlerOptions, write WriteFunc) *Handler {
buf := &bytes.Buffer{}
inner := &slog.HandlerOptions{}
if opts != nil {
*inner = *opts
}
user := inner.ReplaceAttr
inner.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && isBuiltin(a) {
return slog.Attr{}
}
if user != nil {
return user(groups, a)
}
return a
}
return &Handler{
write: write,
mu: &sync.Mutex{},
buf: buf,
delegate: slog.NewTextHandler(buf, inner),
}
}
// isBuiltin reports whether an attr is slog's own time or level,
// rather than a caller's attribute that happens to share the name.
//
// ReplaceAttr cannot tell those apart by key. It is called with an
// empty group path for the built-ins *and* for every top-level
// attribute, so a key comparison alone silently eats a caller's own
// "level" or "time" -- which is not hypothetical: the probe that
// verified this package on the device logged one, and the attribute
// vanished. The kinds are what separate them, because slog builds the
// built-ins as slog.Any(LevelKey, r.Level) and slog.Time(TimeKey, ...)
// and an attribute value of type slog.Level is not something a caller
// passes by accident.
func isBuiltin(a slog.Attr) bool {
switch a.Key {
case slog.TimeKey:
return a.Value.Kind() == slog.KindTime
case slog.LevelKey:
_, ok := a.Value.Any().(slog.Level)
return ok
default:
return false
}
}
// Enabled reports whether the level is worth formatting.
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
return h.delegate.Enabled(ctx, level)
}
// Handle formats one record and writes it out, in as many entries as
// its length demands.
func (h *Handler) Handle(ctx context.Context, rec slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.buf.Reset()
if err := h.delegate.Handle(ctx, rec); err != nil {
return err
}
prio := Priority(rec.Level)
for _, line := range Chunk(strings.TrimRight(h.buf.String(), "\n")) {
h.write(prio, Tag, line)
}
return nil
}
// WithAttrs returns a handler carrying the given attributes.
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
return h.derive(h.delegate.WithAttrs(attrs))
}
// WithGroup returns a handler that qualifies subsequent attributes.
func (h *Handler) WithGroup(name string) slog.Handler {
return h.derive(h.delegate.WithGroup(name))
}
// derive shares the buffer and its mutex with the parent.
//
// They must be shared rather than copied: the delegate returned by
// WithAttrs writes into the *same* buffer this one does, so a second
// mutex would guard nothing and two loggers derived from one would
// interleave their bytes into a single line.
func (h *Handler) derive(delegate slog.Handler) *Handler {
return &Handler{
write: h.write,
mu: h.mu,
buf: h.buf,
delegate: delegate,
}
}
// Chunk splits a formatted record into entries liblog will carry
// whole.
//
// A record short enough to fit is returned as it is, which is nearly
// every record; the numbering only appears where something was going
// to be silently truncated anyway. It splits on bytes rather than runes
// because the limit is a byte count -- a multi-byte rune straddling the
// boundary is a mojibake character in a log line, against a lost one.
func Chunk(msg string) []string {
if len(msg) <= maxPayload {
return []string{msg}
}
var parts []string
for rest := msg; rest != ""; {
n := min(maxPayload, len(rest))
parts = append(parts, rest[:n])
rest = rest[n:]
}
numbered := make([]string, 0, len(parts))
for i, p := range parts {
numbered = append(
numbered,
"("+strconv.Itoa(i+1)+"/"+strconv.Itoa(len(parts))+") "+p,
)
}
return numbered
}
+355
View File
@@ -0,0 +1,355 @@
package androidlog_test
import (
"log/slog"
"strings"
"sync"
"testing"
"yellowjacket/backend/androidlog"
)
// entry is one call to the sink.
type entry struct {
prio int
tag string
msg string
}
// recorder is the platform write, on a machine with no platform.
type recorder struct {
mu sync.Mutex
entries []entry
}
func (r *recorder) write(prio int, tag, msg string) {
r.mu.Lock()
defer r.mu.Unlock()
r.entries = append(r.entries, entry{prio: prio, tag: tag, msg: msg})
}
func (r *recorder) only(t *testing.T) entry {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
if len(r.entries) != 1 {
t.Fatalf("want exactly one entry, got %d: %v", len(r.entries), r.entries)
}
return r.entries[0]
}
func newLogger(r *recorder, level slog.Level) *slog.Logger {
return slog.New(androidlog.NewHandler(
&slog.HandlerOptions{Level: level},
r.write,
))
}
// TestPriorityMapsEveryLevel pins the level banding.
//
// This is the one thing in #160 that a wrong answer hides rather than
// breaks: logcat prints whatever priority it is handed, so an Error
// filed as Info is a line that is present, correct and invisible to
// every filter anyone would use to look for it.
func TestPriorityMapsEveryLevel(t *testing.T) {
t.Parallel()
tests := []struct {
name string
level slog.Level
want int
}{
{"below debug is verbose", slog.LevelDebug - 1, androidlog.PrioVerbose},
{"debug", slog.LevelDebug, androidlog.PrioDebug},
{"info", slog.LevelInfo, androidlog.PrioInfo},
{"warn", slog.LevelWarn, androidlog.PrioWarn},
{"error", slog.LevelError, androidlog.PrioError},
// slog's levels are open, so a caller may sit between two of
// the named ones. Each lands in the band beneath it, which is
// what slog's own level naming does ("INFO+2").
{"between info and warn", slog.LevelInfo + 2, androidlog.PrioInfo},
{"between warn and error", slog.LevelWarn + 1, androidlog.PrioWarn},
{"above error", slog.LevelError + 4, androidlog.PrioError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := androidlog.Priority(tt.level); got != tt.want {
t.Errorf("Priority(%v) = %d, want %d", tt.level, got, tt.want)
}
})
}
}
// TestPrioritiesAreTheHeadersValues pins the constants themselves.
//
// android.go asserts these against android/log.h at compile time, but
// only a cross-compiler ever builds that file. This is the assertion
// that runs in CI, and the numbers are written out longhand on purpose
// -- comparing a constant to itself would pass on any renumbering.
func TestPrioritiesAreTheHeadersValues(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
got int
want int
}{
{"verbose", androidlog.PrioVerbose, 2},
{"debug", androidlog.PrioDebug, 3},
{"info", androidlog.PrioInfo, 4},
{"warn", androidlog.PrioWarn, 5},
{"error", androidlog.PrioError, 6},
{"fatal", androidlog.PrioFatal, 7},
} {
if tt.got != tt.want {
t.Errorf("%s priority = %d, want %d", tt.name, tt.got, tt.want)
}
}
}
// TestRecordReachesTheSink is the whole point of the package: a line
// written with slog arrives, under the app's tag, at the right
// priority.
func TestRecordReachesTheSink(t *testing.T) {
t.Parallel()
rec := &recorder{}
newLogger(rec, slog.LevelInfo).Error("application error", "err", "boom")
got := rec.only(t)
if got.prio != androidlog.PrioError {
t.Errorf("priority = %d, want %d", got.prio, androidlog.PrioError)
}
if got.tag != androidlog.Tag {
t.Errorf("tag = %q, want %q", got.tag, androidlog.Tag)
}
if !strings.Contains(got.msg, "application error") {
t.Errorf("message %q does not carry the message", got.msg)
}
if !strings.Contains(got.msg, `err=boom`) {
t.Errorf("message %q does not carry the attribute", got.msg)
}
}
// TestTheTagIsNotTheApplicationID guards the trap the tag exists to
// avoid.
//
// The debug build carries `applicationIdSuffix ".dev"`, so it is
// installed as app.yellowjacket.dev -- and it is the *only* build whose
// WebView can be inspected, so it is the build anyone debugging this
// app is running. A tag derived from the application id therefore
// differs between the build being looked at and the build the filter
// was written for, which is the failure this whole issue is about
// wearing a different hat.
func TestTheTagIsNotTheApplicationID(t *testing.T) {
t.Parallel()
if strings.Contains(androidlog.Tag, ".") {
t.Errorf(
"tag %q looks like an application id; it must be stable "+
"across the debug suffix",
androidlog.Tag,
)
}
// Logcat's tag field is 23 bytes. A longer one is truncated, and a
// truncated tag matches no filter.
if len(androidlog.Tag) > 23 {
t.Errorf("tag %q is %d bytes, over logcat's 23", androidlog.Tag, len(androidlog.Tag))
}
}
// TestTimeAndLevelAreDropped checks the formatting decision.
//
// logcat stamps every entry with a timestamp and a priority letter, so
// carrying slog's own is the same information twice on a 424px screen.
func TestTimeAndLevelAreDropped(t *testing.T) {
t.Parallel()
rec := &recorder{}
newLogger(rec, slog.LevelInfo).Warn("scan finished", "files", 1577)
got := rec.only(t).msg
if strings.Contains(got, "time=") {
t.Errorf("message %q still carries a timestamp", got)
}
if strings.Contains(got, "level=") {
t.Errorf("message %q still carries a level", got)
}
if !strings.Contains(got, "files=1577") {
t.Errorf("message %q lost its attributes with them", got)
}
}
// TestACallersOwnLevelAttrSurvives is a regression, and it was found on
// the phone rather than here.
//
// Dropping slog's built-in time and level by key alone also drops a
// caller's attribute of the same name, because ReplaceAttr sees an
// empty group path for both. The probe that verified this package on
// the device wrote slog.Info("...", "level", "info") and logcat showed
// the message with no attributes at all.
func TestACallersOwnLevelAttrSurvives(t *testing.T) {
t.Parallel()
rec := &recorder{}
newLogger(rec, slog.LevelInfo).Info("probe", "level", "info", "time", "soon")
got := rec.only(t).msg
for _, want := range []string{"level=info", "time=soon"} {
if !strings.Contains(got, want) {
t.Errorf("message %q lost the caller's %q", got, want)
}
}
// And slog's own are still gone: the built-in level renders as a
// bare word like INFO, never as the caller's value.
if strings.Contains(got, "level=INFO") {
t.Errorf("message %q carries slog's own level", got)
}
}
// TestLevelIsHonoured checks that Enabled reaches the delegate.
func TestLevelIsHonoured(t *testing.T) {
t.Parallel()
rec := &recorder{}
log := newLogger(rec, slog.LevelWarn)
log.Info("not this one")
log.Warn("this one")
if got := rec.only(t).msg; !strings.Contains(got, "this one") {
t.Errorf("wrong record survived: %q", got)
}
}
// TestGroupsAndAttrsSurvive covers the half of slog.Handler this
// delegates rather than implements -- the reason it delegates at all.
func TestGroupsAndAttrsSurvive(t *testing.T) {
t.Parallel()
rec := &recorder{}
log := newLogger(rec, slog.LevelInfo).
With("component", "player").
WithGroup("track")
log.Info("loaded", "path", "/sdcard/Music/a.flac")
got := rec.only(t).msg
for _, want := range []string{
"component=player",
"track.path=/sdcard/Music/a.flac",
} {
if !strings.Contains(got, want) {
t.Errorf("message %q is missing %q", got, want)
}
}
}
// TestDerivedHandlersDoNotInterleave is why derive shares the buffer's
// mutex rather than taking a new one.
//
// Two loggers derived from one write into the same buffer, so a second
// mutex would guard nothing and a concurrent pair would splice each
// other's bytes into a single line -- which reads as corrupted logs
// under load and as nothing at all in a test that logs once.
func TestDerivedHandlersDoNotInterleave(t *testing.T) {
t.Parallel()
rec := &recorder{}
base := newLogger(rec, slog.LevelInfo)
var wg sync.WaitGroup
for i := range 8 {
wg.Add(1)
go func() {
defer wg.Done()
log := base.With("worker", i).WithGroup("g")
for range 50 {
log.Info("tick", "n", i)
}
}()
}
wg.Wait()
rec.mu.Lock()
defer rec.mu.Unlock()
if len(rec.entries) != 8*50 {
t.Fatalf("got %d entries, want %d", len(rec.entries), 8*50)
}
for _, e := range rec.entries {
if strings.Count(e.msg, "msg=tick") != 1 {
t.Fatalf("interleaved line: %q", e.msg)
}
}
}
// TestChunkLeavesShortLinesAlone is the common case: no numbering
// appears on a record that was never going to be truncated.
func TestChunkLeavesShortLinesAlone(t *testing.T) {
t.Parallel()
got := androidlog.Chunk("msg=short")
if len(got) != 1 || got[0] != "msg=short" {
t.Errorf("Chunk(short) = %q, want the input unchanged", got)
}
}
// TestChunkSplitsWhatWouldBeTruncated covers the case liblog drops
// silently.
func TestChunkSplitsWhatWouldBeTruncated(t *testing.T) {
t.Parallel()
const n = 9000
long := strings.Repeat("x", n)
parts := androidlog.Chunk(long)
if len(parts) < 2 {
t.Fatalf("a %d-byte line was not split", n)
}
var payload strings.Builder
for i, p := range parts {
if len(p) > 4000 {
t.Errorf("part %d is %d bytes, over liblog's entry", i, len(p))
}
_, rest, found := strings.Cut(p, ") ")
if !found {
t.Fatalf("part %d carries no (n/m) marker: %q", i, p)
}
payload.WriteString(rest)
}
if payload.String() != long {
t.Errorf("the parts do not reassemble into the input")
}
}
+2 -1
View File
@@ -40,7 +40,8 @@ WHERE id = ? AND (mbid IS NULL OR mbid = '');
-- name: GetAlbumsWithPendingReleaseMBID :many
SELECT id, pending_release_mbid FROM albums
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
AND (mbid IS NULL OR mbid = '');
AND (mbid IS NULL OR mbid = '')
LIMIT ?;
-- name: DeleteAlbum :exec
DELETE FROM albums WHERE id = ?;
+3 -2
View File
@@ -331,6 +331,7 @@ const getAlbumsWithPendingReleaseMBID = `-- name: GetAlbumsWithPendingReleaseMBI
SELECT id, pending_release_mbid FROM albums
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
AND (mbid IS NULL OR mbid = '')
LIMIT ?
`
type GetAlbumsWithPendingReleaseMBIDRow struct {
@@ -338,8 +339,8 @@ type GetAlbumsWithPendingReleaseMBIDRow struct {
PendingReleaseMbid sql.NullString
}
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID)
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context, limit int64) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID, limit)
if err != nil {
return nil, err
}
+35 -31
View File
@@ -2,6 +2,7 @@ package explore
import (
"context"
"database/sql"
"log/slog"
"math"
"sort"
@@ -12,6 +13,7 @@ import (
"golang.org/x/sync/singleflight"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
@@ -279,37 +281,32 @@ func (e *Service) BackfillReleaseGroupMBIDs() {
go e.backfillReleaseGroupMBIDs(e.ctx)
}
func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
rows, err := e.db.QueryContext(
"SELECT id, pending_release_mbid FROM release_groups "+
"WHERE (mbid IS NULL OR mbid = '') "+
"AND pending_release_mbid IS NOT NULL AND pending_release_mbid != '' "+
"LIMIT ?",
releaseGroupMBIDBackfillMaxPerRun,
// pendingReleaseMBIDs is the albums this pass has work to do on.
//
// It is separate from the pass, and returns its error rather than
// logging it, so that a test can assert the statement runs against the
// real schema. That is not a general preference -- it is this
// statement's history: it named `release_groups`, a table plan 013
// renamed to `albums`, so it failed on every launch since e7748f1 and
// the pass returned quietly having done nothing. A test of the pass
// as a whole cannot see that, because a query error and an empty
// library are the same early return.
func (e *Service) pendingReleaseMBIDs(
ctx context.Context,
) ([]sqlcgen.GetAlbumsWithPendingReleaseMBIDRow, error) {
return e.db.ReadQueries.GetAlbumsWithPendingReleaseMBID(
ctx, releaseGroupMBIDBackfillMaxPerRun,
)
}
func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
pending, err := e.pendingReleaseMBIDs(ctx)
if err != nil {
e.logger.Warn("release-group mbid backfill: query failed", "error", err)
return
}
type pendingRow struct {
id int64
releaseMBID string
}
var pending []pendingRow
for rows.Next() {
var p pendingRow
if err := rows.Scan(&p.id, &p.releaseMBID); err == nil {
pending = append(pending, p)
}
}
_ = rows.Close()
if len(pending) == 0 {
return
}
@@ -335,7 +332,7 @@ func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
job.progress(i, len(pending))
release, err := e.mb.LookupRelease(ctx, p.releaseMBID)
release, err := e.mb.LookupRelease(ctx, p.PendingReleaseMbid.String)
if err != nil || release.ReleaseGroupMBID == "" {
// Left alone rather than cleared: LookupRelease caches its
// answer (success or a release with no group) for 7 days,
@@ -344,12 +341,19 @@ func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
continue
}
_, err = e.db.ExecContext(
"UPDATE release_groups SET mbid = ?, pending_release_mbid = NULL "+
"WHERE id = ? AND (mbid IS NULL OR mbid = '')",
release.ReleaseGroupMBID, p.id,
)
if err != nil {
// The writer, not ReadQueries: an UPDATE issued on the
// query-only pool fails at runtime with "attempt to write a
// readonly database".
if err := e.db.Queries.ResolveAlbumPendingReleaseMBID(
ctx,
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
Mbid: sql.NullString{
String: release.ReleaseGroupMBID,
Valid: true,
},
ID: p.ID,
},
); err != nil {
e.logger.Warn("release-group mbid backfill: update failed", "error", err)
}
}
+250
View File
@@ -0,0 +1,250 @@
package explore
import (
"database/sql"
"log/slog"
"strconv"
"testing"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// The release-group MBID backfill queried `release_groups`, a table
// plan 013 renamed to `albums`, so it failed on its first statement on
// every launch from e7748f1 until #189 -- and the pass swallowed that,
// because a query error and an empty library are the same early
// return. Nothing noticed for two reasons worth keeping in mind:
//
// - the statement was **raw SQL**, so sqlc never read it. Every other
// statement in the repo was renamed by the same change because sqlc
// reads sql/schemas/ and cannot generate against a table that is not
// declared. The two sqlc queries this now calls were written by 013
// and left uncalled.
// - it needs no network and no fixture library to reproduce. The
// failure is at prepare time.
// seedPendingAlbum inserts an album whose files carried a release MBID
// but no release-group MBID, which is what `library.updateMBIDs`
// leaves behind for this pass to resolve.
func seedPendingAlbum(
t *testing.T,
db *database.DB,
name, pendingMBID string,
) int64 {
t.Helper()
res, err := db.ExecContext(
"INSERT INTO albums (name, artist_credit, pending_release_mbid) "+
"VALUES (?, ?, ?)",
name, "Test Artist", pendingMBID,
)
if err != nil {
t.Fatalf("insert albums row: %v", err)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("last insert id: %v", err)
}
return id
}
func newPendingTestService(db *database.DB) *Service {
return &Service{db: db, logger: slog.Default()}
}
// TestPendingReleaseMBIDsRunsAgainstTheRealSchema is the regression.
//
// It asserts the statement *runs*, which is the whole of what was
// broken: against the old raw SQL this returns
// "no such table: release_groups" rather than a row.
func TestPendingReleaseMBIDsRunsAgainstTheRealSchema(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
want := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != 1 {
t.Fatalf("got %d pending albums, want 1", len(pending))
}
if pending[0].ID != want {
t.Errorf("got album id %d, want %d", pending[0].ID, want)
}
if got := pending[0].PendingReleaseMbid.String; got != "release-mbid-1" {
t.Errorf("got pending mbid %q, want %q", got, "release-mbid-1")
}
}
// TestOnlyUnresolvedAlbumsAreReturned pins the two conditions that make
// the pass idempotent, since between them they are what stops it doing
// the same MusicBrainz lookups on every launch forever.
func TestOnlyUnresolvedAlbumsAreReturned(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
pendingID := seedPendingAlbum(t, db, "Still Pending", "release-mbid-1")
// Already resolved: it has a real MBID, so there is nothing to
// look up even though a marker is still sitting on it.
resolved := seedPendingAlbum(t, db, "Already Resolved", "release-mbid-2")
if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{
Mbid: sql.NullString{String: "rg-mbid", Valid: true},
ID: resolved,
}); err != nil {
t.Fatalf("set album mbid: %v", err)
}
// Never had a release MBID to resolve in the first place, which is
// most of a library.
seedPendingAlbum(t, db, "Nothing Pending", "")
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != 1 || pending[0].ID != pendingID {
t.Fatalf(
"got %d albums %v, want only the unresolved one (%d)",
len(pending), pending, pendingID,
)
}
}
// TestResolvingClearsTheMarker is the other half: once the lookup has
// answered, the album must stop being a candidate, or the pass repeats
// the same live MusicBrainz call on every launch.
func TestResolvingClearsTheMarker(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
id := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
// The writer, deliberately: this is an UPDATE, and the read pool
// would refuse it at runtime.
if err := db.Queries.ResolveAlbumPendingReleaseMBID(
db.Ctx,
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
Mbid: sql.NullString{String: "resolved-rg-mbid", Valid: true},
ID: id,
},
); err != nil {
t.Fatalf("resolve pending release mbid: %v", err)
}
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != 0 {
t.Fatalf("a resolved album is still a candidate: %v", pending)
}
album, err := db.ReadQueries.GetAlbum(db.Ctx, id)
if err != nil {
t.Fatalf("get album: %v", err)
}
if album.Mbid.String != "resolved-rg-mbid" {
t.Errorf("album mbid = %q, want the resolved one", album.Mbid.String)
}
if album.PendingReleaseMbid.Valid &&
album.PendingReleaseMbid.String != "" {
t.Errorf(
"the pending marker survived as %q",
album.PendingReleaseMbid.String,
)
}
}
// TestAResolvedMBIDIsNeverOverwritten covers the guard in the UPDATE.
//
// The pass runs against rows it read earlier, and a rescan can resolve
// an album from its tags in between -- a real MBID from the file must
// win over one this pass inferred from a release.
func TestAResolvedMBIDIsNeverOverwritten(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
id := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{
Mbid: sql.NullString{String: "from-the-tags", Valid: true},
ID: id,
}); err != nil {
t.Fatalf("set album mbid: %v", err)
}
if err := db.Queries.ResolveAlbumPendingReleaseMBID(
db.Ctx,
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
Mbid: sql.NullString{String: "from-the-backfill", Valid: true},
ID: id,
},
); err != nil {
t.Fatalf("resolve pending release mbid: %v", err)
}
album, err := db.ReadQueries.GetAlbum(db.Ctx, id)
if err != nil {
t.Fatalf("get album: %v", err)
}
if album.Mbid.String != "from-the-tags" {
t.Errorf(
"album mbid = %q, want the tagged one to have won",
album.Mbid.String,
)
}
}
// TestThePassIsBounded checks the LIMIT.
//
// Each row costs a live MusicBrainz lookup on a 1 req/s limiter shared
// with every page the user can open, so an unbounded read is a run that
// lasts as long as the library is untagged. The sqlc query 013 wrote
// had no LIMIT; the raw statement it was replacing did.
func TestThePassIsBounded(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newPendingTestService(db)
for i := range releaseGroupMBIDBackfillMaxPerRun + 10 {
seedPendingAlbum(
t, db,
"Album "+string(rune('A'+i%26))+strconv.Itoa(i),
"release-mbid-"+strconv.Itoa(i),
)
}
pending, err := e.pendingReleaseMBIDs(db.Ctx)
if err != nil {
t.Fatalf("the backfill's query failed: %v", err)
}
if len(pending) != releaseGroupMBIDBackfillMaxPerRun {
t.Errorf(
"got %d albums, want the run bounded at %d",
len(pending), releaseGroupMBIDBackfillMaxPerRun,
)
}
}
+58
View File
@@ -47,6 +47,39 @@ type BufferedStreamer struct {
// seek bar and suppresses its interpolation.
starved int
starvedSince time.Time
// underruns accumulates for the life of this streamer, where
// starved is reset by every arriving sample.
//
// The two answer different questions and only the first was being
// asked. starved is a *stall* detector: it exists to end a track
// whose source has died, so it forgets a run the moment audio
// resumes -- which is exactly the case this counts. A hundred 20ms
// underruns a minute never approach the give-up threshold and were
// invisible to the log, the UI and every test tier, while being
// audible as static: an underrun is served as a run of zeros
// spliced into the waveform, and a step discontinuity at each edge
// is what a click is.
underruns UnderrunStats
}
// UnderrunStats is what the ring buffer missed, cumulatively.
//
// Samples rather than milliseconds because this type does not know the
// sample rate -- the player does, and converts at the point of
// reporting.
type UnderrunStats struct {
// Runs is the number of *episodes*: transitions from healthy into
// starved. Calls is how many Stream calls were served with
// silence, and Samples is how much silence that was.
//
// Runs is the count that means something audible. One episode is
// one pop however many calls it spans, and the ratio of the two is
// how long the average episode was -- which is what separates
// "clicking" from "dropping out".
Runs int64
Calls int64
Samples int64
}
// The silence fill is bounded by both a duration and a run of calls,
@@ -226,6 +259,13 @@ func (bs *BufferedStreamer) Stream(
// but only for a bounded stretch, because "forever" is
// reported upward as healthy playback and there is no watchdog
// above this to notice otherwise.
if bs.starved == 0 {
bs.underruns.Runs++
}
bs.underruns.Calls++
bs.underruns.Samples += int64(len(samples))
bs.starved++
if bs.starvedSince.IsZero() {
@@ -269,6 +309,20 @@ func (bs *BufferedStreamer) Stream(
return n, true
}
// Underruns returns the cumulative underrun count.
//
// It is a snapshot rather than a live view, and it is read from
// outside the audio callback: counting happens in Stream, under the
// lock it already takes, because that path has a real-time deadline
// and anything that allocates or formats on it is a cause of the
// defect it is measuring rather than a measurement of it.
func (bs *BufferedStreamer) Underruns() UnderrunStats {
bs.mu.Lock()
defer bs.mu.Unlock()
return bs.underruns
}
// Err returns any error encountered by the source streamer.
func (bs *BufferedStreamer) Err() error {
bs.mu.Lock()
@@ -298,6 +352,10 @@ func (bs *BufferedStreamer) Flush() {
// resetStarvationLocked forgets an underrun run. Must be called with
// bs.mu held.
//
// Deliberately does not touch bs.underruns: forgetting the run is what
// makes starved a stall detector, and remembering it is the whole
// point of the counter beside it.
func (bs *BufferedStreamer) resetStarvationLocked() {
bs.starved = 0
bs.starvedSince = time.Time{}
+279
View File
@@ -0,0 +1,279 @@
package player
import (
"sync"
"testing"
"time"
)
// An underrun is audible and nothing counted it (#135).
//
// The distinction these tests exist for is that `starved` and
// `underruns` disagree on purpose. `starved` is a stall detector: it is
// reset by every arriving sample, because its job is to end a track
// whose source has died and a source that is merely slow must not be
// cut short (TestUnderrunsDoNotAccumulateAcrossASlowSource, next
// door). That reset is exactly what made the audible case invisible --
// a hundred short underruns a minute never approach the give-up
// threshold, and each one is a run of zeros spliced into the waveform
// with a step discontinuity at both edges.
// TestAnEmptyRingIsCounted is the measurement itself: silence served
// for a missing sample is recorded rather than merely tolerated.
func TestAnEmptyRingIsCounted(t *testing.T) {
t.Parallel()
// A source that never produces is the cleanest way to make the
// ring empty on demand; the stall budget is far longer than the
// handful of calls below.
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
defer bs.Close()
if got := bs.Underruns(); got != (UnderrunStats{}) {
t.Fatalf("a fresh streamer already reports %+v", got)
}
buf := make([][2]float64, 256)
for range 3 {
if _, ok := bs.Stream(buf); !ok {
t.Fatal("the stall budget ran out before the test did")
}
}
got := bs.Underruns()
if got.Calls != 3 {
t.Errorf("Calls = %d, want 3", got.Calls)
}
if got.Samples != int64(3*len(buf)) {
t.Errorf("Samples = %d, want %d", got.Samples, 3*len(buf))
}
// Three consecutive silent calls are one episode, not three. That
// is the number that means something audible: one interruption is
// one pop however many callbacks it spans.
if got.Runs != 1 {
t.Errorf("Runs = %d, want 1 -- an unbroken run is one episode", got.Runs)
}
}
// TestSilenceIsWhatIsCounted pins what an underrun actually does to the
// waveform, which is the reason to count it at all.
func TestSilenceIsWhatIsCounted(t *testing.T) {
t.Parallel()
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
defer bs.Close()
buf := make([][2]float64, 64)
for i := range buf {
buf[i] = [2]float64{0.5, 0.5}
}
n, ok := bs.Stream(buf)
if !ok || n != len(buf) {
t.Fatalf("Stream = (%d, %v), want (%d, true)", n, ok, len(buf))
}
for i := range buf {
if buf[i] != ([2]float64{}) {
t.Fatalf("sample %d is %v, want silence", i, buf[i])
}
}
if got := bs.Underruns().Samples; got != int64(len(buf)) {
t.Errorf("counted %d samples of silence, wrote %d", got, len(buf))
}
}
// TestSeparateEpisodesAreSeparateRuns is the counter's whole shape:
// audio arriving between two underruns makes them two, because that is
// two interruptions and two clicks.
func TestSeparateEpisodesAreSeparateRuns(t *testing.T) {
t.Parallel()
// A source that yields nothing until it is fed, so the ring can be
// emptied, filled and emptied again on demand.
src := &gatedStreamer{}
bs := NewBufferedStreamer(src, 1024)
defer bs.Close()
buf := make([][2]float64, 128)
starve := func() {
t.Helper()
for range 2 {
if _, ok := bs.Stream(buf); !ok {
t.Fatal("the stall budget ran out before the test did")
}
}
}
// feed lets exactly one bufferful through and drains it, so the
// ring is empty again on return. Allowing more would mean the
// starve() after it drained real audio instead of underrunning,
// which is what the first version of this test did -- it reported
// one episode and looked like the counter was wrong.
feed := func() {
t.Helper()
src.allow(len(buf))
// The read-ahead is a goroutine, so wait for real samples
// rather than assuming they have landed.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
n, ok := bs.Stream(buf)
if ok && n > 0 && buf[0] != ([2]float64{}) {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("the source never delivered a sample")
}
starve()
feed()
starve()
if got := bs.Underruns().Runs; got < 2 {
t.Errorf(
"Runs = %d, want at least 2 -- audio in between makes two "+
"episodes, not one",
got,
)
}
}
// TestTheStallResetDoesNotClearTheCounter is the regression this file
// is really about.
//
// resetStarvationLocked runs on every arriving sample and on every
// Flush. If it cleared the cumulative count too, the counter would
// report zero on exactly the workload it exists to measure -- a stream
// that underruns repeatedly but always recovers -- which is
// indistinguishable from healthy playback and is what the code did
// before #135.
func TestTheStallResetDoesNotClearTheCounter(t *testing.T) {
t.Parallel()
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
defer bs.Close()
buf := make([][2]float64, 128)
if _, ok := bs.Stream(buf); !ok {
t.Fatal("the stall budget ran out before the test did")
}
before := bs.Underruns()
if before.Runs == 0 {
t.Fatal("nothing was counted, so the reset cannot be tested")
}
// Both of the ways a run is forgotten.
bs.mu.Lock()
bs.resetStarvationLocked()
bs.mu.Unlock()
bs.Flush()
if got := bs.Underruns(); got != before {
t.Errorf(
"forgetting the stall run also discarded the count: %+v, "+
"want %+v",
got, before,
)
}
}
// TestUnderrunDeltaNeverGoesBackwards covers the one arithmetic trap in
// the reporting side.
//
// The counter belongs to the streamer and the streamer is replaced on
// every track, so a baseline carried across a track change is the
// previous track's total subtracted from a fresh zero. The load path
// resets the baseline, and this clamps as well -- a negative count in a
// log line reads as a broken instrument, which would discredit the
// measurement rather than merely mis-state it.
func TestUnderrunDeltaNeverGoesBackwards(t *testing.T) {
t.Parallel()
tests := []struct {
name string
now UnderrunStats
last UnderrunStats
want UnderrunStats
}{
{
name: "ordinary progress",
now: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
last: UnderrunStats{Runs: 2, Calls: 10, Samples: 1000},
want: UnderrunStats{Runs: 3, Calls: 30, Samples: 3000},
},
{
name: "nothing happened",
now: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
last: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
want: UnderrunStats{},
},
{
name: "a new streamer, with a stale baseline",
now: UnderrunStats{},
last: UnderrunStats{Runs: 9, Calls: 90, Samples: 9000},
want: UnderrunStats{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := underrunDelta(tt.now, tt.last); got != tt.want {
t.Errorf("underrunDelta = %+v, want %+v", got, tt.want)
}
})
}
}
// gatedStreamer produces only what it has been allowed to, and
// otherwise stalls without ending -- so a test can decide exactly when
// the ring runs dry.
type gatedStreamer struct {
mu sync.Mutex
remaining int
}
func (g *gatedStreamer) allow(n int) {
g.mu.Lock()
defer g.mu.Unlock()
g.remaining += n
}
func (g *gatedStreamer) Stream(samples [][2]float64) (int, bool) {
g.mu.Lock()
defer g.mu.Unlock()
if g.remaining <= 0 {
return 0, true
}
n := min(len(samples), g.remaining)
for i := range n {
samples[i] = [2]float64{0.25, 0.25}
}
g.remaining -= n
return n, true
}
func (g *gatedStreamer) Err() error { return nil }
+146 -17
View File
@@ -39,16 +39,21 @@ type Player struct {
// via the queue).
mu sync.Mutex
ctx context.Context
logger *slog.Logger
db *database.DB
state State
currentFile *os.File
format beep.Format
baseStreamer beep.Streamer
seeker beep.StreamSeeker
resampled beep.Streamer
buffered *BufferedStreamer
ctx context.Context
logger *slog.Logger
db *database.DB
state State
currentFile *os.File
format beep.Format
baseStreamer beep.Streamer
seeker beep.StreamSeeker
resampled beep.Streamer
buffered *BufferedStreamer
// lastUnderruns is the previous report, so the 1 Hz log can say
// what happened in the last second and stay quiet when nothing did.
// It is reset with the streamer, in loadFileLocked.
lastUnderruns UnderrunStats
control *beep.Ctrl
volume *effects.Volume
speakerStreamer beep.Streamer
@@ -71,6 +76,19 @@ type Player struct {
// not something the user chose.
duckAmount float64
// systemVolume is what SystemOwnsVolume answers: the platform's own
// control is the only one, so ours neither acts nor persists. It is
// a field rather than the build constant read directly so that a
// test can exercise both sides on any machine. See systemvolume.go.
systemVolume bool
// storedVolume and storedMuted hold the persisted level as it was
// found at restore, for a platform whose volume we do not own: the
// maximum we then run at is not a level the user chose, so saveState
// writes back what it read rather than overwriting it.
storedVolume UserVolume
storedMuted bool
// trackLengthMs holds the authoritative track duration in
// milliseconds, sourced from the database (which uses the
// custom header parser). The go-mp3 decoder's Len() can be
@@ -156,6 +174,8 @@ func NewPlayer(logger *slog.Logger, db *database.DB) *Player {
logger: logger,
db: db,
state: Stopped,
systemVolume: platformOwnsVolume,
storedVolume: DefaultUserVol,
baseStreamer: generators.Silence(-1),
format: beep.Format{
SampleRate: speakerSampleRate,
@@ -277,9 +297,78 @@ func (p *Player) emitPositionIfPlaying() {
return
}
p.reportUnderrunsLocked()
p.emitPositionLocked()
}
// underrunDelta is what happened since the last report.
//
// It clamps at zero rather than subtracting blind, because the counter
// belongs to the *streamer* and the streamer is replaced on every
// track: a baseline carried across that boundary is the previous
// track's total subtracted from a fresh zero, which is negative. That
// is repaired at the load (lastUnderruns is reset with the streamer)
// and clamped here as well, because a negative count in a log line
// reads as a broken instrument and would discredit the measurement
// this exists to make.
func underrunDelta(now, last UnderrunStats) UnderrunStats {
return UnderrunStats{
Runs: max(0, now.Runs-last.Runs),
Calls: max(0, now.Calls-last.Calls),
Samples: max(0, now.Samples-last.Samples),
}
}
// reportUnderrunsLocked logs what the ring buffer missed, at most once
// a second and only when the number moved. Must be called with p.mu
// held.
//
// **An underrun is audible and nothing counted it** (#135). The ring
// serves silence when it is empty, so a run of zeros is spliced into
// the waveform and the step discontinuity at each edge is a click; a
// series of short ones is static. Everything that makes one likelier
// is worse on a phone than on a desktop -- slower storage, a governor
// that parks cores, background work, GC -- and no tier here can see it,
// since CI's audio device is a null sink chosen because it keeps time.
//
// Three things about the reporting are deliberate.
//
// **It is on the 1 Hz position ticker rather than in Stream.** Stream
// runs on the speaker callback's real-time deadline, and a log line
// there would allocate, format and write on the exact path whose
// missed deadline is the defect -- measuring by making it worse.
//
// **An unchanged count is not logged.** That is emitStatus' rule one
// package over: a healthy player is silent, so anything in the log is
// news, and the line appears exactly while it is popping. Reading it
// off a device means `make android-logs` with the audio audible.
//
// **It is Info rather than Debug**, because the default level is Info
// and a phone has no convenient way to set YJ_LOG_LEVEL -- a debug
// line here would be a counter nobody on the affected platform can
// read, which is the shape of the bug that made #160 necessary.
func (p *Player) reportUnderrunsLocked() {
if p.buffered == nil {
return
}
stats := p.buffered.Underruns()
if stats == p.lastUnderruns {
return
}
since := underrunDelta(stats, p.lastUnderruns)
p.lastUnderruns = stats
slog.Info("audio underrun",
"runs", since.Runs,
"calls", since.Calls,
"silenceMs", speakerSampleRate.D(int(since.Samples)).Milliseconds(),
"trackRuns", stats.Runs,
"trackSilenceMs", speakerSampleRate.D(int(stats.Samples)).Milliseconds(),
)
}
// emitPositionLocked pushes the current position to the frontend.
// Must be called with p.mu held.
func (p *Player) emitPositionLocked() {
@@ -469,6 +558,12 @@ func (p *Player) updateStreamers(
p.resampled, int(speakerSampleRate)*2,
)
// The counter belongs to the streamer, so the baseline it is
// reported against has to go with it -- otherwise the first report
// of a new track is the previous track's total subtracted from
// zero, which is negative and looks like the instrument is broken.
p.lastUnderruns = UnderrunStats{}
// wrap in ctrl streamer to allow play/pause
p.control = &beep.Ctrl{Streamer: p.buffered}
@@ -875,6 +970,10 @@ func (p *Player) SetVolume(desiredVolume UserVolume) {
p.mu.Lock()
defer p.mu.Unlock()
if p.systemVolume {
return
}
p.setVolumeLocked(desiredVolume)
p.emitVolumeChanged()
p.saveState()
@@ -923,6 +1022,10 @@ func (p *Player) ChangeVolume(deltaVolume int) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.systemVolume {
return nil
}
p.setVolumeLocked(p.getUserVolume() + UserVolume(deltaVolume))
p.emitVolumeChanged()
p.saveState()
@@ -953,6 +1056,14 @@ func (p *Player) MuteToggle() error {
return errNoAudioFileLoaded
}
// Mute is a level of zero by another name, so it goes with the rest
// of the volume where the system owns it -- and it would be the one
// state on such a platform the user could not get out of, since with
// no control rendered there is nothing left to un-mute with.
if p.systemVolume {
return nil
}
speaker.Lock()
p.volume.Silent = !p.volume.Silent
speaker.Unlock()
@@ -1403,7 +1514,15 @@ func (p *Player) saveState() {
volume := int64(DefaultUserVol)
muted := false
if p.volume != nil {
switch {
case p.systemVolume:
// The maximum this platform runs at is not a level anybody
// chose, so it is not one to remember. Writing back what
// restore found keeps the row a description of the user's
// setting without needing a second query that omits the column.
volume = int64(p.storedVolume)
muted = p.storedMuted
case p.volume != nil:
volume = int64(p.getUserVolume())
muted = p.volume.Silent
}
@@ -1487,11 +1606,20 @@ func (p *Player) restoreStateLocked() {
}
}
vol := clampVolume(UserVolume(state.Volume))
p.setVolumeLocked(vol)
if p.systemVolume {
// Remembered, not applied: the device's keys are the volume
// control here, so the player runs wide open and hands the
// stored level back untouched at the next save.
p.storedVolume = clampVolume(UserVolume(state.Volume))
p.storedMuted = state.Muted
p.setVolumeLocked(MaxUserVol)
} else {
vol := clampVolume(UserVolume(state.Volume))
p.setVolumeLocked(vol)
if state.Muted {
p.volume.Silent = true
if state.Muted {
p.volume.Silent = true
}
}
// Restore last track if the file still exists.
@@ -1531,8 +1659,9 @@ func (p *Player) restoreStateLocked() {
}
p.logger.Info("Player state restored",
"volume", vol,
"muted", state.Muted,
"volume", p.getUserVolume(),
"muted", p.volume.Silent,
"systemVolume", p.systemVolume,
"trackPath", state.LastTrackPath,
"positionSeconds", state.LastPositionSeconds,
)
+42
View File
@@ -0,0 +1,42 @@
package player
// Who owns the volume, and what follows when it is not us.
//
// On Android the hardware keys *are* the volume control and the
// framework mixes our stream against the device level, so a second
// control inside the app is a slider that moves something the user
// already moved (#64). Where that is true the player's own level sits
// at maximum, nothing changes it, and nothing persists it.
//
// **The predicate is named after the capability, not the platform.**
// The frontend asks "is there a volume for me to control", which is a
// question about this build; asking "is this a phone" instead would
// key the answer to a viewport, and an Android tablet at 600px or more
// would then draw the bottom bar's slider over a level pinned at
// maximum -- a control that cannot act, which is the thing
// `library-status-indicator` already settled is worse than none.
//
// **Only `platformOwnsVolume` is behind a build tag**, in two files
// that declare nothing else. A tagged file is compiled by nothing
// `make lint` or `make test` runs and is untestable off a phone, which
// is the reasoning `mediacontrols/androidpayload.go` states for
// keeping its contract out of one -- so everything decidable here is
// decided against `Player.systemVolume`, a field a test sets either
// way, and the tag decides only what that field starts as.
//
// The one thing this must not disturb is ducking. `SetDuck` applies
// its attenuation by re-applying the *user's* level through
// `setVolumeLocked`, so pinning that level to maximum leaves the
// offset arithmetic exactly as it was: an OS asking us to get out of
// the way of a navigation prompt is not the user setting a volume, and
// it is the only thing that may move the output on such a platform.
// SystemOwnsVolume reports whether the platform's own control is the
// only volume control there is, so this app neither offers one nor
// remembers a level.
//
// It is bound: the frontend renders no `<volume-control>` when it is
// true, at any width.
func (p *Player) SystemOwnsVolume() bool {
return p.systemVolume
}
+11
View File
@@ -0,0 +1,11 @@
//go:build android
package player
// platformOwnsVolume is true on Android: volume is the device's, set
// with the hardware keys, and `mediacontrols`' Android handler
// implements no volume callback for the same reason.
//
// See systemvolume.go for why this constant is the whole of what a
// build tag decides here.
const platformOwnsVolume = true
+10
View File
@@ -0,0 +1,10 @@
//go:build !android
package player
// platformOwnsVolume is false everywhere but Android: a desktop mixer
// is per-application, so our level is the one the user reaches for.
//
// See systemvolume.go for why this constant is the whole of what a
// build tag decides here.
const platformOwnsVolume = false
+215
View File
@@ -0,0 +1,215 @@
package player
import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gopxl/beep/v2/effects"
"yellowjacket/backend/database"
)
// pinnedPlayer is a player on a platform whose volume belongs to the
// device. The field is set rather than the build constant read,
// because the constant is true on exactly one platform and no tier
// here runs on it -- see systemvolume.go.
func pinnedPlayer(t *testing.T, db *database.DB) *Player {
t.Helper()
p := NewPlayer(slog.Default(), db)
p.systemVolume = true
p.volume = &effects.Volume{Base: 2}
p.setVolumeLocked(MaxUserVol)
return p
}
// TestSystemVolumeRefusesEveryWayToChangeTheLevel is the first half of
// #64: where the device owns the volume, ours sits at maximum and none
// of the three routes to a level moves it. Mute is in that list
// because it is a level of zero by another name, and because with no
// control rendered it is the one state on such a platform there would
// be nothing to get out of.
func TestSystemVolumeRefusesEveryWayToChangeTheLevel(t *testing.T) {
t.Parallel()
p := pinnedPlayer(t, nil)
if !p.SystemOwnsVolume() {
t.Fatal("SystemOwnsVolume() = false on a pinned player")
}
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("starting volume = %d, want %d", got, MaxUserVol)
}
p.SetVolume(20)
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("volume after SetVolume(20) = %d, want %d", got, MaxUserVol)
}
if err := p.ChangeVolume(-30); err != nil {
t.Fatalf("ChangeVolume: %v", err)
}
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("volume after ChangeVolume(-30) = %d, want %d", got, MaxUserVol)
}
if err := p.MuteToggle(); err != nil {
t.Fatalf("MuteToggle: %v", err)
}
if p.volume.Silent {
t.Error("MuteToggle silenced a player whose volume the system owns")
}
}
// TestAnUnpinnedPlayerStillChangesItsVolume is the other side of the
// same switch. Without it the test above passes on a player that
// refuses everything, which is what a mis-wired field would produce.
func TestAnUnpinnedPlayerStillChangesItsVolume(t *testing.T) {
t.Parallel()
p := NewPlayer(slog.Default(), nil)
p.volume = &effects.Volume{Base: 2}
p.setVolumeLocked(MaxUserVol)
if p.SystemOwnsVolume() {
t.Fatal("SystemOwnsVolume() = true off Android")
}
p.SetVolume(20)
if got := p.getUserVolume(); got != 20 {
t.Errorf("volume after SetVolume(20) = %d, want 20", got)
}
if err := p.MuteToggle(); err != nil {
t.Fatalf("MuteToggle: %v", err)
}
if !p.volume.Silent {
t.Error("MuteToggle did not silence an ordinary player")
}
}
// TestSystemVolumeStillDucks is the issue's second Finding, made a
// test: pinning the user's level must leave the OS's attenuation
// working, because a duck is not a volume the user chose and is the
// only thing that may move the output on such a platform.
func TestSystemVolumeStillDucks(t *testing.T) {
t.Parallel()
p := pinnedPlayer(t, nil)
open := p.volume.Volume
p.SetDuck(true)
if p.volume.Volume >= open {
t.Errorf(
"ducked output = %v, want less than %v", p.volume.Volume, open,
)
}
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("user volume while ducked = %d, want %d", got, MaxUserVol)
}
// A refused SetVolume must not disturb the offset either: it
// returns before setVolumeLocked, which is what re-applies it.
ducked := p.volume.Volume
p.SetVolume(10)
if p.volume.Volume != ducked {
t.Errorf(
"output after a refused SetVolume = %v, want %v",
p.volume.Volume, ducked,
)
}
p.SetDuck(false)
if p.volume.Volume != open {
t.Errorf("output after unduck = %v, want %v", p.volume.Volume, open)
}
}
// TestSystemVolumeWritesBackTheLevelItFound is the rest of the
// Direction: "make sure nothing writes a persisted volume from that
// platform". The maximum the player runs at is synthetic, so saving
// must not record it over whatever the row already said.
func TestSystemVolumeWritesBackTheLevelItFound(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
// A level set by some earlier, unpinned session.
writer := NewPlayer(slog.Default(), db)
writer.volume = &effects.Volume{Base: 2}
writer.setVolumeLocked(30)
writer.SaveState()
p := pinnedPlayer(t, db)
p.RestoreState()
if got := p.getUserVolume(); got != MaxUserVol {
t.Errorf("restored volume = %d, want %d (the level is pinned)", got, MaxUserVol)
}
if p.volume.Silent {
t.Error("restore muted a player whose volume the system owns")
}
p.SaveState()
state, err := db.Queries.GetPlayerState(db.Ctx)
if err != nil {
t.Fatalf("GetPlayerState: %v", err)
}
if state.Volume != 30 {
t.Errorf("persisted volume = %d, want 30 (untouched)", state.Volume)
}
}
// TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform sweeps the
// source, because the pair of tagged files is the one thing here no
// tier compiles both halves of: `make lint` and `make test` build the
// `!android` side only, so a deleted or edited android file fails
// nothing until somebody has a phone in their hand.
func TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform(t *testing.T) {
t.Parallel()
want := map[string]string{
"systemvolume_other.go": "const platformOwnsVolume = false",
"systemvolume_android.go": "const platformOwnsVolume = true",
}
tags := map[string]string{
"systemvolume_other.go": "//go:build !android",
"systemvolume_android.go": "//go:build android",
}
for name, decl := range want {
src, err := os.ReadFile(filepath.Join(".", name))
if err != nil {
t.Errorf("%s: %v", name, err)
continue
}
if !strings.Contains(string(src), decl) {
t.Errorf("%s does not declare %q", name, decl)
}
if !strings.Contains(string(src), tags[name]) {
t.Errorf("%s does not carry %q", name, tags[name])
}
}
}
+69
View File
@@ -52,6 +52,75 @@ func UseHomeOverride(base string) {
_ = os.Setenv(envHomeOverride, base)
}
// envTempDir is the variable Go's os.TempDir() reads, and through it
// every library in the process that asks for a temporary file.
const envTempDir = "TMPDIR"
// tempDirName is the subdirectory of the app's own storage that
// becomes that answer.
const tempDirName = "tmp"
// UseTempDir gives the process a temporary directory that exists.
//
// **Android has no /tmp and hands an app no TMPDIR**, and Go's
// os.TempDir() falls back to "/tmp" when the variable is unset -- so
// every library in the process that wants scratch space is handed a
// path that has never existed. SQLite is the one that noticed: an
// INSERT ... SELECT large enough to spill returned
// SQLITE_IOERR_GETTEMPPATH (disk I/O error 6410), which is how the
// champion search index came to fail its rebuild on every launch while
// the app otherwise looked healthy (#190).
//
// It is the *class* that is fixed here rather than that statement.
// Anything that spills fails the same way on that platform -- large
// sorts, large joins, VACUUM -- so the repair belongs at the process's
// one answer to the question rather than at each caller. The
// alternative considered was PRAGMA temp_store = MEMORY, which is
// cheaper and more local and is a promise that every future spill fits
// in RAM on a phone; the catalog is the largest thing in this app and
// that is not a promise worth making silently.
//
// The rules are UseHomeOverride's, for the same reasons. **An empty
// base is a no-op**, because that is what
// application.Mobile.StoragePath() returns on desktop -- so this needs
// no build tag and changes nothing off mobile, where /tmp is real. And
// **an explicit TMPDIR wins**, so anyone who set one deliberately gets
// what they asked for; nothing sets it on the platform this exists for.
//
// It returns its error rather than swallowing it because a temp
// directory that could not be created is the same silent failure one
// step earlier, and since #160 a log line on that platform is
// something a person can actually read.
func UseTempDir(base string) error {
if base == "" || os.Getenv(envTempDir) != "" {
return nil
}
dir := filepath.Join(base, tempDirName)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return fmt.Errorf("could not make the temp directory %s: %w", dir, err)
}
// Writability is checked rather than assumed: the whole failure
// this repairs is a directory that is named and cannot be used, and
// MkdirAll on an existing unwritable directory succeeds.
probe, err := os.CreateTemp(dir, "probe")
if err != nil {
return fmt.Errorf("temp directory %s is not writable: %w", dir, err)
}
name := probe.Name()
_ = probe.Close()
_ = os.Remove(name)
if err := os.Setenv(envTempDir, dir); err != nil {
return fmt.Errorf("could not set %s: %w", envTempDir, err)
}
return nil
}
// getUserDirPath returns and creates the path for a user directory.
func getUserDirPath(dt dirType) (string, error) {
path, err := resolveUserDirPath(dt)
+113
View File
@@ -87,3 +87,116 @@ func TestUseHomeOverride(t *testing.T) {
})
}
}
// UseTempDir carries UseHomeOverride's two rules for the same reasons,
// plus one of its own: the directory it names has to be usable.
//
// **The only tier that can compile the platform this exists for is a
// phone**, so everything decidable off one is decided here -- which is
// androidpayload.go's discipline, and is why the platform call is a
// parameter rather than something this package reaches for. The
// device's half is a single measurement: no /tmp, no TMPDIR (#190).
func TestUseTempDir(t *testing.T) {
t.Run("an empty base is a no-op", func(t *testing.T) {
// This is the desktop case in full: StoragePath() answers ""
// off mobile, where /tmp is real and must be left alone.
t.Setenv(envTempDir, "")
if err := UseTempDir(""); err != nil {
t.Fatalf("UseTempDir(\"\") = %v, want nil", err)
}
if got := os.Getenv(envTempDir); got != "" {
t.Errorf("%s = %q, want it untouched", envTempDir, got)
}
})
t.Run("an explicit TMPDIR wins", func(t *testing.T) {
const chosen = "/somewhere/deliberate"
// The base is taken before TMPDIR moves, because t.TempDir()
// reads TMPDIR too -- which is the same fact this function is
// about, met from the other side.
base := t.TempDir()
t.Setenv(envTempDir, chosen)
if err := UseTempDir(base); err != nil {
t.Fatalf("UseTempDir = %v, want nil", err)
}
if got := os.Getenv(envTempDir); got != chosen {
t.Errorf("%s = %q, want the explicit %q", envTempDir, got, chosen)
}
})
t.Run("points at a real directory under the base", func(t *testing.T) {
base := t.TempDir()
t.Setenv(envTempDir, "")
if err := UseTempDir(base); err != nil {
t.Fatalf("UseTempDir = %v, want nil", err)
}
got := os.Getenv(envTempDir)
want := filepath.Join(base, tempDirName)
if got != want {
t.Fatalf("%s = %q, want %q", envTempDir, got, want)
}
// The whole failure being repaired is a temp directory that is
// named and does not exist, so naming one is not enough.
info, err := os.Stat(got)
if err != nil {
t.Fatalf("the temp directory was named but not created: %v", err)
}
if !info.IsDir() {
t.Fatalf("%s is not a directory", got)
}
})
t.Run("os.TempDir then answers with it", func(t *testing.T) {
// The point of setting the variable at all: this is what every
// library in the process reads, SQLite's driver included.
base := t.TempDir()
t.Setenv(envTempDir, "")
if err := UseTempDir(base); err != nil {
t.Fatalf("UseTempDir = %v, want nil", err)
}
if got := os.TempDir(); got != filepath.Join(base, tempDirName) {
t.Errorf("os.TempDir() = %q, want the directory we made", got)
}
})
t.Run("an unwritable directory is an error, not a silent success", func(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("root can write anywhere, so there is nothing to refuse")
}
base := t.TempDir()
// MkdirAll on an existing directory succeeds whatever its
// mode, so without the write probe this case would set TMPDIR
// to a directory nothing can use -- which is the bug again,
// one directory over.
if err := os.Mkdir(filepath.Join(base, tempDirName), 0o500); err != nil {
t.Fatalf("prepare the unwritable directory: %v", err)
}
t.Setenv(envTempDir, "")
if err := UseTempDir(base); err == nil {
t.Fatal("UseTempDir accepted a directory it cannot write to")
}
if got := os.Getenv(envTempDir); got != "" {
t.Errorf("%s was set to %q despite the failure", envTempDir, got)
}
})
}
+209
View File
@@ -0,0 +1,209 @@
import { test, expect } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The album page on a phone (#66).
*
* Two faults, and neither was visible to `layout-overflow.spec.ts`:
* that spec asserts the *shell* needs no sideways scrolling, and the
* shell was correct throughout `body.scrollWidth === clientWidth`
* while `explore-album-details` itself measured 443 inside a 424px box
* and clipped two of the album's three primary actions with its own
* `overflow: hidden`. So the measurement here is **per control against
* the component's box**, which is the same shape `top-bar-fit.spec.ts`
* needed for the same reason.
*
* The other half is the scroll: the page was a fixed header over a
* scrolling tracklist, so at the reference device's 424x439 the header
* owned 253 of the panel's 318px and the list scrolled in the 64 that
* were left. It is one scroll container below 600px, which is a
* property of the *host* rather than of `.content`.
*
* The engine is the caveat this tier cannot close: the reference device
* renders in Chrome 113 and this is Chromium/WebKit. A flex direction
* and a scroll container are nowhere near that engine's documented gaps
* (relaxed nesting, the Popover API, `light-dark()`), but "it renders
* at that size in Chromium" is not evidence about the phone.
*/
/** The phone this was measured on, in CSS pixels. */
const DEVICE = { width: 424, height: 439 };
const details = (page: Page) => page.locator('explore-album-details');
/** The page's own boxes, read from inside its shadow root. */
const geometry = (page: Page) =>
page.evaluate(() => {
const host = document.querySelector('explore-album-details');
const sr = host?.shadowRoot;
if (!host || !sr) return null;
const box = (sel: string) => {
const el = sr.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return { width: Math.round(r.width), right: Math.round(r.right) };
};
const content = sr.querySelector('.content');
return {
hostWidth: host.clientWidth,
hostScrollWidth: host.scrollWidth,
// The host is the scroller below 600px, so the page is taller
// than its box rather than the tracklist being a window inside it.
hostScrolls: host.scrollHeight > host.clientHeight,
contentScrolls: content
? content.scrollHeight > content.clientHeight
: null,
header: box('.album-header'),
play: box('[data-testid="album-play"]'),
shuffle: box('[data-testid="album-shuffle"]'),
queue: box('[data-testid="album-queue"]'),
title: (() => {
const el = sr.querySelector('.album-title-text');
return el ? el.scrollWidth <= el.clientWidth + 1 : null;
})(),
};
});
test.describe('the album page on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await openFirstAlbum(app);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-tracks').click();
});
test('keeps every action inside its own box', async ({ app }) => {
const geo = await geometry(app);
expect(geo).not.toBeNull();
// "Shuffle album" ended at x=443 in a 424px component and could not
// be reached by any gesture; "Add to queue" at 440.
for (const action of ['play', 'shuffle', 'queue'] as const) {
expect(
geo?.[action],
`${action} is rendered`,
).not.toBeNull();
expect(
geo?.[action]?.right ?? 0,
`${action} ends inside the page`,
).toBeLessThanOrEqual(geo?.hostWidth ?? 0);
}
expect(geo?.hostScrollWidth).toBe(geo?.hostWidth);
expect(geo?.header?.width).toBe(geo?.hostWidth);
});
test('gives the title the row rather than one glyph of it', async ({
app,
}) => {
// `.album-info` was squeezed to 112px beside the art, so an album
// called *Glass Harbour* drew as `G…`. It carries `min-width: 0`
// and was shrinking as asked — the row had to stack.
expect(await geometry(app).then((g) => g?.title)).toBe(true);
});
test('scrolls as one page, with the header scrolling away', async ({
app,
}) => {
const before = await geometry(app);
expect(before?.hostScrolls).toBe(true);
expect(before?.contentScrolls).toBe(false);
const headerTop = () =>
app.evaluate(
() =>
document
.querySelector('explore-album-details')
?.shadowRoot?.querySelector('.album-header')
?.getBoundingClientRect().top ?? 0,
);
expect(await headerTop()).toBeGreaterThanOrEqual(0);
// A wheel gesture, not `scrollTop`: `overflow: hidden` still permits
// programmatic scrolling, so a probe that assigns it passes on the
// build this exists to fail.
await details(app).hover();
await app.mouse.wheel(0, 250);
await expect.poll(headerTop).toBeLessThan(-100);
});
test('is the desktop arrangement again above the breakpoint', async ({
app,
}) => {
await app.setViewportSize({ width: 1024, height: 800 });
// The same element, re-laid-out: one component with two
// arrangements, not a phone-only copy.
await expect
.poll(async () => (await geometry(app))?.hostScrolls)
.toBe(false);
const arrangement = await app.evaluate(() => {
const sr = document.querySelector('explore-album-details')?.shadowRoot;
const header = sr?.querySelector('.album-header');
const content = sr?.querySelector('.content');
return {
direction: header ? getComputedStyle(header).flexDirection : null,
contentOverflow: content ? getComputedStyle(content).overflowY : null,
};
});
expect(arrangement.direction).toBe('row');
expect(arrangement.contentOverflow).toBe('auto');
});
});
/** Albums → the second card, which navigates to the album page. */
async function openFirstAlbum(app: Page): Promise<void> {
// Below 600px the sidebar is gone; the tab bar is the navigation.
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'albums',
);
await expect.poll(() => cardCount(app)).toBeGreaterThan(1);
// Dispatched rather than clicked: the card lives in a virtualizer
// inside a shadow root, and Enter expands the dropdown instead.
await app.evaluate(() => {
document
.querySelector('cover-grid')
?.shadowRoot?.querySelectorAll('.album-card')[1]
?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
});
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
await expect(
details(app).locator('[data-testid="album-play"]'),
).toBeVisible();
}
async function cardCount(app: Page): Promise<number> {
return app.evaluate(
() =>
document
.querySelector('cover-grid')
?.shadowRoot?.querySelectorAll('.album-card').length ?? 0,
);
}
+283
View File
@@ -0,0 +1,283 @@
import { test, expect } from '../support/fixtures.js';
/**
* Now Playing on a short screen (#51).
*
* #51 asks for a layout that "survives" ~424x439 with the controls
* never scrolling off. #172 measured why it did not the stacked
* layout's budget is fixed, so the art gets whatever is left, and that
* was 39px before #64 and 53px after it.
*
* **Two separate claims are asserted here, and only one of them is
* about the phone.**
*
* The first is that the art is *square*. It was not: `aspect-ratio` is
* specified not to re-derive the width when `max-height` clamps the
* height, so the art was drawn as a letterbox band and `object-fit:
* cover` cropped the cover to it — 264x53 on the reference device. The
* leftover only exceeds the width above ~843px of viewport, so this
* was every height from ~500 to ~843 as well: most phones, and any
* short window. That is ordinary CSS rather than a Chrome 113 quirk,
* so this tier can see it, and the heights below are chosen to cover
* the range rather than the one device.
*
* The second is the reflow: below 500px the art and the names sit side
* by side, which is what takes the art from 53px to 143px. That is
* asserted as a *relation between boxes* the art beside the names,
* not above them because the pixel count is a consequence of the
* arrangement and would pin this file to one device's chrome.
*
* **What this tier cannot see** is the device's engine: CI's Chromium
* and WebKit are current, and #60's clipping showed what that costs.
* Nothing here depends on Chrome 113 behaviour the sizing rules were
* checked against the device itself, at column heights of 288, 300,
* 451, 600 and 800, and the numbers are on #51.
*/
type Page = import('@playwright/test').Page;
/** The reference device's real viewport. */
const DEVICE = { width: 424, height: 439 };
/**
* A tall phone, above the reflow's 500px. Roughly a Pixel 7, which is
* #51's other named device and was not attached so what is checked
* here is the layout it *should* get, not that device.
*/
const TALL_PHONE = { width: 412, height: 869 };
/** Inside the crop's old range and above the reflow: a short window. */
const SHORT_WINDOW = { width: 390, height: 700 };
/**
* The height the layout reflows at. Written down once here because the
* specs have to know which arrangement to *wait* for, not only which
* to assert.
*/
const REFLOW_AT = 500;
/** Put a track in the player, so the view has art and names to lay out. */
async function stageATrack(page: Page): Promise<void> {
await page.evaluate(async () => {
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string }[];
await window.__yjEvents.call(
'queue.Queue.SetQueue',
[tracks.slice(0, 4).map((t) => t.FilePath), 0, false, { type: '', id: 0, label: '' }],
10_000,
);
});
}
/** Open the full-screen view and wait for the shell to say so. */
async function openNowPlaying(page: Page): Promise<void> {
await page.evaluate(() => {
document.dispatchEvent(
new CustomEvent('navigate', {
detail: { view: 'now-playing' },
bubbles: true,
}),
);
});
await expect(page.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'now-playing',
);
// The attribute is the shell's bookkeeping and lands before the view
// has a track, so measuring on it alone races the first layout --
// which showed up as a 60x5 art on the first spec of a cold run.
//
// Waiting for a non-zero box is not enough on its own either: a
// previous test leaves the *other* arrangement on screen, and a
// stale column satisfies "has a size" perfectly. So the wait is for
// the arrangement this viewport should have, which is the thing
// every assertion below depends on. Found by this file passing one
// test at a time and failing in file order.
const wantRow = (page.viewportSize()?.height ?? 0) <= REFLOW_AT;
await page.waitForFunction(
(row: boolean) => {
const v = document.querySelector('now-playing-view');
const stack = v?.shadowRoot?.querySelector('.stack');
const el = v?.shadowRoot?.querySelector('.art img, .art .placeholder');
const t = v?.shadowRoot?.querySelector('.transport');
if (!el || !t) return false;
// A build with no `.stack` at all is the one before this change,
// and the squareness assertions are still meaningful against it
// -- so this waits for the arrangement only where there is one to
// wait for. Otherwise reverting the component to check that these
// tests bite produces eight timeouts instead of the measurements
// that make the case.
if (stack) {
const dir = getComputedStyle(stack).flexDirection;
if (dir !== (row ? 'row' : 'column')) return false;
}
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && t.getBoundingClientRect().height > 0;
},
wantRow,
);
}
/**
* The boxes this file reasons about, read in one evaluate.
*
* It reaches into the view's shadow root rather than using locators
* because the question is geometric where these boxes are *relative
* to each other* and a testid per edge would be four locators and
* four round trips to say one thing.
*/
async function boxes(page: Page) {
return page.evaluate(() => {
const v = document.querySelector('now-playing-view');
if (!v || !v.shadowRoot) return null;
const rect = (sel: string) => {
const el = v.shadowRoot!.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return {
left: r.left, right: r.right, top: r.top, bottom: r.bottom,
width: r.width, height: r.height,
};
};
return {
// Whichever of the two the track has; both carry the sizing.
art: rect('.art img') ?? rect('.art .placeholder'),
artBox: rect('.art'),
stack: rect('.stack'),
meta: rect('.meta'),
transport: rect('.transport'),
scrollHeight: v.scrollHeight,
clientHeight: v.clientHeight,
};
});
}
test.describe('Now Playing survives a short screen', () => {
test.beforeEach(async ({ app }) => {
await stageATrack(app);
});
/**
* The crop, at four heights spanning the range it covered. This is
* the assertion that fails on the build before this change: at
* 424x439 the art measured 264x53.
*/
for (const vp of [DEVICE, SHORT_WINDOW, TALL_PHONE, { width: 900, height: 500 }]) {
test(`draws the art square at ${vp.width}x${vp.height}`, async ({ app }) => {
await app.setViewportSize(vp);
await openNowPlaying(app);
const b = await boxes(app);
expect(b, 'now-playing-view did not mount').not.toBeNull();
expect(b!.art, 'neither art nor placeholder rendered').not.toBeNull();
const { width, height } = b!.art!;
expect(width, 'the art has no width').toBeGreaterThan(0);
// One pixel of slack for sub-pixel layout, and no more: the
// defect this guards was a 5:1 band.
expect(
Math.abs(width - height),
`art is ${Math.round(width)}x${Math.round(height)}, not square`,
).toBeLessThanOrEqual(1);
});
}
/**
* The promise #51 states and plan 018's matrix repeats. A floor on
* the art with the block scrolling was the other option on #172 and
* this is why it was not taken.
*/
test('never scrolls the transport off the bottom', async ({ app }) => {
await app.setViewportSize(DEVICE);
await openNowPlaying(app);
const b = await boxes(app);
expect(b!.transport!.bottom).toBeLessThanOrEqual(DEVICE.height);
expect(
b!.scrollHeight,
'the view scrolls, so the transport can be moved off screen',
).toBeLessThanOrEqual(b!.clientHeight + 1);
});
/**
* The reflow itself, as a relation rather than a measurement: below
* 500px the names are *beside* the art, above it they are below.
*/
test('puts the names beside the art below 500px', async ({ app }) => {
await app.setViewportSize(DEVICE);
await openNowPlaying(app);
const b = await boxes(app);
expect(
b!.meta!.left,
'the names are not to the right of the art',
).toBeGreaterThanOrEqual(b!.artBox!.right - 1);
});
test('keeps the names below the art on a tall phone', async ({ app }) => {
await app.setViewportSize(TALL_PHONE);
await openNowPlaying(app);
const b = await boxes(app);
expect(
b!.meta!.top,
'the names are not below the art',
).toBeGreaterThanOrEqual(b!.artBox!.bottom - 1);
});
/**
* What the reflow actually does, stated as a mechanism rather than
* as a number: in a row the art is bounded by the row's *height*,
* so it fills it where in a column it is the leftover after the
* names, which is what made it 53px.
*
* **The pixel count is deliberately not asserted here.** Two drafts
* tried. The first compared the art against the column's leftover
* computed from the boxes on screen and passed on the broken build,
* because the subtraction goes negative when the names are taller
* than the art precisely the defect. The second put a floor of
* 100px on it, passed locally at 114 and **failed in CI at 64**: this
* app is long-lived, so a job staged by an earlier spec is still on
* screen, and the volume control renders here where it does not on
* Android. Both are chrome above and below this view, and both move
* the leftover. A test that asserts how much room CI happened to
* have is a test about the runner.
*
* The device numbers 53px to 143px are on #51, measured there,
* which is the only tier that can honestly produce them.
*/
test('fills the row with the art rather than the leftover', async ({ app }) => {
await app.setViewportSize(DEVICE);
await openNowPlaying(app);
const b = await boxes(app);
expect(b!.stack, 'there is no row to fill').not.toBeNull();
expect(
Math.abs(b!.artBox!.height - b!.stack!.height),
'the art does not fill the row, so it is still a leftover',
).toBeLessThanOrEqual(1);
});
});
+136
View File
@@ -0,0 +1,136 @@
import {
test,
expect,
callBinding,
resetEvents,
waitForEvent,
LONG_TRACK,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The phone's progress line (#58).
*
* The component tier already pins what the line *says* that it
* renders the backend's reported position and never a count of its own.
* What only a real shell can answer is **where it is**: the issue asks
* for a line on the border between the mini player and the tab bar, and
* "on the border" is two adjacencies in a grid that no component-level
* render has around it.
*
* It also asserts the line is not there on a desktop, which is the
* other half of the same fact: above 600px there is no tab bar for it
* to sit on the border of, and the bar carries a real seek bar.
*/
type Rect = { x: number; y: number; width: number; height: number };
/** The reference device's real viewport. */
const DEVICE = { width: 424, height: 439 };
const DESKTOP = { width: 1280, height: 800 };
async function rectOf(app: Page, selector: string): Promise<Rect | null> {
return app.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}, selector);
}
/**
* Put the 90-second fixture on and wait for the first position report.
*
* The long track rather than any track: every other fixture is 2-6
* seconds, which is shorter than the time this spec takes to measure
* three rectangles.
*/
async function play(app: Page): Promise<void> {
const tracks = await callBinding<{ FilePath: string; TrackName: string }[]>(
app,
'library.Library.GetTracks',
[0],
);
// `TrackName`, not `Title`: that is what the library model calls it.
const long = tracks.find((t) => t.TrackName === LONG_TRACK);
expect(long, `no fixture track named ${LONG_TRACK}`).toBeTruthy();
await callBinding(app, 'queue.Queue.Clear');
await resetEvents(app);
await callBinding(app, 'queue.Queue.SetQueue', [
[long!.FilePath],
0,
false,
NO_QUEUE_SOURCE,
]);
await waitForEvent(app, 'QueueChanged');
await callBinding(app, 'queue.Queue.Play');
await waitForEvent(app, 'PlaybackPositionChanged', { timeoutMs: 15_000 });
}
test.describe('the progress line sits on the border between the bars', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await play(app);
});
/*
* Every test here starts a LONG_TRACK and the suite is workers: 1,
* fullyParallel: false against one long-lived app so without this
* the four phone-* specs that follow alphabetically inherit a playing
* queue. phone-transport.spec.ts records where that lesson came from:
* the fault first showed up as a flake in a spec about something else.
*/
test.afterEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* already empty */
});
await app.setViewportSize(DESKTOP);
});
test('spans the width, between the mini player and the tab bar', async ({
app,
}) => {
const line = await rectOf(app, 'player-progress-line');
const bar = await rectOf(app, '.bottom-bar');
const nav = await rectOf(app, 'bottom-nav');
expect(line, 'no progress line on the phone').not.toBeNull();
expect(bar).not.toBeNull();
expect(nav).not.toBeNull();
// A border, not a band: 2px, the full width, and touching both.
expect(line!.height).toBeCloseTo(2, 0);
expect(line!.width).toBeCloseTo(bar!.width, 0);
expect(line!.y).toBeCloseTo(bar!.y + bar!.height, 0);
expect(nav!.y).toBeCloseTo(line!.y + line!.height, 0);
});
/**
* It is 2px on the top edge of the tab bar, which is exactly where a
* thumb aiming at a tab lands. A line that sometimes seeks is worse
* than one that never does, so it must take no part in hit testing
* at all.
*/
test('takes no taps', async ({ app }) => {
const line = await rectOf(app, 'player-progress-line');
const hit = await app.evaluate(
({ x, y }) => document.elementFromPoint(x, y)?.tagName ?? '',
{ x: line!.x + line!.width / 2, y: line!.y + 1 },
);
expect(hit).not.toBe('PLAYER-PROGRESS-LINE');
});
test('is not there on a desktop', async ({ app }) => {
await app.setViewportSize(DESKTOP);
await expect(app.locator('player-progress-line')).toBeHidden();
});
});
+19
View File
@@ -130,6 +130,25 @@ test.describe('the shell on a phone', () => {
// are here, and they are the *same* components -- this view
// composes the transport rather than reimplementing it.
await expect(app.locator('now-playing-view seek-bar')).toBeVisible();
// Volume is here **because the player says there is one** (#64),
// not because this is a phone. This tier is the platform that owns
// its own volume, so what it can assert is that the control's
// presence follows that answer -- an inverted polarity in
// `volume-style-store` fails here and in `bottom-bar.spec.ts`, and
// the *absent* branch is checked in the component tier, where the
// binding can be stubbed. Nothing here can reach the Android side.
const systemOwns = await app.evaluate(
async () =>
(await window.__yjEvents.call(
'player.Player.SystemOwnsVolume',
[],
5_000,
)) as boolean,
);
expect(systemOwns, 'this platform should own its own volume').toBe(false);
await expect(app.locator('now-playing-view volume-control')).toBeVisible();
// Back goes where the user came from, through the nav stack.
+315
View File
@@ -0,0 +1,315 @@
import { test, expect } from '../support/fixtures.js';
/**
* The phone's transport (#59, #56).
*
* #56 reports that "the playback controls are the most important thing
* in the mobile app and they are tiny". Measured at the reference
* device's 424x439 before this, every one of them was **33x21px**, and
* the favourite beside them which #59 keeps on the bar was
* **18x14px**, the smallest control in the app.
*
* #59 is what makes the sizes affordable: five controls plus a queue
* button at 44px does not fit 424 CSS px, so the bar carries three and
* the rest are on the full-screen view.
*
* **The assertion that matters is not the pixel count.** Plan 018's
* matrix promises that *no action is ever unreachable at any supported
* size*, and #59 removes three controls from the phone's bar so the
* first thing this file checks is that all three are still reachable,
* by walking the route a user would. A spec that only measured the
* survivors would be green on a build that had made shuffle
* unreachable, which is the failure mode this pair of issues is one
* mistake away from.
*/
type Page = import('@playwright/test').Page;
/** The reference device's real viewport. */
const DEVICE = { width: 424, height: 439 };
const PHONE = { width: 390, height: 780 };
const DESKTOP = { width: 1280, height: 800 };
/**
* The touch-target floor. 44px is what #56's Findings name and what
* #55's queue header was sized to, so the app has one number.
*/
const TARGET = 44;
/** The play button is named for its action, not its identity. */
const PLAY_PAUSE = /^(Play|Pause)$/;
const barControls = (page: Page) =>
page.locator('audio-player player-controls');
/**
* `name` may be a regex, and for play/pause it must be: that button is
* named for the *action*, so it is "Pause" while a track runs and
* "Play" when it stops. An exact 'Play' made these tests wait out a
* fixture track (11.1s each, passing by luck) and would have failed
* outright against `LONG_TRACK`. A test about a control's size does not
* care what the transport is doing.
*/
async function sizeOf(
page: Page,
name: string | RegExp,
): Promise<[number, number]> {
const box = await page
.getByRole('button', { name, exact: typeof name === 'string' })
.boundingBox();
expect(box, `no button named ${name}`).not.toBeNull();
return [box!.width, box!.height];
}
/** Put something in the queue, so the transport has a track to act on. */
async function stageATrack(page: Page): Promise<void> {
await page.evaluate(async () => {
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string }[];
await window.__yjEvents.call(
'queue.Queue.SetQueue',
[tracks.slice(0, 4).map((t) => t.FilePath), 0, false, { type: '', id: 0, label: '' }],
10_000,
);
});
}
test.describe('the phone bar carries three controls', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await stageATrack(app);
});
test('drops shuffle, repeat and the queue from the bar', async ({ app }) => {
const bar = barControls(app);
await expect(bar.getByRole('button', { name: 'Previous track' })).toBeVisible();
await expect(bar.getByRole('button', { name: 'Next track' })).toBeVisible();
// Not in the bar's own subtree. Asserted against the bar rather
// than the page, because the whole point is that they moved rather
// than went away -- a page-wide `not.toBeVisible()` would fail the
// moment Now Playing is open and would be asserting the wrong
// thing besides.
await expect(bar.getByRole('button', { name: 'Shuffle' })).toHaveCount(0);
await expect(bar.getByRole('button', { name: /^Repeat/ })).toHaveCount(0);
await expect(app.locator('#queue-button')).toBeHidden();
});
/**
* The promise, walked. Every control #59 takes off the bar is
* reachable from the mini player's art in one tap.
*/
test('leaves every removed control reachable from Now Playing', async ({
app,
}) => {
await app.getByTestId('open-now-playing').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'now-playing',
);
await expect(app.getByRole('button', { name: 'Shuffle' })).toBeVisible();
await expect(app.getByRole('button', { name: /^Repeat/ })).toBeVisible();
await expect(app.getByRole('button', { name: 'Show the queue' })).toBeVisible();
});
test('sizes what is left for a thumb', async ({ app }) => {
for (const name of ['Previous track', 'Next track']) {
const [w, h] = await sizeOf(app, name);
expect(w, `${name} width`).toBeGreaterThanOrEqual(TARGET);
expect(h, `${name} height`).toBeGreaterThanOrEqual(TARGET);
}
// Play is deliberately bigger than its neighbours: a row of
// identical squares says every action is equally likely, which is
// not true of play.
const [pw, ph] = await sizeOf(app, PLAY_PAUSE);
const [nw] = await sizeOf(app, 'Next track');
expect(ph).toBeGreaterThanOrEqual(TARGET);
expect(pw).toBeGreaterThan(nw);
});
/**
* The favourite was 18x14 and is one of the three controls #59
* keeps, so it is part of this issue rather than a nicety.
*/
test('sizes the favourite, which was the smallest control in the app', async ({
app,
}) => {
const fav = app
.locator('now-playing')
.getByRole('button', { name: /Favorites$/ });
const box = await fav.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(TARGET);
expect(box!.height).toBeGreaterThanOrEqual(TARGET);
});
/**
* **The route to the queue must not depend on what is playing.**
*
* `now-playing` renders two branches, and the no-track one had no
* `.expand` button on its placeholder so with nothing loaded there
* was no way to Now Playing, and once #59 takes the queue button off
* the bar that makes the *queue* unreachable. The queue is persisted
* across restarts, so "tracks queued, nothing playing" is a state the
* app launches into.
*
* This is asserted with the queue explicitly emptied rather than by
* relying on the app not having played anything: `make e2e` runs one
* long-lived app across every spec file (#168), so "no track loaded"
* is otherwise whatever the file before this one left behind which
* is how the underlying fault first showed up as a flake in a spec
* about something else.
*/
test('reaches the queue with nothing playing', async ({ app }) => {
await app.evaluate(async () => {
await window.__yjEvents.call('queue.Queue.Clear', [], 10_000);
});
await expect(app.getByTestId('open-now-playing')).toBeVisible();
await app.getByTestId('open-now-playing').click();
await app.getByTestId('npv-queue').click();
await expect(app.locator('#queue-panel')).toHaveAttribute('open', '');
});
test('still fits, with nothing to scroll sideways to', async ({ app }) => {
const fit = await app.evaluate(() => ({
scroll: document.body.scrollWidth,
client: document.body.clientWidth,
}));
expect(fit.scroll).toBe(fit.client);
});
});
test.describe('the full-screen transport is the page', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await stageATrack(app);
await app.getByTestId('open-now-playing').click();
});
test('draws all five, larger than the bar draws any', async ({ app }) => {
const [pw, ph] = await sizeOf(app, PLAY_PAUSE);
expect(pw).toBeGreaterThanOrEqual(56);
expect(ph).toBeGreaterThanOrEqual(56);
for (const name of ['Shuffle', 'Previous track', 'Next track']) {
const [w, h] = await sizeOf(app, name);
expect(w, `${name} width`).toBeGreaterThanOrEqual(TARGET);
expect(h, `${name} height`).toBeGreaterThanOrEqual(TARGET);
}
});
test('fits at both phone widths', async ({ app }) => {
for (const size of [DEVICE, PHONE]) {
await app.setViewportSize(size);
const fit = await app.evaluate(() => ({
scroll: document.body.scrollWidth,
client: document.body.clientWidth,
}));
expect(fit.scroll, `${size.width}px`).toBe(fit.client);
}
});
});
/**
* **The desktop bar is not what either issue is about, and must not
* move.** Both are `Platform/Android`; this is the guard that says so
* in a way a build can check.
*
* It caught a real regression while it was being written: a generic
* `font-size` on the buttons took them from the UA stylesheet's 13.3px
* to the shell's 16px and grew every one from 33x21 to 36x24 a
* change nobody asked for, invisible to every other assertion here.
*/
test.describe('the desktop bar is untouched', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DESKTOP);
await stageATrack(app);
});
test('keeps all five controls and the queue button', async ({ app }) => {
const bar = barControls(app);
for (const name of ['Shuffle', 'Previous track', 'Next track']) {
await expect(bar.getByRole('button', { name })).toBeVisible();
}
await expect(bar.getByRole('button', { name: /^Repeat/ })).toBeVisible();
await expect(app.locator('#queue-button')).toBeVisible();
});
/**
* **The mechanism, because the pixels are the engine's.**
*
* The first version of this asserted the literal `'33x21'`, measured
* on `main` in Chromium and WebKit draws the same button **36x24**,
* so it failed in CI on a build where nothing was wrong. A button's
* box comes from the UA stylesheet when the author sets nothing, and
* what each UA sets is its own business.
*
* What this PR must not do is *set* anything here, so that is what is
* asserted: our two box properties are unset, and the font is still
* the UA's rather than the shell's. That is precisely the regression
* this caught the first time a generic `font-size: inherit` took
* these from the UA's default to 16px and it catches it in either
* engine.
*/
test('sets no size of its own on the desktop bar', async ({ app }) => {
const measured = await barControls(app).evaluate((el) => {
// A bare button with no author styles: whatever this engine
// gives one is what the bar's buttons must still be.
const probe = document.createElement('button');
document.body.appendChild(probe);
const uaFontSize = getComputedStyle(probe).fontSize;
probe.remove();
return [...el.shadowRoot!.querySelectorAll('button')].map((b) => {
const cs = getComputedStyle(b);
const r = b.getBoundingClientRect();
return {
minWidth: cs.minWidth,
minHeight: cs.minHeight,
usesUaFont: cs.fontSize === uaFontSize,
size: `${Math.round(r.width)}x${Math.round(r.height)}`,
};
});
});
expect(measured).toHaveLength(5);
for (const m of measured) {
expect(m.minWidth, 'min-width').toBe('0px');
expect(m.minHeight, 'min-height').toBe('0px');
expect(m.usesUaFont, 'font-size is still the UA default').toBe(true);
}
// And all five are the same box: `.play` takes a larger size in
// both sized contexts, so this is what says the desktop is neither
// of them.
expect(new Set(measured.map((m) => m.size)).size).toBe(1);
});
});
+142 -39
View File
@@ -1,4 +1,4 @@
import { test, expect } from '../support/fixtures.js';
import { test, expect, openTheQueue } from '../support/fixtures.js';
/**
* #55 the queue is a *place* while it covers the content, and a
@@ -37,15 +37,37 @@ const DEVICE = { width: 424, height: 439 };
/** Wide enough that the queue is a column: 1280 200 320 ≥ 480. */
const DESKTOP = { width: 1280, height: 800 };
/**
* The Compact band, where the queue is a *screen* (644 320 < 480) and
* the bottom bar still carries its button.
*
* Two of these tests need both facts at once and only this band has
* them: below 600px #59 takes the button off the bar, so there is no
* toggle to re-press and the queue is opened from Now Playing which
* is itself a detail view, so "the destination stays lit" is vacuously
* true there rather than tested.
*/
const COMPACT = { width: 700, height: 600 };
const activeView = (page: Page) => page.getByTestId('main-content');
const queue = (page: Page) => page.locator('#queue-panel');
const toggle = (page: Page) => page.locator('#queue-button');
/**
* Whether the queue is up.
*
* The panel's own attribute rather than the toggle's `aria-expanded`,
* because below 600px there is no toggle to ask (#59) and the panel
* is the one fact both of them reflect anyway.
*/
async function expectQueue(page: Page, open: boolean): Promise<void> {
await expect(toggle(page)).toHaveAttribute(
'aria-expanded',
String(open),
);
const panel = queue(page);
if (open) {
await expect(panel).toHaveAttribute('open', '');
} else {
await expect(panel).not.toHaveAttribute('open', '');
}
}
test.describe('the queue is a screen where it covers the content', () => {
@@ -55,12 +77,17 @@ test.describe('the queue is a screen where it covers the content', () => {
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
});
// On a phone the queue is opened from Now Playing (#59), so the page
// *underneath* it is `now-playing` and the journey is two entries
// deep: albums -> now-playing -> queue. That is the real route a user
// takes, which is why these do not reach for the shortcut.
test('back closes the queue and leaves the page where it was', async ({
app,
}) => {
await expect(queue(app)).toHaveAttribute('overlay', '');
await toggle(app).click();
await openTheQueue(app);
await expectQueue(app, true);
await app.goBack();
@@ -69,27 +96,31 @@ test.describe('the queue is a screen where it covers the content', () => {
// The page underneath is untouched. Before #55 this was the
// *previous* view, because the queue was not in the stack at all
// and back spent an entry navigating something nobody could see.
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'now-playing',
);
});
test('costs exactly one entry, so the next press navigates', async ({
app,
}) => {
await toggle(app).click();
await openTheQueue(app);
await expectQueue(app, true);
await app.goBack();
await expectQueue(app, false);
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'now-playing',
);
await app.goBack();
// Whatever the launch page is, it is not Albums — the point is that
// this press moved the app rather than being swallowed by a queue
// that had already closed.
await expect(activeView(app)).not.toHaveAttribute(
'data-active-view',
'albums',
);
// Exactly one entry each: the second press leaves Now Playing for
// the page it was opened from, rather than being swallowed by a
// queue that had already closed.
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
});
/**
@@ -116,15 +147,9 @@ test.describe('the queue is a screen where it covers the content', () => {
await app.keyboard.press('Escape');
},
],
[
'the toggle it was opened from',
async (app: Page) => {
await toggle(app).click();
},
],
] as Array<[string, (app: Page) => Promise<void>]>) {
test(`${name} leaves no entry behind`, async ({ app }) => {
await toggle(app).click();
await openTheQueue(app);
await expectQueue(app, true);
await dismiss(app);
@@ -132,7 +157,10 @@ test.describe('the queue is a screen where it covers the content', () => {
await app.goBack();
await expect(activeView(app)).not.toHaveAttribute(
// One press, one screen: Now Playing is what the queue was opened
// from, so leaving it lands on Albums. An orphaned entry would
// have spent this press on nothing and left it here.
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'albums',
);
@@ -149,26 +177,14 @@ test.describe('the queue is a screen where it covers the content', () => {
* `back-navigation.spec.ts` gives: the class was right throughout the
* bug that rule exists for.
*/
test('leaves the tab it was opened from highlighted', async ({ app }) => {
await expect(
app.getByRole('button', { name: 'Albums', exact: true }),
).toHaveAttribute('aria-current', 'page');
await toggle(app).click();
await expectQueue(app, true);
await expect(
app.getByRole('button', { name: 'Albums', exact: true }),
).toHaveAttribute('aria-current', 'page');
});
/**
* With the panel spanning the whole width the scrim has no uncovered
* pixels, so the close button is the only pointer route out of a
* With the panel spanning the whole width there is no scrim here at
* all (#171), so the close button is the only pointer route out of a
* full-screen surface. Measured at 424×439 before #55: **25×21px**.
*/
test('offers a way out a thumb can hit', async ({ app }) => {
await toggle(app).click();
await openTheQueue(app);
const box = await app
.getByRole('button', { name: 'Close queue' })
@@ -178,6 +194,33 @@ test.describe('the queue is a screen where it covers the content', () => {
expect(box!.width).toBeGreaterThanOrEqual(44);
expect(box!.height).toBeGreaterThanOrEqual(44);
});
/**
* #171 and it draws no scrim, because there is nowhere to tap.
*
* `.panel-content` is `width: 100%` here, so the scrim sat entirely
* underneath it: measured at 424×439, host, panel and scrim all
* 424×318. #24's tap-outside-to-close cannot exist on a surface with
* no outside, and a `cursor: pointer` layer nobody can reach is a
* claim the component cannot keep.
*
* Asserted as absence rather than by clicking, for the reason the
* issue gives: a naive phone case clicks the scrim's centre and hits
* the panel, so it passes on the build this exists to fail. The scrim
* is still real between 600 and 899px, which `queue-overlay.spec.ts`
* asserts at 900×600 by clicking it.
*/
test('draws no scrim, because a screen has no outside to tap', async ({
app,
}) => {
await openTheQueue(app);
const scrim = await queue(app).evaluate(
(el) => el.shadowRoot!.querySelector('.scrim') !== null,
);
expect(scrim).toBe(false);
});
});
/**
@@ -204,7 +247,7 @@ test('the panel stays out of the paint-contained region', async ({ app }) => {
// from it — and because the host drops `paint` from its own
// containment deliberately in overlay mode, so a closed panel answers
// a different question.
await toggle(app).click();
await openTheQueue(app);
await expectQueue(app, true);
const ancestry = await app.evaluate(() => {
@@ -235,6 +278,66 @@ test('the panel stays out of the paint-contained region', async ({ app }) => {
}
});
/**
* Two properties need the queue to be a *screen* and the bar to still
* have its button, and only the Compact band has both below 600px #59
* takes the button off the bar.
*/
test.describe('a screen opened from the bar', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(COMPACT);
await app.getByTestId('nav-albums').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expect(queue(app)).toHaveAttribute('overlay', '');
});
/**
* A detail view leaves the destination it was opened from lit
* (`active-view-store`, #72), and the queue inherits that it is
* published with `isPrimary: false`, so `isActive('albums')` is still
* true underneath it.
*
* `aria-current` rather than a class, for the reason
* `back-navigation.spec.ts` gives: the class was right throughout the
* bug that rule exists for.
*/
test('leaves the destination it was opened from highlighted', async ({
app,
}) => {
// By testid, not by role: at 700px the sidebar is in icon mode, so
// what the item is *named* is a different question from which item
// it is. The assertion is still `aria-current`, which is the
// accessible fact.
const albums = app.getByTestId('nav-albums');
await expect(albums).toHaveAttribute('aria-current', 'page');
await toggle(app).click();
await expectQueue(app, true);
await expect(albums).toHaveAttribute('aria-current', 'page');
});
/** The toggle is a fourth way out, and it unwinds the entry like the
* other three through the panel's attribute, not its own handler. */
test('closes from the same toggle, leaving no entry behind', async ({
app,
}) => {
await toggle(app).click();
await expectQueue(app, true);
await toggle(app).click();
await expectQueue(app, false);
await app.goBack();
await expect(activeView(app)).not.toHaveAttribute(
'data-active-view',
'albums',
);
});
});
/**
* The column is not a place. Somebody docked it; back must not undock
* it, and navigating to another view must not take it away.
+10 -10
View File
@@ -1,4 +1,4 @@
import { test, expect } from '../support/fixtures.js';
import { test, expect, openTheQueue } from '../support/fixtures.js';
/**
* #24 the queue panel does not take the page's width away from it.
@@ -54,15 +54,15 @@ const shellGeometry = (page: import('@playwright/test').Page) =>
};
});
async function openQueue(page: import('@playwright/test').Page) {
const toggle = page.locator('#queue-button');
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
await toggle.click();
}
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
}
/**
* Opening the queue is `openTheQueue`, which takes the route this
* viewport offers. It used to be a local helper that clicked
* `#queue-button` unconditionally, and #59 hid that button below
* 600px -- so the two phone bands here failed on a build where the
* queue was working perfectly, having been asserting *how* it opens as
* much as what it does.
*/
const openQueue = openTheQueue;
test.describe('an open queue leaves the content its width', () => {
for (const band of BANDS) {
+44
View File
@@ -140,6 +140,50 @@ export async function navigateTo(page: Page, view: string): Promise<void> {
.waitFor({ state: 'attached' });
}
/**
* Open the queue the way a user at this viewport would.
*
* **The route differs by width and that is the feature, not an
* inconvenience.** Above 600px the bottom bar carries a queue button.
* Below it that button is gone (#59) and the queue is reached from the
* full-screen Now Playing view, which the mini player's art opens
* "reachable only from Now Playing", which is what the issue asks for.
*
* It is here rather than in one spec because four files need it, and
* because a spec that hard-codes `#queue-button` is quietly asserting
* *which* route exists as well as what the queue does. Four of them
* were, which is how hiding one button failed ten tests about
* something else.
*
* The width is read from the page rather than passed, so a caller that
* resizes and then opens does not have to say so twice.
*/
export async function openTheQueue(page: Page): Promise<void> {
const toggle = page.locator('#queue-button');
if (await toggle.isVisible()) {
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
await toggle.click();
}
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
return;
}
// The phone: through Now Playing. `open-now-playing` is the mini
// player's art, which is a button only below 600px.
if (
(await page.getByTestId('main-content').getAttribute('data-active-view')) !==
'now-playing'
) {
await page.getByTestId('open-now-playing').click();
}
await page.getByTestId('npv-queue').click();
await expect(page.locator('#queue-panel')).toHaveAttribute('open', '');
}
/** Thin client for the dev-only /__test/ surface (backend/testctl). */
export class TestCtl {
constructor(private readonly baseURL: string) {}
@@ -142,6 +142,18 @@ export function SetVolume(desiredVolume: $models.UserVolume): $CancellablePromis
return $Call.ByID(1375836663, desiredVolume);
}
/**
* SystemOwnsVolume reports whether the platform's own control is the
* only volume control there is, so this app neither offers one nor
* remembers a level.
*
* It is bound: the frontend renders no `<volume-control>` when it is
* true, at any width.
*/
export function SystemOwnsVolume(): $CancellablePromise<boolean> {
return $Call.ByID(1027623185);
}
/**
* TrackLengthInSeconds returns the duration of the current track.
*/
+64 -12
View File
@@ -426,6 +426,7 @@ body div.sidebar {
"jobs-band" auto
"main-panel" 1fr
"bottom-bar" auto
"progress-line" auto
"bottom-nav" auto
/ 1fr;
/* Nothing may scroll sideways here. On a desktop the shell is
@@ -501,7 +502,8 @@ body div.sidebar {
expression of the same fact is a second thing to keep in step.
The view carries its own queue button, because this is where
that one lived. */
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar,
body:has(#main-content[data-active-view="now-playing"]) player-progress-line {
display: none;
}
}
@@ -522,26 +524,58 @@ body div.sidebar {
}
/* Volume stands down here whatever the setting says, because this
is about room and about the platform rather than about
preference: the hardware keys own volume on a phone, which is
also why mediacontrols' Android handler implements no volume
callback. It moved from `audio-player`'s own media query when
#42 moved the control into the bar same rule, and now stated
where the element actually is.
is about room: five controls and a slider do not fit a 360px
bar, and the full-screen now-playing view is where seeking and
volume go on a phone. It moved from `audio-player`'s own media
query when #42 moved the control into the bar same rule, and
now stated where the element actually is.
`.bottom-bar volume-control`, not the one in
`now-playing-view`: that view is the phone's transport and is
where a slider does belong. */
**This rule used to carry the platform argument too, and no
longer does** (#64). "The hardware keys own the volume" is not a
width: it is false of a narrow desktop window and true of an
Android tablet, which this selector gets backwards both ways.
The player answers it now `SystemOwnsVolume` and
`volume-control` renders nothing when it is true, at every
width and in both of its mount points. What is left here is the
question a stylesheet can actually answer. */
.bottom-bar volume-control {
display: none;
}
/* The queue leaves the phone's bar (#59), because #55 made it a
screen with an entry in the back stack and Now Playing already
carries its own button for it. The route is the mini player's
art -> Now Playing -> the queue, which is the "reachable only
from Now Playing" this issue asks for.
This is allowed to remove a control only because the control is
still reachable: plan 018's matrix promises that no action is
ever unreachable at any supported size, and that promise is what
`phone-transport.spec.ts` asserts rather than the button count.
**`.bottom-bar #queue-button`, not `#queue-button`**, and that is
not decoration. The rule this overrides is written *nested*
inside `.bottom-bar`, so it builds to a descendant selector one
class more specific than it looks in the source -- and a bare
`#queue-button` here loses to it, media query or not. Being last
in the file is not enough when the thing above is more specific,
which is the same lesson as this section's own header one level
down: nesting adds specificity the source does not show, and the
failure is silent (the button simply stayed). */
.bottom-bar #queue-button {
display: none;
}
}
/* Out of the desktop grid entirely. `job-band` renders nothing above
600px anyway, but an in-flow grid child with no named area is
auto-placed into a row of the shell -- the same trap the skip link is
absolutely positioned to avoid. */
body job-band {
absolutely positioned to avoid. `player-progress-line` (#58) is the
same element in the same position for the same reason: below 600px it
has a named row, and above it there is no border for it to sit on --
the desktop bar carries a real, interactive seek bar. */
body job-band,
body player-progress-line {
display: none;
}
@@ -574,3 +608,21 @@ body job-band {
background-color: var(--yj-bg-elevated, #343a40);
}
}
/* #58. How far through the song we are, in its own grid row between
the two bars -- so the line is *on* the border rather than inside
either of them, and in flow rather than over it. The row is `auto`
and the element renders nothing while no track is loaded, so it costs
no height at all until there is something to say.
**This block is below the `display: none` above and has to be**, for
the reason the band's rule is: a media query adds no specificity, so
`body player-progress-line { display: block }` written before that
rule loses to it at equal specificity and the line never appears at
any width. Nothing fails; it is simply not there. */
@media (max-width: 599px) {
body player-progress-line {
display: block;
grid-area: progress-line;
}
}
+10
View File
@@ -81,6 +81,16 @@
</button>
</div>
</footer>
<!-- How far through the song we are, on the border between the two
bars (#58). The shell's element rather than either bar's:
they are separate components stacked in this grid, so a line
on the border between them is a row of it, and neither one has
to reach into the other's box for two pixels. It renders
nothing above 600px and nothing with no track, is `aria-hidden`
(Now Playing's seek bar is what announces the position) and
takes no pointer events at all -- a thin line that sometimes
seeks is worse than one that never does. -->
<player-progress-line></player-progress-line>
<!-- The phone's primary navigation, hidden above 600px by
index.css. Eager rather than a chunk, for the reason
notification-host is: it is the only way to move around the
+4
View File
@@ -21,6 +21,10 @@ import '@components/audio-player/audio-player.ts';
// In the bar rather than inside `audio-player` since #42, so the shell
// is what has to register it.
import '@components/audio-player/volume-control/volume-control.ts';
// The phone's progress line (#58), on the border between the mini
// player and the tab bar. In the shell for the same reason the volume
// is, and eager because it is part of the bottom bar's first paint.
import '@components/audio-player/progress-line/progress-line.ts';
import '@components/track-list/track-list.ts';
import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts';
+5 -79
View File
@@ -23,97 +23,23 @@
* as the literal contains an unterminated `/*`. Nothing else produces
* that, and a legitimate literal cannot contain one.
*/
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';
import { globSync, readFileSync } from 'node:fs';
import { taggedLiterals } from './css-literals.mjs';
const TAGS = ['css', 'html', 'svg'];
/**
* Find the end of a template literal that starts at `start` (the index
* of its opening backtick), respecting escapes and `${}` substitutions.
* Returns the index of the closing backtick, or -1.
*/
function endOfTemplate(src, start) {
let depth = 0;
for (let i = start + 1; i < src.length; i++) {
const c = src[i];
if (c === '\\') {
i++;
continue;
}
if (c === '$' && src[i + 1] === '{') {
depth++;
i++;
continue;
}
if (c === '}' && depth > 0) {
depth--;
continue;
}
if (c === '`' && depth === 0) return i;
}
return -1;
}
/** Strip `${...}` substitutions, which may legitimately contain anything. */
function stripSubstitutions(text) {
let out = '';
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '$' && text[i + 1] === '{') {
depth++;
i++;
continue;
}
if (text[i] === '}' && depth > 0) {
depth--;
continue;
}
if (depth === 0) out += text[i];
}
return out;
}
function lineOf(src, index) {
return src.slice(0, index).split('\n').length;
}
const files = globSync('src/**/*.ts', { cwd: process.cwd() });
const problems = [];
for (const file of files) {
const src = readFileSync(file, 'utf8');
const tagPattern = new RegExp(`(^|[^\\w$.])(${TAGS.join('|')})\``, 'g');
let match;
while ((match = tagPattern.exec(src)) !== null) {
const open = match.index + match[0].length - 1;
const close = endOfTemplate(src, open);
if (close === -1) continue;
const body = stripSubstitutions(src.slice(open + 1, close));
for (const { tag, body, line } of taggedLiterals(src, TAGS)) {
const opens = (body.match(/\/\*/g) ?? []).length;
const closes = (body.match(/\*\//g) ?? []).length;
if (opens > closes) {
problems.push({
file,
line: lineOf(src, open),
tag: match[2],
});
}
if (opens > closes) problems.push({ file, line, tag });
}
}
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env node
/**
* Fail on a nested rule whose selector starts with an element name.
*
* See `css-nesting.mjs` for what the phone does with one. No tier here
* can see it: the component tier, the e2e tier and `make ui-visual` all
* run a current Chromium, where the rule applies normally, so the only
* report is a screenshot of the device which is how the bottom bar's
* title came to have never truncated there.
*
* It covers `index.css` and the `css` literals in the components alike,
* because a shadow-root stylesheet is parsed by the same engine.
*/
import { globSync, readFileSync } from 'node:fs';
import { taggedLiterals } from './css-literals.mjs';
import { findBareNestedRules } from './css-nesting.mjs';
const problems = [];
// Every stylesheet, not `index.css` by name: the hook that runs this
// fires on `frontend/**/*.{ts,css}`, so naming one file promises a
// coverage the sweep does not deliver -- a second stylesheet would be
// silently unswept while the hook still went green over it. There is
// only `index.css` today, which is exactly when this is free to fix.
const stylesheets = globSync('*.css', { cwd: process.cwd() });
if (stylesheets.length === 0) {
console.error('css-nesting-check: no stylesheet matched *.css');
process.exit(1);
}
for (const file of stylesheets) {
for (const { line, selector } of findBareNestedRules(
readFileSync(file, 'utf8'),
)) {
problems.push({ file, line, selector });
}
}
const sources = globSync('src/**/*.ts', { cwd: process.cwd() });
// A sweep over an empty glob passes, and this one is expected to find
// nothing, so "it found nothing" has to mean it looked.
if (sources.length === 0) {
console.error('css-nesting-check: no sources matched src/**/*.ts');
process.exit(1);
}
for (const file of sources) {
const src = readFileSync(file, 'utf8');
for (const literal of taggedLiterals(src, ['css'])) {
for (const { line, selector } of findBareNestedRules(literal.body)) {
problems.push({ file, line: literal.line + line - 1, selector });
}
}
}
if (problems.length > 0) {
for (const p of problems) {
console.error(
`${p.file}:${p.line}: nested rule "${p.selector.split('\n')[0]}" starts ` +
'with an element name — write it as "& ' +
`${p.selector.split('\n')[0]}"`,
);
}
console.error(
`\ncss-nesting-check: ${problems.length} problem(s). ` +
'Chrome 113 (the device) drops a nested rule that does not start ' +
'with a symbol; the leading & is valid in both syntaxes.',
);
process.exit(1);
}
console.log(
`css-nesting-check: ${stylesheets.length} stylesheet(s) + ${sources.length} files, no bare nested rules`,
);
+103
View File
@@ -0,0 +1,103 @@
/**
* Finding the `css` tagged templates in a TypeScript source.
*
* Two checks read them the unterminated-comment one and the nesting
* one and a second scanner would be a second thing to keep in step
* with how a template literal actually ends.
*/
/**
* Find the end of a template literal that starts at `start` (the index
* of its opening backtick), respecting escapes and `${}` substitutions.
* Returns the index of the closing backtick, or -1.
*/
export function endOfTemplate(src, start) {
let depth = 0;
for (let i = start + 1; i < src.length; i++) {
const c = src[i];
if (c === '\\') {
i++;
continue;
}
if (c === '$' && src[i + 1] === '{') {
depth++;
i++;
continue;
}
if (c === '}' && depth > 0) {
depth--;
continue;
}
if (c === '`' && depth === 0) return i;
}
return -1;
}
/**
* Strip `${...}` substitutions, which may legitimately contain anything.
*
* Newlines inside them are kept, so a line number taken from the
* stripped text still names the right line of the file it came from.
*/
export function stripSubstitutions(text) {
let out = '';
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '$' && text[i + 1] === '{') {
depth++;
i++;
continue;
}
if (text[i] === '}' && depth > 0) {
depth--;
continue;
}
if (depth === 0) out += text[i];
else if (text[i] === '\n') out += '\n';
}
return out;
}
/** The 1-based line number of `index` in `src`. */
export function lineOf(src, index) {
return src.slice(0, index).split('\n').length;
}
/**
* Every tagged template literal in `src` whose tag is in `tags`.
*
* `body` has its substitutions stripped and `line` is the line its
* opening backtick sits on, so `line + (n - 1)` is the file line of the
* body's own line `n`.
*/
export function taggedLiterals(src, tags) {
const pattern = new RegExp(`(^|[^\\w$.])(${tags.join('|')})\``, 'g');
const found = [];
let match;
while ((match = pattern.exec(src)) !== null) {
const open = match.index + match[0].length - 1;
const close = endOfTemplate(src, open);
if (close === -1) continue;
found.push({
tag: match[2],
body: stripSubstitutions(src.slice(open + 1, close)),
line: lineOf(src, open),
});
}
return found;
}
+119
View File
@@ -0,0 +1,119 @@
/**
* A nested rule whose selector starts with an element name is silently
* dropped on the phone.
*
* The device renders in Chrome 113, which predates relaxed CSS nesting
* (Chrome 120): before that a nested selector had to start with
* something that could not be read as the beginning of a declaration,
* so `.bottom-bar { audio-player { … } }` is not a parse error anyone
* would notice the inner rule simply does not exist, on the phone and
* only on the phone. Three were live in `index.css`, one of them the
* `text-overflow: ellipsis` on the bottom bar's title, which had
* therefore never truncated on the device.
*
* `& audio-player` is valid in both syntaxes, so no nested rule here
* has any reason to omit it.
*
* Two things the detection has to get right:
*
* - **A rule directly inside an at-rule is not nested.**
* `@media (…) { bottom-nav { … } }` at the top level is an ordinary
* rule and is fine and it is the majority of the matches a regex
* over the file would produce. What decides it is whether a *style*
* rule is somewhere above, not what the immediate parent is: inside
* `.bar { @media (…) { audio-player { … } } }` the inner rule is
* nested, at-rule in between or not.
* - **A declaration is not a rule.** `background: url(…)` and any
* string or comment can hold a brace, so this tracks them rather than
* matching lines.
*/
/** Does this selector start with an identifier, rather than a symbol? */
function startsWithIdent(selector) {
return /^[A-Za-z_\u00A0-\uFFFF]/.test(selector);
}
/**
* Every nested style rule in `css` whose selector starts with an
* element name, as `{ line, selector }` with a 1-based line.
*/
export function findBareNestedRules(css) {
const found = [];
/** The blocks we are inside, innermost last: 'style' or 'at'. */
const stack = [];
/** The text since the last `{`, `}` or `;` — a prelude, if a `{` follows. */
let prelude = '';
let preludeLine = 1;
let line = 1;
const startPrelude = () => {
prelude = '';
preludeLine = line;
};
for (let i = 0; i < css.length; i++) {
const c = css[i];
if (c === '\n') {
line++;
if (prelude.trim() === '') preludeLine = line;
prelude += c;
continue;
}
if (c === '/' && css[i + 1] === '*') {
const end = css.indexOf('*/', i + 2);
const comment = css.slice(i, end === -1 ? css.length : end + 2);
line += (comment.match(/\n/g) ?? []).length;
i += comment.length - 1;
if (prelude.trim() === '') preludeLine = line;
continue;
}
if (c === '"' || c === "'") {
let j = i + 1;
while (j < css.length && css[j] !== c) {
if (css[j] === '\\') j++;
j++;
}
prelude += css.slice(i, j + 1);
i = j;
continue;
}
if (c === '{') {
const selector = prelude.trim();
const kind = selector.startsWith('@') ? 'at' : 'style';
if (
kind === 'style' &&
stack.includes('style') &&
startsWithIdent(selector)
) {
found.push({ line: preludeLine, selector });
}
stack.push(kind);
startPrelude();
continue;
}
if (c === '}') {
stack.pop();
startPrelude();
continue;
}
if (c === ';') {
startPrelude();
continue;
}
prelude += c;
}
return found;
}
@@ -26,14 +26,15 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@components/playlist-picker/playlist-picker.js';
import { dict, list } from '@utils/binding';
@@ -131,17 +132,17 @@ export class ArtistsView
private contextMenuArtistId: number | null = null;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup():
| WaPopup
| MenuTarget
| undefined {
return this.playlistSubmenuPopup;
}
@@ -1336,11 +1337,8 @@ export class ArtistsView
private renderContextMenu() {
return html`
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu
.contextMenuOpen}
>
@@ -1434,13 +1432,12 @@ export class ArtistsView
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
@@ -1468,7 +1465,7 @@ export class ArtistsView
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
@@ -1,22 +1,77 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { PlayerController } from '@store/controllers/player-controller';
import { queueStore } from '@store/queue-store';
import type { RepeatMode } from '@store/queue-store';
import { designTokens } from '../../../styles/tokens.css';
import { PHONE_QUERY } from '../../../utils/breakpoints';
/**
* The transport, in the two places it appears.
*
* **The context is a property and cannot be a media query**, which is
* the whole reason this exists (#56). Everywhere else in this app a
* component states what it drops at phone width itself, because a media
* query inside a shadow root is answered by the viewport and that is
* the honest signal. Here the two hosts want *different* answers at the
* *same* viewport: on a phone the bottom bar wants three controls sized
* for a thumb, and `now-playing-view` wants five, larger still. So the
* host says which context and the viewport says which size band, and
* neither one alone can express it.
*
* Measured at the reference device's 424x439 before this: every button
* here was **33x21px**, in both places, which is what #56 reports as
* "the most important thing in the mobile app and they are tiny".
*/
export type ControlsContext = 'bar' | 'full';
@customElement('player-controls')
export class PlayerControls extends LitElement {
private player = new PlayerController(this);
private unsubscribeQueue?: () => void;
/**
* Where these controls are drawn. `bar` is the bottom bar in both
* bands; `full` is the full-screen transport.
*
* Reflected so a spec can read it and so the stylesheet keys off one
* fact rather than a class the host has to remember to set.
*/
@property({ type: String, reflect: true })
context: ControlsContext = 'bar';
@state() private shuffleMode = false;
@state() private repeatMode: RepeatMode = 'off';
/**
* Phone width, from `matchMedia` rather than from a media query,
* because what it decides is whether shuffle and repeat *exist* here
* and a stylesheet can only decide whether they are painted.
* `job-band` and `search-trigger` are the same pattern for the same
* reason.
*/
@state() private phone = false;
private media?: MediaQueryList;
private onMedia = (e: MediaQueryListEvent) => {
this.phone = e.matches;
};
/** Whether this is the phone's bottom bar, which carries three
* controls rather than five. */
private get slim(): boolean {
return this.context === 'bar' && this.phone;
}
override connectedCallback(): void {
super.connectedCallback();
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
const s = queueStore.getState();
this.shuffleMode = s.shuffleMode;
this.repeatMode = s.repeatMode;
@@ -37,6 +92,7 @@ export class PlayerControls extends LitElement {
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribeQueue?.();
this.media?.removeEventListener('change', this.onMedia);
}
static override styles = [designTokens, css`
@@ -58,6 +114,99 @@ export class PlayerControls extends LitElement {
justify-content: center;
}
/* ---------------------------------------------------------------
Sizes (#56).
44px is the floor everything here is sized to, and play/pause
alone goes above it -- "large play/pause, adequate prev/next" is
the Direction, and it is the one control the report calls "front
and centre".
They are stated as custom properties rather than on each button
so a context sets two numbers instead of five rules, and so the
icon scales with its target: a 44px box around a 16px glyph is a
big hit area that still looks tiny, which is half of what the
report is about.
**The desktop bar sets none of them and must not change at all.**
#56 is an Android issue; the desktop's buttons are 33x21 before
this and are 33x21 after it.
That is why the box rules take a zero fallback and the *font-size*
rules are scoped to the two contexts instead of sharing them. A
button does not inherit its font from its parent -- the UA
stylesheet gives it one -- so a generic font-size: inherit is
not the no-op it reads as: it moved the desktop's buttons from
33x21 to 36x24, silently, by taking them from the UA's 13.3px to
the shell's 16px. Measured before and after by stashing this
file, which is the only way that particular 3px shows up.
--------------------------------------------------------------- */
button {
min-width: var(--yj-control-target, 0);
min-height: var(--yj-control-target, 0);
}
button.play {
min-width: var(--yj-control-play-target, 0);
min-height: var(--yj-control-play-target, 0);
}
/* The phone's bottom bar: three controls, sized for a thumb.
Shuffle and repeat are not here -- see the render method, which
does not draw them rather than hiding them, because a control
that is display:none is still a thing the component claims to
have. They are on the full-screen view, which is one tap away
through the mini player's art (#59). */
@media (max-width: 599px) {
:host([context='bar']) {
--yj-control-target: 44px;
--yj-control-icon: 18px;
--yj-control-play-target: 56px;
--yj-control-play-icon: 24px;
}
:host([context='bar']) button {
font-size: var(--yj-control-icon);
}
:host([context='bar']) button.play {
font-size: var(--yj-control-play-icon);
}
}
/* The full-screen transport, at every width: this view *is* the
player, so the controls are the page rather than a strip of it. */
:host([context='full']) {
--yj-control-target: 44px;
--yj-control-icon: 20px;
--yj-control-play-target: 64px;
--yj-control-play-icon: 28px;
}
:host([context='full']) button {
font-size: var(--yj-control-icon);
}
:host([context='full']) button.play {
font-size: var(--yj-control-play-icon);
}
:host([context='full']) #player-control-buttons {
gap: 12px;
}
/* Secondary controls sit below the primary row rather than beside
it, which is the Direction's shape and is why this is a second
group in the DOM instead of a CSS order property: visual order
and focus order have to agree. */
.secondary {
display: flex;
justify-content: center;
align-items: center;
gap: 24px;
margin-top: 8px;
}
button:hover {
color: var(--yj-accent-text, #ffd43b);
}
@@ -104,46 +253,107 @@ export class PlayerControls extends LitElement {
queueStore.cycleRepeat();
};
override render() {
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play';
const playOrPauseHandler = this.player.isPlaying
? this.handlePauseClick
: this.handlePlayClick;
/** Shuffle. Secondary: it changes how the queue behaves rather than
* what is playing now. */
private renderShuffle() {
return html`
<button
class=${this.shuffleMode ? 'active' : ''}
aria-label="Shuffle"
aria-pressed=${this.shuffleMode}
@click=${this.handleShuffleClick}
>
<wa-icon name="shuffle"></wa-icon>
</button>
`;
}
const shuffleClass = this.shuffleMode ? 'active' : '';
/** Repeat, whose label spells the mode out because one icon covers
* three states. */
private renderRepeat() {
const repeatMode = this.repeatMode;
const repeatClasses = [
repeatMode !== 'off' ? 'active' : '',
repeatMode === 'one' ? 'repeat-one' : '',
].filter(Boolean).join(' ');
return html`
<button
class=${repeatClasses}
aria-label=${`Repeat: ${repeatMode}`}
aria-pressed=${repeatMode !== 'off'}
@click=${this.handleRepeatClick}
>
<wa-icon name="repeat"></wa-icon>
</button>
`;
}
/** Previous, play/pause, next the three that are always drawn, in
* every context and at every width. Only play/pause takes the large
* size: the Direction asks for "large play/pause, adequate
* prev/next", and a row of identical squares says every action here
* is equally likely, which is not true of play. */
private renderPrimary() {
const playOrPauseIcon = this.player.isPlaying ? 'pause' : 'play';
const playOrPauseHandler = this.player.isPlaying
? this.handlePauseClick
: this.handlePlayClick;
return html`
<button
aria-label="Previous track"
@click=${this.handlePreviousClick}
>
<wa-icon name="backward-step"></wa-icon>
</button>
<button
class="play"
aria-label=${this.player.isPlaying ? 'Pause' : 'Play'}
@click="${playOrPauseHandler}"
>
<wa-icon name=${playOrPauseIcon}></wa-icon>
</button>
<button
aria-label="Next track"
@click=${this.handleNextClick}
>
<wa-icon name="forward-step"></wa-icon>
</button>
`;
}
/**
* Two arrangements, not two components.
*
* `bar` keeps the order it has always had shuffle, prev, play,
* next, repeat, one row so nothing about the desktop bar moves.
* `full` puts the primary three on their own row with the secondary
* pair beneath, which the Direction asks for.
*
* **The phone's bar draws three buttons rather than hiding two.** A
* `display: none` control is still in the component's shadow root,
* still in the accessibility tree's markup, and still something a
* `shadowAll('button')[4]` finds so "the phone has three controls"
* would be true of the pixels and false of the element. They are
* reachable on the full-screen view, which the mini player's art
* opens, and through the global shortcuts.
*/
override render() {
if (this.context === 'full') {
return html`
<div id="player-control-buttons">${this.renderPrimary()}</div>
<div class="secondary">
${this.renderShuffle()}${this.renderRepeat()}
</div>
`;
}
return html`
<div id="player-control-buttons">
<button
class=${shuffleClass}
aria-label="Shuffle"
aria-pressed=${this.shuffleMode}
@click=${this.handleShuffleClick}
>
<wa-icon name="shuffle"></wa-icon>
</button>
<button aria-label="Previous track" @click=${this.handlePreviousClick}>
<wa-icon name="backward-step"></wa-icon>
</button>
<button aria-label=${this.player.isPlaying ? 'Pause' : 'Play'} @click="${playOrPauseHandler}">
<wa-icon name=${playOrPauseIcon}></wa-icon>
</button>
<button aria-label="Next track" @click=${this.handleNextClick}>
<wa-icon name="forward-step"></wa-icon>
</button>
<button
class=${repeatClasses}
aria-label=${`Repeat: ${repeatMode}`}
aria-pressed=${repeatMode !== 'off'}
@click=${this.handleRepeatClick}
>
<wa-icon name="repeat"></wa-icon>
</button>
${this.slim ? nothing : this.renderShuffle()}
${this.renderPrimary()}
${this.slim ? nothing : this.renderRepeat()}
</div>
`;
}
@@ -0,0 +1,204 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { PlayerController } from '@store/controllers/player-controller';
import { designTokens } from '../../../styles/tokens.css';
import { PHONE_QUERY } from '../../../utils/breakpoints';
/**
* How far through the song we are, on the border between the mini
* player and the tab bar (#58).
*
* The phone's bottom bar carries three controls and no seek bar plan
* 016 B2 took it out, because 4px of height is not a thumb target and the
* full-screen `now-playing-view` is where seeking belongs. What went
* with it is the one thing a mini player is expected to say without
* being opened: how far through the song it is. This is that, and
* only that.
*
* Four things about it are load-bearing.
*
* **It is the shell's element, not either bar's.** The mini player and
* `<bottom-nav>` are separate components stacked in the shell's grid,
* so a line on the border between them is a row of the grid either
* one drawing it means reaching into the other's box for two pixels.
*
* **It never counts.** The position is pushed at 1 Hz by the backend
* (`PlaybackPositionChanged`), and the interval here interpolates
* *between* those reports and is stopped and restarted by every one of
* them the seek bar's rule, for the reason the seek bar has it: a
* local clock drifted 30 s away from the backend across four keyboard
* seeks. The `trackChangeId` and `seq` guards come along for the same
* reason: the store is a singleton, so a report about the previous
* track must not be adopted, and the same second reported twice still
* has to reset the interpolation.
*
* **It is not a control and cannot become one.** `aria-hidden` on the
* host and `pointer-events: none` throughout: the real progress is
* announced by the seek bar on Now Playing, and a 2px strip on the top
* edge of the tab bar that sometimes seeks is worse than one that
* never does. It is also where a thumb aiming at a tab lands.
*
* **It renders nothing above 600px**, from `matchMedia` rather than a
* media query, because that decides whether the element *exists* and
* with it whether a 1 Hz interval runs for the life of every desktop
* session about a line nobody can see. `job-band`, `search-trigger`
* and `player-controls` are the same pattern for the same reason.
*/
/**
* The reporting cadence, matched. This is not the clock: it exists
* only so the line moves in the second between two reports, and its
* error is discarded by the next one rather than carried.
*/
const InterpolationIntervalMillis = 1000;
@customElement('player-progress-line')
export class PlayerProgressLine extends LitElement {
private player = new PlayerController(this);
/** Phone width. See the class comment: existence, not paint. */
@state() private phone = false;
/** Seconds into the track, from the last report plus interpolation. */
@state() private elapsed = 0;
private previousTrackChangeId = -1;
/** The sequence number of the last backend report applied. */
private previousPositionSeq = -1;
private timerID = -1;
private media?: MediaQueryList;
private onMedia = (e: MediaQueryListEvent) => {
this.phone = e.matches;
};
static override styles = [
designTokens,
css`
:host {
display: block;
/* Not a target, at any depth. */
pointer-events: none;
}
.track {
height: 2px;
background-color: var(--yj-bg-surface, #212529);
}
.fill {
height: 100%;
background-color: var(--yj-accent, #ffd43b);
/* scaleX off a full-width box rather than a width in
percent, so the moving thing is a transform and the
line costs no layout once a second. */
transform-origin: left center;
}
`,
];
private get trackLength(): number {
return this.player.currentTrack?.trackLength ?? 0;
}
override connectedCallback(): void {
super.connectedCallback();
// Decorative in full: the seek bar on Now Playing is what
// announces the position, and this says the same thing without
// a name, a value or a way to act on it.
this.setAttribute('aria-hidden', 'true');
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.stopInterpolating();
this.media?.removeEventListener('change', this.onMedia);
}
override updated(): void {
// A track change resets the line, and `trackChangeId` is what
// reveals one when the same file plays twice in a row.
const currentChangeId = this.player.currentTrack?.trackChangeId ?? -1;
if (currentChangeId !== this.previousTrackChangeId) {
this.previousTrackChangeId = currentChangeId;
this.elapsed = this.player.currentTrack?.seekPosition ?? 0;
this.stopInterpolating();
}
// The backend's own position wins over anything counted here,
// and a report for a track that is no longer loaded is stale by
// definition.
const position = this.player.position;
if (
position &&
position.trackChangeId === currentChangeId &&
position.seq !== this.previousPositionSeq
) {
this.previousPositionSeq = position.seq;
this.elapsed = position.positionSeconds;
this.stopInterpolating();
}
// One owner for the interval, as in `seek-bar`: everything that
// wants it started or stopped says so by changing state that
// brings us back here.
if (this.phone && this.player.isPlaying && currentChangeId !== -1) {
this.startInterpolating();
} else {
this.stopInterpolating();
}
}
private stopInterpolating(): void {
if (this.timerID !== -1) {
clearInterval(this.timerID);
this.timerID = -1;
}
}
private startInterpolating(): void {
if (this.timerID !== -1) {
return;
}
this.timerID = window.setInterval(() => {
if (this.elapsed < this.trackLength) {
this.elapsed += 1;
}
}, InterpolationIntervalMillis);
}
override render() {
// Nothing playing is nothing to say, and the grid row is `auto`
// so an empty render costs no height at all -- `job-band`'s
// rule one row down.
if (!this.phone || this.player.currentTrack === null) return nothing;
const length = this.trackLength;
const fraction =
length > 0 ? Math.min(1, Math.max(0, this.elapsed / length)) : 0;
return html`
<div class="track" data-testid="progress-line">
<div class="fill" style="transform: scaleX(${fraction})"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'player-progress-line': PlayerProgressLine;
}
}
@@ -45,18 +45,6 @@ export class SeekBar extends LitElement {
private showRemaining: boolean = true;
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 {
--track-size: 6px;
flex: 1;
@@ -80,6 +68,57 @@ export class SeekBar extends LitElement {
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 {
display: flex;
justify-content: space-between;
@@ -1,4 +1,4 @@
import { LitElement, html, css } from 'lit';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/slider/slider.js';
@@ -27,6 +27,26 @@ export class VolumeControl extends LitElement {
@state()
private popup = volumeStyleStore.popup;
/**
* Whether there is a volume of ours to control at all (#64).
*
* The decision is made here rather than at either mount point,
* because there are two -- the bottom bar's copy lives in
* `index.html`, which has no module scope to make it conditional --
* and one of them is a control the shell cannot un-render. So the
* control answers for itself, and the bar and the phone's
* full-screen transport get the same answer without either knowing
* the question exists.
*
* It renders `nothing` *and* hides the host: an empty shadow root is
* what stops a positional or role query finding a button that cannot
* act, and `:host([hidden])` is what stops the element occupying a
* flex item's worth of the transport -- the `:host` display above
* outranks the UA's `[hidden]` rule, so it has to be said.
*/
@state()
private available = volumeStyleStore.available;
private unsubscribeStyle?: () => void;
// Locally-tracked volume while the user is actively dragging or scrolling.
@@ -43,6 +63,13 @@ export class VolumeControl extends LitElement {
align-items: center;
}
/* See the available field. A gap is only drawn between boxes,
so a hidden host costs its parent nothing -- which is where the
29px this gives back to Now Playing comes from (#172). */
:host([hidden]) {
display: none;
}
button {
background: none;
border: none;
@@ -154,12 +181,15 @@ export class VolumeControl extends LitElement {
this.unsubscribeStyle = volumeStyleStore.subscribe(() => {
this.popup = volumeStyleStore.popup;
this.setAvailable(volumeStyleStore.available);
// Switching to the slider while the popup is open would leave the
// document listener installed for a popup that no longer renders.
if (!this.popup) this.closeSlider();
});
this.setAvailable(volumeStyleStore.available);
void volumeStyleStore.init();
}
@@ -233,7 +263,25 @@ export class VolumeControl extends LitElement {
// RENDER
// ===================================================================
/**
* `hidden` is set imperatively rather than reflected from the state,
* because it has to be on the *host* and a `@state` does not reflect.
* It is the right attribute besides: it takes the element out of the
* accessibility tree as well as out of the layout.
*/
private setAvailable(available: boolean) {
this.available = available;
this.hidden = !available;
// A popup left open when the control goes away would keep its
// document click listener installed for markup that no longer
// renders.
if (!available) this.closeSlider();
}
override render() {
if (!this.available) return nothing;
const muted = this.player.muted;
// Inline, the icon is the mute toggle rather than a disclosure:
@@ -79,6 +79,17 @@ export class ShortcutCapture extends LitElement {
.reset-btn:hover {
color: var(--yj-accent-text, #ffd43b);
}
/*
* Reset is the only way to put a rebound shortcut back, so where
* the device has no hover it is always visible rather than an
* invisible button holding its hit area. The inverse of #68's
* rule, which applies where the hover control is redundant.
*/
@media not all and (hover: hover) {
.reset-btn {
opacity: 1;
}
}
`;
private handleClick = () => {
@@ -23,7 +23,8 @@ import { gridColumnsFor, gridSpacingFor } from '@utils/grid-spacing';
import { queueStore } from '@store/queue-store';
import type { QueueSource } from '@store/queue-store';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/playlist-picker/playlist-picker.js';
@@ -51,7 +52,7 @@ import {
ContextMenuController,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { creditLink, exploreLinkStyles } from '../../utils/explore-link';
import { creditStore } from '@store/credit-store';
@@ -415,17 +416,17 @@ export class CoverGrid
splitIndex = 0;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
// ContextMenuHost interface.
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -2087,11 +2088,8 @@ export class CoverGrid
const { ctxMenu } = this;
return html`
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${ctxMenu.contextMenuOpen}
>
${ctxMenu.contextMenuOpen
@@ -2204,13 +2202,12 @@ export class CoverGrid
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${ctxMenu.playlistSubmenuOpen}
>
${ctxMenu.playlistSubmenuOpen
@@ -2232,7 +2229,7 @@ export class CoverGrid
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<track-details></track-details>
`;
@@ -49,9 +49,11 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { dictByName } from '@utils/binding';
import type { TrackDetails } from '@components/track-details/track-details.js';
@@ -327,7 +329,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
@state() private ctxMenuTrack: MBTrack | null = null;
@query('#track-context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup?: WaPopup;
@@ -337,11 +339,11 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
// -- ContextMenuHost interface --
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -890,6 +892,67 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
.track-row .track-request {
flex-shrink: 0;
}
/* The phone (#66)
*
* **This block is last on purpose**, for index.css's
* reason: a media query adds no specificity, so a rule
* written above the plain one it overrides loses to it and
* every declaration here is silently dead.
*
* Two faults, one shape. The page is a fixed header over a
* scrolling tracklist the desktop arrangement so at the
* reference device's 424x439 the header owned 253 of the
* panel's 318px and the tracklist scrolled inside the 64px
* that were left. And the header's flex row squeezed
* .album-info to 112px, so the title drew as one ellipsised
* glyph and two of the album's three primary actions were
* clipped by the host's own overflow: Shuffle album ended
* at x=443 in a 424px box, unreachable by any gesture.
*
* .album-info carries min-width: 0 and was shrinking as
* asked, so another one is not the fix the row has to
* stack, or the info column has nothing to be wide with.
*
* The scroller moves to the host and .content stops being
* one, which is what makes the header scroll away; the
* tracklist is plain DOM rather than a virtualizer, so
* nothing inside wants a scroll window of its own. */
@media (max-width: 599px) {
:host {
overflow-y: auto;
}
.album-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
}
/* Stacked, the art is the whole of the header's width
* budget and its 200px square is 45% of the reference
* device's height. It is still what identifies the
* album, so it shrinks rather than going. */
.cover-art-container {
width: 140px;
height: 140px;
}
/* A column flex item takes its content's width from
* align-items: flex-start above, which would leave the
* actions wrapping inside a box narrower than the row
* they now have to themselves. */
.album-info {
align-self: stretch;
}
.content {
flex: 0 0 auto;
overflow-y: visible;
padding: 16px 16px 24px;
}
}
`,
];
@@ -3781,11 +3844,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
const track = this.ctxMenuTrack;
return html`
<wa-popup
<menu-surface
id="track-context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && track
@@ -3846,13 +3906,12 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen
@@ -3869,7 +3928,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
}
@@ -61,9 +61,11 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { dict, dictByName } from '@utils/binding';
import type { TrackDetails } from '@components/track-details/track-details.js';
@@ -215,7 +217,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
@state() private ctxMenuTarget: ContextMenuTarget | null = null;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup?: WaPopup;
@@ -233,11 +235,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
// -- ContextMenuHost interface --
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -2621,11 +2623,8 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
const target = this.ctxMenuTarget;
return html`
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && target
@@ -2643,13 +2642,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen
@@ -2666,7 +2664,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
@@ -37,9 +37,11 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { dict, dictByName } from '@utils/binding';
import { ICON_QUEUE } from '@utils/icon-language';
@@ -206,13 +208,13 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
@state() private ctxMenuTarget: ExploreMenuTarget | null = null;
@litQuery('#explore-context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
// -- ContextMenuHost interface --
// No playlist submenu — same reason as the album/artist detail
// pages: every action here resolves its one file lazily.
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
@@ -1341,11 +1343,8 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
const owned = Boolean(target?.localId);
return html`
<wa-popup
<menu-surface
id="explore-context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && target
@@ -1374,7 +1373,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
@@ -24,14 +24,15 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@components/playlist-picker/playlist-picker.js';
import { dictByName } from '@utils/binding';
@@ -137,19 +138,19 @@ export class GenresView
private contextMenuGenreName: string | null = null;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
// ----- ContextMenuHost interface -----
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup():
| WaPopup
| MenuTarget
| undefined {
return this.playlistSubmenuPopup;
}
@@ -1176,11 +1177,8 @@ export class GenresView
private renderContextMenu() {
return html`
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu
.contextMenuOpen}
>
@@ -1284,13 +1282,12 @@ export class GenresView
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
@@ -1318,7 +1315,7 @@ export class GenresView
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
@@ -0,0 +1,318 @@
/**
* Where a context menu is drawn: a popup on a desktop, a bottom sheet
* on a phone (#60).
*
* Every context menu in this app is a `.context-menu-panel` inside a
* `<wa-popup>` anchored to the touch point, driven by
* `ContextMenuController`. On the reference device that is structurally
* broken, and the failure was measured on the hardware rather than
* inferred:
*
* - Chrome 113 has **no Popover API** (`popover` is Chrome 114), so
* `wa-popup` takes its own documented fallback and positions with
* `strategy: "fixed"` instead of the top layer. Measured on the
* device: `HTMLElement.prototype.hasOwnProperty('popover')` is false
* and the popup's computed `position` is `fixed`.
* - `index.css` puts `contain: layout style paint` on `.main-panel`,
* the ancestor of every view. Paint containment **clips** fixed
* descendants. Measured: `.main-panel` computes `contain: content`
* and spans 0-318 of a 439px viewport, while the open menu spans
* 191-401 so 83px of it, three of its seven items, is cut off.
*
* A `<dialog>` fixes it by construction rather than by styling, because
* `showModal()` is Chrome 37 and uses the real top layer. **That was
* measured too, and it needed to be**: every other dialog in this app
* is mounted in `index.html`, *outside* `.main-panel`, so "dialogs are
* fine" was not evidence about a dialog opened from inside a view. A
* probe dialog appended to `track-list`'s shadow root paints to y=439,
* over the mini player and the tab bar, with the contained ancestor
* still there.
*
* Four things about this component are load-bearing.
*
* **It is one element with two presentations, not two components.**
* The host keeps rendering exactly the panel it rendered before and
* slots it into whichever surface is up, so the twelve call sites
* changed one tag name each and nothing else no second item model, no
* second keyboard model, and `ContextMenuController` still drives
* `.active` and `.anchor` as if it were talking to a `wa-popup`.
*
* **Which surface exists is `matchMedia`, not a media query.** The
* decision is whether a `<dialog>` is in the tree at all, which is
* `job-band` and `player-controls`' rule: a `display: none` surface is
* still in the shadow root and still something a positional or by-role
* query finds.
*
* **The sheet has to un-do the UA stylesheet to be full-bleed.**
* A native `<dialog>` carries `max-width: calc(100% - 6px - 2em)` and
* `margin: auto`, which on the device produced a 354px panel floating
* in the middle of a 424px screen. `max-width: none` and explicit
* margins are what make it a sheet rather than a small centred box.
* The *positioning* needs no such care: a top-layer dialog's containing
* block is the viewport even with a paint-contained ancestor, which is
* why `bottom: 0` reaches y=439 and not the main panel's 318.
*
* **Dismissal has to travel back.** `wa-dialog` closes itself on
* Escape, which would otherwise leave the controller's
* `contextMenuOpen` true and the menu unopenable until something else
* cleared it. `menu-dismiss` is that signal, and the controller listens
* for it on the document beside the click and contextmenu listeners it
* already has.
*/
import { LitElement, css, html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import { PHONE_QUERY } from '@utils/breakpoints';
import { nameDialogsIn } from '@utils/name-dialog';
/** The event a surface dispatches when it closed itself. */
export const MENU_DISMISS_EVENT = 'menu-dismiss';
/**
* The event a surface dispatches once it has finished showing.
*
* Only the sheet sends it, and only because `wa-dialog` moves focus to
* itself on the frame after `showModal()` -- see `MenuKeyboard.refocus`
* for why waiting longer is not the fix.
*/
export const MENU_SHOWN_EVENT = 'menu-shown';
/**
* A `wa-popup` anchor: a real element or a virtual one.
*
* `undefined` rather than `null` for "not set yet", because that is
* what `wa-popup`'s own property accepts this surface hands the value
* straight through and must not widen it.
*/
type MenuAnchor = WaPopup['anchor'] | undefined;
/** A `wa-dialog`, as much of it as this file needs. */
type DialogEl = HTMLElement & { open: boolean };
@customElement('menu-surface')
export class MenuSurface extends LitElement {
/** Whether the menu is showing. Set by `ContextMenuController`. */
@property({ type: Boolean }) active = false;
/**
* Where the popup hangs from. Ignored in sheet mode, which is
* anchored to the bottom of the screen rather than to the touch
* point that is the whole point of a sheet.
*/
@property({ attribute: false }) anchor: MenuAnchor = undefined;
/**
* `wa-popup`'s placement, defaulted because all twelve call sites
* passed the same one. Kept as a property so a future menu that
* wants another does not have to reach past this component.
*/
@property() placement = 'bottom-start';
/**
* What to call the sheet, for a surface whose content is not a
* `.context-menu-panel` with an `aria-label` of its own -- the
* playlist submenu, whose content is a `playlist-picker`.
*/
@property() label = '';
@state() private sheet = false;
@query('wa-popup') private popup?: WaPopup;
@query('wa-dialog') private dialog?: DialogEl;
private phoneQuery?: MediaQueryList;
static override styles = css`
:host {
display: contents;
}
wa-popup {
z-index: 200;
}
/* The sheet. A native dialog's UA stylesheet centres it and
caps its width, which on the device drew a 354px box in the
middle of a 424px screen so all four of these are undoing
that rather than decorating. */
wa-dialog::part(dialog) {
margin: auto auto 0 auto;
max-width: none;
max-height: 85vh;
width: 100%;
border-radius: 12px 12px 0 0;
background: var(--yj-bg-elevated, #343a40);
padding: 0;
}
/* **A long menu scrolls; it does not hang off the bottom.**
Measured on the device at 80vh: seven 48px rows plus the grip
came to 364px against a 351px dialog, so the last row's
bottom was at y=452 on a 439px screen -- the one row a
destructive action is most likely to be. The cap has to stay
(a sheet covering the whole screen is a page, not a sheet),
so the body is what gives. */
wa-dialog::part(body) {
padding: 0;
overflow-y: auto;
}
/* A sheet is dragged at with a thumb, so it says where its top
edge is. Decorative: the panel below it carries the actions. */
.grip {
width: 36px;
height: 4px;
margin: 8px auto 4px;
border-radius: 2px;
background: var(--yj-text-tertiary, #888);
}
`;
override connectedCallback(): void {
super.connectedCallback();
// Looked up here rather than at module load, so a test can
// install its own matchMedia before the element is created.
this.phoneQuery = window.matchMedia?.(PHONE_QUERY);
this.sheet = this.phoneQuery?.matches ?? false;
this.phoneQuery?.addEventListener('change', this.onPhoneChange);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.phoneQuery?.removeEventListener('change', this.onPhoneChange);
}
private onPhoneChange = (e: MediaQueryListEvent): void => {
this.sheet = e.matches;
};
/**
* Re-run the popup's positioning.
*
* Forwarded rather than dropped because `page-header` calls it when
* it opens the overflow menu: the popup is rendered before the
* button it anchors to has settled. A sheet has nothing to
* reposition -- it is anchored to the bottom of the screen -- so
* there it is deliberately a no-op rather than an error.
*/
reposition(): void {
this.popup?.reposition();
}
/**
* The panel the host slotted in. It is light DOM here and stays in
* the host's shadow root, which is what keeps the host's own
* `contextMenuStyles` applying to it in both presentations.
*/
private get panel(): HTMLElement | null {
return this.querySelector('.context-menu-panel');
}
override updated(): void {
const panel = this.panel;
// The sheet's rows are bigger, and that rule lives in the one
// stylesheet every call site already includes rather than in
// twelve places. The attribute is how it knows.
if (panel) panel.toggleAttribute('data-sheet', this.sheet);
if (this.sheet) {
this.syncSheet(panel);
return;
}
if (this.popup) {
if (this.anchor) this.popup.anchor = this.anchor;
this.popup.active = this.active;
}
}
private syncSheet(panel: HTMLElement | null): void {
const dialog = this.dialog;
if (!dialog) return;
// The dialog is named after the menu it contains, so no call
// site has to say the same thing twice: the panel already
// carries `role="menu"` and an `aria-label` naming what it acts
// on. `without-header` renders no heading, which is
// `name-dialog`'s documented `aria-label` path.
const label = panel?.getAttribute('aria-label') || this.label;
if (label) dialog.setAttribute('label', label);
nameDialogsIn(this.shadowRoot);
if (dialog.open !== this.active) dialog.open = this.active;
}
/**
* `wa-dialog` closed itself Escape, or its own close button.
* The controller owns `contextMenuOpen`, so it has to hear about
* it or the menu is left open in state and shut on screen.
*/
private onDialogShown = (): void => {
if (!this.active) return;
this.dispatchEvent(
new CustomEvent(MENU_SHOWN_EVENT, {
bubbles: true,
composed: true,
}),
);
};
private onDialogHide = (): void => {
if (!this.active) return;
this.dispatchEvent(
new CustomEvent(MENU_DISMISS_EVENT, {
bubbles: true,
composed: true,
}),
);
};
override render() {
if (this.sheet) {
// **The anchor stays out of the sheet.** One call site --
// `page-header`'s overflow menu -- slots its own trigger
// button as the thing the popup hangs from, and a sheet
// hangs from the bottom of the screen instead. Rendering
// that slot outside the dialog is what keeps the button on
// the page rather than inside the surface it opens.
return html`
<slot name="anchor"></slot>
<wa-dialog
without-header
data-testid="menu-sheet"
@wa-after-show=${this.onDialogShown}
@wa-hide=${this.onDialogHide}
>
<div class="grip"></div>
<slot></slot>
</wa-dialog>
`;
}
return html`
<wa-popup placement=${this.placement} flip shift>
<slot name="anchor" slot="anchor"></slot>
<slot></slot>
</wa-popup>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'menu-surface': MenuSurface;
}
}
@@ -105,6 +105,19 @@ export class NowPlayingView extends LitElement {
color: var(--yj-text-secondary, #adb5bd);
}
/* The art and the names are one block, so that a short
screen can lay them out side by side without either of them
knowing about the other's box. Vertically it is exactly what
the host used to do -- same gap, art flexible, names fixed --
so the tall layout is unchanged. */
.stack {
display: flex;
flex-direction: column;
gap: 0.75em;
flex: 1 1 auto;
min-height: 0;
}
.art {
flex: 1 1 auto;
display: flex;
@@ -113,19 +126,89 @@ export class NowPlayingView extends LitElement {
min-height: 0;
}
.art img,
.art .placeholder {
/* Square, and never taller than the room left over: the
art is the one thing here that would happily push the
transport off the bottom of a short phone. */
width: min(100%, 60vh);
.art img {
/* Square, and never larger than the room left over --
where "square" is a property of what is painted and not
just of what was asked for.
The previous rule asked for a square and did not get
one. width: min(100%, 60vh) makes the width definite,
aspect-ratio: 1 derives the height from it, and
max-height: 100% then clamps that height **without
re-deriving the width** -- which is how the
aspect-ratio property is specified to behave, unlike
an intrinsic ratio. So whenever the room left over was
shorter than the box was wide, the art was drawn as a
letterbox strip and object-fit: cover cropped the
cover to it. Measured on the reference device at
424x439: **264x53**, a 5:1 band of a square image.
That is not only the phone. The leftover exceeds the
width only above ~843px of viewport, so every height
from ~500 to ~843 -- most phones, and any small window
-- drew a cropped strip too.
Both maxes with auto sizes is the fix, and it is the
replaced-element path rather than the aspect-ratio
one: the used size preserves the ratio under *both*
bounds (CSS2.1 10.4), so the art is square at every
height. Checked against Chrome 113 itself -- the
device's engine -- at column heights of 288, 300, 451,
600 and 800: square at all five, where the old rule
cropped at four.
A corollary worth knowing: auto will not upscale past
the image's natural size, and the largest tier
saveCoverArt keeps is 400px. Drawing it larger was
upscaling, so nothing is lost. */
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
aspect-ratio: 1;
object-fit: cover;
border-radius: 12px;
background-color: var(--yj-bg-elevated, #343a40);
}
/* The placeholder is not a replaced element, so it cannot use
the rule above: with no intrinsic size, auto/auto collapses
it to its icon -- measured at 13x58 in Chrome 113, which is
neither square nor the art's size.
So it is sized from the height, and then bounded by the
width in the one way a box like this can be. A non-replaced
element cannot express "the largest square that fits" in a
single rule: aspect-ratio derives the second axis from the
first, and whichever max clamps it does not re-derive the
other, which is the same trap the image rule above is about.
Driving it from the height alone is right until the column
is taller than it is wide -- ~843px of viewport, which is a
tall phone and #51's other named device -- and there it went
380x484.
max-height in viewport units is what closes it, and it is
sound here for the reason 60vh was not: this view is a
phone-width detail view, so its content box really is the
viewport less the host's own 1rem gutters. It is a *max*, so
the failure mode if that ever stopped being true is a square
bounded slightly early rather than a crop. rem and not em --
this box sets font-size: 3rem for the icon, so 2em here
would be 96px. */
.art .placeholder {
height: 100%;
width: auto;
max-width: 100%;
max-height: calc(100vw - 2rem);
/* A flex item's automatic minimum is its content, so
without this the icon's own width becomes a floor and
the box goes wider than it is tall the moment the row is
shorter than the icon -- which is exactly the state a
job band puts this screen in. */
min-width: 0;
aspect-ratio: 1;
border-radius: 12px;
background-color: var(--yj-bg-elevated, #343a40);
display: flex;
align-items: center;
justify-content: center;
@@ -213,6 +296,60 @@ export class NowPlayingView extends LitElement {
color: var(--yj-text-secondary, #adb5bd);
text-align: center;
}
/* Below 500px of viewport the art and the names sit side by
side, and that is the whole of this screen's answer to a
short phone (#51).
The stacked layout cannot be rescued by sizing alone. Its
budget is fixed -- 48px of header, 143px of transport since
#64, 78px of names, 68px of padding and gaps -- so the art
gets height - 386, which on the reference device's 424x439
is **53px**. #172 measured 39px before #64 and named the
two options: give the art a floor and let the block scroll,
or reflow. A floor scrolls the transport off the bottom,
and "controls never scroll off" is #51's own Direction and
plan 018's promise -- so it is the reflow.
Sideways the art is bounded by the row's height rather than
by the column's leftover, which is the whole gain: the same
439px screen goes from a 53px sliver to **143px**, measured
on the device, with nothing scrolling and the transport
untouched.
500 is where the two layouts cross rather than a round
number. In a row the art is height - 296 and the names get
what is left of 392px, so the names hold 176px at exactly
500 and less above it; stacked, the art is height - 386,
which passes 176px at 562. Below 500 the row is the bigger
art *and* the readable one -- above it the column is, which
is why a tall phone (a Pixel 7's ~869) keeps the layout it
has. Unverified on that device: none was attached.
It is keyed on height alone, not on the phone's width,
because it is an answer to vertical room -- a 900x450 window
has the same problem and the same fix. */
@media (max-height: 500px) {
.stack {
flex-direction: row;
align-items: center;
}
/* A square of the row's height. The box has to carry the
ratio here rather than the image, because in a row the
art's width is what the ratio has to produce -- and the
image's own rule then fits it to a box that is already
square. */
.art {
flex: 0 1 auto;
height: 100%;
aspect-ratio: 1;
}
.meta {
flex: 1 1 auto;
}
}
`];
private back() {
@@ -264,60 +401,74 @@ export class NowPlayingView extends LitElement {
return html`
${this.renderHeader()}
<div class="art">
${art
? html`<img
src=${art}
alt=""
decoding="async"
data-testid="npv-art"
/>`
: html`<div class="placeholder" aria-hidden="true">
<wa-icon name="compact-disc"></wa-icon>
</div>`}
</div>
<div class="meta">
<div class="names">
<h2 class="title" data-testid="npv-title">
${track.title || track.fileName}
</h2>
<p class="artist">
${creditLink(
creditStore.credits(track.recordingMbid),
track.artist,
track.artistMbid,
)}
</p>
${track.album
? html`<p class="album">
${albumLink(
track.album,
track.releaseGroupMbid,
undefined,
track.artist,
)}
</p>`
: nothing}
<div class="stack">
<div class="art">
${art
? html`<img
src=${art}
alt=""
decoding="async"
data-testid="npv-art"
/>`
: html`<div class="placeholder" aria-hidden="true">
<wa-icon name="compact-disc"></wa-icon>
</div>`}
</div>
<button
type="button"
class="favorite ${favorited ? 'on' : ''}"
data-testid="npv-favorite"
aria-pressed=${favorited ? 'true' : 'false'}
aria-label=${favorited
? `Remove ${track.title} from ${this.favCtrl.playlistName}`
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite}
>
<wa-icon name=${this.favCtrl.iconFor(favorited)}></wa-icon>
</button>
<div class="meta">
<div class="names">
<h2 class="title" data-testid="npv-title">
${track.title || track.fileName}
</h2>
<p class="artist">
${creditLink(
creditStore.credits(track.recordingMbid),
track.artist,
track.artistMbid,
)}
</p>
${track.album
? html`<p class="album">
${albumLink(
track.album,
track.releaseGroupMbid,
undefined,
track.artist,
)}
</p>`
: nothing}
</div>
<button
type="button"
class="favorite ${favorited ? 'on' : ''}"
data-testid="npv-favorite"
aria-pressed=${favorited ? 'true' : 'false'}
aria-label=${favorited
? `Remove ${track.title} from ${this.favCtrl.playlistName}`
: `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite}
>
<wa-icon name=${this.favCtrl.iconFor(favorited)}></wa-icon>
</button>
</div>
</div>
<div class="transport">
<seek-bar></seek-bar>
<player-controls></player-controls>
<!-- context="full": this view *is* the player, so the
transport is the page rather than a strip of it --
primary controls large, shuffle and repeat beneath
at normal size (#56). It is a property rather than
a media query because the bottom bar wants a
different answer at this same viewport. -->
<player-controls context="full"></player-controls>
<!-- Rendered unconditionally and absent on its own
terms where the device owns the volume (#64): the
control asks the player, not this view and not the
viewport. A hidden host draws no gap, so that is
29px of a 439px screen back to the album art
(#172). -->
<volume-control></volume-control>
</div>
`;
@@ -215,6 +215,17 @@ export class NowPlaying extends LitElement {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
/* The favourite is one of the three controls #59 keeps on the
phone's bar, and it was the **smallest control in the app**:
measured at 424x439, 18x14px, against the 48x48 art beside it.
Zero padding around an icon-sized glyph is a reasonable mouse
target and is not a thumb target at all. */
.fav-btn {
min-width: 44px;
min-height: 44px;
font-size: var(--yj-icon-md);
}
}
.cover-preview-panel {
@@ -446,8 +457,30 @@ export class NowPlaying extends LitElement {
return html`
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
<div class="now-playing">
<div class="cover-art">
<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>
<!-- **The way to Now Playing does not depend on what is
playing.** This branch used to render the placeholder
with no button on it, so on a phone there was no route to
the full-screen view while nothing was loaded -- and once
#59 took the queue button off the bar, that made the
queue itself unreachable, because Now Playing is where it
is reached from. The queue is persisted across restarts,
so "a queue with tracks in it and nothing playing" is an
ordinary state to launch into, not a corner.
Plan 018's matrix promises no action is unreachable at
any supported size, and the promise is what makes #59
allowed to remove a control at all. -->
<div class="cover-art-wrapper">
<button
type="button"
class="expand"
data-testid="open-now-playing"
aria-label="Open now playing"
@click=${this.openNowPlaying}
></button>
<div class="cover-art">
<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>
</div>
</div>
</div>
<div
@@ -1,9 +1,9 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import { designTokens } from '../../styles/tokens.css';
import {
@@ -183,8 +183,8 @@ export class PageHeader extends LitElement {
@query('#page-header-overflow')
private menuPanel?: HTMLElement;
@query('wa-popup')
private popup?: WaPopup;
@query('menu-surface')
private popup?: MenuSurface;
private menuKeyboard = new MenuKeyboard(() => this.closeMenu());
@@ -418,7 +418,7 @@ export class PageHeader extends LitElement {
outline-offset: -1px;
}
wa-popup {
menu-surface {
z-index: 200;
}
@@ -670,10 +670,17 @@ export class PageHeader extends LitElement {
return html`
<div class="actions">
${this.actions.map((a) => this.renderActionButton(a))}
<wa-popup
<!-- A sheet below 600px, like every other menu in the
app (#60). The clipping that issue is about does
not bite here this one opens downward from the
top of a full-height view, so it has somewhere to
go even without top-layer promotion but the touch
targets do: on a phone *every* action of a page
that overflows lives in here, at wa-dropdown-item
defaults. One surface, so there is no second
answer to what a menu looks like. -->
<menu-surface
placement="bottom-end"
flip
shift
.active=${this.menuOpen}
>
<button
@@ -711,7 +718,7 @@ export class PageHeader extends LitElement {
`,
)}
</div>
</wa-popup>
</menu-surface>
</div>
`;
}
@@ -7,7 +7,8 @@ import {
} from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@lit-labs/virtualizer';
import type { LitVirtualizer } from '@lit-labs/virtualizer';
@@ -35,7 +36,7 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { notificationStore } from '@store/notification-store';
@@ -145,10 +146,10 @@ export class PlaylistDetails
private dragImageEl: HTMLElement | null = null;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
@query('track-details')
private trackDetailsDialog!: TrackDetails;
@@ -163,11 +164,11 @@ export class PlaylistDetails
// ContextMenuHost interface
// =================================================================
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -1617,11 +1618,8 @@ export class PlaylistDetails
private renderContextMenu() {
return html`
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu
.contextMenuOpen}
>
@@ -1783,13 +1781,12 @@ export class PlaylistDetails
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
@@ -1816,7 +1813,7 @@ export class PlaylistDetails
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
}
@@ -2,7 +2,9 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import {
@@ -140,7 +142,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
private pendingDropPaths: string[] = [];
@query('#playlist-context-menu')
private playlistContextMenuPopup!: WaPopup;
private playlistContextMenuPopup!: MenuSurface;
@query('duplicate-tracks-dialog')
private duplicateDialog!: DuplicateTracksDialog;
@@ -1059,7 +1061,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
);
}
private closePlaylistContextMenu() {
private closePlaylistContextMenu = () => {
if (!this.playlistContextMenuOpen) return;
this.menuKeyboard.close();
@@ -1072,7 +1074,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
if (popup) {
popup.active = false;
}
}
};
private async onPlaylistContextAction(
action: string,
@@ -1500,13 +1502,11 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
</div>`
: this.renderPlaylistList()}
<wa-popup
<menu-surface
id="playlist-context-menu"
placement="bottom-start"
flip
shift
.active=${this
.playlistContextMenuOpen}
@menu-dismiss=${this.closePlaylistContextMenu}
>
${this.playlistContextMenuOpen
? html`
@@ -1564,7 +1564,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<duplicate-tracks-dialog
@playlist-action-complete=${() =>
@@ -9,9 +9,11 @@ import {
} from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { QueueController } from '@store/controllers/queue-controller';
import { PHONE_QUERY } from '@utils/breakpoints';
import { creditStore } from '@store/credit-store';
import {
describeQueueSource,
@@ -34,7 +36,7 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import { FavoritesController } from '@store/controllers/favorites-controller';
import {
@@ -119,6 +121,25 @@ export class QueuePanel
@property({ type: Boolean, reflect: true })
overlay = false;
/**
* Phone width, from `matchMedia` rather than from a media query,
* because it decides whether the scrim *exists* (#171)
* `job-band`'s rule, and a stylesheet cannot express it: a
* `display: none` scrim is still an element with a click handler.
*
* Below 600px the panel spans the whole content area, so the scrim
* has no uncovered pixels: measured at 424x439, host, panel and
* scrim are all 424x318 with the scrim entirely underneath. It dims
* nothing and dismisses nothing there, and the queue is a *screen*
* at that width anyway (#55) back and a 44px close button are its
* ways out. Between 600 and 899 the panel is a 320px column of a
* wider content area, the scrim is reachable, and #24's
* tap-outside-to-close is real; that band is untouched.
*/
@state() private phone = false;
private phoneQuery?: MediaQueryList;
@state()
private isDragging = false;
@@ -140,13 +161,13 @@ export class QueuePanel
private delegationAttached = false;
@query('#add-to-playlist-popup')
private addToPlaylistPopup!: WaPopup;
private addToPlaylistPopup!: MenuSurface;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
/** Unsubscribes the credit-arrival repaint. */
private creditsUnsub?: () => void;
@@ -305,11 +326,11 @@ export class QueuePanel
// ContextMenuHost interface
// =================================================================
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -390,6 +411,8 @@ export class QueuePanel
display: none;
}
/* Overlay only, and above 600px only -- see the phone field,
which is where that half is decided (#171). */
.scrim {
position: absolute;
inset: 0;
@@ -413,10 +436,10 @@ export class QueuePanel
/* A screen's way out has to be hittable with a thumb.
Measured at 424x439 before #55: these were **25x21px**,
and with the panel spanning the whole width the scrim
underneath has no uncovered pixels at all -- so it was
the only pointer route out of a full-screen surface.
Back answers it now as well, which is the other half.
and with the panel spanning the whole width there is no
scrim here at all (#171) -- so this is the only pointer
route out of a full-screen surface. Back answers it now
as well, which is the other half.
Sized only in overlay mode: inline these sit in a 320px
column beside the content, where a mouse is what reaches
@@ -638,23 +661,42 @@ export class QueuePanel
text-overflow: ellipsis;
}
/*
* The per-row remove is a hover affordance, and on a device
* without hover it is redundant rather than missing: the row's
* context menu is a bottom sheet since #60 and carries "Remove
* from Queue", so the action is one long-press away. An
* always-visible X would instead spend part of a 424px row on
* something already reachable. #68's treatment, for #68's reason.
*
* display:none outside the query rather than visibility:hidden:
* a hidden button still occupies its hit area and is still in
* the accessibility tree, so a phone would keep a target for a
* control it can never see.
*/
.remove-button {
background: none;
border: none;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
visibility: hidden;
display: none;
}
.track-item:hover .remove-button {
visibility: visible;
}
@media (hover: hover) and (pointer: fine) {
.remove-button {
background: none;
border: none;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
visibility: hidden;
}
.remove-button:hover {
color: var(--yj-error-text, #ff8787);
.track-item:hover .remove-button {
visibility: visible;
}
.remove-button:hover {
color: var(--yj-error-text, #ff8787);
}
}
.list-area.drag-over {
@@ -827,6 +869,12 @@ export class QueuePanel
// desktop width rather than the minimum.
this.updateOverlayMode();
// Read here rather than in a field initialiser, so a test can
// install its own matchMedia before the element is created.
this.phoneQuery = window.matchMedia?.(PHONE_QUERY);
this.phone = this.phoneQuery?.matches ?? false;
this.phoneQuery?.addEventListener('change', this.onPhoneMedia);
if (this.parentElement) {
this.spaceObserver = new ResizeObserver(() =>
this.updateOverlayMode(),
@@ -869,6 +917,8 @@ export class QueuePanel
this.creditsUnsub = undefined;
this.spaceObserver?.disconnect();
this.spaceObserver = undefined;
this.phoneQuery?.removeEventListener('change', this.onPhoneMedia);
this.phoneQuery = undefined;
document.removeEventListener('keydown', this.onOverlayKeydown);
document.removeEventListener(
'mousemove',
@@ -935,6 +985,10 @@ export class QueuePanel
this.overlay = available - this.panelWidth < MAIN_PANEL_FLOOR;
};
private onPhoneMedia = (e: MediaQueryListEvent): void => {
this.phone = e.matches;
};
/**
* Escape closes a scrimmed overlay, which is the one keyboard rule
* every dialog in this app already follows.
@@ -1092,7 +1146,7 @@ export class QueuePanel
}
}
private closePlaylistPicker() {
private closePlaylistPicker = () => {
if (!this.playlistPickerOpen) return;
this.playlistPickerOpen = false;
@@ -1102,7 +1156,7 @@ export class QueuePanel
if (popup) {
popup.active = false;
}
}
};
private onPlaylistActionComplete = () => {
this.closePlaylistPicker();
@@ -1971,7 +2025,7 @@ export class QueuePanel
const tracks = this.queue.tracks;
return html`
${this.overlay
${this.overlay && !this.phone
? html`<div
class="scrim"
part="scrim"
@@ -2042,9 +2096,11 @@ export class QueuePanel
</div>
</div>
<wa-popup
<menu-surface
id="add-to-playlist-popup"
label="Add to playlist"
placement="bottom-end"
@menu-dismiss=${this.closePlaylistPicker}
.active=${this.playlistPickerOpen}
>
${this.playlistPickerOpen
@@ -2060,7 +2116,7 @@ export class QueuePanel
></playlist-picker>
`
: nothing}
</wa-popup>
</menu-surface>
<div
class="list-area"
@@ -2103,11 +2159,8 @@ export class QueuePanel
</div>
</div>
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen
@@ -2195,13 +2248,12 @@ export class QueuePanel
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen &&
@@ -2224,7 +2276,7 @@ export class QueuePanel
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<track-details></track-details>
`;
@@ -26,7 +26,7 @@ import {
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import { FavoritesController } from '@store/controllers/favorites-controller';
import {
@@ -40,7 +40,8 @@ import {
} from '@utils/drag-image';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@lit-labs/virtualizer';
import type { LitVirtualizer } from '@lit-labs/virtualizer';
@@ -176,10 +177,10 @@ export class SmartPlaylistDetails
private dragImageEl: HTMLElement | null = null;
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
@query('track-details')
private trackDetailsDialog!: TrackDetails;
@@ -188,11 +189,11 @@ export class SmartPlaylistDetails
// ContextMenuHost interface
// =================================================================
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -1465,11 +1466,8 @@ export class SmartPlaylistDetails
private renderContextMenu() {
return html`
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen
@@ -1583,13 +1581,12 @@ export class SmartPlaylistDetails
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu
.playlistSubmenuOpen}
>
@@ -1616,7 +1613,7 @@ export class SmartPlaylistDetails
</div>
`
: nothing}
</wa-popup>
</menu-surface>
`;
}
}
@@ -529,6 +529,44 @@ export class TrackDetails extends LitElement {
background: var(--yj-error, #e03131);
}
/*
* Both are the *only* route to changing or removing a track's
* cover art, so where the device has no hover they are always
* visible rather than hidden the inverse of #68's rule, which
* applies where the hover control is redundant. Revealed by
* opacity, so what is on screen is what the desktop reveal shows
* and nothing about the layout moves.
*/
@media not all and (hover: hover) {
/* The × is genuinely the only route to removing the art, so
on a device that cannot hover it is simply always there.
The pen is not: .cover-art-edit carries the click that
opens the file picker, so tapping the artwork already
worked while the overlay was invisible. It is a discovery
hint and paying for discovery by covering the artwork
being edited in 50% black, permanently, on every touch
device, is heavier than the hint is worth. It becomes a
corner chip in the remove button's own visual language
instead: same size, same disc, same alpha. */
.cover-art-remove {
opacity: 1;
}
.cover-art-overlay {
opacity: 1;
inset: auto 4px 4px auto;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
}
.cover-art-overlay wa-icon {
font-size: 14px;
}
}
/* Error message */
.error-message {
flex: 1;
@@ -18,7 +18,7 @@ import {
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller.js';
import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
import '@components/page-header/page-header';
@@ -63,7 +63,8 @@ import type {
} from '@lit-labs/virtualizer';
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import type { MenuSurface } from '../menu-surface/menu-surface';
import '../menu-surface/menu-surface';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { describeError } from '@utils/describe-error';
@@ -231,18 +232,18 @@ export class TrackList
private tracks: library.Track[] = [];
@query('#context-menu')
private contextMenuPopup!: WaPopup;
private contextMenuPopup!: MenuSurface;
@query('#playlist-submenu')
private playlistSubmenuPopup!: WaPopup;
private playlistSubmenuPopup!: MenuSurface;
// -- ContextMenuHost interface --
getContextMenuPopup(): WaPopup | undefined {
getContextMenuPopup(): MenuTarget | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
getPlaylistSubmenuPopup(): MenuTarget | undefined {
return this.playlistSubmenuPopup;
}
@@ -2323,11 +2324,8 @@ export class TrackList
</div>
`}
<wa-popup
<menu-surface
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen
@@ -2413,13 +2411,12 @@ export class TrackList
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<wa-popup
<menu-surface
id="playlist-submenu"
label="Add to playlist"
placement="right-start"
flip
shift
.active=${this.ctxMenu.playlistSubmenuOpen}
>
${this.ctxMenu.playlistSubmenuOpen && this.selection.hasSelection
@@ -2437,7 +2434,7 @@ export class TrackList
</div>
`
: nothing}
</wa-popup>
</menu-surface>
<track-details></track-details>
`;
+59 -1
View File
@@ -1,5 +1,6 @@
import { EventsOn } from '@runtime/runtime';
import { GetPopupVolume } from '@go/config/config.js';
import { SystemOwnsVolume } from '@go/player/player.js';
import { Events } from '../events';
type Subscriber = () => void;
@@ -28,10 +29,37 @@ type Subscriber = () => void;
* becomes one. An install that has chosen the popup sees it swap once
* on load, which is the cheaper of the two wrong first frames: the
* inline slider occupies the space the popup's button would have.
*
* **`available` is the question one step earlier whether there is a
* volume of ours to draw at all (#64).** On Android the hardware keys
* are the volume control and the backend pins its own level at
* maximum, so a slider here would move nothing.
*
* It is asked of the *player* rather than of the viewport, and that is
* the whole design decision. Every other stand-down rule in this app
* is a width, because a width is what a browser can answer and what
* every tier can test but this one is a property of the build. Keyed
* on width instead, an Android tablet at 600px or more would draw the
* bottom bar's slider over a pinned level: a control that cannot act,
* which `library-status-indicator` settled is worse than none.
*
* It lives beside `popup` because both answer "what presentation does
* the volume control get", both readers are the same two components,
* and "none" is a presentation. A second store would be a second
* subscription in the same `connectedCallback` saying the same thing.
*
* The initial value is `true` on the same first-frame rule: there is a
* volume on every platform but one, and the platform that pins it sees
* the control once at boot and never again in the session the answer
* cannot change while the app runs, so by the time the lazily-mounted
* now-playing view exists it has long been settled by the bar's own
* copy.
*/
class VolumeStyleStore {
private value = false;
private hasVolume = true;
private loaded = false;
private subscribers = new Set<Subscriber>();
@@ -47,13 +75,21 @@ class VolumeStyleStore {
return this.value;
}
/**
* Whether this app has a volume of its own to control. False where
* the device owns it; see the class comment.
*/
get available(): boolean {
return this.hasVolume;
}
/** Reads the setting once. Safe to call from every mount. */
async init(): Promise<void> {
if (this.loaded) return;
this.loaded = true;
await this.refresh();
await Promise.all([this.refreshAvailability(), this.refresh()]);
}
subscribe(fn: Subscriber): () => void {
@@ -77,6 +113,28 @@ class VolumeStyleStore {
}
}
/**
* Asked once, not on `GeneralConfigChanged`: this is a property of
* the platform the binary was built for and cannot change while
* the app is running.
*/
private async refreshAvailability(): Promise<void> {
try {
const owned = await SystemOwnsVolume();
if (owned === !this.hasVolume) return;
this.hasVolume = !owned;
this.notify();
} catch (err) {
// The control renders, which is the answer on every
// platform but one and is the recoverable way to be wrong:
// a working control nobody needs, rather than a missing one
// somebody does.
console.error('failed to ask who owns the volume', err);
}
}
private notify(): void {
for (const fn of this.subscribers) fn();
}
+145 -6
View File
@@ -5,7 +5,20 @@ import type {
} from 'lit';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
/**
* What this controller needs of a surface: something it can switch on
* and point at. Both `wa-popup` and `menu-surface` satisfy it.
*/
export type MenuTarget = HTMLElement & {
active: boolean;
anchor?: WaPopup['anchor'];
};
import { registerViewAware } from './view-lifecycle';
import {
MENU_DISMISS_EVENT,
MENU_SHOWN_EVENT,
} from '../components/menu-surface/menu-surface';
/**
* Host interface for components using the ContextMenuController.
@@ -17,10 +30,18 @@ export interface ContextMenuHost
extends ReactiveControllerHost {
updateComplete: Promise<boolean>;
shadowRoot: ShadowRoot | null;
/** Return the main context-menu popup element. */
getContextMenuPopup(): WaPopup | undefined;
/** Return the playlist submenu popup element. */
getPlaylistSubmenuPopup(): WaPopup | undefined;
/**
* Return the main context-menu surface.
*
* `MenuSurface` since #60, which is a `wa-popup` above 600px and a
* bottom sheet below it. The type is the narrow shape this
* controller drives rather than either element, so a host that
* still renders a bare `wa-popup` the playlist submenu does
* satisfies it unchanged.
*/
getContextMenuPopup(): MenuTarget | undefined;
/** Return the playlist submenu surface. */
getPlaylistSubmenuPopup(): MenuTarget | undefined;
/**
* Called when the context menu is closed by an
* outside click/contextmenu/mousedown. Components
@@ -33,6 +54,15 @@ export interface ContextMenuHost
/** Submenu close delay in milliseconds. */
const SUBMENU_CLOSE_DELAY = 150;
/**
* How long to keep trying to put focus on a menu's first item.
*
* Long enough to outlast `wa-dialog`'s show animation, which ends by
* focusing the dialog; short enough that a menu which genuinely has no
* items stops rather than spinning for the life of the page.
*/
const FOCUS_RETRY_BUDGET_MS = 500;
/** A menu item, focusable and clickable. Web Awesome sets `role` itself. */
type MenuItem = HTMLElement & { active?: boolean; disabled?: boolean };
@@ -73,6 +103,30 @@ export class MenuKeyboard {
void this.focusFirstItem(panel);
}
/**
* Take focus back, for a surface that finished showing after we
* had already placed it.
*
* `wa-dialog` focuses `[autofocus]` or *itself* on the animation
* frame after `showModal()`, and it cannot see our first menu item
* to prefer it: the panel is slotted through `menu-surface`, so the
* dialog's own `querySelector` stops at the `<slot>`. Retrying on a
* longer budget does not fix this either -- the first attempt
* *succeeds*, and the steal happens afterwards. Measured on the
* device: the sheet opened with focus on the `<dialog>` and every
* arrow key went nowhere.
*
* So the surface says when it has settled and this re-asserts. It
* is a no-op for a menu that is closed or that already has focus.
*/
refocus(): void {
const panel = this.panel;
if (!panel || panel.contains(deepActiveElement())) return;
void this.focusFirstItem(panel);
}
/**
* Focus the first item, once the items are items.
*
@@ -91,11 +145,24 @@ export class MenuKeyboard {
await Promise.all(candidates.map((el) => el.updateComplete ?? null));
// …and once the popup has positioned itself. `wa-popup` places the
// …and once the surface has shown itself. `wa-popup` places the
// panel on an animation frame, and `focus()` on a not-yet-shown
// element is a silent no-op — which looks identical to a menu
// that opened and refused to take focus.
for (let attempt = 0; attempt < 3; attempt++) {
//
// **The budget is time, not frames, because #60 gave this a
// second kind of surface.** Three frames was enough for a
// popup; a `wa-dialog` runs a show *animation* and moves focus
// to the dialog itself when it finishes, which lands after
// those frames and takes the focus back. Measured on the
// device: the sheet opened with `document.activeElement` on the
// `<dialog>`, so every arrow key went nowhere. Retrying to a
// deadline is `roving-grid`'s rule for the same reason — the
// thing being waited for is another component's animation, not
// a fixed number of paints.
const deadline = Date.now() + FOCUS_RETRY_BUDGET_MS;
while (Date.now() < deadline) {
// Bail if the menu closed while we waited.
if (this.panel !== panel) return;
@@ -259,6 +326,20 @@ export class ContextMenuController
/** Bound close handler for document events. */
private closeHandler = () => this.close();
/**
* A surface finished showing; see `MenuKeyboard.refocus`.
*
* **Not while the submenu is up.** Both surfaces send this, and the
* submenu's sheet opens *over* the main one -- so re-asserting
* focus on the main panel's first item would snatch it straight
* back out of the playlist picker the user just opened.
*/
private shownHandler = () => {
if (this.contextMenuOpen && !this.playlistSubmenuOpen) {
this.keyboard.refocus();
}
};
/** Bound mousedown handler for outside-click detection. */
private mousedownCloseHandler = (
e: MouseEvent,
@@ -325,12 +406,28 @@ export class ContextMenuController
'mousedown',
this.mousedownCloseHandler,
);
document.addEventListener(
MENU_DISMISS_EVENT,
this.closeHandler,
);
document.addEventListener(
MENU_SHOWN_EVENT,
this.shownHandler,
);
}
private detach(): void {
if (!this.listening) return;
this.listening = false;
document.removeEventListener(
MENU_DISMISS_EVENT,
this.closeHandler,
);
document.removeEventListener(
MENU_SHOWN_EVENT,
this.shownHandler,
);
document.removeEventListener(
'click',
this.closeHandler,
@@ -550,6 +647,48 @@ export const contextMenuStyles = css`
z-index: 200;
}
/* ---------------------------------------------------------------
The sheet (#60).
menu-surface puts data-sheet on the panel when it is drawn
as a bottom sheet, and these rules are here rather than in that
component because the panel is the *host's* light DOM: it lives
in the host's shadow root, so only the host's stylesheet can
reach it. This file is the one every call site already includes,
which is what makes twelve menus grow thumb-sized rows from one
edit.
Measured on the device before the change: rows were 29px, against
the 44px floor plan 018 promises and the 48px this issue asks
for. --------------------------------------------------------- */
.context-menu-panel[data-sheet] {
border: none;
border-radius: 0;
box-shadow: none;
min-width: 0;
padding: 4px 0 8px;
background-color: transparent;
}
.context-menu-panel[data-sheet] wa-dropdown-item {
font-size: var(--yj-text-md, 0.9375rem);
min-height: 48px;
align-items: center;
}
.context-menu-panel[data-sheet] wa-dropdown-item::part(base) {
min-height: 48px;
align-items: center;
}
/* A submenu arrow means "a flyout opens to the right", which is not
what happens on a phone and is not a thing a thumb can aim at.
The row still works it is the tap handler that opens the
playlist picker so what goes is the arrow, not the item. */
.context-menu-panel[data-sheet] .submenu-arrow {
display: none;
}
.context-menu-panel {
background-color: var(
--yj-bg-elevated,
@@ -1,5 +1,6 @@
/**
* A hover affordance is gated on the device having hover.
* A hover affordance is gated on the device having hover in whichever
* direction keeps the action reachable.
*
* The home page's cover cards reveal a play button on :hover. A touch
* long-press synthesises a hover state in the WebView, so on a phone
@@ -7,6 +8,15 @@
* utils/long-press.ts is measuring for a context menu a control
* appearing because the user was reaching for a different one.
*
* #137 is the same sweep with the opposite answer for two of its three
* cases. Where the revealed control is the *only* route to its action,
* hiding it removes the action, so it is always visible where there is
* no hover: `track-details`'s cover-art overlay and remove, and
* `shortcut-capture`'s reset. The queue's per-row remove is the third,
* and is the redundant kind since #60 the row's context menu is a
* bottom sheet carrying "Remove from Queue" so it takes #68's
* treatment here.
*
* This is asserted against the *parsed stylesheet* rather than by
* emulating a touch device, and that is a limitation worth stating
* rather than hiding. CDP's Emulation.setEmulatedMedia does not reach
@@ -24,6 +34,9 @@
import { describe, expect, it } from 'vitest';
import '@components/home-view/home-view';
import '@components/queue-panel/queue-panel';
import '@components/track-details/track-details';
import '@components/config-page/shortcut-capture';
import { fixture } from '@test/support/render';
/** Every rule in the element's own adopted stylesheets, flattened. */
@@ -83,3 +96,91 @@ describe('the home card play button', () => {
expect(unconditional.some((r) => /display:\s*none/.test(r.text))).toBe(true);
});
});
describe("the queue row's remove button", () => {
it('is absent where the device has no hover, the menu carrying the action', async () => {
const el = await fixture('queue-panel', {});
const rules = rulesOf(el);
expect(rules.length).toBeGreaterThan(0);
// visibility:hidden alone would leave an invisible button holding
// its hit area on a phone, which is the trap #68's commit names.
const unconditional = rules.filter(
(r) => r.condition === null && r.text.startsWith('.remove-button'),
);
expect(unconditional.length).toBeGreaterThan(0);
expect(unconditional.some((r) => /display:\s*none/.test(r.text))).toBe(true);
const reveals = rules.filter(
(r) =>
r.text.includes('.remove-button') && /visibility:\s*visible/.test(r.text),
);
expect(reveals.length).toBeGreaterThan(0);
for (const rule of reveals) {
expect(rule.condition).toMatch(/hover:\s*hover/);
expect(rule.condition).toMatch(/pointer:\s*fine/);
}
});
});
/**
* The two affordances that are the only route to their action.
*
* Asserted as "there is a rule showing it, and its condition is a
* *negated* hover query" the same stylesheet reading as above, for
* the same reason: this tier's iframe cannot be emulated as a touch
* device, and the regression worth catching is someone folding the rule
* away as redundant on the desktop it does nothing on.
*/
describe('an affordance with no other route', () => {
const cases: Array<[string, string, string[]]> = [
['track-details', 'track-details', ['.cover-art-overlay', '.cover-art-remove']],
['shortcut-capture', 'shortcut-capture', ['.reset-btn']],
];
for (const [name, tag, selectors] of cases) {
it(`${name} shows it where the device has no hover`, async () => {
const el = await fixture(tag, {});
const rules = rulesOf(el);
expect(rules.length).toBeGreaterThan(0);
for (const selector of selectors) {
const shown = rules.filter(
(r) =>
r.condition !== null &&
r.text.includes(selector) &&
/opacity:\s*1/.test(r.text),
);
const touch = shown.filter((r) => /not[\s\S]*hover:\s*hover/.test(r.condition!));
expect(touch.length).toBeGreaterThan(0);
}
});
}
// The one half this tier can measure rather than read: the query is
// negated, so on the hover-capable browser running these tests the
// control must still be revealed by hover and by nothing else. A rule
// written without the `not` would show it here, permanently, on every
// desktop.
it('leaves the desktop reveal alone, where the device does have hover', async () => {
expect(matchMedia('(hover: hover)').matches).toBe(true);
const el = await fixture('shortcut-capture', {
action: 'player.next',
label: 'Next Track',
currentKey: 'X',
defaultKey: 'N',
});
const btn = el.shadowRoot?.querySelector('.reset-btn');
expect(btn).not.toBeNull();
expect(getComputedStyle(btn!).opacity).toBe('0');
});
});
@@ -0,0 +1,222 @@
/**
* Where a context menu is drawn (#60).
*
* **This file asserts the mechanism, not the symptom, and that is the
* whole point of it.** The defect is that on the reference device's
* Chrome 113 a `wa-popup` has no Popover API to promote it to the top
* layer, so it falls back to `position: fixed` and is then *clipped* by
* `.main-panel`'s `contain: paint`. No tier here can reproduce that:
* this runner's Chromium and CI's WebKit both have the Popover API, so
* the popup is top-layered and looks perfectly correct. A test that
* asserted "the menu is not clipped" would pass on the broken build.
*
* What is checkable everywhere is *which surface exists*. A native
* `<dialog>` uses the real top layer, which Chrome 37 has, so "it is a
* dialog at phone width" is the property that makes the device
* behaviour follow. The measurements that needed the hardware are on
* the PR and in `.planning/NOTES.md`.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import '@components/menu-surface/menu-surface';
import { MENU_DISMISS_EVENT } from '@components/menu-surface/menu-surface';
import { fixture } from '@test/support/render';
/** Every source file, as text. */
const SOURCES = import.meta.glob<string>('../../src/**/*.ts', {
eager: true,
query: '?raw',
import: 'default',
});
/**
* The two files allowed to render a raw `wa-popup`.
*
* `menu-surface` *is* the popup, in its desktop presentation.
* `job-indicator` is the documented exception and the contrast that
* proved the diagnosis: it lives in `.top-bar`, no ancestor of which
* has containment, so even the fixed fallback lands correctly on the
* device -- measured on #62, unclipped at every width.
*
* `now-playing`'s cover preview is the third, and it is a different
* reason: it is not a menu. It opens on `mouseenter` over the album
* art, so a touch device never sees it at all, and a bottom sheet for
* a hover preview would be absurd. It is also in the bottom bar rather
* than the main panel, so nothing clips it either.
*
* **Both were found by this sweep, not by the conversion**, which is
* the argument for having it: twelve call sites were converted by hand
* and two more existed.
*/
const MAY_USE_POPUP = [
'menu-surface/menu-surface.ts',
'jobs/job-indicator.ts',
'now-playing/now-playing.ts',
];
/**
* Answer `matchMedia` for the phone query, on `transport-context`'s
* pattern: what is under test is the component's reaction to the
* answer, not whether this runner's window can get below 600px.
*/
const realMatchMedia = window.matchMedia;
function pretendPhone(phone: boolean): void {
window.matchMedia = ((query: string) => ({
matches: phone && query.includes('599'),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
}
/** A surface with the panel a real call site slots into it. */
async function surfaceWithPanel(): Promise<HTMLElement> {
const el = await fixture('menu-surface');
el.innerHTML =
'<div class="context-menu-panel" role="menu" aria-label="Track actions">' +
'<wa-dropdown-item>Play</wa-dropdown-item>' +
'</div>';
const surface = el as unknown as HTMLElement & {
active: boolean;
updateComplete: Promise<unknown>;
};
surface.active = true;
await surface.updateComplete;
return el;
}
/**
* The sweep, in the spirit of `icon-language.test.ts` and
* `TestNoDirectRuntimeEmits`: the rule is about *every* call site, and
* checking one checks nothing.
*
* Twelve menus were converted by hand. A thirteenth written as a bare
* `<wa-popup>` would work perfectly in every tier here and be clipped
* on the device, which is exactly the failure this whole change is
* about and exactly the one no runtime assertion can see.
*/
describe('every menu goes through the one surface', () => {
it('reads the sources at all', () => {
// A sweep over an empty glob passes, so this is asserted first.
expect(Object.keys(SOURCES).length).toBeGreaterThan(100);
});
it('leaves no raw wa-popup outside the two files allowed one', () => {
const offenders = Object.entries(SOURCES)
.filter(([path]) => !MAY_USE_POPUP.some((ok) => path.endsWith(ok)))
.filter(([, src]) => src.includes('<wa-popup'))
.map(([path]) => path.replace(/^.*\/src\//, 'src/'));
expect(
offenders,
'these render a popup directly; use <menu-surface> so the phone gets a sheet',
).toEqual([]);
});
});
describe('menu-surface', () => {
afterEach(() => {
window.matchMedia = realMatchMedia;
});
describe('above the phone breakpoint', () => {
beforeEach(() => pretendPhone(false));
it('draws a popup, which is what the desktop has always had', async () => {
const el = await surfaceWithPanel();
expect(el.shadowRoot?.querySelector('wa-popup')).not.toBeNull();
expect(el.shadowRoot?.querySelector('wa-dialog')).toBeNull();
});
it('does not mark the panel as a sheet', async () => {
const el = await surfaceWithPanel();
expect(
el.querySelector('.context-menu-panel')?.hasAttribute('data-sheet'),
).toBe(false);
});
});
describe('at phone width', () => {
beforeEach(() => pretendPhone(true));
/**
* The load-bearing one. `wa-dialog` renders a *native* `<dialog>`,
* and it is the native element -- not the wrapper -- that gets the
* top layer and therefore escapes the paint containment that clips
* the popup on the device.
*/
it('draws a native dialog, which is what escapes the clip', async () => {
const el = await surfaceWithPanel();
const wrapper = el.shadowRoot?.querySelector('wa-dialog');
expect(wrapper, 'no wa-dialog at phone width').not.toBeNull();
expect(el.shadowRoot?.querySelector('wa-popup')).toBeNull();
await (wrapper as HTMLElement & { updateComplete: Promise<unknown> })
.updateComplete;
expect(
wrapper?.shadowRoot?.querySelector('dialog'),
'the wrapper is not backed by a native dialog',
).not.toBeNull();
});
/**
* The rows are sized by `contextMenuStyles`, which lives in the
* *host's* shadow root so the only thing this component can do is
* say which mode it is in. That attribute is the contract between
* the two, and it is what twelve call sites get their thumb-sized
* rows from.
*/
it('marks the panel as a sheet, which is what sizes the rows', async () => {
const el = await surfaceWithPanel();
expect(
el.querySelector('.context-menu-panel')?.hasAttribute('data-sheet'),
).toBe(true);
});
/**
* `wa-dialog` closes itself on Escape. Without this the controller
* would still believe the menu was open, and the *next* long-press
* would do nothing which is the failure mode that looks like the
* gesture breaking rather than the dialog.
*/
it('reports a dismissal it did not initiate', async () => {
const el = await surfaceWithPanel();
let dismissed = 0;
document.addEventListener(MENU_DISMISS_EVENT, () => {
dismissed += 1;
});
el.shadowRoot
?.querySelector('wa-dialog')
?.dispatchEvent(new CustomEvent('wa-hide', { bubbles: false }));
expect(dismissed, 'no menu-dismiss reached the document').toBe(1);
});
/**
* A dialog with no accessible name is what `utils/name-dialog.ts`
* exists for; here the name is already written on the panel, so no
* call site says it twice.
*/
it('names the sheet after the menu it contains', async () => {
const el = await surfaceWithPanel();
const wrapper = el.shadowRoot?.querySelector('wa-dialog');
expect(wrapper?.getAttribute('label')).toBe('Track actions');
});
});
});
@@ -0,0 +1,258 @@
/**
* The phone's progress line (#58).
*
* **What this tier can and cannot see.** It can see the whole of what
* the issue asks for that is not a pixel: that the line exists only on
* a phone and only with a track, that it renders the position the
* backend reported rather than a count of its own, and that it is
* neither announced nor touchable. It cannot see where it sits that
* is the shell's grid, and it is asserted in
* `e2e/specs/phone-progress-line.spec.ts` where there is a real bar
* with a real tab bar under it.
*/
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import '@components/audio-player/progress-line/progress-line';
import { Events } from '../../src/events';
import { emit, flush } from '@test/support/harness';
import { fixture, shadow } from '@test/support/render';
const TRACK = {
fileName: 'song.mp3',
filePath: '/music/song.mp3',
trackLength: 90,
seekPosition: 0,
state: 'playing',
title: 'Song',
artist: 'Artist',
album: 'Album',
coverArt: '',
coverArtSmall: '',
coverArtMedium: '',
coverArtLarge: '',
trackChangeId: 1,
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
/**
* Answer `matchMedia` for the phone query, since the runner's own
* window is whatever size the browser provider gives it. Stubbed rather
* than resized for `transport-context.test.ts`'s reason: what is under
* test is the component's reaction to the answer.
*/
const realMatchMedia = window.matchMedia;
function pretendPhone(phone: boolean): void {
window.matchMedia = ((query: string) => ({
matches: phone && query.includes('599'),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
}
/** The horizontal scale of the fill, or null if there is no line. */
function scale(el: Element): number | null {
const fill = shadow<HTMLElement>(el, '.fill');
if (!fill) return null;
const match = /scaleX\(([^)]+)\)/.exec(fill.style.transform);
return match ? Number(match[1]) : null;
}
describe('<player-progress-line>', () => {
beforeEach(() => {
emit(Events.TrackChanged, null);
emit(Events.PlaybackStateChanged, { state: 'stopped' });
});
afterEach(() => {
window.matchMedia = realMatchMedia;
vi.useRealTimers();
});
it('draws nothing above the phone breakpoint', async () => {
pretendPhone(false);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 2 });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 45,
trackLength: 90,
trackChangeId: 2,
seq: 1,
playing: true,
});
await flush();
await el.updateComplete;
// The desktop bar carries a real seek bar, and there is no tab
// bar for this to sit on the border of.
expect(el.shadowRoot!.querySelector('.track')).toBeNull();
});
it('draws nothing until there is a track', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
expect(el.shadowRoot!.querySelector('.track')).toBeNull();
});
it('renders the fraction the backend reported', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 3 });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 45,
trackLength: 90,
trackChangeId: 3,
seq: 1,
playing: true,
});
await flush();
await el.updateComplete;
expect(scale(el)).toBeCloseTo(0.5, 3);
});
it('resumes mid-track at the position the track arrived with', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, {
...TRACK,
seekPosition: 30,
trackChangeId: 4,
});
await flush();
await el.updateComplete;
expect(scale(el)).toBeCloseTo(1 / 3, 3);
});
it('interpolates between reports, and every report resets it', async () => {
pretendPhone(true);
vi.useFakeTimers();
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 5 });
emit(Events.PlaybackStateChanged, { state: 'playing' });
await vi.advanceTimersByTimeAsync(3000);
await el.updateComplete;
expect(scale(el)).toBeCloseTo(3 / 90, 3);
// The user seeks; the backend lands somewhere else and says so.
// The local count is discarded, never added to -- the seek
// bar's rule, and the reason it has it.
emit(Events.PlaybackPositionChanged, {
positionSeconds: 40,
trackLength: 90,
trackChangeId: 5,
seq: 2,
playing: true,
});
await vi.advanceTimersByTimeAsync(1000);
await el.updateComplete;
expect(scale(el)).toBeCloseTo(41 / 90, 3);
});
/*
* The reason this component asks `matchMedia` instead of letting a
* stylesheet hide it: a media query cannot stop a 1 Hz interval
* running for the life of every desktop session. That claim is
* load-bearing in CLAUDE.md, so it is asserted rather than
* described the timer count, because a desktop render is empty
* either way and so cannot tell the two apart.
*/
it('runs no interpolation timer above the breakpoint', async () => {
pretendPhone(false);
vi.useFakeTimers();
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 9 });
emit(Events.PlaybackStateChanged, { state: 'playing' });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 3,
trackLength: 90,
trackChangeId: 9,
seq: 9,
playing: true,
});
await vi.advanceTimersByTimeAsync(5000);
await el.updateComplete;
expect(scale(el)).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
it('ignores a report about a track that is no longer loaded', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 60,
trackLength: 90,
trackChangeId: 5,
seq: 3,
playing: true,
});
await flush();
await el.updateComplete;
// The store is a singleton, so a line mounting late must not
// adopt a report about the previous track.
expect(scale(el)).toBe(0);
});
it('counts nothing while the player is paused', async () => {
pretendPhone(true);
vi.useFakeTimers();
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 7 });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 10,
trackLength: 90,
trackChangeId: 7,
seq: 1,
playing: false,
});
emit(Events.PlaybackStateChanged, { state: 'paused' });
await vi.advanceTimersByTimeAsync(5000);
await el.updateComplete;
expect(scale(el)).toBeCloseTo(10 / 90, 3);
});
it('is decorative and cannot be touched', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 8 });
await flush();
await el.updateComplete;
// The seek bar on Now Playing is what announces the position;
// this says the same thing with no name and no way to act on
// it, and it sits exactly where a thumb aiming at a tab lands.
expect(el.getAttribute('aria-hidden')).toBe('true');
expect(getComputedStyle(el).pointerEvents).toBe('none');
});
});
@@ -29,8 +29,61 @@ import { shadow } from '@test/support/render';
const wrappers: HTMLElement[] = [];
let restoreMedia: (() => void) | null = null;
/**
* Answer the shell's phone query with `phone` until restored.
*
* Stubbed rather than emulated, for the reason `search-dialog.test.ts`
* gives: the runner's viewport is fixed at 1280x800, and the panel
* reads `matchMedia` in `connectedCallback` precisely so a test can
* answer it first.
*/
/**
* Answering the phone query is not enough on its own: what decides
* whether the scrim exists is a `change` listener, and a stub whose
* `addEventListener` is a no-op leaves that listener untested the
* whole suite stays green with it deleted. So the stub records the
* listeners and hands back a way to fire them.
*/
function stubPhone(phone: boolean): (next: boolean) => void {
const real = window.matchMedia.bind(window);
const listeners = new Set<(e: MediaQueryListEvent) => void>();
let matches = phone;
window.matchMedia = ((q: string) =>
q.includes('max-width: 599px')
? {
get matches() {
return matches;
},
media: q,
addEventListener(_: string, fn: (e: MediaQueryListEvent) => void) {
listeners.add(fn);
},
removeEventListener(_: string, fn: (e: MediaQueryListEvent) => void) {
listeners.delete(fn);
},
}
: real(q)) as typeof window.matchMedia;
restoreMedia = () => {
window.matchMedia = real;
};
return (next: boolean) => {
matches = next;
for (const fn of listeners) {
fn({ matches: next } as MediaQueryListEvent);
}
};
}
afterEach(() => {
for (const w of wrappers.splice(0)) w.remove();
restoreMedia?.();
restoreMedia = null;
});
/**
@@ -157,6 +210,78 @@ describe('the queue panel decides whether it can be a column', () => {
}
});
/**
* #171 the scrim is a dismissal target, so it exists only where it
* has pixels to be tapped.
*
* Below 600px `.panel-content` is `width: 100%`, so the scrim is
* entirely underneath an opaque panel: measured at 424x439, host,
* panel and scrim all 424x318. Drawing it there is a `cursor:
* pointer` click target nobody can reach, and the queue is a screen
* at that width anyway (#55) back and the close button are its ways
* out. Existence rather than `display: none`, because a hidden scrim
* is still an element carrying the handler.
*/
it('draws no scrim at phone width, where it would have no reachable pixels', async () => {
stubPhone(true);
const el = await panelIn(424);
expect(el.overlay).toBe(true);
expect(el.shadowRoot?.querySelector('.scrim')).toBeNull();
// The way out a thumb can hit is still there.
expect(
shadow(el, '[data-testid="queue-close"]')?.getAttribute('aria-label'),
).toBe('Close queue');
});
/**
* The 600899 band is where the panel is a 320px column of a wider
* content area, so the scrim has uncovered pixels and #24's
* tap-outside-to-close is real. Same width as the overlay tests
* above, with the phone query explicitly answered `false`, so this
* fails if the scrim is ever dropped for every overlay.
*/
it('keeps the scrim above phone width, where it can be tapped', async () => {
stubPhone(false);
const el = await panelIn(700);
expect(el.overlay).toBe(true);
shadow<HTMLElement>(el, '.scrim')?.click();
await el.updateComplete;
expect(el.open).toBe(false);
});
/**
* The scrim's existence comes from `matchMedia` rather than a
* stylesheet, which only holds up if the query is *listened* to a
* panel opened on a desktop and carried across the breakpoint (a
* resized window, an unfolded phone) has to lose its scrim without
* being reopened. Nothing else in this file fires `change`, so
* deleting the listener leaves the whole suite green.
*/
it('drops the scrim when the viewport crosses the breakpoint', async () => {
const setPhone = stubPhone(false);
const el = await panelIn(700);
expect(el.shadowRoot?.querySelector('.scrim')).not.toBeNull();
setPhone(true);
await el.updateComplete;
expect(el.shadowRoot?.querySelector('.scrim')).toBeNull();
setPhone(false);
await el.updateComplete;
expect(el.shadowRoot?.querySelector('.scrim')).not.toBeNull();
});
/**
* Escape belongs to the overlay, not to the queue. An inline panel is
* beside the content rather than over it, so there is nothing to
@@ -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,181 @@
/**
* The transport in its two contexts (#59, #56).
*
* `player-controls` is one component in two places, and what each place
* wants differs *at the same viewport*: on a phone the bottom bar wants
* three controls sized for a thumb, and `now-playing-view` wants five,
* larger still. So the host states the context and the viewport states
* the size band, and this file pins the half a media query cannot
* express.
*
* **What this tier can and cannot see.** It can see which buttons
* exist, because that is `matchMedia` and a render and existence is
* the whole of #59. It cannot see the *sizes*: those come from the
* context's custom properties, and a component-tier render has no shell
* around it, so the measurements live in `e2e/specs/phone-transport.spec.ts`
* where there is a real bar in a real viewport. Asserting a pixel here
* would be asserting the fallbacks, which is `ui-visual`'s documented
* blind spot one tier over.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import '@components/audio-player/controls/player-controls';
import { Events } from '../../src/events';
import { emit, flush, calls } from '@test/support/harness';
import { fixture, shadowAll, click } from '@test/support/render';
/** Reset the backend-owned state the component reads from. */
function idle(): void {
emit(Events.TrackChanged, null);
emit(Events.PlaybackStateChanged, { state: 'stopped' });
emit(Events.QueueModeChanged, { shuffleMode: false, repeatMode: 'off' });
}
const names = (el: Element): Array<string | null> =>
shadowAll(el, 'button').map((b) => b.getAttribute('aria-label'));
/**
* Answer `matchMedia` for the phone query, since the test runner's own
* window is whatever size the browser provider gives it.
*
* It is stubbed rather than resized because what is under test is the
* component's *reaction* to the answer, and a resize would additionally
* be asserting that this runner's viewport can get below 600px.
*/
const realMatchMedia = window.matchMedia;
function pretendPhone(phone: boolean): void {
window.matchMedia = ((query: string) => ({
matches: phone && query.includes('599'),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
}
afterEach(() => {
window.matchMedia = realMatchMedia;
});
describe('<player-controls> in the bar', () => {
beforeEach(() => {
idle();
});
it('keeps all five on a desktop, in the order it always had', async () => {
pretendPhone(false);
const el = await fixture('player-controls');
// Unchanged from before #59, deliberately: this is the desktop bar
// and nothing about it was reported.
expect(names(el)).toEqual([
'Shuffle',
'Previous track',
'Play',
'Next track',
'Repeat: off',
]);
});
it('draws three on a phone, and does not merely hide the other two', async () => {
pretendPhone(true);
const el = await fixture('player-controls');
expect(names(el)).toEqual(['Previous track', 'Play', 'Next track']);
// The distinction this asserts is the point. A `display: none`
// control is still in the shadow root, still something a positional
// query finds, and still a thing the component claims to have --
// so "the phone has three controls" would have been true of the
// pixels and false of the element.
expect(shadowAll(el, 'button')).toHaveLength(3);
});
it('follows the viewport when it changes, not just at construction', async () => {
pretendPhone(false);
const el = await fixture('player-controls');
expect(names(el)).toHaveLength(5);
// A desktop window dragged narrow is the phone layout, per plan
// 018's decision 4 -- so this is a real transition and not a
// hypothetical.
(el as unknown as { phone: boolean }).phone = true;
await flush();
await el.updateComplete;
expect(names(el)).toEqual(['Previous track', 'Play', 'Next track']);
});
});
describe('<player-controls> full-screen', () => {
beforeEach(() => {
idle();
});
it('keeps all five on a phone, where the bar keeps three', async () => {
pretendPhone(true);
const el = await fixture('player-controls');
el.setAttribute('context', 'full');
await el.updateComplete;
// The same viewport, the other answer: this is why the context is a
// property and cannot be a media query.
expect(names(el)).toHaveLength(5);
});
it('puts the secondary pair after the primary three, in the DOM', async () => {
pretendPhone(true);
const el = await fixture('player-controls');
el.setAttribute('context', 'full');
await el.updateComplete;
// Order, not just membership: the secondary controls are drawn on a
// second row, and this is asserted in the DOM because visual order
// and focus order have to agree. A CSS `order` property would move
// them on screen and leave Tab walking the old sequence.
expect(names(el)).toEqual([
'Previous track',
'Play',
'Next track',
'Shuffle',
'Repeat: off',
]);
});
it('still routes every button to the backend', async () => {
pretendPhone(true);
const el = await fixture('player-controls');
el.setAttribute('context', 'full');
await el.updateComplete;
// Two arrangements, one set of handlers. The regression this
// guards is the reason a second *component* was refused: a second
// template renders buttons wired to nothing, which looks perfect
// in a screenshot and does nothing at all.
for (const name of [
'Previous track',
'Next track',
'Shuffle',
'Repeat: off',
]) {
await click(el, `button[aria-label="${name}"]`);
}
expect(calls().map((c) => c.path)).toEqual([
'queue.Queue.Previous',
'queue.Queue.Next',
'queue.Queue.ToggleShuffle',
'queue.Queue.CycleRepeat',
]);
});
});
@@ -0,0 +1,93 @@
/**
* Who owns the volume, and what the control does when it is not us
* (#64).
*
* **This is the tier that can exercise the Android branch**, and it is
* the reason the predicate is a backend answer rather than a build tag
* the frontend cannot see: `SystemOwnsVolume` is a stub here, so the
* "no volume" rendering is checked on an ordinary Linux CI runner with
* no device anywhere. What no tier here can check is the *constant*
* behind it, which `TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform`
* sweeps the Go source for instead.
*
* It is a file of its own because `volumeStyleStore` asks once and
* latches the answer is a property of the binary and cannot change
* while the app runs, so there is deliberately no event that refreshes
* it. Vitest gives each file its own module registry, which is what
* lets the stub be in place before the singleton is first touched.
* The *available* case is the rest of `transport.test.ts`, which mounts
* the same element under the default stub.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/audio-player/volume-control/volume-control';
import '@components/now-playing-view/now-playing-view';
import { Events } from '../../src/events';
import { emit, resetHarness, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
import type { TrackInfo } from '@store/player-store';
const TRACK: TrackInfo = {
fileName: 'tideline.mp3',
filePath: '/music/tideline.mp3',
trackLength: 245,
seekPosition: 0,
state: 'playing',
title: 'Tideline',
artist: 'Sea Change',
album: 'Ebb',
coverArt: '',
coverArtSmall: '',
coverArtMedium: '',
coverArtLarge: '',
trackChangeId: 1,
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
describe('a platform whose volume we do not own', () => {
beforeEach(async () => {
resetHarness();
stub('player.Player.SystemOwnsVolume', true);
stub('config.Config.GetPopupVolume', false);
// The store latches on the first mount; do it here so every test
// below sees a settled answer rather than the first frame.
const warm = await fixture('volume-control');
await warm.updateComplete;
});
it('renders no control at all, and no empty shadow root to find', async () => {
const el = await fixture('volume-control');
await el.updateComplete;
// Both halves matter. An empty shadow root is what stops a
// positional or by-role query finding a button that cannot act;
// `hidden` is what stops the host taking a flex item's worth of
// space in the transport it sits in.
expect(shadowAll(el, 'button')).toHaveLength(0);
expect(shadowAll(el, 'wa-slider')).toHaveLength(0);
expect(el.hidden, 'the host is not hidden').toBe(true);
});
it('leaves the rest of the phone transport alone', async () => {
emit(Events.TrackChanged, TRACK);
const view = await fixture('now-playing-view');
await view.updateComplete;
// Seeking and the transport buttons are not volume, and #64 is
// allowed to remove one control, not to thin the screen out.
expect(shadow(view, 'seek-bar')).not.toBeNull();
expect(shadow(view, 'player-controls')).not.toBeNull();
const volume = shadow(view, 'volume-control') as HTMLElement | null;
expect(volume, 'the element is still mounted').not.toBeNull();
expect(volume!.hidden, 'a mounted volume-control is not hidden').toBe(true);
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* The nesting check's own semantics.
*
* `make css-check` runs it over a tree that currently has no violation,
* so the check passing says nothing about whether it can still find
* one. What it has to get right is two distinctions, and both are the
* kind a regex over the file gets wrong: a rule directly inside an
* at-rule is not nested, and a brace inside a string or a comment is
* not a block.
*
* The rule it enforces is the device's: Chrome 113 predates relaxed CSS
* nesting, so a nested selector starting with an element name is
* dropped in silence. See `scripts/css-nesting.mjs`.
*/
import { describe, expect, it } from 'vitest';
import { findBareNestedRules } from '../../scripts/css-nesting.mjs';
describe('the nested-rule check', () => {
it('flags a nested rule that starts with an element name', () => {
const found = findBareNestedRules(
'.bottom-bar {\n color: red;\n\n audio-player { margin: 0 }\n}',
);
expect(found).toEqual([{ line: 4, selector: 'audio-player' }]);
});
it('accepts the same rule written with a leading &', () => {
expect(
findBareNestedRules('.bottom-bar {\n & audio-player { margin: 0 }\n}'),
).toEqual([]);
});
it('accepts a nested selector that starts with any other symbol', () => {
expect(
findBareNestedRules('.bar {\n #track-info { color: red }\n}'),
).toEqual([]);
expect(findBareNestedRules('.bar {\n :host { color: red }\n}')).toEqual(
[],
);
});
it('leaves a top-level rule alone, element name or not', () => {
expect(findBareNestedRules('p {\n margin: 0;\n}')).toEqual([]);
});
/**
* The majority of what a naive sweep would report: a media query at
* the top level holds ordinary rules, not nested ones.
*/
it('leaves a rule directly inside an at-rule alone', () => {
expect(
findBareNestedRules(
'@media (max-width: 599px) {\n bottom-nav { display: flex }\n}',
),
).toEqual([]);
});
/**
* And the other half of that: what decides it is whether a style rule
* is anywhere above, not what the immediate parent is.
*/
it('flags one inside an at-rule that is itself inside a rule', () => {
expect(
findBareNestedRules(
'.bar {\n @media (min-width: 900px) {\n audio-player { margin: 0 }\n }\n}',
),
).toEqual([{ line: 3, selector: 'audio-player' }]);
});
it('reads through a brace in a string or a comment', () => {
expect(
findBareNestedRules('.a {\n background: url("x{y}");\n}'),
).toEqual([]);
expect(
findBareNestedRules('.a {\n /* audio-player { x: y } */\n}'),
).toEqual([]);
});
});
+9
View File
@@ -58,6 +58,15 @@ pre-commit:
root: "frontend/"
run: node scripts/check-css-literals.mjs
# A nested rule whose selector starts with an element name is
# silently dropped by the device's Chrome 113, and by nothing else --
# so every tier here renders it correctly and only a screenshot of
# the phone disagrees. Instant.
css-nesting:
glob: "frontend/**/*.{ts,css}"
root: "frontend/"
run: node scripts/check-css-nesting.mjs
# Deliberately sequential, unlike pre-commit. `go test -race`
# saturates every core for the better part of a minute and the UI tier
# is a real browser with wall-clock timeouts, so run together the
+18
View File
@@ -0,0 +1,18 @@
//go:build android
package main
import (
"log/slog"
"yellowjacket/backend/androidlog"
)
// newLogHandler builds the handler slog writes through.
//
// On Android stdout is /dev/null, so devslog here writes the app's
// entire diagnosis into a hole -- see backend/androidlog. logcat is
// the platform's sink and this is what reaches it.
func newLogHandler(opts *slog.HandlerOptions) slog.Handler {
return androidlog.New(opts)
}
+20
View File
@@ -0,0 +1,20 @@
//go:build !android
package main
import (
"log/slog"
"os"
"github.com/golang-cz/devslog"
)
// newLogHandler builds the handler slog writes through.
//
// Off Android that is devslog to stdout, as it has always been. The
// selection is a build tag rather than a runtime check so that a
// desktop binary links no cgo for a platform it will never run on --
// backend/androidlog's write is -llog, which does not exist here.
func newLogHandler(opts *slog.HandlerOptions) slog.Handler {
return devslog.NewHandler(os.Stdout, &devslog.Options{HandlerOptions: opts})
}
+14 -6
View File
@@ -8,7 +8,6 @@ import (
"strings"
"sync/atomic"
"github.com/golang-cz/devslog"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
@@ -110,14 +109,23 @@ func main() {
// create sLogger
loglevel := resolveLogLevel(isDev)
sLogger := slog.New(devslog.NewHandler(os.Stdout, &devslog.Options{
HandlerOptions: &slog.HandlerOptions{
Level: loglevel,
},
}))
sLogger := slog.New(newLogHandler(&slog.HandlerOptions{Level: loglevel}))
slog.SetDefault(sLogger)
sLogger.Info("starting yellowjacket", "version", version, "commit", commit)
// Android has no /tmp and gives an app no TMPDIR, so anything in
// this process that spills to a temporary file is handed a path that
// does not exist -- see system.UseTempDir. It runs here rather than
// beside UseHomeOverride above because it has something to say when
// it fails and the logger does not exist up there; what matters is
// that it is before NewYellowJacketApp, which opens the database.
//
// A failure is not fatal: it leaves the platform's own answer in
// place, which is what every release before this one ran with.
if err := system.UseTempDir(application.Mobile.StoragePath()); err != nil {
sLogger.Error("could not set up a temp directory", "err", err.Error())
}
// Start profiling server (pprof + trace). In production builds this
// is a no-op — the compiler eliminates all profiling code.
stopProfiler := profiling.Start(sLogger)
+7 -1
View File
@@ -276,8 +276,14 @@ cmd_logs() {
# The app's own tags plus the two that report its death. Chasing a
# raw logcat here is hopeless: the emulator emits thousands of lines
# a second, almost all of them WindowManager transitions.
#
# `yellowjacket` is where the Go side's slog goes (backend/androidlog,
# #160). It is a fixed tag rather than "$PKG", which is the whole
# point of it being fixed: the debug build's id carries a ".dev"
# suffix, so a tag derived from the id would be filtered out on the
# one build anybody debugging this app is running.
"$ADB" logcat -v time \
WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F ActivityManager:I '*:S'
yellowjacket:V WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F ActivityManager:I '*:S'
}
# Forward the WebView's devtools socket, so the page can be asked things.