Skip to content

Commit 9014d3a

Browse files
JohnMcLearclaude
andauthored
fix(colors): pick WCAG-higher-contrast text for author colors (#7565)
* 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> * 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> * test(7377): assert relative-contrast invariant, not absolute AA 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) <noreply@anthropic.com> * fix(7377): compare against rendered #222/#fff, not pure black/white 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) <noreply@anthropic.com> * test(7377): pick unambiguous colibris test bgs #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) <noreply@anthropic.com> * fix(7377): use rendered text colour + clamp bg to actually meet AA 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 ce0c5c2 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) <noreply@anthropic.com> * test(7377): add e2e DOM-contrast spec + extra unit cases 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 25c4314 commit 9014d3a

8 files changed

Lines changed: 419 additions & 5 deletions

File tree

doc/docker.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ If your database needs additional settings, you will have to use a personalized
116116
| `PAD_OPTIONS_CHAT_AND_USERS` | | `false` |
117117
| `PAD_OPTIONS_LANG` | | `null` |
118118
| `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` |
119+
| `PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS` | Lighten/darken author bg colours at render time so text contrast meets WCAG 2.1 AA. | `true` |
119120

120121

121122
### Shortcuts

settings.json.docker

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,8 @@
319319
"alwaysShowChat": "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}",
320320
"chatAndUsers": "${PAD_OPTIONS_CHAT_AND_USERS:false}",
321321
"lang": "${PAD_OPTIONS_LANG:null}",
322-
"fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}"
322+
"fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}",
323+
"enforceReadableAuthorColors": "${PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS:true}"
323324
},
324325

325326
/*

settings.json.template

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,14 @@
308308
* as the author goes inactive. Set to false if users pick light colors and the
309309
* faded variants become visually indistinguishable.
310310
*/
311-
"fadeInactiveAuthorColors": true
311+
"fadeInactiveAuthorColors": true,
312+
/*
313+
* Clamp author background colors to a WCAG 2.1 AA contrast ratio (4.5:1)
314+
* against the rendered text colour at render time. The author's stored
315+
* colour is not modified — only the displayed shade is adjusted. Set to
316+
* false to render exact author colours regardless of contrast.
317+
*/
318+
"enforceReadableAuthorColors": true
312319
},
313320

