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>
This commit is contained in:
vegard 2026-03-19 06:56:19 +00:00
parent bf744639c1
commit 798a11f93f
3 changed files with 174 additions and 132 deletions

View file

@ -9,8 +9,10 @@
import { updateNode, createNode, createEdge, deleteNode } from '$lib/api'; import { updateNode, createNode, createEdge, deleteNode } from '$lib/api';
import { import {
type ThemeConfig, type ThemeConfig,
type ThemeSurface,
DEFAULT_THEME, DEFAULT_THEME,
THEME_PRESETS, THEME_PRESETS,
THEME_SURFACES,
applyTheme, applyTheme,
presetAccentCSS, presetAccentCSS,
} from '$lib/workspace/theme.js'; } from '$lib/workspace/theme.js';
@ -242,6 +244,7 @@
// ========================================================================= // =========================================================================
let settingsOpen = $state(false); let settingsOpen = $state(false);
let editingSurface = $state<ThemeSurface>('accent');
function toggleSettings() { function toggleSettings() {
settingsOpen = !settingsOpen; settingsOpen = !settingsOpen;
@ -249,8 +252,11 @@
toolMenuOpen = false; toolMenuOpen = false;
} }
function updateTheme(partial: Partial<ThemeConfig>) { function updateSurface(prop: 'hue' | 'saturation' | 'lightness', value: number) {
const newTheme = { ...theme, ...partial }; const newTheme = {
...theme,
[editingSurface]: { ...theme[editingSurface], [prop]: value },
};
applyTheme(newTheme); applyTheme(newTheme);
onThemeChange?.(newTheme); onThemeChange?.(newTheme);
} }
@ -480,37 +486,56 @@
<!-- Presets --> <!-- Presets -->
<div class="settings-title">Tema</div> <div class="settings-title">Tema</div>
<div class="settings-presets"> <div class="settings-presets">
{#each THEME_PRESETS as preset (preset.name)} {#each THEME_PRESETS as p (p.name)}
<button <button
class="settings-preset" class="settings-preset"
title={preset.name} title={p.name}
onclick={() => applyPreset(preset.theme)} onclick={() => applyPreset(p.theme)}
> >
<span class="settings-preset-swatch" <span class="settings-preset-swatch"
style:background={presetAccentCSS(preset.theme)} style:background={presetAccentCSS(p.theme)}
>{preset.emoji}</span> >{p.emoji}</span>
</button> </button>
{/each} {/each}
</div> </div>
<!-- Accent color --> <!-- Surface selector + sliders -->
<div class="settings-color-group">
<select
class="settings-surface-select"
value={editingSurface}
onchange={(e) => { editingSurface = e.currentTarget.value as ThemeSurface; }}
>
{#each Object.entries(THEME_SURFACES) as [key, label] (key)}
<option value={key}>{label}</option>
{/each}
</select>
</div>
<div class="settings-color-group"> <div class="settings-color-group">
<div class="settings-color-label">Farge</div> <div class="settings-color-label">Farge</div>
<input type="range" class="settings-hue-slider" min="0" max="360" <input type="range" class="settings-hue-slider" min="0" max="360"
value={theme.accentHue} value={theme[editingSurface].hue}
oninput={(e) => updateTheme({ accentHue: +e.currentTarget.value })} /> oninput={(e) => updateSurface('hue', +e.currentTarget.value)} />
</div> </div>
<div class="settings-color-group"> <div class="settings-color-group">
<div class="settings-color-label">Intensitet</div> <div class="settings-color-label">Metning</div>
<input type="range" class="settings-sat-slider" min="0" max="100" <input type="range" class="settings-sat-slider" min="0" max="100"
value={theme.accentSat} value={theme[editingSurface].saturation}
oninput={(e) => updateTheme({ accentSat: +e.currentTarget.value })} /> oninput={(e) => updateSurface('saturation', +e.currentTarget.value)} />
</div> </div>
<div class="settings-color-group"> <div class="settings-color-group">
<div class="settings-color-label">Lys / mørk</div> <div class="settings-color-label">Lyshet</div>
<input type="range" class="settings-light-slider" min="0" max="100" <input type="range" class="settings-light-slider" min="0" max="100"
value={theme.brightness} value={theme[editingSurface].lightness}
oninput={(e) => updateTheme({ brightness: +e.currentTarget.value })} /> oninput={(e) => updateSurface('lightness', +e.currentTarget.value)} />
</div>
<div class="settings-color-preview">
<span class="settings-preview-swatch"
style:background="hsl({theme[editingSurface].hue}, {theme[editingSurface].saturation}%, {theme[editingSurface].lightness}%)"
></span>
<span class="settings-preview-label">{THEME_SURFACES[editingSurface]}</span>
</div> </div>
<div class="settings-divider"></div> <div class="settings-divider"></div>
@ -790,6 +815,14 @@
/* Per-color controls */ /* Per-color controls */
.settings-color-group { margin-bottom: 8px; } .settings-color-group { margin-bottom: 8px; }
.settings-color-label { font-size: 11px; color: var(--color-text-dim, #5a5a66); margin-bottom: 3px; } .settings-color-label { font-size: 11px; color: var(--color-text-dim, #5a5a66); margin-bottom: 3px; }
.settings-surface-select {
width: 100%; padding: 5px 8px; border-radius: 5px; font-size: 12px;
background: var(--color-bg, #0a0a0b) !important; color: var(--color-text, #e8e8ec) !important;
border: 1px solid var(--color-border, #2a2a2e) !important; cursor: pointer;
}
.settings-color-preview { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.settings-preview-swatch { width: 24px; height: 24px; border-radius: 4px; border: 1px solid var(--color-border, #2a2a2e); flex-shrink: 0; }
.settings-preview-label { font-size: 11px; color: var(--color-text-muted, #8a8a96); }
.settings-hue-slider, .settings-sat-slider, .settings-light-slider { .settings-hue-slider, .settings-sat-slider, .settings-light-slider {
width: 100%; width: 100%;

View file

@ -462,7 +462,7 @@
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--color-surface, #1c1c20); background: var(--color-panel, var(--color-surface, #1c1c20));
border: 1px solid var(--color-border, #2a2a2e); border: 1px solid var(--color-border, #2a2a2e);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);

View file

@ -1,173 +1,182 @@
/** /**
* Shared theme logic for Synops workspace. * Shared theme logic for Synops workspace.
* *
* Simplified model: user picks an accent color and a brightness level. * Model: each UI surface has its own HSL color. A dropdown selects
* The system derives all other colors (bg, surface, border, text) * which surface to edit, and three sliders (hue, saturation, lightness)
* automatically. This makes theming intuitive one color choice * control it. Presets set all surfaces at once.
* plus a light/dark slider.
*
* Three-layer inheritance:
* 1. Flate-spesifikt (lagret i nodens metadata)
* 2. Personlig default (fra "Hjem")
* 3. Plattform-default (DEFAULT_THEME)
*/ */
/** Simplified theme: accent color + brightness */ /** The surfaces a user can theme */
export interface ThemeConfig { export const THEME_SURFACES = {
/** Accent hue (0-360) */ canvas: 'Canvas',
accentHue: number; header: 'Menylinje',
/** Accent saturation (0-100) */ panel: 'Bokser',
accentSat: number; border: 'Rammer',
/** Overall brightness (0=pitch black, 100=white) */ accent: 'Knapper/aksent',
brightness: number; 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
} }
/** Platform default — neutral dark with indigo accent */ /** Full theme: one color per surface */
export type ThemeConfig = Record<ThemeSurface, SurfaceColor>;
/** Platform default */
export const DEFAULT_THEME: ThemeConfig = { export const DEFAULT_THEME: ThemeConfig = {
accentHue: 239, canvas: { hue: 0, saturation: 0, lightness: 4 },
accentSat: 70, header: { hue: 0, saturation: 0, lightness: 12 },
brightness: 5, 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 },
}; };
/** Named theme presets */
export interface ThemePreset { export interface ThemePreset {
name: string; name: string;
emoji: string; emoji: string;
theme: ThemeConfig; 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[] = [ export const THEME_PRESETS: ThemePreset[] = [
{ name: 'Standard', emoji: '🌑', theme: { accentHue: 239, accentSat: 70, brightness: 5 } }, { name: 'Standard', emoji: '🌑', theme: DEFAULT_THEME },
{ name: 'Hav', emoji: '🌊', theme: { accentHue: 200, accentSat: 75, brightness: 5 } }, { name: 'Hav', emoji: '🌊', theme: preset([200, 75, 60], 5) },
{ name: 'Skog', emoji: '🌲', theme: { accentHue: 142, accentSat: 65, brightness: 5 } }, { name: 'Skog', emoji: '🌲', theme: preset([142, 65, 55], 5) },
{ name: 'Solnedgang', emoji: '🌅', theme: { accentHue: 25, accentSat: 80, brightness: 6 } }, { name: 'Solnedgang', emoji: '🌅', theme: preset([25, 80, 60], 6) },
{ name: 'Lavendel', emoji: '💜', theme: { accentHue: 280, accentSat: 65, brightness: 5 } }, { name: 'Lavendel', emoji: '💜', theme: preset([280, 65, 60], 5) },
{ name: 'Rosa', emoji: '🌸', theme: { accentHue: 330, accentSat: 70, brightness: 5 } }, { name: 'Rosa', emoji: '🌸', theme: preset([330, 70, 60], 5) },
{ name: 'Lys', emoji: '☀️', theme: { accentHue: 220, accentSat: 50, brightness: 95 } }, { name: 'Lys', emoji: '☀️', theme: preset([220, 50, 50], 95) },
{ name: 'Monokrom', emoji: '⚫', theme: { accentHue: 0, accentSat: 0, brightness: 5 } }, { name: 'Monokrom', emoji: '⚫', theme: preset([0, 0, 60], 5) },
]; ];
function hsl(hue: number, sat: number, light: number): string { function hsl(c: SurfaceColor): string {
return `hsl(${hue}, ${sat}%, ${light}%)`; return `hsl(${c.hue}, ${c.saturation}%, ${c.lightness}%)`;
} }
function hsla(hue: number, sat: number, light: number, alpha: number): string { function hsla(c: SurfaceColor, alpha: number): string {
return `hsla(${hue}, ${sat}%, ${light}%, ${alpha})`; return `hsla(${c.hue}, ${c.saturation}%, ${c.lightness}%, ${alpha})`;
} }
function clamp(v: number, min = 0, max = 100): number { function clamp(v: number): number {
return Math.min(max, Math.max(min, v)); return Math.min(100, Math.max(0, v));
} }
/** /** Apply theme to CSS custom properties */
* Apply theme to :root CSS custom properties. export function applyTheme(t: ThemeConfig): void {
* Derives all colors from accent + brightness.
*/
export function applyTheme(config: ThemeConfig): void {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const root = document.documentElement; const s = document.documentElement.style;
const { accentHue, accentSat, brightness } = config; s.setProperty('--color-bg', hsl(t.canvas));
const isDark = brightness < 50; s.setProperty('--color-surface', hsl(t.header));
s.setProperty('--color-surface-hover', hsl({
// Tint: surfaces get a subtle hint of the accent hue ...t.header,
const tintSat = Math.min(15, accentSat * 0.2); lightness: clamp(t.header.lightness + (t.canvas.lightness < 50 ? 3 : -3)),
}));
// Background and surfaces derived from brightness s.setProperty('--color-panel', hsl(t.panel));
const bgLight = brightness; s.setProperty('--color-border', hsl(t.border));
const surfaceLight = isDark s.setProperty('--color-border-hover', hsl({
? clamp(brightness + 8) ...t.border,
: clamp(brightness - 5); lightness: clamp(t.border.lightness + (t.canvas.lightness < 50 ? 6 : -6)),
const surfaceHoverLight = isDark }));
? clamp(brightness + 12) s.setProperty('--color-text', hsl(t.text));
: clamp(brightness - 8); s.setProperty('--color-text-muted', hsl({
const borderLight = isDark ...t.text,
? clamp(brightness + 16) lightness: clamp(t.canvas.lightness < 50 ? 55 : 40),
: clamp(brightness - 12); saturation: Math.min(8, t.text.saturation),
}));
root.style.setProperty('--color-bg', hsl(accentHue, tintSat, bgLight)); s.setProperty('--color-text-dim', hsl({
root.style.setProperty('--color-surface', hsl(accentHue, tintSat, surfaceLight)); ...t.text,
root.style.setProperty('--color-surface-hover', hsl(accentHue, tintSat, surfaceHoverLight)); lightness: clamp(t.canvas.lightness < 50 ? 38 : 55),
root.style.setProperty('--color-border', hsl(accentHue, Math.max(0, tintSat - 3), borderLight)); saturation: Math.min(6, t.text.saturation),
root.style.setProperty('--color-border-hover', hsl(accentHue, Math.max(0, tintSat - 3), clamp(borderLight + (isDark ? 6 : -6)))); }));
s.setProperty('--color-accent', hsl(t.accent));
// Text adapts to background s.setProperty('--color-accent-hover', hsl({
if (isDark) { ...t.accent,
root.style.setProperty('--color-text', hsl(accentHue, Math.min(8, tintSat), 92)); lightness: clamp(t.accent.lightness + (t.canvas.lightness < 50 ? 5 : -5)),
root.style.setProperty('--color-text-muted', hsl(accentHue, Math.min(6, tintSat), 55)); }));
root.style.setProperty('--color-text-dim', hsl(accentHue, Math.min(4, tintSat), 38)); s.setProperty('--color-accent-glow', hsla(t.accent, 0.15));
} else {
root.style.setProperty('--color-text', hsl(accentHue, Math.min(8, tintSat), 10));
root.style.setProperty('--color-text-muted', hsl(accentHue, Math.min(6, tintSat), 40));
root.style.setProperty('--color-text-dim', hsl(accentHue, Math.min(4, tintSat), 55));
}
// Accent
const accentLight = isDark ? 60 : 45;
root.style.setProperty('--color-accent', hsl(accentHue, accentSat, accentLight));
root.style.setProperty('--color-accent-hover', hsl(accentHue, accentSat, clamp(accentLight + (isDark ? 5 : -5))));
root.style.setProperty('--color-accent-glow', hsla(accentHue, accentSat, accentLight, 0.15));
} }
/** Reset theme to platform defaults */ /** Reset theme */
export function resetTheme(): void { export function resetTheme(): void {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const root = document.documentElement; const s = document.documentElement.style;
const props = [ for (const p of [
'--color-bg', '--color-surface', '--color-surface-hover', '--color-bg', '--color-surface', '--color-surface-hover', '--color-panel',
'--color-border', '--color-border-hover', '--color-border', '--color-border-hover',
'--color-text', '--color-text-muted', '--color-text-dim', '--color-text', '--color-text-muted', '--color-text-dim',
'--color-accent', '--color-accent-hover', '--color-accent-glow', '--color-accent', '--color-accent-hover', '--color-accent-glow',
]; ]) s.removeProperty(p);
for (const prop of props) root.style.removeProperty(prop);
} }
/** /** Load from metadata (backward compatible) */
* Load theme from node metadata. Backward compatible with all previous formats.
*/
export function loadThemeFromMetadata(meta: Record<string, unknown>): ThemeConfig | null { export function loadThemeFromMetadata(meta: Record<string, unknown>): ThemeConfig | null {
const prefs = meta.preferences as Record<string, unknown> | undefined; const prefs = meta.preferences as Record<string, unknown> | undefined;
const theme = prefs?.theme as Record<string, unknown> | undefined; const theme = prefs?.theme as Record<string, unknown> | undefined;
if (!theme) return null; if (!theme) return null;
// Current format: { accentHue, accentSat, brightness } // Current format: has 'canvas' key
if (typeof theme.accentHue === 'number') { if (theme.canvas && typeof theme.canvas === 'object') {
return { const t = theme as unknown as ThemeConfig;
accentHue: theme.accentHue as number, // Fill missing surfaces with defaults
accentSat: (theme.accentSat as number) ?? 70, return { ...DEFAULT_THEME, ...t };
brightness: (theme.brightness as number) ?? 5,
};
} }
// Previous format: { bg: { hue, saturation, lightness }, ... } // 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') { if (theme.bg && typeof theme.bg === 'object') {
const accent = theme.accent as Record<string, number> | undefined; const accent = theme.accent as Record<string, number> | undefined;
const bg = theme.bg as Record<string, number> | undefined; const bg = theme.bg as Record<string, number> | undefined;
return { return preset(
accentHue: accent?.hue ?? 239, [accent?.hue ?? 239, accent?.saturation ?? 70, accent?.lightness ?? 60],
accentSat: accent?.saturation ?? 70, bg?.lightness ?? 4,
brightness: bg?.lightness ?? 4, );
};
} }
// Oldest format: { hueBg, hueSurface, hueAccent } // Oldest: { hueBg, hueAccent }
if (typeof theme.hueAccent === 'number' || typeof theme.hueBg === 'number') { if (typeof theme.hueAccent === 'number') {
return { return preset([(theme.hueAccent as number), 70, 60], 5);
accentHue: (theme.hueAccent as number) ?? 239,
accentSat: 70,
brightness: 5,
};
} }
return null; return null;
} }
/** Serialize theme for storage */ /** Serialize */
export function themeToMetadata(config: ThemeConfig): Record<string, unknown> { export function themeToMetadata(config: ThemeConfig): Record<string, unknown> {
return { accentHue: config.accentHue, accentSat: config.accentSat, brightness: config.brightness }; return { ...config };
} }
/** Get CSS color for a preset swatch */ /** CSS string for preset swatch */
export function presetAccentCSS(theme: ThemeConfig): string { export function presetAccentCSS(theme: ThemeConfig): string {
return hsl(theme.accentHue, theme.accentSat, 55); return hsl(theme.accent);
} }