Skip to content
Merged
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
12 changes: 10 additions & 2 deletions prototype/ui-redesign/ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,13 @@ All five items from the blind NVDA screen-reader user's feedback on UI 3 beta:
26. **#2350** ✅ DONE (revised) — Capability chip container changed from `role="radiogroup"` to `role="group" aria-label="Applies to capabilities"`; each chip button changed from `role="radio" aria-checked={…}` to `aria-pressed={…}` (toggle-button semantics). Radiogroup requires arrow-key navigation (ARIA APG / WCAG 2.1.1); toggle buttons are keyboard-correct with Tab + Enter/Space. File: `PresetManager.tsx` lines ~937–943.
27. **#2352** ✅ DONE — AutoOpt run buttons gain `aria-pressed={selectedAutoRunId === run.id}`, updated on selection change. File: `PresetManager.tsx` line ~528.

### GUI3 A11y Series — targeted fixes (branches feat/gui3-*)

**#2342 — Download progress: native progressbar semantics + status announcements** ✅ DONE (2026-06-22, `feat/gui3-download-a11y`)
- `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.
- 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.
- Tests A59–A62 added (4 new tests): role/valuenow/min/max, model-name in label, live region present.

---

## Running the Accessibility Tests
Expand Down Expand Up @@ -566,7 +573,7 @@ npm test

> 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`).

### Test groups (63 tests)
### Test groups (67 tests)

