Arcade: - perspective grid is masked through the central timeline column so it frames the chat instead of striping messages; lower opacity, thinner non-scaling strokes (also fixes the grid vanishing in the 76px swatch) - neon horizon line is broken through the middle so it never underlines a message; softer sun glow - "1UP" score + "INSERT COIN" moved from top-left/bottom-centre (space bar / composer) to a single bottom-right HUD stack; font-size clamps to 0 via cqw inside the settings swatch so no more clipped glyphs - scanlines: 4px pitch at lower opacity (light theme was fully striped); ambient wash slightly lighter Deep Space: - starfield 28 → 62 stars across the two parallax layers - base violet wash slightly lighter for light themes SeasonalPreview wrapper is now a size container (container-type) so overlays can scale/hide fixed-px details with cqw. LOTUS_FEATURES.md theme table now describes what both overlays actually render. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
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(40, 1000), []);
|
||
const nearStars = useMemo<Star[]>(() => makeStars(22, 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.12)',
|
||
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>
|
||
))}
|
||
</>
|
||
);
|
||
}
|