'use client'; /** * ParticleHero — public entry to the WebGL particle background. * * Responsibilities (kept OUT of ParticleField so that component can * assume happy-path WebGL2): * * 1. WebGL2 missing → static gradient. * 2. Mobile / low-power profile → either 16k particles or skip. * 3. prefers-reduced-motion → still WebGL + cursor tracking, but * capped at 16k particles with halved drift and halved push * velocity. The ring still follows the cursor at full fidelity * because that's the interaction the user explicitly wants; we * only damp the *ambient* motion so the field reads as calm. * 4. Lazy-load Three.js via next/dynamic so the hero LCP text isn't * blocked by ~150kb of WebGL plumbing. * * The static fallback is a CSS-only radial gradient + faint dot mask. * It looks intentional, not broken — same color story as the live * particle field, just without motion. */ import dynamic from 'next/dynamic'; import { useEffect, useState } from 'react'; type Capability = | { kind: 'unknown' } | { kind: 'fallback' } | { kind: 'webgl'; textureSize: 128 | 256; motionScale: number }; // Dynamic import keeps three out of the initial bundle. ssr:false // because there's no DOM/Canvas during SSR anyway. const ParticleField = dynamic( () => import('./ParticleField').then((m) => ({ default: m.ParticleField })), { ssr: false, // No loading UI — the static gradient already lives at z-0 above // until this resolves, and the canvas paints into the same slot. loading: () => null, }, ); /** * Detect WebGL2 + float-render-target support without keeping the * context around. We create a throwaway canvas, ask for `webgl2`, and * probe `EXT_color_buffer_float`. If anything fails we fall back. * * Runs sync-only in the browser; never during SSR. */ function detectWebGL2(): boolean { try { const c = document.createElement('canvas'); const gl = c.getContext('webgl2'); if (!gl) return false; const ext = gl.getExtension('EXT_color_buffer_float'); // Losing context to ensure we don't leak the probe. const loseExt = gl.getExtension('WEBGL_lose_context'); loseExt?.loseContext(); return Boolean(ext); } catch { return false; } } export function ParticleHero() { // Start in 'unknown' so SSR markup matches the first client render — // the fallback gradient is rendered until we resolve capability, so // there's no flash either way. const [cap, setCap] = useState({ kind: 'unknown' }); useEffect(() => { // 1. WebGL2 + float targets — hard gate. Without these the sim // can't run at all, fall through to the static gradient. if (!detectWebGL2()) { setCap({ kind: 'fallback' }); return; } const reduce = window.matchMedia('(prefers-reduced-motion: reduce)'); /** * Pick the right particle tier for the device. * * Returns a non-fallback WebGL config OR null when the device is * too constrained to render the field at all (low-core phones). * Reduced-motion does NOT shrink the tier here — it's applied as * a separate motion scalar on top, because the user still wants * the cursor-tracking interaction. */ const pickTier = (): Capability => { // Heuristic: small viewport OR an absurd DPR (low-DPI phones lying // about retina) with no high-end signal. hardwareConcurrency is a // rough but free proxy; logical cores <= 4 on a small viewport is // a strong hint we shouldn't push 65k particles. const isNarrow = window.matchMedia('(max-width: 768px)').matches; const dpr = window.devicePixelRatio || 1; const cores = navigator.hardwareConcurrency ?? 4; const reduced = reduce.matches; // Motion-reduce caps drift + ring-push velocity at 50% but keeps // pointer position fidelity at 100%. 1.0 means "default motion". const motionScale = reduced ? 0.5 : 1.0; if (isNarrow) { // Phones: drop to 16k. Going lower than that and the field // visibly thins out; going higher and we cook batteries. // 4-core phones get the static fallback — those are the // budget Androids most likely to thermal-throttle. if (cores <= 4) { return { kind: 'fallback' }; } return { kind: 'webgl', textureSize: 128, motionScale }; } if (dpr > 2.5 && cores <= 4) { // High-DPI low-core — likely a low-end tablet. return { kind: 'webgl', textureSize: 128, motionScale }; } // Desktop / capable tablet. Reduced-motion users get the same 128 // tier as mobile — fewer particles means less ambient activity in // peripheral vision, which is what the motion preference is for. if (reduced) { return { kind: 'webgl', textureSize: 128, motionScale }; } return { kind: 'webgl', textureSize: 256, motionScale }; }; setCap(pickTier()); // Respond to motion-preference changes mid-session — re-pick the // tier so toggling the OS setting takes effect without a reload. const onReduceChange = () => setCap(pickTier()); reduce.addEventListener('change', onReduceChange); return () => reduce.removeEventListener('change', onReduceChange); }, []); // Static fallback: pure indigo radial glow, no dot grid. The // dot-mask was confusing — it read as "stippled white texture" // against the indigo glow rather than as resting particles. The // cleaner, dotless gradient holds up better as a fallback. if (cap.kind !== 'webgl') { return (