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 1/2] 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 { From dc1d28c6703c7342d606c7426a650932bb84d615 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:27:51 +0200 Subject: [PATCH 2/2] fix(web): make the shell follow the light theme (#107) The composer, the shell frame and sidebar, the right side panel and the create-bot modal were hard-coded dark while the text on them follows the theme, so in the light theme the typed draft, the sidebar rows and the side panel forms rendered dark on dark. - Composer: the island uses the card and foreground tokens; attach, stop and chips use border and foreground tokens, and send is the default primary button. The focus and shadow utilities on the bar are removed: .apple-island always overrode them, so they never rendered. - Shell frame and page background use the background token. The frame's radial gradient never rendered under Tailwind v4 and is dropped. - Sidebar: the panel uses the sidebar tokens on desktop too. The main conversation, roster, project and Heute rows share one token-based row style; muted text measures 4.4:1 on the light sidebar, so row times use a dimmed foreground instead. - Side panel and create-bot modal use background, popover and overlay tokens. Settings helper texts and inactive tabs use the full muted color, which stays above 4.5:1 in light. - Stage vignette, stage pill, ensemble and hero orb, and the transcript paths behind the call overlay use tokens. - E2E: chat-contrast.spec.ts gains a shell test that measures the composer draft, sidebar rows, the new bot form and the bot settings form in both themes and captures screenshots. Claude-Session: https://claude.ai/code/session_01SSVQGR4WU92LaddTP19GcQ Co-authored-by: Claude --- apps/web/e2e/chat-contrast.spec.ts | 204 +++++++++++++----- apps/web/src/components/stage/OoStage.tsx | 6 +- apps/web/src/components/stage/StageHero.tsx | 2 +- .../src/components/stage/TextStageCanvas.tsx | 2 +- apps/web/src/pages/ScratchpadSection.tsx | 2 +- apps/web/src/pages/Shell.tsx | 123 +++++------ apps/web/src/pages/shell/bot-panel.tsx | 7 +- apps/web/src/pages/shell/roster-row-style.ts | 16 +- apps/web/src/styles.css | 16 +- packages/ui-web/src/components/ui/tabs.tsx | 2 +- 10 files changed, 235 insertions(+), 145 deletions(-) diff --git a/apps/web/e2e/chat-contrast.spec.ts b/apps/web/e2e/chat-contrast.spec.ts index 0cc05d4..c950ab6 100644 --- a/apps/web/e2e/chat-contrast.spec.ts +++ b/apps/web/e2e/chat-contrast.spec.ts @@ -1,58 +1,112 @@ import { expect, type Locator, type Page, test } from "@playwright/test"; -import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers"; +import { + captureScreenshot, + completeOnboarding, + createNamedBot, + openNewBot, + 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; +/** + * Texts in `locator` below WCAG AA against the surface they are painted on. With `inside`, every + * visible text and typed value within it counts, except in disabled controls, which WCAG exempts. + */ +function unreadableTexts(locator: Locator, inside: boolean) { + return locator.evaluate( + (root, { inside, min }) => { + 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); } - 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); - }); + 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 contrast = (element: Element) => { + 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); + }; + // What the element itself shows: a typed value, or its own text nodes. + const ownText = (element: Element) => { + if (element instanceof HTMLTextAreaElement) return element.value; + if (element instanceof HTMLInputElement) { + return /^(text|search|url|email|tel|number|password)$/.test(element.type) + ? element.value + : ""; + } + return Array.from(element.childNodes, (node) => + node.nodeType === Node.TEXT_NODE ? node.textContent : "", + ) + .join("") + .trim(); + }; + const shown = (element: Element) => { + const { width, height } = element.getBoundingClientRect(); + return width > 1 && height > 1 && element.checkVisibility(); + }; + const elements = inside + ? [root, ...root.querySelectorAll("*")].filter( + (element) => ownText(element) && shown(element) && !element.closest(":disabled"), + ) + : [root]; + return elements.flatMap((element) => { + const ratio = contrast(element); + const text = ownText(element) || element.textContent?.trim() || ""; + return ratio < min ? [`${ratio.toFixed(2)}:1 ${text.slice(0, 48)}`] : []; + }); + }, + { inside, min: MIN_CONTRAST }, + ); } -async function expectReadable(locator: Locator, what: string) { +async function expectReadable(locator: Locator, what: string, { inside = false } = {}) { await expect(locator).toBeVisible(); await expect - .poll(() => textContrast(locator), { message: `${what} is readable` }) - .toBeGreaterThanOrEqual(MIN_CONTRAST); + .poll(() => unreadableTexts(locator, inside), { message: `${what} is readable` }) + .toEqual([]); +} + +async function inEachTheme(page: Page, check: (theme: "light" | "dark") => Promise) { + for (const theme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme: theme }); + await expect(page.locator("html")).toHaveAttribute("data-theme", theme); + await check(theme); + } } function runStatus(page: Page, groupId: string) { @@ -128,10 +182,7 @@ test("conversation text stays readable in light and dark themes", async ({ page 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 inEachTheme(page, async (theme) => { await firstMessage.scrollIntoViewIfNeeded(); await captureScreenshot(page, testInfo, `chat-contrast-${theme}-reply`); await expectReadable(firstMessage, `${theme}: the user's own message`); @@ -150,5 +201,62 @@ test("conversation text stays readable in light and dark themes", async ({ page await captureScreenshot(page, testInfo, `chat-contrast-${theme}-ask`); await expectReadable(question, `${theme}: the ask card question`); await expectReadable(lastMessage, `${theme}: the latest user message`); - } + }); +}); + +test("shell surfaces stay readable in light and dark themes", async ({ page }, testInfo) => { + await page.emulateMedia({ colorScheme: "light" }); + await signup(page, `shell-contrast-${Date.now()}@rakazo.test`, "password12", "Contrast QA"); + await completeOnboarding(page); + await createNamedBot(page, "Researcher"); + const composer = page.getByRole("combobox", { name: "Message" }); + await composer.fill("Draft a note about owls"); + const sidebar = page.getByTestId("bots-sidebar"); + const sidePanel = page.getByTestId("side-panel"); + + await inEachTheme(page, async (theme) => { + await captureScreenshot(page, testInfo, `shell-contrast-${theme}`); + await expectReadable(composer, `${theme}: the composer draft`); + await expectReadable(page.getByTestId("composer-bar"), `${theme}: the composer`, { + inside: true, + }); + await expectReadable( + sidebar.getByRole("button", { name: "Heute", exact: true }), + `${theme}: Heute`, + { inside: true }, + ); + await expectReadable( + sidebar.locator("[data-main-conversation]"), + `${theme}: the main conversation row`, + { inside: true }, + ); + await expectReadable( + sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" }), + `${theme}: the selected bot row`, + { inside: true }, + ); + await expectReadable(page.getByTestId("trading-mode-status"), `${theme}: trading status`, { + inside: true, + }); + await expectReadable(page.getByTestId("user-menu-trigger"), `${theme}: the user menu`, { + inside: true, + }); + }); + + await openNewBot(page); + await page.getByTestId("create-bot-form").locator("label:has-text('Name') input").fill("Owl"); + await inEachTheme(page, async (theme) => { + await captureScreenshot(page, testInfo, `shell-contrast-${theme}-new-bot`); + await expectReadable(sidePanel, `${theme}: the new bot form`, { inside: true }); + }); + + await page.getByRole("button", { name: "Cancel new bot" }).click(); + await page.getByTestId("bot-settings-trigger").click(); + const advanced = page.getByTestId("bot-settings-advanced"); + await advanced.locator("summary").click(); + await expect(advanced.getByText("Open work")).toBeVisible(); + await inEachTheme(page, async (theme) => { + await captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`); + await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true }); + }); }); diff --git a/apps/web/src/components/stage/OoStage.tsx b/apps/web/src/components/stage/OoStage.tsx index 1fa1f26..25fa582 100644 --- a/apps/web/src/components/stage/OoStage.tsx +++ b/apps/web/src/components/stage/OoStage.tsx @@ -175,7 +175,7 @@ export function OoStage({ {(hasMessages || phase !== "idle") && (
@@ -484,7 +484,7 @@ export function OoGroupStage({ className={`relative z-10 mx-auto flex w-full max-w-3xl flex-col items-center px-4 md:px-8 ${hasMessages ? "py-2 md:py-3" : "py-5 md:py-6"}`} >
-
+
-
+
Open work
{items.length === 0 ? ( -
+
None yet
) : ( diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index bf25ff9..ae34218 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -3354,7 +3354,7 @@ export function ShellPage() {
{ if ( mobileSidebarOpen || @@ -3428,7 +3428,7 @@ export function ShellPage() { width: mobileSidebarOpen ? undefined : botsSidebarCollapsed ? 0 : leftSidebarWidth, maxWidth: mobileSidebarOpen ? 316 : undefined, }} - className={`absolute inset-y-0 start-0 z-40 flex w-[calc(100%-48px)] shrink-0 flex-col md:rounded-2xl md:border md:border-white/10 md:bg-[#0A0A0C] md:shadow-2xl bg-sidebar md:static md:z-auto md:translate-x-0 ${ + className={`absolute inset-y-0 start-0 z-40 flex w-[calc(100%-48px)] shrink-0 flex-col md:rounded-2xl md:border md:border-sidebar-border md:shadow-2xl bg-sidebar md:static md:z-auto md:translate-x-0 ${ mobileSidebarOpen ? "translate-x-0" : "-translate-x-full rtl:translate-x-full" } ${ botsSidebarCollapsed @@ -3565,13 +3565,9 @@ export function ShellPage() { setActiveNavPillar("heute"); setMobileSidebarOpen(false); }} - className={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-start transition-all duration-150 cursor-pointer min-h-[52px] ${ - activeNavPillar === "heute" - ? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30" - : "hover:bg-white/[0.04]" - }`} + className={rosterRowClass(activeNavPillar === "heute")} > -
+
{heuteActionCount > 0 ? (
@@ -3851,7 +3845,7 @@ export function ShellPage() { {needsYouChipLabel(item.chat.status)} ) : item.chat.title ? ( - + {item.chat.title} ) : null} @@ -3905,7 +3899,7 @@ export function ShellPage() { })}
{/* PROJEKTE SECTION */} -
+
{t`Projekte`}

@@ -4092,15 +4086,11 @@ export function ShellPage() { position: { x: event.clientX, y: event.clientY }, }); }} - className={`flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${ - item.kind === "bot" && !inGroup && active?.id === item.chat.id - ? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30" - : item.kind === "group" && - inGroup && - activeGroup?.id === item.chat.id - ? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30" - : "hover:bg-white/[0.05] border border-transparent" - }`} + className={rosterRowClass( + item.kind === "bot" + ? !inGroup && active?.id === item.chat.id + : inGroup && activeGroup?.id === item.chat.id, + )} > {item.kind === "bot" ? ( {item.chat.name}

- + {formatRosterTime(item.chat.updatedAt)} {item.chat.unread ? (
{cleanRosterPreview(item.chat.preview)}
@@ -4718,7 +4704,7 @@ export function ShellPage() { ? rightSidebarWidth : 0, }} - className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-white/10 md:bg-[#0A0A0C] md:shadow-2xl md:relative ${ + className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl md:relative ${ panel && ( active || @@ -4822,8 +4808,8 @@ export function ShellPage() { /> {isDesktop ? ( devServerRunning ? ( - - + + Dev ) : ( @@ -4998,11 +4984,11 @@ export function ShellPage() {
@@ -6136,13 +6122,13 @@ const Transcript = memo(function Transcript({ onClick={() => { onPromptSelect?.(t`📈 Scanne den Markt nach aktuellen Alpha-Signalen`); }} - className="apple-glass-card group flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] hover:border-sky-500/40 cursor-pointer" + className="apple-glass-card flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] cursor-pointer" > 📈 - + {t`Alpha-Signale`} - + {t`Volatilität & Orderbuch-Liquidität`} @@ -6151,13 +6137,13 @@ const Transcript = memo(function Transcript({ onClick={() => { onPromptSelect?.(t`💎 Führe ein Steve Jobs UX & Architektur-Audit durch`); }} - className="apple-glass-card group flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] hover:border-purple-500/40 cursor-pointer" + className="apple-glass-card flex flex-col items-start p-4 rounded-2xl text-start transition-all hover:scale-[1.02] cursor-pointer" > 💎 - + {t`Steve Jobs Audit`} - + {t`Schlichtheit, Eleganz & Detailtiefe`} @@ -6928,7 +6914,7 @@ const Composer = memo(function Composer({ ) : null}
fileInputRef.current?.click()} - className="tactile-press size-10 shrink-0 rounded-full border border-white/10 bg-white/[0.06] text-foreground/80 transition-all hover:bg-white/[0.12] hover:text-white flex items-center justify-center cursor-pointer" + className="tactile-press size-10 shrink-0 rounded-full border border-border bg-foreground/5 text-foreground/80 transition-all hover:bg-foreground/10 hover:text-foreground flex items-center justify-center cursor-pointer" > @@ -6952,9 +6938,9 @@ const Composer = memo(function Composer({ {selectedSkill ? ( - + {selectedSkill.name} @@ -6973,7 +6959,7 @@ const Composer = memo(function Composer({ key={mentionChipKey(mention)} data-testid="mention-chip" data-mention-kind={mention.kind} - className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.08] px-3 py-1 text-ui-body font-medium text-foreground backdrop-blur-md" + className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-border bg-foreground/[0.08] px-3 py-1 text-ui-body font-medium text-foreground backdrop-blur-md" > @@ -7053,7 +7039,7 @@ const Composer = memo(function Composer({ autoComplete="off" dir="auto" rows={1} - className="max-h-36 min-h-[28px] min-w-[8rem] flex-1 resize-none overflow-y-auto bg-transparent py-1 text-[16.5px] leading-[1.55] text-foreground outline-none placeholder:text-muted-foreground/75 disabled:opacity-40 font-normal selection:bg-emerald-500/30" + className="max-h-36 min-h-[28px] min-w-[8rem] flex-1 resize-none overflow-y-auto bg-transparent py-1 text-[16.5px] leading-[1.55] text-foreground outline-none placeholder:text-muted-foreground/75 disabled:opacity-40 font-normal" />
{onVoice ? ( @@ -7086,7 +7072,7 @@ const Composer = memo(function Composer({ aria-label={t`Send`} disabled={!canSend || disabled} onClick={send} - className="tactile-press size-10 rounded-full bg-emerald-400 text-slate-950 hover:bg-emerald-300 shadow-[0_0_16px_rgba(16,185,129,0.35)] transition-transform active:scale-90 flex items-center justify-center cursor-pointer" + className="tactile-press size-10 rounded-full transition-transform active:scale-90 flex items-center justify-center cursor-pointer" > @@ -7096,7 +7082,7 @@ const Composer = memo(function Composer({ aria-label={t`Stop`} disabled={sending} onClick={() => void onStop()} - className="tactile-press size-10 rounded-full border border-white/15 bg-white/[0.08] text-foreground/90 shadow-sm transition-colors hover:bg-white/[0.15] hover:text-white flex items-center justify-center cursor-pointer" + className="tactile-press size-10 rounded-full border border-border bg-foreground/[0.08] text-foreground/90 shadow-sm transition-colors hover:bg-foreground/15 hover:text-foreground flex items-center justify-center cursor-pointer" > @@ -7107,7 +7093,7 @@ const Composer = memo(function Composer({ aria-label={t`Send`} disabled={!canSend || disabled} onClick={send} - className="tactile-press size-10 shrink-0 rounded-full bg-emerald-400 text-slate-950 hover:bg-emerald-300 shadow-[0_0_16px_rgba(16,185,129,0.35)] transition-all active:scale-90 disabled:bg-white/[0.08] disabled:text-white/20 disabled:shadow-none flex items-center justify-center cursor-pointer" + className="tactile-press size-10 shrink-0 rounded-full transition-all active:scale-90 disabled:bg-foreground/[0.08] disabled:text-foreground/20 flex items-center justify-center cursor-pointer" > @@ -7504,19 +7490,22 @@ const MessageView = memo(function MessageView({ {effectiveSpeakerName && message.role === "bot" && !encounterMode ? (
-
+
- + {effectiveSpeakerName} - + Oo
- + {new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", diff --git a/apps/web/src/pages/shell/bot-panel.tsx b/apps/web/src/pages/shell/bot-panel.tsx index fb6563a..5120807 100644 --- a/apps/web/src/pages/shell/bot-panel.tsx +++ b/apps/web/src/pages/shell/bot-panel.tsx @@ -441,7 +441,7 @@ export function BotSettings({ }} />
-
-
+
Get notified when this Bot finishes or needs input
diff --git a/apps/web/src/pages/shell/roster-row-style.ts b/apps/web/src/pages/shell/roster-row-style.ts index f4e68dd..32cc406 100644 --- a/apps/web/src/pages/shell/roster-row-style.ts +++ b/apps/web/src/pages/shell/roster-row-style.ts @@ -1,20 +1,22 @@ /** One look for sidebar conversation rows: the main conversation entry and the bot roster. */ export function rosterRowClass(selected: boolean): string { - return `flex w-full items-center gap-3 rounded-2xl px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${ + return `flex w-full items-center gap-3 rounded-2xl border px-3 py-3 mb-[2px] text-start transition-all duration-150 cursor-pointer min-h-[52px] ${ selected - ? "bg-white/[0.09] shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)] border border-emerald-500/30" - : "hover:bg-white/[0.05] border border-transparent" + ? "border-sidebar-border bg-sidebar-accent shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)]" + : "border-transparent hover:bg-sidebar-accent/50" }`; } export function rosterNameClass(unread: boolean): string { - return unread ? "font-bold text-white" : "font-semibold text-white/95"; + return unread ? "font-bold text-sidebar-foreground" : "font-semibold text-sidebar-foreground"; } -export const rosterTimeClass = "text-ui-body-sm text-zinc-400 tabular-nums font-medium"; +// Muted text measures about 4.4:1 on the light sidebar; a dimmed foreground stays above 4.5:1. +export const rosterTimeClass = + "text-ui-body-sm text-sidebar-foreground/65 tabular-nums font-medium"; -export const rosterUnreadDotClass = "inline-block h-2 w-2 rounded-full bg-emerald-400"; +export const rosterUnreadDotClass = "inline-block h-2 w-2 rounded-full bg-sidebar-foreground"; export function rosterPreviewClass(unread: boolean): string { - return unread ? "font-medium text-white" : "text-zinc-300"; + return unread ? "font-medium text-sidebar-foreground" : "text-sidebar-foreground/85"; } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 2b69196..6b6e3ea 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -11,13 +11,7 @@ body, #root { height: 100%; margin: 0; - background: #07090e; - background-image: radial-gradient( - circle at 50% 0%, - rgba(16, 185, 129, 0.07) 0%, - rgba(14, 165, 233, 0.04) 40%, - transparent 80% - ); + background: var(--background); color: var(--foreground); font-family: "Inter Variable", -apple-system, BlinkMacSystemFont, "SF Pro Display", "Geist Variable", @@ -41,8 +35,8 @@ body, .apple-island { backdrop-filter: blur(36px) saturate(210%); -webkit-backdrop-filter: blur(36px) saturate(210%); - background: rgba(12, 16, 24, 0.88); - border: 1px solid rgba(255, 255, 255, 0.13); + background: color-mix(in srgb, var(--card) 88%, transparent); + border: 1px solid color-mix(in srgb, var(--foreground) 13%, transparent); box-shadow: 0 18px 44px -8px rgba(0, 0, 0, 0.75), inset 0 1px 0 rgba(255, 255, 255, 0.16); @@ -224,11 +218,11 @@ body, background: transparent; } .rk-scroll::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.14); + background: var(--scrollbar); border-radius: 3px; } .rk-scroll::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.28); + background: var(--scrollbar-hover); } .app-drag { diff --git a/packages/ui-web/src/components/ui/tabs.tsx b/packages/ui-web/src/components/ui/tabs.tsx index c1f427d..5fbfc8a 100644 --- a/packages/ui-web/src/components/ui/tabs.tsx +++ b/packages/ui-web/src/components/ui/tabs.tsx @@ -49,7 +49,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {