Files
cinny/src/app/components/seasonal/themes/StPatricks.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

326 lines
12 KiB
TypeScript

import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animCloverTumble,
animCloverSway,
animVerdantBreathe,
animRainbowShimmer,
animCoinGlint,
animMoteTwinkle,
} from './StPatricks.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) => {
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
// Shamrock (three-leaf) and lucky four-leaf clover silhouettes as inline SVG
// data-URIs — pure CSS, no external assets, Tauri/CSP-safe. The `fill` color is
// baked per-variant in oklch-adjacent sRGB (data-URIs can't carry oklch), kept
// luminous green so the glyphs read as foliage even at low opacity.
const cloverSvg = (leaves: 3 | 4, fill: string) => {
// Each leaf is a heart-ish lobe; petals arranged radially around the stem.
const heart = 'M0,-2 C5,-12 18,-9 14,2 C12,8 4,9 0,3 C-4,9 -12,8 -14,2 C-18,-9 -5,-12 0,-2 Z';
// Rotations for the lobes; 3-leaf = 120° spread, 4-leaf = 90° spread.
const rots = leaves === 4 ? [0, 90, 180, 270] : [-90, 30, 150];
const lobes = rots
.map((r) => `<path d="${heart}" transform="rotate(${r}) translate(0 -12)"/>`)
.join('');
const stem = `<path d="M0,8 C-1,18 2,26 0,34" stroke="${
fill
}" stroke-width="2.4" fill="none" stroke-linecap="round"/>`;
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-26 -26 52 64">` +
`<g fill="${fill}">${lobes}</g>${stem}</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
};
// Three foliage greens for parallax depth — far/dim through near/bright. These
// are the sRGB siblings of the brief's oklch emerald / shamrock-green targets.
const CLOVER_FILLS = [
'#1f9e54', // deep shamrock (far)
'#2db866', // emerald (mid)
'#48d97f', // bright clover (near)
];
type Clover = {
left: number;
size: number;
duration: number;
delay: number;
swayDuration: number;
opacity: number;
blur: number;
fill: string;
leaves: 3 | 4;
// Resting position + tilt for the static (reduced) settled scene.
restTop: number;
restRot: number;
};
type Coin = {
left: number;
top: number;
size: number;
duration: number;
delay: number;
};
type Mote = {
left: number;
top: number;
size: number;
duration: number;
delay: number;
};
export function StPatricksOverlay({ reduced }: SeasonalOverlayProps) {
// Three parallax bands of clovers: far (small/slow/dim) -> near (large/fast).
// ~22 clovers total; one lucky four-leaf seeded in for charm.
const clovers = useMemo<Clover[]>(() => {
const bands = [
{ count: 8, size: [12, 18], dur: [20, 26], op: [0.22, 0.34], blur: 0.8, fill: 0 },
{ count: 8, size: [18, 26], dur: [15, 20], op: [0.34, 0.5], blur: 0.4, fill: 1 },
{ count: 6, size: [26, 38], dur: [11, 15], op: [0.46, 0.62], blur: 0, fill: 2 },
];
const out: Clover[] = [];
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);
const r5 = rand(s + 1.13);
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] + 5),
swayDuration: 5 + r2 * 6,
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
blur: b.blur,
// The single lucky four-leaf: one mid-band clover.
leaves: s === 10 ? 4 : 3,
fill: CLOVER_FILLS[b.fill],
restTop: 6 + r5 * 88,
restRot: (r4 - 0.5) * 80,
});
s += 1;
}
});
return out;
}, []);
// Gold-coin glints scattered low — a faint pot-of-gold sparkle. ~5 discs.
const coins = useMemo<Coin[]>(() => {
const count = 5;
const out: Coin[] = [];
for (let i = 0; i < count; i += 1) {
out.push({
left: 8 + rand(i + 40) * 84,
top: 58 + rand(i + 47) * 36,
size: 8 + rand(i + 51) * 9,
duration: 4 + rand(i + 55) * 3,
delay: -rand(i + 61) * 6,
});
}
return out;
}, []);
// Golden sparkle motes drifting through the scene. ~7 points.
const motes = useMemo<Mote[]>(() => {
const count = 7;
const out: Mote[] = [];
for (let i = 0; i < count; i += 1) {
out.push({
left: rand(i + 70) * 100,
top: 8 + rand(i + 77) * 82,
size: 2 + rand(i + 83) * 3,
duration: 3 + rand(i + 89) * 3.5,
delay: -rand(i + 97) * 6,
});
}
return out;
}, []);
return (
<>
{/* Emerald ambient wash — layered radial + linear oklch gradients for
depth. Kept 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% 85% at 50% -12%, oklch(0.60 0.16 150 / 0.16) 0%, transparent 56%)',
'radial-gradient(90% 65% at 12% 112%, oklch(0.55 0.15 145 / 0.12) 0%, transparent 60%)',
'radial-gradient(80% 60% at 92% 108%, oklch(0.82 0.14 90 / 0.07) 0%, transparent 62%)',
'linear-gradient(180deg, oklch(0.62 0.15 150 / 0.05) 0%, transparent 24%, transparent 82%, oklch(0.5 0.14 148 / 0.08) 100%)',
].join(','),
}}
/>
{/* Verdant vignette frame — green edges, clear center. A single cheap
backdrop-filter adds a faint warm-emerald 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(140% 125% at 50% 44%, transparent 50%, oklch(0.6 0.13 150 / 0.07) 74%, oklch(0.48 0.14 148 / 0.17) 100%)',
animation: reduced ? 'none' : `${animVerdantBreathe} 13s ease-in-out infinite`,
willChange: reduced ? undefined : 'opacity',
}}
/>
{/* Soft rainbow shimmer arc tucked into the top-right corner — a faint
luck-of-the-Irish band. Heavily blurred + screen-blended so it reads
as light, never as a hard stripe over chat. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '-22%',
right: '-18%',
width: '62%',
height: '62%',
contain: 'layout paint style',
mixBlendMode: 'screen',
filter: 'blur(30px)',
opacity: reduced ? 0.6 : undefined,
// Concentric arc bands — red through violet, all low alpha.
backgroundImage: [
'radial-gradient(closest-side at 78% 28%, transparent 58%, oklch(0.7 0.18 28 / 0.16) 62%, transparent 67%)',
'radial-gradient(closest-side at 78% 28%, transparent 63%, oklch(0.82 0.16 80 / 0.16) 67%, transparent 72%)',
'radial-gradient(closest-side at 78% 28%, transparent 68%, oklch(0.85 0.17 130 / 0.16) 72%, transparent 77%)',
'radial-gradient(closest-side at 78% 28%, transparent 73%, oklch(0.72 0.15 230 / 0.15) 77%, transparent 82%)',
'radial-gradient(closest-side at 78% 28%, transparent 78%, oklch(0.6 0.16 300 / 0.13) 82%, transparent 87%)',
].join(','),
animation: reduced ? 'none' : `${animRainbowShimmer} 20s ease-in-out infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
{/* Gold-coin glints — small metallic discs that catch the light. */}
{coins.map((c, i) => (
<div
key={`coin-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${c.left}%`,
top: `${c.top}%`,
width: `${c.size}px`,
height: `${c.size}px`,
borderRadius: '50%',
background:
'radial-gradient(circle at 36% 32%, oklch(0.97 0.06 95 / 0.95) 0%, oklch(0.82 0.14 90 / 0.85) 45%, oklch(0.68 0.13 78 / 0.4) 78%, transparent 100%)',
boxShadow: `0 0 ${c.size * 0.9}px ${c.size * 0.35}px oklch(0.82 0.14 90 / 0.4)`,
opacity: reduced ? 0.85 : undefined,
animation: reduced
? 'none'
: `${animCoinGlint} ${c.duration}s ease-in-out ${c.delay}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
))}
{/* Golden sparkle motes — tiny four-point glints of luck. */}
{motes.map((m, i) => (
<div
key={`mote-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${m.left}%`,
top: `${m.top}%`,
width: `${m.size}px`,
height: `${m.size}px`,
borderRadius: '50%',
background:
'radial-gradient(circle, oklch(0.98 0.05 95 / 0.95) 0%, oklch(0.85 0.13 88 / 0.6) 50%, transparent 100%)',
boxShadow: '0 0 6px oklch(0.85 0.13 88 / 0.6)',
opacity: reduced ? 0.9 : undefined,
animation: reduced
? 'none'
: `${animMoteTwinkle} ${m.duration}s ease-in-out ${m.delay}s infinite`,
willChange: reduced ? undefined : 'transform, opacity',
}}
/>
))}
{/* Drifting clovers (motion only) — three parallax bands tumbling down.
Settled static scatter is rendered below for reduced/preview. */}
{!reduced &&
clovers.map((c, i) => (
<div
key={`clover-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: `${c.left}%`,
width: `${c.size}px`,
height: `${c.size * 1.2}px`,
animation: `${animCloverSway} ${c.swayDuration}s ease-in-out ${c.delay}s infinite`,
willChange: 'transform',
}}
>
<div
style={{
width: '100%',
height: '100%',
backgroundImage: cloverSvg(c.leaves, c.fill),
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
backgroundPosition: 'center',
opacity: c.opacity,
filter: `drop-shadow(0 0 3px oklch(0.55 0.15 145 / 0.4))${
c.blur ? ` blur(${c.blur}px)` : ''
}`,
animation: `${animCloverTumble} ${c.duration}s linear ${c.delay}s infinite`,
willChange: 'transform, opacity',
}}
/>
</div>
))}
{/* Static settled clovers for the reduced-motion / preview scene — a
gentle scatter resting at varied tilts so the thumbnail reads as a
lucky, still field of shamrocks. */}
{reduced &&
clovers.map((c, i) => (
<div
key={`clover-static-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${c.left}%`,
top: `${c.restTop}%`,
width: `${c.size}px`,
height: `${c.size * 1.2}px`,
backgroundImage: cloverSvg(c.leaves, c.fill),
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
backgroundPosition: 'center',
transform: `rotate(${c.restRot}deg)`,
opacity: c.opacity,
filter: `drop-shadow(0 0 3px oklch(0.55 0.15 145 / 0.4))${
c.blur ? ` blur(${c.blur}px)` : ''
}`,
}}
/>
))}
</>
);
}