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
@@ -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');