diff --git a/apps/web/e2e/chat-contrast.spec.ts b/apps/web/e2e/chat-contrast.spec.ts new file mode 100644 index 0000000..c950ab6 --- /dev/null +++ b/apps/web/e2e/chat-contrast.spec.ts @@ -0,0 +1,262 @@ +import { expect, type Locator, type Page, test } from "@playwright/test"; +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 } }); + +/** + * 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); + } + 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, { inside = false } = {}) { + await expect(locator).toBeVisible(); + await expect + .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) { + 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"); + + 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`); + 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`); + }); +}); + +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 b5143c7..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"}`} >
-
+
= { - idle: "bg-emerald-500/70 dark:bg-emerald-400/60", - working: "bg-violet-500/75 dark:bg-violet-400/70", - listening: "bg-cyan-500/90 dark:bg-cyan-400/90", - thinking: "bg-violet-500/75 dark:bg-violet-400/70", - speaking: "bg-emerald-500/90 dark:bg-emerald-400/90", - error: "bg-rose-500/80 dark:bg-rose-400/80", + idle: "bg-emerald-400/60", + working: "bg-violet-400/70", + listening: "bg-cyan-400/90", + thinking: "bg-violet-400/70", + speaking: "bg-emerald-400/90", + error: "bg-rose-400/80", }; export function StageHero({ @@ -55,7 +55,7 @@ export function StageHero({ ) : null}
{reducedMotion ? null : (
-
+
+
-
+
{awarenessKindLabel(event.kind)}
-

+

{label}

diff --git a/apps/web/src/pages/ScratchpadSection.tsx b/apps/web/src/pages/ScratchpadSection.tsx index 3f67545..10d36a4 100644 --- a/apps/web/src/pages/ScratchpadSection.tsx +++ b/apps/web/src/pages/ScratchpadSection.tsx @@ -106,7 +106,7 @@ export function ScratchpadSection({ botId }: { botId: string }) { Open work
{items.length === 0 ? ( -
+
None yet
) : ( diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index bbd92a0..ae34218 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -290,7 +290,6 @@ import { rosterRowClass, rosterTimeClass, rosterUnreadDotClass, - selectedSidebarRowClass, } from "./shell/roster-row-style"; import { WindowChrome } from "./WindowChrome"; @@ -3355,7 +3354,7 @@ export function ShellPage() {
{ if ( mobileSidebarOpen || @@ -3429,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-transparent md:shadow-none dark:md:border-foreground/10 dark: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 @@ -3566,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" - ? selectedSidebarRowClass - : "hover:bg-foreground/[0.04]" - }`} + className={rosterRowClass(activeNavPillar === "heute")} > -
+
{heuteActionCount > 0 ? (
@@ -3852,7 +3845,7 @@ export function ShellPage() { {needsYouChipLabel(item.chat.status)} ) : item.chat.title ? ( - + {item.chat.title} ) : null} @@ -3906,7 +3899,7 @@ export function ShellPage() { })}
{/* PROJEKTE SECTION */} -
+
{t`Projekte`}

