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