mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-15 10:34:43 -05:00
Add user-controlled animation preferences and reduce motion support
- Persist prefersReducedMotion and animationSpeedMultiplier in user profile - Provide UI controls to toggle reduce motion and adjust animation speed globally - Centralize animation preferences via provider and useAnimationPreferences hook - Apply preferences to charts’ animations (duration, enabled/disabled) - Inline script in layout to apply preferences early and avoid FOUC - Update CSS to respect user preference with reduced motion overrides and variable animation speeds
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
|
||||||
@@ -51,6 +52,12 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
|||||||
paid: "hsl(142, 76%, 36%)", // green
|
paid: "hsl(142, 76%, 36%)", // green
|
||||||
overdue: "hsl(0, 84%, 60%)", // red
|
overdue: "hsl(0, 84%, 60%)", // red
|
||||||
};
|
};
|
||||||
|
// Animation / motion preferences
|
||||||
|
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||||
|
useAnimationPreferences();
|
||||||
|
const pieAnimationDuration = Math.round(
|
||||||
|
600 / (animationSpeedMultiplier || 1),
|
||||||
|
);
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("en-US", {
|
||||||
@@ -113,6 +120,9 @@ export function InvoiceStatusChart({ invoices }: InvoiceStatusChartProps) {
|
|||||||
outerRadius={80}
|
outerRadius={80}
|
||||||
stroke="none"
|
stroke="none"
|
||||||
dataKey="count"
|
dataKey="count"
|
||||||
|
isAnimationActive={!prefersReducedMotion}
|
||||||
|
animationDuration={pieAnimationDuration}
|
||||||
|
animationEasing="ease-out"
|
||||||
>
|
>
|
||||||
{chartData.map((entry, index) => (
|
{chartData.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -87,6 +88,13 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Animation / motion preferences
|
||||||
|
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||||
|
useAnimationPreferences();
|
||||||
|
const barAnimationDuration = Math.round(
|
||||||
|
500 / (animationSpeedMultiplier || 1),
|
||||||
|
);
|
||||||
|
|
||||||
const CustomTooltip = ({
|
const CustomTooltip = ({
|
||||||
active,
|
active,
|
||||||
payload,
|
payload,
|
||||||
@@ -167,24 +175,36 @@ export function MonthlyMetricsChart({ invoices }: MonthlyMetricsChartProps) {
|
|||||||
stackId="a"
|
stackId="a"
|
||||||
fill="hsl(0, 0%, 60%)"
|
fill="hsl(0, 0%, 60%)"
|
||||||
radius={[0, 0, 0, 0]}
|
radius={[0, 0, 0, 0]}
|
||||||
|
isAnimationActive={!prefersReducedMotion}
|
||||||
|
animationDuration={barAnimationDuration}
|
||||||
|
animationEasing="ease-out"
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="paidInvoices"
|
dataKey="paidInvoices"
|
||||||
stackId="a"
|
stackId="a"
|
||||||
fill="var(--chart-2)"
|
fill="var(--chart-2)"
|
||||||
radius={[0, 0, 0, 0]}
|
radius={[0, 0, 0, 0]}
|
||||||
|
isAnimationActive={!prefersReducedMotion}
|
||||||
|
animationDuration={barAnimationDuration}
|
||||||
|
animationEasing="ease-out"
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="pendingInvoices"
|
dataKey="pendingInvoices"
|
||||||
stackId="a"
|
stackId="a"
|
||||||
fill="var(--chart-1)"
|
fill="var(--chart-1)"
|
||||||
radius={[0, 0, 0, 0]}
|
radius={[0, 0, 0, 0]}
|
||||||
|
isAnimationActive={!prefersReducedMotion}
|
||||||
|
animationDuration={barAnimationDuration}
|
||||||
|
animationEasing="ease-out"
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="overdueInvoices"
|
dataKey="overdueInvoices"
|
||||||
stackId="a"
|
stackId="a"
|
||||||
fill="var(--chart-3)"
|
fill="var(--chart-3)"
|
||||||
radius={[2, 2, 0, 0]}
|
radius={[2, 2, 0, 0]}
|
||||||
|
isAnimationActive={!prefersReducedMotion}
|
||||||
|
animationDuration={barAnimationDuration}
|
||||||
|
animationEasing="ease-out"
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||||
|
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
|
||||||
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -99,6 +100,8 @@ export function RevenueChart({ invoices }: RevenueChartProps) {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { prefersReducedMotion, animationSpeedMultiplier } =
|
||||||
|
useAnimationPreferences();
|
||||||
if (chartData.length === 0) {
|
if (chartData.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
@@ -147,6 +150,11 @@ export function RevenueChart({ invoices }: RevenueChartProps) {
|
|||||||
stroke="hsl(0, 0%, 60%)"
|
stroke="hsl(0, 0%, 60%)"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="url(#revenueGradient)"
|
fill="url(#revenueGradient)"
|
||||||
|
isAnimationActive={!prefersReducedMotion}
|
||||||
|
animationDuration={Math.round(
|
||||||
|
600 / (animationSpeedMultiplier ?? 1),
|
||||||
|
)}
|
||||||
|
animationEasing="ease-out"
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ import { Input } from "~/components/ui/input";
|
|||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { Textarea } from "~/components/ui/textarea";
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
import { api } from "~/trpc/react";
|
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() {
|
export function SettingsContent() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@@ -76,6 +79,25 @@ export function SettingsContent() {
|
|||||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = 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
|
// Queries
|
||||||
const { data: profile, refetch: refetchProfile } =
|
const { data: profile, refetch: refetchProfile } =
|
||||||
api.settings.getProfile.useQuery();
|
api.settings.getProfile.useQuery();
|
||||||
@@ -271,6 +293,8 @@ export function SettingsContent() {
|
|||||||
}
|
}
|
||||||
}, [profile?.name, name]);
|
}, [profile?.name, name]);
|
||||||
|
|
||||||
|
// (Removed direct DOM mutation; provider handles applying preferences globally)
|
||||||
|
|
||||||
const dataStatItems = [
|
const dataStatItems = [
|
||||||
{
|
{
|
||||||
label: "Clients",
|
label: "Clients",
|
||||||
@@ -498,6 +522,99 @@ export function SettingsContent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Accessibility & Animation */}
|
||||||
|
<Card className="bg-card border-border border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-foreground flex items-center gap-2">
|
||||||
|
<Info className="text-primary h-5 w-5" />
|
||||||
|
Accessibility & Animation
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSaveAnimationPreferences} className="space-y-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Reduce Motion</Label>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
Turn this on to reduce or remove non-essential animations and
|
||||||
|
transitions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={prefersReducedMotion}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setPrefersReducedMotion(Boolean(checked))
|
||||||
|
}
|
||||||
|
aria-label="Toggle reduced motion"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="mb-0">Animation Speed Multiplier</Label>
|
||||||
|
<span className="text-muted-foreground text-xs font-medium">
|
||||||
|
{prefersReducedMotion
|
||||||
|
? "1.00x (locked)"
|
||||||
|
: `${animationSpeedMultiplier.toFixed(2)}x`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs leading-snug">
|
||||||
|
Adjust global animation duration scaling. Lower values (0.25×,
|
||||||
|
0.5×, 0.75×) slow animations; higher values (2×, 3×, 4×) speed
|
||||||
|
them up.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* Slider (desktop / larger screens) */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<Slider
|
||||||
|
value={[animationSpeedMultiplier]}
|
||||||
|
min={0.25}
|
||||||
|
max={4}
|
||||||
|
step={0.25}
|
||||||
|
ticks={[0.25, 0.5, 0.75, 1, 2, 3, 4]}
|
||||||
|
formatTick={(t) => (t === 1 ? "1x" : `${t}x`)}
|
||||||
|
onValueChange={(v: number[]) =>
|
||||||
|
setAnimationSpeedMultiplier(v[0] ?? 1)
|
||||||
|
}
|
||||||
|
aria-label="Animation speed multiplier"
|
||||||
|
className="mt-1"
|
||||||
|
disabled={prefersReducedMotion}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Dropdown fallback (small screens) */}
|
||||||
|
<div className="block sm:hidden">
|
||||||
|
<select
|
||||||
|
className="bg-background border-border text-foreground w-full rounded-md border px-2 py-2 text-sm disabled:opacity-60"
|
||||||
|
value={animationSpeedMultiplier}
|
||||||
|
disabled={prefersReducedMotion}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAnimationSpeedMultiplier(
|
||||||
|
parseFloat(e.target.value) || 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-label="Animation speed multiplier select"
|
||||||
|
>
|
||||||
|
{[0.25, 0.5, 0.75, 1, 2, 3, 4].map((v) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{v === 1 ? "1x (default)" : `${v}x`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={animationPrefsUpdating}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{animationPrefsUpdating ? "Saving..." : "Save Preferences"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Data Management */}
|
{/* Data Management */}
|
||||||
<Card className="bg-card border-border border">
|
<Card className="bg-card border-border border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Geist_Mono } from "next/font/google";
|
|||||||
|
|
||||||
import { TRPCReactProvider } from "~/trpc/react";
|
import { TRPCReactProvider } from "~/trpc/react";
|
||||||
import { Toaster } from "~/components/ui/sonner";
|
import { Toaster } from "~/components/ui/sonner";
|
||||||
|
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "beenvoice - Invoicing Made Simple",
|
title: "beenvoice - Invoicing Made Simple",
|
||||||
@@ -24,10 +25,22 @@ export default function RootLayout({
|
|||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={geistMono.variable}>
|
<html suppressHydrationWarning lang="en" className={geistMono.variable}>
|
||||||
|
<head>
|
||||||
|
{/* Inline early animation preference script to avoid FOUC */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){try{var STORAGE_KEY='bv.animation.prefs';var raw=localStorage.getItem(STORAGE_KEY);var prefersReduced=false;var speed=1;if(raw){try{var parsed=JSON.parse(raw);if(typeof parsed.prefersReducedMotion==='boolean'){prefersReduced=parsed.prefersReducedMotion;}else{prefersReduced=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;}if(typeof parsed.animationSpeedMultiplier==='number'){speed=parsed.animationSpeedMultiplier;if(isNaN(speed)||speed<0.25||speed>4)speed=1;}}catch(e){}}else{prefersReduced=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;}var root=document.documentElement;if(prefersReduced)root.classList.add('user-reduce-motion');function apply(fast,normal,slow){root.style.setProperty('--animation-speed-fast',fast+'s');root.style.setProperty('--animation-speed-normal',normal+'s');root.style.setProperty('--animation-speed-slow',slow+'s');}if(prefersReduced){apply(0.01,0.01,0.01);}else{var fast=(0.15/speed).toFixed(4);var normal=(0.30/speed).toFixed(4);var slow=(0.50/speed).toFixed(4);apply(fast,normal,slow);}}catch(_e){}})();`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
|
||||||
<TRPCReactProvider>{children}</TRPCReactProvider>
|
<TRPCReactProvider>
|
||||||
|
<AnimationPreferencesProvider>
|
||||||
|
{children}
|
||||||
|
</AnimationPreferencesProvider>
|
||||||
|
</TRPCReactProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
460
src/components/providers/animation-preferences-provider.tsx
Normal file
460
src/components/providers/animation-preferences-provider.tsx
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
"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:
|
||||||
|
* <AnimationPreferencesProvider
|
||||||
|
* initial={{
|
||||||
|
* prefersReducedMotion: serverValue.prefersReducedMotion,
|
||||||
|
* animationSpeedMultiplier: serverValue.animationSpeedMultiplier
|
||||||
|
* }}
|
||||||
|
* >
|
||||||
|
* <App />
|
||||||
|
* </AnimationPreferencesProvider>
|
||||||
|
*
|
||||||
|
* 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 { useSession } from "next-auth/react";
|
||||||
|
|
||||||
|
type AnimationPreferences = {
|
||||||
|
prefersReducedMotion: boolean;
|
||||||
|
animationSpeedMultiplier: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PartialPrefs = Partial<AnimationPreferences>;
|
||||||
|
|
||||||
|
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<AnimationPreferencesContextValue | null>(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 } = 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<boolean>(
|
||||||
|
initial?.prefersReducedMotion ?? DEFAULT_PREFERS_REDUCED,
|
||||||
|
);
|
||||||
|
const [animationSpeedMultiplier, setAnimationSpeedMultiplier] =
|
||||||
|
useState<number>(
|
||||||
|
clampSpeed(initial?.animationSpeedMultiplier ?? DEFAULT_SPEED),
|
||||||
|
);
|
||||||
|
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
|
||||||
|
const pendingSyncRef = useRef<PartialPrefs | null>(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 (
|
||||||
|
<AnimationPreferencesContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AnimationPreferencesContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 <script dangerouslySetInnerHTML={{__html: ...}} /> early in <head>.)
|
||||||
|
*
|
||||||
|
* Example usage in layout.tsx (before loading CSS-heavy content):
|
||||||
|
*
|
||||||
|
* <script
|
||||||
|
* dangerouslySetInnerHTML={{ __html: getInlineAnimationPrefsScript() }}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
export function getInlineAnimationPrefsScript(): string {
|
||||||
|
return `
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
var STORAGE_KEY = '${STORAGE_KEY}';
|
||||||
|
var raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
var prefersReduced = false;
|
||||||
|
var speed = 1;
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
var parsed = JSON.parse(raw);
|
||||||
|
if (typeof parsed.prefersReducedMotion === 'boolean') {
|
||||||
|
prefersReduced = parsed.prefersReducedMotion;
|
||||||
|
} else {
|
||||||
|
// fallback to system preference if available
|
||||||
|
prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
if (typeof parsed.animationSpeedMultiplier === 'number') {
|
||||||
|
speed = parsed.animationSpeedMultiplier;
|
||||||
|
if (isNaN(speed) || speed < ${MIN_SPEED} || speed > ${MAX_SPEED}) speed = 1;
|
||||||
|
}
|
||||||
|
} catch (_e) {}
|
||||||
|
} else {
|
||||||
|
prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
var root = document.documentElement;
|
||||||
|
if (prefersReduced) root.classList.add('user-reduce-motion');
|
||||||
|
function apply(fast, normal, slow){
|
||||||
|
root.style.setProperty('--animation-speed-fast', fast + 's');
|
||||||
|
root.style.setProperty('--animation-speed-normal', normal + 's');
|
||||||
|
root.style.setProperty('--animation-speed-slow', slow + 's');
|
||||||
|
}
|
||||||
|
if (prefersReduced) {
|
||||||
|
apply(0.01,0.01,0.01);
|
||||||
|
} else {
|
||||||
|
var fast = (0.15 / speed).toFixed(4);
|
||||||
|
var normal = (0.30 / speed).toFixed(4);
|
||||||
|
var slow = (0.50 / speed).toFixed(4);
|
||||||
|
apply(fast, normal, slow);
|
||||||
|
}
|
||||||
|
} catch(_e){}
|
||||||
|
})();`.trim();
|
||||||
|
}
|
||||||
371
src/components/ui/slider.tsx
Normal file
371
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
"use client";
|
||||||
|
/*
|
||||||
|
Category spacing edit requested, but I don’t have the file’s current line numbers or body available in this interaction to produce a safe minimal patch.
|
||||||
|
|
||||||
|
Please resend (or paste) the current contents (or at least the portion containing:
|
||||||
|
- valueToPct / log scale logic
|
||||||
|
- pct calculation
|
||||||
|
- tick positioning loop
|
||||||
|
|
||||||
|
Once I have the exact text, I will replace only the necessary lines to:
|
||||||
|
1. Remove log scale logic (valueToPct, logDenom, etc.)
|
||||||
|
2. Implement category spacing:
|
||||||
|
const index = derivedTicks.indexOf(t);
|
||||||
|
const posPct = (index / (derivedTicks.length - 1)) * 100;
|
||||||
|
3. Ensure snapping uses the existing tick list (already in place).
|
||||||
|
4. Remove now-unused variables (logDenom, useLogScale).
|
||||||
|
|
||||||
|
If you can provide ~50 lines around the current pct / tick rendering block I can return the precise edit block.
|
||||||
|
|
||||||
|
Alternatively, if you confirm the existing lines that define:
|
||||||
|
const useLogScale ...
|
||||||
|
const logDenom ...
|
||||||
|
const valueToPct = ...
|
||||||
|
I will replace them with a simple linear category mapping:
|
||||||
|
const valueToPct = (val: number) => {
|
||||||
|
if (!derivedTicks.length) return 0;
|
||||||
|
const i = derivedTicks.indexOf(val);
|
||||||
|
if (i === -1) {
|
||||||
|
// fallback: find closest
|
||||||
|
const snapped = snapValue(val);
|
||||||
|
return (derivedTicks.indexOf(snapped) / (derivedTicks.length - 1)) * 100;
|
||||||
|
}
|
||||||
|
return (i / (derivedTicks.length - 1)) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
Let me know and I’ll produce the exact patch in the required format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight accessible single-thumb Slider with tick marks.
|
||||||
|
* - Hides native thumb (uses custom thumb)
|
||||||
|
* - Adds labeled tick marks
|
||||||
|
* - Honors reduced-motion: locks to 1x and disables interaction
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SliderProps {
|
||||||
|
value?: number[];
|
||||||
|
defaultValue?: number[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
onValueChange?: (value: number[]) => void;
|
||||||
|
onValueCommit?: (value: number[]) => void;
|
||||||
|
announceValue?: boolean;
|
||||||
|
formatValueText?: (value: number) => string;
|
||||||
|
"aria-label"?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
/**
|
||||||
|
* Provide a list of tick values (within min/max) to render markers.
|
||||||
|
*/
|
||||||
|
ticks?: number[];
|
||||||
|
/**
|
||||||
|
* Optional formatter for tick labels (fall back to raw value).
|
||||||
|
*/
|
||||||
|
formatTick?: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
||||||
|
function Slider(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 1,
|
||||||
|
onValueChange,
|
||||||
|
onValueCommit,
|
||||||
|
announceValue = true,
|
||||||
|
formatValueText,
|
||||||
|
formatTick,
|
||||||
|
ticks,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
// Detect reduced motion preference (user toggle adds class on <html>)
|
||||||
|
const reducedMotion =
|
||||||
|
typeof document !== "undefined" &&
|
||||||
|
document.documentElement.classList.contains("user-reduce-motion");
|
||||||
|
|
||||||
|
// Lock value to 1 when reduced motion & inside 0.25–4 range scenario
|
||||||
|
const lockValue =
|
||||||
|
reducedMotion && min <= 1 && max >= 1 && step <= 1 ? 1 : null;
|
||||||
|
|
||||||
|
const isControlled = value !== undefined;
|
||||||
|
const initial =
|
||||||
|
lockValue ?? (isControlled ? value?.[0] : defaultValue?.[0]);
|
||||||
|
const [internal, setInternal] = React.useState<number>(initial ?? min);
|
||||||
|
|
||||||
|
// Track last emitted value to avoid redundant parent updates (prevents loops)
|
||||||
|
const lastEmittedRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Sync when controlled or when reduced-motion lock toggles
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (lockValue !== null) {
|
||||||
|
// Only update internal & emit if changed
|
||||||
|
if (!isControlled && internal !== 1) {
|
||||||
|
setInternal(1);
|
||||||
|
}
|
||||||
|
if (lastEmittedRef.current !== 1) {
|
||||||
|
lastEmittedRef.current = 1;
|
||||||
|
// Do NOT call onValueChange here to avoid infinite loops; parent
|
||||||
|
// logic (provider) already enforces 1x on reduced motion toggle.
|
||||||
|
}
|
||||||
|
} else if (isControlled && value) {
|
||||||
|
const next = value[0] ?? min;
|
||||||
|
if (internal !== next) {
|
||||||
|
setInternal(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isControlled, value, lockValue, min]);
|
||||||
|
|
||||||
|
const currentValue =
|
||||||
|
lockValue !== null ? 1 : isControlled ? (value?.[0] ?? min) : internal;
|
||||||
|
|
||||||
|
// Category spacing: each defined tick receives equal horizontal spacing.
|
||||||
|
// We treat ticks as discrete categories rather than using numeric/log scaling.
|
||||||
|
const buildTicks = () =>
|
||||||
|
ticks ?? (min === 0.25 && max === 4 ? [0.25, 0.5, 0.75, 1, 2, 3, 4] : []);
|
||||||
|
|
||||||
|
let derivedTicks = buildTicks();
|
||||||
|
|
||||||
|
// Ensure at least two ticks (fallback to min/max) to avoid divide-by-zero.
|
||||||
|
if (derivedTicks.length < 2) {
|
||||||
|
derivedTicks = [min, max];
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapValue = (val: number) => {
|
||||||
|
return derivedTicks.reduce(
|
||||||
|
(closest, tick) =>
|
||||||
|
Math.abs(tick - val) < Math.abs(closest - val) ? tick : closest,
|
||||||
|
derivedTicks[0]!,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map a value to percentage based on its (snapped) index in the tick list.
|
||||||
|
const valueToPct = (val: number) => {
|
||||||
|
const snapped = snapValue(val);
|
||||||
|
const idx = derivedTicks.indexOf(snapped);
|
||||||
|
if (idx <= 0) return 0;
|
||||||
|
if (idx >= derivedTicks.length - 1) return 100;
|
||||||
|
return (idx / (derivedTicks.length - 1)) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pct = valueToPct(currentValue);
|
||||||
|
|
||||||
|
const getValueText = (v: number) =>
|
||||||
|
formatValueText ? formatValueText(v) : v.toString();
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (lockValue !== null) return;
|
||||||
|
const raw = Number(e.target.value);
|
||||||
|
// Snap raw value to nearest tick (log scale visually, but input still linear)
|
||||||
|
const snapped = snapValue(raw);
|
||||||
|
if (!isControlled) setInternal(snapped);
|
||||||
|
if (lastEmittedRef.current !== snapped) {
|
||||||
|
lastEmittedRef.current = snapped;
|
||||||
|
onValueChange?.([snapped]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommit = () => {
|
||||||
|
if (lockValue !== null) return;
|
||||||
|
onValueCommit?.([currentValue]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use nullish coalescing for the disabled prop fallback, then combine with lockValue
|
||||||
|
const effectiveDisabled = (disabled ?? false) || lockValue !== null;
|
||||||
|
|
||||||
|
// (Derived ticks initialized earlier for category spacing)
|
||||||
|
/**
|
||||||
|
* Commit a value change (used by ticks & keyboard)
|
||||||
|
*/
|
||||||
|
const commitValue = (val: number) => {
|
||||||
|
if (lockValue !== null) return;
|
||||||
|
if (!isControlled) setInternal(val);
|
||||||
|
if (lastEmittedRef.current !== val) {
|
||||||
|
lastEmittedRef.current = val;
|
||||||
|
onValueChange?.([val]);
|
||||||
|
}
|
||||||
|
onValueCommit?.([val]);
|
||||||
|
};
|
||||||
|
const handleTickSelect = (val: number) => {
|
||||||
|
if (effectiveDisabled) return;
|
||||||
|
commitValue(val);
|
||||||
|
};
|
||||||
|
const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (effectiveDisabled) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
let ratio = (e.clientX - rect.left) / rect.width;
|
||||||
|
if (Number.isNaN(ratio)) return;
|
||||||
|
ratio = Math.min(1, Math.max(0, ratio));
|
||||||
|
const idx = Math.round(ratio * (derivedTicks.length - 1));
|
||||||
|
const next = derivedTicks[idx]!;
|
||||||
|
commitValue(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-slot="slider-wrapper"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full flex-col",
|
||||||
|
orientation === "vertical" && "h-40 w-fit",
|
||||||
|
effectiveDisabled && "cursor-not-allowed opacity-60",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-disabled={effectiveDisabled}
|
||||||
|
>
|
||||||
|
{/* Track + fill + thumb */}
|
||||||
|
<div className="relative h-6 w-full">
|
||||||
|
<div className="absolute top-1/2 w-full -translate-y-1/2">
|
||||||
|
<div
|
||||||
|
className="bg-muted relative h-2 w-full cursor-pointer rounded-full"
|
||||||
|
onClick={handleTrackClick}
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-primary absolute top-0 left-0 h-full rounded-full"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
{/* Custom thumb */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border bg-background absolute top-1/2 z-10 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border shadow-sm transition",
|
||||||
|
!effectiveDisabled &&
|
||||||
|
"ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
|
)}
|
||||||
|
style={{ left: `${pct}%` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Native input overlay (thumb hidden) */}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onMouseUp={handleCommit}
|
||||||
|
onTouchEnd={handleCommit}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (["Enter", " ", "Tab"].includes(e.key)) handleCommit();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"absolute top-0 left-0 h-6 w-full cursor-pointer appearance-none bg-transparent focus:outline-none",
|
||||||
|
// Hide native thumb (WebKit)
|
||||||
|
"[&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-0 [&::-webkit-slider-thumb]:bg-transparent",
|
||||||
|
// Firefox
|
||||||
|
"[&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-transparent",
|
||||||
|
// Edge/IE (legacy)
|
||||||
|
"[&::-ms-thumb]:appearance-none",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tick Marks (interactive) */}
|
||||||
|
{derivedTicks.length > 0 && (
|
||||||
|
<div className="relative mt-2 h-8 w-full">
|
||||||
|
{derivedTicks.map((t) => {
|
||||||
|
const active = Math.abs(t - currentValue) < step / 2;
|
||||||
|
const posPct = valueToPct(t); // category-based equal spacing
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={t}
|
||||||
|
onClick={() => handleTickSelect(t)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleTickSelect(t);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`Set value to ${t}`}
|
||||||
|
aria-pressed={active}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:ring-ring focus-visible:ring-offset-background absolute flex -translate-x-1/2 flex-col items-center transition-colors outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||||
|
"select-none",
|
||||||
|
active && "cursor-default",
|
||||||
|
!active && !effectiveDisabled && "hover:opacity-80",
|
||||||
|
effectiveDisabled && "cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
style={{ left: `${posPct}%`, background: "transparent" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-border h-2 w-0.5 rounded-sm",
|
||||||
|
active && "bg-primary h-3",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground mt-1 text-[10px] leading-none whitespace-nowrap",
|
||||||
|
active && "text-primary font-medium",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: "translateX(0)",
|
||||||
|
maxWidth: "3.5ch",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTick ? formatTick(t) : t}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reduced motion note */}
|
||||||
|
{lockValue !== null && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Animation speed locked at 1× (Reduced Motion enabled).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{announceValue && (
|
||||||
|
<span aria-live="polite" className="sr-only">
|
||||||
|
{getValueText(currentValue)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SliderValue: React.FC<{
|
||||||
|
value: number | number[] | undefined;
|
||||||
|
formatAction?: (v: number) => string;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ value, formatAction = (v) => v.toString(), className }) => {
|
||||||
|
if (value == null) return null;
|
||||||
|
const vals = Array.isArray(value) ? value : [value];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="slider-value"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground inline-flex min-w-[2rem] items-center justify-end text-xs tabular-nums",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{vals.map(formatAction).join(" – ")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -91,6 +91,50 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
return user;
|
return user;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Get animation preferences
|
||||||
|
getAnimationPreferences: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const user = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(users.id, ctx.session.user.id),
|
||||||
|
columns: {
|
||||||
|
prefersReducedMotion: true,
|
||||||
|
animationSpeedMultiplier: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefersReducedMotion: user?.prefersReducedMotion ?? false,
|
||||||
|
animationSpeedMultiplier: user?.animationSpeedMultiplier ?? 1,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Update animation preferences
|
||||||
|
updateAnimationPreferences: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
prefersReducedMotion: z.boolean().optional(),
|
||||||
|
animationSpeedMultiplier: z
|
||||||
|
.number()
|
||||||
|
.min(0.25, "Minimum 0.25x")
|
||||||
|
.max(4, "Maximum 4x")
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
...(input.prefersReducedMotion !== undefined && {
|
||||||
|
prefersReducedMotion: input.prefersReducedMotion,
|
||||||
|
}),
|
||||||
|
...(input.animationSpeedMultiplier !== undefined && {
|
||||||
|
animationSpeedMultiplier: input.animationSpeedMultiplier,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, ctx.session.user.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
|
||||||
// Update user profile
|
// Update user profile
|
||||||
updateProfile: protectedProcedure
|
updateProfile: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export const users = createTable("user", (d) => ({
|
|||||||
image: d.varchar({ length: 255 }),
|
image: d.varchar({ length: 255 }),
|
||||||
resetToken: d.varchar({ length: 255 }),
|
resetToken: d.varchar({ length: 255 }),
|
||||||
resetTokenExpiry: d.timestamp(),
|
resetTokenExpiry: d.timestamp(),
|
||||||
|
// User UI/animation preferences
|
||||||
|
prefersReducedMotion: d.boolean().default(false).notNull(),
|
||||||
|
animationSpeedMultiplier: d.real().default(1).notNull(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
|
|||||||
@@ -229,15 +229,20 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.animate-fade-in-up {
|
.animate-fade-in-up {
|
||||||
animation: fade-in-up 0.6s ease-out forwards;
|
/* 0.6s ≈ slow * 1.2 */
|
||||||
|
animation: fade-in-up calc(var(--animation-speed-slow) * 1.2)
|
||||||
|
var(--animation-easing) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-text-shimmer {
|
.animate-text-shimmer {
|
||||||
animation: text-shimmer 2s ease-in-out infinite;
|
/* 2s ≈ slow * 4 */
|
||||||
|
animation: text-shimmer calc(var(--animation-speed-slow) * 4) ease-in-out
|
||||||
|
infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-float {
|
.animate-float {
|
||||||
animation: float 3s ease-in-out infinite;
|
/* 3s ≈ slow * 6 */
|
||||||
|
animation: float calc(var(--animation-speed-slow) * 6) ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animation-delay-300 {
|
.animation-delay-300 {
|
||||||
@@ -466,6 +471,16 @@ li[data-sonner-toast] button:hover,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* User toggle: force reduced motion even if system preference allows motion */
|
||||||
|
html.user-reduce-motion *,
|
||||||
|
html.user-reduce-motion *::before,
|
||||||
|
html.user-reduce-motion *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
BASE KEYFRAMES
|
BASE KEYFRAMES
|
||||||
======================================== */
|
======================================== */
|
||||||
@@ -659,15 +674,21 @@ li[data-sonner-toast] button:hover,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.animate-bounce {
|
.animate-bounce {
|
||||||
animation: bounce 1s var(--animation-easing);
|
/* 1s ≈ slow * 2 */
|
||||||
|
animation: bounce calc(var(--animation-speed-slow) * 2)
|
||||||
|
var(--animation-easing);
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-pulse {
|
.animate-pulse {
|
||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
/* 2s ≈ slow * 4 */
|
||||||
|
animation: pulse calc(var(--animation-speed-slow) * 4)
|
||||||
|
cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-count-up {
|
.animate-count-up {
|
||||||
animation: countUp 0.8s var(--animation-easing);
|
/* 0.8s ≈ slow * 1.6 */
|
||||||
|
animation: countUp calc(var(--animation-speed-slow) * 1.6)
|
||||||
|
var(--animation-easing);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stagger Animation Delays */
|
/* Stagger Animation Delays */
|
||||||
@@ -744,7 +765,8 @@ li[data-sonner-toast] button:hover,
|
|||||||
hsl(var(--muted)) 100%
|
hsl(var(--muted)) 100%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s ease-in-out infinite;
|
/* 1.5s ≈ slow * 3 */
|
||||||
|
animation: shimmer calc(var(--animation-speed-slow) * 3) ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-text {
|
.skeleton-text {
|
||||||
@@ -783,7 +805,9 @@ li[data-sonner-toast] button:hover,
|
|||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
.page-enter {
|
.page-enter {
|
||||||
animation: fadeInUp 0.6s var(--animation-easing);
|
/* 0.6s ≈ slow * 1.2 */
|
||||||
|
animation: fadeInUp calc(var(--animation-speed-slow) * 1.2)
|
||||||
|
var(--animation-easing);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-enter-stagger > * {
|
.page-enter-stagger > * {
|
||||||
@@ -904,7 +928,9 @@ li[data-sonner-toast] button:hover,
|
|||||||
|
|
||||||
/* Status Badge Pulse for Pending States */
|
/* Status Badge Pulse for Pending States */
|
||||||
.status-pending {
|
.status-pending {
|
||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
/* 2s ≈ slow * 4 */
|
||||||
|
animation: pulse calc(var(--animation-speed-slow) * 4)
|
||||||
|
cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button Loading States */
|
/* Button Loading States */
|
||||||
@@ -924,7 +950,8 @@ li[data-sonner-toast] button:hover,
|
|||||||
border: 2px solid currentColor;
|
border: 2px solid currentColor;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border-top-color: transparent;
|
border-top-color: transparent;
|
||||||
animation: spin 0.8s linear infinite;
|
/* 0.8s ≈ slow * 1.6 */
|
||||||
|
animation: spin calc(var(--animation-speed-slow) * 1.6) linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
@@ -938,7 +965,9 @@ li[data-sonner-toast] button:hover,
|
|||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
.success-state {
|
.success-state {
|
||||||
animation: successPulse 0.6s var(--animation-easing);
|
/* 0.6s ≈ slow * 1.2 */
|
||||||
|
animation: successPulse calc(var(--animation-speed-slow) * 1.2)
|
||||||
|
var(--animation-easing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes successPulse {
|
@keyframes successPulse {
|
||||||
@@ -957,7 +986,8 @@ li[data-sonner-toast] button:hover,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error-state {
|
.error-state {
|
||||||
animation: errorShake 0.5s var(--animation-easing);
|
/* 0.5s = slow * 1 */
|
||||||
|
animation: errorShake var(--animation-speed-slow) var(--animation-easing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes errorShake {
|
@keyframes errorShake {
|
||||||
@@ -1077,3 +1107,63 @@ li[data-sonner-toast] button:hover,
|
|||||||
.will-animate.animation-done {
|
.will-animate.animation-done {
|
||||||
will-change: auto;
|
will-change: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
Reduced Motion Alternative (subtle fade / blur entrance)
|
||||||
|
Applied when html has .user-reduce-motion (user preference)
|
||||||
|
Large motion & long-running animations are removed; brief
|
||||||
|
opacity/blur transitions are allowed for context.
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
html.user-reduce-motion .rm-soft-enter {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.user-reduce-motion .rm-soft-enter.rm-visible {
|
||||||
|
transition:
|
||||||
|
opacity var(--animation-speed-normal) var(--animation-easing),
|
||||||
|
filter var(--animation-speed-normal) var(--animation-easing);
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Replace standard entrance animations with subtle fade */
|
||||||
|
html.user-reduce-motion .page-enter,
|
||||||
|
html.user-reduce-motion .page-enter-stagger > *,
|
||||||
|
html.user-reduce-motion .stats-card,
|
||||||
|
html.user-reduce-motion .invoice-item,
|
||||||
|
html.user-reduce-motion .recent-activity-item,
|
||||||
|
html.user-reduce-motion .form-section,
|
||||||
|
html.user-reduce-motion .animate-on-load {
|
||||||
|
animation: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
filter: none !important;
|
||||||
|
transition:
|
||||||
|
opacity var(--animation-speed-fast) var(--animation-easing),
|
||||||
|
filter var(--animation-speed-fast) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Neutralize pulsing / spinning (provide static state) */
|
||||||
|
html.user-reduce-motion .status-pending,
|
||||||
|
html.user-reduce-motion .button-loading::after,
|
||||||
|
html.user-reduce-motion .animate-pulse,
|
||||||
|
html.user-reduce-motion .animate-bounce {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: allow a very quick keyframe for blur-in if needed */
|
||||||
|
@keyframes reducedBlurIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html.user-reduce-motion .allow-reduced-motion-effect {
|
||||||
|
animation: reducedBlurIn var(--animation-speed-fast) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user