Skip to content

Commit ce0c5c2

Browse files
JohnMcLearclaude
andcommitted
refactor(7377): simplify — just pick higher-contrast text, drop bg clamp
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) <noreply@anthropic.com>
1 parent 0109a69 commit ce0c5c2

7 files changed

Lines changed: 59 additions & 105 deletions

File tree

doc/docker.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,6 @@ 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` |
113112

114113

115114
### Shortcuts

settings.json.docker

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,8 +288,7 @@
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}",
292-
"enforceReadableAuthorColors": "${PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS:true}"
291+
"lang": "${PAD_OPTIONS_LANG:null}"
293292
},
294293

295294
/*

settings.json.template

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -261,15 +261,7 @@
261261
"rtl": false,
262262
"alwaysShowChat": false,
263263
"chatAndUsers": false,
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
264+
"lang": null
273265
},
274266

275267
/*

src/node/utils/Settings.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,6 @@ export type SettingsType = {
203203
alwaysShowChat: boolean,
204204
chatAndUsers: boolean,
205205
lang: string | null,
206-
enforceReadableAuthorColors: boolean,
207206
},
208207
enableMetrics: boolean,
209208
padShortcutEnabled: {
@@ -411,7 +410,6 @@ const settings: SettingsType = {
411410
alwaysShowChat: false,
412411
chatAndUsers: false,
413412
lang: null,
414-
enforceReadableAuthorColors: true,
415413
},
416414
/**
417415
* Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this.

src/static/js/ace2_inner.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -239,18 +239,9 @@ 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-
}
242+
// textColorFromBackgroundColor is WCAG-aware (issue #7377): it returns
243+
// whichever of black/white produces the higher contrast against the
244+
// author's bg, guaranteeing at least AA (4.5:1) for any sRGB colour.
254245
const textColor =
255246
colorutils.textColorFromBackgroundColor(bgcolor, window.clientVars.skinName);
256247
const styles = [

src/static/js/colorutils.ts

Lines changed: 22 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -112,64 +112,39 @@ colorutils.complementary = (c) => {
112112
];
113113
};
114114

115-
colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => {
116-
const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff';
117-
const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222';
118-
119-
return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black;
120-
};
121-
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].
115+
// --- WCAG 2.1 helpers (issue #7377) ------------------------------------------
116+
// Pre-fix text colour selection used `luminosity(bg) < 0.5` as the cutoff,
117+
// which produced WCAG-AA-failing combinations for mid-saturation author
118+
// colours (e.g. pure red #ff0000 paired with white text gives a 4.0 contrast
119+
// ratio — below the 4.5 threshold and genuinely hard to read). The helpers
120+
// below implement WCAG 2.1 relative luminance and contrast ratio so text
121+
// colour selection can pick the higher-contrast option and always clear AA.
122+
//
123+
// Reference: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
134124
colorutils.relativeLuminance = (c) => {
135125
const toLinear = (v) => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
136126
return 0.2126 * toLinear(c[0]) + 0.7152 * toLinear(c[1]) + 0.0722 * toLinear(c[2]);
137127
};
138128

139-
// WCAG 2.1 contrast ratio between two sRGB triples, in [1, 21].
140-
// 4.5 = AA for body text; 7.0 = AAA.
129+
// WCAG 2.1 contrast ratio between two sRGB triples, in [1, 21]. 4.5 = AA
130+
// for body text; 7.0 = AAA.
141131
colorutils.contrastRatio = (c1, c2) => {
142132
const l1 = colorutils.relativeLuminance(c1);
143133
const l2 = colorutils.relativeLuminance(c2);
144134
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
145135
};
146136

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';
137+
// WCAG-aware text-colour selection (issue #7377). Pick whichever of black or
138+
// white produces the higher contrast ratio against the background. For every
139+
// sRGB colour at least one of the two choices clears AA (4.5:1) — the dead
140+
// zone at the 0.5-luminosity cutoff the old implementation used is gone.
141+
colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => {
142+
const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff';
143+
const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222';
144+
const triple = colorutils.css2triple(bgcolor);
145+
const ratioWithBlack = colorutils.contrastRatio(triple, [0, 0, 0]);
146+
const ratioWithWhite = colorutils.contrastRatio(triple, [1, 1, 1]);
147+
return ratioWithBlack >= ratioWithWhite ? black : white;
173148
};
174149

175150
exports.colorutils = colorutils;

src/tests/backend/specs/colorutils.ts

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -32,47 +32,47 @@ describe(__filename, function () {
3232
it('is 1 between identical colors', function () {
3333
assert.strictEqual(colorutils.contrastRatio([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]), 1);
3434
});
35+
});
3536

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}`);
37+
describe('textColorFromBackgroundColor (WCAG-aware, issue #7377)', function () {
38+
// Exact failure case from the issue screenshot. Pre-fix the
39+
// luminosity < 0.5 cutoff picked white text on #ff0000, giving a 4.0
40+
// contrast ratio — below WCAG AA.
41+
it('picks black text on #ff0000 (contrast 5.25 > 4.0 for white)', function () {
42+
const result = colorutils.textColorFromBackgroundColor('#ff0000', 'something-else');
43+
assert.strictEqual(result, '#222', `expected black-ish, got ${result}`);
4044
});
41-
});
4245

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');
46+
it('picks white text on dark backgrounds', function () {
47+
const result = colorutils.textColorFromBackgroundColor('#111111', 'something-else');
48+
assert.strictEqual(result, '#fff');
5049
});
5150

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');
51+
it('picks black text on light backgrounds', function () {
52+
const result = colorutils.textColorFromBackgroundColor('#f8f8f8', 'something-else');
53+
assert.strictEqual(result, '#222');
5854
});
5955

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}`);
56+
it('returns colibris CSS vars when the skin matches', function () {
57+
const onRed = colorutils.textColorFromBackgroundColor('#ff0000', 'colibris');
58+
assert.strictEqual(onRed, 'var(--super-dark-color)');
59+
const onNavy = colorutils.textColorFromBackgroundColor('#111111', 'colibris');
60+
assert.strictEqual(onNavy, 'var(--super-light-color)');
6961
});
7062

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}`);
63+
it('every primary picks a text colour clearing WCAG AA', function () {
64+
// The dead-zone regression: for every pure-ish primary, the returned
65+
// text colour must produce ≥4.5:1 contrast.
66+
const samples = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff',
67+
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080'];
68+
for (const bg of samples) {
69+
const textHex = colorutils.textColorFromBackgroundColor(bg, 'something-else');
70+
const textTriple = textHex === '#222'
71+
? colorutils.css2triple('#222222')
72+
: colorutils.css2triple('#ffffff');
73+
const ratio = colorutils.contrastRatio(colorutils.css2triple(bg), textTriple);
74+
assert.ok(ratio >= 4.5, `${bg}${textHex} gave only ${ratio.toFixed(2)}:1`);
75+
}
7676
});
7777
});
7878
});

0 commit comments

Comments
 (0)