Files
cinny/src/app/components/seasonal/themes/EarthDay.tsx
T
jared 26f998d243
CI / Build & Quality Checks (push) Successful in 11m7s
CI / Trigger Desktop Build (push) Successful in 12s
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>
2026-06-30 19:41:58 -04:00

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`,
}}
/>
))}
</>
);
}