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 739b75fc7..b84b7ce62 100644 --- a/src/app/components/seasonal/themes/Autumn.css.ts +++ b/src/app/components/seasonal/themes/Autumn.css.ts @@ -1,35 +1,23 @@ -import { keyframes } from '@vanilla-extract/css'; +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,55 +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. + * Leaf blade. On the dark themes the crimson/russet leaves sank into the + * 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' }, +export const leafBlade = style({ + selectors: { + 'body.dark-theme &, body.butter-theme &': { + 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 32a63de53..612eb7f1f 100644 --- a/src/app/components/seasonal/themes/Autumn.tsx +++ b/src/app/components/seasonal/themes/Autumn.tsx @@ -1,97 +1,108 @@ import React, { useMemo } from 'react'; import { SeasonalOverlayProps } from '../types'; -import { - animLeafFall, - animLeafSway, - animLeafFlutter, - animSunShaft, - animMoteDrift, - animMoteTwinkle, - animEmberPulse, -} 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. -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.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 ]; -// 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 = { + 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 = `` + - `` + - `` + + `` + + `` + + `` + + `` + + `` + ``; return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; } type Leaf = { - kind: 'maple' | 'oak'; 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: 'maple' | 'oak'; + kind: LeafKind; left: number; top: number; size: number; @@ -99,212 +110,198 @@ 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 }, + { 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 = 16; - 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: 'maple' | 'oak' = i % 3 === 0 ? 'oak' : 'maple'; - 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 +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), - left: (i * 6.13 + 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 + 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: 1.6 + (i % 4) * 0.45, - tilt: ((i * 53) % 70) - 35, - opacity: 0.34 + sizeBucket * 0.08, // nearer → slightly bolder + 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. */} -