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>
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { SeasonalOverlayProps } from '../types';
|
|
import {
|
|
animLeafTumble,
|
|
animSeedDrift,
|
|
animPollenFloat,
|
|
animPollenGlow,
|
|
animRayBreathe,
|
|
animAuroraSway,
|
|
animEarthRespire,
|
|
} from './EarthDay.css';
|
|
|
|
// ─── Palette (oklch) ──────────────────────────────────────────────────────────
|
|
// Verdant, hopeful nature: living leaf greens, soft sky + deep ocean blues,
|
|
// and a warm sun highlight. Kept low-alpha so chat text stays WCAG-AA legible.
|
|
const LEAF_GREEN = 'oklch(0.65 0.15 145)';
|
|
const LEAF_DEEP = 'oklch(0.52 0.14 150)';
|
|
const LEAF_LIME = 'oklch(0.78 0.16 130)';
|
|
const SKY_BLUE = 'oklch(0.70 0.10 230)';
|
|
const OCEAN_BLUE = 'oklch(0.55 0.12 240)';
|
|
const SUN_WARM = 'oklch(0.92 0.10 95)';
|
|
const POLLEN_GOLD = 'oklch(0.88 0.13 95)';
|
|
|
|
// Soft, translucent tints for the ambient gradient washes.
|
|
const LEAF_GREEN_SOFT = 'oklch(0.65 0.15 145 / 0.10)';
|
|
const LEAF_LIME_SOFT = 'oklch(0.78 0.16 130 / 0.08)';
|
|
const SKY_BLUE_SOFT = 'oklch(0.70 0.10 230 / 0.07)';
|
|
const SUN_SOFT = 'oklch(0.92 0.10 95 / 0.10)';
|
|
const AURORA_TINT = 'oklch(0.74 0.16 155 / 0.22)';
|
|
|
|
// ─── Inline SVG leaf, drawn once (CSP-safe data-URI, no external assets) ───────
|
|
// A simple veined leaf silhouette. Color is baked per-variant so we can tint
|
|
// individual falling leaves without a runtime filter.
|
|
function leafUri(fill: string, vein: string): string {
|
|
const svg =
|
|
`<svg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'>` +
|
|
`<path fill='${fill}' d='M14 1C7 5 2 11 2 18c0 5 4 9 9 9 7 0 15-7 15-19 0-3-1-6-2-6-3 1-6 2-10 0C12 1 13 1 14 1z'/>` +
|
|
`<path fill='none' stroke='${vein}' stroke-width='0.9' stroke-linecap='round' ` +
|
|
`d='M11 26C13 18 17 9 23 3M11 26c-1-4-2-7-4-9M13 20c2-1 4-2 6-5M12 14c2-1 3-2 5-5'/>` +
|
|
`</svg>`;
|
|
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
|
|
}
|
|
|
|
// Three leaf tints, generated once at module load.
|
|
const LEAF_URIS = [
|
|
leafUri('oklch(0.65 0.15 145 / 0.9)', 'oklch(0.40 0.10 150 / 0.7)'),
|
|
leafUri('oklch(0.78 0.16 130 / 0.9)', 'oklch(0.50 0.12 140 / 0.7)'),
|
|
leafUri('oklch(0.52 0.14 150 / 0.9)', 'oklch(0.34 0.08 155 / 0.7)'),
|
|
];
|
|
|
|
export function EarthDayOverlay({ reduced }: SeasonalOverlayProps) {
|
|
// ── Deterministic per-mount generation — never per-frame React state. ──
|
|
|
|
// Tumbling leaves (the heaviest motif → kept modest).
|
|
const leaves = useMemo(
|
|
() =>
|
|
Array.from({ length: 10 }, (_, i) => ({
|
|
left: (i * 6173 + 137) % 96,
|
|
size: 16 + (i % 4) * 6,
|
|
duration: 16 + (i % 5) * 2.5,
|
|
delay: (i * 1.7) % 16,
|
|
uri: LEAF_URIS[i % LEAF_URIS.length],
|
|
opacity: 0.45 + (i % 3) * 0.12,
|
|
})),
|
|
[],
|
|
);
|
|
|
|
// Tiny drifting seeds / spores — small, faint, slow.
|
|
const seeds = useMemo(
|
|
() =>
|
|
Array.from({ length: 8 }, (_, i) => ({
|
|
left: (i * 4099 + 53) % 98,
|
|
size: 2 + (i % 2),
|
|
duration: 18 + (i % 4) * 3,
|
|
delay: (i * 2.3) % 18,
|
|
})),
|
|
[],
|
|
);
|
|
|
|
// Glowing pollen motes rising from below, catching the light.
|
|
const pollen = useMemo(
|
|
() =>
|
|
Array.from({ length: 12 }, (_, i) => ({
|
|
left: (i * 5279 + 89) % 100,
|
|
bottom: (i * 2731 + 31) % 32,
|
|
size: 3 + (i % 3),
|
|
duration: 13 + (i % 6) * 2,
|
|
delay: (i * 0.9) % 13,
|
|
twinkle: 2.6 + (i % 5) * 0.5,
|
|
})),
|
|
[],
|
|
);
|
|
|
|
// Sun rays fanning down from the top — a few soft angled beams.
|
|
const rays = useMemo(
|
|
() =>
|
|
Array.from({ length: 5 }, (_, i) => ({
|
|
left: 12 + i * 18,
|
|
rotate: -14 + i * 7,
|
|
width: 60 + (i % 3) * 26,
|
|
duration: 8 + (i % 3) * 2,
|
|
delay: i * 1.3,
|
|
opacity: 0.32 + (i % 3) * 0.08,
|
|
})),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* ── Base wash: layered green/sky gradients for verdant depth ── */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
contain: 'layout paint style',
|
|
backgroundImage: [
|
|
// warm sun glow spilling from top-center
|
|
`radial-gradient(60vmax 42vmax at 50% -8%, ${SUN_SOFT} 0%, transparent 60%)`,
|
|
// verdant canopy glow rising from the lower-left
|
|
`radial-gradient(52vmax 52vmax at 14% 100%, ${LEAF_GREEN_SOFT} 0%, transparent 62%)`,
|
|
// lime highlight upper-right for freshness
|
|
`radial-gradient(40vmax 40vmax at 86% 18%, ${LEAF_LIME_SOFT} 0%, transparent 58%)`,
|
|
// cool sky tint at the very top to pair with the Earth
|
|
`radial-gradient(70vmax 30vmax at 70% 4%, ${SKY_BLUE_SOFT} 0%, transparent 64%)`,
|
|
].join(', '),
|
|
opacity: 0.9,
|
|
}}
|
|
/>
|
|
|
|
{/* ── Green aurora veil drifting near the top ── */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
left: '-12%',
|
|
right: '-12%',
|
|
top: '-8%',
|
|
height: '46vh',
|
|
contain: 'layout paint style',
|
|
backgroundImage: `radial-gradient(60% 100% at 50% 0%, ${AURORA_TINT} 0%, transparent 72%)`,
|
|
filter: 'blur(26px)',
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
|
transformOrigin: '50% 0%',
|
|
opacity: reduced ? 0.55 : undefined,
|
|
transform: reduced ? 'translate3d(0, 0, 0) scale(1.15)' : undefined,
|
|
animation: reduced ? 'none' : `${animAuroraSway} 24s ease-in-out infinite`,
|
|
}}
|
|
/>
|
|
|
|
{/* ── Soft sun rays fanning down from above ── */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
contain: 'layout paint style',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{rays.map((r, i) => (
|
|
<div
|
|
key={i}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: '-10%',
|
|
left: `${r.left}%`,
|
|
width: `${r.width}px`,
|
|
height: '95vh',
|
|
transformOrigin: '50% 0%',
|
|
transform: `rotate(${r.rotate}deg)`,
|
|
backgroundImage: `linear-gradient(180deg, ${SUN_WARM} 0%, transparent 70%)`,
|
|
filter: 'blur(8px)',
|
|
mixBlendMode: 'screen',
|
|
opacity: r.opacity,
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animRayBreathe} ${r.duration}s ease-in-out ${r.delay}s infinite`,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── Blue-marble Earth tucked into the bottom-right corner ── */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
right: '-6%',
|
|
bottom: '-10%',
|
|
width: '300px',
|
|
height: '300px',
|
|
contain: 'layout paint style',
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
|
transform: reduced ? 'scale(1.02)' : undefined,
|
|
opacity: reduced ? 0.9 : undefined,
|
|
animation: reduced ? 'none' : `${animEarthRespire} 18s ease-in-out infinite`,
|
|
}}
|
|
>
|
|
{/* atmospheric rim halo */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
inset: '-14%',
|
|
borderRadius: '50%',
|
|
backgroundImage: `radial-gradient(circle at 50% 50%, transparent 58%, ${SKY_BLUE} 68%, transparent 80%)`,
|
|
filter: 'blur(10px)',
|
|
opacity: 0.5,
|
|
}}
|
|
/>
|
|
{/* the globe itself — oceans, land, soft terminator shadow */}
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
borderRadius: '50%',
|
|
backgroundImage: [
|
|
// continents (green landmasses)
|
|
`radial-gradient(26% 30% at 38% 40%, ${LEAF_GREEN} 0%, transparent 60%)`,
|
|
`radial-gradient(22% 26% at 64% 58%, ${LEAF_DEEP} 0%, transparent 62%)`,
|
|
`radial-gradient(16% 18% at 50% 74%, ${LEAF_LIME} 0%, transparent 65%)`,
|
|
// ocean base
|
|
`radial-gradient(circle at 42% 38%, ${SKY_BLUE} 0%, ${OCEAN_BLUE} 55%, oklch(0.40 0.10 250) 100%)`,
|
|
].join(', '),
|
|
// soft day/night terminator from the lower-right
|
|
boxShadow: 'inset -22px -26px 50px oklch(0.18 0.05 250 / 0.7)',
|
|
opacity: 0.42,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* ── Rising, glowing pollen motes ── */}
|
|
{pollen.map((p, i) => (
|
|
<div
|
|
key={`p${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${p.left}%`,
|
|
bottom: `${p.bottom}%`,
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
|
transform: reduced ? 'scale(0.95)' : undefined,
|
|
opacity: reduced ? 0.6 : undefined,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animPollenFloat} ${p.duration}s ease-in ${p.delay}s infinite`,
|
|
}}
|
|
>
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
width: `${p.size}px`,
|
|
height: `${p.size}px`,
|
|
borderRadius: '50%',
|
|
backgroundColor: POLLEN_GOLD,
|
|
boxShadow: `0 0 ${p.size * 2.6}px ${POLLEN_GOLD}`,
|
|
animation: reduced ? 'none' : `${animPollenGlow} ${p.twinkle}s ease-in-out infinite`,
|
|
}}
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{/* ── Drifting seeds / spores (skip entirely when reduced) ── */}
|
|
{!reduced &&
|
|
seeds.map((s, i) => (
|
|
<div
|
|
key={`s${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: '-6%',
|
|
left: `${s.left}%`,
|
|
width: `${s.size}px`,
|
|
height: `${s.size}px`,
|
|
borderRadius: '50%',
|
|
backgroundColor: 'oklch(0.96 0.02 120 / 0.85)',
|
|
boxShadow: '0 0 6px oklch(0.92 0.04 120 / 0.6)',
|
|
willChange: 'transform, opacity',
|
|
animation: `${animSeedDrift} ${s.duration}s linear ${s.delay}s infinite`,
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* ── Tumbling leaves ── */}
|
|
{leaves.map((l, i) => (
|
|
<div
|
|
key={`l${i}`}
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
top: '-10%',
|
|
left: `${l.left}%`,
|
|
width: `${l.size}px`,
|
|
height: `${l.size}px`,
|
|
backgroundImage: l.uri,
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundSize: 'contain',
|
|
willChange: reduced ? undefined : 'transform, opacity',
|
|
opacity: l.opacity,
|
|
// Static leaves are scattered down the column so the still scene
|
|
// reads as a gentle leaf-fall frozen mid-air.
|
|
transform: reduced
|
|
? `translate3d(${(i % 2 ? 1 : -1) * 3}vw, ${6 + i * 9}vh, 0) rotate(${
|
|
(i * 47) % 360
|
|
}deg)`
|
|
: undefined,
|
|
animation: reduced
|
|
? 'none'
|
|
: `${animLeafTumble} ${l.duration}s ease-in ${l.delay}s infinite`,
|
|
}}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|