Skip to content

Commit 3f1ca32

Browse files
authored
Merge pull request #700 from devonahi/feat/approval-model
fix: implement optimistic theme updates with rollback functionality o…
2 parents c330f8a + c7d28cc commit 3f1ca32

7 files changed

Lines changed: 139 additions & 71 deletions

File tree

frontend/src/components/MultisigApprovalModal.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,20 @@ describe("MultisigApprovalModal Component", () => {
340340
expect(modal).toHaveAttribute("aria-labelledby", "multisig-modal-title");
341341
});
342342

343+
it("has proper roles and live regions for screen readers", () => {
344+
renderWithProvider();
345+
346+
// Content area has polite aria-live
347+
const contentArea = screen.getByText("Multi-Signature Approval").parentElement?.nextElementSibling;
348+
expect(contentArea).toHaveAttribute("aria-live", "polite");
349+
350+
// Progress bar role
351+
const progressBar = screen.getByRole("progressbar");
352+
expect(progressBar).toHaveAttribute("aria-valuenow", "0");
353+
expect(progressBar).toHaveAttribute("aria-valuemin", "0");
354+
expect(progressBar).toHaveAttribute("aria-valuemax", "100");
355+
});
356+
343357
it("has proper heading structure", () => {
344358
renderWithProvider();
345359

frontend/src/components/MultisigApprovalModal.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,14 @@ export default function MultisigApprovalModal({
167167
</span>
168168
<span className="text-xs text-slate-400">{Math.round(progress)}%</span>
169169
</div>
170-
<div className="w-full bg-white/10 rounded-full h-2">
170+
<div
171+
className="w-full bg-white/10 rounded-full h-2"
172+
role="progressbar"
173+
aria-valuenow={Math.round(progress)}
174+
aria-valuemin={0}
175+
aria-valuemax={100}
176+
aria-label="Signature progress"
177+
>
171178
<div
172179
className="bg-mint h-2 rounded-full transition-all duration-300"
173180
style={{ width: `${progress}%` }}
@@ -243,8 +250,8 @@ export default function MultisigApprovalModal({
243250
);
244251

245252
const ProcessingStep = () => (
246-
<div className="flex flex-col items-center justify-center py-12 text-center">
247-
<div className="relative mb-6">
253+
<div className="flex flex-col items-center justify-center py-12 text-center" role="status">
254+
<div className="relative mb-6" aria-hidden="true">
248255
<div className="w-16 h-16 border-4 border-mint border-t-transparent rounded-full animate-spin" />
249256
<div className="absolute inset-0 w-16 h-16 border-4 border-mint/20 rounded-full animate-ping" />
250257
</div>
@@ -289,7 +296,7 @@ export default function MultisigApprovalModal({
289296
);
290297

291298
const ErrorStep = () => (
292-
<div className="text-center space-y-6">
299+
<div className="text-center space-y-6" role="alert">
293300
<div className="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto">
294301
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
295302
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
@@ -385,7 +392,7 @@ export default function MultisigApprovalModal({
385392
</div>
386393

387394
{/* Content */}
388-
<div className="p-6 max-h-[70vh] overflow-y-auto">
395+
<div className="p-6 max-h-[70vh] overflow-y-auto" aria-live="polite">
389396
{isExpired ? (
390397
<div className="text-center space-y-6">
391398
<div className="w-16 h-16 bg-amber-500/20 rounded-full flex items-center justify-center mx-auto">
@@ -413,7 +420,7 @@ export default function MultisigApprovalModal({
413420

414421
{/* Error Display */}
415422
{error && currentStep !== "error" && (
416-
<div className="mx-6 mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4">
423+
<div className="mx-6 mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4" role="alert">
417424
<div className="flex items-start gap-3">
418425
<svg className="w-5 h-5 text-red-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
419426
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />

frontend/src/components/ThemeToggle.tsx

Lines changed: 83 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useThemeState, useThemeActions } from "@/lib/theme-context";
44
import { useCallback } from "react";
5+
import { motion, AnimatePresence } from "framer-motion";
56

67
export default function ThemeToggle() {
78
const { theme, resolvedTheme, isMounted, isLoading, error } = useThemeState();
@@ -49,74 +50,98 @@ export default function ThemeToggle() {
4950
title={getTitle()}
5051
disabled={isLoading}
5152
>
52-
{error ? (
53-
<svg
54-
xmlns="http://www.w3.org/2000/svg"
55-
fill="none"
56-
viewBox="0 0 24 24"
57-
strokeWidth={1.5}
58-
stroke="currentColor"
59-
className="h-5 w-5 text-red-500"
60-
>
61-
<path
62-
strokeLinecap="round"
63-
strokeLinejoin="round"
64-
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
65-
/>
66-
</svg>
67-
) : theme === "light" ? (
68-
<svg
69-
xmlns="http://www.w3.org/2000/svg"
70-
fill="none"
71-
viewBox="0 0 24 24"
72-
strokeWidth={1.5}
73-
stroke="currentColor"
74-
className="h-5 w-5 text-amber-500 transition-colors"
75-
>
76-
<path
77-
strokeLinecap="round"
78-
strokeLinejoin="round"
79-
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
80-
/>
81-
</svg>
82-
) : theme === "dark" ? (
83-
<svg
84-
xmlns="http://www.w3.org/2000/svg"
85-
fill="none"
86-
viewBox="0 0 24 24"
87-
strokeWidth={1.5}
88-
stroke="currentColor"
89-
className="h-5 w-5 text-accent transition-colors"
90-
>
91-
<path
92-
strokeLinecap="round"
93-
strokeLinejoin="round"
94-
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
95-
/>
96-
</svg>
97-
) : (
98-
<div className="relative flex items-center justify-center">
99-
<svg
53+
<AnimatePresence mode="wait" initial={false}>
54+
{error ? (
55+
<motion.svg
56+
key="error"
57+
initial={{ scale: 0.5, opacity: 0, rotate: -45 }}
58+
animate={{ scale: 1, opacity: 1, rotate: 0 }}
59+
exit={{ scale: 0.5, opacity: 0, rotate: 45 }}
60+
transition={{ duration: 0.2 }}
10061
xmlns="http://www.w3.org/2000/svg"
10162
fill="none"
10263
viewBox="0 0 24 24"
10364
strokeWidth={1.5}
10465
stroke="currentColor"
105-
className="h-5 w-5 text-slate-400 transition-colors"
66+
className="h-5 w-5 text-red-500"
10667
>
10768
<path
10869
strokeLinecap="round"
10970
strokeLinejoin="round"
110-
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
71+
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
11172
/>
112-
</svg>
113-
<div className="absolute -bottom-0.5 -right-0.5 flex h-2 w-2 items-center justify-center">
114-
<div className={`h-1.5 w-1.5 rounded-full transition-colors ${
115-
resolvedTheme === 'dark' ? 'bg-accent' : 'bg-amber-500'
116-
}`} />
117-
</div>
118-
</div>
119-
)}
73+
</motion.svg>
74+
) : theme === "light" ? (
75+
<motion.svg
76+
key="light"
77+
initial={{ scale: 0.5, opacity: 0, rotate: -45 }}
78+
animate={{ scale: 1, opacity: 1, rotate: 0 }}
79+
exit={{ scale: 0.5, opacity: 0, rotate: 45 }}
80+
transition={{ duration: 0.2 }}
81+
xmlns="http://www.w3.org/2000/svg"
82+
fill="none"
83+
viewBox="0 0 24 24"
84+
strokeWidth={1.5}
85+
stroke="currentColor"
86+
className="h-5 w-5 text-amber-500 transition-colors"
87+
>
88+
<path
89+
strokeLinecap="round"
90+
strokeLinejoin="round"
91+
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
92+
/>
93+
</motion.svg>
94+
) : theme === "dark" ? (
95+
<motion.svg
96+
key="dark"
97+
initial={{ scale: 0.5, opacity: 0, rotate: -45 }}
98+
animate={{ scale: 1, opacity: 1, rotate: 0 }}
99+
exit={{ scale: 0.5, opacity: 0, rotate: 45 }}
100+
transition={{ duration: 0.2 }}
101+
xmlns="http://www.w3.org/2000/svg"
102+
fill="none"
103+
viewBox="0 0 24 24"
104+
strokeWidth={1.5}
105+
stroke="currentColor"
106+
className="h-5 w-5 text-accent transition-colors"
107+
>
108+
<path
109+
strokeLinecap="round"
110+
strokeLinejoin="round"
111+
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
112+
/>
113+
</motion.svg>
114+
) : (
115+
<motion.div
116+
key="system"
117+
initial={{ scale: 0.5, opacity: 0, rotate: -45 }}
118+
animate={{ scale: 1, opacity: 1, rotate: 0 }}
119+
exit={{ scale: 0.5, opacity: 0, rotate: 45 }}
120+
transition={{ duration: 0.2 }}
121+
className="relative flex items-center justify-center"
122+
>
123+
<svg
124+
xmlns="http://www.w3.org/2000/svg"
125+
fill="none"
126+
viewBox="0 0 24 24"
127+
strokeWidth={1.5}
128+
stroke="currentColor"
129+
className="h-5 w-5 text-slate-400 transition-colors"
130+
>
131+
<path
132+
strokeLinecap="round"
133+
strokeLinejoin="round"
134+
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
135+
/>
136+
</svg>
137+
<div className="absolute -bottom-0.5 -right-0.5 flex h-2 w-2 items-center justify-center">
138+
<div className={`h-1.5 w-1.5 rounded-full transition-colors ${
139+
resolvedTheme === 'dark' ? 'bg-accent' : 'bg-amber-500'
140+
}`} />
141+
</div>
142+
</motion.div>
143+
)}
144+
</AnimatePresence>
120145
</button>
121146
);
122147
}

frontend/src/lib/multisig-context.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ export function MultisigProvider({ children, networkPassphrase }: MultisigProvid
122122
return;
123123
}
124124

125+
const previousTransaction = { ...transaction, signers: [...transaction.signers] };
126+
125127
try {
126128
setIsLoading(true);
127129
clearError();
@@ -136,6 +138,12 @@ export function MultisigProvider({ children, networkPassphrase }: MultisigProvid
136138
throw new Error("Signer has already signed");
137139
}
138140

141+
// Optimistic update
142+
const optimisticSigners = transaction.signers.map(s =>
143+
s.id === signerId ? { ...s, hasSigned: true } : s
144+
);
145+
setTransactionSafe({ ...transaction, signers: optimisticSigners });
146+
139147
// Simulate signing process (in real implementation, this would interact with wallet)
140148
await new Promise(resolve => setTimeout(resolve, 1000));
141149

@@ -164,6 +172,8 @@ export function MultisigProvider({ children, networkPassphrase }: MultisigProvid
164172
}
165173

166174
} catch (err) {
175+
// Revert optimistic update
176+
setTransactionSafe(previousTransaction);
167177
const errorMessage = err instanceof Error ? err.message : "Failed to sign transaction";
168178
setError(errorMessage);
169179
console.error("Signing error:", err);

frontend/src/lib/theme-context.test.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,13 @@ describe("Theme Context", () => {
193193
});
194194

195195
describe("Error Handling", () => {
196-
it("handles localStorage errors gracefully", async () => {
196+
it("handles localStorage errors gracefully and reverts optimistic updates", async () => {
197197
const mockSetItem = globalThis.localStorage.setItem as jest.Mock;
198198
mockSetItem.mockImplementation(() => {
199199
throw new Error("Storage error");
200200
});
201201

202-
renderWithThemeProvider();
202+
renderWithThemeProvider({ defaultTheme: "system" });
203203

204204
await waitFor(() => {
205205
expect(screen.getByTestId("is-mounted")).toHaveTextContent("true");
@@ -209,6 +209,8 @@ describe("Theme Context", () => {
209209

210210
await waitFor(() => {
211211
expect(screen.getByTestId("error")).toHaveTextContent("Storage error");
212+
// Theme should be reverted to the previous state (system)
213+
expect(screen.getByTestId("theme")).toHaveTextContent("system");
212214
});
213215
});
214216

frontend/src/lib/theme-context.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@ export function ThemeProvider({
4545
}, []);
4646

4747
const setTheme = useCallback((newTheme: ThemeMode) => {
48+
const previousTheme = theme;
49+
const previousResolved = resolvedTheme;
50+
4851
try {
49-
setIsLoading(true);
5052
setThemeState(newTheme);
5153

5254
if (typeof globalThis !== "undefined" && globalThis.window) {
@@ -69,13 +71,21 @@ export function ThemeProvider({
6971

7072
setError(null);
7173
} catch (err) {
74+
// Revert optimistic update
75+
setThemeState(previousTheme !== undefined ? previousTheme : defaultTheme);
76+
if (previousResolved) {
77+
setResolvedTheme(previousResolved);
78+
if (typeof globalThis !== "undefined" && globalThis.window) {
79+
globalThis.document.documentElement.classList.remove("light", "dark");
80+
globalThis.document.documentElement.classList.add(previousResolved);
81+
}
82+
}
83+
7284
const errorMessage = err instanceof Error ? err.message : "Failed to set theme";
7385
setError(errorMessage);
7486
console.error("Theme setting error:", err);
75-
} finally {
76-
setIsLoading(false);
7787
}
78-
}, [storageKey]);
88+
}, [storageKey, theme, resolvedTheme, defaultTheme]);
7989

8090
const toggleTheme = useCallback(() => {
8191
const themes: ThemeMode[] = ["light", "dark", "system"];

frontend/tsconfig.tsbuildinfo

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)