diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 0e908897..6949f85a 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -15,6 +15,7 @@ import { Pin, ArrowUp, ArrowDown, + Accessibility, } from "lucide-react" import { getPinnedActions, savePinnedActions, ALL_AVAILABLE_ACTIONS } from "@/lib/command-palette/pins" import { DEFAULT_QUIET_HOURS, useQuietHours, type QuietHoursSettings } from "@/lib/quiet-hours" @@ -39,6 +40,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" import { useDensity, densityTokens, type Density, type DensityTokens } from "@/hooks/useDensity" import { useSoundEnabled } from "@/hooks/useSoundEnabled" import { usePrivacy } from '@/context/PrivacyContext'; +import { useAccessibility } from "@/context/AccessibilityContext"; import { cn } from "@/lib/utils" type TimeFormat = "local-12h" | "local-24h" | "utc" @@ -114,11 +116,18 @@ export default function SettingsPage() { // Density from global hook const { density, setDensity, tokens: densityTokensCurrent } = useDensity() + + // Accessibility preferences — persisted globally via AccessibilityContext + const { + reduceMotion, setReduceMotion, + disableParallax, setDisableParallax, + disableAutoplay, setDisableAutoplay, + increaseContrast, setIncreaseContrast, + } = useAccessibility() const [timeFormat, setTimeFormat] = useState("local-24h") const [currencyDisplay, setCurrencyDisplay] = useState("both") const [notificationPreset, setNotificationPreset] = useState("important") - const [reduceMotion, setReduceMotion] = useState(false) const [showNetPayouts, setShowNetPayouts] = useState(true) const [showWalletBadge, setShowWalletBadge] = useState(true) const [disputeAlerts, setDisputeAlerts] = useState(true) @@ -132,22 +141,6 @@ export default function SettingsPage() { const [walletAlias, setWalletAlias] = useState(true) const [copyWarning, setCopyWarning] = useState(true) const { soundEnabled, setSoundEnabled } = useSoundEnabled() - const [forceHighContrast, setHighContrast] = useState(() => { - if (typeof window !== "undefined") { - return localStorage.getItem("force-high-contrast") === "true" - } - return false - }) - - useEffect(() => { - const root = window.document.documentElement - if (forceHighContrast) { - root.classList.add("high-contrast") - } else { - root.classList.remove("high-contrast") - } - localStorage.setItem("force-high-contrast", forceHighContrast.toString()) - }, [forceHighContrast]) useEffect(() => { if (!quietHoursDirty) { @@ -200,6 +193,7 @@ export default function SettingsPage() { Preferences Notifications Privacy + Accessibility {/* Account tab links to the dedicated Settings → Account page */} @@ -363,13 +357,6 @@ export default function SettingsPage() { checked={showWalletBadge} onCheckedChange={setShowWalletBadge} /> - @@ -793,6 +780,88 @@ export default function SettingsPage() { + + {/* ── Accessibility tab ────────────────────────────────────────────── + Four per-user toggles persisted in localStorage via + AccessibilityContext. All four override the OS-level preference + when explicitly set by the user. */} + +
+ + +
+ + Accessibility controls +
+ + Per-user preferences stored in your browser. They override OS-level settings when + explicitly toggled and reset to OS defaults when cleared. + The Reduce motion toggle + defaults to your OS prefers-reduced-motion value + when no stored preference exists. + +
+ + + + + + + + + +
+ + {/* Live status card */} + + + Active overrides + Live readout of your current accessibility state. + + + + + + +
+

+ Stored locally in your browser under{" "} + predictify-a11y. + Clearing site data resets everything to OS defaults. + See docs/ACCESSIBILITY.md for + the full token mapping. +

