101 lines
6.6 KiB
JavaScript
101 lines
6.6 KiB
JavaScript
/*** @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);
|
||
const spatial = auditSpatial(rules);
|
||
return { total: entries.length, entries, gaps: entries.filter((e) => !e.covered), shift, spatial };
|
||
}
|
||
|
||
/*** 作用:悬停 / 按压位移的降级审计——逐个位移规则找对应降级,或以 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) };
|
||
}
|
||
|
||
/*** 作用:旋转 / 缩放过渡的降级审计——第三类空间动效,允许用一条通配 transition-property 收窄兜底 ***/
|
||
function auditSpatial(rules) {
|
||
const isSpatial = (body) => /transition[^;]*\b(rotate|scale|transform)\b/.test(body); // 过渡里提到旋转 / 缩放 / 变换的都算
|
||
// 兜底形态:减少动效块里用通配 ua- 类把 transition-property 收窄(只留颜色类属性)
|
||
const global = rules.some((r) => r.reduced && /transition-property\s*:/.test(r.body) && /\[class\^='?ua-'?\]|\[class\*='? ua-'?\]/.test(r.selector));
|
||
const entries = rules
|
||
.filter((r) => !r.reduced && isSpatial(r.body))
|
||
.flatMap((r) => splitSelectors(r.selector).map((selector) => ({ selector, covered: global })));
|
||
return { total: entries.length, entries, gaps: entries.filter((e) => !e.covered) };
|
||
}
|
||
/*** 作用:把审计结果打成文本表(人读模式) ***/
|
||
export function formatMotionReport({ entries, gaps, shift, spatial }) {
|
||
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} 处位移在减少动效下归零(悬停上浮 / 按压回缩)`);
|
||
lines.push('', '旋转 / 缩放过渡的减少动效兜底:');
|
||
lines.push(spatial.gaps.length ? ` MISS 未退化 ${spatial.gaps.length} 处:${spatial.gaps.map((g) => g.selector).join(' / ')}` : ` 全部 ${spatial.total} 处旋转 / 缩放过渡在减少动效下退化为瞬时(transition-property 收窄兜底)`);
|
||
return lines.join('\n');
|
||
}
|