Studio: pick the accent foreground by contrast, not a luminance cutoff
readableForeground returned white for anything under 0.45 luminance, so mid-tone accents got an unreadable label: #22c55e scored 2.28:1 on white where the dark ink scores 8.11:1. Compare the two contrast ratios and take the winner, which needs no threshold constant.
This commit is contained in:
parent
75ef0f2e23
commit
25b238cc4b
2 changed files with 67 additions and 1 deletions
|
|
@ -395,8 +395,27 @@ function hexLuminance(hex: string): number {
|
||||||
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FOREGROUND_DARK = "#111417";
|
||||||
|
const FOREGROUND_LIGHT = "#ffffff";
|
||||||
|
|
||||||
|
/** WCAG contrast ratio between two relative luminances. */
|
||||||
|
function contrastRatio(a: number, b: number): number {
|
||||||
|
const [high, low] = a >= b ? [a, b] : [b, a];
|
||||||
|
return (high + 0.05) / (low + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whichever foreground actually contrasts more. A fixed luminance threshold
|
||||||
|
* put white on mid-tone accents: #22c55e scored 2.28:1 on white against
|
||||||
|
* 8.11:1 on the dark ink. The crossover for this pair is near 0.19, but
|
||||||
|
* comparing the ratios needs no constant at all.
|
||||||
|
*/
|
||||||
function readableForeground(hex: string): string {
|
function readableForeground(hex: string): string {
|
||||||
return hexLuminance(hex) > 0.45 ? "#111417" : "#ffffff";
|
const accent = hexLuminance(hex);
|
||||||
|
return contrastRatio(accent, hexLuminance(FOREGROUND_DARK)) >=
|
||||||
|
contrastRatio(accent, hexLuminance(FOREGROUND_LIGHT))
|
||||||
|
? FOREGROUND_DARK
|
||||||
|
: FOREGROUND_LIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,53 @@ test("both foregrounds follow the accent so button labels stay readable", () =>
|
||||||
assert.notEqual(vars.get("--primary-foreground"), onDark);
|
assert.notEqual(vars.get("--primary-foreground"), onDark);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** WCAG relative luminance, independent of the implementation under test. */
|
||||||
|
function luminance(hex: string): number {
|
||||||
|
const channel = (index: number) => {
|
||||||
|
const value = Number.parseInt(hex.slice(index, index + 2), 16) / 255;
|
||||||
|
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||||
|
};
|
||||||
|
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratio(a: string, b: string): number {
|
||||||
|
const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||||
|
return ((high ?? 0) + 0.05) / ((low ?? 0) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the foreground is the higher-contrast of the two, not a luminance guess", () => {
|
||||||
|
// Mid-tone accents are the ones a fixed 0.45 cutoff got wrong.
|
||||||
|
for (const accent of [
|
||||||
|
"#22c55e",
|
||||||
|
"#17b88b",
|
||||||
|
"#339cff",
|
||||||
|
"#f59e0b",
|
||||||
|
"#4ade80",
|
||||||
|
"#7c3aed",
|
||||||
|
"#e11d48",
|
||||||
|
"#0d0d0d",
|
||||||
|
"#fde68a",
|
||||||
|
"#ececec",
|
||||||
|
]) {
|
||||||
|
applyCustomizationToDocument(withAccent(accent), "light");
|
||||||
|
const chosen = vars.get("--primary-foreground") ?? "";
|
||||||
|
const other = chosen === "#ffffff" ? "#111417" : "#ffffff";
|
||||||
|
assert.ok(
|
||||||
|
ratio(accent, chosen) >= ratio(accent, other),
|
||||||
|
`${accent}: picked ${chosen} at ${ratio(accent, chosen).toFixed(2)}:1 over ${other} at ${ratio(accent, other).toFixed(2)}:1`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a saturated green label clears WCAG AA instead of failing it", () => {
|
||||||
|
applyCustomizationToDocument(withAccent("#22c55e"), "light");
|
||||||
|
const chosen = vars.get("--primary-foreground") ?? "";
|
||||||
|
assert.ok(
|
||||||
|
ratio("#22c55e", chosen) >= 4.5,
|
||||||
|
`#22c55e on ${chosen} is only ${ratio("#22c55e", chosen).toFixed(2)}:1`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("no accent leaves every palette variable alone", () => {
|
test("no accent leaves every palette variable alone", () => {
|
||||||
applyCustomizationToDocument(withAccent("#7c3aed"), "light");
|
applyCustomizationToDocument(withAccent("#7c3aed"), "light");
|
||||||
applyCustomizationToDocument(withAccent(null), "light");
|
applyCustomizationToDocument(withAccent(null), "light");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue