experiments in a post-browser web
at main 432 lines 12 kB view raw
1/** 2 * Peek Theme System 3 * 4 * Theme registration, switching, and token inheritance for extensions. 5 * 6 * Usage: 7 * import { registerTheme, setTheme, getTheme, ThemeMixin } from 'peek://app/components/theme.js'; 8 * 9 * // Register a custom theme 10 * registerTheme('dark', { 11 * 'theme-bg': '#1a1a1a', 12 * 'theme-text': '#ffffff', 13 * 'theme-accent': '#4dabf7' 14 * }); 15 * 16 * // Switch themes 17 * setTheme('dark'); 18 * 19 * // Extend base theme with custom tokens 20 * registerTheme('brand', { 21 * 'theme-accent': '#ff6b35', 22 * 'peek-btn-radius': '999px' 23 * }, { extends: 'light' }); 24 */ 25 26// Default theme tokens 27const DEFAULT_TOKENS = { 28 // Theme colors 29 'theme-bg': '#ffffff', 30 'theme-bg-secondary': '#fafafa', 31 'theme-bg-tertiary': '#f5f5f5', 32 'theme-text': '#333333', 33 'theme-text-secondary': '#666666', 34 'theme-text-muted': '#999999', 35 'theme-accent': '#007aff', 36 'theme-accent-hover': '#0056b3', 37 'theme-border': '#e0e0e0', 38 'theme-danger': '#dc3545', 39 'theme-success': '#28a745', 40 'theme-warning': '#ffc107', 41 42 // Component spacing 43 'peek-space-xs': '4px', 44 'peek-space-sm': '8px', 45 'peek-space-md': '12px', 46 'peek-space-lg': '16px', 47 'peek-space-xl': '24px', 48 49 // Border radius 50 'peek-radius-sm': '4px', 51 'peek-radius-md': '6px', 52 'peek-radius-lg': '8px', 53 54 // Typography 55 'peek-font-sm': '13px', 56 'peek-font-md': '14px', 57 'peek-font-lg': '16px', 58 'peek-font-medium': '500', 59 'peek-font-semibold': '600', 60 61 // Shadows 62 'peek-shadow-sm': '0 1px 2px rgba(0, 0, 0, 0.05)', 63 'peek-shadow-md': '0 2px 4px rgba(0, 0, 0, 0.1)', 64 'peek-shadow-lg': '0 4px 12px rgba(0, 0, 0, 0.15)', 65 66 // Transitions 67 'peek-transition-fast': '100ms ease', 68 'peek-transition-normal': '150ms ease', 69 70 // Button heights 71 'peek-btn-height-sm': '28px', 72 'peek-btn-height-md': '36px', 73 'peek-btn-height-lg': '44px', 74 75 // Focus ring 76 'peek-focus-ring': '0 0 0 2px rgba(0, 122, 255, 0.3)', 77 78 // Glass-morphism tokens (light mode) 79 'peek-glass-blur': '10px', 80 'peek-glass-blur-heavy': '20px', 81 'peek-glass-bg': 'rgba(255, 255, 255, 0.7)', 82 'peek-glass-bg-hover': 'rgba(255, 255, 255, 0.8)', 83 'peek-glass-bg-active': 'rgba(255, 255, 255, 0.9)', 84 'peek-glass-border': 'rgba(0, 0, 0, 0.1)', 85 'peek-glass-border-hover': 'rgba(0, 0, 0, 0.15)', 86 'peek-glass-shadow': '0 8px 24px rgba(0, 0, 0, 0.15)', 87 'peek-glass-text': '#333333', 88 'peek-glass-text-secondary': 'rgba(0, 0, 0, 0.6)', 89 'peek-glass-text-muted': 'rgba(0, 0, 0, 0.4)', 90 'peek-overlay-bg': 'rgba(255, 255, 255, 0.85)', 91 'peek-overlay-bg-light': 'rgba(255, 255, 255, 0.85)' 92}; 93 94// Built-in dark theme 95const DARK_TOKENS = { 96 'theme-bg': '#1a1a1a', 97 'theme-bg-secondary': '#242424', 98 'theme-bg-tertiary': '#2e2e2e', 99 'theme-text': '#ffffff', 100 'theme-text-secondary': '#b0b0b0', 101 'theme-text-muted': '#808080', 102 'theme-border': '#3a3a3a', 103 'peek-shadow-sm': '0 1px 2px rgba(0, 0, 0, 0.3)', 104 'peek-shadow-md': '0 2px 4px rgba(0, 0, 0, 0.4)', 105 'peek-shadow-lg': '0 4px 12px rgba(0, 0, 0, 0.5)', 106 107 // Glass-morphism tokens (dark mode) 108 'peek-glass-bg': 'rgba(255, 255, 255, 0.1)', 109 'peek-glass-bg-hover': 'rgba(255, 255, 255, 0.15)', 110 'peek-glass-bg-active': 'rgba(255, 255, 255, 0.2)', 111 'peek-glass-border': 'rgba(255, 255, 255, 0.1)', 112 'peek-glass-border-hover': 'rgba(255, 255, 255, 0.2)', 113 'peek-glass-shadow': '0 8px 24px rgba(0, 0, 0, 0.3)', 114 'peek-glass-text': 'white', 115 'peek-glass-text-secondary': 'rgba(255, 255, 255, 0.6)', 116 'peek-glass-text-muted': 'rgba(255, 255, 255, 0.4)', 117 'peek-overlay-bg': 'rgba(0, 0, 0, 0.85)', 118 'peek-overlay-bg-light': 'rgba(255, 255, 255, 0.85)' 119}; 120 121// Theme registry 122const themes = new Map(); 123themes.set('light', { tokens: { ...DEFAULT_TOKENS }, extends: null }); 124themes.set('dark', { tokens: { ...DARK_TOKENS }, extends: 'light' }); 125 126// Current theme state 127let currentTheme = 'light'; 128const themeListeners = new Set(); 129 130/** 131 * Register a custom theme 132 * @param {string} name - Theme name 133 * @param {Object} tokens - CSS custom property values (without -- prefix) 134 * @param {Object} options - Options 135 * @param {string} options.extends - Base theme to extend 136 */ 137export function registerTheme(name, tokens, options = {}) { 138 if (typeof name !== 'string' || !name) { 139 throw new Error('Theme name must be a non-empty string'); 140 } 141 142 const { extends: baseTheme = 'light' } = options; 143 144 // Validate base theme exists 145 if (baseTheme && !themes.has(baseTheme)) { 146 throw new Error(`Base theme '${baseTheme}' does not exist`); 147 } 148 149 themes.set(name, { 150 tokens: { ...tokens }, 151 extends: baseTheme 152 }); 153 154 // If this is the current theme, re-apply it 155 if (currentTheme === name) { 156 applyTheme(name); 157 } 158 159 return true; 160} 161 162/** 163 * Unregister a theme 164 * @param {string} name - Theme name (cannot unregister built-in themes) 165 */ 166export function unregisterTheme(name) { 167 if (name === 'light' || name === 'dark') { 168 throw new Error('Cannot unregister built-in themes'); 169 } 170 171 if (currentTheme === name) { 172 setTheme('light'); 173 } 174 175 return themes.delete(name); 176} 177 178/** 179 * Get all registered theme names 180 * @returns {string[]} 181 */ 182export function getThemeNames() { 183 return Array.from(themes.keys()); 184} 185 186/** 187 * Get the current theme name 188 * @returns {string} 189 */ 190export function getTheme() { 191 return currentTheme; 192} 193 194/** 195 * Get resolved tokens for a theme (with inheritance) 196 * @param {string} name - Theme name 197 * @returns {Object} Resolved token values 198 */ 199export function getThemeTokens(name) { 200 const theme = themes.get(name); 201 if (!theme) { 202 throw new Error(`Theme '${name}' does not exist`); 203 } 204 205 // Build token chain from inheritance 206 const tokenChain = []; 207 let current = name; 208 209 while (current) { 210 const t = themes.get(current); 211 if (t) { 212 tokenChain.unshift(t.tokens); 213 current = t.extends; 214 } else { 215 break; 216 } 217 } 218 219 // Merge tokens (later overrides earlier) 220 return Object.assign({}, ...tokenChain); 221} 222 223/** 224 * Set and apply a theme 225 * @param {string} name - Theme name 226 * @param {Element} target - Target element (default: document.documentElement) 227 */ 228export function setTheme(name, target = document.documentElement) { 229 if (!themes.has(name)) { 230 throw new Error(`Theme '${name}' does not exist`); 231 } 232 233 const previousTheme = currentTheme; 234 currentTheme = name; 235 236 applyTheme(name, target); 237 238 // Notify listeners 239 themeListeners.forEach(listener => { 240 try { 241 listener({ theme: name, previousTheme }); 242 } catch (e) { 243 console.error('Theme listener error:', e); 244 } 245 }); 246} 247 248/** 249 * Apply theme tokens to a target element 250 * @param {string} name - Theme name 251 * @param {Element} target - Target element 252 */ 253export function applyTheme(name, target = document.documentElement) { 254 const tokens = getThemeTokens(name); 255 256 Object.entries(tokens).forEach(([key, value]) => { 257 target.style.setProperty(`--${key}`, value); 258 }); 259 260 // Set data attribute for CSS selectors 261 target.dataset.theme = name; 262} 263 264/** 265 * Remove theme from a target element 266 * @param {Element} target - Target element 267 */ 268export function clearTheme(target = document.documentElement) { 269 const tokens = getThemeTokens(currentTheme); 270 271 Object.keys(tokens).forEach(key => { 272 target.style.removeProperty(`--${key}`); 273 }); 274 275 delete target.dataset.theme; 276} 277 278/** 279 * Subscribe to theme changes 280 * @param {Function} listener - Callback: ({ theme, previousTheme }) => void 281 * @returns {Function} Unsubscribe function 282 */ 283export function onThemeChange(listener) { 284 themeListeners.add(listener); 285 return () => themeListeners.delete(listener); 286} 287 288/** 289 * Get a specific token value 290 * @param {string} token - Token name (without -- prefix) 291 * @param {string} theme - Theme name (default: current theme) 292 * @returns {string|undefined} 293 */ 294export function getToken(token, theme = currentTheme) { 295 const tokens = getThemeTokens(theme); 296 return tokens[token]; 297} 298 299/** 300 * Set a single token value at runtime 301 * @param {string} token - Token name (without -- prefix) 302 * @param {string} value - Token value 303 * @param {Element} target - Target element 304 */ 305export function setToken(token, value, target = document.documentElement) { 306 target.style.setProperty(`--${token}`, value); 307} 308 309/** 310 * Generate CSS string for a theme 311 * @param {string} name - Theme name 312 * @param {string} selector - CSS selector (default: ':root') 313 * @returns {string} CSS string 314 */ 315export function generateThemeCSS(name, selector = ':root') { 316 const tokens = getThemeTokens(name); 317 const props = Object.entries(tokens) 318 .map(([key, value]) => ` --${key}: ${value};`) 319 .join('\n'); 320 321 return `${selector} {\n${props}\n}`; 322} 323 324/** 325 * Inject theme CSS into a document or shadow root 326 * Useful for content scripts and isolated contexts 327 * @param {string} name - Theme name 328 * @param {Document|ShadowRoot} root - Target root 329 * @param {string} selector - CSS selector 330 * @returns {HTMLStyleElement} The injected style element 331 */ 332export function injectThemeCSS(name, root = document, selector = ':root') { 333 const css = generateThemeCSS(name, selector); 334 const style = root.createElement ? root.createElement('style') : document.createElement('style'); 335 style.textContent = css; 336 style.dataset.peekTheme = name; 337 338 const target = root.head || root; 339 target.appendChild(style); 340 341 return style; 342} 343 344/** 345 * Create a scoped theme for a specific element 346 * @param {Element} element - Target element 347 * @param {Object} tokens - Token overrides 348 * @returns {Function} Cleanup function 349 */ 350export function scopedTheme(element, tokens) { 351 Object.entries(tokens).forEach(([key, value]) => { 352 element.style.setProperty(`--${key}`, value); 353 }); 354 355 return () => { 356 Object.keys(tokens).forEach(key => { 357 element.style.removeProperty(`--${key}`); 358 }); 359 }; 360} 361 362/** 363 * Mixin for components that need theme awareness 364 * @param {Class} Base - Base class to extend 365 * @returns {Class} Extended class with theme support 366 */ 367export function ThemeMixin(Base) { 368 return class extends Base { 369 constructor() { 370 super(); 371 this._themeUnsubscribe = null; 372 } 373 374 connectedCallback() { 375 super.connectedCallback?.(); 376 this._themeUnsubscribe = onThemeChange(() => this.requestUpdate?.()); 377 } 378 379 disconnectedCallback() { 380 super.disconnectedCallback?.(); 381 this._themeUnsubscribe?.(); 382 this._themeUnsubscribe = null; 383 } 384 385 get currentTheme() { 386 return getTheme(); 387 } 388 389 getToken(name) { 390 return getToken(name); 391 } 392 }; 393} 394 395/** 396 * Detect system color scheme preference 397 * @returns {'light' | 'dark'} 398 */ 399export function getSystemTheme() { 400 if (typeof window !== 'undefined' && window.matchMedia) { 401 return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 402 } 403 return 'light'; 404} 405 406/** 407 * Auto-switch theme based on system preference 408 * @returns {Function} Cleanup function 409 */ 410export function followSystemTheme() { 411 if (typeof window === 'undefined' || !window.matchMedia) { 412 return () => {}; 413 } 414 415 const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 416 417 const handler = (e) => { 418 setTheme(e.matches ? 'dark' : 'light'); 419 }; 420 421 // Set initial theme 422 handler(mediaQuery); 423 424 // Listen for changes 425 mediaQuery.addEventListener('change', handler); 426 427 return () => mediaQuery.removeEventListener('change', handler); 428} 429 430// Export default tokens for reference 431export const defaultTokens = { ...DEFAULT_TOKENS }; 432export const darkTokens = { ...DARK_TOKENS };