Bug 862998 - Add glue to allow Firefox first run page to highlight UI elements. r=dolske
authorChristian Sonne <csonne@mozilla.com>
Wed, 29 May 2013 14:50:59 -0700
changeset 165995 300cc4ded5d62110dd5738ba09a10fb810a4b4fa
parent 165994 c867a5a5ed973cfb465a9c6b14fa981289de5172
child 165996 7be1beea532811881cdfa20bcb4ebb4187ba6143
push id428
push userbbajaj@mozilla.com
push dateTue, 28 Jan 2014 00:16:25 +0000
treeherdermozilla-release@cd72a7ff3a75 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdolske
bugs862998
milestone27.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 862998 - Add glue to allow Firefox first run page to highlight UI elements. r=dolske
browser/app/profile/firefox.js
browser/base/content/browser.xul
browser/base/content/content.js
browser/modules/UITour.jsm
browser/modules/moz.build
browser/modules/test/browser.ini
browser/modules/test/browser_UITour.js
browser/modules/test/uitour.html
browser/modules/test/uitour.js
browser/themes/linux/browser.css
browser/themes/osx/browser.css
browser/themes/windows/browser.css
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -227,16 +227,22 @@ pref("extensions.dss.switchPending", fal
 pref("extensions.{972ce4c6-7e08-4474-a285-3208198ce6fd}.name", "chrome://browser/locale/browser.properties");
 pref("extensions.{972ce4c6-7e08-4474-a285-3208198ce6fd}.description", "chrome://browser/locale/browser.properties");
 
 pref("xpinstall.whitelist.add", "addons.mozilla.org");
 pref("xpinstall.whitelist.add.180", "marketplace.firefox.com");
 
 pref("lightweightThemes.update.enabled", true);
 
+// UI tour experience.
+pref("browser.uitour.enabled", true);
+pref("browser.uitour.themeOrigin", "https://addons.mozilla.org/%LOCALE%/firefox/themes/");
+pref("browser.uitour.pinnedTabUrl", "https://support.mozilla.org/%LOCALE%/kb/pinned-tabs-keep-favorite-websites-open");
+pref("browser.uitour.whitelist.add.260", "www.mozilla.org,support.mozilla.org");
+
 pref("keyword.enabled", true);
 
 pref("general.useragent.locale", "@AB_CD@");
 pref("general.skins.selectedSkin", "classic/1.0");
 
 pref("general.smoothScroll", true);
 #ifdef UNIX_BUT_NOT_MAC
 pref("general.autoScroll", false);
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -179,16 +179,32 @@
                 class="editBookmarkPanelBottomButton"
                 label="&editBookmark.done.label;"
                 default="true"
                 oncommand="StarUI.panel.hidePopup();"/>
 #endif
       </hbox>
     </panel>
 
+    <!-- UI tour experience -->
+    <panel id="UITourTooltip"
+           type="arrow"
+           hidden="true"
+           consumeoutsideclicks="false"
+           noautofocus="true"
+           align="start"
+           orient="vertical"
+           role="alert">
+      <label id="UITourTooltipTitle" flex="1"/>
+      <description id="UITourTooltipDescription" flex="1"/>
+    </panel>
+    <html:div id="UITourHighlightContainer" style="position:relative">
+      <html:div id="UITourHighlight"></html:div>
+    </html:div>
+
     <panel id="socialActivatedNotification"
            type="arrow"
            hidden="true"
            consumeoutsideclicks="true"
            align="start"
            orient="horizontal"
            role="alert">
       <image id="social-activation-icon" class="popup-notification-icon"/>
--- a/browser/base/content/content.js
+++ b/browser/base/content/content.js
@@ -10,16 +10,18 @@ let Cu = Components.utils;
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this,
   "LoginManagerContent", "resource://gre/modules/LoginManagerContent.jsm");
 XPCOMUtils.defineLazyModuleGetter(this,
   "InsecurePasswordUtils", "resource://gre/modules/InsecurePasswordUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
   "resource://gre/modules/PrivateBrowsingUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "UITour",
+  "resource:///modules/UITour.jsm");
 
 // Creates a new nsIURI object.
 function makeURI(uri, originCharset, baseURI) {
   return Services.io.newURI(uri, originCharset, baseURI);
 }
 
 addMessageListener("Browser:HideSessionRestoreButton", function (message) {
   // Hide session restore button on about:home
@@ -44,16 +46,25 @@ if (Services.prefs.getBoolPref("browser.
     LoginManagerContent.onFormPassword(event);
   });
   addEventListener("DOMAutoComplete", function(event) {
     LoginManagerContent.onUsernameInput(event);
   });
   addEventListener("blur", function(event) {
     LoginManagerContent.onUsernameInput(event);
   });
+
+  addEventListener("mozUITour", function(event) {
+    if (!Services.prefs.getBoolPref("browser.uitour.enabled"))
+      return;
+
+    let handled = UITour.onPageEvent(event);
+    if (handled)
+      addEventListener("pagehide", UITour);
+  }, false, true);
 }
 
 let AboutHomeListener = {
   init: function(chromeGlobal) {
     chromeGlobal.addEventListener('AboutHomeLoad', () => this.onPageLoad(), false, true);
   },
 
   handleEvent: function(aEvent) {
new file mode 100644
--- /dev/null
+++ b/browser/modules/UITour.jsm
@@ -0,0 +1,426 @@
+// 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/.
+
+this.EXPORTED_SYMBOLS = ["UITour"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager",
+  "resource://gre/modules/LightweightThemeManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PermissionsUtils",
+  "resource://gre/modules/PermissionsUtils.jsm");
+
+
+const UITOUR_PERMISSION   = "uitour";
+const PREF_PERM_BRANCH    = "browser.uitour.";
+
+
+this.UITour = {
+  originTabs: new WeakMap(),
+  pinnedTabs: new WeakMap(),
+  urlbarCapture: new WeakMap(),
+
+  highlightEffects: ["wobble", "zoom", "color"],
+  targets: new Map([
+    ["backforward", "#unified-back-forward-button"],
+    ["appmenu", "#appmenu-button"],
+    ["home", "#home-button"],
+    ["urlbar", "#urlbar"],
+    ["bookmarks", "#bookmarks-menu-button"],
+    ["search", "#searchbar"],
+    ["searchprovider", function UITour_target_searchprovider(aDocument) {
+      let searchbar = aDocument.getElementById("searchbar");
+      return aDocument.getAnonymousElementByAttribute(searchbar,
+                                                     "anonid",
+                                                     "searchbar-engine-button");
+    }],
+  ]),
+
+  onPageEvent: function(aEvent) {
+    let contentDocument = null;
+    if (aEvent.target instanceof Ci.nsIDOMHTMLDocument)
+      contentDocument = aEvent.target;
+    else if (aEvent.target instanceof Ci.nsIDOMHTMLElement)
+      contentDocument = aEvent.target.ownerDocument;
+    else
+      return false;
+
+    // Ignore events if they're not from a trusted origin.
+    if (!this.ensureTrustedOrigin(contentDocument))
+      return false;
+
+    if (typeof aEvent.detail != "object")
+      return false;
+
+    let action = aEvent.detail.action;
+    if (typeof action != "string" || !action)
+      return false;
+
+    let data = aEvent.detail.data;
+    if (typeof data != "object")
+      return false;
+
+    let window = this.getChromeWindow(contentDocument);
+
+    switch (action) {
+      case "showHighlight": {
+        let target = this.getTarget(window, data.target);
+        if (!target)
+          return false;
+        this.showHighlight(target);
+        break;
+      }
+
+      case "hideHighlight": {
+        this.hideHighlight(window);
+        break;
+      }
+
+      case "showInfo": {
+        let target = this.getTarget(window, data.target, true);
+        if (!target)
+          return false;
+        this.showInfo(target, data.title, data.text);
+        break;
+      }
+
+      case "hideInfo": {
+        this.hideInfo(window);
+        break;
+      }
+
+      case "previewTheme": {
+        this.previewTheme(data.theme);
+        break;
+      }
+
+      case "resetTheme": {
+        this.resetTheme();
+        break;
+      }
+
+      case "addPinnedTab": {
+        this.ensurePinnedTab(window, true);
+        break;
+      }
+
+      case "removePinnedTab": {
+        this.removePinnedTab(window);
+        break;
+      }
+
+      case "showMenu": {
+        this.showMenu(window, data.name);
+        break;
+      }
+
+      case "startUrlbarCapture": {
+        if (typeof data.text != "string" || !data.text ||
+            typeof data.url != "string" || !data.url) {
+          return false;
+        }
+
+        let uri = null;
+        try {
+          uri = Services.io.newURI(data.url, null, null);
+        } catch (e) {
+          return false;
+        }
+
+        let secman = Services.scriptSecurityManager;
+        let principal = contentDocument.nodePrincipal;
+        let flags = secman.DISALLOW_INHERIT_PRINCIPAL;
+        try {
+          secman.checkLoadURIWithPrincipal(principal, uri, flags);
+        } catch (e) {
+          return false;
+        }
+
+        this.startUrlbarCapture(window, data.text, data.url);
+        break;
+      }
+
+      case "endUrlbarCapture": {
+        this.endUrlbarCapture(window);
+        break;
+      }
+    }
+
+    let tab = window.gBrowser._getTabForContentWindow(contentDocument.defaultView);
+    if (!this.originTabs.has(window))
+      this.originTabs.set(window, new Set());
+    this.originTabs.get(window).add(tab);
+
+    tab.addEventListener("TabClose", this);
+    window.gBrowser.tabContainer.addEventListener("TabSelect", this);
+    window.addEventListener("SSWindowClosing", this);
+
+    return true;
+  },
+
+  handleEvent: function(aEvent) {
+    switch (aEvent.type) {
+      case "pagehide": {
+        let window = this.getChromeWindow(aEvent.target);
+        this.teardownTour(window);
+        break;
+      }
+
+      case "TabClose": {
+        let window = aEvent.target.ownerDocument.defaultView;
+        this.teardownTour(window);
+        break;
+      }
+
+      case "TabSelect": {
+        let window = aEvent.target.ownerDocument.defaultView;
+        let pinnedTab = this.pinnedTabs.get(window);
+        if (pinnedTab && pinnedTab.tab == window.gBrowser.selectedTab)
+          break;
+        let originTabs = this.originTabs.get(window);
+        if (originTabs && originTabs.has(window.gBrowser.selectedTab))
+          break;
+
+        this.teardownTour(window);
+        break;
+      }
+
+      case "SSWindowClosing": {
+        let window = aEvent.target;
+        this.teardownTour(window, true);
+        break;
+      }
+
+      case "input": {
+        if (aEvent.target.id == "urlbar") {
+          let window = aEvent.target.ownerDocument.defaultView;
+          this.handleUrlbarInput(window);
+        }
+        break;
+      }
+    }
+  },
+
+  teardownTour: function(aWindow, aWindowClosing = false) {
+    aWindow.gBrowser.tabContainer.removeEventListener("TabSelect", this);
+    aWindow.removeEventListener("SSWindowClosing", this);
+
+    let originTabs = this.originTabs.get(aWindow);
+    if (originTabs) {
+      for (let tab of originTabs)
+        tab.removeEventListener("TabClose", this);
+    }
+    this.originTabs.delete(aWindow);
+
+    if (!aWindowClosing) {
+      this.hideHighlight(aWindow);
+      this.hideInfo(aWindow);
+    }
+
+    this.endUrlbarCapture(aWindow);
+    this.removePinnedTab(aWindow);
+    this.resetTheme();
+  },
+
+  getChromeWindow: function(aContentDocument) {
+    return aContentDocument.defaultView
+                           .window
+                           .QueryInterface(Ci.nsIInterfaceRequestor)
+                           .getInterface(Ci.nsIWebNavigation)
+                           .QueryInterface(Ci.nsIDocShellTreeItem)
+                           .rootTreeItem
+                           .QueryInterface(Ci.nsIInterfaceRequestor)
+                           .getInterface(Ci.nsIDOMWindow)
+                           .wrappedJSObject;
+  },
+
+  importPermissions: function() {
+    try {
+      PermissionsUtils.importFromPrefs(PREF_PERM_BRANCH, UITOUR_PERMISSION);
+    } catch (e) {
+      Cu.reportError(e);
+    }
+  },
+
+  ensureTrustedOrigin: function(aDocument) {
+    if (aDocument.defaultView.top != aDocument.defaultView)
+      return false;
+
+    let uri = aDocument.documentURIObject;
+
+    if (uri.schemeIs("chrome"))
+      return true;
+
+    if (!uri.schemeIs("https"))
+      return false;
+
+    this.importPermissions();
+    let permission = Services.perms.testPermission(uri, UITOUR_PERMISSION);
+    return permission == Services.perms.ALLOW_ACTION;
+  },
+
+  getTarget: function(aWindow, aTargetName, aSticky = false) {
+    if (typeof aTargetName != "string" || !aTargetName)
+      return null;
+
+    if (aTargetName == "pinnedtab")
+      return this.ensurePinnedTab(aWindow, aSticky);
+
+    let targetQuery = this.targets.get(aTargetName);
+    if (!targetQuery)
+      return null;
+
+    if (typeof targetQuery == "function")
+      return targetQuery(aWindow.document);
+
+    return aWindow.document.querySelector(targetQuery);
+  },
+
+  previewTheme: function(aTheme) {
+    let origin = Services.prefs.getCharPref("browser.uitour.themeOrigin");
+    let data = LightweightThemeManager.parseTheme(aTheme, origin);
+    if (data)
+      LightweightThemeManager.previewTheme(data);
+  },
+
+  resetTheme: function() {
+    LightweightThemeManager.resetPreview();
+  },
+
+  ensurePinnedTab: function(aWindow, aSticky = false) {
+    let tabInfo = this.pinnedTabs.get(aWindow);
+
+    if (tabInfo) {
+      tabInfo.sticky = tabInfo.sticky || aSticky;
+    } else {
+      let url = Services.urlFormatter.formatURLPref("browser.uitour.pinnedTabUrl");
+
+      let tab = aWindow.gBrowser.addTab(url);
+      aWindow.gBrowser.pinTab(tab);
+      tab.addEventListener("TabClose", () => {
+        this.pinnedTabs.delete(aWindow);
+      });
+
+      tabInfo = {
+        tab: tab,
+        sticky: aSticky
+      };
+      this.pinnedTabs.set(aWindow, tabInfo);
+    }
+
+    return tabInfo.tab;
+  },
+
+  removePinnedTab: function(aWindow) {
+    let tabInfo = this.pinnedTabs.get(aWindow);
+    if (tabInfo)
+      aWindow.gBrowser.removeTab(tabInfo.tab);
+  },
+
+  showHighlight: function(aTarget) {
+    let highlighter = aTarget.ownerDocument.getElementById("UITourHighlight");
+
+    let randomEffect = Math.floor(Math.random() * this.highlightEffects.length);
+    if (randomEffect == this.highlightEffects.length)
+      randomEffect--; // On the order of 1 in 2^62 chance of this happening.
+    highlighter.setAttribute("active", this.highlightEffects[randomEffect]);
+
+    let targetRect = aTarget.getBoundingClientRect();
+
+    highlighter.style.height = targetRect.height + "px";
+    highlighter.style.width = targetRect.width + "px";
+
+    let highlighterRect = highlighter.getBoundingClientRect();
+
+    let top = targetRect.top + (targetRect.height / 2) - (highlighterRect.height / 2);
+    highlighter.style.top = top + "px";
+    let left = targetRect.left + (targetRect.width / 2) - (highlighterRect.width / 2);
+    highlighter.style.left = left + "px";
+  },
+
+  hideHighlight: function(aWindow) {
+    let tabData = this.pinnedTabs.get(aWindow);
+    if (tabData && !tabData.sticky)
+      this.removePinnedTab(aWindow);
+
+    let highlighter = aWindow.document.getElementById("UITourHighlight");
+    highlighter.removeAttribute("active");
+  },
+
+  showInfo: function(aAnchor, aTitle, aDescription) {
+    aAnchor.focus();
+
+    let document = aAnchor.ownerDocument;
+    let tooltip = document.getElementById("UITourTooltip");
+    let tooltipTitle = document.getElementById("UITourTooltipTitle");
+    let tooltipDesc = document.getElementById("UITourTooltipDescription");
+
+    tooltip.hidePopup();
+
+    tooltipTitle.textContent = aTitle;
+    tooltipDesc.textContent = aDescription;
+
+    let alignment = "bottomcenter topright";
+    let anchorRect = aAnchor.getBoundingClientRect();
+
+    tooltip.hidden = false;
+    tooltip.openPopup(aAnchor, alignment);
+  },
+
+  hideInfo: function(aWindow) {
+    let tooltip = aWindow.document.getElementById("UITourTooltip");
+    tooltip.hidePopup();
+  },
+
+  showMenu: function(aWindow, aMenuName) {
+    function openMenuButton(aId) {
+      let menuBtn = aWindow.document.getElementById(aId);
+      if (menuBtn && menuBtn.boxObject)
+        menuBtn.boxObject.QueryInterface(Ci.nsIMenuBoxObject).openMenu(true);
+    }
+
+    if (aMenuName == "appmenu")
+      openMenuButton("appmenu-button");
+    else if (aMenuName == "bookmarks")
+      openMenuButton("bookmarks-menu-button");
+  },
+
+  startUrlbarCapture: function(aWindow, aExpectedText, aUrl) {
+    let urlbar = aWindow.document.getElementById("urlbar");
+    this.urlbarCapture.set(aWindow, {
+      expected: aExpectedText.toLocaleLowerCase(),
+      url: aUrl
+    });
+    urlbar.addEventListener("input", this);
+  },
+
+  endUrlbarCapture: function(aWindow) {
+    let urlbar = aWindow.document.getElementById("urlbar");
+    urlbar.removeEventListener("input", this);
+    this.urlbarCapture.delete(aWindow);
+  },
+
+  handleUrlbarInput: function(aWindow) {
+    if (!this.urlbarCapture.has(aWindow))
+      return;
+
+    let urlbar = aWindow.document.getElementById("urlbar");
+
+    let {expected, url} = this.urlbarCapture.get(aWindow);
+
+    if (urlbar.value.toLocaleLowerCase().localeCompare(expected) != 0)
+      return;
+
+    urlbar.handleRevert();
+
+    let tab = aWindow.gBrowser.addTab(url, {
+      owner: aWindow.gBrowser.selectedTab,
+      relatedToCurrent: true
+    });
+    aWindow.gBrowser.selectedTab = tab;
+  },
+};
--- a/browser/modules/moz.build
+++ b/browser/modules/moz.build
@@ -10,16 +10,17 @@ EXTRA_JS_MODULES += [
     'BrowserNewTabPreloader.jsm',
     'ContentClick.jsm',
     'NetworkPrioritizer.jsm',
     'SharedFrame.jsm',
     'SignInToWebsite.jsm',
     'SitePermissions.jsm',
     'Social.jsm',
     'TabCrashReporter.jsm',
+    'UITour.jsm',
     'offlineAppCache.jsm',
     'openLocationLastURL.jsm',
     'webappsUI.jsm',
     'webrtcUI.jsm',
 ]
 
 if CONFIG['MOZ_WIDGET_TOOLKIT'] == 'windows':
     EXTRA_JS_MODULES += [
--- a/browser/modules/test/browser.ini
+++ b/browser/modules/test/browser.ini
@@ -1,3 +1,5 @@
 [DEFAULT]
 
 [browser_NetworkPrioritizer.js]
+[browser_UITour.js]
+support-files = uitour.*
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/browser_UITour.js
@@ -0,0 +1,212 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let gTestTab;
+let gContentAPI;
+
+Components.utils.import("resource:///modules/UITour.jsm");
+
+function is_hidden(element) {
+  var style = element.ownerDocument.defaultView.getComputedStyle(element, "");
+  if (style.display == "none")
+    return true;
+  if (style.visibility != "visible")
+    return true;
+
+  // Hiding a parent element will hide all its children
+  if (element.parentNode != element.ownerDocument)
+    return is_hidden(element.parentNode);
+
+  return false;
+}
+
+function is_element_visible(element, msg) {
+  isnot(element, null, "Element should not be null, when checking visibility");
+  ok(!is_hidden(element), msg);
+}
+
+function is_element_hidden(element, msg) {
+  isnot(element, null, "Element should not be null, when checking visibility");
+  ok(is_hidden(element), msg);
+}
+
+function loadTestPage(callback, untrustedHost = false) {
+   if (gTestTab)
+    gBrowser.removeTab(gTestTab);
+
+  let url = getRootDirectory(gTestPath) + "uitour.html";
+  if (untrustedHost)
+    url = url.replace("chrome://mochitests/content/", "http://example.com/");
+
+  gTestTab = gBrowser.addTab(url);
+  gBrowser.selectedTab = gTestTab;
+
+  gTestTab.linkedBrowser.addEventListener("load", function onLoad() {
+    gTestTab.linkedBrowser.removeEventListener("load", onLoad);
+
+    let contentWindow = Components.utils.waiveXrays(gTestTab.linkedBrowser.contentDocument.defaultView);
+    gContentAPI = contentWindow.Mozilla.UITour;
+
+    waitForFocus(callback, contentWindow);
+  }, true);
+}
+
+function test() {
+  Services.prefs.setBoolPref("browser.uitour.enabled", true);
+
+  waitForExplicitFinish();
+
+  registerCleanupFunction(function() {
+    delete window.UITour;
+    delete window.gContentAPI;
+    if (gTestTab)
+      gBrowser.removeTab(gTestTab);
+    delete window.gTestTab;
+    Services.prefs.clearUserPref("browser.uitour.enabled", true);
+  });
+
+  function done() {
+    if (gTestTab)
+      gBrowser.removeTab(gTestTab);
+    gTestTab = null;
+
+    let highlight = document.getElementById("UITourHighlight");
+    is_element_hidden(highlight, "Highlight should be hidden after UITour tab is closed");
+
+    let popup = document.getElementById("UITourTooltip");
+    isnot(["hidding","closed"].indexOf(popup.state), -1, "Popup should be closed/hidding after UITour tab is closed");
+
+    is(UITour.pinnedTabs.get(window), null, "Any pinned tab should be closed after UITour tab is closed");
+
+    executeSoon(nextTest);
+  }
+
+  function nextTest() {
+    if (tests.length == 0) {
+      finish();
+      return;
+    }
+    let test = tests.shift();
+
+    loadTestPage(function() {
+      test(done);
+    });
+  }
+  nextTest();
+}
+
+let tests = [
+  function test_disabled(done) {
+    Services.prefs.setBoolPref("browser.uitour.enabled", false);
+
+    let highlight = document.getElementById("UITourHighlight");
+    is_element_hidden(highlight, "Highlight should initially be hidden");
+
+    gContentAPI.showHighlight("urlbar");
+    is_element_hidden(highlight, "Highlight should not be shown when feature is disabled");
+
+    Services.prefs.setBoolPref("browser.uitour.enabled", true);
+    done();
+  },
+  function test_untrusted_host(done) {
+    loadTestPage(function() {
+      let highlight = document.getElementById("UITourHighlight");
+      is_element_hidden(highlight, "Highlight should initially be hidden");
+
+      gContentAPI.showHighlight("urlbar");
+      is_element_hidden(highlight, "Highlight should not be shown on a untrusted domain");
+
+      done();
+    }, true);
+  },
+  function test_highlight(done) {
+    let highlight = document.getElementById("UITourHighlight");
+    is_element_hidden(highlight, "Highlight should initially be hidden");
+
+    gContentAPI.showHighlight("urlbar");
+    is_element_visible(highlight, "Highlight should be shown after showHighlight()");
+
+    gContentAPI.hideHighlight();
+    is_element_hidden(highlight, "Highlight should be hidden after hideHighlight()");
+
+    gContentAPI.showHighlight("urlbar");
+    is_element_visible(highlight, "Highlight should be shown after showHighlight()");
+    gContentAPI.showHighlight("backforward");
+    is_element_visible(highlight, "Highlight should be shown after showHighlight()");
+
+    done();
+  },
+  function test_info_1(done) {
+    let popup = document.getElementById("UITourTooltip");
+    let title = document.getElementById("UITourTooltipTitle");
+    let desc = document.getElementById("UITourTooltipDescription");
+    popup.addEventListener("popupshown", function onPopupShown() {
+      popup.removeEventListener("popupshown", onPopupShown);
+      is(popup.popupBoxObject.anchorNode, document.getElementById("urlbar"), "Popup should be anchored to the urlbar");
+      is(title.textContent, "test title", "Popup should have correct title");
+      is(desc.textContent, "test text", "Popup should have correct description text");
+
+      popup.addEventListener("popuphidden", function onPopupHidden() {
+        popup.removeEventListener("popuphidden", onPopupHidden);
+
+        popup.addEventListener("popupshown", function onPopupShown() {
+          popup.removeEventListener("popupshown", onPopupShown);
+          done();
+        });
+
+        gContentAPI.showInfo("urlbar", "test title", "test text");
+
+      });
+      gContentAPI.hideInfo();
+    });
+
+    gContentAPI.showInfo("urlbar", "test title", "test text");
+  },
+  function test_info_2(done) {
+    let popup = document.getElementById("UITourTooltip");
+    let title = document.getElementById("UITourTooltipTitle");
+    let desc = document.getElementById("UITourTooltipDescription");
+    popup.addEventListener("popupshown", function onPopupShown() {
+      popup.removeEventListener("popupshown", onPopupShown);
+      is(popup.popupBoxObject.anchorNode, document.getElementById("urlbar"), "Popup should be anchored to the urlbar");
+      is(title.textContent, "urlbar title", "Popup should have correct title");
+      is(desc.textContent, "urlbar text", "Popup should have correct description text");
+
+      gContentAPI.showInfo("search", "search title", "search text");
+      executeSoon(function() {
+        is(popup.popupBoxObject.anchorNode, document.getElementById("searchbar"), "Popup should be anchored to the searchbar");
+        is(title.textContent, "search title", "Popup should have correct title");
+        is(desc.textContent, "search text", "Popup should have correct description text");
+
+        done();
+      });
+    });
+
+    gContentAPI.showInfo("urlbar", "urlbar title", "urlbar text");
+  },
+  function test_pinnedTab(done) {
+    is(UITour.pinnedTabs.get(window), null, "Should not already have a pinned tab");
+
+    gContentAPI.addPinnedTab();
+    let tabInfo = UITour.pinnedTabs.get(window);
+    isnot(tabInfo, null, "Should have recorded data about a pinned tab after addPinnedTab()");
+    isnot(tabInfo.tab, null, "Should have added a pinned tab after addPinnedTab()");
+    is(tabInfo.tab.pinned, true, "Tab should be marked as pinned");
+
+    let tab = tabInfo.tab;
+
+    gContentAPI.removePinnedTab();
+    isnot(gBrowser.tabs[0], tab, "First tab should not be the pinned tab");
+    let tabInfo = UITour.pinnedTabs.get(window);
+    is(tabInfo, null, "Should not have any data about the removed pinned tab after removePinnedTab()");
+
+    gContentAPI.addPinnedTab();
+    gContentAPI.addPinnedTab();
+    gContentAPI.addPinnedTab();
+    is(gBrowser.tabs[1].pinned, false, "After multiple calls of addPinnedTab, should still only have one pinned tab");
+
+    done();
+  },
+];
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/uitour.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <title>UITour test</title>
+    <script type="application/javascript" src="uitour.js">
+    </script>
+  </head>
+  <body>
+    <h1>UITour tests</h1>
+    <p>Because Firefox is...</p>
+    <p>Never gonna let you down</p>
+    <p>Never gonna give you up</p>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/uitour.js
@@ -0,0 +1,115 @@
+/* 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/. */
+
+// Copied from the proposed JS library for Bedrock (ie, www.mozilla.org).
+
+// create namespace
+if (typeof Mozilla == 'undefined') {
+	var Mozilla = {};
+}
+
+(function($) {
+  'use strict';
+
+	// create namespace
+	if (typeof Mozilla.UITour == 'undefined') {
+		Mozilla.UITour = {};
+	}
+
+	var themeIntervalId = null;
+	function _stopCyclingThemes() {
+		if (themeIntervalId) {
+			clearInterval(themeIntervalId);
+			themeIntervalId = null;
+		}
+	}
+
+
+	function _sendEvent(action, data) {
+		var event = new CustomEvent('mozUITour', {
+			bubbles: true,
+			detail: {
+				action: action,
+				data: data || {}
+			}
+		});
+		console.log("Sending mozUITour event: ", event);
+		document.dispatchEvent(event);
+	}
+
+	Mozilla.UITour.DEFAULT_THEME_CYCLE_DELAY = 10 * 1000;
+
+	Mozilla.UITour.showHighlight = function(target) {
+		_sendEvent('showHighlight', {
+			target: target
+		});
+	};
+
+	Mozilla.UITour.hideHighlight = function() {
+		_sendEvent('hideHighlight');
+	};
+
+	Mozilla.UITour.showInfo = function(target, title, text) {
+		_sendEvent('showInfo', {
+			target: target,
+			title: title,
+			text: text
+		});
+	};
+
+	Mozilla.UITour.hideInfo = function() {
+		_sendEvent('hideInfo');
+	};
+
+	Mozilla.UITour.previewTheme = function(theme) {
+		_stopCyclingThemes();
+
+		_sendEvent('previewTheme', {
+			theme: JSON.stringify(theme)
+		});
+	};
+
+	Mozilla.UITour.resetTheme = function() {
+		_stopCyclingThemes();
+
+		_sendEvent('resetTheme');
+	};
+
+	Mozilla.UITour.cycleThemes = function(themes, delay, callback) {
+		_stopCyclingThemes();
+
+		if (!delay) {
+			delay = Mozilla.UITour.DEFAULT_THEME_CYCLE_DELAY;
+		}
+
+		function nextTheme() {
+			var theme = themes.shift();
+			themes.push(theme);
+
+			_sendEvent('previewTheme', {
+				theme: JSON.stringify(theme),
+				state: true
+			});
+
+			callback(theme);
+		}
+
+		themeIntervalId = setInterval(nextTheme, delay);
+		nextTheme();
+	};
+
+	Mozilla.UITour.addPinnedTab = function() {
+		_sendEvent('addPinnedTab');
+	};
+
+	Mozilla.UITour.removePinnedTab = function() {
+		_sendEvent('removePinnedTab');
+	};
+
+	Mozilla.UITour.showMenu = function(name) {
+		_sendEvent('showMenu', {
+			name: name
+		});
+	};
+})();
\ No newline at end of file
--- a/browser/themes/linux/browser.css
+++ b/browser/themes/linux/browser.css
@@ -2108,16 +2108,100 @@ toolbar[mode="text"] toolbarbutton.chevr
   color: #FDF3DE;
   min-width: 16px;
   text-shadow: none;
   background-image: linear-gradient(#B4211B, #8A1915);
   border-radius: 1px;
   -moz-margin-end: 2px;
 }
 
+/* UI Tour */
+
+@keyframes uitour-wobble {
+  from {
+    transform: rotate(0deg) translateX(2px) rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg) translateX(2px) rotate(-360deg);
+  }
+}
+
+@keyframes uitour-zoom {
+  from {
+    transform: scale(0.9);
+  }
+  50% {
+    transform: scale(1.1);
+  }
+  to {
+    transform: scale(0.9);
+  }
+}
+
+@keyframes uitour-color {
+  from {
+    border-color: #5B9CD9;
+  }
+  50% {
+    border-color: #FF0000;
+  }
+  to {
+    border-color: #5B9CD9;
+  }
+}
+
+html|div#UITourHighlight {
+  display: none;
+  position: absolute;
+  min-height: 32px;
+  min-width: 32px;
+  display: none;
+  border: 2px #5B9CD9 solid;
+  box-shadow: 0 0 2px #5B9CD9, inset 0 0 1px #5B9CD9;
+  border-radius: 20px;
+  z-index: 10000000000;
+}
+
+html|div#UITourHighlight[active] {
+  display: block;
+  animation-delay: 2s;
+  animation-timing-function: linear;
+  animation-iteration-count: infinite;
+  animation-fill-mode: forwards;
+}
+
+html|div#UITourHighlight[active="wobble"] {
+  animation-name: uitour-wobble;
+  animation-duration: 1s;
+}
+html|div#UITourHighlight[active="zoom"] {
+  animation-name: uitour-zoom;
+  animation-duration: 1s;
+}
+html|div#UITourHighlight[active="color"] {
+  animation-name: uitour-color;
+  animation-duration: 2s;
+}
+
+#UITourTooltip {
+  max-width: 20em;
+}
+
+#UITourTooltipTitle {
+  font-weight: bold;
+  font-size: 130%;
+  margin: 0 0 5px 0;
+}
+
+#UITourTooltipDescription {
+  max-width: 20em;
+}
+
+/* Social toolbar item */
+
 #social-provider-button {
   -moz-image-region: rect(0, 16px, 16px, 0);
   list-style-image: url(chrome://browser/skin/social/services-16.png);
 }
 
 #social-provider-button > .toolbarbutton-menu-dropmarker {
   display: none;
 }
