Bug 1347204 - Implement the colors.ntp_background and colors.ntp_text properties. r=mconley draft
authorTim Nguyen <ntim.bugs@gmail.com>
Thu, 12 Apr 2018 16:48:23 -0400
changeset 814676 97b007626fc9c9ed6f49b3153b02a883b293ca08
parent 814669 e80b522fc585581f9e40f2c046188c7567191c42
push id115313
push userbmo:ntim.bugs@gmail.com
push dateThu, 05 Jul 2018 22:32:50 +0000
reviewersmconley
bugs1347204
milestone63.0a1
Bug 1347204 - Implement the colors.ntp_background and colors.ntp_text properties. r=mconley MozReview-Commit-ID: En8HajryiJS
browser/base/content/contentTheme.js
browser/base/content/tab-content.js
browser/base/content/test/performance/browser_startup_content.js
browser/base/jar.mn
browser/components/nsBrowserGlue.js
browser/modules/LightweightThemeChildListener.jsm
browser/modules/ThemeVariableMap.jsm
browser/modules/moz.build
toolkit/components/extensions/parent/ext-theme.js
toolkit/components/extensions/schemas/theme.json
toolkit/components/extensions/test/browser/browser.ini
toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js
toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js
toolkit/modules/LightweightThemeConsumer.jsm
new file mode 100644
--- /dev/null
+++ b/browser/base/content/contentTheme.js
@@ -0,0 +1,108 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+{
+
+function _isTextColorDark(r, g, b) {
+  return (0.2125 * r + 0.7154 * g + 0.0721 * b) <= 110;
+}
+
+const inContentVariableMap = [
+  ["--newtab-background-color", {
+    lwtProperty: "ntp_background"
+  }],
+  ["--newtab-text-primary-color", {
+    lwtProperty: "ntp_text",
+    processColor(rgbaChannels, element) {
+      if (!rgbaChannels) {
+        element.removeAttribute("lwt-newtab");
+        element.removeAttribute("lwt-newtab-brighttext");
+        return null;
+      }
+
+      element.setAttribute("lwt-newtab", "true");
+      const {r, g, b, a} = rgbaChannels;
+      if (!_isTextColorDark(r, g, b)) {
+        element.setAttribute("lwt-newtab-brighttext", "true");
+      }
+
+      return `rgba(${r}, ${g}, ${b}, ${a})`;
+    },
+  }],
+];
+
+/**
+ * ContentThemeController handles theme updates sent by the frame script.
+ * To be able to use ContentThemeController, you must add your page to the whitelist
+ * in LightweightThemeChildListener.jsm
+ */
+const ContentThemeController = {
+  /**
+   * Tell the frame script that the page supports theming, and watch for updates
+   * from the frame script.
+   */
+  init() {
+    addEventListener("LightweightTheme:Set", this);
+
+    const event = new CustomEvent("LightweightTheme:Support", {bubbles: true});
+    document.dispatchEvent(event);
+  },
+
+  /**
+   * Handle theme updates from the frame script.
+   * @param {Object} event object containing the theme update.
+   */
+  handleEvent({ detail }) {
+    if (detail.type == "LightweightTheme:Update") {
+      let {data} = detail;
+      if (!data) {
+        data = {};
+      }
+      this._setProperties(document.body, data);
+    }
+  },
+
+  /**
+   * Set a CSS variable to a given value
+   * @param {Element} elem The element where the CSS variable should be added.
+   * @param {string} variableName The CSS variable to set.
+   * @param {string} value The new value of the CSS variable.
+   */
+  _setProperty(elem, variableName, value) {
+    if (value) {
+      elem.style.setProperty(variableName, value);
+    } else {
+      elem.style.removeProperty(variableName);
+    }
+  },
+
+  /**
+   * Apply theme data to an element
+   * @param {Element} root The element where the properties should be applied.
+   * @param {Object} themeData The theme data.
+   */
+  _setProperties(elem, themeData) {
+    for (let [cssVarName, definition] of inContentVariableMap) {
+      const {
+        lwtProperty,
+        processColor,
+      } = definition;
+      let value = themeData[lwtProperty];
+
+      if (processColor) {
+        value = processColor(value, document.body);
+      } else if (value) {
+        const {r, g, b, a} = value;
+        value = `rgba(${r}, ${g}, ${b}, ${a})`;
+      }
+
+      this._setProperty(elem, cssVarName, value);
+    }
+  },
+};
+ContentThemeController.init();
+
+}
--- a/browser/base/content/tab-content.js
+++ b/browser/base/content/tab-content.js
@@ -19,16 +19,18 @@ ChromeUtils.defineModuleGetter(this, "Ut
 ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "AboutReader",
   "resource://gre/modules/AboutReader.jsm");
 ChromeUtils.defineModuleGetter(this, "ReaderMode",
   "resource://gre/modules/ReaderMode.jsm");
 ChromeUtils.defineModuleGetter(this, "PageStyleHandler",
   "resource:///modules/PageStyleHandler.jsm");
