fix(web): keep secondary shell text readable in the light theme

Secondary shell text was muted text dimmed further with opacity: the
section labels, the projects caption, the sidebar "+" icons, the privacy
panel, the trading subtitle and the Heute brief's Mission Control link.
Muted text measures 4.4:1 on the light sidebar, so these fell to 1.9 to
3.4:1 in the light theme; a few were below 4.5:1 in dark as well.

- Secondary sidebar text and icons share `rk-sidebar-muted`, the sidebar
  foreground at 65% that row times already used (5.0:1 on the light
  sidebar). Row times now use the class too.
- Section labels and space or project group titles use the dimmed
  foreground in light and keep the 80% muted color in dark; group titles
  move up from 60%, which was 3.4:1 in dark.
- The privacy panel heading is plain text with a success-colored check
  instead of emerald text, and its details pass 4.5:1 in both themes.
- Needs-you chips keep the warning tint and border; their label uses the
  foreground color in light.
- The Mission Control link uses the full muted color.
- E2E: the shell test checks every text in the sidebar except the logo,
  and the Heute brief, in both themes, and captures
  `shell-contrast-{light,dark}-heute`.

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 14:58:48 +00:00
parent dc1d28c670
commit 53ec88ac7d
No known key found for this signature in database
7 changed files with 59 additions and 53 deletions

View File

