Skip to content

Commit 89484a3

Browse files
authored
Merge pull request #152 from jongalloway/issue-150-monochrome-palette-fallback
Add monochrome palette fallback infrastructure
2 parents e9d0c4a + 0694967 commit 89484a3

5 files changed

Lines changed: 144 additions & 13 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: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,54 @@ 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 palette suitable for direct node-fill use, regardless of whether
50+
/// the theme's <see cref="Theme.NodePalette"/> is chromatic.
51+
/// </summary>
52+
/// <remarks>
53+
/// <para>
54+
/// If <see cref="Theme.NodePalette"/> is non-empty and
55+
/// <see cref="ColorUtils.IsPaletteMonochrome"/> returns <see langword="false"/>,
56+
/// the existing palette is returned unchanged — <paramref name="desiredCount"/>
57+
/// does <em>not</em> resize or pad a chromatic palette.
58+
/// </para>
59+
/// <para>
60+
/// <paramref name="desiredCount"/> controls only the number of entries produced
61+
/// when a fallback palette must be synthesized (i.e., when the theme's
62+
/// <see cref="Theme.NodePalette"/> is absent, monochrome, or matches the background):
63+
/// <list type="number">
64+
/// <item>
65+
/// If <see cref="Theme.UseBorderGradients"/> is <see langword="true"/> and
66+
/// <see cref="Theme.BorderGradientStops"/> contains more than one stop, the stops
67+
/// are linearly interpolated to produce <paramref name="desiredCount"/> entries.
68+
/// </item>
69+
/// <item>
70+
/// Otherwise, <paramref name="desiredCount"/> hue-rotated colors are derived from
71+
/// <see cref="Theme.AccentColor"/> and <see cref="Theme.SecondaryColor"/>.
72+
/// </item>
73+
/// </list>
74+
/// </para>
75+
/// <para>This is a pure function of the <see cref="Theme"/> — no side effects.</para>
76+
/// </remarks>
77+
/// <param name="theme">Source theme.</param>
78+
/// <param name="desiredCount">
79+
/// Minimum number of entries to generate when a fallback palette is synthesized.
80+
/// Has no effect when the theme's <see cref="Theme.NodePalette"/> is already chromatic.
81+
/// Must be at least 1.
82+
/// </param>
83+
/// <returns>
84+
/// A <see cref="IReadOnlyList{T}"/> of hex color strings suitable for node fills.
85+
/// </returns>
86+
/// <exception cref="ArgumentNullException"><paramref name="theme"/> is <see langword="null"/>.</exception>
87+
/// <exception cref="ArgumentOutOfRangeException"><paramref name="desiredCount"/> is less than 1.</exception>
88+
public static IReadOnlyList<string> ResolveEffectivePalette(Theme theme, int desiredCount)
4689
{
4790
ArgumentNullException.ThrowIfNull(theme);
91+
if (desiredCount < 1)
92+
throw new ArgumentOutOfRangeException(nameof(desiredCount), desiredCount, "desiredCount must be at least 1.");
4893

4994
if (theme.NodePalette is { Count: > 0 } &&
5095
!ColorUtils.IsPaletteMonochrome(theme.NodePalette, theme.BackgroundColor))
@@ -53,14 +98,14 @@ public static IReadOnlyList<string> ResolveEffectivePalette(Theme theme)
5398
// Build fallback from gradient stops when they are available and meaningful.
5499
if (theme.UseBorderGradients && theme.BorderGradientStops is { Count: > 1 })
55100
{
56-
var gradientPalette = BuildPaletteFromGradientStops(theme.BorderGradientStops, DefaultPaletteSize);
101+
var gradientPalette = BuildPaletteFromGradientStops(theme.BorderGradientStops, desiredCount);
57102
if (!ColorUtils.IsPaletteMonochrome(gradientPalette, theme.BackgroundColor))
58103
return gradientPalette;
59104
}
60105

61106
// Fall back to hue-rotation derivation from the theme's semantic colors.
62107
bool isLightBackground = ColorUtils.IsLight(theme.BackgroundColor);
63-
return BuildPaletteFromHueRotation(theme.AccentColor, theme.SecondaryColor, DefaultPaletteSize, isLightBackground);
108+
return BuildPaletteFromHueRotation(theme.AccentColor, theme.SecondaryColor, desiredCount, isLightBackground);
64109
}
65110

