mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-18 00:57:55 +00:00
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>
This commit is contained in:
parent
0109a698c5
commit
ce0c5c2839
7 changed files with 60 additions and 106 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
},
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue