Skip to content

Commit b764eac

Browse files
Copilotjongalloway
andauthored
Add monochrome palette detection and fallback palette resolution
Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com> Agent-Logs-Url: https://github.com/jongalloway/DiagramForge/sessions/27f13f43-5376-482e-8812-a2b0189deafb
1 parent 026bf48 commit b764eac

4 files changed

Lines changed: 538 additions & 4 deletions

File tree

src/DiagramForge/Models/ColorUtils.cs

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,72 @@ public static double GetContrastRatio(string hex1, string hex2)
160160
return (lighter + 0.05) / (darker + 0.05);
161161
}
162162

163+
// ── Achromatic / monochrome helpers ──────────────────────────────────────
164+
165+
/// <summary>
166+
/// Returns <see langword="true"/> when the HSL saturation of the color is below
167+
/// <paramref name="saturationThreshold"/>. Achromatic colors include pure white,
168+
/// pure black, and grays.
169+
/// </summary>
170+
/// <param name="hex">Hex color string.</param>
171+
/// <param name="saturationThreshold">
172+
/// Saturation threshold 0–1; colors with saturation below this value are considered
173+
/// achromatic. Defaults to 0.08.
174+
/// </param>
175+
public static bool IsAchromatic(string hex, double saturationThreshold = 0.08)
176+
{
177+
var (rRaw, gRaw, bRaw) = ParseHex(hex);
178+
double r = rRaw / 255d;
179+
double g = gRaw / 255d;
180+
double b = bRaw / 255d;
181+
double max = Math.Max(r, Math.Max(g, b));
182+
double min = Math.Min(r, Math.Min(g, b));
183+
double delta = max - min;
184+
double lightness = (max + min) / 2;
185+
double denominator = 1 - Math.Abs(2 * lightness - 1);
186+
// When delta is near zero the color is already achromatic; guard the denominator
187+
// (which is also ~0 at lightness=0 or lightness=1) to avoid division by zero.
188+
double saturation = delta < 0.0001 || denominator < 0.0001
189+
? 0
190+
: delta / denominator;
191+
return saturation < saturationThreshold;
192+
}
193+
194+
/// <summary>
195+
/// Returns <see langword="true"/> when all palette entries are achromatic,
196+
/// all entries are the same color, or all entries match the background color.
197+
/// This is the single check that layout engines should call before consuming
198+
/// <see cref="Theme.NodePalette"/> directly.
199+
/// </summary>
200+
/// <param name="palette">Palette to evaluate (must not be <see langword="null"/>).</param>
201+
/// <param name="backgroundColor">
202+
/// Optional background color. When provided, a palette whose every entry matches
203+
/// the background is considered monochrome (the nodes would be invisible).
204+
/// </param>
205+
/// <exception cref="ArgumentNullException"><paramref name="palette"/> is <see langword="null"/>.</exception>
206+
public static bool IsPaletteMonochrome(IReadOnlyList<string> palette, string? backgroundColor = null)
207+
{
208+
ArgumentNullException.ThrowIfNull(palette);
209+
210+
if (palette.Count == 0)
211+
return false;
212+
213+
// All entries are achromatic (white, black, or gray).
214+
if (palette.All(c => IsAchromatic(c)))
215+
return true;
216+
217+
// 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;
220+
221+
// 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;
225+
226+
return false;
227+
}
228+
163229
// ── HSL helpers ───────────────────────────────────────────────────────────
164230

165231
/// <summary>
@@ -354,16 +420,44 @@ private static string ExpandShorthand(string hex)
354420
/// (removing the shared "whiteness") and adding a small brightness floor.
355421
/// Useful when a pastel palette needs a bold accent variant (e.g. pillar titles).
356422
/// </summary>
423+
/// <remarks>
424+
/// When the input color is achromatic (no hue to amplify), a lightness shift is
425+
/// applied instead: light achromatic inputs are darkened and dark achromatic inputs
426+
/// are lightened to produce a visually distinguishable result. Callers that require
427+
/// a chromatic output for achromatic inputs should use
428+
/// <see cref="IsPaletteMonochrome"/> / <c>ThemePaletteResolver.ResolveEffectivePalette</c>
429+
/// before calling this method.
430+
/// </remarks>
357431
/// <param name="hex">Hex color string (typically a pastel).</param>
358432
/// <param name="amplify">How much to amplify the hue deviation (default 3).</param>
359433
public static string Vibrant(string hex, double amplify = 3.0)
360434
{
361435
var (r, g, b, a) = ParseHexWithAlpha(hex);
362-
int min = Math.Min(r, Math.Min(g, b));
436+
437+
if (IsAchromatic(hex))
438+
{
439+
// Achromatic colors have no hue channel to amplify.
440+
// Shift lightness toward mid-range so the result is visually
441+
// distinguishable from both the input and a same-color background.
442+
double max = Math.Max(r, Math.Max(g, b));
443+
double min = Math.Min(r, Math.Min(g, b));
444+
double lightness = (max + min) / (2.0 * 255);
445+
// 0.5 splits light (≥0.5) from dark (<0.5).
446+
// A shift of 0.35 reliably moves the result away from the input without
447+
// pushing it all the way to the opposite extreme.
448+
// The 0.25 / 0.75 bounds prevent the result landing too close to black or white.
449+
double targetLightness = lightness >= 0.5
450+
? Math.Max(0.25, lightness - 0.35)
451+
: Math.Min(0.75, lightness + 0.35);
452+
int channel = (int)Math.Round(targetLightness * 255);
453+
return ToHex(channel, channel, channel, a);
454+
}
455+
456+
int minChannel = Math.Min(r, Math.Min(g, b));
363457
return ToHex(
364-
Clamp((int)((r - min) * amplify + min / 4.0)),
365-
Clamp((int)((g - min) * amplify + min / 4.0)),
366-
Clamp((int)((b - min) * amplify + min / 4.0)),
458+
Clamp((int)((r - minChannel) * amplify + minChannel / 4.0)),
459+
Clamp((int)((g - minChannel) * amplify + minChannel / 4.0)),
460+
Clamp((int)((b - minChannel) * amplify + minChannel / 4.0)),
367461
a);
368462
}
369463

