From 71787fde211c6abb1d28fef12fa26b935f93ca74 Mon Sep 17 00:00:00 2001 From: Andy Wong Date: Sat, 21 Mar 2026 17:42:40 -0400 Subject: [PATCH 1/3] Add high-contrast mode setting - Persist and apply a high-contrast class at startup and in the root route - Add a settings toggle and restore control - Boost muted text contrast in the UI --- apps/web/src/appSettings.ts | 23 ++++++++++++++++- apps/web/src/index.css | 30 ++++++++++++++++++++++ apps/web/src/main.tsx | 2 ++ apps/web/src/routes/__root.tsx | 7 ++++++ apps/web/src/routes/_chat.settings.tsx | 35 ++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 1 deletion(-) diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 14b6a6a92d..14a41167c0 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -7,12 +7,13 @@ import { normalizeModelSlug, resolveSelectableModel, } from "@t3tools/shared/model"; -import { useLocalStorage } from "./hooks/useLocalStorage"; +import { getLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; import { EnvMode } from "./components/BranchToolbar.logic"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; +export const HIGH_CONTRAST_CLASS_NAME = "high-contrast"; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; @@ -52,6 +53,7 @@ export const AppSettingsSchema = Schema.Struct({ defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), + highContrastMode: Schema.Boolean.pipe(withDefaults(() => false)), timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), @@ -221,6 +223,25 @@ export function getCustomModelOptionsByProvider( }; } +export function applyHighContrastMode(enabled: boolean) { + if (typeof document === "undefined") return; + document.documentElement.classList.toggle(HIGH_CONTRAST_CLASS_NAME, enabled); +} + +export function getStoredAppSettings(): AppSettings { + try { + return normalizeAppSettings( + getLocalStorageItem(APP_SETTINGS_STORAGE_KEY, AppSettingsSchema) ?? DEFAULT_APP_SETTINGS, + ); + } catch { + return DEFAULT_APP_SETTINGS; + } +} + +export function getStoredHighContrastMode(): boolean { + return getStoredAppSettings().highContrastMode; +} + export function useAppSettings() { const [settings, setSettings] = useLocalStorage( APP_SETTINGS_STORAGE_KEY, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ea76f24fac..94746a4597 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -120,6 +120,36 @@ } } +:root.high-contrast { + --muted-foreground: color-mix(in srgb, var(--color-neutral-700) 92%, var(--color-black)); +} + +:root.high-contrast.dark { + --muted-foreground: color-mix(in srgb, var(--color-neutral-300) 92%, var(--color-white)); +} + +:root.high-contrast ::placeholder { + color: var(--muted-foreground); + opacity: 1; +} + +:root.high-contrast .text-muted-foreground\/30, +:root.high-contrast .text-muted-foreground\/35, +:root.high-contrast .text-muted-foreground\/40, +:root.high-contrast .text-muted-foreground\/45, +:root.high-contrast .text-muted-foreground\/50, +:root.high-contrast .text-muted-foreground\/55, +:root.high-contrast .text-muted-foreground\/60, +:root.high-contrast .text-muted-foreground\/65, +:root.high-contrast .text-muted-foreground\/70, +:root.high-contrast .text-muted-foreground\/72, +:root.high-contrast .text-muted-foreground\/75, +:root.high-contrast .text-muted-foreground\/80, +:root.high-contrast .text-muted-foreground\/85, +:root.high-contrast .text-muted-foreground\/90 { + color: var(--muted-foreground) !important; +} + body { font-family: "DM Sans", diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fda5913c97..2f3d36a299 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,6 +6,7 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" import "@xterm/xterm/css/xterm.css"; import "./index.css"; +import { applyHighContrastMode, getStoredHighContrastMode } from "./appSettings"; import { isElectron } from "./env"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; @@ -16,6 +17,7 @@ const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history); document.title = APP_DISPLAY_NAME; +applyHighContrastMode(getStoredHighContrastMode()); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82f..8fe96c6ed3 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -10,6 +10,7 @@ import { useEffect, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; +import { applyHighContrastMode, useAppSettings } from "../appSettings"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; @@ -36,6 +37,12 @@ export const Route = createRootRouteWithContext<{ }); function RootRouteView() { + const { settings } = useAppSettings(); + + useEffect(() => { + applyHighContrastMode(settings.highContrastMode); + }, [settings.highContrastMode]); + if (!readNativeApi()) { return (
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index acc8763fb4..ce15e95dc2 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -236,6 +236,24 @@ function SettingsRouteView() { Active theme: {resolvedTheme}

+
+
+

High contrast mode

+

+ Strengthen muted text and low-contrast labels across light and dark mode. +

+
+ + updateSettings({ + highContrastMode: Boolean(checked), + }) + } + aria-label="High contrast mode" + /> +
+

Timestamp format

@@ -279,6 +297,22 @@ function SettingsRouteView() {
) : null} + + {settings.highContrastMode !== defaults.highContrastMode ? ( +
+ +
+ ) : null}
@@ -699,6 +733,7 @@ function SettingsRouteView() {
) : null} +

