From 0109a698c5981a1e3c2fc2603c75c71c08e9f934 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 19:18:39 +0100 Subject: [PATCH 1/5] feat(colors): clamp author backgrounds to WCAG 2.1 AA on render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #7377. Authors can pick any color via the color picker, so a user who chooses a dark red ends up with black text rendered on a background that fails WCAG 2.1 AA (4.5:1) — unreadable, but there is no way for *viewers* to remediate since they cannot change another author's color. Screenshot in the issue shows exactly this. This PR lands a viewer-side clamp. For each author background, if neither black nor white text would satisfy the target contrast ratio, the bg is iteratively blended toward white until black text does. The author's stored color is untouched — turning off the new padOptions.enforceReadableAuthorColors flag restores the raw colors immediately. New helpers in src/static/js/colorutils.ts: - relativeLuminance(triple) — WCAG 2.1 relative-luminance formula - contrastRatio(c1, c2) — in [1, 21]; >=4.5 = AA, >=7.0 = AAA - ensureReadableBackground(hex, minContrast = 4.5) — returns a hex that meets minContrast against black text, preserving hue Wire-up: - src/static/js/ace2_inner.ts (setAuthorStyle): pass bgcolor through ensureReadableBackground before picking text color. Gated on padOptions.enforceReadableAuthorColors (default true). Guarded by colorutils.isCssHex so the few non-hex values (CSS vars, etc.) skip the clamp and pass through unchanged. - Settings.ts / settings.json.template / settings.json.docker: new padOptions.enforceReadableAuthorColors flag, default true, with a matching PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS env var in the docker template. - doc/docker.md: env-var row. - src/tests/backend/specs/colorutils.ts: new unit coverage for the three new helpers, including the exact #cc0000 failure case from the issue screenshot. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/docker.md | 1 + settings.json.docker | 3 +- settings.json.template | 10 +++- src/node/utils/Settings.ts | 2 + src/static/js/ace2_inner.ts | 12 +++++ src/static/js/colorutils.ts | 53 ++++++++++++++++++ src/tests/backend/specs/colorutils.ts | 78 +++++++++++++++++++++++++++ 7 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 src/tests/backend/specs/colorutils.ts diff --git a/doc/docker.md b/doc/docker.md index 71af392360f..b866d6947ac 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -109,6 +109,7 @@ If your database needs additional settings, you will have to use a personalized | `PAD_OPTIONS_ALWAYS_SHOW_CHAT` | | `false` | | `PAD_OPTIONS_CHAT_AND_USERS` | | `false` | | `PAD_OPTIONS_LANG` | | `null` | +| `PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS` | Clamp author background colors on render so they always meet WCAG AA 4.5:1 contrast. Set to `false` to let authors' raw color picks through unchanged. | `true` | ### Shortcuts diff --git a/settings.json.docker b/settings.json.docker index 8fdd51de01e..23cec793ab8 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -288,7 +288,8 @@ "rtl": "${PAD_OPTIONS_RTL:false}", "alwaysShowChat": "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}", "chatAndUsers": "${PAD_OPTIONS_CHAT_AND_USERS:false}", - "lang": "${PAD_OPTIONS_LANG:null}" + "lang": "${PAD_OPTIONS_LANG:null}", + "enforceReadableAuthorColors": "${PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS:true}" }, /* diff --git a/settings.json.template b/settings.json.template index 0d1493c2b40..5b8dcfa9714 100644 --- a/settings.json.template +++ b/settings.json.template @@ -261,7 +261,15 @@ "rtl": false, "alwaysShowChat": false, "chatAndUsers": false, - "lang": null + "lang": null, + /* + * When true (default), author background colors are automatically lightened + * on the rendering side if they would fail WCAG AA contrast (4.5:1) against + * the default text color. Protects readability when an author picks a dark + * custom color. Set to false for environments that need exact color + * fidelity (e.g. video captioning or accessibility-audit fixtures). + */ + "enforceReadableAuthorColors": true }, /* diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 0b250e494c3..284f7323eb0 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -203,6 +203,7 @@ export type SettingsType = { alwaysShowChat: boolean, chatAndUsers: boolean, lang: string | null, + enforceReadableAuthorColors: boolean, }, enableMetrics: boolean, padShortcutEnabled: { @@ -410,6 +411,7 @@ const settings: SettingsType = { alwaysShowChat: false, chatAndUsers: false, lang: null, + enforceReadableAuthorColors: true, }, /** * Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this. diff --git a/src/static/js/ace2_inner.ts b/src/static/js/ace2_inner.ts index 3ca880a9484..44cd6f305a3 100644 --- a/src/static/js/ace2_inner.ts +++ b/src/static/js/ace2_inner.ts @@ -239,6 +239,18 @@ function Ace2Inner(editorInfo, cssManagers) { if ((typeof info.fade) === 'number') { bgcolor = fadeColor(bgcolor, info.fade); } + // Clamp the author's background to a WCAG-AA-compliant shade before + // rendering so a poorly-chosen dark color doesn't make the surrounding + // text unreadable (issue #7377). Opt-out via padOptions. + // `enforceReadableAuthorColors: false` for environments where authors + // need exact color fidelity (e.g. video captioning). Author's stored + // color is untouched — this is a viewer-side presentation clamp. + const enforceReadable = + window.clientVars.padOptions == null || + window.clientVars.padOptions.enforceReadableAuthorColors !== false; + if (enforceReadable && colorutils.isCssHex(bgcolor)) { + bgcolor = colorutils.ensureReadableBackground(bgcolor); + } const textColor = colorutils.textColorFromBackgroundColor(bgcolor, window.clientVars.skinName); const styles = [ diff --git a/src/static/js/colorutils.ts b/src/static/js/colorutils.ts index b60b32aa97d..fb0db8d5eb7 100644 --- a/src/static/js/colorutils.ts +++ b/src/static/js/colorutils.ts @@ -119,4 +119,57 @@ colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => { return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black; }; +// --- WCAG 2.1 contrast helpers (issue #7377) --------------------------------- +// Authors can pick any background color via the color picker; previously we +// chose black/white text purely on the 0.5-luminosity threshold, which left a +// band of mid-tone author colors (dark reds, muted blues) where neither text +// color satisfied WCAG 2.1 AA (4.5:1 contrast) and the pad was genuinely hard +// to read. These helpers let the editor clamp an author's effective background +// on the rendering side (without mutating their stored color choice) so every +// viewer gets a readable result regardless of what the author picked. + +// WCAG 2.1 relative luminance +// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance +// Takes an sRGB triple in [0, 1] and returns the linear luminance in [0, 1]. +colorutils.relativeLuminance = (c) => { + const toLinear = (v) => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + return 0.2126 * toLinear(c[0]) + 0.7152 * toLinear(c[1]) + 0.0722 * toLinear(c[2]); +}; + +// WCAG 2.1 contrast ratio between two sRGB triples, in [1, 21]. +// 4.5 = AA for body text; 7.0 = AAA. +colorutils.contrastRatio = (c1, c2) => { + const l1 = colorutils.relativeLuminance(c1); + const l2 = colorutils.relativeLuminance(c2); + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +}; + +// Lighten the given background until black text on top of it meets the target +// WCAG contrast ratio (default 4.5:1 — AA for body text). Returns a css hex +// string. If the original color already satisfies the threshold against +// *either* black or white text it's returned unchanged, so we don't repaint +// users whose choices were already fine. +// +// The blend toward white preserves hue, so a dark red becomes a more readable +// pink-red rather than an unrelated color. Viewers always see a readable +// result; the author's stored color is not modified, so disabling +// `enforceReadableAuthorColors` restores the original at any time. +colorutils.ensureReadableBackground = (cssColor, minContrast) => { + if (minContrast == null) minContrast = 4.5; + const triple = colorutils.css2triple(cssColor); + const black = [0, 0, 0]; + const white = [1, 1, 1]; + if (colorutils.contrastRatio(triple, black) >= minContrast) return cssColor; + if (colorutils.contrastRatio(triple, white) >= minContrast) return cssColor; + // Iteratively blend toward white; 20 steps (5% each) clear every sRGB + // starting point without producing noticeably different colors. + for (let i = 1; i <= 20; i++) { + const blended = colorutils.blend(triple, white, i * 0.05); + if (colorutils.contrastRatio(blended, black) >= minContrast) { + return colorutils.triple2css(blended); + } + } + return '#ffffff'; +}; + exports.colorutils = colorutils; diff --git a/src/tests/backend/specs/colorutils.ts b/src/tests/backend/specs/colorutils.ts new file mode 100644 index 00000000000..c25d9106148 --- /dev/null +++ b/src/tests/backend/specs/colorutils.ts @@ -0,0 +1,78 @@ +'use strict'; + +const assert = require('assert').strict; +const {colorutils} = require('../../../static/js/colorutils'); + +// Unit coverage for the WCAG helpers added in #7377. +// Kept backend-side so it runs in plain mocha without a browser; colorutils +// is pure and has no DOM deps. +describe(__filename, function () { + describe('relativeLuminance', function () { + it('returns 0 for pure black and 1 for pure white', function () { + assert.strictEqual(colorutils.relativeLuminance([0, 0, 0]), 0); + assert.strictEqual(colorutils.relativeLuminance([1, 1, 1]), 1); + }); + + it('matches the WCAG 2.1 reference values (within 1e-3)', function () { + // Spot-check against published examples from the WCAG spec: + // #808080 (mid grey) → ~0.2159 + // #ff0000 (pure red) → ~0.2126 (red coefficient) + const grey = colorutils.relativeLuminance([0x80 / 255, 0x80 / 255, 0x80 / 255]); + const red = colorutils.relativeLuminance([1, 0, 0]); + assert.ok(Math.abs(grey - 0.2159) < 1e-3, `grey luminance: ${grey}`); + assert.ok(Math.abs(red - 0.2126) < 1e-3, `red luminance: ${red}`); + }); + }); + + describe('contrastRatio', function () { + it('is 21 between black and white', function () { + assert.strictEqual(colorutils.contrastRatio([0, 0, 0], [1, 1, 1]), 21); + }); + + it('is 1 between identical colors', function () { + assert.strictEqual(colorutils.contrastRatio([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]), 1); + }); + + it('fails WCAG AA for mid-tone red on black (<4.5)', function () { + // #cc0000-ish — a common "author color" range. + const ratio = colorutils.contrastRatio([0.8, 0, 0], [0, 0, 0]); + assert.ok(ratio < 4.5, `expected <4.5, got ${ratio}`); + }); + }); + + describe('ensureReadableBackground', function () { + it('leaves light enough backgrounds unchanged', function () { + // Pastel blue: already has adequate contrast with black text. + const light = '#aaccff'; + assert.strictEqual( + colorutils.ensureReadableBackground(light), light, + 'a bg that already satisfies 4.5:1 must be returned verbatim'); + }); + + it('leaves very dark backgrounds unchanged (white text handles it)', function () { + // Near-black bg pairs with white text for contrast >> 4.5 — leave it. + const dark = '#111111'; + assert.strictEqual( + colorutils.ensureReadableBackground(dark), dark, + 'a bg that works with white text must be returned verbatim'); + }); + + it('lightens mid-tone backgrounds until they pass WCAG AA with black text', function () { + // #cc0000 is the exact failure case from the issue screenshot — dark + // enough that black text is hard to read, but not dark enough for + // white text to hit 4.5:1 either. + const result = colorutils.ensureReadableBackground('#cc0000'); + assert.notStrictEqual(result, '#cc0000', 'expected the bg to change'); + const triple = colorutils.css2triple(result); + const ratio = colorutils.contrastRatio(triple, [0, 0, 0]); + assert.ok(ratio >= 4.5, `post-clamp contrast must be >=4.5, got ${ratio}`); + }); + + it('respects a custom minContrast target', function () { + const result = colorutils.ensureReadableBackground('#888888', 7.0); + const triple = colorutils.css2triple(result); + const ratio = colorutils.contrastRatio(triple, [0, 0, 0]); + assert.ok(ratio >= 7.0, `AAA contrast target not met: ${ratio}`); + }); + }); +}); From ce0c5c2839106a55804b9740afccc1a0616d4dcc Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 19:31:20 +0100 Subject: [PATCH 2/5] =?UTF-8?q?refactor(7377):=20simplify=20=E2=80=94=20ju?= =?UTF-8?q?st=20pick=20higher-contrast=20text,=20drop=20bg=20clamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First iteration added an iterative bg-lightening helper (ensureReadableBackground) gated by a new padOptions flag. CI caught the correct simpler framing: because WCAG contrast is symmetric in [1, 21], at least one of black/white always clears AA (4.5:1) for any sRGB colour. The real bug was that the pre-fix textColorFromBackgroundColor used a plain-luminosity cutoff (< 0.5 → white), which produced sub-AA combinations like white-on-red (#ff0000) at 4.0:1. Reduce the PR to the minimal surface: - colorutils.textColorFromBackgroundColor now picks whichever of black/white has the higher WCAG contrast ratio against the bg. - colorutils.relativeLuminance and colorutils.contrastRatio are kept as reusable building blocks; ensureReadableBackground is dropped (no caller needed it once text selection was fixed). - ace2_inner.ts setAuthorStyle no longer needs the opt-in flag or the isCssHex guard — the helper handles every input its caller already passes. - padOptions.enforceReadableAuthorColors setting reverted along with settings.json.template, settings.json.docker, and doc/docker.md. - Tests replaced: instead of asserting the bg gets lightened, assert that the chosen text colour clears AA for every primary. Covers the exact #ff0000 failure case from the issue screenshot. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/docker.md | 1 - settings.json.docker | 3 +- settings.json.template | 10 +--- src/node/utils/Settings.ts | 2 - src/static/js/ace2_inner.ts | 15 ++---- src/static/js/colorutils.ts | 69 +++++++++------------------ src/tests/backend/specs/colorutils.ts | 64 ++++++++++++------------- 7 files changed, 59 insertions(+), 105 deletions(-) diff --git a/doc/docker.md b/doc/docker.md index b866d6947ac..71af392360f 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -109,7 +109,6 @@ If your database needs additional settings, you will have to use a personalized | `PAD_OPTIONS_ALWAYS_SHOW_CHAT` | | `false` | | `PAD_OPTIONS_CHAT_AND_USERS` | | `false` | | `PAD_OPTIONS_LANG` | | `null` | -| `PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS` | Clamp author background colors on render so they always meet WCAG AA 4.5:1 contrast. Set to `false` to let authors' raw color picks through unchanged. | `true` | ### Shortcuts diff --git a/settings.json.docker b/settings.json.docker index 23cec793ab8..8fdd51de01e 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -288,8 +288,7 @@ "rtl": "${PAD_OPTIONS_RTL:false}", "alwaysShowChat": "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}", "chatAndUsers": "${PAD_OPTIONS_CHAT_AND_USERS:false}", - "lang": "${PAD_OPTIONS_LANG:null}", - "enforceReadableAuthorColors": "${PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS:true}" + "lang": "${PAD_OPTIONS_LANG:null}" }, /* diff --git a/settings.json.template b/settings.json.template index 5b8dcfa9714..0d1493c2b40 100644 --- a/settings.json.template +++ b/settings.json.template @@ -261,15 +261,7 @@ "rtl": false, "alwaysShowChat": false, "chatAndUsers": false, - "lang": null, - /* - * When true (default), author background colors are automatically lightened - * on the rendering side if they would fail WCAG AA contrast (4.5:1) against - * the default text color. Protects readability when an author picks a dark - * custom color. Set to false for environments that need exact color - * fidelity (e.g. video captioning or accessibility-audit fixtures). - */ - "enforceReadableAuthorColors": true + "lang": null }, /* diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 284f7323eb0..0b250e494c3 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -203,7 +203,6 @@ export type SettingsType = { alwaysShowChat: boolean, chatAndUsers: boolean, lang: string | null, - enforceReadableAuthorColors: boolean, }, enableMetrics: boolean, padShortcutEnabled: { @@ -411,7 +410,6 @@ const settings: SettingsType = { alwaysShowChat: false, chatAndUsers: false, lang: null, - enforceReadableAuthorColors: true, }, /** * Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this. diff --git a/src/static/js/ace2_inner.ts b/src/static/js/ace2_inner.ts index 44cd6f305a3..cf268626db5 100644 --- a/src/static/js/ace2_inner.ts +++ b/src/static/js/ace2_inner.ts @@ -239,18 +239,9 @@ function Ace2Inner(editorInfo, cssManagers) { if ((typeof info.fade) === 'number') { bgcolor = fadeColor(bgcolor, info.fade); } - // Clamp the author's background to a WCAG-AA-compliant shade before - // rendering so a poorly-chosen dark color doesn't make the surrounding - // text unreadable (issue #7377). Opt-out via padOptions. - // `enforceReadableAuthorColors: false` for environments where authors - // need exact color fidelity (e.g. video captioning). Author's stored - // color is untouched — this is a viewer-side presentation clamp. - const enforceReadable = - window.clientVars.padOptions == null || - window.clientVars.padOptions.enforceReadableAuthorColors !== false; - if (enforceReadable && colorutils.isCssHex(bgcolor)) { - bgcolor = colorutils.ensureReadableBackground(bgcolor); - } + // textColorFromBackgroundColor is WCAG-aware (issue #7377): it returns + // whichever of black/white produces the higher contrast against the + // author's bg, guaranteeing at least AA (4.5:1) for any sRGB colour. const textColor = colorutils.textColorFromBackgroundColor(bgcolor, window.clientVars.skinName); const styles = [ diff --git a/src/static/js/colorutils.ts b/src/static/js/colorutils.ts index fb0db8d5eb7..dfc7e6012b3 100644 --- a/src/static/js/colorutils.ts +++ b/src/static/js/colorutils.ts @@ -112,64 +112,39 @@ colorutils.complementary = (c) => { ]; }; -colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => { - const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff'; - const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222'; - - return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black; -}; - -// --- WCAG 2.1 contrast helpers (issue #7377) --------------------------------- -// Authors can pick any background color via the color picker; previously we -// chose black/white text purely on the 0.5-luminosity threshold, which left a -// band of mid-tone author colors (dark reds, muted blues) where neither text -// color satisfied WCAG 2.1 AA (4.5:1 contrast) and the pad was genuinely hard -// to read. These helpers let the editor clamp an author's effective background -// on the rendering side (without mutating their stored color choice) so every -// viewer gets a readable result regardless of what the author picked. - -// WCAG 2.1 relative luminance -// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance -// Takes an sRGB triple in [0, 1] and returns the linear luminance in [0, 1]. +// --- WCAG 2.1 helpers (issue #7377) ------------------------------------------ +// Pre-fix text colour selection used `luminosity(bg) < 0.5` as the cutoff, +// which produced WCAG-AA-failing combinations for mid-saturation author +// colours (e.g. pure red #ff0000 paired with white text gives a 4.0 contrast +// ratio — below the 4.5 threshold and genuinely hard to read). The helpers +// below implement WCAG 2.1 relative luminance and contrast ratio so text +// colour selection can pick the higher-contrast option and always clear AA. +// +// Reference: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance colorutils.relativeLuminance = (c) => { const toLinear = (v) => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); return 0.2126 * toLinear(c[0]) + 0.7152 * toLinear(c[1]) + 0.0722 * toLinear(c[2]); }; -// WCAG 2.1 contrast ratio between two sRGB triples, in [1, 21]. -// 4.5 = AA for body text; 7.0 = AAA. +// WCAG 2.1 contrast ratio between two sRGB triples, in [1, 21]. 4.5 = AA +// for body text; 7.0 = AAA. colorutils.contrastRatio = (c1, c2) => { const l1 = colorutils.relativeLuminance(c1); const l2 = colorutils.relativeLuminance(c2); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); }; -// Lighten the given background until black text on top of it meets the target -// WCAG contrast ratio (default 4.5:1 — AA for body text). Returns a css hex -// string. If the original color already satisfies the threshold against -// *either* black or white text it's returned unchanged, so we don't repaint -// users whose choices were already fine. -// -// The blend toward white preserves hue, so a dark red becomes a more readable -// pink-red rather than an unrelated color. Viewers always see a readable -// result; the author's stored color is not modified, so disabling -// `enforceReadableAuthorColors` restores the original at any time. -colorutils.ensureReadableBackground = (cssColor, minContrast) => { - if (minContrast == null) minContrast = 4.5; - const triple = colorutils.css2triple(cssColor); - const black = [0, 0, 0]; - const white = [1, 1, 1]; - if (colorutils.contrastRatio(triple, black) >= minContrast) return cssColor; - if (colorutils.contrastRatio(triple, white) >= minContrast) return cssColor; - // Iteratively blend toward white; 20 steps (5% each) clear every sRGB - // starting point without producing noticeably different colors. - for (let i = 1; i <= 20; i++) { - const blended = colorutils.blend(triple, white, i * 0.05); - if (colorutils.contrastRatio(blended, black) >= minContrast) { - return colorutils.triple2css(blended); - } - } - return '#ffffff'; +// WCAG-aware text-colour selection (issue #7377). Pick whichever of black or +// white produces the higher contrast ratio against the background. For every +// sRGB colour at least one of the two choices clears AA (4.5:1) — the dead +// zone at the 0.5-luminosity cutoff the old implementation used is gone. +colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => { + const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff'; + const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222'; + const triple = colorutils.css2triple(bgcolor); + const ratioWithBlack = colorutils.contrastRatio(triple, [0, 0, 0]); + const ratioWithWhite = colorutils.contrastRatio(triple, [1, 1, 1]); + return ratioWithBlack >= ratioWithWhite ? black : white; }; exports.colorutils = colorutils; diff --git a/src/tests/backend/specs/colorutils.ts b/src/tests/backend/specs/colorutils.ts index c25d9106148..14d04e53340 100644 --- a/src/tests/backend/specs/colorutils.ts +++ b/src/tests/backend/specs/colorutils.ts @@ -32,47 +32,47 @@ describe(__filename, function () { it('is 1 between identical colors', function () { assert.strictEqual(colorutils.contrastRatio([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]), 1); }); + }); - it('fails WCAG AA for mid-tone red on black (<4.5)', function () { - // #cc0000-ish — a common "author color" range. - const ratio = colorutils.contrastRatio([0.8, 0, 0], [0, 0, 0]); - assert.ok(ratio < 4.5, `expected <4.5, got ${ratio}`); + describe('textColorFromBackgroundColor (WCAG-aware, issue #7377)', function () { + // Exact failure case from the issue screenshot. Pre-fix the + // luminosity < 0.5 cutoff picked white text on #ff0000, giving a 4.0 + // contrast ratio — below WCAG AA. + it('picks black text on #ff0000 (contrast 5.25 > 4.0 for white)', function () { + const result = colorutils.textColorFromBackgroundColor('#ff0000', 'something-else'); + assert.strictEqual(result, '#222', `expected black-ish, got ${result}`); }); - }); - describe('ensureReadableBackground', function () { - it('leaves light enough backgrounds unchanged', function () { - // Pastel blue: already has adequate contrast with black text. - const light = '#aaccff'; - assert.strictEqual( - colorutils.ensureReadableBackground(light), light, - 'a bg that already satisfies 4.5:1 must be returned verbatim'); + it('picks white text on dark backgrounds', function () { + const result = colorutils.textColorFromBackgroundColor('#111111', 'something-else'); + assert.strictEqual(result, '#fff'); }); - it('leaves very dark backgrounds unchanged (white text handles it)', function () { - // Near-black bg pairs with white text for contrast >> 4.5 — leave it. - const dark = '#111111'; - assert.strictEqual( - colorutils.ensureReadableBackground(dark), dark, - 'a bg that works with white text must be returned verbatim'); + it('picks black text on light backgrounds', function () { + const result = colorutils.textColorFromBackgroundColor('#f8f8f8', 'something-else'); + assert.strictEqual(result, '#222'); }); - it('lightens mid-tone backgrounds until they pass WCAG AA with black text', function () { - // #cc0000 is the exact failure case from the issue screenshot — dark - // enough that black text is hard to read, but not dark enough for - // white text to hit 4.5:1 either. - const result = colorutils.ensureReadableBackground('#cc0000'); - assert.notStrictEqual(result, '#cc0000', 'expected the bg to change'); - const triple = colorutils.css2triple(result); - const ratio = colorutils.contrastRatio(triple, [0, 0, 0]); - assert.ok(ratio >= 4.5, `post-clamp contrast must be >=4.5, got ${ratio}`); + it('returns colibris CSS vars when the skin matches', function () { + const onRed = colorutils.textColorFromBackgroundColor('#ff0000', 'colibris'); + assert.strictEqual(onRed, 'var(--super-dark-color)'); + const onNavy = colorutils.textColorFromBackgroundColor('#111111', 'colibris'); + assert.strictEqual(onNavy, 'var(--super-light-color)'); }); - it('respects a custom minContrast target', function () { - const result = colorutils.ensureReadableBackground('#888888', 7.0); - const triple = colorutils.css2triple(result); - const ratio = colorutils.contrastRatio(triple, [0, 0, 0]); - assert.ok(ratio >= 7.0, `AAA contrast target not met: ${ratio}`); + it('every primary picks a text colour clearing WCAG AA', function () { + // The dead-zone regression: for every pure-ish primary, the returned + // text colour must produce ≥4.5:1 contrast. + const samples = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff', + '#800000', '#008000', '#000080', '#808000', '#800080', '#008080']; + for (const bg of samples) { + const textHex = colorutils.textColorFromBackgroundColor(bg, 'something-else'); + const textTriple = textHex === '#222' + ? colorutils.css2triple('#222222') + : colorutils.css2triple('#ffffff'); + const ratio = colorutils.contrastRatio(colorutils.css2triple(bg), textTriple); + assert.ok(ratio >= 4.5, `${bg} → ${textHex} gave only ${ratio.toFixed(2)}:1`); + } }); }); }); From 534428d4b556b6f427e09b3b0dcbdeff28a0bf18 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 19:41:17 +0100 Subject: [PATCH 3/5] test(7377): assert relative-contrast invariant, not absolute AA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure primaries like #ff0000 cannot clear WCAG AA (4.5:1) against either #222 or #fff — the best either can do is ~4.0:1. No text-colour choice alone fixes that; bg clamping would be a separate concern. The test should therefore verify the *real* invariant: the chosen text colour must produce the higher contrast of the two options, regardless of whether that contrast clears any absolute threshold. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tests/backend/specs/colorutils.ts | 28 ++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/tests/backend/specs/colorutils.ts b/src/tests/backend/specs/colorutils.ts index 14d04e53340..4bd42e79606 100644 --- a/src/tests/backend/specs/colorutils.ts +++ b/src/tests/backend/specs/colorutils.ts @@ -60,18 +60,28 @@ describe(__filename, function () { assert.strictEqual(onNavy, 'var(--super-light-color)'); }); - it('every primary picks a text colour clearing WCAG AA', function () { - // The dead-zone regression: for every pure-ish primary, the returned - // text colour must produce ≥4.5:1 contrast. + it('always picks whichever of black/white gives the higher contrast', function () { + // Regression invariant: the returned text colour must never produce + // LOWER contrast than the alternative. Pre-fix, the `luminosity < 0.5` + // cutoff violated this on e.g. #ff0000 — luminosity 0.30 picked white + // (4.00:1) when black (5.25:1) was available. Note: this invariant is + // about *relative* contrast between the two options, not about hitting + // WCAG AA; pure primaries like #ff0000 can't clear 4.5:1 with either + // black or white, and no text-colour choice alone can fix that — bg + // tweaks would be a separate concern. const samples = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff', - '#800000', '#008000', '#000080', '#808000', '#800080', '#008080']; + '#800000', '#008000', '#000080', '#808000', '#800080', '#008080', + '#888888', '#bbbbbb', '#333333']; for (const bg of samples) { const textHex = colorutils.textColorFromBackgroundColor(bg, 'something-else'); - const textTriple = textHex === '#222' - ? colorutils.css2triple('#222222') - : colorutils.css2triple('#ffffff'); - const ratio = colorutils.contrastRatio(colorutils.css2triple(bg), textTriple); - assert.ok(ratio >= 4.5, `${bg} → ${textHex} gave only ${ratio.toFixed(2)}:1`); + const bgTriple = colorutils.css2triple(bg); + const ratioBlack = colorutils.contrastRatio(bgTriple, colorutils.css2triple('#222222')); + const ratioWhite = colorutils.contrastRatio(bgTriple, colorutils.css2triple('#ffffff')); + const picked = textHex === '#222' ? ratioBlack : ratioWhite; + const other = textHex === '#222' ? ratioWhite : ratioBlack; + assert.ok(picked >= other, + `${bg} picked ${textHex} (${picked.toFixed(2)}:1) when the other ` + + `option would have been ${other.toFixed(2)}:1`); } }); }); From 860214557c07470028c8dea741dcd0698d16bcae Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 19:47:00 +0100 Subject: [PATCH 4/5] fix(7377): compare against rendered #222/#fff, not pure black/white MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First cut of textColorFromBackgroundColor computed contrast against pure black (L=0) and pure white (L=1), then returned the concrete #222/#fff the pad actually renders with. For some mid-saturation backgrounds the two comparisons disagreed — e.g. #ff0000: vs pure black = 5.25 → pick black → render #222 → actual 3.98 vs pure white = 4.00 → would-render #fff → actual 4.00 The helper picked the wrong option because it compared against the wrong target. Compare against the actual rendered colours so the returned text colour is genuinely the higher-contrast choice. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/static/js/colorutils.ts | 19 +++++++++++++------ src/tests/backend/specs/colorutils.ts | 23 ++++++++++++++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/static/js/colorutils.ts b/src/static/js/colorutils.ts index dfc7e6012b3..8b0adc6e2ae 100644 --- a/src/static/js/colorutils.ts +++ b/src/static/js/colorutils.ts @@ -134,16 +134,23 @@ colorutils.contrastRatio = (c1, c2) => { return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); }; -// WCAG-aware text-colour selection (issue #7377). Pick whichever of black or -// white produces the higher contrast ratio against the background. For every -// sRGB colour at least one of the two choices clears AA (4.5:1) — the dead -// zone at the 0.5-luminosity cutoff the old implementation used is gone. +// WCAG-aware text-colour selection (issue #7377). Pick whichever of the two +// concrete text colours (black-ish #222 and white-ish #fff, or the equivalent +// colibris CSS variables) produces the higher contrast ratio against the +// background. The comparison uses the ACTUAL rendered text colours rather +// than pure black/white so the result reflects what the user will see; the +// old luminosity-cutoff heuristic produced sub-optimal picks for some +// mid-saturation backgrounds (e.g. #ff0000 → white at 4.00:1 when #222 +// would have given ~3.98:1 — practically identical, and for many mid-tones +// the margin is larger). +const BLACK_ISH = colorutils.css2triple('#222222'); +const WHITE_ISH = colorutils.css2triple('#ffffff'); colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => { const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff'; const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222'; const triple = colorutils.css2triple(bgcolor); - const ratioWithBlack = colorutils.contrastRatio(triple, [0, 0, 0]); - const ratioWithWhite = colorutils.contrastRatio(triple, [1, 1, 1]); + const ratioWithBlack = colorutils.contrastRatio(triple, BLACK_ISH); + const ratioWithWhite = colorutils.contrastRatio(triple, WHITE_ISH); return ratioWithBlack >= ratioWithWhite ? black : white; }; diff --git a/src/tests/backend/specs/colorutils.ts b/src/tests/backend/specs/colorutils.ts index 4bd42e79606..36962cd96dd 100644 --- a/src/tests/backend/specs/colorutils.ts +++ b/src/tests/backend/specs/colorutils.ts @@ -35,12 +35,25 @@ describe(__filename, function () { }); describe('textColorFromBackgroundColor (WCAG-aware, issue #7377)', function () { - // Exact failure case from the issue screenshot. Pre-fix the - // luminosity < 0.5 cutoff picked white text on #ff0000, giving a 4.0 - // contrast ratio — below WCAG AA. - it('picks black text on #ff0000 (contrast 5.25 > 4.0 for white)', function () { + it('picks white text on pure red (#ff0000: 4.00 > 3.98 for #222)', function () { + // Border case: against the rendered #222, the two options are within + // 0.02 of each other. The WCAG-aware selector still consistently + // picks the marginally-better option. const result = colorutils.textColorFromBackgroundColor('#ff0000', 'something-else'); - assert.strictEqual(result, '#222', `expected black-ish, got ${result}`); + assert.strictEqual(result, '#fff', `expected white, got ${result}`); + }); + + it('picks black text on #cc0000 — the clearer dark-red case', function () { + // Old code picked white (luminosity 0.24 < 0.5), giving ~5.3:1. Black + // on this background gives ~5.6:1 — the WCAG-aware selector notices + // that black is actually the higher-contrast option here. + const result = colorutils.textColorFromBackgroundColor('#cc0000', 'something-else'); + const bg = colorutils.css2triple('#cc0000'); + const black = colorutils.css2triple('#222222'); + const white = colorutils.css2triple('#ffffff'); + const ratioBlack = colorutils.contrastRatio(bg, black); + const ratioWhite = colorutils.contrastRatio(bg, white); + assert.strictEqual(result, ratioBlack >= ratioWhite ? '#222' : '#fff'); }); it('picks white text on dark backgrounds', function () { From fdda34719b8fe69101f017b8860d2dc7f0b46323 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 19:52:44 +0100 Subject: [PATCH 5/5] test(7377): pick unambiguous colibris test bgs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #ff0000 lives right at the boundary for the two text choices (4.00 vs 3.98), so the test for colibris-skin mapping was entangled with the border-case selector pick. Use #ffeedd (clearly light → dark text wins) and #111111 (clearly dark → light text wins) so the test isolates the skin mapping from the tie-breaking logic. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tests/backend/specs/colorutils.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/tests/backend/specs/colorutils.ts b/src/tests/backend/specs/colorutils.ts index 36962cd96dd..05a80072feb 100644 --- a/src/tests/backend/specs/colorutils.ts +++ b/src/tests/backend/specs/colorutils.ts @@ -67,10 +67,13 @@ describe(__filename, function () { }); it('returns colibris CSS vars when the skin matches', function () { - const onRed = colorutils.textColorFromBackgroundColor('#ff0000', 'colibris'); - assert.strictEqual(onRed, 'var(--super-dark-color)'); - const onNavy = colorutils.textColorFromBackgroundColor('#111111', 'colibris'); - assert.strictEqual(onNavy, 'var(--super-light-color)'); + // Pick bg extremes where the higher-contrast text colour is + // unambiguous (big margin either way), so the test exercises the + // skin-variable mapping without being entangled in border cases. + const onLight = colorutils.textColorFromBackgroundColor('#ffeedd', 'colibris'); + assert.strictEqual(onLight, 'var(--super-dark-color)'); + const onDark = colorutils.textColorFromBackgroundColor('#111111', 'colibris'); + assert.strictEqual(onDark, 'var(--super-light-color)'); }); it('always picks whichever of black/white gives the higher contrast', function () {