Skip to content

Commit 222d817

Browse files
authored
Merge pull request #698 from ShantelPeters/a11y/contrast-verify-and-correct-badge
A11y/contrast verify and correct badge
2 parents fade45f + 6def22e commit 222d817

7 files changed

Lines changed: 385 additions & 6 deletions

File tree

docs/pairs.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Pairs
2+
3+
`PairsClient` (`src/app/pairs/Client.tsx`) is the client-side view for managing
4+
source→destination routing pairs. It fetches the pair list, displays them
5+
grouped by source asset, and exposes filtering, column visibility, copy, and
6+
delete actions.
7+
8+
## Props
9+
10+
`PairsClient` takes no props. It is the default export of `Client.tsx` and is
11+
rendered by the RSC wrapper `src/app/pairs/page.tsx`.
12+
13+
## States
14+
15+
| State | Description |
16+
|-----------|-----------------------------------------------------------------------------|
17+
| **Loading** | A `<Spinner>` and "Loading…" text are shown inside the polite live region. |
18+
| **Error** | An `role="alert"` paragraph with the error message is rendered above the list region. |
19+
| **Empty (no pairs)** | `<EmptyState title="No pairs registered yet" …>` — the API returned an empty array. |
20+
| **Empty (filter match)** | `<EmptyState title="No pairs found" …>` — pairs exist but the current filter excludes all of them. |
21+
| **Success** | Pairs grouped by source asset under `<h2>` headings, with per-pair Quote / Details / Copy / Delete controls. |
22+
23+
States are mutually exclusive and live in a single `aria-live="polite"` region
24+
so screen readers announce transitions automatically.
25+
26+
## Key behaviours
27+
28+
### Filtering
29+
The search input (`placeholder="Search by asset code"`) filters pairs by
30+
matching the query against both the source and destination fields. The
31+
`filterPairs` helper in `pairsUtils.ts` implements the case-insensitive match.
32+
33+
### Grouping
34+
`groupBySource` (also in `pairsUtils.ts`) groups matching pairs by source and
35+
sorts both the source keys and their destination arrays alphabetically.
36+
37+
### Memoization
38+
Both derivations are wrapped in `useMemo` keyed on `[pairs, query]`:
39+
40+
```tsx
41+
const filtered = useMemo(() => (pairs ? filterPairs(pairs, query) : null), [pairs, query]);
42+
const grouped = useMemo(() => (filtered ? groupBySource(filtered) : []), [filtered]);
43+
```
44+
45+
Unrelated state changes — opening the delete dialog, a clipboard copy in
46+
progress — do not trigger refiltering or regrouping.
47+
48+
### Copy pair symbol
49+
Clicking **Copy** writes `"<source>/<destination>"` to the clipboard via
50+
`writeToClipboard`. If the Clipboard API is unavailable or the write is denied,
51+
a read-only `<textarea>` fallback is rendered inline so the user can select and
52+
copy manually.
53+
54+
### Delete
55+
Clicking **Delete** opens a `<ConfirmDialog>`. Confirming calls `apiDelete` and
56+
then re-fetches the list.
57+
58+
### Column visibility
59+
The **Columns** toggle menu (rendered by `<ColumnVisibilityToggle>`) lets users
60+
show or hide the **Source**, **Destination**, and **Actions** columns. Preferences
61+
are persisted to `localStorage` under the key exported from
62+
`src/lib/columnVisibility.ts`. At least one column is always visible.
63+
64+
## Accessibility
65+
66+
- The loading/empty/success content lives inside a single `<section aria-live="polite" aria-busy={…}>`.
67+
- Errors use `role="alert"` and are rendered outside the live region so they are announced immediately.
68+
- Copy buttons carry `aria-label="Copy pair symbol <source>/<destination>"`.
69+
- The fallback textarea is labelled `aria-label="Pair symbol <source>/<destination>"` and auto-selects on focus.
70+
71+
## Usage example
72+
73+
```tsx
74+
// src/app/pairs/page.tsx (RSC wrapper — do not add 'use client' here)
75+
import PairsClient from './Client';
76+
export default function PairsPage() {
77+
return <PairsClient />;
78+
}
79+
```
80+
81+
## Related utilities
82+
83+
| File | Role |
84+
|------|------|
85+
| `src/app/pairs/pairsUtils.ts` | `filterPairs`, `groupBySource` |
86+
| `src/app/pairs/PairsDrawer.tsx` | Detail drawer shown by the Details button |
87+
| `src/lib/useApi.ts` | Data-fetching hook |
88+
| `src/lib/clipboard.ts` | `writeToClipboard` with Clipboard API + fallback |
89+
| `src/lib/columnVisibility.ts` | `useColumnVisibility`, `STORAGE_KEY` |

