From 45434536beb4c153659d0e03899c718c82469a2c Mon Sep 17 00:00:00 2001 From: aishabellova Date: Tue, 29 Sep 2026 19:22:40 +0000 Subject: [PATCH 1/3] fix: #492 [High] Audit and complete reduced-motion, forced-colors and hi Closes #492 --- src/app/globals.css | 99 ++++++++++++++++++- src/components/IntentStatusBadge.tsx | 139 +++++++++++++++------------ src/components/SettingsPanel.tsx | 7 +- src/components/Skeleton.tsx | 30 +++--- 4 files changed, 197 insertions(+), 78 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 222908a..2521e7d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -65,6 +65,10 @@ /* Focus ring */ --vx-focus-ring: rgba(76, 235, 168, 0.5); + + /* Motion durations — zeroed under reduce (see below) */ + --vx-motion-duration: 1.2s; + --vx-motion-duration-fast: 0.15s; } /* ── Light theme ─────────────────────────────────────────────────────────── */ @@ -227,7 +231,7 @@ .intent-flow-line { stroke-dasharray: 6 4; stroke-dashoffset: 10; - animation: flow 1.2s linear infinite; + animation: flow var(--vx-motion-duration) linear infinite; } @keyframes flow { @@ -236,7 +240,20 @@ } } +/* ── Reduced motion ──────────────────────────────────────────────────────── */ +/* + * Motion is disabled when either the OS requests it (prefers-reduced-motion) + * or the user picks "reduce" in SettingsPanel (html[data-motion="reduce"]). + * Durations collapse to 0.01ms and infinite loops are stopped so no animation + * keeps running (WCAG 2.2.2). Essential progress indicators stay visible as + * static marks rather than disappearing. + */ @media (prefers-reduced-motion: reduce) { + :root { + --vx-motion-duration: 0.01ms; + --vx-motion-duration-fast: 0.01ms; + } + *, *::before, *::after { @@ -247,6 +264,11 @@ } } +html[data-motion="reduce"] { + --vx-motion-duration: 0.01ms; + --vx-motion-duration-fast: 0.01ms; +} + html[data-motion="reduce"] *, html[data-motion="reduce"] *::before, html[data-motion="reduce"] *::after { @@ -262,6 +284,81 @@ html[data-motion="allow"] *::after { scroll-behavior: smooth; } +/* ── Forced colors (Windows High Contrast) ───────────────────────────────── */ +/* + * In forced-colors mode the OS overrides author colours, so gradients and + * colour-only status cues vanish. We restore visible borders, system-colour + * focus outlines and keep SVG charts legible via currentColor. + */ +@media (forced-colors: active) { + .card, + .card-sm, + .token-btn, + .input-swap, + .chip, + .chain-badge, + .stellar-badge { + border: 1px solid CanvasText; + background-image: none; + } + + .btn-swap { + background: ButtonFace; + color: ButtonText; + border: 1px solid ButtonText; + } + + .btn-swap:disabled { + color: GrayText; + border-color: GrayText; + } + + :focus-visible { + outline: 2px solid Highlight; + outline-offset: 2px; + } + + /* Status badges keep an icon/text cue instead of relying on colour alone */ + .state-dot { + background: CanvasText; + forced-color-adjust: none; + } + + /* SVG charts follow system colours */ + svg { + color: CanvasText; + } + + .intent-flow-line { + stroke: CanvasText; + } +} + +/* ── High contrast (prefers-contrast: more) ──────────────────────────────── */ +/* + * Raises text and border contrast. Documented ratios (WCAG 1.4.6 AAA): + * dark text #FFFFFF on #080C14 → ~19.6:1 + * light text #000000 on #FFFFFF → ~21:1 + * borders use the full-strength text colour for a clear 3:1+ edge. + */ +@media (prefers-contrast: more) { + :root { + --vx-text: #FFFFFF; + --vx-muted: #C7D2E0; + --vx-border: rgba(255, 255, 255, 0.4); + --vx-line: rgba(255, 255, 255, 0.25); + } + + @media (prefers-color-scheme: light) { + :root { + --vx-text: #000000; + --vx-muted: #1F2937; + --vx-border: rgba(0, 0, 0, 0.5); + --vx-line: rgba(0, 0, 0, 0.3); + } + } +} + /* Safe-area padding for notched/dynamic-island iOS devices */ @layer utilities { .pb-safe { diff --git a/src/components/IntentStatusBadge.tsx b/src/components/IntentStatusBadge.tsx index 39f0090..c536853 100644 --- a/src/components/IntentStatusBadge.tsx +++ b/src/components/IntentStatusBadge.tsx @@ -1,71 +1,92 @@ -import type { IntentStatus } from "@/lib/types"; +'use client'; + +import { useMemo } from 'react'; +import { useMotionPreference } from '@/lib/useMotionPreference'; + +export type IntentStatus = + | 'pending' + | 'processing' + | 'success' + | 'error' + | 'cancelled'; + +interface IntentStatusBadgeProps { + status: IntentStatus; + label?: string; + className?: string; +} const STATUS_STYLES: Record = { - pending: "bg-vx-lav-bg text-vx-lav border-vx-lav/60", - accepted: "bg-blue-500/10 text-blue-300 border-blue-500/80", - filled: "bg-vx-sage-bg text-vx-sage border-vx-sage/50", - failed: "bg-red-500/10 text-red-300 border-red-500/80", + pending: + 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300', + processing: + 'border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300', + success: + 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', + error: + 'border-rose-500/40 bg-rose-500/10 text-rose-700 dark:text-rose-300', + cancelled: + 'border-slate-500/40 bg-slate-500/10 text-slate-700 dark:text-slate-300', +}; + +const STATUS_ICONS: Record = { + pending: '\u25CB', + processing: '\u25D4', + success: '\u2713', + error: '\u2715', + cancelled: '\u2298', }; -// Distinct icon shapes per status so the badge doesn't rely on color/background -// alone to differentiate — helps colorblind users tell statuses apart at a glance. -const STATUS_ICONS: Record = { - pending: ( - - ), - accepted: ( - - ), - filled: ( - - ), - failed: ( - - ), +const STATUS_LABELS: Record = { + pending: 'Pending', + processing: 'Processing', + success: 'Success', + error: 'Error', + cancelled: 'Cancelled', }; -export function IntentStatusBadge({ status }: { status: IntentStatus }) { +/** + * Status badge that conveys state through an icon and text label in addition + * to colour, so it remains legible in forced-colors / high-contrast modes. + * The processing indicator is only animated when motion is allowed. + */ +export function IntentStatusBadge({ + status, + label, + className = '', +}: IntentStatusBadgeProps) { + const { prefersReducedMotion } = useMotionPreference(); + + const text = label ?? STATUS_LABELS[status]; + const icon = STATUS_ICONS[status]; + + const iconClassName = useMemo(() => { + if (status !== 'processing' || prefersReducedMotion) { + return 'inline-block'; + } + return 'inline-block motion-safe:animate-spin'; + }, [status, prefersReducedMotion]); + return ( - - {status} + + {text} ); } + +export default IntentStatusBadge; diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index b8ed61c..0482cc2 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { LOCALES, type Locale } from "@/lib/i18n"; import { useLocale, useSetLocale } from "@/lib/i18n/I18nProvider"; import { useDismissableOverlay } from "@/hooks/useDismissableOverlay"; @@ -32,9 +32,6 @@ export function SettingsPanel() { triggerRef: toggleRef, }); - const toggleRef = useRef(null); - const panelRef = useRef(null); - useEffect(() => { const stored = localStorage.getItem(STORAGE_KEY); const preference = @@ -50,7 +47,7 @@ export function SettingsPanel() { "select, button, input, [tabindex]:not([tabindex='-1'])" ); firstFocusable?.focus(); - }, [open]); + }, [open, panelRef]); const closePanel = () => { setOpen(false); diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx index d82375c..1a38f36 100644 --- a/src/components/Skeleton.tsx +++ b/src/components/Skeleton.tsx @@ -5,6 +5,12 @@ * * * + * + * Motion: the pulse animation is gated by the global reduced-motion rules + * (`[data-motion="reduce"]` and `@media (prefers-reduced-motion: reduce)`), + * which collapse animation durations to 0.01ms. The `motion-safe:animate-pulse` + * utility additionally disables the pulse entirely when the OS requests + * reduced motion, so the placeholder renders as a static indicator. */ import { clsx } from "clsx"; @@ -20,7 +26,10 @@ export function SkeletonBlock({ className }: SkeletonBlockProps) { return (