MAESTRO: Add visual polish for file tab styling and theme-aware extension badges

- Fix max-width truncation inconsistency: file tabs now use max-w-[120px] to match AI tabs
- Add theme-aware hover background: dark mode (rgba(255,255,255,0.08)), light mode (rgba(0,0,0,0.06))
- Enhance getExtensionColor() with theme-aware colors for both light and dark modes:
  - TypeScript/JavaScript, Markdown, JSON, CSS, HTML with contrast-appropriate colors
  - Add support for Python (teal), Rust (red), Go (cyan), Shell (gray)
  - Unknown extensions fall back to theme border/textDim colors
- Add 10 unit tests verifying extension badge colors across themes
This commit is contained in:
Pedram Amini
2026-02-02 06:59:00 -06:00
parent 32e7d88cf9
commit 58c6331176
2 changed files with 439 additions and 19 deletions

View File

@@ -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(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// 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(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={lightTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// 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(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
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(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
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(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
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(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
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(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
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(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
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(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1"
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
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(
<TabBar
tabs={[aiTab]}
activeTabId="ai-tab-1" // AI tab active, file tab inactive
theme={darkTheme}
onTabSelect={mockOnTabSelect}
onTabClose={mockOnTabClose}
onNewTab={mockOnNewTab}
unifiedTabs={unifiedTabs}
activeFileTabId={null}
onFileTabSelect={mockOnFileTabSelect}
onFileTabClose={mockOnFileTabClose}
/>
);
// 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]');
});
});

View File

@@ -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 */}
<span
className={`text-xs font-medium ${isActive ? 'whitespace-nowrap' : 'truncate max-w-[100px]'}`}
className={`text-xs font-medium ${isActive ? 'whitespace-nowrap' : 'truncate max-w-[120px]'}`}
style={{ color: isActive ? theme.colors.textMain : theme.colors.textDim }}
>
{tab.name}