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);