From 98cb302f8f184af693bc26be5681cff75f0d24b4 Mon Sep 17 00:00:00 2001 From: QSchlegel Date: Fri, 11 Sep 2026 10:05:33 +0200 Subject: [PATCH] feat: auto-switch background effects on device graphics capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three decorative surfaces (CSS aurora, WebGL marble field, three.js globe) rendered unconditionally for everyone, which is what motivated removing them outright. Instead, detect what the device can afford and render that much. - src/lib/graphics-tier.ts: pure classification of CPU cores, device memory, unmasked GPU renderer, framebuffer size, handheld-ness, reduced-motion and data-saver into high/medium/low, plus the feature set each tier may render. Split from the browser reads so it is unit testable; `high` is never returned without a usable GPU. - src/hooks/useGraphicsTier.ts: shared runtime state — one detection pass, a post-load frame-rate probe that steps the tier down when the page is not keeping budget (cached per session), a battery cap below 20% discharging, and a live reduced-motion listener. Publishes data-gfx-tier on so CSS keyframes degrade with it. - Aurora keeps its layers at every tier but stops animating at low; the marble field and globe only mount at high. api-docs now loads the globe dynamically instead of shipping three.js to every visitor, and drops its full-page backdrop-filter when there is nothing behind it. - Appearance setting becomes Auto / Full / Reduced / Off (persist v2 migration from the old on/off switch) and shows what was detected and why, including an automatic step-down. Co-Authored-By: Claude Opus 5 --- src/__tests__/graphicsTier.test.ts | 145 +++++++++ .../common/overall-layout/layout.tsx | 23 +- src/components/pages/homepage/index.tsx | 37 ++- .../shared/GlassMorphismPageWrapper.tsx | 51 ++-- src/components/ui/background.tsx | 44 ++- src/hooks/useGraphicsTier.ts | 280 ++++++++++++++++++ src/lib/graphics-tier.ts | 273 +++++++++++++++++ src/lib/zustand/appearance.ts | 54 +++- src/pages/api-docs.tsx | 42 ++- src/pages/user/index.tsx | 90 ++++-- src/styles/globals.css | 20 ++ 11 files changed, 970 insertions(+), 89 deletions(-) create mode 100644 src/__tests__/graphicsTier.test.ts create mode 100644 src/hooks/useGraphicsTier.ts create mode 100644 src/lib/graphics-tier.ts 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 {