diff --git a/doc/docker.md b/doc/docker.md index b866d6947..71af39236 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 23cec793a..8fdd51de0 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 5b8dcfa97..0d1493c2b 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 284f7323e..0b250e494 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 44cd6f305..cf268626d 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 fb0db8d5e..dfc7e6012 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 c25d91061..14d04e533 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('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'); + 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}`); }); - 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 white text on dark backgrounds', function () { + const result = colorutils.textColorFromBackgroundColor('#111111', 'something-else'); + assert.strictEqual(result, '#fff'); }); - 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('picks black text on light backgrounds', function () { + const result = colorutils.textColorFromBackgroundColor('#f8f8f8', 'something-else'); + assert.strictEqual(result, '#222'); }); - 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('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('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`); + } }); }); });