Merge mozilla-central to mozilla-inbound. a=merge CLOSED TREE
authorCiure Andrei <aciure@mozilla.com>
Fri, 08 Jun 2018 00:56:15 +0300
changeset 805582 38c222c1bf73be8ef89397c23c607dfe34d748ab
parent 805581 8e386f33143372071bfbfeca7596a2144cf0ca85 (current diff)
parent 805551 ea21bf3e665d10066b6dce39873de9b353a12e57 (diff)
child 805583 0d24499ad4e81c211f892a3e2d025d2677b4eee8
push id112702
push userbmo:mh+mozilla@glandium.org
push dateFri, 08 Jun 2018 01:32:47 +0000
reviewersmerge
milestone62.0a1
Merge mozilla-central to mozilla-inbound. a=merge CLOSED TREE
browser/app/winlauncher/LaunchUnelevated.cpp
browser/app/winlauncher/LaunchUnelevated.h
browser/app/winlauncher/LauncherProcessWin.cpp
browser/app/winlauncher/LauncherProcessWin.h
browser/app/winlauncher/ProcThreadAttributes.h
netwerk/test/unit_ipc/test_bug248970_cookie_wrap.js
netwerk/test/unit_ipc/test_cookie_header_wrap.js
testing/marionette/harness/marionette_harness/tests/harness_unit/pytest.ini
testing/web-platform/meta/css/geometry/DOMMatrix-a-f-alias.html.ini
testing/web-platform/meta/css/geometry/DOMPoint-001.html.ini
testing/web-platform/meta/css/geometry/DOMRect-001.html.ini
--- a/.eslintignore
+++ b/.eslintignore
@@ -84,20 +84,18 @@ browser/components/translation/cld2/**
 # their own lint rules currently.
 browser/extensions/followonsearch/**
 browser/extensions/screenshots/**
 browser/extensions/pdfjs/content/build**
 browser/extensions/pdfjs/content/web**
 # generated or library files in pocket
 browser/extensions/pocket/content/panels/js/tmpl.js
 browser/extensions/pocket/content/panels/js/vendor/**
-# generated or library files in activity-stream
-browser/extensions/activity-stream/data/content/activity-stream.bundle.js
-browser/extensions/activity-stream/test/**
-browser/extensions/activity-stream/vendor/**
+# Activity Stream has incompatible eslintrc. `npm run lint` from its directory
+browser/extensions/activity-stream/**
 # The only file in browser/locales/ is pre-processed.
 browser/locales/**
 # imported from chromium
 browser/extensions/mortar/**
 # Generated data files
 browser/extensions/formautofill/phonenumberutils/PhoneNumberMetaData.jsm
 
 # devtools/ exclusions
--- a/.flake8
+++ b/.flake8
@@ -1,11 +1,12 @@
 [flake8]
 # See http://pep8.readthedocs.io/en/latest/intro.html#configuration
 ignore = E121, E123, E126, E129, E133, E226, E241, E242, E704, W503, E402, E741
 max-line-length = 99
 exclude =
     browser/extensions/mortar/ppapi/,
     build/pymake/,
+    node_modules,
     security/nss/,
     testing/mochitest/pywebsocket,
     tools/lint/test/files,
 
--- a/browser/app/blocklist.xml
+++ b/browser/app/blocklist.xml
@@ -1,10 +1,10 @@
 <?xml version='1.0' encoding='UTF-8'?>
-<blocklist lastupdate="1526381862851" xmlns="http://www.mozilla.org/2006/addons-blocklist">
+<blocklist lastupdate="1527680138898" xmlns="http://www.mozilla.org/2006/addons-blocklist">
   <emItems>
     <emItem blockID="i334" id="{0F827075-B026-42F3-885D-98981EE7B1AE}">
       <prefs/>
       <versionRange minVersion="0" maxVersion="*" severity="3"/>
     </emItem>
     <emItem blockID="i1211" id="flvto@hotger.com">
       <prefs/>
       <versionRange minVersion="0" maxVersion="*" severity="1"/>
--- a/browser/base/content/browser-places.js
+++ b/browser/base/content/browser-places.js
@@ -1596,16 +1596,22 @@ var BookmarkingUI = {
   },
 
   onStarCommand(aEvent) {
     // Ignore non-left clicks on the star, or if we are updating its state.
     if (!this._pendingUpdate && (aEvent.type != "click" || aEvent.button == 0)) {
       let isBookmarked = this._itemGuids.size > 0;
       if (!isBookmarked) {
         BrowserUtils.setToolbarButtonHeightProperty(this.star);
+        // there are no other animations on this element, so we can simply
+        // listen for animationend with the "once" option to clean up
+        let animatableBox = document.getElementById("star-button-animatable-box");
+        animatableBox.addEventListener("animationend", event => {
+          this.star.removeAttribute("animate");
+        }, { once: true });
         this.star.setAttribute("animate", "true");
       }
       PlacesCommandHook.bookmarkPage(gBrowser.selectedBrowser, true);
     }
   },
 
   onCurrentPageContextPopupShowing() {
     this.updateBookmarkPageMenuItem();
--- a/browser/base/content/tabbrowser.css
+++ b/browser/base/content/tabbrowser.css
@@ -31,20 +31,16 @@
 .tab-icon-overlay[crashed] {
   display: -moz-box;
 }
 
 .tab-label {
   white-space: nowrap;
 }
 
-.tab-label[multiselected] {
-  font-weight: bold;
-}
-
 .tab-label-container {
   overflow: hidden;
 }
 
 .tab-label-container[pinned] {
   width: 0;
 }
 
--- a/browser/base/content/tabbrowser.js
+++ b/browser/base/content/tabbrowser.js
@@ -3619,23 +3619,27 @@ window._gBrowser = {
    *          Can be from a different window as well
    * @param   aRestoreTabImmediately
    *          Can defer loading of the tab contents
    */
   duplicateTab(aTab, aRestoreTabImmediately) {
     return SessionStore.duplicateTab(window, aTab, 0, aRestoreTabImmediately);
   },
 
-  addToMultiSelectedTabs(aTab) {
+  addToMultiSelectedTabs(aTab, skipPositionalAttributes) {
     if (aTab.multiselected) {
       return;
     }
 
     aTab.setAttribute("multiselected", "true");
     this._multiSelectedTabsSet.add(aTab);
+
+    if (!skipPositionalAttributes) {
+      this.tabContainer._setPositionalAttributes();
+    }
   },
 
   /**
    * Adds two given tabs and all tabs between them into the (multi) selected tabs collection
    */
   addRangeToMultiSelectedTabs(aTab1, aTab2) {
     // Let's avoid going through all the heavy process below when the same
     // tab is given as params.
@@ -3647,36 +3651,41 @@ window._gBrowser = {
     const tabs = this._visibleTabs;
     const indexOfTab1 = tabs.indexOf(aTab1);
     const indexOfTab2 = tabs.indexOf(aTab2);
 
     const [lowerIndex, higherIndex] = indexOfTab1 < indexOfTab2 ?
       [indexOfTab1, indexOfTab2] : [indexOfTab2, indexOfTab1];
 
     for (let i = lowerIndex; i <= higherIndex; i++) {
-      this.addToMultiSelectedTabs(tabs[i]);
-    }
+      this.addToMultiSelectedTabs(tabs[i], true);
+    }
+    this.tabContainer._setPositionalAttributes();
   },
 
   removeFromMultiSelectedTabs(aTab) {
     if (!aTab.multiselected) {
       return;
     }
     aTab.removeAttribute("multiselected");
+    this.tabContainer._setPositionalAttributes();
     this._multiSelectedTabsSet.delete(aTab);
   },
 
-  clearMultiSelectedTabs() {
+  clearMultiSelectedTabs(updatePositionalAttributes) {
     const selectedTabs = ChromeUtils.nondeterministicGetWeakSetKeys(this._multiSelectedTabsSet);
     for (let tab of selectedTabs) {
       if (tab.isConnected && tab.multiselected) {
         tab.removeAttribute("multiselected");
       }
     }
     this._multiSelectedTabsSet = new WeakSet();
+    if (updatePositionalAttributes) {
+      this.tabContainer._setPositionalAttributes();
+    }
   },
 
   get multiSelectedTabsCount() {
     return ChromeUtils.nondeterministicGetWeakSetKeys(this._multiSelectedTabsSet)
       .filter(tab => tab.isConnected && !tab.closing)
       .length;
   },
 
--- a/browser/base/content/tabbrowser.xml
+++ b/browser/base/content/tabbrowser.xml
@@ -292,16 +292,27 @@
           let hoveredTab = this._hoveredTab;
           if (hoveredTab) {
             hoveredTab._mouseleave();
           }
           hoveredTab = this.querySelector("tab:hover");
           if (hoveredTab) {
             hoveredTab._mouseenter();
           }
+
+          // Update before-multiselected attributes.
+          // gBrowser may not be initialized yet, so avoid using it
+          for (let i = 0; i < visibleTabs.length - 1; i++) {
+            let tab = visibleTabs[i];
+            let nextTab = visibleTabs[i + 1];
+            tab.removeAttribute("before-multiselected");
+            if (nextTab.multiselected) {
+              tab.setAttribute("before-multiselected", "true");
+            }
+          }
         ]]></body>
       </method>
 
       <field name="_blockDblClick">false</field>
 
       <field name="_tabDropIndicator">
         document.getAnonymousElementByAttribute(this, "anonid", "tab-drop-indicator");
       </field>
@@ -1540,17 +1551,17 @@
   </binding>
 
   <binding id="tabbrowser-tab" display="xul:hbox"
            extends="chrome://global/content/bindings/tabbox.xml#tab">
     <content context="tabContextMenu">
       <xul:stack class="tab-stack" flex="1">
         <xul:vbox xbl:inherits="selected=visuallyselected,fadein"
                   class="tab-background">
-          <xul:hbox xbl:inherits="selected=visuallyselected"
+          <xul:hbox xbl:inherits="selected=visuallyselected,multiselected,before-multiselected"
                     class="tab-line"/>
           <xul:spacer flex="1"/>
           <xul:hbox class="tab-bottom-line"/>
         </xul:vbox>
         <xul:hbox xbl:inherits="pinned,bursting,notselectedsinceload"
                   anonid="tab-loading-burst"
                   class="tab-loading-burst"/>
         <xul:hbox xbl:inherits="pinned,selected=visuallyselected,titlechanged,attention"
@@ -1577,17 +1588,17 @@
                      class="tab-icon-overlay"
                      role="presentation"/>
           <xul:hbox class="tab-label-container"
                     xbl:inherits="pinned,selected=visuallyselected,labeldirection"
                     onoverflow="this.setAttribute('textoverflow', 'true');"
                     onunderflow="this.removeAttribute('textoverflow');"
                     flex="1">
             <xul:label class="tab-text tab-label"
-                       xbl:inherits="xbl:text=label,accesskey,fadein,pinned,selected=visuallyselected,attention,multiselected"
+                       xbl:inherits="xbl:text=label,accesskey,fadein,pinned,selected=visuallyselected,attention"
                        role="presentation"/>
           </xul:hbox>
           <xul:image xbl:inherits="soundplaying,soundplaying-scheduledremoval,pinned,muted,blocked,selected=visuallyselected,activemedia-blocked"
                      anonid="soundplaying-icon"
                      class="tab-icon-sound"
                      role="presentation"/>
           <xul:image anonid="close-button"
                      xbl:inherits="fadein,pinned,selected=visuallyselected"
@@ -1663,16 +1674,21 @@
           return this.getAttribute("muted") == "true";
         </getter>
       </property>
       <property name="multiselected" readonly="true">
         <getter>
           return this.getAttribute("multiselected") == "true";
         </getter>
       </property>
+      <property name="beforeMultiselected" readonly="true">
+        <getter>
+          return this.getAttribute("before-multiselected") == "true";
+        </getter>
+      </property>
       <!--
       Describes how the tab ended up in this mute state. May be any of:
 
        - undefined: The tabs mute state has never changed.
        - null: The mute state was last changed through the UI.
        - Any string: The ID was changed through an extension API. The string
                      must be the ID of the extension which changed it.
       -->
@@ -1999,17 +2015,22 @@
             }
             return;
           }
 
           const overCloseButton = event.originalTarget.getAttribute("anonid") == "close-button";
           if (gBrowser.multiSelectedTabsCount > 0 && !overCloseButton) {
             // Tabs were previously multi-selected and user clicks on a tab
             // without holding Ctrl/Cmd Key
-            gBrowser.clearMultiSelectedTabs();
+
+            // Force positional attributes to update when the
+            // target (of the click) is the "active" tab.
+            let updatePositionalAttr = gBrowser.selectedTab == this;
+
+            gBrowser.clearMultiSelectedTabs(updatePositionalAttr);
           }
         }
 
         if (this._overPlayingIcon) {
           this.toggleMuteAudio();
           return;
         }
 
