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/); + }); + }); });