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
48 changes: 41 additions & 7 deletions prototype/ui-redesign/ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,41 @@
**Date:** 2026-06-14
**Branch:** `kpoin/ui-accessibility`
**Scope:** `prototype/ui-redesign/` only
**Status:** Phase 1 ✅ complete, Phase 2 ✅ mostly complete (items 16–18 deferred to Phase 3), Phase 3 (GUI3 preset a11y) ✅ complete
**Test status (2026-06-22):** All 61 automated tests passing, 7 skipped, 0 failed on `feat/gui3-presets-a11y`
**Status:** Phase 1 ✅ complete, Phase 2 ✅ mostly complete (items 16–18 deferred to Phase 3), Phase 3 (GUI3 preset a11y) ✅ complete, Group C (BackendManager) ✅ complete
**Test status (2026-06-22):** All 58 automated tests passing, 7 skipped, 0 failed on `feat/gui3-backend-a11y`

---

## Group C — BackendManager (2026-06-22, `feat/gui3-backend-a11y`)

Fixes three NVDA/keyboard issues in `BackendManager.tsx`:

### C1 — #2343: Matrix cell keyboard operability

- **Status:** ✅ **Fixed 2026-06-22**
- **What:** Clickable `<div>` cells in the backend matrix were mouse-only — no keyboard focus, no ARIA role, no selected state.
- **Fix:** Overlay-button pattern (same as `recipe-card__overlay-btn` in PresetManager). Each `.cell--selectable` div now has `position: relative`. A `<button class="cell__select-btn">` with `position: absolute; inset: 0; z-index: 0` covers the full cell. The button has `aria-pressed` (selected state) and an `aria-label` including the recipe label and backend identifier. Action buttons (`.cell__actions`) have `position: relative; z-index: 1` so they remain clickable above the overlay. `:focus-visible` ring from global CSS applies automatically to the button.
- **WCAG:** 4.1.2 (Name, Role, Value), 2.1.1 (Keyboard)

### C2 — #2344: Action button qualified accessible names

- **Status:** ✅ **Fixed 2026-06-22**
- **What:** Install, Update, Uninstall, and Setup guide buttons had generic labels ("Install", "Update", "Uninstall") — indistinguishable when multiple backends share the same action.
- **Fix:** Added `aria-label` to each action button: `Install ${RECIPE_LABELS[recipe]} (${backend})`, `Update …`, `Uninstall …`, `Setup guide for … (${backend})`. Visible text unchanged.
- **WCAG:** 4.1.2 (Name, Role, Value)

### C3 — #2351: Toast and notice live regions

- **Status:** ✅ **Fixed 2026-06-22**
- **What:** `backends__toast` was conditionally mounted (`{toastMsg && <div>…</div>}`); `context-rail__notice` likewise. Mounting with content does not trigger NVDA live region announcements.
- **Fix:** Added two always-present `<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">` elements alongside the visual elements:
- `data-backends-toast-live` — mirrors `toastMsg` (install/update/uninstall progress and completion messages)
- `data-backends-preset-notice-live` — mirrors `presetNotice` (preset assignment confirmation and incompatibility notices)
Visual toast and notice remain conditionally rendered; only the sr-only live regions are always in DOM.
- **WCAG:** 4.1.3 (Status Messages)

**Tests added:** A51–A58 (8 tests) in `tests/a11y.spec.ts`.
**Files changed:** `BackendManager.tsx`, `styles/styles.css`, `tests/a11y.spec.ts`, `ACCESSIBILITY.md`.

---

Expand Down Expand Up @@ -53,14 +86,14 @@

#### 1.1.2 `div.onClick` / `span.onClick` used as interactive elements

