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:
parent
bf744639c1
commit
798a11f93f
3 changed files with 174 additions and 132 deletions
|
|
@ -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<ThemeSurface>('accent');
|
||||
|
||||
function toggleSettings() {
|
||||
settingsOpen = !settingsOpen;
|
||||
|
|
@ -249,8 +252,11 @@
|
|||
toolMenuOpen = false;
|
||||
}
|
||||
|
||||
function updateTheme(partial: Partial<ThemeConfig>) {
|
||||
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 @@
|
|||
<!-- Presets -->
|
||||
<div class="settings-title">Tema</div>
|
||||
<div class="settings-presets">
|
||||
{#each THEME_PRESETS as preset (preset.name)}
|
||||
{#each THEME_PRESETS as p (p.name)}
|
||||
<button
|
||||
class="settings-preset"
|
||||
title={preset.name}
|
||||
onclick={() => applyPreset(preset.theme)}
|
||||
title={p.name}
|
||||
onclick={() => applyPreset(p.theme)}
|
||||
>
|
||||
<span class="settings-preset-swatch"
|
||||
style:background={presetAccentCSS(preset.theme)}
|
||||
>{preset.emoji}</span>
|
||||
style:background={presetAccentCSS(p.theme)}
|
||||
>{p.emoji}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</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-label">Farge</div>
|
||||
<input type="range" class="settings-hue-slider" min="0" max="360"
|
||||
value={theme.accentHue}
|
||||
oninput={(e) => updateTheme({ accentHue: +e.currentTarget.value })} />
|
||||
value={theme[editingSurface].hue}
|
||||
oninput={(e) => updateSurface('hue', +e.currentTarget.value)} />
|
||||
</div>
|
||||
<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"
|
||||
value={theme.accentSat}
|
||||
oninput={(e) => updateTheme({ accentSat: +e.currentTarget.value })} />
|
||||
value={theme[editingSurface].saturation}
|
||||
oninput={(e) => updateSurface('saturation', +e.currentTarget.value)} />
|
||||
</div>
|
||||
<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"
|
||||
value={theme.brightness}
|
||||
oninput={(e) => updateTheme({ brightness: +e.currentTarget.value })} />
|
||||
value={theme[editingSurface].lightness}
|
||||
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 class="settings-divider"></div>
|
||||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<ThemeSurface, SurfaceColor>;
|
||||
|
||||
/** 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));
|
||||
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));
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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<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: { 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<string, number> | undefined;
|
||||
const bg = theme.bg as Record<string, number> | 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<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 {
|
||||
return hsl(theme.accentHue, theme.accentSat, 55);
|
||||
return hsl(theme.accent);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue