diff --git a/src/preview/cdp.ts b/src/preview/cdp.ts index 6835499..7c09314 100644 --- a/src/preview/cdp.ts +++ b/src/preview/cdp.ts @@ -1031,6 +1031,24 @@ const SLOP_AUDIT = `(() => { const cardish = kids.every((k) => k.querySelector('svg, img') && k.querySelector('h2,h3,h4') && k.querySelector('p')); if (sameSize && cardish) { out.push('identical icon-card grid (' + kids.length + ' cards)'); break; } } + // Reduced-motion respect: infinite CSS animations with no + // prefers-reduced-motion guard anywhere spin forever for users who + // asked motion to stop (vestibular triggers). + let infiniteAnims = 0; + for (const el of document.querySelectorAll('*')) { + if (infiniteAnims > 0) break; + const cs = getComputedStyle(el); + if (cs.animationIterationCount === 'infinite' && cs.animationName !== 'none' && parseFloat(cs.animationDuration) > 0) infiniteAnims++; + } + if (infiniteAnims > 0) { + let guarded = false; + for (const sheet of document.styleSheets) { + let rules; try { rules = sheet.cssRules; } catch { continue; } + for (const r of rules) { if (r.media && /prefers-reduced-motion/.test(r.media.mediaText || '')) { guarded = true; break; } } + if (guarded) break; + } + if (!guarded) out.push('reduced motion: infinite animation(s) run with no prefers-reduced-motion guard anywhere — they never stop for users who disabled motion'); + } let emojiBullets = 0; for (const li of document.querySelectorAll('li')) { if (/^[\\u{1F300}-\\u{1FAFF}\\u{2600}-\\u{27BF}]/u.test((li.textContent || '').trim())) emojiBullets++; diff --git a/test/cdp.test.ts b/test/cdp.test.ts index 71ed7b5..7f6dd85 100644 --- a/test/cdp.test.ts +++ b/test/cdp.test.ts @@ -78,6 +78,8 @@ describe.skipIf(!chrome || !hasWebSocket())('cdpCapture (requires Chrome + WebSo .c1{color:#111}.c2{color:#222}.c3{color:#333}.c4{color:#444}.c5{color:#555} .c6{color:#666}.c7{color:#777}.c8{color:#888}.c9{color:#999}.c10{color:#aaa} .real-thing{font-weight:bold} + @keyframes spin { to { transform: rotate(360deg) } } + .spinner { animation: spin 1s linear infinite } @font-face { font-family: 'Slopsans'; src: url('slopsans.woff2'); } .card-a{view-transition-name:hero-card} .card-b{view-transition-name:hero-card} @@ -93,6 +95,7 @@ describe.skipIf(!chrome || !hasWebSocket())('cdpCapture (requires Chrome + WebSo +
loading
Get started
a
b
tip
@@ -189,6 +192,7 @@ describe.skipIf(!chrome || !hasWebSocket())('cdpCapture (requires Chrome + WebSo expect(slop).toContain('generic font stack: arial') expect(slop).toContain('emoji-bulleted') expect(slop).toContain('the Purple Problem') + expect(slop).toContain('reduced motion: infinite animation') expect(slop).toContain('font loading: @font-face "Slopsans" has no font-display') expect(slop).toContain('template CTA copy: "Get started"')