diff --git a/src/node/utils/SkinColors.ts b/src/node/utils/SkinColors.ts
new file mode 100644
index 00000000000..c599b4c3816
--- /dev/null
+++ b/src/node/utils/SkinColors.ts
@@ -0,0 +1,34 @@
+'use strict';
+
+// Toolbar background colors that the colibris skin variants resolve to.
+// Mirrors --bg-color in src/static/skins/colibris/src/pad-variants.css. Only
+// the colibris skin has a known mapping; for any other skin we cannot derive
+// the toolbar color server-side and emit no theme-color meta.
+//
+// Order matters: when skinVariants contains multiple *-toolbar tokens the
+// CSS cascade picks the rule defined last in pad-variants.css, so iterate in
+// source order and let the last matching token win.
+const TOOLBAR_COLORS_IN_CSS_ORDER: Array<[string, string]> = [
+ ['super-light-toolbar', '#ffffff'],
+ ['light-toolbar', '#f2f3f4'],
+ ['super-dark-toolbar', '#485365'],
+ ['dark-toolbar', '#576273'],
+];
+
+const COLIBRIS_DEFAULT_TOOLBAR_COLOR = '#ffffff';
+
+// The toolbar color the user actually sees on first paint, derived from the
+// configured skin and skinVariants. Returns null when the skin is unknown so
+// callers can omit the meta rather than emit a misleading value.
+export const configuredToolbarColor = (
+ skinName: string | undefined | null,
+ skinVariants: string | undefined | null,
+): string | null => {
+ if (skinName !== 'colibris') return null;
+ const tokens = new Set((skinVariants || '').split(/\s+/).filter(Boolean));
+ let color: string | null = null;
+ for (const [variant, c] of TOOLBAR_COLORS_IN_CSS_ORDER) {
+ if (tokens.has(variant)) color = c;
+ }
+ return color || COLIBRIS_DEFAULT_TOOLBAR_COLOR;
+};
diff --git a/src/templates/pad.html b/src/templates/pad.html
index 46d0c942e78..fd16e3c6cb9 100644
--- a/src/templates/pad.html
+++ b/src/templates/pad.html
@@ -1,10 +1,17 @@
<%
var langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs
, pluginUtils = require('ep_etherpad-lite/static/js/pluginfw/shared')
+ , skinColors = require('ep_etherpad-lite/node/utils/SkinColors')
;
var renderLang = (req && typeof req.acceptsLanguages === 'function'
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
+ // theme-color matches the configured toolbar so mobile address bars don't
+ // paint a mismatched system color above the toolbar on first paint. We do
+ // not emit a prefers-color-scheme: dark variant: the client-side dark-mode
+ // auto-switch is gated on enableDarkMode, matchMedia, and a localStorage
+ // white-mode override, none of which a media query can express.
+ var configuredColor = skinColors.configuredToolbarColor(settings.skinName, settings.skinVariants);
%>
@@ -41,6 +48,7 @@
+ <% if (configuredColor) { %><% } %>
<% e.begin_block("styles"); %>
diff --git a/src/templates/timeslider.html b/src/templates/timeslider.html
index d39f3232c10..82dbe29d0a0 100644
--- a/src/templates/timeslider.html
+++ b/src/templates/timeslider.html
@@ -1,8 +1,10 @@
<%
var langs = require("ep_etherpad-lite/node/hooks/i18n").availableLangs
+ var skinColors = require('ep_etherpad-lite/node/utils/SkinColors');
var renderLang = (req && typeof req.acceptsLanguages === 'function'
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
+ var themeColor = skinColors.configuredToolbarColor(settings.skinName, settings.skinVariants);
%>
@@ -36,6 +38,7 @@
+ <% if (themeColor) { %><% } %>
<% e.begin_block("timesliderStyles"); %>
diff --git a/src/tests/backend-new/specs/SkinColors.ts b/src/tests/backend-new/specs/SkinColors.ts
new file mode 100644
index 00000000000..ea79784abc5
--- /dev/null
+++ b/src/tests/backend-new/specs/SkinColors.ts
@@ -0,0 +1,38 @@
+import {configuredToolbarColor} from "../../../node/utils/SkinColors";
+import {expect, describe, it} from "vitest";
+
+describe('SkinColors.configuredToolbarColor', function () {
+ it('returns null for non-colibris skins so the meta is omitted', function () {
+ expect(configuredToolbarColor('no-skin', 'super-light-toolbar')).toBeNull();
+ expect(configuredToolbarColor(null, 'super-light-toolbar')).toBeNull();
+ expect(configuredToolbarColor('custom-skin', 'dark-toolbar')).toBeNull();
+ });
+
+ it('returns the colibris default when no toolbar token is set', function () {
+ expect(configuredToolbarColor('colibris', '')).toBe('#ffffff');
+ expect(configuredToolbarColor('colibris', null)).toBe('#ffffff');
+ expect(configuredToolbarColor('colibris', 'full-width-editor')).toBe('#ffffff');
+ });
+
+ it('maps each *-toolbar token to its colibris --bg-color', function () {
+ expect(configuredToolbarColor('colibris', 'super-light-toolbar')).toBe('#ffffff');
+ expect(configuredToolbarColor('colibris', 'light-toolbar')).toBe('#f2f3f4');
+ expect(configuredToolbarColor('colibris', 'super-dark-toolbar')).toBe('#485365');
+ expect(configuredToolbarColor('colibris', 'dark-toolbar')).toBe('#576273');
+ });
+
+ it('respects CSS source order when multiple toolbar tokens are present', function () {
+ // pad-variants.css declares dark-toolbar last, so it wins on tie regardless of token order.
+ expect(configuredToolbarColor('colibris', 'super-light-toolbar dark-toolbar')).toBe('#576273');
+ expect(configuredToolbarColor('colibris', 'dark-toolbar super-light-toolbar')).toBe('#576273');
+ // super-dark-toolbar precedes dark-toolbar in CSS, so dark wins when both are present.
+ expect(configuredToolbarColor('colibris', 'super-dark-toolbar dark-toolbar')).toBe('#576273');
+ // super-dark-toolbar wins over light-toolbar.
+ expect(configuredToolbarColor('colibris', 'light-toolbar super-dark-toolbar')).toBe('#485365');
+ });
+
+ it('ignores unrelated tokens', function () {
+ expect(configuredToolbarColor('colibris', 'super-light-toolbar full-width-editor light-background'))
+ .toBe('#ffffff');
+ });
+});
diff --git a/src/tests/backend/specs/specialpages.ts b/src/tests/backend/specs/specialpages.ts
index 3f0092a6149..d41aade6446 100644
--- a/src/tests/backend/specs/specialpages.ts
+++ b/src/tests/backend/specs/specialpages.ts
@@ -54,4 +54,55 @@ describe(__filename, function () {
.expect(200);
});
});
+
+ describe('theme-color meta', function () {
+ const backups:MapArrayType = {};
+ beforeEach(function () {
+ backups.skinName = settings.skinName;
+ backups.skinVariants = settings.skinVariants;
+ });
+ afterEach(function () {
+ settings.skinName = backups.skinName;
+ settings.skinVariants = backups.skinVariants;
+ });
+
+ it('pad page emits theme-color matching the configured colibris toolbar', async function () {
+ settings.skinName = 'colibris';
+ settings.skinVariants = 'super-light-toolbar super-light-editor light-background';
+ const res = await agent.get('/p/testpad').expect(200);
+ assert.match(res.text, //);
+ // No media-query variants — runtime dark-mode also depends on localStorage,
+ // which a server-rendered media query cannot account for.
+ assert.doesNotMatch(res.text, /prefers-color-scheme/);
+ });
+
+ it('pad page tracks an explicit dark toolbar variant', async function () {
+ settings.skinName = 'colibris';
+ settings.skinVariants = 'dark-toolbar dark-editor dark-background';
+ const res = await agent.get('/p/testpad').expect(200);
+ assert.match(res.text, //);
+ });
+
+ it('pad page omits theme-color for non-colibris skins', async function () {
+ settings.skinName = 'no-skin';
+ settings.skinVariants = 'super-light-toolbar';
+ const res = await agent.get('/p/testpad').expect(200);
+ assert.doesNotMatch(res.text, /theme-color/);
+ });
+
+ it('timeslider page emits theme-color matching the configured toolbar', async function () {
+ settings.skinName = 'colibris';
+ settings.skinVariants = 'super-dark-toolbar super-dark-editor dark-background';
+ const res = await agent.get('/p/testpad/timeslider').expect(200);
+ assert.match(res.text, //);
+ assert.doesNotMatch(res.text, /prefers-color-scheme/);
+ });
+
+ it('timeslider page omits theme-color for non-colibris skins', async function () {
+ settings.skinName = 'no-skin';
+ settings.skinVariants = 'super-light-toolbar';
+ const res = await agent.get('/p/testpad/timeslider').expect(200);
+ assert.doesNotMatch(res.text, /theme-color/);
+ });
+ });
});