Repository: GoogleChrome/lighthouse
Pull request #6987 (2019-01-11 13:38:00)

docs(readme): add Speedrank to the list of integrations
**Summary**
Add [Speedrank](https://speedrank.app) to the list of integrations in alphabetical order.

Speedrank monitors the performance of your website in the background. It displays Lighthouse reports in time and delivers recommendations for improvement. Speedrank is a paid product with 14-day-trial.


Patch:
diff --git a/lighthouse-core/lib/traces/pwmetrics-events.js b/lighthouse-core/lib/traces/pwmetrics-events.js
index 3fc0aa9fa..33fccecc6 100644
--- a/lighthouse-core/lib/traces/pwmetrics-events.js
+++ b/lighthouse-core/lib/traces/pwmetrics-events.js
@@ -3,24 +3,28 @@
  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
  * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
  */
-// @ts-nocheck
 'use strict';
 
 const log = require('lighthouse-logger');
 
-// TODO: rework this file to not need this function
-// see https://github.com/GoogleChrome/lighthouse/pull/5101/files#r186168840
-function findValueInMetricsAuditFn(metricName) {
-  return auditResults => {
-    const metricsAudit = auditResults.metrics;
-    if (!metricsAudit || !metricsAudit.details || !metricsAudit.details.items) return;
+/** @typedef {import('../../audits/metrics.js').UberMetricsItem} UberMetricsItem */
 
-    const values = metricsAudit.details.items[0];
-    return values && values[metricName];
-  };
+/**
+ * @param {LH.Audit.Results} auditResults
+ * @return {UberMetricsItem|undefined}
+ */
+function getUberMetrics(auditResults) {
+  const metricsAudit = auditResults.metrics;
+  if (!metricsAudit || !metricsAudit.details || !metricsAudit.details.items) return;
+
+  return metricsAudit.details.items[0];
 }
 
 class Metrics {
+  /**
+   * @param {Array<LH.TraceEvent>} traceEvents
+   * @param {LH.Audit.Results} auditResults
+   */
   constructor(traceEvents, auditResults) {
     this._traceEvents = traceEvents;
     this._auditResults = auditResults;
@@ -28,109 +32,107 @@ class Metrics {
 
   /**
    * Returns simplified representation of all metrics
-   * @return {!Array<{getTs: Function, id: string, name: string}>} metrics to consider
+   * @return {Array<{id: string, name: string, tsKey: keyof UberMetricsItem}>} metrics to consider
    */
   static get metricsDefinitions() {
     return [
       {
         name: 'Navigation Start',
         id: 'navstart',
-        getTs: findValueInMetricsAuditFn('observedNavigationStartTs'),
-        getTiming: findValueInMetricsAuditFn('observedNavigationStart'),
+        tsKey: 'observedNavigationStartTs',
       },
       {
         name: 'First Contentful Paint',
         id: 'ttfcp',
-        getTs: findValueInMetricsAuditFn('observedFirstContentfulPaintTs'),
-        getTiming: findValueInMetricsAuditFn('observedFirstContentfulPaint'),
+        tsKey: 'observedFirstContentfulPaintTs',
       },
       {
         name: 'First Meaningful Paint',
         id: 'ttfmp',
-        getTs: findValueInMetricsAuditFn('observedFirstMeaningfulPaintTs'),
-        getTiming: findValueInMetricsAuditFn('observedFirstMeaningfulPaint'),
+        tsKey: 'observedFirstMeaningfulPaintTs',
       },
       {
         name: 'Speed Index',
         id: 'si',
-        getTs: findValueInMetricsAuditFn('observedSpeedIndexTs'),
-        getTiming: findValueInMetricsAuditFn('observedSpeedIndex'),
+        tsKey: 'observedSpeedIndexTs',
       },
       {
         name: 'First Visual Change',
         id: 'fv',
-        getTs: findValueInMetricsAuditFn('observedFirstVisualChangeTs'),
-        getTiming: findValueInMetricsAuditFn('observedFirstVisualChange'),
+        tsKey: 'observedFirstVisualChangeTs',
       },
       {
         name: 'Visually Complete 100%',
         id: 'vc100',
-        getTs: findValueInMetricsAuditFn('observedLastVisualChangeTs'),
-        getTiming: findValueInMetricsAuditFn('observedLastVisualChange'),
+        tsKey: 'observedLastVisualChangeTs',
       },
       {
         name: 'First CPU Idle',
         id: 'ttfi',
-        getTs: findValueInMetricsAuditFn('firstCPUIdleTs'),
-        getTiming: findValueInMetricsAuditFn('firstCPUIdle'),
+        tsKey: 'firstCPUIdleTs',
       },
       {
         name: 'Interactive',
         id: 'tti',
-        getTs: findValueInMetricsAuditFn('interactiveTs'),
-        getTiming: findValueInMetricsAuditFn('interactive'),
+        tsKey: 'interactiveTs',
       },
       {
         name: 'End of Trace',
         id: 'eot',
-        getTs: findValueInMetricsAuditFn('observedTraceEndTs'),
-        getTiming: findValueInMetricsAuditFn('observedTraceEnd'),
+        tsKey: 'observedTraceEndTs',
       },
       {
         name: 'On Load',
         id: 'onload',
-        getTs: findValueInMetricsAuditFn('observedLoadTs'),
-        getTiming: findValueInMetricsAuditFn('observedLoad'),
+        tsKey: 'observedLoadTs',
       },
       {
         name: 'DOM Content Loaded',
         id: 'dcl',
-        getTs: findValueInMetricsAuditFn('observedDomContentLoadedTs'),
-        getTiming: findValueInMetricsAuditFn('observedDomContentLoaded'),
+        tsKey: 'observedDomContentLoadedTs',
       },
     ];
   }
 
   /**
    * Returns simplified representation of all metrics' timestamps from monotonic clock
-   * @return {!Array<{ts: number, id: string, name: string}>} metrics to consider
+   * @return {Array<{ts: number, id: string, name: string}>} metrics to consider
    */
   gatherMetrics() {
-    const metricDfns = Metrics.metricsDefinitions;
+    const uberMetrics = getUberMetrics(this._auditResults);
+    if (!uberMetrics) {
+      return [];
+    }
+
+    /** @type {Array<{ts: number, id: string, name: string}>} */
     const resolvedMetrics = [];
-    metricDfns.forEach(metric => {
-      // try/catch in case auditResults is missing a particular audit result
-      try {
-        resolvedMetrics.push({
-          id: metric.id,
-          name: metric.name,
-          ts: metric.getTs(this._auditResults),
-        });
-      } catch (e) {
-        log.error('pwmetrics-events', `${metric.name} timestamp not found: ${e.message}`);
+    Metrics.metricsDefinitions.forEach(metric => {
+      // Skip if auditResults is missing a particular audit result
+      const ts = uberMetrics[metric.tsKey];
+      if (ts === undefined) {
+        log.error('pwmetrics-events', `${metric.name} timestamp not found`);
+        return;
       }
+
+      resolvedMetrics.push({
+        id: metric.id,
+        name: metric.name,
+        ts,
+      });
     });
+
     return resolvedMetrics;
   }
 
   /**
    * Get the full trace event for our navigationStart
-   * @param {!Array<{ts: number, id: string, name: string}>} metrics
+   * @param {Array<{ts: number, id: string, name: string}>} metrics
+   * @return {LH.TraceEvent|undefined}
    */
-  identifyNavigationStartEvt(metrics) {
+  getNavigationStartEvt(metrics) {
     const navStartMetric = metrics.find(e => e.id === 'navstart');
     if (!navStartMetric) return;
-    this._navigationStartEvt = this._traceEvents.find(
+    return this._traceEvents.find(
       e => e.name === 'navigationStart' && e.ts === navStartMetric.ts
     );
   }
@@ -140,13 +142,14 @@ class Metrics {
    *     { "pid": 89922,"tid":1295,"ts":77176783452,"ph":"b","cat":"blink.user_timing","name":"innermeasure","args":{},"tts":1257886,"id":"0xe66c67"}
    *     { "pid": 89922,"tid":1295,"ts":77176882592,"ph":"e","cat":"blink.user_timing","name":"innermeasure","args":{},"tts":1257898,"id":"0xe66c67"}
    * @param {{ts: number, id: string, name: string}} metric
-   * @return {!Array} Pair of trace events (start/end)
+   * @param {LH.TraceEvent} navigationStartEvt
+   * @return {Array<LH.TraceEvent>} Pair of trace events (start/end)
    */
-  synthesizeEventPair(metric) {
+  synthesizeEventPair(metric, navigationStartEvt) {
     // We'll masquerade our fake events to look mostly like navigationStart
     const eventBase = {
-      pid: this._navigationStartEvt.pid,
-      tid: this._navigationStartEvt.tid,
+      pid: navigationStartEvt.pid,
+      tid: navigationStartEvt.tid,
       cat: 'blink.user_timing',
       name: metric.name,
       args: {},
@@ -154,33 +157,34 @@ class Metrics {
       id: `0x${((Math.random() * 1000000) | 0).toString(16)}`,
     };
     const fakeMeasureStartEvent = Object.assign({}, eventBase, {
-      ts: this._navigationStartEvt.ts,
+      ts: navigationStartEvt.ts,
       ph: 'b',
     });
     const fakeMeasureEndEvent = Object.assign({}, eventBase, {
       ts: metric.ts,
       ph: 'e',
     });
-    return [fakeMeasureStartEvent, fakeMeasureEndEvent];
+    return /** @type {Array<LH.TraceEvent>} */ ([fakeMeasureStartEvent, fakeMeasureEndEvent]);
   }
 
   /**
-   * @returns {Array<LH.TraceEvent>} User timing raw trace event pairs
+   * @return {Array<LH.TraceEvent>} User timing raw trace event pairs
    */
   generateFakeEvents() {
-    const fakeEvents = [];
     const metrics = this.gatherMetrics();
     if (metrics.length === 0) {
       log.error('metrics-events', 'Metrics collection had errors, not synthetizing trace events');
       return [];
     }
 
-    this.identifyNavigationStartEvt(metrics);
-    if (!this._navigationStartEvt) {
+    const navigationStartEvt = this.getNavigationStartEvt(metrics);
+    if (!navigationStartEvt) {
       log.error('pwmetrics-events', 'Reference navigationStart not found');
       return [];
     }
 
+    /** @type {Array<LH.TraceEvent>} */
+    const fakeEvents = [];
     metrics.forEach(metric => {
       if (metric.id === 'navstart') {
         return;
@@ -190,7 +194,7 @@ class Metrics {
         return;
       }
       log.verbose('pwmetrics-events', `Sythesizing trace events for ${metric.name}`);
-      fakeEvents.push(...this.synthesizeEventPair(metric));
+      fakeEvents.push(...this.synthesizeEventPair(metric, navigationStartEvt));
     });
     return fakeEvents;
   }
diff --git a/lighthouse-core/report/html/renderer/category-renderer.js b/lighthouse-core/report/html/renderer/category-renderer.js
index 5a84a5781..0fa8be23b 100644
--- a/lighthouse-core/report/html/renderer/category-renderer.js
+++ b/lighthouse-core/report/html/renderer/category-renderer.js
@@ -43,23 +43,11 @@ class CategoryRenderer {
   /**
    * Display info per top-level clump. Define on class to avoid race with Util init.
    */
-  get _clumpDisplayInfo() {
+  get _clumpTitles() {
     return {
-      failed: {
-        className: 'lh-clump--failed',
-      },
-      manual: {
-        title: Util.UIStrings.manualAuditsGroupTitle,
-        className: 'lh-clump--manual',
-      },
-      passed: {
-        title: Util.UIStrings.passedAuditsGroupTitle,
-        className: 'lh-clump--passed',
-      },
-      notApplicable: {
-        title: Util.UIStrings.notApplicableAuditsGroupTitle,
-        className: 'lh-clump--notapplicable',
-      },
+      manual: Util.UIStrings.manualAuditsGroupTitle,
+      passed: Util.UIStrings.passedAuditsGroupTitle,
+      notApplicable: Util.UIStrings.notApplicableAuditsGroupTitle,
     };
   }
 
@@ -183,20 +171,13 @@ class CategoryRenderer {
    * Renders the group container for a group of audits. Individual audit elements can be added
    * directly to the returned element.
    * @param {LH.Result.ReportGroup} group
-   * @param {{expandable: boolean, itemCount?: number}} opts
    * @return {Element}
    */
-  renderAuditGroup(group, opts) {
-    const expandable = opts.expandable;
-    const groupEl = this.dom.createElement(expandable ? 'details' : 'div', 'lh-audit-group');
-    const summaryEl = this.dom.createChildOf(groupEl, expandable ? 'summary' : 'div');
+  renderAuditGroup(group) {
+    const groupEl = this.dom.createElement('div', 'lh-audit-group');
+    const summaryEl = this.dom.createChildOf(groupEl, 'div');
     const summaryInnerEl = this.dom.createChildOf(summaryEl, 'div', 'lh-audit-group__summary');
     const headerEl = this.dom.createChildOf(summaryInnerEl, 'div', 'lh-audit-group__header');
-    const itemCountEl = this.dom.createChildOf(summaryInnerEl, 'div', 'lh-audit-group__itemcount');
-    if (expandable) {
-      const chevronEl = summaryInnerEl.appendChild(this._createChevron());
-      chevronEl.title = Util.UIStrings.auditGroupExpandTooltip;
-    }
 
     if (group.description) {
       const auditGroupDescription = this.dom.createElement('div', 'lh-audit-group__description');
@@ -205,10 +186,6 @@ class CategoryRenderer {
     }
     headerEl.textContent = group.title;
 
-    if (opts.itemCount) {
-      // TODO(i18n): support multiple locales here
-      itemCountEl.textContent = `${opts.itemCount} audits`;
-    }
     return groupEl;
   }
 
@@ -251,10 +228,7 @@ class CategoryRenderer {
 
       // Push grouped audits as a group.
       const groupDef = groupDefinitions[groupId];
-      // Expanded by default!
-      // 1. The 'failed' clump has all groups expanded.
-      // 2. If a clump is collapsed (passed, n/a), we want the groups within to already be expanded
-      const auditGroupElem = this.renderAuditGroup(groupDef, {expandable: false});
+      const auditGroupElem = this.renderAuditGroup(groupDef);
       for (const auditRef of groupAuditRefs) {
         auditGroupElem.appendChild(this.renderAudit(auditRef, index++));
       }
@@ -280,45 +254,40 @@ class CategoryRenderer {
   }
 
   /**
-   * Renders a clump (a grouping of groups), under a status of failed, manual,
-   * passed, or notApplicable. The result ends up something like:
-   *
-   * clump (e.g. 'failed')
-   *   ├── audit 1 (w/o group)
-   *   ├── audit 2 (w/o group)
-   *   ├── audit group
-   *   |  ├── audit 3
-   *   |  └── audit 4
-   *   └── audit group
-   *      ├── audit 5
-   *      └── audit 6
-   * clump (e.g. 'manual')
-   *   ├── audit 1
-   *   ├── audit 2
-   *   ⋮
-   * @param {TopLevelClumpId} clumpId
-   * @param {{auditRefs: Array<LH.ReportResult.AuditRef>, groupDefinitions: Object<string, LH.Result.ReportGroup>, description?: string}} clumpOpts
+   * Take a set of audits and render in a top-level, expandable clump that starts
+   * in a collapsed state.
+   * @param {Exclude<TopLevelClumpId, 'failed'>} clumpId
+   * @param {{auditRefs: Array<LH.ReportResult.AuditRef>, description?: string}} clumpOpts
    * @return {Element}
    */
-  renderClump(clumpId, {auditRefs, groupDefinitions, description}) {
-    if (clumpId === 'failed') {
-      // Failed audit clump is always expanded and not nested in an lh-audit-group.
-      const failedElem = this.renderUnexpandableClump(auditRefs, groupDefinitions);
-      failedElem.classList.add('lh-clump', this._clumpDisplayInfo.failed.className);
-      return failedElem;
+  renderClump(clumpId, {auditRefs, description}) {
+    const clumpTmpl = this.dom.cloneTemplate('#tmpl-lh-clump', this.templateContext);
+    const clumpElement = this.dom.find('.lh-clump', clumpTmpl);
+
+    const summaryInnerEl = this.dom.find('.lh-audit-group__summary', clumpElement);
+    const chevronEl = summaryInnerEl.appendChild(this._createChevron());
+    chevronEl.title = Util.UIStrings.auditGroupExpandTooltip;
+
+    const headerEl = this.dom.find('.lh-audit-group__header', clumpElement);
+    const title = this._clumpTitles[clumpId];
+    headerEl.textContent = title;
+    if (description) {
+      const markdownDescriptionEl = this.dom.convertMarkdownLinkSnippets(description);
+      const auditGroupDescription = this.dom.createElement('div', 'lh-audit-group__description');
+      auditGroupDescription.appendChild(markdownDescriptionEl);
+      clumpElement.appendChild(auditGroupDescription);
     }
 
-    const clumpInfo = this._clumpDisplayInfo[clumpId];
-    // TODO: renderAuditGroup shouldn't be used to render a clump (since it *contains* audit groups).
-    const groupDef = {title: clumpInfo.title, description};
-    const groupOpts = {expandable: true, itemCount: auditRefs.length};
-    const clumpElem = this.renderAuditGroup(groupDef, groupOpts);
-    clumpElem.classList.add('lh-clump', clumpInfo.className);
+    const itemCountEl = this.dom.find('.lh-audit-group__itemcount', clumpElement);
+    // TODO(i18n): support multiple locales here
+    itemCountEl.textContent = `${auditRefs.length} audits`;
 
-    // For all non-failed clumps, we don't group
-    clumpElem.append(...auditRefs.map(this.renderAudit.bind(this)));
+    // Add all audit results to the clump.
+    const auditElements = auditRefs.map(this.renderAudit.bind(this));
+    clumpElement.append(...auditElements);
 
-    return clumpElem;
+    clumpElement.classList.add(`lh-clump--${clumpId.toLowerCase()}`);
+    return clumpElement;
   }
 
   /**
@@ -383,6 +352,23 @@ class CategoryRenderer {
   }
 
   /**
+   * Renders a set of top level sections (clumps), under a status of failed, manual,
+   * passed, or notApplicable. The result ends up something like:
+   *
+   * failed clump
+   *   ├── audit 1 (w/o group)
+   *   ├── audit 2 (w/o group)
+   *   ├── audit group
+   *   |  ├── audit 3
+   *   |  └── audit 4
+   *   └── audit group
+   *      ├── audit 5
+   *      └── audit 6
+   * other clump (e.g. 'manual')
+   *   ├── audit 1
+   *   ├── audit 2
+   *   ├── …
+   *   ⋮
    * @param {LH.ReportResult.Category} category
    * @param {Object<string, LH.Result.ReportGroup>} [groupDefinitions]
    * @return {Element}
@@ -409,12 +395,18 @@ class CategoryRenderer {
     }
 
     // Render each clump.
-    for (const [clumpId, clumpRefs] of clumps) {
-      if (clumpRefs.length === 0) continue;
+    for (const [clumpId, auditRefs] of clumps) {
+      if (auditRefs.length === 0) continue;
+
+      if (clumpId === 'failed') {
+        const clumpElem = this.renderUnexpandableClump(auditRefs, groupDefinitions);
+        clumpElem.classList.add(`lh-clump--failed`);
+        element.appendChild(clumpElem);
+        continue;
+      }
 
       const description = clumpId === 'manual' ? category.manualDescription : undefined;
-      const clumpElem = this.renderClump(clumpId, {auditRefs: clumpRefs, groupDefinitions,
-        description});
+      const clumpElem = this.renderClump(clumpId, {auditRefs, description});
       element.appendChild(clumpElem);
     }
 
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 252b66701..870bdfc4e 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -129,7 +129,7 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
 
     // Metrics
     const metricAudits = category.auditRefs.filter(audit => audit.group === 'metrics');
-    const metricAuditsEl = this.renderAuditGroup(groups.metrics, {expandable: false});
+    const metricAuditsEl = this.renderAuditGroup(groups.metrics);
 
     const keyMetrics = metricAudits.filter(a => a.weight >= 3);
     const otherMetrics = metricAudits.filter(a => a.weight < 3);
@@ -176,7 +176,7 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
       const wastedMsValues = opportunityAudits.map(audit => this._getWastedMs(audit));
       const maxWaste = Math.max(...wastedMsValues);
       const scale = Math.max(Math.ceil(maxWaste / 1000) * 1000, minimumScale);
-      const groupEl = this.renderAuditGroup(groups['load-opportunities'], {expandable: false});
+      const groupEl = this.renderAuditGroup(groups['load-opportunities']);
       const tmpl = this.dom.cloneTemplate('#tmpl-lh-opportunity-header', this.templateContext);
 
       this.dom.find('.lh-load-opportunity__col--one', tmpl).textContent =
@@ -202,7 +202,7 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
         });
 
     if (diagnosticAudits.length) {
-      const groupEl = this.renderAuditGroup(groups['diagnostics'], {expandable: false});
+      const groupEl = this.renderAuditGroup(groups['diagnostics']);
       diagnosticAudits.forEach((item, i) => groupEl.appendChild(this.renderAudit(item, i)));
       groupEl.classList.add('lh-audit-group--diagnostics');
       element.appendChild(groupEl);
diff --git a/lighthouse-core/report/html/renderer/pwa-category-renderer.js b/lighthouse-core/report/html/renderer/pwa-category-renderer.js
index 690e8da89..7e07206f3 100644
--- a/lighthouse-core/report/html/renderer/pwa-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/pwa-category-renderer.js
@@ -40,7 +40,7 @@ class PwaCategoryRenderer extends CategoryRenderer {
     // Manual audits are still in a manual clump.
     const manualAuditRefs = auditRefs.filter(ref => ref.result.scoreDisplayMode === 'manual');
     const manualElem = this.renderClump('manual',
-      {auditRefs: manualAuditRefs, groupDefinitions, description: category.manualDescription});
+      {auditRefs: manualAuditRefs, description: category.manualDescription});
     categoryElem.appendChild(manualElem);
 
     return categoryElem;
diff --git a/lighthouse-core/report/html/renderer/report-renderer.js b/lighthouse-core/report/html/renderer/report-renderer.js
index 8b9559fa4..72f1711fd 100644
--- a/lighthouse-core/report/html/renderer/report-renderer.js
+++ b/lighthouse-core/report/html/renderer/report-renderer.js
@@ -206,6 +206,15 @@ class ReportRenderer {
       categories.appendChild(renderer.render(category, report.categoryGroups));
     }
 
+    // Fireworks
+    const scoresAll100 = report.reportCategories.every(cat => cat.score === 1);
+    if (scoresAll100) {
+      headerContainer.classList.add('score100');
+      this._dom.find('.lh-header', headerContainer).addEventListener('click', _ => {
+        headerContainer.classList.toggle('fireworks-paused');
+      });
+    }
+
     if (scoreHeader) {
       const defaultGauges = [];
       const customGauges = [];
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 16b1fd3f8..bfc1b627c 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -241,10 +241,12 @@ class ReportUIFeatures {
     scoreShadow.style.transform = `scaleY(${1 - scrollPct * 0.2})`;
 
     // Fade & move the scorescale
-    const scoreScalePositionDelta = 32;
-    const scoreScale = this._dom.find('.lh-scorescale', scoresContainer);
-    scoreScale.style.opacity = `${1 - scrollPct}`;
-    scoreScale.style.transform = `translateY(${scrollPct * -scoreScalePositionDelta}px)`;
+    try {
+      const scoreScalePositionDelta = 32;
+      const scoreScale = this._dom.find('.lh-scorescale', scoresContainer);
+      scoreScale.style.opacity = `${1 - scrollPct}`;
+      scoreScale.style.transform = `translateY(${scrollPct * -scoreScalePositionDelta}px)`;
+    } catch (e) {}
 
     // Move the toolbar & export
     this.toolbar.style.transform = `translateY(${heightDiff * scrollPct}px)`;
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 0971ccd1f..1f90384b3 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -52,6 +52,20 @@ limitations under the License.
   </div>
 </template>
 
+<!-- Lighthouse clump -->
+<template id="tmpl-lh-clump">
+  <!-- TODO: group classes shouldn't be reused for clumps. -->
+  <details class="lh-clump lh-audit-group">
+    <summary>
+      <div class="lh-audit-group__summary">
+        <div class="lh-audit-group__header"></div>
+        <div class="lh-audit-group__itemcount"></div>
+        <div class=""></div>
+      </div>
+    </summary>
+  </details>
+</template>
+
 <!-- Lighthouse audit -->
 <template id="tmpl-lh-audit">
   <div class="lh-audit">
@@ -163,6 +177,7 @@ limitations under the License.
       top: 0;
       width: 100%;
       will-change: transform;
+      contain: strict;
     }
     .lh-lighthouse {
       position: absolute;
@@ -352,8 +367,91 @@ limitations under the License.
       77.0001% { transform: translateX(195px); }
       100% { transform: translateX(0px); }
     } */
+
+    .score100 .lh-header-bg {
+      background-color: hsl(234, 64%, 19%);
+    }
+    .score100 .lh-metadata, .score100 .lh-toolbar__metadata, .score100 .lh-product-info {
+      color: #fff;
+    }
+    .score100 .lh-config {
+      color: #eee;
+    }
+
+    /* CSS Fireworks. Originally by Eddie Lin
+       https://codepen.io/paulirish/pen/yEVMbP
+    */
+    .pyro {
+      display: none;
+    }
+    .score100 .pyro {
+      display: block;
+    }
+    .score100 .lh-lighthouse stop:first-child {
+      stop-color: hsla(200, 12%, 95%, 0);
+    }
+    .score100 .lh-lighthouse stop:last-child {
+      stop-color: hsla(65, 81%, 76%, 1);
+    }
+
+    .pyro > .before, .pyro > .after {
+      position: absolute;
+      width: 5px;
+      height: 5px;
+      border-radius: 2.5px;
+      box-shadow: 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff, 0 0 #fff;
+      animation: 1s bang ease-out infinite backwards,  1s gravity ease-in infinite backwards,  5s position linear infinite backwards;
+      animation-delay: 1s, 1s, 1s;
+    }
+
+    .pyro > .after {
+      animation-delay: 2.25s, 2.25s, 2.25s;
+      animation-duration: 1.25s, 1.25s, 6.25s;
+    }
+    .fireworks-paused .pyro > div {
+      animation-play-state: paused;
+    }
+
+    @keyframes bang {
+      to {
+        box-shadow: -70px -115.67px #47ebbc, -28px -99.67px #eb47a4, 58px -31.67px #7eeb47, 13px -141.67px #eb47c5, -19px 6.33px #7347eb, -2px -74.67px #ebd247, 24px -151.67px #eb47e0, 57px -138.67px #b4eb47, -51px -104.67px #479eeb, 62px 8.33px #ebcf47, -93px 0.33px #d547eb, -16px -118.67px #47bfeb, 53px -84.67px #47eb83, 66px -57.67px #eb47bf, -93px -65.67px #91eb47, 30px -13.67px #86eb47, -2px -59.67px #83eb47, -44px 1.33px #eb47eb, 61px -58.67px #47eb73, 5px -22.67px #47e8eb, -66px -28.67px #ebe247, 42px -123.67px #eb5547, -75px 26.33px #7beb47, 15px -52.67px #a147eb, 36px -51.67px #eb8347, -38px -12.67px #eb5547, -46px -59.67px #47eb81, 78px -114.67px #eb47ba, 15px -156.67px #eb47bf, -36px 1.33px #eb4783, -72px -86.67px #eba147, 31px -46.67px #ebe247, -68px 29.33px #47e2eb, -55px 19.33px #ebe047, -56px 27.33px #4776eb, -13px -91.67px #eb5547, -47px -138.67px #47ebc7, -18px -96.67px #eb47ac, 11px -88.67px #4783eb, -67px -28.67px #47baeb, 53px 10.33px #ba47eb, 11px 19.33px #5247eb, -5px -11.67px #eb4791, -68px -4.67px #47eba7, 95px -37.67px #eb478b, -67px -162.67px #eb5d47, -54px -120.67px #eb6847, 49px -12.67px #ebe047, 88px 8.33px #47ebda, 97px 33.33px #eb8147, 6px -71.67px #ebbc47;
+      }
+    }
+    @keyframes gravity {
+      to {
+        transform: translateY(80px);
+        opacity: 0;
+      }
+    }
+    @keyframes position {
+      0%, 19.9% {
+        margin-top: 4%;
+        margin-left: 47%;
+      }
+      20%, 39.9% {
+        margin-top: 7%;
+        margin-left: 30%;
+      }
+      40%, 59.9% {
+        margin-top: 6%;
+        margin-left: 70%;
+      }
+      60%, 79.9% {
+        margin-top: 3%;
+        margin-left: 20%;
+      }
+      80%, 99.9% {
+        margin-top: 3%;
+        margin-left: 80%;
+      }
+    }
   </style>
-  <div class="lh-header-bg"></div>
+  <div class="lh-header-bg">
+    <div class="pyro">
+      <div class="before"></div>
+      <div class="after"></div>
+    </div>
+  </div>
   
 
   <div class="lh-header-container">
@@ -370,12 +468,14 @@ limitations under the License.
             </linearGradient>
           </defs>
           <g mask="url(#a)">
+            <!-- lighthouse building elements -->
             <path d="M95 47h24v2H95z" fill="#ec5548"/>
-            <path d="M98 49h18v11H98z" fill="#fbc21b"/>
             <path d="M95 59h24v7H95z" fill="#ec5548"/>
             <path d="M97.63 66h19.74l2.63 47H95z" fill="#fff"/>
             <path d="M107 38a10 10 0 0 1 10 10v1H97v-1a10 10 0 0 1 10-10zM96.77 82.23l21-10.7.63 11.87-22.31 11.87zM95 110.8L119.1 98l.9 14H95z" fill="#ec5548"/>
             <path d="M0 148a177.58 177.58 0 0 1 217 0z" fill="#e8eaed"/>
+            <!-- lighthouse beam elements -->
+            <path d="M98 49h18v11H98z" fill="#fbc21b"/>
             <path d="M103 49a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5z" fill="#fef0c8"/>
             <path d="M7 16l91 33.18v10L7 93z" fill="url(#b)"/>
           </g>
diff --git a/readme.md b/readme.md
index f07bb1b3c..d0cc15dc9 100644
--- a/readme.md
+++ b/readme.md
@@ -271,6 +271,8 @@ This section details services that have integrated Lighthouse data. If you're wo
 
 * **[SpeedCurve](https://speedcurve.com)** — SpeedCurve is a tool for continuously monitoring web performance across different browsers, devices, and regions. It can aggregate any metric including Lighthouse scores across multiple pages and sites, and allows you to set performance budgets with Slack or email alerts. SpeedCurve is a paid product with a free 30-day trial.
 
+* **[Speedrank](https://speedrank.app)** - Speedrank monitors the performance of your website in the background. It displays Lighthouse reports over time and delivers recommendations for improvement. Speedrank is a paid product with 14-day-trial.
+
 * **[Treo](https://treo.sh)** - Treo is Lighthouse as a Service. It provides regression testing, geographical regions, custom networks, and integrations with GitHub & Slack. Treo is a paid product with plans for solo-developers and teams.
 
 * **[Web Page Test](https://www.webpagetest.org)** — An [open source](https://github.com/WPO-Foundation/webpagetest) tool for measuring and analyzing the performance of web pages on real devices. Users can choose to produce a Lighthouse report alongside the analysis of WebPageTest results.


Test patch:
diff --git a/lighthouse-core/test/report/html/renderer/report-renderer-test.js b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
index 2609dbc44..478d8dd81 100644
--- a/lighthouse-core/test/report/html/renderer/report-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/report-renderer-test.js
@@ -81,6 +81,8 @@ describe('ReportRenderer', () => {
       assert.ok(output.querySelector('.lh-report'), 'has report body');
       assert.equal(output.querySelectorAll('.lh-gauge__wrapper, .lh-gauge--pwa__wrapper').length,
           sampleResults.reportCategories.length * 2, 'renders category gauges');
+      // no fireworks
+      assert.ok(output.querySelector('.score100') === null, 'has no fireworks treatment');
     });
 
     it('renders additional reports by replacing the existing one', () => {
@@ -180,6 +182,23 @@ describe('ReportRenderer', () => {
     assert.equal(renderer._templateContext, otherDocument);
   });
 
+  it('should render an all 100 report with fireworks', () => {
+    const container = renderer._dom._document.body;
+
+    sampleResults.reportCategories.forEach(element => {
+      element.score = 1;
+    });
+
+    const output = renderer.renderReport(sampleResults, container);
+    // standard checks
+    assert.ok(output.querySelector('.lh-header-sticky'), 'has a header');
+    assert.ok(output.querySelector('.lh-report'), 'has report body');
+    assert.equal(output.querySelectorAll('.lh-gauge__wrapper, .lh-gauge--pwa__wrapper').length,
+        sampleResults.reportCategories.length * 2, 'renders category gauges');
+    // fireworks!
+    assert.ok(output.querySelector('.score100'), 'has fireworks treatment');
+  });
+
   it('renders `not_applicable` audits as `notApplicable`', () => {
     const clonedSampleResult = JSON.parse(JSON.stringify(sampleResultsOrig));