FABRIC/src/tui/utils/theme.ts
jedarden 8434701bd5 fix(ci): handle missing homedir in ThemeManager for CI environments
Wrap os.homedir() in try/catch and guard against undefined return value
in ThemeManager.getConfigPath(). In CI environments, os.homedir() may be
undefined, causing path.join() to throw and preventing SessionReplay
from being constructed. When homedir is unavailable, theme persistence
is gracefully skipped and the default dark theme is used.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:38:40 -04:00

316 lines
7.4 KiB
TypeScript

/**
* 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 {
try {
const home = os.homedir();
if (!home) {
return '';
}
const configDir = path.join(home, '.fabric');
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
return path.join(configDir, 'theme.json');
} catch {
// In CI or environments without a home directory, skip config persistence
return '';
}
}
/**
* Load theme from config file
*/
private loadTheme(): ThemeName {
try {
if (this.configPath && 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 {
if (!this.configPath) return;
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();
}