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>
268 lines
8.8 KiB
TypeScript
268 lines
8.8 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { SeasonalOverlayProps } from '../types';
|
|
import {
|
|
animMoonPulse,
|
|
animFogDrift,
|
|
animBatGlide,
|
|
animWingFlap,
|
|
animEmberFloat,
|
|
animEmberTwinkle,
|
|
} from './Halloween.css';
|
|
|
|
// ─── Palette (oklch) ──────────────────────────────────────────────────────────
|
|
// Deep haunted indigo, sickly toxic-green moon glow, warm ember orange.
|
|
const PURPLE_DEEP = 'oklch(0.20 0.12 300)';
|
|
const PURPLE_FAINT = 'oklch(0.28 0.10 300 / 0.45)';
|
|
const TOXIC_GREEN = 'oklch(0.80 0.18 150)';
|
|
const TOXIC_GREEN_SOFT = 'oklch(0.72 0.16 150 / 0.35)';
|
|
const EMBER_ORANGE = 'oklch(0.70 0.18 50)';
|
|
const FOG_TINT = 'oklch(0.45 0.06 280 / 0.32)';
|
|
|
|
// A corner cobweb, drawn once as an inline SVG data-URI (CSP-safe, no assets).
|
|
// strokeWidth kept hairline so it reads as gossamer thread, not a cage.
|
|
const cobwebUri = (() => {
|
|
const svg =
|
|
`<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180'>` +
|
|
`<g fill='none' stroke='rgba(196,176,224,0.32)' stroke-width='0.8'>` +
|
|
// radial threads
|
|
`<line x1='0' y1='0' x2='180' y2='180'/>` +
|
|
`<line x1='0' y1='0' x2='180' y2='90'/>` +
|
|
`<line x1='0' y1='0' x2='90' y2='180'/>` +
|
|
`<line x1='0' y1='0' x2='180' y2='40'/>` +
|
|
`<line x1='0' y1='0' x2='40' y2='180'/>` +
|
|
// concentric catch-threads (gentle sag via quadratic curves)
|
|
`<path d='M40 0 Q22 22 0 40'/>` +
|
|
`<path d='M85 0 Q48 48 0 85'/>` +
|
|
`<path d='M130 0 Q74 74 0 130'/>` +
|
|
`<path d='M180 0 Q104 104 0 180'/>` +
|
|
`<path d='M180 60 Q120 120 60 180'/>` +
|
|
`</g></svg>`;
|
|
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
|
|
})();
|
|
|
|
// A single silhouetted bat, inline SVG. Wings are separate so the wrapper can
|
|
// glide while an inner element flaps independently — we re-use one body shape.
|
|
function BatSilhouette() {
|
|
return (
|
|
<svg
|
|
width="46"
|
|
height="22"
|
|
viewBox="0 0 46 22"
|
|
aria-hidden="true"
|
|
style={{ display: 'block', overflow: 'visible' }}
|
|
>
|
|
<path
|
|
fill="oklch(0.12 0.04 300 / 0.85)"
|
|
d="M23 6c1.6 0 2.7 1.3 3 3 .9-1.4 2.4-2.6 4.2-2.6-.5 1-.4 2.1.2 2.9 2-2.4 5.4-4 8.6-3.7-1.5 1-2.3 2.6-2.4 4.3 1.3-.8 3-1 4.4-.4-2.2.8-3.9 2.5-5.2 4.5-2 3-4.8 5-8.3 4.4-1.9-.3-3.4-1.6-4.5-3.2-1.1 1.6-2.6 2.9-4.5 3.2-3.5.6-6.3-1.4-8.3-4.4-1.3-2-3-3.7-5.2-4.5 1.4-.6 3.1-.4 4.4.4-.1-1.7-.9-3.3-2.4-4.3 3.2-.3 6.6 1.3 8.6 3.7.6-.8.7-1.9.2-2.9 1.8 0 3.3 1.2 4.2 2.6.3-1.7 1.4-3 3-3z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function HalloweenOverlay({ reduced }: SeasonalOverlayProps) {
|
|
// Deterministic per-mount generation — never per-frame React state.
|
|
const embers = useMemo(
|
|
() =>
|
|
Array.from({ length: 12 }, (_, i) => {
|
|
const green = i % 3 === 0; // ~1/3 toxic-green wisps, rest warm embers
|
|
return {
|
|
left: (i * 6151 + 113) % 100,
|
|
bottom: (i * 3137 + 47) % 28, // start near floor
|
|
size: 3 + (i % 4),
|
|
duration: 11 + (i % 6) * 2.2,
|
|
delay: (i * 0.83) % 11,
|
|
twinkle: 2.4 + (i % 5) * 0.6,
|
|
color: green ? TOXIC_GREEN : EMBER_ORANGE,
|
|
};
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const bats = useMemo(
|
|
() =>
|
|
Array.from({ length: 3 }, (_, i) => ({
|
|
top: 8 + i * 13,
|
|
duration: 22 + i * 7,
|
|
delay: i * 6.5,
|
|
flap: 0.5 + i * 0.12,
|
|
scale: 0.7 + i * 0.18,
|
|
})),
|
|
[],
|
|
);
|
|
|
|
const fogBands = useMemo(
|
|
() =>
|
|
Array.from({ length: 3 }, (_, i) => ({
|
|
bottom: -6 + i * 9,
|
|
duration: 26 + i * 8,
|
|
delay: i * 5,
|
|
height: 130 + i * 30,
|
|
})),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* ── Sky: layered indigo→black gradient with toxic-green moon vignette ── */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
contain: 'layout paint style',
|
|
backgroundColor: 'transparent',
|
|
backgroundImage: [
|
|
// sickly moon glow, upper-right
|
|
`radial-gradient(38vmax 38vmax at 78% 14%, ${TOXIC_GREEN_SOFT} 0%, transparent 58%)`,
|
|
// cold counter-glow lower-left for depth
|
|
`radial-gradient(46vmax 46vmax at 12% 92%, ${PURPLE_FAINT} 0%, transparent 60%)`,
|
|
// overall indigo→black wash, darker toward edges (vignette)
|
|
`radial-gradient(120% 120% at 50% 30%, transparent 32%, ${PURPLE_DEEP} 100%)`,
|
|
`linear-gradient(180deg, ${PURPLE_DEEP} 0%, transparent 45%)`,
|
|
].join(', '),
|
|
opacity: 0.5,
|
|
}}
|
|
/>
|
|
|
|
{/* ── Moon disc + breathing halo (the only backdrop-filter, kept cheap) ── */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: '8%',
|
|
right: '12%',
|
|
width: '160px',
|
|
height: '160px',
|
|
borderRadius: '50%',
|
|
willChange: 'transform, opacity',
|
|
backgroundImage: `radial-gradient(circle at 42% 40%, ${TOXIC_GREEN} 0%, oklch(0.55 0.14 150 / 0.5) 38%, transparent 72%)`,
|
|
filter: 'blur(2px)',
|
|
backdropFilter: 'saturate(1.15)',
|
|
animation: reduced ? 'none' : `${animMoonPulse} 9s ease-in-out infinite`,
|
|
}}
|
|
/>
|
|
|
|
{/* ── Low drifting fog bands ── */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
contain: 'layout paint style',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{fogBands.map((f, i) => (
|
|
<div
|
|
key={i}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
left: '-15%',
|
|
right: '-15%',
|
|
bottom: `${f.bottom}%`,
|
|
height: `${f.height}px`,
|
|
backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`,
|
|
filter: 'blur(14px)',
|
|
willChange: 'transform, opacity',
|
|
opacity: reduced ? 0.5 : undefined,
|
|
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animFogDrift} ${f.duration}s ease-in-out ${f.delay}s infinite`,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── Will-o'-wisps / floating embers ── */}
|
|
{embers.map((e, i) => (
|
|
<div
|
|
key={i}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${e.left}%`,
|
|
bottom: `${e.bottom}%`,
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
|
transform: reduced ? 'scale(0.9)' : undefined,
|
|
opacity: reduced ? 0.4 : undefined,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animEmberFloat} ${e.duration}s ease-in ${e.delay}s infinite`,
|
|
}}
|
|
>
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
width: `${e.size}px`,
|
|
height: `${e.size}px`,
|
|
borderRadius: '50%',
|
|
backgroundColor: e.color,
|
|
boxShadow: `0 0 ${e.size * 2.5}px ${e.color}`,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animEmberTwinkle} ${e.twinkle}s ease-in-out infinite`,
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{/* ── Silhouetted bats gliding across (skip entirely when reduced) ── */}
|
|
{!reduced &&
|
|
bats.map((b, i) => (
|
|
<div
|
|
key={i}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: `${b.top}%`,
|
|
left: 0,
|
|
willChange: 'transform, opacity',
|
|
animation: `${animBatGlide} ${b.duration}s linear ${b.delay}s infinite`,
|
|
}}
|
|
>
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
transform: `scale(${b.scale})`,
|
|
animation: `${animWingFlap} ${b.flap}s ease-in-out infinite`,
|
|
}}
|
|
>
|
|
<BatSilhouette />
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* ── Cobwebs tucked into two corners (top-left, top-right mirrored) ── */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '180px',
|
|
height: '180px',
|
|
backgroundImage: cobwebUri,
|
|
backgroundRepeat: 'no-repeat',
|
|
opacity: 0.7,
|
|
}}
|
|
/>
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
right: 0,
|
|
width: '180px',
|
|
height: '180px',
|
|
backgroundImage: cobwebUri,
|
|
backgroundRepeat: 'no-repeat',
|
|
transform: 'scaleX(-1)',
|
|
opacity: 0.7,
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|