diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 99a62f663f..4215414706 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -11,12 +11,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; @@ -57,6 +58,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(() => [])), @@ -226,6 +228,20 @@ export function getCustomModelOptionsByProvider( }; } +export function applyHighContrastMode(enabled: boolean) { + if (typeof document === "undefined") return; + document.documentElement.classList.toggle(HIGH_CONTRAST_CLASS_NAME, enabled); +} + +export function getStoredHighContrastMode(): boolean { + try { + const stored = getLocalStorageItem(APP_SETTINGS_STORAGE_KEY, AppSettingsSchema); + return stored?.highContrastMode ?? false; + } catch { + return false; + } +} + export function getProviderStartOptions( settings: Pick, ): ProviderStartOptions | undefined { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ea76f24fac..46d758f4cc 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -120,6 +120,27 @@ } } +: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 { + color: var(--muted-foreground); + opacity: 1; +} + +:root.high-contrast [class*="text-muted-foreground/"] { + 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 0fbff1cdad..36cac5e94c 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -455,6 +455,34 @@ function SettingsRouteView() { } /> + + updateSettings({ + highContrastMode: defaults.highContrastMode, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + highContrastMode: Boolean(checked), + }) + } + aria-label="High contrast mode" + /> + } + /> +