- **Status:** ✅ **Fixed 2026-06-15 for PresetCard** — `<article role="button">` replaced with overlay-button pattern (`<button class="recipe-card__overlay-btn">` at absolute inset:0, card content at z-index:1). Remaining `div.onClick` instances in AccountMenu, BackendManager, ModelManager are P1 deferred.
- **Status:** ✅ **Fixed 2026-06-15 for PresetCard** — `<article role="button">` replaced with overlay-button pattern (`<button class="recipe-card__overlay-btn">` at absolute inset:0, card content at z-index:1). ✅ **Fixed 2026-06-22 for BackendManager** — matrix cells use same overlay-button pattern (`<button class="cell__select-btn">`). Remaining `div.onClick` in AccountMenu, ModelManager are P1 deferred.
- **What:** Clickable divs/spans without button semantics — no keyboard activation, no role, not focusable.
- **Current state:**
- `AccountMenu.tsx` — 2 `div.onClick` instances (account row items)
- `BackendManager.tsx` — 1 `div.onClick`
- `BackendManager.tsx` — ✅ fixed (overlay button pattern, #2343)
- `ChatView.tsx` — 1 `div.onClick` (backdrop `aria-hidden="true"` — OK as-is)
- `ModelManager.tsx` — 3 `div.onClick` instances
- `PresetManager.tsx` — 2 `div.onClick` instances (preset card selection at line 610 uses `tabIndex={0}` but lacks `role="button"` and `onKeyDown`)
- `PresetManager.tsx` — ✅ fixed (overlay-button pattern on preset card)
- **Target:** Replace clickable `div`/`span` elements with `<button>` (or `<a>` where navigation applies). At minimum add `role="button"`, `tabIndex={0}`, and `onKeyDown` handling (`Enter`/`Space` triggers click).
- **Effort:** M
- **Priority:** P0 — WCAG 4.1.2 (Name, Role, Value)
Expand Down Expand Up @@ -525,7 +558,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 (61 tests)
### Test groups (58 tests)

| Group | Tests | What it checks |
|-------|-------|----------------|
Expand All @@ -543,11 +576,12 @@ npm test
| Preset card metadata (#2345) | A40 | Card button aria-describedby includes applies_to, prompt, tools |
| Capability toggle-button semantics (#2350) | A41–A43 | Container has role=group + aria-label; buttons are plain buttons with aria-pressed; exactly 1 pressed=true, all others false |
| 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 |

### 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)*
*Last updated: 2026-06-22 by Mattingly (GUI3 preset a11y items #2338 #2339 #2345 #2350 #2352; BackendManager #2343 #2344 #2351)*
27 changes: 24 additions & 3 deletions prototype/ui-redesign/src/components/BackendManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,10 @@ const BackendManager: React.FC = () => {
<span><PresetIcon preset={selectedBackendPreset} /> {selectedBackendPreset.name}</span>
</div>
)}
{/* #2351: always-present polite live region for preset assignment notices (NVDA) */}
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only" data-backends-preset-notice-live>
{presetNotice || ''}
</div>
{presetNotice && <div className="context-rail__notice">{presetNotice}</div>}
</div>
</aside>
Expand Down Expand Up @@ -735,8 +739,15 @@ const BackendManager: React.FC = () => {
const isPresetHighlighted = Boolean(highlightedPresetId && activePreset.id === highlightedPresetId);
return (
<div className={`cell cell--selectable${isSelectedBackend ? ' is-selected' : ''}${isPresetHighlighted ? ' cell--preset-highlight' : ''}`} key={`${recipe}-${backend}`}
data-cell={cellKey}
onClick={() => setSelectedBackendKey(current => current === cellKey ? '' : cellKey)}>
data-cell={cellKey}>
{/* #2343: overlay button provides keyboard + SR selection; covers the full cell area */}
<button
type="button"
className="cell__select-btn"
aria-pressed={isSelectedBackend}
aria-label={`${RECIPE_LABELS[recipe] || recipe} (${backend}), ${badge.label}`}
onClick={() => setSelectedBackendKey(current => current === cellKey ? '' : cellKey)}
/>
<span className="cell__name">
{RECIPE_LABELS[recipe] || recipe}
{backend !== 'cpu' && backend !== 'npu' && ` · ${backend}`}
Expand All @@ -750,6 +761,7 @@ const BackendManager: React.FC = () => {
<button
className="cell__swap"
disabled={isInstalling}
aria-label={`Install ${RECIPE_LABELS[recipe] || recipe} (${backend})`}
onClick={() => handleInstall(recipe, backend)}>
{isInstalling ? 'Installing…' : 'Install'}
</button>
Expand All @@ -758,19 +770,24 @@ const BackendManager: React.FC = () => {
<button
className="cell__swap"
disabled={isInstalling}
aria-label={`Update ${RECIPE_LABELS[recipe] || recipe} (${backend})`}
onClick={() => handleInstall(recipe, backend, true)}>
{isInstalling ? 'Updating…' : 'Update'}
</button>
)}
{info.state === 'action_required' && info.action && (
<button className="cell__swap" onClick={() => handleAction(info.action)}>
<button
className="cell__swap"
aria-label={`Setup guide for ${RECIPE_LABELS[recipe] || recipe} (${backend})`}
onClick={() => handleAction(info.action)}>
Setup guide ▸
</button>
)}
{canShowUninstall(info) && (
<button
className="cell__swap cell__swap--danger"
disabled={isInstalling}
aria-label={`Uninstall ${RECIPE_LABELS[recipe] || recipe} (${backend})`}
onClick={() => handleUninstall(recipe, backend)}>
{isInstalling ? 'Working…' : 'Uninstall'}
</button>
Expand Down Expand Up @@ -803,6 +820,10 @@ const BackendManager: React.FC = () => {
</div>
)}

{/* #2351: always-present polite live region so NVDA announces toast messages */}
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only" data-backends-toast-live>
{toastMsg || ''}
</div>
{toastMsg && <div className="backends__toast" data-backends-toast>{toastMsg}</div>}
</div>
</section>
Expand Down
13 changes: 13 additions & 0 deletions prototype/ui-redesign/src/styles/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3192,6 +3192,8 @@ input, textarea {
.show-tech .cell__device-detail { display: block; }

.cell__actions {
position: relative;
z-index: 1;
display: flex;
gap: var(--space-1);
margin-top: var(--space-1);
Expand Down Expand Up @@ -3718,12 +3720,23 @@ input, textarea {
}

.cell--selectable {
position: relative; /* needed for overlay select button */
cursor: pointer;
padding: var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-md);
}

/* Overlay button for keyboard + SR selection (#2343).
* Covers the full cell area; action buttons sit above it via z-index. */
.cell__select-btn {
position: absolute;
inset: 0;
z-index: 0;
border-radius: var(--radius-md);
cursor: pointer;
}

.cell--preset-highlight {
border-color: var(--accent-fg);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent);
Expand Down
110 changes: 110 additions & 0 deletions prototype/ui-redesign/tests/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,3 +836,113 @@ test.describe('Accessibility — AutoOpt run selection state (#2352)', () => {
expect(firstPressed).toBe('false');
});
});

// ─── 15. Backend Manager — matrix cells, action labels, live regions ──────────
// Covers: #2343 (keyboard-operable cells), #2344 (qualified action names),
// #2351 (live-region toasts/notices).

test.describe('Accessibility — Backend Manager (#2343 #2344 #2351)', () => {
const MOCK_SYSTEM_INFO = {
lemonade_version: '1.0.0',
os_version: 'Test OS',
devices: { cpu: { name: 'Test CPU', available: true } },
recipes: {
llamacpp: {
default_backend: 'cpu',
backends: {
vulkan: { state: 'installable', version: 'b1234', message: '', action: '' },
cpu: { state: 'installed', version: 'b1234', message: '', action: '', can_uninstall: true },
},
},
},
};

test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/health**', route =>
route.fulfill({ json: { status: 'ok', all_models_loaded: [], version: '1.0.0' } }),
);
await page.route('**/api/v1/system-info**', route =>
route.fulfill({ json: MOCK_SYSTEM_INFO }),
);
await page.goto('/');
await page.waitForSelector('.titlebar__nav');
await page.locator('.titlebar__nav').getByText('Backends').click();
await page.waitForSelector('[data-backends-matrix]', { timeout: 5000 });
});

// ── #2343 — matrix cells are keyboard-focusable with selected state ────────

test('A51 — each matrix cell contains a button with aria-pressed (selected state)', async ({ page }) => {
const cellBtn = page.locator('.cell__select-btn').first();
await expect(cellBtn).toBeVisible();
const pressed = await cellBtn.getAttribute('aria-pressed');
expect(['true', 'false']).toContain(pressed);
});

test('A52 — clicking the cell select button toggles aria-pressed between "true" and "false"', async ({ page }) => {
const cellBtn = page.locator('[data-cell="llamacpp:vulkan"] .cell__select-btn');
await expect(cellBtn).toHaveAttribute('aria-pressed', 'false');
await cellBtn.click();
await expect(cellBtn).toHaveAttribute('aria-pressed', 'true');
await cellBtn.click();
await expect(cellBtn).toHaveAttribute('aria-pressed', 'false');
});

test('A53 — cell select button is keyboard-focusable (tabIndex >= 0, is a <button>)', async ({ page }) => {
const cellBtn = page.locator('.cell__select-btn').first();
// Verify it is a <button> element (native keyboard operability)
const tag = await cellBtn.evaluate(el => el.tagName.toLowerCase());
expect(tag).toBe('button');
// Verify it is in the tab order
const tabIndex = await cellBtn.evaluate(el => (el as HTMLElement).tabIndex);
expect(tabIndex).toBeGreaterThanOrEqual(0);
});

test('A54 — cell select button aria-label contains both recipe label and backend identifier', async ({ page }) => {
const btn = page.locator('[data-cell="llamacpp:vulkan"] .cell__select-btn');
const label = await btn.getAttribute('aria-label');
expect(label).toBeTruthy();
// Should mention the human-readable recipe label (llama.cpp) and backend (vulkan)
expect(label!.toLowerCase()).toContain('llama');
expect(label!.toLowerCase()).toContain('vulkan');
});

// ── #2344 — action buttons have qualified accessible names ─────────────────

test('A55 — Install button aria-label includes recipe and backend identifiers', async ({ page }) => {
const installBtn = page.locator('[data-cell="llamacpp:vulkan"] button.cell__swap');
await expect(installBtn).toBeVisible();
const label = await installBtn.getAttribute('aria-label');
expect(label).toBeTruthy();
expect(label!.toLowerCase()).toContain('install');
expect(label!.toLowerCase()).toContain('llama');
expect(label!.toLowerCase()).toContain('vulkan');
});

test('A56 — Uninstall button aria-label includes recipe and backend identifiers', async ({ page }) => {
const uninstallBtn = page.locator('[data-cell="llamacpp:cpu"] button.cell__swap--danger');
await expect(uninstallBtn).toBeVisible();
const label = await uninstallBtn.getAttribute('aria-label');
expect(label).toBeTruthy();
expect(label!.toLowerCase()).toContain('uninstall');
expect(label!.toLowerCase()).toContain('llama');
expect(label!.toLowerCase()).toContain('cpu');
});

// ── #2351 — persistent polite live regions for toasts and preset notices ───

test('A57 — backends view has a persistent role="status" live region for toast messages', async ({ page }) => {
const liveRegion = page.locator('[data-backends-toast-live]');
await expect(liveRegion).toHaveCount(1);
expect(await liveRegion.getAttribute('role')).toBe('status');
expect(await liveRegion.getAttribute('aria-live')).toBe('polite');
expect(await liveRegion.getAttribute('aria-atomic')).toBe('true');
});

test('A58 — backend preset rail has a persistent role="status" live region for preset notices', async ({ page }) => {
const liveRegion = page.locator('[data-backends-preset-notice-live]');
await expect(liveRegion).toHaveCount(1);
expect(await liveRegion.getAttribute('role')).toBe('status');
expect(await liveRegion.getAttribute('aria-live')).toBe('polite');
});
});
Loading