Skip to content

Commit 88931fd

Browse files
committed
Add harmony swatches, recent colors, and palette variant nudges
Color picker gains a row of 5 harmony swatches (analogous, triad, complement) that rotate the hue while holding saturation and lightness, plus a 12-slot recent-colors grid persisted in localStorage across sessions. Recents are debounced and skip writes when the color is already most-recent. Color Adjustments sidebar gains 6 nudge buttons (hue ±15°, saturation ±15%, warmer/cooler) that bump the existing adjustment pipeline. Rapid clicks coalesce into a single undo entry.
1 parent c4d56ec commit 88931fd

4 files changed

Lines changed: 218 additions & 6 deletions

File tree

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,20 @@
2323
ANSI_COLOR_NAMES,
2424
EXTENDED_COLOR_LABELS,
2525
} from '$lib/constants/colors';
26+
import {onDestroy} from 'svelte';
2627
import {
2728
hexToRgb,
2829
rgbToHex,
2930
hexToHsl,
3031
hslToHex,
32+
isValidHex,
3133
relativeLuminance,
3234
contrastLevel,
3335
} from '$lib/utils/color';
36+
import {
37+
getRecentColors,
38+
pushRecentColor,
39+
} from '$lib/stores/recentColors.svelte';
3440
import ShadeGrid from './ShadeGrid.svelte';
3541
3642
const ROLE_LABELS: Record<string, string> = {
@@ -176,6 +182,34 @@
176182
applyColor(hslToHex(next.h, next.s, next.l));
177183
}
178184
185+
const HARMONY: {label: string; delta: number; title: string}[] = [
186+
{label: 'An−', delta: -30, title: 'Analogous −30°'},
187+
{label: 'Tri', delta: 120, title: 'Triad +120°'},
188+
{label: 'Comp', delta: 180, title: 'Complement +180°'},
189+
{label: 'Tri', delta: 240, title: 'Triad +240°'},
190+
{label: 'An+', delta: 30, title: 'Analogous +30°'},
191+
];
192+
193+
let harmonyColors = $derived(
194+
HARMONY.map(h => ({
195+
...h,
196+
hex: hslToHex(hsl.h + h.delta, hsl.s, hsl.l),
197+
}))
198+
);
199+
200+
// Debounced so slider drags don't flood the recents list with intermediate
201+
// hexes; only the value the user settles on is recorded.
202+
let recordTimer: ReturnType<typeof setTimeout> | null = null;
203+
$effect(() => {
204+
const hex = currentColor;
205+
if (!isValidHex(hex)) return;
206+
if (recordTimer) clearTimeout(recordTimer);
207+
recordTimer = setTimeout(() => pushRecentColor(hex), 600);
208+
});
209+
onDestroy(() => {
210+
if (recordTimer) clearTimeout(recordTimer);
211+
});
212+
179213
// When editing an app override, prefer the role map's computed bg/fg so
180214
// the ratio reflects what the target app will actually render against.
181215
let contrastBg = $derived(
@@ -407,6 +441,32 @@
407441
</div>
408442
{/if}
409443

444+
<div class="space-y-1.5">
445+
<span class="text-fg-dimmed text-[9px] uppercase tracking-wider"
446+
>Harmony</span
447+
>
448+
<div class="flex gap-1">
449+
{#each harmonyColors as h}
450+
<button
451+
type="button"
452+
class="border-border hover:border-border-focus flex flex-1 flex-col items-stretch border transition-colors disabled:opacity-40"
453+
onclick={() => applyColor(h.hex)}
454+
disabled={locked}
455+
title="{h.title} · {h.hex}"
456+
>
457+
<div
458+
class="h-6 w-full"
459+
style:background-color={h.hex}
460+
></div>
461+
<span
462+
class="text-fg-dimmed py-0.5 text-center text-[8px] tabular-nums leading-none"
463+
>{h.label}</span
464+
>
465+
</button>
466+
{/each}
467+
</div>
468+
</div>
469+
410470
<div class="space-y-2">
411471
<span class="text-fg-dimmed text-[9px] uppercase tracking-wider"
412472
>RGB Channels</span
@@ -492,6 +552,26 @@
492552
{/each}
493553
</div>
494554

555+
{#if getRecentColors().length > 0}
556+
<div class="space-y-1.5">
557+
<span class="text-fg-dimmed text-[9px] uppercase tracking-wider"
558+
>Recent</span
559+
>
560+
<div class="grid grid-cols-12 gap-1">
561+
{#each getRecentColors() as hex}
562+
<button
563+
type="button"
564+
class="border-border hover:border-border-focus aspect-square border transition-colors disabled:opacity-40"
565+
style:background-color={hex}
566+
onclick={() => applyColor(hex)}
567+
disabled={locked}
568+
title={hex}
569+
></button>
570+
{/each}
571+
</div>
572+
</div>
573+
{/if}
574+
495575
<div>
496576
<span
497577
class="text-fg-dimmed mb-2 block text-[9px] uppercase tracking-wider"

frontend/src/lib/components/sidebar/ColorAdjustments.svelte

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,74 @@
180180
setPaletteCurvePoints([]);
181181
setPalette(getBasePalette(), true);
182182
}
183+
184+
type AdjustmentKey = (typeof sliderDefs)[number]['key'];
185+
186+
// Rapid nudge clicks coalesce into a single undo entry — snapshot the
187+
// state before the first click in a burst, commit once the user stops.
188+
let nudgeSnapshot: {
189+
palette: string[];
190+
ext: Record<string, string>;
191+
adj: Adjustments;
192+
} | null = null;
193+
let nudgeCommitTimer: ReturnType<typeof setTimeout> | null = null;
194+
195+
function nudge(key: AdjustmentKey, delta: number) {
196+
const current = getAdjustments();
197+
const limits = ADJUSTMENT_LIMITS[key];
198+
const next = Math.max(
199+
limits.min,
200+
Math.min(limits.max, current[key] + delta)
201+
);
202+
if (next === current[key]) return;
203+
204+
if (!nudgeSnapshot) {
205+
nudgeSnapshot = {
206+
palette: [...getPalette()],
207+
ext: {...getExtendedColors()},
208+
adj: {...current},
209+
};
210+
}
211+
if (nudgeCommitTimer) clearTimeout(nudgeCommitTimer);
212+
nudgeCommitTimer = setTimeout(() => {
213+
if (nudgeSnapshot) {
214+
pushState(
215+
nudgeSnapshot.palette,
216+
nudgeSnapshot.ext,
217+
nudgeSnapshot.adj
218+
);
219+
nudgeSnapshot = null;
220+
}
221+
}, 500);
222+
223+
const newAdj = {...current, [key]: next} as Adjustments;
224+
setAdjustments(newAdj);
225+
applyAdjustments(newAdj);
226+
}
227+
228+
const VARIANTS: {
229+
label: string;
230+
title: string;
231+
key: AdjustmentKey;
232+
delta: number;
233+
}[] = [
234+
{label: '−H', title: 'Shift hue −15°', key: 'hueShift', delta: -15},
235+
{label: '+H', title: 'Shift hue +15°', key: 'hueShift', delta: 15},
236+
{label: '−S', title: 'Desaturate 15%', key: 'saturation', delta: -15},
237+
{label: '+S', title: 'Saturate 15%', key: 'saturation', delta: 15},
238+
{
239+
label: '',
240+
title: 'Cooler (temperature −15)',
241+
key: 'temperature',
242+
delta: -15,
243+
},
244+
{
245+
label: '',
246+
title: 'Warmer (temperature +15)',
247+
key: 'temperature',
248+
delta: 15,
249+
},
250+
];
183251
</script>
184252

185253
<ExpandableSection title="Color Adjustments" bind:expanded>
@@ -191,12 +259,25 @@
191259
/>
192260
</div>
193261

194-
<button
195-
class="text-fg-dimmed hover:text-fg-secondary mb-2 text-[10px]"
196-
onclick={resetAll}
197-
>
198-
Reset All
199-
</button>
262+
<div class="mb-2 flex items-center justify-between gap-2">
263+
<div class="flex gap-1">
264+
{#each VARIANTS as v}
265+
<button
266+
type="button"
267+
class="text-fg-secondary hover:text-fg-primary border-border hover:bg-bg-surface border px-1.5 py-0.5 text-[10px] tabular-nums transition-colors"
268+
onclick={() => nudge(v.key, v.delta)}
269+
title={v.title}
270+
aria-label={v.title}>{v.label}</button
271+
>
272+
{/each}
273+
</div>
274+
<button
275+
class="text-fg-dimmed hover:text-fg-secondary text-[10px]"
276+
onclick={resetAll}
277+
>
278+
Reset All
279+
</button>
280+
</div>
200281
<div class="flex flex-col gap-1.5">
201282
{#each sliderDefs as def}
202283
<AdjustmentSlider
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {isValidHex} from '$lib/utils/color';
2+
3+
const STORAGE_KEY = 'aether.recentColors';
4+
const MAX_RECENT = 12;
5+
6+
function load(): string[] {
7+
try {
8+
const raw = localStorage.getItem(STORAGE_KEY);
9+
if (!raw) return [];
10+
const parsed = JSON.parse(raw);
11+
if (!Array.isArray(parsed)) return [];
12+
return parsed.filter(isValidHex).slice(0, MAX_RECENT);
13+
} catch {
14+
return [];
15+
}
16+
}
17+
18+
function persist(colors: string[]) {
19+
try {
20+
localStorage.setItem(STORAGE_KEY, JSON.stringify(colors));
21+
} catch {
22+
// quota / storage disabled
23+
}
24+
}
25+
26+
let recent = $state<string[]>(load());
27+
28+
export function getRecentColors(): string[] {
29+
return recent;
30+
}
31+
32+
export function pushRecentColor(hex: string): void {
33+
if (!isValidHex(hex)) return;
34+
const norm = hex.toLowerCase();
35+
if (recent[0]?.toLowerCase() === norm) return;
36+
const next = [hex, ...recent.filter(c => c.toLowerCase() !== norm)].slice(
37+
0,
38+
MAX_RECENT
39+
);
40+
recent = next;
41+
persist(next);
42+
}
43+
44+
export function clearRecentColors(): void {
45+
recent = [];
46+
persist([]);
47+
}

frontend/src/lib/utils/color.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export function isLightColor(hex: string): boolean {
1212
return (r * 299 + g * 587 + b * 114) / 1000 > 128;
1313
}
1414

15+
export function isValidHex(hex: string): boolean {
16+
return typeof hex === 'string' && /^#[0-9a-fA-F]{6}$/.test(hex);
17+
}
18+
1519
export function hexToRgb(hex: string): {r: number; g: number; b: number} {
1620
if (!hex || hex.length < 7) return {r: 0, g: 0, b: 0};
1721
return {

0 commit comments

Comments
 (0)