Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/node/utils/SkinColors.ts
Original file line number Diff line number Diff line change
@@ -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;
};
8 changes: 8 additions & 0 deletions src/templates/pad.html
Original file line number Diff line number Diff line change
@@ -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%>">
Expand Down Expand Up @@ -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%>"><% } %>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. theme-color skipped for non-colibris 📎 Requirement gap ≡ Correctness

pad.html only emits <meta name="theme-color"> when configuredToolbarColor() returns a value,
but that helper returns null for any skinName other than colibris. This means pads using
no-skin or third-party skins will not include the meta tag, failing the requirement that pad HTML
output includes a theme-color meta matching the active theme.
Agent Prompt
## Issue description
`pad.html` conditionally omits `<meta name="theme-color">` for non-colibris skins because `configuredToolbarColor()` returns `null` unless `skinName === 'colibris'`. This violates the requirement that pad HTML output includes a theme-color meta whose content matches the active theme's toolbar color.

## Issue Context
The current implementation avoids emitting a potentially wrong color for unknown skins, but the compliance requirement is explicit about always including the meta and matching the active theme.

## Fix Focus Areas
- src/templates/pad.html[9-14]
- src/templates/pad.html[51-51]
- src/node/utils/SkinColors.ts[23-33]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

<link rel="shortcut icon" href="../favicon.ico">

<% e.begin_block("styles"); %>
Expand Down
3 changes: 3 additions & 0 deletions src/templates/timeslider.html
Original file line number Diff line number Diff line change
@@ -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%>">
Expand Down Expand Up @@ -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%>">
Expand Down
38 changes: 38 additions & 0 deletions src/tests/backend-new/specs/SkinColors.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
51 changes: 51 additions & 0 deletions src/tests/backend/specs/specialpages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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