diff --git a/src/app/dashboard/_components/invoice-status-chart.tsx b/src/app/dashboard/_components/invoice-status-chart.tsx index e720384..fbf5db3 100644 --- a/src/app/dashboard/_components/invoice-status-chart.tsx +++ b/src/app/dashboard/_components/invoice-status-chart.tsx @@ -1,6 +1,7 @@ "use client"; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; +import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import type { StoredInvoiceStatus } from "~/types/invoice"; @@ -51,6 +52,12 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { paid: "hsl(142, 76%, 36%)", // green overdue: "hsl(0, 84%, 60%)", // red }; + // Animation / motion preferences + const { prefersReducedMotion, animationSpeedMultiplier } = + useAnimationPreferences(); + const pieAnimationDuration = Math.round( + 600 / (animationSpeedMultiplier || 1), + ); const formatCurrency = (value: number) => { return new Intl.NumberFormat("en-US", { @@ -113,6 +120,9 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) { outerRadius={80} stroke="none" dataKey="count" + isAnimationActive={!prefersReducedMotion} + animationDuration={pieAnimationDuration} + animationEasing="ease-out" > {chartData.map((entry, index) => ( diff --git a/src/app/dashboard/_components/revenue-chart.tsx b/src/app/dashboard/_components/revenue-chart.tsx index 79b24f9..c256d61 100644 --- a/src/app/dashboard/_components/revenue-chart.tsx +++ b/src/app/dashboard/_components/revenue-chart.tsx @@ -10,6 +10,7 @@ import { } from "recharts"; import { getEffectiveInvoiceStatus } from "~/lib/invoice-status"; import type { StoredInvoiceStatus } from "~/types/invoice"; +import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; interface Invoice { id: string; @@ -99,6 +100,8 @@ export function RevenueChart({ invoices }: RevenueChartProps) { return null; }; + const { prefersReducedMotion, animationSpeedMultiplier } = + useAnimationPreferences(); if (chartData.length === 0) { return (
@@ -147,6 +150,11 @@ export function RevenueChart({ invoices }: RevenueChartProps) { stroke="hsl(0, 0%, 60%)" strokeWidth={2} fill="url(#revenueGradient)" + isAnimationActive={!prefersReducedMotion} + animationDuration={Math.round( + 600 / (animationSpeedMultiplier ?? 1), + )} + animationEasing="ease-out" /> diff --git a/src/app/dashboard/settings/_components/settings-content.tsx b/src/app/dashboard/settings/_components/settings-content.tsx index a8eaf8a..bdaaa14 100644 --- a/src/app/dashboard/settings/_components/settings-content.tsx +++ b/src/app/dashboard/settings/_components/settings-content.tsx @@ -59,6 +59,9 @@ import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; import { Textarea } from "~/components/ui/textarea"; import { api } from "~/trpc/react"; +import { Switch } from "~/components/ui/switch"; +import { Slider } from "~/components/ui/slider"; +import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider"; export function SettingsContent() { const { data: session } = useSession(); @@ -76,6 +79,25 @@ export function SettingsContent() { const [showNewPassword, setShowNewPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); + // Animation preferences via provider (centralized) + const { + prefersReducedMotion, + animationSpeedMultiplier, + updatePreferences, + isUpdating: animationPrefsUpdating, + setPrefersReducedMotion, + setAnimationSpeedMultiplier, + } = useAnimationPreferences(); + + const handleSaveAnimationPreferences = (e: React.FormEvent) => { + e.preventDefault(); + updatePreferences({ + prefersReducedMotion, + animationSpeedMultiplier, + }); + toast.success("Animation preferences updated"); + }; + // Queries const { data: profile, refetch: refetchProfile } = api.settings.getProfile.useQuery(); @@ -271,6 +293,8 @@ export function SettingsContent() { } }, [profile?.name, name]); + // (Removed direct DOM mutation; provider handles applying preferences globally) + const dataStatItems = [ { label: "Clients", @@ -498,6 +522,99 @@ export function SettingsContent() { + {/* Accessibility & Animation */} + + + + + Accessibility & Animation + + + +
+
+
+ +

+ Turn this on to reduce or remove non-essential animations and + transitions. +

+
+ + setPrefersReducedMotion(Boolean(checked)) + } + aria-label="Toggle reduced motion" + /> +
+ +
+
+ + + {prefersReducedMotion + ? "1.00x (locked)" + : `${animationSpeedMultiplier.toFixed(2)}x`} + +
+

+ Adjust global animation duration scaling. Lower values (0.25×, + 0.5×, 0.75×) slow animations; higher values (2×, 3×, 4×) speed + them up. +

+
+ {/* Slider (desktop / larger screens) */} +
+ (t === 1 ? "1x" : `${t}x`)} + onValueChange={(v: number[]) => + setAnimationSpeedMultiplier(v[0] ?? 1) + } + aria-label="Animation speed multiplier" + className="mt-1" + disabled={prefersReducedMotion} + /> +
+ {/* Dropdown fallback (small screens) */} +
+ +
+
+
+ + +
+
+
+ {/* Data Management */} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b08bd4e..5ef964c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import { Geist_Mono } from "next/font/google"; import { TRPCReactProvider } from "~/trpc/react"; import { Toaster } from "~/components/ui/sonner"; +import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider"; export const metadata: Metadata = { title: "beenvoice - Invoicing Made Simple", @@ -24,10 +25,22 @@ export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - + + + {/* Inline early animation preference script to avoid FOUC */} +