Skip to content

Commit 0e5b06f

Browse files
Copilotjongalloway
andauthored
Address review feedback: clamp threshold, normalize hex, fix isLightBackground, validate gradient palette, fix test names
Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com> Agent-Logs-Url: https://github.com/jongalloway/DiagramForge/sessions/4b46141f-444c-46cc-9873-d40f91adb1a3
1 parent b764eac commit 0e5b06f

4 files changed

Lines changed: 77 additions & 15 deletions

File tree

src/DiagramForge/Models/ColorUtils.cs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,11 @@ public static double GetContrastRatio(string hex1, string hex2)
170170
/// <param name="hex">Hex color string.</param>
171171
/// <param name="saturationThreshold">
172172
/// Saturation threshold 0–1; colors with saturation below this value are considered
173-
/// achromatic. Defaults to 0.08.
173+
/// achromatic. Values outside [0,1] are clamped. Defaults to 0.08.
174174
/// </param>
175175
public static bool IsAchromatic(string hex, double saturationThreshold = 0.08)
176176
{
177+
saturationThreshold = Math.Clamp(saturationThreshold, 0, 1);
177178
var (rRaw, gRaw, bRaw) = ParseHex(hex);
178179
double r = rRaw / 255d;
179180
double g = gRaw / 255d;
@@ -215,13 +216,20 @@ public static bool IsPaletteMonochrome(IReadOnlyList<string> palette, string? ba
215216
return true;
216217

217218
// All entries are the same color (trivially monochrome — no hue variety).
218-
if (palette.All(c => string.Equals(c, palette[0], StringComparison.OrdinalIgnoreCase)))
219-
return true;
219+
// Compare by parsed RGBA values so shorthand formats (#FFF vs #FFFFFF) are treated as equal.
220+
{
221+
var referenceColor = ParseHexWithAlpha(palette[0]);
222+
if (palette.All(c => ParseHexWithAlpha(c) == referenceColor))
223+
return true;
224+
}
220225

221226
// All entries match the background (nodes would be invisible against the canvas).
222-
if (backgroundColor is not null &&
223-
palette.All(c => string.Equals(c, backgroundColor, StringComparison.OrdinalIgnoreCase)))
224-
return true;
227+
if (backgroundColor is not null)
228+
{
229+
var background = ParseHexWithAlpha(backgroundColor);
230+
if (palette.All(c => ParseHexWithAlpha(c) == background))
231+
return true;
232+
}
225233

226234
return false;
227235
}

src/DiagramForge/Models/ThemePaletteResolver.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public static class ThemePaletteResolver
2727
/// <item>
2828
/// If <see cref="Theme.UseBorderGradients"/> is <see langword="true"/> and
2929
/// <see cref="Theme.BorderGradientStops"/> contains more than one stop, the stops
30-
/// are interpolated/cycled to produce <see cref="DefaultPaletteSize"/> entries.
30+
/// are linearly interpolated to produce <see cref="DefaultPaletteSize"/> entries.
3131
/// </item>
3232
/// <item>
3333
/// Otherwise, a palette is derived from <see cref="Theme.AccentColor"/> and
@@ -52,10 +52,15 @@ public static IReadOnlyList<string> ResolveEffectivePalette(Theme theme)
5252

5353
// Build fallback from gradient stops when they are available and meaningful.
5454
if (theme.UseBorderGradients && theme.BorderGradientStops is { Count: > 1 })
55-
return BuildPaletteFromGradientStops(theme.BorderGradientStops, DefaultPaletteSize);
55+
{
56+
var gradientPalette = BuildPaletteFromGradientStops(theme.BorderGradientStops, DefaultPaletteSize);
57+
if (!ColorUtils.IsPaletteMonochrome(gradientPalette, theme.BackgroundColor))
58+
return gradientPalette;
59+
}
5660

5761
// Fall back to hue-rotation derivation from the theme's semantic colors.
58-
return BuildPaletteFromHueRotation(theme.AccentColor, theme.SecondaryColor, DefaultPaletteSize);
62+
bool isLightBackground = ColorUtils.IsLight(theme.BackgroundColor);
63+
return BuildPaletteFromHueRotation(theme.AccentColor, theme.SecondaryColor, DefaultPaletteSize, isLightBackground);
5964
}
6065

6166
// ── Private helpers ───────────────────────────────────────────────────────
@@ -97,17 +102,16 @@ private static IReadOnlyList<string> BuildPaletteFromGradientStops(IReadOnlyList
97102
/// in equal steps, alternating between the two base colors.
98103
/// </summary>
99104
private static IReadOnlyList<string> BuildPaletteFromHueRotation(
100-
string accentColor, string secondaryColor, int count)
105+
string accentColor, string secondaryColor, int count, bool isLightBackground)
101106
{
102-
bool isLight = ColorUtils.IsLight(accentColor);
103107
double hueStep = 360.0 / count;
104108
var result = new List<string>(count);
105109

106110
for (int i = 0; i < count; i++)
107111
{
108112
string baseColor = i % 2 == 0 ? accentColor : secondaryColor;
109113
double rotation = Math.Floor(i / 2.0) * hueStep;
110-
result.Add(ColorUtils.RotateHue(baseColor, rotation, isLight));
114+
result.Add(ColorUtils.RotateHue(baseColor, rotation, isLightBackground));
111115
}
112116

113117
return result;

tests/DiagramForge.Tests/Models/ColorUtilsTests.cs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -724,7 +724,7 @@ public void IsAchromatic_LowSaturationPastel_DefaultThreshold_ReturnsTrue()
724724
}
725725

