Skip to content

Commit caa2b21

Browse files
committed
Merge branch 'pr-78'
2 parents 8670454 + 2123c4e commit caa2b21

9 files changed

Lines changed: 374 additions & 84 deletions

File tree

README.md

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,18 +86,9 @@ The frontend communicates with the StableRoute API backend.
8686

8787
## Accessibility
8888

89-
### Reduced Motion
90-
91-
The app respects the user's `prefers-reduced-motion` OS setting. When enabled, all CSS animations and transitions are disabled via a global media query in [`src/app/globals.css`](src/app/globals.css). The loading spinner retains its `role="status"` semantics so screen-reader users still receive feedback.
92-
93-
To test:
94-
- **Windows:** Settings → Ease of Access → Display → "Show animations in Windows"
95-
- **macOS:** System Settings → Accessibility → Display → "Reduce motion"
96-
- **Browser DevTools:** Ctrl+Shift+P → "Show Rendering" → Emulate CSS media feature `prefers-reduced-motion: reduce`
97-
9889
### ARIA Live Regions
9990

100-
Dynamic list updates (loading → loaded / loading → empty) on the pairs, events, api-keys, and webhooks pages are wrapped in `aria-live="polite"` regions so screen-reader users are notified when content arrives. Error messages continue to use `role="alert"` for assertive announcements.
91+
Dynamic list updates (loading → loaded / loading → empty) on the pairs, events, api-keys, and webhooks pages are wrapped in `aria-live="polite"` regions so screen-reader users are notified when content arrives. Error messages continue to use `role="alert"` for assertive announcements. A single polite region per page prevents double announcements.
10192

10293
## CI/CD
10394

src/app/api-keys/page.test.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import ApiKeysPage from "./page";
3+
4+
describe("ApiKeysPage", () => {
5+
let originalFetch: typeof globalThis.fetch;
6+
7+
beforeEach(() => {
8+
originalFetch = globalThis.fetch;
9+
});
10+
11+
afterEach(() => {
12+
globalThis.fetch = originalFetch;
13+
});
14+
15+
it("shows loading before data arrives", () => {
16+
globalThis.fetch = jest.fn(() => new Promise(() => {})) as unknown as typeof globalThis.fetch;
17+
render(<ApiKeysPage />);
18+
expect(screen.getByText("Loading…")).toBeInTheDocument();
19+
});
20+
21+
it("renders api keys in a single polite live region", async () => {
22+
globalThis.fetch = jest.fn().mockResolvedValueOnce({
23+
ok: true,
24+
json: async () => ({
25+
items: [{ prefix: "sk_abc", label: "Production", createdAt: Date.now() }],
26+
}),
27+
} as unknown as Response);
28+
29+
render(<ApiKeysPage />);
30+
await waitFor(() => {
31+
expect(screen.getByText("Production")).toBeInTheDocument();
32+
});
33+
const live = document.querySelector("[aria-live=polite]");
34+
expect(live).toBeInTheDocument();
35+
expect(live).toHaveAttribute("aria-atomic", "true");
36+
});
37+
38+
it("announces empty state via live region", async () => {
39+
globalThis.fetch = jest.fn().mockResolvedValueOnce({
40+
ok: true,
41+
json: async () => ({ items: [] }),
42+
} as unknown as Response);
43+
44+
render(<ApiKeysPage />);
45+
await waitFor(() => {
46+
expect(screen.getByText(/No API keys yet/i)).toBeInTheDocument();
47+
});
48+
});
49+
50+
it("surfaces errors with role=alert", async () => {
51+
globalThis.fetch = jest.fn().mockRejectedValueOnce(new Error("Unauthorized"));
52+
53+
render(<ApiKeysPage />);
54+
await waitFor(() => {
55+
expect(screen.getByRole("alert")).toHaveTextContent(/Unauthorized/i);
56+
});
57+
});
58+
59+
it("has exactly one aria-live=polite region", async () => {
60+
globalThis.fetch = jest.fn().mockResolvedValueOnce({
61+
ok: true,
62+
json: async () => ({ items: [] }),
63+
} as unknown as Response);
64+
65+
render(<ApiKeysPage />);
66+
await waitFor(() => {
67+
expect(screen.getByText(/No API keys yet/i)).toBeInTheDocument();
68+
});
69+
expect(document.querySelectorAll("[aria-live=polite]")).toHaveLength(1);
70+
});
71+
});

