Files
novalon-website/scripts/audit/motion-duration-audit.mjs
T
zhangxiang a9d55298fc fix(motion): ScrollReveal 默认入场 400ms→300ms,对齐动效四原则目标值
约束纠正:CONTEXT.md L49-61 规定入场 200-300ms 为**目标值**,700ms 只是
「禁止超过」的**硬上限**(L58)。此前按 700ms 执行属最低限度合规。

杠杆点改动:
- src/components/ui/scroll-reveal.tsx:69 duration 0.4 → 0.3
  该默认值被 22 个文件消费,一行改动即把全站默认入场拉到目标区间
- 同文件 staggerDelay/delayChildren = 0.05 已在 30-60ms 目标内,未动

新增审计工具(Task #16 各批次复用):
- scripts/audit/motion-duration-audit.mjs:扫 273 文件,抓 framer duration /
  默认参数 / Tailwind duration-NNN / staggerDelay·delayChildren·delay,
  按 P0(>700ms) / P1(300-700ms) / P2(stagger>60ms) 分级
  - 已修正两处自身缺陷:漏掉默认参数赋值 duration=N;CountUp/toast 的
    毫秒值误判为秒(加 >10 判毫秒排除 7 处误报)
- scripts/audit/motion-smoke.mjs:多路由 × 浅深运行时冒烟(hydration + 0 报错)

盘点基线(本次审计):P0 硬违规 99 / P1 超目标 164 / P2 stagger 28
- 其中 animations.tsx 20 处为死代码(生产零消费方,仅自家 test import),
  按「保留待用」决策不动,不计入用户可见违规 → 用户可见 P0 89 / P1 157 / P2 25

验证:tsc 0 错 · eslint 0 错(50 既有 warnings) · jest 129 套件/1628 通过
      冒烟 5 路由 × 浅深 = 10/10 PASS(hydration=true, errors=0)
2026-09-03 07:05:08 +08:00

139 lines
5.8 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 的递增延迟)
// 排除: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
/** 秒 → 严重度;返回 '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
for (const m of line.matchAll(/\bdelay:\s*([0-9]*\.?[0-9]+)/g)) {
const sec = parseFloat(m[1]);
rows.push({ file: f, ln, kind: 'delay', raw: m[0].trim(), sec, ms: Math.round(sec * 1000), sev: 'INFO' });
}
});
}
// 聚合
const byFile = {};
for (const r of rows) {
byFile[r.file] ??= { P0: 0, P1: 0, P2: 0, OK: 0, INFO: 0, MS: 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 };
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}\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`);