fix(web): make the shell follow the light theme

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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SSVQGR4WU92LaddTP19GcQ
This commit is contained in:
Claude 2026-10-03 12:13:10 +00:00 committed by Cursor Agent
parent 3d180dc2dd
commit 396c0236d1
No known key found for this signature in database
10 changed files with 235 additions and 145 deletions

View File

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

View File

@ -175,7 +175,7 @@ export function OoStage({
{(hasMessages || phase !== "idle") && (
<div className={`flex w-full justify-center ${hasMessages ? "mb-1" : "mb-3"}`}>
<div
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm"
className="inline-flex items-center gap-2 rounded-full border border-border bg-foreground/5 px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm"
style={
reducedMotion
? undefined
@ -280,7 +280,7 @@ function StageEnsemble({
return (
<li key={member.botId} className="flex flex-col items-center gap-1" title={member.name}>
<div
className={`relative flex size-14 items-center justify-center rounded-full border border-white/12 bg-slate-950/40 backdrop-blur-md ${
className={`relative flex size-14 items-center justify-center rounded-full border border-border bg-muted/40 backdrop-blur-md ${
isWorking && !reducedMotion ? "animate-pulse" : ""
}`}
>
@ -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"}`}
>
<div className="mb-3 w-full min-w-0 text-center">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm">
<div className="inline-flex items-center gap-2 rounded-full border border-border bg-foreground/5 px-2.5 py-0.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground backdrop-blur-sm">
<span
className={`size-1.5 rounded-full ${
running ? "bg-violet-400" : "bg-emerald-400"

View File

@ -55,7 +55,7 @@ export function StageHero({
) : null}
<div
className={`relative flex ${orbSize} items-center justify-center rounded-full border border-white/14 bg-slate-950/45 shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-xl`}
className={`relative flex ${orbSize} items-center justify-center rounded-full border border-border bg-muted/45 shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-xl`}
style={
reducedMotion
? undefined

View File

@ -35,7 +35,7 @@ export function TextStageCanvas({
return (
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_100%_70%_at_50%_0%,rgba(12,16,24,0.55)_0%,transparent_68%)]" />
<div className="absolute inset-0 bg-radial-[ellipse_100%_70%_at_50%_0%] from-card/55 to-transparent to-68%" />
<div
className={`absolute left-1/2 top-0 h-[min(48vh,320px)] w-[min(72vw,480px)] -translate-x-1/2 rounded-full blur-[72px] ${reducedMotion ? "" : "voice-bloom-emerald"}`}
style={{

View File

@ -106,7 +106,7 @@ export function ScratchpadSection({ botId }: { botId: string }) {
<Trans>Open work</Trans>
</div>
{items.length === 0 ? (
<div className="py-1 text-ui-body text-muted-foreground/80">
<div className="py-1 text-ui-body text-muted-foreground">
<Trans>None yet</Trans>
</div>
) : (

View File

@ -3354,7 +3354,7 @@ export function ShellPage() {
<div
data-testid="shell-root"
data-ready={shellReady}
className="relative flex h-full min-w-0 overflow-hidden bg-black bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-zinc-900 via-black to-black text-foreground/90 p-0 md:p-3 gap-0 md:gap-3"
className="relative flex h-full min-w-0 overflow-hidden bg-background text-foreground/90 p-0 md:p-3 gap-0 md:gap-3"
onTouchStartCapture={(event) => {
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")}
>
<div className="relative grid size-control-lg shrink-0 place-items-center rounded-full bg-emerald-500/10 text-emerald-400">
<div className="relative grid size-control-lg shrink-0 place-items-center rounded-full bg-muted text-muted-foreground">
<Calendar strokeWidth={2.2} className="size-icon-md" />
{heuteActionCount > 0 ? (
<span
@ -3825,12 +3821,10 @@ export function ShellPage() {
status={item.chat.status}
/>
<span
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[#0b0c10] ${
rosterStatusDot(item.chat.status) === "needs-you"
? "bg-warning animate-pulse"
: rosterStatusDot(item.chat.status) === "working"
? "bg-amber-400 animate-pulse"
: "bg-emerald-400"
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-sidebar ${
rosterStatusDot(item.chat.status) === "idle"
? "bg-success"
: "bg-warning animate-pulse"
}`}
/>
</div>
@ -3851,7 +3845,7 @@ export function ShellPage() {
{needsYouChipLabel(item.chat.status)}
</span>
) : item.chat.title ? (
<span className="min-w-0 truncate rounded-full border border-emerald-500/25 bg-emerald-500/10 px-2 py-0.5 text-ui-label font-medium text-emerald-400">
<span className="min-w-0 truncate rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label font-medium text-muted-foreground">
{item.chat.title}
</span>
) : null}
@ -3905,7 +3899,7 @@ export function ShellPage() {
})}
<div className="sidebar-projekte">
{/* PROJEKTE SECTION */}
<div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-white/5 px-2.5">
<div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-sidebar-border px-2.5">
<div>
<div className="rk-shell-section-label">{t`Projekte`}</div>
<p className="mt-0.5 px-0.5 text-ui-caption text-muted-foreground/70">
@ -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" ? (
<BotAvatar
@ -4117,34 +4107,30 @@ export function ShellPage() {
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<span
dir="auto"
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${
item.chat.unread
? "font-bold text-white"
: "font-semibold text-white/95"
}`}
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${rosterNameClass(
item.chat.unread,
)}`}
>
{item.chat.name}
</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span className="text-ui-body-sm text-zinc-400 tabular-nums font-medium">
<span className={rosterTimeClass}>
{formatRosterTime(item.chat.updatedAt)}
</span>
{item.chat.unread ? (
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-full bg-emerald-400"
className={rosterUnreadDotClass}
/>
) : null}
</div>
</div>
<div
dir="auto"
className={`mt-0.5 truncate text-ui-body leading-snug ${
item.chat.unread
? "font-medium text-white"
: "text-zinc-300"
}`}
className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(
item.chat.unread,
)}`}
>
{cleanRosterPreview(item.chat.preview)}
</div>
@ -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 ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-ui-label font-medium text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-400" />
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-ui-label font-medium text-foreground">
<span className="size-1.5 rounded-full bg-success" />
<Trans>Dev</Trans>
</span>
) : (
@ -4998,11 +4984,11 @@ export function ShellPage() {
<div className="fixed inset-0 z-50 flex items-center justify-center">
<button
type="button"
className="absolute inset-0 bg-black/60"
className="absolute inset-0 bg-overlay"
aria-label={t`Close`}
onClick={() => setShowCreateBotModal(false)}
/>
<div className="relative z-10 max-h-[90vh] w-[480px] overflow-y-auto rounded-2xl border border-white/10 bg-[#0A0A0C] p-6 shadow-2xl">
<div className="relative z-10 max-h-[90vh] w-[480px] overflow-y-auto rounded-2xl border border-border bg-popover p-6 text-popover-foreground shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
<Trans>Create new Bot</Trans>
@ -6107,10 +6093,10 @@ const Transcript = memo(function Transcript({
<div className="relative mb-5 flex items-center justify-center p-3">
<EvooMascot size={76} pose="greeting" interactive trackPointer="hover" />
</div>
<h2 className="text-3xl font-bold tracking-tight text-white mb-2">
{t`Guten Tag.`} <span className="text-emerald-400">evOo</span> {t`ist bereit.`}
<h2 className="text-3xl font-bold tracking-tight text-foreground mb-2">
{t`Guten Tag.`} evOo {t`ist bereit.`}
</h2>
<p className="max-w-md text-ui-body leading-relaxed text-zinc-300 mb-8 font-normal">
<p className="max-w-md text-ui-body leading-relaxed text-foreground/85 mb-8 font-normal">
{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.`}
@ -6121,13 +6107,13 @@ 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"
>
<span className="text-xl mb-2">⚡</span>
<span className="text-[14.5px] font-semibold text-white group-hover:text-emerald-400 transition-colors">
<span className="text-[14.5px] font-semibold text-foreground">
{t`Portfolio-Check`}
</span>
<span className="text-ui-body-sm text-zinc-400 mt-1 leading-snug">
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
{t`Echtzeit-Risiko & Drawdown-Analyse`}
</span>
</button>
@ -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"
>
<span className="text-xl mb-2">📈</span>
<span className="text-[14.5px] font-semibold text-white group-hover:text-sky-400 transition-colors">
<span className="text-[14.5px] font-semibold text-foreground">
{t`Alpha-Signale`}
</span>
<span className="text-ui-body-sm text-zinc-400 mt-1 leading-snug">
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
{t`Volatilität & Orderbuch-Liquidität`}
</span>
</button>
@ -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"
>
<span className="text-xl mb-2">💎</span>
<span className="text-[14.5px] font-semibold text-white group-hover:text-purple-400 transition-colors">
<span className="text-[14.5px] font-semibold text-foreground">
{t`Steve Jobs Audit`}
</span>
<span className="text-ui-body-sm text-zinc-400 mt-1 leading-snug">
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
{t`Schlichtheit, Eleganz & Detailtiefe`}
</span>
</button>
@ -6928,7 +6914,7 @@ const Composer = memo(function Composer({
) : null}
<div
data-testid="composer-bar"
className="apple-island relative flex items-center gap-3.5 rounded-[30px] py-3 pe-3.5 ps-4 transition-all duration-200 focus-within:border-emerald-500/40 focus-within:shadow-[0_20px_50px_-10px_rgba(0,0,0,0.85),inset_0_1px_0_rgba(255,255,255,0.22),0_0_0_1px_rgba(16,185,129,0.35)] shadow-[0_16px_40px_-8px_rgba(0,0,0,0.7),inset_0_1px_0_rgba(255,255,255,0.14)]"
className="apple-island relative flex items-center gap-3.5 rounded-[30px] py-3 pe-3.5 ps-4 transition-all duration-200"
>
<input
ref={fileInputRef}
@ -6944,7 +6930,7 @@ const Composer = memo(function Composer({
aria-label={t`Attach file`}
disabled={disabled}
onClick={() => 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"
>
<Plus strokeWidth={2.2} className="size-icon-lg" />
</Button>
@ -6952,9 +6938,9 @@ const Composer = memo(function Composer({
{selectedSkill ? (
<span
data-testid="skill-chip"
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"
>
<Box strokeWidth={1.8} className="shrink-0 text-blue-400 size-icon-md" />
<Box strokeWidth={1.8} className="shrink-0 text-muted-foreground size-icon-md" />
<span dir="auto" className="truncate">
{selectedSkill.name}
</span>
@ -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"
>
<MentionChipIcon mention={mention} />
<span dir="auto" className="truncate">
@ -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"
/>
</div>
{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"
>
<ArrowUp strokeWidth={2.6} className="size-icon-lg" />
</Button>
@ -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"
>
<Square strokeWidth={0} fill="currentColor" className="size-icon-sm" />
</Button>
@ -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"
>
<ArrowUp strokeWidth={2.6} className="size-icon-lg" />
</Button>
@ -7504,19 +7490,22 @@ const MessageView = memo(function MessageView({
{effectiveSpeakerName && message.role === "bot" && !encounterMode ? (
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="relative flex items-center justify-center size-7 rounded-xl bg-white/[0.08] border border-white/[0.12] shadow-xs">
<div className="relative flex items-center justify-center size-7 rounded-xl bg-foreground/[0.08] border border-border shadow-xs">
<EvooMascot size={22} interactive={false} pose={isLive ? "working" : "idle"} />
</div>
<div className="flex items-center gap-2">
<span className="text-[14.5px] font-semibold text-white tracking-tight" dir="auto">
<span
className="text-[14.5px] font-semibold text-foreground tracking-tight"
dir="auto"
>
{effectiveSpeakerName}
</span>
<span className="rounded-full bg-emerald-500/15 border border-emerald-500/25 px-2 py-0.5 text-ui-label font-medium text-emerald-400">
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label font-medium text-muted-foreground">
Oo
</span>
</div>
</div>
<span className="text-ui-label font-mono text-zinc-400 tabular-nums">
<span className="text-ui-label font-mono text-muted-foreground tabular-nums">
{new Date(message.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",

View File

@ -441,7 +441,7 @@ export function BotSettings({
}}
/>
</div>
<label htmlFor={`${ids}-name`} className="mt-4 block text-ui-body text-muted-foreground/80">
<label htmlFor={`${ids}-name`} className={fieldLabelClass}>
<Trans>Name</Trans>
<Input
id={`${ids}-name`}
@ -484,10 +484,7 @@ export function BotSettings({
>
<Trans>Notifications</Trans>
</div>
<div
id={`${ids}-notify-finish-desc`}
className="text-ui-body-sm text-muted-foreground/70"
>
<div id={`${ids}-notify-finish-desc`} className="text-ui-body-sm text-muted-foreground">
<Trans>Get notified when this Bot finishes or needs input</Trans>
</div>
</div>

View File

@ -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";
}

View File

@ -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 {

View File

@ -49,7 +49,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-ui-body-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-icon-md",
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-ui-body-sm font-medium whitespace-nowrap text-muted-foreground transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-icon-md",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",