726726
[Fact]
727-
public void IsAchromatic_CustomThreshold_LowSaturationColor_ReturnsTrue()
727+
public void IsAchromatic_CustomThreshold_LowSaturationColor_ReturnsFalse()
728728
{
729729
// A muted blue — below the default threshold but above an even lower custom one
730730
// Test that the threshold parameter is respected
@@ -804,14 +804,30 @@ public void IsPaletteMonochrome_MatchesBackground_ReturnsTrue()
804804
}
805805

806806
[Fact]
807-
public void IsPaletteMonochrome_MatchesBackground_NoBackground_ReturnsFalse()
807+
public void IsPaletteMonochrome_MatchesBackground_NoBackground_ReturnsTrue()
808808
{
809809
// Same chromatic entries, but without a background to compare against —
810810
// they are the same color, so still "all same color" → true
811811
var palette = new[] { "#2563EB", "#2563EB", "#2563EB" };
812812
Assert.True(ColorUtils.IsPaletteMonochrome(palette, backgroundColor: null));
813813
}
814814

815+
[Fact]
816+
public void IsPaletteMonochrome_ShorthandVsFullEquivalent_ReturnsTrue()
817+
{
818+
// #FFF and #FFFFFF are the same color — palette should be detected as monochrome
819+
var palette = new[] { "#FFF", "#FFFFFF", "#FFF" };
820+
Assert.True(ColorUtils.IsPaletteMonochrome(palette));
821+
}
822+
823+
[Fact]
824+
public void IsPaletteMonochrome_ShorthandBackground_NormalizedComparison_ReturnsTrue()
825+
{
826+
// #FFF (shorthand) vs #FFFFFF (full) should match background correctly
827+
var palette = new[] { "#FFFFFF", "#FFFFFF" };
828+
Assert.True(ColorUtils.IsPaletteMonochrome(palette, backgroundColor: "#FFF"));
829+
}
830+
815831
[Fact]
816832
public void IsPaletteMonochrome_PrismTheme_ReturnsTrue()
817833
{
@@ -821,7 +837,24 @@ public void IsPaletteMonochrome_PrismTheme_ReturnsTrue()
821837
Assert.True(ColorUtils.IsPaletteMonochrome(prism.NodePalette, prism.BackgroundColor));
822838
}
823839

824-
// ── Vibrant (achromatic hardening) ────────────────────────────────────────
840+
// ── IsAchromatic threshold clamping ───────────────────────────────────────
841+
842+
[Fact]
843+
public void IsAchromatic_NegativeThreshold_ClampsToZero_ReturnsFalse()
844+
{
845+
// Threshold clamped to 0 — no color can be below a 0 saturation threshold
846+
Assert.False(ColorUtils.IsAchromatic("#808080", saturationThreshold: -1.0));
847+
}
848+
849+
[Fact]
850+
public void IsAchromatic_ThresholdAbove1_ClampsTo1_ReturnsTrue()
851+
{
852+
// Threshold clamped to 1 — any color with saturation < 1 is considered achromatic.
853+
// #CC5050 is a muted red with HSL saturation ~0.5, so it returns true when
854+
// the (clamped) threshold is 1.0, even though it is chromatic at the default threshold.
855+
Assert.True(ColorUtils.IsAchromatic("#CC5050", saturationThreshold: 2.0));
856+
Assert.False(ColorUtils.IsAchromatic("#CC5050")); // default threshold does not treat it as achromatic
857+
}
825858

826859
[Fact]
827860
public void Vibrant_WhiteInput_ProducesVisiblyDifferentColor()

tests/DiagramForge.Tests/Models/ThemePaletteResolverTests.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,4 +275,21 @@ public void ResolveEffectivePalette_MixedMonochromeGrays_ReturnsFallback()
275275
Assert.False(ColorUtils.IsPaletteMonochrome(result),
276276
"Fallback for mixed-gray palette should not be monochrome.");
277277
}
278+
279+
[Fact]
280+
public void ResolveEffectivePalette_MonochromeGradientStops_FallsBackToHueRotation()
281+
{
282+
// Even when UseBorderGradients is true, if the gradient stops are themselves
283+
// monochrome the method should skip them and fall back to hue rotation.
284+
var theme = Theme.FromPalette("#3B82F6");
285+
theme.NodePalette = ["#FFFFFF", "#FFFFFF"];
286+
theme.UseBorderGradients = true;
287+
theme.BorderGradientStops = ["#808080", "#AAAAAA"]; // achromatic stops
288+
289+
var result = ThemePaletteResolver.ResolveEffectivePalette(theme);
290+
291+
Assert.Equal(8, result.Count);
292+
Assert.False(ColorUtils.IsPaletteMonochrome(result),
293+
"Result should be chromatic even when gradient stops are monochrome.");
294+
}
278295
}

0 commit comments

Comments
 (0)