Compare commits

..
Author SHA1 Message Date
logan f31331c83b docs(skill): what a fresh install is doing before you measure it
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 9m32s
Three things about a fresh install cost a measurement each, and none of
them was written down: it downloads the real catalog, so job-band is
103px of a 439px screen and every vertical number is wrong; stopping
that build returns cleanly and it **starts again within seconds**, so it
has to be stopped immediately before a measurement rather than once at
the start; and a library added over the bridge does not dismiss the
first-run wizard, which then sits over whatever you are looking at with
a correctly disabled button, reading exactly like a swallowed tap.

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

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

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

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

Also records the two false positives the probe produced before it was
right, since the next audit will write the same two checks: "painted
outside the viewport" flags a horizontally scrolling carousel, so the
question is whether a scrollable ancestor can bring it back; and a hit
test at a control's centre flags everything below the fold in a scroll
container.
2026-08-21 15:46:50 -04:00
3 changed files with 111 additions and 19 deletions
@@ -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 app.yellowjacket.dev android.permission.READ_MEDIA_AUDIO` (and
`POST_NOTIFICATIONS`) ahead of the launch skips it. `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 ### Calling a binding on the device
**The runtime call does not go over HTTP on Android**, and this is worth **The runtime call does not go over HTTP on Android**, and this is worth
+22 -1
View File
@@ -4867,12 +4867,33 @@ target is 6px tall.
**What the audit did not find is a reachability failure**, which is **What the audit did not find is a reachability failure**, which is
worth recording because it is the promise plan 018 makes. At 424x439, worth recording because it is the promise plan 018 makes. At 424x439,
on all ten primary views plus the queue, `documentElement.scrollWidth` 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 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. 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 The width work of #57, #62, #55 and #59 holds; what was left was
vertical, and it was this screen. 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) ## 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. Recorded because both are in `android-tier.md` and I met them anyway.
+34 -18
View File
@@ -107,11 +107,19 @@ async function openNowPlaying(page: Page): Promise<void> {
const el = v?.shadowRoot?.querySelector('.art img, .art .placeholder'); const el = v?.shadowRoot?.querySelector('.art img, .art .placeholder');
const t = v?.shadowRoot?.querySelector('.transport'); const t = v?.shadowRoot?.querySelector('.transport');
if (!stack || !el || !t) return false; if (!el || !t) return false;
const dir = getComputedStyle(stack).flexDirection; // 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; if (dir !== (row ? 'row' : 'column')) return false;
}
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
@@ -152,6 +160,7 @@ async function boxes(page: Page) {
// Whichever of the two the track has; both carry the sizing. // Whichever of the two the track has; both carry the sizing.
art: rect('.art img') ?? rect('.art .placeholder'), art: rect('.art img') ?? rect('.art .placeholder'),
artBox: rect('.art'), artBox: rect('.art'),
stack: rect('.stack'),
meta: rect('.meta'), meta: rect('.meta'),
transport: rect('.transport'), transport: rect('.transport'),
scrollHeight: v.scrollHeight, scrollHeight: v.scrollHeight,
@@ -239,29 +248,36 @@ test.describe('Now Playing survives a short screen', () => {
}); });
/** /**
* The reflow is only worth having if it buys something, and the * What the reflow actually does, stated as a mechanism rather than
* honest form of that is a floor on the device's own viewport * as a number: in a row the art is bounded by the row's *height*,
* rather than a comparison with a layout that is no longer there. * so it fills it — where in a column it is the leftover after the
* names, which is what made it 53px.
* *
* The first draft compared the art against the column's leftover * **The pixel count is deliberately not asserted here.** Two drafts
* computed from the boxes on screen, and it passed on the build * tried. The first compared the art against the column's leftover
* before this change as well -- the subtraction goes negative when * computed from the boxes on screen and passed on the broken build,
* the names are taller than the art, which is precisely the broken * because the subtraction goes negative when the names are taller
* state. A test that cannot fail on the defect is not evidence. * 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.
* *
* 100 is a floor, not the measurement: the device draws 143 and CI's * The device numbers — 53px to 143px — are on #51, measured there,
* chrome differs by whatever the volume control adds, so pinning the * which is the only tier that can honestly produce them.
* exact number would make this a test about the runner.
*/ */
test('gives the art a real size on the reference device', async ({ app }) => { test('fills the row with the art rather than the leftover', async ({ app }) => {
await app.setViewportSize(DEVICE); await app.setViewportSize(DEVICE);
await openNowPlaying(app); await openNowPlaying(app);
const b = await boxes(app); const b = await boxes(app);
expect(b!.stack, 'there is no row to fill').not.toBeNull();
expect( expect(
b!.art!.height, Math.abs(b!.artBox!.height - b!.stack!.height),
'the art is still a sliver at the size #51 is about', 'the art does not fill the row, so it is still a leftover',
).toBeGreaterThan(100); ).toBeLessThanOrEqual(1);
}); });
}); });