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
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { SeasonalOverlayProps } from '../types';
|
|
import { animGust, animLeafFall, animLeafFlutter, animLeafSway, leafBlade } from './Autumn.css';
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* [#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.
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
// [#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.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
|
|
];
|
|
|
|
type LeafKind = 'maple' | 'oak' | 'aspen';
|
|
|
|
// 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];
|
|
const svg =
|
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>` +
|
|
`<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'/>` +
|
|
`</svg>`;
|
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
|
}
|
|
|
|
type Leaf = {
|
|
uri: string;
|
|
left: number;
|
|
size: number;
|
|
duration: number; // s — fall time
|
|
delay: number; // s
|
|
swayDuration: number;
|
|
flutterDuration: number;
|
|
opacity: number;
|
|
};
|
|
|
|
type GustLeaf = {
|
|
uri: string;
|
|
top: number; // % of height where it enters
|
|
size: number;
|
|
delay: number; // s within the gust
|
|
opacity: number;
|
|
};
|
|
|
|
/** 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;
|
|
top: number;
|
|
size: number;
|
|
tilt: number;
|
|
tone: number;
|
|
opacity: number;
|
|
}> = [
|
|
{ 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 },
|
|
];
|
|
|
|
// 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 },
|
|
];
|
|
|
|
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 {
|
|
uri: leafDataUri(kinds[i % 4], LEAF_TONES[i % LEAF_TONES.length]),
|
|
left: (i * 8.3 + 4) % 100,
|
|
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: 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 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, gust: builtGust };
|
|
}, []);
|
|
|
|
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
|
|
aria-hidden="true"
|
|
style={{
|
|
position: 'absolute',
|
|
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%)',
|
|
}}
|
|
/>
|
|
{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)',
|
|
}}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
|
|
}}
|
|
>
|
|
{/* The leaf itself, with a soft flutter. */}
|
|
<div
|
|
className={leafBlade}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
backgroundImage: leaf.uri,
|
|
backgroundSize: 'contain',
|
|
backgroundRepeat: 'no-repeat',
|
|
opacity: leaf.opacity,
|
|
animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* 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>
|
|
))}
|
|
</>
|
|
);
|
|
}
|