diff --git a/scripts/audit/motion-duration-audit.mjs b/scripts/audit/motion-duration-audit.mjs index 966406e..9baeb39 100644 --- a/scripts/audit/motion-duration-audit.mjs +++ b/scripts/audit/motion-duration-audit.mjs @@ -11,6 +11,7 @@ // 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'; @@ -30,6 +31,7 @@ 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) { @@ -84,23 +86,70 @@ for (const f of files) { } // 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]); - rows.push({ file: f, ln, kind: 'delay', raw: m[0].trim(), sec, ms: Math.round(sec * 1000), sev: 'INFO' }); + 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, items: [] }; + 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 }; +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 = { @@ -126,7 +175,23 @@ 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(`- 排除(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) { diff --git a/src/app/(marketing)/about/about-content-v4.tsx b/src/app/(marketing)/about/about-content-v4.tsx index aa02803..b7d7353 100644 --- a/src/app/(marketing)/about/about-content-v4.tsx +++ b/src/app/(marketing)/about/about-content-v4.tsx @@ -134,7 +134,7 @@ function MetricsStrip({ data }: { data: AboutData }) { initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: '-80px' }} - transition={{ duration: 0.3, delay: i * 0.08, ease: EASE_OUT }} + transition={{ duration: 0.3, delay: i * 0.06, ease: EASE_OUT }} className="text-center lg:text-left" >