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