Repository: GoogleChrome/lighthouse
Pull request #9198 (2019-06-13 20:55:23)

core(gather-runner): don't save trace on pass with pageLoadError
part of https://github.com/GoogleChrome/lighthouse/pull/8865#issuecomment-497507618

#9176 turned a load that ends up on a Chrome interstitial (e.g. `INSECURE_DOCUMENT_REQUEST`) into a runtime error to let the user know that the page they were testing had a major problem and was never actually loaded for the test. Currently, however, all the artifacts in that errored pass are returned as errors *except* the devtoolsLog and trace, which end up in a kind of weird state where they're actually tracing the load of the interstitial, not any useful page.

If the load ends up just going to the interstitial, the metrics all end up as `NO_NAVSTART` errors, but if there was somehow a navigation (e.g. a URL redirects to a page with a bad cert), you'll end up with some nice metrics for the interstitial page.

Example report:

[<img width="962" alt="report with valid metrics for error interstitial" src="https://user-images.githubusercontent.com/316891/59466133-5ed9c980-8de1-11e9-8e5b-a6b5e30df376.png">](https://googlechrome.github.io/lighthouse/viewer/?gist=91d3c00c1186fdac9cc64f348dd14274)

https://googlechrome.github.io/lighthouse/viewer/?gist=91d3c00c1186fdac9cc64f348dd14274

Regardless of seeing an audit error, in a pass with a `pageLoadError`, we shouldn't save the trace or devtoolsLog, just like we don't save the rest of the artifacts in that pass.

After this PR:

<img width="985" alt="report with errored metrics for error interstitial" src="https://user-images.githubusercontent.com/316891/59466521-6057c180-8de2-11e9-8d79-eec548952b4a.png">

(also adds a test for the if-pageLoadError-stop-reloading behavior added in #8866/#9121)

patrickhulce: So I understand doing this from the perspective of the audit-visible artifacts and agree we prefer this report outcome, but from what I understand this eliminates the only current benefit of not having a fatal error, i.e. it's helpful right now when there's a `runtimeError` to be able to inspect the devtools log and trace for screenshots and network debugging data.

WDYT about trying to make the trace and devtools log still pass through to artifact saver/node result object?

Patch:
diff --git a/.appveyor.yml b/.appveyor.yml
index 71fa74955..f6e6cf6b9 100644
--- a/.appveyor.yml
+++ b/.appveyor.yml
@@ -21,7 +21,7 @@ install:
 
 before_test:
   - set "PATH=C:\MinGW\msys\1.0\bin;%PATH%"
-  - set "LIGHTHOUSE_CHROMIUM_PATH=%CD%\chrome-win32\chrome.exe"
+  - set "CHROME_PATH=%CD%\chrome-win32\chrome.exe"
   - bash ./lighthouse-core/scripts/download-chrome.sh
   - yarn build-all
 
diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md
index ea0f5dc98..098af3840 100644
--- a/.github/ISSUE_TEMPLATE/Bug_report.md
+++ b/.github/ISSUE_TEMPLATE/Bug_report.md
@@ -10,6 +10,8 @@ about: Report something working incorrectly
 
 #### Provide the steps to reproduce
 1. Run LH on <affected url>
+  
+<!-- If your page is only local, or is liable to change, consider uploading a repro so that we can more easily debug the problem. Some services that will help are: https://jsbin.com/, https://surge.sh/ -->
 
 #### What is the current behavior?
 
diff --git a/docs/understanding-results.md b/docs/understanding-results.md
index a98ec06f7..c753e8e1d 100644
--- a/docs/understanding-results.md
+++ b/docs/understanding-results.md
@@ -158,7 +158,7 @@ An array containing the different categories, their scores, and the results of t
 | id | `string` | The string identifier of the category. |
 | title | `string` | The human-friendly display name of the category. |
 | description | `string` | A brief description of the purpose of the category, supports markdown links. |
-| score | `string` | The overall score of the category, the weighted average of all its audits. |
+| score | `number` | The overall score of the category, the weighted average of all its audits. |
 | auditRefs | `AuditEntry[]` | An array of all the audit results in the category. |
 
 ### AuditEntry Properties
diff --git a/lighthouse-core/audits/font-display.js b/lighthouse-core/audits/font-display.js
index 52f0c1c35..9f273a0a2 100644
--- a/lighthouse-core/audits/font-display.js
+++ b/lighthouse-core/audits/font-display.js
@@ -24,6 +24,9 @@ const UIStrings = {
     'Leverage the font-display CSS feature to ensure text is user-visible while ' +
     'webfonts are loading. ' +
     '[Learn more](https://developers.google.com/web/updates/2016/02/font-display).',
+  /** A warning message that is shown when Lighthouse couldn't automatically check some of the page's fonts and that the user will need to manually check it. */
+  undeclaredFontURLWarning: 'Lighthouse was unable to automatically check the font-display value ' +
+    'for the following URL: {fontURL}.',
 };
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
@@ -44,10 +47,13 @@ class FontDisplay extends Audit {
 
   /**
    * @param {LH.Artifacts} artifacts
+   * @return {{passingURLs: Set<string>, failingURLs: Set<string>}}
    */
-  static findPassingFontDisplayDeclarations(artifacts) {
+  static findFontDisplayDeclarations(artifacts) {
     /** @type {Set<string>} */
     const passingURLs = new Set();
+    /** @type {Set<string>} */
+    const failingURLs = new Set();
 
     // Go through all the stylesheets to find all @font-face declarations
     for (const stylesheet of artifacts.CSSUsage.stylesheets) {
@@ -57,20 +63,18 @@ class FontDisplay extends Audit {
       const fontFaceDeclarations = newlinesStripped.match(/@font-face\s*{(.*?)}/g) || [];
       // Go through all the @font-face declarations to find a declared `font-display: ` property
       for (const declaration of fontFaceDeclarations) {
-        // Find the font-display value by matching a single token, optionally surrounded by whitespace,
-        // followed either by a semicolon or the end of a block.
-        const rawFontDisplay = declaration.match(/font-display\s*:\s*(\w+)\s*(;|\})/);
-        // If they didn't have a font-display property, it's the default, and it's failing; bail
-        if (!rawFontDisplay) continue;
-        // If they don't have one of the passing font-display values, it's failing; bail
-        const hasPassingFontDisplay = PASSING_FONT_DISPLAY_REGEX.test(rawFontDisplay[1]);
-        if (!hasPassingFontDisplay) continue;
-
-        // If it's passing, we'll try to find the URL it's referencing.
+        // We'll try to find the URL it's referencing.
         const rawFontURLs = declaration.match(CSS_URL_GLOBAL_REGEX);
         // If no URLs, we can't really do anything; bail
         if (!rawFontURLs) continue;
+        // Find the font-display value by matching a single token, optionally surrounded by whitespace,
+        // followed either by a semicolon or the end of a block.
+        const fontDisplayMatch = declaration.match(/font-display\s*:\s*(\w+)\s*(;|\})/);
+        const rawFontDisplay = (fontDisplayMatch && fontDisplayMatch[1]) || '';
+        const hasPassingFontDisplay = PASSING_FONT_DISPLAY_REGEX.test(rawFontDisplay);
+        const targetURLSet = hasPassingFontDisplay ? passingURLs : failingURLs;
 
+        // Finally convert the raw font URLs to the absolute URLs and add them to the set.
         const relativeURLs = rawFontURLs
           // @ts-ignore - guaranteed to match from previous regex, pull URL group out
           .map(s => s.match(CSS_URL_REGEX)[1].trim())
@@ -82,13 +86,14 @@ class FontDisplay extends Audit {
 
             return s;
           });
-        // Convert the relative CSS URL to an absolute URL and add it to the passing set
+
+        // Convert the relative CSS URL to an absolute URL and add it to the target set.
         for (const relativeURL of relativeURLs) {
           try {
             const relativeRoot = URL.isValid(stylesheet.header.sourceURL) ?
               stylesheet.header.sourceURL : artifacts.URL.finalUrl;
             const absoluteURL = new URL(relativeURL, relativeRoot);
-            passingURLs.add(absoluteURL.href);
+            targetURLSet.add(absoluteURL.href);
           } catch (err) {
             Sentry.captureException(err, {tags: {audit: this.meta.id}});
           }
@@ -96,7 +101,7 @@ class FontDisplay extends Audit {
       }
     }
 
-    return passingURLs;
+    return {passingURLs, failingURLs};
   }
 
   /**
@@ -107,16 +112,24 @@ class FontDisplay extends Audit {
   static async audit(artifacts, context) {
     const devtoolsLogs = artifacts.devtoolsLogs[this.DEFAULT_PASS];
     const networkRecords = await NetworkRecords.request(devtoolsLogs, context);
-    const passingFontURLs = FontDisplay.findPassingFontDisplayDeclarations(artifacts);
+    const {passingURLs, failingURLs} = FontDisplay.findFontDisplayDeclarations(artifacts);
+    /** @type {Array<string>} */
+    const warningURLs = [];
 
     const results = networkRecords
       // Find all fonts...
       .filter(record => record.resourceType === 'Font')
-      // ...that don't have a passing font-display value
-      .filter(record => !passingFontURLs.has(record.url))
       // ...and that aren't data URLs, the blocking concern doesn't really apply
       .filter(record => !/^data:/.test(record.url))
       .filter(record => !/^blob:/.test(record.url))
+      // ...that have a failing font-display value
+      .filter(record => {
+        // Failing URLs should be considered.
+        if (failingURLs.has(record.url)) return true;
+        // Everything else shouldn't be, but we should warn if we don't recognize the URL at all.
+        if (!passingURLs.has(record.url)) warningURLs.push(record.url);
+        return false;
+      })
       .map(record => {
         // In reality the end time should be calculated with paint time included
         // all browsers wait 3000ms to block text so we make sure 3000 is our max wasted time
@@ -139,6 +152,7 @@ class FontDisplay extends Audit {
     return {
       score: Number(results.length === 0),
       details,
+      warnings: warningURLs.map(fontURL => str_(UIStrings.undeclaredFontURLWarning, {fontURL})),
     };
   }
 }
diff --git a/lighthouse-core/gather/gather-runner.js b/lighthouse-core/gather/gather-runner.js
index deb8d4611..36216a074 100644
--- a/lighthouse-core/gather/gather-runner.js
+++ b/lighthouse-core/gather/gather-runner.js
@@ -622,6 +622,18 @@ class GatherRunner {
     return !settings.disableStorageReset && passConfig.recordTrace && passConfig.useThrottling;
   }
 
+  /**
+   * Save the devtoolsLog and trace (if applicable) to baseArtifacts.
+   * @param {LH.Gatherer.PassContext} passContext
+   * @param {LH.Gatherer.LoadData} loadData
+   * @param {string} passName
+   */
+  static _addLoadDataToBaseArtifacts(passContext, loadData, passName) {
+    const baseArtifacts = passContext.baseArtifacts;
+    baseArtifacts.devtoolsLogs[passName] = loadData.devtoolsLog;
+    if (loadData.trace) baseArtifacts.traces[passName] = loadData.trace;
+  }
+
   /**
    * Starting from about:blank, load the page and run gatherers for this pass.
    * @param {LH.Gatherer.PassContext} passContext
@@ -648,20 +660,20 @@ class GatherRunner {
     // Disable throttling so the afterPass analysis isn't throttled
     await driver.setThrottling(passContext.settings, {useThrottling: false});
 
-    // Save devtoolsLog and trace.
-    const baseArtifacts = passContext.baseArtifacts;
-    baseArtifacts.devtoolsLogs[passConfig.passName] = loadData.devtoolsLog;
-    if (loadData.trace) baseArtifacts.traces[passConfig.passName] = loadData.trace;
-
     // If there were any load errors, treat all gatherers as if they errored.
     const pageLoadError = GatherRunner.getPageLoadError(passContext, loadData, possibleNavError);
     if (pageLoadError) {
       log.error('GatherRunner', pageLoadError.friendlyMessage, passContext.url);
       passContext.LighthouseRunWarnings.push(pageLoadError.friendlyMessage);
+      GatherRunner._addLoadDataToBaseArtifacts(passContext, loadData,
+          `pageLoadError-${passConfig.passName}`);
       return GatherRunner.generatePageLoadErrorArtifacts(passContext, pageLoadError);
     }
 
-    // If no error, run `afterPass()` on gatherers and return collected artifacts.
+    // If no error, save devtoolsLog and trace.
+    GatherRunner._addLoadDataToBaseArtifacts(passContext, loadData, passConfig.passName);
+
+    // Run `afterPass()` on gatherers and return collected artifacts.
     await GatherRunner.afterPass(passContext, loadData, gathererResults);
     return GatherRunner.collectArtifacts(gathererResults);
   }
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index 224f11629..91c6886af 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -651,6 +651,10 @@
     "message": "All text remains visible during webfont loads",
     "description": "Title of a diagnostic audit that provides detail on if all the text on a webpage was visible while the page was loading its webfonts. This descriptive title is shown to users when the amount is acceptable and no user action is required."
   },
+  "lighthouse-core/audits/font-display.js | undeclaredFontURLWarning": {
+    "message": "Lighthouse was unable to automatically check the font-display value for the following URL: {fontURL}.",
+    "description": "A warning message that is shown when Lighthouse couldn't automatically check some of the page's fonts and that the user will need to manually check it."
+  },
   "lighthouse-core/audits/load-fast-enough-for-pwa.js | description": {
     "message": "A fast page load over a cellular network ensures a good mobile user experience. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/fast-3g).",
     "description": "Description of a Lighthouse audit that tells the user *why* they need to load fast enough on mobile networks. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation."
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 58083b5ee..1193bb3a8 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -123,14 +123,14 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
     }
 
     // Metrics.
-    const metricAudits = category.auditRefs.filter(audit => audit.group === 'metrics');
     const metricAuditsEl = this.renderAuditGroup(groups.metrics);
 
     // Metric descriptions toggle.
     const toggleTmpl = this.dom.cloneTemplate('#tmpl-lh-metrics-toggle', this.templateContext);
     const toggleEl = this.dom.find('.lh-metrics-toggle', toggleTmpl);
-    metricAuditsEl.prepend(...toggleEl.childNodes);
+    metricAuditsEl.append(...toggleEl.childNodes);
 
+    const metricAudits = category.auditRefs.filter(audit => audit.group === 'metrics');
     const keyMetrics = metricAudits.filter(a => a.weight >= 3);
     const otherMetrics = metricAudits.filter(a => a.weight < 3);
 
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index d1752a9dc..4377ad093 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -15,52 +15,68 @@
  * limitations under the License.
  */
 
+/*
+  Naming convention:
+
+  If a variable is used for a specific component: --{component}-{property name}-{modifier}
+
+  Both {component} and {property name} should be kebab-case. If the target is the entire page,
+  use 'report' for the component. The property name should be not be abbreviated. Use the
+  property name the variable is intended for - if it's used for multiple, a common descriptor
+  is fine (ex: 'size' for a variable applied to 'width' and 'height'). If a variable is shared
+  across multiple components, either create more variables or just drop the "{component}-"
+  part of the name. Append any modifiers at the end (ex: 'big', 'dark').
+
+  For colors: --color-{hue}-{intensity}
+
+  {intensity} is the Material Design tag - 700, A700, etc. Omit "-{intensity}" if there is only one
+  color of that hue.
+*/
 .lh-vars {
   --text-font-family: Roboto, Helvetica, Arial, sans-serif;
   --monospace-font-family: 'Roboto Mono', 'Menlo', 'dejavu sans mono', 'Consolas', 'Lucida Console', monospace;
-  --body-background-color: #fff;
-  --body-text-color: var(--color-black-900);
-  --body-font-size: 16px;
-  --body-line-height: 24px;
-  --subheader-color: hsl(206, 6%, 25%);
-  --header-font-size: 20px;
+  --report-background-color: #fff;
+  --report-text-color: var(--color-gray-900);
+  --report-font-size: 16px;
+  --report-line-height: 24px;
+  --snippet-color: var(--color-gray-800);
+  --category-header-font-size: 20px;
   --header-line-height: 24px;
-  --caption-line-height: 16px;
+  --audit-explanation-line-height: 16px;
   --default-padding: 12px;
-  --section-padding: 16px;
-  --section-indent: 16px;
-  --audit-item-gap: 5px;
-  --text-indent: 8px;
-  --secondary-text-color: var(--color-black-800);
+  --footer-padding-vertical: 16px;
+  --audit-margin-horizontal: 5px;
+  --stackpack-padding-horizontal: 10px;
+  --secondary-text-color: var(--color-gray-800);
   --informative-color: var(--color-blue-900);
-  --medium-75-gray: #757575;
-  --medium-50-gray: hsl(210, 17%, 98%);
+  --snippet-background-color: var(--color-gray-50);
   --report-secondary-border-color: #ebebeb;
-  --display-value-gray: hsl(216, 5%, 39%);
-  --report-width: calc(60 * var(--body-font-size));
+  --chevron-line-stroke: var(--color-gray-600);
+  --report-width: calc(60 * var(--report-font-size));
   --report-min-width: 400px;
-  --lh-score-icon-background-size: 24px;
-  --lh-tools-icon-size: var(--lh-score-icon-background-size);
-  --lh-tools-icon-color: var(--medium-75-gray);
-  --lh-table-higlight-bg: hsla(0, 0%, 75%, 0.1);
-  --lh-sparkline-height: 5px;
-  --lh-audit-vpadding: 8px;
-  --lh-audit-group-vpadding: 8px;
-  --lh-section-vpadding: 12px;
+  --score-icon-background-size: 24px;
+  --tools-icon-size: var(--score-icon-background-size);
+  --tools-icon-color: var(--color-gray-600);
+  --table-higlight-background-color: hsla(0, 0%, 75%, 0.1);
+  --sparkline-height: 5px;
+  --audit-padding-vertical: 8px;
+  --audit-group-padding-vertical: 8px;
+  --section-padding-vertical: 12px;
   --chevron-size: 12px;
-  --inner-audit-left-padding: calc(var(--score-shape-size) + var(--score-shape-margin-left) + var(--score-shape-margin-right));
+  --audit-description-padding-left: calc(var(--score-icon-size) + var(--score-icon-margin-left) + var(--score-icon-margin-right));
 
   /* Palette using Material Design Colors
    * https://www.materialui.co/colors */
-  --color-black-100: #F5F5F5;
-  --color-black-200: #E0E0E0;
-  --color-black-400: #BDBDBD;
-  --color-black-500: #9E9E9E;
-  --color-black-600: #757575;
-  --color-black-800: #424242;
-  --color-black-900: #212121;
-  --color-black: #000000;
-  --color-blue: #2962FF;
+  --color-gray-50: #FAFAFA;
+  --color-gray-100: #F5F5F5;
+  --color-gray-200: #E0E0E0;
+  --color-gray-400: #BDBDBD;
+  --color-gray-500: #9E9E9E;
+  --color-gray-600: #757575;
+  --color-gray-800: #424242;
+  --color-gray-900: #212121;
+  --color-gray: #000000;
+  --color-blue-A700: #2962FF;
   --color-green-700: #018642;
   --color-green: #0CCE6B;
   --color-orange-700: #D04900;
@@ -73,43 +89,43 @@
   --color-cyan-500: #00BCD4;
   --color-teal-600: #00897B;
 
-
-  /* TODO(cjamcl) clean up unused variables. */
-  --audits-margin-bottom: 40px;
-  --env-item-bg: var(--color-black-100);
+  --audit-group-margin-bottom: 40px;
+  --env-item-background-color: var(--color-gray-100);
   --env-name-min-width: 220px;
-  --env-tem-padding: 10px 0px;
-  --expandable-padding: 0 0 2px calc(var(--score-shape-margin-left) + var(--score-shape-size) + var(--score-shape-margin-right));
+  --env-item-padding: 10px 0px;
+  --env-item-font-size: 28px;
+  --env-item-line-height: 36px;
+  --metric-description-padding: 0 0 2px calc(var(--score-icon-margin-left) + var(--score-icon-size) + var(--score-icon-margin-right));
   --gauge-circle-size-big: 112px;
   --gauge-circle-size: 80px;
-  --header-padding: 20px 0 20px 0;
-  --icon-square-size: calc(var(--score-shape-size) * 0.88);
-  --plugin-badge-bg: var(--color-white);
+  --scores-container-padding: 20px 0 20px 0;
+  --icon-square-size: calc(var(--score-icon-size) * 0.88);
+  --plugin-badge-background-color: var(--color-white);
   --plugin-badge-size-big: calc(var(--gauge-circle-size-big) / 2.7);
   --plugin-badge-size: calc(var(--gauge-circle-size) / 2.7);
   --plugin-icon-size: 65%;
   --pwa-icon-margin: 0 6px 0 -2px;
-  --pwa-icon-size: var(--topbar-icon-size);
+  --pwa-icon-size: var(--topbar-logo-size);
   --score-container-padding: 8px;
-  --score-container-width: 148px;
-  --score-number-font-size-big: 38px;
-  --score-number-font-size: 28px;
-  --score-shape-margin-left: 4px;
-  --score-shape-margin-right: 12px;
-  --score-shape-margin: 0 var(--score-shape-margin-right) 0 var(--score-shape-margin-left);
-  --score-shape-size: 12px;
-  --score-title-font-size-big: 28px;
-  --score-title-font-size: 20px;
-  --score-title-line-height-big: 36px;
-  --score-title-line-height: 26px;
+  --gauge-wrapper-width: 148px;
+  --gauge-percentage-font-size-big: 38px;
+  --gauge-percentage-font-size: 28px;
+  --score-icon-margin-left: 4px;
+  --score-icon-margin-right: 12px;
+  --score-icon-margin: 0 var(--score-icon-margin-right) 0 var(--score-icon-margin-left);
+  --score-icon-size: 12px;
+  --gauge-label-font-size-big: 28px;
+  --gauge-label-font-size: 20px;
+  --gauge-label-line-height-big: 36px;
+  --gauge-label-line-height: 26px;
   --scorescale-height: 6px;
   --scorescale-width: 18px;
-  --section-padding: 40px;
-  --topbar-bg: var(--color-black-100);
+  --category-padding: 40px;
+  --topbar-background-color: var(--color-gray-100);
   --topbar-height: 32px;
-  --topbar-icon-size: 24px;
+  --topbar-logo-size: 24px;
   --topbar-padding: 0 8px;
-  --metrics-toggle-color: var(--color-black-200);
+  --metrics-toggle-background-color: var(--color-gray-200);
 
   --color-average-secondary: var(--color-orange-700);
   --color-average: var(--color-orange);
@@ -117,11 +133,11 @@
   --color-fail: var(--color-red);
   --color-pass-secondary: var(--color-green-700);
   --color-pass: var(--color-green);
-  --color-sticky-header-bg: var(--body-background-color);
-  --color-highlighter-bg: var(--body-text-color);
+  --sticky-header-background-color: var(--report-background-color);
+  --highlighter-background-color: var(--report-text-color);
 
   --color-hover: #FAFAFA;
-  --color-metric-toggle-lines: #7F7F7F;
+  --metric-toggle-lines-fill: #7F7F7F;
 
   --plugin-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="%23757575"><path d="M0 0h24v24H0z" fill="none"/><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>');
   --plugin-icon-url-dark: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="%23FFFFFF"><path d="M0 0h24v24H0z" fill="none"/><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>');
@@ -149,29 +165,28 @@
     --color-green-700: var(--color-green);
     --color-teal-600: var(--color-cyan-500);
     --color-orange-700: var(--color-orange);
-  
-    --color-black-200: var(--color-black-800);
-    --color-black-400: var(--color-black-600);
-    --color-black-600: var(--color-black-500);
-  
-    --topbar-bg: var(--color-black);
-    --plugin-badge-bg: var(--color-black-800);
-    --env-item-bg: var(--color-black);
-    --report-secondary-border-color: var(--color-black-200);
-  
-    --body-background-color: var(--color-black-900);
-    --body-text-color: var(--color-black-100);
-    --secondary-text-color: var(--color-black-400);
-  
+
+    --color-gray-200: var(--color-gray-800);
+    --color-gray-400: var(--color-gray-600);
+    --color-gray-600: var(--color-gray-500);
+
+    --topbar-background-color: var(--color-gray);
+    --plugin-badge-background-color: var(--color-gray-800);
+    --env-item-background-color: var(--color-gray);
+    --report-secondary-border-color: var(--color-gray-200);
+
+    --report-background-color: var(--color-gray-900);
+    --report-text-color: var(--color-gray-100);
+    --secondary-text-color: var(--color-gray-400);
+
     --plugin-icon-url: var(--plugin-icon-url-dark);
-  
+
     --informative-color: var(--color-blue-200);
-  
-    --medium-50-gray: #757575;
-    --medium-75-gray: var(--color-white);
-  
+
+    --color-gray-50: #757575;
+
     --color-hover: rgba(0, 0, 0, 0.2);
-  
+
     --pwa-fast-reliable-gray-url: var(--pwa-fast-reliable-gray-url-dark);
     --pwa-installable-gray-url: var(--pwa-installable-gray-url-dark);
     --pwa-optimized-gray-url: var(--pwa-optimized-gray-url-dark);
@@ -180,27 +195,27 @@
 
 @media only screen and (max-width: 480px) {
   .lh-vars {
-    --audits-margin-bottom: 20px;
-    --body-font-size: 14px;
-    --body-line-height: 20px;
+    --audit-group-margin-bottom: 20px;
+    --report-font-size: 14px;
+    --report-line-height: 20px;
     --env-name-min-width: 120px;
     --gauge-circle-size-big: 96px;
     --gauge-circle-size: 72px;
     --header-padding: 16px 0 16px 0;
     --plugin-icon-size: 75%;
     --pwa-icon-margin: 0 7px 0 -3px;
-    --score-container-width: 112px;
-    --score-number-font-size-big: 34px;
-    --score-number-font-size: 26px;
-    --score-shape-margin-left: 2px;
-    --score-shape-size: 10px;
-    --score-title-font-size-big: 22px;
-    --score-title-font-size: 14px;
-    --score-title-line-height-big: 26px;
-    --score-title-line-height: 20px;
-    --section-padding: 24px;
+    --gauge-wrapper-width: 112px;
+    --gauge-percentage-font-size-big: 34px;
+    --gauge-percentage-font-size: 26px;
+    --score-icon-margin-left: 2px;
+    --score-icon-size: 10px;
+    --gauge-label-font-size-big: 22px;
+    --gauge-label-font-size: 14px;
+    --gauge-label-line-height-big: 26px;
+    --gauge-label-line-height: 20px;
+    --category-padding: 24px;
     --topbar-height: 28px;
-    --topbar-icon-size: 20px;
+    --topbar-logo-size: 20px;
   }
 
   /* Not enough space to adequately show the relative savings bars. */
@@ -212,36 +227,36 @@
 .lh-vars.lh-devtools {
   --text-font-family: '.SFNSDisplay-Regular', 'Helvetica Neue', 'Lucida Grande', sans-serif;
   --monospace-font-family: 'Menlo', 'dejavu sans mono', 'Consolas', 'Lucida Console', monospace;
-  --body-font-size: 12px;
-  --body-line-height: 20px;
-  --header-font-size: 16px;
+  --report-font-size: 12px;
+  --report-line-height: 20px;
+  --category-header-font-size: 16px;
   --header-line-height: 20px;
-  --caption-line-height: 14px;
+  --audit-explanation-line-height: 14px;
   --default-padding: 12px;
-  --section-padding: 12px;
-  --section-indent: 8px;
+  --category-padding: 12px;
+  --footer-padding-vertical: 8px;
 
   --gauge-circle-size-big: 72px;
   --gauge-circle-size: 64px;
 
-  --audits-margin-bottom: 20px;
+  --audit-group-margin-bottom: 20px;
   --env-name-min-width: 120px;
   --header-padding: 16px 0 16px 0;
   --plugin-icon-size: 75%;
   --pwa-icon-margin: 0 7px 0 -3px;
-  --score-container-width: 97px;
-  --score-number-font-size-big: 34px;
-  --score-number-font-size: 26px;
-  --score-shape-margin-left: 2px;
-  --score-shape-size: 10px;
-  --score-title-font-size-big: 22px;
-  --score-title-font-size: 14px;
-  --score-title-line-height-big: 26px;
-  --score-title-line-height: 20px;
+  --gauge-wrapper-width: 97px;
+  --gauge-percentage-font-size-big: 34px;
+  --gauge-percentage-font-size: 26px;
+  --score-icon-margin-left: 2px;
+  --score-icon-size: 10px;
+  --gauge-label-font-size-big: 22px;
+  --gauge-label-font-size: 14px;
+  --gauge-label-line-height-big: 26px;
+  --gauge-label-line-height: 20px;
 
-  --lh-audit-vpadding: 4px;
-  --lh-audit-group-vpadding: 12px;
-  --lh-section-vpadding: 8px;
+  --audit-padding-vertical: 4px;
+  --audit-group-padding-vertical: 12px;
+  --section-padding-vertical: 8px;
 }
 
 .lh-devtools.lh-root {
@@ -268,12 +283,12 @@
 
 .lh-root {
   font-family: var(--text-font-family);
-  font-size: var(--body-font-size);
+  font-size: var(--report-font-size);
   margin: 0;
-  line-height: var(--body-line-height);
-  background: var(--body-background-color);
+  line-height: var(--report-line-height);
+  background: var(--report-background-color);
   scroll-behavior: smooth;
-  color: var(--body-text-color);
+  color: var(--report-text-color);
 }
 
 .lh-root :focus {
@@ -321,18 +336,18 @@
 }
 
 .lh-audit__description, .lh-audit__stackpack {
-  --inner-audit-right-padding: calc(var(--text-indent) + 2px);
-  padding-left: var(--inner-audit-left-padding);
-  padding-right: var(--inner-audit-right-padding);
+  --inner-audit-padding-right: var(--stackpack-padding-horizontal);
+  padding-left: var(--audit-description-padding-left);
+  padding-right: var(--inner-audit-padding-right);
   padding-top: 8px;
   padding-bottom: 8px;
 }
 
 .lh-details {
-  font-size: var(--body-font-size);
+  font-size: var(--report-font-size);
   margin-top: var(--default-padding);
   margin-bottom: var(--default-padding);
-  margin-left: var(--inner-audit-left-padding);
+  margin-left: var(--audit-description-padding-left);
   /* whatever the .lh-details side margins are */
   width: 100%;
 }
@@ -391,9 +406,9 @@
 /* Score */
 
 .lh-audit__score-icon {
-  width: var(--score-shape-size);
-  height: var(--score-shape-size);
-  margin: var(--score-shape-margin);
+  width: var(--score-icon-size);
+  height: var(--score-icon-size);
+  margin: var(--score-icon-margin);
 }
 
 .lh-audit--pass .lh-audit__display-text {
@@ -417,19 +432,19 @@
   color: var(--color-fail-secondary);
 }
 .lh-audit--fail .lh-audit__score-icon {
-  border-left: calc(var(--score-shape-size) / 2) solid transparent;
-  border-right: calc(var(--score-shape-size) / 2) solid transparent;
-  border-bottom: var(--score-shape-size) solid var(--color-fail);
+  border-left: calc(var(--score-icon-size) / 2) solid transparent;
+  border-right: calc(var(--score-icon-size) / 2) solid transparent;
+  border-bottom: var(--score-icon-size) solid var(--color-fail);
 }
 
 .lh-audit--manual .lh-audit__display-text,
 .lh-audit--notapplicable .lh-audit__display-text {
-  color: var(--color-black-600);
+  color: var(--color-gray-600);
 }
 .lh-audit--manual .lh-audit__score-icon,
 .lh-audit--notapplicable .lh-audit__score-icon {
   border-radius: 100%;
-  background: var(--color-black-400);
+  background: var(--color-gray-400);
 }
 
 .lh-audit--error .lh-audit__score-icon {
@@ -437,13 +452,13 @@
 }
 
 .lh-audit--informative .lh-audit__display-text {
-  color: var(--color-black-600);
+  color: var(--color-gray-600);
 }
 
 .lh-audit--informative .lh-audit__score-icon {
   border: none;
   border-radius: 100%;
-  background: var(--color-black-400);
+  background: var(--color-gray-400);
 }
 
 .lh-audit__description,
@@ -451,7 +466,7 @@
   color: var(--secondary-text-color);
 }
 .lh-category-header__description  {
-  font-size: var(--body-font-size);
+  font-size: var(--report-font-size);
   text-align: center;
   margin: 0px auto;
   max-width: 400px;
@@ -461,7 +476,7 @@
 .lh-audit__display-text,
 .lh-load-opportunity__sparkline,
 .lh-chevron-container {
-  margin: 0 var(--audit-item-gap);
+  margin: 0 var(--audit-margin-horizontal);
 }
 .lh-chevron-container {
   margin-right: 0;
@@ -474,7 +489,7 @@
 /* Prepend display text with em dash separator. But not in Opportunities. */
 .lh-audit__display-text:not(:empty):before {
   content: '—';
-  margin-right: var(--audit-item-gap);
+  margin-right: var(--audit-margin-horizontal);
 }
 .lh-audit-group.lh-audit-group--load-opportunities .lh-audit__display-text:not(:empty):before {
   display: none;
@@ -485,7 +500,7 @@
   display: flex;
   align-items: center;
   font-weight: 500;
-  padding: var(--lh-audit-vpadding) 0;
+  padding: var(--audit-padding-vertical) 0;
 }
 
 .lh-audit--load-opportunity .lh-audit__header {
@@ -575,14 +590,14 @@
 }
 
 .lh-metrics__disclaimer {
-  color: var(--medium-75-gray);
-  margin: var(--lh-section-vpadding) 0;
+  color: var(--color-gray-600);
+  margin: var(--section-padding-vertical) 0;
 }
 
 .lh-metric__description {
   display: none;
   color: var(--secondary-text-color);
-  padding: var(--expandable-padding);
+  padding: var(--metric-description-padding);
 }
 
 .lh-metric__value {
@@ -628,15 +643,15 @@
 }
 .lh-metrics-toggle__input:not(:checked) + label .lh-metrics-toggle__icon--less,
 .lh-metrics-toggle__input:checked + label .lh-metrics-toggle__icon--more {
-  background-color: var(--color-blue);
-  --color-metric-toggle-lines: var(--color-white);
+  background-color: var(--color-blue-A700);
+  --metric-toggle-lines-fill: var(--color-white);
 }
 .lh-metrics-toggle__lines {
-  fill: var(--color-metric-toggle-lines);
+  fill: var(--metric-toggle-lines-fill);
 }
 
 .lh-metrics-toggle__label  {
-  background-color: var(--metrics-toggle-color);
+  background-color: var(--metrics-toggle-background-color);
 }
 
 .lh-metrics-toggle__label .lh-metrics-toggle__icon--less {
@@ -656,10 +671,10 @@
 
 .lh-metric .lh-metric__innerwrap::before {
   content: '';
-  width: var(--score-shape-size);
-  height: var(--score-shape-size);
+  width: var(--score-icon-size);
+  height: var(--score-icon-size);
   display: inline-block;
-  margin: var(--score-shape-margin);
+  margin: var(--score-icon-margin);
 }
 
 .lh-metric--pass .lh-metric__value {
@@ -683,9 +698,9 @@
   color: var(--color-fail-secondary);
 }
 .lh-metric--fail .lh-metric__innerwrap::before {
-  border-left: calc(var(--score-shape-size) / 2) solid transparent;
-  border-right: calc(var(--score-shape-size) / 2) solid transparent;
-  border-bottom: var(--score-shape-size) solid var(--color-fail);
+  border-left: calc(var(--score-icon-size) / 2) solid transparent;
+  border-right: calc(var(--score-icon-size) / 2) solid transparent;
+  border-bottom: var(--score-icon-size) solid var(--color-fail);
 }
 
 .lh-metric--error .lh-metric__value,
@@ -706,9 +721,9 @@
 }
 
 .lh-load-opportunity__header .lh-load-opportunity__col {
-  color: var(--medium-75-gray);
+  color: var(--color-gray-600);
   display: unset;
-  line-height: calc(2.3 * var(--body-font-size));
+  line-height: calc(2.3 * var(--report-font-size));
 }
 
 .lh-load-opportunity__col {
@@ -727,7 +742,7 @@
 
 .lh-audit--load-opportunity .lh-audit__display-text {
   text-align: right;
-  flex: 0 0 calc(3 * var(--body-font-size));
+  flex: 0 0 calc(3 * var(--report-font-size));
 }
 
 
@@ -735,11 +750,11 @@
 
 .lh-load-opportunity__sparkline {
   flex: 1;
-  margin-top: calc((var(--body-line-height) - var(--lh-sparkline-height)) / 2);
+  margin-top: calc((var(--report-line-height) - var(--sparkline-height)) / 2);
 }
 
 .lh-sparkline {
-  height: var(--lh-sparkline-height);
+  height: var(--sparkline-height);
   width: 100%;
 }
 
@@ -823,7 +838,7 @@
 /* Audit Group */
 
 .lh-audit-group {
-  margin-bottom: var(--audits-margin-bottom);
+  margin-bottom: var(--audit-group-margin-bottom);
   position: relative;
 }
 
@@ -882,22 +897,21 @@
 .lh-audit-group__summary {
   display: flex;
   justify-content: space-between;
-  padding-right: var(--text-indent);
-  margin-top: calc(var(--section-padding) * 1.5);
-  margin-bottom: var(--section-padding);
+  margin-top: calc(var(--category-padding) * 1.5);
+  margin-bottom: var(--category-padding);
 }
 
 .lh-audit-group__itemcount {
-  color: var(--color-black-600);
+  color: var(--color-gray-600);
   font-weight: bold;
 }
 .lh-audit-group__header .lh-chevron {
-  margin-top: calc((var(--body-line-height) - 5px) / 2);
+  margin-top: calc((var(--report-line-height) - 5px) / 2);
 }
 
 .lh-audit-group__header {
-  font-size: var(--body-font-size);
-  margin: 0 0 var(--lh-audit-group-vpadding);
+  font-size: var(--report-font-size);
+  margin: 0 0 var(--audit-group-padding-vertical);
   /* When the header takes 100% width, the chevron becomes small. */
   max-width: calc(100% - var(--chevron-size));
 }
@@ -912,16 +926,16 @@
 
 .lh-audit-group__header span.lh-audit-group__itemcount {
   font-weight: bold;
-  color: var(--color-black-600);
+  color: var(--color-gray-600);
 }
 
 .lh-audit-group__header span.lh-audit-group__description {
   font-weight: 500;
-  color: var(--color-black-600);
+  color: var(--color-gray-600);
 }
 .lh-audit-group__header span.lh-audit-group__description::before {
   content: '—';
-  margin: 0px var(--audit-item-gap);
+  margin: 0px var(--audit-margin-horizontal);
 }
 
 .lh-clump > .lh-audit-group__header,
@@ -931,12 +945,12 @@
 .lh-audit-group--pwa-fast-reliable .lh-audit-group__header,
 .lh-audit-group--pwa-installable .lh-audit-group__header,
 .lh-audit-group--pwa-optimized .lh-audit-group__header {
-  margin-top: var(--lh-audit-group-vpadding);
+  margin-top: var(--audit-group-padding-vertical);
 }
 
 .lh-audit-explanation {
-  margin: var(--lh-audit-vpadding) 0 calc(var(--lh-audit-vpadding) / 2);
-  line-height: var(--caption-line-height);
+  margin: var(--audit-padding-vertical) 0 calc(var(--audit-padding-vertical) / 2);
+  line-height: var(--audit-explanation-line-height);
 }
 
 .lh-audit--fail .lh-audit-explanation {
@@ -970,10 +984,10 @@
 }
 
 .lh-warnings {
-  --item-margin: calc(var(--body-line-height) / 6);
+  --item-margin: calc(var(--report-line-height) / 6);
   color: var(--color-average);
-  margin: var(--lh-audit-vpadding) 0;
-  padding: calc(var(--lh-audit-vpadding) / 2) var(--lh-audit-vpadding);
+  margin: var(--audit-padding-vertical) 0;
+  padding: calc(var(--audit-padding-vertical) / 2) var(--audit-padding-vertical);
 }
 .lh-warnings span {
   font-weight: bold;
@@ -982,11 +996,11 @@
 .lh-warnings--toplevel {
   --item-margin: calc(var(--header-line-height) / 4);
   color: var(--secondary-text-color);
-  margin: var(--section-padding);
-  padding: var(--section-padding);
+  margin: var(--category-padding);
+  padding: var(--category-padding);
 }
 .lh-warnings ul {
-  padding-left: calc(var(--section-padding) * 2);
+  padding-left: calc(var(--category-padding) * 2);
   margin: 0;
 }
 .lh-warnings li {
@@ -1062,7 +1076,7 @@
 .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before {
   width: var(--plugin-badge-size);
   height: var(--plugin-badge-size);
-  background-color: var(--plugin-badge-bg);
+  background-color: var(--plugin-badge-background-color);
   background-image: var(--plugin-icon-url);
   background-repeat: no-repeat;
   background-size: var(--plugin-icon-size);
@@ -1098,7 +1112,7 @@
 
 .lh-category .lh-gauge__percentage {
   --gauge-circle-size: var(--gauge-circle-size-big);
-  --score-number-font-size: var(--score-number-font-size-big);
+  --gauge-percentage-font-size: var(--gauge-percentage-font-size-big);
 }
 
 .lh-gauge__wrapper {
@@ -1118,17 +1132,17 @@
 }
 
 .lh-gauge__label {
-  font-size: var(--score-title-font-size);
-  line-height: var(--score-title-line-height);
+  font-size: var(--gauge-label-font-size);
+  line-height: var(--gauge-label-line-height);
   margin-top: 10px;
   text-align: center;
-  color: var(--body-text-color);
+  color: var(--report-text-color);
 }
 
 /* TODO(#8185) use more BEM (.lh-gauge__label--big) instead of relying on descendant selector */
 .lh-category .lh-gauge__label {
-  --score-title-font-size: var(--score-title-font-size-big);
-  --score-title-line-height: var(--score-title-line-height-big);
+  --gauge-label-font-size: var(--gauge-label-font-size-big);
+  --gauge-label-line-height: var(--gauge-label-line-height-big);
   margin-top: 14px;
 }
 
@@ -1137,7 +1151,7 @@
 .lh-scores-header .lh-gauge--pwa__wrapper,
 .lh-sticky-header .lh-gauge__wrapper,
 .lh-sticky-header .lh-gauge--pwa__wrapper {
-  width: var(--score-container-width);
+  width: var(--gauge-wrapper-width);
 }
 
 .lh-scores-header .lh-gauge--pwa__wrapper {
@@ -1148,7 +1162,7 @@
 .lh-scorescale {
   display: inline-flex;
   margin: 12px auto 0 auto;
-  border: 1px solid var(--color-black-200);
+  border: 1px solid var(--color-gray-200);
   border-radius: 20px;
   padding: 8px 8px;
 }
@@ -1194,17 +1208,17 @@
 }
 
 .lh-category {
-  padding: var(--section-padding);
+  padding: var(--category-padding);
   max-width: var(--report-width);
   margin: 0 auto;
 }
 
 .lh-category-wrapper {
-  border-bottom: 1px solid var(--color-black-200);
+  border-bottom: 1px solid var(--color-gray-200);
 }
 
 .lh-category-wrapper:first-of-type {
-  border-top: 1px solid var(--color-black-200);
+  border-top: 1px solid var(--color-gray-200);
 }
 
 /* section hash link jump should preserve fixed header
@@ -1220,9 +1234,9 @@
 }
 
 .lh-category-header {
-  font-size: var(--header-font-size);
+  font-size: var(--category-header-font-size);
   min-height: var(--gauge-circle-size);
-  margin-bottom: var(--lh-section-vpadding);
+  margin-bottom: var(--section-padding-vertical);
 }
 
 .lh-category-header .lh-score__gauge {
@@ -1232,7 +1246,7 @@
 }
 
 .lh-category-header .lh-audit__title {
-  font-size: var(--header-font-size);
+  font-size: var(--category-header-font-size);
   line-height: var(--header-line-height);
 }
 
@@ -1289,18 +1303,18 @@
   --image-preview-size: 24px;
   border-collapse: collapse;
   /* Can't assign padding to table, so shorten the width instead. */
-  width: calc(100% - var(--inner-audit-left-padding));
+  width: calc(100% - var(--audit-description-padding-left));
 }
 
 .lh-table thead th {
   font-weight: normal;
-  color: var(--color-black-600);
+  color: var(--color-gray-600);
   /* See text-wrapping comment on .lh-container. */
   word-break: normal;
 }
 
 .lh-table tbody tr:nth-child(odd) {
-  background-color: var(--lh-table-higlight-bg);
+  background-color: var(--table-higlight-background-color);
 }
 
 .lh-table th,
@@ -1361,7 +1375,7 @@
 }
 
 .lh-text__url-host {
-  margin-left: calc(var(--body-font-size) / 2);
+  margin-left: calc(var(--report-font-size) / 2);
   opacity: 0.6;
   font-size: 90%
 }
@@ -1381,15 +1395,15 @@
   --chevron-angle-right: -42deg;
   width: var(--chevron-size);
   height: var(--chevron-size);
-  margin-top: calc((var(--body-line-height) - 12px) / 2);
+  margin-top: calc((var(--report-line-height) - 12px) / 2);
 }
 
 .lh-chevron__lines {
   transition: transform 0.4s;
-  transform: translateY(var(--body-line-height));
+  transform: translateY(var(--report-line-height));
 }
 .lh-chevron__line {
- stroke: var(--display-value-gray);
+ stroke: var(--chevron-line-stroke);
  stroke-width: var(--chevron-size);
  stroke-linecap: square;
  transform-origin: 50%;
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 01498480b..b4097829d 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -71,7 +71,7 @@ limitations under the License.
 <!-- Lighthouse metrics toggle -->
 <template id="tmpl-lh-metrics-toggle">
   <div class="lh-metrics-toggle">
-    <input class="lh-metrics-toggle__input" type="checkbox" name="toggle-metric-descriptions">
+    <input class="lh-metrics-toggle__input" type="checkbox" id="toggle-metric-descriptions" aria-label="Toggle the display of metric descriptions">
     <label class="lh-metrics-toggle__label" for="toggle-metric-descriptions">
       <div class="lh-metrics-toggle__icon lh-metrics-toggle__icon--less" aria-hidden="true">
         <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
@@ -159,7 +159,7 @@ limitations under the License.
     .lh-scores-container {
       display: flex;
       flex-direction: column;
-      padding: var(--header-padding);
+      padding: var(--scores-container-padding);
       position: relative;
       width: 100%;
     }
@@ -168,8 +168,8 @@ limitations under the License.
       --gauge-circle-size: 36px;
       --plugin-badge-size: 18px;
       --plugin-icon-size: 75%;
-      --score-container-width: 60px;
-      --score-number-font-size: 13px;
+      --gauge-wrapper-width: 60px;
+      --gauge-percentage-font-size: 13px;
       position: sticky;
       left: 0;
       right: 0;
@@ -177,8 +177,8 @@ limitations under the License.
       font-weight: 700;
       display: none;
       justify-content: center;
-      background-color: var(--color-sticky-header-bg);
-      border-bottom: 1px solid var(--color-black-200);
+      background-color: var(--sticky-header-background-color);
+      border-bottom: 1px solid var(--color-gray-200);
       padding-top: var(--score-container-padding);
       padding-bottom: 4px;
       z-index: 1;
@@ -202,9 +202,9 @@ limitations under the License.
     }
 
     .lh-highlighter {
-      width: var(--score-container-width);
+      width: var(--gauge-wrapper-width);
       height: 1px;
-      background: var(--color-highlighter-bg);
+      background-color: var(--highlighter-background-color);
       /* Position at bottom of first gauge in sticky header. */
       position: absolute;
       grid-column: 1;
@@ -237,23 +237,23 @@ limitations under the License.
       display: flex;
       align-items: center;
       height: var(--topbar-height);
-      background-color: var(--topbar-bg);
+      background-color: var(--topbar-background-color);
       padding: var(--topbar-padding);
     }
 
     .lh-topbar__logo {
-      width: var(--topbar-icon-size);
-      height: var(--topbar-icon-size);
+      width: var(--topbar-logo-size);
+      height: var(--topbar-logo-size);
       user-select: none;
     }
     .lh-topbar__logo .shape {
-      fill: var(--body-text-color);
+      fill: var(--report-text-color);
     }
 
     .lh-topbar__url {
       margin: var(--topbar-padding);
       text-decoration: none;
-      color: var(--body-text-color);
+      color: var(--report-text-color);
     }
 
     .lh-tools {
@@ -261,8 +261,8 @@ limitations under the License.
       will-change: transform;
     }
     .lh-tools__button {
-      width: var(--lh-tools-icon-size);
-      height: var(--lh-tools-icon-size);
+      width: var(--tools-icon-size);
+      height: var(--tools-icon-size);
       cursor: pointer;
       margin-right: 5px;
       /* This is actually a button element, but we want to style it like a transparent div. */
@@ -275,7 +275,7 @@ limitations under the License.
       outline: inherit;
     }
     .lh-tools__button svg {
-      fill: var(--lh-tools-icon-color);
+      fill: var(--tools-icon-color);
     }
     .dark .lh-tools__button svg {
       filter: invert(1);
@@ -287,7 +287,7 @@ limitations under the License.
     }
     .lh-tools__dropdown {
       position: absolute;
-      background-color: var(--body-background-color);
+      background-color: var(--report-background-color);
       border: 1px solid var(--report-border-color);
       border-radius: 3px;
       padding: calc(var(--default-padding) / 2) 0;
@@ -311,7 +311,7 @@ limitations under the License.
     }
     .lh-tools__dropdown a:hover,
     .lh-tools__dropdown a:focus {
-      background-color: var(--color-black-200);
+      background-color: var(--color-gray-200);
       outline: none;
     }
     .lh-tools__dropdown .report-icon {
@@ -323,7 +323,7 @@ limitations under the License.
       text-indent: 18px;
     }
     .dark .report-icon {
-      color: var(--color-black-900);
+      color: var(--color-gray-900);
       filter: invert(1);
     }
     .dark .lh-tools__dropdown a:hover,
@@ -366,7 +366,7 @@ limitations under the License.
     <a href="" class="lh-topbar__url" target="_blank" rel="noopener"></a>
 
     <div class="lh-tools">
-      <button class="report-icon report-icon--share lh-tools__button" title="Export report">
+      <button class="report-icon report-icon--share lh-tools__button" title="Tools menu" aria-label="Toggle report tools menu">
         <svg width="100%" height="100%" viewBox="0 0 24 24">
             <path d="M0 0h24v24H0z" fill="none"/>
             <path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
@@ -494,7 +494,7 @@ limitations under the License.
 <template id="tmpl-lh-footer">
   <style>
     .lh-footer {
-      padding: var(--section-indent) calc(var(--default-padding) * 2);
+      padding: var(--footer-padding-vertical) calc(var(--default-padding) * 2);
       max-width: var(--report-width);
       margin: 0 auto;
     }
@@ -502,8 +502,8 @@ limitations under the License.
       text-align: center;
     }
     .lh-env__title {
-      font-size: var(--score-title-font-size-big);
-      line-height: var(--score-title-line-height-big);
+      font-size: var(--env-item-font-size-big);
+      line-height: var(--env-item-line-height-big);
       text-align: center;
       padding: var(--score-container-padding);
     }
@@ -516,11 +516,11 @@ limitations under the License.
       padding: 0;
     }
     .lh-env__items .lh-env__item:nth-child(2n) {
-      background: var(--env-item-bg);
+      background-color: var(--env-item-background-color);
     }
     .lh-env__item {
       display: flex;
-      padding: var(--env-tem-padding);
+      padding: var(--env-item-padding);
       position: relative;
     }
     span.lh-env__name {
@@ -584,7 +584,7 @@ limitations under the License.
     }
 
     .lh-gauge--pwa__disc {
-      fill: var(--color-black-200);
+      fill: var(--color-gray-200);
     }
 
     .lh-gauge--pwa__logo--primary-color {
@@ -782,8 +782,8 @@ limitations under the License.
 <template id="tmpl-lh-3p-filter">
   <style>
     .lh-3p-filter {
-      background: var(--lh-table-higlight-bg);
-      color: var(--medium-75-gray);
+      background-color: var(--table-higlight-background-color);
+      color: var(--color-gray-600);
       float: right;
       padding: 6px;
     }
@@ -815,8 +815,8 @@ limitations under the License.
           overflow: hidden;
           padding: 10px;
           border-bottom: none;
-          color: var(--subheader-color);
-          background: var(--medium-50-gray);
+          color: var(--snippet-color);
+          background-color: var(--snippet-background-color);
           border: 1px solid var(--report-secondary-border-color);
         }
         .lh-snippet__title {
@@ -898,8 +898,8 @@ limitations under the License.
           display: inline-block;
           vertical-align: middle;
           margin-right: 4px;
-          width: var(--score-shape-size);
-          height: var(--score-shape-size);
+          width: var(--score-icon-size);
+          height: var(--score-icon-size);
           background-image: var(--fail-icon-url);
         }
         .lh-snippet__line-number {
@@ -909,7 +909,7 @@ limitations under the License.
           font-family: monospace;
           padding-right: 5px;
           margin-right: 5px;
-          color: var(--medium-75-gray);
+          color: var(--color-gray-600);
           user-select: none;
         }
       </style>
diff --git a/lighthouse-core/scripts/download-chrome.sh b/lighthouse-core/scripts/download-chrome.sh
index 5c073cd7d..aeeae3e90 100755
--- a/lighthouse-core/scripts/download-chrome.sh
+++ b/lighthouse-core/scripts/download-chrome.sh
@@ -14,12 +14,12 @@ else
   url="https://download-chromium.appspot.com/dl/Linux_x64?type=snapshots"
 fi
 
-if [ x"$LIGHTHOUSE_CHROMIUM_PATH" == x ]; then
-  echo "Error: Environment variable LIGHTHOUSE_CHROMIUM_PATH not set"
+if [ x"$CHROME_PATH" == x ]; then
+  echo "Error: Environment variable CHROME_PATH not set"
   exit 1
 fi
 
-if [ -e "$LIGHTHOUSE_CHROMIUM_PATH" ]; then
+if [ -e "$CHROME_PATH" ]; then
   echo "cached chrome found"
 else
   wget "$url" --no-check-certificate -q -O chrome.zip && unzip -q chrome.zip
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 5e0e3279f..54f58ebd4 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -759,7 +759,8 @@
             "id": "font-display",
             "score": 1.0,
             "scoreDisplayMode": "binary",
-            "title": "All text remains visible during webfont loads"
+            "title": "All text remains visible during webfont loads",
+            "warnings": []
         },
         "font-size": {
             "description": "Font sizes less than 12px are too small to be legible and require mobile visitors to \u201cpinch to zoom\u201d in order to read. Strive to have >60% of page text \u226512px. [Learn more](https://developers.google.com/web/tools/lighthouse/audits/font-sizes).",


Test patch:
diff --git a/lighthouse-cli/test/smokehouse/seo/expectations.js b/lighthouse-cli/test/smokehouse/seo/expectations.js
index 098d0f035..0d370cd37 100644
--- a/lighthouse-cli/test/smokehouse/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/seo/expectations.js
@@ -216,8 +216,7 @@ module.exports = [
       },
       audits: {
         'http-status-code': {
-          score: 0,
-          displayValue: '403',
+          score: null,
         },
         'viewport': {
           score: null,
diff --git a/lighthouse-core/test/audits/font-display-test.js b/lighthouse-core/test/audits/font-display-test.js
index 36b674050..f3dfad9d5 100644
--- a/lighthouse-core/test/audits/font-display-test.js
+++ b/lighthouse-core/test/audits/font-display-test.js
@@ -6,7 +6,6 @@
 'use strict';
 
 const FontDisplayAudit = require('../../audits/font-display.js');
-const assert = require('assert');
 const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
 
 /* eslint-env jest */
@@ -73,8 +72,9 @@ describe('Performance: Font Display audit', () => {
       {url: networkRecords[1].url, wastedMs: 3000},
       {url: networkRecords[2].url, wastedMs: 1000},
     ];
-    assert.strictEqual(result.score, 0);
+    expect(result.score).toEqual(0);
     expect(result.details.items).toEqual(items);
+    expect(result.warnings).toEqual([]);
   });
 
   it('resolves URLs relative to stylesheet URL when available', async () => {
@@ -129,8 +129,9 @@ describe('Performance: Font Display audit', () => {
     ];
 
     const result = await FontDisplayAudit.audit(getArtifacts(), context);
-    assert.strictEqual(result.score, 1);
+    expect(result.score).toEqual(1);
     expect(result.details.items).toEqual([]);
+    expect(result.warnings).toEqual([]);
   });
 
   it('passes when all fonts have a correct font-display rule', async () => {
@@ -180,8 +181,9 @@ describe('Performance: Font Display audit', () => {
     ];
 
     const result = await FontDisplayAudit.audit(getArtifacts(), context);
-    assert.strictEqual(result.score, 1);
+    expect(result.score).toEqual(1);
     expect(result.details.items).toEqual([]);
+    expect(result.warnings).toEqual([]);
   });
 
   it('should handle real-world font-face declarations', async () => {
@@ -220,7 +222,7 @@ describe('Performance: Font Display audit', () => {
     ];
 
     const result = await FontDisplayAudit.audit(getArtifacts(), context);
-    assert.strictEqual(result.score, 0);
+    expect(result.score).toEqual(0);
     expect(result.details.items.map(item => item.url)).toEqual([
       'https://edition.i.cdn.cnn.com/.a/fonts/cnn/3.7.2/cnnclock-black.woff2',
       'https://registry.api.cnn.io/assets/fave/fonts/2.0.15/cnnsans-bold.woff',
@@ -228,6 +230,7 @@ describe('Performance: Font Display audit', () => {
       // 'https://example.com/foo/fonts/fontawesome-webfont.woff2?v=4.6.1',
       'https://fonts.gstatic.com/s/lato/v14/S6u9w4BMUTPHh50XSwiPGQ3q5d0.woff2',
     ]);
+    expect(result.warnings).toEqual([]);
   });
 
   it('handles varied font-display declarations', async () => {
@@ -259,7 +262,8 @@ describe('Performance: Font Display audit', () => {
 
     const result = await FontDisplayAudit.audit(getArtifacts(), context);
     expect(result.details.items).toEqual([]);
-    assert.strictEqual(result.score, 1);
+    expect(result.score).toEqual(1);
+    expect(result.warnings).toEqual([]);
   });
 
   it('handles custom source URLs from sourcemaps', async () => {
@@ -280,6 +284,57 @@ describe('Performance: Font Display audit', () => {
 
     const result = await FontDisplayAudit.audit(getArtifacts(), context);
     expect(result.details.items).toEqual([]);
-    assert.strictEqual(result.score, 1);
+    expect(result.score).toEqual(1);
+  });
+
+  it('should not flag a URL for which there is not @font-face at all', async () => {
+    // Sometimes the content does not come through, see https://github.com/GoogleChrome/lighthouse/issues/8493
+    stylesheet.content = ``;
+
+    networkRecords = [{
+      url: `https://example.com/foo/bar/font-0.woff`,
+      endTime: 2, startTime: 1,
+      resourceType: 'Font',
+    }];
+
+    const result = await FontDisplayAudit.audit(getArtifacts(), context);
+    expect(result.details.items).toEqual([]);
+    expect(result.score).toEqual(1);
+    expect(result.warnings).toHaveLength(1);
+    expect(result.warnings[0]).toBeDisplayString(/font-0.woff/);
+  });
+
+  it('should handle mixed content', async () => {
+    networkRecords = [{
+      url: `https://example.com/foo/bar/font-0.woff`,
+      endTime: 2, startTime: 1,
+      resourceType: 'Font',
+    }, {
+      url: `https://example.com/foo/bar/font-1.woff`,
+      endTime: 2, startTime: 1,
+      resourceType: 'Font',
+    }];
+
+    const artifacts = getArtifacts();
+    artifacts.CSSUsage.stylesheets = [
+      {content: '', header: {}},
+      {
+        content: `
+          @font-face {
+            /* try with " */
+            src: url("./font-0.woff");
+          }
+        `,
+        header: {},
+      },
+    ];
+    const result = await FontDisplayAudit.audit(artifacts, context);
+    expect(result.details.items).toEqual([{
+      url: `https://example.com/foo/bar/font-0.woff`,
+      wastedMs: 1000,
+    }]);
+    expect(result.score).toEqual(0);
+    expect(result.warnings).toHaveLength(1);
+    expect(result.warnings[0]).toBeDisplayString(/font-1.woff/);
   });
 });
diff --git a/lighthouse-core/test/gather/gather-runner-test.js b/lighthouse-core/test/gather/gather-runner-test.js
index 234bd7fd7..533e77646 100644
--- a/lighthouse-core/test/gather/gather-runner-test.js
+++ b/lighthouse-core/test/gather/gather-runner-test.js
@@ -135,34 +135,31 @@ describe('GatherRunner', function() {
   });
 
   it('collects benchmark as an artifact', async () => {
-    const url = 'https://example.com';
+    const requestedUrl = 'https://example.com';
     const driver = fakeDriver;
-    const config = new Config({});
-    const settings = {};
-    const options = {url, driver, config, settings};
+    const config = new Config({passes: []});
+    const options = {requestedUrl, driver, settings: config.settings};
 
-    const results = await GatherRunner.run([], options);
+    const results = await GatherRunner.run(config.passes, options);
     expect(Number.isFinite(results.BenchmarkIndex)).toBeTruthy();
   });
 
   it('collects host user agent as an artifact', async () => {
-    const url = 'https://example.com';
+    const requestedUrl = 'https://example.com';
     const driver = fakeDriver;
-    const config = new Config({});
-    const settings = {};
-    const options = {url, driver, config, settings};
+    const config = new Config({passes: []});
+    const options = {requestedUrl, driver, settings: config.settings};
 
-    const results = await GatherRunner.run([], options);
+    const results = await GatherRunner.run(config.passes, options);
     expect(results.HostUserAgent).toEqual(fakeDriver.protocolGetVersionResponse.userAgent);
     expect(results.HostUserAgent).toMatch(/Chrome\/\d+/);
   });
 
   it('collects network user agent as an artifact', async () => {
-    const url = 'https://example.com';
+    const requestedUrl = 'https://example.com';
     const driver = fakeDriver;
     const config = new Config({passes: [{}]});
-    const settings = {};
-    const options = {url, driver, config, settings};
+    const options = {requestedUrl, driver, settings: config.settings};
 
     const results = await GatherRunner.run(config.passes, options);
     expect(results.NetworkUserAgent).toContain('Mozilla');
@@ -177,8 +174,7 @@ describe('GatherRunner', function() {
       },
     });
     const config = new Config({passes: [{}]});
-    const settings = {};
-    const options = {requestedUrl, driver, config, settings};
+    const options = {requestedUrl, driver, settings: config.settings};
 
     return GatherRunner.run(config.passes, options).then(artifacts => {
       assert.deepStrictEqual(artifacts.URL, {requestedUrl, finalUrl},
@@ -187,13 +183,15 @@ describe('GatherRunner', function() {
   });
 
   describe('collects TestedAsMobileDevice as an artifact', () => {
-    const url = 'https://example.com';
+    const requestedUrl = 'https://example.com';
 
     it('works when running on desktop device without emulation', async () => {
       const driver = fakeDriver;
-      const config = new Config({passes: [{}]});
-      const settings = {};
-      const options = {url, driver, config, settings};
+      const config = new Config({
+        passes: [],
+        settings: {emulatedFormFactor: 'none'},
+      });
+      const options = {requestedUrl, driver, settings: config.settings};
 
       const results = await GatherRunner.run(config.passes, options);
       expect(results.TestedAsMobileDevice).toBe(false);
@@ -201,9 +199,11 @@ describe('GatherRunner', function() {
 
     it('works when running on desktop device with mobile emulation', async () => {
       const driver = fakeDriver;
-      const config = new Config({passes: [{}]});
-      const settings = {emulatedFormFactor: 'mobile'};
-      const options = {url, driver, config, settings};
+      const config = new Config({
+        passes: [],
+        settings: {emulatedFormFactor: 'mobile'},
+      });
+      const options = {requestedUrl, driver, settings: config.settings};
 
       const results = await GatherRunner.run(config.passes, options);
       expect(results.TestedAsMobileDevice).toBe(true);
@@ -211,9 +211,11 @@ describe('GatherRunner', function() {
 
     it('works when running on mobile device without emulation', async () => {
       const driver = fakeDriverUsingRealMobileDevice;
-      const config = new Config({passes: [{}]});
-      const settings = {};
-      const options = {url, driver, config, settings};
+      const config = new Config({
+        passes: [],
+        settings: {emulatedFormFactor: 'none'},
+      });
+      const options = {requestedUrl, driver, settings: config.settings};
 
       const results = await GatherRunner.run(config.passes, options);
       expect(results.TestedAsMobileDevice).toBe(true);
@@ -221,9 +223,11 @@ describe('GatherRunner', function() {
 
     it('works when running on mobile device with desktop emulation', async () => {
       const driver = fakeDriverUsingRealMobileDevice;
-      const config = new Config({passes: [{}]});
-      const settings = {emulatedFormFactor: 'desktop'};
-      const options = {url, driver, config, settings};
+      const config = new Config({
+        passes: [],
+        settings: {emulatedFormFactor: 'desktop'},
+      });
+      const options = {requestedUrl, driver, settings: config.settings};
 
       const results = await GatherRunner.run(config.passes, options);
       expect(results.TestedAsMobileDevice).toBe(false);
@@ -416,7 +420,7 @@ describe('GatherRunner', function() {
       baseArtifacts: await GatherRunner.initializeBaseArtifacts({driver, settings, requestedUrl}),
     };
 
-    await GatherRunner.runPass(passContext, {TestGatherer: []});
+    await GatherRunner.runPass(passContext);
     assert.equal(tests.calledCleanBrowserCaches, true);
   });
 
@@ -588,9 +592,6 @@ describe('GatherRunner', function() {
       beginDevtoolsLog() {
         return Promise.resolve();
       },
-      gotoURL() {
-        return Promise.resolve();
-      },
     };
 
     const passConfig = {
@@ -601,7 +602,7 @@ describe('GatherRunner', function() {
     };
     const settings = {};
 
-    await GatherRunner.beginRecording({driver, passConfig, settings}, {TestGatherer: []});
+    await GatherRunner.beginRecording({driver, passConfig, settings});
     assert.equal(calledTrace, true);
   });
 
@@ -649,7 +650,7 @@ describe('GatherRunner', function() {
     };
     const settings = {};
 
-    await GatherRunner.beginRecording({driver, passConfig, settings}, {TestGatherer: []});
+    await GatherRunner.beginRecording({driver, passConfig, settings});
     assert.equal(calledDevtoolsLogCollect, true);
   });
 
@@ -710,27 +711,26 @@ describe('GatherRunner', function() {
   it('does as many passes as are required', () => {
     const t1 = new TestGatherer();
     const t2 = new TestGatherer();
-    const config = new Config({});
-    const settings = {};
 
-    const passes = [{
-      recordTrace: true,
-      passName: 'firstPass',
-      gatherers: [
-        {instance: t1},
-      ],
-    }, {
-      passName: 'secondPass',
-      gatherers: [
-        {instance: t2},
-      ],
-    }];
+    const config = new Config({
+      passes: [{
+        recordTrace: true,
+        passName: 'firstPass',
+        gatherers: [
+          {instance: t1},
+        ],
+      }, {
+        passName: 'secondPass',
+        gatherers: [
+          {instance: t2},
+        ],
+      }],
+    });
 
-    return GatherRunner.run(passes, {
+    return GatherRunner.run(config.passes, {
       driver: fakeDriver,
       requestedUrl: 'https://example.com',
-      settings,
-      config,
+      settings: config.settings,
     }).then(_ => {
       assert.ok(t1.called);
       assert.ok(t2.called);
@@ -738,18 +738,24 @@ describe('GatherRunner', function() {
   });
 
   it('respects trace names', () => {
-    const passes = [{
-      recordTrace: true,
-      passName: 'firstPass',
-      gatherers: [{instance: new TestGatherer()}],
-    }, {
-      recordTrace: true,
-      passName: 'secondPass',
-      gatherers: [{instance: new TestGatherer()}],
-    }];
-    const options = {driver: fakeDriver, requestedUrl: 'https://example.com', settings: {}, config: {}};
+    const config = new Config({
+      passes: [{
+        recordTrace: true,
+        passName: 'firstPass',
+        gatherers: [{instance: new TestGatherer()}],
+      }, {
+        recordTrace: true,
+        passName: 'secondPass',
+        gatherers: [{instance: new TestGatherer()}],
+      }],
+    });
+    const options = {
+      driver: fakeDriver,
+      requestedUrl: 'https://example.com',
+      settings: config.settings,
+    };
 
-    return GatherRunner.run(passes, options)
+    return GatherRunner.run(config.passes, options)
       .then(artifacts => {
         assert.ok(artifacts.traces.firstPass);
         assert.ok(artifacts.devtoolsLogs.firstPass);
@@ -759,23 +765,108 @@ describe('GatherRunner', function() {
   });
 
   it('doesn\'t leave networkRecords as an artifact', () => {
-    const passes = [{
-      recordTrace: true,
-      passName: 'firstPass',
-      gatherers: [{instance: new TestGatherer()}],
-    }, {
-      recordTrace: true,
-      passName: 'secondPass',
-      gatherers: [{instance: new TestGatherer()}],
-    }];
-    const options = {driver: fakeDriver, requestedUrl: 'https://example.com', settings: {}, config: {}};
+    const config = new Config({
+      passes: [{
+        recordTrace: true,
+        passName: 'firstPass',
+        gatherers: [{instance: new TestGatherer()}],
+      }, {
+        recordTrace: true,
+        passName: 'secondPass',
+        gatherers: [{instance: new TestGatherer()}],
+      }],
+    });
+    const options = {
+      driver: fakeDriver,
+      requestedUrl: 'https://example.com',
+      settings: config.settings,
+    };
 
-    return GatherRunner.run(passes, options)
+    return GatherRunner.run(config.passes, options)
       .then(artifacts => {
         assert.equal(artifacts.networkRecords, undefined);
       });
   });
 
+  it('saves trace and devtoolsLog with error prefix when there was a runtime error', async () => {
+    const requestedUrl = 'https://example.com';
+    const driver = Object.assign({}, fakeDriver, {
+      // resolved URL here does not match any request in the network records, causing a runtime error.
+      gotoURL: async _ => requestedUrl,
+      online: true,
+    });
+
+    const config = new Config({
+      passes: [{
+        passName: 'firstPass',
+        recordTrace: true,
+        gatherers: [{instance: new TestGatherer()}],
+      }],
+    });
+    const options = {driver, requestedUrl, settings: config.settings};
+    const artifacts = await GatherRunner.run(config.passes, options);
+
+    expect(artifacts.TestGatherer.code).toEqual('NO_DOCUMENT_REQUEST');
+
+    // The only loadData available should be prefixed with `pageLoadError-`.
+    expect(Object.keys(artifacts.traces)).toEqual(['pageLoadError-firstPass']);
+    expect(Object.keys(artifacts.devtoolsLogs)).toEqual(['pageLoadError-firstPass']);
+  });
+
+  it('does not run additional passes after a runtime error', async () => {
+    const t1 = new (class Test1 extends TestGatherer {})();
+    const t2 = new (class Test2 extends TestGatherer {})();
+    const t3 = new (class Test3 extends TestGatherer {})();
+    const config = new Config({
+      passes: [{
+        passName: 'firstPass',
+        recordTrace: true,
+        gatherers: [{instance: t1}],
+      }, {
+        passName: 'secondPass',
+        recordTrace: true,
+        gatherers: [{instance: t2}],
+      }, {
+        passName: 'thirdPass',
+        recordTrace: true,
+        gatherers: [{instance: t3}],
+      }],
+    });
+
+    const requestedUrl = 'https://www.reddit.com/r/nba';
+    let firstLoad = true;
+    const driver = Object.assign({}, fakeDriver, {
+      // Loads the page successfully in the first pass, fails with NO_FCP in the second.
+      async gotoURL(url) {
+        if (url.includes('blank')) return null;
+        if (firstLoad) {
+          firstLoad = false;
+          return requestedUrl;
+        } else {
+          throw new LHError(LHError.errors.NO_FCP);
+        }
+      },
+      online: true,
+    });
+    const options = {driver, requestedUrl, settings: config.settings};
+    const artifacts = await GatherRunner.run(config.passes, options);
+
+    // t1.pass() and t2.pass() called; t3.pass(), after the error, was not.
+    expect(t1.called).toBe(true);
+    expect(t2.called).toBe(true);
+    expect(t3.called).toBe(false);
+
+    // But only t1 has a valid artifact, t2 has an error artifact, and t3 never ran.
+    expect(artifacts.Test1).toBe('MyArtifact');
+    expect(artifacts.Test2).toBeInstanceOf(LHError);
+    expect(artifacts.Test2.code).toEqual('NO_FCP');
+    expect(artifacts.Test3).toBeUndefined();
+
+    // firstPass has a saved trace and devtoolsLog, secondPass has an error trace and log.
+    expect(Object.keys(artifacts.traces)).toEqual(['firstPass', 'pageLoadError-secondPass']);
+    expect(Object.keys(artifacts.devtoolsLogs)).toEqual(['firstPass', 'pageLoadError-secondPass']);
+  });
+
   describe('#getNetworkError', () => {
     it('passes when the page is loaded', () => {
       const url = 'http://the-page.com';
@@ -1043,15 +1134,16 @@ describe('GatherRunner', function() {
           }
         },
       ];
-      const passes = [{
-
-        gatherers: gatherers.map(G => ({instance: new G()})),
-      }];
+      const config = new Config({
+        passes: [{
+          gatherers: gatherers.map(G => ({instance: new G()})),
+        }],
+      });
 
-      const artifacts = await GatherRunner.run(passes, {
+      const artifacts = await GatherRunner.run(config.passes, {
         driver: fakeDriver,
         requestedUrl: 'https://example.com',
-        settings: {},
+        settings: config.settings,
       });
 
       // Ensure artifacts returned and not errors.
@@ -1097,16 +1189,16 @@ describe('GatherRunner', function() {
         }(),
       ].map(instance => ({instance}));
       const gathererNames = gatherers.map(gatherer => gatherer.instance.name);
-      const passes = [{
-
-        gatherers,
-      }];
+      const config = new Config({
+        passes: [{
+          gatherers,
+        }],
+      });
 
-      return GatherRunner.run(passes, {
+      return GatherRunner.run(config.passes, {
         driver: fakeDriver,
         requestedUrl: 'https://example.com',
-        settings: {},
-        config: new Config({}),
+        settings: config.settings,
       }).then(artifacts => {
         gathererNames.forEach(gathererName => {
           assert.strictEqual(artifacts[gathererName], gathererName);
@@ -1135,12 +1227,14 @@ describe('GatherRunner', function() {
         {instance: new class EavesdropGatherer3 extends EavesdropGatherer {}()},
       ];
 
-      const passes = [{gatherers}];
-      return GatherRunner.run(passes, {
+      const config = new Config({
+        passes: [{gatherers}],
+      });
+
+      return GatherRunner.run(config.passes, {
         driver: fakeDriver,
         requestedUrl: 'https://example.com',
-        settings: {},
-        config: new Config({}),
+        settings: config.settings,
       }).then(artifacts => {
         assert.equal(artifacts.EavesdropGatherer1, 1);
         assert.equal(artifacts.EavesdropGatherer2, 2);
@@ -1191,7 +1285,7 @@ describe('GatherRunner', function() {
         ],
       };
 
-      return GatherRunner.collectArtifacts(gathererResults, {}).then(({artifacts}) => {
+      return GatherRunner.collectArtifacts(gathererResults).then(({artifacts}) => {
         assert.strictEqual(artifacts.AfterGatherer, 97);
         assert.strictEqual(artifacts.PassGatherer, 284);
         assert.strictEqual(artifacts.SingleErrorGatherer, recoverableError);
@@ -1215,14 +1309,15 @@ describe('GatherRunner', function() {
         }
       }
 
-      const passes = [{
-        gatherers: [{instance: new WarningGatherer()}],
-      }];
-      const artifacts = await GatherRunner.run(passes, {
+      const config = new Config({
+        passes: [{
+          gatherers: [{instance: new WarningGatherer()}],
+        }],
+      });
+      const artifacts = await GatherRunner.run(config.passes, {
         driver: fakeDriver,
         requestedUrl: 'https://example.com',
-        settings: {},
-        config: new Config({}),
+        settings: config.settings,
       });
       assert.deepStrictEqual(artifacts.LighthouseRunWarnings, runWarnings);
     });
@@ -1267,16 +1362,16 @@ describe('GatherRunner', function() {
         }(),
       ].map(instance => ({instance}));
       const gathererNames = gatherers.map(gatherer => gatherer.instance.name);
-      const passes = [{
-
-        gatherers,
-      }];
+      const config = new Config({
+        passes: [{
+          gatherers,
+        }],
+      });
 
-      return GatherRunner.run(passes, {
+      return GatherRunner.run(config.passes, {
         driver: fakeDriver,
         requestedUrl: 'https://example.com',
-        settings: {},
-        config: new Config({}),
+        settings: config.settings,
       }).then(artifacts => {
         gathererNames.forEach(gathererName => {
           const errorArtifact = artifacts[gathererName];
@@ -1287,48 +1382,48 @@ describe('GatherRunner', function() {
     });
 
     it('rejects if a gatherer does not provide an artifact', () => {
-      const passes = [{
-
-        recordTrace: true,
-        passName: 'firstPass',
-        gatherers: [
-          {instance: new TestGathererNoArtifact()},
-        ],
-      }];
+      const config = new Config({
+        passes: [{
+          recordTrace: true,
+          passName: 'firstPass',
+          gatherers: [
+            {instance: new TestGathererNoArtifact()},
+          ],
+        }],
+      });
 
-      return GatherRunner.run(passes, {
+      return GatherRunner.run(config.passes, {
         driver: fakeDriver,
         requestedUrl: 'https://example.com',
-        settings: {},
-        config: new Config({}),
+        settings: config.settings,
       }).then(_ => assert.ok(false), _ => assert.ok(true));
     });
 
     it('rejects when domain name can\'t be resolved', () => {
-      const passes = [{
-
-        recordTrace: true,
-        passName: 'firstPass',
-        gatherers: [],
-      }];
+      const config = new Config({
+        passes: [{
+          recordTrace: true,
+          passName: 'firstPass',
+          gatherers: [],
+        }],
+      });
 
       // Arrange for driver to return unresolved request.
-      const url = 'http://www.some-non-existing-domain.com/';
+      const requestedUrl = 'http://www.some-non-existing-domain.com/';
       const unresolvedDriver = Object.assign({}, fakeDriver, {
         online: true,
         gotoURL() {
-          return Promise.resolve(url);
+          return Promise.resolve(requestedUrl);
         },
         endDevtoolsLog() {
           return unresolvedPerfLog;
         },
       });
 
-      return GatherRunner.run(passes, {
+      return GatherRunner.run(config.passes, {
         driver: unresolvedDriver,
-        url,
-        settings: {},
-        config: new Config({}),
+        requestedUrl,
+        settings: config.settings,
       }).then(artifacts => {
         assert.equal(artifacts.LighthouseRunWarnings.length, 1);
         expect(artifacts.LighthouseRunWarnings[0])
@@ -1337,30 +1432,30 @@ describe('GatherRunner', function() {
     });
 
     it('resolves when domain name can\'t be resolved but is offline', () => {
-      const passes = [{
-
-        recordTrace: true,
-        passName: 'firstPass',
-        gatherers: [],
-      }];
+      const config = new Config({
+        passes: [{
+          recordTrace: true,
+          passName: 'firstPass',
+          gatherers: [],
+        }],
+      });
 
       // Arrange for driver to return unresolved request.
-      const url = 'http://www.some-non-existing-domain.com/';
+      const requestedUrl = 'http://www.some-non-existing-domain.com/';
       const unresolvedDriver = Object.assign({}, fakeDriver, {
         online: false,
         gotoURL() {
-          return Promise.resolve(url);
+          return Promise.resolve(requestedUrl);
         },
         endDevtoolsLog() {
           return unresolvedPerfLog;
         },
       });
 
-      return GatherRunner.run(passes, {
+      return GatherRunner.run(config.passes, {
         driver: unresolvedDriver,
-        url,
-        settings: {},
-        config: new Config({}),
+        requestedUrl,
+        settings: config.settings,
       })
         .then(_ => {
           assert.ok(true);
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 088cbea10..d9a6deefb 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -796,6 +796,7 @@
       "description": "Leverage the font-display CSS feature to ensure text is user-visible while webfonts are loading. [Learn more](https://developers.google.com/web/updates/2016/02/font-display).",
       "score": 1,
       "scoreDisplayMode": "binary",
+      "warnings": [],
       "details": {
         "type": "table",
         "headings": [],