import { useEffect, useRef } from 'react'; import { Gradient } from '../lib/meshGradient'; import { channelsToHex } from '../lib/theme/color'; import { useAppSelector } from '../store/hooks'; import { selectEffectiveTheme, selectThemeVariant } from '../store/themeSlice'; /** * Animated WebGL mesh gradient background (Stripe-style), tinted by the active * theme. The four gradient stops are derived from the theme's primary ramp + * surface token (read as hex from the resolved CSS variables), so the backdrop * follows Matrix green / HAL red / Ocean blue / etc. It re-initialises whenever * the active theme or its light/dark variant changes. * * Renders behind the dotted-canvas overlay so dots remain visible on top, and * catches WebGL errors gracefully (Tauri WebView can lack a GPU context). */ export default function MeshGradient() { const canvasRef = useRef(null); // Re-init the gradient when the effective theme's mesh-driving tokens change. const effectiveTheme = useAppSelector(selectEffectiveTheme); const variant = useAppSelector(selectThemeVariant); const tokenSignature = [ effectiveTheme.id, effectiveTheme.isDark ? 'dark' : 'light', effectiveTheme.colors['primary-700'] ?? '', effectiveTheme.colors['primary-300'] ?? '', effectiveTheme.colors.surface ?? '', effectiveTheme.colors['primary-500'] ?? '', ].join('|'); useEffect(() => { let gradient: InstanceType | null = null; let raf = 0; let disposed = false; const disconnectGradient = () => { try { if (gradient) { gradient.disconnect(); gradient.pause(); } } catch { // Cleanup is best-effort. } finally { gradient = null; } }; // Only animate when the app window is actually visible AND focused, and the // user hasn't asked for reduced motion. A full-screen WebGL shader that keeps // rendering every frame while the app merely sits open in the background // (occluded/blurred) or minimized is pure wasted GPU/CPU — the sustained // load users see as the machine running hot with nothing happening (#3524). const prefersReducedMotion = () => typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false; const applyPlayState = () => { if (!gradient) return; const shouldAnimate = !document.hidden && document.hasFocus() && !prefersReducedMotion(); // Read the gradient's own play flag so we never double-schedule its rAF // loop (calling play() while already playing starts a second loop). const playing = gradient.conf?.playing ?? false; if (shouldAnimate === playing) return; try { // Only (re)start once the WebGL mesh actually initialized. On a no-GPU / // headless environment `connect()` leaves `conf.playing` truthy without // ever creating `mesh`, so resuming here would schedule `animate`, which // dereferences `mesh.material` on the next frame and throws — exactly the // environment this component is meant to tolerate (#3524). `pause()` is // always safe (it just clears the flag / cancels any rAF). if (shouldAnimate) { if (gradient.mesh) gradient.play(); } else { gradient.pause(); } } catch { // Play-state control is best-effort. } }; const start = () => { if (disposed) return; const root = document.documentElement; const canvas = canvasRef.current; if (!canvas) return; disconnectGradient(); const hex = (token: string, fallback: string) => { const v = window.getComputedStyle(root).getPropertyValue(`--${token}`).trim(); return v ? channelsToHex(v) : fallback; }; // Accent-led stops + the surface base, so the mesh reads on any theme. canvas.style.setProperty('--gradient-color-1', hex('primary-700', '#0019d9')); canvas.style.setProperty('--gradient-color-2', hex('primary-300', '#b5d5ff')); canvas.style.setProperty('--gradient-color-3', hex('surface', '#ffffff')); canvas.style.setProperty('--gradient-color-4', hex('primary-500', '#4fa4ff')); console.debug('[theme] mesh gradient stops applied', { themeId: effectiveTheme.id, variant }); try { gradient = new Gradient(); gradient.initGradient('#mesh-gradient'); // Honor the current visibility/focus/reduced-motion state right away, so // a gradient created while the window is backgrounded never animates. applyPlayState(); } catch (err) { console.warn('[MeshGradient] WebGL init failed, gradient disabled:', err); gradient = null; } }; const scheduleStart = () => { window.cancelAnimationFrame(raf); // Defer one frame so ThemeProvider (an ancestor) has applied the theme's // CSS variables before we read them — child effects run before parents. raf = window.requestAnimationFrame(start); }; scheduleStart(); // Pause/resume the animation as the window gains or loses visibility/focus // (and when the reduced-motion preference flips), so it only ever burns // cycles while the user is actually looking at a focused OpenHuman window. const onPlayStateChange = () => applyPlayState(); document.addEventListener('visibilitychange', onPlayStateChange); window.addEventListener('focus', onPlayStateChange); window.addEventListener('blur', onPlayStateChange); let removeMotionListener: (() => void) | undefined; if (typeof window !== 'undefined' && window.matchMedia) { const motionMq = window.matchMedia('(prefers-reduced-motion: reduce)'); if (motionMq.addEventListener) { motionMq.addEventListener('change', onPlayStateChange); removeMotionListener = () => motionMq.removeEventListener('change', onPlayStateChange); } else { motionMq.addListener(onPlayStateChange); removeMotionListener = () => motionMq.removeListener(onPlayStateChange); } } let removeSystemListener: (() => void) | undefined; if (variant === 'system' && typeof window !== 'undefined' && window.matchMedia) { const mq = window.matchMedia('(prefers-color-scheme: dark)'); const listener = () => scheduleStart(); if (mq.addEventListener) { mq.addEventListener('change', listener); removeSystemListener = () => mq.removeEventListener('change', listener); } else { mq.addListener(listener); removeSystemListener = () => mq.removeListener(listener); } } return () => { disposed = true; removeSystemListener?.(); removeMotionListener?.(); document.removeEventListener('visibilitychange', onPlayStateChange); window.removeEventListener('focus', onPlayStateChange); window.removeEventListener('blur', onPlayStateChange); window.cancelAnimationFrame(raf); disconnectGradient(); }; }, [effectiveTheme.id, tokenSignature, variant]); return ( ); }