Skip to content

Add monochrome palette detection and fallback palette resolution#145

Merged
jongalloway merged 3 commits into
mainfrom
copilot/add-monochrome-palette-detection
Mar 25, 2026
Merged

Add monochrome palette detection and fallback palette resolution#145
jongalloway merged 3 commits into
mainfrom
copilot/add-monochrome-palette-detection

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 24, 2026

Themes with all-achromatic NodePalette entries (e.g. Prism's all-#FFFFFF palette) had no shared infrastructure to detect the condition or substitute meaningful colors—leading to invisible fills, hue-scoring no-ops, and silent Vibrant() failures.

ColorUtils additions

  • IsAchromatic(hex, saturationThreshold = 0.08) — computes HSL saturation and returns true below the threshold; guards against division-by-zero at extreme lightness values.
  • IsPaletteMonochrome(palette, backgroundColor?) — returns true when all entries are achromatic, all are the same color, or all match the background (invisible nodes). Returns false for empty palettes.
  • Vibrant() hardening — detects achromatic input via IsAchromatic and applies an explicit lightness shift (light → −0.35, dark → +0.35, bounded to [0.25, 0.75]) instead of silently running the hue-amplification formula on colorless input.

ThemePaletteResolver addition

  • ResolveEffectivePalette(theme) — pure function returning a usable 8-entry fill palette. Returns NodePalette unchanged when it's chromatic; otherwise falls back to:
    1. Linear interpolation of BorderGradientStops to 8 entries (when UseBorderGradients && BorderGradientStops.Count > 1)
    2. Hue-rotation of AccentColor/SecondaryColor across 8 evenly-spaced positions
// Prism has all-white NodePalette — ResolveEffectivePalette returns chromatic colors from gradient stops
var palette = ThemePaletteResolver.ResolveEffectivePalette(Theme.Prism);
// → ["#2563EB", …, "#F59E0B"] — 8 interpolated entries, none monochrome

// IsAchromatic / IsPaletteMonochrome as the shared gate before consuming NodePalette directly
bool skip = ColorUtils.IsPaletteMonochrome(theme.NodePalette, theme.BackgroundColor);

These are additive utilities only—no existing rendering paths are changed; per-diagram wiring is handled in follow-on sub-issues.


⚡ Quickly spin up Copilot coding agent tasks from anywhere on your macOS or Windows machine with Raycast.

Copilot AI changed the title [WIP] Add monochrome palette detection and fallback palette resolution Add monochrome palette detection and fallback palette resolution Mar 24, 2026
Copilot AI requested a review from jongalloway March 24, 2026 00:52
@jongalloway jongalloway marked this pull request as ready for review March 24, 2026 02:10
@jongalloway jongalloway requested a review from Copilot March 24, 2026 02:10
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds shared infrastructure to detect achromatic/monochrome palettes and derive a usable fallback palette, preventing “invisible” node fills and improving robustness of color derivation utilities.

Changes:

  • Added ColorUtils.IsAchromatic / ColorUtils.IsPaletteMonochrome and hardened ColorUtils.Vibrant() for achromatic inputs.
  • Added ThemePaletteResolver.ResolveEffectivePalette(theme) to return either the theme palette (when usable) or an 8-color fallback derived from gradient stops or hue rotation.
  • Added unit tests covering the new utilities and fallback behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.

File Description
tests/DiagramForge.Tests/Models/ThemePaletteResolverTests.cs Adds tests for ResolveEffectivePalette behavior across monochrome and gradient-stop fallback scenarios.
tests/DiagramForge.Tests/Models/ColorUtilsTests.cs Adds tests for IsAchromatic, IsPaletteMonochrome, and the new achromatic behavior in Vibrant().
src/DiagramForge/Models/ThemePaletteResolver.cs Implements ResolveEffectivePalette plus internal helpers for gradient sampling and hue-rotation palette derivation.
src/DiagramForge/Models/ColorUtils.cs Implements achromatic/monochrome detection and modifies Vibrant() to handle achromatic inputs explicitly.
Comments suppressed due to low confidence (1)

tests/DiagramForge.Tests/Models/ColorUtilsTests.cs:813

  • Test name doesn’t match the assertion: IsPaletteMonochrome_MatchesBackground_NoBackground_ReturnsFalse asserts True. Rename the test (e.g., “…ReturnsTrue”) or change the assertion so the name accurately describes the expected behavior.
    public void IsPaletteMonochrome_MatchesBackground_NoBackground_ReturnsFalse()
    {
        // Same chromatic entries, but without a background to compare against —
        // they are the same color, so still "all same color" → true
        var palette = new[] { "#2563EB", "#2563EB", "#2563EB" };
        Assert.True(ColorUtils.IsPaletteMonochrome(palette, backgroundColor: null));
    }

Comment on lines +170 to +191
/// <param name="hex">Hex color string.</param>
/// <param name="saturationThreshold">
/// Saturation threshold 0–1; colors with saturation below this value are considered
/// achromatic. Defaults to 0.08.
/// </param>
public static bool IsAchromatic(string hex, double saturationThreshold = 0.08)
{
var (rRaw, gRaw, bRaw) = ParseHex(hex);
double r = rRaw / 255d;
double g = gRaw / 255d;
double b = bRaw / 255d;
double max = Math.Max(r, Math.Max(g, b));
double min = Math.Min(r, Math.Min(g, b));
double delta = max - min;
double lightness = (max + min) / 2;
double denominator = 1 - Math.Abs(2 * lightness - 1);
// When delta is near zero the color is already achromatic; guard the denominator
// (which is also ~0 at lightness=0 or lightness=1) to avoid division by zero.
double saturation = delta < 0.0001 || denominator < 0.0001
? 0
: delta / denominator;
return saturation < saturationThreshold;
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The XML doc for saturationThreshold says it is a 0–1 value, but the method currently accepts any double without clamping/validation. Either clamp saturationThreshold into [0,1] (similar to Blend/WithOpacity) or update the docs to reflect the actual behavior to avoid surprising results for negative/large thresholds.

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +111
private static IReadOnlyList<string> BuildPaletteFromHueRotation(
string accentColor, string secondaryColor, int count)
{
bool isLight = ColorUtils.IsLight(accentColor);
double hueStep = 360.0 / count;
var result = new List<string>(count);

for (int i = 0; i < count; i++)
{
string baseColor = i % 2 == 0 ? accentColor : secondaryColor;
double rotation = Math.Floor(i / 2.0) * hueStep;
result.Add(ColorUtils.RotateHue(baseColor, rotation, isLight));
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildPaletteFromHueRotation passes ColorUtils.IsLight(accentColor) as the isLightBackground argument to ColorUtils.RotateHue. That parameter controls clamping based on the canvas/background, not whether the accent color itself is light. This can skew the generated palette (too dark/light) depending on theme background. Consider deriving isLightBackground from theme.BackgroundColor (and passing it down) or adding an explicit parameter so callers can provide the correct background context.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +58
// Build fallback from gradient stops when they are available and meaningful.
if (theme.UseBorderGradients && theme.BorderGradientStops is { Count: > 1 })
return BuildPaletteFromGradientStops(theme.BorderGradientStops, DefaultPaletteSize);

// Fall back to hue-rotation derivation from the theme's semantic colors.
return BuildPaletteFromHueRotation(theme.AccentColor, theme.SecondaryColor, DefaultPaletteSize);
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResolveEffectivePalette can still return a monochrome palette if BorderGradientStops are present but themselves monochrome/achromatic (e.g., two identical stops, or all-gray stops). Since the method’s contract is to return a palette suitable for direct fill use, consider validating the gradient-derived palette with IsPaletteMonochrome (optionally against theme.BackgroundColor) and falling back to the hue-rotation path when the gradient result is still monochrome.

Copilot uses AI. Check for mistakes.
/// <item>
/// If <see cref="Theme.UseBorderGradients"/> is <see langword="true"/> and
/// <see cref="Theme.BorderGradientStops"/> contains more than one stop, the stops
/// are interpolated/cycled to produce <see cref="DefaultPaletteSize"/> entries.
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remarks mention gradient stops are “interpolated/cycled”, but BuildPaletteFromGradientStops only linearly interpolates across the stop range and does not cycle/repeat stops. Please update the comment to match the actual behavior (or implement cycling if that was intended).

Suggested change
/// are interpolated/cycled to produce <see cref="DefaultPaletteSize"/> entries.
/// are linearly interpolated to produce <see cref="DefaultPaletteSize"/> entries.

Copilot uses AI. Check for mistakes.
Comment on lines +727 to +732
public void IsAchromatic_CustomThreshold_LowSaturationColor_ReturnsTrue()
{
// A muted blue — below the default threshold but above an even lower custom one
// Test that the threshold parameter is respected
Assert.False(ColorUtils.IsAchromatic("#4F81BD", saturationThreshold: 0.01));
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test name doesn’t match the assertion: IsAchromatic_CustomThreshold_LowSaturationColor_ReturnsTrue asserts False. Rename the test (e.g., “…ReturnsFalse”) or adjust the expectation so the name accurately reflects the behavior being validated.

This issue also appears on line 807 of the same file.

Copilot uses AI. Check for mistakes.
Comment thread src/DiagramForge/Models/ColorUtils.cs Outdated
Comment on lines +218 to +224
if (palette.All(c => string.Equals(c, palette[0], StringComparison.OrdinalIgnoreCase)))
return true;

// All entries match the background (nodes would be invisible against the canvas).
if (backgroundColor is not null &&
palette.All(c => string.Equals(c, backgroundColor, StringComparison.OrdinalIgnoreCase)))
return true;
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsPaletteMonochrome compares colors using string.Equals, which treats equivalent hex encodings as different (e.g. #F00 vs #FF0000, or #ABC vs #AABBCC). Because ColorUtils explicitly supports shorthand formats, this can incorrectly return false for palettes that are effectively monochrome or match the background. Consider normalizing colors (e.g., via ParseHex/ParseHexWithAlpha or ExpandShorthand) before doing “all same” / “matches background” comparisons, and compare by RGB(A) values rather than raw strings.

Suggested change
if (palette.All(c => string.Equals(c, palette[0], StringComparison.OrdinalIgnoreCase)))
return true;
// All entries match the background (nodes would be invisible against the canvas).
if (backgroundColor is not null &&
palette.All(c => string.Equals(c, backgroundColor, StringComparison.OrdinalIgnoreCase)))
return true;
{
var referenceColor = ParseHexWithAlpha(palette[0]);
if (palette.All(c => ParseHexWithAlpha(c) == referenceColor))
return true;
}
// All entries match the background (nodes would be invisible against the canvas).
if (backgroundColor is not null)
{
var background = ParseHexWithAlpha(backgroundColor);
if (palette.All(c => ParseHexWithAlpha(c) == background))
return true;
}

Copilot uses AI. Check for mistakes.
@jongalloway
Copy link
Copy Markdown
Owner

@copilot apply changes based on the comments in this thread

…ackground, validate gradient palette, fix test names

Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
Agent-Logs-Url: https://github.com/jongalloway/DiagramForge/sessions/4b46141f-444c-46cc-9873-d40f91adb1a3
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 25, 2026

@copilot apply changes based on the comments in this thread

Applied all six review comments in commit 0e5b06f:

  1. saturationThreshold clampingIsAchromatic now clamps the parameter to [0,1] via Math.Clamp, and the doc updated to mention the clamp.
  2. isLightBackground sourceBuildPaletteFromHueRotation now takes an explicit isLightBackground parameter; ResolveEffectivePalette derives it from theme.BackgroundColor instead of the accent color.
  3. Monochrome gradient validationResolveEffectivePalette now checks the gradient-derived palette with IsPaletteMonochrome before returning it; if the gradient stops are themselves monochrome, it falls through to hue-rotation.
  4. Doc comment — "interpolated/cycled" → "linearly interpolated".
  5. Test name mismatchesIsAchromatic_CustomThreshold_LowSaturationColor_ReturnsTrueReturnsFalse; IsPaletteMonochrome_MatchesBackground_NoBackground_ReturnsFalseReturnsTrue.
  6. Hex normalizationIsPaletteMonochrome now compares via ParseHexWithAlpha tuples so #FFF and #FFFFFF are treated as equal. New tests cover #FFF/#FFFFFF equivalence and threshold clamping edge cases.

@jongalloway jongalloway merged commit f73e1c8 into main Mar 25, 2026
5 checks passed
@jongalloway jongalloway deleted the copilot/add-monochrome-palette-detection branch March 25, 2026 05:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add monochrome palette detection and fallback palette resolution

3 participants