diff --git a/tests/contrast.js b/tests/contrast.js index 72245e5..5574caa 100644 --- a/tests/contrast.js +++ b/tests/contrast.js @@ -11,6 +11,7 @@ const blocks = {}; for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) { const vars = {}; for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase(); + const mix = m[2].match(/--fieldmix:\s*(\d+)%/); if (mix) vars.fieldmix = +mix[1] / 100; if (Object.keys(vars).length) blocks[m[1]] = vars; } @@ -49,6 +50,21 @@ for (const sel of Object.keys(blocks)) { } themes['modern'] = base; +// --field, a text box's and a switch's edge, is --fg mixed into --bg in OKLab, as color-mix() does. +const oklab = h => { + const [r, g, b] = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4); + const [l, m, s] = [.4122214708 * r + .5363325363 * g + .0514459929 * b, .2119034982 * r + .6806995451 * g + .1073969566 * b, .0883024619 * r + .2817188376 * g + .6299787005 * b].map(Math.cbrt); + return [.2104542553 * l + .793617785 * m - .0040720468 * s, 1.9779984951 * l - 2.428592205 * m + .4505937099 * s, .0259040371 * l + .7827717662 * m - .808675766 * s]; +}; +const fromOklab = ([L, a, b]) => { + const [l, m, s] = [L + .3963377774 * a + .2158037573 * b, L - .1055613458 * a - .0638541728 * b, L - .0894841775 * a - 1.291485548 * b].map(x => x ** 3); + return '#' + [4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, -.0041960863 * l - .7034186147 * m + 1.707614701 * s] + .map(c => Math.min(1, Math.max(0, c)) <= .0031308 ? 12.92 * Math.max(0, c) : 1.055 * Math.min(1, c) ** (1 / 2.4) - .055) + .map(c => Math.round(c * 255).toString(16).padStart(2, '0')).join(''); +}; +for (const p of Object.values(themes)) { const f = oklab(p.fg), g = oklab(p.bg), k = p.fieldmix; p.field = fromOklab(f.map((v, i) => v * k + g[i] * (1 - k))); } +RULES.push(['field', ['bg', 'panel', 'panel2', 'raise'], 3]); // a text box's or a switch's edge, wherever one sits + let bad = 0; for (const [name, p] of Object.entries(themes)) { for (const [fg, grounds, min] of RULES) for (const g of grounds) { diff --git a/tests/dom-stub.js b/tests/dom-stub.js index d317944..78c9222 100644 --- a/tests/dom-stub.js +++ b/tests/dom-stub.js @@ -16,7 +16,7 @@ function makeContext(html, script, { media = false } = {}) { const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } }, value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null, appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){}, - setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){}, + setAttribute(){}, getAttribute(){ return null; }, removeAttribute(){}, contains(){ return false; }, select(){}, setSelectionRange(){}, focus(){}, replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; }, play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } }, { get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) }); diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index f51d07d..32d2e8d 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -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); +}); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index fd31532..b8f467f 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -11,6 +11,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- An item opens from the keyboard and with a screen reader: its title is a button. The feed list's rows and the Directory's shows are links to a screen reader, the open one marked current (#298). +- An item's Play, Download and Delete buttons show while the keyboard is on one, as they do under the pointer (#299). +- A screen reader names an item's read button for what it does, Mark read or Mark unread, not "●" (#300). +- The player's position slider has a name, and a screen reader reads where you are as a time, "12:30 of 45:00" (#301). +- Placeholder text is in the theme's own hint colour, readable in every theme, and the search and feed filter boxes have names of their own (#302). +- A screen reader reads toasts out, and an error stays until you dismiss it with its x; no more than four show at once (#303). +- Error messages say what to do, for a server error, a lost connection, a file that would not play or a download that failed, in place of the browser's or the server's own words (#304). +- Text boxes, selects and switches have an edge you can see in every theme, and a box with the keyboard in it has the same ring as every other control (#305, #308). +- Dialogs are dialogs to a screen reader, named by their heading. The page behind one is out of reach while it is open, and closing it puts the keyboard back where it was (#306). +- The boxes in Add a feed, Settings, Download latest and OPML are named by their labels, and clicking a label puts the cursor in its box (#307). +- Settings shows iPX's and the page's versions at its foot: the logo's tooltip was out of reach of a keyboard and of a phone (#309). + ## [1.2.0] - 2026-10-19 ### Added diff --git a/web/admin.html b/web/admin.html index caf3875..31769d7 100644 --- a/web/admin.html +++ b/web/admin.html @@ -31,7 +31,7 @@ -
+