Bug 1552366 - Add placeholder cards, wrapping buttons and bug fixes to Activity Stream r=k88hudson
authorEd Lee <edilee@mozilla.com>
Fri, 17 May 2019 13:25:09 +0000
changeset 533167 c7a169c8670c79253241ed244a51e0927346bf15
parent 533166 2bc53ddb0a2b142662265355e54c6deb3ab716ad
child 533168 b84060d7fa8d43fe978058f976308194195d6a2d
push id11276
push userrgurzau@mozilla.com
push dateMon, 20 May 2019 13:11:24 +0000
treeherdermozilla-beta@847755a7c325 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersk88hudson
bugs1552366
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1552366 - Add placeholder cards, wrapping buttons and bug fixes to Activity Stream r=k88hudson Differential Revision: https://phabricator.services.mozilla.com/D31549
browser/components/newtab/.mcignore
browser/components/newtab/README.md
browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx
browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss
browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json
browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json
browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json
browser/components/newtab/content-src/asrouter/templates/ReturnToAMO/ReturnToAMO.jsx
browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json
browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx
browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json
browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx
browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json
browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx
browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json
browser/components/newtab/content-src/asrouter/templates/Trailhead/_Trailhead.scss
browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
browser/components/newtab/content-src/lib/selectLayoutRender.js
browser/components/newtab/contributing.md
browser/components/newtab/css/activity-stream-linux.css
browser/components/newtab/css/activity-stream-mac.css
browser/components/newtab/css/activity-stream-windows.css
browser/components/newtab/data/content/activity-stream.bundle.js
browser/components/newtab/docs/index.rst
browser/components/newtab/hooks/pre-push
browser/components/newtab/lib/ASRouter.jsm
browser/components/newtab/lib/SnippetsTestMessageProvider.jsm
browser/components/newtab/moz.build
browser/components/newtab/package-lock.json
browser/components/newtab/package.json
browser/components/newtab/test/browser/browser.ini
browser/components/newtab/test/browser/browser_asrouter_snippets.js
browser/components/newtab/test/unit/asrouter/ASRouter.test.js
browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx
browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx
browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx
browser/components/newtab/test/unit/content-src/components/ASRouterAdmin.test.jsx
browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js
browser/components/newtab/yamscripts.yml
--- a/browser/components/newtab/.mcignore
+++ b/browser/components/newtab/.mcignore
@@ -9,14 +9,17 @@ npm-debug.log
 /.git/
 /bin/prerender.js
 /bin/prerender.js.map
 /data/locales.json
 /dist/
 /logs/
 /node_modules/
 
+# ignore README since it's GitHub specific
+/README.md
+
 # also ignores ping centre tests
 ping-centre/
 
 # ignore things from about:library for now
 aboutlibrary/
 content-src/aboutlibrary/
