26f998d243
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>
114 lines
4.4 KiB
TypeScript
114 lines
4.4 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { useAtomValue } from 'jotai';
|
|
import { settingsAtom } from '../../state/settings';
|
|
import { zIndices } from '../../styles/zIndex';
|
|
import { SeasonTheme } from './types';
|
|
import { getActiveSeason } from './seasonSchedule';
|
|
import { HalloweenOverlay } from './themes/Halloween';
|
|
import { ChristmasOverlay } from './themes/Christmas';
|
|
import { NewYearOverlay } from './themes/NewYear';
|
|
import { AutumnOverlay } from './themes/Autumn';
|
|
import { AprilFoolsOverlay } from './themes/AprilFools';
|
|
import { LunarNewYearOverlay } from './themes/LunarNewYear';
|
|
import { ValentinesOverlay } from './themes/Valentines';
|
|
import { StPatricksOverlay } from './themes/StPatricks';
|
|
import { EarthDayOverlay } from './themes/EarthDay';
|
|
import { DeepSpaceOverlay } from './themes/DeepSpace';
|
|
import { ArcadeOverlay } from './themes/Arcade';
|
|
|
|
// SeasonTheme + the date-window logic now live in leaf modules (single source
|
|
// of truth, shared with the settings UI). Re-exported here for existing
|
|
// importers that still reach for it from this file.
|
|
export type { SeasonTheme };
|
|
|
|
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
|
|
|
|
function buildOverlayContent(theme: SeasonTheme, reduced: boolean): React.ReactNode {
|
|
switch (theme) {
|
|
case 'halloween':
|
|
return <HalloweenOverlay reduced={reduced} />;
|
|
case 'christmas':
|
|
return <ChristmasOverlay reduced={reduced} />;
|
|
case 'newyear':
|
|
return <NewYearOverlay reduced={reduced} />;
|
|
case 'autumn':
|
|
return <AutumnOverlay reduced={reduced} />;
|
|
case 'aprilfools':
|
|
return <AprilFoolsOverlay reduced={reduced} />;
|
|
case 'lunar':
|
|
return <LunarNewYearOverlay reduced={reduced} />;
|
|
case 'valentines':
|
|
return <ValentinesOverlay reduced={reduced} />;
|
|
case 'stpatricks':
|
|
return <StPatricksOverlay reduced={reduced} />;
|
|
case 'earthday':
|
|
return <EarthDayOverlay reduced={reduced} />;
|
|
case 'deepspace':
|
|
return <DeepSpaceOverlay reduced={reduced} />;
|
|
case 'arcade':
|
|
return <ArcadeOverlay reduced={reduced} />;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Full-screen overlay (fixed position, used in App) ────────────────────────
|
|
|
|
function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: boolean }) {
|
|
return (
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
pointerEvents: 'none',
|
|
// Below the Night Light overlay (9998) so seasonal particles are tinted
|
|
// by it, and below modals (9999) so dialogs are never obscured.
|
|
zIndex: zIndices.seasonalEffect,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{buildOverlayContent(theme, reduced)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Preview overlay (absolute position, contained in a card) ─────────────────
|
|
|
|
/**
|
|
* Renders the ambient (reduced-motion) version of a seasonal overlay inside
|
|
* a parent container. The parent must have `position: relative; overflow: hidden`.
|
|
*/
|
|
export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
|
return (
|
|
<div
|
|
aria-hidden="true"
|
|
style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}
|
|
>
|
|
{buildOverlayContent(theme, true)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main exported component ──────────────────────────────────────────────────
|
|
|
|
export function SeasonalEffect() {
|
|
const settings = useAtomValue(settingsAtom);
|
|
const reduced =
|
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
const theme = useMemo<SeasonTheme | null>(() => {
|
|
const override = settings.seasonalThemeOverride ?? 'auto';
|
|
if (override === 'off') return null;
|
|
if (override === 'auto') return getActiveSeason(new Date());
|
|
return override as SeasonTheme;
|
|
}, [settings.seasonalThemeOverride]);
|
|
|
|
if (!theme) return null;
|
|
// Suppress seasonal overlay when a chat background is active — both running simultaneously
|
|
// wastes GPU and looks cluttered. The settings UI enforces mutual exclusion on write;
|
|
// this guard covers any legacy state already persisted.
|
|
if (settings.chatBackground !== 'none') return null;
|
|
return <SeasonalOverlay theme={theme} reduced={reduced} />;
|
|
}
|