Repository: GoogleChrome/lighthouse
Pull request #5455 (2018-06-08 07:58:11)

report: celebrate your all 100's
It's come up before. But it came up today: https://twitter.com/RobertJGabriel/status/1004604294102945793

The logic is, reward within the report if all the categories in your report have a 100. This can trigger even if you're only testing a single category.  I considered enforcing that 2 or more categories are present, but that seems arbitrary. 

(A further thing to explore: add additional fireworks per each included category. From a trickle up to a finale.)

example report: 

* https://ripe-church.surge.sh/score100.html
* https://ripe-church.surge.sh/solocat100.html

JoelEinbinder: ```css
.score100 .lh-lighthouse {
    animation: 4s linear infinite party-lighthouse;
}

@keyframes party-lighthouse {
    from{
        filter: hue-rotate(0deg);
    }
    to{
        filter: hue-rotate(360deg);
    }
}
```
patrickhulce: Ha, this is awesome!!! ❤️ 
patrickhulce: @hwikyounglee and I were just thinking it might be cool to go all out and replace the dark gray background and do a animation through the rainbow :D 

```css
.score100 .lh-header-bg {
  background: #f00;
  animation: 4s linear infinite party-background;
}

@keyframes party-background {
    from{
        filter: hue-rotate(0deg);
    }
    to{
        filter: hue-rotate(360deg);
    }
}
```
hwikyounglee: A suggestion to the excitement: shall we have a way to stop the animation to provide a control?
Options might be: play only once on load, stop when click the sky, stop on scrolling, or a combination of those. 


paulirish: see commits for changes

