Files
cinny/src/app/components/seasonal/SeasonalEffect.tsx
T
jaredandClaude Fable 5.1 4bea48959e
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Trigger Desktop Build (push) Successful in 7s
fix(seasonal): keep Arcade + Deep Space overlays out of the chat text
Arcade:
- perspective grid is masked through the central timeline column so it
  frames the chat instead of striping messages; lower opacity, thinner
  non-scaling strokes (also fixes the grid vanishing in the 76px swatch)
- neon horizon line is broken through the middle so it never underlines
  a message; softer sun glow
- "1UP" score + "INSERT COIN" moved from top-left/bottom-centre (space
  bar / composer) to a single bottom-right HUD stack; font-size clamps
  to 0 via cqw inside the settings swatch so no more clipped glyphs
- scanlines: 4px pitch at lower opacity (light theme was fully striped);
  ambient wash slightly lighter

Deep Space:
- starfield 28 → 62 stars across the two parallax layers
- base violet wash slightly lighter for light themes

SeasonalPreview wrapper is now a size container (container-type) so
overlays can scale/hide fixed-px details with cqw. LOTUS_FEATURES.md
theme table now describes what both overlays actually render.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 01:29:33 -04:00

134 lines
5.1 KiB
TypeScript

import React, { 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 } from './types';
import { resolveSeasonTheme } 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',
// 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',
}}
>
{buildOverlayContent(theme, true)}
</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} />;
}