From 73cf7bae512e341d97af21439177f62d1e7a0866 Mon Sep 17 00:00:00 2001 From: jeda Date: Sat, 7 Mar 2026 04:28:29 +0000 Subject: [PATCH] feat(bd-2ot): Add theme support (Dark/Light) for TUI and Web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TUI Theme Support - Create ThemeManager class with dark/light theme palettes - Update colors.ts to use theme system via proxy pattern - Add Ctrl+T keybinding for theme toggle - Add theme commands to CommandPalette (theme:toggle, theme:dark, theme:light) - Persist theme preference to ~/.fabric/theme.json - Update footer to show current theme ## Web Dashboard Theme Support - Create ThemeContext with React context API - Add light theme CSS variables (data-theme="light") - Add ThemeToggle button to header - Persist theme preference to localStorage - Support system color scheme preference detection 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Worker --- src/tui/app.ts | 169 +++++++++++- src/tui/components/CommandPalette.ts | 3 + src/tui/utils/colors.ts | 74 ++--- src/tui/utils/theme.ts | 306 +++++++++++++++++++++ src/web/frontend/src/App.tsx | 51 +++- src/web/frontend/src/ThemeContext.tsx | 78 ++++++ src/web/frontend/src/index.css | 378 ++++++++++++++++++++++++++ 7 files changed, 1003 insertions(+), 56 deletions(-) create mode 100644 src/tui/utils/theme.ts create mode 100644 src/web/frontend/src/ThemeContext.tsx diff --git a/src/tui/app.ts b/src/tui/app.ts index 504ebf3..d775470 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -7,7 +7,7 @@ import blessed from 'blessed'; import { LogEvent, WorkerInfo, WorkerStatus } from '../types.js'; import { InMemoryEventStore } from '../store.js'; -import { colors, getStatusColor } from './utils/colors.js'; +import { colors, getStatusColor, getThemeManager, ThemeName } from './utils/colors.js'; import { WorkerGrid } from './components/WorkerGrid.js'; import { ActivityStream } from './components/ActivityStream.js'; import { WorkerDetail } from './components/WorkerDetail.js'; @@ -21,6 +21,7 @@ import { CollisionAlert } from './components/CollisionAlert.js'; import { GitIntegration } from './components/GitIntegration.js'; import { SemanticNarrativePanel } from './components/SemanticNarrativePanel.js'; import { WorkerAnalyticsPanel } from './components/WorkerAnalyticsPanel.js'; +import { FileContextPanel } from './components/FileContextPanel.js'; import { getErrorGroupManager } from '../errorGrouping.js'; import { WorkerSessionSummary } from '../types.js'; import { parseGitEvents } from '../gitParser.js'; @@ -65,9 +66,18 @@ export class FabricTuiApp { private gitIntegration!: GitIntegration; private semanticNarrativePanel!: SemanticNarrativePanel; private workerAnalyticsPanel!: WorkerAnalyticsPanel; + private fileContextPanel!: FileContextPanel; private footerBox!: blessed.Widgets.BoxElement; private helpOverlay?: blessed.Widgets.BoxElement; + // Split view state + private fileContextVisible = false; + private splitRatio = 0.5; // 50% for activity, 50% for file context + + // Theme + private currentTheme: ThemeName; + private themeUnsubscribe?: () => void; + constructor(store: InMemoryEventStore, options: TuiOptions = {}) { this.store = store; this.options = { @@ -76,6 +86,14 @@ export class FabricTuiApp { refreshInterval: options.refreshInterval || 100, }; + // Initialize theme + const themeManager = getThemeManager(); + this.currentTheme = themeManager.getTheme(); + this.themeUnsubscribe = themeManager.subscribe((theme) => { + this.currentTheme = theme; + this.render(); + }); + this.screen = this.createScreen(); this.createLayout(); this.bindKeys(); @@ -319,6 +337,16 @@ export class FabricTuiApp { }); this.workerAnalyticsPanel.hide(); + // File Context panel (split view, 'F' key) + this.fileContextPanel = new FileContextPanel({ + parent: this.screen, + top: 1, + left: '60%', + width: '40%', + bottom: 1, + }); + this.fileContextPanel.hide(); + // Footer with key hints this.footerBox = blessed.box({ parent: this.screen, @@ -352,8 +380,17 @@ export class FabricTuiApp { } } + // Show file context status + if (this.fileContextVisible) { + content += ` {cyan-fg}[FILE CONTEXT ${Math.round((1-this.splitRatio)*100)}%]{/}`; + } + + // Show current theme + content += ` {${this.currentTheme === 'dark' ? 'blue' : 'yellow'}-fg}[${this.currentTheme.toUpperCase()}]{/}`; + content += ' [p]Pin Worker [P]Pin Bead [F]Focus'; - content += ' [?] Help [q] Quit'; + content += ' [Ctrl+F]File Panel [?/]]Resize'; + content += ' [Ctrl+T]Theme [?] Help [q] Quit'; return content; } @@ -473,6 +510,25 @@ export class FabricTuiApp { this.screen.key(['F'], () => { this.toggleFocusMode(); }); + + // Toggle file context panel (Ctrl+F) + this.screen.key(['C-f'], () => { + this.toggleFileContextPanel(); + }); + + // Resize split view + this.screen.key(['['], () => { + this.resizeFileContext(-0.05); + }); + + this.screen.key([']'], () => { + this.resizeFileContext(0.05); + }); + + // Theme toggle (Ctrl+T) + this.screen.key(['C-t'], () => { + this.toggleTheme(); + }); } /** @@ -513,6 +569,12 @@ export class FabricTuiApp { } else if (cmd.startsWith('filter:level:')) { const level = cmd.replace('filter:level:', ''); this.activityStream.setFilter({ level }); + } else if (cmd === 'theme' || cmd === 'theme:toggle') { + this.toggleTheme(); + } else if (cmd === 'theme:dark') { + getThemeManager().setTheme('dark'); + } else if (cmd === 'theme:light') { + getThemeManager().setTheme('light'); } } @@ -615,6 +677,24 @@ export class FabricTuiApp { } } + /** + * Toggle theme between dark and light + */ + private toggleTheme(): void { + const themeManager = getThemeManager(); + const newTheme = themeManager.toggleTheme(); + // Update header to show theme change briefly + const originalContent = this.headerBox.getContent(); + this.headerBox.setContent(` FABRIC - Theme: ${newTheme.toUpperCase()}`); + this.screen.render(); + // Restore original content after a short delay + setTimeout(() => { + this.headerBox.setContent(originalContent); + this.updateHeader(); + this.screen.render(); + }, 1000); + } + /** * Update collision alerts from store */ @@ -629,6 +709,12 @@ export class FabricTuiApp { private setViewMode(mode: 'default' | 'heatmap' | 'dag' | 'replay' | 'errors' | 'digest' | 'collisions' | 'git' | 'narrative' | 'analytics'): void { this.viewMode = mode; + // Hide file context panel when switching views (except default) + if (mode !== 'default') { + this.fileContextPanel.hide(); + this.fileContextVisible = false; + } + if (mode === 'heatmap') { // Hide other panels this.workerGrid.getElement().hide(); @@ -636,6 +722,7 @@ export class FabricTuiApp { this.dependencyDag.getElement().hide(); this.sessionReplay.hide(); this.errorGroupPanel.hide(); + this.fileContextPanel.hide(); // Show heatmap this.fileHeatmap.getElement().show(); @@ -855,11 +942,22 @@ export class FabricTuiApp { this.gitIntegration.hide(); this.semanticNarrativePanel.hide(); this.workerAnalyticsPanel.hide(); + this.fileContextPanel.hide(); // Show default panels this.workerGrid.getElement().show(); this.activityStream.getElement().show(); + // Restore file context panel if it was visible + if (this.fileContextVisible) { + this.updateSplitViewLayout(); + this.fileContextPanel.show(); + } else { + // Restore activity stream to full width + this.activityStream.getElement().right = 0; + this.activityStream.getElement().width = '60%'; + } + // Update header this.headerBox.setContent(' FABRIC - Worker Activity Monitor'); this.footerBox.setContent(this.getFooterContent()); @@ -955,6 +1053,54 @@ export class FabricTuiApp { this.render(); } + /** + * Toggle file context panel (split view) + */ + private toggleFileContextPanel(): void { + if (this.viewMode !== 'default') return; + + this.fileContextVisible = !this.fileContextVisible; + + if (this.fileContextVisible) { + this.updateSplitViewLayout(); + this.fileContextPanel.show(); + } else { + this.fileContextPanel.hide(); + // Restore activity stream to full width + this.activityStream.getElement().right = 0; + this.activityStream.getElement().width = '60%'; + } + + this.screen.render(); + } + + /** + * Resize file context panel + */ + private resizeFileContext(delta: number): void { + if (!this.fileContextVisible || this.viewMode !== 'default') return; + + this.splitRatio = Math.max(0.2, Math.min(0.8, this.splitRatio + delta)); + this.updateSplitViewLayout(); + this.screen.render(); + } + + /** + * Update split view layout based on split ratio + */ + private updateSplitViewLayout(): void { + const activityWidth = Math.round(this.splitRatio * 100); + const fileContextWidth = 100 - activityWidth; + + // Update activity stream + this.activityStream.getElement().width = `${activityWidth}%`; + this.activityStream.getElement().right = `${fileContextWidth}%`; + + // Update file context panel + this.fileContextPanel.getElement().left = `${activityWidth}%`; + this.fileContextPanel.getElement().width = `${fileContextWidth}%`; + } + /** * Toggle help overlay */ @@ -1000,6 +1146,13 @@ Focus Mode: p - Pin/unpin selected worker P - Pin/unpin bead (from selected worker) +File Context Panel (Split View): + Ctrl+F - Toggle file context panel + [ - Decrease file context panel width + ] - Increase file context panel width + o - Open current file in editor + Tab - Switch focus between panels + Heatmap View: s - Cycle sort mode c - Toggle collisions only @@ -1054,6 +1207,9 @@ Worker Analytics: r - Refresh metrics Esc - Return to default view +Theme: + Ctrl+T - Toggle dark/light theme + General: ? - Toggle this help q - Quit @@ -1093,6 +1249,11 @@ General: this.workerGrid.setFocusMode(this.focusModeEnabled, this.pinnedWorkerId); this.activityStream.setFocusMode(this.focusModeEnabled, this.pinnedBeadId, this.pinnedWorkerId); + // Update file context panel if this is a file event + if (event.path && this.fileContextVisible) { + this.fileContextPanel.setContextFromEvent(event); + } + // Update heatmap if visible if (this.viewMode === 'heatmap') { this.fileHeatmap.updateData( @@ -1145,6 +1306,10 @@ General: */ stop(): void { this.isRunning = false; + // Clean up theme subscription + if (this.themeUnsubscribe) { + this.themeUnsubscribe(); + } this.screen.destroy(); process.exit(0); } diff --git a/src/tui/components/CommandPalette.ts b/src/tui/components/CommandPalette.ts index f92d9d1..c61d7b4 100644 --- a/src/tui/components/CommandPalette.ts +++ b/src/tui/components/CommandPalette.ts @@ -39,6 +39,9 @@ const DEFAULT_SUGGESTIONS: CommandSuggestion[] = [ { label: 'Clear filters', category: 'Action', action: 'clear' }, { label: 'Toggle pause', category: 'Action', action: 'pause' }, { label: 'Refresh', category: 'Action', action: 'refresh' }, + { label: 'Toggle theme', category: 'Theme', action: 'theme:toggle' }, + { label: 'Dark theme', category: 'Theme', action: 'theme:dark' }, + { label: 'Light theme', category: 'Theme', action: 'theme:light' }, { label: 'Help', category: 'Navigation', action: 'help' }, { label: 'Quit', category: 'Navigation', action: 'quit' }, ]; diff --git a/src/tui/utils/colors.ts b/src/tui/utils/colors.ts index 46f48b8..015508a 100644 --- a/src/tui/utils/colors.ts +++ b/src/tui/utils/colors.ts @@ -2,8 +2,10 @@ * FABRIC TUI Color Scheme * * Color definitions for terminal UI rendering. - * Uses bright/light color variants for better contrast and - * readability in both light and dark terminal themes. + * Supports dark and light themes via the theme system. + * + * This module provides a backward-compatible API while delegating + * to the theme manager for actual color values. * * Blessed color options: * - Basic: black, red, green, yellow, blue, magenta, cyan, white @@ -12,71 +14,36 @@ * - Gray: light-black (better than 'gray' for consistency) */ -export const colors = { - // Status colors - using bright variants for visibility - active: 'light-green', - idle: 'light-yellow', - error: 'light-red', +import { getColors, getThemeManager, ThemeName, ThemeColors } from './theme.js'; - // Log level colors - optimized for readability - debug: 'light-black', // Muted but visible - info: 'light-cyan', // Distinct from text - warn: 'light-yellow', // High visibility warning - warning: 'light-yellow', // Alias for warn - error_level: 'light-red', // High visibility error +// Re-export theme types and functions for convenience +export type { ThemeName, ThemeColors } from './theme.js'; +export { getThemeManager, darkTheme, lightTheme } from './theme.js'; - // UI colors - improved contrast - border: 'light-blue', - header: 'light-cyan', - focus: 'light-green', - muted: 'light-black', // Consistent muted color - text: 'light-white', // Bright readable text - selected: 'light-green', +/** + * Colors object that proxies to the current theme + * This provides backward compatibility with existing code that imports `colors` + */ +export const colors: ThemeColors = new Proxy({} as ThemeColors, { + get(_target, prop: keyof ThemeColors) { + return getColors()[prop]; + }, +}); - // Background colors - transparent/none for theme compatibility - bgPanel: 'default', // Use terminal's default background - bgFocus: 'blue', // Distinct but not too bright - - // Input colors - inputBg: 'default', // Use terminal's default background - inputFocusBg: 'blue', - dim: 'light-black', - - // Heat level colors - progressive intensity - heatCold: 'light-blue', - heatWarm: 'light-yellow', - heatHot: 'light-magenta', - heatCritical: 'light-red', - - // Named colors (for components that reference by name) - // Using light variants for better contrast - green: 'light-green', - yellow: 'light-yellow', - blue: 'light-blue', - red: 'light-red', - cyan: 'light-cyan', - magenta: 'light-magenta', - orange: 'yellow', // Orange not widely supported, use yellow - purple: 'light-magenta', - teal: 'light-cyan', - white: 'light-white', - black: 'black', - gray: 'light-black', // Consistent gray using light-black -} as const; - -export type ColorName = keyof typeof colors; +export type ColorName = keyof ThemeColors; /** * Get color for worker status */ export function getStatusColor(status: 'active' | 'idle' | 'error'): string { - return colors[status]; + return getColors()[status]; } /** * Get color for log level */ export function getLevelColor(level: 'debug' | 'info' | 'warn' | 'error'): string { + const colors = getColors(); switch (level) { case 'debug': return colors.debug; case 'info': return colors.info; @@ -89,6 +56,7 @@ export function getLevelColor(level: 'debug' | 'info' | 'warn' | 'error'): strin * Get color for heat level */ export function getHeatColor(level: 'cold' | 'warm' | 'hot' | 'critical'): string { + const colors = getColors(); switch (level) { case 'cold': return colors.heatCold; case 'warm': return colors.heatWarm; diff --git a/src/tui/utils/theme.ts b/src/tui/utils/theme.ts new file mode 100644 index 0000000..1779334 --- /dev/null +++ b/src/tui/utils/theme.ts @@ -0,0 +1,306 @@ +/** + * FABRIC TUI Theme System + * + * Provides dark and light theme support for the terminal UI. + * Themes are persisted to a config file for session persistence. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export type ThemeName = 'dark' | 'light'; + +export interface ThemeColors { + // Status colors + active: string; + idle: string; + error: string; + + // Log level colors + debug: string; + info: string; + warn: string; + warning: string; + error_level: string; + + // UI colors + border: string; + header: string; + focus: string; + muted: string; + text: string; + selected: string; + + // Background colors + bgPanel: string; + bgFocus: string; + + // Input colors + inputBg: string; + inputFocusBg: string; + dim: string; + + // Heat level colors + heatCold: string; + heatWarm: string; + heatHot: string; + heatCritical: string; + + // Named colors + green: string; + yellow: string; + blue: string; + red: string; + cyan: string; + magenta: string; + orange: string; + purple: string; + teal: string; + white: string; + black: string; + gray: string; +} + +/** + * Dark theme - optimized for dark terminal backgrounds + * Uses light variants for visibility + */ +export const darkTheme: ThemeColors = { + // Status colors - using bright variants for visibility + active: 'light-green', + idle: 'light-yellow', + error: 'light-red', + + // Log level colors - optimized for readability + debug: 'light-black', // Muted but visible + info: 'light-cyan', // Distinct from text + warn: 'light-yellow', // High visibility warning + warning: 'light-yellow', // Alias for warn + error_level: 'light-red', // High visibility error + + // UI colors - improved contrast + border: 'light-blue', + header: 'light-cyan', + focus: 'light-green', + muted: 'light-black', // Consistent muted color + text: 'light-white', // Bright readable text + selected: 'light-green', + + // Background colors - transparent/none for theme compatibility + bgPanel: 'default', // Use terminal's default background + bgFocus: 'blue', // Distinct but not too bright + + // Input colors + inputBg: 'default', // Use terminal's default background + inputFocusBg: 'blue', + dim: 'light-black', + + // Heat level colors - progressive intensity + heatCold: 'light-blue', + heatWarm: 'light-yellow', + heatHot: 'light-magenta', + heatCritical: 'light-red', + + // Named colors (for components that reference by name) + green: 'light-green', + yellow: 'light-yellow', + blue: 'light-blue', + red: 'light-red', + cyan: 'light-cyan', + magenta: 'light-magenta', + orange: 'yellow', // Orange not widely supported, use yellow + purple: 'light-magenta', + teal: 'light-cyan', + white: 'light-white', + black: 'black', + gray: 'light-black', +}; + +/** + * Light theme - optimized for light terminal backgrounds + * Uses dark variants for contrast + */ +export const lightTheme: ThemeColors = { + // Status colors - using dark variants for contrast + active: 'green', + idle: 'yellow', + error: 'red', + + // Log level colors - optimized for light background readability + debug: 'black', // Dark for visibility on light bg + info: 'blue', // Distinct from text + warn: 'yellow', // High visibility warning + warning: 'yellow', // Alias for warn + error_level: 'red', // High visibility error + + // UI colors - dark colors for contrast + border: 'blue', + header: 'cyan', + focus: 'green', + muted: 'black', // Dark muted color + text: 'black', // Dark readable text + selected: 'green', + + // Background colors + bgPanel: 'default', // Use terminal's default background + bgFocus: 'white', // Light focus background + + // Input colors + inputBg: 'default', + inputFocusBg: 'white', + dim: 'black', + + // Heat level colors - progressive intensity + heatCold: 'blue', + heatWarm: 'yellow', + heatHot: 'magenta', + heatCritical: 'red', + + // Named colors (for components that reference by name) + green: 'green', + yellow: 'yellow', + blue: 'blue', + red: 'red', + cyan: 'cyan', + magenta: 'magenta', + orange: 'yellow', + purple: 'magenta', + teal: 'cyan', + white: 'white', + black: 'black', + gray: 'black', +}; + +/** + * Theme manager class for managing theme state and persistence + */ +export class ThemeManager { + private currentTheme: ThemeName; + private configPath: string; + private listeners: Set<(theme: ThemeName) => void> = new Set(); + + constructor() { + this.configPath = this.getConfigPath(); + this.currentTheme = this.loadTheme(); + } + + /** + * Get the config file path for theme persistence + */ + private getConfigPath(): string { + const configDir = path.join(os.homedir(), '.fabric'); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + return path.join(configDir, 'theme.json'); + } + + /** + * Load theme from config file + */ + private loadTheme(): ThemeName { + try { + if (fs.existsSync(this.configPath)) { + const content = fs.readFileSync(this.configPath, 'utf-8'); + const config = JSON.parse(content); + if (config.theme === 'dark' || config.theme === 'light') { + return config.theme; + } + } + } catch (error) { + // Ignore errors, fall back to default + } + return 'dark'; // Default to dark theme + } + + /** + * Save theme to config file + */ + private saveTheme(): void { + try { + const config = { theme: this.currentTheme }; + fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8'); + } catch (error) { + // Ignore save errors + } + } + + /** + * Get the current theme name + */ + getTheme(): ThemeName { + return this.currentTheme; + } + + /** + * Set the current theme + */ + setTheme(theme: ThemeName): void { + if (this.currentTheme !== theme) { + this.currentTheme = theme; + this.saveTheme(); + this.notifyListeners(); + } + } + + /** + * Toggle between dark and light themes + */ + toggleTheme(): ThemeName { + const newTheme = this.currentTheme === 'dark' ? 'light' : 'dark'; + this.setTheme(newTheme); + return newTheme; + } + + /** + * Get the colors for the current theme + */ + getColors(): ThemeColors { + return this.currentTheme === 'dark' ? darkTheme : lightTheme; + } + + /** + * Subscribe to theme changes + */ + subscribe(listener: (theme: ThemeName) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Notify all listeners of theme change + */ + private notifyListeners(): void { + for (const listener of this.listeners) { + listener(this.currentTheme); + } + } +} + +// Global theme manager instance +let themeManager: ThemeManager | null = null; + +/** + * Get the global theme manager instance + */ +export function getThemeManager(): ThemeManager { + if (!themeManager) { + themeManager = new ThemeManager(); + } + return themeManager; +} + +/** + * Get current theme colors (convenience function) + */ +export function getColors(): ThemeColors { + return getThemeManager().getColors(); +} + +/** + * Get current theme name (convenience function) + */ +export function getCurrentTheme(): ThemeName { + return getThemeManager().getTheme(); +} diff --git a/src/web/frontend/src/App.tsx b/src/web/frontend/src/App.tsx index f62dabf..87475fa 100644 --- a/src/web/frontend/src/App.tsx +++ b/src/web/frontend/src/App.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { LogEvent, WorkerInfo, WebSocketMessage, CollisionAlert as CollisionAlertData, RecoverySuggestion } from './types'; +import { ThemeProvider, useTheme } from './ThemeContext'; import WorkerGrid from './components/WorkerGrid'; import ActivityStream from './components/ActivityStream'; import WorkerDetail from './components/WorkerDetail'; @@ -7,6 +8,7 @@ import CollisionAlert from './components/CollisionAlert'; import FileHeatmap from './components/FileHeatmap'; import DependencyDag from './components/DependencyDag'; import RecoveryPanel from './components/RecoveryPanel'; +import FileContextPanel from './components/FileContextPanel'; const FOCUS_MODE_STORAGE_KEY = 'fabric-focus-mode'; @@ -16,6 +18,24 @@ interface FocusModeState { pinnedBeads: string[]; } +/** + * Theme toggle button component + */ +const ThemeToggle: React.FC = () => { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +}; + const App: React.FC = () => { const [workers, setWorkers] = useState([]); const [events, setEvents] = useState([]); @@ -26,6 +46,7 @@ const App: React.FC = () => { const [showFileHeatmap, setShowFileHeatmap] = useState(false); const [showDependencyDag, setShowDependencyDag] = useState(false); const [showRecoveryPanel, setShowRecoveryPanel] = useState(false); + const [showFileContext, setShowFileContext] = useState(false); const [recoverySuggestions, setRecoverySuggestions] = useState([]); // Focus Mode state @@ -203,6 +224,7 @@ const App: React.FC = () => {

FABRIC

+ + {unacknowledgedAlertCount > 0 && (
); }; -export default App; +// Wrap with ThemeProvider for theme support +const AppWithTheme: React.FC = () => ( + + + +); + +export default AppWithTheme; diff --git a/src/web/frontend/src/ThemeContext.tsx b/src/web/frontend/src/ThemeContext.tsx new file mode 100644 index 0000000..2f14954 --- /dev/null +++ b/src/web/frontend/src/ThemeContext.tsx @@ -0,0 +1,78 @@ +import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; + +export type Theme = 'dark' | 'light'; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; + setTheme: (theme: Theme) => void; +} + +const ThemeContext = createContext(undefined); + +const THEME_STORAGE_KEY = 'fabric-theme'; + +interface ThemeProviderProps { + children: ReactNode; +} + +export const ThemeProvider: React.FC = ({ children }) => { + // Initialize theme from localStorage or system preference + const [theme, setThemeState] = useState(() => { + // Check localStorage first + const savedTheme = localStorage.getItem(THEME_STORAGE_KEY); + if (savedTheme === 'dark' || savedTheme === 'light') { + return savedTheme; + } + // Fall back to system preference + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) { + return 'light'; + } + return 'dark'; // Default to dark + }); + + // Apply theme to document + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(THEME_STORAGE_KEY, theme); + }, [theme]); + + // Listen for system theme changes + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: light)'); + const handleChange = (e: MediaQueryListEvent) => { + // Only auto-switch if no saved preference + const savedTheme = localStorage.getItem(THEME_STORAGE_KEY); + if (!savedTheme) { + setThemeState(e.matches ? 'light' : 'dark'); + } + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + const toggleTheme = useCallback(() => { + setThemeState(prev => prev === 'dark' ? 'light' : 'dark'); + }, []); + + const setTheme = useCallback((newTheme: Theme) => { + setThemeState(newTheme); + }, []); + + return ( + + {children} + + ); +}; + +export const useTheme = (): ThemeContextType => { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; + +export default ThemeContext; diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index c80cd0c..dec8b49 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -1,4 +1,5 @@ :root { + /* Dark theme (default) */ --bg-primary: #1a1a2e; --bg-secondary: #16213e; --bg-tertiary: #0f3460; @@ -10,6 +11,29 @@ --warning: #ffc107; --error: #f44336; --info: #2196f3; + --border-color: #2a2a4e; + --shadow-color: rgba(0, 0, 0, 0.3); + --input-bg: #0f3460; + --hover-bg: #1a1a4e; +} + +/* Light theme */ +[data-theme="light"] { + --bg-primary: #f5f5f5; + --bg-secondary: #ffffff; + --bg-tertiary: #e8e8e8; + --accent: #c62828; + --accent-dim: #c6282860; + --text-primary: #212121; + --text-secondary: #666666; + --success: #2e7d32; + --warning: #f57c00; + --error: #c62828; + --info: #1565c0; + --border-color: #d0d0d0; + --shadow-color: rgba(0, 0, 0, 0.1); + --input-bg: #ffffff; + --hover-bg: #f0f0f0; } * { @@ -151,6 +175,41 @@ body { background: var(--success); } +/* Theme toggle button */ +.theme-toggle { + display: flex; + align-items: center; + gap: 0.375rem; + background: rgba(156, 39, 176, 0.2); + border: 1px solid #9c27b0; + color: #9c27b0; + padding: 0.375rem 0.625rem; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.theme-toggle:hover { + background: rgba(156, 39, 176, 0.3); +} + +[data-theme="light"] .theme-toggle { + background: rgba(156, 39, 176, 0.15); +} + +.theme-toggle:hover { + background: rgba(156, 39, 176, 0.3); +} + +.theme-toggle-icon { + font-size: 1rem; +} + +.theme-toggle-label { + font-size: 0.75rem; + font-weight: 600; +} + .main-content { display: grid; grid-template-columns: 300px 1fr; @@ -2660,3 +2719,322 @@ body { padding: 0.2rem 0.4rem; } } + +/* ============================================ + File Context Panel Styles + ============================================ */ + +.file-context-panel { + position: fixed; + top: 60px; + right: 0; + bottom: 0; + background: var(--bg-secondary); + border-left: 1px solid var(--bg-tertiary); + display: flex; + flex-direction: column; + z-index: 100; +} + +.file-context-panel .resize-handle { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + cursor: ew-resize; + background: transparent; + transition: background 0.2s; +} + +.file-context-panel .resize-handle:hover { + background: var(--accent); +} + +.file-context-panel .panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--bg-primary); +} + +.file-context-panel .panel-header h3 { + font-size: 0.875rem; + font-weight: 600; + margin: 0; + color: var(--text-primary); +} + +.file-context-panel .panel-actions { + display: flex; + gap: 0.5rem; +} + +.file-context-panel .panel-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; + transition: all 0.2s; +} + +.file-context-panel .panel-btn:hover { + background: var(--bg-primary); + color: var(--text-primary); +} + +.file-context-panel .panel-btn.close:hover { + color: var(--error); +} + +.file-context-panel .file-selector { + padding: 0.5rem 1rem; + background: var(--bg-primary); + border-bottom: 1px solid var(--bg-tertiary); +} + +.file-context-panel .file-selector select { + width: 100%; + padding: 0.5rem; + background: var(--bg-secondary); + border: 1px solid var(--bg-tertiary); + border-radius: 4px; + color: var(--text-primary); + font-size: 0.8rem; +} + +.file-context-panel .file-info { + padding: 0.75rem 1rem; + background: var(--bg-primary); + border-bottom: 1px solid var(--bg-tertiary); +} + +.file-context-panel .file-path { + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.8rem; + color: var(--info); + margin-bottom: 0.25rem; + word-break: break-all; +} + +.file-context-panel .file-meta { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.7rem; + color: var(--text-secondary); +} + +.file-context-panel .language-badge { + background: var(--bg-tertiary); + padding: 0.125rem 0.5rem; + border-radius: 3px; + font-size: 0.65rem; + text-transform: uppercase; + color: var(--accent); +} + +.file-context-panel .file-content { + flex: 1; + overflow-y: auto; + padding: 1rem; +} + +.file-context-panel .no-file { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-secondary); + text-align: center; +} + +.file-context-panel .no-file-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.3; +} + +.file-context-panel .no-file-text { + font-size: 1rem; + margin-bottom: 0.5rem; +} + +.file-context-panel .no-file-hint { + font-size: 0.75rem; + opacity: 0.7; + max-width: 200px; +} + +.file-context-panel .content-placeholder { + display: flex; + flex-direction: column; + height: 100%; +} + +.file-context-panel .placeholder-message { + text-align: center; + padding: 1rem; + color: var(--text-secondary); + font-size: 0.875rem; +} + +.file-context-panel .placeholder-hint { + text-align: center; + font-size: 0.7rem; + color: var(--text-secondary); + opacity: 0.7; + margin-bottom: 1rem; +} + +.file-context-panel .simulated-lines { + flex: 1; + background: var(--bg-primary); + border-radius: 4px; + padding: 0.5rem; +} + +.file-context-panel .simulated-line { + display: flex; + align-items: center; + padding: 0.25rem 0; + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.75rem; +} + +.file-context-panel .line-num { + color: var(--text-secondary); + opacity: 0.5; + width: 30px; + text-align: right; + margin-right: 1rem; + user-select: none; +} + +.file-context-panel .line-text { + flex: 1; + background: var(--bg-secondary); + height: 0.875rem; + border-radius: 2px; + opacity: 0.3; +} + +.file-context-panel .operations-list { + padding: 0.75rem 1rem; + background: var(--bg-primary); + border-top: 1px solid var(--bg-tertiary); + max-height: 200px; + overflow-y: auto; +} + +.file-context-panel .operations-header { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.file-context-panel .operations-items { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.file-context-panel .operation-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.7rem; + padding: 0.25rem 0.5rem; + background: var(--bg-secondary); + border-radius: 3px; +} + +.file-context-panel .op-icon { + font-size: 0.875rem; +} + +.file-context-panel .op-type { + color: var(--info); + text-transform: capitalize; +} + +.file-context-panel .op-worker { + color: var(--text-secondary); + margin-left: auto; +} + +.file-context-panel .op-time { + color: var(--text-secondary); + opacity: 0.7; +} + +.file-context-panel .more-operations { + text-align: center; + font-size: 0.7rem; + color: var(--text-secondary); + padding: 0.5rem; +} + +.file-context-panel .quick-actions { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--bg-tertiary); + border-top: 1px solid var(--bg-primary); +} + +.file-context-panel .quick-actions button { + flex: 1; + padding: 0.5rem; + background: var(--bg-secondary); + border: 1px solid var(--bg-primary); + border-radius: 4px; + color: var(--text-primary); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s; +} + +.file-context-panel .quick-actions button:hover { + background: var(--bg-primary); + border-color: var(--accent); +} + +.file-context-panel .quick-actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* File context toggle button */ +.file-context-toggle { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.375rem 0.75rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; +} + +.file-context-toggle:hover { + background: rgba(255, 255, 255, 0.15); +} + +.file-context-icon { + font-size: 0.875rem; +} + +.file-context-label { + font-size: 0.75rem; + font-weight: 500; +}