Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions frontend/src/__tests__/a11y.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<IncomingStreamCard
stream={{ ...streamRecord, isActive: false, status: "Completed" }}
withdrawing={false}
onWithdraw={() => {}}
/>,
);
await assertNoCriticalOrSerious(container);
});

it("StreamDetailsModal is accessible with focusable content", async () => {
const { container } = render(
<StreamDetailsModal
stream={mockStream}
onClose={() => {}}
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(
<TopUpModal
streamId="stream-1"
token="USDC"
currentDeposited={1000}
onConfirm={() => Promise.resolve()}
onClose={() => {}}
/>,
);
await assertNoCriticalOrSerious(container);
});

it("WalletModal is accessible", async () => {
const { container } = render(<WalletModal onClose={() => {}} />);
await assertNoCriticalOrSerious(container);
});
});
61 changes: 61 additions & 0 deletions frontend/src/__tests__/live-value.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LiveValue value="12.5 USDC" prefix="Claimable amount" />);
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(<LiveValue value="1" />);
expect(container.querySelector('span[aria-live="polite"]')?.textContent).toBe("1");

// Rapid value updates before the cadence elapses are not announced.
rerender(<LiveValue value="2" />);
rerender(<LiveValue value="2.0001" />);
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(<LiveValue value="2.0001" />);
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(<LiveValue value="1" />);

rerender(<LiveValue value="1.0005" />);
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(<LiveValue value="" />);
expect(container.querySelector('[aria-live]')).toBeNull();
});
});
17 changes: 17 additions & 0 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/streams/[id]/stream-details-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -731,6 +732,7 @@ function StatCard({
{value}
{live && <span className="ml-2 text-xs animate-pulse">●</span>}
</p>
{live && <LiveValue value={value} prefix={label} />}
</div>
);
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/dashboard/StreamDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ export const StreamDetailsModal: React.FC<StreamDetailsModalProps> = ({
<code className="text-sm text-accent truncate">{stream.recipient}</code>
<button
onClick={() => navigator.clipboard.writeText(stream.recipient)}
className="text-slate-500 hover:text-accent transition-colors"
aria-label="Copy recipient address"
className="text-slate-500 dark:text-slate-400 hover:text-accent transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
Expand Down
46 changes: 24 additions & 22 deletions frontend/src/components/streams/IncomingStreamCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React from "react";
import { Button } from "@/components/ui/Button";
import { LiveValue } from "@/components/ui/LiveValue";
import { useStreamingAmount } from "@/hooks/useStreamingAmount";
import type {
IncomingStreamRecord,
Expand All @@ -24,12 +25,12 @@ function formatTokenAmount(value: number, maximumFractionDigits = 7): string {
function badgeClassName(status: IncomingStreamStatus): string {
switch (status) {
case "Active":
return "bg-emerald-500/15 text-emerald-700";
return "bg-emerald-500/15 text-emerald-700 dark:bg-emerald-400/15 dark:text-emerald-300";
case "Paused":
return "bg-amber-500/15 text-amber-700";
return "bg-amber-500/15 text-amber-700 dark:bg-amber-400/15 dark:text-amber-300";
case "Completed":
default:
return "bg-slate-500/15 text-slate-700";
return "bg-slate-500/15 text-slate-700 dark:bg-slate-400/15 dark:text-slate-300";
}
}

Expand Down Expand Up @@ -59,13 +60,13 @@ export const IncomingStreamCard = React.memo(function IncomingStreamCard({
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-800/70">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-800 dark:text-sky-300">
Incoming stream
</p>
<h2 className="mt-2 text-lg font-semibold text-slate-900">
<h2 className="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">
{stream.senderDisplay}
</h2>
<p className="mt-1 text-sm text-slate-500">
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
Sender
</p>
</div>
Expand All @@ -76,38 +77,39 @@ export const IncomingStreamCard = React.memo(function IncomingStreamCard({
</span>
</div>

<dl className="mt-6 grid grid-cols-2 gap-4 text-sm text-slate-600">
<div className="mt-6 grid grid-cols-2 gap-4 text-sm text-slate-600 dark:text-slate-400">
<div className="rounded-2xl bg-slate-900/5 p-4">
<dt className="text-xs uppercase tracking-[0.16em] text-slate-500">
<p className="text-xs uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
Token
</dt>
<dd className="mt-2 text-base font-semibold text-slate-900">
</p>
<p className="mt-2 text-base font-semibold text-slate-900 dark:text-slate-100">
{stream.token}
</dd>
</p>
</div>
<div className="rounded-2xl bg-slate-900/5 p-4">
<dt className="text-xs uppercase tracking-[0.16em] text-slate-500">
<p className="text-xs uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
Rate
</dt>
<dd className="mt-2 text-base font-semibold text-slate-900">
</p>
<p className="mt-2 text-base font-semibold text-slate-900 dark:text-slate-100">
{formatTokenAmount(stream.ratePerSecond)} / sec
</dd>
</p>
</div>
<div className="col-span-2 rounded-[1.5rem] bg-gradient-to-r from-emerald-500/12 to-sky-500/10 p-4">
<dt className="text-xs uppercase tracking-[0.16em] text-slate-500">
<p className="text-xs uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
Claimable amount
</dt>
<dd className="mt-2 text-2xl font-semibold text-slate-900">
</p>
<p className="mt-2 text-2xl font-semibold text-slate-900 dark:text-slate-100">
{formatTokenAmount(claimable)} {stream.token}
</dd>
<p className="mt-1 text-sm text-slate-500">
</p>
<LiveValue value={`${formatTokenAmount(claimable)} ${stream.token}`} prefix="Claimable amount" />
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
Stream #{stream.streamId}
</p>
</div>
</dl>
</div>

<div className="mt-6 flex items-center justify-between gap-3">
<div className="text-xs text-slate-500">
<div className="text-xs text-slate-600 dark:text-slate-400">
{stream.status === "Paused"
? "Withdrawals resume once the stream is active again."
: stream.status === "Completed"
Expand Down
Loading
Loading