@@ -4093,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 - ? selectedSidebarRowClass - : item.kind === "group" && - inGroup && - activeGroup?.id === item.chat.id - ? selectedSidebarRowClass - : "hover:bg-foreground/[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)}
@@ -4357,7 +4342,7 @@ export function ShellPage() {
{activeNavPillar === "heute" ? (
@@ -4719,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-border md:shadow-sm dark:md:border-foreground/10 dark: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 || @@ -4823,8 +4808,8 @@ export function ShellPage() { /> {isDesktop ? ( devServerRunning ? ( - - + + Dev ) : ( @@ -5003,7 +4988,7 @@ export function ShellPage() { aria-label={t`Close`} onClick={() => setShowCreateBotModal(false)} /> -
+

Create new Bot @@ -6109,10 +6094,9 @@ const Transcript = memo(function Transcript({

- {t`Guten Tag.`} evOo{" "} - {t`ist bereit.`} + {t`Guten Tag.`} evOo {t`ist bereit.`}

-

+

{activeName ? t`Frag ${activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews.` : t`Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt.`} @@ -6123,10 +6107,10 @@ const Transcript = memo(function Transcript({ onClick={() => { onPromptSelect?.(t`⚡ Führe einen autonomen Portfolio- und Risiko-Check 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-emerald-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`Portfolio-Check`} @@ -6138,10 +6122,10 @@ 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`} @@ -6153,10 +6137,10 @@ 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`} @@ -6735,8 +6719,8 @@ const Composer = memo(function Composer({ {/* Quick Action Prompt Chips */} {showComposerPlaceholder ? (

- - + + Quick {quickPromptsForBot(activeName).map((promptText) => ( @@ -6747,7 +6731,7 @@ const Composer = memo(function Composer({ updateDraft(promptText); focusComposer(); }} - className="shrink-0 rounded-full border border-foreground/[0.1] bg-foreground/[0.05] px-3.5 py-1.5 text-ui-body-sm font-medium text-foreground/85 hover:border-foreground/20 dark:hover:border-emerald-500/40 hover:bg-foreground/[0.1] hover:text-foreground 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} @@ -6930,7 +6914,7 @@ const Composer = memo(function Composer({ ) : null}
fileInputRef.current?.click()} - className="tactile-press size-10 shrink-0 rounded-full border border-foreground/10 bg-foreground/[0.06] text-foreground/80 transition-all hover:bg-foreground/[0.12] hover:text-foreground 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" > @@ -6954,12 +6938,9 @@ const Composer = memo(function Composer({ {selectedSkill ? ( - + {selectedSkill.name} @@ -6978,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-foreground/10 bg-foreground/[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" > @@ -7058,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 ? ( @@ -7091,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-primary text-primary-foreground hover:bg-primary/85 dark:bg-emerald-400 dark:text-slate-950 dark:hover:bg-emerald-300 dark: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" > @@ -7101,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-foreground/15 bg-foreground/[0.08] text-foreground/90 shadow-sm transition-colors hover:bg-foreground/[0.15] hover:text-foreground 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" > @@ -7112,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-primary text-primary-foreground hover:bg-primary/85 dark:bg-emerald-400 dark:text-slate-950 dark:hover:bg-emerald-300 dark:shadow-[0_0_16px_rgba(16,185,129,0.35)] transition-all active:scale-90 disabled:bg-foreground/[0.08] disabled:text-foreground/20 disabled:shadow-none dark:disabled:bg-foreground/[0.08] dark:disabled:text-foreground/20 dark: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" > @@ -7509,7 +7490,7 @@ const MessageView = memo(function MessageView({ {effectiveSpeakerName && message.role === "bot" && !encounterMode ? (
-
+
@@ -7519,7 +7500,7 @@ const MessageView = memo(function MessageView({ > {effectiveSpeakerName} - + Oo
@@ -7880,8 +7861,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-foreground/10 bg-foreground/[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-chat-user-foreground dark:text-[#FAFAFA] dark: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" > @@ -7903,7 +7884,7 @@ const MessageView = memo(function MessageView({ dir="auto" > {block.text} -
+
-
-
+
Get notified when this Bot finishes or needs input
diff --git a/apps/web/src/pages/shell/message-cards.tsx b/apps/web/src/pages/shell/message-cards.tsx index f8bb677..d53e9f8 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/pages/shell/roster-row-style.ts b/apps/web/src/pages/shell/roster-row-style.ts index 528a6b1..b924524 100644 --- a/apps/web/src/pages/shell/roster-row-style.ts +++ b/apps/web/src/pages/shell/roster-row-style.ts @@ -1,23 +1,22 @@ -/** Selected sidebar row: a raised paper row in light, a lit glass row in dark. */ -export const selectedSidebarRowClass = - "border border-sidebar-border bg-sidebar-accent shadow-xs dark:border-emerald-500/30 dark:bg-foreground/[0.09] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_4px_16px_rgba(0,0,0,0.4)]"; - /** 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] ${ - selected ? selectedSidebarRowClass : "hover:bg-foreground/[0.05] border border-transparent" + 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 + ? "border-sidebar-border bg-sidebar-accent shadow-xs dark: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-foreground" : "font-semibold text-foreground/95"; + return unread ? "font-bold text-sidebar-foreground" : "font-semibold text-sidebar-foreground"; } -export const rosterTimeClass = "text-ui-body-sm text-muted-foreground 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-500 dark: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-foreground" : "text-foreground/75"; + 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 30473a2..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); @@ -51,24 +45,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); @@ -76,45 +70,6 @@ body, -webkit-backdrop-filter: blur(20px); } -/* Light theme: paper surfaces with hairline borders and soft, short shadows - instead of dark glass. */ -html[data-theme="light"], -html[data-theme="light"] body, -html[data-theme="light"] #root { - background: var(--sidebar); -} - -[data-theme="light"] .apple-glass, -[data-theme="light"] .apple-glass-card { - background: color-mix(in srgb, var(--card) 88%, transparent); - border-color: var(--border); - box-shadow: - 0 1px 2px rgba(24, 24, 22, 0.04), - 0 8px 24px -12px rgba(24, 24, 22, 0.1); -} - -[data-theme="light"] .apple-island { - background: color-mix(in srgb, var(--card) 92%, transparent); - border-color: var(--input); - box-shadow: - 0 1px 2px rgba(24, 24, 22, 0.05), - 0 12px 32px -14px rgba(24, 24, 22, 0.16); -} - -[data-theme="light"] .chat-bubble-user { - background: var(--chat-user); - border-color: transparent; - box-shadow: none; -} - -[data-theme="light"] .chat-bubble-bot { - background: var(--card); - border-color: var(--border); - box-shadow: 0 1px 2px rgba(24, 24, 22, 0.04); - backdrop-filter: none; - -webkit-backdrop-filter: none; -} - /* Shell layout utilities — token-backed, use instead of one-off pixel classes */ @layer components { .rk-shell-section-label { @@ -134,7 +89,8 @@ html[data-theme="light"] #root { } } -/* 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); @@ -149,8 +105,6 @@ html[data-theme="light"] #root { } .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), @@ -161,21 +115,12 @@ html[data-theme="light"] #root { font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } -[data-theme="light"] .rk-chat-markdown pre { - background: var(--muted); - border-color: var(--border); - box-shadow: none; -} - .sidebar-roster-row[data-selected="true"], .sidebar-roster-row:hover { background-color: var(--accent); } .rk-chat-markdown code:not(pre code) { - background: color-mix(in srgb, var(--foreground) 7%, transparent); - border: 1px solid color-mix(in srgb, var(--foreground) 11%, transparent); - color: var(--foreground); font-weight: 500; border-radius: 6px; padding: 0.15em 0.4em; @@ -188,7 +133,6 @@ html[data-theme="light"] #root { font-weight: 700; line-height: 1.3; letter-spacing: -0.025em; - color: var(--foreground); margin: 1.6em 0 0.6em; } @@ -197,7 +141,6 @@ html[data-theme="light"] #root { font-weight: 600; line-height: 1.35; letter-spacing: -0.02em; - color: var(--foreground); margin: 1.4em 0 0.5em; } @@ -206,7 +149,6 @@ html[data-theme="light"] #root { font-weight: 600; line-height: 1.4; letter-spacing: -0.015em; - color: var(--foreground); margin: 1.2em 0 0.4em; } @@ -222,29 +164,18 @@ html[data-theme="light"] #root { } .rk-chat-markdown strong { - color: var(--foreground); 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; } -[data-theme="light"] .rk-chat-markdown blockquote { - border-left-color: var(--input); - color: var(--muted-foreground); - background: none; - border-radius: 0; -} - :root { --spring-snappy: cubic-bezier(0.175, 0.885, 0.32, 1.275); --spring-smooth: cubic-bezier(0.16, 1, 0.3, 1); @@ -262,10 +193,6 @@ html[data-theme="light"] #root { box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.18); } -[data-theme="light"] .tactile-press:hover { - box-shadow: none; -} - .tactile-press:active { transform: scale(0.965); transition-duration: 0.06s; 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 { 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) {