Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions BUILD-PLAN-issue-27.md
Original file line number Diff line number Diff line change
@@ -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
24 changes: 21 additions & 3 deletions bin/mint-ds.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
Expand Down Expand Up @@ -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) {
Expand Down
98 changes: 98 additions & 0 deletions lib/__tests__/css-lint-rules.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
lintGapDecorationsCompat,
lintGapDecorationAdoption,
lintCss,
lintMotionAccessibility,
} from '../css-lint-rules.mjs'

describe('parseCssRules', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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')
})
})
116 changes: 116 additions & 0 deletions lib/css-lint-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
24 changes: 24 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -183,6 +206,7 @@ export interface AuditReport {
adoptionSuggestions?: AdoptionSuggestion[]
overflowSafetyIssues?: OverflowSafetyIssue[]
propertyTypeIssues?: PropertyTypeIssue[]
motionAccessibilityAudit?: MotionAccessibilityAudit
}

export interface ColorDecision {
Expand Down
Loading