From 29f0a1160aeebf28bf3f049846e8ea75c7e8ee3b Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 30 Apr 2026 07:17:47 +0100 Subject: [PATCH 1/5] feat(pad): add matching toolbar (#7606) Mobile browsers paint the address-bar / status-bar area above the viewport. Without theme-color this is a system color that does not match the Etherpad toolbar, leaving a visible gap above the pad. Render server-side so the bar matches the configured toolbar on first paint. Light + dark variants are emitted with prefers-color-scheme media queries when dark mode is enabled. Colors are derived from settings.skinVariants via a new SkinColors helper (mirrors --bg-color in the colibris pad-variants.css). Closes #7606 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/node/utils/SkinColors.ts | 30 +++++++++++++ src/templates/pad.html | 4 ++ src/templates/timeslider.html | 4 ++ src/tests/backend-new/specs/SkinColors.ts | 42 +++++++++++++++++ src/tests/backend/specs/specialpages.ts | 55 +++++++++++++++++++++++ 5 files changed, 135 insertions(+) create mode 100644 src/node/utils/SkinColors.ts create mode 100644 src/tests/backend-new/specs/SkinColors.ts diff --git a/src/node/utils/SkinColors.ts b/src/node/utils/SkinColors.ts new file mode 100644 index 00000000000..895a52bf06c --- /dev/null +++ b/src/node/utils/SkinColors.ts @@ -0,0 +1,30 @@ +'use strict'; + +// Toolbar background colors that the colibris skin variants resolve to. +// Mirrors --bg-color in src/static/skins/colibris/src/pad-variants.css so +// that can match the toolbar on first paint +// (before client-side JS runs). +const TOOLBAR_COLORS: {[variant: string]: string} = { + 'super-light-toolbar': '#ffffff', + 'light-toolbar': '#f2f3f4', + 'dark-toolbar': '#576273', + 'super-dark-toolbar': '#485365', +}; + +const DEFAULT_LIGHT = '#ffffff'; +const DEFAULT_DARK = '#485365'; + +export const toolbarThemeColors = (skinVariants: string | undefined | null) => { + const tokens = (skinVariants || '').split(/\s+/).filter(Boolean); + let light = DEFAULT_LIGHT; + let dark = DEFAULT_DARK; + for (const token of tokens) { + const color = TOOLBAR_COLORS[token]; + if (!color) continue; + if (token.includes('dark')) dark = color; + else light = color; + } + return {light, dark}; +}; + +module.exports = {toolbarThemeColors}; diff --git a/src/templates/pad.html b/src/templates/pad.html index 46d0c942e78..90f0a3c9e1f 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -1,10 +1,12 @@ <% 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'; + var themeColors = skinColors.toolbarThemeColors(settings.skinVariants); %> @@ -41,6 +43,8 @@ + + <% if (settings.enableDarkMode) { %><% } %> <% e.begin_block("styles"); %> diff --git a/src/templates/timeslider.html b/src/templates/timeslider.html index d39f3232c10..e49f58dae32 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 themeColors = skinColors.toolbarThemeColors(settings.skinVariants); %> @@ -36,6 +38,8 @@ + + <% if (settings.enableDarkMode) { %><% } %> <% 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..89e14e8fe84 --- /dev/null +++ b/src/tests/backend-new/specs/SkinColors.ts @@ -0,0 +1,42 @@ +import {toolbarThemeColors} from "../../../node/utils/SkinColors"; +import {expect, describe, it} from "vitest"; + +describe('SkinColors.toolbarThemeColors', function () { + it('returns defaults for an empty skinVariants string', function () { + expect(toolbarThemeColors('')).toEqual({light: '#ffffff', dark: '#485365'}); + }); + + it('returns defaults for null/undefined', function () { + expect(toolbarThemeColors(null)).toEqual({light: '#ffffff', dark: '#485365'}); + expect(toolbarThemeColors(undefined)).toEqual({light: '#ffffff', dark: '#485365'}); + }); + + it('maps super-light-toolbar to white', function () { + expect(toolbarThemeColors('super-light-toolbar super-light-editor light-background').light) + .toBe('#ffffff'); + }); + + it('maps light-toolbar to its --light-color value', function () { + expect(toolbarThemeColors('light-toolbar').light).toBe('#f2f3f4'); + }); + + it('maps dark-toolbar to its --dark-color value', function () { + expect(toolbarThemeColors('dark-toolbar').dark).toBe('#576273'); + }); + + it('maps super-dark-toolbar to its --super-dark-color value', function () { + expect(toolbarThemeColors('super-dark-toolbar').dark).toBe('#485365'); + }); + + it('ignores unrelated tokens', function () { + const colors = toolbarThemeColors('super-light-toolbar full-width-editor light-background'); + expect(colors.light).toBe('#ffffff'); + expect(colors.dark).toBe('#485365'); + }); + + it('handles a mix of light and dark toolbar tokens', function () { + const colors = toolbarThemeColors('light-toolbar dark-toolbar'); + expect(colors.light).toBe('#f2f3f4'); + expect(colors.dark).toBe('#576273'); + }); +}); diff --git a/src/tests/backend/specs/specialpages.ts b/src/tests/backend/specs/specialpages.ts index 3f0092a6149..7ef78ed724c 100644 --- a/src/tests/backend/specs/specialpages.ts +++ b/src/tests/backend/specs/specialpages.ts @@ -54,4 +54,59 @@ describe(__filename, function () { .expect(200); }); }); + + describe('theme-color meta', function () { + const backupVariants:MapArrayType = {}; + beforeEach(function () { + backupVariants.skinVariants = settings.skinVariants; + backupVariants.enableDarkMode = settings.enableDarkMode; + }); + afterEach(function () { + settings.skinVariants = backupVariants.skinVariants; + settings.enableDarkMode = backupVariants.enableDarkMode; + }); + + it('pad page emits theme-color matching the configured toolbar', async function () { + settings.skinVariants = 'super-light-toolbar super-light-editor light-background'; + settings.enableDarkMode = true; + const res = await agent.get('/p/testpad').expect(200); + assert.match( + res.text, + //); + assert.match( + res.text, + //); + }); + + it('pad page omits dark theme-color when dark mode is disabled', async function () { + settings.skinVariants = 'super-light-toolbar super-light-editor light-background'; + settings.enableDarkMode = false; + const res = await agent.get('/p/testpad').expect(200); + assert.match( + res.text, + //); + assert.doesNotMatch(res.text, /prefers-color-scheme: dark/); + }); + + it('pad page picks up an explicit dark toolbar variant', async function () { + settings.skinVariants = 'dark-toolbar dark-editor dark-background'; + settings.enableDarkMode = true; + const res = await agent.get('/p/testpad').expect(200); + assert.match( + res.text, + //); + }); + + it('timeslider page emits theme-color', async function () { + settings.skinVariants = 'super-light-toolbar super-light-editor light-background'; + settings.enableDarkMode = true; + const res = await agent.get('/p/testpad/timeslider').expect(200); + assert.match( + res.text, + //); + assert.match( + res.text, + //); + }); + }); }); From 7b41829d11fe1fe7971165b73cd42275af65e2f0 Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 30 Apr 2026 07:28:43 +0100 Subject: [PATCH 2/5] fix(timeslider): emit single theme-color matching configured toolbar Qodo flagged a mismatch: timeslider does not switch skin variants on prefers-color-scheme, so emitting a dark theme-color via media query would leave dark-mode devices with a dark address bar over a light toolbar. Drop the media-query metas on timeslider and emit one unconditional theme-color resolved from settings.skinVariants. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/node/utils/SkinColors.ts | 15 ++++++++++++++- src/templates/timeslider.html | 8 +++++--- src/tests/backend-new/specs/SkinColors.ts | 20 +++++++++++++++++++- src/tests/backend/specs/specialpages.ts | 19 ++++++++++++------- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/node/utils/SkinColors.ts b/src/node/utils/SkinColors.ts index 895a52bf06c..bc3c4e3a0cb 100644 --- a/src/node/utils/SkinColors.ts +++ b/src/node/utils/SkinColors.ts @@ -27,4 +27,17 @@ export const toolbarThemeColors = (skinVariants: string | undefined | null) => { return {light, dark}; }; -module.exports = {toolbarThemeColors}; +// The toolbar color that the configured skinVariants resolves to with no +// client-side dark-mode toggling. Used by pages (e.g. timeslider) that do not +// switch skin variants based on prefers-color-scheme, so that theme-color +// always matches what the user actually sees. +export const configuredToolbarColor = (skinVariants: string | undefined | null) => { + const tokens = (skinVariants || '').split(/\s+/).filter(Boolean); + for (const token of tokens) { + const color = TOOLBAR_COLORS[token]; + if (color) return color; + } + return DEFAULT_LIGHT; +}; + +module.exports = {toolbarThemeColors, configuredToolbarColor}; diff --git a/src/templates/timeslider.html b/src/templates/timeslider.html index e49f58dae32..e44e5a73a93 100644 --- a/src/templates/timeslider.html +++ b/src/templates/timeslider.html @@ -4,7 +4,10 @@ var renderLang = (req && typeof req.acceptsLanguages === 'function' && req.acceptsLanguages(Object.keys(langs))) || 'en'; var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr'; - var themeColors = skinColors.toolbarThemeColors(settings.skinVariants); + // Timeslider does not switch skin variants based on prefers-color-scheme, so + // emit a single theme-color matching the configured toolbar to avoid a + // dark address-bar over a light toolbar mismatch. + var themeColor = skinColors.configuredToolbarColor(settings.skinVariants); %> @@ -38,8 +41,7 @@ - - <% if (settings.enableDarkMode) { %><% } %> + <% e.begin_block("timesliderStyles"); %> diff --git a/src/tests/backend-new/specs/SkinColors.ts b/src/tests/backend-new/specs/SkinColors.ts index 89e14e8fe84..b8716e95c0d 100644 --- a/src/tests/backend-new/specs/SkinColors.ts +++ b/src/tests/backend-new/specs/SkinColors.ts @@ -1,4 +1,4 @@ -import {toolbarThemeColors} from "../../../node/utils/SkinColors"; +import {toolbarThemeColors, configuredToolbarColor} from "../../../node/utils/SkinColors"; import {expect, describe, it} from "vitest"; describe('SkinColors.toolbarThemeColors', function () { @@ -40,3 +40,21 @@ describe('SkinColors.toolbarThemeColors', function () { expect(colors.dark).toBe('#576273'); }); }); + +describe('SkinColors.configuredToolbarColor', function () { + it('returns the default light color when no toolbar token is set', function () { + expect(configuredToolbarColor('')).toBe('#ffffff'); + expect(configuredToolbarColor(null)).toBe('#ffffff'); + expect(configuredToolbarColor('full-width-editor')).toBe('#ffffff'); + }); + + it('returns the configured light toolbar color', function () { + expect(configuredToolbarColor('super-light-toolbar super-light-editor')).toBe('#ffffff'); + expect(configuredToolbarColor('light-toolbar')).toBe('#f2f3f4'); + }); + + it('returns the configured dark toolbar color', function () { + expect(configuredToolbarColor('dark-toolbar dark-editor')).toBe('#576273'); + expect(configuredToolbarColor('super-dark-toolbar')).toBe('#485365'); + }); +}); diff --git a/src/tests/backend/specs/specialpages.ts b/src/tests/backend/specs/specialpages.ts index 7ef78ed724c..db1c5b56efc 100644 --- a/src/tests/backend/specs/specialpages.ts +++ b/src/tests/backend/specs/specialpages.ts @@ -97,16 +97,21 @@ describe(__filename, function () { //); }); - it('timeslider page emits theme-color', async function () { + it('timeslider page emits a single theme-color matching the configured toolbar', async function () { settings.skinVariants = 'super-light-toolbar super-light-editor light-background'; settings.enableDarkMode = true; const res = await agent.get('/p/testpad/timeslider').expect(200); - assert.match( - res.text, - //); - assert.match( - res.text, - //); + assert.match(res.text, //); + // No prefers-color-scheme variants — timeslider does not switch skin variants on OS theme. + assert.doesNotMatch(res.text, /prefers-color-scheme/); + }); + + it('timeslider page picks up an explicitly dark configured toolbar', async function () { + settings.skinVariants = 'super-dark-toolbar super-dark-editor dark-background'; + settings.enableDarkMode = true; + const res = await agent.get('/p/testpad/timeslider').expect(200); + assert.match(res.text, //); + assert.doesNotMatch(res.text, /prefers-color-scheme/); }); }); }); From dcedf724c8c79712902e4526229eb32f956e7aa3 Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 30 Apr 2026 07:35:36 +0100 Subject: [PATCH 3/5] fix(pad): emit unconditional theme-color so dark-OS users still match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo flagged that gating the light theme-color on prefers-color-scheme: light leaves no applicable meta on dark-OS devices when enableDarkMode is false — the address bar then uses a system color while the toolbar stays light. Drop the light media query so the light theme-color is the baseline, and let the prefers-color-scheme: dark meta override it when dark mode is enabled. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/templates/pad.html | 2 +- src/tests/backend/specs/specialpages.ts | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/templates/pad.html b/src/templates/pad.html index 90f0a3c9e1f..5839f6d9b3b 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -43,7 +43,7 @@ - + <% if (settings.enableDarkMode) { %><% } %> diff --git a/src/tests/backend/specs/specialpages.ts b/src/tests/backend/specs/specialpages.ts index db1c5b56efc..b717a42e8ca 100644 --- a/src/tests/backend/specs/specialpages.ts +++ b/src/tests/backend/specs/specialpages.ts @@ -70,9 +70,9 @@ describe(__filename, function () { settings.skinVariants = 'super-light-toolbar super-light-editor light-background'; settings.enableDarkMode = true; const res = await agent.get('/p/testpad').expect(200); - assert.match( - res.text, - //); + // Unconditional light theme-color so dark-OS users with dark mode disabled + // still match the (light) toolbar. + assert.match(res.text, //); assert.match( res.text, //); @@ -82,10 +82,8 @@ describe(__filename, function () { settings.skinVariants = 'super-light-toolbar super-light-editor light-background'; settings.enableDarkMode = false; const res = await agent.get('/p/testpad').expect(200); - assert.match( - res.text, - //); - assert.doesNotMatch(res.text, /prefers-color-scheme: dark/); + assert.match(res.text, //); + assert.doesNotMatch(res.text, /prefers-color-scheme/); }); it('pad page picks up an explicit dark toolbar variant', async function () { From d6753ccf6a393faff932149199a0eb41c9827949 Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 30 Apr 2026 08:09:25 +0100 Subject: [PATCH 4/5] fix(theme-color): align dark meta with client-side super-dark override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related Qodo findings on the SkinColors helper: - The pad client's dark-mode auto-switch (pad.ts L650) forces super-dark-toolbar regardless of the configured skinVariants, so the prefers-color-scheme: dark meta must always be #485365 — not whichever dark variant the operator configured. - When skinVariants only carries a dark token (e.g. dark-toolbar), the previous helper left the baseline meta at #ffffff, so light-OS users would see white above a dark toolbar. Replace toolbarThemeColors() with configuredToolbarColor() (used as the unconditional baseline) and a fixed DARK_MODE_TOOLBAR_COLOR constant (used in the prefers-color-scheme: dark meta). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/node/utils/SkinColors.ts | 31 ++++++---------- src/templates/pad.html | 11 ++++-- src/tests/backend-new/specs/SkinColors.ts | 44 ++++------------------- src/tests/backend/specs/specialpages.ts | 9 +++-- 4 files changed, 32 insertions(+), 63 deletions(-) diff --git a/src/node/utils/SkinColors.ts b/src/node/utils/SkinColors.ts index bc3c4e3a0cb..b5ecfd22365 100644 --- a/src/node/utils/SkinColors.ts +++ b/src/node/utils/SkinColors.ts @@ -11,33 +11,24 @@ const TOOLBAR_COLORS: {[variant: string]: string} = { 'super-dark-toolbar': '#485365', }; -const DEFAULT_LIGHT = '#ffffff'; -const DEFAULT_DARK = '#485365'; +const DEFAULT_TOOLBAR_COLOR = '#ffffff'; -export const toolbarThemeColors = (skinVariants: string | undefined | null) => { - const tokens = (skinVariants || '').split(/\s+/).filter(Boolean); - let light = DEFAULT_LIGHT; - let dark = DEFAULT_DARK; - for (const token of tokens) { - const color = TOOLBAR_COLORS[token]; - if (!color) continue; - if (token.includes('dark')) dark = color; - else light = color; - } - return {light, dark}; -}; +// The colibris dark-mode auto-switch in pad.ts forces the toolbar variant to +// 'super-dark-toolbar' regardless of what skinVariants was configured with, so +// the prefers-color-scheme: dark theme-color meta must always resolve to that +// color rather than to whatever dark variant the operator picked. +export const DARK_MODE_TOOLBAR_COLOR = TOOLBAR_COLORS['super-dark-toolbar']; -// The toolbar color that the configured skinVariants resolves to with no -// client-side dark-mode toggling. Used by pages (e.g. timeslider) that do not -// switch skin variants based on prefers-color-scheme, so that theme-color -// always matches what the user actually sees. +// The toolbar color that the configured skinVariants resolves to (the color +// the user sees before any client-side dark-mode override). Returns the first +// recognized *-toolbar token; falls back to the default light color. export const configuredToolbarColor = (skinVariants: string | undefined | null) => { const tokens = (skinVariants || '').split(/\s+/).filter(Boolean); for (const token of tokens) { const color = TOOLBAR_COLORS[token]; if (color) return color; } - return DEFAULT_LIGHT; + return DEFAULT_TOOLBAR_COLOR; }; -module.exports = {toolbarThemeColors, configuredToolbarColor}; +module.exports = {DARK_MODE_TOOLBAR_COLOR, configuredToolbarColor}; diff --git a/src/templates/pad.html b/src/templates/pad.html index 5839f6d9b3b..f8c341f35be 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -6,7 +6,12 @@ var renderLang = (req && typeof req.acceptsLanguages === 'function' && req.acceptsLanguages(Object.keys(langs))) || 'en'; var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr'; - var themeColors = skinColors.toolbarThemeColors(settings.skinVariants); + // The baseline theme-color matches the configured toolbar (whatever skinVariants + // resolved to). When dark mode is enabled, the client-side override forces the + // toolbar to super-dark-toolbar on dark-OS clients, so the dark media-query + // meta uses that fixed color rather than anything derived from skinVariants. + var configuredColor = skinColors.configuredToolbarColor(settings.skinVariants); + var darkModeColor = skinColors.DARK_MODE_TOOLBAR_COLOR; %> @@ -43,8 +48,8 @@ - - <% if (settings.enableDarkMode) { %><% } %> + + <% if (settings.enableDarkMode) { %><% } %> <% e.begin_block("styles"); %> diff --git a/src/tests/backend-new/specs/SkinColors.ts b/src/tests/backend-new/specs/SkinColors.ts index b8716e95c0d..12bd32adbbf 100644 --- a/src/tests/backend-new/specs/SkinColors.ts +++ b/src/tests/backend-new/specs/SkinColors.ts @@ -1,43 +1,11 @@ -import {toolbarThemeColors, configuredToolbarColor} from "../../../node/utils/SkinColors"; +import {DARK_MODE_TOOLBAR_COLOR, configuredToolbarColor} from "../../../node/utils/SkinColors"; import {expect, describe, it} from "vitest"; -describe('SkinColors.toolbarThemeColors', function () { - it('returns defaults for an empty skinVariants string', function () { - expect(toolbarThemeColors('')).toEqual({light: '#ffffff', dark: '#485365'}); - }); - - it('returns defaults for null/undefined', function () { - expect(toolbarThemeColors(null)).toEqual({light: '#ffffff', dark: '#485365'}); - expect(toolbarThemeColors(undefined)).toEqual({light: '#ffffff', dark: '#485365'}); - }); - - it('maps super-light-toolbar to white', function () { - expect(toolbarThemeColors('super-light-toolbar super-light-editor light-background').light) - .toBe('#ffffff'); - }); - - it('maps light-toolbar to its --light-color value', function () { - expect(toolbarThemeColors('light-toolbar').light).toBe('#f2f3f4'); - }); - - it('maps dark-toolbar to its --dark-color value', function () { - expect(toolbarThemeColors('dark-toolbar').dark).toBe('#576273'); - }); - - it('maps super-dark-toolbar to its --super-dark-color value', function () { - expect(toolbarThemeColors('super-dark-toolbar').dark).toBe('#485365'); - }); - - it('ignores unrelated tokens', function () { - const colors = toolbarThemeColors('super-light-toolbar full-width-editor light-background'); - expect(colors.light).toBe('#ffffff'); - expect(colors.dark).toBe('#485365'); - }); - - it('handles a mix of light and dark toolbar tokens', function () { - const colors = toolbarThemeColors('light-toolbar dark-toolbar'); - expect(colors.light).toBe('#f2f3f4'); - expect(colors.dark).toBe('#576273'); +describe('SkinColors.DARK_MODE_TOOLBAR_COLOR', function () { + it('matches the super-dark-toolbar color forced by client-side dark mode', function () { + // pad.ts swaps to super-dark-toolbar on dark OS, so the dark theme-color + // must match that fixed value, not whatever was configured in skinVariants. + expect(DARK_MODE_TOOLBAR_COLOR).toBe('#485365'); }); }); diff --git a/src/tests/backend/specs/specialpages.ts b/src/tests/backend/specs/specialpages.ts index b717a42e8ca..506ff05fb8d 100644 --- a/src/tests/backend/specs/specialpages.ts +++ b/src/tests/backend/specs/specialpages.ts @@ -86,13 +86,18 @@ describe(__filename, function () { assert.doesNotMatch(res.text, /prefers-color-scheme/); }); - it('pad page picks up an explicit dark toolbar variant', async function () { + it('pad page baseline theme-color tracks an explicit dark toolbar variant', async function () { settings.skinVariants = 'dark-toolbar dark-editor dark-background'; settings.enableDarkMode = true; const res = await agent.get('/p/testpad').expect(200); + // Baseline meta matches the configured toolbar (#576273 = dark-toolbar) so + // light-OS users see the right color. + assert.match(res.text, //); + // The dark media-query meta is hardcoded to super-dark because pad.ts + // forces super-dark-toolbar on dark-OS clients regardless of skinVariants. assert.match( res.text, - //); + //); }); it('timeslider page emits a single theme-color matching the configured toolbar', async function () { From 51115af1312ba836775d8f20ecbae5c8a798b149 Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 30 Apr 2026 08:23:08 +0100 Subject: [PATCH 5/5] refactor(theme-color): server-side only, drop fragile dark media query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address remaining Qodo findings on the theme-color rollout: - (#1) Skip emitting the meta entirely when settings.skinName is not colibris — the helper only knows colibris's --bg-color values, so on no-skin or third-party skins the previous code would emit a white meta over a non-white toolbar. - (#4) Drop the prefers-color-scheme: dark variant. The pad's client-side dark mode is also gated on a localStorage white-mode override that no media query can express, so the dark meta could paint a dark address bar over a still-light toolbar. The single baseline meta always matches what the user sees on first paint. - (#8) Remove the redundant module.exports assignment; rely on the ES named export only (tsx handles the require() interop). - (#9) Iterate the toolbar variants in CSS source order and let the last match win, matching the cascade in pad-variants.css when multiple *-toolbar tokens are present. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/node/utils/SkinColors.ts | 54 ++++++++++---------- src/templates/pad.html | 15 +++--- src/templates/timeslider.html | 7 +-- src/tests/backend-new/specs/SkinColors.ts | 46 ++++++++++------- src/tests/backend/specs/specialpages.ts | 62 +++++++++-------------- 5 files changed, 89 insertions(+), 95 deletions(-) diff --git a/src/node/utils/SkinColors.ts b/src/node/utils/SkinColors.ts index b5ecfd22365..c599b4c3816 100644 --- a/src/node/utils/SkinColors.ts +++ b/src/node/utils/SkinColors.ts @@ -1,34 +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 so -// that can match the toolbar on first paint -// (before client-side JS runs). -const TOOLBAR_COLORS: {[variant: string]: string} = { - 'super-light-toolbar': '#ffffff', - 'light-toolbar': '#f2f3f4', - 'dark-toolbar': '#576273', - 'super-dark-toolbar': '#485365', -}; - -const DEFAULT_TOOLBAR_COLOR = '#ffffff'; +// 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'], +]; -// The colibris dark-mode auto-switch in pad.ts forces the toolbar variant to -// 'super-dark-toolbar' regardless of what skinVariants was configured with, so -// the prefers-color-scheme: dark theme-color meta must always resolve to that -// color rather than to whatever dark variant the operator picked. -export const DARK_MODE_TOOLBAR_COLOR = TOOLBAR_COLORS['super-dark-toolbar']; +const COLIBRIS_DEFAULT_TOOLBAR_COLOR = '#ffffff'; -// The toolbar color that the configured skinVariants resolves to (the color -// the user sees before any client-side dark-mode override). Returns the first -// recognized *-toolbar token; falls back to the default light color. -export const configuredToolbarColor = (skinVariants: string | undefined | null) => { - const tokens = (skinVariants || '').split(/\s+/).filter(Boolean); - for (const token of tokens) { - const color = TOOLBAR_COLORS[token]; - if (color) return color; +// 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 DEFAULT_TOOLBAR_COLOR; + return color || COLIBRIS_DEFAULT_TOOLBAR_COLOR; }; - -module.exports = {DARK_MODE_TOOLBAR_COLOR, configuredToolbarColor}; diff --git a/src/templates/pad.html b/src/templates/pad.html index f8c341f35be..fd16e3c6cb9 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -6,12 +6,12 @@ var renderLang = (req && typeof req.acceptsLanguages === 'function' && req.acceptsLanguages(Object.keys(langs))) || 'en'; var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr'; - // The baseline theme-color matches the configured toolbar (whatever skinVariants - // resolved to). When dark mode is enabled, the client-side override forces the - // toolbar to super-dark-toolbar on dark-OS clients, so the dark media-query - // meta uses that fixed color rather than anything derived from skinVariants. - var configuredColor = skinColors.configuredToolbarColor(settings.skinVariants); - var darkModeColor = skinColors.DARK_MODE_TOOLBAR_COLOR; + // 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); %> @@ -48,8 +48,7 @@ - - <% if (settings.enableDarkMode) { %><% } %> + <% if (configuredColor) { %><% } %> <% e.begin_block("styles"); %> diff --git a/src/templates/timeslider.html b/src/templates/timeslider.html index e44e5a73a93..82dbe29d0a0 100644 --- a/src/templates/timeslider.html +++ b/src/templates/timeslider.html @@ -4,10 +4,7 @@ var renderLang = (req && typeof req.acceptsLanguages === 'function' && req.acceptsLanguages(Object.keys(langs))) || 'en'; var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr'; - // Timeslider does not switch skin variants based on prefers-color-scheme, so - // emit a single theme-color matching the configured toolbar to avoid a - // dark address-bar over a light toolbar mismatch. - var themeColor = skinColors.configuredToolbarColor(settings.skinVariants); + var themeColor = skinColors.configuredToolbarColor(settings.skinName, settings.skinVariants); %> @@ -41,7 +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 index 12bd32adbbf..ea79784abc5 100644 --- a/src/tests/backend-new/specs/SkinColors.ts +++ b/src/tests/backend-new/specs/SkinColors.ts @@ -1,28 +1,38 @@ -import {DARK_MODE_TOOLBAR_COLOR, configuredToolbarColor} from "../../../node/utils/SkinColors"; +import {configuredToolbarColor} from "../../../node/utils/SkinColors"; import {expect, describe, it} from "vitest"; -describe('SkinColors.DARK_MODE_TOOLBAR_COLOR', function () { - it('matches the super-dark-toolbar color forced by client-side dark mode', function () { - // pad.ts swaps to super-dark-toolbar on dark OS, so the dark theme-color - // must match that fixed value, not whatever was configured in skinVariants. - expect(DARK_MODE_TOOLBAR_COLOR).toBe('#485365'); +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(); }); -}); -describe('SkinColors.configuredToolbarColor', function () { - it('returns the default light color when no toolbar token is set', function () { - expect(configuredToolbarColor('')).toBe('#ffffff'); - expect(configuredToolbarColor(null)).toBe('#ffffff'); - expect(configuredToolbarColor('full-width-editor')).toBe('#ffffff'); + 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('returns the configured light toolbar color', function () { - expect(configuredToolbarColor('super-light-toolbar super-light-editor')).toBe('#ffffff'); - expect(configuredToolbarColor('light-toolbar')).toBe('#f2f3f4'); + 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('returns the configured dark toolbar color', function () { - expect(configuredToolbarColor('dark-toolbar dark-editor')).toBe('#576273'); - expect(configuredToolbarColor('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 506ff05fb8d..d41aade6446 100644 --- a/src/tests/backend/specs/specialpages.ts +++ b/src/tests/backend/specs/specialpages.ts @@ -56,65 +56,53 @@ describe(__filename, function () { }); describe('theme-color meta', function () { - const backupVariants:MapArrayType = {}; + const backups:MapArrayType = {}; beforeEach(function () { - backupVariants.skinVariants = settings.skinVariants; - backupVariants.enableDarkMode = settings.enableDarkMode; + backups.skinName = settings.skinName; + backups.skinVariants = settings.skinVariants; }); afterEach(function () { - settings.skinVariants = backupVariants.skinVariants; - settings.enableDarkMode = backupVariants.enableDarkMode; + settings.skinName = backups.skinName; + settings.skinVariants = backups.skinVariants; }); - it('pad page emits theme-color matching the configured toolbar', async function () { + 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'; - settings.enableDarkMode = true; - const res = await agent.get('/p/testpad').expect(200); - // Unconditional light theme-color so dark-OS users with dark mode disabled - // still match the (light) toolbar. - assert.match(res.text, //); - assert.match( - res.text, - //); - }); - - it('pad page omits dark theme-color when dark mode is disabled', async function () { - settings.skinVariants = 'super-light-toolbar super-light-editor light-background'; - settings.enableDarkMode = false; 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 baseline theme-color tracks an explicit dark toolbar variant', async function () { + it('pad page tracks an explicit dark toolbar variant', async function () { + settings.skinName = 'colibris'; settings.skinVariants = 'dark-toolbar dark-editor dark-background'; - settings.enableDarkMode = true; const res = await agent.get('/p/testpad').expect(200); - // Baseline meta matches the configured toolbar (#576273 = dark-toolbar) so - // light-OS users see the right color. assert.match(res.text, //); - // The dark media-query meta is hardcoded to super-dark because pad.ts - // forces super-dark-toolbar on dark-OS clients regardless of skinVariants. - assert.match( - res.text, - //); }); - it('timeslider page emits a single theme-color matching the configured toolbar', async function () { - settings.skinVariants = 'super-light-toolbar super-light-editor light-background'; - settings.enableDarkMode = true; - const res = await agent.get('/p/testpad/timeslider').expect(200); - assert.match(res.text, //); - // No prefers-color-scheme variants — timeslider does not switch skin variants on OS theme. - assert.doesNotMatch(res.text, /prefers-color-scheme/); + 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 picks up an explicitly dark configured toolbar', async function () { + 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'; - settings.enableDarkMode = true; 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/); + }); }); });