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>
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { SeasonalOverlayProps } from '../types';
|
|
import {
|
|
animCloverTumble,
|
|
animCloverSway,
|
|
animVerdantBreathe,
|
|
animRainbowShimmer,
|
|
animCoinGlint,
|
|
animMoteTwinkle,
|
|
} from './StPatricks.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);
|
|
};
|
|
|
|
// Shamrock (three-leaf) and lucky four-leaf clover silhouettes as inline SVG
|
|
// data-URIs — pure CSS, no external assets, Tauri/CSP-safe. The `fill` color is
|
|
// baked per-variant in oklch-adjacent sRGB (data-URIs can't carry oklch), kept
|
|
// luminous green so the glyphs read as foliage even at low opacity.
|
|
const cloverSvg = (leaves: 3 | 4, fill: string) => {
|
|
// Each leaf is a heart-ish lobe; petals arranged radially around the stem.
|
|
const heart = 'M0,-2 C5,-12 18,-9 14,2 C12,8 4,9 0,3 C-4,9 -12,8 -14,2 C-18,-9 -5,-12 0,-2 Z';
|
|
// Rotations for the lobes; 3-leaf = 120° spread, 4-leaf = 90° spread.
|
|
const rots = leaves === 4 ? [0, 90, 180, 270] : [-90, 30, 150];
|
|
const lobes = rots
|
|
.map((r) => `<path d="${heart}" transform="rotate(${r}) translate(0 -12)"/>`)
|
|
.join('');
|
|
const stem = `<path d="M0,8 C-1,18 2,26 0,34" stroke="${
|
|
fill
|
|
}" stroke-width="2.4" fill="none" stroke-linecap="round"/>`;
|
|
const svg =
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-26 -26 52 64">` +
|
|
`<g fill="${fill}">${lobes}</g>${stem}</svg>`;
|
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
|
};
|
|
|
|
// Three foliage greens for parallax depth — far/dim through near/bright. These
|
|
// are the sRGB siblings of the brief's oklch emerald / shamrock-green targets.
|
|
const CLOVER_FILLS = [
|
|
'#1f9e54', // deep shamrock (far)
|
|
'#2db866', // emerald (mid)
|
|
'#48d97f', // bright clover (near)
|
|
];
|
|
|
|
type Clover = {
|
|
left: number;
|
|
size: number;
|
|
duration: number;
|
|
delay: number;
|
|
swayDuration: number;
|
|
opacity: number;
|
|
blur: number;
|
|
fill: string;
|
|
leaves: 3 | 4;
|
|
// Resting position + tilt for the static (reduced) settled scene.
|
|
restTop: number;
|
|
restRot: number;
|
|
};
|
|
|
|
type Coin = {
|
|
left: number;
|
|
top: number;
|
|
size: number;
|
|
duration: number;
|
|
delay: number;
|
|
};
|
|
|
|
type Mote = {
|
|
left: number;
|
|
top: number;
|
|
size: number;
|
|
duration: number;
|
|
delay: number;
|
|
};
|
|
|
|
export function StPatricksOverlay({ reduced }: SeasonalOverlayProps) {
|
|
// Three parallax bands of clovers: far (small/slow/dim) -> near (large/fast).
|
|
// ~22 clovers total; one lucky four-leaf seeded in for charm.
|
|
const clovers = useMemo<Clover[]>(() => {
|
|
const bands = [
|
|
{ count: 8, size: [12, 18], dur: [20, 26], op: [0.22, 0.34], blur: 0.8, fill: 0 },
|
|
{ count: 8, size: [18, 26], dur: [15, 20], op: [0.34, 0.5], blur: 0.4, fill: 1 },
|
|
{ count: 6, size: [26, 38], dur: [11, 15], op: [0.46, 0.62], blur: 0, fill: 2 },
|
|
];
|
|
const out: Clover[] = [];
|
|
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);
|
|
const r5 = rand(s + 1.13);
|
|
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] + 5),
|
|
swayDuration: 5 + r2 * 6,
|
|
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
|
|
blur: b.blur,
|
|
// The single lucky four-leaf: one mid-band clover.
|
|
leaves: s === 10 ? 4 : 3,
|
|
fill: CLOVER_FILLS[b.fill],
|
|
restTop: 6 + r5 * 88,
|
|
restRot: (r4 - 0.5) * 80,
|
|
});
|
|
s += 1;
|
|
}
|
|
});
|
|
return out;
|
|
}, []);
|
|
|
|
// Gold-coin glints scattered low — a faint pot-of-gold sparkle. ~5 discs.
|
|
const coins = useMemo<Coin[]>(() => {
|
|
const count = 5;
|
|
const out: Coin[] = [];
|
|
for (let i = 0; i < count; i += 1) {
|
|
out.push({
|
|
left: 8 + rand(i + 40) * 84,
|
|
top: 58 + rand(i + 47) * 36,
|
|
size: 8 + rand(i + 51) * 9,
|
|
duration: 4 + rand(i + 55) * 3,
|
|
delay: -rand(i + 61) * 6,
|
|
});
|
|
}
|
|
return out;
|
|
}, []);
|
|
|
|
// Golden sparkle motes drifting through the scene. ~7 points.
|
|
const motes = useMemo<Mote[]>(() => {
|
|
const count = 7;
|
|
const out: Mote[] = [];
|
|
for (let i = 0; i < count; i += 1) {
|
|
out.push({
|
|
left: rand(i + 70) * 100,
|
|
top: 8 + rand(i + 77) * 82,
|
|
size: 2 + rand(i + 83) * 3,
|
|
duration: 3 + rand(i + 89) * 3.5,
|
|
delay: -rand(i + 97) * 6,
|
|
});
|
|
}
|
|
return out;
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
{/* Emerald 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% 85% at 50% -12%, oklch(0.60 0.16 150 / 0.16) 0%, transparent 56%)',
|
|
'radial-gradient(90% 65% at 12% 112%, oklch(0.55 0.15 145 / 0.12) 0%, transparent 60%)',
|
|
'radial-gradient(80% 60% at 92% 108%, oklch(0.82 0.14 90 / 0.07) 0%, transparent 62%)',
|
|
'linear-gradient(180deg, oklch(0.62 0.15 150 / 0.05) 0%, transparent 24%, transparent 82%, oklch(0.5 0.14 148 / 0.08) 100%)',
|
|
].join(','),
|
|
}}
|
|
/>
|
|
|
|
{/* Verdant vignette frame — green edges, clear center. A single cheap
|
|
backdrop-filter adds a faint warm-emerald haze around the rim. */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
contain: 'layout paint style',
|
|
backdropFilter: 'blur(0.4px) saturate(1.05)',
|
|
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
|
|
backgroundImage:
|
|
'radial-gradient(140% 125% at 50% 44%, transparent 50%, oklch(0.6 0.13 150 / 0.07) 74%, oklch(0.48 0.14 148 / 0.17) 100%)',
|
|
animation: reduced ? 'none' : `${animVerdantBreathe} 13s ease-in-out infinite`,
|
|
willChange: reduced ? undefined : 'opacity',
|
|
}}
|
|
/>
|
|
|
|
{/* Soft rainbow shimmer arc tucked into the top-right corner — a faint
|
|
luck-of-the-Irish band. Heavily blurred + screen-blended so it reads
|
|
as light, never as a hard stripe over chat. */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: '-22%',
|
|
right: '-18%',
|
|
width: '62%',
|
|
height: '62%',
|
|
contain: 'layout paint style',
|
|
mixBlendMode: 'screen',
|
|
filter: 'blur(30px)',
|
|
opacity: reduced ? 0.6 : undefined,
|
|
// Concentric arc bands — red through violet, all low alpha.
|
|
backgroundImage: [
|
|
'radial-gradient(closest-side at 78% 28%, transparent 58%, oklch(0.7 0.18 28 / 0.16) 62%, transparent 67%)',
|
|
'radial-gradient(closest-side at 78% 28%, transparent 63%, oklch(0.82 0.16 80 / 0.16) 67%, transparent 72%)',
|
|
'radial-gradient(closest-side at 78% 28%, transparent 68%, oklch(0.85 0.17 130 / 0.16) 72%, transparent 77%)',
|
|
'radial-gradient(closest-side at 78% 28%, transparent 73%, oklch(0.72 0.15 230 / 0.15) 77%, transparent 82%)',
|
|
'radial-gradient(closest-side at 78% 28%, transparent 78%, oklch(0.6 0.16 300 / 0.13) 82%, transparent 87%)',
|
|
].join(','),
|
|
animation: reduced ? 'none' : `${animRainbowShimmer} 20s ease-in-out infinite`,
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
|
}}
|
|
/>
|
|
|
|
{/* Gold-coin glints — small metallic discs that catch the light. */}
|
|
{coins.map((c, i) => (
|
|
<div
|
|
key={`coin-${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${c.left}%`,
|
|
top: `${c.top}%`,
|
|
width: `${c.size}px`,
|
|
height: `${c.size}px`,
|
|
borderRadius: '50%',
|
|
background:
|
|
'radial-gradient(circle at 36% 32%, oklch(0.97 0.06 95 / 0.95) 0%, oklch(0.82 0.14 90 / 0.85) 45%, oklch(0.68 0.13 78 / 0.4) 78%, transparent 100%)',
|
|
boxShadow: `0 0 ${c.size * 0.9}px ${c.size * 0.35}px oklch(0.82 0.14 90 / 0.4)`,
|
|
opacity: reduced ? 0.85 : undefined,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animCoinGlint} ${c.duration}s ease-in-out ${c.delay}s infinite`,
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* Golden sparkle motes — tiny four-point glints of luck. */}
|
|
{motes.map((m, i) => (
|
|
<div
|
|
key={`mote-${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${m.left}%`,
|
|
top: `${m.top}%`,
|
|
width: `${m.size}px`,
|
|
height: `${m.size}px`,
|
|
borderRadius: '50%',
|
|
background:
|
|
'radial-gradient(circle, oklch(0.98 0.05 95 / 0.95) 0%, oklch(0.85 0.13 88 / 0.6) 50%, transparent 100%)',
|
|
boxShadow: '0 0 6px oklch(0.85 0.13 88 / 0.6)',
|
|
opacity: reduced ? 0.9 : undefined,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animMoteTwinkle} ${m.duration}s ease-in-out ${m.delay}s infinite`,
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* Drifting clovers (motion only) — three parallax bands tumbling down.
|
|
Settled static scatter is rendered below for reduced/preview. */}
|
|
{!reduced &&
|
|
clovers.map((c, i) => (
|
|
<div
|
|
key={`clover-${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: `${c.left}%`,
|
|
width: `${c.size}px`,
|
|
height: `${c.size * 1.2}px`,
|
|
animation: `${animCloverSway} ${c.swayDuration}s ease-in-out ${c.delay}s infinite`,
|
|
willChange: 'transform',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
backgroundImage: cloverSvg(c.leaves, c.fill),
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundSize: 'contain',
|
|
backgroundPosition: 'center',
|
|
opacity: c.opacity,
|
|
filter: `drop-shadow(0 0 3px oklch(0.55 0.15 145 / 0.4))${
|
|
c.blur ? ` blur(${c.blur}px)` : ''
|
|
}`,
|
|
animation: `${animCloverTumble} ${c.duration}s linear ${c.delay}s infinite`,
|
|
willChange: 'transform, opacity',
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{/* Static settled clovers for the reduced-motion / preview scene — a
|
|
gentle scatter resting at varied tilts so the thumbnail reads as a
|
|
lucky, still field of shamrocks. */}
|
|
{reduced &&
|
|
clovers.map((c, i) => (
|
|
<div
|
|
key={`clover-static-${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${c.left}%`,
|
|
top: `${c.restTop}%`,
|
|
width: `${c.size}px`,
|
|
height: `${c.size * 1.2}px`,
|
|
backgroundImage: cloverSvg(c.leaves, c.fill),
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundSize: 'contain',
|
|
backgroundPosition: 'center',
|
|
transform: `rotate(${c.restRot}deg)`,
|
|
opacity: c.opacity,
|
|
filter: `drop-shadow(0 0 3px oklch(0.55 0.15 145 / 0.4))${
|
|
c.blur ? ` blur(${c.blur}px)` : ''
|
|
}`,
|
|
}}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|