Files
openhuman/app/src/components/MeshGradient.tsx
T

175 lines
7.2 KiB
TypeScript

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<HTMLCanvasElement>(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<typeof Gradient> | 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 (
<canvas
ref={canvasRef}
id="mesh-gradient"
data-transition-in
className="absolute inset-0 w-full h-full opacity-10"
/>
);
}