From 3e132069c02579f6fa286e963fc4a20b124ec3b7 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 25 Sep 2026 13:58:15 -0400 Subject: [PATCH] =?UTF-8?q?feat(seasonal):=20Autumn=20pass=202=20=E2=80=94?= =?UTF-8?q?=20no=20tint,=20a=20gust,=20a=20real=20preview=20(#237)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../components/seasonal/SeasonalEffect.tsx | 14 +- .../components/seasonal/themes/Autumn.css.ts | 98 ++--- src/app/components/seasonal/themes/Autumn.tsx | 406 ++++++++---------- src/app/components/seasonal/types.ts | 5 + 4 files changed, 230 insertions(+), 293 deletions(-) diff --git a/src/app/components/seasonal/SeasonalEffect.tsx b/src/app/components/seasonal/SeasonalEffect.tsx index 1d570fe66..49e3a4394 100644 --- a/src/app/components/seasonal/SeasonalEffect.tsx +++ b/src/app/components/seasonal/SeasonalEffect.tsx @@ -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 }>) => 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 ; @@ -61,7 +65,7 @@ function buildOverlayContent(theme: SeasonTheme, reduced: boolean): React.ReactN case 'newyear': return ; case 'autumn': - return ; + return ; case 'aprilfools': return ; case 'lunar': @@ -122,7 +126,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) { containerType: 'inline-size', }} > - {buildOverlayContent(theme, true)} + {buildOverlayContent(theme, true, true)} ); } diff --git a/src/app/components/seasonal/themes/Autumn.css.ts b/src/app/components/seasonal/themes/Autumn.css.ts index be26a8756..b84b7ce62 100644 --- a/src/app/components/seasonal/themes/Autumn.css.ts +++ b/src/app/components/seasonal/themes/Autumn.css.ts @@ -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)', }, }, }); diff --git a/src/app/components/seasonal/themes/Autumn.tsx b/src/app/components/seasonal/themes/Autumn.tsx index c9d5e1e89..612eb7f1f 100644 --- a/src/app/components/seasonal/themes/Autumn.tsx +++ b/src/app/components/seasonal/themes/Autumn.tsx @@ -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. */} -