Files
cinny/src/app/components/seasonal/SeasonalEffect.tsx
T
Lotus CIandClaude Opus 5.5 14731f2229
CI / Build & Quality Checks (pull_request) Successful in 1m36s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Canceled after 31s
feat(seasonal): Halloween redesign — webs, a spider, bats, a ghost; no tint (#239)
Draft for human review (#239 must be approved and closed by a human).

- Removed the full-screen purple wash, fog and the blurred green "moon" that
  sat over the member list and made it unreadable.
- Corner cobwebs (generated radial threads + sagging rings), thin and
  readable in both themes, smaller on phones.
- A spider on a thread in the empty space under the member list (near the
  top on phones), bobbing and swaying.
- Every 30 s a group of four bats flaps across the top of the screen (a real
  bat silhouette; wing beat without flattening them into dashes).
- Every 50 s a small, see-through ghost rises and fades (outlined so it
  shows on light themes; text stays readable through it).
- Theme-aware ink via --hw-ink/--hw-web: dark silhouettes on light themes,
  pale dusk-violet on dark ones.
- Reduced motion: webs and the spider at rest. Settings swatch: night sky,
  crescent moon with a bat across it, a corner web.

Rendered and inspected: light/dark/phone at the bat and ghost moments,
reduced on desktop and phone, the Settings swatch; 60 fps on vs off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 14:50:35 -04:00

166 lines
6.4 KiB
TypeScript

import React, { Suspense, useEffect, useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { zIndices } from '../../styles/zIndex';
import { SeasonTheme, SeasonalOverlayProps } from './types';
import { resolveSeasonTheme } from './seasonSchedule';
// [Gitea #167] Each overlay (its particles + keyframes) is its own chunk,
// fetched only when that season is active or previewed in Settings — the
// date/override decision stays in the main bundle.
const lazyOverlay = (load: () => Promise<{ default: React.ComponentType<SeasonalOverlayProps> }>) =>
React.lazy(load);
const HalloweenOverlay = lazyOverlay(() =>
import('./themes/Halloween').then((m) => ({ default: m.HalloweenOverlay })),
);
const ChristmasOverlay = lazyOverlay(() =>
import('./themes/Christmas').then((m) => ({ default: m.ChristmasOverlay })),
);
const NewYearOverlay = lazyOverlay(() =>
import('./themes/NewYear').then((m) => ({ default: m.NewYearOverlay })),
);
const AutumnOverlay = lazyOverlay(() =>
import('./themes/Autumn').then((m) => ({ default: m.AutumnOverlay })),
);
const AprilFoolsOverlay = lazyOverlay(() =>
import('./themes/AprilFools').then((m) => ({ default: m.AprilFoolsOverlay })),
);
const LunarNewYearOverlay = lazyOverlay(() =>
import('./themes/LunarNewYear').then((m) => ({ default: m.LunarNewYearOverlay })),
);
const ValentinesOverlay = lazyOverlay(() =>
import('./themes/Valentines').then((m) => ({ default: m.ValentinesOverlay })),
);
const StPatricksOverlay = lazyOverlay(() =>
import('./themes/StPatricks').then((m) => ({ default: m.StPatricksOverlay })),
);
const EarthDayOverlay = lazyOverlay(() =>
import('./themes/EarthDay').then((m) => ({ default: m.EarthDayOverlay })),
);
const DeepSpaceOverlay = lazyOverlay(() =>
import('./themes/DeepSpace').then((m) => ({ default: m.DeepSpaceOverlay })),
);
const ArcadeOverlay = lazyOverlay(() =>
import('./themes/Arcade').then((m) => ({ default: m.ArcadeOverlay })),
);
// 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,
preview = false,
): React.ReactNode {
switch (theme) {
case 'halloween':
return <HalloweenOverlay reduced={reduced} preview={preview} />;
case 'christmas':
return <ChristmasOverlay reduced={reduced} />;
case 'newyear':
return <NewYearOverlay reduced={reduced} />;
case 'autumn':
return <AutumnOverlay reduced={reduced} preview={preview} />;
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',
}}
>
<Suspense fallback={null}>{buildOverlayContent(theme, reduced)}</Suspense>
</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',
// Size container so overlays can scale/hide fixed-size details (e.g.
// Arcade's HUD text) with `cqw` instead of rendering clipped in a swatch.
containerType: 'inline-size',
}}
>
<Suspense fallback={null}>{buildOverlayContent(theme, true, true)}</Suspense>
</div>
);
}
// ─── Main exported component ──────────────────────────────────────────────────
export function SeasonalEffect() {
const settings = useAtomValue(settingsAtom);
const reduced = useReducedMotion();
const override = settings.seasonalThemeOverride ?? 'auto';
// In auto mode, re-evaluate hourly so a long-lived session crosses a
// season/holiday-window boundary (e.g. into a new day) without a reload —
// otherwise the active season is frozen at the value it had on mount.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (override !== 'auto') return undefined;
// Refresh on entering auto too: `now` may be a stale mount-time value if we
// were previously in a pinned/off mode (the interval only runs while auto).
setNow(Date.now());
const id = window.setInterval(() => setNow(Date.now()), 60 * 60 * 1000);
return () => window.clearInterval(id);
}, [override]);
const theme = useMemo<SeasonTheme | null>(
() => resolveSeasonTheme(override, now),
[override, now],
);
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} />;
}