refactor(motion): 收敛末批 P1 入场时长与变量递增 stagger 步进

动效合规专项(Task #16)收尾。

改动:
- page-transition.tsx:入场 duration 0.4/0.5 → 0.3(末处真 P1,由 client-layout 消费)
- 17 文件 21 处子元素 stagger 步进 80/100ms → 60ms
  (CONTEXT.md 原则4「子元素 stagger 30-60ms」;属已批准决策 04e91e3 的漏网,
   因当时审计脚本看不见 delay: i * N 的乘法写法)
- 审计脚本补盲区:新增变量递增 stagger 识别 + 明细表(此前漏掉全站 33 处)
- 审计脚本新增 NON_ENTRANCE 排除:flip-clock 机械翻转、design-system 死代码令牌
- delay 判据自我纠正:硬禁令约束的是 duration(动效时长),
  delay 属调度而非动效本身,一律判 P2(偏离指引),不得判 P0

门禁(全绿):
- tsc 0 error
- eslint 0 error(50 warnings 均为既有)
- jest 130/130 套件,1573 通过 / 2 跳过 / 0 失败
- 运行时冒烟 16/16 PASS(8 路由 × 浅深双版,0 控制台报错)
- 审计:P0=0 · P1=0 · stagger 步进 P2=0
This commit is contained in:
2026-09-04 08:39:00 +08:00
parent aad714e268
commit 8f4a860180
18 changed files with 92 additions and 27 deletions
+69 -4
View File
@@ -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} · INFOdelay 仅记录):${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) {
@@ -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"
>
<div className={`text-3xl sm:text-4xl md:text-5xl font-black tracking-tight mb-2 ${i === 0 ? 'text-brand' : 'text-ink'}`}>
@@ -264,7 +264,7 @@ function MilestonesSection({ data }: { data: AboutData }) {
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.3, delay: idx * 0.08, ease: EASE_OUT }}
transition={{ duration: 0.3, delay: idx * 0.06, ease: EASE_OUT }}
className="relative pl-8 sm:pl-12 md:pl-16 pb-10 sm:pb-12 md:pb-14 last:pb-0"
>
<div className="absolute left-0 top-2 bottom-0 w-px bg-border-primary last:hidden" />
@@ -207,7 +207,7 @@ export default function CasesContentV3({
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"
>
<div className={`text-3xl sm:text-4xl md:text-5xl font-black tracking-tight mb-2 ${
+1 -1
View File
@@ -23,7 +23,7 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: index * 0.08, ease: EASE_OUT }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }}
>
<StaticLink
href={`/news/${newsItem.id}`}
@@ -15,7 +15,7 @@ function RelatedNewsCard({ news, index }: { news: NewsItem; index: number }) {
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: index * 0.1, ease: EASE_OUT }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }}
>
<a
href={`/news/${news.id}`}
@@ -96,7 +96,7 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: index * 0.08 }}
transition={{ duration: 0.3, delay: index * 0.06 }}
className="rounded-lg border border-border-primary bg-bg-secondary p-4"
>
<div className="mb-2 flex h-8 w-8 items-center justify-center rounded-lg bg-bg-primary text-text-muted">
@@ -227,7 +227,7 @@ function ProcessSection({ service }: { service: Service }) {
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.3, delay: index * 0.08, ease: EASE_OUT }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }}
className="relative group p-6 sm:p-8 bg-bg-primary hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 transition-all duration-150"
>
<div
@@ -209,7 +209,7 @@ function ServicesGridSection({ services, pageCopy }: { services?: Service[]; pag
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.3, delay: i * 0.08, ease: EASE_OUT }}
transition={{ duration: 0.3, delay: i * 0.06, ease: EASE_OUT }}
>
<StaticLink
href={`/services/${service.id}`}
@@ -129,7 +129,7 @@ function SolutionCard({ solution, index, products, featured = false }: { solutio
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.3, delay: index * 0.08, ease: EASE_OUT }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }}
>
<StaticLink
href={`/solutions/${solution.id}`}
+2 -2
View File
@@ -69,7 +69,7 @@ function StrengthCard({ strength, index }: { strength: StrengthData; index: numb
initial={shouldReduceMotion ? {} : { opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: index * 0.08, ease: EASE_OUT }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }}
whileHover={{ y: -6 }}
>
<div className="absolute top-0 left-0 bottom-0 w-1 bg-brand scale-y-0 group-hover:scale-y-100 transition-transform duration-150 origin-top" />
@@ -104,7 +104,7 @@ function CultureCard({ culture, index }: { culture: CultureData; index: number }
initial={shouldReduceMotion ? {} : { opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: index * 0.1, ease: EASE_OUT }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }}
whileHover={{ y: -8 }}
>
<div className="absolute top-0 left-0 right-0 h-1 bg-brand scale-x-0 group-hover:scale-x-100 transition-transform duration-150 origin-left" />
+1 -1
View File
@@ -49,7 +49,7 @@ export function MethodologyFramework({
initial={{ opacity: 0, x: -30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.3, delay: index * 0.08, ease: EASE_OUT }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }}
>
<div className="flex gap-6 lg:gap-10">
<div className="flex flex-col items-center pt-2">
+1 -1
View File
@@ -14,7 +14,7 @@ export function CaseStudyCard({ study, index }: CaseStudyCardProps) {
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.3, delay: index * 0.1 }}
transition={{ duration: 0.3, delay: index * 0.06 }}
className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
>
<div className="mb-3 flex items-center gap-2">
@@ -103,7 +103,7 @@ export function DetailTrustSection({
viewport={{ once: true, margin: '-60px' }}
transition={{
duration: 0.3,
delay: index * 0.08,
delay: index * 0.06,
ease: [0.22, 1, 0.36, 1],
}}
className="bain-card p-8 sm:p-10"
@@ -140,7 +140,7 @@ export function DetailTrustSection({
viewport={{ once: true, margin: '-60px' }}
transition={{
duration: 0.3,
delay: index * 0.08,
delay: index * 0.06,
ease: [0.22, 1, 0.36, 1],
}}
>
+1 -1
View File
@@ -134,7 +134,7 @@ export function ServiceValueSection({ service }: ServiceValueSectionProps) {
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{
delay: index * 0.08,
delay: index * 0.06,
duration: 0.3,
ease: [0.22, 1, 0.36, 1],
}}
+2 -2
View File
@@ -62,7 +62,7 @@ export function SolutionValueSection({ solution }: SolutionValueSectionProps) {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
delay: index * 0.08,
delay: index * 0.06,
duration: 0.3,
ease: [0.22, 1, 0.36, 1],
}}
@@ -141,7 +141,7 @@ export function SolutionValueSection({ solution }: SolutionValueSectionProps) {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
delay: index * 0.08,
delay: index * 0.06,
duration: 0.3,
ease: [0.22, 1, 0.36, 1],
}}
@@ -67,7 +67,7 @@ export function SocialProofSection() {
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.3, delay: idx * 0.1, ease: [0.16, 1, 0.3, 1] }}
transition={{ duration: 0.3, delay: idx * 0.06, ease: [0.16, 1, 0.3, 1] }}
className="text-center group"
>
<div className="w-12 h-12 rounded-full bg-[var(--color-brand)]/5 flex items-center justify-center mx-auto mb-4 group-hover:bg-[var(--color-brand)]/10 transition-colors duration-300">
+1 -1
View File
@@ -83,7 +83,7 @@ export function WhyUsSection() {
initial={shouldReduceMotion ? {} : { opacity: 0, y: 24, scale: 0.96 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.3, delay: idx * 0.08, ease: [0.16, 1, 0.3, 1] }}
transition={{ duration: 0.3, delay: idx * 0.06, ease: [0.16, 1, 0.3, 1] }}
className="relative group p-6 md:p-8 rounded-2xl bg-[var(--color-bg-primary)] border border-[var(--color-border-primary)] hover:border-[rgba(var(--color-brand-rgb),0.25)] transition-all duration-300"
>
<div className="flex items-start justify-between mb-4">
+3 -3
View File
@@ -16,7 +16,7 @@ const pageVariants = {
y: 0,
scale: 1,
transition: {
duration: 0.4,
duration: 0.3,
ease: [0.25, 1, 0.5, 1] as const,
staggerChildren: 0.05,
},
@@ -71,8 +71,8 @@ const sectionVariants = {
opacity: 1,
y: 0,
transition: {
delay: i * 0.08,
duration: 0.5,
delay: i * 0.06,
duration: 0.3,
ease: [0.25, 1, 0.5, 1] as const,
},
}),