src/DiagramForge/Models/ThemePaletteResolver.cs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,111 @@ namespace DiagramForge.Models;
88
/// </summary>
99
public static class ThemePaletteResolver
1010
{
11+
private const int DefaultPaletteSize = 8;
12+
13+
/// <summary>
14+
/// Returns a palette suitable for direct node-fill use, regardless of whether
15+
/// the theme's <see cref="Theme.NodePalette"/> is chromatic.
16+
/// </summary>
17+
/// <remarks>
18+
/// <para>
19+
/// If <see cref="Theme.NodePalette"/> is non-empty and
20+
/// <see cref="ColorUtils.IsPaletteMonochrome"/> returns <see langword="false"/>,
21+
/// the palette is returned unchanged.
22+
/// </para>
23+
/// <para>
24+
/// When the palette is monochrome (all-white, all-black, all-same, or all matching
25+
/// the background), a fallback palette is built:
26+
/// <list type="number">
27+
/// <item>
28+
/// If <see cref="Theme.UseBorderGradients"/> is <see langword="true"/> and
29+
/// <see cref="Theme.BorderGradientStops"/> contains more than one stop, the stops
30+
/// are interpolated/cycled to produce <see cref="DefaultPaletteSize"/> entries.
31+
/// </item>
32+
/// <item>
33+
/// Otherwise, a palette is derived from <see cref="Theme.AccentColor"/> and
34+
/// <see cref="Theme.SecondaryColor"/> via hue rotation.
35+
/// </item>
36+
/// </list>
37+
/// </para>
38+
/// <para>This is a pure function of the <see cref="Theme"/> — no side effects.</para>
39+
/// </remarks>
40+
/// <param name="theme">Source theme.</param>
41+
/// <returns>
42+
/// A <see cref="IReadOnlyList{T}"/> of hex color strings suitable for node fills.
43+
/// </returns>
44+
/// <exception cref="ArgumentNullException"><paramref name="theme"/> is <see langword="null"/>.</exception>
45+
public static IReadOnlyList<string> ResolveEffectivePalette(Theme theme)
46+
{
47+
ArgumentNullException.ThrowIfNull(theme);
48+
49+
if (theme.NodePalette is { Count: > 0 } &&
50+
!ColorUtils.IsPaletteMonochrome(theme.NodePalette, theme.BackgroundColor))
51+
return theme.NodePalette;
52+
53+
// Build fallback from gradient stops when they are available and meaningful.
54+
if (theme.UseBorderGradients && theme.BorderGradientStops is { Count: > 1 })
55+
return BuildPaletteFromGradientStops(theme.BorderGradientStops, DefaultPaletteSize);
56+
57+
// Fall back to hue-rotation derivation from the theme's semantic colors.
58+
return BuildPaletteFromHueRotation(theme.AccentColor, theme.SecondaryColor, DefaultPaletteSize);
59+
}
60+
61+
// ── Private helpers ───────────────────────────────────────────────────────
62+
63+
/// <summary>
64+
/// Samples <paramref name="count"/> evenly-distributed colors from the gradient
65+
/// by linearly interpolating between adjacent stop pairs.
66+
/// </summary>
67+
private static IReadOnlyList<string> BuildPaletteFromGradientStops(IReadOnlyList<string> stops, int count)
68+
{
69+
// Callers must supply at least two stops so there is an interpolatable range.
70+
if (stops.Count < 2)
71+
throw new ArgumentException("At least two gradient stops are required.", nameof(stops));
72+
73+
var result = new List<string>(count);
74+
75+
if (count == 1)
76+
{
77+
result.Add(stops[0]);
78+
return result;
79+
}
80+
81+
for (int i = 0; i < count; i++)
82+
{
83+
double t = (double)i / (count - 1);
84+
double position = t * (stops.Count - 1);
85+
int lo = Math.Min((int)Math.Floor(position), stops.Count - 2);
86+
int hi = lo + 1;
87+
double blend = position - lo;
88+
result.Add(ColorUtils.Blend(stops[lo], stops[hi], blend));
89+
}
90+
91+
return result;
92+
}
93+
94+
/// <summary>
95+
/// Derives <paramref name="count"/> colors by rotating the hue of
96+
/// <paramref name="accentColor"/> and <paramref name="secondaryColor"/>
97+
/// in equal steps, alternating between the two base colors.
98+
/// </summary>
99+
private static IReadOnlyList<string> BuildPaletteFromHueRotation(
100+
string accentColor, string secondaryColor, int count)
101+
{
102+
bool isLight = ColorUtils.IsLight(accentColor);
103+
double hueStep = 360.0 / count;
104+
var result = new List<string>(count);
105+
106+
for (int i = 0; i < count; i++)
107+
{
108+
string baseColor = i % 2 == 0 ? accentColor : secondaryColor;
109+
double rotation = Math.Floor(i / 2.0) * hueStep;
110+
result.Add(ColorUtils.RotateHue(baseColor, rotation, isLight));
111+
}
112+
113+
return result;
114+
}
115+
11116
/// <summary>
12117
/// Builds an array of <paramref name="ringCount"/> visually distinct ring colors
13118
/// derived from the theme palette.

0 commit comments

Comments
 (0)