diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md
index 8ec720742759..c2c091b205cf 100644
--- a/.macroscope/check-run-agents/ui-consistency.md
+++ b/.macroscope/check-run-agents/ui-consistency.md
@@ -47,6 +47,9 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put
- light-only declarations use `@variant light`;
- raw `.dark` should remain only in the `dark` and `light` custom-variant definitions.
- Preserve custom themes and runtime token bridges. Removing a variable or selector is safe only when all runtime, inspector, generated, and theme-palette consumers are accounted for.
+- Contrast and accessibility settings that target app chrome must derive from semantic color tokens. Do not apply `filter` to `html`, `body`, or the app root: it also changes user media, previews, terminals, glass backdrop ownership, and view-transition snapshots.
+- Preserve alpha and surface ownership when deriving contrast tokens. Soften translucent borders and inputs toward transparent rather than an opaque canvas, use a modest semantic-foreground mix for stronger borders, and adjust card, popover, accent, secondary, and message foregrounds against their own surfaces when the base foreground changes.
+- Runtime-adjusted roles must be ordinary custom properties shared by the Tailwind bridge, global CSS, imperative style strings, and bridge snapshots sent to other renderers. Audit literal `var(--foreground)`, `var(--border)`, and related role reads so headings, markdown chrome, menus, previews, and utilities do not split into adjusted and unadjusted colors.
- Inspect emitted production CSS after unusual variants, arbitrary selectors, nested pseudo-elements, or attribute matching. Source syntax that looks valid is insufficient.
- Flag malformed or empty emitted selectors such as empty `:is()` or `:not(:is())`, selector branches that can never match their own class attribute, and transformations that silently drop the intended rule.
- Prefer source-level logic over clever selectors when behavior depends on consumer-provided class strings. Preserve `MenuPopup`'s current defaulting contract: a string `className` containing a `w-*`, `min-w-*`, or `max-w-*` utility after variant prefixes are stripped suppresses `min-w-32`; a string without one and a functional/non-string `className` keep the default. Arbitrary width values count as width utilities, and the consumer class must be merged last so it retains control. Do not replace this with a raw class-attribute substring selector.
diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 1c17d58215ea..1a304d582bb0 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopClientSettings from "./DesktopClientSettings.ts";
const clientSettings: ClientSettings = {
+ appearanceContrast: 100,
browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
browserDefaultZoomFactor: 1.25,
browserDefaultAppearance: "dark",
diff --git a/apps/web/src/appearanceContrast.test.ts b/apps/web/src/appearanceContrast.test.ts
new file mode 100644
index 000000000000..3e6c1fad0448
--- /dev/null
+++ b/apps/web/src/appearanceContrast.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import { applyAppearanceContrast } from "./appearanceContrast";
+
+function makeRoot() {
+ const setProperty = vi.fn();
+ return {
+ root: { style: { setProperty } } as unknown as HTMLElement,
+ setProperty,
+ };
+}
+
+describe("applyAppearanceContrast", () => {
+ it("boosts semantic contrast above the default", () => {
+ const { root, setProperty } = makeRoot();
+
+ applyAppearanceContrast(root, 135);
+
+ expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%");
+ expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%");
+ expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "8.75%");
+ });
+
+ it("supports the maximum contrast boost", () => {
+ const { root, setProperty } = makeRoot();
+
+ applyAppearanceContrast(root, 200);
+
+ expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%");
+ expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "100%");
+ expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "25%");
+ });
+
+ it("softens semantic contrast below the default", () => {
+ const { root, setProperty } = makeRoot();
+
+ applyAppearanceContrast(root, 70);
+
+ expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "70%");
+ expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%");
+ expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%");
+ });
+
+ it("disables contrast mixing at the default", () => {
+ const { root, setProperty } = makeRoot();
+
+ applyAppearanceContrast(root, 100);
+
+ expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%");
+ expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%");
+ expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%");
+ });
+});
diff --git a/apps/web/src/appearanceContrast.ts b/apps/web/src/appearanceContrast.ts
new file mode 100644
index 000000000000..a26dca0131e6
--- /dev/null
+++ b/apps/web/src/appearanceContrast.ts
@@ -0,0 +1,10 @@
+import type { AppearanceContrast } from "@t3tools/contracts/settings";
+
+export function applyAppearanceContrast(root: HTMLElement, contrast: AppearanceContrast): void {
+ root.style.setProperty("--appearance-contrast-base", `${Math.min(contrast, 100)}%`);
+ root.style.setProperty("--appearance-contrast-boost", `${Math.max(contrast - 100, 0)}%`);
+ root.style.setProperty(
+ "--appearance-contrast-border-boost",
+ `${Math.max(contrast - 100, 0) / 4}%`,
+ );
+}
diff --git a/apps/web/src/browser/annotationTheme.ts b/apps/web/src/browser/annotationTheme.ts
index e12c667d23d7..cb3382449598 100644
--- a/apps/web/src/browser/annotationTheme.ts
+++ b/apps/web/src/browser/annotationTheme.ts
@@ -10,17 +10,17 @@ export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme {
colorScheme: root.classList.contains("dark") ? "dark" : "light",
radius: readVariable(styles, "--radius", "0.625rem"),
background: readVariable(styles, "--background", "white"),
- foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"),
+ foreground: readVariable(styles, "--contrast-foreground", "oklch(0.269 0 0)"),
popover: readVariable(styles, "--popover", "white"),
- popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"),
+ popoverForeground: readVariable(styles, "--contrast-popover-foreground", "oklch(0.269 0 0)"),
primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"),
primaryForeground: readVariable(styles, "--primary-foreground", "white"),
muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"),
- mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"),
+ mutedForeground: readVariable(styles, "--contrast-muted-foreground", "oklch(0.556 0 0)"),
accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"),
- accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"),
- border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"),
- input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"),
+ accentForeground: readVariable(styles, "--contrast-accent-foreground", "oklch(0.269 0 0)"),
+ border: readVariable(styles, "--contrast-border", "rgb(0 0 0 / 8%)"),
+ input: readVariable(styles, "--contrast-input", "rgb(0 0 0 / 10%)"),
ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"),
fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"),
fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"),
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index 81f901d7f015..28fa6b69e55c 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -1322,7 +1322,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
>
{/* The full path: the chip already shows the shortened form, and a link
to the workspace root collapses to a bare label that repeats it. */}
-
+
{targetPath}
diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx
index 1212030ba339..906bf4c34cb4 100644
--- a/apps/web/src/components/chat/ChangedFilesTree.tsx
+++ b/apps/web/src/components/chat/ChangedFilesTree.tsx
@@ -66,7 +66,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
className={cn(
"flex items-center justify-between gap-2 rounded-xl",
expanded &&
- "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]",
+ "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--contrast-foreground)_2.5%,var(--background))]",
)}
>
+
+ updateSettings({
+ appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+
+ {
+ const appearanceContrast = Number(event.currentTarget.value);
+ if (
+ Number.isInteger(appearanceContrast) &&
+ appearanceContrast >= MIN_APPEARANCE_CONTRAST &&
+ appearanceContrast <= MAX_APPEARANCE_CONTRAST
+ ) {
+ updateSettings({ appearanceContrast });
+ }
+ }}
+ step={5}
+ style={appearanceContrastSliderStyle}
+ type="range"
+ value={settings.appearanceContrast}
+ />
+
+ }
+ />
+
{
);
expect(html).toContain("rounded-[var(--control-radius)]");
- expect(html).toContain("[--control-icon-color:var(--muted-foreground)]");
+ expect(html).toContain("[--control-icon-color:var(--contrast-muted-foreground)]");
expect(html).toContain("text-[var(--control-icon-color)]");
expect(html).not.toContain("opacity-80");
});
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index f11ebd6fe21c..274eb00d7dba 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -43,14 +43,14 @@ const buttonVariants = cva(
"destructive-outline":
"border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-destructive/32 [:hover,[data-pressed]]:bg-destructive/4",
ghost:
- "[--control-icon-color:var(--muted-foreground)] border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent",
+ "[--control-icon-color:var(--contrast-muted-foreground)] border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent",
"ghost-muted":
- "[--control-icon-color:var(--muted-foreground)] border-transparent text-muted-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground",
+ "[--control-icon-color:var(--contrast-muted-foreground)] border-transparent text-muted-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground",
glass:
- "surface-glass [--control-icon-color:var(--muted-foreground)] border-border/60 text-foreground shadow-sm [:hover,[data-pressed]]:border-border",
+ "surface-glass [--control-icon-color:var(--contrast-muted-foreground)] border-border/60 text-foreground shadow-sm [:hover,[data-pressed]]:border-border",
link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline",
outline:
- "[--control-icon-color:var(--muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64",
+ "[--control-icon-color:var(--contrast-muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64",
secondary:
"border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90",
},
diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts
index 615980cd460a..74ee27a2b8e9 100644
--- a/apps/web/src/components/usage/usageProviders.ts
+++ b/apps/web/src/components/usage/usageProviders.ts
@@ -16,7 +16,7 @@ type UsageProviderPresentation = {
export const PROVIDER_PRESENTATION = {
codex: {
label: "Codex",
- color: "var(--foreground)",
+ color: "var(--contrast-foreground)",
mark: OpenAI,
},
claude: {
diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts
index 92efa723933b..ddc5da05f806 100644
--- a/apps/web/src/contextMenuFallback.test.ts
+++ b/apps/web/src/contextMenuFallback.test.ts
@@ -315,7 +315,7 @@ describe("showContextMenuFallback", () => {
expect(siblingButton).toBeTruthy();
expect(childButton?.focused).toBe(true);
expect(childButton?.style.background).toBe("var(--accent)");
- expect(childButton?.style.color).toBe("var(--accent-foreground)");
+ expect(childButton?.style.color).toBe("var(--contrast-accent-foreground)");
siblingButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(childButton?.focused).toBe(false);
expect(childButton?.style.background).toBe("transparent");
diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts
index f2c7f42a0617..ce8b8950a8b9 100644
--- a/apps/web/src/contextMenuFallback.ts
+++ b/apps/web/src/contextMenuFallback.ts
@@ -278,7 +278,7 @@ export function showContextMenuFallback(
menu.className =
"dropdown-glass fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg bg-clip-padding text-popover-foreground outline-none";
menu.style.cssText =
- "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--popover-foreground);outline:none;pointer-events:auto;";
+ "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--contrast-popover-foreground);outline:none;pointer-events:auto;";
menu.style.left = `${preferredLeft}px`;
menu.style.top = `${preferredTop}px`;
menu.dataset.level = String(level);
@@ -293,7 +293,8 @@ export function showContextMenuFallback(
if (item.separatorBefore === true && inner.children.length > 0) {
const separator = document.createElement("div");
separator.className = "mx-2 my-1 h-px bg-border";
- separator.style.cssText = "height:1px;margin:0.25rem 0.5rem;background:var(--border);";
+ separator.style.cssText =
+ "height:1px;margin:0.25rem 0.5rem;background:var(--contrast-border);";
separator.dataset.contextMenuSeparator = "true";
separator.setAttribute("role", "separator");
inner.appendChild(separator);
@@ -323,12 +324,12 @@ export function showContextMenuFallback(
? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground`
: `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`;
button.style.cssText =
- "display:flex;width:100%;min-height:1.75rem;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;";
+ "display:flex;width:100%;min-height:1.75rem;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--contrast-foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;";
if (isLeafDestructive) {
button.style.color = "var(--destructive-foreground)";
}
if (isDisabled) {
- button.style.color = "var(--muted-foreground)";
+ button.style.color = "var(--contrast-muted-foreground)";
button.style.opacity = "0.64";
button.style.pointerEvents = "none";
}
@@ -373,10 +374,10 @@ export function showContextMenuFallback(
button.style.color = isHighlighted
? isLeafDestructive
? "var(--destructive-foreground)"
- : "var(--accent-foreground)"
+ : "var(--contrast-accent-foreground)"
: isLeafDestructive
? "var(--destructive-foreground)"
- : "var(--foreground)";
+ : "var(--contrast-foreground)";
};
button.addEventListener("mouseenter", () => {
button.focus({ preventScroll: true });
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 63751f589019..f69adb9cf08e 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -78,6 +78,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
:root {
--app-scrollbar-width: 6px;
+ --appearance-contrast-base: 100%;
+ --appearance-contrast-boost: 0%;
+ --appearance-contrast-border-boost: 0%;
+ --appearance-contrast-target: black;
--app-scrollbar-thumb: rgb(217 217 217);
--app-scrollbar-thumb-hover: rgb(191 191 191);
/*
@@ -87,7 +91,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--control-radius: 0.5rem;
--sidebar-content-inset: 0.5rem;
--sidebar-control-gap: 0.5rem;
- --sidebar-icon-color: color-mix(in srgb, var(--sidebar-muted-foreground) 60%, var(--sidebar));
+ --sidebar-icon-color: color-mix(
+ in srgb,
+ var(--contrast-sidebar-muted-foreground) 60%,
+ var(--sidebar)
+ );
--sidebar-row-content-inset: 0.625rem;
--command-shell-inset: 0.5rem;
--command-content-inset: 1rem;
@@ -105,6 +113,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--workspace-titlebar-control-gap: 0.75rem;
@variant dark {
+ --appearance-contrast-target: white;
--app-scrollbar-thumb: rgb(255 255 255 / 8%);
--app-scrollbar-thumb-hover: rgb(255 255 255 / 12%);
--glass-blur: 16px;
@@ -156,40 +165,40 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--color-info: var(--info);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
- --color-input: var(--input);
- --color-border: var(--border);
+ --color-input: var(--contrast-input);
+ --color-border: var(--contrast-border);
--color-destructive: var(--destructive);
- --color-accent-foreground: var(--accent-foreground);
+ --color-accent-foreground: var(--contrast-accent-foreground);
--color-accent: var(--accent);
- --color-muted-foreground: var(--muted-foreground);
+ --color-muted-foreground: var(--contrast-muted-foreground);
--color-muted: var(--muted);
- --color-placeholder: var(--placeholder);
- --color-secondary-label: var(--secondary-label);
- --color-icon-muted: var(--icon-muted);
- --color-secondary-foreground: var(--secondary-foreground);
+ --color-placeholder: var(--contrast-placeholder);
+ --color-secondary-label: var(--contrast-secondary-label);
+ --color-icon-muted: var(--contrast-icon-muted);
+ --color-secondary-foreground: var(--contrast-secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
- --color-popover-foreground: var(--popover-foreground);
+ --color-popover-foreground: var(--contrast-popover-foreground);
--color-popover: var(--popover);
- --color-card-foreground: var(--card-foreground);
+ --color-card-foreground: var(--contrast-card-foreground);
--color-card: var(--card);
- --color-foreground: var(--foreground);
+ --color-foreground: var(--contrast-foreground);
--color-background: var(--background);
--color-surface-raised: var(--surface-raised);
--color-message: var(--message-surface);
- --color-message-foreground: var(--message-foreground);
+ --color-message-foreground: var(--contrast-message-foreground);
--color-message-action: var(--message-action);
--color-message-action-foreground: var(--message-action-foreground);
--color-message-action-hover: var(--message-action-hover);
--color-sidebar: var(--sidebar);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-muted-foreground: var(--sidebar-muted-foreground);
+ --color-sidebar-foreground: var(--contrast-sidebar-foreground);
+ --color-sidebar-muted-foreground: var(--contrast-sidebar-muted-foreground);
--color-sidebar-control-surface: var(--sidebar-control-surface);
--color-sidebar-row-hover: var(--sidebar-row-hover);
--color-sidebar-row-active: var(--sidebar-row-active);
--color-sidebar-row-selected: var(--sidebar-row-selected);
- --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-border: var(--contrast-sidebar-border);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -304,7 +313,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
- border-color: color-mix(in srgb, var(--foreground) 10%, transparent);
+ border-color: color-mix(in srgb, var(--contrast-foreground) 10%, transparent);
box-shadow: 0 24px 64px -24px rgb(0 0 0 / 65%);
@variant dark {
@@ -337,7 +346,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
- border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
+ border: 1px solid color-mix(in srgb, var(--contrast-foreground) 10%, transparent);
@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
background: var(--popover) !important;
@@ -757,7 +766,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
.chat-composer-shoulder-tab {
border-color: var(
--chat-composer-outline,
- color-mix(in srgb, var(--foreground) 8%, transparent)
+ color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
);
background: color-mix(
in srgb,
@@ -781,7 +790,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
--chat-composer-attached-outline: var(
--chat-composer-outline,
- color-mix(in srgb, var(--foreground) 8%, transparent)
+ color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
);
background: color-mix(
in srgb,
@@ -817,7 +826,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
--chat-composer-attached-outline: var(
--chat-composer-outline,
- color-mix(in srgb, var(--foreground) 8%, transparent)
+ color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
);
--chat-composer-attached-tint: transparent;
position: relative;
@@ -1238,17 +1247,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
background: linear-gradient(
to right,
var(--primary) 0 var(--settings-slider-fill-position),
- color-mix(in srgb, var(--muted-foreground) 22%, transparent)
+ color-mix(in srgb, var(--contrast-muted-foreground) 22%, transparent)
var(--settings-slider-fill-position) 100%
);
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--contrast-border) 55%, transparent);
}
.settings-slider::-moz-range-track {
height: 0.375rem;
border-radius: 9999px;
- background: color-mix(in srgb, var(--muted-foreground) 22%, transparent);
- box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent);
+ background: color-mix(in srgb, var(--contrast-muted-foreground) 22%, transparent);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--contrast-border) 55%, transparent);
}
.settings-slider::-moz-range-progress {
@@ -1265,7 +1274,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
border: 2px solid var(--primary);
border-radius: 9999px;
background: var(--background);
- box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 12%, transparent);
+ box-shadow: 0 1px 2px color-mix(in srgb, var(--contrast-foreground) 12%, transparent);
transition:
transform 120ms ease,
box-shadow 120ms ease;
@@ -1277,7 +1286,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
border: 2px solid var(--primary);
border-radius: 9999px;
background: var(--background);
- box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 12%, transparent);
+ box-shadow: 0 1px 2px color-mix(in srgb, var(--contrast-foreground) 12%, transparent);
transition:
transform 120ms ease,
box-shadow 120ms ease;
@@ -1285,12 +1294,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
.settings-slider:hover::-webkit-slider-thumb {
transform: scale(1.08);
- box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent);
+ box-shadow: 0 1px 3px color-mix(in srgb, var(--contrast-foreground) 20%, transparent);
}
.settings-slider:hover::-moz-range-thumb {
transform: scale(1.08);
- box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent);
+ box-shadow: 0 1px 3px color-mix(in srgb, var(--contrast-foreground) 20%, transparent);
}
.settings-slider:active::-webkit-slider-thumb {
@@ -1524,9 +1533,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--sidebar-foreground: var(--foreground);
--sidebar-muted-foreground: var(--muted-foreground);
--sidebar-control-surface: var(--muted);
- --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent);
- --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent);
- --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent);
+ --sidebar-row-hover: color-mix(in srgb, var(--contrast-foreground) 8%, transparent);
+ --sidebar-row-active: color-mix(in srgb, var(--contrast-foreground) 11%, transparent);
+ --sidebar-row-selected: color-mix(in srgb, var(--contrast-foreground) 7%, transparent);
--sidebar-border: var(--border);
--sidebar-stage-fade: var(--card);
}
@@ -1711,11 +1720,11 @@ html[data-theme-token-probe] *::after {
max-width: 12rem;
overflow: hidden;
padding: 0.2rem 0.45rem;
- border: 1px solid color-mix(in oklab, var(--ring) 48%, var(--border));
+ border: 1px solid color-mix(in oklab, var(--ring) 48%, var(--contrast-border));
border-radius: 0.4rem;
background: var(--popover);
box-shadow: 0 4px 16px rgb(0 0 0 / 24%);
- color: var(--popover-foreground);
+ color: var(--contrast-popover-foreground);
font-size: 0.6875rem;
font-weight: 600;
line-height: 1rem;
@@ -1736,16 +1745,16 @@ html[data-theme-token-probe] *::after {
dark-mode whites while preserving the same theme file across clients. */
html[data-theme-id] [data-chat-header] {
background-color: var(--toolbar-background);
- color: var(--toolbar-foreground);
+ color: var(--contrast-toolbar-foreground);
}
html[data-theme-id] [data-chat-header] [data-slot="button"],
html[data-theme-id] [data-chat-header] [data-slot="menu-trigger"],
html[data-theme-id] [data-chat-header] [data-toolbar-control] {
- --control-icon-color: var(--toolbar-control-foreground);
+ --control-icon-color: var(--contrast-toolbar-control-foreground);
background-color: var(--toolbar-control);
- border-color: var(--toolbar-border);
- color: var(--toolbar-control-foreground);
+ border-color: var(--contrast-toolbar-border);
+ color: var(--contrast-toolbar-control-foreground);
}
/* The panel layout toggles stay ghost: they render both inside the header and
@@ -1757,8 +1766,8 @@ html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"],
html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"],
html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"],
html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] {
- --control-icon-color: var(--toolbar-foreground);
- color: var(--toolbar-foreground);
+ --control-icon-color: var(--contrast-toolbar-foreground);
+ color: var(--contrast-toolbar-foreground);
}
html[data-theme-id] [data-chat-header] [data-slot="button"]:hover,
@@ -1771,7 +1780,7 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control][data-pressed] {
}
html[data-theme-id] [data-chat-header] [data-slot="separator"] {
- background-color: var(--toolbar-border);
+ background-color: var(--contrast-toolbar-border);
}
/* Chat code blocks join the other code surfaces on the code tokens. The
@@ -1779,7 +1788,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] {
surfaces with the accent, which fights syntax highlighting. */
html[data-theme-id] .chat-markdown .chat-markdown-codeblock {
background-color: var(--code-background);
- border-color: var(--border);
+ border-color: var(--contrast-border);
color: var(--code-foreground);
}
@@ -1814,7 +1823,7 @@ html[data-theme-id="t3-chat"] {
}
& .chat-markdown :not(pre) > code {
- color: var(--message-foreground);
+ color: var(--contrast-message-foreground);
}
}
}
@@ -1844,10 +1853,10 @@ html[data-theme-id] [data-app-sidebar] {
--sidebar-row-selected: var(--app-theme-sidebar-row-selected);
--sidebar-border: var(--app-theme-sidebar-border);
--sidebar-stage-fade: var(--app-theme-sidebar);
- border-color: color-mix(in srgb, var(--sidebar-foreground) 10%, transparent);
+ border-color: color-mix(in srgb, var(--contrast-sidebar-foreground) 10%, transparent);
@variant dark {
- border-color: color-mix(in srgb, var(--sidebar-foreground) 8%, transparent);
+ border-color: color-mix(in srgb, var(--contrast-sidebar-foreground) 8%, transparent);
}
}
@@ -1855,10 +1864,127 @@ html[data-theme-id] [data-app-sidebar] {
keeps that color while hovered. Do not neutralize this branded edge. */
html[data-theme-id="t3-chat"] [data-app-sidebar] {
@variant dark {
- border-color: var(--sidebar-border);
+ border-color: var(--contrast-sidebar-border);
}
}
+/* Contrast stays in ordinary custom properties so both Tailwind utilities and
+ global/imperative chrome styles resolve the same adjusted role. Redeclare on
+ the sidebar because it owns a local semantic palette. */
+:root,
+[data-app-sidebar] {
+ --contrast-toolbar-foreground: color-mix(
+ in oklab,
+ color-mix(
+ in oklab,
+ var(--toolbar-foreground) var(--appearance-contrast-base),
+ var(--toolbar-background)
+ ),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-toolbar-border: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--toolbar-border) var(--appearance-contrast-base), transparent),
+ var(--toolbar-foreground) var(--appearance-contrast-border-boost)
+ );
+ --contrast-toolbar-control-foreground: color-mix(
+ in oklab,
+ color-mix(
+ in oklab,
+ var(--toolbar-control-foreground) var(--appearance-contrast-base),
+ var(--toolbar-control)
+ ),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-input: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--input) var(--appearance-contrast-base), transparent),
+ var(--foreground) var(--appearance-contrast-border-boost)
+ );
+ --contrast-border: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--border) var(--appearance-contrast-base), transparent),
+ var(--foreground) var(--appearance-contrast-border-boost)
+ );
+ --contrast-foreground: color-mix(
+ in oklab,
+ color-mix(in oklab, var(--foreground) var(--appearance-contrast-base), var(--background)),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-muted-foreground: color-mix(
+ in oklab,
+ color-mix(in oklab, var(--muted-foreground) var(--appearance-contrast-base), var(--background)),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-placeholder: color-mix(
+ in oklab,
+ color-mix(in oklab, var(--placeholder) var(--appearance-contrast-base), var(--background)),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-secondary-label: color-mix(
+ in oklab,
+ color-mix(in oklab, var(--secondary-label) var(--appearance-contrast-base), var(--background)),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-icon-muted: color-mix(
+ in oklab,
+ color-mix(in oklab, var(--icon-muted) var(--appearance-contrast-base), var(--background)),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-card-foreground: color-mix(
+ in oklab,
+ color-mix(in oklab, var(--card-foreground) var(--appearance-contrast-base), var(--card)),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-popover-foreground: color-mix(
+ in oklab,
+ color-mix(in oklab, var(--popover-foreground) var(--appearance-contrast-base), var(--popover)),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-accent-foreground: color-mix(
+ in oklab,
+ color-mix(in oklab, var(--accent-foreground) var(--appearance-contrast-base), var(--accent)),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-secondary-foreground: color-mix(
+ in oklab,
+ color-mix(
+ in oklab,
+ var(--secondary-foreground) var(--appearance-contrast-base),
+ var(--secondary)
+ ),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-message-foreground: color-mix(
+ in oklab,
+ color-mix(
+ in oklab,
+ var(--message-foreground) var(--appearance-contrast-base),
+ var(--message-surface)
+ ),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-sidebar-foreground: color-mix(
+ in oklab,
+ color-mix(in oklab, var(--sidebar-foreground) var(--appearance-contrast-base), var(--sidebar)),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-sidebar-muted-foreground: color-mix(
+ in oklab,
+ color-mix(
+ in oklab,
+ var(--sidebar-muted-foreground) var(--appearance-contrast-base),
+ var(--sidebar)
+ ),
+ var(--appearance-contrast-target) var(--appearance-contrast-boost)
+ );
+ --contrast-sidebar-border: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--sidebar-border) var(--appearance-contrast-base), transparent),
+ var(--sidebar-foreground) var(--appearance-contrast-border-boost)
+ );
+}
+
body {
/* Reference the theme token (not a literal stack) so the Settings ->
Appearance runtime override of --font-sans reaches all interface text. */
@@ -2004,7 +2130,7 @@ code {
margin: 1.25rem 0 0.5rem;
font-weight: 600;
line-height: 1.3;
- color: var(--foreground);
+ color: var(--contrast-foreground);
}
.chat-markdown h1 {
@@ -2026,7 +2152,7 @@ code {
}
.chat-markdown h6 {
- color: var(--muted-foreground);
+ color: var(--contrast-muted-foreground);
}
.chat-markdown ul {
@@ -2103,16 +2229,16 @@ code {
}
.chat-markdown blockquote {
- border-left: 2px solid var(--border);
+ border-left: 2px solid var(--contrast-border);
padding-left: 0.8rem;
- color: var(--muted-foreground);
+ color: var(--contrast-muted-foreground);
}
.chat-markdown section[data-footnotes] {
margin-top: 1.25rem;
- border-top: 1px solid var(--border);
+ border-top: 1px solid var(--contrast-border);
padding-top: 0.75rem;
- color: var(--muted-foreground);
+ color: var(--contrast-muted-foreground);
font-size: 0.75rem;
}
@@ -2137,17 +2263,17 @@ code {
}
.chat-markdown :not(pre) > code {
- border: 1px solid var(--border);
+ border: 1px solid var(--contrast-border);
border-radius: 0.375rem;
background: var(--muted);
padding: 0.1rem 0.35rem;
- color: var(--foreground);
+ color: var(--contrast-foreground);
font-size: 0.75rem;
}
.chat-markdown a.chat-markdown-file-link,
.chat-markdown a.chat-markdown-file-link:hover {
- color: var(--foreground);
+ color: var(--contrast-foreground);
text-decoration: none;
}
@@ -2159,12 +2285,12 @@ code {
.chat-markdown pre {
max-width: 100%;
overflow-x: auto;
- border: 1px solid var(--border);
+ border: 1px solid var(--contrast-border);
border-radius: 0.75rem;
background: var(--muted);
padding: 0.8rem 0.9rem;
scrollbar-width: thin;
- scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent;
+ scrollbar-color: color-mix(in srgb, var(--contrast-border) 78%, transparent) transparent;
}
.chat-markdown pre code {
@@ -2179,21 +2305,21 @@ code {
.chat-markdown pre::-webkit-scrollbar-thumb {
border-radius: 999px;
- background: color-mix(in srgb, var(--border) 78%, transparent);
+ background: color-mix(in srgb, var(--contrast-border) 78%, transparent);
}
.chat-markdown .chat-markdown-codeblock-header,
.chat-markdown .chat-markdown-chrome-action {
- color: color-mix(in srgb, var(--foreground) 72%, transparent);
+ color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent);
}
.chat-markdown .chat-markdown-chrome-action:hover {
- color: var(--foreground);
+ color: var(--contrast-foreground);
}
.chat-markdown .chat-markdown-chrome-action[aria-pressed="true"] {
- color: var(--foreground);
- background: color-mix(in srgb, var(--foreground) 8%, transparent);
+ color: var(--contrast-foreground);
+ background: color-mix(in srgb, var(--contrast-foreground) 8%, transparent);
}
.chat-markdown .chat-markdown-codeblock pre {
@@ -2233,14 +2359,14 @@ code {
}
.chat-markdown thead th {
- border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
+ border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent);
padding-block: 0.55rem;
font-weight: 600;
white-space: nowrap;
}
.chat-markdown tbody td {
- border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
+ border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent);
}
/* Collapsed (default): single-line cells truncate so arbitrary chat content
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 91381301418e..7c715dff9e95 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -30,6 +30,7 @@ import {
} from "../components/ui/toast";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { applyAppearanceFontVariables } from "~/appearanceFonts";
+import { applyAppearanceContrast } from "~/appearanceContrast";
import { useClientSettings } from "../hooks/useSettings";
import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal";
import {
@@ -131,6 +132,7 @@ function RootRouteView() {
+
{primaryEnvironmentAuthenticated ? : null}
@@ -152,6 +154,16 @@ function RootRouteView() {
);
}
+function ContrastAppearanceSync() {
+ const appearanceContrast = useClientSettings((settings) => settings.appearanceContrast);
+
+ useEffect(() => {
+ applyAppearanceContrast(document.documentElement, appearanceContrast);
+ }, [appearanceContrast]);
+
+ return null;
+}
+
function GlassAppearanceSync() {
const glassOpacity = useClientSettings((settings) => settings.glassOpacity);
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 0f59da5ece14..55023bcc48e7 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -51,6 +51,22 @@ describe("ClientSettings glass opacity", () => {
});
});
+describe("ClientSettings appearance contrast", () => {
+ it("defaults to the theme's original contrast", () => {
+ expect(decodeClientSettings({}).appearanceContrast).toBe(100);
+ });
+
+ it.each([49, 201, 92.5])("rejects an invalid appearance contrast: %s", (value) => {
+ expect(() => decodeClientSettings({ appearanceContrast: value })).toThrow();
+ expect(() => decodeClientSettingsPatch({ appearanceContrast: value })).toThrow();
+ });
+
+ it.each([50, 100, 150, 200])("accepts an appearance contrast in range: %s", (value) => {
+ expect(decodeClientSettings({ appearanceContrast: value }).appearanceContrast).toBe(value);
+ expect(decodeClientSettingsPatch({ appearanceContrast: value }).appearanceContrast).toBe(value);
+ });
+});
+
describe("ClientSettings environment identification", () => {
it("defaults to artwork and accepts each presentation mode", () => {
expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork");
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 0502d303d249..ba4facaf53ce 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -75,6 +75,14 @@ export const GlassOpacity = Schema.Int.check(
);
export type GlassOpacity = typeof GlassOpacity.Type;
export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80;
+
+export const MIN_APPEARANCE_CONTRAST = 50;
+export const MAX_APPEARANCE_CONTRAST = 200;
+export const AppearanceContrast = Schema.Int.check(
+ Schema.isBetween({ minimum: MIN_APPEARANCE_CONTRAST, maximum: MAX_APPEARANCE_CONTRAST }),
+);
+export type AppearanceContrast = typeof AppearanceContrast.Type;
+export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100;
/**
* Font size preferences, in CSS pixels. The ranges are deliberately narrow:
* the interface size scales every rem-based dimension in the app, so the
@@ -133,6 +141,9 @@ export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIE
export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true;
export const ClientSettingsSchema = Schema.Struct({
+ appearanceContrast: AppearanceContrast.pipe(
+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)),
+ ),
browserDefaultViewport: PreviewViewportSetting.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)),
),
@@ -860,6 +871,7 @@ export const ServerSettingsPatch = Schema.Struct({
export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
export const ClientSettingsPatch = Schema.Struct({
+ appearanceContrast: Schema.optionalKey(AppearanceContrast),
browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting),
browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),
browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),