Enhance performance and user experience in AnimatedBackground and FortuneWheel components. Added low-performance device detection, optimized rendering logic, and improved CSS animations. Refactored theme color application in useThemeColors hook for better palette generation. Updated Dashboard to use static icons on low-performance devices.

This commit is contained in:
PEDZEO
2026-01-20 00:02:28 +03:00
parent cd0a5a4218
commit c96ec5ecf6
5 changed files with 255 additions and 295 deletions

View File

@@ -1,9 +1,21 @@
import { useEffect, useState } from 'react' import { useEffect, useState, memo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { brandingApi } from '../api/branding' import { brandingApi } from '../api/branding'
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled' const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled'
// Detect low-performance device (mobile in Telegram WebApp)
const isLowPerformance = (): boolean => {
// Check if running in Telegram WebApp
const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
// Check if mobile
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
// Check for reduced motion preference
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
return prefersReducedMotion || (isTelegramWebApp && isMobile)
}
// Get cached value from localStorage // Get cached value from localStorage
const getCachedAnimationEnabled = (): boolean | null => { const getCachedAnimationEnabled = (): boolean | null => {
try { try {
@@ -26,15 +38,17 @@ export const setCachedAnimationEnabled = (enabled: boolean) => {
} }
} }
export default function AnimatedBackground() { // Memoized background component to prevent re-renders
const AnimatedBackground = memo(function AnimatedBackground() {
// Start with cached value (null means unknown yet) // Start with cached value (null means unknown yet)
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled()) const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled())
const [isLowPerf] = useState(() => isLowPerformance())
const { data: animationSettings } = useQuery({ const { data: animationSettings } = useQuery({
queryKey: ['animation-enabled'], queryKey: ['animation-enabled'],
queryFn: brandingApi.getAnimationEnabled, queryFn: brandingApi.getAnimationEnabled,
staleTime: 1000 * 60, // 1 minute - быстрее обновляется при изменении админом staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls
refetchOnWindowFocus: true, // Обновить при возврате в окно refetchOnWindowFocus: false, // Don't refetch on focus - save resources
retry: false, retry: false,
}) })
@@ -47,184 +61,26 @@ export default function AnimatedBackground() {
} }
}, [animationSettings]) }, [animationSettings])
// Don't render anything until we know the state (from cache or server) // Don't render if disabled or on low-performance devices
// If cache says disabled, don't render if (isEnabled !== true || isLowPerf) {
// If cache is null (first visit), default to not showing to avoid flash
if (isEnabled !== true) {
return null return null
} }
// Render only 2 blobs on mobile for better performance
const isMobile = window.innerWidth < 768
return ( return (
<> <div className="wave-bg-container">
{/* SVG Filter for glow effect */} <div className="wave-blob wave-blob-1" />
<svg style={{ position: 'absolute', width: 0, height: 0 }}> <div className="wave-blob wave-blob-2" />
<filter id="glow"> {!isMobile && (
<feGaussianBlur stdDeviation="3" result="coloredBlur" /> <>
<feMerge> <div className="wave-blob wave-blob-3" />
<feMergeNode in="coloredBlur" /> <div className="wave-blob wave-blob-4" />
<feMergeNode in="SourceGraphic" /> </>
</feMerge> )}
</filter> </div>
</svg>
{/* Animated wave gradients */}
<div className="wave-bg-container">
<div className="wave-blob wave-blob-1" />
<div className="wave-blob wave-blob-2" />
<div className="wave-blob wave-blob-3" />
<div className="wave-blob wave-blob-4" />
</div>
<style>{`
.wave-bg-container {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
background: transparent;
}
.wave-blob {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
mix-blend-mode: screen;
}
.wave-blob-1 {
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(249, 115, 22, 0.8) 0%, transparent 70%);
top: -20%;
left: -10%;
animation: wave1 15s ease-in-out infinite;
}
.wave-blob-2 {
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(59, 130, 246, 0.8) 0%, transparent 70%);
bottom: -15%;
right: -10%;
animation: wave2 18s ease-in-out infinite;
}
.wave-blob-3 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(168, 85, 247, 0.6) 0%, transparent 70%);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: wave3 20s ease-in-out infinite;
}
.wave-blob-4 {
width: 350px;
height: 350px;
background: radial-gradient(circle, rgba(236, 72, 153, 0.5) 0%, transparent 70%);
bottom: 20%;
left: 20%;
animation: wave4 12s ease-in-out infinite;
}
@keyframes wave1 {
0%, 100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(10%, 15%) scale(1.1);
}
50% {
transform: translate(5%, 25%) scale(0.95);
}
75% {
transform: translate(-5%, 10%) scale(1.05);
}
}
@keyframes wave2 {
0%, 100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(-15%, -10%) scale(1.15);
}
50% {
transform: translate(-10%, -20%) scale(0.9);
}
75% {
transform: translate(5%, -5%) scale(1.1);
}
}
@keyframes wave3 {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
}
33% {
transform: translate(-40%, -60%) scale(1.2);
}
66% {
transform: translate(-60%, -40%) scale(0.85);
}
}
@keyframes wave4 {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(20%, -15%) scale(1.25);
}
}
/* Dark theme - brighter */
:root.dark .wave-blob {
opacity: 0.5;
}
:root.dark .wave-blob-1 {
background: radial-gradient(circle, rgba(249, 115, 22, 0.7) 0%, transparent 70%);
}
:root.dark .wave-blob-2 {
background: radial-gradient(circle, rgba(59, 130, 246, 0.7) 0%, transparent 70%);
}
:root.dark .wave-blob-3 {
background: radial-gradient(circle, rgba(168, 85, 247, 0.5) 0%, transparent 70%);
}
:root.dark .wave-blob-4 {
background: radial-gradient(circle, rgba(236, 72, 153, 0.4) 0%, transparent 70%);
}
/* Light theme - visible */
:root:not(.dark) .wave-blob {
opacity: 0.7;
mix-blend-mode: multiply;
filter: blur(100px);
}
:root:not(.dark) .wave-blob-1 {
background: radial-gradient(circle, rgba(249, 115, 22, 0.9) 0%, transparent 70%);
}
:root:not(.dark) .wave-blob-2 {
background: radial-gradient(circle, rgba(59, 130, 246, 0.9) 0%, transparent 70%);
}
:root:not(.dark) .wave-blob-3 {
background: radial-gradient(circle, rgba(168, 85, 247, 0.7) 0%, transparent 70%);
}
:root:not(.dark) .wave-blob-4 {
background: radial-gradient(circle, rgba(236, 72, 153, 0.6) 0%, transparent 70%);
}
`}</style>
</>
) )
} })
export default AnimatedBackground

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, useMemo, memo } from 'react'
import type { WheelPrize } from '../../api/wheel' import type { WheelPrize } from '../../api/wheel'
interface FortuneWheelProps { interface FortuneWheelProps {
@@ -8,7 +8,14 @@ interface FortuneWheelProps {
onSpinComplete: () => void onSpinComplete: () => void
} }
export default function FortuneWheel({ // Pre-generate sparkle positions to avoid recalculating on each render
const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({
top: `${20 + (i * 10) % 60}%`,
left: `${15 + (i * 13) % 70}%`,
delay: `${i * 0.15}s`,
}))
const FortuneWheel = memo(function FortuneWheel({
prizes, prizes,
isSpinning, isSpinning,
targetRotation, targetRotation,
@@ -16,17 +23,17 @@ export default function FortuneWheel({
}: FortuneWheelProps) { }: FortuneWheelProps) {
const wheelRef = useRef<SVGGElement>(null) const wheelRef = useRef<SVGGElement>(null)
const [currentRotation, setCurrentRotation] = useState(0) const [currentRotation, setCurrentRotation] = useState(0)
const [lightPattern, setLightPattern] = useState<boolean[]>([]) const [lightPhase, setLightPhase] = useState(0)
// Animated lights effect // Animated lights effect - use phase instead of random array (less re-renders)
useEffect(() => { useEffect(() => {
if (isSpinning) { if (isSpinning) {
const interval = setInterval(() => { const interval = setInterval(() => {
setLightPattern(Array.from({ length: 20 }, () => Math.random() > 0.4)) setLightPhase(p => (p + 1) % 3) // Just toggle phase 0-1-2
}, 100) }, 250) // Slower interval = better performance
return () => clearInterval(interval) return () => clearInterval(interval)
} else { } else {
setLightPattern(Array.from({ length: 20 }, (_, i) => i % 2 === 0)) setLightPhase(0)
} }
}, [isSpinning]) }, [isSpinning])
@@ -42,6 +49,14 @@ export default function FortuneWheel({
} }
}, [isSpinning, targetRotation, onSpinComplete]) }, [isSpinning, targetRotation, onSpinComplete])
// Memoize light pattern calculation
const lightPattern = useMemo(() => {
return Array.from({ length: 20 }, (_, i) => {
if (!isSpinning) return i % 2 === 0
return (i + lightPhase) % 3 !== 0
})
}, [isSpinning, lightPhase])
if (prizes.length === 0) { if (prizes.length === 0) {
return ( return (
<div className="w-full max-w-md mx-auto aspect-square flex items-center justify-center"> <div className="w-full max-w-md mx-auto aspect-square flex items-center justify-center">
@@ -416,19 +431,19 @@ export default function FortuneWheel({
)} )}
</div> </div>
{/* Sparkle effects when spinning */} {/* Sparkle effects when spinning - optimized with pre-calculated positions */}
{isSpinning && ( {isSpinning && (
<div className="absolute inset-0 pointer-events-none overflow-hidden"> <div className="absolute inset-0 pointer-events-none overflow-hidden">
{Array.from({ length: 12 }).map((_, i) => ( {SPARKLE_POSITIONS.map((pos, i) => (
<div <div
key={`sparkle-${i}`} key={`sparkle-${i}`}
className="absolute w-2 h-2 bg-yellow-300 rounded-full" className="absolute w-2 h-2 bg-yellow-300 rounded-full animate-ping"
style={{ style={{
top: `${15 + Math.random() * 70}%`, top: pos.top,
left: `${15 + Math.random() * 70}%`, left: pos.left,
animation: `ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite`, animationDelay: pos.delay,
animationDelay: `${i * 0.12}s`, animationDuration: '1.5s',
opacity: 0.8, opacity: 0.7,
}} }}
/> />
))} ))}
@@ -436,4 +451,6 @@ export default function FortuneWheel({
)} )}
</div> </div>
) )
} })
export default FortuneWheel

