Android: a Now Playing that survives a 439px screen, and the audit behind it #188

Merged
logan merged 7 commits from 51-android-small-screens into main 2026-08-21 20:19:46 +00:00
Collaborator

Closes the last open item in #73's Phase 3. #51 asks for two things — an
audit of every view at ~424x439, and a Now Playing layout that survives
it — and they had different answers: the audit found no reachability
failure, and Now Playing had a defect underneath the design question it
was supposed to settle.

Worked on the attached Light Phone III (TLP301, Android 14 / SDK 34,
arm64, WebView Chrome 113, 424x439 CSS px) with a real 1,577-track
library scanned from the device.

Commits

commit issue what
75a24f9 fix(player) #51 the art is square at every height; below 500px it and the names share a row
dee176c test(player) #51 eight e2e assertions across four viewports
dd76bd2 docs(player) #51 CLAUDE.md's model, NOTES' measurements, the audit's null result
99a4540 docs(player) #51 corrects the audit's scope, and the probe's own false positives
ce9951b test(player) #51 replaces a floor CI cannot assert with the mechanism it stood for
6a22601 docs(player) #51 why that number differed between here and CI

The audit, and its null result

Every view at 424x439 — the ten primary ones, the queue,
album-details, artist-details, Downloads and Autotag — walked with a
probe that descends open shadow roots.

  • documentElement.scrollWidth is 424 against a 424 viewport on every
    view
    — no horizontal overflow anywhere.
  • No control is stranded: nothing sits outside the viewport without
    a scrollable ancestor that can bring it back.
  • A hit test at each control's centre reaches the control — nothing
    is occluded in place.

So plan 018's promise holds and the width work of #57, #62, #55 and #59
is intact. What was left was vertical, and it was this screen. Two
things the audit did find are filed rather than fixed here: #186
(every control that is not the transport is under the 44px floor — 18
of them measured, from search-trigger at 40x40, which exists only
on a phone, down to Autotag's 15px section toggle; artist-details'
back button is 32x32, which is #55's "the way out is 44px" wearing the
same mistake) and #187 (the seek bar's drag target is 6px).

The art was a crop, not a small 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 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. That is what #172's "39px of art" looked
like; the missing half of that measurement is that the 39px were 263
wide.

And it was never only the phone. The leftover exceeds the width only
above ~843px of viewport, so every height from ~500 to ~843 — most
phones, any short window — drew one too. The spec fails on the old build
at 424x439, 390x700 and 900x500, and passes at 412x869, which is the
boundary landing where the arithmetic says it should.

The rule was chosen by measuring Chrome 113 itself — a probe shadow
root at column heights of 288, 300, 451, 600 and 800 — rather than by
reasoning about the spec. max-width/max-height: 100% with
width/height: auto is square at all five.

The placeholder needed its own rule and would have shipped broken:
with no intrinsic size auto/auto collapses it to its icon (13x58),
a flex item's automatic minimum is its content so min-width: 0 is
load-bearing exactly when a job band shortens the row, and a
non-replaced box cannot express "the largest square that fits" at all —
it went 380x484 on a tall phone until max-height: calc(100vw - 2rem) bounded it.

The reflow

The stacked budget is fixed — 48px header, 143px transport since #64,
78px names, 68px padding and gaps — so the art gets height - 386, or
53px at 439. #172 handed over two options; a floor on the art scrolls
the transport off the bottom, and controls never scroll off is #51's
own Direction. 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 → 143px, measured on the shipped build on the device, with
scrollHeight === clientHeight and the transport unmoved.

500 is where the two layouts cross, not a round number: in a row the art
is height - 296 and the names hold 176px at exactly 500; stacked, the
art is height - 386 and passes 176px at 562. Keyed on height alone
because it answers vertical room — a 900x450 window has the same problem.

Verification

Everything below was run on this branch.

  • make e2e236 passed, whole suite, not just the new file.
  • make ui-test — 1007 passed. make lint, make test (all three tag
    sets), tsc --noEmit in both frontend/ and e2e/ — clean.
  • make css-check, make bindings-check, make skill-check — clean.
    css-check earned its place: the first draft put backticks in a CSS
    comment and it caught the broken literal.
  • The specs bite. Reverted the component to 327785e and re-ran:
    5 of 8 fail, with art is 263x39, not square, 358x315,
    300x36, the names are not to the right of the art, and there is no row to fill.
  • Three assertions cannot fail on the old build and are labelled as
    guards in the file: the transport not scrolling off (the old build
    shrank the art instead, so it did not scroll either), a tall phone
    keeping its column, and the art being square at 412x869 — which is
    the boundary, and passing there on both builds is the arithmetic
    checking out rather than a weak test.

