Files
UI/packages/tools/cli/lib/motion-audit.mjs
T

71 lines
3.7 KiB
JavaScript

/*** @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');
}