experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(components): add Phase 4.2 theme and extension systems

- theme.js: Theme registration, switching, token inheritance
- Built-in light/dark themes
- Custom theme registration with extends option
- System theme detection and auto-follow
- Token getters/setters for runtime customization
- ThemeMixin for theme-aware components
- extension.js: Extension loader for content scripts/popups
- ExtensionContext for managing extension resources
- Shadow DOM container creation for style isolation
- CSS injection helpers
- initContentScript/initPopup quick-setup helpers
- Update index.js with theme and extension exports
- Document theme and extension APIs in README

+890
+168
app/components/README.md
··· 585 585 586 586 --- 587 587 588 + ## Theme System 589 + 590 + Dynamic theme registration, switching, and token inheritance. 591 + 592 + ```javascript 593 + import { 594 + registerTheme, setTheme, getTheme, 595 + onThemeChange, followSystemTheme, 596 + ThemeMixin 597 + } from 'peek://app/components/theme.js'; 598 + 599 + // Built-in themes: 'light', 'dark' 600 + setTheme('dark'); 601 + 602 + // Register custom theme (extends light by default) 603 + registerTheme('brand', { 604 + 'theme-accent': '#ff6b35', 605 + 'theme-bg': '#fefefe', 606 + 'peek-radius-md': '12px' 607 + }); 608 + 609 + // Extend a specific theme 610 + registerTheme('brand-dark', { 611 + 'theme-accent': '#ff8c5a' 612 + }, { extends: 'dark' }); 613 + 614 + // Listen for theme changes 615 + const unsubscribe = onThemeChange(({ theme, previousTheme }) => { 616 + console.log(`Theme changed from ${previousTheme} to ${theme}`); 617 + }); 618 + 619 + // Auto-follow system preference (light/dark) 620 + const stopFollowing = followSystemTheme(); 621 + 622 + // Get/set individual tokens 623 + import { getToken, setToken } from 'peek://app/components/theme.js'; 624 + const accent = getToken('theme-accent'); 625 + setToken('theme-accent', '#00ff00'); 626 + ``` 627 + 628 + ### Theme API 629 + 630 + | Function | Description | 631 + |----------|-------------| 632 + | `registerTheme(name, tokens, options)` | Register a custom theme | 633 + | `setTheme(name, target?)` | Switch to a theme | 634 + | `getTheme()` | Get current theme name | 635 + | `getThemeTokens(name)` | Get resolved tokens (with inheritance) | 636 + | `getToken(name)` | Get single token value | 637 + | `setToken(name, value, target?)` | Set single token at runtime | 638 + | `onThemeChange(listener)` | Subscribe to theme changes | 639 + | `followSystemTheme()` | Auto-switch based on OS preference | 640 + | `getSystemTheme()` | Get OS color scheme preference | 641 + | `generateThemeCSS(name, selector?)` | Generate CSS string | 642 + | `injectThemeCSS(name, root, selector?)` | Inject theme into document/shadow | 643 + | `scopedTheme(element, tokens)` | Apply tokens to specific element | 644 + | `ThemeMixin(Base)` | Mixin for theme-aware components | 645 + 646 + ### Available Tokens 647 + 648 + See [Design Tokens](#design-tokens) for the full list. Key theme tokens: 649 + 650 + | Token | Description | 651 + |-------|-------------| 652 + | `theme-bg` | Primary background | 653 + | `theme-bg-secondary` | Secondary background | 654 + | `theme-bg-tertiary` | Tertiary background | 655 + | `theme-text` | Primary text color | 656 + | `theme-text-secondary` | Secondary text | 657 + | `theme-text-muted` | Muted text | 658 + | `theme-accent` | Accent/brand color | 659 + | `theme-border` | Border color | 660 + | `theme-danger` | Danger/error color | 661 + | `theme-success` | Success color | 662 + | `theme-warning` | Warning color | 663 + 664 + --- 665 + 666 + ## Extension System 667 + 668 + Tools for extensions to register, inject styles, and create isolated component containers. 669 + 670 + ```javascript 671 + import { 672 + registerExtension, 673 + initContentScript, 674 + initPopup 675 + } from 'peek://app/components/extension.js'; 676 + 677 + // Register extension with custom theme 678 + const ext = registerExtension('my-extension', { 679 + theme: { 680 + 'theme-accent': '#9b59b6', 681 + 'peek-card-bg': '#f8f8f8' 682 + } 683 + }); 684 + 685 + // Inject styles into document 686 + ext.injectStyles(document); 687 + 688 + // Create isolated shadow DOM container 689 + const container = ext.createContainer(); 690 + container.innerHTML = '<peek-card>...</peek-card>'; 691 + 692 + // Clean up when done 693 + ext.destroy(); 694 + ``` 695 + 696 + ### Content Script Usage 697 + 698 + ```javascript 699 + import { initContentScript } from 'peek://app/components/extension.js'; 700 + 701 + // One-call setup for content scripts 702 + const { container, context, destroy } = initContentScript({ 703 + id: 'my-content-script', 704 + theme: { 'theme-accent': '#e74c3c' }, 705 + parent: document.body, 706 + render: (shadow) => { 707 + shadow.innerHTML = ` 708 + <peek-card> 709 + <span slot="header">Injected Card</span> 710 + <p>Content script UI</p> 711 + </peek-card> 712 + `; 713 + } 714 + }); 715 + 716 + // Later: cleanup 717 + destroy(); 718 + ``` 719 + 720 + ### Popup/Sidebar Usage 721 + 722 + ```javascript 723 + import { initPopup } from 'peek://app/components/extension.js'; 724 + 725 + // Initialize popup with theming 726 + const context = initPopup({ 727 + id: 'my-popup', 728 + theme: { 'theme-accent': '#3498db' } 729 + }); 730 + ``` 731 + 732 + ### Extension API 733 + 734 + | Function | Description | 735 + |----------|-------------| 736 + | `registerExtension(id, options)` | Register extension, get context | 737 + | `getExtension(id)` | Get existing extension context | 738 + | `unregisterExtension(id)` | Unregister and cleanup | 739 + | `initContentScript(config)` | Quick setup for content scripts | 740 + | `initPopup(config)` | Quick setup for popups/sidebars | 741 + | `injectStyles(root, options)` | Inject component styles | 742 + | `createContainer(options)` | Create isolated container | 743 + 744 + ### ExtensionContext Methods 745 + 746 + | Method | Description | 747 + |--------|-------------| 748 + | `injectStyles(root, options)` | Inject styles into document/shadow | 749 + | `createContainer(options)` | Create scoped shadow DOM container | 750 + | `setToken(name, value)` | Override token in all containers | 751 + | `getTokens()` | Get resolved theme tokens | 752 + | `destroy()` | Clean up all resources | 753 + 754 + --- 755 + 588 756 ## Complex Components 589 757 590 758 ### `<peek-carousel>`
+298
app/components/extension.js
··· 1 + /** 2 + * Peek Extension System 3 + * 4 + * Extension registration, component loading, and CSS injection for content scripts. 5 + * 6 + * Usage: 7 + * import { registerExtension, injectStyles, ExtensionContext } from 'peek://app/components/extension.js'; 8 + * 9 + * // Register an extension with custom theme tokens 10 + * const ext = registerExtension('my-extension', { 11 + * theme: { 12 + * 'theme-accent': '#ff6b35', 13 + * 'peek-card-bg': '#fafafa' 14 + * } 15 + * }); 16 + * 17 + * // Inject component styles into content script context 18 + * ext.injectStyles(document); 19 + * 20 + * // Create scoped component container 21 + * const container = ext.createContainer(); 22 + */ 23 + 24 + import { registerTheme, getThemeTokens, generateThemeCSS, getTheme } from './theme.js'; 25 + 26 + // Extension registry 27 + const extensions = new Map(); 28 + 29 + // Component stylesheets cache 30 + const componentStyles = new Map(); 31 + 32 + /** 33 + * Register component styles for injection 34 + * Called internally by components or manually for custom components 35 + * @param {string} name - Component name (e.g., 'peek-button') 36 + * @param {string} css - Component CSS 37 + */ 38 + export function registerComponentStyles(name, css) { 39 + componentStyles.set(name, css); 40 + } 41 + 42 + /** 43 + * Get all registered component styles 44 + * @returns {Map<string, string>} 45 + */ 46 + export function getComponentStyles() { 47 + return new Map(componentStyles); 48 + } 49 + 50 + /** 51 + * Generate combined CSS for all components 52 + * @param {string[]} components - Optional specific components (default: all) 53 + * @returns {string} 54 + */ 55 + export function generateComponentCSS(components = null) { 56 + const styles = []; 57 + 58 + if (components) { 59 + components.forEach(name => { 60 + const css = componentStyles.get(name); 61 + if (css) styles.push(css); 62 + }); 63 + } else { 64 + componentStyles.forEach(css => styles.push(css)); 65 + } 66 + 67 + return styles.join('\n\n'); 68 + } 69 + 70 + /** 71 + * Extension context for managing extension-specific resources 72 + */ 73 + export class ExtensionContext { 74 + constructor(id, options = {}) { 75 + this.id = id; 76 + this.options = options; 77 + this._injectedStyles = new Set(); 78 + this._containers = new Set(); 79 + this._themeId = `${id}-theme`; 80 + 81 + // Register extension-specific theme if provided 82 + if (options.theme) { 83 + registerTheme(this._themeId, options.theme, { 84 + extends: options.extendsTheme || getTheme() 85 + }); 86 + } 87 + } 88 + 89 + /** 90 + * Inject Peek component styles into a document or shadow root 91 + * @param {Document|ShadowRoot} root - Target root 92 + * @param {Object} options - Injection options 93 + * @param {string[]} options.components - Specific components to inject 94 + * @param {boolean} options.includeTheme - Include theme CSS (default: true) 95 + * @returns {HTMLStyleElement} The injected style element 96 + */ 97 + injectStyles(root, options = {}) { 98 + const { components = null, includeTheme = true } = options; 99 + 100 + const styles = []; 101 + 102 + // Add theme CSS 103 + if (includeTheme) { 104 + const themeId = this.options.theme ? this._themeId : getTheme(); 105 + styles.push(generateThemeCSS(themeId, ':host, :root')); 106 + } 107 + 108 + // Add component CSS 109 + styles.push(generateComponentCSS(components)); 110 + 111 + // Create and inject style element 112 + const doc = root.ownerDocument || root; 113 + const style = doc.createElement('style'); 114 + style.textContent = styles.join('\n\n'); 115 + style.dataset.peekExtension = this.id; 116 + 117 + const target = root.head || root; 118 + target.appendChild(style); 119 + 120 + this._injectedStyles.add(style); 121 + return style; 122 + } 123 + 124 + /** 125 + * Create a scoped container for Peek components 126 + * Useful for content scripts to isolate styles 127 + * @param {Object} options - Container options 128 + * @param {boolean} options.useShadow - Use Shadow DOM for isolation (default: true) 129 + * @param {Element} options.parent - Parent element (default: document.body) 130 + * @returns {Element} The container element (shadow root if useShadow) 131 + */ 132 + createContainer(options = {}) { 133 + const { useShadow = true, parent = document.body } = options; 134 + 135 + const wrapper = document.createElement('div'); 136 + wrapper.dataset.peekExtension = this.id; 137 + wrapper.style.cssText = 'all: initial;'; // Reset inherited styles 138 + 139 + if (useShadow) { 140 + const shadow = wrapper.attachShadow({ mode: 'open' }); 141 + this.injectStyles(shadow); 142 + parent.appendChild(wrapper); 143 + this._containers.add(wrapper); 144 + return shadow; 145 + } else { 146 + parent.appendChild(wrapper); 147 + this._containers.add(wrapper); 148 + return wrapper; 149 + } 150 + } 151 + 152 + /** 153 + * Set extension-specific token override 154 + * @param {string} token - Token name 155 + * @param {string} value - Token value 156 + */ 157 + setToken(token, value) { 158 + this._containers.forEach(container => { 159 + const target = container.shadowRoot || container; 160 + target.host?.style.setProperty(`--${token}`, value); 161 + }); 162 + } 163 + 164 + /** 165 + * Get current theme tokens with extension overrides 166 + * @returns {Object} 167 + */ 168 + getTokens() { 169 + const themeId = this.options.theme ? this._themeId : getTheme(); 170 + return getThemeTokens(themeId); 171 + } 172 + 173 + /** 174 + * Clean up all injected styles and containers 175 + */ 176 + destroy() { 177 + this._injectedStyles.forEach(style => style.remove()); 178 + this._injectedStyles.clear(); 179 + 180 + this._containers.forEach(container => container.remove()); 181 + this._containers.clear(); 182 + 183 + extensions.delete(this.id); 184 + } 185 + } 186 + 187 + /** 188 + * Register a new extension 189 + * @param {string} id - Unique extension ID 190 + * @param {Object} options - Extension options 191 + * @param {Object} options.theme - Custom theme tokens 192 + * @param {string} options.extendsTheme - Base theme to extend 193 + * @returns {ExtensionContext} 194 + */ 195 + export function registerExtension(id, options = {}) { 196 + if (extensions.has(id)) { 197 + console.warn(`Extension '${id}' already registered, returning existing context`); 198 + return extensions.get(id); 199 + } 200 + 201 + const context = new ExtensionContext(id, options); 202 + extensions.set(id, context); 203 + return context; 204 + } 205 + 206 + /** 207 + * Get an existing extension context 208 + * @param {string} id - Extension ID 209 + * @returns {ExtensionContext|undefined} 210 + */ 211 + export function getExtension(id) { 212 + return extensions.get(id); 213 + } 214 + 215 + /** 216 + * Unregister an extension and clean up resources 217 + * @param {string} id - Extension ID 218 + */ 219 + export function unregisterExtension(id) { 220 + const ext = extensions.get(id); 221 + if (ext) { 222 + ext.destroy(); 223 + } 224 + } 225 + 226 + /** 227 + * Get all registered extension IDs 228 + * @returns {string[]} 229 + */ 230 + export function getExtensionIds() { 231 + return Array.from(extensions.keys()); 232 + } 233 + 234 + /** 235 + * Quick style injection for simple use cases 236 + * @param {Document|ShadowRoot} root - Target root 237 + * @param {Object} options - Options 238 + * @returns {HTMLStyleElement} 239 + */ 240 + export function injectStyles(root, options = {}) { 241 + const tempContext = new ExtensionContext('_temp_inject', options); 242 + const style = tempContext.injectStyles(root, options); 243 + // Don't track in registry for temp injection 244 + return style; 245 + } 246 + 247 + /** 248 + * Create a standalone scoped container 249 + * @param {Object} options - Container options 250 + * @returns {Element} 251 + */ 252 + export function createContainer(options = {}) { 253 + const tempContext = new ExtensionContext('_temp_container', options); 254 + return tempContext.createContainer(options); 255 + } 256 + 257 + /** 258 + * Content script helper - wraps entire injection workflow 259 + * @param {Object} config - Configuration 260 + * @param {string} config.id - Extension ID 261 + * @param {Object} config.theme - Custom theme tokens 262 + * @param {Function} config.render - Render function: (container) => void 263 + * @param {Element} config.parent - Parent element 264 + * @returns {Object} - { container, context, destroy } 265 + */ 266 + export function initContentScript(config) { 267 + const { id, theme, render, parent = document.body } = config; 268 + 269 + const context = registerExtension(id, { theme }); 270 + const container = context.createContainer({ parent }); 271 + 272 + if (render) { 273 + render(container); 274 + } 275 + 276 + return { 277 + container, 278 + context, 279 + destroy: () => context.destroy() 280 + }; 281 + } 282 + 283 + /** 284 + * Popup/sidebar helper - injects styles into popup document 285 + * @param {Object} config - Configuration 286 + * @param {string} config.id - Extension ID 287 + * @param {Object} config.theme - Custom theme tokens 288 + * @param {Document} config.document - Popup document (default: window.document) 289 + * @returns {ExtensionContext} 290 + */ 291 + export function initPopup(config) { 292 + const { id, theme, document: doc = document } = config; 293 + 294 + const context = registerExtension(id, { theme }); 295 + context.injectStyles(doc, { includeTheme: true }); 296 + 297 + return context; 298 + }
+20
app/components/index.js
··· 35 35 // Event bus 36 36 export { bus, on, once, emit, channel, typedEvent, waitFor, EventBusMixin } from './events.js'; 37 37 38 + // Theming 39 + export { 40 + registerTheme, unregisterTheme, getThemeNames, 41 + setTheme, getTheme, applyTheme, clearTheme, 42 + getThemeTokens, getToken, setToken, 43 + generateThemeCSS, injectThemeCSS, scopedTheme, 44 + onThemeChange, ThemeMixin, 45 + getSystemTheme, followSystemTheme, 46 + defaultTokens, darkTokens 47 + } from './theme.js'; 48 + 49 + // Extension system 50 + export { 51 + registerExtension, getExtension, unregisterExtension, getExtensionIds, 52 + ExtensionContext, 53 + registerComponentStyles, getComponentStyles, generateComponentCSS, 54 + injectStyles, createContainer, 55 + initContentScript, initPopup 56 + } from './extension.js'; 57 + 38 58 // Components - Basic 39 59 export { PeekButton } from './peek-button.js'; 40 60 export { PeekCard } from './peek-card.js';
+404
app/components/theme.js
··· 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 27 + const 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 + 79 + // Built-in dark theme 80 + const DARK_TOKENS = { 81 + 'theme-bg': '#1a1a1a', 82 + 'theme-bg-secondary': '#242424', 83 + 'theme-bg-tertiary': '#2e2e2e', 84 + 'theme-text': '#ffffff', 85 + 'theme-text-secondary': '#b0b0b0', 86 + 'theme-text-muted': '#808080', 87 + 'theme-border': '#3a3a3a', 88 + 'peek-shadow-sm': '0 1px 2px rgba(0, 0, 0, 0.3)', 89 + 'peek-shadow-md': '0 2px 4px rgba(0, 0, 0, 0.4)', 90 + 'peek-shadow-lg': '0 4px 12px rgba(0, 0, 0, 0.5)' 91 + }; 92 + 93 + // Theme registry 94 + const themes = new Map(); 95 + themes.set('light', { tokens: { ...DEFAULT_TOKENS }, extends: null }); 96 + themes.set('dark', { tokens: { ...DARK_TOKENS }, extends: 'light' }); 97 + 98 + // Current theme state 99 + let currentTheme = 'light'; 100 + const themeListeners = new Set(); 101 + 102 + /** 103 + * Register a custom theme 104 + * @param {string} name - Theme name 105 + * @param {Object} tokens - CSS custom property values (without -- prefix) 106 + * @param {Object} options - Options 107 + * @param {string} options.extends - Base theme to extend 108 + */ 109 + export function registerTheme(name, tokens, options = {}) { 110 + if (typeof name !== 'string' || !name) { 111 + throw new Error('Theme name must be a non-empty string'); 112 + } 113 + 114 + const { extends: baseTheme = 'light' } = options; 115 + 116 + // Validate base theme exists 117 + if (baseTheme && !themes.has(baseTheme)) { 118 + throw new Error(`Base theme '${baseTheme}' does not exist`); 119 + } 120 + 121 + themes.set(name, { 122 + tokens: { ...tokens }, 123 + extends: baseTheme 124 + }); 125 + 126 + // If this is the current theme, re-apply it 127 + if (currentTheme === name) { 128 + applyTheme(name); 129 + } 130 + 131 + return true; 132 + } 133 + 134 + /** 135 + * Unregister a theme 136 + * @param {string} name - Theme name (cannot unregister built-in themes) 137 + */ 138 + export function unregisterTheme(name) { 139 + if (name === 'light' || name === 'dark') { 140 + throw new Error('Cannot unregister built-in themes'); 141 + } 142 + 143 + if (currentTheme === name) { 144 + setTheme('light'); 145 + } 146 + 147 + return themes.delete(name); 148 + } 149 + 150 + /** 151 + * Get all registered theme names 152 + * @returns {string[]} 153 + */ 154 + export function getThemeNames() { 155 + return Array.from(themes.keys()); 156 + } 157 + 158 + /** 159 + * Get the current theme name 160 + * @returns {string} 161 + */ 162 + export function getTheme() { 163 + return currentTheme; 164 + } 165 + 166 + /** 167 + * Get resolved tokens for a theme (with inheritance) 168 + * @param {string} name - Theme name 169 + * @returns {Object} Resolved token values 170 + */ 171 + export function getThemeTokens(name) { 172 + const theme = themes.get(name); 173 + if (!theme) { 174 + throw new Error(`Theme '${name}' does not exist`); 175 + } 176 + 177 + // Build token chain from inheritance 178 + const tokenChain = []; 179 + let current = name; 180 + 181 + while (current) { 182 + const t = themes.get(current); 183 + if (t) { 184 + tokenChain.unshift(t.tokens); 185 + current = t.extends; 186 + } else { 187 + break; 188 + } 189 + } 190 + 191 + // Merge tokens (later overrides earlier) 192 + return Object.assign({}, ...tokenChain); 193 + } 194 + 195 + /** 196 + * Set and apply a theme 197 + * @param {string} name - Theme name 198 + * @param {Element} target - Target element (default: document.documentElement) 199 + */ 200 + export function setTheme(name, target = document.documentElement) { 201 + if (!themes.has(name)) { 202 + throw new Error(`Theme '${name}' does not exist`); 203 + } 204 + 205 + const previousTheme = currentTheme; 206 + currentTheme = name; 207 + 208 + applyTheme(name, target); 209 + 210 + // Notify listeners 211 + themeListeners.forEach(listener => { 212 + try { 213 + listener({ theme: name, previousTheme }); 214 + } catch (e) { 215 + console.error('Theme listener error:', e); 216 + } 217 + }); 218 + } 219 + 220 + /** 221 + * Apply theme tokens to a target element 222 + * @param {string} name - Theme name 223 + * @param {Element} target - Target element 224 + */ 225 + export function applyTheme(name, target = document.documentElement) { 226 + const tokens = getThemeTokens(name); 227 + 228 + Object.entries(tokens).forEach(([key, value]) => { 229 + target.style.setProperty(`--${key}`, value); 230 + }); 231 + 232 + // Set data attribute for CSS selectors 233 + target.dataset.theme = name; 234 + } 235 + 236 + /** 237 + * Remove theme from a target element 238 + * @param {Element} target - Target element 239 + */ 240 + export function clearTheme(target = document.documentElement) { 241 + const tokens = getThemeTokens(currentTheme); 242 + 243 + Object.keys(tokens).forEach(key => { 244 + target.style.removeProperty(`--${key}`); 245 + }); 246 + 247 + delete target.dataset.theme; 248 + } 249 + 250 + /** 251 + * Subscribe to theme changes 252 + * @param {Function} listener - Callback: ({ theme, previousTheme }) => void 253 + * @returns {Function} Unsubscribe function 254 + */ 255 + export function onThemeChange(listener) { 256 + themeListeners.add(listener); 257 + return () => themeListeners.delete(listener); 258 + } 259 + 260 + /** 261 + * Get a specific token value 262 + * @param {string} token - Token name (without -- prefix) 263 + * @param {string} theme - Theme name (default: current theme) 264 + * @returns {string|undefined} 265 + */ 266 + export function getToken(token, theme = currentTheme) { 267 + const tokens = getThemeTokens(theme); 268 + return tokens[token]; 269 + } 270 + 271 + /** 272 + * Set a single token value at runtime 273 + * @param {string} token - Token name (without -- prefix) 274 + * @param {string} value - Token value 275 + * @param {Element} target - Target element 276 + */ 277 + export function setToken(token, value, target = document.documentElement) { 278 + target.style.setProperty(`--${token}`, value); 279 + } 280 + 281 + /** 282 + * Generate CSS string for a theme 283 + * @param {string} name - Theme name 284 + * @param {string} selector - CSS selector (default: ':root') 285 + * @returns {string} CSS string 286 + */ 287 + export function generateThemeCSS(name, selector = ':root') { 288 + const tokens = getThemeTokens(name); 289 + const props = Object.entries(tokens) 290 + .map(([key, value]) => ` --${key}: ${value};`) 291 + .join('\n'); 292 + 293 + return `${selector} {\n${props}\n}`; 294 + } 295 + 296 + /** 297 + * Inject theme CSS into a document or shadow root 298 + * Useful for content scripts and isolated contexts 299 + * @param {string} name - Theme name 300 + * @param {Document|ShadowRoot} root - Target root 301 + * @param {string} selector - CSS selector 302 + * @returns {HTMLStyleElement} The injected style element 303 + */ 304 + export function injectThemeCSS(name, root = document, selector = ':root') { 305 + const css = generateThemeCSS(name, selector); 306 + const style = root.createElement ? root.createElement('style') : document.createElement('style'); 307 + style.textContent = css; 308 + style.dataset.peekTheme = name; 309 + 310 + const target = root.head || root; 311 + target.appendChild(style); 312 + 313 + return style; 314 + } 315 + 316 + /** 317 + * Create a scoped theme for a specific element 318 + * @param {Element} element - Target element 319 + * @param {Object} tokens - Token overrides 320 + * @returns {Function} Cleanup function 321 + */ 322 + export function scopedTheme(element, tokens) { 323 + Object.entries(tokens).forEach(([key, value]) => { 324 + element.style.setProperty(`--${key}`, value); 325 + }); 326 + 327 + return () => { 328 + Object.keys(tokens).forEach(key => { 329 + element.style.removeProperty(`--${key}`); 330 + }); 331 + }; 332 + } 333 + 334 + /** 335 + * Mixin for components that need theme awareness 336 + * @param {Class} Base - Base class to extend 337 + * @returns {Class} Extended class with theme support 338 + */ 339 + export function ThemeMixin(Base) { 340 + return class extends Base { 341 + constructor() { 342 + super(); 343 + this._themeUnsubscribe = null; 344 + } 345 + 346 + connectedCallback() { 347 + super.connectedCallback?.(); 348 + this._themeUnsubscribe = onThemeChange(() => this.requestUpdate?.()); 349 + } 350 + 351 + disconnectedCallback() { 352 + super.disconnectedCallback?.(); 353 + this._themeUnsubscribe?.(); 354 + this._themeUnsubscribe = null; 355 + } 356 + 357 + get currentTheme() { 358 + return getTheme(); 359 + } 360 + 361 + getToken(name) { 362 + return getToken(name); 363 + } 364 + }; 365 + } 366 + 367 + /** 368 + * Detect system color scheme preference 369 + * @returns {'light' | 'dark'} 370 + */ 371 + export function getSystemTheme() { 372 + if (typeof window !== 'undefined' && window.matchMedia) { 373 + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 374 + } 375 + return 'light'; 376 + } 377 + 378 + /** 379 + * Auto-switch theme based on system preference 380 + * @returns {Function} Cleanup function 381 + */ 382 + export function followSystemTheme() { 383 + if (typeof window === 'undefined' || !window.matchMedia) { 384 + return () => {}; 385 + } 386 + 387 + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 388 + 389 + const handler = (e) => { 390 + setTheme(e.matches ? 'dark' : 'light'); 391 + }; 392 + 393 + // Set initial theme 394 + handler(mediaQuery); 395 + 396 + // Listen for changes 397 + mediaQuery.addEventListener('change', handler); 398 + 399 + return () => mediaQuery.removeEventListener('change', handler); 400 + } 401 + 402 + // Export default tokens for reference 403 + export const defaultTokens = { ...DEFAULT_TOKENS }; 404 + export const darkTokens = { ...DARK_TOKENS };