diff --git a/e2e/app.tauri-mock.spec.ts b/e2e/app.tauri-mock.spec.ts index e1668f80..95afd641 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,53 @@ 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'); + 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(); + + 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 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 c0ebb22b..be1fb48a 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,35 @@ 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, context picker and mentions 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'); + 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?' }); + 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(); + 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 new file mode 100644 index 00000000..5dc36670 --- /dev/null +++ b/e2e/contrast.ts @@ -0,0 +1,113 @@ +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' || + // 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"]') || + (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;