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 <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-03 14:43:05 +02:00 committed by GitHub
parent 110984b7bc
commit 3d180dc2dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 177 additions and 35 deletions

View File

@ -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`);
}
});

View File

@ -28,15 +28,15 @@ export function VoiceAwarenessStrip({
<div <div
role="status" role="status"
data-testid="voice-awareness" data-testid="voice-awareness"
className="mx-auto flex max-w-2xl items-start gap-3 rounded-[20px] border border-white/10 bg-white/[0.05] px-4 py-3 backdrop-blur-md" className="mx-auto flex max-w-2xl items-start gap-3 rounded-[20px] border border-border bg-muted/40 px-4 py-3 backdrop-blur-md"
> >
<BotAvatar color={botColor} identity={botId} size={32} className="shrink-0" /> <BotAvatar color={botColor} identity={botId} size={32} className="shrink-0" />
<div className="min-w-0 flex-1 text-start"> <div className="min-w-0 flex-1 text-start">
<div className="flex items-center gap-2 text-ui-label font-semibold uppercase tracking-wide text-zinc-300"> <div className="flex items-center gap-2 text-ui-label font-semibold uppercase tracking-wide text-muted-foreground">
<Icon strokeWidth={1.8} className="size-icon-sm shrink-0 opacity-80" /> <Icon strokeWidth={1.8} className="size-icon-sm shrink-0 opacity-80" />
<span>{awarenessKindLabel(event.kind)}</span> <span>{awarenessKindLabel(event.kind)}</span>
</div> </div>
<p dir="auto" className="mt-1 text-ui-body leading-relaxed text-zinc-100"> <p dir="auto" className="mt-1 text-ui-body leading-relaxed text-foreground">
{label} {label}
</p> </p>
</div> </div>

View File

@ -4356,7 +4356,7 @@ export function ShellPage() {
<main <main
aria-hidden={mobileSidebarOpen || undefined} aria-hidden={mobileSidebarOpen || undefined}
inert={mobileSidebarOpen} inert={mobileSidebarOpen}
className="flex min-w-0 flex-1 flex-col bg-background md:rounded-2xl md:border md:border-white/10 md:bg-[#0A0A0C] md:shadow-2xl overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" className="flex min-w-0 flex-1 flex-col bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]"
> >
{activeNavPillar === "heute" ? ( {activeNavPillar === "heute" ? (
<div className="flex h-full flex-1 flex-col overflow-hidden"> <div className="flex h-full flex-1 flex-col overflow-hidden">
@ -6733,7 +6733,7 @@ const Composer = memo(function Composer({
{/* Quick Action Prompt Chips */} {/* Quick Action Prompt Chips */}
{showComposerPlaceholder ? ( {showComposerPlaceholder ? (
<div className="mb-3 flex items-center gap-2 overflow-x-auto no-scrollbar py-1"> <div className="mb-3 flex items-center gap-2 overflow-x-auto no-scrollbar py-1">
<span className="flex items-center gap-1.5 text-ui-label font-semibold uppercase tracking-wider text-emerald-400 shrink-0 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 me-1 shadow-xs"> <span className="flex items-center gap-1.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground shrink-0 px-2.5 py-1 rounded-full bg-muted/40 border border-border me-1 shadow-xs">
<Sparkles className="text-amber-400 size-icon-sm" /> <Sparkles className="text-amber-400 size-icon-sm" />
Quick Quick
</span> </span>
@ -6745,7 +6745,7 @@ const Composer = memo(function Composer({
updateDraft(promptText); updateDraft(promptText);
focusComposer(); 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} {promptText}
</button> </button>
@ -7872,8 +7872,8 @@ const MessageView = memo(function MessageView({
data-testid="message-user-bubble" data-testid="message-user-bubble"
className={ className={
encounterMode 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" ? "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-[#FAFAFA] shadow-md" : "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" dir="auto"
> >
@ -7895,7 +7895,7 @@ const MessageView = memo(function MessageView({
dir="auto" dir="auto"
> >
<ChatMarkdown>{block.text}</ChatMarkdown> <ChatMarkdown>{block.text}</ChatMarkdown>
<div className="mt-3 pt-2.5 flex items-center justify-between border-t border-white/[0.08] text-ui-label text-muted-foreground/75"> <div className="mt-3 pt-2.5 flex items-center justify-between border-t border-border text-ui-label text-muted-foreground/75">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"
@ -7904,7 +7904,7 @@ const MessageView = memo(function MessageView({
navigator.clipboard.writeText(block.text); navigator.clipboard.writeText(block.text);
} }
}} }}
className="inline-flex items-center gap-1.5 hover:text-white transition-colors cursor-pointer text-ui-body-sm font-medium" className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium"
title={t`Antwort kopieren`} title={t`Antwort kopieren`}
> >
<Copy size={13.5} /> <Copy size={13.5} />
@ -7915,14 +7915,14 @@ const MessageView = memo(function MessageView({
type="button" type="button"
aria-label={speaking ? t`Stop speaking` : t`Speak this reply`} aria-label={speaking ? t`Stop speaking` : t`Speak this reply`}
onClick={onSpeak} onClick={onSpeak}
className="inline-flex items-center gap-1.5 hover:text-white transition-colors cursor-pointer text-ui-body-sm font-medium" className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium"
> >
<Volume2 size={13.5} /> <Volume2 size={13.5} />
<span>{speaking ? t`Stop` : t`Vorlesen`}</span> <span>{speaking ? t`Stop` : t`Vorlesen`}</span>
</button> </button>
) : null} ) : null}
</div> </div>
<span className="tabular-nums font-mono text-ui-label text-zinc-400"> <span className="tabular-nums font-mono text-ui-label text-muted-foreground">
{new Date(message.createdAt).toLocaleTimeString([], { {new Date(message.createdAt).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
@ -7940,7 +7940,7 @@ const MessageView = memo(function MessageView({
{block.lines.map((line) => ( {block.lines.map((line) => (
<div key={line.k} className="flex items-baseline gap-2.5 text-ui-body"> <div key={line.k} className="flex items-baseline gap-2.5 text-ui-body">
<span className="text-success">✓</span> <span className="text-success">✓</span>
<span className="font-semibold text-white">{line.k}</span> <span className="font-semibold text-foreground">{line.k}</span>
<span className="text-muted-foreground">→</span> <span className="text-muted-foreground">→</span>
<span>{line.v}</span> <span>{line.v}</span>
</div> </div>

View File

@ -428,7 +428,7 @@ export function ChartBlockView({
variant="outline" variant="outline"
size="xs" size="xs"
onClick={() => setExpanded(true)} 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"
> >
<Trans>Expand</Trans> <Trans>Expand</Trans>
</Button> </Button>

View File

@ -51,24 +51,24 @@ body,
.apple-glass-card { .apple-glass-card {
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
background: rgba(16, 21, 30, 0.68); background: color-mix(in srgb, var(--card) 68%, transparent);
border: 1px solid rgba(255, 255, 255, 0.09); border: 1px solid var(--border);
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 6px 24px rgba(0, 0, 0, 0.35); 0 6px 24px rgba(0, 0, 0, 0.35);
} }
.chat-bubble-user { .chat-bubble-user {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%); background: var(--chat-user);
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid var(--border);
box-shadow: box-shadow:
0 8px 24px -4px rgba(0, 0, 0, 0.5), 0 8px 24px -4px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.2); inset 0 1px 0 rgba(255, 255, 255, 0.2);
} }
.chat-bubble-bot { .chat-bubble-bot {
background: rgba(15, 20, 29, 0.72); background: color-mix(in srgb, var(--card) 72%, transparent);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--border);
box-shadow: box-shadow:
0 10px 32px -4px rgba(0, 0, 0, 0.35), 0 10px 32px -4px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.08); 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 { .rk-chat-markdown {
font-size: var(--font-size-reading); font-size: var(--font-size-reading);
line-height: var(--line-height-reading); line-height: var(--line-height-reading);
color: #f8fafc; color: var(--foreground);
letter-spacing: -0.011em; letter-spacing: -0.011em;
font-weight: 400; font-weight: 400;
} }
@ -110,8 +111,6 @@ body,
} }
.rk-chat-markdown pre { .rk-chat-markdown pre {
background: rgba(8, 11, 16, 0.96);
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 16px; border-radius: 16px;
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.09), inset 0 1px 0 rgba(255, 255, 255, 0.09),
@ -128,9 +127,6 @@ body,
} }
.rk-chat-markdown code:not(pre code) { .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; font-weight: 500;
border-radius: 6px; border-radius: 6px;
padding: 0.15em 0.4em; padding: 0.15em 0.4em;
@ -143,7 +139,6 @@ body,
font-weight: 700; font-weight: 700;
line-height: 1.3; line-height: 1.3;
letter-spacing: -0.025em; letter-spacing: -0.025em;
color: #ffffff;
margin: 1.6em 0 0.6em; margin: 1.6em 0 0.6em;
} }
@ -152,7 +147,6 @@ body,
font-weight: 600; font-weight: 600;
line-height: 1.35; line-height: 1.35;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: #ffffff;
margin: 1.4em 0 0.5em; margin: 1.4em 0 0.5em;
} }
@ -161,7 +155,6 @@ body,
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.4;
letter-spacing: -0.015em; letter-spacing: -0.015em;
color: #ffffff;
margin: 1.2em 0 0.4em; margin: 1.2em 0 0.4em;
} }
@ -177,17 +170,13 @@ body,
} }
.rk-chat-markdown strong { .rk-chat-markdown strong {
color: #ffffff;
font-weight: 600; font-weight: 600;
} }
.rk-chat-markdown blockquote { .rk-chat-markdown blockquote {
border-left: 3px solid #10b981;
padding-left: 1.2em; padding-left: 1.2em;
margin: 1.3em 0; margin: 1.3em 0;
color: #cbd5e1;
font-style: normal; font-style: normal;
background: rgba(16, 185, 129, 0.05);
border-radius: 0 8px 8px 0; border-radius: 0 8px 8px 0;
padding-top: 0.4em; padding-top: 0.4em;
padding-bottom: 0.4em; padding-bottom: 0.4em;

View File

@ -157,7 +157,6 @@
.rk-chat-markdown blockquote { .rk-chat-markdown blockquote {
border-left: 3px solid var(--border); border-left: 3px solid var(--border);
padding-left: 0.85em; padding-left: 0.85em;
color: var(--muted-foreground);
} }
.rk-chat-markdown table { .rk-chat-markdown table {