fix(colors): pick WCAG-higher-contrast text for author colors#7565
fix(colors): pick WCAG-higher-contrast text for author colors#7565JohnMcLear wants to merge 7 commits intoether:developfrom
Conversation
Fixes ether#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>
Review Summary by QodoClamp author backgrounds to WCAG 2.1 AA on render
WalkthroughsDescription• Add WCAG 2.1 AA contrast helpers to ensure author background colors are readable • Implement viewer-side color clamping that lightens dark backgrounds while preserving hue • Add enforceReadableAuthorColors setting (default true) to control the feature • Include comprehensive unit tests for luminance, contrast ratio, and background clamping Diagramflowchart LR
A["Author picks color<br/>e.g. #cc0000"] -->|stored unchanged| B["Author's color<br/>in database"]
B -->|viewer-side render| C["Check contrast<br/>with black text"]
C -->|fails AA 4.5:1| D["Iteratively blend<br/>toward white"]
D -->|preserve hue| E["Readable color<br/>e.g. #ff9999"]
C -->|passes AA| F["Use original color"]
E -->|render with| G["Black text on<br/>readable background"]
F -->|render with| G
File Changes1. src/node/utils/Settings.ts
|
Code Review by Qodo
1.
|
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>
Pure primaries like #ff0000 cannot clear WCAG AA (4.5:1) against either ether#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>
First cut of textColorFromBackgroundColor computed contrast against pure black (L=0) and pure white (L=1), then returned the concrete ether#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 ether#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>
055b627 to
8602145
Compare
#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>
|
I'm not sure how well this will work with ep_themes ? |
Local repro of the issue exposed two real bugs in the previous fix: 1. textColorFromBackgroundColor compared bg against a hardcoded ether#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 ether#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>
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>
|
Tested locally with ep_themes v11.0.17 installed and active. The running pad reports How it interacts:
Net: ep_themes doesn't break the fix; the worst case is a future enhancement to make the per-variant refs even more accurate. |
Summary
Fixes #7377. Local repro of the issue revealed two real bugs the previous draft of this PR did not solve — the rendered behaviour on the issue's exact inputs was unchanged from the pre-fix.
Bugs found while testing locally
Wrong reference colour.
textColorFromBackgroundColorcompared the bg against a hardcoded#222, but in the colibris skin--super-dark-coloractually resolves to#485365. For the issue's exact case (#9AB3FAauthor bg) the selector returnedvar(--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 reports.Text-colour selection is not enough. For mid-saturation pastels (
#9AB3FA) and pure primaries (#ff0000), neither rendered dark nor white text can clear AA. Picking the higher-contrast option still leaves the user below 4.5:1. TheensureReadableBackgroundclamp dropped in commit ce0c5c2 was load-bearing — without it, the issue's scenario is provably unfixable by text-colour selection alone.What changed
src/static/js/colorutils.ts:SKIN_TEXT_COLORStable mapping skin name →{darkRef, lightRef, darkOut, lightOut}. The*Refvalues are what the browser actually paints (colibris dark =#485365, default dark =#222), so contrast comparisons match what the user sees. The*Outvalues are what we hand back to CSS (the variable name in colibris, hex literal otherwise).textColorFromBackgroundColorrewritten to use the per-skin refs.ensureReadableBackground(bg, skinName, minContrast = 4.5)re-introduced, but skin-aware and symmetric — picks the better text colour for the skin, then blends the bg toward the OPPOSITE end (white for dark text, black for light text) in 5% increments until AA holds. Non-hex inputs (CSS vars) pass through unchanged.src/static/js/ace2_inner.ts(setAuthorStyle): runs the bg through the clamp before picking text colour. Gated onpadOptions.enforceReadableAuthorColors(defaulttrue).Settings.ts/settings.json.template/settings.json.docker/doc/docker.md: newpadOptions.enforceReadableAuthorColorsflag with matchingPAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORSenv var.src/tests/backend/specs/colorutils.ts: failing-then-green coverage for the issue's exact case (#9AB3FA+ colibris), the previously-flagged-as-impossible#ff0000, the no-mutation case (#ffeeddalready AA-friendly), non-hex pass-through, and a sweep over primaries asserting every one clears AA after the clamp.Why bring the clamp back
The PR's previous test-suite invariant (
'always picks whichever of black/white gives the higher contrast') is technically true but green-misleading: it asserts relative contrast between two options, not absolute AA. The very colour from the issue (#ff0000) only reaches 4.00:1 under that selector. The user's reported scenario (#9AB3FA+ colibris) reaches 3.78:1. Without a bg clamp the fix doesn't actually fix the bug.Authors keep their stored colour. Setting
padOptions.enforceReadableAuthorColors: falserestores raw colours immediately for environments that need exact colour fidelity (e.g. video captioning).Test plan
pnpm run ts-checkclean locally.src/tests/backend/specs/colorutils.ts: 16/16 passing, including failing-then-green tests for the issue scenario.#9AB3FAon colibris reaches ≥4.5:1; without it, 3.78:1 (matches issue screenshot).Closes #7377
🤖 Generated with Claude Code