@@ -160,6 +160,80 @@ 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. Values outside [0,1] are clamped. Defaults to 0.08.
174+ /// </param>
175+ public static bool IsAchromatic ( string hex , double saturationThreshold = 0.08 )
176+ {
177+ saturationThreshold = Math . Clamp ( saturationThreshold , 0 , 1 ) ;
178+ var ( rRaw , gRaw , bRaw ) = ParseHex ( hex ) ;
179+ double r = rRaw / 255d ;
180+ double g = gRaw / 255d ;
181+ double b = bRaw / 255d ;
182+ double max = Math . Max ( r , Math . Max ( g , b ) ) ;
183+ double min = Math . Min ( r , Math . Min ( g , b ) ) ;
184+ double delta = max - min ;
185+ double lightness = ( max + min ) / 2 ;
186+ double denominator = 1 - Math . Abs ( 2 * lightness - 1 ) ;
187+ // When delta is near zero the color is already achromatic; guard the denominator
188+ // (which is also ~0 at lightness=0 or lightness=1) to avoid division by zero.
189+ double saturation = delta < 0.0001 || denominator < 0.0001
190+ ? 0
191+ : delta / denominator ;
192+ return saturation < saturationThreshold ;
193+ }
194+
195+ /// <summary>
196+ /// Returns <see langword="true"/> when all palette entries are achromatic,
197+ /// all entries are the same color, or all entries match the background color.
198+ /// This is the single check that layout engines should call before consuming
199+ /// <see cref="Theme.NodePalette"/> directly.
200+ /// </summary>
201+ /// <param name="palette">Palette to evaluate (must not be <see langword="null"/>).</param>
202+ /// <param name="backgroundColor">
203+ /// Optional background color. When provided, a palette whose every entry matches
204+ /// the background is considered monochrome (the nodes would be invisible).
205+ /// </param>
206+ /// <exception cref="ArgumentNullException"><paramref name="palette"/> is <see langword="null"/>.</exception>
207+ public static bool IsPaletteMonochrome ( IReadOnlyList < string > palette , string ? backgroundColor = null )
208+ {
209+ ArgumentNullException . ThrowIfNull ( palette ) ;
210+
211+ if ( palette . Count == 0 )
212+ return false ;
213+
214+ // All entries are achromatic (white, black, or gray).
215+ if ( palette . All ( c => IsAchromatic ( c ) ) )
216+ return true ;
217+
218+ // All entries are the same color (trivially monochrome — no hue variety).
219+ // Compare by parsed RGBA values so shorthand formats (#FFF vs #FFFFFF) are treated as equal.
220+ {
221+ var referenceColor = ParseHexWithAlpha ( palette [ 0 ] ) ;
222+ if ( palette . All ( c => ParseHexWithAlpha ( c ) == referenceColor ) )
223+ return true ;
224+ }
225+
226+ // All entries match the background (nodes would be invisible against the canvas).
227+ if ( backgroundColor is not null )
228+ {
229+ var background = ParseHexWithAlpha ( backgroundColor ) ;
230+ if ( palette . All ( c => ParseHexWithAlpha ( c ) == background ) )
231+ return true ;
232+ }
233+
234+ return false ;
235+ }
236+
163237 // ── HSL helpers ───────────────────────────────────────────────────────────
164238
165239 /// <summary>
@@ -354,16 +428,44 @@ private static string ExpandShorthand(string hex)
354428 /// (removing the shared "whiteness") and adding a small brightness floor.
355429 /// Useful when a pastel palette needs a bold accent variant (e.g. pillar titles).
356430 /// </summary>
431+ /// <remarks>
432+ /// When the input color is achromatic (no hue to amplify), a lightness shift is
433+ /// applied instead: light achromatic inputs are darkened and dark achromatic inputs
434+ /// are lightened to produce a visually distinguishable result. Callers that require
435+ /// a chromatic output for achromatic inputs should use
436+ /// <see cref="IsPaletteMonochrome"/> / <c>ThemePaletteResolver.ResolveEffectivePalette</c>
437+ /// before calling this method.
438+ /// </remarks>
357439 /// <param name="hex">Hex color string (typically a pastel).</param>
358440 /// <param name="amplify">How much to amplify the hue deviation (default 3).</param>
359441 public static string Vibrant ( string hex , double amplify = 3.0 )
360442 {
361443 var ( r , g , b , a ) = ParseHexWithAlpha ( hex ) ;
362- int min = Math . Min ( r , Math . Min ( g , b ) ) ;
444+
445+ if ( IsAchromatic ( hex ) )
446+ {
447+ // Achromatic colors have no hue channel to amplify.
448+ // Shift lightness toward mid-range so the result is visually
449+ // distinguishable from both the input and a same-color background.
450+ double max = Math . Max ( r , Math . Max ( g , b ) ) ;
451+ double min = Math . Min ( r , Math . Min ( g , b ) ) ;
452+ double lightness = ( max + min ) / ( 2.0 * 255 ) ;
453+ // 0.5 splits light (≥0.5) from dark (<0.5).
454+ // A shift of 0.35 reliably moves the result away from the input without
455+ // pushing it all the way to the opposite extreme.
456+ // The 0.25 / 0.75 bounds prevent the result landing too close to black or white.
457+ double targetLightness = lightness >= 0.5
458+ ? Math . Max ( 0.25 , lightness - 0.35 )
459+ : Math . Min ( 0.75 , lightness + 0.35 ) ;
460+ int channel = ( int ) Math . Round ( targetLightness * 255 ) ;
461+ return ToHex ( channel , channel , channel , a ) ;
462+ }
463+
464+ int minChannel = Math . Min ( r , Math . Min ( g , b ) ) ;
363465 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 ) ) ,
466+ Clamp ( ( int ) ( ( r - minChannel ) * amplify + minChannel / 4.0 ) ) ,
467+ Clamp ( ( int ) ( ( g - minChannel ) * amplify + minChannel / 4.0 ) ) ,
468+ Clamp ( ( int ) ( ( b - minChannel ) * amplify + minChannel / 4.0 ) ) ,
367469 a ) ;
368470 }
369471
0 commit comments