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 { settingsAtom } from '../../state/settings';
import { useReducedMotion } from '../../hooks/useReducedMotion'; import { useReducedMotion } from '../../hooks/useReducedMotion';
import { zIndices } from '../../styles/zIndex'; import { zIndices } from '../../styles/zIndex';
import { SeasonTheme } from './types'; import { SeasonTheme, SeasonalOverlayProps } from './types';
import { resolveSeasonTheme } from './seasonSchedule'; import { resolveSeasonTheme } from './seasonSchedule';
// [Gitea #167] Each overlay (its particles + keyframes) is its own chunk, // [Gitea #167] Each overlay (its particles + keyframes) is its own chunk,
// fetched only when that season is active or previewed in Settings — the // fetched only when that season is active or previewed in Settings — the
// date/override decision stays in the main bundle. // 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); React.lazy(load);
const HalloweenOverlay = lazyOverlay(() => const HalloweenOverlay = lazyOverlay(() =>
import('./themes/Halloween').then((m) => ({ default: m.HalloweenOverlay })), import('./themes/Halloween').then((m) => ({ default: m.HalloweenOverlay })),
@@ -52,7 +52,11 @@ export type { SeasonTheme };
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ── // ─── 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) { switch (theme) {
case 'halloween': case 'halloween':
return <HalloweenOverlay reduced={reduced} />; return <HalloweenOverlay reduced={reduced} />;
@@ -61,7 +65,7 @@ function buildOverlayContent(theme: SeasonTheme, reduced: boolean): React.ReactN
case 'newyear': case 'newyear':
return <NewYearOverlay reduced={reduced} />; return <NewYearOverlay reduced={reduced} />;
case 'autumn': case 'autumn':
return <AutumnOverlay reduced={reduced} />; return <AutumnOverlay reduced={reduced} preview={preview} />;
case 'aprilfools': case 'aprilfools':
return <AprilFoolsOverlay reduced={reduced} />; return <AprilFoolsOverlay reduced={reduced} />;
case 'lunar': case 'lunar':
@@ -122,7 +126,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
containerType: 'inline-size', containerType: 'inline-size',
}} }}
> >
<Suspense fallback={null}>{buildOverlayContent(theme, true)}</Suspense> <Suspense fallback={null}>{buildOverlayContent(theme, true, true)}</Suspense>
</div> </div>
); );
} }
@@ -2,34 +2,22 @@ import { keyframes, style } from '@vanilla-extract/css';
/** /**
* Autumn overlay keyframes. Every animation touches ONLY `transform` and * Autumn overlay keyframes. Every animation touches ONLY `transform` and
* `opacity` so the compositor can run them on the GPU without triggering * `opacity` so the compositor can run them without layout or paint.
* 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.
*/ */
/** /**
* A leaf falls from above to below the viewport while continuously rotating. * A leaf falls from above to below the viewport while slowly turning. One tall
* A single tall translateY serves every leaf — per-leaf duration/delay/scale * translateY serves every leaf; per-leaf duration/delay/size give parallax.
* create the parallax variety. Horizontal travel is intentionally small here
* because the real lateral motion comes from the sway wrapper below.
*/ */
export const animLeafFall = keyframes({ export const animLeafFall = keyframes({
'0%': { transform: 'translate3d(0, -12vh, 0) rotate(-30deg)', opacity: '0' }, '0%': { transform: 'translate3d(0, -12vh, 0) rotate(-30deg)', opacity: '0' },
'8%': { opacity: '1' }, '6%': { opacity: '1' },
'50%': { transform: 'translate3d(10px, 50vh, 0) rotate(200deg)' }, '50%': { transform: 'translate3d(10px, 50vh, 0) rotate(160deg)' },
'92%': { opacity: '0.85' }, '94%': { opacity: '0.9' },
'100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(430deg)', opacity: '0' }, '100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(340deg)', opacity: '0' },
}); });
/** /** Lateral sway on the leaf's wrapper so the descent reads as wind. */
* 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.
*/
export const animLeafSway = keyframes({ export const animLeafSway = keyframes({
'0%': { transform: 'translate3d(0, 0, 0)' }, '0%': { transform: 'translate3d(0, 0, 0)' },
'50%': { transform: 'translate3d(34px, 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 * Soft flutter on the blade. [#237] Gentler than before (scaleX 0.82 made
* mimics the blade catching air as it spins. Cheap, transform-only. * leaves flatten into slivers mid-flip).
*/ */
export const animLeafFlutter = keyframes({ export const animLeafFlutter = keyframes({
'0%': { transform: 'rotate(-8deg) scaleX(1)' }, '0%': { transform: 'rotate(-10deg) scaleX(1)' },
'50%': { transform: 'rotate(8deg) scaleX(0.82)' }, '50%': { transform: 'rotate(10deg) scaleX(0.92)' },
'100%': { transform: 'rotate(-8deg) scaleX(1)' }, '100%': { transform: 'rotate(-10deg) scaleX(1)' },
}); });
/** /**
* Low-sun light shaft: a long soft beam slowly slides and breathes. Uses * [#237] A gust: the leaf enters from the left, is blown across and up while
* translateX + opacity (never background-position) so it stays on the * spinning, and leaves on the right in ~6 s (14 % of a 42 s cycle). The rest
* compositor. Scale on Y makes the beam subtly elongate as it brightens. * of the cycle it waits off-screen, invisible.
*/ */
export const animSunShaft = keyframes({ export const animGust = keyframes({
'0%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' }, '0%': { transform: 'translate3d(-12vw, 0, 0) rotate(0deg)', opacity: '0' },
'50%': { transform: 'translate3d(4%, 0, 0) scaleY(1.06)', opacity: '0.75' }, '1%': { opacity: '1' },
'100%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' }, // 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, * Leaf blade. On the dark themes the crimson/russet leaves sank into the
* pulsing softly in brightness as it catches the sun. transform + opacity. * background, so they are lifted there.
*/
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).
*/ */
export const leafBlade = style({ export const leafBlade = style({
selectors: { selectors: {
'body.dark-theme &, body.butter-theme &': { '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)',
}, },
}, },
}); });
+139 -171
View File
@@ -1,45 +1,33 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { SeasonalOverlayProps } from '../types'; import { SeasonalOverlayProps } from '../types';
import { import { animGust, animLeafFall, animLeafFlutter, animLeafSway, leafBlade } from './Autumn.css';
animLeafFall,
animLeafSway,
animLeafFlutter,
animSunShaft,
animMoteDrift,
animMoteTwinkle,
animEmberPulse,
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 * A full-screen, pointer-events:none ambient decoration. The parent supplies a
* fixed inset:0 overflow:hidden pointer-events:none container at the correct * fixed inset:0 overflow:hidden pointer-events:none container at the correct
* z-index, so this component only returns absolutely-positioned aria-hidden * z-index, so this component only returns absolutely-positioned aria-hidden
* children and never sets position:fixed / z-index / pointer-events. * children and never sets position:fixed / z-index / pointer-events.
* *
* Composition (back to front): * [#237] Deliberately no full-screen tint, light shafts or vignette: they
* 1. amber -> rust ambient gradient wash (cozy low-sun atmosphere) * turned the user's theme beige (muddy brown on dark) and lowered contrast
* 2. soft angled sun shafts breathing high across the scene * everywhere. The leaves carry the season on their own.
* 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` * Composition:
* is true we render a static-but-gorgeous scene: a handful of leaves at rest, * 1. maple / oak / aspen leaves drifting down with sway and a soft flutter
* still sun shafts, and the warm vignette — no `animation` anywhere. The * 2. every ~42 s a gust: a small group of leaves blows across the screen
* settings preview always passes reduced=true, so the still form stands alone. * 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 // [#237] A real autumn palette: [body, tip highlight, vein]. The previous
// muted tans read as beige smudges on the light theme. // muted tans read as beige smudges on the light theme.
const LEAF_TONES: [string, string, string][] = [ 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.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.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.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 ['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 = { type Leaf = {
kind: LeafKind;
uri: string; uri: string;
left: number; // vw column left: number;
size: number; // px size: number;
duration: number; // s — fall time duration: number; // s — fall time
delay: number; // s delay: number; // s
swayDuration: number; // s — wrapper sway swayDuration: number;
flutterDuration: number; // s — inner flutter flutterDuration: number;
tilt: number; // deg — resting rotation (used for static scene)
opacity: number; opacity: number;
}; };
type Mote = { type GustLeaf = {
left: number; uri: string;
bottom: number; top: number; // % of height where it enters
size: number; size: number;
duration: number; delay: number; // s within the gust
delay: number;
twinkle: number;
opacity: number; opacity: number;
}; };
// A few hand-placed leaves at rest for the reduced/static scene — arranged so /** Seconds between gusts, and when the first one comes after load. */
// they read as "settled" near edges and corners, never over the busy center. 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<{ const RESTING_LEAVES: ReadonlyArray<{
kind: LeafKind; kind: LeafKind;
left: number; left: number;
@@ -121,161 +110,129 @@ const RESTING_LEAVES: ReadonlyArray<{
tone: number; tone: number;
opacity: number; opacity: number;
}> = [ }> = [
// [#237] In areas that are empty in the layout (the middle of the left icon { kind: 'maple', left: 0.5, top: 44, size: 60, tilt: 24, tone: 0, opacity: 0.62 },
// rail, the space under the member list), partly off-edge, instead of on { kind: 'aspen', left: 1.5, top: 54, size: 44, tilt: -30, tone: 2, opacity: 0.6 },
// the sidebar, members, composer or corner controls. { kind: 'oak', left: 99, top: 62, size: 54, tilt: 160, tone: 3, opacity: 0.6 },
{ kind: 'maple', left: 0.5, top: 44, size: 60, tilt: 24, tone: 0, opacity: 0.55 }, { kind: 'maple', left: 97.5, top: 72, size: 46, tilt: 200, tone: 4, opacity: 0.6 },
{ 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 },
]; ];
export function AutumnOverlay({ reduced }: SeasonalOverlayProps) { // Composition for the Settings swatch (sizes are % of the tile width).
// Deterministic pseudo-random field, computed ONCE. No per-frame state. const PREVIEW_LEAVES: ReadonlyArray<{
const { leaves, motes } = useMemo(() => { kind: LeafKind;
const LEAF_COUNT = 12; left: number;
const MOTE_COUNT = 12; 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) => { export function AutumnOverlay({ reduced, preview }: SeasonalOverlayProps) {
const kind: LeafKind = (['maple', 'oak', 'maple', 'aspen'] as const)[i % 4]; // Deterministic field, computed ONCE. No per-frame state.
const tone = LEAF_TONES[i % LEAF_TONES.length]; const { leaves, gust } = useMemo(() => {
const sizeBucket = i % 4; // 0..3 → depth bucket for parallax const kinds = ['maple', 'oak', 'maple', 'aspen'] as const;
const size = 28 + sizeBucket * 8; // 28..52 px const builtLeaves: Leaf[] = Array.from({ length: 12 }, (_, i) => {
const depth = i % 4; // 0..3 → parallax bucket
return { return {
kind, uri: leafDataUri(kinds[i % 4], LEAF_TONES[i % LEAF_TONES.length]),
uri: leafDataUri(kind, tone),
left: (i * 8.3 + 4) % 100, left: (i * 8.3 + 4) % 100,
size, size: 28 + depth * 8, // 28..52 px
// Larger (nearer) leaves fall a touch faster; all slow + cozy. // Nearer leaves fall a touch faster; all slow and calm.
duration: 16 - sizeBucket * 1.6 + (i % 3) * 1.3, duration: 16 - depth * 1.6 + (i % 3) * 1.3,
delay: -((i * 1.37) % 16), // negative → staggered, already mid-fall delay: -((i * 1.37) % 16), // negative → already mid-fall on load
swayDuration: 5 + (i % 5) * 0.8, swayDuration: 5 + (i % 5) * 0.8,
flutterDuration: 1.6 + (i % 4) * 0.45, flutterDuration: 2 + (i % 4) * 0.5,
tilt: ((i * 53) % 70) - 35, // Nearer → bolder. Small, moving and semi-transparent, so text under a
// Nearer → bolder, capped at 0.6 so text under a passing leaf stays readable. // passing leaf stays readable.
opacity: 0.5 + sizeBucket * 0.05, opacity: 0.58 + depth * 0.05,
}; };
}); });
const builtGust: GustLeaf[] = Array.from({ length: 5 }, (_, i) => ({
const builtMotes: Mote[] = Array.from({ length: MOTE_COUNT }, (_, i) => ({ uri: leafDataUri(kinds[(i + 1) % 4], LEAF_TONES[(i * 2) % LEAF_TONES.length]),
left: (i * 8.7 + 5) % 100, top: 30 + ((i * 13) % 40),
bottom: (i * 4.3) % 30, // start in lower third, drift up size: 30 + (i % 3) * 10,
size: 2 + (i % 3), delay: GUST_FIRST + i * 0.35,
duration: 16 + (i % 6) * 2.4, opacity: 0.7,
delay: -((i * 2.1) % 16),
twinkle: 2.2 + (i % 4) * 0.6,
opacity: 0.4 + (i % 3) * 0.12,
})); }));
return { leaves: builtLeaves, gust: builtGust };
return { leaves: builtLeaves, motes: builtMotes };
}, []); }, []);
if (preview) {
// The Settings swatch: a warm backdrop (fine inside the tile, unlike over
// the app) with a few leaves arranged across it.
return ( 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 <div
aria-hidden="true" aria-hidden="true"
style={{ style={{
position: 'absolute', position: 'absolute',
inset: 0, 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: background:
'radial-gradient(circle, oklch(0.88 0.1 85 / 0.95) 0%, oklch(0.78 0.12 70 / 0.4) 60%, transparent 100%)', 'linear-gradient(160deg, oklch(0.42 0.09 55) 0%, oklch(0.32 0.08 40) 60%, oklch(0.24 0.06 35) 100%)',
opacity: m.opacity, }}
animation: `${animMoteTwinkle} ${m.twinkle}s ease-in-out infinite`, />
{PREVIEW_LEAVES.map((leaf, i) => (
<div
key={`pv-${i}`}
aria-hidden="true"
style={{
position: 'absolute',
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)',
}} }}
/> />
</div>
))} ))}
</>
);
}
{/* 4. Falling / resting maple & oak leaves. */} if (reduced) {
{reduced // Phones have no icon rail and the timeline runs edge to edge, so the
? RESTING_LEAVES.map((leaf, i) => ( // 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 <div
key={`rest-${i}`} key={`rest-${i}`}
aria-hidden="true" aria-hidden="true"
className={leafBlade} className={leafBlade}
style={{ style={{
position: 'absolute', position: 'absolute',
left: `${leaf.left}%`, left: narrow ? `${leaf.left < 50 ? -2 : 102}%` : `${leaf.left}%`,
top: `${leaf.top}%`, top: `${leaf.top}%`,
width: `${leaf.size}px`, width: `${narrow ? leaf.size * 0.8 : leaf.size}px`,
height: `${leaf.size}px`, height: `${narrow ? leaf.size * 0.8 : leaf.size}px`,
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]), backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
backgroundSize: 'contain', backgroundSize: 'contain',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`, transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
opacity: leaf.opacity, 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. );
}
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 <div
key={`leaf-${i}`} key={`leaf-${i}`}
aria-hidden="true" aria-hidden="true"
@@ -286,14 +243,10 @@ export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
width: `${leaf.size}px`, width: `${leaf.size}px`,
height: `${leaf.size}px`, height: `${leaf.size}px`,
willChange: 'transform', 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`, animation: `${animLeafSway} ${leaf.swayDuration}s ease-in-out ${leaf.delay}s infinite`,
}} }}
> >
{/* Fall wrapper: vertical descent + tumble rotation. */} {/* Fall wrapper: vertical descent + tumble. */}
<div <div
style={{ style={{
width: '100%', width: '100%',
@@ -302,7 +255,7 @@ export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`, animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
}} }}
> >
{/* Inner blade: the actual silhouette + flutter wobble. */} {/* The leaf itself, with a soft flutter. */}
<div <div
className={leafBlade} className={leafBlade}
style={{ style={{
@@ -312,7 +265,6 @@ export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
backgroundSize: 'contain', backgroundSize: 'contain',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
opacity: leaf.opacity, 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`, animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
}} }}
/> />
@@ -320,20 +272,36 @@ export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
</div> </div>
))} ))}
{/* 5. Warm low-saturation vignette. Frames the scene and gently darkens {/* Gust: a handful of leaves blown across the screen together, then a
edges — protecting central chat text contrast. Breathes faintly. */} long calm. Off-screen (and invisible) the rest of the cycle. */}
{gust.map((leaf, i) => (
<div <div
key={`gust-${i}`}
aria-hidden="true" aria-hidden="true"
style={{ style={{
position: 'absolute', position: 'absolute',
inset: 0, top: `${leaf.top}%`,
backgroundImage: left: 0,
'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%)', width: `${leaf.size}px`,
contain: 'layout paint style', height: `${leaf.size}px`,
opacity: reduced ? 1 : undefined, opacity: 0,
animation: reduced ? 'none' : `${animEmberPulse} 9s ease-in-out infinite`, 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. // passes reduced=true, so the static form has to stand on its own.
export type SeasonalOverlayProps = { export type SeasonalOverlayProps = {
reduced: boolean; 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;
}; };