1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
8 // Get the theme variables from the app resource directory.
9 // This allows per-app variables.
10 ChromeUtils.defineESModuleGetters(lazy, {
11 NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
12 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
13 ThemeContentPropertyList: "resource:///modules/ThemeVariableMap.sys.mjs",
14 ThemeVariableMap: "resource:///modules/ThemeVariableMap.sys.mjs",
17 // Whether the content and chrome areas should always use the same color
18 // scheme (unless user-overridden). Thunderbird uses this.
19 XPCOMUtils.defineLazyPreferenceGetter(
21 "BROWSER_THEME_UNIFIED_COLOR_SCHEME",
22 "browser.theme.unified-color-scheme",
26 const DEFAULT_THEME_ID = "default-theme@mozilla.org";
28 const toolkitVariableMap = [
32 lwtProperty: "accentcolor",
33 processColor(rgbaChannels, element) {
34 if (!rgbaChannels || rgbaChannels.a == 0) {
37 // Remove the alpha channel
38 const { r, g, b } = rgbaChannels;
39 return `rgb(${r}, ${g}, ${b})`;
46 lwtProperty: "textcolor",
47 processColor(rgbaChannels, element) {
49 rgbaChannels = { r: 0, g: 0, b: 0 };
51 // Remove the alpha channel
52 const { r, g, b } = rgbaChannels;
53 return `rgba(${r}, ${g}, ${b})`;
58 "--arrowpanel-background",
66 lwtProperty: "popup_text",
70 "--arrowpanel-border-color",
72 lwtProperty: "popup_border",
76 "--toolbar-field-background-color",
78 lwtProperty: "toolbar_field",
79 fallbackColor: "rgba(255, 255, 255, 0.8)",
85 lwtProperty: "toolbarColor",
91 lwtProperty: "toolbar_text",
95 "--toolbar-field-color",
97 lwtProperty: "toolbar_field_text",
98 fallbackColor: "black",
102 "--toolbar-field-border-color",
104 lwtProperty: "toolbar_field_border",
105 fallbackColor: "transparent",
109 "--toolbar-field-focus-background-color",
111 lwtProperty: "toolbar_field_focus",
112 fallbackProperty: "toolbar_field",
113 fallbackColor: "white",
114 processColor(rgbaChannels, element, propertyOverrides) {
118 // Ensure minimum opacity as this is used behind address bar results.
119 const min_opacity = 0.9;
120 let { r, g, b, a } = rgbaChannels;
121 if (a < min_opacity) {
122 propertyOverrides.set(
123 "toolbar_field_text_focus",
124 _isColorDark(r, g, b) ? "white" : "black"
126 return `rgba(${r}, ${g}, ${b}, ${min_opacity})`;
128 return `rgba(${r}, ${g}, ${b}, ${a})`;
133 "--toolbar-field-focus-color",
135 lwtProperty: "toolbar_field_text_focus",
136 fallbackProperty: "toolbar_field_text",
137 fallbackColor: "black",
141 "--toolbar-field-focus-border-color",
143 lwtProperty: "toolbar_field_border_focus",
147 "--lwt-toolbar-field-highlight",
149 lwtProperty: "toolbar_field_highlight",
150 processColor(rgbaChannels, element) {
154 const { r, g, b, a } = rgbaChannels;
155 return `rgba(${r}, ${g}, ${b}, ${a})`;
160 "--lwt-toolbar-field-highlight-text",
162 lwtProperty: "toolbar_field_highlight_text",
165 // The following 3 are given to the new tab page by contentTheme.js. They are
166 // also exposed here, in the browser chrome, so popups anchored on top of the
167 // new tab page can use them to avoid clashing with the new tab page content.
169 "--newtab-background-color",
171 lwtProperty: "ntp_background",
172 processColor(rgbaChannels) {
176 const { r, g, b } = rgbaChannels;
177 // Drop alpha channel
178 return `rgb(${r}, ${g}, ${b})`;
183 "--newtab-background-color-secondary",
184 { lwtProperty: "ntp_card_background" },
186 ["--newtab-text-primary-color", { lwtProperty: "ntp_text" }],
189 export function LightweightThemeConsumer(aDocument) {
190 this._doc = aDocument;
191 this._win = aDocument.defaultView;
192 this._winId = this._win.docShell.outerWindowID;
194 Services.obs.addObserver(this, "lightweight-theme-styling-update");
196 this.darkThemeMediaQuery = this._win.matchMedia("(-moz-system-dark-theme)");
197 this.darkThemeMediaQuery.addListener(this);
199 const { LightweightThemeManager } = ChromeUtils.importESModule(
200 "resource://gre/modules/LightweightThemeManager.sys.mjs"
202 this._update(LightweightThemeManager.themeData);
204 this._win.addEventListener("unload", this, { once: true });
207 LightweightThemeConsumer.prototype = {
210 observe(aSubject, aTopic, aData) {
211 if (aTopic != "lightweight-theme-styling-update") {
215 let data = aSubject.wrappedJSObject;
216 if (data.window && data.window !== this._winId) {
223 handleEvent(aEvent) {
224 if (aEvent.target == this.darkThemeMediaQuery) {
225 this._update(this._lastData);
229 switch (aEvent.type) {
231 Services.obs.removeObserver(this, "lightweight-theme-styling-update");
232 Services.ppmm.sharedData.delete(`theme/${this._winId}`);
233 this._win = this._doc = null;
234 if (this.darkThemeMediaQuery) {
235 this.darkThemeMediaQuery.removeListener(this);
236 this.darkThemeMediaQuery = null;
243 this._lastData = themeData;
245 const hasDarkTheme = !!themeData.darkTheme;
246 let updateGlobalThemeData = true;
247 let useDarkTheme = (() => {
252 if (this.darkThemeMediaQuery?.matches) {
253 return themeData.darkTheme.id != DEFAULT_THEME_ID;
256 // If enabled, apply the dark theme variant to private browsing windows.
258 !lazy.NimbusFeatures.majorRelease2022.getVariable(
259 "feltPrivacyPBMDarkTheme"
261 !lazy.PrivateBrowsingUtils.isWindowPrivate(this._win) ||
262 lazy.PrivateBrowsingUtils.permanentPrivateBrowsing
266 // When applying the dark theme for a PBM window we need to skip calling
267 // _determineToolbarAndContentTheme, because it applies the color scheme
268 // globally for all windows. Skipping this method also means we don't
269 // switch the content theme to dark.
271 // TODO: On Linux we most likely need to apply the dark theme, but on
272 // Windows and macOS we should be able to render light and dark windows
273 // with the default theme at the same time.
274 updateGlobalThemeData = false;
278 // If this is a per-window dark theme, set the color scheme override so
279 // child BrowsingContexts, such as embedded prompts, get themed
281 // If not, reset the color scheme override field. This is required to reset
282 // the color scheme on theme switch.
283 if (this._win.browsingContext == this._win.browsingContext.top) {
284 if (useDarkTheme && !updateGlobalThemeData) {
285 this._win.browsingContext.prefersColorSchemeOverride = "dark";
287 this._win.browsingContext.prefersColorSchemeOverride = "none";
291 let theme = useDarkTheme ? themeData.darkTheme : themeData.theme;
293 theme = { id: DEFAULT_THEME_ID };
296 let active = (this._active = Object.keys(theme).length);
298 let root = this._doc.documentElement;
300 if (active && theme.headerURL) {
301 root.setAttribute("lwtheme-image", "true");
303 root.removeAttribute("lwtheme-image");
306 let hasTheme = theme.id != DEFAULT_THEME_ID || useDarkTheme;
308 this._setExperiment(active, themeData.experiment, theme.experimental);
309 _setImage(this._win, root, active, "--lwt-header-image", theme.headerURL);
314 "--lwt-additional-images",
315 theme.additionalBackgrounds
317 _setProperties(root, active, theme, hasTheme);
320 if (updateGlobalThemeData) {
321 _determineToolbarAndContentTheme(
328 root.setAttribute("lwtheme", "true");
330 _determineToolbarAndContentTheme(this._doc, null);
331 root.removeAttribute("lwtheme");
334 _setDarkModeAttributes(this._doc, root, theme._processedColors, hasTheme);
336 let contentThemeData = _getContentProperties(this._doc, active, theme);
337 Services.ppmm.sharedData.set(`theme/${this._winId}`, contentThemeData);
338 // We flush sharedData because contentThemeData can be responsible for
339 // painting large background surfaces. If this data isn't delivered to the
340 // content process before about:home is painted, we will paint a default
341 // background and then replace it when sharedData syncs, causing flashing.
342 Services.ppmm.sharedData.flush();
344 this._win.dispatchEvent(new CustomEvent("windowlwthemeupdate"));
347 _setExperiment(active, experiment, properties) {
348 const root = this._doc.documentElement;
349 if (this._lastExperimentData) {
350 const { stylesheet, usedVariables } = this._lastExperimentData;
355 for (const [variable] of usedVariables) {
356 _setProperty(root, false, variable);
361 this._lastExperimentData = {};
363 if (!active || !experiment) {
367 let usedVariables = [];
368 if (properties.colors) {
369 for (const property in properties.colors) {
370 const cssVariable = experiment.colors[property];
371 const value = _rgbaToString(
372 _cssColorToRGBA(root.ownerDocument, properties.colors[property])
374 usedVariables.push([cssVariable, value]);
378 if (properties.images) {
379 for (const property in properties.images) {
380 const cssVariable = experiment.images[property];
383 `url(${properties.images[property]})`,
387 if (properties.properties) {
388 for (const property in properties.properties) {
389 const cssVariable = experiment.properties[property];
390 usedVariables.push([cssVariable, properties.properties[property]]);
393 for (const [variable, value] of usedVariables) {
394 _setProperty(root, true, variable, value);
396 this._lastExperimentData.usedVariables = usedVariables;
398 if (experiment.stylesheet) {
399 /* Stylesheet URLs are validated using WebExtension schemas */
400 let stylesheetAttr = `href="${experiment.stylesheet}" type="text/css"`;
401 let stylesheet = this._doc.createProcessingInstruction(
405 this._doc.insertBefore(stylesheet, root);
406 this._lastExperimentData.stylesheet = stylesheet;
411 function _getContentProperties(doc, active, data) {
416 for (let property in data) {
417 if (lazy.ThemeContentPropertyList.includes(property)) {
418 properties[property] = _cssColorToRGBA(doc, data[property]);
421 if (data.experimental) {
422 for (const property in data.experimental.colors) {
423 if (lazy.ThemeContentPropertyList.includes(property)) {
424 properties[property] = _cssColorToRGBA(
426 data.experimental.colors[property]
430 for (const property in data.experimental.images) {
431 if (lazy.ThemeContentPropertyList.includes(property)) {
432 properties[property] = `url(${data.experimental.images[property]})`;
435 for (const property in data.experimental.properties) {
436 if (lazy.ThemeContentPropertyList.includes(property)) {
437 properties[property] = data.experimental.properties[property];
444 function _setImage(aWin, aRoot, aActive, aVariableName, aURLs) {
445 if (aURLs && !Array.isArray(aURLs)) {
452 aURLs && aURLs.map(v => `url(${aWin.CSS.escape(v)})`).join(", ")
456 function _setProperty(elem, active, variableName, value) {
457 if (active && value) {
458 elem.style.setProperty(variableName, value);
460 elem.style.removeProperty(variableName);
464 function _isToolbarDark(aDoc, aColors) {
465 // We prefer looking at toolbar background first (if it's opaque) because
466 // some text colors can be dark enough for our heuristics, but still
467 // contrast well enough with a dark background, see bug 1743010.
468 if (aColors.toolbarColor) {
469 let color = _cssColorToRGBA(aDoc, aColors.toolbarColor);
471 return _isColorDark(color.r, color.g, color.b);
474 if (aColors.toolbar_text) {
475 let color = _cssColorToRGBA(aDoc, aColors.toolbar_text);
476 return !_isColorDark(color.r, color.g, color.b);
478 // It'd seem sensible to try looking at the "frame" background (accentcolor),
479 // but we don't because some themes that use background images leave it to
480 // black, see bug 1741931.
482 // Fall back to black as per the textcolor processing above.
483 let color = _cssColorToRGBA(aDoc, aColors.textcolor || "black");
484 return !_isColorDark(color.r, color.g, color.b);
487 function _determineToolbarAndContentTheme(
490 aHasDarkTheme = false,
497 const colors = aTheme?._processedColors;
498 function colorSchemeValue(aColorScheme) {
502 switch (aColorScheme) {
516 let toolbarTheme = (function () {
520 let themeValue = colorSchemeValue(aTheme.color_scheme);
521 if (themeValue !== null) {
525 return aIsDarkTheme ? kDark : kLight;
527 return _isToolbarDark(aDoc, colors) ? kDark : kLight;
530 let contentTheme = (function () {
531 if (lazy.BROWSER_THEME_UNIFIED_COLOR_SCHEME) {
537 let themeValue = colorSchemeValue(
538 aTheme.content_color_scheme || aTheme.color_scheme
540 if (themeValue !== null) {
546 Services.prefs.setIntPref("browser.theme.toolbar-theme", toolbarTheme);
547 Services.prefs.setIntPref("browser.theme.content-theme", contentTheme);
551 * Sets dark mode attributes on root, if required. We must do this here,
552 * instead of in each color's processColor function, because multiple colors
554 * @param {Document} doc
555 * @param {Element} root
556 * @param {object} colors
557 * The `_processedColors` object from the object created for our theme.
558 * @param {boolean} hasTheme
560 function _setDarkModeAttributes(doc, root, colors, hasTheme) {
562 let textColor = _cssColorToRGBA(doc, colors.textcolor);
563 if (textColor && !_isColorDark(textColor.r, textColor.g, textColor.b)) {
564 root.setAttribute("lwtheme-brighttext", "true");
566 root.removeAttribute("lwtheme-brighttext");
573 _isToolbarDark(doc, colors) ? "dark" : "light"
576 root.removeAttribute("lwt-toolbar");
579 const setAttribute = function (
582 backgroundPropertyName
584 let dark = _determineIfColorPairIsDark(
588 backgroundPropertyName
591 root.removeAttribute(attribute);
593 root.setAttribute(attribute, dark ? "dark" : "light");
597 setAttribute("lwt-tab-selected", "tab_text", "tab_selected");
598 setAttribute("lwt-toolbar-field", "toolbar_field_text", "toolbar_field");
600 "lwt-toolbar-field-focus",
601 "toolbar_field_text_focus",
602 "toolbar_field_focus"
604 setAttribute("lwt-popup", "popup_text", "popup");
605 setAttribute("lwt-sidebar", "sidebar_text", "sidebar");
609 * Determines if a themed color pair should be considered to have a dark color
610 * scheme. We consider both the background and foreground (i.e. usually text)
611 * colors because some text colors can be dark enough for our heuristics, but
612 * still contrast well enough with a dark background
613 * @param {Document} doc
614 * @param {object} colors
615 * @param {string} foregroundElementId
616 * The key for the foreground element in `colors`.
617 * @param {string} backgroundElementId
618 * The key for the background element in `colors`.
619 * @returns {boolean | null} True if the element should be considered dark, false
620 * if light, null for preferred scheme.
622 function _determineIfColorPairIsDark(
626 backgroundPropertyName
628 if (!colors[backgroundPropertyName] && !colors[textPropertyName]) {
629 // Handles the system theme.
633 let color = _cssColorToRGBA(doc, colors[backgroundPropertyName]);
634 if (color && color.a == 1) {
635 return _isColorDark(color.r, color.g, color.b);
638 color = _cssColorToRGBA(doc, colors[textPropertyName]);
640 // Handles the case where a theme only provides a background color and it is
645 return !_isColorDark(color.r, color.g, color.b);
648 function _setProperties(root, active, themeData, hasTheme) {
649 let propertyOverrides = new Map();
650 let doc = root.ownerDocument;
652 // Copy the theme into _processedColors. We'll replace values with processed
653 // colors if necessary. We copy because some colors (such as those used in
654 // content) are not processed here, but are referenced in places that check
655 // _processedColors. Copying means _processedColors will contain irrelevant
656 // properties like `id`. There aren't too many, so that's OK.
657 themeData._processedColors = { ...themeData };
658 for (let map of [toolkitVariableMap, lazy.ThemeVariableMap]) {
659 for (let [cssVarName, definition] of map) {
668 let elem = optionalElementID
669 ? doc.getElementById(optionalElementID)
671 let val = propertyOverrides.get(lwtProperty) || themeData[lwtProperty];
673 val = _cssColorToRGBA(doc, val);
674 if (!val && fallbackProperty) {
675 val = _cssColorToRGBA(doc, themeData[fallbackProperty]);
677 if (!val && hasTheme && fallbackColor) {
678 val = _cssColorToRGBA(doc, fallbackColor);
681 val = processColor(val, elem, propertyOverrides);
683 val = _rgbaToString(val);
687 // Add processed color to themeData.
688 themeData._processedColors[lwtProperty] = val;
690 _setProperty(elem, active, cssVarName, val);
695 const kInvalidColor = { r: 0, g: 0, b: 0, a: 1 };
697 function _cssColorToRGBA(doc, cssColor) {
702 doc.defaultView.InspectorUtils.colorToRGBA(cssColor, doc) || kInvalidColor
706 function _rgbaToString(parsedColor) {
710 let { r, g, b, a } = parsedColor;
712 return `rgb(${r}, ${g}, ${b})`;
714 return `rgba(${r}, ${g}, ${b}, ${a})`;
717 function _isColorDark(r, g, b) {
718 return 0.2125 * r + 0.7154 * g + 0.0721 * b <= 127;