From 3d180dc2ddebaec64c688af9512c5a2aa217267c Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:43:05 +0200 Subject: [PATCH] fix(web): keep chat text readable in the light theme (#104) 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 --- apps/web/e2e/chat-contrast.spec.ts | 154 ++++++++++++++++++ .../components/voice/VoiceAwarenessStrip.tsx | 6 +- apps/web/src/pages/Shell.tsx | 20 +-- apps/web/src/pages/shell/message-cards.tsx | 2 +- apps/web/src/styles.css | 29 +--- packages/chat-ui/src/markdown.web.css | 1 - 6 files changed, 177 insertions(+), 35 deletions(-) create mode 100644 apps/web/e2e/chat-contrast.spec.ts 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({
-
+
{awarenessKindLabel(event.kind)}
-

+

{label}

diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 07af7f4..bf25ff9 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -4356,7 +4356,7 @@ export function ShellPage() {
{activeNavPillar === "heute" ? (
@@ -6733,7 +6733,7 @@ const Composer = memo(function Composer({ {/* Quick Action Prompt Chips */} {showComposerPlaceholder ? (
- + Quick @@ -6745,7 +6745,7 @@ const Composer = memo(function Composer({ updateDraft(promptText); focusComposer(); }} - className="shrink-0 rounded-full border border-white/[0.1] bg-white/[0.05] px-3.5 py-1.5 text-ui-body-sm font-medium text-zinc-200 hover:border-emerald-500/40 hover:bg-white/[0.1] hover:text-white transition-all shadow-sm active:scale-95 cursor-pointer" + className="shrink-0 rounded-full border border-border bg-muted/40 px-3.5 py-1.5 text-ui-body-sm font-medium text-foreground/85 hover:bg-accent hover:text-foreground transition-all shadow-sm active:scale-95 cursor-pointer" > {promptText} @@ -7872,8 +7872,8 @@ const MessageView = memo(function MessageView({ data-testid="message-user-bubble" className={ encounterMode - ? "max-w-full whitespace-pre-wrap wrap-anywhere rounded-2xl border border-white/10 bg-white/[0.06] px-3.5 py-2 text-ui-body-sm leading-relaxed text-foreground/95" - : "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-[17px] leading-[1.62] text-[#FAFAFA] shadow-md" + ? "max-w-full whitespace-pre-wrap wrap-anywhere rounded-2xl bg-chat-user px-3.5 py-2 text-ui-body-sm leading-relaxed text-chat-user-foreground" + : "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-[17px] leading-[1.62] text-chat-user-foreground shadow-md" } dir="auto" > @@ -7895,7 +7895,7 @@ const MessageView = memo(function MessageView({ dir="auto" > {block.text} -
+
) : null}
- + {new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", @@ -7940,7 +7940,7 @@ const MessageView = memo(function MessageView({ {block.lines.map((line) => (
✓ - {line.k} + {line.k} → {line.v}
diff --git a/apps/web/src/pages/shell/message-cards.tsx b/apps/web/src/pages/shell/message-cards.tsx index 0112f1a..14131d6 100644 --- a/apps/web/src/pages/shell/message-cards.tsx +++ b/apps/web/src/pages/shell/message-cards.tsx @@ -428,7 +428,7 @@ export function ChartBlockView({ variant="outline" size="xs" onClick={() => setExpanded(true)} - className="absolute end-3 top-3 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 rounded-full border-white/15 bg-white/10 text-white backdrop-blur-md" + className="absolute end-3 top-3 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 rounded-full" > Expand diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 9190132..2b69196 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -51,24 +51,24 @@ body, .apple-glass-card { backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); - background: rgba(16, 21, 30, 0.68); - border: 1px solid rgba(255, 255, 255, 0.09); + background: color-mix(in srgb, var(--card) 68%, transparent); + border: 1px solid var(--border); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 6px 24px rgba(0, 0, 0, 0.35); } .chat-bubble-user { - background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%); - border: 1px solid rgba(255, 255, 255, 0.14); + background: var(--chat-user); + border: 1px solid var(--border); box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.2); } .chat-bubble-bot { - background: rgba(15, 20, 29, 0.72); - border: 1px solid rgba(255, 255, 255, 0.08); + background: color-mix(in srgb, var(--card) 72%, transparent); + border: 1px solid var(--border); box-shadow: 0 10px 32px -4px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.08); @@ -95,11 +95,12 @@ body, } } -/* Gemini-Grade Accessible Typography (Apple-like with Inter Variable) */ +/* Gemini-Grade Accessible Typography (Apple-like with Inter Variable). + Heading, code and quote colors come from the shared @rakazo/chat-ui markdown styles. */ .rk-chat-markdown { font-size: var(--font-size-reading); line-height: var(--line-height-reading); - color: #f8fafc; + color: var(--foreground); letter-spacing: -0.011em; font-weight: 400; } @@ -110,8 +111,6 @@ body, } .rk-chat-markdown pre { - background: rgba(8, 11, 16, 0.96); - border: 1px solid rgba(255, 255, 255, 0.11); border-radius: 16px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.09), @@ -128,9 +127,6 @@ body, } .rk-chat-markdown code:not(pre code) { - background: rgba(255, 255, 255, 0.09); - border: 1px solid rgba(255, 255, 255, 0.13); - color: var(--foreground); font-weight: 500; border-radius: 6px; padding: 0.15em 0.4em; @@ -143,7 +139,6 @@ body, font-weight: 700; line-height: 1.3; letter-spacing: -0.025em; - color: #ffffff; margin: 1.6em 0 0.6em; } @@ -152,7 +147,6 @@ body, font-weight: 600; line-height: 1.35; letter-spacing: -0.02em; - color: #ffffff; margin: 1.4em 0 0.5em; } @@ -161,7 +155,6 @@ body, font-weight: 600; line-height: 1.4; letter-spacing: -0.015em; - color: #ffffff; margin: 1.2em 0 0.4em; } @@ -177,17 +170,13 @@ body, } .rk-chat-markdown strong { - color: #ffffff; font-weight: 600; } .rk-chat-markdown blockquote { - border-left: 3px solid #10b981; padding-left: 1.2em; margin: 1.3em 0; - color: #cbd5e1; font-style: normal; - background: rgba(16, 185, 129, 0.05); border-radius: 0 8px 8px 0; padding-top: 0.4em; padding-bottom: 0.4em; diff --git a/packages/chat-ui/src/markdown.web.css b/packages/chat-ui/src/markdown.web.css index 1a6ee59..caf9904 100644 --- a/packages/chat-ui/src/markdown.web.css +++ b/packages/chat-ui/src/markdown.web.css @@ -157,7 +157,6 @@ .rk-chat-markdown blockquote { border-left: 3px solid var(--border); padding-left: 0.85em; - color: var(--muted-foreground); } .rk-chat-markdown table {