Files
novalon-website/scripts/audit/motion-duration-audit.mjs
T
zhangxiang ad0b265ef2 refactor(motion): 收敛末批 P1 入场时长与变量递增 stagger 步进
动效合规专项(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
2026-09-20 11:50:58 +08:00

204 lines
9.7 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.
// 动效时长合规审计(Task #16 专项盘点)
//
// 权威约束:CONTEXT.md L49-61「动效设计四原则」(✅ 2026-06-29 确认)
// 原则2 Fast :入场 200-300mshover 150ms,反馈 100ms
// 原则4 Layered:子元素入场 stagger 30-60msSection 间 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} · INFOdelay 仅记录):${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`);