Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions ThemeProvider.Test/SemanticColorMapperEdgeCaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,59 @@ public void MapColors_ForLightTheme_DarkensAsPriorityRises()
Assert.IsLessThan(lowest, highest, "A light theme must darken as priority rises");
}

/// <summary>
/// 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.
/// </summary>
[TestMethod]
public void MapColors_WithSingleTranslucentSourceColor_KeepsItsAlpha()
{
StubTheme theme = new(
new Dictionary<SemanticMeaning, Collection<Color>>
{
[SemanticMeaning.Neutral] = [Color.FromHex("#10101080"), Color.FromHex("#f0f0f080")],
[SemanticMeaning.Primary] = [Color.FromHex("#3366cc80")],
},
isDark: true);

IReadOnlyDictionary<SemanticColorRequest, Color> palette = SemanticColorMapper.MakeCompletePalette(theme);
double sourceAlpha = Color.FromHex("#3366cc80").A;

foreach (Priority priority in Enum.GetValues<Priority>())
{
Assert.AreEqual(
sourceAlpha,
palette[new(SemanticMeaning.Primary, priority)].A,
1e-9,
$"Primary at {priority} lost its source alpha");
}
}

/// <summary>
/// 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).
/// </summary>
/// <param name="hex">The source color.</param>
[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());
}

/// <summary>
/// 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.
Expand Down
54 changes: 27 additions & 27 deletions ThemeProvider/SemanticColorMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,9 @@ private static Color InterpolateToTargetLightness(
}

/// <summary>
/// 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.
/// </summary>
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();
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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();
}

/// <summary>
/// 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.
/// </summary>
private const double GamutTolerance = 1e-6;

/// <summary>
/// Determines whether every linear channel of <paramref name="color"/> lies within the sRGB gamut,
/// allowing <see cref="GamutTolerance"/> of floating-point error.
/// </summary>
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;

/// <summary>
/// Interpolates between colors in a sorted list to achieve a target lightness.
/// </summary>
Expand Down
Loading