Skip to content

Commit 44107ed

Browse files
committed
Add OKLCH extraction modes and grouped mode picker
- New color-theory modes (complementary, triadic, split-complementary, tetradic), mood modes (fire, ocean, forest, earthtone, neon, sunset, vaporwave, midnight, aurora), and practical modes (high-contrast, duotone) - Refactor pastel/colorful/muted/bright from HSL to OKLCH so lightness is perceptually uniform; widen monochromatic chroma + analogous lightness alternation past JND so slots stay distinguishable - Replace mode dropdown with grouped collapsible list (Auto + Style open by default; Color Theory / Mood / Practical collapsed, show active mode inline when collapsed) - Bump cache version to 5; LoadCachedPalette now validates version
1 parent 0010792 commit 44107ed

14 files changed

Lines changed: 919 additions & 269 deletions

File tree

cli/cli.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,12 @@ var validModes = map[string]bool{
217217
"normal": true, "monochromatic": true, "analogous": true,
218218
"pastel": true, "material": true, "colorful": true,
219219
"muted": true, "bright": true,
220+
"complementary": true, "triadic": true,
221+
"split-complementary": true, "tetradic": true,
222+
"fire": true, "ocean": true, "forest": true,
223+
"earthtone": true, "neon": true, "sunset": true, "vaporwave": true,
224+
"midnight": true, "aurora": true,
225+
"high-contrast": true, "duotone": true,
220226
}
221227

