feat(home): Hero 动态粒子层(入场浮现后静止)

新增 hero-particle-field 组件作为 Hero L3 层:品牌红点缀、Canvas + rAF
仅在 ~600ms 入场窗运行后冻结为静态帧,遵守「无持续循环动画」动效铁律;
prefers-reduced-motion 退化为一次性静态帧。固定种子 PRNG 保证视觉快照可复现。
同步更新 home-content-v15 接入与单测、home 视觉基线。

Co-Authored-By: 阿睿 <workbuddy@tencent.com>
This commit is contained in:
2026-09-01 07:57:12 +08:00
co-authored by 阿睿
parent ac21936785
commit 549c151c6a
13 changed files with 203 additions and 0 deletions
@@ -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(<HomeContentV15 />);
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(<HomeContentV15 />);
+4
View File
@@ -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 && (
<div ref={spotRef} className="absolute inset-0 pointer-events-none hero-dot-spot opacity-60" aria-hidden="true" />
)}
{/* L3 动态粒子(品牌红点缀):入场浮现后静止,rAF 仅跑 ~600ms,无持续循环;
reduced-motion 下退化为一次性静态帧 */}
<HeroParticleField />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-16 sm:py-20 md:py-24 lg:py-24">
<div className="grid items-center gap-16 lg:grid-cols-[1.05fr_0.95fr]">
@@ -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(<HeroParticleField />);
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(<HeroParticleField />)).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(<HeroParticleField />)).not.toThrow();
ctxSpy.mockRestore();
});
});
@@ -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];
}
/** 固定种子 PRNGmulberry32):让粒子位置对给定视口可复现,避免视觉回归快照 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<HTMLCanvasElement>(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 (
<canvas
ref={canvasRef}
data-testid="hero-particle-field"
aria-hidden="true"
className={`absolute inset-0 pointer-events-none ${className ?? ''}`}
/>
);
}
export default HeroParticleField;