26f998d243
Split the 808-line SeasonalEffect monolith into one self-contained module per theme under seasonal/themes/ (<Theme>.tsx + <Theme>.css.ts), and gave every theme a premium, research-backed redesign (one Opus agent per theme against a shared brief). SeasonalEffect now just imports the 11 overlays and dispatches; the orphaned shared Seasonal.css.ts is removed (each theme owns its keyframes). Each overlay: layered oklch palettes, GPU-only animation (transform/opacity), `contain: layout paint style` to kill repaint flicker, ≤~40-element perf budget, particles seeded once via useMemo (no per-frame state), a gorgeous STATIC prefers-reduced-motion form (the settings preview thumbnail), WCAG-AA-preserving low opacities, and no new deps / no external assets (inline SVG data-URIs, Tauri/CSP-safe). Themes: Halloween, Christmas, New Year, Autumn, April Fools, Lunar New Year, Valentines, St. Patrick's, Earth Day, Deep Space, Arcade. Gates: tsc clean, ESLint clean, Prettier clean, build OK, 551 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { SeasonalOverlayProps } from '../types';
|
|
import {
|
|
animBurst,
|
|
animCoreFlash,
|
|
animShimmer,
|
|
animConfettiFall,
|
|
animConfettiSway,
|
|
animTwinkle,
|
|
animSkyPulse,
|
|
} from './NewYear.css';
|
|
|
|
/**
|
|
* New Year overlay — a midnight celebration. Layered oklch gradients sink the
|
|
* app into a deep navy night; fireworks bloom as expanding spark rings, a
|
|
* champagne-gold shimmer sweeps across, confetti slivers tumble down, and
|
|
* sparkle stars twinkle. All motion is transform/opacity only.
|
|
*
|
|
* Palette (oklch): midnight navy oklch(0.20 0.07 260), champagne gold
|
|
* oklch(0.85 0.13 90), bursts in magenta oklch(0.7 0.22 350), cyan
|
|
* oklch(0.8 0.15 200), and gold.
|
|
*
|
|
* RENDERING CONTRACT: the parent supplies a fixed inset:0 overflow:hidden
|
|
* pointer-events:none container at the right z-index. We only return
|
|
* absolutely-positioned aria-hidden children at low opacity — no z-index,
|
|
* position:fixed, or pointer-events here — kept well below opaque so chat text
|
|
* stays WCAG-AA legible.
|
|
*
|
|
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (a frozen
|
|
* firework bloom mid-burst, scattered gold confetti, a still shimmer band) with
|
|
* no `animation` at all. The settings preview always passes reduced=true.
|
|
*/
|
|
|
|
const BURST_HUES = [
|
|
// [ring oklch, core oklch]
|
|
['oklch(0.7 0.22 350)', 'oklch(0.88 0.14 350)'], // magenta
|
|
['oklch(0.8 0.15 200)', 'oklch(0.92 0.1 200)'], // cyan
|
|
['oklch(0.85 0.13 90)', 'oklch(0.95 0.09 95)'], // gold
|
|
['oklch(0.75 0.2 30)', 'oklch(0.9 0.12 40)'], // warm coral
|
|
] as const;
|
|
|
|
const CONFETTI_COLORS = [
|
|
'oklch(0.85 0.13 90)', // champagne gold
|
|
'oklch(0.7 0.22 350)', // magenta
|
|
'oklch(0.8 0.15 200)', // cyan
|
|
'oklch(0.9 0.06 90)', // pale gold
|
|
'oklch(0.78 0.18 30)', // coral
|
|
] as const;
|
|
|
|
type Burst = {
|
|
top: number;
|
|
left: number;
|
|
size: number;
|
|
ring: string;
|
|
core: string;
|
|
duration: number;
|
|
delay: number;
|
|
};
|
|
|
|
type Confetto = {
|
|
left: number;
|
|
w: number;
|
|
h: number;
|
|
color: string;
|
|
round: boolean;
|
|
fallDur: number;
|
|
swayDur: number;
|
|
delay: number;
|
|
};
|
|
|
|
type Star = {
|
|
top: number;
|
|
left: number;
|
|
size: number;
|
|
color: string;
|
|
duration: number;
|
|
delay: number;
|
|
};
|
|
|
|
// Deterministic pseudo-random so the memoized scene is stable across renders.
|
|
const rand = (seed: number) => {
|
|
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
|
|
return x - Math.floor(x);
|
|
};
|
|
|
|
// A four-point sparkle (gleam) as an inline SVG data-URI — CSP-safe, no assets.
|
|
const sparkleUri = (color: string) =>
|
|
`url("data:image/svg+xml,${encodeURIComponent(
|
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 0 L14 10 L24 12 L14 14 L12 24 L10 14 L0 12 L10 10 Z' fill='${color}'/></svg>`,
|
|
)}")`;
|
|
|
|
export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
|
const bursts = useMemo<Burst[]>(
|
|
() =>
|
|
// Bursts cluster in the upper two-thirds of the sky, away from typical text.
|
|
Array.from({ length: 7 }, (_, i) => {
|
|
const hue = BURST_HUES[i % BURST_HUES.length];
|
|
return {
|
|
top: 8 + rand(i + 1) * 48,
|
|
left: 8 + rand(i + 11) * 84,
|
|
size: 130 + Math.floor(rand(i + 21) * 110),
|
|
ring: hue[0],
|
|
core: hue[1],
|
|
duration: 6.5 + rand(i + 31) * 4,
|
|
delay: rand(i + 41) * 9,
|
|
};
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const confetti = useMemo<Confetto[]>(
|
|
() =>
|
|
Array.from({ length: 20 }, (_, i) => ({
|
|
left: rand(i + 101) * 100,
|
|
w: 4 + Math.floor(rand(i + 111) * 4),
|
|
h: 7 + Math.floor(rand(i + 121) * 7),
|
|
color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
|
|
round: i % 4 === 0,
|
|
fallDur: 9 + rand(i + 131) * 7,
|
|
swayDur: 3 + rand(i + 141) * 3,
|
|
delay: rand(i + 151) * 10,
|
|
})),
|
|
[],
|
|
);
|
|
|
|
const stars = useMemo<Star[]>(
|
|
() =>
|
|
Array.from({ length: 9 }, (_, i) => ({
|
|
top: 4 + rand(i + 201) * 64,
|
|
left: 4 + rand(i + 211) * 92,
|
|
size: 8 + Math.floor(rand(i + 221) * 10),
|
|
color: i % 2 === 0 ? 'oklch(0.85 0.13 90)' : 'oklch(0.92 0.06 200)',
|
|
duration: 3 + rand(i + 231) * 3,
|
|
delay: rand(i + 241) * 4,
|
|
})),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* Midnight sky — layered oklch gradients for depth, with a faint breathe. */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
contain: 'layout paint style',
|
|
backgroundColor: 'oklch(0.2 0.07 260 / 0.12)',
|
|
backgroundImage: [
|
|
'radial-gradient(120% 90% at 50% -10%, oklch(0.32 0.1 280 / 0.16) 0%, transparent 60%)',
|
|
'radial-gradient(90% 70% at 18% 8%, oklch(0.7 0.22 350 / 0.07) 0%, transparent 55%)',
|
|
'radial-gradient(90% 70% at 84% 4%, oklch(0.8 0.15 200 / 0.07) 0%, transparent 55%)',
|
|
'radial-gradient(140% 120% at 50% 120%, oklch(0.2 0.07 260 / 0.14) 0%, transparent 70%)',
|
|
].join(','),
|
|
animation: reduced ? 'none' : `${animSkyPulse} 9s ease-in-out infinite`,
|
|
}}
|
|
/>
|
|
|
|
{/* Champagne-gold shimmer sweep. */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
bottom: 0,
|
|
left: 0,
|
|
width: '55%',
|
|
contain: 'layout paint style',
|
|
backgroundImage:
|
|
'linear-gradient(100deg, transparent 0%, oklch(0.85 0.13 90 / 0.05) 38%, oklch(0.95 0.09 95 / 0.1) 50%, oklch(0.85 0.13 90 / 0.05) 62%, transparent 100%)',
|
|
transform: reduced ? 'translate3d(30%, 0, 0) skewX(-12deg)' : undefined,
|
|
opacity: reduced ? 0.45 : undefined,
|
|
animation: reduced ? 'none' : `${animShimmer} 11s ease-in-out infinite`,
|
|
}}
|
|
/>
|
|
|
|
{/* Fireworks — expanding spark rings + a core flash. In reduced mode we
|
|
freeze the first burst mid-bloom and drop the rest. */}
|
|
{(reduced ? bursts.slice(0, 1) : bursts).map((b, i) => (
|
|
<div
|
|
key={`b${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: `${b.top}%`,
|
|
left: `${b.left}%`,
|
|
width: `${b.size}px`,
|
|
height: `${b.size}px`,
|
|
marginLeft: `${-b.size / 2}px`,
|
|
marginTop: `${-b.size / 2}px`,
|
|
}}
|
|
>
|
|
{/* Spark ring */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
borderRadius: '50%',
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
|
background: `radial-gradient(circle, transparent 56%, ${b.ring} 64%, transparent 74%)`,
|
|
transform: reduced ? 'scale(0.82)' : undefined,
|
|
opacity: reduced ? 0.6 : undefined,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animBurst} ${b.duration}s ease-out ${b.delay}s infinite`,
|
|
}}
|
|
/>
|
|
{/* Inner secondary ring for a fuller bloom */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
inset: '18%',
|
|
borderRadius: '50%',
|
|
background: `radial-gradient(circle, transparent 50%, ${b.ring} 60%, transparent 72%)`,
|
|
transform: reduced ? 'scale(0.7)' : undefined,
|
|
opacity: reduced ? 0.4 : undefined,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animBurst} ${b.duration}s ease-out ${b.delay + 0.12}s infinite`,
|
|
}}
|
|
/>
|
|
{/* Core flash */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
left: '50%',
|
|
top: '50%',
|
|
width: '14%',
|
|
height: '14%',
|
|
marginLeft: '-7%',
|
|
marginTop: '-7%',
|
|
borderRadius: '50%',
|
|
background: `radial-gradient(circle, ${b.core} 0%, transparent 70%)`,
|
|
transform: reduced ? 'scale(0.9)' : undefined,
|
|
opacity: reduced ? 0.85 : undefined,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animCoreFlash} ${b.duration}s ease-out ${b.delay}s infinite`,
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{/* Twinkling sparkle stars. */}
|
|
{stars.map((s, i) => (
|
|
<div
|
|
key={`s${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: `${s.top}%`,
|
|
left: `${s.left}%`,
|
|
width: `${s.size}px`,
|
|
height: `${s.size}px`,
|
|
backgroundImage: sparkleUri(s.color),
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundSize: 'contain',
|
|
transform: reduced ? 'scale(0.9) rotate(30deg)' : undefined,
|
|
opacity: reduced ? 0.75 : undefined,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animTwinkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* Falling confetti slivers. In reduced mode, a still scatter at varied
|
|
heights so the static thumbnail reads as a celebration in progress. */}
|
|
{confetti.map((c, i) => {
|
|
const staticTop = reduced ? 6 + rand(i + 301) * 78 : undefined;
|
|
return (
|
|
<div
|
|
key={`c${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: reduced ? `${staticTop}%` : 0,
|
|
left: `${c.left}%`,
|
|
willChange: reduced ? undefined : 'transform',
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animConfettiSway} ${c.swayDur}s ease-in-out ${c.delay}s infinite`,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: `${c.w}px`,
|
|
height: reduced && c.round ? `${c.w}px` : `${c.h}px`,
|
|
borderRadius: c.round ? '50%' : '1px',
|
|
backgroundColor: c.color,
|
|
opacity: reduced ? 0.8 : 0.85,
|
|
transform: reduced ? `rotate(${Math.floor(rand(i + 311) * 360)}deg)` : undefined,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animConfettiFall} ${c.fallDur}s ease-in ${c.delay}s infinite`,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|