+
+
+
+
+
) diff --git a/app/globals.css b/app/globals.css index 98734053..f3d8f0ea 100644 --- a/app/globals.css +++ b/app/globals.css @@ -281,6 +281,66 @@ body { } } +/* ── Per-user accessibility overrides ──────────────────────────────────────── + * Applied via data-* attributes written to by AccessibilityContext. + * These override OS-level media queries so user preferences always take effect. + * ─────────────────────────────────────────────────────────────────────────── */ + +/** + * Reduce motion: kill ALL decorative animations when the user opts in. + * Applies regardless of OS prefers-reduced-motion state. + */ +[data-reduce-motion="true"] *, +[data-reduce-motion="true"] *::before, +[data-reduce-motion="true"] *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; +} + +/** + * Disable parallax: zero out transforms on elements carrying data-parallax. + * Apply data-parallax to any parallax container to opt it in automatically. + */ +[data-disable-parallax="true"] [data-parallax] { + transform: none !important; + will-change: auto !important; +} + +/** + * Increase contrast — CSS custom-property token swap. + * + * Light-mode targets (WCAG AAA 7:1 where feasible) + * --foreground 0 0% 3.9% → 0 0% 2% (text on white bg) + * --muted-foreground 0 0% 45.1% → 0 0% 18% (secondary text, 7:1 on white) + * --border 0 0% 89.8% → 0 0% 28% (visible UI chrome) + * --primary 0 0% 9% → 0 0% 4% (interactive affordances) + * + * Dark-mode targets + * --foreground 0 0% 98% → 0 0% 100% + * --muted-foreground 0 0% 63.9% → 0 0% 88% (7:1 on dark bg ≈ #141414) + * --border 0 0% 14.9% → 0 0% 72% (visible on dark) + */ +[data-increase-contrast="true"] { + --foreground: 0 0% 2%; + --muted-foreground: 0 0% 18%; + --border: 0 0% 28%; + --primary: 0 0% 4%; + --card-foreground: 0 0% 2%; + --popover-foreground: 0 0% 2%; +} + +.dark[data-increase-contrast="true"], +.dark [data-increase-contrast="true"] { + --foreground: 0 0% 100%; + --muted-foreground: 0 0% 88%; + --border: 0 0% 72%; + --primary: 0 0% 98%; + --card-foreground: 0 0% 100%; + --popover-foreground: 0 0% 100%; +} + /* ===== HIGH CONTRAST MODE (Accessibility) ===== */ /** diff --git a/components/providers.tsx b/components/providers.tsx index 2148e6ad..4b01abcd 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -3,6 +3,7 @@ import { ThemeProvider } from "@/components/theme-provider"; import { WalletProvider } from "@/context/WalletContext"; import { PrivacyProvider } from "@/context/PrivacyContext"; +import { AccessibilityProvider } from "@/context/AccessibilityContext"; import { Toaster } from "@/components/ui/sonner"; import { ErrorBoundary } from "@/components/error-boundary"; import { ReactNode } from "react"; @@ -31,11 +32,13 @@ export function Providers({ children }: ProvidersProps) { disableTransitionOnChange > - - - {children} - - + + + + {children} + + + diff --git a/components/ui/animated-background.tsx b/components/ui/animated-background.tsx index a7172c44..6e07bf90 100644 --- a/components/ui/animated-background.tsx +++ b/components/ui/animated-background.tsx @@ -1,9 +1,29 @@ +"use client"; + +import { useAccessibility } from "@/context/AccessibilityContext"; + +/** + * AnimatedBackground + * + * Decorative pulsing blobs rendered behind marketing/hero sections. + * + * Motion behaviour + * ───────────────── + * When the user has enabled "Reduce motion" in Settings → Accessibility + * (or the OS prefers-reduced-motion media query fires), the animate-pulse + * Tailwind class is removed and the blobs render as static colour washes. + * The CSS [data-reduce-motion="true"] rule in globals.css provides the + * same guarantee for any residual CSS animations we might have missed. + */ export function AnimatedBackground() { + const { reduceMotion } = useAccessibility(); + const pulse = reduceMotion ? "" : "animate-pulse"; + return ( -
-
-
-
+