Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f31331c83b | ||
|
|
6a22601af7 | ||
|
|
ce9951b93a | ||
|
|
99a45401c7 | ||
|
|
dd76bd2fa7 | ||
|
|
dee176c0f7 | ||
|
|
75a24f98b6 | ||
|
|
327785e5ec | ||
|
|
7ba5d321f6 | ||
|
|
f126dd7397 | ||
|
|
510d3470f9 | ||
|
|
d78830aa52 | ||
|
|
a72d1f68ed | ||
|
|
60f1c5a6b2 | ||
|
|
11ba7b3180 | ||
|
|
7f8e185d7c | ||
|
|
42483c4b61 |
@@ -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:
|
||||
|
||||
@@ -524,6 +524,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
|
||||
|
||||
@@ -4785,3 +4785,130 @@ 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.
|
||||
|
||||
@@ -1462,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**:
|
||||
@@ -2033,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 600–899 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
|
||||
@@ -2067,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
|
||||
@@ -3496,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -179,8 +179,8 @@ test.describe('the queue is a screen where it covers the content', () => {
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 }) => {
|
||||
@@ -194,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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,38 +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.
|
||||
.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.
|
||||
|
||||
**max-height is what actually keeps that promise**, and
|
||||
it was missing. With a definite width and
|
||||
a 1:1 aspect-ratio the height is *derived from the width*
|
||||
and is bounded by nothing: at the reference device's
|
||||
424x439 that is a 263px square (60vh) in a box with far
|
||||
less than 263px left, so the art overflowed its own
|
||||
centred flex item and drew over the header above and the
|
||||
title below it. The comment claimed this was handled;
|
||||
60vh is a bound on the *viewport*, not on the room left
|
||||
over, and those differ by however much chrome is above
|
||||
and below.
|
||||
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.
|
||||
|
||||
Pre-existing -- screenshotted on main -- and made acute
|
||||
by #56, which gives the transport 95px more than it had.
|
||||
Found by reading a screenshot, which is the only tier
|
||||
that can see it: nothing fails, nothing overflows the
|
||||
*shell*, and every control is still hittable. */
|
||||
width: min(100%, 60vh);
|
||||
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;
|
||||
@@ -232,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() {
|
||||
@@ -283,55 +401,57 @@ 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">
|
||||
|
||||
@@ -13,6 +13,7 @@ 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,
|
||||
@@ -120,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;
|
||||
|
||||
@@ -391,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;
|
||||
@@ -414,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
|
||||
@@ -639,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 {
|
||||
@@ -828,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(),
|
||||
@@ -870,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',
|
||||
@@ -936,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.
|
||||
@@ -1972,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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 600–899 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,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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user