diff --git a/BUILD-PLAN-issue-27.md b/BUILD-PLAN-issue-27.md new file mode 100644 index 0000000..774280e --- /dev/null +++ b/BUILD-PLAN-issue-27.md @@ -0,0 +1,15 @@ +# BUILD-PLAN-issue-27.md + +**Card:** https://github.com/nujovich/mint-radar/issues/27 +**Title:** prefers-reduced-motion: gap de 4 anos sin resolver en stylelint + +## Decision + +PLAN (2026-07-10) defined 4 concrete code milestones. No further decision needed. + +## Milestones + +- [x] Milestone 1 -- Add `MotionAccessibilityAudit` type system detecting animation/transition declarations without a `@media (prefers-reduced-motion)` wrapper +- [x] Milestone 2 -- Implement parser identifying animation properties (`animation`, `transition`, `transform`) and verifying reduced-motion wrapping +- [x] Milestone 3 -- Add report with count of animations not respecting user motion preference and wrap suggestion +- [ ] Milestone 4 -- Add tests with the stylelint `prefers-reduced-motion` rule dataset diff --git a/bin/mint-ds.mjs b/bin/mint-ds.mjs index 4f96653..dd29011 100755 --- a/bin/mint-ds.mjs +++ b/bin/mint-ds.mjs @@ -527,11 +527,11 @@ async function cmdLint(argv) { ) const result = lintCss(css) - const { findings } = result + const { findings, motionAccessibility } = result - if (findings.length === 0) { + if (findings.length === 0 && motionAccessibility.unwrappedCount === 0) { log(styles.green('✓') + ' No lint issues found.') - } else { + } else if (findings.length > 0) { log('') log(styles.bold(`Found ${findings.length} issue(s):`)) log('') @@ -569,6 +569,24 @@ async function cmdLint(argv) { } log('') } + + // Reduced Motion: report declarations that are not wrapped in a + // @media (prefers-reduced-motion: reduce) block. + if (motionAccessibility.unwrappedCount > 0) { + log('') + log(styles.bold('Reduced Motion')) + log( + styles.dim( + ` ${motionAccessibility.unwrappedCount} of ${motionAccessibility.totalMotionDeclarations} motion declaration(s) do not respect prefers-reduced-motion: reduce` + ) + ) + for (const issue of motionAccessibility.issues) { + log(styles.yellow(' WARN') + ` ${issue.selector}`) + log(styles.dim(` ${issue.property}: ${issue.value}`)) + log(styles.dim(` ${issue.suggestion}`)) + log('') + } + } } async function cmdExport(argv) { diff --git a/lib/__tests__/css-lint-rules.test.mjs b/lib/__tests__/css-lint-rules.test.mjs index 3aaffdd..cb9930a 100644 --- a/lib/__tests__/css-lint-rules.test.mjs +++ b/lib/__tests__/css-lint-rules.test.mjs @@ -6,6 +6,7 @@ import { lintGapDecorationsCompat, lintGapDecorationAdoption, lintCss, + lintMotionAccessibility, } from '../css-lint-rules.mjs' describe('parseCssRules', () => { @@ -224,6 +225,26 @@ describe('lintCss', () => { const result = lintCss(css) expect(result.findings).toEqual([]) }) + + it('includes reduced-motion accessibility results in the aggregate', () => { + const css = '.hero { animation: pulse 1s infinite; }' + const result = lintCss(css) + expect(result.motionAccessibility.totalMotionDeclarations).toBe(1) + expect(result.motionAccessibility.unwrappedCount).toBe(1) + expect(result.motionAccessibility.issues).toHaveLength(1) + expect(result.motionAccessibility.issues[0].selector).toBe('.hero') + expect(result.motionAccessibility.issues[0].suggestion).toContain( + 'prefers-reduced-motion: reduce' + ) + }) + + it('returns zero reduced-motion findings for CSS without motion', () => { + const css = '.text { color: red; font-size: 16px; }' + const result = lintCss(css) + expect(result.motionAccessibility.totalMotionDeclarations).toBe(0) + expect(result.motionAccessibility.unwrappedCount).toBe(0) + expect(result.motionAccessibility.issues).toEqual([]) + }) }) describe('lintGapDecorationsCompat', () => { @@ -360,3 +381,80 @@ describe('lintGapDecorationAdoption', () => { expect(adoption.stylesheetsWithHacks).toBe(1) }) }) + +describe('lintMotionAccessibility', () => { + it('flags animation and transition declarations outside a reduced-motion wrapper', () => { + const css = '.b { animation: spin 2s; transition: opacity 0.3s; }' + const result = lintMotionAccessibility(css) + expect(result.totalMotionDeclarations).toBe(2) + expect(result.unwrappedCount).toBe(2) + expect(result.issues).toHaveLength(2) + expect(result.issues[0].selector).toBe('.b') + expect(result.issues[0].property).toBe('animation') + expect(result.issues[1].property).toBe('transition') + }) + + it('does not flag declarations wrapped in prefers-reduced-motion: reduce', () => { + const css = + '@media (prefers-reduced-motion: reduce) { .a { animation: fade 1s; } }' + const result = lintMotionAccessibility(css) + expect(result.totalMotionDeclarations).toBe(0) + expect(result.unwrappedCount).toBe(0) + expect(result.issues).toEqual([]) + }) + + it('flags only the unwrapped declaration when mixed with a reduced-motion override', () => { + const css = + '.hero { animation: pulse 1s infinite; } ' + + '@media (prefers-reduced-motion: reduce) { .hero { animation: none; } }' + const result = lintMotionAccessibility(css) + expect(result.totalMotionDeclarations).toBe(1) + expect(result.unwrappedCount).toBe(1) + expect(result.issues[0].selector).toBe('.hero') + expect(result.issues[0].value).toBe('pulse 1s infinite') + }) + + it('detects animation-name and transition-property longhands', () => { + const css = '.x { animation-name: slide; transition-property: transform; }' + const result = lintMotionAccessibility(css) + expect(result.totalMotionDeclarations).toBe(2) + expect(result.issues.map((i) => i.property)).toEqual([ + 'animation-name', + 'transition-property', + ]) + }) + + it('reports zero findings for CSS without motion', () => { + const css = '.text { color: red; font-size: 16px; }' + const result = lintMotionAccessibility(css) + expect(result.totalMotionDeclarations).toBe(0) + expect(result.unwrappedCount).toBe(0) + expect(result.issues).toEqual([]) + }) + + it('scans nested rules inside a non-reduced-motion media block', () => { + const css = '@media (max-width: 600px) { .c { animation: z 1s; } }' + const result = lintMotionAccessibility(css) + expect(result.totalMotionDeclarations).toBe(1) + expect(result.issues[0].selector).toBe('.c') + }) + + it('flags transform declarations outside a reduced-motion wrapper', () => { + const css = '.card { transform: translateX(24px) rotate(3deg); }' + const result = lintMotionAccessibility(css) + expect(result.totalMotionDeclarations).toBe(1) + expect(result.unwrappedCount).toBe(1) + expect(result.issues[0].property).toBe('transform') + expect(result.issues[0].value).toBe('translateX(24px) rotate(3deg)') + }) + + it('ignores keyframes internals and flags the animation reference', () => { + const css = + '@keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } } ' + + '.e { animation: spin 1s; }' + const result = lintMotionAccessibility(css) + expect(result.totalMotionDeclarations).toBe(1) + expect(result.issues[0].selector).toBe('.e') + expect(result.issues[0].value).toBe('spin 1s') + }) +}) diff --git a/lib/css-lint-rules.mjs b/lib/css-lint-rules.mjs index 2df989d..e255428 100644 --- a/lib/css-lint-rules.mjs +++ b/lib/css-lint-rules.mjs @@ -372,13 +372,129 @@ export function lintGapDecorationAdoption(css, opts = {}) { } } +const MOTION_PROPERTIES = new Set([ + 'animation', + 'animation-name', + 'transition', + 'transition-property', + 'transform', +]) + +/** + * Scan CSS for animation/transition declarations that are not wrapped in a + * `@media (prefers-reduced-motion: reduce)` block. + * + * Users who opt into reduced motion should not receive full-strength motion + * effects. This rule finds every `animation` / `animation-name` / `transition` + * / `transition-property` / `transform` declaration that sits outside such a + * wrapper and emits a per-declaration issue with a wrap suggestion. + * + * The return shape mirrors the `MotionAccessibilityAudit` interface in + * `lib/types.ts`. + * + * @param {string} css - Raw CSS source + * @returns {{ totalMotionDeclarations: number, unwrappedCount: number, issues: Array<{ selector: string, property: string, value: string, suggestion: string }> }} + */ +export function lintMotionAccessibility(css) { + const source = String(css) + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/\/\/[^\n]*/g, ' ') + + const issues = [] + let totalMotionDeclarations = 0 + const declRe = /([a-z-]+)\s*:\s*([^;]+);/gi + + const isReducedMotionMedia = (sel) => + /^@media\s*\([^)]*prefers-reduced-motion[^)]*:\s*reduce[^)]*\)\s*$/i.test( + sel + ) + + // Collect every rule block, tracking whether it sits inside a + // prefers-reduced-motion media block, so protected declarations can be + // skipped. `selStart` marks where the current (possibly nested) selector + // begins; the reduced-motion flag for the enclosing block is pushed/popped + // alongside braces. + const rules = [] + let selStart = -1 + const reducedStack = [false] + const keyframesStack = [false] + + const isKeyframesAtRule = (sel) => /^@(-[a-z]+-)?keyframes\b/i.test(sel) + + for (let i = 0; i < source.length; i++) { + const ch = source[i] + if (ch === '{') { + const selector = source.slice(selStart, i).trim() + const inReduced = reducedStack[reducedStack.length - 1] + const inKeyframes = keyframesStack[keyframesStack.length - 1] + const reduced = isReducedMotionMedia(selector) + const isKeyframes = isKeyframesAtRule(selector) + let bodyEnd = -1 + let braceDepth = 1 + let j = i + 1 + while (j < source.length && braceDepth > 0) { + if (source[j] === '{') braceDepth += 1 + else if (source[j] === '}') braceDepth -= 1 + if (braceDepth === 0) bodyEnd = j + j += 1 + } + const body = bodyEnd === -1 ? '' : source.slice(i + 1, bodyEnd) + rules.push({ + selector, + body, + reduced, + inReduced, + inKeyframes, + isAtRule: selector.startsWith('@'), + }) + reducedStack.push(inReduced || reduced) + keyframesStack.push(inKeyframes || isKeyframes) + selStart = -1 + } else if (ch === '}') { + reducedStack.pop() + keyframesStack.pop() + selStart = -1 + } else if (selStart === -1 && /\S/.test(ch) && ch !== '{' && ch !== ';') { + selStart = i + } + } + + for (const rule of rules) { + if (rule.reduced || rule.inReduced || rule.inKeyframes || rule.isAtRule) + continue + declRe.lastIndex = 0 + let declMatch + while ((declMatch = declRe.exec(rule.body)) !== null) { + const prop = declMatch[1].trim().toLowerCase() + if (!MOTION_PROPERTIES.has(prop)) continue + const value = declMatch[2].trim() + totalMotionDeclarations += 1 + issues.push({ + selector: rule.selector, + property: prop, + value, + suggestion: + 'Wrap this motion declaration in @media (prefers-reduced-motion: reduce) to respect user motion preferences.', + }) + } + } + + return { + totalMotionDeclarations, + unwrappedCount: issues.length, + issues, + } +} + /** * Run all lint rules against CSS and return combined findings. */ export function lintCss(css, projectDir) { const gapResult = lintGapDecorationHacks(css) const compatResult = lintGapDecorationsCompat(css, projectDir) + const motionAccessibility = lintMotionAccessibility(css) return { findings: [...gapResult.findings, ...compatResult.findings], + motionAccessibility, } } diff --git a/lib/types.ts b/lib/types.ts index 7171834..66ab425 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -130,6 +130,29 @@ export interface MotionAudit { duplicateDeclarations: number } +/** + * A single motion declaration (animation/transition) that is not wrapped in a + * `@media (prefers-reduced-motion: reduce)` block. Users who opt into reduced + * motion should never have these play at full strength. + */ +export interface MotionAccessibilityIssue { + selector: string + property: string // 'animation' | 'animation-name' | 'transition' | 'transition-property' | 'transform' + value: string + suggestion: string +} + +/** + * Aggregate of a reduced-motion accessibility scan: how many motion + * declarations were found, how many of them sit outside a + * `prefers-reduced-motion: reduce` wrapper, and the per-declaration issues. + */ +export interface MotionAccessibilityAudit { + totalMotionDeclarations: number + unwrappedCount: number + issues: MotionAccessibilityIssue[] +} + export interface LayoutA11yIssue { selector: string property: string // 'order' or 'tabindex' @@ -183,6 +206,7 @@ export interface AuditReport { adoptionSuggestions?: AdoptionSuggestion[] overflowSafetyIssues?: OverflowSafetyIssue[] propertyTypeIssues?: PropertyTypeIssue[] + motionAccessibilityAudit?: MotionAccessibilityAudit } export interface ColorDecision {