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
@@ -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();
});
});