docs/settings.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Settings
2+
3+
`SettingsClient` (`src/app/settings/Client.tsx`) is the client-side view for
4+
user-facing configuration options. It groups three functional areas: theme
5+
selection, a live preview of the resolved appearance, and the router status card
6+
(which shows whether the API router is running or paused and lets the user
7+
refresh it).
8+
9+
## Props
10+
11+
`SettingsClient` takes no props. It is the default export of `Client.tsx` and
12+
is rendered by the RSC wrapper `src/app/settings/page.tsx`.
13+
14+
## Sub-components
15+
16+
### `RouterStatusRow`
17+
18+
Fetches `GET /api/v1/admin/status` via `useApi` and displays the router state.
19+
20+
| API status | Rendered content |
21+
|------------|------------------|
22+
| `loading` | "Loading…" text |
23+
| `error` | Error message with `role="alert"` |
24+
| `success` | "Router is **Live**" or "Router is **Paused**" |
25+
26+
A **Refresh** button always renders alongside the status text; it calls
27+
`refetch` from `useApi`.
28+
29+
### `AppearancePreview`
30+
31+
Renders a small styled panel that reflects the currently-resolved theme
32+
(`'light'` or `'dark'`). It reads the theme from `localStorage` via
33+
`readTheme()` and converts it to the resolved value via `effectiveTheme()`,
34+
which calls `window.matchMedia` for the `'system'` setting.
35+
36+
The component listens to the `storage` window event so it updates automatically
37+
when another tab — or `<ThemeToggle>` in the same page — writes a new theme
38+
value. The resolved theme is exposed via `data-resolved-theme` on the preview
39+
`<div>` for testing.
40+
41+
### `ApiBaseRow`
42+
43+
Displays the configured API base URL (from `getApiBase()` in
44+
`src/lib/config.ts`) in a monospaced paragraph with `data-testid="api-base-value"`.
45+
The value is read from `NEXT_PUBLIC_STABLEROUTE_API_BASE`, falling back to the
46+
compiled-in `DEFAULT_API_BASE`. Trailing slashes are stripped.
47+
48+
## Theme selection
49+
50+
`<ThemeToggle>` renders three segmented buttons: **Light**, **Dark**, and
51+
**System**. Clicking a button:
52+
53+
1. Writes the value (`'light'`, `'dark'`, or `'system'`) to `localStorage` under
54+
the key `'stableroute.theme'`.
55+
2. Sets `aria-pressed="true"` on the active button and `false` on the others.
56+
57+
The storage key is documented in `docs/theme-storage.md`.
58+
59+
## Accessibility
60+
61+
- The Appearance section wraps `<ThemeToggle>` in a `<section>` element; `ThemeToggle` itself uses `role="group" aria-label="Theme"` to group the three segmented buttons.
62+
- Router status errors use `role="alert"`.
63+
- The **Refresh** button has a visible focus ring via
64+
`focus-visible:outline-blue-500`.
65+
- `AppearancePreview` updates reactively, so the `data-resolved-theme`
66+
attribute always matches the visually rendered colours.
67+
68+
## Usage example
69+
70+
```tsx
71+
// src/app/settings/page.tsx (RSC wrapper)
72+
import SettingsClient from './Client';
73+
export default function SettingsPage() {
74+
return <SettingsClient />;
75+
}
76+
```
77+
78+
## Related files
79+
80+
| File | Role |
81+
|------|------|
82+
| `src/lib/theme.ts` | `readTheme`, `effectiveTheme`, `Theme` type |
83+
| `src/lib/config.ts` | `getApiBase`, `DEFAULT_API_BASE` |
84+
| `src/lib/useApi.ts` | `useApi` hook used in `RouterStatusRow` |
85+
| `src/lib/validate.ts` | `isRouterStatus` — runtime validator |
86+
| `src/components/ThemeToggle.tsx` | Segmented theme selector |
87+
| `src/components/Card.tsx` | Card wrapper used by all three sub-components |
88+
| `docs/theme-storage.md` | `localStorage` key and theme-resolution rules |

