diff --git a/apps/web-clipper-manifestv3/src/shared/theme.css b/apps/web-clipper-manifestv3/src/shared/theme.css new file mode 100644 index 000000000..2ba1fd26f --- /dev/null +++ b/apps/web-clipper-manifestv3/src/shared/theme.css @@ -0,0 +1,334 @@ +/* + * Shared theme system for all extension UI components + * Supports light, dark, and system themes with smooth transitions + */ + +:root { + /* Color scheme detection */ + color-scheme: light dark; + + /* Animation settings */ + --theme-transition: all 0.2s ease-in-out; +} + +/* Light Theme (Default) */ +:root, +:root.theme-light, +[data-theme="light"] { + /* Primary colors */ + --color-primary: #007cba; + --color-primary-hover: #005a87; + --color-primary-light: #e8f4f8; + + /* Background colors */ + --color-bg-primary: #ffffff; + --color-bg-secondary: #f8f9fa; + --color-bg-tertiary: #e9ecef; + --color-bg-modal: rgba(255, 255, 255, 0.95); + + /* Surface colors */ + --color-surface: #ffffff; + --color-surface-hover: #f8f9fa; + --color-surface-active: #e9ecef; + + /* Text colors */ + --color-text-primary: #212529; + --color-text-secondary: #6c757d; + --color-text-tertiary: #adb5bd; + --color-text-inverse: #ffffff; + + /* Border colors */ + --color-border-primary: #dee2e6; + --color-border-secondary: #e9ecef; + --color-border-focus: #007cba; + + /* Status colors */ + --color-success: #28a745; + --color-success-bg: #d4edda; + --color-success-border: #c3e6cb; + + --color-warning: #ffc107; + --color-warning-bg: #fff3cd; + --color-warning-border: #ffeaa7; + + --color-error: #dc3545; + --color-error-bg: #f8d7da; + --color-error-border: #f5c6cb; + + --color-info: #17a2b8; + --color-info-bg: #d1ecf1; + --color-info-border: #bee5eb; + + /* Shadow colors */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.1); + --shadow-focus: 0 0 0 3px rgba(0, 124, 186, 0.25); + + /* Log viewer specific */ + --log-bg-debug: #f8f9fa; + --log-bg-info: #d1ecf1; + --log-bg-warn: #fff3cd; + --log-bg-error: #f8d7da; + --log-border-debug: #6c757d; + --log-border-info: #17a2b8; + --log-border-warn: #ffc107; + --log-border-error: #dc3545; +} + +/* Dark Theme */ +:root.theme-dark, +[data-theme="dark"] { + /* Primary colors */ + --color-primary: #4dabf7; + --color-primary-hover: #339af0; + --color-primary-light: #1c2a3a; + + /* Background colors */ + --color-bg-primary: #1a1a1a; + --color-bg-secondary: #2d2d2d; + --color-bg-tertiary: #404040; + --color-bg-modal: rgba(26, 26, 26, 0.95); + + /* Surface colors */ + --color-surface: #2d2d2d; + --color-surface-hover: #404040; + --color-surface-active: #525252; + + /* Text colors */ + --color-text-primary: #f8f9fa; + --color-text-secondary: #adb5bd; + --color-text-tertiary: #6c757d; + --color-text-inverse: #212529; + + /* Border colors */ + --color-border-primary: #404040; + --color-border-secondary: #525252; + --color-border-focus: #4dabf7; + + /* Status colors */ + --color-success: #51cf66; + --color-success-bg: #1a3d1a; + --color-success-border: #2d5a2d; + + --color-warning: #ffd43b; + --color-warning-bg: #3d3a1a; + --color-warning-border: #5a572d; + + --color-error: #ff6b6b; + --color-error-bg: #3d1a1a; + --color-error-border: #5a2d2d; + + --color-info: #74c0fc; + --color-info-bg: #1a2a3d; + --color-info-border: #2d405a; + + /* Shadow colors */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.3); + --shadow-focus: 0 0 0 3px rgba(77, 171, 247, 0.25); + + /* Log viewer specific */ + --log-bg-debug: #2d2d2d; + --log-bg-info: #1a2a3d; + --log-bg-warn: #3d3a1a; + --log-bg-error: #3d1a1a; + --log-border-debug: #6c757d; + --log-border-info: #74c0fc; + --log-border-warn: #ffd43b; + --log-border-error: #ff6b6b; +} + +/* System theme preference detection */ +@media (prefers-color-scheme: dark) { + :root:not(.theme-light):not([data-theme="light"]) { + /* Auto-apply dark theme variables when system is dark */ + --color-primary: #4dabf7; + --color-primary-hover: #339af0; + --color-primary-light: #1c2a3a; + --color-bg-primary: #1a1a1a; + --color-bg-secondary: #2d2d2d; + --color-bg-tertiary: #404040; + --color-bg-modal: rgba(26, 26, 26, 0.95); + --color-surface: #2d2d2d; + --color-surface-hover: #404040; + --color-surface-active: #525252; + --color-text-primary: #f8f9fa; + --color-text-secondary: #adb5bd; + --color-text-tertiary: #6c757d; + --color-text-inverse: #212529; + --color-border-primary: #404040; + --color-border-secondary: #525252; + --color-border-focus: #4dabf7; + --color-success: #51cf66; + --color-success-bg: #1a3d1a; + --color-success-border: #2d5a2d; + --color-warning: #ffd43b; + --color-warning-bg: #3d3a1a; + --color-warning-border: #5a572d; + --color-error: #ff6b6b; + --color-error-bg: #3d1a1a; + --color-error-border: #5a2d2d; + --color-info: #74c0fc; + --color-info-bg: #1a2a3d; + --color-info-border: #2d405a; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.3); + --shadow-focus: 0 0 0 3px rgba(77, 171, 247, 0.25); + --log-bg-debug: #2d2d2d; + --log-bg-info: #1a2a3d; + --log-bg-warn: #3d3a1a; + --log-bg-error: #3d1a1a; + --log-border-debug: #6c757d; + --log-border-info: #74c0fc; + --log-border-warn: #ffd43b; + --log-border-error: #ff6b6b; + } +} + +/* Base styling for all themed elements */ +* { + transition: var(--theme-transition); +} + +body { + background-color: var(--color-bg-primary); + color: var(--color-text-primary); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; +} + +/* Theme toggle button */ +.theme-toggle { + background: var(--color-surface); + border: 1px solid var(--color-border-primary); + border-radius: 6px; + padding: 8px 12px; + cursor: pointer; + font-size: 16px; + transition: var(--theme-transition); + display: inline-flex; + align-items: center; + gap: 6px; +} + +.theme-toggle:hover { + background: var(--color-surface-hover); + border-color: var(--color-border-focus); +} + +.theme-toggle:focus { + outline: none; + box-shadow: var(--shadow-focus); +} + +.theme-icon { + font-size: 14px; + line-height: 1; +} + +/* Theme selector dropdown */ +.theme-selector { + background: var(--color-surface); + border: 1px solid var(--color-border-primary); + border-radius: 4px; + padding: 6px 8px; + color: var(--color-text-primary); + font-size: 14px; + cursor: pointer; + transition: var(--theme-transition); +} + +.theme-selector:hover { + border-color: var(--color-border-focus); +} + +.theme-selector:focus { + outline: none; + border-color: var(--color-border-focus); + box-shadow: var(--shadow-focus); +} + +/* Common form elements theming */ +input, textarea, select, button { + background: var(--color-surface); + border: 1px solid var(--color-border-primary); + color: var(--color-text-primary); + transition: var(--theme-transition); +} + +input:focus, textarea:focus, select:focus { + border-color: var(--color-border-focus); + box-shadow: var(--shadow-focus); + outline: none; +} + +button { + cursor: pointer; +} + +button:hover { + background: var(--color-surface-hover); +} + +button.primary { + background: var(--color-primary); + color: var(--color-text-inverse); + border-color: var(--color-primary); +} + +button.primary:hover { + background: var(--color-primary-hover); + border-color: var(--color-primary-hover); +} + +/* Status message theming */ +.status.success { + background: var(--color-success-bg); + color: var(--color-success); + border-color: var(--color-success-border); +} + +.status.error { + background: var(--color-error-bg); + color: var(--color-error); + border-color: var(--color-error-border); +} + +.status.warning { + background: var(--color-warning-bg); + color: var(--color-warning); + border-color: var(--color-warning-border); +} + +.status.info { + background: var(--color-info-bg); + color: var(--color-info); + border-color: var(--color-info-border); +} + +/* Scrollbar theming */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--color-bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--color-border-primary); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-text-tertiary); +} + +/* Selection theming */ +::selection { + background: var(--color-primary-light); + color: var(--color-text-primary); +} \ No newline at end of file diff --git a/apps/web-clipper-manifestv3/src/shared/theme.ts b/apps/web-clipper-manifestv3/src/shared/theme.ts new file mode 100644 index 000000000..294606d47 --- /dev/null +++ b/apps/web-clipper-manifestv3/src/shared/theme.ts @@ -0,0 +1,238 @@ +/** + * Theme management system for the extension + * Supports light, dark, and system (auto) themes + */ + +export type ThemeMode = 'light' | 'dark' | 'system'; + +export interface ThemeConfig { + mode: ThemeMode; + followSystem: boolean; +} + +/** + * Theme Manager - Handles theme switching and persistence + */ +export class ThemeManager { + private static readonly STORAGE_KEY = 'theme_config'; + private static readonly DEFAULT_CONFIG: ThemeConfig = { + mode: 'system', + followSystem: true, + }; + + private static listeners: Array<(theme: 'light' | 'dark') => void> = []; + private static mediaQuery: MediaQueryList | null = null; + + /** + * Initialize the theme system + */ + static async initialize(): Promise { + const config = await this.getThemeConfig(); + await this.applyTheme(config); + this.setupSystemThemeListener(); + } + + /** + * Get current theme configuration + */ + static async getThemeConfig(): Promise { + try { + const result = await chrome.storage.sync.get(this.STORAGE_KEY); + return { ...this.DEFAULT_CONFIG, ...result[this.STORAGE_KEY] }; + } catch (error) { + console.warn('Failed to load theme config, using defaults:', error); + return this.DEFAULT_CONFIG; + } + } + + /** + * Set theme configuration + */ + static async setThemeConfig(config: Partial): Promise { + try { + const currentConfig = await this.getThemeConfig(); + const newConfig = { ...currentConfig, ...config }; + + await chrome.storage.sync.set({ [this.STORAGE_KEY]: newConfig }); + await this.applyTheme(newConfig); + } catch (error) { + console.error('Failed to save theme config:', error); + throw error; + } + } + + /** + * Apply theme to the current page + */ + static async applyTheme(config: ThemeConfig): Promise { + const effectiveTheme = this.getEffectiveTheme(config); + + // Apply theme to document + this.applyThemeToDocument(effectiveTheme); + + // Notify listeners + this.notifyListeners(effectiveTheme); + } + + /** + * Get the effective theme (resolves 'system' to 'light' or 'dark') + */ + static getEffectiveTheme(config: ThemeConfig): 'light' | 'dark' { + if (config.mode === 'system' || config.followSystem) { + return this.getSystemTheme(); + } + return config.mode === 'dark' ? 'dark' : 'light'; + } + + /** + * Get system theme preference + */ + static getSystemTheme(): 'light' | 'dark' { + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return 'light'; // Default fallback + } + + /** + * Apply theme classes to document + */ + static applyThemeToDocument(theme: 'light' | 'dark'): void { + const html = document.documentElement; + + // Remove existing theme classes + html.classList.remove('theme-light', 'theme-dark'); + + // Add current theme class + html.classList.add(`theme-${theme}`); + + // Set data attribute for CSS targeting + html.setAttribute('data-theme', theme); + } + + /** + * Toggle between light, dark, and system themes + */ + static async toggleTheme(): Promise { + const config = await this.getThemeConfig(); + + let newMode: ThemeMode; + let followSystem: boolean; + + if (config.followSystem || config.mode === 'system') { + // System -> Light + newMode = 'light'; + followSystem = false; + } else if (config.mode === 'light') { + // Light -> Dark + newMode = 'dark'; + followSystem = false; + } else { + // Dark -> System + newMode = 'system'; + followSystem = true; + } + + await this.setThemeConfig({ + mode: newMode, + followSystem + }); + } + + /** + * Set to follow system theme + */ + static async followSystem(): Promise { + await this.setThemeConfig({ + mode: 'system', + followSystem: true + }); + } + + /** + * Setup system theme change listener + */ + private static setupSystemThemeListener(): void { + if (typeof window === 'undefined' || !window.matchMedia) { + return; + } + + this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleSystemThemeChange = async (): Promise => { + const config = await this.getThemeConfig(); + if (config.followSystem || config.mode === 'system') { + await this.applyTheme(config); + } + }; + + // Modern browsers + if (this.mediaQuery.addEventListener) { + this.mediaQuery.addEventListener('change', handleSystemThemeChange); + } else { + // Fallback for older browsers + this.mediaQuery.addListener(handleSystemThemeChange); + } + } + + /** + * Add theme change listener + */ + static addThemeListener(callback: (theme: 'light' | 'dark') => void): () => void { + this.listeners.push(callback); + + // Return unsubscribe function + return () => { + const index = this.listeners.indexOf(callback); + if (index > -1) { + this.listeners.splice(index, 1); + } + }; + } + + /** + * Notify all listeners of theme change + */ + private static notifyListeners(theme: 'light' | 'dark'): void { + this.listeners.forEach(callback => { + try { + callback(theme); + } catch (error) { + console.error('Theme listener error:', error); + } + }); + } + + /** + * Get current effective theme without config lookup + */ + static getCurrentTheme(): 'light' | 'dark' { + const html = document.documentElement; + return html.classList.contains('theme-dark') ? 'dark' : 'light'; + } + + /** + * Create theme toggle button + */ + static createThemeToggle(): HTMLButtonElement { + const button = document.createElement('button'); + button.className = 'theme-toggle'; + button.title = 'Toggle theme'; + button.setAttribute('aria-label', 'Toggle between light and dark theme'); + + const updateButton = (theme: 'light' | 'dark') => { + button.innerHTML = theme === 'dark' + ? '' + : ''; + }; // Set initial state + updateButton(this.getCurrentTheme()); + + // Add click handler + button.addEventListener('click', () => this.toggleTheme()); + + // Listen for theme changes + this.addThemeListener(updateButton); + + return button; + } +}