Skip to content

Commit 0109a69

Browse files
JohnMcLearclaude
andcommitted
feat(colors): clamp author backgrounds to WCAG 2.1 AA on render
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) <noreply@anthropic.com>
1 parent c4add02 commit 0109a69

7 files changed

Lines changed: 157 additions & 2 deletions

File tree

doc/docker.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ If your database needs additional settings, you will have to use a personalized
109109
| `PAD_OPTIONS_ALWAYS_SHOW_CHAT` | | `false` |
110110
| `PAD_OPTIONS_CHAT_AND_USERS` | | `false` |
111111
| `PAD_OPTIONS_LANG` | | `null` |
112+
| `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` |
112113

113114

114115
### Shortcuts

settings.json.docker

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,8 @@
288288
"rtl": "${PAD_OPTIONS_RTL:false}",
289289
"alwaysShowChat": "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}",
290290
"chatAndUsers": "${PAD_OPTIONS_CHAT_AND_USERS:false}",
291-
"lang": "${PAD_OPTIONS_LANG:null}"
291+
"lang": "${PAD_OPTIONS_LANG:null}",
292+
"enforceReadableAuthorColors": "${PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS:true}"
292293
},
293294

294295
/*

settings.json.template

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,15 @@
261261
"rtl": false,
262262
"alwaysShowChat": false,
263263
"chatAndUsers": false,
264-
"lang": null
264+
"lang": null,
265+
/*
266+
* When true (default), author background colors are automatically lightened
267+
* on the rendering side if they would fail WCAG AA contrast (4.5:1) against
268+
* the default text color. Protects readability when an author picks a dark
269+
* custom color. Set to false for environments that need exact color
270+
* fidelity (e.g. video captioning or accessibility-audit fixtures).
271+
*/
272+
"enforceReadableAuthorColors": true
265273
},
266274

