"use client"; /** * AnimationPreferencesProvider * * Centralized manager for user animation / motion preferences: * - prefersReducedMotion (boolean) * - animationSpeedMultiplier (0.25x – 4x) * * Responsibilities: * 1. Hydrate from (priority): * - Inline early localStorage value (if already written by an inline script in layout) * - Existing localStorage value * - Server value (tRPC - user profile) * - Initial props (e.g. server-fetched) * - System media query (prefers-reduced-motion) * 2. Apply preferences to: * - documentElement class list (adds / removes .user-reduce-motion) * - CSS custom properties: --animation-speed-fast/normal/slow * 3. Persist to localStorage * 4. Sync to server via tRPC mutation (debounced & resilient) * * Usage: * * * * * const { * prefersReducedMotion, * animationSpeedMultiplier, * updatePreferences, * isUpdating * } = useAnimationPreferences(); * * updatePreferences({ animationSpeedMultiplier: 1.5 }); * * NOTE: After integrating this provider, remove the duplicated logic * from the settings page (SettingsContent) and call updatePreferences() * instead of directly manipulating DOM / CSS variables there. */ import React, { createContext, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { api } from "~/trpc/react"; import { authClient } from "~/lib/auth-client"; type AnimationPreferences = { prefersReducedMotion: boolean; animationSpeedMultiplier: number; }; type PartialPrefs = Partial; interface AnimationPreferencesContextValue extends AnimationPreferences { updatePreferences: (patch: PartialPrefs, opts?: { sync?: boolean }) => void; setPrefersReducedMotion: (val: boolean) => void; setAnimationSpeedMultiplier: (val: number) => void; isUpdating: boolean; lastSyncedAt: number | null; } interface AnimationPreferencesProviderProps { children: React.ReactNode; /** * Optional initial values (e.g. from server / tRPC prefetch). */ initial?: PartialPrefs; /** * Disable auto-sync to server (mostly for test environments). */ autoSync?: boolean; } const STORAGE_KEY = "bv.animation.prefs"; const MIN_SPEED = 0.25; const MAX_SPEED = 4; const DEFAULT_SPEED = 1; const DEFAULT_PREFERS_REDUCED = false; const AnimationPreferencesContext = createContext(null); function clampSpeed(value: number): number { if (Number.isNaN(value)) return DEFAULT_SPEED; return Math.min(MAX_SPEED, Math.max(MIN_SPEED, value)); } function readLocalStorage(): PartialPrefs | null { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as PartialPrefs; if ( typeof parsed === "object" && parsed !== null && ("prefersReducedMotion" in parsed || "animationSpeedMultiplier" in parsed) ) { return { prefersReducedMotion: typeof parsed.prefersReducedMotion === "boolean" ? parsed.prefersReducedMotion : undefined, animationSpeedMultiplier: typeof parsed.animationSpeedMultiplier === "number" ? clampSpeed(parsed.animationSpeedMultiplier) : undefined, }; } return null; } catch { return null; } } function writeLocalStorage(prefs: AnimationPreferences) { try { localStorage.setItem( STORAGE_KEY, JSON.stringify({ prefersReducedMotion: prefs.prefersReducedMotion, animationSpeedMultiplier: prefs.animationSpeedMultiplier, }), ); } catch { // Fail silently; storage may be unavailable } } function applyPreferencesToDOM(prefs: AnimationPreferences) { if (typeof document === "undefined") return; const root = document.documentElement; // Class toggle if (prefs.prefersReducedMotion) { root.classList.add("user-reduce-motion"); } else { root.classList.remove("user-reduce-motion"); } // Derive effective speeds const multiplier = prefs.animationSpeedMultiplier || 1; const fast = prefs.prefersReducedMotion ? 0.01 : parseFloat((0.15 / multiplier).toFixed(4)); const normal = prefs.prefersReducedMotion ? 0.01 : parseFloat((0.3 / multiplier).toFixed(4)); const slow = prefs.prefersReducedMotion ? 0.01 : parseFloat((0.5 / multiplier).toFixed(4)); root.style.setProperty("--animation-speed-fast", `${fast}s`); root.style.setProperty("--animation-speed-normal", `${normal}s`); root.style.setProperty("--animation-speed-slow", `${slow}s`); } /** * Provider component */ export function AnimationPreferencesProvider({ children, initial, autoSync = true, }: AnimationPreferencesProviderProps) { const updateMutation = api.settings.updateAnimationPreferences.useMutation(); const { data: session } = authClient.useSession(); const isAuthed = !!session?.user; // Server query only when authenticated const { data: serverPrefs } = api.settings.getAnimationPreferences.useQuery( undefined, { enabled: isAuthed, refetchOnWindowFocus: false, staleTime: 60_000, }, ); const [prefersReducedMotion, setPrefersReducedMotion] = useState( initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED, ); const [animationSpeedMultiplier, setAnimationSpeedMultiplier] = useState( clampSpeed(initial?.animationSpeedMultiplier ?? DEFAULT_SPEED), ); const [lastSyncedAt, setLastSyncedAt] = useState(null); const pendingSyncRef = useRef(null); const isHydratedRef = useRef(false); const serverHydratedRef = useRef(false); const [isUpdating, setIsUpdating] = useState(false); // Hydration: run once on mount (local + system + initial) useEffect(() => { if (typeof window === "undefined") return; const stored = readLocalStorage(); const systemReduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; const finalPrefers = stored?.prefersReducedMotion ?? initial?.prefersReducedMotion ?? systemReduced ?? DEFAULT_PREFERS_REDUCED; const finalSpeed = clampSpeed( stored?.animationSpeedMultiplier ?? initial?.animationSpeedMultiplier ?? DEFAULT_SPEED, ); setPrefersReducedMotion(finalPrefers); setAnimationSpeedMultiplier(finalSpeed); applyPreferencesToDOM({ prefersReducedMotion: finalPrefers, animationSpeedMultiplier: finalSpeed, }); isHydratedRef.current = true; }, [initial?.prefersReducedMotion, initial?.animationSpeedMultiplier]); /** * Core updater */ const performUpdate = useCallback( (patch: PartialPrefs, opts?: { sync?: boolean }) => { setIsUpdating(true); setPrefersReducedMotion((prev) => patch.prefersReducedMotion ?? prev); setAnimationSpeedMultiplier((prev) => clampSpeed(patch.animationSpeedMultiplier ?? prev), ); // Normalize patch (avoid mutating the original function argument directly) const normalizedPatch: PartialPrefs = { ...patch }; // If user enables reduced motion, force the animation speed multiplier to 1x (unless already specified) if ( normalizedPatch.prefersReducedMotion === true && normalizedPatch.animationSpeedMultiplier === undefined && animationSpeedMultiplier !== 1 ) { normalizedPatch.animationSpeedMultiplier = 1; } const nextReduced = normalizedPatch.prefersReducedMotion ?? prefersReducedMotion; let nextSpeed = clampSpeed( normalizedPatch.animationSpeedMultiplier ?? animationSpeedMultiplier, ); // Enforce 1x when reduced motion is active if (nextReduced && nextSpeed !== 1) { nextSpeed = 1; normalizedPatch.animationSpeedMultiplier ??= 1; } const newPrefs: AnimationPreferences = { prefersReducedMotion: nextReduced, animationSpeedMultiplier: nextSpeed, }; // Apply to DOM immediately applyPreferencesToDOM(newPrefs); // Persist locally writeLocalStorage(newPrefs); // Optionally sync to server const shouldSync = opts?.sync ?? autoSync; if (shouldSync && isAuthed) { pendingSyncRef.current = { prefersReducedMotion: patch.prefersReducedMotion, animationSpeedMultiplier: patch.animationSpeedMultiplier, }; updateMutation.mutate( { ...(normalizedPatch.prefersReducedMotion !== undefined && { prefersReducedMotion: normalizedPatch.prefersReducedMotion, }), ...(normalizedPatch.animationSpeedMultiplier !== undefined && { animationSpeedMultiplier: clampSpeed( normalizedPatch.animationSpeedMultiplier, ), }), }, { onSuccess: () => { setLastSyncedAt(Date.now()); pendingSyncRef.current = null; setIsUpdating(false); }, onError: () => { setIsUpdating(false); }, }, ); } else { setIsUpdating(false); } }, [ prefersReducedMotion, animationSpeedMultiplier, autoSync, updateMutation, isAuthed, ], ); // Secondary hydration: apply server values if they differ AND user hasn't customized locally yet. useEffect(() => { if (!isHydratedRef.current) return; if (serverHydratedRef.current) return; if (!isAuthed || !serverPrefs) return; const localIsDefault = prefersReducedMotion === DEFAULT_PREFERS_REDUCED && animationSpeedMultiplier === DEFAULT_SPEED; const differs = serverPrefs.prefersReducedMotion !== prefersReducedMotion || serverPrefs.animationSpeedMultiplier !== animationSpeedMultiplier; if (localIsDefault || differs) { performUpdate( { prefersReducedMotion: serverPrefs.prefersReducedMotion, animationSpeedMultiplier: serverPrefs.animationSpeedMultiplier, }, { sync: false }, // Do not echo immediately back to server ); } serverHydratedRef.current = true; }, [ serverPrefs, performUpdate, prefersReducedMotion, animationSpeedMultiplier, isAuthed, ]); const updatePreferences = useCallback< AnimationPreferencesContextValue["updatePreferences"] >( (patch, opts) => { performUpdate(patch, opts); }, [performUpdate], ); // Dedicated setters (they sync by default) const handleSetReduced = useCallback( (val: boolean) => { updatePreferences({ prefersReducedMotion: val }); }, [updatePreferences], ); const handleSetSpeed = useCallback( (val: number) => { updatePreferences({ animationSpeedMultiplier: clampSpeed(val) }); }, [updatePreferences], ); const value: AnimationPreferencesContextValue = { prefersReducedMotion, animationSpeedMultiplier, updatePreferences, setPrefersReducedMotion: handleSetReduced, setAnimationSpeedMultiplier: handleSetSpeed, isUpdating: isUpdating || updateMutation.isPending, lastSyncedAt, }; return ( {children} ); } /** * Hook consumer */ export function useAnimationPreferences(): AnimationPreferencesContextValue { const ctx = useContext(AnimationPreferencesContext); if (!ctx) { throw new Error( "useAnimationPreferences must be used within an AnimationPreferencesProvider", ); } return ctx; } /** * Helper: Inline script snippet (for layout) to minimize FOUC. * (Not executed here—copy the string contents into a