@@ -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
0 commit comments