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={ +
+ + {settings.appearanceContrast}% + + { + 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),