deleted file mode 100644
--- a/browser/components/newtab/README.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# activity-stream
-
-[![Task Status](https://github.taskcluster.net/v1/repository/mozilla/activity-stream/master/badge.svg)](https://github.taskcluster.net/v1/repository/mozilla/activity-stream/master/latest)
-
-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.
--- a/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx
+++ b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx
@@ -19,17 +19,18 @@ const ALLOWED_TAGS = {
  */
 export function convertLinks(links, sendClick, doNotAutoBlock, openNewWindow = false) {
   if (links) {
     return Object.keys(links).reduce((acc, linkTag) => {
       const {action} = links[linkTag];
       // Setting the value to false will not include the attribute in the anchor
       const url = action ? false : safeURI(links[linkTag].url);
 
-      acc[linkTag] = (<a href={url}
+      acc[linkTag] = (<a href={url} // eslint-disable-line jsx-a11y/anchor-has-content
+        // eslint was getting a false positive caused by the dynamic injection of content.
         target={openNewWindow ? "_blank" : ""}
         data-metric={links[linkTag].metric}
         data-action={action}
         data-args={links[linkTag].args}
         data-do_not_autoblock={doNotAutoBlock}
         onClick={sendClick} />);
       return acc;
     }, {});
--- a/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss
+++ b/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss
@@ -93,8 +93,28 @@
   position: absolute;
   top: 0;
   width: 100%;
 
   span {
     vertical-align: middle;
   }
 }
+
+// We show snippet icons for both themes and conditionally hide
+// based on which theme is currently active
+body {
+  &:not([lwt-newtab-brighttext]) {
+    .icon-dark-theme,
+    .icon.icon-dark-theme,
+    .scene2Icon .icon-dark-theme {
+      display: none;
+    }
+  }
+
+  &[lwt-newtab-brighttext] {
+    .icon-light-theme,
+    .icon.icon-light-theme,
+    .scene2Icon .icon-light-theme {
+      display: none;
+    }
+  }
+}
--- a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json
+++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json
@@ -1,12 +1,12 @@
 {
   "title": "EOYSnippet",
   "description": "Fundraising Snippet",
-  "version": "1.0.0",
+  "version": "1.1.0",
   "type": "object",
   "definitions": {
     "plainText": {
       "description": "Plain text (no HTML allowed)",
       "type": "string"
     },
     "richText": {
       "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
@@ -71,26 +71,34 @@
       "type": "string",
       "description": "Default donation_amount_second. Donation amount button that's selected by default.",
       "default": "donation_amount_second"
     },
     "icon": {
       "type": "string",
       "description": "Snippet icon. 64x64px. SVG or PNG preferred."
     },
+    "icon_dark_theme": {
+      "type": "string",
+      "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+    },
     "title": {
       "allOf": [
         {"$ref": "#/definitions/plainText"},
         {"description": "Snippet title displayed before snippet text"}
       ]
     },
     "title_icon": {
       "type": "string",
       "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
     },
+    "title_icon_dark_theme": {
+      "type": "string",
+      "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+    },
     "button_label": {
       "allOf": [
         {"$ref": "#/definitions/plainText"},
         {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
       ]
     },
     "button_color": {
       "type": "string",
--- a/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json
+++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json
@@ -1,12 +1,12 @@
 {
   "title": "FXASignupSnippet",
   "description": "A snippet template for FxA sign up/sign in",
-  "version": "1.0.0",
+  "version": "1.1.0",
   "type": "object",
   "definitions": {
     "plainText": {
       "description": "Plain text (no HTML allowed)",
       "type": "string"
     },
     "richText": {
       "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
@@ -42,20 +42,28 @@
         {"$ref": "#/definitions/richText"},
         {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
       ]
     },
     "scene1_icon": {
       "type": "string",
       "description": "Snippet icon. 64x64px. SVG or PNG preferred."
     },
+    "scene1_icon_dark_theme": {
+      "type": "string",
+      "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+    },
     "scene1_title_icon": {
       "type": "string",
       "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
     },
+    "scene1_title_icon_dark_theme": {
+      "type": "string",
+      "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+    },
     "scene2_email_placeholder_text": {
       "type": "string",
       "description": "Value to show while input is empty.",
       "default": "Your email here"
     },
     "scene2_button_label": {
       "type": "string",
       "description": "Label for form submit button",
--- a/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json
+++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json
@@ -1,12 +1,12 @@
 {
   "title": "NewsletterSnippet",
   "description": "A snippet template for send to device mobile download",
-  "version": "1.0.0",
+  "version": "1.1.0",
   "type": "object",
   "definitions": {
     "plainText": {
       "description": "Plain text (no HTML allowed)",
       "type": "string"
     },
     "richText": {
       "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
@@ -47,20 +47,28 @@
         {"$ref": "#/definitions/richText"},
         {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
       ]
     },
     "scene1_icon": {
       "type": "string",
       "description": "Snippet icon. 64x64px. SVG or PNG preferred."
     },
+    "scene1_icon_dark_theme": {
+      "type": "string",
+      "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+    },
     "scene1_title_icon": {
       "type": "string",
       "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
     },
+    "scene1_title_icon_dark_theme": {
+      "type": "string",
+      "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+    },
     "scene2_email_placeholder_text": {
       "type": "string",
       "description": "Value to show while input is empty.",
       "default": "Your email here"
     },
     "scene2_button_label": {
       "type": "string",
       "description": "Label for form submit button",
--- a/browser/components/newtab/content-src/asrouter/templates/ReturnToAMO/ReturnToAMO.jsx
+++ b/browser/components/newtab/content-src/asrouter/templates/ReturnToAMO/ReturnToAMO.jsx
@@ -1,11 +1,14 @@
 import React from "react";
 import {RichText} from "../../components/RichText/RichText";
 
+// Alt text if available; in the future this should come from the server. See bug 1551711
+const ICON_ALT_TEXT = "";
+
 export class ReturnToAMO extends React.PureComponent {
   constructor(props) {
     super(props);
     this.onClickAddExtension = this.onClickAddExtension.bind(this);
     this.onBlockButton = this.onBlockButton.bind(this);
   }
 
   componentDidMount() {
@@ -29,17 +32,17 @@ export class ReturnToAMO extends React.P
     document.body.classList.remove("welcome", "hide-main", "amo");
     this.props.sendUserActionTelemetry({
       event: "BLOCK",
       id: this.props.UISurface,
     });
   }
 
   renderText() {
-    const customElement = <img src={this.props.content.addon_icon} width="20px" height="20px" />;
+    const customElement = <img src={this.props.content.addon_icon} width="20px" height="20px" alt={ICON_ALT_TEXT} />;
     return (<RichText
       customElements={{icon: customElement}}
       amo_html={this.props.content.text}
       localization_id="amo_html" />);
   }
 
   render() {
     const {content} = this.props;
--- a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json
+++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json
@@ -1,12 +1,12 @@
 {
   "title": "SendToDeviceSnippet",
   "description": "A snippet template for send to device mobile download",
-  "version": "1.0.0",
+  "version": "1.1.0",
   "type": "object",
   "definitions": {
     "plainText": {
       "description": "Plain text (no HTML allowed)",
       "type": "string"
     },
     "richText": {
       "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
@@ -52,24 +52,36 @@
         {"$ref": "#/definitions/richText"},
         {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
       ]
     },
     "scene1_icon": {
       "type": "string",
       "description": "Snippet icon. 64x64px. SVG or PNG preferred."
     },
+    "scene1_icon_dark_theme": {
+      "type": "string",
+      "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+    },
     "scene2_icon": {
       "type": "string",
+      "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred."
+    },
+    "scene2_icon_dark_theme": {
+      "type": "string",
       "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred."
     },
     "scene1_title_icon": {
       "type": "string",
       "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
     },
+    "scene1_title_icon_dark_theme": {
+      "type": "string",
+      "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+    },
     "scene2_button_label": {
       "type": "string",
       "description": "Label for form submit button",
       "default": "Send"
     },
     "scene2_input_placeholder": {
       "type": "string",
       "description": "(send to device) Value to show while input is empty.",
--- a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx
@@ -1,14 +1,16 @@
 import React from "react";
 import {RichText} from "../../components/RichText/RichText";
 import {safeURI} from "../../template-utils";
 import {SnippetBase} from "../../components/SnippetBase/SnippetBase";
 
 const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png";
+// Alt text if available; in the future this should come from the server. See bug 1551711
+const ICON_ALT_TEXT = "";
 
 export class SimpleBelowSearchSnippet extends React.PureComponent {
   renderText() {
     const {props} = this;
     return (<RichText text={props.content.text}
       customElements={this.props.customElements}
       localization_id="text"
       links={props.content.links}
@@ -19,16 +21,17 @@ export class SimpleBelowSearchSnippet ex
     const {props} = this;
     let className = "SimpleBelowSearchSnippet";
 
     if (props.className) {
       className += ` ${props.className}`;
     }
 
     return (<SnippetBase {...props} className={className} textStyle={this.props.textStyle}>
-      <img src={safeURI(props.content.icon) || DEFAULT_ICON_PATH} className="icon" />
+      <img src={safeURI(props.content.icon) || DEFAULT_ICON_PATH} className="icon icon-light-theme" alt={ICON_ALT_TEXT} />
+      <img src={safeURI(props.content.icon_dark_theme || props.content.icon) || DEFAULT_ICON_PATH} className="icon icon-dark-theme" alt={ICON_ALT_TEXT} />
       <div>
         <p className="body">{this.renderText()}</p>
         {this.props.extraContent}
       </div>
     </SnippetBase>);
   }
 }
--- a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json
@@ -1,12 +1,12 @@
 {
   "title": "SimpleBelowSearchSnippet",
   "description": "A simple template with just an icon and rich text. It gets inserted below the Activity Stream search box.",
-  "version": "1.1.0",
+  "version": "1.2.0",
   "type": "object",
   "definitions": {
     "richText": {
       "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
       "type": "string"
     },
     "link_url": {
       "description": "Target for links or buttons",
@@ -20,16 +20,20 @@
         {"$ref": "#/definitions/richText"},
         {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
       ]
     },
     "icon": {
       "type": "string",
       "description": "Snippet icon. 64x64px. SVG or PNG preferred."
     },
+    "icon_dark_theme": {
+      "type": "string",
+      "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+    },
     "block_button_text": {
       "type": "string",
       "description": "Tooltip text used for dismiss button.",
       "default": "Remove this"
     },
     "do_not_autoblock": {
       "type": "boolean",
       "description": "Used to prevent blocking the snippet after the CTA link has been clicked"
--- a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx
@@ -1,16 +1,18 @@
 import {Button} from "../../components/Button/Button";
 import {ConditionalWrapper} from "../../components/ConditionalWrapper/ConditionalWrapper";
 import React from "react";
 import {RichText} from "../../components/RichText/RichText";
 import {safeURI} from "../../template-utils";
 import {SnippetBase} from "../../components/SnippetBase/SnippetBase";
 
 const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png";
+// Alt text if available; in the future this should come from the server. See bug 1551711
+const ICON_ALT_TEXT = "";
 
 export class SimpleSnippet extends React.PureComponent {
   constructor(props) {
     super(props);
     this.onButtonClick = this.onButtonClick.bind(this);
   }
 
   onButtonClick() {
@@ -36,18 +38,26 @@ export class SimpleSnippet extends React
   renderTitle() {
     const {title} = this.props.content;
     return title ?
       <h3 className={`title ${this._shouldRenderButton() ? "title-inline" : ""}`}>{this.renderTitleIcon()} {title}</h3> :
       null;
   }
 
   renderTitleIcon() {
-    const titleIcon = safeURI(this.props.content.title_icon);
-    return titleIcon ? <span className="titleIcon" style={{backgroundImage: `url("${titleIcon}")`}} /> : null;
+    const titleIconLight = safeURI(this.props.content.title_icon);
+    const titleIconDark = safeURI(this.props.content.title_icon_dark_theme || this.props.content.title_icon);
+    if (!titleIconLight) {
+      return null;
+    }
+
+    return (<React.Fragment>
+        <span className="titleIcon icon-light-theme" style={{backgroundImage: `url("${titleIconLight}")`}} />
+        <span className="titleIcon icon-dark-theme" style={{backgroundImage: `url("${titleIconDark}")`}} />
+      </React.Fragment>);
   }
 
   renderButton() {
     const {props} = this;
     if (!this._shouldRenderButton()) {
       return null;
     }
 
@@ -78,24 +88,26 @@ export class SimpleSnippet extends React
     return <div className="innerContentWrapper">{children}</div>;
   }
 
   renderSectionHeader() {
     const {props} = this;
 
     // an icon and text must be specified to render the section header
     if (props.content.section_title_icon && props.content.section_title_text) {
-      const sectionTitleIcon = safeURI(props.content.section_title_icon);
+      const sectionTitleIconLight = safeURI(props.content.section_title_icon);
+      const sectionTitleIconDark = safeURI(props.content.section_title_icon_dark_theme || props.content.section_title_icon);
       const sectionTitleURL = props.content.section_title_url;
 
       return (
         <div className="section-header">
           <h3 className="section-title">
             <ConditionalWrapper condition={sectionTitleURL} wrap={this.wrapSectionHeader(sectionTitleURL)}>
-              <span className="icon icon-small-spacer" style={{backgroundImage: `url("${sectionTitleIcon}")`}} />
+              <span className="icon icon-small-spacer icon-light-theme" style={{backgroundImage: `url("${sectionTitleIconLight}")`}} />
+              <span className="icon icon-small-spacer icon-dark-theme" style={{backgroundImage: `url("${sectionTitleIconDark}")`}} />
               <span className="section-title-text">{props.content.section_title_text}</span>
             </ConditionalWrapper>
           </h3>
         </div>
       );
     }
 
     return null;
@@ -114,17 +126,18 @@ export class SimpleSnippet extends React
     }
     if (sectionHeader) {
       className += " has-section-header";
     }
 
     return (<SnippetBase {...props} className={className} textStyle={this.props.textStyle}>
       {sectionHeader}
       <ConditionalWrapper condition={sectionHeader} wrap={this.wrapSnippetContent}>
-        <img src={safeURI(props.content.icon) || DEFAULT_ICON_PATH} className="icon" />
+        <img src={safeURI(props.content.icon) || DEFAULT_ICON_PATH} className="icon icon-light-theme" alt={ICON_ALT_TEXT} />
+        <img src={safeURI(props.content.icon_dark_theme || props.content.icon) || DEFAULT_ICON_PATH} className="icon icon-dark-theme" alt={ICON_ALT_TEXT} />
         <div>
           {this.renderTitle()} <p className="body">{this.renderText()}</p>
           {this.props.extraContent}
         </div>
         {<div>{this.renderButton()}</div>}
       </ConditionalWrapper>
     </SnippetBase>);
   }
--- a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json
@@ -30,20 +30,28 @@
         {"$ref": "#/definitions/richText"},
         {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
       ]
     },
     "icon": {
       "type": "string",
       "description": "Snippet icon. 64x64px. SVG or PNG preferred."
     },
+    "icon_dark_theme": {
+      "type": "string",
+      "description": "Snippet icon, dark theme variant. 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."
     },
+    "title_icon_dark_theme": {
+      "type": "string",
+      "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+    },
     "button_action": {
       "type": "string",
       "description": "The type of action the button should trigger."
     },
     "button_url": {
       "allOf": [
         {"$ref": "#/definitions/link_url"},
         {"description": "A url, button_label links to this"}
@@ -97,16 +105,20 @@
           "description": "Additional parameters for link action, example which specific menu the button should open"
         }
       }
     },
     "section_title_icon": {
       "type": "string",
       "description": "Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."
     },
+    "section_title_icon_dark_theme": {
+      "type": "string",
+      "description": "Section title icon, dark theme variant. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."
+    },
     "section_title_text": {
       "type": "string",
       "description": "Section title text. section_title_icon must also be specified to display."
     },
     "section_title_url": {
       "allOf": [
         {"$ref": "#/definitions/link_url"},
         {"description": "A url, section_title_text links to this"}
--- a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx
+++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx
@@ -1,14 +1,18 @@
 import {Button} from "../../components/Button/Button";
 import React from "react";
 import {RichText} from "../../components/RichText/RichText";
+import {safeURI} from "../../template-utils";
 import {SimpleSnippet} from "../SimpleSnippet/SimpleSnippet";
 import {SnippetBase} from "../../components/SnippetBase/SnippetBase";
 
+// Alt text if available; in the future this should come from the server. See bug 1551711
+const ICON_ALT_TEXT = "";
+
 export class SubmitFormSnippet extends React.PureComponent {
   constructor(props) {
     super(props);
     this.expandSnippet = this.expandSnippet.bind(this);
     this.handleSubmit = this.handleSubmit.bind(this);
     this.handleSubmitAttempt = this.handleSubmitAttempt.bind(this);
     this.onInputChange = this.onInputChange.bind(this);
     this.state = {
@@ -150,25 +154,28 @@ export class SubmitFormSnippet extends R
     const placholder = this.props.content.scene2_email_placeholder_text || this.props.content.scene2_input_placeholder;
     return (<input
       ref="mainInput"
       type={this.props.inputType || "email"}
       className={`mainInput${(this.state.submitAttempted ? "" : " clean")}`}
       name="email"
       required={true}
       placeholder={placholder}
-      onChange={this.props.validateInput ? this.onInputChange : null}
-      autoFocus={true} />);
+      onChange={this.props.validateInput ? this.onInputChange : null} />);
   }
 
   renderSignupView() {
     const {content} = this.props;
     const containerClass = `SubmitFormSnippet ${this.props.className}`;
     return (<SnippetBase {...this.props} className={containerClass} footerDismiss={true}>
-        {content.scene2_icon ? <div className="scene2Icon"><img src={content.scene2_icon} /></div> : null}
+        {content.scene2_icon ?
+          <div className="scene2Icon">
+            <img src={safeURI(content.scene2_icon)} className="icon-light-theme" alt={ICON_ALT_TEXT} />
+            <img src={safeURI(content.scene2_icon_dark_theme || content.scene2_icon)} className="icon-dark-theme" alt={ICON_ALT_TEXT} />
+          </div> : null}
         <div className="message">
           <p>
             {content.scene2_title && <h3 className="scene2Title">{content.scene2_title}</h3>}
             {" "}
             {content.scene2_text && <RichText scene2_text={content.scene2_text} localization_id="scene2_text" />}
           </p>
         </div>
         <form action={this.props.form_action} method={this.props.form_method} onSubmit={this.handleSubmit} ref="form">
--- a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json
+++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json
@@ -1,12 +1,12 @@
 {
   "title": "SubmitFormSnippet",
   "description": "A template with two states: a SimpleSnippet and another that contains a form",
-  "version": "1.0.0",
+  "version": "1.1.0",
   "type": "object",
   "definitions": {
     "plainText": {
       "description": "Plain text (no HTML allowed)",
       "type": "string"
     },
     "richText": {
       "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
@@ -50,20 +50,28 @@
         {"$ref": "#/definitions/richText"},
         {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
       ]
     },
     "scene1_icon": {
       "type": "string",
       "description": "Snippet icon. 64x64px. SVG or PNG preferred."
     },
+    "scene1_icon_dark_theme": {
+      "type": "string",
+      "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+    },
     "scene1_title_icon": {
       "type": "string",
       "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
     },
+    "scene1_title_icon_dark_theme": {
+      "type": "string",
+      "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+    },
     "form_action": {
       "type": "string",
       "description": "Endpoint to submit form data."
     },
     "success_title": {
       "type": "string",
       "description": "(send to device) Title shown before text on successful registration."
     },
@@ -98,16 +106,20 @@
     "scene2_dismiss_button_text": {
       "type": "string",
       "description": "Label for the dismiss button when the sign-up form is expanded."
     },
     "scene2_icon": {
       "type": "string",
       "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred."
     },
+    "scene2_icon_dark_theme": {
+      "type": "string",
+      "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred."
+    },
     "scene2_newsletter": {
       "type": "string",
       "description": "Newsletter/basket id user is subscribing to. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/. Default 'mozilla-foundation'."
     },
     "hidden_inputs": {
       "type": "object",
       "description": "Each entry represents a hidden input, key is used as value for the name property."
     },
--- a/browser/components/newtab/content-src/asrouter/templates/Trailhead/_Trailhead.scss
+++ b/browser/components/newtab/content-src/asrouter/templates/Trailhead/_Trailhead.scss
@@ -366,19 +366,20 @@
     line-height: 1.5;
     font-weight: 200;
   }
 
   .onboardingButton {
     color: var(--newtab-text-conditional-color);
     background: var(--trailhead-card-button-background-color);
     border: 0;
-    height: 30px;
+    margin: 14px;
     min-width: 70%;
-    padding: 0 14px;
+    padding: 6px 14px;
+    white-space: pre-wrap;
 
     &:focus,
     &:hover {
       box-shadow: none;
       background: var(--trailhead-card-button-background-hover-color);
     }
 
     &:focus {
@@ -386,19 +387,18 @@
     }
 
     &:active {
       background: var(--trailhead-card-button-background-active-color);
     }
   }
 
   .onboardingButtonContainer {
-    height: 60px;
     position: absolute;
-    bottom: 0;
+    bottom: 16px;
     left: 0;
     width: 100%;
     text-align: center;
   }
 }
 
 .inline-onboarding {
   &.activity-stream.welcome {
--- a/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx
+++ b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx
@@ -467,17 +467,17 @@ export class ASRouterAdminInner extends 
       {messagesToShow.map(msg => this.renderMessageItem(msg))}
     </tbody></table>);
   }
 
   renderMessageFilter() {
     if (!this.state.providers) {
       return null;
     }
-    return (<p>Show messages from <select value={this.state.messageFilter} onChange={this.onChangeMessageFilter}>
+    return (<p>Show messages from <select value={this.state.messageFilter} onBlur={this.onChangeMessageFilter}>
       <option value="all">all providers</option>
       {this.state.providers.map(provider => (<option key={provider.id} value={provider.id}>{provider.id}</option>))}
     </select></p>);
   }
 
   renderTableHead() {
     return (<thead>
       <tr className="message-item">
@@ -536,17 +536,17 @@ export class ASRouterAdminInner extends 
     if (!this.state.pasteFromClipboard) {
       return null;
     }
     const errors = this.refs.targetingParamsEval && this.refs.targetingParamsEval.innerText.length;
     return (
       <ModalOverlay title="New targeting parameters" button_label={errors ? "Cancel" : "Done"} onDoneButton={this.onPasteTargetingParams}>
         <div className="onboardingMessage">
           <p>
-            <textarea onChange={this.onNewTargetingParams} value={this.state.newStringTargetingParameters} autoFocus={true} rows="20" cols="60" />
+            <textarea onChange={this.onNewTargetingParams} value={this.state.newStringTargetingParameters} rows="20" cols="60" />
           </p>
           <p ref="targetingParamsEval" />
         </div>
       </ModalOverlay>
     );
   }
 
   renderTargetingParameters() {
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
@@ -4,35 +4,35 @@ import React from "react";
 
 export class CardGrid extends React.PureComponent {
   renderCards() {
     const recs = this.props.data.recommendations.slice(0, this.props.items);
     const cards = [];
 
     for (let index = 0; index < this.props.items; index++) {
       const rec = recs[index];
-      cards.push(rec ? (
+      cards.push(!rec || rec.placeholder ? (
+        <PlaceholderDSCard key={`dscard-${index}`} />
+      ) : (
         <DSCard
           key={`dscard-${index}`}
           pos={rec.pos}
           campaignId={rec.campaign_id}
           image_src={rec.image_src}
           raw_image_src={rec.raw_image_src}
           title={rec.title}
           excerpt={rec.excerpt}
           url={rec.url}
           id={rec.id}
           type={this.props.type}
           context={rec.context}
           dispatch={this.props.dispatch}
           source={rec.domain}
           pocket_id={rec.pocket_id}
           bookmarkGuid={rec.bookmarkGuid} />
-      ) : (
-        <PlaceholderDSCard key={`dscard-${index}`} />
       ));
     }
 
     let divisibility = ``;
 
     if (this.props.items % 4 === 0) {
       divisibility = `divisible-by-4`;
     } else if (this.props.items % 3 === 0) {
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
@@ -32,49 +32,45 @@ export class Hero extends React.PureComp
 
   renderHero() {
     let [heroRec, ...otherRecs] = this.props.data.recommendations.slice(0, this.props.items);
     this.heroRec = heroRec;
 
     const cards = [];
     for (let index = 0; index < this.props.items - 1; index++) {
       const rec = otherRecs[index];
-      cards.push(rec ? (
+      cards.push(!rec || rec.placeholder ? (
+        <PlaceholderDSCard key={`dscard-${index}`} />
+      ) : (
         <DSCard
         campaignId={rec.campaign_id}
         key={`dscard-${index}`}
         image_src={rec.image_src}
         raw_image_src={rec.raw_image_src}
         title={rec.title}
         url={rec.url}
         id={rec.id}
         pos={rec.pos}
         type={this.props.type}
         dispatch={this.props.dispatch}
         context={rec.context}
         source={rec.domain}
         pocket_id={rec.pocket_id}
         bookmarkGuid={rec.bookmarkGuid} />
-      ) : (
-        <PlaceholderDSCard key={`dscard-${index}`} />
       ));
     }
 
-    let list = (
-      <List
-        recStartingPoint={1}
-        data={this.props.data}
-        hasImages={true}
-        hasBorders={this.props.border === `border`}
-        items={this.props.items - 1}
-        type={`Hero`} />
-    );
+    let heroCard = null;
 
-    return (
-      <div className={`ds-hero ds-hero-${this.props.border}`}>
+    if (!heroRec || heroRec.placeholder) {
+      heroCard = (
+        <PlaceholderDSCard />
+      );
+    } else {
+      heroCard = (
         <div className="ds-hero-item">
           <SafeAnchor
             className="wrapper"
             dispatch={this.props.dispatch}
             onLinkClick={this.onLinkClick}
             url={heroRec.url}>
             <div className="img-wrapper">
               <DSImage extraClassNames="img" source={heroRec.image_src} rawSource={heroRec.raw_image_src} />
@@ -103,16 +99,32 @@ export class Hero extends React.PureComp
             intl={this.props.intl}
             url={heroRec.url}
             title={heroRec.title}
             source={heroRec.domain}
             type={this.props.type}
             pocket_id={heroRec.pocket_id}
             bookmarkGuid={heroRec.bookmarkGuid} />
         </div>
+      );
+    }
+
+    let list = (
+      <List
+        recStartingPoint={1}
+        data={this.props.data}
+        hasImages={true}
+        hasBorders={this.props.border === `border`}
+        items={this.props.items - 1}
+        type={`Hero`} />
+    );
+
+    return (
+      <div className={`ds-hero ds-hero-${this.props.border}`}>
+        {heroCard}
         <div className={`${this.props.subComponentType}`}>
           { this.props.subComponentType === `cards` ? cards : list }
         </div>
       </div>
     );
   }
 
   render() {
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
@@ -87,34 +87,34 @@ export const PlaceholderListItem = props
  */
 export function _List(props) {
   const renderList = () => {
     const recs = props.data.recommendations.slice(props.recStartingPoint, props.recStartingPoint + props.items);
     const recMarkup = [];
 
     for (let index = 0; index < props.items; index++) {
       const rec = recs[index];
-      recMarkup.push(rec ? (
+      recMarkup.push(!rec || rec.placeholder ? (
+        <PlaceholderListItem key={`ds-list-item-${index}`} />
+      ) : (
         <ListItem key={`ds-list-item-${index}`}
         dispatch={props.dispatch}
         campaignId={rec.campaign_id}
         domain={rec.domain}
         excerpt={rec.excerpt}
         id={rec.id}
         image_src={rec.image_src}
         raw_image_src={rec.raw_image_src}
         pos={rec.pos}
         title={rec.title}
         context={rec.context}
         type={props.type}
         url={rec.url}
         pocket_id={rec.pocket_id}
         bookmarkGuid={rec.bookmarkGuid} />
-      ) : (
-        <PlaceholderListItem key={`ds-list-item-${index}`} />
       ));
     }
 
     const listStyles = [
       "ds-list",
       props.fullWidth ? "ds-list-full-width" : "",
       props.hasBorders ? "ds-list-borders" : "",
       props.hasImages ? "ds-list-images" : "",
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
@@ -201,17 +201,20 @@
 
     display: flex;
     justify-content: space-between;
     height: 100%;
   }
 
   .ds-list-item-excerpt {
     @include limit-visibile-lines(2, $item-line-height, $item-font-size);
-    color: $grey-10-80;
+    @include dark-theme-only {
+      color: $grey-10-80;
+    }
+    color: $grey-50;
     margin: 4px 0 8px;
   }
 
   p {
     font-size: $item-font-size * 1px;
     line-height: $item-line-height * 1px;
     margin: 0;
   }
--- a/browser/components/newtab/content-src/lib/selectLayoutRender.js
+++ b/browser/components/newtab/content-src/lib/selectLayoutRender.js
@@ -34,88 +34,109 @@ export const selectLayoutRender = (state
     }
 
     return {
       ...data,
       recommendations,
     };
   }
 
-  function maybeInjectSpocs(data, spocsConfig) {
-    // Do we ever expect to possibly have a spoc.
-    if (data && spocsConfig && spocsConfig.positions && spocsConfig.positions.length) {
-      // We expect a spoc, spocs are loaded, but the server returned no spocs.
-      if (!spocs.data.spocs || !spocs.data.spocs.length) {
-        return data;
-      }
-
-      // We expect a spoc, spocs are loaded, and we have spocs available.
-      return rollForSpocs(data, spocsConfig);
-    }
-
-    return data;
-  }
-
   const positions = {};
   const DS_COMPONENTS = ["Message", "SectionTitle", "Navigation",
     "CardGrid", "Hero", "HorizontalRule", "List"];
 
   const filterArray = [];
 
   if (!prefs["feeds.topsites"]) {
     filterArray.push("TopSites");
   }
 
   if (!prefs["feeds.section.topstories"]) {
     filterArray.push(...DS_COMPONENTS);
   }
 
+  const placeholderComponent = component => {
+    const data = {
+      recommendations: [],
+    };
+
+    let items = 0;
+    if (component.properties && component.properties.items) {
+      items = component.properties.items;
+    }
+    for (let i = 0; i < items; i++) {
+      data.recommendations.push({"placeholder": true});
+    }
+
+    return {...component, data};
+  };
+
   const handleComponent = component => {
     positions[component.type] = positions[component.type] || 0;
 
-    let {data} = feeds.data[component.feed.url];
+    const feed = feeds.data[component.feed.url];
+    let data = {
+      recommendations: [],
+    };
+    if (feed && feed.data) {
+      data = {
+        ...feed.data,
+        recommendations: [...feed.data.recommendations],
+      };
+    }
 
     if (component && component.properties && component.properties.offset) {
       data = {
         ...data,
         recommendations: data.recommendations.slice(component.properties.offset),
       };
     }
 
-    data = maybeInjectSpocs(data, component.spocs);
+    // Do we ever expect to possibly have a spoc.
+    if (data && component.spocs && component.spocs.positions && component.spocs.positions.length) {
+      // We expect a spoc, spocs are loaded, and the server returned spocs.
+      if (spocs.loaded && spocs.data.spocs && spocs.data.spocs.length) {
+        data = rollForSpocs(data, component.spocs);
+      }
+    }
 
     let items = 0;
     if (component.properties && component.properties.items) {
       items = Math.min(component.properties.items, data.recommendations.length);
     }
 
     // loop through a component items
     // Store the items position sequentially for multiple components of the same type.
     // Example: A second card grid starts pos offset from the last card grid.
     for (let i = 0; i < items; i++) {
-      data.recommendations[i].pos = positions[component.type]++;
+      data.recommendations[i] = {
+        ...data.recommendations[i],
+        pos: positions[component.type]++,
+      };
     }
 
     return {...component, data};
   };
 
   const renderLayout = () => {
     const renderedLayoutArray = [];
     for (const row of layout.filter(r => r.components.filter(c => !filterArray.includes(c.type)).length)) {
       let components = [];
       renderedLayoutArray.push({
         ...row,
         components,
       });
-      for (const component of row.components) {
+      for (const component of row.components.filter(c => !filterArray.includes(c.type))) {
         if (component.feed) {
           const spocsConfig = component.spocs;
-          // Are we still waiting on a feed/spocs, render what we have, and bail out early.
+          // Are we still waiting on a feed/spocs, render what we have,
+          // add a placeholder for this component, and bail out early.
           if (!feeds.data[component.feed.url] ||
             (spocsConfig && spocsConfig.positions && spocsConfig.positions.length && !spocs.loaded)) {
+            components.push(placeholderComponent(component));
             return renderedLayoutArray;
           }
           components.push(handleComponent(component));
         } else {
           components.push(component);
         }
       }
     }
--- a/browser/components/newtab/contributing.md
+++ b/browser/components/newtab/contributing.md
@@ -54,16 +54,32 @@ You have identified the bug, written cod
 All code is added using a pull request against the `master` branch of our repo.  Before submitting a PR, please go through this checklist:
 - all [unit tests](#unit-tests) must pass
 - if you haven't written unit tests for your patch, eyebrows will be curmudgeonly furrowed (write unit tests!)
 - if your pull request fixes a particular ticket (it does, right?), please use the `fixes #nnn` github annotation to indicate this
 - please add a `PR / Needs review` tag to your PR (if you have permission).  This starts the code review process.  If you cannot add a tag, don't worry, we will add it during triage.
 - if you can pick a module owner to be your reviewer by including `r? @username` in the comment (if not, don't worry, we will assign a reviewer)
 - make sure your PR will merge gracefully with `master` at the time you create the PR, and that your commit history is 'clean'
 
+### Setting up pre-push hooks
+
+If you contribute often and would like to set up a pre-push hook to always run `npm lint` before you push to Github,
+you can run the following from the root of the activity-stream directory:
+
+```
+cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
+```
+
+Your hook should now run whenever you run `git push`. To skip it, use the `--no-verify` option:
+
+```
+git push --no-verify
+```
+
+
 ## Code Reviews ##
 
 You have created a PR and submitted it to the repo, and now are waiting patiently for you code review feedback.  One of the projects
 module owners will be along and will either:
 - make suggestions for some improvements
 - give you an `R+` in the comments section, indicating the review is done and the code can be merged
 
 Typically, you will iterate on the PR, making changes and pushing your changes to new commits on the PR.  When the reviewer is
--- a/browser/components/newtab/css/activity-stream-linux.css
+++ b/browser/components/newtab/css/activity-stream-linux.css
@@ -2376,18 +2376,20 @@ main {
   .ds-list-item .ds-list-item-excerpt {
     -webkit-box-orient: vertical;
     display: -webkit-box;
     font-size: 14px;
     -webkit-line-clamp: 2;
     line-height: 20px;
     max-height: 2.85714em;
     overflow: hidden;
-    color: rgba(249, 249, 250, 0.8);
+    color: #737373;
     margin: 4px 0 8px; }
+    [lwt-newtab-brighttext] .ds-list-item .ds-list-item-excerpt {
+      color: rgba(249, 249, 250, 0.8); }
   .ds-list-item p {
     font-size: 14px;
     line-height: 20px;
     margin: 0; }
   .ds-list-item .ds-list-item-info,
   .ds-list-item .ds-list-item-context {
     -webkit-box-orient: vertical;
     display: -webkit-box;
@@ -2879,16 +2881,26 @@ main {
   background: rgba(215, 215, 219, 0.6);
   text-align: center;
   position: absolute;
   top: 0;
   width: 100%; }
   .snippets-preview-banner span {
     vertical-align: middle; }
 
+body:not([lwt-newtab-brighttext]) .icon-dark-theme,
+body:not([lwt-newtab-brighttext]) .icon.icon-dark-theme,
+body:not([lwt-newtab-brighttext]) .scene2Icon .icon-dark-theme {
+  display: none; }
+
+body[lwt-newtab-brighttext] .icon-light-theme,
+body[lwt-newtab-brighttext] .icon.icon-light-theme,
+body[lwt-newtab-brighttext] .scene2Icon .icon-light-theme {
+  display: none; }
+
 .activity-stream.modal-open {
   overflow: hidden; }
 
 .modalOverlayOuter {
   background: var(--newtab-overlay-color);
   height: 100%;
   position: fixed;
   top: 0;
@@ -3982,30 +3994,30 @@ a.firstrun-link {
     margin: 0 0 60px;
     color: var(--newtab-text-conditional-color);
     line-height: 1.5;
     font-weight: 200; }
   .trailheadCard .onboardingButton {
     color: var(--newtab-text-conditional-color);
     background: var(--trailhead-card-button-background-color);
     border: 0;
-    height: 30px;
+    margin: 14px;
     min-width: 70%;
-    padding: 0 14px; }
+    padding: 6px 14px;
+    white-space: pre-wrap; }
     .trailheadCard .onboardingButton:focus, .trailheadCard .onboardingButton:hover {
       box-shadow: none;
       background: var(--trailhead-card-button-background-hover-color); }
     .trailheadCard .onboardingButton:focus {
       outline: dotted 1px; }
     .trailheadCard .onboardingButton:active {
       background: var(--trailhead-card-button-background-active-color); }
   .trailheadCard .onboardingButtonContainer {
-    height: 60px;
     position: absolute;
-    bottom: 0;
+    bottom: 16px;
     left: 0;
     width: 100%;
     text-align: center; }
 
 .inline-onboarding.activity-stream.welcome {
   overflow-y: scroll; }
 
 .inline-onboarding .modalOverlayInner {
--- a/browser/components/newtab/css/activity-stream-mac.css
+++ b/browser/components/newtab/css/activity-stream-mac.css
@@ -2379,18 +2379,20 @@ main {
   .ds-list-item .ds-list-item-excerpt {
     -webkit-box-orient: vertical;
     display: -webkit-box;
     font-size: 14px;
     -webkit-line-clamp: 2;
     line-height: 20px;
     max-height: 2.85714em;
     overflow: hidden;
-    color: rgba(249, 249, 250, 0.8);
+    color: #737373;
     margin: 4px 0 8px; }
+    [lwt-newtab-brighttext] .ds-list-item .ds-list-item-excerpt {
+      color: rgba(249, 249, 250, 0.8); }
   .ds-list-item p {
     font-size: 14px;
     line-height: 20px;
     margin: 0; }
   .ds-list-item .ds-list-item-info,
   .ds-list-item .ds-list-item-context {
     -webkit-box-orient: vertical;
     display: -webkit-box;
@@ -2882,16 +2884,26 @@ main {
   background: rgba(215, 215, 219, 0.6);
   text-align: center;
   position: absolute;
   top: 0;
   width: 100%; }
   .snippets-preview-banner span {
     vertical-align: middle; }
 
+body:not([lwt-newtab-brighttext]) .icon-dark-theme,
+body:not([lwt-newtab-brighttext]) .icon.icon-dark-theme,
+body:not([lwt-newtab-brighttext]) .scene2Icon .icon-dark-theme {
+  display: none; }
+
+body[lwt-newtab-brighttext] .icon-light-theme,
+body[lwt-newtab-brighttext] .icon.icon-light-theme,
+body[lwt-newtab-brighttext] .scene2Icon .icon-light-theme {
+  display: none; }
+
 .activity-stream.modal-open {
   overflow: hidden; }
 
 .modalOverlayOuter {
   background: var(--newtab-overlay-color);
   height: 100%;
   position: fixed;
   top: 0;
@@ -3985,30 +3997,30 @@ a.firstrun-link {
     margin: 0 0 60px;
     color: var(--newtab-text-conditional-color);
     line-height: 1.5;
     font-weight: 200; }
   .trailheadCard .onboardingButton {
     color: var(--newtab-text-conditional-color);
     background: var(--trailhead-card-button-background-color);
     border: 0;
-    height: 30px;
+    margin: 14px;
     min-width: 70%;
-    padding: 0 14px; }
+    padding: 6px 14px;
+    white-space: pre-wrap; }
     .trailheadCard .onboardingButton:focus, .trailheadCard .onboardingButton:hover {
       box-shadow: none;
       background: var(--trailhead-card-button-background-hover-color); }
     .trailheadCard .onboardingButton:focus {
       outline: dotted 1px; }
     .trailheadCard .onboardingButton:active {
       background: var(--trailhead-card-button-background-active-color); }
   .trailheadCard .onboardingButtonContainer {
-    height: 60px;
     position: absolute;
-    bottom: 0;
+    bottom: 16px;
     left: 0;
     width: 100%;
     text-align: center; }
 
 .inline-onboarding.activity-stream.welcome {
   overflow-y: scroll; }
 
 .inline-onboarding .modalOverlayInner {
--- a/browser/components/newtab/css/activity-stream-windows.css
+++ b/browser/components/newtab/css/activity-stream-windows.css
@@ -2376,18 +2376,20 @@ main {
   .ds-list-item .ds-list-item-excerpt {
     -webkit-box-orient: vertical;
     display: -webkit-box;
     font-size: 14px;
     -webkit-line-clamp: 2;
     line-height: 20px;
     max-height: 2.85714em;
     overflow: hidden;
-    color: rgba(249, 249, 250, 0.8);
+    color: #737373;
     margin: 4px 0 8px; }
+    [lwt-newtab-brighttext] .ds-list-item .ds-list-item-excerpt {
+      color: rgba(249, 249, 250, 0.8); }
   .ds-list-item p {
     font-size: 14px;
     line-height: 20px;
     margin: 0; }
   .ds-list-item .ds-list-item-info,
   .ds-list-item .ds-list-item-context {
     -webkit-box-orient: vertical;
     display: -webkit-box;
@@ -2879,16 +2881,26 @@ main {
   background: rgba(215, 215, 219, 0.6);
   text-align: center;
   position: absolute;
   top: 0;
   width: 100%; }
   .snippets-preview-banner span {
     vertical-align: middle; }
 
+body:not([lwt-newtab-brighttext]) .icon-dark-theme,
+body:not([lwt-newtab-brighttext]) .icon.icon-dark-theme,
+body:not([lwt-newtab-brighttext]) .scene2Icon .icon-dark-theme {
+  display: none; }
+
+body[lwt-newtab-brighttext] .icon-light-theme,
+body[lwt-newtab-brighttext] .icon.icon-light-theme,
+body[lwt-newtab-brighttext] .scene2Icon .icon-light-theme {
+  display: none; }
+
 .activity-stream.modal-open {
   overflow: hidden; }
 
 .modalOverlayOuter {
   background: var(--newtab-overlay-color);
   height: 100%;
   position: fixed;
   top: 0;
@@ -3982,30 +3994,30 @@ a.firstrun-link {
     margin: 0 0 60px;
     color: var(--newtab-text-conditional-color);
     line-height: 1.5;
     font-weight: 200; }
   .trailheadCard .onboardingButton {
     color: var(--newtab-text-conditional-color);
     background: var(--trailhead-card-button-background-color);
     border: 0;
-    height: 30px;
+    margin: 14px;
     min-width: 70%;
-    padding: 0 14px; }
+    padding: 6px 14px;
+    white-space: pre-wrap; }
     .trailheadCard .onboardingButton:focus, .trailheadCard .onboardingButton:hover {
       box-shadow: none;
       background: var(--trailhead-card-button-background-hover-color); }
     .trailheadCard .onboardingButton:focus {
       outline: dotted 1px; }
     .trailheadCard .onboardingButton:active {
       background: var(--trailhead-card-button-background-active-color); }
   .trailheadCard .onboardingButtonContainer {
-    height: 60px;
     position: absolute;
-    bottom: 0;
+    bottom: 16px;
     left: 0;
     width: 100%;
     text-align: center; }
 
 .inline-onboarding.activity-stream.welcome {
   overflow-y: scroll; }
 
 .inline-onboarding .modalOverlayInner {
--- a/browser/components/newtab/data/content/activity-stream.bundle.js
+++ b/browser/components/newtab/data/content/activity-stream.bundle.js
@@ -1337,17 +1337,17 @@ class ASRouterAdminInner extends react__
 
   renderMessageFilter() {
     if (!this.state.providers) {
       return null;
     }
 
     return react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("p", null, "Show messages from ", react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("select", {
       value: this.state.messageFilter,
-      onChange: this.onChangeMessageFilter
+      onBlur: this.onChangeMessageFilter
     }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("option", {
       value: "all"
     }, "all providers"), this.state.providers.map(provider => react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("option", {
       key: provider.id,
       value: provider.id
     }, provider.id))));
   }
 
@@ -1434,17 +1434,16 @@ class ASRouterAdminInner extends react__
       title: "New targeting parameters",
       button_label: errors ? "Cancel" : "Done",
       onDoneButton: this.onPasteTargetingParams
     }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("div", {
       className: "onboardingMessage"
     }, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("p", null, react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("textarea", {
       onChange: this.onNewTargetingParams,
       value: this.state.newStringTargetingParameters,
-      autoFocus: true,
       rows: "20",
       cols: "60"
     })), react__WEBPACK_IMPORTED_MODULE_4___default.a.createElement("p", {
       ref: "targetingParamsEval"
     })));
   }
 
   renderTargetingParameters() {
@@ -2782,17 +2781,19 @@ module.exports = ReactDOM;
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ReturnToAMO", function() { return ReturnToAMO; });
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(11);
 /* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);
 /* harmony import */ var _components_RichText_RichText__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(18);
 
-
+ // Alt text if available; in the future this should come from the server. See bug 1551711
+
+const ICON_ALT_TEXT = "";
 class ReturnToAMO extends react__WEBPACK_IMPORTED_MODULE_0___default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onClickAddExtension = this.onClickAddExtension.bind(this);
     this.onBlockButton = this.onBlockButton.bind(this);
   }
 
   componentDidMount() {
@@ -2819,17 +2820,18 @@ class ReturnToAMO extends react__WEBPACK
       id: this.props.UISurface
     });
   }
 
   renderText() {
     const customElement = react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("img", {
       src: this.props.content.addon_icon,
       width: "20px",
-      height: "20px"
+      height: "20px",
+      alt: ICON_ALT_TEXT
     });
     return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement(_components_RichText_RichText__WEBPACK_IMPORTED_MODULE_1__["RichText"], {
       customElements: {
         icon: customElement
       },
       amo_html: this.props.content.text,
       localization_id: "amo_html"
     });
@@ -2899,17 +2901,19 @@ function convertLinks(links, sendClick, 
   if (links) {
     return Object.keys(links).reduce((acc, linkTag) => {
       const {
         action
       } = links[linkTag]; // Setting the value to false will not include the attribute in the anchor
 
       const url = action ? false : Object(_template_utils__WEBPACK_IMPORTED_MODULE_3__["safeURI"])(links[linkTag].url);
       acc[linkTag] = react__WEBPACK_IMPORTED_MODULE_1___default.a.createElement("a", {
-        href: url,
+        href: url // eslint-disable-line jsx-a11y/anchor-has-content
+        // eslint was getting a false positive caused by the dynamic injection of content.
+        ,
         target: openNewWindow ? "_blank" : "",
         "data-metric": links[linkTag].metric,
         "data-action": action,
         "data-args": links[linkTag].args,
         "data-do_not_autoblock": doNotAutoBlock,
         onClick: sendClick
       });
       return acc;
@@ -2955,41 +2959,41 @@ function safeURI(url) {
 
   return isAllowed ? url : "";
 }
 
 /***/ }),
 /* 20 */
 /***/ (function(module) {
 
-module.exports = {"title":"EOYSnippet","description":"Fundraising Snippet","version":"1.0.0","type":"object","definitions":{"plainText":{"description":"Plain text (no HTML allowed)","type":"string"},"richText":{"description":"Text with HTML subset allowed: i, b, u, strong, em, br","type":"string"},"link_url":{"description":"Target for links or buttons","type":"string","format":"uri"}},"properties":{"donation_form_url":{"type":"string","description":"Url to the donation form."},"currency_code":{"type":"string","description":"The code for the currency. Examle gbp, cad, usd.","default":"usd"},"locale":{"type":"string","description":"String for the locale code.","default":"en-US"},"text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"text_color":{"type":"string","description":"Modify the text message color"},"background_color":{"type":"string","description":"Snippet background color."},"highlight_color":{"type":"string","description":"Paragraph em highlight color."},"donation_amount_first":{"type":"number","description":"First button amount."},"donation_amount_second":{"type":"number","description":"Second button amount."},"donation_amount_third":{"type":"number","description":"Third button amount."},"donation_amount_fourth":{"type":"number","description":"Fourth button amount."},"selected_button":{"type":"string","description":"Default donation_amount_second. Donation amount button that's selected by default.","default":"donation_amount_second"},"icon":{"type":"string","description":"Snippet icon. 64x64px. SVG or PNG preferred."},"title":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Snippet title displayed before snippet text"}]},"title_icon":{"type":"string","description":"Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."},"button_label":{"allOf":[{"$ref":"#/definitions/plainText"},{"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."},"block_button_text":{"type":"string","description":"Tooltip text used for dismiss button."},"monthly_checkbox_label_text":{"type":"string","description":"Label text for monthly checkbox.","default":"Make my donation monthly"},"test":{"type":"string","description":"Different styles for the snippet. Options are bold and takeover."},"do_not_autoblock":{"type":"boolean","description":"Used to prevent blocking the snippet after the CTA (link or button) has been clicked"},"links":{"additionalProperties":{"url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"The url where the link points to."}]},"metric":{"type":"string","description":"Custom event name sent with telemetry event."},"args":{"type":"string","description":"Additional parameters for link action, example which specific menu the button should open"}}}},"additionalProperties":false,"required":["text","donation_form_url","donation_amount_first","donation_amount_second","donation_amount_third","donation_amount_fourth","button_label","currency_code"],"dependencies":{"button_color":["button_label"],"button_background_color":["button_label"]}};
+module.exports = {"title":"EOYSnippet","description":"Fundraising Snippet","version":"1.1.0","type":"object","definitions":{"plainText":{"description":"Plain text (no HTML allowed)","type":"string"},"richText":{"description":"Text with HTML subset allowed: i, b, u, strong, em, br","type":"string"},"link_url":{"description":"Target for links or buttons","type":"string","format":"uri"}},"properties":{"donation_form_url":{"type":"string","description":"Url to the donation form."},"currency_code":{"type":"string","description":"The code for the currency. Examle gbp, cad, usd.","default":"usd"},"locale":{"type":"string","description":"String for the locale code.","default":"en-US"},"text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"text_color":{"type":"string","description":"Modify the text message color"},"background_color":{"type":"string","description":"Snippet background color."},"highlight_color":{"type":"string","description":"Paragraph em highlight color."},"donation_amount_first":{"type":"number","description":"First button amount."},"donation_amount_second":{"type":"number","description":"Second button amount."},"donation_amount_third":{"type":"number","description":"Third button amount."},"donation_amount_fourth":{"type":"number","description":"Fourth button amount."},"selected_button":{"type":"string","description":"Default donation_amount_second. Donation amount button that's selected by default.","default":"donation_amount_second"},"icon":{"type":"string","description":"Snippet icon. 64x64px. SVG or PNG preferred."},"icon_dark_theme":{"type":"string","description":"Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."},"title":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Snippet title displayed before snippet text"}]},"title_icon":{"type":"string","description":"Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."},"title_icon_dark_theme":{"type":"string","description":"Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."},"button_label":{"allOf":[{"$ref":"#/definitions/plainText"},{"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."},"block_button_text":{"type":"string","description":"Tooltip text used for dismiss button."},"monthly_checkbox_label_text":{"type":"string","description":"Label text for monthly checkbox.","default":"Make my donation monthly"},"test":{"type":"string","description":"Different styles for the snippet. Options are bold and takeover."},"do_not_autoblock":{"type":"boolean","description":"Used to prevent blocking the snippet after the CTA (link or button) has been clicked"},"links":{"additionalProperties":{"url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"The url where the link points to."}]},"metric":{"type":"string","description":"Custom event name sent with telemetry event."},"args":{"type":"string","description":"Additional parameters for link action, example which specific menu the button should open"}}}},"additionalProperties":false,"required":["text","donation_form_url","donation_amount_first","donation_amount_second","donation_amount_third","donation_amount_fourth","button_label","currency_code"],"dependencies":{"button_color":["button_label"],"button_background_color":["button_label"]}};
 
 /***/ }),
 /* 21 */
 /***/ (function(module) {
 
-module.exports = {"title":"SimpleSnippet","description":"A simple template with an icon, text, and optional button.","version":"1.1.1","type":"object","definitions":{"plainText":{"description":"Plain text (no HTML allowed)","type":"string"},"richText":{"description":"Text with HTML subset allowed: i, b, u, strong, em, br","type":"string"},"link_url":{"description":"Target for links or buttons","type":"string","format":"uri"}},"properties":{"title":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Snippet title displayed before snippet text"}]},"text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"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_action":{"type":"string","description":"The type of action the button should trigger."},"button_url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"A url, button_label links to this"}]},"button_action_args":{"type":"string","description":"Additional parameters for button action, example which specific menu the button should open"},"button_label":{"allOf":[{"$ref":"#/definitions/plainText"},{"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."},"block_button_text":{"type":"string","description":"Tooltip text used for dismiss button.","default":"Remove this"},"tall":{"type":"boolean","description":"To be used by fundraising only, increases height to roughly 120px. Defaults to false."},"do_not_autoblock":{"type":"boolean","description":"Used to prevent blocking the snippet after the CTA (link or button) has been clicked"},"links":{"additionalProperties":{"url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"The url where the link points to."}]},"metric":{"type":"string","description":"Custom event name sent with telemetry event."},"args":{"type":"string","description":"Additional parameters for link action, example which specific menu the button should open"}}},"section_title_icon":{"type":"string","description":"Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."},"section_title_text":{"type":"string","description":"Section title text. section_title_icon must also be specified to display."},"section_title_url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"A url, section_title_text links to this"}]}},"additionalProperties":false,"required":["text"],"dependencies":{"button_action":["button_label"],"button_url":["button_label"],"button_color":["button_label"],"button_background_color":["button_label"],"section_title_url":["section_title_text"]}};
+module.exports = {"title":"SimpleSnippet","description":"A simple template with an icon, text, and optional button.","version":"1.1.1","type":"object","definitions":{"plainText":{"description":"Plain text (no HTML allowed)","type":"string"},"richText":{"description":"Text with HTML subset allowed: i, b, u, strong, em, br","type":"string"},"link_url":{"description":"Target for links or buttons","type":"string","format":"uri"}},"properties":{"title":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Snippet title displayed before snippet text"}]},"text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"icon":{"type":"string","description":"Snippet icon. 64x64px. SVG or PNG preferred."},"icon_dark_theme":{"type":"string","description":"Snippet icon, dark theme variant. 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."},"title_icon_dark_theme":{"type":"string","description":"Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."},"button_action":{"type":"string","description":"The type of action the button should trigger."},"button_url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"A url, button_label links to this"}]},"button_action_args":{"type":"string","description":"Additional parameters for button action, example which specific menu the button should open"},"button_label":{"allOf":[{"$ref":"#/definitions/plainText"},{"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."},"block_button_text":{"type":"string","description":"Tooltip text used for dismiss button.","default":"Remove this"},"tall":{"type":"boolean","description":"To be used by fundraising only, increases height to roughly 120px. Defaults to false."},"do_not_autoblock":{"type":"boolean","description":"Used to prevent blocking the snippet after the CTA (link or button) has been clicked"},"links":{"additionalProperties":{"url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"The url where the link points to."}]},"metric":{"type":"string","description":"Custom event name sent with telemetry event."},"args":{"type":"string","description":"Additional parameters for link action, example which specific menu the button should open"}}},"section_title_icon":{"type":"string","description":"Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."},"section_title_icon_dark_theme":{"type":"string","description":"Section title icon, dark theme variant. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."},"section_title_text":{"type":"string","description":"Section title text. section_title_icon must also be specified to display."},"section_title_url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"A url, section_title_text links to this"}]}},"additionalProperties":false,"required":["text"],"dependencies":{"button_action":["button_label"],"button_url":["button_label"],"button_color":["button_label"],"button_background_color":["button_label"],"section_title_url":["section_title_text"]}};
 
 /***/ }),
 /* 22 */
 /***/ (function(module) {
 
-module.exports = {"title":"FXASignupSnippet","description":"A snippet template for FxA sign up/sign in","version":"1.0.0","type":"object","definitions":{"plainText":{"description":"Plain text (no HTML allowed)","type":"string"},"richText":{"description":"Text with HTML subset allowed: i, b, u, strong, em, br","type":"string"},"link_url":{"description":"Target for links or buttons","type":"string","format":"uri"}},"properties":{"scene1_title":{"allof":[{"$ref":"#/definitions/plainText"},{"description":"snippet title displayed before snippet text"}]},"scene1_text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"scene2_title":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Title displayed before text in scene 2. Should be plain text."}]},"scene2_text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"scene1_icon":{"type":"string","description":"Snippet icon. 64x64px. SVG or PNG preferred."},"scene1_title_icon":{"type":"string","description":"Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."},"scene2_email_placeholder_text":{"type":"string","description":"Value to show while input is empty.","default":"Your email here"},"scene2_button_label":{"type":"string","description":"Label for form submit button","default":"Sign me up"},"scene2_dismiss_button_text":{"type":"string","description":"Label for the dismiss button when the sign-up form is expanded.","default":"Dismiss"},"hidden_inputs":{"type":"object","description":"Each entry represents a hidden input, key is used as value for the name property.","properties":{"action":{"type":"string","enum":["email"]},"context":{"type":"string","enum":["fx_desktop_v3"]},"entrypoint":{"type":"string","enum":["snippets"]},"service":{"type":"string","enum":["sync"]},"utm_content":{"type":"number","description":"Firefox version number"},"utm_source":{"type":"string","enum":["snippet"]},"utm_campaign":{"type":"string","description":"(fxa) Value to pass through to GA as utm_campaign."},"utm_term":{"type":"string","description":"(fxa) Value to pass through to GA as utm_term."},"additionalProperties":false}},"scene1_button_label":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Text for a button next to main snippet text that links to button_url. Requires button_url."}],"default":"Learn more"},"scene1_button_color":{"type":"string","description":"The text color of the button. Valid CSS color."},"scene1_button_background_color":{"type":"string","description":"The background color of the button. Valid CSS color."},"do_not_autoblock":{"type":"boolean","description":"Used to prevent blocking the snippet after the CTA (link or button) has been clicked","default":false},"utm_campaign":{"type":"string","description":"(fxa) Value to pass through to GA as utm_campaign."},"utm_term":{"type":"string","description":"(fxa) Value to pass through to GA as utm_term."},"links":{"additionalProperties":{"url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"The url where the link points to."}]},"metric":{"type":"string","description":"Custom event name sent with telemetry event."}}}},"additionalProperties":false,"required":["scene1_text","scene2_text","scene1_button_label"],"dependencies":{"scene1_button_color":["scene1_button_label"],"scene1_button_background_color":["scene1_button_label"]}};
+module.exports = {"title":"FXASignupSnippet","description":"A snippet template for FxA sign up/sign in","version":"1.1.0","type":"object","definitions":{"plainText":{"description":"Plain text (no HTML allowed)","type":"string"},"richText":{"description":"Text with HTML subset allowed: i, b, u, strong, em, br","type":"string"},"link_url":{"description":"Target for links or buttons","type":"string","format":"uri"}},"properties":{"scene1_title":{"allof":[{"$ref":"#/definitions/plainText"},{"description":"snippet title displayed before snippet text"}]},"scene1_text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"scene2_title":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Title displayed before text in scene 2. Should be plain text."}]},"scene2_text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"scene1_icon":{"type":"string","description":"Snippet icon. 64x64px. SVG or PNG preferred."},"scene1_icon_dark_theme":{"type":"string","description":"Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."},"scene1_title_icon":{"type":"string","description":"Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."},"scene1_title_icon_dark_theme":{"type":"string","description":"Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."},"scene2_email_placeholder_text":{"type":"string","description":"Value to show while input is empty.","default":"Your email here"},"scene2_button_label":{"type":"string","description":"Label for form submit button","default":"Sign me up"},"scene2_dismiss_button_text":{"type":"string","description":"Label for the dismiss button when the sign-up form is expanded.","default":"Dismiss"},"hidden_inputs":{"type":"object","description":"Each entry represents a hidden input, key is used as value for the name property.","properties":{"action":{"type":"string","enum":["email"]},"context":{"type":"string","enum":["fx_desktop_v3"]},"entrypoint":{"type":"string","enum":["snippets"]},"service":{"type":"string","enum":["sync"]},"utm_content":{"type":"number","description":"Firefox version number"},"utm_source":{"type":"string","enum":["snippet"]},"utm_campaign":{"type":"string","description":"(fxa) Value to pass through to GA as utm_campaign."},"utm_term":{"type":"string","description":"(fxa) Value to pass through to GA as utm_term."},"additionalProperties":false}},"scene1_button_label":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Text for a button next to main snippet text that links to button_url. Requires button_url."}],"default":"Learn more"},"scene1_button_color":{"type":"string","description":"The text color of the button. Valid CSS color."},"scene1_button_background_color":{"type":"string","description":"The background color of the button. Valid CSS color."},"do_not_autoblock":{"type":"boolean","description":"Used to prevent blocking the snippet after the CTA (link or button) has been clicked","default":false},"utm_campaign":{"type":"string","description":"(fxa) Value to pass through to GA as utm_campaign."},"utm_term":{"type":"string","description":"(fxa) Value to pass through to GA as utm_term."},"links":{"additionalProperties":{"url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"The url where the link points to."}]},"metric":{"type":"string","description":"Custom event name sent with telemetry event."}}}},"additionalProperties":false,"required":["scene1_text","scene2_text","scene1_button_label"],"dependencies":{"scene1_button_color":["scene1_button_label"],"scene1_button_background_color":["scene1_button_label"]}};
 
 /***/ }),
 /* 23 */
 /***/ (function(module) {
 
-module.exports = {"title":"NewsletterSnippet","description":"A snippet template for send to device mobile download","version":"1.0.0","type":"object","definitions":{"plainText":{"description":"Plain text (no HTML allowed)","type":"string"},"richText":{"description":"Text with HTML subset allowed: i, b, u, strong, em, br","type":"string"},"link_url":{"description":"Target for links or buttons","type":"string","format":"uri"}},"properties":{"locale":{"type":"string","description":"Two to five character string for the locale code","default":"en-US"},"scene1_title":{"allof":[{"$ref":"#/definitions/plainText"},{"description":"snippet title displayed before snippet text"}]},"scene1_text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"scene2_title":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Title displayed before text in scene 2. Should be plain text."}]},"scene2_text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"scene1_icon":{"type":"string","description":"Snippet icon. 64x64px. SVG or PNG preferred."},"scene1_title_icon":{"type":"string","description":"Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."},"scene2_email_placeholder_text":{"type":"string","description":"Value to show while input is empty.","default":"Your email here"},"scene2_button_label":{"type":"string","description":"Label for form submit button","default":"Sign me up"},"scene2_privacy_html":{"type":"string","description":"(send to device) Html for disclaimer and link underneath input box."},"scene2_dismiss_button_text":{"type":"string","description":"Label for the dismiss button when the sign-up form is expanded.","default":"Dismiss"},"hidden_inputs":{"type":"object","description":"Each entry represents a hidden input, key is used as value for the name property.","properties":{"fmt":{"type":"string","description":"","default":"H"}}},"scene1_button_label":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Text for a button next to main snippet text that links to button_url. Requires button_url."}],"default":"Learn more"},"scene1_button_color":{"type":"string","description":"The text color of the button. Valid CSS color."},"scene1_button_background_color":{"type":"string","description":"The background color of the button. Valid CSS color."},"do_not_autoblock":{"type":"boolean","description":"Used to prevent blocking the snippet after the CTA (link or button) has been clicked","default":false},"success_text":{"type":"string","description":"Message shown on successful registration."},"error_text":{"type":"string","description":"Message shown if registration failed."},"scene2_newsletter":{"type":"string","description":"Newsletter/basket id user is subscribing to.","default":"mozilla-foundation"},"links":{"additionalProperties":{"url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"The url where the link points to."}]},"metric":{"type":"string","description":"Custom event name sent with telemetry event."}}}},"additionalProperties":false,"required":["scene1_text","scene2_text","scene1_button_label"],"dependencies":{"scene1_button_color":["scene1_button_label"],"scene1_button_background_color":["scene1_button_label"]}};
+module.exports = {"title":"NewsletterSnippet","description":"A snippet template for send to device mobile download","version":"1.1.0","type":"object","definitions":{"plainText":{"description":"Plain text (no HTML allowed)","type":"string"},"richText":{"description":"Text with HTML subset allowed: i, b, u, strong, em, br","type":"string"},"link_url":{"description":"Target for links or buttons","type":"string","format":"uri"}},"properties":{"locale":{"type":"string","description":"Two to five character string for the locale code","default":"en-US"},"scene1_title":{"allof":[{"$ref":"#/definitions/plainText"},{"description":"snippet title displayed before snippet text"}]},"scene1_text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"scene2_title":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Title displayed before text in scene 2. Should be plain text."}]},"scene2_text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"scene1_icon":{"type":"string","description":"Snippet icon. 64x64px. SVG or PNG preferred."},"scene1_icon_dark_theme":{"type":"string","description":"Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."},"scene1_title_icon":{"type":"string","description":"Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."},"scene1_title_icon_dark_theme":{"type":"string","description":"Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."},"scene2_email_placeholder_text":{"type":"string","description":"Value to show while input is empty.","default":"Your email here"},"scene2_button_label":{"type":"string","description":"Label for form submit button","default":"Sign me up"},"scene2_privacy_html":{"type":"string","description":"(send to device) Html for disclaimer and link underneath input box."},"scene2_dismiss_button_text":{"type":"string","description":"Label for the dismiss button when the sign-up form is expanded.","default":"Dismiss"},"hidden_inputs":{"type":"object","description":"Each entry represents a hidden input, key is used as value for the name property.","properties":{"fmt":{"type":"string","description":"","default":"H"}}},"scene1_button_label":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Text for a button next to main snippet text that links to button_url. Requires button_url."}],"default":"Learn more"},"scene1_button_color":{"type":"string","description":"The text color of the button. Valid CSS color."},"scene1_button_background_color":{"type":"string","description":"The background color of the button. Valid CSS color."},"do_not_autoblock":{"type":"boolean","description":"Used to prevent blocking the snippet after the CTA (link or button) has been clicked","default":false},"success_text":{"type":"string","description":"Message shown on successful registration."},"error_text":{"type":"string","description":"Message shown if registration failed."},"scene2_newsletter":{"type":"string","description":"Newsletter/basket id user is subscribing to.","default":"mozilla-foundation"},"links":{"additionalProperties":{"url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"The url where the link points to."}]},"metric":{"type":"string","description":"Custom event name sent with telemetry event."}}}},"additionalProperties":false,"required":["scene1_text","scene2_text","scene1_button_label"],"dependencies":{"scene1_button_color":["scene1_button_label"],"scene1_button_background_color":["scene1_button_label"]}};
 
 /***/ }),
 /* 24 */
 /***/ (function(module) {
 
-module.exports = {"title":"SendToDeviceSnippet","description":"A snippet template for send to device mobile download","version":"1.0.0","type":"object","definitions":{"plainText":{"description":"Plain text (no HTML allowed)","type":"string"},"richText":{"description":"Text with HTML subset allowed: i, b, u, strong, em, br","type":"string"},"link_url":{"description":"Target for links or buttons","type":"string","format":"uri"}},"properties":{"locale":{"type":"string","description":"Two to five character string for the locale code","default":"en-US"},"country":{"type":"string","description":"Two character string for the country code (used for SMS)","default":"us"},"scene1_title":{"allof":[{"$ref":"#/definitions/plainText"},{"description":"snippet title displayed before snippet text"}]},"scene1_text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"scene2_title":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Title displayed before text in scene 2. Should be plain text."}]},"scene2_text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"scene1_icon":{"type":"string","description":"Snippet icon. 64x64px. SVG or PNG preferred."},"scene2_icon":{"type":"string","description":"(send to device) Image to display above the form. 98x98px. SVG or PNG preferred."},"scene1_title_icon":{"type":"string","description":"Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."},"scene2_button_label":{"type":"string","description":"Label for form submit button","default":"Send"},"scene2_input_placeholder":{"type":"string","description":"(send to device) Value to show while input is empty.","default":"Your email here"},"scene2_disclaimer_html":{"type":"string","description":"(send to device) Html for disclaimer and link underneath input box."},"scene2_dismiss_button_text":{"type":"string","description":"Label for the dismiss button when the sign-up form is expanded.","default":"Dismiss"},"hidden_inputs":{"type":"object","description":"Each entry represents a hidden input, key is used as value for the name property.","properties":{"action":{"type":"string","enum":["email"]},"context":{"type":"string","enum":["fx_desktop_v3"]},"entrypoint":{"type":"string","enum":["snippets"]},"service":{"type":"string","enum":["sync"]},"utm_content":{"type":"string","description":"Firefox version number"},"utm_source":{"type":"string","enum":["snippet"]},"utm_campaign":{"type":"string","description":"(fxa) Value to pass through to GA as utm_campaign."},"utm_term":{"type":"string","description":"(fxa) Value to pass through to GA as utm_term."},"additionalProperties":false}},"scene1_button_label":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Text for a button next to main snippet text that links to button_url. Requires button_url."}],"default":"Learn more"},"scene1_button_color":{"type":"string","description":"The text color of the button. Valid CSS color."},"scene1_button_background_color":{"type":"string","description":"The background color of the button. Valid CSS color."},"do_not_autoblock":{"type":"boolean","description":"Used to prevent blocking the snippet after the CTA (link or button) has been clicked","default":false},"success_title":{"type":"string","description":"(send to device) Title shown before text on successful registration."},"success_text":{"type":"string","description":"Message shown on successful registration."},"error_text":{"type":"string","description":"Message shown if registration failed."},"include_sms":{"type":"boolean","description":"(send to device) Allow users to send an SMS message with the form?","default":false},"message_id_sms":{"type":"string","description":"(send to device) Newsletter/basket id representing the SMS message to be sent."},"message_id_email":{"type":"string","description":"(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/."},"utm_campaign":{"type":"string","description":"(fxa) Value to pass through to GA as utm_campaign."},"utm_term":{"type":"string","description":"(fxa) Value to pass through to GA as utm_term."},"links":{"additionalProperties":{"url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"The url where the link points to."}]},"metric":{"type":"string","description":"Custom event name sent with telemetry event."}}}},"additionalProperties":false,"required":["scene1_text","scene2_text","scene1_button_label"],"dependencies":{"scene1_button_color":["scene1_button_label"],"scene1_button_background_color":["scene1_button_label"]}};
+module.exports = {"title":"SendToDeviceSnippet","description":"A snippet template for send to device mobile download","version":"1.1.0","type":"object","definitions":{"plainText":{"description":"Plain text (no HTML allowed)","type":"string"},"richText":{"description":"Text with HTML subset allowed: i, b, u, strong, em, br","type":"string"},"link_url":{"description":"Target for links or buttons","type":"string","format":"uri"}},"properties":{"locale":{"type":"string","description":"Two to five character string for the locale code","default":"en-US"},"country":{"type":"string","description":"Two character string for the country code (used for SMS)","default":"us"},"scene1_title":{"allof":[{"$ref":"#/definitions/plainText"},{"description":"snippet title displayed before snippet text"}]},"scene1_text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"scene2_title":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Title displayed before text in scene 2. Should be plain text."}]},"scene2_text":{"allOf":[{"$ref":"#/definitions/richText"},{"description":"Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}]},"scene1_icon":{"type":"string","description":"Snippet icon. 64x64px. SVG or PNG preferred."},"scene1_icon_dark_theme":{"type":"string","description":"Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."},"scene2_icon":{"type":"string","description":"(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred."},"scene2_icon_dark_theme":{"type":"string","description":"(send to device) Image to display above the form. 98x98px. SVG or PNG preferred."},"scene1_title_icon":{"type":"string","description":"Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."},"scene1_title_icon_dark_theme":{"type":"string","description":"Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."},"scene2_button_label":{"type":"string","description":"Label for form submit button","default":"Send"},"scene2_input_placeholder":{"type":"string","description":"(send to device) Value to show while input is empty.","default":"Your email here"},"scene2_disclaimer_html":{"type":"string","description":"(send to device) Html for disclaimer and link underneath input box."},"scene2_dismiss_button_text":{"type":"string","description":"Label for the dismiss button when the sign-up form is expanded.","default":"Dismiss"},"hidden_inputs":{"type":"object","description":"Each entry represents a hidden input, key is used as value for the name property.","properties":{"action":{"type":"string","enum":["email"]},"context":{"type":"string","enum":["fx_desktop_v3"]},"entrypoint":{"type":"string","enum":["snippets"]},"service":{"type":"string","enum":["sync"]},"utm_content":{"type":"string","description":"Firefox version number"},"utm_source":{"type":"string","enum":["snippet"]},"utm_campaign":{"type":"string","description":"(fxa) Value to pass through to GA as utm_campaign."},"utm_term":{"type":"string","description":"(fxa) Value to pass through to GA as utm_term."},"additionalProperties":false}},"scene1_button_label":{"allOf":[{"$ref":"#/definitions/plainText"},{"description":"Text for a button next to main snippet text that links to button_url. Requires button_url."}],"default":"Learn more"},"scene1_button_color":{"type":"string","description":"The text color of the button. Valid CSS color."},"scene1_button_background_color":{"type":"string","description":"The background color of the button. Valid CSS color."},"do_not_autoblock":{"type":"boolean","description":"Used to prevent blocking the snippet after the CTA (link or button) has been clicked","default":false},"success_title":{"type":"string","description":"(send to device) Title shown before text on successful registration."},"success_text":{"type":"string","description":"Message shown on successful registration."},"error_text":{"type":"string","description":"Message shown if registration failed."},"include_sms":{"type":"boolean","description":"(send to device) Allow users to send an SMS message with the form?","default":false},"message_id_sms":{"type":"string","description":"(send to device) Newsletter/basket id representing the SMS message to be sent."},"message_id_email":{"type":"string","description":"(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/."},"utm_campaign":{"type":"string","description":"(fxa) Value to pass through to GA as utm_campaign."},"utm_term":{"type":"string","description":"(fxa) Value to pass through to GA as utm_term."},"links":{"additionalProperties":{"url":{"allOf":[{"$ref":"#/definitions/link_url"},{"description":"The url where the link points to."}]},"metric":{"type":"string","description":"Custom event name sent with telemetry event."}}}},"additionalProperties":false,"required":["scene1_text","scene2_text","scene1_button_label"],"dependencies":{"scene1_button_color":["scene1_button_label"],"scene1_button_background_color":["scene1_button_label"]}};
 
 /***/ }),
 /* 25 */
 /***/ (function(module, __webpack_exports__, __webpack_require__) {
 
 "use strict";
 __webpack_require__.r(__webpack_exports__);
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "_StartupOverlay", function() { return _StartupOverlay; });
@@ -8030,34 +8034,34 @@ class DSEmptyState_DSEmptyState extends 
 
 class CardGrid_CardGrid extends external_React_default.a.PureComponent {
   renderCards() {
     const recs = this.props.data.recommendations.slice(0, this.props.items);
     const cards = [];
 
     for (let index = 0; index < this.props.items; index++) {
       const rec = recs[index];
-      cards.push(rec ? external_React_default.a.createElement(DSCard_DSCard, {
+      cards.push(!rec || rec.placeholder ? external_React_default.a.createElement(PlaceholderDSCard, {
+        key: `dscard-${index}`
+      }) : external_React_default.a.createElement(DSCard_DSCard, {
         key: `dscard-${index}`,
         pos: rec.pos,
         campaignId: rec.campaign_id,
         image_src: rec.image_src,
         raw_image_src: rec.raw_image_src,
         title: rec.title,
         excerpt: rec.excerpt,
         url: rec.url,
         id: rec.id,
         type: this.props.type,
         context: rec.context,
         dispatch: this.props.dispatch,
         source: rec.domain,
         pocket_id: rec.pocket_id,
         bookmarkGuid: rec.bookmarkGuid
-      }) : external_React_default.a.createElement(PlaceholderDSCard, {
-        key: `dscard-${index}`
       }));
     }
 
     let divisibility = ``;
 
     if (this.props.items % 4 === 0) {
       divisibility = `divisible-by-4`;
     } else if (this.props.items % 3 === 0) {
@@ -8215,34 +8219,34 @@ const PlaceholderListItem = props => ext
 
 function _List(props) {
   const renderList = () => {
     const recs = props.data.recommendations.slice(props.recStartingPoint, props.recStartingPoint + props.items);
     const recMarkup = [];
 
     for (let index = 0; index < props.items; index++) {
       const rec = recs[index];
-      recMarkup.push(rec ? external_React_default.a.createElement(List_ListItem, {
+      recMarkup.push(!rec || rec.placeholder ? external_React_default.a.createElement(PlaceholderListItem, {
+        key: `ds-list-item-${index}`
+      }) : external_React_default.a.createElement(List_ListItem, {
         key: `ds-list-item-${index}`,
         dispatch: props.dispatch,
         campaignId: rec.campaign_id,
         domain: rec.domain,
         excerpt: rec.excerpt,
         id: rec.id,
         image_src: rec.image_src,
         raw_image_src: rec.raw_image_src,
         pos: rec.pos,
         title: rec.title,
         context: rec.context,
         type: props.type,
         url: rec.url,
         pocket_id: rec.pocket_id,
         bookmarkGuid: rec.bookmarkGuid
-      }) : external_React_default.a.createElement(PlaceholderListItem, {
-        key: `ds-list-item-${index}`
       }));
     }
 
     const listStyles = ["ds-list", props.fullWidth ? "ds-list-full-width" : "", props.hasBorders ? "ds-list-borders" : "", props.hasImages ? "ds-list-images" : "", props.hasNumbers ? "ds-list-numbers" : ""];
     return external_React_default.a.createElement("ul", {
       className: listStyles.join(" ")
     }, recMarkup);
   };
@@ -8314,89 +8318,97 @@ class Hero_Hero extends external_React_d
 
   renderHero() {
     let [heroRec, ...otherRecs] = this.props.data.recommendations.slice(0, this.props.items);
     this.heroRec = heroRec;
     const cards = [];
 
     for (let index = 0; index < this.props.items - 1; index++) {
       const rec = otherRecs[index];
-      cards.push(rec ? external_React_default.a.createElement(DSCard_DSCard, {
+      cards.push(!rec || rec.placeholder ? external_React_default.a.createElement(PlaceholderDSCard, {
+        key: `dscard-${index}`
+      }) : external_React_default.a.createElement(DSCard_DSCard, {
         campaignId: rec.campaign_id,
         key: `dscard-${index}`,
         image_src: rec.image_src,
         raw_image_src: rec.raw_image_src,
         title: rec.title,
         url: rec.url,
         id: rec.id,
         pos: rec.pos,
         type: this.props.type,
         dispatch: this.props.dispatch,
         context: rec.context,
         source: rec.domain,
         pocket_id: rec.pocket_id,
         bookmarkGuid: rec.bookmarkGuid
-      }) : external_React_default.a.createElement(PlaceholderDSCard, {
-        key: `dscard-${index}`
+      }));
+    }
+
+    let heroCard = null;
+
+    if (!heroRec || heroRec.placeholder) {
+      heroCard = external_React_default.a.createElement(PlaceholderDSCard, null);
+    } else {
+      heroCard = external_React_default.a.createElement("div", {
+        className: "ds-hero-item"
+      }, external_React_default.a.createElement(SafeAnchor_SafeAnchor, {
+        className: "wrapper",
+        dispatch: this.props.dispatch,
+        onLinkClick: this.onLinkClick,
+        url: heroRec.url
+      }, external_React_default.a.createElement("div", {
+        className: "img-wrapper"
+      }, external_React_default.a.createElement(DSImage_DSImage, {
+        extraClassNames: "img",
+        source: heroRec.image_src,
+        rawSource: heroRec.raw_image_src
+      })), external_React_default.a.createElement("div", {
+        className: "meta"
+      }, external_React_default.a.createElement("div", {
+        className: "header-and-excerpt"
+      }, heroRec.context ? external_React_default.a.createElement("p", {
+        className: "context"
+      }, heroRec.context) : external_React_default.a.createElement("p", {
+        className: "source"
+      }, heroRec.domain), external_React_default.a.createElement("header", null, heroRec.title), external_React_default.a.createElement("p", {
+        className: "excerpt"
+      }, heroRec.excerpt))), external_React_default.a.createElement(ImpressionStats["ImpressionStats"], {
+        campaignId: heroRec.campaignId,
+        rows: [{
+          id: heroRec.id,
+          pos: heroRec.pos
+        }],
+        dispatch: this.props.dispatch,
+        source: this.props.type
+      })), external_React_default.a.createElement(DSLinkMenu, {
+        id: heroRec.id,
+        index: heroRec.pos,
+        dispatch: this.props.dispatch,
+        intl: this.props.intl,
+        url: heroRec.url,
+        title: heroRec.title,
+        source: heroRec.domain,
+        type: this.props.type,
+        pocket_id: heroRec.pocket_id,
+        bookmarkGuid: heroRec.bookmarkGuid
       }));
     }
 
     let list = external_React_default.a.createElement(List, {
       recStartingPoint: 1,
       data: this.props.data,
       hasImages: true,
       hasBorders: this.props.border === `border`,
       items: this.props.items - 1,
       type: `Hero`
     });
     return external_React_default.a.createElement("div", {
       className: `ds-hero ds-hero-${this.props.border}`
-    }, external_React_default.a.createElement("div", {
-      className: "ds-hero-item"
-    }, external_React_default.a.createElement(SafeAnchor_SafeAnchor, {
-      className: "wrapper",
-      dispatch: this.props.dispatch,
-      onLinkClick: this.onLinkClick,
-      url: heroRec.url
-    }, external_React_default.a.createElement("div", {
-      className: "img-wrapper"
-    }, external_React_default.a.createElement(DSImage_DSImage, {
-      extraClassNames: "img",
-      source: heroRec.image_src,
-      rawSource: heroRec.raw_image_src
-    })), external_React_default.a.createElement("div", {
-      className: "meta"
-    }, external_React_default.a.createElement("div", {
-      className: "header-and-excerpt"
-    }, heroRec.context ? external_React_default.a.createElement("p", {
-      className: "context"
-    }, heroRec.context) : external_React_default.a.createElement("p", {
-      className: "source"
-    }, heroRec.domain), external_React_default.a.createElement("header", null, heroRec.title), external_React_default.a.createElement("p", {
-      className: "excerpt"
-    }, heroRec.excerpt))), external_React_default.a.createElement(ImpressionStats["ImpressionStats"], {
-      campaignId: heroRec.campaignId,
-      rows: [{
-        id: heroRec.id,
-        pos: heroRec.pos
-      }],
-      dispatch: this.props.dispatch,
-      source: this.props.type
-    })), external_React_default.a.createElement(DSLinkMenu, {
-      id: heroRec.id,
-      index: heroRec.pos,
-      dispatch: this.props.dispatch,
-      intl: this.props.intl,
-      url: heroRec.url,
-      title: heroRec.title,
-      source: heroRec.domain,
-      type: this.props.type,
-      pocket_id: heroRec.pocket_id,
-      bookmarkGuid: heroRec.bookmarkGuid
-    })), external_React_default.a.createElement("div", {
+    }, heroCard, external_React_default.a.createElement("div", {
       className: `${this.props.subComponentType}`
     }, this.props.subComponentType === `cards` ? cards : list));
   }
 
   render() {
     const {
       data
     } = this.props; // Handle a render before feed has been fetched by displaying nothing
@@ -8531,88 +8543,112 @@ const selectLayoutRender = (state, prefs
       }
     }
 
     return { ...data,
       recommendations
     };
   }
 
-  function maybeInjectSpocs(data, spocsConfig) {
-    // Do we ever expect to possibly have a spoc.
-    if (data && spocsConfig && spocsConfig.positions && spocsConfig.positions.length) {
-      // We expect a spoc, spocs are loaded, but the server returned no spocs.
-      if (!spocs.data.spocs || !spocs.data.spocs.length) {
-        return data;
-      } // We expect a spoc, spocs are loaded, and we have spocs available.
-
-
-      return rollForSpocs(data, spocsConfig);
-    }
-
-    return data;
-  }
-
   const positions = {};
   const DS_COMPONENTS = ["Message", "SectionTitle", "Navigation", "CardGrid", "Hero", "HorizontalRule", "List"];
   const filterArray = [];
 
   if (!prefs["feeds.topsites"]) {
     filterArray.push("TopSites");
   }
 
   if (!prefs["feeds.section.topstories"]) {
     filterArray.push(...DS_COMPONENTS);
   }
 
+  const placeholderComponent = component => {
+    const data = {
+      recommendations: []
+    };
+    let items = 0;
+
+    if (component.properties && component.properties.items) {
+      items = component.properties.items;
+    }
+
+    for (let i = 0; i < items; i++) {
+      data.recommendations.push({
+        "placeholder": true
+      });
+    }
+
+    return { ...component,
+      data
+    };
+  };
+
   const handleComponent = component => {
     positions[component.type] = positions[component.type] || 0;
-    let {
-      data
-    } = feeds.data[component.feed.url];
+    const feed = feeds.data[component.feed.url];
+    let data = {
+      recommendations: []
+    };
+
+    if (feed && feed.data) {
+      data = { ...feed.data,
+        recommendations: [...feed.data.recommendations]
+      };
+    }
 
     if (component && component.properties && component.properties.offset) {
       data = { ...data,
         recommendations: data.recommendations.slice(component.properties.offset)
       };
-    }
-
-    data = maybeInjectSpocs(data, component.spocs);
+    } // Do we ever expect to possibly have a spoc.
+
+
+    if (data && component.spocs && component.spocs.positions && component.spocs.positions.length) {
+      // We expect a spoc, spocs are loaded, and the server returned spocs.
+      if (spocs.loaded && spocs.data.spocs && spocs.data.spocs.length) {
+        data = rollForSpocs(data, component.spocs);
+      }
+    }
+
     let items = 0;
 
     if (component.properties && component.properties.items) {
       items = Math.min(component.properties.items, data.recommendations.length);
     } // loop through a component items
     // Store the items position sequentially for multiple components of the same type.
     // Example: A second card grid starts pos offset from the last card grid.
 
 
     for (let i = 0; i < items; i++) {
-      data.recommendations[i].pos = positions[component.type]++;
+      data.recommendations[i] = { ...data.recommendations[i],
+        pos: positions[component.type]++
+      };
     }
 
     return { ...component,
       data
     };
   };
 
   const renderLayout = () => {
     const renderedLayoutArray = [];
 
     for (const row of layout.filter(r => r.components.filter(c => !filterArray.includes(c.type)).length)) {
       let components = [];
       renderedLayoutArray.push({ ...row,
         components
       });
 
-      for (const component of row.components) {
+      for (const component of row.components.filter(c => !filterArray.includes(c.type))) {
         if (component.feed) {
-          const spocsConfig = component.spocs; // Are we still waiting on a feed/spocs, render what we have, and bail out early.
+          const spocsConfig = component.spocs; // Are we still waiting on a feed/spocs, render what we have,
+          // add a placeholder for this component, and bail out early.
 
           if (!feeds.data[component.feed.url] || spocsConfig && spocsConfig.positions && spocsConfig.positions.length && !spocs.loaded) {
+            components.push(placeholderComponent(component));
             return renderedLayoutArray;
           }
 
           components.push(handleComponent(component));
         } else {
           components.push(component);
         }
       }
@@ -9137,17 +9173,19 @@ class SnippetBase_SnippetBase extends ex
 function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
 
 
 
 
 
 
 
-const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png";
+const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png"; // Alt text if available; in the future this should come from the server. See bug 1551711
+
+const ICON_ALT_TEXT = "";
 class SimpleSnippet_SimpleSnippet extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onButtonClick = this.onButtonClick.bind(this);
   }
 
   onButtonClick() {
     if (this.props.provider !== "preview") {
@@ -9183,23 +9221,34 @@ class SimpleSnippet_SimpleSnippet extend
       title
     } = this.props.content;
     return title ? external_React_default.a.createElement("h3", {
       className: `title ${this._shouldRenderButton() ? "title-inline" : ""}`
     }, this.renderTitleIcon(), " ", title) : null;
   }
 
   renderTitleIcon() {
-    const titleIcon = Object(template_utils["safeURI"])(this.props.content.title_icon);
-    return titleIcon ? external_React_default.a.createElement("span", {
-      className: "titleIcon",
+    const titleIconLight = Object(template_utils["safeURI"])(this.props.content.title_icon);
+    const titleIconDark = Object(template_utils["safeURI"])(this.props.content.title_icon_dark_theme || this.props.content.title_icon);
+
+    if (!titleIconLight) {
+      return null;
+    }
+
+    return external_React_default.a.createElement(external_React_default.a.Fragment, null, external_React_default.a.createElement("span", {
+      className: "titleIcon icon-light-theme",
       style: {
-        backgroundImage: `url("${titleIcon}")`
-      }
-    }) : null;
+        backgroundImage: `url("${titleIconLight}")`
+      }
+    }), external_React_default.a.createElement("span", {
+      className: "titleIcon icon-dark-theme",
+      style: {
+        backgroundImage: `url("${titleIconDark}")`
+      }
+    }));
   }
 
   renderButton() {
     const {
       props
     } = this;
 
     if (!this._shouldRenderButton()) {
@@ -9241,29 +9290,35 @@ class SimpleSnippet_SimpleSnippet extend
   }
 
   renderSectionHeader() {
     const {
       props
     } = this; // an icon and text must be specified to render the section header
 
     if (props.content.section_title_icon && props.content.section_title_text) {
-      const sectionTitleIcon = Object(template_utils["safeURI"])(props.content.section_title_icon);
+      const sectionTitleIconLight = Object(template_utils["safeURI"])(props.content.section_title_icon);
+      const sectionTitleIconDark = Object(template_utils["safeURI"])(props.content.section_title_icon_dark_theme || props.content.section_title_icon);
       const sectionTitleURL = props.content.section_title_url;
       return external_React_default.a.createElement("div", {
         className: "section-header"
       }, external_React_default.a.createElement("h3", {
         className: "section-title"
       }, external_React_default.a.createElement(ConditionalWrapper, {
         condition: sectionTitleURL,
         wrap: this.wrapSectionHeader(sectionTitleURL)
       }, external_React_default.a.createElement("span", {
-        className: "icon icon-small-spacer",
+        className: "icon icon-small-spacer icon-light-theme",
         style: {
-          backgroundImage: `url("${sectionTitleIcon}")`
+          backgroundImage: `url("${sectionTitleIconLight}")`
+        }
+      }), external_React_default.a.createElement("span", {
+        className: "icon icon-small-spacer icon-dark-theme",
+        style: {
+          backgroundImage: `url("${sectionTitleIconDark}")`
         }
       }), external_React_default.a.createElement("span", {
         className: "section-title-text"
       }, props.content.section_title_text))));
     }
 
     return null;
   }
@@ -9290,17 +9345,22 @@ class SimpleSnippet_SimpleSnippet extend
     return external_React_default.a.createElement(SnippetBase_SnippetBase, _extends({}, props, {
       className: className,
       textStyle: this.props.textStyle
     }), sectionHeader, external_React_default.a.createElement(ConditionalWrapper, {
       condition: sectionHeader,
       wrap: this.wrapSnippetContent
     }, external_React_default.a.createElement("img", {
       src: Object(template_utils["safeURI"])(props.content.icon) || DEFAULT_ICON_PATH,
-      className: "icon"
+      className: "icon icon-light-theme",
+      alt: ICON_ALT_TEXT
+    }), external_React_default.a.createElement("img", {
+      src: Object(template_utils["safeURI"])(props.content.icon_dark_theme || props.content.icon) || DEFAULT_ICON_PATH,
+      className: "icon icon-dark-theme",
+      alt: ICON_ALT_TEXT
     }), external_React_default.a.createElement("div", null, this.renderTitle(), " ", external_React_default.a.createElement("p", {
       className: "body"
     }, this.renderText()), this.props.extraContent), external_React_default.a.createElement("div", null, this.renderButton())));
   }
 
 }
 // CONCATENATED MODULE: ./content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx
 function EOYSnippet_extends() { EOYSnippet_extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return EOYSnippet_extends.apply(this, arguments); }
@@ -9453,16 +9513,19 @@ var FXASignupSnippet_schema = __webpack_
 // CONCATENATED MODULE: ./content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx
 function SubmitFormSnippet_extends() { SubmitFormSnippet_extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return SubmitFormSnippet_extends.apply(this, arguments); }
 
 
 
 
 
 
+ // Alt text if available; in the future this should come from the server. See bug 1551711
+
+const SubmitFormSnippet_ICON_ALT_TEXT = "";
 class SubmitFormSnippet_SubmitFormSnippet extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.expandSnippet = this.expandSnippet.bind(this);
     this.handleSubmit = this.handleSubmit.bind(this);
     this.handleSubmitAttempt = this.handleSubmitAttempt.bind(this);
     this.onInputChange = this.onInputChange.bind(this);
     this.state = {
@@ -9679,33 +9742,38 @@ class SubmitFormSnippet_SubmitFormSnippe
     const placholder = this.props.content.scene2_email_placeholder_text || this.props.content.scene2_input_placeholder;
     return external_React_default.a.createElement("input", {
       ref: "mainInput",
       type: this.props.inputType || "email",
       className: `mainInput${this.state.submitAttempted ? "" : " clean"}`,
       name: "email",
       required: true,
       placeholder: placholder,
-      onChange: this.props.validateInput ? this.onInputChange : null,
-      autoFocus: true
+      onChange: this.props.validateInput ? this.onInputChange : null
     });
   }
 
   renderSignupView() {
     const {
       content
     } = this.props;
     const containerClass = `SubmitFormSnippet ${this.props.className}`;
     return external_React_default.a.createElement(SnippetBase_SnippetBase, SubmitFormSnippet_extends({}, this.props, {
       className: containerClass,
       footerDismiss: true
     }), content.scene2_icon ? external_React_default.a.createElement("div", {
       className: "scene2Icon"
     }, external_React_default.a.createElement("img", {
-      src: content.scene2_icon
+      src: Object(template_utils["safeURI"])(content.scene2_icon),
+      className: "icon-light-theme",
+      alt: SubmitFormSnippet_ICON_ALT_TEXT
+    }), external_React_default.a.createElement("img", {
+      src: Object(template_utils["safeURI"])(content.scene2_icon_dark_theme || content.scene2_icon),
+      className: "icon-dark-theme",
+      alt: SubmitFormSnippet_ICON_ALT_TEXT
     })) : null, external_React_default.a.createElement("div", {
       className: "message"
     }, external_React_default.a.createElement("p", null, content.scene2_title && external_React_default.a.createElement("h3", {
       className: "scene2Title"
     }, content.scene2_title), " ", content.scene2_text && external_React_default.a.createElement(RichText["RichText"], {
       scene2_text: content.scene2_text,
       localization_id: "scene2_text"
     }))), external_React_default.a.createElement("form", {
@@ -9925,17 +9993,19 @@ const SendToDeviceSnippet = props => {
 };
 // CONCATENATED MODULE: ./content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx
 function SimpleBelowSearchSnippet_extends() { SimpleBelowSearchSnippet_extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return SimpleBelowSearchSnippet_extends.apply(this, arguments); }
 
 
 
 
 
-const SimpleBelowSearchSnippet_DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png";
+const SimpleBelowSearchSnippet_DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png"; // Alt text if available; in the future this should come from the server. See bug 1551711
+
+const SimpleBelowSearchSnippet_ICON_ALT_TEXT = "";
 class SimpleBelowSearchSnippet_SimpleBelowSearchSnippet extends external_React_default.a.PureComponent {
   renderText() {
     const {
       props
     } = this;
     return external_React_default.a.createElement(RichText["RichText"], {
       text: props.content.text,
       customElements: this.props.customElements,
@@ -9955,17 +10025,22 @@ class SimpleBelowSearchSnippet_SimpleBel
       className += ` ${props.className}`;
     }
 
     return external_React_default.a.createElement(SnippetBase_SnippetBase, SimpleBelowSearchSnippet_extends({}, props, {
       className: className,
       textStyle: this.props.textStyle
     }), external_React_default.a.createElement("img", {
       src: Object(template_utils["safeURI"])(props.content.icon) || SimpleBelowSearchSnippet_DEFAULT_ICON_PATH,
-      className: "icon"
+      className: "icon icon-light-theme",
+      alt: SimpleBelowSearchSnippet_ICON_ALT_TEXT
+    }), external_React_default.a.createElement("img", {
+      src: Object(template_utils["safeURI"])(props.content.icon_dark_theme || props.content.icon) || SimpleBelowSearchSnippet_DEFAULT_ICON_PATH,
+      className: "icon icon-dark-theme",
+      alt: SimpleBelowSearchSnippet_ICON_ALT_TEXT
     }), external_React_default.a.createElement("div", null, external_React_default.a.createElement("p", {
       className: "body"
     }, this.renderText()), this.props.extraContent));
   }
 
 }
 // CONCATENATED MODULE: ./content-src/asrouter/templates/template-manifest.jsx
 /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SnippetsTemplates", function() { return SnippetsTemplates; });
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/docs/index.rst
@@ -0,0 +1,65 @@
+======================
+Firefox Home (New Tab)
+======================
+
+All files related to Firefox Home, which includes content that appears on `about:home`,
+`about:newtab`, and `about:welcome`, can we found in the `browser/components/newtab` directory.
+Some of these source files (such as `.js`, `.jsx`, and `.sass`) require an additional build step.
+We are working on migrating this to work with `mach`, but in the meantime, please
+follow the following steps if you need to make changes in this directory:
+
+For .jsm files
+---------------
+
+No build step is necessary. Use `mach` and run mochi tests according to your regular Firefox workflow.
+
+For .js, .jsx, .sass, or .css files
+-----------------------------------
+
+Prerequisites
+`````````````
+
+You will need the following:
+
+- Node.js 8+ (On Mac, the best way to install Node.js is to use the [install link on the Node.js homepage](https://nodejs.org/en/))
+- npm (packaged with Node.js)
+
+To install dependencies, run the following from the root of the mozilla-central repository
+(or cd into browser/components/newtab to omit the `--prefix` in any of these commands):
+
+.. code-block:: shell
+
+  npm install --prefix browser/components/newtab
+
+
+Which files should you edit?
+````````````````````````````
+
+You should not make changes to `.js` or `.css` files in `browser/components/newtab/css` or
+`browser/components/newtab/data` directory. Instead, you should edit the `.jsx`, `.js`, and `.sass` files
+in `browser/components/newtab/content-src` directory.
+
+Building assets and running Firefox
+```````````````````````````````````
+
+To build assets and run Firefox, run the following from the root of the mozilla-central repository:
+
+.. code-block:: shell
+
+  npm run bundle --prefix browser/components/newtab && ./mach run
+
+Running tests
+`````````````
+
+Mochi tests and xpcshell tests can be run normally. To run our additional unit tests, you can run the following:
+
+.. code-block:: shell
+
+  npm test --prefix browser/components/newtab
+
+The Newtab team is currently responsible for fixing any test failures that result from changes
+until these tests are running in Try, so this is currently an optional step.
+
+GitHub workflow
+---------------
+The files in this directory, including vendor dependencies, are synchronized with the https://github.com/mozilla/activity-stream repository. If you prefer a GitHub-based workflow, you can look at the documentation there to learn more.
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/hooks/pre-push
@@ -0,0 +1,11 @@
+#!/bin/sh
+# Recommended pre-push hook for activity-stream
+
+hookName=`basename "$0"`
+
+if ! command -v node >/dev/null 2>&1; then
+  echo "Can't find node in PATH, trying to find a node binary on your system"
+fi
+
+echo "Running $hookName hook..."
+npm run lint
--- a/browser/components/newtab/lib/ASRouter.jsm
+++ b/browser/components/newtab/lib/ASRouter.jsm
@@ -700,16 +700,25 @@ class _ASRouter {
         message_id: message.id,
         action: "asrouter_undesired_event",
         event: "TARGETING_EXPRESSION_ERROR",
         value: type,
       }));
     }
   }
 
+  async _hasAddonAttributionData() {
+    try {
+      const data = await AttributionCode.getAttrDataAsync() || {};
+      return data.source === "addons.mozilla.org";
+    } catch (e) {
+      return false;
+    }
+  }
+
   /**
    * _generateTrailheadBranches - Generates and returns Trailhead configuration and chooses an experiment
    *                             based on clientID and locale.
    * @returns {{experiment: string, interrupt: string, triplet: string}}
    */
   async _generateTrailheadBranches() {
     let experiment = "";
     let interrupt;
@@ -719,17 +728,17 @@ class _ASRouter {
     const overrideValue = Services.prefs.getStringPref(TRAILHEAD_CONFIG.OVERRIDE_PREF, "");
     if (overrideValue) {
       [interrupt, triplet] = overrideValue.split("-");
       return {experiment, interrupt, triplet: triplet || ""};
     }
 
     const locale = Services.locale.appLocaleAsLangTag;
 
-    if (TRAILHEAD_CONFIG.LOCALES.includes(locale)) {
+    if (TRAILHEAD_CONFIG.LOCALES.includes(locale) && !(await this._hasAddonAttributionData())) {
       const {userId} = ClientEnvironment;
       experiment = await chooseBranch(`${userId}-trailhead-experiments`, TRAILHEAD_CONFIG.EXPERIMENT_RATIOS);
 
       // For the interrupts experiment,
       // we randomly assign an interrupt and always use the "supercharge" triplet.
       if (experiment === "interrupts") {
         interrupt =  await chooseBranch(`${userId}-interrupts-branch`, TRAILHEAD_CONFIG.BRANCHES.interrupts);
         if (["join", "sync", "cards"].includes(interrupt)) {
--- a/browser/components/newtab/lib/SnippetsTestMessageProvider.jsm
+++ b/browser/components/newtab/lib/SnippetsTestMessageProvider.jsm
@@ -1,83 +1,107 @@
 /* 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/. */
 "use strict";
 
 const TEST_ICON = "chrome://branding/content/icon64.png";
 const TEST_ICON_16 = "chrome://branding/content/icon16.png";
+const TEST_ICON_BW = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQfjBQ8QDifrKGc/AAABf0lEQVQoz4WRO08UUQCFvztzd1AgG9jRgGwkhEoMIYGSygYt+A00tpZGY0jYxAJKEwkNjX9AK2xACx4dhFiQQCiMMRr2kYXdnQcz7L0z91qAMVac6hTfSU7OgVsk/prtyfSNfRb7ge2cd7dmVucP/wM2lwqVqoyICahRx9Nz71+8AnAAvlTct+dSYDBYcgJ+Fj68XFu/AfamnIoWFoHFYrAUuYMSn55/fAIOxIs1t4MhQpNxRYsUD0ld7r8DCfZph4QecrqkhCREgMLSeISQkAy0UBgE0CYgIkeRA9HdsCQhpEGCxichpItHigEcPH4XJLRbTf8STY0iiiuu60Ifxexx04F0N+aCgJCAhPQmD/cp/RC5A79WvUyhUHSIidAIoESv9VfAhW9n8+XqTCoyMsz1cviMMrGz9BrjAuboYHZajyXCInEocI8yvccbC+0muABanR4/tONjQz3DzgNKtj9sfv66XD9B/3tT9g/akb7h0bJwzxqqmlRHLr4rLPwBlYWoYj77l2AAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTktMDUtMTVUMTY6MTQ6MzkrMDA6MDD5/4XBAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE5LTA1LTE1VDE2OjE0OjM5KzAwOjAwiKI9fQAAAABJRU5ErkJggg==";
 
 const MESSAGES = () => ([
   {
     "id": "SIMPLE_TEST_1",
     "template": "simple_snippet",
     "campaign": "test_campaign_blocking",
     "content": {
       "icon": TEST_ICON,
+      "icon_dark_theme": TEST_ICON_BW,
       "title": "Firefox Account!",
       "title_icon": TEST_ICON_16,
+      "title_icon_dark_theme": TEST_ICON_BW,
+      "text": "<syncLink>Sync it, link it, take it with you</syncLink>. All this and more with a Firefox Account.",
+      "links": {"syncLink": {"url": "https://www.mozilla.org/en-US/firefox/accounts"}},
+      "block_button_text": "Block",
+    },
+  },
+  {
+    "id": "SIMPLE_TEST_1_NO_DARK_THEME",
+    "template": "simple_snippet",
+    "campaign": "test_campaign_blocking",
+    "content": {
+      "icon": TEST_ICON,
+      "icon_dark_theme": "",
+      "title": "Firefox Account!",
+      "title_icon": TEST_ICON_16,
+      "title_icon_dark_theme": "",
       "text": "<syncLink>Sync it, link it, take it with you</syncLink>. All this and more with a Firefox Account.",
       "links": {"syncLink": {"url": "https://www.mozilla.org/en-US/firefox/accounts"}},
       "block_button_text": "Block",
     },
   },
   {
     "id": "SIMPLE_TEST_1_SAME_CAMPAIGN",
     "template": "simple_snippet",
     "campaign": "test_campaign_blocking",
     "content": {
       "icon": TEST_ICON,
+      "icon_dark_theme": TEST_ICON_BW,
       "text": "<syncLink>Sync it, link it, take it with you</syncLink>. All this and more with a Firefox Account.",
       "links": {"syncLink": {"url": "https://www.mozilla.org/en-US/firefox/accounts"}},
       "block_button_text": "Block",
     },
   },
   {
     "id": "SIMPLE_TEST_TALL",
     "template": "simple_snippet",
     "content": {
       "icon": TEST_ICON,
+      "icon_dark_theme": TEST_ICON_BW,
       "text": "<syncLink>Sync it, link it, take it with you</syncLink>. All this and more with a Firefox Account.",
       "links": {"syncLink": {"url": "https://www.mozilla.org/en-US/firefox/accounts"}},
       "button_label": "Get one now!",
       "button_url": "https://www.mozilla.org/en-US/firefox/accounts",
       "block_button_text": "Block",
       "tall": true,
     },
   },
   {
     "id": "SIMPLE_TEST_BUTTON_URL_1",
     "template": "simple_snippet",
     "content": {
       "icon": TEST_ICON,
+      "icon_dark_theme": TEST_ICON_BW,
       "button_label": "Get one now!",
       "button_url": "https://www.mozilla.org/en-US/firefox/accounts",
       "text": "Sync it, link it, take it with you. All this and more with a Firefox Account.",
       "block_button_text": "Block",
     },
   },
   {
     "id": "SIMPLE_WITH_TITLE_TEST_1",
     "template": "simple_snippet",
     "content": {
       "icon": TEST_ICON,
+      "icon_dark_theme": TEST_ICON_BW,
       "title": "Ready to sync?",
       "text": "Get connected with a <syncLink>Firefox account</syncLink>.",
       "links": {"syncLink": {"url": "https://www.mozilla.org/en-US/firefox/accounts"}},
       "block_button_text": "Block",
     },
   },
   {
     "id": "NEWSLETTER_TEST_DEFAULTS",
     "template": "newsletter_snippet",
     "content": {
       "scene1_icon": TEST_ICON,
+      "scene1_icon_dark_theme": TEST_ICON_BW,
       "scene1_title": "Be a part of a movement.",
       "scene1_title_icon": TEST_ICON_16,
+      "scene1_title_icon_dark_theme": TEST_ICON_BW,
       "scene1_text": "Internet shutdowns, hackers, harassment &ndash; the health of the internet is on the line. Sign up and Mozilla will keep you updated on how you can help.",
       "scene1_button_label": "Continue",
       "scene1_button_color": "#712b00",
       "scene1_button_background_color": "#ff9400",
       "scene2_title": "Let's do this!",
       "locale": "en-CA",
       "scene2_dismiss_button_text": "Dismiss",
       "scene2_text": "Sign up for the Mozilla newsletter and we will keep you updated on how you can help.",
@@ -88,16 +112,17 @@ const MESSAGES = () => ([
       "links": {"privacyLink": {"url": "https://www.mozilla.org/privacy/websites/?sample_rate=0.001&snippet_name=7894"}},
     },
   },
   {
     "id": "NEWSLETTER_TEST_1",
     "template": "newsletter_snippet",
     "content": {
       "scene1_icon": TEST_ICON,
+      "scene1_icon_dark_theme": TEST_ICON_BW,
       "scene1_title": "Be a part of a movement.",
       "scene1_title_icon": "",
       "scene1_text": "Internet shutdowns, hackers, harassment &ndash; the health of the internet is on the line. Sign up and Mozilla will keep you updated on how you can help.",
       "scene1_button_label": "Continue",
       "scene1_button_color": "#712b00",
       "scene1_button_background_color": "#ff9400",
       "scene2_title": "Let's do this!",
       "locale": "en-CA",
@@ -112,23 +137,25 @@ const MESSAGES = () => ([
       "links": {"privacyLink": {"url": "https://www.mozilla.org/privacy/websites/?sample_rate=0.001&snippet_name=7894"}},
     },
   },
   {
     "id": "FXA_SNIPPET_TEST_1",
     "template": "fxa_signup_snippet",
     "content": {
       "scene1_icon": TEST_ICON,
+      "scene1_icon_dark_theme": TEST_ICON_BW,
       "scene1_button_label": "Get connected with sync!",
       "scene1_button_color": "#712b00",
       "scene1_button_background_color": "#ff9400",
 
       "scene1_text": "Connect to Firefox by securely syncing passwords, bookmarks, and open tabs.",
       "scene1_title": "Browser better.",
       "scene1_title_icon": TEST_ICON_16,
+      "scene1_title_icon_dark_theme": TEST_ICON_BW,
 
       "scene2_text": "Connect to your Firefox account to securely sync passwords, bookmarks, and open tabs.",
       "scene2_title": "Title 123",
       "scene2_email_placeholder_text": "Your email",
       "scene2_button_label": "Continue",
       "scene2_dismiss_button_text": "Dismiss",
     },
   },
@@ -141,24 +168,62 @@ const MESSAGES = () => ([
       country: "us",
       message_id_sms: "ff-mobilesn-download",
       message_id_email: "download-firefox-mobile",
 
       scene1_button_background_color: "#6200a4",
       scene1_button_color: "#FFFFFF",
       scene1_button_label: "Install now",
       scene1_icon: TEST_ICON,
+      scene1_icon_dark_theme: TEST_ICON_BW,
       scene1_text: "Browse without compromise with Firefox Mobile.",
       scene1_title: "Full-featured. Customizable. Lightning fast",
       scene1_title_icon: TEST_ICON_16,
+      scene1_title_icon_dark_theme: TEST_ICON_BW,
 
       scene2_button_label: "Send",
       scene2_disclaimer_html: "The intended recipient of the email must have consented. <privacyLink>Learn more</privacyLink>.",
       scene2_dismiss_button_text: "Dismiss",
       scene2_icon: TEST_ICON,
+      scene2_icon_dark_theme: TEST_ICON_BW,
+      scene2_input_placeholder: "Your email address or phone number",
+      scene2_text: "Send Firefox to your phone and take a powerful independent browser with you.",
+      scene2_title: "Let's do this!",
+
+      error_text: "Oops, there was a problem.",
+      success_title: "Your download link was sent.",
+      success_text: "Check your device for the email message!",
+      links: {"privacyLink": {"url": "https://www.mozilla.org/privacy/websites/?sample_rate=0.001&snippet_name=7894"}},
+    },
+  },
+  {
+    id: "SNIPPETS_SEND_TO_DEVICE_TEST_NO_DARK_THEME",
+    template: "send_to_device_snippet",
+    content: {
+      include_sms: true,
+      locale: "en-CA",
+      country: "us",
+      message_id_sms: "ff-mobilesn-download",
+      message_id_email: "download-firefox-mobile",
+
+      scene1_button_background_color: "#6200a4",
+      scene1_button_color: "#FFFFFF",
+      scene1_button_label: "Install now",
+      scene1_icon: TEST_ICON,
+      scene1_icon_dark_theme: "",
+      scene1_text: "Browse without compromise with Firefox Mobile.",
+      scene1_title: "Full-featured. Customizable. Lightning fast",
+      scene1_title_icon: TEST_ICON_16,
+      scene1_title_icon_dark_theme: "",
+
+      scene2_button_label: "Send",
+      scene2_disclaimer_html: "The intended recipient of the email must have consented. <privacyLink>Learn more</privacyLink>.",
+      scene2_dismiss_button_text: "Dismiss",
+      scene2_icon: TEST_ICON,
+      scene2_icon_dark_theme: "",
       scene2_input_placeholder: "Your email address or phone number",
       scene2_text: "Send Firefox to your phone and take a powerful independent browser with you.",
       scene2_title: "Let's do this!",
 
       error_text: "Oops, there was a problem.",
       success_title: "Your download link was sent.",
       success_text: "Check your device for the email message!",
       links: {"privacyLink": {"url": "https://www.mozilla.org/privacy/websites/?sample_rate=0.001&snippet_name=7894"}},
@@ -168,32 +233,34 @@ const MESSAGES = () => ([
     "id": "EOY_TEST_1",
     "template": "eoy_snippet",
     "content": {
       "highlight_color": "#f05",
       "background_color": "#ddd",
       "text_color": "yellow",
       "selected_button": "donation_amount_first",
       "icon": TEST_ICON,
+      "icon_dark_theme": TEST_ICON_BW,
       "button_label": "Donate",
       "monthly_checkbox_label_text": "Make my donation monthly",
       "currency_code": "usd",
       "donation_amount_first": 50,
       "donation_amount_second": 25,
       "donation_amount_third": 10,
       "donation_amount_fourth": 5,
       "donation_form_url": "https://donate.mozilla.org/pl/?utm_source=desktop-snippet&amp;utm_medium=snippet&amp;utm_campaign=donate&amp;utm_term=7556",
       "text": "Big corporations want to restrict how we access the web. Fake news is making it harder for us to find the truth. Online bullies are silencing inspired voices. The <em>not-for-profit Mozilla Foundation</em> fights for a healthy internet with programs like our Tech Policy Fellowships and Internet Health Report; <b>will you donate today</b>?",
     },
   },
   {
     "id": "EOY_BOLD_TEST_1",
     "template": "eoy_snippet",
     "content": {
       "icon": TEST_ICON,
+      "icon_dark_theme": TEST_ICON_BW,
       "selected_button": "donation_amount_second",
       "button_label": "Donate",
       "monthly_checkbox_label_text": "Make my donation monthly",
       "currency_code": "usd",
       "donation_amount_first": 50,
       "donation_amount_second": 25,
       "donation_amount_third": 10,
       "donation_amount_fourth": 5,
@@ -202,16 +269,17 @@ const MESSAGES = () => ([
       "test": "bold",
     },
   },
   {
     "id": "EOY_TAKEOVER_TEST_1",
     "template": "eoy_snippet",
     "content": {
       "icon": TEST_ICON,
+      "icon_dark_theme": TEST_ICON_BW,
       "button_label": "Donate",
       "monthly_checkbox_label_text": "Make my donation monthly",
       "currency_code": "usd",
       "donation_amount_first": 50,
       "donation_amount_second": 25,
       "donation_amount_third": 10,
       "donation_amount_fourth": 5,
       "donation_form_url": "https://donate.mozilla.org",
@@ -221,42 +289,45 @@ const MESSAGES = () => ([
   },
   {
     "id": "SIMPLE_TEST_WITH_SECTION_HEADING",
     "template": "simple_snippet",
     "content": {
       "button_label": "Get one now!",
       "button_url": "https://www.mozilla.org/en-US/firefox/accounts",
       "icon": TEST_ICON,
+      "icon_dark_theme": TEST_ICON_BW,
       "title": "Firefox Account!",
       "text": "<syncLink>Sync it, link it, take it with you</syncLink>. All this and more with a Firefox Account.",
       "links": {"syncLink": {"url": "https://www.mozilla.org/en-US/firefox/accounts"}},
       "block_button_text": "Block",
       "section_title_icon": "resource://activity-stream/data/content/assets/glyph-pocket-16.svg",
       "section_title_text": "Messages from Mozilla",
     },
   },
   {
     "id": "SIMPLE_TEST_WITH_SECTION_HEADING_AND_LINK",
     "template": "simple_snippet",
     "content": {
       "icon": TEST_ICON,
+      "icon_dark_theme": TEST_ICON_BW,
       "title": "Firefox Account!",
       "text": "Sync it, link it, take it with you. All this and more with a Firefox Account.",
       "block_button_text": "Block",
       "section_title_icon": "resource://activity-stream/data/content/assets/glyph-pocket-16.svg",
       "section_title_text": "Messages from Mozilla (click for info)",
       "section_title_url": "https://www.mozilla.org/about",
     },
   },
   {
     "id": "SIMPLE_BELOW_SEARCH_TEST_1",
     "template": "simple_below_search_snippet",
     "content": {
       "icon": TEST_ICON,
+      "icon_dark_theme": TEST_ICON_BW,
       "text": "Securely store passwords, bookmarks, and more with a Firefox Account. <syncLink>Sign up</syncLink>",
       "links": {"syncLink": {"url": "https://www.mozilla.org/en-US/firefox/accounts"}},
       "block_button_text": "Block",
     },
   },
 ]);
 
 const SnippetsTestMessageProvider = {
--- a/browser/components/newtab/moz.build
+++ b/browser/components/newtab/moz.build
@@ -4,16 +4,18 @@
 # 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/.
 
 with Files("**"):
     BUG_COMPONENT = ("Firefox", "Activity Streams: Newtab")
 
 BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
 
+SPHINX_TREES['docs'] = 'docs'
+
 XPCSHELL_TESTS_MANIFESTS += [
     'test/xpcshell/xpcshell.ini',
 ]
 
 XPIDL_SOURCES += [
     'nsIAboutNewTabService.idl',
 ]
 
--- a/browser/components/newtab/package-lock.json
+++ b/browser/components/newtab/package-lock.json
@@ -1750,42 +1750,16 @@
         "has-value": "^1.0.0",
         "isobject": "^3.0.1",
         "set-value": "^2.0.0",
         "to-object-path": "^0.3.0",
         "union-value": "^1.0.0",
         "unset-value": "^1.0.0"
       }
     },
-    "caller-callsite": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz",
-      "integrity": "sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=",
-      "dev": true,
-      "requires": {
-        "callsites": "^2.0.0"
-      },
-      "dependencies": {
-        "callsites": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz",
-          "integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=",
-          "dev": true
-        }
-      }
-    },
-    "caller-path": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz",
-      "integrity": "sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=",
-      "dev": true,
-      "requires": {
-        "caller-callsite": "^2.0.0"
-      }
-    },
     "callsite": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz",
       "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA=",
       "dev": true
     },
     "callsites": {
       "version": "3.1.0",
@@ -1911,22 +1885,16 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.0.tgz",
       "integrity": "sha512-xDbVgyfDTT2piup/h8dK/y4QZfJRSa73bw1WZ8b4XM1o7fsFubUVGYcE+1ANtOzJJELGpYoG2961z0Z6OAld9A==",
       "dev": true,
       "requires": {
         "tslib": "^1.9.0"
       }
     },
-    "ci-info": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
-      "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
-      "dev": true
-    },
     "cipher-base": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
       "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
       "dev": true,
       "requires": {
         "inherits": "^2.0.1",
         "safe-buffer": "^5.0.1"
@@ -2191,57 +2159,16 @@
       "dev": true
     },
     "core-util-is": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
       "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
       "dev": true
     },
-    "cosmiconfig": {
-      "version": "5.1.0",
-      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.1.0.tgz",
-      "integrity": "sha512-kCNPvthka8gvLtzAxQXvWo4FxqRB+ftRZyPZNuab5ngvM9Y7yw7hbEysglptLgpkGX9nAOKTBVkHUAe8xtYR6Q==",
-      "dev": true,
-      "requires": {
-        "import-fresh": "^2.0.0",
-        "is-directory": "^0.3.1",
-        "js-yaml": "^3.9.0",
-        "lodash.get": "^4.4.2",
-        "parse-json": "^4.0.0"
-      },
-      "dependencies": {
-        "import-fresh": {
-          "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz",
-          "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=",
-          "dev": true,
-          "requires": {
-            "caller-path": "^2.0.0",
-            "resolve-from": "^3.0.0"
-          }
-        },
-        "parse-json": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
-          "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=",
-          "dev": true,
-          "requires": {
-            "error-ex": "^1.3.1",
-            "json-parse-better-errors": "^1.0.1"
-          }
-        },
-        "resolve-from": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz",
-          "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=",
-          "dev": true
-        }
-      }
-    },
     "cpx": {
       "version": "1.5.0",
       "resolved": "https://registry.npmjs.org/cpx/-/cpx-1.5.0.tgz",
       "integrity": "sha1-GFvgGFEdhycN7czCkxceN2VauI8=",
       "dev": true,
       "requires": {
         "babel-runtime": "^6.9.2",
         "chokidar": "^1.6.0",
@@ -5189,22 +5116,16 @@
       "dev": true
     },
     "get-func-name": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz",
       "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=",
       "dev": true
     },
-    "get-stdin": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz",
-      "integrity": "sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==",
-      "dev": true
-    },
     "get-stream": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
       "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
       "dev": true,
       "requires": {
         "pump": "^3.0.0"
       }
@@ -5640,87 +5561,16 @@
       }
     },
     "https-browserify": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
       "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=",
       "dev": true
     },
-    "husky": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/husky/-/husky-1.3.1.tgz",
-      "integrity": "sha512-86U6sVVVf4b5NYSZ0yvv88dRgBSSXXmHaiq5pP4KDj5JVzdwKgBjEtUPOm8hcoytezFwbU+7gotXNhpHdystlg==",
-      "dev": true,
-      "requires": {
-        "cosmiconfig": "^5.0.7",
-        "execa": "^1.0.0",
-        "find-up": "^3.0.0",
-        "get-stdin": "^6.0.0",
-        "is-ci": "^2.0.0",
-        "pkg-dir": "^3.0.0",
-        "please-upgrade-node": "^3.1.1",
-        "read-pkg": "^4.0.1",
-        "run-node": "^1.0.0",
-        "slash": "^2.0.0"
-      },
-      "dependencies": {
-        "execa": {
-          "version": "1.0.0",
-          "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
-          "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
-          "dev": true,
-          "requires": {
-            "cross-spawn": "^6.0.0",
-            "get-stream": "^4.0.0",
-            "is-stream": "^1.1.0",
-            "npm-run-path": "^2.0.0",
-            "p-finally": "^1.0.0",
-            "signal-exit": "^3.0.0",
-            "strip-eof": "^1.0.0"
-          }
-        },
-        "get-stream": {
-          "version": "4.1.0",
-          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
-          "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
-          "dev": true,
-          "requires": {
-            "pump": "^3.0.0"
-          }
-        },
-        "parse-json": {
-          "version": "4.0.0",
-          "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
-          "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=",
-          "dev": true,
-          "requires": {
-            "error-ex": "^1.3.1",
-            "json-parse-better-errors": "^1.0.1"
-          }
-        },
-        "pify": {
-          "version": "3.0.0",
-          "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
-          "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
-          "dev": true
-        },
-        "read-pkg": {
-          "version": "4.0.1",
-          "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-4.0.1.tgz",
-          "integrity": "sha1-ljYlN48+HE1IyFhytabsfV0JMjc=",
-          "dev": true,
-          "requires": {
-            "normalize-package-data": "^2.3.2",
-            "parse-json": "^4.0.0",
-            "pify": "^3.0.0"
-          }
-        }
-      }
-    },
     "iconv-lite": {
       "version": "0.4.24",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
       "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
       "dev": true,
       "requires": {
         "safer-buffer": ">= 2.1.2 < 3"
       }
@@ -5939,25 +5789,16 @@
       "dev": true
     },
     "is-callable": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz",
       "integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==",
       "dev": true
     },
-    "is-ci": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz",
-      "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==",
-      "dev": true,
-      "requires": {
-        "ci-info": "^2.0.0"
-      }
-    },
     "is-data-descriptor": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz",
       "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=",
       "dev": true,
       "requires": {
         "kind-of": "^3.0.2"
       }
@@ -5982,22 +5823,16 @@
         "kind-of": {
           "version": "5.1.0",
           "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz",
           "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==",
           "dev": true
         }
       }
     },
-    "is-directory": {
-      "version": "0.3.1",
-      "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz",
-      "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=",
-      "dev": true
-    },
     "is-dotfile": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz",
       "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=",
       "dev": true
     },
     "is-equal-shallow": {
       "version": "0.1.3",
@@ -7236,22 +7071,16 @@
       "dev": true
     },
     "lodash.flattendeep": {
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
       "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=",
       "dev": true
     },
-    "lodash.get": {
-      "version": "4.4.2",
-      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
-      "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
-      "dev": true
-    },
     "lodash.isempty": {
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz",
       "integrity": "sha1-b4bL7di+TsmHvpqvM8loTbGzHn4=",
       "dev": true
     },
     "lodash.isequal": {
       "version": "4.5.0",
@@ -8855,25 +8684,16 @@
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
       "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
       "dev": true,
       "requires": {
         "find-up": "^3.0.0"
       }
     },
-    "please-upgrade-node": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.1.1.tgz",
-      "integrity": "sha512-KY1uHnQ2NlQHqIJQpnh/i54rKkuxCEBx+voJIS/Mvb+L2iYd2NMotwduhKTMjfC1uKoX3VXOxLjIYG66dfJTVQ==",
-      "dev": true,
-      "requires": {
-        "semver-compare": "^1.0.0"
-      }
-    },
     "pluralize": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-1.2.1.tgz",
       "integrity": "sha1-0aIUg/0iu0HlihL6NCGCMUCJfEU=",
       "dev": true
     },
     "pontoon-to-json": {
       "version": "2.0.0",
@@ -10009,22 +9829,16 @@
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz",
       "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=",
       "dev": true,
       "requires": {
         "is-promise": "^2.1.0"
       }
     },
-    "run-node": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/run-node/-/run-node-1.0.0.tgz",
-      "integrity": "sha512-kc120TBlQ3mih1LSzdAJXo4xn/GWS2ec0l3S+syHDXP9uRr0JAT8Qd3mdMuyjqCzeZktgP3try92cEgf9Nks8A==",
-      "dev": true
-    },
     "run-queue": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/run-queue/-/run-queue-1.0.3.tgz",
       "integrity": "sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec=",
       "dev": true,
       "requires": {
         "aproba": "^1.1.1"
       }
@@ -11142,22 +10956,16 @@
       }
     },
     "semver": {
       "version": "5.6.0",
       "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz",
       "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==",
       "dev": true
     },
-    "semver-compare": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
-      "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=",
-      "dev": true
-    },
     "serialize-javascript": {
       "version": "1.7.0",
       "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-1.7.0.tgz",
       "integrity": "sha512-ke8UG8ulpFOxO8f8gRYabHQe/ZntKlcig2Mp+8+URDP1D8vJZ0KUt7LYo07q25Z/+JVSgpr/cui9PIp5H6/+nA==",
       "dev": true
     },
     "set-blocking": {
       "version": "2.0.0",
@@ -11264,22 +11072,16 @@
         "@sinonjs/formatio": "^3.2.1",
         "@sinonjs/samsam": "^3.3.1",
         "diff": "^3.5.0",
         "lolex": "^4.0.1",
         "nise": "^1.4.10",
         "supports-color": "^5.5.0"
       }
     },
-    "slash": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
-      "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
-      "dev": true
-    },
     "slice-ansi": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz",
       "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==",
       "dev": true,
       "requires": {
         "ansi-styles": "^3.2.0",
         "astral-regex": "^1.0.0",
--- a/browser/components/newtab/package.json
+++ b/browser/components/newtab/package.json
@@ -36,17 +36,16 @@
     "eslint-plugin-json": "1.4.0",
     "eslint-plugin-jsx-a11y": "6.2.1",
     "eslint-plugin-mozilla": "1.2.1",
     "eslint-plugin-no-unsanitized": "3.0.2",
     "eslint-plugin-promise": "4.1.1",
     "eslint-plugin-react": "7.12.4",
     "eslint-plugin-react-hooks": "1.6.0",
     "eslint-watch": "5.1.2",
-    "husky": "1.3.1",
     "istanbul-instrumenter-loader": "3.0.1",
     "joi-browser": "13.4.0",
     "karma": "4.1.0",
     "karma-chai": "0.1.0",
     "karma-coverage-istanbul-reporter": "2.0.5",
     "karma-firefox-launcher": "1.1.0",
     "karma-mocha": "1.3.0",
     "karma-mocha-reporter": "2.2.5",
@@ -128,22 +127,21 @@
     "testmc": "npm-run-all testmc:*",
     "testmc:lint": "npm run lint",
     "testmc:build": "npm run bundle:webpack && npm run bundle:locales",
     "testmc:unit": "karma start karma.mc.config.js",
     "tddmc": "karma start karma.mc.config.js --tdd",
     "debugcoverage": "open logs/coverage/index.html",
     "lint": "npm-run-all lint:*",
     "lint:eslint": "esw --ext=.js,.jsm,.json,.jsx .",
-    "lint:jsx-a11y": "esw --config=.eslintrc.jsx-a11y.js --ext=.jsx content-src/asrouter/components/ModalOverlay content-src/asrouter/templates/OnboardingMessage content-src/asrouter/templates/Trailhead",
+    "lint:jsx-a11y": "esw --config=.eslintrc.jsx-a11y.js --ext=.jsx content-src/asrouter content-src/components/ASRouterAdmin",
     "lint:sasslint": "sass-lint -v -q",
     "strings-import": "node ./bin/strings-import.js",
     "test": "npm run testmc",
     "tdd": "npm run tddmc",
-    "prepush": "npm run lint && npm run yamscripts",
     "vendor": "npm-run-all vendor:*",
     "vendor:react": "node ./bin/vendor-react.js",
     "help": "yamscripts help",
     "yamscripts": "yamscripts compile",
     "__": "# NOTE: THESE SCRIPTS ARE COMPILED!!! EDIT yamscripts.yml instead!!!"
   },
   "title": "Activity Stream",
   "permissions": {
--- a/browser/components/newtab/test/browser/browser.ini
+++ b/browser/components/newtab/test/browser/browser.ini
@@ -3,20 +3,22 @@ support-files =
   blue_page.html
   red_page.html
   head.js
 prefs =
   browser.newtabpage.activity-stream.debug=false
   browser.newtabpage.activity-stream.discoverystream.endpoints=data:
   browser.newtabpage.activity-stream.feeds.section.topstories=true
   browser.newtabpage.activity-stream.feeds.section.topstories.options={}
+  browser.newtabpage.activity-stream.asrouter.devtoolsEnabled=true
 
 [browser_activity_stream_strings.js]
 [browser_as_load_location.js]
 [browser_as_render.js]
+[browser_asrouter_snippets.js]
 [browser_asrouter_targeting.js]
 [browser_asrouter_trigger_listeners.js]
 [browser_discovery_styles.js]
 [browser_enabled_newtabpage.js]
 [browser_highlights_section.js]
 [browser_getScreenshots.js]
 [browser_newtab_overrides.js]
 [browser_packaged_as_locales.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_snippets.js
@@ -0,0 +1,37 @@
+"use strict";
+
+const {ASRouter} = ChromeUtils.import("resource://activity-stream/lib/ASRouter.jsm");
+
+test_newtab({
+  async before() {
+    let data = ASRouter.state.messages.find(m => m.id === "SIMPLE_BELOW_SEARCH_TEST_1");
+    ASRouter.messageChannel.sendAsyncMessage("ASRouter:parent-to-child", {type: "SET_MESSAGE", data});
+  },
+  test: async function test_simple_below_search_snippet() {
+    // Verify the simple_below_search_snippet renders in container below searchbox
+    // and nothing is rendered in the footer.
+    await ContentTaskUtils.waitForCondition(
+      () => content.document.querySelector(".below-search-snippet .SimpleBelowSearchSnippet"),
+      "Should find the snippet inside the below search container");
+
+    is(0, content.document.querySelector("#footer-asrouter-container").childNodes.length,
+      "Should not find any snippets in the footer container");
+  },
+});
+
+test_newtab({
+  async before() {
+    let data = ASRouter.state.messages.find(m => m.id === "SIMPLE_TEST_1");
+    ASRouter.messageChannel.sendAsyncMessage("ASRouter:parent-to-child", {type: "SET_MESSAGE", data});
+  },
+  test: async function test_simple_snippet() {
+    // Verify the simple_snippet renders in the footer and the container below
+    // searchbox is not rendered.
+    await ContentTaskUtils.waitForCondition(
+      () => content.document.querySelector("#footer-asrouter-container .SimpleSnippet"),
+      "Should find the snippet inside the footer container");
+
+    ok(!content.document.querySelector(".below-search-snippet"),
+      "Should not find any snippets below search");
+  },
+});
--- a/browser/components/newtab/test/unit/asrouter/ASRouter.test.js
+++ b/browser/components/newtab/test/unit/asrouter/ASRouter.test.js
@@ -1703,16 +1703,20 @@ describe("ASRouter", () => {
         assert.propertyVal(result, "experiment", expected.experiment);
         assert.propertyVal(result, "interrupt", expected.interrupt);
         assert.propertyVal(result, "triplet", expected.triplet);
       }
       it("should return control experience with no experiment if locale is NOT in TRAILHEAD_LOCALES", async () => {
         sandbox.stub(global.Services.locale, "appLocaleAsLangTag").get(() => "zh-CN");
         checkReturnValue({experiment: "", interrupt: "control", triplet: ""});
       });
+      it("should return control experience with no experiment if attribution data contains an addon source", async () => {
+        sandbox.stub(fakeAttributionCode, "getAttrDataAsync").resolves({source: "addons.mozilla.org"});
+        checkReturnValue({experiment: "", interrupt: "control", triplet: ""});
+      });
       it("should use values in override pref if it is set with no experiment", async () => {
         getStringPrefStub.withArgs(TRAILHEAD_CONFIG.OVERRIDE_PREF).returns("join-privacy");
         checkReturnValue({experiment: "", interrupt: "join", triplet: "privacy"});
 
         getStringPrefStub.withArgs(TRAILHEAD_CONFIG.OVERRIDE_PREF).returns("nofirstrun");
         checkReturnValue({experiment: "", interrupt: "nofirstrun", triplet: ""});
       });
       it("should return control experience with no experiment if locale is NOT in TRAILHEAD_LOCALES", async () => {
@@ -1723,16 +1727,29 @@ describe("ASRouter", () => {
         sandbox.stub(global.Services.locale, "appLocaleAsLangTag").get(() => "zh-CN");
         checkReturnValue({experiment: "", interrupt: "control", triplet: ""});
       });
       it("should roll for experiment if locale is in TRAILHEAD_LOCALES", async () => {
         sandbox.stub(global.Sampling, "ratioSample").resolves(1); // 1 = interrupts experiment
         sandbox.stub(global.Services.locale, "appLocaleAsLangTag").get(() => "en-US");
         checkReturnValue({experiment: "interrupts", interrupt: "join", triplet: "supercharge"});
       });
+      it("should roll for experiment if attribution data is empty", async () => {
+        sandbox.stub(global.Sampling, "ratioSample").resolves(1); // 1 = interrupts experiment
+        sandbox.stub(global.Services.locale, "appLocaleAsLangTag").get(() => "en-US");
+        sandbox.stub(fakeAttributionCode, "getAttrDataAsync").resolves(null);
+
+        checkReturnValue({experiment: "interrupts", interrupt: "join", triplet: "supercharge"});
+      });
+      it("should roll for experiment if attribution data rejects with an error", async () => {
+        sandbox.stub(global.Sampling, "ratioSample").resolves(1); // 1 = interrupts experiment
+        sandbox.stub(global.Services.locale, "appLocaleAsLangTag").get(() => "en-US");
+        sandbox.stub(fakeAttributionCode, "getAttrDataAsync").rejects(new Error("whoops"));
+        checkReturnValue({experiment: "interrupts", interrupt: "join", triplet: "supercharge"});
+      });
       it("should roll a triplet experiment", async () => {
         sandbox.stub(global.Sampling, "ratioSample").resolves(2); // 2 = triplets experiment
         sandbox.stub(global.Services.locale, "appLocaleAsLangTag").get(() => "en-US");
         checkReturnValue({experiment: "triplets", interrupt: "join", triplet: "multidevice"});
       });
       it("should roll no experiment", async () => {
         sandbox.stub(global.Sampling, "ratioSample").resolves(0); // 0 = no experiment
         sandbox.stub(global.Services.locale, "appLocaleAsLangTag").get(() => "en-US");
--- a/browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx
+++ b/browser/components/newtab/test/unit/asrouter/templates/SimpleBelowSearchSnippet.test.jsx
@@ -35,13 +35,18 @@ describe("SimpleBelowSearchSnippet", () 
     sandbox.restore();
   });
 
   it("should render .text", () => {
     const wrapper = mountAndCheckProps({text: "bar"});
     assert.equal(wrapper.find(".body").text(), "bar");
   });
 
-  it("should render .icon", () => {
+  it("should render .icon (light theme)", () => {
     const wrapper = mountAndCheckProps({icon: "data:image/gif;base64,R0lGODl"});
-    assert.equal(wrapper.find(".icon").prop("src"), "data:image/gif;base64,R0lGODl");
+    assert.equal(wrapper.find(".icon-light-theme").prop("src"), "data:image/gif;base64,R0lGODl");
+  });
+
+  it("should render .icon (dark theme)", () => {
+    const wrapper = mountAndCheckProps({icon_dark_theme: "data:image/gif;base64,R0lGODl"});
+    assert.equal(wrapper.find(".icon-dark-theme").prop("src"), "data:image/gif;base64,R0lGODl");
   });
 });
--- a/browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx
+++ b/browser/components/newtab/test/unit/asrouter/templates/SimpleSnippet.test.jsx
@@ -45,19 +45,27 @@ describe("SimpleSnippet", () => {
   it("should not render title element if no .title prop is supplied", () => {
     const wrapper = mountAndCheckProps();
     assert.lengthOf(wrapper.find(".title"), 0);
   });
   it("should render .title", () => {
     const wrapper = mountAndCheckProps({title: "Foo"});
     assert.equal(wrapper.find(".title").text().trim(), "Foo");
   });
-  it("should render .icon", () => {
+  it("should render a light theme variant .icon", () => {
     const wrapper = mountAndCheckProps({icon: "data:image/gif;base64,R0lGODl"});
-    assert.equal(wrapper.find(".icon").prop("src"), "data:image/gif;base64,R0lGODl");
+    assert.equal(wrapper.find(".icon-light-theme").prop("src"), "data:image/gif;base64,R0lGODl");
+  });
+  it("should render a dark theme variant .icon", () => {
+    const wrapper = mountAndCheckProps({icon_dark_theme: "data:image/gif;base64,R0lGODl"});
+    assert.equal(wrapper.find(".icon-dark-theme").prop("src"), "data:image/gif;base64,R0lGODl");
+  });
+  it("should render a light theme variant .icon as fallback", () => {
+    const wrapper = mountAndCheckProps({icon_dark_theme: "", icon: "data:image/gif;base64,R0lGODp"});
+    assert.equal(wrapper.find(".icon-dark-theme").prop("src"), "data:image/gif;base64,R0lGODp");
   });
   it("should render .button_label and default className", () => {
     const wrapper = mountAndCheckProps({
       button_label: "Click here",
       button_action: "OPEN_APPLICATIONS_MENU",
       button_action_args: "appMenu",
     });
 
@@ -76,23 +84,35 @@ describe("SimpleSnippet", () => {
   it("should wrap the main content if a section header is present", () => {
     const wrapper = mountAndCheckProps({
       section_title_icon: "data:image/gif;base64,R0lGODl",
       section_title_text: "Messages from Mozilla",
     });
 
     assert.lengthOf(wrapper.find(".innerContentWrapper"), 1);
   });
-  it("should render a section header if text and icon are specified", () => {
+  it("should render a section header if text and icon (light-theme) are specified", () => {
     const wrapper = mountAndCheckProps({
       section_title_icon: "data:image/gif;base64,R0lGODl",
       section_title_text: "Messages from Mozilla",
     });
 
-    assert.equal(wrapper.find(".section-title .icon").prop("style").backgroundImage, 'url("data:image/gif;base64,R0lGODl")');
+    assert.equal(wrapper.find(".section-title .icon-light-theme").prop("style").backgroundImage, 'url("data:image/gif;base64,R0lGODl")');
+    assert.equal(wrapper.find(".section-title-text").text().trim(), "Messages from Mozilla");
+    // ensure there is no <a> when a section_title_url is not specified
+    assert.lengthOf(wrapper.find(".section-title a"), 0);
+  });
+  it("should render a section header if text and icon (light-theme) are specified", () => {
+    const wrapper = mountAndCheckProps({
+      section_title_icon: "data:image/gif;base64,R0lGODl",
+      section_title_icon_dark_theme: "data:image/gif;base64,R0lGODl",
+      section_title_text: "Messages from Mozilla",
+    });
+
+    assert.equal(wrapper.find(".section-title .icon-dark-theme").prop("style").backgroundImage, 'url("data:image/gif;base64,R0lGODl")');
     assert.equal(wrapper.find(".section-title-text").text().trim(), "Messages from Mozilla");
     // ensure there is no <a> when a section_title_url is not specified
     assert.lengthOf(wrapper.find(".section-title a"), 0);
   });
   it("should render a section header wrapped in an <a> tag if a url is provided", () => {
     const wrapper = mountAndCheckProps({
       section_title_icon: "data:image/gif;base64,R0lGODl",
       section_title_text: "Messages from Mozilla",
--- a/browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx
+++ b/browser/components/newtab/test/unit/asrouter/templates/SubmitFormSnippet.test.jsx
@@ -53,19 +53,23 @@ describe("SubmitFormSnippet", () => {
   it("should not render title element if no .title prop is supplied", () => {
     const wrapper = mountAndCheckProps();
     assert.lengthOf(wrapper.find(".title"), 0);
   });
   it("should render .title", () => {
     const wrapper = mountAndCheckProps({scene1_title: "Foo"});
     assert.equal(wrapper.find(".title").text().trim(), "Foo");
   });
-  it("should render .icon", () => {
+  it("should render light-theme .icon", () => {
     const wrapper = mountAndCheckProps({scene1_icon: "data:image/gif;base64,R0lGODl"});
-    assert.equal(wrapper.find(".icon").prop("src"), "data:image/gif;base64,R0lGODl");
+    assert.equal(wrapper.find(".icon-light-theme").prop("src"), "data:image/gif;base64,R0lGODl");
+  });
+  it("should render dark-theme .icon", () => {
+    const wrapper = mountAndCheckProps({scene1_icon_dark_theme: "data:image/gif;base64,R0lGODl"});
+    assert.equal(wrapper.find(".icon-dark-theme").prop("src"), "data:image/gif;base64,R0lGODl");
   });
   it("should render .button_label and default className", () => {
     const wrapper = mountAndCheckProps({scene1_button_label: "Click here"});
 
     const button = wrapper.find("button.ASRouterButton");
     assert.equal(button.text(), "Click here");
     assert.equal(button.prop("className"), "ASRouterButton secondary");
   });
--- a/browser/components/newtab/test/unit/content-src/components/ASRouterAdmin.test.jsx
+++ b/browser/components/newtab/test/unit/content-src/components/ASRouterAdmin.test.jsx
@@ -167,17 +167,17 @@ describe("ASRouterAdmin", () => {
       it("should hide message if provider filter changes", () => {
         wrapper.setState({
           messageFilter: "messageProvider",
           messages: [{id: "foo", provider: "messageProvider"}],
         });
 
         assert.lengthOf(wrapper.find(".message-id"), 1);
 
-        wrapper.find("select").simulate("change", {target: {value: "bar"}});
+        wrapper.find("select").simulate("blur", {target: {value: "bar"}});
 
         assert.lengthOf(wrapper.find(".message-id"), 0);
       });
     });
   });
   describe("#DiscoveryStream", () => {
     it("should render a DiscoveryStreamAdmin component", () => {
       wrapper = shallow(<DiscoveryStreamAdmin otherPrefs={{}} state={{
--- a/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js
+++ b/browser/components/newtab/test/unit/content-src/lib/selectLayoutRender.test.js
@@ -1,16 +1,15 @@
 import {combineReducers, createStore} from "redux";
 import {actionTypes as at} from "common/Actions.jsm";
 import {GlobalOverrider} from "test/unit/utils";
 import {reducers} from "common/Reducers.jsm";
 import {selectLayoutRender} from "content-src/lib/selectLayoutRender";
-
-const FAKE_LAYOUT = [{width: 3, components: [{type: "foo", feed: {url: "foo.com"}}]}];
-const FAKE_FEEDS = {"foo.com": {data: {recommendations: ["foo", "bar"]}}};
+const FAKE_LAYOUT = [{width: 3, components: [{type: "foo", feed: {url: "foo.com"}, properties: {items: 2}}]}];
+const FAKE_FEEDS = {"foo.com": {data: {recommendations: [{id: "foo"}, {id: "bar"}]}}};
 
 describe("selectLayoutRender", () => {
   let store;
   let globals;
 
   beforeEach(() => {
     globals = new GlobalOverrider();
     store = createStore(combineReducers(reducers));
@@ -34,39 +33,39 @@ describe("selectLayoutRender", () => {
     store.dispatch({type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, data: {layout: FAKE_LAYOUT}});
     store.dispatch({type: at.DISCOVERY_STREAM_FEED_UPDATE, data: {feed: FAKE_FEEDS["foo.com"], url: "foo.com"}});
     store.dispatch({type: at.DISCOVERY_STREAM_FEEDS_UPDATE});
 
     const {layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {}, []);
 
     assert.lengthOf(layoutRender, 1);
     assert.propertyVal(layoutRender[0], "width", 3);
-    assert.deepEqual(layoutRender[0].components[0], {type: "foo", feed: {url: "foo.com"}, data: {recommendations: ["foo", "bar"]}});
+    assert.deepEqual(layoutRender[0].components[0], {type: "foo", feed: {url: "foo.com"}, properties: {items: 2}, data: {recommendations: [{id: "foo", pos: 0}, {id: "bar", pos: 1}]}});
   });
 
-  it("should return layout property without data if feed isn't available", () => {
+  it("should return layout with placeholder data if feed isn't available", () => {
     store.dispatch({type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, data: {layout: FAKE_LAYOUT}});
     store.dispatch({type: at.DISCOVERY_STREAM_FEEDS_UPDATE});
 
     const {layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {}, []);
 
     assert.lengthOf(layoutRender, 1);
     assert.propertyVal(layoutRender[0], "width", 3);
-    assert.deepEqual(layoutRender[0].components.length, 0);
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations, [{placeholder: true}, {placeholder: true}]);
   });
 
   it("should return feed data offset by layout set prop", () => {
     const fakeLayout = [{width: 3, components: [{type: "foo", properties: {offset: 1}, feed: {url: "foo.com"}}]}];
     store.dispatch({type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, data: {layout: fakeLayout}});
     store.dispatch({type: at.DISCOVERY_STREAM_FEED_UPDATE, data: {feed: FAKE_FEEDS["foo.com"], url: "foo.com"}});
     store.dispatch({type: at.DISCOVERY_STREAM_FEEDS_UPDATE});
 
     const {layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {}, []);
 
-    assert.deepEqual(layoutRender[0].components[0].data, {recommendations: ["bar"]});
+    assert.deepEqual(layoutRender[0].components[0].data, {recommendations: [{id: "bar"}]});
   });
 
   it("should return spoc result and spocs fill for rolls below the probability", () => {
     const fakeSpocConfig = {positions: [{index: 0}, {index: 1}], probability: 0.5};
     const fakeLayout = [{width: 3, components: [{type: "foo", feed: {url: "foo.com"}, spocs: fakeSpocConfig}]}];
     const fakeSpocsData = {lastUpdated: 0, spocs: {spocs: ["fooSpoc", "barSpoc"]}};
 
     store.dispatch({type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, data: {layout: fakeLayout}});
@@ -76,18 +75,18 @@ describe("selectLayoutRender", () => {
     const randomStub = globals.sandbox.stub(global.Math, "random").returns(0.1);
 
     const {spocsFill, layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {}, []);
 
     assert.calledTwice(randomStub);
     assert.lengthOf(layoutRender, 1);
     assert.deepEqual(layoutRender[0].components[0].data.recommendations[0], "fooSpoc");
     assert.deepEqual(layoutRender[0].components[0].data.recommendations[1], "barSpoc");
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[2], "foo");
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[3], "bar");
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[2], {id: "foo"});
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[3], {id: "bar"});
 
     assert.deepEqual(spocsFill, [
       {id: undefined, reason: "n/a", displayed: 1, full_recalc: 0},
       {id: undefined, reason: "n/a", displayed: 1, full_recalc: 0},
     ]);
   });
 
   it("should return spoc result and spocs fill when there are more positions than spocs", () => {
@@ -102,18 +101,18 @@ describe("selectLayoutRender", () => {
     const randomStub = globals.sandbox.stub(global.Math, "random").returns(0.1);
 
     const {spocsFill, layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {}, []);
 
     assert.calledTwice(randomStub);
     assert.lengthOf(layoutRender, 1);
     assert.deepEqual(layoutRender[0].components[0].data.recommendations[0], "fooSpoc");
     assert.deepEqual(layoutRender[0].components[0].data.recommendations[1], "barSpoc");
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[2], "foo");
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[3], "bar");
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[2], {id: "foo"});
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[3], {id: "bar"});
 
     assert.deepEqual(spocsFill, [
       {id: undefined, reason: "n/a", displayed: 1, full_recalc: 0},
       {id: undefined, reason: "n/a", displayed: 1, full_recalc: 0},
     ]);
   });
 
   it("should report non-displayed spocs with reason as probability_selection and out_of_position", () => {
@@ -126,19 +125,19 @@ describe("selectLayoutRender", () => {
     store.dispatch({type: at.DISCOVERY_STREAM_FEEDS_UPDATE});
     store.dispatch({type: at.DISCOVERY_STREAM_SPOCS_UPDATE, data: fakeSpocsData});
     const randomStub = globals.sandbox.stub(global.Math, "random");
 
     const {spocsFill, layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {}, [0.7, 0.3, 0.8]);
 
     assert.notCalled(randomStub);
     assert.lengthOf(layoutRender, 1);
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[0], "foo");
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[0], {id: "foo"});
     assert.deepEqual(layoutRender[0].components[0].data.recommendations[1], "fooSpoc");
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[2], "bar");
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[2], {id: "bar"});
 
     assert.deepEqual(spocsFill, [
       {id: undefined, reason: "n/a", displayed: 1, full_recalc: 0},
       {id: undefined, reason: "probability_selection", displayed: 0, full_recalc: 0},
       {id: undefined, reason: "out_of_position", displayed: 0, full_recalc: 0},
     ]);
   });
 
@@ -152,18 +151,18 @@ describe("selectLayoutRender", () => {
     store.dispatch({type: at.DISCOVERY_STREAM_FEEDS_UPDATE});
     store.dispatch({type: at.DISCOVERY_STREAM_SPOCS_UPDATE, data: fakeSpocsData});
     const randomStub = globals.sandbox.stub(global.Math, "random").returns(0.6);
 
     const {spocsFill, layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {}, []);
 
     assert.calledTwice(randomStub);
     assert.lengthOf(layoutRender, 1);
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[0], "foo");
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[1], "bar");
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[0], {id: "foo"});
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[1], {id: "bar"});
 
     assert.deepEqual(spocsFill, [
       {id: undefined, reason: "probability_selection", displayed: 0, full_recalc: 0},
       {id: undefined, reason: "out_of_position", displayed: 0, full_recalc: 0},
     ]);
   });
 
   it("Subsequent render should return spoc result for cached rolls below the probability", () => {
@@ -178,18 +177,18 @@ describe("selectLayoutRender", () => {
     const randomStub = globals.sandbox.stub(global.Math, "random");
 
     const {spocsFill, layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {}, [0.4, 0.3]);
 
     assert.notCalled(randomStub);
     assert.lengthOf(layoutRender, 1);
     assert.deepEqual(layoutRender[0].components[0].data.recommendations[0], "fooSpoc");
     assert.deepEqual(layoutRender[0].components[0].data.recommendations[1], "barSpoc");
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[2], "foo");
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[3], "bar");
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[2], {id: "foo"});
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[3], {id: "bar"});
 
     assert.deepEqual(spocsFill, [
       {id: undefined, reason: "n/a", displayed: 1, full_recalc: 0},
       {id: undefined, reason: "n/a", displayed: 1, full_recalc: 0},
     ]);
   });
 
   it("Subsequent render should not return spoc result for cached rolls above the probability", () => {
@@ -202,18 +201,18 @@ describe("selectLayoutRender", () => {
     store.dispatch({type: at.DISCOVERY_STREAM_FEEDS_UPDATE});
     store.dispatch({type: at.DISCOVERY_STREAM_SPOCS_UPDATE, data: fakeSpocsData});
     const randomStub = globals.sandbox.stub(global.Math, "random");
 
     const {spocsFill, layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {}, [0.6, 0.7]);
 
     assert.notCalled(randomStub);
     assert.lengthOf(layoutRender, 1);
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[0], "foo");
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[1], "bar");
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[0], {id: "foo"});
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[1], {id: "bar"});
 
     assert.deepEqual(spocsFill, [
       {id: undefined, reason: "probability_selection", displayed: 0, full_recalc: 0},
       {id: undefined, reason: "out_of_position", displayed: 0, full_recalc: 0},
     ]);
   });
 
   it("Subsequent render should return spoc result by cached rolls probability", () => {
@@ -226,19 +225,19 @@ describe("selectLayoutRender", () => {
     store.dispatch({type: at.DISCOVERY_STREAM_FEEDS_UPDATE});
     store.dispatch({type: at.DISCOVERY_STREAM_SPOCS_UPDATE, data: fakeSpocsData});
     const randomStub = globals.sandbox.stub(global.Math, "random");
 
     const {spocsFill, layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {}, [0.7, 0.2]);
 
     assert.notCalled(randomStub);
     assert.lengthOf(layoutRender, 1);
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[0], "foo");
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[0], {id: "foo"});
     assert.deepEqual(layoutRender[0].components[0].data.recommendations[1], "fooSpoc");
-    assert.deepEqual(layoutRender[0].components[0].data.recommendations[2], "bar");
+    assert.deepEqual(layoutRender[0].components[0].data.recommendations[2], {id: "bar"});
 
     assert.deepEqual(spocsFill, [
       {id: undefined, reason: "n/a", displayed: 1, full_recalc: 0},
       {id: undefined, reason: "out_of_position", displayed: 0, full_recalc: 0},
     ]);
   });
 
   it("should return a layout with feeds of items length with positions", () => {
@@ -278,17 +277,19 @@ describe("selectLayoutRender", () => {
     }];
     store.dispatch({type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, data: {layout: fakeLayout}});
     store.dispatch({type: at.DISCOVERY_STREAM_FEED_UPDATE, data: {feed: {data: {recommendations: []}}, url: "foo2.com"}});
 
     const {layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {}, []);
 
     assert.equal(layoutRender[0].components[0].type, "foo1");
     assert.equal(layoutRender[0].components[1].type, "foo2");
-    assert.equal(layoutRender[0].components[2], undefined);
+    assert.isTrue(layoutRender[0].components[2].data.recommendations[0].placeholder);
+    assert.lengthOf(layoutRender[0].components, 3);
+    assert.isUndefined(layoutRender[0].components[3]);
   });
   it("should render everything if everything is ready", () => {
     const fakeLayout = [{
       width: 3,
       components: [
         {type: "foo1"},
         {type: "foo2", properties: {items: 3}, feed: {url: "foo2.com"}},
         {type: "foo3", properties: {items: 3}, feed: {url: "foo3.com"}},
@@ -324,17 +325,17 @@ describe("selectLayoutRender", () => {
     store.dispatch({type: at.DISCOVERY_STREAM_FEED_UPDATE, data: {feed: {data: {recommendations: []}}, url: "foo2.com"}});
     store.dispatch({type: at.DISCOVERY_STREAM_FEED_UPDATE, data: {feed: {data: {recommendations: []}}, url: "foo3.com"}});
     store.dispatch({type: at.DISCOVERY_STREAM_FEED_UPDATE, data: {feed: {data: {recommendations: []}}, url: "foo4.com"}});
 
     const {layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {}, []);
 
     assert.equal(layoutRender[0].components[0].type, "foo1");
     assert.equal(layoutRender[0].components[1].type, "foo2");
-    assert.equal(layoutRender[0].components[2], undefined);
+    assert.deepEqual(layoutRender[0].components[2].data.recommendations, [{placeholder: true}, {placeholder: true}, {placeholder: true}]);
   });
   it("should not render a spoc if there are no available spocs", () => {
     const fakeLayout = [{
       width: 3,
       components: [
         {type: "foo1"},
         {type: "foo2", properties: {items: 3}, feed: {url: "foo2.com"}},
         {type: "foo3", properties: {items: 3}, feed: {url: "foo3.com"}, spocs: {positions: [{index: 0, probability: 1}]}},
@@ -367,9 +368,24 @@ describe("selectLayoutRender", () => {
     }];
     store.dispatch({type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, data: {layout: fakeLayout}});
 
     const {layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {"feeds.topsites": true}, []);
 
     assert.equal(layoutRender[0].components[0].type, "TopSites");
     assert.equal(layoutRender[1], undefined);
   });
+  it("should not render a component if filtered", () => {
+    const fakeLayout = [{
+      width: 3,
+      components: [
+        {type: "Message"},
+        {type: "TopSites"},
+      ],
+    }];
+    store.dispatch({type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, data: {layout: fakeLayout}});
+
+    const {layoutRender} = selectLayoutRender(store.getState().DiscoveryStream, {"feeds.topsites": true}, []);
+
+    assert.equal(layoutRender[0].components[0].type, "TopSites");
+    assert.equal(layoutRender[0].components[1], undefined);
+  });
 });
--- a/browser/components/newtab/yamscripts.yml
+++ b/browser/components/newtab/yamscripts.yml
@@ -59,27 +59,23 @@ scripts:
 
   tddmc: karma start karma.mc.config.js --tdd
 
   debugcoverage: open logs/coverage/index.html
 
 # lint: Run eslint and sass-lint
   lint:
     eslint: esw --ext=.js,.jsm,.json,.jsx .
-    jsx-a11y: esw --config=.eslintrc.jsx-a11y.js --ext=.jsx content-src/asrouter/components/ModalOverlay content-src/asrouter/templates/OnboardingMessage content-src/asrouter/templates/Trailhead
+    jsx-a11y: esw --config=.eslintrc.jsx-a11y.js --ext=.jsx content-src/asrouter content-src/components/ASRouterAdmin 
     sasslint: sass-lint -v -q
 
 # strings-import: Replace local strings with those from l10n-central
   strings-import: node ./bin/strings-import.js
 
 # test: Run all tests once
   test: =>testmc
 
 # tdd: Run content tests continuously
   tdd: =>tddmc
 
-  # This is just to make sure we don't make commits with failing tests
-  # or uncompiled yamscripts.yml. Run automatically with husky.
-  prepush: =>lint && =>yamscripts
-
   # Utility scripts for use when vendoring in Node packages
   vendor:
     react: node ./bin/vendor-react.js