Bug 1577888 - Add whatsnew badge, optimistic cards and bug fixes to New Tab Page r=k88hudson
authorEd Lee <edilee@mozilla.com>
Fri, 30 Aug 2019 23:28:49 +0000
changeset 490958 9a0918622c83a226c6208ff7ea7a99df2d9a6bc2
parent 490957 efca0164a7621c926a4661314d2120c67754ed0d
child 490959 ee9ff899ce0c6dba4270b8259c1eb85497e9c910
push id36515
push userdluca@mozilla.com
push dateSat, 31 Aug 2019 09:47:12 +0000
treeherdermozilla-central@56db66978b42 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersk88hudson
bugs1577888
milestone70.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 1577888 - Add whatsnew badge, optimistic cards and bug fixes to New Tab Page r=k88hudson Differential Revision: https://phabricator.services.mozilla.com/D44271
browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx
browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss
browser/components/newtab/content-src/asrouter/templates/Trailhead/Trailhead.jsx
browser/components/newtab/content-src/asrouter/templates/Trailhead/_Trailhead.scss
browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx
browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss
browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
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/lib/OnboardingMessageProvider.jsm
browser/components/newtab/lib/PanelTestProvider.jsm
browser/components/newtab/test/browser/browser.ini
browser/components/newtab/test/browser/browser_asrouter_whatsnewpanel.js
browser/components/newtab/test/unit/asrouter/PanelTestProvider.test.js
browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx
browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
--- a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx
@@ -24,17 +24,17 @@ export class OnboardingCard extends Reac
   render() {
     const { content } = this.props;
     const className = this.props.className || "onboardingMessage";
     return (
       <div className={className}>
         <div className={`onboardingMessageImage ${content.icon}`} />
         <div className="onboardingContent">
           <span>
-            <h3
+            <h2
               className="onboardingTitle"
               data-l10n-id={content.title.string_id}
             />
             <p
               className="onboardingText"
               data-l10n-id={content.text.string_id}
             />
           </span>
--- a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss
@@ -81,17 +81,18 @@
       padding: 0;
       text-align: inherit;
     }
 
     @media (min-width: $break-point-medium) {
       @include full-width-styles;
     }
 
-    @media (max-width: 1120px) {
+    // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 1121px.
+    @media (max-width: $break-point-widest + 1px) {
       margin: 0 60px;
     }
 
     @media (max-width: 865px) {
       margin-inline-start: 0;
     }
 
     // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 610px.
@@ -170,17 +171,18 @@
       margin: auto;
       top: unset;
 
       &:focus {
         opacity: 1;
         box-shadow: none;
       }
 
-      @media (max-width: 1120px) {
+      // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 1121px.
+      @media (max-width: $break-point-widest + 1px) {
         inset-inline-end: 2%;
       }
 
       .ds-outer-wrapper-breakpoint-override & {
         inset-inline-end: -10%;
         margin: auto;
 
         @media (max-width: 865px) {
--- a/browser/components/newtab/content-src/asrouter/templates/Trailhead/Trailhead.jsx
+++ b/browser/components/newtab/content-src/asrouter/templates/Trailhead/Trailhead.jsx
@@ -143,17 +143,17 @@ export class Trailhead extends React.Pur
           <div className="trailheadContent">
             <h1 data-l10n-id={content.title.string_id} id="trailheadHeader" />
             {content.subtitle && (
               <p data-l10n-id={content.subtitle.string_id} />
             )}
             <ul className="trailheadBenefits">
               {content.benefits.map(item => (
                 <li key={item.id} className={item.id}>
-                  <h3 data-l10n-id={item.title.string_id} />
+                  <h2 data-l10n-id={item.title.string_id} />
                   <p data-l10n-id={item.text.string_id} />
                 </li>
               ))}
             </ul>
             <a
               className="trailheadLearn"
               data-l10n-id={content.learn.text.string_id}
               href={addUtmParams(content.learn.url, UTMTerm)}
--- a/browser/components/newtab/content-src/asrouter/templates/Trailhead/_Trailhead.scss
+++ b/browser/components/newtab/content-src/asrouter/templates/Trailhead/_Trailhead.scss
@@ -108,17 +108,19 @@
         background-image: url('#{$image-path}trailhead/benefit-privacy.png');
       }
 
       &.products {
         background-image: url('#{$image-path}trailhead/benefit-products.png');
       }
     }
 
-    h3 {
+    h2 {
+      text-align: start;
+      line-height: inherit;
       color: $violet-20;
       font-size: 22px;
       font-weight: 400;
       margin: 0 0 4px;
       padding-inline-start: $benefit-icon-spacing-small;
 
       @media (min-width: $responsive-breakpoint) {
         padding-inline-start: 0;
--- a/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
@@ -138,16 +138,17 @@ export class _DiscoveryStreamBase extend
           !component.data.spocs[0]
         ) {
           return null;
         }
         // Grab the first item in the array as we only have 1 spoc position.
         const [spoc] = component.data.spocs;
         const {
           image_src,
+          raw_image_src,
           alt_text,
           title,
           url,
           context,
           cta,
           campaign_id,
           id,
           shim,
@@ -161,16 +162,17 @@ export class _DiscoveryStreamBase extend
               shim: spoc.shim,
             }}
             dispatch={this.props.dispatch}
             shouldSendImpressionStats={true}
           >
             <DSTextPromo
               dispatch={this.props.dispatch}
               image={image_src}
+              raw_image_src={raw_image_src}
               alt_text={alt_text || title}
               header={title}
               cta_text={cta}
               cta_url={url}
               subtitle={context}
               campaignId={campaign_id}
               id={id}
               pos={0}
--- a/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss
@@ -46,18 +46,16 @@
   }
 }
 
 .collapsible-section.ds-layout {
   margin: auto;
   width: $ds-width + 2 * $section-horizontal-padding;
 
   .section-top-bar {
-    margin-bottom: 0;
-
     .learn-more-link a {
       color: var(--newtab-link-primary-color);
       font-weight: normal;
 
       &:-moz-any(:focus, :hover) {
         text-decoration: underline;
       }
     }
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
@@ -71,17 +71,19 @@ export class CardGrid extends React.Pure
       return null;
     }
 
     // Handle the case where a user has dismissed all recommendations
     const isEmpty = data.recommendations.length === 0;
 
     return (
       <div>
-        <div className="ds-header">{this.props.title}</div>
+        {this.props.title && (
+          <div className="ds-header">{this.props.title}</div>
+        )}
         {isEmpty ? (
           <div className="ds-card-grid empty">
             <DSEmptyState
               status={data.status}
               dispatch={this.props.dispatch}
               feed={this.props.feed}
             />
           </div>
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss
@@ -1,15 +1,14 @@
 $col4-header-line-height: 20;
 $col4-header-font-size: 14;
 
 .ds-card-grid {
   display: grid;
   grid-gap: 24px;
-  margin: 16px 0;
 
   .ds-card {
     @include dark-theme-only {
       background: none;
     }
 
     background: $white;
     border-radius: 4px;
@@ -63,17 +62,17 @@
       grid-template-columns: repeat(3, 1fr);
 
       .title {
         font-size: 17px;
         line-height: 24px;
       }
 
       .excerpt {
-        @include limit-visibile-lines(4, 24, 15);
+        @include limit-visibile-lines(3, 24, 15);
       }
     }
 
     &.ds-card-grid-divisible-by-4 .title {
       @include limit-visibile-lines(3, 20, 15);
     }
   }
 
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
@@ -124,17 +124,31 @@ export class DSCard extends React.PureCo
         // Stop observing since element has been seen
         this.setState({
           isSeen: true,
         });
       }
     }
   }
 
+  onIdleCallback() {
+    if (!this.state.isSeen) {
+      if (this.observer && this.placholderElement) {
+        this.observer.unobserve(this.placholderElement);
+      }
+      this.setState({
+        isSeen: true,
+      });
+    }
+  }
+
   componentDidMount() {
+    this.idleCallbackId = window.requestIdleCallback(
+      this.onIdleCallback.bind(this)
+    );
     if (this.placholderElement) {
       this.observer = new IntersectionObserver(this.onSeen.bind(this));
       this.observer.observe(this.placholderElement);
     }
   }
 
   componentWillUnmount() {
     // Remove observer on unmount
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
@@ -92,17 +92,17 @@
       // show only 3 lines of copy
       @include limit-visibile-lines(3, $header-line-height, $header-font-size);
       font-weight: 600;
     }
 
     .excerpt {
       // show only 3 lines of copy
       @include limit-visibile-lines(
-        4,
+        3,
         $excerpt-line-height,
         $excerpt-font-size
       );
     }
 
     .source {
       @include dark-theme-only {
         color: $grey-40;
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
@@ -10,66 +10,86 @@ export class DSImage extends React.PureC
     super(props);
 
     this.onOptimizedImageError = this.onOptimizedImageError.bind(this);
     this.onNonOptimizedImageError = this.onNonOptimizedImageError.bind(this);
 
     this.state = {
       isSeen: false,
       optimizedImageFailed: false,
+      useTransition: false,
     };
   }
 
   onSeen(entries) {
     if (this.state) {
       const entry = entries.find(e => e.isIntersecting);
 
       if (entry) {
         if (this.props.optimize) {
           this.setState({
-            containerWidth: entry.boundingClientRect.width,
-            containerHeight: entry.boundingClientRect.height,
+            // Thumbor doesn't handle subpixels and just errors out, so rounding...
+            containerWidth: Math.round(entry.boundingClientRect.width),
+            containerHeight: Math.round(entry.boundingClientRect.height),
           });
         }
 
         this.setState({
           isSeen: true,
         });
 
         // Stop observing since element has been seen
         this.observer.unobserve(ReactDOM.findDOMNode(this));
       }
     }
   }
 
+  onIdleCallback() {
+    if (!this.state.isSeen) {
+      this.setState({
+        useTransition: true,
+      });
+    }
+  }
+
   reformatImageURL(url, width, height) {
     // Change the image URL to request a size tailored for the parent container width
     // Also: force JPEG, quality 60, no upscaling, no EXIF data
     // Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html
     return `https://img-getpocket.cdn.mozilla.net/${width}x${height}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent(
       url
     )}`;
   }
 
   componentDidMount() {
-    this.observer = new IntersectionObserver(this.onSeen.bind(this));
+    this.idleCallbackId = window.requestIdleCallback(
+      this.onIdleCallback.bind(this)
+    );
+    this.observer = new IntersectionObserver(this.onSeen.bind(this), {
+      // Assume an image will be eventually seen if it is within
+      // half the average Desktop vertical screen size:
+      // http://gs.statcounter.com/screen-resolution-stats/desktop/north-america
+      rootMargin: `540px`,
+    });
     this.observer.observe(ReactDOM.findDOMNode(this));
   }
 
   componentWillUnmount() {
     // Remove observer on unmount
     if (this.observer) {
       this.observer.unobserve(ReactDOM.findDOMNode(this));
     }
   }
 
   render() {
-    const classNames = `ds-image${
-      this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``
-    }`;
+    let classNames = `ds-image
+      ${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``}
+      ${this.state && this.state.useTransition ? ` use-transition` : ``}
+      ${this.state && this.state.isSeen ? ` loaded` : ``}
+    `;
 
     let img;
 
     if (this.state && this.state.isSeen) {
       if (
         this.props.optimize &&
         this.props.rawSource &&
         !this.state.optimizedImageFailed
@@ -89,28 +109,28 @@ export class DSImage extends React.PureC
           source2x = this.reformatImageURL(
             baseSource,
             this.state.containerWidth * 2,
             this.state.containerHeight * 2
           );
 
           img = (
             <img
-              alt=""
+              alt={this.props.alt_text}
               crossOrigin="anonymous"
               onError={this.onOptimizedImageError}
               src={source}
               srcSet={`${source2x} 2x`}
             />
           );
         }
       } else if (!this.state.nonOptimizedImageFailed) {
         img = (
           <img
-            alt=""
+            alt={this.props.alt_text}
             crossOrigin="anonymous"
             onError={this.onNonOptimizedImageError}
             src={this.props.source}
           />
         );
       } else {
         // Remove the img element if both sources fail. Render a placeholder instead.
         img = <div className="broken-image" />;
@@ -134,9 +154,10 @@ export class DSImage extends React.PureC
   }
 }
 
 DSImage.defaultProps = {
   source: null, // The current source style from Pocket API (always 450px)
   rawSource: null, // Unadulterated image URL to filter through Thumbor
   extraClassNames: null, // Additional classnames to append to component
   optimize: true, // Measure parent container to request exact sizes
+  alt_text: null,
 };
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss
@@ -1,11 +1,20 @@
 .ds-image {
   display: block;
   position: relative;
+  opacity: 0;
+
+  &.use-transition {
+    transition: opacity 0.8s;
+  }
+
+  &.loaded {
+    opacity: 1;
+  }
 
   img,
   .broken-image {
     background-color: var(--newtab-card-placeholder-color);
     position: absolute;
     top: 0;
     width: 100%;
     height: 100%;
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx
@@ -1,13 +1,14 @@
 /* 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/. */
 
 import { actionCreators as ac } from "common/Actions.jsm";
+import { DSImage } from "../DSImage/DSImage.jsx";
 import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
 import React from "react";
 import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
 
 export class DSTextPromo extends React.PureComponent {
   constructor(props) {
     super(props);
     this.onLinkClick = this.onLinkClick.bind(this);
@@ -39,17 +40,21 @@ export class DSTextPromo extends React.P
         })
       );
     }
   }
 
   render() {
     return (
       <div className="ds-text-promo">
-        <img src={this.props.image} alt={this.props.alt_text} />
+        <DSImage
+          alt_text={this.props.alt_text}
+          source={this.props.image}
+          rawSource={this.props.raw_image_src}
+        />
         <div className="text">
           <h3>
             {`${this.props.header}\u2003`}
             <SafeAnchor
               className="ds-chevron-link"
               dispatch={this.props.dispatch}
               onLinkClick={this.onLinkClick}
               url={this.props.cta_url}
--- a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss
@@ -1,18 +1,19 @@
 .ds-text-promo {
   display: flex;
   max-width: 744px;
   margin: 16px auto;
 
-  img {
+  picture {
     width: 40px;
     height: 40px;
     margin: 0 12px 0 0;
     border-radius: 4px;
+    flex-shrink: 0;
   }
 
   .text {
     line-height: 24px;
     margin: -4.5px 0 0;
   }
 
   h3 {
--- a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
@@ -18,18 +18,19 @@ export const INTERSECTION_RATIO = 0.5;
 /**
  * Impression wrapper for Discovery Stream related React components.
  *
  * It makses use of the Intersection Observer API to detect the visibility,
  * and relies on page visibility to ensure the impression is reported
  * only when the component is visible on the page.
  *
  * Note:
- *   * This wrapper could be used either at the individual card level,
- *     or by the card container components
+ *   * This wrapper used to be used either at the individual card level,
+ *     or by the card container components.
+ *     It is now only used for individual card level.
  *   * Each impression will be sent only once as soon as the desired
  *     visibility is detected
  *   * Batching is not yet implemented, hence it might send multiple
  *     impression pings separately
  */
 export class ImpressionStats extends React.PureComponent {
   // This checks if the given cards are the same as those in the last impression ping.
   // If so, it should not send the same impression ping again.
@@ -188,22 +189,16 @@ export class ImpressionStats extends Rea
   }
 
   componentDidMount() {
     if (this.props.rows.length) {
       this.setImpressionObserverOrAddListener();
     }
   }
 
-  componentDidUpdate(prevProps) {
-    if (this.props.rows.length && this.props.rows !== prevProps.rows) {
-      this.setImpressionObserverOrAddListener();
-    }
-  }
-
   componentWillUnmount() {
     if (this._handleIntersect && this.impressionObserver) {
       this.impressionObserver.unobserve(this.refs.impression);
     }
     if (this._onVisibilityChange) {
       this.props.document.removeEventListener(
         VISIBILITY_CHANGE_EVENT,
         this._onVisibilityChange
--- a/browser/components/newtab/css/activity-stream-linux.css
+++ b/browser/components/newtab/css/activity-stream-linux.css
@@ -1887,28 +1887,25 @@ main {
     color: #D7D7DB; }
   .ds-header .icon,
   .ds-layout .section-title span .icon {
     fill: var(--newtab-text-secondary-color); }
 
 .collapsible-section.ds-layout {
   margin: auto;
   width: 986px; }
-  .collapsible-section.ds-layout .section-top-bar {
-    margin-bottom: 0; }
-    .collapsible-section.ds-layout .section-top-bar .learn-more-link a {
-      color: var(--newtab-link-primary-color);
-      font-weight: normal; }
-      .collapsible-section.ds-layout .section-top-bar .learn-more-link a:-moz-any(:focus, :hover) {
-        text-decoration: underline; }
+  .collapsible-section.ds-layout .section-top-bar .learn-more-link a {
+    color: var(--newtab-link-primary-color);
+    font-weight: normal; }
+    .collapsible-section.ds-layout .section-top-bar .learn-more-link a:-moz-any(:focus, :hover) {
+      text-decoration: underline; }
 
 .ds-card-grid {
   display: grid;
-  grid-gap: 24px;
-  margin: 16px 0; }
+  grid-gap: 24px; }
   .ds-card-grid .ds-card {
     background: #FFF;
     border-radius: 4px; }
     [lwt-newtab-brighttext] .ds-card-grid .ds-card {
       background: none; }
   .ds-card-grid .ds-card-link:focus {
     box-shadow: 0 0 0 5px rgba(10, 132, 255, 0.3);
     transition: box-shadow 150ms;
@@ -1946,17 +1943,17 @@ main {
       .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-3 .title {
         font-size: 17px;
         line-height: 24px; }
       .ds-column-9 .ds-card-grid.ds-card-grid-divisible-by-3 .excerpt,
       .ds-column-10 .ds-card-grid.ds-card-grid-divisible-by-3 .excerpt,
       .ds-column-11 .ds-card-grid.ds-card-grid-divisible-by-3 .excerpt,
       .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-3 .excerpt {
         font-size: 15px;
-        -webkit-line-clamp: 4;
+        -webkit-line-clamp: 3;
         line-height: 24px; }
     .ds-column-9 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-10 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-11 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-4 .title {
       font-size: 15px;
       -webkit-line-clamp: 3;
       line-height: 20px; }
@@ -2707,17 +2704,17 @@ main {
       flex-grow: 1; }
     .ds-card .meta .title {
       font-size: 17px;
       -webkit-line-clamp: 3;
       line-height: 24px;
       font-weight: 600; }
     .ds-card .meta .excerpt {
       font-size: 14px;
-      -webkit-line-clamp: 4;
+      -webkit-line-clamp: 3;
       line-height: 20px; }
     .ds-card .meta .source {
       -webkit-line-clamp: 1;
       margin-bottom: 2px;
       font-size: 13px;
       color: #737373; }
       [lwt-newtab-brighttext] .ds-card .meta .source {
         color: #B1B1B3; }
@@ -2865,17 +2862,22 @@ main {
   opacity: 1; }
 
 .story-animate-exit-active {
   opacity: 0;
   transition: opacity 250ms ease-in; }
 
 .ds-image {
   display: block;
-  position: relative; }
+  position: relative;
+  opacity: 0; }
+  .ds-image.use-transition {
+    transition: opacity 0.8s; }
+  .ds-image.loaded {
+    opacity: 1; }
   .ds-image img,
   .ds-image .broken-image {
     background-color: var(--newtab-card-placeholder-color);
     position: absolute;
     top: 0;
     width: 100%;
     height: 100%;
     object-fit: cover; }
@@ -3021,21 +3023,22 @@ main {
 @keyframes spinner {
   to {
     transform: rotate(360deg); } }
 
 .ds-text-promo {
   display: flex;
   max-width: 744px;
   margin: 16px auto; }
-  .ds-text-promo img {
+  .ds-text-promo picture {
     width: 40px;
     height: 40px;
     margin: 0 12px 0 0;
-    border-radius: 4px; }
+    border-radius: 4px;
+    flex-shrink: 0; }
   .ds-text-promo .text {
     line-height: 24px;
     margin: -4.5px 0 0; }
   .ds-text-promo h3 {
     margin: 0;
     font-weight: 600;
     font-size: 15px; }
     [lwt-newtab-brighttext] .ds-text-promo h3 {
@@ -3451,17 +3454,17 @@ body[lwt-newtab-brighttext] .scene2Icon 
       .SimpleBelowSearchSnippet .innerWrapper {
         align-items: flex-start;
         background-color: transparent;
         border-radius: 4px;
         box-shadow: none;
         flex-direction: row;
         padding: 0;
         text-align: inherit; } }
-    @media (max-width: 1120px) {
+    @media (max-width: 1123px) {
       .SimpleBelowSearchSnippet .innerWrapper {
         margin: 0 60px; } }
     @media (max-width: 865px) {
       .SimpleBelowSearchSnippet .innerWrapper {
         margin-inline-start: 0; } }
     @media (max-width: 609px) {
       .SimpleBelowSearchSnippet .innerWrapper {
         margin: auto; } }
@@ -3514,17 +3517,17 @@ body[lwt-newtab-brighttext] .scene2Icon 
       display: block;
       inset-inline-end: -15%;
       opacity: 0;
       margin: auto;
       top: unset; }
       .SimpleBelowSearchSnippet.withButton .blockButton:focus {
         opacity: 1;
         box-shadow: none; }
-      @media (max-width: 1120px) {
+      @media (max-width: 1123px) {
         .SimpleBelowSearchSnippet.withButton .blockButton {
           inset-inline-end: 2%; } }
       .ds-outer-wrapper-breakpoint-override .SimpleBelowSearchSnippet.withButton .blockButton {
         inset-inline-end: -10%;
         margin: auto; }
         @media (max-width: 865px) {
           .ds-outer-wrapper-breakpoint-override .SimpleBelowSearchSnippet.withButton .blockButton {
             inset-inline-end: 2%; } }
@@ -4222,24 +4225,26 @@ a.firstrun-link {
       .trailhead .trailheadBenefits li:dir(rtl) {
         background-position-x: right; }
       .trailhead .trailheadBenefits li.knowledge {
         background-image: url("../data/content/assets/trailhead/benefit-knowledge.png"); }
       .trailhead .trailheadBenefits li.privacy {
         background-image: url("../data/content/assets/trailhead/benefit-privacy.png"); }
       .trailhead .trailheadBenefits li.products {
         background-image: url("../data/content/assets/trailhead/benefit-products.png"); }
-    .trailhead .trailheadBenefits h3 {
+    .trailhead .trailheadBenefits h2 {
+      text-align: start;
+      line-height: inherit;
       color: #CB9EFF;
       font-size: 22px;
       font-weight: 400;
       margin: 0 0 4px;
       padding-inline-start: 52px; }
       @media (min-width: 850px) {
-        .trailhead .trailheadBenefits h3 {
+        .trailhead .trailheadBenefits h2 {
           padding-inline-start: 0; } }
     .trailhead .trailheadBenefits p {
       color: #FFF;
       font-size: 15px;
       line-height: 22px;
       margin: 4px 0 15px; }
   .trailhead .trailheadForm {
     background: url("../data/content/assets/trailhead/firefox-logo.png") top center/100px no-repeat;
--- a/browser/components/newtab/css/activity-stream-mac.css
+++ b/browser/components/newtab/css/activity-stream-mac.css
@@ -1890,28 +1890,25 @@ main {
     color: #D7D7DB; }
   .ds-header .icon,
   .ds-layout .section-title span .icon {
     fill: var(--newtab-text-secondary-color); }
 
 .collapsible-section.ds-layout {
   margin: auto;
   width: 986px; }
-  .collapsible-section.ds-layout .section-top-bar {
-    margin-bottom: 0; }
-    .collapsible-section.ds-layout .section-top-bar .learn-more-link a {
-      color: var(--newtab-link-primary-color);
-      font-weight: normal; }
-      .collapsible-section.ds-layout .section-top-bar .learn-more-link a:-moz-any(:focus, :hover) {
-        text-decoration: underline; }
+  .collapsible-section.ds-layout .section-top-bar .learn-more-link a {
+    color: var(--newtab-link-primary-color);
+    font-weight: normal; }
+    .collapsible-section.ds-layout .section-top-bar .learn-more-link a:-moz-any(:focus, :hover) {
+      text-decoration: underline; }
 
 .ds-card-grid {
   display: grid;
-  grid-gap: 24px;
-  margin: 16px 0; }
+  grid-gap: 24px; }
   .ds-card-grid .ds-card {
     background: #FFF;
     border-radius: 4px; }
     [lwt-newtab-brighttext] .ds-card-grid .ds-card {
       background: none; }
   .ds-card-grid .ds-card-link:focus {
     box-shadow: 0 0 0 5px rgba(10, 132, 255, 0.3);
     transition: box-shadow 150ms;
@@ -1949,17 +1946,17 @@ main {
       .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-3 .title {
         font-size: 17px;
         line-height: 24px; }
       .ds-column-9 .ds-card-grid.ds-card-grid-divisible-by-3 .excerpt,
       .ds-column-10 .ds-card-grid.ds-card-grid-divisible-by-3 .excerpt,
       .ds-column-11 .ds-card-grid.ds-card-grid-divisible-by-3 .excerpt,
       .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-3 .excerpt {
         font-size: 15px;
-        -webkit-line-clamp: 4;
+        -webkit-line-clamp: 3;
         line-height: 24px; }
     .ds-column-9 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-10 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-11 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-4 .title {
       font-size: 15px;
       -webkit-line-clamp: 3;
       line-height: 20px; }
@@ -2710,17 +2707,17 @@ main {
       flex-grow: 1; }
     .ds-card .meta .title {
       font-size: 17px;
       -webkit-line-clamp: 3;
       line-height: 24px;
       font-weight: 600; }
     .ds-card .meta .excerpt {
       font-size: 14px;
-      -webkit-line-clamp: 4;
+      -webkit-line-clamp: 3;
       line-height: 20px; }
     .ds-card .meta .source {
       -webkit-line-clamp: 1;
       margin-bottom: 2px;
       font-size: 13px;
       color: #737373; }
       [lwt-newtab-brighttext] .ds-card .meta .source {
         color: #B1B1B3; }
@@ -2868,17 +2865,22 @@ main {
   opacity: 1; }
 
 .story-animate-exit-active {
   opacity: 0;
   transition: opacity 250ms ease-in; }
 
 .ds-image {
   display: block;
-  position: relative; }
+  position: relative;
+  opacity: 0; }
+  .ds-image.use-transition {
+    transition: opacity 0.8s; }
+  .ds-image.loaded {
+    opacity: 1; }
   .ds-image img,
   .ds-image .broken-image {
     background-color: var(--newtab-card-placeholder-color);
     position: absolute;
     top: 0;
     width: 100%;
     height: 100%;
     object-fit: cover; }
@@ -3024,21 +3026,22 @@ main {
 @keyframes spinner {
   to {
     transform: rotate(360deg); } }
 
 .ds-text-promo {
   display: flex;
   max-width: 744px;
   margin: 16px auto; }
-  .ds-text-promo img {
+  .ds-text-promo picture {
     width: 40px;
     height: 40px;
     margin: 0 12px 0 0;
-    border-radius: 4px; }
+    border-radius: 4px;
+    flex-shrink: 0; }
   .ds-text-promo .text {
     line-height: 24px;
     margin: -4.5px 0 0; }
   .ds-text-promo h3 {
     margin: 0;
     font-weight: 600;
     font-size: 15px; }
     [lwt-newtab-brighttext] .ds-text-promo h3 {
@@ -3454,17 +3457,17 @@ body[lwt-newtab-brighttext] .scene2Icon 
       .SimpleBelowSearchSnippet .innerWrapper {
         align-items: flex-start;
         background-color: transparent;
         border-radius: 4px;
         box-shadow: none;
         flex-direction: row;
         padding: 0;
         text-align: inherit; } }
-    @media (max-width: 1120px) {
+    @media (max-width: 1123px) {
       .SimpleBelowSearchSnippet .innerWrapper {
         margin: 0 60px; } }
     @media (max-width: 865px) {
       .SimpleBelowSearchSnippet .innerWrapper {
         margin-inline-start: 0; } }
     @media (max-width: 609px) {
       .SimpleBelowSearchSnippet .innerWrapper {
         margin: auto; } }
@@ -3517,17 +3520,17 @@ body[lwt-newtab-brighttext] .scene2Icon 
       display: block;
       inset-inline-end: -15%;
       opacity: 0;
       margin: auto;
       top: unset; }
       .SimpleBelowSearchSnippet.withButton .blockButton:focus {
         opacity: 1;
         box-shadow: none; }
-      @media (max-width: 1120px) {
+      @media (max-width: 1123px) {
         .SimpleBelowSearchSnippet.withButton .blockButton {
           inset-inline-end: 2%; } }
       .ds-outer-wrapper-breakpoint-override .SimpleBelowSearchSnippet.withButton .blockButton {
         inset-inline-end: -10%;
         margin: auto; }
         @media (max-width: 865px) {
           .ds-outer-wrapper-breakpoint-override .SimpleBelowSearchSnippet.withButton .blockButton {
             inset-inline-end: 2%; } }
@@ -4225,24 +4228,26 @@ a.firstrun-link {
       .trailhead .trailheadBenefits li:dir(rtl) {
         background-position-x: right; }
       .trailhead .trailheadBenefits li.knowledge {
         background-image: url("../data/content/assets/trailhead/benefit-knowledge.png"); }
       .trailhead .trailheadBenefits li.privacy {
         background-image: url("../data/content/assets/trailhead/benefit-privacy.png"); }
       .trailhead .trailheadBenefits li.products {
         background-image: url("../data/content/assets/trailhead/benefit-products.png"); }
-    .trailhead .trailheadBenefits h3 {
+    .trailhead .trailheadBenefits h2 {
+      text-align: start;
+      line-height: inherit;
       color: #CB9EFF;
       font-size: 22px;
       font-weight: 400;
       margin: 0 0 4px;
       padding-inline-start: 52px; }
       @media (min-width: 850px) {
-        .trailhead .trailheadBenefits h3 {
+        .trailhead .trailheadBenefits h2 {
           padding-inline-start: 0; } }
     .trailhead .trailheadBenefits p {
       color: #FFF;
       font-size: 15px;
       line-height: 22px;
       margin: 4px 0 15px; }
   .trailhead .trailheadForm {
     background: url("../data/content/assets/trailhead/firefox-logo.png") top center/100px no-repeat;
--- a/browser/components/newtab/css/activity-stream-windows.css
+++ b/browser/components/newtab/css/activity-stream-windows.css
@@ -1887,28 +1887,25 @@ main {
     color: #D7D7DB; }
   .ds-header .icon,
   .ds-layout .section-title span .icon {
     fill: var(--newtab-text-secondary-color); }
 
 .collapsible-section.ds-layout {
   margin: auto;
   width: 986px; }
-  .collapsible-section.ds-layout .section-top-bar {
-    margin-bottom: 0; }
-    .collapsible-section.ds-layout .section-top-bar .learn-more-link a {
-      color: var(--newtab-link-primary-color);
-      font-weight: normal; }
-      .collapsible-section.ds-layout .section-top-bar .learn-more-link a:-moz-any(:focus, :hover) {
-        text-decoration: underline; }
+  .collapsible-section.ds-layout .section-top-bar .learn-more-link a {
+    color: var(--newtab-link-primary-color);
+    font-weight: normal; }
+    .collapsible-section.ds-layout .section-top-bar .learn-more-link a:-moz-any(:focus, :hover) {
+      text-decoration: underline; }
 
 .ds-card-grid {
   display: grid;
-  grid-gap: 24px;
-  margin: 16px 0; }
+  grid-gap: 24px; }
   .ds-card-grid .ds-card {
     background: #FFF;
     border-radius: 4px; }
     [lwt-newtab-brighttext] .ds-card-grid .ds-card {
       background: none; }
   .ds-card-grid .ds-card-link:focus {
     box-shadow: 0 0 0 5px rgba(10, 132, 255, 0.3);
     transition: box-shadow 150ms;
@@ -1946,17 +1943,17 @@ main {
       .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-3 .title {
         font-size: 17px;
         line-height: 24px; }
       .ds-column-9 .ds-card-grid.ds-card-grid-divisible-by-3 .excerpt,
       .ds-column-10 .ds-card-grid.ds-card-grid-divisible-by-3 .excerpt,
       .ds-column-11 .ds-card-grid.ds-card-grid-divisible-by-3 .excerpt,
       .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-3 .excerpt {
         font-size: 15px;
-        -webkit-line-clamp: 4;
+        -webkit-line-clamp: 3;
         line-height: 24px; }
     .ds-column-9 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-10 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-11 .ds-card-grid.ds-card-grid-divisible-by-4 .title,
     .ds-column-12 .ds-card-grid.ds-card-grid-divisible-by-4 .title {
       font-size: 15px;
       -webkit-line-clamp: 3;
       line-height: 20px; }
@@ -2707,17 +2704,17 @@ main {
       flex-grow: 1; }
     .ds-card .meta .title {
       font-size: 17px;
       -webkit-line-clamp: 3;
       line-height: 24px;
       font-weight: 600; }
     .ds-card .meta .excerpt {
       font-size: 14px;
-      -webkit-line-clamp: 4;
+      -webkit-line-clamp: 3;
       line-height: 20px; }
     .ds-card .meta .source {
       -webkit-line-clamp: 1;
       margin-bottom: 2px;
       font-size: 13px;
       color: #737373; }
       [lwt-newtab-brighttext] .ds-card .meta .source {
         color: #B1B1B3; }
@@ -2865,17 +2862,22 @@ main {
   opacity: 1; }
 
 .story-animate-exit-active {
   opacity: 0;
   transition: opacity 250ms ease-in; }
 
 .ds-image {
   display: block;
-  position: relative; }
+  position: relative;
+  opacity: 0; }
+  .ds-image.use-transition {
+    transition: opacity 0.8s; }
+  .ds-image.loaded {
+    opacity: 1; }
   .ds-image img,
   .ds-image .broken-image {
     background-color: var(--newtab-card-placeholder-color);
     position: absolute;
     top: 0;
     width: 100%;
     height: 100%;
     object-fit: cover; }
@@ -3021,21 +3023,22 @@ main {
 @keyframes spinner {
   to {
     transform: rotate(360deg); } }
 
 .ds-text-promo {
   display: flex;
   max-width: 744px;
   margin: 16px auto; }
-  .ds-text-promo img {
+  .ds-text-promo picture {
     width: 40px;
     height: 40px;
     margin: 0 12px 0 0;
-    border-radius: 4px; }
+    border-radius: 4px;
+    flex-shrink: 0; }
   .ds-text-promo .text {
     line-height: 24px;
     margin: -4.5px 0 0; }
   .ds-text-promo h3 {
     margin: 0;
     font-weight: 600;
     font-size: 15px; }
     [lwt-newtab-brighttext] .ds-text-promo h3 {
@@ -3451,17 +3454,17 @@ body[lwt-newtab-brighttext] .scene2Icon 
       .SimpleBelowSearchSnippet .innerWrapper {
         align-items: flex-start;
         background-color: transparent;
         border-radius: 4px;
         box-shadow: none;
         flex-direction: row;
         padding: 0;
         text-align: inherit; } }
-    @media (max-width: 1120px) {
+    @media (max-width: 1123px) {
       .SimpleBelowSearchSnippet .innerWrapper {
         margin: 0 60px; } }
     @media (max-width: 865px) {
       .SimpleBelowSearchSnippet .innerWrapper {
         margin-inline-start: 0; } }
     @media (max-width: 609px) {
       .SimpleBelowSearchSnippet .innerWrapper {
         margin: auto; } }
@@ -3514,17 +3517,17 @@ body[lwt-newtab-brighttext] .scene2Icon 
       display: block;
       inset-inline-end: -15%;
       opacity: 0;
       margin: auto;
       top: unset; }
       .SimpleBelowSearchSnippet.withButton .blockButton:focus {
         opacity: 1;
         box-shadow: none; }
-      @media (max-width: 1120px) {
+      @media (max-width: 1123px) {
         .SimpleBelowSearchSnippet.withButton .blockButton {
           inset-inline-end: 2%; } }
       .ds-outer-wrapper-breakpoint-override .SimpleBelowSearchSnippet.withButton .blockButton {
         inset-inline-end: -10%;
         margin: auto; }
         @media (max-width: 865px) {
           .ds-outer-wrapper-breakpoint-override .SimpleBelowSearchSnippet.withButton .blockButton {
             inset-inline-end: 2%; } }
@@ -4222,24 +4225,26 @@ a.firstrun-link {
       .trailhead .trailheadBenefits li:dir(rtl) {
         background-position-x: right; }
       .trailhead .trailheadBenefits li.knowledge {
         background-image: url("../data/content/assets/trailhead/benefit-knowledge.png"); }
       .trailhead .trailheadBenefits li.privacy {
         background-image: url("../data/content/assets/trailhead/benefit-privacy.png"); }
       .trailhead .trailheadBenefits li.products {
         background-image: url("../data/content/assets/trailhead/benefit-products.png"); }
-    .trailhead .trailheadBenefits h3 {
+    .trailhead .trailheadBenefits h2 {
+      text-align: start;
+      line-height: inherit;
       color: #CB9EFF;
       font-size: 22px;
       font-weight: 400;
       margin: 0 0 4px;
       padding-inline-start: 52px; }
       @media (min-width: 850px) {
-        .trailhead .trailheadBenefits h3 {
+        .trailhead .trailheadBenefits h2 {
           padding-inline-start: 0; } }
     .trailhead .trailheadBenefits p {
       color: #FFF;
       font-size: 15px;
       line-height: 22px;
       margin: 4px 0 15px; }
   .trailhead .trailheadForm {
     background: url("../data/content/assets/trailhead/firefox-logo.png") top center/100px no-repeat;
--- a/browser/components/newtab/data/content/activity-stream.bundle.js
+++ b/browser/components/newtab/data/content/activity-stream.bundle.js
@@ -2861,17 +2861,17 @@ class Trailhead extends react__WEBPACK_I
       id: "trailheadHeader"
     }), content.subtitle && react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("p", {
       "data-l10n-id": content.subtitle.string_id
     }), react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("ul", {
       className: "trailheadBenefits"
     }, content.benefits.map(item => react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("li", {
       key: item.id,
       className: item.id
-    }, react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("h3", {
+    }, react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("h2", {
       "data-l10n-id": item.title.string_id
     }), react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("p", {
       "data-l10n-id": item.text.string_id
     })))), react__WEBPACK_IMPORTED_MODULE_3___default.a.createElement("a", {
       className: "trailheadLearn",
       "data-l10n-id": content.learn.text.string_id,
       href: Object(_FirstRun_addUtmParams__WEBPACK_IMPORTED_MODULE_2__["addUtmParams"])(content.learn.url, UTMTerm),
       target: "_blank",
@@ -3595,17 +3595,17 @@ class OnboardingCard extends react__WEBP
     } = this.props;
     const className = this.props.className || "onboardingMessage";
     return react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", {
       className: className
     }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", {
       className: `onboardingMessageImage ${content.icon}`
     }), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("div", {
       className: "onboardingContent"
-    }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("span", null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("h3", {
+    }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("span", null, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("h2", {
       className: "onboardingTitle",
       "data-l10n-id": content.title.string_id
     }), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("p", {
       className: "onboardingText",
       "data-l10n-id": content.text.string_id
     })), react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("span", {
       className: "onboardingButtonContainer"
     }, react__WEBPACK_IMPORTED_MODULE_0___default.a.createElement("button", {
@@ -4460,18 +4460,19 @@ const INTERSECTION_RATIO = 0.5;
 /**
  * Impression wrapper for Discovery Stream related React components.
  *
  * It makses use of the Intersection Observer API to detect the visibility,
  * and relies on page visibility to ensure the impression is reported
  * only when the component is visible on the page.
  *
  * Note:
- *   * This wrapper could be used either at the individual card level,
- *     or by the card container components
+ *   * This wrapper used to be used either at the individual card level,
+ *     or by the card container components.
+ *     It is now only used for individual card level.
  *   * Each impression will be sent only once as soon as the desired
  *     visibility is detected
  *   * Batching is not yet implemented, hence it might send multiple
  *     impression pings separately
  */
 
 class ImpressionStats extends react__WEBPACK_IMPORTED_MODULE_1___default.a.PureComponent {
   // This checks if the given cards are the same as those in the last impression ping.
@@ -4623,22 +4624,16 @@ class ImpressionStats extends react__WEB
   }
 
   componentDidMount() {
     if (this.props.rows.length) {
       this.setImpressionObserverOrAddListener();
     }
   }
 
-  componentDidUpdate(prevProps) {
-    if (this.props.rows.length && this.props.rows !== prevProps.rows) {
-      this.setImpressionObserverOrAddListener();
-    }
-  }
-
   componentWillUnmount() {
     if (this._handleIntersect && this.impressionObserver) {
       this.impressionObserver.unobserve(this.refs.impression);
     }
 
     if (this._onVisibilityChange) {
       this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
     }
@@ -8020,84 +8015,104 @@ var external_ReactDOM_default = /*#__PUR
 
 class DSImage_DSImage extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onOptimizedImageError = this.onOptimizedImageError.bind(this);
     this.onNonOptimizedImageError = this.onNonOptimizedImageError.bind(this);
     this.state = {
       isSeen: false,
-      optimizedImageFailed: false
+      optimizedImageFailed: false,
+      useTransition: false
     };
   }
 
   onSeen(entries) {
     if (this.state) {
       const entry = entries.find(e => e.isIntersecting);
 
       if (entry) {
         if (this.props.optimize) {
           this.setState({
-            containerWidth: entry.boundingClientRect.width,
-            containerHeight: entry.boundingClientRect.height
+            // Thumbor doesn't handle subpixels and just errors out, so rounding...
+            containerWidth: Math.round(entry.boundingClientRect.width),
+            containerHeight: Math.round(entry.boundingClientRect.height)
           });
         }
 
         this.setState({
           isSeen: true
         }); // Stop observing since element has been seen
 
         this.observer.unobserve(external_ReactDOM_default.a.findDOMNode(this));
       }
     }
   }
 
+  onIdleCallback() {
+    if (!this.state.isSeen) {
+      this.setState({
+        useTransition: true
+      });
+    }
+  }
+
   reformatImageURL(url, width, height) {
     // Change the image URL to request a size tailored for the parent container width
     // Also: force JPEG, quality 60, no upscaling, no EXIF data
     // Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html
     return `https://img-getpocket.cdn.mozilla.net/${width}x${height}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent(url)}`;
   }
 
   componentDidMount() {
-    this.observer = new IntersectionObserver(this.onSeen.bind(this));
+    this.idleCallbackId = window.requestIdleCallback(this.onIdleCallback.bind(this));
+    this.observer = new IntersectionObserver(this.onSeen.bind(this), {
+      // Assume an image will be eventually seen if it is within
+      // half the average Desktop vertical screen size:
+      // http://gs.statcounter.com/screen-resolution-stats/desktop/north-america
+      rootMargin: `540px`
+    });
     this.observer.observe(external_ReactDOM_default.a.findDOMNode(this));
   }
 
   componentWillUnmount() {
     // Remove observer on unmount
     if (this.observer) {
       this.observer.unobserve(external_ReactDOM_default.a.findDOMNode(this));
     }
   }
 
   render() {
-    const classNames = `ds-image${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``}`;
+    let classNames = `ds-image
+      ${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``}
+      ${this.state && this.state.useTransition ? ` use-transition` : ``}
+      ${this.state && this.state.isSeen ? ` loaded` : ``}
+    `;
     let img;
 
     if (this.state && this.state.isSeen) {
       if (this.props.optimize && this.props.rawSource && !this.state.optimizedImageFailed) {
         let source;
         let source2x;
 
         if (this.state && this.state.containerWidth) {
           let baseSource = this.props.rawSource;
           source = this.reformatImageURL(baseSource, this.state.containerWidth, this.state.containerHeight);
           source2x = this.reformatImageURL(baseSource, this.state.containerWidth * 2, this.state.containerHeight * 2);
           img = external_React_default.a.createElement("img", {
-            alt: "",
+            alt: this.props.alt_text,
             crossOrigin: "anonymous",
             onError: this.onOptimizedImageError,
             src: source,
             srcSet: `${source2x} 2x`
           });
         }
       } else if (!this.state.nonOptimizedImageFailed) {
         img = external_React_default.a.createElement("img", {
-          alt: "",
+          alt: this.props.alt_text,
           crossOrigin: "anonymous",
           onError: this.onNonOptimizedImageError,
           src: this.props.source
         });
       } else {
         // Remove the img element if both sources fail. Render a placeholder instead.
         img = external_React_default.a.createElement("div", {
           className: "broken-image"
@@ -8126,18 +8141,19 @@ class DSImage_DSImage extends external_R
 }
 DSImage_DSImage.defaultProps = {
   source: null,
   // The current source style from Pocket API (always 450px)
   rawSource: null,
   // Unadulterated image URL to filter through Thumbor
   extraClassNames: null,
   // Additional classnames to append to component
-  optimize: true // Measure parent container to request exact sizes
-
+  optimize: true,
+  // Measure parent container to request exact sizes
+  alt_text: null
 };
 // EXTERNAL MODULE: ./content-src/components/LinkMenu/LinkMenu.jsx
 var LinkMenu = __webpack_require__(30);
 
 // EXTERNAL MODULE: ./content-src/components/ContextMenu/ContextMenuButton.jsx
 var ContextMenuButton = __webpack_require__(33);
 
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx
@@ -8468,17 +8484,31 @@ class DSCard_DSCard extends external_Rea
 
         this.setState({
           isSeen: true
         });
       }
     }
   }
 
+  onIdleCallback() {
+    if (!this.state.isSeen) {
+      if (this.observer && this.placholderElement) {
+        this.observer.unobserve(this.placholderElement);
+      }
+
+      this.setState({
+        isSeen: true
+      });
+    }
+  }
+
   componentDidMount() {
+    this.idleCallbackId = window.requestIdleCallback(this.onIdleCallback.bind(this));
+
     if (this.placholderElement) {
       this.observer = new IntersectionObserver(this.onSeen.bind(this));
       this.observer.observe(this.placholderElement);
     }
   }
 
   componentWillUnmount() {
     // Remove observer on unmount
@@ -8710,17 +8740,17 @@ class CardGrid_CardGrid extends external
     } = this.props; // Handle a render before feed has been fetched by displaying nothing
 
     if (!data) {
       return null;
     } // Handle the case where a user has dismissed all recommendations
 
 
     const isEmpty = data.recommendations.length === 0;
-    return external_React_default.a.createElement("div", null, external_React_default.a.createElement("div", {
+    return external_React_default.a.createElement("div", null, this.props.title && external_React_default.a.createElement("div", {
       className: "ds-header"
     }, this.props.title), isEmpty ? external_React_default.a.createElement("div", {
       className: "ds-card-grid empty"
     }, external_React_default.a.createElement(DSEmptyState_DSEmptyState, {
       status: data.status,
       dispatch: this.props.dispatch,
       feed: this.props.feed
     })) : this.renderCards());
@@ -8843,16 +8873,17 @@ class DSMessage_DSMessage extends extern
 // CONCATENATED MODULE: ./content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx
 /* 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/. */
 
 
 
 
+
 class DSTextPromo_DSTextPromo extends external_React_default.a.PureComponent {
   constructor(props) {
     super(props);
     this.onLinkClick = this.onLinkClick.bind(this);
   }
 
   onLinkClick() {
     if (this.props.dispatch) {
@@ -8873,19 +8904,20 @@ class DSTextPromo_DSTextPromo extends ex
         }]
       }));
     }
   }
 
   render() {
     return external_React_default.a.createElement("div", {
       className: "ds-text-promo"
-    }, external_React_default.a.createElement("img", {
-      src: this.props.image,
-      alt: this.props.alt_text
+    }, external_React_default.a.createElement(DSImage_DSImage, {
+      alt_text: this.props.alt_text,
+      source: this.props.image,
+      rawSource: this.props.raw_image_src
     }), external_React_default.a.createElement("div", {
       className: "text"
     }, external_React_default.a.createElement("h3", null, `${this.props.header}\u2003`, external_React_default.a.createElement(SafeAnchor_SafeAnchor, {
       className: "ds-chevron-link",
       dispatch: this.props.dispatch,
       onLinkClick: this.onLinkClick,
       url: this.props.cta_url
     }, this.props.cta_text)), external_React_default.a.createElement("p", {
@@ -9862,16 +9894,17 @@ class DiscoveryStreamBase_DiscoveryStrea
         if (!component.data || !component.data.spocs || !component.data.spocs[0]) {
           return null;
         } // Grab the first item in the array as we only have 1 spoc position.
 
 
         const [spoc] = component.data.spocs;
         const {
           image_src,
+          raw_image_src,
           alt_text,
           title,
           url,
           context,
           cta,
           campaign_id,
           id,
           shim
@@ -9882,16 +9915,17 @@ class DiscoveryStreamBase_DiscoveryStrea
             guid: spoc.id,
             shim: spoc.shim
           },
           dispatch: this.props.dispatch,
           shouldSendImpressionStats: true
         }, external_React_default.a.createElement(DSTextPromo_DSTextPromo, {
           dispatch: this.props.dispatch,
           image: image_src,
+          raw_image_src: raw_image_src,
           alt_text: alt_text || title,
           header: title,
           cta_text: cta,
           cta_url: url,
           subtitle: context,
           campaignId: campaign_id,
           id: id,
           pos: 0,
--- a/browser/components/newtab/lib/OnboardingMessageProvider.jsm
+++ b/browser/components/newtab/lib/OnboardingMessageProvider.jsm
@@ -9,16 +9,18 @@ ChromeUtils.defineModuleGetter(
   "resource:///modules/AttributionCode.jsm"
 );
 ChromeUtils.defineModuleGetter(
   this,
   "AddonRepository",
   "resource://gre/modules/addons/AddonRepository.jsm"
 );
 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const FIREFOX_VERSION = parseInt(Services.appinfo.version.match(/\d+/), 10);
+const ONE_MINUTE = 60 * 1000;
 
 const L10N = new Localization([
   "branding/brand.ftl",
   "browser/branding/brandings.ftl",
   "browser/branding/sync-brand.ftl",
   "browser/newtab/onboarding.ftl",
 ]);
 
@@ -404,16 +406,37 @@ const ONBOARDING_MESSAGES = () => [
       link_text: { string_id: "cfr-protections-panel-link-text" },
       cta_url: `${Services.urlFormatter.formatURLPref(
         "app.support.baseURL"
       )}etp-promotions?as=u&utm_source=inproduct`,
       cta_type: "OPEN_URL",
     },
     trigger: { id: "protectionsPanelOpen" },
   },
+  {
+    id: `WHATS_NEW_BADGE_${FIREFOX_VERSION}`,
+    template: "toolbar_badge",
+    content: {
+      delay: 5 * ONE_MINUTE,
+      target: "whats-new-menu-button",
+      action: { id: "show-whatsnew-button" },
+    },
+    priority: 1,
+    trigger: { id: "toolbarBadgeUpdate" },
+    frequency: {
+      // Makes it so that we track impressions for this message while at the
+      // same time it can have unlimited impressions
+      lifetime: Infinity,
+    },
+    // Never saw this message or saw it in the past 4 days or more recent
+    targeting: `isWhatsNewPanelEnabled &&
+      (!messageImpressions['WHATS_NEW_BADGE_${FIREFOX_VERSION}'] ||
+        (messageImpressions['WHATS_NEW_BADGE_${FIREFOX_VERSION}']|length >= 1 &&
+          currentDate|date - messageImpressions['WHATS_NEW_BADGE_${FIREFOX_VERSION}'][0] <= 4 * 24 * 3600 * 1000))`,
+  },
 ];
 
 const OnboardingMessageProvider = {
   async getExtraAttributes() {
     const [header, button_label] = await L10N.formatMessages([
       { id: "onboarding-welcome-header" },
       { id: "onboarding-start-browsing-button-label" },
     ]);
--- a/browser/components/newtab/lib/PanelTestProvider.jsm
+++ b/browser/components/newtab/lib/PanelTestProvider.jsm
@@ -1,16 +1,13 @@
 /* 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
-
-const FIREFOX_VERSION = parseInt(Services.appinfo.version.match(/\d+/), 10);
 const TWO_DAYS = 2 * 24 * 3600 * 1000;
 
 const MESSAGES = () => [
   {
     id: "SIMPLE_FXA_BOOKMARK_TEST_FLUENT",
     template: "fxa_bookmark_panel",
     content: {
       title: { string_id: "cfr-doorhanger-bookmark-fxa-header" },
@@ -62,39 +59,16 @@ const MESSAGES = () => [
             "https://www.mozilla.org/%LOCALE%/etc/firefox/retention/thank-you-a/",
           expireDelta: TWO_DAYS,
         },
       },
     },
     trigger: { id: "momentsUpdate" },
   },
   {
-    id: `WHATS_NEW_BADGE_${FIREFOX_VERSION}`,
-    template: "toolbar_badge",
-    content: {
-      // delay: 5 * 3600 * 1000,
-      delay: 5000,
-      target: "whats-new-menu-button",
-      action: { id: "show-whatsnew-button" },
-    },
-    priority: 1,
-    trigger: { id: "toolbarBadgeUpdate" },
-    frequency: {
-      // Makes it so that we track impressions for this message while at the
-      // same time it can have unlimited impressions
-      lifetime: Infinity,
-    },
-    // Never saw this message or saw it in the past 4 days or more recent
-    targeting: `isWhatsNewPanelEnabled &&
-      (earliestFirefoxVersion && firefoxVersion > earliestFirefoxVersion) &&
-        (!messageImpressions['WHATS_NEW_BADGE_${FIREFOX_VERSION}'] ||
-      (messageImpressions['WHATS_NEW_BADGE_${FIREFOX_VERSION}']|length >= 1 &&
-        currentDate|date - messageImpressions['WHATS_NEW_BADGE_${FIREFOX_VERSION}'][0] <= 4 * 24 * 3600 * 1000))`,
-  },
-  {
     id: "WHATS_NEW_70_1",
     template: "whatsnew_panel_message",
     order: 3,
     content: {
       published_date: 1560969794394,
       title: "Protection Is Our Focus",
       icon_url:
         "resource://activity-stream/data/content/assets/whatsnew-send-icon.png",
--- a/browser/components/newtab/test/browser/browser.ini
+++ b/browser/components/newtab/test/browser/browser.ini
@@ -25,8 +25,9 @@ prefs =
 [browser_onboarding_rtamo.js]
 skip-if = (os == "linux") # Test setup only implemented for OSX and Windows
 [browser_topsites_contextMenu_options.js]
 [browser_topsites_section.js]
 [browser_asrouter_cfr.js]
 skip-if = fission
 [browser_asrouter_bookmarkpanel.js]
 [browser_asrouter_toolbarbadge.js]
+[browser_asrouter_whatsnewpanel.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/newtab/test/browser/browser_asrouter_whatsnewpanel.js
@@ -0,0 +1,48 @@
+const { PanelTestProvider } = ChromeUtils.import(
+  "resource://activity-stream/lib/PanelTestProvider.jsm"
+);
+const { ToolbarPanelHub } = ChromeUtils.import(
+  "resource://activity-stream/lib/ToolbarPanelHub.jsm"
+);
+
+add_task(async function test_messages_rendering() {
+  const msgs = (await PanelTestProvider.getMessages()).filter(
+    ({ template }) => template === "whatsnew_panel_message"
+  );
+
+  Assert.ok(msgs.length, "FxA test message exists");
+
+  Object.defineProperty(ToolbarPanelHub, "messages", {
+    get: () => Promise.resolve(msgs),
+    configurable: true,
+  });
+
+  await ToolbarPanelHub.enableAppmenuButton();
+
+  const mainView = document.getElementById("appMenu-mainView");
+  UITour.showMenu(window, "appMenu");
+  await BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+
+  Assert.equal(mainView.hidden, false, "Panel is visible");
+
+  const whatsNewBtn = document.getElementById("appMenu-whatsnew-button");
+  Assert.equal(whatsNewBtn.hidden, false, "What's New is present");
+
+  // Show the What's New Messages
+  whatsNewBtn.click();
+
+  const shownMessages = await BrowserTestUtils.waitForCondition(
+    () =>
+      document.getElementById("PanelUI-whatsNew-message-container") &&
+      document.querySelectorAll(
+        "#PanelUI-whatsNew-message-container .whatsNew-message"
+      ).length
+  );
+  Assert.equal(
+    shownMessages,
+    msgs.length,
+    "Expected number of What's New messages rendered."
+  );
+
+  UITour.hideMenu(window, "appMenu");
+});
--- a/browser/components/newtab/test/unit/asrouter/PanelTestProvider.test.js
+++ b/browser/components/newtab/test/unit/asrouter/PanelTestProvider.test.js
@@ -3,17 +3,17 @@ import schema from "content-src/asrouter
 import update_schema from "content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json";
 import whats_new_schema from "content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json";
 const messages = PanelTestProvider.getMessages();
 
 describe("PanelTestProvider", () => {
   it("should have a message", () => {
     // Careful: when changing this number make sure that new messages also go
     // through schema verifications.
-    assert.lengthOf(messages, 8);
+    assert.lengthOf(messages, 7);
   });
   it("should be a valid message", () => {
     const fxaMessages = messages.filter(
       ({ template }) => template === "fxa_bookmark_panel"
     );
     for (let message of fxaMessages) {
       assert.jsonSchema(message.content, schema);
     }
--- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx
+++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/ImpressionStats.test.jsx
@@ -148,76 +148,46 @@ describe("<ImpressionStats>", () => {
     const [action] = dispatch.secondCall.args;
     assert.equal(action.type, at.DISCOVERY_STREAM_SPOC_IMPRESSION);
     assert.deepEqual(action.data, { campaignId });
   });
   it("should send an impression when the wrapped item transiting from invisible to visible", () => {
     const dispatch = sinon.spy();
     const props = {
       dispatch,
-      IntersectionObserver: buildIntersectionObserver(
-        ZeroIntersectEntries,
-        false
-      ),
+      IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries),
     };
     const wrapper = renderImpressionStats(props);
 
     // For the loaded content
     assert.calledOnce(dispatch);
 
     let [action] = dispatch.firstCall.args;
     assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT);
     assert.equal(action.data.source, SOURCE);
     assert.deepEqual(action.data.tiles, [
       { id: 1, pos: 0 },
       { id: 2, pos: 1 },
       { id: 3, pos: 2 },
     ]);
 
     dispatch.resetHistory();
-
-    // Simulating the full intersection change with a row change
-    wrapper.setProps({
-      ...props,
-      ...{ rows: [{ id: 1, pos: 0 }, { id: 2, pos: 1 }, { id: 3, pos: 2 }] },
-      ...{
-        IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
-      },
-    });
+    wrapper.instance().impressionObserver.callback(FullIntersectEntries);
 
     // For the impression
     assert.calledOnce(dispatch);
 
     [action] = dispatch.firstCall.args;
     assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS);
     assert.deepEqual(action.data.tiles, [
       { id: 1, pos: 0 },
       { id: 2, pos: 1 },
       { id: 3, pos: 2 },
     ]);
   });
-  it("should send a loaded content and an impression if props are updated and props.rows are different", () => {
-    const props = { dispatch: sinon.spy() };
-    const wrapper = renderImpressionStats(props);
-    props.dispatch.resetHistory();
-
-    // New rows
-    wrapper.setProps({ ...DEFAULT_PROPS, ...{ rows: [{ id: 4, pos: 3 }] } });
-
-    assert.calledTwice(props.dispatch);
-  });
-  it("should not send any ping if props are updated but IDs are the same", () => {
-    const props = { dispatch: sinon.spy() };
-    const wrapper = renderImpressionStats(props);
-    props.dispatch.resetHistory();
-
-    wrapper.setProps(DEFAULT_PROPS);
-
-    assert.notCalled(props.dispatch);
-  });
   it("should remove visibility change listener when the wrapper is removed", () => {
     const props = {
       dispatch: sinon.spy(),
       document: {
         visibilityState: "hidden",
         addEventListener: sinon.spy(),
         removeEventListener: sinon.spy(),
       },
--- a/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
+++ b/browser/components/newtab/test/unit/lib/ToolbarBadgeHub.test.js
@@ -1,11 +1,10 @@
 import { _ToolbarBadgeHub } from "lib/ToolbarBadgeHub.jsm";
 import { GlobalOverrider } from "test/unit/utils";
-import { PanelTestProvider } from "lib/PanelTestProvider.jsm";
 import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
 import { _ToolbarPanelHub } from "lib/ToolbarPanelHub.jsm";
 
 describe("ToolbarBadgeHub", () => {
   let sandbox;
   let instance;
   let fakeAddImpression;
   let fakeDispatch;
@@ -26,20 +25,19 @@ describe("ToolbarBadgeHub", () => {
   let requestIdleCallbackStub;
   beforeEach(async () => {
     globals = new GlobalOverrider();
     sandbox = sinon.createSandbox();
     instance = new _ToolbarBadgeHub();
     fakeAddImpression = sandbox.stub();
     fakeDispatch = sandbox.stub();
     isBrowserPrivateStub = sandbox.stub();
-    const panelTestMsgs = await PanelTestProvider.getMessages();
     const onboardingMsgs = await OnboardingMessageProvider.getUntranslatedMessages();
     fxaMessage = onboardingMsgs.find(({ id }) => id === "FXA_ACCOUNTS_BADGE");
-    whatsnewMessage = panelTestMsgs.find(({ id }) =>
+    whatsnewMessage = onboardingMsgs.find(({ id }) =>
       id.includes("WHATS_NEW_BADGE_")
     );
     fakeElement = {
       classList: {
         add: sandbox.stub(),
         remove: sandbox.stub(),
       },
       setAttribute: sandbox.stub(),