diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index fadb8cb69d..1b812e9006 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -38,7 +38,11 @@ import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; -const DIFF_PANEL_UNSAFE_CSS = ` +function buildDiffPanelCss(colorblind: boolean): string { + const addition = colorblind ? "var(--cb-addition)" : "var(--success)"; + const deletion = colorblind ? "var(--cb-deletion)" : "var(--destructive)"; + + return ` [data-diffs-header], [data-diff], [data-file], @@ -55,23 +59,23 @@ const DIFF_PANEL_UNSAFE_CSS = ` --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); - --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success)); - --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success)); - --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); - --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); - - --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive)); - --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive)); - --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); - --diffs-bg-deletion-emphasis-override: color-mix( - in srgb, - var(--background) 80%, - var(--destructive) - ); + --diffs-addition-color-override: ${addition}; + --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, ${addition}); + --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, ${addition}); + --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, ${addition}); + --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, ${addition}); + + --diffs-deletion-color-override: ${deletion}; + --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, ${deletion}); + --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, ${deletion}); + --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, ${deletion}); + --diffs-bg-deletion-emphasis-override: color-mix(in srgb, var(--background) 80%, ${deletion}); background-color: var(--diffs-bg) !important; +}`; } +const DIFF_PANEL_STATIC_CSS = ` [data-file-info] { background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; border-block-color: var(--border) !important; @@ -169,6 +173,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const settings = useSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); + const diffUnsafeCss = useMemo( + () => buildDiffPanelCss(settings.colorblindMode) + DIFF_PANEL_STATIC_CSS, + [settings.colorblindMode], + ); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); @@ -615,7 +623,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { overflow: diffWordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme as DiffThemeType, - unsafeCSS: DIFF_PANEL_UNSAFE_CSS, + unsafeCSS: diffUnsafeCss, }} /> diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 1593a151da..30b4ddf3d2 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -970,9 +970,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions Excluded ) : ( <> - +{file.insertions} + +{file.insertions} / - -{file.deletions} + -{file.deletions} )} @@ -983,11 +983,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
- + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index 2dda06fd9d..596ac263b1 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -13,9 +13,9 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: { return ( <> {showParentheses && (} - +{additions} + +{additions} / - -{deletions} + -{deletions} {showParentheses && )} ); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b7fde0c5f6..a4ed07c7cc 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -457,6 +457,9 @@ export function useSettingsRestore(onRestored?: () => void) { const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), + ...(settings.colorblindMode !== DEFAULT_UNIFIED_SETTINGS.colorblindMode + ? ["Colorblind mode"] + : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -481,6 +484,7 @@ export function useSettingsRestore(onRestored?: () => void) { [ areProviderSettingsDirty, isGitWritingModelDirty, + settings.colorblindMode, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.defaultThreadEnvMode, @@ -814,6 +818,30 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + colorblindMode: DEFAULT_UNIFIED_SETTINGS.colorblindMode, + }) + } + /> + ) : null + } + control={ + updateSettings({ colorblindMode: Boolean(checked) })} + aria-label="Enable colorblind mode" + /> + } + /> + { confirmThreadDelete: false, }); }); + + it("does not migrate colorblindMode since it never existed in legacy settings", () => { + expect(buildLegacyClientSettingsMigrationPatch({ colorblindMode: true })).toEqual({}); + }); }); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 3f804bc48b..80ac153a34 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -31,7 +31,7 @@ import { } from "@t3tools/contracts/settings"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { ensureNativeApi } from "~/nativeApi"; -import { useLocalStorage } from "./useLocalStorage"; +import { getLocalStorageItem, useLocalStorage } from "./useLocalStorage"; import { normalizeCustomModelSlugs } from "~/modelSelection"; import { Predicate, Schema, Struct } from "effect"; import { DeepMutable } from "effect/Types"; @@ -268,3 +268,19 @@ export function migrateLocalSettingsToServer(): void { localStorage.removeItem(OLD_SETTINGS_KEY); } } + +// ── Colorblind mode root class ────────────────────────────────── + +export function applyColorblindMode(enabled: boolean): void { + if (typeof document === "undefined") return; + document.documentElement.classList.toggle("colorblind-mode", enabled); +} + +export function getStoredColorblindMode(): boolean { + try { + const stored = getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); + return stored?.colorblindMode ?? false; + } catch { + return false; + } +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ea76f24fac..6b312594c6 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -120,6 +120,34 @@ } } +/* Colorblind mode: blue/orange for addition/deletion comparative contexts. + Colors from GitHub Primer @primer/primitives diffBlob tokens. */ +:root.colorblind-mode { + --cb-addition: #0969da; + --cb-deletion: #bc4c00; +} + +:root.colorblind-mode.dark { + --cb-addition: #388bfd; + --cb-deletion: #db6d28; +} + +.diff-stat-addition { + color: var(--color-success); +} + +.diff-stat-deletion { + color: var(--color-destructive); +} + +:root.colorblind-mode .diff-stat-addition { + color: var(--cb-addition); +} + +:root.colorblind-mode .diff-stat-deletion { + color: var(--cb-deletion); +} + body { font-family: "DM Sans", diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fda5913c97..476d7ff932 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,7 @@ import "./index.css"; import { isElectron } from "./env"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; +import { applyColorblindMode, getStoredColorblindMode } from "./hooks/useSettings"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); @@ -16,6 +17,7 @@ const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history); document.title = APP_DISPLAY_NAME; +applyColorblindMode(getStoredColorblindMode()); 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 e99ec50226..db79261888 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -22,7 +22,11 @@ import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; import { onServerConfigUpdated, onServerProvidersUpdated, onServerWelcome } from "../wsNativeApi"; -import { migrateLocalSettingsToServer } from "../hooks/useSettings"; +import { + applyColorblindMode, + migrateLocalSettingsToServer, + useSettings, +} from "../hooks/useSettings"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; @@ -53,6 +57,7 @@ function RootRouteView() { return ( + @@ -63,6 +68,14 @@ function RootRouteView() { ); } +function ColorblindModeSync() { + const settings = useSettings(); + useEffect(() => { + applyColorblindMode(settings.colorblindMode); + }, [settings.colorblindMode]); + return null; +} + function RootRouteErrorView({ error, reset }: ErrorComponentProps) { const message = errorMessage(error); const details = errorDetails(error); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 51fe683f99..e63d36ba95 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -24,6 +24,7 @@ export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; export const ClientSettingsSchema = Schema.Struct({ + colorblindMode: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),