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:
John McLear 2026-04-19 19:31:20 +01:00
parent 0109a698c5
commit ce0c5c2839
7 changed files with 60 additions and 106 deletions

View file

@ -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

View file

@ -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}"
},
/*

View file

@ -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
},
/*

View file

@ -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.

View file

@ -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 = [

View file

@ -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;

View file

@ -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`);
}
});
});
});