Skip to content
Merged
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
30 changes: 6 additions & 24 deletions src/node/utils/SkinColors.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,16 @@
'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';
import {toolbarColorForTokens} from '../../static/js/skin_toolbar_colors';

// 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.
// configured skin and skinVariants. Only the colibris skin has a known
// mapping (see src/static/js/skin_toolbar_colors). For any other skin we
// cannot derive the toolbar color server-side and return null 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;
return toolbarColorForTokens((skinVariants || '').split(/\s+/).filter(Boolean));
};
31 changes: 31 additions & 0 deletions src/static/js/skin_toolbar_colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use strict';

// Toolbar background colors that the colibris skin variants resolve to.
// Mirrors --bg-color in src/static/skins/colibris/src/pad-variants.css. Lives
// here (under static/js/) so both the browser bundle (skin_variants.ts) and
// the server-side EJS helper (node/utils/SkinColors.ts) can import it without
// duplication — a drift between client and server tables would silently
// reintroduce the "address bar disagrees with toolbar" bug.
//
// 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.
export const TOOLBAR_COLORS_IN_CSS_ORDER: ReadonlyArray<readonly [string, string]> = [
['super-light-toolbar', '#ffffff'],
['light-toolbar', '#f2f3f4'],
['super-dark-toolbar', '#485365'],
['dark-toolbar', '#576273'],
];

export const COLIBRIS_DEFAULT_TOOLBAR_COLOR = '#ffffff';

// Resolve the toolbar color for a set of skin-variant tokens. Pure data: no
// DOM, no Node APIs — safe to call from both server and client.
export const toolbarColorForTokens = (tokens: Iterable<string>): string => {
const set = new Set(tokens);
let color = COLIBRIS_DEFAULT_TOOLBAR_COLOR;
for (const [variant, c] of TOOLBAR_COLORS_IN_CSS_ORDER) {
if (set.has(variant)) color = c;
}
return color;
};
20 changes: 20 additions & 0 deletions src/static/js/skin_variants.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
// @ts-nocheck
'use strict';

import {toolbarColorForTokens} from './skin_toolbar_colors';

const containers = ['editor', 'background', 'toolbar'];
const colors = ['super-light', 'light', 'dark', 'super-dark'];

// Keep <meta name="theme-color"> in sync with the toolbar the user actually
// sees. The server emits a baseline derived from settings.skinVariants, but
// pad.ts may flip the toolbar to super-dark on first paint (enableDarkMode
// + prefers-color-scheme:dark + no localStorage white-mode override) and
// the user can toggle via #options-darkmode. Without this, dark-mode users
// keep the light meta and see a white address bar above a dark toolbar
// (issue #7606 follow-up). Color resolution lives in skin_toolbar_colors so
// the server-rendered baseline and the client updates share one source of
// truth — Qodo flagged the prior duplicated table as a drift hazard.
const updateThemeColorMeta = (newClasses: string[]) => {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) return;
meta.setAttribute('content',
toolbarColorForTokens(newClasses.join(' ').split(/\s+/).filter(Boolean)));
};

// add corresponding classes when config change
const updateSkinVariantsClasses = (newClasses) => {
const domsToUpdate = [
Expand All @@ -21,6 +39,8 @@ const updateSkinVariantsClasses = (newClasses) => {
domsToUpdate.forEach((el) => { el.removeClass('full-width-editor'); });

domsToUpdate.forEach((el) => { el.addClass(newClasses.join(' ')); });

updateThemeColorMeta(newClasses);
};


Expand Down
42 changes: 42 additions & 0 deletions src/tests/frontend-new/specs/theme_color_dark_mode.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {expect, test, Page} from '@playwright/test';
import {goToNewPad} from '../helper/padHelper';

const themeColor = (page: Page) =>
page.locator('meta[name="theme-color"]').getAttribute('content');

test.describe('light color scheme', () => {
test.use({colorScheme: 'light'});

test('theme-color meta tracks the dark-mode toggle', async ({page}) => {
await goToNewPad(page);
// Server emits the light baseline derived from settings.skinVariants.
expect(await themeColor(page)).toBe('#ffffff');

await page.locator('button[data-l10n-id="pad.toolbar.settings.title"]').click();
await expect(page.locator('#theme-toggle-row')).toBeVisible();

// Colibris styles the native checkbox via a sibling label; click the label
// so the toggle fires the real change event the production code listens on.
await page.locator('label[for="options-darkmode"]').click();
// pad.ts forces super-dark-toolbar (#485365) regardless of the configured
// light skinVariants, so the meta must follow the client-applied class.
await expect.poll(() => themeColor(page)).toBe('#485365');

await page.locator('label[for="options-darkmode"]').click();
await expect.poll(() => themeColor(page)).toBe('#ffffff');
});
});

test.describe('dark color scheme', () => {
test.use({colorScheme: 'dark'});

test('theme-color meta follows the auto dark-mode switch on dark-OS clients',
async ({page}) => {
await goToNewPad(page);
// pad.ts auto-switches to super-dark-toolbar when enableDarkMode is on,
// matchMedia(prefers-color-scheme:dark) matches, and no localStorage
// white-mode override is set. The meta must follow the applied class —
// this is the case stffen reported on issue #7606.
await expect.poll(() => themeColor(page)).toBe('#485365');
});
});
Loading