android: the phone turned sideways — the player beside itself, the chrome cut down

The phone UI was drawn portrait-first and had never been looked at in
landscape. The player sheet was the casualty: its cover is a square sized by
width, so at 915×411 it came out 879px tall and the title, the scrubber, the
transport and the face buttons all sat below the bottom of a sheet that does
not scroll — a corner of the artwork and no way to pause. Sideways the sheet
is two columns now, the cover sized by height beside the controls, with a
lyrics or queue face taking the cover's column; the nav and the mini bar come
down to 48 and 60dp, the artist hero becomes the banner the desktop's hero has
always been rather than 300px of a 411px screen, the record head puts the
cover beside the title, and the shell reads the side insets the cutout and the
gesture pill take when the phone is on its side. The walk turns the phone over
and back on its own: `{ view: [w, h] }` is a step kind now, and the steps
assert what used to be off the screen.
This commit is contained in:
KeelrLeek committed 2026-09-14 00:14:23 -05:00
1 parent aff29aba5c
commit 9df162360e
5 files changed
+228 -3

No files matched your search

+39 -2
View File
@@ -68,6 +68,37 @@ transform (the small art flies to the big one). The player's scrubber is the
lane itself, no knob. Motion is Material 3 Expressive's springs, sampled into
`linear()`. Reduce motion (You → Appearance, or the OS setting) turns it off.
The activity declares no `screenOrientation` and handles `orientation` and
`screenSize` itself, so turning the phone over is a resize and nothing else —
the audio thread, the stack and the screen's scroll all survive it, and the
layout is the only thing that has to answer. Two media queries do that. The
player sheet is the one surface whose portrait shape cannot survive a wide
viewport: its artwork is a square sized by WIDTH, so at 915×411 the cover came
out 879px tall and the title, the lane, the transport and the face buttons sat
below the bottom of a sheet that does not scroll — a corner of the cover and no
way to pause. Sideways it is two columns: the head across the top, the cover
down the left sized by HEIGHT, and the track, the scrubber, the transport and
the faces stacked on the right. With lyrics or the queue up, the face takes the
cover's column and the thumbnail goes — the record is already the ground behind
the whole sheet. That is an aspect problem rather than a height one, so the rule
keys off `orientation: landscape` alone and a tablet gets the same two columns.
The rest is a phone-sized query (`max-height: 560px`). The nav comes down to
Android's own 48dp minimum and the mini bar to a 60dp row over the same lane
strip, which gives the page back 22 of a 411-pixel screen — the chrome was 46%
of the height, it is 40%. The artist hero stops being a 300px portrait, which
covered its own Play button with the mini bar, and becomes a 190px banner: what
the desktop's hero has always been. The record head puts the cover beside the
title rather than above it, again the desktop's own shape, so Play is in view
without a scroll. A half sheet may take all but 36px of the height instead of
76% of it. Everything else — the grids, the rails, the artwork viewer, the
tap-to-sync room, the chat composer — already answered to width and was left
alone. `viewport-fit=cover` means the cutout and the gesture pill come in from
the SIDES in landscape, so the shell reads `--inset-left` / `--inset-right`
beside the top and bottom ones now. Those rules are appended at the end of
`shell.css` on purpose: they have to come after `.gbar`'s padding, `.mini`'s
and `.toast`'s left/right to win at equal specificity.
## Search and the resolver
Search is the desktop's top bar: the field asks the **catalog** once the
@@ -631,14 +662,20 @@ a catalog card's and the record page's sheets → the now-playing Save →
a peer's sheet, a raw file's sheet, a user's shares browsed and a folder
of theirs streamed → a buddy's sheet → You (every switch and chip saved
and read back from the config) → recent searches and a spelling
suggestion → the sleep timer, asserts what each step should show, screenshots every step into `tests/mobile/out/`, and fails on
suggestion → the sleep timer → the phone turned sideways (the player's three faces, a
record with a long sheet over it, an artist page, and the side insets faked,
all at 915×411) and back, asserts what each step should show, screenshots every step into `tests/mobile/out/`, and fails on
any exception or console error. The mock answers every command the phone
uses; one it does not know is named in the report (`[mock] no mock for X`)
rather than surfacing as a TypeError toast, console warnings carry their top
frames (the smoke bundle is built unminified for that), and every probe
carries the history mirror — the entry's depth against the layers the app
says the back gesture can peel (`hd`/`hwant`; a drift is how the blank
page after a confirmed delete was found).
page after a confirmed delete was found). A mock step can also set the
viewport — `{ view: [915, 411] }` sends `Emulation.setDeviceMetricsOverride`
through the same client, which is how the walk turns the phone over and back;
the phone walk refuses one, because a real device's orientation is not the
harness's to set.
The phone walk is the same driver on the real thing (plus a step kind of
its own: `{ adb: [...] }` runs an adb command — the share-sheet intent, a
+1 -1
View File
@@ -141,7 +141,7 @@
<div class="amb-tint"></div>
<div class="grain"></div>
</div>
<div class="np">
<div class="np" class:small>
<div class="np-head">
<button class="iconbtn" onclick={closeSheet} aria-label="Close"><Icon name="chevron-down" size={24} /></button>
<button class="np-ctx" onclick={goAlbum} aria-label="Go to the record">
+4
View File
@@ -57,6 +57,10 @@
/* Android's own insets: the status bar above, the gesture handle below. */
--inset-top: env(safe-area-inset-top, 0px);
--inset-bottom: env(safe-area-inset-bottom, 0px);
/* landscape moves the cutout and the gesture pill to the SIDES, and
viewport-fit=cover puts our content under both */
--inset-left: env(safe-area-inset-left, 0px);
--inset-right: env(safe-area-inset-right, 0px);
--top-h: calc(var(--bar-h) + var(--inset-top));
/* ── motion: real springs, sampled ────────────────────────────────────
+92
View File
@@ -1415,3 +1415,95 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
.sy-foot .iconbtn { width: auto; min-width: var(--tap); padding: 0 12px; border-radius: var(--r-pill); font-size: 13px; font-weight: 750; }
.sy-foot .iconbtn:disabled { opacity: 0.4; }
.sy-save { flex: 1; }
/* ── the phone turned sideways ────────────────────────────────────────────
The activity declares no screenOrientation and handles orientation and
screenSize itself, so a rotation is a RESIZE: no state is lost and the
layout is the only thing that has to answer. Two queries do it. The player
sheet is the surface whose portrait shape cannot survive a wide viewport —
its artwork is a square sized by WIDTH, so at 915x411 the cover came out
879px tall and the title, the lane, the transport and the face buttons all
sat below the bottom of a sheet that does not scroll. That is aspect, not
height, so its rule keys off orientation alone and a tablet gets it too;
everything under it is a phone-sized query.
These rules are appended, and the order is load-bearing: the side-inset
longhands below have to come after .gbar's padding shorthand, .mini's
left/right and .toast's left/right to win at equal specificity. */
/* the side insets: the cutout and the gesture pill, from the edges */
.gbar { padding-left: calc(6px + var(--inset-left)); padding-right: calc(8px + var(--inset-right)); }
.nav { padding-left: var(--inset-left); padding-right: var(--inset-right); }
.mini { left: calc(8px + var(--inset-left)); right: calc(8px + var(--inset-right)); }
.screen { padding-left: var(--inset-left); padding-right: var(--inset-right); }
.np { padding-left: var(--inset-left); padding-right: var(--inset-right); }
.halfsheet { padding-left: var(--inset-left); padding-right: var(--inset-right); }
.syncroom { padding-left: var(--inset-left); padding-right: var(--inset-right); }
.toast { left: calc(12px + var(--inset-left)); right: calc(12px + var(--inset-right)); }
/* ── the player sheet, beside itself ─────────────────────────────────── */
@media (orientation: landscape) {
.np {
display: grid;
grid-template-columns: minmax(0, 46%) minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) auto auto auto auto;
grid-template-areas:
"head head"
"left slack"
"left meta"
"left scrub"
"left trans"
"left foot";
column-gap: 6px;
/* insurance for a viewport shorter than the controls themselves —
the keyboard up on a small phone — where the sheet would clip */
overflow-y: auto;
overscroll-behavior: contain;
}
.np-head { grid-area: head; }
.np-art { grid-area: left; padding: 10px 4px 10px var(--pad); }
/* the square is sized by whichever is smaller, the column or the height */
.np-frame { width: min(100%, calc(100vh - var(--inset-top) - var(--inset-bottom) - 92px)); }
.np-spacer { grid-area: slack; padding: 10px var(--pad) 0; justify-content: center; }
.np-face { grid-area: left; }
.np-meta { grid-area: meta; padding-top: 2px; }
.np-scrub { grid-area: scrub; padding-top: 8px; }
.np-transport { grid-area: trans; padding-top: 10px; justify-self: center; width: min(100%, 430px); }
.np-foot { grid-area: foot; padding-top: 8px; }
/* a face is up: it takes the artwork's column and the thumbnail goes —
the record is already the ground behind the whole sheet */
.np.small { grid-template-columns: minmax(0, 1fr) minmax(0, 430px); }
.np.small .np-art { display: none; }
}
/* ── a phone turned sideways: 411 pixels of height to spend ───────────── */
@media (orientation: landscape) and (max-height: 560px) {
/* the nav comes down to Android's own 48dp minimum and the mini bar to a
60dp row over the same lane strip: 22 pixels back, 46% of the height as
chrome down to 40% */
:root { --nav-h: 48px; --mini-h: 60px; }
.nav button .gl { height: 22px; }
.nav button .lb { font-size: 9.5px; }
.nav button .pill { top: 3px; height: 24px; }
.mini { padding-bottom: 24px; }
.mini .art { width: 34px; height: 34px; }
.mini .mini-lane { bottom: 5px; }
/* the hero stops being a portrait and becomes the banner the desktop's
hero has always been */
.hero { height: 190px; }
.hero-name { font-size: 27px; }
.hero-logo { max-height: 64px; }
/* the record head puts the cover beside the title, again the desktop's
own shape; .pl needs naming because its own rule is more specific */
.rec-head { flex-direction: row; align-items: center; gap: 22px; padding: calc(var(--bar-h) + 12px) var(--pad) 4px; }
.rec-head .rec-cover, .rec-head.pl .rec-cover { width: 34%; max-width: 240px; }
.rec-meta { text-align: left; }
.rec-title { margin-top: 0; }
.rec-chips { justify-content: flex-start; }
.rec-acts { justify-content: flex-start; padding: 14px 0 4px; }
/* 76% of 411 is 312; a half sheet may take all but a strip */
.halfsheet { max-height: calc(100% - 36px); }
}
+92
View File
@@ -68,6 +68,11 @@ window.__settle = function () {
/* what the page shows, read back after every still */
const PROBE = `(function () {
var e = document.querySelector('.screen');
/* geometry, for the orientation steps: box() is the rect or null, and
inside() asks whether every pixel of it is within the viewport —
which, sideways, is the whole question */
function box(sel) { var x = document.querySelector(sel); return x ? x.getBoundingClientRect() : null; }
function inside(b) { return !!b && b.height > 0 && b.top >= -1 && b.bottom <= innerHeight + 1 && b.left >= -1 && b.right <= innerWidth + 1; }
return {
screen: e ? e.className : '',
title: (document.querySelector('.gb-t') || {}).textContent || '',
@@ -183,6 +188,27 @@ const PROBE = `(function () {
halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(),
halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'),
contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(),
vp: innerWidth + 'x' + innerHeight,
/* the sheet's controls: any one of them off the viewport is the landscape bug */
npOut: [].filter.call(document.querySelectorAll('.sheet.up .np-frame, .sheet.up .np-meta, .sheet.up .np-scrub, .sheet.up .np-transport, .sheet.up .np-foot'), function (x) { var b = x.getBoundingClientRect(); return b.height > 0 && !inside(b); }).length,
/* stacked = the portrait column; beside = the cover left of the controls; face = a face has the column and the thumbnail is gone */
npCols: (function () { var a = box('.sheet.up .np-frame'), b = box('.sheet.up .np-transport'); if (!a || !b) return ''; if (!a.height) return 'face'; return a.bottom <= b.top + 1 ? 'stacked' : 'beside'; })(),
faceH: Math.round((box('.sheet.up .np-face') || { height: 0 }).height),
/* WHICH face: the queue's own, up whether or not anything is after this one */
qFace: !!document.querySelector('.sheet.up .q-face'),
/* a half sheet: how tall it is allowed to be, that it is all on screen, and that its body still reaches its last row */
halfH: Math.round((box('.halfsheet.up') || { height: 0 }).height),
halfIn: inside(box('.halfsheet.up')),
hsScroll: (function () { var x = document.querySelector('.halfsheet.up .hs-body'); return x ? getComputedStyle(x).overflowY : ''; })(),
hsTail: (function () { var x = document.querySelector('.halfsheet.up .hs-body'); return x ? x.scrollHeight - x.clientHeight : -1; })(),
heroH: Math.round((box('.screen .hero') || { height: 0 }).height),
/* what a page offers first: Play on an artist hero, Play on a record head */
actsIn: inside(box('.screen .hero-acts') || box('.screen .rec-acts')),
recCols: (function () { var c = box('.screen .rec-cover'), m = box('.screen .rec-meta'); return c && m ? (c.bottom <= m.top + 1 ? 'stacked' : 'beside') : ''; })(),
/* the glass bar above plus the floating bars below, as a share of the screen */
chromePct: (function () { var g = box('.gbar'), low = box('.mini') || box('.nav'); return Math.round((((g ? g.height : 0) + (low ? innerHeight - low.top : 0)) / innerHeight) * 100); })(),
miniLeft: Math.round((box('.mini') || { left: -1 }).left),
screenPadL: (function () { var x = document.querySelector('.screen'); return x ? Math.round(parseFloat(getComputedStyle(x).paddingLeft)) : -1; })(),
};
})()`;
@@ -542,6 +568,38 @@ const MOCK_STEPS = [
{ do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "home-more", wait: 700 },
{ do: `__click('.halfsheet .row', 'Sleep timer')`, name: "sleep-sheet", wait: 700 },
{ do: `__click('.halfsheet .chip', '30 min')`, name: "sleep-set", wait: 700 },
/* ── the phone turned over. A resize, so the app keeps playing and keeps
its stack; what changes is the layout, and every one of these asserts
something that was clipped or unreachable at 915x411 before. ────── */
{ view: [915, 411], name: "landscape", wait: 900 },
{ do: `document.querySelector('.mini').click()`, name: "landscape-sheet", wait: 1200 },
{ do: `document.querySelectorAll('.fbtn')[1].click()`, name: "landscape-lyrics", wait: 1200 },
{ do: `document.querySelectorAll('.fbtn')[2].click()`, name: "landscape-queue", wait: 900 },
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 500 },
{ do: `__ss.closeSheet()`, wait: 800 },
/* a record sideways: the cover beside the title, Play in view without a
scroll, and a long sheet over it — the track's, 473px of rows and
facts — capped taller than portrait's 76% and still scrolling to its
last row. (Home's own overflow sheet is 212px tall at either cap and
would have proved nothing.) */
{ do: `document.querySelectorAll('.nav button')[2].click()`, wait: 800 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 500 },
{ do: `document.querySelectorAll('.grid2 .release')[2].click()`, name: "landscape-album", wait: 1100 },
{ do: `document.querySelector('.trk .r').click()`, name: "landscape-half", wait: 900 },
{ do: `document.querySelector('.scrim').click()`, wait: 700 },
/* and the artist: the hero a banner rather than 300px of a 411px screen,
so it stops covering its own Play button with the mini bar */
{ do: `document.querySelector('.rec-artist').click()`, name: "landscape-artist", wait: 1400 },
{ do: `document.querySelectorAll('.nav button')[0].click()`, wait: 800 },
/* the side insets, faked: sideways the cutout and the gesture pill come
in from the edges, and env() cannot be emulated here — the plumbing can */
{ do: `document.documentElement.style.setProperty('--inset-left', '28px'); document.documentElement.style.setProperty('--inset-right', '28px'); true`, name: "landscape-insets", wait: 700 },
{ do: `document.documentElement.style.removeProperty('--inset-left'); document.documentElement.style.removeProperty('--inset-right'); true`, wait: 500 },
/* and back, which is also the override reset: portrait must be exactly
what it was before any of this */
{ view: [411, 914], wait: 800 },
{ do: `document.querySelector('.mini').click()`, name: "portrait-again", wait: 1200 },
{ do: `__ss.closeSheet()`, wait: 600 },
];
const MOCK_EXPECT = {
tracks: (i) => i.title.startsWith("Your library"),
@@ -588,6 +646,25 @@ const MOCK_EXPECT = {
"search-recent-pick": (i) => i.title.startsWith("Search") && (i.songs >= 4 || !!i.top),
"sleep-sheet": (i) => i.half.includes("up"),
"sleep-set": (i) => !i.half.includes("up"),
/* sideways: the chrome budget, then every surface that was clipped. The
before-value of each is in the rig's now.json — 46% of the height as
chrome, a 879px cover with the transport 687px below the fold, a
lyrics face collapsed to nothing, a 300px hero, and the record's own
Play button off the bottom of a 411px screen. */
landscape: (i) => i.vp === "915x411" && i.mini && i.chromePct <= 42 && /^(Good |Still)/.test(i.title),
"landscape-sheet": (i) => i.vp === "915x411" && i.sheet.includes("up") && i.npCols === "beside" && i.npOut === 0,
"landscape-lyrics": (i) => i.sheet.includes("up") && i.npCols === "face" && i.faceH >= 240 && i.npOut === 0,
/* the queue face by name, not by its rows: the track this walk is on is
the LAST queueable row of Late night drive (the missing and the
unresolved ones never reach the queue), so there is nothing up next
and `qrows` is 0 here — the assertion is that the queue's own face has
the cover's column, not that something is after this song */
"landscape-queue": (i) => i.sheet.includes("up") && i.npCols === "face" && i.qFace && i.faceH >= 240 && i.npOut === 0,
"landscape-album": (i) => i.screen.includes("tr-push") && i.recCols === "beside" && i.actsIn,
"landscape-half": (i) => i.half.includes("up") && i.halfIn && i.halfH >= 340 && i.halfH <= 380 && i.hsScroll === "auto" && i.hsTail > 0,
"landscape-artist": (i) => i.heroH > 0 && i.heroH <= 200 && i.actsIn,
"landscape-insets": (i) => i.miniLeft >= 34 && i.screenPadL >= 26,
"portrait-again": (i) => i.vp === "411x914" && i.sheet.includes("up") && i.npCols === "stacked" && i.npOut === 0 && i.chromePct <= 24,
home: (i) => /^(Good |Still)/.test(i.title),
"hold-sheet": (i) => i.half.includes("up") && /Re-fetch the cover/.test(i.halfText),
"cover-refetched": (i) => !i.half.includes("up") && /Cover re-fetched/.test(i.notice),
@@ -1088,6 +1165,21 @@ async function main() {
if (s.adb) {
try { adb(...s.adb); } catch (e) { problems.push("ADB " + (s.name || s.adb.join(" ")) + ": " + e.message); }
}
/* a mock step can turn the phone over: the viewport, through the
same override the walk boots with. A real device's orientation
is not the harness's to set, so a view step on the phone walk
is a failure rather than a no-op. send() resolves with the
whole reply and does not reject on a protocol error, so a
refused override has to be read out of it. */
if (s.view) {
if (PHONE) problems.push("VIEW " + (s.name || s.view.join("x")) + ": the phone walk must not set the viewport");
else {
try {
const r = await cdp.send("Emulation.setDeviceMetricsOverride", { width: s.view[0], height: s.view[1], deviceScaleFactor: 2, mobile: true });
if (r.error) throw new Error(r.error.message || JSON.stringify(r.error));
} catch (e) { problems.push("VIEW " + (s.name || s.view.join("x")) + ": " + e.message); }
}
}
if (s.do) {
try {
/* the helpers live in the page; a reload step wipes them */