From bf744639c16ebb56abc1cca9cd34152236f45c78 Mon Sep 17 00:00:00 2001 From: vegard Date: Thu, 19 Mar 2026 06:50:16 +0000 Subject: [PATCH] =?UTF-8?q?Forenklet=20fargevelger:=20=C3=A9n=20aksentfarg?= =?UTF-8?q?e=20+=20lys/m=C3=B8rk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/lib/components/ContextHeader.svelte | 71 +++--- .../src/lib/components/canvas/Canvas.svelte | 4 +- frontend/src/lib/workspace/theme.ts | 239 ++++++++---------- 3 files changed, 135 insertions(+), 179 deletions(-) diff --git a/frontend/src/lib/components/ContextHeader.svelte b/frontend/src/lib/components/ContextHeader.svelte index 05b2e81..3a7343e 100644 --- a/frontend/src/lib/components/ContextHeader.svelte +++ b/frontend/src/lib/components/ContextHeader.svelte @@ -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) { + const newTheme = { ...theme, ...partial }; applyTheme(newTheme); onThemeChange?.(newTheme); } @@ -489,38 +486,32 @@ title={preset.name} onclick={() => applyPreset(preset.theme)} > - - {preset.name} + {preset.emoji} {/each} - - {#each [ - { key: 'bg' as const, label: 'Bakgrunn' }, - { key: 'surface' as const, label: 'Overflate' }, - { key: 'accent' as const, label: 'Aksent' }, - ] as { key, label } (key)} -
-
{label}
-
- setThemeColor(key, 'hue', +e.currentTarget.value)} /> - setThemeColor(key, 'lightness', +e.currentTarget.value)} /> - setThemeColor(key, 'saturation', +e.currentTarget.value)} /> - -
-
- {/each} + +
+
Farge
+ updateTheme({ accentHue: +e.currentTarget.value })} /> +
+
+
Intensitet
+ updateTheme({ accentSat: +e.currentTarget.value })} /> +
+
+
Lys / mørk
+ updateTheme({ brightness: +e.currentTarget.value })} /> +
{#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; diff --git a/frontend/src/lib/components/canvas/Canvas.svelte b/frontend/src/lib/components/canvas/Canvas.svelte index 2e1a597..fdf8eb6 100644 --- a/frontend/src/lib/components/canvas/Canvas.svelte +++ b/frontend/src/lib/components/canvas/Canvas.svelte @@ -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 { diff --git a/frontend/src/lib/workspace/theme.ts b/frontend/src/lib/workspace/theme.ts index ecb66f0..2f40e00 100644 --- a/frontend/src/lib/workspace/theme.ts +++ b/frontend/src/lib/workspace/theme.ts @@ -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): ThemeConfig | null { const prefs = meta.preferences as Record | undefined; const theme = prefs?.theme as Record | 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 | undefined; + const bg = theme.bg as Record | 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 { - 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); }