+ChromeUtils.defineModuleGetter(this, "LightweightThemeChildListener",
+  "resource:///modules/LightweightThemeChildListener.jsm");
 
 // TabChildGlobal
 var global = this;
 
 
 addEventListener("MozDOMPointerLock:Entered", function(aEvent) {
   sendAsyncMessage("PointerLock:Entered", {
     originNoSuffix: aEvent.target.nodePrincipal.originNoSuffix
@@ -74,16 +76,43 @@ addMessageListener("Browser:Reload", fun
   } catch (e) {
   }
 });
 
 addMessageListener("MixedContent:ReenableProtection", function() {
   docShell.mixedContentChannel = null;
 });
 
+var LightweightThemeChildListenerStub = {
+  _childListener: null,
+  get childListener() {
+    if (!this._childListener) {
+      this._childListener = new LightweightThemeChildListener();
+    }
+    return this._childListener;
+  },
+
+  init() {
+    addEventListener("LightweightTheme:Support", this, false, true);
+    addMessageListener("LightweightTheme:Update", this);
+    sendAsyncMessage("LightweightTheme:Request");
+  },
+
+  handleEvent(event) {
+    return this.childListener.handleEvent(event);
+  },
+
+  receiveMessage(msg) {
+    return this.childListener.receiveMessage(msg);
+  },
+};
+
+LightweightThemeChildListenerStub.init();
+
+
 var AboutReaderListener = {
 
   _articlePromise: null,
 
   _isLeavingReaderableReaderMode: false,
 
   init() {
     addEventListener("AboutReaderContentLoaded", this, false, true);
--- a/browser/base/content/test/performance/browser_startup_content.js
+++ b/browser/base/content/test/performance/browser_startup_content.js
@@ -51,16 +51,17 @@ const whitelist = {
     // Forms and passwords
     "resource://formautofill/FormAutofillContent.jsm",
     "resource://formautofill/FormAutofillUtils.jsm",
 
     // Browser front-end
     "resource:///modules/ContentLinkHandler.jsm",
     "resource:///modules/ContentMetaHandler.jsm",
     "resource:///modules/PageStyleHandler.jsm",
+    "resource:///modules/LightweightThemeChildListener.jsm",
     "resource://gre/modules/BrowserUtils.jsm",
     "resource://gre/modules/E10SUtils.jsm",
     "resource://gre/modules/PrivateBrowsingUtils.jsm",
     "resource://gre/modules/ReaderMode.jsm",
     "resource://gre/modules/RemotePageManager.jsm",
 
     // Pocket
     "chrome://pocket/content/AboutPocket.jsm",
--- a/browser/base/jar.mn
+++ b/browser/base/jar.mn
@@ -96,16 +96,17 @@ browser.jar:
         content/browser/tabbrowser.css                (content/tabbrowser.css)
         content/browser/tabbrowser.js                 (content/tabbrowser.js)
         content/browser/tabbrowser.xml                (content/tabbrowser.xml)
 *       content/browser/urlbarBindings.xml            (content/urlbarBindings.xml)
         content/browser/utilityOverlay.js             (content/utilityOverlay.js)
         content/browser/webext-panels.js              (content/webext-panels.js)
 *       content/browser/webext-panels.xul             (content/webext-panels.xul)
         content/browser/nsContextMenu.js              (content/nsContextMenu.js)
+        content/browser/contentTheme.js               (content/contentTheme.js)
 #ifdef XP_MACOSX
 # XXX: We should exclude this one as well (bug 71895)
 *       content/browser/hiddenWindow.xul              (content/hiddenWindow.xul)
 #endif
 #ifndef XP_MACOSX
 *       content/browser/webrtcIndicator.xul           (content/webrtcIndicator.xul)
         content/browser/webrtcIndicator.js            (content/webrtcIndicator.js)
 #endif
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -755,16 +755,18 @@ BrowserGlue.prototype = {
       iconURL: "resource:///chrome/browser/content/browser/defaultthemes/dark.icon.svg",
       textcolor: "white",
       accentcolor: "black",
       popup: "#4a4a4f",
       popup_text: "rgb(249, 249, 250)",
       popup_border: "#27272b",
       toolbar_field_text: "rgb(249, 249, 250)",
       toolbar_field_border: "rgba(249, 249, 250, 0.2)",
+      ntp_background: "#2A2A2E",
+      ntp_text: "rgb(249, 249, 250)",
       author: vendorShortName,
     }, {
       useInDarkMode: true
     });
 
     Normandy.init();
 
     // Initialize the default l10n resource sources for L10nRegistry.
new file mode 100644
--- /dev/null
+++ b/browser/modules/LightweightThemeChildListener.jsm
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * LightweightThemeChildListener forwards theme updates from LightweightThemeConsumer to
+ * the whitelisted in-content pages
+ */
+class LightweightThemeChildListener {
+  constructor() {
+    /**
+     * The pages that will receive theme updates
+     */
+    this.whitelist = new Set([
+      "about:home",
+      "about:newtab",
+      "about:welcome",
+    ]);
+
+    /**
+     * The last theme data received from LightweightThemeConsumer
+     */
+    this._lastData = null;
+  }
+
+  /**
+   * Handles theme updates from the parent process
+   * @param {Object} message from the parent process.
+   */
+  receiveMessage({ name, data, target }) {
+    if (name == "LightweightTheme:Update") {
+      this._lastData = data;
+      this._update(data, target.content);
+    }
+  }
+
+  /**
+   * Handles events from the content scope.
+   * @param {Object} event The received event.
+   */
+  handleEvent(event) {
+    const content = event.originalTarget.defaultView;
+    if (content != content.top) {
+      return;
+    }
+
+    if (event.type == "LightweightTheme:Support") {
+      this._update(this._lastData, content);
+    }
+  }
+
+  /**
+   * Checks if a given global is allowed to receive theme updates
+   * @param {Object} content The global to check against.
+   * @returns {boolean} Whether the global is allowed to receive updates.
+   */
+  _isContentWhitelisted(content) {
+    return this.whitelist.has(content.document.documentURI);
+  }
+
+  /**
+   * Forward the theme data to the page.
+   * @param {Object} data The theme data to forward
+   * @param {Object} content The receiving global
+   */
+  _update(data, content) {
+    if (this._isContentWhitelisted(content)) {
+      const event = Cu.cloneInto({
+        detail: {
+          type: "LightweightTheme:Update",
+          data,
+        },
+      }, content);
+      content.dispatchEvent(new content.CustomEvent("LightweightTheme:Set",
+                                                    event));
+    }
+  }
+}
+
+var EXPORTED_SYMBOLS = ["LightweightThemeChildListener"];
--- a/browser/modules/ThemeVariableMap.jsm
+++ b/browser/modules/ThemeVariableMap.jsm
@@ -1,13 +1,13 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
-var EXPORTED_SYMBOLS = ["ThemeVariableMap"];
+var EXPORTED_SYMBOLS = ["ThemeVariableMap", "ThemeContentPropertyList"];
 
 const ThemeVariableMap = [
   ["--lwt-accent-color-inactive", {
     lwtProperty: "accentcolorInactive"
   }],
   ["--lwt-background-alignment", {
     isColor: false,
     lwtProperty: "backgroundsAlignment"
@@ -75,8 +75,13 @@ const ThemeVariableMap = [
   }],
   ["--autocomplete-popup-highlight-background", {
     lwtProperty: "popup_highlight"
   }],
   ["--autocomplete-popup-highlight-color", {
     lwtProperty: "popup_highlight_text"
   }],
 ];
+
+const ThemeContentPropertyList = [
+  "ntp_background",
+  "ntp_text",
+];
--- a/browser/modules/moz.build
+++ b/browser/modules/moz.build
@@ -65,16 +65,19 @@ with Files("ContentWebRTC.jsm"):
     BUG_COMPONENT = ("Firefox", "Device Permissions")
 
 with Files("ExtensionsUI.jsm"):
     BUG_COMPONENT = ("WebExtensions", "General")
 
 with Files("LaterRun.jsm"):
     BUG_COMPONENT = ("Firefox", "Tours")
 
+with Files("LightweightThemeChildListener.jsm"):
+    BUG_COMPONENT = ("WebExtensions", "Themes")
+
 with Files("LightWeightThemeWebInstallListener.jsm"):
     BUG_COMPONENT = ("Firefox", "Theme")
 
 with Files("OpenInTabsUtils.jsm"):
     BUG_COMPONENT = ("Firefox", "Tabbed Browser")
 
 with Files("PageInfoListener.jsm"):
     BUG_COMPONENT = ("Firefox", "Page Info Window")
@@ -150,16 +153,17 @@ EXTRA_JS_MODULES += [
     'ContentSearch.jsm',
     'ContentWebRTC.jsm',
     'ContextMenu.jsm',
     'ExtensionsUI.jsm',
     'Feeds.jsm',
     'FormSubmitObserver.jsm',
     'FormValidationHandler.jsm',
     'LaterRun.jsm',
+    'LightweightThemeChildListener.jsm',
     'LightWeightThemeWebInstallListener.jsm',
     'NetErrorContent.jsm',
     'OpenInTabsUtils.jsm',
     'PageActions.jsm',
     'PageInfoListener.jsm',
     'PageStyleHandler.jsm',
     'PermissionUI.jsm',
     'PingCentre.jsm',
--- a/toolkit/components/extensions/parent/ext-theme.js
+++ b/toolkit/components/extensions/parent/ext-theme.js
@@ -165,16 +165,18 @@ class Theme {
         case "toolbar_vertical_separator":
         case "button_background_hover":
         case "button_background_active":
         case "popup":
         case "popup_text":
         case "popup_border":
         case "popup_highlight":
         case "popup_highlight_text":
+        case "ntp_background":
+        case "ntp_text":
           this.lwtStyles[color] = cssColor;
           break;
       }
     }
   }
 
   /**
    * Helper method for loading images found in the extension's manifest.
--- a/toolkit/components/extensions/schemas/theme.json
+++ b/toolkit/components/extensions/schemas/theme.json
@@ -191,16 +191,24 @@
               },
               "popup_highlight": {
                 "$ref": "ThemeColor",
                 "optional": true
               },
               "popup_highlight_text": {
                 "$ref": "ThemeColor",
                 "optional": true
+              },
+              "ntp_background": {
+                "$ref": "ThemeColor",
+                "optional": true
+              },
+              "ntp_text": {
+                "$ref": "ThemeColor",
+                "optional": true
               }
             },
             "additionalProperties": { "$ref": "UnrecognizedProperty" }
           },
           "icons": {
             "type": "object",
             "optional": true,
             "properties": {
--- a/toolkit/components/extensions/test/browser/browser.ini
+++ b/toolkit/components/extensions/test/browser/browser.ini
@@ -7,16 +7,18 @@ skip-if = verify
 [browser_ext_themes_alpha_accentcolor.js]
 [browser_ext_themes_chromeparity.js]
 [browser_ext_themes_dynamic_getCurrent.js]
 [browser_ext_themes_dynamic_onUpdated.js]
 [browser_ext_themes_dynamic_updates.js]
 [browser_ext_themes_getCurrent_differentExt.js]
 [browser_ext_themes_lwtsupport.js]
 [browser_ext_themes_multiple_backgrounds.js]
+[browser_ext_themes_ntp_colors.js]
+[browser_ext_themes_ntp_colors_perwindow.js]
 [browser_ext_themes_persistence.js]
 [browser_ext_themes_separators.js]
 [browser_ext_themes_static_onUpdated.js]
 [browser_ext_themes_tab_line.js]
 [browser_ext_themes_tab_loading.js]
 [browser_ext_themes_tab_separators.js]
 [browser_ext_themes_tab_text.js]
 [browser_ext_themes_toolbar_fields_focus.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js
@@ -0,0 +1,103 @@
+"use strict";
+
+// This test checks whether the new tab page color properties work.
+
+/**
+ * Test whether the selected browser has the new tab page theme applied
+ * @param {Object} theme that is applied
+ * @param {boolean} isBrightText whether the brighttext attribute should be set
+ */
+async function test_ntp_theme(theme, isBrightText) {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      theme,
+    },
+  });
+
+  let browser = gBrowser.selectedBrowser;
+
+  let {
+    originalBackground,
+    originalColor,
+  } = await ContentTask.spawn(browser, {}, function() {
+    let doc = content.document;
+    ok(!doc.body.hasAttribute("lwt-newtab"),
+       "New tab page should not have lwt-newtab attribute");
+    ok(!doc.body.hasAttribute("lwt-newtab-brighttext"),
+       `New tab page should not have lwt-newtab-brighttext attribute`);
+
+    return {
+      originalBackground: content.getComputedStyle(doc.body).backgroundColor,
+      originalColor: content.getComputedStyle(doc.querySelector(".outer-wrapper")).color,
+    };
+  });
+
+  await extension.startup();
+
+  await ContentTask.spawn(browser, {
+    isBrightText,
+    background: hexToCSS(theme.colors.ntp_background),
+    color: hexToCSS(theme.colors.ntp_text),
+  }, function({isBrightText, background, color}) {
+    let doc = content.document;
+    ok(doc.body.hasAttribute("lwt-newtab"),
+       "New tab page should have lwt-newtab attribute");
+    is(doc.body.hasAttribute("lwt-newtab-brighttext"), isBrightText,
+       `New tab page should${!isBrightText ? " not" : ""} have lwt-newtab-brighttext attribute`);
+
+    is(content.getComputedStyle(doc.body).backgroundColor, background,
+       "New tab page background should be set.");
+    is(content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, color,
+       "New tab page text color should be set.");
+  });
+
+  await extension.unload();
+
+  await ContentTask.spawn(browser, {
+    originalBackground,
+    originalColor,
+  }, function({originalBackground, originalColor}) {
+    let doc = content.document;
+    ok(!doc.body.hasAttribute("lwt-newtab"),
+       "New tab page should not have lwt-newtab attribute");
+    ok(!doc.body.hasAttribute("lwt-newtab-brighttext"),
+       `New tab page should not have lwt-newtab-brighttext attribute`);
+
+    is(content.getComputedStyle(doc.body).backgroundColor, originalBackground,
+       "New tab page background should be reset.");
+    is(content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, originalColor,
+       "New tab page text color should be reset.");
+  });
+}
+
+add_task(async function test_support_ntp_colors() {
+  // BrowserTestUtils.withNewTab waits for about:newtab to load
+  // so we disable preloading before running the test.
+  SpecialPowers.setBoolPref("browser.newtab.preload", false);
+  registerCleanupFunction(() => {
+    SpecialPowers.clearUserPref("browser.newtab.preload");
+  });
+  gBrowser.removePreloadedBrowser();
+  for (let url of ["about:newtab", "about:home", "about:welcome"]) {
+    info("Opening url: " + url);
+    await BrowserTestUtils.withNewTab({gBrowser, url}, async browser => {
+      await test_ntp_theme({
+        colors: {
+          accentcolor: ACCENT_COLOR,
+          textcolor: TEXT_COLOR,
+          ntp_background: "#add8e6",
+          ntp_text: "#00008b",
+        },
+      }, false, url);
+
+      await test_ntp_theme({
+        colors: {
+          accentcolor: ACCENT_COLOR,
+          textcolor: TEXT_COLOR,
+          ntp_background: "#00008b",
+          ntp_text: "#add8e6",
+        },
+      }, true, url);
+    });
+  }
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js
@@ -0,0 +1,190 @@
+"use strict";
+
+// This test checks whether the new tab page color properties work per-window.
+
+/**
+ * Test whether a given browser has the new tab page theme applied
+ * @param {Object} browser to test against
+ * @param {Object} theme that is applied
+ * @param {boolean} isBrightText whether the brighttext attribute should be set
+ * @returns {Promise} The task as a promise
+ */
+function test_ntp_theme(browser, theme, isBrightText) {
+  return ContentTask.spawn(browser, {
+    isBrightText,
+    background: hexToCSS(theme.colors.ntp_background),
+    color: hexToCSS(theme.colors.ntp_text),
+  }, function({isBrightText, background, color}) {
+    let doc = content.document;
+    ok(doc.body.hasAttribute("lwt-newtab"),
+       "New tab page should have lwt-newtab attribute");
+    is(doc.body.hasAttribute("lwt-newtab-brighttext"), isBrightText,
+       `New tab page should${!isBrightText ? " not" : ""} have lwt-newtab-brighttext attribute`);
+
+    is(content.getComputedStyle(doc.body).backgroundColor, background,
+       "New tab page background should be set.");
+    is(content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, color,
+       "New tab page text color should be set.");
+  });
+}
+
+/**
+ * Test whether a given browser has the default theme applied
+ * @param {Object} browser to test against
+ * @returns {Promise} The task as a promise
+ */
+function test_ntp_default_theme(browser) {
+  return ContentTask.spawn(browser, {
+    background: hexToCSS("#F9F9FA"),
+    color: hexToCSS("#0C0C0D"),
+  }, function({background, color}) {
+    let doc = content.document;
+    ok(!doc.body.hasAttribute("lwt-newtab"),
+       "New tab page should not have lwt-newtab attribute");
+    ok(!doc.body.hasAttribute("lwt-newtab-brighttext"),
+       `New tab page should not have lwt-newtab-brighttext attribute`);
+
+    is(content.getComputedStyle(doc.body).backgroundColor, background,
+       "New tab page background should be reset.");
+    is(content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, color,
+       "New tab page text color should be reset.");
+  });
+}
+
+add_task(async function test_per_window_ntp_theme() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: ["theme"],
+    },
+    async background() {
+      function promiseWindowChanged(winId) {
+        return new Promise(resolve => {
+          let listener = windowId => {
+            if (windowId === winId) {
+              browser.windows.onFocusChanged.removeListener(listener);
+              resolve();
+            }
+          };
+          browser.windows.onFocusChanged.addListener(listener);
+        });
+      }
+
+      function promiseWindowChecked() {
+        return new Promise(resolve => {
+          let listener = msg => {
+            if (msg == "checked-window") {
+              browser.test.onMessage.removeListener(listener);
+              resolve();
+            }
+          };
+          browser.test.onMessage.addListener(listener);
+        });
+      }
+
+      function createWindow() {
+        return new Promise(resolve => {
+          let listener = win => {
+            browser.windows.onCreated.removeListener(listener);
+            resolve(win);
+          };
+          browser.windows.onCreated.addListener(listener);
+          browser.windows.create();
+        });
+      }
+
+      function removeWindow(winId) {
+        return new Promise(resolve => {
+          let listener = removedWinId => {
+            if (removedWinId == winId) {
+              browser.windows.onRemoved.removeListener(listener);
+              resolve();
+            }
+          };
+          browser.windows.onRemoved.addListener(listener);
+          browser.windows.remove(winId);
+        });
+      }
+
+      async function checkWindow(theme, isBrightText, winId) {
+        // We query the window again to have the updated focus information
+        let win = await browser.windows.get(winId);
+        if (!win.focused) {
+          let focusChanged = promiseWindowChanged(win.id);
+          await browser.windows.update(win.id, {focused: true});
+          await focusChanged;
+        }
+
+        let windowChecked = promiseWindowChecked();
+        browser.test.sendMessage("check-window", {theme, isBrightText});
+        await windowChecked;
+      }
+
+      const darkTextTheme = {
+        colors: {
+          accentcolor: "#add8e6",
+          textcolor: "#000",
+          ntp_background: "#add8e6",
+          ntp_text: "#000",
+        },
+      };
+
+      const brightTextTheme = {
+        colors: {
+          accentcolor: "#00008b",
+          textcolor: "#add8e6",
+          ntp_background: "#00008b",
+          ntp_text: "#add8e6",
+        },
+      };
+
+      let {id: winId} = await browser.windows.getCurrent();
+      let {id: secondWinId} = await createWindow();
+
+      browser.test.log("Test that single window update works");
+      await browser.theme.update(winId, darkTextTheme);
+      await checkWindow(darkTextTheme, false, winId);
+      await checkWindow(null, false, secondWinId);
+
+      browser.test.log("Test that applying different themes on both windows");
+      await browser.theme.update(secondWinId, brightTextTheme);
+      await checkWindow(darkTextTheme, false, winId);
+      await checkWindow(brightTextTheme, true, secondWinId);
+
+      browser.test.log("Test resetting the theme on one window");
+      await browser.theme.reset(winId);
+      await checkWindow(null, false, winId);
+      await checkWindow(brightTextTheme, true, secondWinId);
+
+      await removeWindow(secondWinId);
+      await checkWindow(null, false, winId);
+      browser.test.notifyPass("perwindow-ntp-theme");
+    },
+  });
+
+  extension.onMessage("check-window", async ({theme, isBrightText}) => {
+    let win = Services.wm.getMostRecentWindow("navigator:browser");
+    win.gBrowser.removePreloadedBrowser();
+    for (let url of ["about:newtab", "about:home", "about:welcome"]) {
+      info("Opening url: " + url);
+      await BrowserTestUtils.withNewTab({gBrowser: win.gBrowser, url}, async browser => {
+        if (theme) {
+          await test_ntp_theme(browser, theme, isBrightText);
+        } else {
+          await test_ntp_default_theme(browser);
+        }
+      });
+    }
+    extension.sendMessage("checked-window");
+  });
+
+  // BrowserTestUtils.withNewTab waits for about:newtab to load
+  // so we disable preloading before running the test.
+  SpecialPowers.setBoolPref("browser.newtab.preload", false);
+  registerCleanupFunction(() => {
+    SpecialPowers.clearUserPref("browser.newtab.preload");
+  });
+
+  await extension.startup();
+  await extension.awaitFinish("perwindow-ntp-theme");
+  await extension.unload();
+});
--- a/toolkit/modules/LightweightThemeConsumer.jsm
+++ b/toolkit/modules/LightweightThemeConsumer.jsm
@@ -94,33 +94,36 @@ const toolkitVariableMap = [
   }],
   ["--toolbar-field-focus-border-color", {
     lwtProperty: "toolbar_field_border_focus"
   }],
 ];
 
 // Get the theme variables from the app resource directory.
 // This allows per-app variables.
