From eae963708ae48b11ca327188e95065f751f90ddc Mon Sep 17 00:00:00 2001 From: James Klein Date: Fri, 17 Jul 2026 12:04:21 -0700 Subject: [PATCH] feat: add colorblind-safe diff palette (--colorblind) Add an orthogonal `--colorblind` axis that remaps the red/green diff palette to a colorblind-safe blue (additions) / orange (deletions) scheme, following GitHub's approach. Works on top of both light and dark themes, and the hunk header shifts to neutral gray so it does not collide with the blue used for additions. - CLI: `--colorblind` flag on the main command and `diffity tree`, threaded through as a `colorblind=1` URL param (incl. PR-URL reparse). - UI: `useColorblind` hook persists the setting and toggles a `data-colorblind` attribute; CSS provides light and dark overrides. - Options menu gains a runtime "Colorblind colors" toggle. - README documents the new flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + packages/cli/src/commands/tree.ts | 7 ++++ packages/cli/src/index.ts | 9 +++++ packages/ui/src/components/diff/diff-page.tsx | 8 +++-- .../ui/src/components/layout/options-menu.tsx | 17 ++++++++- packages/ui/src/components/layout/toolbar.tsx | 6 ++++ packages/ui/src/components/tree/tree-page.tsx | 7 ++-- packages/ui/src/hooks/use-theme.ts | 35 +++++++++++++++++++ packages/ui/src/routes/diff.tsx | 3 +- packages/ui/src/routes/tree.tsx | 3 +- packages/ui/src/styles/app.css | 35 +++++++++++++++++++ 11 files changed, 123 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 88dd760..9aa99cf 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,7 @@ diffity list --json # machine-readable output --port Custom port (default: auto-assigned from 5391) --no-open Don't open browser --dark Dark mode +--colorblind Colorblind-safe diff palette (blue/orange) --unified Unified view (default: split) --quiet Minimal terminal output --new Stop existing instance and start fresh diff --git a/packages/cli/src/commands/tree.ts b/packages/cli/src/commands/tree.ts index 626b490..be72b93 100644 --- a/packages/cli/src/commands/tree.ts +++ b/packages/cli/src/commands/tree.ts @@ -13,6 +13,7 @@ export function registerTreeCommand(program: Command, version: string) { .option('--port ', 'Port to use') .option('--no-open', 'Do not open browser automatically') .option('--dark', 'Open in dark mode') + .option('--colorblind', 'Use a colorblind-safe diff palette (blue/orange)') .option('--quiet', 'Minimal terminal output') .option('--new', 'Stop existing instance and start fresh') .action(async (opts) => { @@ -41,6 +42,9 @@ export function registerTreeCommand(program: Command, version: string) { if (opts.dark) { urlParams.set('theme', 'dark'); } + if (opts.colorblind) { + urlParams.set('colorblind', '1'); + } const qs = urlParams.toString(); const url = `http://${getHost()}:${existing.port}/tree${qs ? `?${qs}` : ''}`; @@ -78,6 +82,9 @@ export function registerTreeCommand(program: Command, version: string) { if (opts.dark) { urlParams.set('theme', 'dark'); } + if (opts.colorblind) { + urlParams.set('colorblind', '1'); + } const qs = urlParams.toString(); const url = `http://${getHost()}:${actualPort}/tree${qs ? `?${qs}` : ''}`; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f0e7338..1bdcac7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -44,6 +44,7 @@ program .option('--no-open', 'Do not open browser automatically') .option('--quiet', 'Minimal terminal output') .option('--dark', 'Open in dark mode (default: light)') + .option('--colorblind', 'Use a colorblind-safe diff palette (blue/orange)') .option('--unified', 'Open in unified view (default: split)') .option('--new', 'Stop existing instance and start fresh') .addHelpText('after', ` @@ -58,6 +59,7 @@ Common usage: $ diffity unstaged Only unstaged changes $ diffity https://github.com/owner/repo/pull/123 Review a GitHub PR $ diffity --dark --unified Dark mode, unified view + $ diffity --colorblind Colorblind-safe diff palette $ diffity --new Force restart existing instance Other commands: @@ -91,6 +93,7 @@ range syntax (main..feature, main...feature) also work.`) case '--no-open': opts.open = false; break; case '--quiet': opts.quiet = true; break; case '--dark': opts.dark = true; break; + case '--colorblind': opts.colorblind = true; break; case '--unified': opts.unified = true; break; case '--new': opts.new = true; break; default: @@ -248,6 +251,9 @@ range syntax (main..feature, main...feature) also work.`) if (opts.dark) { urlParams.set('theme', 'dark'); } + if (opts.colorblind) { + urlParams.set('colorblind', '1'); + } if (opts.unified) { urlParams.set('view', 'unified'); } @@ -286,6 +292,9 @@ range syntax (main..feature, main...feature) also work.`) if (opts.dark) { urlParams.set('theme', 'dark'); } + if (opts.colorblind) { + urlParams.set('colorblind', '1'); + } if (opts.unified) { urlParams.set('view', 'unified'); } diff --git a/packages/ui/src/components/diff/diff-page.tsx b/packages/ui/src/components/diff/diff-page.tsx index 9027ff9..8d083ca 100644 --- a/packages/ui/src/components/diff/diff-page.tsx +++ b/packages/ui/src/components/diff/diff-page.tsx @@ -3,7 +3,7 @@ import { useLoaderData } from 'react-router'; import { useQueryClient } from '@tanstack/react-query'; import { useDiff } from '../../hooks/use-diff'; import { useInfo } from '../../hooks/use-info'; -import { useTheme } from '../../hooks/use-theme'; +import { useTheme, useColorblind } from '../../hooks/use-theme'; import { useKeyboard } from '../../hooks/use-keyboard'; import { useReviewThreads } from '../../hooks/use-review-threads'; import { useCommentActions } from '../../hooks/use-comment-actions'; @@ -23,16 +23,18 @@ import type { LineSelection } from '../comments/types'; import { isThreadResolved } from '../comments/types'; export function DiffPage() { - const { ref: refParam, theme: initialTheme, view: initialViewMode } = useLoaderData<{ + const { ref: refParam, theme: initialTheme, view: initialViewMode, colorblind: initialColorblind } = useLoaderData<{ ref: string; theme: 'light' | 'dark' | null; view: 'split' | 'unified' | null; + colorblind: boolean; }>(); const [viewMode, setViewMode] = useState(initialViewMode || 'split'); const [hideWhitespace, setHideWhitespace] = useState(false); const [showHelp, setShowHelp] = useState(false); const { theme, toggleTheme } = useTheme(initialTheme); + const { colorblind, toggleColorblind } = useColorblind(initialColorblind); const { data: diff, error } = useDiff(hideWhitespace, refParam); const { data: info } = useInfo(refParam); const [activeFile, setActiveFile] = useState(null); @@ -334,6 +336,8 @@ export function DiffPage() { onHideWhitespaceChange={setHideWhitespace} theme={theme} onToggleTheme={toggleTheme} + colorblind={colorblind} + onToggleColorblind={toggleColorblind} onShowHelp={() => setShowHelp(true)} diff={diff || undefined} diffRef={refParam} diff --git a/packages/ui/src/components/layout/options-menu.tsx b/packages/ui/src/components/layout/options-menu.tsx index c113496..b9bbf47 100644 --- a/packages/ui/src/components/layout/options-menu.tsx +++ b/packages/ui/src/components/layout/options-menu.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, type ReactNode } from 'react'; import { SunIcon } from '../icons/sun-icon'; import { MoonIcon } from '../icons/moon-icon'; import { EllipsisIcon } from '../icons/ellipsis-icon'; +import { EyeIcon } from '../icons/eye-icon'; import { GitHubIcon } from '../icons/github-icon'; export const menuItemClass = 'flex items-center gap-2.5 w-full px-3 py-1.5 text-xs text-text-secondary hover:bg-hover hover:text-text transition-colors cursor-pointer text-left'; @@ -9,11 +10,13 @@ export const menuItemClass = 'flex items-center gap-2.5 w-full px-3 py-1.5 text- interface OptionsMenuProps { theme: 'light' | 'dark'; onToggleTheme: () => void; + colorblind?: boolean; + onToggleColorblind?: () => void; renderExtraItems?: (close: () => void) => ReactNode; } export function OptionsMenu(props: OptionsMenuProps) { - const { theme, onToggleTheme, renderExtraItems } = props; + const { theme, onToggleTheme, colorblind, onToggleColorblind, renderExtraItems } = props; const [showMenu, setShowMenu] = useState(false); const menuRef = useRef(null); @@ -54,6 +57,18 @@ export function OptionsMenu(props: OptionsMenuProps) { {theme === 'light' ? : } {theme === 'light' ? 'Dark mode' : 'Light mode'} + {onToggleColorblind && ( + + )}
void; theme: 'light' | 'dark'; onToggleTheme: () => void; + colorblind?: boolean; + onToggleColorblind?: () => void; onShowHelp: () => void; diff?: ParsedDiff; diffRef?: string; @@ -119,6 +121,8 @@ export function Toolbar(props: ToolbarProps) { onHideWhitespaceChange, theme, onToggleTheme, + colorblind, + onToggleColorblind, onShowHelp, diff, diffRef, @@ -183,6 +187,8 @@ export function Toolbar(props: ToolbarProps) { ( <>
diff --git a/packages/ui/src/hooks/use-theme.ts b/packages/ui/src/hooks/use-theme.ts index 1c859fb..5523967 100644 --- a/packages/ui/src/hooks/use-theme.ts +++ b/packages/ui/src/hooks/use-theme.ts @@ -32,3 +32,38 @@ export function useTheme(initialTheme?: Theme | null) { return { theme, toggleTheme }; } + +function getStoredColorblind(): boolean | null { + if (typeof window === 'undefined') { + return null; + } + const stored = localStorage.getItem('diffity-colorblind'); + return stored === null ? null : stored === 'true'; +} + +// Colorblind mode is an orthogonal axis to light/dark — it swaps the diff +// palette to a colorblind-safe blue/orange scheme via the `data-colorblind` +// attribute (see styles/app.css). +export function useColorblind(initialColorblind?: boolean | null) { + const [colorblind, setColorblind] = useState( + () => getStoredColorblind() ?? initialColorblind ?? false + ); + + useLayoutEffect(() => { + if (colorblind) { + document.documentElement.setAttribute('data-colorblind', 'true'); + } else { + document.documentElement.removeAttribute('data-colorblind'); + } + }, [colorblind]); + + const toggleColorblind = useCallback(() => { + setColorblind(prev => { + const next = !prev; + localStorage.setItem('diffity-colorblind', String(next)); + return next; + }); + }, []); + + return { colorblind, toggleColorblind }; +} diff --git a/packages/ui/src/routes/diff.tsx b/packages/ui/src/routes/diff.tsx index 0ca66fc..dcd160e 100644 --- a/packages/ui/src/routes/diff.tsx +++ b/packages/ui/src/routes/diff.tsx @@ -11,13 +11,14 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { const ref = url.searchParams.get("ref") || "work"; const theme = url.searchParams.get("theme") as "light" | "dark" | null; const view = url.searchParams.get("view") as "split" | "unified" | null; + const colorblind = url.searchParams.get("colorblind") === "1"; await Promise.all([ queryClient.ensureQueryData(diffOptions(false, ref)), queryClient.ensureQueryData(repoInfoOptions(ref)), ]); - return { ref, theme, view }; + return { ref, theme, view, colorblind }; } export default function DiffRoute({ loaderData }: Route.ComponentProps) { diff --git a/packages/ui/src/routes/tree.tsx b/packages/ui/src/routes/tree.tsx index 99253fc..1aa16f6 100644 --- a/packages/ui/src/routes/tree.tsx +++ b/packages/ui/src/routes/tree.tsx @@ -10,6 +10,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { const path = url.searchParams.get("path") || ""; const type = (url.searchParams.get("type") || "dir") as "file" | "dir"; const theme = url.searchParams.get("theme") as "light" | "dark" | null; + const colorblind = url.searchParams.get("colorblind") === "1"; const fetches: Promise[] = [ queryClient.ensureQueryData(treePathsOptions()), @@ -24,7 +25,7 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { await Promise.all(fetches); - return { path, type, theme }; + return { path, type, theme, colorblind }; } export default function TreeRoute() { diff --git a/packages/ui/src/styles/app.css b/packages/ui/src/styles/app.css index 7edc322..a4f16f5 100644 --- a/packages/ui/src/styles/app.css +++ b/packages/ui/src/styles/app.css @@ -83,6 +83,41 @@ --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.5); } +/* + * Colorblind-safe palette. Remaps the red/green diff colors to a + * blue (additions) / orange (deletions) scheme that stays distinguishable + * for deuteranopia and protanopia. Applied as an orthogonal axis, so it + * combines with both light and dark themes. The hunk header is shifted to a + * neutral gray so it does not collide with the blue used for additions. + */ +[data-colorblind='true'] { + --color-diff-add-bg: #ddf4ff; + --color-diff-add-line: #b6e3ff; + --color-diff-add-word: #80ccff; + --color-diff-del-bg: #fff1e5; + --color-diff-del-line: #ffd8b5; + --color-diff-del-word: #ffb77c66; + --color-diff-hunk-bg: #eaeef2; + --color-diff-hunk-text: #57606a; + + --color-added: #0969da; + --color-deleted: #bc4c00; +} + +[data-colorblind='true'][data-theme='dark'] { + --color-diff-add-bg: rgba(96, 165, 250, 0.1); + --color-diff-add-line: rgba(96, 165, 250, 0.18); + --color-diff-add-word: rgba(96, 165, 250, 0.35); + --color-diff-del-bg: rgba(236, 142, 44, 0.1); + --color-diff-del-line: rgba(236, 142, 44, 0.18); + --color-diff-del-word: rgba(236, 142, 44, 0.35); + --color-diff-hunk-bg: rgba(163, 163, 163, 0.1); + --color-diff-hunk-text: #a3a3a3; + + --color-added: #60a5fa; + --color-deleted: #ec8e2c; +} + * { scrollbar-width: thin; scrollbar-color: var(--color-border) var(--color-bg-secondary);