Bug 1347207 - Implement theme_experiment manifest field. r=jaws draft
authorTim Nguyen <ntim.bugs@gmail.com>
Mon, 23 Jul 2018 18:46:40 +0100
changeset 827974 2fef94584c628da57a59d08f240e32f1e55d6b6e
parent 827268 936c5d6bd40b08188993cbd08e8622820398687a
child 827975 6102716d52355d7a681bbe1bd2a9b44af0a1dc0a
child 827976 2acd741506c3dc7dbcc9a215749ee271de64189d
push id118615
push userbmo:ntim.bugs@gmail.com
push dateThu, 09 Aug 2018 18:07:50 +0000
reviewersjaws
bugs1347207
milestone63.0a1
Bug 1347207 - Implement theme_experiment manifest field. r=jaws MozReview-Commit-ID: DuUiVAMcti2
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_experiment.js
toolkit/modules/LightweightThemeConsumer.jsm
--- a/toolkit/components/extensions/parent/ext-theme.js
+++ b/toolkit/components/extensions/parent/ext-theme.js
@@ -31,25 +31,45 @@ let windowOverrides = new Map();
  */
 class Theme {
   /**
    * Creates a theme instance.
    *
    * @param {string} extension Extension that created the theme.
    * @param {Integer} windowId The windowId where the theme is applied.
    */
-  constructor({extension, details, windowId}) {
+  constructor({extension, details, windowId, experiment}) {
     this.extension = extension;
     this.details = details;
     this.windowId = windowId;
 
     this.lwtStyles = {
       icons: {},
     };
 
+    if (experiment) {
+      const canRunExperiment = AppConstants.MOZ_ALLOW_LEGACY_EXTENSIONS &&
+        Services.prefs.getBoolPref("extensions.legacy.enabled");
+      if (canRunExperiment) {
+        this.lwtStyles.experimental = {
+          colors: {},
+          images: {},
+          properties: {},
+        };
+        const {baseURI} = this.extension;
+        if (experiment.stylesheet) {
+          experiment.stylesheet = baseURI.resolve(experiment.stylesheet);
+        }
+        this.experiment = experiment;
+      } else {
+        const {logger} = this.extension;
+        logger.warn("This extension is not allowed to run theme experiments");
+        return;
+      }
+    }
     this.load();
   }
 
   /**
    * Loads a theme by reading the properties from the extension's manifest.
    * This method will override any currently applied theme.
    *
    * @param {Object} details Theme part of the manifest. Supported
@@ -83,16 +103,19 @@ class Theme {
         getWinUtils(windowTracker.getWindow(this.windowId)).outerWindowID;
       windowOverrides.set(this.windowId, this);
     } else {
       windowOverrides.clear();
       defaultTheme = this;
     }
     onUpdatedEmitter.emit("theme-updated", this.details, this.windowId);
 
+    if (this.experiment) {
+      lwtData.experiment = this.experiment;
+    }
     LightweightThemeManager.fallbackThemeData = this.lwtStyles;
     Services.obs.notifyObservers(null,
                                  "lightweight-theme-styling-update",
                                  JSON.stringify(lwtData));
   }
 
   /**
    * Helper method for loading colors found in the extension's manifest.
@@ -158,27 +181,35 @@ class Theme {
         case "popup_text":
         case "popup_border":
         case "popup_highlight":
         case "popup_highlight_text":
         case "ntp_background":
         case "ntp_text":
           this.lwtStyles[color] = cssColor;
           break;
+        default:
+          if (this.experiment && this.experiment.colors && color in this.experiment.colors) {
+            this.lwtStyles.experimental.colors[color] = cssColor;
+          } else {
+            const {logger} = this.extension;
+            logger.warn(`Unrecognized theme property found: colors.${color}`);
+          }
+          break;
       }
     }
   }
 
   /**
    * Helper method for loading images found in the extension's manifest.
    *
    * @param {Object} images Dictionary mapping image properties to values.
    */
   loadImages(images) {
-    const {baseURI} = this.extension;
+    const {baseURI, logger} = this.extension;
 
     for (let image of Object.keys(images)) {
       let val = images[image];
 
       if (!val) {
         continue;
       }
 
@@ -189,16 +220,24 @@ class Theme {
           break;
         }
         case "headerURL":
         case "theme_frame": {
           let resolvedURL = baseURI.resolve(val);
           this.lwtStyles.headerURL = resolvedURL;
           break;
         }
+        default: {
+          if (this.experiment && this.experiment.images && image in this.experiment.images) {
+            this.lwtStyles.experimental.images[image] = baseURI.resolve(val);
+          } else {
+            logger.warn(`Unrecognized theme property found: images.${image}`);
+          }
+          break;
+        }
       }
     }
   }
 
   /**
    * Helper method for loading icons found in the extension's manifest.
    *
    * @param {Object} icons Dictionary mapping icon properties to extension URLs.
@@ -280,16 +319,25 @@ class Theme {
             tiling.push("no-repeat");
           }
           for (let i = 0, l = this.lwtStyles.additionalBackgrounds.length; i < l; ++i) {
             tiling.push(val[i] || "no-repeat");
           }
           this.lwtStyles.backgroundsTiling = tiling.join(",");
           break;
         }
+        default: {
+          if (this.experiment && this.experiment.properties && property in this.experiment.properties) {
+            this.lwtStyles.experimental.properties[property] = val;
+          } else {
+            const {logger} = this.extension;
+            logger.warn(`Unrecognized theme property found: properties.${property}`);
+          }
+          break;
+        }
       }
     }
   }
 
   static unload(windowId) {
     let lwtData = {
       theme: null,
     };
@@ -309,20 +357,22 @@ class Theme {
                                  JSON.stringify(lwtData));
   }
 }
 
 this.theme = class extends ExtensionAPI {
   onManifestEntry(entryName) {
     let {extension} = this;
     let {manifest} = extension;
+    let {theme, theme_experiment} = manifest;
 
     defaultTheme = new Theme({
       extension,
-      details: manifest.theme,
+      details: theme,
+      experiment: theme_experiment,
     });
   }
 
   onShutdown(reason) {
     if (reason === "APP_SHUTDOWN") {
       return;
     }
 
@@ -361,16 +411,17 @@ this.theme = class extends ExtensionAPI 
               return Promise.reject(`Invalid window ID: ${windowId}`);
             }
           }
 
           new Theme({
             extension,
             details,
             windowId,
+            experiment: this.extension.manifest.theme_experiment,
           });
         },
         reset: (windowId) => {
           if (windowId) {
             const browserWindow = windowTracker.getWindow(windowId, context);
             if (!browserWindow) {
               return Promise.reject(`Invalid window ID: ${windowId}`);
             }
--- a/toolkit/components/extensions/schemas/theme.json
+++ b/toolkit/components/extensions/schemas/theme.json
@@ -37,16 +37,47 @@
             "maxItems": 4,
             "items": {
               "type": "number"
             }
           }
         ]
       },
       {
+        "id": "ThemeExperiment",
+        "type": "object",
+        "properties": {
+          "stylesheet": {
+            "optional": true,
+            "$ref": "ExtensionURL"
+          },
+          "images": {
+            "type": "object",
+            "optional": true,
+            "additionalProperties": {
+              "type": "string"
+            }
+          },
+          "colors": {
+            "type": "object",
+            "optional": true,
+            "additionalProperties": {
+              "type": "string"
+            }
+          },
+          "properties": {
+            "type": "object",
+            "optional": true,
+            "additionalProperties": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      {
         "id": "ThemeType",
         "type": "object",
         "properties": {
           "images": {
             "type": "object",
             "optional": true,
             "properties": {
               "additional_backgrounds": {
@@ -59,17 +90,17 @@
                 "$ref": "ImageDataOrExtensionURL",
                 "optional": true
               },
               "theme_frame": {
                 "$ref": "ImageDataOrExtensionURL",
                 "optional": true
               }
             },
-            "additionalProperties": { "$ref": "UnrecognizedProperty" }
+            "additionalProperties": { "$ref": "ImageDataOrExtensionURL" }
           },
           "colors": {
             "type": "object",
             "optional": true,
             "properties": {
               "tab_selected": {
                 "$ref": "ThemeColor",
                 "optional": true
@@ -202,17 +233,17 @@
                 "$ref": "ThemeColor",
                 "optional": true
               },
               "ntp_text": {
                 "$ref": "ThemeColor",
                 "optional": true
               }
             },
-            "additionalProperties": { "$ref": "UnrecognizedProperty" }
+            "additionalProperties": { "$ref": "ThemeColor" }
           },
           "icons": {
             "type": "object",
             "optional": true,
             "properties": {
               "back": {
                 "$ref": "ExtensionURL",
                 "optional": true
@@ -559,42 +590,55 @@
                 "items": {
                   "type": "string",
                   "enum": ["no-repeat", "repeat", "repeat-x", "repeat-y"]
                 },
                 "maxItems": 15,
                 "optional": true
               }
             },
-            "additionalProperties": { "$ref": "UnrecognizedProperty" }
+            "additionalProperties": { "type": "string" }
           }
         },
         "additionalProperties": { "$ref": "UnrecognizedProperty" }
       },
       {
         "id": "ThemeManifest",
         "type": "object",
         "description": "Contents of manifest.json for a static theme",
         "$import": "manifest.ManifestBase",
         "properties": {
           "theme": {
             "$ref": "ThemeType"
           },
           "default_locale": {
             "type": "string",
-            "optional": "true"
+            "optional": true
+          },
+          "theme_experiment": {
+            "$ref": "ThemeExperiment",
+            "optional": true
           },
           "icons": {
             "type": "object",
             "optional": true,
             "patternProperties": {
               "^[1-9]\\d*$": { "type": "string" }
             }
           }
         }
+      },
+      {
+        "$extend": "WebExtensionManifest",
+        "properties": {
+          "theme_experiment": {
+            "$ref": "ThemeExperiment",
+            "optional": true
+          }
+        }
       }
     ]
   },
   {
     "namespace": "theme",
     "description": "The theme API allows customizing of visual elements of the browser.",
     "types": [
       {
--- a/toolkit/components/extensions/test/browser/browser.ini
+++ b/toolkit/components/extensions/test/browser/browser.ini
@@ -4,16 +4,17 @@ support-files =
 
 [browser_ext_management_themes.js]
 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_experiment.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]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js
@@ -0,0 +1,258 @@
+"use strict";
+
+// This test checks whether the theme experiments work
+
+add_task(async function setup() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["extensions.legacy.enabled", true]],
+  });
+});
+
+add_task(async function test_experiment_static_theme() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      theme: {
+        colors: {
+          some_color_property: "#ff00ff",
+        },
+        images: {
+          some_image_property: "background.jpg",
+        },
+        properties: {
+          some_random_property: "no-repeat",
+        },
+      },
+      theme_experiment: {
+        colors: {
+          some_color_property: "--some-color-property",
+        },
+        images: {
+          some_image_property: "--some-image-property",
+        },
+        properties: {
+          some_random_property: "--some-random-property",
+        },
+      },
+    },
+  });
+
+  const root = window.document.documentElement;
+
+  is(root.style.getPropertyValue("--some-color-property"), "",
+     "Color property should be unset");
+  is(root.style.getPropertyValue("--some-image-property"), "",
+     "Image property should be unset");
+  is(root.style.getPropertyValue("--some-random-property"), "",
+     "Generic Property should be unset.");
+
+  await extension.startup();
+
+  if (AppConstants.MOZ_ALLOW_LEGACY_EXTENSIONS) {
+    is(root.style.getPropertyValue("--some-color-property"), hexToCSS("#ff00ff"),
+       "Color property should be parsed and set.");
+    ok(root.style.getPropertyValue("--some-image-property").startsWith("url("),
+       "Image property should be parsed.");
+    ok(root.style.getPropertyValue("--some-image-property").endsWith("background.jpg)"),
+       "Image property should be set.");
+    is(root.style.getPropertyValue("--some-random-property"), "no-repeat",
+       "Generic Property should be set.");
+  } else {
+    is(root.style.getPropertyValue("--some-color-property"), "",
+       "Color property should be unset");
+    is(root.style.getPropertyValue("--some-image-property"), "",
+       "Image property should be unset");
+    is(root.style.getPropertyValue("--some-random-property"), "",
+       "Generic Property should be unset.");
+  }
+
+  await extension.unload();
+
+  is(root.style.getPropertyValue("--some-color-property"), "",
+     "Color property should be unset");
+  is(root.style.getPropertyValue("--some-image-property"), "",
+     "Image property should be unset");
+  is(root.style.getPropertyValue("--some-random-property"), "",
+     "Generic Property should be unset.");
+});
+
+add_task(async function test_experiment_dynamic_theme() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      permissions: ["theme"],
+      theme_experiment: {
+        colors: {
+          some_color_property: "--some-color-property",
+        },
+        images: {
+          some_image_property: "--some-image-property",
+        },
+        properties: {
+          some_random_property: "--some-random-property",
+        },
+      },
+    },
+    background() {
+      const theme = {
+        colors: {
+          some_color_property: "#ff00ff",
+        },
+        images: {
+          some_image_property: "background.jpg",
+        },
+        properties: {
+          some_random_property: "no-repeat",
+        },
+      };
+      browser.test.onMessage.addListener((msg) => {
+        if (msg === "update-theme") {
+          browser.theme.update(theme).then(() => {
+            browser.test.sendMessage("theme-updated");
+          });
+        } else {
+          browser.theme.reset().then(() => {
+            browser.test.sendMessage("theme-reset");
+          });
+        }
+      });
+    },
+  });
+
+  await extension.startup();
+
+  const root = window.document.documentElement;
+
+  is(root.style.getPropertyValue("--some-color-property"), "",
+     "Color property should be unset");
+  is(root.style.getPropertyValue("--some-image-property"), "",
+     "Image property should be unset");
+  is(root.style.getPropertyValue("--some-random-property"), "",
+     "Generic Property should be unset.");
+
+  extension.sendMessage("update-theme");
+  await extension.awaitMessage("theme-updated");
+
+  if (AppConstants.MOZ_ALLOW_LEGACY_EXTENSIONS) {
+    is(root.style.getPropertyValue("--some-color-property"), hexToCSS("#ff00ff"),
+       "Color property should be parsed and set.");
+    ok(root.style.getPropertyValue("--some-image-property").startsWith("url("),
+       "Image property should be parsed.");
+    ok(root.style.getPropertyValue("--some-image-property").endsWith("background.jpg)"),
+       "Image property should be set.");
+    is(root.style.getPropertyValue("--some-random-property"), "no-repeat",
+       "Generic Property should be set.");
+  } else {
+    is(root.style.getPropertyValue("--some-color-property"), "",
+       "Color property should be unset");
+    is(root.style.getPropertyValue("--some-image-property"), "",
+       "Image property should be unset");
+    is(root.style.getPropertyValue("--some-random-property"), "",
+       "Generic Property should be unset.");
+  }
+
+  extension.sendMessage("reset-theme");
+  await extension.awaitMessage("theme-reset");
+
+  is(root.style.getPropertyValue("--some-color-property"), "",
+     "Color property should be unset");
+  is(root.style.getPropertyValue("--some-image-property"), "",
+     "Image property should be unset");
+  is(root.style.getPropertyValue("--some-random-property"), "",
+     "Generic Property should be unset.");
+
+  extension.sendMessage("update-theme");
+  await extension.awaitMessage("theme-updated");
+
+  if (AppConstants.MOZ_ALLOW_LEGACY_EXTENSIONS) {
+    is(root.style.getPropertyValue("--some-color-property"), hexToCSS("#ff00ff"),
+       "Color property should be parsed and set.");
+    ok(root.style.getPropertyValue("--some-image-property").startsWith("url("),
+       "Image property should be parsed.");
+    ok(root.style.getPropertyValue("--some-image-property").endsWith("background.jpg)"),
+       "Image property should be set.");
+    is(root.style.getPropertyValue("--some-random-property"), "no-repeat",
+       "Generic Property should be set.");
+  } else {
+    is(root.style.getPropertyValue("--some-color-property"), "",
+       "Color property should be unset");
+    is(root.style.getPropertyValue("--some-image-property"), "",
+       "Image property should be unset");
+    is(root.style.getPropertyValue("--some-random-property"), "",
+       "Generic Property should be unset.");
+  }
+
+  await extension.unload();
+
+  is(root.style.getPropertyValue("--some-color-property"), "",
+     "Color property should be unset");
+  is(root.style.getPropertyValue("--some-image-property"), "",
+     "Image property should be unset");
+  is(root.style.getPropertyValue("--some-random-property"), "",
+     "Generic Property should be unset.");
+});
+
+add_task(async function test_experiment_stylesheet() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      theme: {
+        colors: {
+          menu_button_background: "#ff00ff",
+        },
+      },
+      theme_experiment: {
+        stylesheet: "experiment.css",
+        colors: {
+          menu_button_background: "--menu-button-background",
+        },
+      },
+    },
+    files: {
+      "experiment.css": `#PanelUI-menu-button {
+        background-color: var(--menu-button-background);
+        fill: white;
+      }`,
+    },
+  });
+
+  const root = window.document.documentElement;
+  const menuButton = document.getElementById("PanelUI-menu-button");
+  const computedStyle = window.getComputedStyle(menuButton);
+  const expectedColor = hexToCSS("#ff00ff");
+  const expectedFill = hexToCSS("#ffffff");
+
+  is(root.style.getPropertyValue("--menu-button-background"), "",
+     "Variable should be unset");
+  isnot(computedStyle.backgroundColor, expectedColor,
+        "Menu button should not have custom background");
+  isnot(computedStyle.fill, expectedFill,
+        "Menu button should not have stylesheet fill");
+
+  await extension.startup();
+
+  if (AppConstants.MOZ_ALLOW_LEGACY_EXTENSIONS) {
+    // Wait for stylesheet load.
+    await BrowserTestUtils.waitForCondition(() => computedStyle.fill === expectedFill);
+
+    is(root.style.getPropertyValue("--menu-button-background"), expectedColor,
+       "Variable should be parsed and set.");
+    is(computedStyle.backgroundColor, expectedColor,
+       "Menu button should be have correct background");
+    is(computedStyle.fill, expectedFill,
+       "Menu button should be have correct fill");
+  } else {
+    is(root.style.getPropertyValue("--menu-button-background"), "",
+       "Variable should be unset");
+    isnot(computedStyle.backgroundColor, expectedColor,
+          "Menu button should not have custom background");
+    isnot(computedStyle.fill, expectedFill,
+          "Menu button should not have stylesheet fill");
+  }
+
+  await extension.unload();
+
+  is(root.style.getPropertyValue("--menu-button-background"), "",
+     "Variable should be unset");
+  isnot(computedStyle.backgroundColor, expectedColor,
+        "Menu button should not have custom background");
+  isnot(computedStyle.fill, expectedFill,
+        "Menu button should not have stylesheet fill");
+});
--- a/toolkit/modules/LightweightThemeConsumer.jsm
+++ b/toolkit/modules/LightweightThemeConsumer.jsm
@@ -132,24 +132,24 @@ LightweightThemeConsumer.prototype = {
   _active: false,
 
   observe(aSubject, aTopic, aData) {
     if (aTopic != "lightweight-theme-styling-update")
       return;
 
     let parsedData = JSON.parse(aData);
     if (!parsedData) {
-      parsedData = { theme: null };
+      parsedData = { theme: null, experiment: null };
     }
 
     if (parsedData.window && parsedData.window !== this._winId) {
       return;
     }
 
-    this._update(parsedData.theme);
+    this._update(parsedData.theme, parsedData.experiment);
   },
 
   handleEvent(aEvent) {
     switch (aEvent.type) {
       case "resolutionchange":
         if (this._active) {
           this._update(this._lastData);
         }
@@ -158,67 +158,120 @@ LightweightThemeConsumer.prototype = {
         Services.obs.removeObserver(this, "lightweight-theme-styling-update");
         Services.ppmm.sharedData.delete(`theme/${this._winId}`);
         this._win.removeEventListener("resolutionchange", this);
         this._win = this._doc = null;
         break;
     }
   },
 
-  _update(aData) {
-    this._lastData = aData;
-    if (aData) {
-      aData = LightweightThemeImageOptimizer.optimize(aData, this._win.screen);
+  _update(theme, experiment) {
+    this._lastData = theme;
+    if (theme) {
+      theme = LightweightThemeImageOptimizer.optimize(theme, this._win.screen);
     }
 
-    let active = this._active = aData && aData.id !== DEFAULT_THEME_ID;
+    let active = this._active = theme && theme.id !== DEFAULT_THEME_ID;
 
-    if (!aData) {
-      aData = {};
+    if (!theme) {
+      theme = {};
     }
 
     let root = this._doc.documentElement;
 
-    if (active && aData.headerURL) {
+    if (active && theme.headerURL) {
       root.setAttribute("lwtheme-image", "true");
     } else {
       root.removeAttribute("lwtheme-image");
     }
 
-    if (active && aData.icons) {
-      let activeIcons = Object.keys(aData.icons).join(" ");
+    if (active && theme.icons) {
+      let activeIcons = Object.keys(theme.icons).join(" ");
       root.setAttribute("lwthemeicons", activeIcons);
     } else {
       root.removeAttribute("lwthemeicons");
     }
 
     for (let icon of ICONS) {
-      let value = aData.icons ? aData.icons[`--${icon}-icon`] : null;
+      let value = theme.icons ? theme.icons[`--${icon}-icon`] : null;
       _setImage(root, active, `--${icon}-icon`, value);
     }
 
-    _setImage(root, active, "--lwt-header-image", aData.headerURL);
-    _setImage(root, active, "--lwt-footer-image", aData.footerURL);
-    _setImage(root, active, "--lwt-additional-images", aData.additionalBackgrounds);
-    _setProperties(root, active, aData);
+    this._setExperiment(active, experiment, theme.experimental);
+    _setImage(root, active, "--lwt-header-image", theme.headerURL);
+    _setImage(root, active, "--lwt-footer-image", theme.footerURL);
+    _setImage(root, active, "--lwt-additional-images", theme.additionalBackgrounds);
+    _setProperties(root, active, theme);
 
     if (active) {
       root.setAttribute("lwtheme", "true");
     } else {
       root.removeAttribute("lwtheme");
       root.removeAttribute("lwthemetextcolor");
     }
 
-    if (active && aData.footerURL)
+    if (active && theme.footerURL)
       root.setAttribute("lwthemefooter", "true");
     else
       root.removeAttribute("lwthemefooter");
 
-    let contentThemeData = _getContentProperties(this._doc, active, aData);
+    let contentThemeData = _getContentProperties(this._doc, active, theme);
     Services.ppmm.sharedData.set(`theme/${this._winId}`, contentThemeData);
+  },
+
+  _setExperiment(active, experiment, properties) {
+    const root = this._doc.documentElement;
+    if (this._lastExperimentData) {
+      const { stylesheet, usedVariables } = this._lastExperimentData;
+      if (stylesheet) {
+        stylesheet.remove();
+      }
+      if (usedVariables) {
+        for (const variable of usedVariables) {
+          _setProperty(root, false, variable);
+        }
+      }
+    }
+    if (active && experiment) {
+      this._lastExperimentData = {};
+      if (experiment.stylesheet) {
+        /* Stylesheet URLs are validated using WebExtension schemas */
+        let stylesheetAttr = `href="${experiment.stylesheet}" type="text/css"`;
+        let stylesheet = this._doc.createProcessingInstruction("xml-stylesheet",
+          stylesheetAttr);
+        this._doc.insertBefore(stylesheet, root);
+        this._lastExperimentData.stylesheet = stylesheet;
+      }
+      let usedVariables = [];
+      if (properties.colors) {
+        for (const property in properties.colors) {
+          const cssVariable = experiment.colors[property];
+          const value = _sanitizeCSSColor(root.ownerDocument, properties.colors[property]);
+          _setProperty(root, active, cssVariable, value);
+          usedVariables.push(cssVariable);
+        }
+      }
+      if (properties.images) {
+        for (const property in properties.images) {
+          const cssVariable = experiment.images[property];
+          _setProperty(root, active, cssVariable, `url(${properties.images[property]})`);
+          usedVariables.push(cssVariable);
+        }
+      }
+      if (properties.properties) {
+        for (const property in properties.properties) {
+          const cssVariable = experiment.properties[property];
+          _setProperty(root, active, cssVariable, properties.properties[property]);
+          usedVariables.push(cssVariable);
+        }
+      }
+      this._lastExperimentData.usedVariables = usedVariables;
+    } else {
+      this._lastExperimentData = null;
+    }
   }
 };
 
 function _getContentProperties(doc, active, data) {
   if (!active) {
     return {};
   }
   let properties = {};