iter(g250): 新增动效兜底门禁,补上转圈与不确定进度的减少动效降级

This commit is contained in:
HIsheR
2026-10-26 22:07:53 +08:00
parent cd20b0ce99
commit b6229e2a1c
11 changed files with 131 additions and 18 deletions
+12
View File
@@ -5283,3 +5283,15 @@ h5.ua-type-title { font-size: var(--ua-font-size-md); }
max-width: 100%;
overflow-wrap: anywhere;
}
/* ---- 减少动效补充兜底:转圈与不确定态进度是仅有的两处漏网无限动画 ----
放在文件末尾:与既有 prefers-reduced-motion 清单同序,后置胜出即可压住源码序更早的 animation 声明 */
@media (prefers-reduced-motion: reduce) {
.ua-spinner {
animation: none; /* 转圈停下:保留缺角静态环,仍看得出是「加载中」而不是坏图标 */
}
.ua-progress-fill.ua-indeterminate {
animation: none;
width: 100%; /* 不确定态降级为满宽淡条:不再左右扫,语义仍是「进行中」 */
}
}
+25 -2
View File
@@ -7,6 +7,7 @@ import { spawnSync } from 'node:child_process';
import { visualAudit } from '../lib/visual-audit.mjs';
import { auditContrast, formatReport, PAIRS } from '../lib/contrast-audit.mjs';
import { shellPalette } from '../lib/shell-palette.mjs';
import { auditMotion, formatMotionReport } from '../lib/motion-audit.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -1625,6 +1626,25 @@ function cmdAuditContrast(args) {
process.exit(result.failures.length === 0 ? 0 : 1);
}
/*** 动效兜底审计:带 infinite 的动画必须在 prefers-reduced-motion 下被显式关掉(--json 出明细)
* 只盯无限循环动画:有限时长的过渡不会持续刺激,把它也纳入只会让门禁变吵而没人看。
*/
function cmdAuditMotion(args) {
let result;
try {
result = auditMotion(ROOT);
} catch (err) {
console.error(`audit:motion 失败:${err.message}`);
process.exit(1);
}
if (args.includes('--json')) {
console.log(JSON.stringify({ ok: result.gaps.length === 0, total: result.total, failing: result.gaps.length, entries: result.entries }, null, 2));
} else {
console.log(formatMotionReport(result));
}
process.exit(result.gaps.length === 0 ? 0 : 1);
}
/*** 迭代留痕校验:.ua/iterations.log 每行 JSON 字段与格式必须符合 ITERATION.md 规范 ***/
function cmdLintIterations() {
const file = path.join(INDEX_DIR, 'iterations.log');
@@ -1722,7 +1742,7 @@ function cmdDoctor() {
detail: [missingInUsage.length ? `usage 缺 ${missingInUsage.join('/')}` : '', missingInCode.length ? `实现缺 ${missingInCode.join('/')}` : ''].filter(Boolean).join(';'),
});
// 门禁链自检:package.json 的 check 必须跑齐所有门禁步骤(新增门禁忘了接进链等于没加)
const needSteps = ['build', 'demo', 'style', 'typecheck', 'smoke', 'index', 'verify', 'lint-iterations', 'audit:visual', 'audit:contrast'];
const needSteps = ['build', 'demo', 'style', 'typecheck', 'smoke', 'index', 'verify', 'lint-iterations', 'audit:visual', 'audit:contrast', 'audit:motion'];
const chain = String(JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).scripts?.check ?? '');
const lacking = needSteps.filter((step) => !chain.includes(`npm run ${step}`));
checks.push({ name: 'check 链覆盖全部门禁步骤', pass: !lacking.length, detail: lacking.length ? `缺 ${lacking.join(' / ')}` : '' });
@@ -1798,10 +1818,13 @@ switch (cmd) {
case 'audit:contrast':
cmdAuditContrast(args);
break;
case 'audit:motion':
cmdAuditMotion(args);
break;
case 'mcp':
cmdMcp();
break;
default:
console.log('usage: ua <index|list|search <kw>|info <name> [--json|--md]|recipes [编号] [--json]|docs|tokens [--json]|style|sync-counts|verify|doctor|commit-lint|lint-iterations|audit:visual [--json|--baseline]|audit:contrast [--json|--list]|changelog|mcp>');
console.log('usage: ua <index|list|search <kw>|info <name> [--json|--md]|recipes [编号] [--json]|docs|tokens [--json]|style|sync-counts|verify|doctor|commit-lint|lint-iterations|audit:visual [--json|--baseline]|audit:contrast [--json|--list]|audit:motion [--json]|changelog|mcp>');
process.exit(cmd ? 1 : 0);
}
+70
View File
@@ -0,0 +1,70 @@
/*** @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');
}
+4 -2
View File
@@ -22,6 +22,7 @@
"ua commit-lint <文件|message>:提交信息门禁(type 枚举 / scope 规则 / subject 中文祈使 ≤50 字符),规范见 COMMITTING.md,违规退出码 1",
"ua audit:visual [--json|--baseline]:视觉漂移门禁,统计皮肤包里各类硬编码视觉字面量(字号/动效时长/阴影/圆角/焦点环/内距/控件高度/字重/行高/层级/边框色)并与 .ua/visual-baseline.json 棘轮比对,超基线退出码 1;--baseline 重写基线,--json 出机器可读计数与每类命中行号(line / prop / value / hint,Agent 可直接定位)",
"ua audit:contrast [--json|--list]:对比度门禁,把令牌里 16 组「文字色 × 底色」配对按 WCAG 公式算成对比度(求值支持 var() 与 color-mix,半透明底先压到页面底色),亮/暗 × 常规/高对比四套分算,低于阈值(正文 7 / 常规 4.5 / 禁用 2.6)退出码 1;--json 出逐对明细,--list 只列配对表",
"ua audit:motion [--json]:动效兜底门禁,扫描皮肤样式表里带 infinite 的动画,逐个核对是否在 prefers-reduced-motion 块里 animation: none,缺兜底退出码 1(--json 出逐条明细)",
"ua mcp:零依赖 stdio MCP server,暴露 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe"
],
"keyTypes": [
@@ -29,7 +30,7 @@
"MCP tool 入参 schema"
]
},
"usage": "node packages/tools/cli/bin/ua.js <index|list|info <包名|组件id> [--json|--md]|docs|tokens|style|verify|commit-lint|audit:visual|audit:contrast|doctor|mcp>;仓库内等价于 npm run index / docs / style / verify / audit:visual / audit:contrast / doctor",
"usage": "node packages/tools/cli/bin/ua.js <index|list|info <包名|组件id> [--json|--md]|docs|tokens|style|verify|commit-lint|audit:visual|audit:contrast|audit:motion|doctor|mcp>;仓库内等价于 npm run index / docs / style / verify / audit:visual / audit:contrast / audit:motion / doctor",
"conventions": [
"零依赖:只用 node 标准库,Node >= 18",
"清单是单一来源:代码注释、文档站、llms-full.txt、MCP 全部从 ua.manifest.json 派生",
@@ -38,13 +39,14 @@
"docs": {
"intro": "工程层工具包(private,不发布)。所有 Agent 与人都通过它读取组件库:清单驱动索引、文档、MCP 三件套,外加 style / verify 两道门禁。",
"steps": [
"接手先跑 ua doctor(11 项:node / 依赖 / git 钩子与身份 / 元数据 / 命令面三方一致 / 门禁链完整,秒级),再跑 npm run check 确认基线全绿(build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual → audit:contrast)",
"接手先跑 ua doctor(11 项:node / 依赖 / git 钩子与身份 / 元数据 / 命令面三方一致 / 门禁链完整,秒级),再跑 npm run check 确认基线全绿(build → demo → style → typecheck → smoke → index → verify → lint-iterations → audit:visual → audit:contrast → audit:motion)",
"查组件:ua list 看全量,ua search <关键词> 按 id/标题/参数名/参数说明/步骤五层 + 配方三层(标题/配方组件/要点)模糊找(命中非 id 标题会标出命中字段,配方命中行直接给 ua recipes <编号> 指路;拼错会回相近 id),ua info <组件id> 看 props/步骤/示例(ua info <包名> 看单包 api 与约定,加 --json 出原始清单);要给文档 / issue / 回复贴组件说明时加 --md,直接拿到标题 + 参数表 + 用法 + 示例的 Markdown 片段",
"查配方:ua recipes 列全量(编号 / 标题 / 用到的组件),ua recipes <编号> 看骨架代码与要点(--json 给脚本用;MCP 里对应 list_recipes / get_recipe)",
"查令牌:ua tokens 打印全部 --ua-* 变量(颜色带暗色值),--json 输出结构化数组;写样式前先查,禁止硬编码。令牌分十三组:颜色 / 混色浓度 / 圆角 / 间距 / 字号 / 字重行高 / 边框 / 阴影 / 动效 / 位移 / 焦点环 / 控件高度 / 层级,color-mix 混入浓度用 --ua-alpha-soft/tint/line/mid/strong/heavy,弹层阴影用 --ua-shadow-1..4,过渡用 --ua-duration-* + --ua-ease,transform 位移用 --ua-translate-*(负号 calc(-1 * var(...))),焦点环用 --ua-focus-ring(_inset)",
"改完清单跑 npm run docs:同时刷新人类文档站与 llms-full.txt(Agent 首选喂料)",
"改完样式跑 npm run audit:visual:各类硬编码计数不得高于 .ua/visual-baseline.json(棘轮只减不增);字面量可用 npm run visual:migrate 幂等改写成令牌",
"改完颜色令牌跑 npm run audit:contrast:16 组文字/底色配对在亮/暗 × 常规/高对比四套主题下都必须达标(正文 7 / 常规 4.5 / 禁用 2.6),跌破即退出码 1——调一个色值就能让全站辅助文字掉到 4.4,人眼看不出但门禁看得出",
"改完动效跑 npm run audit:motion:带 infinite 的动画必须在 prefers-reduced-motion 块里被关掉,缺一条即退出码 1——循环动画漏兜底会把「减少动效」用户整页交给抖动",
"在 ~/.workbuddy/mcp.json 注册 ua mcp 后,Agent 可直接调 list_components / get_component / search_components / get_tokens / list_recipes / get_recipe"
],
"components": []