fix(colors): pick WCAG-higher-contrast text for author colors#7565
fix(colors): pick WCAG-higher-contrast text for author colors#7565JohnMcLear merged 7 commits intoether:developfrom
Conversation
Review Summary by Qodo(Agentic_describe updated until commit f33f1ea)Fix WCAG AA compliance for author background colours via intelligent clamping
WalkthroughsDescription• Implement WCAG 2.1 contrast ratio helpers for accurate text colour selection • Clamp author background colours to meet AA (4.5:1) via iterative blending • Add skin-aware text colour references matching actual rendered values • Gate feature with padOptions.enforceReadableAuthorColors flag (default true) Diagramflowchart LR
A["Author picks colour<br/>#9AB3FA"] -->|ace2_inner.ts| B["Check enforceReadableAuthorColors"]
B -->|true| C["ensureReadableBackground<br/>clamp to AA"]
B -->|false| D["Skip clamp"]
C -->|blend toward white| E["Recompute contrast"]
E -->|ratio >= 4.5| F["Return clamped hex"]
E -->|ratio < 4.5| E
F -->|textColorFromBackgroundColor| G["Pick higher-contrast text<br/>using WCAG formula"]
D -->|textColorFromBackgroundColor| G
G -->|render| H["Author span with<br/>readable text"]
File Changes1. src/static/js/colorutils.ts
|
Code Review by Qodo
1. Opt-out doesn’t restore old path
|
055b627 to
8602145
Compare
|
I'm not sure how well this will work with ep_themes ? |
|
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. |
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one. |
|
Persistent review updated to latest commit f33f1ea |
| colorutils.textColorFromBackgroundColor = (bgcolor, skinName) => { | ||
| const white = skinName === 'colibris' ? 'var(--super-light-color)' : '#fff'; | ||
| const black = skinName === 'colibris' ? 'var(--super-dark-color)' : '#222'; | ||
| 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; | ||
| }; |
There was a problem hiding this comment.
1. Opt-out doesn’t restore old path 📘 Rule violation ≡ Correctness
Even when padOptions.enforceReadableAuthorColors is set to false, the code still uses the new WCAG contrast-based textColorFromBackgroundColor() logic, so disabling the flag does not restore the pre-change rendering behavior. This violates the requirement that flag-off preserves the pre-existing code path/behavior.
Agent Prompt
## Issue description
Setting `padOptions.enforceReadableAuthorColors: false` does not fully revert to pre-change behavior because `textColorFromBackgroundColor()` is always using the new WCAG contrast-based algorithm.
## Issue Context
To satisfy the default-off/flag-off invariants, turning the flag off must follow the same logic as before this PR (including text color selection).
## Fix Focus Areas
- src/static/js/ace2_inner.ts[246-254]
- src/static/js/colorutils.ts[154-160]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| // 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; |
There was a problem hiding this comment.
2. Hardcoded skin ref color 🐞 Bug ≡ Correctness
SKIN_TEXT_COLORS hardcodes colibris’ rendered dark text reference as #485365; if a deployment overrides --super-dark-color, ensureReadableBackground/textColorFromBackgroundColor will compute contrast against the wrong color and can still render sub-AA combinations. Colibris’ CSS explicitly encourages overriding these variables, so drift is a realistic configuration.
Agent Prompt
### Issue description
`SKIN_TEXT_COLORS.colibris.darkRef` is hardcoded to `#485365`, but colibris’ `--super-dark-color` is a CSS variable that is explicitly meant to be customized. If it is overridden, WCAG contrast comparisons and background clamping can be computed against the wrong rendered text color.
### Issue Context
- `textColorFromBackgroundColor()` and `ensureReadableBackground()` rely on `darkRef/lightRef` to match the *actually rendered* colors.
- Colibris’ main colors are defined via CSS variables and can be retuned.
### Fix Focus Areas
- src/static/js/colorutils.ts[137-192]
- src/static/skins/colibris/pad.css[27-45]
### Suggested approach
- When running in a browser (DOM available), read the computed values of the relevant CSS variables (e.g. `--super-dark-color`, `--super-light-color`) via `getComputedStyle(...)` and use those for `*Ref`.
- Keep the current hardcoded values as a non-DOM fallback (server-side usage/tests).
- Add/adjust tests to cover overridden variable scenarios (if test harness supports injecting CSS).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| // 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); | ||
| } |
There was a problem hiding this comment.
3. Clamp not applied everywhere 🐞 Bug ≡ Correctness
The WCAG clamp is only applied in ace2_inner’s setAuthorStyle, but other author-color renderers still apply raw author colors without ensureReadableBackground. This means low-contrast author colors can still render (and the new setting won’t take effect) in timeslider/broadcast-related views.
Agent Prompt
### Issue description
`ensureReadableBackground()` is applied to author span styling in `ace2_inner.ts`, but timeslider/broadcast rendering paths still set author background colors without clamping, so WCAG-AA failures can persist outside the main editor view.
### Issue Context
Relevant non-editor renderers:
- `broadcast.ts` sets background and uses `luminosity(css2triple(bgcolor))` with hard-coded `#ffffff/#000000`.
- `broadcast_slider.ts` uses `textColorFromBackgroundColor()` but still applies the *raw* `authorColor` background.
### Fix Focus Areas
- src/static/js/broadcast.ts[558-567]
- src/static/js/broadcast_slider.ts[141-159]
- src/static/js/colorutils.ts[151-192]
### Suggested approach
- In `broadcast.ts.receiveAuthorData()`: if `bgcolor` is a CSS hex, run it through `colorutils.ensureReadableBackground(bgcolor, clientVars.skinName)` before assigning `selector.backgroundColor`, then use `colorutils.textColorFromBackgroundColor()` for `selector.color`.
- In `broadcast_slider.ts.setAuthors()`: clamp `authorColor` before applying `.css('background-color', ...)`, and compute `textColor` from the clamped background.
- Respect the same opt-out flag if `clientVars.padOptions?.enforceReadableAuthorColors === false` is available in those pages; otherwise default to enforcing (matching ace2_inner’s behavior).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
SamTV12345
left a comment
There was a problem hiding this comment.
I am okay with the code. But would it not be better to have this in the client. So a person with sight problems could just choose this mode?
|
@SamTV12345 TBH it's better UX to have this irrespective, it is better for people with and without accessibility requirements as it provides a nicer UX. |
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>
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>
#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>
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>
f33f1ea to
dd9b986
Compare
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