66111
// ── Private helpers ───────────────────────────────────────────────────────
@@ -146,6 +191,10 @@ public static string[] BuildRingColors(Theme theme, int ringCount, string center
146191
if (ringCount < 1)
147192
throw new ArgumentOutOfRangeException(nameof(ringCount), ringCount, "ringCount must be at least 1.");
148193

194+
var chromaticPalette = ResolveEffectivePalette(theme, Math.Max(DefaultPaletteSize, ringCount * 2));
195+
bool isThemePaletteMonochrome = theme.NodePalette is { Count: > 0 }
196+
&& ColorUtils.IsPaletteMonochrome(theme.NodePalette, theme.BackgroundColor);
197+
149198
var colors = new List<string>(ringCount)
150199
{
151200
outerColor,
@@ -156,22 +205,32 @@ public static string[] BuildRingColors(Theme theme, int ringCount, string center
156205

157206
var candidatePool = new List<string>
158207
{
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),
208+
ColorUtils.Vibrant(theme.SecondaryColor, chromaticPalette[0], 2.4),
209+
ColorUtils.Vibrant(theme.AccentColor, chromaticPalette[1 % chromaticPalette.Count], 2.4),
210+
ColorUtils.Vibrant(ColorUtils.Blend(theme.SecondaryColor, theme.AccentColor, 0.5), chromaticPalette[2 % chromaticPalette.Count], 2.6),
211+
ColorUtils.Vibrant(ColorUtils.Blend(theme.AccentColor, theme.PrimaryColor, 0.35), chromaticPalette[3 % chromaticPalette.Count], 2.5),
212+
ColorUtils.Vibrant(ColorUtils.Blend(theme.PrimaryColor, theme.SecondaryColor, 0.25), chromaticPalette[4 % chromaticPalette.Count], 2.5),
164213
ColorUtils.RotateHue(theme.SecondaryColor, 34, isLightBackground),
165214
ColorUtils.RotateHue(theme.AccentColor, -34, isLightBackground),
166215
ColorUtils.RotateHue(theme.PrimaryColor, 52, isLightBackground),
167216
};
168217

169218
if (theme.NodePalette is { Count: > 0 })
170219
{
171-
foreach (var paletteColor in theme.NodePalette)
220+
for (int i = 0; i < theme.NodePalette.Count; i++)
172221
{
173-
candidatePool.Add(ColorUtils.Vibrant(paletteColor, 2.6));
174-
candidatePool.Add(ColorUtils.RotateHue(paletteColor, 28, isLightBackground));
222+
string paletteColor = theme.NodePalette[i];
223+
if (isThemePaletteMonochrome)
224+
{
225+
string fallbackColor = chromaticPalette[i % chromaticPalette.Count];
226+
candidatePool.Add(ColorUtils.Vibrant(paletteColor, fallbackColor, 2.6));
227+
candidatePool.Add(ColorUtils.RotateHue(fallbackColor, 28, isLightBackground));
228+
}
229+
else
230+
{
231+
candidatePool.Add(ColorUtils.Vibrant(paletteColor, 2.6));
232+
candidatePool.Add(ColorUtils.RotateHue(paletteColor, 28, isLightBackground));
233+
}
175234
}
176235
}
177236

@@ -181,6 +240,15 @@ public static string[] BuildRingColors(Theme theme, int ringCount, string center
181240
.Where(color => ColorUtils.GetHueDistance(color, outerColor) >= 18)
182241
.ToList();
183242

243+
if (ColorUtils.IsPaletteMonochrome(distinctCandidates, theme.BackgroundColor))
244+
{
245+
distinctCandidates = chromaticPalette
246+
.Select(color => ColorUtils.Blend(color, theme.BackgroundColor, isLightBackground ? 0.06 : 0.10))
247+
.Distinct(StringComparer.OrdinalIgnoreCase)
248+
.Where(color => ColorUtils.GetHueDistance(color, outerColor) >= 18)
249+
.ToList();
250+
}
251+
184252
while (colors.Count < ringCount)
185253
{
186254
string? nextColor = distinctCandidates

tests/DiagramForge.E2ETests/Fixtures/conceptual-target-prism.expected.svg

Lines changed: 2 additions & 2 deletions
Loading

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)