diff --git a/src/features/auth/components/LoginPage.tsx b/src/features/auth/components/LoginPage.tsx
index dbdb183..7cba457 100644
--- a/src/features/auth/components/LoginPage.tsx
+++ b/src/features/auth/components/LoginPage.tsx
@@ -53,6 +53,8 @@ import { config as envConfig } from '@config/env'
import { fetchLoginConfig } from '../login-config'
import { hasPasswordLayer1, selectPuzzleConfig, type LoginConfig } from '@domain/models/LoginConfig'
import StepProgress from '../../../verify-app/StepProgress'
+import { usePrefersReducedMotion } from '@hooks/usePrefersReducedMotion'
+import { loginShellBackgroundSx, glassCardBackdropFilter } from './loginBackground'
/**
* Login form validation schema
@@ -106,7 +108,13 @@ const logoVariants: Variants = {
},
}
-// Floating shapes for background
+// Decorative background orb. ONE-SHOT fade/scale-in only (no `repeat: Infinity`)
+// and a soft radial fill instead of an animated `backdrop-filter: blur` — the
+// continuous orb animation + per-frame backdrop re-blur was a main-thread compositing
+// cost (worst with hardware-acceleration off) that contributed to the login jank.
+// After the entrance it is fully static, so it adds zero per-frame work — the orbs
+// stay as brand decoration without the cost. See loginBackground.ts for the matching
+// static-gradient fix.
const FloatingShape = ({ delay, size, left, top }: {
delay: number
size: number
@@ -114,17 +122,12 @@ const FloatingShape = ({ delay, size, left, top }: {
top: string
}) => (
)
@@ -146,6 +149,14 @@ const FloatingShape = ({ delay, size, left, top }: {
export default function LoginPage() {
const { t } = useTranslation()
const navigate = useNavigate()
+ // The shell gradient is now STATIC for everyone (see loginBackground.ts) and the
+ // orbs are one-shot — so the page has no continuous background animation, the fix
+ // for the login jank (and parity with the smooth verify.fivucsas shell). On top of
+ // that we still honour `prefers-reduced-motion`: those users get no decorative orbs
+ // and a flat (un-blurred) card. The FACE/PUZZLE capture step renders through
+ // TwoFactorDispatcher (an early return below), which additionally drops the glass
+ // blur while the camera is live.
+ const prefersReducedMotion = usePrefersReducedMotion()
const { login, loading, error, user, logout, refreshUser } = useAuth()
const tokenService = useService(TYPES.TokenService)
const httpClient = useService(TYPES.HttpClient)
@@ -603,14 +614,7 @@ export default function LoginPage() {
justifyContent: 'center',
overflowY: 'auto',
py: 4,
- background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f64f59 100%)',
- backgroundSize: '400% 400%',
- animation: 'gradientShift 15s ease infinite',
- '@keyframes gradientShift': {
- '0%': { backgroundPosition: '0% 50%' },
- '50%': { backgroundPosition: '100% 50%' },
- '100%': { backgroundPosition: '0% 50%' },
- },
+ ...loginShellBackgroundSx(),
}}
>
- {/* Animated background shapes */}
-
-
-
-
-
+ {/* Animated background shapes — skipped entirely for users who prefer
+ reduced motion (their infinite rAF animations are decorative only). */}
+ {!prefersReducedMotion && (
+ <>
+
+
+
+
+
+ >
+ )}
(TYPES.AuthRepository)
const httpClient = useService(TYPES.HttpClient)
const { t } = useTranslation()
+ const prefersReducedMotion = usePrefersReducedMotion()
+
+ // The shell gradient is now STATIC (see loginBackground.ts), so the general
+ // background no longer composites every frame. One per-frame cost remains during
+ // FACE/PUZZLE: those run a live camera + MediaPipe requestAnimationFrame loop, and
+ // the glass card's `backdrop-filter: blur(20px)` re-blurs over changing content
+ // each frame (worst with browser hardware-acceleration off), stealing budget from
+ // the capture loop. So drop the glass blur while a camera step is active (and for
+ // `prefers-reduced-motion`); the gradient/colours are unchanged, so the screens
+ // look identical apart from the (off-camera) glass blur.
+ const isCameraStep =
+ method === AuthMethodType.FACE || method === AuthMethodType.PUZZLE
+ const quietBackground = prefersReducedMotion || isCameraStep
const [loading, setLoading] = useState(false)
const [error, setError] = useState(undefined)
@@ -136,14 +151,7 @@ export default function TwoFactorDispatcher({
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
- background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f64f59 100%)',
- backgroundSize: '400% 400%',
- animation: 'gradientShift 15s ease infinite',
- '@keyframes gradientShift': {
- '0%': { backgroundPosition: '0% 50%' },
- '50%': { backgroundPosition: '100% 50%' },
- '100%': { backgroundPosition: '0% 50%' },
- },
+ ...loginShellBackgroundSx(),
p: { xs: 2, sm: 3 },
}}
>
@@ -157,7 +165,7 @@ export default function TwoFactorDispatcher({
sx={{
borderRadius: '24px',
background: 'rgba(255, 255, 255, 0.95)',
- backdropFilter: 'blur(20px)',
+ backdropFilter: glassCardBackdropFilter(quietBackground),
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
color: '#1a1a2e',
'& .MuiTypography-root': { color: '#1a1a2e' },
@@ -212,14 +220,7 @@ export default function TwoFactorDispatcher({
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
- background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f64f59 100%)',
- backgroundSize: '400% 400%',
- animation: 'gradientShift 15s ease infinite',
- '@keyframes gradientShift': {
- '0%': { backgroundPosition: '0% 50%' },
- '50%': { backgroundPosition: '100% 50%' },
- '100%': { backgroundPosition: '0% 50%' },
- },
+ ...loginShellBackgroundSx(),
p: { xs: 2, sm: 3 },
}}
>
@@ -233,7 +234,7 @@ export default function TwoFactorDispatcher({
sx={{
borderRadius: '24px',
background: 'rgba(255, 255, 255, 0.95)',
- backdropFilter: 'blur(20px)',
+ backdropFilter: glassCardBackdropFilter(quietBackground),
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
color: '#1a1a2e',
'& .MuiTypography-root': { color: '#1a1a2e' },
diff --git a/src/features/auth/components/loginBackground.ts b/src/features/auth/components/loginBackground.ts
new file mode 100644
index 0000000..ee77a72
--- /dev/null
+++ b/src/features/auth/components/loginBackground.ts
@@ -0,0 +1,50 @@
+/**
+ * loginBackground — shared sx builders for the dashboard (app.fivucsas) auth
+ * surfaces' full-screen glass shell.
+ *
+ * WHY THIS EXISTS (perf): the dashboard login + MFA shells used to paint the
+ * brand gradient with a `gradientShift` keyframe animation — a `400% 400%`
+ * background-size whose `background-position` was animated indefinitely. Animating
+ * background-position cannot be GPU-composited; it forces a full-viewport REPAINT
+ * every frame, on EVERY login screen (identifier / password / MFA), and with
+ * browser hardware-acceleration OFF that paint lands on the main thread. Layered
+ * with the floating-orb `backdrop-filter` animations and the glass card blur, that
+ * was the entire reason app.fivucsas login felt laggy while verify.fivucsas — whose
+ * shell uses a STATIC radial gradient with no continuous animation — stayed smooth.
+ * (It was never WebGPU; both surfaces share the same WASM/MediaPipe face pipeline.)
+ *
+ * Fix: the dashboard shell now renders a STATIC brand gradient for everyone — same
+ * colours/stops, no infinite pan — so there is no per-frame background repaint. This
+ * also brings app.fivucsas to visual+behavioural parity with verify.fivucsas. The
+ * change is purely presentational and reversible (restore the `gradientShift`
+ * animation here to get the old behaviour back).
+ */
+import type { SxProps, Theme } from '@mui/material'
+
+/** The brand gradient stops, shared by every dashboard auth shell. */
+const BRAND_GRADIENT = 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f64f59 100%)'
+
+/**
+ * Full-screen background sx for the dashboard auth shell: a STATIC brand gradient.
+ *
+ * Intentionally has no `gradientShift`/`backgroundSize` animation — the continuous
+ * pan was the dominant cause of the login-page jank (full-viewport repaint per
+ * frame, worst with hardware-acceleration off). verify.fivucsas is smooth for the
+ * same reason: a static gradient.
+ */
+export function loginShellBackgroundSx(): SxProps {
+ return { background: BRAND_GRADIENT }
+}
+
+/**
+ * `backdrop-filter` value for the glass card.
+ *
+ * Over the now-STATIC shell gradient the 20px glass blur is composited once and is
+ * cheap, so off-camera screens keep the original glassmorphism. It is dropped while
+ * `quiet` (the live FACE/PUZZLE camera step, or `prefers-reduced-motion`): there the
+ * card can sit over changing content and dropping the blur frees main-thread budget
+ * for the MediaPipe capture loop.
+ */
+export function glassCardBackdropFilter(quiet: boolean): string | undefined {
+ return quiet ? undefined : 'blur(20px)'
+}
diff --git a/src/hooks/usePrefersReducedMotion.ts b/src/hooks/usePrefersReducedMotion.ts
new file mode 100644
index 0000000..13cdf8d
--- /dev/null
+++ b/src/hooks/usePrefersReducedMotion.ts
@@ -0,0 +1,48 @@
+/**
+ * usePrefersReducedMotion — reflects the OS/browser
+ * `(prefers-reduced-motion: reduce)` user setting, live.
+ *
+ * Returns `true` when the user has asked the system to minimize non-essential
+ * motion. Components use it to skip decorative, continuously-animating chrome
+ * (infinite gradient pans, floating shapes) — both as an accessibility courtesy
+ * and, on the login surfaces, as a performance lever: those animations otherwise
+ * contend with the camera/MediaPipe rAF loop during the FACE/PUZZLE capture step
+ * (worst with browser hardware-acceleration off, where the animated gradient +
+ * backdrop-blur composite on the main thread).
+ *
+ * SSR-safe (defaults to `false` when `window`/`matchMedia` is unavailable) and
+ * updates if the preference changes at runtime.
+ */
+import { useEffect, useState } from 'react'
+
+const QUERY = '(prefers-reduced-motion: reduce)'
+
+function readPreference(): boolean {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return false
+ }
+ return window.matchMedia(QUERY).matches
+}
+
+export function usePrefersReducedMotion(): boolean {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(readPreference)
+
+ useEffect(() => {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return
+ }
+ const mql = window.matchMedia(QUERY)
+ const onChange = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches)
+ // Sync once on mount in case the value changed before the listener attached.
+ setPrefersReducedMotion(mql.matches)
+ // addEventListener is the modern API; older Safari only has addListener.
+ if (typeof mql.addEventListener === 'function') {
+ mql.addEventListener('change', onChange)
+ return () => mql.removeEventListener('change', onChange)
+ }
+ mql.addListener(onChange)
+ return () => mql.removeListener(onChange)
+ }, [])
+
+ return prefersReducedMotion
+}