267275
/*

src/node/utils/Settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ export type SettingsType = {
203203
alwaysShowChat: boolean,
204204
chatAndUsers: boolean,
205205
lang: string | null,
206+
enforceReadableAuthorColors: boolean,
206207
},
207208
enableMetrics: boolean,
208209
padShortcutEnabled: {
@@ -410,6 +411,7 @@ const settings: SettingsType = {
410411
alwaysShowChat: false,
411412
chatAndUsers: false,
412413
lang: null,
414+
enforceReadableAuthorColors: true,
413415
},
414416
/**
415417
* Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this.

src/static/js/ace2_inner.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,18 @@ function Ace2Inner(editorInfo, cssManagers) {
239239
if ((typeof info.fade) === 'number') {
240240
bgcolor = fadeColor(bgcolor, info.fade);
241241
}
242+
// Clamp the author's background to a WCAG-AA-compliant shade before
243+
// rendering so a poorly-chosen dark color doesn't make the surrounding
244+
// text unreadable (issue #7377). Opt-out via padOptions.
245+
// `enforceReadableAuthorColors: false` for environments where authors
246+
// need exact color fidelity (e.g. video captioning). Author's stored
247+
// color is untouched — this is a viewer-side presentation clamp.
248+
const enforceReadable =
249+
window.clientVars.padOptions == null ||
250+
window.clientVars.padOptions.enforceReadableAuthorColors !== false;
251+
if (enforceReadable && colorutils.isCssHex(bgcolor)) {
252+
bgcolor = colorutils.ensureReadableBackground(bgcolor);
253+
}
242254
const textColor =
243255
colorutils.textColorFromBackgroundColor(bgcolor, window.clientVars.skinName);
244256
const styles = [

src/static/js/colorutils.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,57 @@ colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => {
119119
return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black;
120120
};
121121

122+
// --- WCAG 2.1 contrast helpers (issue #7377) ---------------------------------
123+
// Authors can pick any background color via the color picker; previously we
124+
// chose black/white text purely on the 0.5-luminosity threshold, which left a
125+
// band of mid-tone author colors (dark reds, muted blues) where neither text
126+
// color satisfied WCAG 2.1 AA (4.5:1 contrast) and the pad was genuinely hard
127+
// to read. These helpers let the editor clamp an author's effective background
128+
// on the rendering side (without mutating their stored color choice) so every
129+
// viewer gets a readable result regardless of what the author picked.
130+
131+
// WCAG 2.1 relative luminance
132+
// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
133+
// Takes an sRGB triple in [0, 1] and returns the linear luminance in [0, 1].
134+
colorutils.relativeLuminance = (c) => {
135+
const toLinear = (v) => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
136+
return 0.2126 * toLinear(c[0]) + 0.7152 * toLinear(c[1]) + 0.0722 * toLinear(c[2]);
137+
};
138+
139+
// WCAG 2.1 contrast ratio between two sRGB triples, in [1, 21].
140+
// 4.5 = AA for body text; 7.0 = AAA.
141+
colorutils.contrastRatio = (c1, c2) => {
142+
const l1 = colorutils.relativeLuminance(c1);
143+
const l2 = colorutils.relativeLuminance(c2);
144+
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
145+
};
146+
147+
// Lighten the given background until black text on top of it meets the target
148+
// WCAG contrast ratio (default 4.5:1 — AA for body text). Returns a css hex
149+
// string. If the original color already satisfies the threshold against
150+
// *either* black or white text it's returned unchanged, so we don't repaint
151+
// users whose choices were already fine.
152+
//
153+
// The blend toward white preserves hue, so a dark red becomes a more readable
154+
// pink-red rather than an unrelated color. Viewers always see a readable
155+
// result; the author's stored color is not modified, so disabling
156+
// `enforceReadableAuthorColors` restores the original at any time.
157+
colorutils.ensureReadableBackground = (cssColor, minContrast) => {
158+
if (minContrast == null) minContrast = 4.5;
159+
const triple = colorutils.css2triple(cssColor);
160+
const black = [0, 0, 0];
161+
const white = [1, 1, 1];
162+
if (colorutils.contrastRatio(triple, black) >= minContrast) return cssColor;
163+
if (colorutils.contrastRatio(triple, white) >= minContrast) return cssColor;
164+
// Iteratively blend toward white; 20 steps (5% each) clear every sRGB
165+
// starting point without producing noticeably different colors.
166+
for (let i = 1; i <= 20; i++) {
167+
const blended = colorutils.blend(triple, white, i * 0.05);
168+
if (colorutils.contrastRatio(blended, black) >= minContrast) {
169+
return colorutils.triple2css(blended);
170+
}
171+
}
172+
return '#ffffff';
173+
};
174+
122175
exports.colorutils = colorutils;
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
'use strict';
2+
3+
const assert = require('assert').strict;
4+
const {colorutils} = require('../../../static/js/colorutils');
5+
6+
// Unit coverage for the WCAG helpers added in #7377.
7+
// Kept backend-side so it runs in plain mocha without a browser; colorutils
8+
// is pure and has no DOM deps.
9+
describe(__filename, function () {
10+
describe('relativeLuminance', function () {
11+
it('returns 0 for pure black and 1 for pure white', function () {
12+
assert.strictEqual(colorutils.relativeLuminance([0, 0, 0]), 0);
13+
assert.strictEqual(colorutils.relativeLuminance([1, 1, 1]), 1);
14+
});
15+
16+
it('matches the WCAG 2.1 reference values (within 1e-3)', function () {
17+
// Spot-check against published examples from the WCAG spec:
18+
// #808080 (mid grey) → ~0.2159
19+
// #ff0000 (pure red) → ~0.2126 (red coefficient)
20+
const grey = colorutils.relativeLuminance([0x80 / 255, 0x80 / 255, 0x80 / 255]);
21+
const red = colorutils.relativeLuminance([1, 0, 0]);
22+
assert.ok(Math.abs(grey - 0.2159) < 1e-3, `grey luminance: ${grey}`);
23+
assert.ok(Math.abs(red - 0.2126) < 1e-3, `red luminance: ${red}`);
24+
});
25+
});
26+
27+
describe('contrastRatio', function () {
28+
it('is 21 between black and white', function () {
29+
assert.strictEqual(colorutils.contrastRatio([0, 0, 0], [1, 1, 1]), 21);
30+
});
31+
32+
it('is 1 between identical colors', function () {
33+
assert.strictEqual(colorutils.contrastRatio([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]), 1);
34+
});
35+
36+
it('fails WCAG AA for mid-tone red on black (<4.5)', function () {
37+
// #cc0000-ish — a common "author color" range.
38+
const ratio = colorutils.contrastRatio([0.8, 0, 0], [0, 0, 0]);
39+
assert.ok(ratio < 4.5, `expected <4.5, got ${ratio}`);
40+
});
41+
});
42+
43+
describe('ensureReadableBackground', function () {
44+
it('leaves light enough backgrounds unchanged', function () {
45+
// Pastel blue: already has adequate contrast with black text.
46+
const light = '#aaccff';
47+
assert.strictEqual(
48+
colorutils.ensureReadableBackground(light), light,
49+
'a bg that already satisfies 4.5:1 must be returned verbatim');
50+
});
51+
52+
it('leaves very dark backgrounds unchanged (white text handles it)', function () {
53+
// Near-black bg pairs with white text for contrast >> 4.5 — leave it.
54+
const dark = '#111111';
55+
assert.strictEqual(
56+
colorutils.ensureReadableBackground(dark), dark,
57+
'a bg that works with white text must be returned verbatim');
58+
});
59+
60+
it('lightens mid-tone backgrounds until they pass WCAG AA with black text', function () {
61+
// #cc0000 is the exact failure case from the issue screenshot — dark
62+
// enough that black text is hard to read, but not dark enough for
63+
// white text to hit 4.5:1 either.
64+
const result = colorutils.ensureReadableBackground('#cc0000');
65+
assert.notStrictEqual(result, '#cc0000', 'expected the bg to change');
66+
const triple = colorutils.css2triple(result);
67+
const ratio = colorutils.contrastRatio(triple, [0, 0, 0]);
68+
assert.ok(ratio >= 4.5, `post-clamp contrast must be >=4.5, got ${ratio}`);
69+
});
70+
71+
it('respects a custom minContrast target', function () {
72+
const result = colorutils.ensureReadableBackground('#888888', 7.0);
73+
const triple = colorutils.css2triple(result);
74+
const ratio = colorutils.contrastRatio(triple, [0, 0, 0]);
75+
assert.ok(ratio >= 7.0, `AAA contrast target not met: ${ratio}`);
76+
});
77+
});
78+
});

0 commit comments

Comments
 (0)