222228
func expandHome(path string) string {

cli/inspect.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,26 @@ func runListModes(args []string) int {
6767
{"normal", "Auto-detect: monochrome or chromatic based on image analysis"},
6868
{"monochromatic", "Single-hue palette with varying lightness"},
6969
{"analogous", "Colors within ±30° hue range in OKLCH space"},
70-
{"pastel", "Soft, desaturated colors with high lightness"},
70+
{"pastel", "Soft, low-chroma, high-lightness palette in OKLCH"},
7171
{"material", "Material Design-inspired palette with balanced saturation"},
72-
{"colorful", "High-saturation, vibrant colors"},
73-
{"muted", "Low-saturation, subdued colors"},
74-
{"bright", "High-lightness colors with moderate saturation"},
72+
{"colorful", "High-chroma, vibrant palette in OKLCH"},
73+
{"muted", "Low-chroma, subdued palette with widened lightness stagger"},
74+
{"bright", "High-lightness palette with healthy chroma"},
75+
{"complementary", "Base hue and 180° opposite alternating across slots"},
76+
{"triadic", "Three hues at 120° spacing"},
77+
{"split-complementary", "Base hue plus 150°/210° (softer than complementary)"},
78+
{"tetradic", "Four hues at 90° spacing (rectangle on the wheel)"},
79+
{"fire", "Bonfire mood: deep dark bg, warm-shifted ANSI hues"},
80+
{"ocean", "Ocean mood: blue-black bg, cool-shifted ANSI hues"},
81+
{"forest", "Forest mood: green-black bg, sage-shifted ANSI hues"},
82+
{"earthtone", "Earth mood: warm dark brown bg, very low chroma ANSI"},
83+
{"neon", "Cyberpunk mood: very dark bg, max chroma narrow lightness band"},
84+
{"sunset", "Sunset mood: warm dark with magenta cast, peach fg"},
85+
{"vaporwave", "Vaporwave mood: pinks/purples/cyans on a soft dark"},
86+
{"midnight", "Midnight mood: deep indigo, subdued, peaceful"},
87+
{"aurora", "Aurora mood: shimmery green-cyan glow on deep blue-night"},
88+
{"high-contrast", "WCAG AAA (7:1) contrast against a hard-extreme background"},
89+
{"duotone", "Two hues only (base + complement) at varying lightness"},
7590
}
7691

7792
if jsonOut {

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

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,46 @@
99
setAdjustments,
1010
} from '$lib/stores/theme.svelte';
1111
import {showToast} from '$lib/stores/ui.svelte';
12-
import {EXTRACTION_MODES} from '$lib/constants/colors';
12+
import {
13+
EXTRACTION_MODES,
14+
EXTRACTION_MODE_GROUPS,
15+
type ExtractionMode,
16+
type ExtractionModeGroup,
17+
} from '$lib/constants/colors';
1318
import {DEFAULT_ADJUSTMENTS} from '$lib/types/theme';
19+
import ExpandableSection from '$lib/components/shared/ExpandableSection.svelte';
20+
21+
let expanded = $state(true);
22+
23+
const openGroups: Record<ExtractionModeGroup, boolean> = $state(
24+
Object.fromEntries(
25+
EXTRACTION_MODE_GROUPS.map(g => [g.id, g.defaultOpen])
26+
) as Record<ExtractionModeGroup, boolean>
27+
);
28+
29+
const grouped = $derived.by(() => {
30+
const out: Record<string, ExtractionMode[]> = {};
31+
for (const g of EXTRACTION_MODE_GROUPS) out[g.id] = [];
32+
for (const m of EXTRACTION_MODES) out[m.group].push(m);
33+
return out;
34+
});
35+
36+
function activeInGroup(groupId: ExtractionModeGroup) {
37+
return grouped[groupId]?.find(m => m.value === getExtractionMode());
38+
}
1439
1540
async function handleModeChange(mode: string) {
41+
if (mode === getExtractionMode()) return;
42+
1643
setExtractionMode(mode);
1744
18-
// Tell the Go backend about the mode change
1945
try {
2046
const {SetExtractionMode} = await import(
2147
'../../../../wailsjs/go/main/App'
2248
);
2349
await SetExtractionMode(mode);
2450
} catch {}
2551
26-
// Re-extract colors if a wallpaper is loaded
2752
const path = getWallpaperPath();
2853
if (path) {
2954
setIsExtracting(true);
@@ -44,19 +69,48 @@
4469
}
4570
</script>
4671

47-
<div>
48-
<h3
49-
class="text-fg-dimmed mb-2 text-[10px] font-medium uppercase tracking-wider"
50-
>
51-
Extraction Mode
52-
</h3>
53-
<select
54-
class="bg-bg-surface border-border text-fg-primary focus:border-border-focus w-full border px-2 py-1.5 text-[11px] outline-none transition-colors duration-100"
55-
value={getExtractionMode()}
56-
onchange={e => handleModeChange(e.currentTarget.value)}
57-
>
58-
{#each EXTRACTION_MODES as mode}
59-
<option value={mode.value}>{mode.label}</option>
72+
{#snippet modeList(items: ExtractionMode[])}
73+
<ul class="flex flex-col">
74+
{#each items as mode}
75+
{@const isActive = getExtractionMode() === mode.value}
76+
<li>
77+
<button
78+
type="button"
79+
onclick={() => handleModeChange(mode.value)}
80+
title={mode.description}
81+
aria-pressed={isActive}
82+
class="hover:bg-bg-hover flex w-full items-center justify-between px-2 py-1.5 text-left text-[11px] transition-colors duration-100 {isActive
83+
? 'bg-bg-elevated text-accent border-border-focus border-l-2'
84+
: 'text-fg-primary border-l-2 border-transparent'}"
85+
>
86+
<span class="truncate">{mode.label}</span>
87+
{#if isActive}
88+
<span
89+
class="text-accent ml-2 text-[10px]"
90+
aria-hidden="true">●</span
91+
>
92+
{/if}
93+
</button>
94+
</li>
95+
{/each}
96+
</ul>
97+
{/snippet}
98+
99+
<ExpandableSection title="Extraction Mode" bind:expanded>
100+
<div class="flex flex-col gap-3">
101+
{#each EXTRACTION_MODE_GROUPS as group}
102+
{@const items = grouped[group.id]}
103+
{#if items?.length}
104+
{@const isOpen = openGroups[group.id]}
105+
{@const active = activeInGroup(group.id)}
106+
<ExpandableSection
107+
title={group.label}
108+
suffix={!isOpen && active ? active.label : ''}
109+
bind:expanded={openGroups[group.id]}
110+
>
111+
{@render modeList(items)}
112+
</ExpandableSection>
113+
{/if}
60114
{/each}
61-
</select>
62-
</div>
115+
</div>
116+
</ExpandableSection>

frontend/src/lib/constants/colors.ts

Lines changed: 169 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -733,13 +733,173 @@ export const EXTENDED_COLOR_LABELS: Record<string, string> = Object.fromEntries(
733733
EXTENDED_COLOR_ROLES.map(r => [r.key, r.label])
734734
);
735735

736-
export const EXTRACTION_MODES = [
737-
{value: 'normal', label: 'Auto Detect'},
738-
{value: 'monochromatic', label: 'Monochromatic'},
739-
{value: 'analogous', label: 'Analogous'},
740-
{value: 'pastel', label: 'Pastel'},
741-
{value: 'material', label: 'Material'},
742-
{value: 'colorful', label: 'Colorful'},
743-
{value: 'muted', label: 'Muted'},
744-
{value: 'bright', label: 'Bright'},
736+
export type ExtractionModeGroup =
737+
| 'auto'
738+
| 'theory'
739+
| 'style'
740+
| 'mood'
741+
| 'practical';
742+
743+
export interface ExtractionMode {
744+
value: string;
745+
label: string;
746+
group: ExtractionModeGroup;
747+
description: string;
748+
}
749+
750+
export const EXTRACTION_MODES: ExtractionMode[] = [
751+
{
752+
value: 'normal',
753+
label: 'Auto Detect',
754+
group: 'auto',
755+
description: 'Picks monochrome or chromatic based on image analysis',
756+
},
757+
758+
{
759+
value: 'monochromatic',
760+
label: 'Monochromatic',
761+
group: 'theory',
762+
description: 'Single hue across all slots, varying lightness',
763+
},
764+
{
765+
value: 'analogous',
766+
label: 'Analogous',
767+
group: 'theory',
768+
description: 'Hues within ±30° of the dominant color',
769+
},
770+
{
771+
value: 'complementary',
772+
label: 'Complementary',
773+
group: 'theory',
774+
description: 'Base hue alternating with its 180° opposite',
775+
},
776+
{
777+
value: 'triadic',
778+
label: 'Triadic',
779+
group: 'theory',
780+
description: 'Three hues evenly spaced at 120°',
781+
},
782+
{
783+
value: 'split-complementary',
784+
label: 'Split-Complementary',
785+
group: 'theory',
786+
description: 'Base hue plus 150° and 210° (softer complement)',
787+
},
788+
{
789+
value: 'tetradic',
790+
label: 'Tetradic',
791+
group: 'theory',
792+
description: 'Four hues at 90° on the color wheel',
793+
},
794+
795+
{
796+
value: 'pastel',
797+
label: 'Pastel',
798+
group: 'style',
799+
description: 'Soft, low chroma, high lightness',
800+
},
801+
{
802+
value: 'muted',
803+
label: 'Muted',
804+
group: 'style',
805+
description: 'Subdued, low chroma, lightness-staggered',
806+
},
807+
{
808+
value: 'bright',
809+
label: 'Bright',
810+
group: 'style',
811+
description: 'High lightness with healthy chroma',
812+
},
813+
{
814+
value: 'colorful',
815+
label: 'Colorful',
816+
group: 'style',
817+
description: 'Vivid, high chroma, mid lightness',
818+
},
819+
{
820+
value: 'material',
821+
label: 'Material',
822+
group: 'style',
823+
description: 'Material Design-inspired with fixed bg/fg',
824+
},
825+
826+
{
827+
value: 'fire',
828+
label: 'Fire',
829+
group: 'mood',
830+
description: 'Bonfire warmth — deep dark, warm ANSI',
831+
},
832+
{
833+
value: 'ocean',
834+
label: 'Ocean',
835+
group: 'mood',
836+
description: 'Deep blue-black, cool-shifted ANSI',
837+
},
838+
{
839+
value: 'forest',
840+
label: 'Forest',
841+
group: 'mood',
842+
description: 'Dark green-black, sage-shifted ANSI',
843+
},
844+
{
845+
value: 'earthtone',
846+
label: 'Earthtone',
847+
group: 'mood',
848+
description: 'Warm browns and beiges, very low chroma',
849+
},
850+
{
851+
value: 'neon',
852+
label: 'Neon',
853+
group: 'mood',
854+
description: 'Cyberpunk: very dark bg, max chroma',
855+
},
856+
{
857+
value: 'sunset',
858+
label: 'Sunset',
859+
group: 'mood',
860+
description: 'Warm dark with magenta cast, peach fg',
861+
},
862+
{
863+
value: 'vaporwave',
864+
label: 'Vaporwave',
865+
group: 'mood',
866+
description: 'Pinks, purples, cyans on a soft dark',
867+
},
868+
{
869+
value: 'midnight',
870+
label: 'Midnight',
871+
group: 'mood',
872+
description: 'Peaceful deep indigo, subdued silver',
873+
},
874+
{
875+
value: 'aurora',
876+
label: 'Aurora',
877+
group: 'mood',
878+
description: 'Shimmery green-cyan glow on deep blue-night',
879+
},
880+
881+
{
882+
value: 'high-contrast',
883+
label: 'High Contrast',
884+
group: 'practical',
885+
description: 'WCAG AAA (7:1) for maximum readability',
886+
},
887+
{
888+
value: 'duotone',
889+
label: 'Duotone',
890+
group: 'practical',
891+
description: 'Two hues only at varying lightness',
892+
},
893+
];
894+
895+
export const EXTRACTION_MODE_GROUPS: {
896+
id: ExtractionModeGroup;
897+
label: string;
898+
defaultOpen: boolean;
899+
}[] = [
900+
{id: 'auto', label: 'Auto', defaultOpen: true},
901+
{id: 'style', label: 'Style', defaultOpen: true},
902+
{id: 'theory', label: 'Color Theory', defaultOpen: false},
903+
{id: 'mood', label: 'Mood', defaultOpen: false},
904+
{id: 'practical', label: 'Practical', defaultOpen: false},
745905
];

internal/extraction/cache.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ func LoadCachedPalette(cacheKey string) ([16]string, bool) {
103103
return [16]string{}, false
104104
}
105105

106+
// Discard caches written by older algorithm versions
107+
if data.Version != CacheVersion {
108+
return [16]string{}, false
109+
}
110+
106111
// Validate that we got a complete palette
107112
for _, c := range data.Palette {
108113
if c == "" {

internal/extraction/constants.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package extraction
22

33
const (
44
ANSIPaletteSize = 16
5-
CacheVersion = 3 // Bumped: OKLab-based extraction
5+
CacheVersion = 5 // Bumped: mood iteration (stronger character per mood) + midnight/aurora
66
ImageScaleSize = 400
77
MinPixelsToSample = 1000
88
MaxPixelsToSample = 50000
@@ -34,6 +34,7 @@ const (
3434
VeryDarkBgLightness = 0.25 // OKLab L: very dark background
3535
VeryLightBgLightness = 0.82 // OKLab L: very light background
3636
MinContrastRatio = 4.5 // WCAG AA minimum for normal text
37+
MinHighContrastRatio = 7.0 // WCAG AAA target for the high-contrast mode
3738
MinCommentContrast = 3.0 // Minimum for color8 (comments)
3839
MinFgBgContrast = 7.0 // Target contrast for fg/bg pair
3940

0 commit comments

Comments
 (0)