From a853904c274884c090268de057bb63cc1dc71252 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 11:38:31 -0400 Subject: [PATCH 1/5] Add colorblind-friendly diff colors setting Add an opt-in toggle in Settings > General that swaps the diff panel's red/green palette to blue/orange for users with color vision deficiency. Closes #1533 --- apps/web/src/components/DiffPanel.tsx | 40 ++++++++++++------- .../components/settings/SettingsPanels.tsx | 30 ++++++++++++++ apps/web/src/hooks/useSettings.ts | 4 ++ packages/contracts/src/settings.ts | 1 + 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index fadb8cb69d..1068a850c9 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -38,7 +38,15 @@ import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; -const DIFF_PANEL_UNSAFE_CSS = ` +// Blue/orange palette for colorblind users (distinguishable under deuteranopia/protanopia). +const CB_ADDITION = "oklch(0.75 0.15 70)"; +const CB_DELETION = "oklch(0.55 0.18 250)"; + +function buildDiffPanelCss(colorblind: boolean): string { + const addition = colorblind ? CB_ADDITION : "var(--success)"; + const deletion = colorblind ? CB_DELETION : "var(--destructive)"; + + return ` [data-diffs-header], [data-diff], [data-file], @@ -55,23 +63,21 @@ 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-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-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 +175,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.colorblindDiffColors) + DIFF_PANEL_STATIC_CSS, + [settings.colorblindDiffColors], + ); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); @@ -615,7 +625,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/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f9fdb1d615..96fd608c51 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -459,6 +459,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), + ...(settings.colorblindDiffColors !== DEFAULT_UNIFIED_SETTINGS.colorblindDiffColors + ? ["Colorblind diff colors"] + : []), ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ["Diff line wrapping"] : []), @@ -480,6 +483,7 @@ export function useSettingsRestore(onRestored?: () => void) { [ areProviderSettingsDirty, isGitWritingModelDirty, + settings.colorblindDiffColors, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.defaultThreadEnvMode, @@ -837,6 +841,32 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + colorblindDiffColors: DEFAULT_UNIFIED_SETTINGS.colorblindDiffColors, + }) + } + /> + ) : null + } + control={ + + updateSettings({ colorblindDiffColors: Boolean(checked) }) + } + aria-label="Use colorblind-friendly diff colors" + /> + } + /> + > { const patch: Partial> = {}; + if (Predicate.isBoolean(legacySettings.colorblindDiffColors)) { + patch.colorblindDiffColors = legacySettings.colorblindDiffColors; + } + if (Predicate.isBoolean(legacySettings.confirmThreadArchive)) { patch.confirmThreadArchive = legacySettings.confirmThreadArchive; } diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9cd8a5f251..10f42bb3a0 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({ + colorblindDiffColors: 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)), From 74cfef23a34da0e87398c781209048e9ce9c54ff Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 12:40:03 -0400 Subject: [PATCH 2/5] Use GitHub Primer colorblind palette and fix header stat colors Switch from invented oklch values to GitHub Primer's production-tested diffBlob tokens (blue/orange) with proper light/dark variants. Add --diffs-addition-color-override and --diffs-deletion-color-override to also recolor the -N/+N header text, not just line backgrounds. --- apps/web/src/components/DiffPanel.tsx | 33 +++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 1068a850c9..cce60d2d05 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -38,13 +38,26 @@ import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; -// Blue/orange palette for colorblind users (distinguishable under deuteranopia/protanopia). -const CB_ADDITION = "oklch(0.75 0.15 70)"; -const CB_DELETION = "oklch(0.55 0.18 250)"; - -function buildDiffPanelCss(colorblind: boolean): string { - const addition = colorblind ? CB_ADDITION : "var(--success)"; - const deletion = colorblind ? CB_DELETION : "var(--destructive)"; +// GitHub Primer colorblind palette (protanopia/deuteranopia). +// Source: @primer/primitives diffBlob tokens — the industry standard. +// Light: addition #0969da (blue), deletion #bc4c00 (orange) +// Dark: addition #388bfd (blue), deletion #db6d28 (orange) +const CB_ADDITION_LIGHT = "#0969da"; +const CB_ADDITION_DARK = "#388bfd"; +const CB_DELETION_LIGHT = "#bc4c00"; +const CB_DELETION_DARK = "#db6d28"; + +function buildDiffPanelCss(colorblind: boolean, theme: "light" | "dark"): string { + const addition = colorblind + ? theme === "dark" + ? CB_ADDITION_DARK + : CB_ADDITION_LIGHT + : "var(--success)"; + const deletion = colorblind + ? theme === "dark" + ? CB_DELETION_DARK + : CB_DELETION_LIGHT + : "var(--destructive)"; return ` [data-diffs-header], @@ -63,11 +76,13 @@ function buildDiffPanelCss(colorblind: boolean): string { --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-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}); @@ -176,8 +191,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const diffUnsafeCss = useMemo( - () => buildDiffPanelCss(settings.colorblindDiffColors) + DIFF_PANEL_STATIC_CSS, - [settings.colorblindDiffColors], + () => buildDiffPanelCss(settings.colorblindDiffColors, resolvedTheme) + DIFF_PANEL_STATIC_CSS, + [settings.colorblindDiffColors, resolvedTheme], ); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); From cb6e93796878f7c88646ddca44deaf1bcab7ce1c Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 12:44:42 -0400 Subject: [PATCH 3/5] Broaden to colorblind mode and cover diff stats Rename colorblindDiffColors to colorblindMode. Apply the Primer blue/orange palette to DiffStatLabel and GitActionsControl insertion/ deletion counts so all red/green stat indicators respect the setting. --- apps/web/src/components/DiffPanel.tsx | 4 +-- apps/web/src/components/GitActionsControl.tsx | 35 ++++++++++++++++--- .../web/src/components/chat/DiffStatLabel.tsx | 30 ++++++++++++++-- .../components/settings/SettingsPanels.tsx | 24 ++++++------- apps/web/src/hooks/useSettings.ts | 4 +-- packages/contracts/src/settings.ts | 2 +- 6 files changed, 75 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index cce60d2d05..0420c8639e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -191,8 +191,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const diffUnsafeCss = useMemo( - () => buildDiffPanelCss(settings.colorblindDiffColors, resolvedTheme) + DIFF_PANEL_STATIC_CSS, - [settings.colorblindDiffColors, resolvedTheme], + () => buildDiffPanelCss(settings.colorblindMode, resolvedTheme) + DIFF_PANEL_STATIC_CSS, + [settings.colorblindMode, resolvedTheme], ); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 1593a151da..b6e834080a 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -50,6 +50,20 @@ import { import { randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; +import { useSettings } from "~/hooks/useSettings"; +import { useTheme } from "~/hooks/useTheme"; + +// GitHub Primer colorblind palette (protanopia/deuteranopia). +const CB_STYLES = { + light: { + addition: { color: "#0969da" }, + deletion: { color: "#bc4c00" }, + }, + dark: { + addition: { color: "#388bfd" }, + deletion: { color: "#db6d28" }, + }, +} as const; interface GitActionsControlProps { gitCwd: string | null; @@ -204,6 +218,9 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { } export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { + const { colorblindMode } = useSettings(); + const { resolvedTheme } = useTheme(); + const cb = colorblindMode ? CB_STYLES[resolvedTheme] : null; const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], @@ -970,9 +987,19 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions Excluded ) : ( <> - +{file.insertions} + + +{file.insertions} + / - -{file.deletions} + + -{file.deletions} + )} @@ -983,11 +1010,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..e95ca042c1 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -1,21 +1,47 @@ import { memo } from "react"; +import { useSettings } from "~/hooks/useSettings"; +import { useTheme } from "~/hooks/useTheme"; export function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { return stat.additions > 0 || stat.deletions > 0; } +// GitHub Primer colorblind palette (protanopia/deuteranopia). +const CB_STYLES = { + light: { + addition: { color: "#0969da" }, + deletion: { color: "#bc4c00" }, + }, + dark: { + addition: { color: "#388bfd" }, + deletion: { color: "#db6d28" }, + }, +} as const; + export const DiffStatLabel = memo(function DiffStatLabel(props: { additions: number; deletions: number; showParentheses?: boolean; }) { const { additions, deletions, showParentheses = false } = props; + const { colorblindMode } = useSettings(); + const { resolvedTheme } = useTheme(); + const cb = colorblindMode ? CB_STYLES[resolvedTheme] : null; + return ( <> {showParentheses && (} - +{additions} + {cb ? ( + +{additions} + ) : ( + +{additions} + )} / - -{deletions} + {cb ? ( + -{deletions} + ) : ( + -{deletions} + )} {showParentheses && )} ); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 96fd608c51..14c2ba8e50 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -459,8 +459,8 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), - ...(settings.colorblindDiffColors !== DEFAULT_UNIFIED_SETTINGS.colorblindDiffColors - ? ["Colorblind diff colors"] + ...(settings.colorblindMode !== DEFAULT_UNIFIED_SETTINGS.colorblindMode + ? ["Colorblind mode"] : []), ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ["Diff line wrapping"] @@ -483,7 +483,7 @@ export function useSettingsRestore(onRestored?: () => void) { [ areProviderSettingsDirty, isGitWritingModelDirty, - settings.colorblindDiffColors, + settings.colorblindMode, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.defaultThreadEnvMode, @@ -842,15 +842,15 @@ export function GeneralSettingsPanel() { /> updateSettings({ - colorblindDiffColors: DEFAULT_UNIFIED_SETTINGS.colorblindDiffColors, + colorblindMode: DEFAULT_UNIFIED_SETTINGS.colorblindMode, }) } /> @@ -858,11 +858,9 @@ export function GeneralSettingsPanel() { } control={ - updateSettings({ colorblindDiffColors: Boolean(checked) }) - } - aria-label="Use colorblind-friendly diff colors" + checked={settings.colorblindMode} + onCheckedChange={(checked) => updateSettings({ colorblindMode: Boolean(checked) })} + aria-label="Enable colorblind mode" /> } /> diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 5b4d66b9f5..03a548a1c7 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -202,8 +202,8 @@ export function buildLegacyClientSettingsMigrationPatch( ): Partial> { const patch: Partial> = {}; - if (Predicate.isBoolean(legacySettings.colorblindDiffColors)) { - patch.colorblindDiffColors = legacySettings.colorblindDiffColors; + if (Predicate.isBoolean(legacySettings.colorblindMode)) { + patch.colorblindMode = legacySettings.colorblindMode; } if (Predicate.isBoolean(legacySettings.confirmThreadArchive)) { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 10f42bb3a0..36b7aee19b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -24,7 +24,7 @@ export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; export const ClientSettingsSchema = Schema.Struct({ - colorblindDiffColors: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + 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)), From 0e999b590f1644f9952acfc293595d9246fc6584 Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 12:49:48 -0400 Subject: [PATCH 4/5] Address review feedback: remove dead migration, add tests Remove colorblindMode from legacy migration since the setting never existed in old localStorage. Add test coverage for the schema default and a regression test confirming migration correctly ignores it. --- apps/web/src/hooks/useSettings.test.ts | 4 ++++ apps/web/src/hooks/useSettings.ts | 4 ---- packages/contracts/src/settings.test.ts | 4 ++++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 832ee17f7f..9844878e1f 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -13,4 +13,8 @@ describe("buildLegacyClientSettingsMigrationPatch", () => { 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 03a548a1c7..3f804bc48b 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -202,10 +202,6 @@ export function buildLegacyClientSettingsMigrationPatch( ): Partial> { const patch: Partial> = {}; - if (Predicate.isBoolean(legacySettings.colorblindMode)) { - patch.colorblindMode = legacySettings.colorblindMode; - } - if (Predicate.isBoolean(legacySettings.confirmThreadArchive)) { patch.confirmThreadArchive = legacySettings.confirmThreadArchive; } diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e7f638c7f3..9a4414072d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -5,4 +5,8 @@ describe("DEFAULT_CLIENT_SETTINGS", () => { it("includes archive confirmation with a false default", () => { expect(DEFAULT_CLIENT_SETTINGS.confirmThreadArchive).toBe(false); }); + + it("includes colorblind mode with a false default", () => { + expect(DEFAULT_CLIENT_SETTINGS.colorblindMode).toBe(false); + }); }); From 604523948909a650a936a834b919ab2f71be119d Mon Sep 17 00:00:00 2001 From: Tim Crooker Date: Sun, 29 Mar 2026 13:03:59 -0400 Subject: [PATCH 5/5] Extract shared colorblind palette and fix DiffStatLabel memoization Move COLORBLIND_DIFF_STYLES to diffRendering.ts so the palette is defined once instead of duplicated across three files. Convert DiffStatLabel to accept colorblindStyle as a prop instead of calling useSettings/useTheme internally, preserving its memo() contract. Thread the prop from ChatView through MessagesTimeline and ChangedFilesTree. --- apps/web/src/components/ChatView.tsx | 3 ++ apps/web/src/components/DiffPanel.tsx | 29 +++++-------------- apps/web/src/components/GitActionsControl.tsx | 15 ++-------- .../src/components/chat/ChangedFilesTree.tsx | 17 +++++++++-- .../web/src/components/chat/DiffStatLabel.tsx | 21 ++------------ .../src/components/chat/MessagesTimeline.tsx | 5 ++++ apps/web/src/lib/diffRendering.ts | 17 +++++++++++ 7 files changed, 52 insertions(+), 55 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fbd332354a..39c3ceaa97 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,6 +27,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; +import { COLORBLIND_DIFF_STYLES } from "~/lib/diffRendering"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; @@ -263,6 +264,7 @@ export default function ChatView({ threadId }: ChatViewProps) { select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); + const colorblindStyle = settings.colorblindMode ? COLORBLIND_DIFF_STYLES[resolvedTheme] : null; const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); @@ -3655,6 +3657,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} + colorblindStyle={colorblindStyle} timestampFormat={timestampFormat} workspaceRoot={activeProject?.cwd ?? undefined} /> diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 0420c8639e..c47d5af704 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -26,8 +26,11 @@ import { readNativeApi } from "../nativeApi"; import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { useTheme } from "../hooks/useTheme"; -import { buildPatchCacheKey } from "../lib/diffRendering"; -import { resolveDiffThemeName } from "../lib/diffRendering"; +import { + buildPatchCacheKey, + COLORBLIND_DIFF_STYLES, + resolveDiffThemeName, +} from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; import { useSettings } from "../hooks/useSettings"; @@ -38,26 +41,10 @@ import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; -// GitHub Primer colorblind palette (protanopia/deuteranopia). -// Source: @primer/primitives diffBlob tokens — the industry standard. -// Light: addition #0969da (blue), deletion #bc4c00 (orange) -// Dark: addition #388bfd (blue), deletion #db6d28 (orange) -const CB_ADDITION_LIGHT = "#0969da"; -const CB_ADDITION_DARK = "#388bfd"; -const CB_DELETION_LIGHT = "#bc4c00"; -const CB_DELETION_DARK = "#db6d28"; - function buildDiffPanelCss(colorblind: boolean, theme: "light" | "dark"): string { - const addition = colorblind - ? theme === "dark" - ? CB_ADDITION_DARK - : CB_ADDITION_LIGHT - : "var(--success)"; - const deletion = colorblind - ? theme === "dark" - ? CB_DELETION_DARK - : CB_DELETION_LIGHT - : "var(--destructive)"; + const cb = colorblind ? COLORBLIND_DIFF_STYLES[theme] : null; + const addition = cb ? cb.addition.color : "var(--success)"; + const deletion = cb ? cb.deletion.color : "var(--destructive)"; return ` [data-diffs-header], diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index b6e834080a..0bdd89d68d 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -52,18 +52,7 @@ import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; import { useSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; - -// GitHub Primer colorblind palette (protanopia/deuteranopia). -const CB_STYLES = { - light: { - addition: { color: "#0969da" }, - deletion: { color: "#bc4c00" }, - }, - dark: { - addition: { color: "#388bfd" }, - deletion: { color: "#db6d28" }, - }, -} as const; +import { COLORBLIND_DIFF_STYLES } from "~/lib/diffRendering"; interface GitActionsControlProps { gitCwd: string | null; @@ -220,7 +209,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { const { colorblindMode } = useSettings(); const { resolvedTheme } = useTheme(); - const cb = colorblindMode ? CB_STYLES[resolvedTheme] : null; + const cb = colorblindMode ? COLORBLIND_DIFF_STYLES[resolvedTheme] : null; const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 0174a77088..fefd00a207 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -6,15 +6,18 @@ import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { VscodeEntryIcon } from "./VscodeEntryIcon"; +import type { ColorblindDiffStyle } from "~/lib/diffRendering"; export const ChangedFilesTree = memo(function ChangedFilesTree(props: { turnId: TurnId; files: ReadonlyArray; allDirectoriesExpanded: boolean; resolvedTheme: "light" | "dark"; + colorblindStyle?: ColorblindDiffStyle | null | undefined; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { - const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; + const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, colorblindStyle, turnId } = + props; const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); const directoryPathsKey = useMemo( () => collectDirectoryPaths(treeNodes).join("\u0000"), @@ -71,7 +74,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { {hasNonZeroStat(node.stat) && ( - + )} @@ -104,7 +111,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { {node.stat && ( - + )} diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index e95ca042c1..e098066b1e 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -1,32 +1,17 @@ import { memo } from "react"; -import { useSettings } from "~/hooks/useSettings"; -import { useTheme } from "~/hooks/useTheme"; +import type { ColorblindDiffStyle } from "~/lib/diffRendering"; export function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { return stat.additions > 0 || stat.deletions > 0; } -// GitHub Primer colorblind palette (protanopia/deuteranopia). -const CB_STYLES = { - light: { - addition: { color: "#0969da" }, - deletion: { color: "#bc4c00" }, - }, - dark: { - addition: { color: "#388bfd" }, - deletion: { color: "#db6d28" }, - }, -} as const; - export const DiffStatLabel = memo(function DiffStatLabel(props: { additions: number; deletions: number; showParentheses?: boolean; + colorblindStyle?: ColorblindDiffStyle | null | undefined; }) { - const { additions, deletions, showParentheses = false } = props; - const { colorblindMode } = useSettings(); - const { resolvedTheme } = useTheme(); - const cb = colorblindMode ? CB_STYLES[resolvedTheme] : null; + const { additions, deletions, showParentheses = false, colorblindStyle: cb } = props; return ( <> diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3174030ef..c468188c8b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -40,6 +40,7 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImage import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; +import type { ColorblindDiffStyle } from "~/lib/diffRendering"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; @@ -79,6 +80,7 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; + colorblindStyle?: ColorblindDiffStyle | null | undefined; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; } @@ -103,6 +105,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, markdownCwd, resolvedTheme, + colorblindStyle, timestampFormat, workspaceRoot, }: MessagesTimelineProps) { @@ -474,6 +477,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )} @@ -505,6 +509,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ files={checkpointFiles} allDirectoriesExpanded={allDirectoriesExpanded} resolvedTheme={resolvedTheme} + colorblindStyle={colorblindStyle} onOpenTurnDiff={onOpenTurnDiff} /> diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 7218f72978..f2204b6af0 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,3 +1,20 @@ +// GitHub Primer colorblind palette (protanopia/deuteranopia). +// Source: @primer/primitives diffBlob tokens. +// Light: addition #0969da (blue), deletion #bc4c00 (orange) +// Dark: addition #388bfd (blue), deletion #db6d28 (orange) +export const COLORBLIND_DIFF_STYLES = { + light: { + addition: { color: "#0969da" } as const, + deletion: { color: "#bc4c00" } as const, + }, + dark: { + addition: { color: "#388bfd" } as const, + deletion: { color: "#db6d28" } as const, + }, +} as const; + +export type ColorblindDiffStyle = (typeof COLORBLIND_DIFF_STYLES)["light" | "dark"]; + export const DIFF_THEME_NAMES = { light: "pierre-light", dark: "pierre-dark",