experiments in a post-browser web
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 };