From 798a11f93f55206ebd4d54a51ff7de42f753f6fc Mon Sep 17 00:00:00 2001 From: vegard Date: Thu, 19 Mar 2026 06:56:19 +0000 Subject: [PATCH] Fargevelger: velg grensesnittelement fra dropdown, full HSL-kontroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/lib/components/ContextHeader.svelte | 65 +++-- .../components/blockshell/BlockShell.svelte | 2 +- frontend/src/lib/workspace/theme.ts | 239 +++++++++--------- 3 files changed, 174 insertions(+), 132 deletions(-) diff --git a/frontend/src/lib/components/ContextHeader.svelte b/frontend/src/lib/components/ContextHeader.svelte index 3a7343e..0d30183 100644 --- a/frontend/src/lib/components/ContextHeader.svelte +++ b/frontend/src/lib/components/ContextHeader.svelte @@ -9,8 +9,10 @@ import { updateNode, createNode, createEdge, deleteNode } from '$lib/api'; import { type ThemeConfig, + type ThemeSurface, DEFAULT_THEME, THEME_PRESETS, + THEME_SURFACES, applyTheme, presetAccentCSS, } from '$lib/workspace/theme.js'; @@ -242,6 +244,7 @@ // ========================================================================= let settingsOpen = $state(false); + let editingSurface = $state('accent'); function toggleSettings() { settingsOpen = !settingsOpen; @@ -249,8 +252,11 @@ toolMenuOpen = false; } - function updateTheme(partial: Partial) { - const newTheme = { ...theme, ...partial }; + function updateSurface(prop: 'hue' | 'saturation' | 'lightness', value: number) { + const newTheme = { + ...theme, + [editingSurface]: { ...theme[editingSurface], [prop]: value }, + }; applyTheme(newTheme); onThemeChange?.(newTheme); } @@ -480,37 +486,56 @@
Tema
- {#each THEME_PRESETS as preset (preset.name)} + {#each THEME_PRESETS as p (p.name)} {/each}
- + +
+ +
+
Farge
updateTheme({ accentHue: +e.currentTarget.value })} /> + value={theme[editingSurface].hue} + oninput={(e) => updateSurface('hue', +e.currentTarget.value)} />
-
Intensitet
+
Metning
updateTheme({ accentSat: +e.currentTarget.value })} /> + value={theme[editingSurface].saturation} + oninput={(e) => updateSurface('saturation', +e.currentTarget.value)} />
-
Lys / mørk
+
Lyshet
updateTheme({ brightness: +e.currentTarget.value })} /> + value={theme[editingSurface].lightness} + oninput={(e) => updateSurface('lightness', +e.currentTarget.value)} /> +
+ +
+ + {THEME_SURFACES[editingSurface]}
@@ -790,6 +815,14 @@ /* 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-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 { width: 100%; diff --git a/frontend/src/lib/components/blockshell/BlockShell.svelte b/frontend/src/lib/components/blockshell/BlockShell.svelte index 8c47f40..e9086c5 100644 --- a/frontend/src/lib/components/blockshell/BlockShell.svelte +++ b/frontend/src/lib/components/blockshell/BlockShell.svelte @@ -462,7 +462,7 @@ position: relative; display: flex; flex-direction: column; - background: var(--color-surface, #1c1c20); + background: var(--color-panel, var(--color-surface, #1c1c20)); border: 1px solid var(--color-border, #2a2a2e); border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); diff --git a/frontend/src/lib/workspace/theme.ts b/frontend/src/lib/workspace/theme.ts index 2f40e00..75f90f8 100644 --- a/frontend/src/lib/workspace/theme.ts +++ b/frontend/src/lib/workspace/theme.ts @@ -1,173 +1,182 @@ /** * Shared theme logic for Synops workspace. * - * 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 "Hjem") - * 3. Plattform-default (DEFAULT_THEME) + * 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. */ -/** Simplified theme: accent color + brightness */ -export interface ThemeConfig { - /** Accent hue (0-360) */ - accentHue: number; - /** Accent saturation (0-100) */ - accentSat: number; - /** Overall brightness (0=pitch black, 100=white) */ - brightness: number; +/** 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 } -/** Platform default — neutral dark with indigo accent */ +/** Full theme: one color per surface */ +export type ThemeConfig = Record; + +/** Platform default */ export const DEFAULT_THEME: ThemeConfig = { - accentHue: 239, - accentSat: 70, - brightness: 5, + 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 }, }; -/** Named theme presets */ 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: { 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 } }, + { 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(hue: number, sat: number, light: number): string { - return `hsl(${hue}, ${sat}%, ${light}%)`; +function hsl(c: SurfaceColor): string { + return `hsl(${c.hue}, ${c.saturation}%, ${c.lightness}%)`; } -function hsla(hue: number, sat: number, light: number, alpha: number): string { - return `hsla(${hue}, ${sat}%, ${light}%, ${alpha})`; +function hsla(c: SurfaceColor, alpha: number): string { + return `hsla(${c.hue}, ${c.saturation}%, ${c.lightness}%, ${alpha})`; } -function clamp(v: number, min = 0, max = 100): number { - return Math.min(max, Math.max(min, v)); +function clamp(v: number): number { + return Math.min(100, Math.max(0, v)); } -/** - * Apply theme to :root CSS custom properties. - * Derives all colors from accent + brightness. - */ -export function applyTheme(config: ThemeConfig): void { +/** Apply theme to CSS custom properties */ +export function applyTheme(t: ThemeConfig): void { if (typeof document === 'undefined') return; - const root = document.documentElement; + const s = document.documentElement.style; - 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)); + 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 to platform defaults */ +/** Reset theme */ export function resetTheme(): void { if (typeof document === 'undefined') return; - const root = document.documentElement; - const props = [ - '--color-bg', '--color-surface', '--color-surface-hover', + 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', - ]; - for (const prop of props) root.style.removeProperty(prop); + ]) s.removeProperty(p); } -/** - * Load theme from node metadata. Backward compatible with all previous formats. - */ +/** 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: { accentHue, accentSat, brightness } - if (typeof theme.accentHue === 'number') { - return { - accentHue: theme.accentHue as number, - accentSat: (theme.accentSat as number) ?? 70, - brightness: (theme.brightness as number) ?? 5, - }; + // 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: { 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') { const accent = theme.accent as Record | undefined; const bg = theme.bg as Record | undefined; - return { - accentHue: accent?.hue ?? 239, - accentSat: accent?.saturation ?? 70, - brightness: bg?.lightness ?? 4, - }; + return preset( + [accent?.hue ?? 239, accent?.saturation ?? 70, accent?.lightness ?? 60], + 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, - }; + // Oldest: { hueBg, hueAccent } + if (typeof theme.hueAccent === 'number') { + return preset([(theme.hueAccent as number), 70, 60], 5); } return null; } -/** Serialize theme for storage */ +/** Serialize */ export function themeToMetadata(config: ThemeConfig): Record { - 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 { - return hsl(theme.accentHue, theme.accentSat, 55); + return hsl(theme.accent); }