Bug 1347207 - Implement theme_experiment manifest field. r=jaws
authorTim Nguyen <ntim.bugs@gmail.com>
Mon, 23 Jul 2018 18:46:40 +0100
changeset 485950 98010ec675282db53e3e48491c5efeb1496bfc4c
parent 485949 68d89e639065b204947e8007d33658a5ace141ac
child 485951 56a97a3eee824d1b5174d4ca075c080e8fbbc054
push id9719
push userffxbld-merge
push dateFri, 24 Aug 2018 17:49:46 +0000
treeherdermozilla-beta@719ec98fba77 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws
bugs1347207
milestone63.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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 = {};