动效合规专项(Task #16)收尾。
改动:
- page-transition.tsx:入场 duration 0.4/0.5 → 0.3(末处真 P1,由 client-layout 消费)
- 17 文件 21 处子元素 stagger 步进 80/100ms → 60ms
(CONTEXT.md 原则4「子元素 stagger 30-60ms」;属已批准决策 04e91e3 的漏网,
因当时审计脚本看不见 delay: i * N 的乘法写法)
- 审计脚本补盲区:新增变量递增 stagger 识别 + 明细表(此前漏掉全站 33 处)
- 审计脚本新增 NON_ENTRANCE 排除:flip-clock 机械翻转、design-system 死代码令牌
- delay 判据自我纠正:硬禁令约束的是 duration(动效时长),
delay 属调度而非动效本身,一律判 P2(偏离指引),不得判 P0
门禁(全绿):
- tsc 0 error
- eslint 0 error(50 warnings 均为既有)
- jest 130/130 套件,1573 通过 / 2 跳过 / 0 失败
- 运行时冒烟 16/16 PASS(8 路由 × 浅深双版,0 控制台报错)
- 审计:P0=0 · P1=0 · stagger 步进 P2=0
204 lines
9.7 KiB
JavaScript
204 lines
9.7 KiB
JavaScript
// 动效时长合规审计(Task #16 专项盘点)
|
||
//
|
||
// 权威约束:CONTEXT.md L49-61「动效设计四原则」(✅ 2026-06-29 确认)
|
||
// 原则2 Fast :入场 200-300ms,hover 150ms,反馈 100ms
|
||
// 原则4 Layered:子元素入场 stagger 30-60ms,Section 间 stagger 100-150ms
|
||
// 禁止事项 :禁止超过 700ms 的入场动效(硬上限)
|
||
// 注:700ms 是「禁止超过」的硬上限,不是目标值。目标值是 200-300ms。
|
||
//
|
||
// 扫描四类写法:
|
||
// 1. framer-motion transition: { duration: <秒> }
|
||
// 2. Tailwind CSS 类 duration-<毫秒>
|
||
// 3. StaggerReveal staggerDelay / delayChildren
|
||
// 4. 显式 delay(含 i * N 的递增延迟)
|
||
// 5. 变量递增 stagger 步进:delay: index * 0.08(子元素逐个入场的步进值)
|
||
// 排除:node_modules / dist / coverage / e2e / _archive / *.test.* / *.spec.*
|
||
|
||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||
import { execSync } from 'node:child_process';
|
||
|
||
const OUT = 'dogfood-motion-audit';
|
||
mkdirSync(OUT, { recursive: true });
|
||
|
||
const files = execSync(
|
||
"find src -type f \\( -name '*.ts' -o -name '*.tsx' \\) " +
|
||
"! -path '*/_archive/*' ! -name '*.test.*' ! -name '*.spec.*' | sort",
|
||
{ encoding: 'utf8' }
|
||
).trim().split('\n');
|
||
|
||
// 阈值
|
||
const ENTER_TARGET_MAX = 0.3; // 入场目标上限 300ms
|
||
const ENTER_HARD_MAX = 0.7; // 入场硬上限 700ms(>700ms 禁止)
|
||
const HOVER_MAX = 0.15; // hover 150ms
|
||
const STAGGER_MAX = 0.06; // 子元素 stagger 60ms
|
||
const SECTION_STAGGER_MAX = 0.15; // Section 间 stagger 150ms(原则4 上限)
|
||
|
||
/** 秒 → 严重度;返回 'MS' 表示判定为毫秒单位(计数器/通知时长,非入场动效) */
|
||
function severity(sec) {
|
||
// 单位启发式:framer-motion duration 恒为秒,实际取值 ≤ 几秒。
|
||
// 数值 > 10 只可能是毫秒单位(CountUp 1800/2000、toast 3000 等),
|
||
// 这类是计数器/自动消失时长,不属「入场动效」,不参与 700ms 禁令判定。
|
||
if (sec > 10) return 'MS';
|
||
if (sec > ENTER_HARD_MAX) return 'P0'; // 硬禁止:>700ms
|
||
if (sec > ENTER_TARGET_MAX) return 'P1'; // 超目标:300-700ms
|
||
return 'OK';
|
||
}
|
||
|
||
const rows = [];
|
||
|
||
for (const f of files) {
|
||
let src;
|
||
try { src = readFileSync(f, 'utf8'); } catch { continue; }
|
||
const lines = src.split('\n');
|
||
|
||
lines.forEach((line, i) => {
|
||
const ln = i + 1;
|
||
const trimmed = line.trim();
|
||
// 跳过注释行
|
||
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
|
||
|
||
// 1) framer-motion 时长:两种写法都要抓
|
||
// a) 对象字面量:transition={{ duration: 0.8 }}
|
||
// b) 默认参数赋值:duration = 0.4 <-- 漏掉这个会漏掉共享组件默认值(ScrollReveal 400ms)
|
||
for (const m of line.matchAll(/duration:\s*([0-9]*\.?[0-9]+)\s*([,}])/g)) {
|
||
const sec = parseFloat(m[1]);
|
||
rows.push({ file: f, ln, kind: 'framer-duration', raw: m[0].trim(), sec, ms: Math.round(sec * 1000), sev: severity(sec) });
|
||
}
|
||
for (const m of line.matchAll(/\bduration\s*=\s*([0-9]*\.?[0-9]+)/g)) {
|
||
const sec = parseFloat(m[1]);
|
||
rows.push({ file: f, ln, kind: 'default-param', raw: m[0].trim(), sec, ms: Math.round(sec * 1000), sev: severity(sec) });
|
||
}
|
||
|
||
// 2) Tailwind: duration-300 / duration-700 / duration-1000 等
|
||
for (const m of line.matchAll(/duration-([0-9]{2,4})\b/g)) {
|
||
const ms = parseInt(m[1], 10);
|
||
const sec = ms / 1000;
|
||
rows.push({ file: f, ln, kind: 'tailwind-duration', raw: m[0], sec, ms, sev: severity(sec) });
|
||
}
|
||
|
||
// 3) staggerDelay / delayChildren
|
||
for (const m of line.matchAll(/(staggerDelay|delayChildren)\s*[=:]\s*\{?\s*([0-9]*\.?[0-9]+)/g)) {
|
||
const sec = parseFloat(m[2]);
|
||
rows.push({
|
||
file: f, ln, kind: m[1], raw: m[0].trim(), sec, ms: Math.round(sec * 1000),
|
||
sev: sec > STAGGER_MAX ? 'P2' : 'OK',
|
||
});
|
||
}
|
||
|
||
// 4) 显式 delay(非 delayChildren)
|
||
// 严重度说明(2026-09-03 自我纠正,勿再拔高):
|
||
// CONTEXT.md L58 硬禁令原文是「禁止超过 700ms 的**入场动效**」——
|
||
// 动效时长 = duration(已全站收敛到 300ms)。delay 是**调度**而非动效本身,
|
||
// 不受该硬禁令约束,只受原则4「Section 间 stagger 100-150ms」这条**指引**约束。
|
||
// 故 delay 一律判 P2(偏离指引),不得判 P0。
|
||
// 另注:大 delay 多处为装饰性编排(标题下划线 scaleX:0→1、滚动提示 opacity),
|
||
// 属刻意编排而非内容入场,改动属设计判断,不属硬违规修复。
|
||
for (const m of line.matchAll(/\bdelay:\s*([0-9]*\.?[0-9]+)/g)) {
|
||
const sec = parseFloat(m[1]);
|
||
const durM = line.match(/\bduration:\s*([0-9]*\.?[0-9]+)/);
|
||
const dur = durM ? parseFloat(durM[1]) : 0;
|
||
const total = sec + dur;
|
||
rows.push({
|
||
file: f, ln, kind: 'delay', raw: m[0].trim(), sec, ms: Math.round(sec * 1000),
|
||
dur, totalMs: Math.round(total * 1000),
|
||
sev: sec > SECTION_STAGGER_MAX ? 'P2' : 'INFO',
|
||
});
|
||
}
|
||
|
||
// 5) 变量递增 stagger 步进:delay: index * 0.08 / delay: i * 0.1
|
||
// 这是 .map() 里子元素逐个入场的**步进值**(第 i 个元素延迟 = i × 步进),
|
||
// 判据同 delayChildren/staggerDelay,即原则4「子元素 stagger 30-60ms」。
|
||
// ⚠️ 盲区修复(2026-09-04):早期版本只匹配字面量 `delay: N`,
|
||
// 漏掉全站 33 处该写法(0.05×6 / 0.06×5 / 0.08×15 / 0.1×4),
|
||
// 其中 19 处步进 >60ms 属 P2 偏离,此前完全不可见。
|
||
for (const m of line.matchAll(/\bdelay:\s*[A-Za-z_$][\w$]*\s*\*\s*([0-9]*\.?[0-9]+)/g)) {
|
||
const sec = parseFloat(m[1]);
|
||
rows.push({
|
||
file: f, ln, kind: 'stagger-step', raw: m[0].trim(), sec, ms: Math.round(sec * 1000),
|
||
sev: sec > STAGGER_MAX ? 'P2' : 'OK',
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// 非入场动效排除清单(2026-09-03 修正:避免把机械/功能性动画误判为入场动效)
|
||
// 判定依据:CONTEXT.md L58 硬禁令针对的是「入场动效」,机械感、功能性动效不在此列。
|
||
const NON_ENTRANCE = [
|
||
// 翻页时钟的 rotateX 机械翻转:600ms 是刻意保留的机械质感,
|
||
// 按 200-300ms 入场标准压缩会让翻页显得抽搐。
|
||
{ file: 'src/components/ui/flip-clock.tsx', reason: '机械翻转动效,非入场' },
|
||
// design-system.ts 生产零消费方,且已按「标注陷阱」决策保留原值(见下方 P1 说明)
|
||
{ file: 'src/lib/constants/design-system.ts', reason: '死代码,已标注为令牌陷阱' },
|
||
];
|
||
|
||
// 应用排除:非入场动效的 P0/P1/P2 降级为 NA(不计入违规,但保留记录可追溯)
|
||
const nonEntranceSet = new Set(NON_ENTRANCE.map((n) => n.file));
|
||
for (const r of rows) {
|
||
if (nonEntranceSet.has(r.file) && (r.sev === 'P0' || r.sev === 'P1' || r.sev === 'P2')) {
|
||
r.sev = 'NA';
|
||
r.excludedReason = NON_ENTRANCE.find((n) => n.file === r.file)?.reason ?? '';
|
||
}
|
||
}
|
||
|
||
// 聚合
|
||
const byFile = {};
|
||
for (const r of rows) {
|
||
byFile[r.file] ??= { P0: 0, P1: 0, P2: 0, OK: 0, INFO: 0, MS: 0, NA: 0, items: [] };
|
||
byFile[r.file][r.sev]++;
|
||
byFile[r.file].items.push(r);
|
||
}
|
||
|
||
const viol = rows.filter((r) => r.sev === 'P0' || r.sev === 'P1' || r.sev === 'P2');
|
||
const counts = { P0: 0, P1: 0, P2: 0, OK: 0, INFO: 0, MS: 0, NA: 0 };
|
||
for (const r of rows) counts[r.sev]++;
|
||
|
||
const report = {
|
||
generatedAt: new Date().toISOString(),
|
||
scannedFiles: files.length,
|
||
totalMatches: rows.length,
|
||
counts,
|
||
filesWithViolations: Object.entries(byFile)
|
||
.filter(([, v]) => v.P0 + v.P1 + v.P2 > 0)
|
||
.map(([file, v]) => ({ file, P0: v.P0, P1: v.P1, P2: v.P2, items: v.items.filter((i) => i.sev !== 'OK' && i.sev !== 'INFO') }))
|
||
.sort((a, b) => (b.P0 - a.P0) || (b.P1 - a.P1) || (b.P2 - a.P2)),
|
||
allViolations: viol,
|
||
};
|
||
|
||
writeFileSync(`${OUT}/motion-audit.json`, JSON.stringify(report, null, 2));
|
||
|
||
// Markdown 摘要
|
||
const md = [];
|
||
md.push('# 动效时长合规审计(Task #16)\n');
|
||
md.push(`- 扫描文件:${files.length}(排除 _archive / test / spec / node_modules)`);
|
||
md.push(`- 匹配总数:${rows.length}`);
|
||
md.push(`- **P0 硬违规(>700ms,明确禁止):${counts.P0}**`);
|
||
md.push(`- P1 超目标(300-700ms):${counts.P1}`);
|
||
md.push(`- P2 stagger 超 60ms:${counts.P2}`);
|
||
md.push(`- OK(≤300ms / stagger ≤60ms):${counts.OK} · INFO(delay 仅记录):${counts.INFO}`);
|
||
md.push(`- 排除(MS 单位,计数器/通知时长,非入场动效):${counts.MS}`);
|
||
const delayP2 = rows.filter((r) => r.kind === 'delay' && r.sev === 'P2');
|
||
md.push(`- delay 超 Section 间 stagger 150ms 指引(属偏离指引,非硬违规;多处为装饰性编排):${delayP2.length}`);
|
||
const stepRows = rows.filter((r) => r.kind === 'stagger-step');
|
||
const stepP2 = stepRows.filter((r) => r.sev === 'P2');
|
||
md.push(`- 变量递增 stagger 步进(delay: i * N):${stepRows.length} 处,其中 >60ms 的 P2 偏离 ${stepP2.length} 处`);
|
||
if (stepP2.length) {
|
||
md.push('');
|
||
md.push('**stagger 步进 >60ms 明细(子元素 stagger 指引为 30-60ms):**');
|
||
md.push('');
|
||
md.push('| 文件 | 行 | 步进 |');
|
||
md.push('|---|---|---|');
|
||
for (const r of stepP2.sort((a, b) => b.ms - a.ms || a.file.localeCompare(b.file))) {
|
||
md.push(`| \`${r.file}\` | ${r.ln} | ${r.ms}ms |`);
|
||
}
|
||
}
|
||
md.push(`- 已排除(NA,非入场动效 / 死代码令牌):${counts.NA} —— ${NON_ENTRANCE.map((n) => n.file.split('/').pop()).join('、')}\n`);
|
||
md.push('| 文件 | P0 | P1 | P2 |');
|
||
md.push('|---|---|---|---|');
|
||
for (const f of report.filesWithViolations) {
|
||
md.push(`| \`${f.file}\` | ${f.P0} | ${f.P1} | ${f.P2} |`);
|
||
}
|
||
writeFileSync(`${OUT}/motion-audit.md`, md.join('\n'));
|
||
|
||
console.log(md.join('\n'));
|
||
console.log(`\n完整数据:${OUT}/motion-audit.json`);
|