diff --git a/Semantics.Color/Color.Operations.cs b/Semantics.Color/Color.Operations.cs index 3bb5ebc6..828bc6c3 100644 --- a/Semantics.Color/Color.Operations.cs +++ b/Semantics.Color/Color.Operations.cs @@ -60,34 +60,46 @@ public Color AdjustForContrast(Color background, AccessibilityLevel target, bool Oklab lab = ToOklab(); double alpha = A; - bool goLighter = background.RelativeLuminance < 0.5; - double lo = goLighter ? lab.L : 0.0; - double hi = goLighter ? 1.0 : lab.L; - // Contrast increases monotonically as L moves toward the chosen extreme; binary-search the - // smallest movement that meets the requirement. - for (int i = 0; i < 30; i++) + // Try first the direction whose extreme (white or black) gives the higher contrast against + // this background. The two are equal near luminance 0.179, not 0.5, so on a mid-tone + // background darkening is usually the only way to reach the target. If the preferred + // direction still falls short, fall back to the other one before giving up. + double bgLum = background.RelativeLuminance; + bool preferLighter = (1.05 / (bgLum + 0.05)) >= ((bgLum + 0.05) / 0.05); + + return Search(preferLighter) ?? Search(!preferLighter) ?? this; + + Color? Search(bool goLighter) { - double mid = (lo + hi) / 2.0; - Color candidate = Candidate(lab, mid); - bool meets = candidate.ContrastRatio(background) >= required; - - // The interval always shrinks toward the end that satisfies the requirement. When - // lightening that is the upper bound if the midpoint already meets it; when darkening - // the roles swap. Both cases reduce to whether the midpoint landed on the goLighter - // side, so the four-way branch collapses to one comparison. - if (meets == goLighter) - { - hi = mid; - } - else + double lo = goLighter ? lab.L : 0.0; + double hi = goLighter ? 1.0 : lab.L; + + // Contrast increases monotonically as L moves toward the chosen extreme; binary-search + // the smallest movement that meets the requirement. + for (int i = 0; i < 30; i++) { - lo = mid; + double mid = (lo + hi) / 2.0; + Color candidate = Candidate(lab, mid); + bool meets = candidate.ContrastRatio(background) >= required; + + // The interval always shrinks toward the end that satisfies the requirement. When + // lightening that is the upper bound if the midpoint already meets it; when darkening + // the roles swap. Both cases reduce to whether the midpoint landed on the goLighter + // side, so the four-way branch collapses to one comparison. + if (meets == goLighter) + { + hi = mid; + } + else + { + lo = mid; + } } - } - Color result = Candidate(lab, goLighter ? hi : lo); - return result.ContrastRatio(background) >= required ? result : this; + Color result = Candidate(lab, goLighter ? hi : lo); + return result.ContrastRatio(background) >= required ? result : null; + } Color Candidate(Oklab source, double lightness) => FromOklab(new Oklab(lightness, source.A, source.B), alpha).Clamp(); diff --git a/Semantics.Test/Colors/AccessibilityTests.cs b/Semantics.Test/Colors/AccessibilityTests.cs index 5a466fda..e3ac86f1 100644 --- a/Semantics.Test/Colors/AccessibilityTests.cs +++ b/Semantics.Test/Colors/AccessibilityTests.cs @@ -40,4 +40,41 @@ public void AdjustForContrast_ReachesRequestedLevel() adjusted.AccessibilityLevelAgainst(background) >= AccessibilityLevel.AA, $"contrast was {adjusted.ContrastRatio(background)}"); } + + [TestMethod] + public void AdjustForContrast_DarkensOnMidToneBackground() + { + // Luminance ~0.296: lightening cannot reach AA here, darkening can. + Color background = Color.FromSrgb(0.58, 0.58, 0.58); + Color faint = Color.FromSrgb(0.55, 0.55, 0.55); + Color adjusted = faint.AdjustForContrast(background, AccessibilityLevel.AA); + Assert.IsTrue( + adjusted.AccessibilityLevelAgainst(background) >= AccessibilityLevel.AA, + $"contrast was {adjusted.ContrastRatio(background)}"); + Assert.IsTrue(adjusted.RelativeLuminance < background.RelativeLuminance); + } + + [TestMethod] + public void AdjustForContrast_ReachesLargeTextAAOnUpperMidToneBackground() + { + // Luminance ~0.45: white tops out below 3:1, black clears it comfortably. + Color background = Color.FromSrgb(0.7, 0.7, 0.7); + Color faint = Color.FromSrgb(0.75, 0.75, 0.75); + Color adjusted = faint.AdjustForContrast(background, AccessibilityLevel.AA, largeText: true); + Assert.IsTrue( + adjusted.AccessibilityLevelAgainst(background, largeText: true) >= AccessibilityLevel.AA, + $"contrast was {adjusted.ContrastRatio(background)}"); + } + + [TestMethod] + public void AdjustForContrast_LightensOnDarkBackground() + { + Color background = Color.FromSrgb(0.1, 0.1, 0.1); + Color faint = Color.FromSrgb(0.2, 0.2, 0.2); + Color adjusted = faint.AdjustForContrast(background, AccessibilityLevel.AA); + Assert.IsTrue( + adjusted.AccessibilityLevelAgainst(background) >= AccessibilityLevel.AA, + $"contrast was {adjusted.ContrastRatio(background)}"); + Assert.IsTrue(adjusted.RelativeLuminance > background.RelativeLuminance); + } }