Skip to content

Commit fc5efb5

Browse files
committed
Add OKLCH editor with tabs and polish color picker sidebar
1 parent 0eb3e4c commit fc5efb5

3 files changed

Lines changed: 148 additions & 131 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<script lang="ts">
2+
let {
3+
label,
4+
value,
5+
max,
6+
step = 1,
7+
display,
8+
gradient,
9+
disabled = false,
10+
onchange,
11+
}: {
12+
label: string;
13+
value: number;
14+
max: number;
15+
step?: number | string;
16+
display: string;
17+
gradient: string;
18+
disabled?: boolean;
19+
onchange: (value: number) => void;
20+
} = $props();
21+
22+
let percent = $derived((value / max) * 100);
23+
</script>
24+
25+
<div class="flex items-center gap-2">
26+
<span class="text-fg-dimmed w-3 font-mono text-[10px]">{label}</span>
27+
28+
<div
29+
class="border-border group relative h-4 flex-1 border"
30+
style:background={gradient}
31+
>
32+
<input
33+
type="range"
34+
class="absolute inset-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
35+
min="0"
36+
{max}
37+
{step}
38+
{value}
39+
oninput={e => onchange(parseFloat(e.currentTarget.value))}
40+
{disabled}
41+
aria-label={label}
42+
aria-valuetext={display}
43+
/>
44+
<!-- Thumb: white bar with subtle dark ring + drop shadow so it's legible
45+
on any gradient. Offset by half its width so the value position is
46+
dead-center of the bar, not at its left edge. -->
47+
<div
48+
class="pointer-events-none absolute inset-y-[-1px] w-[3px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.4),0_1px_3px_rgba(0,0,0,0.35)] transition-[width] group-hover:w-1"
49+
style:left="calc({percent}% - 1.5px)"
50+
></div>
51+
</div>
52+
53+
<span
54+
class="text-fg-dimmed w-12 text-right font-mono text-[10px] tabular-nums"
55+
>{display}</span
56+
>
57+
</div>

frontend/src/lib/components/color-picker/ColorPickerDialog.svelte

