From 9447e64e5e6f49aad0eafdab89f26e07111bf4f4 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 5 Jun 2026 11:46:16 -0400 Subject: [PATCH] feat(P5-4): animated chat backgrounds + pause toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 new CSS-only animated backgrounds in the chat background picker: - Digital Rain: two-layer vertical stripe scroll with parallax (wide stripes at 8s, narrow at 4s via single keyframe with split positions) - Star Drift: three-layer radial-gradient star field drifting diagonally - Grid Pulse: neon grid lines that expand/contract (backgroundSize keyframe) - Aurora Flow: large radial gradient bands sweeping across 200% canvas - Fireflies: three layers of glowing dots drifting across the viewport All use vanilla-extract keyframes (GPU-composited transforms/positions, no canvas, no JS timers). prefers-reduced-motion is respected in getChatBg() by stripping the animation property at call time. A "Pause Background Animations" toggle in Settings → Appearance provides an in-app override for the same purpose. BG labels de-duplicated ("Digital Rain", "Star Drift", "Aurora Flow") to avoid the duplicate "Stars" and "Aurora" entries that had appeared. LIGHT anim-fireflies background corrected from near-black #0a0a10 to warm white #fffdf0. Four unused keyframe exports removed from Animations.css.ts. Co-Authored-By: Claude Sonnet 4.6 --- src/app/features/lotus/chatBackground.ts | 156 +++++++++++++++++- src/app/features/room/RoomView.tsx | 9 +- src/app/features/settings/general/General.tsx | 12 +- src/app/pages/client/SidebarNav.tsx | 8 +- src/app/state/settings.ts | 11 +- src/app/styles/Animations.css.ts | 36 ++++ 6 files changed, 224 insertions(+), 8 deletions(-) diff --git a/src/app/features/lotus/chatBackground.ts b/src/app/features/lotus/chatBackground.ts index 38170969c..665e21e62 100644 --- a/src/app/features/lotus/chatBackground.ts +++ b/src/app/features/lotus/chatBackground.ts @@ -1,5 +1,12 @@ import { CSSProperties } from 'react'; import { ChatBackground } from '../../state/settings'; +import { + animRainKeyframe, + animStarsDriftKeyframe, + animGridPulseKeyframe, + animAuroraKeyframe, + animFirefliesKeyframe, +} from '../../styles/Animations.css'; export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [ { value: 'none', label: 'None' }, @@ -19,6 +26,11 @@ export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [ { value: 'waves', label: 'Waves' }, { value: 'neon', label: 'Neon Grid' }, { value: 'aurora', label: 'Aurora' }, + { value: 'anim-rain', label: 'Digital Rain' }, + { value: 'anim-stars', label: 'Star Drift' }, + { value: 'anim-pulse', label: 'Grid Pulse' }, + { value: 'anim-aurora', label: 'Aurora Flow' }, + { value: 'anim-fireflies', label: 'Fireflies' }, ]; const DARK: Record = { @@ -184,6 +196,71 @@ const DARK: Record = { 'radial-gradient(ellipse at 60% 90%, rgba(0,212,255,0.06) 0%, transparent 50%)', ].join(','), }, + + // Animated: Matrix digital rain — scrolling vertical green stripes + 'anim-rain': { + backgroundColor: '#010804', + backgroundImage: [ + 'repeating-linear-gradient(180deg, rgba(0,255,136,0.13) 0px, rgba(0,255,136,0.13) 1px, transparent 1px, transparent 20px)', + 'repeating-linear-gradient(180deg, rgba(0,255,136,0.07) 0px, rgba(0,255,136,0.07) 1px, transparent 1px, transparent 8px)', + ].join(','), + backgroundSize: '40px 200px, 12px 200px', + backgroundPosition: '0 0, 0 0', + animation: `${animRainKeyframe} 8s linear infinite`, + }, + + // Animated: drifting star field — three layers at different speeds + 'anim-stars': { + backgroundColor: '#050510', + backgroundImage: [ + 'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)', + 'radial-gradient(circle, rgba(200,220,255,0.55) 1px, transparent 1px)', + 'radial-gradient(circle, rgba(180,200,255,0.3) 1px, transparent 1px)', + ].join(','), + backgroundSize: '130px 130px, 190px 190px, 260px 260px', + backgroundPosition: '0 0, 65px 32px, 32px 97px', + animation: `${animStarsDriftKeyframe} 25s linear infinite`, + }, + + // Animated: neon grid pulse — grid lines that expand/contract + 'anim-pulse': { + backgroundColor: '#030508', + backgroundImage: [ + 'linear-gradient(rgba(255,107,0,0.12) 1px, transparent 1px)', + 'linear-gradient(90deg, rgba(255,107,0,0.12) 1px, transparent 1px)', + 'linear-gradient(rgba(0,212,255,0.06) 1px, transparent 1px)', + 'linear-gradient(90deg, rgba(0,212,255,0.06) 1px, transparent 1px)', + ].join(','), + backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px', + animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`, + }, + + // Animated: aurora borealis — slowly drifting gradient bands + 'anim-aurora': { + backgroundColor: '#020a10', + backgroundImage: [ + 'radial-gradient(ellipse at 20% 30%, rgba(0,255,136,0.10) 0%, transparent 55%)', + 'radial-gradient(ellipse at 80% 70%, rgba(0,100,255,0.10) 0%, transparent 55%)', + 'radial-gradient(ellipse at 50% 10%, rgba(191,95,255,0.08) 0%, transparent 50%)', + 'radial-gradient(ellipse at 60% 90%, rgba(0,212,255,0.08) 0%, transparent 50%)', + ].join(','), + backgroundSize: '200% 200%', + backgroundPosition: '0% 0%', + animation: `${animAuroraKeyframe} 20s ease-in-out infinite`, + }, + + // Animated: fireflies — three layers of glowing dots at different speeds + 'anim-fireflies': { + backgroundColor: '#030508', + backgroundImage: [ + 'radial-gradient(circle, rgba(255,220,50,0.55) 1.5px, rgba(255,160,0,0.15) 3px, transparent 4px)', + 'radial-gradient(circle, rgba(255,200,30,0.45) 1px, rgba(255,140,0,0.12) 2.5px, transparent 3.5px)', + 'radial-gradient(circle, rgba(255,240,100,0.35) 1px, transparent 2px)', + ].join(','), + backgroundSize: '200px 200px, 280px 280px, 160px 160px', + backgroundPosition: '0 0, 120px 80px, 60px 140px', + animation: `${animFirefliesKeyframe} 15s linear infinite`, + }, }; const LIGHT: Record = { @@ -340,7 +417,82 @@ const LIGHT: Record = { 'radial-gradient(ellipse at 60% 90%, rgba(0,160,200,0.07) 0%, transparent 50%)', ].join(','), }, + + // Animated light variants + + 'anim-rain': { + backgroundColor: '#f0fff4', + backgroundImage: [ + 'repeating-linear-gradient(180deg, rgba(0,160,80,0.14) 0px, rgba(0,160,80,0.14) 1px, transparent 1px, transparent 20px)', + 'repeating-linear-gradient(180deg, rgba(0,160,80,0.07) 0px, rgba(0,160,80,0.07) 1px, transparent 1px, transparent 8px)', + ].join(','), + backgroundSize: '40px 200px, 12px 200px', + backgroundPosition: '0 0, 0 0', + animation: `${animRainKeyframe} 8s linear infinite`, + }, + + 'anim-stars': { + backgroundColor: '#f5f5ff', + backgroundImage: [ + 'radial-gradient(circle, rgba(60,60,160,0.50) 1px, transparent 1px)', + 'radial-gradient(circle, rgba(80,80,180,0.35) 1px, transparent 1px)', + 'radial-gradient(circle, rgba(100,100,200,0.20) 1px, transparent 1px)', + ].join(','), + backgroundSize: '130px 130px, 190px 190px, 260px 260px', + backgroundPosition: '0 0, 65px 32px, 32px 97px', + animation: `${animStarsDriftKeyframe} 25s linear infinite`, + }, + + 'anim-pulse': { + backgroundColor: '#ffffff', + backgroundImage: [ + 'linear-gradient(rgba(0,98,184,0.14) 1px, transparent 1px)', + 'linear-gradient(90deg, rgba(0,98,184,0.14) 1px, transparent 1px)', + 'linear-gradient(rgba(0,98,184,0.06) 1px, transparent 1px)', + 'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)', + ].join(','), + backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px', + animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`, + }, + + 'anim-aurora': { + backgroundColor: '#f0f8f4', + backgroundImage: [ + 'radial-gradient(ellipse at 20% 30%, rgba(0,160,80,0.12) 0%, transparent 55%)', + 'radial-gradient(ellipse at 80% 70%, rgba(0,80,200,0.12) 0%, transparent 55%)', + 'radial-gradient(ellipse at 50% 10%, rgba(140,60,220,0.09) 0%, transparent 50%)', + 'radial-gradient(ellipse at 60% 90%, rgba(0,160,200,0.09) 0%, transparent 50%)', + ].join(','), + backgroundSize: '200% 200%', + backgroundPosition: '0% 0%', + animation: `${animAuroraKeyframe} 20s ease-in-out infinite`, + }, + + 'anim-fireflies': { + backgroundColor: '#fffdf0', + backgroundImage: [ + 'radial-gradient(circle, rgba(180,120,0,0.55) 1.5px, rgba(160,90,0,0.15) 3px, transparent 4px)', + 'radial-gradient(circle, rgba(160,100,0,0.45) 1px, rgba(140,80,0,0.12) 2.5px, transparent 3.5px)', + 'radial-gradient(circle, rgba(200,140,0,0.35) 1px, transparent 2px)', + ].join(','), + backgroundSize: '200px 200px, 280px 280px, 160px 160px', + backgroundPosition: '0 0, 120px 80px, 60px 140px', + animation: `${animFirefliesKeyframe} 15s linear infinite`, + }, }; -export const getChatBg = (bg: ChatBackground, isDark: boolean): CSSProperties => - isDark ? DARK[bg] : LIGHT[bg]; +export const getChatBg = ( + bg: ChatBackground, + isDark: boolean, + pauseAnimations?: boolean, +): CSSProperties => { + const style = isDark ? DARK[bg] : LIGHT[bg]; + const reducedMotion = + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if ((pauseAnimations || reducedMotion) && style.animation) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { animation: _anim, ...rest } = style; + return rest; + } + return style; +}; diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 7d5e01301..9abd0aecf 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -61,6 +61,7 @@ export function RoomView({ eventId }: { eventId?: string }) { const roomViewRef = useRef(null) as React.RefObject; const [chatBackground] = useSetting(settingsAtom, 'chatBackground'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); + const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations'); const theme = useTheme(); const isDark = theme.kind === ThemeKind.Dark; @@ -98,8 +99,12 @@ export function RoomView({ eventId }: { eventId?: string }) { const chatBgStyle = useMemo( () => - getChatBg(lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, isDark), - [chatBackground, lotusTerminal, isDark], + getChatBg( + lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, + isDark, + pauseAnimations, + ), + [chatBackground, lotusTerminal, isDark, pauseAnimations], ); return ( diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index c406cb2df..94ada8d74 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -331,6 +331,7 @@ function Appearance() { settingsAtom, 'glassmorphismSidebar', ); + const [pauseAnimations, setPauseAnimations] = useSetting(settingsAtom, 'pauseAnimations'); return ( @@ -390,6 +391,14 @@ function Appearance() { + + } + /> + + { style.removeProperty('background-image'); style.removeProperty('background-color'); style.removeProperty('background-size'); style.removeProperty('background-position'); + style.removeProperty('animation'); }; - }, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark]); + }, [glassmorphismSidebar, chatBackground, lotusTerminal, isDark, pauseAnimations]); return ( diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 74e8609fe..852283b15 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -26,7 +26,12 @@ export type ChatBackground = | 'hexgrid' | 'waves' | 'neon' - | 'aurora'; + | 'aurora' + | 'anim-rain' + | 'anim-stars' + | 'anim-pulse' + | 'anim-aurora' + | 'anim-fireflies'; export enum MessageLayout { Modern = 0, Compact = 1, @@ -94,6 +99,8 @@ export interface Settings { deafenKey: string; warnOnUnverifiedDevices: boolean; + + pauseAnimations: boolean; } const defaultSettings: Settings = { @@ -157,6 +164,8 @@ const defaultSettings: Settings = { deafenKey: 'KeyM', warnOnUnverifiedDevices: false, + + pauseAnimations: false, }; export const getSettings = (): Settings => { diff --git a/src/app/styles/Animations.css.ts b/src/app/styles/Animations.css.ts index 5a92b9a64..b3a1488fd 100644 --- a/src/app/styles/Animations.css.ts +++ b/src/app/styles/Animations.css.ts @@ -69,3 +69,39 @@ export const MsgAppearClass = style({ }, }, }); + +// Animated chat background keyframes + +// Animated chat background keyframes + +/** Matrix rain — two stripe layers scroll at different speeds for parallax depth */ +export const animRainKeyframe = keyframes({ + from: { backgroundPosition: '0 0, 0 0' }, + to: { backgroundPosition: '0 200px, 0 100px' }, +}); + +/** Drifting stars — three layers drift diagonally */ +export const animStarsDriftKeyframe = keyframes({ + from: { backgroundPosition: '0 0, 65px 32px, 32px 97px' }, + to: { backgroundPosition: '130px 130px, 195px 162px, 162px 227px' }, +}); + +/** Grid pulse — expands/contracts backgroundSize slightly */ +export const animGridPulseKeyframe = keyframes({ + '0%': { backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px' }, + '50%': { backgroundSize: '66px 66px, 66px 66px, 13px 13px, 13px 13px' }, + '100%': { backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px' }, +}); + +/** Aurora — sweeps backgroundPosition in large cycle */ +export const animAuroraKeyframe = keyframes({ + '0%': { backgroundPosition: '0% 0%' }, + '50%': { backgroundPosition: '-50% -25%' }, + '100%': { backgroundPosition: '0% 0%' }, +}); + +/** Fireflies — three layers of glowing dots drift diagonally */ +export const animFirefliesKeyframe = keyframes({ + from: { backgroundPosition: '0 0, 120px 80px, 60px 140px' }, + to: { backgroundPosition: '200px 150px, 320px 230px, 260px 290px' }, +});