--- a/browser/themes/osx/browser.css
+++ b/browser/themes/osx/browser.css
@@ -3723,16 +3723,98 @@ toolbarbutton.chevron > .toolbarbutton-m
 #developer-toolbar-toolbox-button[error-count]:before {
   color: #FDF3DE;
   min-width: 16px;
   text-shadow: none;
   background-image: linear-gradient(#B4211B, #8A1915);
   border-radius: 1px;
 }
 
+/* UI Tour */
+
+@keyframes uitour-wobble {
+  from {
+    transform: rotate(0deg) translateX(2px) rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg) translateX(2px) rotate(-360deg);
+  }
+}
+
+@keyframes uitour-zoom {
+  from {
+    transform: scale(0.9);
+  }
+  50% {
+    transform: scale(1.1);
+  }
+  to {
+    transform: scale(0.9);
+  }
+}
+
+@keyframes uitour-color {
+  from {
+    border-color: #5B9CD9;
+  }
+  50% {
+    border-color: #FF0000;
+  }
+  to {
+    border-color: #5B9CD9;
+  }
+}
+
+html|div#UITourHighlight {
+  display: none;
+  position: absolute;
+  min-height: 32px;
+  min-width: 32px;
+  display: none;
+  border: 2px #5B9CD9 solid;
+  box-shadow: 0 0 2px #5B9CD9, inset 0 0 1px #5B9CD9;
+  border-radius: 20px;
+  z-index: 10000000000;
+}
+
+html|div#UITourHighlight[active] {
+  display: block;
+  animation-delay: 2s;
+  animation-timing-function: linear;
+  animation-iteration-count: infinite;
+  animation-fill-mode: forwards;
+}
+
+html|div#UITourHighlight[active="wobble"] {
+  animation-name: uitour-wobble;
+  animation-duration: 1s;
+}
+html|div#UITourHighlight[active="zoom"] {
+  animation-name: uitour-zoom;
+  animation-duration: 1s;
+}
+html|div#UITourHighlight[active="color"] {
+  animation-name: uitour-color;
+  animation-duration: 2s;
+}
+
+#UITourTooltip {
+  max-width: 20em;
+}
+
+#UITourTooltipTitle {
+  font-weight: bold;
+  font-size: 130%;
+  margin: 0 0 5px 0;
+}
+
+#UITourTooltipDescription {
+  max-width: 20em;
+}
+
 /* === social toolbar button === */
 
 #social-toolbar-item > .toolbarbutton-1 {
   margin-left: 0;
   margin-right: 0;
   border-top-left-radius: 0;
   border-bottom-left-radius: 0;
   border-top-right-radius: 0;
--- a/browser/themes/windows/browser.css
+++ b/browser/themes/windows/browser.css
@@ -2850,16 +2850,97 @@ toolbarbutton.bookmark-item[dragover="tr
   color: #FDF3DE;
   min-width: 16px;
   text-shadow: none;
   background-image: linear-gradient(#B4211B, #8A1915);
   border-radius: 1px;
   -moz-margin-end: 5px;
 }
 
+/* UI Tour */
+
+@keyframes uitour-wobble {
+  from {
+    transform: rotate(0deg) translateX(2px) rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg) translateX(2px) rotate(-360deg);
+  }
+}
+
+@keyframes uitour-zoom {
+  from {
+    transform: scale(0.9);
+  }
+  50% {
+    transform: scale(1.1);
+  }
+  to {
+    transform: scale(0.9);
+  }
+}
+
+@keyframes uitour-color {
+  from {
+    border-color: #5B9CD9;
+  }
+  50% {
+    border-color: #FF0000;
+  }
+  to {
+    border-color: #5B9CD9;
+  }
+}
+
+html|div#UITourHighlight {
+  display: none;
+  position: absolute;
+  min-height: 32px;
+  min-width: 32px;
+  display: none;
+  border: 2px #5B9CD9 solid;
+  box-shadow: 0 0 2px #5B9CD9, inset 0 0 1px #5B9CD9;
+  border-radius: 20px;
+  z-index: 10000000000;
+}
+
+html|div#UITourHighlight[active] {
+  display: block;
+  animation-delay: 2s;
+  animation-timing-function: linear;
+  animation-iteration-count: infinite;
+  animation-fill-mode: forwards;
+}
+
+html|div#UITourHighlight[active="wobble"] {
+  animation-name: uitour-wobble;
+  animation-duration: 1s;
+}
+html|div#UITourHighlight[active="zoom"] {
+  animation-name: uitour-zoom;
+  animation-duration: 1s;
+}
+html|div#UITourHighlight[active="color"] {
+  animation-name: uitour-color;
+  animation-duration: 2s;
+}
+
+#UITourTooltip {
+}
+
+#UITourTooltipTitle {
+  font-weight: bold;
+  font-size: 130%;
+  margin: 0 0 5px 0;
+}
+
+#UITourTooltipDescription {
+  max-width: 20em;
+}
+
 /* Social toolbar item */
 
 #social-provider-button {
   -moz-image-region: rect(0, 16px, 16px, 0);
   list-style-image: url(chrome://browser/skin/social/services-16.png);
 }
 
 #social-provider-button > .toolbarbutton-menu-dropmarker {