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

88 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*** @ui-arch 动效兜底审计:跑起来不停的和「一动就位移的」都必须在 prefers-reduced-motion 下被显式关掉
*
* 为什么单独成门禁:`audit:visual` 只管字面量、`verify` 只管「动效时长走令牌」,没人管「动效有没有降级」。
* 两类必查:① 无限循环动画(转圈、扫光、进度条流纹、时间线脉冲)——前庭敏感用户的硬伤,漏一条整页都在抖;
* ② 悬停 / 按压位移(translate)——位移是最容易诱发不适的一类,纯装饰手感,减动效时必须归零。
* 只查这两类而不是所有过渡:颜色 / 阴影 / 透明度变化不产生空间位移,砍掉它们反而丢了状态反馈。
*/
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) })));
const shift = auditShift(rules);
return { total: entries.length, entries, gaps: entries.filter((e) => !e.covered), shift };
}
/*** 作用:悬停 / 按压位移的降级审计——逐个位移规则找对应降级,或以 ua- 通配写的全局归零规则兜底 ***/
function auditShift(rules) {
const isShift = (body) => /transform\s*:[^;]*translate|translate\s*:/.test(body); // 位移两种写法:transform 里的 translate / 独立的 translate 属性
const isState = (selector) => /:(hover|active)\b/.test(selector); // 只认交互态位移:布局位移(抽屉开合)不在降级范围
const nulled = rules.filter((r) => r.reduced && /transform\s*:\s*none|translate\s*:\s*none/.test(r.body));
const exact = new Set(nulled.flatMap((r) => splitSelectors(r.selector)));
const global = nulled.some((r) => isState(r.selector) && /\[class\^='?ua-'?\]|\[class\*='? ua-'?\]/.test(r.selector)); // 通配 ua- 类的全局规则视为覆盖全部组件
const entries = rules
.filter((r) => !r.reduced && isState(r.selector) && isShift(r.body))
.flatMap((r) => splitSelectors(r.selector).filter(isState).map((selector) => ({ selector, covered: global || exact.has(selector) })));
return { total: entries.length, entries, gaps: entries.filter((e) => !e.covered) };
}
/*** 作用:把审计结果打成文本表(人读模式) ***/
export function formatMotionReport({ entries, gaps, shift }) {
const lines = ['无限动画的减少动效兜底:'];
for (const e of entries) lines.push(` ${e.covered ? 'OK ' : 'MISS'} ${e.selector}`);
lines.push(gaps.length ? `缺兜底 ${gaps.length} 处:${gaps.map((g) => g.selector).join(' / ')}` : `全部 ${entries.length} 处无限动画都有 prefers-reduced-motion 兜底`);
lines.push('', '悬停 / 按压位移的减少动效兜底:');
const ok = shift.entries.filter((e) => e.covered).length;
lines.push(shift.gaps.length ? ` MISS 未归零 ${shift.gaps.length} 处:${shift.gaps.map((g) => g.selector).join(' / ')}` : ` 全部 ${ok} 处位移在减少动效下归零(悬停上浮 / 按压回缩)`);
return lines.join('\n');
}