diff --git a/.gitignore b/.gitignore index 3b3893d..91840b5 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,8 @@ build/android/overlay.json # Written by @semantic-release/changelog purely to carry the release notes # into scripts/gitea-release.sh; the release page is the changelog. .release-notes.md + +# Agent session log: local scratch, not repo memory (that is CLAUDE.md +# and .planning/). Written by the scheduled backlog runs. +.pi/journal.md +.pi/schedule-prompts.json diff --git a/CLAUDE.md b/CLAUDE.md index 904af8c..d5475c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1909,6 +1909,33 @@ every desktop button from 33×21 to 36×24, silently. The sizes are asserted as `'33x21'` rather than as a range, because the regression was three pixels. +**What that bar lost is how far through the song it is, and +`` is where it went** (#58). Plan 016 B2 took the +seek bar off the phone's transport, so the one thing a mini player is +expected to say without being opened had nowhere left to be said. It is +a 2px line on the border between the mini player and the tab bar: the +**shell's** element and its own `auto` grid row between `bottom-bar` +and `bottom-nav`, because those two are separate components and either +one drawing it means reaching into the other's box for two pixels. + +Four things about it are load-bearing. **It never counts** — the fill is +`scaleX()` off the same `PlaybackPositionChanged` the seek bar renders, +with the same `trackChangeId` and `seq` guards and an interval that is +stopped and restarted by every report, which is the rule that exists +because a local clock drifted 30 s away across four keyboard seeks. +**It is not a control and cannot become one**: `aria-hidden` on the host +and `pointer-events: none` throughout, because Now Playing's seek bar +is what announces the position and a 2px strip on the top edge of the +tab bar is exactly where a thumb aiming at a tab lands. **It renders +nothing above 600px**, from `matchMedia` rather than a media query, for +`job-band`'s reason plus one of its own — a stylesheet cannot stop a +1 Hz interval running for the life of every desktop session about a +line nobody can see. And **its phone rule sits at the foot of +`index.css`, beside `job-band`'s**, not in the phone block above: a +media query adds no specificity, so a `display: block` written before +the `display: none` that takes it out of the desktop grid loses to it +and the line never appears at any width, silently. + **900 is the worst desktop width, not the 800×600 minimum.** The sidebar collapses to icons *below* 900, so the main panel is 843px at 899 and 700px at 900 — the narrowest content area any desktop width diff --git a/e2e/specs/phone-progress-line.spec.ts b/e2e/specs/phone-progress-line.spec.ts new file mode 100644 index 0000000..006e79f --- /dev/null +++ b/e2e/specs/phone-progress-line.spec.ts @@ -0,0 +1,136 @@ +import { + test, + expect, + callBinding, + resetEvents, + waitForEvent, + LONG_TRACK, + NO_QUEUE_SOURCE, +} from '../support/fixtures.js'; +import type { Page } from '@playwright/test'; + +/** + * The phone's progress line (#58). + * + * The component tier already pins what the line *says* — that it + * renders the backend's reported position and never a count of its own. + * What only a real shell can answer is **where it is**: the issue asks + * for a line on the border between the mini player and the tab bar, and + * "on the border" is two adjacencies in a grid that no component-level + * render has around it. + * + * It also asserts the line is not there on a desktop, which is the + * other half of the same fact: above 600px there is no tab bar for it + * to sit on the border of, and the bar carries a real seek bar. + */ +type Rect = { x: number; y: number; width: number; height: number }; + +/** The reference device's real viewport. */ +const DEVICE = { width: 424, height: 439 }; +const DESKTOP = { width: 1280, height: 800 }; + +async function rectOf(app: Page, selector: string): Promise { + return app.evaluate((sel) => { + const el = document.querySelector(sel); + + if (!el) return null; + + const r = el.getBoundingClientRect(); + + return { x: r.x, y: r.y, width: r.width, height: r.height }; + }, selector); +} + +/** + * Put the 90-second fixture on and wait for the first position report. + * + * The long track rather than any track: every other fixture is 2-6 + * seconds, which is shorter than the time this spec takes to measure + * three rectangles. + */ +async function play(app: Page): Promise { + const tracks = await callBinding<{ FilePath: string; TrackName: string }[]>( + app, + 'library.Library.GetTracks', + [0], + ); + + // `TrackName`, not `Title`: that is what the library model calls it. + const long = tracks.find((t) => t.TrackName === LONG_TRACK); + + expect(long, `no fixture track named ${LONG_TRACK}`).toBeTruthy(); + + await callBinding(app, 'queue.Queue.Clear'); + await resetEvents(app); + await callBinding(app, 'queue.Queue.SetQueue', [ + [long!.FilePath], + 0, + false, + NO_QUEUE_SOURCE, + ]); + await waitForEvent(app, 'QueueChanged'); + await callBinding(app, 'queue.Queue.Play'); + await waitForEvent(app, 'PlaybackPositionChanged', { timeoutMs: 15_000 }); +} + +test.describe('the progress line sits on the border between the bars', () => { + test.beforeEach(async ({ app }) => { + await app.setViewportSize(DEVICE); + await play(app); + }); + + /* + * Every test here starts a LONG_TRACK and the suite is workers: 1, + * fullyParallel: false against one long-lived app — so without this + * the four phone-* specs that follow alphabetically inherit a playing + * queue. phone-transport.spec.ts records where that lesson came from: + * the fault first showed up as a flake in a spec about something else. + */ + test.afterEach(async ({ app }) => { + await callBinding(app, 'queue.Queue.Clear').catch(() => { + /* already empty */ + }); + await app.setViewportSize(DESKTOP); + }); + + test('spans the width, between the mini player and the tab bar', async ({ + app, + }) => { + const line = await rectOf(app, 'player-progress-line'); + const bar = await rectOf(app, '.bottom-bar'); + const nav = await rectOf(app, 'bottom-nav'); + + expect(line, 'no progress line on the phone').not.toBeNull(); + expect(bar).not.toBeNull(); + expect(nav).not.toBeNull(); + + // A border, not a band: 2px, the full width, and touching both. + expect(line!.height).toBeCloseTo(2, 0); + expect(line!.width).toBeCloseTo(bar!.width, 0); + expect(line!.y).toBeCloseTo(bar!.y + bar!.height, 0); + expect(nav!.y).toBeCloseTo(line!.y + line!.height, 0); + }); + + /** + * It is 2px on the top edge of the tab bar, which is exactly where a + * thumb aiming at a tab lands. A line that sometimes seeks is worse + * than one that never does, so it must take no part in hit testing + * at all. + */ + test('takes no taps', async ({ app }) => { + const line = await rectOf(app, 'player-progress-line'); + + const hit = await app.evaluate( + ({ x, y }) => document.elementFromPoint(x, y)?.tagName ?? '', + { x: line!.x + line!.width / 2, y: line!.y + 1 }, + ); + + expect(hit).not.toBe('PLAYER-PROGRESS-LINE'); + }); + + test('is not there on a desktop', async ({ app }) => { + await app.setViewportSize(DESKTOP); + + await expect(app.locator('player-progress-line')).toBeHidden(); + }); +}); diff --git a/frontend/index.css b/frontend/index.css index 30172ae..a7e2438 100644 --- a/frontend/index.css +++ b/frontend/index.css @@ -426,6 +426,7 @@ body div.sidebar { "jobs-band" auto "main-panel" 1fr "bottom-bar" auto + "progress-line" auto "bottom-nav" auto / 1fr; /* Nothing may scroll sideways here. On a desktop the shell is @@ -501,7 +502,8 @@ body div.sidebar { expression of the same fact is a second thing to keep in step. The view carries its own queue button, because this is where that one lived. */ - body:has(#main-content[data-active-view="now-playing"]) .bottom-bar { + body:has(#main-content[data-active-view="now-playing"]) .bottom-bar, + body:has(#main-content[data-active-view="now-playing"]) player-progress-line { display: none; } } @@ -568,8 +570,12 @@ body div.sidebar { /* Out of the desktop grid entirely. `job-band` renders nothing above 600px anyway, but an in-flow grid child with no named area is auto-placed into a row of the shell -- the same trap the skip link is - absolutely positioned to avoid. */ -body job-band { + absolutely positioned to avoid. `player-progress-line` (#58) is the + same element in the same position for the same reason: below 600px it + has a named row, and above it there is no border for it to sit on -- + the desktop bar carries a real, interactive seek bar. */ +body job-band, +body player-progress-line { display: none; } @@ -602,3 +608,21 @@ body job-band { background-color: var(--yj-bg-elevated, #343a40); } } + +/* #58. How far through the song we are, in its own grid row between + the two bars -- so the line is *on* the border rather than inside + either of them, and in flow rather than over it. The row is `auto` + and the element renders nothing while no track is loaded, so it costs + no height at all until there is something to say. + + **This block is below the `display: none` above and has to be**, for + the reason the band's rule is: a media query adds no specificity, so + `body player-progress-line { display: block }` written before that + rule loses to it at equal specificity and the line never appears at + any width. Nothing fails; it is simply not there. */ +@media (max-width: 599px) { + body player-progress-line { + display: block; + grid-area: progress-line; + } +} diff --git a/frontend/index.html b/frontend/index.html index 991def6..cebe75a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -81,6 +81,16 @@ + +