synops/frontend/src/lib/workspace/theme.ts
vegard 798a11f93f Fargevelger: velg grensesnittelement fra dropdown, full HSL-kontroll
Ny modell: dropdown velger hva du farger (Canvas, Menylinje, Bokser,
Rammer, Knapper/aksent, Tekst). Tre slidere per element (farge,
metning, lyshet) gir full kontroll over hele spekteret.
Presets setter alle elementer på én gang. Fargeprøve viser valgt farge.
BlockShell bruker ny --color-panel variabel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 06:56:19 +00:00

182 lines
6 KiB
TypeScript

/**
* 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<ThemeSurface, SurfaceColor>;
/** 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<string, unknown>): ThemeConfig | null {
const prefs = meta.preferences as Record<string, unknown> | undefined;
const theme = prefs?.theme as Record<string, unknown> | 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<string, number> | undefined;
const bg = theme.bg as Record<string, number> | 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<string, unknown> {
return { ...config };
}
/** CSS string for preset swatch */
export function presetAccentCSS(theme: ThemeConfig): string {
return hsl(theme.accent);
}