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