feat(seasonal): redesign all 11 seasonal themes as modular per-theme overlays
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>
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user