mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-05-05 12:16:45 +02:00
* 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:
parent
4704d80e82
commit
63cae17720
34
src/node/utils/SkinColors.ts
Normal file
34
src/node/utils/SkinColors.ts
Normal 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;
|
||||
};
|
||||
@ -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"); %>
|
||||
|
||||
@ -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%>">
|
||||
|
||||
38
src/tests/backend-new/specs/SkinColors.ts
Normal file
38
src/tests/backend-new/specs/SkinColors.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user