Fargevelger: full HSL-kontroll (hue + lightness + saturation)

ThemeColor har nå lightness-felt. Alle tre slidere (hue, lightness,
saturation) dekker hele spekteret 0-100/360. Brukeren kan gå fra
hvit til svart og alt imellom. Tekst auto-tilpasses lys/mørk bakgrunn.
Nytt preset «Lys» for de som foretrekker lys skjerm.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-19 06:41:09 +00:00
parent 149046572f
commit 28f3b17261
2 changed files with 97 additions and 72 deletions

View file

@ -499,42 +499,28 @@
</div> </div>
<!-- Per-color sliders --> <!-- 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)}
<div class="settings-color-group"> <div class="settings-color-group">
<div class="settings-color-label">Bakgrunn</div> <div class="settings-color-label">{label}</div>
<div class="settings-color-row"> <div class="settings-color-row">
<input type="range" class="settings-hue-slider" min="0" max="360" <input type="range" class="settings-hue-slider" min="0" max="360"
value={theme.bg.hue} value={theme[key].hue}
oninput={(e) => setThemeColor('bg', 'hue', +e.currentTarget.value)} /> oninput={(e) => setThemeColor(key, 'hue', +e.currentTarget.value)} />
<input type="range" class="settings-sat-slider" min="0" max="30" <input type="range" class="settings-light-slider" min="0" max="100"
value={theme.bg.saturation} value={theme[key].lightness}
oninput={(e) => setThemeColor('bg', 'saturation', +e.currentTarget.value)} /> oninput={(e) => setThemeColor(key, 'lightness', +e.currentTarget.value)} />
<span class="settings-color-swatch" style:background={themeColorCSS(theme.bg, 12)}></span>
</div>
</div>
<div class="settings-color-group">
<div class="settings-color-label">Overflate</div>
<div class="settings-color-row">
<input type="range" class="settings-hue-slider" min="0" max="360"
value={theme.surface.hue}
oninput={(e) => setThemeColor('surface', 'hue', +e.currentTarget.value)} />
<input type="range" class="settings-sat-slider" min="0" max="30"
value={theme.surface.saturation}
oninput={(e) => setThemeColor('surface', 'saturation', +e.currentTarget.value)} />
<span class="settings-color-swatch" style:background={themeColorCSS(theme.surface, 20)}></span>
</div>
</div>
<div class="settings-color-group">
<div class="settings-color-label">Aksent</div>
<div class="settings-color-row">
<input type="range" class="settings-hue-slider" min="0" max="360"
value={theme.accent.hue}
oninput={(e) => setThemeColor('accent', 'hue', +e.currentTarget.value)} />
<input type="range" class="settings-sat-slider" min="0" max="100" <input type="range" class="settings-sat-slider" min="0" max="100"
value={theme.accent.saturation} value={theme[key].saturation}
oninput={(e) => setThemeColor('accent', 'saturation', +e.currentTarget.value)} /> oninput={(e) => setThemeColor(key, 'saturation', +e.currentTarget.value)} />
<span class="settings-color-swatch" style:background={themeColorCSS(theme.accent, 60)}></span> <span class="settings-color-swatch"
style:background={themeColorCSS(theme[key], theme[key].lightness)}></span>
</div> </div>
</div> </div>
{/each}
<div class="settings-divider"></div> <div class="settings-divider"></div>
{#if $page.data.session?.user} {#if $page.data.session?.user}
@ -818,7 +804,7 @@
.settings-color-row { display: flex; align-items: center; gap: 6px; } .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-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-hue-slider, .settings-sat-slider, .settings-light-slider {
flex: 1; flex: 1;
height: 6px; height: 6px;
-webkit-appearance: none; -webkit-appearance: none;
@ -837,11 +823,17 @@
} }
.settings-sat-slider { .settings-sat-slider {
background: linear-gradient(to right, background: linear-gradient(to right,
hsl(0,0%,40%), hsl(0,50%,50%) hsl(0,0%,40%), hsl(0,100%,50%)
) !important;
}
.settings-light-slider {
background: linear-gradient(to right,
hsl(0,0%,0%), hsl(0,0%,50%), hsl(0,0%,100%)
) !important; ) !important;
} }
.settings-hue-slider::-webkit-slider-thumb, .settings-hue-slider::-webkit-slider-thumb,
.settings-sat-slider::-webkit-slider-thumb { .settings-sat-slider::-webkit-slider-thumb,
.settings-light-slider::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
width: 14px; height: 14px; border-radius: 50%; width: 14px; height: 14px; border-radius: 50%;
background: white; border: 2px solid rgba(0,0,0,0.3); cursor: pointer; background: white; border: 2px solid rgba(0,0,0,0.3); cursor: pointer;

View file

@ -11,10 +11,11 @@
* 3. Plattform-default (DEFAULT_THEME) * 3. Plattform-default (DEFAULT_THEME)
*/ */
/** Color definition: hue + saturation. Lightness is derived per use. */ /** Color definition: full HSL control. */
export interface ThemeColor { export interface ThemeColor {
hue: number; // 0-360 hue: number; // 0-360
saturation: number; // 0-100 saturation: number; // 0-100
lightness: number; // 0-100
} }
/** Full theme configuration */ /** Full theme configuration */
@ -26,9 +27,9 @@ export interface ThemeConfig {
/** Platform default — neutral dark with indigo accent */ /** Platform default — neutral dark with indigo accent */
export const DEFAULT_THEME: ThemeConfig = { export const DEFAULT_THEME: ThemeConfig = {
bg: { hue: 0, saturation: 0 }, bg: { hue: 0, saturation: 0, lightness: 4 },
surface: { hue: 0, saturation: 0 }, surface: { hue: 0, saturation: 0, lightness: 12 },
accent: { hue: 239, saturation: 70 }, accent: { hue: 239, saturation: 70, lightness: 60 },
}; };
/** Named theme presets */ /** Named theme presets */
@ -45,41 +46,49 @@ export const THEME_PRESETS: ThemePreset[] = [
{ {
name: 'Hav', name: 'Hav',
theme: { theme: {
bg: { hue: 210, saturation: 15 }, bg: { hue: 210, saturation: 15, lightness: 4 },
surface: { hue: 210, saturation: 12 }, surface: { hue: 210, saturation: 12, lightness: 12 },
accent: { hue: 200, saturation: 75 }, accent: { hue: 200, saturation: 75, lightness: 60 },
}, },
}, },
{ {
name: 'Skog', name: 'Skog',
theme: { theme: {
bg: { hue: 150, saturation: 12 }, bg: { hue: 150, saturation: 12, lightness: 4 },
surface: { hue: 150, saturation: 10 }, surface: { hue: 150, saturation: 10, lightness: 12 },
accent: { hue: 142, saturation: 65 }, accent: { hue: 142, saturation: 65, lightness: 55 },
}, },
}, },
{ {
name: 'Solnedgang', name: 'Solnedgang',
theme: { theme: {
bg: { hue: 15, saturation: 12 }, bg: { hue: 15, saturation: 12, lightness: 5 },
surface: { hue: 15, saturation: 10 }, surface: { hue: 15, saturation: 10, lightness: 13 },
accent: { hue: 25, saturation: 80 }, accent: { hue: 25, saturation: 80, lightness: 60 },
}, },
}, },
{ {
name: 'Lavendel', name: 'Lavendel',
theme: { theme: {
bg: { hue: 270, saturation: 10 }, bg: { hue: 270, saturation: 10, lightness: 5 },
surface: { hue: 270, saturation: 8 }, surface: { hue: 270, saturation: 8, lightness: 13 },
accent: { hue: 280, saturation: 65 }, 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', name: 'Monokrom',
theme: { theme: {
bg: { hue: 0, saturation: 0 }, bg: { hue: 0, saturation: 0, lightness: 4 },
surface: { hue: 0, saturation: 0 }, surface: { hue: 0, saturation: 0, lightness: 12 },
accent: { hue: 0, saturation: 0 }, accent: { hue: 0, saturation: 0, lightness: 60 },
}, },
}, },
]; ];
@ -98,22 +107,40 @@ export function applyTheme(config: ThemeConfig): void {
const root = document.documentElement; const root = document.documentElement;
const { bg, surface, accent } = config; const { bg, surface, accent } = config;
const isDark = bg.lightness < 50;
// Background: very dark, slight tint // Background
root.style.setProperty('--color-bg', hsl(bg.hue, bg.saturation, 4)); root.style.setProperty('--color-bg', hsl(bg.hue, bg.saturation, bg.lightness));
// Surface: slightly lighter // Surface: offset from background
root.style.setProperty('--color-surface', hsl(surface.hue, surface.saturation, 12)); root.style.setProperty('--color-surface', hsl(surface.hue, surface.saturation, surface.lightness));
root.style.setProperty('--color-surface-hover', hsl(surface.hue, Math.max(0, surface.saturation - 2), 15)); const hoverOffset = isDark ? 3 : -3;
root.style.setProperty('--color-surface-hover', hsl(surface.hue, surface.saturation, clamp(surface.lightness + hoverOffset)));
// Border: derived from surface // Border: derived from surface
root.style.setProperty('--color-border', hsl(surface.hue, Math.max(0, surface.saturation - 3), 18)); const borderOffset = isDark ? 6 : -8;
root.style.setProperty('--color-border-hover', hsl(surface.hue, Math.max(0, surface.saturation - 3), 24)); 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 // Accent
root.style.setProperty('--color-accent', hsl(accent.hue, accent.saturation, 60)); root.style.setProperty('--color-accent', hsl(accent.hue, accent.saturation, accent.lightness));
root.style.setProperty('--color-accent-hover', hsl(accent.hue, accent.saturation, 65)); 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, 60, 0.15)); 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) */ /** Reset theme to platform defaults (remove inline styles) */
@ -139,17 +166,23 @@ export function loadThemeFromMetadata(meta: Record<string, unknown>): ThemeConfi
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;
// New format // New format (with lightness)
if (theme.bg && typeof theme.bg === 'object') { if (theme.bg && typeof theme.bg === 'object') {
return theme as unknown as ThemeConfig; const t = theme as unknown as ThemeConfig;
// Ensure lightness exists (backcompat with intermediate format)
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 },
};
} }
// Old format: { hueBg, hueSurface, hueAccent } // Old format: { hueBg, hueSurface, hueAccent }
if (typeof theme.hueBg === 'number' || typeof theme.hueAccent === 'number') { if (typeof theme.hueBg === 'number' || typeof theme.hueAccent === 'number') {
return { return {
bg: { hue: (theme.hueBg as number) ?? 0, saturation: (theme.hueBg as number) ? 10 : 0 }, 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 }, 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 }, accent: { hue: (theme.hueAccent as number) ?? 239, saturation: 70, lightness: 60 },
}; };
} }