From a674da70cb8cbb934bafa32b877c47c244f9ff78 Mon Sep 17 00:00:00 2001
From: nanlebenthel-web <nanlebenthel@gmail.com>
Date: Thu, 28 May 2026 19:17:00 +0100
Subject: [PATCH] feat(frontend): theme a11y + optimistic rollback,
 balance-sync refactor and animations

- #801: respect prefers-reduced-motion for the theme-toggle icon swaps, drop the
  redundant ARIA role, and restore the missing </button> so the Dark Mode toggle
  compiles and renders.
- #802: complete the optimistic theme-update rollback so a failed persist also
  reverts the <meta name="theme-color"> alongside the theme state and class.
- #803: refactor useBalanceSync state into a reducer (start/success/error) and
  surface the previously-swallowed error to consumers.
- #804: animate the Real-time Balance Sync status in SupportPanel with
  framer-motion, transitioning the figure each time polling syncs a new balance.

Closes #801
Closes #802
Closes #803
Closes #804
---
 frontend/src/components/SupportPanel.tsx | 62 +++++++++++++++-------
 frontend/src/components/ThemeToggle.tsx  | 16 +++---
 frontend/src/hooks/useBalanceSync.ts     | 67 +++++++++++++++++++-----
 frontend/src/lib/theme-context.tsx       |  9 +++-
 4 files changed, 115 insertions(+), 39 deletions(-)

diff --git a/frontend/src/components/SupportPanel.tsx b/frontend/src/components/SupportPanel.tsx
index 5b92be9..f701356 100644
--- a/frontend/src/components/SupportPanel.tsx
+++ b/frontend/src/components/SupportPanel.tsx
@@ -6,6 +6,7 @@ import { getAccountBalances, type AssetBalance } from "@/lib/stellar";
 import { Spinner } from "./ui/Spinner";
 import { TransactionResultModal } from "./TransactionResultModal";
 import { toast } from "sonner";
+import { motion, AnimatePresence } from "framer-motion";
 import { useBalanceSync } from "@/hooks/useBalanceSync";
 
 const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
