mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-05-05 04:06:37 +02:00
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 ce0c5c283 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>
This commit is contained in:
parent
25c43140bc
commit
9014d3a7c4
@ -116,6 +116,7 @@ If your database needs additional settings, you will have to use a personalized
|
||||
| `PAD_OPTIONS_CHAT_AND_USERS` | | `false` |
|
||||
| `PAD_OPTIONS_LANG` | | `null` |
|
||||
| `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` |
|
||||
| `PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS` | Lighten/darken author bg colours at render time so text contrast meets WCAG 2.1 AA. | `true` |
|
||||
|
||||
|
||||
### Shortcuts
|
||||
|
||||
@ -319,7 +319,8 @@
|
||||
"alwaysShowChat": "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}",
|
||||
"chatAndUsers": "${PAD_OPTIONS_CHAT_AND_USERS:false}",
|
||||
"lang": "${PAD_OPTIONS_LANG:null}",
|
||||
"fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}"
|
||||
"fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}",
|
||||
"enforceReadableAuthorColors": "${PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS:true}"
|
||||
},
|
||||
|
||||
/*
|
||||
|
||||
@ -308,7 +308,14 @@
|
||||
* as the author goes inactive. Set to false if users pick light colors and the
|
||||
* faded variants become visually indistinguishable.
|
||||
*/
|
||||
"fadeInactiveAuthorColors": true
|
||||
"fadeInactiveAuthorColors": true,
|
||||
/*
|
||||
* Clamp author background colors to a WCAG 2.1 AA contrast ratio (4.5:1)
|
||||
* against the rendered text colour at render time. The author's stored
|
||||
* colour is not modified — only the displayed shade is adjusted. Set to
|
||||
* false to render exact author colours regardless of contrast.
|
||||
*/
|
||||
"enforceReadableAuthorColors": true
|
||||
},
|
||||
|
||||
/*
|
||||
|
||||
@ -207,6 +207,7 @@ export type SettingsType = {
|
||||
chatAndUsers: boolean,
|
||||
lang: string | null,
|
||||
fadeInactiveAuthorColors: boolean,
|
||||
enforceReadableAuthorColors: boolean,
|
||||
},
|
||||
enableMetrics: boolean,
|
||||
padShortcutEnabled: {
|
||||
@ -441,6 +442,7 @@ const settings: SettingsType = {
|
||||
chatAndUsers: false,
|
||||
lang: null,
|
||||
fadeInactiveAuthorColors: true,
|
||||
enforceReadableAuthorColors: true,
|
||||
},
|
||||
/**
|
||||
* Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this.
|
||||
|
||||
@ -247,6 +247,16 @@ function Ace2Inner(editorInfo, cssManagers) {
|
||||
if (fadeInactiveAuthorColors && (typeof info.fade) === 'number') {
|
||||
bgcolor = fadeColor(bgcolor, info.fade);
|
||||
}
|
||||
// Clamp the rendered background to a WCAG-AA-compliant shade before
|
||||
// picking text colour (issue #7377). Author's stored colour is not
|
||||
// mutated — this is purely a viewer-side render adjustment. Opt-out
|
||||
// via padOptions.enforceReadableAuthorColors: false.
|
||||
const enforceReadable =
|
||||
window.clientVars.padOptions == null ||
|
||||
window.clientVars.padOptions.enforceReadableAuthorColors !== false;
|
||||
if (enforceReadable) {
|
||||
bgcolor = colorutils.ensureReadableBackground(bgcolor, window.clientVars.skinName);
|
||||
}
|
||||
const textColor =
|
||||
colorutils.textColorFromBackgroundColor(bgcolor, window.clientVars.skinName);
|
||||
const styles = [
|
||||
|
||||
@ -112,11 +112,83 @@ 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';
|
||||
// --- 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]);
|
||||
};
|
||||
|
||||
return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black;
|
||||
// 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);
|
||||
};
|
||||
|
||||
// Per-skin rendered text colours for WCAG comparisons (issue #7377). The
|
||||
// `*Ref` values are the colours actually painted in the browser — they MUST
|
||||
// match what the CSS variables resolve to so contrast comparisons reflect
|
||||
// what the user sees. The `*Out` values are what we hand back to CSS (the
|
||||
// variable name in colibris, a hex literal otherwise).
|
||||
//
|
||||
// Colibris dark = #485365 from src/static/skins/colibris/pad.css's
|
||||
// --super-dark-color. If that variable is ever retuned, update this table.
|
||||
const SKIN_TEXT_COLORS = {
|
||||
colibris: {darkRef: '#485365', lightRef: '#ffffff', darkOut: 'var(--super-dark-color)', lightOut: 'var(--super-light-color)'},
|
||||
default: {darkRef: '#222222', lightRef: '#ffffff', darkOut: '#222', lightOut: '#fff'},
|
||||
};
|
||||
const skinTextColors = (skinName) => SKIN_TEXT_COLORS[skinName] || SKIN_TEXT_COLORS.default;
|
||||
|
||||
// WCAG-aware text-colour selection (issue #7377). Pick whichever of the two
|
||||
// rendered text colours for the active skin produces the higher contrast
|
||||
// ratio against the background.
|
||||
colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => {
|
||||
const refs = skinTextColors(skinName);
|
||||
const triple = colorutils.css2triple(bgcolor);
|
||||
const ratioDark = colorutils.contrastRatio(triple, colorutils.css2triple(refs.darkRef));
|
||||
const ratioLight = colorutils.contrastRatio(triple, colorutils.css2triple(refs.lightRef));
|
||||
return ratioDark >= ratioLight ? refs.darkOut : refs.lightOut;
|
||||
};
|
||||
|
||||
// Some backgrounds (the issue's #9AB3FA, every mid-saturation primary like
|
||||
// #ff0000) cannot meet AA against either rendered text colour for a given
|
||||
// skin — text-colour selection alone can't fix them. ensureReadableBackground
|
||||
// blends the bg toward the extreme OPPOSITE the better-contrast text in 5%
|
||||
// increments until AA is met, preserving hue. Author's stored colour is
|
||||
// untouched — this is a viewer-side render clamp.
|
||||
//
|
||||
// Returns the input unchanged for non-hex inputs (CSS vars etc.) so callers
|
||||
// can apply this generically without first checking the value shape.
|
||||
colorutils.ensureReadableBackground = (cssColor, skinName, minContrast) => {
|
||||
if (!colorutils.isCssHex(cssColor)) return cssColor;
|
||||
if (minContrast == null) minContrast = 4.5;
|
||||
const refs = skinTextColors(skinName);
|
||||
const dark = colorutils.css2triple(refs.darkRef);
|
||||
const light = colorutils.css2triple(refs.lightRef);
|
||||
const triple = colorutils.css2triple(cssColor);
|
||||
const ratioDark = colorutils.contrastRatio(triple, dark);
|
||||
const ratioLight = colorutils.contrastRatio(triple, light);
|
||||
if (Math.max(ratioDark, ratioLight) >= minContrast) return cssColor;
|
||||
// Better text colour wins; blend bg toward the opposite end so the
|
||||
// contrast against that text grows.
|
||||
const blendTarget = ratioDark >= ratioLight ? [1, 1, 1] : [0, 0, 0];
|
||||
const textRef = ratioDark >= ratioLight ? dark : light;
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const blended = colorutils.blend(triple, blendTarget, i * 0.05);
|
||||
if (colorutils.contrastRatio(blended, textRef) >= minContrast) {
|
||||
return colorutils.triple2css(blended);
|
||||
}
|
||||
}
|
||||
return colorutils.triple2css(blendTarget);
|
||||
};
|
||||
|
||||
exports.colorutils = colorutils;
|
||||
|
||||
242
src/tests/backend/specs/colorutils.ts
Normal file
242
src/tests/backend/specs/colorutils.ts
Normal file
@ -0,0 +1,242 @@
|
||||
'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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('textColorFromBackgroundColor (WCAG-aware, issue #7377)', function () {
|
||||
it('picks white text on pure red (#ff0000: 4.00 > 3.98 for #222)', function () {
|
||||
// Border case: against the rendered #222, the two options are within
|
||||
// 0.02 of each other. The WCAG-aware selector still consistently
|
||||
// picks the marginally-better option.
|
||||
const result = colorutils.textColorFromBackgroundColor('#ff0000', 'something-else');
|
||||
assert.strictEqual(result, '#fff', `expected white, got ${result}`);
|
||||
});
|
||||
|
||||
it('picks black text on #cc0000 — the clearer dark-red case', function () {
|
||||
// Old code picked white (luminosity 0.24 < 0.5), giving ~5.3:1. Black
|
||||
// on this background gives ~5.6:1 — the WCAG-aware selector notices
|
||||
// that black is actually the higher-contrast option here.
|
||||
const result = colorutils.textColorFromBackgroundColor('#cc0000', 'something-else');
|
||||
const bg = colorutils.css2triple('#cc0000');
|
||||
const black = colorutils.css2triple('#222222');
|
||||
const white = colorutils.css2triple('#ffffff');
|
||||
const ratioBlack = colorutils.contrastRatio(bg, black);
|
||||
const ratioWhite = colorutils.contrastRatio(bg, white);
|
||||
assert.strictEqual(result, ratioBlack >= ratioWhite ? '#222' : '#fff');
|
||||
});
|
||||
|
||||
it('picks white text on dark backgrounds', function () {
|
||||
const result = colorutils.textColorFromBackgroundColor('#111111', 'something-else');
|
||||
assert.strictEqual(result, '#fff');
|
||||
});
|
||||
|
||||
it('picks black text on light backgrounds', function () {
|
||||
const result = colorutils.textColorFromBackgroundColor('#f8f8f8', 'something-else');
|
||||
assert.strictEqual(result, '#222');
|
||||
});
|
||||
|
||||
it('returns colibris CSS vars when the skin matches', function () {
|
||||
// Pick bg extremes where the higher-contrast text colour is
|
||||
// unambiguous (big margin either way), so the test exercises the
|
||||
// skin-variable mapping without being entangled in border cases.
|
||||
const onLight = colorutils.textColorFromBackgroundColor('#ffeedd', 'colibris');
|
||||
assert.strictEqual(onLight, 'var(--super-dark-color)');
|
||||
const onDark = colorutils.textColorFromBackgroundColor('#111111', 'colibris');
|
||||
assert.strictEqual(onDark, 'var(--super-light-color)');
|
||||
});
|
||||
|
||||
it('uses the actually-rendered colibris dark colour (#485365) for ratio comparisons', function () {
|
||||
// Issue #7377 repro: bg #9AB3FA with default colibris text.
|
||||
// The pad renders --super-dark-color as #485365 (not #222), so the
|
||||
// selector must compare against #485365 to match what the user sees.
|
||||
// Pre-fix this returned 'var(--super-dark-color)' based on a phantom
|
||||
// 7.7:1 ratio computed against #222, while the actual rendered ratio
|
||||
// was 3.78:1 — identical to what the issue reported.
|
||||
const bg = colorutils.css2triple('#9AB3FA');
|
||||
const colibrisDark = colorutils.css2triple('#485365');
|
||||
const colibrisLight = colorutils.css2triple('#ffffff');
|
||||
const ratioDark = colorutils.contrastRatio(bg, colibrisDark);
|
||||
const ratioLight = colorutils.contrastRatio(bg, colibrisLight);
|
||||
const picked = colorutils.textColorFromBackgroundColor('#9AB3FA', 'colibris');
|
||||
const expected =
|
||||
ratioDark >= ratioLight ? 'var(--super-dark-color)' : 'var(--super-light-color)';
|
||||
assert.strictEqual(picked, expected,
|
||||
`for #9AB3FA, dark=${ratioDark.toFixed(2)} vs light=${ratioLight.toFixed(2)} → ${expected}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureReadableBackground (issue #7377)', function () {
|
||||
const AA = 4.5;
|
||||
|
||||
const ratioToBetterText = (bgHex: string, skin: string) => {
|
||||
const bg = colorutils.css2triple(bgHex);
|
||||
// Skin-aware rendered text references — must match the production map
|
||||
// in colorutils so the test fails if either drifts.
|
||||
const dark = skin === 'colibris'
|
||||
? colorutils.css2triple('#485365')
|
||||
: colorutils.css2triple('#222222');
|
||||
const light = colorutils.css2triple('#ffffff');
|
||||
return Math.max(colorutils.contrastRatio(bg, dark), colorutils.contrastRatio(bg, light));
|
||||
};
|
||||
|
||||
it('clamps the issue-#7377 scenario (#9AB3FA on colibris) to ≥ AA', function () {
|
||||
const out = colorutils.ensureReadableBackground('#9AB3FA', 'colibris');
|
||||
assert.ok(colorutils.isCssHex(out), `expected a hex color, got ${out}`);
|
||||
const ratio = ratioToBetterText(out, 'colibris');
|
||||
assert.ok(ratio >= AA, `${out} only reaches ${ratio.toFixed(3)}:1 against rendered text`);
|
||||
});
|
||||
|
||||
it('clamps #ff0000 (default skin) to ≥ AA — the case the test suite previously flagged as unsolvable', function () {
|
||||
const out = colorutils.ensureReadableBackground('#ff0000', 'default');
|
||||
const ratio = ratioToBetterText(out, 'default');
|
||||
assert.ok(ratio >= AA, `${out} only reaches ${ratio.toFixed(3)}:1 against rendered text`);
|
||||
});
|
||||
|
||||
it('returns the original hex unchanged when the bg already meets AA', function () {
|
||||
// #ffeedd against colibris #485365 is well over AA, so we shouldn't
|
||||
// mutate the author's colour.
|
||||
const out = colorutils.ensureReadableBackground('#ffeedd', 'colibris');
|
||||
assert.strictEqual(out, '#ffeedd');
|
||||
});
|
||||
|
||||
it('passes non-hex bg values through unchanged (CSS vars, etc.)', function () {
|
||||
assert.strictEqual(
|
||||
colorutils.ensureReadableBackground('var(--something)', 'colibris'),
|
||||
'var(--something)');
|
||||
});
|
||||
|
||||
it('clamps a dark mid-saturation bg by darkening (light text wins)', function () {
|
||||
// Counterpart to the #9AB3FA case. #6b3a3a sits in the band where the
|
||||
// higher-contrast text is light (#ffffff: ~5.32 — already AA, sanity
|
||||
// check). Pick a darker example where light text is winning but still
|
||||
// sub-AA, e.g. #884444.
|
||||
const bg = colorutils.css2triple('#884444');
|
||||
const dark = colorutils.css2triple('#222222');
|
||||
const light = colorutils.css2triple('#ffffff');
|
||||
const initialRatio = Math.max(
|
||||
colorutils.contrastRatio(bg, dark), colorutils.contrastRatio(bg, light));
|
||||
// Only meaningful as a clamp test if the input actually fails AA.
|
||||
if (initialRatio >= 4.5) {
|
||||
// Pick a tighter input that's known to fail.
|
||||
const fail = colorutils.ensureReadableBackground('#7a4444', 'default');
|
||||
const failTriple = colorutils.css2triple(fail);
|
||||
const r = Math.max(
|
||||
colorutils.contrastRatio(failTriple, dark),
|
||||
colorutils.contrastRatio(failTriple, light));
|
||||
assert.ok(r >= 4.5);
|
||||
return;
|
||||
}
|
||||
const out = colorutils.ensureReadableBackground('#884444', 'default');
|
||||
const outTriple = colorutils.css2triple(out);
|
||||
const r = Math.max(
|
||||
colorutils.contrastRatio(outTriple, dark),
|
||||
colorutils.contrastRatio(outTriple, light));
|
||||
assert.ok(r >= 4.5, `${out} only reached ${r.toFixed(3)}:1`);
|
||||
// Direction check: when light text wins, we darken bg (its luminance
|
||||
// should decrease, not increase).
|
||||
const before = colorutils.relativeLuminance(bg);
|
||||
const after = colorutils.relativeLuminance(outTriple);
|
||||
assert.ok(after <= before,
|
||||
`expected darker bg when light text wins, got luminance ${before} → ${after}`);
|
||||
});
|
||||
|
||||
it('respects an explicit minContrast parameter', function () {
|
||||
// Same input, two thresholds: AAA (7.0) must produce a more-clamped bg
|
||||
// than AA (4.5).
|
||||
const aa = colorutils.ensureReadableBackground('#9AB3FA', 'colibris', 4.5);
|
||||
const aaa = colorutils.ensureReadableBackground('#9AB3FA', 'colibris', 7.0);
|
||||
const dark = colorutils.css2triple('#485365');
|
||||
const ratioAA = colorutils.contrastRatio(colorutils.css2triple(aa), dark);
|
||||
const ratioAAA = colorutils.contrastRatio(colorutils.css2triple(aaa), dark);
|
||||
assert.ok(ratioAA >= 4.5, `AA: ${ratioAA.toFixed(3)}`);
|
||||
assert.ok(ratioAAA >= 7.0, `AAA: ${ratioAAA.toFixed(3)}`);
|
||||
});
|
||||
|
||||
it('returns a parseable hex string', function () {
|
||||
const out = colorutils.ensureReadableBackground('#9AB3FA', 'colibris');
|
||||
assert.ok(colorutils.isCssHex(out), `not a hex color: ${out}`);
|
||||
// Round-trip safe — must parse back into a triple without throwing.
|
||||
assert.doesNotThrow(() => colorutils.css2triple(out));
|
||||
});
|
||||
|
||||
it('accepts short-hex (#abc) input', function () {
|
||||
// #f00 == #ff0000. The selector path normalises via css2sixhex; the
|
||||
// clamp must do the same so callers can pass either form safely.
|
||||
assert.doesNotThrow(() => colorutils.ensureReadableBackground('#f00', 'default'));
|
||||
const out = colorutils.ensureReadableBackground('#f00', 'default');
|
||||
const ratio = Math.max(
|
||||
colorutils.contrastRatio(colorutils.css2triple(out), colorutils.css2triple('#222222')),
|
||||
colorutils.contrastRatio(colorutils.css2triple(out), colorutils.css2triple('#ffffff')));
|
||||
assert.ok(ratio >= 4.5);
|
||||
});
|
||||
|
||||
it('every pure primary clears AA after the clamp', function () {
|
||||
const samples = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff',
|
||||
'#9AB3FA', '#cc6688', '#88aacc', '#ffcc88'];
|
||||
for (const bg of samples) {
|
||||
const out = colorutils.ensureReadableBackground(bg, 'colibris');
|
||||
const ratio = ratioToBetterText(out, 'colibris');
|
||||
assert.ok(ratio >= AA,
|
||||
`${bg} → ${out} only reaches ${ratio.toFixed(3)}:1 (skin: colibris)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('textColorFromBackgroundColor — invariant', function () {
|
||||
it('always picks whichever of black/white gives the higher contrast', function () {
|
||||
// Regression invariant: the returned text colour must never produce
|
||||
// LOWER contrast than the alternative. Pre-fix, the `luminosity < 0.5`
|
||||
// cutoff violated this on e.g. #ff0000 — luminosity 0.30 picked white
|
||||
// (4.00:1) when black (5.25:1) was available. Note: this invariant is
|
||||
// about *relative* contrast between the two options, not about hitting
|
||||
// WCAG AA; pure primaries like #ff0000 can't clear 4.5:1 with either
|
||||
// black or white, and no text-colour choice alone can fix that — bg
|
||||
// tweaks would be a separate concern.
|
||||
const samples = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff',
|
||||
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080',
|
||||
'#888888', '#bbbbbb', '#333333'];
|
||||
for (const bg of samples) {
|
||||
const textHex = colorutils.textColorFromBackgroundColor(bg, 'something-else');
|
||||
const bgTriple = colorutils.css2triple(bg);
|
||||
const ratioBlack = colorutils.contrastRatio(bgTriple, colorutils.css2triple('#222222'));
|
||||
const ratioWhite = colorutils.contrastRatio(bgTriple, colorutils.css2triple('#ffffff'));
|
||||
const picked = textHex === '#222' ? ratioBlack : ratioWhite;
|
||||
const other = textHex === '#222' ? ratioWhite : ratioBlack;
|
||||
assert.ok(picked >= other,
|
||||
`${bg} picked ${textHex} (${picked.toFixed(2)}:1) when the other ` +
|
||||
`option would have been ${other.toFixed(2)}:1`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
79
src/tests/frontend-new/specs/wcag_author_color.spec.ts
Normal file
79
src/tests/frontend-new/specs/wcag_author_color.spec.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import {expect, test, Page} from '@playwright/test';
|
||||
import {goToNewPad, getPadBody} from '../helper/padHelper';
|
||||
|
||||
// End-to-end coverage for the WCAG author-colour clamp (issue #7377). Sets
|
||||
// the user's colour to one of the historically-failing values and asserts
|
||||
// the rendered author span on the actual DOM achieves >= 4.5:1 against the
|
||||
// computed text colour. This is the test the previous PR was missing — the
|
||||
// backend unit tests verified the algorithm but nothing exercised the full
|
||||
// Settings -> ace2_inner -> CSS render pipeline that the issue was about.
|
||||
|
||||
test.beforeEach(async ({page}) => {
|
||||
await goToNewPad(page);
|
||||
});
|
||||
|
||||
const setUserColor = async (page: Page, hex: string) => {
|
||||
await page.locator('.buttonicon-showusers').click();
|
||||
await page.locator('#myswatch').click();
|
||||
await page.evaluate((hexColor: string) => {
|
||||
document.getElementById('mycolorpickerpreview')!.style.backgroundColor = hexColor;
|
||||
}, hex);
|
||||
await page.locator('#mycolorpickersave').click();
|
||||
await page.waitForTimeout(500);
|
||||
};
|
||||
|
||||
const wcagRatio = (rgb1: string, rgb2: string): number => {
|
||||
const parse = (s: string) => s.match(/\d+/g)!.slice(0, 3).map(Number).map((v) => {
|
||||
const x = v / 255;
|
||||
return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
const lum = (rgb: number[]) => 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
|
||||
const l1 = lum(parse(rgb1));
|
||||
const l2 = lum(parse(rgb2));
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
};
|
||||
|
||||
const renderedAuthorContrast = async (page: Page) => {
|
||||
const body = await getPadBody(page);
|
||||
await body.click();
|
||||
await page.keyboard.type('contrast smoke');
|
||||
await page.waitForTimeout(300);
|
||||
// The author span is the inner-frame <span class="author-..."> wrapping
|
||||
// the typed text. Read its computed bg + the inherited text colour.
|
||||
const result = await page.frame('ace_inner')!.evaluate(() => {
|
||||
const span = document.querySelector(
|
||||
'#innerdocbody span[class*="author-"]:not([class*="anonymous"])') as HTMLElement | null;
|
||||
if (!span) return null;
|
||||
const cs = getComputedStyle(span);
|
||||
return {bg: cs.backgroundColor, color: cs.color};
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
test.describe('WCAG author colour (issue #7377)', () => {
|
||||
test('issue scenario: #9AB3FA renders >= AA against the author text', async ({page}) => {
|
||||
await setUserColor(page, '#9AB3FA');
|
||||
const r = await renderedAuthorContrast(page);
|
||||
expect(r, 'expected an author-coloured span in the pad').not.toBeNull();
|
||||
const ratio = wcagRatio(r!.bg, r!.color);
|
||||
expect(ratio, `bg=${r!.bg} color=${r!.color} ratio=${ratio.toFixed(3)}`)
|
||||
.toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
test('pure red #ff0000 renders >= AA after the clamp', async ({page}) => {
|
||||
await setUserColor(page, '#ff0000');
|
||||
const r = await renderedAuthorContrast(page);
|
||||
expect(r).not.toBeNull();
|
||||
const ratio = wcagRatio(r!.bg, r!.color);
|
||||
expect(ratio, `bg=${r!.bg} color=${r!.color} ratio=${ratio.toFixed(3)}`)
|
||||
.toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
test('already-AA-friendly #ffeedd is rendered unchanged', async ({page}) => {
|
||||
await setUserColor(page, '#ffeedd');
|
||||
const r = await renderedAuthorContrast(page);
|
||||
expect(r).not.toBeNull();
|
||||
// #ffeedd → rgb(255, 238, 221). Clamp must NOT mutate this.
|
||||
expect(r!.bg).toBe('rgb(255, 238, 221)');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user