diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css index 68e001e..446948d 100644 --- a/frontend/src/css/main.css +++ b/frontend/src/css/main.css @@ -16,9 +16,27 @@ :root { --crossfade-duration: 700ms; /* Smooth easing: slow start, gentle acceleration, soft landing */ + /* Chrome/Firefox: Material Design standard easing */ --crossfade-easing: cubic-bezier(0.4, 0, 0.2, 1); } +/* Safari-specific easing - Safari's bezier curve rendering is different, + use a simpler curve that Safari handles more smoothly */ +@supports (-webkit-touch-callout: none) { + :root { + /* Safari: use ease-in-out which Safari renders more consistently */ + --crossfade-easing: cubic-bezier(0.42, 0, 0.58, 1); + } +} + +/* Firefox-specific optimizations */ +@-moz-document url-prefix() { + :root { + /* Firefox handles the standard easing well, but we can optimize */ + --crossfade-easing: cubic-bezier(0.4, 0, 0.2, 1); + } +} + .introjs-tooltip { @apply min-w-[400px] max-w-[480px] p-2 !important; @@ -90,32 +108,52 @@ } } -/* Crossfade animation classes - GPU accelerated for Safari */ +/* Crossfade animation classes - GPU accelerated for all browsers */ /* Duration controlled by --crossfade-duration CSS variable (single source of truth) */ .animate-crossfade-in { - /* Explicit initial state prevents Safari flash during animation setup */ + /* Explicit initial state prevents flash during animation setup */ opacity: 0; -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); - -webkit-animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards; - animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards; + /* Full animation property for maximum browser compatibility */ + -webkit-animation-name: page-crossfade-in; + animation-name: page-crossfade-in; + -webkit-animation-duration: var(--crossfade-duration, 700ms); + animation-duration: var(--crossfade-duration, 700ms); + -webkit-animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)); + animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)); + -webkit-animation-fill-mode: forwards; + animation-fill-mode: forwards; + -webkit-animation-play-state: running; + animation-play-state: running; -webkit-backface-visibility: hidden; backface-visibility: hidden; - /* Only animate opacity - transform is for GPU layer promotion */ + /* Optimize compositing */ will-change: opacity; + contain: layout style paint; } .animate-crossfade-out { - /* Explicit initial state prevents Safari flash during animation setup */ + /* Explicit initial state prevents flash during animation setup */ opacity: 1; -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); - -webkit-animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards; - animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards; + /* Full animation property for maximum browser compatibility */ + -webkit-animation-name: page-crossfade-out; + animation-name: page-crossfade-out; + -webkit-animation-duration: var(--crossfade-duration, 700ms); + animation-duration: var(--crossfade-duration, 700ms); + -webkit-animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)); + animation-timing-function: var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)); + -webkit-animation-fill-mode: forwards; + animation-fill-mode: forwards; + -webkit-animation-play-state: running; + animation-play-state: running; -webkit-backface-visibility: hidden; backface-visibility: hidden; - /* Only animate opacity - transform is for GPU layer promotion */ + /* Optimize compositing */ will-change: opacity; + contain: layout style paint; } /* Safari-specific GPU compositing optimization */ @@ -126,8 +164,22 @@ -webkit-transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0); /* Prevent Safari from optimizing away the GPU layer */ - -webkit-perspective: 1000; - perspective: 1000; + -webkit-perspective: 1000px; + perspective: 1000px; + /* Safari performs better with explicit transform-style */ + -webkit-transform-style: preserve-3d; + transform-style: preserve-3d; + /* Isolate the layer for better compositing */ + isolation: isolate; + } +} + +/* Firefox-specific optimizations */ +@-moz-document url-prefix() { + .animate-crossfade-in, + .animate-crossfade-out { + /* Firefox handles animations well but benefits from layer isolation */ + will-change: opacity, transform; } } diff --git a/frontend/src/hooks/useBackgroundTransition.ts b/frontend/src/hooks/useBackgroundTransition.ts index 09c0905..6887e97 100644 --- a/frontend/src/hooks/useBackgroundTransition.ts +++ b/frontend/src/hooks/useBackgroundTransition.ts @@ -14,6 +14,11 @@ * Two modes: * 1. fadeIn mode: Crossfade animation for direct (non-video) page navigation * 2. fadeOut mode: Legacy support - kept for backwards compatibility but not recommended + * + * Cross-browser notes: + * - Chrome: Uses CSS animations with onAnimationEnd event + * - Safari: Uses JS timer fallback (Safari's onAnimationEnd can be unreliable) + * - Firefox: Uses CSS animations with onAnimationEnd event */ import { @@ -74,7 +79,7 @@ export interface UseBackgroundTransitionResult { /** Whether page content is currently fading (crossfade in progress) */ isFadingIn: boolean; /** Handler to call when fade-in animation ends (pass to onAnimationEnd) */ - onFadeInAnimationEnd: () => void; + onFadeInAnimationEnd: (e?: React.AnimationEvent) => void; /** Reset fade-in state (for cleanup or cancellation) */ resetFadeIn: () => void; } @@ -121,6 +126,11 @@ export function useBackgroundTransition({ // Track previous isSwitching state to detect transition start const wasSwitchingRef = useRef(false); + // Safari fallback timer ref - Safari's onAnimationEnd can be unreliable + const fadeInTimerRef = useRef | null>(null); + // Track if animation was already completed (by event or timer) + const fadeInCompletedRef = useRef(false); + /** * Reset fade-out state before starting a new transition. * This prevents the fade-out effect from re-triggering when state resets. @@ -134,15 +144,44 @@ export function useBackgroundTransition({ */ const resetFadeIn = useCallback(() => { setIsFadingIn(false); + fadeInCompletedRef.current = false; + if (fadeInTimerRef.current) { + clearTimeout(fadeInTimerRef.current); + fadeInTimerRef.current = null; + } + }, []); + + /** + * Complete fade-in animation. + * Called either by onAnimationEnd or by timer fallback. + * Uses ref to prevent double-completion. + */ + const completeFadeIn = useCallback(() => { + if (fadeInCompletedRef.current) return; + fadeInCompletedRef.current = true; + + // Clear backup timer if it exists + if (fadeInTimerRef.current) { + clearTimeout(fadeInTimerRef.current); + fadeInTimerRef.current = null; + } + + setIsFadingIn(false); }, []); /** * Handler for onAnimationEnd event. - * Called when CSS animation completes - CSS is the source of truth for duration. + * Called when CSS animation completes. + * Uses completeFadeIn to handle deduplication with timer fallback. */ - const onFadeInAnimationEnd = useCallback(() => { - setIsFadingIn(false); - }, []); + const onFadeInAnimationEnd = useCallback( + (e?: React.AnimationEvent) => { + // Only handle the crossfade animation, not child animations + if (e && e.animationName !== 'page-crossfade-in') return; + completeFadeIn(); + }, + [completeFadeIn], + ); /** * Effect: Fade out and remove transition overlay when background is ready. @@ -240,6 +279,10 @@ export function useBackgroundTransition({ * preventing any flash of new content at full opacity. * * IMPORTANT: Skip this for transitions - transition video IS the effect. + * + * Cross-browser handling: + * - Sets up JS timer fallback for Safari (unreliable onAnimationEnd) + * - Chrome/Firefox rely on CSS onAnimationEnd event */ useLayoutEffect(() => { if (!fadeIn) { @@ -256,9 +299,41 @@ export function useBackgroundTransition({ // Only start crossfade for NON-transition navigation // Transitions use video overlay - no fade needed if (justStartedSwitching && !hasActiveTransition) { + // Reset completion flag for new animation + fadeInCompletedRef.current = false; + + // Clear any existing timer + if (fadeInTimerRef.current) { + clearTimeout(fadeInTimerRef.current); + fadeInTimerRef.current = null; + } + setIsFadingIn(true); + + // Safari/Firefox fallback: Use JS timer as backup since onAnimationEnd + // can be unreliable or fire on wrong animations. + // Timer is slightly longer than CSS duration to let CSS complete first. + // Chrome typically fires onAnimationEnd reliably, but timer is harmless backup. + const duration = getCrossfadeDuration(); + // Add 50ms buffer for Safari's animation timing variance + const bufferMs = isSafari() ? 100 : 50; + + fadeInTimerRef.current = setTimeout(() => { + fadeInTimerRef.current = null; + completeFadeIn(); + }, duration + bufferMs); } - }, [pageSwitch.isSwitching, fadeIn]); + }, [pageSwitch.isSwitching, fadeIn, completeFadeIn]); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (fadeInTimerRef.current) { + clearTimeout(fadeInTimerRef.current); + fadeInTimerRef.current = null; + } + }; + }, []); return { isOverlayFadingOut,