docs/stats.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Stats
2+
3+
`StatsClient` (`src/app/stats/Client.tsx`) is the client-side view for the
4+
router metrics dashboard. It polls `/api/v1/stats` on a back-off schedule,
5+
displays live pair-count and pause/live status tiles, and lets users export a
6+
point-in-time snapshot as JSON or CSV.
7+
8+
## Props
9+
10+
`StatsClient` takes no props. It is the default export of `Client.tsx` and is
11+
rendered by the RSC wrapper `src/app/stats/page.tsx`.
12+
13+
## States
14+
15+
| State | Description |
16+
|-----------|---------------------------------------------------------------------------------|
17+
| **Loading** | `<Spinner label="Loading stats" />` and "Loading…" text inside the live region. |
18+
| **Error** | A styled card with `role="alert"` shows the error message, a note that retry is automatic, and a manual **Retry** button. |
19+
| **Empty** | `<EmptyState title="No stats available yet" …>` — API returned `totalPairs: 0`. |
20+
| **Success** | A `<dl>` grid of `<StatTile>` tiles (Pairs count, Live/Paused status), a freshness timestamp, and Download JSON / Download CSV buttons. |
21+
22+
States are mutually exclusive within a single `aria-live="polite" aria-atomic="true"` section.
23+
24+
## Polling
25+
26+
Stats are refreshed automatically using `useBackoffInterval`:
27+
28+
- On success: next poll fires after `POLL_MS` (5 s default).
29+
- On error: the delay doubles on each consecutive failure (10 s → 20 s → 40 s), capped at `MAX_POLL_MS` (60 s).
30+
- A successful response resets the failure counter and restores the 5 s cadence.
31+
- Unmounting the component cancels any pending poll timer.
32+
33+
### Injecting timers for tests
34+
35+
`useBackoffInterval` accepts optional `schedule` / `cancel` functions so tests
36+
can drive timing without real timers:
37+
38+
```tsx
39+
useBackoffInterval(status, refetch, {
40+
baseMs: 5_000,
41+
maxMs: 60_000,
42+
schedule: (cb, ms) => setTimeout(cb, ms),
43+
cancel: clearTimeout,
44+
});
45+
```
46+
47+
## Freshness label
48+
49+
A `<LastUpdated>` sub-component re-renders every second to display how long ago
50+
the last successful response was received (e.g. "just now", "12s ago"). The
51+
`formatStatsAge(deltaMs)` helper is exported for unit testing.
52+
53+
## Export
54+
55+
See [`docs/stats-export.md`](./stats-export.md) for the full snapshot shape,
56+
serialisation, and download implementation details.
57+
58+
## Accessibility
59+
60+
- Loading / empty / success live in a single `aria-live="polite" aria-atomic="true"` region.
61+
- The error card wraps its content in `role="alert"` and is rendered **outside** the live region so it announces immediately.
62+
- The **Retry** button in the error card is keyboard-operable (`<Button type="button">Retry</Button>`).
63+
- The metrics panel is a `<section aria-labelledby="stats-metrics-heading">` with a visually-hidden `<h2>`.
64+
65+
## Exported symbols
66+
67+
All of these are exported from `src/app/stats/Client.tsx` and are unit-tested
68+
in `page.test.tsx`.
69+
70+
| Symbol | Kind | Purpose |
71+
|--------|------|---------|
72+
| `useBackoffInterval` | hook | Schedules the next poll after each settled request |
73+
| `formatStatsAge` | function | Formats a millisecond delta as a human-readable age string |
74+
| `buildStatsSnapshot` | function | Converts live `Stats` data to a typed snapshot object |
75+
| `statsSnapshotToJson` | function | Serialises a snapshot to pretty-printed JSON |
76+
| `statsSnapshotToCsv` | function | Serialises a snapshot to CSV |
77+
| `downloadStatsSnapshot` | function | Triggers a browser file download (JSON or CSV) |
78+
| `BackoffIntervalOptions` | type | Options accepted by `useBackoffInterval` |
79+
| `StatsSnapshot` | type | Snapshot shape |
80+
| `StatsSnapshotMetric` | type | Per-metric shape within a snapshot |
81+
82+
## Usage example
83+
84+
```tsx
85+
// src/app/stats/page.tsx (RSC wrapper)
86+
import StatsClient from './Client';
87+
export default function StatsPage() {
88+
return <StatsClient />;
89+
}
90+
```
91+
92+
## Related files
93+
94+
| File | Role |
95+
|------|------|
96+
| `src/lib/useApi.ts` | `useApi` hook used for the initial and manual refetch |
97+
| `src/lib/format.ts` | `formatNumber`, `formatTimestamp` |
98+
| `src/lib/validate.ts` | `isStats` — runtime response validator |
99+
| `src/components/StatTile.tsx` | Metric tile rendered in the success state |

