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 {