Compare commits

..
Author SHA1 Message Date
logan 4bf2414653 build(frontend): fail css-check on a nested rule the phone drops
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m30s
CI / e2e (pull_request) Successful in 8m55s
The device renders in Chrome 113, which predates relaxed CSS nesting, so
a nested rule whose selector starts with an element name is not a parse
error anyone would notice -- the rule simply does not exist, there and
nowhere else. Three were live in `index.css`, and the one that mattered
was the `text-overflow: ellipsis` on the bottom bar's title and artist,
which had therefore never truncated on the device. No tier here can see
the class at all: the component tier, the e2e tier and `make ui-visual`
all run a current engine, where the rule applies normally.

So `make css-check` carries a second script. It reads `index.css` and
the `css` literals in `src/**/*.ts` alike, since a shadow-root
stylesheet is parsed by the same engine, and it names the file, the line
and the fix -- a leading `&`, which is valid in both syntaxes.

The detection walks blocks rather than matching lines, and both things
it has to get right fall out of one rule: a rule is nested when a
*style* rule is somewhere above it, not when its immediate parent is a
block. That leaves `@media (...) { bottom-nav { ... } }` at the top
level alone, which is the majority of what a regex over the file would
report, and still flags the same rule inside an at-rule that is itself
inside a style rule. Strings and comments are read through, so a brace
in a `url()` is not a block.

The tree has no violation left, so the check would pass just as happily
over an empty glob: it refuses one, and `test/utils/css-nesting.test.ts`
pins the semantics that make the sweep mean something. The literal
scanner the two checks share is lifted into `css-literals.mjs`
unchanged, except that a `${}` substitution is now blanked keeping its
newlines so a line number survives it.

