From e08faf0ad817b79ec4fde19d59975bd11230c124 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:59:05 +0000 Subject: [PATCH 1/9] feat(web): add appearance contrast control Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/appearanceContrast.test.ts | 21 +++++++ apps/web/src/appearanceContrast.ts | 8 +++ .../components/settings/SettingsPanels.tsx | 57 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 ++ apps/web/src/index.css | 2 + apps/web/src/routes/__root.tsx | 12 ++++ packages/contracts/src/settings.test.ts | 16 ++++++ packages/contracts/src/settings.ts | 12 ++++ 8 files changed, 134 insertions(+) create mode 100644 apps/web/src/appearanceContrast.test.ts create mode 100644 apps/web/src/appearanceContrast.ts diff --git a/apps/web/src/appearanceContrast.test.ts b/apps/web/src/appearanceContrast.test.ts new file mode 100644 index 000000000000..3e9c2800c8fc --- /dev/null +++ b/apps/web/src/appearanceContrast.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { applyAppearanceContrast } from "./appearanceContrast"; + +describe("applyAppearanceContrast", () => { + it("applies a non-default contrast filter", () => { + const setProperty = vi.fn(); + + applyAppearanceContrast({ style: { setProperty } } as unknown as HTMLElement, 135); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-filter", "contrast(135%)"); + }); + + it("disables the filter at the default contrast", () => { + const setProperty = vi.fn(); + + applyAppearanceContrast({ style: { setProperty } } as unknown as HTMLElement, 100); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-filter", "none"); + }); +}); diff --git a/apps/web/src/appearanceContrast.ts b/apps/web/src/appearanceContrast.ts new file mode 100644 index 000000000000..5977626b8154 --- /dev/null +++ b/apps/web/src/appearanceContrast.ts @@ -0,0 +1,8 @@ +import type { AppearanceContrast } from "@t3tools/contracts/settings"; + +export function applyAppearanceContrast(root: HTMLElement, contrast: AppearanceContrast): void { + root.style.setProperty( + "--appearance-contrast-filter", + contrast === 100 ? "none" : `contrast(${contrast}%)`, + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9539f95914cb..b31c54eb8adc 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -20,6 +20,7 @@ import { DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, DEFAULT_UNIFIED_SETTINGS, type EnvironmentIdentificationMode, + MAX_APPEARANCE_CONTRAST, MAX_CODE_FONT_SIZE, MAX_GLASS_OPACITY, MAX_INTERFACE_FONT_SIZE, @@ -27,6 +28,7 @@ import { MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MAX_TERMINAL_FONT_SIZE, MIN_CODE_FONT_SIZE, + MIN_APPEARANCE_CONTRAST, MIN_GLASS_OPACITY, MIN_INTERFACE_FONT_SIZE, MIN_PROMPT_FONT_SIZE, @@ -986,6 +988,13 @@ export function AppearanceSettingsPanel() { "--settings-slider-progress": `${glassOpacityRatio * 100}%`, "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`, } as CSSProperties; + const appearanceContrastRatio = + (settings.appearanceContrast - MIN_APPEARANCE_CONTRAST) / + (MAX_APPEARANCE_CONTRAST - MIN_APPEARANCE_CONTRAST); + const appearanceContrastSliderStyle = { + "--settings-slider-progress": `${appearanceContrastRatio * 100}%`, + "--settings-slider-fill-offset": `${0.5 - appearanceContrastRatio}rem`, + } as CSSProperties; return ( @@ -1006,6 +1015,54 @@ export function AppearanceSettingsPanel() { /> + + 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} + /> +
+ } + /> + + {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..e223f6960ca0 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, 151, 92.5])("rejects an invalid appearance contrast: %s", (value) => { + expect(() => decodeClientSettings({ appearanceContrast: value })).toThrow(); + expect(() => decodeClientSettingsPatch({ appearanceContrast: value })).toThrow(); + }); + + it.each([50, 100, 150])("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..a0df8505a1bc 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 = 150; +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), From 2d26aeb6919f559ac227d6bf4310348338c3ba23 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:17:50 +0000 Subject: [PATCH 2/9] fix(web): derive contrast from theme tokens Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- .../check-run-agents/ui-consistency.md | 1 + .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/appearanceContrast.test.ts | 40 ++++++++--- apps/web/src/appearanceContrast.ts | 7 +- apps/web/src/index.css | 70 +++++++++++++++---- 5 files changed, 95 insertions(+), 24 deletions(-) diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 8ec720742759..b4a7fc76b804 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -47,6 +47,7 @@ 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. - 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 index 3e9c2800c8fc..7887bbb0e18a 100644 --- a/apps/web/src/appearanceContrast.test.ts +++ b/apps/web/src/appearanceContrast.test.ts @@ -2,20 +2,44 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { applyAppearanceContrast } from "./appearanceContrast"; +function makeRoot() { + const setProperty = vi.fn(); + const toggleAttribute = vi.fn(); + return { + root: { style: { setProperty }, toggleAttribute } as unknown as HTMLElement, + setProperty, + toggleAttribute, + }; +} + describe("applyAppearanceContrast", () => { - it("applies a non-default contrast filter", () => { - const setProperty = vi.fn(); + it("boosts semantic contrast above the default", () => { + const { root, setProperty, toggleAttribute } = makeRoot(); + + applyAppearanceContrast(root, 135); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%"); + expect(toggleAttribute).toHaveBeenCalledWith("data-appearance-contrast", true); + }); + + it("softens semantic contrast below the default", () => { + const { root, setProperty, toggleAttribute } = makeRoot(); - applyAppearanceContrast({ style: { setProperty } } as unknown as HTMLElement, 135); + applyAppearanceContrast(root, 70); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-filter", "contrast(135%)"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "70%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); + expect(toggleAttribute).toHaveBeenCalledWith("data-appearance-contrast", true); }); - it("disables the filter at the default contrast", () => { - const setProperty = vi.fn(); + it("disables contrast mixing at the default", () => { + const { root, setProperty, toggleAttribute } = makeRoot(); - applyAppearanceContrast({ style: { setProperty } } as unknown as HTMLElement, 100); + applyAppearanceContrast(root, 100); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-filter", "none"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); + expect(toggleAttribute).toHaveBeenCalledWith("data-appearance-contrast", false); }); }); diff --git a/apps/web/src/appearanceContrast.ts b/apps/web/src/appearanceContrast.ts index 5977626b8154..78aeb4c34ae8 100644 --- a/apps/web/src/appearanceContrast.ts +++ b/apps/web/src/appearanceContrast.ts @@ -1,8 +1,7 @@ import type { AppearanceContrast } from "@t3tools/contracts/settings"; export function applyAppearanceContrast(root: HTMLElement, contrast: AppearanceContrast): void { - root.style.setProperty( - "--appearance-contrast-filter", - contrast === 100 ? "none" : `contrast(${contrast}%)`, - ); + root.style.setProperty("--appearance-contrast-base", `${Math.min(contrast, 100)}%`); + root.style.setProperty("--appearance-contrast-boost", `${Math.max(contrast - 100, 0)}%`); + root.toggleAttribute("data-appearance-contrast", contrast !== 100); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ccc6372c4e02..84c88951384a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -78,7 +78,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil :root { --app-scrollbar-width: 6px; - --appearance-contrast-filter: none; + --appearance-contrast-base: 100%; + --appearance-contrast-boost: 0%; + --appearance-contrast-target: black; --app-scrollbar-thumb: rgb(217 217 217); --app-scrollbar-thumb-hover: rgb(191 191 191); /* @@ -104,9 +106,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --workspace-native-controls-inset: 0px; --workspace-titlebar-control-size: 1.75rem; --workspace-titlebar-control-gap: 0.75rem; - filter: var(--appearance-contrast-filter); @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; @@ -158,16 +160,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: color-mix( + in oklab, + color-mix(in oklab, var(--input) var(--appearance-contrast-base), var(--background)), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --color-border: color-mix( + in oklab, + color-mix(in oklab, var(--border) var(--appearance-contrast-base), var(--background)), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); + --color-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) + ); --color-muted: var(--muted); - --color-placeholder: var(--placeholder); - --color-secondary-label: var(--secondary-label); - --color-icon-muted: var(--icon-muted); + --color-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) + ); + --color-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) + ); + --color-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) + ); --color-secondary-foreground: var(--secondary-foreground); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); @@ -176,7 +202,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); - --color-foreground: var(--foreground); + --color-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) + ); --color-background: var(--background); --color-surface-raised: var(--surface-raised); --color-message: var(--message-surface); @@ -185,13 +215,29 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --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: 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) + ); + --color-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) + ); --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: color-mix( + in oklab, + color-mix(in oklab, var(--sidebar-border) var(--appearance-contrast-base), var(--sidebar)), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); From 5c8db7a35ba31a2f782d94cdbd43632b161ee33d Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:19:55 +0000 Subject: [PATCH 3/9] fix(web): reset contrast with settings defaults Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/settings/SettingsPanels.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b31c54eb8adc..582dc9f6cb94 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -477,6 +477,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(theme !== "system" ? ["Theme"] : []), ...(!followSystem ? ["Follow system"] : []), ...(themeHalves !== null ? ["Theme mix"] : []), + ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast + ? ["Contrast"] + : []), ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), ...(settings.environmentIdentificationMode !== DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode @@ -545,6 +548,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserDefaultZoomFactor, settings.browserDefaultAppearance, settings.browserAutoShowFloatingPreview, + settings.appearanceContrast, settings.enableAgentBrowserAccess, settings.confirmQuit, settings.confirmThreadArchive, @@ -640,6 +644,7 @@ export function useSettingsRestore(onRestored?: () => void) { return; } updateSettings({ + appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, From 0249d3a088748f61a782c74dcf1bfc842b7ee324 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:33:20 +0000 Subject: [PATCH 4/9] fix(web): preserve contrast token ownership Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- .../check-run-agents/ui-consistency.md | 1 + apps/web/src/appearanceContrast.test.ts | 16 +++--- apps/web/src/appearanceContrast.ts | 5 +- apps/web/src/index.css | 55 ++++++++++++++----- 4 files changed, 54 insertions(+), 23 deletions(-) diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index b4a7fc76b804..10ec1c0e874e 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -48,6 +48,7 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put - 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. - 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/web/src/appearanceContrast.test.ts b/apps/web/src/appearanceContrast.test.ts index 7887bbb0e18a..552e6bcd997b 100644 --- a/apps/web/src/appearanceContrast.test.ts +++ b/apps/web/src/appearanceContrast.test.ts @@ -4,42 +4,40 @@ import { applyAppearanceContrast } from "./appearanceContrast"; function makeRoot() { const setProperty = vi.fn(); - const toggleAttribute = vi.fn(); return { - root: { style: { setProperty }, toggleAttribute } as unknown as HTMLElement, + root: { style: { setProperty } } as unknown as HTMLElement, setProperty, - toggleAttribute, }; } describe("applyAppearanceContrast", () => { it("boosts semantic contrast above the default", () => { - const { root, setProperty, toggleAttribute } = makeRoot(); + const { root, setProperty } = makeRoot(); applyAppearanceContrast(root, 135); expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%"); - expect(toggleAttribute).toHaveBeenCalledWith("data-appearance-contrast", true); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "8.75%"); }); it("softens semantic contrast below the default", () => { - const { root, setProperty, toggleAttribute } = makeRoot(); + const { root, setProperty } = makeRoot(); applyAppearanceContrast(root, 70); expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "70%"); expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); - expect(toggleAttribute).toHaveBeenCalledWith("data-appearance-contrast", true); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); }); it("disables contrast mixing at the default", () => { - const { root, setProperty, toggleAttribute } = makeRoot(); + const { root, setProperty } = makeRoot(); applyAppearanceContrast(root, 100); expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); - expect(toggleAttribute).toHaveBeenCalledWith("data-appearance-contrast", false); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); }); }); diff --git a/apps/web/src/appearanceContrast.ts b/apps/web/src/appearanceContrast.ts index 78aeb4c34ae8..a26dca0131e6 100644 --- a/apps/web/src/appearanceContrast.ts +++ b/apps/web/src/appearanceContrast.ts @@ -3,5 +3,8 @@ 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.toggleAttribute("data-appearance-contrast", contrast !== 100); + root.style.setProperty( + "--appearance-contrast-border-boost", + `${Math.max(contrast - 100, 0) / 4}%`, + ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 84c88951384a..57c5ec6aea1c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -80,6 +80,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --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); @@ -161,17 +162,21 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --color-destructive-foreground: var(--destructive-foreground); --color-ring: var(--ring); --color-input: color-mix( - in oklab, - color-mix(in oklab, var(--input) var(--appearance-contrast-base), var(--background)), - var(--appearance-contrast-target) var(--appearance-contrast-boost) + in srgb, + color-mix(in srgb, var(--input) var(--appearance-contrast-base), transparent), + var(--foreground) var(--appearance-contrast-border-boost) ); --color-border: color-mix( + in srgb, + color-mix(in srgb, var(--border) var(--appearance-contrast-base), transparent), + var(--foreground) var(--appearance-contrast-border-boost) + ); + --color-destructive: var(--destructive); + --color-accent-foreground: color-mix( in oklab, - color-mix(in oklab, var(--border) var(--appearance-contrast-base), var(--background)), + color-mix(in oklab, var(--accent-foreground) var(--appearance-contrast-base), var(--accent)), var(--appearance-contrast-target) var(--appearance-contrast-boost) ); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: color-mix( in oklab, @@ -194,13 +199,29 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil color-mix(in oklab, var(--icon-muted) var(--appearance-contrast-base), var(--background)), var(--appearance-contrast-target) var(--appearance-contrast-boost) ); - --color-secondary-foreground: var(--secondary-foreground); + --color-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) + ); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); + --color-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) + ); --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); + --color-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) + ); --color-card: var(--card); --color-foreground: color-mix( in oklab, @@ -210,7 +231,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --color-background: var(--background); --color-surface-raised: var(--surface-raised); --color-message: var(--message-surface); - --color-message-foreground: var(--message-foreground); + --color-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) + ); --color-message-action: var(--message-action); --color-message-action-foreground: var(--message-action-foreground); --color-message-action-hover: var(--message-action-hover); @@ -234,9 +263,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --color-sidebar-row-active: var(--sidebar-row-active); --color-sidebar-row-selected: var(--sidebar-row-selected); --color-sidebar-border: color-mix( - in oklab, - color-mix(in oklab, var(--sidebar-border) var(--appearance-contrast-base), var(--sidebar)), - var(--appearance-contrast-target) var(--appearance-contrast-boost) + in srgb, + color-mix(in srgb, var(--sidebar-border) var(--appearance-contrast-base), transparent), + var(--sidebar-foreground) var(--appearance-contrast-border-boost) ); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); From 4b52a03bce00e149abfe68b321c317eaad1b4818 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:48:18 +0000 Subject: [PATCH 5/9] fix(web): share contrast roles across chrome Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- .../check-run-agents/ui-consistency.md | 1 + apps/web/src/components/ChatMarkdown.tsx | 2 +- .../src/components/chat/ChangedFilesTree.tsx | 2 +- apps/web/src/components/chat/ModelListRow.tsx | 2 +- .../components/chat/ModelPickerSidebar.tsx | 4 +- .../components/chat/ProviderModelPicker.tsx | 2 +- .../components/clerk/clerkAppearance.test.ts | 10 +- .../src/components/clerk/clerkAppearance.ts | 10 +- apps/web/src/components/color-selector.tsx | 2 +- .../src/components/files/FileBrowserPanel.tsx | 2 +- .../pullRequest/pullRequestPresentation.tsx | 2 +- apps/web/src/components/ui/button.test.tsx | 2 +- apps/web/src/components/ui/button.tsx | 8 +- .../src/components/usage/usageProviders.ts | 2 +- apps/web/src/contextMenuFallback.test.ts | 2 +- apps/web/src/contextMenuFallback.ts | 13 +- apps/web/src/index.css | 286 ++++++++++-------- 17 files changed, 190 insertions(+), 162 deletions(-) diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 10ec1c0e874e..1132553b1144 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -49,6 +49,7 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put - 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, and imperative style strings. Audit literal `var(--foreground)`, `var(--border)`, and related role reads so headings, markdown chrome, menus, 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/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))]", )} >