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');
});
});
@@ -57,6 +57,21 @@ export class NowPlaying extends LitElement {
@state()
private artistHovered = false;
/**
* `prefers-reduced-motion: reduce`, live (a11y.15).
*
* It is a `@state` and not a CSS query because suppressing the
* *transition* is not enough: the cycle is a transition out, a
* `transitionend`, and a transition back, so removing the animation
* leaves the text translated off its own box and `onScrollCycleEnd`
* never fires to bring it back. The scroll has to not be armed at
* all, which is a decision `shouldScroll()` already owns.
*/
@state()
private reduceMotion = false;
private reduceMotionQuery?: MediaQueryList;
/** Whether each field is actively mid-scroll (class toggle). */
@state()
private titleScrolling = false;
@@ -200,9 +215,19 @@ export class NowPlaying extends LitElement {
color: var(--yj-text-tertiary, #666);
}
/* Static ellipsis when not scrolling */
.track-title:not(.will-scroll),
.track-artist:not(.will-scroll) {
/* Static ellipsis when not scrolling.
It has to be on .scroll-content, not on the outer span: the child
is an inline-block, so it is the box that overflows, and
text-overflow on an ancestor does not ellipsise an overflowing
inline-block descendant — it clips it. The outer rule was there
from the start and never produced an ellipsis in any mode; the
default mode is hover, so what every user saw when not hovering
was a title cut mid-glyph. Only visible in a screenshot. */
.track-title:not(.will-scroll) .scroll-content,
.track-artist:not(.will-scroll) .scroll-content {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -248,6 +273,12 @@ export class NowPlaying extends LitElement {
this.updateWidth(DEFAULT_WIDTH);
window.addEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
// Looked up here rather than at module load so a test can install
// its own matchMedia before the element is created.
this.reduceMotionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');
this.reduceMotion = this.reduceMotionQuery?.matches ?? false;
this.reduceMotionQuery?.addEventListener('change', this.handleReduceMotionChange);
this.resizeObserver = new ResizeObserver(() => {
this.geometryDirty = true;
this.requestUpdate();
@@ -259,6 +290,7 @@ export class NowPlaying extends LitElement {
// A drag interrupted by the bar going away still has to clean up.
this.attachDragListeners(false);
window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
this.reduceMotionQuery?.removeEventListener('change', this.handleReduceMotionChange);
this.resizeObserver?.disconnect();
this.stopScrollCycle('title');
this.stopScrollCycle('artist');
@@ -371,6 +403,7 @@ export class NowPlaying extends LitElement {
<span
class="track-title ${titleScrolling ? 'will-scroll' : ''} ${this.titleScrolling ? 'scrolling' : ''}"
data-testid="now-playing-title"
title=${track.title}
@mouseenter=${this.handleTitleMouseEnter}
@mouseleave=${this.handleTitleMouseLeave}
@transitionend=${() => this.onScrollCycleEnd('title')}
@@ -380,6 +413,7 @@ export class NowPlaying extends LitElement {
<span
class="track-artist ${artistScrolling ? 'will-scroll' : ''} ${this.artistScrolling ? 'scrolling' : ''}"
data-testid="now-playing-artist"
title=${track.artist || 'Unknown Artist'}
@mouseenter=${this.handleArtistMouseEnter}
@mouseleave=${this.handleArtistMouseLeave}
@transitionend=${() => this.onScrollCycleEnd('artist')}
@@ -429,9 +463,21 @@ export class NowPlaying extends LitElement {
this.loadScrollMode();
};
private handleReduceMotionChange = (e: MediaQueryListEvent): void => {
this.reduceMotion = e.matches;
};
private shouldScroll(field: 'title' | 'artist'): boolean {
const overflows = field === 'title' ? this.titleOverflows : this.artistOverflows;
// A stated OS-level preference outranks an app default the user
// may never have touched, so this comes before the mode — and it
// covers `hover` as well as `always`. Hover-scrolling is
// user-initiated and so arguably passes WCAG 2.2.2 on its own,
// but `reduce` is a request about motion, not about autoplay.
// The text falls back to the ellipsis every other mode uses.
if (this.reduceMotion) return false;
if (!overflows || this.scrollMode === 'never') return false;
if (this.scrollMode === 'always') return true;
@@ -477,9 +523,18 @@ export class NowPlaying extends LitElement {
const content = el.querySelector<HTMLElement>('.scroll-content');
const width = el.clientWidth;
// Both numbers come from the *child*, which is the box that
// holds the text. Asking the outer span whether it overflows
// only works while the child is an overflowing inline-block:
// once the non-scrolling state gives the child its own
// `overflow: hidden` (for the ellipsis), the parent stops
// overflowing and nothing ever arms the scroll again.
// `scrollWidth` reports the content size either way.
const full = content?.scrollWidth ?? 0;
return {
overflows: el.scrollWidth > width,
distance: content ? content.scrollWidth - width : 0,
overflows: full > width,
distance: content ? full - width : 0,
};
};
@@ -232,6 +232,61 @@ describe('<now-playing>', () => {
expect(queries()).toBeGreaterThan(0);
});
// a11y.15 (WCAG 2.2.2). Reproduced in the running app first: under an
// emulated `prefers-reduced-motion: reduce` the title still carried
// `will-scroll` with a 15s transition and the transform was still
// moving — the read landed in the *snap-back* half of the cycle,
// which is why suppressing the transition alone is not the fix.
//
// Both directions are asserted because a guard that suppresses
// everything passes the negative case for free, and a component that
// never scrolls at this width would too.
const LONG =
'An Exhaustively Overlong Track Title That Exists Solely To Find Out ' +
'Whether The Bottom Bar Truncates Or Overflows';
async function mountScrolling(reduce: boolean) {
const real = window.matchMedia.bind(window);
window.matchMedia = ((q: string) =>
q.includes('prefers-reduced-motion')
? {
matches: reduce,
media: q,
addEventListener() {},
removeEventListener() {},
}
: real(q)) as typeof window.matchMedia;
try {
localStorage.setItem('yj-now-playing-scroll-mode', 'always');
const el = await fixture('now-playing');
// The real host is sized by `--now-playing-width` on `.bottom-bar`,
// which the fixture does not have — so it is document-width here
// and nothing overflows, which made the positive case fail first.
el.style.width = '320px';
emit(Events.TrackChanged, { ...TRACK, title: LONG, trackChangeId: 10 });
await flush();
await settle(el);
return shadow(el, '.track-title')?.className ?? '';
} finally {
window.matchMedia = real;
localStorage.removeItem('yj-now-playing-scroll-mode');
}
}
it('scrolls an overflowing title when motion is not a problem', async () => {
expect(await mountScrolling(false)).toContain('will-scroll');
});
it('does not scroll at all under prefers-reduced-motion', async () => {
expect(await mountScrolling(true)).not.toContain('will-scroll');
});
it('looks the way it did last time', async () => {
const el = await fixture('now-playing');