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 fadb8cb69d..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,7 +41,12 @@ import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; -const DIFF_PANEL_UNSAFE_CSS = ` +function buildDiffPanelCss(colorblind: boolean, theme: "light" | "dark"): string { + 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], [data-diff], [data-file], @@ -55,23 +63,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 +177,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, resolvedTheme) + DIFF_PANEL_STATIC_CSS, + [settings.colorblindMode, resolvedTheme], + ); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); @@ -615,7 +627,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..0bdd89d68d 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -50,6 +50,9 @@ 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"; +import { COLORBLIND_DIFF_STYLES } from "~/lib/diffRendering"; interface GitActionsControlProps { gitCwd: string | null; @@ -204,6 +207,9 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { } export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { + const { colorblindMode } = useSettings(); + const { resolvedTheme } = useTheme(); + const cb = colorblindMode ? COLORBLIND_DIFF_STYLES[resolvedTheme] : null; const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], @@ -970,9 +976,19 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions Excluded ) : ( <> - +{file.insertions} + + +{file.insertions} + / - -{file.deletions} + + -{file.deletions} + )} @@ -983,11 +999,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/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 2dda06fd9d..e098066b1e 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -1,4 +1,5 @@ import { memo } from "react"; +import type { ColorblindDiffStyle } from "~/lib/diffRendering"; export function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { return stat.additions > 0 || stat.deletions > 0; @@ -8,14 +9,24 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: { additions: number; deletions: number; showParentheses?: boolean; + colorblindStyle?: ColorblindDiffStyle | null | undefined; }) { - const { additions, deletions, showParentheses = false } = props; + const { additions, deletions, showParentheses = false, colorblindStyle: cb } = props; + return ( <> {showParentheses && (} - +{additions} + {cb ? ( + +{additions} + ) : ( + +{additions} + )} / - -{deletions} + {cb ? ( + -{deletions} + ) : ( + -{deletions} + )} {showParentheses && )} ); 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/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f9fdb1d615..14c2ba8e50 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.colorblindMode !== DEFAULT_UNIFIED_SETTINGS.colorblindMode + ? ["Colorblind mode"] + : []), ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ["Diff line wrapping"] : []), @@ -480,6 +483,7 @@ export function useSettingsRestore(onRestored?: () => void) { [ areProviderSettingsDirty, isGitWritingModelDirty, + settings.colorblindMode, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.defaultThreadEnvMode, @@ -837,6 +841,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/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", 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); + }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9cd8a5f251..36b7aee19b 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)),