2026-06-30 19:41:58 -04:00
|
|
|
import React, { useMemo } from 'react';
|
|
|
|
|
import { SeasonalOverlayProps } from '../types';
|
|
|
|
|
import {
|
|
|
|
|
animLeafFall,
|
|
|
|
|
animLeafSway,
|
|
|
|
|
animLeafFlutter,
|
|
|
|
|
animSunShaft,
|
|
|
|
|
animMoteDrift,
|
|
|
|
|
animMoteTwinkle,
|
|
|
|
|
animEmberPulse,
|
2026-09-25 13:36:13 -04:00
|
|
|
leafBlade,
|
2026-06-30 19:41:58 -04:00
|
|
|
} 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.
|
2026-09-25 13:36:13 -04:00
|
|
|
// [#237] A real autumn palette: [body, tip highlight, vein]. The previous
|
|
|
|
|
// muted tans read as beige smudges on the light theme.
|
|
|
|
|
const LEAF_TONES: [string, string, string][] = [
|
|
|
|
|
['oklch(0.56 0.19 30)', 'oklch(0.68 0.17 45)', 'oklch(0.38 0.12 28)'], // crimson
|
|
|
|
|
['oklch(0.68 0.17 52)', 'oklch(0.8 0.15 75)', 'oklch(0.45 0.12 45)'], // pumpkin orange
|
|
|
|
|
['oklch(0.8 0.15 85)', 'oklch(0.9 0.11 95)', 'oklch(0.55 0.1 70)'], // gold
|
|
|
|
|
['oklch(0.5 0.1 50)', 'oklch(0.62 0.11 60)', 'oklch(0.34 0.06 45)'], // russet brown
|
|
|
|
|
['oklch(0.62 0.18 40)', 'oklch(0.74 0.16 60)', 'oklch(0.42 0.12 35)'], // burnt orange
|
2026-06-30 19:41:58 -04:00
|
|
|
];
|
|
|
|
|
|
2026-09-25 13:36:13 -04:00
|
|
|
type LeafKind = 'maple' | 'oak' | 'aspen';
|
2026-06-30 19:41:58 -04:00
|
|
|
|
2026-09-25 13:36:13 -04:00
|
|
|
// Leaf silhouettes (viewBox 0 0 100 100, stem at the bottom). The maple is the
|
|
|
|
|
// flag-style silhouette on purpose: its bold lobes stay recognisable at 30 px,
|
|
|
|
|
// where the previous star-like outline read as a splat.
|
|
|
|
|
const LEAF_SHAPES: Record<LeafKind, { body: string; veins: string }> = {
|
|
|
|
|
maple: {
|
|
|
|
|
body:
|
|
|
|
|
'M50 5 L54 20 L62 14 L60 30 L74 22 L70 36 L86 34 L78 46 L90 52 L74 56 L78 66 ' +
|
|
|
|
|
'L60 62 L56 72 L52 64 L52 94 L48 94 L48 64 L44 72 L40 62 L22 66 L26 56 L10 52 ' +
|
|
|
|
|
'L22 46 L14 34 L30 36 L26 22 L40 30 L38 14 L46 20 Z',
|
|
|
|
|
veins: 'M50 66 L50 14 M50 58 L80 40 M50 58 L20 40 M50 64 L70 60 M50 64 L30 60',
|
|
|
|
|
},
|
|
|
|
|
oak: {
|
|
|
|
|
body:
|
|
|
|
|
'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 80 52 86 52 96 L48 96 ' +
|
|
|
|
|
'C48 86 46 80 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',
|
|
|
|
|
veins:
|
|
|
|
|
'M50 76 L50 12 M50 34 L64 28 M50 34 L36 28 M50 50 L66 44 M50 50 L34 44 M50 64 L62 60 M50 64 L38 60',
|
|
|
|
|
},
|
|
|
|
|
aspen: {
|
|
|
|
|
body: 'M50 8 C74 22 80 54 52 84 L52 96 L48 96 L48 84 C20 54 26 22 50 8 Z',
|
|
|
|
|
veins: 'M50 84 L50 16 M50 40 L64 32 M50 40 L36 32 M50 58 L66 50 M50 58 L34 50',
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Build a CSS-ready data-URI of one leaf: gradient body, darker veins. */
|
|
|
|
|
function leafDataUri(kind: LeafKind, [body, tip, vein]: [string, string, string]): string {
|
|
|
|
|
const shape = LEAF_SHAPES[kind];
|
2026-06-30 19:41:58 -04:00
|
|
|
const svg =
|
|
|
|
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>` +
|
2026-09-25 13:36:13 -04:00
|
|
|
`<defs><linearGradient id='g' x1='0' y1='1' x2='0.4' y2='0'>` +
|
|
|
|
|
`<stop offset='0' stop-color='${body}'/><stop offset='1' stop-color='${tip}'/>` +
|
|
|
|
|
`</linearGradient></defs>` +
|
|
|
|
|
`<path d='${shape.body}' fill='url(#g)'/>` +
|
|
|
|
|
`<path d='${shape.veins}' stroke='${vein}' stroke-opacity='0.7' stroke-width='2' ` +
|
|
|
|
|
`fill='none' stroke-linecap='round'/>` +
|
2026-06-30 19:41:58 -04:00
|
|
|
`</svg>`;
|
|
|
|
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Leaf = {
|
2026-09-25 13:36:13 -04:00
|
|
|
kind: LeafKind;
|
2026-06-30 19:41:58 -04:00
|
|
|
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<{
|
2026-09-25 13:36:13 -04:00
|
|
|
kind: LeafKind;
|
2026-06-30 19:41:58 -04:00
|
|
|
left: number;
|
|
|
|
|
top: number;
|
|
|
|
|
size: number;
|
|
|
|
|
tilt: number;
|
|
|
|
|
tone: number;
|
|
|
|
|
opacity: number;
|
|
|
|
|
}> = [
|
2026-09-25 13:36:13 -04:00
|
|
|
// [#237] In areas that are empty in the layout (the middle of the left icon
|
|
|
|
|
// rail, the space under the member list), partly off-edge, instead of on
|
|
|
|
|
// the sidebar, members, composer or corner controls.
|
|
|
|
|
{ kind: 'maple', left: 0.5, top: 44, size: 60, tilt: 24, tone: 0, opacity: 0.55 },
|
|
|
|
|
{ kind: 'aspen', left: 1.5, top: 54, size: 44, tilt: -30, tone: 2, opacity: 0.5 },
|
|
|
|
|
{ kind: 'oak', left: 99, top: 62, size: 54, tilt: 160, tone: 3, opacity: 0.5 },
|
|
|
|
|
{ kind: 'maple', left: 97.5, top: 72, size: 46, tilt: 200, tone: 4, opacity: 0.5 },
|
2026-06-30 19:41:58 -04:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
|
|
|
|
|
// Deterministic pseudo-random field, computed ONCE. No per-frame state.
|
|
|
|
|
const { leaves, motes } = useMemo(() => {
|
2026-09-25 13:36:13 -04:00
|
|
|
const LEAF_COUNT = 12;
|
2026-06-30 19:41:58 -04:00
|
|
|
const MOTE_COUNT = 12;
|
|
|
|
|
|
|
|
|
|
const builtLeaves: Leaf[] = Array.from({ length: LEAF_COUNT }, (_, i) => {
|
2026-09-25 13:36:13 -04:00
|
|
|
const kind: LeafKind = (['maple', 'oak', 'maple', 'aspen'] as const)[i % 4];
|
2026-06-30 19:41:58 -04:00
|
|
|
const tone = LEAF_TONES[i % LEAF_TONES.length];
|
|
|
|
|
const sizeBucket = i % 4; // 0..3 → depth bucket for parallax
|
2026-09-25 13:36:13 -04:00
|
|
|
const size = 28 + sizeBucket * 8; // 28..52 px
|
2026-06-30 19:41:58 -04:00
|
|
|
return {
|
|
|
|
|
kind,
|
|
|
|
|
uri: leafDataUri(kind, tone),
|
2026-09-25 13:36:13 -04:00
|
|
|
left: (i * 8.3 + 4) % 100,
|
2026-06-30 19:41:58 -04:00
|
|
|
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,
|
2026-09-25 13:36:13 -04:00
|
|
|
// Nearer → bolder, capped at 0.6 so text under a passing leaf stays readable.
|
|
|
|
|
opacity: 0.5 + sizeBucket * 0.05,
|
2026-06-30 19:41:58 -04:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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"
|
2026-09-25 13:36:13 -04:00
|
|
|
className={leafBlade}
|
2026-06-30 19:41:58 -04:00
|
|
|
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',
|
2026-09-25 13:36:13 -04:00
|
|
|
// No `paint` containment: it clips children to this leaf-sized
|
|
|
|
|
// box pinned at the top, so the falling leaf was never drawn
|
|
|
|
|
// once it left the first ~40 px (#237).
|
|
|
|
|
contain: 'layout style',
|
2026-06-30 19:41:58 -04:00
|
|
|
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
|
2026-09-25 13:36:13 -04:00
|
|
|
className={leafBlade}
|
2026-06-30 19:41:58 -04:00
|
|
|
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`,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|