mirror of
https://github.com/zadam/trilium.git
synced 2025-12-06 15:34:26 +01:00
feat: implement comprehensive theme system
Features: - ThemeManager with three modes (light/dark/system) - CSS custom properties for semantic colors - Persistent storage via chrome.storage.sync - Real-time OS theme detection and updates - Event subscription system for theme changes Provides professional theming across all UI components. System mode automatically follows OS preference.
This commit is contained in:
parent
6811b91a17
commit
c28add177e
334
apps/web-clipper-manifestv3/src/shared/theme.css
Normal file
334
apps/web-clipper-manifestv3/src/shared/theme.css
Normal file
@ -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);
|
||||
}
|
||||
238
apps/web-clipper-manifestv3/src/shared/theme.ts
Normal file
238
apps/web-clipper-manifestv3/src/shared/theme.ts
Normal file
@ -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<void> {
|
||||
const config = await this.getThemeConfig();
|
||||
await this.applyTheme(config);
|
||||
this.setupSystemThemeListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current theme configuration
|
||||
*/
|
||||
static async getThemeConfig(): Promise<ThemeConfig> {
|
||||
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<ThemeConfig>): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> => {
|
||||
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'
|
||||
? '<span class="theme-icon">☀</span>'
|
||||
: '<span class="theme-icon">☽</span>';
|
||||
}; // Set initial state
|
||||
updateButton(this.getCurrentTheme());
|
||||
|
||||
// Add click handler
|
||||
button.addEventListener('click', () => this.toggleTheme());
|
||||
|
||||
// Listen for theme changes
|
||||
this.addThemeListener(updateButton);
|
||||
|
||||
return button;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user