Lines changed: 81 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
closeColorPicker,
88
getEyedropperActive,
99
setEyedropperActive,
10+
getColorPickerModel,
11+
setColorPickerModel,
12+
COLOR_MODELS,
13+
type ColorModel,
1014
} from '$lib/stores/ui.svelte';
1115
import {
1216
getPalette,
@@ -40,6 +44,8 @@
4044
pushRecentColor,
4145
} from '$lib/stores/recentColors.svelte';
4246
import ShadeGrid from './ShadeGrid.svelte';
47+
import ChannelSlider from './ChannelSlider.svelte';
48+
import LockIcon from '$lib/components/shared/LockIcon.svelte';
4349
4450
const ROLE_LABELS: Record<string, string> = {
4551
background: 'Background',
@@ -186,12 +192,19 @@
186192
187193
const HARMONY: {label: string; delta: number; title: string}[] = [
188194
{label: 'An−', delta: -30, title: 'Analogous −30°'},
189-
{label: 'Tri', delta: 120, title: 'Triad +120°'},
195+
{label: 'Tri+', delta: 120, title: 'Triad +120°'},
190196
{label: 'Comp', delta: 180, title: 'Complement +180°'},
191-
{label: 'Tri', delta: 240, title: 'Triad +240°'},
197+
{label: 'Tri', delta: 240, title: 'Triad +240° (−120°)'},
192198
{label: 'An+', delta: 30, title: 'Analogous +30°'},
193199
];
194200
201+
const MODEL_LABELS: Record<ColorModel, string> = {
202+
rgb: 'RGB',
203+
hsl: 'HSL',
204+
oklch: 'OKLCH',
205+
};
206+
let activeModel = $derived(getColorPickerModel());
207+
195208
let harmonyColors = $derived(
196209
HARMONY.map(h => ({
197210
...h,
@@ -402,13 +415,21 @@
402415
<div class="space-y-4 p-4">
403416
<div class="flex gap-3">
404417
<label
405-
class="border-border relative h-20 w-20 shrink-0 cursor-pointer overflow-hidden border"
418+
class="border-border relative h-20 w-20 shrink-0 overflow-hidden border {locked
419+
? 'cursor-not-allowed'
420+
: 'cursor-pointer'}"
406421
>
407422
<div
408423
class="absolute inset-0"
409424
style:background-color={currentColor}
410425
></div>
411-
{#if !locked}
426+
{#if locked}
427+
<div
428+
class="absolute inset-0 flex items-center justify-center bg-black/45 text-white"
429+
>
430+
<LockIcon locked={true} size="h-6 w-6" />
431+
</div>
432+
{:else}
412433
<input
413434
type="color"
414435
value={currentColor}
@@ -426,7 +447,7 @@
426447
>
427448
<input
428449
type="text"
429-
class="text-fg-primary bg-bg-secondary w-full border px-2.5 py-1.5 font-mono text-[13px] outline-none
450+
class="text-fg-primary bg-bg-secondary w-full border px-2.5 py-1.5 font-mono text-[13px] outline-none disabled:cursor-not-allowed disabled:opacity-50
430451
{isValid
431452
? 'focus:border-accent border-border'
432453
: 'border-destructive'}"
@@ -440,7 +461,10 @@
440461
</div>
441462
{#if !isExtended && !isOverride}
442463
<div class="mt-2 flex items-center justify-between">
443-
<span class="text-fg-dimmed text-[10px]">Locked</span>
464+
<span
465+
class="text-fg-dimmed text-[9px] uppercase tracking-wider"
466+
>Lock</span
467+
>
444468
<button
445469
class="relative h-5 w-9 transition-colors duration-150
446470
{locked
@@ -517,133 +541,59 @@
517541
</div>
518542
</div>
519543

520-
<div class="space-y-2">
521-
<span class="text-fg-dimmed text-[9px] uppercase tracking-wider"
522-
>RGB Channels</span
523-
>
524-
{#each RGB_CHANNELS as channel}
525-
<div class="flex items-center gap-2">
526-
<span class="text-fg-dimmed w-3 font-mono text-[10px]"
527-
>{RGB_LABELS[channel]}</span
528-
>
529-
<div
530-
class="relative h-3 flex-1"
531-
style="background: {channelGradient(
532-
channel
533-
)}; border: 1px solid var(--color-border);"
534-
>
535-
<input
536-
type="range"
537-
class="absolute inset-0 h-full w-full cursor-pointer opacity-0"
538-
min="0"
539-
max="255"
540-
step="1"
541-
value={rgb[channel]}
542-
oninput={e =>
543-
handleRgbChange(
544-
channel,
545-
parseInt(e.currentTarget.value)
546-
)}
547-
disabled={locked}
548-
/>
549-
<div
550-
class="pointer-events-none absolute bottom-0 top-0 w-0.5 bg-white shadow-sm"
551-
style:left="{(rgb[channel] / 255) * 100}%"
552-
></div>
553-
</div>
554-
<span
555-
class="text-fg-dimmed w-7 text-right font-mono text-[10px]"
556-
>{rgb[channel]}</span
557-
>
558-
</div>
559-
{/each}
560-
</div>
561-
562-
<div class="space-y-2">
563-
<span class="text-fg-dimmed text-[9px] uppercase tracking-wider"
564-
>HSL Channels</span
565-
>
566-
{#each HSL_CHANNELS as channel}
567-
<div class="flex items-center gap-2">
568-
<span class="text-fg-dimmed w-3 font-mono text-[10px]"
569-
>{HSL_LABELS[channel]}</span
570-
>
571-
<div
572-
class="relative h-3 flex-1"
573-
style="background: {hslChannelGradient(
574-
channel
575-
)}; border: 1px solid var(--color-border);"
576-
>
577-
<input
578-
type="range"
579-
class="absolute inset-0 h-full w-full cursor-pointer opacity-0"
580-
min="0"
581-
max={HSL_MAX[channel]}
582-
step="1"
583-
value={hsl[channel]}
584-
oninput={e =>
585-
handleHslChange(
586-
channel,
587-
parseFloat(e.currentTarget.value)
588-
)}
589-
disabled={locked}
590-
/>
591-
<div
592-
class="pointer-events-none absolute bottom-0 top-0 w-0.5 bg-white shadow-sm"
593-
style:left="{(hsl[channel] / HSL_MAX[channel]) *
594-
100}%"
595-
></div>
596-
</div>
597-
<span
598-
class="text-fg-dimmed w-7 text-right font-mono text-[10px] tabular-nums"
599-
>{Math.round(hsl[channel])}{HSL_SUFFIX[channel]}</span
544+
<div class="space-y-2.5">
545+
<div class="flex items-center gap-1">
546+
{#each COLOR_MODELS as id}
547+
<button
548+
type="button"
549+
class="border px-2 py-0.5 text-[9px] uppercase tracking-wider transition-colors
550+
{activeModel === id
551+
? 'text-accent border-accent bg-accent-muted'
552+
: 'text-fg-dimmed border-border hover:text-fg-secondary'}"
553+
onclick={() => setColorPickerModel(id)}
554+
aria-pressed={activeModel === id}
555+
>{MODEL_LABELS[id]}</button
600556
>
601-
</div>
602-
{/each}
603-
</div>
557+
{/each}
558+
</div>
604559

605-
<div class="space-y-2">
606-
<span class="text-fg-dimmed text-[9px] uppercase tracking-wider"
607-
>OKLCH Channels</span
608-
>
609-
{#each OKLCH_CHANNELS as channel}
610-
<div class="flex items-center gap-2">
611-
<span class="text-fg-dimmed w-3 font-mono text-[10px]"
612-
>{OKLCH_LABELS[channel]}</span
613-
>
614-
<div
615-
class="relative h-3 flex-1"
616-
style="background: {oklchChannelGradient(
617-
channel
618-
)}; border: 1px solid var(--color-border);"
619-
>
620-
<input
621-
type="range"
622-
class="absolute inset-0 h-full w-full cursor-pointer opacity-0"
623-
min="0"
624-
max={OKLCH_MAX[channel]}
625-
step="1"
626-
value={oklchSlider[channel]}
627-
oninput={e =>
628-
handleOklchChange(
629-
channel,
630-
parseFloat(e.currentTarget.value)
631-
)}
632-
disabled={locked}
633-
/>
634-
<div
635-
class="pointer-events-none absolute bottom-0 top-0 w-0.5 bg-white shadow-sm"
636-
style:left="{(oklchSlider[channel] /
637-
OKLCH_MAX[channel]) *
638-
100}%"
639-
></div>
640-
</div>
641-
<span
642-
class="text-fg-dimmed w-10 text-right font-mono text-[10px] tabular-nums"
643-
>{formatOklch(channel)}</span
644-
>
645-
</div>
646-
{/each}
560+
{#if activeModel === 'rgb'}
561+
{#each RGB_CHANNELS as channel}
562+
<ChannelSlider
563+
label={RGB_LABELS[channel]}
564+
value={rgb[channel]}
565+
max={255}
566+
display={String(rgb[channel])}
567+
gradient={channelGradient(channel)}
568+
disabled={locked}
569+
onchange={v => handleRgbChange(channel, Math.round(v))}
570+
/>
571+
{/each}
572+
{:else if activeModel === 'hsl'}
573+
{#each HSL_CHANNELS as channel}
574+
<ChannelSlider
575+
label={HSL_LABELS[channel]}
576+
value={hsl[channel]}
577+
max={HSL_MAX[channel]}
578+
display={`${Math.round(hsl[channel])}${HSL_SUFFIX[channel]}`}
579+
gradient={hslChannelGradient(channel)}
580+
disabled={locked}
581+
onchange={v => handleHslChange(channel, v)}
582+
/>
583+
{/each}
584+
{:else}
585+
{#each OKLCH_CHANNELS as channel}
586+
<ChannelSlider
587+
label={OKLCH_LABELS[channel]}
588+
value={oklchSlider[channel]}
589+
max={OKLCH_MAX[channel]}
590+
display={formatOklch(channel)}
591+
gradient={oklchChannelGradient(channel)}
592+
disabled={locked}
593+
onchange={v => handleOklchChange(channel, v)}
594+
/>
595+
{/each}
596+
{/if}
647597
</div>
648598

649599
{#if getRecentColors().length > 0}

frontend/src/lib/stores/ui.svelte.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ export type Tab =
66
| 'blueprints'
77
| 'system';
88

9+
export const COLOR_MODELS = ['rgb', 'hsl', 'oklch'] as const;
10+
export type ColorModel = (typeof COLOR_MODELS)[number];
11+
912
// --- Reactive state ---
1013
let activeTab = $state<Tab>('editor');
1114
let sidebarVisible = $state<boolean>(true);
@@ -17,6 +20,7 @@ let colorPickerExtKey = $state<string>(''); // non-empty = editing an extended c
1720
let colorPickerOverrideApp = $state<string>(''); // non-empty = editing an app override
1821
let colorPickerOverrideRole = $state<string>(''); // the color role being overridden
1922
let eyedropperActive = $state<boolean>(false);
23+
let colorPickerModel = $state<ColorModel>('rgb');
2024
let commandPaletteOpen = $state<boolean>(false);
2125
let keymapOpen = $state<boolean>(false);
2226
let imageEditorOpen = $state<boolean>(false);
@@ -52,6 +56,12 @@ export function getColorPickerOverrideRole(): string {
5256
export function getEyedropperActive(): boolean {
5357
return eyedropperActive;
5458
}
59+
export function getColorPickerModel(): ColorModel {
60+
return colorPickerModel;
61+
}
62+
export function setColorPickerModel(m: ColorModel): void {
63+
colorPickerModel = m;
64+
}
5565

5666
// --- Actions ---
5767
export function setActiveTab(tab: Tab): void {

0 commit comments

Comments
 (0)