/** * Shared theme logic for Synops workspace. * * Model: each UI surface has its own HSL color. A dropdown selects * which surface to edit, and three sliders (hue, saturation, lightness) * control it. Presets set all surfaces at once. */ /** The surfaces a user can theme */ export const THEME_SURFACES = { canvas: 'Canvas', header: 'Menylinje', panel: 'Bokser', border: 'Rammer', accent: 'Knapper/aksent', text: 'Tekst', } as const; export type ThemeSurface = keyof typeof THEME_SURFACES; /** HSL color for one surface */ export interface SurfaceColor { hue: number; // 0-360 saturation: number; // 0-100 lightness: number; // 0-100 } /** Full theme: one color per surface */ export type ThemeConfig = Record; /** Platform default */ export const DEFAULT_THEME: ThemeConfig = { canvas: { hue: 0, saturation: 0, lightness: 4 }, header: { hue: 0, saturation: 0, lightness: 12 }, panel: { hue: 0, saturation: 0, lightness: 12 }, border: { hue: 0, saturation: 0, lightness: 18 }, accent: { hue: 239, saturation: 70, lightness: 60 }, text: { hue: 0, saturation: 0, lightness: 92 }, }; export interface ThemePreset { name: string; emoji: string; theme: ThemeConfig; } function preset( accent: [number, number, number], brightness: number, ): ThemeConfig { const isDark = brightness < 50; const [ah, as, al] = accent; const tint = Math.min(12, as * 0.15); return { canvas: { hue: ah, saturation: tint, lightness: brightness }, header: { hue: ah, saturation: tint, lightness: isDark ? brightness + 8 : brightness - 5 }, panel: { hue: ah, saturation: tint, lightness: isDark ? brightness + 8 : brightness - 5 }, border: { hue: ah, saturation: Math.max(0, tint - 3), lightness: isDark ? brightness + 14 : brightness - 10 }, accent: { hue: ah, saturation: as, lightness: al }, text: { hue: ah, saturation: Math.min(8, tint), lightness: isDark ? 92 : 10 }, }; } export const THEME_PRESETS: ThemePreset[] = [ { name: 'Standard', emoji: '🌑', theme: DEFAULT_THEME }, { name: 'Hav', emoji: '🌊', theme: preset([200, 75, 60], 5) }, { name: 'Skog', emoji: '🌲', theme: preset([142, 65, 55], 5) }, { name: 'Solnedgang', emoji: '🌅', theme: preset([25, 80, 60], 6) }, { name: 'Lavendel', emoji: '💜', theme: preset([280, 65, 60], 5) }, { name: 'Rosa', emoji: '🌸', theme: preset([330, 70, 60], 5) }, { name: 'Lys', emoji: '☀️', theme: preset([220, 50, 50], 95) }, { name: 'Monokrom', emoji: '⚫', theme: preset([0, 0, 60], 5) }, ]; function hsl(c: SurfaceColor): string { return `hsl(${c.hue}, ${c.saturation}%, ${c.lightness}%)`; } function hsla(c: SurfaceColor, alpha: number): string { return `hsla(${c.hue}, ${c.saturation}%, ${c.lightness}%, ${alpha})`; } function clamp(v: number): number { return Math.min(100, Math.max(0, v)); } /** Apply theme to CSS custom properties */ export function applyTheme(t: ThemeConfig): void { if (typeof document === 'undefined') return; const s = document.documentElement.style; s.setProperty('--color-bg', hsl(t.canvas)); s.setProperty('--color-surface', hsl(t.header)); s.setProperty('--color-surface-hover', hsl({ ...t.header, lightness: clamp(t.header.lightness + (t.canvas.lightness < 50 ? 3 : -3)), })); s.setProperty('--color-panel', hsl(t.panel)); s.setProperty('--color-border', hsl(t.border)); s.setProperty('--color-border-hover', hsl({ ...t.border, lightness: clamp(t.border.lightness + (t.canvas.lightness < 50 ? 6 : -6)), })); s.setProperty('--color-text', hsl(t.text)); s.setProperty('--color-text-muted', hsl({ ...t.text, lightness: clamp(t.canvas.lightness < 50 ? 55 : 40), saturation: Math.min(8, t.text.saturation), })); s.setProperty('--color-text-dim', hsl({ ...t.text, lightness: clamp(t.canvas.lightness < 50 ? 38 : 55), saturation: Math.min(6, t.text.saturation), })); s.setProperty('--color-accent', hsl(t.accent)); s.setProperty('--color-accent-hover', hsl({ ...t.accent, lightness: clamp(t.accent.lightness + (t.canvas.lightness < 50 ? 5 : -5)), })); s.setProperty('--color-accent-glow', hsla(t.accent, 0.15)); } /** Reset theme */ export function resetTheme(): void { if (typeof document === 'undefined') return; const s = document.documentElement.style; for (const p of [ '--color-bg', '--color-surface', '--color-surface-hover', '--color-panel', '--color-border', '--color-border-hover', '--color-text', '--color-text-muted', '--color-text-dim', '--color-accent', '--color-accent-hover', '--color-accent-glow', ]) s.removeProperty(p); } /** Load from metadata (backward compatible) */ export function loadThemeFromMetadata(meta: Record): ThemeConfig | null { const prefs = meta.preferences as Record | undefined; const theme = prefs?.theme as Record | undefined; if (!theme) return null; // Current format: has 'canvas' key if (theme.canvas && typeof theme.canvas === 'object') { const t = theme as unknown as ThemeConfig; // Fill missing surfaces with defaults return { ...DEFAULT_THEME, ...t }; } // Previous format: { accentHue, accentSat, brightness } if (typeof theme.accentHue === 'number') { return preset( [theme.accentHue as number, (theme.accentSat as number) ?? 70, 60], (theme.brightness as number) ?? 5, ); } // Older: { bg: { hue, ... }, accent: { hue, ... } } if (theme.bg && typeof theme.bg === 'object') { const accent = theme.accent as Record | undefined; const bg = theme.bg as Record | undefined; return preset( [accent?.hue ?? 239, accent?.saturation ?? 70, accent?.lightness ?? 60], bg?.lightness ?? 4, ); } // Oldest: { hueBg, hueAccent } if (typeof theme.hueAccent === 'number') { return preset([(theme.hueAccent as number), 70, 60], 5); } return null; } /** Serialize */ export function themeToMetadata(config: ThemeConfig): Record { return { ...config }; } /** CSS string for preset swatch */ export function presetAccentCSS(theme: ThemeConfig): string { return hsl(theme.accent); }