/*** @ui-arch 动效兜底审计:带 `infinite` 的动画必须在 prefers-reduced-motion 下被显式关掉 * * 为什么单独成门禁:`audit:visual` 只管字面量、`verify` 只管「动效时长走令牌」,没人管「无限循环动画有没有降级」。 * 循环动画(转圈、扫光、进度条流纹、时间线脉冲)是前庭敏感用户的硬伤,漏一条就等于把整页交给抖动; * 有限时长的过渡(hover 上浮之类)危害小得多,不在本门禁范围——只盯 infinite 才有明确可执行的判定。 */ import fs from 'node:fs'; import path from 'node:path'; /*** 作用:被审计的样式表相对仓库根的路径 ***/ const CSS_REL = 'packages/skin/ui/ui.css'; /*** 作用:极简 CSS 规则扫描器——按大括号层级切出 { 选择器, 规则体, 是否在减少动效媒体块内 }(尾缓冲在 ; 与 } 处清空,遇 { 即为选择器) ***/ export function scanRules(css) { const rules = []; const stack = []; let tail = ''; // 当前块内自上个 ; 或 } 以来的文本:遇 { 即为选择器 const clean = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').trim(); // 剥掉前导注释再比对 for (const ch of css) { if (ch === '{') { const prelude = clean(tail); stack.push({ prelude, reduced: prelude.startsWith('@media') && /prefers-reduced-motion/.test(prelude), body: '' }); tail = ''; continue; } if (ch === '}') { const frame = stack.pop(); if (!frame) { tail = ''; continue; } const parentReduced = stack.length ? stack[stack.length - 1].reduced : false; if (frame.prelude && !frame.prelude.startsWith('@')) { rules.push({ selector: frame.prelude, body: frame.body, reduced: frame.reduced || parentReduced }); } tail = ''; continue; } if (stack.length) stack[stack.length - 1].body += ch; tail += ch; if (ch === ';') tail = ''; } return rules; } /*** 作用:拆选择器组(`.a, .b` → 两项),去掉伪元素后缀,便于与降级块里的写法对齐 ***/ const splitSelectors = (selector) => selector.split(',').map((s) => s.trim().replace(/::?(before|after)/g, '')).filter(Boolean); /*** 作用:跑一遍审计:返回无限动画清单与缺口(有 animation 声明但没在降级块里关掉的) ***/ export function auditMotion(root) { const file = path.join(root, CSS_REL); if (!fs.existsSync(file)) throw new Error(`找不到 ${CSS_REL}`); const css = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); const rules = scanRules(css); // 无限动画:动画名后面带 infinite(本项目写法固定,正则认出关键片段即可) const infinite = rules.filter((r) => !r.reduced && /animation\s*:[^;]*\binfinite\b/.test(r.body)); // 降级:减少动效块里把动画关掉的规则(animation: none 或 duration 归零两种写法都算达标) const offline = new Set( rules.filter((r) => r.reduced && /animation\s*:\s*none|animation-duration\s*:\s*0/.test(r.body)) .flatMap((r) => splitSelectors(r.selector)), ); const entries = infinite.flatMap((r) => splitSelectors(r.selector).map((selector) => ({ selector, covered: offline.has(selector) }))); return { total: entries.length, entries, gaps: entries.filter((e) => !e.covered) }; } /*** 作用:把审计结果打成文本表(人读模式) ***/ export function formatMotionReport({ entries, gaps }) { const lines = ['无限动画的减少动效兜底:']; for (const e of entries) lines.push(` ${e.covered ? 'OK ' : 'MISS'} ${e.selector}`); lines.push(''); lines.push(gaps.length ? `缺兜底 ${gaps.length} 处:${gaps.map((g) => g.selector).join(' / ')}` : `全部 ${entries.length} 处无限动画都有 prefers-reduced-motion 兜底`); return lines.join('\n'); }