From 762322635ba35062c30bd24ba437af390e02aed7 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 19:18:39 +0100 Subject: [PATCH 1/7] 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 8e22ba51837..29776de6b81 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -116,6 +116,7 @@ If your database needs additional settings, you will have to use a personalized | `PAD_OPTIONS_CHAT_AND_USERS` | | `false` | | `PAD_OPTIONS_LANG` | | `null` | | `PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS` | Fade each author's caret/background toward white as they go inactive. Set to `false` on busy pads (every faded author counts as a second on-screen color, so 30 contributors visually become 60), when users pick light colors that fade into the background, or whenever inactivity tracking is undesirable. | `true` | +| `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 1f1d6d05bca..bbd6413577f 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -319,7 +319,8 @@ "alwaysShowChat": "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}", "chatAndUsers": "${PAD_OPTIONS_CHAT_AND_USERS:false}", "lang": "${PAD_OPTIONS_LANG:null}", - "fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}" + "fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}", + "enforceReadableAuthorColors": "${PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS:true}" }, /* diff --git a/settings.json.template b/settings.json.template index 4fc59819c94..acd904c15e5 100644 --- a/settings.json.template +++ b/settings.json.template @@ -308,7 +308,15 @@ * as the author goes inactive. Set to false if users pick light colors and the * faded variants become visually indistinguishable. */ - "fadeInactiveAuthorColors": true + "fadeInactiveAuthorColors": true, + /* + * 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 26876cd08cf..7cd42a81185 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -207,6 +207,7 @@ export type SettingsType = { chatAndUsers: boolean, lang: string | null, fadeInactiveAuthorColors: boolean, + enforceReadableAuthorColors: boolean, }, enableMetrics: boolean, padShortcutEnabled: { @@ -441,6 +442,7 @@ const settings: SettingsType = { chatAndUsers: false, lang: null, fadeInactiveAuthorColors: true, + 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 b0b7df0d1d3..5176b64ed27 100644 --- a/src/static/js/ace2_inner.ts +++ b/src/static/js/ace2_inner.ts @@ -247,6 +247,18 @@ function Ace2Inner(editorInfo, cssManagers) { if (fadeInactiveAuthorColors && (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 c132e33111f28b8a1da2df84fc7278f632d590dc Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 19:31:20 +0100 Subject: [PATCH 2/7] =?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 29776de6b81..8e22ba51837 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -116,7 +116,6 @@ If your database needs additional settings, you will have to use a personalized | `PAD_OPTIONS_CHAT_AND_USERS` | | `false` | | `PAD_OPTIONS_LANG` | | `null` | | `PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS` | Fade each author's caret/background toward white as they go inactive. Set to `false` on busy pads (every faded author counts as a second on-screen color, so 30 contributors visually become 60), when users pick light colors that fade into the background, or whenever inactivity tracking is undesirable. | `true` | -| `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 bbd6413577f..1f1d6d05bca 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -319,8 +319,7 @@ "alwaysShowChat": "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}", "chatAndUsers": "${PAD_OPTIONS_CHAT_AND_USERS:false}", "lang": "${PAD_OPTIONS_LANG:null}", - "fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}", - "enforceReadableAuthorColors": "${PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS:true}" + "fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}" }, /* diff --git a/settings.json.template b/settings.json.template index acd904c15e5..4fc59819c94 100644 --- a/settings.json.template +++ b/settings.json.template @@ -308,15 +308,7 @@ * as the author goes inactive. Set to false if users pick light colors and the * faded variants become visually indistinguishable. */ - "fadeInactiveAuthorColors": true, - /* - * 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 + "fadeInactiveAuthorColors": true }, /* diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 7cd42a81185..26876cd08cf 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -207,7 +207,6 @@ export type SettingsType = { chatAndUsers: boolean, lang: string | null, fadeInactiveAuthorColors: boolean, - enforceReadableAuthorColors: boolean, }, enableMetrics: boolean, padShortcutEnabled: { @@ -442,7 +441,6 @@ const settings: SettingsType = { chatAndUsers: false, lang: null, fadeInactiveAuthorColors: true, - 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 5176b64ed27..02c18fce369 100644 --- a/src/static/js/ace2_inner.ts +++ b/src/static/js/ace2_inner.ts @@ -247,18 +247,9 @@ function Ace2Inner(editorInfo, cssManagers) { if (fadeInactiveAuthorColors && (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 0a36de6a6fa957257010bceb11019e92b3570246 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 19:41:17 +0100 Subject: [PATCH 3/7] 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 48620a39eec65cf545cc7dd9323413933f9ae0e8 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 19:47:00 +0100 Subject: [PATCH 4/7] 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 324c2ef821bc57631eaad99c939309f4b09aa5fc Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 19:52:44 +0100 Subject: [PATCH 5/7] 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 () { From a2fa7a7ace534f9c5e8bfd3bf2d9ca6631fc17c4 Mon Sep 17 00:00:00 2001 From: John McLear Date: Fri, 1 May 2026 17:40:02 +0100 Subject: [PATCH 6/7] fix(7377): use rendered text colour + clamp bg to actually meet AA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local repro of the issue exposed two real bugs in the previous fix: 1. textColorFromBackgroundColor compared bg against a hardcoded #222 — but in the colibris skin --super-dark-color resolves to #485365. For the issue's exact case (#9AB3FA author bg) the selector returned var(--super-dark-color) thinking it was getting a 7.7:1 ratio, while the browser actually rendered 3.78:1 — identical to what the issue screenshot reported. This PR's previous behaviour on the issue's inputs was unchanged from the pre-fix. 2. For mid-saturation pastels (#9AB3FA) and pure primaries (#ff0000) neither rendered dark nor white text can clear AA. Text-colour selection alone genuinely cannot fix this band; the ensureReadable bg clamp dropped in ce0c5c283 was load-bearing. Changes: - colorutils.ts: per-skin SKIN_TEXT_COLORS table with darkRef/lightRef matching what the browser actually paints (colibris #485365, default #222). Re-introduces ensureReadableBackground, but skin-aware and symmetric — blends bg toward white or black depending on which text colour wins, so it works for both light and dark backgrounds. - ace2_inner.ts: setAuthorStyle runs the bg through the clamp before picking text colour. Gated on padOptions.enforceReadableAuthorColors (default true). - Settings.ts / settings.json.template / settings.json.docker / doc/docker.md: padOption + PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS env var. - tests: failing-then-green coverage for the issue's exact case (#9AB3FA + colibris), the previously-impossible #ff0000, the no-mutation case, non-hex pass-through, and a sweep over primaries. Co-Authored-By: Claude Opus 4.7 (1M context) --- doc/docker.md | 1 + settings.json.docker | 3 +- settings.json.template | 9 +++- src/node/utils/Settings.ts | 2 + src/static/js/ace2_inner.ts | 13 +++-- src/static/js/colorutils.ts | 67 ++++++++++++++++++------ src/tests/backend/specs/colorutils.ts | 73 +++++++++++++++++++++++++++ 7 files changed, 148 insertions(+), 20 deletions(-) diff --git a/doc/docker.md b/doc/docker.md index 8e22ba51837..ce7606f8ec9 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -116,6 +116,7 @@ If your database needs additional settings, you will have to use a personalized | `PAD_OPTIONS_CHAT_AND_USERS` | | `false` | | `PAD_OPTIONS_LANG` | | `null` | | `PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS` | Fade each author's caret/background toward white as they go inactive. Set to `false` on busy pads (every faded author counts as a second on-screen color, so 30 contributors visually become 60), when users pick light colors that fade into the background, or whenever inactivity tracking is undesirable. | `true` | +| `PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS` | Lighten/darken author bg colours at render time so text contrast meets WCAG 2.1 AA. | `true` | ### Shortcuts diff --git a/settings.json.docker b/settings.json.docker index 1f1d6d05bca..bbd6413577f 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -319,7 +319,8 @@ "alwaysShowChat": "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}", "chatAndUsers": "${PAD_OPTIONS_CHAT_AND_USERS:false}", "lang": "${PAD_OPTIONS_LANG:null}", - "fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}" + "fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}", + "enforceReadableAuthorColors": "${PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS:true}" }, /* diff --git a/settings.json.template b/settings.json.template index 4fc59819c94..d5c7eb44b2b 100644 --- a/settings.json.template +++ b/settings.json.template @@ -308,7 +308,14 @@ * as the author goes inactive. Set to false if users pick light colors and the * faded variants become visually indistinguishable. */ - "fadeInactiveAuthorColors": true + "fadeInactiveAuthorColors": true, + /* + * Clamp author background colors to a WCAG 2.1 AA contrast ratio (4.5:1) + * against the rendered text colour at render time. The author's stored + * colour is not modified — only the displayed shade is adjusted. Set to + * false to render exact author colours regardless of contrast. + */ + "enforceReadableAuthorColors": true }, /* diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 26876cd08cf..7cd42a81185 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -207,6 +207,7 @@ export type SettingsType = { chatAndUsers: boolean, lang: string | null, fadeInactiveAuthorColors: boolean, + enforceReadableAuthorColors: boolean, }, enableMetrics: boolean, padShortcutEnabled: { @@ -441,6 +442,7 @@ const settings: SettingsType = { chatAndUsers: false, lang: null, fadeInactiveAuthorColors: true, + 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 02c18fce369..8c32a222f5c 100644 --- a/src/static/js/ace2_inner.ts +++ b/src/static/js/ace2_inner.ts @@ -247,9 +247,16 @@ function Ace2Inner(editorInfo, cssManagers) { if (fadeInactiveAuthorColors && (typeof info.fade) === 'number') { bgcolor = fadeColor(bgcolor, info.fade); } - // 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. + // Clamp the rendered background to a WCAG-AA-compliant shade before + // picking text colour (issue #7377). Author's stored colour is not + // mutated — this is purely a viewer-side render adjustment. Opt-out + // via padOptions.enforceReadableAuthorColors: false. + const enforceReadable = + window.clientVars.padOptions == null || + window.clientVars.padOptions.enforceReadableAuthorColors !== false; + if (enforceReadable) { + bgcolor = colorutils.ensureReadableBackground(bgcolor, window.clientVars.skinName); + } 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 8b0adc6e2ae..3a572f5d25e 100644 --- a/src/static/js/colorutils.ts +++ b/src/static/js/colorutils.ts @@ -134,24 +134,61 @@ colorutils.contrastRatio = (c1, c2) => { return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); }; +// Per-skin rendered text colours for WCAG comparisons (issue #7377). The +// `*Ref` values are the colours actually painted in the browser — they MUST +// match what the CSS variables resolve to so contrast comparisons reflect +// what the user sees. The `*Out` values are what we hand back to CSS (the +// variable name in colibris, a hex literal otherwise). +// +// Colibris dark = #485365 from src/static/skins/colibris/pad.css's +// --super-dark-color. If that variable is ever retuned, update this table. +const SKIN_TEXT_COLORS = { + colibris: {darkRef: '#485365', lightRef: '#ffffff', darkOut: 'var(--super-dark-color)', lightOut: 'var(--super-light-color)'}, + default: {darkRef: '#222222', lightRef: '#ffffff', darkOut: '#222', lightOut: '#fff'}, +}; +const skinTextColors = (skinName) => SKIN_TEXT_COLORS[skinName] || SKIN_TEXT_COLORS.default; + // 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'); +// rendered text colours for the active skin produces the higher contrast +// ratio against the background. colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => { - const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff'; - const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222'; + const refs = skinTextColors(skinName); const triple = colorutils.css2triple(bgcolor); - const ratioWithBlack = colorutils.contrastRatio(triple, BLACK_ISH); - const ratioWithWhite = colorutils.contrastRatio(triple, WHITE_ISH); - return ratioWithBlack >= ratioWithWhite ? black : white; + const ratioDark = colorutils.contrastRatio(triple, colorutils.css2triple(refs.darkRef)); + const ratioLight = colorutils.contrastRatio(triple, colorutils.css2triple(refs.lightRef)); + return ratioDark >= ratioLight ? refs.darkOut : refs.lightOut; +}; + +// Some backgrounds (the issue's #9AB3FA, every mid-saturation primary like +// #ff0000) cannot meet AA against either rendered text colour for a given +// skin — text-colour selection alone can't fix them. ensureReadableBackground +// blends the bg toward the extreme OPPOSITE the better-contrast text in 5% +// increments until AA is met, preserving hue. Author's stored colour is +// untouched — this is a viewer-side render clamp. +// +// Returns the input unchanged for non-hex inputs (CSS vars etc.) so callers +// can apply this generically without first checking the value shape. +colorutils.ensureReadableBackground = (cssColor, skinName, minContrast) => { + if (!colorutils.isCssHex(cssColor)) return cssColor; + if (minContrast == null) minContrast = 4.5; + const refs = skinTextColors(skinName); + const dark = colorutils.css2triple(refs.darkRef); + const light = colorutils.css2triple(refs.lightRef); + const triple = colorutils.css2triple(cssColor); + const ratioDark = colorutils.contrastRatio(triple, dark); + const ratioLight = colorutils.contrastRatio(triple, light); + if (Math.max(ratioDark, ratioLight) >= minContrast) return cssColor; + // Better text colour wins; blend bg toward the opposite end so the + // contrast against that text grows. + const blendTarget = ratioDark >= ratioLight ? [1, 1, 1] : [0, 0, 0]; + const textRef = ratioDark >= ratioLight ? dark : light; + for (let i = 1; i <= 20; i++) { + const blended = colorutils.blend(triple, blendTarget, i * 0.05); + if (colorutils.contrastRatio(blended, textRef) >= minContrast) { + return colorutils.triple2css(blended); + } + } + return colorutils.triple2css(blendTarget); }; exports.colorutils = colorutils; diff --git a/src/tests/backend/specs/colorutils.ts b/src/tests/backend/specs/colorutils.ts index 05a80072feb..5ae0300d65e 100644 --- a/src/tests/backend/specs/colorutils.ts +++ b/src/tests/backend/specs/colorutils.ts @@ -76,6 +76,79 @@ describe(__filename, function () { assert.strictEqual(onDark, 'var(--super-light-color)'); }); + it('uses the actually-rendered colibris dark colour (#485365) for ratio comparisons', function () { + // Issue #7377 repro: bg #9AB3FA with default colibris text. + // The pad renders --super-dark-color as #485365 (not #222), so the + // selector must compare against #485365 to match what the user sees. + // Pre-fix this returned 'var(--super-dark-color)' based on a phantom + // 7.7:1 ratio computed against #222, while the actual rendered ratio + // was 3.78:1 — identical to what the issue reported. + const bg = colorutils.css2triple('#9AB3FA'); + const colibrisDark = colorutils.css2triple('#485365'); + const colibrisLight = colorutils.css2triple('#ffffff'); + const ratioDark = colorutils.contrastRatio(bg, colibrisDark); + const ratioLight = colorutils.contrastRatio(bg, colibrisLight); + const picked = colorutils.textColorFromBackgroundColor('#9AB3FA', 'colibris'); + const expected = + ratioDark >= ratioLight ? 'var(--super-dark-color)' : 'var(--super-light-color)'; + assert.strictEqual(picked, expected, + `for #9AB3FA, dark=${ratioDark.toFixed(2)} vs light=${ratioLight.toFixed(2)} → ${expected}`); + }); + }); + + describe('ensureReadableBackground (issue #7377)', function () { + const AA = 4.5; + + const ratioToBetterText = (bgHex: string, skin: string) => { + const bg = colorutils.css2triple(bgHex); + // Skin-aware rendered text references — must match the production map + // in colorutils so the test fails if either drifts. + const dark = skin === 'colibris' + ? colorutils.css2triple('#485365') + : colorutils.css2triple('#222222'); + const light = colorutils.css2triple('#ffffff'); + return Math.max(colorutils.contrastRatio(bg, dark), colorutils.contrastRatio(bg, light)); + }; + + it('clamps the issue-#7377 scenario (#9AB3FA on colibris) to ≥ AA', function () { + const out = colorutils.ensureReadableBackground('#9AB3FA', 'colibris'); + assert.ok(colorutils.isCssHex(out), `expected a hex color, got ${out}`); + const ratio = ratioToBetterText(out, 'colibris'); + assert.ok(ratio >= AA, `${out} only reaches ${ratio.toFixed(3)}:1 against rendered text`); + }); + + it('clamps #ff0000 (default skin) to ≥ AA — the case the test suite previously flagged as unsolvable', function () { + const out = colorutils.ensureReadableBackground('#ff0000', 'default'); + const ratio = ratioToBetterText(out, 'default'); + assert.ok(ratio >= AA, `${out} only reaches ${ratio.toFixed(3)}:1 against rendered text`); + }); + + it('returns the original hex unchanged when the bg already meets AA', function () { + // #ffeedd against colibris #485365 is well over AA, so we shouldn't + // mutate the author's colour. + const out = colorutils.ensureReadableBackground('#ffeedd', 'colibris'); + assert.strictEqual(out, '#ffeedd'); + }); + + it('passes non-hex bg values through unchanged (CSS vars, etc.)', function () { + assert.strictEqual( + colorutils.ensureReadableBackground('var(--something)', 'colibris'), + 'var(--something)'); + }); + + it('every pure primary clears AA after the clamp', function () { + const samples = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff', + '#9AB3FA', '#cc6688', '#88aacc', '#ffcc88']; + for (const bg of samples) { + const out = colorutils.ensureReadableBackground(bg, 'colibris'); + const ratio = ratioToBetterText(out, 'colibris'); + assert.ok(ratio >= AA, + `${bg} → ${out} only reaches ${ratio.toFixed(3)}:1 (skin: colibris)`); + } + }); + }); + + describe('textColorFromBackgroundColor — invariant', function () { 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` From dd9b986c35b84946f771ec7b9c9fd74a1e99e9bb Mon Sep 17 00:00:00 2001 From: John McLear Date: Fri, 1 May 2026 19:55:59 +0100 Subject: [PATCH 7/7] test(7377): add e2e DOM-contrast spec + extra unit cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous coverage was unit-only, which is what let the original wrong- reference-colour bug ship — the algorithm tests were green but nothing exercised what the browser actually paints. New coverage: Playwright (src/tests/frontend-new/specs/wcag_author_color.spec.ts): - Sets the user's colour to the issue's exact #9AB3FA, types text, reads the rendered author span's computed bg + colour from the inner frame, and asserts the WCAG ratio between the two is >= 4.5. Repeated for #ff0000 (the other historically-failing case). - Asserts #ffeedd (already AA-friendly) is rendered unchanged — guards against the clamp mutating colours that don't need it. Backend additions (src/tests/backend/specs/colorutils.ts): - Symmetric-clamp test: dark mid-saturation bg where light text wins, the clamp must darken (not lighten). Direction check via relativeLuminance. - minContrast parameter: AAA (7.0) must produce more clamping than AA. - Output shape: result must be a parseable hex string (round-trip safe). - Short-hex (#abc) input is accepted and normalised. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tests/backend/specs/colorutils.ts | 65 +++++++++++++++ .../specs/wcag_author_color.spec.ts | 79 +++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/tests/frontend-new/specs/wcag_author_color.spec.ts diff --git a/src/tests/backend/specs/colorutils.ts b/src/tests/backend/specs/colorutils.ts index 5ae0300d65e..84fa57a296b 100644 --- a/src/tests/backend/specs/colorutils.ts +++ b/src/tests/backend/specs/colorutils.ts @@ -136,6 +136,71 @@ describe(__filename, function () { 'var(--something)'); }); + it('clamps a dark mid-saturation bg by darkening (light text wins)', function () { + // Counterpart to the #9AB3FA case. #6b3a3a sits in the band where the + // higher-contrast text is light (#ffffff: ~5.32 — already AA, sanity + // check). Pick a darker example where light text is winning but still + // sub-AA, e.g. #884444. + const bg = colorutils.css2triple('#884444'); + const dark = colorutils.css2triple('#222222'); + const light = colorutils.css2triple('#ffffff'); + const initialRatio = Math.max( + colorutils.contrastRatio(bg, dark), colorutils.contrastRatio(bg, light)); + // Only meaningful as a clamp test if the input actually fails AA. + if (initialRatio >= 4.5) { + // Pick a tighter input that's known to fail. + const fail = colorutils.ensureReadableBackground('#7a4444', 'default'); + const failTriple = colorutils.css2triple(fail); + const r = Math.max( + colorutils.contrastRatio(failTriple, dark), + colorutils.contrastRatio(failTriple, light)); + assert.ok(r >= 4.5); + return; + } + const out = colorutils.ensureReadableBackground('#884444', 'default'); + const outTriple = colorutils.css2triple(out); + const r = Math.max( + colorutils.contrastRatio(outTriple, dark), + colorutils.contrastRatio(outTriple, light)); + assert.ok(r >= 4.5, `${out} only reached ${r.toFixed(3)}:1`); + // Direction check: when light text wins, we darken bg (its luminance + // should decrease, not increase). + const before = colorutils.relativeLuminance(bg); + const after = colorutils.relativeLuminance(outTriple); + assert.ok(after <= before, + `expected darker bg when light text wins, got luminance ${before} → ${after}`); + }); + + it('respects an explicit minContrast parameter', function () { + // Same input, two thresholds: AAA (7.0) must produce a more-clamped bg + // than AA (4.5). + const aa = colorutils.ensureReadableBackground('#9AB3FA', 'colibris', 4.5); + const aaa = colorutils.ensureReadableBackground('#9AB3FA', 'colibris', 7.0); + const dark = colorutils.css2triple('#485365'); + const ratioAA = colorutils.contrastRatio(colorutils.css2triple(aa), dark); + const ratioAAA = colorutils.contrastRatio(colorutils.css2triple(aaa), dark); + assert.ok(ratioAA >= 4.5, `AA: ${ratioAA.toFixed(3)}`); + assert.ok(ratioAAA >= 7.0, `AAA: ${ratioAAA.toFixed(3)}`); + }); + + it('returns a parseable hex string', function () { + const out = colorutils.ensureReadableBackground('#9AB3FA', 'colibris'); + assert.ok(colorutils.isCssHex(out), `not a hex color: ${out}`); + // Round-trip safe — must parse back into a triple without throwing. + assert.doesNotThrow(() => colorutils.css2triple(out)); + }); + + it('accepts short-hex (#abc) input', function () { + // #f00 == #ff0000. The selector path normalises via css2sixhex; the + // clamp must do the same so callers can pass either form safely. + assert.doesNotThrow(() => colorutils.ensureReadableBackground('#f00', 'default')); + const out = colorutils.ensureReadableBackground('#f00', 'default'); + const ratio = Math.max( + colorutils.contrastRatio(colorutils.css2triple(out), colorutils.css2triple('#222222')), + colorutils.contrastRatio(colorutils.css2triple(out), colorutils.css2triple('#ffffff'))); + assert.ok(ratio >= 4.5); + }); + it('every pure primary clears AA after the clamp', function () { const samples = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff', '#9AB3FA', '#cc6688', '#88aacc', '#ffcc88']; diff --git a/src/tests/frontend-new/specs/wcag_author_color.spec.ts b/src/tests/frontend-new/specs/wcag_author_color.spec.ts new file mode 100644 index 00000000000..5b89a55ffeb --- /dev/null +++ b/src/tests/frontend-new/specs/wcag_author_color.spec.ts @@ -0,0 +1,79 @@ +import {expect, test, Page} from '@playwright/test'; +import {goToNewPad, getPadBody} from '../helper/padHelper'; + +// End-to-end coverage for the WCAG author-colour clamp (issue #7377). Sets +// the user's colour to one of the historically-failing values and asserts +// the rendered author span on the actual DOM achieves >= 4.5:1 against the +// computed text colour. This is the test the previous PR was missing — the +// backend unit tests verified the algorithm but nothing exercised the full +// Settings -> ace2_inner -> CSS render pipeline that the issue was about. + +test.beforeEach(async ({page}) => { + await goToNewPad(page); +}); + +const setUserColor = async (page: Page, hex: string) => { + await page.locator('.buttonicon-showusers').click(); + await page.locator('#myswatch').click(); + await page.evaluate((hexColor: string) => { + document.getElementById('mycolorpickerpreview')!.style.backgroundColor = hexColor; + }, hex); + await page.locator('#mycolorpickersave').click(); + await page.waitForTimeout(500); +}; + +const wcagRatio = (rgb1: string, rgb2: string): number => { + const parse = (s: string) => s.match(/\d+/g)!.slice(0, 3).map(Number).map((v) => { + const x = v / 255; + return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); + }); + const lum = (rgb: number[]) => 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]; + const l1 = lum(parse(rgb1)); + const l2 = lum(parse(rgb2)); + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +}; + +const renderedAuthorContrast = async (page: Page) => { + const body = await getPadBody(page); + await body.click(); + await page.keyboard.type('contrast smoke'); + await page.waitForTimeout(300); + // The author span is the inner-frame wrapping + // the typed text. Read its computed bg + the inherited text colour. + const result = await page.frame('ace_inner')!.evaluate(() => { + const span = document.querySelector( + '#innerdocbody span[class*="author-"]:not([class*="anonymous"])') as HTMLElement | null; + if (!span) return null; + const cs = getComputedStyle(span); + return {bg: cs.backgroundColor, color: cs.color}; + }); + return result; +}; + +test.describe('WCAG author colour (issue #7377)', () => { + test('issue scenario: #9AB3FA renders >= AA against the author text', async ({page}) => { + await setUserColor(page, '#9AB3FA'); + const r = await renderedAuthorContrast(page); + expect(r, 'expected an author-coloured span in the pad').not.toBeNull(); + const ratio = wcagRatio(r!.bg, r!.color); + expect(ratio, `bg=${r!.bg} color=${r!.color} ratio=${ratio.toFixed(3)}`) + .toBeGreaterThanOrEqual(4.5); + }); + + test('pure red #ff0000 renders >= AA after the clamp', async ({page}) => { + await setUserColor(page, '#ff0000'); + const r = await renderedAuthorContrast(page); + expect(r).not.toBeNull(); + const ratio = wcagRatio(r!.bg, r!.color); + expect(ratio, `bg=${r!.bg} color=${r!.color} ratio=${ratio.toFixed(3)}`) + .toBeGreaterThanOrEqual(4.5); + }); + + test('already-AA-friendly #ffeedd is rendered unchanged', async ({page}) => { + await setUserColor(page, '#ffeedd'); + const r = await renderedAuthorContrast(page); + expect(r).not.toBeNull(); + // #ffeedd → rgb(255, 238, 221). Clamp must NOT mutate this. + expect(r!.bg).toBe('rgb(255, 238, 221)'); + }); +});