Skip to content
Merged
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
96 changes: 42 additions & 54 deletions src/features/auth/components/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -106,25 +108,26 @@ 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
left: string
top: string
}) => (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0.1, 0.3, 0.1],
scale: [1, 1.2, 1],
y: [0, -20, 0],
}}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 0.28, scale: 1 }}
transition={{
duration: 6,
duration: 1.2,
delay,
repeat: Infinity,
ease: 'easeInOut',
ease: 'easeOut',
}}
style={{
position: 'absolute',
Expand All @@ -133,8 +136,8 @@ const FloatingShape = ({ delay, size, left, top }: {
width: size,
height: size,
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.1)',
backdropFilter: 'blur(10px)',
background:
'radial-gradient(circle, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0.06) 60%, rgba(255,255,255,0) 100%)',
}}
/>
)
Expand All @@ -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<ITokenService>(TYPES.TokenService)
const httpClient = useService<IHttpClient>(TYPES.HttpClient)
Expand Down Expand Up @@ -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(),
}}
>
<Card
Expand All @@ -620,7 +624,7 @@ export default function LoginPage() {
mx: 2,
borderRadius: '24px',
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
backdropFilter: glassCardBackdropFilter(prefersReducedMotion),
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
color: '#1a1a2e',
'& .MuiTypography-root': { color: '#1a1a2e' },
Expand Down Expand Up @@ -651,14 +655,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(),
}}
>
<Card
Expand All @@ -668,7 +665,7 @@ export default function LoginPage() {
mx: 2,
borderRadius: '24px',
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
backdropFilter: glassCardBackdropFilter(prefersReducedMotion),
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
color: '#1a1a2e',
'& .MuiTypography-root': { color: '#1a1a2e' },
Expand Down Expand Up @@ -701,14 +698,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(),
}}
>
<Card
Expand All @@ -718,7 +708,7 @@ export default function LoginPage() {
mx: 2,
borderRadius: '24px',
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
backdropFilter: glassCardBackdropFilter(prefersReducedMotion),
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
color: '#1a1a2e',
'& .MuiTypography-root': { color: '#1a1a2e' },
Expand Down Expand Up @@ -864,22 +854,20 @@ export default function LoginPage() {
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
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 */}
<FloatingShape delay={0} size={300} left="10%" top="20%" />
<FloatingShape delay={1} size={200} left="70%" top="10%" />
<FloatingShape delay={2} size={150} left="80%" top="60%" />
<FloatingShape delay={0.5} size={100} left="5%" top="70%" />
<FloatingShape delay={1.5} size={250} left="50%" top="80%" />
{/* Animated background shapes — skipped entirely for users who prefer
reduced motion (their infinite rAF animations are decorative only). */}
{!prefersReducedMotion && (
<>
<FloatingShape delay={0} size={300} left="10%" top="20%" />
<FloatingShape delay={1} size={200} left="70%" top="10%" />
<FloatingShape delay={2} size={150} left="80%" top="60%" />
<FloatingShape delay={0.5} size={100} left="5%" top="70%" />
<FloatingShape delay={1.5} size={250} left="50%" top="80%" />
</>
)}

<motion.div
initial="hidden"
Expand All @@ -890,7 +878,7 @@ export default function LoginPage() {
<Card
sx={{
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
backdropFilter: glassCardBackdropFilter(prefersReducedMotion),
borderRadius: '24px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid rgba(255, 255, 255, 0.3)',
Expand Down
37 changes: 19 additions & 18 deletions src/features/auth/components/TwoFactorDispatcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { AuthMethodType, MfaStepStatus, EASE_OUT } from '../constants'
import MfaStepRenderer from '../login-shared/MfaStepRenderer'
import { makeRequestWebAuthnChallenge } from '../login-shared/webauthnChallenge'
import type { PuzzleConfig } from '@domain/models/AuthMethod'
import { usePrefersReducedMotion } from '@hooks/usePrefersReducedMotion'
import { loginShellBackgroundSx, glassCardBackdropFilter } from './loginBackground'

interface TwoFactorDispatcherProps {
method: string
Expand Down Expand Up @@ -61,6 +63,19 @@ export default function TwoFactorDispatcher({
const authRepository = useService<IAuthRepository>(TYPES.AuthRepository)
const httpClient = useService<IHttpClient>(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<string | undefined>(undefined)
Expand Down Expand Up @@ -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 },
}}
>
Expand All @@ -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' },
Expand Down Expand Up @@ -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 },
}}
>
Expand All @@ -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' },
Expand Down
50 changes: 50 additions & 0 deletions src/features/auth/components/loginBackground.ts
Original file line number Diff line number Diff line change
@@ -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<Theme> {
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)'
}
48 changes: 48 additions & 0 deletions src/hooks/usePrefersReducedMotion.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(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
}
Loading