feat(seasonal): redesign all 11 seasonal themes as modular per-theme overlays
Split the 808-line SeasonalEffect monolith into one self-contained module per theme under seasonal/themes/ (<Theme>.tsx + <Theme>.css.ts), and gave every theme a premium, research-backed redesign (one Opus agent per theme against a shared brief). SeasonalEffect now just imports the 11 overlays and dispatches; the orphaned shared Seasonal.css.ts is removed (each theme owns its keyframes). Each overlay: layered oklch palettes, GPU-only animation (transform/opacity), `contain: layout paint style` to kill repaint flicker, ≤~40-element perf budget, particles seeded once via useMemo (no per-frame state), a gorgeous STATIC prefers-reduced-motion form (the settings preview thumbnail), WCAG-AA-preserving low opacities, and no new deps / no external assets (inline SVG data-URIs, Tauri/CSP-safe). Themes: Halloween, Christmas, New Year, Autumn, April Fools, Lunar New Year, Valentines, St. Patrick's, Earth Day, Deep Space, Arcade. Gates: tsc clean, ESLint clean, Prettier clean, build OK, 551 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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' },
|
||||
});
|
||||
@@ -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 */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(25,0,45,0.22)',
|
||||
backgroundImage:
|
||||
'radial-gradient(ellipse at 50% 50%, rgba(100,0,180,0.08) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
{/* Spider web corners */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '160px',
|
||||
height: '160px',
|
||||
backgroundImage: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'><g stroke='rgba(180,120,255,0.35)' stroke-width='0.7' fill='none'><line x1='0' y1='0' x2='80' y2='80'/><line x1='40' y1='0' x2='80' y2='80'/><line x1='80' y1='0' x2='80' y2='80'/><line x1='0' y1='40' x2='80' y2='80'/><line x1='0' y1='80' x2='80' y2='80'/><ellipse cx='80' cy='80' rx='20' ry='20'/><ellipse cx='80' cy='80' rx='40' ry='40'/><ellipse cx='80' cy='80' rx='60' ry='60'/><ellipse cx='80' cy='80' rx='80' ry='80'/></g></svg>")`,
|
||||
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 (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
left: `${left}%`,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isOrange ? 'rgba(255,100,0,0.75)' : 'rgba(160,0,255,0.7)',
|
||||
boxShadow: isOrange ? '0 0 8px rgba(255,100,0,0.5)' : '0 0 8px rgba(160,0,255,0.5)',
|
||||
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChristmasOverlay({ reduced }: { reduced: boolean }) {
|
||||
const flakes = Array.from({ length: 28 });
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage:
|
||||
'radial-gradient(ellipse at 50% 0%, rgba(220,240,255,0.06) 0%, transparent 60%)',
|
||||
}}
|
||||
/>
|
||||
{!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 (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-10px',
|
||||
left: `${left}%`,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'rgba(255,255,255,0.82)',
|
||||
boxShadow: '0 0 4px rgba(200,230,255,0.6)',
|
||||
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
|
||||
transform: `translateX(${drift}px)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(10,5,0,0.10)',
|
||||
backgroundImage:
|
||||
'radial-gradient(ellipse at 50% 50%, rgba(255,200,0,0.04) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
{/* 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 (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
left: `${left}%`,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
borderRadius: i % 2 === 0 ? '50%' : '1px',
|
||||
backgroundColor: c,
|
||||
boxShadow: `0 0 ${size + 2}px ${c}`,
|
||||
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
|
||||
opacity: 0.7 + (i % 3) * 0.1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Slow gold shimmer */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage:
|
||||
'linear-gradient(105deg, transparent 30%, rgba(255,215,0,0.05) 50%, transparent 70%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: reduced ? 'none' : `${animGoldShimmer} 5s linear infinite`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage:
|
||||
'radial-gradient(ellipse at 50% 100%, rgba(180,80,0,0.06) 0%, transparent 60%)',
|
||||
}}
|
||||
/>
|
||||
{!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 (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-15px',
|
||||
left: `${left}%`,
|
||||
width: `${size}px`,
|
||||
height: `${size * 0.7}px`,
|
||||
borderRadius: '50% 0 50% 0',
|
||||
backgroundColor: col,
|
||||
boxShadow: `0 0 4px ${col}`,
|
||||
animation: `${animLeafFall} ${duration}s ease-in ${delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, rgba(255,0,0,0.4), rgba(255,165,0,0.4), rgba(255,255,0,0.4), rgba(0,200,0,0.4), rgba(0,0,255,0.4), rgba(128,0,128,0.4))',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
{/* 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 (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-20px',
|
||||
left: `${left}%`,
|
||||
fontSize: `${size}px`,
|
||||
color: col,
|
||||
fontWeight: 700,
|
||||
fontFamily: 'monospace',
|
||||
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{sym}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(140,0,0,0.05)',
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(45deg, rgba(200,20,0,0.015) 0px, rgba(200,20,0,0.015) 1px, transparent 1px, transparent 8px)',
|
||||
'repeating-linear-gradient(135deg, rgba(200,20,0,0.015) 0px, rgba(200,20,0,0.015) 1px, transparent 1px, transparent 8px)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
{/* Slow gold shimmer */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage:
|
||||
'linear-gradient(100deg, transparent 25%, rgba(255,200,0,0.05) 45%, rgba(255,220,50,0.07) 50%, rgba(255,200,0,0.05) 55%, transparent 75%)',
|
||||
backgroundSize: '300% 100%',
|
||||
animation: reduced ? 'none' : `${animGoldShimmer} 8s linear infinite`,
|
||||
}}
|
||||
/>
|
||||
{/* 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 (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${left}%`,
|
||||
top: `${top}%`,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBob} ${duration}s ease-in-out ${delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '18px',
|
||||
height: '5px',
|
||||
backgroundColor: '#ffd700',
|
||||
borderRadius: '2px',
|
||||
margin: '0 auto',
|
||||
boxShadow: '0 0 4px rgba(255,215,0,0.6)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '32px',
|
||||
backgroundColor: '#cc0000',
|
||||
borderRadius: '50%',
|
||||
border: '1.5px solid #ffd700',
|
||||
boxShadow: '0 0 14px rgba(200,0,0,0.5), inset 0 0 10px rgba(255,200,0,0.2)',
|
||||
margin: '1px auto',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: '18px',
|
||||
height: '5px',
|
||||
backgroundColor: '#ffd700',
|
||||
borderRadius: '2px',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: '2px',
|
||||
height: '14px',
|
||||
backgroundColor: '#ffd700',
|
||||
margin: '0 auto',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animTasselSway} ${duration * 0.6}s ease-in-out ${delay}s infinite`,
|
||||
transformOrigin: 'top center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage:
|
||||
'radial-gradient(ellipse at 50% 100%, rgba(255,100,140,0.06) 0%, transparent 55%)',
|
||||
}}
|
||||
/>
|
||||
{!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 (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-20px',
|
||||
left: `${left}%`,
|
||||
fontSize: `${size}px`,
|
||||
color: col,
|
||||
filter: 'drop-shadow(0 0 4px rgba(255,100,140,0.4))',
|
||||
animation: `${animFloatUp} ${duration}s ease-in ${delay}s infinite`,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
♥
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StPatricksOverlay({ reduced }: { reduced: boolean }) {
|
||||
const clovers = Array.from({ length: 18 });
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: [
|
||||
'radial-gradient(ellipse at 50% 0%, rgba(0,160,60,0.07) 0%, transparent 50%)',
|
||||
'radial-gradient(ellipse at 50% 100%, rgba(0,130,50,0.05) 0%, transparent 40%)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, transparent 0%, #ffd700 20%, #fff4a0 40%, #ffd700 60%, transparent 100%)',
|
||||
backgroundSize: '300% 100%',
|
||||
animation: reduced ? 'none' : `${animGoldShimmer} 3s linear infinite`,
|
||||
}}
|
||||
/>
|
||||
{!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 (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-20px',
|
||||
left: `${left}%`,
|
||||
fontSize: `${size}px`,
|
||||
opacity: 0.45 + (i % 3) * 0.1,
|
||||
filter: 'drop-shadow(0 0 3px rgba(0,180,60,0.3))',
|
||||
animation: `${animCloverDrift} ${duration}s linear ${delay}s infinite`,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
☘
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EarthDayOverlay({ reduced }: { reduced: boolean }) {
|
||||
const leaves = Array.from({ length: 16 });
|
||||
const leafEmoji = ['🌿', '🍃', '🌱', '🍀'];
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: [
|
||||
'radial-gradient(ellipse at 30% 70%, rgba(60,160,60,0.07) 0%, transparent 50%)',
|
||||
'radial-gradient(ellipse at 70% 30%, rgba(100,180,80,0.05) 0%, transparent 45%)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '3px',
|
||||
backgroundImage:
|
||||
'linear-gradient(180deg, transparent 0%, rgba(60,160,60,0.4) 20%, rgba(80,180,60,0.6) 50%, rgba(60,160,60,0.4) 80%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
{!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 (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-20px',
|
||||
left: `${left}%`,
|
||||
fontSize: `${size}px`,
|
||||
opacity: 0.5 + (i % 3) * 0.1,
|
||||
animation: `${animEarthLeafDrift} ${duration}s ease-in ${delay}s infinite`,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{leafEmoji[i % leafEmoji.length]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeepSpaceOverlay({ reduced }: { reduced: boolean }) {
|
||||
const stars = Array.from({ length: 24 });
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0,0,8,0.3)',
|
||||
backgroundImage: [
|
||||
'radial-gradient(ellipse at 30% 40%, rgba(80,0,180,0.10) 0%, transparent 50%)',
|
||||
'radial-gradient(ellipse at 70% 60%, rgba(0,60,180,0.10) 0%, transparent 50%)',
|
||||
'radial-gradient(ellipse at 50% 20%, rgba(120,0,200,0.07) 0%, transparent 40%)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
{!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 (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: `${80 + i * 6}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: starColors[i % starColors.length],
|
||||
transformOrigin: '0 50%',
|
||||
transform: `rotate(${angle}deg)`,
|
||||
boxShadow: `0 0 ${size * 2}px ${starColors[i % starColors.length]}`,
|
||||
animation: `${animWarp} ${duration}s ease-out ${delay}s ${period}s infinite`,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ArcadeOverlay({ reduced }: { reduced: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(0deg, rgba(0,0,0,0.12) 0px, rgba(0,0,0,0.12) 1px, transparent 1px, transparent 3px)',
|
||||
animation: reduced ? 'none' : `${animScanline} 3s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
{(['0,0', '0,auto', 'auto,0', 'auto,auto'] as const).map((corner, i) => {
|
||||
const [t, b] = corner.split(',');
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: t === '0' ? '8px' : undefined,
|
||||
bottom: b === '0' ? '8px' : undefined,
|
||||
left: i % 2 === 0 ? '8px' : undefined,
|
||||
right: i % 2 === 1 ? '8px' : undefined,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '11px',
|
||||
color: 'rgba(0,255,136,0.5)',
|
||||
letterSpacing: '0.05em',
|
||||
animation: reduced ? 'none' : `${animPixelBlink} ${1 + i * 0.3}s step-end infinite`,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{['[■]', '[■]', '[■]', '[■]'][i]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '16px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.2em',
|
||||
color: 'rgba(255,220,0,0.4)',
|
||||
animation: reduced ? 'none' : `${animPixelBlink} 1.2s step-end infinite`,
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
— INSERT COIN —
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage:
|
||||
'radial-gradient(ellipse at 50% 50%, transparent 60%, rgba(0,0,0,0.35) 100%)',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
|
||||
|
||||
function buildOverlayContent(theme: SeasonTheme, reduced: boolean): React.ReactNode {
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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) =>
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none' ` +
|
||||
`stroke='${color}' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'>${body}</svg>`;
|
||||
|
||||
// Question mark — the playful "huh?" centerpiece doodle.
|
||||
const glyphQuestion = (c: string) =>
|
||||
stroke(
|
||||
c,
|
||||
`<path d='M11 11 q0 -6 6 -6 q6 0 6 5 q0 4 -5 6 q-2 1 -2 4'/>` +
|
||||
`<circle cx='16' cy='27' r='0.6' fill='${c}'/>`,
|
||||
);
|
||||
|
||||
// Exclamation / "bang" — a surprised little doodle.
|
||||
const glyphBang = (c: string) =>
|
||||
stroke(c, `<path d='M16 5 L16 20'/><circle cx='16' cy='27' r='0.6' fill='${c}'/>`);
|
||||
|
||||
// Squiggle — a loopy scribble that adds whimsy.
|
||||
const glyphSquiggle = (c: string) => stroke(c, `<path d='M5 18 q4 -10 8 0 t8 0 t8 0'/>`);
|
||||
|
||||
// Five-point doodle star (open-stroke, hand-drawn look).
|
||||
const glyphStar = (c: string) =>
|
||||
stroke(
|
||||
c,
|
||||
`<path d='M16 5 L19.4 13 L28 13.6 L21.4 19.2 L23.5 27.6 L16 22.8 L8.5 27.6 ` +
|
||||
`L10.6 19.2 L4 13.6 L12.6 13 Z'/>`,
|
||||
);
|
||||
|
||||
// A tiny heart doodle for extra grin.
|
||||
const glyphHeart = (c: string) =>
|
||||
stroke(c, `<path d='M16 26 C6 18 7 8 16 12 C25 8 26 18 16 26 Z'/>`);
|
||||
|
||||
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<Doodle[]>(() => {
|
||||
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<Confetti[]>(() => {
|
||||
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<Eye[]>(() => {
|
||||
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<Spark[]>(() => {
|
||||
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(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>` +
|
||||
`<path d='M12 0 C13 8 16 11 24 12 C16 13 13 16 12 24 C11 16 8 13 0 12 C8 11 11 8 12 0 Z' fill='${c}'/></svg>`,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Soft pastel ambient wash — layered oklch radials for depth. Very low
|
||||
opacity so chat text keeps WCAG-AA contrast. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: [
|
||||
'radial-gradient(110% 70% at 18% -8%, oklch(0.85 0.12 20 / 0.1) 0%, transparent 55%)',
|
||||
'radial-gradient(95% 65% at 86% 0%, oklch(0.82 0.12 160 / 0.09) 0%, transparent 58%)',
|
||||
'radial-gradient(120% 80% at 50% 112%, oklch(0.8 0.12 260 / 0.1) 0%, transparent 60%)',
|
||||
'linear-gradient(180deg, oklch(0.88 0.12 90 / 0.05) 0%, transparent 30%, transparent 78%, oklch(0.84 0.12 320 / 0.06) 100%)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-2%',
|
||||
contain: 'layout paint style',
|
||||
backdropFilter: 'saturate(1.06) brightness(1.01)',
|
||||
WebkitBackdropFilter: 'saturate(1.06) brightness(1.01)',
|
||||
backgroundImage:
|
||||
'radial-gradient(130% 120% at 50% 45%, transparent 60%, oklch(0.86 0.11 50 / 0.05) 80%, oklch(0.8 0.12 260 / 0.08) 100%)',
|
||||
transformOrigin: '50% 50%',
|
||||
animation: reduced ? 'none' : `${animWobble} 14s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pastel rainbow aurora high up — soft band of the full palette. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8%',
|
||||
left: '-10%',
|
||||
right: '-10%',
|
||||
height: '42%',
|
||||
contain: 'layout paint style',
|
||||
mixBlendMode: 'screen',
|
||||
filter: 'blur(30px)',
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
backgroundImage: [
|
||||
'radial-gradient(50% 100% at 18% 0%, oklch(0.85 0.12 20 / 0.16) 0%, transparent 72%)',
|
||||
'radial-gradient(50% 100% at 40% 0%, oklch(0.88 0.12 90 / 0.14) 0%, transparent 72%)',
|
||||
'radial-gradient(50% 100% at 62% 0%, oklch(0.82 0.12 160 / 0.14) 0%, transparent 72%)',
|
||||
'radial-gradient(50% 100% at 84% 0%, oklch(0.8 0.12 260 / 0.16) 0%, transparent 72%)',
|
||||
].join(','),
|
||||
animation: reduced ? 'none' : `${animRainbowDrift} 20s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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 (
|
||||
<div
|
||||
key={`doodle-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
top: `${d.startTop}%`,
|
||||
transform: `rotate(${(rand(i + 11) - 0.5) * 24}deg)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`doodle-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
top: 0,
|
||||
animation: `${animDoodleFloat} ${d.duration}s ease-in-out ${d.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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 (
|
||||
<div
|
||||
key={`confetti-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
top: `${c.startTop}%`,
|
||||
transform: `rotate(${rand(i + 13) * 360}deg)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`confetti-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...common,
|
||||
top: 0,
|
||||
animation: `${animConfettiTumble} ${c.duration}s linear ${c.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Googly eyes peeking from the edges — pupil wanders cheekily. */}
|
||||
{eyes.map((e, i) => (
|
||||
<div
|
||||
key={`eye-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${e.left}%`,
|
||||
top: `${e.top}%`,
|
||||
width: `${e.size}px`,
|
||||
height: `${e.size}px`,
|
||||
marginLeft: `${-e.size / 2}px`,
|
||||
marginTop: `${-e.size / 2}px`,
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle at 38% 32%, oklch(0.99 0.005 90 / 0.85) 0%, oklch(0.95 0.01 90 / 0.7) 62%, oklch(0.75 0.02 90 / 0.6) 100%)',
|
||||
boxShadow: 'inset 0 0 0 1.5px oklch(0.45 0.03 300 / 0.35)',
|
||||
opacity: 0.6,
|
||||
}}
|
||||
>
|
||||
{/* Pupil */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: `${e.size * 0.4}px`,
|
||||
height: `${e.size * 0.4}px`,
|
||||
marginLeft: `${-e.size * 0.2}px`,
|
||||
marginTop: `${-e.size * 0.2}px`,
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle at 36% 30%, oklch(0.5 0.04 300 / 0.95) 0%, oklch(0.28 0.04 300 / 0.95) 70%)',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animGoogly} ${e.duration}s ease-in-out ${e.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
>
|
||||
{/* Catchlight */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '22%',
|
||||
top: '20%',
|
||||
width: '28%',
|
||||
height: '28%',
|
||||
borderRadius: '50%',
|
||||
background: 'oklch(0.99 0.005 90 / 0.85)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Sly winking sparkles. Static (reduced) shows them mid-glint. */}
|
||||
{sparks.map((s, i) => (
|
||||
<div
|
||||
key={`spark-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${s.left}%`,
|
||||
top: `${s.top}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
backgroundImage: sparkGlint(s.color),
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
filter: `drop-shadow(0 0 3px ${s.color.replace(')', ' / 0.5)')})`,
|
||||
opacity: reduced ? 0.8 : undefined,
|
||||
transform: reduced ? 'scale(0.95) rotate(40deg)' : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animSparkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)' },
|
||||
});
|
||||
@@ -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(
|
||||
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
|
||||
`stroke-width='1.4' stroke-opacity='0.9'/>`,
|
||||
);
|
||||
});
|
||||
// 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(
|
||||
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
|
||||
`stroke-width='1.4' stroke-opacity='0.8'/>`,
|
||||
);
|
||||
}
|
||||
const svg =
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600' ` +
|
||||
`preserveAspectRatio='none'>${lines.join('')}</svg>`;
|
||||
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<Sparkle[]>(() => {
|
||||
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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: [
|
||||
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)',
|
||||
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 0%, transparent 60%)',
|
||||
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)',
|
||||
].join(','),
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-25%',
|
||||
right: '-25%',
|
||||
bottom: 0,
|
||||
height: '62%',
|
||||
overflow: 'hidden',
|
||||
perspective: '280px',
|
||||
perspectiveOrigin: '50% 0%',
|
||||
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
||||
opacity: reduced ? 0.5 : 0.62,
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: '200%',
|
||||
transformOrigin: 'top center',
|
||||
transform: 'rotateX(74deg)',
|
||||
backgroundImage: GRID_URI,
|
||||
backgroundRepeat: 'repeat-y',
|
||||
backgroundSize: '100% 50%',
|
||||
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '38%',
|
||||
width: '70%',
|
||||
height: '34%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
backgroundImage:
|
||||
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '12%',
|
||||
right: '12%',
|
||||
top: '38%',
|
||||
height: '1.5px',
|
||||
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`,
|
||||
opacity: 0.55,
|
||||
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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) => (
|
||||
<div
|
||||
key={`rest-spark-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${s.left}%`,
|
||||
bottom: `${s.bottom}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
background: sparkleColor(s.hue),
|
||||
opacity: s.opacity,
|
||||
boxShadow: `0 0 6px ${sparkleColor(s.hue)}`,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: sparkles.map((s, i) => (
|
||||
<div
|
||||
key={`spark-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${s.left}%`,
|
||||
bottom: `${s.bottom}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
willChange: 'transform, opacity',
|
||||
animation: `${animSparkleDrift} ${s.duration}s linear ${s.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: sparkleColor(s.hue),
|
||||
opacity: s.opacity,
|
||||
boxShadow: `0 0 6px ${sparkleColor(s.hue)}`,
|
||||
animation: `${animSparkleTwinkle} ${s.twinkle}s step-end infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
mixBlendMode: 'multiply',
|
||||
opacity: 0.5,
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: '-8px',
|
||||
bottom: '-8px',
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
boxShadow: `inset 2px 0 14px oklch(0.65 0.25 350 / 0.16), inset -2px 0 14px oklch(0.80 0.15 200 / 0.16)`,
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
contain: 'layout paint style',
|
||||
animation: reduced ? 'none' : `${animChromaShift} 4.5s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
|
||||
Static scene shows it steady (no blink). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '5%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.32em',
|
||||
color: NEON_CYAN,
|
||||
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
|
||||
}}
|
||||
>
|
||||
INSERT COIN
|
||||
</div>
|
||||
|
||||
{/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left,
|
||||
very low opacity so it reads as ambient chrome, not UI. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '2.5%',
|
||||
left: '2%',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.18em',
|
||||
color: NEON_MAGENTA,
|
||||
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: reduced ? 0.5 : undefined,
|
||||
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
|
||||
}}
|
||||
>
|
||||
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: [
|
||||
'radial-gradient(125% 95% at 50% 46%, oklch(0.72 0.18 340 / 0.05) 0%, transparent 40%)',
|
||||
'radial-gradient(120% 115% at 50% 50%, transparent 50%, oklch(0.10 0.05 310 / 0.20) 84%, oklch(0.06 0.04 305 / 0.34) 100%)',
|
||||
].join(','),
|
||||
contain: 'layout paint style',
|
||||
opacity: reduced ? 1 : undefined,
|
||||
willChange: reduced ? undefined : 'opacity',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animScreenGlow} 8s ease-in-out infinite, ${animCrtFlicker} 5.5s steps(1, end) infinite`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 =
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>` +
|
||||
`<path d='${path}' fill='${fill}' fill-opacity='0.92'/>` +
|
||||
`<path d='M50 96 L50 24' stroke='oklch(0.42 0.12 38)' stroke-opacity='0.35' ` +
|
||||
`stroke-width='2.5' fill='none' stroke-linecap='round'/>` +
|
||||
`</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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: [
|
||||
'radial-gradient(120% 90% at 18% 8%, oklch(0.82 0.13 85 / 0.16) 0%, transparent 55%)',
|
||||
'radial-gradient(130% 100% at 50% 118%, oklch(0.55 0.16 40 / 0.18) 0%, transparent 60%)',
|
||||
'linear-gradient(180deg, oklch(0.75 0.15 70 / 0.07) 0%, transparent 40%, oklch(0.5 0.14 35 / 0.08) 100%)',
|
||||
].join(','),
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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) => (
|
||||
<div
|
||||
key={`shaft-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-30%',
|
||||
left: `${shaft.left}%`,
|
||||
width: `${shaft.w}vw`,
|
||||
height: '160%',
|
||||
transformOrigin: 'top center',
|
||||
transform: `rotate(${shaft.rotate}deg)`,
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, transparent 0%, oklch(0.85 0.12 82 / 0.5) 50%, transparent 100%)',
|
||||
filter: 'blur(14px)',
|
||||
mixBlendMode: 'screen',
|
||||
opacity: reduced ? shaft.opacity * 0.85 : undefined,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
contain: 'layout paint style',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animSunShaft} ${shaft.dur}s ease-in-out ${shaft.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 3. Drifting pollen / dust motes catching the light. Static scene omits
|
||||
them — stillness reads cleaner at rest. */}
|
||||
{!reduced &&
|
||||
motes.map((m, i) => (
|
||||
<div
|
||||
key={`mote-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${m.left}%`,
|
||||
bottom: `${m.bottom}%`,
|
||||
width: `${m.size}px`,
|
||||
height: `${m.size}px`,
|
||||
willChange: 'transform, opacity',
|
||||
animation: `${animMoteDrift} ${m.duration}s linear ${m.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle, oklch(0.88 0.1 85 / 0.95) 0%, oklch(0.78 0.12 70 / 0.4) 60%, transparent 100%)',
|
||||
opacity: m.opacity,
|
||||
animation: `${animMoteTwinkle} ${m.twinkle}s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 4. Falling / resting maple & oak leaves. */}
|
||||
{reduced
|
||||
? RESTING_LEAVES.map((leaf, i) => (
|
||||
<div
|
||||
key={`rest-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${leaf.left}%`,
|
||||
top: `${leaf.top}%`,
|
||||
width: `${leaf.size}px`,
|
||||
height: `${leaf.size}px`,
|
||||
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
|
||||
opacity: leaf.opacity,
|
||||
filter: 'drop-shadow(0 2px 3px oklch(0.3 0.08 40 / 0.35))',
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: leaves.map((leaf, i) => (
|
||||
// Sway wrapper: horizontal wind motion, decoupled from the fall.
|
||||
<div
|
||||
key={`leaf-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: `${leaf.left}%`,
|
||||
width: `${leaf.size}px`,
|
||||
height: `${leaf.size}px`,
|
||||
willChange: 'transform',
|
||||
contain: 'layout paint style',
|
||||
animation: `${animLeafSway} ${leaf.swayDuration}s ease-in-out ${leaf.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
{/* Fall wrapper: vertical descent + tumble rotation. */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
willChange: 'transform, opacity',
|
||||
animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
{/* Inner blade: the actual silhouette + flutter wobble. */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundImage: leaf.uri,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
opacity: leaf.opacity,
|
||||
filter: 'drop-shadow(0 1px 2px oklch(0.3 0.08 40 / 0.3))',
|
||||
animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 5. Warm low-saturation vignette. Frames the scene and gently darkens
|
||||
edges — protecting central chat text contrast. Breathes faintly. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage:
|
||||
'radial-gradient(120% 110% at 50% 45%, transparent 52%, oklch(0.38 0.07 45 / 0.14) 82%, oklch(0.3 0.06 40 / 0.22) 100%)',
|
||||
contain: 'layout paint style',
|
||||
opacity: reduced ? 1 : undefined,
|
||||
animation: reduced ? 'none' : `${animEmberPulse} 9s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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<Flake[]>(() => {
|
||||
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<Bulb[]>(() => {
|
||||
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). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: [
|
||||
'radial-gradient(120% 80% at 50% -10%, oklch(0.25 0.07 250 / 0.16) 0%, transparent 55%)',
|
||||
'radial-gradient(90% 60% at 85% 110%, oklch(0.3 0.06 255 / 0.1) 0%, transparent 60%)',
|
||||
'linear-gradient(180deg, oklch(0.95 0.03 230 / 0.05) 0%, transparent 22%, transparent 80%, oklch(0.22 0.07 255 / 0.08) 100%)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Frosted vignette frame — cold edges, clear center. backdrop-filter on a
|
||||
single cheap layer for a faint icy haze around the rim. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backdropFilter: 'blur(0.4px) saturate(1.04)',
|
||||
WebkitBackdropFilter: 'blur(0.4px) saturate(1.04)',
|
||||
backgroundImage:
|
||||
'radial-gradient(135% 120% at 50% 42%, transparent 52%, oklch(0.9 0.04 225 / 0.07) 74%, oklch(0.28 0.07 250 / 0.16) 100%)',
|
||||
animation: reduced ? 'none' : `${animFrostPulse} 12s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Aurora shimmer band high up — soft conic-ish wash of icy blue/green. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-6%',
|
||||
left: '-10%',
|
||||
right: '-10%',
|
||||
height: '40%',
|
||||
contain: 'layout paint style',
|
||||
mixBlendMode: 'screen',
|
||||
filter: 'blur(26px)',
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
backgroundImage: [
|
||||
'radial-gradient(60% 100% at 30% 0%, oklch(0.85 0.12 165 / 0.18) 0%, transparent 70%)',
|
||||
'radial-gradient(55% 100% at 68% 0%, oklch(0.8 0.1 235 / 0.16) 0%, transparent 72%)',
|
||||
'radial-gradient(50% 100% at 50% 0%, oklch(0.9 0.06 280 / 0.1) 0%, transparent 75%)',
|
||||
].join(','),
|
||||
animation: reduced ? 'none' : `${animAurora} 18s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* String-light wire — a faint catenary line the bulbs hang from. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '14%',
|
||||
contain: 'layout paint style',
|
||||
backgroundImage:
|
||||
'radial-gradient(140% 60% at 50% -30%, oklch(0.3 0.04 250 / 0.14) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bokeh string lights — soft blurred orbs that breathe. */}
|
||||
{bulbs.map((b, i) => (
|
||||
<div
|
||||
key={`bulb-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${b.left}%`,
|
||||
top: `${b.top}%`,
|
||||
width: `${b.size}px`,
|
||||
height: `${b.size}px`,
|
||||
marginLeft: `${-b.size / 2}px`,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle at 38% 34%, oklch(0.98 0.02 95 / 0.95) 0%, ${b.color} 38%, transparent 72%)`,
|
||||
boxShadow: `0 0 ${b.size}px ${b.size * 0.45}px ${b.color.replace(')', ' / 0.45)')}`,
|
||||
filter: 'blur(0.5px)',
|
||||
opacity: reduced ? 0.9 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBulbBreathe} ${b.duration}s ease-in-out ${b.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Snowfall (motion only) — three parallax bands. Static dusting below. */}
|
||||
{!reduced &&
|
||||
flakes.map((f, i) => (
|
||||
<div
|
||||
key={`snow-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: `${f.left}%`,
|
||||
width: `${f.size}px`,
|
||||
height: `${f.size}px`,
|
||||
animation: `${animSnowSway} ${f.swayDuration}s ease-in-out ${f.delay}s infinite`,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
|
||||
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.55)',
|
||||
opacity: f.opacity,
|
||||
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
||||
animation: `${animSnowFall} ${f.duration}s linear ${f.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 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 (
|
||||
<div
|
||||
key={`snow-static-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${f.left}%`,
|
||||
top: `${fy}%`,
|
||||
width: `${f.size}px`,
|
||||
height: `${f.size}px`,
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
|
||||
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.5)',
|
||||
opacity: f.opacity,
|
||||
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 0 C12.6 7.4 16.6 11.4 24 12 C16.6 12.6 12.6 16.6 12 24 C11.4 16.6 7.4 12.6 0 12 C7.4 11.4 11.4 7.4 12 0 Z' fill='${color}'/></svg>`,
|
||||
)}")`;
|
||||
|
||||
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<Star[]>(() => makeStars(16, 1000), []);
|
||||
const nearStars = useMemo<Star[]>(() => makeStars(12, 2000), []);
|
||||
|
||||
const heroStars = useMemo<HeroStar[]>(
|
||||
() =>
|
||||
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<Comet[]>(
|
||||
() =>
|
||||
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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-6%',
|
||||
contain: 'layout paint style',
|
||||
backgroundColor: 'oklch(0.2 0.12 300 / 0.16)',
|
||||
backgroundImage: [
|
||||
'radial-gradient(120% 90% at 50% -8%, oklch(0.28 0.13 295 / 0.2) 0%, transparent 60%)',
|
||||
'radial-gradient(100% 80% at 12% 18%, oklch(0.55 0.2 330 / 0.1) 0%, transparent 55%)',
|
||||
'radial-gradient(100% 80% at 88% 28%, oklch(0.75 0.13 200 / 0.08) 0%, transparent 55%)',
|
||||
'radial-gradient(150% 130% at 50% 118%, oklch(0.18 0.1 300 / 0.22) 0%, transparent 70%)',
|
||||
].join(','),
|
||||
animation: reduced ? 'none' : `${animCosmosDrift} 26s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Drifting nebula clouds — blurred radial gradients in magenta + cyan. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-12%',
|
||||
left: '-14%',
|
||||
width: '70%',
|
||||
height: '70%',
|
||||
contain: 'layout paint style',
|
||||
filter: 'blur(42px)',
|
||||
background:
|
||||
'radial-gradient(closest-side, oklch(0.55 0.2 330 / 0.16) 0%, oklch(0.45 0.18 320 / 0.06) 45%, transparent 72%)',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced ? 'none' : `${animNebulaA} 34s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-16%',
|
||||
right: '-12%',
|
||||
width: '74%',
|
||||
height: '74%',
|
||||
contain: 'layout paint style',
|
||||
filter: 'blur(46px)',
|
||||
background:
|
||||
'radial-gradient(closest-side, oklch(0.75 0.13 200 / 0.13) 0%, oklch(0.6 0.14 240 / 0.05) 48%, transparent 74%)',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced ? 'none' : `${animNebulaB} 40s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
{/* A third, central violet wash to bind the two color clouds together. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '20%',
|
||||
left: '28%',
|
||||
width: '50%',
|
||||
height: '50%',
|
||||
contain: 'layout paint style',
|
||||
filter: 'blur(50px)',
|
||||
background:
|
||||
'radial-gradient(closest-side, oklch(0.35 0.16 305 / 0.12) 0%, transparent 70%)',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced ? 'none' : `${animNebulaA} 46s ease-in-out infinite reverse`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Faint distant galaxy spiral — an inline conic-ish swirl from layered
|
||||
radial gradients, blurred and very slowly rotating. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12%',
|
||||
right: '14%',
|
||||
width: '180px',
|
||||
height: '180px',
|
||||
contain: 'layout paint style',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(6px)',
|
||||
opacity: reduced ? 0.5 : 0.6,
|
||||
background: [
|
||||
'radial-gradient(closest-side, oklch(0.95 0.04 280 / 0.35) 0%, oklch(0.7 0.16 320 / 0.12) 22%, transparent 40%)',
|
||||
'conic-gradient(from 0deg, transparent 0deg, oklch(0.7 0.16 320 / 0.16) 60deg, transparent 130deg, oklch(0.75 0.13 200 / 0.12) 230deg, transparent 300deg)',
|
||||
].join(','),
|
||||
maskImage: 'radial-gradient(closest-side, #000 0%, #000 55%, transparent 80%)',
|
||||
WebkitMaskImage: 'radial-gradient(closest-side, #000 0%, #000 55%, transparent 80%)',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
transform: reduced ? 'rotate(28deg)' : undefined,
|
||||
animation: reduced ? 'none' : `${animGalaxySpin} 120s linear infinite`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Far parallax starfield — dense, faint, tiny twinkling stars. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced ? 'none' : `${animParallaxFar} 60s ease-in-out infinite alternate`,
|
||||
}}
|
||||
>
|
||||
{farStars.map((s, i) => (
|
||||
<div
|
||||
key={`f${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${s.top}%`,
|
||||
left: `${s.left}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: s.color,
|
||||
boxShadow: `0 0 ${s.size * 2}px ${s.color}`,
|
||||
opacity: reduced ? s.staticOpacity : undefined,
|
||||
transform: reduced ? 'scale(0.95)' : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animTwinkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Near parallax starfield — sparser, brighter, drifts a touch faster. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced ? 'none' : `${animParallaxNear} 48s ease-in-out infinite alternate`,
|
||||
}}
|
||||
>
|
||||
{nearStars.map((s, i) => (
|
||||
<div
|
||||
key={`n${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${s.top}%`,
|
||||
left: `${s.left}%`,
|
||||
width: `${s.size + 1}px`,
|
||||
height: `${s.size + 1}px`,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: s.color,
|
||||
boxShadow: `0 0 ${(s.size + 1) * 2.5}px ${s.color}`,
|
||||
opacity: reduced ? Math.min(1, s.staticOpacity + 0.15) : undefined,
|
||||
transform: reduced ? 'scale(1)' : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animTwinkle} ${s.duration * 0.85}s ease-in-out ${s.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hero stars — a few bright four-point gleams that pulse slowly. */}
|
||||
{heroStars.map((s, i) => (
|
||||
<div
|
||||
key={`h${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${s.top}%`,
|
||||
left: `${s.left}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
backgroundImage: gleamUri(s.color),
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
filter: `drop-shadow(0 0 4px ${s.color})`,
|
||||
opacity: reduced ? s.staticOpacity : undefined,
|
||||
transform: reduced ? 'scale(1) rotate(20deg)' : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animStarPulse} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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) => (
|
||||
<div
|
||||
key={`c${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${c.top}%`,
|
||||
left: `${c.left}%`,
|
||||
width: `${c.length}px`,
|
||||
height: '2px',
|
||||
transformOrigin: '0 50%',
|
||||
// Outer wrapper holds the rotation; inner element does the travel so
|
||||
// the streak always moves along its own length axis.
|
||||
transform: `rotate(${c.angle}deg)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: '2px',
|
||||
background: `linear-gradient(90deg, transparent 0%, ${c.color} 80%, oklch(0.98 0.02 280 / 0.95) 100%)`,
|
||||
boxShadow: `0 0 6px ${c.color}`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
opacity: reduced ? 0.85 : undefined,
|
||||
transform: reduced ? 'translate3d(46%, 0, 0)' : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animComet} ${c.duration}s ease-in ${c.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 =
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'>` +
|
||||
`<path fill='${fill}' d='M14 1C7 5 2 11 2 18c0 5 4 9 9 9 7 0 15-7 15-19 0-3-1-6-2-6-3 1-6 2-10 0C12 1 13 1 14 1z'/>` +
|
||||
`<path fill='none' stroke='${vein}' stroke-width='0.9' stroke-linecap='round' ` +
|
||||
`d='M11 26C13 18 17 9 23 3M11 26c-1-4-2-7-4-9M13 20c2-1 4-2 6-5M12 14c2-1 3-2 5-5'/>` +
|
||||
`</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 ── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: [
|
||||
// warm sun glow spilling from top-center
|
||||
`radial-gradient(60vmax 42vmax at 50% -8%, ${SUN_SOFT} 0%, transparent 60%)`,
|
||||
// verdant canopy glow rising from the lower-left
|
||||
`radial-gradient(52vmax 52vmax at 14% 100%, ${LEAF_GREEN_SOFT} 0%, transparent 62%)`,
|
||||
// lime highlight upper-right for freshness
|
||||
`radial-gradient(40vmax 40vmax at 86% 18%, ${LEAF_LIME_SOFT} 0%, transparent 58%)`,
|
||||
// cool sky tint at the very top to pair with the Earth
|
||||
`radial-gradient(70vmax 30vmax at 70% 4%, ${SKY_BLUE_SOFT} 0%, transparent 64%)`,
|
||||
].join(', '),
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Green aurora veil drifting near the top ── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-12%',
|
||||
right: '-12%',
|
||||
top: '-8%',
|
||||
height: '46vh',
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: `radial-gradient(60% 100% at 50% 0%, ${AURORA_TINT} 0%, transparent 72%)`,
|
||||
filter: 'blur(26px)',
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
transformOrigin: '50% 0%',
|
||||
opacity: reduced ? 0.55 : undefined,
|
||||
transform: reduced ? 'translate3d(0, 0, 0) scale(1.15)' : undefined,
|
||||
animation: reduced ? 'none' : `${animAuroraSway} 24s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Soft sun rays fanning down from above ── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{rays.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
left: `${r.left}%`,
|
||||
width: `${r.width}px`,
|
||||
height: '95vh',
|
||||
transformOrigin: '50% 0%',
|
||||
transform: `rotate(${r.rotate}deg)`,
|
||||
backgroundImage: `linear-gradient(180deg, ${SUN_WARM} 0%, transparent 70%)`,
|
||||
filter: 'blur(8px)',
|
||||
mixBlendMode: 'screen',
|
||||
opacity: r.opacity,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animRayBreathe} ${r.duration}s ease-in-out ${r.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Blue-marble Earth tucked into the bottom-right corner ── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '-6%',
|
||||
bottom: '-10%',
|
||||
width: '300px',
|
||||
height: '300px',
|
||||
contain: 'layout paint style',
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
transform: reduced ? 'scale(1.02)' : undefined,
|
||||
opacity: reduced ? 0.9 : undefined,
|
||||
animation: reduced ? 'none' : `${animEarthRespire} 18s ease-in-out infinite`,
|
||||
}}
|
||||
>
|
||||
{/* atmospheric rim halo */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-14%',
|
||||
borderRadius: '50%',
|
||||
backgroundImage: `radial-gradient(circle at 50% 50%, transparent 58%, ${SKY_BLUE} 68%, transparent 80%)`,
|
||||
filter: 'blur(10px)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
{/* the globe itself — oceans, land, soft terminator shadow */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: '50%',
|
||||
backgroundImage: [
|
||||
// continents (green landmasses)
|
||||
`radial-gradient(26% 30% at 38% 40%, ${LEAF_GREEN} 0%, transparent 60%)`,
|
||||
`radial-gradient(22% 26% at 64% 58%, ${LEAF_DEEP} 0%, transparent 62%)`,
|
||||
`radial-gradient(16% 18% at 50% 74%, ${LEAF_LIME} 0%, transparent 65%)`,
|
||||
// ocean base
|
||||
`radial-gradient(circle at 42% 38%, ${SKY_BLUE} 0%, ${OCEAN_BLUE} 55%, oklch(0.40 0.10 250) 100%)`,
|
||||
].join(', '),
|
||||
// soft day/night terminator from the lower-right
|
||||
boxShadow: 'inset -22px -26px 50px oklch(0.18 0.05 250 / 0.7)',
|
||||
opacity: 0.42,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Rising, glowing pollen motes ── */}
|
||||
{pollen.map((p, i) => (
|
||||
<div
|
||||
key={`p${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${p.left}%`,
|
||||
bottom: `${p.bottom}%`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
transform: reduced ? 'scale(0.95)' : undefined,
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animPollenFloat} ${p.duration}s ease-in ${p.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size}px`,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: POLLEN_GOLD,
|
||||
boxShadow: `0 0 ${p.size * 2.6}px ${POLLEN_GOLD}`,
|
||||
animation: reduced ? 'none' : `${animPollenGlow} ${p.twinkle}s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Drifting seeds / spores (skip entirely when reduced) ── */}
|
||||
{!reduced &&
|
||||
seeds.map((s, i) => (
|
||||
<div
|
||||
key={`s${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-6%',
|
||||
left: `${s.left}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'oklch(0.96 0.02 120 / 0.85)',
|
||||
boxShadow: '0 0 6px oklch(0.92 0.04 120 / 0.6)',
|
||||
willChange: 'transform, opacity',
|
||||
animation: `${animSeedDrift} ${s.duration}s linear ${s.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ── Tumbling leaves ── */}
|
||||
{leaves.map((l, i) => (
|
||||
<div
|
||||
key={`l${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
left: `${l.left}%`,
|
||||
width: `${l.size}px`,
|
||||
height: `${l.size}px`,
|
||||
backgroundImage: l.uri,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
opacity: l.opacity,
|
||||
// Static leaves are scattered down the column so the still scene
|
||||
// reads as a gentle leaf-fall frozen mid-air.
|
||||
transform: reduced
|
||||
? `translate3d(${(i % 2 ? 1 : -1) * 3}vw, ${6 + i * 9}vh, 0) rotate(${
|
||||
(i * 47) % 360
|
||||
}deg)`
|
||||
: undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animLeafTumble} ${l.duration}s ease-in ${l.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 =
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180'>` +
|
||||
`<g fill='none' stroke='rgba(196,176,224,0.32)' stroke-width='0.8'>` +
|
||||
// radial threads
|
||||
`<line x1='0' y1='0' x2='180' y2='180'/>` +
|
||||
`<line x1='0' y1='0' x2='180' y2='90'/>` +
|
||||
`<line x1='0' y1='0' x2='90' y2='180'/>` +
|
||||
`<line x1='0' y1='0' x2='180' y2='40'/>` +
|
||||
`<line x1='0' y1='0' x2='40' y2='180'/>` +
|
||||
// concentric catch-threads (gentle sag via quadratic curves)
|
||||
`<path d='M40 0 Q22 22 0 40'/>` +
|
||||
`<path d='M85 0 Q48 48 0 85'/>` +
|
||||
`<path d='M130 0 Q74 74 0 130'/>` +
|
||||
`<path d='M180 0 Q104 104 0 180'/>` +
|
||||
`<path d='M180 60 Q120 120 60 180'/>` +
|
||||
`</g></svg>`;
|
||||
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 (
|
||||
<svg
|
||||
width="46"
|
||||
height="22"
|
||||
viewBox="0 0 46 22"
|
||||
aria-hidden="true"
|
||||
style={{ display: 'block', overflow: 'visible' }}
|
||||
>
|
||||
<path
|
||||
fill="oklch(0.12 0.04 300 / 0.85)"
|
||||
d="M23 6c1.6 0 2.7 1.3 3 3 .9-1.4 2.4-2.6 4.2-2.6-.5 1-.4 2.1.2 2.9 2-2.4 5.4-4 8.6-3.7-1.5 1-2.3 2.6-2.4 4.3 1.3-.8 3-1 4.4-.4-2.2.8-3.9 2.5-5.2 4.5-2 3-4.8 5-8.3 4.4-1.9-.3-3.4-1.6-4.5-3.2-1.1 1.6-2.6 2.9-4.5 3.2-3.5.6-6.3-1.4-8.3-4.4-1.3-2-3-3.7-5.2-4.5 1.4-.6 3.1-.4 4.4.4-.1-1.7-.9-3.3-2.4-4.3 3.2-.3 6.6 1.3 8.6 3.7.6-.8.7-1.9.2-2.9 1.8 0 3.3 1.2 4.2 2.6.3-1.7 1.4-3 3-3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 ── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundColor: 'transparent',
|
||||
backgroundImage: [
|
||||
// sickly moon glow, upper-right
|
||||
`radial-gradient(38vmax 38vmax at 78% 14%, ${TOXIC_GREEN_SOFT} 0%, transparent 58%)`,
|
||||
// cold counter-glow lower-left for depth
|
||||
`radial-gradient(46vmax 46vmax at 12% 92%, ${PURPLE_FAINT} 0%, transparent 60%)`,
|
||||
// overall indigo→black wash, darker toward edges (vignette)
|
||||
`radial-gradient(120% 120% at 50% 30%, transparent 32%, ${PURPLE_DEEP} 100%)`,
|
||||
`linear-gradient(180deg, ${PURPLE_DEEP} 0%, transparent 45%)`,
|
||||
].join(', '),
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Moon disc + breathing halo (the only backdrop-filter, kept cheap) ── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8%',
|
||||
right: '12%',
|
||||
width: '160px',
|
||||
height: '160px',
|
||||
borderRadius: '50%',
|
||||
willChange: 'transform, opacity',
|
||||
backgroundImage: `radial-gradient(circle at 42% 40%, ${TOXIC_GREEN} 0%, oklch(0.55 0.14 150 / 0.5) 38%, transparent 72%)`,
|
||||
filter: 'blur(2px)',
|
||||
backdropFilter: 'saturate(1.15)',
|
||||
animation: reduced ? 'none' : `${animMoonPulse} 9s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* ── Low drifting fog bands ── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{fogBands.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-15%',
|
||||
right: '-15%',
|
||||
bottom: `${f.bottom}%`,
|
||||
height: `${f.height}px`,
|
||||
backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`,
|
||||
filter: 'blur(14px)',
|
||||
willChange: 'transform, opacity',
|
||||
opacity: reduced ? 0.5 : undefined,
|
||||
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animFogDrift} ${f.duration}s ease-in-out ${f.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Will-o'-wisps / floating embers ── */}
|
||||
{embers.map((e, i) => (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${e.left}%`,
|
||||
bottom: `${e.bottom}%`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
transform: reduced ? 'scale(0.9)' : undefined,
|
||||
opacity: reduced ? 0.4 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animEmberFloat} ${e.duration}s ease-in ${e.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: `${e.size}px`,
|
||||
height: `${e.size}px`,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: e.color,
|
||||
boxShadow: `0 0 ${e.size * 2.5}px ${e.color}`,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animEmberTwinkle} ${e.twinkle}s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Silhouetted bats gliding across (skip entirely when reduced) ── */}
|
||||
{!reduced &&
|
||||
bats.map((b, i) => (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${b.top}%`,
|
||||
left: 0,
|
||||
willChange: 'transform, opacity',
|
||||
animation: `${animBatGlide} ${b.duration}s linear ${b.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
transform: `scale(${b.scale})`,
|
||||
animation: `${animWingFlap} ${b.flap}s ease-in-out infinite`,
|
||||
}}
|
||||
>
|
||||
<BatSilhouette />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* ── Cobwebs tucked into two corners (top-left, top-right mirrored) ── */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '180px',
|
||||
height: '180px',
|
||||
backgroundImage: cobwebUri,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: '180px',
|
||||
height: '180px',
|
||||
backgroundImage: cobwebUri,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
transform: 'scaleX(-1)',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 =
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' width='760' height='320' viewBox='0 0 760 320'>` +
|
||||
`<defs>` +
|
||||
`<linearGradient id='g' x1='0' y1='0' x2='1' y2='0'>` +
|
||||
`<stop offset='0' stop-color='oklch(0.86 0.13 88)' stop-opacity='0.85'/>` +
|
||||
`<stop offset='0.55' stop-color='oklch(0.82 0.14 85)' stop-opacity='0.7'/>` +
|
||||
`<stop offset='1' stop-color='oklch(0.78 0.13 80)' stop-opacity='0.45'/>` +
|
||||
`</linearGradient>` +
|
||||
`</defs>` +
|
||||
`<g fill='none' stroke='url(%23g)' stroke-linecap='round' stroke-linejoin='round'>` +
|
||||
// Sinuous body — a thick tapering serpentine ribbon.
|
||||
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
|
||||
`stroke-width='26' opacity='0.5'/>` +
|
||||
// Inner highlight running along the body for a calligraphic sheen.
|
||||
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
|
||||
`stroke-width='7' opacity='0.7'/>` +
|
||||
// Head + horn flourish at the leading end.
|
||||
`<path d='M30 180 C10 160 8 130 26 120 M26 120 C36 112 50 116 52 130' ` +
|
||||
`stroke-width='9' opacity='0.6'/>` +
|
||||
// Mane / whisker strokes flaring back from the head.
|
||||
`<path d='M44 134 C70 120 96 132 110 152 M40 150 C66 148 92 160 104 180' ` +
|
||||
`stroke-width='5' opacity='0.45'/>` +
|
||||
// Tail wisps.
|
||||
`<path d='M740 150 C754 138 758 160 748 172 M726 158 C742 168 744 186 732 196' ` +
|
||||
`stroke-width='5' opacity='0.45'/>` +
|
||||
`</g></svg>`;
|
||||
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 `<circle cx='${cx.toFixed(1)}' cy='${cy.toFixed(1)}' r='5.4' />`;
|
||||
}).join('');
|
||||
const svg =
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'>` +
|
||||
`<g fill='oklch(0.86 0.13 88)' opacity='0.92'>${petals}</g>` +
|
||||
`<circle cx='16' cy='16' r='3.2' fill='oklch(0.94 0.10 95)'/>` +
|
||||
`</svg>`;
|
||||
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<Lantern[]>(() => {
|
||||
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<Petal[]>(() => {
|
||||
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<Ember[]>(
|
||||
() =>
|
||||
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). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: [
|
||||
`radial-gradient(120% 80% at 50% -8%, ${CRIMSON.replace(')', ' / 0.16)')} 0%, transparent 56%)`,
|
||||
`radial-gradient(90% 70% at 50% 112%, oklch(0.42 0.17 28 / 0.1) 0%, transparent 60%)`,
|
||||
`linear-gradient(180deg, oklch(0.55 0.20 28 / 0.07) 0%, transparent 26%, transparent 82%, oklch(0.40 0.16 28 / 0.08) 100%)`,
|
||||
].join(','),
|
||||
animation: reduced ? 'none' : `${animLacquerPulse} 13s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8%',
|
||||
left: '-6%',
|
||||
right: '-6%',
|
||||
height: '46%',
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: dragonUri,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'contain',
|
||||
mixBlendMode: 'screen',
|
||||
filter: 'blur(1.1px)',
|
||||
opacity: reduced ? 0.52 : undefined,
|
||||
animation: reduced ? 'none' : `${animDragonDrift} 30s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Warm vignette frame — crimson edges, clear center, with a faint cheap
|
||||
backdrop-filter for a silken haze around the rim. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backdropFilter: 'blur(0.4px) saturate(1.05)',
|
||||
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
|
||||
backgroundImage:
|
||||
'radial-gradient(135% 120% at 50% 40%, transparent 54%, oklch(0.55 0.16 28 / 0.06) 76%, oklch(0.40 0.16 28 / 0.16) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* The garland string the lanterns hang from — a faint warm line. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '6%',
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: `radial-gradient(140% 80% at 50% -40%, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.14)',
|
||||
)} 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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 (
|
||||
<div
|
||||
key={`lantern-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${l.left}%`,
|
||||
top: `${l.top}%`,
|
||||
marginLeft: `${-W / 2}px`,
|
||||
transformOrigin: 'top center',
|
||||
opacity: l.opacity,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animLanternSway} ${l.swayDuration}s ease-in-out ${l.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animLanternBob} ${l.bobDuration}s ease-in-out ${l.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
>
|
||||
{/* short cord from the string to the top cap */}
|
||||
<div
|
||||
style={{
|
||||
width: '2px',
|
||||
height: `${10 * l.scale}px`,
|
||||
margin: '0 auto',
|
||||
background: `linear-gradient(${GOLD}, ${GOLD.replace(')', ' / 0.3)')})`,
|
||||
}}
|
||||
/>
|
||||
{/* top gold cap */}
|
||||
<div
|
||||
style={{
|
||||
width: `${cap}px`,
|
||||
height: `${4 * l.scale}px`,
|
||||
margin: '0 auto',
|
||||
borderRadius: `${2 * l.scale}px`,
|
||||
background: `linear-gradient(90deg, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.6)',
|
||||
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
|
||||
boxShadow: `0 0 ${5 * l.scale}px ${GOLD.replace(')', ' / 0.55)')}`,
|
||||
}}
|
||||
/>
|
||||
{/* lantern body */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: `${W}px`,
|
||||
height: `${H}px`,
|
||||
margin: `${1 * l.scale}px auto`,
|
||||
borderRadius: '50% / 42%',
|
||||
background: `radial-gradient(circle at 38% 32%, ${VERMILION.replace(
|
||||
')',
|
||||
' / 0.95)',
|
||||
)} 0%, ${CRIMSON} 58%, oklch(0.40 0.18 26 / 0.95) 100%)`,
|
||||
border: `${1.2 * l.scale}px solid ${GOLD.replace(')', ' / 0.8)')}`,
|
||||
boxShadow: `0 0 ${16 * l.scale}px ${CRIMSON.replace(
|
||||
')',
|
||||
' / 0.5)',
|
||||
)}, inset 0 0 ${10 * l.scale}px oklch(0.78 0.16 60 / 0.35)`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* breathing inner candle glow */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '52%',
|
||||
width: `${W * 0.6}px`,
|
||||
height: `${H * 0.55}px`,
|
||||
marginLeft: `${-W * 0.3}px`,
|
||||
marginTop: `${-H * 0.275}px`,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${GOLD_HI.replace(
|
||||
')',
|
||||
' / 0.9)',
|
||||
)} 0%, oklch(0.80 0.16 65 / 0.5) 45%, transparent 75%)`,
|
||||
filter: 'blur(1px)',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animGlowBreathe} ${l.bobDuration * 0.7}s ease-in-out ${
|
||||
l.delay
|
||||
}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
{/* vertical paper ribs */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: `repeating-linear-gradient(90deg, transparent 0, transparent ${
|
||||
W / 6 - 0.6
|
||||
}px, ${GOLD.replace(')', ' / 0.18)')} ${W / 6 - 0.6}px, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.18)',
|
||||
)} ${W / 6}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* bottom gold cap */}
|
||||
<div
|
||||
style={{
|
||||
width: `${cap}px`,
|
||||
height: `${4 * l.scale}px`,
|
||||
margin: '0 auto',
|
||||
borderRadius: `${2 * l.scale}px`,
|
||||
background: `linear-gradient(90deg, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.6)',
|
||||
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
|
||||
}}
|
||||
/>
|
||||
{/* swaying silk tassel */}
|
||||
<div
|
||||
style={{
|
||||
width: `${2 * l.scale}px`,
|
||||
height: `${16 * l.scale}px`,
|
||||
margin: '0 auto',
|
||||
transformOrigin: 'top center',
|
||||
background: `linear-gradient(${CRIMSON}, ${GOLD.replace(')', ' / 0.8)')})`,
|
||||
borderRadius: '1px',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animTasselSway} ${l.swayDuration * 0.8}s ease-in-out ${l.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Drifting gold plum-blossom petals (motion only). Static settled
|
||||
blossoms render below for the reduced/preview scene. */}
|
||||
{!reduced &&
|
||||
petals.map((p, i) => (
|
||||
<div
|
||||
key={`petal-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: `${p.left}%`,
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size}px`,
|
||||
animation: `${animPetalSway} ${p.swayDuration}s ease-in-out ${p.delay}s infinite`,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundImage: blossomUri,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
opacity: p.opacity,
|
||||
filter: p.blur ? `blur(${p.blur}px)` : undefined,
|
||||
animation: `${animPetalFall} ${p.duration}s linear ${p.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 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 (
|
||||
<div
|
||||
key={`petal-static-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${p.left}%`,
|
||||
top: `${py}%`,
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size}px`,
|
||||
backgroundImage: blossomUri,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
opacity: p.opacity,
|
||||
transform: `rotate(${rand(i + 3) * 360}deg)`,
|
||||
filter: p.blur ? `blur(${p.blur}px)` : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Gold embers rising off the lanterns (motion only). */}
|
||||
{!reduced &&
|
||||
embers.map((e, i) => (
|
||||
<div
|
||||
key={`ember-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${e.left}%`,
|
||||
bottom: `${e.bottom}%`,
|
||||
width: `${e.size}px`,
|
||||
height: `${e.size}px`,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${GOLD_HI} 0%, ${GOLD.replace(
|
||||
')',
|
||||
' / 0.7)',
|
||||
)} 50%, transparent 80%)`,
|
||||
boxShadow: `0 0 5px ${GOLD.replace(')', ' / 0.6)')}`,
|
||||
animation: `${animEmberRise} ${e.duration}s ease-in ${e.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 0 L14 10 L24 12 L14 14 L12 24 L10 14 L0 12 L10 10 Z' fill='${color}'/></svg>`,
|
||||
)}")`;
|
||||
|
||||
export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
const bursts = useMemo<Burst[]>(
|
||||
() =>
|
||||
// Bursts cluster in the upper two-thirds of the sky, away from typical text.
|
||||
Array.from({ length: 7 }, (_, i) => {
|
||||
const hue = BURST_HUES[i % BURST_HUES.length];
|
||||
return {
|
||||
top: 8 + rand(i + 1) * 48,
|
||||
left: 8 + rand(i + 11) * 84,
|
||||
size: 130 + Math.floor(rand(i + 21) * 110),
|
||||
ring: hue[0],
|
||||
core: hue[1],
|
||||
duration: 6.5 + rand(i + 31) * 4,
|
||||
delay: rand(i + 41) * 9,
|
||||
};
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const confetti = useMemo<Confetto[]>(
|
||||
() =>
|
||||
Array.from({ length: 20 }, (_, i) => ({
|
||||
left: rand(i + 101) * 100,
|
||||
w: 4 + Math.floor(rand(i + 111) * 4),
|
||||
h: 7 + Math.floor(rand(i + 121) * 7),
|
||||
color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
|
||||
round: i % 4 === 0,
|
||||
fallDur: 9 + rand(i + 131) * 7,
|
||||
swayDur: 3 + rand(i + 141) * 3,
|
||||
delay: rand(i + 151) * 10,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const stars = useMemo<Star[]>(
|
||||
() =>
|
||||
Array.from({ length: 9 }, (_, i) => ({
|
||||
top: 4 + rand(i + 201) * 64,
|
||||
left: 4 + rand(i + 211) * 92,
|
||||
size: 8 + Math.floor(rand(i + 221) * 10),
|
||||
color: i % 2 === 0 ? 'oklch(0.85 0.13 90)' : 'oklch(0.92 0.06 200)',
|
||||
duration: 3 + rand(i + 231) * 3,
|
||||
delay: rand(i + 241) * 4,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Midnight sky — layered oklch gradients for depth, with a faint breathe. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundColor: 'oklch(0.2 0.07 260 / 0.12)',
|
||||
backgroundImage: [
|
||||
'radial-gradient(120% 90% at 50% -10%, oklch(0.32 0.1 280 / 0.16) 0%, transparent 60%)',
|
||||
'radial-gradient(90% 70% at 18% 8%, oklch(0.7 0.22 350 / 0.07) 0%, transparent 55%)',
|
||||
'radial-gradient(90% 70% at 84% 4%, oklch(0.8 0.15 200 / 0.07) 0%, transparent 55%)',
|
||||
'radial-gradient(140% 120% at 50% 120%, oklch(0.2 0.07 260 / 0.14) 0%, transparent 70%)',
|
||||
].join(','),
|
||||
animation: reduced ? 'none' : `${animSkyPulse} 9s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Champagne-gold shimmer sweep. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '55%',
|
||||
contain: 'layout paint style',
|
||||
backgroundImage:
|
||||
'linear-gradient(100deg, transparent 0%, oklch(0.85 0.13 90 / 0.05) 38%, oklch(0.95 0.09 95 / 0.1) 50%, oklch(0.85 0.13 90 / 0.05) 62%, transparent 100%)',
|
||||
transform: reduced ? 'translate3d(30%, 0, 0) skewX(-12deg)' : undefined,
|
||||
opacity: reduced ? 0.45 : undefined,
|
||||
animation: reduced ? 'none' : `${animShimmer} 11s ease-in-out infinite`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Fireworks — expanding spark rings + a core flash. In reduced mode we
|
||||
freeze the first burst mid-bloom and drop the rest. */}
|
||||
{(reduced ? bursts.slice(0, 1) : bursts).map((b, i) => (
|
||||
<div
|
||||
key={`b${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${b.top}%`,
|
||||
left: `${b.left}%`,
|
||||
width: `${b.size}px`,
|
||||
height: `${b.size}px`,
|
||||
marginLeft: `${-b.size / 2}px`,
|
||||
marginTop: `${-b.size / 2}px`,
|
||||
}}
|
||||
>
|
||||
{/* Spark ring */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: '50%',
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
background: `radial-gradient(circle, transparent 56%, ${b.ring} 64%, transparent 74%)`,
|
||||
transform: reduced ? 'scale(0.82)' : undefined,
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBurst} ${b.duration}s ease-out ${b.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
{/* Inner secondary ring for a fuller bloom */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '18%',
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, transparent 50%, ${b.ring} 60%, transparent 72%)`,
|
||||
transform: reduced ? 'scale(0.7)' : undefined,
|
||||
opacity: reduced ? 0.4 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBurst} ${b.duration}s ease-out ${b.delay + 0.12}s infinite`,
|
||||
}}
|
||||
/>
|
||||
{/* Core flash */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
width: '14%',
|
||||
height: '14%',
|
||||
marginLeft: '-7%',
|
||||
marginTop: '-7%',
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle, ${b.core} 0%, transparent 70%)`,
|
||||
transform: reduced ? 'scale(0.9)' : undefined,
|
||||
opacity: reduced ? 0.85 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animCoreFlash} ${b.duration}s ease-out ${b.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Twinkling sparkle stars. */}
|
||||
{stars.map((s, i) => (
|
||||
<div
|
||||
key={`s${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${s.top}%`,
|
||||
left: `${s.left}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
backgroundImage: sparkleUri(s.color),
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
transform: reduced ? 'scale(0.9) rotate(30deg)' : undefined,
|
||||
opacity: reduced ? 0.75 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animTwinkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Falling confetti slivers. In reduced mode, a still scatter at varied
|
||||
heights so the static thumbnail reads as a celebration in progress. */}
|
||||
{confetti.map((c, i) => {
|
||||
const staticTop = reduced ? 6 + rand(i + 301) * 78 : undefined;
|
||||
return (
|
||||
<div
|
||||
key={`c${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: reduced ? `${staticTop}%` : 0,
|
||||
left: `${c.left}%`,
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animConfettiSway} ${c.swayDur}s ease-in-out ${c.delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${c.w}px`,
|
||||
height: reduced && c.round ? `${c.w}px` : `${c.h}px`,
|
||||
borderRadius: c.round ? '50%' : '1px',
|
||||
backgroundColor: c.color,
|
||||
opacity: reduced ? 0.8 : 0.85,
|
||||
transform: reduced ? `rotate(${Math.floor(rand(i + 311) * 360)}deg)` : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animConfettiFall} ${c.fallDur}s ease-in ${c.delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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) => `<path d="${heart}" transform="rotate(${r}) translate(0 -12)"/>`)
|
||||
.join('');
|
||||
const stem = `<path d="M0,8 C-1,18 2,26 0,34" stroke="${
|
||||
fill
|
||||
}" stroke-width="2.4" fill="none" stroke-linecap="round"/>`;
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-26 -26 52 64">` +
|
||||
`<g fill="${fill}">${lobes}</g>${stem}</svg>`;
|
||||
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<Clover[]>(() => {
|
||||
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<Coin[]>(() => {
|
||||
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<Mote[]>(() => {
|
||||
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). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: [
|
||||
'radial-gradient(120% 85% at 50% -12%, oklch(0.60 0.16 150 / 0.16) 0%, transparent 56%)',
|
||||
'radial-gradient(90% 65% at 12% 112%, oklch(0.55 0.15 145 / 0.12) 0%, transparent 60%)',
|
||||
'radial-gradient(80% 60% at 92% 108%, oklch(0.82 0.14 90 / 0.07) 0%, transparent 62%)',
|
||||
'linear-gradient(180deg, oklch(0.62 0.15 150 / 0.05) 0%, transparent 24%, transparent 82%, oklch(0.5 0.14 148 / 0.08) 100%)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Verdant vignette frame — green edges, clear center. A single cheap
|
||||
backdrop-filter adds a faint warm-emerald haze around the rim. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backdropFilter: 'blur(0.4px) saturate(1.05)',
|
||||
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
|
||||
backgroundImage:
|
||||
'radial-gradient(140% 125% at 50% 44%, transparent 50%, oklch(0.6 0.13 150 / 0.07) 74%, oklch(0.48 0.14 148 / 0.17) 100%)',
|
||||
animation: reduced ? 'none' : `${animVerdantBreathe} 13s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-22%',
|
||||
right: '-18%',
|
||||
width: '62%',
|
||||
height: '62%',
|
||||
contain: 'layout paint style',
|
||||
mixBlendMode: 'screen',
|
||||
filter: 'blur(30px)',
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
// Concentric arc bands — red through violet, all low alpha.
|
||||
backgroundImage: [
|
||||
'radial-gradient(closest-side at 78% 28%, transparent 58%, oklch(0.7 0.18 28 / 0.16) 62%, transparent 67%)',
|
||||
'radial-gradient(closest-side at 78% 28%, transparent 63%, oklch(0.82 0.16 80 / 0.16) 67%, transparent 72%)',
|
||||
'radial-gradient(closest-side at 78% 28%, transparent 68%, oklch(0.85 0.17 130 / 0.16) 72%, transparent 77%)',
|
||||
'radial-gradient(closest-side at 78% 28%, transparent 73%, oklch(0.72 0.15 230 / 0.15) 77%, transparent 82%)',
|
||||
'radial-gradient(closest-side at 78% 28%, transparent 78%, oklch(0.6 0.16 300 / 0.13) 82%, transparent 87%)',
|
||||
].join(','),
|
||||
animation: reduced ? 'none' : `${animRainbowShimmer} 20s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gold-coin glints — small metallic discs that catch the light. */}
|
||||
{coins.map((c, i) => (
|
||||
<div
|
||||
key={`coin-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${c.left}%`,
|
||||
top: `${c.top}%`,
|
||||
width: `${c.size}px`,
|
||||
height: `${c.size}px`,
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle at 36% 32%, oklch(0.97 0.06 95 / 0.95) 0%, oklch(0.82 0.14 90 / 0.85) 45%, oklch(0.68 0.13 78 / 0.4) 78%, transparent 100%)',
|
||||
boxShadow: `0 0 ${c.size * 0.9}px ${c.size * 0.35}px oklch(0.82 0.14 90 / 0.4)`,
|
||||
opacity: reduced ? 0.85 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animCoinGlint} ${c.duration}s ease-in-out ${c.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Golden sparkle motes — tiny four-point glints of luck. */}
|
||||
{motes.map((m, i) => (
|
||||
<div
|
||||
key={`mote-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${m.left}%`,
|
||||
top: `${m.top}%`,
|
||||
width: `${m.size}px`,
|
||||
height: `${m.size}px`,
|
||||
borderRadius: '50%',
|
||||
background:
|
||||
'radial-gradient(circle, oklch(0.98 0.05 95 / 0.95) 0%, oklch(0.85 0.13 88 / 0.6) 50%, transparent 100%)',
|
||||
boxShadow: '0 0 6px oklch(0.85 0.13 88 / 0.6)',
|
||||
opacity: reduced ? 0.9 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animMoteTwinkle} ${m.duration}s ease-in-out ${m.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Drifting clovers (motion only) — three parallax bands tumbling down.
|
||||
Settled static scatter is rendered below for reduced/preview. */}
|
||||
{!reduced &&
|
||||
clovers.map((c, i) => (
|
||||
<div
|
||||
key={`clover-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: `${c.left}%`,
|
||||
width: `${c.size}px`,
|
||||
height: `${c.size * 1.2}px`,
|
||||
animation: `${animCloverSway} ${c.swayDuration}s ease-in-out ${c.delay}s infinite`,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundImage: cloverSvg(c.leaves, c.fill),
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
opacity: c.opacity,
|
||||
filter: `drop-shadow(0 0 3px oklch(0.55 0.15 145 / 0.4))${
|
||||
c.blur ? ` blur(${c.blur}px)` : ''
|
||||
}`,
|
||||
animation: `${animCloverTumble} ${c.duration}s linear ${c.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 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) => (
|
||||
<div
|
||||
key={`clover-static-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${c.left}%`,
|
||||
top: `${c.restTop}%`,
|
||||
width: `${c.size}px`,
|
||||
height: `${c.size * 1.2}px`,
|
||||
backgroundImage: cloverSvg(c.leaves, c.fill),
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
transform: `rotate(${c.restRot}deg)`,
|
||||
opacity: c.opacity,
|
||||
filter: `drop-shadow(0 0 3px oklch(0.55 0.15 145 / 0.4))${
|
||||
c.blur ? ` blur(${c.blur}px)` : ''
|
||||
}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>
|
||||
<defs><radialGradient id='g' cx='38%' cy='32%' r='75%'>
|
||||
<stop offset='0%' stop-color='${glint}'/><stop offset='55%' stop-color='${fill}'/>
|
||||
<stop offset='100%' stop-color='${fill}' stop-opacity='0.85'/></radialGradient></defs>
|
||||
<path fill='url(%23g)' d='M16 28C16 28 3 19.5 3 11.2 3 6.8 6.4 4 10 4c2.6 0 4.7 1.5 6 3.6C17.3 5.5 19.4 4 22 4c3.6 0 7 2.8 7 7.2C29 19.5 16 28 16 28z'/></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 = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 32'>
|
||||
<defs><linearGradient id='p' x1='0' y1='0' x2='1' y2='1'>
|
||||
<stop offset='0%' stop-color='${fill}' stop-opacity='0.6'/>
|
||||
<stop offset='100%' stop-color='${fill}'/></linearGradient></defs>
|
||||
<path fill='url(%23p)' d='M12 1C5 8 2 16 4 24c1.4 5.4 6 7 8 7s6.6-1.6 8-7C22 16 19 8 12 1z'/>
|
||||
<path d='M12 4C9 11 8 18 11 30' stroke='${fill}' stroke-opacity='0.35' stroke-width='1' fill='none'/></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<Heart[]>(() => {
|
||||
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<Petal[]>(() => {
|
||||
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<Bokeh[]>(() => {
|
||||
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<Sparkle[]>(() => {
|
||||
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). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backgroundImage: [
|
||||
'radial-gradient(120% 80% at 50% 112%, oklch(0.7 0.15 10 / 0.12) 0%, transparent 58%)',
|
||||
'radial-gradient(90% 70% at 15% -8%, oklch(0.9 0.06 350 / 0.1) 0%, transparent 60%)',
|
||||
'radial-gradient(90% 70% at 88% 0%, oklch(0.6 0.18 20 / 0.07) 0%, transparent 62%)',
|
||||
'linear-gradient(180deg, oklch(0.96 0.03 60 / 0.04) 0%, transparent 30%, transparent 72%, oklch(0.66 0.16 12 / 0.07) 100%)',
|
||||
].join(','),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Blush vignette frame — soft warm edges, clear center. A single cheap
|
||||
backdrop-filter layer for a faint dreamy haze around the rim. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
contain: 'layout paint style',
|
||||
backdropFilter: 'saturate(1.05) brightness(1.01)',
|
||||
WebkitBackdropFilter: 'saturate(1.05) brightness(1.01)',
|
||||
backgroundImage:
|
||||
'radial-gradient(135% 120% at 50% 46%, transparent 50%, oklch(0.85 0.1 355 / 0.06) 74%, oklch(0.62 0.16 12 / 0.14) 100%)',
|
||||
animation: reduced ? 'none' : `${animBlushPulse} 13s ease-in-out infinite`,
|
||||
willChange: reduced ? undefined : 'opacity',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dreamy bokeh orbs — soft blurred blush lights that breathe. */}
|
||||
{bokeh.map((b, i) => (
|
||||
<div
|
||||
key={`bokeh-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${b.left}%`,
|
||||
top: `${b.top}%`,
|
||||
width: `${b.size}px`,
|
||||
height: `${b.size}px`,
|
||||
marginLeft: `${-b.size / 2}px`,
|
||||
marginTop: `${-b.size / 2}px`,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(circle at 42% 38%, ${b.color.replace(
|
||||
')',
|
||||
' / 0.5)',
|
||||
)} 0%, ${b.color.replace(')', ' / 0.18)')} 45%, transparent 72%)`,
|
||||
filter: 'blur(10px)',
|
||||
mixBlendMode: 'screen',
|
||||
opacity: reduced ? 0.7 : undefined,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBokehBreathe} ${b.duration}s ease-in-out ${b.delay}s infinite`,
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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) => (
|
||||
<div
|
||||
key={`heart-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: `${h.left}%`,
|
||||
width: `${h.size}px`,
|
||||
height: `${h.size}px`,
|
||||
animation: `${animHeartBob} ${h.bobDuration}s ease-in-out ${h.delay}s infinite`,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundImage: h.image,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
filter: `drop-shadow(0 0 ${h.size * 0.2}px oklch(0.7 0.15 10 / 0.45))${
|
||||
h.blur ? ` blur(${h.blur}px)` : ''
|
||||
}`,
|
||||
opacity: h.opacity,
|
||||
animation: `${animHeartRise} ${h.duration}s ease-in-out ${h.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Drifting rose petals (motion) — tumbling down through the scene. */}
|
||||
{!reduced &&
|
||||
petals.map((p, i) => (
|
||||
<div
|
||||
key={`petal-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: `${p.left}%`,
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size * 1.33}px`,
|
||||
backgroundImage: p.image,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
opacity: p.opacity,
|
||||
animation: `${animPetalTumble} ${p.duration}s linear ${p.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Faint sparkle glints (motion) — sparse romantic twinkle. */}
|
||||
{!reduced &&
|
||||
sparkles.map((s, i) => (
|
||||
<div
|
||||
key={`sparkle-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${s.left}%`,
|
||||
top: `${s.top}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
background: `radial-gradient(circle, ${CREAM.replace(
|
||||
')',
|
||||
' / 0.9)',
|
||||
)} 0%, oklch(0.9 0.06 350 / 0.5) 40%, transparent 70%)`,
|
||||
borderRadius: '50%',
|
||||
animation: `${animSparkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||
willChange: 'transform, opacity',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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) => (
|
||||
<div
|
||||
key={`heart-static-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${h.left}%`,
|
||||
top: `${h.restTop}%`,
|
||||
width: `${h.size}px`,
|
||||
height: `${h.size}px`,
|
||||
backgroundImage: h.image,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
filter: `drop-shadow(0 0 ${h.size * 0.2}px oklch(0.7 0.15 10 / 0.4))${
|
||||
h.blur ? ` blur(${h.blur}px)` : ''
|
||||
}`,
|
||||
opacity: h.opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{reduced &&
|
||||
petals.map((p, i) => (
|
||||
<div
|
||||
key={`petal-static-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${p.left}%`,
|
||||
top: `${p.restTop}%`,
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size * 1.33}px`,
|
||||
backgroundImage: p.image,
|
||||
backgroundSize: 'contain',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
transform: `rotate(${p.rotate}deg)`,
|
||||
opacity: p.opacity,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{reduced &&
|
||||
sparkles.map((s, i) => (
|
||||
<div
|
||||
key={`sparkle-static-${i}`}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${s.left}%`,
|
||||
top: `${s.top}%`,
|
||||
width: `${s.size}px`,
|
||||
height: `${s.size}px`,
|
||||
background: `radial-gradient(circle, ${CREAM.replace(
|
||||
')',
|
||||
' / 0.85)',
|
||||
)} 0%, oklch(0.9 0.06 350 / 0.45) 40%, transparent 70%)`,
|
||||
borderRadius: '50%',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user