Chat markdown hard-coded a near-white text color, so on light surfaces (ask cards, delegated task briefs, peer and file previews) the text was white on white. The desktop conversation panel and the bubble and glass surfaces were hard-coded dark while the text on them follows the theme, so in the light theme the user's own messages rendered as empty pills. - Chat markdown uses the foreground token; heading, code and quote colors come from the shared @rakazo/chat-ui markdown styles instead of white/emerald overrides. Quotes read in the body color, as on mobile, so they stay legible in the dimmed transcript. - User bubbles use the chat-user tokens; bubble and glass card surfaces use card and border tokens. - The conversation panel uses the background token on desktop too, as it already did on mobile. Bot reply footers, summary cards, quick prompt chips, the awareness strip and the chart expand button drop hard-coded white/zinc/emerald classes that would vanish on that panel. - New web E2E measures the contrast of the user's messages, a markdown bot reply, a delegated brief and an ask card in a group transcript in both themes and captures screenshots. Claude-Session: https://claude.ai/code/session_01WnqLpAn8253EY1eCLKVJb2 Co-authored-by: Claude <noreply@anthropic.com>
155 lines
6.9 KiB
TypeScript
155 lines
6.9 KiB
TypeScript
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`);
|
|
}
|
|
});
|