A first CI run failed and is worth reading rather than hiding. Run
17462 was green on check and failed one test on both browsers,
identically: a floor of 100px on the art's height, measured at 64.
It passes locally at 114. Both numbers are honest and neither is about
this change — 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. That assertion now states the mechanism
instead (ce9951b), and the device numbers stay on #51 where they were
measured. It was the second draft of that one assertion to be thrown
away; the first passed on the defect, because its subtraction goes
negative exactly when the names are taller than the art.

The same run is the WebKit signal, and it is good news. E2E — webkit ran (it has silently skipped before) and reported 235 passed,
1 failed
— the same single test, the same 64px. So every squareness
and arrangement assertion passes in WebKit as well as Chromium, which
is what there was to learn: these are ordinary CSS sizing rules and the
two engines agree on them.

Formalities, run and green but not touched by a frontend-only change:
make lint, make test, make bindings-check, make skill-check.
make release-dry says nothing publishes from a branch, and
release.yml is triggered by hand in any case.

What no tier here can see is the device's engine — CI's Chromium and
WebKit are current, and #60 showed what that costs. Nothing in the spec
depends on Chrome 113 behaviour; the sizing rules were chosen by
measuring that engine directly, at five column heights, and the numbers
are on #51. make e2e is chromium-only on this machine (Arch cannot run
Playwright's Linux WebKit), so WebKit is CI's to report.

Not verified: a Pixel 7. #51 names it and none was attached, so what
is checked at 412x869 is the layout that viewport should get, not that
device. It is also the case where the placeholder needed its second
bound, so it is the one most worth re-checking if one is ever plugged in.

Closes #51

