From 5f784ee90f12550b6b4fedb0bd5b57bba5fac292 Mon Sep 17 00:00:00 2001 From: Ahmet Abdullah Gultekin Date: Fri, 12 Jun 2026 10:03:14 +0000 Subject: [PATCH] =?UTF-8?q?perf(login):=20static=20shell=20gradient=20?= =?UTF-8?q?=E2=80=94=20fix=20general=20dashboard=20login=20jank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app.fivucsas dashboard login felt laggy across EVERY login screen (identifier / password / MFA / face), while verify.fivucsas stayed smooth — even with browser hardware-acceleration off on both. Root cause was the dashboard auth shell's decorative motion, not the face pipeline and not WebGPU (both surfaces share the same WASM/MediaPipe path): - `gradientShift`: the brand gradient was painted at `background-size: 400% 400%` with `background-position` animated infinitely. Animating background-position cannot be GPU-composited; it forces a full-viewport repaint every frame, on the main thread when hardware-acceleration is off. This ran on all login screens — the dominant cost. - 5x `FloatingShape` orbs each ran an infinite framer-motion animation with a per-frame `backdrop-filter: blur(10px)` re-composite. - the glass card `backdrop-filter: blur(20px)` re-blurred the moving gradient every frame. verify.fivucsas (HostedLoginApp) uses a STATIC radial gradient with no continuous animation — which is exactly why it is smooth. Fix (matches verify; same colours/layout; reversible): - shell gradient is now STATIC for everyone (no `gradientShift` pan) via the shared `loginShellBackgroundSx()`. - `FloatingShape` orbs become a ONE-SHOT fade/scale-in with a soft radial fill (no infinite repeat, no animated backdrop-blur) — decoration with zero per-frame cost after mount. - glass card blur kept on the (now static) off-camera screens; dropped during the live FACE/PUZZLE camera step and under prefers-reduced-motion. - honour prefers-reduced-motion (no orbs, flat card) as an extra layer. After this the dashboard login has no continuous compositing, so it renders as smoothly as verify.fivucsas. tsc + eslint + 380 auth tests + vite build green. --- src/features/auth/components/LoginPage.tsx | 96 ++++++++----------- .../auth/components/TwoFactorDispatcher.tsx | 37 +++---- .../auth/components/loginBackground.ts | 50 ++++++++++ src/hooks/usePrefersReducedMotion.ts | 48 ++++++++++ 4 files changed, 159 insertions(+), 72 deletions(-) create mode 100644 src/features/auth/components/loginBackground.ts create mode 100644 src/hooks/usePrefersReducedMotion.ts 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 +}