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