From 25b238cc4b7635a5654d7e96d06a4c3e9e6a732c Mon Sep 17 00:00:00 2001 From: Unsloth Date: Wed, 29 Jul 2026 02:29:09 -0700 Subject: [PATCH] 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. --- .../stores/appearance-custom-store.ts | 21 ++++++++- .../tests/appearance-accent-vars.test.ts | 47 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/studio/frontend/src/features/settings/stores/appearance-custom-store.ts b/studio/frontend/src/features/settings/stores/appearance-custom-store.ts index 76386406fb..e43711d751 100644 --- a/studio/frontend/src/features/settings/stores/appearance-custom-store.ts +++ b/studio/frontend/src/features/settings/stores/appearance-custom-store.ts @@ -395,8 +395,27 @@ function hexLuminance(hex: string): number { 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 { - 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; } /** diff --git a/studio/frontend/tests/appearance-accent-vars.test.ts b/studio/frontend/tests/appearance-accent-vars.test.ts index 555d76e5ca..15f049dcee 100644 --- a/studio/frontend/tests/appearance-accent-vars.test.ts +++ b/studio/frontend/tests/appearance-accent-vars.test.ts @@ -63,6 +63,53 @@ test("both foregrounds follow the accent so button labels stay readable", () => 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", () => { applyCustomizationToDocument(withAccent("#7c3aed"), "light"); applyCustomizationToDocument(withAccent(null), "light");