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
+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>
))}
</>
);
}