diff --git a/apps/web/e2e/chat-contrast.spec.ts b/apps/web/e2e/chat-contrast.spec.ts new file mode 100644 index 0000000..0cc05d4 --- /dev/null +++ b/apps/web/e2e/chat-contrast.spec.ts @@ -0,0 +1,154 @@ +import { expect, type Locator, type Page, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers"; + +// WCAG AA for body text. +const MIN_CONTRAST = 4.5; + +test.use({ viewport: { width: 1280, height: 1000 } }); + +/** Contrast of an element's text against the surface it is painted on. */ +function textContrast(locator: Locator) { + return locator.evaluate((element) => { + const context = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error("No 2D canvas context"); + // The canvas normalizes every CSS color syntax (oklab, color-mix, ...) to sRGB. + const rgba = (color: string) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + const [r = 0, g = 0, b = 0, a = 0] = context.getImageData(0, 0, 1, 1).data; + return [r / 255, g / 255, b / 255, a / 255] as const; + }; + // One pixel of `color`, painted inside `from` and composited through every ancestor's + // background color and opacity onto the white canvas. + const paint = (color: readonly [number, number, number, number], from: Element) => { + let [r, g, b, a] = color; + for (let node: Element | null = from; node; node = node.parentElement) { + const style = getComputedStyle(node); + const [br, bg, bb, ba] = rgba(style.backgroundColor); + const alpha = a + ba * (1 - a); + if (alpha > 0) { + r = (r * a + br * ba * (1 - a)) / alpha; + g = (g * a + bg * ba * (1 - a)) / alpha; + b = (b * a + bb * ba * (1 - a)) / alpha; + } + a = alpha * Number(style.opacity); + } + return [r * a + 1 - a, g * a + 1 - a, b * a + 1 - a] as const; + }; + const luminance = (rgb: readonly [number, number, number]) => { + const [r = 0, g = 0, b = 0] = rgb.map((channel) => + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4, + ); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const text = luminance(paint(rgba(getComputedStyle(element).color), element)); + const surface = luminance(paint([0, 0, 0, 0], element)); + return (Math.max(text, surface) + 0.05) / (Math.min(text, surface) + 0.05); + }); +} + +async function expectReadable(locator: Locator, what: string) { + await expect(locator).toBeVisible(); + await expect + .poll(() => textContrast(locator), { message: `${what} is readable` }) + .toBeGreaterThanOrEqual(MIN_CONTRAST); +} + +function runStatus(page: Page, groupId: string) { + return rpc<{ run?: { status: string } | null }>(page, "threads/get", { groupId }).then( + (snapshot) => snapshot.run?.status ?? "idle", + ); +} + +async function send(page: Page, groupId: string, text: string) { + await expect + .poll(() => runStatus(page, groupId), { timeout: 60_000 }) + .not.toMatch(/^(queued|running)$/); + const composer = page.getByRole("combobox", { name: "Message" }); + await composer.fill(text); + await composer.press("Enter"); +} + +test("conversation text stays readable in light and dark themes", async ({ page }, testInfo) => { + const stamp = Date.now(); + await page.emulateMedia({ colorScheme: "light" }); + await signup(page, `chat-contrast-${stamp}@rakazo.test`, "password12", "Contrast QA"); + await completeOnboarding(page, undefined, { mainConversation: true }); + const groupId = new URL(page.url()).pathname.split("/").at(-1)!; + await rpc(page, "bots/create", { + name: "Researcher", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + await page.reload(); + await waitForChatComposer(page); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + const transcript = page.getByTestId("transcript"); + + // The scripted bot echoes the request, so its reply renders this markdown. + await send( + page, + groupId, + "Summarize the **owl facts** in `notes/owls.md`\n\n## Owl notes\n\n> Owls turn their heads\n\n```\nowls = 3\n```", + ); + const firstMessage = transcript + .getByTestId("message-user-bubble") + .filter({ hasText: "Summarize the **owl facts**" }); + const markdown = transcript + .locator(".rk-chat-markdown") + .filter({ hasText: "done. i handled: Summarize the owl facts" }); + const reply = markdown.locator("p").filter({ hasText: "done. i handled" }); + await expect(reply).toBeVisible({ timeout: 60_000 }); + + await send(page, groupId, "delegate to the bot named Researcher: collect three owl facts"); + const task = transcript.getByTestId("delegated-task-card"); + await expect(task).toHaveAttribute("data-status", "done", { timeout: 60_000 }); + await expect( + transcript.getByText("closing the task: collect three owl facts.").first(), + ).toBeVisible({ timeout: 60_000 }); + + await send(page, groupId, "ask me which city to use"); + await expect.poll(() => runStatus(page, groupId), { timeout: 60_000 }).toBe("waiting_input"); + const question = transcript + .locator(".rk-chat-markdown p") + .filter({ hasText: /^Which city should I use\?$/ }); + // threads/get can observe waiting_input before the realtime feed paints the ask card. + if ((await question.count()) === 0) { + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForChatComposer(page); + } + await expect(question).toBeVisible({ timeout: 15_000 }); + const lastMessage = transcript + .getByTestId("message-user-bubble") + .filter({ hasText: "ask me which city to use" }); + await task.locator("summary").click(); + const brief = task.locator(".rk-chat-markdown p"); + await expect(brief).toHaveText("collect three owl facts"); + + for (const theme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme: theme }); + await expect(page.locator("html")).toHaveAttribute("data-theme", theme); + + await firstMessage.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, `chat-contrast-${theme}-reply`); + await expectReadable(firstMessage, `${theme}: the user's own message`); + await expectReadable(reply, `${theme}: a bot reply`); + await expectReadable(reply.locator("strong"), `${theme}: bold text in a bot reply`); + await expectReadable(reply.locator("code"), `${theme}: inline code in a bot reply`); + await expectReadable(markdown.locator("h2"), `${theme}: a heading in a bot reply`); + await expectReadable(markdown.locator("blockquote p"), `${theme}: a quote in a bot reply`); + await expectReadable(markdown.locator("pre code"), `${theme}: a code block in a bot reply`); + + await brief.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, `chat-contrast-${theme}-task`); + await expectReadable(brief, `${theme}: the delegated task brief`); + + await question.scrollIntoViewIfNeeded(); + await captureScreenshot(page, testInfo, `chat-contrast-${theme}-ask`); + await expectReadable(question, `${theme}: the ask card question`); + await expectReadable(lastMessage, `${theme}: the latest user message`); + } +}); diff --git a/apps/web/src/components/voice/VoiceAwarenessStrip.tsx b/apps/web/src/components/voice/VoiceAwarenessStrip.tsx index 87fe8d7..7e026e9 100644 --- a/apps/web/src/components/voice/VoiceAwarenessStrip.tsx +++ b/apps/web/src/components/voice/VoiceAwarenessStrip.tsx @@ -28,15 +28,15 @@ export function VoiceAwarenessStrip({
+
{label}