Skip to content

Commit 6e9d7e8

Browse files
kpoinealCopilot
andcommitted
a11y(downloads): expose role=progressbar with valuenow/name + status announcements
Closes #2342 - DownloadManager.tsx: replace bare aria-label="NN%" div with proper role="progressbar" + aria-valuenow / aria-valuemin=0 / aria-valuemax=100. aria-label now includes the model name e.g. "Downloading Llama-3.1-8B: 42%". Visual percent span gets aria-hidden="true" to prevent double-reading. - Added always-present sr-only role="status" aria-live="polite" aria-atomic="true" live region inside the panel. Announces status transitions (start / complete / error / cancelled / paused / resumed) -- never fires on every percentage tick. Clears when the panel closes to prevent stale announcements on reopen. - Tests A35-A38 added (4 new): role=progressbar present, valuenow/min/max correct, model name in label, sr-only live region exists. - ACCESSIBILITY.md updated: #2342 entry, test count 34->38 in table. Test result: 54 passed, 7 skipped, 0 failed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 36e3de4 commit 6e9d7e8

3 files changed

Lines changed: 146 additions & 6 deletions

File tree

prototype/ui-redesign/ACCESSIBILITY.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,13 @@ Do these first. All are small changes with high compliance impact.
478478
21. **2.4** — Response verbosity preference in composer toolbar
479479
22. **2.8** — Full message role announcement polish (combined with Phase 2 article work)
480480

481+
### GUI3 A11y Series — targeted fixes (branches feat/gui3-*)
482+
483+
**#2342 — Download progress: native progressbar semantics + status announcements** ✅ DONE (2026-06-22, `feat/gui3-download-a11y`)
484+
- `DownloadManager.tsx` line ~283: replaced `<div aria-label="NN%">` with `role="progressbar"` + `aria-valuenow` / `aria-valuemin={0}` / `aria-valuemax={100}` + `aria-label` including the model name (`"Downloading Llama-3.1-8B: 42%"`). Visual `<span>` text gets `aria-hidden="true"` to avoid double-reading.
485+
- Added always-present sr-only `role="status" aria-live="polite" aria-atomic="true"` live region inside the panel. Announces status transitions only (start / complete / error / cancelled / paused / resumed) — never on every percentage tick. Cleared when panel closes to prevent stale re-reads on reopen.
486+
- Tests A35–A38 added (4 new tests): role/valuenow/min/max, model-name in label, live region present.
487+
481488
---
482489

483490
## Running the Accessibility Tests
@@ -510,7 +517,7 @@ npm test
510517

511518
> Playwright's `webServer` config in `playwright.config.ts` starts `npm run dev` automatically if nothing is already listening on port 8080. If you already have the dev server running, it reuses it (`reuseExistingServer: true`).
512519
513-
### Test groups (34 tests)
520+
### Test groups (38 tests)
514521

515522
| Group | Tests | What it checks |
516523
|-------|-------|----------------|
@@ -523,11 +530,12 @@ npm test
523530
| aria-live regions | A25–A27 | Assertive + polite regions in DOM at load; both are `.sr-only` |
524531
| :focus-visible rings | A28–A30 | Keyboard = outline present; mouse click = no ring; textarea keyboard ring present |
525532
| prefers-reduced-motion | A31–A34 | Bottom sheet transition near-zero; normal = 280ms; all transitions; `transform: none` snap |
533+
| Download progress bar | A35–A38 | `role="progressbar"` present; aria-valuenow/min/max correct; model name in label; sr-only status live region exists |
526534

527535
### Known limitation
528536

529537
Tests A25–A27 only verify that the aria-live regions **exist**. Verifying that the polite region receives debounced content during streaming requires mocking `POST /api/v1/chat/completions` with a chunked SSE response via `page.route()`. That mock infrastructure is tracked as a TODO in the test file.
530538

531539
---
532540

533-
*Last updated: 2026-06-14 by Mattingly*
541+
*Last updated: 2026-06-22 by Mattingly — added download progress bar semantics (#2342)*

prototype/ui-redesign/src/components/DownloadManager.tsx

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
import api, { friendlyErrorMessage } from '../api';
33
import { Icon } from './Icon';
4-
import { DownloadListItem, downloadStore, isDownloadActive } from '../features/downloadManager/downloadStore';
4+
import { DownloadListItem, DownloadStatus, downloadStore, isDownloadActive } from '../features/downloadManager/downloadStore';
55

66
interface DownloadManagerProps {
77
isVisible: boolean;
@@ -68,9 +68,52 @@ const DownloadManager: React.FC<DownloadManagerProps> = ({ isVisible, onClose })
6868
const [downloads, setDownloads] = useState<DownloadListItem[]>(() => downloadStore.snapshot());
6969
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
7070
const [busyIds, setBusyIds] = useState<Set<string>>(() => new Set());
71+
const prevStatusRef = useRef<Map<string, DownloadStatus> | null>(null);
72+
const [statusAnnouncement, setStatusAnnouncement] = useState('');
7173

7274
useEffect(() => downloadStore.subscribe(setDownloads), []);
7375

76+
// Announce status transitions (start/complete/error/pause/resume) to screen readers.
77+
// Runs on every downloads change but only emits announcements on status transitions,
78+
// never on every percentage tick.
79+
useEffect(() => {
80+
if (prevStatusRef.current === null) {
81+
// First run — initialise the map without announcing (avoids spurious on-mount reads).
82+
const initial = new Map<string, DownloadStatus>();
83+
for (const d of downloads) initial.set(d.id, d.status);
84+
prevStatusRef.current = initial;
85+
return;
86+
}
87+
const prev = prevStatusRef.current;
88+
let message = '';
89+
for (const d of downloads) {
90+
const prevStatus = prev.get(d.id);
91+
const name = displayName(d.modelName);
92+
if (!prevStatus && d.status === 'downloading') {
93+
message = `Downloading ${name} started`;
94+
} else if (prevStatus === 'downloading' && d.status === 'completed') {
95+
message = `${name} download complete`;
96+
} else if (prevStatus === 'downloading' && d.status === 'error') {
97+
message = `${name} download failed${d.error ? ': ' + d.error : ''}`;
98+
} else if (prevStatus === 'downloading' && d.status === 'cancelled') {
99+
message = `${name} download cancelled`;
100+
} else if (prevStatus === 'downloading' && d.status === 'paused') {
101+
message = `${name} download paused`;
102+
} else if (prevStatus === 'paused' && d.status === 'downloading') {
103+
message = `${name} download resumed`;
104+
}
105+
}
106+
const next = new Map<string, DownloadStatus>();
107+
for (const d of downloads) next.set(d.id, d.status);
108+
prevStatusRef.current = next;
109+
if (message) setStatusAnnouncement(message);
110+
}, [downloads]);
111+
112+
// Clear the announcement when the panel closes so stale text is not re-read on reopen.
113+
useEffect(() => {
114+
if (!isVisible) setStatusAnnouncement('');
115+
}, [isVisible]);
116+
74117
useEffect(() => {
75118
const onKey = (event: KeyboardEvent) => {
76119
if (event.key === 'Escape' && isVisible) onClose();
@@ -205,6 +248,9 @@ const DownloadManager: React.FC<DownloadManagerProps> = ({ isVisible, onClose })
205248
</button>
206249
</div>
207250

251+
{/* sr-only live region: announces status transitions without spamming every percent tick */}
252+
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">{statusAnnouncement}</div>
253+
208254
<div className="download-manager__body">
209255
{downloads.length === 0 ? (
210256
<div className="download-manager__empty">
@@ -280,9 +326,16 @@ const DownloadManager: React.FC<DownloadManagerProps> = ({ isVisible, onClose })
280326
</div>
281327

282328
{download.status === 'downloading' && (
283-
<div className="download-item__progress" aria-label={`${download.percent.toFixed(0)}%`}>
329+
<div
330+
className="download-item__progress"
331+
role="progressbar"
332+
aria-valuenow={Math.round(download.percent)}
333+
aria-valuemin={0}
334+
aria-valuemax={100}
335+
aria-label={`Downloading ${displayName(download.modelName)}: ${Math.round(download.percent)}%`}
336+
>
284337
<div className="download-item__progress-track"><div className="download-item__progress-fill" style={{ width: `${download.percent}%` }} /></div>
285-
<span>{download.percent.toFixed(0)}%</span>
338+
<span aria-hidden="true">{download.percent.toFixed(0)}%</span>
286339
</div>
287340
)}
288341

prototype/ui-redesign/tests/a11y.spec.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,3 +652,82 @@ test.describe('Accessibility — prefers-reduced-motion', () => {
652652
expect(transform).toBe('none');
653653
});
654654
});
655+
656+
// ─── 10. Download progress bar semantics (#2342) ──────────────────────────────
657+
658+
test.describe('Accessibility — download progress bar semantics', () => {
659+
// A valid DownloadListItem for a 42%-complete model download.
660+
// Passed to addInitScript so the singleton DownloadStore reads it from
661+
// localStorage before any React code runs (avoids poll-timing flakiness).
662+
const MOCK_DOWNLOAD = {
663+
id: 'model:Llama-3.1-8B',
664+
downloadType: 'model',
665+
modelName: 'Llama-3.1-8B',
666+
fileName: 'Llama-3.1-8B.gguf',
667+
fileIndex: 1,
668+
totalFiles: 1,
669+
bytesDownloaded: 420_000_000,
670+
bytesTotal: 1_000_000_000,
671+
bytesTotalIsLowerBound: false,
672+
percent: 42,
673+
status: 'downloading',
674+
startTime: 1_000_000_000_000,
675+
bytesResumed: 0,
676+
running: true,
677+
speedBytesPerSecond: 5_000_000,
678+
updatedAt: Date.now(),
679+
};
680+
681+
test.beforeEach(async ({ page }) => {
682+
// Pre-populate localStorage so the DownloadStore singleton (read at module init)
683+
// has an active downloading item before React renders anything.
684+
await page.addInitScript((item: unknown) => {
685+
localStorage.setItem('lemonade_download_manager_items_v1', JSON.stringify([item]));
686+
}, MOCK_DOWNLOAD);
687+
688+
await page.route('/api/v1/health', route =>
689+
route.fulfill({ json: { status: 'ok', all_models_loaded: [] } }),
690+
);
691+
// Return empty from the server so the mock item is not overwritten by polling.
692+
await page.route('/api/v1/downloads**', route =>
693+
route.fulfill({ json: { downloads: [] } }),
694+
);
695+
696+
await page.goto('/');
697+
await page.waitForSelector('.titlebar__nav');
698+
// Open the download manager via its titlebar toggle button.
699+
await page.locator('.titlebar__download-toggle').click();
700+
await page.waitForSelector('.download-manager__panel');
701+
// Ensure the download item row is rendered before we start asserting.
702+
await page.waitForSelector('.download-item--downloading', { timeout: 5000 });
703+
});
704+
705+
test('A35 — active download progress element has role="progressbar"', async ({ page }) => {
706+
const progressBar = page.locator('.download-manager__panel [role="progressbar"]').first();
707+
await expect(progressBar).toBeVisible();
708+
});
709+
710+
test('A36 — progressbar has aria-valuenow matching percent, aria-valuemin=0, aria-valuemax=100', async ({ page }) => {
711+
const progressBar = page.locator('.download-manager__panel [role="progressbar"]').first();
712+
const valuenow = await progressBar.getAttribute('aria-valuenow');
713+
const valuemin = await progressBar.getAttribute('aria-valuemin');
714+
const valuemax = await progressBar.getAttribute('aria-valuemax');
715+
expect(Number(valuenow)).toBe(42);
716+
expect(Number(valuemin)).toBe(0);
717+
expect(Number(valuemax)).toBe(100);
718+
});
719+
720+
test('A37 — progressbar aria-label includes the model name', async ({ page }) => {
721+
const progressBar = page.locator('.download-manager__panel [role="progressbar"]').first();
722+
const label = await progressBar.getAttribute('aria-label');
723+
expect(label).toBeTruthy();
724+
expect(label).toContain('Llama-3.1-8B');
725+
});
726+
727+
test('A38 — sr-only polite status live region is present inside the download manager panel', async ({ page }) => {
728+
const liveRegion = page.locator(
729+
'.download-manager__panel [role="status"][aria-live="polite"]',
730+
);
731+
await expect(liveRegion).toBeAttached();
732+
});
733+
});

0 commit comments

Comments
 (0)