From 1c11da12097141a7c782c0c4d4aefb7159bccce6 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 27 Sep 2026 03:42:57 -0500 Subject: [PATCH 1/2] Measure overlays, hover states, menus and dialogs at WCAG AA in both schemes The rendered contrast check moves to e2e/contrast.ts and now runs with each overlay open: a hovered sidebar row, the find bar, the screen viewer, mention suggestions, the drop overlay, the chat actions menu, the delete dialog and the context picker. It waits for entry transitions to settle, and while a modal is open it reads only the modal, since the page behind is inert. The context picker is not modal, so the page behind it stays in use. Its 15% black scrim dropped light-scheme muted text to 3.76:1; on a light page the scrim is now transparent and the panel's shadow separates it. Co-Authored-By: Claude Opus 5.5 (1M context) --- e2e/app.tauri-mock.spec.ts | 148 ++++++++++++----------------------- e2e/chat-lifecycle.spec.ts | 26 ++++++ e2e/contrast.ts | 111 ++++++++++++++++++++++++++ src/coven/context-picker.css | 7 ++ 4 files changed, 192 insertions(+), 100 deletions(-) create mode 100644 e2e/contrast.ts diff --git a/e2e/app.tauri-mock.spec.ts b/e2e/app.tauri-mock.spec.ts index e1668f80..75e3b4b5 100644 --- a/e2e/app.tauri-mock.spec.ts +++ b/e2e/app.tauri-mock.spec.ts @@ -1,4 +1,5 @@ import { expect, type Page, test } from '@playwright/test'; +import { contrastFailures } from './contrast'; declare global { interface Window { @@ -315,107 +316,54 @@ for (const colorScheme of ['dark', 'light'] as const) { await expect(page.getByRole('button', { name: 'Copy message' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Copy reply' })).toBeVisible(); await page.mouse.move(2, 2); - // The palette tests check tokens in isolation; this measures the rendered - // page. Every text is made transparent and the viewport captured, so each - // text box is compared with the pixels actually behind it, gradients and - // layered surfaces included. Every text must meet WCAG AA for normal text - // (4.5:1); the app sets none large enough for the 3:1 allowance. - const texts = await page.evaluate(() => { - const found: { label: string; color: string; opacity: number; box: number[] }[] = []; - const seen = new Set(); - const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); - for (let node = walker.nextNode(); node; node = walker.nextNode()) { - const element = node.parentElement; - if (!element || !node.textContent?.trim() || seen.has(element)) continue; - seen.add(element); - const style = getComputedStyle(element); - if ( - style.visibility === 'hidden' || - element.closest('[aria-hidden="true"], [inert], .coven-sr-only') || - // Inactive controls are exempt from contrast (WCAG 1.4.3). - element.closest(':disabled, [aria-disabled="true"]') || - (element.closest('details:not([open])') && !element.closest('summary')) - ) - continue; - const range = document.createRange(); - range.selectNodeContents(node); - const rect = range.getBoundingClientRect(); - if (rect.width < 2 || rect.height < 2 || rect.bottom <= 0 || rect.top >= innerHeight) - continue; - let opacity = 1; - for (let e: Element | null = element; e; e = e.parentElement) - opacity *= Number.parseFloat(getComputedStyle(e).opacity); - found.push({ - label: node.textContent.trim().slice(0, 30), - color: style.color, - opacity, - box: [rect.left, rect.top, rect.width, rect.height], - }); - } - return found; - }); - expect(texts.length).toBeGreaterThan(20); - const hide = await page.addStyleTag({ - content: - '*, *::placeholder { color: transparent !important; -webkit-text-fill-color: transparent !important; text-shadow: none !important; caret-color: transparent !important; }', + expect(await contrastFailures(page)).toEqual([]); + }); +} + +for (const colorScheme of ['dark', 'light'] as const) { + test(`keeps overlays and hover states at WCAG AA contrast (${colorScheme})`, async ({ page }) => { + await page.emulateMedia({ colorScheme }); + 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(); + const found: string[] = []; + const measure = async (state: string) => { + for (const failure of await contrastFailures(page)) found.push(`${state}: ${failure}`); + }; + + await page.getByRole('button', { name: 'Local familiar', exact: true }).hover(); + await measure('hovered sidebar row'); + await page.mouse.move(2, 2); + + await page.getByRole('log', { name: 'Messages' }).focus(); + await page.keyboard.press('Control+f'); + await page.getByRole('searchbox', { name: 'Find in conversation' }).fill('hello'); + await measure('find bar'); + await page.keyboard.press('Escape'); + + await page.getByRole('button', { name: 'Show screen' }).click(); + await measure('screen viewer'); + await page.getByRole('button', { name: 'Hide screen' }).click(); + + await composer.fill('@'); + await measure('mention suggestions'); + await page.keyboard.press('Escape'); + await composer.fill(''); + + const transfer = await page.evaluateHandle(() => { + const data = new DataTransfer(); + data.items.add(new File(['x'], 'a.md', { type: 'text/markdown' })); + return data; }); - const backdrop = (await page.screenshot()).toString('base64'); - await hide.evaluate((element) => (element as HTMLElement).remove()); - const failures = await page.evaluate( - async ({ image, texts }) => { - const picture = new Image(); - picture.src = `data:image/png;base64,${image}`; - 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); - const scale = picture.width / innerWidth; - const pixel = (x: number, y: number) => - Array.from(context.getImageData(Math.round(x * scale), Math.round(y * scale), 1, 1).data); - const probe = document - .createElement('canvas') - .getContext('2d', { willReadFrequently: true }); - if (!probe) throw new Error('No 2D context.'); - const parse = (color: string): number[] => { - probe.clearRect(0, 0, 1, 1); - probe.fillStyle = color; - probe.fillRect(0, 0, 1, 1); - const [r = 0, g = 0, b = 0, a = 255] = probe.getImageData(0, 0, 1, 1).data; - return [r, g, b, a / 255]; - }; - const luminance = (c: number[]) => - c - .slice(0, 3) - .map((v) => v / 255) - .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)) - .reduce((sum, v, i) => sum + v * ([0.2126, 0.7152, 0.0722][i] ?? 0), 0); - const ratio = (a: number[], b: number[]) => { - const [light = 0, dark = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x); - return (light + 0.05) / (dark + 0.05); - }; - const found: string[] = []; - for (const text of texts) { - const [left = 0, top = 0, width = 0, height = 0] = text.box; - // The darkest-to-lightest spread of a few points inside the box; the - // worst of them is the backdrop the text must stand out from. - const points = [0.25, 0.5, 0.75].map((f) => pixel(left + width * f, top + height / 2)); - const [r, g, b, a] = parse(text.color); - let worst = Number.POSITIVE_INFINITY; - for (const back of points) { - const alpha = (a ?? 1) * text.opacity; - const shown = [r, g, b].map((v, i) => (v ?? 0) * alpha + (back[i] ?? 0) * (1 - alpha)); - worst = Math.min(worst, ratio(shown, back)); - } - if (worst < 4.5) found.push(`${worst.toFixed(2)} "${text.label}"`); - } - return found; - }, - { image: backdrop, texts }, - ); - expect(failures).toEqual([]); + await page.locator('main.fr-thread').dispatchEvent('dragenter', { dataTransfer: transfer }); + await measure('drop overlay'); + + expect(found).toEqual([]); }); } diff --git a/e2e/chat-lifecycle.spec.ts b/e2e/chat-lifecycle.spec.ts index c0ebb22b..93cd904f 100644 --- a/e2e/chat-lifecycle.spec.ts +++ b/e2e/chat-lifecycle.spec.ts @@ -1,5 +1,7 @@ import { expect, test } from '@playwright/test'; +import { contrastFailures } from './contrast'; + test.beforeEach(async ({ page }) => { await page.addInitScript(() => { const session = { @@ -120,3 +122,27 @@ test('confirms app-local delete and never lists tombstones', async ({ page }) => await archived.check(); await expect(page.getByText('No archived familiars.')).toBeVisible(); }); + +for (const colorScheme of ['dark', 'light'] as const) { + test(`keeps the chat actions menu, delete dialog and context picker at WCAG AA contrast (${colorScheme})`, async ({ + page, + }) => { + await page.emulateMedia({ colorScheme }); + await expect(page.getByRole('textbox', { name: 'Message Lifecycle familiar' })).toBeEnabled(); + const found: string[] = []; + await page.getByRole('button', { name: 'Chat actions' }).click(); + await expect(page.getByRole('menu')).toBeVisible(); + await page.keyboard.press('ArrowDown'); + for (const failure of await contrastFailures(page)) found.push(`menu: ${failure}`); + await page.getByRole('menuitem', { name: 'Delete chat' }).click(); + const dialog = page.getByRole('dialog', { name: 'Delete chat from this app?' }); + await expect(dialog).toBeVisible(); + for (const failure of await contrastFailures(page, { minimum: 3 })) + found.push(`dialog: ${failure}`); + await dialog.getByRole('button', { name: 'Keep chat' }).click(); + await page.getByRole('button', { name: 'Add context', exact: true }).click(); + for (const failure of await contrastFailures(page)) found.push(`context picker: ${failure}`); + await page.keyboard.press('Escape'); + expect(found).toEqual([]); + }); +} diff --git a/e2e/contrast.ts b/e2e/contrast.ts new file mode 100644 index 00000000..553dce44 --- /dev/null +++ b/e2e/contrast.ts @@ -0,0 +1,111 @@ +import { expect, type Page } from '@playwright/test'; + +/** Every visible text measured against the pixels behind it; failures below 4.5:1. */ +export async function contrastFailures(page: Page, { minimum = 20 } = {}): Promise { + // The palette tests check tokens in isolation; this measures the rendered + // page. Every text is made transparent and the viewport captured, so each + // text box is compared with the pixels actually behind it, gradients and + // layered surfaces included. Every text must meet WCAG AA for normal text + // (4.5:1); the app sets none large enough for the 3:1 allowance. + // Let entry transitions settle; a menu measured mid-fade reads as a failure. + await page.waitForFunction(() => + document.getAnimations().every((animation) => animation.playState !== 'running'), + ); + const texts = await page.evaluate(() => { + // An open modal makes everything outside it inert; only the modal is read. + const modal = document.querySelector(':modal'); + const found: { label: string; color: string; opacity: number; box: number[] }[] = []; + const seen = new Set(); + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const element = node.parentElement; + if (!element || !node.textContent?.trim() || seen.has(element)) continue; + seen.add(element); + const style = getComputedStyle(element); + if ( + style.visibility === 'hidden' || + element.closest('[aria-hidden="true"], [inert], .coven-sr-only') || + (modal && !modal.contains(element)) || + // Inactive controls are exempt from contrast (WCAG 1.4.3). + element.closest(':disabled, [aria-disabled="true"]') || + (element.closest('details:not([open])') && !element.closest('summary')) + ) + continue; + const range = document.createRange(); + range.selectNodeContents(node); + const rect = range.getBoundingClientRect(); + if (rect.width < 2 || rect.height < 2 || rect.bottom <= 0 || rect.top >= innerHeight) + continue; + let opacity = 1; + for (let e: Element | null = element; e; e = e.parentElement) + opacity *= Number.parseFloat(getComputedStyle(e).opacity); + found.push({ + label: node.textContent.trim().slice(0, 30), + color: style.color, + opacity, + box: [rect.left, rect.top, rect.width, rect.height], + }); + } + return found; + }); + expect(texts.length).toBeGreaterThanOrEqual(minimum); + const hide = await page.addStyleTag({ + content: + '*, *::placeholder { color: transparent !important; -webkit-text-fill-color: transparent !important; text-shadow: none !important; caret-color: transparent !important; }', + }); + const backdrop = (await page.screenshot()).toString('base64'); + await hide.evaluate((element) => (element as HTMLElement).remove()); + const failures = await page.evaluate( + async ({ image, texts }) => { + const picture = new Image(); + picture.src = `data:image/png;base64,${image}`; + 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); + const scale = picture.width / innerWidth; + const pixel = (x: number, y: number) => + Array.from(context.getImageData(Math.round(x * scale), Math.round(y * scale), 1, 1).data); + const probe = document.createElement('canvas').getContext('2d', { willReadFrequently: true }); + if (!probe) throw new Error('No 2D context.'); + const parse = (color: string): number[] => { + probe.clearRect(0, 0, 1, 1); + probe.fillStyle = color; + probe.fillRect(0, 0, 1, 1); + const [r = 0, g = 0, b = 0, a = 255] = probe.getImageData(0, 0, 1, 1).data; + return [r, g, b, a / 255]; + }; + const luminance = (c: number[]) => + c + .slice(0, 3) + .map((v) => v / 255) + .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)) + .reduce((sum, v, i) => sum + v * ([0.2126, 0.7152, 0.0722][i] ?? 0), 0); + const ratio = (a: number[], b: number[]) => { + const [light = 0, dark = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (light + 0.05) / (dark + 0.05); + }; + const found: string[] = []; + for (const text of texts) { + const [left = 0, top = 0, width = 0, height = 0] = text.box; + // The darkest-to-lightest spread of a few points inside the box; the + // worst of them is the backdrop the text must stand out from. + const points = [0.25, 0.5, 0.75].map((f) => pixel(left + width * f, top + height / 2)); + const [r, g, b, a] = parse(text.color); + let worst = Number.POSITIVE_INFINITY; + for (const back of points) { + const alpha = (a ?? 1) * text.opacity; + const shown = [r, g, b].map((v, i) => (v ?? 0) * alpha + (back[i] ?? 0) * (1 - alpha)); + worst = Math.min(worst, ratio(shown, back)); + } + if (worst < 4.5) found.push(`${worst.toFixed(2)} "${text.label}"`); + } + return found; + }, + { image: backdrop, texts }, + ); + return failures; +} diff --git a/src/coven/context-picker.css b/src/coven/context-picker.css index 97e180fd..d068fcb8 100644 --- a/src/coven/context-picker.css +++ b/src/coven/context-picker.css @@ -32,6 +32,13 @@ .chat-context-panel::backdrop { background: rgb(0 0 0 / 15%); } +/* The picker is not modal: the page behind stays readable and clickable, so + on a light page it is not dimmed below AA. The panel's shadow separates it. */ +@media (prefers-color-scheme: light) { + .chat-context-panel::backdrop { + background: transparent; + } +} /* Older desktop webviews retain the bounded inline picker. */ .chat-context-panel:not([popover]) { position: static; From 70f833e590b4a5c5bbbb80fe493df9b01bed35ea Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 28 Sep 2026 13:40:55 -0500 Subject: [PATCH 2/2] Assert each overlay is open before measuring its contrast Review found the checks could pass against the plain page if an overlay failed to open. Each state now asserts its overlay is visible first. That showed mention suggestions never opened in the app fixture, which has no known context, so the check moves to the lifecycle fixture. The drop overlay is aria-hidden, and the helper skipped aria-hidden text, so its message was never read; sighted users still see it, so only inert and screen-reader-only text is skipped now. Co-Authored-By: Claude Opus 5.5 (1M context) --- e2e/app.tauri-mock.spec.ts | 11 +++++------ e2e/chat-lifecycle.spec.ts | 10 +++++++++- e2e/contrast.ts | 4 +++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/e2e/app.tauri-mock.spec.ts b/e2e/app.tauri-mock.spec.ts index 75e3b4b5..95afd641 100644 --- a/e2e/app.tauri-mock.spec.ts +++ b/e2e/app.tauri-mock.spec.ts @@ -342,25 +342,24 @@ for (const colorScheme of ['dark', 'light'] as const) { await page.getByRole('log', { name: 'Messages' }).focus(); await page.keyboard.press('Control+f'); - await page.getByRole('searchbox', { name: 'Find in conversation' }).fill('hello'); + const find = page.getByRole('searchbox', { name: 'Find in conversation' }); + await find.fill('hello'); + await expect(find).toBeVisible(); await measure('find bar'); await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Show screen' }).click(); + await expect(page.locator('#coven-screen-viewer')).toBeVisible(); await measure('screen viewer'); await page.getByRole('button', { name: 'Hide screen' }).click(); - await composer.fill('@'); - await measure('mention suggestions'); - await page.keyboard.press('Escape'); - await composer.fill(''); - const transfer = await page.evaluateHandle(() => { const data = new DataTransfer(); data.items.add(new File(['x'], 'a.md', { type: 'text/markdown' })); return data; }); await page.locator('main.fr-thread').dispatchEvent('dragenter', { dataTransfer: transfer }); + await expect(page.locator('.coven-drop-zone')).toBeVisible(); await measure('drop overlay'); expect(found).toEqual([]); diff --git a/e2e/chat-lifecycle.spec.ts b/e2e/chat-lifecycle.spec.ts index 93cd904f..be1fb48a 100644 --- a/e2e/chat-lifecycle.spec.ts +++ b/e2e/chat-lifecycle.spec.ts @@ -124,7 +124,7 @@ test('confirms app-local delete and never lists tombstones', async ({ page }) => }); for (const colorScheme of ['dark', 'light'] as const) { - test(`keeps the chat actions menu, delete dialog and context picker at WCAG AA contrast (${colorScheme})`, async ({ + test(`keeps the chat actions menu, delete dialog, context picker and mentions at WCAG AA contrast (${colorScheme})`, async ({ page, }) => { await page.emulateMedia({ colorScheme }); @@ -133,6 +133,7 @@ for (const colorScheme of ['dark', 'light'] as const) { await page.getByRole('button', { name: 'Chat actions' }).click(); await expect(page.getByRole('menu')).toBeVisible(); await page.keyboard.press('ArrowDown'); + await expect(page.getByRole('menuitem').first()).toBeVisible(); for (const failure of await contrastFailures(page)) found.push(`menu: ${failure}`); await page.getByRole('menuitem', { name: 'Delete chat' }).click(); const dialog = page.getByRole('dialog', { name: 'Delete chat from this app?' }); @@ -141,8 +142,15 @@ for (const colorScheme of ['dark', 'light'] as const) { found.push(`dialog: ${failure}`); await dialog.getByRole('button', { name: 'Keep chat' }).click(); await page.getByRole('button', { name: 'Add context', exact: true }).click(); + await expect(page.getByRole('listbox', { name: 'Known context' })).toBeVisible(); for (const failure of await contrastFailures(page)) found.push(`context picker: ${failure}`); await page.keyboard.press('Escape'); + const composer = page.getByRole('textbox', { name: 'Message Lifecycle familiar' }); + await composer.fill('@'); + await expect(page.getByRole('listbox', { name: 'Context suggestions' })).toBeVisible(); + for (const failure of await contrastFailures(page)) + found.push(`mention suggestions: ${failure}`); + await composer.fill(''); expect(found).toEqual([]); }); } diff --git a/e2e/contrast.ts b/e2e/contrast.ts index 553dce44..5dc36670 100644 --- a/e2e/contrast.ts +++ b/e2e/contrast.ts @@ -24,7 +24,9 @@ export async function contrastFailures(page: Page, { minimum = 20 } = {}): Promi const style = getComputedStyle(element); if ( style.visibility === 'hidden' || - element.closest('[aria-hidden="true"], [inert], .coven-sr-only') || + // aria-hidden text is still seen, so it is measured; inert and + // screen-reader-only text is not. + element.closest('[inert], .coven-sr-only') || (modal && !modal.contains(element)) || // Inactive controls are exempt from contrast (WCAG 1.4.3). element.closest(':disabled, [aria-disabled="true"]') ||