Repository: GoogleChrome/lighthouse
Pull request #8425 (2019-04-19 04:12:15)

report(redesign): dark theme
#8185

~Merge base is a branch I made w/ #8222 and #8307.~ merged in master.

Still need to do the PWA badge, and inline some svgs (it prevents duplicating by letting CSS change the colors. Only inlining svgs that would get used just once.)

googlebot: We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for all the commit author(s) or Co-authors.  If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA ([login here](https://cla.developers.google.com/) to double check)?  If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google.
In order to pass this check, please resolve this problem and have the pull request author add another comment and the bot will run again. If the bot doesn't comment, it means it doesn't think anything has changed.

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2FGoogleChrome%2Flighthouse%2Fpull%2F8425) for more info**.

<!-- need_author_cla -->
googlebot: CLAs look good, thanks!

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2FGoogleChrome%2Flighthouse%2Fpull%2F8425) for more info**.

<!-- ok -->
Hoten: > Only inlining svgs that would get used just once.

realized there would be duplication either way, so I did the easier option, which was not inlining these svgs.
googlebot: We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for all the commit author(s) or Co-authors.  If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA ([login here](https://cla.developers.google.com/) to double check)?  If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google.
In order to pass this check, please resolve this problem and have the pull request author add another comment and the bot will run again. If the bot doesn't comment, it means it doesn't think anything has changed.

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2FGoogleChrome%2Flighthouse%2Fpull%2F8425) for more info**.

<!-- need_author_cla -->
exterkamp: .dark .report-icon--share's background-image has a light gray background color, while the normal .report-icon--share does not.
![image](https://user-images.githubusercontent.com/6392995/56517128-0b3db480-64f1-11e9-929f-6e32f03def03.png)

Hoten: @exterkamp fixed
Hoten: @patrickhulce PWA badge changes aren't as trivial as the rest. Will be a different PR.
Hoten: > best practices links and the file an issue link are both the old, low-contrast blue for me (maybe we need to take care of `a:visited` too? not sure)

fixed


Hoten: closing/opening to force travis build
brendankenny: do you mind rebasing/renaming the PR to remove the merge blockers?
Hoten: renamed.

I don't understand how rebasing would remove the other two failures. The CLA?
brendankenny: > I don't understand how rebasing would remove the other two failures.

for whatever reason the Travis merge with master isn't picking up #8583, so rebase/merge with master would force it to include the fix
Hoten: my track record with rebasing is awful, but I think just merging master back in will do the trick.
Hoten: ![image](https://user-images.githubusercontent.com/4071474/56702514-3d9a0e00-66b9-11e9-9c76-d8773aa47a5d.png)

?
paulirish: btw: 

```sh
git commit --allow-empty -m "empty commit to trigger CI"
```

Patch:
diff --git a/build/build-dt-report-resources.js b/build/build-dt-report-resources.js
index 3abfc03c3..0e568c59f 100644
--- a/build/build-dt-report-resources.js
+++ b/build/build-dt-report-resources.js
@@ -17,24 +17,22 @@ const generatorFilename = `./lighthouse-core/report/report-generator.js`;
 const htmlReportAssets = require('../lighthouse-core/report/html/html-report-assets.js');
 
 /**
- * Used to save cached resources (Runtime.cachedResources). Content must be converted to ascii.
+ * Used to save cached resources (Runtime.cachedResources).
  * @param {string} name
  * @param {string} content
  */
-function convertToAsciiAndWriteFile(name, content) {
+function writeFile(name, content) {
   assert(content);
-  // eslint-disable-next-line no-control-regex
-  const escaped = content.replace(/[^\x00-\x7F]/g, c => '\\\\u' + c.charCodeAt(0).toString(16));
-  fs.writeFileSync(`${distDir}/${name}`, escaped);
+  fs.writeFileSync(`${distDir}/${name}`, content);
 }
 
 rimraf.sync(distDir);
 fs.mkdirSync(distDir);
 
-convertToAsciiAndWriteFile('report.js', htmlReportAssets.REPORT_JAVASCRIPT);
-convertToAsciiAndWriteFile('report.css', htmlReportAssets.REPORT_CSS);
-convertToAsciiAndWriteFile('template.html', htmlReportAssets.REPORT_TEMPLATE);
-convertToAsciiAndWriteFile('templates.html', htmlReportAssets.REPORT_TEMPLATES);
+writeFile('report.js', htmlReportAssets.REPORT_JAVASCRIPT);
+writeFile('report.css', htmlReportAssets.REPORT_CSS);
+writeFile('template.html', htmlReportAssets.REPORT_TEMPLATE);
+writeFile('templates.html', htmlReportAssets.REPORT_TEMPLATES);
 
 const pathToReportAssets = require.resolve('../clients/devtools-report-assets.js');
 browserify(generatorFilename, {standalone: 'Lighthouse.ReportGenerator'})
diff --git a/clients/extension/scripts/popup.js b/clients/extension/scripts/popup.js
index 9676a8f4a..bf33fe7c2 100644
--- a/clients/extension/scripts/popup.js
+++ b/clients/extension/scripts/popup.js
@@ -6,13 +6,25 @@
 'use strict';
 
 /** @typedef {typeof import('./extension-entry.js') & {console: typeof console}} BackgroundPage */
+/** @typedef {import('../../../lighthouse-core/lib/lh-error.js')} LighthouseError */
 
 /**
  * Error strings that indicate a problem in how Lighthouse was run, not in
  * Lighthouse itself, mapped to more useful strings to report to the user.
  */
 const NON_BUG_ERROR_MESSAGES = {
-  'Another debugger': 'You probably have DevTools open. Close DevTools to use Lighthouse',
+  // The user tries to review an error page or has network issues
+  'ERRORED_DOCUMENT_REQUEST': 'Unable to load the page. Please verify the url you ' +
+      'are trying to review.',
+  'FAILED_DOCUMENT_REQUEST': 'Unable to load the page. Please verify the url you ' +
+      'are trying to review.',
+  'DNS_FAILURE': 'DNS servers could not resolve the provided domain.',
+  'INSECURE_DOCUMENT_REQUEST': 'The URL you have provided does not have a valid' +
+      ' SSL certificate.',
+  'INVALID_URL': 'Lighthouse can only audit URLs that start' +
+      ' with http:// or https://.',
+
+  // chrome extension API errors
   'multiple tabs': 'You probably have multiple tabs open to the same origin. ' +
       'Close the other tabs to use Lighthouse.',
   // The extension debugger API is forbidden from attaching to the web store.
@@ -21,19 +33,10 @@ const NON_BUG_ERROR_MESSAGES = {
       'Chrome Web Store. If necessary, use the Lighthouse CLI to do so.',
   'Cannot access a chrome': 'The Lighthouse extension cannot audit ' +
       'Chrome-specific urls. If necessary, use the Lighthouse CLI to do so.',
-  // The user tries to review an error page or has network issues
-  'Unable to load the page': 'Unable to load the page. Please verify the url you ' +
-      'are trying to review.',
   'Cannot access contents of the page': 'Lighthouse can only audit URLs that start' +
       ' with http:// or https://.',
-  'INSECURE_DOCUMENT_REQUEST': 'The URL you have provided does not have valid' +
-      ' security credentials.',
-  'INVALID_URL': 'Lighthouse can only audit URLs that start' +
-      ' with http:// or https://.',
 };
 
-const MAX_ISSUE_ERROR_LENGTH = 60;
-
 const subpageVisibleClass = 'subpage--visible';
 
 /** @type {?URL} */
@@ -79,35 +82,40 @@ function find(query, context = document) {
 }
 
 /**
+ * @param {string} message
  * @param {Error} err
- * @return {HTMLAnchorElement}
+ * @return {HTMLButtonElement}
  */
-function buildReportErrorLink(err) {
+function buildErrorCopyButton(message, err) {
   const issueBody = `
 **Lighthouse Version**: ${getLighthouseVersion()}
 **Lighthouse Commit**: ${getLighthouseCommitHash()}
 **Chrome Version**: ${getChromeVersion()}
 **Initial URL**: ${siteURL}
-**Error Message**: ${err.message}
+**Error Message**: ${message}
 **Stack Trace**:
 \`\`\`
 ${err.stack}
 \`\`\`
     `;
 
-  const url = new URL('https://github.com/GoogleChrome/lighthouse/issues/new');
+  const errorButtonDefaultText = 'Copy details to clipboard 📋';
+  const errorButtonEl = document.createElement('button');
+  errorButtonEl.className = 'button button--report-error';
+  errorButtonEl.textContent = errorButtonDefaultText;
 
-  const errorTitle = err.message.substring(0, MAX_ISSUE_ERROR_LENGTH);
-  url.searchParams.append('title', `Extension Error: ${errorTitle}`);
-  url.searchParams.append('body', issueBody.trim());
+  errorButtonEl.addEventListener('click', async () => {
+    // @ts-ignore - tsc doesn't include `clipboard` on `navigator`
+    await navigator.clipboard.writeText(issueBody);
+    errorButtonEl.textContent = 'Copied to clipboard 📋';
 
-  const reportErrorEl = document.createElement('a');
-  reportErrorEl.className = 'button button--report-error';
-  reportErrorEl.href = url.href;
-  reportErrorEl.textContent = 'Report Error';
-  reportErrorEl.target = '_blank';
+    // Return button to inviting state after timeout.
+    setTimeout(() => {
+      errorButtonEl.textContent = errorButtonDefaultText;
+    }, 1000);
+  });
 
-  return reportErrorEl;
+  return errorButtonEl;
 }
 
 /**
@@ -176,13 +184,14 @@ async function onGenerateReportButtonClick(background, settings) {
     // Close popup once report is opened in a new tab
     window.close();
   } catch (err) {
-    let message = err.message;
+    let message = err.friendlyMessage || err.message;
     let includeReportLink = true;
 
     // Check for errors in how the user ran Lighthouse and replace with a more
     // helpful message (and remove 'Report Error' link).
     for (const [test, replacement] of Object.entries(NON_BUG_ERROR_MESSAGES)) {
-      if (message.includes(test)) {
+      if (err.message.includes(test) ||
+          (err.friendlyMessage && err.friendlyMessage.includes(test))) {
         message = replacement;
         includeReportLink = false;
         break;
@@ -193,7 +202,7 @@ async function onGenerateReportButtonClick(background, settings) {
 
     if (includeReportLink) {
       feedbackEl.className = 'feedback feedback-error';
-      feedbackEl.appendChild(buildReportErrorLink(err));
+      feedbackEl.appendChild(buildErrorCopyButton(message, err));
     }
 
     hideRunningSubpage();
diff --git a/clients/extension/styles/lighthouse.css b/clients/extension/styles/lighthouse.css
index 01d62639a..cb54d65ef 100644
--- a/clients/extension/styles/lighthouse.css
+++ b/clients/extension/styles/lighthouse.css
@@ -132,8 +132,8 @@ html, body {
   padding: 3px 5px;
   font-size: .8em;
   text-align: center;
-  display: inline;
-  margin-left: 5px;
+  display: block;
+  margin-top: 5px;
 }
 
 .subpage {
diff --git a/docs/recipes/custom-audit/searchable-audit.js b/docs/recipes/custom-audit/searchable-audit.js
index 86ecdd2cf..18fe22656 100644
--- a/docs/recipes/custom-audit/searchable-audit.js
+++ b/docs/recipes/custom-audit/searchable-audit.js
@@ -36,7 +36,7 @@ class LoadAudit extends Audit {
     const belowThreshold = loadMetrics.searchableTime <= MAX_SEARCHABLE_TIME;
 
     return {
-      rawValue: loadMetrics.searchableTime,
+      numericValue: loadMetrics.searchableTime,
       // Cast true/false to 1/0
       score: Number(belowThreshold),
     };
diff --git a/docs/recipes/lighthouse-plugin-example/audits/preload-as.js b/docs/recipes/lighthouse-plugin-example/audits/preload-as.js
index 48d8385ed..892aba6c3 100644
--- a/docs/recipes/lighthouse-plugin-example/audits/preload-as.js
+++ b/docs/recipes/lighthouse-plugin-example/audits/preload-as.js
@@ -37,7 +37,7 @@ class PreloadAsAudit extends Audit {
     const passed = noAsLinks.length === 0;
 
     return {
-      rawValue: passed,
+      score: passed ? 1 : 0,
       displayValue: `Found ${noAsLinks.length} preload requests with missing \`as\` attributes`,
     };
   }
diff --git a/lighthouse-cli/bin.js b/lighthouse-cli/bin.js
index 510a766e4..887a533d8 100644
--- a/lighthouse-cli/bin.js
+++ b/lighthouse-cli/bin.js
@@ -77,6 +77,13 @@ async function begin() {
     configJson = require(`../lighthouse-core/config/${cliFlags.preset}-config.js`);
   }
 
+  if (cliFlags.budgetPath) {
+    cliFlags.budgetPath = path.resolve(process.cwd(), cliFlags.budgetPath);
+    /** @type {Array<LH.Budget>} */
+    const parsedBudget = JSON.parse(fs.readFileSync(cliFlags.budgetPath, 'utf8'));
+    cliFlags.budgets = parsedBudget;
+  }
+
   // set logging preferences
   cliFlags.logLevel = 'info';
   if (cliFlags.verbose) {
diff --git a/lighthouse-cli/cli-flags.js b/lighthouse-cli/cli-flags.js
index 58c1939a6..06e96f93d 100644
--- a/lighthouse-cli/cli-flags.js
+++ b/lighthouse-cli/cli-flags.js
@@ -59,7 +59,7 @@ function getFlags(manualArgv) {
           'save-assets', 'list-all-audits', 'list-trace-categories', 'print-config', 'additional-trace-categories',
           'config-path', 'preset', 'chrome-flags', 'port', 'hostname', 'emulated-form-factor',
           'max-wait-for-load', 'enable-error-reporting', 'gather-mode', 'audit-mode',
-          'only-audits', 'only-categories', 'skip-audits',
+          'only-audits', 'only-categories', 'skip-audits', 'budget-path',
         ],
         'Configuration:')
       .describe({
@@ -88,6 +88,7 @@ function getFlags(manualArgv) {
             'Additional categories to capture with the trace (comma-delimited).',
         'config-path': `The path to the config JSON.
             An example config file: lighthouse-core/config/lr-desktop-config.js`,
+        'budget-path': `The path to the budget.json file for LightWallet.`,
         'preset': `Use a built-in configuration.
             WARNING: If the --config-path flag is provided, this preset will be ignored.`,
         'chrome-flags':
@@ -141,6 +142,7 @@ function getFlags(manualArgv) {
       .string('channel')
       .string('precomputedLanternDataPath')
       .string('lanternDataOutputPath')
+      .string('budgetPath')
 
       // default values
       .default('chrome-flags', '')
diff --git a/lighthouse-core/audits/audit.js b/lighthouse-core/audits/audit.js
index 3c9230a44..5daa69c5c 100644
--- a/lighthouse-core/audits/audit.js
+++ b/lighthouse-core/audits/audit.js
@@ -270,7 +270,7 @@ class Audit {
 
       score,
       scoreDisplayMode,
-      numericValue: product.rawValue,
+      numericValue: product.numericValue,
 
       displayValue: product.displayValue,
       explanation: product.explanation,
diff --git a/lighthouse-core/audits/bootup-time.js b/lighthouse-core/audits/bootup-time.js
index c2cc645b6..c5e178fef 100644
--- a/lighthouse-core/audits/bootup-time.js
+++ b/lighthouse-core/audits/bootup-time.js
@@ -179,7 +179,7 @@ class BootupTime extends Audit {
 
     return {
       score,
-      rawValue: totalBootupTime,
+      numericValue: totalBootupTime,
       displayValue: totalBootupTime > 0 ?
         str_(i18n.UIStrings.seconds, {timeInMs: totalBootupTime}) : '',
       details,
diff --git a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
index 9c99a78fc..634f44851 100644
--- a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
+++ b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js
@@ -208,7 +208,7 @@ class UnusedBytes extends Audit {
       explanation: result.explanation,
       warnings: result.warnings,
       displayValue,
-      rawValue: wastedMs,
+      numericValue: wastedMs,
       score: UnusedBytes.scoreForWastedMs(wastedMs),
       extendedInfo: {
         value: {
diff --git a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
index 5e51c98a0..bcd9de011 100644
--- a/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
+++ b/lighthouse-core/audits/byte-efficiency/render-blocking-resources.js
@@ -224,7 +224,7 @@ class RenderBlockingResources extends Audit {
     return {
       displayValue,
       score: ByteEfficiencyAudit.scoreForWastedMs(wastedMs),
-      rawValue: wastedMs,
+      numericValue: wastedMs,
       details,
     };
   }
diff --git a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
index 542b726e8..615f6ed99 100644
--- a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
+++ b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js
@@ -99,7 +99,7 @@ class TotalByteWeight extends ByteEfficiencyAudit {
 
     return {
       score,
-      rawValue: totalBytes,
+      numericValue: totalBytes,
       displayValue: str_(UIStrings.displayValue, {totalBytes}),
       extendedInfo: {
         value: {
diff --git a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
index 692fdc384..0d4738055 100644
--- a/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
+++ b/lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js
@@ -284,7 +284,7 @@ class CacheHeaders extends Audit {
 
       return {
         score,
-        rawValue: totalWastedBytes,
+        numericValue: totalWastedBytes,
         displayValue: str_(UIStrings.displayValue, {itemCount: results.length}),
         extendedInfo: {
           value: {
diff --git a/lighthouse-core/audits/diagnostics.js b/lighthouse-core/audits/diagnostics.js
index 2a1fb54d4..ac52685d5 100644
--- a/lighthouse-core/audits/diagnostics.js
+++ b/lighthouse-core/audits/diagnostics.js
@@ -66,7 +66,6 @@ class Diagnostics extends Audit {
 
     return {
       score: 1,
-      rawValue: 1,
       details: {
         type: 'debugdata',
         // TODO: Consider not nesting diagnostics under `items`.
diff --git a/lighthouse-core/audits/dobetterweb/dom-size.js b/lighthouse-core/audits/dobetterweb/dom-size.js
index e02e9869f..c3cffd61f 100644
--- a/lighthouse-core/audits/dobetterweb/dom-size.js
+++ b/lighthouse-core/audits/dobetterweb/dom-size.js
@@ -135,7 +135,7 @@ class DOMSize extends Audit {
 
     return {
       score,
-      rawValue: stats.totalBodyElements,
+      numericValue: stats.totalBodyElements,
       displayValue: str_(UIStrings.displayValue, {itemCount: stats.totalBodyElements}),
       extendedInfo: {
         value: items,
diff --git a/lighthouse-core/audits/errors-in-console.js b/lighthouse-core/audits/errors-in-console.js
index 1df3399a4..079c6f898 100644
--- a/lighthouse-core/audits/errors-in-console.js
+++ b/lighthouse-core/audits/errors-in-console.js
@@ -71,7 +71,7 @@ class ErrorLogs extends Audit {
 
     return {
       score: Number(numErrors === 0),
-      rawValue: numErrors,
+      numericValue: numErrors,
       details,
     };
   }
diff --git a/lighthouse-core/audits/font-display.js b/lighthouse-core/audits/font-display.js
index e05fd1b68..f6460c28e 100644
--- a/lighthouse-core/audits/font-display.js
+++ b/lighthouse-core/audits/font-display.js
@@ -6,7 +6,7 @@
 'use strict';
 
 const Audit = require('./audit');
-const URL = require('../lib/url-shim').URL;
+const URL = require('../lib/url-shim');
 const PASSING_FONT_DISPLAY_REGEX = /^(block|fallback|optional|swap)$/;
 const CSS_URL_REGEX = /url\((.*?)\)/;
 const CSS_URL_GLOBAL_REGEX = new RegExp(CSS_URL_REGEX, 'g');
@@ -82,11 +82,11 @@ class FontDisplay extends Audit {
 
             return s;
           });
-
         // Convert the relative CSS URL to an absolute URL and add it to the passing set
         for (const relativeURL of relativeURLs) {
           try {
-            const relativeRoot = stylesheet.header.sourceURL || artifacts.URL.finalUrl;
+            const relativeRoot = URL.isValid(stylesheet.header.sourceURL) ?
+              stylesheet.header.sourceURL : artifacts.URL.finalUrl;
             const absoluteURL = new URL(relativeURL, relativeRoot);
             passingURLs.add(absoluteURL.href);
           } catch (err) {
diff --git a/lighthouse-core/audits/load-fast-enough-for-pwa.js b/lighthouse-core/audits/load-fast-enough-for-pwa.js
index 102fd9796..cbc226b74 100644
--- a/lighthouse-core/audits/load-fast-enough-for-pwa.js
+++ b/lighthouse-core/audits/load-fast-enough-for-pwa.js
@@ -95,7 +95,7 @@ class LoadFastEnough4Pwa extends Audit {
       score,
       displayValue,
       explanation,
-      rawValue: tti.timing,
+      numericValue: tti.timing,
     };
   }
 }
diff --git a/lighthouse-core/audits/main-thread-tasks.js b/lighthouse-core/audits/main-thread-tasks.js
index 1399e6248..e3265f8bb 100644
--- a/lighthouse-core/audits/main-thread-tasks.js
+++ b/lighthouse-core/audits/main-thread-tasks.js
@@ -51,7 +51,7 @@ class MainThreadTasks extends Audit {
 
     return {
       score: 1,
-      rawValue: results.length,
+      numericValue: results.length,
       details: tableDetails,
     };
   }
diff --git a/lighthouse-core/audits/mainthread-work-breakdown.js b/lighthouse-core/audits/mainthread-work-breakdown.js
index 9325693ae..1865613c3 100644
--- a/lighthouse-core/audits/mainthread-work-breakdown.js
+++ b/lighthouse-core/audits/mainthread-work-breakdown.js
@@ -122,7 +122,7 @@ class MainThreadWorkBreakdown extends Audit {
 
     return {
       score,
-      rawValue: totalExecutionTime,
+      numericValue: totalExecutionTime,
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: totalExecutionTime}),
       details: tableDetails,
     };
diff --git a/lighthouse-core/audits/metrics.js b/lighthouse-core/audits/metrics.js
index 8818812c8..2a7db3068 100644
--- a/lighthouse-core/audits/metrics.js
+++ b/lighthouse-core/audits/metrics.js
@@ -117,7 +117,7 @@ class Metrics extends Audit {
 
     return {
       score: 1,
-      rawValue: (interactive && interactive.timing) || 0,
+      numericValue: (interactive && interactive.timing) || 0,
       details,
     };
   }
diff --git a/lighthouse-core/audits/metrics/estimated-input-latency.js b/lighthouse-core/audits/metrics/estimated-input-latency.js
index 5c9128814..38efde2b3 100644
--- a/lighthouse-core/audits/metrics/estimated-input-latency.js
+++ b/lighthouse-core/audits/metrics/estimated-input-latency.js
@@ -66,7 +66,7 @@ class EstimatedInputLatency extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/first-contentful-paint-3g.js b/lighthouse-core/audits/metrics/first-contentful-paint-3g.js
index 1e9413fb6..8742c2752 100644
--- a/lighthouse-core/audits/metrics/first-contentful-paint-3g.js
+++ b/lighthouse-core/audits/metrics/first-contentful-paint-3g.js
@@ -55,7 +55,7 @@ class FirstContentfulPaint3G extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: `${metricResult.timing}\xa0ms`,
     };
   }
diff --git a/lighthouse-core/audits/metrics/first-contentful-paint.js b/lighthouse-core/audits/metrics/first-contentful-paint.js
index 5da25f0d9..aa8753738 100644
--- a/lighthouse-core/audits/metrics/first-contentful-paint.js
+++ b/lighthouse-core/audits/metrics/first-contentful-paint.js
@@ -63,7 +63,7 @@ class FirstContentfulPaint extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/first-cpu-idle.js b/lighthouse-core/audits/metrics/first-cpu-idle.js
index fe2e3d586..6557ba152 100644
--- a/lighthouse-core/audits/metrics/first-cpu-idle.js
+++ b/lighthouse-core/audits/metrics/first-cpu-idle.js
@@ -67,7 +67,7 @@ class FirstCPUIdle extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/first-meaningful-paint.js b/lighthouse-core/audits/metrics/first-meaningful-paint.js
index 6272884ae..fe83fc0f7 100644
--- a/lighthouse-core/audits/metrics/first-meaningful-paint.js
+++ b/lighthouse-core/audits/metrics/first-meaningful-paint.js
@@ -66,7 +66,7 @@ class FirstMeaningfulPaint extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/interactive.js b/lighthouse-core/audits/metrics/interactive.js
index df3891628..d14628444 100644
--- a/lighthouse-core/audits/metrics/interactive.js
+++ b/lighthouse-core/audits/metrics/interactive.js
@@ -78,7 +78,7 @@ class InteractiveMetric extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: timeInMs,
+      numericValue: timeInMs,
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs}),
       extendedInfo: {
         value: extendedInfo,
diff --git a/lighthouse-core/audits/metrics/max-potential-fid.js b/lighthouse-core/audits/metrics/max-potential-fid.js
index 29bbbfc43..3a81771bf 100644
--- a/lighthouse-core/audits/metrics/max-potential-fid.js
+++ b/lighthouse-core/audits/metrics/max-potential-fid.js
@@ -66,7 +66,7 @@ class MaxPotentialFID extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/metrics/speed-index.js b/lighthouse-core/audits/metrics/speed-index.js
index 54ad2de7b..d8cb2f193 100644
--- a/lighthouse-core/audits/metrics/speed-index.js
+++ b/lighthouse-core/audits/metrics/speed-index.js
@@ -65,7 +65,7 @@ class SpeedIndex extends Audit {
         context.options.scorePODR,
         context.options.scoreMedian
       ),
-      rawValue: metricResult.timing,
+      numericValue: metricResult.timing,
       displayValue: str_(i18n.UIStrings.seconds, {timeInMs: metricResult.timing}),
     };
   }
diff --git a/lighthouse-core/audits/network-requests.js b/lighthouse-core/audits/network-requests.js
index 2a8235220..d15f8e28b 100644
--- a/lighthouse-core/audits/network-requests.js
+++ b/lighthouse-core/audits/network-requests.js
@@ -90,7 +90,7 @@ class NetworkRequests extends Audit {
 
       return {
         score: 1,
-        rawValue: results.length,
+        numericValue: results.length,
         details: tableDetails,
       };
     });
diff --git a/lighthouse-core/audits/network-rtt.js b/lighthouse-core/audits/network-rtt.js
index 49986ca85..13f074fe2 100644
--- a/lighthouse-core/audits/network-rtt.js
+++ b/lighthouse-core/audits/network-rtt.js
@@ -69,7 +69,7 @@ class NetworkRTT extends Audit {
 
     return {
       score: 1,
-      rawValue: maxRtt,
+      numericValue: maxRtt,
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: maxRtt}),
       details: tableDetails,
     };
diff --git a/lighthouse-core/audits/network-server-latency.js b/lighthouse-core/audits/network-server-latency.js
index 1f3b37853..a3067a58d 100644
--- a/lighthouse-core/audits/network-server-latency.js
+++ b/lighthouse-core/audits/network-server-latency.js
@@ -68,7 +68,7 @@ class NetworkServerLatency extends Audit {
 
     return {
       score: Math.max(1 - (maxLatency / 500), 0),
-      rawValue: maxLatency,
+      numericValue: maxLatency,
       displayValue: str_(i18n.UIStrings.ms, {timeInMs: maxLatency}),
       details: tableDetails,
     };
diff --git a/lighthouse-core/audits/predictive-perf.js b/lighthouse-core/audits/predictive-perf.js
index 369c1c07e..0c892df96 100644
--- a/lighthouse-core/audits/predictive-perf.js
+++ b/lighthouse-core/audits/predictive-perf.js
@@ -88,7 +88,7 @@ class PredictivePerf extends Audit {
 
     return {
       score,
-      rawValue: values.roughEstimateOfTTI,
+      numericValue: values.roughEstimateOfTTI,
       displayValue: Util.formatMilliseconds(values.roughEstimateOfTTI),
       details: {
         type: 'debugdata',
diff --git a/lighthouse-core/audits/redirects.js b/lighthouse-core/audits/redirects.js
index a2b1c7456..9e40a18a1 100644
--- a/lighthouse-core/audits/redirects.js
+++ b/lighthouse-core/audits/redirects.js
@@ -108,7 +108,7 @@ class Redirects extends Audit {
     return {
       // We award a passing grade if you only have 1 redirect
       score: redirectRequests.length <= 2 ? 1 : UnusedBytes.scoreForWastedMs(totalWastedMs),
-      rawValue: totalWastedMs,
+      numericValue: totalWastedMs,
       displayValue: totalWastedMs ?
         str_(i18n.UIStrings.displayValueMsSavings, {wastedMs: totalWastedMs}) :
         '',
diff --git a/lighthouse-core/audits/time-to-first-byte.js b/lighthouse-core/audits/time-to-first-byte.js
index ec6610ff2..37982b6e4 100644
--- a/lighthouse-core/audits/time-to-first-byte.js
+++ b/lighthouse-core/audits/time-to-first-byte.js
@@ -69,7 +69,7 @@ class TTFBMetric extends Audit {
     };
 
     return {
-      rawValue: ttfb,
+      numericValue: ttfb,
       score: Number(passed),
       displayValue,
       details,
diff --git a/lighthouse-core/audits/uses-rel-preconnect.js b/lighthouse-core/audits/uses-rel-preconnect.js
index e08620b25..8e80cda44 100644
--- a/lighthouse-core/audits/uses-rel-preconnect.js
+++ b/lighthouse-core/audits/uses-rel-preconnect.js
@@ -188,7 +188,7 @@ class UsesRelPreconnectAudit extends Audit {
 
     return {
       score: UnusedBytes.scoreForWastedMs(maxWasted),
-      rawValue: maxWasted,
+      numericValue: maxWasted,
       displayValue: maxWasted ?
         str_(i18n.UIStrings.displayValueMsSavings, {wastedMs: maxWasted}) :
         '',
diff --git a/lighthouse-core/audits/uses-rel-preload.js b/lighthouse-core/audits/uses-rel-preload.js
index 671e2ffea..240a6a9ba 100644
--- a/lighthouse-core/audits/uses-rel-preload.js
+++ b/lighthouse-core/audits/uses-rel-preload.js
@@ -222,7 +222,7 @@ class UsesRelPreloadAudit extends Audit {
 
     return {
       score: UnusedBytes.scoreForWastedMs(wastedMs),
-      rawValue: wastedMs,
+      numericValue: wastedMs,
       displayValue: wastedMs ?
         str_(i18n.UIStrings.displayValueMsSavings, {wastedMs}) :
         '',
diff --git a/lighthouse-core/config/budget.js b/lighthouse-core/config/budget.js
new file mode 100644
index 000000000..820dda302
--- /dev/null
+++ b/lighthouse-core/config/budget.js
@@ -0,0 +1,131 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * 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.
+ */
+'use strict';
+
+class Budget {
+  /**
+   * Asserts that obj has no own properties, throwing a nice error message if it does.
+   * Plugin and object name are included for nicer logging.
+   * @param {Record<string, unknown>} obj
+   * @param {string} objectName
+   */
+  static assertNoExcessProperties(obj, objectName) {
+    const invalidKeys = Object.keys(obj);
+    if (invalidKeys.length > 0) {
+      const keys = invalidKeys.join(', ');
+      throw new Error(`${objectName} has unrecognized properties: [${keys}]`);
+    }
+  }
+
+  /**
+   * @param {LH.Budget.ResourceBudget} resourceBudget
+   * @return {LH.Budget.ResourceBudget}
+   */
+  static validateResourceBudget(resourceBudget) {
+    const {resourceType, budget, ...invalidRest} = resourceBudget;
+    Budget.assertNoExcessProperties(invalidRest, 'Resource Budget');
+
+    const validResourceTypes = [
+      'total',
+      'document',
+      'script',
+      'stylesheet',
+      'image',
+      'media',
+      'font',
+      'other',
+      'third-party',
+    ];
+    if (!validResourceTypes.includes(resourceBudget.resourceType)) {
+      throw new Error(`Invalid resource type: ${resourceBudget.resourceType}. \n` +
+        `Valid resource types are: ${ validResourceTypes.join(', ') }`);
+    }
+    if (isNaN(resourceBudget.budget)) {
+      throw new Error('Invalid budget: ${resourceBudget.budget}');
+    }
+    return {
+      resourceType,
+      budget,
+    };
+  }
+
+  /**
+   * @param {LH.Budget.TimingBudget} timingBudget
+   * @return {LH.Budget.TimingBudget}
+   */
+  static validateTimingBudget(timingBudget) {
+    const {metric, budget, tolerance, ...invalidRest} = timingBudget;
+    Budget.assertNoExcessProperties(invalidRest, 'Timing Budget');
+
+    const validTimingMetrics = [
+      'first-contentful-paint',
+      'first-cpu-idle',
+      'interactive',
+      'first-meaningful-paint',
+      'estimated-input-latency',
+    ];
+    if (!validTimingMetrics.includes(timingBudget.metric)) {
+      throw new Error(`Invalid timing metric: ${timingBudget.metric}. \n` +
+        `Valid timing metrics are: ${validTimingMetrics.join(', ')}`);
+    }
+    if (isNaN(timingBudget.budget)) {
+      throw new Error('Invalid budget: ${timingBudget.budget}');
+    }
+    if (timingBudget.tolerance !== undefined && isNaN(timingBudget.tolerance)) {
+      throw new Error('Invalid tolerance: ${timingBudget.tolerance}');
+    }
+    return {
+      metric,
+      budget,
+      tolerance,
+    };
+  }
+
+  /**
+   * More info on the Budget format:
+   * https://github.com/GoogleChrome/lighthouse/issues/6053#issuecomment-428385930
+   * @param {Array<LH.Budget>} budgetArr
+   * @return {Array<LH.Budget>}
+   */
+  static initializeBudget(budgetArr) {
+    /** @type {Array<LH.Budget>} */
+    const budgets = [];
+
+    budgetArr.forEach((b) => {
+      /** @type {LH.Budget} */
+      const budget = {};
+
+      const {resourceSizes, resourceCounts, timings, ...invalidRest} = b;
+      Budget.assertNoExcessProperties(invalidRest, 'Budget');
+
+      if (b.resourceSizes !== undefined) {
+        budget.resourceSizes = b.resourceSizes.map((r) => {
+          return Budget.validateResourceBudget(r);
+        });
+      }
+
+      if (b.resourceCounts !== undefined) {
+        budget.resourceCounts = b.resourceCounts.map((r) => {
+          return Budget.validateResourceBudget(r);
+        });
+      }
+
+      if (b.timings !== undefined) {
+        budget.timings = b.timings.map((t) => {
+          return Budget.validateTimingBudget(t);
+        });
+      }
+      budgets.push({
+        resourceSizes,
+        resourceCounts,
+        timings,
+      });
+    });
+    return budgets;
+  }
+}
+
+module.exports = Budget;
diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js
index b7fb5f7b9..7b26f588a 100644
--- a/lighthouse-core/config/config.js
+++ b/lighthouse-core/config/config.js
@@ -17,6 +17,7 @@ const path = require('path');
 const Audit = require('../audits/audit.js');
 const Runner = require('../runner.js');
 const ConfigPlugin = require('./config-plugin.js');
+const Budget = require('./budget.js');
 
 /** @typedef {typeof import('../gather/gatherers/gatherer.js')} GathererConstructor */
 /** @typedef {InstanceType<GathererConstructor>} Gatherer */
@@ -495,7 +496,7 @@ class Config {
   }
 
   /**
-   * @param {LH.Config.SettingsJson=} settingsJson
+   * @param {LH.SharedFlagsSettings=} settingsJson
    * @param {LH.Flags=} flags
    * @return {LH.Config.Settings}
    */
@@ -512,6 +513,9 @@ class Config {
     // Override any applicable settings with CLI flags
     const settingsWithFlags = merge(settingWithDefaults || {}, cleanFlagsForSettings(flags), true);
 
+    if (settingsWithFlags.budgets) {
+      settingsWithFlags.budgets = Budget.initializeBudget(settingsWithFlags.budgets);
+    }
     // Locale is special and comes only from flags/settings/lookupLocale.
     settingsWithFlags.locale = locale;
 
diff --git a/lighthouse-core/config/constants.js b/lighthouse-core/config/constants.js
index 331975751..9f5ce283b 100644
--- a/lighthouse-core/config/constants.js
+++ b/lighthouse-core/config/constants.js
@@ -55,6 +55,7 @@ const defaultSettings = {
 
   // the following settings have no defaults but we still want ensure that `key in settings`
   // in config will work in a typechecked way
+  budgets: null,
   locale: 'en-US', // actual default determined by Config using lib/i18n
   blockedUrlPatterns: null,
   additionalTraceCategories: null,
diff --git a/lighthouse-core/report/html/renderer/category-renderer.js b/lighthouse-core/report/html/renderer/category-renderer.js
index dcfc9b1fb..adbfcc063 100644
--- a/lighthouse-core/report/html/renderer/category-renderer.js
+++ b/lighthouse-core/report/html/renderer/category-renderer.js
@@ -81,8 +81,8 @@ class CategoryRenderer {
     this.dom.find('.lh-audit__description', auditEl)
         .appendChild(this.dom.convertMarkdownLinkSnippets(audit.result.description));
 
-    if (audit.result.stackPacks) {
-      audit.result.stackPacks.forEach(pack => {
+    if (audit.stackPacks) {
+      audit.stackPacks.forEach(pack => {
         const packElm = this.dom.createElement('div');
         packElm.classList.add('lh-audit__stackpack');
 
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 62e5e2e8d..883bb54ad 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -54,8 +54,11 @@ class ReportUIFeatures {
     this.onExportButtonClick = this.onExportButtonClick.bind(this);
     this.onExport = this.onExport.bind(this);
     this.onKeyDown = this.onKeyDown.bind(this);
-    this.printShortCutDetect = this.printShortCutDetect.bind(this);
+    this.onKeyUp = this.onKeyUp.bind(this);
     this.onChevronClick = this.onChevronClick.bind(this);
+    this.collapseAllDetails = this.collapseAllDetails.bind(this);
+    this.expandAllDetails = this.expandAllDetails.bind(this);
+    this._toggleDarkTheme = this._toggleDarkTheme.bind(this);
     this._updateStickyHeaderOnScroll = this._updateStickyHeaderOnScroll.bind(this);
   }
 
@@ -73,10 +76,12 @@ class ReportUIFeatures {
     this._setupStickyHeaderElements();
     this._setUpCollapseDetailsAfterPrinting();
     this._resetUIState();
-    this._document.addEventListener('keydown', this.printShortCutDetect);
+    this._document.addEventListener('keyup', this.onKeyUp);
     this._document.addEventListener('copy', this.onCopy);
     this._document.addEventListener('scroll', this._updateStickyHeaderOnScroll);
     window.addEventListener('resize', this._updateStickyHeaderOnScroll);
+    const topbarLogo = this._dom.find('.lh-topbar__logo', this._document);
+    topbarLogo.addEventListener('click', this._toggleDarkTheme);
   }
 
   /**
@@ -272,6 +277,17 @@ class ReportUIFeatures {
     }
   }
 
+  /**
+   * Keyup handler for the document.
+   * @param {KeyboardEvent} e
+   */
+  onKeyUp(e) {
+    // Ctrl+P - Expands audit details when user prints via keyboard shortcut.
+    if ((e.ctrlKey || e.metaKey) && e.keyCode === 80) {
+      this.closeExportDropdown();
+    }
+  }
+
   /**
    * Opens a new tab to the online viewer and sends the local page's JSON results
    * to the online viewer using postMessage.
@@ -304,16 +320,6 @@ class ReportUIFeatures {
     const popup = window.open(`${VIEWER_ORIGIN}${viewerPath}`, windowName);
   }
 
-  /**
-   * Expands audit details when user prints via keyboard shortcut.
-   * @param {KeyboardEvent} e
-   */
-  printShortCutDetect(e) {
-    if ((e.ctrlKey || e.metaKey) && e.keyCode === 80) { // Ctrl+P
-      this.closeExportDropdown();
-    }
-  }
-
   /**
    * Expands all audit `<details>`.
    * Ideally, a print stylesheet could take care of this, but CSS has no way to
@@ -400,6 +406,13 @@ class ReportUIFeatures {
     setTimeout(_ => URL.revokeObjectURL(href), 500);
   }
 
+  /**
+   * @private
+   */
+  _toggleDarkTheme() {
+    this._document.body.classList.toggle('dark');
+  }
+
   _updateStickyHeaderOnScroll() {
     // Show sticky header when the score scale begins to go underneath the topbar.
     const topbarBottom = this.topbarEl.getBoundingClientRect().bottom;
diff --git a/lighthouse-core/report/html/renderer/util.js b/lighthouse-core/report/html/renderer/util.js
index 8b1ba2fae..f0dd4e25c 100644
--- a/lighthouse-core/report/html/renderer/util.js
+++ b/lighthouse-core/report/html/renderer/util.js
@@ -72,21 +72,21 @@ class Util {
       }
     }
 
-    // For convenience, smoosh all AuditResults into their auditDfn (which has just weight & group)
+    // For convenience, smoosh all AuditResults into their auditRef (which has just weight & group)
     for (const category of clone.reportCategories) {
-      category.auditRefs.forEach(auditMeta => {
-        const result = clone.audits[auditMeta.id];
-        auditMeta.result = result;
+      category.auditRefs.forEach(auditRef => {
+        const result = clone.audits[auditRef.id];
+        auditRef.result = result;
 
         // attach the stackpacks to the auditRef object
         if (clone.stackPacks) {
           clone.stackPacks.forEach(pack => {
-            if (pack.descriptions[auditMeta.id]) {
-              auditMeta.result.stackPacks = auditMeta.result.stackPacks || [];
-              auditMeta.result.stackPacks.push({
+            if (pack.descriptions[auditRef.id]) {
+              auditRef.stackPacks = auditRef.stackPacks || [];
+              auditRef.stackPacks.push({
                 title: pack.title,
                 iconDataURL: pack.iconDataURL,
-                description: pack.descriptions[auditMeta.id],
+                description: pack.descriptions[auditRef.id],
               });
             }
           });
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 0a5a5d345..0da801a3d 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -19,6 +19,7 @@
   --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-font-size: 14px;
@@ -39,9 +40,9 @@
   --audit-indent: 16px;
   --text-indent: 8px;
   --expandable-indent: 20px;
-  --secondary-text-color: #565656;
+  --secondary-text-color: var(--color-black-800);
   /*--accent-color: #3879d9;*/
-  --informative-color: #0c50c7;
+  --informative-color: var(--color-blue-900);
   --medium-75-gray: #757575;
   --medium-50-gray: hsl(210, 17%, 98%);
   --medium-100-gray: hsl(200, 12%, 95%);
@@ -92,6 +93,9 @@
   --color-red-700: #EB0F00;
   --color-red: #FF4E42;
   --color-white: #FFFFFF;
+  --color-blue-200: #90CAF9;
+  --color-blue-900: #0D47A1;
+
 
   /* TODO(cjamcl) clean up unused variables. */
   --audits-margin-bottom: 40px;
@@ -101,6 +105,7 @@
   --gauge-circle-size-big: 120px;
   --gauge-circle-size: 96px;
   --header-padding: 20px 0 20px 0;
+  --highlighter-bg: var(--color-black-400);
   --icon-square-size: calc(var(--score-shape-size) * 0.88);
   --plugin-badge-bg: var(--color-white);
   --plugin-badge-size-big: calc(var(--gauge-circle-size-big) / 2.7);
@@ -132,11 +137,13 @@
   --color-fail: var(--color-red);
   --color-pass-secondary: var(--color-green-700);
   --color-pass: var(--color-green);
-  --color-sticky-header-bg: var(--color-white);
-  --color-highlighter-bg: var(--color-black);
+  --color-sticky-header-bg: var(--body-background-color);
+  --color-highlighter-bg: var(--body-text-color);
+
+  --color-hover: #FAFAFA;
 
   --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>');
-  --logo-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192"><g fill="none" fill-rule="evenodd"><path d="M0 0h192v192H0z"/><path d="M67.705 179.352l2.603-20.82 49.335-16.39 4.652 37.21A87.893 87.893 0 0 1 96 184a87.893 87.893 0 0 1-28.295-4.648zM52.44 172.48C25.894 157.328 8 128.754 8 96 8 47.399 47.399 8 96 8s88 39.399 88 88c0 32.754-17.894 61.328-44.44 76.48L130 96h6V80h-8V48L96 28 64 48v32h-8v16h6l-9.56 76.48zM113.875 96l2.882 23.05-43.318 14.433L78.125 96h35.75zM80 80V56.868l16-10 16 10V80H80z" fill="%23000000"/></g></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>');
 
   --pass-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>check</title><path fill="%23178239" d="M24 4C12.95 4 4 12.95 4 24c0 11.04 8.95 20 20 20 11.04 0 20-8.96 20-20 0-11.05-8.96-20-20-20zm-4 30L10 24l2.83-2.83L20 28.34l15.17-15.17L38 16 20 34z"/></svg>');
   --average-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>info</title><path fill="%23E67700" d="M24 4C12.95 4 4 12.95 4 24s8.95 20 20 20 20-8.95 20-20S35.05 4 24 4zm2 30h-4V22h4v12zm0-16h-4v-4h4v4z"/></svg>');
@@ -146,11 +153,48 @@
   --pwa-installable-gray-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="nonzero"><circle fill="%23DAE0E3" cx="12" cy="12" r="12"/><path d="M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm3.5 7.7h-2.8v2.8h-1.4v-2.8H8.5v-1.4h2.8V8.5h1.4v2.8h2.8v1.4z" fill="%23FFF"/></g></svg>');
   --pwa-optimized-gray-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><rect fill="%23DAE0E3" width="24" height="24" rx="12"/><path fill="%23FFF" d="M12 15.07l3.6 2.18-.95-4.1 3.18-2.76-4.2-.36L12 6.17l-1.64 3.86-4.2.36 3.2 2.76-.96 4.1z"/><path d="M5 5h14v14H5z"/></g></svg>');
 
+  --pwa-fast-reliable-gray-url-dark: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="nonzero"><circle fill="%23424242" cx="12" cy="12" r="12"/><path d="M12.3 4l6.3 2.8V11c0 3.88-2.69 7.52-6.3 8.4C8.69 18.52 6 14.89 6 11V6.8L12.3 4zm-.56 12.88l3.3-5.79.04-.08c.05-.1.01-.29-.26-.29h-1.96l.56-3.92h-.56L9.6 12.52c0 .03.07-.12-.03.07-.11.2-.12.37.2.37h1.97l-.56 3.92h.56z" fill="%23FFF"/></g></svg>');
+  --pwa-installable-gray-url-dark: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="nonzero"><circle fill="%23424242" cx="12" cy="12" r="12"/><path d="M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm3.5 7.7h-2.8v2.8h-1.4v-2.8H8.5v-1.4h2.8V8.5h1.4v2.8h2.8v1.4z" fill="%23FFF"/></g></svg>');
+  --pwa-optimized-gray-url-dark: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><rect fill="%23424242" width="24" height="24" rx="12"/><path fill="%23FFF" d="M12 15.07l3.6 2.18-.95-4.1 3.18-2.76-4.2-.36L12 6.17l-1.64 3.86-4.2.36 3.2 2.76-.96 4.1z"/><path d="M5 5h14v14H5z"/></g></svg>');
+
   --pwa-fast-reliable-color-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><circle fill="%230CCE6B" cx="12" cy="12" r="12"/><path d="M12 4.3l6.3 2.8v4.2c0 3.88-2.69 7.52-6.3 8.4-3.61-.88-6.3-4.51-6.3-8.4V7.1L12 4.3zm-.56 12.88l3.3-5.79.04-.08c.05-.1.01-.29-.26-.29h-1.96l.56-3.92h-.56L9.3 12.82c0 .03.07-.12-.03.07-.11.2-.12.37.2.37h1.97l-.56 3.92h.56z" fill="%23FFF"/></g></svg>');
   --pwa-installable-color-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><circle fill="%230CCE6B" cx="12" cy="12" r="12"/><path d="M12 5a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm3.5 7.7h-2.8v2.8h-1.4v-2.8H8.5v-1.4h2.8V8.5h1.4v2.8h2.8v1.4z" fill="%23FFF"/></g></svg>');
   --pwa-optimized-color-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><rect fill="%230CCE6B" width="24" height="24" rx="12"/><path d="M5 5h14v14H5z"/><path fill="%23FFF" d="M12 15.07l3.6 2.18-.95-4.1 3.18-2.76-4.2-.36L12 6.17l-1.64 3.86-4.2.36 3.2 2.76-.96 4.1z"/></g></svg>');
 }
 
+.lh-vars.dark {
+  --color-red-700: var(--color-red);
+  --color-green-700: var(--color-green);
+  --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);
+  --header-bg-color: var(--color-black-900);
+  --env-item-bg: rgba(0, 0, 0, 0.5);
+
+  --body-background-color: var(--color-black-900);
+  --body-text-color: var(--color-black-100);
+  --secondary-text-color: var(--color-black-400);
+
+  --highlighter-bg: var(--color-black-200);
+  --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-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);
+}
+
 @media only screen and (max-width: 480px) {
   .lh-vars {
     --audits-margin-bottom: 20px;
@@ -221,7 +265,7 @@
   line-height: var(--body-line-height);
   background: var(--body-background-color);
   scroll-behavior: smooth;
-  color: var(--color-black-900);
+  color: var(--body-text-color);
 }
 
 .lh-root :focus {
@@ -245,8 +289,10 @@
 }
 
 .lh-audit-group a,
-.lh-category-header__description a {
-  color: #0c50c7;
+.lh-category-header__description a,
+.lh-audit__description a,
+.lh-footer a {
+  color: var(--informative-color);
 }
 
 .lh-audit__description, .lh-audit__stackpack {
@@ -309,6 +355,22 @@
   background-image: url('data:image/svg+xml;utf8,<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>');
 }
 
+.dark .report-icon--share {
+  background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="white" d="M0 0h24v24H0z"/><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/></svg>');
+}
+.dark .report-icon--print {
+  background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="white" d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/><path fill="none" d="M0 0h24v24H0z"/></svg>');
+}
+.dark .report-icon--copy {
+  background-image: url('data:image/svg+xml;utf8,<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path fill="none" d="M0 0h24v24H0z" /><path fill="white" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>');
+}
+.dark .report-icon--open {
+  background-image: url('data:image/svg+xml;utf8,<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path fill="none" d="M0 0h24v24H0z" /><path fill="white" d="M19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h4v-2H5V8h14v10h-4v2h4c1.1 0 2-.9 2-2V6c0-1.1-.89-2-2-2zm-7 6l-4 4h3v6h2v-6h3l-4-4z"/></svg>');
+}
+.dark .report-icon--download {
+  background-image: url('data:image/svg+xml;utf8,<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path fill="white" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>');
+}
+
 /* Node */
 .lh-node {
   display: block;
@@ -316,10 +378,6 @@
   word-break: break-word;
   color: hsl(174, 100%, 27%);
 }
-.lh-node:hover {
-    background: hsl(0, 0%, 98%);
-    border-radius: 2px;
-}
 
 /* Score */
 
@@ -369,7 +427,6 @@
   display: none;
 }
 
-.lh-category-header__description,
 .lh-audit__description,
 .lh-audit__stackpack {
   color: var(--secondary-text-color);
@@ -398,7 +455,7 @@
 /* Expandable Details (Audit Groups, Audits) */
 .lh-audit__header {
   display: flex;
-  padding: var(--lh-audit-vpadding) var(--text-indent);
+  padding: var(--lh-audit-vpadding) 0;
 }
 
 .lh-audit--load-opportunity .lh-audit__header {
@@ -406,7 +463,7 @@
 }
 
 .lh-audit__header:hover {
-  background-color: #F8F9FA;
+  background-color: var(--color-hover);
 }
 
 /* Hide the expandable arrow icon, three ways: via the CSS Counter Styles spec, for webkit/blink browsers, hiding the polyfilled icon */
@@ -650,6 +707,15 @@
   border-bottom: 1px solid var(--report-secondary-border-color);
 }
 
+/* Apply border-top to just the first audit. */
+.lh-audit {
+  border-top: 1px solid var(--report-secondary-border-color);
+}
+.lh-audit ~ .lh-audit {
+  border-top: none;
+}
+
+
 .lh-audit--error .lh-audit__display-text {
   color: var(--color-fail);
 }
@@ -657,7 +723,7 @@
 /* Audit Group */
 
 .lh-audit-group {
-  margin: var(--lh-audit-group-vpadding) 0;
+  margin-bottom: var(--audits-margin-bottom);
 }
 
 .lh-audit-group__header::before {
@@ -692,11 +758,6 @@
   background-image: var(--pwa-optimized-color-url);
 }
 
-/* Removing too much whitespace */
-.lh-audit-group--metrics {
-  margin-top: calc(var(--gauge-circle-size)/2 * -1);
-  border-bottom: none;
-}
 .lh-audit-group--metrics .lh-audit-group__summary {
   margin-top: 0;
   margin-bottom: 0;
@@ -721,6 +782,8 @@
 .lh-audit-group__header {
   font-size: var(--body-font-size);
   margin: 0 0 var(--lh-audit-group-vpadding);
+  /* When the header takes 100% width, the chevron becomes small. */
+  max-width: calc(100% - var(--chevron-size));
 }
 
 .lh-audit-group__header span.lh-audit-group__title {
@@ -788,7 +851,6 @@
 }
 
 .lh-report {
-  background-color: #fff;
   min-width: var(--report-min-width);
 }
 
@@ -838,6 +900,10 @@
   border: 0;
 }
 
+.lh-gauge__wrapper {
+  padding: var(--score-container-padding);
+}
+
 .lh-scores-header .lh-gauge__wrapper,
 .lh-scores-header .lh-gauge--pwa__wrapper,
 .lh-sticky-header .lh-gauge__wrapper,
@@ -994,10 +1060,9 @@
 }
 
 .lh-table thead {
-  background: var(--lh-table-header-bg);
+  background-color: var(--lh-table-higlight-bg);
 }
 .lh-table thead th {
-  color: var(--medium-75-gray);
   font-weight: normal;
   word-wrap: normal;
 }
@@ -1145,7 +1210,7 @@
 }
 
 .tooltip-boundary:hover {
-  background-color: #F8F9FA;
+  background-color: var(--color-hover);
 }
 
 .tooltip-boundary:hover .tooltip {
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index dbe7fb8fc..e3e3753c2 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -209,14 +209,15 @@ limitations under the License.
     .lh-topbar__logo {
       width: var(--topbar-icon-size);
       height: var(--topbar-icon-size);
-      background-image: var(--logo-url);
-      background-size: cover;
+    }
+    .lh-topbar__logo .shape {
+      fill: var(--body-text-color);
     }
 
     .lh-topbar__url {
       margin: var(--topbar-padding);
       text-decoration: none;
-      color: var(--color-black);
+      color: var(--body-text-color);
     }
 
     .lh-export {
@@ -224,7 +225,6 @@ limitations under the License.
       will-change: transform;
     }
     .lh-export__button {
-      background-color: #fff;
       border: 1px solid #dadada;
       border-radius: 2px;
       cursor: pointer;
@@ -246,7 +246,7 @@ limitations under the License.
     }
     .lh-export__dropdown {
       position: absolute;
-      background-color: #fff;
+      background-color: var(--body-background-color);
       border: 1px solid var(--report-border-color);
       border-radius: 3px;
       padding: calc(var(--default-padding) / 2) 0;
@@ -269,7 +269,7 @@ limitations under the License.
     }
     .lh-export__dropdown a:hover,
     .lh-export__dropdown a:focus {
-      background-color: #efefef;
+      background-color: var(--color-black-200);
       outline: none;
     }
     .lh-export__dropdown .report-icon {
@@ -305,7 +305,13 @@ limitations under the License.
   </style>
 
   <div class="lh-topbar">
-    <div class="lh-topbar__logo"></div>
+    <!-- Flat Lighthouse logo.  -->
+    <svg class="lh-topbar__logo" viewBox="0 0 192 192">
+      <g fill="none" fill-rule="evenodd">
+        <path d="M0 0h192v192H0z"/>
+        <path class="shape" d="M67.705 179.352l2.603-20.82 49.335-16.39 4.652 37.21A87.893 87.893 0 0 1 96 184a87.893 87.893 0 0 1-28.295-4.648zM52.44 172.48C25.894 157.328 8 128.754 8 96 8 47.399 47.399 8 96 8s88 39.399 88 88c0 32.754-17.894 61.328-44.44 76.48L130 96h6V80h-8V48L96 28 64 48v32h-8v16h6l-9.56 76.48zM113.875 96l2.882 23.05-43.318 14.433L78.125 96h35.75zM80 80V56.868l16-10 16 10V80H80z"/>
+      </g>
+    </svg>
 
     <a href="" class="lh-topbar__url" target="_blank" rel="noopener"></a>
     
@@ -539,7 +545,12 @@ limitations under the License.
 
     .lh-gauge__svg-wrapper {
       position: relative;
+      height: var(--gauge-circle-size);
     }
+    .lh-category .lh-gauge__svg-wrapper {
+      --gauge-circle-size: var(--gauge-circle-size-big);
+    }
+
     /* The plugin badge overlay */
     .lh-gauge__wrapper--plugin .lh-gauge__svg-wrapper::before {
       width: var(--plugin-badge-size);
@@ -576,15 +587,16 @@ limitations under the License.
       font-size: var(--score-number-font-size);
       line-height: var(--score-number-font-size);
       text-align: center;
-      top: calc(var(--gauge-circle-size) / 3);
+      top: calc(var(--score-container-padding) + var(--gauge-circle-size) / 3);
     }
 
     .lh-category .lh-gauge__percentage {
       --gauge-circle-size: var(--gauge-circle-size-big);
-      --score-number-fontsize: var(--score-number-fontsize-big);
+      --score-number-font-size: var(--score-number-font-size-big);
     }
 
     .lh-gauge__wrapper {
+      position: relative;
       display: flex;
       align-items: center;
       flex-direction: column;
@@ -601,16 +613,16 @@ limitations under the License.
     .lh-gauge__label {
       font-size: var(--score-title-font-size);
       line-height: var(--score-title-line-height);
-      margin-bottom: calc(0.5 * var(--score-title-line-height));
+      margin-top: 10px;
       text-align: center;
-      color: black;
+      color: var(--body-text-color);
     }
 
     /* TODO(#8185) use more BEM (.lh-gauge__label--big) instead of relying on descendent 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);
-      margin: calc(0.5 * var(--score-title-line-height));
+      margin-top: 14px;
     }
 
   </style>
@@ -640,6 +652,21 @@ limitations under the License.
       fill: #B0B0B0;
     }
 
+    .lh-gauge--pwa__disc {
+      fill: var(--color-black-200);
+    }
+
+    .lh-gauge--pwa__logo--primary-color {
+      fill: #304FFE;
+    }
+
+    .lh-gauge--pwa__logo--secondary-color {
+      fill: #3D3D3D;
+    }
+    .dark .lh-gauge--pwa__logo--secondary-color {
+      fill: #D8B6B6;
+    }
+
     /* No passing groups. */
     .lh-gauge--pwa__wrapper:not([class*='lh-badged--']) .lh-gauge--pwa__na-line {
       display: inline;
@@ -673,10 +700,6 @@ limitations under the License.
   <a href="#" class="lh-gauge__wrapper lh-gauge--pwa__wrapper">
     <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" class="lh-gauge lh-gauge--pwa">
       <defs>
-        <linearGradient id="lh-gauge--pwa__bg-disk__gradient" x1="50%" y1="2.15%" x2="50%" y2="97.645%">
-          <stop stop-color="#F1F3F4" offset="0%"></stop>
-          <stop stop-color="#DEE6EA" offset="100%"></stop>
-        </linearGradient>
         <linearGradient id="lh-gauge--pwa__check-circle__gradient" x1="50%" y1="0%" x2="50%" y2="100%">
           <stop stop-color="#00C852" offset="0%"></stop>
           <stop stop-color="#009688" offset="100%"></stop>
@@ -702,11 +725,11 @@ limitations under the License.
 
       <g stroke="none" fill-rule="nonzero">
         <!-- Background and PWA logo (color by default) -->
-        <circle fill="url(#lh-gauge--pwa__bg-disk__gradient)" cx="30" cy="30" r="30"></circle>
+        <circle class="lh-gauge--pwa__disc" cx="30" cy="30" r="30"></circle>
         <g class="lh-gauge--pwa__logo">
-          <path fill="#3D3D3D" d="M35.66 19.39l.7-1.75h2L37.4 15 38.6 12l3.4 9h-2.51l-.58-1.61z"/>
-          <path fill="#304FFE" d="M33.52 21l3.65-9h-2.42l-2.5 5.82L30.5 12h-1.86l-1.9 5.82-1.35-2.65-1.21 3.72L25.4 21h2.38l1.72-5.2 1.64 5.2z"/>
-          <path fill="#3D3D3D" fill-rule="nonzero" d="M20.3 17.91h1.48c.45 0 .85-.05 1.2-.15l.39-1.18 1.07-3.3a2.64 2.64 0 0 0-.28-.37c-.55-.6-1.36-.91-2.42-.91H18v9h2.3V17.9zm1.96-3.84c.22.22.33.5.33.87 0 .36-.1.65-.29.87-.2.23-.59.35-1.15.35h-.86v-2.41h.87c.52 0 .89.1 1.1.32z"/>
+          <path class="lh-gauge--pwa__logo--secondary-color" d="M35.66 19.39l.7-1.75h2L37.4 15 38.6 12l3.4 9h-2.51l-.58-1.61z"/>
+          <path class="lh-gauge--pwa__logo--primary-color" d="M33.52 21l3.65-9h-2.42l-2.5 5.82L30.5 12h-1.86l-1.9 5.82-1.35-2.65-1.21 3.72L25.4 21h2.38l1.72-5.2 1.64 5.2z"/>
+          <path class="lh-gauge--pwa__logo--secondary-color" fill-rule="nonzero" d="M20.3 17.91h1.48c.45 0 .85-.05 1.2-.15l.39-1.18 1.07-3.3a2.64 2.64 0 0 0-.28-.37c-.55-.6-1.36-.91-2.42-.91H18v9h2.3V17.9zm1.96-3.84c.22.22.33.5.33.87 0 .36-.1.65-.29.87-.2.23-.59.35-1.15.35h-.86v-2.41h.87c.52 0 .89.1 1.1.32z"/>
         </g>
 
         <!-- No badges. -->
diff --git a/lighthouse-core/runner.js b/lighthouse-core/runner.js
index 1cbbf12ce..1504607bc 100644
--- a/lighthouse-core/runner.js
+++ b/lighthouse-core/runner.js
@@ -234,6 +234,7 @@ class Runner {
         gatherMode: undefined,
         auditMode: undefined,
         output: undefined,
+        budgets: undefined,
       };
       const normalizedGatherSettings = Object.assign({}, artifacts.settings, overrides);
       const normalizedAuditSettings = Object.assign({}, settings, overrides);
diff --git a/lighthouse-core/scripts/update-report-fixtures.js b/lighthouse-core/scripts/update-report-fixtures.js
index 8aabdc464..39b8b689b 100644
--- a/lighthouse-core/scripts/update-report-fixtures.js
+++ b/lighthouse-core/scripts/update-report-fixtures.js
@@ -25,7 +25,12 @@ async function update() {
   }));
 
   const url = `http://localhost:${port}/dobetterweb/dbw_tester.html`;
-  const flags = cliFlags.getFlags(`--gather-mode=lighthouse-core/test/results/artifacts ${url}`);
+  const rawFlags = [
+    '--gather-mode=lighthouse-core/test/results/artifacts',
+    '--throttling-method=devtools',
+    url,
+  ].join(' ');
+  const flags = cliFlags.getFlags(rawFlags);
   await cli.runLighthouse(url, flags, undefined);
   await new Promise(res => server.close(res));
 }
diff --git a/package.json b/package.json
index f97637f73..a08836112 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
     "type-check": "tsc -p . && tsc -p lighthouse-viewer/",
     "i18n:checks": "./lighthouse-core/scripts/i18n/assert-strings-collected.sh",
     "i18n:collect-strings": "node lighthouse-core/scripts/i18n/collect-strings.js",
-    "update:sample-artifacts": "node lighthouse-core/scripts/update-report-fixtures.js -G",
+    "update:sample-artifacts": "node lighthouse-core/scripts/update-report-fixtures.js",
     "update:sample-json": "yarn i18n:collect-strings && node ./lighthouse-cli -A=./lighthouse-core/test/results/artifacts --throttling-method=devtools --output=json --output-path=./lighthouse-core/test/results/sample_v2.json && node lighthouse-core/scripts/cleanup-LHR-for-diff.js ./lighthouse-core/test/results/sample_v2.json --only-remove-timing && yarn compile-proto && yarn build-proto-roundtrip",
     "diff:sample-json": "yarn i18n:checks && bash lighthouse-core/scripts/assert-golden-lhr-unchanged.sh",
     "ultradumbBenchmark": "./lighthouse-core/scripts/benchmark.js",
diff --git a/proto/lighthouse-result.proto b/proto/lighthouse-result.proto
index 76e9dfc90..1216b94dd 100644
--- a/proto/lighthouse-result.proto
+++ b/proto/lighthouse-result.proto
@@ -161,6 +161,9 @@ message LighthouseResult {
       string entry_type = 2;
       google.protobuf.DoubleValue start_time = 3;
       google.protobuf.DoubleValue duration = 4;
+
+      // Whether timing entry was collected during artifact gathering.
+      bool gather = 5;
     }
 
     // The total duration of Lighthouse's run
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 215f93705..d7fecdafd 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -461,7 +461,6 @@
                 "type": "debugdata"
             }, 
             "id": "diagnostics", 
-            "numericValue": 1.0, 
             "score": null, 
             "scoreDisplayMode": "informative", 
             "title": "Diagnostics"
@@ -3653,11 +3652,10 @@
             "warningAuditsGroupTitle": "Passed audits but with warnings", 
             "warningHeader": "Warnings: "
         }
-    },
+    }, 
     "lighthouseVersion": "4.3.0", 
     "requestedUrl": "http://localhost:10200/dobetterweb/dbw_tester.html", 
-    "runWarnings": [],
-    "stackPacks": [],
+    "runWarnings": [], 
     "timing": {
         "entries": [
             {
diff --git a/types/audit.d.ts b/types/audit.d.ts
index 9be3f533d..05ecf3d85 100644
--- a/types/audit.d.ts
+++ b/types/audit.d.ts
@@ -81,7 +81,7 @@ declare global {
       /** Overrides scoreDisplayMode with notApplicable if set to true */
       notApplicable?: boolean;
       /** A numeric value that has a meaning specific to the audit, e.g. the number of nodes in the DOM or the timestamp of a specific load event. More information can be found in the audit details, if present. */
-      rawValue?: number;
+      numericValue?: number;
       /** Extra information about the page provided by some types of audits, in one of several possible forms that can be rendered in the HTML report. */
       details?: Audit.Details;
     }
diff --git a/types/budget.d.ts b/types/budget.d.ts
new file mode 100644
index 000000000..b1da9e8de
--- /dev/null
+++ b/types/budget.d.ts
@@ -0,0 +1,49 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * 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.
+ */
+
+declare global {
+  module LH {
+    /**
+     * The performance budget interface.
+     * More info: https://github.com/GoogleChrome/lighthouse/issues/6053#issuecomment-428385930
+     */
+    export interface Budget {
+      /** Budgets based on resource count. */
+      resourceCounts?: Array<Budget.ResourceBudget>;
+      /** Budgets based on resource size. */
+      resourceSizes?: Array<Budget.ResourceBudget>;
+      /** Budgets based on timing metrics. */
+      timings?: Array<Budget.TimingBudget> ;
+    }
+
+    module Budget {
+      export interface ResourceBudget {
+        /** The resource type that a budget applies to. */
+        resourceType: ResourceType;
+        /** Budget for resource. Depending on context, this is either the count or size (KB) of a resource. */
+        budget: number;
+      }
+
+      export interface TimingBudget {
+        /** The type of timing metric. */
+        metric: TimingMetric;
+        /** Budget for timing measurement, in milliseconds. */
+        budget: number;
+        /** Tolerance, i.e. buffer, to apply to a timing budget. Units: milliseconds. */
+        tolerance?: number;
+      }
+
+      /** Supported timing metrics. */
+      export type TimingMetric = 'first-contentful-paint' | 'first-cpu-idle' | 'interactive' | 'first-meaningful-paint' | 'estimated-input-latency';
+
+      /** Supported resource types. */
+      export type ResourceType = 'stylesheet' | 'image' | 'media' | 'font' | 'script' | 'document' | 'other';
+    }
+  }
+}
+
+// empty export to keep file a module
+export {}
diff --git a/types/config.d.ts b/types/config.d.ts
index 3ba80f0c7..88c4c89a5 100644
--- a/types/config.d.ts
+++ b/types/config.d.ts
@@ -15,7 +15,7 @@ declare global {
        */
       export interface Json {
         extends?: 'lighthouse:default' | 'lighthouse:full' | string | boolean;
-        settings?: SettingsJson;
+        settings?: SharedFlagsSettings;
         passes?: PassJson[] | null;
         audits?: Config.AuditJson[] | null;
         categories?: Record<string, CategoryJson> | null;
@@ -23,10 +23,6 @@ declare global {
         plugins?: Array<string>,
       }
 
-      export interface SettingsJson extends SharedFlagsSettings {
-        extraHeaders?: Crdp.Network.Headers | null;
-      }
-
       export interface PassJson {
         passName: string;
         recordTrace?: boolean;
@@ -80,7 +76,7 @@ declare global {
         group?: string;
       }
 
-      export interface Settings extends Required<SettingsJson> {
+      export interface Settings extends Required<SharedFlagsSettings> {
         throttling: Required<ThrottlingSettings>;
       }
 
diff --git a/types/externs.d.ts b/types/externs.d.ts
index 8de82bbb0..0b7780e13 100644
--- a/types/externs.d.ts
+++ b/types/externs.d.ts
@@ -119,6 +119,8 @@ declare global {
       channel?: string
       /** Precomputed lantern estimates to use instead of observed analysis. */
       precomputedLanternData?: PrecomputedLanternData | null;
+      /** The budget.json object for LightWallet. */
+      budgets?: Array<Budget> | null;
     }
 
     /**
@@ -169,6 +171,8 @@ declare global {
       precomputedLanternDataPath?: string;
       /** Path to the file where precomputed lantern data should be written to. */
       lanternDataOutputPath?: string;
+      /** Path to the budget.json file for LightWallet. */
+      budgetPath?: string | null;
 
       // The following are given defaults in cli-flags, so are not optional like in Flags or SharedFlagsSettings.
       output: OutputMode[];
diff --git a/types/html-renderer.d.ts b/types/html-renderer.d.ts
index de49d00d1..512329309 100644
--- a/types/html-renderer.d.ts
+++ b/types/html-renderer.d.ts
@@ -59,7 +59,8 @@ declare global {
       }
 
       export interface AuditRef extends Result.AuditRef {
-        result: Audit.Result & { stackPacks?: StackPackDescription[] }
+        result: Audit.Result;
+        stackPacks?: StackPackDescription[];
       }
 
       export interface StackPackDescription {
diff --git a/types/lhr.d.ts b/types/lhr.d.ts
index f78312b5c..28c60b9ed 100644
--- a/types/lhr.d.ts
+++ b/types/lhr.d.ts
@@ -61,7 +61,7 @@ declare global {
       /** The record of all formatted string locations in the LHR and their corresponding source values. */
       i18n: {rendererFormattedStrings: I18NRendererStrings, icuMessagePaths: I18NMessages};
       /** An array containing the result of all stack packs. */
-      stackPacks: Result.StackPack[];
+      stackPacks?: Result.StackPack[];
     }
 
     // Result namespace


Test patch:
diff --git a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
index 593c3c48d..dd4636eb9 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -1198,6 +1198,7 @@ Object {
     "additionalTraceCategories": null,
     "auditMode": false,
     "blockedUrlPatterns": null,
+    "budgets": null,
     "channel": "cli",
     "disableStorageReset": false,
     "emulatedFormFactor": "mobile",
@@ -1327,6 +1328,7 @@ Object {
     "additionalTraceCategories": null,
     "auditMode": true,
     "blockedUrlPatterns": null,
+    "budgets": null,
     "channel": "cli",
     "disableStorageReset": false,
     "emulatedFormFactor": "mobile",
diff --git a/lighthouse-cli/test/cli/bin-test.js b/lighthouse-cli/test/cli/bin-test.js
index 713d0172c..d6b3ce96b 100644
--- a/lighthouse-cli/test/cli/bin-test.js
+++ b/lighthouse-cli/test/cli/bin-test.js
@@ -99,6 +99,17 @@ describe('CLI bin', function() {
     });
   });
 
+  describe('budget', () => {
+    it('should load the config from the path', async () => {
+      const budgetPath = '../../../lighthouse-core/test/fixtures/simple-budget.json';
+      cliFlags = {...cliFlags, budgetPath: require.resolve(budgetPath)};
+      const budgetFile = require(budgetPath);
+      await bin.begin();
+
+      expect(getRunLighthouseArgs()[1].budgets).toEqual(budgetFile);
+    });
+  });
+
   describe('logging', () => {
     it('should have info by default', async () => {
       await bin.begin();
diff --git a/lighthouse-core/test/audits/bootup-time-test.js b/lighthouse-core/test/audits/bootup-time-test.js
index a91cb3be5..5365626bc 100644
--- a/lighthouse-core/test/audits/bootup-time-test.js
+++ b/lighthouse-core/test/audits/bootup-time-test.js
@@ -37,7 +37,7 @@ describe('Performance: bootup-time audit', () => {
       assert.deepEqual(roundedValueOf(output, 'https://www.google-analytics.com/analytics.js'), {scripting: 40.6, scriptParseCompile: 9.6, total: 53.4});
       assert.deepEqual(roundedValueOf(output, 'Other'), {scripting: 11.7, scriptParseCompile: 0, total: 1123.8}); // eslint-disable-line max-len
 
-      assert.equal(Math.round(output.rawValue), 225);
+      assert.equal(Math.round(output.numericValue), 225);
       assert.equal(output.details.items.length, 5);
       assert.equal(output.score, 1);
     });
@@ -58,7 +58,7 @@ describe('Performance: bootup-time audit', () => {
 
     assert.equal(output.details.items.length, 8);
     assert.equal(output.score, 0.98);
-    assert.equal(Math.round(output.rawValue), 720);
+    assert.equal(Math.round(output.numericValue), 720);
   });
 
   it('should get no data when no events are present', () => {
@@ -72,7 +72,7 @@ describe('Performance: bootup-time audit', () => {
       .then(output => {
         assert.equal(output.details.items.length, 0);
         assert.equal(output.score, 1);
-        assert.equal(Math.round(output.rawValue), 0);
+        assert.equal(Math.round(output.numericValue), 0);
       });
   });
 });
diff --git a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
index d000649f6..1dd1369af 100644
--- a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js
@@ -88,7 +88,7 @@ describe('Byte efficiency base audit', () => {
     assert.deepEqual(result.details.items, []);
   });
 
-  it('should set the rawValue', () => {
+  it('should set the numericValue', () => {
     const result = ByteEfficiencyAudit.createAuditProduct(
       {
         headings: baseHeadings,
@@ -101,7 +101,7 @@ describe('Byte efficiency base audit', () => {
     );
 
     // 900ms savings comes from the graph calculation
-    assert.equal(result.rawValue, 900);
+    assert.equal(result.numericValue, 900);
   });
 
   it('should score the wastedMs', () => {
@@ -200,7 +200,7 @@ describe('Byte efficiency base audit', () => {
       simulator
     );
 
-    assert.equal(result.rawValue, 300);
+    assert.equal(result.numericValue, 300);
   });
 
   it('should create load simulator with the specified settings', async () => {
@@ -224,14 +224,14 @@ describe('Byte efficiency base audit', () => {
     let settings = {throttlingMethod: 'simulate', throttling: modestThrottling};
     let result = await MockAudit.audit(artifacts, {settings, computedCache});
     // expect modest savings
-    expect(result.rawValue).toBeLessThan(5000);
-    expect(result.rawValue).toMatchSnapshot();
+    expect(result.numericValue).toBeLessThan(5000);
+    expect(result.numericValue).toMatchSnapshot();
 
     settings = {throttlingMethod: 'simulate', throttling: ultraSlowThrottling};
     result = await MockAudit.audit(artifacts, {settings, computedCache});
     // expect lots of savings
-    expect(result.rawValue).not.toBeLessThan(5000);
-    expect(result.rawValue).toMatchSnapshot();
+    expect(result.numericValue).not.toBeLessThan(5000);
+    expect(result.numericValue).toMatchSnapshot();
   });
 
   it('should allow overriding of computeWasteWithTTIGraph', async () => {
@@ -262,7 +262,7 @@ describe('Byte efficiency base audit', () => {
     const result = await MockAudit.audit(artifacts, {settings, computedCache});
     const resultTti = await MockJustTTIAudit.audit(artifacts, {settings, computedCache});
     // expect less savings with just TTI
-    expect(resultTti.rawValue).toBeLessThan(result.rawValue);
-    expect({default: result.rawValue, justTTI: resultTti.rawValue}).toMatchSnapshot();
+    expect(resultTti.numericValue).toBeLessThan(result.numericValue);
+    expect({default: result.numericValue, justTTI: resultTti.numericValue}).toMatchSnapshot();
   });
 });
diff --git a/lighthouse-core/test/audits/byte-efficiency/render-blocking-resources-test.js b/lighthouse-core/test/audits/byte-efficiency/render-blocking-resources-test.js
index d0b256e7b..d1fd48b27 100644
--- a/lighthouse-core/test/audits/byte-efficiency/render-blocking-resources-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/render-blocking-resources-test.js
@@ -36,7 +36,7 @@ describe('Render blocking resources audit', () => {
     const computedCache = new Map();
     const result = await RenderBlockingResourcesAudit.audit(artifacts, {settings, computedCache});
     assert.equal(result.score, 1);
-    assert.equal(result.rawValue, 0);
+    assert.equal(result.numericValue, 0);
   });
 
   describe('#estimateSavingsWithGraphs', () => {
diff --git a/lighthouse-core/test/audits/byte-efficiency/total-byte-weight-test.js b/lighthouse-core/test/audits/byte-efficiency/total-byte-weight-test.js
index 90afd4e46..2fe5e7e91 100644
--- a/lighthouse-core/test/audits/byte-efficiency/total-byte-weight-test.js
+++ b/lighthouse-core/test/audits/byte-efficiency/total-byte-weight-test.js
@@ -47,7 +47,7 @@ describe('Total byte weight audit', () => {
     ]);
 
     return TotalByteWeight.audit(artifacts, {options, computedCache: new Map()}).then(result => {
-      assert.strictEqual(result.rawValue, 150 * 1024);
+      assert.strictEqual(result.numericValue, 150 * 1024);
       assert.strictEqual(result.score, 1);
       const results = result.details.items;
       assert.strictEqual(results.length, 3);
@@ -73,7 +73,7 @@ describe('Total byte weight audit', () => {
 
     return TotalByteWeight.audit(artifacts, {options, computedCache: new Map()}).then(result => {
       assert.ok(0.40 < result.score && result.score < 0.6, 'score is around 0.5');
-      assert.strictEqual(result.rawValue, 4180 * 1024);
+      assert.strictEqual(result.numericValue, 4180 * 1024);
       const results = result.details.items;
       assert.strictEqual(results.length, 10, 'results are clipped at top 10');
       assert.strictEqual(result.extendedInfo.value.totalCompletedRequests, 11);
@@ -88,7 +88,7 @@ describe('Total byte weight audit', () => {
     ]);
 
     return TotalByteWeight.audit(artifacts, {options, computedCache: new Map()}).then(result => {
-      assert.strictEqual(result.rawValue, 15000 * 1024);
+      assert.strictEqual(result.numericValue, 15000 * 1024);
       assert.strictEqual(result.score, 0);
     });
   });
diff --git a/lighthouse-core/test/audits/dobetterweb/dom-size-test.js b/lighthouse-core/test/audits/dobetterweb/dom-size-test.js
index 152a6eb41..509568c44 100644
--- a/lighthouse-core/test/audits/dobetterweb/dom-size-test.js
+++ b/lighthouse-core/test/audits/dobetterweb/dom-size-test.js
@@ -24,7 +24,7 @@ describe('DOMSize audit', () => {
   it('calculates score hitting mid distribution', () => {
     const auditResult = DOMSize.audit(artifact, {options});
     assert.equal(auditResult.score, 0.43);
-    assert.equal(auditResult.rawValue, numElements);
+    assert.equal(auditResult.numericValue, numElements);
     expect(auditResult.displayValue).toBeDisplayString('1,500 elements');
     assert.equal(auditResult.details.items[0].value, numElements.toLocaleString());
     assert.equal(auditResult.details.items[1].value, '1');
diff --git a/lighthouse-core/test/audits/errors-in-console-test.js b/lighthouse-core/test/audits/errors-in-console-test.js
index 6382a9d46..482d52a0d 100644
--- a/lighthouse-core/test/audits/errors-in-console-test.js
+++ b/lighthouse-core/test/audits/errors-in-console-test.js
@@ -16,7 +16,7 @@ describe('Console error logs audit', () => {
       ConsoleMessages: [],
       RuntimeExceptions: [],
     });
-    assert.equal(auditResult.rawValue, 0);
+    assert.equal(auditResult.numericValue, 0);
     assert.equal(auditResult.score, 1);
     assert.ok(!auditResult.displayValue, 0);
     assert.equal(auditResult.details.items.length, 0);
@@ -35,7 +35,7 @@ describe('Console error logs audit', () => {
       ],
       RuntimeExceptions: [],
     });
-    assert.equal(auditResult.rawValue, 0);
+    assert.equal(auditResult.numericValue, 0);
     assert.equal(auditResult.score, 1);
     assert.equal(auditResult.details.items.length, 0);
   });
@@ -81,7 +81,7 @@ describe('Console error logs audit', () => {
       }],
     });
 
-    assert.equal(auditResult.rawValue, 3);
+    assert.equal(auditResult.numericValue, 3);
     assert.equal(auditResult.score, 0);
     assert.equal(auditResult.details.items.length, 3);
     assert.equal(auditResult.details.items[0].url, 'http://www.example.com/favicon.ico');
@@ -107,7 +107,7 @@ describe('Console error logs audit', () => {
       ],
       RuntimeExceptions: [],
     });
-    assert.equal(auditResult.rawValue, 1);
+    assert.equal(auditResult.numericValue, 1);
     assert.equal(auditResult.score, 0);
     assert.equal(auditResult.details.items.length, 1);
     // url is undefined
@@ -138,7 +138,7 @@ describe('Console error logs audit', () => {
         },
       }],
     });
-    assert.equal(auditResult.rawValue, 1);
+    assert.equal(auditResult.numericValue, 1);
     assert.equal(auditResult.score, 0);
     assert.equal(auditResult.details.items.length, 1);
     assert.strictEqual(auditResult.details.items[0].url, 'http://example.com/fancybox.js');
diff --git a/lighthouse-core/test/audits/font-display-test.js b/lighthouse-core/test/audits/font-display-test.js
index 7d3a22881..36b674050 100644
--- a/lighthouse-core/test/audits/font-display-test.js
+++ b/lighthouse-core/test/audits/font-display-test.js
@@ -261,4 +261,25 @@ describe('Performance: Font Display audit', () => {
     expect(result.details.items).toEqual([]);
     assert.strictEqual(result.score, 1);
   });
+
+  it('handles custom source URLs from sourcemaps', async () => {
+    // Make sure we don't use sourceURL when it's not a valid URL, see https://github.com/GoogleChrome/lighthouse/issues/8534
+    stylesheet.header.sourceURL = 'custom-url-from-source-map';
+    stylesheet.content = `
+      @font-face {
+        src: url(font-0.woff);
+        font-display: swap
+      }
+    `;
+
+    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([]);
+    assert.strictEqual(result.score, 1);
+  });
 });
diff --git a/lighthouse-core/test/audits/load-fast-enough-for-pwa-test.js b/lighthouse-core/test/audits/load-fast-enough-for-pwa-test.js
index 90d2eceab..4b2ac5211 100644
--- a/lighthouse-core/test/audits/load-fast-enough-for-pwa-test.js
+++ b/lighthouse-core/test/audits/load-fast-enough-for-pwa-test.js
@@ -26,7 +26,7 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
     const settings = {throttlingMethod: 'devtools', throttling: mobileSlow4GThrottling};
     return FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()}).then(result => {
       assert.equal(result.score, true, 'fixture trace is not passing audit');
-      assert.equal(result.rawValue, 1582.189);
+      assert.equal(result.numericValue, 1582.189);
     });
   });
 
@@ -50,7 +50,7 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
     const settings = {throttlingMethod: 'devtools', throttling: mobileSlow4GThrottling};
     return FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()}).then(result => {
       assert.equal(result.score, false, 'not failing a long TTI value');
-      assert.equal(result.rawValue, 15000);
+      assert.equal(result.numericValue, 15000);
       expect(result.displayValue).toBeDisplayString('Interactive at 15.0\xa0s');
       assert.ok(result.explanation);
     });
@@ -64,7 +64,7 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
 
     const settings = {throttlingMethod: 'devtools', throttling: mobileSlow4GThrottling};
     const result = await FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()});
-    assert.equal(Math.round(result.rawValue), 1582);
+    assert.equal(Math.round(result.numericValue), 1582);
   });
 
   it('overrides with simulated result when throttling is modified', async () => {
@@ -75,8 +75,8 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
 
     const settings = {throttlingMethod: 'provided', throttling: {rttMs: 40, throughput: 100000}};
     const result = await FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()});
-    expect(result.rawValue).toBeGreaterThan(2000); // If not overridden this would be 1582
-    expect(Math.round(result.rawValue)).toMatchSnapshot();
+    expect(result.numericValue).toBeGreaterThan(2000); // If not overridden this would be 1582
+    expect(Math.round(result.numericValue)).toMatchSnapshot();
   });
 
   it('overrides when throttling is modified but method is not "provided"', async () => {
@@ -87,7 +87,7 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
 
     const settings = {throttlingMethod: 'devtools', throttling: {rttMs: 40, throughput: 100000}};
     const result = await FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()});
-    expect(result.rawValue).toBeGreaterThan(2000); // If not overridden this would be 1582
+    expect(result.numericValue).toBeGreaterThan(2000); // If not overridden this would be 1582
   });
 
   it('overrides when throttling is "provided" and fails the simulated TTI value', async () => {
@@ -110,6 +110,6 @@ describe('PWA: load-fast-enough-for-pwa audit', () => {
     const result = await FastPWAAudit.audit(artifacts, {settings, computedCache: new Map()});
     expect(result.displayValue)
       .toBeDisplayString('Interactive on simulated mobile network at 24.9\xa0s');
-    expect(result.rawValue).toBeGreaterThan(10000);
+    expect(result.numericValue).toBeGreaterThan(10000);
   });
 });
diff --git a/lighthouse-core/test/audits/mainthread-work-breakdown-test.js b/lighthouse-core/test/audits/mainthread-work-breakdown-test.js
index 4710b4ac1..e3f5e473d 100644
--- a/lighthouse-core/test/audits/mainthread-work-breakdown-test.js
+++ b/lighthouse-core/test/audits/mainthread-work-breakdown-test.js
@@ -56,7 +56,7 @@ describe('Performance: page execution timings audit', () => {
 
     const output = await PageExecutionTimings.audit(artifacts, {options, computedCache: new Map()});
     assert.deepStrictEqual(keyOutput(output), acceptableTraceExpectations);
-    assert.equal(Math.round(output.rawValue), 1360);
+    assert.equal(Math.round(output.numericValue), 1360);
     assert.equal(output.details.items.length, 7);
     assert.equal(output.score, 0.98);
   });
@@ -75,7 +75,7 @@ describe('Performance: page execution timings audit', () => {
       assert.ok(Math.abs(actual - expected) <= 2, `expected ${expected} got ${actual}`);
     }
 
-    assert.equal(Math.round(output.rawValue), 4081);
+    assert.equal(Math.round(output.numericValue), 4081);
     assert.equal(output.details.items.length, 7);
     assert.equal(output.score, 0.49);
   });
@@ -85,7 +85,7 @@ describe('Performance: page execution timings audit', () => {
 
     const output = await PageExecutionTimings.audit(artifacts, {options, computedCache: new Map()});
     assert.deepStrictEqual(keyOutput(output), siteWithRedirectTraceExpectations);
-    assert.equal(Math.round(output.rawValue), 784);
+    assert.equal(Math.round(output.numericValue), 784);
     assert.equal(output.details.items.length, 7);
     assert.equal(output.score, 1);
   });
@@ -95,7 +95,7 @@ describe('Performance: page execution timings audit', () => {
 
     const output = await PageExecutionTimings.audit(artifacts, {options, computedCache: new Map()});
     assert.deepStrictEqual(keyOutput(output), loadTraceExpectations);
-    assert.equal(Math.round(output.rawValue), 933);
+    assert.equal(Math.round(output.numericValue), 933);
     assert.equal(output.details.items.length, 6);
     assert.equal(output.score, 1);
   });
@@ -107,7 +107,7 @@ describe('Performance: page execution timings audit', () => {
     return PageExecutionTimings.audit(artifacts, context).then(output => {
       assert.equal(output.details.items.length, 0);
       assert.equal(output.score, 1);
-      assert.equal(Math.round(output.rawValue), 0);
+      assert.equal(Math.round(output.numericValue), 0);
     });
   });
 });
diff --git a/lighthouse-core/test/audits/metrics/__snapshots__/first-meaningful-paint-test.js.snap b/lighthouse-core/test/audits/metrics/__snapshots__/first-meaningful-paint-test.js.snap
index 4ca3dd6f7..ab837653f 100644
--- a/lighthouse-core/test/audits/metrics/__snapshots__/first-meaningful-paint-test.js.snap
+++ b/lighthouse-core/test/audits/metrics/__snapshots__/first-meaningful-paint-test.js.snap
@@ -2,7 +2,7 @@
 
 exports[`Performance: first-meaningful-paint audit computes FMP correctly for simulated 1`] = `
 Object {
-  "rawValue": 1510.6100000208241,
+  "numericValue": 1510.6100000208241,
   "score": 0.99,
 }
 `;
diff --git a/lighthouse-core/test/audits/metrics/estimated-input-latency-test.js b/lighthouse-core/test/audits/metrics/estimated-input-latency-test.js
index f52772738..d51d9e043 100644
--- a/lighthouse-core/test/audits/metrics/estimated-input-latency-test.js
+++ b/lighthouse-core/test/audits/metrics/estimated-input-latency-test.js
@@ -25,7 +25,7 @@ describe('Performance: estimated-input-latency audit', () => {
     const settings = {throttlingMethod: 'provided'};
     const context = {options, settings, computedCache: new Map()};
     return Audit.audit(artifacts, context).then(output => {
-      assert.equal(Math.round(output.rawValue * 10) / 10, 17.1);
+      assert.equal(Math.round(output.numericValue * 10) / 10, 17.1);
       assert.equal(output.score, 1);
       expect(output.displayValue).toBeDisplayString('20\xa0ms');
     });
diff --git a/lighthouse-core/test/audits/metrics/first-contentful-paint-3g-test.js b/lighthouse-core/test/audits/metrics/first-contentful-paint-3g-test.js
index b231a304d..120d29287 100644
--- a/lighthouse-core/test/audits/metrics/first-contentful-paint-3g-test.js
+++ b/lighthouse-core/test/audits/metrics/first-contentful-paint-3g-test.js
@@ -26,7 +26,7 @@ describe('Performance: first-contentful-paint-3g audit', () => {
 
     const result = await FCP3G.audit(artifacts, {settings: {}, options, computedCache: new Map()});
     // Use InlineSnapshot here so changes to Lantern coefficients can be easily updated en masse
-    expect({score: result.score, value: Math.round(result.rawValue)}).toMatchInlineSnapshot(`
+    expect({score: result.score, value: Math.round(result.numericValue)}).toMatchInlineSnapshot(`
 Object {
   "score": 0.99,
   "value": 2057,
diff --git a/lighthouse-core/test/audits/metrics/first-contentful-paint-test.js b/lighthouse-core/test/audits/metrics/first-contentful-paint-test.js
index ee3ee168c..7be3496bb 100644
--- a/lighthouse-core/test/audits/metrics/first-contentful-paint-test.js
+++ b/lighthouse-core/test/audits/metrics/first-contentful-paint-test.js
@@ -27,6 +27,6 @@ describe('Performance: first-contentful-paint audit', () => {
     const settings = {throttlingMethod: 'provided'};
     const result = await Audit.audit(artifacts, {settings, options, computedCache: new Map()});
     assert.equal(result.score, 1);
-    assert.equal(result.rawValue, 498.87);
+    assert.equal(result.numericValue, 498.87);
   });
 });
diff --git a/lighthouse-core/test/audits/metrics/first-meaningful-paint-test.js b/lighthouse-core/test/audits/metrics/first-meaningful-paint-test.js
index b3862b65c..61a416e72 100644
--- a/lighthouse-core/test/audits/metrics/first-meaningful-paint-test.js
+++ b/lighthouse-core/test/audits/metrics/first-meaningful-paint-test.js
@@ -23,7 +23,7 @@ describe('Performance: first-meaningful-paint audit', () => {
     const fmpResult = await FMPAudit.audit(artifacts, context);
 
     assert.equal(fmpResult.score, 1);
-    assert.equal(fmpResult.rawValue, 783.328);
+    assert.equal(fmpResult.numericValue, 783.328);
     expect(fmpResult.displayValue).toBeDisplayString('0.8\xa0s');
   });
 
@@ -37,7 +37,7 @@ describe('Performance: first-meaningful-paint audit', () => {
 
     expect({
       score: fmpResult.score,
-      rawValue: fmpResult.rawValue,
+      numericValue: fmpResult.numericValue,
     }).toMatchSnapshot();
   });
 });
diff --git a/lighthouse-core/test/audits/metrics/interactive-test.js b/lighthouse-core/test/audits/metrics/interactive-test.js
index 20fcef213..40efb636e 100644
--- a/lighthouse-core/test/audits/metrics/interactive-test.js
+++ b/lighthouse-core/test/audits/metrics/interactive-test.js
@@ -32,7 +32,7 @@ describe('Performance: interactive audit', () => {
     const context = {options, settings: {throttlingMethod: 'provided'}, computedCache: new Map()};
     return Interactive.audit(artifacts, context).then(output => {
       assert.equal(output.score, 1);
-      assert.equal(Math.round(output.rawValue), 1582);
+      assert.equal(Math.round(output.numericValue), 1582);
       expect(output.displayValue).toBeDisplayString('1.6\xa0s');
     });
   });
@@ -50,7 +50,7 @@ describe('Performance: interactive audit', () => {
     const context = {options, settings: {throttlingMethod: 'provided'}, computedCache: new Map()};
     return Interactive.audit(artifacts, context).then(output => {
       assert.equal(output.score, 0.97);
-      assert.equal(Math.round(output.rawValue), 2712);
+      assert.equal(Math.round(output.numericValue), 2712);
       expect(output.displayValue).toBeDisplayString('2.7\xa0s');
     });
   });
diff --git a/lighthouse-core/test/audits/metrics/speed-index-test.js b/lighthouse-core/test/audits/metrics/speed-index-test.js
index 199485036..48d6e7a91 100644
--- a/lighthouse-core/test/audits/metrics/speed-index-test.js
+++ b/lighthouse-core/test/audits/metrics/speed-index-test.js
@@ -24,7 +24,7 @@ describe('Performance: speed-index audit', () => {
     const settings = {throttlingMethod: 'provided'};
     return Audit.audit(artifacts, {options, settings, computedCache: new Map()}).then(result => {
       assert.equal(result.score, 1);
-      assert.equal(result.rawValue, 605);
+      assert.equal(result.numericValue, 605);
     });
   }, 10000);
 });
diff --git a/lighthouse-core/test/audits/network-requests-test.js b/lighthouse-core/test/audits/network-requests-test.js
index 6703d78e7..da967a2f5 100644
--- a/lighthouse-core/test/audits/network-requests-test.js
+++ b/lighthouse-core/test/audits/network-requests-test.js
@@ -22,7 +22,7 @@ describe('Network requests audit', () => {
 
     return NetworkRequests.audit(artifacts, {computedCache: new Map()}).then(output => {
       assert.equal(output.score, 1);
-      assert.equal(output.rawValue, 66);
+      assert.equal(output.numericValue, 66);
       assert.equal(output.details.items.length, 66);
       assert.equal(output.details.items[0].url, 'https://pwa.rocks/');
       assert.equal(output.details.items[0].startTime, 0);
diff --git a/lighthouse-core/test/audits/redirects-test.js b/lighthouse-core/test/audits/redirects-test.js
index 1bc6ded0e..94dfec03e 100644
--- a/lighthouse-core/test/audits/redirects-test.js
+++ b/lighthouse-core/test/audits/redirects-test.js
@@ -97,7 +97,7 @@ describe('Performance: Redirects audit', () => {
     return RedirectsAudit.audit(artifacts, context).then(output => {
       assert.equal(Math.round(output.score * 100) / 100, 0.37);
       assert.equal(output.details.items.length, 4);
-      assert.equal(output.rawValue, 1890);
+      assert.equal(output.numericValue, 1890);
     });
   });
 
@@ -107,7 +107,7 @@ describe('Performance: Redirects audit', () => {
     return RedirectsAudit.audit(artifacts, context).then(output => {
       assert.equal(Math.round(output.score * 100) / 100, 0.46);
       assert.equal(output.details.items.length, 3);
-      assert.equal(Math.round(output.rawValue), 1110);
+      assert.equal(Math.round(output.numericValue), 1110);
     });
   });
 
@@ -119,7 +119,7 @@ describe('Performance: Redirects audit', () => {
       assert.equal(output.score, 1);
       // We will still generate a table and show wasted time
       assert.equal(output.details.items.length, 2);
-      assert.equal(Math.round(output.rawValue), 780);
+      assert.equal(Math.round(output.numericValue), 780);
     });
   });
 
@@ -129,7 +129,7 @@ describe('Performance: Redirects audit', () => {
     return RedirectsAudit.audit(artifacts, context).then(output => {
       assert.equal(output.score, 1);
       assert.equal(output.details.items.length, 0);
-      assert.equal(output.rawValue, 0);
+      assert.equal(output.numericValue, 0);
     });
   });
 });
diff --git a/lighthouse-core/test/audits/time-to-first-byte-test.js b/lighthouse-core/test/audits/time-to-first-byte-test.js
index c0c8abd73..97b50ac17 100644
--- a/lighthouse-core/test/audits/time-to-first-byte-test.js
+++ b/lighthouse-core/test/audits/time-to-first-byte-test.js
@@ -25,7 +25,7 @@ describe('Performance: time-to-first-byte audit', () => {
     };
 
     return TimeToFirstByte.audit(artifacts, {computedCache: new Map()}).then(result => {
-      assert.strictEqual(result.rawValue, 630);
+      assert.strictEqual(result.numericValue, 630);
       assert.strictEqual(result.score, 0);
     });
   });
@@ -44,7 +44,7 @@ describe('Performance: time-to-first-byte audit', () => {
     };
 
     return TimeToFirstByte.audit(artifacts, {computedCache: new Map()}).then(result => {
-      assert.strictEqual(result.rawValue, 200);
+      assert.strictEqual(result.numericValue, 200);
       assert.strictEqual(result.score, 1);
     });
   });
diff --git a/lighthouse-core/test/audits/uses-rel-preconnect-test.js b/lighthouse-core/test/audits/uses-rel-preconnect-test.js
index 957e52508..07322ce44 100644
--- a/lighthouse-core/test/audits/uses-rel-preconnect-test.js
+++ b/lighthouse-core/test/audits/uses-rel-preconnect-test.js
@@ -36,9 +36,9 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, context);
+    const {score, numericValue, details} = await UsesRelPreconnect.audit(artifacts, context);
     assert.equal(score, 1);
-    assert.equal(rawValue, 0);
+    assert.equal(numericValue, 0);
     assert.equal(details.items.length, 0);
   });
 
@@ -61,9 +61,9 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, context);
+    const {score, numericValue, details} = await UsesRelPreconnect.audit(artifacts, context);
     assert.equal(score, 1);
-    assert.equal(rawValue, 0);
+    assert.equal(numericValue, 0);
     assert.equal(details.items.length, 0);
   });
 
@@ -83,9 +83,9 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, context);
+    const {score, numericValue, details} = await UsesRelPreconnect.audit(artifacts, context);
     assert.equal(score, 1);
-    assert.equal(rawValue, 0);
+    assert.equal(numericValue, 0);
     assert.equal(details.items.length, 0);
   });
 
@@ -111,9 +111,9 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, context);
+    const {score, numericValue, details} = await UsesRelPreconnect.audit(artifacts, context);
     assert.equal(score, 1);
-    assert.equal(rawValue, 0);
+    assert.equal(numericValue, 0);
     assert.equal(details.items.length, 0);
   });
 
@@ -134,9 +134,9 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {score, rawValue, details} = await UsesRelPreconnect.audit(artifacts, context);
+    const {score, numericValue, details} = await UsesRelPreconnect.audit(artifacts, context);
     assert.equal(score, 1);
-    assert.equal(rawValue, 0);
+    assert.equal(numericValue, 0);
     assert.equal(details.items.length, 0);
   });
 
@@ -201,8 +201,8 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {rawValue, extendedInfo} = await UsesRelPreconnect.audit(artifacts, context);
-    assert.equal(rawValue, 300);
+    const {numericValue, extendedInfo} = await UsesRelPreconnect.audit(artifacts, context);
+    assert.equal(numericValue, 300);
     assert.equal(extendedInfo.value.length, 1);
     assert.deepStrictEqual(extendedInfo.value, [
       {url: 'https://cdn.example.com', wastedMs: 300},
@@ -242,8 +242,8 @@ describe('Performance: uses-rel-preconnect audit', () => {
     };
 
     const context = {settings: {}, computedCache: new Map()};
-    const {rawValue, extendedInfo} = await UsesRelPreconnect.audit(artifacts, context);
-    assert.equal(rawValue, 300);
+    const {numericValue, extendedInfo} = await UsesRelPreconnect.audit(artifacts, context);
+    assert.equal(numericValue, 300);
     assert.equal(extendedInfo.value.length, 2);
     assert.deepStrictEqual(extendedInfo.value, [
       {url: 'https://othercdn.example.com', wastedMs: 300},
diff --git a/lighthouse-core/test/audits/uses-rel-preload-test.js b/lighthouse-core/test/audits/uses-rel-preload-test.js
index 6765a7370..c97fb5a78 100644
--- a/lighthouse-core/test/audits/uses-rel-preload-test.js
+++ b/lighthouse-core/test/audits/uses-rel-preload-test.js
@@ -205,7 +205,7 @@ describe('Performance: uses-rel-preload audit', () => {
     const artifacts = mockArtifacts(networkRecords, defaultMainResourceUrl);
     const context = {settings: {}, computedCache: new Map()};
     return UsesRelPreload.audit(artifacts, context).then(output => {
-      assert.equal(output.rawValue, 0);
+      assert.equal(output.numericValue, 0);
       assert.equal(output.details.items.length, 0);
     });
   });
@@ -217,7 +217,7 @@ describe('Performance: uses-rel-preload audit', () => {
     const artifacts = mockArtifacts(networkRecords, defaultMainResourceUrl);
     const context = {settings: {}, computedCache: new Map()};
     return UsesRelPreload.audit(artifacts, context).then(output => {
-      assert.equal(output.rawValue, 0);
+      assert.equal(output.numericValue, 0);
       assert.equal(output.details.items.length, 0);
     });
   });
@@ -237,6 +237,6 @@ describe('Performance: uses-rel-preload audit', () => {
     const context = {settings, computedCache: new Map()};
     const result = await UsesRelPreload.audit(artifacts, context);
     assert.equal(result.score, 1);
-    assert.equal(result.rawValue, 0);
+    assert.equal(result.numericValue, 0);
   });
 });
diff --git a/lighthouse-core/test/config/budget-test.js b/lighthouse-core/test/config/budget-test.js
new file mode 100644
index 000000000..7a56d587b
--- /dev/null
+++ b/lighthouse-core/test/config/budget-test.js
@@ -0,0 +1,128 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * 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.
+ */
+'use strict';
+
+const Budget = require('../../config/budget.js');
+const assert = require('assert');
+/* eslint-env jest */
+
+describe('Budget', () => {
+  let budget;
+  beforeEach(() => {
+    budget = [
+      {
+        resourceSizes: [
+          {
+            resourceType: 'script',
+            budget: 123,
+          },
+          {
+            resourceType: 'image',
+            budget: 456,
+          },
+        ],
+        resourceCounts: [
+          {
+            resourceType: 'total',
+            budget: 100,
+          },
+          {
+            resourceType: 'third-party',
+            budget: 10,
+          },
+        ],
+        timings: [
+          {
+            metric: 'interactive',
+            budget: 2000,
+            tolerance: 1000,
+          },
+          {
+            metric: 'first-contentful-paint',
+            budget: 1000,
+            tolerance: 500,
+          },
+        ],
+      },
+      {
+        resourceSizes: [
+          {
+            resourceType: 'script',
+            budget: 1000,
+          },
+        ],
+      },
+    ];
+  });
+
+  it('initializes correctly', () => {
+    const budgets = Budget.initializeBudget(budget);
+    assert.equal(budgets.length, 2);
+
+    // Sets resources sizes correctly
+    assert.equal(budgets[0].resourceSizes.length, 2);
+    assert.equal(budgets[0].resourceSizes[0].resourceType, 'script');
+    assert.equal(budgets[0].resourceSizes[0].budget, 123);
+
+    // Sets resource counts correctly
+    assert.equal(budgets[0].resourceCounts.length, 2);
+    assert.equal(budgets[0].resourceCounts[0].resourceType, 'total');
+    assert.equal(budgets[0].resourceCounts[0].budget, 100);
+
+    // Sets timings correctly
+    assert.equal(budgets[0].timings.length, 2);
+    assert.equal(budgets[0].timings[1].metric, 'first-contentful-paint');
+    assert.equal(budgets[0].timings[1].budget, 1000);
+    assert.equal(budgets[0].timings[1].tolerance, 500);
+
+    // Does not set unsupplied budgets
+    assert.equal(budgets[1].timings, null);
+  });
+
+  it('throws error if an unsupported budget property is used', () => {
+    budget[0].sizes = [];
+    assert.throws(_ => Budget.initializeBudget(budget), /[sizes]/);
+  });
+
+  describe('resource budget validation', () => {
+    it('throws when an invalid resource type is supplied', () => {
+      budget[0].resourceSizes[0].resourceType = 'movies';
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid resource type/);
+    });
+
+    it('throws when an invalid budget is supplied', () => {
+      budget[0].resourceSizes[0].budget = '100 MB';
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget/);
+    });
+
+    it('throws when an invalid property is supplied', () => {
+      budget[0].resourceSizes[0].browser = 'Chrome';
+      assert.throws(_ => Budget.initializeBudget(budget), /[browser]/);
+    });
+  });
+
+  describe('timing budget validation', () => {
+    it('throws when an invalid metric is supplied', () => {
+      budget[0].timings[0].metric = 'lastMeaningfulPaint';
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid timing metric/);
+    });
+
+    it('throws when an invalid budget is supplied', () => {
+      budget[0].timings[0].budget = '100KB';
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid budget/);
+    });
+
+    it('throws when an invalid tolerance is supplied', () => {
+      budget[0].timings[0].tolerance = '100ms';
+      assert.throws(_ => Budget.initializeBudget(budget), /Invalid tolerance/);
+    });
+
+    it('throws when an invalid property is supplied', () => {
+      budget[0].timings[0].device = 'Phone';
+      assert.throws(_ => Budget.initializeBudget(budget), /[device]/);
+    });
+  });
+});
diff --git a/lighthouse-core/test/config/config-test.js b/lighthouse-core/test/config/config-test.js
index efeefb736..c3c29045b 100644
--- a/lighthouse-core/test/config/config-test.js
+++ b/lighthouse-core/test/config/config-test.js
@@ -741,6 +741,30 @@ describe('Config', () => {
       assert.strictEqual(config.categories['lighthouse-plugin-simple'].title, 'Simple');
     });
 
+    describe('budget setting', () => {
+      it('should be initialized', () => {
+        const configJson = {
+          settings: {
+            budgets: [{
+              resourceCounts: [{
+                resourceType: 'image',
+                budget: 500,
+              }],
+            }],
+          },
+        };
+        const config = new Config(configJson);
+        assert.equal(config.settings.budgets[0].resourceCounts.length, 1);
+        assert.equal(config.settings.budgets[0].resourceCounts[0].resourceType, 'image');
+        assert.equal(config.settings.budgets[0].resourceCounts[0].budget, 500);
+      });
+
+      it('should throw when provided an invalid budget', () => {
+        assert.throws(() => new Config({settings: {budgets: ['invalid123']}}),
+          /Budget has unrecognized properties/);
+      });
+    });
+
     it('should load plugins from the config and from passed-in flags', () => {
       const baseConfigJson = {
         audits: ['installable-manifest'],
diff --git a/lighthouse-core/test/fixtures/simple-budget.json b/lighthouse-core/test/fixtures/simple-budget.json
new file mode 100644
index 000000000..e8f3030e6
--- /dev/null
+++ b/lighthouse-core/test/fixtures/simple-budget.json
@@ -0,0 +1,45 @@
+[
+    {
+        "resourceSizes": [
+            {
+                "resourceType": "script",
+                "budget": 125
+            },
+            {
+                "resourceType": "image",
+                "budget": 300
+            },
+            {
+                "resourceType": "total",
+                "budget": 500
+            },
+            {
+                "resourceType": "third-party",
+                "budget": 200
+            }
+        ],
+        "timings": [
+            {
+                "metric": "interactive",
+                "budget": 5000,
+                "tolerance": 1000
+            },
+            {
+                "metric": "first-meaningful-paint",
+                "budget": 2000
+            }
+        ]
+    },
+    {
+        "resourceCounts": [
+            {
+                "resourceType": "total",
+                "budget": 100
+            },
+            {
+                "resourceType": "third-party",
+                "budget": 0
+            }
+        ]
+    }
+]
diff --git a/lighthouse-core/test/report/html/renderer/util-test.js b/lighthouse-core/test/report/html/renderer/util-test.js
index d0b74574e..8d958b6d9 100644
--- a/lighthouse-core/test/report/html/renderer/util-test.js
+++ b/lighthouse-core/test/report/html/renderer/util-test.js
@@ -154,5 +154,31 @@ describe('util helpers', () => {
       const preparedResult = Util.prepareReportResult(clonedSampleResult);
       assert.deepStrictEqual(preparedResult.audits, sampleResult.audits);
     });
+
+    it('appends stack pack descriptions to auditRefs', () => {
+      const clonedSampleResult = JSON.parse(JSON.stringify(sampleResult));
+      const iconDataURL = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E';
+      clonedSampleResult.stackPacks = [{
+        id: 'snackpack',
+        title: 'SnackPack',
+        iconDataURL,
+        descriptions: {
+          'unused-css-rules': 'Consider using snacks in packs.',
+        },
+      }];
+      const preparedResult = Util.prepareReportResult(clonedSampleResult);
+
+      const perfAuditRefs = preparedResult.categories.performance.auditRefs;
+      const unusedCssRef = perfAuditRefs.find(ref => ref.id === 'unused-css-rules');
+      assert.deepStrictEqual(unusedCssRef.stackPacks, [{
+        title: 'SnackPack',
+        iconDataURL,
+        description: 'Consider using snacks in packs.',
+      }]);
+
+      // No stack pack on audit wth no stack pack.
+      const interactiveRef = perfAuditRefs.find(ref => ref.id === 'interactive');
+      assert.strictEqual(interactiveRef.stackPacks, undefined);
+    });
   });
 });
diff --git a/lighthouse-core/test/report/proto-test.js b/lighthouse-core/test/report/proto-test.js
index a90811ee7..8282ffbf3 100644
--- a/lighthouse-core/test/report/proto-test.js
+++ b/lighthouse-core/test/report/proto-test.js
@@ -45,12 +45,19 @@ describe('round trip JSON comparison subsets', () => {
   });
 
   it('has the same top level values', () => {
+    // Don't test all top level properties that are objects.
     Object.keys(sampleJson).forEach(audit => {
       if (typeof sampleJson[audit] === 'object' && !Array.isArray(sampleJson[audit])) {
         delete sampleJson[audit];
       }
     });
 
+    // Properties set to their type's default value will be omitted in the roundTripJson.
+    // For an explicit list of properties, remove sampleJson values if set to a default.
+    if (Array.isArray(sampleJson.stackPacks) && sampleJson.stackPacks.length === 0) {
+      delete sampleJson.stackPacks;
+    }
+
     expect(roundTripJson).toMatchObject(sampleJson);
   });
 
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 0aef7b229..c5a3d0894 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -29,6 +29,33 @@
     "disableStorageReset": false,
     "emulatedFormFactor": "mobile",
     "channel": "cli",
+    "budgets": [
+      {
+        "resourceSizes": [
+          {
+            "resourceType": "script",
+            "budget": 125
+          },
+          {
+            "resourceType": "total",
+            "budget": 500
+          }
+        ],
+        "timings": [
+          {
+            "metric": "interactive",
+            "budget": 5000,
+            "tolerance": 1000
+          }
+        ],
+         "resourceCounts": [
+          {
+           "resourceType": "third-party",
+           "budget": 0
+          }
+        ]
+      }
+    ],
     "locale": "en-US",
     "blockedUrlPatterns": null,
     "additionalTraceCategories": null,
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index a7548037f..a413fe98d 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -791,7 +791,6 @@
       "description": "Collection of useful page vitals.",
       "score": null,
       "scoreDisplayMode": "informative",
-      "numericValue": 1,
       "details": {
         "type": "debugdata",
         "items": [
@@ -2963,6 +2962,7 @@
     "disableStorageReset": false,
     "emulatedFormFactor": "mobile",
     "channel": "cli",
+    "budgets": null,
     "locale": "en-US",
     "blockedUrlPatterns": null,
     "additionalTraceCategories": null,