--- a/browser/base/content/test/tabs/browser.ini
+++ b/browser/base/content/test/tabs/browser.ini
@@ -40,8 +40,9 @@ skip-if = (debug && os == 'mac') || (deb
 [browser_viewsource_of_data_URI_in_file_process.js]
 [browser_visibleTabs_bookmarkAllTabs.js]
 [browser_visibleTabs_contextMenu.js]
 [browser_open_newtab_start_observer_notification.js]
 [browser_bug_1387976_restore_lazy_tab_browser_muted_state.js]
 [browser_multiselect_tabs_using_Ctrl.js]
 [browser_multiselect_tabs_using_Shift.js]
 [browser_multiselect_tabs_close.js]
+[browser_multiselect_tabs_positional_attrs.js]
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_positional_attrs.js
@@ -0,0 +1,54 @@
+const PREF_MULTISELECT_TABS = "browser.tabs.multiselect";
+const PREF_WARN_ON_CLOSE = "browser.tabs.warnOnCloseOtherTabs";
+
+add_task(async function setPref() {
+    await SpecialPowers.pushPrefEnv({
+        set: [
+            [PREF_MULTISELECT_TABS, true],
+            [PREF_WARN_ON_CLOSE, false]
+        ]
+    });
+});
+
+add_task(async function checkBeforeMultiselectedAttributes() {
+    let tab1 = await addTab();
+    let tab2 = await addTab();
+    let tab3 = await addTab();
+
+    let visibleTabs = gBrowser._visibleTabs;
+
+    await triggerClickOn(tab3, { ctrlKey: true });
+
+    is(visibleTabs.indexOf(tab1), 1, "The index of Tab1 is one");
+    is(visibleTabs.indexOf(tab2), 2, "The index of Tab2 is two");
+    is(visibleTabs.indexOf(tab3), 3, "The index of Tab3 is three");
+
+    ok(!tab1.multiselected, "Tab1 is not multi-selected");
+    ok(!tab2.multiselected, "Tab2 is not multi-selected");
+    ok(tab3.multiselected, "Tab3 is multi-selected");
+
+    ok(!tab1.beforeMultiselected, "Tab1 is not before-multiselected");
+    ok(tab2.beforeMultiselected, "Tab2 is before-multiselected");
+
+    info("Close Tab2");
+    let tab2Closing = BrowserTestUtils.waitForTabClosing(tab2);
+    BrowserTestUtils.removeTab(tab2);
+    await tab2Closing;
+
+    // Cache invalidated, so we need to update the collection
+    visibleTabs = gBrowser._visibleTabs;
+
+    is(visibleTabs.indexOf(tab1), 1, "The index of Tab1 is one");
+    is(visibleTabs.indexOf(tab3), 2, "The index of Tab3 is two");
+    ok(tab1.beforeMultiselected, "Tab1 is before-multiselected");
+
+    // Checking if positional attributes are updated when "active" tab is clicked.
+    info("Click on the active tab to clear multiselect");
+    await triggerClickOn(gBrowser.selectedTab, {});
+
+    is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+    ok(!tab1.beforeMultiselected, "Tab1 is not before-multiselected anymore");
+
+    BrowserTestUtils.removeTab(tab1);
+    BrowserTestUtils.removeTab(tab3);
+});
--- a/browser/components/contextualidentity/test/browser/browser_broadcastchannel.js
+++ b/browser/components/contextualidentity/test/browser/browser_broadcastchannel.js
@@ -1,37 +1,76 @@
 const BASE_ORIGIN = "http://example.com";
 const URI = BASE_ORIGIN +
   "/browser/browser/components/contextualidentity/test/browser/empty_file.html";
 
-// opens `uri' in a new tab with the provided userContextId and focuses it.
-// returns the newly opened tab
+// Opens `uri' in a new tab with the provided userContextId and focuses it.
+// Returns the newly opened tab and browser.
 async function openTabInUserContext(uri, userContextId) {
   // open the tab in the correct userContextId
   let tab = BrowserTestUtils.addTab(gBrowser, uri, {userContextId});
 
   // select tab and make sure its browser is focused
   gBrowser.selectedTab = tab;
   tab.ownerGlobal.focus();
 
   let browser = gBrowser.getBrowserForTab(tab);
   await BrowserTestUtils.browserLoaded(browser);
   return {tab, browser};
 }
 
-add_task(async function setup() {
-  // make sure userContext is enabled.
-  await SpecialPowers.pushPrefEnv({"set": [
-    ["privacy.userContext.enabled", true]
-  ]});
-});
+// Opens `uri' in a new <iframe mozbrowser> with the provided userContextId.
+// Returns the newly opened browser.
+async function addBrowserFrameInUserContext(uri, userContextId) {
+  // Create a browser frame with the user context and uri
+  const browser = document.createElementNS("http://www.w3.org/1999/xhtml", "iframe");
+  browser.setAttribute("remote", "true");
+  browser.setAttribute("usercontextid", userContextId);
+  browser.setAttribute("mozbrowser", "true");
+  // `noisolation = true` means `OA.mInIsolatedMozBrowser = false` which matches
+  // the default for a XUL browser. It is indepedent from user contexts.
+  browser.setAttribute("noisolation", "true");
+  browser.setAttribute("src", uri);
+  gBrowser.tabpanels.appendChild(browser);
+
+  // Create a XUL browser-like API expected by test helpers
+  Object.defineProperty(browser, "messageManager", {
+    get() {
+      return browser.frameLoader.messageManager;
+    },
+    configurable: true,
+    enumerable: true,
+  });
+
+  await browserFrameLoaded(browser);
 
-add_task(async function test() {
-  let receiver = await openTabInUserContext(URI, 2);
+  return { browser };
+}
 
+function browserFrameLoaded(browser) {
+  const mm = browser.messageManager;
+  return new Promise(resolve => {
+    const eventName = "browser-test-utils:loadEvent";
+    mm.addMessageListener(eventName, function onLoad(msg) {
+      if (msg.target != browser) {
+        return;
+      }
+      mm.removeMessageListener(eventName, onLoad);
+      resolve(msg.data.internalURL);
+    });
+  });
+}
+
+function removeBrowserFrame({ browser }) {
+  browser.remove();
+  // Clean up Browser API parent-side data
+  delete window._browserElementParents;
+}
+
+async function runTestForReceiver(receiver) {
   let channelName = "contextualidentity-broadcastchannel";
 
   // reflect the received message on title
   await ContentTask.spawn(receiver.browser, channelName,
     function(name) {
       content.window.testPromise = new content.window.Promise(resolve => {
         content.window.bc = new content.window.BroadcastChannel(name);
         content.window.bc.onmessage = function(e) {
@@ -67,10 +106,33 @@ add_task(async function test() {
         is(content.document.title, message,
            "should only receive messages from the same user context");
       });
     }
   );
 
   gBrowser.removeTab(sender1.tab);
   gBrowser.removeTab(sender2.tab);
+}
+
+add_task(async function setup() {
+  // make sure userContext is enabled.
+  await SpecialPowers.pushPrefEnv({"set": [
+    ["privacy.userContext.enabled", true]
+  ]});
+});
+
+add_task(async function test() {
+  info("Checking broadcast channel with browser tab receiver");
+  let receiver = await openTabInUserContext(URI, 2);
+  await runTestForReceiver(receiver);
   gBrowser.removeTab(receiver.tab);
 });
+
+add_task(async function test() {
+  info("Checking broadcast channel with <iframe mozbrowser> receiver");
+  await SpecialPowers.pushPrefEnv({"set": [
+    ["dom.mozBrowserFramesEnabled", true]
+  ]});
+  let receiver = await addBrowserFrameInUserContext(URI, 2);
+  await runTestForReceiver(receiver);
+  removeBrowserFrame(receiver);
+});
--- a/browser/components/enterprisepolicies/EnterprisePolicies.js
+++ b/browser/components/enterprisepolicies/EnterprisePolicies.js
@@ -419,46 +419,39 @@ class JSONPoliciesProvider {
   }
 }
 
 class GPOPoliciesProvider {
   constructor() {
     this._policies = null;
 
     let wrk = Cc["@mozilla.org/windows-registry-key;1"].createInstance(Ci.nsIWindowsRegKey);
+
     // Machine policies override user policies, so we read
     // user policies first and then replace them if necessary.
-    wrk.open(wrk.ROOT_KEY_CURRENT_USER,
-             "SOFTWARE\\Policies",
-             wrk.ACCESS_READ);
-    if (wrk.hasChild("Mozilla\\Firefox")) {
-      this._readData(wrk);
-    }
-    wrk.close();
-
-    wrk.open(wrk.ROOT_KEY_LOCAL_MACHINE,
-             "SOFTWARE\\Policies",
-             wrk.ACCESS_READ);
-    if (wrk.hasChild("Mozilla\\Firefox")) {
-      this._readData(wrk);
-    }
-    wrk.close();
+    this._readData(wrk, wrk.ROOT_KEY_CURRENT_USER);
+    this._readData(wrk, wrk.ROOT_KEY_LOCAL_MACHINE);
   }
 
   get hasPolicies() {
     return this._policies !== null;
   }
 
   get policies() {
     return this._policies;
   }
 
   get failed() {
     return this._failed;
   }
 
-  _readData(wrk) {
-    this._policies = WindowsGPOParser.readPolicies(wrk, this._policies);
+  _readData(wrk, root) {
+    wrk.open(root, "SOFTWARE\\Policies", wrk.ACCESS_READ);
+    if (wrk.hasChild("Mozilla\\Firefox")) {
+      let isMachineRoot = (root == wrk.ROOT_KEY_LOCAL_MACHINE);
+      this._policies = WindowsGPOParser.readPolicies(wrk, this._policies, isMachineRoot);
+    }
+    wrk.close();
   }
 }
 
 var components = [EnterprisePoliciesManager];
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
--- a/browser/components/enterprisepolicies/Policies.jsm
+++ b/browser/components/enterprisepolicies/Policies.jsm
@@ -667,17 +667,17 @@ var Policies = {
                                                      newEngineParameters);
               } catch (ex) {
                 log.error("Unable to add search engine", ex);
               }
             }
           });
         }
         if (param.Default) {
-          runOnce("setDefaultSearchEngine", () => {
+          runOncePerModification("setDefaultSearchEngine", param.Default, () => {
             let defaultEngine;
             try {
               defaultEngine = Services.search.getEngineByName(param.Default);
               if (!defaultEngine) {
                 throw "No engine by that name could be found";
               }
             } catch (ex) {
               log.error(`Search engine lookup failed when attempting to set ` +
--- a/browser/components/enterprisepolicies/WindowsGPOParser.jsm
+++ b/browser/components/enterprisepolicies/WindowsGPOParser.jsm
@@ -14,57 +14,70 @@ XPCOMUtils.defineLazyGetter(this, "log",
     prefix: "GPOParser.jsm",
     // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
     // messages during development. See LOG_LEVELS in Console.jsm for details.
     maxLogLevel: "error",
     maxLogLevelPref: PREF_LOGLEVEL,
   });
 });
 
+XPCOMUtils.defineLazyModuleGetters(this, {
+  schema: "resource:///modules/policies/schema.jsm",
+});
+
 var EXPORTED_SYMBOLS = ["WindowsGPOParser"];
 
 var WindowsGPOParser = {
-  readPolicies(wrk, policies) {
+  readPolicies(wrk, policies, isMachineRoot) {
     let childWrk = wrk.openChild("Mozilla\\Firefox", wrk.ACCESS_READ);
     if (!policies) {
       policies = {};
     }
     try {
-      policies = registryToObject(childWrk, policies);
+      policies = registryToObject(childWrk, policies, isMachineRoot);
     } catch (e) {
       log.error(e);
     } finally {
       childWrk.close();
     }
     // Need an extra check here so we don't
     // JSON.stringify if we aren't in debug mode
     if (log._maxLogLevel == "debug") {
+      log.debug("root = " + isMachineRoot ? "HKEY_LOCAL_MACHINE" : "HKEY_CURRENT_USER");
       log.debug(JSON.stringify(policies, null, 2));
     }
     return policies;
   }
 };
 
-function registryToObject(wrk, policies) {
+function registryToObject(wrk, policies, isMachineRoot) {
   if (!policies) {
     policies = {};
   }
   if (wrk.valueCount > 0) {
     if (wrk.getValueName(0) == "1") {
       // If the first item is 1, just assume it is an array
       let array = [];
       for (let i = 0; i < wrk.valueCount; i++) {
         array.push(readRegistryValue(wrk, wrk.getValueName(i)));
       }
       // If it's an array, it shouldn't have any children
       return array;
     }
     for (let i = 0; i < wrk.valueCount; i++) {
       let name = wrk.getValueName(i);
       let value = readRegistryValue(wrk, name);
+
+      if (!isMachineRoot &&
+          schema.properties[name] &&
+          schema.properties[name].machine_only) {
+        log.error(`Policy ${name} is only allowed under the HKEY_LOCAL_MACHINE root`);
+        continue;
+      }
+
       policies[name] = value;
     }
   }
   if (wrk.childCount > 0) {
     if (wrk.getChildName(0) == "1") {
       // If the first item is 1, it's an array of objects
       let array = [];
       for (let i = 0; i < wrk.childCount; i++) {
--- a/browser/components/places/tests/browser/browser_bookmarkProperties_remember_folders.js
+++ b/browser/components/places/tests/browser/browser_bookmarkProperties_remember_folders.js
@@ -19,50 +19,71 @@ async function clickBookmarkStar() {
 
 async function hideBookmarksPanel() {
   let hiddenPromise = promisePopupHidden(bookmarkPanel);
   // Confirm and close the dialog.
   document.getElementById("editBookmarkPanelDoneButton").click();
   await hiddenPromise;
 }
 
-async function openPopupAndSelectFolder(guid) {
+async function openPopupAndSelectFolder(guid, newBookmark = false) {
   await clickBookmarkStar();
 
+  let notificationPromise;
+  if (!newBookmark) {
+    notificationPromise = PlacesTestUtils.waitForNotification("onItemMoved",
+      (id, oldParentId, oldIndex, newParentId, newIndex, type,
+       itemGuid, oldParentGuid, newParentGuid) => guid == newParentGuid);
+  }
+
   // Expand the folder tree.
   document.getElementById("editBMPanel_foldersExpander").click();
   document.getElementById("editBMPanel_folderTree").selectItems([guid]);
 
   await hideBookmarksPanel();
-  // Ensure the meta data has had chance to be written to disk.
-  await PlacesTestUtils.promiseAsyncUpdates();
+  if (!newBookmark) {
+    await notificationPromise;
+  }
 }
 
 async function assertRecentFolders(expectedGuids, msg) {
+  // Give the metadata chance to be written to the database before we attempt
+  // to open the dialog again.
+  let diskGuids = [];
+  await TestUtils.waitForCondition(async () => {
+    diskGuids = await PlacesUtils.metadata.get(PlacesUIUtils.LAST_USED_FOLDERS_META_KEY, []);
+    return diskGuids.length == expectedGuids.length;
+  }, `Should have written data to disk for: ${msg}`);
+
+  Assert.deepEqual(diskGuids, expectedGuids, `Should match the disk GUIDS for ${msg}`);
+
   await clickBookmarkStar();
 
   let actualGuids = [];
   function getGuids() {
+    actualGuids = [];
     const folderMenuPopup = document.getElementById("editBMPanel_folderMenuList").children[0];
 
     let separatorFound = false;
     // The list of folders goes from editBMPanel_foldersSeparator to the end.
     for (let child of folderMenuPopup.children) {
       if (separatorFound) {
         actualGuids.push(child.folderGuid);
       } else if (child.id == "editBMPanel_foldersSeparator") {
         separatorFound = true;
       }
     }
   }
 
+  // The dialog fills in the folder list asnychronously, so we might need to wait
+  // for that to complete.
   await TestUtils.waitForCondition(() => {
     getGuids();
     return actualGuids.length == expectedGuids.length;
-  }, msg);
+  }, `Should have opened dialog with expected recent folders for: ${msg}`);
 
   Assert.deepEqual(actualGuids, expectedGuids, msg);
 
   await hideBookmarksPanel();
 }
 
 add_task(async function setup() {
   await PlacesUtils.bookmarks.eraseEverything();
@@ -110,17 +131,17 @@ add_task(async function setup() {
     await PlacesUtils.bookmarks.eraseEverything();
     await PlacesUtils.metadata.delete(PlacesUIUtils.LAST_USED_FOLDERS_META_KEY);
   });
 });
 
 add_task(async function test_remember_last_folder() {
   await assertRecentFolders([], "Should have no recent folders to start with.");
 
-  await openPopupAndSelectFolder(folders[0].guid);
+  await openPopupAndSelectFolder(folders[0].guid, true);
 
   await assertRecentFolders([folders[0].guid], "Should have one folder in the list.");
 });
 
 add_task(async function test_forget_oldest_folder() {
   // Add some more folders.
   let expectedFolders = [folders[0].guid];
   for (let i = 1; i < folders.length; i++) {
--- a/browser/components/sessionstore/content/content-sessionStore.js
+++ b/browser/components/sessionstore/content/content-sessionStore.js
@@ -729,20 +729,20 @@ var MessageQueue = {
   /**
    * Whether or not sending batched messages on a timer is disabled. This should
    * only be used for debugging or testing. If you need to access this value,
    * you should probably use the timeoutDisabled getter.
    */
   _timeoutDisabled: false,
 
   /**
-   * The idle callback ID referencing an active idle callback. When no idle
-   * callback is pending, this is null.
-   * */
-  _idleCallbackID: null,
+   * True if there is already a send pending idle dispatch, set to prevent
+   * scheduling more than one. If false there may or may not be one scheduled.
+   */
+  _idleScheduled: false,
 
   /**
    * True if batched messages are not being fired on a timer. This should only
    * ever be true when debugging or during tests.
    */
   get timeoutDisabled() {
     return this._timeoutDisabled;
   },
@@ -777,20 +777,17 @@ var MessageQueue = {
     Services.prefs.removeObserver(PREF_INTERVAL, this);
     this.cleanupTimers();
   },
 
   /**
    * Cleanup pending idle callback and timer.
    */
   cleanupTimers() {
-    if (this._idleCallbackID) {
-      content.cancelIdleCallback(this._idleCallbackID);
-      this._idleCallbackID = null;
-    }
+    this._idleScheduled = false;
     if (this._timeout) {
       clearTimeout(this._timeout);
       this._timeout = null;
     }
   },
 
   observe(subject, topic, data) {
     if (topic == "nsPref:changed") {
@@ -832,36 +829,36 @@ var MessageQueue = {
   },
 
   /**
    * Sends queued data when the remaining idle time is enough or waiting too
    * long; otherwise, request an idle time again. If the |deadline| is not
    * given, this function is going to schedule the first request.
    *
    * @param deadline (object)
-   *        An IdleDeadline object passed by requestIdleCallback().
+   *        An IdleDeadline object passed by idleDispatch().
    */
   sendWhenIdle(deadline) {
     if (!content) {
       // The frameloader is being torn down. Nothing more to do.
       return;
     }
 
     if (deadline) {
       if (deadline.didTimeout || deadline.timeRemaining() > MessageQueue.NEEDED_IDLE_PERIOD_MS) {
         MessageQueue.send();
         return;
       }
-    } else if (MessageQueue._idleCallbackID) {
+    } else if (MessageQueue._idleScheduled) {
       // Bail out if there's a pending run.
       return;
     }
-    MessageQueue._idleCallbackID =
-      content.requestIdleCallback(MessageQueue.sendWhenIdle, {timeout: MessageQueue._timeoutWaitIdlePeriodMs});
-   },
+    ChromeUtils.idleDispatch(MessageQueue.sendWhenIdle, {timeout: MessageQueue._timeoutWaitIdlePeriodMs});
+    MessageQueue._idleScheduled = true;
+  },
 
   /**
    * Sends queued data to the chrome process.
    *
    * @param options (object)
    *        {flushID: 123} to specify that this is a flush
    *        {isFinal: true} to signal this is the final message sent on unload
    */
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/.eslintignore
@@ -0,0 +1,10 @@
+activity-streams-env/
+dist/
+firefox/
+logs/
+stats.json
+prerendered/
+vendor/
+data/
+bin/prerender.js
+bin/prerender.js.map
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/.eslintrc.js
@@ -0,0 +1,231 @@
+module.exports = {
+  // When adding items to this file please check for effects on sub-directories.
+  "parserOptions": {
+    "ecmaFeatures": {
+      "jsx": true
+    },
+    "sourceType": "module"
+  },
+  "env": {
+    "node": true
+  },
+  "plugins": [
+    "import", // require("eslint-plugin-import")
+    "json", // require("eslint-plugin-json")
+    "promise", // require("eslint-plugin-promise")
+    "react" // require("eslint-plugin-react")
+  ],
+  "extends": [
+    "eslint:recommended",
+    "plugin:mozilla/recommended" // require("eslint-plugin-mozilla")
+  ],
+  "overrides": [{
+    // Use a configuration that's more appropriate for JSMs
+    "files": "**/*.jsm",
+    "parserOptions": {
+      "sourceType": "script"
+    },
+    "env": {
+      "node": false
+    },
+    "rules": {
+      "no-implicit-globals": 0
+    }
+  }],
+  "rules": {
+    "promise/catch-or-return": 2,
+    "promise/param-names": 2,
+
+    "react/jsx-boolean-value": [2, "always"],
+    "react/jsx-closing-bracket-location": [2, "after-props"],
+    "react/jsx-curly-spacing": [2, "never"],
+    "react/jsx-equals-spacing": [2, "never"],
+    "react/jsx-key": 2,
+    "react/jsx-no-bind": 2,
+    "react/jsx-no-comment-textnodes": 2,
+    "react/jsx-no-duplicate-props": 2,
+    "react/jsx-no-target-blank": 2,
+    "react/jsx-no-undef": 2,
+    "react/jsx-pascal-case": 2,
+    "react/jsx-space-before-closing": [2, "always"],
+    "react/jsx-uses-react": 2,
+    "react/jsx-uses-vars": 2,
+    "react/jsx-wrap-multilines": 2,
+    "react/no-access-state-in-setstate": 2,
+    "react/no-danger": 2,
+    "react/no-deprecated": 2,
+    "react/no-did-mount-set-state": 2,
+    "react/no-did-update-set-state": 2,
+    "react/no-direct-mutation-state": 2,
+    "react/no-is-mounted": 2,
+    "react/no-unknown-property": 2,
+    "react/require-render-return": 2,
+    "react/self-closing-comp": 2,
+
+    "accessor-pairs": [2, {"setWithoutGet": true, "getWithoutSet": false}],
+    "array-bracket-newline": [2, "consistent"],
+    "array-bracket-spacing": [2, "never"],
+    "array-callback-return": 2,
+    "array-element-newline": 0,
+    "arrow-body-style": [2, "as-needed"],
+    "arrow-parens": [2, "as-needed"],
+    "block-scoped-var": 2,
+    "callback-return": 0,
+    "camelcase": 0,
+    "capitalized-comments": 0,
+    "class-methods-use-this": 0,
+    "comma-dangle": [2, "never"],
+    "consistent-this": [2, "use-bind"],
+    "curly": [2, "all"],
+    "default-case": 0,
+    "dot-location": [2, "property"],
+    "eqeqeq": 2,
+    "for-direction": 2,
+    "func-name-matching": 2,
+    "func-names": 0,
+    "func-style": 0,
+    "function-paren-newline": 0,
+    "getter-return": 2,
+    "global-require": 0,
+    "guard-for-in": 2,
+    "handle-callback-err": 2,
+    "id-blacklist": 0,
+    "id-length": 0,
+    "id-match": 0,
+    "implicit-arrow-linebreak": 0,
+    // XXX Switch back to indent once mozilla-central has decided what it is using.
+    "indent": 0,
+    "indent-legacy": ["error", 2, {"SwitchCase": 1}],
+    "init-declarations": 0,
+    "jsx-quotes": [2, "prefer-double"],
+    "line-comment-position": 0,
+    "lines-around-comment": ["error", {
+      "allowClassStart": true,
+      "allowObjectStart": true,
+      "beforeBlockComment": true
+    }],
+    "lines-between-class-members": 2,
+    "max-depth": [2, 4],
+    "max-len": 0,
+    "max-lines": 0,
+    "max-nested-callbacks": [2, 4],
+    "max-params": [2, 6],
+    "max-statements": [2, 50],
+    "max-statements-per-line": [2, {"max": 2}],
+    "multiline-comment-style": 0,
+    "multiline-ternary": 0,
+    "new-cap": [2, {"newIsCap": true, "capIsNew": false}],
+    "new-parens": 2,
+    "newline-after-var": 0,
+    "newline-before-return": 0,
+    "newline-per-chained-call": [2, {"ignoreChainWithDepth": 3}],
+    "no-alert": 2,
+    "no-await-in-loop": 0,
+    "no-bitwise": 0,
+    "no-buffer-constructor": 2,
+    "no-catch-shadow": 2,
+    "no-confusing-arrow": [2, {"allowParens": true}],
+    "no-console": 1,
+    "no-continue": 0,
+    "no-div-regex": 2,
+    "no-duplicate-imports": 2,
+    "no-empty-function": 0,
+    "no-eq-null": 2,
+    "no-extend-native": 2,
+    "no-extra-label": 2,
+    "no-extra-parens": 0,
+    "no-floating-decimal": 2,
+    "no-implicit-coercion": [2, {"allow": ["!!"]}],
+    "no-implicit-globals": 2,
+    "no-inline-comments": 0,
+    "no-invalid-this": 0,
+    "no-label-var": 2,
+    "no-loop-func": 2,
+    "no-magic-numbers": 0,
+    "no-mixed-operators": [2, {"allowSamePrecedence": true, "groups": [["&", "|", "^", "~", "<<", ">>", ">>>"], ["==", "!=", "===", "!==", ">", ">=", "<", "<="], ["&&", "||"], ["in", "instanceof"]]}],
+    "no-mixed-requires": 2,
+    "no-multi-assign": 2,
+    "no-multi-str": 2,
+    "no-multiple-empty-lines": [2, {"max": 1, "maxBOF": 0, "maxEOF": 0}],
+    "no-negated-condition": 0,
+    "no-negated-in-lhs": 2,
+    "no-new": 2,
+    "no-new-func": 2,
+    "no-new-require": 2,
+    "no-octal-escape": 2,
+    "no-param-reassign": 2,
+    "no-path-concat": 2,
+    "no-plusplus": 0,
+    "no-process-env": 0,
+    "no-process-exit": 2,
+    "no-proto": 2,
+    "no-prototype-builtins": 2,
+    "no-restricted-globals": 0,
+    "no-restricted-imports": 0,
+    "no-restricted-modules": 0,
+    "no-restricted-properties": 0,
+    "no-restricted-syntax": 0,
+    "no-return-assign": [2, "except-parens"],
+    "no-script-url": 2,
+    "no-sequences": 2,
+    "no-shadow": 2,
+    "no-spaced-func": 2,
+    "no-sync": 0,
+    "no-template-curly-in-string": 2,
+    "no-ternary": 0,
+    "no-throw-literal": 2,
+    "no-undef-init": 2,
+    "no-undefined": 0,
+    "no-underscore-dangle": 0,
+    "no-unmodified-loop-condition": 2,
+    "no-unused-expressions": 2,
+    "no-use-before-define": 2,
+    "no-useless-computed-key": 2,
+    "no-useless-constructor": 2,
+    "no-useless-rename": 2,
+    "no-var": 2,
+    "no-void": 2,
+    "no-warning-comments": 0, // TODO: Change to `1`?
+    "nonblock-statement-body-position": 2,
+    "object-curly-newline": [2, {"multiline": true}],
+    "object-curly-spacing": [2, "never"],
+    "object-property-newline": [2, {"allowMultiplePropertiesPerLine": true}],
+    "one-var": [2, "never"],
+    "one-var-declaration-per-line": [2, "initializations"],
+    "operator-assignment": [2, "always"],
+    "operator-linebreak": [2, "after"],
+    "padded-blocks": [2, "never"],
+    "padding-line-between-statements": 0,
+    "prefer-arrow-callback": ["error", {"allowNamedFunctions": true}],
+    "prefer-const": 0, // TODO: Change to `1`?
+    "prefer-destructuring": [2, {"AssignmentExpression": {"array": true}, "VariableDeclarator": {"array": true, "object": true}}],
+    "prefer-numeric-literals": 2,
+    "prefer-promise-reject-errors": 2,
+    "prefer-reflect": 0,
+    "prefer-rest-params": 2,
+    "prefer-spread": 2,
+    "prefer-template": 2,
+    "quote-props": [2, "consistent"],
+    "radix": [2, "always"],
+    "require-await": 2,
+    "require-jsdoc": 0,
+    "semi-spacing": [2, {"before": false, "after": true}],
+    "semi-style": 2,
+    "sort-imports": [2, {"ignoreCase": true}],
+    "sort-keys": 0,
+    "sort-vars": 2,
+    "space-in-parens": [2, "never"],
+    "strict": 0,
+    "switch-colon-spacing": 2,
+    "symbol-description": 2,
+    "template-curly-spacing": [2, "never"],
+    "template-tag-spacing": 2,
+    "unicode-bom": [2, "never"],
+    "valid-jsdoc": [0, {"requireReturn": false, "requireParamDescription": false, "requireReturnDescription": false}],
+    "vars-on-top": 2,
+    "wrap-iife": [2, "inside"],
+    "wrap-regex": 0,
+    "yield-star-spacing": [2, "after"],
+    "yoda": [2, "never"]
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/.mcignore
@@ -0,0 +1,18 @@
+npm-debug.log
+.DS_Store
+*.sw[po]
+*.xpi
+*.pyc
+*.update.rdf
+.gitignore
+
+/.git/
+/bin/prerender.js
+/bin/prerender.js.map
+/data/locales.json
+/dist/
+/logs/
+/node_modules/
+
+# also ignores ping centre tests
+ping-centre/
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/.nvmrc
@@ -0,0 +1,1 @@
+7.*
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/.sass-lint.yml
@@ -0,0 +1,25 @@
+options:
+  merge-default-rules: true
+
+files:
+  include: 'content-src/**/*.scss'
+
+rules:
+  class-name-format: [{convention: ["hyphenatedlowercase", "camelcase"]}]
+  extends-before-declarations: 2
+  extends-before-mixins: 2
+  force-element-nesting: 0
+  force-pseudo-nesting: 0
+  hex-notation: [2, {style: uppercase}]
+  indentation: [2, {size: 2}]
+  leading-zero: [2, {include: true}]
+  mixins-before-declarations: [2, {exclude: [breakpoint, mq]}]
+  nesting-depth: [2, {max-depth: 4}]
+  no-debug: 1
+  no-duplicate-properties: 2
+  no-misspelled-properties: [2, {extra-properties: [-moz-context-properties]}]
+  no-url-domains: 0
+  no-vendor-prefixes: 0
+  no-warn: 1
+  placeholder-in-extend: 2
+  property-sort-order: 0
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/.travis.yml
@@ -0,0 +1,33 @@
+language: node_js
+
+node_js:
+  # when changing this, be sure to edit .nvrmc and package.json too
+  - 7
+
+python:
+  - "2.7"
+
+cache:
+  directories:
+    - node_modules
+
+before_install:
+  # see https://docs.travis-ci.com/user/gui-and-headless-browsers/#Using-xvfb-to-Run-Tests-That-Require-a-GUI
+  - "export DISPLAY=:99.0"
+  - "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16 -extension RANDR"
+  - export PATH="$PATH:$HOME/.rvm/bin"
+  - sleep 3
+
+install:
+  - npm config set spin false
+  - npm install
+
+before_script:
+  - bash bin/download-firefox-travis.sh release-linux64-add-on-devel
+  - export FIREFOX_BIN=./firefox/firefox
+
+script:
+  - npm test
+
+notifications:
+  email: false
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/CODEOWNERS
@@ -0,0 +1,2 @@
+# flod as main contact for string changes
+locales/en-US/strings.properties @flodolo
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/LICENSE
@@ -0,0 +1,374 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in 
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  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/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.
+
--- a/browser/extensions/activity-stream/README.md
+++ b/browser/extensions/activity-stream/README.md
@@ -2,8 +2,21 @@
 
 This system add-on replaces the new tab page in Firefox with a new design and
 functionality as part of the Activity Stream project.
 
 The files in this directory, including vendor dependencies, are imported from the
 system-addon directory in https://github.com/mozilla/activity-stream.
 
 Read [docs/v2-system-addon](https://github.com/mozilla/activity-stream/tree/master/docs/v2-system-addon/1.GETTING_STARTED.md) for more detail.
+
+## Where should I file bugs?
+
+We regularly check the ActivityStream:NewTab component on Bugzilla.
+
+## For Developers
+
+If you are interested in contributing, take a look at [this guide](contributing.md) on where to find us and how to contribute,
+and [this guide](docs/v2-system-addon/1.GETTING_STARTED.md) for getting your development environment set up.
+
+## For Localizers
+
+Activity Stream localization is managed via [Pontoon](https://pontoon.mozilla.org/projects/activity-stream-new-tab/), not direct pull requests to the repository. If you want to fix a typo, add a new language, or simply know more about localization, please get in touch with the [existing localization team](https://pontoon.mozilla.org/teams/) for your language, or Mozilla’s [l10n-drivers](https://wiki.mozilla.org/L10n:Mozilla_Team#Mozilla_Corporation) for guidance.
new file mode 100755
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/download-firefox-artifact
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash -x
+
+# Forked from https://github.com/devtools-html/debugger.html/blob/master/bin/download-firefox-artifact
+#
+# This looks for a mozilla-central artifact build as a sibling of the
+# activity-stream tree.  If it's not there, it creates it.  If it is there, it
+# updates it.
+
+# If AS_GIT_BIN_REPO (the git repo from which prepare-mochitests-dev and
+# friends will be executed) isn't set in the environment, just use the repo
+#  we're running from.
+if [ -z ${AS_GIT_BIN_REPO+x} ]; then
+  ROOT=`dirname $0`
+  AS_GIT_BIN_REPO="../../../../activity-stream"
+else
+  ROOT=${AS_GIT_BIN_REPO}/bin
+fi
+
+# Compute the mozilla-central path based on whether AS_PINE_TEST_DIR is set
+# (i.e. whether this script has been called from test-merges.js)
+if [ -z ${AS_PINE_TEST_DIR+x} ]; then
+  FIREFOX_PATH="$ROOT/../../mozilla-central"
+else
+  FIREFOX_PATH=${AS_PINE_TEST_DIR}/mozilla-central
+fi
+
+# check that mercurial is installed
+if [ -z "`command -v hg`" ]; then
+  echo >&2 "mercurial is required for mochitests, use 'brew install mercurial' on MacOS";
+  exit 1;
+fi
+
+if [ -d "$FIREFOX_PATH" ]; then
+    # convert path to absolute path
+    FIREFOX_PATH=$(cd "$FIREFOX_PATH"; pwd)
+
+    # If we already have Firefox locally, just update it
+    cd "$FIREFOX_PATH";
+
+    if [ -n "`hg status`" ]; then
+        read -p "There are local changes to Firefox which will be overwritten. Are you sure? [Y/n] " -r
+        if [[ $REPLY == "n" ]]; then
+            exit 0;
+        fi
+
+        hg revert -a
+    fi
+
+    hg pull
+    hg update -C
+else
+    echo "Downloading Firefox source code, requires about 10-30min depending on connection"
+    hg clone https://hg.mozilla.org/mozilla-central/ "$FIREFOX_PATH"
+    # if somebody cancels (ctrl-c) out of the long download don't continue
+    exit_code=$?
+    if [ $exit_code -ne 0 ]; then
+      exit $exit_code
+    fi
+    cd "$FIREFOX_PATH"
+
+    # Make an artifact build so it builds much faster
+    echo "
+ac_add_options --enable-artifact-builds
+mk_add_options AUTOCLOBBER=1
+mk_add_options MOZ_OBJDIR=./objdir-frontend
+" > .mozconfig
+fi
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/download-firefox-travis.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+# Copied and slightly modified from https://github.com/lidel/ipfs-firefox-addon/commit/d656832eec807ebae59543982dde96932ce5bb7c
+# Licensed under Creative Commons -  CC0 1.0 Universal - https://github.com/lidel/ipfs-firefox-addon/blob/master/LICENSE
+BUILD_TYPE=${1:-$FIREFOX_RELEASE}
+echo "Looking up latest URL for $BUILD_TYPE"
+BUILD_ROOT="/pub/firefox/tinderbox-builds/mozilla-${BUILD_TYPE}/"
+ROOT="https://archive.mozilla.org"
+LATEST=$(curl -s "$ROOT$BUILD_ROOT" | grep $BUILD_TYPE | grep -Po '<a href=".+">\K[[:digit:]]+' | sort -n | tail -1)
+echo "Latest build located at $ROOT$BUILD_ROOT$LATEST"
+FILE=$(curl -s "$ROOT$BUILD_ROOT$LATEST/" | grep '.tar.' | grep -Po '<a href="\K[^"]*')
+echo "URL: $ROOT$FILE"
+wget -O "firefox-${BUILD_TYPE}.tar.bz2" "$ROOT$FILE" && tar xf "firefox-${BUILD_TYPE}.tar.bz2"
new file mode 100755
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/prepare-mochitests-dev
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash -x -e
+#
+# -e means "exit on error", so that we don't have to constantly
+# check exit codes
+#
+# Forked from https://github.com/devtools-html/debugger.html/blob/master/bin/prepare-mochitests-dev
+#
+# This sets up a mozilla-central build for local mochitest development with an
+# exported activity-stream tree and test directory.
+
+# If AS_GIT_BIN_REPO (the git repo from which prepare-mochitests-dev and
+# friends will be executed) isn't set in the environment, just use the repo
+#  we're running from.
+if [ -z ${AS_GIT_BIN_REPO+x} ]; then
+  ROOT=`dirname $0`
+  AS_GIT_BIN_REPO="../activity-stream" # as seen from mozilla-central
+else
+  ROOT=${AS_GIT_BIN_REPO}/bin
+fi
+
+# Compute the mozilla-central path based on whether AS_PINE_TEST_DIR is set
+# (i.e. whether this script has been called from test-merges.js)
+if [ -z ${AS_PINE_TEST_DIR+x} ]; then
+  FIREFOX_PATH="$ROOT/../../mozilla-central"
+else
+  FIREFOX_PATH=${AS_PINE_TEST_DIR}/mozilla-central
+fi
+
+MC_MODULE_PATH="$FIREFOX_PATH/browser/extensions/activity-stream"
+
+# By default, just use mozilla-central + the export.  If ENABLE_MC_AS is set to
+# 1, patch on top of mozilla-central + the export to turn on the AS pref and
+# turn on the tests.  Once AS is on by default in mozilla-central, stuff
+# related to ENABLE_MC_AS can go away entirely.
+ENABLE_MC_AS=${ENABLE_MC_AS-0}
+
+# This will either download or update the local Firefox repo
+"$ROOT/download-firefox-artifact"
+
+# blow away any old bits in order to workaround bug 1335976 for users
+# who are using the default objdir-frontend
+rm -f ${FIREFOX_PATH}/objdir-frontend/dist/bin/browser/features/@activity-streams/*
+
+# Clean, package, and copy the activity stream files.
+npm run buildmc
+
+# Patch mozilla-central (on top of the export) so that AS is preffed on, and
+# the mochitests are turned on.
+shopt -s nullglob # don't explode if there are no patches right now
+if [ $ENABLE_MC_AS ]; then
+  PATCHES=$AS_GIT_BIN_REPO/mozilla-central-patches/*.diff
+  for p in $PATCHES
+  do
+    patch --directory="$FIREFOX_PATH" -p1 --force --no-backup-if-mismatch \
+    --input=$p
+  done
+fi
+shopt -u nullglob
+
+# Be sure that we've built, and that the test glop in the objdir has been
+# created.
+#
+cd "$FIREFOX_PATH"
+./mach build
+exit $?
new file mode 100755
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/process-system-addon-for-package.js
@@ -0,0 +1,21 @@
+#! /usr/bin/env node
+"use strict";
+
+const MIN_FIREFOX_VERSION = "55.0a1";
+
+/* globals cd, mv, sed */
+require("shelljs/global");
+
+cd(process.argv[2]);
+
+// Convert install.rdf.in to install.rdf without substitutions
+mv("install.rdf.in", "install.rdf");
+sed("-i", /^#filter substitution/, "", "install.rdf");
+sed("-i", /(<em:minVersion>).+(<\/em:minVersion>)/, `$1${MIN_FIREFOX_VERSION}$2`, "install.rdf");
+sed("-i", /(<em:maxVersion>).+(<\/em:maxVersion>)/, "$1*$2", "install.rdf");
+
+// Convert jar.mn to chrome.manifest with just manifest
+mv("jar.mn", "chrome.manifest");
+sed("-i", /^[^%].*$/, "", "chrome.manifest");
+sed("-i", /^% (content.*) %(.*)$/, "$1 $2", "chrome.manifest");
+sed("-i", /^% (resource.*) %.*$/, "$1 .", "chrome.manifest");
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/render-activity-stream-html.js
@@ -0,0 +1,352 @@
+ /* eslint-disable no-console */
+const fs = require("fs");
+const {mkdir} = require("shelljs");
+const path = require("path");
+
+// Note: this file is generated by webpack from content-src/activity-stream-prerender.jsx
+const {prerender} = require("./prerender");
+
+const DEFAULT_LOCALE = "en-US";
+const DEFAULT_OPTIONS = {
+  addonPath: "..",
+  baseUrl: "resource://activity-stream/"
+};
+
+// This locales list is to find any similar locales that we can reuse strings
+// instead of falling back to the default, e.g., use bn-BD strings for bn-IN.
+// https://hg.mozilla.org/mozilla-central/file/tip/browser/locales/l10n.toml
+const CENTRAL_LOCALES = [
+  "ach",
+  "af",
+  "an",
+  "ar",
+  "as",
+  "ast",
+  "az",
+  "be",
+  "bg",
+  "bn-BD",
+  "bn-IN",
+  "br",
+  "bs",
+  "ca",
+  "cak",
+  "crh",
+  "cs",
+  "cy",
+  "da",
+  "de",
+  "dsb",
+  "el",
+  "en-CA",
+  "en-GB",
+  "en-ZA",
+  "eo",
+  "es-AR",
+  "es-CL",
+  "es-ES",
+  "es-MX",
+  "et",
+  "eu",
+  "fa",
+  "ff",
+  "fi",
+  "fr",
+  "fy-NL",
+  "ga-IE",
+  "gd",
+  "gl",
+  "gn",
+  "gu-IN",
+  "he",
+  "hi-IN",
+  "hr",
+  "hsb",
+  "hu",
+  "hy-AM",
+  "ia",
+  "id",
+  "is",
+  "it",
+  "ja",
+  "ja-JP-mac",
+  "ka",
+  "kab",
+  "kk",
+  "km",
+  "kn",
+  "ko",
+  "lij",
+  "lo",
+  "lt",
+  "ltg",
+  "lv",
+  "mai",
+  "mk",
+  "ml",
+  "mr",
+  "ms",
+  "my",
+  "nb-NO",
+  "ne-NP",
+  "nl",
+  "nn-NO",
+  "oc",
+  "or",
+  "pa-IN",
+  "pl",
+  "pt-BR",
+  "pt-PT",
+  "rm",
+  "ro",
+  "ru",
+  "si",
+  "sk",
+  "sl",
+  "son",
+  "sq",
+  "sr",
+  "sv-SE",
+  "ta",
+  "te",
+  "th",
+  "tl",
+  "tr",
+  "uk",
+  "ur",
+  "uz",
+  "vi",
+  "wo",
+  "xh",
+  "zh-CN",
+  "zh-TW"
+];
+
+// Locales that should be displayed RTL
+const RTL_LIST = ["ar", "he", "fa", "ur"];
+
+/**
+ * Get the language part of the locale.
+ */
+function getLanguage(locale) {
+  return locale.split("-")[0];
+}
+
+/**
+ * Get the best strings for a single provided locale using similar locales and
+ * DEFAULT_LOCALE as fallbacks.
+ */
+function getStrings(locale, allStrings) {
+  const availableLocales = Object.keys(allStrings);
+
+  const language = getLanguage(locale);
+  const similarLocales = availableLocales.filter(other =>
+    other !== locale && getLanguage(other) === language);
+
+  // Rank locales from least desired to most desired
+  const localeFallbacks = [DEFAULT_LOCALE, ...similarLocales, locale];
+
+  // Get strings from each locale replacing with those from more desired ones
+  return Object.assign({}, ...localeFallbacks.map(l => allStrings[l]));
+}
+
+/**
+ * Get the text direction of the locale.
+ */
+function getTextDirection(locale) {
+  return RTL_LIST.includes(locale.split("-")[0]) ? "rtl" : "ltr";
+}
+
+/**
+ * templateHTML - Generates HTML for activity stream, given some options and
+ * prerendered HTML if necessary.
+ *
+ * @param  {obj} options
+ *         {str} options.locale         The locale to render in lang="" attribute
+ *         {str} options.direction      The language direction to render in dir="" attribute
+ *         {str} options.baseUrl        The base URL for all local assets
+ *         {bool} options.debug         Should we use dev versions of JS libraries?
+ * @param  {str} html    The prerendered HTML created with React.renderToString (optional)
+ * @return {str}         An HTML document as a string
+ */
+function templateHTML(options, html) {
+  const isPrerendered = !!html;
+  const debugString = options.debug ? "-dev" : "";
+  const scripts = [
+    "chrome://browser/content/contentSearchUI.js",
+    `${options.baseUrl}vendor/react${debugString}.js`,
+    `${options.baseUrl}vendor/react-dom${debugString}.js`,
+    `${options.baseUrl}vendor/prop-types.js`,
+    `${options.baseUrl}vendor/react-intl.js`,
+    `${options.baseUrl}vendor/redux.js`,
+    `${options.baseUrl}vendor/react-redux.js`,
+    `${options.baseUrl}prerendered/${options.locale}/activity-stream-strings.js`,
+    `${options.baseUrl}data/content/activity-stream.bundle.js`
+  ];
+  if (isPrerendered) {
+    scripts.unshift(`${options.baseUrl}prerendered/static/activity-stream-initial-state.js`);
+  }
+  return `<!doctype html>
+<html lang="${options.locale}" dir="${options.direction}">
+  <head>
+    <meta charset="utf-8">
+    <meta http-equiv="Content-Security-Policy-Report-Only" content="script-src 'unsafe-inline'; img-src http: https: data: blob:; style-src 'unsafe-inline'; child-src 'none'; object-src 'none'; report-uri https://tiles.services.mozilla.com/v4/links/activity-stream/csp">
+    <title>${options.strings.newtab_page_title}</title>
+    <link rel="icon" type="image/png" href="chrome://branding/content/icon32.png"/>
+    <link rel="stylesheet" href="chrome://browser/content/contentSearchUI.css" />
+    <link rel="stylesheet" href="${options.baseUrl}css/activity-stream.css" />
+  </head>
+  <body class="activity-stream">
+    <div id="root">${isPrerendered ? html : ""}</div>
+    <div id="snippets-container">
+      <div id="snippets"></div>
+    </div>
+    <script>
+// Don't directly load the following scripts as part of html to let the page
+// finish loading to render the content sooner.
+for (const src of ${JSON.stringify(scripts, null, 2)}) {
+  // These dynamically inserted scripts by default are async, but we need them
+  // to load in the desired order (i.e., bundle last).
+  const script = document.body.appendChild(document.createElement("script"));
+  script.async = false;
+  script.src = src;
+}
+    </script>
+  </body>
+</html>
+`;
+}
+
+/**
+ * templateJs - Generates a js file that passes the initial state of the prerendered
+ * DOM to the React version. This is necessary to ensure the checksum matches when
+ * React mounts so that it can attach to the prerendered elements instead of blowing
+ * them away.
+ *
+ * Note that this may no longer be necessary in React 16 and we should review whether
+ * it is still necessary.
+ *
+ * @param  {string} name The name of the global to expose
+ * @param  {string} desc Extra description to include in a js comment
+ * @param  {obj}   state The data to expose as a window global
+ * @return {str}         The js file as a string
+ */
+function templateJs(name, desc, state) {
+  return `// Note - this is a generated ${desc} file.
+window.${name} = ${JSON.stringify(state, null, 2)};
+`;
+}
+
+/**
+ * writeFiles - Writes to the desired files the result of a template given
+ * various prerendered data and options.
+ *
+ * @param {string} name          Something to identify in the console
+ * @param {string} destPath      Path to write the files to
+ * @param {Map}    filesMap      Mapping of a string file name to templater
+ * @param {Object} prerenderData Contains the html and state
+ * @param {Object} options       Various options for the templater
+ */
+function writeFiles(name, destPath, filesMap, {html, state}, options) {
+  for (const [file, templater] of filesMap) {
+    fs.writeFileSync(path.join(destPath, file), templater({html, options, state}));
+  }
+  console.log("\x1b[32m", `✓ ${name}`, "\x1b[0m");
+}
+
+const STATIC_FILES = new Map([
+  ["activity-stream-debug.html", ({options}) => templateHTML(options)],
+  ["activity-stream-initial-state.js", ({state}) => templateJs("gActivityStreamPrerenderedState", "static", state)],
+  ["activity-stream-prerendered-debug.html", ({html, options}) => templateHTML(options, html)]
+]);
+
+const LOCALIZED_FILES = new Map([
+  ["activity-stream-prerendered.html", ({html, options}) => templateHTML(options, html)],
+  ["activity-stream-strings.js", ({options: {locale, strings}}) => templateJs("gActivityStreamStrings", locale, strings)],
+  ["activity-stream.html", ({options}) => templateHTML(options)]
+]);
+
+/**
+ * main - Parses command line arguments, generates html and js with templates,
+ *        and writes files to their specified locations.
+ */
+function main() { // eslint-disable-line max-statements
+  // This code parses command line arguments passed to this script.
+  // Note: process.argv.slice(2) is necessary because the first two items in
+  // process.argv are paths
+  const args = require("minimist")(process.argv.slice(2), {
+    alias: {
+      addonPath: "a",
+      baseUrl: "b"
+    }
+  });
+
+  const baseOptions = Object.assign({debug: false}, DEFAULT_OPTIONS, args || {});
+  const addonPath = path.resolve(__dirname, baseOptions.addonPath);
+  const allStrings = require(`${baseOptions.addonPath}/data/locales.json`);
+  const extraLocales = Object.keys(allStrings).filter(locale =>
+    locale !== DEFAULT_LOCALE && !CENTRAL_LOCALES.includes(locale));
+
+  const prerenderedPath = path.join(addonPath, "prerendered");
+  console.log(`Writing prerendered files to individual directories under ${prerenderedPath}:`);
+
+  // Save default locale's strings to compare against other locales' strings
+  let defaultStrings;
+  let langStrings;
+  const isSubset = (strings, existing) => existing &&
+    Object.keys(strings).every(key => strings[key] === existing[key]);
+
+  // Process the default locale first then all the ones from mozilla-central
+  const localizedLocales = [];
+  const skippedLocales = [];
+  for (const locale of [DEFAULT_LOCALE, ...CENTRAL_LOCALES, ...extraLocales]) {
+    // Skip the locale if it would have resulted in duplicate packaged files
+    const strings = getStrings(locale, allStrings);
+    if (isSubset(strings, defaultStrings) || isSubset(strings, langStrings)) {
+      skippedLocales.push(locale);
+      continue;
+    }
+
+    const prerenderData  = prerender(locale, strings);
+    const options = Object.assign({}, baseOptions, {
+      direction: getTextDirection(locale),
+      locale,
+      strings
+    });
+
+    // Put locale-specific files in their own directory
+    const localePath = path.join(prerenderedPath, "locales", locale);
+    mkdir("-p", localePath);
+    writeFiles(locale, localePath, LOCALIZED_FILES, prerenderData, options);
+
+    // Only write static files once for the default locale
+    if (locale === DEFAULT_LOCALE) {
+      const staticPath = path.join(prerenderedPath, "static");
+      mkdir("-p", staticPath);
+      writeFiles(`${locale} (static)`, staticPath, STATIC_FILES, prerenderData,
+        Object.assign({}, options, {debug: true}));
+
+      // Save the default strings to compare against other locales' strings
+      defaultStrings = strings;
+    }
+
+    // Save the language's strings to maybe reuse for the next similar locales
+    if (getLanguage(locale) === locale) {
+      langStrings = strings;
+    }
+
+    localizedLocales.push(locale);
+  }
+
+  if (skippedLocales.length) {
+    console.log("\x1b[33m", `Skipped the following locales because they use the same strings as ${DEFAULT_LOCALE} or its language locale: ${skippedLocales.join(", ")}`, "\x1b[0m");
+  }
+  if (extraLocales.length) {
+    console.log("\x1b[31m", `✗ These locales were not in CENTRAL_LOCALES, but probably should be: ${extraLocales.join(", ")}`, "\x1b[0m");
+  }
+
+  // Provide some help to copy/paste locales if tests are failing
+  console.log(`\nIf aboutNewTabService tests are failing for unexpected locales, make sure its list is updated:\nconst ACTIVITY_STREAM_LOCALES = "${localizedLocales.join(" ")}".split(" ");`);
+}
+
+main();
new file mode 100755
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/strings-import.js
@@ -0,0 +1,75 @@
+#! /usr/bin/env node
+"use strict";
+
+/* eslint-disable no-console */
+const fetch = require("node-fetch");
+
+/* globals cd, ls, mkdir, rm, ShellString */
+require("shelljs/global");
+
+const DEFAULT_LOCALE = "en-US";
+const L10N_CENTRAL = "https://hg.mozilla.org/l10n-central";
+const PROPERTIES_PATH = "raw-file/default/browser/chrome/browser/activity-stream/newtab.properties";
+const STRINGS_FILE = "strings.properties";
+
+// Get all the locales in l10n-central
+async function getLocales() {
+  console.log(`Getting locales from ${L10N_CENTRAL}`);
+
+  // Add all non-test sub repository locales
+  const locales = [];
+  const subrepos = await (await fetch(`${L10N_CENTRAL}?style=json`)).json();
+  subrepos.entries.forEach(({name}) => {
+    if (name !== "x-testing") {
+      locales.push(name);
+    }
+  });
+
+  console.log(`Got ${locales.length} locales: ${locales}`);
+  return locales;
+}
+
+// Save the properties file to the locale's directory
+async function saveProperties(locale) {
+  // Only save a file if the repository has the file
+  const url = `${L10N_CENTRAL}/${locale}/${PROPERTIES_PATH}`;
+  const response = await fetch(url);
+  if (!response.ok) {
+    // Indicate that this locale didn't save
+    return locale;
+  }
+
+  // Save the file to the right place
+  const text = await response.text();
+  mkdir(locale);
+  cd(locale);
+  ShellString(text).to(STRINGS_FILE);
+  cd("..");
+
+  // Indicate that we were successful in saving
+  return "";
+}
+
+// Replace and update each locale's strings
+async function updateLocales() {
+  console.log("Switching to and deleting existing l10n tree under: locales");
+
+  cd("locales");
+  ls().forEach(dir => {
+    // Keep the default/source locale as it might have newer strings
+    if (dir !== DEFAULT_LOCALE) {
+      rm("-r", dir);
+    }
+  });
+
+  // Save the properties file for each locale in parallel
+  const locales = await getLocales();
+  const missing = (await Promise.all(locales.map(saveProperties))).filter(v => v);
+  console.log(`Skipped ${missing.length} locales without strings: ${missing.sort()}`);
+
+  console.log(`
+Please check the diffs, add/remove files, and then commit the result. Suggested commit message:
+chore(l10n): Update from l10n-central ${new Date()}`);
+}
+
+updateLocales().catch(console.error);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/test-merges.js
@@ -0,0 +1,344 @@
+#! /usr/bin/env node
+"use strict";
+
+/* eslint-disable no-console, mozilla/no-task */
+/* this is a node script; primary interaction is via console */
+
+const Task = require("co-task");
+const process = require("process");
+const path = require("path");
+const GitHubApi = require("@octokit/rest");
+const shelljs = require("shelljs");
+const child_process = require("child_process");
+const github = new GitHubApi();
+
+// some of our API requests need to be authenticated
+let token = process.env.AS_PINE_TOKEN;
+github.authenticate({type: "token", token});
+
+// note that this token MUST have the public_repo scope set in the github API
+
+const AS_REPO_OWNER = process.env.AS_REPO_OWNER || "mozilla";
+const AS_REPO_NAME = process.env.AS_REPO_NAME || "activity-stream";
+const AS_REPO = `${AS_REPO_OWNER}/${AS_REPO_NAME}`;
+const OLDEST_PR_DATE = "2017-03-17";
+const HG = "hg"; // mercurial
+const HG_BRANCH_NAME = "pine";
+const ALREADY_PUSHED_LABEL = "pushed-to-pine";
+const TREEHERDER_PREFIX = "https://treeherder.mozilla.org/#/jobs?repo=pine&revision=";
+
+// Path to the working directory where the export/commit operations will be
+// done.  Highly advisted to be used only for this testing purpose so you don't
+// accidently clobber real work.
+//
+// There will be two child directories:
+//
+// activity-stream - the github repo to be exported from.  MUST
+//
+// * be cloned by hand before running this script
+// * be 'npm install'ed
+// * have the ${ALREADY_PUSHED_LABEL} label created by hand
+// * have the user who has issued AS_PINE_TOKEN as a collaborator for the repo
+//   in order to be able to change labels.
+//
+// mozilla-central - the hg repo for firefox. Will be created if it doesn't
+// already exist.
+const {AS_PINE_TEST_DIR} = process.env;
+
+const TESTING_LOCAL_MC = path.join(AS_PINE_TEST_DIR, "mozilla-central");
+
+const SimpleGit = require("simple-git");
+const TESTING_LOCAL_GIT = path.join(AS_PINE_TEST_DIR, AS_REPO_NAME);
+const git = new SimpleGit(TESTING_LOCAL_GIT);
+
+// Mostly useful to specify during development of the test automation so that
+// prepare-mochitests-dev and friends from the development repo get used
+// instead of from the testing repo, which won't have had any changes checked in
+// just yet.
+const AS_GIT_BIN_REPO = process.env.AS_GIT_BIN_REPO || TESTING_LOCAL_GIT;
+
+const PREPARE_MOCHITESTS_DEV =
+  path.join(AS_GIT_BIN_REPO, "bin", "prepare-mochitests-dev");
+
+/**
+ * Find all PRs merged since ${OLDEST_PR_DATE} that don't have
+ * ${ALREADY_PUSHED_LABEL}
+ *
+ * @return {Promise} Promise that resolves with the search results or rejects
+ */
+function findNewlyMergedPRs() {
+  const searchTerms = [
+    // closed PRs in our repo
+    `repo:${AS_REPO}`, "type:pr", "state:closed", "is:merged",
+
+    // don't try and mochitest old closed stuff, we don't want to kick off a
+    // zillion test jobs
+    `merged:>=${OLDEST_PR_DATE}`,
+
+    // only look at merges to master
+    "base:master",
+
+    // if it's already been pushed to pine, don't do it again
+    `-label:${ALREADY_PUSHED_LABEL}`
+  ];
+
+  console.log(`Searching ${AS_REPO} for newly merged PRs`);
+  return github.search.issues({q: searchTerms.join("+")});
+}
+
+/**
+ * Return the commitId when the given PR was merged.  This is the one
+ * we will want to export and test.
+ *
+ * @param  {String} prNumber  The number of the PR to export.
+ * @return {String}           The commitId associated with the merge of this PR.
+ */
+function getPRMergeCommitId(prNumber) {
+  return github.issues.getEvents({
+    owner: AS_REPO_OWNER,
+    repo: AS_REPO_NAME,
+    issue_number: prNumber
+  }).then(({data}) => {
+    if (data.incomplete_results) {
+      // XXX should handle this case theoretically, but since we'll be running
+      // regularly from cron, it seems unlikely that we'll even hit 30 new
+      // merges (default GitHub page size) in a single run.
+      throw new Error("data.incomplete_results is true, aborting");
+    }
+
+    let mergeEvents = data.filter(item => item.event === "merged");
+    if (mergeEvents.length > 1) {
+      throw new Error("more than one merge event, aborting");
+    } else if (!mergeEvents.length) {
+      throw new Error(`Github returned no merge events for PR ${prNumber}, aborting.  Workaround: mark this PR as pushed-to-pine, so it gets skipped`);
+    }
+    let [mergeEvent] = mergeEvents;
+
+    if (!mergeEvent.commit_id) {
+      throw new Error("merge event has no commit id attached, aborted");
+    }
+
+    return mergeEvent.commit_id;
+  }).catch(err => { throw err; });
+}
+
+/**
+ * Checks out the given commit into ${TESTING_LOCAL_GIT}
+ *
+ * @param  {String} commitId
+ * @return {Promise<String[]|?>} Resolves with commit [id, message] on checkout, or
+ *                      rejects with error
+ */
+function checkoutGitCommit(commitId) {
+  return new Promise((resolve, reject) => {
+    console.log(`Fetching changes from github remote ${AS_REPO}...`);
+    // fetch any changes from the remote
+    git.fetch({}, (err, data) => {
+      if (err) {
+        reject(err);
+        return;
+      }
+      console.log(`Starting github checkout of ${commitId}...`);
+      git.checkout(commitId, (err2, data2) => {
+        if (err2) {
+          reject(err2);
+          return;
+        }
+
+        // Pass along the original commit message
+        git.show(["-s", "--format=%B"], (err3, data3) => {
+          if (err3) {
+            reject(err3);
+            return;
+          }
+          resolve([commitId, data3.trim()]);
+        });
+      });
+    });
+  });
+}
+
+function exportToLocalMC(commitId) {
+  return new Promise((resolve, reject) => {
+    console.log("Preparing mochitest dev environment...");
+    // Weirdly, /bin/yes causes npm-run-all bundle-static to explode, so we
+    // use echo.
+    shelljs.exec(`
+      echo yes | \
+        env AS_GIT_BIN_REPO=${AS_GIT_BIN_REPO} SYMLINK_TESTS=false \
+        ENABLE_MC_AS=1 ${PREPARE_MOCHITESTS_DEV}`,
+      {async: true, cwd: TESTING_LOCAL_GIT, silent: false}, (code, stdout, stderr) => {
+        if (code) {
+          reject(new Error(`${PREPARE_MOCHITESTS_DEV} failed, exit code: ${code}`));
+          return;
+        }
+
+        resolve(commitId);
+      });
+  });
+}
+
+function commitToHg([commitId, commitMsg]) {
+  return new Promise((resolve, reject) => {
+    // we use child_process.execFile here because shelljs.exec goes through
+    // the shell, which means that if the original commit message has shell
+    // quote characters, things can go haywire in weird ways.
+    console.log(`Committing exported ${commitId} to ${AS_REPO_NAME}...`);
+    child_process.execFile(HG,
+      [
+        "commit",
+        "--addremove",
+        "-m",
+        `${commitMsg}\n\nExport of ${commitId} from ${AS_REPO_OWNER}/${AS_REPO_NAME}`,
+        "."
+      ],
+      {cwd: TESTING_LOCAL_MC, env: process.env, timeout: 5 * 60 * 1000},
+      (code, stdout, stderr) => {
+        if (code) {
+          reject(new Error(`${HG} commit failed, output: ${stderr}`));
+          return;
+        }
+
+        resolve(code);
+      }
+    );
+  });
+}
+
+/**
+ * [pushToHgProjectBranch description]
+ *
+ * @return {Promise<String|Number>} resolves with the text written to XXXstdout, or
+ *                                  rejects with the exit code from ${HG}.
+ */
+function pushToHgProjectBranch() {
+  return new Promise((resolve, reject) => {
+    shelljs.exec(`${HG} push -f ${HG_BRANCH_NAME}`, {async: true, cwd: TESTING_LOCAL_MC},
+      (code, stdout, stderr) => {
+        if (code) {
+          reject(new Error(`${HG} failed, exit code: ${code}`));
+          return;
+        }
+
+        // Grab the last linked revision from the push output
+        const [rev] = stdout.split(/(?:\/rev\/|changeset=)/).slice(-1)[0].split("\n");
+        resolve(`[Treeherder: ${rev}](${TREEHERDER_PREFIX}${rev})`);
+      }
+    );
+  });
+}
+
+/**
+ * Remove last commit from the repo so the next artifact build will work right
+ */
+function stripTipFromHg() {
+  return new Promise((resolve, reject) => {
+    console.log("Stripping tip commit from mozilla-central so the next artifact build will work ...");
+    shelljs.exec(`${HG} strip --force --rev -1`,
+      {async: true, cwd: TESTING_LOCAL_MC},
+      (code, stdout, stderr) => {
+        if (code) {
+          reject(new Error(`${HG} strip failed, output: ${stderr}`));
+          return;
+        }
+
+        resolve(code);
+      }
+    );
+  });
+}
+
+function annotateGithubPR(prNumber, annotation) {
+  console.log(`Annotating ${prNumber} with ${annotation}...`);
+
+  // We use createComment from issues instead of pullRequests because we're
+  // not commenting on a particular commit
+  return github.issues.createComment({
+    owner: AS_REPO_OWNER,
+    repo: AS_REPO_NAME,
+    number: prNumber,
+    body: annotation
+  }).catch(reason => console.log(reason));
+}
+
+/**
+ * Labels a given github PR ${ALREADY_PUSHED_LABEL}.
+ */
+function labelGithubPR(prNumber) {
+  console.log(`Labeling PR ${prNumber} with ${ALREADY_PUSHED_LABEL}...`);
+
+  return github.issues.addLabels({
+    owner: AS_REPO_OWNER,
+    repo: AS_REPO_NAME,
+    number: prNumber,
+    labels: [ALREADY_PUSHED_LABEL]
+  }).catch(reason => console.log(reason));
+}
+
+function pushPR(pr) {
+  return getPRMergeCommitId(pr.number)
+
+    // get the merged commit to test
+    .then(checkoutGitCommit)
+
+    // use prepare-mochitest-dev to export
+    .then(exportToLocalMC)
+
+    // commit latest export to hg
+    .then(commitToHg)
+
+    // hg push
+    .then(() => pushToHgProjectBranch().catch(() => {
+      stripTipFromHg();
+      throw new Error("pushToHgProjectBranch failed; tip stripped from hg");
+    }))
+
+    // annotate PR with URL to watch
+    .then(annotation => annotateGithubPR(pr.number, annotation))
+
+    // make sure next artifact build doesn't explode
+    .then(() => stripTipFromHg())
+
+    // label with ${ALREADY_PUSHED_LABEL}
+    .then(() => labelGithubPR(pr.number))
+
+    .catch(err => {
+      console.log(err);
+      throw err;
+    });
+}
+
+function main() {
+  findNewlyMergedPRs().then(({data}) => {
+    if (data.incomplete_results) {
+      throw new Error("data.incomplete_results is true, aborting");
+    }
+
+    if (data.items.length === 0) {
+      console.log("No newly merged PRs to test");
+      return;
+    }
+
+    function* executePush() {
+      for (let pr of data.items) {
+        yield pushPR(pr);
+      }
+    }
+
+    // Serialize the execution of the export and pushing tests since each
+    // depend on exclusive access the state of the git and hg repos used to
+    // stage the tests.
+    Task.spawn(executePush).then(() => {
+      console.log("Processed all new merges.");
+    }).catch(reason => {
+      console.log("Something went wrong processing the merges:", reason);
+      process.exitCode = -1;
+    });
+  })
+  .catch(reason => {
+    console.error(reason);
+    process.exitCode = -1;
+  });
+}
+
+main();
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/bin/update-version.js
@@ -0,0 +1,62 @@
+#! /usr/bin/env node
+/* globals cd, sed */
+"use strict";
+
+/**
+ * Generate update install.rdf.in in the given directory with a version string
+ * composed of YYYY.MM.DD.${minuteOfDay}-${github_commit_hash}.
+ *
+ * @note The github hash is taken from the github repo in the current directory
+ * the script is run in.
+ *
+ * @note The minute of the day was chosen so that the version number is
+ * (more-or-less) consistently increasing (modulo clock-skew and builds that
+ * happen within a minute of each other), and although it's UTC, it won't likely
+ * be confused with something in a readers own time zone.
+ *
+ * @example generated version string: 2017.08.28.1217-ebda466c
+ */
+const process = require("process");
+require("shelljs/global");
+const simpleGit = require("simple-git")(process.cwd());
+
+const time = new Date();
+const minuteOfDay = time.getUTCHours() * 60 + time.getUTCMinutes();
+
+/**
+ * Return the given string padded with 0s out to the given width.
+ *
+ * XXX we should ditch this function in favor of using padStart once
+ * we start requiring Node 8.
+ *
+ * @param {any} s - the string to pad, will be coerced to String first
+ * @param {Number} width - what's the desired width?
+ */
+function zeroPadStart(s, width) {
+  let padded = String(s);
+  while (padded.length < width) {
+    padded = `0${padded}`;
+  }
+
+  return padded;
+}
+
+// git rev-parse --short HEAD
+simpleGit.revparse(["--short", "HEAD"], (err, gitHash) => {
+  if (err) {
+    // eslint-disable-next-line no-console
+    console.error(`SimpleGit.revparse failed: ${err}`);
+    throw new Error(`SimpleGit.revparse failed: ${err}`);
+  }
+
+  // eslint-disable-next-line prefer-template
+  let versionString = String(time.getUTCFullYear()) +
+    "." + zeroPadStart(time.getUTCMonth() + 1, 2) +
+    "." + zeroPadStart(time.getUTCDate(), 2) +
+    "." + zeroPadStart(minuteOfDay, 4) +
+    "-" + gitHash.trim();
+
+  cd(process.argv[2]);
+  sed("-i", /(<em:version>).+(<\/em:version>)$/, `$1${versionString}$2`,
+      "install.rdf.in");
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/.eslintrc.js
@@ -0,0 +1,5 @@
+module.exports = {
+  rules: {
+    "import/no-commonjs": 2
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/activity-stream-prerender.jsx
@@ -0,0 +1,45 @@
+import {INITIAL_STATE, reducers} from "common/Reducers.jsm";
+import {actionTypes as at} from "common/Actions.jsm";
+import {Base} from "content-src/components/Base/Base";
+import {initStore} from "content-src/lib/init-store";
+import {PrerenderData} from "common/PrerenderData.jsm";
+import {Provider} from "react-redux";
+import React from "react";
+import ReactDOMServer from "react-dom/server";
+
+/**
+ * prerenderStore - Generate a store with the initial state required for a prerendered page
+ *
+ * @return {obj}         A store
+ */
+export function prerenderStore() {
+  const store = initStore(reducers, INITIAL_STATE);
+  store.dispatch({type: at.PREFS_INITIAL_VALUES, data: PrerenderData.initialPrefs});
+  PrerenderData.initialSections.forEach(data => store.dispatch({type: at.SECTION_REGISTER, data}));
+  return store;
+}
+
+export function prerender(locale, strings,
+                          renderToString = ReactDOMServer.renderToString) {
+  const store = prerenderStore();
+
+  const html = renderToString(
+    <Provider store={store}>
+      <Base
+        isPrerendered={true}
+        locale={locale}
+        strings={strings} />
+    </Provider>);
+
+  // If this happens, it means pre-rendering is effectively disabled, so we
+  // need to sound the alarms:
+  if (!html || !html.length) {
+    throw new Error("no HTML returned");
+  }
+
+  return {
+    html,
+    state: store.getState(),
+    store
+  };
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/activity-stream.jsx
@@ -0,0 +1,30 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {addSnippetsSubscriber} from "content-src/lib/snippets";
+import {Base} from "content-src/components/Base/Base";
+import {DetectUserSessionStart} from "content-src/lib/detect-user-session-start";
+import {initStore} from "content-src/lib/init-store";
+import {Provider} from "react-redux";
+import React from "react";
+import ReactDOM from "react-dom";
+import {reducers} from "common/Reducers.jsm";
+
+const store = initStore(reducers, global.gActivityStreamPrerenderedState);
+
+new DetectUserSessionStart(store).sendEventOrAddListener();
+
+// If we are starting in a prerendered state, we must wait until the first render
+// to request state rehydration (see Base.jsx). If we are NOT in a prerendered state,
+// we can request it immedately.
+if (!global.gActivityStreamPrerenderedState) {
+  store.dispatch(ac.AlsoToMain({type: at.NEW_TAB_STATE_REQUEST}));
+}
+
+ReactDOM.hydrate(<Provider store={store}>
+  <Base
+    isFirstrun={global.document.location.href === "about:welcome"}
+    isPrerendered={!!global.gActivityStreamPrerenderedState}
+    locale={global.document.documentElement.lang}
+    strings={global.gActivityStreamStrings} />
+</Provider>, document.getElementById("root"));
+
+addSnippetsSubscriber(store);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/asrouter-content.jsx
@@ -0,0 +1,188 @@
+import {actionCreators as ac, ASRouterActions as ra} from "common/Actions.jsm";
+import {OUTGOING_MESSAGE_NAME as AS_GENERAL_OUTGOING_MESSAGE_NAME} from "content-src/lib/init-store";
+import {ImpressionsWrapper} from "./components/ImpressionsWrapper/ImpressionsWrapper";
+import {OnboardingMessage} from "./templates/OnboardingMessage/OnboardingMessage";
+import React from "react";
+import ReactDOM from "react-dom";
+import {SimpleSnippet} from "./templates/SimpleSnippet/SimpleSnippet";
+
+const INCOMING_MESSAGE_NAME = "ASRouter:parent-to-child";
+const OUTGOING_MESSAGE_NAME = "ASRouter:child-to-parent";
+
+export const ASRouterUtils = {
+  addListener(listener) {
+    global.addMessageListener(INCOMING_MESSAGE_NAME, listener);
+  },
+  removeListener(listener) {
+    global.removeMessageListener(INCOMING_MESSAGE_NAME, listener);
+  },
+  sendMessage(action) {
+    global.sendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
+  },
+  blockById(id) {
+    ASRouterUtils.sendMessage({type: "BLOCK_MESSAGE_BY_ID", data: {id}});
+  },
+  blockBundle(bundle) {
+    ASRouterUtils.sendMessage({type: "BLOCK_BUNDLE", data: {bundle}});
+  },
+  executeAction({button_action, button_action_params}) {
+    if (button_action in ra) {
+      ASRouterUtils.sendMessage({type: button_action, data: {button_action_params}});
+    }
+  },
+  unblockById(id) {
+    ASRouterUtils.sendMessage({type: "UNBLOCK_MESSAGE_BY_ID", data: {id}});
+  },
+  unblockBundle(bundle) {
+    ASRouterUtils.sendMessage({type: "UNBLOCK_BUNDLE", data: {bundle}});
+  },
+  getNextMessage() {
+    ASRouterUtils.sendMessage({type: "GET_NEXT_MESSAGE"});
+  },
+  overrideMessage(id) {
+    ASRouterUtils.sendMessage({type: "OVERRIDE_MESSAGE", data: {id}});
+  },
+  sendTelemetry(ping) {
+    const payload = ac.ASRouterUserEvent(ping);
+    global.sendAsyncMessage(AS_GENERAL_OUTGOING_MESSAGE_NAME, payload);
+  }
+};
+
+// Note: nextProps/prevProps refer to props passed to <ImpressionsWrapper />, not <ASRouterUISurface />
+function shouldSendImpressionOnUpdate(nextProps, prevProps) {
+  return (nextProps.message.id && (!prevProps.message || prevProps.message.id !== nextProps.message.id));
+}
+
+export class ASRouterUISurface extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onMessageFromParent = this.onMessageFromParent.bind(this);
+    this.sendImpression = this.sendImpression.bind(this);
+    this.sendUserActionTelemetry = this.sendUserActionTelemetry.bind(this);
+    this.state = {message: {}, bundle: {}};
+  }
+
+  sendUserActionTelemetry(extraProps = {}) {
+    const {message, bundle} = this.state;
+    if (!message && !extraProps.message_id) {
+      throw new Error(`You must provide a message_id for bundled messages`);
+    }
+    const eventType = `${message.provider || bundle.provider}_user_event`;
+    ASRouterUtils.sendTelemetry({
+      message_id: message.id || extraProps.message_id,
+      source: extraProps.id,
+      action: eventType,
+      ...extraProps
+    });
+  }
+
+  sendImpression(extraProps) {
+    this.sendUserActionTelemetry({event: "IMPRESSION", ...extraProps});
+  }
+
+  onBlockById(id) {
+    return () => ASRouterUtils.blockById(id);
+  }
+
+  clearBundle(bundle) {
+    return () => ASRouterUtils.blockBundle(bundle);
+  }
+
+  onMessageFromParent({data: action}) {
+    switch (action.type) {
+      case "SET_MESSAGE":
+        this.setState({message: action.data});
+        break;
+      case "SET_BUNDLED_MESSAGES":
+        this.setState({bundle: action.data});
+        break;
+      case "CLEAR_MESSAGE":
+        if (action.data.id === this.state.message.id) {
+          this.setState({message: {}});
+        }
+        break;
+      case "CLEAR_BUNDLE":
+        if (this.state.bundle.bundle) {
+          this.setState({bundle: {}});
+        }
+        break;
+      case "CLEAR_ALL":
+        this.setState({message: {}, bundle: {}});
+    }
+  }
+
+  componentWillMount() {
+    ASRouterUtils.addListener(this.onMessageFromParent);
+    ASRouterUtils.sendMessage({type: "CONNECT_UI_REQUEST"});
+  }
+
+  componentWillUnmount() {
+    ASRouterUtils.removeListener(this.onMessageFromParent);
+  }
+
+  renderSnippets() {
+    return (
+      <ImpressionsWrapper
+        id="NEWTAB_FOOTER_BAR"
+        message={this.state.message}
+        sendImpression={this.sendImpression}
+        shouldSendImpressionOnUpdate={shouldSendImpressionOnUpdate}
+        // This helps with testing
+        document={this.props.document}>
+          <SimpleSnippet
+            {...this.state.message}
+            UISurface="NEWTAB_FOOTER_BAR"
+            getNextMessage={ASRouterUtils.getNextMessage}
+            onBlock={this.onBlockById(this.state.message.id)}
+            sendUserActionTelemetry={this.sendUserActionTelemetry} />
+      </ImpressionsWrapper>);
+  }
+
+  renderOnboarding() {
+    return (
+      <OnboardingMessage
+        {...this.state.bundle}
+        UISurface="NEWTAB_OVERLAY"
+        onAction={ASRouterUtils.executeAction}
+        onDoneButton={this.clearBundle(this.state.bundle.bundle)}
+        getNextMessage={ASRouterUtils.getNextMessage}
+        sendUserActionTelemetry={this.sendUserActionTelemetry} />);
+  }
+
+  render() {
+    const {message, bundle} = this.state;
+    if (!message.id && !bundle.template) { return null; }
+    if (bundle.template === "onboarding") { return this.renderOnboarding(); }
+    return this.renderSnippets();
+  }
+}
+
+ASRouterUISurface.defaultProps = {document: global.document};
+
+export class ASRouterContent {
+  constructor() {
+    this.initialized = false;
+    this.containerElement = null;
+  }
+
+  _mount() {
+    this.containerElement = global.document.getElementById("snippets-container");
+    ReactDOM.render(<ASRouterUISurface />, this.containerElement);
+  }
+
+  _unmount() {
+    ReactDOM.unmountComponentAtNode(this.containerElement);
+  }
+
+  init() {
+    this._mount();
+    this.initialized = true;
+  }
+
+  uninit() {
+    if (this.initialized) {
+      this._unmount();
+      this.initialized = false;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/Button/Button.jsx
@@ -0,0 +1,26 @@
+import React from "react";
+import {safeURI} from "../../template-utils";
+
+const ALLOWED_STYLE_TAGS = ["color", "backgroundColor"];
+
+export const Button = props => {
+  const style = {};
+
+  // Add allowed style tags from props, e.g. props.color becomes style={color: props.color}
+  for (const tag of ALLOWED_STYLE_TAGS) {
+    if (typeof props[tag] !== "undefined") {
+      style[tag] = props[tag];
+    }
+  }
+  // remove border if bg is set to something custom
+  if (style.backgroundColor) {
+    style.border = "0";
+  }
+
+  return (<a href={safeURI(props.url)}
+    onClick={props.onClick}
+    className={props.className || "ASRouterButton"}
+    style={style}>
+    {props.children}
+  </a>);
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/Button/_Button.scss
@@ -0,0 +1,13 @@
+.ASRouterButton {
+  white-space: nowrap;
+  border-radius: 4px;
+  border: 1px solid var(--newtab-border-secondary-color);
+  background-color: var(--newtab-button-secondary-color);
+  font-family: inherit;
+  padding: 8px 15px;
+  margin-inline-start: 12px;
+  color: inherit;
+  .tall & {
+    margin-inline-start: 20px;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx
@@ -0,0 +1,60 @@
+import React from "react";
+
+export const VISIBLE = "visible";
+export const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+/**
+ * Component wrapper used to send telemetry pings on every impression.
+ */
+export class ImpressionsWrapper extends React.PureComponent {
+  // This sends an event when a user sees a set of new content. If content
+  // changes while the page is hidden (i.e. preloaded or on a hidden tab),
+  // only send the event if the page becomes visible again.
+  sendImpressionOrAddListener() {
+    if (this.props.document.visibilityState === VISIBLE) {
+      this.props.sendImpression({id: this.props.id});
+    } else {
+      // We should only ever send the latest impression stats ping, so remove any
+      // older listeners.
+      if (this._onVisibilityChange) {
+        this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+      }
+
+      // When the page becomes visible, send the impression stats ping if the section isn't collapsed.
+      this._onVisibilityChange = () => {
+        if (this.props.document.visibilityState === VISIBLE) {
+          this.props.sendImpression({id: this.props.id});
+          this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+        }
+      };
+      this.props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+    }
+  }
+
+  componentWillUnmount() {
+    if (this._onVisibilityChange) {
+      this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+    }
+  }
+
+  componentDidMount() {
+    if (this.props.sendOnMount) {
+      this.sendImpressionOrAddListener();
+    }
+  }
+
+  componentDidUpdate(prevProps) {
+    if (this.props.shouldSendImpressionOnUpdate(this.props, prevProps)) {
+      this.sendImpressionOrAddListener();
+    }
+  }
+
+  render() {
+    return this.props.children;
+  }
+}
+
+ImpressionsWrapper.defaultProps = {
+  document: global.document,
+  sendOnMount: true
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx
@@ -0,0 +1,30 @@
+import React from "react";
+
+export class ModalOverlay extends React.PureComponent {
+  componentWillMount() {
+    this.setState({active: true});
+    document.body.classList.add("modal-open");
+  }
+
+  componentWillUnmount() {
+    document.body.classList.remove("modal-open");
+    this.setState({active: false});
+  }
+
+  render() {
+    const {active} = this.state;
+    const {title, button_label} = this.props;
+    return (
+      <div>
+        <div className={`modalOverlayOuter ${active ? "active" : ""}`} />
+        <div className={`modalOverlayInner ${active ? "active" : ""}`}>
+          <h2> {title} </h2>
+          {this.props.children}
+          <div className="footer">
+            <button onClick={this.props.onDoneButton} className="button primary modalButton"> {button_label} </button>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss
@@ -0,0 +1,93 @@
+.activity-stream {
+  &.modal-open {
+    overflow: hidden;
+  }
+}
+.modalOverlayOuter {
+  background: $white;
+  opacity: 0.93;
+  height: 100%;
+  position: fixed;
+  top: 0;
+  width: 100%;
+  display: none;
+  z-index: 100000;
+
+  &.active {
+    display: block;
+  }
+}
+
+.modalOverlayInner {
+  width: 960px;
+  height: 510px;
+  position: fixed;
+  top: calc(50% - 255px); // halfway down minus half the height of the modal
+  left: calc(50% - 480px); // halfway across minus half the width of the modal
+  background: $white;
+  box-shadow: 0 1px 15px 0 $black-30;
+  border-radius: 4px;
+  display: none;
+  z-index: 100001;
+
+
+  // modal takes over entire screen
+  @media(max-width: 960px) {
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+    box-shadow: none;
+    border-radius: 0;
+  }
+
+  // if modal is short enough, add a vertical scroll bar
+  @media(max-width: 850px) and (max-height: 730px) {
+    overflow-y: scroll;
+  }
+
+  &.active {
+    display: block;
+  }
+
+  h2 {
+    color: $grey-60;
+    text-align: center;
+    font-weight: 200;
+    margin-top: 30px;
+    font-size: 28px;
+    line-height: 37px;
+    letter-spacing: -0.13px;
+
+    @media(max-width: 960px) {
+      margin-top: 100px;
+    }
+
+    @media(max-width: 850px) {
+      margin-top: 30px;
+    }
+  }
+
+  .footer {
+    border-top: 1px solid $grey-30;
+    height: 70px;
+    width: 100%;
+    position: absolute;
+    bottom: 0;
+    text-align: center;
+    background-color: $white;
+
+    // if modal is short enough, footer becomes sticky
+    @media(max-width: 850px) and (max-height: 730px) {
+      position: sticky;
+    }
+
+    .modalButton {
+      margin-top: 20px;
+      width: 150px;
+      height: 30px;
+      padding: 4px 0 6px 0;
+      font-size: 15px;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/SnippetBase/SnippetBase.jsx
@@ -0,0 +1,26 @@
+import React from "react";
+
+export class SnippetBase extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onBlockClicked = this.onBlockClicked.bind(this);
+  }
+
+  onBlockClicked() {
+    this.props.sendUserActionTelemetry({event: "BLOCK", id: this.props.UISurface});
+    this.props.onBlock();
+  }
+
+  render() {
+    const {props} = this;
+
+    const containerClassName = `SnippetBaseContainer${props.className ? ` ${props.className}` : ""}`;
+
+    return (<div className={containerClassName}>
+      <div className="innerWrapper">
+        {props.children}
+      </div>
+      <button className="blockButton" onClick={this.onBlockClicked} />
+    </div>);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/components/SnippetBase/_SnippetBase.scss
@@ -0,0 +1,58 @@
+.SnippetBaseContainer {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background-color: var(--newtab-snippets-background-color);
+  color: var(--newtab-text-primary-color);
+  font-size: 12px;
+  line-height: 16px;
+  border-top: 1px solid var(--newtab-snippets-hairline-color);
+  box-shadow: $shadow-secondary;
+  display: flex;
+  align-items: center;
+
+  .innerWrapper {
+    margin: 0 auto;
+    display: flex;
+    align-items: center;
+    padding: 12px $section-horizontal-padding;
+
+    // This is to account for the block button on smaller screens
+    padding-inline-end: 36px;
+    @media (min-width: $break-point-large) {
+      padding-inline-end: $section-horizontal-padding;
+    }
+
+    max-width: $wrapper-max-width-large;
+    @media (min-width: $break-point-widest) {
+      max-width: $wrapper-max-width-widest;
+    }
+  }
+
+  .blockButton {
+    display: none;
+    background: none;
+    border: 0;
+    position: absolute;
+    top: 50%;
+    offset-inline-end: 12px;
+    height: 16px;
+    width: 16px;
+    background-image: url('resource://activity-stream/data/content/assets/glyph-dismiss-16.svg');
+    -moz-context-properties: fill;
+    fill: var(--newtab-icon-primary-color);
+    opacity: 0.5;
+    margin-top: -8px;
+    padding: 0;
+    cursor: pointer;
+
+    @media (min-width: 766px) {
+      offset-inline-end: 24px;
+    }
+  }
+
+  &:hover .blockButton {
+    display: block;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/schemas/message-format.md
@@ -0,0 +1,44 @@
+## Activity Stream Router message format
+
+Field name | Type     | Required | Description | Example / Note
+---        | ---      | ---      | ---         | ---
+`id`       | `string` | Yes | A unique identifier for the message that should not conflict with any other previous message | `ONBOARDING_1`
+`template` | `string` | Yes | An id matching an existing Activity Stream Router template | [See example](https://github.com/mozilla/activity-stream/blob/33669c67c2269078a6d3d6d324fb48175d98f634/system-addon/content-src/message-center/templates/SimpleSnippet.jsx)
+`publish_start` | `date` | No | When to start showing the message | `1524474850876`
+`publish_end` | `date` | No | When to stop showing the message | `1524474850876`
+`content` | `object` | Yes | An object containing all variables/props to be rendered in the template. Subset of allowed tags detailed below. | [See example below](#html-subset)
+`campaign` | `string` | No | Campaign id that the message belongs to | `RustWebAssembly`
+`targeting` | `string` `JEXL` | Yes | A [JEXL expression](http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#jexl-basics) with all targeting information needed in order to decide if the message is shown | Not yet implemented, [some examples](http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#examples)
+
+### Message example
+```javascript
+{
+  id: "ONBOARDING_1",
+  template: "simple_snippet",
+  content: {
+    title: "Find it faster",
+    body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box."
+  }
+}
+```
+
+### HTML subset
+The following tags are allowed in the content of the snippet: `i, b, u, strong, em, br`.
+
+Links cannot be rendered using regular anchor tags because [Fluent does not allow for href attributes](https://github.com/projectfluent/fluent.js/blob/a03d3aa833660f8c620738b26c80e46b1a4edb05/fluent-dom/src/overlay.js#L13). They will be wrapped in custom tags, for example `<cta>link</cta>` and the url will be provided as part of the payload:
+```
+{
+  "id": "7899",
+  "content": {
+    "text": "Use the CMD (CTRL) + T keyboard shortcut to <cta>open a new tab quickly!</cta>",
+    "links": {
+      "cta": {
+        "url": "https://support.mozilla.org/en-US/kb/keyboard-shortcuts-perform-firefox-tasks-quickly"
+      }
+    }
+  }
+}
+```
+If a tag that is not on the allowed is used, the text content will be extracted and displayed.
+
+Grouping multiple allowed elements is not possible, only the first level will be used: `<u><b>text</b></u>` will be interpreted as `<u>text</u>`.
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/schemas/provider-response.schema.json
@@ -0,0 +1,37 @@
+{
+  "title": "ProviderResponse",
+  "description": "A response object for remote providers of AS Router",
+  "type": "object",
+  "properties": {
+    "messages": {
+      "type": "array",
+      "description": "An array of router messages",
+      "items": {
+        "title": "RouterMessage",
+        "description": "A definition of an individual message",
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string",
+            "description": "A unique identifier for the message that should not conflict with any other previous message"
+          },
+          "template": {
+            "type": "string",
+            "description": "An id matching an existing Activity Stream Router template",
+            "enum": ["simple_snippet"]
+          },
+          "content": {
+            "type": "object",
+            "description": "An object containing all variables/props to be rendered in the template. See individual template schemas for details."
+          },
+          "targeting": {
+            "type": "string",
+            "description": "a JEXL expression representing targeting information"
+          }
+        },
+        "required": ["id", "template", "content"]
+      }
+    }
+  },
+  "required": ["messages"]
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/template-utils.js
@@ -0,0 +1,17 @@
+export function safeURI(url) {
+  if (!url) {
+    return "";
+  }
+  const {protocol} = new URL(url);
+  const isAllowed = [
+    "http:",
+    "https:",
+    "data:",
+    "resource:",
+    "chrome:"
+  ].includes(protocol);
+  if (!isAllowed) {
+    console.warn(`The protocol ${protocol} is not allowed for template URLs.`); // eslint-disable-line no-console
+  }
+  return isAllowed ? url : "";
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx
@@ -0,0 +1,52 @@
+import {ModalOverlay} from "../../components/ModalOverlay/ModalOverlay";
+import React from "react";
+
+class OnboardingCard extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onClick = this.onClick.bind(this);
+  }
+
+  onClick() {
+    const {props} = this;
+    props.sendUserActionTelemetry({event: "CLICK_BUTTON", message_id: props.id, id: props.UISurface});
+    props.onAction(props.content);
+  }
+
+  render() {
+    const {content} = this.props;
+    return (
+      <div className="onboardingMessage">
+        <div className={`onboardingMessageImage ${content.icon}`} />
+        <div className="onboardingContent">
+          <span>
+            <h3> {content.title} </h3>
+            <p> {content.text} </p>
+          </span>
+          <span>
+            <button className="button onboardingButton" onClick={this.onClick}> {content.button_label} </button>
+          </span>
+        </div>
+      </div>
+    );
+  }
+}
+
+export class OnboardingMessage extends React.PureComponent {
+  render() {
+    const {props} = this;
+    return (
+      <ModalOverlay {...props} button_label={"Start Browsing"} title={"Welcome to Firefox"}>
+        <div className="onboardingMessageContainer">
+          {props.bundle.map(message => (
+            <OnboardingCard key={message.id}
+              sendUserActionTelemetry={props.sendUserActionTelemetry}
+              onAction={props.onAction}
+              UISurface={props.UISurface}
+              {...message} />
+          ))}
+        </div>
+      </ModalOverlay>
+    );
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss
@@ -0,0 +1,142 @@
+.onboardingMessageContainer {
+  display: grid;
+  grid-column-gap: 21px;
+  grid-template-columns: auto auto auto;
+  padding-left: 30px;
+  padding-right: 30px;
+
+  // at 850px, the cards go from vertical layout to horizontal layout
+  @media(max-width: 850px) {
+    grid-template-columns: none;
+    grid-template-rows: auto auto auto;
+    padding-left: 110px;
+    padding-right: 110px;
+  }
+}
+
+.onboardingMessage {
+  height: 340px;
+  text-align: center;
+  padding: 13px;
+  font-weight: 200;
+
+  // at 850px, img floats left, content floats right next to it
+  @media(max-width: 850px) {
+    height: 170px;
+    text-align: left;
+    padding: 10px;
+    border-bottom: 1px solid #D8D8D8;
+    display: flex;
+    margin-bottom: 11px;
+
+    &:last-child {
+      border: none;
+    }
+
+    .onboardingContent {
+      padding-left: 10px;
+      height: 100%;
+
+      > span > h3 {
+        margin-top: 0;
+        margin-bottom: 4px;
+        font-weight: 400;
+      }
+
+      > span > p {
+        margin-top: 0;
+        line-height: 22px;
+        font-size: 15px;
+      }
+    }
+  }
+
+  .onboardingMessageImage {
+    height: 100px;
+    width: 120px;
+    background-size: 120px;
+    background-position: center center;
+    background-repeat: no-repeat;
+    display: inline-block;
+    vertical-align: middle;
+
+
+    @media(max-width: 850px) {
+      height: 75px;
+      min-width: 80px;
+      background-size: 80px;
+    }
+
+    &.addons {
+      background-image: url("resource://activity-stream/data/content/assets/illustration-addons@2x.png");
+    }
+
+    &.privatebrowsing {
+      background-image: url("resource://activity-stream/data/content/assets/illustration-privatebrowsing@2x.png");
+    }
+
+    &.screenshots {
+      background-image: url("resource://activity-stream/data/content/assets/illustration-screenshots@2x.png");
+    }
+
+    &.gift {
+      background-image: url("resource://activity-stream/data/content/assets/illustration-gift@2x.png");
+    }
+  }
+
+  .onboardingContent {
+    height: 175px;
+
+    > span > h3 {
+      color: $grey-90;
+      margin-bottom: 8px;
+      font-weight: 400;
+    }
+
+    > span > p {
+      color: $grey-60;
+      margin-top: 0;
+      height: 130px;
+      margin-bottom: 12px;
+      font-size: 15px;
+      line-height: 22px;
+    }
+  }
+
+  .onboardingButton {
+    background-color: $grey-90-10;
+    border: none;
+    width: 150px;
+    height: 30px;
+    margin-bottom: 23px;
+    padding: 4px 0 6px 0;
+    font-size: 15px;
+
+    // at 850px, the button shimmies down and to the right
+    @media(max-width: 850px) {
+      float: right;
+      margin-top: -60px;
+      margin-right: -10px;
+    }
+  }
+
+
+  &::before {
+    content: '';
+    height: 220px;
+    width: 1px;
+    position: absolute;
+    background-color: #D8D8D8;
+    margin-top: 40px;
+    margin-left: 215px;
+
+    // at 850px, the line goes from vertical to horizontal
+    @media(max-width: 850px) {
+      content: none;
+    }
+  }
+
+  &:last-child::before {
+    content: none;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx
@@ -0,0 +1,53 @@
+import {Button} from "../../components/Button/Button";
+import React from "react";
+import {safeURI} from "../../template-utils";
+import {SnippetBase} from "../../components/SnippetBase/SnippetBase";
+
+const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png";
+
+export class SimpleSnippet extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onButtonClick = this.onButtonClick.bind(this);
+  }
+
+  onButtonClick() {
+    this.props.sendUserActionTelemetry({event: "CLICK_BUTTON", id: this.props.UISurface});
+  }
+
+  renderTitle() {
+    const {title} = this.props.content;
+    return title ? <h3 className="title">{title}</h3> : null;
+  }
+
+  renderTitleIcon() {
+    const titleIcon = safeURI(this.props.content.title_icon);
+    return titleIcon ? <span className="titleIcon" style={{backgroundImage: `url("${titleIcon}")`}} /> : null;
+  }
+
+  renderButton(className) {
+    const {props} = this;
+    return (<Button
+      className={className}
+      onClick={this.onButtonClick}
+      url={props.content.button_url}
+      color={props.content.button_color}
+      backgroundColor={props.content.button_background_color}>
+      {props.content.button_label}
+    </Button>);
+  }
+
+  render() {
+    const {props} = this;
+    const hasLink = props.content.button_url && props.content.button_type === "anchor";
+    const hasButton = props.content.button_url && !props.content.button_type;
+    const className = `SimpleSnippet${props.content.tall ? " tall" : ""}`;
+    return (<SnippetBase {...props} className={className}>
+      <img src={safeURI(props.content.icon) || DEFAULT_ICON_PATH} className="icon" />
+      <div>
+        {this.renderTitleIcon()} {this.renderTitle()} <p className="body">{props.content.text}</p> {hasLink ? this.renderButton("ASRouterAnchor") : null}
+      </div>
+      {hasButton ? <div>{this.renderButton()}</div> : null}
+    </SnippetBase>);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json
@@ -0,0 +1,66 @@
+{
+  "title": "SimpleSnippet",
+  "description": "A simple template with an icon, text, and optional button.",
+  "version": "0.2.0",
+  "type": "object",
+  "properties": {
+    "title": {
+      "type": "string",
+      "description": "Snippet title displayed before snippet text"
+    },
+    "text": {
+      "type": "string",
+      "description": "Main body text of snippet"
+    },
+    "icon": {
+      "type": "string",
+      "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+    },
+    "title_icon": {
+      "type": "string",
+      "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+    },
+    "button_url": {
+      "type": "string",
+      "description": "A url, button_label links to this"
+    },
+    "button_label": {
+      "type": "string",
+      "description": "Text for a button next to main snippet text that links to button_url. Requires button_url."
+    },
+    "button_color": {
+      "type": "string",
+      "description": "The text color of the button. Valid CSS color."
+    },
+    "button_background_color": {
+      "type": "string",
+      "description": "The background color of the button. Valid CSS color."
+    },
+    "button_type": {
+      "type": "string",
+      "enum": ["anchor", "button"],
+      "description": "(**temporary**, until we get html support in text field Bug 1457233) Style for button, either a regular button or a text link."
+    },
+    "tall": {
+      "type": "boolean",
+      "description": "To be used by fundraising only, increases height to roughly 120px. Defaults to false."
+    },
+    "links": {
+      "additionalProperties": {
+        "url": {
+          "type": "string",
+          "description": "The url where the link points to."
+        }
+      }
+    }
+  },
+  "additionalProperties": false,
+  "required": ["text"],
+  "dependencies": {
+    "button_url": ["button_label"],
+    "button_label": ["button_url"],
+    "button_type": ["button_url"],
+    "button_color": ["button_url"],
+    "button_background_color": ["button_url"]
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss
@@ -0,0 +1,42 @@
+.SimpleSnippet {
+  &.tall {
+    padding: 27px 0;
+  }
+
+  .title {
+    display: inline;
+    font-size: inherit;
+    margin: 0;
+  }
+
+  .titleIcon {
+    background-repeat: no-repeat;
+    background-size: 14px;
+    height: 16px;
+    width: 16px;
+    margin-top: 2px;
+    margin-inline-end: 2px;
+    display: inline-block;
+    vertical-align: top;
+  }
+
+  .body {
+    display: inline;
+    margin: 0;
+  }
+
+  .icon {
+    height: 42px;
+    width: 42px;
+    margin-inline-end: 12px;
+    flex-shrink: 0;
+  }
+  &.tall .icon {
+    margin-inline-end: 20px;
+  }
+
+  .ASRouterAnchor {
+    color: inherit;
+    text-decoration: underline;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx
@@ -0,0 +1,101 @@
+import {ASRouterUtils} from "../../asrouter/asrouter-content";
+import React from "react";
+
+export class ASRouterAdmin extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onMessage = this.onMessage.bind(this);
+    this.findOtherBundledMessagesOfSameTemplate = this.findOtherBundledMessagesOfSameTemplate.bind(this);
+    this.state = {};
+  }
+
+  onMessage({data: action}) {
+    if (action.type === "ADMIN_SET_STATE") {
+      this.setState(action.data);
+    }
+  }
+
+  componentWillMount() {
+    ASRouterUtils.sendMessage({type: "ADMIN_CONNECT_STATE"});
+    ASRouterUtils.addListener(this.onMessage);
+  }
+
+  componentWillUnmount() {
+    ASRouterUtils.removeListener(this.onMessage);
+  }
+
+  findOtherBundledMessagesOfSameTemplate(template) {
+    return this.state.messages.filter(msg => msg.template === template && msg.bundled);
+  }
+
+  handleBlock(msg) {
+    if (msg.bundled) {
+      // If we are blocking a message that belongs to a bundle, block all other messages that are bundled of that same template
+      let bundle = this.findOtherBundledMessagesOfSameTemplate(msg.template);
+      return () => ASRouterUtils.blockBundle(bundle);
+    }
+    return () => ASRouterUtils.blockById(msg.id);
+  }
+
+  handleUnblock(msg) {
+    if (msg.bundled) {
+      // If we are unblocking a message that belongs to a bundle, unblock all other messages that are bundled of that same template
+      let bundle = this.findOtherBundledMessagesOfSameTemplate(msg.template);
+      return () => ASRouterUtils.unblockBundle(bundle);
+    }
+    return () => ASRouterUtils.unblockById(msg.id);
+  }
+
+  handleOverride(id) {
+    return () => ASRouterUtils.overrideMessage(id);
+  }
+
+  renderMessageItem(msg) {
+    const isCurrent = msg.id === this.state.lastMessageId;
+    const isBlocked = this.state.blockList.includes(msg.id);
+
+    let itemClassName = "message-item";
+    if (isCurrent) { itemClassName += " current"; }
+    if (isBlocked) { itemClassName += " blocked"; }
+
+    return (<tr className={itemClassName} key={msg.id}>
+      <td className="message-id"><span>{msg.id}</span></td>
+      <td>
+        <button className={`button ${(isBlocked ? "" : " primary")}`} onClick={isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg)}>{isBlocked ? "Unblock" : "Block"}</button>
+       {isBlocked ? null : <button className="button" onClick={this.handleOverride(msg.id)}>Show</button>}
+      </td>
+      <td className="message-summary">
+        <pre>{JSON.stringify(msg, null, 2)}</pre>
+      </td>
+    </tr>);
+  }
+
+  renderMessages() {
+    if (!this.state.messages) {
+      return null;
+    }
+    return (<table><tbody>
+      {this.state.messages.map(msg => this.renderMessageItem(msg))}
+    </tbody></table>);
+  }
+
+  renderProviders() {
+    return (<table><tbody>
+      {this.state.providers.map((provider, i) => (<tr className="message-item" key={i}>
+        <td>{provider.id}</td>
+        <td>{provider.type === "remote" ? <a target="_blank" href={provider.url}>{provider.url}</a> : "(local)"}</td>
+      </tr>))}
+    </tbody></table>);
+  }
+
+  render() {
+    return (<div className="asrouter-admin outer-wrapper">
+      <h1>AS Router Admin</h1>
+      <button className="button primary" onClick={ASRouterUtils.getNextMessage}>Refresh Current Message</button>
+      <h2>Message Providers</h2>
+      {this.state.providers ? this.renderProviders() : null}
+      <h2>Messages</h2>
+      {this.renderMessages()}
+    </div>);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ASRouterAdmin/ASRouterAdmin.scss
@@ -0,0 +1,78 @@
+
+.asrouter-admin {
+  $border-color: var(--newtab-border-secondary-color);
+  $monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace;
+  max-width: 996px;
+  margin: 0 auto;
+  font-size: 14px;
+  // Reset .outer-wrapper styles
+  display: inherit;
+  padding: 0 0 92px;
+
+  h1 {
+    font-weight: 200;
+    font-size: 32px;
+  }
+
+  table {
+    border-collapse: collapse;
+    width: 100%;
+  }
+
+  .message-item {
+    &:first-child td {
+      border-top: 1px solid $border-color;
+    }
+
+    td {
+      vertical-align: top;
+      border-bottom: 1px solid $border-color;
+      padding: 8px;
+
+      &:first-child {
+        border-left: 1px solid $border-color;
+      }
+
+      &:last-child {
+        border-right: 1px solid $border-color;
+      }
+    }
+
+    &.current {
+      .message-id span {
+        background: $yellow-50;
+        padding: 2px 5px;
+
+        .dark-theme & {
+          color: $black;
+        }
+      }
+    }
+
+    &.blocked {
+      .message-id,
+      .message-summary {
+        opacity: 0.5;
+      }
+
+      .message-id {
+        opacity: 0.5;
+      }
+    }
+
+    .message-id {
+      font-family: $monospace;
+      font-size: 12px;
+    }
+  }
+
+  pre {
+    background: var(--newtab-textbox-background-color);
+    margin: 0;
+    padding: 8px;
+    font-size: 12px;
+    max-width: 750px;
+    overflow: auto;
+    font-family: $monospace;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Base/Base.jsx
@@ -0,0 +1,148 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {addLocaleData, injectIntl, IntlProvider} from "react-intl";
+import {ASRouterAdmin} from "content-src/components/ASRouterAdmin/ASRouterAdmin";
+import {ConfirmDialog} from "content-src/components/ConfirmDialog/ConfirmDialog";
+import {connect} from "react-redux";
+import {ErrorBoundary} from "content-src/components/ErrorBoundary/ErrorBoundary";
+import {ManualMigration} from "content-src/components/ManualMigration/ManualMigration";
+import {PrerenderData} from "common/PrerenderData.jsm";
+import React from "react";
+import {Search} from "content-src/components/Search/Search";
+import {Sections} from "content-src/components/Sections/Sections";
+import {StartupOverlay} from "content-src/components/StartupOverlay/StartupOverlay";
+
+const PrefsButton = injectIntl(props => (
+  <div className="prefs-button">
+    <button className="icon icon-settings" onClick={props.onClick} title={props.intl.formatMessage({id: "settings_pane_button_label"})} />
+  </div>
+));
+
+// Add the locale data for pluralization and relative-time formatting for now,
+// this just uses english locale data. We can make this more sophisticated if
+// more features are needed.
+function addLocaleDataForReactIntl(locale) {
+  addLocaleData([{locale, parentLocale: "en"}]);
+}
+
+export class _Base extends React.PureComponent {
+  componentWillMount() {
+    const {App, locale, Theme} = this.props;
+    if (Theme.className) {
+      this.updateTheme(Theme);
+    }
+    this.sendNewTabRehydrated(App);
+    addLocaleDataForReactIntl(locale);
+  }
+
+  componentDidMount() {
+    // Request state AFTER the first render to ensure we don't cause the
+    // prerendered DOM to be unmounted. Otherwise, NEW_TAB_STATE_REQUEST is
+    // dispatched right after the store is ready.
+    if (this.props.isPrerendered) {
+      this.props.dispatch(ac.AlsoToMain({type: at.NEW_TAB_STATE_REQUEST}));
+      this.props.dispatch(ac.AlsoToMain({type: at.PAGE_PRERENDERED}));
+    }
+  }
+
+  componentWillUnmount() {
+    this.updateTheme({className: ""});
+  }
+
+  componentWillUpdate({App, Theme}) {
+    this.updateTheme(Theme);
+    this.sendNewTabRehydrated(App);
+  }
+
+  updateTheme(Theme) {
+    const bodyClassName = [
+      "activity-stream",
+      Theme.className,
+      this.props.isFirstrun ? "welcome" : ""
+    ].filter(v => v).join(" ");
+    global.document.body.className = bodyClassName;
+  }
+
+  // The NEW_TAB_REHYDRATED event is used to inform feeds that their
+  // data has been consumed e.g. for counting the number of tabs that
+  // have rendered that data.
+  sendNewTabRehydrated(App) {
+    if (App && App.initialized && !this.renderNotified) {
+      this.props.dispatch(ac.AlsoToMain({type: at.NEW_TAB_REHYDRATED, data: {}}));
+      this.renderNotified = true;
+    }
+  }
+
+  render() {
+    const {props} = this;
+    const {App, locale, strings} = props;
+    const {initialized} = App;
+
+    if (props.Prefs.values.asrouterExperimentEnabled && window.location.hash === "#asrouter") {
+      return (<ASRouterAdmin />);
+    }
+
+    if (!props.isPrerendered && !initialized) {
+      return null;
+    }
+
+    return (<IntlProvider locale={locale} messages={strings}>
+        <ErrorBoundary className="base-content-fallback">
+          <BaseContent {...this.props} />
+        </ErrorBoundary>
+      </IntlProvider>);
+  }
+}
+
+export class BaseContent extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.openPreferences = this.openPreferences.bind(this);
+  }
+
+  openPreferences() {
+    this.props.dispatch(ac.OnlyToMain({type: at.SETTINGS_OPEN}));
+    this.props.dispatch(ac.UserEvent({event: "OPEN_NEWTAB_PREFS"}));
+  }
+
+  render() {
+    const {props} = this;
+    const {App} = props;
+    const {initialized} = App;
+    const prefs = props.Prefs.values;
+
+    const shouldBeFixedToTop = PrerenderData.arePrefsValid(name => prefs[name]);
+
+    const outerClassName = [
+      "outer-wrapper",
+      shouldBeFixedToTop && "fixed-to-top"
+    ].filter(v => v).join(" ");
+
+    return (
+      <div>
+        <div className={outerClassName}>
+          <main>
+            {prefs.showSearch &&
+              <div className="non-collapsible-section">
+                <ErrorBoundary>
+                  <Search />
+                </ErrorBoundary>
+              </div>
+            }
+            <div className={`body-wrapper${(initialized ? " on" : "")}`}>
+              {!prefs.migrationExpired &&
+                <div className="non-collapsible-section">
+                  <ManualMigration />
+                </div>
+                }
+              <Sections />
+              <PrefsButton onClick={this.openPreferences} />
+            </div>
+            <ConfirmDialog />
+          </main>
+        </div>
+        {this.props.isFirstrun && <StartupOverlay />}
+      </div>);
+  }
+}
+
+export const Base = connect(state => ({App: state.App, Prefs: state.Prefs, Theme: state.Theme}))(_Base);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Base/_Base.scss
@@ -0,0 +1,96 @@
+.outer-wrapper {
+  color: var(--newtab-text-primary-color);
+  display: flex;
+  flex-grow: 1;
+  min-height: 100vh;
+  padding: ($section-spacing + $section-vertical-padding) $base-gutter $base-gutter;
+
+  &.fixed-to-top {
+    display: block;
+  }
+
+  a {
+    color: var(--newtab-link-primary-color);
+  }
+}
+
+main {
+  margin: auto;
+  // Offset the snippets container so things at the bottom of the page are still
+  // visible when snippets / onboarding are visible. Adjust for other spacing.
+  padding-bottom: $snippets-container-height - $section-spacing - $base-gutter;
+  width: $wrapper-default-width;
+
+  @media (min-width: $break-point-small) {
+    width: $wrapper-max-width-small;
+  }
+
+  @media (min-width: $break-point-medium) {
+    width: $wrapper-max-width-medium;
+  }
+
+  @media (min-width: $break-point-large) {
+    width: $wrapper-max-width-large;
+  }
+
+  @media (min-width: $break-point-widest) {
+    width: $wrapper-max-width-widest;
+  }
+
+  section {
+    margin-bottom: $section-spacing;
+    position: relative;
+  }
+}
+
+.base-content-fallback {
+  // Make the error message be centered against the viewport
+  height: 100vh;
+}
+
+.body-wrapper {
+  // Hide certain elements so the page structure is fixed, e.g., placeholders,
+  // while avoiding flashes of changing content, e.g., icons and text
+  $selectors-to-hide: '
+    .section-title,
+    .sections-list .section:last-of-type,
+    .topic
+  ';
+
+  #{$selectors-to-hide} {
+    opacity: 0;
+  }
+
+  &.on {
+    #{$selectors-to-hide} {
+      opacity: 1;
+    }
+  }
+}
+
+.non-collapsible-section {
+  padding: 0 $section-horizontal-padding;
+}
+
+.prefs-button {
+  button {
+    background-color: transparent;
+    border: 0;
+    cursor: pointer;
+    fill: var(--newtab-icon-primary-color);
+    offset-inline-end: 15px;
+    padding: 15px;
+    position: fixed;
+    top: 15px;
+    z-index: 12001;
+
+    &:hover,
+    &:focus {
+      background-color: var(--newtab-element-hover-color);
+    }
+
+    &:active {
+      background-color: var(--newtab-element-active-color);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Card/Card.jsx
@@ -0,0 +1,312 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {cardContextTypes} from "./types";
+import {connect} from "react-redux";
+import {FormattedMessage} from "react-intl";
+import {GetPlatformString} from "content-src/lib/link-menu-options";
+import {LinkMenu} from "content-src/components/LinkMenu/LinkMenu";
+import React from "react";
+
+// Keep track of pending image loads to only request once
+const gImageLoading = new Map();
+
+/**
+ * Card component.
+ * Cards are found within a Section component and contain information about a link such
+ * as preview image, page title, page description, and some context about if the page
+ * was visited, bookmarked, trending etc...
+ * Each Section can make an unordered list of Cards which will create one instane of
+ * this class. Each card will then get a context menu which reflects the actions that
+ * can be done on this Card.
+ */
+export class _Card extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {
+      activeCard: null,
+      imageLoaded: false,
+      showContextMenu: false,
+      cardImage: null
+    };
+    this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
+    this.onMenuUpdate = this.onMenuUpdate.bind(this);
+    this.onLinkClick = this.onLinkClick.bind(this);
+  }
+
+  /**
+   * Helper to conditionally load an image and update state when it loads.
+   */
+  async maybeLoadImage() {
+    // No need to load if it's already loaded or no image
+    const {cardImage} = this.state;
+    if (!cardImage) {
+      return;
+    }
+
+    const imageUrl = cardImage.url;
+    if (!this.state.imageLoaded) {
+      // Initialize a promise to share a load across multiple card updates
+      if (!gImageLoading.has(imageUrl)) {
+        const loaderPromise = new Promise((resolve, reject) => {
+          const loader = new Image();
+          loader.addEventListener("load", resolve);
+          loader.addEventListener("error", reject);
+          loader.src = imageUrl;
+        });
+
+        // Save and remove the promise only while it's pending
+        gImageLoading.set(imageUrl, loaderPromise);
+        loaderPromise.catch(ex => ex).then(() => gImageLoading.delete(imageUrl)).catch();
+      }
+
+      // Wait for the image whether just started loading or reused promise
+      await gImageLoading.get(imageUrl);
+
+      // Only update state if we're still waiting to load the original image
+      if (_Card.isImageInState(this.state, this.props.link.image) && !this.state.imageLoaded) {
+        this.setState({imageLoaded: true});
+      }
+    }
+  }
+
+  /**
+   * Checks if `.image` property on link object is a local image with blob data.
+   * This function only works for props since state has `.url` and not `.data`.
+   *
+   * @param {obj|string} image
+   * @returns {bool} true if image is a local image object, otherwise false
+   *                 (otherwise, image will be a URL as a string)
+   */
+  static isLocalImageObject(image) {
+    return image && image.data && image.path;
+  }
+
+  /**
+   * Helper to obtain the next state based on nextProps and prevState.
+   *
+   * NOTE: Rename this method to getDerivedStateFromProps when we update React
+   *       to >= 16.3. We will need to update tests as well. We cannot rename this
+   *       method to getDerivedStateFromProps now because there is a mismatch in
+   *       the React version that we are using for both testing and production.
+   *       (i.e. react-test-render => "16.3.2", react => "16.2.0").
+   *
+   * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43.
+   */
+  static getNextStateFromProps(nextProps, prevState) {
+    const {image} = nextProps.link;
+    const imageInState = _Card.isImageInState(prevState, image);
+    let nextState = null;
+
+    // Image is updating.
+    if (!imageInState && nextProps.link) {
+      nextState = {imageLoaded: false};
+    }
+
+    if (imageInState) {
+      return nextState;
+    }
+
+    nextState = nextState || {};
+
+    // Since image was updated, attempt to revoke old image blob URL, if it exists.
+    _Card.maybeRevokeImageBlob(prevState);
+
+    if (!image) {
+      nextState.cardImage = null;
+    } else if (_Card.isLocalImageObject(image)) {
+      nextState.cardImage = {url: global.URL.createObjectURL(image.data), path: image.path};
+    } else {
+      nextState.cardImage = {url: image};
+    }
+
+    return nextState;
+  }
+
+  /**
+   * Helper to conditionally revoke the previous card image if it is a blob.
+   */
+  static maybeRevokeImageBlob(prevState) {
+    if (prevState.cardImage && prevState.cardImage.path) {
+      global.URL.revokeObjectURL(prevState.cardImage.url);
+    }
+  }
+
+  /**
+   * Helper to check if an image is already in state.
+   */
+  static isImageInState(state, image) {
+    const {cardImage} = state;
+
+    // Both image and cardImage are present.
+    if (image && cardImage) {
+      return _Card.isLocalImageObject(image) ?
+             cardImage.path === image.path :
+             cardImage.url === image;
+    }
+
+    // This will only handle the remaining three possible outcomes.
+    // (i.e. everything except when both image and cardImage are present)
+    return !image && !cardImage;
+  }
+
+  onMenuButtonClick(event) {
+    event.preventDefault();
+    this.setState({
+      activeCard: this.props.index,
+      showContextMenu: true
+    });
+  }
+
+  /**
+   * Report to telemetry additional information about the item.
+   */
+  _getTelemetryInfo() {
+    // Filter out "history" type for being the default
+    if (this.props.link.type !== "history") {
+      return {value: {card_type: this.props.link.type}};
+    }
+
+    return null;
+  }
+
+  onLinkClick(event) {
+    event.preventDefault();
+    if (this.props.link.type === "download") {
+      this.props.dispatch(ac.OnlyToMain({
+        type: at.SHOW_DOWNLOAD_FILE,
+        data: this.props.link
+      }));
+    } else {
+      const {altKey, button, ctrlKey, metaKey, shiftKey} = event;
+      this.props.dispatch(ac.OnlyToMain({
+        type: at.OPEN_LINK,
+        data: Object.assign(this.props.link, {event: {altKey, button, ctrlKey, metaKey, shiftKey}})
+      }));
+    }
+    if (this.props.isWebExtension) {
+      this.props.dispatch(ac.WebExtEvent(at.WEBEXT_CLICK, {
+        source: this.props.eventSource,
+        url: this.props.link.url,
+        action_position: this.props.index
+      }));
+    } else {
+      this.props.dispatch(ac.UserEvent(Object.assign({
+        event: "CLICK",
+        source: this.props.eventSource,
+        action_position: this.props.index
+      }, this._getTelemetryInfo())));
+
+      if (this.props.shouldSendImpressionStats) {
+        this.props.dispatch(ac.ImpressionStats({
+          source: this.props.eventSource,
+          click: 0,
+          tiles: [{id: this.props.link.guid, pos: this.props.index}]
+        }));
+      }
+    }
+  }
+
+  onMenuUpdate(showContextMenu) {
+    this.setState({showContextMenu});
+  }
+
+  componentDidMount() {
+    this.maybeLoadImage();
+  }
+
+  componentDidUpdate() {
+    this.maybeLoadImage();
+  }
+
+  // NOTE: Remove this function when we update React to >= 16.3 since React will
+  //       call getDerivedStateFromProps automatically. We will also need to
+  //       rename getNextStateFromProps to getDerivedStateFromProps.
+  componentWillMount() {
+    const nextState = _Card.getNextStateFromProps(this.props, this.state);
+    if (nextState) {
+      this.setState(nextState);
+    }
+  }
+
+  // NOTE: Remove this function when we update React to >= 16.3 since React will
+  //       call getDerivedStateFromProps automatically. We will also need to
+  //       rename getNextStateFromProps to getDerivedStateFromProps.
+  componentWillReceiveProps(nextProps) {
+    const nextState = _Card.getNextStateFromProps(nextProps, this.state);
+    if (nextState) {
+      this.setState(nextState);
+    }
+  }
+
+  componentWillUnmount() {
+    _Card.maybeRevokeImageBlob(this.state);
+  }
+
+  render() {
+    const {index, className, link, dispatch, contextMenuOptions, eventSource, shouldSendImpressionStats} = this.props;
+    const {props} = this;
+    const isContextMenuOpen = this.state.showContextMenu && this.state.activeCard === index;
+    // Display "now" as "trending" until we have new strings #3402
+    const {icon, intlID} = cardContextTypes[link.type === "now" ? "trending" : link.type] || {};
+    const hasImage = this.state.cardImage || link.hasImage;
+    const imageStyle = {backgroundImage: this.state.cardImage ? `url(${this.state.cardImage.url})` : "none"};
+    const outerClassName = [
+      "card-outer",
+      className,
+      isContextMenuOpen && "active",
+      props.placeholder && "placeholder"
+    ].filter(v => v).join(" ");
+
+    return (<li className={outerClassName}>
+      <a href={link.type === "pocket" ? link.open_url : link.url} onClick={!props.placeholder ? this.onLinkClick : undefined}>
+        <div className="card">
+          <div className="card-preview-image-outer">
+            {hasImage &&
+              <div className={`card-preview-image${this.state.imageLoaded ? " loaded" : ""}`} style={imageStyle} />
+            }
+          </div>
+          <div className="card-details">
+            {link.type === "download" && <div className="card-host-name alternate"><FormattedMessage id={GetPlatformString(this.props.platform)} /></div>}
+            {link.hostname &&
+              <div className="card-host-name">
+                {link.hostname.slice(0, 100)}{link.type === "download" && `  \u2014 ${link.description}`}
+              </div>
+            }
+            <div className={[
+              "card-text",
+              icon ? "" : "no-context",
+              link.description ? "" : "no-description",
+              link.hostname ? "" : "no-host-name"
+            ].join(" ")}>
+              <h4 className="card-title" dir="auto">{link.title}</h4>
+              <p className="card-description" dir="auto">{link.description}</p>
+            </div>
+            <div className="card-context">
+              {icon && !link.context && <span className={`card-context-icon icon icon-${icon}`} />}
+              {link.icon && link.context && <span className="card-context-icon icon" style={{backgroundImage: `url('${link.icon}')`}} />}
+              {intlID && !link.context && <div className="card-context-label"><FormattedMessage id={intlID} defaultMessage="Visited" /></div>}
+              {link.context && <div className="card-context-label">{link.context}</div>}
+            </div>
+          </div>
+        </div>
+      </a>
+      {!props.placeholder && <button className="context-menu-button icon"
+        onClick={this.onMenuButtonClick}>
+        <span className="sr-only">{`Open context menu for ${link.title}`}</span>
+      </button>}
+      {isContextMenuOpen &&
+        <LinkMenu
+          dispatch={dispatch}
+          index={index}
+          source={eventSource}
+          onUpdate={this.onMenuUpdate}
+          options={link.contextMenuOptions || contextMenuOptions}
+          site={link}
+          siteInfo={this._getTelemetryInfo()}
+          shouldSendImpressionStats={shouldSendImpressionStats} />
+      }
+   </li>);
+  }
+}
+_Card.defaultProps = {link: {}};
+export const Card = connect(state => ({platform: state.Prefs.values.platform}))(_Card);
+export const PlaceholderCard = props => <Card placeholder={true} className={props.className} />;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Card/_Card.scss
@@ -0,0 +1,315 @@
+.card-outer {
+  @include context-menu-button;
+  background: var(--newtab-card-background-color);
+  border-radius: $border-radius;
+  display: inline-block;
+  height: $card-height;
+  margin-inline-end: $base-gutter;
+  position: relative;
+  width: 100%;
+
+  &.placeholder {
+    background: transparent;
+
+    .card {
+      box-shadow: inset $inner-box-shadow;
+    }
+
+    .card-preview-image-outer,
+    .card-context {
+      display: none;
+    }
+  }
+
+  .card {
+    border-radius: $border-radius;
+    box-shadow: var(--newtab-card-shadow);
+    height: 100%;
+  }
+
+  > a {
+    color: inherit;
+    display: block;
+    height: 100%;
+    outline: none;
+    position: absolute;
+    width: 100%;
+
+    &:-moz-any(.active, :focus) {
+      .card {
+        @include fade-in-card;
+      }
+
+      .card-title {
+        color: var(--newtab-link-primary-color);
+      }
+    }
+  }
+
+  &:-moz-any(:hover, :focus, .active):not(.placeholder) {
+    @include fade-in-card;
+    @include context-menu-button-hover;
+    outline: none;
+
+    .card-title {
+      color: var(--newtab-link-primary-color);
+    }
+
+    .alternate ~ .card-host-name {
+      display: none;
+    }
+
+    .card-host-name.alternate {
+      display: block;
+    }
+  }
+
+  .card-preview-image-outer {
+    background-color: $grey-30;
+    border-radius: $border-radius $border-radius 0 0;
+    height: $card-preview-image-height;
+    overflow: hidden;
+    position: relative;
+
+    &::after {
+      border-bottom: 1px solid var(--newtab-card-hairline-color);
+      bottom: 0;
+      content: '';
+      position: absolute;
+      width: 100%;
+    }
+
+    .card-preview-image {
+      background-position: center;
+      background-repeat: no-repeat;
+      background-size: cover;
+      height: 100%;
+      opacity: 0;
+      transition: opacity 1s $photon-easing;
+      width: 100%;
+
+      &.loaded {
+        opacity: 1;
+      }
+    }
+  }
+
+  .card-details {
+    padding: 15px 16px 12px;
+  }
+
+  .card-text {
+    max-height: 4 * $card-text-line-height + $card-title-margin;
+    overflow: hidden;
+
+    &.no-host-name,
+    &.no-context {
+      max-height: 5 * $card-text-line-height + $card-title-margin;
+    }
+
+    &.no-host-name.no-context {
+      max-height: 6 * $card-text-line-height + $card-title-margin;
+    }
+
+    &:not(.no-description) .card-title {
+      max-height: 3 * $card-text-line-height;
+      overflow: hidden;
+    }
+  }
+
+  .card-host-name {
+    color: var(--newtab-text-secondary-color);
+    font-size: 10px;
+    overflow: hidden;
+    padding-bottom: 4px;
+    text-overflow: ellipsis;
+    text-transform: uppercase;
+    white-space: nowrap;
+  }
+
+  .card-host-name.alternate { display: none; }
+
+  .card-title {
+    font-size: 14px;
+    font-weight: 600;
+    line-height: $card-text-line-height;
+    margin: 0 0 $card-title-margin;
+    word-wrap: break-word;
+  }
+
+  .card-description {
+    font-size: 12px;
+    line-height: $card-text-line-height;
+    margin: 0;
+    overflow: hidden;
+    word-wrap: break-word;
+  }
+
+  .card-context {
+    bottom: 0;
+    color: var(--newtab-text-secondary-color);
+    display: flex;
+    font-size: 11px;
+    offset-inline-start: 0;
+    padding: 9px 16px 9px 14px;
+    position: absolute;
+  }
+
+  .card-context-icon {
+    fill: var(--newtab-text-secondary-color);
+    height: 22px;
+    margin-inline-end: 6px;
+  }
+
+  .card-context-label {
+    flex-grow: 1;
+    line-height: 22px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
+
+.normal-cards {
+  .card-outer {
+    // Wide layout styles
+    @media (min-width: $break-point-widest) {
+      $line-height: 23px;
+      height: $card-height-large;
+
+      .card-preview-image-outer {
+        height: $card-preview-image-height-large;
+      }
+
+      .card-details {
+        padding: 13px 16px 12px;
+      }
+
+      .card-text {
+        max-height: 6 * $line-height + $card-title-margin;
+      }
+
+      .card-host-name {
+        font-size: 12px;
+        padding-bottom: 5px;
+      }
+
+      .card-title {
+        font-size: 17px;
+        line-height: $line-height;
+        margin-bottom: 0;
+      }
+
+      .card-text:not(.no-description) {
+        .card-title {
+          max-height: 3 * $line-height;
+        }
+      }
+
+      .card-description {
+        font-size: 15px;
+        line-height: $line-height;
+      }
+
+      .card-context {
+        bottom: 4px;
+        font-size: 14px;
+      }
+    }
+  }
+}
+
+.compact-cards {
+  $card-detail-vertical-spacing: 12px;
+  $card-title-font-size: 12px;
+
+  .card-outer {
+    height: $card-height-compact;
+
+    .card-preview-image-outer {
+      height: $card-preview-image-height-compact;
+    }
+
+    .card-details {
+      padding: $card-detail-vertical-spacing 16px;
+    }
+
+    .card-host-name {
+      line-height: 10px;
+    }
+
+    .card-text {
+      .card-title,
+      &:not(.no-description) .card-title {
+        font-size: $card-title-font-size;
+        line-height: $card-title-font-size + 1;
+        max-height: $card-title-font-size + 1;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+    }
+
+    .card-description {
+      display: none;
+    }
+
+    .card-context {
+      $icon-size: 16px;
+      $container-size: 32px;
+      background-color: var(--newtab-card-background-color);
+      border-radius: $container-size / 2;
+      clip-path: inset(-1px -1px $container-size - ($card-height-compact - $card-preview-image-height-compact - 2 * $card-detail-vertical-spacing));
+      height: $container-size;
+      width: $container-size;
+      padding: ($container-size - $icon-size) / 2;
+      top: $card-preview-image-height-compact - $icon-size;
+      offset-inline-end: 12px;
+      offset-inline-start: auto;
+
+      &::after {
+        border: 1px solid var(--newtab-card-hairline-color);
+        border-bottom: 0;
+        border-radius: ($container-size / 2) + 1 ($container-size / 2) + 1 0 0;
+        content: '';
+        position: absolute;
+        height: ($container-size + 2) / 2;
+        width: $container-size + 2;
+        top: -1px;
+        left: -1px;
+      }
+
+      .card-context-icon {
+        margin-inline-end: 0;
+        height: $icon-size;
+        width: $icon-size;
+
+        &.icon-bookmark-added {
+          fill: $bookmark-icon-fill;
+        }
+
+        &.icon-download {
+          fill: $download-icon-fill;
+        }
+
+        &.icon-history-item {
+          fill: $history-icon-fill;
+        }
+
+        &.icon-pocket {
+          fill: $pocket-icon-fill;
+        }
+      }
+
+      .card-context-label {
+        display: none;
+      }
+    }
+  }
+
+  @media not all and (min-width: $break-point-widest) {
+    .hide-for-narrow {
+      display: none;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Card/types.js
@@ -0,0 +1,26 @@
+export const cardContextTypes = {
+  history: {
+    intlID: "type_label_visited",
+    icon: "history-item"
+  },
+  bookmark: {
+    intlID: "type_label_bookmarked",
+    icon: "bookmark-added"
+  },
+  trending: {
+    intlID: "type_label_recommended",
+    icon: "trending"
+  },
+  now: {
+    intlID: "type_label_now",
+    icon: "now"
+  },
+  pocket: {
+    intlID: "type_label_pocket",
+    icon: "pocket"
+  },
+  download: {
+    intlID: "type_label_downloaded",
+    icon: "download"
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/CollapsibleSection/CollapsibleSection.jsx
@@ -0,0 +1,218 @@
+import {FormattedMessage, injectIntl} from "react-intl";
+import {actionCreators as ac} from "common/Actions.jsm";
+import {ErrorBoundary} from "content-src/components/ErrorBoundary/ErrorBoundary";
+import React from "react";
+import {SectionMenu} from "content-src/components/SectionMenu/SectionMenu";
+import {SectionMenuOptions} from "content-src/lib/section-menu-options";
+
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+function getFormattedMessage(message) {
+  return typeof message === "string" ? <span>{message}</span> : <FormattedMessage {...message} />;
+}
+
+export class Disclaimer extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onAcknowledge = this.onAcknowledge.bind(this);
+  }
+
+  onAcknowledge() {
+    this.props.dispatch(ac.SetPref(this.props.disclaimerPref, false));
+    this.props.dispatch(ac.UserEvent({event: "DISCLAIMER_ACKED", source: this.props.eventSource}));
+  }
+
+  render() {
+    const {disclaimer} = this.props;
+    return (
+      <div className="section-disclaimer">
+          <div className="section-disclaimer-text">
+            {getFormattedMessage(disclaimer.text)}
+            {disclaimer.link &&
+              <a href={disclaimer.link.href} target="_blank" rel="noopener noreferrer">
+                {getFormattedMessage(disclaimer.link.title || disclaimer.link)}
+              </a>
+            }
+          </div>
+
+          <button onClick={this.onAcknowledge}>
+            {getFormattedMessage(disclaimer.button)}
+          </button>
+      </div>
+    );
+  }
+}
+
+export const DisclaimerIntl = injectIntl(Disclaimer);
+
+export class _CollapsibleSection extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onBodyMount = this.onBodyMount.bind(this);
+    this.onHeaderClick = this.onHeaderClick.bind(this);
+    this.onTransitionEnd = this.onTransitionEnd.bind(this);
+    this.enableOrDisableAnimation = this.enableOrDisableAnimation.bind(this);
+    this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
+    this.onMenuButtonMouseEnter = this.onMenuButtonMouseEnter.bind(this);
+    this.onMenuButtonMouseLeave = this.onMenuButtonMouseLeave.bind(this);
+    this.onMenuUpdate = this.onMenuUpdate.bind(this);
+    this.state = {enableAnimation: true, isAnimating: false, menuButtonHover: false, showContextMenu: false};
+  }
+
+  componentWillMount() {
+    this.props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this.enableOrDisableAnimation);
+  }
+
+  componentWillUpdate(nextProps) {
+    // Check if we're about to go from expanded to collapsed
+    if (!this.props.collapsed && nextProps.collapsed) {
+      // This next line forces a layout flush of the section body, which has a
+      // max-height style set, so that the upcoming collapse animation can
+      // animate from that height to the collapsed height. Without this, the
+      // update is coalesced and there's no animation from no-max-height to 0.
+      this.sectionBody.scrollHeight; // eslint-disable-line no-unused-expressions
+    }
+  }
+
+  componentWillUnmount() {
+    this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this.enableOrDisableAnimation);
+  }
+
+  enableOrDisableAnimation() {
+    // Only animate the collapse/expand for visible tabs.
+    const visible = this.props.document.visibilityState === VISIBLE;
+    if (this.state.enableAnimation !== visible) {
+      this.setState({enableAnimation: visible});
+    }
+  }
+
+  onBodyMount(node) {
+    this.sectionBody = node;
+  }
+
+  onHeaderClick() {
+    // If this.sectionBody is unset, it means that we're in some sort of error
+    // state, probably displaying the error fallback, so we won't be able to
+    // compute the height, and we don't want to persist the preference.
+    // If props.collapsed is undefined handler shouldn't do anything.
+    if (!this.sectionBody || this.props.collapsed === undefined) {
+      return;
+    }
+
+    // Get the current height of the body so max-height transitions can work
+    this.setState({
+      isAnimating: true,
+      maxHeight: `${this.sectionBody.scrollHeight}px`
+    });
+    const {action, userEvent} = SectionMenuOptions.CheckCollapsed(this.props);
+    this.props.dispatch(action);
+    this.props.dispatch(ac.UserEvent({
+      event: userEvent,
+      source: this.props.source
+    }));
+  }
+
+  onTransitionEnd(event) {
+    // Only update the animating state for our own transition (not a child's)
+    if (event.target === event.currentTarget) {
+      this.setState({isAnimating: false});
+    }
+  }
+
+  renderIcon() {
+    const {icon} = this.props;
+    if (icon && icon.startsWith("moz-extension://")) {
+      return <span className="icon icon-small-spacer" style={{backgroundImage: `url('${icon}')`}} />;
+    }
+    return <span className={`icon icon-small-spacer icon-${icon || "webextension"}`} />;
+  }
+
+  onMenuButtonClick(event) {
+    event.preventDefault();
+    this.setState({showContextMenu: true});
+  }
+
+  onMenuButtonMouseEnter() {
+    this.setState({menuButtonHover: true});
+  }
+
+  onMenuButtonMouseLeave() {
+    this.setState({menuButtonHover: false});
+  }
+
+  onMenuUpdate(showContextMenu) {
+    this.setState({showContextMenu});
+  }
+
+  render() {
+    const isCollapsible = this.props.collapsed !== undefined;
+    const {enableAnimation, isAnimating, maxHeight, menuButtonHover, showContextMenu} = this.state;
+    const {id, eventSource, collapsed, disclaimer, title, extraMenuOptions, showPrefName, privacyNoticeURL, dispatch, isFirst, isLast, isWebExtension} = this.props;
+    const disclaimerPref = `section.${id}.showDisclaimer`;
+    const needsDisclaimer = disclaimer && this.props.Prefs.values[disclaimerPref];
+    const active = menuButtonHover || showContextMenu;
+    return (
+      <section
+        className={`collapsible-section ${this.props.className}${enableAnimation ? " animation-enabled" : ""}${collapsed ? " collapsed" : ""}${active ? " active" : ""}`}
+        // Note: data-section-id is used for web extension api tests in mozilla central
+        data-section-id={id}>
+        <div className="section-top-bar">
+          <h3 className="section-title">
+            <span className="click-target" onClick={this.onHeaderClick}>
+              {this.renderIcon()}
+              {getFormattedMessage(title)}
+              {isCollapsible && <span className={`collapsible-arrow icon ${collapsed ? "icon-arrowhead-forward-small" : "icon-arrowhead-down-small"}`} />}
+            </span>
+          </h3>
+          <div>
+            <button
+              className="context-menu-button icon"
+              onClick={this.onMenuButtonClick}
+              onMouseEnter={this.onMenuButtonMouseEnter}
+              onMouseLeave={this.onMenuButtonMouseLeave}>
+              <span className="sr-only">
+                <FormattedMessage id="section_context_menu_button_sr" />
+              </span>
+            </button>
+            {showContextMenu &&
+              <SectionMenu
+                id={id}
+                extraOptions={extraMenuOptions}
+                eventSource={eventSource}
+                showPrefName={showPrefName}
+                privacyNoticeURL={privacyNoticeURL}
+                collapsed={collapsed}
+                onUpdate={this.onMenuUpdate}
+                isFirst={isFirst}
+                isLast={isLast}
+                dispatch={dispatch}
+                isWebExtension={isWebExtension} />
+            }
+          </div>
+        </div>
+        <ErrorBoundary className="section-body-fallback">
+          <div
+            className={`section-body${isAnimating ? " animating" : ""}`}
+            onTransitionEnd={this.onTransitionEnd}
+            ref={this.onBodyMount}
+            style={isAnimating && !collapsed ? {maxHeight} : null}>
+            {needsDisclaimer && <DisclaimerIntl disclaimerPref={disclaimerPref} disclaimer={disclaimer} eventSource={eventSource} dispatch={this.props.dispatch} />}
+            {this.props.children}
+          </div>
+        </ErrorBoundary>
+      </section>
+    );
+  }
+}
+
+_CollapsibleSection.defaultProps = {
+  document: global.document || {
+    addEventListener: () => {},
+    removeEventListener: () => {},
+    visibilityState: "hidden"
+  },
+  Prefs: {values: {}}
+};
+
+export const CollapsibleSection = injectIntl(_CollapsibleSection);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/CollapsibleSection/_CollapsibleSection.scss
@@ -0,0 +1,166 @@
+.collapsible-section {
+  padding: $section-vertical-padding $section-horizontal-padding;
+  transition-delay: 100ms;
+  transition-duration: 100ms;
+  transition-property: background-color;
+
+  .section-title {
+    font-size: $section-title-font-size;
+    font-weight: bold;
+    margin: 0;
+    text-transform: uppercase;
+
+    span {
+      color: var(--newtab-section-header-text-color);
+      display: inline-block;
+      fill: var(--newtab-section-header-text-color);
+      vertical-align: middle;
+    }
+
+    .click-target {
+      cursor: pointer;
+      vertical-align: top;
+      white-space: nowrap;
+    }
+
+    .collapsible-arrow {
+      margin-inline-start: 8px;
+      margin-top: -1px;
+    }
+  }
+
+  .section-top-bar {
+    height: 19px;
+    margin-bottom: 13px;
+    position: relative;
+
+    .context-menu-button {
+      background: url('chrome://browser/skin/page-action.svg') no-repeat right center;
+      border: 0;
+      cursor: pointer;
+      fill: var(--newtab-section-header-text-color);
+      height: 100%;
+      offset-inline-end: 0;
+      opacity: 0;
+      position: absolute;
+      top: 0;
+      transition-duration: 200ms;
+      transition-property: opacity;
+      width: $context-menu-button-size;
+
+      &:-moz-any(:active, :focus, :hover) {
+        fill: $grey-90;
+        opacity: 1;
+      }
+    }
+
+    .context-menu {
+      top: 16px;
+    }
+
+    @media (max-width: $break-point-widest + $card-width * 1.5) {
+      @include context-menu-open-left;
+    }
+  }
+
+  &:hover,
+  &.active {
+    .section-top-bar {
+      .context-menu-button {
+        opacity: 1;
+      }
+    }
+  }
+
+  &.active {
+    background: var(--newtab-element-hover-color);
+    border-radius: 4px;
+
+    .section-top-bar {
+      .context-menu-button {
+        fill: var(--newtab-section-active-contextmenu-color);
+      }
+    }
+  }
+
+  .section-disclaimer {
+    $max-button-width: 130px;
+    $min-button-height: 26px;
+
+    color: var(--newtab-text-conditional-color);
+    font-size: 13px;
+    margin-bottom: 16px;
+    position: relative;
+
+    .section-disclaimer-text {
+      display: inline-block;
+      min-height: $min-button-height;
+      width: calc(100% - #{$max-button-width});
+
+      @media (max-width: $break-point-medium) {
+        width: $card-width;
+      }
+    }
+
+    a {
+      color: var(--newtab-link-primary-color);
+      font-weight: bold;
+      padding-left: 3px;
+    }
+
+    button {
+      background: var(--newtab-button-secondary-color);
+      border: 1px solid $grey-40;
+      border-radius: 4px;
+      cursor: pointer;
+      margin-top: 2px;
+      max-width: $max-button-width;
+      min-height: $min-button-height;
+      offset-inline-end: 0;
+
+      &:hover:not(.dismiss) {
+        box-shadow: $shadow-primary;
+        transition: box-shadow 150ms;
+      }
+
+      @media (min-width: $break-point-small) {
+        position: absolute;
+      }
+    }
+  }
+
+  .section-body-fallback {
+    height: $card-height;
+  }
+
+  .section-body {
+    // This is so the top sites favicon and card dropshadows don't get clipped during animation:
+    $horizontal-padding: 7px;
+    margin: 0 (-$horizontal-padding);
+    padding: 0 $horizontal-padding;
+
+    &.animating {
+      overflow: hidden;
+      pointer-events: none;
+    }
+  }
+
+  &.animation-enabled {
+    .section-title {
+      .collapsible-arrow {
+        transition: transform 0.5s $photon-easing;
+      }
+    }
+
+    .section-body {
+      transition: max-height 0.5s $photon-easing;
+    }
+  }
+
+  &.collapsed {
+    .section-body {
+      max-height: 0;
+      overflow: hidden;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx
@@ -0,0 +1,163 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {perfService as perfSvc} from "common/PerfService.jsm";
+import React from "react";
+
+// Currently record only a fixed set of sections. This will prevent data
+// from custom sections from showing up or from topstories.
+const RECORDED_SECTIONS = ["highlights", "topsites"];
+
+export class ComponentPerfTimer extends React.Component {
+  constructor(props) {
+    super(props);
+    // Just for test dependency injection:
+    this.perfSvc = this.props.perfSvc || perfSvc;
+
+    this._sendBadStateEvent = this._sendBadStateEvent.bind(this);
+    this._sendPaintedEvent = this._sendPaintedEvent.bind(this);
+    this._reportMissingData = false;
+    this._timestampHandled = false;
+    this._recordedFirstRender = false;
+  }
+
+  componentDidMount() {
+    if (!RECORDED_SECTIONS.includes(this.props.id)) {
+      return;
+    }
+
+    this._maybeSendPaintedEvent();
+  }
+
+  componentDidUpdate() {
+    if (!RECORDED_SECTIONS.includes(this.props.id)) {
+      return;
+    }
+
+    this._maybeSendPaintedEvent();
+  }
+
+  /**
+   * Call the given callback after the upcoming frame paints.
+   *
+   * @note Both setTimeout and requestAnimationFrame are throttled when the page
+   * is hidden, so this callback may get called up to a second or so after the
+   * requestAnimationFrame "paint" for hidden tabs.
+   *
+   * Newtabs hidden while loading will presumably be fairly rare (other than
+   * preloaded tabs, which we will be filtering out on the server side), so such
+   * cases should get lost in the noise.
+   *
+   * If we decide that it's important to find out when something that's hidden
+   * has "painted", however, another option is to post a message to this window.
+   * That should happen even faster than setTimeout, and, at least as of this
+   * writing, it's not throttled in hidden windows in Firefox.
+   *
+   * @param {Function} callback
+   *
+   * @returns void
+   */
+  _afterFramePaint(callback) {
+    requestAnimationFrame(() => setTimeout(callback, 0));
+  }
+
+  _maybeSendBadStateEvent() {
+    // Follow up bugs:
+    // https://github.com/mozilla/activity-stream/issues/3691
+    if (!this.props.initialized) {
+      // Remember to report back when data is available.
+      this._reportMissingData = true;
+    } else if (this._reportMissingData) {
+      this._reportMissingData = false;
+      // Report how long it took for component to become initialized.
+      this._sendBadStateEvent();
+    }
+  }
+
+  _maybeSendPaintedEvent() {
+    // If we've already handled a timestamp, don't do it again.
+    if (this._timestampHandled || !this.props.initialized) {
+      return;
+    }
+
+    // And if we haven't, we're doing so now, so remember that. Even if
+    // something goes wrong in the callback, we can't try again, as we'd be
+    // sending back the wrong data, and we have to do it here, so that other
+    // calls to this method while waiting for the next frame won't also try to
+    // handle it.
+    this._timestampHandled = true;
+    this._afterFramePaint(this._sendPaintedEvent);
+  }
+
+  /**
+   * Triggered by call to render. Only first call goes through due to
+   * `_recordedFirstRender`.
+   */
+  _ensureFirstRenderTsRecorded() {
+    // Used as t0 for recording how long component took to initialize.
+    if (!this._recordedFirstRender) {
+      this._recordedFirstRender = true;
+      // topsites_first_render_ts, highlights_first_render_ts.
+      const key = `${this.props.id}_first_render_ts`;
+      this.perfSvc.mark(key);
+    }
+  }
+
+  /**
+   * Creates `TELEMETRY_UNDESIRED_EVENT` with timestamp in ms
+   * of how much longer the data took to be ready for display than it would
+   * have been the ideal case.
+   * https://github.com/mozilla/ping-centre/issues/98
+   */
+  _sendBadStateEvent() {
+    // highlights_data_ready_ts, topsites_data_ready_ts.
+    const dataReadyKey = `${this.props.id}_data_ready_ts`;
+    this.perfSvc.mark(dataReadyKey);
+
+    try {
+      const firstRenderKey = `${this.props.id}_first_render_ts`;
+      // value has to be Int32.
+      const value = parseInt(this.perfSvc.getMostRecentAbsMarkStartByName(dataReadyKey) -
+                             this.perfSvc.getMostRecentAbsMarkStartByName(firstRenderKey), 10);
+      this.props.dispatch(ac.OnlyToMain({
+        type: at.SAVE_SESSION_PERF_DATA,
+        // highlights_data_late_by_ms, topsites_data_late_by_ms.
+        data: {[`${this.props.id}_data_late_by_ms`]: value}
+      }));
+    } catch (ex) {
+      // If this failed, it's likely because the `privacy.resistFingerprinting`
+      // pref is true.
+    }
+  }
+
+  _sendPaintedEvent() {
+    // Record first_painted event but only send if topsites.
+    if (this.props.id !== "topsites") {
+      return;
+    }
+
+    // topsites_first_painted_ts.
+    const key = `${this.props.id}_first_painted_ts`;
+    this.perfSvc.mark(key);
+
+    try {
+      const data = {};
+      data[key] = this.perfSvc.getMostRecentAbsMarkStartByName(key);
+
+      this.props.dispatch(ac.OnlyToMain({
+        type: at.SAVE_SESSION_PERF_DATA,
+        data
+      }));
+    } catch (ex) {
+      // If this failed, it's likely because the `privacy.resistFingerprinting`
+      // pref is true.  We should at least not blow up, and should continue
+      // to set this._timestampHandled to avoid going through this again.
+    }
+  }
+
+  render() {
+    if (RECORDED_SECTIONS.includes(this.props.id)) {
+      this._ensureFirstRenderTsRecorded();
+      this._maybeSendBadStateEvent();
+    }
+    return this.props.children;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ConfirmDialog/ConfirmDialog.jsx
@@ -0,0 +1,78 @@
+import {actionCreators as ac, actionTypes} from "common/Actions.jsm";
+import {connect} from "react-redux";
+import {FormattedMessage} from "react-intl";
+import React from "react";
+
+/**
+ * ConfirmDialog component.
+ * One primary action button, one cancel button.
+ *
+ * Content displayed is controlled by `data` prop the component receives.
+ * Example:
+ * data: {
+ *   // Any sort of data needed to be passed around by actions.
+ *   payload: site.url,
+ *   // Primary button AlsoToMain action.
+ *   action: "DELETE_HISTORY_URL",
+ *   // Primary button USerEvent action.
+ *   userEvent: "DELETE",
+ *   // Array of locale ids to display.
+ *   message_body: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"],
+ *   // Text for primary button.
+ *   confirm_button_string_id: "menu_action_delete"
+ * },
+ */
+export class _ConfirmDialog extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this._handleCancelBtn = this._handleCancelBtn.bind(this);
+    this._handleConfirmBtn = this._handleConfirmBtn.bind(this);
+  }
+
+  _handleCancelBtn() {
+    this.props.dispatch({type: actionTypes.DIALOG_CANCEL});
+    this.props.dispatch(ac.UserEvent({event: actionTypes.DIALOG_CANCEL, source: this.props.data.eventSource}));
+  }
+
+  _handleConfirmBtn() {
+    this.props.data.onConfirm.forEach(this.props.dispatch);
+  }
+
+  _renderModalMessage() {
+    const message_body = this.props.data.body_string_id;
+
+    if (!message_body) {
+      return null;
+    }
+
+    return (<span>
+      {message_body.map(msg => <p key={msg}><FormattedMessage id={msg} /></p>)}
+    </span>);
+  }
+
+  render() {
+    if (!this.props.visible) {
+      return null;
+    }
+
+    return (<div className="confirmation-dialog">
+      <div className="modal-overlay" onClick={this._handleCancelBtn} />
+      <div className="modal">
+        <section className="modal-message">
+          {this.props.data.icon && <span className={`icon icon-spacer icon-${this.props.data.icon}`} />}
+          {this._renderModalMessage()}
+        </section>
+        <section className="actions">
+          <button onClick={this._handleCancelBtn}>
+            <FormattedMessage id={this.props.data.cancel_button_string_id} />
+          </button>
+          <button className="done" onClick={this._handleConfirmBtn}>
+            <FormattedMessage id={this.props.data.confirm_button_string_id} />
+          </button>
+        </section>
+      </div>
+    </div>);
+  }
+}
+
+export const ConfirmDialog = connect(state => state.Dialog)(_ConfirmDialog);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ConfirmDialog/_ConfirmDialog.scss
@@ -0,0 +1,67 @@
+.confirmation-dialog {
+  .modal {
+    box-shadow: 0 2px 2px 0 $black-10;
+    left: 50%;
+    margin-left: -200px;
+    position: fixed;
+    top: 20%;
+    width: 400px;
+  }
+
+  section {
+    margin: 0;
+  }
+
+  .modal-message {
+    display: flex;
+    padding: 16px;
+    padding-bottom: 0;
+
+    p {
+      margin: 0;
+      margin-bottom: 16px;
+    }
+  }
+
+  .actions {
+    border: 0;
+    display: flex;
+    flex-wrap: nowrap;
+    padding: 0 16px;
+
+    button {
+      margin-inline-end: 16px;
+      padding-inline-end: 18px;
+      padding-inline-start: 18px;
+      white-space: normal;
+      width: 50%;
+
+      &.done {
+        margin-inline-end: 0;
+        margin-inline-start: 0;
+      }
+    }
+  }
+
+  .icon {
+    margin-inline-end: 16px;
+  }
+}
+
+.modal-overlay {
+  background: var(--newtab-overlay-color);
+  height: 100%;
+  left: 0;
+  position: fixed;
+  top: 0;
+  width: 100%;
+  z-index: 11001;
+}
+
+.modal {
+  background: var(--newtab-modal-color);
+  border: $border-secondary;
+  border-radius: 5px;
+  font-size: 15px;
+  z-index: 11002;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ContextMenu/ContextMenu.jsx
@@ -0,0 +1,83 @@
+import React from "react";
+
+export class ContextMenu extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.hideContext = this.hideContext.bind(this);
+    this.onClick = this.onClick.bind(this);
+  }
+
+  hideContext() {
+    this.props.onUpdate(false);
+  }
+
+  componentDidMount() {
+    setTimeout(() => {
+      global.addEventListener("click", this.hideContext);
+    }, 0);
+  }
+
+  componentWillUnmount() {
+    global.removeEventListener("click", this.hideContext);
+  }
+
+  onClick(event) {
+    // Eat all clicks on the context menu so they don't bubble up to window.
+    // This prevents the context menu from closing when clicking disabled items
+    // or the separators.
+    event.stopPropagation();
+  }
+
+  render() {
+    return (<span className="context-menu" onClick={this.onClick}>
+      <ul role="menu" className="context-menu-list">
+        {this.props.options.map((option, i) => (option.type === "separator" ?
+          (<li key={i} className="separator" />) :
+          (option.type !== "empty" && <ContextMenuItem key={i} option={option} hideContext={this.hideContext} />)
+        ))}
+      </ul>
+    </span>);
+  }
+}
+
+export class ContextMenuItem extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onClick = this.onClick.bind(this);
+    this.onKeyDown = this.onKeyDown.bind(this);
+  }
+
+  onClick() {
+    this.props.hideContext();
+    this.props.option.onClick();
+  }
+
+  onKeyDown(event) {
+    const {option} = this.props;
+    switch (event.key) {
+      case "Tab":
+        // tab goes down in context menu, shift + tab goes up in context menu
+        // if we're on the last item, one more tab will close the context menu
+        // similarly, if we're on the first item, one more shift + tab will close it
+        if ((event.shiftKey && option.first) || (!event.shiftKey && option.last)) {
+          this.props.hideContext();
+        }
+        break;
+      case "Enter":
+        this.props.hideContext();
+        option.onClick();
+        break;
+    }
+  }
+
+  render() {
+    const {option} = this.props;
+    return (
+      <li role="menuitem" className="context-menu-item">
+        <a onClick={this.onClick} onKeyDown={this.onKeyDown} tabIndex="0" className={option.disabled ? "disabled" : ""}>
+          {option.icon && <span className={`icon icon-spacer icon-${option.icon}`} />}
+          {option.label}
+        </a>
+      </li>);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ContextMenu/_ContextMenu.scss
@@ -0,0 +1,52 @@
+.context-menu {
+  background: var(--newtab-contextmenu-background-color);
+  border-radius: $context-menu-border-radius;
+  box-shadow: $context-menu-shadow;
+  display: block;
+  font-size: $context-menu-font-size;
+  margin-inline-start: 5px;
+  offset-inline-start: 100%;
+  position: absolute;
+  top: ($context-menu-button-size / 4);
+  z-index: 10000;
+
+  > ul {
+    list-style: none;
+    margin: 0;
+    padding: $context-menu-outer-padding 0;
+
+    > li {
+      margin: 0;
+      width: 100%;
+
+      &.separator {
+        border-bottom: $border-secondary;
+        margin: $context-menu-outer-padding 0;
+      }
+
+      > a {
+        align-items: center;
+        color: inherit;
+        cursor: pointer;
+        display: flex;
+        line-height: 16px;
+        outline: none;
+        padding: $context-menu-item-padding;
+        white-space: nowrap;
+
+        &:-moz-any(:focus, :hover) {
+          background: var(--newtab-element-hover-color);
+        }
+
+        &:active {
+          background: var(--newtab-element-active-color);
+        }
+
+        &.disabled {
+          opacity: 0.4;
+          pointer-events: none;
+        }
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ErrorBoundary/ErrorBoundary.jsx
@@ -0,0 +1,68 @@
+import {FormattedMessage} from "react-intl";
+import React from "react";
+
+export class ErrorBoundaryFallback extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.windowObj = this.props.windowObj || window;
+    this.onClick = this.onClick.bind(this);
+  }
+
+  /**
+   * Since we only get here if part of the page has crashed, do a
+   * forced reload to give us the best chance at recovering.
+   */
+  onClick() {
+    this.windowObj.location.reload(true);
+  }
+
+  render() {
+    const defaultClass = "as-error-fallback";
+    let className;
+    if ("className" in this.props) {
+      className = `${this.props.className} ${defaultClass}`;
+    } else {
+      className = defaultClass;
+    }
+
+    // href="#" to force normal link styling stuff (eg cursor on hover)
+    return (
+      <div className={className}>
+        <div>
+          <FormattedMessage
+            defaultMessage="Oops, something went wrong loading this content."
+            id="error_fallback_default_info" />
+        </div>
+        <span>
+          <a href="#" className="reload-button" onClick={this.onClick}>
+            <FormattedMessage
+              defaultMessage="Refresh page to try again."
+              id="error_fallback_default_refresh_suggestion" />
+          </a>
+        </span>
+      </div>
+    );
+  }
+}
+ErrorBoundaryFallback.defaultProps = {className: "as-error-fallback"};
+
+export class ErrorBoundary extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {hasError: false};
+  }
+
+  componentDidCatch(error, info) {
+    this.setState({hasError: true});
+  }
+
+  render() {
+    if (!this.state.hasError) {
+      return (this.props.children);
+    }
+
+    return <this.props.FallbackComponent className={this.props.className} />;
+  }
+}
+
+ErrorBoundary.defaultProps = {FallbackComponent: ErrorBoundaryFallback};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ErrorBoundary/_ErrorBoundary.scss
@@ -0,0 +1,17 @@
+.as-error-fallback {
+  align-items: center;
+  border-radius: $border-radius;
+  box-shadow: inset $inner-box-shadow;
+  color: var(--newtab-text-conditional-color);
+  display: flex;
+  flex-direction: column;
+  font-size: $error-fallback-font-size;
+  justify-content: center;
+  justify-items: center;
+  line-height: $error-fallback-line-height;
+
+  a {
+    color: var(--newtab-text-conditional-color);
+    text-decoration: underline;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/LinkMenu/LinkMenu.jsx
@@ -0,0 +1,56 @@
+import {actionCreators as ac} from "common/Actions.jsm";
+import {connect} from "react-redux";
+import {ContextMenu} from "content-src/components/ContextMenu/ContextMenu";
+import {injectIntl} from "react-intl";
+import {LinkMenuOptions} from "content-src/lib/link-menu-options";
+import React from "react";
+
+const DEFAULT_SITE_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"];
+
+export class _LinkMenu extends React.PureComponent {
+  getOptions() {
+    const {props} = this;
+    const {site, index, source, isPrivateBrowsingEnabled, siteInfo, platform} = props;
+
+    // Handle special case of default site
+    const propOptions = !site.isDefault ? props.options : DEFAULT_SITE_MENU_OPTIONS;
+
+    const options = propOptions.map(o => LinkMenuOptions[o](site, index, source, isPrivateBrowsingEnabled, siteInfo, platform)).map(option => {
+      const {action, impression, id, string_id, type, userEvent} = option;
+      if (!type && id) {
+        option.label = props.intl.formatMessage({id: string_id || id});
+        option.onClick = () => {
+          props.dispatch(action);
+          if (userEvent) {
+            const userEventData = Object.assign({
+              event: userEvent,
+              source,
+              action_position: index
+            }, siteInfo);
+            props.dispatch(ac.UserEvent(userEventData));
+          }
+          if (impression && props.shouldSendImpressionStats) {
+            props.dispatch(impression);
+          }
+        };
+      }
+      return option;
+    });
+
+    // This is for accessibility to support making each item tabbable.
+    // We want to know which item is the first and which item
+    // is the last, so we can close the context menu accordingly.
+    options[0].first = true;
+    options[options.length - 1].last = true;
+    return options;
+  }
+
+  render() {
+    return (<ContextMenu
+      onUpdate={this.props.onUpdate}
+      options={this.getOptions()} />);
+  }
+}
+
+const getState = state => ({isPrivateBrowsingEnabled: state.Prefs.values.isPrivateBrowsingEnabled, platform: state.Prefs.values.platform});
+export const LinkMenu = connect(getState)(injectIntl(_LinkMenu));
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ManualMigration/ManualMigration.jsx
@@ -0,0 +1,49 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {connect} from "react-redux";
+import {FormattedMessage} from "react-intl";
+import React from "react";
+
+/**
+ * Manual migration component used to start the profile import wizard.
+ * Message is presented temporarily and will go away if:
+ * 1.  User clicks "No Thanks"
+ * 2.  User completed the data import
+ * 3.  After 3 active days
+ * 4.  User clicks "Cancel" on the import wizard (currently not implemented).
+ */
+export class _ManualMigration extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onLaunchTour = this.onLaunchTour.bind(this);
+    this.onCancelTour = this.onCancelTour.bind(this);
+  }
+
+  onLaunchTour() {
+    this.props.dispatch(ac.AlsoToMain({type: at.MIGRATION_START}));
+    this.props.dispatch(ac.UserEvent({event: at.MIGRATION_START}));
+  }
+
+  onCancelTour() {
+    this.props.dispatch(ac.AlsoToMain({type: at.MIGRATION_CANCEL}));
+    this.props.dispatch(ac.UserEvent({event: at.MIGRATION_CANCEL}));
+  }
+
+  render() {
+    return (<div className="manual-migration-container">
+        <p>
+          <span className="icon icon-import" />
+          <FormattedMessage id="manual_migration_explanation2" />
+        </p>
+        <div className="manual-migration-actions actions">
+          <button className="dismiss" onClick={this.onCancelTour}>
+            <FormattedMessage id="manual_migration_cancel_button" />
+          </button>
+          <button onClick={this.onLaunchTour}>
+            <FormattedMessage id="manual_migration_import_button" />
+          </button>
+        </div>
+    </div>);
+  }
+}
+
+export const ManualMigration = connect()(_ManualMigration);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/ManualMigration/_ManualMigration.scss
@@ -0,0 +1,52 @@
+.manual-migration-container {
+  color: var(--newtab-text-conditional-color);
+  font-size: 13px;
+  line-height: 15px;
+  margin-bottom: $section-spacing;
+  text-align: center;
+
+  @media (min-width: $break-point-medium) {
+    display: flex;
+    justify-content: space-between;
+    text-align: left;
+  }
+
+  p {
+    margin: 0;
+    @media (min-width: $break-point-medium) {
+      align-self: center;
+      display: flex;
+      justify-content: space-between;
+    }
+  }
+
+  .icon {
+    display: none;
+    @media (min-width: $break-point-medium) {
+      align-self: center;
+      display: block;
+      fill: var(--newtab-icon-secondary-color);
+      margin-inline-end: 6px;
+    }
+  }
+}
+
+.manual-migration-actions {
+  border: 0;
+  display: block;
+  flex-wrap: nowrap;
+
+  @media (min-width: $break-point-medium) {
+    display: flex;
+    justify-content: space-between;
+    padding: 0;
+  }
+
+  button {
+    align-self: center;
+    height: 26px;
+    margin: 0;
+    margin-inline-start: 20px;
+    padding: 0 12px;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Search/Search.jsx
@@ -0,0 +1,88 @@
+/* globals ContentSearchUIController */
+"use strict";
+
+import {FormattedMessage, injectIntl} from "react-intl";
+import {actionCreators as ac} from "common/Actions.jsm";
+import {connect} from "react-redux";
+import {IS_NEWTAB} from "content-src/lib/constants";
+import React from "react";
+
+export class _Search extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onClick = this.onClick.bind(this);
+    this.onInputMount = this.onInputMount.bind(this);
+  }
+
+  handleEvent(event) {
+    // Also track search events with our own telemetry
+    if (event.detail.type === "Search") {
+      this.props.dispatch(ac.UserEvent({event: "SEARCH"}));
+    }
+  }
+
+  onClick(event) {
+    window.gContentSearchController.search(event);
+  }
+
+  componentWillUnmount() {
+    delete window.gContentSearchController;
+  }
+
+  onInputMount(input) {
+    if (input) {
+      // The "healthReportKey" and needs to be "newtab" or "abouthome" so that
+      // BrowserUsageTelemetry.jsm knows to handle events with this name, and
+      // can add the appropriate telemetry probes for search. Without the correct
+      // name, certain tests like browser_UsageTelemetry_content.js will fail
+      // (See github ticket #2348 for more details)
+      const healthReportKey = IS_NEWTAB ? "newtab" : "abouthome";
+
+      // The "searchSource" needs to be "newtab" or "homepage" and is sent with
+      // the search data and acts as context for the search request (See
+      // nsISearchEngine.getSubmission). It is necessary so that search engine
+      // plugins can correctly atribute referrals. (See github ticket #3321 for
+      // more details)
+      const searchSource = IS_NEWTAB ? "newtab" : "homepage";
+
+      // gContentSearchController needs to exist as a global so that tests for
+      // the existing about:home can find it; and so it allows these tests to pass.
+      // In the future, when activity stream is default about:home, this can be renamed
+      window.gContentSearchController = new ContentSearchUIController(input, input.parentNode,
+        healthReportKey, searchSource);
+      addEventListener("ContentSearchClient", this);
+    } else {
+      window.gContentSearchController = null;
+      removeEventListener("ContentSearchClient", this);
+    }
+  }
+
+  /*
+   * Do not change the ID on the input field, as legacy newtab code
+   * specifically looks for the id 'newtab-search-text' on input fields
+   * in order to execute searches in various tests
+   */
+  render() {
+    return (<div className="search-wrapper">
+      <label htmlFor="newtab-search-text" className="search-label">
+        <span className="sr-only"><FormattedMessage id="search_web_placeholder" /></span>
+      </label>
+      <input
+        id="newtab-search-text"
+        maxLength="256"
+        placeholder={this.props.intl.formatMessage({id: "search_web_placeholder"})}
+        ref={this.onInputMount}
+        title={this.props.intl.formatMessage({id: "search_web_placeholder"})}
+        type="search" />
+      <button
+        id="searchSubmit"
+        className="search-button"
+        onClick={this.onClick}
+        title={this.props.intl.formatMessage({id: "search_button"})}>
+        <span className="sr-only"><FormattedMessage id="search_button" /></span>
+      </button>
+    </div>);
+  }
+}
+
+export const Search = connect()(injectIntl(_Search));
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Search/_Search.scss
@@ -0,0 +1,153 @@
+.search-wrapper {
+  $search-height: 35px;
+  $search-icon-size: 18px;
+  $search-icon-padding: 8px;
+  $search-icon-width: 2 * $search-icon-padding + $search-icon-size;
+  $search-input-left-label-width: 35px;
+  $search-button-width: 36px;
+  $glyph-forward: url('chrome://browser/skin/forward.svg');
+
+  cursor: default;
+  display: flex;
+  height: $search-height;
+  margin-bottom: $section-spacing;
+  position: relative;
+  width: 100%;
+
+  input {
+    background: var(--newtab-textbox-background-color) var(--newtab-search-icon) $search-icon-padding center / $search-icon-size no-repeat;
+    border: solid 1px var(--newtab-search-border-color);
+    box-shadow: $shadow-secondary, 0 0 0 1px $black-15;
+    font-size: 15px;
+    -moz-context-properties: fill;
+    fill: var(--newtab-search-icon-color);
+    padding: 0;
+    padding-inline-end: $search-button-width;
+    padding-inline-start: $search-icon-width;
+    width: 100%;
+
+    &:dir(rtl) {
+      background-position-x: right $search-icon-padding;
+    }
+  }
+
+  &:hover input {
+    box-shadow: $shadow-secondary, 0 0 0 1px $black-25;
+  }
+
+  &:active input,
+  input:focus {
+    border: $input-border-active;
+    box-shadow: var(--newtab-textbox-focus-boxshadow);
+  }
+
+  .search-button {
+    background: $glyph-forward no-repeat center center;
+    background-size: 16px 16px;
+    border: 0;
+    border-radius: 0 $border-radius $border-radius 0;
+    -moz-context-properties: fill;
+    fill: var(--newtab-search-icon-color);
+    height: 100%;
+    offset-inline-end: 0;
+    position: absolute;
+    width: $search-button-width;
+
+    &:focus,
+    &:hover {
+      background-color: $grey-90-10;
+      cursor: pointer;
+    }
+
+    &:active {
+      background-color: $grey-90-20;
+    }
+
+    &:dir(rtl) {
+      transform: scaleX(-1);
+    }
+  }
+}
+
+@at-root {
+  // Adjust the style of the contentSearchUI-generated table
+  .contentSearchSuggestionTable {
+    background-color: var(--newtab-search-dropdown-color);
+    border: 0;
+    box-shadow: $context-menu-shadow;
+    transform: translateY($textbox-shadow-size);
+
+    .contentSearchHeader {
+      background-color: var(--newtab-search-dropdown-header-color);
+      color: var(--newtab-text-secondary-color);
+    }
+
+    .contentSearchHeader,
+    .contentSearchSettingsButton {
+      border-color: var(--newtab-border-secondary-color);
+    }
+
+    .contentSearchSuggestionsList {
+      border: 0;
+    }
+
+    .contentSearchOneOffsTable {
+      background-color: var(--newtab-search-dropdown-header-color);
+      border-top: solid 1px var(--newtab-border-secondary-color);
+    }
+
+    .contentSearchSearchWithHeaderSearchText {
+      color: var(--newtab-text-primary-color);
+    }
+
+    .contentSearchSuggestionsContainer {
+      background-color: var(--newtab-search-dropdown-color);
+    }
+
+    .contentSearchSuggestionRow {
+      &.selected {
+        background: var(--newtab-element-hover-color);
+        color: var(--newtab-text-primary-color);
+
+        &:active {
+          background: var(--newtab-element-active-color);
+        }
+
+        .historyIcon {
+          fill: var(--newtab-icon-secondary-color);
+        }
+      }
+    }
+
+    .contentSearchOneOffsTable {
+      .contentSearchSuggestionsContainer {
+        background-color: var(--newtab-search-dropdown-header-color);
+      }
+    }
+
+    .contentSearchOneOffItem {
+      // Make the border slightly shorter by offsetting from the top and bottom
+      $border-offset: 18%;
+
+      background-image: none;
+      border-image: linear-gradient(transparent $border-offset, var(--newtab-border-secondary-color) $border-offset, var(--newtab-border-secondary-color) 100% - $border-offset, transparent 100% - $border-offset) 1;
+      border-inline-end: 1px solid;
+      position: relative;
+
+      &.selected {
+        background: var(--newtab-element-hover-color);
+      }
+
+      &:active {
+        background: var(--newtab-element-active-color);
+      }
+    }
+
+    .contentSearchSettingsButton {
+      &:hover {
+        background: var(--newtab-element-hover-color);
+        color: var(--newtab-text-primary-color);
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/SectionMenu/SectionMenu.jsx
@@ -0,0 +1,56 @@
+import {actionCreators as ac} from "common/Actions.jsm";
+import {ContextMenu} from "content-src/components/ContextMenu/ContextMenu";
+import {injectIntl} from "react-intl";
+import React from "react";
+import {SectionMenuOptions} from "content-src/lib/section-menu-options";
+
+const DEFAULT_SECTION_MENU_OPTIONS = ["MoveUp", "MoveDown", "Separator", "RemoveSection", "CheckCollapsed", "Separator", "ManageSection"];
+const WEBEXT_SECTION_MENU_OPTIONS = ["MoveUp", "MoveDown", "Separator", "CheckCollapsed", "Separator", "ManageWebExtension"];
+
+export class _SectionMenu extends React.PureComponent {
+  getOptions() {
+    const {props} = this;
+
+    const propOptions = props.isWebExtension ? [...WEBEXT_SECTION_MENU_OPTIONS] : [...DEFAULT_SECTION_MENU_OPTIONS];
+    // Prepend custom options and a separator
+    if (props.extraOptions) {
+      propOptions.splice(0, 0, ...props.extraOptions, "Separator");
+    }
+    // Insert privacy notice before the last option ("ManageSection")
+    if (props.privacyNoticeURL) {
+      propOptions.splice(-1, 0, "PrivacyNotice");
+    }
+
+    const options = propOptions.map(o => SectionMenuOptions[o](props)).map(option => {
+      const {action, id, type, userEvent} = option;
+      if (!type && id) {
+        option.label = props.intl.formatMessage({id});
+        option.onClick = () => {
+          props.dispatch(action);
+          if (userEvent) {
+            props.dispatch(ac.UserEvent({
+              event: userEvent,
+              source: props.source
+            }));
+          }
+        };
+      }
+      return option;
+    });
+
+    // This is for accessibility to support making each item tabbable.
+    // We want to know which item is the first and which item
+    // is the last, so we can close the context menu accordingly.
+    options[0].first = true;
+    options[options.length - 1].last = true;
+    return options;
+  }
+
+  render() {
+    return (<ContextMenu
+      onUpdate={this.props.onUpdate}
+      options={this.getOptions()} />);
+  }
+}
+
+export const SectionMenu = injectIntl(_SectionMenu);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Sections/Sections.jsx
@@ -0,0 +1,258 @@
+import {Card, PlaceholderCard} from "content-src/components/Card/Card";
+import {FormattedMessage, injectIntl} from "react-intl";
+import {actionCreators as ac} from "common/Actions.jsm";
+import {CollapsibleSection} from "content-src/components/CollapsibleSection/CollapsibleSection";
+import {ComponentPerfTimer} from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
+import {connect} from "react-redux";
+import React from "react";
+import {Topics} from "content-src/components/Topics/Topics";
+import {TopSites} from "content-src/components/TopSites/TopSites";
+
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+const CARDS_PER_ROW_DEFAULT = 3;
+const CARDS_PER_ROW_COMPACT_WIDE = 4;
+
+function getFormattedMessage(message) {
+  return typeof message === "string" ? <span>{message}</span> : <FormattedMessage {...message} />;
+}
+
+export class Section extends React.PureComponent {
+  get numRows() {
+    const {rowsPref, maxRows, Prefs} = this.props;
+    return rowsPref ? Prefs.values[rowsPref] : maxRows;
+  }
+
+  _dispatchImpressionStats() {
+    const {props} = this;
+    let cardsPerRow = CARDS_PER_ROW_DEFAULT;
+    if (props.compactCards && global.matchMedia(`(min-width: 1072px)`).matches) {
+      // If the section has compact cards and the viewport is wide enough, we show
+      // 4 columns instead of 3.
+      // $break-point-widest = 1072px (from _variables.scss)
+      cardsPerRow = CARDS_PER_ROW_COMPACT_WIDE;
+    }
+    const maxCards = cardsPerRow * this.numRows;
+    const cards = props.rows.slice(0, maxCards);
+
+    if (this.needsImpressionStats(cards)) {
+      props.dispatch(ac.ImpressionStats({
+        source: props.eventSource,
+        tiles: cards.map(link => ({id: link.guid}))
+      }));
+      this.impressionCardGuids = cards.map(link => link.guid);
+    }
+  }
+
+  // This sends an event when a user sees a set of new content. If content
+  // changes while the page is hidden (i.e. preloaded or on a hidden tab),
+  // only send the event if the page becomes visible again.
+  sendImpressionStatsOrAddListener() {
+    const {props} = this;
+
+    if (!props.shouldSendImpressionStats || !props.dispatch) {
+      return;
+    }
+
+    if (props.document.visibilityState === VISIBLE) {
+      this._dispatchImpressionStats();
+    } else {
+      // We should only ever send the latest impression stats ping, so remove any
+      // older listeners.
+      if (this._onVisibilityChange) {
+        props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+      }
+
+      // When the page becomes visible, send the impression stats ping if the section isn't collapsed.
+      this._onVisibilityChange = () => {
+        if (props.document.visibilityState === VISIBLE) {
+          if (!this.props.pref.collapsed) {
+            this._dispatchImpressionStats();
+          }
+          props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+        }
+      };
+      props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+    }
+  }
+
+  componentDidMount() {
+    if (this.props.rows.length && !this.props.pref.collapsed) {
+      this.sendImpressionStatsOrAddListener();
+    }
+  }
+
+  componentDidUpdate(prevProps) {
+    const {props} = this;
+    const isCollapsed = props.pref.collapsed;
+    const wasCollapsed = prevProps.pref.collapsed;
+    if (
+      // Don't send impression stats for the empty state
+      props.rows.length &&
+      (
+        // We only want to send impression stats if the content of the cards has changed
+        // and the section is not collapsed...
+        (props.rows !== prevProps.rows && !isCollapsed) ||
+        // or if we are expanding a section that was collapsed.
+        (wasCollapsed && !isCollapsed)
+      )
+    ) {
+      this.sendImpressionStatsOrAddListener();
+    }
+  }
+
+  componentWillUnmount() {
+    if (this._onVisibilityChange) {
+      this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+    }
+  }
+
+  needsImpressionStats(cards) {
+    if (!this.impressionCardGuids || (this.impressionCardGuids.length !== cards.length)) {
+      return true;
+    }
+
+    for (let i = 0; i < cards.length; i++) {
+      if (cards[i].guid !== this.impressionCardGuids[i]) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  render() {
+    const {
+      id, eventSource, title, icon, rows,
+      emptyState, dispatch, compactCards,
+      contextMenuOptions, initialized, disclaimer,
+      pref, privacyNoticeURL, isFirst, isLast
+    } = this.props;
+
+    const maxCardsPerRow = compactCards ? CARDS_PER_ROW_COMPACT_WIDE : CARDS_PER_ROW_DEFAULT;
+    const {numRows} = this;
+    const maxCards = maxCardsPerRow * numRows;
+    const maxCardsOnNarrow = CARDS_PER_ROW_DEFAULT * numRows;
+
+    // Show topics only for top stories and if it's not initialized yet (so
+    // content doesn't shift when it is loaded) or has loaded with topics
+    const shouldShowTopics = (id === "topstories" &&
+      (!this.props.topics || this.props.topics.length > 0));
+
+    const realRows = rows.slice(0, maxCards);
+
+    // The empty state should only be shown after we have initialized and there is no content.
+    // Otherwise, we should show placeholders.
+    const shouldShowEmptyState = initialized && !rows.length;
+
+    const cards = [];
+    if (!shouldShowEmptyState) {
+      for (let i = 0; i < maxCards; i++) {
+        const link = realRows[i];
+        // On narrow viewports, we only show 3 cards per row. We'll mark the rest as
+        // .hide-for-narrow to hide in CSS via @media query.
+        const className = (i >= maxCardsOnNarrow) ? "hide-for-narrow" : "";
+        cards.push(link ? (
+          <Card key={i}
+            index={i}
+            className={className}
+            dispatch={dispatch}
+            link={link}
+            contextMenuOptions={contextMenuOptions}
+            eventSource={eventSource}
+            shouldSendImpressionStats={this.props.shouldSendImpressionStats}
+            isWebExtension={this.props.isWebExtension} />
+        ) : (
+          <PlaceholderCard key={i} className={className} />
+        ));
+      }
+    }
+
+    const sectionClassName = [
+      "section",
+      compactCards ? "compact-cards" : "normal-cards"
+    ].join(" ");
+
+    // <Section> <-- React component
+    // <section> <-- HTML5 element
+    return (<ComponentPerfTimer {...this.props}>
+      <CollapsibleSection className={sectionClassName} icon={icon}
+        title={title}
+        id={id}
+        eventSource={eventSource}
+        disclaimer={disclaimer}
+        collapsed={this.props.pref.collapsed}
+        showPrefName={(pref && pref.feed) || id}
+        privacyNoticeURL={privacyNoticeURL}
+        Prefs={this.props.Prefs}
+        isFirst={isFirst}
+        isLast={isLast}
+        dispatch={this.props.dispatch}
+        isWebExtension={this.props.isWebExtension}>
+
+        {!shouldShowEmptyState && (<ul className="section-list" style={{padding: 0}}>
+          {cards}
+        </ul>)}
+        {shouldShowEmptyState &&
+          <div className="section-empty-state">
+            <div className="empty-state">
+              {emptyState.icon && emptyState.icon.startsWith("moz-extension://") ?
+                <img className="empty-state-icon icon" style={{"background-image": `url('${emptyState.icon}')`}} /> :
+                <img className={`empty-state-icon icon icon-${emptyState.icon}`} />}
+              <p className="empty-state-message">
+                {getFormattedMessage(emptyState.message)}
+              </p>
+            </div>
+          </div>}
+        {shouldShowTopics && <Topics topics={this.props.topics} read_more_endpoint={this.props.read_more_endpoint} />}
+      </CollapsibleSection>
+    </ComponentPerfTimer>);
+  }
+}
+
+Section.defaultProps = {
+  document: global.document,
+  rows: [],
+  emptyState: {},
+  pref: {},
+  title: ""
+};
+
+export const SectionIntl = connect(state => ({Prefs: state.Prefs}))(injectIntl(Section));
+
+export class _Sections extends React.PureComponent {
+  renderSections() {
+    const sections = [];
+    const enabledSections = this.props.Sections.filter(section => section.enabled);
+    const {sectionOrder, "feeds.topsites": showTopSites} = this.props.Prefs.values;
+    // Enabled sections doesn't include Top Sites, so we add it if enabled.
+    const expectedCount = enabledSections.length + ~~showTopSites;
+
+    for (const sectionId of sectionOrder.split(",")) {
+      const commonProps = {
+        key: sectionId,
+        isFirst: sections.length === 0,
+        isLast: sections.length === expectedCount - 1
+      };
+      if (sectionId === "topsites" && showTopSites) {
+        sections.push(<TopSites {...commonProps} />);
+      } else {
+        const section = enabledSections.find(s => s.id === sectionId);
+        if (section) {
+          sections.push(<SectionIntl {...section} {...commonProps} />);
+        }
+      }
+    }
+    return sections;
+  }
+
+  render() {
+    return (
+      <div className="sections-list">
+        {this.renderSections()}
+      </div>
+    );
+  }
+}
+
+export const Sections = connect(state => ({Sections: state.Sections, Prefs: state.Prefs}))(_Sections);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Sections/_Sections.scss
@@ -0,0 +1,77 @@
+.sections-list {
+  .section-list {
+    display: grid;
+    grid-gap: $base-gutter;
+    grid-template-columns: repeat(auto-fit, $card-width);
+    margin: 0;
+
+    @media (max-width: $break-point-medium) {
+      @include context-menu-open-left;
+    }
+
+    @media (min-width: $break-point-medium) and (max-width: $break-point-large) {
+      :nth-child(2n) {
+        @include context-menu-open-left;
+      }
+    }
+
+    @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) {
+      :nth-child(3n) {
+        @include context-menu-open-left;
+      }
+    }
+
+    @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) {
+      :nth-child(3n) {
+        @include context-menu-open-left;
+      }
+    }
+  }
+
+  .section-empty-state {
+    border: $border-secondary;
+    border-radius: $border-radius;
+    display: flex;
+    height: $card-height;
+    width: 100%;
+
+    .empty-state {
+      margin: auto;
+      max-width: 350px;
+
+      .empty-state-icon {
+        background-position: center;
+        background-repeat: no-repeat;
+        background-size: 50px 50px;
+        -moz-context-properties: fill;
+        display: block;
+        fill: var(--newtab-icon-secondary-color);
+        height: 50px;
+        margin: 0 auto;
+        width: 50px;
+      }
+
+      .empty-state-message {
+        color: var(--newtab-text-primary-color);
+        font-size: 13px;
+        margin-bottom: 0;
+        text-align: center;
+      }
+    }
+
+    @media (min-width: $break-point-widest) {
+      height: $card-height-large;
+    }
+  }
+}
+
+@media (min-width: $break-point-widest) {
+  .sections-list {
+    // Compact cards stay the same size but normal cards get bigger.
+    .normal-cards {
+      .section-list {
+        grid-template-columns: repeat(auto-fit, $card-width-large);
+      }
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/StartupOverlay/StartupOverlay.jsx
@@ -0,0 +1,89 @@
+import {FormattedMessage, injectIntl} from "react-intl";
+import {actionCreators as ac} from "common/Actions.jsm";
+import {connect} from "react-redux";
+import React from "react";
+
+export class _StartupOverlay extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onInputChange = this.onInputChange.bind(this);
+    this.onSubmit = this.onSubmit.bind(this);
+    this.clickSkip = this.clickSkip.bind(this);
+    this.initScene = this.initScene.bind(this);
+    this.removeOverlay = this.removeOverlay.bind(this);
+
+    this.state = {emailInput: ""};
+    this.initScene();
+  }
+
+  initScene() {
+    // Timeout to allow the scene to render once before attaching the attribute
+    // to trigger the animation.
+    setTimeout(() => {
+      this.setState({show: true});
+    }, 10);
+  }
+
+  removeOverlay() {
+    window.removeEventListener("visibilitychange", this.removeOverlay);
+    this.setState({show: false});
+    setTimeout(() => {
+      // Allow scrolling and fully remove overlay after animation finishes.
+      document.body.classList.remove("welcome");
+    }, 400);
+  }
+
+  onInputChange(e) {
+    this.setState({emailInput: e.target.value});
+  }
+
+  onSubmit() {
+    this.props.dispatch(ac.UserEvent({event: "SUBMIT_EMAIL"}));
+    window.addEventListener("visibilitychange", this.removeOverlay);
+  }
+
+  clickSkip() {
+    this.props.dispatch(ac.UserEvent({event: "SKIPPED_SIGNIN"}));
+    this.removeOverlay();
+  }
+
+  render() {
+    let termsLink = (<a href="https://accounts.firefox.com/legal/terms" target="_blank" rel="noopener noreferrer"><FormattedMessage id="firstrun_terms_of_service" /></a>);
+    let privacyLink = (<a href="https://accounts.firefox.com/legal/privacy" target="_blank" rel="noopener noreferrer"><FormattedMessage id="firstrun_privacy_notice" /></a>);
+    return (
+      <div className={`overlay-wrapper ${this.state.show ? "show " : ""}`}>
+        <div className="background" />
+        <div className="firstrun-scene">
+          <div className="fxaccounts-container">
+            <div className="firstrun-left-divider">
+              <h1 className="firstrun-title"><FormattedMessage id="firstrun_title" /></h1>
+              <p className="firstrun-content"><FormattedMessage id="firstrun_content" /></p>
+              <a className="firstrun-link" href="https://www.mozilla.org/firefox/features/sync/" target="_blank" rel="noopener noreferrer"><FormattedMessage id="firstrun_learn_more_link" /></a>
+            </div>
+            <div className="firstrun-sign-in">
+              <p className="form-header"><FormattedMessage id="firstrun_form_header" /><span><FormattedMessage id="firstrun_form_sub_header" /></span></p>
+              <form method="get" action="https://accounts.firefox.com?entrypoint=activity-stream-firstrun&utm_source=activity-stream&utm_campaign=firstrun" target="_blank" rel="noopener noreferrer" onSubmit={this.onSubmit}>
+                <input name="service" type="hidden" value="sync" />
+                <input name="action" type="hidden" value="email" />
+                <input name="context" type="hidden" value="fx_desktop_v3" />
+                <input className="email-input" name="email" type="email" required="true" placeholder={this.props.intl.formatMessage({id: "firstrun_email_input_placeholder"})} onChange={this.onInputChange} />
+                <div className="extra-links">
+                  <FormattedMessage
+                    id="firstrun_extra_legal_links"
+                    values={{
+                      terms: termsLink,
+                      privacy: privacyLink
+                    }} />
+                </div>
+                <button className="continue-button" type="submit"><FormattedMessage id="firstrun_continue_to_login" /></button>
+              </form>
+              <button className="skip-button" disabled={!!this.state.emailInput} onClick={this.clickSkip}><FormattedMessage id="firstrun_skip_login" /></button>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+export const StartupOverlay = connect()(injectIntl(_StartupOverlay));
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/StartupOverlay/_StartupOverlay.scss
@@ -0,0 +1,247 @@
+.activity-stream {
+  &.welcome {
+    overflow: hidden;
+  }
+
+  &:not(.welcome) {
+    .overlay-wrapper {
+      display: none;
+    }
+  }
+}
+
+.overlay-wrapper {
+  position: fixed;
+  top: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: 21000;
+  font-weight: 200;
+  transition: opacity 0.4s;
+  opacity: 0;
+
+  &.show {
+    transition: none;
+    opacity: 1;
+
+    .firstrun-sign-in {
+      transition: opacity 1.5s, transform 1.5s;
+      transition-delay: 0.2s;
+      transform: translateY(-50%) scale(1);
+      opacity: 1;
+    }
+
+    .firstrun-firefox-logo {
+      transition: opacity 2.3s;
+      opacity: 1;
+    }
+
+    .firstrun-title,
+    .firstrun-content,
+    .firstrun-link {
+      transition: transform 0.5s, opacity 0.8s;
+      transform: translateY(0);
+      opacity: 1;
+    }
+
+    .firstrun-title {
+      transition-delay: 0.2s;
+    }
+
+    .firstrun-content {
+      transition-delay: 0.4s;
+    }
+
+    .firstrun-link {
+      transition-delay: 0.6s;
+    }
+
+    .fxaccounts-container {
+      transition: none;
+      opacity: 1;
+    }
+  }
+}
+
+.background {
+  width: 100%;
+  height: 100%;
+  display: block;
+  background: url('#{$image-path}fox-tail.png') top -200px center no-repeat,
+  linear-gradient(to bottom, $blue-70 40%, #004EC2 60%, $blue-60 80%, #0080FF 90%, #00C7FF 100%) top center no-repeat,
+  $blue-70;
+  background-size: cover;
+}
+
+.firstrun-sign-in {
+  transform: translateY(-50%) scale(0.8);
+  position: relative;
+  top: 50%;
+  width: 358px;
+  opacity: 0;
+  background-color: $white;
+  float: inline-end;
+  color: $grey-90;
+  text-align: center;
+  padding: 10px;
+
+  .extra-links {
+    font-size: 12px;
+    max-width: 340px;
+    margin: 14px 50px;
+    color: #676F7E;
+    cursor: default;
+
+    a {
+      color: $grey-50;
+      cursor: pointer;
+      text-decoration: underline;
+    }
+
+    a:hover,
+    a:active,
+    a:focus {
+      color: $blue-50;
+    }
+  }
+
+  .email-input {
+    box-shadow: none;
+    margin: auto;
+    width: 244px;
+    display: block;
+    height: 40px;
+    padding-inline-start: 20px;
+    border: 1px solid $grey-50;
+    border-radius: 2px;
+    font-size: 16px;
+
+    &:hover {
+      border-color: $grey-90;
+    }
+  }
+
+  .form-header {
+    font-size: 18px;
+    margin: 15px auto;
+  }
+
+  .form-header span {
+    font-size: 14px;
+    margin-top: 4px;
+    display: block;
+  }
+
+  button {
+    border-radius: 2px;
+    display: block;
+    cursor: pointer;
+    margin: 10px auto 0;
+  }
+
+  .continue-button {
+    font-size: 18px;
+    height: 43px;
+    width: 250px;
+    padding: 8px 0;
+    border: 1px solid $blue-60;
+    color: $white;
+    background-color: $blue-50;
+    transition-duration: 150ms;
+    transition-property: background-color;
+
+    &:not([disabled]):active {
+      background: $blue-70;
+      border-color: $blue-80;
+    }
+  }
+
+  .skip-button {
+    font-size: 13px;
+    margin-top: 40px;
+    margin-bottom: 20px;
+    background-color: #FCFCFC;
+    color: $blue-50;
+    border: 1px solid $blue-50;
+    min-height: 24px;
+    padding: 5px 10px;
+    transition: background-color 150ms, color 150ms, border-color 150ms;
+
+    &[disabled] {
+      background-color: #EBEBEB;
+      border-color: #B1B1B1;
+      color: #6A6A6A;
+      cursor: default;
+      opacity: 0.5;
+    }
+
+    &:not([disabled]):hover {
+      background-color: $blue-50;
+      border-color: $blue-60;
+      color: $white;
+    }
+  }
+}
+
+.firstrun-left-divider {
+  position: relative;
+  float: inline-start;
+  clear: both;
+  width: 435px;
+}
+
+.firstrun-content {
+  line-height: 1.5;
+  margin-bottom: 48px;
+  max-width: 352px;
+  background: url('#{$image-path}sync-devices.svg') bottom center no-repeat;
+  padding-bottom: 210px;
+}
+
+.firstrun-link {
+  color: $white;
+  display: block;
+  text-decoration: underline;
+
+  &:hover,
+  &:active,
+  &:focus {
+    color: $white;
+  }
+}
+
+.firstrun-title {
+  background: url('chrome://branding/content/about-logo.png') top left no-repeat;
+  background-size: 90px 90px;
+  margin: 40px 0 10px;
+  padding-top: 110px;
+  font-weight: 200;
+}
+
+[dir='rtl'] {
+  .firstrun-title {
+    background-position: top right;
+  }
+}
+
+.fxaccounts-container {
+  position: absolute;
+  bottom: 0;
+  right: 0;
+  top: 0;
+  left: 0;
+  color: $white;
+  height: 515px;
+  margin: auto;
+  width: 819px;
+  z-index: 10;
+  transition: opacity 0.3s;
+  opacity: 0;
+}
+
+.firstrun-title,
+.firstrun-content,
+.firstrun-link {
+  opacity: 0;
+  transform: translateY(-5px);
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/TopSites/TopSite.jsx
@@ -0,0 +1,425 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {FormattedMessage, injectIntl} from "react-intl";
+import {
+  MIN_CORNER_FAVICON_SIZE,
+  MIN_RICH_FAVICON_SIZE,
+  TOP_SITES_CONTEXT_MENU_OPTIONS,
+  TOP_SITES_SOURCE
+} from "./TopSitesConstants";
+import {LinkMenu} from "content-src/components/LinkMenu/LinkMenu";
+import React from "react";
+import {TOP_SITES_MAX_SITES_PER_ROW} from "common/Reducers.jsm";
+
+export class TopSiteLink extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onDragEvent = this.onDragEvent.bind(this);
+  }
+
+  /*
+   * Helper to determine whether the drop zone should allow a drop. We only allow
+   * dropping top sites for now.
+   */
+  _allowDrop(e) {
+    return e.dataTransfer.types.includes("text/topsite-index");
+  }
+
+  onDragEvent(event) {
+    switch (event.type) {
+      case "click":
+        // Stop any link clicks if we started any dragging
+        if (this.dragged) {
+          event.preventDefault();
+        }
+        break;
+      case "dragstart":
+        this.dragged = true;
+        event.dataTransfer.effectAllowed = "move";
+        event.dataTransfer.setData("text/topsite-index", this.props.index);
+        event.target.blur();
+        this.props.onDragEvent(event, this.props.index, this.props.link, this.props.title);
+        break;
+      case "dragend":
+        this.props.onDragEvent(event);
+        break;
+      case "dragenter":
+      case "dragover":
+      case "drop":
+        if (this._allowDrop(event)) {
+          event.preventDefault();
+          this.props.onDragEvent(event, this.props.index);
+        }
+        break;
+      case "mousedown":
+        // Reset at the first mouse event of a potential drag
+        this.dragged = false;
+        break;
+    }
+  }
+
+  render() {
+    const {children, className, defaultStyle, isDraggable, link, onClick, title} = this.props;
+    const topSiteOuterClassName = `top-site-outer${className ? ` ${className}` : ""}${link.isDragged ? " dragged" : ""}`;
+    const {tippyTopIcon, faviconSize} = link;
+    const [letterFallback] = title;
+    let imageClassName;
+    let imageStyle;
+    let showSmallFavicon = false;
+    let smallFaviconStyle;
+    let smallFaviconFallback;
+    if (defaultStyle) { // force no styles (letter fallback) even if the link has imagery
+      smallFaviconFallback = false;
+    } else if (link.customScreenshotURL) {
+      // assume high quality custom screenshot and use rich icon styles and class names
+      imageClassName = "top-site-icon rich-icon";
+      imageStyle = {
+        backgroundColor: link.backgroundColor,
+        backgroundImage: `url(${link.screenshot})`
+      };
+    } else if (tippyTopIcon || faviconSize >= MIN_RICH_FAVICON_SIZE) {
+      // styles and class names for top sites with rich icons
+      imageClassName = "top-site-icon rich-icon";
+      imageStyle = {
+        backgroundColor: link.backgroundColor,
+        backgroundImage: `url(${tippyTopIcon || link.favicon})`
+      };
+    } else {
+      // styles and class names for top sites with screenshot + small icon in top left corner
+      imageClassName = `screenshot${link.screenshot ? " active" : ""}`;
+      imageStyle = {backgroundImage: link.screenshot ? `url(${link.screenshot})` : "none"};
+
+      // only show a favicon in top left if it's greater than 16x16
+      if (faviconSize >= MIN_CORNER_FAVICON_SIZE) {
+        showSmallFavicon = true;
+        smallFaviconStyle = {backgroundImage:  `url(${link.favicon})`};
+      } else if (link.screenshot) {
+        // Don't show a small favicon if there is no screenshot, because that
+        // would result in two fallback icons
+        showSmallFavicon = true;
+        smallFaviconFallback = true;
+      }
+    }
+    let draggableProps = {};
+    if (isDraggable) {
+      draggableProps = {
+        onClick: this.onDragEvent,
+        onDragEnd: this.onDragEvent,
+        onDragStart: this.onDragEvent,
+        onMouseDown: this.onDragEvent
+      };
+    }
+    return (<li className={topSiteOuterClassName} onDrop={this.onDragEvent} onDragOver={this.onDragEvent} onDragEnter={this.onDragEvent} onDragLeave={this.onDragEvent} {...draggableProps}>
+      <div className="top-site-inner">
+         <a href={link.url} onClick={onClick}>
+            <div className="tile" aria-hidden={true} data-fallback={letterFallback}>
+              <div className={imageClassName} style={imageStyle} />
+              {showSmallFavicon && <div
+                className="top-site-icon default-icon"
+                data-fallback={smallFaviconFallback && letterFallback}
+                style={smallFaviconStyle} />}
+           </div>
+           <div className={`title ${link.isPinned ? "pinned" : ""}`}>
+             {link.isPinned && <div className="icon icon-pin-small" />}
+              <span dir="auto">{title}</span>
+           </div>
+         </a>
+         {children}
+      </div>
+    </li>);
+  }
+}
+TopSiteLink.defaultProps = {
+  title: "",
+  link: {},
+  isDraggable: true
+};
+
+export class TopSite extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {showContextMenu: false};
+    this.onLinkClick = this.onLinkClick.bind(this);
+    this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
+    this.onMenuUpdate = this.onMenuUpdate.bind(this);
+  }
+
+  /**
+   * Report to telemetry additional information about the item.
+   */
+  _getTelemetryInfo() {
+    const value = {icon_type: this.props.link.iconType};
+    // Filter out "not_pinned" type for being the default
+    if (this.props.link.isPinned) {
+      value.card_type = "pinned";
+    }
+    return {value};
+  }
+
+  userEvent(event) {
+    this.props.dispatch(ac.UserEvent(Object.assign({
+      event,
+      source: TOP_SITES_SOURCE,
+      action_position: this.props.index
+    }, this._getTelemetryInfo())));
+  }
+
+  onLinkClick(event) {
+    this.userEvent("CLICK");
+
+    // Specially handle a top site link click for "typed" frecency bonus as
+    // specified as a property on the link.
+    event.preventDefault();
+    const {altKey, button, ctrlKey, metaKey, shiftKey} = event;
+    this.props.dispatch(ac.OnlyToMain({
+      type: at.OPEN_LINK,
+      data: Object.assign(this.props.link, {event: {altKey, button, ctrlKey, metaKey, shiftKey}})
+    }));
+  }
+
+  onMenuButtonClick(event) {
+    event.preventDefault();
+    this.props.onActivate(this.props.index);
+    this.setState({showContextMenu: true});
+  }
+
+  onMenuUpdate(showContextMenu) {
+    this.setState({showContextMenu});
+  }
+
+  render() {
+    const {props} = this;
+    const {link} = props;
+    const isContextMenuOpen = this.state.showContextMenu && props.activeIndex === props.index;
+    const title = link.label || link.hostname;
+    return (<TopSiteLink {...props} onClick={this.onLinkClick} onDragEvent={this.props.onDragEvent} className={`${props.className || ""}${isContextMenuOpen ? " active" : ""}`} title={title}>
+        <div>
+          <button className="context-menu-button icon" onClick={this.onMenuButtonClick}>
+            <span className="sr-only">
+              <FormattedMessage id="context_menu_button_sr" values={{title}} />
+            </span>
+          </button>
+          {isContextMenuOpen &&
+            <LinkMenu
+              dispatch={props.dispatch}
+              index={props.index}
+              onUpdate={this.onMenuUpdate}
+              options={TOP_SITES_CONTEXT_MENU_OPTIONS}
+              site={link}
+              siteInfo={this._getTelemetryInfo()}
+              source={TOP_SITES_SOURCE} />
+          }
+        </div>
+    </TopSiteLink>);
+  }
+}
+TopSite.defaultProps = {
+  link: {},
+  onActivate() {}
+};
+
+export class TopSitePlaceholder extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onEditButtonClick = this.onEditButtonClick.bind(this);
+  }
+
+  onEditButtonClick() {
+    this.props.dispatch(
+      {type: at.TOP_SITES_EDIT, data: {index: this.props.index}});
+  }
+
+  render() {
+    return (<TopSiteLink {...this.props} className={`placeholder ${this.props.className || ""}`} isDraggable={false}>
+      <button className="context-menu-button edit-button icon"
+       title={this.props.intl.formatMessage({id: "edit_topsites_edit_button"})}
+       onClick={this.onEditButtonClick} />
+    </TopSiteLink>);
+  }
+}
+
+export class _TopSiteList extends React.PureComponent {
+  static get DEFAULT_STATE() {
+    return {
+      activeIndex: null,
+      draggedIndex: null,
+      draggedSite: null,
+      draggedTitle: null,
+      topSitesPreview: null
+    };
+  }
+
+  constructor(props) {
+    super(props);
+    this.state = _TopSiteList.DEFAULT_STATE;
+    this.onDragEvent = this.onDragEvent.bind(this);
+    this.onActivate = this.onActivate.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (this.state.draggedSite) {
+      const prevTopSites = this.props.TopSites && this.props.TopSites.rows;
+      const newTopSites = nextProps.TopSites && nextProps.TopSites.rows;
+      if (prevTopSites && prevTopSites[this.state.draggedIndex] &&
+        prevTopSites[this.state.draggedIndex].url === this.state.draggedSite.url &&
+        (!newTopSites[this.state.draggedIndex] || newTopSites[this.state.draggedIndex].url !== this.state.draggedSite.url)) {
+        // We got the new order from the redux store via props. We can clear state now.
+        this.setState(_TopSiteList.DEFAULT_STATE);
+      }
+    }
+  }
+
+  userEvent(event, index) {
+    this.props.dispatch(ac.UserEvent({
+      event,
+      source: TOP_SITES_SOURCE,
+      action_position: index
+    }));
+  }
+
+  onDragEvent(event, index, link, title) {
+    switch (event.type) {
+      case "dragstart":
+        this.dropped = false;
+        this.setState({
+          draggedIndex: index,
+          draggedSite: link,
+          draggedTitle: title,
+          activeIndex: null
+        });
+        this.userEvent("DRAG", index);
+        break;
+      case "dragend":
+        if (!this.dropped) {
+          // If there was no drop event, reset the state to the default.
+          this.setState(_TopSiteList.DEFAULT_STATE);
+        }
+        break;
+      case "dragenter":
+        if (index === this.state.draggedIndex) {
+          this.setState({topSitesPreview: null});
+        } else {
+          this.setState({topSitesPreview: this._makeTopSitesPreview(index)});
+        }
+        break;
+      case "drop":
+        if (index !== this.state.draggedIndex) {
+          this.dropped = true;
+          this.props.dispatch(ac.AlsoToMain({
+            type: at.TOP_SITES_INSERT,
+            data: {
+              site: {
+                url: this.state.draggedSite.url,
+                label: this.state.draggedTitle,
+                customScreenshotURL: this.state.draggedSite.customScreenshotURL
+              },
+              index,
+              draggedFromIndex: this.state.draggedIndex
+            }
+          }));
+          this.userEvent("DROP", index);
+        }
+        break;
+    }
+  }
+
+  _getTopSites() {
+    // Make a copy of the sites to truncate or extend to desired length
+    let topSites = this.props.TopSites.rows.slice();
+    topSites.length = this.props.TopSitesRows * TOP_SITES_MAX_SITES_PER_ROW;
+    return topSites;
+  }
+
+  /**
+   * Make a preview of the topsites that will be the result of dropping the currently
+   * dragged site at the specified index.
+   */
+  _makeTopSitesPreview(index) {
+    const topSites = this._getTopSites();
+    topSites[this.state.draggedIndex] = null;
+    const pinnedOnly = topSites.map(site => ((site && site.isPinned) ? site : null));
+    const unpinned = topSites.filter(site => site && !site.isPinned);
+    const siteToInsert = Object.assign({}, this.state.draggedSite, {isPinned: true, isDragged: true});
+    if (!pinnedOnly[index]) {
+      pinnedOnly[index] = siteToInsert;
+    } else {
+      // Find the hole to shift the pinned site(s) towards. We shift towards the
+      // hole left by the site being dragged.
+      let holeIndex = index;
+      const indexStep = index > this.state.draggedIndex ? -1 : 1;
+      while (pinnedOnly[holeIndex]) {
+        holeIndex += indexStep;
+      }
+
+      // Shift towards the hole.
+      const shiftingStep = index > this.state.draggedIndex ? 1 : -1;
+      while (holeIndex !== index) {
+        const nextIndex = holeIndex + shiftingStep;
+        pinnedOnly[holeIndex] = pinnedOnly[nextIndex];
+        holeIndex = nextIndex;
+      }
+      pinnedOnly[index] = siteToInsert;
+    }
+
+    // Fill in the remaining holes with unpinned sites.
+    const preview = pinnedOnly;
+    for (let i = 0; i < preview.length; i++) {
+      if (!preview[i]) {
+        preview[i] = unpinned.shift() || null;
+      }
+    }
+
+    return preview;
+  }
+
+  onActivate(index) {
+    this.setState({activeIndex: index});
+  }
+
+  render() {
+    const {props} = this;
+    const topSites = this.state.topSitesPreview || this._getTopSites();
+    const topSitesUI = [];
+    const commonProps = {
+      onDragEvent: this.onDragEvent,
+      dispatch: props.dispatch,
+      intl: props.intl
+    };
+    // We assign a key to each placeholder slot. We need it to be independent
+    // of the slot index (i below) so that the keys used stay the same during
+    // drag and drop reordering and the underlying DOM nodes are reused.
+    // This mostly (only?) affects linux so be sure to test on linux before changing.
+    let holeIndex = 0;
+
+    // On narrow viewports, we only show 6 sites per row. We'll mark the rest as
+    // .hide-for-narrow to hide in CSS via @media query.
+    const maxNarrowVisibleIndex = props.TopSitesRows * 6;
+
+    for (let i = 0, l = topSites.length; i < l; i++) {
+      const link = topSites[i] && Object.assign({}, topSites[i], {iconType: this.props.topSiteIconType(topSites[i])});
+      const slotProps = {
+        key: link ? link.url : holeIndex++,
+        index: i
+      };
+      if (i >= maxNarrowVisibleIndex) {
+        slotProps.className = "hide-for-narrow";
+      }
+      topSitesUI.push(!link ? (
+        <TopSitePlaceholder
+          {...slotProps}
+          {...commonProps} />
+      ) : (
+        <TopSite
+          link={link}
+          activeIndex={this.state.activeIndex}
+          onActivate={this.onActivate}
+          {...slotProps}
+          {...commonProps} />
+      ));
+    }
+    return (<ul className={`top-sites-list${this.state.draggedSite ? " dnd-active" : ""}`}>
+      {topSitesUI}
+    </ul>);
+  }
+}
+
+export const TopSiteList = injectIntl(_TopSiteList);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/TopSites/TopSiteForm.jsx
@@ -0,0 +1,251 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {FormattedMessage} from "react-intl";
+import React from "react";
+import {TOP_SITES_SOURCE} from "./TopSitesConstants";
+import {TopSiteFormInput} from "./TopSiteFormInput";
+import {TopSiteLink} from "./TopSite";
+
+export class TopSiteForm extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    const {site} = props;
+    this.state = {
+      label: site ? (site.label || site.hostname) : "",
+      url: site ? site.url : "",
+      validationError: false,
+      customScreenshotUrl: site ? site.customScreenshotURL : "",
+      showCustomScreenshotForm: site ? site.customScreenshotURL : false
+    };
+    this.onClearScreenshotInput = this.onClearScreenshotInput.bind(this);
+    this.onLabelChange = this.onLabelChange.bind(this);
+    this.onUrlChange = this.onUrlChange.bind(this);
+    this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
+    this.onClearUrlClick = this.onClearUrlClick.bind(this);
+    this.onDoneButtonClick = this.onDoneButtonClick.bind(this);
+    this.onCustomScreenshotUrlChange = this.onCustomScreenshotUrlChange.bind(this);
+    this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this);
+    this.onEnableScreenshotUrlForm = this.onEnableScreenshotUrlForm.bind(this);
+    this.validateUrl = this.validateUrl.bind(this);
+  }
+
+  onLabelChange(event) {
+    this.setState({"label": event.target.value});
+  }
+
+  onUrlChange(event) {
+    this.setState({
+      url: event.target.value,
+      validationError: false
+    });
+  }
+
+  onClearUrlClick() {
+    this.setState({
+      url: "",
+      validationError: false
+    });
+  }
+
+  onEnableScreenshotUrlForm() {
+    this.setState({showCustomScreenshotForm: true});
+  }
+
+  _updateCustomScreenshotInput(customScreenshotUrl) {
+    this.setState({
+      customScreenshotUrl,
+      validationError: false
+    });
+    this.props.dispatch({type: at.PREVIEW_REQUEST_CANCEL});
+  }
+
+  onCustomScreenshotUrlChange(event) {
+    this._updateCustomScreenshotInput(event.target.value);
+  }
+
+  onClearScreenshotInput() {
+    this._updateCustomScreenshotInput("");
+  }
+
+  onCancelButtonClick(ev) {
+    ev.preventDefault();
+    this.props.onClose();
+  }
+
+  onDoneButtonClick(ev) {
+    ev.preventDefault();
+
+    if (this.validateForm()) {
+      const site = {url: this.cleanUrl(this.state.url)};
+      const {index} = this.props;
+      if (this.state.label !== "") {
+        site.label = this.state.label;
+      }
+
+      if (this.state.customScreenshotUrl) {
+        site.customScreenshotURL = this.cleanUrl(this.state.customScreenshotUrl);
+      } else if (this.props.site && this.props.site.customScreenshotURL) {
+        // Used to flag that previously cached screenshot should be removed
+        site.customScreenshotURL = null;
+      }
+      this.props.dispatch(ac.AlsoToMain({
+        type: at.TOP_SITES_PIN,
+        data: {site, index}
+      }));
+      this.props.dispatch(ac.UserEvent({
+        source: TOP_SITES_SOURCE,
+        event: "TOP_SITES_EDIT",
+        action_position: index
+      }));
+
+      this.props.onClose();
+    }
+  }
+
+  onPreviewButtonClick(event) {
+    event.preventDefault();
+    if (this.validateForm()) {
+      this.props.dispatch(ac.AlsoToMain({
+        type: at.PREVIEW_REQUEST,
+        data: {url: this.cleanUrl(this.state.customScreenshotUrl)}
+      }));
+      this.props.dispatch(ac.UserEvent({
+        source: TOP_SITES_SOURCE,
+        event: "PREVIEW_REQUEST"
+      }));
+    }
+  }
+
+  cleanUrl(url) {
+    // If we are missing a protocol, prepend http://
+    if (!url.startsWith("http:") && !url.startsWith("https:")) {
+      return `http://${url}`;
+    }
+    return url;
+  }
+
+  _tryParseUrl(url) {
+    try {
+      return new URL(url);
+    } catch (e) {
+      return null;
+    }
+  }
+
+  validateUrl(url) {
+    const validProtocols = ["http:", "https:"];
+    const urlObj = this._tryParseUrl(url) || this._tryParseUrl(this.cleanUrl(url));
+
+    return urlObj && validProtocols.includes(urlObj.protocol);
+  }
+
+  validateCustomScreenshotUrl() {
+    const {customScreenshotUrl} = this.state;
+    return !customScreenshotUrl || this.validateUrl(customScreenshotUrl);
+  }
+
+  validateForm() {
+    const validate = this.validateUrl(this.state.url) && this.validateCustomScreenshotUrl();
+
+    if (!validate) {
+      this.setState({validationError: true});
+    }
+
+    return validate;
+  }
+
+  _renderCustomScreenshotInput() {
+    const {customScreenshotUrl} = this.state;
+    const requestFailed = this.props.previewResponse === "";
+    const validationError = (this.state.validationError && !this.validateCustomScreenshotUrl()) || requestFailed;
+    // Set focus on error if the url field is valid or when the input is first rendered and is empty
+    const shouldFocus = (validationError && this.validateUrl(this.state.url)) || !customScreenshotUrl;
+    const isLoading = this.props.previewResponse === null &&
+      customScreenshotUrl && this.props.previewUrl === this.cleanUrl(customScreenshotUrl);
+
+    if (!this.state.showCustomScreenshotForm) {
+      return (<a className="enable-custom-image-input" onClick={this.onEnableScreenshotUrlForm}>
+        <FormattedMessage id="topsites_form_use_image_link" />
+      </a>);
+    }
+    return (<div className="custom-image-input-container">
+      <TopSiteFormInput
+        errorMessageId={requestFailed ? "topsites_form_image_validation" : "topsites_form_url_validation"}
+        loading={isLoading}
+        onChange={this.onCustomScreenshotUrlChange}
+        onClear={this.onClearScreenshotInput}
+        shouldFocus={shouldFocus}
+        typeUrl={true}
+        value={customScreenshotUrl}
+        validationError={validationError}
+        titleId="topsites_form_image_url_label"
+        placeholderId="topsites_form_url_placeholder"
+        intl={this.props.intl} />
+    </div>);
+  }
+
+  render() {
+    const {customScreenshotUrl} = this.state;
+    const requestFailed = this.props.previewResponse === "";
+    // For UI purposes, editing without an existing link is "add"
+    const showAsAdd = !this.props.site;
+    const previous = (this.props.site && this.props.site.customScreenshotURL) || "";
+    const changed = customScreenshotUrl && this.cleanUrl(customScreenshotUrl) !== previous;
+    // Preview mode if changes were made to the custom screenshot URL and no preview was received yet
+    // or the request failed
+    const previewMode = changed && !this.props.previewResponse;
+    const previewLink = Object.assign({}, this.props.site);
+    if (this.props.previewResponse) {
+      previewLink.screenshot = this.props.previewResponse;
+      previewLink.customScreenshotURL = this.props.previewUrl;
+    }
+    return (
+      <form className="topsite-form">
+        <div className="form-input-container">
+          <h3 className="section-title">
+            <FormattedMessage id={showAsAdd ? "topsites_form_add_header" : "topsites_form_edit_header"} />
+          </h3>
+          <div className="fields-and-preview">
+            <div className="form-wrapper">
+              <TopSiteFormInput onChange={this.onLabelChange}
+                value={this.state.label}
+                titleId="topsites_form_title_label"
+                placeholderId="topsites_form_title_placeholder"
+                intl={this.props.intl} />
+              <TopSiteFormInput onChange={this.onUrlChange}
+                shouldFocus={this.state.validationError && !this.validateUrl(this.state.url)}
+                value={this.state.url}
+                onClear={this.onClearUrlClick}
+                validationError={this.state.validationError && !this.validateUrl(this.state.url)}
+                titleId="topsites_form_url_label"
+                typeUrl={true}
+                placeholderId="topsites_form_url_placeholder"
+                errorMessageId="topsites_form_url_validation"
+                intl={this.props.intl} />
+              {this._renderCustomScreenshotInput()}
+            </div>
+            <TopSiteLink link={previewLink}
+              defaultStyle={requestFailed}
+              title={this.state.label} />
+          </div>
+        </div>
+        <section className="actions">
+          <button className="cancel" type="button" onClick={this.onCancelButtonClick}>
+            <FormattedMessage id="topsites_form_cancel_button" />
+          </button>
+          {previewMode ?
+            <button className="done preview" type="submit" onClick={this.onPreviewButtonClick}>
+              <FormattedMessage id="topsites_form_preview_button" />
+            </button> :
+            <button className="done" type="submit" onClick={this.onDoneButtonClick}>
+              <FormattedMessage id={showAsAdd ? "topsites_form_add_button" : "topsites_form_save_button"} />
+            </button>}
+        </section>
+      </form>
+    );
+  }
+}
+
+TopSiteForm.defaultProps = {
+  site: null,
+  index: -1
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/TopSites/TopSiteFormInput.jsx
@@ -0,0 +1,66 @@
+import {FormattedMessage} from "react-intl";
+import React from "react";
+
+export class TopSiteFormInput extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.state = {validationError: this.props.validationError};
+    this.onChange = this.onChange.bind(this);
+    this.onMount = this.onMount.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.shouldFocus && !this.props.shouldFocus) {
+      this.input.focus();
+    }
+    if (nextProps.validationError && !this.props.validationError) {
+      this.setState({validationError: true});
+    }
+    // If the component is in an error state but the value was cleared by the parent
+    if (this.state.validationError && !nextProps.value) {
+      this.setState({validationError: false});
+    }
+  }
+
+  onChange(ev) {
+    if (this.state.validationError) {
+      this.setState({validationError: false});
+    }
+    this.props.onChange(ev);
+  }
+
+  onMount(input) {
+    this.input = input;
+  }
+
+  render() {
+    const showClearButton = this.props.value && this.props.onClear;
+    const {typeUrl} = this.props;
+    const {validationError} = this.state;
+
+    return (<label><FormattedMessage id={this.props.titleId} />
+      <div className={`field ${typeUrl ? "url" : ""}${validationError ? " invalid" : ""}`}>
+        {this.props.loading ?
+          <div className="loading-container"><div className="loading-animation" /></div> :
+          showClearButton && <div className="icon icon-clear-input" onClick={this.props.onClear} />}
+        <input type="text"
+          value={this.props.value}
+          ref={this.onMount}
+          onChange={this.onChange}
+          placeholder={this.props.intl.formatMessage({id: this.props.placeholderId})}
+          autoFocus={this.props.shouldFocus}
+          disabled={this.props.loading} />
+        {validationError &&
+          <aside className="error-tooltip">
+            <FormattedMessage id={this.props.errorMessageId} />
+          </aside>}
+      </div>
+    </label>);
+  }
+}
+
+TopSiteFormInput.defaultProps = {
+  showClearButton: false,
+  value: "",
+  validationError: false
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/TopSites/TopSites.jsx
@@ -0,0 +1,143 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {MIN_CORNER_FAVICON_SIZE, MIN_RICH_FAVICON_SIZE, TOP_SITES_SOURCE} from "./TopSitesConstants";
+import {CollapsibleSection} from "content-src/components/CollapsibleSection/CollapsibleSection";
+import {ComponentPerfTimer} from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
+import {connect} from "react-redux";
+import {injectIntl} from "react-intl";
+import React from "react";
+import {TOP_SITES_MAX_SITES_PER_ROW} from "common/Reducers.jsm";
+import {TopSiteForm} from "./TopSiteForm";
+import {TopSiteList} from "./TopSite";
+
+function topSiteIconType(link) {
+  if (link.customScreenshotURL) {
+    return "custom_screenshot";
+  }
+  if (link.tippyTopIcon || link.faviconRef === "tippytop") {
+    return "tippytop";
+  }
+  if (link.faviconSize >= MIN_RICH_FAVICON_SIZE) {
+    return "rich_icon";
+  }
+  if (link.screenshot && link.faviconSize >= MIN_CORNER_FAVICON_SIZE) {
+    return "screenshot_with_icon";
+  }
+  if (link.screenshot) {
+    return "screenshot";
+  }
+  return "no_image";
+}
+
+/**
+ * Iterates through TopSites and counts types of images.
+ * @param acc Accumulator for reducer.
+ * @param topsite Entry in TopSites.
+ */
+function countTopSitesIconsTypes(topSites) {
+  const countTopSitesTypes = (acc, link) => {
+    acc[topSiteIconType(link)]++;
+    return acc;
+  };
+
+  return topSites.reduce(countTopSitesTypes, {
+    "custom_screenshot": 0,
+    "screenshot_with_icon": 0,
+    "screenshot": 0,
+    "tippytop": 0,
+    "rich_icon": 0,
+    "no_image": 0
+  });
+}
+
+export class _TopSites extends React.PureComponent {
+  constructor(props) {
+    super(props);
+    this.onFormClose = this.onFormClose.bind(this);
+  }
+
+  /**
+   * Dispatch session statistics about the quality of TopSites icons and pinned count.
+   */
+  _dispatchTopSitesStats() {
+    const topSites = this._getVisibleTopSites();
+    const topSitesIconsStats = countTopSitesIconsTypes(topSites);
+    const topSitesPinned = topSites.filter(site => !!site.isPinned).length;
+    // Dispatch telemetry event with the count of TopSites images types.
+    this.props.dispatch(ac.AlsoToMain({
+      type: at.SAVE_SESSION_PERF_DATA,
+      data: {topsites_icon_stats: topSitesIconsStats, topsites_pinned: topSitesPinned}
+    }));
+  }
+
+  /**
+   * Return the TopSites that are visible based on prefs and window width.
+   */
+  _getVisibleTopSites() {
+    // We hide 2 sites per row when not in the wide layout.
+    let sitesPerRow = TOP_SITES_MAX_SITES_PER_ROW;
+    // $break-point-widest = 1072px (from _variables.scss)
+    if (!global.matchMedia(`(min-width: 1072px)`).matches) {
+      sitesPerRow -= 2;
+    }
+    return this.props.TopSites.rows.slice(0, this.props.TopSitesRows * sitesPerRow);
+  }
+
+  componentDidUpdate() {
+    this._dispatchTopSitesStats();
+  }
+
+  componentDidMount() {
+    this._dispatchTopSitesStats();
+  }
+
+  onFormClose() {
+    this.props.dispatch(ac.UserEvent({
+      source: TOP_SITES_SOURCE,
+      event: "TOP_SITES_EDIT_CLOSE"
+    }));
+    this.props.dispatch({type: at.TOP_SITES_CANCEL_EDIT});
+  }
+
+  render() {
+    const {props} = this;
+    const {editForm} = props.TopSites;
+
+    return (<ComponentPerfTimer id="topsites" initialized={props.TopSites.initialized} dispatch={props.dispatch}>
+      <CollapsibleSection
+        className="top-sites"
+        icon="topsites"
+        id="topsites"
+        title={{id: "header_top_sites"}}
+        extraMenuOptions={["AddTopSite"]}
+        showPrefName="feeds.topsites"
+        eventSource={TOP_SITES_SOURCE}
+        collapsed={props.TopSites.pref ? props.TopSites.pref.collapsed : undefined}
+        isFirst={props.isFirst}
+        isLast={props.isLast}
+        dispatch={props.dispatch}>
+        <TopSiteList TopSites={props.TopSites} TopSitesRows={props.TopSitesRows} dispatch={props.dispatch} intl={props.intl} topSiteIconType={topSiteIconType} />
+        <div className="edit-topsites-wrapper">
+          {editForm &&
+            <div className="edit-topsites">
+              <div className="modal-overlay" onClick={this.onFormClose} />
+              <div className="modal">
+                <TopSiteForm
+                  site={props.TopSites.rows[editForm.index]}
+                  onClose={this.onFormClose}
+                  dispatch={this.props.dispatch}
+                  intl={this.props.intl}
+                  {...editForm} />
+              </div>
+            </div>
+          }
+        </div>
+      </CollapsibleSection>
+    </ComponentPerfTimer>);
+  }
+}
+
+export const TopSites = connect(state => ({
+  TopSites: state.TopSites,
+  Prefs: state.Prefs,
+  TopSitesRows: state.Prefs.values.topSitesRows
+}))(injectIntl(_TopSites));
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/TopSites/TopSitesConstants.js
@@ -0,0 +1,7 @@
+export const TOP_SITES_SOURCE = "TOP_SITES";
+export const TOP_SITES_CONTEXT_MENU_OPTIONS = ["CheckPinTopSite", "EditTopSite", "Separator",
+  "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl", "DeleteUrl"];
+// minimum size necessary to show a rich icon instead of a screenshot
+export const MIN_RICH_FAVICON_SIZE = 96;
+// minimum size necessary to show any icon in the top left corner with a screenshot
+export const MIN_CORNER_FAVICON_SIZE = 16;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/TopSites/_TopSites.scss
@@ -0,0 +1,485 @@
+$top-sites-size: $grid-unit;
+$top-sites-border-radius: 6px;
+$top-sites-title-height: 30px;
+$top-sites-vertical-space: 8px;
+$screenshot-size: cover;
+$rich-icon-size: 96px;
+$default-icon-wrapper-size: 42px;
+$default-icon-size: 32px;
+$default-icon-offset: 6px;
+$half-base-gutter: $base-gutter / 2;
+
+.top-sites {
+  // Take back the margin from the bottom row of vertical spacing as well as the
+  // extra whitespace below the title text as it's vertically centered.
+  margin-bottom: $section-spacing - ($top-sites-vertical-space + $top-sites-title-height / 3);
+}
+
+.top-sites-list {
+  list-style: none;
+  margin: 0 (-$half-base-gutter);
+  padding: 0;
+
+  // Two columns
+  @media (max-width: $break-point-small) {
+    :nth-child(2n+1) {
+      @include context-menu-open-middle;
+    }
+
+    :nth-child(2n) {
+      @include context-menu-open-left;
+    }
+  }
+
+  // Three columns
+  @media (min-width: $break-point-small) and (max-width: $break-point-medium) {
+    :nth-child(3n+2),
+    :nth-child(3n) {
+      @include context-menu-open-left;
+    }
+  }
+
+  // Four columns
+  @media (min-width: $break-point-medium) and (max-width: $break-point-large) {
+    :nth-child(4n) {
+      @include context-menu-open-left;
+    }
+  }
+  @media (min-width: $break-point-medium) and (max-width: $break-point-medium + $card-width) {
+    :nth-child(4n+3) {
+      @include context-menu-open-left;
+    }
+  }
+
+  // Six columns
+  @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) {
+    :nth-child(6n) {
+      @include context-menu-open-left;
+    }
+  }
+  @media (min-width: $break-point-large) and (max-width: $break-point-large + $card-width) {
+    :nth-child(6n+5) {
+      @include context-menu-open-left;
+    }
+  }
+
+  // Eight columns
+  @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) {
+    :nth-child(8n) {
+      @include context-menu-open-left;
+    }
+  }
+  @media (min-width: $break-point-widest) and (max-width: $break-point-widest + $card-width) {
+    :nth-child(8n+7) {
+      @include context-menu-open-left;
+    }
+  }
+
+  @media not all and (min-width: $break-point-widest) {
+    .hide-for-narrow {
+      display: none;
+    }
+  }
+
+  li {
+    margin: 0 0 $top-sites-vertical-space;
+  }
+
+  &:not(.dnd-active) {
+    .top-site-outer:-moz-any(.active, :focus, :hover) {
+      .tile {
+        @include fade-in;
+      }
+
+      @include context-menu-button-hover;
+    }
+  }
+}
+
+// container for drop zone
+.top-site-outer {
+  padding: 0 $half-base-gutter;
+  display: inline-block;
+
+  // container for context menu
+  .top-site-inner {
+    position: relative;
+
+    > a {
+      color: inherit;
+      display: block;
+      outline: none;
+
+      &:-moz-any(.active, :focus) {
+        .tile {
+          @include fade-in;
+        }
+      }
+    }
+  }
+
+  @include context-menu-button;
+
+  .tile { // sass-lint:disable-block property-sort-order
+    border-radius: $top-sites-border-radius;
+    box-shadow: inset $inner-box-shadow, var(--newtab-card-shadow);
+    height: $top-sites-size;
+    position: relative;
+    width: $top-sites-size;
+
+    // For letter fallback
+    align-items: center;
+    color: var(--newtab-text-secondary-color);
+    display: flex;
+    font-size: 32px;
+    font-weight: 200;
+    justify-content: center;
+    text-transform: uppercase;
+
+    &::before {
+      content: attr(data-fallback);
+    }
+  }
+
+  .screenshot {
+    background-color: $white;
+    background-position: top left;
+    background-size: $screenshot-size;
+    border-radius: $top-sites-border-radius;
+    box-shadow: inset $inner-box-shadow;
+    height: 100%;
+    left: 0;
+    opacity: 0;
+    position: absolute;
+    top: 0;
+    transition: opacity 1s;
+    width: 100%;
+
+    &.active {
+      opacity: 1;
+    }
+  }
+
+  // Some common styles for all icons (rich and default) in top sites
+  .top-site-icon {
+    background-color: var(--newtab-topsites-background-color);
+    background-position: center center;
+    background-repeat: no-repeat;
+    border-radius: $top-sites-border-radius;
+    box-shadow: var(--newtab-topsites-icon-shadow);
+    position: absolute;
+  }
+
+  .rich-icon {
+    background-size: cover;
+    height: 100%;
+    offset-inline-start: 0;
+    top: 0;
+    width: 100%;
+  }
+
+  .default-icon { // sass-lint:disable block property-sort-order
+    background-size: $default-icon-size;
+    bottom: -$default-icon-offset;
+    height: $default-icon-wrapper-size;
+    offset-inline-end: -$default-icon-offset;
+    width: $default-icon-wrapper-size;
+
+    // for corner letter fallback
+    align-items: center;
+    display: flex;
+    font-size: 20px;
+    justify-content: center;
+
+    &[data-fallback]::before {
+      content: attr(data-fallback);
+    }
+  }
+
+  .title {
+    color: var(--newtab-topsites-label-color);
+    font: message-box;
+    height: $top-sites-title-height;
+    line-height: $top-sites-title-height;
+    text-align: center;
+    width: $top-sites-size;
+    position: relative;
+
+    .icon {
+      fill: var(--newtab-icon-tertiary-color);
+      offset-inline-start: 0;
+      position: absolute;
+      top: 10px;
+    }
+
+    span {
+      height: $top-sites-title-height;
+      display: block;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    &.pinned {
+      span {
+        padding: 0 13px;
+      }
+    }
+  }
+
+  .edit-button {
+    background-image: url('#{$image-path}glyph-edit-16.svg');
+  }
+
+  &.placeholder {
+    .tile {
+      box-shadow: inset $inner-box-shadow;
+    }
+
+    .screenshot {
+      display: none;
+    }
+  }
+
+  &.dragged {
+    .tile {
+      background: $grey-20;
+      box-shadow: none;
+
+      *,
+      &::before {
+        display: none;
+      }
+    }
+
+    .title {
+      visibility: hidden;
+    }
+  }
+}
+
+.edit-topsites-wrapper {
+  .modal {
+    box-shadow: $shadow-secondary;
+    left: 0;
+    margin: 0 auto;
+    position: fixed;
+    right: 0;
+    top: 40px;
+    width: $wrapper-default-width;
+
+    @media (min-width: $break-point-small) {
+      width: $wrapper-max-width-small;
+    }
+
+    @media (min-width: $break-point-medium) {
+      width: $wrapper-max-width-medium;
+    }
+
+    @media (min-width: $break-point-large) {
+      width: $wrapper-max-width-large;
+    }
+  }
+}
+
+.topsite-form {
+  $form-width: 300px;
+  $form-spacing: 32px;
+
+  .form-input-container {
+    max-width: $form-width + 3 * $form-spacing + $rich-icon-size;
+    margin: 0 auto;
+    padding: $form-spacing;
+
+    .top-site-outer {
+      padding: 0;
+      margin: 24px 0 0;
+      margin-inline-start: $form-spacing;
+      pointer-events: none;
+    }
+
+    .section-title {
+      text-transform: none;
+      font-size: 16px;
+      margin: 0 0 16px;
+    }
+  }
+
+  .fields-and-preview {
+    display: flex;
+  }
+
+  label {
+    font-size: $section-title-font-size;
+  }
+
+  .form-wrapper {
+    width: 100%;
+
+    .field {
+      position: relative;
+
+      .icon-clear-input {
+        position: absolute;
+        transform: translateY(-50%);
+        top: 50%;
+        offset-inline-end: 8px;
+      }
+    }
+
+    .url {
+      input:dir(ltr) {
+        padding-right: 32px;
+      }
+
+      input:dir(rtl) {
+        padding-left: 32px;
+
+        &:not(:placeholder-shown) {
+          direction: ltr;
+          text-align: right;
+        }
+      }
+    }
+
+    .enable-custom-image-input {
+      display: inline-block;
+      font-size: 13px;
+      margin-top: 4px;
+      cursor: pointer;
+
+      &:hover {
+        text-decoration: underline;
+      }
+    }
+
+    .custom-image-input-container {
+      margin-top: 4px;
+
+      .loading-container {
+        width: 16px;
+        height: 16px;
+        overflow: hidden;
+        position: absolute;
+        transform: translateY(-50%);
+        top: 50%;
+        offset-inline-end: 8px;
+      }
+
+      // This animation is derived from Firefox's tab loading animation
+      // See https://searchfox.org/mozilla-central/rev/b29daa46443b30612415c35be0a3c9c13b9dc5f6/browser/themes/shared/tabs.inc.css#208-216
+      .loading-animation {
+        @keyframes tab-throbber-animation {
+          100% { transform: translateX(-960px); }
+        }
+
+        @keyframes tab-throbber-animation-rtl {
+          100% { transform: translateX(960px); }
+        }
+
+        width: 960px;
+        height: 16px;
+        -moz-context-properties: fill;
+        fill: $blue-50;
+        background-image: url('chrome://browser/skin/tabbrowser/loading.svg');
+        animation: tab-throbber-animation 1.05s steps(60) infinite;
+
+        &:dir(rtl) {
+          animation-name: tab-throbber-animation-rtl;
+        }
+      }
+    }
+
+    input {
+      &[type='text'] {
+        background-color: var(--newtab-textbox-background-color);
+        border: $input-border;
+        margin: 8px 0;
+        padding: 0 8px;
+        height: 32px;
+        width: 100%;
+        font-size: 15px;
+
+        &:focus {
+          border: $input-border-active;
+          box-shadow: var(--newtab-textbox-focus-boxshadow);
+        }
+
+        &[disabled] {
+          border: $input-border;
+          box-shadow: none;
+          opacity: 0.4;
+        }
+      }
+    }
+
+    .invalid {
+      input {
+        &[type='text'] {
+          border: $input-error-border;
+          box-shadow: $input-error-boxshadow;
+        }
+      }
+    }
+
+    .error-tooltip {
+      animation: fade-up-tt 450ms;
+      background: $red-60;
+      border-radius: 2px;
+      color: $white;
+      offset-inline-start: 3px;
+      padding: 5px 12px;
+      position: absolute;
+      top: 44px;
+      z-index: 1;
+
+      // tooltip caret
+      &::before {
+        background: $red-60;
+        bottom: -8px;
+        content: '.';
+        height: 16px;
+        offset-inline-start: 12px;
+        position: absolute;
+        text-indent: -999px;
+        top: -7px;
+        transform: rotate(45deg);
+        white-space: nowrap;
+        width: 16px;
+        z-index: -1;
+      }
+    }
+  }
+
+  .actions {
+    justify-content: flex-end;
+
+    button {
+      margin-inline-start: 10px;
+      margin-inline-end: 0;
+    }
+  }
+
+  @media (max-width: $break-point-small) {
+    .fields-and-preview {
+      flex-direction: column;
+
+      .top-site-outer {
+        margin-inline-start: 0;
+      }
+    }
+  }
+}
+
+//used for tooltips below form element
+@keyframes fade-up-tt {
+  0% {
+    opacity: 0;
+    transform: translateY(15px);
+  }
+
+  100% {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Topics/Topics.jsx
@@ -0,0 +1,25 @@
+import {FormattedMessage} from "react-intl";
+import React from "react";
+
+export class Topic extends React.PureComponent {
+  render() {
+    const {url, name} = this.props;
+    return (<li><a key={name} className="topic-link" href={url}>{name}</a></li>);
+  }
+}
+
+export class Topics extends React.PureComponent {
+  render() {
+    const {topics, read_more_endpoint} = this.props;
+    return (
+      <div className="topic">
+        <span><FormattedMessage id="pocket_read_more" /></span>
+        <ul>{topics && topics.map(t => <Topic key={t.name} url={t.url} name={t.name} />)}</ul>
+
+        {read_more_endpoint && <a className="topic-read-more" href={read_more_endpoint}>
+          <FormattedMessage id="pocket_read_even_more" />
+        </a>}
+      </div>
+    );
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/components/Topics/_Topics.scss
@@ -0,0 +1,77 @@
+.topic {
+  color: var(--newtab-section-navigation-text-color);
+  font-size: 12px;
+  line-height: 1.6;
+  margin-top: $topic-margin-top;
+
+  @media (min-width: $break-point-large) {
+    line-height: 16px;
+  }
+
+  ul {
+    margin: 0;
+    padding: 0;
+    @media (min-width: $break-point-large) {
+      display: inline;
+      padding-inline-start: 12px;
+    }
+  }
+
+
+  ul li {
+    display: inline-block;
+
+    &::after {
+      content: '•';
+      padding: 8px;
+    }
+
+    &:last-child::after {
+      content: none;
+    }
+  }
+
+  .topic-link {
+    color: var(--newtab-link-secondary-color);
+    font-weight: bold;
+  }
+
+  .topic-read-more {
+    color: var(--newtab-link-secondary-color);
+    font-weight: bold;
+
+    @media (min-width: $break-point-large) {
+      // This is floating to accomodate a very large number of topics and/or
+      // very long topic names due to l10n.
+      float: right;
+
+      &:dir(rtl) {
+        float: left;
+      }
+    }
+
+    &::after {
+      background: url('#{$image-path}topic-show-more-12.svg') no-repeat center center;
+      content: '';
+      -moz-context-properties: fill;
+      display: inline-block;
+      fill: var(--newtab-link-secondary-color);
+      height: 16px;
+      margin-inline-start: 5px;
+      vertical-align: top;
+      width: 12px;
+    }
+
+    &:dir(rtl)::after  {
+      transform: scaleX(-1);
+    }
+  }
+
+  // This is a clearfix to for the topics-read-more link which is floating and causes
+  // some jank when we set overflow:hidden for the animation.
+  &::after {
+    clear: both;
+    content: '';
+    display: table;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/lib/constants.js
@@ -0,0 +1,1 @@
+export const IS_NEWTAB = global.document && global.document.documentURI === "about:newtab";
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/lib/detect-user-session-start.js
@@ -0,0 +1,65 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {perfService as perfSvc} from "common/PerfService.jsm";
+
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+export class DetectUserSessionStart {
+  constructor(store, options = {}) {
+    this._store = store;
+    // Overrides for testing
+    this.document = options.document || global.document;
+    this._perfService = options.perfService || perfSvc;
+    this._onVisibilityChange = this._onVisibilityChange.bind(this);
+  }
+
+  /**
+   * sendEventOrAddListener - Notify immediately if the page is already visible,
+   *                    or else set up a listener for when visibility changes.
+   *                    This is needed for accurate session tracking for telemetry,
+   *                    because tabs are pre-loaded.
+   */
+  sendEventOrAddListener() {
+    if (this.document.visibilityState === VISIBLE) {
+      // If the document is already visible, to the user, send a notification
+      // immediately that a session has started.
+      this._sendEvent();
+    } else {
+      // If the document is not visible, listen for when it does become visible.
+      this.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+    }
+  }
+
+  /**
+   * _sendEvent - Sends a message to the main process to indicate the current
+   *              tab is now visible to the user, includes the
+   *              visibility_event_rcvd_ts time in ms from the UNIX epoch.
+   */
+  _sendEvent() {
+    this._perfService.mark("visibility_event_rcvd_ts");
+
+    try {
+      let visibility_event_rcvd_ts = this._perfService
+        .getMostRecentAbsMarkStartByName("visibility_event_rcvd_ts");
+
+      this._store.dispatch(ac.AlsoToMain({
+        type: at.SAVE_SESSION_PERF_DATA,
+        data: {visibility_event_rcvd_ts}
+      }));
+    } catch (ex) {
+      // If this failed, it's likely because the `privacy.resistFingerprinting`
+      // pref is true.  We should at least not blow up.
+    }
+  }
+
+  /**
+   * _onVisibilityChange - If the visibility has changed to visible, sends a notification
+   *                      and removes the event listener. This should only be called once per tab.
+   */
+  _onVisibilityChange() {
+    if (this.document.visibilityState === VISIBLE) {
+      this._sendEvent();
+      this.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/lib/init-store.js
@@ -0,0 +1,139 @@
+/* eslint-env mozilla/frame-script */
+
+import {actionCreators as ac, actionTypes as at, actionUtils as au} from "common/Actions.jsm";
+import {applyMiddleware, combineReducers, createStore} from "redux";
+
+export const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE";
+export const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain";
+export const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent";
+export const EARLY_QUEUED_ACTIONS = [at.SAVE_SESSION_PERF_DATA, at.PAGE_PRERENDERED];
+
+/**
+ * A higher-order function which returns a reducer that, on MERGE_STORE action,
+ * will return the action.data object merged into the previous state.
+ *
+ * For all other actions, it merely calls mainReducer.
+ *
+ * Because we want this to merge the entire state object, it's written as a
+ * higher order function which takes the main reducer (itself often a call to
+ * combineReducers) as a parameter.
+ *
+ * @param  {function} mainReducer reducer to call if action != MERGE_STORE_ACTION
+ * @return {function}             a reducer that, on MERGE_STORE_ACTION action,
+ *                                will return the action.data object merged
+ *                                into the previous state, and the result
+ *                                of calling mainReducer otherwise.
+ */
+function mergeStateReducer(mainReducer) {
+  return (prevState, action) => {
+    if (action.type === MERGE_STORE_ACTION) {
+      return {...prevState, ...action.data};
+    }
+
+    return mainReducer(prevState, action);
+  };
+}
+
+/**
+ * messageMiddleware - Middleware that looks for SentToMain type actions, and sends them if necessary
+ */
+const messageMiddleware = store => next => action => {
+  const skipLocal = action.meta && action.meta.skipLocal;
+  if (au.isSendToMain(action)) {
+    sendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
+  }
+  if (!skipLocal) {
+    next(action);
+  }
+};
+
+export const rehydrationMiddleware = store => next => action => {
+  if (store._didRehydrate) {
+    return next(action);
+  }
+
+  const isMergeStoreAction = action.type === MERGE_STORE_ACTION;
+  const isRehydrationRequest = action.type === at.NEW_TAB_STATE_REQUEST;
+
+  if (isRehydrationRequest) {
+    store._didRequestInitialState = true;
+    return next(action);
+  }
+
+  if (isMergeStoreAction) {
+    store._didRehydrate = true;
+    return next(action);
+  }
+
+  // If init happened after our request was made, we need to re-request
+  if (store._didRequestInitialState && action.type === at.INIT) {
+    return next(ac.AlsoToMain({type: at.NEW_TAB_STATE_REQUEST}));
+  }
+
+  if (au.isBroadcastToContent(action) || au.isSendToOneContent(action) || au.isSendToPreloaded(action)) {
+    // Note that actions received before didRehydrate will not be dispatched
+    // because this could negatively affect preloading and the the state
+    // will be replaced by rehydration anyway.
+    return null;
+  }
+
+  return next(action);
+};
+
+/**
+ * This middleware queues up all the EARLY_QUEUED_ACTIONS until it receives
+ * the first action from main. This is useful for those actions for main which
+ * require higher reliability, i.e. the action will not be lost in the case
+ * that it gets sent before the main is ready to receive it. Conversely, any
+ * actions allowed early are accepted to be ignorable or re-sendable.
+ */
+export const queueEarlyMessageMiddleware = store => next => action => {
+  if (store._receivedFromMain) {
+    next(action);
+  } else if (au.isFromMain(action)) {
+    next(action);
+    store._receivedFromMain = true;
+    // Sending out all the early actions as main is ready now
+    if (store._earlyActionQueue) {
+      store._earlyActionQueue.forEach(next);
+      store._earlyActionQueue = [];
+    }
+  } else if (EARLY_QUEUED_ACTIONS.includes(action.type)) {
+    store._earlyActionQueue = store._earlyActionQueue || [];
+    store._earlyActionQueue.push(action);
+  } else {
+    // Let any other type of action go through
+    next(action);
+  }
+};
+
+/**
+ * initStore - Create a store and listen for incoming actions
+ *
+ * @param  {object} reducers An object containing Redux reducers
+ * @param  {object} intialState (optional) The initial state of the store, if desired
+ * @return {object}          A redux store
+ */
+export function initStore(reducers, initialState) {
+  const store = createStore(
+    mergeStateReducer(combineReducers(reducers)),
+    initialState,
+    global.addMessageListener && applyMiddleware(rehydrationMiddleware, queueEarlyMessageMiddleware, messageMiddleware)
+  );
+
+  store._didRehydrate = false;
+  store._didRequestInitialState = false;
+
+  if (global.addMessageListener) {
+    global.addMessageListener(INCOMING_MESSAGE_NAME, msg => {
+      try {
+        store.dispatch(msg.data);
+      } catch (ex) {
+        console.error("Content msg:", msg, "Dispatch error: ", ex); // eslint-disable-line no-console
+        dump(`Content msg: ${JSON.stringify(msg)}\nDispatch error: ${ex}\n${ex.stack}`);
+      }
+    });
+  }
+
+  return store;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/lib/link-menu-options.js
@@ -0,0 +1,215 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+
+const _OpenInPrivateWindow = site => ({
+  id: "menu_action_open_private_window",
+  icon: "new-window-private",
+  action: ac.OnlyToMain({
+    type: at.OPEN_PRIVATE_WINDOW,
+    data: {url: site.url, referrer: site.referrer}
+  }),
+  userEvent: "OPEN_PRIVATE_WINDOW"
+});
+
+export const GetPlatformString = platform => {
+  switch (platform) {
+    case "win":
+      return "menu_action_show_file_windows";
+    case "macosx":
+      return "menu_action_show_file_mac_os";
+    case "linux":
+      return "menu_action_show_file_linux";
+    default:
+      return "menu_action_show_file_default";
+  }
+};
+
+/**
+ * List of functions that return items that can be included as menu options in a
+ * LinkMenu. All functions take the site as the first parameter, and optionally
+ * the index of the site.
+ */
+export const LinkMenuOptions = {
+  Separator: () => ({type: "separator"}),
+  EmptyItem: () => ({type: "empty"}),
+  RemoveBookmark: site => ({
+    id: "menu_action_remove_bookmark",
+    icon: "bookmark-added",
+    action: ac.AlsoToMain({
+      type: at.DELETE_BOOKMARK_BY_ID,
+      data: site.bookmarkGuid
+    }),
+    userEvent: "BOOKMARK_DELETE"
+  }),
+  AddBookmark: site => ({
+    id: "menu_action_bookmark",
+    icon: "bookmark-hollow",
+    action: ac.AlsoToMain({
+      type: at.BOOKMARK_URL,
+      data: {url: site.url, title: site.title, type: site.type}
+    }),
+    userEvent: "BOOKMARK_ADD"
+  }),
+  OpenInNewWindow: site => ({
+    id: "menu_action_open_new_window",
+    icon: "new-window",
+    action: ac.AlsoToMain({
+      type: at.OPEN_NEW_WINDOW,
+      data: {
+        referrer: site.referrer,
+        typedBonus: site.typedBonus,
+        url: site.url
+      }
+    }),
+    userEvent: "OPEN_NEW_WINDOW"
+  }),
+  BlockUrl: (site, index, eventSource) => ({
+    id: "menu_action_dismiss",
+    icon: "dismiss",
+    action: ac.AlsoToMain({
+      type: at.BLOCK_URL,
+      data: {url: site.url, pocket_id: site.pocket_id}
+    }),
+    impression: ac.ImpressionStats({
+      source: eventSource,
+      block: 0,
+      tiles: [{id: site.guid, pos: index}]
+    }),
+    userEvent: "BLOCK"
+  }),
+
+  // This is an option for web extentions which will result in remove items from
+  // memory and notify the web extenion, rather than using the built-in block list.
+  WebExtDismiss: (site, index, eventSource) => ({
+    id: "menu_action_webext_dismiss",
+    string_id: "menu_action_dismiss",
+    icon: "dismiss",
+    action: ac.WebExtEvent(at.WEBEXT_DISMISS, {
+      source: eventSource,
+      url: site.url,
+      action_position: index
+    })
+  }),
+  DeleteUrl: (site, index, eventSource, isEnabled, siteInfo) => ({
+    id: "menu_action_delete",
+    icon: "delete",
+    action: {
+      type: at.DIALOG_OPEN,
+      data: {
+        onConfirm: [
+          ac.AlsoToMain({type: at.DELETE_HISTORY_URL, data: {url: site.url, pocket_id: site.pocket_id, forceBlock: site.bookmarkGuid}}),
+          ac.UserEvent(Object.assign({event: "DELETE", source: eventSource, action_position: index}, siteInfo))
+        ],
+        eventSource,
+        body_string_id: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"],
+        confirm_button_string_id: "menu_action_delete",
+        cancel_button_string_id: "topsites_form_cancel_button",
+        icon: "modal-delete"
+      }
+    },
+    userEvent: "DIALOG_OPEN"
+  }),
+  ShowFile: (site, index, eventSource, isEnabled, siteInfo, platform) => ({
+    id: GetPlatformString(platform),
+    icon: "search",
+    action: ac.OnlyToMain({
+      type: at.SHOW_DOWNLOAD_FILE,
+      data: {url: site.url}
+    })
+  }),
+  OpenFile: site => ({
+    id: "menu_action_open_file",
+    icon: "open-file",
+    action: ac.OnlyToMain({
+      type: at.OPEN_DOWNLOAD_FILE,
+      data: {url: site.url}
+    })
+  }),
+  CopyDownloadLink: site => ({
+    id: "menu_action_copy_download_link",
+    icon: "copy",
+    action: ac.OnlyToMain({
+      type: at.COPY_DOWNLOAD_LINK,
+      data: {url: site.url}
+    })
+  }),
+  GoToDownloadPage: site => ({
+    id: "menu_action_go_to_download_page",
+    icon: "download",
+    action: ac.OnlyToMain({
+      type: at.OPEN_LINK,
+      data: {url: site.referrer}
+    }),
+    disabled: !site.referrer
+  }),
+  RemoveDownload: site => ({
+    id: "menu_action_remove_download",
+    icon: "delete",
+    action: ac.OnlyToMain({
+      type: at.REMOVE_DOWNLOAD_FILE,
+      data: {url: site.url}
+    })
+  }),
+  PinTopSite: (site, index) => ({
+    id: "menu_action_pin",
+    icon: "pin",
+    action: ac.AlsoToMain({
+      type: at.TOP_SITES_PIN,
+      data: {site: {url: site.url}, index}
+    }),
+    userEvent: "PIN"
+  }),
+  UnpinTopSite: site => ({
+    id: "menu_action_unpin",
+    icon: "unpin",
+    action: ac.AlsoToMain({
+      type: at.TOP_SITES_UNPIN,
+      data: {site: {url: site.url}}
+    }),
+    userEvent: "UNPIN"
+  }),
+  SaveToPocket: (site, index, eventSource) => ({
+    id: "menu_action_save_to_pocket",
+    icon: "pocket",
+    action: ac.AlsoToMain({
+      type: at.SAVE_TO_POCKET,
+      data: {site: {url: site.url, title: site.title}}
+    }),
+    impression: ac.ImpressionStats({
+      source: eventSource,
+      pocket: 0,
+      tiles: [{id: site.guid, pos: index}]
+    }),
+    userEvent: "SAVE_TO_POCKET"
+  }),
+  DeleteFromPocket: site => ({
+    id: "menu_action_delete_pocket",
+    icon: "delete",
+    action: ac.AlsoToMain({
+      type: at.DELETE_FROM_POCKET,
+      data: {pocket_id: site.pocket_id}
+    }),
+    userEvent: "DELETE_FROM_POCKET"
+  }),
+  ArchiveFromPocket: site => ({
+    id: "menu_action_archive_pocket",
+    icon: "check",
+    action: ac.AlsoToMain({
+      type: at.ARCHIVE_FROM_POCKET,
+      data: {pocket_id: site.pocket_id}
+    }),
+    userEvent: "ARCHIVE_FROM_POCKET"
+  }),
+  EditTopSite: (site, index) => ({
+    id: "edit_topsites_button_text",
+    icon: "edit",
+    action: {
+      type: at.TOP_SITES_EDIT,
+      data: {index}
+    }
+  }),
+  CheckBookmark: site => (site.bookmarkGuid ? LinkMenuOptions.RemoveBookmark(site) : LinkMenuOptions.AddBookmark(site)),
+  CheckPinTopSite: (site, index) => (site.isPinned ? LinkMenuOptions.UnpinTopSite(site) : LinkMenuOptions.PinTopSite(site, index)),
+  CheckSavedToPocket: (site, index) => (site.pocket_id ? LinkMenuOptions.DeleteFromPocket(site) : LinkMenuOptions.SaveToPocket(site, index)),
+  CheckBookmarkOrArchive: site => (site.pocket_id ? LinkMenuOptions.ArchiveFromPocket(site) : LinkMenuOptions.CheckBookmark(site)),
+  OpenInPrivateWindow: (site, index, eventSource, isEnabled) => (isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem())
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/lib/section-menu-options.js
@@ -0,0 +1,74 @@
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+
+/**
+ * List of functions that return items that can be included as menu options in a
+ * SectionMenu. All functions take the section as the only parameter.
+ */
+export const SectionMenuOptions = {
+  Separator: () => ({type: "separator"}),
+  MoveUp: section => ({
+    id: "section_menu_action_move_up",
+    icon: "arrowhead-up",
+    action: ac.OnlyToMain({
+      type: at.SECTION_MOVE,
+      data: {id: section.id, direction: -1}
+    }),
+    userEvent: "MENU_MOVE_UP",
+    disabled: !!section.isFirst
+  }),
+  MoveDown: section => ({
+    id: "section_menu_action_move_down",
+    icon: "arrowhead-down",
+    action: ac.OnlyToMain({
+      type: at.SECTION_MOVE,
+      data: {id: section.id, direction: +1}
+    }),
+    userEvent: "MENU_MOVE_DOWN",
+    disabled: !!section.isLast
+  }),
+  RemoveSection: section => ({
+    id: "section_menu_action_remove_section",
+    icon: "dismiss",
+    action: ac.SetPref(section.showPrefName, false),
+    userEvent: "MENU_REMOVE"
+  }),
+  CollapseSection: section => ({
+    id: "section_menu_action_collapse_section",
+    icon: "minimize",
+    action: ac.OnlyToMain({type: at.UPDATE_SECTION_PREFS, data: {id: section.id, value: {collapsed: true}}}),
+    userEvent: "MENU_COLLAPSE"
+  }),
+  ExpandSection: section => ({
+    id: "section_menu_action_expand_section",
+    icon: "maximize",
+    action: ac.OnlyToMain({type: at.UPDATE_SECTION_PREFS, data: {id: section.id, value: {collapsed: false}}}),
+    userEvent: "MENU_EXPAND"
+  }),
+  ManageSection: section => ({
+    id: "section_menu_action_manage_section",
+    icon: "settings",
+    action: ac.OnlyToMain({type: at.SETTINGS_OPEN}),
+    userEvent: "MENU_MANAGE"
+  }),
+  ManageWebExtension: section => ({
+    id: "section_menu_action_manage_webext",
+    icon: "settings",
+    action: ac.OnlyToMain({type: at.OPEN_WEBEXT_SETTINGS, data: section.id})
+  }),
+  AddTopSite: section => ({
+    id: "section_menu_action_add_topsite",
+    icon: "add",
+    action: {type: at.TOP_SITES_EDIT, data: {index: -1}},
+    userEvent: "MENU_ADD_TOPSITE"
+  }),
+  PrivacyNotice: section => ({
+    id: "section_menu_action_privacy_notice",
+    icon: "info",
+    action: ac.OnlyToMain({
+      type: at.OPEN_LINK,
+      data: {url: section.privacyNoticeURL}
+    }),
+    userEvent: "MENU_PRIVACY_NOTICE"
+  }),
+  CheckCollapsed: section => (section.collapsed ? SectionMenuOptions.ExpandSection(section) : SectionMenuOptions.CollapseSection(section))
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/lib/snippets.js
@@ -0,0 +1,427 @@
+const DATABASE_NAME = "snippets_db";
+const DATABASE_VERSION = 1;
+const SNIPPETS_OBJECTSTORE_NAME = "snippets";
+export const SNIPPETS_UPDATE_INTERVAL_MS = 14400000; // 4 hours.
+
+const SNIPPETS_ENABLED_EVENT = "Snippets:Enabled";
+const SNIPPETS_DISABLED_EVENT = "Snippets:Disabled";
+
+import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
+import {ASRouterContent} from "content-src/asrouter/asrouter-content";
+
+/**
+ * SnippetsMap - A utility for cacheing values related to the snippet. It has
+ *               the same interface as a Map, but is optionally backed by
+ *               indexedDB for persistent storage.
+ *               Call .connect() to open a database connection and restore any
+ *               previously cached data, if necessary.
+ *
+ */
+export class SnippetsMap extends Map {
+  constructor(dispatch) {
+    super();
+    this._db = null;
+    this._dispatch = dispatch;
+  }
+
+  set(key, value) {
+    super.set(key, value);
+    return this._dbTransaction(db => db.put(value, key));
+  }
+
+  delete(key) {
+    super.delete(key);
+    return this._dbTransaction(db => db.delete(key));
+  }
+
+  clear() {
+    super.clear();
+    this._dispatch(ac.OnlyToMain({type: at.SNIPPETS_BLOCKLIST_CLEARED}));
+    return this._dbTransaction(db => db.clear());
+  }
+
+  get blockList() {
+    return this.get("blockList") || [];
+  }
+
+  /**
+   * blockSnippetById - Blocks a snippet given an id
+   *
+   * @param  {str|int} id   The id of the snippet
+   * @return {Promise}      Resolves when the id has been written to indexedDB,
+   *                        or immediately if the snippetMap is not connected
+   */
+  async blockSnippetById(id) {
+    if (!id) {
+      return;
+    }
+    const {blockList} = this;
+    if (!blockList.includes(id)) {
+      blockList.push(id);
+      this._dispatch(ac.AlsoToMain({type: at.SNIPPETS_BLOCKLIST_UPDATED, data: id}));
+      await this.set("blockList", blockList);
+    }
+  }
+
+  disableOnboarding() {
+    this._dispatch(ac.AlsoToMain({type: at.DISABLE_ONBOARDING}));
+  }
+
+  showFirefoxAccounts() {
+    this._dispatch(ac.AlsoToMain({type: at.SHOW_FIREFOX_ACCOUNTS}));
+  }
+
+  getTotalBookmarksCount() {
+    return new Promise(resolve => {
+      this._dispatch(ac.OnlyToMain({type: at.TOTAL_BOOKMARKS_REQUEST}));
+      global.addMessageListener("ActivityStream:MainToContent", function onMessage({data: action}) {
+        if (action.type === at.TOTAL_BOOKMARKS_RESPONSE) {
+          resolve(action.data);
+          global.removeMessageListener("ActivityStream:MainToContent", onMessage);
+        }
+      });
+    });
+  }
+
+  getAddonsInfo() {
+    return new Promise(resolve => {
+      this._dispatch(ac.OnlyToMain({type: at.ADDONS_INFO_REQUEST}));
+      global.addMessageListener("ActivityStream:MainToContent", function onMessage({data: action}) {
+        if (action.type === at.ADDONS_INFO_RESPONSE) {
+          resolve(action.data);
+          global.removeMessageListener("ActivityStream:MainToContent", onMessage);
+        }
+      });
+    });
+  }
+
+  /**
+   * connect - Attaches an indexedDB back-end to the Map so that any set values
+   *           are also cached in a store. It also restores any existing values
+   *           that are already stored in the indexedDB store.
+   *
+   * @return {type}  description
+   */
+  async connect() {
+    // Open the connection
+    const db = await this._openDB();
+
+    // Restore any existing values
+    await this._restoreFromDb(db);
+
+    // Attach a reference to the db
+    this._db = db;
+  }
+
+  /**
+   * _dbTransaction - Returns a db transaction wrapped with the given modifier
+   *                  function as a Promise. If the db has not been connected,
+   *                  it resolves immediately.
+   *
+   * @param  {func} modifier A function to call with the transaction
+   * @return {obj}           A Promise that resolves when the transaction has
+   *                         completed or errored
+   */
+  _dbTransaction(modifier) {
+    if (!this._db) {
+      return Promise.resolve();
+    }
+    return new Promise((resolve, reject) => {
+      const transaction = modifier(
+        this._db
+          .transaction(SNIPPETS_OBJECTSTORE_NAME, "readwrite")
+          .objectStore(SNIPPETS_OBJECTSTORE_NAME)
+      );
+      transaction.onsuccess = event => resolve();
+
+      /* istanbul ignore next */
+      transaction.onerror = event => reject(transaction.error);
+    });
+  }
+
+  _openDB() {
+    return new Promise((resolve, reject) => {
+      const openRequest = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
+
+      /* istanbul ignore next */
+      openRequest.onerror = event => {
+        // Try to delete the old database so that we can start this process over
+        // next time.
+        indexedDB.deleteDatabase(DATABASE_NAME);
+        reject(event);
+      };
+
+      openRequest.onupgradeneeded = event => {
+        const db = event.target.result;
+        if (!db.objectStoreNames.contains(SNIPPETS_OBJECTSTORE_NAME)) {
+          db.createObjectStore(SNIPPETS_OBJECTSTORE_NAME);
+        }
+      };
+
+      openRequest.onsuccess = event => {
+        let db = event.target.result;
+
+        /* istanbul ignore next */
+        db.onerror = err => console.error(err); // eslint-disable-line no-console
+        /* istanbul ignore next */
+        db.onversionchange = versionChangeEvent => versionChangeEvent.target.close();
+
+        resolve(db);
+      };
+    });
+  }
+
+  _restoreFromDb(db) {
+    return new Promise((resolve, reject) => {
+      let cursorRequest;
+      try {
+        cursorRequest = db.transaction(SNIPPETS_OBJECTSTORE_NAME)
+          .objectStore(SNIPPETS_OBJECTSTORE_NAME).openCursor();
+      } catch (err) {
+        // istanbul ignore next
+        reject(err);
+        // istanbul ignore next
+        return;
+      }
+
+      /* istanbul ignore next */
+      cursorRequest.onerror = event => reject(event);
+
+      cursorRequest.onsuccess = event => {
+        let cursor = event.target.result;
+        // Populate the cache from the persistent storage.
+        if (cursor) {
+          if (cursor.value !== "blockList") {
+            this.set(cursor.key, cursor.value);
+          }
+          cursor.continue();
+        } else {
+          // We are done.
+          resolve();
+        }
+      };
+    });
+  }
+}
+
+/**
+ * SnippetsProvider - Initializes a SnippetsMap and loads snippets from a
+ *                    remote location, or else default snippets if the remote
+ *                    snippets cannot be retrieved.
+ */
+export class SnippetsProvider {
+  constructor(dispatch) {
+    // Initialize the Snippets Map and attaches it to a global so that
+    // the snippet payload can interact with it.
+    global.gSnippetsMap = new SnippetsMap(dispatch);
+    this._onAction = this._onAction.bind(this);
+  }
+
+  get snippetsMap() {
+    return global.gSnippetsMap;
+  }
+
+  async _refreshSnippets() {
+    // Check if the cached version of of the snippets in snippetsMap. If it's too
+    // old, blow away the entire snippetsMap.
+    const cachedVersion = this.snippetsMap.get("snippets-cached-version");
+
+    if (cachedVersion !== this.appData.version) {
+      this.snippetsMap.clear();
+    }
+
+    // Has enough time passed for us to require an update?
+    const lastUpdate = this.snippetsMap.get("snippets-last-update");
+    const needsUpdate = !(lastUpdate >= 0) || Date.now() - lastUpdate > SNIPPETS_UPDATE_INTERVAL_MS;
+
+    if (needsUpdate && this.appData.snippetsURL) {
+      this.snippetsMap.set("snippets-last-update", Date.now());
+      try {
+        const response = await fetch(this.appData.snippetsURL);
+        if (response.status === 200) {
+          const payload = await response.text();
+
+          this.snippetsMap.set("snippets", payload);
+          this.snippetsMap.set("snippets-cached-version", this.appData.version);
+        }
+      } catch (e) {
+        console.error(e); // eslint-disable-line no-console
+      }
+    }
+  }
+
+  _noSnippetFallback() {
+    // TODO
+  }
+
+  _forceOnboardingVisibility(shouldBeVisible) {
+    const onboardingEl = document.getElementById("onboarding-notification-bar");
+
+    if (onboardingEl) {
+      onboardingEl.style.display = shouldBeVisible ? "" : "none";
+    }
+  }
+
+  _showRemoteSnippets() {
+    const snippetsEl = document.getElementById(this.elementId);
+    const payload = this.snippetsMap.get("snippets");
+
+    if (!snippetsEl) {
+      throw new Error(`No element was found with id '${this.elementId}'.`);
+    }
+
+    // This could happen if fetching failed
+    if (!payload) {
+      throw new Error("No remote snippets were found in gSnippetsMap.");
+    }
+
+    if (typeof payload !== "string") {
+      throw new Error("Snippet payload was incorrectly formatted");
+    }
+
+    // Note that injecting snippets can throw if they're invalid XML.
+    // eslint-disable-next-line no-unsanitized/property
+    snippetsEl.innerHTML = payload;
+
+    // Scripts injected by innerHTML are inactive, so we have to relocate them
+    // through DOM manipulation to activate their contents.
+    for (const scriptEl of snippetsEl.getElementsByTagName("script")) {
+      const relocatedScript = document.createElement("script");
+      relocatedScript.text = scriptEl.text;
+      scriptEl.parentNode.replaceChild(relocatedScript, scriptEl);
+    }
+  }
+
+  _onAction(msg) {
+    if (msg.data.type === at.SNIPPET_BLOCKED) {
+      if (!this.snippetsMap.blockList.includes(msg.data.data)) {
+        this.snippetsMap.set("blockList", this.snippetsMap.blockList.concat(msg.data.data));
+        document.getElementById("snippets-container").style.display = "none";
+      }
+    }
+  }
+
+  /**
+   * init - Fetch the snippet payload and show snippets
+   *
+   * @param  {obj} options
+   * @param  {str} options.appData.snippetsURL  The URL from which we fetch snippets
+   * @param  {int} options.appData.version  The current snippets version
+   * @param  {str} options.elementId  The id of the element in which to inject snippets
+   * @param  {bool} options.connect  Should gSnippetsMap connect to indexedDB?
+   */
+  async init(options) {
+    Object.assign(this, {
+      appData: {},
+      elementId: "snippets",
+      connect: true
+    }, options);
+
+    // Add listener so we know when snippets are blocked on other pages
+    if (global.addMessageListener) {
+      global.addMessageListener("ActivityStream:MainToContent", this._onAction);
+    }
+
+    // TODO: Requires enabling indexedDB on newtab
+    // Restore the snippets map from indexedDB
+    if (this.connect) {
+      try {
+        await this.snippetsMap.connect();
+      } catch (e) {
+        console.error(e); // eslint-disable-line no-console
+      }
+    }
+
+    // Cache app data values so they can be accessible from gSnippetsMap
+    for (const key of Object.keys(this.appData)) {
+      if (key === "blockList") {
+        this.snippetsMap.set("blockList", this.appData[key]);
+      } else {
+        this.snippetsMap.set(`appData.${key}`, this.appData[key]);
+      }
+    }
+
+    // Refresh snippets, if enough time has passed.
+    await this._refreshSnippets();
+
+    // Try showing remote snippets, falling back to defaults if necessary.
+    try {
+      this._showRemoteSnippets();
+    } catch (e) {
+      this._noSnippetFallback(e);
+    }
+
+    window.dispatchEvent(new Event(SNIPPETS_ENABLED_EVENT));
+
+    this._forceOnboardingVisibility(true);
+    this.initialized = true;
+  }
+
+  uninit() {
+    window.dispatchEvent(new Event(SNIPPETS_DISABLED_EVENT));
+    this._forceOnboardingVisibility(false);
+    if (global.removeMessageListener) {
+      global.removeMessageListener("ActivityStream:MainToContent", this._onAction);
+    }
+    this.initialized = false;
+  }
+}
+
+/**
+ * addSnippetsSubscriber - Creates a SnippetsProvider that Initializes
+ *                         when the store has received the appropriate
+ *                         Snippet data.
+ *
+ * @param  {obj} store   The redux store
+ * @return {obj}         Returns the snippets instance, asrouterContent instance and unsubscribe function
+ */
+export function addSnippetsSubscriber(store) {
+  const snippets = new SnippetsProvider(store.dispatch);
+  const asrouterContent = new ASRouterContent();
+
+  let initializing = false;
+
+  store.subscribe(async () => {
+    const state = store.getState();
+    // state.Prefs.values["feeds.snippets"]:  Should snippets be shown?
+    // state.Snippets.initialized             Is the snippets data initialized?
+    // snippets.initialized:                  Is SnippetsProvider currently initialised?
+    if (state.Prefs.values["feeds.snippets"] &&
+      // If the message center experiment is enabled, don't show snippets
+      !state.Prefs.values.asrouterExperimentEnabled &&
+      !state.Prefs.values.disableSnippets &&
+      state.Snippets.initialized &&
+      !snippets.initialized &&
+      // Don't call init multiple times
+      !initializing &&
+      location.href !== "about:welcome"
+    ) {
+      initializing = true;
+      await snippets.init({appData: state.Snippets});
+      initializing = false;
+    } else if (
+      (state.Prefs.values["feeds.snippets"] === false ||
+        state.Prefs.values.disableSnippets === true) &&
+      snippets.initialized
+    ) {
+      snippets.uninit();
+    }
+
+    // Turn on AS Router snippets if the experiment is enabled and the snippets pref is on;
+    // otherwise, turn it off.
+    if (
+      state.Prefs.values.asrouterExperimentEnabled &&
+      state.Prefs.values["feeds.snippets"] &&
+      !asrouterContent.initialized) {
+      asrouterContent.init();
+    } else if (
+      (!state.Prefs.values.asrouterExperimentEnabled || !state.Prefs.values["feeds.snippets"]) &&
+      asrouterContent.initialized
+    ) {
+      asrouterContent.uninit();
+    }
+  });
+
+  // These values are returned for testing purposes
+  return {snippets, asrouterContent};
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/styles/_activity-stream.scss
@@ -0,0 +1,151 @@
+@import './normalize';
+@import './variables';
+@import './theme';
+@import './icons';
+
+html {
+  height: 100%;
+}
+
+body,
+#root { // sass-lint:disable-line no-ids
+  min-height: 100vh;
+}
+
+body {
+  background-color: var(--newtab-background-color);
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Ubuntu', 'Helvetica Neue', sans-serif;
+  font-size: 16px;
+  overflow-y: scroll;
+}
+
+h1,
+h2 {
+  font-weight: normal;
+}
+
+a {
+  text-decoration: none;
+}
+
+// For screen readers
+.sr-only {
+  border: 0;
+  clip: rect(0, 0, 0, 0);
+  height: 1px;
+  margin: -1px;
+  overflow: hidden;
+  padding: 0;
+  position: absolute;
+  width: 1px;
+}
+
+.inner-border {
+  border: $border-secondary;
+  border-radius: $border-radius;
+  height: 100%;
+  left: 0;
+  pointer-events: none;
+  position: absolute;
+  top: 0;
+  width: 100%;
+  z-index: 100;
+}
+
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
+}
+
+.show-on-init {
+  opacity: 0;
+  transition: opacity 0.2s ease-in;
+
+  &.on {
+    animation: fadeIn 0.2s;
+    opacity: 1;
+  }
+}
+
+.actions {
+  border-top: $border-secondary;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  justify-content: flex-start;
+  margin: 0;
+  padding: 15px 25px 0;
+}
+
+// Default button (grey)
+.button,
+.actions button {
+  background-color: var(--newtab-button-secondary-color);
+  border: $border-primary;
+  border-radius: 4px;
+  color: inherit;
+  cursor: pointer;
+  margin-bottom: 15px;
+  padding: 10px 30px;
+  white-space: nowrap;
+
+  &:hover:not(.dismiss) {
+    box-shadow: $shadow-primary;
+    transition: box-shadow 150ms;
+  }
+
+  &.dismiss {
+    background-color: transparent;
+    border: 0;
+    padding: 0;
+    text-decoration: underline;
+  }
+
+  // Blue button
+  &.primary,
+  &.done {
+    background-color: var(--newtab-button-primary-color);
+    border: solid 1px var(--newtab-button-primary-color);
+    color: $white;
+    margin-inline-start: auto;
+  }
+}
+
+input {
+  &[type='text'],
+  &[type='search'] {
+    border-radius: $border-radius;
+  }
+}
+
+// Make sure snippets show up above other UI elements
+#snippets-container { // sass-lint:disable-line no-ids
+  z-index: 1;
+}
+
+// Components
+@import '../components/Base/Base';
+@import '../components/ErrorBoundary/ErrorBoundary';
+@import '../components/TopSites/TopSites';
+@import '../components/Sections/Sections';
+@import '../components/StartupOverlay/StartupOverlay';
+@import '../components/Topics/Topics';
+@import '../components/Search/Search';
+@import '../components/ContextMenu/ContextMenu';
+@import '../components/ConfirmDialog/ConfirmDialog';
+@import '../components/Card/Card';
+@import '../components/ManualMigration/ManualMigration';
+@import '../components/CollapsibleSection/CollapsibleSection';
+@import '../components/ASRouterAdmin/ASRouterAdmin';
+
+// AS Router
+@import '../asrouter/components/Button/Button';
+@import '../asrouter/components/SnippetBase/SnippetBase';
+@import '../asrouter/components/ModalOverlay/ModalOverlay';
+@import '../asrouter/templates/SimpleSnippet/SimpleSnippet';
+@import '../asrouter/templates/OnboardingMessage/OnboardingMessage';
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/styles/_icons.scss
@@ -0,0 +1,180 @@
+.icon {
+  background-position: center center;
+  background-repeat: no-repeat;
+  background-size: $icon-size;
+  -moz-context-properties: fill;
+  display: inline-block;
+  fill: var(--newtab-icon-primary-color);
+  height: $icon-size;
+  vertical-align: middle;
+  width: $icon-size;
+
+  &.icon-spacer {
+    margin-inline-end: 8px;
+  }
+
+  &.icon-small-spacer {
+    margin-inline-end: 6px;
+  }
+
+  &.icon-bookmark-added {
+    background-image: url('chrome://browser/skin/bookmark.svg');
+  }
+
+  &.icon-bookmark-hollow {
+    background-image: url('chrome://browser/skin/bookmark-hollow.svg');
+  }
+
+  &.icon-clear-input {
+    fill: var(--newtab-icon-secondary-color);
+    background-image: url('#{$image-path}glyph-cancel-16.svg');
+  }
+
+  &.icon-delete {
+    background-image: url('#{$image-path}glyph-delete-16.svg');
+  }
+
+  &.icon-search {
+    background-image: url('chrome://browser/skin/search-glass.svg');
+  }
+
+  &.icon-modal-delete {
+    flex-shrink: 0;
+    background-image: url('#{$image-path}glyph-modal-delete-32.svg');
+    background-size: $larger-icon-size;
+    height: $larger-icon-size;
+    width: $larger-icon-size;
+  }
+
+  &.icon-dismiss {
+    background-image: url('#{$image-path}glyph-dismiss-16.svg');
+  }
+
+  &.icon-info {
+    background-image: url('#{$image-path}glyph-info-16.svg');
+  }
+
+  &.icon-import {
+    background-image: url('#{$image-path}glyph-import-16.svg');
+  }
+
+  &.icon-new-window {
+    @include flip-icon;
+    background-image: url('#{$image-path}glyph-newWindow-16.svg');
+  }
+
+  &.icon-new-window-private {
+    background-image: url('chrome://browser/skin/privateBrowsing.svg');
+  }
+
+  &.icon-settings {
+    background-image: url('chrome://browser/skin/settings.svg');
+  }
+
+  &.icon-pin {
+    @include flip-icon;
+    background-image: url('#{$image-path}glyph-pin-16.svg');
+  }
+
+  &.icon-unpin {
+    @include flip-icon;
+    background-image: url('#{$image-path}glyph-unpin-16.svg');
+  }
+
+  &.icon-edit {
+    background-image: url('#{$image-path}glyph-edit-16.svg');
+  }
+
+  &.icon-pocket {
+    background-image: url('#{$image-path}glyph-pocket-16.svg');
+  }
+
+  &.icon-history-item {
+    background-image: url('chrome://browser/skin/history.svg');
+  }
+
+  &.icon-trending {
+    background-image: url('#{$image-path}glyph-trending-16.svg');
+    transform: translateY(2px); // trending bolt is visually top heavy
+  }
+
+  &.icon-now {
+    background-image: url('chrome://browser/skin/history.svg');
+  }
+
+  &.icon-topsites {
+    background-image: url('#{$image-path}glyph-topsites-16.svg');
+  }
+
+  &.icon-pin-small {
+    @include flip-icon;
+    background-image: url('#{$image-path}glyph-pin-12.svg');
+    background-size: $smaller-icon-size;
+    height: $smaller-icon-size;
+    width: $smaller-icon-size;
+  }
+
+  &.icon-check {
+    background-image: url('chrome://browser/skin/check.svg');
+  }
+
+  &.icon-download {
+    background-image: url('chrome://browser/skin/downloads/download-icons.svg#arrow-with-bar');
+  }
+
+  &.icon-copy {
+    background-image: url('chrome://browser/skin/edit-copy.svg');
+  }
+
+  &.icon-open-file {
+    background-image: url('#{$image-path}glyph-open-file-16.svg');
+  }
+
+  &.icon-webextension {
+    background-image: url('#{$image-path}glyph-webextension-16.svg');
+  }
+
+  &.icon-highlights {
+    background-image: url('#{$image-path}glyph-highlights-16.svg');
+  }
+
+  &.icon-arrowhead-down {
+    background-image: url('#{$image-path}glyph-arrowhead-down-16.svg');
+  }
+
+  &.icon-arrowhead-down-small {
+    background-image: url('#{$image-path}glyph-arrowhead-down-12.svg');
+    background-size: $smaller-icon-size;
+    height: $smaller-icon-size;
+    width: $smaller-icon-size;
+  }
+
+  &.icon-arrowhead-forward-small {
+    background-image: url('#{$image-path}glyph-arrowhead-down-12.svg');
+    background-size: $smaller-icon-size;
+    height: $smaller-icon-size;
+    transform: rotate(-90deg);
+    width: $smaller-icon-size;
+
+    &:dir(rtl) {
+      transform: rotate(90deg);
+    }
+  }
+
+  &.icon-arrowhead-up {
+    background-image: url('#{$image-path}glyph-arrowhead-down-16.svg');
+    transform: rotate(180deg);
+  }
+
+  &.icon-add {
+    background-image: url('#{$image-path}glyph-add-16.svg');
+  }
+
+  &.icon-minimize {
+    background-image: url('#{$image-path}glyph-minimize-16.svg');
+  }
+
+  &.icon-maximize {
+    background-image: url('#{$image-path}glyph-maximize-16.svg');
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/styles/_normalize.scss
@@ -0,0 +1,29 @@
+html {
+  box-sizing: border-box;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: inherit;
+}
+
+*::-moz-focus-inner {
+  border: 0;
+}
+
+body {
+  margin: 0;
+}
+
+button,
+input {
+  background-color: inherit;
+  color: inherit;
+  font-family: inherit;
+  font-size: inherit;
+}
+
+[hidden] {
+  display: none !important; // sass-lint:disable-line no-important
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/styles/_theme.scss
@@ -0,0 +1,135 @@
+@function textbox-shadow($color) {
+  @return 0 0 0 1px $color, 0 0 0 $textbox-shadow-size rgba($color, 0.3);
+}
+
+@mixin textbox-focus($color) {
+  --newtab-textbox-focus-color: $color;
+  --newtab-textbox-focus-boxshadow: textbox-shadow($color);
+}
+
+// scss variables related to the theme.
+$border-primary: 1px solid var(--newtab-border-primary-color);
+$border-secondary: 1px solid var(--newtab-border-secondary-color);
+$inner-box-shadow: 0 0 0 1px var(--newtab-inner-box-shadow-color);
+$input-border: 1px solid var(--newtab-textbox-border);
+$input-border-active: 1px solid var(--newtab-textbox-focus-color);
+$input-error-border: 1px solid $red-60;
+$input-error-boxshadow: textbox-shadow($red-60);
+$shadow-primary: 0 0 0 5px var(--newtab-card-active-outline-color);
+$shadow-secondary: 0 1px 4px 0 $grey-90-20;
+
+// Default theme
+body {
+  // General styles
+  --newtab-background-color: $grey-10;
+  --newtab-border-primary-color: $grey-40;
+  --newtab-border-secondary-color: $grey-30;
+  --newtab-button-primary-color: $blue-60;
+  --newtab-button-secondary-color: inherit;
+  --newtab-element-active-color: $grey-30-60;
+  --newtab-element-hover-color: $grey-20;
+  --newtab-icon-primary-color: $grey-90-80;
+  --newtab-icon-secondary-color: $grey-90-60;
+  --newtab-icon-tertiary-color: $grey-30;
+  --newtab-inner-box-shadow-color: $black-10;
+  --newtab-link-primary-color: $blue-60;
+  --newtab-link-secondary-color: $teal-70;
+  --newtab-text-conditional-color: $grey-60;
+  --newtab-text-primary-color: $grey-90;
+  --newtab-text-secondary-color: $grey-50;
+  --newtab-textbox-background-color: $white;
+  --newtab-textbox-border: $grey-90-20;
+  @include textbox-focus($blue-60); // sass-lint:disable-line mixins-before-declarations
+
+  // Context menu
+  --newtab-contextmenu-background-color: $grey-10;
+  --newtab-contextmenu-button-color: $white;
+
+  // Modal + overlay
+  --newtab-modal-color: $white;
+  --newtab-overlay-color: $grey-20-80;
+
+  // Sections
+  --newtab-section-header-text-color: $grey-50;
+  --newtab-section-navigation-text-color: $grey-50;
+  --newtab-section-active-contextmenu-color: $grey-90;
+
+  // Search
+  --newtab-search-border-color: transparent;
+  --newtab-search-dropdown-color: $white;
+  --newtab-search-dropdown-header-color: $grey-10;
+  --newtab-search-icon-color: $grey-90-40;
+
+  // Top Sites
+  --newtab-topsites-background-color: $white;
+  --newtab-topsites-icon-shadow: inset $inner-box-shadow;
+  --newtab-topsites-label-color: inherit;
+
+  // Cards
+  --newtab-card-active-outline-color: $grey-30;
+  --newtab-card-background-color: $white;
+  --newtab-card-hairline-color: $black-10;
+  --newtab-card-shadow: 0 1px 4px 0 $grey-90-10;
+
+  // Snippets
+  --newtab-snippets-background-color: $white;
+  --newtab-snippets-hairline-color: transparent;
+}
+
+// Dark theme
+.dark-theme {
+  // General styles
+  --newtab-background-color: $grey-80;
+  --newtab-border-primary-color: $grey-10-80;
+  --newtab-border-secondary-color: $grey-10-10;
+  --newtab-button-primary-color: $blue-60;
+  --newtab-button-secondary-color: $grey-70;
+  --newtab-element-active-color: $grey-10-20;
+  --newtab-element-hover-color: $grey-10-10;
+  --newtab-icon-primary-color: $grey-10-80;
+  --newtab-icon-secondary-color: $grey-10-40;
+  --newtab-icon-tertiary-color: $grey-10-40;
+  --newtab-inner-box-shadow-color: $grey-10-20;
+  --newtab-link-primary-color: $blue-40;
+  --newtab-link-secondary-color: $pocket-teal;
+  --newtab-text-conditional-color: $grey-10;
+  --newtab-text-primary-color: $grey-10;
+  --newtab-text-secondary-color: $grey-10-80;
+  --newtab-textbox-background-color: $grey-70;
+  --newtab-textbox-border: $grey-10-20;
+  @include textbox-focus($blue-40); // sass-lint:disable-line mixins-before-declarations
+
+  // Context menu
+  --newtab-contextmenu-background-color: $grey-60;
+  --newtab-contextmenu-button-color: $grey-80;
+
+  // Modal + overlay
+  --newtab-modal-color: $grey-80;
+  --newtab-overlay-color: $grey-90-80;
+
+  // Sections
+  --newtab-section-header-text-color: $grey-10-80;
+  --newtab-section-navigation-text-color: $grey-10-80;
+  --newtab-section-active-contextmenu-color: $white;
+
+  // Search
+  --newtab-search-border-color: $grey-10-20;
+  --newtab-search-dropdown-color: $grey-70;
+  --newtab-search-dropdown-header-color: $grey-60;
+  --newtab-search-icon-color: $grey-10-60;
+
+  // Top Sites
+  --newtab-topsites-background-color: $grey-70;
+  --newtab-topsites-icon-shadow: none;
+  --newtab-topsites-label-color: $grey-10-80;
+
+  // Cards
+  --newtab-card-active-outline-color: $grey-60;
+  --newtab-card-background-color: $grey-70;
+  --newtab-card-hairline-color: $grey-10-10;
+  --newtab-card-shadow: 0 1px 8px 0 $grey-90-20;
+
+  // Snippets
+  --newtab-snippets-background-color: $grey-70;
+  --newtab-snippets-hairline-color: $white-10;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/styles/_variables.scss
@@ -0,0 +1,187 @@
+// Photon colors from http://design.firefox.com/photon/visuals/color.html
+$blue-40: #45A1FF;
+$blue-50: #0A84FF;
+$blue-60: #0060DF;
+$blue-70: #003EAA;
+$blue-80: #002275;
+$grey-10: #F9F9FA;
+$grey-20: #EDEDF0;
+$grey-30: #D7D7DB;
+$grey-40: #B1B1B3;
+$grey-50: #737373;
+$grey-60: #4A4A4F;
+$grey-70: #38383D;
+$grey-80: #2A2A2E;
+$grey-90: #0C0C0D;
+$teal-70: #008EA4;
+$red-60: #D70022;
+$yellow-50: #FFE900;
+
+// Photon opacity from http://design.firefox.com/photon/visuals/color.html#opacity
+$grey-10-10: rgba($grey-10, 0.1);
+$grey-10-20: rgba($grey-10, 0.2);
+$grey-10-40: rgba($grey-10, 0.4);
+$grey-10-60: rgba($grey-10, 0.6);
+$grey-10-80: rgba($grey-10, 0.8);
+$grey-20-60: rgba($grey-20, 0.6);
+$grey-20-80: rgba($grey-20, 0.8);
+$grey-30-60: rgba($grey-30, 0.6);
+$grey-90-10: rgba($grey-90, 0.1);
+$grey-90-20: rgba($grey-90, 0.2);
+$grey-90-30: rgba($grey-90, 0.3);
+$grey-90-40: rgba($grey-90, 0.4);
+$grey-90-50: rgba($grey-90, 0.5);
+$grey-90-60: rgba($grey-90, 0.6);
+$grey-90-70: rgba($grey-90, 0.7);
+$grey-90-80: rgba($grey-90, 0.8);
+$grey-90-90: rgba($grey-90, 0.9);
+
+$black: #000;
+$black-5: rgba($black, 0.05);
+$black-10: rgba($black, 0.1);
+$black-15: rgba($black, 0.15);
+$black-20: rgba($black, 0.2);
+$black-25: rgba($black, 0.25);
+$black-30: rgba($black, 0.3);
+
+// Other colors
+$white: #FFF;
+$white-10: rgba($white, 0.1);
+$pocket-teal: #50BCB6;
+$bookmark-icon-fill: #0A84FF;
+$download-icon-fill: #12BC00;
+$history-icon-fill: #B1B1B3;
+$pocket-icon-fill: #D70022;
+
+// Photon transitions from http://design.firefox.com/photon/motion/duration-and-easing.html
+$photon-easing: cubic-bezier(0.07, 0.95, 0, 1);
+
+$border-radius: 3px;
+
+// Grid related styles
+$base-gutter: 32px;
+$section-horizontal-padding: 25px;
+$section-vertical-padding: 10px;
+$section-spacing: 40px - $section-vertical-padding * 2;
+$grid-unit: 96px; // 1 top site
+
+$icon-size: 16px;
+$smaller-icon-size: 12px;
+$larger-icon-size: 32px;
+
+$wrapper-default-width: $grid-unit * 2 + $base-gutter * 1 + $section-horizontal-padding * 2; // 2 top sites
+$wrapper-max-width-small: $grid-unit * 3 + $base-gutter * 2 + $section-horizontal-padding * 2; // 3 top sites
+$wrapper-max-width-medium: $grid-unit * 4 + $base-gutter * 3 + $section-horizontal-padding * 2; // 4 top sites
+$wrapper-max-width-large: $grid-unit * 6 + $base-gutter * 5 + $section-horizontal-padding * 2; // 6 top sites
+$wrapper-max-width-widest: $grid-unit * 8 + $base-gutter * 7 + $section-horizontal-padding * 2; // 8 top sites
+// For the breakpoints, we need to add space for the scrollbar to avoid weird
+// layout issues when the scrollbar is visible. 16px is wide enough to cover all
+// OSes and keeps it simpler than a per-OS value.
+$scrollbar-width: 16px;
+$break-point-small: $wrapper-max-width-small + $base-gutter * 2 + $scrollbar-width;
+$break-point-medium: $wrapper-max-width-medium + $base-gutter * 2 + $scrollbar-width;
+$break-point-large: $wrapper-max-width-large + $base-gutter * 2 + $scrollbar-width;
+$break-point-widest: $wrapper-max-width-widest + $base-gutter * 2 + $scrollbar-width;
+
+$section-title-font-size: 13px;
+
+$card-width: $grid-unit * 2 + $base-gutter;
+$card-height: 266px;
+$card-preview-image-height: 122px;
+$card-title-margin: 2px;
+$card-text-line-height: 19px;
+// Larger cards for wider screens:
+$card-width-large: 309px;
+$card-height-large: 370px;
+$card-preview-image-height-large: 155px;
+// Compact cards for Highlights
+$card-height-compact: 160px;
+$card-preview-image-height-compact: 108px;
+
+$topic-margin-top: 12px;
+
+$context-menu-button-size: 27px;
+$context-menu-button-boxshadow: 0 2px $grey-90-10;
+$context-menu-shadow: 0 5px 10px $black-30, 0 0 0 1px $black-20;
+$context-menu-font-size: 14px;
+$context-menu-border-radius: 5px;
+$context-menu-outer-padding: 5px;
+$context-menu-item-padding: 3px 12px;
+
+$error-fallback-font-size: 12px;
+$error-fallback-line-height: 1.5;
+
+$image-path: '../data/content/assets/';
+
+$snippets-container-height: 120px;
+
+$textbox-shadow-size: 4px;
+
+@mixin fade-in {
+  box-shadow: inset $inner-box-shadow, $shadow-primary;
+  transition: box-shadow 150ms;
+}
+
+@mixin fade-in-card {
+  box-shadow: $shadow-primary;
+  transition: box-shadow 150ms;
+}
+
+@mixin context-menu-button {
+  .context-menu-button {
+    background-clip: padding-box;
+    background-color: var(--newtab-contextmenu-button-color);
+    background-image: url('chrome://browser/skin/page-action.svg');
+    background-position: 55%;
+    border: $border-primary;
+    border-radius: 100%;
+    box-shadow: $context-menu-button-boxshadow;
+    cursor: pointer;
+    fill: var(--newtab-icon-primary-color);
+    height: $context-menu-button-size;
+    offset-inline-end: -($context-menu-button-size / 2);
+    opacity: 0;
+    position: absolute;
+    top: -($context-menu-button-size / 2);
+    transform: scale(0.25);
+    transition-duration: 200ms;
+    transition-property: transform, opacity;
+    width: $context-menu-button-size;
+
+    &:-moz-any(:active, :focus) {
+      opacity: 1;
+      transform: scale(1);
+    }
+  }
+}
+
+@mixin context-menu-button-hover {
+  .context-menu-button {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+
+@mixin context-menu-open-middle {
+  .context-menu {
+    margin-inline-end: auto;
+    margin-inline-start: auto;
+    offset-inline-end: auto;
+    offset-inline-start: -$base-gutter;
+  }
+}
+
+@mixin context-menu-open-left {
+  .context-menu {
+    margin-inline-end: 5px;
+    margin-inline-start: auto;
+    offset-inline-end: 0;
+    offset-inline-start: auto;
+  }
+}
+
+@mixin flip-icon {
+  &:dir(rtl) {
+    transform: scaleX(-1);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/styles/activity-stream-linux.scss
@@ -0,0 +1,7 @@
+/* This is the linux variant */ // sass-lint:disable-line no-css-comments
+
+$os-infopanel-arrow-height: 10px;
+$os-infopanel-arrow-offset-end: 6px;
+$os-infopanel-arrow-width: 20px;
+
+@import './activity-stream';
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/styles/activity-stream-mac.scss
@@ -0,0 +1,11 @@
+/* This is the mac variant */ // sass-lint:disable-line no-css-comments
+
+$os-infopanel-arrow-height: 10px;
+$os-infopanel-arrow-offset-end: 7px;
+$os-infopanel-arrow-width: 18px;
+
+.dark-theme {
+  -moz-osx-font-smoothing: grayscale;
+}
+
+@import './activity-stream';
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/content-src/styles/activity-stream-windows.scss
@@ -0,0 +1,7 @@
+/* This is the windows variant */ // sass-lint:disable-line no-css-comments
+
+$os-infopanel-arrow-height: 10px;
+$os-infopanel-arrow-offset-end: 6px;
+$os-infopanel-arrow-width: 20px;
+
+@import './activity-stream';
new file mode 100644
--- /dev/null
+++ b/browser/extensions/activity-stream/contributing.md
@@ -0,0 +1,138 @@
+# Contributing to Activity Stream
+
+Activity Stream is an enhancement to the functionality of Firefox's about:newtab page.  We welcome new 'streamers' to contribute to the project!
+
+## Where to ask questions
+
+- Most of the core dev team can be found on the `#activity-stream` channel on `irc.mozilla.org`.
+  You can also direct message the core team (`dmose`, `emtwo`, `jkerim`, `k88hudson`, `Mardak`, `nanj`, `r1cky`, `ursula`, `andreio`)
+  or our manager (`tspurway`)