feat(pad): add theme-color meta to match toolbar on mobile (#7606) (#7636)

* feat(pad): add <meta name="theme-color"> 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 <meta name="theme-color"> 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* fix(pad): emit unconditional theme-color so dark-OS users still match

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) <noreply@anthropic.com>

* fix(theme-color): align dark meta with client-side super-dark override

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) <noreply@anthropic.com>

* refactor(theme-color): server-side only, drop fragile dark media query

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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-01 17:25:24 +08:00 committed by GitHub
parent 4704d80e82
commit 63cae17720
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 134 additions and 0 deletions

View File

@ -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;
};

View File

@ -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);
%>
<!doctype html>
<html lang="<%=renderLang%>" dir="<%=renderDir%>" translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
@ -41,6 +48,7 @@
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<% if (configuredColor) { %><meta name="theme-color" content="<%=configuredColor%>"><% } %>
<link rel="shortcut icon" href="../favicon.ico">
<% e.begin_block("styles"); %>

View File

@ -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);
%>
<!doctype html>
<html lang="<%=renderLang%>" dir="<%=renderDir%>" translate="no" class="pad <%=settings.skinVariants%>">
@ -36,6 +38,7 @@
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<% if (themeColor) { %><meta name="theme-color" content="<%=themeColor%>"><% } %>
<link rel="shortcut icon" href="../../favicon.ico">
<% e.begin_block("timesliderStyles"); %>
<link rel="stylesheet" href="../../static/css/pad.css?v=<%=settings.randomVersionString%>">

View File

@ -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');
});
});

View File

@ -54,4 +54,55 @@ describe(__filename, function () {
.expect(200);
});
});
describe('theme-color meta', function () {
const backups:MapArrayType<any> = {};
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, /<meta name="theme-color" content="#ffffff">/);
// 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, /<meta name="theme-color" content="#576273">/);
});
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, /<meta name="theme-color" content="#485365">/);
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/);
});
});
});