Merge mozilla-central to mozilla-inbound. a=merge CLOSED TREE
authorCiure Andrei <aciure@mozilla.com>
Fri, 08 Jun 2018 00:56:15 +0300
changeset 421842 38c222c1bf73be8ef89397c23c607dfe34d748ab
parent 421786 8e386f33143372071bfbfeca7596a2144cf0ca85 (current diff)
parent 421841 ea21bf3e665d10066b6dce39873de9b353a12e57 (diff)
child 421843 0d24499ad4e81c211f892a3e2d025d2677b4eee8
push id104125
push useraciure@mozilla.com
push dateThu, 07 Jun 2018 21:57:03 +0000
treeherdermozilla-inbound@38c222c1bf73 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone62.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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;
<