Keyboard, screen reader and contrast fixes from the interface review (#298 #299 #300 #301 #302 #303 #304 #305 #306 #307 #308 #309)

Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes,
light and dark), in Playwright against the test daemon.

- #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's
  rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named
  by what they show, the open one aria-current. Not role=button, which would flatten a folder's
  triangle inside it.
- #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0.
- #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers.
- #301: the seek slider is named Position, its value read as "12:30 of 45:00".
- #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search
  and filter boxes have names that do not depend on their placeholder.
- #303: #toasts is role=status; an error toast stays until dismissed, at most four at once.
- #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the
  daemon's internals, already in its log); play() failures are put in words and reported, an
  AbortError ignored; a failed download names its item.
- #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80%
  for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks,
  phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on
  bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0,
  so they take the same focus ring as everything else.
- #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it,
  and makes the rest of the body inert until closeModal, which gives focus back to what opened
  it. Not <dialog>: its top layer would cover the toasts and their Dismiss.
- #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes.
- #309: Settings shows iPX's and the page's versions at its foot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 20:12:34 +00:00
parent c8afcec73d
commit 6ec2c35291
16 changed files with 218 additions and 47 deletions

View File

@@ -2291,3 +2291,53 @@ signalsTest('the server pays for Signals only when it has a key to pay with', as
await page.request.patch('/api/settings', { data: { signals_on_server: false } });
}
});
test('a dialog keeps the keyboard inside it and gives it back on closing', async ({ page }) => {
// #306: Tab walked out of a dialog into the toolbar, and closing one left focus on the page.
await page.locator('#addFeed').focus();
await page.keyboard.press('Enter');
await expect(page.locator('#modalCard[role=dialog]')).toHaveAttribute('aria-labelledby', /.+/);
await expect(page.locator('#nurl')).toBeFocused();
// Labels name their boxes (#307).
await expect(page.getByRole('textbox', { name: 'Feed URL' })).toHaveCount(1);
for (let i = 0; i < 8; i++) {
await page.keyboard.press('Tab');
// Past the last control Tab goes to the browser's own toolbar (the body, from here), never the page behind.
expect(await page.evaluate(() => { const a = document.activeElement; return a === document.body || document.querySelector('#modalCard').contains(a); }), `Tab ${i + 1} stays out of the page behind`).toBe(true);
}
await page.keyboard.press('Escape');
await expect(page.locator('#modal.on')).toBeHidden();
await expect(page.locator('#addFeed')).toBeFocused();
expect(await page.locator('#shell').evaluate(e => e.inert)).toBe(false);
});
test('an item opens from the keyboard, and its row actions show while focused', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).first().click();
const row = page.locator('#eps .ep').first();
// #298: the title is a button, so Tab reaches the item itself.
await row.locator('button.t').focus();
await page.keyboard.press('Enter');
await expect(row).toHaveClass(/\bsel\b/);
// #300: the read toggle is named for what it does, not its dot.
await expect(row.locator('.st')).toHaveAttribute('aria-label', /^Mark (read|unread)$/);
// #299: focus shows what hover shows.
await page.mouse.move(0, 0);
const act = row.locator('.rowacts button').first();
if (await act.count()) {
await act.focus();
await expect(row.locator('.rowacts')).toHaveCSS('opacity', '1');
}
// #298: a feed in the list is a link to a screen reader, the open one current.
await expect(page.locator('#feedlist .feed[aria-current=page]')).toHaveAttribute('role', 'link');
});
test('an error toast is announced and stays until it is dismissed', async ({ page }) => {
// #303: role=status, and no timer on an error.
await expect(page.locator('#toasts')).toHaveAttribute('role', 'status');
await page.evaluate(() => toast('Unable to play this file. Try again, or download it again.', true));
const t = page.locator('.toast.bad').last();
await page.waitForTimeout(6500);
await expect(t).toBeVisible();
await t.getByRole('button', { name: 'Dismiss' }).click();
await expect(t).toHaveCount(0);
});