feat(ui): 营销站 UI/UE/UX 治理 P0→P3 + 数字口径 basis 机制 #28
@@ -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`);
|
||||
@@ -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);
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user