Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

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

Merge pull request 'feat: expanded theming with custom color themes (#62)' (#130) from feat/expanded-theming into main

scott 7c0f6000 931e6c30

+381
+233
src/theming.ts
··· 1 + /** 2 + * Expanded Theming — custom color themes beyond light/dark. 3 + * 4 + * Pure logic module: theme definitions, CSS variable generation, 5 + * persistence. DOM application handled by the entry points. 6 + */ 7 + 8 + export interface ThemeColors { 9 + /** Primary accent color (OkLCH format) */ 10 + accent: string; 11 + /** Background color */ 12 + bg: string; 13 + /** Surface/card color */ 14 + surface: string; 15 + /** Primary text color */ 16 + text: string; 17 + /** Secondary/muted text */ 18 + textMuted: string; 19 + /** Border color */ 20 + border: string; 21 + } 22 + 23 + export interface Theme { 24 + id: string; 25 + name: string; 26 + base: 'light' | 'dark'; 27 + colors: ThemeColors; 28 + builtIn: boolean; 29 + } 30 + 31 + /** Built-in themes */ 32 + export const BUILT_IN_THEMES: Theme[] = [ 33 + { 34 + id: 'light', 35 + name: 'Light', 36 + base: 'light', 37 + builtIn: true, 38 + colors: { 39 + accent: 'oklch(0.65 0.15 195)', 40 + bg: 'oklch(0.985 0 0)', 41 + surface: 'oklch(0.97 0 0)', 42 + text: 'oklch(0.25 0 0)', 43 + textMuted: 'oklch(0.55 0 0)', 44 + border: 'oklch(0.88 0 0)', 45 + }, 46 + }, 47 + { 48 + id: 'dark', 49 + name: 'Dark', 50 + base: 'dark', 51 + builtIn: true, 52 + colors: { 53 + accent: 'oklch(0.7 0.15 195)', 54 + bg: 'oklch(0.17 0.01 260)', 55 + surface: 'oklch(0.22 0.01 260)', 56 + text: 'oklch(0.9 0 0)', 57 + textMuted: 'oklch(0.6 0 0)', 58 + border: 'oklch(0.32 0.01 260)', 59 + }, 60 + }, 61 + { 62 + id: 'midnight', 63 + name: 'Midnight Blue', 64 + base: 'dark', 65 + builtIn: true, 66 + colors: { 67 + accent: 'oklch(0.7 0.12 250)', 68 + bg: 'oklch(0.15 0.03 260)', 69 + surface: 'oklch(0.2 0.03 260)', 70 + text: 'oklch(0.9 0.01 250)', 71 + textMuted: 'oklch(0.6 0.02 250)', 72 + border: 'oklch(0.3 0.03 260)', 73 + }, 74 + }, 75 + { 76 + id: 'forest', 77 + name: 'Forest', 78 + base: 'dark', 79 + builtIn: true, 80 + colors: { 81 + accent: 'oklch(0.65 0.15 155)', 82 + bg: 'oklch(0.16 0.03 155)', 83 + surface: 'oklch(0.21 0.03 155)', 84 + text: 'oklch(0.9 0.01 155)', 85 + textMuted: 'oklch(0.6 0.02 155)', 86 + border: 'oklch(0.3 0.03 155)', 87 + }, 88 + }, 89 + { 90 + id: 'sepia', 91 + name: 'Sepia', 92 + base: 'light', 93 + builtIn: true, 94 + colors: { 95 + accent: 'oklch(0.6 0.12 60)', 96 + bg: 'oklch(0.95 0.02 80)', 97 + surface: 'oklch(0.92 0.02 80)', 98 + text: 'oklch(0.3 0.03 60)', 99 + textMuted: 'oklch(0.55 0.03 60)', 100 + border: 'oklch(0.85 0.03 80)', 101 + }, 102 + }, 103 + { 104 + id: 'rose', 105 + name: 'Rose', 106 + base: 'light', 107 + builtIn: true, 108 + colors: { 109 + accent: 'oklch(0.65 0.18 350)', 110 + bg: 'oklch(0.98 0.005 350)', 111 + surface: 'oklch(0.96 0.01 350)', 112 + text: 'oklch(0.25 0.02 350)', 113 + textMuted: 'oklch(0.55 0.03 350)', 114 + border: 'oklch(0.88 0.02 350)', 115 + }, 116 + }, 117 + ]; 118 + 119 + const THEME_STORAGE_KEY = 'tools-theme'; 120 + const CUSTOM_THEMES_KEY = 'tools-custom-themes'; 121 + let _themeCounter = 0; 122 + 123 + /** 124 + * Get a built-in theme by ID. 125 + */ 126 + export function getBuiltInTheme(id: string): Theme | null { 127 + return BUILT_IN_THEMES.find(t => t.id === id) || null; 128 + } 129 + 130 + /** 131 + * Generate CSS custom properties from a theme's colors. 132 + */ 133 + export function generateCssVars(colors: ThemeColors): Record<string, string> { 134 + return { 135 + '--color-accent': colors.accent, 136 + '--color-bg': colors.bg, 137 + '--color-surface': colors.surface, 138 + '--color-text': colors.text, 139 + '--color-text-muted': colors.textMuted, 140 + '--color-border': colors.border, 141 + }; 142 + } 143 + 144 + /** 145 + * Create a custom theme based on an existing theme. 146 + */ 147 + export function createCustomTheme( 148 + name: string, 149 + baseThemeId: string, 150 + overrides: Partial<ThemeColors>, 151 + ): Theme { 152 + const base = getBuiltInTheme(baseThemeId) || BUILT_IN_THEMES[0]; 153 + return { 154 + id: `custom-${Date.now()}-${++_themeCounter}`, 155 + name, 156 + base: base.base, 157 + builtIn: false, 158 + colors: { ...base.colors, ...overrides }, 159 + }; 160 + } 161 + 162 + /** 163 + * Load the saved theme ID from localStorage. 164 + */ 165 + export function loadSavedThemeId(): string { 166 + if (typeof localStorage === 'undefined') return 'light'; 167 + return localStorage.getItem(THEME_STORAGE_KEY) || 'light'; 168 + } 169 + 170 + /** 171 + * Save the active theme ID to localStorage. 172 + */ 173 + export function saveThemeId(themeId: string): void { 174 + if (typeof localStorage === 'undefined') return; 175 + localStorage.setItem(THEME_STORAGE_KEY, themeId); 176 + } 177 + 178 + /** 179 + * Load custom themes from localStorage. 180 + */ 181 + export function loadCustomThemes(): Theme[] { 182 + if (typeof localStorage === 'undefined') return []; 183 + try { 184 + const raw = localStorage.getItem(CUSTOM_THEMES_KEY); 185 + if (!raw) return []; 186 + const parsed = JSON.parse(raw); 187 + if (!Array.isArray(parsed)) return []; 188 + return parsed.filter( 189 + (t: any) => t && t.id && t.name && t.colors, 190 + ); 191 + } catch { 192 + return []; 193 + } 194 + } 195 + 196 + /** 197 + * Save custom themes to localStorage. 198 + */ 199 + export function saveCustomThemes(themes: Theme[]): void { 200 + if (typeof localStorage === 'undefined') return; 201 + localStorage.setItem( 202 + CUSTOM_THEMES_KEY, 203 + JSON.stringify(themes.filter(t => !t.builtIn)), 204 + ); 205 + } 206 + 207 + /** 208 + * Get all available themes (built-in + custom). 209 + */ 210 + export function getAllThemes(customThemes: Theme[] = []): Theme[] { 211 + return [...BUILT_IN_THEMES, ...customThemes]; 212 + } 213 + 214 + /** 215 + * Resolve a theme by ID from all available themes. 216 + */ 217 + export function resolveTheme( 218 + themeId: string, 219 + customThemes: Theme[] = [], 220 + ): Theme { 221 + const all = getAllThemes(customThemes); 222 + return all.find(t => t.id === themeId) || BUILT_IN_THEMES[0]; 223 + } 224 + 225 + /** 226 + * Delete a custom theme. Built-in themes cannot be deleted. 227 + */ 228 + export function deleteCustomTheme( 229 + themes: Theme[], 230 + themeId: string, 231 + ): Theme[] { 232 + return themes.filter(t => t.id !== themeId || t.builtIn); 233 + }
+148
tests/theming.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + BUILT_IN_THEMES, 4 + getBuiltInTheme, 5 + generateCssVars, 6 + createCustomTheme, 7 + getAllThemes, 8 + resolveTheme, 9 + deleteCustomTheme, 10 + type Theme, 11 + } from '../src/theming.js'; 12 + 13 + describe('Theming', () => { 14 + describe('BUILT_IN_THEMES', () => { 15 + it('includes light and dark themes', () => { 16 + const ids = BUILT_IN_THEMES.map(t => t.id); 17 + expect(ids).toContain('light'); 18 + expect(ids).toContain('dark'); 19 + }); 20 + 21 + it('has at least 4 built-in themes', () => { 22 + expect(BUILT_IN_THEMES.length).toBeGreaterThanOrEqual(4); 23 + }); 24 + 25 + it('all themes have required color properties', () => { 26 + for (const theme of BUILT_IN_THEMES) { 27 + expect(theme.colors.accent).toBeTruthy(); 28 + expect(theme.colors.bg).toBeTruthy(); 29 + expect(theme.colors.surface).toBeTruthy(); 30 + expect(theme.colors.text).toBeTruthy(); 31 + expect(theme.colors.textMuted).toBeTruthy(); 32 + expect(theme.colors.border).toBeTruthy(); 33 + } 34 + }); 35 + 36 + it('all themes use OkLCH colors', () => { 37 + for (const theme of BUILT_IN_THEMES) { 38 + expect(theme.colors.accent).toMatch(/oklch/); 39 + expect(theme.colors.bg).toMatch(/oklch/); 40 + } 41 + }); 42 + }); 43 + 44 + describe('getBuiltInTheme', () => { 45 + it('finds theme by ID', () => { 46 + const theme = getBuiltInTheme('dark'); 47 + expect(theme).not.toBeNull(); 48 + expect(theme!.name).toBe('Dark'); 49 + }); 50 + 51 + it('returns null for unknown ID', () => { 52 + expect(getBuiltInTheme('nonexistent')).toBeNull(); 53 + }); 54 + }); 55 + 56 + describe('generateCssVars', () => { 57 + it('generates CSS custom properties', () => { 58 + const vars = generateCssVars(BUILT_IN_THEMES[0].colors); 59 + expect(vars['--color-accent']).toBeTruthy(); 60 + expect(vars['--color-bg']).toBeTruthy(); 61 + expect(vars['--color-surface']).toBeTruthy(); 62 + expect(vars['--color-text']).toBeTruthy(); 63 + expect(vars['--color-text-muted']).toBeTruthy(); 64 + expect(vars['--color-border']).toBeTruthy(); 65 + }); 66 + 67 + it('returns 6 variables', () => { 68 + const vars = generateCssVars(BUILT_IN_THEMES[0].colors); 69 + expect(Object.keys(vars)).toHaveLength(6); 70 + }); 71 + }); 72 + 73 + describe('createCustomTheme', () => { 74 + it('creates a custom theme from a base', () => { 75 + const custom = createCustomTheme('My Theme', 'dark', { 76 + accent: 'oklch(0.7 0.2 300)', 77 + }); 78 + expect(custom.name).toBe('My Theme'); 79 + expect(custom.builtIn).toBe(false); 80 + expect(custom.colors.accent).toBe('oklch(0.7 0.2 300)'); 81 + // Inherits non-overridden colors from base 82 + expect(custom.colors.bg).toBe(BUILT_IN_THEMES.find(t => t.id === 'dark')!.colors.bg); 83 + }); 84 + 85 + it('falls back to light theme for unknown base', () => { 86 + const custom = createCustomTheme('Test', 'nonexistent', {}); 87 + expect(custom.base).toBe('light'); 88 + }); 89 + 90 + it('generates unique ID', () => { 91 + const c1 = createCustomTheme('A', 'light', {}); 92 + const c2 = createCustomTheme('B', 'light', {}); 93 + expect(c1.id).not.toBe(c2.id); 94 + }); 95 + }); 96 + 97 + describe('getAllThemes', () => { 98 + it('returns built-in themes when no custom', () => { 99 + const all = getAllThemes(); 100 + expect(all.length).toBe(BUILT_IN_THEMES.length); 101 + }); 102 + 103 + it('includes custom themes', () => { 104 + const custom: Theme = { 105 + id: 'custom-1', name: 'Custom', base: 'dark', 106 + builtIn: false, colors: BUILT_IN_THEMES[1].colors, 107 + }; 108 + const all = getAllThemes([custom]); 109 + expect(all.length).toBe(BUILT_IN_THEMES.length + 1); 110 + expect(all.find(t => t.id === 'custom-1')).toBeDefined(); 111 + }); 112 + }); 113 + 114 + describe('resolveTheme', () => { 115 + it('resolves built-in theme', () => { 116 + expect(resolveTheme('midnight').name).toBe('Midnight Blue'); 117 + }); 118 + 119 + it('resolves custom theme', () => { 120 + const custom: Theme = { 121 + id: 'custom-x', name: 'X', base: 'light', 122 + builtIn: false, colors: BUILT_IN_THEMES[0].colors, 123 + }; 124 + expect(resolveTheme('custom-x', [custom]).name).toBe('X'); 125 + }); 126 + 127 + it('falls back to light for unknown ID', () => { 128 + expect(resolveTheme('nonexistent').id).toBe('light'); 129 + }); 130 + }); 131 + 132 + describe('deleteCustomTheme', () => { 133 + it('removes custom theme', () => { 134 + const themes: Theme[] = [ 135 + { id: 'custom-1', name: 'C1', base: 'dark', builtIn: false, colors: BUILT_IN_THEMES[1].colors }, 136 + { id: 'custom-2', name: 'C2', base: 'light', builtIn: false, colors: BUILT_IN_THEMES[0].colors }, 137 + ]; 138 + const result = deleteCustomTheme(themes, 'custom-1'); 139 + expect(result).toHaveLength(1); 140 + expect(result[0].id).toBe('custom-2'); 141 + }); 142 + 143 + it('does not delete built-in themes', () => { 144 + const result = deleteCustomTheme(BUILT_IN_THEMES, 'dark'); 145 + expect(result.length).toBe(BUILT_IN_THEMES.length); 146 + }); 147 + }); 148 + });