365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
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>
|
|||
|
|
))}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|