Refactor/optimize ui #24
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 3.5 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 3.4 MiB |
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/** 固定种子 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<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;
|
||||