@@ -24,23 +24,42 @@ func CalculateHueDistance(hue1, hue2 float64) float64 {
2424}
2525
2626// IsMonochromeImage detects whether the extracted colors are mostly monochrome/grayscale
27- // using OKLCH chroma (perceptually accurate colorfulness metric).
28- // Returns true if more than 70% of colors have chroma below the threshold.
27+ // or have very low hue diversity (e.g., a solid blue wallpaper). Returns true if the
28+ // achromatic share exceeds MonochromeImageThreshold OR if chromatic hues cluster
29+ // within a narrow arc on the wheel.
2930func IsMonochromeImage (colors []string ) bool {
3031 if len (colors ) == 0 {
3132 return false
3233 }
3334
3435 lowChromaCount := 0
36+ var sinSum , cosSum float64
37+ chromaticCount := 0
3538
3639 for _ , c := range colors {
3740 lch := color .HexToOKLCH (c )
3841 if lch .C < MonochromeChromaThreshold {
3942 lowChromaCount ++
43+ continue
4044 }
45+ rad := lch .H * math .Pi / 180
46+ sinSum += math .Sin (rad )
47+ cosSum += math .Cos (rad )
48+ chromaticCount ++
49+ }
50+
51+ if float64 (lowChromaCount )/ float64 (len (colors )) > MonochromeImageThreshold {
52+ return true
4153 }
4254
43- return float64 (lowChromaCount )/ float64 (len (colors )) > MonochromeImageThreshold
55+ // Circular variance: |Σe^iθ|/N close to 1 = hues clustered tightly.
56+ if chromaticCount >= 4 {
57+ mag := math .Sqrt (sinSum * sinSum + cosSum * cosSum ) / float64 (chromaticCount )
58+ if mag > HueClusterMagnitudeThreshold {
59+ return true
60+ }
61+ }
62+ return false
4463}
4564
4665// findColorByPerceptualLightness finds a color by OKLab perceptual lightness extremity.
@@ -116,58 +135,58 @@ func FindForegroundColor(colors []string, lightMode bool, bgColor string, usedIn
116135 return colors [candidates [0 ].index ], candidates [0 ].index
117136 }
118137
119- // Fallback: adjust the original fg to meet contrast
138+ // Fallback: synthesize a foreground at the right contrast. Returns -1 for index
139+ // so callers don't lock a real pool color out of ANSI assignment based on a
140+ // color that isn't actually the synthesized fg.
120141 bgLab := color .HexToOKLab (bgColor )
121142 fgLab := color .HexToOKLab (fgColor )
122143 if bgLab .L < 0.5 {
123- // Dark bg: push fg lighter
124144 fgLab .L = math .Min (1.0 , bgLab .L + 0.65 )
125145 } else {
126- // Light bg: push fg darker
127146 fgLab .L = math .Max (0.0 , bgLab .L - 0.65 )
128147 }
129- adjustedHex := color .OKLabToHex (fgLab )
130- return adjustedHex , fgIndex
148+ return color .OKLabToHex (fgLab ), - 1
131149}
132150
133- // CalculateColorScore calculates a color quality score for ANSI color selection
134- // using OKLCH perceptual color space. Lower score = better match.
135- func CalculateColorScore ( lch color. OKLCH , targetHue float64 ) float64 {
136- // Hue accuracy in perceptually uniform OKLCH space
151+ // CalculateColorScore scores a candidate color for an ANSI slot. Lower = better match.
152+ // The lightness ideal shifts based on lightMode so light-mode palettes pick darker
153+ // candidates (which actually contrast against a near-white bg).
154+ func CalculateColorScore ( lch color. OKLCH , targetHue float64 , lightMode bool ) float64 {
137155 hueScore := CalculateHueDistance (lch .H , targetHue ) * 2.0
138156
139- // Chroma preference - strongly prefer chromatic colors
140157 var chromaScore float64
141158 if lch .C < MinChromaForAnsiMatch {
142- chromaScore = 80 // Heavy penalty for near-gray
159+ chromaScore = 80
143160 } else if lch .C < LowChromaThreshold {
144161 chromaScore = 40
145162 } else if lch .C < IdealChromaMin {
146163 chromaScore = 15
147164 } else if lch .C <= IdealChromaMax {
148- chromaScore = 0 // Ideal range - no penalty
165+ chromaScore = 0
149166 } else {
150- chromaScore = (lch .C - IdealChromaMax ) * 50 // Mild penalty for very vivid
167+ chromaScore = (lch .C - IdealChromaMax ) * 50
168+ }
169+
170+ idealL := 0.60
171+ if lightMode {
172+ idealL = 0.45
151173 }
152174
153- // Lightness suitability - prefer mid-range, penalize extremes
154- // OKLab L is perceptually linear, so these thresholds are meaningful
155175 var lightnessScore float64
156176 if lch .L < TooDarkLightness {
157177 lightnessScore = (TooDarkLightness - lch .L ) * 200
158178 } else if lch .L > TooBrightLightness {
159179 lightnessScore = (lch .L - TooBrightLightness ) * 150
160180 } else {
161- // Slight preference for L around 0.55-0.65 (good readability zone)
162- lightnessScore = math .Abs (lch .L - 0.60 ) * 20
181+ lightnessScore = math .Abs (lch .L - idealL ) * 20
163182 }
164183
165184 return hueScore + chromaScore + lightnessScore
166185}
167186
168187// FindBestColorMatch finds the best matching color for a specific ANSI color role
169188// using OKLCH-based scoring. Returns the index of the best match in colorPool.
170- func FindBestColorMatch (targetHue float64 , colorPool []string , usedIndices map [int ]bool ) int {
189+ func FindBestColorMatch (targetHue float64 , colorPool []string , usedIndices map [int ]bool , lightMode bool ) int {
171190 bestIndex := - 1
172191 bestScore := math .Inf (1 )
173192
@@ -177,7 +196,7 @@ func FindBestColorMatch(targetHue float64, colorPool []string, usedIndices map[i
177196 }
178197
179198 lch := color .HexToOKLCH (colorPool [i ])
180- score := CalculateColorScore (lch , targetHue )
199+ score := CalculateColorScore (lch , targetHue , lightMode )
181200
182201 if score < bestScore {
183202 bestScore = score
@@ -191,30 +210,28 @@ func FindBestColorMatch(targetHue float64, colorPool []string, usedIndices map[i
191210 return 0
192211}
193212
194- // GenerateBrightVersion generates a perceptually lighter version of a color for bright ANSI slots.
195- // Works in OKLab space so the boost is perceptually uniform.
213+ // GenerateBrightVersion generates a perceptually distinct "bright" variant for ANSI
214+ // slots 9-14. For mid-lightness bases the variant is mostly L-boosted; for already-
215+ // bright bases (L >= 0.78, e.g. yellow) there's no L headroom, so the variant gets
216+ // a stronger chroma boost instead — otherwise bright yellow ≈ regular yellow.
196217func GenerateBrightVersion (hex string ) string {
197218 lab := color .HexToOKLab (hex )
198219 lch := color .OKLabToOKLCH (lab )
199220
200- // Scale boost based on available headroom
221+ if lab .L >= 0.78 {
222+ newL := math .Min (0.94 , lab .L + 0.04 )
223+ newC := math .Min (0.32 , math .Max (lch .C + 0.04 , lch .C * 1.3 ))
224+ return color .OKLCHToHex (color.OKLCH {L : newL , C : newC , H : lch .H })
225+ }
226+
201227 headroom := 0.92 - lab .L
202228 boost := math .Max (0.04 , math .Min (BrightColorLightnessBoost , headroom * 0.6 ))
203229 newL := math .Min (0.92 , lab .L + boost )
204-
205- // Slight chroma boost for vibrancy
206- newC := math .Min (0.3 , lch .C * BrightColorSaturationBoost )
230+ newC := math .Min (0.30 , lch .C * BrightColorSaturationBoost )
207231
208232 return color .OKLCHToHex (color.OKLCH {L : newL , C : newC , H : lch .H })
209233}
210234
211- // AdjustColorLightness adjusts a color to the given target OKLab lightness (0-1).
212- func AdjustColorLightness (hex string , targetLightness float64 ) string {
213- lab := color .HexToOKLab (hex )
214- lab .L = targetLightness
215- return color .OKLabToHex (lab )
216- }
217-
218235// ColorLightnessInfo holds a color with its perceptual lightness and hue.
219236type ColorLightnessInfo struct {
220237 Color string
@@ -280,7 +297,7 @@ type AnsiAssignment struct {
280297// global greedy matching. Instead of assigning colors sequentially, this finds the
281298// globally best (ANSI slot, color) pair at each step, preventing earlier slots from
282299// stealing good matches from later ones.
283- func FindOptimalAnsiAssignment (colorPool []string , usedIndices map [int ]bool ) [6 ]* AnsiAssignment {
300+ func FindOptimalAnsiAssignment (colorPool []string , usedIndices map [int ]bool , lightMode bool ) [6 ]* AnsiAssignment {
284301 type candidate struct {
285302 poolIndex int
286303 score float64
@@ -296,7 +313,7 @@ func FindOptimalAnsiAssignment(colorPool []string, usedIndices map[int]bool) [6]
296313 candidates [i ] = & candidate {poolIndex : i , score : math .Inf (1 )}
297314 } else {
298315 lch := color .HexToOKLCH (c )
299- candidates [i ] = & candidate {poolIndex : i , score : CalculateColorScore (lch , targetHue )}
316+ candidates [i ] = & candidate {poolIndex : i , score : CalculateColorScore (lch , targetHue , lightMode )}
300317 }
301318 }
302319 sort .Slice (candidates , func (i , j int ) bool {
0 commit comments