@@ -110,25 +111,48 @@ export function SupportPanel() {
           <div className="flex justify-between items-end">
             <label className="text-xs font-semibold text-white" htmlFor="amount-input">Amount</label>
             <div className="text-[10px] text-slate-400" aria-live="polite" aria-atomic="true">
-              {loadingBalance ? (
-                <div className="flex items-center gap-2">
-                  <span className="sr-only">Loading balance...</span>
-                  <Spinner size="xs" aria-hidden="true" />
-                </div>
-              ) : isUnfunded ? (
-                <a 
-                  href="https://laboratory.stellar.org/#account-creator?network=testnet" 
-                  target="_blank" 
-                  rel="noopener noreferrer"
-                  className="text-yellow-400 hover:underline"
-                >
-                  Unfunded (Fund on Testnet)
-                </a>
-              ) : (
-                <span aria-label={`Available balance: ${selectedBalance} ${assetCode}`}>
-                  Available: {selectedBalance} {assetCode}
-                </span>
-              )}
+              <AnimatePresence mode="wait" initial={false}>
+                {loadingBalance ? (
+                  <motion.div
+                    key="loading"
+                    initial={{ opacity: 0, y: -4 }}
+                    animate={{ opacity: 1, y: 0 }}
+                    exit={{ opacity: 0, y: 4 }}
+                    transition={{ duration: 0.18 }}
+                    className="flex items-center gap-2"
+                  >
+                    <span className="sr-only">Loading balance...</span>
+                    <Spinner size="xs" aria-hidden="true" />
+                  </motion.div>
+                ) : isUnfunded ? (
+                  <motion.a
+                    key="unfunded"
+                    initial={{ opacity: 0, y: -4 }}
+                    animate={{ opacity: 1, y: 0 }}
+                    exit={{ opacity: 0, y: 4 }}
+                    transition={{ duration: 0.18 }}
+                    href="https://laboratory.stellar.org/#account-creator?network=testnet"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="inline-block text-yellow-400 hover:underline"
+                  >
+                    Unfunded (Fund on Testnet)
+                  </motion.a>
+                ) : (
+                  // Keying by value animates the figure each time polling syncs a new balance.
+                  <motion.span
+                    key={`balance-${selectedBalance}-${assetCode}`}
+                    initial={{ opacity: 0, y: -4 }}
+                    animate={{ opacity: 1, y: 0 }}
+                    exit={{ opacity: 0, y: 4 }}
+                    transition={{ duration: 0.18 }}
+                    className="inline-block"
+                    aria-label={`Available balance: ${selectedBalance} ${assetCode}`}
+                  >
+                    Available: {selectedBalance} {assetCode}
+                  </motion.span>
+                )}
+              </AnimatePresence>
             </div>
           </div>
           <div className="relative">
diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx
index d8f1d64..8cdb97e 100644
--- a/frontend/src/components/ThemeToggle.tsx
+++ b/frontend/src/components/ThemeToggle.tsx
@@ -2,12 +2,16 @@
 
 import { useThemeState, useThemeActions } from "@/lib/theme-context";
 import { useCallback, useEffect, useState } from "react";
-import { motion, AnimatePresence } from "framer-motion";
+import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
 
 export default function ThemeToggle() {
   const { theme, resolvedTheme, isMounted, isLoading, error } = useThemeState();
   const { toggleTheme, clearError } = useThemeActions();
   const [announcement, setAnnouncement] = useState<string>("");
+  // Honour the user's reduced-motion preference so the icon swap doesn't
+  // animate for people who find motion distracting (a standard a11y audit item).
+  const shouldReduceMotion = useReducedMotion();
+  const iconTransition = { duration: shouldReduceMotion ? 0 : 0.2 };
 
   const handleThemeToggle = useCallback(() => {
     if (error) {
@@ -91,7 +95,6 @@ export default function ThemeToggle() {
         aria-describedby="theme-description"
         title={getTitle()}
         disabled={isLoading}
-        role="button"
       >
       <AnimatePresence mode="wait" initial={false}>
         {error ? (
@@ -100,7 +103,7 @@ export default function ThemeToggle() {
             initial={{ scale: 0.5, opacity: 0, rotate: -45 }}
             animate={{ scale: 1, opacity: 1, rotate: 0 }}
             exit={{ scale: 0.5, opacity: 0, rotate: 45 }}
-            transition={{ duration: 0.2 }}
+            transition={iconTransition}
             xmlns="http://www.w3.org/2000/svg"
             fill="none"
             viewBox="0 0 24 24"
@@ -120,7 +123,7 @@ export default function ThemeToggle() {
             initial={{ scale: 0.5, opacity: 0, rotate: -45 }}
             animate={{ scale: 1, opacity: 1, rotate: 0 }}
             exit={{ scale: 0.5, opacity: 0, rotate: 45 }}
-            transition={{ duration: 0.2 }}
+            transition={iconTransition}
             xmlns="http://www.w3.org/2000/svg"
             fill="none"
             viewBox="0 0 24 24"
@@ -140,7 +143,7 @@ export default function ThemeToggle() {
             initial={{ scale: 0.5, opacity: 0, rotate: -45 }}
             animate={{ scale: 1, opacity: 1, rotate: 0 }}
             exit={{ scale: 0.5, opacity: 0, rotate: 45 }}
-            transition={{ duration: 0.2 }}
+            transition={iconTransition}
             xmlns="http://www.w3.org/2000/svg"
             fill="none"
             viewBox="0 0 24 24"
@@ -160,7 +163,7 @@ export default function ThemeToggle() {
             initial={{ scale: 0.5, opacity: 0, rotate: -45 }}
             animate={{ scale: 1, opacity: 1, rotate: 0 }}
             exit={{ scale: 0.5, opacity: 0, rotate: 45 }}
-            transition={{ duration: 0.2 }}
+            transition={iconTransition}
             className="relative flex items-center justify-center"
           >
             <svg
@@ -190,6 +193,7 @@ export default function ThemeToggle() {
       <div id="theme-description" className="sr-only">
         Use this button to cycle through light, dark, and system themes. The system theme follows your device preference.
       </div>
+      </button>
     </>
   );
 }
diff --git a/frontend/src/hooks/useBalanceSync.ts b/frontend/src/hooks/useBalanceSync.ts
index 1f68d8f..fb45bc1 100644
--- a/frontend/src/hooks/useBalanceSync.ts
+++ b/frontend/src/hooks/useBalanceSync.ts
@@ -1,5 +1,4 @@
-import { useEffect, useState, useCallback, useRef } from "react";
-import { toast } from "sonner";
+import { useEffect, useReducer, useCallback, useRef } from "react";
 
 interface Balance {
   code: string;
@@ -14,6 +13,49 @@ interface UseBalanceSyncOptions {
   horizonUrl?: string;
 }
 
+interface BalanceSyncState {
+  balances: Balance[];
+  isLoading: boolean;
+  lastUpdated: Date | null;
+  error: string | null;
+}
+
+type BalanceSyncAction =
+  | { type: "FETCH_START" }
+  | { type: "FETCH_SUCCESS"; balances: Balance[]; at: Date }
+  | { type: "FETCH_ERROR"; error: string };
+
+const initialState: BalanceSyncState = {
+  balances: [],
+  isLoading: false,
+  lastUpdated: null,
+  error: null,
+};
+
+// Consolidating the related pieces of state into a reducer keeps the
+// fetch lifecycle (start → success → error) explicit and prevents the
+// inconsistent intermediate renders that separate setState calls can cause.
+function balanceSyncReducer(
+  state: BalanceSyncState,
+  action: BalanceSyncAction,
+): BalanceSyncState {
+  switch (action.type) {
+    case "FETCH_START":
+      return { ...state, isLoading: true, error: null };
+    case "FETCH_SUCCESS":
+      return {
+        balances: action.balances,
+        isLoading: false,
+        lastUpdated: action.at,
+        error: null,
+      };
+    case "FETCH_ERROR":
+      return { ...state, isLoading: false, error: action.error };
+    default:
+      return state;
+  }
+}
+
 /**
  * Hook for real-time balance synchronization with polling and race condition prevention.
  */
@@ -22,16 +64,15 @@ export function useBalanceSync(
   apiKey: string | null | undefined,
   options: UseBalanceSyncOptions = {}
 ) {
-  const { 
-    pollingInterval = 30000, 
-    onUpdate, 
+  const {
+    pollingInterval = 30000,
+    onUpdate,
     enabled = true,
     address = null,
     horizonUrl = "https://horizon-testnet.stellar.org"
   } = options;
-  const [balances, setBalances] = useState<Balance[]>([]);
-  const [isLoading, setIsLoading] = useState(false);
-  const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
+  const [state, dispatch] = useReducer(balanceSyncReducer, initialState);
+  const { balances, isLoading, lastUpdated, error } = state;
   const abortControllerRef = useRef<AbortController | null>(null);
 
   const fetchBalances = useCallback(async () => {
@@ -44,7 +85,7 @@ export function useBalanceSync(
     }
     abortControllerRef.current = new AbortController();
 
-    setIsLoading(true);
+    dispatch({ type: "FETCH_START" });
     try {
       let newBalances: Balance[] = [];
 
@@ -78,14 +119,13 @@ export function useBalanceSync(
         }));
       }
 
-      setBalances(newBalances);
-      setLastUpdated(new Date());
+      dispatch({ type: "FETCH_SUCCESS", balances: newBalances, at: new Date() });
       onUpdate?.(newBalances);
     } catch (error) {
       if (error instanceof Error && error.name === "AbortError") return;
+      const message = error instanceof Error ? error.message : "Balance sync failed";
       console.error("Balance sync error:", error);
-    } finally {
-      setIsLoading(false);
+      dispatch({ type: "FETCH_ERROR", error: message });
     }
   }, [merchantId, apiKey, enabled, onUpdate, address, horizonUrl]);
 
@@ -107,6 +147,7 @@ export function useBalanceSync(
     balances,
     isLoading,
     lastUpdated,
+    error,
     refresh: fetchBalances,
     isStale: lastUpdated ? Date.now() - lastUpdated.getTime() > pollingInterval * 2 : true,
   };
diff --git a/frontend/src/lib/theme-context.tsx b/frontend/src/lib/theme-context.tsx
index ab8b95f..76fb89d 100644
--- a/frontend/src/lib/theme-context.tsx
+++ b/frontend/src/lib/theme-context.tsx
@@ -71,13 +71,20 @@ export function ThemeProvider({
       
       setError(null);
     } catch (err) {
-      // Revert optimistic update
+      // Revert optimistic update — restore the previous theme, the document
+      // class AND the meta theme-color so no part of the optimistic change
+      // lingers after a failed persist.
       setThemeState(previousTheme !== undefined ? previousTheme : defaultTheme);
       if (previousResolved) {
         setResolvedTheme(previousResolved);
         if (typeof globalThis !== "undefined" && globalThis.window) {
           globalThis.document.documentElement.classList.remove("light", "dark");
           globalThis.document.documentElement.classList.add(previousResolved);
+
+          const metaThemeColor = globalThis.document.querySelector('meta[name="theme-color"]');
+          if (metaThemeColor) {
+            metaThemeColor.setAttribute("content", previousResolved === "dark" ? "#0A0A0A" : "#FFFFFF");
+          }
         }
       }
       
