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>
<!-- 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-label">Bakgrunn</div>
<div class="settings-color-label">{label}</div>
<div class="settings-color-row">
<input type="range" class="settings-hue-slider" min="0" max="360"
value={theme.bg.hue}
oninput={(e) => setThemeColor('bg', 'hue', +e.currentTarget.value)} />
<input type="range" class="settings-sat-slider" min="0" max="30"
value={theme.bg.saturation}
oninput={(e) => setThemeColor('bg', 'saturation', +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)} />
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)} />
<input type="range" class="settings-sat-slider" min="0" max="100"
value={theme.accent.saturation}
oninput={(e) => setThemeColor('accent', 'saturation', +e.currentTarget.value)} />
<span class="settings-color-swatch" style:background={themeColorCSS(theme.accent, 60)}></span>
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>
</div>
</div>
{/each}
<div class="settings-divider"></div>
{#if $page.data.session?.user}
@ -818,7 +804,7 @@
.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-hue-slider, .settings-sat-slider, .settings-light-slider {
flex: 1;
height: 6px;
-webkit-appearance: none;
@ -837,11 +823,17 @@
}
.settings-sat-slider {
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;
}
.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;
width: 14px; height: 14px; border-radius: 50%;
background: white; border: 2px solid rgba(0,0,0,0.3); cursor: pointer;

View file

@ -11,10 +11,11 @@
* 3. Plattform-default (DEFAULT_THEME)
*/
/** Color definition: hue + saturation. Lightness is derived per use. */
/** Color definition: full HSL control. */
export interface ThemeColor {
hue: number; // 0-360
saturation: number; // 0-100
lightness: number; // 0-100
}
/** Full theme configuration */
@ -26,9 +27,9 @@ export interface ThemeConfig {
/** Platform default — neutral dark with indigo accent */
export const DEFAULT_THEME: ThemeConfig = {
bg: { hue: 0, saturation: 0 },
surface: { hue: 0, saturation: 0 },
accent: { hue: 239, saturation: 70 },
bg: { hue: 0, saturation: 0, lightness: 4 },
surface: { hue: 0, saturation: 0, lightness: 12 },
accent: { hue: 239, saturation: 70, lightness: 60 },
};
/** Named theme presets */
@ -45,41 +46,49 @@ export const THEME_PRESETS: ThemePreset[] = [
{
name: 'Hav',
theme: {
bg: { hue: 210, saturation: 15 },
surface: { hue: 210, saturation: 12 },
accent: { hue: 200, saturation: 75 },
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 },
surface: { hue: 150, saturation: 10 },
accent: { hue: 142, saturation: 65 },
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 },
surface: { hue: 15, saturation: 10 },
accent: { hue: 25, saturation: 80 },
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 },
surface: { hue: 270, saturation: 8 },
accent: { hue: 280, saturation: 65 },
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 },
surface: { hue: 0, saturation: 0 },
accent: { hue: 0, saturation: 0 },
bg: { hue: 0, saturation: 0, lightness: 4 },
surface: { hue: 0, saturation: 0, lightness: 12 },
accent: { hue: 0, saturation: 0, lightness: 60 },
},
},
];
@ -98,22 +107,40 @@ export function applyTheme(config: ThemeConfig): void {
const root = document.documentElement;
const { bg, surface, accent } = config;
const isDark = bg.lightness < 50;
// Background: very dark, slight tint
root.style.setProperty('--color-bg', hsl(bg.hue, bg.saturation, 4));
// Background
root.style.setProperty('--color-bg', hsl(bg.hue, bg.saturation, bg.lightness));
// Surface: slightly lighter
root.style.setProperty('--color-surface', hsl(surface.hue, surface.saturation, 12));
root.style.setProperty('--color-surface-hover', hsl(surface.hue, Math.max(0, surface.saturation - 2), 15));
// 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
root.style.setProperty('--color-border', hsl(surface.hue, Math.max(0, surface.saturation - 3), 18));
root.style.setProperty('--color-border-hover', hsl(surface.hue, Math.max(0, surface.saturation - 3), 24));
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, 60));
root.style.setProperty('--color-accent-hover', hsl(accent.hue, accent.saturation, 65));
root.style.setProperty('--color-accent-glow', hsla(accent.hue, accent.saturation, 60, 0.15));
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) */
@ -139,17 +166,23 @@ export function loadThemeFromMetadata(meta: Record<string, unknown>): ThemeConfi
const theme = prefs?.theme as Record<string, unknown> | undefined;
if (!theme) return null;
// New format
// New format (with lightness)
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 }
if (typeof theme.hueBg === 'number' || typeof theme.hueAccent === 'number') {
return {
bg: { hue: (theme.hueBg as number) ?? 0, saturation: (theme.hueBg as number) ? 10 : 0 },
surface: { hue: (theme.hueSurface as number) ?? 0, saturation: (theme.hueSurface as number) ? 8 : 0 },
accent: { hue: (theme.hueAccent as number) ?? 239, saturation: 70 },
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 },
};
}