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>
257 lines
8.8 KiB
TypeScript
257 lines
8.8 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { SeasonalOverlayProps } from '../types';
|
|
import {
|
|
animSnowFall,
|
|
animSnowSway,
|
|
animBulbBreathe,
|
|
animAurora,
|
|
animFrostPulse,
|
|
} from './Christmas.css';
|
|
|
|
// Deterministic pseudo-random so the scene is identical every mount (no React
|
|
// state per frame). Large primes keep the distribution well spread.
|
|
const rand = (seed: number) => {
|
|
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
|
return x - Math.floor(x);
|
|
};
|
|
|
|
// Warm incandescent string-light hues in oklch — gold, soft red, cool white,
|
|
// pine green, icy blue. Kept luminous and gentle so they read as bokeh glow.
|
|
const BULB_COLORS = [
|
|
'oklch(0.85 0.12 85)', // warm gold
|
|
'oklch(0.72 0.15 28)', // soft red
|
|
'oklch(0.95 0.03 230)', // icy white
|
|
'oklch(0.78 0.13 150)', // pine green
|
|
'oklch(0.8 0.1 235)', // cool blue
|
|
];
|
|
|
|
type Flake = {
|
|
left: number;
|
|
size: number;
|
|
duration: number;
|
|
delay: number;
|
|
swayDuration: number;
|
|
opacity: number;
|
|
blur: number;
|
|
};
|
|
|
|
type Bulb = {
|
|
left: number;
|
|
top: number;
|
|
size: number;
|
|
color: string;
|
|
duration: number;
|
|
delay: number;
|
|
};
|
|
|
|
export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
|
|
// Three parallax bands of snow: far (small/slow/dim) -> near (large/fast).
|
|
const flakes = useMemo<Flake[]>(() => {
|
|
const bands = [
|
|
{ count: 12, size: [1.5, 2.5], dur: [16, 22], op: [0.35, 0.55], blur: 0.6 },
|
|
{ count: 10, size: [2.5, 4], dur: [11, 15], op: [0.55, 0.8], blur: 0.3 },
|
|
{ count: 8, size: [4, 6.5], dur: [8, 11], op: [0.7, 0.95], blur: 0 },
|
|
];
|
|
const out: Flake[] = [];
|
|
let s = 1;
|
|
bands.forEach((b) => {
|
|
for (let i = 0; i < b.count; i += 1) {
|
|
const r1 = rand(s);
|
|
const r2 = rand(s + 0.37);
|
|
const r3 = rand(s + 0.71);
|
|
const r4 = rand(s + 0.91);
|
|
out.push({
|
|
left: r1 * 100,
|
|
size: b.size[0] + r2 * (b.size[1] - b.size[0]),
|
|
duration: b.dur[0] + r3 * (b.dur[1] - b.dur[0]),
|
|
delay: -r4 * (b.dur[1] + 4),
|
|
swayDuration: 4 + r2 * 5,
|
|
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
|
|
blur: b.blur,
|
|
});
|
|
s += 1;
|
|
}
|
|
});
|
|
return out;
|
|
}, []);
|
|
|
|
// Bokeh string lights strung along the very top edge, gently sagging.
|
|
const bulbs = useMemo<Bulb[]>(() => {
|
|
const count = 9;
|
|
const out: Bulb[] = [];
|
|
for (let i = 0; i < count; i += 1) {
|
|
const t = i / (count - 1);
|
|
// Two-segment garland sag so the lights drape rather than sit in a line.
|
|
const sag = Math.sin(t * Math.PI * 2) * 3.2;
|
|
out.push({
|
|
left: 4 + t * 92,
|
|
top: 2.5 + Math.abs(Math.sin(t * Math.PI)) * 2 + sag,
|
|
size: 12 + rand(i + 5) * 8,
|
|
color: BULB_COLORS[i % BULB_COLORS.length],
|
|
duration: 3.4 + rand(i + 2) * 2.6,
|
|
delay: -rand(i + 9) * 3,
|
|
});
|
|
}
|
|
return out;
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
{/* Deep night-blue ambient wash — layered radial + linear oklch gradients
|
|
for depth. Kept low-opacity so chat text stays legible (WCAG-AA). */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
contain: 'layout paint style',
|
|
backgroundImage: [
|
|
'radial-gradient(120% 80% at 50% -10%, oklch(0.25 0.07 250 / 0.16) 0%, transparent 55%)',
|
|
'radial-gradient(90% 60% at 85% 110%, oklch(0.3 0.06 255 / 0.1) 0%, transparent 60%)',
|
|
'linear-gradient(180deg, oklch(0.95 0.03 230 / 0.05) 0%, transparent 22%, transparent 80%, oklch(0.22 0.07 255 / 0.08) 100%)',
|
|
].join(','),
|
|
}}
|
|
/>
|
|
|
|
{/* Frosted vignette frame — cold edges, clear center. backdrop-filter on a
|
|
single cheap layer for a faint icy haze around the rim. */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
contain: 'layout paint style',
|
|
backdropFilter: 'blur(0.4px) saturate(1.04)',
|
|
WebkitBackdropFilter: 'blur(0.4px) saturate(1.04)',
|
|
backgroundImage:
|
|
'radial-gradient(135% 120% at 50% 42%, transparent 52%, oklch(0.9 0.04 225 / 0.07) 74%, oklch(0.28 0.07 250 / 0.16) 100%)',
|
|
animation: reduced ? 'none' : `${animFrostPulse} 12s ease-in-out infinite`,
|
|
willChange: reduced ? undefined : 'opacity',
|
|
}}
|
|
/>
|
|
|
|
{/* Aurora shimmer band high up — soft conic-ish wash of icy blue/green. */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: '-6%',
|
|
left: '-10%',
|
|
right: '-10%',
|
|
height: '40%',
|
|
contain: 'layout paint style',
|
|
mixBlendMode: 'screen',
|
|
filter: 'blur(26px)',
|
|
opacity: reduced ? 0.6 : undefined,
|
|
backgroundImage: [
|
|
'radial-gradient(60% 100% at 30% 0%, oklch(0.85 0.12 165 / 0.18) 0%, transparent 70%)',
|
|
'radial-gradient(55% 100% at 68% 0%, oklch(0.8 0.1 235 / 0.16) 0%, transparent 72%)',
|
|
'radial-gradient(50% 100% at 50% 0%, oklch(0.9 0.06 280 / 0.1) 0%, transparent 75%)',
|
|
].join(','),
|
|
animation: reduced ? 'none' : `${animAurora} 18s ease-in-out infinite`,
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
|
}}
|
|
/>
|
|
|
|
{/* String-light wire — a faint catenary line the bulbs hang from. */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: '14%',
|
|
contain: 'layout paint style',
|
|
backgroundImage:
|
|
'radial-gradient(140% 60% at 50% -30%, oklch(0.3 0.04 250 / 0.14) 0%, transparent 70%)',
|
|
}}
|
|
/>
|
|
|
|
{/* Bokeh string lights — soft blurred orbs that breathe. */}
|
|
{bulbs.map((b, i) => (
|
|
<div
|
|
key={`bulb-${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${b.left}%`,
|
|
top: `${b.top}%`,
|
|
width: `${b.size}px`,
|
|
height: `${b.size}px`,
|
|
marginLeft: `${-b.size / 2}px`,
|
|
borderRadius: '50%',
|
|
background: `radial-gradient(circle at 38% 34%, oklch(0.98 0.02 95 / 0.95) 0%, ${b.color} 38%, transparent 72%)`,
|
|
boxShadow: `0 0 ${b.size}px ${b.size * 0.45}px ${b.color.replace(')', ' / 0.45)')}`,
|
|
filter: 'blur(0.5px)',
|
|
opacity: reduced ? 0.9 : undefined,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animBulbBreathe} ${b.duration}s ease-in-out ${b.delay}s infinite`,
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* Snowfall (motion only) — three parallax bands. Static dusting below. */}
|
|
{!reduced &&
|
|
flakes.map((f, i) => (
|
|
<div
|
|
key={`snow-${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: `${f.left}%`,
|
|
width: `${f.size}px`,
|
|
height: `${f.size}px`,
|
|
animation: `${animSnowSway} ${f.swayDuration}s ease-in-out ${f.delay}s infinite`,
|
|
willChange: 'transform',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
borderRadius: '50%',
|
|
background:
|
|
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
|
|
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.55)',
|
|
opacity: f.opacity,
|
|
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
|
animation: `${animSnowFall} ${f.duration}s linear ${f.delay}s infinite`,
|
|
willChange: 'transform, opacity',
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{/* Static dusting of snow for the reduced-motion / preview scene — a
|
|
sparse scatter so the thumbnail still reads as snowfall. */}
|
|
{reduced &&
|
|
flakes.map((f, i) => {
|
|
const fy = rand(i + 0.5) * 96 + 2;
|
|
return (
|
|
<div
|
|
key={`snow-static-${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${f.left}%`,
|
|
top: `${fy}%`,
|
|
width: `${f.size}px`,
|
|
height: `${f.size}px`,
|
|
borderRadius: '50%',
|
|
background:
|
|
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
|
|
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.5)',
|
|
opacity: f.opacity,
|
|
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|