314321
/*

src/node/utils/Settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ export type SettingsType = {
207207
chatAndUsers: boolean,
208208
lang: string | null,
209209
fadeInactiveAuthorColors: boolean,
210+
enforceReadableAuthorColors: boolean,
210211
},
211212
enableMetrics: boolean,
212213
padShortcutEnabled: {
@@ -441,6 +442,7 @@ const settings: SettingsType = {
441442
chatAndUsers: false,
442443
lang: null,
443444
fadeInactiveAuthorColors: true,
445+
enforceReadableAuthorColors: true,
444446
},
445447
/**
446448
* Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this.

src/static/js/ace2_inner.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,16 @@ function Ace2Inner(editorInfo, cssManagers) {
247247
if (fadeInactiveAuthorColors && (typeof info.fade) === 'number') {
248248
bgcolor = fadeColor(bgcolor, info.fade);
249249
}
250+
// Clamp the rendered background to a WCAG-AA-compliant shade before
251+
// picking text colour (issue #7377). Author's stored colour is not
252+
// mutated — this is purely a viewer-side render adjustment. Opt-out
253+
// via padOptions.enforceReadableAuthorColors: false.
254+
const enforceReadable =
255+
window.clientVars.padOptions == null ||
256+
window.clientVars.padOptions.enforceReadableAuthorColors !== false;
257+
if (enforceReadable) {
258+
bgcolor = colorutils.ensureReadableBackground(bgcolor, window.clientVars.skinName);
259+
}
250260
const textColor =
251261
colorutils.textColorFromBackgroundColor(bgcolor, window.clientVars.skinName);
252262
const styles = [

src/static/js/colorutils.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,83 @@ colorutils.complementary = (c) => {
112112
];
113113
};
114114

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
124+
colorutils.relativeLuminance = (c) => {
125+
const toLinear = (v) => v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
126+
return 0.2126 * toLinear(c[0]) + 0.7152 * toLinear(c[1]) + 0.0722 * toLinear(c[2]);
127+
};
128+
129+
// WCAG 2.1 contrast ratio between two sRGB triples, in [1, 21]. 4.5 = AA
130+
// for body text; 7.0 = AAA.
131+
colorutils.contrastRatio = (c1, c2) => {
132+
const l1 = colorutils.relativeLuminance(c1);
133+
const l2 = colorutils.relativeLuminance(c2);
134+
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
135+
};
136+
137+
// Per-skin rendered text colours for WCAG comparisons (issue #7377). The
138+
// `*Ref` values are the colours actually painted in the browser — they MUST
139+
// match what the CSS variables resolve to so contrast comparisons reflect
140+
// what the user sees. The `*Out` values are what we hand back to CSS (the
141+
// variable name in colibris, a hex literal otherwise).
142+
//
143+
// Colibris dark = #485365 from src/static/skins/colibris/pad.css's
144+
// --super-dark-color. If that variable is ever retuned, update this table.
145+
const SKIN_TEXT_COLORS = {
146+
colibris: {darkRef: '#485365', lightRef: '#ffffff', darkOut: 'var(--super-dark-color)', lightOut: 'var(--super-light-color)'},
147+
default: {darkRef: '#222222', lightRef: '#ffffff', darkOut: '#222', lightOut: '#fff'},
148+
};
149+
const skinTextColors = (skinName) => SKIN_TEXT_COLORS[skinName] || SKIN_TEXT_COLORS.default;
150+
151+
// WCAG-aware text-colour selection (issue #7377). Pick whichever of the two
152+
// rendered text colours for the active skin produces the higher contrast
153+
// ratio against the background.
115154
colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => {
116-
const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff';
117-
const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222';
155+
const refs = skinTextColors(skinName);
156+
const triple = colorutils.css2triple(bgcolor);
157+
const ratioDark = colorutils.contrastRatio(triple, colorutils.css2triple(refs.darkRef));
158+
const ratioLight = colorutils.contrastRatio(triple, colorutils.css2triple(refs.lightRef));
159+
return ratioDark >= ratioLight ? refs.darkOut : refs.lightOut;
160+
};
118161

119-
return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black;
162+
// Some backgrounds (the issue's #9AB3FA, every mid-saturation primary like
163+
// #ff0000) cannot meet AA against either rendered text colour for a given
164+
// skin — text-colour selection alone can't fix them. ensureReadableBackground
165+
// blends the bg toward the extreme OPPOSITE the better-contrast text in 5%
166+
// increments until AA is met, preserving hue. Author's stored colour is
167+
// untouched — this is a viewer-side render clamp.
168+
//
169+
// Returns the input unchanged for non-hex inputs (CSS vars etc.) so callers
170+
// can apply this generically without first checking the value shape.
171+
colorutils.ensureReadableBackground = (cssColor, skinName, minContrast) => {
172+
if (!colorutils.isCssHex(cssColor)) return cssColor;
173+
if (minContrast == null) minContrast = 4.5;
174+
const refs = skinTextColors(skinName);
175+
const dark = colorutils.css2triple(refs.darkRef);
176+
const light = colorutils.css2triple(refs.lightRef);
177+
const triple = colorutils.css2triple(cssColor);
178+
const ratioDark = colorutils.contrastRatio(triple, dark);
179+
const ratioLight = colorutils.contrastRatio(triple, light);
180+
if (Math.max(ratioDark, ratioLight) >= minContrast) return cssColor;
181+
// Better text colour wins; blend bg toward the opposite end so the
182+
// contrast against that text grows.
183+
const blendTarget = ratioDark >= ratioLight ? [1, 1, 1] : [0, 0, 0];
184+
const textRef = ratioDark >= ratioLight ? dark : light;
185+
for (let i = 1; i <= 20; i++) {
186+
const blended = colorutils.blend(triple, blendTarget, i * 0.05);
187+
if (colorutils.contrastRatio(blended, textRef) >= minContrast) {
188+
return colorutils.triple2css(blended);
189+
}
190+
}
191+
return colorutils.triple2css(blendTarget);
120192
};
121193

122194
exports.colorutils = colorutils;

0 commit comments

Comments
 (0)