From 11b4aaef6abe24d771560a01d03e6dcaa9e004fe Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 12 Aug 2026 22:58:56 -0400 Subject: [PATCH] fix(a11y): stop the now-playing marquee under reduced motion 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. --- e2e/specs/reduced-motion.spec.ts | 104 ++++++++++++++++++ .../src/components/now-playing/now-playing.ts | 65 ++++++++++- frontend/test/components/now-playing.test.ts | 55 +++++++++ 3 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 e2e/specs/reduced-motion.spec.ts diff --git a/e2e/specs/reduced-motion.spec.ts b/e2e/specs/reduced-motion.spec.ts new file mode 100644 index 0000000..b725dc9 --- /dev/null +++ b/e2e/specs/reduced-motion.spec.ts @@ -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 { + 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 { + 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'); + }); +}); diff --git a/frontend/src/components/now-playing/now-playing.ts b/frontend/src/components/now-playing/now-playing.ts index 3cb3090..f27e2fa 100644 --- a/frontend/src/components/now-playing/now-playing.ts +++ b/frontend/src/components/now-playing/now-playing.ts @@ -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 { this.onScrollCycleEnd('title')} @@ -380,6 +413,7 @@ export class NowPlaying extends LitElement { 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('.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, }; }; diff --git a/frontend/test/components/now-playing.test.ts b/frontend/test/components/now-playing.test.ts index 91a7857..71f4908 100644 --- a/frontend/test/components/now-playing.test.ts +++ b/frontend/test/components/now-playing.test.ts @@ -232,6 +232,61 @@ describe('', () => { 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');