// 动效时长合规审计(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 的递增延迟) // 排除: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} · INFO(delay 仅记录):${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`);