-ChromeUtils.import("resource:///modules/ThemeVariableMap.jsm");
-
+ChromeUtils.defineModuleGetter(this, "ThemeContentPropertyList",
+  "resource:///modules/ThemeVariableMap.jsm");
+ChromeUtils.defineModuleGetter(this, "ThemeVariableMap",
+  "resource:///modules/ThemeVariableMap.jsm");
 ChromeUtils.defineModuleGetter(this, "LightweightThemeImageOptimizer",
   "resource://gre/modules/addons/LightweightThemeImageOptimizer.jsm");
 
 function LightweightThemeConsumer(aDocument) {
   this._doc = aDocument;
   this._win = aDocument.defaultView;
 
   Services.obs.addObserver(this, "lightweight-theme-styling-update");
 
   var temp = {};
   ChromeUtils.import("resource://gre/modules/LightweightThemeManager.jsm", temp);
   this._update(temp.LightweightThemeManager.currentThemeForDisplay);
 
   this._win.addEventListener("resolutionchange", this);
   this._win.addEventListener("unload", this, { once: true });
+  this._win.messageManager.addMessageListener("LightweightTheme:Request", this);
 
   let darkThemeMediaQuery = this._win.matchMedia("(-moz-system-dark-theme)");
   darkThemeMediaQuery.addListener(temp.LightweightThemeManager);
   temp.LightweightThemeManager.systemThemeChanged(darkThemeMediaQuery);
 }
 
 LightweightThemeConsumer.prototype = {
   _lastData: null,
@@ -138,16 +141,23 @@ LightweightThemeConsumer.prototype = {
     const parsedData = JSON.parse(aData);
     if (parsedData && parsedData.window && parsedData.window !== outerWindowID) {
       return;
     }
 
     this._update(parsedData);
   },
 
+  receiveMessage({ name, target }) {
+    if (name == "LightweightTheme:Request") {
+      let contentThemeData = _getContentProperties(this._doc, this._active, this._lastData);
+      target.messageManager.sendAsyncMessage("LightweightTheme:Update", contentThemeData);
+    }
+  },
+
   handleEvent(aEvent) {
     switch (aEvent.type) {
       case "resolutionchange":
         if (this._active) {
           this._update(this._lastData);
         }
         break;
       case "unload":
@@ -199,19 +209,39 @@ LightweightThemeConsumer.prototype = {
       root.removeAttribute("lwtheme");
       root.removeAttribute("lwthemetextcolor");
     }
 
     if (active && aData.footerURL)
       root.setAttribute("lwthemefooter", "true");
     else
       root.removeAttribute("lwthemefooter");
+
+    let contentThemeData = _getContentProperties(this._doc, active, aData);
+
+    let browserMessageManager = this._win.getGroupMessageManager("browsers");
+    browserMessageManager.broadcastAsyncMessage(
+      "LightweightTheme:Update", contentThemeData
+    );
   }
 };
 
+function _getContentProperties(doc, active, data) {
+  if (!active) {
+    return {};
+  }
+  let properties = {};
+  for (let property in data) {
+    if (ThemeContentPropertyList.includes(property)) {
+      properties[property] = _parseRGBA(_sanitizeCSSColor(doc, data[property]));
+    }
+  }
+  return properties;
+}
+
 function _setImage(aRoot, aActive, aVariableName, aURLs) {
   if (aURLs && !Array.isArray(aURLs)) {
     aURLs = [aURLs];
   }
   _setProperty(aRoot, aActive, aVariableName, aURLs && aURLs.map(v => `url("${v.replace(/"/g, '\\"')}")`).join(","));
 }
 
 function _setProperty(elem, active, variableName, value) {