From e9dc4c7e3c71f860dbe511a4171f52870ca00220 Mon Sep 17 00:00:00 2001
From: dimka90
Date: Sun, 30 Aug 2026 22:51:13 +0100
Subject: [PATCH] feat(frontend): WCAG 2.1 AA accessibility audit & remediation
(#1198)
- Add LiveValue throttled aria-live region for ticking balances
(IncomingStreamCard claimable, stream-details StatCard claimable) so
screen readers announce on user query rather than per frame.
- Respect prefers-reduced-motion in useStreamingAmount: compute accrued
value once instead of running the rAF loop.
- Global :focus-visible outline token for keyboard users (2.4.7).
- Fix dark-mode contrast for ticker labels, badges, and modal copy
(slate/emerald/amber/indigo utilities get dark: variants).
- give the clipboard icon button in StreamDetailsModal an accessible name.
- Replace invalid dl/dt/dd layout in IncomingStreamCard with div-based
structure to satisfy axe definition-list/dlitem rules.
- Add axe-core a11y suite (a11y.test.tsx) asserting zero critical/serious
violations across IncomingStreamCard, StreamDetailsModal, TopUpModal,
WalletModal; unit tests for LiveValue throttling.
---
frontend/src/__tests__/a11y.test.tsx | 139 ++++++++++++++++++
frontend/src/__tests__/live-value.test.tsx | 61 ++++++++
frontend/src/app/globals.css | 17 +++
.../streams/[id]/stream-details-content.tsx | 2 +
.../dashboard/StreamDetailsModal.tsx | 3 +-
.../components/streams/IncomingStreamCard.tsx | 46 +++---
frontend/src/components/ui/LiveValue.tsx | 53 +++++++
frontend/src/hooks/useStreamingAmount.ts | 11 +-
8 files changed, 308 insertions(+), 24 deletions(-)
create mode 100644 frontend/src/__tests__/a11y.test.tsx
create mode 100644 frontend/src/__tests__/live-value.test.tsx
create mode 100644 frontend/src/components/ui/LiveValue.tsx
diff --git a/frontend/src/__tests__/a11y.test.tsx b/frontend/src/__tests__/a11y.test.tsx
new file mode 100644
index 00000000..5338f51f
--- /dev/null
+++ b/frontend/src/__tests__/a11y.test.tsx
@@ -0,0 +1,139 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, cleanup } from "@testing-library/react";
+import axe, { type AxeResults } from "axe-core";
+
+vi.mock("@/context/wallet-context", () => ({
+ useWallet: () => ({
+ wallets: [],
+ status: "disconnected",
+ selectedWalletId: null,
+ errorMessage: null,
+ connect: vi.fn(),
+ clearError: vi.fn(),
+ isConnected: vi.fn().mockResolvedValue({ isConnected: false }),
+ }),
+}));
+
+vi.mock("@stellar/freighter-api", () => ({
+ isConnected: () => Promise.resolve({ isConnected: false }),
+}));
+
+// #1198 — Automated accessibility suite.
+//
+// Renders the primary interactive surfaces in happy-dom and asserts that axe
+// reports zero critical/serious violations. `color-contrast` and any rule that
+// depends on real browser layout (canvas-based color computation, native
+// widget rendering) is disabled here because happy-dom cannot reproduce
+// computed styles/canvas; the visual contrast audit is enforced separately via
+// the dark-mode token changes and a browser-based Playwright run.
+
+import { IncomingStreamCard } from "@/components/streams/IncomingStreamCard";
+import { StreamDetailsModal } from "@/components/dashboard/StreamDetailsModal";
+import { TopUpModal } from "@/components/stream-creation/TopUpModal";
+import { WalletModal } from "@/components/wallet/WalletModal";
+import type { IncomingStreamRecord } from "@/lib/api/streams";
+import type { Stream } from "@/lib/dashboard";
+
+const AXE_RULES = (() => {
+ const disabled: {
+ [key: string]: { enabled: false };
+ } = {
+ "color-contrast": { enabled: false },
+ };
+ return disabled;
+})();
+
+async function assertNoCriticalOrSerious(container: HTMLElement) {
+ const results: AxeResults = await axe.run(container, {
+ rules: AXE_RULES,
+ resultTypes: ["violations"],
+ });
+ const failures = results.violations.filter((v) =>
+ v.impact === "critical" || v.impact === "serious"
+ );
+ expect(
+ failures.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`),
+ ).toEqual([]);
+}
+
+const streamRecord: IncomingStreamRecord = {
+ id: "stream-1",
+ streamId: 1,
+ sender: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
+ senderDisplay: "alice*stellar",
+ token: "USDC",
+ tokenAddress: "CAS3FLKZ2N6YUFY66TKSXJQVOTLNOB4IIBW7YHDWQ7M5AGPB2QRUUAAA",
+ ratePerSecond: 0.5,
+ deposited: 1000,
+ withdrawn: 0,
+ startTime: Math.floor(Date.now() / 1000) - 3600,
+ lastUpdateTime: Math.floor(Date.now() / 1000),
+ isActive: true,
+ isPaused: false,
+ pausedAt: null,
+ totalPausedDuration: 0,
+ status: "Active",
+};
+
+const mockStream: Stream = {
+ id: "stream-1",
+ recipient: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
+ amount: 1000,
+ token: "USDC",
+ status: "Active",
+ deposited: 1000,
+ withdrawn: 250,
+ date: "2026-08-30",
+ ratePerSecond: 0.5,
+ lastUpdateTime: Math.floor(Date.now() / 1000),
+ isActive: true,
+};
+
+afterEach(() => cleanup());
+
+describe("Accessibility (axe-core) — critical & serious violations", () => {
+ it("IncomingStreamCard is accessible", async () => {
+ const { container } = render(
+ {}}
+ />,
+ );
+ await assertNoCriticalOrSerious(container);
+ });
+
+ it("StreamDetailsModal is accessible with focusable content", async () => {
+ const { container } = render(
+ {}}
+ onCancelClick={() => {}}
+ onTopUpClick={() => {}}
+ />,
+ );
+
+ expect(container.querySelector('[role="dialog"]')).not.toBeNull();
+ expect(container.querySelector('[aria-modal="true"]')).not.toBeNull();
+
+ await assertNoCriticalOrSerious(container);
+ });
+
+ it("TopUpModal is accessible", async () => {
+ const { container } = render(
+ Promise.resolve()}
+ onClose={() => {}}
+ />,
+ );
+ await assertNoCriticalOrSerious(container);
+ });
+
+ it("WalletModal is accessible", async () => {
+ const { container } = render( {}} />);
+ await assertNoCriticalOrSerious(container);
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/__tests__/live-value.test.tsx b/frontend/src/__tests__/live-value.test.tsx
new file mode 100644
index 00000000..2b3c5428
--- /dev/null
+++ b/frontend/src/__tests__/live-value.test.tsx
@@ -0,0 +1,61 @@
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { render, cleanup, act } from "@testing-library/react";
+import { LiveValue } from "@/components/ui/LiveValue";
+
+afterEach(() => cleanup());
+
+describe("LiveValue", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("renders a polite atomic live region with the current value", () => {
+ const { container } = render();
+ const region = container.querySelector('span[aria-live="polite"]');
+ expect(region).not.toBeNull();
+ expect(region?.getAttribute("aria-atomic")).toBe("true");
+ expect(region?.textContent).toBe("Claimable amount 12.5 USDC");
+ });
+
+ it("throttles announcements (at most one per cadence, not one per frame)", () => {
+ const { container, rerender } = render();
+ expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("1");
+
+ // Rapid value updates before the cadence elapses are not announced.
+ rerender();
+ rerender();
+ expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("1");
+
+ // After the cadence, the latest value is announced.
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("2.0001");
+
+ // An unchanged value does not re-announce.
+ rerender();
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("2.0001");
+ });
+
+ it("announces the settled value within one cadence after streaming stops", () => {
+ const { container, rerender } = render();
+
+ rerender();
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("1.0005");
+ });
+
+ it("provides no live region until a value is announced", () => {
+ const { container } = render();
+ expect(container.querySelector('[aria-live]')).toBeNull();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index d42f8839..3b1d35f9 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -1,6 +1,23 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
+/*
+ * #1198 — Visible keyboard focus indicator (WCAG 2.1 AA 2.4.7).
+ * Every interactive element must show a clear focus ring when reached via
+ * keyboard; component-level custom focus states complement rather than
+ * replace this baseline.
+ */
+:focus-visible {
+ outline: 2px solid var(--accent-secondary);
+ outline-offset: 2px;
+ border-radius: 2px;
+}
+
+/* Neutralize the default outline only when a richer ring is provided. */
+:focus-visible:has(.focus-ring) {
+ outline: none;
+}
+
:root {
--background: #020617;
--foreground: #f8fafc;
diff --git a/frontend/src/app/streams/[id]/stream-details-content.tsx b/frontend/src/app/streams/[id]/stream-details-content.tsx
index 63879898..b9f5b4fb 100644
--- a/frontend/src/app/streams/[id]/stream-details-content.tsx
+++ b/frontend/src/app/streams/[id]/stream-details-content.tsx
@@ -6,6 +6,7 @@ import { getApiBaseUrl } from "@/lib/api/_shared";
import { logger } from "@/lib/logger";
import { ArrowLeft, Pause, Play, X, Plus, Download, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/Button";
+import { LiveValue } from "@/components/ui/LiveValue";
import toast from "react-hot-toast";
import { useWallet } from "@/context/wallet-context";
import { useStreamEvents } from "@/hooks/useStreamEvents";
@@ -731,6 +732,7 @@ function StatCard({
{value}
{live && ●}
+ {live && }
);
}
diff --git a/frontend/src/components/dashboard/StreamDetailsModal.tsx b/frontend/src/components/dashboard/StreamDetailsModal.tsx
index 999891c2..a09c04a4 100644
--- a/frontend/src/components/dashboard/StreamDetailsModal.tsx
+++ b/frontend/src/components/dashboard/StreamDetailsModal.tsx
@@ -63,7 +63,8 @@ export const StreamDetailsModal: React.FC = ({
{stream.recipient}