docs/webhooks.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Webhooks
2+
3+
`WebhooksClient` (`src/app/webhooks/Client.tsx`) is the client-side view for
4+
managing webhook subscriptions. It loads the registered webhook list, lets users
5+
register new endpoints with selected event types, and supports removing existing
6+
webhooks with a confirmation dialog.
7+
8+
## Props
9+
10+
`WebhooksClient` takes no props. It is the default export of `Client.tsx` and
11+
is rendered by the RSC wrapper `src/app/webhooks/page.tsx`.
12+
13+
## States
14+
15+
The list panel is controlled by a `useList` hook and transitions through four
16+
states:
17+
18+
| State | Description |
19+
|-------------|----------------------------------------------------------------------------------|
20+
| **Loading** | "Loading…" text inside the `aria-live="polite"` region; `aria-busy="true"`. |
21+
| **Error** | A styled alert card with the error message and a keyboard-accessible **Retry** button. |
22+
| **Empty** | `<EmptyState title="No webhooks registered" …>` prompts the user to use the registration form. |
23+
| **Success** | A `<ResourceList>` table of registered webhooks with URL, event badges, registration time, and a remove icon button. |
24+
25+
States are mutually exclusive and rendered inside a single `aria-live="polite" aria-atomic="true"` region.
26+
27+
## Registration form
28+
29+
The form is always visible above the list. Fields:
30+
31+
| Field | Type | Notes |
32+
|-------|------|-------|
33+
| **URL** | `<TextField type="url">` | Must use `https:` — validated client-side before the confirmation dialog. |
34+
| **Events** | Checkbox group | At least one event must be selected. Defaults to `pair.registered`. |
35+
36+
Form submission opens a `<ConfirmDialog>`. Clicking **Confirm** calls
37+
`apiPost('/api/v1/webhooks', { url, events })` and, on success, clears the URL
38+
field and reloads the list.
39+
40+
Available events are sourced from `WEBHOOK_EVENT_OPTIONS` in
41+
`src/lib/webhookEvents.ts`.
42+
43+
### Validation
44+
45+
Client-side validation runs in `registerWebhook()` before the POST:
46+
47+
1. `isHttpsUrl(url)` — rejects non-HTTPS URLs.
48+
2. `selectedEvents.length > 0` — rejects empty event selection.
49+
50+
Validation errors are shown via `role="alert"` beneath the submit button.
51+
52+
## Remove flow
53+
54+
Each webhook row has a **Remove** `<IconButton>` (rendered by `<ResourceList>`).
55+
Clicking it opens a confirmation dialog; confirming calls
56+
`apiDelete('/api/v1/webhooks/:id')` and reloads the list.
57+
58+
## Accessibility
59+
60+
- List loading / empty / error states live inside a single `aria-live="polite" aria-atomic="true"` `<div>`.
61+
- The error card uses `role="alert"` and contains a **Retry** button reachable by keyboard.
62+
- The **Register** button has `aria-busy` set while submitting.
63+
- Event checkboxes are wrapped in a `<fieldset>` / `<legend>` group.
64+
- The webhook table has a `caption` and explicit `scope` attributes on column headers (see `<ResourceList>`).
65+
66+
## Local error state
67+
68+
`localError` accumulates validation and server errors from the registration
69+
flow. It is distinct from the list-load error surfaced by `useList`. Both use
70+
`role="alert"`, but `localError` is scoped to the form.
71+
72+
## Usage example
73+
74+
```tsx
75+
// src/app/webhooks/page.tsx (RSC wrapper)
76+
import WebhooksClient from './Client';
77+
export default function WebhooksPage() {
78+
return <WebhooksClient />;
79+
}
80+
```
81+
82+
## Related files
83+
84+
| File | Role |
85+
|------|------|
86+
| `src/lib/useList.ts` | `useList` hook — loads the webhook list |
87+
| `src/lib/webhookEvents.ts` | `WEBHOOK_EVENT_OPTIONS` constant |
88+
| `src/lib/apiClient.ts` | `apiGet`, `apiPost`, `apiDelete` |
89+
| `src/components/ResourceList.tsx` | Responsive list/table with built-in remove dialog |
90+
| `src/lib/validate.ts` | `isWebhookListResponse` — runtime validator |

src/app/quote/QuoteHistory.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@ describe('QuoteHistory Component', () => {
4545
let renderCount = 0;
4646

4747
// Component wrapper that tracks render count of memoized QuoteHistory
48-
const TrackedHistory = React.memo(function TrackedHistory(
49-
props: React.ComponentProps<typeof QuoteHistory>
50-
) {
48+
const TrackedHistoryInner = (props: React.ComponentProps<typeof QuoteHistory>) => {
5149
renderCount++;
5250
return <QuoteHistory {...props} />;
53-
});
51+
};
52+
TrackedHistoryInner.displayName = 'TrackedHistory';
53+
const TrackedHistory = React.memo(TrackedHistoryInner);
5454

5555
const ParentComponent = () => {
5656
const [dummyState, setDummyState] = useState(0);

0 commit comments

Comments
 (0)