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,483 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animLanternBob,
animLanternSway,
animTasselSway,
animGlowBreathe,
animPetalFall,
animPetalSway,
animDragonDrift,
animLacquerPulse,
animEmberRise,
} from './LunarNewYear.css';
// Deterministic pseudo-random so the scene is identical every mount (no React
// state per frame). Large primes keep the distribution well spread.
const rand = (seed: number): number => {
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
// Core oklch palette — auspicious crimson/vermilion lanterns, imperial gold
// trim and blossoms, over a deep lacquer-red ambient tint. Kept luminous and
// gentle so everything reads as soft ambient glow, never solid paint.
const CRIMSON = 'oklch(0.50 0.20 25)';
const VERMILION = 'oklch(0.58 0.21 30)';
const GOLD = 'oklch(0.82 0.14 85)';
const GOLD_HI = 'oklch(0.92 0.10 92)';
// A coiling dragon silhouette in imperial gold, rendered once as an inline SVG
// data-URI so it costs a single GPU-composited layer (no DOM weight). The curve
// is intentionally abstract and very subtle — a calligraphic ribbon-body with a
// suggestion of a head, mane and tail arcing across the upper scene.
const dragonUri = ((): string => {
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='760' height='320' viewBox='0 0 760 320'>` +
`<defs>` +
`<linearGradient id='g' x1='0' y1='0' x2='1' y2='0'>` +
`<stop offset='0' stop-color='oklch(0.86 0.13 88)' stop-opacity='0.85'/>` +
`<stop offset='0.55' stop-color='oklch(0.82 0.14 85)' stop-opacity='0.7'/>` +
`<stop offset='1' stop-color='oklch(0.78 0.13 80)' stop-opacity='0.45'/>` +
`</linearGradient>` +
`</defs>` +
`<g fill='none' stroke='url(%23g)' stroke-linecap='round' stroke-linejoin='round'>` +
// Sinuous body — a thick tapering serpentine ribbon.
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
`stroke-width='26' opacity='0.5'/>` +
// Inner highlight running along the body for a calligraphic sheen.
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
`stroke-width='7' opacity='0.7'/>` +
// Head + horn flourish at the leading end.
`<path d='M30 180 C10 160 8 130 26 120 M26 120 C36 112 50 116 52 130' ` +
`stroke-width='9' opacity='0.6'/>` +
// Mane / whisker strokes flaring back from the head.
`<path d='M44 134 C70 120 96 132 110 152 M40 150 C66 148 92 160 104 180' ` +
`stroke-width='5' opacity='0.45'/>` +
// Tail wisps.
`<path d='M740 150 C754 138 758 160 748 172 M726 158 C742 168 744 186 732 196' ` +
`stroke-width='5' opacity='0.45'/>` +
`</g></svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})();
type Lantern = {
left: number;
top: number;
scale: number;
bobDuration: number;
swayDuration: number;
delay: number;
opacity: number;
};
type Petal = {
left: number;
size: number;
duration: number;
delay: number;
swayDuration: number;
opacity: number;
blur: number;
hue: number;
};
type Ember = {
left: number;
bottom: number;
size: number;
duration: number;
delay: number;
};
// A single five-petal plum blossom (gold), inline SVG so each petal sliver is
// one cheap element. Returned as a data-URI background painted on a square.
const blossomUri = ((): string => {
const petals = Array.from({ length: 5 }, (_, i) => {
const a = (i * 72 * Math.PI) / 180;
const cx = 16 + Math.cos(a - Math.PI / 2) * 8;
const cy = 16 + Math.sin(a - Math.PI / 2) * 8;
return `<circle cx='${cx.toFixed(1)}' cy='${cy.toFixed(1)}' r='5.4' />`;
}).join('');
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'>` +
`<g fill='oklch(0.86 0.13 88)' opacity='0.92'>${petals}</g>` +
`<circle cx='16' cy='16' r='3.2' fill='oklch(0.94 0.10 95)'/>` +
`</svg>`;
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
})();
export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
// Paper lanterns strung across the upper third, gently staggered in depth.
const lanterns = useMemo<Lantern[]>(() => {
const slots = [
{ left: 9, top: 7, scale: 1.0 },
{ left: 27, top: 13, scale: 0.82 },
{ left: 46, top: 6, scale: 1.12 },
{ left: 64, top: 15, scale: 0.78 },
{ left: 82, top: 9, scale: 0.95 },
{ left: 92, top: 20, scale: 0.7 },
];
return slots.map((s, i) => ({
left: s.left,
top: s.top,
scale: s.scale,
bobDuration: 7 + rand(i + 1) * 4,
swayDuration: 5.5 + rand(i + 4) * 3,
delay: -rand(i + 7) * 6,
opacity: 0.78 + rand(i + 2) * 0.18,
}));
}, []);
// Drifting gold plum-blossom petals — two parallax bands (far small/dim/slow,
// near large/bright/fast) for depth.
const petals = useMemo<Petal[]>(() => {
const bands = [
{ count: 9, size: [9, 14], dur: [15, 21], op: [0.4, 0.6], blur: 0.6 },
{ count: 8, size: [15, 24], dur: [10, 14], op: [0.6, 0.85], blur: 0 },
];
const out: Petal[] = [];
let s = 1;
bands.forEach((b) => {
for (let i = 0; i < b.count; i += 1) {
const r1 = rand(s);
const r2 = rand(s + 0.37);
const r3 = rand(s + 0.71);
const r4 = rand(s + 0.91);
out.push({
left: r1 * 100,
size: b.size[0] + r2 * (b.size[1] - b.size[0]),
duration: b.dur[0] + r3 * (b.dur[1] - b.dur[0]),
delay: -r4 * (b.dur[1] + 4),
swayDuration: 5 + r2 * 5,
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
blur: b.blur,
hue: 82 + r4 * 10,
});
s += 1;
}
});
return out;
}, []);
// A few gold embers rising from the lanterns (motion scene only).
const embers = useMemo<Ember[]>(
() =>
Array.from({ length: 7 }, (_, i) => ({
left: 8 + rand(i + 11) * 84,
bottom: 8 + rand(i + 21) * 30,
size: 1.6 + rand(i + 31) * 2.2,
duration: 9 + rand(i + 41) * 6,
delay: -rand(i + 51) * 12,
})),
[],
);
return (
<>
{/* Deep lacquer-red ambient wash — layered radial + linear oklch gradients
for depth and a warm crimson lantern-glow from above. Low-opacity so
chat text stays legible (WCAG-AA). */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundImage: [
`radial-gradient(120% 80% at 50% -8%, ${CRIMSON.replace(')', ' / 0.16)')} 0%, transparent 56%)`,
`radial-gradient(90% 70% at 50% 112%, oklch(0.42 0.17 28 / 0.1) 0%, transparent 60%)`,
`linear-gradient(180deg, oklch(0.55 0.20 28 / 0.07) 0%, transparent 26%, transparent 82%, oklch(0.40 0.16 28 / 0.08) 100%)`,
].join(','),
animation: reduced ? 'none' : `${animLacquerPulse} 13s ease-in-out infinite`,
willChange: reduced ? undefined : 'opacity',
}}
/>
{/* Imperial-gold dragon silhouette arcing across the upper scene — a
single composited SVG layer, blurred and screen-blended so it reads as
an ethereal gilt apparition, never a hard graphic. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '8%',
left: '-6%',
right: '-6%',
height: '46%',
contain: 'layout paint style',
backgroundImage: dragonUri,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundSize: 'contain',
mixBlendMode: 'screen',
filter: 'blur(1.1px)',
opacity: reduced ? 0.52 : undefined,
animation: reduced ? 'none' : `${animDragonDrift} 30s ease-in-out infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* Warm vignette frame — crimson edges, clear center, with a faint cheap
backdrop-filter for a silken haze around the rim. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backdropFilter: 'blur(0.4px) saturate(1.05)',
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
backgroundImage:
'radial-gradient(135% 120% at 50% 40%, transparent 54%, oklch(0.55 0.16 28 / 0.06) 76%, oklch(0.40 0.16 28 / 0.16) 100%)',
}}
/>
{/* The garland string the lanterns hang from — a faint warm line. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '6%',
contain: 'layout paint style',
backgroundImage: `radial-gradient(140% 80% at 50% -40%, ${GOLD.replace(
')',
' / 0.14)',
)} 0%, transparent 70%)`,
}}
/>
{/* Paper lanterns. Each is a hung group: a sway wrapper rotating about its
mount, an inner bob, then the lantern body (glow + ribs + caps) and a
trailing tassel. */}
{lanterns.map((l, i) => {
const W = 30 * l.scale;
const H = 38 * l.scale;
const cap = Math.max(8, W * 0.5);
return (
<div
key={`lantern-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${l.left}%`,
top: `${l.top}%`,
marginLeft: `${-W / 2}px`,
transformOrigin: 'top center',
opacity: l.opacity,
animation: reduced
? 'none'
: `${animLanternSway} ${l.swayDuration}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
>
<div
style={{
animation: reduced
? 'none'
: `${animLanternBob} ${l.bobDuration}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
>
{/* short cord from the string to the top cap */}
<div
style={{
width: '2px',
height: `${10 * l.scale}px`,
margin: '0 auto',
background: `linear-gradient(${GOLD}, ${GOLD.replace(')', ' / 0.3)')})`,
}}
/>
{/* top gold cap */}
<div
style={{
width: `${cap}px`,
height: `${4 * l.scale}px`,
margin: '0 auto',
borderRadius: `${2 * l.scale}px`,
background: `linear-gradient(90deg, ${GOLD.replace(
')',
' / 0.6)',
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
boxShadow: `0 0 ${5 * l.scale}px ${GOLD.replace(')', ' / 0.55)')}`,
}}
/>
{/* lantern body */}
<div
style={{
position: 'relative',
width: `${W}px`,
height: `${H}px`,
margin: `${1 * l.scale}px auto`,
borderRadius: '50% / 42%',
background: `radial-gradient(circle at 38% 32%, ${VERMILION.replace(
')',
' / 0.95)',
)} 0%, ${CRIMSON} 58%, oklch(0.40 0.18 26 / 0.95) 100%)`,
border: `${1.2 * l.scale}px solid ${GOLD.replace(')', ' / 0.8)')}`,
boxShadow: `0 0 ${16 * l.scale}px ${CRIMSON.replace(
')',
' / 0.5)',
)}, inset 0 0 ${10 * l.scale}px oklch(0.78 0.16 60 / 0.35)`,
overflow: 'hidden',
}}
>
{/* breathing inner candle glow */}
<div
style={{
position: 'absolute',
left: '50%',
top: '52%',
width: `${W * 0.6}px`,
height: `${H * 0.55}px`,
marginLeft: `${-W * 0.3}px`,
marginTop: `${-H * 0.275}px`,
borderRadius: '50%',
background: `radial-gradient(circle, ${GOLD_HI.replace(
')',
' / 0.9)',
)} 0%, oklch(0.80 0.16 65 / 0.5) 45%, transparent 75%)`,
filter: 'blur(1px)',
animation: reduced
? 'none'
: `${animGlowBreathe} ${l.bobDuration * 0.7}s ease-in-out ${
l.delay
}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* vertical paper ribs */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: `repeating-linear-gradient(90deg, transparent 0, transparent ${
W / 6 - 0.6
}px, ${GOLD.replace(')', ' / 0.18)')} ${W / 6 - 0.6}px, ${GOLD.replace(
')',
' / 0.18)',
)} ${W / 6}px)`,
}}
/>
</div>
{/* bottom gold cap */}
<div
style={{
width: `${cap}px`,
height: `${4 * l.scale}px`,
margin: '0 auto',
borderRadius: `${2 * l.scale}px`,
background: `linear-gradient(90deg, ${GOLD.replace(
')',
' / 0.6)',
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
}}
/>
{/* swaying silk tassel */}
<div
style={{
width: `${2 * l.scale}px`,
height: `${16 * l.scale}px`,
margin: '0 auto',
transformOrigin: 'top center',
background: `linear-gradient(${CRIMSON}, ${GOLD.replace(')', ' / 0.8)')})`,
borderRadius: '1px',
animation: reduced
? 'none'
: `${animTasselSway} ${l.swayDuration * 0.8}s ease-in-out ${l.delay}s infinite`,
willChange: reduced ? undefined : 'transform',
}}
/>
</div>
</div>
);
})}
{/* Drifting gold plum-blossom petals (motion only). Static settled
blossoms render below for the reduced/preview scene. */}
{!reduced &&
petals.map((p, i) => (
<div
key={`petal-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: `${p.left}%`,
width: `${p.size}px`,
height: `${p.size}px`,
animation: `${animPetalSway} ${p.swayDuration}s ease-in-out ${p.delay}s infinite`,
willChange: 'transform',
}}
>
<div
style={{
width: '100%',
height: '100%',
backgroundImage: blossomUri,
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
opacity: p.opacity,
filter: p.blur ? `blur(${p.blur}px)` : undefined,
animation: `${animPetalFall} ${p.duration}s linear ${p.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
</div>
))}
{/* Static settled blossoms for the reduced-motion / preview scene — a
serene scatter so the thumbnail still reads as a blossom drift. */}
{reduced &&
petals.slice(0, 12).map((p, i) => {
const py = rand(i + 0.5) * 92 + 4;
return (
<div
key={`petal-static-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${p.left}%`,
top: `${py}%`,
width: `${p.size}px`,
height: `${p.size}px`,
backgroundImage: blossomUri,
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
opacity: p.opacity,
transform: `rotate(${rand(i + 3) * 360}deg)`,
filter: p.blur ? `blur(${p.blur}px)` : undefined,
}}
/>
);
})}
{/* Gold embers rising off the lanterns (motion only). */}
{!reduced &&
embers.map((e, i) => (
<div
key={`ember-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${e.left}%`,
bottom: `${e.bottom}%`,
width: `${e.size}px`,
height: `${e.size}px`,
borderRadius: '50%',
background: `radial-gradient(circle, ${GOLD_HI} 0%, ${GOLD.replace(
')',
' / 0.7)',
)} 50%, transparent 80%)`,
boxShadow: `0 0 5px ${GOLD.replace(')', ' / 0.6)')}`,
animation: `${animEmberRise} ${e.duration}s ease-in ${e.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
))}
</>
);
}