src/app/api-keys/page.tsx

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -63,25 +63,31 @@ export default function ApiKeysPage() {
6363
</div>
6464
)}
6565
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
66-
{items && (
67-
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
68-
{items.map((k) => (
69-
<li key={k.prefix} className="flex items-center justify-between py-3">
70-
<div>
71-
<p className="text-sm font-medium">{k.label}</p>
72-
<p className="font-mono text-xs text-neutral-500">{k.prefix}</p>
73-
</div>
74-
<button
75-
type="button"
76-
onClick={() => apiDelete(`/api/v1/api-keys/${k.prefix}`).then(() => load())}
77-
className="rounded border border-neutral-300 px-3 py-1 text-xs hover:border-rose-500 hover:text-rose-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700"
78-
>
79-
Revoke
80-
</button>
81-
</li>
82-
))}
83-
</ul>
84-
)}
66+
{!items && !error && <p>Loading…</p>}
67+
<section aria-live="polite" aria-atomic="true" className="contents">
68+
{items && items.length === 0 && (
69+
<p className="text-sm text-neutral-600 dark:text-neutral-400">No API keys yet.</p>
70+
)}
71+
{items && items.length > 0 && (
72+
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
73+
{items.map((k) => (
74+
<li key={k.prefix} className="flex items-center justify-between py-3">
75+
<div>
76+
<p className="text-sm font-medium">{k.label}</p>
77+
<p className="font-mono text-xs text-neutral-500">{k.prefix}</p>
78+
</div>
79+
<button
80+
type="button"
81+
onClick={() => apiDelete(`/api/v1/api-keys/${k.prefix}`).then(() => load())}
82+
className="rounded border border-neutral-300 px-3 py-1 text-xs hover:border-rose-500 hover:text-rose-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700"
83+
>
84+
Revoke
85+
</button>
86+
</li>
87+
))}
88+
</ul>
89+
)}
90+
</section>
8591
</main>
8692
);
8793
}

src/app/events/page.test.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import EventsPage from "./page";
3+
4+
describe("EventsPage", () => {
5+
let originalFetch: typeof globalThis.fetch;
6+
7+
beforeEach(() => {
8+
originalFetch = globalThis.fetch;
9+
});
10+
11+
afterEach(() => {
12+
globalThis.fetch = originalFetch;
13+
});
14+
15+
it("shows loading before data arrives", () => {
16+
globalThis.fetch = jest.fn(() => new Promise(() => {})) as unknown as typeof globalThis.fetch;
17+
render(<EventsPage />);
18+
expect(screen.getByText("Loading…")).toBeInTheDocument();
19+
});
20+
21+
it("renders events in a single polite live region", async () => {
22+
globalThis.fetch = jest.fn().mockResolvedValueOnce({
23+
ok: true,
24+
json: async () => ({
25+
items: [{ id: "evt1", ts: Date.now(), type: "pair.registered", payload: {} }],
26+
}),
27+
} as unknown as Response);
28+
29+
render(<EventsPage />);
30+
await waitFor(() => {
31+
expect(screen.getByText("pair.registered")).toBeInTheDocument();
32+
});
33+
const live = document.querySelector("[aria-live=polite]");
34+
expect(live).toBeInTheDocument();
35+
expect(live).toHaveAttribute("aria-atomic", "true");
36+
});
37+
38+
it("announces empty state via live region", async () => {
39+
globalThis.fetch = jest.fn().mockResolvedValueOnce({
40+
ok: true,
41+
json: async () => ({ items: [] }),
42+
} as unknown as Response);
43+
44+
render(<EventsPage />);
45+
await waitFor(() => {
46+
expect(screen.getByText(/No events/i)).toBeInTheDocument();
47+
});
48+
});
49+
50+
it("surfaces errors with role=alert", async () => {
51+
globalThis.fetch = jest.fn().mockRejectedValueOnce(new Error("Failed to load"));
52+
53+
render(<EventsPage />);
54+
await waitFor(() => {
55+
expect(screen.getByRole("alert")).toHaveTextContent(/Failed to load/i);
56+
});
57+
});
58+
59+
it("has exactly one aria-live=polite region", async () => {
60+
globalThis.fetch = jest.fn().mockResolvedValueOnce({
61+
ok: true,
62+
json: async () => ({ items: [] }),
63+
} as unknown as Response);
64+
65+
render(<EventsPage />);
66+
await waitFor(() => {
67+
expect(screen.getByText(/No events/i)).toBeInTheDocument();
68+
});
69+
expect(document.querySelectorAll("[aria-live=polite]")).toHaveLength(1);
70+
});
71+
});

src/app/events/page.tsx

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,27 +28,30 @@ export default function EventsPage() {
2828
>
2929
<h1 className="text-3xl font-semibold tracking-tight">Event log</h1>
3030
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
31-
{items && items.length === 0 && (
32-
<p className="text-sm text-neutral-600 dark:text-neutral-400">No events.</p>
33-
)}
34-
{items && items.length > 0 && (
35-
<ol className="flex flex-col gap-2">
36-
{items.map((e) => (
37-
<li
38-
key={e.id}
39-
className="rounded border border-neutral-200 p-3 font-mono text-xs dark:border-neutral-800"
40-
>
41-
<div className="flex justify-between text-neutral-500">
42-
<span>{e.type}</span>
43-
<span>{new Date(e.ts).toISOString()}</span>
44-
</div>
45-
<pre className="mt-2 whitespace-pre-wrap break-words">
46-
{JSON.stringify(e.payload, null, 2)}
47-
</pre>
48-
</li>
49-
))}
50-
</ol>
51-
)}
31+
<section aria-live="polite" aria-atomic="true" className="contents">
32+
{!items && !error && <p>Loading…</p>}
33+
{items && items.length === 0 && (
34+
<p className="text-sm text-neutral-600 dark:text-neutral-400">No events.</p>
35+
)}
36+
{items && items.length > 0 && (
37+
<ol className="flex flex-col gap-2">
38+
{items.map((e) => (
39+
<li
40+
key={e.id}
41+
className="rounded border border-neutral-200 p-3 font-mono text-xs dark:border-neutral-800"
42+
>
43+
<div className="flex justify-between text-neutral-500">
44+
<span>{e.type}</span>
45+
<span>{new Date(e.ts).toISOString()}</span>
46+
</div>
47+
<pre className="mt-2 whitespace-pre-wrap break-words">
48+
{JSON.stringify(e.payload, null, 2)}
49+
</pre>
50+
</li>
51+
))}
52+
</ol>
53+
)}
54+
</section>
5255
</main>
5356
);
5457
}