@ -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 * 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( return locator.evaluate(
(root, { inside, min }) => { (root, { inside, skip, min }) => {
const context = document const context = document
.createElement("canvas") .createElement("canvas")
.getContext("2d", { willReadFrequently: true }); .getContext("2d", { willReadFrequently: true });
@ -81,7 +82,11 @@ function unreadableTexts(locator: Locator, inside: boolean) {
}; };
const elements = inside const elements = inside
? [root, ...root.querySelectorAll("*")].filter( ? [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]; : [root];
return elements.flatMap((element) => { 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)}`] : []; 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(locator).toBeVisible();
await expect await expect
.poll(() => unreadableTexts(locator, inside), { message: `${what} is readable` }) .poll(() => unreadableTexts(locator, inside, skip), { message: `${what} is readable` })
.toEqual([]); .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"); await composer.fill("Draft a note about owls");
const sidebar = page.getByTestId("bots-sidebar"); const sidebar = page.getByTestId("bots-sidebar");
const sidePanel = page.getByTestId("side-panel"); 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 inEachTheme(page, async (theme) => {
await captureScreenshot(page, testInfo, `shell-contrast-${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`, { await expectReadable(page.getByTestId("composer-bar"), `${theme}: the composer`, {
inside: true, inside: true,
}); });
await expectReadable( // The header carries the logo, which WCAG exempts; its create button is checked on its own.
sidebar.getByRole("button", { name: "Heute", exact: true }), await expectReadable(sidebar, `${theme}: the sidebar`, { inside: true, skip: ".app-drag" });
`${theme}: Heute`, await expectReadable(page.getByTestId("create-menu-trigger"), `${theme}: the create button`);
{ 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 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 captureScreenshot(page, testInfo, `shell-contrast-${theme}-settings`);
await expectReadable(sidePanel, `${theme}: the bot settings form`, { inside: true }); 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 });
});
}); });

View File

@ -10,16 +10,16 @@ const TRUST_ITEMS = [
export const DataTrustPanel = memo(function DataTrustPanel() { export const DataTrustPanel = memo(function DataTrustPanel() {
return ( return (
<div className="hidden xl:flex flex-col items-start gap-1.5 rounded-xl border border-border/40 bg-muted/30 px-3 py-2.5"> <div className="hidden xl:flex flex-col items-start gap-1.5 rounded-xl border border-border/40 bg-muted/30 px-3 py-2.5">
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400"> <div className="flex items-center gap-1.5 text-xs font-medium text-sidebar-foreground">
<CheckCircle2 className="size-3.5" /> <CheckCircle2 className="size-3.5 text-success" />
<span>Datenschutz aktiv</span> <span>Datenschutz aktiv</span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{TRUST_ITEMS.map(({ icon: Icon, label, desc }) => ( {TRUST_ITEMS.map(({ icon: Icon, label, desc }) => (
<div key={label} className="flex items-center gap-1.5"> <div key={label} className="flex items-center gap-1.5 rk-sidebar-muted">
<Icon className="size-3 text-muted-foreground/60" /> <Icon className="size-3" />
<span className="text-[11px] leading-tight text-muted-foreground">{label}</span> <span className="text-[11px] leading-tight">{label}</span>
<span className="text-[10px] leading-tight text-muted-foreground/50">· {desc}</span> <span className="text-[10px] leading-tight">· {desc}</span>
</div> </div>
))} ))}
</div> </div>

View File

@ -45,7 +45,7 @@ export function TradingModeIndicator({
<TradingModeLabel label={presentation.label} /> <TradingModeLabel label={presentation.label} />
</span> </span>
{presentation.subtitle ? ( {presentation.subtitle ? (
<span className="block truncate text-ui-body-sm text-muted-foreground/70"> <span className="block truncate text-ui-body-sm rk-sidebar-muted">
<TradingModeSubtitleText subtitle={presentation.subtitle} /> <TradingModeSubtitleText subtitle={presentation.subtitle} />
</span> </span>
) : null} ) : null}

View File

@ -178,7 +178,7 @@ export function HeuteBrief({
href={MISSION_CONTROL_URL} href={MISSION_CONTROL_URL}
target="_blank" target="_blank"
rel="noreferrer" 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" data-testid="heute-mission-control-link"
> >
<Trans>Mission Control</Trans> <Trans>Mission Control</Trans>
@ -235,7 +235,7 @@ function EmptyDay({ onStartChat, hasBots }: { onStartChat: () => void; hasBots:
function ActionableChip({ label }: { label: string }) { function ActionableChip({ label }: { label: string }) {
return ( return (
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-warning"> <span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-foreground dark:text-warning">
{label} {label}
</span> </span>
); );

View File

@ -3456,7 +3456,7 @@ export function ShellPage() {
className={`app-no-drag flex size-control-sm items-center justify-center rounded-full ${ className={`app-no-drag flex size-control-sm items-center justify-center rounded-full ${
activityMode activityMode
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground/70 hover:text-foreground/75" : "rk-sidebar-muted hover:text-foreground/75"
}`} }`}
> >
<Bell <Bell
@ -3469,7 +3469,7 @@ export function ShellPage() {
<Popover open={createMenuOpen} onOpenChange={setCreateMenuOpen}> <Popover open={createMenuOpen} onOpenChange={setCreateMenuOpen}>
<PopoverTrigger <PopoverTrigger
className="app-no-drag text-[21px] text-muted-foreground/70 hover:text-foreground/75" className="app-no-drag text-[21px] rk-sidebar-muted hover:text-foreground/75"
title={t`Create`} title={t`Create`}
data-testid="create-menu-trigger" data-testid="create-menu-trigger"
> >
@ -3592,7 +3592,7 @@ export function ShellPage() {
type="button" type="button"
aria-label={t`Neue Unterhaltung`} aria-label={t`Neue Unterhaltung`}
data-testid="conversation-picker-trigger" 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"
> >
<Plus strokeWidth={2.5} className="size-icon-md" /> <Plus strokeWidth={2.5} className="size-icon-md" />
</PopoverTrigger> </PopoverTrigger>
@ -3633,7 +3633,7 @@ export function ShellPage() {
</div> </div>
) : null} ) : null}
{conversationSidebarGroups.length === 0 && !mainConversationEntry ? ( {conversationSidebarGroups.length === 0 && !mainConversationEntry ? (
<div className="p-4 text-center text-ui-label text-muted-foreground"> <div className="p-4 text-center text-ui-label rk-sidebar-muted">
<p>{t`Noch keine Unterhaltung.`}</p> <p>{t`Noch keine Unterhaltung.`}</p>
<Button <Button
variant="outline" variant="outline"
@ -3657,7 +3657,7 @@ export function ShellPage() {
<div className="flex items-center pt-3 pb-0.5"> <div className="flex items-center pt-3 pb-0.5">
<button <button
type="button" type="button"
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring" className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg px-2.5 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onClick={() => { onClick={() => {
if (group.emptySpaceId) { if (group.emptySpaceId) {
openSpaceChat(group.emptySpaceId, "/onboarding"); openSpaceChat(group.emptySpaceId, "/onboarding");
@ -3841,7 +3841,7 @@ export function ShellPage() {
{item.chat.name} {item.chat.name}
</span> </span>
{needsYouChipLabel(item.chat.status) ? ( {needsYouChipLabel(item.chat.status) ? (
<span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-warning"> <span className="shrink-0 rounded-full border border-warning/30 bg-warning/10 px-2 py-0.5 text-ui-label font-medium text-foreground dark:text-warning">
{needsYouChipLabel(item.chat.status)} {needsYouChipLabel(item.chat.status)}
</span> </span>
) : item.chat.title ? ( ) : item.chat.title ? (
@ -3887,7 +3887,7 @@ export function ShellPage() {
[group.key]: !prev[group.key], [group.key]: !prev[group.key],
})) }))
} }
className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium text-muted-foreground hover:bg-sidebar-accent hover:text-foreground transition-colors" className="mt-1 w-full flex items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-ui-label font-medium rk-sidebar-muted hover:bg-sidebar-accent hover:text-foreground transition-colors"
> >
{expandedTeamGroups[group.key] {expandedTeamGroups[group.key]
? "Weniger anzeigen" ? "Weniger anzeigen"
@ -3902,7 +3902,7 @@ export function ShellPage() {
<div className="flex items-center justify-between pt-4 pb-1 mt-3 border-t border-sidebar-border 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 rk-sidebar-muted">
{t`Threads by topic · folders`} {t`Threads by topic · folders`}
</p> </p>
</div> </div>
@ -3912,14 +3912,14 @@ export function ShellPage() {
aria-label={t`Create a project`} aria-label={t`Create a project`}
data-testid="create-project-trigger" data-testid="create-project-trigger"
onClick={() => openNewSpaceDialog("project")} onClick={() => openNewSpaceDialog("project")}
className="text-muted-foreground/60 hover:text-foreground/90 transition-colors" className="rk-sidebar-muted hover:text-foreground/90 transition-colors"
> >
<Plus strokeWidth={2.5} className="size-icon-md" /> <Plus strokeWidth={2.5} className="size-icon-md" />
</button> </button>
</div> </div>
</div> </div>
{projectSidebarGroups.length === 0 ? ( {projectSidebarGroups.length === 0 ? (
<div className="p-4 text-center text-ui-label text-muted-foreground"> <div className="p-4 text-center text-ui-label rk-sidebar-muted">
<p>{t`No projects yet.`}</p> <p>{t`No projects yet.`}</p>
<Button <Button
variant="outline" variant="outline"
@ -3957,7 +3957,7 @@ export function ShellPage() {
</ProjectDragGrip> </ProjectDragGrip>
<button <button
type="button" type="button"
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-muted-foreground/60 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring" className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onClick={() => { onClick={() => {
if (group.emptySpaceId) { if (group.emptySpaceId) {
openSpaceChat(group.emptySpaceId, "/app"); openSpaceChat(group.emptySpaceId, "/app");
@ -4153,7 +4153,7 @@ export function ShellPage() {
type="button" type="button"
aria-expanded={archivedOpen} aria-expanded={archivedOpen}
onClick={() => setArchivedOpen((open) => !open)} 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"
> >
<span> <span>
<Trans>Archived</Trans> <Trans>Archived</Trans>

View File

@ -11,9 +11,7 @@ export function rosterNameClass(unread: boolean): string {
return unread ? "font-bold text-sidebar-foreground" : "font-semibold text-sidebar-foreground"; 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 rk-sidebar-muted tabular-nums font-medium";
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-sidebar-foreground"; export const rosterUnreadDotClass = "inline-block h-2 w-2 rounded-full bg-sidebar-foreground";

View File

@ -73,7 +73,7 @@ body,
/* 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 {
@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 { .rk-shell-icon-btn {
@ -84,6 +84,12 @@ body,
@apply text-ui-body-sm text-muted-foreground; @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 { .rk-shell-row-title {
@apply text-ui-body font-medium text-foreground; @apply text-ui-body font-medium text-foreground;
} }