Closes #154
2026-08-21 05:38:34 -04:00
17 changed files with 33 additions and 1421 deletions
-5
View File
@@ -88,8 +88,3 @@ 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
+4 -112
View File
@@ -1462,42 +1462,6 @@ 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**:
@@ -1945,33 +1909,6 @@ 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
`<player-progress-line>` 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
@@ -2069,26 +2006,9 @@ 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 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 600899 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.
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**.
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
@@ -2569,31 +2489,6 @@ missing half; `catalogFailed` is the only route to `unavailable` now,
and the timer is a 60 s backstop for a genuine hang rather than the
verdict.
**On a phone that page is one scroll container, and the header is in
it** (#66). It was built as a fixed header over a scrolling tracklist,
which is the desktop arrangement: at the reference device's 424×439 the
header owned **253 of the panel's 318px** and the list scrolled inside
the 64 that were left. Below 600px the *host* is the scroller and
`.content` stops being one, so the whole page moves together — which is
only available because this tracklist is plain DOM rather than a
virtualizer, and because `.main-panel > *` already gives the host a
definite height.
Three things about it are load-bearing. **Another `min-width: 0` was
not the fix**: `.album-info` carries one and was shrinking exactly as
asked, to 112px beside a 200px cover — so the title drew as `G…` and
"Shuffle album" ended at x=443 inside a 424px box, clipped by the
component's own `overflow: hidden` and reachable by no gesture. A row
with a fixed-size sibling has to **stack** at that width, or the column
that must shrink has nothing to be wide with. **`layout-overflow.spec.ts`
cannot see any of this** — `body.scrollWidth` equalled the viewport
throughout, because the overflow was *inside* a component; the spec
measures each header control against the host's own box, which is
`top-bar-fit.spec.ts`'s shape for the same reason. And **the phone block
is last in the stylesheet**, on `index.css`'s rule: a media query adds
no specificity, so written above the plain rules it overrides every
declaration in it is silently dead.
**Activating a row plays the list the row is in, from that row.** A
double-click — and Play on a single row's context menu — queues the
list as *displayed* with `startIndex` on that row, not a queue of one
@@ -3555,10 +3450,7 @@ 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
step) fails on one, over `index.css` and the `css` literals alike, 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
-209
View File
@@ -1,209 +0,0 @@
import { test, expect } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The album page on a phone (#66).
*
* Two faults, and neither was visible to `layout-overflow.spec.ts`:
* that spec asserts the *shell* needs no sideways scrolling, and the
* shell was correct throughout — `body.scrollWidth === clientWidth`
* while `explore-album-details` itself measured 443 inside a 424px box
* and clipped two of the album's three primary actions with its own
* `overflow: hidden`. So the measurement here is **per control against
* the component's box**, which is the same shape `top-bar-fit.spec.ts`
* needed for the same reason.
*
* The other half is the scroll: the page was a fixed header over a
* scrolling tracklist, so at the reference device's 424x439 the header
* owned 253 of the panel's 318px and the list scrolled in the 64 that
* were left. It is one scroll container below 600px, which is a
* property of the *host* rather than of `.content`.
*
* The engine is the caveat this tier cannot close: the reference device
* renders in Chrome 113 and this is Chromium/WebKit. A flex direction
* and a scroll container are nowhere near that engine's documented gaps
* (relaxed nesting, the Popover API, `light-dark()`), but "it renders
* at that size in Chromium" is not evidence about the phone.
*/
/** The phone this was measured on, in CSS pixels. */
const DEVICE = { width: 424, height: 439 };
const details = (page: Page) => page.locator('explore-album-details');
/** The page's own boxes, read from inside its shadow root. */
const geometry = (page: Page) =>
page.evaluate(() => {
const host = document.querySelector('explore-album-details');
const sr = host?.shadowRoot;
if (!host || !sr) return null;
const box = (sel: string) => {
const el = sr.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return { width: Math.round(r.width), right: Math.round(r.right) };
};
const content = sr.querySelector('.content');
return {
hostWidth: host.clientWidth,
hostScrollWidth: host.scrollWidth,
// The host is the scroller below 600px, so the page is taller
// than its box rather than the tracklist being a window inside it.
hostScrolls: host.scrollHeight > host.clientHeight,
contentScrolls: content
? content.scrollHeight > content.clientHeight
: null,
header: box('.album-header'),
play: box('[data-testid="album-play"]'),
shuffle: box('[data-testid="album-shuffle"]'),
queue: box('[data-testid="album-queue"]'),
title: (() => {
const el = sr.querySelector('.album-title-text');
return el ? el.scrollWidth <= el.clientWidth + 1 : null;
})(),
};
});
test.describe('the album page on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await openFirstAlbum(app);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-tracks').click();
});
test('keeps every action inside its own box', async ({ app }) => {
const geo = await geometry(app);
expect(geo).not.toBeNull();
// "Shuffle album" ended at x=443 in a 424px component and could not
// be reached by any gesture; "Add to queue" at 440.
for (const action of ['play', 'shuffle', 'queue'] as const) {
expect(
geo?.[action],
`${action} is rendered`,
).not.toBeNull();
expect(
geo?.[action]?.right ?? 0,
`${action} ends inside the page`,
).toBeLessThanOrEqual(geo?.hostWidth ?? 0);
}
expect(geo?.hostScrollWidth).toBe(geo?.hostWidth);
expect(geo?.header?.width).toBe(geo?.hostWidth);
});
test('gives the title the row rather than one glyph of it', async ({
app,
}) => {
// `.album-info` was squeezed to 112px beside the art, so an album
// called *Glass Harbour* drew as `G…`. It carries `min-width: 0`
// and was shrinking as asked — the row had to stack.
expect(await geometry(app).then((g) => g?.title)).toBe(true);
});
test('scrolls as one page, with the header scrolling away', async ({
app,
}) => {
const before = await geometry(app);
expect(before?.hostScrolls).toBe(true);
expect(before?.contentScrolls).toBe(false);
const headerTop = () =>
app.evaluate(
() =>
document
.querySelector('explore-album-details')
?.shadowRoot?.querySelector('.album-header')
?.getBoundingClientRect().top ?? 0,
);
expect(await headerTop()).toBeGreaterThanOrEqual(0);
// A wheel gesture, not `scrollTop`: `overflow: hidden` still permits
// programmatic scrolling, so a probe that assigns it passes on the
// build this exists to fail.
await details(app).hover();
await app.mouse.wheel(0, 250);
await expect.poll(headerTop).toBeLessThan(-100);
});
test('is the desktop arrangement again above the breakpoint', async ({
app,
}) => {
await app.setViewportSize({ width: 1024, height: 800 });
// The same element, re-laid-out: one component with two
// arrangements, not a phone-only copy.
await expect
.poll(async () => (await geometry(app))?.hostScrolls)
.toBe(false);
const arrangement = await app.evaluate(() => {
const sr = document.querySelector('explore-album-details')?.shadowRoot;
const header = sr?.querySelector('.album-header');
const content = sr?.querySelector('.content');
return {
direction: header ? getComputedStyle(header).flexDirection : null,
contentOverflow: content ? getComputedStyle(content).overflowY : null,
};
});
expect(arrangement.direction).toBe('row');
expect(arrangement.contentOverflow).toBe('auto');
});
});
/** Albums → the second card, which navigates to the album page. */
async function openFirstAlbum(app: Page): Promise<void> {
// Below 600px the sidebar is gone; the tab bar is the navigation.
await app.getByTestId('tab-albums').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'albums',
);
await expect.poll(() => cardCount(app)).toBeGreaterThan(1);
// Dispatched rather than clicked: the card lives in a virtualizer
// inside a shadow root, and Enter expands the dropdown instead.
await app.evaluate(() => {
document
.querySelector('cover-grid')
?.shadowRoot?.querySelectorAll('.album-card')[1]
?.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true }),
);
});
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
await expect(
details(app).locator('[data-testid="album-play"]'),
).toBeVisible();
}
async function cardCount(app: Page): Promise<number> {
return app.evaluate(
() =>
document
.querySelector('cover-grid')
?.shadowRoot?.querySelectorAll('.album-card').length ?? 0,
);
}
-136
View File
@@ -1,136 +0,0 @@
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<Rect | null> {
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<void> {
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();
});
});
+2 -29
View File
@@ -179,8 +179,8 @@ test.describe('the queue is a screen where it covers the content', () => {
*/
/**
* 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
* 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
* full-screen surface. Measured at 424×439 before #55: **25×21px**.
*/
test('offers a way out a thumb can hit', async ({ app }) => {
@@ -194,33 +194,6 @@ 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);
});
});
/**
+3 -27
View File
@@ -426,7 +426,6 @@ 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
@@ -502,8 +501,7 @@ 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"]) player-progress-line {
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
display: none;
}
}
@@ -570,12 +568,8 @@ 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. `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 {
absolutely positioned to avoid. */
body job-band {
display: none;
}
@@ -608,21 +602,3 @@ body player-progress-line {
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;
}
}
-10
View File
@@ -81,16 +81,6 @@
</button>
</div>
</footer>
<!-- How far through the song we are, on the border between the two
bars (#58). The shell's element rather than either bar's:
they are separate components stacked in this grid, so a line
on the border between them is a row of it, and neither one has
to reach into the other's box for two pixels. It renders
nothing above 600px and nothing with no track, is `aria-hidden`
(Now Playing's seek bar is what announces the position) and
takes no pointer events at all -- a thin line that sometimes
seeks is worse than one that never does. -->
<player-progress-line></player-progress-line>
<!-- The phone's primary navigation, hidden above 600px by
index.css. Eager rather than a chunk, for the reason
notification-host is: it is the only way to move around the
-4
View File
@@ -21,10 +21,6 @@ import '@components/audio-player/audio-player.ts';
// In the bar rather than inside `audio-player` since #42, so the shell
// is what has to register it.
import '@components/audio-player/volume-control/volume-control.ts';
// The phone's progress line (#58), on the border between the mini
// player and the tab bar. In the shell for the same reason the volume
// is, and eager because it is part of the bottom bar's first paint.
import '@components/audio-player/progress-line/progress-line.ts';
import '@components/track-list/track-list.ts';
import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts';
+5 -19
View File
@@ -18,24 +18,10 @@ 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 });
}
for (const { line, selector } of findBareNestedRules(
readFileSync('index.css', 'utf8'),
)) {
problems.push({ file: 'index.css', line, selector });
}
const sources = globSync('src/**/*.ts', { cwd: process.cwd() });
@@ -75,5 +61,5 @@ if (problems.length > 0) {
}
console.log(
`css-nesting-check: ${stylesheets.length} stylesheet(s) + ${sources.length} files, no bare nested rules`,
`css-nesting-check: index.css + ${sources.length} files, no bare nested rules`,
);
@@ -1,204 +0,0 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { PlayerController } from '@store/controllers/player-controller';
import { designTokens } from '../../../styles/tokens.css';
import { PHONE_QUERY } from '../../../utils/breakpoints';
/**
* How far through the song we are, on the border between the mini
* player and the tab bar (#58).
*
* The phone's bottom bar carries three controls and no seek bar — plan
* 016 B2 took it out, because 4px of height is not a thumb target and the
* full-screen `now-playing-view` is where seeking belongs. What went
* with it is the one thing a mini player is expected to say without
* being opened: how far through the song it is. This is that, and
* only that.
*
* Four things about it are load-bearing.
*
* **It is the shell's element, not either bar's.** The mini player and
* `<bottom-nav>` are separate components stacked in the shell's grid,
* so a line on the border between them is a row of the grid — either
* one drawing it means reaching into the other's box for two pixels.
*
* **It never counts.** The position is pushed at 1 Hz by the backend
* (`PlaybackPositionChanged`), and the interval here interpolates
* *between* those reports and is stopped and restarted by every one of
* them — the seek bar's rule, for the reason the seek bar has it: a
* local clock drifted 30 s away from the backend across four keyboard
* seeks. The `trackChangeId` and `seq` guards come along for the same
* reason: the store is a singleton, so a report about the previous
* track must not be adopted, and the same second reported twice still
* has to reset the interpolation.
*
* **It is not a control and cannot become one.** `aria-hidden` on the
* host and `pointer-events: none` throughout: the real progress is
* announced by the seek bar on Now Playing, and a 2px strip on the top
* edge of the tab bar that sometimes seeks is worse than one that
* never does. It is also where a thumb aiming at a tab lands.
*
* **It renders nothing above 600px**, from `matchMedia` rather than a
* media query, because that decides whether the element *exists* — and
* with it whether a 1 Hz interval runs for the life of every desktop
* session about a line nobody can see. `job-band`, `search-trigger`
* and `player-controls` are the same pattern for the same reason.
*/
/**
* The reporting cadence, matched. This is not the clock: it exists
* only so the line moves in the second between two reports, and its
* error is discarded by the next one rather than carried.
*/
const InterpolationIntervalMillis = 1000;
@customElement('player-progress-line')
export class PlayerProgressLine extends LitElement {
private player = new PlayerController(this);
/** Phone width. See the class comment: existence, not paint. */
@state() private phone = false;
/** Seconds into the track, from the last report plus interpolation. */
@state() private elapsed = 0;
private previousTrackChangeId = -1;
/** The sequence number of the last backend report applied. */
private previousPositionSeq = -1;
private timerID = -1;
private media?: MediaQueryList;
private onMedia = (e: MediaQueryListEvent) => {
this.phone = e.matches;
};
static override styles = [
designTokens,
css`
:host {
display: block;
/* Not a target, at any depth. */
pointer-events: none;
}
.track {
height: 2px;
background-color: var(--yj-bg-surface, #212529);
}
.fill {
height: 100%;
background-color: var(--yj-accent, #ffd43b);
/* scaleX off a full-width box rather than a width in
percent, so the moving thing is a transform and the
line costs no layout once a second. */
transform-origin: left center;
}
`,
];
private get trackLength(): number {
return this.player.currentTrack?.trackLength ?? 0;
}
override connectedCallback(): void {
super.connectedCallback();
// Decorative in full: the seek bar on Now Playing is what
// announces the position, and this says the same thing without
// a name, a value or a way to act on it.
this.setAttribute('aria-hidden', 'true');
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.stopInterpolating();
this.media?.removeEventListener('change', this.onMedia);
}
override updated(): void {
// A track change resets the line, and `trackChangeId` is what
// reveals one when the same file plays twice in a row.
const currentChangeId = this.player.currentTrack?.trackChangeId ?? -1;
if (currentChangeId !== this.previousTrackChangeId) {
this.previousTrackChangeId = currentChangeId;
this.elapsed = this.player.currentTrack?.seekPosition ?? 0;
this.stopInterpolating();
}
// The backend's own position wins over anything counted here,
// and a report for a track that is no longer loaded is stale by
// definition.
const position = this.player.position;
if (
position &&
position.trackChangeId === currentChangeId &&
position.seq !== this.previousPositionSeq
) {
this.previousPositionSeq = position.seq;
this.elapsed = position.positionSeconds;
this.stopInterpolating();
}
// One owner for the interval, as in `seek-bar`: everything that
// wants it started or stopped says so by changing state that
// brings us back here.
if (this.phone && this.player.isPlaying && currentChangeId !== -1) {
this.startInterpolating();
} else {
this.stopInterpolating();
}
}
private stopInterpolating(): void {
if (this.timerID !== -1) {
clearInterval(this.timerID);
this.timerID = -1;
}
}
private startInterpolating(): void {
if (this.timerID !== -1) {
return;
}
this.timerID = window.setInterval(() => {
if (this.elapsed < this.trackLength) {
this.elapsed += 1;
}
}, InterpolationIntervalMillis);
}
override render() {
// Nothing playing is nothing to say, and the grid row is `auto`
// so an empty render costs no height at all -- `job-band`'s
// rule one row down.
if (!this.phone || this.player.currentTrack === null) return nothing;
const length = this.trackLength;
const fraction =
length > 0 ? Math.min(1, Math.max(0, this.elapsed / length)) : 0;
return html`
<div class="track" data-testid="progress-line">
<div class="fill" style="transform: scaleX(${fraction})"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'player-progress-line': PlayerProgressLine;
}
}
@@ -79,17 +79,6 @@ 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 = () => {
@@ -892,67 +892,6 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
.track-row .track-request {
flex-shrink: 0;
}
/* ── The phone (#66) ──
*
* **This block is last on purpose**, for index.css's
* reason: a media query adds no specificity, so a rule
* written above the plain one it overrides loses to it and
* every declaration here is silently dead.
*
* Two faults, one shape. The page is a fixed header over a
* scrolling tracklist — the desktop arrangement — so at the
* reference device's 424x439 the header owned 253 of the
* panel's 318px and the tracklist scrolled inside the 64px
* that were left. And the header's flex row squeezed
* .album-info to 112px, so the title drew as one ellipsised
* glyph and two of the album's three primary actions were
* clipped by the host's own overflow: Shuffle album ended
* at x=443 in a 424px box, unreachable by any gesture.
*
* .album-info carries min-width: 0 and was shrinking as
* asked, so another one is not the fix — the row has to
* stack, or the info column has nothing to be wide with.
*
* The scroller moves to the host and .content stops being
* one, which is what makes the header scroll away; the
* tracklist is plain DOM rather than a virtualizer, so
* nothing inside wants a scroll window of its own. */
@media (max-width: 599px) {
:host {
overflow-y: auto;
}
.album-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
}
/* Stacked, the art is the whole of the header's width
* budget and its 200px square is 45% of the reference
* device's height. It is still what identifies the
* album, so it shrinks rather than going. */
.cover-art-container {
width: 140px;
height: 140px;
}
/* A column flex item takes its content's width from
* align-items: flex-start above, which would leave the
* actions wrapping inside a box narrower than the row
* they now have to themselves. */
.album-info {
align-self: stretch;
}
.content {
flex: 0 0 auto;
overflow-y: visible;
padding: 16px 16px 24px;
}
}
`,
];
@@ -13,7 +13,6 @@ 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,
@@ -121,25 +120,6 @@ 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;
@@ -411,8 +391,6 @@ 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;
@@ -436,10 +414,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 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.
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.
Sized only in overlay mode: inline these sit in a 320px
column beside the content, where a mouse is what reaches
@@ -661,42 +639,23 @@ 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 {
display: none;
background: none;
border: none;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
visibility: hidden;
}
@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;
}
.track-item:hover .remove-button {
visibility: visible;
}
.track-item:hover .remove-button {
visibility: visible;
}
.remove-button:hover {
color: var(--yj-error-text, #ff8787);
}
.remove-button:hover {
color: var(--yj-error-text, #ff8787);
}
.list-area.drag-over {
@@ -869,12 +828,6 @@ 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(),
@@ -917,8 +870,6 @@ 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',
@@ -985,10 +936,6 @@ 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.
@@ -2025,7 +1972,7 @@ export class QueuePanel
const tracks = this.queue.tracks;
return html`
${this.overlay && !this.phone
${this.overlay
? html`<div
class="scrim"
part="scrim"
@@ -529,44 +529,6 @@ 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,6 +1,5 @@
/**
* A hover affordance is gated on the device having hover — in whichever
* direction keeps the action reachable.
* A hover affordance is gated on the device having hover.
*
* 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
@@ -8,15 +7,6 @@
* 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
@@ -34,9 +24,6 @@
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. */
@@ -96,91 +83,3 @@ 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');
});
});
@@ -1,258 +0,0 @@
/**
* The phone's progress line (#58).
*
* **What this tier can and cannot see.** It can see the whole of what
* the issue asks for that is not a pixel: that the line exists only on
* a phone and only with a track, that it renders the position the
* backend reported rather than a count of its own, and that it is
* neither announced nor touchable. It cannot see where it sits — that
* is the shell's grid, and it is asserted in
* `e2e/specs/phone-progress-line.spec.ts` where there is a real bar
* with a real tab bar under it.
*/
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import '@components/audio-player/progress-line/progress-line';
import { Events } from '../../src/events';
import { emit, flush } from '@test/support/harness';
import { fixture, shadow } from '@test/support/render';
const TRACK = {
fileName: 'song.mp3',
filePath: '/music/song.mp3',
trackLength: 90,
seekPosition: 0,
state: 'playing',
title: 'Song',
artist: 'Artist',
album: 'Album',
coverArt: '',
coverArtSmall: '',
coverArtMedium: '',
coverArtLarge: '',
trackChangeId: 1,
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
};
/**
* Answer `matchMedia` for the phone query, since the runner's own
* window is whatever size the browser provider gives it. Stubbed rather
* than resized for `transport-context.test.ts`'s reason: what is under
* test is the component's reaction to the answer.
*/
const realMatchMedia = window.matchMedia;
function pretendPhone(phone: boolean): void {
window.matchMedia = ((query: string) => ({
matches: phone && query.includes('599'),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
}
/** The horizontal scale of the fill, or null if there is no line. */
function scale(el: Element): number | null {
const fill = shadow<HTMLElement>(el, '.fill');
if (!fill) return null;
const match = /scaleX\(([^)]+)\)/.exec(fill.style.transform);
return match ? Number(match[1]) : null;
}
describe('<player-progress-line>', () => {
beforeEach(() => {
emit(Events.TrackChanged, null);
emit(Events.PlaybackStateChanged, { state: 'stopped' });
});
afterEach(() => {
window.matchMedia = realMatchMedia;
vi.useRealTimers();
});
it('draws nothing above the phone breakpoint', async () => {
pretendPhone(false);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 2 });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 45,
trackLength: 90,
trackChangeId: 2,
seq: 1,
playing: true,
});
await flush();
await el.updateComplete;
// The desktop bar carries a real seek bar, and there is no tab
// bar for this to sit on the border of.
expect(el.shadowRoot!.querySelector('.track')).toBeNull();
});
it('draws nothing until there is a track', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
expect(el.shadowRoot!.querySelector('.track')).toBeNull();
});
it('renders the fraction the backend reported', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 3 });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 45,
trackLength: 90,
trackChangeId: 3,
seq: 1,
playing: true,
});
await flush();
await el.updateComplete;
expect(scale(el)).toBeCloseTo(0.5, 3);
});
it('resumes mid-track at the position the track arrived with', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, {
...TRACK,
seekPosition: 30,
trackChangeId: 4,
});
await flush();
await el.updateComplete;
expect(scale(el)).toBeCloseTo(1 / 3, 3);
});
it('interpolates between reports, and every report resets it', async () => {
pretendPhone(true);
vi.useFakeTimers();
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 5 });
emit(Events.PlaybackStateChanged, { state: 'playing' });
await vi.advanceTimersByTimeAsync(3000);
await el.updateComplete;
expect(scale(el)).toBeCloseTo(3 / 90, 3);
// The user seeks; the backend lands somewhere else and says so.
// The local count is discarded, never added to -- the seek
// bar's rule, and the reason it has it.
emit(Events.PlaybackPositionChanged, {
positionSeconds: 40,
trackLength: 90,
trackChangeId: 5,
seq: 2,
playing: true,
});
await vi.advanceTimersByTimeAsync(1000);
await el.updateComplete;
expect(scale(el)).toBeCloseTo(41 / 90, 3);
});
/*
* The reason this component asks `matchMedia` instead of letting a
* stylesheet hide it: a media query cannot stop a 1 Hz interval
* running for the life of every desktop session. That claim is
* load-bearing in CLAUDE.md, so it is asserted rather than
* described — the timer count, because a desktop render is empty
* either way and so cannot tell the two apart.
*/
it('runs no interpolation timer above the breakpoint', async () => {
pretendPhone(false);
vi.useFakeTimers();
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 9 });
emit(Events.PlaybackStateChanged, { state: 'playing' });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 3,
trackLength: 90,
trackChangeId: 9,
seq: 9,
playing: true,
});
await vi.advanceTimersByTimeAsync(5000);
await el.updateComplete;
expect(scale(el)).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
it('ignores a report about a track that is no longer loaded', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 60,
trackLength: 90,
trackChangeId: 5,
seq: 3,
playing: true,
});
await flush();
await el.updateComplete;
// The store is a singleton, so a line mounting late must not
// adopt a report about the previous track.
expect(scale(el)).toBe(0);
});
it('counts nothing while the player is paused', async () => {
pretendPhone(true);
vi.useFakeTimers();
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 7 });
emit(Events.PlaybackPositionChanged, {
positionSeconds: 10,
trackLength: 90,
trackChangeId: 7,
seq: 1,
playing: false,
});
emit(Events.PlaybackStateChanged, { state: 'paused' });
await vi.advanceTimersByTimeAsync(5000);
await el.updateComplete;
expect(scale(el)).toBeCloseTo(10 / 90, 3);
});
it('is decorative and cannot be touched', async () => {
pretendPhone(true);
const el = await fixture('player-progress-line');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 8 });
await flush();
await el.updateComplete;
// The seek bar on Now Playing is what announces the position;
// this says the same thing with no name and no way to act on
// it, and it sits exactly where a thumb aiming at a tab lands.
expect(el.getAttribute('aria-hidden')).toBe('true');
expect(getComputedStyle(el).pointerEvents).toBe('none');
});
});
@@ -29,61 +29,8 @@ 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;
});
/**
@@ -210,78 +157,6 @@ 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 600899 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