mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 00:21:21 +00:00
MAESTRO: Add colorblind-safe extension badge colors for file tabs
Use Wong's colorblind-safe palette for file extension badges when colorBlindMode is enabled. Colors are distinguishable across protanopia, deuteranopia, and tritanopia color vision deficiencies. - Add COLORBLIND_EXTENSION_PALETTE to colorblindPalettes.ts - Update getExtensionColor() in TabBar and TabSwitcherModal - Pass colorBlindMode through App → MainPanel → TabBar - Add 11 unit tests for colorblind extension badge colors
This commit is contained in:
@@ -4379,3 +4379,416 @@ describe('Extension badge styling across themes', () => {
|
||||
expect(fileNameSpan).toHaveClass('max-w-[120px]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('File tab extension badge colorblind mode', () => {
|
||||
const mockOnTabSelect = vi.fn();
|
||||
const mockOnTabClose = vi.fn();
|
||||
const mockOnNewTab = vi.fn();
|
||||
const mockOnFileTabSelect = vi.fn();
|
||||
const mockOnFileTabClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Light theme for testing contrast
|
||||
const lightTheme: Theme = {
|
||||
id: 'github-light',
|
||||
name: 'GitHub Light',
|
||||
mode: 'light',
|
||||
colors: {
|
||||
bgMain: '#ffffff',
|
||||
bgSidebar: '#f6f8fa',
|
||||
bgActivity: '#eff2f5',
|
||||
textMain: '#24292f',
|
||||
textDim: '#57606a',
|
||||
accent: '#0969da',
|
||||
border: '#d0d7de',
|
||||
error: '#cf222e',
|
||||
success: '#1a7f37',
|
||||
warning: '#9a6700',
|
||||
},
|
||||
};
|
||||
|
||||
// Dark theme for comparison
|
||||
const darkTheme: Theme = {
|
||||
id: 'dracula',
|
||||
name: 'Dracula',
|
||||
mode: 'dark',
|
||||
colors: {
|
||||
bgMain: '#282a36',
|
||||
bgSidebar: '#21222c',
|
||||
bgActivity: '#343746',
|
||||
textMain: '#f8f8f2',
|
||||
textDim: '#6272a4',
|
||||
accent: '#bd93f9',
|
||||
border: '#44475a',
|
||||
error: '#ff5555',
|
||||
success: '#50fa7b',
|
||||
warning: '#ffb86c',
|
||||
},
|
||||
};
|
||||
|
||||
const createTab = (overrides: Partial<AITab> = {}): AITab => ({
|
||||
id: 'test-tab',
|
||||
name: '',
|
||||
agentSessionId: 'abc12345-def6-7890',
|
||||
logs: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createFileTab = (extension: string): FilePreviewTab => ({
|
||||
id: `file-tab-${extension}`,
|
||||
path: `/test/example${extension}`,
|
||||
name: 'example',
|
||||
extension: extension,
|
||||
content: 'test content',
|
||||
scrollTop: 0,
|
||||
searchQuery: '',
|
||||
editMode: false,
|
||||
editContent: undefined,
|
||||
createdAt: Date.now(),
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
|
||||
it('renders colorblind-safe colors for TypeScript files in dark mode', () => {
|
||||
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
||||
const fileTab = createFileTab('.ts');
|
||||
|
||||
const unifiedTabs = [
|
||||
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
||||
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
||||
];
|
||||
|
||||
render(
|
||||
<TabBar
|
||||
tabs={[aiTab]}
|
||||
activeTabId="ai-tab-1"
|
||||
theme={darkTheme}
|
||||
onTabSelect={mockOnTabSelect}
|
||||
onTabClose={mockOnTabClose}
|
||||
onNewTab={mockOnNewTab}
|
||||
unifiedTabs={unifiedTabs}
|
||||
activeFileTabId={null}
|
||||
onFileTabSelect={mockOnFileTabSelect}
|
||||
onFileTabClose={mockOnFileTabClose}
|
||||
colorBlindMode={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = screen.getByText('.ts');
|
||||
expect(badge).toBeInTheDocument();
|
||||
// Strong Blue (#0077BB) from Wong's colorblind-safe palette
|
||||
expect(badge).toHaveStyle({ backgroundColor: 'rgba(0, 119, 187, 0.35)' });
|
||||
});
|
||||
|
||||
it('renders colorblind-safe colors for TypeScript files in light mode', () => {
|
||||
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
||||
const fileTab = createFileTab('.tsx');
|
||||
|
||||
const unifiedTabs = [
|
||||
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
||||
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
||||
];
|
||||
|
||||
render(
|
||||
<TabBar
|
||||
tabs={[aiTab]}
|
||||
activeTabId="ai-tab-1"
|
||||
theme={lightTheme}
|
||||
onTabSelect={mockOnTabSelect}
|
||||
onTabClose={mockOnTabClose}
|
||||
onNewTab={mockOnNewTab}
|
||||
unifiedTabs={unifiedTabs}
|
||||
activeFileTabId={null}
|
||||
onFileTabSelect={mockOnFileTabSelect}
|
||||
onFileTabClose={mockOnFileTabClose}
|
||||
colorBlindMode={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = screen.getByText('.tsx');
|
||||
expect(badge).toBeInTheDocument();
|
||||
// Strong Blue (#0077BB) lighter for light theme
|
||||
expect(badge).toHaveStyle({ backgroundColor: 'rgba(0, 119, 187, 0.18)' });
|
||||
});
|
||||
|
||||
it('renders colorblind-safe colors for Markdown files (teal)', () => {
|
||||
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
||||
const fileTab = createFileTab('.md');
|
||||
|
||||
const unifiedTabs = [
|
||||
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
||||
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
||||
];
|
||||
|
||||
render(
|
||||
<TabBar
|
||||
tabs={[aiTab]}
|
||||
activeTabId="ai-tab-1"
|
||||
theme={darkTheme}
|
||||
onTabSelect={mockOnTabSelect}
|
||||
onTabClose={mockOnTabClose}
|
||||
onNewTab={mockOnNewTab}
|
||||
unifiedTabs={unifiedTabs}
|
||||
activeFileTabId={null}
|
||||
onFileTabSelect={mockOnFileTabSelect}
|
||||
onFileTabClose={mockOnFileTabClose}
|
||||
colorBlindMode={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = screen.getByText('.md');
|
||||
expect(badge).toBeInTheDocument();
|
||||
// Teal (#009988) from Wong's colorblind-safe palette
|
||||
expect(badge).toHaveStyle({ backgroundColor: 'rgba(0, 153, 136, 0.35)' });
|
||||
});
|
||||
|
||||
it('renders colorblind-safe colors for JSON/Config files (orange)', () => {
|
||||
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
||||
const fileTab = createFileTab('.json');
|
||||
|
||||
const unifiedTabs = [
|
||||
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
||||
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
||||
];
|
||||
|
||||
render(
|
||||
<TabBar
|
||||
tabs={[aiTab]}
|
||||
activeTabId="ai-tab-1"
|
||||
theme={darkTheme}
|
||||
onTabSelect={mockOnTabSelect}
|
||||
onTabClose={mockOnTabClose}
|
||||
onNewTab={mockOnNewTab}
|
||||
unifiedTabs={unifiedTabs}
|
||||
activeFileTabId={null}
|
||||
onFileTabSelect={mockOnFileTabSelect}
|
||||
onFileTabClose={mockOnFileTabClose}
|
||||
colorBlindMode={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = screen.getByText('.json');
|
||||
expect(badge).toBeInTheDocument();
|
||||
// Orange (#EE7733) from Wong's colorblind-safe palette
|
||||
expect(badge).toHaveStyle({ backgroundColor: 'rgba(238, 119, 51, 0.35)' });
|
||||
});
|
||||
|
||||
it('renders colorblind-safe colors for CSS files (purple)', () => {
|
||||
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
||||
const fileTab = createFileTab('.css');
|
||||
|
||||
const unifiedTabs = [
|
||||
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
||||
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
||||
];
|
||||
|
||||
render(
|
||||
<TabBar
|
||||
tabs={[aiTab]}
|
||||
activeTabId="ai-tab-1"
|
||||
theme={darkTheme}
|
||||
onTabSelect={mockOnTabSelect}
|
||||
onTabClose={mockOnTabClose}
|
||||
onNewTab={mockOnNewTab}
|
||||
unifiedTabs={unifiedTabs}
|
||||
activeFileTabId={null}
|
||||
onFileTabSelect={mockOnFileTabSelect}
|
||||
onFileTabClose={mockOnFileTabClose}
|
||||
colorBlindMode={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = screen.getByText('.css');
|
||||
expect(badge).toBeInTheDocument();
|
||||
// Purple (#AA4499) from Wong's colorblind-safe palette
|
||||
expect(badge).toHaveStyle({ backgroundColor: 'rgba(170, 68, 153, 0.35)' });
|
||||
});
|
||||
|
||||
it('renders colorblind-safe colors for HTML files (vermillion)', () => {
|
||||
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
||||
const fileTab = createFileTab('.html');
|
||||
|
||||
const unifiedTabs = [
|
||||
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
||||
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
||||
];
|
||||
|
||||
render(
|
||||
<TabBar
|
||||
tabs={[aiTab]}
|
||||
activeTabId="ai-tab-1"
|
||||
theme={darkTheme}
|
||||
onTabSelect={mockOnTabSelect}
|
||||
onTabClose={mockOnTabClose}
|
||||
onNewTab={mockOnNewTab}
|
||||
unifiedTabs={unifiedTabs}
|
||||
activeFileTabId={null}
|
||||
onFileTabSelect={mockOnFileTabSelect}
|
||||
onFileTabClose={mockOnFileTabClose}
|
||||
colorBlindMode={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = screen.getByText('.html');
|
||||
expect(badge).toBeInTheDocument();
|
||||
// Vermillion (#CC3311) from Wong's colorblind-safe palette
|
||||
expect(badge).toHaveStyle({ backgroundColor: 'rgba(204, 51, 17, 0.35)' });
|
||||
});
|
||||
|
||||
it('renders colorblind-safe colors for Python files (cyan)', () => {
|
||||
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
||||
const fileTab = createFileTab('.py');
|
||||
|
||||
const unifiedTabs = [
|
||||
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
||||
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
||||
];
|
||||
|
||||
render(
|
||||
<TabBar
|
||||
tabs={[aiTab]}
|
||||
activeTabId="ai-tab-1"
|
||||
theme={darkTheme}
|
||||
onTabSelect={mockOnTabSelect}
|
||||
onTabClose={mockOnTabClose}
|
||||
onNewTab={mockOnNewTab}
|
||||
unifiedTabs={unifiedTabs}
|
||||
activeFileTabId={null}
|
||||
onFileTabSelect={mockOnFileTabSelect}
|
||||
onFileTabClose={mockOnFileTabClose}
|
||||
colorBlindMode={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = screen.getByText('.py');
|
||||
expect(badge).toBeInTheDocument();
|
||||
// Cyan (#33BBEE) from Wong's colorblind-safe palette
|
||||
expect(badge).toHaveStyle({ backgroundColor: 'rgba(51, 187, 238, 0.35)' });
|
||||
});
|
||||
|
||||
it('renders colorblind-safe colors for Rust files (magenta)', () => {
|
||||
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
||||
const fileTab = createFileTab('.rs');
|
||||
|
||||
const unifiedTabs = [
|
||||
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
||||
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
||||
];
|
||||
|
||||
render(
|
||||
<TabBar
|
||||
tabs={[aiTab]}
|
||||
activeTabId="ai-tab-1"
|
||||
theme={darkTheme}
|
||||
onTabSelect={mockOnTabSelect}
|
||||
onTabClose={mockOnTabClose}
|
||||
onNewTab={mockOnNewTab}
|
||||
unifiedTabs={unifiedTabs}
|
||||
activeFileTabId={null}
|
||||
onFileTabSelect={mockOnFileTabSelect}
|
||||
onFileTabClose={mockOnFileTabClose}
|
||||
colorBlindMode={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = screen.getByText('.rs');
|
||||
expect(badge).toBeInTheDocument();
|
||||
// Magenta (#EE3377) from Wong's colorblind-safe palette
|
||||
expect(badge).toHaveStyle({ backgroundColor: 'rgba(238, 51, 119, 0.35)' });
|
||||
});
|
||||
|
||||
it('renders colorblind-safe colors for Go files (blue-green)', () => {
|
||||
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
||||
const fileTab = createFileTab('.go');
|
||||
|
||||
const unifiedTabs = [
|
||||
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
||||
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
||||
];
|
||||
|
||||
render(
|
||||
<TabBar
|
||||
tabs={[aiTab]}
|
||||
activeTabId="ai-tab-1"
|
||||
theme={darkTheme}
|
||||
onTabSelect={mockOnTabSelect}
|
||||
onTabClose={mockOnTabClose}
|
||||
onNewTab={mockOnNewTab}
|
||||
unifiedTabs={unifiedTabs}
|
||||
activeFileTabId={null}
|
||||
onFileTabSelect={mockOnFileTabSelect}
|
||||
onFileTabClose={mockOnFileTabClose}
|
||||
colorBlindMode={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = screen.getByText('.go');
|
||||
expect(badge).toBeInTheDocument();
|
||||
// Blue-Green (#44AA99) from Wong's colorblind-safe palette
|
||||
expect(badge).toHaveStyle({ backgroundColor: 'rgba(68, 170, 153, 0.35)' });
|
||||
});
|
||||
|
||||
it('renders colorblind-safe colors for Shell scripts (gray)', () => {
|
||||
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
||||
const fileTab = createFileTab('.sh');
|
||||
|
||||
const unifiedTabs = [
|
||||
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
||||
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
||||
];
|
||||
|
||||
render(
|
||||
<TabBar
|
||||
tabs={[aiTab]}
|
||||
activeTabId="ai-tab-1"
|
||||
theme={darkTheme}
|
||||
onTabSelect={mockOnTabSelect}
|
||||
onTabClose={mockOnTabClose}
|
||||
onNewTab={mockOnNewTab}
|
||||
unifiedTabs={unifiedTabs}
|
||||
activeFileTabId={null}
|
||||
onFileTabSelect={mockOnFileTabSelect}
|
||||
onFileTabClose={mockOnFileTabClose}
|
||||
colorBlindMode={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = screen.getByText('.sh');
|
||||
expect(badge).toBeInTheDocument();
|
||||
// Gray for shell scripts (distinguishable by luminance)
|
||||
expect(badge).toHaveStyle({ backgroundColor: 'rgba(150, 150, 150, 0.35)' });
|
||||
});
|
||||
|
||||
it('falls back to theme colors for unknown extensions in colorblind mode', () => {
|
||||
const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' });
|
||||
const fileTab = createFileTab('.xyz');
|
||||
|
||||
const unifiedTabs = [
|
||||
{ type: 'ai' as const, id: 'ai-tab-1', data: aiTab },
|
||||
{ type: 'file' as const, id: fileTab.id, data: fileTab },
|
||||
];
|
||||
|
||||
render(
|
||||
<TabBar
|
||||
tabs={[aiTab]}
|
||||
activeTabId="ai-tab-1"
|
||||
theme={darkTheme}
|
||||
onTabSelect={mockOnTabSelect}
|
||||
onTabClose={mockOnTabClose}
|
||||
onNewTab={mockOnNewTab}
|
||||
unifiedTabs={unifiedTabs}
|
||||
activeFileTabId={null}
|
||||
onFileTabSelect={mockOnFileTabSelect}
|
||||
onFileTabClose={mockOnFileTabClose}
|
||||
colorBlindMode={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = screen.getByText('.xyz');
|
||||
expect(badge).toBeInTheDocument();
|
||||
// Falls back to theme border color for unknown extensions
|
||||
expect(badge).toHaveStyle({ backgroundColor: darkTheme.colors.border });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13068,6 +13068,9 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
// Unread filter
|
||||
showUnreadOnly,
|
||||
|
||||
// Accessibility
|
||||
colorBlindMode,
|
||||
|
||||
// Setters
|
||||
setLogViewerSelectedLevels,
|
||||
setGitDiffPreview,
|
||||
|
||||
@@ -874,6 +874,8 @@ export interface AppUtilityModalsProps {
|
||||
sessionName: string,
|
||||
starred?: boolean
|
||||
) => void;
|
||||
/** Whether colorblind-friendly colors should be used for extension badges */
|
||||
colorBlindMode?: boolean;
|
||||
|
||||
// FileSearchModal
|
||||
fuzzyFileSearchOpen: boolean;
|
||||
@@ -1051,6 +1053,7 @@ export function AppUtilityModals({
|
||||
onTabSelect,
|
||||
onFileTabSelect,
|
||||
onNamedSessionSelect,
|
||||
colorBlindMode,
|
||||
// FileSearchModal
|
||||
fuzzyFileSearchOpen,
|
||||
filteredFileTree,
|
||||
@@ -1255,6 +1258,7 @@ export function AppUtilityModals({
|
||||
onFileTabSelect={onFileTabSelect}
|
||||
onNamedSessionSelect={onNamedSessionSelect}
|
||||
onClose={onCloseTabSwitcher}
|
||||
colorBlindMode={colorBlindMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2551,6 +2555,7 @@ export function AppModals(props: AppModalsProps) {
|
||||
onCloseTabSwitcher={onCloseTabSwitcher}
|
||||
onTabSelect={onTabSelect}
|
||||
onNamedSessionSelect={onNamedSessionSelect}
|
||||
colorBlindMode={colorBlindMode}
|
||||
fuzzyFileSearchOpen={fuzzyFileSearchOpen}
|
||||
filteredFileTree={filteredFileTree}
|
||||
onCloseFileSearch={onCloseFileSearch}
|
||||
|
||||
@@ -197,6 +197,8 @@ interface MainPanelProps {
|
||||
onToggleTabSaveToHistory?: () => void;
|
||||
onToggleTabShowThinking?: () => void;
|
||||
showUnreadOnly?: boolean;
|
||||
/** Whether colorblind-friendly colors should be used for extension badges */
|
||||
colorBlindMode?: boolean;
|
||||
onToggleUnreadFilter?: () => void;
|
||||
onOpenTabSearch?: () => void;
|
||||
// Bulk tab close operations
|
||||
@@ -471,6 +473,7 @@ export const MainPanel = React.memo(
|
||||
onTabStar,
|
||||
onTabMarkUnread,
|
||||
showUnreadOnly,
|
||||
colorBlindMode,
|
||||
onToggleUnreadFilter,
|
||||
onOpenTabSearch,
|
||||
onCloseAllTabs,
|
||||
@@ -1471,6 +1474,8 @@ export const MainPanel = React.memo(
|
||||
activeFileTabId={activeFileTabId}
|
||||
onFileTabSelect={onFileTabSelect}
|
||||
onFileTabClose={onFileTabClose}
|
||||
// Accessibility
|
||||
colorBlindMode={colorBlindMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { AITab, Theme, FilePreviewTab, UnifiedTab } from '../types';
|
||||
import { hasDraft } from '../utils/tabHelpers';
|
||||
import { getColorBlindExtensionColor } from '../constants/colorblindPalettes';
|
||||
|
||||
interface TabBarProps {
|
||||
tabs: AITab[];
|
||||
@@ -73,6 +74,10 @@ interface TabBarProps {
|
||||
onFileTabSelect?: (tabId: string) => void;
|
||||
/** Handler to close a file preview tab */
|
||||
onFileTabClose?: (tabId: string) => void;
|
||||
|
||||
// === Accessibility ===
|
||||
/** Whether colorblind-friendly colors should be used for extension badges */
|
||||
colorBlindMode?: boolean;
|
||||
}
|
||||
|
||||
interface TabProps {
|
||||
@@ -946,17 +951,36 @@ interface FileTabProps {
|
||||
totalTabs?: number;
|
||||
/** Tab index in the full unified list (0-based) */
|
||||
tabIndex?: number;
|
||||
/** Whether colorblind-friendly colors should be used for extension badges */
|
||||
colorBlindMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for file extension badge.
|
||||
* Returns a muted color based on file type for visual differentiation.
|
||||
* Colors are adapted for both light and dark themes for good contrast.
|
||||
* When colorBlindMode is enabled, uses Wong's colorblind-safe palette.
|
||||
*/
|
||||
function getExtensionColor(extension: string, theme: Theme): { bg: string; text: string } {
|
||||
const ext = extension.toLowerCase();
|
||||
function getExtensionColor(
|
||||
extension: string,
|
||||
theme: Theme,
|
||||
colorBlindMode?: boolean
|
||||
): { bg: string; text: string } {
|
||||
const isLightTheme = theme.mode === 'light';
|
||||
|
||||
// Use colorblind-safe colors when enabled
|
||||
if (colorBlindMode) {
|
||||
const colorBlindColors = getColorBlindExtensionColor(extension, isLightTheme);
|
||||
if (colorBlindColors) {
|
||||
return colorBlindColors;
|
||||
}
|
||||
// Fall through to default for unknown extensions
|
||||
return { bg: theme.colors.border, text: theme.colors.textDim };
|
||||
}
|
||||
|
||||
// Standard color scheme
|
||||
const ext = extension.toLowerCase();
|
||||
|
||||
// TypeScript/JavaScript - blue tones
|
||||
if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) {
|
||||
return isLightTheme
|
||||
@@ -1047,6 +1071,7 @@ const FileTab = memo(function FileTab({
|
||||
onCloseTabsRight,
|
||||
totalTabs,
|
||||
tabIndex,
|
||||
colorBlindMode,
|
||||
}: FileTabProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
@@ -1243,8 +1268,8 @@ const FileTab = memo(function FileTab({
|
||||
|
||||
// Get extension badge colors
|
||||
const extensionColors = useMemo(
|
||||
() => getExtensionColor(tab.extension, theme),
|
||||
[tab.extension, theme]
|
||||
() => getExtensionColor(tab.extension, theme, colorBlindMode),
|
||||
[tab.extension, theme, colorBlindMode]
|
||||
);
|
||||
|
||||
// Hover background varies by theme mode for proper contrast
|
||||
@@ -1587,6 +1612,8 @@ function TabBarInner({
|
||||
onFileTabSelect,
|
||||
onFileTabClose,
|
||||
onUnifiedTabReorder,
|
||||
// Accessibility
|
||||
colorBlindMode,
|
||||
}: TabBarProps) {
|
||||
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
|
||||
const [dragOverTabId, setDragOverTabId] = useState<string | null>(null);
|
||||
@@ -2035,6 +2062,7 @@ function TabBarInner({
|
||||
onCloseTabsRight={onCloseTabsRight ? handleTabCloseRight : undefined}
|
||||
totalTabs={unifiedTabs!.length}
|
||||
tabIndex={originalIndex}
|
||||
colorBlindMode={colorBlindMode}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getContextColor } from '../utils/theme';
|
||||
import { formatShortcutKeys } from '../utils/shortcutFormatter';
|
||||
import { formatTokensCompact, formatRelativeTime, formatCost } from '../utils/formatters';
|
||||
import { calculateContextTokens } from '../utils/contextUsage';
|
||||
import { getColorBlindExtensionColor } from '../constants/colorblindPalettes';
|
||||
|
||||
/** Named session from the store (not currently open) */
|
||||
interface NamedSession {
|
||||
@@ -47,6 +48,8 @@ interface TabSwitcherModalProps {
|
||||
starred?: boolean
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
/** Whether colorblind-friendly colors should be used for extension badges */
|
||||
colorBlindMode?: boolean;
|
||||
}
|
||||
|
||||
// formatTokensCompact, formatRelativeTime, and formatCost imported from ../utils/formatters
|
||||
@@ -108,9 +111,27 @@ function getUuidPill(agentSessionId: string | undefined | null): string | null {
|
||||
/**
|
||||
* Get color for file extension badge.
|
||||
* Returns a muted color based on file type for visual differentiation.
|
||||
* (Copied from TabBar.tsx for consistency)
|
||||
* When colorBlindMode is enabled, uses Wong's colorblind-safe palette.
|
||||
* (Synchronized with TabBar.tsx for consistency)
|
||||
*/
|
||||
function getExtensionColor(extension: string, theme: Theme): { bg: string; text: string } {
|
||||
function getExtensionColor(
|
||||
extension: string,
|
||||
theme: Theme,
|
||||
colorBlindMode?: boolean
|
||||
): { bg: string; text: string } {
|
||||
const isLightTheme = theme.mode === 'light';
|
||||
|
||||
// Use colorblind-safe colors when enabled
|
||||
if (colorBlindMode) {
|
||||
const colorBlindColors = getColorBlindExtensionColor(extension, isLightTheme);
|
||||
if (colorBlindColors) {
|
||||
return colorBlindColors;
|
||||
}
|
||||
// Fall through to default for unknown extensions
|
||||
return { bg: theme.colors.border, text: theme.colors.textDim };
|
||||
}
|
||||
|
||||
// Standard color scheme (dark theme only for backward compatibility)
|
||||
const ext = extension.toLowerCase();
|
||||
// TypeScript/JavaScript - blue tones
|
||||
if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) {
|
||||
@@ -212,6 +233,7 @@ export function TabSwitcherModal({
|
||||
onFileTabSelect,
|
||||
onNamedSessionSelect,
|
||||
onClose,
|
||||
colorBlindMode,
|
||||
}: TabSwitcherModalProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [firstVisibleIndex, setFirstVisibleIndex] = useState(0);
|
||||
@@ -776,7 +798,7 @@ export function TabSwitcherModal({
|
||||
// File preview tab
|
||||
const { tab } = item;
|
||||
const isActive = tab.id === activeFileTabId;
|
||||
const extColors = getExtensionColor(tab.extension, theme);
|
||||
const extColors = getExtensionColor(tab.extension, theme, colorBlindMode);
|
||||
const hasUnsavedEdits = !!tab.editContent;
|
||||
|
||||
return (
|
||||
|
||||
@@ -114,3 +114,120 @@ export function getColorBlindPattern(index: number): ColorBlindPattern {
|
||||
];
|
||||
return patterns[index % patterns.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Colorblind-safe palette for file extension badges.
|
||||
* Uses Wong's palette with appropriate contrast for badge backgrounds and text.
|
||||
* Each extension category is mapped to a distinct, colorblind-safe color.
|
||||
*
|
||||
* Colors are chosen to be distinguishable in:
|
||||
* - Protanopia (red-green, red-weak)
|
||||
* - Deuteranopia (red-green, green-weak)
|
||||
* - Tritanopia (blue-yellow)
|
||||
*
|
||||
* Each color has a light mode and dark mode variant for proper contrast.
|
||||
*/
|
||||
export const COLORBLIND_EXTENSION_PALETTE = {
|
||||
// TypeScript/JavaScript - Strong Blue (#0077BB)
|
||||
typescript: {
|
||||
light: { bg: 'rgba(0, 119, 187, 0.18)', text: 'rgba(0, 90, 150, 0.95)' },
|
||||
dark: { bg: 'rgba(0, 119, 187, 0.35)', text: 'rgba(102, 178, 230, 0.95)' },
|
||||
},
|
||||
// Markdown/Docs - Teal (#009988)
|
||||
markdown: {
|
||||
light: { bg: 'rgba(0, 153, 136, 0.18)', text: 'rgba(0, 115, 100, 0.95)' },
|
||||
dark: { bg: 'rgba(0, 153, 136, 0.35)', text: 'rgba(77, 204, 189, 0.95)' },
|
||||
},
|
||||
// JSON/Config - Orange (#EE7733)
|
||||
config: {
|
||||
light: { bg: 'rgba(238, 119, 51, 0.18)', text: 'rgba(180, 85, 30, 0.95)' },
|
||||
dark: { bg: 'rgba(238, 119, 51, 0.35)', text: 'rgba(255, 170, 120, 0.95)' },
|
||||
},
|
||||
// CSS/Styles - Purple (#AA4499)
|
||||
styles: {
|
||||
light: { bg: 'rgba(170, 68, 153, 0.18)', text: 'rgba(130, 50, 115, 0.95)' },
|
||||
dark: { bg: 'rgba(170, 68, 153, 0.35)', text: 'rgba(210, 140, 195, 0.95)' },
|
||||
},
|
||||
// HTML/Templates - Vermillion (#CC3311)
|
||||
html: {
|
||||
light: { bg: 'rgba(204, 51, 17, 0.18)', text: 'rgba(160, 40, 15, 0.95)' },
|
||||
dark: { bg: 'rgba(204, 51, 17, 0.35)', text: 'rgba(255, 130, 100, 0.95)' },
|
||||
},
|
||||
// Python - Cyan (#33BBEE)
|
||||
python: {
|
||||
light: { bg: 'rgba(51, 187, 238, 0.18)', text: 'rgba(30, 130, 175, 0.95)' },
|
||||
dark: { bg: 'rgba(51, 187, 238, 0.35)', text: 'rgba(130, 210, 245, 0.95)' },
|
||||
},
|
||||
// Rust - Magenta (#EE3377)
|
||||
rust: {
|
||||
light: { bg: 'rgba(238, 51, 119, 0.18)', text: 'rgba(180, 35, 85, 0.95)' },
|
||||
dark: { bg: 'rgba(238, 51, 119, 0.35)', text: 'rgba(255, 140, 175, 0.95)' },
|
||||
},
|
||||
// Go - Blue-Green (#44AA99)
|
||||
go: {
|
||||
light: { bg: 'rgba(68, 170, 153, 0.18)', text: 'rgba(45, 130, 115, 0.95)' },
|
||||
dark: { bg: 'rgba(68, 170, 153, 0.35)', text: 'rgba(130, 210, 195, 0.95)' },
|
||||
},
|
||||
// Shell - Gray (#BBBBBB)
|
||||
shell: {
|
||||
light: { bg: 'rgba(120, 120, 120, 0.18)', text: 'rgba(80, 80, 80, 0.95)' },
|
||||
dark: { bg: 'rgba(150, 150, 150, 0.35)', text: 'rgba(200, 200, 200, 0.95)' },
|
||||
},
|
||||
// Default/Unknown - uses theme colors (handled in getExtensionColor)
|
||||
};
|
||||
|
||||
/**
|
||||
* Get colorblind-safe color for file extension badges.
|
||||
* Maps file extensions to colorblind-friendly colors from Wong's palette.
|
||||
*
|
||||
* @param extension - File extension including dot (e.g., '.ts', '.md')
|
||||
* @param isLightTheme - Whether the current theme is light mode
|
||||
* @returns Object with bg (background) and text color in rgba format
|
||||
*/
|
||||
export function getColorBlindExtensionColor(
|
||||
extension: string,
|
||||
isLightTheme: boolean
|
||||
): { bg: string; text: string } | null {
|
||||
const ext = extension.toLowerCase();
|
||||
const mode = isLightTheme ? 'light' : 'dark';
|
||||
|
||||
// TypeScript/JavaScript
|
||||
if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) {
|
||||
return COLORBLIND_EXTENSION_PALETTE.typescript[mode];
|
||||
}
|
||||
// Markdown/Docs
|
||||
if (['.md', '.mdx', '.txt', '.rst'].includes(ext)) {
|
||||
return COLORBLIND_EXTENSION_PALETTE.markdown[mode];
|
||||
}
|
||||
// JSON/Config
|
||||
if (['.json', '.yaml', '.yml', '.toml', '.ini', '.env'].includes(ext)) {
|
||||
return COLORBLIND_EXTENSION_PALETTE.config[mode];
|
||||
}
|
||||
// CSS/Styles
|
||||
if (['.css', '.scss', '.sass', '.less', '.styl'].includes(ext)) {
|
||||
return COLORBLIND_EXTENSION_PALETTE.styles[mode];
|
||||
}
|
||||
// HTML/Templates
|
||||
if (['.html', '.htm', '.xml', '.svg'].includes(ext)) {
|
||||
return COLORBLIND_EXTENSION_PALETTE.html[mode];
|
||||
}
|
||||
// Python
|
||||
if (['.py', '.pyw', '.pyi'].includes(ext)) {
|
||||
return COLORBLIND_EXTENSION_PALETTE.python[mode];
|
||||
}
|
||||
// Rust
|
||||
if (['.rs'].includes(ext)) {
|
||||
return COLORBLIND_EXTENSION_PALETTE.rust[mode];
|
||||
}
|
||||
// Go
|
||||
if (['.go'].includes(ext)) {
|
||||
return COLORBLIND_EXTENSION_PALETTE.go[mode];
|
||||
}
|
||||
// Shell scripts
|
||||
if (['.sh', '.bash', '.zsh', '.fish'].includes(ext)) {
|
||||
return COLORBLIND_EXTENSION_PALETTE.shell[mode];
|
||||
}
|
||||
|
||||
// Return null for unknown extensions (caller should use theme defaults)
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -133,6 +133,9 @@ export interface UseMainPanelPropsDeps {
|
||||
// Unread filter
|
||||
showUnreadOnly: boolean;
|
||||
|
||||
// Accessibility
|
||||
colorBlindMode: boolean;
|
||||
|
||||
// Setters (these are stable callbacks - should be memoized at definition site)
|
||||
setLogViewerSelectedLevels: (levels: string[]) => void;
|
||||
setGitDiffPreview: (preview: string | null) => void;
|
||||
@@ -403,6 +406,7 @@ export function useMainPanelProps(deps: UseMainPanelPropsDeps) {
|
||||
onTabMarkUnread: deps.handleTabMarkUnread,
|
||||
onToggleTabReadOnlyMode: deps.handleToggleTabReadOnlyMode,
|
||||
showUnreadOnly: deps.showUnreadOnly,
|
||||
colorBlindMode: deps.colorBlindMode,
|
||||
onToggleUnreadFilter: deps.toggleUnreadFilter,
|
||||
onOpenTabSearch: deps.handleOpenTabSearch,
|
||||
onCloseAllTabs: deps.handleCloseAllTabs,
|
||||
@@ -579,6 +583,7 @@ export function useMainPanelProps(deps: UseMainPanelPropsDeps) {
|
||||
deps.ghCliAvailable,
|
||||
deps.hasGist,
|
||||
deps.showUnreadOnly,
|
||||
deps.colorBlindMode,
|
||||
// Stable callbacks (shouldn't cause re-renders, but included for completeness)
|
||||
deps.setLogViewerSelectedLevels,
|
||||
deps.setGitDiffPreview,
|
||||
|
||||
Reference in New Issue
Block a user