Merge main into the light theme branch

main now carries the token-based shell and chat from #104 and #107, so
this branch takes main's version of the shell, stage, styles and roster
row files and keeps what main still lacks: the light palette, the cn()
font-size fix, the centered stage bloom, and light-weight shadows for the
selected sidebar row and the stage orb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016or6oqCqP8wbNafvhnbLQT
This commit is contained in:
Claude 2026-10-03 14:52:41 +00:00
commit ce0e7fc8db
No known key found for this signature in database
13 changed files with 355 additions and 193 deletions

View File

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

View File

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

View File

@ -9,12 +9,12 @@ export type StagePresencePhase =
| "error"; | "error";
const PHASE_BAR_COLOR: Record<StagePresencePhase, string> = { const PHASE_BAR_COLOR: Record<StagePresencePhase, string> = {
idle: "bg-emerald-500/70 dark:bg-emerald-400/60", idle: "bg-emerald-400/60",
working: "bg-violet-500/75 dark:bg-violet-400/70", working: "bg-violet-400/70",
listening: "bg-cyan-500/90 dark:bg-cyan-400/90", listening: "bg-cyan-400/90",
thinking: "bg-violet-500/75 dark:bg-violet-400/70", thinking: "bg-violet-400/70",
speaking: "bg-emerald-500/90 dark:bg-emerald-400/90", speaking: "bg-emerald-400/90",
error: "bg-rose-500/80 dark:bg-rose-400/80", error: "bg-rose-400/80",
}; };
export function StageHero({ export function StageHero({
@ -55,7 +55,7 @@ export function StageHero({
) : null} ) : null}
<div <div
className={`relative flex ${orbSize} items-center justify-center rounded-full border border-border bg-card shadow-[0_12px_32px_-12px_rgba(24,24,22,0.18)] backdrop-blur-xl dark:border-foreground/14 dark:bg-slate-950/45 dark:shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)]`} className={`relative flex ${orbSize} items-center justify-center rounded-full border border-border bg-muted/45 shadow-[0_12px_32px_-12px_rgba(24,24,22,0.18)] backdrop-blur-xl dark:shadow-[0_16px_48px_rgba(0,0,0,0.45),inset_0_1px_0_rgba(255,255,255,0.12)]`}
> >
{reducedMotion ? null : ( {reducedMotion ? null : (
<div <div

View File

@ -34,11 +34,8 @@ export function TextStageCanvas({
const personaTint = ACCENT_TINT[personaAccent]; const personaTint = ACCENT_TINT[personaAccent];
return ( return (
<div <div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
aria-hidden <div className="absolute inset-0 bg-radial-[ellipse_100%_70%_at_50%_0%] from-card/55 to-transparent to-68%" />
className="pointer-events-none absolute inset-0 overflow-hidden opacity-60 dark:opacity-100"
>
<div className="absolute inset-0 dark:bg-[radial-gradient(ellipse_100%_70%_at_50%_0%,rgba(12,16,24,0.55)_0%,transparent_68%)]" />
<div <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"}`} 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={{ style={{

View File

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

View File

@ -106,7 +106,7 @@ export function ScratchpadSection({ botId }: { botId: string }) {
<Trans>Open work</Trans> <Trans>Open work</Trans>
</div> </div>
{items.length === 0 ? ( {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> <Trans>None yet</Trans>
</div> </div>
) : ( ) : (

View File

@ -290,7 +290,6 @@ import {
rosterRowClass, rosterRowClass,
rosterTimeClass, rosterTimeClass,
rosterUnreadDotClass, rosterUnreadDotClass,
selectedSidebarRowClass,
} from "./shell/roster-row-style"; } from "./shell/roster-row-style";
import { WindowChrome } from "./WindowChrome"; import { WindowChrome } from "./WindowChrome";
@ -3355,7 +3354,7 @@ export function ShellPage() {
<div <div
data-testid="shell-root" data-testid="shell-root"
data-ready={shellReady} data-ready={shellReady}
className="relative flex h-full min-w-0 overflow-hidden bg-sidebar dark:bg-black dark:bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] dark:from-zinc-900 dark:via-black dark: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) => { onTouchStartCapture={(event) => {
if ( if (
mobileSidebarOpen || mobileSidebarOpen ||
@ -3429,7 +3428,7 @@ export function ShellPage() {
width: mobileSidebarOpen ? undefined : botsSidebarCollapsed ? 0 : leftSidebarWidth, width: mobileSidebarOpen ? undefined : botsSidebarCollapsed ? 0 : leftSidebarWidth,
maxWidth: mobileSidebarOpen ? 316 : undefined, 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" mobileSidebarOpen ? "translate-x-0" : "-translate-x-full rtl:translate-x-full"
} ${ } ${
botsSidebarCollapsed botsSidebarCollapsed
@ -3566,13 +3565,9 @@ export function ShellPage() {
setActiveNavPillar("heute"); setActiveNavPillar("heute");
setMobileSidebarOpen(false); 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] ${ className={rosterRowClass(activeNavPillar === "heute")}
activeNavPillar === "heute"
? selectedSidebarRowClass
: "hover:bg-foreground/[0.04]"
}`}
> >
<div className="relative grid size-control-lg shrink-0 place-items-center rounded-full bg-emerald-500/10 text-emerald-700 dark: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" /> <Calendar strokeWidth={2.2} className="size-icon-md" />
{heuteActionCount > 0 ? ( {heuteActionCount > 0 ? (
<span <span
@ -3827,11 +3822,9 @@ export function ShellPage() {
/> />
<span <span
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-sidebar ${ className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-sidebar ${
rosterStatusDot(item.chat.status) === "needs-you" rosterStatusDot(item.chat.status) === "idle"
? "bg-warning animate-pulse" ? "bg-success"
: rosterStatusDot(item.chat.status) === "working" : "bg-warning animate-pulse"
? "bg-amber-500 animate-pulse dark:bg-amber-400"
: "bg-emerald-500 dark:bg-emerald-400"
}`} }`}
/> />
</div> </div>
@ -3852,7 +3845,7 @@ export function ShellPage() {
{needsYouChipLabel(item.chat.status)} {needsYouChipLabel(item.chat.status)}
</span> </span>
) : item.chat.title ? ( ) : 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-700 dark: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} {item.chat.title}
</span> </span>
) : null} ) : null}
@ -3906,7 +3899,7 @@ export function ShellPage() {
})} })}
<div className="sidebar-projekte"> <div className="sidebar-projekte">
{/* PROJEKTE SECTION */} {/* PROJEKTE SECTION */}
<div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-foreground/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>
<div className="rk-shell-section-label">{t`Projekte`}</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"> <p className="mt-0.5 px-0.5 text-ui-caption text-muted-foreground/70">
@ -4093,15 +4086,11 @@ export function ShellPage() {
position: { x: event.clientX, y: event.clientY }, 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] ${ className={rosterRowClass(
item.kind === "bot" && !inGroup && active?.id === item.chat.id item.kind === "bot"
? selectedSidebarRowClass ? !inGroup && active?.id === item.chat.id
: item.kind === "group" && : inGroup && activeGroup?.id === item.chat.id,
inGroup && )}
activeGroup?.id === item.chat.id
? selectedSidebarRowClass
: "hover:bg-foreground/[0.05] border border-transparent"
}`}
> >
{item.kind === "bot" ? ( {item.kind === "bot" ? (
<BotAvatar <BotAvatar
@ -4118,34 +4107,30 @@ export function ShellPage() {
<div className="flex min-w-0 items-center gap-2 overflow-hidden"> <div className="flex min-w-0 items-center gap-2 overflow-hidden">
<span <span
dir="auto" dir="auto"
className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${ className={`shrink-0 max-w-[170px] truncate text-ui-body tracking-tight ${rosterNameClass(
item.chat.unread item.chat.unread,
? "font-bold text-foreground" )}`}
: "font-semibold text-foreground/95"
}`}
> >
{item.chat.name} {item.chat.name}
</span> </span>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
<span className="text-ui-body-sm text-muted-foreground tabular-nums font-medium"> <span className={rosterTimeClass}>
{formatRosterTime(item.chat.updatedAt)} {formatRosterTime(item.chat.updatedAt)}
</span> </span>
{item.chat.unread ? ( {item.chat.unread ? (
<span <span
aria-hidden="true" aria-hidden="true"
className="inline-block h-2 w-2 rounded-full bg-emerald-400" className={rosterUnreadDotClass}
/> />
) : null} ) : null}
</div> </div>
</div> </div>
<div <div
dir="auto" dir="auto"
className={`mt-0.5 truncate text-ui-body leading-snug ${ className={`mt-0.5 truncate text-ui-body leading-snug ${rosterPreviewClass(
item.chat.unread item.chat.unread,
? "font-medium text-foreground" )}`}
: "text-foreground/75"
}`}
> >
{cleanRosterPreview(item.chat.preview)} {cleanRosterPreview(item.chat.preview)}
</div> </div>
@ -4357,7 +4342,7 @@ export function ShellPage() {
<main <main
aria-hidden={mobileSidebarOpen || undefined} aria-hidden={mobileSidebarOpen || undefined}
inert={mobileSidebarOpen} inert={mobileSidebarOpen}
className="flex min-w-0 flex-1 flex-col bg-background md:rounded-2xl md:border md:border-border md:shadow-sm dark:md:border-foreground/10 dark:md:shadow-2xl overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" className="flex min-w-0 flex-1 flex-col bg-background md:rounded-2xl md:border md:border-border md:shadow-2xl overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]"
> >
{activeNavPillar === "heute" ? ( {activeNavPillar === "heute" ? (
<div className="flex h-full flex-1 flex-col overflow-hidden"> <div className="flex h-full flex-1 flex-col overflow-hidden">
@ -4719,7 +4704,7 @@ export function ShellPage() {
? rightSidebarWidth ? rightSidebarWidth
: 0, : 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 && panel &&
( (
active || active ||
@ -4823,8 +4808,8 @@ export function ShellPage() {
/> />
{isDesktop ? ( {isDesktop ? (
devServerRunning ? ( 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-700 dark:text-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-emerald-400" /> <span className="size-1.5 rounded-full bg-success" />
<Trans>Dev</Trans> <Trans>Dev</Trans>
</span> </span>
) : ( ) : (
@ -5003,7 +4988,7 @@ export function ShellPage() {
aria-label={t`Close`} aria-label={t`Close`}
onClick={() => setShowCreateBotModal(false)} onClick={() => setShowCreateBotModal(false)}
/> />
<div className="relative z-10 max-h-[90vh] w-[480px] overflow-y-auto rounded-2xl border border-border bg-popover 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"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground"> <h2 className="text-lg font-semibold text-foreground">
<Trans>Create new Bot</Trans> <Trans>Create new Bot</Trans>
@ -6109,10 +6094,9 @@ const Transcript = memo(function Transcript({
<EvooMascot size={76} pose="greeting" interactive trackPointer="hover" /> <EvooMascot size={76} pose="greeting" interactive trackPointer="hover" />
</div> </div>
<h2 className="text-3xl font-bold tracking-tight text-foreground mb-2"> <h2 className="text-3xl font-bold tracking-tight text-foreground mb-2">
{t`Guten Tag.`} <span className="text-emerald-700 dark:text-emerald-400">evOo</span>{" "} {t`Guten Tag.`} evOo {t`ist bereit.`}
{t`ist bereit.`}
</h2> </h2>
<p className="max-w-md text-ui-body leading-relaxed text-foreground/75 mb-8 font-normal"> <p className="max-w-md text-ui-body leading-relaxed text-foreground/85 mb-8 font-normal">
{activeName {activeName
? t`Frag ${activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews.` ? t`Frag ${activeName} nach Marktchancen, autonomen Tasks oder Architektur-Reviews.`
: t`Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt.`} : t`Wähle einen autonomen Agenten aus deinem Team oder starte direkt einen Prompt.`}
@ -6123,10 +6107,10 @@ const Transcript = memo(function Transcript({
onClick={() => { onClick={() => {
onPromptSelect?.(t`⚡ Führe einen autonomen Portfolio- und Risiko-Check durch`); 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-xl mb-2">⚡</span>
<span className="text-[14.5px] font-semibold text-foreground group-hover:text-emerald-700 dark:group-hover:text-emerald-400 transition-colors"> <span className="text-[14.5px] font-semibold text-foreground">
{t`Portfolio-Check`} {t`Portfolio-Check`}
</span> </span>
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug"> <span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
@ -6138,10 +6122,10 @@ const Transcript = memo(function Transcript({
onClick={() => { onClick={() => {
onPromptSelect?.(t`📈 Scanne den Markt nach aktuellen Alpha-Signalen`); 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-xl mb-2">📈</span>
<span className="text-[14.5px] font-semibold text-foreground group-hover:text-sky-700 dark:group-hover:text-sky-400 transition-colors"> <span className="text-[14.5px] font-semibold text-foreground">
{t`Alpha-Signale`} {t`Alpha-Signale`}
</span> </span>
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug"> <span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
@ -6153,10 +6137,10 @@ const Transcript = memo(function Transcript({
onClick={() => { onClick={() => {
onPromptSelect?.(t`💎 Führe ein Steve Jobs UX & Architektur-Audit durch`); 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-xl mb-2">💎</span>
<span className="text-[14.5px] font-semibold text-foreground group-hover:text-purple-700 dark:group-hover:text-purple-400 transition-colors"> <span className="text-[14.5px] font-semibold text-foreground">
{t`Steve Jobs Audit`} {t`Steve Jobs Audit`}
</span> </span>
<span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug"> <span className="text-ui-body-sm text-muted-foreground mt-1 leading-snug">
@ -6735,8 +6719,8 @@ const Composer = memo(function Composer({
{/* Quick Action Prompt Chips */} {/* Quick Action Prompt Chips */}
{showComposerPlaceholder ? ( {showComposerPlaceholder ? (
<div className="mb-3 flex items-center gap-2 overflow-x-auto no-scrollbar py-1"> <div className="mb-3 flex items-center gap-2 overflow-x-auto no-scrollbar py-1">
<span className="flex items-center gap-1.5 text-ui-label font-semibold uppercase tracking-wider text-emerald-700 dark:text-emerald-400 shrink-0 px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 me-1 shadow-xs"> <span className="flex items-center gap-1.5 text-ui-label font-semibold uppercase tracking-wider text-muted-foreground shrink-0 px-2.5 py-1 rounded-full bg-muted/40 border border-border me-1 shadow-xs">
<Sparkles className="text-amber-700 dark:text-amber-400 size-icon-sm" /> <Sparkles className="text-amber-400 size-icon-sm" />
Quick Quick
</span> </span>
{quickPromptsForBot(activeName).map((promptText) => ( {quickPromptsForBot(activeName).map((promptText) => (
@ -6747,7 +6731,7 @@ const Composer = memo(function Composer({
updateDraft(promptText); updateDraft(promptText);
focusComposer(); 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} {promptText}
</button> </button>
@ -6930,7 +6914,7 @@ const Composer = memo(function Composer({
) : null} ) : null}
<div <div
data-testid="composer-bar" 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-foreground/25 dark:focus-within:border-emerald-500/40 dark: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)] dark: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 <input
ref={fileInputRef} ref={fileInputRef}
@ -6946,7 +6930,7 @@ const Composer = memo(function Composer({
aria-label={t`Attach file`} aria-label={t`Attach file`}
disabled={disabled} disabled={disabled}
onClick={() => fileInputRef.current?.click()} onClick={() => 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"
> >
<Plus strokeWidth={2.2} className="size-icon-lg" /> <Plus strokeWidth={2.2} className="size-icon-lg" />
</Button> </Button>
@ -6954,12 +6938,9 @@ const Composer = memo(function Composer({
{selectedSkill ? ( {selectedSkill ? (
<span <span
data-testid="skill-chip" data-testid="skill-chip"
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"
> >
<Box <Box strokeWidth={1.8} className="shrink-0 text-muted-foreground size-icon-md" />
strokeWidth={1.8}
className="shrink-0 text-blue-700 dark:text-blue-400 size-icon-md"
/>
<span dir="auto" className="truncate"> <span dir="auto" className="truncate">
{selectedSkill.name} {selectedSkill.name}
</span> </span>
@ -6978,7 +6959,7 @@ const Composer = memo(function Composer({
key={mentionChipKey(mention)} key={mentionChipKey(mention)}
data-testid="mention-chip" data-testid="mention-chip"
data-mention-kind={mention.kind} 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"
> >
<MentionChipIcon mention={mention} /> <MentionChipIcon mention={mention} />
<span dir="auto" className="truncate"> <span dir="auto" className="truncate">
@ -7058,7 +7039,7 @@ const Composer = memo(function Composer({
autoComplete="off" autoComplete="off"
dir="auto" dir="auto"
rows={1} 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> </div>
{onVoice ? ( {onVoice ? (
@ -7091,7 +7072,7 @@ const Composer = memo(function Composer({
aria-label={t`Send`} aria-label={t`Send`}
disabled={!canSend || disabled} disabled={!canSend || disabled}
onClick={send} 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"
> >
<ArrowUp strokeWidth={2.6} className="size-icon-lg" /> <ArrowUp strokeWidth={2.6} className="size-icon-lg" />
</Button> </Button>
@ -7101,7 +7082,7 @@ const Composer = memo(function Composer({
aria-label={t`Stop`} aria-label={t`Stop`}
disabled={sending} disabled={sending}
onClick={() => void onStop()} 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"
> >
<Square strokeWidth={0} fill="currentColor" className="size-icon-sm" /> <Square strokeWidth={0} fill="currentColor" className="size-icon-sm" />
</Button> </Button>
@ -7112,7 +7093,7 @@ const Composer = memo(function Composer({
aria-label={t`Send`} aria-label={t`Send`}
disabled={!canSend || disabled} disabled={!canSend || disabled}
onClick={send} 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"
> >
<ArrowUp strokeWidth={2.6} className="size-icon-lg" /> <ArrowUp strokeWidth={2.6} className="size-icon-lg" />
</Button> </Button>
@ -7509,7 +7490,7 @@ const MessageView = memo(function MessageView({
{effectiveSpeakerName && message.role === "bot" && !encounterMode ? ( {effectiveSpeakerName && message.role === "bot" && !encounterMode ? (
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="relative flex items-center justify-center size-7 rounded-xl bg-foreground/[0.08] border border-foreground/[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"} /> <EvooMascot size={22} interactive={false} pose={isLive ? "working" : "idle"} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -7519,7 +7500,7 @@ const MessageView = memo(function MessageView({
> >
{effectiveSpeakerName} {effectiveSpeakerName}
</span> </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-700 dark: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 Oo
</span> </span>
</div> </div>
@ -7880,8 +7861,8 @@ const MessageView = memo(function MessageView({
data-testid="message-user-bubble" data-testid="message-user-bubble"
className={ className={
encounterMode encounterMode
? "max-w-full whitespace-pre-wrap wrap-anywhere rounded-2xl border border-foreground/10 bg-foreground/[0.06] px-3.5 py-2 text-ui-body-sm leading-relaxed text-foreground/95" ? "max-w-full whitespace-pre-wrap wrap-anywhere rounded-2xl bg-chat-user px-3.5 py-2 text-ui-body-sm leading-relaxed text-chat-user-foreground"
: "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-[17px] leading-[1.62] text-chat-user-foreground dark:text-[#FAFAFA] dark:shadow-md" : "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-[17px] leading-[1.62] text-chat-user-foreground shadow-md"
} }
dir="auto" dir="auto"
> >
@ -7903,7 +7884,7 @@ const MessageView = memo(function MessageView({
dir="auto" dir="auto"
> >
<ChatMarkdown>{block.text}</ChatMarkdown> <ChatMarkdown>{block.text}</ChatMarkdown>
<div className="mt-3 pt-2.5 flex items-center justify-between border-t border-foreground/[0.08] text-ui-label text-muted-foreground/75"> <div className="mt-3 pt-2.5 flex items-center justify-between border-t border-border text-ui-label text-muted-foreground/75">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"

View File

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

View File

@ -428,7 +428,7 @@ export function ChartBlockView({
variant="outline" variant="outline"
size="xs" size="xs"
onClick={() => setExpanded(true)} onClick={() => setExpanded(true)}
className="absolute end-3 top-3 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 rounded-full border-white/15 bg-white/10 text-white backdrop-blur-md" className="absolute end-3 top-3 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 rounded-full"
> >
<Trans>Expand</Trans> <Trans>Expand</Trans>
</Button> </Button>

View File

@ -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. */ /** One look for sidebar conversation rows: the main conversation entry and the bot roster. */
export function rosterRowClass(selected: boolean): string { 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 ? selectedSidebarRowClass : "hover:bg-foreground/[0.05] border border-transparent" 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 { 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 = export const rosterUnreadDotClass = "inline-block h-2 w-2 rounded-full bg-sidebar-foreground";
"inline-block h-2 w-2 rounded-full bg-emerald-500 dark:bg-emerald-400";
export function rosterPreviewClass(unread: boolean): string { 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";
} }

View File

@ -11,13 +11,7 @@ body,
#root { #root {
height: 100%; height: 100%;
margin: 0; margin: 0;
background: #07090e; background: var(--background);
background-image: radial-gradient(
circle at 50% 0%,
rgba(16, 185, 129, 0.07) 0%,
rgba(14, 165, 233, 0.04) 40%,
transparent 80%
);
color: var(--foreground); color: var(--foreground);
font-family: font-family:
"Inter Variable", -apple-system, BlinkMacSystemFont, "SF Pro Display", "Geist Variable", "Inter Variable", -apple-system, BlinkMacSystemFont, "SF Pro Display", "Geist Variable",
@ -41,8 +35,8 @@ body,
.apple-island { .apple-island {
backdrop-filter: blur(36px) saturate(210%); backdrop-filter: blur(36px) saturate(210%);
-webkit-backdrop-filter: blur(36px) saturate(210%); -webkit-backdrop-filter: blur(36px) saturate(210%);
background: rgba(12, 16, 24, 0.88); background: color-mix(in srgb, var(--card) 88%, transparent);
border: 1px solid rgba(255, 255, 255, 0.13); border: 1px solid color-mix(in srgb, var(--foreground) 13%, transparent);
box-shadow: box-shadow:
0 18px 44px -8px rgba(0, 0, 0, 0.75), 0 18px 44px -8px rgba(0, 0, 0, 0.75),
inset 0 1px 0 rgba(255, 255, 255, 0.16); inset 0 1px 0 rgba(255, 255, 255, 0.16);
@ -51,24 +45,24 @@ body,
.apple-glass-card { .apple-glass-card {
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
background: rgba(16, 21, 30, 0.68); background: color-mix(in srgb, var(--card) 68%, transparent);
border: 1px solid rgba(255, 255, 255, 0.09); border: 1px solid var(--border);
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 6px 24px rgba(0, 0, 0, 0.35); 0 6px 24px rgba(0, 0, 0, 0.35);
} }
.chat-bubble-user { .chat-bubble-user {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%); background: var(--chat-user);
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid var(--border);
box-shadow: box-shadow:
0 8px 24px -4px rgba(0, 0, 0, 0.5), 0 8px 24px -4px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.2); inset 0 1px 0 rgba(255, 255, 255, 0.2);
} }
.chat-bubble-bot { .chat-bubble-bot {
background: rgba(15, 20, 29, 0.72); background: color-mix(in srgb, var(--card) 72%, transparent);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--border);
box-shadow: box-shadow:
0 10px 32px -4px rgba(0, 0, 0, 0.35), 0 10px 32px -4px rgba(0, 0, 0, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.08); inset 0 1px 0 rgba(255, 255, 255, 0.08);
@ -76,45 +70,6 @@ body,
-webkit-backdrop-filter: blur(20px); -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 */ /* Shell layout utilities — token-backed, use instead of one-off pixel classes */
@layer components { @layer components {
.rk-shell-section-label { .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 { .rk-chat-markdown {
font-size: var(--font-size-reading); font-size: var(--font-size-reading);
line-height: var(--line-height-reading); line-height: var(--line-height-reading);
@ -149,8 +105,6 @@ html[data-theme="light"] #root {
} }
.rk-chat-markdown pre { .rk-chat-markdown pre {
background: rgba(8, 11, 16, 0.96);
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 16px; border-radius: 16px;
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.09), inset 0 1px 0 rgba(255, 255, 255, 0.09),
@ -161,21 +115,12 @@ html[data-theme="light"] #root {
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 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[data-selected="true"],
.sidebar-roster-row:hover { .sidebar-roster-row:hover {
background-color: var(--accent); background-color: var(--accent);
} }
.rk-chat-markdown code:not(pre code) { .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; font-weight: 500;
border-radius: 6px; border-radius: 6px;
padding: 0.15em 0.4em; padding: 0.15em 0.4em;
@ -188,7 +133,6 @@ html[data-theme="light"] #root {
font-weight: 700; font-weight: 700;
line-height: 1.3; line-height: 1.3;
letter-spacing: -0.025em; letter-spacing: -0.025em;
color: var(--foreground);
margin: 1.6em 0 0.6em; margin: 1.6em 0 0.6em;
} }
@ -197,7 +141,6 @@ html[data-theme="light"] #root {
font-weight: 600; font-weight: 600;
line-height: 1.35; line-height: 1.35;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: var(--foreground);
margin: 1.4em 0 0.5em; margin: 1.4em 0 0.5em;
} }
@ -206,7 +149,6 @@ html[data-theme="light"] #root {
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.4;
letter-spacing: -0.015em; letter-spacing: -0.015em;
color: var(--foreground);
margin: 1.2em 0 0.4em; margin: 1.2em 0 0.4em;
} }
@ -222,29 +164,18 @@ html[data-theme="light"] #root {
} }
.rk-chat-markdown strong { .rk-chat-markdown strong {
color: var(--foreground);
font-weight: 600; font-weight: 600;
} }
.rk-chat-markdown blockquote { .rk-chat-markdown blockquote {
border-left: 3px solid #10b981;
padding-left: 1.2em; padding-left: 1.2em;
margin: 1.3em 0; margin: 1.3em 0;
color: #cbd5e1;
font-style: normal; font-style: normal;
background: rgba(16, 185, 129, 0.05);
border-radius: 0 8px 8px 0; border-radius: 0 8px 8px 0;
padding-top: 0.4em; padding-top: 0.4em;
padding-bottom: 0.4em; padding-bottom: 0.4em;
} }
[data-theme="light"] .rk-chat-markdown blockquote {
border-left-color: var(--input);
color: var(--muted-foreground);
background: none;
border-radius: 0;
}
:root { :root {
--spring-snappy: cubic-bezier(0.175, 0.885, 0.32, 1.275); --spring-snappy: cubic-bezier(0.175, 0.885, 0.32, 1.275);
--spring-smooth: cubic-bezier(0.16, 1, 0.3, 1); --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); box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.18);
} }
[data-theme="light"] .tactile-press:hover {
box-shadow: none;
}
.tactile-press:active { .tactile-press:active {
transform: scale(0.965); transform: scale(0.965);
transition-duration: 0.06s; transition-duration: 0.06s;

View File

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

View File

@ -49,7 +49,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab <TabsPrimitive.Tab
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( 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", "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", "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", "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",