Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 47 additions & 100 deletions e2e/app.tauri-mock.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, type Page, test } from '@playwright/test';
import { contrastFailures } from './contrast';

declare global {
interface Window {
Expand Down Expand Up @@ -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<Element>();
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([]);
});
}

Expand Down
34 changes: 34 additions & 0 deletions e2e/chat-lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';

import { contrastFailures } from './contrast';

test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
const session = {
Expand Down Expand Up @@ -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([]);
});
}
113 changes: 113 additions & 0 deletions e2e/contrast.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
// 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<Element>();
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;
}
7 changes: 7 additions & 0 deletions src/coven/context-picker.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading