From 728c94f75c8e9b03e4df8b5a2f6f14a15163965b Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 28 Sep 2026 13:48:09 -0500 Subject: [PATCH 1/2] Give the composer and search fields a focus ring that is visible at 3:1 A new rendered test tabs through every stop in both schemes and compares each one unfocused and focused: the pixels that change must include a ring's worth whose colour moved by at least 3:1. It found the composer showed no such change at all. Its focus border was a 45% accent mix and its glow 12%, both too faint to see. It now takes a full-strength 2px ring (border plus a 1px spread) and keeps the soft glow outside it. The familiar search, find bar and screen-viewer fields showed focus only as a 1px border or a 15% halo; they take the same 2px ring. Co-Authored-By: Claude Opus 5.5 (1M context) --- e2e/app.tauri-mock.spec.ts | 107 ++++++++++++++++++++++++++++++++++++ src/coven/chat-app.css | 3 +- src/coven/screen-viewer.css | 2 +- src/ui/ui.css | 10 ++-- 4 files changed, 114 insertions(+), 8 deletions(-) diff --git a/e2e/app.tauri-mock.spec.ts b/e2e/app.tauri-mock.spec.ts index e1668f80..9c06210b 100644 --- a/e2e/app.tauri-mock.spec.ts +++ b/e2e/app.tauri-mock.spec.ts @@ -1388,3 +1388,110 @@ 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(''); + 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.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); + 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); } From 9f5694f1658f75ae6149212679c7a6a32ef14e83 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 28 Sep 2026 13:53:47 -0500 Subject: [PATCH 2/2] Open the find bar and screen viewer before the focus sweep Review noted both panels render only when open, so the sweep never reached the fields whose focus rings this branch changes. They are now opened before the baseline, and the sweep must reach "Find in conversation" and "Screen address". Fields are named by their label. With the previous styles the sweep fails on the search, find, screen address and password fields. Co-Authored-By: Claude Opus 5.5 (1M context) --- e2e/app.tauri-mock.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/e2e/app.tauri-mock.spec.ts b/e2e/app.tauri-mock.spec.ts index 9c06210b..128ac63d 100644 --- a/e2e/app.tauri-mock.spec.ts +++ b/e2e/app.tauri-mock.spec.ts @@ -1404,6 +1404,13 @@ for (const colorScheme of ['dark', 'light'] as const) { 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()); @@ -1427,6 +1434,7 @@ for (const colorScheme of ['dark', 'light'] as const) { 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 { @@ -1492,6 +1500,9 @@ for (const colorScheme of ['dark', 'light'] as const) { 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([]); }); }