diff --git a/src/__tests__/renderer/components/TabBar.test.tsx b/src/__tests__/renderer/components/TabBar.test.tsx index f6cd3553..0da735b6 100644 --- a/src/__tests__/renderer/components/TabBar.test.tsx +++ b/src/__tests__/renderer/components/TabBar.test.tsx @@ -3994,3 +3994,388 @@ describe('File tab content and SSH support', () => { expect(fileTab.editMode).toBe(true); }); }); + +// Extension badge styling tests for visual polish across themes +describe('Extension badge styling across themes', () => { + const mockOnTabSelect = vi.fn(); + const mockOnTabClose = vi.fn(); + const mockOnNewTab = vi.fn(); + const mockOnFileTabSelect = vi.fn(); + const mockOnFileTabClose = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + Element.prototype.scrollTo = vi.fn(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // 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 createFileTab = (extension: string): FilePreviewTab => ({ + id: `file-tab-${extension}`, + path: `/test/file${extension}`, + name: 'file', + extension: extension, + content: 'test content', + scrollTop: 0, + searchQuery: '', + editMode: false, + editContent: undefined, + createdAt: Date.now(), + lastModified: Date.now(), + }); + + it('renders extension badges for TypeScript files with appropriate styling', () => { + 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( + + ); + + // Extension badge should be rendered + const badge = screen.getByText('.ts'); + expect(badge).toBeInTheDocument(); + // Badge should have blue-ish background for TypeScript + expect(badge).toHaveStyle({ backgroundColor: 'rgba(59, 130, 246, 0.3)' }); + }); + + it('renders extension badges for TypeScript files with light theme appropriate styling', () => { + 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( + + ); + + // Extension badge should be rendered with light theme colors + const badge = screen.getByText('.tsx'); + expect(badge).toBeInTheDocument(); + // Badge should have darker blue for better contrast on light backgrounds + expect(badge).toHaveStyle({ backgroundColor: 'rgba(37, 99, 235, 0.15)' }); + }); + + it('renders extension badges for Markdown files with dark theme styling', () => { + 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( + + ); + + const badge = screen.getByText('.md'); + expect(badge).toBeInTheDocument(); + // Green tones for Markdown/Docs + expect(badge).toHaveStyle({ backgroundColor: 'rgba(34, 197, 94, 0.3)' }); + }); + + it('renders extension badges for JSON files with dark theme styling', () => { + 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( + + ); + + const badge = screen.getByText('.json'); + expect(badge).toBeInTheDocument(); + // Yellow tones for JSON/Config + expect(badge).toHaveStyle({ backgroundColor: 'rgba(234, 179, 8, 0.3)' }); + }); + + it('renders extension badges for CSS files with dark theme styling', () => { + 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( + + ); + + const badge = screen.getByText('.css'); + expect(badge).toBeInTheDocument(); + // Purple tones for CSS/Styles + expect(badge).toHaveStyle({ backgroundColor: 'rgba(168, 85, 247, 0.3)' }); + }); + + it('renders extension badges for HTML files with dark theme styling', () => { + 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( + + ); + + const badge = screen.getByText('.html'); + expect(badge).toBeInTheDocument(); + // Orange tones for HTML/Templates + expect(badge).toHaveStyle({ backgroundColor: 'rgba(249, 115, 22, 0.3)' }); + }); + + it('renders extension badges for Python files with dark theme styling', () => { + 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( + + ); + + const badge = screen.getByText('.py'); + expect(badge).toBeInTheDocument(); + // Teal/cyan tones for Python + expect(badge).toHaveStyle({ backgroundColor: 'rgba(20, 184, 166, 0.3)' }); + }); + + it('renders extension badges for Rust files with dark theme styling', () => { + 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( + + ); + + const badge = screen.getByText('.rs'); + expect(badge).toBeInTheDocument(); + // Rust/red-orange tones for Rust + expect(badge).toHaveStyle({ backgroundColor: 'rgba(239, 68, 68, 0.3)' }); + }); + + it('renders extension badges for unknown files using theme border color', () => { + 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( + + ); + + const badge = screen.getByText('.xyz'); + expect(badge).toBeInTheDocument(); + // Uses theme border color for unknown extensions + expect(badge).toHaveStyle({ backgroundColor: darkTheme.colors.border }); + }); + + it('renders consistent tab name truncation for file tabs (max-w-[120px])', () => { + const aiTab = createTab({ id: 'ai-tab-1', name: 'AI Tab' }); + const fileTab: FilePreviewTab = { + id: 'file-tab-1', + path: '/test/very-long-filename-that-should-be-truncated.ts', + name: 'very-long-filename-that-should-be-truncated', + extension: '.ts', + content: 'test', + scrollTop: 0, + searchQuery: '', + editMode: false, + editContent: undefined, + createdAt: Date.now(), + lastModified: Date.now(), + }; + + const unifiedTabs = [ + { type: 'ai' as const, id: 'ai-tab-1', data: aiTab }, + { type: 'file' as const, id: fileTab.id, data: fileTab }, + ]; + + render( + + ); + + // File tab name span should have truncation class + const fileNameSpan = screen.getByText('very-long-filename-that-should-be-truncated'); + expect(fileNameSpan).toHaveClass('truncate'); + expect(fileNameSpan).toHaveClass('max-w-[120px]'); + }); +}); diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx index 74c4a2fb..479bb3cb 100644 --- a/src/renderer/components/TabBar.tsx +++ b/src/renderer/components/TabBar.tsx @@ -476,6 +476,9 @@ const Tab = memo(function Tab({ // Memoize display name to avoid recalculation on every render const displayName = useMemo(() => getTabDisplayName(tab), [tab.name, tab.agentSessionId]); + // Hover background varies by theme mode for proper contrast + const hoverBgColor = theme.mode === 'light' ? 'rgba(0, 0, 0, 0.06)' : 'rgba(255, 255, 255, 0.08)'; + // Memoize tab styles to avoid creating new object references on every render const tabStyle = useMemo( () => @@ -485,11 +488,7 @@ const Tab = memo(function Tab({ borderTopRightRadius: '6px', // Active tab: bright background matching content area // Inactive tabs: transparent with subtle hover - backgroundColor: isActive - ? theme.colors.bgMain - : isHovered - ? 'rgba(255, 255, 255, 0.08)' - : 'transparent', + backgroundColor: isActive ? theme.colors.bgMain : isHovered ? hoverBgColor : 'transparent', // Active tab has visible borders, inactive tabs have no borders (cleaner look) borderTop: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', borderLeft: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', @@ -502,7 +501,7 @@ const Tab = memo(function Tab({ zIndex: isActive ? 1 : 0, '--tw-ring-color': isDragOver ? theme.colors.accent : 'transparent', }) as React.CSSProperties, - [isActive, isHovered, isDragOver, theme.colors.bgMain, theme.colors.border, theme.colors.accent] + [isActive, isHovered, isDragOver, theme.colors.bgMain, theme.colors.border, theme.colors.accent, hoverBgColor] ); // Browser-style tab: all tabs have borders, active tab "connects" to content @@ -952,28 +951,65 @@ interface FileTabProps { /** * 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. */ function getExtensionColor(extension: string, theme: Theme): { bg: string; text: string } { const ext = extension.toLowerCase(); + const isLightTheme = theme.mode === 'light'; + // TypeScript/JavaScript - blue tones if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) { - return { bg: 'rgba(59, 130, 246, 0.3)', text: 'rgba(147, 197, 253, 0.9)' }; + return isLightTheme + ? { bg: 'rgba(37, 99, 235, 0.15)', text: 'rgba(29, 78, 216, 0.9)' } + : { bg: 'rgba(59, 130, 246, 0.3)', text: 'rgba(147, 197, 253, 0.9)' }; } // Markdown/Docs - green tones if (['.md', '.mdx', '.txt', '.rst'].includes(ext)) { - return { bg: 'rgba(34, 197, 94, 0.3)', text: 'rgba(134, 239, 172, 0.9)' }; + return isLightTheme + ? { bg: 'rgba(22, 163, 74, 0.15)', text: 'rgba(21, 128, 61, 0.9)' } + : { bg: 'rgba(34, 197, 94, 0.3)', text: 'rgba(134, 239, 172, 0.9)' }; } - // JSON/Config - yellow tones + // JSON/Config - yellow/amber tones if (['.json', '.yaml', '.yml', '.toml', '.ini', '.env'].includes(ext)) { - return { bg: 'rgba(234, 179, 8, 0.3)', text: 'rgba(253, 224, 71, 0.9)' }; + return isLightTheme + ? { bg: 'rgba(217, 119, 6, 0.15)', text: 'rgba(180, 83, 9, 0.9)' } + : { bg: 'rgba(234, 179, 8, 0.3)', text: 'rgba(253, 224, 71, 0.9)' }; } // CSS/Styles - purple tones if (['.css', '.scss', '.sass', '.less', '.styl'].includes(ext)) { - return { bg: 'rgba(168, 85, 247, 0.3)', text: 'rgba(216, 180, 254, 0.9)' }; + return isLightTheme + ? { bg: 'rgba(147, 51, 234, 0.15)', text: 'rgba(126, 34, 206, 0.9)' } + : { bg: 'rgba(168, 85, 247, 0.3)', text: 'rgba(216, 180, 254, 0.9)' }; } // HTML/Templates - orange tones if (['.html', '.htm', '.xml', '.svg'].includes(ext)) { - return { bg: 'rgba(249, 115, 22, 0.3)', text: 'rgba(253, 186, 116, 0.9)' }; + return isLightTheme + ? { bg: 'rgba(234, 88, 12, 0.15)', text: 'rgba(194, 65, 12, 0.9)' } + : { bg: 'rgba(249, 115, 22, 0.3)', text: 'rgba(253, 186, 116, 0.9)' }; + } + // Python - teal/cyan tones + if (['.py', '.pyw', '.pyi'].includes(ext)) { + return isLightTheme + ? { bg: 'rgba(13, 148, 136, 0.15)', text: 'rgba(15, 118, 110, 0.9)' } + : { bg: 'rgba(20, 184, 166, 0.3)', text: 'rgba(94, 234, 212, 0.9)' }; + } + // Rust - rust/orange-red tones + if (['.rs'].includes(ext)) { + return isLightTheme + ? { bg: 'rgba(185, 28, 28, 0.15)', text: 'rgba(153, 27, 27, 0.9)' } + : { bg: 'rgba(239, 68, 68, 0.3)', text: 'rgba(252, 165, 165, 0.9)' }; + } + // Go - cyan tones + if (['.go'].includes(ext)) { + return isLightTheme + ? { bg: 'rgba(8, 145, 178, 0.15)', text: 'rgba(14, 116, 144, 0.9)' } + : { bg: 'rgba(6, 182, 212, 0.3)', text: 'rgba(103, 232, 249, 0.9)' }; + } + // Shell scripts - gray/slate tones + if (['.sh', '.bash', '.zsh', '.fish'].includes(ext)) { + return isLightTheme + ? { bg: 'rgba(71, 85, 105, 0.15)', text: 'rgba(51, 65, 85, 0.9)' } + : { bg: 'rgba(100, 116, 139, 0.3)', text: 'rgba(203, 213, 225, 0.9)' }; } // Default - use theme's dim colors return { bg: theme.colors.border, text: theme.colors.textDim }; @@ -1211,6 +1247,9 @@ const FileTab = memo(function FileTab({ [tab.extension, theme] ); + // Hover background varies by theme mode for proper contrast + const hoverBgColor = theme.mode === 'light' ? 'rgba(0, 0, 0, 0.06)' : 'rgba(255, 255, 255, 0.08)'; + // Memoize tab styles to avoid creating new object references on every render const tabStyle = useMemo( () => @@ -1220,11 +1259,7 @@ const FileTab = memo(function FileTab({ borderTopRightRadius: '6px', // Active tab: bright background matching content area // Inactive tabs: transparent with subtle hover - backgroundColor: isActive - ? theme.colors.bgMain - : isHovered - ? 'rgba(255, 255, 255, 0.08)' - : 'transparent', + backgroundColor: isActive ? theme.colors.bgMain : isHovered ? hoverBgColor : 'transparent', // Active tab has visible borders, inactive tabs have no borders (cleaner look) borderTop: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', borderLeft: isActive ? `1px solid ${theme.colors.border}` : '1px solid transparent', @@ -1237,7 +1272,7 @@ const FileTab = memo(function FileTab({ zIndex: isActive ? 1 : 0, '--tw-ring-color': isDragOver ? theme.colors.accent : 'transparent', }) as React.CSSProperties, - [isActive, isHovered, isDragOver, theme.colors.bgMain, theme.colors.border, theme.colors.accent] + [isActive, isHovered, isDragOver, theme.colors.bgMain, theme.colors.border, theme.colors.accent, hoverBgColor] ); // Check if tab has unsaved edits @@ -1273,7 +1308,7 @@ const FileTab = memo(function FileTab({ {/* Tab name - filename without extension */} {tab.name}