From 5b4a06d71c283a8348576fdab971ef3ada8a18e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:24:37 +0000 Subject: [PATCH] Let AdjustForContrast darken on mid-tone backgrounds [patch] AdjustForContrast chose its direction with a 0.5 luminance threshold, but white and black give equal contrast near luminance 0.179. On any background between those two it searched only the lightening direction, which cannot reach AA, and returned the failing colour unchanged. Search first toward the extreme with more contrast headroom, and fall back to the other direction before giving up. Fixes ktsu-dev/Semantics#279 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PKmokmgnpkeYgp439Qbthh --- Semantics.Color/Color.Operations.cs | 58 +++++++++++++-------- Semantics.Test/Colors/AccessibilityTests.cs | 37 +++++++++++++ 2 files changed, 72 insertions(+), 23 deletions(-) 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); + } }