Closes the last open item in #73's Phase 3. #51 asks for two things — an audit of every view at ~424x439, and a Now Playing layout that survives it — and they had different answers: the audit found no reachability failure, and Now Playing had a defect underneath the design question it was supposed to settle. Worked on the attached **Light Phone III** (TLP301, Android 14 / SDK 34, arm64, WebView **Chrome 113**, 424x439 CSS px) with a real 1,577-track library scanned from the device. ## Commits | commit | issue | what | |---|---|---| | `75a24f9` `fix(player)` | #51 | the art is square at every height; below 500px it and the names share a row | | `dee176c` `test(player)` | #51 | eight e2e assertions across four viewports | | `dd76bd2` `docs(player)` | #51 | CLAUDE.md's model, NOTES' measurements, the audit's null result | | `99a4540` `docs(player)` | #51 | corrects the audit's scope, and the probe's own false positives | | `ce9951b` `test(player)` | #51 | replaces a floor CI cannot assert with the mechanism it stood for | | `6a22601` `docs(player)` | #51 | why that number differed between here and CI | ## The audit, and its null result Every view at 424x439 — the ten primary ones, the queue, `album-details`, `artist-details`, Downloads and Autotag — walked with a probe that descends open shadow roots. - `documentElement.scrollWidth` is **424 against a 424 viewport on every view** — no horizontal overflow anywhere. - **No control is stranded**: nothing sits outside the viewport without a scrollable ancestor that can bring it back. - A **hit test at each control's centre reaches the control** — nothing is occluded in place. So plan 018's promise holds and the width work of #57, #62, #55 and #59 is intact. What was left was vertical, and it was this screen. Two things the audit *did* find are filed rather than fixed here: **#186** (every control that is not the transport is under the 44px floor — 18 of them measured, from `search-trigger` at 40x40, which exists *only* on a phone, down to Autotag's 15px section toggle; `artist-details`' back button is 32x32, which is #55's "the way out is 44px" wearing the same mistake) and **#187** (the seek bar's drag target is 6px). ## The art was a crop, not a small 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 `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. That is what #172's "39px of art" looked like; the missing half of that measurement is that the 39px were 263 wide. **And it was never only the phone.** The leftover exceeds the width only above ~843px of viewport, so every height from ~500 to ~843 — most phones, any short window — drew one too. The spec fails on the old build at 424x439, 390x700 and 900x500, and passes at 412x869, which is the boundary landing where the arithmetic says it should. The rule was chosen by measuring **Chrome 113 itself** — a probe shadow root at column heights of 288, 300, 451, 600 and 800 — rather than by reasoning about the spec. `max-width`/`max-height: 100%` with `width`/`height: auto` is square at all five. The placeholder needed its own rule and would have shipped broken: with no intrinsic size auto/auto collapses it to its icon (**13x58**), a flex item's automatic minimum is its content so `min-width: 0` is load-bearing exactly when a job band shortens the row, and a non-replaced box cannot express "the largest square that fits" at all — it went **380x484** on a tall phone until `max-height: calc(100vw - 2rem)` bounded it. ## The reflow The stacked budget is fixed — 48px header, 143px transport since #64, 78px names, 68px padding and gaps — so the art gets `height - 386`, or 53px at 439. #172 handed over two options; a floor on the art scrolls the transport off the bottom, and *controls never scroll off* is #51's own Direction. 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 → 143px**, measured on the shipped build on the device, with `scrollHeight === clientHeight` and the transport unmoved. 500 is where the two layouts cross, not a round number: in a row the art is `height - 296` and the names hold 176px at exactly 500; stacked, the art is `height - 386` and passes 176px at 562. Keyed on height alone because it answers vertical room — a 900x450 window has the same problem. ## Verification Everything below was run on this branch. - `make e2e` — **236 passed**, whole suite, not just the new file. - `make ui-test` — 1007 passed. `make lint`, `make test` (all three tag sets), `tsc --noEmit` in both `frontend/` and `e2e/` — clean. - `make css-check`, `make bindings-check`, `make skill-check` — clean. `css-check` earned its place: the first draft put backticks in a CSS comment and it caught the broken literal. - **The specs bite.** Reverted the component to `327785e` and re-ran: **5 of 8 fail**, with `art is 263x39, not square`, `358x315`, `300x36`, `the names are not to the right of the art`, and `there is no row to fill`. - Three assertions **cannot** fail on the old build and are labelled as guards in the file: the transport not scrolling off (the old build shrank the art instead, so it did not scroll either), a tall phone keeping its column, and the art being square at 412x869 — which is the boundary, and passing there on both builds is the arithmetic checking out rather than a weak test. **A first CI run failed and is worth reading rather than hiding.** Run 17462 was green on `check` and failed one test on **both** browsers, identically: a floor of 100px on the art's height, measured at **64**. It passes locally at 114. Both numbers are honest and neither is about this change — 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. That assertion now states the mechanism instead (`ce9951b`), and the device numbers stay on #51 where they were measured. It was the second draft of that one assertion to be thrown away; the first passed on the defect, because its subtraction goes negative exactly when the names are taller than the art. **The same run is the WebKit signal, and it is good news.** `E2E — webkit` ran (it has silently skipped before) and reported **235 passed, 1 failed** — the same single test, the same 64px. So every squareness and arrangement assertion passes in WebKit as well as Chromium, which is what there was to learn: these are ordinary CSS sizing rules and the two engines agree on them. Formalities, run and green but not touched by a frontend-only change: `make lint`, `make test`, `make bindings-check`, `make skill-check`. `make release-dry` says nothing publishes from a branch, and `release.yml` is triggered by hand in any case. **What no tier here can see** is the device's engine — CI's Chromium and WebKit are current, and #60 showed what that costs. Nothing in the spec depends on Chrome 113 behaviour; the sizing rules were chosen by measuring that engine directly, at five column heights, and the numbers are on #51. `make e2e` is chromium-only on this machine (Arch cannot run Playwright's Linux WebKit), so WebKit is CI's to report. Not verified: a **Pixel 7**. #51 names it and none was attached, so what is checked at 412x869 is the layout that viewport *should* get, not that device. It is also the case where the placeholder needed its second bound, so it is the one most worth re-checking if one is ever plugged in. Closes #51
logan added 3 commits 2026-08-21 19:43:24 +00:00
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.
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.
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
dd76bd2fa7
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
logan added 3 commits 2026-08-21 19:56:08 +00:00
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.
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.
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
6a22601af7
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.
logan added 1 commit 2026-08-21 20:06:31 +00:00
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
f31331c83b
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.
logan merged commit 7eb55bd378 into main 2026-08-21 20:19:46 +00:00
Sign in to join this conversation.