Skip to content
Open
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
117 changes: 93 additions & 24 deletions app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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<TimeFormat>("local-24h")
const [currencyDisplay, setCurrencyDisplay] = useState<CurrencyDisplay>("both")
const [notificationPreset, setNotificationPreset] = useState<NotificationIntensity>("important")
const [reduceMotion, setReduceMotion] = useState(false)
const [showNetPayouts, setShowNetPayouts] = useState(true)
const [showWalletBadge, setShowWalletBadge] = useState(true)
const [disputeAlerts, setDisputeAlerts] = useState(true)
Expand All @@ -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) {
Expand Down Expand Up @@ -200,6 +193,7 @@ export default function SettingsPage() {
<TabsTrigger value="preferences">Preferences</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
<TabsTrigger value="privacy">Privacy</TabsTrigger>
<TabsTrigger value="accessibility">Accessibility</TabsTrigger>
{/* Account tab links to the dedicated Settings → Account page */}
<TabsTrigger value="account" asChild>
<Link href="/settings/account" className="flex items-center gap-1">
Expand Down Expand Up @@ -363,13 +357,6 @@ export default function SettingsPage() {
checked={showWalletBadge}
onCheckedChange={setShowWalletBadge}
/>
<PreferenceSwitch
id="force-high-contrast"
label="Force high contrast"
description="Overrides the current theme with a high-contrast palette for maximum readability. Recommended for visual impairments."
checked={forceHighContrast}
onCheckedChange={setHighContrast}
/>
</div>
</CardContent>
</Card>
Expand Down Expand Up @@ -793,6 +780,88 @@ export default function SettingsPage() {
</CardContent>
</Card>
</TabsContent>

{/* ── Accessibility tab ──────────────────────────────────────────────
Four per-user toggles persisted in localStorage via
AccessibilityContext. All four override the OS-level preference
when explicitly set by the user. */}
<TabsContent value="accessibility">
<div className="grid gap-6 lg:grid-cols-[1.5fr_0.85fr]">
<Card className="border-border/70">
<CardHeader className="space-y-2">
<div className="flex items-center gap-2">
<Accessibility className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-xl">Accessibility controls</CardTitle>
</div>
<CardDescription>
Per-user preferences stored in your browser. They override OS-level settings when
explicitly toggled and reset to OS defaults when cleared.
The <code className="text-xs bg-muted rounded px-1 py-0.5">Reduce motion</code> toggle
defaults to your OS <code className="text-xs bg-muted rounded px-1 py-0.5">prefers-reduced-motion</code> value
when no stored preference exists.
</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
<PreferenceSwitch
id="a11y-reduce-motion"
label="Reduce motion"
description="Stops all decorative animations app-wide. Overrides OS reduced-motion. Affects animated backgrounds, entrance transitions, live-pulse indicators, and carousel motion."
checked={reduceMotion}
onCheckedChange={setReduceMotion}
/>
<PreferenceSwitch
id="a11y-disable-parallax"
label="Disable parallax effects"
description="Removes scroll-linked depth and translate effects from hero and banner sections. Useful for users who find parallax disorienting independently of general animation."
checked={disableParallax}
onCheckedChange={setDisableParallax}
/>
<PreferenceSwitch
id="a11y-disable-autoplay"
label="Disable auto-playing carousels"
description="Prevents carousels from advancing automatically. Slides only move when you interact with Previous / Next. Manual navigation is never affected."
checked={disableAutoplay}
onCheckedChange={setDisableAutoplay}
/>
<PreferenceSwitch
id="a11y-increase-contrast"
label="Increase contrast"
description="Swaps foreground, muted text, and border tokens for higher-contrast values targeting WCAG AAA (7:1) where feasible. Works in both light and dark mode."
checked={increaseContrast}
onCheckedChange={setIncreaseContrast}
/>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full sm:w-auto">
Save settings
</Button>
</CardFooter>
</Card>

{/* Live status card */}
<Card className="border-border/70 h-fit">
<CardHeader className="space-y-2">
<CardTitle className="text-lg">Active overrides</CardTitle>
<CardDescription>Live readout of your current accessibility state.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<PreferencePill label="Reduce motion" value={reduceMotion ? "On" : "Off (OS default)"} />
<PreferencePill label="Parallax" value={disableParallax ? "Disabled" : "Enabled"} />
<PreferencePill label="Carousels" value={disableAutoplay ? "Manual only" : "Auto-advance"} />
<PreferencePill label="Contrast" value={increaseContrast ? "High contrast" : "Default"} />
<div className="rounded-2xl border border-dashed border-border/80 bg-muted/40 p-4 mt-2">
<p className="text-xs text-muted-foreground leading-relaxed">
Stored locally in your browser under{" "}
<code className="bg-background rounded px-1">predictify-a11y</code>.
Clearing site data resets everything to OS defaults.
See <code className="bg-background rounded px-1">docs/ACCESSIBILITY.md</code> for
the full token mapping.
</p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</form>
)
Expand Down
60 changes: 60 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,66 @@ body {
}
}

/* ── Per-user accessibility overrides ────────────────────────────────────────
* Applied via data-* attributes written to <html> 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) ===== */

/**
Expand Down
13 changes: 8 additions & 5 deletions components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -31,11 +32,13 @@ export function Providers({ children }: ProvidersProps) {
disableTransitionOnChange
>
<PrivacyProvider>
<WalletProvider>
<ClaimShareProvider>
{children}
</ClaimShareProvider>
</WalletProvider>
<AccessibilityProvider>
<WalletProvider>
<ClaimShareProvider>
{children}
</ClaimShareProvider>
</WalletProvider>
</AccessibilityProvider>
</PrivacyProvider>
<Toaster />
</ThemeProvider>
Expand Down
28 changes: 24 additions & 4 deletions components/ui/animated-background.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="absolute inset-0">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-emerald-500/10 rounded-full blur-3xl animate-pulse delay-1000"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-purple-500/5 rounded-full blur-3xl animate-pulse delay-500"></div>
<div className="absolute inset-0" aria-hidden="true">
<div className={`absolute top-0 left-1/4 w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl ${pulse}`} />
<div className={`absolute bottom-0 right-1/4 w-96 h-96 bg-emerald-500/10 rounded-full blur-3xl ${pulse} delay-1000`} />
<div className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-purple-500/5 rounded-full blur-3xl ${pulse} delay-500`} />
</div>
);
}
13 changes: 12 additions & 1 deletion components/ui/carousel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ArrowLeft, ArrowRight } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { useAccessibility } from "@/context/AccessibilityContext"

type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
Expand Down Expand Up @@ -58,12 +59,22 @@ const Carousel = React.forwardRef<
},
ref
) => {
// Strip Embla autoplay/autoscroll plugins when the user has disabled autoplay.
// Matched case-insensitively so any variant (AutoPlay, autoScroll, etc.) is caught.
const { disableAutoplay } = useAccessibility()
const activePlugins = React.useMemo(() => {
if (!disableAutoplay || !plugins) return plugins
return (plugins as Array<{ name?: string }>).filter(
(p) => !/auto(?:play|scroll)/i.test(p?.name ?? "")
) as CarouselPlugin
}, [plugins, disableAutoplay])

const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
activePlugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
Expand Down
Loading