diff --git a/apps/web/e2e/chat-contrast.spec.ts b/apps/web/e2e/chat-contrast.spec.ts
index c950ab6..675f974 100644
--- a/apps/web/e2e/chat-contrast.spec.ts
+++ b/apps/web/e2e/chat-contrast.spec.ts
@@ -16,11 +16,12 @@ 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.
+ * visible text and typed value within it counts, except in disabled controls, which WCAG exempts,
+ * and in elements matching `skip`.
*/
-function unreadableTexts(locator: Locator, inside: boolean) {
+function unreadableTexts(locator: Locator, inside: boolean, skip: string | null) {
return locator.evaluate(
- (root, { inside, min }) => {
+ (root, { inside, skip, min }) => {
const context = document
.createElement("canvas")
.getContext("2d", { willReadFrequently: true });
@@ -81,7 +82,11 @@ function unreadableTexts(locator: Locator, inside: boolean) {
};
const elements = inside
? [root, ...root.querySelectorAll("*")].filter(
- (element) => ownText(element) && shown(element) && !element.closest(":disabled"),
+ (element) =>
+ ownText(element) &&
+ shown(element) &&
+ !element.closest(":disabled") &&
+ !(skip && element.closest(skip)),
)
: [root];
return elements.flatMap((element) => {
@@ -90,14 +95,18 @@ function unreadableTexts(locator: Locator, inside: boolean) {
return ratio < min ? [`${ratio.toFixed(2)}:1 ${text.slice(0, 48)}`] : [];
});
},
- { inside, min: MIN_CONTRAST },
+ { inside, skip, min: MIN_CONTRAST },
);
}
-async function expectReadable(locator: Locator, what: string, { inside = false } = {}) {
+async function expectReadable(
+ locator: Locator,
+ what: string,
+ { inside = false, skip = null }: { inside?: boolean; skip?: string | null } = {},
+) {
await expect(locator).toBeVisible();
await expect
- .poll(() => unreadableTexts(locator, inside), { message: `${what} is readable` })
+ .poll(() => unreadableTexts(locator, inside, skip), { message: `${what} is readable` })
.toEqual([]);
}
@@ -213,6 +222,9 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
await composer.fill("Draft a note about owls");
const sidebar = page.getByTestId("bots-sidebar");
const sidePanel = page.getByTestId("side-panel");
+ await expect(sidebar.locator("[data-main-conversation]")).toBeVisible();
+ await expect(sidebar.getByRole("button", { name: "Researcher, 1:1 conversation" })).toBeVisible();
+ await expect(page.getByTestId("trading-mode-status")).toBeVisible();
await inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${theme}`);
@@ -220,27 +232,9 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
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,
- });
+ // The header carries the logo, which WCAG exempts; its create button is checked on its own.
+ await expectReadable(sidebar, `${theme}: the sidebar`, { inside: true, skip: ".app-drag" });
+ await expectReadable(page.getByTestId("create-menu-trigger"), `${theme}: the create button`);
});
await openNewBot(page);
@@ -259,4 +253,12 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t
await captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`);
await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true });
});
+
+ await sidebar.getByRole("button", { name: "Heute", exact: true }).click();
+ const heute = page.getByTestId("heute-brief");
+ await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible();
+ await inEachTheme(page, async (theme) => {
+ await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`);
+ await expectReadable(heute, `${theme}: the Heute brief`, { inside: true });
+ });
});
diff --git a/apps/web/src/components/DataTrustPanel.tsx b/apps/web/src/components/DataTrustPanel.tsx
index 5400308..885fc91 100644
--- a/apps/web/src/components/DataTrustPanel.tsx
+++ b/apps/web/src/components/DataTrustPanel.tsx
@@ -10,16 +10,16 @@ const TRUST_ITEMS = [
export const DataTrustPanel = memo(function DataTrustPanel() {
return (
-
-
+
+
Datenschutz aktiv
{TRUST_ITEMS.map(({ icon: Icon, label, desc }) => (
-
-
-
{label}
-
· {desc}
+
+
+ {label}
+ · {desc}
))}
diff --git a/apps/web/src/components/TradingModeIndicator.tsx b/apps/web/src/components/TradingModeIndicator.tsx
index 6780aa1..90c9622 100644
--- a/apps/web/src/components/TradingModeIndicator.tsx
+++ b/apps/web/src/components/TradingModeIndicator.tsx
@@ -45,7 +45,7 @@ export function TradingModeIndicator({
{presentation.subtitle ? (
-
+
) : null}
diff --git a/apps/web/src/pages/HeuteBrief.tsx b/apps/web/src/pages/HeuteBrief.tsx
index cdf12b7..b6e9664 100644
--- a/apps/web/src/pages/HeuteBrief.tsx
+++ b/apps/web/src/pages/HeuteBrief.tsx
@@ -178,7 +178,7 @@ export function HeuteBrief({
href={MISSION_CONTROL_URL}
target="_blank"
rel="noreferrer"
- className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground/70 transition-colors hover:text-muted-foreground"
+ className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-ui-label text-muted-foreground transition-colors hover:text-foreground"
data-testid="heute-mission-control-link"
>
Mission Control
@@ -235,7 +235,7 @@ function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots:
function ActionableChip({ label }: { label: string }) {
return (
-
+
{label}
);
diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx
index ae34218..f50bf3b 100644
--- a/apps/web/src/pages/Shell.tsx
+++ b/apps/web/src/pages/Shell.tsx
@@ -3456,7 +3456,7 @@ export function ShellPage() {
className={`app-no-drag flex size-control-sm items-center justify-center rounded-full ${
activityMode
? "bg-primary text-primary-foreground"
- : "text-muted-foreground/70 hover:text-foreground/75"
+ : "rk-sidebar-muted hover:text-foreground/75"
}`}
>
@@ -3592,7 +3592,7 @@ export function ShellPage() {
type="button"
aria-label={t`Neue Unterhaltung`}
data-testid="conversation-picker-trigger"
- className="text-muted-foreground/60 hover:text-foreground/90 transition-colors"
+ className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
>
@@ -3633,7 +3633,7 @@ export function ShellPage() {
) : null}
{conversationSidebarGroups.length === 0 && !mainConversationEntry ? (
-
+
{t`Noch keine Unterhaltung.`}
{projectSidebarGroups.length === 0 ? (
-
+
{t`No projects yet.`}
{
if (group.emptySpaceId) {
openSpaceChat(group.emptySpaceId, "/app");
@@ -4153,7 +4153,7 @@ export function ShellPage() {
type="button"
aria-expanded={archivedOpen}
onClick={() => setArchivedOpen((open) => !open)}
- className="flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-ui-body text-muted-foreground hover:bg-sidebar-accent"
+ className="flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-ui-body rk-sidebar-muted hover:bg-sidebar-accent"
>
Archived
diff --git a/apps/web/src/pages/shell/roster-row-style.ts b/apps/web/src/pages/shell/roster-row-style.ts
index 32cc406..3db57de 100644
--- a/apps/web/src/pages/shell/roster-row-style.ts
+++ b/apps/web/src/pages/shell/roster-row-style.ts
@@ -11,9 +11,7 @@ export function rosterNameClass(unread: boolean): string {
return unread ? "font-bold text-sidebar-foreground" : "font-semibold text-sidebar-foreground";
}
-// 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 rosterTimeClass = "text-ui-body-sm rk-sidebar-muted tabular-nums font-medium";
export const rosterUnreadDotClass = "inline-block h-2 w-2 rounded-full bg-sidebar-foreground";
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 6b6e3ea..258c7d2 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -73,7 +73,7 @@ body,
/* Shell layout utilities — token-backed, use instead of one-off pixel classes */
@layer components {
.rk-shell-section-label {
- @apply text-ui-label font-bold tracking-wider uppercase text-muted-foreground/80;
+ @apply text-ui-label font-bold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80;
}
.rk-shell-icon-btn {
@@ -84,6 +84,12 @@ body,
@apply text-ui-body-sm text-muted-foreground;
}
+ /* Secondary sidebar text and icons. Muted text measures 4.4:1 on the light sidebar;
+ a dimmed foreground stays above 4.5:1. */
+ .rk-sidebar-muted {
+ @apply text-sidebar-foreground/65;
+ }
+
.rk-shell-row-title {
@apply text-ui-body font-medium text-foreground;
}