Forenklet fargevelger: én aksentfarge + lys/mørk

Erstattet 9 slidere (3 per farge × 3 farger) med 3 intuitive kontroller:
- Farge: hue-stripe for aksentfarge
- Intensitet: saturation
- Lys/mørk: brightness slider (0=svart, 100=hvit)

Systemet utleder bg, surface, border, text automatisk fra disse.
Canvas-bakgrunn styres nå av --color-bg (var ikke det før).
Presets med emoji-ikoner. Rosa og lys preset lagt til.
Bakoverkompatibel med alle tidligere tema-formater.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-19 06:50:16 +00:00
parent 28f3b17261
commit bf744639c1
3 changed files with 135 additions and 179 deletions

View file

@ -12,7 +12,7 @@
DEFAULT_THEME,
THEME_PRESETS,
applyTheme,
themeColorCSS,
presetAccentCSS,
} from '$lib/workspace/theme.js';
interface Props {
@ -249,11 +249,8 @@
toolMenuOpen = false;
}
function setThemeColor(channel: 'bg' | 'surface' | 'accent', prop: 'hue' | 'saturation', value: number) {
const newTheme = {
...theme,
[channel]: { ...theme[channel], [prop]: value },
};
function updateTheme(partial: Partial<ThemeConfig>) {
const newTheme = { ...theme, ...partial };
applyTheme(newTheme);
onThemeChange?.(newTheme);
}
@ -489,38 +486,32 @@
title={preset.name}
onclick={() => applyPreset(preset.theme)}
>
<span
class="settings-preset-swatch"
style:background={themeColorCSS(preset.theme.accent, 60)}
></span>
<span class="settings-preset-label">{preset.name}</span>
<span class="settings-preset-swatch"
style:background={presetAccentCSS(preset.theme)}
>{preset.emoji}</span>
</button>
{/each}
</div>
<!-- Per-color sliders -->
{#each [
{ key: 'bg' as const, label: 'Bakgrunn' },
{ key: 'surface' as const, label: 'Overflate' },
{ key: 'accent' as const, label: 'Aksent' },
] as { key, label } (key)}
<!-- Accent color -->
<div class="settings-color-group">
<div class="settings-color-label">{label}</div>
<div class="settings-color-row">
<div class="settings-color-label">Farge</div>
<input type="range" class="settings-hue-slider" min="0" max="360"
value={theme[key].hue}
oninput={(e) => setThemeColor(key, 'hue', +e.currentTarget.value)} />
<input type="range" class="settings-light-slider" min="0" max="100"
value={theme[key].lightness}
oninput={(e) => setThemeColor(key, 'lightness', +e.currentTarget.value)} />
value={theme.accentHue}
oninput={(e) => updateTheme({ accentHue: +e.currentTarget.value })} />
</div>
<div class="settings-color-group">
<div class="settings-color-label">Intensitet</div>
<input type="range" class="settings-sat-slider" min="0" max="100"
value={theme[key].saturation}
oninput={(e) => setThemeColor(key, 'saturation', +e.currentTarget.value)} />
<span class="settings-color-swatch"
style:background={themeColorCSS(theme[key], theme[key].lightness)}></span>
value={theme.accentSat}
oninput={(e) => updateTheme({ accentSat: +e.currentTarget.value })} />
</div>
<div class="settings-color-group">
<div class="settings-color-label">Lys / mørk</div>
<input type="range" class="settings-light-slider" min="0" max="100"
value={theme.brightness}
oninput={(e) => updateTheme({ brightness: +e.currentTarget.value })} />
</div>
{/each}
<div class="settings-divider"></div>
{#if $page.data.session?.user}
@ -790,22 +781,18 @@
/* Presets */
.settings-presets { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
.settings-preset {
display: flex; align-items: center; gap: 4px; padding: 3px 8px;
border: 1px solid var(--color-border, #2a2a2e); border-radius: 4px; background: transparent;
cursor: pointer; font-size: 11px; color: var(--color-text-muted, #8a8a96); transition: border-color 0.1s;
padding: 2px; border: 2px solid transparent; border-radius: 8px; background: transparent;
cursor: pointer; transition: border-color 0.15s;
}
.settings-preset:hover { border-color: var(--color-accent, #6366f1); }
.settings-preset-swatch { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.settings-preset-label { white-space: nowrap; }
.settings-preset-swatch { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; }
/* Per-color controls */
.settings-color-group { margin-bottom: 8px; }
.settings-color-label { font-size: 11px; color: var(--color-text-dim, #5a5a66); margin-bottom: 3px; }
.settings-color-row { display: flex; align-items: center; gap: 6px; }
.settings-color-swatch { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; border: 1px solid var(--color-border, #2a2a2e); }
.settings-hue-slider, .settings-sat-slider, .settings-light-slider {
flex: 1;
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;

View file

@ -537,7 +537,7 @@
width: 100%;
height: 100%;
overflow: hidden;
background: #0a0a0b;
background: var(--color-bg, #0a0a0b);
touch-action: none; /* We handle all touch ourselves */
user-select: none;
cursor: default;
@ -551,7 +551,7 @@
position: fixed;
inset: 0;
z-index: 50;
background: #0a0a0b;
background: var(--color-bg, #0a0a0b);
}
.canvas-world {

View file

@ -1,96 +1,50 @@
/**
* Shared theme logic for Synops workspace.
*
* Each workspace/collection can have its own theme stored in
* metadata.preferences.theme. Themes use HSL color model for
* background, surface, and accent colors.
* Simplified model: user picks an accent color and a brightness level.
* The system derives all other colors (bg, surface, border, text)
* automatically. This makes theming intuitive one color choice
* plus a light/dark slider.
*
* Three-layer inheritance:
* 1. Flate-spesifikt (lagret i nodens metadata)
* 2. Personlig default (fra "Min arbeidsflate")
* 2. Personlig default (fra "Hjem")
* 3. Plattform-default (DEFAULT_THEME)
*/
/** Color definition: full HSL control. */
export interface ThemeColor {
hue: number; // 0-360
saturation: number; // 0-100
lightness: number; // 0-100
}
/** Full theme configuration */
/** Simplified theme: accent color + brightness */
export interface ThemeConfig {
bg: ThemeColor;
surface: ThemeColor;
accent: ThemeColor;
/** Accent hue (0-360) */
accentHue: number;
/** Accent saturation (0-100) */
accentSat: number;
/** Overall brightness (0=pitch black, 100=white) */
brightness: number;
}
/** Platform default — neutral dark with indigo accent */
export const DEFAULT_THEME: ThemeConfig = {
bg: { hue: 0, saturation: 0, lightness: 4 },
surface: { hue: 0, saturation: 0, lightness: 12 },
accent: { hue: 239, saturation: 70, lightness: 60 },
accentHue: 239,
accentSat: 70,
brightness: 5,
};
/** Named theme presets */
export interface ThemePreset {
name: string;
emoji: string;
theme: ThemeConfig;
}
export const THEME_PRESETS: ThemePreset[] = [
{
name: 'Standard',
theme: DEFAULT_THEME,
},
{
name: 'Hav',
theme: {
bg: { hue: 210, saturation: 15, lightness: 4 },
surface: { hue: 210, saturation: 12, lightness: 12 },
accent: { hue: 200, saturation: 75, lightness: 60 },
},
},
{
name: 'Skog',
theme: {
bg: { hue: 150, saturation: 12, lightness: 4 },
surface: { hue: 150, saturation: 10, lightness: 12 },
accent: { hue: 142, saturation: 65, lightness: 55 },
},
},
{
name: 'Solnedgang',
theme: {
bg: { hue: 15, saturation: 12, lightness: 5 },
surface: { hue: 15, saturation: 10, lightness: 13 },
accent: { hue: 25, saturation: 80, lightness: 60 },
},
},
{
name: 'Lavendel',
theme: {
bg: { hue: 270, saturation: 10, lightness: 5 },
surface: { hue: 270, saturation: 8, lightness: 13 },
accent: { hue: 280, saturation: 65, lightness: 60 },
},
},
{
name: 'Lys',
theme: {
bg: { hue: 220, saturation: 15, lightness: 95 },
surface: { hue: 220, saturation: 12, lightness: 100 },
accent: { hue: 239, saturation: 70, lightness: 55 },
},
},
{
name: 'Monokrom',
theme: {
bg: { hue: 0, saturation: 0, lightness: 4 },
surface: { hue: 0, saturation: 0, lightness: 12 },
accent: { hue: 0, saturation: 0, lightness: 60 },
},
},
{ name: 'Standard', emoji: '🌑', theme: { accentHue: 239, accentSat: 70, brightness: 5 } },
{ name: 'Hav', emoji: '🌊', theme: { accentHue: 200, accentSat: 75, brightness: 5 } },
{ name: 'Skog', emoji: '🌲', theme: { accentHue: 142, accentSat: 65, brightness: 5 } },
{ name: 'Solnedgang', emoji: '🌅', theme: { accentHue: 25, accentSat: 80, brightness: 6 } },
{ name: 'Lavendel', emoji: '💜', theme: { accentHue: 280, accentSat: 65, brightness: 5 } },
{ name: 'Rosa', emoji: '🌸', theme: { accentHue: 330, accentSat: 70, brightness: 5 } },
{ name: 'Lys', emoji: '☀️', theme: { accentHue: 220, accentSat: 50, brightness: 95 } },
{ name: 'Monokrom', emoji: '⚫', theme: { accentHue: 0, accentSat: 0, brightness: 5 } },
];
function hsl(hue: number, sat: number, light: number): string {
@ -101,104 +55,119 @@ function hsla(hue: number, sat: number, light: number, alpha: number): string {
return `hsla(${hue}, ${sat}%, ${light}%, ${alpha})`;
}
/** Apply theme to :root CSS custom properties */
export function applyTheme(config: ThemeConfig): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const { bg, surface, accent } = config;
const isDark = bg.lightness < 50;
// Background
root.style.setProperty('--color-bg', hsl(bg.hue, bg.saturation, bg.lightness));
// Surface: offset from background
root.style.setProperty('--color-surface', hsl(surface.hue, surface.saturation, surface.lightness));
const hoverOffset = isDark ? 3 : -3;
root.style.setProperty('--color-surface-hover', hsl(surface.hue, surface.saturation, clamp(surface.lightness + hoverOffset)));
// Border: derived from surface
const borderOffset = isDark ? 6 : -8;
root.style.setProperty('--color-border', hsl(surface.hue, Math.max(0, surface.saturation - 3), clamp(surface.lightness + borderOffset)));
root.style.setProperty('--color-border-hover', hsl(surface.hue, Math.max(0, surface.saturation - 3), clamp(surface.lightness + borderOffset * 2)));
// Text: auto-adapt to background lightness
if (isDark) {
root.style.setProperty('--color-text', hsl(bg.hue, Math.min(10, bg.saturation), 92));
root.style.setProperty('--color-text-muted', hsl(bg.hue, Math.min(8, bg.saturation), 55));
root.style.setProperty('--color-text-dim', hsl(bg.hue, Math.min(6, bg.saturation), 38));
} else {
root.style.setProperty('--color-text', hsl(bg.hue, Math.min(10, bg.saturation), 10));
root.style.setProperty('--color-text-muted', hsl(bg.hue, Math.min(8, bg.saturation), 40));
root.style.setProperty('--color-text-dim', hsl(bg.hue, Math.min(6, bg.saturation), 55));
}
// Accent
root.style.setProperty('--color-accent', hsl(accent.hue, accent.saturation, accent.lightness));
root.style.setProperty('--color-accent-hover', hsl(accent.hue, accent.saturation, clamp(accent.lightness + (isDark ? 5 : -5))));
root.style.setProperty('--color-accent-glow', hsla(accent.hue, accent.saturation, accent.lightness, 0.15));
}
function clamp(v: number, min = 0, max = 100): number {
return Math.min(max, Math.max(min, v));
}
/** Reset theme to platform defaults (remove inline styles) */
/**
* Apply theme to :root CSS custom properties.
* Derives all colors from accent + brightness.
*/
export function applyTheme(config: ThemeConfig): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const { accentHue, accentSat, brightness } = config;
const isDark = brightness < 50;
// Tint: surfaces get a subtle hint of the accent hue
const tintSat = Math.min(15, accentSat * 0.2);
// Background and surfaces derived from brightness
const bgLight = brightness;
const surfaceLight = isDark
? clamp(brightness + 8)
: clamp(brightness - 5);
const surfaceHoverLight = isDark
? clamp(brightness + 12)
: clamp(brightness - 8);
const borderLight = isDark
? clamp(brightness + 16)
: clamp(brightness - 12);
root.style.setProperty('--color-bg', hsl(accentHue, tintSat, bgLight));
root.style.setProperty('--color-surface', hsl(accentHue, tintSat, surfaceLight));
root.style.setProperty('--color-surface-hover', hsl(accentHue, tintSat, surfaceHoverLight));
root.style.setProperty('--color-border', hsl(accentHue, Math.max(0, tintSat - 3), borderLight));
root.style.setProperty('--color-border-hover', hsl(accentHue, Math.max(0, tintSat - 3), clamp(borderLight + (isDark ? 6 : -6))));
// Text adapts to background
if (isDark) {
root.style.setProperty('--color-text', hsl(accentHue, Math.min(8, tintSat), 92));
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));
} 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 */
export function resetTheme(): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const props = [
'--color-bg', '--color-surface', '--color-surface-hover',
'--color-border', '--color-border-hover',
'--color-text', '--color-text-muted', '--color-text-dim',
'--color-accent', '--color-accent-hover', '--color-accent-glow',
];
for (const prop of props) {
root.style.removeProperty(prop);
}
for (const prop of props) root.style.removeProperty(prop);
}
/**
* Load theme from node metadata. Backward compatible with old format
* ({ hueBg, hueSurface, hueAccent }) and new format ({ bg, surface, accent }).
* Load theme from node metadata. Backward compatible with all previous formats.
*/
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;
// New format (with lightness)
if (theme.bg && typeof theme.bg === 'object') {
const t = theme as unknown as ThemeConfig;
// Ensure lightness exists (backcompat with intermediate format)
// Current format: { accentHue, accentSat, brightness }
if (typeof theme.accentHue === 'number') {
return {
bg: { hue: t.bg.hue ?? 0, saturation: t.bg.saturation ?? 0, lightness: t.bg.lightness ?? 4 },
surface: { hue: t.surface.hue ?? 0, saturation: t.surface.saturation ?? 0, lightness: t.surface.lightness ?? 12 },
accent: { hue: t.accent.hue ?? 239, saturation: t.accent.saturation ?? 70, lightness: t.accent.lightness ?? 60 },
accentHue: theme.accentHue as number,
accentSat: (theme.accentSat as number) ?? 70,
brightness: (theme.brightness as number) ?? 5,
};
}
// Old format: { hueBg, hueSurface, hueAccent }
if (typeof theme.hueBg === 'number' || typeof theme.hueAccent === 'number') {
// Previous format: { bg: { hue, saturation, lightness }, ... }
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 {
bg: { hue: (theme.hueBg as number) ?? 0, saturation: (theme.hueBg as number) ? 10 : 0, lightness: 4 },
surface: { hue: (theme.hueSurface as number) ?? 0, saturation: (theme.hueSurface as number) ? 8 : 0, lightness: 12 },
accent: { hue: (theme.hueAccent as number) ?? 239, saturation: 70, lightness: 60 },
accentHue: accent?.hue ?? 239,
accentSat: accent?.saturation ?? 70,
brightness: bg?.lightness ?? 4,
};
}
// Oldest format: { hueBg, hueSurface, hueAccent }
if (typeof theme.hueAccent === 'number' || typeof theme.hueBg === 'number') {
return {
accentHue: (theme.hueAccent as number) ?? 239,
accentSat: 70,
brightness: 5,
};
}
return null;
}
/** Serialize theme for storage in node metadata */
/** Serialize theme for storage */
export function themeToMetadata(config: ThemeConfig): Record<string, unknown> {
return {
bg: config.bg,
surface: config.surface,
accent: config.accent,
};
return { accentHue: config.accentHue, accentSat: config.accentSat, brightness: config.brightness };
}
/** Get a CSS color string for a theme color at a given lightness */
export function themeColorCSS(color: ThemeColor, lightness: number): string {
return hsl(color.hue, color.saturation, lightness);
/** Get CSS color for a preset swatch */
export function presetAccentCSS(theme: ThemeConfig): string {
return hsl(theme.accentHue, theme.accentSat, 55);
}