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
58 changes: 35 additions & 23 deletions Semantics.Color/Color.Operations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
37 changes: 37 additions & 0 deletions Semantics.Test/Colors/AccessibilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,41 @@
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);

Check warning on line 54 in Semantics.Test/Colors/AccessibilityTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsLessThan' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Semantics&issues=AaDlqOdulvjeCtXqBzFu&open=AaDlqOdulvjeCtXqBzFu&pullRequest=292
}

[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);

Check warning on line 78 in Semantics.Test/Colors/AccessibilityTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsGreaterThan' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Semantics&issues=AaDlqOdulvjeCtXqBzFv&open=AaDlqOdulvjeCtXqBzFv&pullRequest=292
}
}
Loading