From a9d55298fc5b2a9dceb1ab9aeaf9ef704c28828e Mon Sep 17 00:00:00 2001 From: zhangxiang Date: Thu, 3 Sep 2026 07:05:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(motion):=20ScrollReveal=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=85=A5=E5=9C=BA=20400ms=E2=86=92300ms=EF=BC=8C?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=8A=A8=E6=95=88=E5=9B=9B=E5=8E=9F=E5=88=99?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 约束纠正: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) --- scripts/audit/motion-duration-audit.mjs | 138 ++++++++++++++++++++++++ scripts/audit/motion-smoke.mjs | 78 ++++++++++++++ src/components/ui/scroll-reveal.tsx | 6 +- 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 scripts/audit/motion-duration-audit.mjs create mode 100644 scripts/audit/motion-smoke.mjs diff --git a/scripts/audit/motion-duration-audit.mjs b/scripts/audit/motion-duration-audit.mjs new file mode 100644 index 0000000..966406e --- /dev/null +++ b/scripts/audit/motion-duration-audit.mjs @@ -0,0 +1,138 @@ +// 动效时长合规审计(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`); diff --git a/scripts/audit/motion-smoke.mjs b/scripts/audit/motion-smoke.mjs new file mode 100644 index 0000000..a5db397 --- /dev/null +++ b/scripts/audit/motion-smoke.mjs @@ -0,0 +1,78 @@ +// 动效专项通用运行时冒烟(Task #16 各批次复用) +// +// 用途:验证批次改动后页面不破坏——hydration 成功、hero 渲染、0 控制台报错。 +// 说明:时长类改动(如 400ms→300ms)的**终态不变**,截图无法体现差异, +// 故本脚本不做截图比对,只验证运行时完整性与无回归报错。 +// (项目约定:网页任务保证编译通过 + 冒烟,不做额外视觉校验) +// +// 环境约束(2026-09-02 踩坑固化): +// - 必须 chromium.launch({ args: ['--no-proxy-server'] }):本机系统代理会拦 403 +// - 必须 localhost(非 127.0.0.1):Next dev allowedDevOrigins 白名单不含 127.0.0.1, +// 否则 _next/static/chunks/*.js 被 403 → framer-motion 加载失败 → hydration 从不发生 +// +// CLI: node scripts/audit/motion-smoke.mjs [--routes / /products/erp] [--dark] + +import { chromium } from 'playwright'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +const args = process.argv.slice(2); +const getList = (flag, def) => { + const i = args.indexOf(flag); + return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1].split(',') : def; +}; +const ROUTES = getList('--routes', ['/', '/products/erp', '/solutions', '/about']); +const WITH_DARK = args.includes('--dark'); + +const BASE = 'http://localhost:3000'; +const OUT = 'dogfood-motion-audit'; +mkdirSync(OUT, { recursive: true }); + +const browser = await chromium.launch({ args: ['--no-proxy-server'] }); + +async function check(path, theme) { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + if (theme === 'dark') { + await page.addInitScript(() => { + try { localStorage.setItem('novalon-theme', 'dark'); } catch {} + }); + } + const errors = []; + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + page.on('pageerror', (e) => errors.push('PAGEERROR: ' + e.message)); + + await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 45000 }); + + // hydration 探测:framer-motion 跑起来后会有元素 opacity 达到 1 + let hydrated = false; + try { + await page.waitForFunction(() => { + const els = Array.from(document.querySelectorAll('div[style*="opacity"]')); + return els.some((el) => getComputedStyle(el).opacity === '1'); + }, { timeout: 12000 }); + hydrated = true; + } catch { hydrated = false; } + + const h1 = await page.locator('h1').first().innerText().catch(() => ''); + await ctx.close(); + return { path, theme, hydrated, h1: h1.slice(0, 30), errors }; +} + +const results = []; +for (const r of ROUTES) { + results.push(await check(r, 'light')); + if (WITH_DARK) results.push(await check(r, 'dark')); +} +await browser.close(); + +const failed = results.filter((r) => !r.hydrated || r.errors.length > 0 || !r.h1); +const report = { generatedAt: new Date().toISOString(), routes: ROUTES, results, failedCount: failed.length }; +writeFileSync(`${OUT}/motion-smoke.json`, JSON.stringify(report, null, 2)); + +for (const r of results) { + const ok = r.hydrated && r.errors.length === 0 && r.h1 ? 'PASS' : 'FAIL'; + console.log(`${ok} ${r.theme.padEnd(5)} ${r.path.padEnd(18)} hydrated=${r.hydrated} errors=${r.errors.length} h1="${r.h1}"`); + r.errors.slice(0, 3).forEach((e) => console.log(` └ ${e.slice(0, 160)}`)); +} +console.log(`\n${results.length - failed.length}/${results.length} PASS`); +process.exit(failed.length > 0 ? 1 : 0); diff --git a/src/components/ui/scroll-reveal.tsx b/src/components/ui/scroll-reveal.tsx index fcfe473..878bd6d 100644 --- a/src/components/ui/scroll-reveal.tsx +++ b/src/components/ui/scroll-reveal.tsx @@ -66,7 +66,11 @@ export function ScrollReveal({ xOffset = 0, margin = '-40px', variant = 'fadeIn', - duration = 0.4, + // 入场时长默认值:CONTEXT.md L52「动效设计四原则 · Fast」规定入场 200-300ms。 + // 原值 0.4(400ms)超出目标区间,改为 300ms(目标上限)。 + // 注意:700ms 是「禁止超过」的硬上限(CONTEXT.md L58),不是目标值。 + // 本默认值被 22 个文件消费,改动影响面大,需视觉回归。 + duration = 0.3, once = true, amount = 0, }: ScrollRevealProps) {