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 151051 300cc4ded5d62110dd5738ba09a10fb810a4b4fa
parent 151050 c867a5a5ed973cfb465a9c6b14fa981289de5172
child 151052 7be1beea532811881cdfa20bcb4ebb4187ba6143
push id3089
push userbmcbride@mozilla.com
push dateThu, 17 Oct 2013 06:59:26 +0000
treeherderfx-team@300cc4ded5d6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdolske
bugs862998
milestone27.0a1
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 {