| Group | Tests | What it checks |
|-------|-------|----------------|
Expand All @@ -586,11 +593,12 @@ npm test
| AutoOpt selection state (#2352) | A44–A45 | aria-pressed exposed; updates on click |
| Backend matrix + action/live regions | A51–A58 | Matrix cell buttons expose selection + labels; action buttons include recipe/backend; persistent status live regions exist |
| Model row qualified names | A46–A50 | Load/Delete/Download/Get&Load buttons include model name in aria-label; no bare generic names |
| Download progress bar | A59–A62 | `role="progressbar"` present; aria-valuenow/min/max correct; model name in label; sr-only status live region exists |

### Known limitation

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.

---

*Last updated: 2026-06-22 by Mattingly (GUI3 preset a11y items #2338 #2339 #2345 #2350 #2352; BackendManager #2343 #2344 #2351; model row qualified names #2341)*
*Last updated: 2026-06-22 by Mattingly (GUI3 preset a11y items #2338 #2339 #2345 #2350 #2352; BackendManager #2343 #2344 #2351; model row qualified names #2341; download progress bar semantics #2342)*
61 changes: 57 additions & 4 deletions prototype/ui-redesign/src/components/DownloadManager.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import api, { friendlyErrorMessage } from '../api';
import { Icon } from './Icon';
import { DownloadListItem, downloadStore, isDownloadActive } from '../features/downloadManager/downloadStore';
import { DownloadListItem, DownloadStatus, downloadStore, isDownloadActive } from '../features/downloadManager/downloadStore';

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

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

// Announce status transitions (start/complete/error/pause/resume) to screen readers.
// Runs on every downloads change but only emits announcements on status transitions,
// never on every percentage tick.
useEffect(() => {
if (prevStatusRef.current === null) {
// First run — initialise the map without announcing (avoids spurious on-mount reads).
const initial = new Map<string, DownloadStatus>();
for (const d of downloads) initial.set(d.id, d.status);
prevStatusRef.current = initial;
return;
}
const prev = prevStatusRef.current;
let message = '';
for (const d of downloads) {
const prevStatus = prev.get(d.id);
const name = displayName(d.modelName);
if (!prevStatus && d.status === 'downloading') {
message = `Downloading ${name} started`;
} else if (prevStatus === 'downloading' && d.status === 'completed') {
message = `${name} download complete`;
} else if (prevStatus === 'downloading' && d.status === 'error') {
message = `${name} download failed${d.error ? ': ' + d.error : ''}`;
} else if (prevStatus === 'downloading' && d.status === 'cancelled') {
message = `${name} download cancelled`;
} else if (prevStatus === 'downloading' && d.status === 'paused') {
message = `${name} download paused`;
} else if (prevStatus === 'paused' && d.status === 'downloading') {
message = `${name} download resumed`;
}
}
const next = new Map<string, DownloadStatus>();
for (const d of downloads) next.set(d.id, d.status);
prevStatusRef.current = next;
if (message) setStatusAnnouncement(message);
}, [downloads]);

// Clear the announcement when the panel closes so stale text is not re-read on reopen.
useEffect(() => {
if (!isVisible) setStatusAnnouncement('');
}, [isVisible]);

useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isVisible) onClose();
Expand Down Expand Up @@ -205,6 +248,9 @@ const DownloadManager: React.FC<DownloadManagerProps> = ({ isVisible, onClose })
</button>
</div>

{/* sr-only live region: announces status transitions without spamming every percent tick */}
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">{statusAnnouncement}</div>

<div className="download-manager__body">
{downloads.length === 0 ? (
<div className="download-manager__empty">
Expand Down Expand Up @@ -280,9 +326,16 @@ const DownloadManager: React.FC<DownloadManagerProps> = ({ isVisible, onClose })
</div>

{download.status === 'downloading' && (
<div className="download-item__progress" aria-label={`${download.percent.toFixed(0)}%`}>
<div
className="download-item__progress"
role="progressbar"
aria-valuenow={Math.round(download.percent)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Downloading ${displayName(download.modelName)}: ${Math.round(download.percent)}%`}
>
<div className="download-item__progress-track"><div className="download-item__progress-fill" style={{ width: `${download.percent}%` }} /></div>
<span>{download.percent.toFixed(0)}%</span>
<span aria-hidden="true">{download.percent.toFixed(0)}%</span>
</div>
)}

Expand Down
79 changes: 79 additions & 0 deletions prototype/ui-redesign/tests/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,3 +1035,82 @@ test.describe('Accessibility — model row action qualified names (#2341)', () =
}
});
});

// ─── 16. Download progress bar semantics (#2342) ──────────────────────────────

test.describe('Accessibility — download progress bar semantics', () => {
// A valid DownloadListItem for a 42%-complete model download.
// Passed to addInitScript so the singleton DownloadStore reads it from
// localStorage before any React code runs (avoids poll-timing flakiness).
const MOCK_DOWNLOAD = {
id: 'model:Llama-3.1-8B',
downloadType: 'model',
modelName: 'Llama-3.1-8B',
fileName: 'Llama-3.1-8B.gguf',
fileIndex: 1,
totalFiles: 1,
bytesDownloaded: 420_000_000,
bytesTotal: 1_000_000_000,
bytesTotalIsLowerBound: false,
percent: 42,
status: 'downloading',
startTime: 1_000_000_000_000,
bytesResumed: 0,
running: true,
speedBytesPerSecond: 5_000_000,
updatedAt: Date.now(),
};

test.beforeEach(async ({ page }) => {
// Pre-populate localStorage so the DownloadStore singleton (read at module init)
// has an active downloading item before React renders anything.
await page.addInitScript((item: unknown) => {
localStorage.setItem('lemonade_download_manager_items_v1', JSON.stringify([item]));
}, MOCK_DOWNLOAD);

await page.route('/api/v1/health', route =>
route.fulfill({ json: { status: 'ok', all_models_loaded: [] } }),
);
// Return empty from the server so the mock item is not overwritten by polling.
await page.route('/api/v1/downloads**', route =>
route.fulfill({ json: { downloads: [] } }),
);

await page.goto('/');
await page.waitForSelector('.titlebar__nav');
// Open the download manager via its titlebar toggle button.
await page.locator('.titlebar__download-toggle').click();
await page.waitForSelector('.download-manager__panel');
// Ensure the download item row is rendered before we start asserting.
await page.waitForSelector('.download-item--downloading', { timeout: 5000 });
});

test('A59 — active download progress element has role="progressbar"', async ({ page }) => {
const progressBar = page.locator('.download-manager__panel [role="progressbar"]').first();
await expect(progressBar).toBeVisible();
});

test('A60 — progressbar has aria-valuenow matching percent, aria-valuemin=0, aria-valuemax=100', async ({ page }) => {
const progressBar = page.locator('.download-manager__panel [role="progressbar"]').first();
const valuenow = await progressBar.getAttribute('aria-valuenow');
const valuemin = await progressBar.getAttribute('aria-valuemin');
const valuemax = await progressBar.getAttribute('aria-valuemax');
expect(Number(valuenow)).toBe(42);
expect(Number(valuemin)).toBe(0);
expect(Number(valuemax)).toBe(100);
});

test('A61 — progressbar aria-label includes the model name', async ({ page }) => {
const progressBar = page.locator('.download-manager__panel [role="progressbar"]').first();
const label = await progressBar.getAttribute('aria-label');
expect(label).toBeTruthy();
expect(label).toContain('Llama-3.1-8B');
});

test('A62 — sr-only polite status live region is present inside the download manager panel', async ({ page }) => {
const liveRegion = page.locator(
'.download-manager__panel [role="status"][aria-live="polite"]',
);
await expect(liveRegion).toBeAttached();
});
});
Loading