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,310 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { SeasonalOverlayProps } from '../types';
|
||||
import {
|
||||
animLeafFall,
|
||||
animLeafSway,
|
||||
animLeafFlutter,
|
||||
animSunShaft,
|
||||
animMoteDrift,
|
||||
animMoteTwinkle,
|
||||
animEmberPulse,
|
||||
} from './Autumn.css';
|
||||
|
||||
/**
|
||||
* AutumnOverlay — warm falling leaves.
|
||||
*
|
||||
* A full-screen, pointer-events:none ambient decoration. The parent supplies a
|
||||
* fixed inset:0 overflow:hidden pointer-events:none container at the correct
|
||||
* z-index, so this component only returns absolutely-positioned aria-hidden
|
||||
* children and never sets position:fixed / z-index / pointer-events.
|
||||
*
|
||||
* Composition (back to front):
|
||||
* 1. amber -> rust ambient gradient wash (cozy low-sun atmosphere)
|
||||
* 2. soft angled sun shafts breathing high across the scene
|
||||
* 3. drifting pollen / dust motes catching the light
|
||||
* 4. maple & oak leaf silhouettes tumbling and rotating as they fall
|
||||
* 5. a warm low-saturation vignette that frames + protects text contrast
|
||||
*
|
||||
* All motion is transform/opacity only (compositor-friendly). When `reduced`
|
||||
* is true we render a static-but-gorgeous scene: a handful of leaves at rest,
|
||||
* still sun shafts, and the warm vignette — no `animation` anywhere. The
|
||||
* settings preview always passes reduced=true, so the still form stands alone.
|
||||
*/
|
||||
|
||||
// Warm autumn palette in oklch. Kept low-saturation enough to never fight the
|
||||
// chat text underneath. Each leaf picks a tone for variety.
|
||||
const LEAF_TONES = [
|
||||
'oklch(0.75 0.15 70)', // amber
|
||||
'oklch(0.55 0.16 40)', // rust
|
||||
'oklch(0.82 0.13 85)', // warm gold
|
||||
'oklch(0.62 0.16 55)', // burnt orange
|
||||
'oklch(0.5 0.14 35)', // deep ember
|
||||
];
|
||||
|
||||
// Two leaf silhouettes as inline SVG path data (no external assets, CSP-safe).
|
||||
// `maple` = classic five-lobed maple; `oak` = rounded-lobe oak blade.
|
||||
const MAPLE_PATH =
|
||||
'M50 4 L57 30 L78 18 L66 40 L92 40 L70 52 L84 74 L58 62 L56 92 L50 70 ' +
|
||||
'L44 92 L42 62 L16 74 L30 52 L8 40 L34 40 L22 18 L43 30 Z';
|
||||
const OAK_PATH =
|
||||
'M50 4 C58 14 56 22 64 24 C74 22 74 32 68 36 C78 38 76 48 68 50 ' +
|
||||
'C78 54 74 64 66 64 C70 74 60 78 54 72 C54 84 50 96 50 96 ' +
|
||||
'C50 96 46 84 46 72 C40 78 30 74 34 64 C26 64 22 54 32 50 ' +
|
||||
'C24 48 22 38 32 36 C26 32 26 22 36 24 C44 22 42 14 50 4 Z';
|
||||
|
||||
/** Build a CSS-ready data-URI of a single tinted leaf silhouette. */
|
||||
function leafDataUri(kind: 'maple' | 'oak', fill: string): string {
|
||||
const path = kind === 'maple' ? MAPLE_PATH : OAK_PATH;
|
||||
// A faint vein line gives the blade depth without extra DOM nodes.
|
||||
const svg =
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>` +
|
||||
`<path d='${path}' fill='${fill}' fill-opacity='0.92'/>` +
|
||||
`<path d='M50 96 L50 24' stroke='oklch(0.42 0.12 38)' stroke-opacity='0.35' ` +
|
||||
`stroke-width='2.5' fill='none' stroke-linecap='round'/>` +
|
||||
`</svg>`;
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||
}
|
||||
|
||||
type Leaf = {
|
||||
kind: 'maple' | 'oak';
|
||||
uri: string;
|
||||
left: number; // vw column
|
||||
size: number; // px
|
||||
duration: number; // s — fall time
|
||||
delay: number; // s
|
||||
swayDuration: number; // s — wrapper sway
|
||||
flutterDuration: number; // s — inner flutter
|
||||
tilt: number; // deg — resting rotation (used for static scene)
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
type Mote = {
|
||||
left: number;
|
||||
bottom: number;
|
||||
size: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
twinkle: number;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
// A few hand-placed leaves at rest for the reduced/static scene — arranged so
|
||||
// they read as "settled" near edges and corners, never over the busy center.
|
||||
const RESTING_LEAVES: ReadonlyArray<{
|
||||
kind: 'maple' | 'oak';
|
||||
left: number;
|
||||
top: number;
|
||||
size: number;
|
||||
tilt: number;
|
||||
tone: number;
|
||||
opacity: number;
|
||||
}> = [
|
||||
{ kind: 'maple', left: 6, top: 14, size: 46, tilt: -22, tone: 0, opacity: 0.4 },
|
||||
{ kind: 'oak', left: 88, top: 22, size: 38, tilt: 28, tone: 1, opacity: 0.34 },
|
||||
{ kind: 'maple', left: 16, top: 78, size: 54, tilt: 16, tone: 3, opacity: 0.42 },
|
||||
{ kind: 'oak', left: 80, top: 82, size: 44, tilt: -34, tone: 4, opacity: 0.36 },
|
||||
{ kind: 'maple', left: 50, top: 90, size: 40, tilt: 8, tone: 2, opacity: 0.32 },
|
||||
{ kind: 'oak', left: 70, top: 8, size: 32, tilt: -12, tone: 2, opacity: 0.3 },
|
||||
];
|
||||
|
||||
export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
// Deterministic pseudo-random field, computed ONCE. No per-frame state.
|
||||
const { leaves, motes } = useMemo(() => {
|
||||
const LEAF_COUNT = 16;
|
||||
const MOTE_COUNT = 12;
|
||||
|
||||
const builtLeaves: Leaf[] = Array.from({ length: LEAF_COUNT }, (_, i) => {
|
||||
const kind: 'maple' | 'oak' = i % 3 === 0 ? 'oak' : 'maple';
|
||||
const tone = LEAF_TONES[i % LEAF_TONES.length];
|
||||
const sizeBucket = i % 4; // 0..3 → depth bucket for parallax
|
||||
const size = 22 + sizeBucket * 9; // 22..49 px
|
||||
return {
|
||||
kind,
|
||||
uri: leafDataUri(kind, tone),
|
||||
left: (i * 6.13 + 4) % 100,
|
||||
size,
|
||||
// Larger (nearer) leaves fall a touch faster; all slow + cozy.
|
||||
duration: 16 - sizeBucket * 1.6 + (i % 3) * 1.3,
|
||||
delay: -((i * 1.37) % 16), // negative → staggered, already mid-fall
|
||||
swayDuration: 5 + (i % 5) * 0.8,
|
||||
flutterDuration: 1.6 + (i % 4) * 0.45,
|
||||
tilt: ((i * 53) % 70) - 35,
|
||||
opacity: 0.34 + sizeBucket * 0.08, // nearer → slightly bolder
|
||||
};
|
||||
});
|
||||
|
||||
const builtMotes: Mote[] = Array.from({ length: MOTE_COUNT }, (_, i) => ({
|
||||
left: (i * 8.7 + 5) % 100,
|
||||
bottom: (i * 4.3) % 30, // start in lower third, drift up
|
||||
size: 2 + (i % 3),
|
||||
duration: 16 + (i % 6) * 2.4,
|
||||
delay: -((i * 2.1) % 16),
|
||||
twinkle: 2.2 + (i % 4) * 0.6,
|
||||
opacity: 0.4 + (i % 3) * 0.12,
|
||||
}));
|
||||
|
||||
return { leaves: builtLeaves, motes: builtMotes };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 1. Ambient amber → rust atmospheric wash. Layered oklch gradients give
|
||||
depth: a warm low-sun glow from the upper-left, a rust pool at the
|
||||
base, and a faint gold core. Kept very low opacity. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: [
|
||||
'radial-gradient(120% 90% at 18% 8%, oklch(0.82 0.13 85 / 0.16) 0%, transparent 55%)',
|
||||
'radial-gradient(130% 100% at 50% 118%, oklch(0.55 0.16 40 / 0.18) 0%, transparent 60%)',
|
||||
'linear-gradient(180deg, oklch(0.75 0.15 70 / 0.07) 0%, transparent 40%, oklch(0.5 0.14 35 / 0.08) 100%)',
|
||||
].join(','),
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 2. Soft angled low-sun light shafts. Two long beams skewed to suggest
|
||||
late-afternoon light raking across the room. */}
|
||||
{[
|
||||
{ left: -8, rotate: 18, w: 38, opacity: 0.5, dur: 17, delay: 0 },
|
||||
{ left: 46, rotate: 14, w: 30, opacity: 0.38, dur: 22, delay: -6 },
|
||||
{ left: 78, rotate: 22, w: 26, opacity: 0.32, dur: 19, delay: -11 },
|
||||
].map((shaft, i) => (
|
||||
<div
|
||||
key={`shaft-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-30%',
|
||||
left: `${shaft.left}%`,
|
||||
width: `${shaft.w}vw`,
|
||||
height: '160%',
|
||||
transformOrigin: 'top center',
|
||||
transform: `rotate(${shaft.rotate}deg)`,
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, transparent 0%, oklch(0.85 0.12 82 / 0.5) 50%, transparent 100%)',
|
||||
filter: 'blur(14px)',
|
||||
mixBlendMode: 'screen',
|
||||
opacity: reduced ? shaft.opacity * 0.85 : undefined,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
contain: 'layout paint style',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animSunShaft} ${shaft.dur}s ease-in-out ${shaft.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 3. Drifting pollen / dust motes catching the light. Static scene omits
|
||||
them — stillness reads cleaner at rest. */}
|
||||
{!reduced &&
|
||||
motes.map((m, i) => (
|
||||
<div
|
||||
key={`mote-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${m.left}%`,
|
||||
bottom: `${m.bottom}%`,
|
||||
width: `${m.size}px`,
|
||||
height: `${m.size}px`,
|
||||
willChange: 'transform, opacity',
|
||||
animation: `${animMoteDrift} ${m.duration}s linear ${m.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle, oklch(0.88 0.1 85 / 0.95) 0%, oklch(0.78 0.12 70 / 0.4) 60%, transparent 100%)',
|
||||
opacity: m.opacity,
|
||||
animation: `${animMoteTwinkle} ${m.twinkle}s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 4. Falling / resting maple & oak leaves. */}
|
||||
{reduced
|
||||
? RESTING_LEAVES.map((leaf, i) => (
|
||||
<div
|
||||
key={`rest-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${leaf.left}%`,
|
||||
top: `${leaf.top}%`,
|
||||
width: `${leaf.size}px`,
|
||||
height: `${leaf.size}px`,
|
||||
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
|
||||
opacity: leaf.opacity,
|
||||
filter: 'drop-shadow(0 2px 3px oklch(0.3 0.08 40 / 0.35))',
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: leaves.map((leaf, i) => (
|
||||
// Sway wrapper: horizontal wind motion, decoupled from the fall.
|
||||
<div
|
||||
key={`leaf-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: `${leaf.left}%`,
|
||||
width: `${leaf.size}px`,
|
||||
height: `${leaf.size}px`,
|
||||
willChange: 'transform',
|
||||
contain: 'layout paint style',
|
||||
animation: `${animLeafSway} ${leaf.swayDuration}s ease-in-out ${leaf.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
{/* Fall wrapper: vertical descent + tumble rotation. */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
willChange: 'transform, opacity',
|
||||
animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
{/* Inner blade: the actual silhouette + flutter wobble. */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundImage: leaf.uri,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
opacity: leaf.opacity,
|
||||
filter: 'drop-shadow(0 1px 2px oklch(0.3 0.08 40 / 0.3))',
|
||||
animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 5. Warm low-saturation vignette. Frames the scene and gently darkens
|
||||
edges — protecting central chat text contrast. Breathes faintly. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage:
|
||||
'radial-gradient(120% 110% at 50% 45%, transparent 52%, oklch(0.38 0.07 45 / 0.14) 82%, oklch(0.3 0.06 40 / 0.22) 100%)',
|
||||
contain: 'layout paint style',
|
||||
opacity: reduced ? 1 : undefined,
|
||||
animation: reduced ? 'none' : `${animEmberPulse} 9s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user