diff --git a/src/app/components/seasonal/Seasonal.css.ts b/src/app/components/seasonal/Seasonal.css.ts
deleted file mode 100644
index bf897133e..000000000
--- a/src/app/components/seasonal/Seasonal.css.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-import { keyframes } from '@vanilla-extract/css';
-
-/** Generic fall: particles drop from top to bottom with a slight rotate. */
-export const animSeasonFall = keyframes({
- '0%': { transform: 'translateY(-20px) translateX(0) rotate(0deg)', opacity: '0' },
- '5%': { opacity: '1' },
- '90%': { opacity: '0.8' },
- '100%': { transform: 'translateY(110vh) translateX(25px) rotate(360deg)', opacity: '0' },
-});
-
-/** Leaf fall: exaggerated horizontal sway as the leaf tumbles down. */
-export const animLeafFall = keyframes({
- '0%': { transform: 'translateY(-20px) translateX(0) rotate(-20deg)', opacity: '0' },
- '8%': { opacity: '0.85' },
- '25%': { transform: 'translateY(25vh) translateX(35px) rotate(40deg)' },
- '50%': { transform: 'translateY(50vh) translateX(-25px) rotate(130deg)' },
- '75%': { transform: 'translateY(75vh) translateX(45px) rotate(260deg)' },
- '92%': { opacity: '0.6' },
- '100%': { transform: 'translateY(110vh) translateX(5px) rotate(380deg)', opacity: '0' },
-});
-
-/** Float up: hearts / embers rise from the bottom. */
-export const animFloatUp = keyframes({
- '0%': { transform: 'translateY(0) scale(0.6) translateX(0)', opacity: '0' },
- '8%': { opacity: '0.9' },
- '50%': { transform: 'translateY(-50vh) scale(1) translateX(15px)' },
- '85%': { opacity: '0.4' },
- '100%': { transform: 'translateY(-105vh) scale(1.3) translateX(-10px)', opacity: '0' },
-});
-
-/** Bob: lanterns gently rise and fall with a slight tilt. */
-export const animBob = keyframes({
- '0%': { transform: 'translateY(0px) rotate(-4deg)' },
- '50%': { transform: 'translateY(-18px) rotate(4deg)' },
- '100%': { transform: 'translateY(0px) rotate(-4deg)' },
-});
-
-/** Lantern tassel sway (used on the tassel element only). */
-export const animTasselSway = keyframes({
- '0%': { transform: 'rotate(-8deg)' },
- '50%': { transform: 'rotate(8deg)' },
- '100%': { transform: 'rotate(-8deg)' },
-});
-
-/** Glitch jitter: rapid position jumps that feel like a signal error. */
-export const animGlitch = keyframes({
- '0%': { transform: 'translate(0, 0)' },
- '2%': { transform: 'translate(-4px, 2px)' },
- '4%': { transform: 'translate(4px, -2px)' },
- '6%': { transform: 'translate(0, 0)' },
- '48%': { transform: 'translate(0, 0)' },
- '50%': { transform: 'translate(3px, -3px)' },
- '52%': { transform: 'translate(-3px, 3px)' },
- '54%': { transform: 'translate(0, 0)' },
- '78%': { transform: 'translate(0, 0)' },
- '80%': { transform: 'translate(-5px, 1px)' },
- '82%': { transform: 'translate(0, 0)' },
- '100%': { transform: 'translate(0, 0)' },
-});
-
-/** Glitch color: hue + saturation spikes that look like a corrupted signal. */
-export const animGlitchColor = keyframes({
- '0%': { filter: 'hue-rotate(0deg) saturate(1)' },
- '8%': { filter: 'hue-rotate(180deg) saturate(3)' },
- '9%': { filter: 'hue-rotate(0deg) saturate(1)' },
- '55%': { filter: 'hue-rotate(0deg) saturate(1)' },
- '57%': { filter: 'hue-rotate(90deg) saturate(2)' },
- '58%': { filter: 'hue-rotate(0deg) saturate(1)' },
- '80%': { filter: 'hue-rotate(0deg) saturate(1)' },
- '82%': { filter: 'hue-rotate(270deg) saturate(2.5)' },
- '83%': { filter: 'hue-rotate(0deg) saturate(1)' },
- '100%': { filter: 'hue-rotate(0deg) saturate(1)' },
-});
-
-/** Glitch scanline: a horizontal band sweeps across, flickering. */
-export const animGlitchScan = keyframes({
- '0%': { transform: 'translateY(-100%)' },
- '100%': { transform: 'translateY(100vh)' },
-});
-
-/** Burst: circle expands outward from a point and fades — firework petal. */
-export const animBurst = keyframes({
- '0%': { transform: 'scale(0) rotate(0deg)', opacity: '1' },
- '50%': { opacity: '0.7' },
- '100%': { transform: 'scale(1) rotate(45deg)', opacity: '0' },
-});
-
-/** Firework trail: a small dot rockets upward before bursting. */
-export const animRocket = keyframes({
- '0%': { transform: 'translateY(0)', opacity: '1' },
- '100%': { transform: 'translateY(-40vh)', opacity: '0' },
-});
-
-/** Deep space warp: stars streak from center outward. */
-export const animWarp = keyframes({
- '0%': { transform: 'scale(0.05) translate(0, 0)', opacity: '0' },
- '10%': { opacity: '1' },
- '100%': { transform: 'scale(4) translate(0, 0)', opacity: '0' },
-});
-
-/** Arcade scanline flicker. */
-export const animScanline = keyframes({
- '0%': { opacity: '0.12' },
- '50%': { opacity: '0.04' },
- '100%': { opacity: '0.12' },
-});
-
-/** Arcade pixel blink: decorative corner glyphs blink. */
-export const animPixelBlink = keyframes({
- '0%, 49%': { opacity: '1' },
- '50%, 100%': { opacity: '0' },
-});
-
-/** Gold shimmer: a shine sweeps across a metallic surface. */
-export const animGoldShimmer = keyframes({
- '0%': { backgroundPosition: '-300% 0' },
- '100%': { backgroundPosition: '300% 0' },
-});
-
-/** Clover drift: gentle fall with a slow spin. */
-export const animCloverDrift = keyframes({
- '0%': { transform: 'translateY(-20px) rotate(0deg)', opacity: '0' },
- '5%': { opacity: '0.7' },
- '90%': { opacity: '0.5' },
- '100%': { transform: 'translateY(110vh) rotate(720deg)', opacity: '0' },
-});
-
-/** Earth Day leaf sway: gentle horizontal oscillation for ambient leaf particles. */
-export const animEarthLeafDrift = keyframes({
- '0%': { transform: 'translateY(-10px) translateX(0) rotate(0deg)', opacity: '0' },
- '8%': { opacity: '0.6' },
- '30%': { transform: 'translateY(30vh) translateX(20px) rotate(90deg)' },
- '60%': { transform: 'translateY(60vh) translateX(-15px) rotate(200deg)' },
- '90%': { opacity: '0.4' },
- '100%': { transform: 'translateY(110vh) translateX(10px) rotate(340deg)', opacity: '0' },
-});
diff --git a/src/app/components/seasonal/SeasonalEffect.tsx b/src/app/components/seasonal/SeasonalEffect.tsx
index 7a968c870..a49c76278 100644
--- a/src/app/components/seasonal/SeasonalEffect.tsx
+++ b/src/app/components/seasonal/SeasonalEffect.tsx
@@ -4,682 +4,23 @@ import { settingsAtom } from '../../state/settings';
import { zIndices } from '../../styles/zIndex';
import { SeasonTheme } from './types';
import { getActiveSeason } from './seasonSchedule';
-import {
- animSeasonFall,
- animLeafFall,
- animFloatUp,
- animBob,
- animTasselSway,
- animGoldShimmer,
- animCloverDrift,
- animEarthLeafDrift,
- animWarp,
- animScanline,
- animPixelBlink,
-} from './Seasonal.css';
+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 };
-// ─── Individual theme overlays ────────────────────────────────────────────────
-
-function HalloweenOverlay({ reduced }: { reduced: boolean }) {
- const particles = Array.from({ length: 22 });
- return (
- <>
- {/* Dark purple ambient tint */}
-
")`,
- backgroundRepeat: 'no-repeat',
- opacity: 0.7,
- }}
- />
- {/* Falling purple/orange particles */}
- {!reduced &&
- particles.map((_, i) => {
- const isOrange = i % 3 === 0;
- const size = 4 + (i % 3) * 2;
- const left = (i * 4597 + 137) % 100;
- const duration = 8 + (i % 7) * 1.5;
- const delay = (i * 0.45) % 7;
- return (
-
- );
- })}
- >
- );
-}
-
-function ChristmasOverlay({ reduced }: { reduced: boolean }) {
- const flakes = Array.from({ length: 28 });
- return (
- <>
-
- {!reduced &&
- flakes.map((_, i) => {
- const size = 3 + (i % 4) * 2;
- const left = (i * 3571 + 251) % 100;
- const duration = 10 + (i % 8) * 2;
- const delay = (i * 0.55) % 10;
- const drift = ((i % 5) - 2) * 12;
- return (
-
- );
- })}
- >
- );
-}
-
-// Replaced flashing burst rays with gentle falling confetti
-function NewYearOverlay({ reduced }: { reduced: boolean }) {
- const confetti = Array.from({ length: 24 });
- const colors = ['#ffd700', '#ff4466', '#00d4ff', '#aa44ff', '#ff8800', '#ffffff'];
-
- return (
- <>
-
- {/* Gentle falling confetti */}
- {!reduced &&
- confetti.map((_, i) => {
- const c = colors[i % colors.length];
- const left = (i * 4597 + 137) % 100;
- const size = 3 + (i % 3) * 2;
- const duration = 8 + (i % 7) * 1.5;
- const delay = (i * 0.4) % 8;
- return (
-
- );
- })}
- {/* Slow gold shimmer */}
-
- >
- );
-}
-
-function AutumnOverlay({ reduced }: { reduced: boolean }) {
- const leaves = Array.from({ length: 18 });
- const colors = [
- 'rgba(220,80,20,0.75)',
- 'rgba(200,120,0,0.7)',
- 'rgba(180,50,10,0.7)',
- 'rgba(230,150,0,0.65)',
- 'rgba(160,80,0,0.6)',
- ];
- return (
- <>
-
- {!reduced &&
- leaves.map((_, i) => {
- const left = (i * 5381 + 179) % 100;
- const duration = 12 + (i % 6) * 2;
- const delay = (i * 0.65) % 12;
- const size = 10 + (i % 4) * 4;
- const col = colors[i % colors.length];
- return (
-
- );
- })}
- >
- );
-}
-
-// Replaced aggressive glitch with playful confetti rain
-function AprilFoolsOverlay({ reduced }: { reduced: boolean }) {
- const particles = Array.from({ length: 20 });
- const symbols = ['?', '!', '¿', '‽', '?', '!'];
- const colors = [
- 'rgba(255,80,80,0.55)',
- 'rgba(255,200,0,0.55)',
- 'rgba(80,200,80,0.55)',
- 'rgba(80,80,255,0.55)',
- 'rgba(200,80,200,0.55)',
- 'rgba(80,200,200,0.55)',
- ];
-
- return (
- <>
- {/* Subtle rainbow stripe along top edge */}
-
- {/* Gentle falling punctuation symbols */}
- {!reduced &&
- particles.map((_, i) => {
- const left = (i * 5381 + 179) % 100;
- const duration = 11 + (i % 5) * 2.5;
- const delay = (i * 0.55) % 10;
- const col = colors[i % colors.length];
- const sym = symbols[i % symbols.length];
- const size = 12 + (i % 3) * 5;
- return (
-
- {sym}
-
- );
- })}
- >
- );
-}
-
-// Reduced to 4 lanterns, subtler tint and shimmer
-function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
- const lanterns = Array.from({ length: 4 }); // was 9
- return (
- <>
- {/* Very subtle red silk tint */}
-
- {/* Slow gold shimmer */}
-
- {/* 4 floating lanterns */}
- {lanterns.map((_, i) => {
- const left = 10 + ((i * 4603 + 311) % 75);
- const top = 10 + ((i * 2311 + 97) % 50);
- const duration = 3.5 + (i % 4) * 0.7;
- const delay = i * 0.9;
- return (
-
- );
- })}
- >
- );
-}
-
-function ValentinesOverlay({ reduced }: { reduced: boolean }) {
- const hearts = Array.from({ length: 18 });
- const colors = [
- 'rgba(255,100,140,0.8)',
- 'rgba(255,150,180,0.65)',
- 'rgba(220,70,110,0.7)',
- 'rgba(255,180,200,0.55)',
- ];
- return (
- <>
-
- {!reduced &&
- hearts.map((_, i) => {
- const left = 3 + ((i * 6271 + 443) % 94);
- const duration = 9 + (i % 6) * 1.8;
- const delay = (i * 0.6) % 9;
- const size = 14 + (i % 4) * 5;
- const col = colors[i % colors.length];
- return (
-
- ♥
-
- );
- })}
- >
- );
-}
-
-function StPatricksOverlay({ reduced }: { reduced: boolean }) {
- const clovers = Array.from({ length: 18 });
- return (
- <>
-
-
- {!reduced &&
- clovers.map((_, i) => {
- const left = (i * 4129 + 223) % 100;
- const duration = 14 + (i % 6) * 2;
- const delay = (i * 0.7) % 12;
- const size = 14 + (i % 3) * 6;
- return (
-
- ☘
-
- );
- })}
- >
- );
-}
-
-function EarthDayOverlay({ reduced }: { reduced: boolean }) {
- const leaves = Array.from({ length: 16 });
- const leafEmoji = ['🌿', '🍃', '🌱', '🍀'];
- return (
- <>
-
-
- {!reduced &&
- leaves.map((_, i) => {
- const left = 3 + ((i * 5023 + 317) % 92);
- const duration = 13 + (i % 5) * 2;
- const delay = (i * 0.75) % 11;
- const size = 14 + (i % 3) * 5;
- return (
-
- {leafEmoji[i % leafEmoji.length]}
-
- );
- })}
- >
- );
-}
-
-function DeepSpaceOverlay({ reduced }: { reduced: boolean }) {
- const stars = Array.from({ length: 24 });
- return (
- <>
-
- {!reduced &&
- stars.map((_, i) => {
- const angle = (i / stars.length) * 360;
- const duration = 2.5 + (i % 5) * 0.4;
- const delay = (i * 0.18) % 2.5;
- const period = 3 + (i % 4) * 0.5;
- const size = 1 + (i % 3);
- const starColors = [
- 'rgba(200,180,255,0.9)',
- 'rgba(150,200,255,0.8)',
- 'rgba(255,255,255,0.7)',
- ];
- return (
-
- );
- })}
- >
- );
-}
-
-function ArcadeOverlay({ reduced }: { reduced: boolean }) {
- return (
- <>
-
- {(['0,0', '0,auto', 'auto,0', 'auto,auto'] as const).map((corner, i) => {
- const [t, b] = corner.split(',');
- return (
-
- {['[■]', '[■]', '[■]', '[■]'][i]}
-
- );
- })}
-
- — INSERT COIN —
-
-
- >
- );
-}
-
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
function buildOverlayContent(theme: SeasonTheme, reduced: boolean): React.ReactNode {
diff --git a/src/app/components/seasonal/themes/AprilFools.css.ts b/src/app/components/seasonal/themes/AprilFools.css.ts
new file mode 100644
index 000000000..17c9f2463
--- /dev/null
+++ b/src/app/components/seasonal/themes/AprilFools.css.ts
@@ -0,0 +1,71 @@
+import { keyframes } from '@vanilla-extract/css';
+
+/**
+ * Doodle float-up — a hand-drawn glyph drifts gently upward while bobbing
+ * side to side and lazily rotating, like a thought balloon escaping the page.
+ * GPU-only: transform + opacity exclusively. A tall translateY lets one set of
+ * keyframes serve every doodle; per-element duration/delay/scale add variety.
+ */
+export const animDoodleFloat = keyframes({
+ '0%': { transform: 'translate3d(0, 8vh, 0) rotate(-8deg) scale(0.85)', opacity: '0' },
+ '10%': { opacity: '1' },
+ '35%': { transform: 'translate3d(16px, -28vh, 0) rotate(6deg) scale(1)' },
+ '65%': { transform: 'translate3d(-14px, -64vh, 0) rotate(-5deg) scale(1.04)' },
+ '90%': { opacity: '0.8' },
+ '100%': { transform: 'translate3d(10px, -112vh, 0) rotate(7deg) scale(1.1)', opacity: '0' },
+});
+
+/**
+ * Confetti tumble — a small chip falls while flipping. Reuses a single tall
+ * translateY; the flip (rotate + scaleX) sells the paper tumble cheaply.
+ */
+export const animConfettiTumble = keyframes({
+ '0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg) scaleX(1)', opacity: '0' },
+ '8%': { opacity: '1' },
+ '50%': { transform: 'translate3d(18px, 50vh, 0) rotate(220deg) scaleX(-1)' },
+ '92%': { opacity: '0.9' },
+ '100%': { transform: 'translate3d(-12px, 112vh, 0) rotate(440deg) scaleX(1)', opacity: '0' },
+});
+
+/**
+ * Playful wobble — an almost-imperceptible skew/rotate of a faux tint layer so
+ * the whole scene feels gently "tickled". Tiny amplitude keeps it from being
+ * disorienting. Transform only, stays on the compositor.
+ */
+export const animWobble = keyframes({
+ '0%': { transform: 'rotate(-0.5deg) skewX(-0.4deg) scale(1.01)' },
+ '50%': { transform: 'rotate(0.5deg) skewX(0.4deg) scale(1.01)' },
+ '100%': { transform: 'rotate(-0.5deg) skewX(-0.4deg) scale(1.01)' },
+});
+
+/**
+ * Pastel aurora drift — a soft rainbow wash high in the scene slides and
+ * breathes. translateX + opacity (never background-position) to stay on GPU.
+ */
+export const animRainbowDrift = keyframes({
+ '0%': { transform: 'translate3d(-5%, 0, 0) scaleY(1)', opacity: '0.55' },
+ '50%': { transform: 'translate3d(5%, 0, 0) scaleY(1.06)', opacity: '0.8' },
+ '100%': { transform: 'translate3d(-5%, 0, 0) scaleY(1)', opacity: '0.55' },
+});
+
+/**
+ * Googly-eye look-around — the pupil layer nudges around its socket, giving
+ * each eye a cheeky wandering gaze. Small translate only.
+ */
+export const animGoogly = keyframes({
+ '0%': { transform: 'translate3d(1.5px, 1px, 0)' },
+ '20%': { transform: 'translate3d(-1.5px, 1.5px, 0)' },
+ '45%': { transform: 'translate3d(1px, -1.5px, 0)' },
+ '70%': { transform: 'translate3d(-1px, -0.5px, 0)' },
+ '100%': { transform: 'translate3d(1.5px, 1px, 0)' },
+});
+
+/**
+ * Sly wink/sparkle — a four-point glint that twinkles open and shut, scaling
+ * and fading like a sly little wink. Transform + opacity only.
+ */
+export const animSparkle = keyframes({
+ '0%, 100%': { transform: 'scale(0.2) rotate(0deg)', opacity: '0' },
+ '40%': { transform: 'scale(1) rotate(35deg)', opacity: '0.9' },
+ '60%': { transform: 'scale(0.95) rotate(45deg)', opacity: '0.7' },
+});
diff --git a/src/app/components/seasonal/themes/AprilFools.tsx b/src/app/components/seasonal/themes/AprilFools.tsx
new file mode 100644
index 000000000..ec4783e09
--- /dev/null
+++ b/src/app/components/seasonal/themes/AprilFools.tsx
@@ -0,0 +1,409 @@
+import React, { useMemo } from 'react';
+import { SeasonalOverlayProps } from '../types';
+import {
+ animDoodleFloat,
+ animConfettiTumble,
+ animWobble,
+ animRainbowDrift,
+ animGoogly,
+ animSparkle,
+} from './AprilFools.css';
+
+// Deterministic pseudo-random so the scene is identical on every mount and the
+// reduced-motion preview thumbnail is stable. Large primes spread the values.
+const rand = (seed: number) => {
+ const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
+ return x - Math.floor(x);
+};
+
+// Bright-but-soft pastel rainbow in oklch. Kept luminous and gentle so the
+// doodles read as crayon pastel over chat without ever fighting the text.
+const PASTELS = [
+ 'oklch(0.85 0.12 20)', // pink
+ 'oklch(0.88 0.12 90)', // butter yellow
+ 'oklch(0.82 0.12 160)', // mint
+ 'oklch(0.8 0.12 260)', // periwinkle
+ 'oklch(0.84 0.12 320)', // lilac
+ 'oklch(0.86 0.11 50)', // peach
+];
+
+// Inline-SVG data-URI doodle glyphs, drawn hand-sketch style (round caps,
+// open paths). `enc()` keeps them CSP-safe — no external assets, no base64.
+const enc = (svg: string) => `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
+
+// A single rough stroke wrapper helper for the glyph SVGs.
+const stroke = (color: string, body: string) =>
+ `
${body} `;
+
+// Question mark — the playful "huh?" centerpiece doodle.
+const glyphQuestion = (c: string) =>
+ stroke(
+ c,
+ `
` +
+ `
`,
+ );
+
+// Exclamation / "bang" — a surprised little doodle.
+const glyphBang = (c: string) =>
+ stroke(c, `
`);
+
+// Squiggle — a loopy scribble that adds whimsy.
+const glyphSquiggle = (c: string) => stroke(c, `
`);
+
+// Five-point doodle star (open-stroke, hand-drawn look).
+const glyphStar = (c: string) =>
+ stroke(
+ c,
+ `
`,
+ );
+
+// A tiny heart doodle for extra grin.
+const glyphHeart = (c: string) =>
+ stroke(c, `
`);
+
+const GLYPHS = [glyphQuestion, glyphBang, glyphSquiggle, glyphStar, glyphHeart, glyphQuestion];
+
+type Doodle = {
+ left: number;
+ size: number;
+ glyph: string;
+ duration: number;
+ delay: number;
+ startTop: number; // used for the static (reduced) scatter
+ opacity: number;
+};
+
+type Confetti = {
+ left: number;
+ size: number;
+ color: string;
+ duration: number;
+ delay: number;
+ startTop: number;
+ ratio: number; // chip aspect
+ round: boolean;
+};
+
+type Eye = {
+ left: number;
+ top: number;
+ size: number;
+ duration: number;
+ delay: number;
+};
+
+type Spark = {
+ left: number;
+ top: number;
+ size: number;
+ color: string;
+ duration: number;
+ delay: number;
+};
+
+export function AprilFoolsOverlay({ reduced }: SeasonalOverlayProps) {
+ // ~16 drifting doodles. Built once; per-element timing creates the variety.
+ const doodles = useMemo
(() => {
+ const count = 16;
+ const out: Doodle[] = [];
+ for (let i = 0; i < count; i += 1) {
+ const color = PASTELS[i % PASTELS.length];
+ out.push({
+ left: rand(i + 0.1) * 96 + 2,
+ size: 18 + rand(i + 0.3) * 22,
+ glyph: enc(GLYPHS[i % GLYPHS.length](color)),
+ duration: 16 + rand(i + 0.5) * 12,
+ delay: -rand(i + 0.7) * 26,
+ startTop: rand(i + 0.9) * 92 + 4,
+ opacity: 0.5 + rand(i + 0.2) * 0.32,
+ });
+ }
+ return out;
+ }, []);
+
+ // ~14 confetti chips in a couple of falling bands.
+ const confetti = useMemo(() => {
+ const count = 14;
+ const out: Confetti[] = [];
+ for (let i = 0; i < count; i += 1) {
+ out.push({
+ left: rand(i + 3.1) * 98 + 1,
+ size: 5 + rand(i + 3.3) * 6,
+ color: PASTELS[(i + 2) % PASTELS.length],
+ duration: 10 + rand(i + 3.5) * 9,
+ delay: -rand(i + 3.7) * 18,
+ startTop: rand(i + 3.9) * 96 + 2,
+ ratio: 0.45 + rand(i + 3.2) * 0.8,
+ round: rand(i + 3.6) > 0.6,
+ });
+ }
+ return out;
+ }, []);
+
+ // A few googly eyes peeking from corners/edges — the cheeky surprise.
+ const eyes = useMemo(() => {
+ const anchors = [
+ { left: 6, top: 12 },
+ { left: 90, top: 20 },
+ { left: 80, top: 82 },
+ { left: 14, top: 74 },
+ ];
+ return anchors.map((a, i) => ({
+ left: a.left,
+ top: a.top,
+ size: 22 + rand(i + 5.1) * 12,
+ duration: 3 + rand(i + 5.3) * 2.5,
+ delay: -rand(i + 5.5) * 3,
+ }));
+ }, []);
+
+ // Sly winking sparkles scattered sparsely.
+ const sparks = useMemo(() => {
+ const count = 5;
+ const out: Spark[] = [];
+ for (let i = 0; i < count; i += 1) {
+ out.push({
+ left: rand(i + 7.1) * 90 + 5,
+ top: rand(i + 7.3) * 84 + 8,
+ size: 12 + rand(i + 7.5) * 12,
+ color: PASTELS[(i + 1) % PASTELS.length],
+ duration: 4 + rand(i + 7.7) * 3,
+ delay: -rand(i + 7.9) * 5,
+ });
+ }
+ return out;
+ }, []);
+
+ // Four-point glint used for the winking sparkles.
+ const sparkGlint = (c: string) =>
+ enc(
+ `` +
+ ` `,
+ );
+
+ return (
+ <>
+ {/* Soft pastel ambient wash — layered oklch radials for depth. Very low
+ opacity so chat text keeps WCAG-AA contrast. */}
+
+
+ {/* Faux wobble layer — a near-invisible pastel haze that gently skews so
+ the whole scene feels playfully "tickled". Tiny amplitude = not
+ nauseating. backdrop-filter is one cheap layer for a candy bloom. */}
+
+
+ {/* Pastel rainbow aurora high up — soft band of the full palette. */}
+
+
+ {/* Drifting doodles. Motion: rise from below. Reduced: static scatter. */}
+ {doodles.map((d, i) => {
+ const common: React.CSSProperties = {
+ position: 'absolute',
+ left: `${d.left}%`,
+ width: `${d.size}px`,
+ height: `${d.size}px`,
+ backgroundImage: d.glyph,
+ backgroundRepeat: 'no-repeat',
+ backgroundSize: 'contain',
+ backgroundPosition: 'center',
+ opacity: d.opacity,
+ filter: 'drop-shadow(0 1px 1px oklch(0.4 0.05 300 / 0.18))',
+ };
+ if (reduced) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ })}
+
+ {/* Light confetti — tumbling pastel chips. */}
+ {confetti.map((c, i) => {
+ const common: React.CSSProperties = {
+ position: 'absolute',
+ left: `${c.left}%`,
+ width: `${c.size}px`,
+ height: `${c.size * c.ratio}px`,
+ background: c.color,
+ borderRadius: c.round ? '50%' : '1px',
+ opacity: 0.75,
+ };
+ if (reduced) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ })}
+
+ {/* Googly eyes peeking from the edges — pupil wanders cheekily. */}
+ {eyes.map((e, i) => (
+
+ ))}
+
+ {/* Sly winking sparkles. Static (reduced) shows them mid-glint. */}
+ {sparks.map((s, i) => (
+
+ ))}
+ >
+ );
+}
diff --git a/src/app/components/seasonal/themes/Arcade.css.ts b/src/app/components/seasonal/themes/Arcade.css.ts
new file mode 100644
index 000000000..83f77a572
--- /dev/null
+++ b/src/app/components/seasonal/themes/Arcade.css.ts
@@ -0,0 +1,121 @@
+import { keyframes } from '@vanilla-extract/css';
+
+/**
+ * Arcade overlay keyframes — retro synthwave CRT.
+ *
+ * Every animation touches ONLY `transform` and `opacity` so the compositor can
+ * run them on the GPU without triggering layout or paint. keyframes() returns
+ * the generated animation-name string, which is applied inline in Arcade.tsx.
+ *
+ * Motion philosophy: a neon perspective grid scrolls toward the viewer, a soft
+ * CRT scanline field breathes, the whole screen glows and flickers ever so
+ * faintly, sparse pixel sparkles drift up, and an "INSERT COIN" blip pulses.
+ * The grid scroll is done with a translateY on a tiled, perspective-projected
+ * plane — never background-position — so it rides the compositor.
+ */
+
+/**
+ * The neon grid plane is laid out twice its visible height and tiled with the
+ * horizontal rule lines. Translating it up by exactly one tile makes the lines
+ * appear to flow continuously toward the viewer (the horizon). Because the
+ * plane sits under a `perspective` transform, the lines also accelerate as they
+ * approach, giving a true receding-grid illusion. Pure transform.
+ */
+export const animGridScroll = keyframes({
+ '0%': { transform: 'translateZ(0) translateY(0)' },
+ '100%': { transform: 'translateZ(0) translateY(50%)' },
+});
+
+/**
+ * Slow vertical drift of the fine scanline field — a couple of pixels so the
+ * raster looks like it's gently rolling, the way a real CRT does. Transform
+ * only; the line texture itself never moves on the GPU's paint layer.
+ */
+export const animScanRoll = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0)' },
+ '100%': { transform: 'translate3d(0, 4px, 0)' },
+});
+
+/**
+ * The overall CRT screen-glow breathes: a barely-there opacity swell that keeps
+ * the static neon tint feeling alive and powered-on. Opacity only.
+ */
+export const animScreenGlow = keyframes({
+ '0%': { opacity: '0.72' },
+ '50%': { opacity: '1' },
+ '100%': { opacity: '0.72' },
+});
+
+/**
+ * A faint, irregular CRT brightness flicker laid over the glow — the classic
+ * unstable-tube shimmer. Kept extremely shallow so it never distracts or harms
+ * readability. Opacity only.
+ */
+export const animCrtFlicker = keyframes({
+ '0%': { opacity: '0.94' },
+ '12%': { opacity: '1' },
+ '20%': { opacity: '0.9' },
+ '34%': { opacity: '0.98' },
+ '52%': { opacity: '0.92' },
+ '70%': { opacity: '1' },
+ '83%': { opacity: '0.95' },
+ '100%': { opacity: '0.94' },
+});
+
+/**
+ * Chromatic-aberration twin: the magenta/cyan fringe layers nudge a sub-pixel
+ * apart and back so the edges shimmer with RGB split, like a misconverged tube.
+ * transform + opacity only.
+ */
+export const animChromaShift = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0)', opacity: '0.5' },
+ '50%': { transform: 'translate3d(1.5px, 0, 0)', opacity: '0.8' },
+ '100%': { transform: 'translate3d(0, 0, 0)', opacity: '0.5' },
+});
+
+/**
+ * Pixel sparkle drift: a tiny neon speck rises and twinkles like a coin-burst
+ * particle floating up off the grid. transform + opacity, single tall path.
+ */
+export const animSparkleDrift = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0) scale(0.6)', opacity: '0' },
+ '12%': { opacity: '1' },
+ '50%': { transform: 'translate3d(8px, -42vh, 0) scale(1)', opacity: '0.85' },
+ '78%': { transform: 'translate3d(-6px, -70vh, 0) scale(0.8)', opacity: '0.5' },
+ '92%': { opacity: '0.18' },
+ '100%': { transform: 'translate3d(6px, -92vh, 0) scale(0.55)', opacity: '0' },
+});
+
+/**
+ * Independent pixel twinkle layered on the drift so specks blink on/off like a
+ * low-res sprite. Stepped opacity for a crisp 8-bit feel.
+ */
+export const animSparkleTwinkle = keyframes({
+ '0%, 44%': { opacity: '1' },
+ '50%, 94%': { opacity: '0.35' },
+ '100%': { opacity: '1' },
+});
+
+/**
+ * "INSERT COIN" blink: the classic attract-mode pulse. Stepped so it reads as a
+ * hard retro blink rather than a soft fade, but with a brief bright swell.
+ * Opacity + a hair of scale for a CRT bloom feel.
+ */
+export const animCoinBlink = keyframes({
+ '0%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
+ '6%': { opacity: '1', transform: 'translateX(-50%) scale(1.015)' },
+ '12%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
+ '49%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
+ '50%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
+ '100%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
+});
+
+/**
+ * Score-blip pulse for the corner HUD glyph: a quick pop then settle, like a
+ * counter ticking up. transform + opacity.
+ */
+export const animScoreBlip = keyframes({
+ '0%': { opacity: '0.4', transform: 'scale(1)' },
+ '50%': { opacity: '0.85', transform: 'scale(1.12)' },
+ '100%': { opacity: '0.4', transform: 'scale(1)' },
+});
diff --git a/src/app/components/seasonal/themes/Arcade.tsx b/src/app/components/seasonal/themes/Arcade.tsx
new file mode 100644
index 000000000..95f4471dc
--- /dev/null
+++ b/src/app/components/seasonal/themes/Arcade.tsx
@@ -0,0 +1,382 @@
+import React, { useMemo } from 'react';
+import { SeasonalOverlayProps } from '../types';
+import {
+ animGridScroll,
+ animScanRoll,
+ animScreenGlow,
+ animCrtFlicker,
+ animChromaShift,
+ animSparkleDrift,
+ animSparkleTwinkle,
+ animCoinBlink,
+ animScoreBlip,
+} from './Arcade.css';
+
+/**
+ * ArcadeOverlay — retro synthwave CRT.
+ *
+ * A full-screen, pointer-events:none ambient decoration. The parent supplies a
+ * fixed inset:0 overflow:hidden pointer-events:none container at the correct
+ * z-index, so this component only returns absolutely-positioned aria-hidden
+ * children and never sets position:fixed / z-index / pointer-events.
+ *
+ * Composition (back to front):
+ * 1. near-black synthwave ambient wash (magenta sky-glow up top, cyan/purple
+ * pool toward the floor) — layered oklch gradients for depth
+ * 2. a neon perspective grid receding to a vanishing point on the horizon,
+ * scrolling toward the viewer via transform translateY (never bg-position)
+ * 3. a soft horizon sun-glow + thin neon horizon line where the grid meets sky
+ * 4. drifting pixel sparkles / neon coin-burst specks rising off the grid
+ * 5. fine CRT scanlines, gently rolling
+ * 6. a faint chromatic-aberration fringe at the screen edges
+ * 7. a glowing "INSERT COIN" blip + a corner SCORE HUD glyph
+ * 8. a CRT vignette + screen-glow that frames and protects central text
+ *
+ * All motion is transform/opacity only (compositor-friendly). When `reduced` is
+ * true we render a static-but-gorgeous scene: a still neon grid, steady
+ * scanlines + vignette, and a steady "INSERT COIN" — no `animation` anywhere,
+ * no flicker. The settings preview always passes reduced=true, so the still
+ * form stands on its own.
+ */
+
+// Synthwave neon palette in oklch. Saturated where it glows, but every layer is
+// held at low opacity so it tints rather than takes over the chat beneath.
+const NEON_MAGENTA = 'oklch(0.65 0.25 350)';
+const NEON_CYAN = 'oklch(0.80 0.15 200)';
+const GRID_PURPLE = 'oklch(0.45 0.18 300)';
+
+// The receding grid as an inline SVG data-URI (CSP-safe, no external assets).
+// It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical
+// lines fanning toward a top-center vanishing point. The plane is then placed
+// under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
+// tile up by one tile-height (animGridScroll → translateY 50%) loops seamlessly.
+function gridDataUri(): string {
+ const lines: string[] = [];
+ // Horizontal rules — denser toward the top (the horizon) for a perspective
+ // feel even before the CSS rotateX is applied.
+ const rows = [0, 16, 34, 54, 76, 100, 126, 156, 190, 228, 270, 316, 366, 420, 478, 540];
+ rows.forEach((y) => {
+ lines.push(
+ ` `,
+ );
+ });
+ // Vertical lines fanning out from the top-center vanishing point.
+ for (let i = -7; i <= 7; i += 1) {
+ const topX = 300 + i * 6; // tight near the horizon
+ const botX = 300 + i * 95; // wide at the foreground
+ lines.push(
+ ` `,
+ );
+ }
+ const svg =
+ `${lines.join('')} `;
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
+}
+
+type Sparkle = {
+ left: number; // vw
+ bottom: number; // % up from floor where it spawns
+ size: number; // px
+ duration: number; // s
+ delay: number; // s
+ twinkle: number; // s
+ hue: 'magenta' | 'cyan';
+ opacity: number;
+};
+
+// Hand-placed still sparkles for the reduced/static scene — a few neon specks
+// resting low over the grid, away from the busy chat center.
+const RESTING_SPARKLES: ReadonlyArray<{
+ left: number;
+ bottom: number;
+ size: number;
+ hue: 'magenta' | 'cyan';
+ opacity: number;
+}> = [
+ { left: 12, bottom: 18, size: 4, hue: 'cyan', opacity: 0.5 },
+ { left: 26, bottom: 30, size: 3, hue: 'magenta', opacity: 0.42 },
+ { left: 78, bottom: 22, size: 4, hue: 'magenta', opacity: 0.5 },
+ { left: 88, bottom: 34, size: 3, hue: 'cyan', opacity: 0.4 },
+ { left: 50, bottom: 14, size: 3, hue: 'cyan', opacity: 0.38 },
+];
+
+const GRID_URI = gridDataUri();
+
+export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
+ // Deterministic sparkle field, computed ONCE. No per-frame state.
+ const sparkles = useMemo(() => {
+ const COUNT = 16;
+ return Array.from({ length: COUNT }, (_, i) => ({
+ left: (i * 6.27 + 4) % 100,
+ bottom: (i * 3.7) % 28, // spawn in the lower third (over the grid)
+ size: 2 + (i % 3), // 2..4 px pixels
+ duration: 14 + (i % 6) * 2.2,
+ delay: -((i * 1.83) % 16),
+ twinkle: 1.4 + (i % 4) * 0.5,
+ hue: i % 2 === 0 ? 'cyan' : 'magenta',
+ opacity: 0.45 + (i % 3) * 0.12,
+ }));
+ }, []);
+
+ const sparkleColor = (hue: 'magenta' | 'cyan') => (hue === 'cyan' ? NEON_CYAN : NEON_MAGENTA);
+
+ return (
+ <>
+ {/* 1. Near-black synthwave ambient wash. Magenta sky-glow up top, a
+ cyan/purple pool toward the floor, and an overall dark vertical
+ grade. Layered oklch gradients give depth at very low opacity. */}
+
+
+ {/* 2. The neon perspective grid. A wide, tall plane is tilted away from
+ the viewer with `perspective` + rotateX so its rule lines recede to
+ a vanishing point at the top (the horizon). It lives in the lower
+ half of the screen — the "floor". The inner plane scrolls upward by
+ one tile via transform translateY, which reads as the grid flowing
+ toward the viewer. Pure transform; never background-position. */}
+
+
+ {/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
+ where the grid meets the sky, with a thin bright rule on top of it
+ to seal the vanishing point. Static (no motion) either way. */}
+
+
+
+ {/* 4. Drifting pixel sparkles / neon coin-burst specks. Tiny square
+ neon pixels rising off the grid and twinkling. The static scene uses
+ a small resting set instead. */}
+ {reduced
+ ? RESTING_SPARKLES.map((s, i) => (
+
+ ))
+ : sparkles.map((s, i) => (
+
+ ))}
+
+ {/* 5. Fine CRT scanlines. A repeating 1px dark rule field over the whole
+ screen, gently rolling downward on the compositor (transform only).
+ Held faint so text stays crisp. The pattern is in a child taller
+ than the frame so the roll never reveals an edge. */}
+
+
+ {/* 6. Chromatic-aberration fringe. Two thin edge-glows — magenta and cyan —
+ offset a sub-pixel apart at the screen border so the frame shimmers
+ with an RGB split, like a misconverged tube. Animated only; in the
+ static scene it sits as a steady fringe. */}
+
+
+ {/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
+ Static scene shows it steady (no blink). */}
+
+ INSERT COIN
+
+
+ {/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left,
+ very low opacity so it reads as ambient chrome, not UI. */}
+
+ 1UP 000000
+
+
+ {/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
+ with a faint magenta tube-glow swell. The vignette protects central
+ chat-text contrast. Static scene holds it steady; live scene adds a
+ shallow breathing glow + irregular flicker, both opacity-only. */}
+
+ >
+ );
+}
diff --git a/src/app/components/seasonal/themes/Autumn.css.ts b/src/app/components/seasonal/themes/Autumn.css.ts
new file mode 100644
index 000000000..739b75fc7
--- /dev/null
+++ b/src/app/components/seasonal/themes/Autumn.css.ts
@@ -0,0 +1,91 @@
+import { keyframes } from '@vanilla-extract/css';
+
+/**
+ * Autumn overlay keyframes. Every animation touches ONLY `transform` and
+ * `opacity` so the compositor can run them on the GPU without triggering
+ * layout or paint. keyframes() returns the generated animation-name string,
+ * which is applied inline in Autumn.tsx.
+ *
+ * Motion philosophy: warm, slow, cozy. Leaves tumble and rotate as they fall
+ * with a per-leaf sway decoupled on a wrapper; sun shafts breathe; dust motes
+ * drift up through the light; the whole frame has a barely-there warm pulse.
+ */
+
+/**
+ * A leaf falls from above to below the viewport while continuously rotating.
+ * A single tall translateY serves every leaf — per-leaf duration/delay/scale
+ * create the parallax variety. Horizontal travel is intentionally small here
+ * because the real lateral motion comes from the sway wrapper below.
+ */
+export const animLeafFall = keyframes({
+ '0%': { transform: 'translate3d(0, -12vh, 0) rotate(-30deg)', opacity: '0' },
+ '8%': { opacity: '1' },
+ '50%': { transform: 'translate3d(10px, 50vh, 0) rotate(200deg)' },
+ '92%': { opacity: '0.85' },
+ '100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(430deg)', opacity: '0' },
+});
+
+/**
+ * Lateral sway applied to a leaf's wrapper so the descent reads as wind
+ * catching the blade. Decoupled from the fall so the two compose into an
+ * organic, non-repeating-looking path.
+ */
+export const animLeafSway = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0)' },
+ '50%': { transform: 'translate3d(34px, 0, 0)' },
+ '100%': { transform: 'translate3d(0, 0, 0)' },
+});
+
+/**
+ * A second flutter on the leaf's inner shape: a gentle skew/scale wobble that
+ * mimics the blade catching air as it spins. Cheap, transform-only.
+ */
+export const animLeafFlutter = keyframes({
+ '0%': { transform: 'rotate(-8deg) scaleX(1)' },
+ '50%': { transform: 'rotate(8deg) scaleX(0.82)' },
+ '100%': { transform: 'rotate(-8deg) scaleX(1)' },
+});
+
+/**
+ * Low-sun light shaft: a long soft beam slowly slides and breathes. Uses
+ * translateX + opacity (never background-position) so it stays on the
+ * compositor. Scale on Y makes the beam subtly elongate as it brightens.
+ */
+export const animSunShaft = keyframes({
+ '0%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
+ '50%': { transform: 'translate3d(4%, 0, 0) scaleY(1.06)', opacity: '0.75' },
+ '100%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
+});
+
+/**
+ * Dust / pollen mote: a tiny speck drifts upward through the light, swaying,
+ * pulsing softly in brightness as it catches the sun. transform + opacity.
+ */
+export const animMoteDrift = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' },
+ '15%': { opacity: '0.85' },
+ '40%': { transform: 'translate3d(16px, -30vh, 0) scale(1)' },
+ '70%': { transform: 'translate3d(-12px, -58vh, 0) scale(0.85)', opacity: '0.6' },
+ '90%': { opacity: '0.2' },
+ '100%': { transform: 'translate3d(10px, -84vh, 0) scale(0.6)', opacity: '0' },
+});
+
+/**
+ * Independent twinkle for motes — a brightness flicker layered on the drift so
+ * specks shimmer as if turning in the light. Opacity only.
+ */
+export const animMoteTwinkle = keyframes({
+ '0%': { opacity: '0.5' },
+ '50%': { opacity: '1' },
+ '100%': { opacity: '0.5' },
+});
+
+/**
+ * Barely-there breathing of the warm vignette frame so the static tint feels
+ * alive without any distracting motion. Opacity only.
+ */
+export const animEmberPulse = keyframes({
+ '0%': { opacity: '0.82' },
+ '50%': { opacity: '1' },
+ '100%': { opacity: '0.82' },
+});
diff --git a/src/app/components/seasonal/themes/Autumn.tsx b/src/app/components/seasonal/themes/Autumn.tsx
new file mode 100644
index 000000000..32a63de53
--- /dev/null
+++ b/src/app/components/seasonal/themes/Autumn.tsx
@@ -0,0 +1,310 @@
+import React, { useMemo } from 'react';
+import { SeasonalOverlayProps } from '../types';
+import {
+ animLeafFall,
+ animLeafSway,
+ animLeafFlutter,
+ animSunShaft,
+ animMoteDrift,
+ animMoteTwinkle,
+ animEmberPulse,
+} from './Autumn.css';
+
+/**
+ * AutumnOverlay — warm falling leaves.
+ *
+ * A full-screen, pointer-events:none ambient decoration. The parent supplies a
+ * fixed inset:0 overflow:hidden pointer-events:none container at the correct
+ * z-index, so this component only returns absolutely-positioned aria-hidden
+ * children and never sets position:fixed / z-index / pointer-events.
+ *
+ * Composition (back to front):
+ * 1. amber -> rust ambient gradient wash (cozy low-sun atmosphere)
+ * 2. soft angled sun shafts breathing high across the scene
+ * 3. drifting pollen / dust motes catching the light
+ * 4. maple & oak leaf silhouettes tumbling and rotating as they fall
+ * 5. a warm low-saturation vignette that frames + protects text contrast
+ *
+ * All motion is transform/opacity only (compositor-friendly). When `reduced`
+ * is true we render a static-but-gorgeous scene: a handful of leaves at rest,
+ * still sun shafts, and the warm vignette — no `animation` anywhere. The
+ * settings preview always passes reduced=true, so the still form stands alone.
+ */
+
+// Warm autumn palette in oklch. Kept low-saturation enough to never fight the
+// chat text underneath. Each leaf picks a tone for variety.
+const LEAF_TONES = [
+ 'oklch(0.75 0.15 70)', // amber
+ 'oklch(0.55 0.16 40)', // rust
+ 'oklch(0.82 0.13 85)', // warm gold
+ 'oklch(0.62 0.16 55)', // burnt orange
+ 'oklch(0.5 0.14 35)', // deep ember
+];
+
+// Two leaf silhouettes as inline SVG path data (no external assets, CSP-safe).
+// `maple` = classic five-lobed maple; `oak` = rounded-lobe oak blade.
+const MAPLE_PATH =
+ 'M50 4 L57 30 L78 18 L66 40 L92 40 L70 52 L84 74 L58 62 L56 92 L50 70 ' +
+ 'L44 92 L42 62 L16 74 L30 52 L8 40 L34 40 L22 18 L43 30 Z';
+const OAK_PATH =
+ 'M50 4 C58 14 56 22 64 24 C74 22 74 32 68 36 C78 38 76 48 68 50 ' +
+ 'C78 54 74 64 66 64 C70 74 60 78 54 72 C54 84 50 96 50 96 ' +
+ 'C50 96 46 84 46 72 C40 78 30 74 34 64 C26 64 22 54 32 50 ' +
+ 'C24 48 22 38 32 36 C26 32 26 22 36 24 C44 22 42 14 50 4 Z';
+
+/** Build a CSS-ready data-URI of a single tinted leaf silhouette. */
+function leafDataUri(kind: 'maple' | 'oak', fill: string): string {
+ const path = kind === 'maple' ? MAPLE_PATH : OAK_PATH;
+ // A faint vein line gives the blade depth without extra DOM nodes.
+ const svg =
+ `` +
+ ` ` +
+ ` ` +
+ ` `;
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
+}
+
+type Leaf = {
+ kind: 'maple' | 'oak';
+ uri: string;
+ left: number; // vw column
+ size: number; // px
+ duration: number; // s — fall time
+ delay: number; // s
+ swayDuration: number; // s — wrapper sway
+ flutterDuration: number; // s — inner flutter
+ tilt: number; // deg — resting rotation (used for static scene)
+ opacity: number;
+};
+
+type Mote = {
+ left: number;
+ bottom: number;
+ size: number;
+ duration: number;
+ delay: number;
+ twinkle: number;
+ opacity: number;
+};
+
+// A few hand-placed leaves at rest for the reduced/static scene — arranged so
+// they read as "settled" near edges and corners, never over the busy center.
+const RESTING_LEAVES: ReadonlyArray<{
+ kind: 'maple' | 'oak';
+ left: number;
+ top: number;
+ size: number;
+ tilt: number;
+ tone: number;
+ opacity: number;
+}> = [
+ { kind: 'maple', left: 6, top: 14, size: 46, tilt: -22, tone: 0, opacity: 0.4 },
+ { kind: 'oak', left: 88, top: 22, size: 38, tilt: 28, tone: 1, opacity: 0.34 },
+ { kind: 'maple', left: 16, top: 78, size: 54, tilt: 16, tone: 3, opacity: 0.42 },
+ { kind: 'oak', left: 80, top: 82, size: 44, tilt: -34, tone: 4, opacity: 0.36 },
+ { kind: 'maple', left: 50, top: 90, size: 40, tilt: 8, tone: 2, opacity: 0.32 },
+ { kind: 'oak', left: 70, top: 8, size: 32, tilt: -12, tone: 2, opacity: 0.3 },
+];
+
+export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
+ // Deterministic pseudo-random field, computed ONCE. No per-frame state.
+ const { leaves, motes } = useMemo(() => {
+ const LEAF_COUNT = 16;
+ const MOTE_COUNT = 12;
+
+ const builtLeaves: Leaf[] = Array.from({ length: LEAF_COUNT }, (_, i) => {
+ const kind: 'maple' | 'oak' = i % 3 === 0 ? 'oak' : 'maple';
+ const tone = LEAF_TONES[i % LEAF_TONES.length];
+ const sizeBucket = i % 4; // 0..3 → depth bucket for parallax
+ const size = 22 + sizeBucket * 9; // 22..49 px
+ return {
+ kind,
+ uri: leafDataUri(kind, tone),
+ left: (i * 6.13 + 4) % 100,
+ size,
+ // Larger (nearer) leaves fall a touch faster; all slow + cozy.
+ duration: 16 - sizeBucket * 1.6 + (i % 3) * 1.3,
+ delay: -((i * 1.37) % 16), // negative → staggered, already mid-fall
+ swayDuration: 5 + (i % 5) * 0.8,
+ flutterDuration: 1.6 + (i % 4) * 0.45,
+ tilt: ((i * 53) % 70) - 35,
+ opacity: 0.34 + sizeBucket * 0.08, // nearer → slightly bolder
+ };
+ });
+
+ const builtMotes: Mote[] = Array.from({ length: MOTE_COUNT }, (_, i) => ({
+ left: (i * 8.7 + 5) % 100,
+ bottom: (i * 4.3) % 30, // start in lower third, drift up
+ size: 2 + (i % 3),
+ duration: 16 + (i % 6) * 2.4,
+ delay: -((i * 2.1) % 16),
+ twinkle: 2.2 + (i % 4) * 0.6,
+ opacity: 0.4 + (i % 3) * 0.12,
+ }));
+
+ return { leaves: builtLeaves, motes: builtMotes };
+ }, []);
+
+ return (
+ <>
+ {/* 1. Ambient amber → rust atmospheric wash. Layered oklch gradients give
+ depth: a warm low-sun glow from the upper-left, a rust pool at the
+ base, and a faint gold core. Kept very low opacity. */}
+
+
+ {/* 2. Soft angled low-sun light shafts. Two long beams skewed to suggest
+ late-afternoon light raking across the room. */}
+ {[
+ { left: -8, rotate: 18, w: 38, opacity: 0.5, dur: 17, delay: 0 },
+ { left: 46, rotate: 14, w: 30, opacity: 0.38, dur: 22, delay: -6 },
+ { left: 78, rotate: 22, w: 26, opacity: 0.32, dur: 19, delay: -11 },
+ ].map((shaft, i) => (
+
+ ))}
+
+ {/* 3. Drifting pollen / dust motes catching the light. Static scene omits
+ them — stillness reads cleaner at rest. */}
+ {!reduced &&
+ motes.map((m, i) => (
+
+ ))}
+
+ {/* 4. Falling / resting maple & oak leaves. */}
+ {reduced
+ ? RESTING_LEAVES.map((leaf, i) => (
+
+ ))
+ : leaves.map((leaf, i) => (
+ // Sway wrapper: horizontal wind motion, decoupled from the fall.
+
+ {/* Fall wrapper: vertical descent + tumble rotation. */}
+
+ {/* Inner blade: the actual silhouette + flutter wobble. */}
+
+
+
+ ))}
+
+ {/* 5. Warm low-saturation vignette. Frames the scene and gently darkens
+ edges — protecting central chat text contrast. Breathes faintly. */}
+
+ >
+ );
+}
diff --git a/src/app/components/seasonal/themes/Christmas.css.ts b/src/app/components/seasonal/themes/Christmas.css.ts
new file mode 100644
index 000000000..39ce801c8
--- /dev/null
+++ b/src/app/components/seasonal/themes/Christmas.css.ts
@@ -0,0 +1,56 @@
+import { keyframes } from '@vanilla-extract/css';
+
+/**
+ * Snowfall — a flake drifts downward while swaying horizontally and slowly
+ * rotating. GPU-only: animates transform + opacity exclusively. The vertical
+ * travel uses a tall translateY so a single keyframe set serves all flakes;
+ * per-flake duration/delay/scale create the parallax variety.
+ */
+export const animSnowFall = keyframes({
+ '0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg)', opacity: '0' },
+ '8%': { opacity: '1' },
+ '50%': { transform: 'translate3d(14px, 50vh, 0) rotate(180deg)' },
+ '92%': { opacity: '0.85' },
+ '100%': { transform: 'translate3d(-10px, 112vh, 0) rotate(360deg)', opacity: '0' },
+});
+
+/**
+ * Gentle lateral sway applied to a flake's wrapper so the drift reads as wind,
+ * decoupled from the fall so the two combine into an organic path.
+ */
+export const animSnowSway = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0)' },
+ '50%': { transform: 'translate3d(18px, 0, 0)' },
+ '100%': { transform: 'translate3d(0, 0, 0)' },
+});
+
+/**
+ * String-light breathing — bokeh orbs softly pulse in brightness and scale,
+ * like incandescent bulbs warming and cooling. Opacity + transform only.
+ */
+export const animBulbBreathe = keyframes({
+ '0%': { transform: 'scale(0.92)', opacity: '0.55' },
+ '50%': { transform: 'scale(1.08)', opacity: '0.95' },
+ '100%': { transform: 'scale(0.92)', opacity: '0.55' },
+});
+
+/**
+ * Aurora shimmer — a wide soft band high in the scene slowly slides and
+ * breathes. Uses translateX + opacity (never background-position) so it stays
+ * on the compositor.
+ */
+export const animAurora = keyframes({
+ '0%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' },
+ '50%': { transform: 'translate3d(6%, 0, 0) scaleY(1.08)', opacity: '0.8' },
+ '100%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' },
+});
+
+/**
+ * Vignette frost — a barely-there breathing of the cold frame so the static
+ * tint feels alive without distracting motion.
+ */
+export const animFrostPulse = keyframes({
+ '0%': { opacity: '0.85' },
+ '50%': { opacity: '1' },
+ '100%': { opacity: '0.85' },
+});
diff --git a/src/app/components/seasonal/themes/Christmas.tsx b/src/app/components/seasonal/themes/Christmas.tsx
new file mode 100644
index 000000000..fad1d0a75
--- /dev/null
+++ b/src/app/components/seasonal/themes/Christmas.tsx
@@ -0,0 +1,256 @@
+import React, { useMemo } from 'react';
+import { SeasonalOverlayProps } from '../types';
+import {
+ animSnowFall,
+ animSnowSway,
+ animBulbBreathe,
+ animAurora,
+ animFrostPulse,
+} from './Christmas.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);
+};
+
+// Warm incandescent string-light hues in oklch — gold, soft red, cool white,
+// pine green, icy blue. Kept luminous and gentle so they read as bokeh glow.
+const BULB_COLORS = [
+ 'oklch(0.85 0.12 85)', // warm gold
+ 'oklch(0.72 0.15 28)', // soft red
+ 'oklch(0.95 0.03 230)', // icy white
+ 'oklch(0.78 0.13 150)', // pine green
+ 'oklch(0.8 0.1 235)', // cool blue
+];
+
+type Flake = {
+ left: number;
+ size: number;
+ duration: number;
+ delay: number;
+ swayDuration: number;
+ opacity: number;
+ blur: number;
+};
+
+type Bulb = {
+ left: number;
+ top: number;
+ size: number;
+ color: string;
+ duration: number;
+ delay: number;
+};
+
+export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
+ // Three parallax bands of snow: far (small/slow/dim) -> near (large/fast).
+ const flakes = useMemo(() => {
+ const bands = [
+ { count: 12, size: [1.5, 2.5], dur: [16, 22], op: [0.35, 0.55], blur: 0.6 },
+ { count: 10, size: [2.5, 4], dur: [11, 15], op: [0.55, 0.8], blur: 0.3 },
+ { count: 8, size: [4, 6.5], dur: [8, 11], op: [0.7, 0.95], blur: 0 },
+ ];
+ const out: Flake[] = [];
+ 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);
+ 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] + 4),
+ swayDuration: 4 + r2 * 5,
+ opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
+ blur: b.blur,
+ });
+ s += 1;
+ }
+ });
+ return out;
+ }, []);
+
+ // Bokeh string lights strung along the very top edge, gently sagging.
+ const bulbs = useMemo(() => {
+ const count = 9;
+ const out: Bulb[] = [];
+ for (let i = 0; i < count; i += 1) {
+ const t = i / (count - 1);
+ // Two-segment garland sag so the lights drape rather than sit in a line.
+ const sag = Math.sin(t * Math.PI * 2) * 3.2;
+ out.push({
+ left: 4 + t * 92,
+ top: 2.5 + Math.abs(Math.sin(t * Math.PI)) * 2 + sag,
+ size: 12 + rand(i + 5) * 8,
+ color: BULB_COLORS[i % BULB_COLORS.length],
+ duration: 3.4 + rand(i + 2) * 2.6,
+ delay: -rand(i + 9) * 3,
+ });
+ }
+ return out;
+ }, []);
+
+ return (
+ <>
+ {/* Deep night-blue ambient wash — layered radial + linear oklch gradients
+ for depth. Kept low-opacity so chat text stays legible (WCAG-AA). */}
+
+
+ {/* Frosted vignette frame — cold edges, clear center. backdrop-filter on a
+ single cheap layer for a faint icy haze around the rim. */}
+
+
+ {/* Aurora shimmer band high up — soft conic-ish wash of icy blue/green. */}
+
+
+ {/* String-light wire — a faint catenary line the bulbs hang from. */}
+
+
+ {/* Bokeh string lights — soft blurred orbs that breathe. */}
+ {bulbs.map((b, i) => (
+
+ ))}
+
+ {/* Snowfall (motion only) — three parallax bands. Static dusting below. */}
+ {!reduced &&
+ flakes.map((f, i) => (
+
+ ))}
+
+ {/* Static dusting of snow for the reduced-motion / preview scene — a
+ sparse scatter so the thumbnail still reads as snowfall. */}
+ {reduced &&
+ flakes.map((f, i) => {
+ const fy = rand(i + 0.5) * 96 + 2;
+ return (
+
+ );
+ })}
+ >
+ );
+}
diff --git a/src/app/components/seasonal/themes/DeepSpace.css.ts b/src/app/components/seasonal/themes/DeepSpace.css.ts
new file mode 100644
index 000000000..485f3b37a
--- /dev/null
+++ b/src/app/components/seasonal/themes/DeepSpace.css.ts
@@ -0,0 +1,73 @@
+import { keyframes } from '@vanilla-extract/css';
+
+/**
+ * Deep Space overlay keyframes. Everything here animates ONLY transform/opacity
+ * so the compositor can run it cheaply. The `keyframes()` helper returns the
+ * generated class name string, which the component splices into inline
+ * `animation` shorthands.
+ */
+
+/** Cosmos breathe: the whole nebula backdrop drifts and dims almost imperceptibly. */
+export const animCosmosDrift = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0) scale(1)', opacity: '0.9' },
+ '50%': { transform: 'translate3d(-1.5%, 1%, 0) scale(1.04)', opacity: '1' },
+ '100%': { transform: 'translate3d(0, 0, 0) scale(1)', opacity: '0.9' },
+});
+
+/** Nebula cloud drift: a single blurred cloud floats slowly across its layer. */
+export const animNebulaA = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0) scale(1)' },
+ '50%': { transform: 'translate3d(4%, -3%, 0) scale(1.08)' },
+ '100%': { transform: 'translate3d(0, 0, 0) scale(1)' },
+});
+
+export const animNebulaB = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0) scale(1.05)' },
+ '50%': { transform: 'translate3d(-5%, 2.5%, 0) scale(1)' },
+ '100%': { transform: 'translate3d(0, 0, 0) scale(1.05)' },
+});
+
+/** Galaxy spiral: an exceptionally slow rotation of a distant pinwheel. */
+export const animGalaxySpin = keyframes({
+ '0%': { transform: 'rotate(0deg) scale(1)' },
+ '50%': { transform: 'rotate(180deg) scale(1.03)' },
+ '100%': { transform: 'rotate(360deg) scale(1)' },
+});
+
+/** Tiny star twinkle: gentle opacity + micro-scale pulse. */
+export const animTwinkle = keyframes({
+ '0%': { transform: 'scale(0.85)', opacity: '0.35' },
+ '50%': { transform: 'scale(1)', opacity: '1' },
+ '100%': { transform: 'scale(0.85)', opacity: '0.35' },
+});
+
+/** Bright star pulse: a slower, fuller bloom for the few hero stars. */
+export const animStarPulse = keyframes({
+ '0%': { transform: 'scale(0.8) rotate(0deg)', opacity: '0.55' },
+ '50%': { transform: 'scale(1.15) rotate(45deg)', opacity: '1' },
+ '100%': { transform: 'scale(0.8) rotate(0deg)', opacity: '0.55' },
+});
+
+/** Parallax depth: a star layer drifts as if the viewer is gliding through space. */
+export const animParallaxNear = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0)' },
+ '100%': { transform: 'translate3d(-3%, 1.5%, 0)' },
+});
+
+export const animParallaxFar = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0)' },
+ '100%': { transform: 'translate3d(-1.2%, 0.6%, 0)' },
+});
+
+/**
+ * Comet streak: a thin meteor crosses the field on a diagonal, fading in then
+ * out. The element is rotated by the component; this only translates along its
+ * own local X axis (its length direction) and fades.
+ */
+export const animComet = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0)', opacity: '0' },
+ '6%': { opacity: '1' },
+ '40%': { opacity: '0.9' },
+ '60%': { transform: 'translate3d(150%, 0, 0)', opacity: '0' },
+ '100%': { transform: 'translate3d(150%, 0, 0)', opacity: '0' },
+});
diff --git a/src/app/components/seasonal/themes/DeepSpace.tsx b/src/app/components/seasonal/themes/DeepSpace.tsx
new file mode 100644
index 000000000..e4d2d0212
--- /dev/null
+++ b/src/app/components/seasonal/themes/DeepSpace.tsx
@@ -0,0 +1,364 @@
+import React, { useMemo } from 'react';
+import { SeasonalOverlayProps } from '../types';
+import {
+ animCosmosDrift,
+ animNebulaA,
+ animNebulaB,
+ animGalaxySpin,
+ animTwinkle,
+ animStarPulse,
+ animParallaxNear,
+ animParallaxFar,
+ animComet,
+} from './DeepSpace.css';
+
+/**
+ * Deep Space overlay — a cosmic, awe-inspiring ambient mode. Layered oklch
+ * radial gradients build a deep violet void seeded with drifting magenta/cyan
+ * nebula clouds and a faint distant galaxy spiral. A parallax starfield sits at
+ * two depths (a dense field of tiny twinkling stars plus a handful of brighter
+ * hero stars), and slow comet streaks cross the sky occasionally.
+ *
+ * Palette (oklch): deep cosmic violet oklch(0.2 0.12 300), nebula magenta
+ * oklch(0.55 0.2 330), cyan oklch(0.75 0.13 200), starlight white
+ * oklch(0.98 0.02 280).
+ *
+ * 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 still
+ * nebula, a static starfield, a frozen galaxy and one frozen comet streak) with
+ * no `animation` at all. The settings preview always passes reduced=true.
+ */
+
+const STAR_TINTS = [
+ 'oklch(0.98 0.02 280)', // starlight white
+ 'oklch(0.9 0.07 230)', // cool cyan-white
+ 'oklch(0.88 0.08 330)', // faint magenta-white
+] as const;
+
+const HERO_TINTS = [
+ 'oklch(0.92 0.06 200)', // cyan starlight
+ 'oklch(0.9 0.09 330)', // magenta starlight
+ 'oklch(0.98 0.02 280)', // pure starlight
+] as const;
+
+type Star = {
+ top: number;
+ left: number;
+ size: number;
+ color: string;
+ duration: number;
+ delay: number;
+ staticOpacity: number;
+};
+
+type HeroStar = Star;
+
+type Comet = {
+ top: number;
+ left: number;
+ length: number;
+ angle: 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 gleam (sparkle) as an inline SVG data-URI — CSP-safe, no assets.
+const gleamUri = (color: string) =>
+ `url("data:image/svg+xml,${encodeURIComponent(
+ ` `,
+ )}")`;
+
+function makeStars(count: number, seedBase: number): Star[] {
+ return Array.from({ length: count }, (_, i) => {
+ const s = seedBase + i;
+ return {
+ top: rand(s + 1) * 100,
+ left: rand(s + 101) * 100,
+ size: 1 + Math.floor(rand(s + 201) * 2), // 1–2px tiny stars
+ color: STAR_TINTS[i % STAR_TINTS.length],
+ duration: 2.6 + rand(s + 301) * 3.4,
+ delay: rand(s + 401) * 5,
+ staticOpacity: 0.4 + rand(s + 501) * 0.55,
+ };
+ });
+}
+
+export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
+ // Two parallax depths. Far = dense + faint, Near = sparser + slightly larger.
+ const farStars = useMemo(() => makeStars(16, 1000), []);
+ const nearStars = useMemo(() => makeStars(12, 2000), []);
+
+ const heroStars = useMemo(
+ () =>
+ Array.from({ length: 6 }, (_, i) => {
+ const s = 3000 + i;
+ return {
+ top: 6 + rand(s + 1) * 78,
+ left: 6 + rand(s + 101) * 88,
+ size: 9 + Math.floor(rand(s + 201) * 9), // 9–17px gleams
+ color: HERO_TINTS[i % HERO_TINTS.length],
+ duration: 4 + rand(s + 301) * 4,
+ delay: rand(s + 401) * 5,
+ staticOpacity: 0.85,
+ };
+ }),
+ [],
+ );
+
+ const comets = useMemo(
+ () =>
+ Array.from({ length: 3 }, (_, i) => {
+ const s = 4000 + i;
+ return {
+ top: 8 + rand(s + 1) * 44,
+ left: -10 + rand(s + 101) * 30,
+ length: 120 + Math.floor(rand(s + 201) * 120),
+ angle: 18 + rand(s + 301) * 16, // gentle downward diagonal
+ color: i % 2 === 0 ? 'oklch(0.92 0.06 200)' : 'oklch(0.9 0.09 330)',
+ duration: 7 + rand(s + 401) * 5,
+ delay: 2 + i * 6 + rand(s + 501) * 4,
+ };
+ }),
+ [],
+ );
+
+ return (
+ <>
+ {/* Deep cosmic void — layered oklch radial gradients for depth. A barely
+ perceptible drift gives the whole field life without distraction. */}
+
+
+ {/* Drifting nebula clouds — blurred radial gradients in magenta + cyan. */}
+
+
+ {/* A third, central violet wash to bind the two color clouds together. */}
+
+
+ {/* Faint distant galaxy spiral — an inline conic-ish swirl from layered
+ radial gradients, blurred and very slowly rotating. */}
+
+
+ {/* Far parallax starfield — dense, faint, tiny twinkling stars. */}
+
+ {farStars.map((s, i) => (
+
+ ))}
+
+
+ {/* Near parallax starfield — sparser, brighter, drifts a touch faster. */}
+
+ {nearStars.map((s, i) => (
+
+ ))}
+
+
+ {/* Hero stars — a few bright four-point gleams that pulse slowly. */}
+ {heroStars.map((s, i) => (
+
+ ))}
+
+ {/* Comet / warp streaks. In reduced mode, freeze a single streak mid-flight
+ so the static thumbnail reads as a living cosmos. */}
+ {(reduced ? comets.slice(0, 1) : comets).map((c, i) => (
+
+ ))}
+ >
+ );
+}
diff --git a/src/app/components/seasonal/themes/EarthDay.css.ts b/src/app/components/seasonal/themes/EarthDay.css.ts
new file mode 100644
index 000000000..346f13cc0
--- /dev/null
+++ b/src/app/components/seasonal/themes/EarthDay.css.ts
@@ -0,0 +1,70 @@
+import { keyframes } from '@vanilla-extract/css';
+
+/**
+ * Earth Day overlay keyframes. Every animation touches ONLY `transform` and
+ * `opacity` so the compositor can run them on the GPU — no layout/paint thrash.
+ * keyframes() returns the generated animation-name string, applied inline.
+ *
+ * Motif: verdant, hopeful nature. Leaves tumble, seeds/spores drift, pollen
+ * motes glow and pulse, soft sun rays breathe from above, the blue-marble
+ * Earth gently respires in a corner.
+ */
+
+/** Falling leaf: tumbles down with a wide pendular sway and slow spin. */
+export const animLeafTumble = keyframes({
+ '0%': { transform: 'translate3d(0, -10vh, 0) rotate(-18deg)', opacity: '0' },
+ '8%': { opacity: '0.7' },
+ '28%': { transform: 'translate3d(4vw, 22vh, 0) rotate(60deg)' },
+ '52%': { transform: 'translate3d(-3vw, 48vh, 0) rotate(165deg)' },
+ '76%': { transform: 'translate3d(5vw, 74vh, 0) rotate(280deg)' },
+ '90%': { opacity: '0.5' },
+ '100%': { transform: 'translate3d(1vw, 112vh, 0) rotate(360deg)', opacity: '0' },
+});
+
+/** Tiny seed / spore: drifts slowly downward, swaying like dandelion fluff. */
+export const animSeedDrift = keyframes({
+ '0%': { transform: 'translate3d(0, -6vh, 0) rotate(0deg)', opacity: '0' },
+ '12%': { opacity: '0.55' },
+ '40%': { transform: 'translate3d(3vw, 34vh, 0) rotate(140deg)' },
+ '70%': { transform: 'translate3d(-2.5vw, 64vh, 0) rotate(250deg)' },
+ '88%': { opacity: '0.4' },
+ '100%': { transform: 'translate3d(2vw, 110vh, 0) rotate(360deg)', opacity: '0' },
+});
+
+/** Pollen mote: floats gently upward in a soft serpentine path. */
+export const animPollenFloat = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0) scale(0.75)', opacity: '0' },
+ '14%': { opacity: '0.9' },
+ '38%': { transform: 'translate3d(10px, -22vh, 0) scale(1)' },
+ '64%': { transform: 'translate3d(-10px, -46vh, 0) scale(0.92)', opacity: '0.7' },
+ '90%': { opacity: '0.2' },
+ '100%': { transform: 'translate3d(6px, -72vh, 0) scale(0.7)', opacity: '0' },
+});
+
+/** Soft brightness twinkle layered on each pollen mote's glow. */
+export const animPollenGlow = keyframes({
+ '0%': { opacity: '0.55' },
+ '50%': { opacity: '1' },
+ '100%': { opacity: '0.55' },
+});
+
+/** Sun rays from above: slow breathing of opacity + a faint scale shimmer. */
+export const animRayBreathe = keyframes({
+ '0%': { transform: 'scaleY(1)', opacity: '0.4' },
+ '50%': { transform: 'scaleY(1.05)', opacity: '0.7' },
+ '100%': { transform: 'scaleY(1)', opacity: '0.4' },
+});
+
+/** Green aurora veil: a wide, slow horizontal sway with a gentle swell. */
+export const animAuroraSway = keyframes({
+ '0%': { transform: 'translate3d(-6%, 0, 0) scale(1.1)', opacity: '0.45' },
+ '50%': { transform: 'translate3d(6%, -2%, 0) scale(1.2)', opacity: '0.7' },
+ '100%': { transform: 'translate3d(-6%, 0, 0) scale(1.1)', opacity: '0.45' },
+});
+
+/** Blue-marble Earth: a barely-perceptible respiration of its halo. */
+export const animEarthRespire = keyframes({
+ '0%': { transform: 'scale(1)', opacity: '0.85' },
+ '50%': { transform: 'scale(1.04)', opacity: '1' },
+ '100%': { transform: 'scale(1)', opacity: '0.85' },
+});
diff --git a/src/app/components/seasonal/themes/EarthDay.tsx b/src/app/components/seasonal/themes/EarthDay.tsx
new file mode 100644
index 000000000..b7b237914
--- /dev/null
+++ b/src/app/components/seasonal/themes/EarthDay.tsx
@@ -0,0 +1,319 @@
+import React, { useMemo } from 'react';
+import { SeasonalOverlayProps } from '../types';
+import {
+ animLeafTumble,
+ animSeedDrift,
+ animPollenFloat,
+ animPollenGlow,
+ animRayBreathe,
+ animAuroraSway,
+ animEarthRespire,
+} from './EarthDay.css';
+
+// ─── Palette (oklch) ──────────────────────────────────────────────────────────
+// Verdant, hopeful nature: living leaf greens, soft sky + deep ocean blues,
+// and a warm sun highlight. Kept low-alpha so chat text stays WCAG-AA legible.
+const LEAF_GREEN = 'oklch(0.65 0.15 145)';
+const LEAF_DEEP = 'oklch(0.52 0.14 150)';
+const LEAF_LIME = 'oklch(0.78 0.16 130)';
+const SKY_BLUE = 'oklch(0.70 0.10 230)';
+const OCEAN_BLUE = 'oklch(0.55 0.12 240)';
+const SUN_WARM = 'oklch(0.92 0.10 95)';
+const POLLEN_GOLD = 'oklch(0.88 0.13 95)';
+
+// Soft, translucent tints for the ambient gradient washes.
+const LEAF_GREEN_SOFT = 'oklch(0.65 0.15 145 / 0.10)';
+const LEAF_LIME_SOFT = 'oklch(0.78 0.16 130 / 0.08)';
+const SKY_BLUE_SOFT = 'oklch(0.70 0.10 230 / 0.07)';
+const SUN_SOFT = 'oklch(0.92 0.10 95 / 0.10)';
+const AURORA_TINT = 'oklch(0.74 0.16 155 / 0.22)';
+
+// ─── Inline SVG leaf, drawn once (CSP-safe data-URI, no external assets) ───────
+// A simple veined leaf silhouette. Color is baked per-variant so we can tint
+// individual falling leaves without a runtime filter.
+function leafUri(fill: string, vein: string): string {
+ const svg =
+ `` +
+ ` ` +
+ ` ` +
+ ` `;
+ return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
+}
+
+// Three leaf tints, generated once at module load.
+const LEAF_URIS = [
+ leafUri('oklch(0.65 0.15 145 / 0.9)', 'oklch(0.40 0.10 150 / 0.7)'),
+ leafUri('oklch(0.78 0.16 130 / 0.9)', 'oklch(0.50 0.12 140 / 0.7)'),
+ leafUri('oklch(0.52 0.14 150 / 0.9)', 'oklch(0.34 0.08 155 / 0.7)'),
+];
+
+export function EarthDayOverlay({ reduced }: SeasonalOverlayProps) {
+ // ── Deterministic per-mount generation — never per-frame React state. ──
+
+ // Tumbling leaves (the heaviest motif → kept modest).
+ const leaves = useMemo(
+ () =>
+ Array.from({ length: 10 }, (_, i) => ({
+ left: (i * 6173 + 137) % 96,
+ size: 16 + (i % 4) * 6,
+ duration: 16 + (i % 5) * 2.5,
+ delay: (i * 1.7) % 16,
+ uri: LEAF_URIS[i % LEAF_URIS.length],
+ opacity: 0.45 + (i % 3) * 0.12,
+ })),
+ [],
+ );
+
+ // Tiny drifting seeds / spores — small, faint, slow.
+ const seeds = useMemo(
+ () =>
+ Array.from({ length: 8 }, (_, i) => ({
+ left: (i * 4099 + 53) % 98,
+ size: 2 + (i % 2),
+ duration: 18 + (i % 4) * 3,
+ delay: (i * 2.3) % 18,
+ })),
+ [],
+ );
+
+ // Glowing pollen motes rising from below, catching the light.
+ const pollen = useMemo(
+ () =>
+ Array.from({ length: 12 }, (_, i) => ({
+ left: (i * 5279 + 89) % 100,
+ bottom: (i * 2731 + 31) % 32,
+ size: 3 + (i % 3),
+ duration: 13 + (i % 6) * 2,
+ delay: (i * 0.9) % 13,
+ twinkle: 2.6 + (i % 5) * 0.5,
+ })),
+ [],
+ );
+
+ // Sun rays fanning down from the top — a few soft angled beams.
+ const rays = useMemo(
+ () =>
+ Array.from({ length: 5 }, (_, i) => ({
+ left: 12 + i * 18,
+ rotate: -14 + i * 7,
+ width: 60 + (i % 3) * 26,
+ duration: 8 + (i % 3) * 2,
+ delay: i * 1.3,
+ opacity: 0.32 + (i % 3) * 0.08,
+ })),
+ [],
+ );
+
+ return (
+ <>
+ {/* ── Base wash: layered green/sky gradients for verdant depth ── */}
+
+
+ {/* ── Green aurora veil drifting near the top ── */}
+
+
+ {/* ── Soft sun rays fanning down from above ── */}
+
+ {rays.map((r, i) => (
+
+ ))}
+
+
+ {/* ── Blue-marble Earth tucked into the bottom-right corner ── */}
+
+ {/* atmospheric rim halo */}
+
+ {/* the globe itself — oceans, land, soft terminator shadow */}
+
+
+
+ {/* ── Rising, glowing pollen motes ── */}
+ {pollen.map((p, i) => (
+
+ ))}
+
+ {/* ── Drifting seeds / spores (skip entirely when reduced) ── */}
+ {!reduced &&
+ seeds.map((s, i) => (
+
+ ))}
+
+ {/* ── Tumbling leaves ── */}
+ {leaves.map((l, i) => (
+
+ ))}
+ >
+ );
+}
diff --git a/src/app/components/seasonal/themes/Halloween.css.ts b/src/app/components/seasonal/themes/Halloween.css.ts
new file mode 100644
index 000000000..cd8897e12
--- /dev/null
+++ b/src/app/components/seasonal/themes/Halloween.css.ts
@@ -0,0 +1,56 @@
+import { keyframes } from '@vanilla-extract/css';
+
+/**
+ * Halloween overlay keyframes. Every animation touches ONLY `transform` and
+ * `opacity` so the compositor can run them on the GPU without layout/paint.
+ * keyframes() returns the generated animation-name string, applied inline.
+ */
+
+/** Slow breathing of the sickly moon-glow vignette. */
+export const animMoonPulse = keyframes({
+ '0%': { transform: 'scale(1)', opacity: '0.55' },
+ '50%': { transform: 'scale(1.06)', opacity: '0.8' },
+ '100%': { transform: 'scale(1)', opacity: '0.55' },
+});
+
+/** Low fog band: drifts sideways while gently rising and swelling. */
+export const animFogDrift = keyframes({
+ '0%': { transform: 'translate3d(-12%, 6%, 0) scale(1.1)', opacity: '0' },
+ '15%': { opacity: '0.5' },
+ '50%': { transform: 'translate3d(6%, -2%, 0) scale(1.25)', opacity: '0.65' },
+ '85%': { opacity: '0.45' },
+ '100%': { transform: 'translate3d(18%, 4%, 0) scale(1.1)', opacity: '0' },
+});
+
+/** A bat flaps slowly across the sky in a shallow arc. */
+export const animBatGlide = keyframes({
+ '0%': { transform: 'translate3d(-12vw, 8vh, 0) scale(0.9)', opacity: '0' },
+ '10%': { opacity: '0.7' },
+ '45%': { transform: 'translate3d(45vw, -4vh, 0) scale(1)' },
+ '80%': { transform: 'translate3d(85vw, 6vh, 0) scale(0.95)', opacity: '0.6' },
+ '100%': { transform: 'translate3d(112vw, 2vh, 0) scale(0.9)', opacity: '0' },
+});
+
+/** The bat's wings beat — fast vertical squash of the wing element. */
+export const animWingFlap = keyframes({
+ '0%': { transform: 'scaleY(1) scaleX(1)' },
+ '50%': { transform: 'scaleY(0.35) scaleX(1.08)' },
+ '100%': { transform: 'scaleY(1) scaleX(1)' },
+});
+
+/** Will-o'-wisp ember: floats upward, swaying, pulsing in brightness. */
+export const animEmberFloat = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' },
+ '12%': { opacity: '0.85' },
+ '35%': { transform: 'translate3d(14px, -28vh, 0) scale(1)' },
+ '65%': { transform: 'translate3d(-12px, -55vh, 0) scale(0.9)', opacity: '0.7' },
+ '90%': { opacity: '0.25' },
+ '100%': { transform: 'translate3d(8px, -82vh, 0) scale(0.6)', opacity: '0' },
+});
+
+/** Soft twinkle for embers — independent opacity flicker layered on top. */
+export const animEmberTwinkle = keyframes({
+ '0%': { opacity: '0.6' },
+ '50%': { opacity: '1' },
+ '100%': { opacity: '0.6' },
+});
diff --git a/src/app/components/seasonal/themes/Halloween.tsx b/src/app/components/seasonal/themes/Halloween.tsx
new file mode 100644
index 000000000..e46fe7b2a
--- /dev/null
+++ b/src/app/components/seasonal/themes/Halloween.tsx
@@ -0,0 +1,267 @@
+import React, { useMemo } from 'react';
+import { SeasonalOverlayProps } from '../types';
+import {
+ animMoonPulse,
+ animFogDrift,
+ animBatGlide,
+ animWingFlap,
+ animEmberFloat,
+ animEmberTwinkle,
+} from './Halloween.css';
+
+// ─── Palette (oklch) ──────────────────────────────────────────────────────────
+// Deep haunted indigo, sickly toxic-green moon glow, warm ember orange.
+const PURPLE_DEEP = 'oklch(0.20 0.12 300)';
+const PURPLE_FAINT = 'oklch(0.28 0.10 300 / 0.45)';
+const TOXIC_GREEN = 'oklch(0.80 0.18 150)';
+const TOXIC_GREEN_SOFT = 'oklch(0.72 0.16 150 / 0.35)';
+const EMBER_ORANGE = 'oklch(0.70 0.18 50)';
+const FOG_TINT = 'oklch(0.45 0.06 280 / 0.32)';
+
+// A corner cobweb, drawn once as an inline SVG data-URI (CSP-safe, no assets).
+// strokeWidth kept hairline so it reads as gossamer thread, not a cage.
+const cobwebUri = (() => {
+ const svg =
+ `` +
+ `` +
+ // radial threads
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ // concentric catch-threads (gentle sag via quadratic curves)
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` `;
+ return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
+})();
+
+// A single silhouetted bat, inline SVG. Wings are separate so the wrapper can
+// glide while an inner element flaps independently — we re-use one body shape.
+function BatSilhouette() {
+ return (
+
+
+
+ );
+}
+
+export function HalloweenOverlay({ reduced }: SeasonalOverlayProps) {
+ // Deterministic per-mount generation — never per-frame React state.
+ const embers = useMemo(
+ () =>
+ Array.from({ length: 12 }, (_, i) => {
+ const green = i % 3 === 0; // ~1/3 toxic-green wisps, rest warm embers
+ return {
+ left: (i * 6151 + 113) % 100,
+ bottom: (i * 3137 + 47) % 28, // start near floor
+ size: 3 + (i % 4),
+ duration: 11 + (i % 6) * 2.2,
+ delay: (i * 0.83) % 11,
+ twinkle: 2.4 + (i % 5) * 0.6,
+ color: green ? TOXIC_GREEN : EMBER_ORANGE,
+ };
+ }),
+ [],
+ );
+
+ const bats = useMemo(
+ () =>
+ Array.from({ length: 3 }, (_, i) => ({
+ top: 8 + i * 13,
+ duration: 22 + i * 7,
+ delay: i * 6.5,
+ flap: 0.5 + i * 0.12,
+ scale: 0.7 + i * 0.18,
+ })),
+ [],
+ );
+
+ const fogBands = useMemo(
+ () =>
+ Array.from({ length: 3 }, (_, i) => ({
+ bottom: -6 + i * 9,
+ duration: 26 + i * 8,
+ delay: i * 5,
+ height: 130 + i * 30,
+ })),
+ [],
+ );
+
+ return (
+ <>
+ {/* ── Sky: layered indigo→black gradient with toxic-green moon vignette ── */}
+
+
+ {/* ── Moon disc + breathing halo (the only backdrop-filter, kept cheap) ── */}
+
+
+ {/* ── Low drifting fog bands ── */}
+
+ {fogBands.map((f, i) => (
+
+ ))}
+
+
+ {/* ── Will-o'-wisps / floating embers ── */}
+ {embers.map((e, i) => (
+
+ ))}
+
+ {/* ── Silhouetted bats gliding across (skip entirely when reduced) ── */}
+ {!reduced &&
+ bats.map((b, i) => (
+
+ ))}
+
+ {/* ── Cobwebs tucked into two corners (top-left, top-right mirrored) ── */}
+
+
+ >
+ );
+}
diff --git a/src/app/components/seasonal/themes/LunarNewYear.css.ts b/src/app/components/seasonal/themes/LunarNewYear.css.ts
new file mode 100644
index 000000000..a86cb5adb
--- /dev/null
+++ b/src/app/components/seasonal/themes/LunarNewYear.css.ts
@@ -0,0 +1,109 @@
+import { keyframes } from '@vanilla-extract/css';
+
+/**
+ * Lunar New Year overlay keyframes — red paper lanterns, drifting gold plum
+ * blossoms, and a coiling dragon. Every animation touches ONLY `transform` and
+ * `opacity`, so the compositor runs them on the GPU with zero layout/paint.
+ * keyframes() returns the generated animation-name string, applied inline by the
+ * component. Static structure (gradients, SVG data-URIs, geometry) lives in the
+ * component; this module is motion only.
+ */
+
+/**
+ * Lantern bob — a hung lantern rises a touch and sinks again on a long, lazy
+ * cycle, as if buoyed by warm air. translateY + a whisper of scale only; the
+ * per-lantern duration/delay desynchronise the swarm.
+ */
+export const animLanternBob = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0) scale(1)' },
+ '50%': { transform: 'translate3d(0, -2.2vh, 0) scale(1.015)' },
+ '100%': { transform: 'translate3d(0, 0, 0) scale(1)' },
+});
+
+/**
+ * Lantern pendulum — a gentle rotational sway about the top mount, so each
+ * lantern rocks like it hangs from a string. Pairs with the bob on a different
+ * period to read as organic drift rather than a metronome.
+ */
+export const animLanternSway = keyframes({
+ '0%': { transform: 'rotate(-2.4deg)' },
+ '50%': { transform: 'rotate(2.4deg)' },
+ '100%': { transform: 'rotate(-2.4deg)' },
+});
+
+/**
+ * Tassel sway — the silk tassel under a lantern trails its parent's motion with
+ * a wider, slightly lagging swing. transformOrigin is the top of the tassel.
+ */
+export const animTasselSway = keyframes({
+ '0%': { transform: 'rotate(5deg)' },
+ '50%': { transform: 'rotate(-5deg)' },
+ '100%': { transform: 'rotate(5deg)' },
+});
+
+/**
+ * Lantern inner glow — the warm light inside each lantern swells and dims, like
+ * a candle breathing. Opacity + scale only.
+ */
+export const animGlowBreathe = keyframes({
+ '0%': { transform: 'scale(0.94)', opacity: '0.55' },
+ '50%': { transform: 'scale(1.06)', opacity: '0.9' },
+ '100%': { transform: 'scale(0.94)', opacity: '0.55' },
+});
+
+/**
+ * Petal drift — a gold plum-blossom petal falls the full height while spinning
+ * and swaying. A tall translateY lets one keyframe set serve every petal;
+ * per-petal duration/delay/scale create the parallax variety.
+ */
+export const animPetalFall = keyframes({
+ '0%': { transform: 'translate3d(0, -10vh, 0) rotateZ(0deg) rotateY(0deg)', opacity: '0' },
+ '10%': { opacity: '0.9' },
+ '50%': { transform: 'translate3d(3vw, 52vh, 0) rotateZ(190deg) rotateY(180deg)' },
+ '90%': { opacity: '0.8' },
+ '100%': {
+ transform: 'translate3d(-2.4vw, 114vh, 0) rotateZ(380deg) rotateY(360deg)',
+ opacity: '0',
+ },
+});
+
+/**
+ * Lateral petal sway on the wrapper, decoupled from the fall so the two combine
+ * into an organic wind-borne path rather than a straight drop.
+ */
+export const animPetalSway = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0)' },
+ '50%': { transform: 'translate3d(2.8vw, 0, 0)' },
+ '100%': { transform: 'translate3d(0, 0, 0)' },
+});
+
+/**
+ * Dragon drift — the gold dragon silhouette breathes and undulates almost
+ * imperceptibly across the scene. translate + scale + opacity only, very slow.
+ */
+export const animDragonDrift = keyframes({
+ '0%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
+ '50%': { transform: 'translate3d(2%, -1%, 0) scale(1.04)', opacity: '0.6' },
+ '100%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
+});
+
+/**
+ * Lacquer-tint breathing — a barely-there pulse of the warm red ambient wash so
+ * the static base feels alive without distracting motion.
+ */
+export const animLacquerPulse = keyframes({
+ '0%': { opacity: '0.82' },
+ '50%': { opacity: '1' },
+ '100%': { opacity: '0.82' },
+});
+
+/**
+ * Gold ember rise — tiny sparks of lantern light float gently upward and fade,
+ * like motes drifting off the flames. translateY + opacity only.
+ */
+export const animEmberRise = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0) scale(0.6)', opacity: '0' },
+ '15%': { opacity: '0.85' },
+ '80%': { opacity: '0.5' },
+ '100%': { transform: 'translate3d(0.6vw, -26vh, 0) scale(1)', opacity: '0' },
+});
diff --git a/src/app/components/seasonal/themes/LunarNewYear.tsx b/src/app/components/seasonal/themes/LunarNewYear.tsx
new file mode 100644
index 000000000..409f0997e
--- /dev/null
+++ b/src/app/components/seasonal/themes/LunarNewYear.tsx
@@ -0,0 +1,483 @@
+import React, { useMemo } from 'react';
+import { SeasonalOverlayProps } from '../types';
+import {
+ animLanternBob,
+ animLanternSway,
+ animTasselSway,
+ animGlowBreathe,
+ animPetalFall,
+ animPetalSway,
+ animDragonDrift,
+ animLacquerPulse,
+ animEmberRise,
+} from './LunarNewYear.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): number => {
+ const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
+ return x - Math.floor(x);
+};
+
+// Core oklch palette — auspicious crimson/vermilion lanterns, imperial gold
+// trim and blossoms, over a deep lacquer-red ambient tint. Kept luminous and
+// gentle so everything reads as soft ambient glow, never solid paint.
+const CRIMSON = 'oklch(0.50 0.20 25)';
+const VERMILION = 'oklch(0.58 0.21 30)';
+const GOLD = 'oklch(0.82 0.14 85)';
+const GOLD_HI = 'oklch(0.92 0.10 92)';
+
+// A coiling dragon silhouette in imperial gold, rendered once as an inline SVG
+// data-URI so it costs a single GPU-composited layer (no DOM weight). The curve
+// is intentionally abstract and very subtle — a calligraphic ribbon-body with a
+// suggestion of a head, mane and tail arcing across the upper scene.
+const dragonUri = ((): string => {
+ const svg =
+ `` +
+ `` +
+ `` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ `` +
+ // Sinuous body — a thick tapering serpentine ribbon.
+ ` ` +
+ // Inner highlight running along the body for a calligraphic sheen.
+ ` ` +
+ // Head + horn flourish at the leading end.
+ ` ` +
+ // Mane / whisker strokes flaring back from the head.
+ ` ` +
+ // Tail wisps.
+ ` ` +
+ ` `;
+ return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
+})();
+
+type Lantern = {
+ left: number;
+ top: number;
+ scale: number;
+ bobDuration: number;
+ swayDuration: number;
+ delay: number;
+ opacity: number;
+};
+
+type Petal = {
+ left: number;
+ size: number;
+ duration: number;
+ delay: number;
+ swayDuration: number;
+ opacity: number;
+ blur: number;
+ hue: number;
+};
+
+type Ember = {
+ left: number;
+ bottom: number;
+ size: number;
+ duration: number;
+ delay: number;
+};
+
+// A single five-petal plum blossom (gold), inline SVG so each petal sliver is
+// one cheap element. Returned as a data-URI background painted on a square.
+const blossomUri = ((): string => {
+ const petals = Array.from({ length: 5 }, (_, i) => {
+ const a = (i * 72 * Math.PI) / 180;
+ const cx = 16 + Math.cos(a - Math.PI / 2) * 8;
+ const cy = 16 + Math.sin(a - Math.PI / 2) * 8;
+ return ` `;
+ }).join('');
+ const svg =
+ `` +
+ `${petals} ` +
+ ` ` +
+ ` `;
+ return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
+})();
+
+export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
+ // Paper lanterns strung across the upper third, gently staggered in depth.
+ const lanterns = useMemo(() => {
+ const slots = [
+ { left: 9, top: 7, scale: 1.0 },
+ { left: 27, top: 13, scale: 0.82 },
+ { left: 46, top: 6, scale: 1.12 },
+ { left: 64, top: 15, scale: 0.78 },
+ { left: 82, top: 9, scale: 0.95 },
+ { left: 92, top: 20, scale: 0.7 },
+ ];
+ return slots.map((s, i) => ({
+ left: s.left,
+ top: s.top,
+ scale: s.scale,
+ bobDuration: 7 + rand(i + 1) * 4,
+ swayDuration: 5.5 + rand(i + 4) * 3,
+ delay: -rand(i + 7) * 6,
+ opacity: 0.78 + rand(i + 2) * 0.18,
+ }));
+ }, []);
+
+ // Drifting gold plum-blossom petals — two parallax bands (far small/dim/slow,
+ // near large/bright/fast) for depth.
+ const petals = useMemo(() => {
+ const bands = [
+ { count: 9, size: [9, 14], dur: [15, 21], op: [0.4, 0.6], blur: 0.6 },
+ { count: 8, size: [15, 24], dur: [10, 14], op: [0.6, 0.85], blur: 0 },
+ ];
+ const out: Petal[] = [];
+ 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);
+ 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] + 4),
+ swayDuration: 5 + r2 * 5,
+ opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
+ blur: b.blur,
+ hue: 82 + r4 * 10,
+ });
+ s += 1;
+ }
+ });
+ return out;
+ }, []);
+
+ // A few gold embers rising from the lanterns (motion scene only).
+ const embers = useMemo(
+ () =>
+ Array.from({ length: 7 }, (_, i) => ({
+ left: 8 + rand(i + 11) * 84,
+ bottom: 8 + rand(i + 21) * 30,
+ size: 1.6 + rand(i + 31) * 2.2,
+ duration: 9 + rand(i + 41) * 6,
+ delay: -rand(i + 51) * 12,
+ })),
+ [],
+ );
+
+ return (
+ <>
+ {/* Deep lacquer-red ambient wash — layered radial + linear oklch gradients
+ for depth and a warm crimson lantern-glow from above. Low-opacity so
+ chat text stays legible (WCAG-AA). */}
+
+
+ {/* Imperial-gold dragon silhouette arcing across the upper scene — a
+ single composited SVG layer, blurred and screen-blended so it reads as
+ an ethereal gilt apparition, never a hard graphic. */}
+
+
+ {/* Warm vignette frame — crimson edges, clear center, with a faint cheap
+ backdrop-filter for a silken haze around the rim. */}
+
+
+ {/* The garland string the lanterns hang from — a faint warm line. */}
+
+
+ {/* Paper lanterns. Each is a hung group: a sway wrapper rotating about its
+ mount, an inner bob, then the lantern body (glow + ribs + caps) and a
+ trailing tassel. */}
+ {lanterns.map((l, i) => {
+ const W = 30 * l.scale;
+ const H = 38 * l.scale;
+ const cap = Math.max(8, W * 0.5);
+ return (
+
+
+ {/* short cord from the string to the top cap */}
+
+ {/* top gold cap */}
+
+ {/* lantern body */}
+
+ {/* breathing inner candle glow */}
+
+ {/* vertical paper ribs */}
+
+
+ {/* bottom gold cap */}
+
+ {/* swaying silk tassel */}
+
+
+
+ );
+ })}
+
+ {/* Drifting gold plum-blossom petals (motion only). Static settled
+ blossoms render below for the reduced/preview scene. */}
+ {!reduced &&
+ petals.map((p, i) => (
+
+ ))}
+
+ {/* Static settled blossoms for the reduced-motion / preview scene — a
+ serene scatter so the thumbnail still reads as a blossom drift. */}
+ {reduced &&
+ petals.slice(0, 12).map((p, i) => {
+ const py = rand(i + 0.5) * 92 + 4;
+ return (
+
+ );
+ })}
+
+ {/* Gold embers rising off the lanterns (motion only). */}
+ {!reduced &&
+ embers.map((e, i) => (
+
+ ))}
+ >
+ );
+}
diff --git a/src/app/components/seasonal/themes/NewYear.css.ts b/src/app/components/seasonal/themes/NewYear.css.ts
new file mode 100644
index 000000000..590c33d77
--- /dev/null
+++ b/src/app/components/seasonal/themes/NewYear.css.ts
@@ -0,0 +1,87 @@
+import { keyframes } from '@vanilla-extract/css';
+
+/**
+ * New Year overlay keyframes — a midnight celebration. Every animation touches
+ * ONLY `transform` and `opacity` so the compositor runs them on the GPU with no
+ * layout/paint. keyframes() returns the generated animation-name string, which
+ * is applied inline by the component. Heavy/static structure (gradients, SVG
+ * data-URIs, geometry) lives in the component; this module is motion only.
+ */
+
+/**
+ * Firework burst — a thin spark ring expands from a pinpoint, brightens, then
+ * fades as it grows. Scale + opacity only; the ring is a radial-gradient border
+ * supplied inline. Long pauses between bursts come from a low keyframe-duty:
+ * the ring spends most of the cycle collapsed and invisible.
+ */
+export const animBurst = keyframes({
+ '0%': { transform: 'scale(0.05)', opacity: '0' },
+ '4%': { transform: 'scale(0.12)', opacity: '0.95' },
+ '22%': { transform: 'scale(1)', opacity: '0.55' },
+ '34%': { transform: 'scale(1.25)', opacity: '0' },
+ '100%': { transform: 'scale(1.25)', opacity: '0' },
+});
+
+/**
+ * Burst core flash — the bright pinpoint at a firework's origin pops just before
+ * the ring blooms, then quickly dims. Pairs with animBurst on the same cadence.
+ */
+export const animCoreFlash = keyframes({
+ '0%': { transform: 'scale(0.2)', opacity: '0' },
+ '3%': { transform: 'scale(1)', opacity: '1' },
+ '14%': { transform: 'scale(0.6)', opacity: '0' },
+ '100%': { transform: 'scale(0.6)', opacity: '0' },
+});
+
+/**
+ * Champagne shimmer sweep — a wide soft gold band glides diagonally across the
+ * scene and breathes in brightness. translateX + opacity (never
+ * background-position) keep it on the compositor.
+ */
+export const animShimmer = keyframes({
+ '0%': { transform: 'translate3d(-120%, 0, 0) skewX(-12deg)', opacity: '0' },
+ '12%': { opacity: '0.7' },
+ '50%': { opacity: '0.5' },
+ '88%': { opacity: '0.6' },
+ '100%': { transform: 'translate3d(120%, 0, 0) skewX(-12deg)', opacity: '0' },
+});
+
+/**
+ * Confetti fall — a small sliver tumbles the full height while spinning on two
+ * axes, fading in at the top and out at the bottom. A tall translateY lets one
+ * keyframe set serve every sliver; per-piece duration/delay/scale add variety.
+ */
+export const animConfettiFall = keyframes({
+ '0%': { transform: 'translate3d(0, -10vh, 0) rotateZ(0deg) rotateX(0deg)', opacity: '0' },
+ '8%': { opacity: '0.9' },
+ '50%': { transform: 'translate3d(2.2vw, 52vh, 0) rotateZ(220deg) rotateX(180deg)' },
+ '92%': { opacity: '0.85' },
+ '100%': {
+ transform: 'translate3d(-1.8vw, 114vh, 0) rotateZ(440deg) rotateX(360deg)',
+ opacity: '0',
+ },
+});
+
+/**
+ * Lateral confetti sway on the wrapper, decoupled from the fall so the two
+ * combine into an organic drifting path rather than a straight drop.
+ */
+export const animConfettiSway = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0)' },
+ '50%': { transform: 'translate3d(2.4vw, 0, 0)' },
+ '100%': { transform: 'translate3d(0, 0, 0)' },
+});
+
+/** Star twinkle — a sparkle pulses in brightness and size, like a glint. */
+export const animTwinkle = keyframes({
+ '0%': { transform: 'scale(0.5) rotate(0deg)', opacity: '0.2' },
+ '50%': { transform: 'scale(1) rotate(45deg)', opacity: '0.95' },
+ '100%': { transform: 'scale(0.5) rotate(0deg)', opacity: '0.2' },
+});
+
+/** Barely-there breathing of the midnight tint so the static base feels alive. */
+export const animSkyPulse = keyframes({
+ '0%': { opacity: '0.82' },
+ '50%': { opacity: '1' },
+ '100%': { opacity: '0.82' },
+});
diff --git a/src/app/components/seasonal/themes/NewYear.tsx b/src/app/components/seasonal/themes/NewYear.tsx
new file mode 100644
index 000000000..782704951
--- /dev/null
+++ b/src/app/components/seasonal/themes/NewYear.tsx
@@ -0,0 +1,303 @@
+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(
+ ` `,
+ )}")`;
+
+export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
+ const bursts = useMemo(
+ () =>
+ // 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(
+ () =>
+ 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(
+ () =>
+ 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. */}
+
+
+ {/* Champagne-gold shimmer sweep. */}
+
+
+ {/* 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) => (
+
+ {/* Spark ring */}
+
+ {/* Inner secondary ring for a fuller bloom */}
+
+ {/* Core flash */}
+
+
+ ))}
+
+ {/* Twinkling sparkle stars. */}
+ {stars.map((s, i) => (
+
+ ))}
+
+ {/* 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 (
+
+ );
+ })}
+ >
+ );
+}
diff --git a/src/app/components/seasonal/themes/StPatricks.css.ts b/src/app/components/seasonal/themes/StPatricks.css.ts
new file mode 100644
index 000000000..7df27b1c4
--- /dev/null
+++ b/src/app/components/seasonal/themes/StPatricks.css.ts
@@ -0,0 +1,67 @@
+import { keyframes } from '@vanilla-extract/css';
+
+/**
+ * Clover tumble — a shamrock silhouette drifts down while tumbling on two axes.
+ * GPU-only: a single tall translateY plus rotate; per-clover duration/delay and
+ * a decoupled sway (below) create organic, non-repeating paths. The horizontal
+ * offsets stay small so clovers fall roughly in their column.
+ */
+export const animCloverTumble = keyframes({
+ '0%': { transform: 'translate3d(0, -10vh, 0) rotate(0deg)', opacity: '0' },
+ '8%': { opacity: '1' },
+ '50%': { transform: 'translate3d(12px, 50vh, 0) rotate(220deg)' },
+ '92%': { opacity: '0.8' },
+ '100%': { transform: 'translate3d(-8px, 114vh, 0) rotate(420deg)', opacity: '0' },
+});
+
+/**
+ * Lateral sway applied to a clover's wrapper so the descent reads as a leaf
+ * caught by a breeze, decoupled from the fall for an organic combined path.
+ */
+export const animCloverSway = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0)' },
+ '50%': { transform: 'translate3d(20px, 0, 0)' },
+ '100%': { transform: 'translate3d(0, 0, 0)' },
+});
+
+/**
+ * Verdant ambiance breathe — the emerald wash and vignette gently swell so the
+ * static tint feels alive without distracting motion. Opacity only.
+ */
+export const animVerdantBreathe = keyframes({
+ '0%': { opacity: '0.8' },
+ '50%': { opacity: '1' },
+ '100%': { opacity: '0.8' },
+});
+
+/**
+ * Rainbow shimmer — the soft arc in the corner slowly slides and breathes.
+ * Uses translate + scale + opacity (never background-position) so it stays on
+ * the compositor.
+ */
+export const animRainbowShimmer = keyframes({
+ '0%': { transform: 'translate3d(-3%, 1%, 0) scale(1)', opacity: '0.45' },
+ '50%': { transform: 'translate3d(3%, -1%, 0) scale(1.04)', opacity: '0.7' },
+ '100%': { transform: 'translate3d(-3%, 1%, 0) scale(1)', opacity: '0.45' },
+});
+
+/**
+ * Gold coin glint — a metallic disc tilts and brightens as a struck-light
+ * flicker, then settles. Transform + opacity only so it composites cheaply.
+ */
+export const animCoinGlint = keyframes({
+ '0%': { transform: 'scale(0.9) rotate(-8deg)', opacity: '0.35' },
+ '20%': { transform: 'scale(1.06) rotate(0deg)', opacity: '0.9' },
+ '45%': { transform: 'scale(0.94) rotate(6deg)', opacity: '0.5' },
+ '100%': { transform: 'scale(0.9) rotate(-8deg)', opacity: '0.35' },
+});
+
+/**
+ * Sparkle mote twinkle — a tiny golden point pulses in scale and brightness
+ * like a struck spark of luck. Opacity + transform only.
+ */
+export const animMoteTwinkle = keyframes({
+ '0%': { transform: 'scale(0.5)', opacity: '0.1' },
+ '50%': { transform: 'scale(1.25)', opacity: '0.95' },
+ '100%': { transform: 'scale(0.5)', opacity: '0.1' },
+});
diff --git a/src/app/components/seasonal/themes/StPatricks.tsx b/src/app/components/seasonal/themes/StPatricks.tsx
new file mode 100644
index 000000000..6ba4c50b3
--- /dev/null
+++ b/src/app/components/seasonal/themes/StPatricks.tsx
@@ -0,0 +1,325 @@
+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) => ` `)
+ .join('');
+ const stem = ` `;
+ const svg =
+ `` +
+ `${lobes} ${stem} `;
+ 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(() => {
+ 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(() => {
+ 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(() => {
+ 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). */}
+
+
+ {/* Verdant vignette frame — green edges, clear center. A single cheap
+ backdrop-filter adds a faint warm-emerald haze around the rim. */}
+
+
+ {/* 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. */}
+
+
+ {/* Gold-coin glints — small metallic discs that catch the light. */}
+ {coins.map((c, i) => (
+
+ ))}
+
+ {/* Golden sparkle motes — tiny four-point glints of luck. */}
+ {motes.map((m, i) => (
+
+ ))}
+
+ {/* Drifting clovers (motion only) — three parallax bands tumbling down.
+ Settled static scatter is rendered below for reduced/preview. */}
+ {!reduced &&
+ clovers.map((c, i) => (
+
+ ))}
+
+ {/* 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) => (
+
+ ))}
+ >
+ );
+}
diff --git a/src/app/components/seasonal/themes/Valentines.css.ts b/src/app/components/seasonal/themes/Valentines.css.ts
new file mode 100644
index 000000000..01d145cba
--- /dev/null
+++ b/src/app/components/seasonal/themes/Valentines.css.ts
@@ -0,0 +1,70 @@
+import { keyframes } from '@vanilla-extract/css';
+
+/**
+ * Heart rise — a soft heart drifts gently upward while bobbing sideways and
+ * breathing in scale, like a balloon caught in a warm draft. GPU-only: animates
+ * transform + opacity exclusively. The tall translateY lets one keyframe set
+ * serve every heart; per-heart duration/delay/scale supply the variety.
+ */
+export const animHeartRise = keyframes({
+ '0%': { transform: 'translate3d(0, 8vh, 0) scale(0.7) rotate(-6deg)', opacity: '0' },
+ '10%': { opacity: '1' },
+ '50%': { transform: 'translate3d(18px, -46vh, 0) scale(1) rotate(5deg)' },
+ '88%': { opacity: '0.85' },
+ '100%': { transform: 'translate3d(-12px, -108vh, 0) scale(1.12) rotate(-4deg)', opacity: '0' },
+});
+
+/**
+ * Heart bob — a small lateral sway applied to each heart's wrapper so the rise
+ * reads as a wandering draft, decoupled from the vertical travel so the two
+ * combine into an organic path. Transform only.
+ */
+export const animHeartBob = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0)' },
+ '50%': { transform: 'translate3d(16px, 0, 0)' },
+ '100%': { transform: 'translate3d(0, 0, 0)' },
+});
+
+/**
+ * Petal tumble — a rose petal falls while swaying horizontally and tumbling on
+ * its own axis, the way a real petal flutters. Opacity + transform only.
+ */
+export const animPetalTumble = keyframes({
+ '0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg)', opacity: '0' },
+ '8%': { opacity: '0.9' },
+ '30%': { transform: 'translate3d(30px, 28vh, 0) rotate(120deg)' },
+ '60%': { transform: 'translate3d(-26px, 62vh, 0) rotate(250deg)' },
+ '92%': { opacity: '0.7' },
+ '100%': { transform: 'translate3d(14px, 112vh, 0) rotate(380deg)', opacity: '0' },
+});
+
+/**
+ * Bokeh breathe — dreamy blush orbs softly pulse in scale and brightness, like
+ * soft-focus lights drifting in and out of focus. Opacity + transform only.
+ */
+export const animBokehBreathe = keyframes({
+ '0%': { transform: 'translate3d(0, 0, 0) scale(0.9)', opacity: '0.45' },
+ '50%': { transform: 'translate3d(0, -10px, 0) scale(1.12)', opacity: '0.9' },
+ '100%': { transform: 'translate3d(0, 0, 0) scale(0.9)', opacity: '0.45' },
+});
+
+/**
+ * Blush pulse — a barely-there breathing of the warm vignette so the static
+ * tint feels alive and tender without distracting motion. Opacity only.
+ */
+export const animBlushPulse = keyframes({
+ '0%': { opacity: '0.82' },
+ '50%': { opacity: '1' },
+ '100%': { opacity: '0.82' },
+});
+
+/**
+ * Sparkle glint — a faint highlight winks on and off with a gentle scale, a
+ * romantic twinkle that never strobes. Transform + opacity only.
+ */
+export const animSparkle = keyframes({
+ '0%': { transform: 'scale(0.4) rotate(0deg)', opacity: '0' },
+ '15%': { transform: 'scale(1) rotate(45deg)', opacity: '0.9' },
+ '35%': { transform: 'scale(0.55) rotate(90deg)', opacity: '0' },
+ '100%': { transform: 'scale(0.4) rotate(90deg)', opacity: '0' },
+});
diff --git a/src/app/components/seasonal/themes/Valentines.tsx b/src/app/components/seasonal/themes/Valentines.tsx
new file mode 100644
index 000000000..cb5eb8c15
--- /dev/null
+++ b/src/app/components/seasonal/themes/Valentines.tsx
@@ -0,0 +1,405 @@
+import React, { useMemo } from 'react';
+import { SeasonalOverlayProps } from '../types';
+import {
+ animHeartRise,
+ animHeartBob,
+ animPetalTumble,
+ animBokehBreathe,
+ animBlushPulse,
+ animSparkle,
+} from './Valentines.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);
+};
+
+// Romantic oklch palette — rose, blush pink, warm red, soft cream. Kept
+// luminous and gentle so everything reads as soft ambient glow over chat.
+const ROSE = 'oklch(0.7 0.15 10)';
+const BLUSH = 'oklch(0.9 0.06 350)';
+const WARM_RED = 'oklch(0.6 0.18 20)';
+const CREAM = 'oklch(0.96 0.03 60)';
+
+const HEART_COLORS = [ROSE, BLUSH, WARM_RED, 'oklch(0.78 0.13 5)'];
+const PETAL_COLORS = [
+ 'oklch(0.66 0.16 12)', // rose
+ 'oklch(0.74 0.13 6)', // lighter rose
+ 'oklch(0.6 0.18 20)', // warm red
+];
+
+// Inline SVG (data-URI) so it is fully Tauri/CSP-safe — no external assets.
+// A soft heart with a gradient fill and a cream highlight glint.
+const heartSvg = (fill: string, glint: string) => {
+ const svg = `
+
+
+
+ `;
+ return `url("data:image/svg+xml,${svg.replace(/\n/g, '').replace(/#/g, '%23')}")`;
+};
+
+// A single rose petal — a soft teardrop/ovate shape with an inner crease,
+// gently asymmetric so the tumble reads as a real petal.
+const petalSvg = (fill: string) => {
+ const svg = `
+
+
+
+
+ `;
+ return `url("data:image/svg+xml,${svg.replace(/\n/g, '').replace(/#/g, '%23')}")`;
+};
+
+type Heart = {
+ left: number;
+ size: number;
+ duration: number;
+ delay: number;
+ bobDuration: number;
+ opacity: number;
+ blur: number;
+ image: string;
+ restTop: number; // static resting position for reduced scene
+};
+
+type Petal = {
+ left: number;
+ size: number;
+ duration: number;
+ delay: number;
+ opacity: number;
+ image: string;
+ rotate: number;
+ restTop: number;
+};
+
+type Bokeh = {
+ left: number;
+ top: number;
+ size: number;
+ color: string;
+ duration: number;
+ delay: number;
+};
+
+type Sparkle = {
+ left: number;
+ top: number;
+ size: number;
+ duration: number;
+ delay: number;
+};
+
+export function ValentinesOverlay({ reduced }: SeasonalOverlayProps) {
+ // Three parallax bands of hearts: far (small/slow/dim) -> near (large/fast).
+ const hearts = useMemo(() => {
+ const bands = [
+ { count: 4, size: [12, 18], dur: [20, 26], op: [0.3, 0.5], blur: 0.8 },
+ { count: 4, size: [18, 26], dur: [15, 19], op: [0.5, 0.72], blur: 0.3 },
+ { count: 3, size: [26, 38], dur: [12, 15], op: [0.62, 0.85], blur: 0 },
+ ];
+ const out: Heart[] = [];
+ 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 fill = HEART_COLORS[Math.floor(r4 * HEART_COLORS.length) % HEART_COLORS.length];
+ out.push({
+ left: r1 * 96 + 2,
+ 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),
+ bobDuration: 5 + r2 * 5,
+ opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
+ blur: b.blur,
+ image: heartSvg(fill, CREAM),
+ restTop: 6 + r3 * 86,
+ });
+ s += 1;
+ }
+ });
+ return out;
+ }, []);
+
+ // Drifting rose petals tumbling down — a gentle counter-motion to the hearts.
+ const petals = useMemo(() => {
+ const count = 8;
+ const out: Petal[] = [];
+ for (let i = 0; i < count; i += 1) {
+ const r1 = rand(i + 40);
+ const r2 = rand(i + 40.5);
+ const r3 = rand(i + 40.9);
+ const fill = PETAL_COLORS[i % PETAL_COLORS.length];
+ out.push({
+ left: r1 * 98,
+ size: 9 + r2 * 9,
+ duration: 14 + r3 * 9,
+ delay: -r1 * 22,
+ opacity: 0.45 + r2 * 0.35,
+ image: petalSvg(fill),
+ rotate: r3 * 360,
+ restTop: 4 + r2 * 90,
+ });
+ }
+ return out;
+ }, []);
+
+ // Dreamy blush bokeh orbs scattered across the scene, softly breathing.
+ const bokeh = useMemo(() => {
+ const count = 7;
+ const out: Bokeh[] = [];
+ for (let i = 0; i < count; i += 1) {
+ const r1 = rand(i + 70);
+ const r2 = rand(i + 70.4);
+ const r3 = rand(i + 70.8);
+ out.push({
+ left: r1 * 94 + 3,
+ top: r2 * 88 + 4,
+ size: 70 + r3 * 130,
+ color: i % 2 === 0 ? BLUSH : 'oklch(0.82 0.1 355)',
+ duration: 9 + r3 * 7,
+ delay: -r1 * 10,
+ });
+ }
+ return out;
+ }, []);
+
+ // Faint sparkle glints — sparse, never strobing.
+ const sparkles = useMemo(() => {
+ const count = 5;
+ const out: Sparkle[] = [];
+ for (let i = 0; i < count; i += 1) {
+ const r1 = rand(i + 200);
+ const r2 = rand(i + 200.5);
+ const r3 = rand(i + 200.9);
+ out.push({
+ left: r1 * 92 + 4,
+ top: r2 * 80 + 6,
+ size: 6 + r3 * 8,
+ duration: 5 + r3 * 4,
+ delay: -r1 * 9,
+ });
+ }
+ return out;
+ }, []);
+
+ return (
+ <>
+ {/* Warm romantic ambient wash — layered radial + linear oklch gradients
+ for depth. Low opacity so chat text stays legible (WCAG-AA). */}
+
+
+ {/* Blush vignette frame — soft warm edges, clear center. A single cheap
+ backdrop-filter layer for a faint dreamy haze around the rim. */}
+
+
+ {/* Dreamy bokeh orbs — soft blurred blush lights that breathe. */}
+ {bokeh.map((b, i) => (
+
+ ))}
+
+ {/* Floating hearts (motion) — three parallax bands rising and bobbing.
+ The wrapper carries the lateral bob; the inner carries the rise so the
+ two combine into a wandering draft. */}
+ {!reduced &&
+ hearts.map((h, i) => (
+
+ ))}
+
+ {/* Drifting rose petals (motion) — tumbling down through the scene. */}
+ {!reduced &&
+ petals.map((p, i) => (
+
+ ))}
+
+ {/* Faint sparkle glints (motion) — sparse romantic twinkle. */}
+ {!reduced &&
+ sparkles.map((s, i) => (
+
+ ))}
+
+ {/* Static reduced-motion / preview scene — settled hearts at rest, a
+ scatter of fallen petals, and still sparkle glints. Tender and still,
+ so the judged thumbnail stands on its own without any animation. */}
+ {reduced &&
+ hearts.map((h, i) => (
+
+ ))}
+
+ {reduced &&
+ petals.map((p, i) => (
+
+ ))}
+
+ {reduced &&
+ sparkles.map((s, i) => (
+
+ ))}
+ >
+ );
+}