Skip to content

Commit cda78b5

Browse files
committed
Add monochrome palette fallback infrastructure
1 parent e9d0c4a commit cda78b5

4 files changed

Lines changed: 99 additions & 11 deletions

File tree

src/DiagramForge/Models/ColorUtils.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,27 @@ public static string Vibrant(string hex, double amplify = 3.0)
469469
a);
470470
}
471471

472+
/// <summary>
473+
/// Derives a vibrant color from <paramref name="hex"/>, but when the input is
474+
/// achromatic returns <paramref name="achromaticFallback"/> instead.
475+
/// </summary>
476+
/// <remarks>
477+
/// The fallback keeps the source alpha channel so callers can supply a chromatic
478+
/// palette color without losing opacity from the original input.
479+
/// </remarks>
480+
/// <param name="hex">Hex color string (typically a pastel).</param>
481+
/// <param name="achromaticFallback">Chromatic fallback used when <paramref name="hex"/> has no usable hue.</param>
482+
/// <param name="amplify">How much to amplify the hue deviation (default 3).</param>
483+
public static string Vibrant(string hex, string achromaticFallback, double amplify = 3.0)
484+
{
485+
if (!IsAchromatic(hex))
486+
return Vibrant(hex, amplify);
487+
488+
var (_, _, _, alpha) = ParseHexWithAlpha(hex);
489+
var (fallbackR, fallbackG, fallbackB, _) = ParseHexWithAlpha(achromaticFallback);
490+
return ToHex(fallbackR, fallbackG, fallbackB, alpha);
491+
}
492+
472493
private static int Clamp(int value) => Math.Max(0, Math.Min(255, value));
473494

474495
/// <summary>

src/DiagramForge/Models/ThemePaletteResolver.cs

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,19 @@ public static class ThemePaletteResolver
4242
/// A <see cref="IReadOnlyList{T}"/> of hex color strings suitable for node fills.
4343
/// </returns>
4444
/// <exception cref="ArgumentNullException"><paramref name="theme"/> is <see langword="null"/>.</exception>
45-
public static IReadOnlyList<string> ResolveEffectivePalette(Theme theme)
45+
public static IReadOnlyList<string> ResolveEffectivePalette(Theme theme) =>
46+
ResolveEffectivePalette(theme, DefaultPaletteSize);
47+
48+
/// <summary>
49+
/// Returns a chromatic palette sized for the caller's repeated-element needs.
50+
/// </summary>
51+
/// <param name="theme">Source theme.</param>
52+
/// <param name="desiredCount">Number of entries to produce for fallback palettes.</param>
53+
public static IReadOnlyList<string> ResolveEffectivePalette(Theme theme, int desiredCount)
4654
{
4755
ArgumentNullException.ThrowIfNull(theme);
56+
if (desiredCount < 1)
57+
throw new ArgumentOutOfRangeException(nameof(desiredCount), desiredCount, "desiredCount must be at least 1.");
4858

4959
if (theme.NodePalette is { Count: > 0 } &&
5060
!ColorUtils.IsPaletteMonochrome(theme.NodePalette, theme.BackgroundColor))
@@ -53,14 +63,14 @@ public static IReadOnlyList<string> ResolveEffectivePalette(Theme theme)
5363
// Build fallback from gradient stops when they are available and meaningful.
5464
if (theme.UseBorderGradients && theme.BorderGradientStops is { Count: > 1 })
5565
{
56-
var gradientPalette = BuildPaletteFromGradientStops(theme.BorderGradientStops, DefaultPaletteSize);
66+
var gradientPalette = BuildPaletteFromGradientStops(theme.BorderGradientStops, desiredCount);
5767
if (!ColorUtils.IsPaletteMonochrome(gradientPalette, theme.BackgroundColor))
5868
return gradientPalette;
5969
}
6070

6171
// Fall back to hue-rotation derivation from the theme's semantic colors.
6272
bool isLightBackground = ColorUtils.IsLight(theme.BackgroundColor);
63-
return BuildPaletteFromHueRotation(theme.AccentColor, theme.SecondaryColor, DefaultPaletteSize, isLightBackground);
73+
return BuildPaletteFromHueRotation(theme.AccentColor, theme.SecondaryColor, desiredCount, isLightBackground);
6474
}
6575

