feat(seasonal): Autumn: leaves actually fall; real leaf shapes and palette (#237)
CI / Build & Quality Checks (pull_request) Failing after 17m50s
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 11s

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

- Bug: every falling leaf sat in a leaf-sized wrapper pinned at the top of
  the screen with `contain: layout paint style`. Paint containment clips
  children to that box, so a leaf stopped being drawn as soon as it fell out
  of its first ~40 px: the animated theme showed almost nothing while the DOM
  reported 10–16 leaves on screen. Now `contain: layout style`.
- Leaf shapes: a flag-style maple (bold lobes, stem) that stays recognisable
  at 30 px (the old outline read as a star/splat), an oak with a stem, and an
  aspen; gradient body with darker veins.
- Palette: crimson, pumpkin orange, gold, russet, burnt orange (was muted
  tans that read as beige on the light theme).
- 12 leaves at 28–52 px, opacity 0.5–0.65 so text under a passing leaf stays
  readable; brightened on the dark themes, where they sank into the wash.
- Reduced motion: resting leaves moved into empty layout areas (middle of
  the left rail, under the member list), partly off-edge, instead of on the
  sidebar, members, composer and corner controls.

Rendered and inspected at 10/15/20 s in light, dark, phone and reduced
motion (screenshots on #237).

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:36:13 -04:00
co-authored by Claude Opus 5.5
parent 75951f9040
commit e080059f37
2 changed files with 80 additions and 38 deletions
@@ -1,4 +1,4 @@
import { keyframes } from '@vanilla-extract/css';
import { keyframes, style } from '@vanilla-extract/css';
/**
* Autumn overlay keyframes. Every animation touches ONLY `transform` and
@@ -89,3 +89,16 @@ export const animEmberPulse = keyframes({
'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({
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))',
},
},
});
+66 -37
View File
@@ -8,6 +8,7 @@ import {
animMoteDrift,
animMoteTwinkle,
animEmberPulse,
leafBlade,
} from './Autumn.css';
/**
@@ -33,40 +34,61 @@ import {
// Warm autumn palette in oklch. Kept low-saturation enough to never fight the
// chat text underneath. Each leaf picks a tone for variety.
const LEAF_TONES = [
'oklch(0.75 0.15 70)', // amber
'oklch(0.55 0.16 40)', // rust
'oklch(0.82 0.13 85)', // warm gold
'oklch(0.62 0.16 55)', // burnt orange
'oklch(0.5 0.14 35)', // deep ember
// [#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
];
// Two leaf silhouettes as inline SVG path data (no external assets, CSP-safe).
// `maple` = classic five-lobed maple; `oak` = rounded-lobe oak blade.
const MAPLE_PATH =
'M50 4 L57 30 L78 18 L66 40 L92 40 L70 52 L84 74 L58 62 L56 92 L50 70 ' +
'L44 92 L42 62 L16 74 L30 52 L8 40 L34 40 L22 18 L43 30 Z';
const OAK_PATH =
'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 84 50 96 50 96 ' +
'C50 96 46 84 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';
type LeafKind = 'maple' | 'oak' | 'aspen';
/** Build a CSS-ready data-URI of a single tinted leaf silhouette. */
function leafDataUri(kind: 'maple' | 'oak', fill: string): string {
const path = kind === 'maple' ? MAPLE_PATH : OAK_PATH;
// A faint vein line gives the blade depth without extra DOM nodes.
// 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'>` +
`<path d='${path}' fill='${fill}' fill-opacity='0.92'/>` +
`<path d='M50 96 L50 24' stroke='oklch(0.42 0.12 38)' stroke-opacity='0.35' ` +
`stroke-width='2.5' fill='none' stroke-linecap='round'/>` +
`<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 = {
kind: 'maple' | 'oak';
kind: LeafKind;
uri: string;
left: number; // vw column
size: number; // px
@@ -91,7 +113,7 @@ type Mote = {
// 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<{
kind: 'maple' | 'oak';
kind: LeafKind;
left: number;
top: number;
size: number;
@@ -99,29 +121,30 @@ const RESTING_LEAVES: ReadonlyArray<{
tone: number;
opacity: number;
}> = [
{ kind: 'maple', left: 6, top: 14, size: 46, tilt: -22, tone: 0, opacity: 0.4 },
{ kind: 'oak', left: 88, top: 22, size: 38, tilt: 28, tone: 1, opacity: 0.34 },
{ kind: 'maple', left: 16, top: 78, size: 54, tilt: 16, tone: 3, opacity: 0.42 },
{ kind: 'oak', left: 80, top: 82, size: 44, tilt: -34, tone: 4, opacity: 0.36 },
{ kind: 'maple', left: 50, top: 90, size: 40, tilt: 8, tone: 2, opacity: 0.32 },
{ kind: 'oak', left: 70, top: 8, size: 32, tilt: -12, tone: 2, opacity: 0.3 },
// [#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 },
];
export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic pseudo-random field, computed ONCE. No per-frame state.
const { leaves, motes } = useMemo(() => {
const LEAF_COUNT = 16;
const LEAF_COUNT = 12;
const MOTE_COUNT = 12;
const builtLeaves: Leaf[] = Array.from({ length: LEAF_COUNT }, (_, i) => {
const kind: 'maple' | 'oak' = i % 3 === 0 ? 'oak' : 'maple';
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 = 22 + sizeBucket * 9; // 22..49 px
const size = 28 + sizeBucket * 8; // 28..52 px
return {
kind,
uri: leafDataUri(kind, tone),
left: (i * 6.13 + 4) % 100,
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,
@@ -129,7 +152,8 @@ export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
swayDuration: 5 + (i % 5) * 0.8,
flutterDuration: 1.6 + (i % 4) * 0.45,
tilt: ((i * 53) % 70) - 35,
opacity: 0.34 + sizeBucket * 0.08, // nearer → slightly bolder
// Nearer → bolder, capped at 0.6 so text under a passing leaf stays readable.
opacity: 0.5 + sizeBucket * 0.05,
};
});
@@ -234,6 +258,7 @@ export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
<div
key={`rest-${i}`}
aria-hidden="true"
className={leafBlade}
style={{
position: 'absolute',
left: `${leaf.left}%`,
@@ -261,7 +286,10 @@ export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
width: `${leaf.size}px`,
height: `${leaf.size}px`,
willChange: 'transform',
contain: 'layout paint style',
// 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`,
}}
>
@@ -276,6 +304,7 @@ export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
>
{/* Inner blade: the actual silhouette + flutter wobble. */}
<div
className={leafBlade}
style={{
width: '100%',
height: '100%',