Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ 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
Expand Down
3 changes: 2 additions & 1 deletion settings.json.docker
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,8 @@
"rtl": "${PAD_OPTIONS_RTL:false}",
"alwaysShowChat": "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}",
"chatAndUsers": "${PAD_OPTIONS_CHAT_AND_USERS:false}",
"lang": "${PAD_OPTIONS_LANG:null}"
"lang": "${PAD_OPTIONS_LANG:null}",
"enforceReadableAuthorColors": "${PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS:true}"
},

/*
Expand Down
10 changes: 9 additions & 1 deletion settings.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,15 @@
"rtl": false,
"alwaysShowChat": false,
"chatAndUsers": false,
"lang": null
"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
},

/*
Expand Down
2 changes: 2 additions & 0 deletions src/node/utils/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export type SettingsType = {
alwaysShowChat: boolean,
chatAndUsers: boolean,
lang: string | null,
enforceReadableAuthorColors: boolean,
},
enableMetrics: boolean,
padShortcutEnabled: {
Expand Down Expand Up @@ -410,6 +411,7 @@ const settings: SettingsType = {
alwaysShowChat: false,
chatAndUsers: false,
lang: null,
enforceReadableAuthorColors: true,
},
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
/**
* Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this.
Expand Down
12 changes: 12 additions & 0 deletions src/static/js/ace2_inner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,18 @@ 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);
}
const textColor =
colorutils.textColorFromBackgroundColor(bgcolor, window.clientVars.skinName);
const styles = [
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated
Expand Down
53 changes: 53 additions & 0 deletions src/static/js/colorutils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
78 changes: 78 additions & 0 deletions src/tests/backend/specs/colorutils.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
});
});
Loading