src/app/pairs/page.test.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import PairsPage from "./page";
3+
4+
describe("PairsPage", () => {
5+
let originalFetch: typeof globalThis.fetch;
6+
7+
beforeEach(() => {
8+
originalFetch = globalThis.fetch;
9+
});
10+
11+
afterEach(() => {
12+
globalThis.fetch = originalFetch;
13+
});
14+
15+
it("shows loading before data arrives", () => {
16+
globalThis.fetch = jest.fn(() => new Promise(() => {})) as unknown as typeof globalThis.fetch;
17+
render(<PairsPage />);
18+
expect(screen.getByText("Loading…")).toBeInTheDocument();
19+
});
20+
21+
it("renders pairs in a single polite live region", async () => {
22+
globalThis.fetch = jest.fn().mockResolvedValueOnce({
23+
ok: true,
24+
json: async () => ({ pairs: [{ source: "USDC", destination: "EURC" }] }),
25+
} as unknown as Response);
26+
27+
render(<PairsPage />);
28+
await waitFor(() => {
29+
expect(screen.getByText("USDC → EURC")).toBeInTheDocument();
30+
});
31+
const live = document.querySelector("[aria-live=polite]");
32+
expect(live).toBeInTheDocument();
33+
expect(live).toHaveAttribute("aria-atomic", "true");
34+
});
35+
36+
it("announces empty state via live region", async () => {
37+
globalThis.fetch = jest.fn().mockResolvedValueOnce({
38+
ok: true,
39+
json: async () => ({ pairs: [] }),
40+
} as unknown as Response);
41+
42+
render(<PairsPage />);
43+
await waitFor(() => {
44+
expect(screen.getByText(/No pairs registered yet/i)).toBeInTheDocument();
45+
});
46+
});
47+
48+
it("surfaces errors with role=alert", async () => {
49+
globalThis.fetch = jest.fn().mockRejectedValueOnce(new Error("Network error"));
50+
51+
render(<PairsPage />);
52+
await waitFor(() => {
53+
expect(screen.getByRole("alert")).toHaveTextContent(/Network error/i);
54+
});
55+
});
56+
57+
it("has exactly one aria-live=polite region", async () => {
58+
globalThis.fetch = jest.fn().mockResolvedValueOnce({
59+
ok: true,
60+
json: async () => ({ pairs: [] }),
61+
} as unknown as Response);
62+
63+
render(<PairsPage />);
64+
await waitFor(() => {
65+
expect(screen.getByText(/No pairs registered yet/i)).toBeInTheDocument();
66+
});
67+
expect(document.querySelectorAll("[aria-live=polite]")).toHaveLength(1);
68+
});
69+
});

src/app/pairs/page.tsx

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,21 +36,23 @@ export default function PairsPage() {
3636
{error}
3737
</p>
3838
)}
39-
{!pairs && !error && <p>Loading…</p>}
40-
{pairs && pairs.length === 0 && (
41-
<p className="text-sm text-neutral-600 dark:text-neutral-400">
42-
No pairs registered yet.
43-
</p>
44-
)}
45-
{pairs && pairs.length > 0 && (
46-
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
47-
{pairs.map((p) => (
48-
<li key={`${p.source}::${p.destination}`} className="py-3 font-mono text-sm">
49-
{p.source}{p.destination}
50-
</li>
51-
))}
52-
</ul>
53-
)}
39+
<section aria-live="polite" aria-atomic="true" className="contents">
40+
{!pairs && !error && <p>Loading…</p>}
41+
{pairs && pairs.length === 0 && (
42+
<p className="text-sm text-neutral-600 dark:text-neutral-400">
43+
No pairs registered yet.
44+
</p>
45+
)}
46+
{pairs && pairs.length > 0 && (
47+
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
48+
{pairs.map((p) => (
49+
<li key={`${p.source}::${p.destination}`} className="py-3 font-mono text-sm">
50+
{p.source}{p.destination}
51+
</li>
52+
))}
53+
</ul>
54+
)}
55+
</section>
5456
</main>
5557
);
5658
}

0 commit comments

Comments
 (0)