From 5775b7d6e98d263bad79b02881cb49cd851ec191 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 13:26:10 +0000 Subject: [PATCH] Keep source alpha and tolerate round-trip error when extrapolating a single colour [patch] ExtrapolateColorToLightness rebuilt colours with Color.FromOklab and no alpha, so every single-colour meaning (every accent) came out opaque while interpolated neutral ramps kept theirs. Pass baseColor.A through. Its gamut test also bounded linear channels exactly at [0, 1]. An Oklab round trip leaves ~1e-7 of error, so colours on the gamut boundary such as #ff0000 were treated as out of gamut and had their chroma shaved (#ff0000 -> #ff0201). Move the check into IsInGamut with a 1e-6 tolerance and clamp the result, at both call sites. Fixes ktsu-dev/ThemeProvider#111 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SerwFWrikSrdorQJydu9st --- .../SemanticColorMapperEdgeCaseTests.cs | 53 ++++++++++++++++++ ThemeProvider/SemanticColorMapper.cs | 54 +++++++++---------- 2 files changed, 80 insertions(+), 27 deletions(-) diff --git a/ThemeProvider.Test/SemanticColorMapperEdgeCaseTests.cs b/ThemeProvider.Test/SemanticColorMapperEdgeCaseTests.cs index e7aa549..1418e10 100644 --- a/ThemeProvider.Test/SemanticColorMapperEdgeCaseTests.cs +++ b/ThemeProvider.Test/SemanticColorMapperEdgeCaseTests.cs @@ -178,6 +178,59 @@ public void MapColors_ForLightTheme_DarkensAsPriorityRises() Assert.IsLessThan(lowest, highest, "A light theme must darken as priority rises"); } + /// + /// A meaning with a single source color keeps that color's alpha, the same way an interpolated + /// neutral ramp does, rather than coming out opaque. + /// + [TestMethod] + public void MapColors_WithSingleTranslucentSourceColor_KeepsItsAlpha() + { + StubTheme theme = new( + new Dictionary> + { + [SemanticMeaning.Neutral] = [Color.FromHex("#10101080"), Color.FromHex("#f0f0f080")], + [SemanticMeaning.Primary] = [Color.FromHex("#3366cc80")], + }, + isDark: true); + + IReadOnlyDictionary palette = SemanticColorMapper.MakeCompletePalette(theme); + double sourceAlpha = Color.FromHex("#3366cc80").A; + + foreach (Priority priority in Enum.GetValues()) + { + Assert.AreEqual( + sourceAlpha, + palette[new(SemanticMeaning.Primary, priority)].A, + 1e-9, + $"Primary at {priority} lost its source alpha"); + } + } + + /// + /// Extrapolating a color to its own lightness returns that color unchanged. Saturated sRGB + /// primaries sit exactly on the gamut boundary, so the Oklab round trip leaves them a hair + /// outside it, and an exact gamut test would wrongly shave their chroma (#ff0000 to #ff0201). + /// + /// The source color. + [TestMethod] + [DataRow("#FF0000")] + [DataRow("#00FF00")] + [DataRow("#0000FF")] + [DataRow("#FFFF00")] + [DataRow("#00FFFF")] + [DataRow("#FF00FF")] + [DataRow("#FFFFFF")] + [DataRow("#000000")] + [DataRow("#3366CC80")] + public void ExtrapolateColorToLightness_AtItsOwnLightness_ReturnsTheSameColor(string hex) + { + Color source = Color.FromHex(hex); + + Color result = SemanticColorMapper.ExtrapolateColorToLightness(source, source.ToOklab().L); + + Assert.AreEqual(hex, result.ToHex()); + } + /// /// Every color the mapper emits for every registered theme must be inside the sRGB gamut. /// The mapper reduces chroma to stay in gamut, and this pins that it always succeeds. diff --git a/ThemeProvider/SemanticColorMapper.cs b/ThemeProvider/SemanticColorMapper.cs index 6eb01d3..24951df 100644 --- a/ThemeProvider/SemanticColorMapper.cs +++ b/ThemeProvider/SemanticColorMapper.cs @@ -268,9 +268,9 @@ private static Color InterpolateToTargetLightness( } /// - /// Extrapolates a single color to a target lightness while preserving its hue and chroma. + /// Extrapolates a single color to a target lightness while preserving its hue, chroma and alpha. /// - private static Color ExtrapolateColorToLightness(Color baseColor, double targetLightness) + internal static Color ExtrapolateColorToLightness(Color baseColor, double targetLightness) { // Work in Oklab space for perceptually uniform lightness adjustment Oklab baseOklab = baseColor.ToOklab(); @@ -283,17 +283,12 @@ private static Color ExtrapolateColorToLightness(Color baseColor, double targetL ); // Convert to Color to check if it's in gamut - Color targetColor = Color.FromOklab(targetOklab); + Color targetColor = Color.FromOklab(targetOklab, baseColor.A); - // Check if RGB values are within valid range - bool inGamut = targetColor.R >= 0.0 && targetColor.R <= 1.0 && - targetColor.G >= 0.0 && targetColor.G <= 1.0 && - targetColor.B >= 0.0 && targetColor.B <= 1.0; - - if (inGamut) + if (IsInGamut(targetColor)) { - // Color is in gamut, use it directly - return targetColor; + // Color is in gamut, use it directly (clamped to absorb the round-trip error IsInGamut tolerates) + return targetColor.Clamp(); } // Color is out of gamut, we need to find the best in-gamut color @@ -320,13 +315,9 @@ private static Color ExtrapolateColorToLightness(Color baseColor, double targetL B: testChroma * Math.Sin(hue) ); - Color testColor = Color.FromOklab(testOklab); - - bool testInGamut = testColor.R >= 0.0 && testColor.R <= 1.0 && - testColor.G >= 0.0 && testColor.G <= 1.0 && - testColor.B >= 0.0 && testColor.B <= 1.0; + Color testColor = Color.FromOklab(testOklab, baseColor.A); - if (testInGamut) + if (IsInGamut(testColor)) { // This chroma works, try higher minChroma = testChroma; @@ -339,18 +330,27 @@ private static Color ExtrapolateColorToLightness(Color baseColor, double targetL } } - // Convert the best in-gamut color to the final Color - Color bestColor = Color.FromOklab(bestOklab); - - // Final safety clamp (should not be needed if binary search worked correctly) - return Color.FromLinear( - Math.Max(0.0, Math.Min(bestColor.R, 1.0)), - Math.Max(0.0, Math.Min(bestColor.G, 1.0)), - Math.Max(0.0, Math.Min(bestColor.B, 1.0)), - bestColor.A - ); + // Convert the best in-gamut color to the final Color, clamping away the tolerated round-trip error + return Color.FromOklab(bestOklab, baseColor.A).Clamp(); } + /// + /// How far outside [0, 1] a linear channel may stray and still count as in gamut. An Oklab round + /// trip of an in-gamut color leaves error on the order of 1e-7, so an exact bounds check would + /// misclassify colors like pure sRGB red as out of gamut and needlessly reduce their chroma. + /// + private const double GamutTolerance = 1e-6; + + /// + /// Determines whether every linear channel of lies within the sRGB gamut, + /// allowing of floating-point error. + /// + private static bool IsInGamut(Color color) => + IsChannelInGamut(color.R) && IsChannelInGamut(color.G) && IsChannelInGamut(color.B); + + private static bool IsChannelInGamut(double channel) => + channel is >= -GamutTolerance and <= 1.0 + GamutTolerance; + /// /// Interpolates between colors in a sorted list to achieve a target lightness. ///