feat(seasonal): Autumn pass 2 — no tint, a gust, a real preview (#237)
CI / Build & Quality Checks (pull_request) Failing after 1m30s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 5s

Draft for human review (#237 must be approved and closed by a human).

- Removed the full-screen amber wash, sun-shaft bands and vignette: they
  turned the user's theme beige (muddy brown on dark) and lowered contrast
  everywhere. The leaves carry the season on their own. Also removed the
  dust motes (invisible specks) and the now-unused keyframes.
- Gust: every 42 s (first ~8 s after load) five leaves blow across the screen
  together, rising and spinning, in ~5 s. Linear timing — an eased curve is
  applied per keyframe segment and made them hang mid-air for a second.
- Softer flutter (leaves flattened into slivers mid-flip), stronger leaf
  colour (opacity 0.58–0.73, less pale gold tip), brighter on dark themes.
- Reduced motion on phones: no icon rail there, so the resting leaves tuck
  further off-edge and smaller instead of landing on the timeline icons.
- Settings swatch: new optional `preview` prop on seasonal overlays; Autumn
  draws a warm backdrop with arranged leaves (the reduced scene rendered in
  the tile as a black square with two edge fragments).

Rendered and inspected: light/dark/phone at 10/15/20 s, reduced on desktop
and phone, gust tracked frame by frame, the Settings swatch; 60 fps with the
theme on vs off (p95 frame 16.7 ms both).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-25 13:58:15 -04:00
co-authored by Claude Opus 5.5
parent e080059f37
commit 3e132069c0
4 changed files with 230 additions and 293 deletions
@@ -3,13 +3,13 @@ import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { zIndices } from '../../styles/zIndex';
import { SeasonTheme } from './types';
import { SeasonTheme, SeasonalOverlayProps } from './types';
import { resolveSeasonTheme } from './seasonSchedule';
// [Gitea #167] Each overlay (its particles + keyframes) is its own chunk,
// fetched only when that season is active or previewed in Settings — the
// date/override decision stays in the main bundle.
const lazyOverlay = (load: () => Promise<{ default: React.ComponentType<{ reduced: boolean }> }>) =>
const lazyOverlay = (load: () => Promise<{ default: React.ComponentType<SeasonalOverlayProps> }>) =>
React.lazy(load);
const HalloweenOverlay = lazyOverlay(() =>
import('./themes/Halloween').then((m) => ({ default: m.HalloweenOverlay })),
@@ -52,7 +52,11 @@ export type { SeasonTheme };
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
function buildOverlayContent(theme: SeasonTheme, reduced: boolean): React.ReactNode {
function buildOverlayContent(
theme: SeasonTheme,
reduced: boolean,
preview = false,
): React.ReactNode {
switch (theme) {
case 'halloween':
return <HalloweenOverlay reduced={reduced} />;
@@ -61,7 +65,7 @@ function buildOverlayContent(theme: SeasonTheme, reduced: boolean): React.ReactN
case 'newyear':
return <NewYearOverlay reduced={reduced} />;
case 'autumn':
return <AutumnOverlay reduced={reduced} />;
return <AutumnOverlay reduced={reduced} preview={preview} />;
case 'aprilfools':
return <AprilFoolsOverlay reduced={reduced} />;
case 'lunar':
@@ -122,7 +126,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
containerType: 'inline-size',
}}
>
<Suspense fallback={null}>{buildOverlayContent(theme, true)}</Suspense>
<Suspense fallback={null}>{buildOverlayContent(theme, true, true)}</Suspense>
</div>
);
}
@@ -2,34 +2,22 @@ import { keyframes, style } from '@vanilla-extract/css';
/**
* Autumn overlay keyframes. Every animation touches ONLY `transform` and
* `opacity` so the compositor can run them on the GPU without triggering
* layout or paint. keyframes() returns the generated animation-name string,
* which is applied inline in Autumn.tsx.
*
* Motion philosophy: warm, slow, cozy. Leaves tumble and rotate as they fall
* with a per-leaf sway decoupled on a wrapper; sun shafts breathe; dust motes
* drift up through the light; the whole frame has a barely-there warm pulse.
* `opacity` so the compositor can run them without layout or paint.
*/
/**
* A leaf falls from above to below the viewport while continuously rotating.
* A single tall translateY serves every leaf — per-leaf duration/delay/scale
* create the parallax variety. Horizontal travel is intentionally small here
* because the real lateral motion comes from the sway wrapper below.
* A leaf falls from above to below the viewport while slowly turning. One tall
* translateY serves every leaf; per-leaf duration/delay/size give parallax.
*/
export const animLeafFall = keyframes({
'0%': { transform: 'translate3d(0, -12vh, 0) rotate(-30deg)', opacity: '0' },
'8%': { opacity: '1' },
'50%': { transform: 'translate3d(10px, 50vh, 0) rotate(200deg)' },
'92%': { opacity: '0.85' },
'100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(430deg)', opacity: '0' },
'6%': { opacity: '1' },
'50%': { transform: 'translate3d(10px, 50vh, 0) rotate(160deg)' },
'94%': { opacity: '0.9' },
'100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(340deg)', opacity: '0' },
});
/**
* Lateral sway applied to a leaf's wrapper so the descent reads as wind
* catching the blade. Decoupled from the fall so the two compose into an
* organic, non-repeating-looking path.
*/
/** Lateral sway on the leaf's wrapper so the descent reads as wind. */
export const animLeafSway = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' },
'50%': { transform: 'translate3d(34px, 0, 0)' },
@@ -37,68 +25,40 @@ export const animLeafSway = keyframes({
});
/**
* A second flutter on the leaf's inner shape: a gentle skew/scale wobble that
* mimics the blade catching air as it spins. Cheap, transform-only.
* Soft flutter on the blade. [#237] Gentler than before (scaleX 0.82 made
* leaves flatten into slivers mid-flip).
*/
export const animLeafFlutter = keyframes({
'0%': { transform: 'rotate(-8deg) scaleX(1)' },
'50%': { transform: 'rotate(8deg) scaleX(0.82)' },
'100%': { transform: 'rotate(-8deg) scaleX(1)' },
'0%': { transform: 'rotate(-10deg) scaleX(1)' },
'50%': { transform: 'rotate(10deg) scaleX(0.92)' },
'100%': { transform: 'rotate(-10deg) scaleX(1)' },
});
/**
* Low-sun light shaft: a long soft beam slowly slides and breathes. Uses
* translateX + opacity (never background-position) so it stays on the
* compositor. Scale on Y makes the beam subtly elongate as it brightens.
* [#237] A gust: the leaf enters from the left, is blown across and up while
* spinning, and leaves on the right in ~6 s (14 % of a 42 s cycle). The rest
* of the cycle it waits off-screen, invisible.
*/
export const animSunShaft = keyframes({
'0%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
'50%': { transform: 'translate3d(4%, 0, 0) scaleY(1.06)', opacity: '0.75' },
'100%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
export const animGust = keyframes({
'0%': { transform: 'translate3d(-12vw, 0, 0) rotate(0deg)', opacity: '0' },
'1%': { opacity: '1' },
// Fast in, then carried steadily across and up (linear timing: an eased
// curve would apply per segment and stop the leaves mid-air).
'3.5%': { transform: 'translate3d(22vw, -3vh, 0) rotate(200deg)' },
'8%': { transform: 'translate3d(62vw, -12vh, 0) rotate(420deg)' },
'12.5%': { opacity: '1' },
'14%': { transform: 'translate3d(112vw, -24vh, 0) rotate(700deg)', opacity: '0' },
'100%': { transform: 'translate3d(112vw, -24vh, 0) rotate(700deg)', opacity: '0' },
});
/**
* Dust / pollen mote: a tiny speck drifts upward through the light, swaying,
* pulsing softly in brightness as it catches the sun. transform + opacity.
*/
export const animMoteDrift = keyframes({
'0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' },
'15%': { opacity: '0.85' },
'40%': { transform: 'translate3d(16px, -30vh, 0) scale(1)' },
'70%': { transform: 'translate3d(-12px, -58vh, 0) scale(0.85)', opacity: '0.6' },
'90%': { opacity: '0.2' },
'100%': { transform: 'translate3d(10px, -84vh, 0) scale(0.6)', opacity: '0' },
});
/**
* Independent twinkle for motes — a brightness flicker layered on the drift so
* specks shimmer as if turning in the light. Opacity only.
*/
export const animMoteTwinkle = keyframes({
'0%': { opacity: '0.5' },
'50%': { opacity: '1' },
'100%': { opacity: '0.5' },
});
/**
* Barely-there breathing of the warm vignette frame so the static tint feels
* alive without any distracting motion. Opacity only.
*/
export const animEmberPulse = keyframes({
'0%': { opacity: '0.82' },
'50%': { opacity: '1' },
'100%': { opacity: '0.82' },
});
/**
* [#237] Leaf blade. On the dark themes the crimson/russet leaves sank into the
* dark warm wash, so they are lifted there (brightness only, no layout/paint
* cost beyond the existing layer).
* Leaf blade. On the dark themes the crimson/russet leaves sank into the
* background, so they are lifted there.
*/
export const leafBlade = style({
selectors: {
'body.dark-theme &, body.butter-theme &': {
filter: 'brightness(1.45) saturate(1.15) drop-shadow(0 1px 2px oklch(0.15 0.04 40 / 0.5))',
filter: 'brightness(1.6) saturate(1.2)',
},
},
});
+187 -219
View File
@@ -1,45 +1,33 @@
import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types';
import {
animLeafFall,
animLeafSway,
animLeafFlutter,
animSunShaft,
animMoteDrift,
animMoteTwinkle,
animEmberPulse,
leafBlade,
} from './Autumn.css';
import { animGust, animLeafFall, animLeafFlutter, animLeafSway, leafBlade } from './Autumn.css';
/**
* AutumnOverlay — warm falling leaves.
* AutumnOverlay — falling leaves over the app as it is.
*
* 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
* [#237] Deliberately no full-screen tint, light shafts or vignette: they
* turned the user's theme beige (muddy brown on dark) and lowered contrast
* everywhere. The leaves carry the season on their own.
*
* 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.
* Composition:
* 1. maple / oak / aspen leaves drifting down with sway and a soft flutter
* 2. every ~42 s a gust: a small group of leaves blows across the screen
* All motion is transform/opacity only. When `reduced` is true: a few leaves
* at rest in empty parts of the layout, no animation, no gust. 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.
// [#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.76 0.15 80)', 'oklch(0.85 0.13 92)', 'oklch(0.52 0.1 65)'], // 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
];
@@ -88,30 +76,31 @@ function leafDataUri(kind: LeafKind, [body, tip, vein]: [string, string, string]
}
type Leaf = {
kind: LeafKind;
uri: string;
left: number; // vw column
size: number; // px
left: number;
size: number;
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)
swayDuration: number;
flutterDuration: number;
opacity: number;
};
type Mote = {
left: number;
bottom: number;
type GustLeaf = {
uri: string;
top: number; // % of height where it enters
size: number;
duration: number;
delay: number;
twinkle: number;
delay: number; // s within the gust
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.
/** Seconds between gusts, and when the first one comes after load. */
const GUST_PERIOD = 42;
const GUST_FIRST = 8;
// Leaves at rest for the reduced/static scene, in areas that are empty in the
// layout (middle of the left icon rail, the space under the member list),
// partly off-edge — never on the sidebar, members, composer or controls.
const RESTING_LEAVES: ReadonlyArray<{
kind: LeafKind;
left: number;
@@ -121,219 +110,198 @@ const RESTING_LEAVES: ReadonlyArray<{
tone: number;
opacity: number;
}> = [
// [#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 },
{ kind: 'maple', left: 0.5, top: 44, size: 60, tilt: 24, tone: 0, opacity: 0.62 },
{ kind: 'aspen', left: 1.5, top: 54, size: 44, tilt: -30, tone: 2, opacity: 0.6 },
{ kind: 'oak', left: 99, top: 62, size: 54, tilt: 160, tone: 3, opacity: 0.6 },
{ kind: 'maple', left: 97.5, top: 72, size: 46, tilt: 200, tone: 4, opacity: 0.6 },
];
export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic pseudo-random field, computed ONCE. No per-frame state.
const { leaves, motes } = useMemo(() => {
const LEAF_COUNT = 12;
const MOTE_COUNT = 12;
// Composition for the Settings swatch (sizes are % of the tile width).
const PREVIEW_LEAVES: ReadonlyArray<{
kind: LeafKind;
left: number;
top: number;
size: number;
tilt: number;
tone: number;
}> = [
{ kind: 'maple', left: 30, top: 38, size: 42, tilt: -18, tone: 0 },
{ kind: 'oak', left: 70, top: 30, size: 30, tilt: 34, tone: 3 },
{ kind: 'aspen', left: 62, top: 70, size: 30, tilt: -60, tone: 2 },
{ kind: 'maple', left: 86, top: 80, size: 24, tilt: 20, tone: 1 },
{ kind: 'maple', left: 12, top: 82, size: 20, tilt: 40, tone: 4 },
];
const builtLeaves: Leaf[] = Array.from({ length: LEAF_COUNT }, (_, i) => {
const kind: LeafKind = (['maple', 'oak', 'maple', 'aspen'] as const)[i % 4];
const tone = LEAF_TONES[i % LEAF_TONES.length];
const sizeBucket = i % 4; // 0..3 → depth bucket for parallax
const size = 28 + sizeBucket * 8; // 28..52 px
export function AutumnOverlay({ reduced, preview }: SeasonalOverlayProps) {
// Deterministic field, computed ONCE. No per-frame state.
const { leaves, gust } = useMemo(() => {
const kinds = ['maple', 'oak', 'maple', 'aspen'] as const;
const builtLeaves: Leaf[] = Array.from({ length: 12 }, (_, i) => {
const depth = i % 4; // 0..3 → parallax bucket
return {
kind,
uri: leafDataUri(kind, tone),
uri: leafDataUri(kinds[i % 4], LEAF_TONES[i % LEAF_TONES.length]),
left: (i * 8.3 + 4) % 100,
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
size: 28 + depth * 8, // 28..52 px
// Nearer leaves fall a touch faster; all slow and calm.
duration: 16 - depth * 1.6 + (i % 3) * 1.3,
delay: -((i * 1.37) % 16), // negative → already mid-fall on load
swayDuration: 5 + (i % 5) * 0.8,
flutterDuration: 1.6 + (i % 4) * 0.45,
tilt: ((i * 53) % 70) - 35,
// Nearer → bolder, capped at 0.6 so text under a passing leaf stays readable.
opacity: 0.5 + sizeBucket * 0.05,
flutterDuration: 2 + (i % 4) * 0.5,
// Nearer → bolder. Small, moving and semi-transparent, so text under a
// passing leaf stays readable.
opacity: 0.58 + depth * 0.05,
};
});
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,
const builtGust: GustLeaf[] = Array.from({ length: 5 }, (_, i) => ({
uri: leafDataUri(kinds[(i + 1) % 4], LEAF_TONES[(i * 2) % LEAF_TONES.length]),
top: 30 + ((i * 13) % 40),
size: 30 + (i % 3) * 10,
delay: GUST_FIRST + i * 0.35,
opacity: 0.7,
}));
return { leaves: builtLeaves, motes: builtMotes };
return { leaves: builtLeaves, gust: builtGust };
}, []);
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) => (
if (preview) {
// The Settings swatch: a warm backdrop (fine inside the tile, unlike over
// the app) with a few leaves arranged across it.
return (
<>
<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`,
inset: 0,
background:
'linear-gradient(160deg, oklch(0.42 0.09 55) 0%, oklch(0.32 0.08 40) 60%, oklch(0.24 0.06 35) 100%)',
}}
/>
))}
{/* 3. Drifting pollen / dust motes catching the light. Static scene omits
them — stillness reads cleaner at rest. */}
{!reduced &&
motes.map((m, i) => (
{PREVIEW_LEAVES.map((leaf, i) => (
<div
key={`mote-${i}`}
key={`pv-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${m.left}%`,
bottom: `${m.bottom}%`,
width: `${m.size}px`,
height: `${m.size}px`,
left: `${leaf.left}%`,
top: `${leaf.top}%`,
width: `${leaf.size}%`,
aspectRatio: '1',
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
filter: 'brightness(1.25)',
}}
/>
))}
</>
);
}
if (reduced) {
// Phones have no icon rail and the timeline runs edge to edge, so the
// resting leaves tuck further off-edge and smaller there.
const narrow = typeof window !== 'undefined' && window.matchMedia('(max-width: 750px)').matches;
return (
<>
{RESTING_LEAVES.map((leaf, i) => (
<div
key={`rest-${i}`}
aria-hidden="true"
className={leafBlade}
style={{
position: 'absolute',
left: narrow ? `${leaf.left < 50 ? -2 : 102}%` : `${leaf.left}%`,
top: `${leaf.top}%`,
width: `${narrow ? leaf.size * 0.8 : leaf.size}px`,
height: `${narrow ? leaf.size * 0.8 : 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,
}}
/>
))}
</>
);
}
return (
<>
{leaves.map((leaf, i) => (
// Sway wrapper: horizontal wind, decoupled from the fall. No `paint`
// containment: it would clip the leaf to this box at the top of the
// screen, so it would never be drawn once falling (#237).
<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',
animation: `${animLeafSway} ${leaf.swayDuration}s ease-in-out ${leaf.delay}s infinite`,
}}
>
{/* Fall wrapper: vertical descent + tumble. */}
<div
style={{
width: '100%',
height: '100%',
willChange: 'transform, opacity',
animation: `${animMoteDrift} ${m.duration}s linear ${m.delay}s infinite`,
animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
}}
>
{/* The leaf itself, with a soft flutter. */}
<div
className={leafBlade}
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`,
backgroundImage: leaf.uri,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
opacity: leaf.opacity,
animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
}}
/>
</div>
))}
</div>
))}
{/* 4. Falling / resting maple & oak leaves. */}
{reduced
? RESTING_LEAVES.map((leaf, i) => (
<div
key={`rest-${i}`}
aria-hidden="true"
className={leafBlade}
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',
// 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',
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
className={leafBlade}
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`,
}}
/>
{/* Gust: a handful of leaves blown across the screen together, then a
long calm. Off-screen (and invisible) the rest of the cycle. */}
{gust.map((leaf, i) => (
<div
key={`gust-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
top: `${leaf.top}%`,
left: 0,
width: `${leaf.size}px`,
height: `${leaf.size}px`,
opacity: 0,
willChange: 'transform, opacity',
animation: `${animGust} ${GUST_PERIOD}s linear ${leaf.delay}s infinite`,
}}
>
<div
className={leafBlade}
style={{
width: '100%',
height: '100%',
backgroundImage: leaf.uri,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
opacity: leaf.opacity,
}}
/>
</div>
))}
</>
);
}
+5
View File
@@ -21,4 +21,9 @@ export type SeasonTheme =
// passes reduced=true, so the static form has to stand on its own.
export type SeasonalOverlayProps = {
reduced: boolean;
/**
* Rendering in the small Settings swatch rather than over the app. Optional:
* a theme may draw a composition made for the tile (always also `reduced`).
*/
preview?: boolean;
};