View File

@@ -100,117 +100,90 @@ function generatePalette(baseHex: string): ColorPalette {
return palette as ColorPalette return palette as ColorPalette
} }
// Generate neutral palette from dark background color (for dark theme) // Interpolate between two RGB colors
function generateDarkPalette(darkBgHex: string): ColorPalette { function interpolateRgb(
const { h, s } = hexToHsl(darkBgHex) rgb1: { r: number; g: number; b: number },
rgb2: { r: number; g: number; b: number },
// Use very low saturation for neutral colors factor: number
const neutralS = Math.min(s, 15) ): string {
return rgbToString(
// Lightness values - from very light (50) to very dark (950) Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
const lightnessMap: Record<number, number> = { Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
50: 97, Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor)
100: 96, )
200: 89,
300: 80,
400: 58,
500: 40,
600: 28,
700: 20,
800: 12,
850: 10,
900: 7,
950: 4,
}
const palette: Partial<ColorPalette> = {}
for (const shade of [...SHADE_LEVELS, 850] as const) {
const lightness = lightnessMap[shade as keyof typeof lightnessMap] || 50
const { r, g, b } = hslToRgb(h, neutralS, lightness)
palette[shade as keyof ColorPalette] = rgbToString(r, g, b)
}
return palette as ColorPalette
}
// Generate light theme palette (champagne-like)
function generateLightPalette(lightBgHex: string): ColorPalette {
const { h, s } = hexToHsl(lightBgHex)
// Lightness values for light theme - inverse of dark
const lightnessMap: Record<number, number> = {
50: 100,
100: 98,
200: 91,
300: 83,
400: 74,
500: 64,
600: 55,
700: 42,
800: 31,
900: 21,
950: 10,
}
const palette: Partial<ColorPalette> = {}
for (const shade of SHADE_LEVELS) {
const lightness = lightnessMap[shade]
const { r, g, b } = hslToRgb(h, s, lightness)
palette[shade] = rgbToString(r, g, b)
}
return palette as ColorPalette
} }
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support) // Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
export function applyThemeColors(colors: ThemeColors): void { export function applyThemeColors(colors: ThemeColors): void {
const root = document.documentElement const root = document.documentElement
// Generate palettes from base colors // Generate palettes from status colors
const accentPalette = generatePalette(colors.accent) const accentPalette = generatePalette(colors.accent)
const successPalette = generatePalette(colors.success) const successPalette = generatePalette(colors.success)
const warningPalette = generatePalette(colors.warning) const warningPalette = generatePalette(colors.warning)
const errorPalette = generatePalette(colors.error) const errorPalette = generatePalette(colors.error)
// Generate dark/light palettes from background colors // === DARK THEME PALETTE ===
const darkPalette = generateDarkPalette(colors.darkBackground) // Convert hex colors to RGB
const champagnePalette = generateLightPalette(colors.lightBackground) const darkBgRgb = hexToRgb(colors.darkBackground)
const darkSurfaceRgb = hexToRgb(colors.darkSurface)
const darkTextRgb = hexToRgb(colors.darkText)
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary)
// Apply dark palette // Apply dark palette with actual user colors:
for (const shade of [...SHADE_LEVELS, 850] as const) { // Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
if (darkPalette[shade as keyof ColorPalette]) { root.style.setProperty('--color-dark-50', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
root.style.setProperty(`--color-dark-${shade}`, darkPalette[shade as keyof ColorPalette]) root.style.setProperty('--color-dark-100', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
} root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33))
} root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66))
root.style.setProperty('--color-dark-400', rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b))
// Apply champagne/light palette // Transition colors (500-700): interpolate between secondary text and surface
for (const shade of SHADE_LEVELS) { root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4))
root.style.setProperty(`--color-champagne-${shade}`, champagnePalette[shade]) root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6))
} root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8))
// Apply accent palette // Surface/card colors (800-850): surface color
root.style.setProperty('--color-dark-800', rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b))
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5))
// Background colors (900-950): background color
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7))
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b))
// === LIGHT THEME PALETTE ===
const lightBgRgb = hexToRgb(colors.lightBackground)
const lightSurfaceRgb = hexToRgb(colors.lightSurface)
const lightTextRgb = hexToRgb(colors.lightText)
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary)
// Apply champagne palette with actual user colors:
// Background colors (light shades): 50-100 = surface, 200-400 = background tones
root.style.setProperty('--color-champagne-50', rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b))
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3))
root.style.setProperty('--color-champagne-200', rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b))
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2))
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4))
// Transition colors (500-600): between bg and text
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6))
root.style.setProperty('--color-champagne-600', rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b))
// Text colors (700-950): secondary to primary text
root.style.setProperty('--color-champagne-700', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33))
root.style.setProperty('--color-champagne-800', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66))
root.style.setProperty('--color-champagne-900', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
root.style.setProperty('--color-champagne-950', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
// === STATUS COLOR PALETTES ===
for (const shade of SHADE_LEVELS) { for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]) root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade])
}
// Apply success palette
for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-success-${shade}`, successPalette[shade]) root.style.setProperty(`--color-success-${shade}`, successPalette[shade])
}
// Apply warning palette
for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade]) root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade])
}
// Apply error palette
for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade]) root.style.setProperty(`--color-error-${shade}`, errorPalette[shade])
} }
// Apply semantic colors (hex for direct use in some places) // Apply semantic colors (hex for direct use)
root.style.setProperty('--color-dark-bg', colors.darkBackground) root.style.setProperty('--color-dark-bg', colors.darkBackground)
root.style.setProperty('--color-dark-surface', colors.darkSurface) root.style.setProperty('--color-dark-surface', colors.darkSurface)
root.style.setProperty('--color-dark-text', colors.darkText) root.style.setProperty('--color-dark-text', colors.darkText)

View File

@@ -32,15 +32,33 @@ const ChevronRightIcon = () => (
</svg> </svg>
) )
const SupportLottieIcon = () => ( // Check if device might be low-performance (Telegram WebApp on mobile)
<div className="w-6 h-6"> const isLowPerfDevice = (() => {
<DotLottieReact const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie" const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
loop return isTelegramWebApp && isMobile
autoplay })()
/>
</div> const SupportLottieIcon = () => {
) // Use static icon on low-performance devices
if (isLowPerfDevice) {
return (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
</svg>
)
}
return (
<div className="w-6 h-6">
<DotLottieReact
src="https://lottie.host/51d2c570-0aa0-47af-a8cb-2bd4afe8d6ae/RzpYZ5zUKX.lottie"
loop
autoplay
/>
</div>
)
}
export default function Dashboard() { export default function Dashboard() {
const { t } = useTranslation() const { t } = useTranslation()

View File

@@ -1050,3 +1050,99 @@
from { width: 100%; } from { width: 100%; }
to { width: 0%; } to { width: 0%; }
} }
/* ========== ANIMATED BACKGROUND (GPU Optimized) ========== */
.wave-bg-container {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
pointer-events: none;
contain: strict; /* Performance: isolate repaints */
}
.wave-blob {
position: absolute;
border-radius: 50%;
filter: blur(60px); /* Reduced from 80px for better performance */
opacity: 0.5;
/* GPU acceleration */
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
/* Smaller sizes for better performance */
.wave-blob-1 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.6) 0%, transparent 70%);
top: -15%;
left: -10%;
animation: wave1 20s ease-in-out infinite; /* Slower = less CPU */
}
.wave-blob-2 {
width: 350px;
height: 350px;
background: radial-gradient(circle, rgba(59, 130, 246, 0.6) 0%, transparent 70%);
bottom: -10%;
right: -10%;
animation: wave2 25s ease-in-out infinite;
}
.wave-blob-3 {
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(168, 85, 247, 0.4) 0%, transparent 70%);
top: 40%;
left: 40%;
animation: wave3 30s ease-in-out infinite;
}
.wave-blob-4 {
width: 250px;
height: 250px;
background: radial-gradient(circle, rgba(236, 72, 153, 0.35) 0%, transparent 70%);
bottom: 25%;
left: 15%;
animation: wave4 22s ease-in-out infinite;
}
/* Simplified keyframes - fewer steps = better performance */
@keyframes wave1 {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(8%, 12%, 0) scale(1.05); }
}
@keyframes wave2 {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(-10%, -8%, 0) scale(1.08); }
}
@keyframes wave3 {
0%, 100% { transform: translate3d(-50%, -50%, 0) scale(1); }
50% { transform: translate3d(-45%, -55%, 0) scale(1.1); }
}
@keyframes wave4 {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(12%, -8%, 0) scale(1.12); }
}
/* Light theme adjustments */
.light .wave-blob {
opacity: 0.6;
filter: blur(70px);
}
.light .wave-blob-1 {
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.7) 0%, transparent 70%);
}
/* Disable animations for reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.wave-blob {
animation: none !important;
}
}