About

From 7255b26434c6e8f8024df726adfe9e279b094917 Mon Sep 17 00:00:00 2001 From: Andy Wong Date: Sun, 22 Mar 2026 13:44:33 -0400 Subject: [PATCH 2/3] added high contrast borders --- apps/web/src/index.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 94746a4597..d529cbab31 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -122,10 +122,14 @@ :root.high-contrast { --muted-foreground: color-mix(in srgb, var(--color-neutral-700) 92%, var(--color-black)); + --border: --alpha(var(--color-black) / 45%); + --input: --alpha(var(--color-black) / 50%); } :root.high-contrast.dark { --muted-foreground: color-mix(in srgb, var(--color-neutral-300) 92%, var(--color-white)); + --border: --alpha(var(--color-white) / 40%); + --input: --alpha(var(--color-white) / 45%); } :root.high-contrast ::placeholder { From e76de050ed5192d49402d63d3d0bf062ea34902b Mon Sep 17 00:00:00 2001 From: Andy Wong Date: Sun, 22 Mar 2026 14:22:59 -0400 Subject: [PATCH 3/3] reduced code --- apps/web/src/appSettings.ts | 13 ++++--------- apps/web/src/index.css | 15 +-------------- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 14a41167c0..1d109c711c 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -228,20 +228,15 @@ export function applyHighContrastMode(enabled: boolean) { document.documentElement.classList.toggle(HIGH_CONTRAST_CLASS_NAME, enabled); } -export function getStoredAppSettings(): AppSettings { +export function getStoredHighContrastMode(): boolean { try { - return normalizeAppSettings( - getLocalStorageItem(APP_SETTINGS_STORAGE_KEY, AppSettingsSchema) ?? DEFAULT_APP_SETTINGS, - ); + const stored = getLocalStorageItem(APP_SETTINGS_STORAGE_KEY, AppSettingsSchema); + return stored?.highContrastMode ?? false; } catch { - return DEFAULT_APP_SETTINGS; + return false; } } -export function getStoredHighContrastMode(): boolean { - return getStoredAppSettings().highContrastMode; -} - export function useAppSettings() { const [settings, setSettings] = useLocalStorage( APP_SETTINGS_STORAGE_KEY, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d529cbab31..46d758f4cc 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -137,20 +137,7 @@ opacity: 1; } -:root.high-contrast .text-muted-foreground\/30, -:root.high-contrast .text-muted-foreground\/35, -:root.high-contrast .text-muted-foreground\/40, -:root.high-contrast .text-muted-foreground\/45, -:root.high-contrast .text-muted-foreground\/50, -:root.high-contrast .text-muted-foreground\/55, -:root.high-contrast .text-muted-foreground\/60, -:root.high-contrast .text-muted-foreground\/65, -:root.high-contrast .text-muted-foreground\/70, -:root.high-contrast .text-muted-foreground\/72, -:root.high-contrast .text-muted-foreground\/75, -:root.high-contrast .text-muted-foreground\/80, -:root.high-contrast .text-muted-foreground\/85, -:root.high-contrast .text-muted-foreground\/90 { +:root.high-contrast [class*="text-muted-foreground/"] { color: var(--muted-foreground) !important; }