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,409 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { SeasonalOverlayProps } from '../types';
|
||||
import {
|
||||
animDoodleFloat,
|
||||
animConfettiTumble,
|
||||
animWobble,
|
||||
animRainbowDrift,
|
||||
animGoogly,
|
||||
animSparkle,
|
||||
} from './AprilFools.css';
|
||||
|
||||
// Deterministic pseudo-random so the scene is identical on every mount and the
|
||||
// reduced-motion preview thumbnail is stable. Large primes spread the values.
|
||||
const rand = (seed: number) => {
|
||||
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
// Bright-but-soft pastel rainbow in oklch. Kept luminous and gentle so the
|
||||
// doodles read as crayon pastel over chat without ever fighting the text.
|
||||
const PASTELS = [
|
||||
'oklch(0.85 0.12 20)', // pink
|
||||
'oklch(0.88 0.12 90)', // butter yellow
|
||||
'oklch(0.82 0.12 160)', // mint
|
||||
'oklch(0.8 0.12 260)', // periwinkle
|
||||
'oklch(0.84 0.12 320)', // lilac
|
||||
'oklch(0.86 0.11 50)', // peach
|
||||
];
|
||||
|
||||
// Inline-SVG data-URI doodle glyphs, drawn hand-sketch style (round caps,
|
||||
// open paths). `enc()` keeps them CSP-safe — no external assets, no base64.
|
||||
const enc = (svg: string) => `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||
|
||||
// A single rough stroke wrapper helper for the glyph SVGs.
|
||||
const stroke = (color: string, body: string) =>
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none' ` +
|
||||
`stroke='${color}' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'>${body}</svg>`;
|
||||
|
||||
// Question mark — the playful "huh?" centerpiece doodle.
|
||||
const glyphQuestion = (c: string) =>
|
||||
stroke(
|
||||
c,
|
||||
`<path d='M11 11 q0 -6 6 -6 q6 0 6 5 q0 4 -5 6 q-2 1 -2 4'/>` +
|
||||
`<circle cx='16' cy='27' r='0.6' fill='${c}'/>`,
|
||||
);
|
||||
|
||||
// Exclamation / "bang" — a surprised little doodle.
|
||||
const glyphBang = (c: string) =>
|
||||
stroke(c, `<path d='M16 5 L16 20'/><circle cx='16' cy='27' r='0.6' fill='${c}'/>`);
|
||||
|
||||
// Squiggle — a loopy scribble that adds whimsy.
|
||||
const glyphSquiggle = (c: string) => stroke(c, `<path d='M5 18 q4 -10 8 0 t8 0 t8 0'/>`);
|
||||
|
||||
// Five-point doodle star (open-stroke, hand-drawn look).
|
||||
const glyphStar = (c: string) =>
|
||||
stroke(
|
||||
c,
|
||||
`<path d='M16 5 L19.4 13 L28 13.6 L21.4 19.2 L23.5 27.6 L16 22.8 L8.5 27.6 ` +
|
||||
`L10.6 19.2 L4 13.6 L12.6 13 Z'/>`,
|
||||
);
|
||||
|
||||
// A tiny heart doodle for extra grin.
|
||||
const glyphHeart = (c: string) =>
|
||||
stroke(c, `<path d='M16 26 C6 18 7 8 16 12 C25 8 26 18 16 26 Z'/>`);
|
||||
|
||||
const GLYPHS = [glyphQuestion, glyphBang, glyphSquiggle, glyphStar, glyphHeart, glyphQuestion];
|
||||
|
||||
type Doodle = {
|
||||
left: number;
|
||||
size: number;
|
||||
glyph: string;
|
||||
duration: number;
|
||||
delay: number;
|
||||
startTop: number; // used for the static (reduced) scatter
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
type Confetti = {
|
||||
left: number;
|
||||
size: number;
|
||||
color: string;
|
||||
duration: number;
|
||||
delay: number;
|
||||
startTop: number;
|
||||
ratio: number; // chip aspect
|
||||
round: boolean;
|
||||
};
|
||||
|
||||
type Eye = {
|
||||
left: number;
|
||||
top: number;
|
||||
size: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
type Spark = {
|
||||
left: number;
|
||||
top: number;
|
||||
size: number;
|
||||
color: string;
|
||||
duration: number;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
export function AprilFoolsOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
// ~16 drifting doodles. Built once; per-element timing creates the variety.
|
||||
const doodles = useMemo<Doodle[]>(() => {
|
||||
const count = 16;
|
||||
const out: Doodle[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const color = PASTELS[i % PASTELS.length];
|
||||
out.push({
|
||||
left: rand(i + 0.1) * 96 + 2,
|
||||
size: 18 + rand(i + 0.3) * 22,
|
||||
glyph: enc(GLYPHS[i % GLYPHS.length](color)),
|
||||
duration: 16 + rand(i + 0.5) * 12,
|
||||
delay: -rand(i + 0.7) * 26,
|
||||
startTop: rand(i + 0.9) * 92 + 4,
|
||||
opacity: 0.5 + rand(i + 0.2) * 0.32,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
// ~14 confetti chips in a couple of falling bands.
|
||||
const confetti = useMemo<Confetti[]>(() => {
|
||||
const count = 14;
|
||||
const out: Confetti[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
out.push({
|
||||
left: rand(i + 3.1) * 98 + 1,
|
||||
size: 5 + rand(i + 3.3) * 6,
|
||||
color: PASTELS[(i + 2) % PASTELS.length],
|
||||
duration: 10 + rand(i + 3.5) * 9,
|
||||
delay: -rand(i + 3.7) * 18,
|
||||
startTop: rand(i + 3.9) * 96 + 2,
|
||||
ratio: 0.45 + rand(i + 3.2) * 0.8,
|
||||
round: rand(i + 3.6) > 0.6,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
// A few googly eyes peeking from corners/edges — the cheeky surprise.
|
||||
const eyes = useMemo<Eye[]>(() => {
|
||||
const anchors = [
|
||||
{ left: 6, top: 12 },
|
||||
{ left: 90, top: 20 },
|
||||
{ left: 80, top: 82 },
|
||||
{ left: 14, top: 74 },
|
||||
];
|
||||
return anchors.map((a, i) => ({
|
||||
left: a.left,
|
||||
top: a.top,
|
||||
size: 22 + rand(i + 5.1) * 12,
|
||||
duration: 3 + rand(i + 5.3) * 2.5,
|
||||
delay: -rand(i + 5.5) * 3,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Sly winking sparkles scattered sparsely.
|
||||
const sparks = useMemo<Spark[]>(() => {
|
||||
const count = 5;
|
||||
const out: Spark[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
out.push({
|
||||
left: rand(i + 7.1) * 90 + 5,
|
||||
top: rand(i + 7.3) * 84 + 8,
|
||||
size: 12 + rand(i + 7.5) * 12,
|
||||
color: PASTELS[(i + 1) % PASTELS.length],
|
||||
duration: 4 + rand(i + 7.7) * 3,
|
||||
delay: -rand(i + 7.9) * 5,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
// Four-point glint used for the winking sparkles.
|
||||
const sparkGlint = (c: string) =>
|
||||
enc(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>` +
|
||||
`<path d='M12 0 C13 8 16 11 24 12 C16 13 13 16 12 24 C11 16 8 13 0 12 C8 11 11 8 12 0 Z' fill='${c}'/></svg>`,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Soft pastel ambient wash — layered oklch radials for depth. Very low
|
||||
opacity so chat text keeps WCAG-AA contrast. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: [
|
||||
'radial-gradient(110% 70% at 18% -8%, oklch(0.85 0.12 20 / 0.1) 0%, transparent 55%)',
|
||||
'radial-gradient(95% 65% at 86% 0%, oklch(0.82 0.12 160 / 0.09) 0%, transparent 58%)',
|
||||
'radial-gradient(120% 80% at 50% 112%, oklch(0.8 0.12 260 / 0.1) 0%, transparent 60%)',
|
||||
'linear-gradient(180deg, oklch(0.88 0.12 90 / 0.05) 0%, transparent 30%, transparent 78%, oklch(0.84 0.12 320 / 0.06) 100%)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Faux wobble layer — a near-invisible pastel haze that gently skews so
|
||||
the whole scene feels playfully "tickled". Tiny amplitude = not
|
||||
nauseating. backdrop-filter is one cheap layer for a candy bloom. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-2%',
|
||||
contain: 'layout paint style',
|
||||
backdropFilter: 'saturate(1.06) brightness(1.01)',
|
||||
WebkitBackdropFilter: 'saturate(1.06) brightness(1.01)',
|
||||
backgroundImage:
|
||||
'radial-gradient(130% 120% at 50% 45%, transparent 60%, oklch(0.86 0.11 50 / 0.05) 80%, oklch(0.8 0.12 260 / 0.08) 100%)',
|
||||
transformOrigin: '50% 50%',
|
||||
animation: reduced ? 'none' : `${animWobble} 14s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pastel rainbow aurora high up — soft band of the full palette. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8%',
|
||||
left: '-10%',
|
||||
right: '-10%',
|
||||
height: '42%',
|
||||
contain: 'layout paint style',
|
||||
mixBlendMode: 'screen',
|
||||
filter: 'blur(30px)',
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
backgroundImage: [
|
||||
'radial-gradient(50% 100% at 18% 0%, oklch(0.85 0.12 20 / 0.16) 0%, transparent 72%)',
|
||||
'radial-gradient(50% 100% at 40% 0%, oklch(0.88 0.12 90 / 0.14) 0%, transparent 72%)',
|
||||
'radial-gradient(50% 100% at 62% 0%, oklch(0.82 0.12 160 / 0.14) 0%, transparent 72%)',
|
||||
'radial-gradient(50% 100% at 84% 0%, oklch(0.8 0.12 260 / 0.16) 0%, transparent 72%)',
|
||||
].join(','),
|
||||
animation: reduced ? 'none' : `${animRainbowDrift} 20s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Drifting doodles. Motion: rise from below. Reduced: static scatter. */}
|
||||
{doodles.map((d, i) => {
|
||||
const common: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: `${d.left}%`,
|
||||
width: `${d.size}px`,
|
||||
height: `${d.size}px`,
|
||||
backgroundImage: d.glyph,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
opacity: d.opacity,
|
||||
filter: 'drop-shadow(0 1px 1px oklch(0.4 0.05 300 / 0.18))',
|
||||
};
|
||||
if (reduced) {
|
||||
return (
|
||||
<div
|
||||
key={`doodle-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
top: `${d.startTop}%`,
|
||||
transform: `rotate(${(rand(i + 11) - 0.5) * 24}deg)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`doodle-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
top: 0,
|
||||
animation: `${animDoodleFloat} ${d.duration}s ease-in-out ${d.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Light confetti — tumbling pastel chips. */}
|
||||
{confetti.map((c, i) => {
|
||||
const common: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: `${c.left}%`,
|
||||
width: `${c.size}px`,
|
||||
height: `${c.size * c.ratio}px`,
|
||||
background: c.color,
|
||||
borderRadius: c.round ? '50%' : '1px',
|
||||
opacity: 0.75,
|
||||
};
|
||||
if (reduced) {
|
||||
return (
|
||||
<div
|
||||
key={`confetti-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
top: `${c.startTop}%`,
|
||||
transform: `rotate(${rand(i + 13) * 360}deg)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`confetti-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
top: 0,
|
||||
animation: `${animConfettiTumble} ${c.duration}s linear ${c.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Googly eyes peeking from the edges — pupil wanders cheekily. */}
|
||||
{eyes.map((e, i) => (
|
||||
<div
|
||||
key={`eye-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${e.left}%`,
|
||||
top: `${e.top}%`,
|
||||
width: `${e.size}px`,
|
||||
height: `${e.size}px`,
|
||||
marginLeft: `${-e.size / 2}px`,
|
||||
marginTop: `${-e.size / 2}px`,
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle at 38% 32%, oklch(0.99 0.005 90 / 0.85) 0%, oklch(0.95 0.01 90 / 0.7) 62%, oklch(0.75 0.02 90 / 0.6) 100%)',
|
||||
boxShadow: 'inset 0 0 0 1.5px oklch(0.45 0.03 300 / 0.35)',
|
||||
opacity: 0.6,
|
||||
}}
|
||||
>
|
||||
{/* Pupil */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: `${e.size * 0.4}px`,
|
||||
height: `${e.size * 0.4}px`,
|
||||
marginLeft: `${-e.size * 0.2}px`,
|
||||
marginTop: `${-e.size * 0.2}px`,
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle at 36% 30%, oklch(0.5 0.04 300 / 0.95) 0%, oklch(0.28 0.04 300 / 0.95) 70%)',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animGoogly} ${e.duration}s ease-in-out ${e.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
>
|
||||
{/* Catchlight */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '22%',
|
||||
top: '20%',
|
||||
width: '28%',
|
||||
height: '28%',
|
||||
borderRadius: '50%',
|
||||
background: 'oklch(0.99 0.005 90 / 0.85)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Sly winking sparkles. Static (reduced) shows them mid-glint. */}
|
||||
{sparks.map((s, i) => (
|
||||
<div
|
||||
key={`spark-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${s.left}%`,
|
||||
top: `${s.top}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
backgroundImage: sparkGlint(s.color),
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
filter: `drop-shadow(0 0 3px ${s.color.replace(')', ' / 0.5)')})`,
|
||||
opacity: reduced ? 0.8 : undefined,
|
||||
transform: reduced ? 'scale(0.95) rotate(40deg)' : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animSparkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user