diff --git a/e2e/visual-snapshots/visual-chromium-mobile/visual-regression.spec.ts/home-fullpage-visual-chromium-mobile.png b/e2e/visual-snapshots/visual-chromium-mobile/visual-regression.spec.ts/home-fullpage-visual-chromium-mobile.png
index a9d4c7c..88a71f4 100644
Binary files a/e2e/visual-snapshots/visual-chromium-mobile/visual-regression.spec.ts/home-fullpage-visual-chromium-mobile.png and b/e2e/visual-snapshots/visual-chromium-mobile/visual-regression.spec.ts/home-fullpage-visual-chromium-mobile.png differ
diff --git a/e2e/visual-snapshots/visual-chromium-mobile/visual-regression.spec.ts/theme-dark-main-visual-chromium-mobile.png b/e2e/visual-snapshots/visual-chromium-mobile/visual-regression.spec.ts/theme-dark-main-visual-chromium-mobile.png
index 878f5c6..870e2d2 100644
Binary files a/e2e/visual-snapshots/visual-chromium-mobile/visual-regression.spec.ts/theme-dark-main-visual-chromium-mobile.png and b/e2e/visual-snapshots/visual-chromium-mobile/visual-regression.spec.ts/theme-dark-main-visual-chromium-mobile.png differ
diff --git a/e2e/visual-snapshots/visual-chromium-mobile/visual-regression.spec.ts/theme-light-main-visual-chromium-mobile.png b/e2e/visual-snapshots/visual-chromium-mobile/visual-regression.spec.ts/theme-light-main-visual-chromium-mobile.png
index 878f5c6..2e5aa3e 100644
Binary files a/e2e/visual-snapshots/visual-chromium-mobile/visual-regression.spec.ts/theme-light-main-visual-chromium-mobile.png and b/e2e/visual-snapshots/visual-chromium-mobile/visual-regression.spec.ts/theme-light-main-visual-chromium-mobile.png differ
diff --git a/e2e/visual-snapshots/visual-chromium-tablet/visual-regression.spec.ts/home-fullpage-visual-chromium-tablet.png b/e2e/visual-snapshots/visual-chromium-tablet/visual-regression.spec.ts/home-fullpage-visual-chromium-tablet.png
index cc64923..c53fc75 100644
Binary files a/e2e/visual-snapshots/visual-chromium-tablet/visual-regression.spec.ts/home-fullpage-visual-chromium-tablet.png and b/e2e/visual-snapshots/visual-chromium-tablet/visual-regression.spec.ts/home-fullpage-visual-chromium-tablet.png differ
diff --git a/e2e/visual-snapshots/visual-chromium-tablet/visual-regression.spec.ts/theme-dark-main-visual-chromium-tablet.png b/e2e/visual-snapshots/visual-chromium-tablet/visual-regression.spec.ts/theme-dark-main-visual-chromium-tablet.png
index 50735f2..0261bf2 100644
Binary files a/e2e/visual-snapshots/visual-chromium-tablet/visual-regression.spec.ts/theme-dark-main-visual-chromium-tablet.png and b/e2e/visual-snapshots/visual-chromium-tablet/visual-regression.spec.ts/theme-dark-main-visual-chromium-tablet.png differ
diff --git a/e2e/visual-snapshots/visual-chromium-tablet/visual-regression.spec.ts/theme-light-main-visual-chromium-tablet.png b/e2e/visual-snapshots/visual-chromium-tablet/visual-regression.spec.ts/theme-light-main-visual-chromium-tablet.png
index 50735f2..cbe9e16 100644
Binary files a/e2e/visual-snapshots/visual-chromium-tablet/visual-regression.spec.ts/theme-light-main-visual-chromium-tablet.png and b/e2e/visual-snapshots/visual-chromium-tablet/visual-regression.spec.ts/theme-light-main-visual-chromium-tablet.png differ
diff --git a/e2e/visual-snapshots/visual-webkit-desktop/visual-regression.spec.ts/home-fullpage-visual-webkit-desktop.png b/e2e/visual-snapshots/visual-webkit-desktop/visual-regression.spec.ts/home-fullpage-visual-webkit-desktop.png
index 96a3f61..037a847 100644
Binary files a/e2e/visual-snapshots/visual-webkit-desktop/visual-regression.spec.ts/home-fullpage-visual-webkit-desktop.png and b/e2e/visual-snapshots/visual-webkit-desktop/visual-regression.spec.ts/home-fullpage-visual-webkit-desktop.png differ
diff --git a/e2e/visual-snapshots/visual-webkit-desktop/visual-regression.spec.ts/theme-dark-main-visual-webkit-desktop.png b/e2e/visual-snapshots/visual-webkit-desktop/visual-regression.spec.ts/theme-dark-main-visual-webkit-desktop.png
index 96a3f61..f7fbfb9 100644
Binary files a/e2e/visual-snapshots/visual-webkit-desktop/visual-regression.spec.ts/theme-dark-main-visual-webkit-desktop.png and b/e2e/visual-snapshots/visual-webkit-desktop/visual-regression.spec.ts/theme-dark-main-visual-webkit-desktop.png differ
diff --git a/e2e/visual-snapshots/visual-webkit-desktop/visual-regression.spec.ts/theme-light-main-visual-webkit-desktop.png b/e2e/visual-snapshots/visual-webkit-desktop/visual-regression.spec.ts/theme-light-main-visual-webkit-desktop.png
index 96a3f61..037a847 100644
Binary files a/e2e/visual-snapshots/visual-webkit-desktop/visual-regression.spec.ts/theme-light-main-visual-webkit-desktop.png and b/e2e/visual-snapshots/visual-webkit-desktop/visual-regression.spec.ts/theme-light-main-visual-webkit-desktop.png differ
diff --git a/src/app/(marketing)/home-content-v15.test.tsx b/src/app/(marketing)/home-content-v15.test.tsx
index f64fb3f..b7cc8e4 100644
--- a/src/app/(marketing)/home-content-v15.test.tsx
+++ b/src/app/(marketing)/home-content-v15.test.tsx
@@ -98,6 +98,16 @@ describe('HomeContentV15 - 浅色 Hero(暗黑模式 token 友好)', () => {
expect(hero.querySelector('img')).toBeNull();
});
+ it('mounts the brand-red dynamic particle layer (entrance-only, no continuous loop)', () => {
+ render();
+
+ const hero = screen.getByTestId('hero-section');
+ const particles = screen.getByTestId('hero-particle-field');
+ expect(particles.tagName).toBe('CANVAS');
+ expect(particles).toHaveAttribute('aria-hidden', 'true');
+ expect(hero.contains(particles)).toBe(true);
+ });
+
it('keeps a single primary CTA and demotes the secondary action to a text link', () => {
render();
diff --git a/src/app/(marketing)/home-content-v15.tsx b/src/app/(marketing)/home-content-v15.tsx
index 58eea50..53faf21 100644
--- a/src/app/(marketing)/home-content-v15.tsx
+++ b/src/app/(marketing)/home-content-v15.tsx
@@ -6,6 +6,7 @@ import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { Badge } from '@/components/ui/badge';
import { StaticLink } from '@/components/ui/static-link';
import { HeroProductVisual } from '@/components/sections/hero-product-visual';
+import { HeroParticleField } from '@/components/sections/hero-particle-field';
import { ArrowRight, Lightbulb, Database, Layers, Code, Puzzle, Server, Quote, Calendar } from 'lucide-react';
import { cn } from '@/lib/utils';
import { EASE_OUT } from '@/components/ui/page-decoration';
@@ -177,6 +178,9 @@ function HeroSection({ heroData }: {
{!reduceMotion && (
diff --git a/src/components/sections/hero-particle-field.test.tsx b/src/components/sections/hero-particle-field.test.tsx
new file mode 100644
index 0000000..dce7bf5
--- /dev/null
+++ b/src/components/sections/hero-particle-field.test.tsx
@@ -0,0 +1,34 @@
+import { describe, it, expect, jest } from '@jest/globals';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import HeroParticleField from './hero-particle-field';
+
+jest.mock('@/hooks/use-reduced-motion', () => ({
+ useReducedMotion: jest.fn(() => false),
+}));
+
+describe('HeroParticleField - 动态粒子层(入场浮现后静止)', () => {
+ it('renders an aria-hidden, non-interactive canvas layer', () => {
+ render();
+ const canvas = screen.getByTestId('hero-particle-field');
+ expect(canvas.tagName).toBe('CANVAS');
+ expect(canvas).toHaveAttribute('aria-hidden', 'true');
+ expect(canvas.className).toContain('pointer-events-none');
+ });
+
+ it('does not throw under reduced motion (degrades to a single static frame)', () => {
+ const { useReducedMotion } = require('@/hooks/use-reduced-motion');
+ (useReducedMotion as jest.Mock).mockReturnValue(true);
+ expect(() => render()).not.toThrow();
+ // 静态降级下仍渲染 canvas(无 rAF 循环,仅一次绘制)
+ expect(screen.getByTestId('hero-particle-field').tagName).toBe('CANVAS');
+ });
+
+ it('bails safely when no 2d context is available (jsdom / SSR guard)', () => {
+ const ctxSpy = jest
+ .spyOn(HTMLCanvasElement.prototype, 'getContext')
+ .mockReturnValue(null as any);
+ expect(() => render()).not.toThrow();
+ ctxSpy.mockRestore();
+ });
+});
diff --git a/src/components/sections/hero-particle-field.tsx b/src/components/sections/hero-particle-field.tsx
new file mode 100644
index 0000000..5071ade
--- /dev/null
+++ b/src/components/sections/hero-particle-field.tsx
@@ -0,0 +1,155 @@
+'use client';
+
+/**
+ * Hero 动态粒子层(入场浮现后静止)
+ *
+ * 设计约束对齐(CLAUDE.md / CONTEXT.md / Impeccable 审计):
+ * - 无持续循环动画:rAF 仅在 ~600ms 入场窗内运行,结束即 cancelAnimationFrame
+ * 并绘制最终静态帧——不突破「无持续循环动画(pulse-soft 除外)」规则。
+ * - 品牌红单电压:粒子用 --color-brand-rgb(#C41E3A),稀疏小点(封顶 64、半径 ≤2.6px),
+ * 品牌红面积占比 <0.1%,远低于单电压 10% 上限,不作为大背景。
+ * - reduced-motion:直接绘制一次静态帧,不进入 rAF 循环。
+ * - 性能:DPR 限幅(≤2)、粒子数随面积封顶、离屏/隐藏标签页不续帧、ResizeObserver 重排。
+ * - 无特效库:纯自包含 canvas,不引入任何第三方动画/粒子依赖(effects/ 已删除)。
+ */
+
+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 DENSITY_DIVISOR = 26000; // 每 ~26000px² 一个粒子,面积驱动密度封顶
+
+interface Particle {
+ x: number;
+ y: number;
+ r: number;
+ alpha: number;
+}
+
+function readBrandRgb(): [number, number, number] {
+ if (typeof window === 'undefined') return [196, 30, 58];
+ const raw = getComputedStyle(document.documentElement)
+ .getPropertyValue('--color-brand-rgb')
+ .trim();
+ if (raw) {
+ const parts = raw.split(/\s+/).map(Number);
+ if (parts.length === 3 && parts.every((n) => !Number.isNaN(n))) {
+ const [r, g, b] = parts as [number, number, number];
+ return [r, g, b];
+ }
+ }
+ return [196, 30, 58];
+}
+
+/** 固定种子 PRNG(mulberry32):让粒子位置对给定视口可复现,避免视觉回归快照 flaky */
+function mulberry32(seed: number): () => number {
+ let s = seed;
+ return function () {
+ s |= 0;
+ s = (s + 0x6d2b79f5) | 0;
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+export function HeroParticleField({ className }: { className?: string }) {
+ const canvasRef = useRef(null);
+ const reduceMotion = useReducedMotion();
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ 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;
+
+ const paint = (alphaScale: number) => {
+ 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.arc(p.x, p.y, p.r, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ };
+
+ const build = () => {
+ const rect = parent.getBoundingClientRect();
+ const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
+ canvas.width = Math.max(1, Math.floor(rect.width * dpr));
+ canvas.height = Math.max(1, Math.floor(rect.height * dpr));
+ canvas.style.width = `${rect.width}px`;
+ canvas.style.height = `${rect.height}px`;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+
+ 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,
+ }));
+ };
+
+ build();
+
+ if (reduceMotion) {
+ // 降级:仅一次性静态帧,无 rAF 循环
+ paint(1);
+ } 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);
+ }
+
+ let ro: ResizeObserver | undefined;
+ if (typeof ResizeObserver !== 'undefined') {
+ ro = new ResizeObserver(() => {
+ build();
+ if (reduceMotion) paint(1);
+ });
+ ro.observe(parent);
+ }
+
+ return () => {
+ if (raf) cancelAnimationFrame(raf);
+ ro?.disconnect();
+ };
+ }, [reduceMotion]);
+
+ return (
+
+ );
+}
+
+export default HeroParticleField;