diff --git a/e2e/app.tauri-mock.spec.ts b/e2e/app.tauri-mock.spec.ts index e1668f80..128ac63d 100644 --- a/e2e/app.tauri-mock.spec.ts +++ b/e2e/app.tauri-mock.spec.ts @@ -1388,3 +1388,121 @@ for (const viewport of [ .toEqual({ x: 0, y: 0 }); }); } + +for (const colorScheme of ['dark', 'light'] as const) { + test(`shows a visible focus indicator on every tab stop (${colorScheme})`, async ({ page }) => { + // WCAG 2.4.7 asks that keyboard focus be visible. Each tab stop is + // captured unfocused and focused; the pixels that change must include a + // ring's worth whose colour moved by at least 3:1 (the 2.4.13 measure). + await page.emulateMedia({ colorScheme, reducedMotion: 'reduce' }); + await installRuntimeFixture(page); + await page.goto('/'); + const composer = page.getByRole('textbox', { name: 'Message Local familiar' }); + await expect(composer).toBeEnabled(); + await composer.fill('hello'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Copy reply' })).toBeVisible(); + await expect(composer).toBeEnabled(); + await composer.fill(''); + // The screen viewer and find bar render only when open; open both so + // their fields are in the sweep. + await page.getByRole('button', { name: 'Show screen' }).click(); + await expect(page.locator('.coven-screen-input').first()).toBeEnabled(); + await page.getByRole('log', { name: 'Messages' }).focus(); + await page.keyboard.press('Control+f'); + await expect(page.getByRole('searchbox', { name: 'Find in conversation' })).toBeVisible(); + await page.mouse.move(2, 2); + await page.addStyleTag({ content: '* { caret-color: transparent !important; }' }); + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + const unfocused = (await page.screenshot()).toString('base64'); + + const failures: string[] = []; + const seen = new Set(); + for (let stop = 0; stop < 80; stop++) { + await page.keyboard.press('Tab'); + const target = await page.evaluate(() => { + const element = document.activeElement as HTMLElement | null; + if (!element || element === document.body) return null; + // Fields whose container shows focus (:focus-within) carry no ring of + // their own; the container is what is measured. + const shown = ( + element.matches('input, textarea') + ? (element.closest('[data-slot="composer"], .coven-agent-search, .coven-find') ?? + element) + : element + ) as HTMLElement; + const rect = shown.getBoundingClientRect(); + const label = + element.getAttribute('aria-label') || + (element as HTMLInputElement).labels?.[0]?.textContent?.trim() || + element.textContent?.trim().slice(0, 30) || + element.tagName.toLowerCase(); + return { + key: `${label}|${Math.round(rect.left)},${Math.round(rect.top)}`, + label, + box: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, + visible: rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < innerHeight, + }; + }); + // Tabbing past the last stop leaves the page once before wrapping. + if (!target) continue; + if (seen.has(target.key)) break; + seen.add(target.key); + if (!target.visible) continue; + await page.waitForFunction(() => + document.getAnimations().every((animation) => animation.playState !== 'running'), + ); + const focused = (await page.screenshot()).toString('base64'); + const changed = await page.evaluate( + async ({ before, after, box }) => { + const read = async (source: string) => { + const picture = new Image(); + picture.src = `data:image/png;base64,${source}`; + await picture.decode(); + const canvas = document.createElement('canvas'); + canvas.width = picture.width; + canvas.height = picture.height; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) throw new Error('No 2D context.'); + context.drawImage(picture, 0, 0); + return context; + }; + const [a, b] = await Promise.all([read(before), read(after)]); + const scale = a.canvas.width / innerWidth; + const pad = 6; + const x = Math.max(0, Math.floor((box.x - pad) * scale)); + const y = Math.max(0, Math.floor((box.y - pad) * scale)); + const w = Math.min(a.canvas.width - x, Math.ceil((box.width + pad * 2) * scale)); + const h = Math.min(a.canvas.height - y, Math.ceil((box.height + pad * 2) * scale)); + const one = a.getImageData(x, y, w, h).data; + const two = b.getImageData(x, y, w, h).data; + const channel = (value: number) => { + const c = value / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + const luminance = (d: Uint8ClampedArray, i: number) => + 0.2126 * channel(d[i] ?? 0) + + 0.7152 * channel(d[i + 1] ?? 0) + + 0.0722 * channel(d[i + 2] ?? 0); + let strong = 0; + for (let i = 0; i < one.length; i += 4) { + const l1 = luminance(one, i); + const l2 = luminance(two, i); + if ((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05) >= 3) strong++; + } + // A 1 CSS px ring around the box, in device pixels. + const ring = 2 * (box.width + box.height) * scale; + return { strong, ring: Math.round(ring) }; + }, + { before: unfocused, after: focused, box: target.box }, + ); + if (changed.strong < changed.ring) + failures.push(`${target.label}: ${changed.strong}/${changed.ring}`); + } + expect(seen.size).toBeGreaterThan(8); + const labels = [...seen].map((key) => key.split('|')[0]); + expect(labels).toContain('Find in conversation'); + expect(labels).toContain('Screen address'); + expect(failures).toEqual([]); + }); +} diff --git a/src/coven/chat-app.css b/src/coven/chat-app.css index f733be89..3682d323 100644 --- a/src/coven/chat-app.css +++ b/src/coven/chat-app.css @@ -593,7 +593,7 @@ .coven-chat .coven-agent-search:focus-within { border-color: var(--ring-focus); - box-shadow: 0 0 0 2px color-mix(in oklch, var(--ring-focus) 15%, transparent); + box-shadow: 0 0 0 1px var(--ring-focus); } .coven-chat .coven-agent-search input:focus-visible { @@ -837,6 +837,7 @@ .coven-find:focus-within { border-color: var(--ring-focus); + box-shadow: 0 0 0 1px var(--ring-focus); } .coven-find-count { diff --git a/src/coven/screen-viewer.css b/src/coven/screen-viewer.css index 2c28957d..1cf5a565 100644 --- a/src/coven/screen-viewer.css +++ b/src/coven/screen-viewer.css @@ -58,7 +58,7 @@ .coven-screen-input:focus-visible { outline: none; border-color: var(--ring-focus); - box-shadow: 0 0 0 2px color-mix(in oklch, var(--ring-focus) 15%, transparent); + box-shadow: 0 0 0 1px var(--ring-focus); } .coven-screen-input:disabled { diff --git a/src/ui/ui.css b/src/ui/ui.css index 7a49ada7..3629f42f 100644 --- a/src/ui/ui.css +++ b/src/ui/ui.css @@ -403,14 +403,12 @@ box-shadow 240ms; } +/* A full-strength 2px ring (border plus 1px spread) keeps focus visible at 3:1. */ :where([data-slot="composer"]):focus-within { - border-color: color-mix( - in oklch, - var(--accent-presence, #a79bf5) 45%, - var(--border-strong, rgba(255, 255, 255, 0.16)) - ); + border-color: var(--ring-focus, #a79bf5); box-shadow: - 0 0 0 3px color-mix(in oklch, var(--accent-presence, #a79bf5) 12%, transparent), + 0 0 0 1px var(--ring-focus, #a79bf5), + 0 0 0 4px color-mix(in srgb, var(--ring-focus, #a79bf5) 12%, transparent), 0 16px 40px -16px rgba(0, 0, 0, 0.9), inset 0 1px 0 rgba(255, 255, 255, 0.05); }