diff --git a/src/__tests__/graphicsTier.test.ts b/src/__tests__/graphicsTier.test.ts new file mode 100644 index 00000000..61eeecca --- /dev/null +++ b/src/__tests__/graphicsTier.test.ts @@ -0,0 +1,145 @@ +import { + classifyGpuRenderer, + classifyGraphicsTier, + minTier, + FEATURES_BY_TIER, + type HardwareSignals, +} from "@/lib/graphics-tier"; + +/** + * The tier decides whether a device renders a full-viewport fragment shader and + * a three.js globe, so the interesting cases are the real device profiles these + * weights were calibrated against — not the arithmetic. + */ + +const base: HardwareSignals = { + reducedMotion: false, + saveData: false, + cores: 8, + memoryGb: 8, + gpu: "strong", + pixels: 1920 * 1080, + touchPrimary: false, + viewportWidth: 1440, +}; + +const signals = (overrides: Partial): HardwareSignals => ({ + ...base, + ...overrides, +}); + +describe("classifyGpuRenderer", () => { + it("detects software rasterizers even when they name a vendor", () => { + expect(classifyGpuRenderer("ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))")).toBe( + "software", + ); + expect(classifyGpuRenderer("Mesa/X.org, llvmpipe (LLVM 15.0.7, 256 bits)")).toBe("software"); + expect(classifyGpuRenderer("Microsoft Basic Render Driver")).toBe("software"); + }); + + it("recognizes discrete and modern mobile GPUs", () => { + expect(classifyGpuRenderer("ANGLE (Apple, Apple M2 Pro, OpenGL 4.1)")).toBe("strong"); + expect(classifyGpuRenderer("Apple GPU")).toBe("strong"); + expect(classifyGpuRenderer("ANGLE (NVIDIA GeForce RTX 4070)")).toBe("strong"); + expect(classifyGpuRenderer("Adreno (TM) 730")).toBe("strong"); + }); + + it("treats integrated and legacy mobile GPUs as weak", () => { + expect(classifyGpuRenderer("ANGLE (Intel, Intel(R) UHD Graphics 620)")).toBe("weak"); + expect(classifyGpuRenderer("Intel(R) HD Graphics 4000")).toBe("weak"); + expect(classifyGpuRenderer("Mali-T830")).toBe("weak"); + expect(classifyGpuRenderer("PowerVR Rogue GE8320")).toBe("weak"); + expect(classifyGpuRenderer("Adreno (TM) 405")).toBe("weak"); + }); + + it("does not guess when the renderer is masked or missing", () => { + expect(classifyGpuRenderer(null)).toBe("unknown"); + expect(classifyGpuRenderer("WebKit WebGL")).toBe("unknown"); + }); +}); + +describe("classifyGraphicsTier", () => { + it("drops to low for reduced motion regardless of hardware", () => { + const probe = classifyGraphicsTier(signals({ reducedMotion: true })); + expect(probe.tier).toBe("low"); + expect(probe.reasons).toContain("prefers-reduced-motion is on"); + }); + + it("drops to low when data saver is on", () => { + expect(classifyGraphicsTier(signals({ saveData: true })).tier).toBe("low"); + }); + + it("gives a modern desktop the full surface set", () => { + expect(classifyGraphicsTier(base).tier).toBe("high"); + }); + + it("keeps a phone with a good GPU on aurora-only", () => { + // iPhone-class: strong GPU, modest core count, no deviceMemory API. + const probe = classifyGraphicsTier( + signals({ + cores: 4, + memoryGb: undefined, + pixels: 390 * 844 * 9, + touchPrimary: true, + viewportWidth: 390, + }), + ); + expect(probe.tier).toBe("medium"); + }); + + it("falls to low on an old integrated-GPU laptop", () => { + const probe = classifyGraphicsTier( + signals({ cores: 4, memoryGb: 4, gpu: "weak", pixels: 1366 * 768 }), + ); + expect(probe.tier).toBe("low"); + }); + + it("never promises WebGL surfaces without a usable GPU", () => { + // Plenty of CPU and memory, but a software rasterizer or no WebGL at all: + // `high` implies MarbleField + globe, which is exactly what these devices + // cannot run. + for (const gpu of ["software", "none"] as const) { + const probe = classifyGraphicsTier(signals({ cores: 16, memoryGb: 32, gpu })); + expect(probe.tier === "medium" || probe.tier === "low").toBe(true); + expect(FEATURES_BY_TIER[probe.tier].webglBackdrop).toBe(false); + expect(FEATURES_BY_TIER[probe.tier].webglGlobe).toBe(false); + } + }); + + it("penalizes very large framebuffers", () => { + const normal = classifyGraphicsTier(base); + const fiveK = classifyGraphicsTier(signals({ pixels: 5120 * 2880 })); + expect(fiveK.score).toBeLessThan(normal.score); + expect(fiveK.reasons).toContain("very high-resolution display"); + }); + + it("does not penalize a browser that hides deviceMemory", () => { + const withApi = classifyGraphicsTier(signals({ memoryGb: 8 })); + const withoutApi = classifyGraphicsTier(signals({ memoryGb: undefined })); + expect(withoutApi.score).toBeGreaterThanOrEqual(withApi.score - 1); + expect(withoutApi.tier).toBe("high"); + }); +}); + +describe("tier features", () => { + it("degrades monotonically", () => { + const order = ["high", "medium", "low"] as const; + const weight = (t: (typeof order)[number]) => + Object.values(FEATURES_BY_TIER[t]).filter(Boolean).length; + expect(weight("high")).toBeGreaterThan(weight("medium")); + expect(weight("medium")).toBeGreaterThan(weight("low")); + }); + + it("keeps a paint-once background at every tier", () => { + // The static gradient costs nothing after first paint, so "low" still has + // a background — the removal proposal this replaces dropped it everywhere. + expect(FEATURES_BY_TIER.low.background).toBe(true); + expect(FEATURES_BY_TIER.low.auroraAnimated).toBe(false); + }); + + it("minTier picks the more conservative tier", () => { + expect(minTier("high", "medium")).toBe("medium"); + expect(minTier("low", "high")).toBe("low"); + expect(minTier("medium", "medium")).toBe("medium"); + }); +}); diff --git a/src/components/common/overall-layout/layout.tsx b/src/components/common/overall-layout/layout.tsx index af790ac3..014ee8a8 100644 --- a/src/components/common/overall-layout/layout.tsx +++ b/src/components/common/overall-layout/layout.tsx @@ -7,6 +7,7 @@ import { api } from "@/utils/api"; import useUser from "@/hooks/useUser"; import { useUserStore } from "@/lib/zustand/user"; import { useAppearanceStore } from "@/lib/zustand/appearance"; +import { useGraphicsTier } from "@/hooks/useGraphicsTier"; import { Background } from "@/components/ui/background"; import { normalizeAddressToBech32 } from "@/utils/addressCompatibility"; import useAppWallet from "@/hooks/useAppWallet"; @@ -128,13 +129,12 @@ export default function RootLayout({ const [hasCheckedSession, setHasCheckedSession] = useState(false); // Prevent duplicate checks const [showPostAuthLoading, setShowPostAuthLoading] = useState(false); // Show loading after authorization - // Animated background preference (persisted to localStorage). Gate render on a - // mounted flag so the server (which can't read localStorage) and the first - // client paint agree, avoiding a hydration mismatch. - const backgroundEnabled = useAppearanceStore((s) => s.backgroundEnabled); + // How much background to render: hardware detection by default, overridable + // in profile → Appearance. `resolved` is false until detection has run on the + // client, so the server markup and the first client paint agree (no hydration + // mismatch) and no WebGL surface mounts before we know the device can take it. const backgroundPreset = useAppearanceStore((s) => s.backgroundPreset); - const [appearanceMounted, setAppearanceMounted] = useState(false); - useEffect(() => setAppearanceMounted(true), []); + const { features: gfx, resolved: gfxResolved } = useGraphicsTier(); // Use WalletState for connection check const connected = String(walletState) === String(WalletState.CONNECTED); @@ -602,14 +602,17 @@ export default function RootLayout({ return (
- {/* Animated app background (on by default; toggle in profile → Appearance). - Renders on every route including the homepage, behind the homepage's - own hero background. */} - {appearanceMounted && backgroundEnabled && ( + {/* App background. The aurora itself is cheap enough to render anywhere; + the hardware tier decides whether its keyframes and pointer parallax + run (profile → Appearance overrides it). Renders on every route + including the homepage, behind the homepage's own hero background. */} + {gfxResolved && gfx.background && (
diff --git a/src/components/pages/homepage/index.tsx b/src/components/pages/homepage/index.tsx index 5ae022bf..6034a2ba 100644 --- a/src/components/pages/homepage/index.tsx +++ b/src/components/pages/homepage/index.tsx @@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Background } from "@/components/ui/background"; import { useAppearanceStore } from "@/lib/zustand/appearance"; +import { useGraphicsTier } from "@/hooks/useGraphicsTier"; import { MarbleField } from "@/components/ui/marble-field"; import { FeatureIcon } from "@/components/pages/homepage/feature-icons"; import { MultisigSigningExplainer } from "@/components/pages/homepage/multisig-explainer"; @@ -169,15 +170,15 @@ export function PageHomepage() { const auroraRef = useRef(null); const meshRef = useRef(null); - // The homepage hero background follows the same appearance setting as the rest - // of the app. Default-show until mounted so the SSR markup and first client - // paint agree (avoids a hydration mismatch on the persisted preference). - const backgroundEnabled = useAppearanceStore((s) => s.backgroundEnabled); + // The homepage hero background follows the same capability tier as the rest of + // the app: the aurora degrades to a static gradient on weak hardware, and the + // WebGL marble field only mounts where a real GPU was detected and the device + // is holding frame budget. Gated on `resolved` so the SSR markup and the first + // client paint agree (no hydration mismatch, no canvas mounted then dropped). const backgroundPreset = useAppearanceStore((s) => s.backgroundPreset); - const [appearanceMounted, setAppearanceMounted] = useState(false); - useEffect(() => setAppearanceMounted(true), []); - const heroBackgroundOn = !appearanceMounted || backgroundEnabled; - const heroPreset = appearanceMounted ? backgroundPreset : "aurora"; + const { features: gfx, resolved: gfxResolved } = useGraphicsTier(); + const heroBackgroundOn = gfxResolved && gfx.background; + const heroPreset = backgroundPreset; useEffect(() => { if (!heroBackgroundOn) return; @@ -243,17 +244,25 @@ export function PageHomepage() { {/* Aurora Background — opacity is driven per-frame via the rAF scroll effect above (ref), not React state, so scrolling stays smooth. */}
- +
{/* Marble swirls under a soft wash, above the aurora. The wash is a plain translucent fill (no backdrop-filter): blurring the live canvas every frame was a major scroll cost, and the marble is now - rendered low-res, so it already reads soft. */} -
- -
-
+ rendered low-res, so it already reads soft. Full-viewport fragment + shader — high tier only. */} + {gfx.webglBackdrop && ( +
+ +
+
+ )} )} diff --git a/src/components/pages/homepage/wallets/new-wallet-flow/shared/GlassMorphismPageWrapper.tsx b/src/components/pages/homepage/wallets/new-wallet-flow/shared/GlassMorphismPageWrapper.tsx index 7114507c..798bb633 100644 --- a/src/components/pages/homepage/wallets/new-wallet-flow/shared/GlassMorphismPageWrapper.tsx +++ b/src/components/pages/homepage/wallets/new-wallet-flow/shared/GlassMorphismPageWrapper.tsx @@ -6,6 +6,7 @@ import React from 'react'; import dynamic from 'next/dynamic'; +import { useGraphicsTier } from '@/hooks/useGraphicsTier'; // Lazy load Globe const Globe = dynamic(() => import('@/components/pages/homepage/globe'), { @@ -23,6 +24,10 @@ export default function GlassMorphismPageWrapper({ className = '' }: GlassMorphismPageWrapperProps) { const [isDarkMode, setIsDarkMode] = React.useState(false); + // three.js scene: only where the hardware tier says the device can afford it. + // Everything else keeps the flat background — the glass body class below still + // applies, so the page reads the same, just without the globe behind it. + const { features: gfx } = useGraphicsTier(); // Add subtle glass effect styles React.useEffect(() => { @@ -53,33 +58,35 @@ export default function GlassMorphismPageWrapper({ return ( <> - {/* Globe background - centered and always visible */} -
-
- +
+ +
-
+ )} {/* Page content */}
diff --git a/src/components/ui/background.tsx b/src/components/ui/background.tsx index 32e4d41a..0897c691 100644 --- a/src/components/ui/background.tsx +++ b/src/components/ui/background.tsx @@ -31,6 +31,18 @@ export interface BackgroundProps * @default "aurora" */ preset?: BackgroundPreset + /** + * Run the CSS keyframes (orbs, sheen, bloom). Defaults to the variant, but + * callers driven by `useGraphicsTier` pass the detected capability instead so + * a weak device gets the same layers painted once and left alone. + */ + animated?: boolean + /** + * Pointer-reactive parallax on the orb layer. Defaults to `animated`; the + * hardware tier turns it off one step before the keyframes, since it is the + * cheapest thing to lose and the least noticeable. + */ + parallax?: boolean } // Two grayscale aurora gradients at different angles. Animating them in opposite @@ -140,18 +152,40 @@ const PRESET_COLORS: Record< * mouse-reactive parallax. Honors `prefers-reduced-motion` (the `.animate-*` * utilities disable motion, and pointer parallax is skipped). * + * Motion is a prop, not a constant: pass `animated` / `parallax` from + * `useGraphicsTier` so the layer degrades to a static gradient on hardware that + * cannot afford it. + * * @example * ```tsx + * const { features } = useGraphicsTier() *
- * + * *
* ``` */ const Background = React.forwardRef( - ({ className, variant, preset = "aurora", showRadialGradient = true, children, ...props }, ref) => { - const isAnimated = variant !== "aurora-static" + ( + { + className, + variant, + preset = "aurora", + showRadialGradient = true, + animated, + parallax, + children, + ...props + }, + ref, + ) => { + const isAnimated = animated ?? variant !== "aurora-static" const colors = PRESET_COLORS[preset] ?? PRESET_COLORS.aurora const reduced = useReducedMotion() + const parallaxOn = (parallax ?? isAnimated) && !reduced const rootRef = React.useRef(null) React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement) @@ -160,7 +194,7 @@ const Background = React.forwardRef( // CSS variables the orb/sheen layers read. rAF-throttled and pointer-passive, // so it's cheap; disabled for reduced-motion users. React.useEffect(() => { - if (!isAnimated || reduced) return + if (!parallaxOn) return const el = rootRef.current if (!el) return let raf = 0 @@ -178,7 +212,7 @@ const Background = React.forwardRef( window.removeEventListener("pointermove", onMove) cancelAnimationFrame(raf) } - }, [isAnimated, reduced]) + }, [parallaxOn]) return (
void>(); + +export interface GraphicsTierSnapshot { + /** Effective tier after the user's mode, detection and runtime caps. */ + tier: GraphicsTier; + /** What the hardware detection alone concluded. */ + detected: GraphicsTier; + /** Signals behind `detected`, for the settings UI. */ + reasons: string[]; + /** OS-level reduced-motion request; overrides any manual mode. */ + reducedMotion: boolean; + /** True once detection has run on the client. */ + resolved: boolean; +} + +/** + * Pre-detection snapshot, used for SSR and the hydration render: a static + * aurora and no WebGL. Safe everywhere, so the first paint never mounts a + * canvas the device turns out not to want. + */ +const SSR_SNAPSHOT: GraphicsTierSnapshot = { + tier: "low", + detected: "low", + reasons: [], + reducedMotion: false, + resolved: false, +}; + +let snapshot: GraphicsTierSnapshot = SSR_SNAPSHOT; + +function readSessionCap(): GraphicsTier | null { + try { + const stored = sessionStorage.getItem(SESSION_CAP_KEY); + return stored === "low" || stored === "medium" || stored === "high" ? stored : null; + } catch { + return null; + } +} + +function writeSessionCap(tier: GraphicsTier) { + try { + sessionStorage.setItem(SESSION_CAP_KEY, tier); + } catch { + // Private mode / storage disabled — the cap simply does not survive the + // page, and the probe re-derives it on the next load. + } +} + +function publish() { + if (!detected) return; + const tier = minTier(minTier(detected.tier, fpsCap), batteryCap); + const reducedMotion = detected.signals.reducedMotion; + if (snapshot.resolved && snapshot.tier === tier && snapshot.reducedMotion === reducedMotion) { + return; + } + snapshot = { + tier, + detected: detected.tier, + reasons: detected.reasons, + reducedMotion, + resolved: true, + }; + listeners.forEach((l) => l()); +} + +/** Measure real frame rate and cap the tier if the device is not keeping up. */ +function runFpsProbe() { + if (probesRun >= 2 || typeof requestAnimationFrame !== "function") return; + probesRun += 1; + + window.setTimeout(() => { + if (document.hidden) { + // A hidden tab throttles rAF to ~0; a sample now would be meaningless. + probesRun -= 1; + document.addEventListener("visibilitychange", () => runFpsProbe(), { once: true }); + return; + } + + let frames = 0; + let start = 0; + const sample = (t: number) => { + if (start === 0) { + start = t; + requestAnimationFrame(sample); + return; + } + frames += 1; + const elapsed = t - start; + if (elapsed < PROBE_WINDOW_MS) { + requestAnimationFrame(sample); + return; + } + if (document.hidden) return; + const fps = (frames * 1000) / elapsed; + const current = snapshot.tier; + if (fps < FPS_FLOOR) { + fpsCap = "low"; + } else if (fps < FPS_DEGRADE && current !== "low") { + fpsCap = minTier(fpsCap, current === "high" ? "medium" : "low"); + } else { + return; // Keeping frame budget — leave the detected tier alone. + } + writeSessionCap(fpsCap); + publish(); + // One more sample after a downgrade: dropping the WebGL surfaces may or + // may not have been enough, and if it was not, we fall the rest of the way. + if (fpsCap !== "low") runFpsProbe(); + }; + requestAnimationFrame(sample); + }, PROBE_DELAY_MS); +} + +/** A discharging device on its last 20% does not get to run shaders. */ +function watchBattery() { + const getBattery = ( + navigator as Navigator & { + getBattery?: () => Promise<{ + level: number; + charging: boolean; + addEventListener: (type: string, cb: () => void) => void; + }>; + } + ).getBattery; + if (typeof getBattery !== "function") return; + + getBattery + .call(navigator) + .then((battery) => { + const update = () => { + const next: GraphicsTier = !battery.charging && battery.level <= 0.2 ? "medium" : "high"; + if (next === batteryCap) return; + batteryCap = next; + publish(); + }; + update(); + battery.addEventListener("levelchange", update); + battery.addEventListener("chargingchange", update); + }) + .catch(() => { + // Firefox/Safari reject or omit the API entirely; no cap, no problem. + }); +} + +function init() { + if (detected) return; + detected = detectGraphicsTier(); + fpsCap = readSessionCap() ?? "high"; + publish(); + + // The OS reduced-motion switch can flip while the app is open. + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + const onMotionChange = () => { + detected = detectGraphicsTier(); + publish(); + }; + mq.addEventListener("change", onMotionChange); + + watchBattery(); + runFpsProbe(); +} + +function subscribe(listener: () => void) { + if (typeof window !== "undefined") init(); + listeners.add(listener); + return () => listeners.delete(listener); +} + +const getSnapshot = () => snapshot; +const getServerSnapshot = () => SSR_SNAPSHOT; + +/** Effective tier for a given user mode. `off` is handled by the caller. */ +function tierForMode(mode: BackgroundMode, auto: GraphicsTier): GraphicsTier { + switch (mode) { + case "full": + return "high"; + case "reduced": + return "medium"; + case "off": + return "low"; + default: + return auto; + } +} + +export interface GraphicsTierState extends GraphicsTierSnapshot { + /** The user's stored preference. */ + mode: BackgroundMode; + /** What may actually render, after mode + detection + runtime caps. */ + features: GraphicsFeatures; +} + +/** + * What the current device should render. + * + * ```tsx + * const { features } = useGraphicsTier(); + * {features.background && } + * {features.webglBackdrop && } + * ``` + */ +export function useGraphicsTier(): GraphicsTierState { + const auto = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const mode = useAppearanceStore((s) => s.backgroundMode); + + // Before detection resolves, behave as if the mode were `auto`: the stored + // preference is read from localStorage during hydration, and honouring a + // `full` override on the server render would mount WebGL surfaces the device + // may be about to fail. One frame later the real tier arrives. + const effective = auto.resolved ? tierForMode(mode, auto.tier) : auto.tier; + + // Accessibility beats any manual override: if the OS asks for reduced + // motion, detection already returned `low` and we keep it there. + const tier = auto.reducedMotion ? "low" : effective; + + // Expose the effective tier to CSS so stylesheets can gate keyframes centrally + // (see globals.css) instead of every component threading a class down. Driven + // from here, not from detection, so a manual override moves the attribute too. + useEffect(() => { + if (!auto.resolved) return; + document.documentElement.setAttribute("data-gfx-tier", tier); + }, [tier, auto.resolved]); + + return { + ...auto, + tier, + mode, + // Until detection resolves, every consumer sees the same conservative + // feature set the server rendered — the stored mode must not change the + // hydration render, or React reconciles against markup it did not produce. + features: !auto.resolved + ? FEATURES_BY_TIER.low + : mode === "off" + ? FEATURES_OFF + : FEATURES_BY_TIER[tier], + }; +} + +export default useGraphicsTier; diff --git a/src/lib/graphics-tier.ts b/src/lib/graphics-tier.ts new file mode 100644 index 00000000..3f9b307e --- /dev/null +++ b/src/lib/graphics-tier.ts @@ -0,0 +1,273 @@ +/** + * Graphics capability detection. + * + * The app ships three decorative background surfaces with very different costs: + * a CSS aurora (cheap, composited), a WebGL fragment-shader marble field, and a + * three.js globe (both expensive, and brutal on integrated/software GPUs). They + * used to render for everyone, which is why the "just delete them" proposal + * (#390) existed. Instead of removing them, this module decides *per device* + * which of them may run. + * + * The classification is deliberately split from the browser reads so it can be + * unit-tested in a plain node environment: + * readHardwareSignals() — browser-only, gathers raw signals + * classifyGraphicsTier() — pure, scores those signals into a tier + * + * Nothing here is a hard guarantee: `useGraphicsTier` also measures real frame + * rate after mount and downgrades if the heuristics were too optimistic. + */ + +export type GraphicsTier = "high" | "medium" | "low"; + +/** Coarse buckets for a GPU's unmasked renderer string. */ +export type GpuClass = "strong" | "unknown" | "weak" | "software" | "none"; + +export interface HardwareSignals { + /** OS-level "minimize animation" request. Hard override to `low`. */ + reducedMotion: boolean; + /** Data Saver / metered connection. Hard override to `low`. */ + saveData: boolean; + /** navigator.hardwareConcurrency, when exposed. */ + cores?: number; + /** navigator.deviceMemory in GB — Chromium only, absent elsewhere. */ + memoryGb?: number; + gpu: GpuClass; + /** Physical pixels the compositor drives: screen area x dpr². */ + pixels: number; + /** Touch is the primary pointer (phones, tablets). */ + touchPrimary: boolean; + viewportWidth: number; +} + +export interface GraphicsProbe { + tier: GraphicsTier; + /** Signed score behind the tier; exposed for debugging and the settings UI. */ + score: number; + /** Human-readable signal list, shown under the "Auto" setting. */ + reasons: string[]; + signals: HardwareSignals; +} + +/** What each tier is allowed to render. */ +export interface GraphicsFeatures { + /** Render the aurora layer at all. */ + background: boolean; + /** Run the aurora's CSS keyframes (orbs, sheen, bloom). */ + auroraAnimated: boolean; + /** Pointer-reactive parallax on the orb layer. */ + auroraParallax: boolean; + /** WebGL fragment-shader surfaces (MarbleField). */ + webglBackdrop: boolean; + /** three.js globe backdrops. */ + webglGlobe: boolean; +} + +export const FEATURES_BY_TIER: Record = { + high: { + background: true, + auroraAnimated: true, + auroraParallax: true, + webglBackdrop: true, + webglGlobe: true, + }, + medium: { + background: true, + auroraAnimated: true, + auroraParallax: false, + webglBackdrop: false, + webglGlobe: false, + }, + // Static gradient only: it paints once and then costs nothing to composite, + // so there is no reason to strip the surface entirely. + low: { + background: true, + auroraAnimated: false, + auroraParallax: false, + webglBackdrop: false, + webglGlobe: false, + }, +}; + +/** Nothing renders — the user turned the background off. */ +export const FEATURES_OFF: GraphicsFeatures = { + background: false, + auroraAnimated: false, + auroraParallax: false, + webglBackdrop: false, + webglGlobe: false, +}; + +const TIER_ORDER: GraphicsTier[] = ["low", "medium", "high"]; + +/** The lower of two tiers — used to apply runtime caps over detection. */ +export function minTier(a: GraphicsTier, b: GraphicsTier): GraphicsTier { + return TIER_ORDER.indexOf(a) <= TIER_ORDER.indexOf(b) ? a : b; +} + +// Matched in order: a software rasterizer also contains vendor words, and +// "Intel" appears inside ANGLE strings that also name a discrete GPU. +const SOFTWARE_RE = + /swiftshader|llvmpipe|softpipe|software|basic render|mesa offscreen|virgl|paravirtual/i; +const STRONG_RE = + /apple m[0-9]|apple gpu|apple a1[2-9]|nvidia|geforce|quadro|rtx|gtx|radeon (rx|pro|vii)|arc a[0-9]|adreno \(tm\) (6[5-9][0-9]|7[0-9][0-9]|8[0-9][0-9])|mali-g[67][0-9]|xclipse/i; +const WEAK_RE = + /intel|iris|hd graphics|uhd graphics|gma|mali-(4|t)|powervr|videocore|adreno \(tm\) [2345][0-9][0-9]|vivante|llvm/i; + +/** + * Bucket a WebGL unmasked-renderer string. + * + * Integrated Intel parts land in `weak` on purpose: they run the CSS aurora + * fine but stutter badly on a full-viewport fragment shader plus a three.js + * scene, which is the exact complaint behind #390. + */ +export function classifyGpuRenderer(renderer: string | null | undefined): GpuClass { + if (!renderer) return "unknown"; + if (SOFTWARE_RE.test(renderer)) return "software"; + if (STRONG_RE.test(renderer)) return "strong"; + if (WEAK_RE.test(renderer)) return "weak"; + return "unknown"; +} + +/** + * Score the signals into a tier. + * + * Pure and synchronous so it can be tested directly. Weights are calibrated so + * that a modern laptop/phone with a real GPU reaches `high`/`medium`, while any + * two independent weak signals (few cores + integrated GPU, say) fall to `low`. + */ +export function classifyGraphicsTier(signals: HardwareSignals): GraphicsProbe { + const reasons: string[] = []; + + if (signals.reducedMotion) { + return { + tier: "low", + score: -99, + reasons: ["prefers-reduced-motion is on"], + signals, + }; + } + if (signals.saveData) { + return { tier: "low", score: -99, reasons: ["data saver is on"], signals }; + } + + let score = 0; + + if (typeof signals.cores === "number" && signals.cores > 0) { + if (signals.cores <= 2) { + score -= 3; + reasons.push(`${signals.cores} CPU cores`); + } else if (signals.cores <= 4) { + score -= 1; + reasons.push(`${signals.cores} CPU cores`); + } else if (signals.cores >= 8) { + score += 1; + reasons.push(`${signals.cores} CPU cores`); + } + } + + // Absent on Safari/Firefox — never penalize for the missing API itself. + if (typeof signals.memoryGb === "number" && signals.memoryGb > 0) { + if (signals.memoryGb <= 2) { + score -= 3; + reasons.push(`${signals.memoryGb}GB device memory`); + } else if (signals.memoryGb <= 4) { + score -= 1; + reasons.push(`${signals.memoryGb}GB device memory`); + } else if (signals.memoryGb >= 8) { + score += 1; + reasons.push(`${signals.memoryGb}GB device memory`); + } + } + + switch (signals.gpu) { + case "software": + score -= 4; + reasons.push("software rendering (no GPU)"); + break; + case "none": + score -= 2; + reasons.push("WebGL unavailable"); + break; + case "weak": + score -= 2; + reasons.push("integrated / low-power GPU"); + break; + case "strong": + score += 2; + reasons.push("discrete or modern GPU"); + break; + default: + break; + } + + // A very large framebuffer multiplies every full-screen shader pass. + if (signals.pixels >= 14_000_000) { + score -= 2; + reasons.push("very high-resolution display"); + } else if (signals.pixels >= 8_300_000) { + score -= 1; + reasons.push("high-resolution display"); + } + + if (signals.touchPrimary && signals.viewportWidth < 900) { + score -= 1; + reasons.push("handheld device"); + } + + let tier: GraphicsTier = score >= 2 ? "high" : score >= -2 ? "medium" : "low"; + + // `high` implies WebGL surfaces; never promise those without a usable GPU, + // whatever the CPU/memory signals say. + if (signals.gpu === "none" || signals.gpu === "software") { + tier = minTier(tier, "medium"); + } + + return { tier, score, reasons, signals }; +} + +/** Read the GPU's unmasked renderer string, disposing the probe context. */ +function readGpuClass(): GpuClass { + try { + const canvas = document.createElement("canvas"); + const gl = (canvas.getContext("webgl", { failIfMajorPerformanceCaveat: false }) ?? + canvas.getContext("experimental-webgl")) as WebGLRenderingContext | null; + if (!gl) return "none"; + + const ext = gl.getExtension("WEBGL_debug_renderer_info"); + const renderer = ext + ? (gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) as string) + : (gl.getParameter(gl.RENDERER) as string); + + // Free the context immediately; browsers cap how many may live at once. + gl.getExtension("WEBGL_lose_context")?.loseContext(); + return classifyGpuRenderer(renderer); + } catch { + return "none"; + } +} + +/** Gather raw signals from the current browser. Client-only. */ +export function readHardwareSignals(): HardwareSignals { + const nav = navigator as Navigator & { + deviceMemory?: number; + connection?: { saveData?: boolean }; + }; + const dpr = Math.min(window.devicePixelRatio || 1, 3); + + return { + reducedMotion: + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false, + saveData: nav.connection?.saveData === true, + cores: typeof nav.hardwareConcurrency === "number" ? nav.hardwareConcurrency : undefined, + memoryGb: typeof nav.deviceMemory === "number" ? nav.deviceMemory : undefined, + gpu: readGpuClass(), + pixels: (window.screen?.width ?? 1280) * (window.screen?.height ?? 800) * dpr * dpr, + touchPrimary: window.matchMedia?.("(pointer: coarse)").matches ?? false, + viewportWidth: window.innerWidth || 1280, + }; +} + +/** Full detection pass. Client-only; callers must guard SSR. */ +export function detectGraphicsTier(): GraphicsProbe { + return classifyGraphicsTier(readHardwareSignals()); +} diff --git a/src/lib/zustand/appearance.ts b/src/lib/zustand/appearance.ts index 85128bfc..955d622b 100644 --- a/src/lib/zustand/appearance.ts +++ b/src/lib/zustand/appearance.ts @@ -2,10 +2,39 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import type { BackgroundPreset } from "@/components/ui/background"; +/** + * How much of the animated background the user wants. + * + * `auto` (the default) hands the decision to hardware detection — see + * `@/lib/graphics-tier` — so weak devices get a static gradient and capable + * ones get the full aurora + WebGL surfaces without anybody touching a switch. + * The other three are explicit overrides for people who disagree with it. + */ +export type BackgroundMode = "auto" | "full" | "reduced" | "off"; + +export const BACKGROUND_MODES = [ + { + id: "auto", + label: "Auto", + description: "Match your device's graphics capability", + }, + { + id: "full", + label: "Full", + description: "Aurora plus WebGL surfaces", + }, + { + id: "reduced", + label: "Reduced", + description: "Aurora only, no WebGL", + }, + { id: "off", label: "Off", description: "No background effects" }, +] as const satisfies readonly { id: BackgroundMode; label: string; description: string }[]; + interface AppearanceState { - /** Master toggle for the animated app background. */ - backgroundEnabled: boolean; - setBackgroundEnabled: (enabled: boolean) => void; + /** How much background effect to render. */ + backgroundMode: BackgroundMode; + setBackgroundMode: (mode: BackgroundMode) => void; /** Which colour theme the background uses. */ backgroundPreset: BackgroundPreset; setBackgroundPreset: (preset: BackgroundPreset) => void; @@ -19,14 +48,29 @@ interface AppearanceState { export const useAppearanceStore = create()( persist( (set) => ({ - backgroundEnabled: true, - setBackgroundEnabled: (backgroundEnabled) => set({ backgroundEnabled }), + backgroundMode: "auto", + setBackgroundMode: (backgroundMode) => set({ backgroundMode }), backgroundPreset: "aurora", setBackgroundPreset: (backgroundPreset) => set({ backgroundPreset }), }), { name: "appearance-settings", storage: createJSONStorage(() => localStorage), + version: 2, + // v1 stored a plain on/off switch. "On" becomes `auto` rather than + // `full`: the whole point of this migration is that the old blanket + // "on" was too heavy for some of the devices that had it. + migrate: (persisted, version) => { + if (version >= 2) return persisted as AppearanceState; + const legacy = persisted as Partial<{ + backgroundEnabled: boolean; + backgroundPreset: BackgroundPreset; + }> | null; + return { + backgroundMode: legacy?.backgroundEnabled === false ? "off" : "auto", + backgroundPreset: legacy?.backgroundPreset ?? "aurora", + } as AppearanceState; + }, }, ), ); diff --git a/src/pages/api-docs.tsx b/src/pages/api-docs.tsx index 899df548..e7a9cf60 100644 --- a/src/pages/api-docs.tsx +++ b/src/pages/api-docs.tsx @@ -3,7 +3,13 @@ import dynamic from "next/dynamic"; import React, { useEffect, useState, useRef } from "react"; import useMeshWallet from "@/hooks/useMeshWallet"; import { Key, Lightbulb, Copy, Check } from "lucide-react"; -import Globe from "./globe"; +import { useGraphicsTier } from "@/hooks/useGraphicsTier"; + +// Dynamic, not static: this pulls three.js + three-globe (~hundreds of KB) and +// most visitors never render it — the hardware tier decides. Keeping the static +// import shipped that payload to every device including the ones we then refuse +// to draw a globe on. +const Globe = dynamic(() => import("./globe"), { ssr: false, loading: () => null }); // Avoid SSR for Swagger UI // Note: swagger-ui CSS is imported globally from src/pages/_app.tsx because @@ -17,6 +23,9 @@ export default function ApiDocs() { // wallet has the args swapped, which broke bearer-token generation on // wallets like VESPR (CIP-30 InternalError -2). const { wallet, connected } = useMeshWallet(); + // The globe is a three.js scene; only devices detected (and measured) as + // capable get it. Everything else reads the docs over the plain background. + const { features: gfx } = useGraphicsTier(); const [isGeneratingToken, setIsGeneratingToken] = useState(false); const [generatedToken, setGeneratedToken] = useState(null); const [copied, setCopied] = useState(false); @@ -300,17 +309,19 @@ export default function ApiDocs() { return (
-
- -
+ {gfx.webglGlobe && ( +
+ +
+ )}
diff --git a/src/pages/user/index.tsx b/src/pages/user/index.tsx index 4c75009e..adaa68f0 100644 --- a/src/pages/user/index.tsx +++ b/src/pages/user/index.tsx @@ -7,9 +7,10 @@ import CardUI from "@/components/ui/card-content"; import RowLabelInfo from "@/components/ui/row-label-info"; import { Button } from "@/components/ui/button"; import { Copy, User as UserIcon, Wallet, Shield, Key, MessageCircle, CheckCircle2, XCircle, Loader2, Clock, Palette } from "lucide-react"; -import { Switch } from "@/components/ui/switch"; import { Background, BACKGROUND_PRESETS } from "@/components/ui/background"; -import { useAppearanceStore } from "@/lib/zustand/appearance"; +import { useAppearanceStore, BACKGROUND_MODES } from "@/lib/zustand/appearance"; +import { useGraphicsTier } from "@/hooks/useGraphicsTier"; +import type { GraphicsTier } from "@/lib/graphics-tier"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import Loading from "@/components/common/overall-layout/loading"; @@ -23,6 +24,13 @@ import SharedProxiesCard from "@/components/pages/user/SharedProxiesCard"; export const getServerSideProps = () => ({ props: {} }); +/** How each detected tier is described in the Appearance card. */ +const TIER_LABELS: Record = { + high: "full effects", + medium: "aurora only", + low: "static background", +}; + export default function UserInfoPage() { const router = useRouter(); const { user, isLoading } = useUser(); @@ -36,11 +44,13 @@ export default function UserInfoPage() { address: userAddress ?? "", }); - // Appearance preferences (persisted per-device). - const backgroundEnabled = useAppearanceStore((s) => s.backgroundEnabled); - const setBackgroundEnabled = useAppearanceStore((s) => s.setBackgroundEnabled); + // Appearance preferences (persisted per-device) plus the live hardware read + // that "Auto" resolves to, so the setting can show its own reasoning. + const backgroundMode = useAppearanceStore((s) => s.backgroundMode); + const setBackgroundMode = useAppearanceStore((s) => s.setBackgroundMode); const backgroundPreset = useAppearanceStore((s) => s.backgroundPreset); const setBackgroundPreset = useAppearanceStore((s) => s.setBackgroundPreset); + const gfx = useGraphicsTier(); const handleCopy = async (text: string, label: string) => { try { @@ -277,19 +287,61 @@ export default function UserInfoPage() { icon={Palette} >
-
-
-

Animated background

+
+

Background effects

+

+ Auto matches the effects to what your device can render — it + checks CPU, memory and GPU, then measures actual frame rate and + steps down if the page is not keeping up. +

+
+ {BACKGROUND_MODES.map((mode) => { + const selected = backgroundMode === mode.id; + return ( + + ); + })} +
+ {gfx.resolved && (

- Show a subtle animated aurora behind the app. Honors your - reduced-motion setting. + {gfx.reducedMotion ? ( + <> + Your system asks for reduced motion, so animation stays off + whichever option you pick. + + ) : ( + <> + This device: {TIER_LABELS[gfx.detected]} + {gfx.reasons.length > 0 && ` (${gfx.reasons.join(", ")})`} + {gfx.tier !== gfx.detected && backgroundMode === "auto" && ( + <> — stepped down to {TIER_LABELS[gfx.tier]} after measuring performance + )} + . + + )}

-
- + )}
@@ -301,7 +353,7 @@ export default function UserInfoPage() {
- {!backgroundEnabled && ( + {backgroundMode === "off" && (

- Enable the animated background to choose a style. + Turn the background on to choose a style.

)}
diff --git a/src/styles/globals.css b/src/styles/globals.css index a0590ecc..3ae1a91e 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -174,6 +174,26 @@ html[data-bg-hidden] [class*="animate-aurora"] { animation-play-state: paused !important; } +/* Hardware capability tier, set on by useGraphicsTier after it reads the + device's CPU/memory/GPU signals and measures real frame rate. Components gate + their own React-rendered effects on the same tier; this exists so decorative + keyframes defined purely in CSS degrade with it too, without every component + having to thread a class down. + + At `low` the device is not keeping frame budget (or asked for reduced + motion), so every looping decoration stops — mirroring the reduced-motion + block further down. `medium` keeps them: the aurora and the small SVG icon + loops composite cheaply; it is the WebGL surfaces that medium gives up, and + those are gated in React. */ +html[data-gfx-tier="low"] [class*="animate-aurora"], +html[data-gfx-tier="low"] .feat-dash, +html[data-gfx-tier="low"] .feat-draw, +html[data-gfx-tier="low"] .feat-pulse, +html[data-gfx-tier="low"] .feat-float, +html[data-gfx-tier="low"] .feat-spin { + animation: none !important; +} + /* Feature-card icon animations */ @keyframes feat-dash { to {