From e0a989094d98c0b13598b0be35c5be7fd1572566 Mon Sep 17 00:00:00 2001
From: John McLear
Date: Sun, 3 May 2026 04:01:13 +0800
Subject: [PATCH] feat(colors): add padOptions.fadeInactiveAuthorColors with
toolbar UI (#7554)
Adds a new pad option `fadeInactiveAuthorColors` (default `true`) that controls whether each author's caret/background fades toward white as they go inactive. Configurable server-side (`settings.json` / `PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS`), per-pad in the Pad Settings panel, per-user in the My View panel, or via `?fadeInactiveAuthorColors=false`.
Disabling the fade is useful on busy pads where every faded author visually counts as a second on-screen color (a 30-author pad becomes a 60-color pad), or when inactivity tracking is undesirable for whatever reason.
Closes #7138.
---
doc/docker.md | 1 +
settings.json.docker | 3 +-
settings.json.template | 8 ++-
src/locales/en.json | 1 +
src/node/db/Pad.ts | 4 ++
src/node/utils/Settings.ts | 2 +
src/static/js/ace2_inner.ts | 19 ++++++-
src/static/js/pad.ts | 14 +++++
src/static/js/pad_editor.ts | 16 ++++++
src/templates/pad.html | 8 +++
src/tests/backend/specs/settings.ts | 11 ++++
.../specs/inactive_color_fade.spec.ts | 53 +++++++++++++++++++
12 files changed, 137 insertions(+), 3 deletions(-)
create mode 100644 src/tests/frontend-new/specs/inactive_color_fade.spec.ts
diff --git a/doc/docker.md b/doc/docker.md
index fe31ea031..8e22ba518 100644
--- a/doc/docker.md
+++ b/doc/docker.md
@@ -115,6 +115,7 @@ 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_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` |
### Shortcuts
diff --git a/settings.json.docker b/settings.json.docker
index 97a027469..1f1d6d05b 100644
--- a/settings.json.docker
+++ b/settings.json.docker
@@ -318,7 +318,8 @@
"rtl": "${PAD_OPTIONS_RTL:false}",
"alwaysShowChat": "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}",
"chatAndUsers": "${PAD_OPTIONS_CHAT_AND_USERS:false}",
- "lang": "${PAD_OPTIONS_LANG:null}"
+ "lang": "${PAD_OPTIONS_LANG:null}",
+ "fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}"
},
/*
diff --git a/settings.json.template b/settings.json.template
index 3b5148318..4fc59819c 100644
--- a/settings.json.template
+++ b/settings.json.template
@@ -302,7 +302,13 @@
"rtl": false,
"alwaysShowChat": false,
"chatAndUsers": false,
- "lang": null
+ "lang": null,
+ /*
+ * When true (default), each author's caret/background color fades toward white
+ * as the author goes inactive. Set to false if users pick light colors and the
+ * faded variants become visually indistinguishable.
+ */
+ "fadeInactiveAuthorColors": true
},
/*
diff --git a/src/locales/en.json b/src/locales/en.json
index a9943ea6e..e33cdef8b 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -108,6 +108,7 @@
"pad.settings.stickychat": "Chat always on screen",
"pad.settings.chatandusers": "Show Chat and Users",
"pad.settings.colorcheck": "Authorship colors",
+ "pad.settings.fadeInactiveAuthorColors": "Fade inactive author colors",
"pad.settings.linenocheck": "Line numbers",
"pad.settings.rtlcheck": "Read content from right to left?",
"pad.settings.enforceSettings": "Enforce settings for other users",
diff --git a/src/node/db/Pad.ts b/src/node/db/Pad.ts
index e2e1f0830..ad135f21c 100644
--- a/src/node/db/Pad.ts
+++ b/src/node/db/Pad.ts
@@ -33,6 +33,7 @@ type PadViewSettings = {
showLineNumbers: boolean;
rtlIsTrue: boolean;
padFontFamily: string;
+ fadeInactiveAuthorColors: boolean;
};
type PadSettings = {
@@ -103,6 +104,9 @@ class Pad {
settings.padOptions.showLineNumbers !== false : !!rawView.showLineNumbers,
rtlIsTrue: !!rawView.rtlIsTrue,
padFontFamily: typeof rawView.padFontFamily === 'string' ? rawView.padFontFamily : '',
+ fadeInactiveAuthorColors: rawView.fadeInactiveAuthorColors == null ?
+ settings.padOptions.fadeInactiveAuthorColors !== false :
+ !!rawView.fadeInactiveAuthorColors,
},
};
}
diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts
index ebedc6937..26876cd08 100644
--- a/src/node/utils/Settings.ts
+++ b/src/node/utils/Settings.ts
@@ -206,6 +206,7 @@ export type SettingsType = {
alwaysShowChat: boolean,
chatAndUsers: boolean,
lang: string | null,
+ fadeInactiveAuthorColors: boolean,
},
enableMetrics: boolean,
padShortcutEnabled: {
@@ -439,6 +440,7 @@ const settings: SettingsType = {
alwaysShowChat: false,
chatAndUsers: false,
lang: null,
+ fadeInactiveAuthorColors: true,
},
/**
* Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this.
diff --git a/src/static/js/ace2_inner.ts b/src/static/js/ace2_inner.ts
index 04b7569fc..b0b7df0d1 100644
--- a/src/static/js/ace2_inner.ts
+++ b/src/static/js/ace2_inner.ts
@@ -141,6 +141,8 @@ function Ace2Inner(editorInfo, cssManagers) {
let doesWrap = true;
let hasLineNumbers = true;
let isStyled = true;
+ let fadeInactiveAuthorColors =
+ window.clientVars?.padOptions?.fadeInactiveAuthorColors !== false;
let console = (DEBUG && window.console);
@@ -236,7 +238,13 @@ function Ace2Inner(editorInfo, cssManagers) {
cssManagers.parent.removeSelectorStyle(authorSelector);
} else if (info.bgcolor) {
let bgcolor = info.bgcolor;
- if ((typeof info.fade) === 'number') {
+ // The fade is controlled at runtime by the fadeInactiveAuthorColors flag (default
+ // true), which tracks padOptions.view.fadeInactiveAuthorColors and is updated by
+ // ace_setProperty when the user/pad-settings checkbox flips. Disabling it keeps
+ // each author's background at their chosen value — useful on busy pads where each
+ // faded author would otherwise count as a second on-screen color, or when
+ // inactivity tracking is undesirable for whatever reason.
+ if (fadeInactiveAuthorColors && (typeof info.fade) === 'number') {
bgcolor = fadeColor(bgcolor, info.fade);
}
const textColor =
@@ -667,6 +675,15 @@ function Ace2Inner(editorInfo, cssManagers) {
targetBody.classList.toggle('ltr', !value);
document.documentElement.dir = value ? 'rtl' : 'ltr';
},
+ fadeinactiveauthorcolors: (value) => {
+ fadeInactiveAuthorColors = `${value}` !== 'false';
+ // Re-apply styles for every known author so that pre-faded backgrounds
+ // refresh immediately when the toggle flips, instead of waiting for the
+ // next fade tick (which only fires on join/leave).
+ for (const [author, info] of Object.entries(authorInfos)) {
+ if (info) setAuthorStyle(author, info);
+ }
+ },
};
const setter = setters[key.toLowerCase()];
diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts
index cc49f458e..4a97bf902 100644
--- a/src/static/js/pad.ts
+++ b/src/static/js/pad.ts
@@ -71,6 +71,15 @@ const getParameters = [
$('#clearAuthorship').hide();
},
},
+ {
+ name: 'fadeInactiveAuthorColors',
+ checkVal: 'false',
+ callback: (val) => {
+ if (!clientVars.initialOptions) return;
+ if (!clientVars.initialOptions.view) clientVars.initialOptions.view = {};
+ clientVars.initialOptions.view.fadeInactiveAuthorColors = false;
+ },
+ },
{
name: 'showControls',
checkVal: 'true',
@@ -214,6 +223,7 @@ const getMyViewOverrides = () => {
showLineNumbers: padcookie.getPref('showLineNumbers'),
rtlIsTrue: padcookie.getPref('rtlIsTrue'),
padFontFamily: padcookie.getPref('padFontFamily'),
+ fadeInactiveAuthorColors: padcookie.getPref('fadeInactiveAuthorColors'),
},
};
if (language == null) delete overrides.lang;
@@ -549,6 +559,8 @@ const pad = {
$('#padsettings-options-stickychat').prop('checked', !!padOptions.alwaysShowChat);
$('#padsettings-options-chatandusers').prop('checked', !!padOptions.chatAndUsers);
$('#padsettings-options-colorscheck').prop('checked', view.showAuthorColors !== false);
+ $('#padsettings-options-fadeauthorcheck')
+ .prop('checked', view.fadeInactiveAuthorColors !== false);
$('#padsettings-options-linenoscheck').prop('checked', view.showLineNumbers !== false);
$('#padsettings-options-rtlcheck').prop('checked', !!view.rtlIsTrue);
$('#padsettings-viewfontmenu').val(view.padFontFamily || '');
@@ -565,6 +577,8 @@ const pad = {
$('#options-stickychat').prop('checked', !!effectiveOptions.alwaysShowChat);
$('#options-chatandusers').prop('checked', !!effectiveOptions.chatAndUsers);
$('#options-colorscheck').prop('checked', effectiveOptions.view?.showAuthorColors !== false);
+ $('#options-fadeauthorcheck')
+ .prop('checked', effectiveOptions.view?.fadeInactiveAuthorColors !== false);
$('#options-linenoscheck').prop('checked', effectiveOptions.view?.showLineNumbers !== false);
$('#options-rtlcheck').prop('checked', !!effectiveOptions.view?.rtlIsTrue);
$('#viewfontmenu').val(effectiveOptions.view?.padFontFamily || '');
diff --git a/src/static/js/pad_editor.ts b/src/static/js/pad_editor.ts
index a828bb38d..96db7879a 100644
--- a/src/static/js/pad_editor.ts
+++ b/src/static/js/pad_editor.ts
@@ -68,6 +68,11 @@ const padeditor = (() => {
padutils.bindCheckboxChange($('#options-colorscheck'), () => {
pad.setMyViewOption('showAuthorColors', padutils.getCheckbox($('#options-colorscheck')));
});
+ padutils.bindCheckboxChange($('#options-fadeauthorcheck'), () => {
+ pad.setMyViewOption(
+ 'fadeInactiveAuthorColors',
+ padutils.getCheckbox($('#options-fadeauthorcheck')));
+ });
padutils.bindCheckboxChange($('#options-linenoscheck'), () => {
pad.setMyViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck')));
});
@@ -108,6 +113,13 @@ const padeditor = (() => {
'showAuthorColors', padutils.getCheckbox('#padsettings-options-colorscheck'));
});
+ // Fade inactive author colors
+ padutils.bindCheckboxChange($('#padsettings-options-fadeauthorcheck'), () => {
+ pad.changePadViewOption(
+ 'fadeInactiveAuthorColors',
+ padutils.getCheckbox('#padsettings-options-fadeauthorcheck'));
+ });
+
// Right to left
padutils.bindCheckboxChange($('#padsettings-options-rtlcheck'), () => {
pad.changePadViewOption(
@@ -248,6 +260,10 @@ const padeditor = (() => {
$('iframe[name="ace_outer"]').contents().find('#sidedivinner').toggleClass('authorColors', v);
padutils.setCheckbox($('#options-colorscheck'), v);
+ v = getOption('fadeInactiveAuthorColors', true);
+ self.ace.setProperty('fadeInactiveAuthorColors', v);
+ padutils.setCheckbox($('#options-fadeauthorcheck'), v);
+
// Override from parameters if true
if (settings.noColors !== false) {
self.ace.setProperty('showsauthorcolors', !settings.noColors);
diff --git a/src/templates/pad.html b/src/templates/pad.html
index c1715a375..ecac35ad6 100644
--- a/src/templates/pad.html
+++ b/src/templates/pad.html
@@ -156,6 +156,10 @@
+
+
+
+
@@ -217,6 +221,10 @@
+
+
+
+
diff --git a/src/tests/backend/specs/settings.ts b/src/tests/backend/specs/settings.ts
index cf515fcac..23290156f 100644
--- a/src/tests/backend/specs/settings.ts
+++ b/src/tests/backend/specs/settings.ts
@@ -186,4 +186,15 @@ describe(__filename, function () {
}
});
});
+
+ // Regression test for ether/etherpad#7138.
+ // padOptions.fadeInactiveAuthorColors must default to true so existing
+ // installations keep the legacy fade-on-inactive behavior, and must be
+ // overridable via PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS in docker.
+ describe('padOptions.fadeInactiveAuthorColors (issue #7138)', function () {
+ it('defaults to true so existing deployments are unchanged', function () {
+ const settings = require('../../../node/utils/Settings');
+ assert.strictEqual(settings.padOptions.fadeInactiveAuthorColors, true);
+ });
+ });
});
diff --git a/src/tests/frontend-new/specs/inactive_color_fade.spec.ts b/src/tests/frontend-new/specs/inactive_color_fade.spec.ts
new file mode 100644
index 000000000..b070a369a
--- /dev/null
+++ b/src/tests/frontend-new/specs/inactive_color_fade.spec.ts
@@ -0,0 +1,53 @@
+import {expect, test} from "@playwright/test";
+import {appendQueryParams, goToNewPad} from "../helper/padHelper";
+import {showSettings} from "../helper/settingsHelper";
+
+test.beforeEach(async ({page}) => {
+ // clearCookies on the page's own context — `browser.newContext()`
+ // creates a separate context that the `page` fixture doesn't use,
+ // so clearing cookies on it is a no-op (Qodo review feedback).
+ await page.context().clearCookies();
+ await goToNewPad(page);
+});
+
+test.describe('fadeInactiveAuthorColors (issue #7138)', function () {
+ test('server-side default is true (legacy fade behavior preserved)', async function ({page}) {
+ const fade = await page.evaluate(
+ () => (window as any).clientVars?.padOptions?.fadeInactiveAuthorColors);
+ expect(fade).toBe(true);
+ });
+
+ test('per-pad view default propagates from server settings', async function ({page}) {
+ const fade = await page.evaluate(
+ () => (window as any).clientVars?.initialOptions?.view?.fadeInactiveAuthorColors);
+ expect(fade).toBe(true);
+ });
+
+ test('?fadeInactiveAuthorColors=false flips the per-pad view value', async function ({page}) {
+ await appendQueryParams(page, {fadeInactiveAuthorColors: 'false'});
+ const fade = await page.evaluate(
+ () => (window as any).clientVars?.initialOptions?.view?.fadeInactiveAuthorColors);
+ expect(fade).toBe(false);
+ });
+
+ test('My View checkbox toggles the per-user cookie', async function ({page}) {
+ // Open the settings popup, untick the box, confirm the cookie pref now
+ // overrides the server-default.
+ await showSettings(page);
+ const checkbox = page.locator('#options-fadeauthorcheck');
+ await expect(checkbox).toBeChecked();
+ // The label is i18n'd, not hardcoded — assert the localized string actually
+ // rendered (catches missing keys, not just a present DOM node).
+ await expect(page.locator('label[for="options-fadeauthorcheck"]'))
+ .toHaveText('Fade inactive author colors');
+ await page.locator('label[for="options-fadeauthorcheck"]').click();
+ await expect(checkbox).not.toBeChecked();
+ // padcookie stores prefs as a single JSON cookie. The name is `prefs` over
+ // HTTPS and `prefsHttp` over HTTP, optionally with a `cookiePrefix`.
+ const cookies = await page.context().cookies();
+ const prefsCookie = cookies.find((c) => /(prefs|prefsHttp)$/.test(c.name));
+ expect(prefsCookie, 'expected a prefs cookie after toggling').toBeDefined();
+ const decoded = JSON.parse(decodeURIComponent(prefsCookie!.value));
+ expect(decoded.fadeInactiveAuthorColors).toBe(false);
+ });
+});