* https://ripe-church.surge.sh/score100.html
* https://ripe-church.surge.sh/solocat100.html
benschwarz: My vote is for lights coming out of he ligthouse as if there’s an epic party going on inside 👏
hwikyounglee: - For the collapsed header, either the text might need to be inverted or go back to the regular gray bar?  
![image](https://user-images.githubusercontent.com/7561395/41177808-39a21ffe-6b1a-11e8-99b3-76390e54e4e0.png)
- I feel we should go with **all** category 100's for fireworks. For a solo cat 100, we could consider a confetti in each gauge in the future perhaps? 
- Also, wondering about the battery usage of the animation: if it consumes battery, I suggest the animation repeats x times (x=3~5?). 

Thanks Paul!
ebidel: Lighthouse blue!? It's safer to sail in the day time.

<img width="959" alt="screen shot 2018-06-08 at 1 06 40 pm" src="https://user-images.githubusercontent.com/238208/41178738-14a74cda-6b1d-11e8-9c90-292adf9bb386.png">

Also noticed a few scroll bugs. 

Background area shows through header:

<img width="903" alt="screen shot 2018-06-08 at 1 06 45 pm" src="https://user-images.githubusercontent.com/238208/41178773-2ce8c4fe-6b1d-11e8-86ae-3b1fc0630926.png">

Is the share icon meant to be see through?

<img width="161" alt="screen shot 2018-06-08 at 1 07 13 pm" src="https://user-images.githubusercontent.com/238208/41178806-47255c9c-6b1d-11e8-8854-cd0031d43208.png">

As @hwikyounglee pointed this out too. The text is hard to see:

<img width="344" alt="screen shot 2018-06-08 at 1 06 49 pm" src="https://user-images.githubusercontent.com/238208/41178827-5572f8cc-6b1d-11e8-8452-16c254b7a383.png">




paulirish: examples are updated:

* https://ripe-church.surge.sh/score100.html
* https://ripe-church.surge.sh/solocat100.html
exterkamp: Took @JoelEinbinder's amazing party CSS and applied it to just the lighthouse light:
![fe8fffb5-1a62-479e-b90f-f08752c56cb4](https://user-images.githubusercontent.com/6392995/50938958-a4a7f580-142f-11e9-8e15-d18b35ff31ab.gif)
Yes? or very yes?
patrickhulce: oh VERY yes!
exterkamp: Updated examples:
- [full](http://simplistic-ghost.surge.sh/example.com_2019-01-10_10-45-43.report.html)
- [one category](http://simplistic-ghost.surge.sh/example.com_2019-01-10_10-47-43.report)
Hoten: Super fun I want this yesterday.

just gonna bring up @hwikyounglee's concerns about how this could be distracting.

> A suggestion to the excitement: shall we have a way to stop the animation to provide a control?
> Options might be: play only once on load, stop when click the sky, stop on scrolling, or a combination of those.

WDYT of stopping when clicking?

Patch:
diff --git a/lighthouse-cli/cli-flags.js b/lighthouse-cli/cli-flags.js
index c6ca5dd57..6aefa4edb 100644
--- a/lighthouse-cli/cli-flags.js
+++ b/lighthouse-cli/cli-flags.js
@@ -35,7 +35,7 @@ function getFlags(manualArgv) {
           'lighthouse <url> --disable-device-emulation --throttling-method=provided',
           'Disable device emulation and all throttling')
       .example(
-          'lighthouse <url> --chrome-flags="--window-size=412,732"',
+          'lighthouse <url> --chrome-flags="--window-size=412,660"',
           'Launch Chrome with a specific window size')
       .example(
           'lighthouse <url> --quiet --chrome-flags="--headless"',
diff --git a/lighthouse-core/lib/emulation.js b/lighthouse-core/lib/emulation.js
index b27406c6e..73a443747 100644
--- a/lighthouse-core/lib/emulation.js
+++ b/lighthouse-core/lib/emulation.js
@@ -14,9 +14,9 @@
 const NEXUS5X_EMULATION_METRICS = {
   mobile: true,
   screenWidth: 412,
-  screenHeight: 732,
+  screenHeight: 660,
   width: 412,
-  height: 732,
+  height: 660,
   positionX: 0,
   positionY: 0,
   scale: 1,
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 25976cce4..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;
   }
 
@@ -217,10 +194,9 @@ class CategoryRenderer {
    * array of audit and audit-group elements.
    * @param {Array<LH.ReportResult.AuditRef>} auditRefs
    * @param {Object<string, LH.Result.ReportGroup>} groupDefinitions
-   * @param {{expandable: boolean}} opts
    * @return {Array<Element>}
    */
-  _renderGroupedAudits(auditRefs, groupDefinitions, opts) {
+  _renderGroupedAudits(auditRefs, groupDefinitions) {
     // Audits grouped by their group (or under notAGroup).
     /** @type {Map<string, Array<LH.ReportResult.AuditRef>>} */
     const grouped = new Map();
@@ -252,7 +228,7 @@ class CategoryRenderer {
 
       // Push grouped audits as a group.
       const groupDef = groupDefinitions[groupId];
-      const auditGroupElem = this.renderAuditGroup(groupDef, opts);
+      const auditGroupElem = this.renderAuditGroup(groupDef);
       for (const auditRef of groupAuditRefs) {
         auditGroupElem.appendChild(this.renderAudit(auditRef, index++));
       }
@@ -272,52 +248,46 @@ class CategoryRenderer {
    */
   renderUnexpandableClump(auditRefs, groupDefinitions) {
     const clumpElement = this.dom.createElement('div');
-    const elements = this._renderGroupedAudits(auditRefs, groupDefinitions, {expandable: false});
+    const elements = this._renderGroupedAudits(auditRefs, groupDefinitions);
     elements.forEach(elem => clumpElement.appendChild(elem));
     return clumpElement;
   }
 
   /**
-   * 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')
-   *   ├── …
-   *   ⋮
-   * @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 expandable = true;
-    const elements = this._renderGroupedAudits(auditRefs, groupDefinitions, {expandable});
-
-    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 opts = {expandable, itemCount: auditRefs.length};
-    const clumpElem = this.renderAuditGroup(groupDef, opts);
-    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`;
 
-    elements.forEach(elem => clumpElem.appendChild(elem));
+    // 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;
   }
 
   /**
@@ -382,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}
@@ -408,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 c9882b27f..f07bb1b3c 100644
--- a/readme.md
+++ b/readme.md
@@ -106,7 +106,7 @@ Examples:
   lighthouse <url> --output=json --output-path=./report.json --save-assets  Save trace, devtoolslog, and named JSON report.
   lighthouse <url> --disable-device-emulation                               Disable device emulation and all throttling.
     --throttling-method=provided
-  lighthouse <url> --chrome-flags="--window-size=412,732"                   Launch Chrome with a specific window size
+  lighthouse <url> --chrome-flags="--window-size=412,660"                   Launch Chrome with a specific window size
   lighthouse <url> --quiet --chrome-flags="--headless"                      Launch Headless Chrome, turn off logging
   lighthouse <url> --extra-headers "{\"Cookie\":\"monster=blue\"}"          Stringify\'d JSON HTTP Header key/value pairs to send in requests
   lighthouse <url> --extra-headers=./path/to/file.json                      Path to JSON file of HTTP Header key/value pairs to send in requests


Test patch:
diff --git a/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json b/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json
index 777cdf1b6..8af6b351a 100644
--- a/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json
+++ b/lighthouse-core/test/fixtures/artifacts/perflog/artifacts.json
@@ -12,9 +12,9 @@
   "Viewport": null,
   "ViewportDimensions": {
     "innerWidth": 412,
-    "innerHeight": 732,
+    "innerHeight": 660,
     "outerWidth": 412,
-    "outerHeight": 732,
+    "outerHeight": 660,
     "devicePixelRatio": 2.625
   }
 }
diff --git a/lighthouse-core/test/report/html/renderer/category-renderer-test.js b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
index 8be1099a4..bbb554754 100644
--- a/lighthouse-core/test/report/html/renderer/category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
@@ -227,14 +227,16 @@ describe('CategoryRenderer', () => {
       assert.equal(failedAuditGroups.length, failedAuditTags.size);
     });
 
-    it('renders the passed audits grouped by group', () => {
+    it('renders the passed audits ungrouped', () => {
       const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
       const passedAudits = category.auditRefs.filter(audit =>
           audit.result.scoreDisplayMode !== 'notApplicable' && audit.result.score === 1);
-      const passedAuditTags = new Set(passedAudits.map(audit => audit.group));
 
       const passedAuditGroups = categoryDOM.querySelectorAll('.lh-clump--passed .lh-audit-group');
-      assert.equal(passedAuditGroups.length, passedAuditTags.size);
+      const passedAuditsElems = categoryDOM.querySelectorAll('.lh-clump--passed .lh-audit');
+
+      assert.equal(passedAuditGroups.length, 0);
+      assert.equal(passedAuditsElems.length, passedAudits.length);
     });
 
     it('renders all the audits', () => {
@@ -293,15 +295,20 @@ describe('CategoryRenderer', () => {
     });
 
     it('gives each group a selectable class', () => {
+      const categoryClone = JSON.parse(JSON.stringify(category));
+      // Force all results to be Failed for accurate counting of groups.
+      categoryClone.auditRefs.forEach(ref => {
+        ref.result.score = 0;
+        ref.result.scoreDisplayMode = 'binary';
+      });
       const categoryGroupIds = new Set(category.auditRefs.filter(a => a.group).map(a => a.group));
       assert.ok(categoryGroupIds.size > 6); // Ensure there's something to test.
 
-      const categoryElem = renderer.render(category, sampleResults.categoryGroups);
+      const categoryElem = renderer.render(categoryClone, sampleResults.categoryGroups);
 
       categoryGroupIds.forEach(groupId => {
         const selector = `.lh-audit-group--${groupId}`;
-        // Could be multiple results (e.g. found in both passed and failed clumps).
-        assert.ok(categoryElem.querySelectorAll(selector).length >= 1,
+        assert.equal(categoryElem.querySelectorAll(selector).length, 1,
           `could not find '${selector}'`);
       });
     });
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));
 
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 3e7fc8677..09f5ae673 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -124,9 +124,9 @@
   "Viewport": "width=device-width, initial-scale=1, minimum-scale=1",
   "ViewportDimensions": {
     "innerWidth": 412,
-    "innerHeight": 732,
+    "innerHeight": 660,
     "outerWidth": 412,
-    "outerHeight": 732,
+    "outerHeight": 660,
     "devicePixelRatio": 2.625
   },
   "ThemeColor": null,
diff --git a/lighthouse-core/test/runner-test.js b/lighthouse-core/test/runner-test.js
index 8774274e2..c0f11d7da 100644
--- a/lighthouse-core/test/runner-test.js
+++ b/lighthouse-core/test/runner-test.js
@@ -478,7 +478,7 @@ describe('Runner', () => {
 
     return Runner.run({}, {config}).then(results => {
       assert.strictEqual(results.artifacts.ViewportDimensions.innerWidth, 412);
-      assert.strictEqual(results.artifacts.ViewportDimensions.innerHeight, 732);
+      assert.strictEqual(results.artifacts.ViewportDimensions.innerHeight, 660);
     });
   });