Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11ba7b3180 | ||
|
|
7f8e185d7c | ||
|
|
42483c4b61 | ||
|
|
deea6ad06d | ||
|
|
fba608fdbd | ||
|
|
f59490b113 | ||
|
|
6cca57f229 | ||
|
|
ea3edde697 | ||
|
|
14e3ab574c |
@@ -88,3 +88,8 @@ build/android/overlay.json
|
|||||||
# Written by @semantic-release/changelog purely to carry the release notes
|
# Written by @semantic-release/changelog purely to carry the release notes
|
||||||
# into scripts/gitea-release.sh; the release page is the changelog.
|
# into scripts/gitea-release.sh; the release page is the changelog.
|
||||||
.release-notes.md
|
.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
|
||||||
|
|||||||
@@ -130,7 +130,13 @@ reference, because you need them *before* the failure, not after.
|
|||||||
what you otherwise get is `Property 'scroll' does not exist on type
|
what you otherwise get is `Property 'scroll' does not exist on type
|
||||||
'CSSResult'` pointing at a line of prose, or every test in the suite
|
'CSSResult'` pointing at a line of prose, or every test in the suite
|
||||||
failing to import. It went in after the trap cost a fourth session in
|
failing to import. It went in after the trap cost a fourth session in
|
||||||
which its own warning had been read twice.
|
which its own warning had been read twice. **The same command carries
|
||||||
|
a second CSS check**: a nested rule whose selector starts with an
|
||||||
|
element name (`audio-player { … }` rather than `& audio-player { … }`)
|
||||||
|
is silently dropped by the device's Chrome 113 and by nothing else, so
|
||||||
|
every tier you can run renders it correctly. Run it after touching
|
||||||
|
`index.css` or any `css` literal; a rule directly inside a top-level
|
||||||
|
`@media` is not nested and is not flagged.
|
||||||
- **A failing CI job's log is reachable even when `gitea_ci job_logs`
|
- **A failing CI job's log is reachable even when `gitea_ci job_logs`
|
||||||
says it is not.** That endpoint 404s on this Gitea build. The REST
|
says it is not.** That endpoint 404s on this Gitea build. The REST
|
||||||
API answers, with the `GITEA_TOKEN` already in the environment:
|
API answers, with the `GITEA_TOKEN` already in the environment:
|
||||||
|
|||||||
@@ -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
|
asserted as `'33x21'` rather than as a range, because the regression
|
||||||
was three pixels.
|
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
|
**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
|
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
|
899 and 700px at 900 — the narrowest content area any desktop width
|
||||||
@@ -2489,6 +2516,31 @@ 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
|
and the timer is a 60 s backstop for a genuine hang rather than the
|
||||||
verdict.
|
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
|
**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
|
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
|
list as *displayed* with `startIndex` on that row, not a queue of one
|
||||||
@@ -3444,6 +3496,27 @@ android-inspect` forwards the WebView's devtools socket and `make
|
|||||||
android-eval` asks the real page — raw CDP, because `connectOverCDP`
|
android-eval` asks the real page — raw CDP, because `connectOverCDP`
|
||||||
calls `Browser.setDownloadBehavior` and a WebView refuses it.
|
calls `Browser.setDownloadBehavior` and a WebView refuses it.
|
||||||
|
|
||||||
|
**One of those gaps is checked rather than remembered.** A nested rule
|
||||||
|
whose selector starts with an element name is not a parse error anyone
|
||||||
|
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
|
||||||
|
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
|
||||||
|
than what the immediate parent is: `@media (…) { bottom-nav { … } }` at
|
||||||
|
the top level is an ordinary rule and is the majority of what a regex
|
||||||
|
over the file would report, while the same rule one level inside
|
||||||
|
`.bar { @media (…) { … } }` is nested and is flagged. The check is the
|
||||||
|
cheap version of the answer; a build-time downlevel (Lightning CSS
|
||||||
|
targeting 113) would fix the class permanently and is a dependency and
|
||||||
|
a build step rather than twenty lines.
|
||||||
|
|
||||||
`build/config.yml`'s `version` is the
|
`build/config.yml`'s `version` is the
|
||||||
*metadata* version and is not what the app reports — `main.version` is
|
*metadata* version and is not what the app reports — `main.version` is
|
||||||
stamped at link time from the packaging recipe's git-derived version.
|
stamped at link time from the packaging recipe's git-derived version.
|
||||||
|
|||||||
@@ -172,12 +172,17 @@ ui-setup: ## Install the Vitest browser provider's own Chromium (once)
|
|||||||
bindings-check: ## Fail if the generated bindings are stale
|
bindings-check: ## Fail if the generated bindings are stale
|
||||||
@./scripts/bindings-check.sh
|
@./scripts/bindings-check.sh
|
||||||
|
|
||||||
|
# Two CSS traps that report a long way from their cause, or not at all.
|
||||||
# A backtick inside a comment in a css`` literal ends the literal, and
|
# A backtick inside a comment in a css`` literal ends the literal, and
|
||||||
# what you get back is a type error about CSSResult, or every test in
|
# what you get back is a type error about CSSResult, or every test in
|
||||||
# the suite failing to import. Four sessions, three plans. Instant.
|
# the suite failing to import. Four sessions, three plans. And a nested
|
||||||
|
# rule starting with an element name is dropped by the device's
|
||||||
|
# Chrome 113 in silence -- no tier here runs an engine that can see it.
|
||||||
|
# Instant.
|
||||||
.PHONY: css-check
|
.PHONY: css-check
|
||||||
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
|
css-check: ## Fail on a css`` literal ended early by a backtick, or a nested rule needing an &
|
||||||
@cd frontend && node scripts/check-css-literals.mjs
|
@cd frontend && node scripts/check-css-literals.mjs
|
||||||
|
@cd frontend && node scripts/check-css-nesting.mjs
|
||||||
|
|
||||||
# .pi/ and CLAUDE.md document commands, and a doc that documents a
|
# .pi/ and CLAUDE.md document commands, and a doc that documents a
|
||||||
# command wrongly is worse than no doc: an agent runs it confidently.
|
# command wrongly is worse than no doc: an agent runs it confidently.
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+27
-3
@@ -426,6 +426,7 @@ body div.sidebar {
|
|||||||
"jobs-band" auto
|
"jobs-band" auto
|
||||||
"main-panel" 1fr
|
"main-panel" 1fr
|
||||||
"bottom-bar" auto
|
"bottom-bar" auto
|
||||||
|
"progress-line" auto
|
||||||
"bottom-nav" auto
|
"bottom-nav" auto
|
||||||
/ 1fr;
|
/ 1fr;
|
||||||
/* Nothing may scroll sideways here. On a desktop the shell is
|
/* 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.
|
expression of the same fact is a second thing to keep in step.
|
||||||
The view carries its own queue button, because this is where
|
The view carries its own queue button, because this is where
|
||||||
that one lived. */
|
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;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,8 +570,12 @@ body div.sidebar {
|
|||||||
/* Out of the desktop grid entirely. `job-band` renders nothing above
|
/* Out of the desktop grid entirely. `job-band` renders nothing above
|
||||||
600px anyway, but an in-flow grid child with no named area is
|
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
|
auto-placed into a row of the shell -- the same trap the skip link is
|
||||||
absolutely positioned to avoid. */
|
absolutely positioned to avoid. `player-progress-line` (#58) is the
|
||||||
body job-band {
|
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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,3 +608,21 @@ body job-band {
|
|||||||
background-color: var(--yj-bg-elevated, #343a40);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,6 +81,16 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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
|
<!-- The phone's primary navigation, hidden above 600px by
|
||||||
index.css. Eager rather than a chunk, for the reason
|
index.css. Eager rather than a chunk, for the reason
|
||||||
notification-host is: it is the only way to move around the
|
notification-host is: it is the only way to move around the
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import '@components/audio-player/audio-player.ts';
|
|||||||
// In the bar rather than inside `audio-player` since #42, so the shell
|
// In the bar rather than inside `audio-player` since #42, so the shell
|
||||||
// is what has to register it.
|
// is what has to register it.
|
||||||
import '@components/audio-player/volume-control/volume-control.ts';
|
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/track-list/track-list.ts';
|
||||||
import '@components/now-playing/now-playing.ts';
|
import '@components/now-playing/now-playing.ts';
|
||||||
import '@components/sidebar/app-sidebar.ts';
|
import '@components/sidebar/app-sidebar.ts';
|
||||||
|
|||||||
@@ -23,97 +23,23 @@
|
|||||||
* as the literal contains an unterminated `/*`. Nothing else produces
|
* as the literal contains an unterminated `/*`. Nothing else produces
|
||||||
* that, and a legitimate literal cannot contain one.
|
* that, and a legitimate literal cannot contain one.
|
||||||
*/
|
*/
|
||||||
import { readFileSync } from 'node:fs';
|
import { globSync, readFileSync } from 'node:fs';
|
||||||
import { globSync } from 'node:fs';
|
|
||||||
|
import { taggedLiterals } from './css-literals.mjs';
|
||||||
|
|
||||||
const TAGS = ['css', 'html', 'svg'];
|
const TAGS = ['css', 'html', 'svg'];
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the end of a template literal that starts at `start` (the index
|
|
||||||
* of its opening backtick), respecting escapes and `${}` substitutions.
|
|
||||||
* Returns the index of the closing backtick, or -1.
|
|
||||||
*/
|
|
||||||
function endOfTemplate(src, start) {
|
|
||||||
let depth = 0;
|
|
||||||
|
|
||||||
for (let i = start + 1; i < src.length; i++) {
|
|
||||||
const c = src[i];
|
|
||||||
|
|
||||||
if (c === '\\') {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c === '$' && src[i + 1] === '{') {
|
|
||||||
depth++;
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c === '}' && depth > 0) {
|
|
||||||
depth--;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c === '`' && depth === 0) return i;
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Strip `${...}` substitutions, which may legitimately contain anything. */
|
|
||||||
function stripSubstitutions(text) {
|
|
||||||
let out = '';
|
|
||||||
let depth = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
if (text[i] === '$' && text[i + 1] === '{') {
|
|
||||||
depth++;
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text[i] === '}' && depth > 0) {
|
|
||||||
depth--;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (depth === 0) out += text[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lineOf(src, index) {
|
|
||||||
return src.slice(0, index).split('\n').length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = globSync('src/**/*.ts', { cwd: process.cwd() });
|
const files = globSync('src/**/*.ts', { cwd: process.cwd() });
|
||||||
const problems = [];
|
const problems = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const src = readFileSync(file, 'utf8');
|
const src = readFileSync(file, 'utf8');
|
||||||
const tagPattern = new RegExp(`(^|[^\\w$.])(${TAGS.join('|')})\``, 'g');
|
|
||||||
|
|
||||||
let match;
|
for (const { tag, body, line } of taggedLiterals(src, TAGS)) {
|
||||||
|
|
||||||
while ((match = tagPattern.exec(src)) !== null) {
|
|
||||||
const open = match.index + match[0].length - 1;
|
|
||||||
const close = endOfTemplate(src, open);
|
|
||||||
|
|
||||||
if (close === -1) continue;
|
|
||||||
|
|
||||||
const body = stripSubstitutions(src.slice(open + 1, close));
|
|
||||||
const opens = (body.match(/\/\*/g) ?? []).length;
|
const opens = (body.match(/\/\*/g) ?? []).length;
|
||||||
const closes = (body.match(/\*\//g) ?? []).length;
|
const closes = (body.match(/\*\//g) ?? []).length;
|
||||||
|
|
||||||
if (opens > closes) {
|
if (opens > closes) problems.push({ file, line, tag });
|
||||||
problems.push({
|
|
||||||
file,
|
|
||||||
line: lineOf(src, open),
|
|
||||||
tag: match[2],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Fail on a nested rule whose selector starts with an element name.
|
||||||
|
*
|
||||||
|
* See `css-nesting.mjs` for what the phone does with one. No tier here
|
||||||
|
* can see it: the component tier, the e2e tier and `make ui-visual` all
|
||||||
|
* run a current Chromium, where the rule applies normally, so the only
|
||||||
|
* report is a screenshot of the device — which is how the bottom bar's
|
||||||
|
* title came to have never truncated there.
|
||||||
|
*
|
||||||
|
* It covers `index.css` and the `css` literals in the components alike,
|
||||||
|
* because a shadow-root stylesheet is parsed by the same engine.
|
||||||
|
*/
|
||||||
|
import { globSync, readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
import { taggedLiterals } from './css-literals.mjs';
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = globSync('src/**/*.ts', { cwd: process.cwd() });
|
||||||
|
|
||||||
|
// A sweep over an empty glob passes, and this one is expected to find
|
||||||
|
// nothing, so "it found nothing" has to mean it looked.
|
||||||
|
if (sources.length === 0) {
|
||||||
|
console.error('css-nesting-check: no sources matched src/**/*.ts');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of sources) {
|
||||||
|
const src = readFileSync(file, 'utf8');
|
||||||
|
|
||||||
|
for (const literal of taggedLiterals(src, ['css'])) {
|
||||||
|
for (const { line, selector } of findBareNestedRules(literal.body)) {
|
||||||
|
problems.push({ file, line: literal.line + line - 1, selector });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length > 0) {
|
||||||
|
for (const p of problems) {
|
||||||
|
console.error(
|
||||||
|
`${p.file}:${p.line}: nested rule "${p.selector.split('\n')[0]}" starts ` +
|
||||||
|
'with an element name — write it as "& ' +
|
||||||
|
`${p.selector.split('\n')[0]}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
`\ncss-nesting-check: ${problems.length} problem(s). ` +
|
||||||
|
'Chrome 113 (the device) drops a nested rule that does not start ' +
|
||||||
|
'with a symbol; the leading & is valid in both syntaxes.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`css-nesting-check: ${stylesheets.length} stylesheet(s) + ${sources.length} files, no bare nested rules`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Finding the `css` tagged templates in a TypeScript source.
|
||||||
|
*
|
||||||
|
* Two checks read them — the unterminated-comment one and the nesting
|
||||||
|
* one — and a second scanner would be a second thing to keep in step
|
||||||
|
* with how a template literal actually ends.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the end of a template literal that starts at `start` (the index
|
||||||
|
* of its opening backtick), respecting escapes and `${}` substitutions.
|
||||||
|
* Returns the index of the closing backtick, or -1.
|
||||||
|
*/
|
||||||
|
export function endOfTemplate(src, start) {
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
for (let i = start + 1; i < src.length; i++) {
|
||||||
|
const c = src[i];
|
||||||
|
|
||||||
|
if (c === '\\') {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === '$' && src[i + 1] === '{') {
|
||||||
|
depth++;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === '}' && depth > 0) {
|
||||||
|
depth--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === '`' && depth === 0) return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip `${...}` substitutions, which may legitimately contain anything.
|
||||||
|
*
|
||||||
|
* Newlines inside them are kept, so a line number taken from the
|
||||||
|
* stripped text still names the right line of the file it came from.
|
||||||
|
*/
|
||||||
|
export function stripSubstitutions(text) {
|
||||||
|
let out = '';
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === '$' && text[i + 1] === '{') {
|
||||||
|
depth++;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text[i] === '}' && depth > 0) {
|
||||||
|
depth--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth === 0) out += text[i];
|
||||||
|
else if (text[i] === '\n') out += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 1-based line number of `index` in `src`. */
|
||||||
|
export function lineOf(src, index) {
|
||||||
|
return src.slice(0, index).split('\n').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every tagged template literal in `src` whose tag is in `tags`.
|
||||||
|
*
|
||||||
|
* `body` has its substitutions stripped and `line` is the line its
|
||||||
|
* opening backtick sits on, so `line + (n - 1)` is the file line of the
|
||||||
|
* body's own line `n`.
|
||||||
|
*/
|
||||||
|
export function taggedLiterals(src, tags) {
|
||||||
|
const pattern = new RegExp(`(^|[^\\w$.])(${tags.join('|')})\``, 'g');
|
||||||
|
const found = [];
|
||||||
|
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = pattern.exec(src)) !== null) {
|
||||||
|
const open = match.index + match[0].length - 1;
|
||||||
|
const close = endOfTemplate(src, open);
|
||||||
|
|
||||||
|
if (close === -1) continue;
|
||||||
|
|
||||||
|
found.push({
|
||||||
|
tag: match[2],
|
||||||
|
body: stripSubstitutions(src.slice(open + 1, close)),
|
||||||
|
line: lineOf(src, open),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* A nested rule whose selector starts with an element name is silently
|
||||||
|
* dropped on the phone.
|
||||||
|
*
|
||||||
|
* The device renders in Chrome 113, which predates relaxed CSS nesting
|
||||||
|
* (Chrome 120): before that a nested selector had to start with
|
||||||
|
* something that could not be read as the beginning of a declaration,
|
||||||
|
* so `.bottom-bar { audio-player { … } }` is not a parse error anyone
|
||||||
|
* would notice — the inner rule simply does not exist, on the phone and
|
||||||
|
* only on the phone. Three were live in `index.css`, one of them the
|
||||||
|
* `text-overflow: ellipsis` on the bottom bar's title, which had
|
||||||
|
* therefore never truncated on the device.
|
||||||
|
*
|
||||||
|
* `& audio-player` is valid in both syntaxes, so no nested rule here
|
||||||
|
* has any reason to omit it.
|
||||||
|
*
|
||||||
|
* Two things the detection has to get right:
|
||||||
|
*
|
||||||
|
* - **A rule directly inside an at-rule is not nested.**
|
||||||
|
* `@media (…) { bottom-nav { … } }` at the top level is an ordinary
|
||||||
|
* rule and is fine — and it is the majority of the matches a regex
|
||||||
|
* over the file would produce. What decides it is whether a *style*
|
||||||
|
* rule is somewhere above, not what the immediate parent is: inside
|
||||||
|
* `.bar { @media (…) { audio-player { … } } }` the inner rule is
|
||||||
|
* nested, at-rule in between or not.
|
||||||
|
* - **A declaration is not a rule.** `background: url(…)` and any
|
||||||
|
* string or comment can hold a brace, so this tracks them rather than
|
||||||
|
* matching lines.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Does this selector start with an identifier, rather than a symbol? */
|
||||||
|
function startsWithIdent(selector) {
|
||||||
|
return /^[A-Za-z_\u00A0-\uFFFF]/.test(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every nested style rule in `css` whose selector starts with an
|
||||||
|
* element name, as `{ line, selector }` with a 1-based line.
|
||||||
|
*/
|
||||||
|
export function findBareNestedRules(css) {
|
||||||
|
const found = [];
|
||||||
|
/** The blocks we are inside, innermost last: 'style' or 'at'. */
|
||||||
|
const stack = [];
|
||||||
|
/** The text since the last `{`, `}` or `;` — a prelude, if a `{` follows. */
|
||||||
|
let prelude = '';
|
||||||
|
let preludeLine = 1;
|
||||||
|
let line = 1;
|
||||||
|
|
||||||
|
const startPrelude = () => {
|
||||||
|
prelude = '';
|
||||||
|
preludeLine = line;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < css.length; i++) {
|
||||||
|
const c = css[i];
|
||||||
|
|
||||||
|
if (c === '\n') {
|
||||||
|
line++;
|
||||||
|
if (prelude.trim() === '') preludeLine = line;
|
||||||
|
prelude += c;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === '/' && css[i + 1] === '*') {
|
||||||
|
const end = css.indexOf('*/', i + 2);
|
||||||
|
const comment = css.slice(i, end === -1 ? css.length : end + 2);
|
||||||
|
|
||||||
|
line += (comment.match(/\n/g) ?? []).length;
|
||||||
|
i += comment.length - 1;
|
||||||
|
if (prelude.trim() === '') preludeLine = line;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === '"' || c === "'") {
|
||||||
|
let j = i + 1;
|
||||||
|
|
||||||
|
while (j < css.length && css[j] !== c) {
|
||||||
|
if (css[j] === '\\') j++;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
prelude += css.slice(i, j + 1);
|
||||||
|
i = j;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === '{') {
|
||||||
|
const selector = prelude.trim();
|
||||||
|
const kind = selector.startsWith('@') ? 'at' : 'style';
|
||||||
|
|
||||||
|
if (
|
||||||
|
kind === 'style' &&
|
||||||
|
stack.includes('style') &&
|
||||||
|
startsWithIdent(selector)
|
||||||
|
) {
|
||||||
|
found.push({ line: preludeLine, selector });
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.push(kind);
|
||||||
|
startPrelude();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === '}') {
|
||||||
|
stack.pop();
|
||||||
|
startPrelude();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === ';') {
|
||||||
|
startPrelude();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
prelude += c;
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -892,6 +892,67 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|
|||||||
.track-row .track-request {
|
.track-row .track-request {
|
||||||
flex-shrink: 0;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* The nesting check's own semantics.
|
||||||
|
*
|
||||||
|
* `make css-check` runs it over a tree that currently has no violation,
|
||||||
|
* so the check passing says nothing about whether it can still find
|
||||||
|
* one. What it has to get right is two distinctions, and both are the
|
||||||
|
* kind a regex over the file gets wrong: a rule directly inside an
|
||||||
|
* at-rule is not nested, and a brace inside a string or a comment is
|
||||||
|
* not a block.
|
||||||
|
*
|
||||||
|
* The rule it enforces is the device's: Chrome 113 predates relaxed CSS
|
||||||
|
* nesting, so a nested selector starting with an element name is
|
||||||
|
* dropped in silence. See `scripts/css-nesting.mjs`.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { findBareNestedRules } from '../../scripts/css-nesting.mjs';
|
||||||
|
|
||||||
|
describe('the nested-rule check', () => {
|
||||||
|
it('flags a nested rule that starts with an element name', () => {
|
||||||
|
const found = findBareNestedRules(
|
||||||
|
'.bottom-bar {\n color: red;\n\n audio-player { margin: 0 }\n}',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(found).toEqual([{ line: 4, selector: 'audio-player' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the same rule written with a leading &', () => {
|
||||||
|
expect(
|
||||||
|
findBareNestedRules('.bottom-bar {\n & audio-player { margin: 0 }\n}'),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a nested selector that starts with any other symbol', () => {
|
||||||
|
expect(
|
||||||
|
findBareNestedRules('.bar {\n #track-info { color: red }\n}'),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(findBareNestedRules('.bar {\n :host { color: red }\n}')).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a top-level rule alone, element name or not', () => {
|
||||||
|
expect(findBareNestedRules('p {\n margin: 0;\n}')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The majority of what a naive sweep would report: a media query at
|
||||||
|
* the top level holds ordinary rules, not nested ones.
|
||||||
|
*/
|
||||||
|
it('leaves a rule directly inside an at-rule alone', () => {
|
||||||
|
expect(
|
||||||
|
findBareNestedRules(
|
||||||
|
'@media (max-width: 599px) {\n bottom-nav { display: flex }\n}',
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* And the other half of that: what decides it is whether a style rule
|
||||||
|
* is anywhere above, not what the immediate parent is.
|
||||||
|
*/
|
||||||
|
it('flags one inside an at-rule that is itself inside a rule', () => {
|
||||||
|
expect(
|
||||||
|
findBareNestedRules(
|
||||||
|
'.bar {\n @media (min-width: 900px) {\n audio-player { margin: 0 }\n }\n}',
|
||||||
|
),
|
||||||
|
).toEqual([{ line: 3, selector: 'audio-player' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads through a brace in a string or a comment', () => {
|
||||||
|
expect(
|
||||||
|
findBareNestedRules('.a {\n background: url("x{y}");\n}'),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
findBareNestedRules('.a {\n /* audio-player { x: y } */\n}'),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,6 +58,15 @@ pre-commit:
|
|||||||
root: "frontend/"
|
root: "frontend/"
|
||||||
run: node scripts/check-css-literals.mjs
|
run: node scripts/check-css-literals.mjs
|
||||||
|
|
||||||
|
# A nested rule whose selector starts with an element name is
|
||||||
|
# silently dropped by the device's Chrome 113, and by nothing else --
|
||||||
|
# so every tier here renders it correctly and only a screenshot of
|
||||||
|
# the phone disagrees. Instant.
|
||||||
|
css-nesting:
|
||||||
|
glob: "frontend/**/*.{ts,css}"
|
||||||
|
root: "frontend/"
|
||||||
|
run: node scripts/check-css-nesting.mjs
|
||||||
|
|
||||||
# Deliberately sequential, unlike pre-commit. `go test -race`
|
# Deliberately sequential, unlike pre-commit. `go test -race`
|
||||||
# saturates every core for the better part of a minute and the UI tier
|
# saturates every core for the better part of a minute and the UI tier
|
||||||
# is a real browser with wall-clock timeouts, so run together the
|
# is a real browser with wall-clock timeouts, so run together the
|
||||||
|
|||||||
Reference in New Issue
Block a user