Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/__tests__/graphicsTier.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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");
});
});
23 changes: 13 additions & 10 deletions src/components/common/overall-layout/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -602,14 +602,17 @@ export default function RootLayout({

return (
<div className="flex h-[100dvh] w-screen flex-col overflow-hidden">
{/* 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 && (
<div className="pointer-events-none fixed inset-0 -z-10">
<Background
variant="aurora"
preset={backgroundPreset}
animated={gfx.auroraAnimated}
parallax={gfx.auroraParallax}
className="opacity-50"
/>
</div>
Expand Down
37 changes: 23 additions & 14 deletions src/components/pages/homepage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -169,15 +170,15 @@ export function PageHomepage() {
const auroraRef = useRef<HTMLDivElement>(null);
const meshRef = useRef<HTMLDivElement>(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;
Expand Down Expand Up @@ -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. */}
<div ref={auroraRef} className="fixed inset-0 -z-10" style={{ opacity: 0.35 }}>
<Background variant="aurora" preset={heroPreset} />
<Background
variant="aurora"
preset={heroPreset}
animated={gfx.auroraAnimated}
parallax={gfx.auroraParallax}
/>
</div>

{/* 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. */}
<div ref={meshRef} className="fixed inset-0 -z-10" style={{ opacity: 0.9 }}>
<MarbleField />
<div className="absolute inset-0 bg-white/30 dark:bg-zinc-900/30" />
</div>
rendered low-res, so it already reads soft. Full-viewport fragment
shader — high tier only. */}
{gfx.webglBackdrop && (
<div ref={meshRef} className="fixed inset-0 -z-10" style={{ opacity: 0.9 }}>
<MarbleField />
<div className="absolute inset-0 bg-white/30 dark:bg-zinc-900/30" />
</div>
)}
</>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'), {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -53,33 +58,35 @@ export default function GlassMorphismPageWrapper({

return (
<>
{/* Globe background - centered and always visible */}
<div className="globe-background" style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '100vw',
height: '100vh',
zIndex: -100,
background: isDarkMode ? '#121212' : '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: '100vmin',
height: '100vmin',
maxWidth: '750px',
maxHeight: '750px',
opacity: 0.75,
{/* Globe background - centered, gated on the device's graphics tier */}
{gfx.webglGlobe && (
<div className="globe-background" style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '100vw',
height: '100vh',
zIndex: -100,
background: isDarkMode ? '#121212' : '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<Globe />
<div style={{
width: '100vmin',
height: '100vmin',
maxWidth: '750px',
maxHeight: '750px',
opacity: 0.75,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<Globe />
</div>
</div>
</div>
)}

{/* Page content */}
<div style={{ position: 'relative' }} className={className}>
Expand Down
Loading
Loading