fix(a11y): stop the now-playing marquee under reduced motion
Build & publish Arch package / arch-package (push) Successful in 2m6s
CI / check (push) Successful in 2m52s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Canceled after 4m33s

a11y.15 / WCAG 2.2.2: the bottom bar's title and artist scrolled for as
long as a track played, re-armed in a loop by transitionend, with no
pause mechanism and no reduced-motion guard.

Reproduced under an emulated prefers-reduced-motion before the fix: the
title still carried will-scroll with a 15s transition and the transform
was still moving. That read landed in the snap-back half of the cycle,
which is why a CSS-only 'transition: none' is the wrong fix -- it leaves
the text translated off its own box and transitionend never fires to
bring it back. The scroll is not armed at all instead, which is a
decision shouldScroll() already owned, and it covers hover as well as
always: reduce is a request about motion, not about autoplay.

Two things came out of looking at the result rather than asserting on
it. The non-scrolling fallback was hard-clipping, not ellipsising, in
every mode including the default -- text-overflow was on the outer span
while the overflowing box is the inline-block child. And moving it to
the child then broke overflow *detection*, because the parent stops
overflowing once the child hides its own; both measurements come from
the child now. The second was caught by the new test's positive case,
which is why it has one.
This commit is contained in:
2026-08-12 22:58:56 -04:00
parent 0a0da0c19c
commit 11b4aaef6a
3 changed files with 219 additions and 5 deletions
+104
View File
@@ -0,0 +1,104 @@
import { test, expect, callBinding, waitForEvent } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* `a11y.15` — WCAG 2.2.2. The bottom bar's title and artist scroll
* continuously while a track plays, re-armed in a loop by
* `transitionend`, with no pause mechanism and no reduced-motion guard.
*
* The component test for this fakes `window.matchMedia`, which is a
* stub of the thing being tested. This spec sets the real context
* option, so the real media query answers.
*
* Both directions are here on purpose. A guard that suppressed
* everything would pass the reduce case for free, and so would a bar
* whose text simply does not overflow at this viewport — which is what
* the component test failed on first.
*/
/** The fixture track whose title is long enough to overflow the bar. */
const LONG_TITLE = 'An Exhaustively Overlong Track Title';
/**
* Read the title line's classes from inside `now-playing`'s shadow root.
*
* `will-scroll` is the class that carries both the transition and the
* `padding-right` the scroll distance is measured against, so its
* absence is the whole fix: suppressing only the animation leaves the
* text translated off its own box with nothing to bring it back.
*/
async function titleClasses(app: Page): Promise<string> {
return app.evaluate(() => {
const np = document.querySelector('now-playing');
const title = np?.shadowRoot?.querySelector('.track-title');
return title?.className ?? '';
});
}
async function playTheLongOne(app: Page): Promise<void> {
await app.getByTestId('nav-tracks').click();
// The scroll mode defaults to `hover`, and a fresh context has no
// persisted setting — so without this the positive case never
// scrolls and reports the same thing a broken build would. Set it
// rather than hovering, because `always` is also the mode the
// finding is about: continuous motion for as long as the track
// plays, with nothing the user has to do to provoke it.
await app.evaluate(() => {
localStorage.setItem('yj-now-playing-scroll-mode', 'always');
window.dispatchEvent(new CustomEvent('yj-scroll-mode-changed'));
});
const paths: string[] = await app.evaluate(async (needle) => {
const tracks = await window.__yjEvents.call(
'library.Library.GetAllTracks',
[],
10_000,
);
return (tracks as { TrackName: string; FilePath: string }[])
.filter((t) => t.TrackName.startsWith(needle))
.map((t) => t.FilePath);
}, LONG_TITLE);
expect(paths.length).toBeGreaterThan(0);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await waitForEvent(app, 'TrackChanged');
// The scroll cycle is armed 1500 ms after the geometry is measured,
// and the geometry is measured after the render that puts the title
// on screen. Reading before that reports "not scrolling" on a build
// that scrolls — the same shape as every probe read too early in
// plan 007.
await expect
.poll(() => titleClasses(app), { timeout: 10_000 })
.toContain('track-title');
}
test.describe('the marquee under prefers-reduced-motion', () => {
test.use({ contextOptions: { reducedMotion: 'reduce' } });
test('does not scroll the now-playing text at all', async ({ app }) => {
await playTheLongOne(app);
// Give the cycle longer than the 1500 ms arming delay to prove it
// never arms, rather than catching it before it would have.
await app.waitForTimeout(2500);
expect(await titleClasses(app)).not.toContain('will-scroll');
});
});
test.describe('the marquee without a motion preference', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } });
test('still scrolls an overflowing title', async ({ app }) => {
await playTheLongOne(app);
await expect
.poll(() => titleClasses(app), { timeout: 10_000 })
.toContain('will-scroll');
});
});