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.
///