6676
// ── Private helpers ───────────────────────────────────────────────────────
@@ -146,6 +156,8 @@ public static string[] BuildRingColors(Theme theme, int ringCount, string center
146156
if (ringCount < 1)
147157
throw new ArgumentOutOfRangeException(nameof(ringCount), ringCount, "ringCount must be at least 1.");
148158

159+
var chromaticPalette = ResolveEffectivePalette(theme, Math.Max(DefaultPaletteSize, ringCount * 2));
160+
149161
var colors = new List<string>(ringCount)
150162
{
151163
outerColor,
@@ -156,31 +168,44 @@ public static string[] BuildRingColors(Theme theme, int ringCount, string center
156168

157169
var candidatePool = new List<string>
158170
{
159-
ColorUtils.Vibrant(theme.SecondaryColor, 2.4),
160-
ColorUtils.Vibrant(theme.AccentColor, 2.4),
161-
ColorUtils.Vibrant(ColorUtils.Blend(theme.SecondaryColor, theme.AccentColor, 0.5), 2.6),
162-
ColorUtils.Vibrant(ColorUtils.Blend(theme.AccentColor, theme.PrimaryColor, 0.35), 2.5),
163-
ColorUtils.Vibrant(ColorUtils.Blend(theme.PrimaryColor, theme.SecondaryColor, 0.25), 2.5),
171+
ColorUtils.Vibrant(theme.SecondaryColor, chromaticPalette[0], 2.4),
172+
ColorUtils.Vibrant(theme.AccentColor, chromaticPalette[1 % chromaticPalette.Count], 2.4),
173+
ColorUtils.Vibrant(ColorUtils.Blend(theme.SecondaryColor, theme.AccentColor, 0.5), chromaticPalette[2 % chromaticPalette.Count], 2.6),
174+
ColorUtils.Vibrant(ColorUtils.Blend(theme.AccentColor, theme.PrimaryColor, 0.35), chromaticPalette[3 % chromaticPalette.Count], 2.5),
175+
ColorUtils.Vibrant(ColorUtils.Blend(theme.PrimaryColor, theme.SecondaryColor, 0.25), chromaticPalette[4 % chromaticPalette.Count], 2.5),
164176
ColorUtils.RotateHue(theme.SecondaryColor, 34, isLightBackground),
165177
ColorUtils.RotateHue(theme.AccentColor, -34, isLightBackground),
166178
ColorUtils.RotateHue(theme.PrimaryColor, 52, isLightBackground),
167179
};
168180

169181
if (theme.NodePalette is { Count: > 0 })
170182
{
171-
foreach (var paletteColor in theme.NodePalette)
183+
for (int i = 0; i < theme.NodePalette.Count; i++)
172184
{
173-
candidatePool.Add(ColorUtils.Vibrant(paletteColor, 2.6));
174-
candidatePool.Add(ColorUtils.RotateHue(paletteColor, 28, isLightBackground));
185+
string paletteColor = theme.NodePalette[i];
186+
string fallbackColor = chromaticPalette[i % chromaticPalette.Count];
187+
candidatePool.Add(ColorUtils.Vibrant(paletteColor, fallbackColor, 2.6));
188+
candidatePool.Add(ColorUtils.RotateHue(fallbackColor, 28, isLightBackground));
175189
}
176190
}
177191

192+
candidatePool.AddRange(chromaticPalette);
193+
178194
var distinctCandidates = candidatePool
179195
.Select(color => ColorUtils.Blend(color, theme.BackgroundColor, isLightBackground ? 0.06 : 0.10))
180196
.Distinct(StringComparer.OrdinalIgnoreCase)
181197
.Where(color => ColorUtils.GetHueDistance(color, outerColor) >= 18)
182198
.ToList();
183199

200+
if (ColorUtils.IsPaletteMonochrome(distinctCandidates, theme.BackgroundColor))
201+
{
202+
distinctCandidates = chromaticPalette
203+
.Select(color => ColorUtils.Blend(color, theme.BackgroundColor, isLightBackground ? 0.06 : 0.10))
204+
.Distinct(StringComparer.OrdinalIgnoreCase)
205+
.Where(color => ColorUtils.GetHueDistance(color, outerColor) >= 18)
206+
.ToList();
207+
}
208+
184209
while (colors.Count < ringCount)
185210
{
186211
string? nextColor = distinctCandidates

tests/DiagramForge.Tests/Models/ColorUtilsTests.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,5 +905,17 @@ public void Vibrant_AchromaticInput_PreservesAlphaChannel()
905905
var (_, _, _, a) = ColorUtils.ParseHexWithAlpha(result);
906906
Assert.Equal(0xCC, a);
907907
}
908+
909+
[Fact]
910+
public void Vibrant_AchromaticInput_WithChromaticFallback_UsesFallbackHueAndPreservesAlpha()
911+
{
912+
string result = ColorUtils.Vibrant("#FFFFFF80", "#2563EB");
913+
914+
Assert.False(ColorUtils.IsAchromatic(result));
915+
916+
var (r, g, b, a) = ColorUtils.ParseHexWithAlpha(result);
917+
Assert.Equal((37, 99, 235), (r, g, b));
918+
Assert.Equal(0x80, a);
919+
}
908920
}
909921

tests/DiagramForge.Tests/Models/ThemePaletteResolverTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,25 @@ public void BuildRingColors_WithNodePalette_InfluencesSelection()
104104
Assert.True(anyDiffers, "NodePalette entries should influence at least one ring color selection.");
105105
}
106106

107+
[Fact]
108+
public void BuildRingColors_PrismTheme_UsesChromaticInnerRingFallback()
109+
{
110+
string[] result = ThemePaletteResolver.BuildRingColors(
111+
Theme.Prism,
112+
ringCount: 5,
113+
centerColor: "#FFFFFF",
114+
outerColor: "#D6DEE8",
115+
isLightBackground: true);
116+
117+
Assert.Equal(5, result.Length);
118+
Assert.Equal("#D6DEE8", result[0], ignoreCase: true);
119+
120+
string[] innerRings = result.Skip(1).ToArray();
121+
Assert.All(innerRings, color => Assert.False(ColorUtils.IsAchromatic(color)));
122+
Assert.False(ColorUtils.IsPaletteMonochrome(innerRings, Theme.Prism.BackgroundColor),
123+
"Prism inner ring colors should come from a chromatic fallback palette rather than stay monochrome.");
124+
}
125+
107126
// ── BuildRingColors — fallback path ───────────────────────────────────────
108127

109128
[Fact]
@@ -210,6 +229,17 @@ public void ResolveEffectivePalette_PrismTheme_ReturnsChromaticFallback()
210229
"Prism fallback palette derived from gradient stops should not be monochrome.");
211230
}
212231

232+
[Fact]
233+
public void ResolveEffectivePalette_PrismTheme_WithRequestedCount_ReturnsChromaticFallbackOfRequestedSize()
234+
{
235+
var result = ThemePaletteResolver.ResolveEffectivePalette(Theme.Prism, desiredCount: 12);
236+
237+
Assert.Equal(12, result.Count);
238+
Assert.Equal(Theme.Prism.BorderGradientStops![0], result[0], ignoreCase: true);
239+
Assert.Equal(Theme.Prism.BorderGradientStops[^1], result[^1], ignoreCase: true);
240+
Assert.False(ColorUtils.IsPaletteMonochrome(result, Theme.Prism.BackgroundColor));
241+
}
242+
213243
[Fact]
214244
public void ResolveEffectivePalette_MonochromeNoGradientStops_UsesHueRotationFallback()
215245
{

0 commit comments

Comments
 (0)