Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion settings.json.docker
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
},

/*
Expand Down
9 changes: 8 additions & 1 deletion settings.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
},

/*
Expand Down
2 changes: 2 additions & 0 deletions src/node/utils/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export type SettingsType = {
chatAndUsers: boolean,
lang: string | null,
fadeInactiveAuthorColors: boolean,
enforceReadableAuthorColors: boolean,
},
enableMetrics: boolean,
padShortcutEnabled: {
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/static/js/ace2_inner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment on lines +250 to +259
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

const textColor =
colorutils.textColorFromBackgroundColor(bgcolor, window.clientVars.skinName);
const styles = [
Expand Down
78 changes: 75 additions & 3 deletions src/static/js/colorutils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,83 @@ colorutils.complementary = (c) => {
];
};

// --- 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.
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;
Comment on lines +143 to +149
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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


// 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 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;
};
Comment on lines 154 to +160
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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


return colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5 ? white : black;
// 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;
Loading
Loading