From fcf6480f5bb021ba0165bc806cd0bc321431e63f Mon Sep 17 00:00:00 2001 From: Hermes BUILD <48018975+nujovich@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:54:22 +0200 Subject: [PATCH 1/3] feat(mint): add reduced-motion accessibility audit type and detection --- BUILD-PLAN-issue-27.md | 15 ++++ lib/__tests__/css-lint-rules.test.mjs | 69 +++++++++++++++++ lib/css-lint-rules.mjs | 104 ++++++++++++++++++++++++++ lib/types.ts | 24 ++++++ 4 files changed, 212 insertions(+) create mode 100644 BUILD-PLAN-issue-27.md diff --git a/BUILD-PLAN-issue-27.md b/BUILD-PLAN-issue-27.md new file mode 100644 index 0000000..7f9178a --- /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 +- [ ] Milestone 2 -- Implement parser identifying animation properties (`animation`, `transition`, `transform`) and verifying reduced-motion wrapping +- [ ] 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/lib/__tests__/css-lint-rules.test.mjs b/lib/__tests__/css-lint-rules.test.mjs index 3aaffdd..29ae412 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', () => { @@ -360,3 +361,71 @@ 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('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..fc7b47f 100644 --- a/lib/css-lint-rules.mjs +++ b/lib/css-lint-rules.mjs @@ -372,6 +372,110 @@ export function lintGapDecorationAdoption(css, opts = {}) { } } +const MOTION_PROPERTIES = new Set([ + 'animation', + 'animation-name', + 'transition', + 'transition-property', +]) + +/** + * 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` 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] + + 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 reduced = isReducedMotionMedia(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, + isAtRule: selector.startsWith('@'), + }) + reducedStack.push(inReduced || reduced) + selStart = -1 + } else if (ch === '}') { + reducedStack.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.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. */ diff --git a/lib/types.ts b/lib/types.ts index 7171834..8dc72c9 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' + 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 { From 45d8ade1f7eaf13675d67bf513d474a5b5d17ab5 Mon Sep 17 00:00:00 2001 From: Hermes BUILD <48018975+nujovich@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:53:31 +0200 Subject: [PATCH 2/3] feat(mint): add transform detection and keyframes handling to reduced-motion audit --- BUILD-PLAN-issue-27.md | 2 +- lib/__tests__/css-lint-rules.test.mjs | 9 +++++++++ lib/css-lint-rules.mjs | 16 +++++++++++++--- lib/types.ts | 2 +- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/BUILD-PLAN-issue-27.md b/BUILD-PLAN-issue-27.md index 7f9178a..2385773 100644 --- a/BUILD-PLAN-issue-27.md +++ b/BUILD-PLAN-issue-27.md @@ -10,6 +10,6 @@ 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 -- [ ] Milestone 2 -- Implement parser identifying animation properties (`animation`, `transition`, `transform`) and verifying reduced-motion wrapping +- [x] Milestone 2 -- Implement parser identifying animation properties (`animation`, `transition`, `transform`) and verifying reduced-motion wrapping - [ ] 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/lib/__tests__/css-lint-rules.test.mjs b/lib/__tests__/css-lint-rules.test.mjs index 29ae412..064112f 100644 --- a/lib/__tests__/css-lint-rules.test.mjs +++ b/lib/__tests__/css-lint-rules.test.mjs @@ -419,6 +419,15 @@ describe('lintMotionAccessibility', () => { 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); } } ' + diff --git a/lib/css-lint-rules.mjs b/lib/css-lint-rules.mjs index fc7b47f..3b77090 100644 --- a/lib/css-lint-rules.mjs +++ b/lib/css-lint-rules.mjs @@ -377,6 +377,7 @@ const MOTION_PROPERTIES = new Set([ 'animation-name', 'transition', 'transition-property', + 'transform', ]) /** @@ -385,8 +386,8 @@ const MOTION_PROPERTIES = new Set([ * * Users who opt into reduced motion should not receive full-strength motion * effects. This rule finds every `animation` / `animation-name` / `transition` - * / `transition-property` declaration that sits outside such a wrapper and - * emits a per-declaration issue with a wrap suggestion. + * / `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`. @@ -416,13 +417,18 @@ export function lintMotionAccessibility(css) { 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 @@ -438,12 +444,15 @@ export function lintMotionAccessibility(css) { 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 @@ -451,7 +460,8 @@ export function lintMotionAccessibility(css) { } for (const rule of rules) { - if (rule.reduced || rule.inReduced || rule.isAtRule) continue + if (rule.reduced || rule.inReduced || rule.inKeyframes || rule.isAtRule) + continue declRe.lastIndex = 0 let declMatch while ((declMatch = declRe.exec(rule.body)) !== null) { diff --git a/lib/types.ts b/lib/types.ts index 8dc72c9..66ab425 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -137,7 +137,7 @@ export interface MotionAudit { */ export interface MotionAccessibilityIssue { selector: string - property: string // 'animation' | 'animation-name' | 'transition' | 'transition-property' + property: string // 'animation' | 'animation-name' | 'transition' | 'transition-property' | 'transform' value: string suggestion: string } From 011e79ed924b05dce48ef124f86e1590375e436c Mon Sep 17 00:00:00 2001 From: Hermes BUILD <48018975+nujovich@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:52:12 +0200 Subject: [PATCH 3/3] feat(mint): surface reduced-motion report with count and wrap suggestion --- BUILD-PLAN-issue-27.md | 2 +- bin/mint-ds.mjs | 24 +++++++++++++++++++++--- lib/__tests__/css-lint-rules.test.mjs | 20 ++++++++++++++++++++ lib/css-lint-rules.mjs | 2 ++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/BUILD-PLAN-issue-27.md b/BUILD-PLAN-issue-27.md index 2385773..774280e 100644 --- a/BUILD-PLAN-issue-27.md +++ b/BUILD-PLAN-issue-27.md @@ -11,5 +11,5 @@ PLAN (2026-07-10) defined 4 concrete code milestones. No further decision needed - [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 -- [ ] Milestone 3 -- Add report with count of animations not respecting user motion preference and wrap suggestion +- [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 064112f..cb9930a 100644 --- a/lib/__tests__/css-lint-rules.test.mjs +++ b/lib/__tests__/css-lint-rules.test.mjs @@ -225,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', () => { diff --git a/lib/css-lint-rules.mjs b/lib/css-lint-rules.mjs index 3b77090..e255428 100644 --- a/lib/css-lint-rules.mjs +++ b/lib/css-lint-rules.mjs @@ -492,7 +492,9 @@ export function lintMotionAccessibility(css) { 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, } }