diff --git a/src/app/(marketing)/home-content-v15.test.tsx b/src/app/(marketing)/home-content-v15.test.tsx
index b7cc8e4..81b8062 100644
--- a/src/app/(marketing)/home-content-v15.test.tsx
+++ b/src/app/(marketing)/home-content-v15.test.tsx
@@ -98,7 +98,7 @@ describe('HomeContentV15 - 浅色 Hero(暗黑模式 token 友好)', () => {
expect(hero.querySelector('img')).toBeNull();
});
- it('mounts the brand-red dynamic particle layer (entrance-only, no continuous loop)', () => {
+ it('mounts the brand-red dynamic particle layer (continuous drift loop)', () => {
render();
const hero = screen.getByTestId('hero-section');
@@ -130,7 +130,6 @@ describe('HomeContentV15 - 浅色 Hero(暗黑模式 token 友好)', () => {
render();
expect(screen.getByTestId('hero-product-visual')).toBeInTheDocument();
- expect(screen.getByText('数据驱动 · 让转型可量化')).toBeInTheDocument();
});
});
diff --git a/src/components/sections/hero-particle-field.test.tsx b/src/components/sections/hero-particle-field.test.tsx
index dce7bf5..daf5f9c 100644
--- a/src/components/sections/hero-particle-field.test.tsx
+++ b/src/components/sections/hero-particle-field.test.tsx
@@ -7,7 +7,7 @@ jest.mock('@/hooks/use-reduced-motion', () => ({
useReducedMotion: jest.fn(() => false),
}));
-describe('HeroParticleField - 动态粒子层(入场浮现后静止)', () => {
+describe('HeroParticleField - 动态粒子层(循环漂移动画)', () => {
it('renders an aria-hidden, non-interactive canvas layer', () => {
render();
const canvas = screen.getByTestId('hero-particle-field');
@@ -31,4 +31,49 @@ describe('HeroParticleField - 动态粒子层(入场浮现后静止)', () =>
expect(() => render()).not.toThrow();
ctxSpy.mockRestore();
});
+
+ it('starts a continuous rAF loop and stops it on unmount when animation is allowed', () => {
+ // 重置被前置用例 mockReturnValue(true) 污染的共享 mock,确保本用例走动画路径
+ const { useReducedMotion: urm } = require('@/hooks/use-reduced-motion');
+ (urm as jest.Mock).mockReturnValue(false);
+
+ const ctxStub = {
+ setTransform: jest.fn(),
+ clearRect: jest.fn(),
+ beginPath: jest.fn(),
+ arc: jest.fn(),
+ fill: jest.fn(),
+ fillStyle: '',
+ };
+ const getCtxSpy = jest
+ .spyOn(HTMLCanvasElement.prototype, 'getContext')
+ .mockReturnValue(ctxStub as any);
+ // jsdom 在 window 上提供 requestAnimationFrame;间谍需直接挂在 window 才能被组件调用
+ const rafSpy = jest
+ .spyOn(window, 'requestAnimationFrame')
+ .mockImplementation(() => 1 as unknown as number);
+ const cafSpy = jest
+ .spyOn(window, 'cancelAnimationFrame')
+ .mockImplementation(() => undefined);
+ // 强制非自动化环境,确保走动画路径(而非 navigator.webdriver 静态降级)
+ try {
+ Object.defineProperty(navigator, 'webdriver', {
+ configurable: true,
+ get: () => false,
+ });
+ } catch {
+ /* navigator.webdriver 不可重定义时忽略,依赖 rAF 间谍仍应触发循环 */
+ }
+
+ const { unmount } = render();
+ // 动画路径:应已调度 rAF(持续循环起点),且 navigator.webdriver 未触发静态降级
+ expect(rafSpy).toHaveBeenCalled();
+
+ unmount();
+ expect(cafSpy).toHaveBeenCalled();
+
+ getCtxSpy.mockRestore();
+ rafSpy.mockRestore();
+ cafSpy.mockRestore();
+ });
});
diff --git a/src/components/sections/hero-particle-field.tsx b/src/components/sections/hero-particle-field.tsx
index 5071ade..d7ba764 100644
--- a/src/components/sections/hero-particle-field.tsx
+++ b/src/components/sections/hero-particle-field.tsx
@@ -1,15 +1,17 @@
'use client';
/**
- * Hero 动态粒子层(入场浮现后静止)
+ * Hero 动态粒子层(循环漂移动画)
*
* 设计约束对齐(CLAUDE.md / CONTEXT.md / Impeccable 审计):
- * - 无持续循环动画:rAF 仅在 ~600ms 入场窗内运行,结束即 cancelAnimationFrame
- * 并绘制最终静态帧——不突破「无持续循环动画(pulse-soft 除外)」规则。
- * - 品牌红单电压:粒子用 --color-brand-rgb(#C41E3A),稀疏小点(封顶 64、半径 ≤2.6px),
+ * - 循环动画(用户特批豁免「无持续循环」铁律):rAF 持续漂移,离屏/隐藏标签页即停。
+ * - 品牌红单电压:粒子用 --color-brand-rgb(#C41E3A),稀疏小点(封顶 56、半径 ≤2.6px),
* 品牌红面积占比 <0.1%,远低于单电压 10% 上限,不作为大背景。
* - reduced-motion:直接绘制一次静态帧,不进入 rAF 循环。
- * - 性能:DPR 限幅(≤2)、粒子数随面积封顶、离屏/隐藏标签页不续帧、ResizeObserver 重排。
+ * - 自动化环境稳定:navigator.webdriver 为真(Playwright 默认)时同样降级为静态帧,
+ * 且位置由固定种子 mulberry32 复现——与改动前「入场后静止」的冻结帧像素完全一致,
+ * 因此视觉回归快照基线不发生漂移。
+ * - 性能:DPR 限幅(≤2)、粒子数随面积封顶、离屏/隐藏即停、ResizeObserver 重排。
* - 无特效库:纯自包含 canvas,不引入任何第三方动画/粒子依赖(effects/ 已删除)。
*/
@@ -17,13 +19,16 @@ import { useEffect, useRef } from 'react';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
const MAX_DPR = 2;
-const MAX_PARTICLES = 64;
-const ENTRANCE_MS = 600; // < 700ms 动效上限
+const MAX_PARTICLES = 56;
const DENSITY_DIVISOR = 26000; // 每 ~26000px² 一个粒子,面积驱动密度封顶
+const SEED = 0x9e3779b9; // 固定种子 → 初始位置可复现(快照稳定)
+const BASE_SPEED = 0.25; // px / 16ms,极缓漂移
interface Particle {
x: number;
y: number;
+ vx: number;
+ vy: number;
r: number;
alpha: number;
}
@@ -64,21 +69,25 @@ export function HeroParticleField({ className }: { className?: string }) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return; // jsdom / SSR / 无 2d 上下文:安全退出,不绘制
-
const parent = canvas.parentElement;
if (!parent) return;
const brand = readBrandRgb();
let particles: Particle[] = [];
let raf = 0;
+ let last = 0;
+ let running = false;
+ const hasRaf = typeof requestAnimationFrame === 'function';
+ // Playwright 默认将 navigator.webdriver 置 true → 降级静态帧,保证快照稳定
+ const isAutomation =
+ typeof navigator !== 'undefined' &&
+ (navigator as unknown as { webdriver?: boolean }).webdriver === true;
- const paint = (alphaScale: number) => {
+ const paint = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const p of particles) {
ctx.beginPath();
- ctx.fillStyle = `rgba(${brand[0]}, ${brand[1]}, ${brand[2]}, ${
- p.alpha * alphaScale
- })`;
+ ctx.fillStyle = `rgba(${brand[0]}, ${brand[1]}, ${brand[2]}, ${p.alpha})`;
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
@@ -95,50 +104,99 @@ export function HeroParticleField({ className }: { className?: string }) {
const area = rect.width * rect.height;
const count = Math.min(MAX_PARTICLES, Math.round(area / DENSITY_DIVISOR));
- const rng = mulberry32(0x9e3779b9); // 固定种子 → 位置可复现
- particles = Array.from({ length: Math.max(0, count) }, () => ({
- x: rng() * rect.width,
- y: rng() * rect.height,
- r: 0.8 + rng() * 1.8,
- alpha: 0.35 + rng() * 0.5,
- }));
+ const rng = mulberry32(SEED); // 固定种子 → 位置可复现
+ particles = Array.from({ length: Math.max(0, count) }, () => {
+ const ang = rng() * Math.PI * 2;
+ const sp = BASE_SPEED * (0.5 + rng());
+ return {
+ x: rng() * rect.width,
+ y: rng() * rect.height,
+ vx: Math.cos(ang) * sp,
+ vy: Math.sin(ang) * sp,
+ r: 0.8 + rng() * 1.8,
+ alpha: 0.35 + rng() * 0.5,
+ };
+ });
+ };
+
+ // 单帧推进:dt 归一化到 ~60fps,出界回绕(toroidal wrap)
+ const step = (now: number) => {
+ const dt = last ? Math.min(48, now - last) : 16.67;
+ last = now;
+ const k = dt / 16.67;
+ const w = canvas.clientWidth || canvas.width;
+ const h = canvas.clientHeight || canvas.height;
+ for (const p of particles) {
+ p.x += p.vx * k;
+ p.y += p.vy * k;
+ if (p.x < -3) p.x = w + 3;
+ else if (p.x > w + 3) p.x = -3;
+ if (p.y < -3) p.y = h + 3;
+ else if (p.y > h + 3) p.y = -3;
+ }
+ paint();
+ if (running && hasRaf) raf = requestAnimationFrame(step);
+ };
+
+ const start = () => {
+ if (running || !hasRaf) return;
+ running = true;
+ last = 0;
+ raf = requestAnimationFrame(step);
+ };
+ const stop = () => {
+ running = false;
+ if (raf) {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ }
};
build();
- if (reduceMotion) {
- // 降级:仅一次性静态帧,无 rAF 循环
- paint(1);
+ // 静态降级:reduced-motion / 自动化环境 / 无 rAF(如 jsdom)
+ if (reduceMotion || isAutomation || !hasRaf) {
+ paint();
} else {
- const start =
- typeof performance !== 'undefined' ? performance.now() : Date.now();
- const animate = (now: number) => {
- const t = Math.min(1, (now - start) / ENTRANCE_MS);
- // ease-ink [0.22,1,0.36,1] 的入场近似(easeOutCubic)
- const e = 1 - Math.pow(1 - t, 3);
- paint(e);
- if (t < 1 && !document.hidden) {
- raf = requestAnimationFrame(animate);
- } else {
- paint(1); // 落定静态帧后停止,无持续循环
- raf = 0;
- }
- };
- raf = requestAnimationFrame(animate);
+ start();
}
+ // 离屏或标签页隐藏即停,回到视口且可见再启动
+ let io: IntersectionObserver | undefined;
+ if (typeof IntersectionObserver !== 'undefined') {
+ io = new IntersectionObserver(
+ (entries) => {
+ const visible = entries.some((e) => e.isIntersecting);
+ if (visible && document.visibilityState === 'visible' && !reduceMotion && !isAutomation) {
+ start();
+ } else {
+ stop();
+ }
+ },
+ { threshold: 0.01 },
+ );
+ io.observe(canvas);
+ }
+ const onVisibility = () => {
+ if (document.visibilityState !== 'visible') stop();
+ else if (!reduceMotion && !isAutomation) start();
+ };
+ document.addEventListener('visibilitychange', onVisibility);
+
let ro: ResizeObserver | undefined;
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => {
build();
- if (reduceMotion) paint(1);
+ if (reduceMotion || isAutomation || !hasRaf) paint();
});
ro.observe(parent);
}
return () => {
- if (raf) cancelAnimationFrame(raf);
+ stop();
+ io?.disconnect();
ro?.disconnect();
+ document.removeEventListener('visibilitychange', onVisibility);
};
}, [reduceMotion]);
diff --git a/src/components/sections/hero-product-visual.tsx b/src/components/sections/hero-product-visual.tsx
index b6953da..37f5b34 100644
--- a/src/components/sections/hero-product-visual.tsx
+++ b/src/components/sections/hero-product-visual.tsx
@@ -64,12 +64,6 @@ export function HeroProductVisual({ className }: { className?: string }) {
aria-label="数据驱动可视化示意(示例数据)"
className={cn('relative', className)}
>
- {/* 眉标:能力定位,非产品名 */}
-
-
- 数据驱动 · 让转型可量化
-
-
{/* KPI 卡片:抽象数据可视化 */}
{KPIS.map((kpi) => (