diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 14b6a6a92..1d109c711 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,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 useAppSettings() { const [settings, setSettings] = useLocalStorage( APP_SETTINGS_STORAGE_KEY, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ea76f24fa..46d758f4c 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 fda5913c9..2f3d36a29 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 34f9c4b82..8fe96c6ed 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 acc8763fb..ce15e95dc 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