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

304 lines
10 KiB
TypeScript

import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animBurst,
animCoreFlash,
animShimmer,
animConfettiFall,
animConfettiSway,
animTwinkle,
animSkyPulse,
} from './NewYear.css';
/**
* New Year overlay — a midnight celebration. Layered oklch gradients sink the
* app into a deep navy night; fireworks bloom as expanding spark rings, a
* champagne-gold shimmer sweeps across, confetti slivers tumble down, and
* sparkle stars twinkle. All motion is transform/opacity only.
*
* Palette (oklch): midnight navy oklch(0.20 0.07 260), champagne gold
* oklch(0.85 0.13 90), bursts in magenta oklch(0.7 0.22 350), cyan
* oklch(0.8 0.15 200), and gold.
*
* RENDERING CONTRACT: the parent supplies a fixed inset:0 overflow:hidden
* pointer-events:none container at the right z-index. We only return
* absolutely-positioned aria-hidden children at low opacity — no z-index,
* position:fixed, or pointer-events here — kept well below opaque so chat text
* stays WCAG-AA legible.
*
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (a frozen
* firework bloom mid-burst, scattered gold confetti, a still shimmer band) with
* no `animation` at all. The settings preview always passes reduced=true.
*/
const BURST_HUES = [
// [ring oklch, core oklch]
['oklch(0.7 0.22 350)', 'oklch(0.88 0.14 350)'], // magenta
['oklch(0.8 0.15 200)', 'oklch(0.92 0.1 200)'], // cyan
['oklch(0.85 0.13 90)', 'oklch(0.95 0.09 95)'], // gold
['oklch(0.75 0.2 30)', 'oklch(0.9 0.12 40)'], // warm coral
] as const;
const CONFETTI_COLORS = [
'oklch(0.85 0.13 90)', // champagne gold
'oklch(0.7 0.22 350)', // magenta
'oklch(0.8 0.15 200)', // cyan
'oklch(0.9 0.06 90)', // pale gold
'oklch(0.78 0.18 30)', // coral
] as const;
type Burst = {
top: number;
left: number;
size: number;
ring: string;
core: string;
duration: number;
delay: number;
};
type Confetto = {
left: number;
w: number;
h: number;
color: string;
round: boolean;
fallDur: number;
swayDur: number;
delay: number;
};
type Star = {
top: number;
left: number;
size: number;
color: string;
duration: number;
delay: number;
};
// Deterministic pseudo-random so the memoized scene is stable across renders.
const rand = (seed: number) => {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
};
// A four-point sparkle (gleam) as an inline SVG data-URI — CSP-safe, no assets.
const sparkleUri = (color: string) =>
`url("data:image/svg+xml,${encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 0 L14 10 L24 12 L14 14 L12 24 L10 14 L0 12 L10 10 Z' fill='${color}'/></svg>`,
)}")`;
export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
const bursts = useMemo<Burst[]>(
() =>
// Bursts cluster in the upper two-thirds of the sky, away from typical text.
Array.from({ length: 7 }, (_, i) => {
const hue = BURST_HUES[i % BURST_HUES.length];
return {
top: 8 + rand(i + 1) * 48,
left: 8 + rand(i + 11) * 84,
size: 130 + Math.floor(rand(i + 21) * 110),
ring: hue[0],
core: hue[1],
duration: 6.5 + rand(i + 31) * 4,
delay: rand(i + 41) * 9,
};
}),
[],
);
const confetti = useMemo<Confetto[]>(
() =>
Array.from({ length: 20 }, (_, i) => ({
left: rand(i + 101) * 100,
w: 4 + Math.floor(rand(i + 111) * 4),
h: 7 + Math.floor(rand(i + 121) * 7),
color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
round: i % 4 === 0,
fallDur: 9 + rand(i + 131) * 7,
swayDur: 3 + rand(i + 141) * 3,
delay: rand(i + 151) * 10,
})),
[],
);
const stars = useMemo<Star[]>(
() =>
Array.from({ length: 9 }, (_, i) => ({
top: 4 + rand(i + 201) * 64,
left: 4 + rand(i + 211) * 92,
size: 8 + Math.floor(rand(i + 221) * 10),
color: i % 2 === 0 ? 'oklch(0.85 0.13 90)' : 'oklch(0.92 0.06 200)',
duration: 3 + rand(i + 231) * 3,
delay: rand(i + 241) * 4,
})),
[],
);
return (
<>
{/* Midnight sky — layered oklch gradients for depth, with a faint breathe. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
contain: 'layout paint style',
backgroundColor: 'oklch(0.2 0.07 260 / 0.12)',
backgroundImage: [
'radial-gradient(120% 90% at 50% -10%, oklch(0.32 0.1 280 / 0.16) 0%, transparent 60%)',
'radial-gradient(90% 70% at 18% 8%, oklch(0.7 0.22 350 / 0.07) 0%, transparent 55%)',
'radial-gradient(90% 70% at 84% 4%, oklch(0.8 0.15 200 / 0.07) 0%, transparent 55%)',
'radial-gradient(140% 120% at 50% 120%, oklch(0.2 0.07 260 / 0.14) 0%, transparent 70%)',
].join(','),
animation: reduced ? 'none' : `${animSkyPulse} 9s ease-in-out infinite`,
}}
/>
{/* Champagne-gold shimmer sweep. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: '55%',
contain: 'layout paint style',
backgroundImage:
'linear-gradient(100deg, transparent 0%, oklch(0.85 0.13 90 / 0.05) 38%, oklch(0.95 0.09 95 / 0.1) 50%, oklch(0.85 0.13 90 / 0.05) 62%, transparent 100%)',
transform: reduced ? 'translate3d(30%, 0, 0) skewX(-12deg)' : undefined,
opacity: reduced ? 0.45 : undefined,
animation: reduced ? 'none' : `${animShimmer} 11s ease-in-out infinite`,
}}
/>
{/* Fireworks — expanding spark rings + a core flash. In reduced mode we
freeze the first burst mid-bloom and drop the rest. */}
{(reduced ? bursts.slice(0, 1) : bursts).map((b, i) => (
<div
key={`b${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: `${b.top}%`,
left: `${b.left}%`,
width: `${b.size}px`,
height: `${b.size}px`,
marginLeft: `${-b.size / 2}px`,
marginTop: `${-b.size / 2}px`,
}}
>
{/* Spark ring */}
<div
style={{
position: 'absolute',
inset: 0,
borderRadius: '50%',
willChange: reduced ? undefined : 'transform, opacity',
background: `radial-gradient(circle, transparent 56%, ${b.ring} 64%, transparent 74%)`,
transform: reduced ? 'scale(0.82)' : undefined,
opacity: reduced ? 0.6 : undefined,
animation: reduced
? 'none'
: `${animBurst} ${b.duration}s ease-out ${b.delay}s infinite`,
}}
/>
{/* Inner secondary ring for a fuller bloom */}
<div
style={{
position: 'absolute',
inset: '18%',
borderRadius: '50%',
background: `radial-gradient(circle, transparent 50%, ${b.ring} 60%, transparent 72%)`,
transform: reduced ? 'scale(0.7)' : undefined,
opacity: reduced ? 0.4 : undefined,
animation: reduced
? 'none'
: `${animBurst} ${b.duration}s ease-out ${b.delay + 0.12}s infinite`,
}}
/>
{/* Core flash */}
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
width: '14%',
height: '14%',
marginLeft: '-7%',
marginTop: '-7%',
borderRadius: '50%',
background: `radial-gradient(circle, ${b.core} 0%, transparent 70%)`,
transform: reduced ? 'scale(0.9)' : undefined,
opacity: reduced ? 0.85 : undefined,
animation: reduced
? 'none'
: `${animCoreFlash} ${b.duration}s ease-out ${b.delay}s infinite`,
}}
/>
</div>
))}
{/* Twinkling sparkle stars. */}
{stars.map((s, i) => (
<div
key={`s${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: `${s.top}%`,
left: `${s.left}%`,
width: `${s.size}px`,
height: `${s.size}px`,
backgroundImage: sparkleUri(s.color),
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
transform: reduced ? 'scale(0.9) rotate(30deg)' : undefined,
opacity: reduced ? 0.75 : undefined,
animation: reduced
? 'none'
: `${animTwinkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
}}
/>
))}
{/* Falling confetti slivers. In reduced mode, a still scatter at varied
heights so the static thumbnail reads as a celebration in progress. */}
{confetti.map((c, i) => {
const staticTop = reduced ? 6 + rand(i + 301) * 78 : undefined;
return (
<div
key={`c${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: reduced ? `${staticTop}%` : 0,
left: `${c.left}%`,
willChange: reduced ? undefined : 'transform',
animation: reduced
? 'none'
: `${animConfettiSway} ${c.swayDur}s ease-in-out ${c.delay}s infinite`,
}}
>
<div
style={{
width: `${c.w}px`,
height: reduced && c.round ? `${c.w}px` : `${c.h}px`,
borderRadius: c.round ? '50%' : '1px',
backgroundColor: c.color,
opacity: reduced ? 0.8 : 0.85,
transform: reduced ? `rotate(${Math.floor(rand(i + 311) * 360)}deg)` : undefined,
animation: reduced
? 'none'
: `${animConfettiFall} ${c.fallDur}s ease-in ${c.delay}s infinite`,
}}
/>
</div>
);
})}
</>
);
}