feat(seasonal): redesign all 11 seasonal themes as modular per-theme overlays
CI / Build & Quality Checks (push) Successful in 11m7s
CI / Trigger Desktop Build (push) Successful in 12s

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:
2026-06-30 19:41:58 -04:00
parent f816049fdf
commit 26f998d243
24 changed files with 4705 additions and 806 deletions
@@ -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',
}}
/>
))}
</>
);
}