Repository: GoogleChrome/lighthouse
Pull request #7270 (2019-02-18 23:21:46)

tests: refactor driver, more gotoURL tests
**Summary**
Final part of driver test reorg. Adds more coverage for gotoURL, the original point of this. Also convinced me that all the logic associated with `gotoURL` should be its own `NavigationManager` or something since this was crazy amount of work for still lots of coverage to go 😆 



Patch:
diff --git a/lighthouse-core/audits/accessibility/manual/accesskeys.js b/lighthouse-core/audits/accessibility/accesskeys.js
similarity index 71%
rename from lighthouse-core/audits/accessibility/manual/accesskeys.js
rename to lighthouse-core/audits/accessibility/accesskeys.js
index e5ce2d009..bdbb1876a 100644
--- a/lighthouse-core/audits/accessibility/manual/accesskeys.js
+++ b/lighthouse-core/audits/accessibility/accesskeys.js
@@ -6,15 +6,18 @@
 'use strict';
 
 /**
- * @fileoverview Manual a11y audit to remind to check every accesskey attribute value is unique.
+ * @fileoverview Ensures accesskey values are unique.
+ * See base class in axe-audit.js for audit() implementation.
  */
 
-const Audit = require('../../audit.js');
-const i18n = require('../../../lib/i18n/i18n.js');
+const AxeAudit = require('./axe-audit.js');
+const i18n = require('../../lib/i18n/i18n.js');
 
 const UIStrings = {
   /** Title of an accesibility audit that evaluates if the accesskey HTML attribute values are unique across all elements. This title is descriptive of the successful state and is shown to users when no user action is required. */
   title: '`[accesskey]` values are unique',
+  /** Title of an accesibility audit that evaluates if the ARIA HTML attributes are misaligned with the aria-role HTML attribute specificed on the element, such mismatches are invalid. This title is descriptive of the failing state and is shown to users when there is a failure that needs to be addressed. */
+  failureTitle: '`[accesskey]` values are not unique',
   /** Description of a Lighthouse audit that tells the user *why* they should try to pass. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
   description: 'Access keys let users quickly focus a part of the page. For proper ' +
       'navigation, each access key must be unique. ' +
@@ -23,7 +26,7 @@ const UIStrings = {
 
 const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
 
-class Accesskeys extends Audit {
+class Accesskeys extends AxeAudit {
   /**
    * @return {LH.Audit.Meta}
    */
@@ -31,18 +34,11 @@ class Accesskeys extends Audit {
     return {
       id: 'accesskeys',
       title: str_(UIStrings.title),
+      failureTitle: str_(UIStrings.failureTitle),
       description: str_(UIStrings.description),
-      scoreDisplayMode: Audit.SCORING_MODES.MANUAL,
-      requiredArtifacts: [],
+      requiredArtifacts: ['Accessibility'],
     };
   }
-
-  /**
-   * @return {LH.Audit.Product}
-   */
-  static audit() {
-    return {rawValue: false};
-  }
 }
 
 module.exports = Accesskeys;
diff --git a/lighthouse-core/audits/audit.js b/lighthouse-core/audits/audit.js
index ce6188de9..425388d3e 100644
--- a/lighthouse-core/audits/audit.js
+++ b/lighthouse-core/audits/audit.js
@@ -125,6 +125,72 @@ class Audit {
     };
   }
 
+  /**
+   * @param {LH.Audit.Details.List['items']} items
+   * @returns {LH.Audit.Details.List}
+   */
+  static makeListDetails(items) {
+    return {
+      type: 'list',
+      items: items,
+    };
+  }
+
+  /** @typedef {{
+   * content: string;
+   * title: string;
+   * lineMessages: LH.Audit.Details.SnippetValue['lineMessages'];
+   * generalMessages: LH.Audit.Details.SnippetValue['generalMessages'];
+   * node?: LH.Audit.Details.NodeValue;
+   * maxLineLength?: number;
+   * maxLinesAroundMessage?: number;
+   * }} SnippetInfo */
+  /**
+   * @param {SnippetInfo} snippetInfo
+   * @return {LH.Audit.Details.SnippetValue}
+   */
+  static makeSnippetDetails({
+    content,
+    title,
+    lineMessages,
+    generalMessages,
+    node,
+    maxLineLength = 200,
+    maxLinesAroundMessage = 20,
+  }) {
+    const allLines = Audit._makeSnippetLinesArray(content, maxLineLength);
+    const lines = Util.filterRelevantLines(allLines, lineMessages, maxLinesAroundMessage);
+    return {
+      type: 'snippet',
+      lines,
+      title,
+      lineMessages,
+      generalMessages,
+      lineCount: allLines.length,
+      node,
+    };
+  }
+
+  /**
+   * @param {string} content
+   * @param {number} maxLineLength
+   * @returns {LH.Audit.Details.SnippetValue['lines']}
+   */
+  static _makeSnippetLinesArray(content, maxLineLength) {
+    return content.split('\n').map((line, lineIndex) => {
+      const lineNumber = lineIndex + 1;
+      /** @type LH.Audit.Details.SnippetValue['lines'][0] */
+      const lineDetail = {
+        content: line.slice(0, maxLineLength),
+        lineNumber,
+      };
+      if (line.length > maxLineLength) {
+        lineDetail.truncated = true;
+      }
+      return lineDetail;
+    });
+  }
+
   /**
    * @param {LH.Audit.Details.Opportunity['headings']} headings
    * @param {LH.Audit.Details.Opportunity['items']} items
diff --git a/lighthouse-core/config/default-config.js b/lighthouse-core/config/default-config.js
index ed83759fb..b06cede09 100644
--- a/lighthouse-core/config/default-config.js
+++ b/lighthouse-core/config/default-config.js
@@ -31,44 +31,44 @@ const UIStrings = {
   diagnosticsGroupTitle: 'Diagnostics',
   /** Description of the diagnostics section of the Performance category. Within this section are audits with non-imperative titles that provide more detail on the page's page load performance characteristics. Whereas the 'Opportunities' suggest an action along with expected time savings, diagnostics do not. Within this section, the user may read the details and deduce additional actions they could take. */
   diagnosticsGroupDescription: 'More information about the performance of your application.',
-  /** Title of the Accessibility category of audits. This section contains audits focused on making web content accessible to users with disabilities. Also used as a label of a score gauge; try to limit to 20 characters. */
+  /** Title of the Accessibility category of audits. This section contains audits focused on making web content accessible to all users. Also used as a label of a score gauge; try to limit to 20 characters. */
   a11yCategoryTitle: 'Accessibility',
-  /** Description of the Accessibility category. This is displayed at the top of a list of audits focused on making web content accessible to users with disabilities. No character length limits. 'improve the accessibility of your web app' becomes link text to additional documentation. */
+  /** Description of the Accessibility category. This is displayed at the top of a list of audits focused on making web content accessible to all users. No character length limits. 'improve the accessibility of your web app' becomes link text to additional documentation. */
   a11yCategoryDescription: 'These checks highlight opportunities to [improve the accessibility of your web app](https://developers.google.com/web/fundamentals/accessibility). Only a subset of accessibility issues can be automatically detected so manual testing is also encouraged.',
   /** Description of the Accessibility manual checks category. This description is displayed above a list of accessibility audits that currently have no automated test and so must be verified manually by the user. No character length limits. 'conducting an accessibility review' becomes link text to additional documentation. */
   a11yCategoryManualDescription: 'These items address areas which an automated testing tool cannot cover. Learn more in our guide on [conducting an accessibility review](https://developers.google.com/web/fundamentals/accessibility/how-to-review).',
+  /** Title of the best practices section of the Accessibility category. Within this section are audits with descriptive titles that highlight common accessibility best practices. */
+  a11yBestPracticesGroupTitle: 'Best practices',
+  /* Description of the best practices section within the Accessibility category. Within this section are audits with descriptive titles that highlight common accessibility best practices. */
+  a11yBestPracticesGroupDescription: 'These items highlight common accessibility best practices.',
   /* Title of the color contrast section within the Accessibility category. Within this section are audits with descriptive titles that highlight the color and vision aspects of the page's accessibility that are passing or failing. */
-  a11yColorContrastGroupTitle: 'Color Contrast Is Satisfactory',
+  a11yColorContrastGroupTitle: 'Contrast',
   /* Description of the color contrast section within the Accessibility category. Within this section are audits with descriptive titles that highlight the color and vision aspects of the page's accessibility that are passing or failing. */
   a11yColorContrastGroupDescription: 'These are opportunities to improve the legibility of your content.',
-  /* Title of the screen reader annotation section within the Accessibility category. Within this section are audits with descriptive titles that highlight the screen reader readability aspects of the page's accessibility that are passing or failing. 'Elements' refers to HTML elements. */
-  a11yDescribeContentsGroupTitle: 'Elements Describe Contents Well',
-  /* Description of the screen reader annotation section within the Accessibility category. Within this section are audits with descriptive titles that highlight the screen reader readability aspects of the page's accessibility that are passing or failing. */
-  a11yDescribeContentsGroupDescription: 'These are opportunities to make your content easier to understand for a user of assistive technology, like a screen reader.',
-  /* Title of the HTML validity section within the Accessibility category. Within this section are audits with descriptive titles that highlight structural HTML aspects of the page's accessibility that are passing or failing (i.e. that list items are contained within list parents, etc). 'Elements' refers to HTML elements. */
-  a11yWellStructuredGroupTitle: 'Elements Are Well Structured',
-  /* Description of the HTML validity section within the Accessibility category. Within this section are audits with descriptive titles that highlight structural HTML aspects of the page's accessibility that are passing or failing. */
-  a11yWellStructuredGroupDescription: 'These are opportunities to make sure your HTML is appropriately structured.',
+  /* Title of the HTML element naming section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the non-textual HTML elements on the page have names discernible by a screen reader. */
+  a11yNamesLabelsGroupTitle: 'Names and labels',
+  /* Description of the HTML element naming section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the non-textual HTML elements on the page have names discernible by a screen reader. */
+  a11yNamesLabelsGroupDescription: 'These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.',
+  /* Title of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to improve keyboard navigation. */
+  a11yNavigationGroupTitle: 'Navigation',
+  /* Description of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to improve keyboard navigation. */
+  a11yNavigationGroupDescription: 'These are opportunities to improve keyboard navigation in your application.',
   /* Title of the ARIA validity section within the Accessibility category. Within this section are audits with descriptive titles that highlight if whether all the aria-* HTML attributes have been used properly. */
-  a11yAriaGroupTitle: 'ARIA Attributes Follow Best Practices',
+  a11yAriaGroupTitle: 'ARIA',
   /* Description of the ARIA validity section within the Accessibility category. Within this section are audits with descriptive titles that highlight if whether all the aria-* HTML attributes have been used properly. */
   a11yAriaGroupDescription: 'These are opportunities to improve the usage of ARIA in your application which may enhance the experience for users of assistive technology, like a screen reader.',
-  /* Title of the HTML attribute validity section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the HTML attribute values on the page are used correctly. 'Elements' refers to HTML elements. */
-  a11yCorrectAttributesGroupTitle: 'Elements Use Attributes Correctly',
-  /* Description of the HTML attribute validity section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the HTML attribute values on the page are used correctly. */
-  a11yCorrectAttributesGroupDescription: 'These are opportunities to improve the configuration of your HTML elements.',
-  /* Title of the HTML element naming section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the non-textual HTML elements on the page have names discernible by a screen reader. */
-  a11yElementNamesGroupTitle: 'Elements Have Discernible Names',
-  /* Description of the HTML element naming section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the non-textual HTML elements on the page have names discernible by a screen reader. */
-  a11yElementNamesGroupDescription: 'These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.',
   /* Title of the language section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the language has been annotated in the correct HTML attributes on the page. */
-  a11yLanguageGroupTitle: 'Page Specifies Valid Language',
+  a11yLanguageGroupTitle: 'Internationalization and localization',
   /* Description of the language section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the language has been annotated in the correct HTML attributes on the page. */
   a11yLanguageGroupDescription: 'These are opportunities to improve the interpretation of your content by users in different locales.',
-  /* Title of the meta tag section within the Accessibility category. Within this section are audits with descriptive titles that highlight if meta tags on the page have been used properly and if any important ones are missing. */
-  a11yMetaGroupTitle: 'Meta Tags Used Properly',
-  /* Description of the meta tag section within the Accessibility category. Within this section are audits with descriptive titles that highlight if meta tags on the page have been used properly and if any important ones are missing. */
-  a11yMetaGroupDescription: 'These are opportunities to improve the user experience of your site.',
+  /* Title of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to provide alternative content for audio and video. */
+  a11yAudioVideoGroupTitle: 'Audio and video',
+  /* Description of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to provide alternative content for audio and video. */
+  a11yAudioVideoGroupDescription: 'These are opportunities to provide alternative content for audio and video. This may improve the experience for users with hearing or vision impairments.',
+  /* Title of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to improve the experience of reading tabular or list data using assistive technology. */
+  a11yTablesListsVideoGroupTitle: 'Tables and lists',
+  /* Description of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to improve the experience of reading tabular or list data using assistive technology. */
+  a11yTablesListsVideoGroupDescription: 'These are opportunities to to improve the experience of reading tabular or list data using assistive technology, like a screen reader.',
   /** Title of the Search Engine Optimization (SEO) category of audits. This is displayed at the top of a list of audits focused on topics related to optimizing a website for indexing by search engines. Also used as a label of a score gauge; try to limit to 20 characters. */
   seoCategoryTitle: 'SEO',
   /** Description of the Search Engine Optimization (SEO) category. This is displayed at the top of a list of audits focused on optimizing a website for indexing by search engines. No character length limits. 'Learn More' becomes link text to additional documentation. */
@@ -184,6 +184,7 @@ const defaultConfig = {
     'manual/pwa-cross-browser',
     'manual/pwa-page-transitions',
     'manual/pwa-each-page-has-url',
+    'accessibility/accesskeys',
     'accessibility/aria-allowed-attr',
     'accessibility/aria-required-attr',
     'accessibility/aria-required-children',
@@ -218,7 +219,6 @@ const defaultConfig = {
     'accessibility/valid-lang',
     'accessibility/video-caption',
     'accessibility/video-description',
-    'accessibility/manual/accesskeys',
     'accessibility/manual/custom-controls-labels',
     'accessibility/manual/custom-controls-roles',
     'accessibility/manual/focus-traps',
@@ -288,37 +288,37 @@ const defaultConfig = {
     'pwa-optimized': {
       title: str_(UIStrings.pwaOptimizedGroupTitle),
     },
+    'a11y-best-practices': {
+      title: str_(UIStrings.a11yBestPracticesGroupTitle),
+      description: str_(UIStrings.a11yBestPracticesGroupDescription),
+    },
     'a11y-color-contrast': {
       title: str_(UIStrings.a11yColorContrastGroupTitle),
       description: str_(UIStrings.a11yColorContrastGroupDescription),
     },
-    'a11y-describe-contents': {
-      title: str_(UIStrings.a11yDescribeContentsGroupTitle),
-      description: str_(UIStrings.a11yDescribeContentsGroupDescription),
+    'a11y-names-labels': {
+      title: str_(UIStrings.a11yNamesLabelsGroupTitle),
+      description: str_(UIStrings.a11yNamesLabelsGroupDescription),
     },
-    'a11y-well-structured': {
-      title: str_(UIStrings.a11yWellStructuredGroupTitle),
-      description: str_(UIStrings.a11yWellStructuredGroupDescription),
+    'a11y-navigation': {
+      title: str_(UIStrings.a11yNavigationGroupTitle),
+      description: str_(UIStrings.a11yNavigationGroupDescription),
     },
     'a11y-aria': {
       title: str_(UIStrings.a11yAriaGroupTitle),
       description: str_(UIStrings.a11yAriaGroupDescription),
     },
-    'a11y-correct-attributes': {
-      title: str_(UIStrings.a11yCorrectAttributesGroupTitle),
-      description: str_(UIStrings.a11yCorrectAttributesGroupDescription),
-    },
-    'a11y-element-names': {
-      title: str_(UIStrings.a11yElementNamesGroupTitle),
-      description: str_(UIStrings.a11yElementNamesGroupDescription),
-    },
     'a11y-language': {
       title: str_(UIStrings.a11yLanguageGroupTitle),
       description: str_(UIStrings.a11yLanguageGroupDescription),
     },
-    'a11y-meta': {
-      title: str_(UIStrings.a11yMetaGroupTitle),
-      description: str_(UIStrings.a11yMetaGroupDescription),
+    'a11y-audio-video': {
+      title: str_(UIStrings.a11yAudioVideoGroupTitle),
+      description: str_(UIStrings.a11yAudioVideoGroupDescription),
+    },
+    'a11y-tables-lists': {
+      title: str_(UIStrings.a11yTablesListsVideoGroupTitle),
+      description: str_(UIStrings.a11yTablesListsVideoGroupDescription),
     },
     'seo-mobile': {
       title: 'Mobile Friendly',
@@ -384,6 +384,7 @@ const defaultConfig = {
       description: str_(UIStrings.a11yCategoryDescription),
       manualDescription: str_(UIStrings.a11yCategoryManualDescription),
       auditRefs: [
+        {id: 'accesskeys', weight: 3, group: 'a11y-navigation'},
         {id: 'aria-allowed-attr', weight: 3, group: 'a11y-aria'},
         {id: 'aria-required-attr', weight: 2, group: 'a11y-aria'},
         {id: 'aria-required-children', weight: 5, group: 'a11y-aria'},
@@ -391,35 +392,34 @@ const defaultConfig = {
         {id: 'aria-roles', weight: 3, group: 'a11y-aria'},
         {id: 'aria-valid-attr-value', weight: 2, group: 'a11y-aria'},
         {id: 'aria-valid-attr', weight: 5, group: 'a11y-aria'},
-        {id: 'audio-caption', weight: 4, group: 'a11y-correct-attributes'},
-        {id: 'button-name', weight: 10, group: 'a11y-element-names'},
-        {id: 'bypass', weight: 10, group: 'a11y-describe-contents'},
+        {id: 'audio-caption', weight: 4, group: 'a11y-audio-video'},
+        {id: 'button-name', weight: 10, group: 'a11y-names-labels'},
+        {id: 'bypass', weight: 10, group: 'a11y-navigation'},
         {id: 'color-contrast', weight: 6, group: 'a11y-color-contrast'},
-        {id: 'definition-list', weight: 1, group: 'a11y-well-structured'},
-        {id: 'dlitem', weight: 1, group: 'a11y-well-structured'},
-        {id: 'document-title', weight: 2, group: 'a11y-describe-contents'},
-        {id: 'duplicate-id', weight: 5, group: 'a11y-well-structured'},
-        {id: 'frame-title', weight: 5, group: 'a11y-describe-contents'},
+        {id: 'definition-list', weight: 1, group: 'a11y-tables-lists'},
+        {id: 'dlitem', weight: 1, group: 'a11y-tables-lists'},
+        {id: 'document-title', weight: 2, group: 'a11y-names-labels'},
+        {id: 'duplicate-id', weight: 5, group: 'a11y-best-practices'},
+        {id: 'frame-title', weight: 5, group: 'a11y-names-labels'},
         {id: 'html-has-lang', weight: 4, group: 'a11y-language'},
         {id: 'html-lang-valid', weight: 1, group: 'a11y-language'},
-        {id: 'image-alt', weight: 8, group: 'a11y-correct-attributes'},
-        {id: 'input-image-alt', weight: 1, group: 'a11y-correct-attributes'},
-        {id: 'label', weight: 10, group: 'a11y-describe-contents'},
-        {id: 'layout-table', weight: 1, group: 'a11y-describe-contents'},
-        {id: 'link-name', weight: 9, group: 'a11y-element-names'},
-        {id: 'list', weight: 5, group: 'a11y-well-structured'},
-        {id: 'listitem', weight: 4, group: 'a11y-well-structured'},
-        {id: 'meta-refresh', weight: 1, group: 'a11y-meta'},
-        {id: 'meta-viewport', weight: 3, group: 'a11y-meta'},
-        {id: 'object-alt', weight: 4, group: 'a11y-describe-contents'},
-        {id: 'tabindex', weight: 4, group: 'a11y-correct-attributes'},
-        {id: 'td-headers-attr', weight: 1, group: 'a11y-correct-attributes'},
-        {id: 'th-has-data-cells', weight: 1, group: 'a11y-correct-attributes'},
+        {id: 'image-alt', weight: 8, group: 'a11y-names-labels'},
+        {id: 'input-image-alt', weight: 1, group: 'a11y-names-labels'},
+        {id: 'label', weight: 10, group: 'a11y-names-labels'},
+        {id: 'layout-table', weight: 1, group: 'a11y-tables-lists'},
+        {id: 'link-name', weight: 9, group: 'a11y-names-labels'},
+        {id: 'list', weight: 5, group: 'a11y-tables-lists'},
+        {id: 'listitem', weight: 4, group: 'a11y-tables-lists'},
+        {id: 'meta-refresh', weight: 1, group: 'a11y-best-practices'},
+        {id: 'meta-viewport', weight: 3, group: 'a11y-best-practices'},
+        {id: 'object-alt', weight: 4, group: 'a11y-names-labels'},
+        {id: 'tabindex', weight: 4, group: 'a11y-navigation'},
+        {id: 'td-headers-attr', weight: 1, group: 'a11y-tables-lists'},
+        {id: 'th-has-data-cells', weight: 1, group: 'a11y-tables-lists'},
         {id: 'valid-lang', weight: 1, group: 'a11y-language'},
-        {id: 'video-caption', weight: 4, group: 'a11y-describe-contents'},
-        {id: 'video-description', weight: 3, group: 'a11y-describe-contents'},
+        {id: 'video-caption', weight: 4, group: 'a11y-audio-video'},
+        {id: 'video-description', weight: 3, group: 'a11y-audio-video'},
         // Manual audits
-        {id: 'accesskeys', weight: 0},
         {id: 'logical-tab-order', weight: 0},
         {id: 'focusable-controls', weight: 0},
         {id: 'interactive-element-affordance', weight: 0},
diff --git a/lighthouse-core/gather/gatherers/accessibility.js b/lighthouse-core/gather/gatherers/accessibility.js
index 0af785460..c74fdcb7c 100644
--- a/lighthouse-core/gather/gatherers/accessibility.js
+++ b/lighthouse-core/gather/gatherers/accessibility.js
@@ -33,6 +33,7 @@ function runA11yChecks() {
     resultTypes: ['violations', 'inapplicable'],
     rules: {
       'tabindex': {enabled: true},
+      'accesskeys': {enabled: true},
       'table-fake-caption': {enabled: false},
       'td-has-header': {enabled: false},
       'marquee': {enabled: false},
diff --git a/lighthouse-core/lib/i18n/en-US.json b/lighthouse-core/lib/i18n/en-US.json
index ce893677a..ad046ddbc 100644
--- a/lighthouse-core/lib/i18n/en-US.json
+++ b/lighthouse-core/lib/i18n/en-US.json
@@ -1,4 +1,16 @@
 {
+  "lighthouse-core/audits/accessibility/accesskeys.js | description": {
+    "message": "Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more](https://dequeuniversity.com/rules/axe/3.1/accesskeys?application=lighthouse).",
+    "description": "Description of a Lighthouse audit that tells the user *why* they should try to pass. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation."
+  },
+  "lighthouse-core/audits/accessibility/accesskeys.js | failureTitle": {
+    "message": "`[accesskey]` values are not unique",
+    "description": "Title of an accesibility audit that evaluates if the ARIA HTML attributes are misaligned with the aria-role HTML attribute specificed on the element, such mismatches are invalid. This title is descriptive of the failing state and is shown to users when there is a failure that needs to be addressed."
+  },
+  "lighthouse-core/audits/accessibility/accesskeys.js | title": {
+    "message": "`[accesskey]` values are unique",
+    "description": "Title of an accesibility audit that evaluates if the accesskey HTML attribute values are unique across all elements. This title is descriptive of the successful state and is shown to users when no user action is required."
+  },
   "lighthouse-core/audits/accessibility/aria-allowed-attr.js | description": {
     "message": "Each ARIA `role` supports a specific subset of `aria-*` attributes. Mismatching these invalidates the `aria-*` attributes. [Learn more](https://dequeuniversity.com/rules/axe/3.1/aria-allowed-attr?application=lighthouse).",
     "description": "Description of a Lighthouse audit that tells the user *why* they should try to pass. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation."
@@ -303,14 +315,6 @@
     "message": "List items (`<li>`) are contained within `<ul>` or `<ol>` parent elements",
     "description": "Title of an accesibility audit that evaluates if any list item elements do not have list parent elements. This title is descriptive of the successful state and is shown to users when no user action is required."
   },
-  "lighthouse-core/audits/accessibility/manual/accesskeys.js | description": {
-    "message": "Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more](https://dequeuniversity.com/rules/axe/3.1/accesskeys?application=lighthouse).",
-    "description": "Description of a Lighthouse audit that tells the user *why* they should try to pass. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation."
-  },
-  "lighthouse-core/audits/accessibility/manual/accesskeys.js | title": {
-    "message": "`[accesskey]` values are unique",
-    "description": "Title of an accesibility audit that evaluates if the accesskey HTML attribute values are unique across all elements. This title is descriptive of the successful state and is shown to users when no user action is required."
-  },
   "lighthouse-core/audits/accessibility/meta-refresh.js | description": {
     "message": "Users do not expect a page to refresh automatically, and doing so will move focus back to the top of the page. This may create a frustrating or confusing experience. [Learn more](https://dequeuniversity.com/rules/axe/3.1/meta-refresh?application=lighthouse).",
     "description": "Description of a Lighthouse audit that tells the user *why* they should try to pass. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation."
@@ -1028,12 +1032,28 @@
     "description": "Description of the ARIA validity section within the Accessibility category. Within this section are audits with descriptive titles that highlight if whether all the aria- HTML attributes have been used properly."
   },
   "lighthouse-core/config/default-config.js | a11yAriaGroupTitle": {
-    "message": "ARIA Attributes Follow Best Practices",
+    "message": "ARIA",
     "description": "Title of the ARIA validity section within the Accessibility category. Within this section are audits with descriptive titles that highlight if whether all the aria- HTML attributes have been used properly."
   },
+  "lighthouse-core/config/default-config.js | a11yAudioVideoGroupDescription": {
+    "message": "These are opportunities to provide alternative content for audio and video. This may improve the experience for users with hearing or vision impairments.",
+    "description": "Description of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to provide alternative content for audio and video."
+  },
+  "lighthouse-core/config/default-config.js | a11yAudioVideoGroupTitle": {
+    "message": "Audio and video",
+    "description": "Title of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to provide alternative content for audio and video."
+  },
+  "lighthouse-core/config/default-config.js | a11yBestPracticesGroupDescription": {
+    "message": "These items highlight common accessibility best practices.",
+    "description": "Description of the best practices section within the Accessibility category. Within this section are audits with descriptive titles that highlight common accessibility best practices."
+  },
+  "lighthouse-core/config/default-config.js | a11yBestPracticesGroupTitle": {
+    "message": "Best practices",
+    "description": "Title of the best practices section of the Accessibility category. Within this section are audits with descriptive titles that highlight common accessibility best practices."
+  },
   "lighthouse-core/config/default-config.js | a11yCategoryDescription": {
     "message": "These checks highlight opportunities to [improve the accessibility of your web app](https://developers.google.com/web/fundamentals/accessibility). Only a subset of accessibility issues can be automatically detected so manual testing is also encouraged.",
-    "description": "Description of the Accessibility category. This is displayed at the top of a list of audits focused on making web content accessible to users with disabilities. No character length limits. 'improve the accessibility of your web app' becomes link text to additional documentation."
+    "description": "Description of the Accessibility category. This is displayed at the top of a list of audits focused on making web content accessible to all users. No character length limits. 'improve the accessibility of your web app' becomes link text to additional documentation."
   },
   "lighthouse-core/config/default-config.js | a11yCategoryManualDescription": {
     "message": "These items address areas which an automated testing tool cannot cover. Learn more in our guide on [conducting an accessibility review](https://developers.google.com/web/fundamentals/accessibility/how-to-review).",
@@ -1041,63 +1061,47 @@
   },
   "lighthouse-core/config/default-config.js | a11yCategoryTitle": {
     "message": "Accessibility",
-    "description": "Title of the Accessibility category of audits. This section contains audits focused on making web content accessible to users with disabilities. Also used as a label of a score gauge; try to limit to 20 characters."
+    "description": "Title of the Accessibility category of audits. This section contains audits focused on making web content accessible to all users. Also used as a label of a score gauge; try to limit to 20 characters."
   },
   "lighthouse-core/config/default-config.js | a11yColorContrastGroupDescription": {
     "message": "These are opportunities to improve the legibility of your content.",
     "description": "Description of the color contrast section within the Accessibility category. Within this section are audits with descriptive titles that highlight the color and vision aspects of the page's accessibility that are passing or failing."
   },
   "lighthouse-core/config/default-config.js | a11yColorContrastGroupTitle": {
-    "message": "Color Contrast Is Satisfactory",
+    "message": "Contrast",
     "description": "Title of the color contrast section within the Accessibility category. Within this section are audits with descriptive titles that highlight the color and vision aspects of the page's accessibility that are passing or failing."
   },
-  "lighthouse-core/config/default-config.js | a11yCorrectAttributesGroupDescription": {
-    "message": "These are opportunities to improve the configuration of your HTML elements.",
-    "description": "Description of the HTML attribute validity section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the HTML attribute values on the page are used correctly."
-  },
-  "lighthouse-core/config/default-config.js | a11yCorrectAttributesGroupTitle": {
-    "message": "Elements Use Attributes Correctly",
-    "description": "Title of the HTML attribute validity section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the HTML attribute values on the page are used correctly. 'Elements' refers to HTML elements."
-  },
-  "lighthouse-core/config/default-config.js | a11yDescribeContentsGroupDescription": {
-    "message": "These are opportunities to make your content easier to understand for a user of assistive technology, like a screen reader.",
-    "description": "Description of the screen reader annotation section within the Accessibility category. Within this section are audits with descriptive titles that highlight the screen reader readability aspects of the page's accessibility that are passing or failing."
-  },
-  "lighthouse-core/config/default-config.js | a11yDescribeContentsGroupTitle": {
-    "message": "Elements Describe Contents Well",
-    "description": "Title of the screen reader annotation section within the Accessibility category. Within this section are audits with descriptive titles that highlight the screen reader readability aspects of the page's accessibility that are passing or failing. 'Elements' refers to HTML elements."
-  },
-  "lighthouse-core/config/default-config.js | a11yElementNamesGroupDescription": {
-    "message": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.",
-    "description": "Description of the HTML element naming section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the non-textual HTML elements on the page have names discernible by a screen reader."
-  },
-  "lighthouse-core/config/default-config.js | a11yElementNamesGroupTitle": {
-    "message": "Elements Have Discernible Names",
-    "description": "Title of the HTML element naming section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the non-textual HTML elements on the page have names discernible by a screen reader."
-  },
   "lighthouse-core/config/default-config.js | a11yLanguageGroupDescription": {
     "message": "These are opportunities to improve the interpretation of your content by users in different locales.",
     "description": "Description of the language section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the language has been annotated in the correct HTML attributes on the page."
   },
   "lighthouse-core/config/default-config.js | a11yLanguageGroupTitle": {
-    "message": "Page Specifies Valid Language",
+    "message": "Internationalization and localization",
     "description": "Title of the language section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the language has been annotated in the correct HTML attributes on the page."
   },
-  "lighthouse-core/config/default-config.js | a11yMetaGroupDescription": {
-    "message": "These are opportunities to improve the user experience of your site.",
-    "description": "Description of the meta tag section within the Accessibility category. Within this section are audits with descriptive titles that highlight if meta tags on the page have been used properly and if any important ones are missing."
+  "lighthouse-core/config/default-config.js | a11yNamesLabelsGroupDescription": {
+    "message": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.",
+    "description": "Description of the HTML element naming section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the non-textual HTML elements on the page have names discernible by a screen reader."
+  },
+  "lighthouse-core/config/default-config.js | a11yNamesLabelsGroupTitle": {
+    "message": "Names and labels",
+    "description": "Title of the HTML element naming section within the Accessibility category. Within this section are audits with descriptive titles that highlight if the non-textual HTML elements on the page have names discernible by a screen reader."
+  },
+  "lighthouse-core/config/default-config.js | a11yNavigationGroupDescription": {
+    "message": "These are opportunities to improve keyboard navigation in your application.",
+    "description": "Description of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to improve keyboard navigation."
   },
-  "lighthouse-core/config/default-config.js | a11yMetaGroupTitle": {
-    "message": "Meta Tags Used Properly",
-    "description": "Title of the meta tag section within the Accessibility category. Within this section are audits with descriptive titles that highlight if meta tags on the page have been used properly and if any important ones are missing."
+  "lighthouse-core/config/default-config.js | a11yNavigationGroupTitle": {
+    "message": "Navigation",
+    "description": "Title of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to improve keyboard navigation."
   },
-  "lighthouse-core/config/default-config.js | a11yWellStructuredGroupDescription": {
-    "message": "These are opportunities to make sure your HTML is appropriately structured.",
-    "description": "Description of the HTML validity section within the Accessibility category. Within this section are audits with descriptive titles that highlight structural HTML aspects of the page's accessibility that are passing or failing."
+  "lighthouse-core/config/default-config.js | a11yTablesListsVideoGroupDescription": {
+    "message": "These are opportunities to to improve the experience of reading tabular or list data using assistive technology, like a screen reader.",
+    "description": "Description of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to improve the experience of reading tabular or list data using assistive technology."
   },
-  "lighthouse-core/config/default-config.js | a11yWellStructuredGroupTitle": {
-    "message": "Elements Are Well Structured",
-    "description": "Title of the HTML validity section within the Accessibility category. Within this section are audits with descriptive titles that highlight structural HTML aspects of the page's accessibility that are passing or failing (i.e. that list items are contained within list parents, etc). 'Elements' refers to HTML elements."
+  "lighthouse-core/config/default-config.js | a11yTablesListsVideoGroupTitle": {
+    "message": "Tables and lists",
+    "description": "Title of the navigation section within the Accessibility category. Within this section are audits with descriptive titles that highlight opportunities to improve the experience of reading tabular or list data using assistive technology."
   },
   "lighthouse-core/config/default-config.js | diagnosticsGroupDescription": {
     "message": "More information about the performance of your application.",
@@ -1307,6 +1311,14 @@
     "message": "Score scale:",
     "description": "Label preceding a pictorial explanation of the scoring scale: 0-50 is red (bad), 50-90 is orange (ok), 90-100 is green (good). These colors are used throughout the report to provide context for how good/bad a particular result is."
   },
+  "lighthouse-core/report/html/renderer/util.js | snippetCollapseButtonLabel": {
+    "message": "Collapse snippet",
+    "description": "Label for button that only shows a few lines of the snippet when clicked"
+  },
+  "lighthouse-core/report/html/renderer/util.js | snippetExpandButtonLabel": {
+    "message": "Expand snippet",
+    "description": "Label for button that shows all lines of the snippet when clicked"
+  },
   "lighthouse-core/report/html/renderer/util.js | toplevelWarningsMessage": {
     "message": "There were issues affecting this run of Lighthouse:",
     "description": "Label shown preceding any important warnings that may have invalidated the entire report. For example, if the user has Chrome extensions installed, they may add enough performance overhead that Lighthouse's performance metrics are unreliable. If shown, this will be displayed at the top of the report UI."
diff --git a/lighthouse-core/report/html/html-report-assets.js b/lighthouse-core/report/html/html-report-assets.js
index 8ac7d203e..0a2b56c0a 100644
--- a/lighthouse-core/report/html/html-report-assets.js
+++ b/lighthouse-core/report/html/html-report-assets.js
@@ -16,6 +16,7 @@ const REPORT_JAVASCRIPT = [
   fs.readFileSync(require.resolve('details-element-polyfill'), 'utf8'),
   fs.readFileSync(__dirname + '/renderer/details-renderer.js', 'utf8'),
   fs.readFileSync(__dirname + '/renderer/crc-details-renderer.js', 'utf8'),
+  fs.readFileSync(__dirname + '/renderer/snippet-renderer.js', 'utf8'),
   fs.readFileSync(__dirname + '/../../lib/file-namer.js', 'utf8'),
   fs.readFileSync(__dirname + '/renderer/logger.js', 'utf8'),
   fs.readFileSync(__dirname + '/renderer/report-ui-features.js', 'utf8'),
diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js
index 91a8384b1..51bf2ce72 100644
--- a/lighthouse-core/report/html/renderer/details-renderer.js
+++ b/lighthouse-core/report/html/renderer/details-renderer.js
@@ -16,12 +16,10 @@
  */
 'use strict';
 
-/* globals self CriticalRequestChainRenderer Util URL */
+/* globals self CriticalRequestChainRenderer SnippetRenderer Util URL */
 
 /** @typedef {import('./dom.js')} DOM */
-/** @typedef {LH.Audit.Details.Opportunity} OpportunityDetails */
 
-/** @type {Array<string>} */
 const URL_PREFIXES = ['http://', 'https://', 'data:'];
 
 class DetailsRenderer {
@@ -43,52 +41,34 @@ class DetailsRenderer {
   }
 
   /**
-   * @param {DetailsJSON|OpportunityDetails} details
+   * @param {LH.Audit.Details} details
    * @return {Element|null}
    */
   render(details) {
     switch (details.type) {
-      case 'text':
-        return this._renderText(/** @type {StringDetailsJSON} */ (details));
-      case 'url':
-        return this._renderTextURL(/** @type {StringDetailsJSON} */ (details));
-      case 'bytes':
-        return this._renderBytes(/** @type {NumericUnitDetailsJSON} */ (details));
-      case 'ms':
-        // eslint-disable-next-line max-len
-        return this._renderMilliseconds(/** @type {NumericUnitDetailsJSON} */ (details));
-      case 'link':
-        // @ts-ignore - TODO(bckenny): Fix type hierarchy
-        return this._renderLink(/** @type {LinkDetailsJSON} */ (details));
-      case 'thumbnail':
-        return this._renderThumbnail(/** @type {ThumbnailDetails} */ (details));
       case 'filmstrip':
-        // @ts-ignore - TODO(bckenny): Fix type hierarchy
-        return this._renderFilmstrip(/** @type {FilmstripDetails} */ (details));
+        return this._renderFilmstrip(details);
+      case 'list':
+        return this._renderList(details);
       case 'table':
-        // @ts-ignore - TODO(bckenny): Fix type hierarchy
-        return this._renderTable(/** @type {TableDetailsJSON} */ (details));
-      case 'code':
-        return this._renderCode(/** @type {DetailsJSON} */ (details));
-      case 'node':
-        return this.renderNode(/** @type {NodeDetailsJSON} */(details));
+        return this._renderTable(details);
       case 'criticalrequestchain':
-        return CriticalRequestChainRenderer.render(this._dom, this._templateContext,
-          // @ts-ignore - TODO(bckenny): Fix type hierarchy
-          /** @type {LH.Audit.Details.CriticalRequestChain} */ (details));
+        return CriticalRequestChainRenderer.render(this._dom, this._templateContext, details);
       case 'opportunity':
-        // @ts-ignore - TODO(bckenny): Fix type hierarchy
-        return this._renderOpportunityTable(details);
-      case 'numeric':
-        return this._renderNumeric(/** @type {StringDetailsJSON} */ (details));
+        return this._renderTable(details);
 
       // Internal-only details, not for rendering.
       case 'screenshot':
       case 'diagnostic':
         return null;
+      // Fallback for old LHRs, where no type meant don't render.
+      case undefined:
+        return null;
 
       default: {
-        throw new Error(`Unknown type: ${details.type}`);
+        // @ts-ignore tsc thinks this unreachable, but ts-ignore for error message just in case.
+        const detailsType = details.type;
+        throw new Error(`Unknown type: ${detailsType}`);
       }
     }
   }
@@ -153,7 +133,7 @@ class DetailsRenderer {
   }
 
   /**
-   * @param {LinkDetailsJSON} details
+   * @param {LH.Audit.Details.LinkValue} details
    * @return {Element}
    */
   _renderLink(details) {
@@ -197,7 +177,6 @@ class DetailsRenderer {
 
   /**
    * Create small thumbnail with scaled down image asset.
-   * If the supplied details doesn't have an image/* mimeType, then an empty span is returned.
    * @param {{value: string}} details
    * @return {Element}
    */
@@ -211,81 +190,126 @@ class DetailsRenderer {
   }
 
   /**
-   * @param {TableDetailsJSON} details
-   * @return {Element}
+   * Render a details item value for embedding in a table. Renders the value
+   * based on the heading's valueType, unless the value itself has a `type`
+   * property to override it.
+   * @param {LH.Audit.Details.TableItem[string] | LH.Audit.Details.OpportunityItem[string]} value
+   * @param {LH.Audit.Details.OpportunityColumnHeading} heading
+   * @return {Element|null}
    */
-  _renderTable(details) {
-    if (!details.items.length) return this._dom.createElement('span');
-
-    const tableElem = this._dom.createElement('table', 'lh-table');
-    const theadElem = this._dom.createChildOf(tableElem, 'thead');
-    const theadTrElem = this._dom.createChildOf(theadElem, 'tr');
-
-    for (const heading of details.headings) {
-      const itemType = heading.itemType || 'text';
-      const classes = `lh-table-column--${itemType}`;
-      // @ts-ignore TODO(bckenny): this can never be null
-      this._dom.createChildOf(theadTrElem, 'th', classes).appendChild(this.render({
-        type: 'text',
-        value: heading.text || '',
-      }));
+  _renderTableValue(value, heading) {
+    if (typeof value === 'undefined' || value === null) {
+      return null;
     }
 
-    const tbodyElem = this._dom.createChildOf(tableElem, 'tbody');
-    for (const row of details.items) {
-      const rowElem = this._dom.createChildOf(tbodyElem, 'tr');
-      for (const heading of details.headings) {
-        const key = /** @type {keyof DetailsJSON} */ (heading.key);
-        // TODO(bckenny): type should be naturally inferred here.
-        const value = /** @type {number|string|DetailsJSON|undefined} */ (row[key]);
-
-        if (typeof value === 'undefined' || value === null) {
-          this._dom.createChildOf(rowElem, 'td', 'lh-table-column--empty');
-          continue;
+    // First deal with the possible object forms of value.
+    if (typeof value === 'object') {
+      // The value's type overrides the heading's for this column.
+      switch (value.type) {
+        case 'code': {
+          return this._renderCode(value);
         }
-        // handle nested types like code blocks in table rows.
-        // @ts-ignore - TODO(bckenny): narrow first
-        if (value.type) {
-          const valueAsDetails = /** @type {DetailsJSON} */ (value);
-          const classes = `lh-table-column--${valueAsDetails.type}`;
-          // @ts-ignore TODO(bckenny): this can never be null
-          this._dom.createChildOf(rowElem, 'td', classes).appendChild(this.render(valueAsDetails));
-          continue;
+        case 'link': {
+          return this._renderLink(value);
         }
+        case 'node': {
+          return this.renderNode(value);
+        }
+        case 'url': {
+          return this._renderTextURL(value);
+        }
+        default: {
+          throw new Error(`Unknown valueType: ${value.type}`);
+        }
+      }
+    }
 
-        // build new details item to render
-        const item = {
-          value: /** @type {number|string} */ (value),
-          type: heading.itemType,
-          displayUnit: heading.displayUnit,
+    // Next, deal with primitives.
+    switch (heading.valueType) {
+      case 'bytes': {
+        const numValue = Number(value);
+        return this._renderBytes({value: numValue, granularity: 1});
+      }
+      case 'code': {
+        const strValue = String(value);
+        return this._renderCode({value: strValue});
+      }
+      case 'ms': {
+        const msValue = {
+          value: Number(value),
           granularity: heading.granularity,
+          displayUnit: heading.displayUnit,
         };
-
-        /** @type {string|undefined} */
-        // @ts-ignore - TODO(bckenny): handle with refactoring above
-        const valueType = value.type;
-        const classes = `lh-table-column--${valueType || heading.itemType}`;
-        // @ts-ignore TODO(bckenny): this can never be null
-        this._dom.createChildOf(rowElem, 'td', classes).appendChild(this.render(item));
+        return this._renderMilliseconds(msValue);
+      }
+      case 'numeric': {
+        const strValue = String(value);
+        return this._renderNumeric({value: strValue});
+      }
+      case 'text': {
+        const strValue = String(value);
+        return this._renderText({value: strValue});
+      }
+      case 'thumbnail': {
+        const strValue = String(value);
+        return this._renderThumbnail({value: strValue});
+      }
+      case 'timespanMs': {
+        const numValue = Number(value);
+        return this._renderMilliseconds({value: numValue});
+      }
+      case 'url': {
+        const strValue = String(value);
+        if (URL_PREFIXES.some(prefix => strValue.startsWith(prefix))) {
+          return this._renderTextURL({value: strValue});
+        } else {
+          // Fall back to <pre> rendering if not actually a URL.
+          return this._renderCode({value: strValue});
+        }
+      }
+      default: {
+        throw new Error(`Unknown valueType: ${heading.valueType}`);
       }
     }
-    return tableElem;
   }
 
   /**
-   * TODO(bckenny): migrate remaining table rendering to this function, then rename
-   * back to _renderTable and replace the original.
-   * @param {OpportunityDetails} details
+   * Get the headings of a table-like details object, converted into the
+   * OpportunityColumnHeading type until we have all details use the same
+   * heading format.
+   * @param {LH.Audit.Details.Table|LH.Audit.Details.Opportunity} tableLike
+   * @return {Array<LH.Audit.Details.OpportunityColumnHeading>} header
+   */
+  _getCanonicalizedTableHeadings(tableLike) {
+    if (tableLike.type === 'opportunity') {
+      return tableLike.headings;
+    }
+
+    return tableLike.headings.map(heading => {
+      return {
+        key: heading.key,
+        label: heading.text,
+        valueType: heading.itemType,
+        displayUnit: heading.displayUnit,
+        granularity: heading.granularity,
+      };
+    });
+  }
+
+  /**
+   * @param {LH.Audit.Details.Table|LH.Audit.Details.Opportunity} details
    * @return {Element}
    */
-  _renderOpportunityTable(details) {
+  _renderTable(details) {
     if (!details.items.length) return this._dom.createElement('span');
 
     const tableElem = this._dom.createElement('table', 'lh-table');
     const theadElem = this._dom.createChildOf(tableElem, 'thead');
     const theadTrElem = this._dom.createChildOf(theadElem, 'tr');
 
-    for (const heading of details.headings) {
+    const headings = this._getCanonicalizedTableHeadings(details);
+
+    for (const heading of headings) {
       const valueType = heading.valueType || 'text';
       const classes = `lh-table-column--${valueType}`;
       const labelEl = this._dom.createElement('div', 'lh-text');
@@ -296,60 +320,38 @@ class DetailsRenderer {
     const tbodyElem = this._dom.createChildOf(tableElem, 'tbody');
     for (const row of details.items) {
       const rowElem = this._dom.createChildOf(tbodyElem, 'tr');
-      for (const heading of details.headings) {
-        const key = /** @type {keyof LH.Audit.Details.OpportunityItem} */ (heading.key);
-        const value = row[key];
-
-        if (typeof value === 'undefined' || value === null) {
+      for (const heading of headings) {
+        const value = row[heading.key];
+        const valueElement = this._renderTableValue(value, heading);
+
+        if (valueElement) {
+          const classes = `lh-table-column--${heading.valueType}`;
+          this._dom.createChildOf(rowElem, 'td', classes).appendChild(valueElement);
+        } else {
           this._dom.createChildOf(rowElem, 'td', 'lh-table-column--empty');
-          continue;
-        }
-
-        const valueType = heading.valueType;
-        let itemElement;
-
-        // TODO(bckenny): as we add more table types, split out into _renderTableItem fn.
-        switch (valueType) {
-          case 'url': {
-            // Fall back to <pre> rendering if not actually a URL.
-            const strValue = /** @type {string} */ (value);
-            if (URL_PREFIXES.some(prefix => strValue.startsWith(prefix))) {
-              itemElement = this._renderTextURL({value: strValue});
-            } else {
-              const codeValue = /** @type {(number|string|undefined)} */ (value);
-              itemElement = this._renderCode({value: codeValue});
-            }
-            break;
-          }
-          case 'timespanMs': {
-            const numValue = /** @type {number} */ (value);
-            itemElement = this._renderMilliseconds({value: numValue});
-            break;
-          }
-          case 'bytes': {
-            const numValue = /** @type {number} */ (value);
-            itemElement = this._renderBytes({value: numValue, granularity: 1});
-            break;
-          }
-          case 'thumbnail': {
-            const strValue = /** @type {string} */ (value);
-            itemElement = this._renderThumbnail({value: strValue});
-            break;
-          }
-          default: {
-            throw new Error(`Unknown valueType: ${valueType}`);
-          }
         }
-
-        const classes = `lh-table-column--${valueType}`;
-        this._dom.createChildOf(rowElem, 'td', classes).appendChild(itemElement);
       }
     }
     return tableElem;
   }
 
   /**
-   * @param {NodeDetailsJSON} item
+   * @param {LH.Audit.Details.List} details
+   * @returns {Element}
+   */
+  _renderList(details) {
+    const listContainer = this._dom.createElement('div', 'lh-list');
+
+    details.items.forEach(item => {
+      const snippetEl = SnippetRenderer.render(this._dom, this._templateContext, item, this);
+      listContainer.appendChild(snippetEl);
+    });
+
+    return listContainer;
+  }
+
+  /**
+   * @param {LH.Audit.Details.NodeValue} item
    * @return {Element}
    * @protected
    */
@@ -400,71 +402,3 @@ if (typeof module !== 'undefined' && module.exports) {
 } else {
   self.DetailsRenderer = DetailsRenderer;
 }
-
-// TODO, what's the diff between DetailsJSON and NumericUnitDetailsJSON?
-/**
- * @typedef {{
-      type: string,
-      value: (string|number|undefined),
-      granularity?: number,
-      displayUnit?: string
-  }} DetailsJSON
- */
-
-/**
- * @typedef {{
-      type: string,
-      value: string,
-      granularity?: number,
-      displayUnit?: string,
-  }} StringDetailsJSON
- */
-
-/**
- * @typedef {{
-      type: string,
-      value: number,
-      granularity?: number,
-      displayUnit?: string,
-  }} NumericUnitDetailsJSON
- */
-
-/**
- * @typedef {{
-      type: string,
-      path?: string,
-      selector?: string,
-      snippet?: string
-  }} NodeDetailsJSON
- */
-
-/**
- * @typedef {{
-      itemType: string,
-      key: string,
-      text?: string,
-      granularity?: number,
-      displayUnit?: string,
-  }} TableHeaderJSON
- */
-
-/** @typedef {{
-      type: string,
-      items: Array<DetailsJSON>,
-      headings: Array<TableHeaderJSON>
-  }} TableDetailsJSON
- */
-
-/** @typedef {{
-      type: string,
-      value: string,
-  }} ThumbnailDetails
- */
-
-/** @typedef {{
-      type: string,
-      text: string,
-      url: string
-  }} LinkDetailsJSON
- */
-
diff --git a/lighthouse-core/report/html/renderer/snippet-renderer.js b/lighthouse-core/report/html/renderer/snippet-renderer.js
new file mode 100644
index 000000000..1a627b5a0
--- /dev/null
+++ b/lighthouse-core/report/html/renderer/snippet-renderer.js
@@ -0,0 +1,364 @@
+/**
+ * @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';
+
+/* globals self, Util */
+
+/** @typedef {import('./details-renderer')} DetailsRenderer */
+
+/** @enum {number} */
+const LineVisibility = {
+  /** Show regardless of whether the snippet is collapsed or expanded */
+  ALWAYS: 0,
+  WHEN_COLLAPSED: 1,
+  WHEN_EXPANDED: 2,
+};
+
+/** @enum {number} */
+const LineContentType = {
+  /** A line of content */
+  CONTENT_NORMAL: 0,
+  /** A line of content that's emphasized by setting the CSS background color */
+  CONTENT_HIGHLIGHTED: 1,
+  /** Use when some lines are hidden, shows the "..." placeholder */
+  PLACEHOLDER: 2,
+  /** A message about a line of content or the snippet in general */
+  MESSAGE: 3,
+};
+
+/** @typedef {{
+    content: string;
+    lineNumber: string | number;
+    contentType: LineContentType;
+    truncated?: boolean;
+    visibility?: LineVisibility;
+}} LineDetails */
+
+const classNamesByContentType = {
+  [LineContentType.CONTENT_NORMAL]: ['lh-snippet__line--content'],
+  [LineContentType.CONTENT_HIGHLIGHTED]: [
+    'lh-snippet__line--content',
+    'lh-snippet__line--content-highlighted',
+  ],
+  [LineContentType.PLACEHOLDER]: ['lh-snippet__line--placeholder'],
+  [LineContentType.MESSAGE]: ['lh-snippet__line--message'],
+};
+
+/**
+ * @param {LH.Audit.Details.SnippetValue['lines']} lines
+ * @param {number} lineNumber
+ * @return {{line?: LH.Audit.Details.SnippetValue['lines'][0], previousLine?: LH.Audit.Details.SnippetValue['lines'][0]}}
+ */
+function getLineAndPreviousLine(lines, lineNumber) {
+  return {
+    line: lines.find(l => l.lineNumber === lineNumber),
+    previousLine: lines.find(l => l.lineNumber === lineNumber - 1),
+  };
+}
+
+/**
+ * @param {LH.Audit.Details.SnippetValue["lineMessages"]} messages
+ * @param {number} lineNumber
+ */
+function getMessagesForLineNumber(messages, lineNumber) {
+  return messages.filter(h => h.lineNumber === lineNumber);
+}
+
+/**
+ * @param {LH.Audit.Details.SnippetValue} details
+ * @returns {LH.Audit.Details.SnippetValue['lines']}
+ */
+function getLinesWhenCollapsed(details) {
+  const SURROUNDING_LINES_TO_SHOW_WHEN_COLLAPSED = 2;
+  return Util.filterRelevantLines(
+    details.lines,
+    details.lineMessages,
+    SURROUNDING_LINES_TO_SHOW_WHEN_COLLAPSED
+  );
+}
+
+/**
+ * Render snippet of text with line numbers and annotations.
+ * By default we only show a few lines around each annotation and the user
+ * can click "Expand snippet" to show more.
+ * Content lines with annotations are highlighted.
+ */
+class SnippetRenderer {
+  /**
+   * @param {DOM} dom
+   * @param {DocumentFragment} tmpl
+   * @param {LH.Audit.Details.SnippetValue} details
+   * @param {DetailsRenderer} detailsRenderer
+   * @param {function} toggleExpandedFn
+   * @return {DocumentFragment}
+   */
+  static renderHeader(dom, tmpl, details, detailsRenderer, toggleExpandedFn) {
+    const linesWhenCollapsed = getLinesWhenCollapsed(details);
+    const canExpand = linesWhenCollapsed.length < details.lines.length;
+
+    const header = dom.cloneTemplate('#tmpl-lh-snippet__header', tmpl);
+    dom.find('.lh-snippet__title', header).textContent = details.title;
+
+    const {
+      snippetCollapseButtonLabel,
+      snippetExpandButtonLabel,
+    } = Util.UIStrings;
+    dom.find(
+      '.lh-snippet__btn-label-collapse',
+      header
+    ).textContent = snippetCollapseButtonLabel;
+    dom.find(
+      '.lh-snippet__btn-label-expand',
+      header
+    ).textContent = snippetExpandButtonLabel;
+
+    const toggleExpandButton = dom.find('.lh-snippet__toggle-expand', header);
+    // If we're already showing all the available lines of the snippet, we don't need an
+    // expand/collapse button and can remove it from the DOM.
+    // If we leave the button in though, wire up the click listener to toggle visibility!
+    if (!canExpand) {
+      toggleExpandButton.remove();
+    } else {
+      toggleExpandButton.addEventListener('click', () => toggleExpandedFn());
+    }
+
+    // We only show the source node of the snippet in DevTools because then the user can
+    // access the full element detail. Just being able to see the outer HTML isn't very useful.
+    if (details.node && dom.isDevTools()) {
+      const nodeContainer = dom.find('.lh-snippet__node', header);
+      nodeContainer.appendChild(detailsRenderer.renderNode(details.node));
+    }
+
+    return header;
+  }
+
+  /**
+   * Renders a line (text content, message, or placeholder) as a DOM element.
+   * @param {DOM} dom
+   * @param {DocumentFragment} tmpl
+   * @param {LineDetails} lineDetails
+   * @return {Element}
+   */
+  static renderSnippetLine(
+      dom,
+      tmpl,
+      {content, lineNumber, truncated, contentType, visibility}
+  ) {
+    const clonedTemplate = dom.cloneTemplate('#tmpl-lh-snippet__line', tmpl);
+    const contentLine = dom.find('.lh-snippet__line', clonedTemplate);
+    const {classList} = contentLine;
+
+    classNamesByContentType[contentType].forEach(typeClass =>
+      classList.add(typeClass)
+    );
+
+    if (visibility === LineVisibility.WHEN_COLLAPSED) {
+      classList.add('lh-snippet__show-if-collapsed');
+    } else if (visibility === LineVisibility.WHEN_EXPANDED) {
+      classList.add('lh-snippet__show-if-expanded');
+    }
+
+    const lineContent = content + (truncated ? '…' : '');
+    const lineContentEl = dom.find('.lh-snippet__line code', contentLine);
+    if (contentType === LineContentType.MESSAGE) {
+      lineContentEl.appendChild(dom.convertMarkdownLinkSnippets(lineContent));
+    } else {
+      lineContentEl.textContent = lineContent;
+    }
+
+    dom.find(
+      '.lh-snippet__line-number',
+      contentLine
+    ).textContent = lineNumber.toString();
+
+    return contentLine;
+  }
+
+  /**
+   * @param {DOM} dom
+   * @param {DocumentFragment} tmpl
+   * @param {{message: string}} message
+   * @return {Element}
+   */
+  static renderMessage(dom, tmpl, message) {
+    return SnippetRenderer.renderSnippetLine(dom, tmpl, {
+      lineNumber: ' ',
+      content: message.message,
+      contentType: LineContentType.MESSAGE,
+    });
+  }
+
+  /**
+   * @param {DOM} dom
+   * @param {DocumentFragment} tmpl
+   * @param {LineVisibility} visibility
+   * @return {Element}
+   */
+  static renderOmittedLinesPlaceholder(dom, tmpl, visibility) {
+    return SnippetRenderer.renderSnippetLine(dom, tmpl, {
+      lineNumber: '…',
+      content: '',
+      visibility,
+      contentType: LineContentType.PLACEHOLDER,
+    });
+  }
+
+  /**
+   * @param {DOM} dom
+   * @param {DocumentFragment} tmpl
+   * @param {LH.Audit.Details.SnippetValue} details
+   * @return {DocumentFragment}
+   */
+  static renderSnippetContent(dom, tmpl, details) {
+    const template = dom.cloneTemplate('#tmpl-lh-snippet__content', tmpl);
+    const snippetEl = dom.find('.lh-snippet__snippet-inner', template);
+
+    // First render messages that don't belong to specific lines
+    details.generalMessages.forEach(m =>
+      snippetEl.append(SnippetRenderer.renderMessage(dom, tmpl, m))
+    );
+    // Then render the lines and their messages, as well as placeholders where lines are omitted
+    snippetEl.append(SnippetRenderer.renderSnippetLines(dom, tmpl, details));
+
+    return template;
+  }
+
+  /**
+   * @param {DOM} dom
+   * @param {DocumentFragment} tmpl
+   * @param {LH.Audit.Details.SnippetValue} details
+   * @returns {DocumentFragment}
+   */
+  static renderSnippetLines(dom, tmpl, details) {
+    const {lineMessages, generalMessages, lineCount, lines} = details;
+    const linesWhenCollapsed = getLinesWhenCollapsed(details);
+    const hasOnlyGeneralMessages =
+      generalMessages.length > 0 && lineMessages.length === 0;
+
+    const lineContainer = dom.createFragment();
+
+    // When a line is not shown in the collapsed state we try to see if we also need an
+    // omitted lines placeholder for the expanded state, rather than rendering two separate
+    // placeholders.
+    let hasPendingOmittedLinesPlaceholderForCollapsedState = false;
+
+    for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
+      const {line, previousLine} = getLineAndPreviousLine(lines, lineNumber);
+      const {
+        line: lineWhenCollapsed,
+        previousLine: previousLineWhenCollapsed,
+      } = getLineAndPreviousLine(linesWhenCollapsed, lineNumber);
+
+      const showLineWhenCollapsed = !!lineWhenCollapsed;
+      const showPreviousLineWhenCollapsed = !!previousLineWhenCollapsed;
+
+      // If we went from showing lines in the collapsed state to not showing them
+      // we need to render a placeholder
+      if (showPreviousLineWhenCollapsed && !showLineWhenCollapsed) {
+        hasPendingOmittedLinesPlaceholderForCollapsedState = true;
+      }
+      // If we are back to lines being visible in the collapsed and the placeholder
+      // hasn't been rendered yet then render it now
+      if (
+        showLineWhenCollapsed &&
+        hasPendingOmittedLinesPlaceholderForCollapsedState
+      ) {
+        lineContainer.append(
+          SnippetRenderer.renderOmittedLinesPlaceholder(
+            dom,
+            tmpl,
+            LineVisibility.WHEN_COLLAPSED
+          )
+        );
+        hasPendingOmittedLinesPlaceholderForCollapsedState = false;
+      }
+
+      // Render omitted lines placeholder if we have not already rendered one for this gap
+      const isFirstOmittedLineWhenExpanded = !line && !!previousLine;
+      const isFirstLineOverallAndIsOmittedWhenExpanded =
+        !line && lineNumber === 1;
+      if (
+        isFirstOmittedLineWhenExpanded ||
+        isFirstLineOverallAndIsOmittedWhenExpanded
+      ) {
+        // In the collapsed state we don't show omitted lines placeholders around
+        // the edges of the snippet
+        const hasRenderedAllLinesVisibleWhenCollapsed = !linesWhenCollapsed.some(
+          l => l.lineNumber > lineNumber
+        );
+        const onlyShowWhenExpanded =
+          hasRenderedAllLinesVisibleWhenCollapsed || lineNumber === 1;
+        lineContainer.append(
+          SnippetRenderer.renderOmittedLinesPlaceholder(
+            dom,
+            tmpl,
+            onlyShowWhenExpanded
+              ? LineVisibility.WHEN_EXPANDED
+              : LineVisibility.ALWAYS
+          )
+        );
+        hasPendingOmittedLinesPlaceholderForCollapsedState = false;
+      }
+
+      if (!line) {
+        // Can't render the line if we don't know its content (instead we've rendered a placeholder)
+        continue;
+      }
+
+      // Now render the line and any messages
+      const messages = getMessagesForLineNumber(lineMessages, lineNumber);
+      const highlightLine = messages.length > 0 || hasOnlyGeneralMessages;
+      const contentLineDetails = Object.assign({}, line, {
+        contentType: highlightLine
+          ? LineContentType.CONTENT_HIGHLIGHTED
+          : LineContentType.CONTENT_NORMAL,
+        visibility: lineWhenCollapsed
+          ? LineVisibility.ALWAYS
+          : LineVisibility.WHEN_EXPANDED,
+      });
+      lineContainer.append(
+        SnippetRenderer.renderSnippetLine(dom, tmpl, contentLineDetails)
+      );
+
+      messages.forEach(message => {
+        lineContainer.append(SnippetRenderer.renderMessage(dom, tmpl, message));
+      });
+    }
+
+    return lineContainer;
+  }
+
+  /**
+   * @param {DOM} dom
+   * @param {ParentNode} templateContext
+   * @param {LH.Audit.Details.SnippetValue} details
+   * @param {DetailsRenderer} detailsRenderer
+   * @return {Element}
+   */
+  static render(dom, templateContext, details, detailsRenderer) {
+    const tmpl = dom.cloneTemplate('#tmpl-lh-snippet', templateContext);
+    const snippetEl = dom.find('.lh-snippet', tmpl);
+
+    const header = SnippetRenderer.renderHeader(
+      dom,
+      tmpl,
+      details,
+      detailsRenderer,
+      () => snippetEl.classList.toggle('lh-snippet--expanded')
+    );
+    const content = SnippetRenderer.renderSnippetContent(dom, tmpl, details);
+    snippetEl.append(header, content);
+
+    return snippetEl;
+  }
+}
+
+// Allow Node require()'ing.
+if (typeof module !== 'undefined' && module.exports) {
+  module.exports = SnippetRenderer;
+} else {
+  self.SnippetRenderer = SnippetRenderer;
+}
diff --git a/lighthouse-core/report/html/renderer/util.js b/lighthouse-core/report/html/renderer/util.js
index 6a752d517..5da98dfaa 100644
--- a/lighthouse-core/report/html/renderer/util.js
+++ b/lighthouse-core/report/html/renderer/util.js
@@ -449,6 +449,48 @@ class Util {
     // When testing, use a locale with more exciting numeric formatting
     if (Util.numberDateLocale === 'en-XA') Util.numberDateLocale = 'de';
   }
+
+  /**
+   * Returns only lines that are near a message, or the first few lines if there are
+   * no line messages.
+   * @param {LH.Audit.Details.SnippetValue['lines']} lines
+   * @param {LH.Audit.Details.SnippetValue['lineMessages']} lineMessages
+   * @param {number} surroundingLineCount Number of lines to include before and after
+   * the message. If this is e.g. 2 this function might return 5 lines.
+   */
+  static filterRelevantLines(lines, lineMessages, surroundingLineCount) {
+    if (lineMessages.length === 0) {
+      // no lines with messages, just return the first bunch of lines
+      return lines.slice(0, surroundingLineCount * 2 + 1);
+    }
+
+    const minGapSize = 3;
+    const lineNumbersToKeep = new Set();
+    // Sort messages so we can check lineNumbersToKeep to see how big the gap to
+    // the previous line is.
+    lineMessages = lineMessages.sort((a, b) => (a.lineNumber || 0) - (b.lineNumber || 0));
+    lineMessages.forEach(({lineNumber}) => {
+      let firstSurroundingLineNumber = lineNumber - surroundingLineCount;
+      let lastSurroundingLineNumber = lineNumber + surroundingLineCount;
+
+      while (firstSurroundingLineNumber < 1) {
+        // make sure we still show (surroundingLineCount * 2 + 1) lines in total
+        firstSurroundingLineNumber++;
+        lastSurroundingLineNumber++;
+      }
+      // If only a few lines would be omitted normally then we prefer to include
+      // extra lines to avoid the tiny gap
+      if (lineNumbersToKeep.has(firstSurroundingLineNumber - minGapSize - 1)) {
+        firstSurroundingLineNumber -= minGapSize;
+      }
+      for (let i = firstSurroundingLineNumber; i <= lastSurroundingLineNumber; i++) {
+        const surroundingLineNumber = i;
+        lineNumbersToKeep.add(surroundingLineNumber);
+      }
+    });
+
+    return lines.filter(line => lineNumbersToKeep.has(line.lineNumber));
+  }
 }
 
 /**
@@ -496,6 +538,11 @@ Util.UIStrings = {
   /** Label of value shown in the summary of critical request chains. Refers to the total amount of time (milliseconds) of the longest critical path chain/sequence of network requests. Example value: 2310 ms */
   crcLongestDurationLabel: 'Maximum critical path latency:',
 
+  /** Label for button that shows all lines of the snippet when clicked */
+  snippetExpandButtonLabel: 'Expand snippet',
+  /** Label for button that only shows a few lines of the snippet when clicked */
+  snippetCollapseButtonLabel: 'Collapse snippet',
+
   /** Explanation shown to users below performance results to inform them that the test was done with a 4G network connection and to warn them that the numbers they see will likely change slightly the next time they run Lighthouse. 'Lighthouse' becomes link text to additional documentation. */
   lsPerformanceCategoryDescription: '[Lighthouse](https://developers.google.com/web/tools/lighthouse/) analysis of the current page on an emulated mobile network. Values are estimated and may vary.',
   /** Title of the lab data section of the Performance category. Within this section are various speed metrics which quantify the pageload performance into values presented in seconds and milliseconds. "Lab" is an abbreviated form of "laboratory", and refers to the fact that the data is from a controlled test of a website, not measurements from real users visiting that site.  */
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 8ece38dc6..7915e7bd0 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -725,6 +725,10 @@
   margin-top: var(--section-padding);
 }
 
+.lh-list > div:not(:last-child) {
+  padding-bottom: 20px;
+}
+
 .lh-header-container {
   display: block;
   margin: 0 auto;
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 7b5362bec..c63bc666f 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -892,3 +892,142 @@ limitations under the License.
     </div>
   </div>
 </template>
+
+
+<!-- Lighthouse snippet component -->
+<template id="tmpl-lh-snippet">
+    <div class="lh-snippet">
+      <style>
+          :root {
+            --snippet-highlight-light: #fbf1f2;
+            --snippet-highlight-dark: #ffd6d8;
+          }
+          
+         .lh-snippet__header {
+          position: relative;
+          overflow: hidden;
+          padding: 10px;
+          border-bottom: none;
+          color: var(--subheader-color);
+          background: var(--medium-50-gray);
+          border: 1px solid var(--report-secondary-border-color);
+        }
+        .lh-snippet__title {
+          font-weight: bold;
+          float: left;
+        }
+        .lh-snippet__node {
+          float: left;
+          margin-left: 4px;
+        }
+        .lh-snippet__toggle-expand {
+          padding: 1px 7px;
+          margin-top: -1px;
+          margin-right: -7px;
+          float: right;
+          background: transparent;
+          border: none;
+          cursor: pointer;
+          font-size: 14px;
+          color: #0c50c7;
+        }
+
+        .lh-snippet__snippet {
+          overflow: auto;
+          border: 1px solid var(--report-secondary-border-color);
+        }
+        /* Container needed so that all children grow to the width of the scroll container */
+        .lh-snippet__snippet-inner {
+          display: inline-block;
+          min-width: 100%;
+        }
+
+        .lh-snippet:not(.lh-snippet--expanded) .lh-snippet__show-if-expanded {
+          display: none;
+        }
+        .lh-snippet.lh-snippet--expanded .lh-snippet__show-if-collapsed {
+          display: none;
+        }
+
+        .lh-snippet__line {
+          background: white;
+          white-space: pre;
+          display: flex;
+        }        
+        .lh-snippet__line:not(.lh-snippet__line--message):first-child {
+          padding-top: 4px;
+        }
+        .lh-snippet__line:not(.lh-snippet__line--message):last-child {
+          padding-bottom: 4px;
+        }
+        .lh-snippet__line--content-highlighted {
+          background: var(--snippet-highlight-dark);
+        }
+        .lh-snippet__line--message {
+          background: var(--snippet-highlight-light);
+        }
+        .lh-snippet__line--message .lh-snippet__line-number {
+          padding-top: 10px;
+          padding-bottom: 10px;
+        }
+        .lh-snippet__line--message code {
+          padding: 10px;
+          padding-left: 5px;
+          color: var(--fail-color);
+          font-family: var(--text-font-family);
+        }
+        .lh-snippet__line--message code {
+          white-space: normal;
+        }
+        .lh-snippet__line-icon {
+          padding-top: 10px;
+          display: none;
+        }
+        .lh-snippet__line--message .lh-snippet__line-icon {
+          display: block;
+        }
+        .lh-snippet__line-icon:before {
+          content: "";
+          display: inline-block;
+          vertical-align: middle;
+          margin-right: 4px;
+          width: var(--lh-score-icon-width);
+          height: var(--lh-score-icon-width);
+          background-image: var(--fail-icon-url);
+        }
+        .lh-snippet__line-number {
+          flex-shrink: 0;
+          width: 40px;
+          text-align: right;
+          font-family: monospace;
+          padding-right: 5px;
+          margin-right: 5px;
+          color: var(--medium-75-gray);
+          user-select: none;
+        }
+      </style>
+      <template id="tmpl-lh-snippet__header">
+        <div class="lh-snippet__header">
+          <div class="lh-snippet__title"></div>
+          <div class="lh-snippet__node"></div>
+          <button class="lh-snippet__toggle-expand">
+            <span class="lh-snippet__btn-label-collapse lh-snippet__show-if-expanded"></span>
+            <span class="lh-snippet__btn-label-expand lh-snippet__show-if-collapsed"></span>
+          </button>
+        </div>
+      </template>
+      <template id="tmpl-lh-snippet__content">
+        <div class="lh-snippet__snippet">
+          <div class="lh-snippet__snippet-inner"></div>
+        </div>
+      </template>
+      <template id="tmpl-lh-snippet__line">
+          <div class="lh-snippet__line">
+            <div class="lh-snippet__line-number"></div>
+            <div class="lh-snippet__line-icon"></div>
+            <code></code>
+          </div>
+        </template>   
+    </div>
+  </template>
+  
diff --git a/package.json b/package.json
index 23e6564f4..99831c2e1 100644
--- a/package.json
+++ b/package.json
@@ -187,7 +187,7 @@
     },
     {
       "path": "./dist/viewer/src/viewer.js",
-      "threshold": "65 Kb"
+      "threshold": "70 Kb"
     }
   ],
   "nyc": {
diff --git a/proto/lighthouse-result.proto b/proto/lighthouse-result.proto
index df0658e7e..fcc45bcb1 100644
--- a/proto/lighthouse-result.proto
+++ b/proto/lighthouse-result.proto
@@ -334,6 +334,12 @@ message I18n {
 
     // The heading that is shown above a list of audits that have warnings
     string warning_audits_group_title = 17;
+
+    // The label for the button to show all lines of a snippet
+    string snippet_expand_button_label = 18;
+
+    // The label for the button to show only a few lines of a snippet
+    string snippet_collapse_button_label = 19;
   }
 
   // The message holding all formatted strings
diff --git a/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index ee6b196f0..0b6944e44 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -2,9 +2,14 @@
     "audits": {
         "accesskeys": {
             "description": "Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more](https://dequeuniversity.com/rules/axe/3.1/accesskeys?application=lighthouse).", 
+            "details": {
+                "headings": [], 
+                "items": [], 
+                "type": "table"
+            }, 
             "id": "accesskeys", 
-            "score": null, 
-            "scoreDisplayMode": "manual", 
+            "score": 1.0, 
+            "scoreDisplayMode": "binary", 
             "title": "`[accesskey]` values are unique"
         }, 
         "appcache-manifest": {
@@ -2997,6 +3002,11 @@
     "categories": {
         "accessibility": {
             "auditRefs": [
+                {
+                    "group": "a11y-navigation", 
+                    "id": "accesskeys", 
+                    "weight": 3.0
+                }, 
                 {
                     "group": "a11y-aria", 
                     "id": "aria-allowed-attr", 
@@ -3033,17 +3043,17 @@
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-correct-attributes", 
+                    "group": "a11y-audio-video", 
                     "id": "audio-caption", 
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-element-names", 
+                    "group": "a11y-names-labels", 
                     "id": "button-name", 
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-describe-contents", 
+                    "group": "a11y-navigation", 
                     "id": "bypass", 
                     "weight": 10.0
                 }, 
@@ -3053,27 +3063,27 @@
                     "weight": 6.0
                 }, 
                 {
-                    "group": "a11y-well-structured", 
+                    "group": "a11y-tables-lists", 
                     "id": "definition-list", 
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-well-structured", 
+                    "group": "a11y-tables-lists", 
                     "id": "dlitem", 
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-describe-contents", 
+                    "group": "a11y-names-labels", 
                     "id": "document-title", 
                     "weight": 2.0
                 }, 
                 {
-                    "group": "a11y-well-structured", 
+                    "group": "a11y-best-practices", 
                     "id": "duplicate-id", 
                     "weight": 5.0
                 }, 
                 {
-                    "group": "a11y-describe-contents", 
+                    "group": "a11y-names-labels", 
                     "id": "frame-title", 
                     "weight": 0.0
                 }, 
@@ -3088,67 +3098,67 @@
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-correct-attributes", 
+                    "group": "a11y-names-labels", 
                     "id": "image-alt", 
                     "weight": 8.0
                 }, 
                 {
-                    "group": "a11y-correct-attributes", 
+                    "group": "a11y-names-labels", 
                     "id": "input-image-alt", 
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-describe-contents", 
+                    "group": "a11y-names-labels", 
                     "id": "label", 
                     "weight": 10.0
                 }, 
                 {
-                    "group": "a11y-describe-contents", 
+                    "group": "a11y-tables-lists", 
                     "id": "layout-table", 
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-element-names", 
+                    "group": "a11y-names-labels", 
                     "id": "link-name", 
                     "weight": 9.0
                 }, 
                 {
-                    "group": "a11y-well-structured", 
+                    "group": "a11y-tables-lists", 
                     "id": "list", 
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-well-structured", 
+                    "group": "a11y-tables-lists", 
                     "id": "listitem", 
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-meta", 
+                    "group": "a11y-best-practices", 
                     "id": "meta-refresh", 
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-meta", 
+                    "group": "a11y-best-practices", 
                     "id": "meta-viewport", 
                     "weight": 3.0
                 }, 
                 {
-                    "group": "a11y-describe-contents", 
+                    "group": "a11y-names-labels", 
                     "id": "object-alt", 
                     "weight": 4.0
                 }, 
                 {
-                    "group": "a11y-correct-attributes", 
+                    "group": "a11y-navigation", 
                     "id": "tabindex", 
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-correct-attributes", 
+                    "group": "a11y-tables-lists", 
                     "id": "td-headers-attr", 
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-correct-attributes", 
+                    "group": "a11y-tables-lists", 
                     "id": "th-has-data-cells", 
                     "weight": 0.0
                 }, 
@@ -3158,19 +3168,15 @@
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-describe-contents", 
+                    "group": "a11y-audio-video", 
                     "id": "video-caption", 
                     "weight": 0.0
                 }, 
                 {
-                    "group": "a11y-describe-contents", 
+                    "group": "a11y-audio-video", 
                     "id": "video-description", 
                     "weight": 0.0
                 }, 
-                {
-                    "id": "accesskeys", 
-                    "weight": 0.0
-                }, 
                 {
                     "id": "logical-tab-order", 
                     "weight": 0.0
@@ -3219,7 +3225,7 @@
             "description": "These checks highlight opportunities to [improve the accessibility of your web app](https://developers.google.com/web/fundamentals/accessibility). Only a subset of accessibility issues can be automatically detected so manual testing is also encouraged.", 
             "id": "accessibility", 
             "manualDescription": "These items address areas which an automated testing tool cannot cover. Learn more in our guide on [conducting an accessibility review](https://developers.google.com/web/fundamentals/accessibility/how-to-review).", 
-            "score": 0.33, 
+            "score": 0.36, 
             "title": "Accessibility"
         }, 
         "best-practices": {
@@ -3630,35 +3636,35 @@
     "categoryGroups": {
         "a11y-aria": {
             "description": "These are opportunities to improve the usage of ARIA in your application which may enhance the experience for users of assistive technology, like a screen reader.", 
-            "title": "ARIA Attributes Follow Best Practices"
+            "title": "ARIA"
         }, 
-        "a11y-color-contrast": {
-            "description": "These are opportunities to improve the legibility of your content.", 
-            "title": "Color Contrast Is Satisfactory"
+        "a11y-audio-video": {
+            "description": "These are opportunities to provide alternative content for audio and video. This may improve the experience for users with hearing or vision impairments.", 
+            "title": "Audio and video"
         }, 
-        "a11y-correct-attributes": {
-            "description": "These are opportunities to improve the configuration of your HTML elements.", 
-            "title": "Elements Use Attributes Correctly"
+        "a11y-best-practices": {
+            "description": "These items highlight common accessibility best practices.", 
+            "title": "Best practices"
         }, 
-        "a11y-describe-contents": {
-            "description": "These are opportunities to make your content easier to understand for a user of assistive technology, like a screen reader.", 
-            "title": "Elements Describe Contents Well"
-        }, 
-        "a11y-element-names": {
-            "description": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.", 
-            "title": "Elements Have Discernible Names"
+        "a11y-color-contrast": {
+            "description": "These are opportunities to improve the legibility of your content.", 
+            "title": "Contrast"
         }, 
         "a11y-language": {
             "description": "These are opportunities to improve the interpretation of your content by users in different locales.", 
-            "title": "Page Specifies Valid Language"
+            "title": "Internationalization and localization"
         }, 
-        "a11y-meta": {
-            "description": "These are opportunities to improve the user experience of your site.", 
-            "title": "Meta Tags Used Properly"
+        "a11y-names-labels": {
+            "description": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.", 
+            "title": "Names and labels"
         }, 
-        "a11y-well-structured": {
-            "description": "These are opportunities to make sure your HTML is appropriately structured.", 
-            "title": "Elements Are Well Structured"
+        "a11y-navigation": {
+            "description": "These are opportunities to improve keyboard navigation in your application.", 
+            "title": "Navigation"
+        }, 
+        "a11y-tables-lists": {
+            "description": "These are opportunities to to improve the experience of reading tabular or list data using assistive technology, like a screen reader.", 
+            "title": "Tables and lists"
         }, 
         "diagnostics": {
             "description": "More information about the performance of your application.", 
@@ -3720,6 +3726,8 @@
             "opportunitySavingsColumnLabel": "Estimated Savings", 
             "passedAuditsGroupTitle": "Passed audits", 
             "scorescaleLabel": "Score scale:", 
+            "snippetCollapseButtonLabel": "Collapse snippet", 
+            "snippetExpandButtonLabel": "Expand snippet", 
             "toplevelWarningsMessage": "There were issues affecting this run of Lighthouse:", 
             "varianceDisclaimer": "Values are estimated and may vary.", 
             "warningAuditsGroupTitle": "Passed audits but with warnings", 
@@ -4151,6 +4159,12 @@
                 "name": "lh:audit:pwa-each-page-has-url", 
                 "startTime": 0.0
             }, 
+            {
+                "duration": 100.0, 
+                "entryType": "measure", 
+                "name": "lh:audit:accesskeys", 
+                "startTime": 0.0
+            }, 
             {
                 "duration": 100.0, 
                 "entryType": "measure", 
@@ -4355,12 +4369,6 @@
                 "name": "lh:audit:video-description", 
                 "startTime": 0.0
             }, 
-            {
-                "duration": 100.0, 
-                "entryType": "measure", 
-                "name": "lh:audit:accesskeys", 
-                "startTime": 0.0
-            }, 
             {
                 "duration": 100.0, 
                 "entryType": "measure", 
diff --git a/readme.md b/readme.md
index 5a77bc0f5..bb4aa2061 100644
--- a/readme.md
+++ b/readme.md
@@ -301,6 +301,7 @@ Other awesome open source projects that use Lighthouse.
 * **[lighthouse-ci](https://github.com/andreasonny83/lighthouse-ci)** - Run Lighthouse and assert scores satisfy your custom thresholds.
 * **[lighthouse4u](https://github.com/godaddy/lighthouse4u)** - LH4U provides Google Lighthouse as a service, surfaced by both a friendly UI+API, and backed by Elastic Search for easy querying and visualization.
 * **[lighthouse-gh-reporter](https://github.com/carlesnunez/lighthouse-gh-reporter)** - Run Lighthouse in CI and report back in a comment on your pull requests
+* **[react-lighthouse-viewer](https://www.npmjs.com/package/react-lighthouse-viewer)** - Render a Lighthouse JSON report in a React Component.
 
 ## FAQ
 
diff --git a/types/audit-details.d.ts b/types/audit-details.d.ts
index f2dd8a329..7bc1530d7 100644
--- a/types/audit-details.d.ts
+++ b/types/audit-details.d.ts
@@ -10,6 +10,7 @@ declare global {
       Details.CriticalRequestChain |
       Details.Diagnostic |
       Details.Filmstrip |
+      Details.List |
       Details.Opportunity |
       Details.Screenshot |
       Details.Table;
@@ -39,6 +40,11 @@ declare global {
         }[];
       }
 
+      export interface List {
+        type: 'list';
+        items: SnippetValue[]
+      }
+
       export interface Opportunity {
         type: 'opportunity';
         overallSavingsMs: number;
@@ -164,6 +170,38 @@ declare global {
         type: 'url';
         value: string;
       }
+
+      /**
+       * Snippet of text with line numbers and annotations.
+       */
+      export interface SnippetValue {
+        type: 'snippet',
+        title: string,
+        /** Node where the content of this snippet came from. */
+        node?: NodeValue,
+        /**
+         * The lines that should be rendered. For long snippets we only include important lines
+         * in the audit result.
+         */
+        lines: {
+          content: string
+          /** Line number, starting from 1. */
+          lineNumber: number;
+          truncated?: boolean
+        }[],
+        /** The total number of lines in the snippet, equal to lines.length for short snippets. */
+        lineCount: number,
+        /** Messages that provide information about a specific lines. */
+        lineMessages: {
+          /** Line number, starting from 1. */
+          lineNumber: number,
+          message: string
+        }[];
+        /** Messages that provide information about the snippet in general. */
+        generalMessages: {
+          message: string
+        }[];
+      }
     }
   }
 }
diff --git a/types/audit.d.ts b/types/audit.d.ts
index 41cceacad..0afbc416f 100644
--- a/types/audit.d.ts
+++ b/types/audit.d.ts
@@ -100,7 +100,6 @@ declare global {
       id: string;
       /** A more detailed description that describes why the audit is important and links to Lighthouse documentation on the audit; markdown links supported. */
       description: string;
-      // TODO(bckenny): define details
       details?: Audit.Details;
     }
 
diff --git a/types/html-renderer.d.ts b/types/html-renderer.d.ts
index 6dcc65d11..453b24399 100644
--- a/types/html-renderer.d.ts
+++ b/types/html-renderer.d.ts
@@ -6,6 +6,7 @@
 
 import _CategoryRenderer = require('../lighthouse-core/report/html/renderer/category-renderer.js');
 import _CriticalRequestChainRenderer = require('../lighthouse-core/report/html/renderer/crc-details-renderer.js');
+import _SnippetRenderer = require('../lighthouse-core/report/html/renderer/snippet-renderer.js');
 import _DetailsRenderer = require('../lighthouse-core/report/html/renderer/details-renderer.js');
 import _DOM = require('../lighthouse-core/report/html/renderer/dom.js');
 import _PerformanceCategoryRenderer = require('../lighthouse-core/report/html/renderer/performance-category-renderer.js');
@@ -19,6 +20,7 @@ import _FileNamer = require('../lighthouse-core/lib/file-namer.js');
 declare global {
   var CategoryRenderer: typeof _CategoryRenderer;
   var CriticalRequestChainRenderer: typeof _CriticalRequestChainRenderer;
+  var SnippetRenderer: typeof _SnippetRenderer;
   var DetailsRenderer: typeof _DetailsRenderer;
   var DOM: typeof _DOM;
   var getFilenamePrefix: typeof _FileNamer.getFilenamePrefix;
@@ -32,6 +34,7 @@ declare global {
   interface Window {
     CategoryRenderer: typeof _CategoryRenderer;
     CriticalRequestChainRenderer: typeof _CriticalRequestChainRenderer;
+    SnippetRenderer: typeof _SnippetRenderer;
     DetailsRenderer: typeof _DetailsRenderer;
     DOM: typeof _DOM;
     PerformanceCategoryRenderer: typeof _PerformanceCategoryRenderer;


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 1f34fbe16..19d1886f2 100644
--- a/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
+++ b/lighthouse-cli/test/cli/__snapshots__/index-test.js.snap
@@ -129,6 +129,9 @@ Object {
     Object {
       "path": "manual/pwa-each-page-has-url",
     },
+    Object {
+      "path": "accessibility/accesskeys",
+    },
     Object {
       "path": "accessibility/aria-allowed-attr",
     },
@@ -231,9 +234,6 @@ Object {
     Object {
       "path": "accessibility/video-description",
     },
-    Object {
-      "path": "accessibility/manual/accesskeys",
-    },
     Object {
       "path": "accessibility/manual/custom-controls-labels",
     },
@@ -376,6 +376,11 @@ Object {
   "categories": Object {
     "accessibility": Object {
       "auditRefs": Array [
+        Object {
+          "group": "a11y-navigation",
+          "id": "accesskeys",
+          "weight": 3,
+        },
         Object {
           "group": "a11y-aria",
           "id": "aria-allowed-attr",
@@ -412,17 +417,17 @@ Object {
           "weight": 5,
         },
         Object {
-          "group": "a11y-correct-attributes",
+          "group": "a11y-audio-video",
           "id": "audio-caption",
           "weight": 4,
         },
         Object {
-          "group": "a11y-element-names",
+          "group": "a11y-names-labels",
           "id": "button-name",
           "weight": 10,
         },
         Object {
-          "group": "a11y-describe-contents",
+          "group": "a11y-navigation",
           "id": "bypass",
           "weight": 10,
         },
@@ -432,27 +437,27 @@ Object {
           "weight": 6,
         },
         Object {
-          "group": "a11y-well-structured",
+          "group": "a11y-tables-lists",
           "id": "definition-list",
           "weight": 1,
         },
         Object {
-          "group": "a11y-well-structured",
+          "group": "a11y-tables-lists",
           "id": "dlitem",
           "weight": 1,
         },
         Object {
-          "group": "a11y-describe-contents",
+          "group": "a11y-names-labels",
           "id": "document-title",
           "weight": 2,
         },
         Object {
-          "group": "a11y-well-structured",
+          "group": "a11y-best-practices",
           "id": "duplicate-id",
           "weight": 5,
         },
         Object {
-          "group": "a11y-describe-contents",
+          "group": "a11y-names-labels",
           "id": "frame-title",
           "weight": 5,
         },
@@ -467,67 +472,67 @@ Object {
           "weight": 1,
         },
         Object {
-          "group": "a11y-correct-attributes",
+          "group": "a11y-names-labels",
           "id": "image-alt",
           "weight": 8,
         },
         Object {
-          "group": "a11y-correct-attributes",
+          "group": "a11y-names-labels",
           "id": "input-image-alt",
           "weight": 1,
         },
         Object {
-          "group": "a11y-describe-contents",
+          "group": "a11y-names-labels",
           "id": "label",
           "weight": 10,
         },
         Object {
-          "group": "a11y-describe-contents",
+          "group": "a11y-tables-lists",
           "id": "layout-table",
           "weight": 1,
         },
         Object {
-          "group": "a11y-element-names",
+          "group": "a11y-names-labels",
           "id": "link-name",
           "weight": 9,
         },
         Object {
-          "group": "a11y-well-structured",
+          "group": "a11y-tables-lists",
           "id": "list",
           "weight": 5,
         },
         Object {
-          "group": "a11y-well-structured",
+          "group": "a11y-tables-lists",
           "id": "listitem",
           "weight": 4,
         },
         Object {
-          "group": "a11y-meta",
+          "group": "a11y-best-practices",
           "id": "meta-refresh",
           "weight": 1,
         },
         Object {
-          "group": "a11y-meta",
+          "group": "a11y-best-practices",
           "id": "meta-viewport",
           "weight": 3,
         },
         Object {
-          "group": "a11y-describe-contents",
+          "group": "a11y-names-labels",
           "id": "object-alt",
           "weight": 4,
         },
         Object {
-          "group": "a11y-correct-attributes",
+          "group": "a11y-navigation",
           "id": "tabindex",
           "weight": 4,
         },
         Object {
-          "group": "a11y-correct-attributes",
+          "group": "a11y-tables-lists",
           "id": "td-headers-attr",
           "weight": 1,
         },
         Object {
-          "group": "a11y-correct-attributes",
+          "group": "a11y-tables-lists",
           "id": "th-has-data-cells",
           "weight": 1,
         },
@@ -537,19 +542,15 @@ Object {
           "weight": 1,
         },
         Object {
-          "group": "a11y-describe-contents",
+          "group": "a11y-audio-video",
           "id": "video-caption",
           "weight": 4,
         },
         Object {
-          "group": "a11y-describe-contents",
+          "group": "a11y-audio-video",
           "id": "video-description",
           "weight": 3,
         },
-        Object {
-          "id": "accesskeys",
-          "weight": 0,
-        },
         Object {
           "id": "logical-tab-order",
           "weight": 0,
@@ -999,35 +1000,35 @@ Object {
   "groups": Object {
     "a11y-aria": Object {
       "description": "These are opportunities to improve the usage of ARIA in your application which may enhance the experience for users of assistive technology, like a screen reader.",
-      "title": "ARIA Attributes Follow Best Practices",
-    },
-    "a11y-color-contrast": Object {
-      "description": "These are opportunities to improve the legibility of your content.",
-      "title": "Color Contrast Is Satisfactory",
+      "title": "ARIA",
     },
-    "a11y-correct-attributes": Object {
-      "description": "These are opportunities to improve the configuration of your HTML elements.",
-      "title": "Elements Use Attributes Correctly",
+    "a11y-audio-video": Object {
+      "description": "These are opportunities to provide alternative content for audio and video. This may improve the experience for users with hearing or vision impairments.",
+      "title": "Audio and video",
     },
-    "a11y-describe-contents": Object {
-      "description": "These are opportunities to make your content easier to understand for a user of assistive technology, like a screen reader.",
-      "title": "Elements Describe Contents Well",
+    "a11y-best-practices": Object {
+      "description": "These items highlight common accessibility best practices.",
+      "title": "Best practices",
     },
-    "a11y-element-names": Object {
-      "description": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.",
-      "title": "Elements Have Discernible Names",
+    "a11y-color-contrast": Object {
+      "description": "These are opportunities to improve the legibility of your content.",
+      "title": "Contrast",
     },
     "a11y-language": Object {
       "description": "These are opportunities to improve the interpretation of your content by users in different locales.",
-      "title": "Page Specifies Valid Language",
+      "title": "Internationalization and localization",
     },
-    "a11y-meta": Object {
-      "description": "These are opportunities to improve the user experience of your site.",
-      "title": "Meta Tags Used Properly",
+    "a11y-names-labels": Object {
+      "description": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.",
+      "title": "Names and labels",
+    },
+    "a11y-navigation": Object {
+      "description": "These are opportunities to improve keyboard navigation in your application.",
+      "title": "Navigation",
     },
-    "a11y-well-structured": Object {
-      "description": "These are opportunities to make sure your HTML is appropriately structured.",
-      "title": "Elements Are Well Structured",
+    "a11y-tables-lists": Object {
+      "description": "These are opportunities to to improve the experience of reading tabular or list data using assistive technology, like a screen reader.",
+      "title": "Tables and lists",
     },
     "diagnostics": Object {
       "description": "More information about the performance of your application.",
@@ -1248,35 +1249,35 @@ Object {
   "groups": Object {
     "a11y-aria": Object {
       "description": "These are opportunities to improve the usage of ARIA in your application which may enhance the experience for users of assistive technology, like a screen reader.",
-      "title": "ARIA Attributes Follow Best Practices",
-    },
-    "a11y-color-contrast": Object {
-      "description": "These are opportunities to improve the legibility of your content.",
-      "title": "Color Contrast Is Satisfactory",
+      "title": "ARIA",
     },
-    "a11y-correct-attributes": Object {
-      "description": "These are opportunities to improve the configuration of your HTML elements.",
-      "title": "Elements Use Attributes Correctly",
+    "a11y-audio-video": Object {
+      "description": "These are opportunities to provide alternative content for audio and video. This may improve the experience for users with hearing or vision impairments.",
+      "title": "Audio and video",
     },
-    "a11y-describe-contents": Object {
-      "description": "These are opportunities to make your content easier to understand for a user of assistive technology, like a screen reader.",
-      "title": "Elements Describe Contents Well",
+    "a11y-best-practices": Object {
+      "description": "These items highlight common accessibility best practices.",
+      "title": "Best practices",
     },
-    "a11y-element-names": Object {
-      "description": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.",
-      "title": "Elements Have Discernible Names",
+    "a11y-color-contrast": Object {
+      "description": "These are opportunities to improve the legibility of your content.",
+      "title": "Contrast",
     },
     "a11y-language": Object {
       "description": "These are opportunities to improve the interpretation of your content by users in different locales.",
-      "title": "Page Specifies Valid Language",
+      "title": "Internationalization and localization",
+    },
+    "a11y-names-labels": Object {
+      "description": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.",
+      "title": "Names and labels",
     },
-    "a11y-meta": Object {
-      "description": "These are opportunities to improve the user experience of your site.",
-      "title": "Meta Tags Used Properly",
+    "a11y-navigation": Object {
+      "description": "These are opportunities to improve keyboard navigation in your application.",
+      "title": "Navigation",
     },
-    "a11y-well-structured": Object {
-      "description": "These are opportunities to make sure your HTML is appropriately structured.",
-      "title": "Elements Are Well Structured",
+    "a11y-tables-lists": Object {
+      "description": "These are opportunities to to improve the experience of reading tabular or list data using assistive technology, like a screen reader.",
+      "title": "Tables and lists",
     },
     "diagnostics": Object {
       "description": "More information about the performance of your application.",
diff --git a/lighthouse-cli/test/fixtures/a11y/a11y_tester.html b/lighthouse-cli/test/fixtures/a11y/a11y_tester.html
index cc7a20dbf..4a8fbf988 100644
--- a/lighthouse-cli/test/fixtures/a11y/a11y_tester.html
+++ b/lighthouse-cli/test/fixtures/a11y/a11y_tester.html
@@ -11,6 +11,11 @@
     <meta name="viewport" content="user-scalable=no, maximum-scale=1.0">
   </head>
   <body>
+    <p>accesskeys</p>
+    <section>
+      <button id="accesskeys1" accesskey="s">Foo</button>
+      <button id="accesskeys2" accesskey="s">Bar</button>
+    </section>
     <p>aria-allowed-attr</p>
     <section>
       <div
diff --git a/lighthouse-cli/test/smokehouse/a11y/expectations.js b/lighthouse-cli/test/smokehouse/a11y/expectations.js
index 256d91c92..ef125a1f5 100644
--- a/lighthouse-cli/test/smokehouse/a11y/expectations.js
+++ b/lighthouse-cli/test/smokehouse/a11y/expectations.js
@@ -230,8 +230,12 @@ module.exports = [
         },
       },
       'accesskeys': {
-        score: null,
-        scoreDisplayMode: 'manual',
+        score: 0,
+        details: {
+          items: {
+            length: 1,
+          },
+        },
       },
     },
   },
diff --git a/lighthouse-core/test/audits/audit-test.js b/lighthouse-core/test/audits/audit-test.js
index 0e4251a2c..b45cd2202 100644
--- a/lighthouse-core/test/audits/audit-test.js
+++ b/lighthouse-core/test/audits/audit-test.js
@@ -99,4 +99,112 @@ describe('Audit', () => {
     assert.equal(result.score, null);
     assert.equal(result.scoreDisplayMode, 'error');
   });
+
+  describe('makeSnippetDetails', () => {
+    const maxLinesAroundMessage = 10;
+
+    it('Transforms code to lines array', () => {
+      const details = Audit.makeSnippetDetails({
+        content: 'a\nb\nc',
+        title: 'Title',
+        lineMessages: [],
+        generalMessages: [],
+      });
+
+      assert.equal(details.lines.length, 3);
+      assert.deepEqual(details.lines[1], {
+        lineNumber: 2,
+        content: 'b',
+      });
+    });
+
+    it('Truncates long lines', () => {
+      const details = Audit.makeSnippetDetails({
+        content: Array(1001).join('-'),
+        title: 'Title',
+        lineMessages: [],
+        generalMessages: [],
+      });
+
+      assert.equal(details.lines[0].truncated, true);
+      assert.ok(details.lines[0].content.length < 1000);
+    });
+
+    function makeLines(lineCount) {
+      return Array(lineCount + 1).join('-\n');
+    }
+
+    it('Limits the number of lines if there are no line messages', () => {
+      const details = Audit.makeSnippetDetails({
+        content: makeLines(100),
+        title: 'Title',
+        lineMessages: [],
+        generalMessages: [{
+          message: 'General',
+        }],
+        maxLinesAroundMessage,
+      });
+      expect(details.lines.length).toBe(2 * maxLinesAroundMessage + 1);
+    });
+
+    it('Does not omit lines if fewer than 4 lines would be omitted', () => {
+      const details = Audit.makeSnippetDetails({
+        content: makeLines(200),
+        title: 'Title',
+        lineMessages: [
+          // without the special logic for small gaps lines 71-73 would be missing
+          {
+            // putting last message first to make sure makeSnippetDetails doesn't depend on order
+            lineNumber: 84,
+            message: 'Message 2',
+          }, {
+            lineNumber: 60,
+            message: 'Message 1',
+          }],
+        generalMessages: [],
+        maxLinesAroundMessage,
+      });
+
+      const normalExpectedLineNumber = 2 * (maxLinesAroundMessage * 2 + 1);
+      assert.equal(details.lines.length, normalExpectedLineNumber + 3);
+    });
+
+    it('Limits the number of lines around line messages', () => {
+      const content = makeLines(99) + 'A\n' + makeLines(99) + '\nB';
+      const allLines = content.split('\n');
+      const details = Audit.makeSnippetDetails({
+        content,
+        title: 'Title',
+        lineMessages: [{
+          lineNumber: allLines.findIndex(l => l === 'A') + 1,
+          message: 'a',
+        }, {
+          lineNumber: allLines.findIndex(l => l === 'B') + 1,
+          message: 'b',
+        }],
+        generalMessages: [],
+        maxLinesAroundMessage,
+      });
+
+      // 2 line messages and their surounding lines, second line with message only has preceding lines
+      const lineCount = maxLinesAroundMessage * 3 + 2;
+      assert.equal(details.lines.length, lineCount);
+      const lastLine = details.lines.slice(-1)[0];
+      assert.deepEqual(lastLine, {
+        lineNumber: 201,
+        content: 'B',
+      });
+    });
+  });
+
+  describe('makeListDetails', () => {
+    it('Generates list details', () => {
+      const details = Audit.makeListDetails([1, 2, 3]);
+
+      assert.deepEqual(details, {
+        type: 'list',
+        items: [1, 2, 3],
+      });
+    });
+  });
 });
diff --git a/lighthouse-core/test/gather/driver-test.js b/lighthouse-core/test/gather/driver-test.js
index 74d0b1649..817dac10c 100644
--- a/lighthouse-core/test/gather/driver-test.js
+++ b/lighthouse-core/test/gather/driver-test.js
@@ -49,6 +49,91 @@ function createMockSendCommandFn() {
   return mockFn;
 }
 
+/**
+ * Creates a jest mock function whose implementation invokes `.on`/`.once` listeners after a setTimeout tick.
+ * Closely mirrors `createMockSendCommandFn`.
+ *
+ * It is decorated with two methods:
+ *    - `mockEvent` which pushes protocol event payload for consumption
+ *    - `findListener` which asserts that `on` was invoked with the given event name and
+ *      returns the listener .
+ */
+function createMockOnceFn() {
+  const mockEvents = [];
+  const mockFn = jest.fn().mockImplementation((eventName, listener) => {
+    const indexOfResponse = mockEvents.findIndex(entry => entry.event === eventName);
+    if (indexOfResponse === -1) return;
+    const {response} = mockEvents[indexOfResponse];
+    mockEvents.splice(indexOfResponse, 1);
+    // Wait a tick because real events never fire immediately
+    setTimeout(() => listener(response), 0);
+  });
+
+  mockFn.mockEvent = (event, response) => {
+    mockEvents.push({event, response});
+    return mockFn;
+  };
+
+  mockFn.findListener = event => {
+    expect(mockFn).toHaveBeenCalledWith(event, expect.anything());
+    return mockFn.mock.calls.find(call => call[0] === event)[1];
+  };
+
+  return mockFn;
+}
+
+/**
+ * Transparently augments the promise with inspectable functions to query its state.
+ *
+ * @template T
+ * @param {Promise<T>} promise
+ * @return {Promise<T> & {isDone: () => boolean, isResolved: () => boolean, isRejected: () => boolean}}
+ */
+function makePromiseInspectable(promise) {
+  let isResolved = false;
+  let isRejected = false;
+  let resolvedValue = undefined;
+  let rejectionError = undefined;
+  const inspectablePromise = promise.then(value => {
+    isResolved = true;
+    resolvedValue = value;
+    return value;
+  }).catch(err => {
+    isRejected = true;
+    rejectionError = err;
+    throw err;
+  });
+
+  inspectablePromise.isDone = () => isResolved || isRejected;
+  inspectablePromise.isResolved = () => isResolved;
+  inspectablePromise.isRejected = () => isRejected;
+  inspectablePromise.getDebugValues = () => ({resolvedValue, rejectionError});
+
+  return inspectablePromise;
+}
+
+expect.extend({
+  /**
+   * Asserts that an inspectable promise created by makePromiseInspectable is currently resolved or rejected.
+   * This is useful for situations where we want to test that we are actually waiting for a particular event.
+   *
+   * @param {ReturnType<makePromiseInspectable>} received
+   * @param {string} failureMessage
+   */
+  toBeDone(received, failureMessage) {
+    const pass = received.isDone();
+
+    const message = () =>
+      [
+        `${this.utils.matcherHint('.toBeDone')}\n`,
+        `Expected promise to be resolved: ${this.utils.printExpected(failureMessage)}`,
+        `  ${this.utils.printReceived(received.getDebugValues())}`,
+      ].join('\n');
+
+    return {message, pass};
+  },
+});
+
 /**
  * In some functions we have lots of promise follow ups that get queued by protocol messages.
  * This is a convenience method to easily advance all timers and flush all the queued microtasks.
@@ -60,18 +145,6 @@ async function flushAllTimersAndMicrotasks() {
   }
 }
 
-function createOnceStub(events) {
-  return (eventName, cb) => {
-    if (events[eventName]) {
-      // wait a tick b/c real events never fire immediately
-      setTimeout(_ => cb(events[eventName]), 0);
-      return;
-    }
-
-    throw Error(`Stub not implemented: ${eventName}`);
-  };
-}
-
 let driver;
 let connectionStub;
 
@@ -360,6 +433,24 @@ describe('.goOffline', () => {
 });
 
 describe('.gotoURL', () => {
+  function createMockWaitForFn() {
+    let resolve;
+    let reject;
+    const promise = new Promise((r1, r2) => {
+      resolve = r1;
+      reject = r2;
+    });
+
+    const mockCancelFn = jest.fn();
+    const mockFn = jest.fn().mockReturnValue({promise, cancel: mockCancelFn});
+
+    mockFn.mockResolve = () => resolve();
+    mockFn.mockReject = err => reject(err || new Error('Rejected'));
+    mockFn.getMockCancelFn = () => mockCancelFn;
+
+    return mockFn;
+  }
+
   beforeEach(() => {
     connectionStub.sendCommand = createMockSendCommandFn()
       .mockResponse('Network.enable', {})
@@ -421,27 +512,140 @@ describe('.gotoURL', () => {
     expect(loadedUrl).toEqual(finalUrl);
   });
 
-  describe('when waitForNavigated', () => {});
+  describe('when waitForNavigated', () => {
+    it('waits for Page.frameNavigated', async () => {
+      driver.on = driver.once = createMockOnceFn();
+
+      const url = 'https://www.example.com';
+      const loadOptions = {
+        waitForNavigated: true,
+      };
+
+      const loadPromise = makePromiseInspectable(driver.gotoURL(url, loadOptions));
+      await flushAllTimersAndMicrotasks();
+      expect(loadPromise).not.toBeDone('Did not wait for frameNavigated');
+
+      // Use `findListener` instead of `mockEvent` so we can control exactly when the promise resolves
+      const listener = driver.on.findListener('Page.frameNavigated');
+      listener();
+      await flushAllTimersAndMicrotasks();
+      expect(loadPromise).toBeDone('Did not resolve after frameNavigated');
+
+      await loadPromise;
+    });
+  });
 
   describe('when waitForLoad', () => {
+    const url = 'https://example.com';
+
+    ['FCP', 'LoadEvent', 'NetworkIdle', 'CPUIdle'].forEach(name => {
+      it(`should wait for ${name}`, async () => {
+        driver._waitForFCP = createMockWaitForFn();
+        driver._waitForLoadEvent = createMockWaitForFn();
+        driver._waitForNetworkIdle = createMockWaitForFn();
+        driver._waitForCPUIdle = createMockWaitForFn();
+
+        const waitForResult = driver[`_waitFor${name}`];
+        const otherWaitForResults = [
+          driver._waitForFCP,
+          driver._waitForLoadEvent,
+          driver._waitForNetworkIdle,
+          driver._waitForCPUIdle,
+        ].filter(l => l !== waitForResult);
+
+        const loadPromise = makePromiseInspectable(driver.gotoURL(url, {
+          waitForFCP: true,
+          waitForLoad: true,
+        }));
+
+        // shouldn't finish all on its own
+        await flushAllTimersAndMicrotasks();
+        expect(loadPromise).not.toBeDone(`Did not wait for anything (${name})`);
+
+        // shouldn't resolve after all the other listeners
+        otherWaitForResults.forEach(result => result.mockResolve());
+        await flushAllTimersAndMicrotasks();
+        expect(loadPromise).not.toBeDone(`Did not wait for ${name}`);
+
+        waitForResult.mockResolve();
+        await flushAllTimersAndMicrotasks();
+        expect(loadPromise).toBeDone(`Did not resolve on ${name}`);
+        await loadPromise;
+      });
+    });
+
+    it('should wait for CPU Idle *after* network idle', async () => {
+      driver._waitForLoadEvent = createMockWaitForFn();
+      driver._waitForNetworkIdle = createMockWaitForFn();
+      driver._waitForCPUIdle = createMockWaitForFn();
+
+      const loadPromise = makePromiseInspectable(driver.gotoURL(url, {
+        waitForLoad: true,
+      }));
+
+      // shouldn't finish all on its own
+      await flushAllTimersAndMicrotasks();
+      expect(loadPromise).not.toBeDone(`Did not wait for anything`);
+      expect(driver._waitForLoadEvent).toHaveBeenCalled();
+      expect(driver._waitForNetworkIdle).toHaveBeenCalled();
+      expect(driver._waitForCPUIdle).not.toHaveBeenCalled();
+
+      // should have been called now
+      driver._waitForLoadEvent.mockResolve();
+      driver._waitForNetworkIdle.mockResolve();
+      await flushAllTimersAndMicrotasks();
+      expect(driver._waitForCPUIdle).toHaveBeenCalled();
+      expect(loadPromise).not.toBeDone(`Did not wait for CPU idle`);
+
+      driver._waitForCPUIdle.mockResolve();
+      await flushAllTimersAndMicrotasks();
+      expect(loadPromise).toBeDone(`Did not resolve on CPU idle`);
+      await loadPromise;
+    });
+
+    it('should timeout when not resolved fast enough', async () => {
+      driver._waitForLoadEvent = createMockWaitForFn();
+      driver._waitForNetworkIdle = createMockWaitForFn();
+      driver._waitForCPUIdle = createMockWaitForFn();
+
+      const loadPromise = makePromiseInspectable(driver.gotoURL(url, {
+        waitForLoad: true,
+        passContext: {
+          passConfig: {},
+          settings: {
+            maxWaitForLoad: 60000,
+          },
+        },
+      }));
+
+      // Resolve load and network to make sure we install CPU
+      driver._waitForLoadEvent.mockResolve();
+      driver._waitForNetworkIdle.mockResolve();
+      await flushAllTimersAndMicrotasks();
+      expect(loadPromise).not.toBeDone(`Did not wait for CPU idle`);
+
+      jest.advanceTimersByTime(60001);
+      await flushAllTimersAndMicrotasks();
+      expect(loadPromise).toBeDone(`Did not wait for timeout`);
+      // Check that we cancelled all our listeners
+      expect(driver._waitForLoadEvent.getMockCancelFn()).toHaveBeenCalled();
+      expect(driver._waitForNetworkIdle.getMockCancelFn()).toHaveBeenCalled();
+      expect(driver._waitForCPUIdle.getMockCancelFn()).toHaveBeenCalled();
+    });
+
     it('does not reject when page is secure', async () => {
       const secureSecurityState = {
         explanations: [],
         securityState: 'secure',
       };
-      driver.on = driver.once = createOnceStub({
-        'Security.securityStateChanged': secureSecurityState,
-        'Page.loadEventFired': {},
-        'Page.domContentEventFired': {},
-      });
+
+      driver.on = driver.once = createMockOnceFn()
+        .mockEvent('Security.securityStateChanged', secureSecurityState);
 
       const startUrl = 'https://www.example.com';
       const loadOptions = {
         waitForLoad: true,
         passContext: {
-          passConfig: {
-            networkQuietThresholdMs: 1,
-          },
           settings: {
             maxWaitForLoad: 1,
           },
@@ -471,9 +675,8 @@ describe('.gotoURL', () => {
         ],
         securityState: 'insecure',
       };
-      driver.on = createOnceStub({
-        'Security.securityStateChanged': insecureSecurityState,
-      });
+
+      driver.on = driver.once = createMockOnceFn();
 
       const startUrl = 'https://www.example.com';
       const loadOptions = {
@@ -485,14 +688,20 @@ describe('.gotoURL', () => {
         },
       };
 
-      expect.assertions(2);
+      // 2 assertions in the catch block and the 1 implicit in `findListener`
+      expect.assertions(3);
 
       try {
         const loadPromise = driver.gotoURL(startUrl, loadOptions);
         await flushAllTimersAndMicrotasks();
+
+        // Use `findListener` instead of `mockEvent` so we can control exactly when the promise resolves
+        const listener = driver.on.findListener('Security.securityStateChanged');
+        listener(insecureSecurityState);
+        await flushAllTimersAndMicrotasks();
         await loadPromise;
       } catch (err) {
-        expect(err.code).toEqual('INSECURE_DOCUMENT_REQUEST');
+        expect(err).toHaveProperty('code', 'INSECURE_DOCUMENT_REQUEST');
         expect(err.friendlyMessage).toBeDisplayString(
           'The URL you have provided does not have valid security credentials. reason 1. reason 2.'
         );
@@ -529,18 +738,10 @@ describe('.assertNoSameOriginServiceWorkerClients', () => {
 
   it('will pass if there are no current service workers', async () => {
     const pageUrl = 'https://example.com/';
-    driver.once = createOnceStub({
-      'ServiceWorker.workerRegistrationUpdated': {
-        registrations: [],
-      },
-    });
-
-    driver.on = createOnceStub({
-      'ServiceWorker.workerVersionUpdated': {
-        versions: [],
-      },
-    });
 
+    driver.on = driver.once = createMockOnceFn()
+      .mockEvent('ServiceWorker.workerRegistrationUpdated', {registrations: []})
+      .mockEvent('ServiceWorker.workerVersionUpdated', {versions: []});
 
     const assertPromise = driver.assertNoSameOriginServiceWorkerClients(pageUrl);
     await flushAllTimersAndMicrotasks();
@@ -555,17 +756,9 @@ describe('.assertNoSameOriginServiceWorkerClients', () => {
     const registrations = [createSWRegistration(1, secondUrl)];
     const versions = [createActiveWorker(1, swUrl, ['uniqueId'])];
 
-    driver.once = createOnceStub({
-      'ServiceWorker.workerRegistrationUpdated': {
-        registrations,
-      },
-    });
-
-    driver.on = createOnceStub({
-      'ServiceWorker.workerVersionUpdated': {
-        versions,
-      },
-    });
+    driver.on = driver.once = createMockOnceFn()
+      .mockEvent('ServiceWorker.workerRegistrationUpdated', {registrations})
+      .mockEvent('ServiceWorker.workerVersionUpdated', {versions});
 
     const assertPromise = driver.assertNoSameOriginServiceWorkerClients(pageUrl);
     await flushAllTimersAndMicrotasks();
@@ -578,17 +771,9 @@ describe('.assertNoSameOriginServiceWorkerClients', () => {
     const registrations = [createSWRegistration(1, pageUrl)];
     const versions = [createActiveWorker(1, swUrl, ['uniqueId'])];
 
-    driver.once = createOnceStub({
-      'ServiceWorker.workerRegistrationUpdated': {
-        registrations,
-      },
-    });
-
-    driver.on = createOnceStub({
-      'ServiceWorker.workerVersionUpdated': {
-        versions,
-      },
-    });
+    driver.on = driver.once = createMockOnceFn()
+      .mockEvent('ServiceWorker.workerRegistrationUpdated', {registrations})
+      .mockEvent('ServiceWorker.workerVersionUpdated', {versions});
 
     expect.assertions(1);
 
@@ -607,17 +792,9 @@ describe('.assertNoSameOriginServiceWorkerClients', () => {
     const registrations = [createSWRegistration(1, pageUrl)];
     const versions = [createActiveWorker(1, swUrl, [])];
 
-    driver.once = createOnceStub({
-      'ServiceWorker.workerRegistrationUpdated': {
-        registrations,
-      },
-    });
-
-    driver.on = createOnceStub({
-      'ServiceWorker.workerVersionUpdated': {
-        versions,
-      },
-    });
+    driver.on = driver.once = createMockOnceFn()
+      .mockEvent('ServiceWorker.workerRegistrationUpdated', {registrations})
+      .mockEvent('ServiceWorker.workerVersionUpdated', {versions});
 
     const assertPromise = driver.assertNoSameOriginServiceWorkerClients(pageUrl);
     await flushAllTimersAndMicrotasks();
@@ -629,31 +806,25 @@ describe('.assertNoSameOriginServiceWorkerClients', () => {
     const swUrl = `${pageUrl}sw.js`;
     const registrations = [createSWRegistration(1, pageUrl)];
     const versions = [createActiveWorker(1, swUrl, [], 'installing')];
+    const activatedVersions = [createActiveWorker(1, swUrl, [], 'activated')];
 
-    driver.once = createOnceStub({
-      'ServiceWorker.workerRegistrationUpdated': {
-        registrations,
-      },
-    });
-
-    driver.on = (eventName, cb) => {
-      if (eventName === 'ServiceWorker.workerVersionUpdated') {
-        cb({versions});
+    driver.on = driver.once = createMockOnceFn()
+      .mockEvent('ServiceWorker.workerRegistrationUpdated', {registrations})
+      .mockEvent('ServiceWorker.workerVersionUpdated', {versions});
 
-        setTimeout(() => {
-          cb({
-            versions: [createActiveWorker(1, swUrl, [], 'activated')],
-          });
-        }, 50);
-
-        return;
-      }
+    const assertPromise = driver.assertNoSameOriginServiceWorkerClients(pageUrl);
+    const inspectable = makePromiseInspectable(assertPromise);
 
-      throw Error(`Stub not implemented: ${eventName}`);
-    };
+    // After receiving the empty versions the promise still shouldn't be resolved
+    await flushAllTimersAndMicrotasks();
+    expect(inspectable).not.toBeDone();
 
-    const assertPromise = driver.assertNoSameOriginServiceWorkerClients(pageUrl);
+    // Use `findListener` instead of `mockEvent` so we can control exactly when the promise resolves
+    // After we invoke the listener with the activated versions we expect the promise to have resolved
+    const listener = driver.on.findListener('ServiceWorker.workerVersionUpdated');
+    listener({versions: activatedVersions});
     await flushAllTimersAndMicrotasks();
+    expect(inspectable).toBeDone();
     await assertPromise;
   });
 });
diff --git a/lighthouse-core/test/report/html/renderer/category-renderer-test.js b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
index b02215e90..209b8ea4e 100644
--- a/lighthouse-core/test/report/html/renderer/category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
@@ -201,7 +201,7 @@ describe('CategoryRenderer', () => {
       const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
 
       const gauge = categoryDOM.querySelector('.lh-gauge__percentage');
-      assert.equal(gauge.textContent.trim(), '33', 'score is 0-100');
+      assert.equal(gauge.textContent.trim(), '36', 'score is 0-100');
 
       const score = categoryDOM.querySelector('.lh-category-header');
       const value = categoryDOM.querySelector('.lh-gauge__percentage');
diff --git a/lighthouse-core/test/report/html/renderer/details-renderer-test.js b/lighthouse-core/test/report/html/renderer/details-renderer-test.js
index 19f54df5d..21d00e620 100644
--- a/lighthouse-core/test/report/html/renderer/details-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/details-renderer-test.js
@@ -12,6 +12,8 @@ const URL = require('../../../../lib/url-shim');
 const DOM = require('../../../../report/html/renderer/dom.js');
 const Util = require('../../../../report/html/renderer/util.js');
 const DetailsRenderer = require('../../../../report/html/renderer/details-renderer.js');
+const SnippetRenderer = require('../../../../report/html/renderer/snippet-renderer.js');
+const CrcDetailsRenderer = require('../../../../report/html/renderer/crc-details-renderer.js');
 
 const TEMPLATE_FILE = fs.readFileSync(__dirname +
     '/../../../../report/html/templates.html', 'utf8');
@@ -24,49 +26,22 @@ describe('DetailsRenderer', () => {
   beforeAll(() => {
     global.URL = URL;
     global.Util = Util;
+    global.CriticalRequestChainRenderer = CrcDetailsRenderer;
+    global.SnippetRenderer = SnippetRenderer;
     const {document} = new jsdom.JSDOM(TEMPLATE_FILE).window;
     const dom = new DOM(document);
     renderer = new DetailsRenderer(dom);
+    renderer.setTemplateContext(dom.document());
   });
 
   afterAll(() => {
     global.URL = undefined;
     global.Util = undefined;
+    global.CriticalRequestChainRenderer = undefined;
+    global.SnippetRenderer = undefined;
   });
 
   describe('render', () => {
-    it('renders text', () => {
-      const el = renderer.render({type: 'text', value: 'My text content'});
-      assert.equal(el.textContent, 'My text content');
-      assert.ok(el.classList.contains('lh-text'), 'adds classes');
-    });
-
-    it('renders code', () => {
-      const el = renderer.render({
-        type: 'code',
-        value: 'code snippet',
-        lineNumber: 123,
-        source: 'deprecation',
-        url: 'https://example.com/feature',
-      });
-
-      assert.ok(el.localName === 'pre');
-      assert.ok(el.classList.contains('lh-code'));
-      assert.equal(el.textContent, 'code snippet');
-    });
-
-    it('renders thumbnails', () => {
-      const el = renderer.render({
-        type: 'thumbnail',
-        value: 'http://example.com/my-image.jpg',
-        mimeType: 'image/jpeg',
-      });
-
-      assert.ok(el.localName === 'img');
-      assert.ok(el.classList.contains('lh-thumbnail'));
-      assert.equal(el.src, 'http://example.com/my-image.jpg');
-    });
-
     it('renders filmstrips', () => {
       const el = renderer.render({
         type: 'filmstrip',
@@ -100,12 +75,12 @@ describe('DetailsRenderer', () => {
           {
             a: 'value A.1',
             b: 'value A.2',
-            c: {type: 'thumbnail', value: 'http://example.com/image.jpg'},
+            c: 'http://example.com/image.jpg',
           },
           {
             a: 'value B.1',
             b: 'value B.2',
-            c: {type: 'thumbnail', value: 'unknown'},
+            c: 'unknown',
           },
         ],
       });
@@ -119,47 +94,400 @@ describe('DetailsRenderer', () => {
           '--thumbnail not set');
     });
 
-    it('renders links', () => {
+    it('renders critical request chains', () => {
+      const details = {
+        type: 'criticalrequestchain',
+        longestChain: {
+          duration: 2,
+          length: 1,
+          transferSize: 221,
+        },
+        chains: {
+          F3B687683512E0F003DD41EB23E2091A: {
+            request: {
+              url: 'https://example.com',
+              startTime: 0,
+              endTime: 2,
+              responseReceivedTime: 1,
+              transferSize: 221,
+            },
+            children: {},
+          },
+        },
+      };
+
+      const crcEl = renderer.render(details);
+      assert.ok(crcEl.classList.contains('lh-crc-container'));
+      assert.strictEqual(crcEl.querySelectorAll('.crc-node').length, 1);
+    });
+
+    it('renders opportunity details as a table', () => {
+      const details = {
+        type: 'opportunity',
+        headings: [
+          {key: 'url', valueType: 'url', label: 'URL'},
+          {key: 'totalBytes', valueType: 'bytes', label: 'Size (KB)'},
+          {key: 'wastedBytes', valueType: 'bytes', label: 'Potential Savings (KB)'},
+        ],
+        items: [{
+          url: 'https://example.com',
+          totalBytes: 71654,
+          wastedBytes: 30470,
+          wastedPercent: 42,
+        }],
+        overallSavingsMs: 150,
+        overallSavingsBytes: 30470,
+      };
+
+      const oppEl = renderer.render(details);
+      assert.equal(oppEl.localName, 'table');
+      assert.ok(oppEl.querySelector('.lh-text__url').title === 'https://example.com', 'did not render recursive items');
+      assert.equal(oppEl.querySelectorAll('th').length, 3, 'did not render header items');
+      assert.equal(oppEl.querySelectorAll('td').length, 3, 'did not render table cells');
+      assert.equal(oppEl.querySelectorAll('.lh-table-column--url').length, 2, 'url column not set');
+      assert.equal(oppEl.querySelectorAll('.lh-table-column--bytes').length, 4, 'bytes not set');
+    });
+
+    it('renders lists', () => {
+      const snippet = {
+        type: 'snippet',
+        lines: [{lineNumber: 1, content: ''}],
+        title: 'Some snippet',
+        lineMessages: [],
+        generalMessages: [],
+        lineCount: 100,
+      };
+
+      const el = renderer.render({
+        type: 'list',
+        items: [snippet, snippet],
+      });
+
+      assert.equal(el.localName, 'div');
+      assert.ok(el.classList.contains('lh-list'), 'has list class');
+      assert.ok(el.children.length, 2, 'renders all items');
+      assert.ok(el.children[0].textContent.includes('Some snippet'), 'renders item content');
+    });
+
+    it('does not render internal-only screenshot details', () => {
+      const details = {
+        type: 'screenshot',
+        timestamp: 185600000,
+        data: 'data:image/jpeg;base64,/9j/4AAQSkZJRYP/2Q==',
+      };
+
+      const screenshotEl = renderer.render(details);
+      assert.strictEqual(screenshotEl, null);
+    });
+
+    it('does not render internal-only diagnostic details', () => {
+      const details = {
+        type: 'diagnostic',
+        items: [{
+          failures: ['No manifest was fetched'],
+          isParseFailure: true,
+          parseFailureReason: 'No manifest was fetched',
+        }],
+      };
+
+      const diagnosticEl = renderer.render(details);
+      assert.strictEqual(diagnosticEl, null);
+    });
+
+    it('throws on unknown details type', () => {
+      // Disallowed by type system, but test that we get an error message out just in case.
+      const details = {
+        type: 'imaginary',
+        items: 5,
+      };
+
+      assert.throws(() => renderer.render(details), /^Error: Unknown type: imaginary$/);
+    });
+
+    it('supports old LHRs by not rendering when type is missing', () => {
+      // Disallowed by type system of current LH, but possible if trying to render an old LHR.
+      const details = {
+        timestamp: 15,
+      };
+
+      assert.strictEqual(renderer.render(details), null);
+    });
+  });
+
+  describe('Table rendering', () => {
+    it('renders text values', () => {
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'text', text: 'Heading'}],
+        items: [{content: 'My text content'}],
+      };
+
+      const el = renderer.render(details);
+      const textEls = el.querySelectorAll('.lh-text');
+      assert.strictEqual(textEls[0].textContent, 'Heading');
+      assert.strictEqual(textEls[1].textContent, 'My text content');
+    });
+
+    it('renders not much if items are empty', () => {
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'text', text: 'Heading'}],
+        items: [],
+      };
+
+      const el = renderer.render(details);
+      assert.strictEqual(el.outerHTML, '<span></span>');
+    });
+
+    it('renders an empty cell if item is missing a property', () => {
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'text', text: 'Heading'}],
+        items: [
+          {},
+          {content: undefined},
+          {content: 'a thing'},
+        ],
+      };
+
+      const el = renderer.render(details);
+      const itemEls = el.querySelectorAll('td');
+      assert.strictEqual(itemEls.length, 3);
+
+      // missing prop
+      assert.ok(itemEls[0].classList.contains('lh-table-column--empty'));
+      assert.strictEqual(itemEls[0].innerHTML, '');
+
+      // undefined prop
+      assert.ok(itemEls[1].classList.contains('lh-table-column--empty'));
+      assert.strictEqual(itemEls[1].innerHTML, '');
+
+      // defined prop
+      assert.ok(itemEls[2].classList.contains('lh-table-column--text'));
+      assert.strictEqual(itemEls[2].textContent, 'a thing');
+    });
+
+    it('renders code values from a string', () => {
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'code', text: 'Heading'}],
+        items: [{content: 'code snippet'}],
+      };
+
+      const el = renderer.render(details);
+      const codeEl = el.querySelector('.lh-code');
+      assert.ok(codeEl.localName === 'pre');
+      assert.equal(codeEl.textContent, 'code snippet');
+    });
+
+    it('renders code values from a code details object', () => {
+      const code = {
+        type: 'code',
+        value: 'code object',
+      };
+
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'code', text: 'Heading'}],
+        items: [{content: code}],
+      };
+
+      const el = renderer.render(details);
+      const codeEl = el.querySelector('.lh-code');
+      assert.ok(codeEl.localName === 'pre');
+      assert.equal(codeEl.textContent, 'code object');
+    });
+
+    it('renders thumbnail values', () => {
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'thumbnail', text: 'Heading'}],
+        items: [{content: 'http://example.com/my-image.jpg'}],
+      };
+
+      const el = renderer.render(details);
+      const thumbnailEl = el.querySelector('img');
+      assert.ok(thumbnailEl.classList.contains('lh-thumbnail'));
+      assert.strictEqual(thumbnailEl.src, 'http://example.com/my-image.jpg');
+      assert.strictEqual(thumbnailEl.title, 'http://example.com/my-image.jpg');
+      assert.strictEqual(thumbnailEl.alt, '');
+    });
+
+    it('renders link values', () => {
       const linkText = 'Example Site';
       const linkUrl = 'https://example.com/';
-      const el = renderer.render({
+      const link = {
         type: 'link',
         text: linkText,
         url: linkUrl,
-      });
+      };
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'link', text: 'Heading'}],
+        items: [{content: link}],
+      };
 
-      assert.equal(el.localName, 'a');
-      assert.equal(el.textContent, linkText);
-      assert.equal(el.href, linkUrl);
-      assert.equal(el.rel, 'noopener');
-      assert.equal(el.target, '_blank');
+      const el = renderer.render(details);
+      const anchorEl = el.querySelector('a');
+      assert.equal(anchorEl.textContent, linkText);
+      assert.equal(anchorEl.href, linkUrl);
+      assert.equal(anchorEl.rel, 'noopener');
+      assert.equal(anchorEl.target, '_blank');
     });
 
-    it('renders link as text if URL is not allowed', () => {
+    it('renders link value as text if URL is not allowed', () => {
       const linkText = 'Evil Link';
       const linkUrl = 'javascript:alert(5)';
-      const el = renderer.render({
+      const link = {
         type: 'link',
         text: linkText,
         url: linkUrl,
-      });
+      };
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'link', text: 'Heading'}],
+        items: [{content: link}],
+      };
 
-      assert.equal(el.localName, 'div');
-      assert.equal(el.textContent, linkText);
-      assert.ok(el.classList.contains('lh-text'), 'adds classes');
+      const el = renderer.render(details);
+      const linkEl = el.querySelector('td.lh-table-column--link > .lh-text');
+      assert.equal(linkEl.localName, 'div');
+      assert.equal(linkEl.textContent, linkText);
     });
 
-    it('renders text URLs', () => {
+    it('renders node values', () => {
+      const node = {
+        type: 'node',
+        path: '3,HTML,1,BODY,5,DIV,0,H2',
+        selector: 'h2',
+        snippet: '<h2>Do better web tester page</h2>',
+      };
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'node', text: 'Heading'}],
+        items: [{content: node}],
+      };
+
+      const el = renderer.render(details);
+      const nodeEl = el.querySelector('.lh-node');
+      assert.strictEqual(nodeEl.localName, 'span');
+      assert.equal(nodeEl.textContent, node.snippet);
+      assert.equal(nodeEl.title, node.selector);
+      assert.equal(nodeEl.getAttribute('data-path'), node.path);
+      assert.equal(nodeEl.getAttribute('data-selector'), node.selector);
+      assert.equal(nodeEl.getAttribute('data-snippet'), node.snippet);
+    });
+
+    it('renders text URL values from a string', () => {
       const urlText = 'https://example.com/';
       const displayUrlText = 'https://example.com';
-      const el = renderer.render({
+
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'url', text: 'Heading'}],
+        items: [{content: urlText}],
+      };
+
+      const el = renderer.render(details);
+      const urlEl = el.querySelector('td.lh-table-column--url > .lh-text__url');
+
+      assert.equal(urlEl.localName, 'div');
+      assert.equal(urlEl.title, urlText);
+      assert.ok(urlEl.firstChild.classList.contains('lh-text'));
+      assert.equal(urlEl.textContent, displayUrlText);
+    });
+
+    it('renders text URL values from a url details object', () => {
+      const urlText = 'https://example.com/';
+      const displayUrlText = 'https://example.com';
+      const url = {
         type: 'url',
         value: urlText,
-      });
+      };
 
-      assert.equal(el.localName, 'div');
-      assert.equal(el.textContent, displayUrlText);
-      assert.ok(el.classList.contains('lh-text__url'), 'adds classes');
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'url', text: 'Heading'}],
+        items: [{content: url}],
+        overallSavingsMs: 100,
+      };
+
+      const el = renderer.render(details);
+      const urlEl = el.querySelector('td.lh-table-column--url > .lh-text__url');
+
+      assert.equal(urlEl.localName, 'div');
+      assert.equal(urlEl.title, urlText);
+      assert.ok(urlEl.firstChild.classList.contains('lh-text'));
+      assert.equal(urlEl.textContent, displayUrlText);
+    });
+
+    it('renders text URL values as code if not an allowed URL', () => {
+      const urlText = 'invalid-url://example.com/';
+
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'url', text: 'Heading'}],
+        items: [{content: urlText}],
+      };
+
+      const el = renderer.render(details);
+      const codeItemEl = el.querySelector('td.lh-table-column--url');
+      assert.strictEqual(codeItemEl.innerHTML, '<pre class="lh-code">invalid-url://example.com/</pre>');
+    });
+
+    it('throws on unknown heading itemType', () => {
+      // Disallowed by type system, but test that we get an error message out just in case.
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'notRealValueType', text: 'Heading'}],
+        items: [{content: 'some string'}],
+      };
+
+      assert.throws(() => renderer.render(details), /^Error: Unknown valueType: notRealValueType$/);
+    });
+
+    it('throws on unknown item object type', () => {
+      // Disallowed by type system, but test that we get an error message out just in case.
+      const item = {
+        type: 'imaginaryItem',
+        items: 'alllll the items',
+      };
+
+      const details = {
+        type: 'table',
+        headings: [{key: 'content', itemType: 'url', text: 'Heading'}],
+        items: [{content: item}],
+      };
+
+      assert.throws(() => renderer.render(details), /^Error: Unknown valueType: imaginaryItem$/);
+    });
+
+    it('uses the item\'s type over the heading type', () => {
+      const details = {
+        type: 'table',
+        // itemType is overriden by code object
+        headings: [{key: 'content', itemType: 'url', text: 'Heading'}],
+        items: [
+          {content: {type: 'code', value: 'code object'}},
+          {content: 'https://example.com'},
+        ],
+      };
+
+      const el = renderer.render(details);
+      const itemElements = el.querySelectorAll('td.lh-table-column--url');
+
+      // First item's value uses its own type.
+      const codeEl = itemElements[0].firstChild;
+      assert.equal(codeEl.localName, 'pre');
+      assert.ok(codeEl.classList.contains('lh-code'));
+      assert.equal(codeEl.textContent, 'code object');
+
+      // Second item uses the heading's specified type for the column.
+      const urlEl = itemElements[1].firstChild;
+      assert.equal(urlEl.localName, 'div');
+      assert.ok(urlEl.classList.contains('lh-text__url'));
+      assert.equal(urlEl.title, 'https://example.com');
+      assert.equal(urlEl.textContent, 'https://example.com');
     });
   });
 });
diff --git a/lighthouse-core/test/report/html/renderer/snippet-renderer-test.js b/lighthouse-core/test/report/html/renderer/snippet-renderer-test.js
new file mode 100644
index 000000000..5e242cf56
--- /dev/null
+++ b/lighthouse-core/test/report/html/renderer/snippet-renderer-test.js
@@ -0,0 +1,293 @@
+/**
+ * @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';
+
+/* eslint-env jest */
+
+const assert = require('assert');
+const fs = require('fs');
+const jsdom = require('jsdom');
+const Util = require('../../../../report/html/renderer/util.js');
+const DOM = require('../../../../report/html/renderer/dom.js');
+const SnippetRenderer = require('../../../../report/html/renderer/snippet-renderer.js');
+
+const TEMPLATE_FILE = fs.readFileSync(
+  __dirname + '/../../../../report/html/templates.html',
+  'utf8'
+);
+
+/* Generates a snippet lines array like this (for a single range from 1 to 4):
+  [
+    {number: 1, content 'L1'},
+    {number: 2, content 'L2'},
+    {number: 3, content 'L3'},
+    {number: 4, content 'L4'}
+  ];
+*/
+function generateAvailableLinesArray(availableLineRanges) {
+  const lines = [];
+  availableLineRanges.forEach(({from, to}) => {
+    for (let i = from; i <= to; i++) {
+      lines.push({
+        lineNumber: i,
+        content: 'L' + i,
+      });
+    }
+  });
+  return lines;
+}
+
+function makeSnippetDetails({
+  lineMessages,
+  generalMessages,
+  lines = null,
+  title = 'Snippet',
+  lineCount,
+}) {
+  return {
+    type: 'snippet',
+    title: title,
+    lines: lines,
+    lineMessages,
+    generalMessages,
+    lineCount,
+  };
+}
+
+describe('DetailsRenderer', () => {
+  let dom;
+
+  beforeAll(() => {
+    global.Util = Util;
+    const {document} = new jsdom.JSDOM(TEMPLATE_FILE).window;
+    dom = new DOM(document);
+  });
+
+  afterAll(() => {
+    global.Util = undefined;
+  });
+
+  function renderSnippet(details) {
+    const el = SnippetRenderer.render(dom, dom.document(), details, {});
+
+    return {
+      el,
+      contentLines: el.querySelectorAll('.lh-snippet__line--content'),
+      highlightedContentLines: el.querySelectorAll('.lh-snippet__line--content-highlighted'),
+      collapsedContentLines: el.querySelectorAll(
+        '.lh-snippet__line--content.lh-snippet__show-if-expanded'
+      ),
+      uncollapsedContentLines: el.querySelectorAll(
+        '.lh-snippet__line--content:not(.lh-snippet__show-if-expanded)'
+      ),
+      messageLines: el.querySelectorAll('.lh-snippet__line--message'),
+      omittedLinesIndicatorsWhenExpanded: el.querySelectorAll(
+        '.lh-snippet__line--placeholder:not(.lh-snippet__show-if-collapsed)'
+      ),
+      omittedLinesIndicatorsWhenCollapsed: el.querySelectorAll(
+        '.lh-snippet__line--placeholder:not(.lh-snippet__show-if-expanded)'
+      ),
+      title: el.querySelector('.lh-snippet__title'),
+      toggleExpandButton: el.querySelector('.lh-snippet__toggle-expand'),
+    };
+  }
+
+  it('Renders snippet with a message at the very top', () => {
+    const details = makeSnippetDetails({
+      lineMessages: [
+        {
+          lineNumber: 1,
+          message: 'Error',
+        },
+      ],
+      generalMessages: [],
+      lines: generateAvailableLinesArray([{from: 1, to: 6}]),
+      lineCount: 100,
+    });
+    const {contentLines, messageLines, collapsedContentLines} = renderSnippet(details);
+
+    // 5 lines are visible, 1 is collapsed
+    assert.equal(collapsedContentLines.length, 1);
+    // All available lines are shown on expansion
+    assert.equal(contentLines.length, 6);
+    // 100 lines in total, so lines towards the end won't be shown
+    const lastLine = contentLines[contentLines.length - 1];
+    assert.equal(lastLine.nextSibling.textContent.trim(), '…');
+
+    // Shows message for second line
+    assert.equal(messageLines[0].textContent.trim(), 'Error');
+    assert.equal(messageLines[0].previousSibling.textContent.replace(/\s/g, ''), '1L1');
+  });
+
+  it('Renders first few lines if there are no messages', () => {
+    const details = makeSnippetDetails({
+      lineMessages: [],
+      generalMessages: [],
+      lines: generateAvailableLinesArray([{from: 1, to: 6}]),
+      lineCount: 100,
+    });
+    const {
+      uncollapsedContentLines,
+      omittedLinesIndicatorsWhenExpanded,
+      omittedLinesIndicatorsWhenCollapsed,
+      highlightedContentLines,
+    } = renderSnippet(details);
+    const lastUncollapsedLine = uncollapsedContentLines[uncollapsedContentLines.length - 1];
+
+    // Shows first 5 visible lines
+    assert.equal(lastUncollapsedLine.textContent.replace(/\s/g, ''), '5L5');
+    // "..." after the available lines, but only shows in expanded state
+    assert.equal(omittedLinesIndicatorsWhenExpanded.length, 1);
+    assert.equal(omittedLinesIndicatorsWhenCollapsed.length, 0);
+    // nothing is highlighted
+    assert.equal(highlightedContentLines.length, 0);
+  });
+
+  it('Renders first few lines if there are no messages for specific lines', () => {
+    const details = makeSnippetDetails({
+      lineMessages: [],
+      generalMessages: [
+        {
+          message: 'General error',
+        },
+      ],
+      lines: generateAvailableLinesArray([{from: 1, to: 6}]),
+      lineCount: 100,
+    });
+    const {uncollapsedContentLines, messageLines, highlightedContentLines} = renderSnippet(details);
+    const lastUncollapsedLine = uncollapsedContentLines[uncollapsedContentLines.length - 1];
+
+    // Shows message
+    assert.equal(messageLines.length, 1);
+
+    // Shows first 5 visible lines
+    assert.equal(lastUncollapsedLine.textContent.replace(/\s/g, ''), '5L5');
+
+    // highlight everything (i.e. the 6 lines that are rendered)
+    assert.equal(highlightedContentLines.length, 6);
+  });
+
+  it('Renders snippet with multiple messages surrounded by other lines', () => {
+    const details = makeSnippetDetails({
+      lineMessages: [
+        {
+          lineNumber: 40,
+          message: 'Error 1',
+        },
+        {
+          lineNumber: 70,
+          message: 'Error 2',
+        },
+      ],
+      generalMessages: [],
+      lines: generateAvailableLinesArray([
+        {
+          from: 30,
+          to: 50,
+        },
+        {
+          from: 60,
+          to: 80,
+        },
+      ]),
+      lineCount: 100,
+    });
+    const {
+      collapsedContentLines,
+      omittedLinesIndicatorsWhenCollapsed,
+      omittedLinesIndicatorsWhenExpanded,
+      highlightedContentLines,
+    } = renderSnippet(details);
+
+    // first available line is collapsed
+    assert.equal(collapsedContentLines[0].textContent.replace(/\s/g, ''), '30L30');
+
+    // puts omitted lines placeholder between the two messages
+    assert.equal(omittedLinesIndicatorsWhenCollapsed.length, 1);
+    // puts omitted lines placeholder between the two messages and around the whole snippet
+    assert.equal(omittedLinesIndicatorsWhenExpanded.length, 3);
+
+    // both lines with messages are highlighted
+    assert.equal(highlightedContentLines.length, 2);
+  });
+
+  it('Can render both line-specific and non line-specific messages in one snippet', () => {
+    const details = makeSnippetDetails({
+      lineMessages: [
+        {
+          lineNumber: 5,
+          message: 'Error on line',
+        },
+      ],
+      generalMessages: [
+        {
+          message: 'General error',
+        },
+      ],
+      lines: generateAvailableLinesArray([{from: 1, to: 6}]),
+      lineCount: 100,
+    });
+    const {messageLines} = renderSnippet(details);
+
+    assert.equal(messageLines.length, 2);
+  });
+
+  it('Renders a snippet header and allows toggling the expanded state', () => {
+    const details = makeSnippetDetails({
+      title: 'Test Snippet',
+      lineMessages: [],
+      generalMessages: [],
+      lines: generateAvailableLinesArray([{from: 1, to: 6}]),
+      lineCount: 100,
+    });
+    const {title, toggleExpandButton, el} = renderSnippet(details);
+
+    // Renders title
+    assert.ok(title.textContent.includes('Test Snippet'));
+    // Renders toggle button
+    assert.ok(toggleExpandButton);
+    assert.ok(!el.classList.contains('lh-snippet--expanded'));
+
+    toggleExpandButton.click();
+    assert.ok(el.classList.contains('lh-snippet--expanded'));
+  });
+
+  it('Does not render toggle button if all available lines are already visible', () => {
+    const details = makeSnippetDetails({
+      title: 'Test Snippet',
+      lineMessages: [],
+      generalMessages: [],
+      // We show all 5 lines by default, so there's nothing to expand
+      lines: generateAvailableLinesArray([{from: 1, to: 5}]),
+    });
+    const {toggleExpandButton} = renderSnippet(details);
+
+    assert.ok(!toggleExpandButton);
+  });
+
+  it('Adds ... to lines that have been truncated', () => {
+    const details = makeSnippetDetails({
+      lineMessages: [],
+      generalMessages: [],
+      lines: [
+        {
+          content: 'abc',
+          lineNumber: 1,
+          truncated: true,
+        },
+        {
+          content: 'xyz',
+          lineNumber: 2,
+        },
+      ],
+      lineCount: 2,
+    });
+    const {contentLines} = renderSnippet(details);
+
+    assert.ok(contentLines[0].textContent.includes('…'));
+    assert.ok(!contentLines[1].textContent.includes('…'));
+  });
+});
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index 5eb5ab45d..3153f3cbd 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -1448,6 +1448,19 @@
       "scoreDisplayMode": "manual",
       "rawValue": false
     },
+    "accesskeys": {
+      "id": "accesskeys",
+      "title": "`[accesskey]` values are unique",
+      "description": "Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more](https://dequeuniversity.com/rules/axe/3.1/accesskeys?application=lighthouse).",
+      "score": 1,
+      "scoreDisplayMode": "binary",
+      "rawValue": true,
+      "details": {
+        "type": "table",
+        "headings": [],
+        "items": []
+      }
+    },
     "aria-allowed-attr": {
       "id": "aria-allowed-attr",
       "title": "`[aria-*]` attributes match their roles",
@@ -2002,14 +2015,6 @@
       "scoreDisplayMode": "notApplicable",
       "rawValue": true
     },
-    "accesskeys": {
-      "id": "accesskeys",
-      "title": "`[accesskey]` values are unique",
-      "description": "Access keys let users quickly focus a part of the page. For proper navigation, each access key must be unique. [Learn more](https://dequeuniversity.com/rules/axe/3.1/accesskeys?application=lighthouse).",
-      "score": null,
-      "scoreDisplayMode": "manual",
-      "rawValue": false
-    },
     "custom-controls-labels": {
       "id": "custom-controls-labels",
       "title": "Custom controls have associated labels",
@@ -3364,6 +3369,11 @@
       "description": "These checks highlight opportunities to [improve the accessibility of your web app](https://developers.google.com/web/fundamentals/accessibility). Only a subset of accessibility issues can be automatically detected so manual testing is also encouraged.",
       "manualDescription": "These items address areas which an automated testing tool cannot cover. Learn more in our guide on [conducting an accessibility review](https://developers.google.com/web/fundamentals/accessibility/how-to-review).",
       "auditRefs": [
+        {
+          "id": "accesskeys",
+          "weight": 3,
+          "group": "a11y-navigation"
+        },
         {
           "id": "aria-allowed-attr",
           "weight": 0,
@@ -3402,17 +3412,17 @@
         {
           "id": "audio-caption",
           "weight": 0,
-          "group": "a11y-correct-attributes"
+          "group": "a11y-audio-video"
         },
         {
           "id": "button-name",
           "weight": 0,
-          "group": "a11y-element-names"
+          "group": "a11y-names-labels"
         },
         {
           "id": "bypass",
           "weight": 10,
-          "group": "a11y-describe-contents"
+          "group": "a11y-navigation"
         },
         {
           "id": "color-contrast",
@@ -3422,27 +3432,27 @@
         {
           "id": "definition-list",
           "weight": 0,
-          "group": "a11y-well-structured"
+          "group": "a11y-tables-lists"
         },
         {
           "id": "dlitem",
           "weight": 0,
-          "group": "a11y-well-structured"
+          "group": "a11y-tables-lists"
         },
         {
           "id": "document-title",
           "weight": 2,
-          "group": "a11y-describe-contents"
+          "group": "a11y-names-labels"
         },
         {
           "id": "duplicate-id",
           "weight": 5,
-          "group": "a11y-well-structured"
+          "group": "a11y-best-practices"
         },
         {
           "id": "frame-title",
           "weight": 0,
-          "group": "a11y-describe-contents"
+          "group": "a11y-names-labels"
         },
         {
           "id": "html-has-lang",
@@ -3457,67 +3467,67 @@
         {
           "id": "image-alt",
           "weight": 8,
-          "group": "a11y-correct-attributes"
+          "group": "a11y-names-labels"
         },
         {
           "id": "input-image-alt",
           "weight": 0,
-          "group": "a11y-correct-attributes"
+          "group": "a11y-names-labels"
         },
         {
           "id": "label",
           "weight": 10,
-          "group": "a11y-describe-contents"
+          "group": "a11y-names-labels"
         },
         {
           "id": "layout-table",
           "weight": 0,
-          "group": "a11y-describe-contents"
+          "group": "a11y-tables-lists"
         },
         {
           "id": "link-name",
           "weight": 9,
-          "group": "a11y-element-names"
+          "group": "a11y-names-labels"
         },
         {
           "id": "list",
           "weight": 0,
-          "group": "a11y-well-structured"
+          "group": "a11y-tables-lists"
         },
         {
           "id": "listitem",
           "weight": 0,
-          "group": "a11y-well-structured"
+          "group": "a11y-tables-lists"
         },
         {
           "id": "meta-refresh",
           "weight": 0,
-          "group": "a11y-meta"
+          "group": "a11y-best-practices"
         },
         {
           "id": "meta-viewport",
           "weight": 3,
-          "group": "a11y-meta"
+          "group": "a11y-best-practices"
         },
         {
           "id": "object-alt",
           "weight": 4,
-          "group": "a11y-describe-contents"
+          "group": "a11y-names-labels"
         },
         {
           "id": "tabindex",
           "weight": 0,
-          "group": "a11y-correct-attributes"
+          "group": "a11y-navigation"
         },
         {
           "id": "td-headers-attr",
           "weight": 0,
-          "group": "a11y-correct-attributes"
+          "group": "a11y-tables-lists"
         },
         {
           "id": "th-has-data-cells",
           "weight": 0,
-          "group": "a11y-correct-attributes"
+          "group": "a11y-tables-lists"
         },
         {
           "id": "valid-lang",
@@ -3527,16 +3537,12 @@
         {
           "id": "video-caption",
           "weight": 0,
-          "group": "a11y-describe-contents"
+          "group": "a11y-audio-video"
         },
         {
           "id": "video-description",
           "weight": 0,
-          "group": "a11y-describe-contents"
-        },
-        {
-          "id": "accesskeys",
-          "weight": 0
+          "group": "a11y-audio-video"
         },
         {
           "id": "logical-tab-order",
@@ -3584,7 +3590,7 @@
         }
       ],
       "id": "accessibility",
-      "score": 0.33
+      "score": 0.36
     },
     "best-practices": {
       "title": "Best Practices",
@@ -3829,37 +3835,37 @@
     "pwa-optimized": {
       "title": "PWA Optimized"
     },
+    "a11y-best-practices": {
+      "title": "Best practices",
+      "description": "These items highlight common accessibility best practices."
+    },
     "a11y-color-contrast": {
-      "title": "Color Contrast Is Satisfactory",
+      "title": "Contrast",
       "description": "These are opportunities to improve the legibility of your content."
     },
-    "a11y-describe-contents": {
-      "title": "Elements Describe Contents Well",
-      "description": "These are opportunities to make your content easier to understand for a user of assistive technology, like a screen reader."
+    "a11y-names-labels": {
+      "title": "Names and labels",
+      "description": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader."
     },
-    "a11y-well-structured": {
-      "title": "Elements Are Well Structured",
-      "description": "These are opportunities to make sure your HTML is appropriately structured."
+    "a11y-navigation": {
+      "title": "Navigation",
+      "description": "These are opportunities to improve keyboard navigation in your application."
     },
     "a11y-aria": {
-      "title": "ARIA Attributes Follow Best Practices",
+      "title": "ARIA",
       "description": "These are opportunities to improve the usage of ARIA in your application which may enhance the experience for users of assistive technology, like a screen reader."
     },
-    "a11y-correct-attributes": {
-      "title": "Elements Use Attributes Correctly",
-      "description": "These are opportunities to improve the configuration of your HTML elements."
-    },
-    "a11y-element-names": {
-      "title": "Elements Have Discernible Names",
-      "description": "These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader."
-    },
     "a11y-language": {
-      "title": "Page Specifies Valid Language",
+      "title": "Internationalization and localization",
       "description": "These are opportunities to improve the interpretation of your content by users in different locales."
     },
-    "a11y-meta": {
-      "title": "Meta Tags Used Properly",
-      "description": "These are opportunities to improve the user experience of your site."
+    "a11y-audio-video": {
+      "title": "Audio and video",
+      "description": "These are opportunities to provide alternative content for audio and video. This may improve the experience for users with hearing or vision impairments."
+    },
+    "a11y-tables-lists": {
+      "title": "Tables and lists",
+      "description": "These are opportunities to to improve the experience of reading tabular or list data using assistive technology, like a screen reader."
     },
     "seo-mobile": {
       "title": "Mobile Friendly",
@@ -4296,6 +4302,12 @@
         "duration": 100,
         "entryType": "measure"
       },
+      {
+        "startTime": 0,
+        "name": "lh:audit:accesskeys",
+        "duration": 100,
+        "entryType": "measure"
+      },
       {
         "startTime": 0,
         "name": "lh:audit:aria-allowed-attr",
@@ -4500,12 +4512,6 @@
         "duration": 100,
         "entryType": "measure"
       },
-      {
-        "startTime": 0,
-        "name": "lh:audit:accesskeys",
-        "duration": 100,
-        "entryType": "measure"
-      },
       {
         "startTime": 0,
         "name": "lh:audit:custom-controls-labels",
@@ -4818,6 +4824,8 @@
       "opportunitySavingsColumnLabel": "Estimated Savings",
       "passedAuditsGroupTitle": "Passed audits",
       "scorescaleLabel": "Score scale:",
+      "snippetCollapseButtonLabel": "Collapse snippet",
+      "snippetExpandButtonLabel": "Expand snippet",
       "toplevelWarningsMessage": "There were issues affecting this run of Lighthouse:",
       "varianceDisclaimer": "Values are estimated and may vary.",
       "warningAuditsGroupTitle": "Passed audits but with warnings",
@@ -5052,6 +5060,12 @@
       "lighthouse-core/audits/network-server-latency.js | description": [
         "audits[network-server-latency].description"
       ],
+      "lighthouse-core/audits/accessibility/accesskeys.js | title": [
+        "audits.accesskeys.title"
+      ],
+      "lighthouse-core/audits/accessibility/accesskeys.js | description": [
+        "audits.accesskeys.description"
+      ],
       "lighthouse-core/audits/accessibility/aria-allowed-attr.js | title": [
         "audits[aria-allowed-attr].title"
       ],
@@ -5264,12 +5278,6 @@
       "lighthouse-core/audits/accessibility/video-description.js | description": [
         "audits[video-description].description"
       ],
-      "lighthouse-core/audits/accessibility/manual/accesskeys.js | title": [
-        "audits.accesskeys.title"
-      ],
-      "lighthouse-core/audits/accessibility/manual/accesskeys.js | description": [
-        "audits.accesskeys.description"
-      ],
       "lighthouse-core/audits/byte-efficiency/uses-long-cache-ttl.js | failureTitle": [
         "audits[uses-long-cache-ttl].title"
       ],
@@ -5556,23 +5564,29 @@
       "lighthouse-core/config/default-config.js | pwaOptimizedGroupTitle": [
         "categoryGroups[pwa-optimized].title"
       ],
+      "lighthouse-core/config/default-config.js | a11yBestPracticesGroupTitle": [
+        "categoryGroups[a11y-best-practices].title"
+      ],
+      "lighthouse-core/config/default-config.js | a11yBestPracticesGroupDescription": [
+        "categoryGroups[a11y-best-practices].description"
+      ],
       "lighthouse-core/config/default-config.js | a11yColorContrastGroupTitle": [
         "categoryGroups[a11y-color-contrast].title"
       ],
       "lighthouse-core/config/default-config.js | a11yColorContrastGroupDescription": [
         "categoryGroups[a11y-color-contrast].description"
       ],
-      "lighthouse-core/config/default-config.js | a11yDescribeContentsGroupTitle": [
-        "categoryGroups[a11y-describe-contents].title"
+      "lighthouse-core/config/default-config.js | a11yNamesLabelsGroupTitle": [
+        "categoryGroups[a11y-names-labels].title"
       ],
-      "lighthouse-core/config/default-config.js | a11yDescribeContentsGroupDescription": [
-        "categoryGroups[a11y-describe-contents].description"
+      "lighthouse-core/config/default-config.js | a11yNamesLabelsGroupDescription": [
+        "categoryGroups[a11y-names-labels].description"
       ],
-      "lighthouse-core/config/default-config.js | a11yWellStructuredGroupTitle": [
-        "categoryGroups[a11y-well-structured].title"
+      "lighthouse-core/config/default-config.js | a11yNavigationGroupTitle": [
+        "categoryGroups[a11y-navigation].title"
       ],
-      "lighthouse-core/config/default-config.js | a11yWellStructuredGroupDescription": [
-        "categoryGroups[a11y-well-structured].description"
+      "lighthouse-core/config/default-config.js | a11yNavigationGroupDescription": [
+        "categoryGroups[a11y-navigation].description"
       ],
       "lighthouse-core/config/default-config.js | a11yAriaGroupTitle": [
         "categoryGroups[a11y-aria].title"
@@ -5580,29 +5594,23 @@
       "lighthouse-core/config/default-config.js | a11yAriaGroupDescription": [
         "categoryGroups[a11y-aria].description"
       ],
-      "lighthouse-core/config/default-config.js | a11yCorrectAttributesGroupTitle": [
-        "categoryGroups[a11y-correct-attributes].title"
-      ],
-      "lighthouse-core/config/default-config.js | a11yCorrectAttributesGroupDescription": [
-        "categoryGroups[a11y-correct-attributes].description"
-      ],
-      "lighthouse-core/config/default-config.js | a11yElementNamesGroupTitle": [
-        "categoryGroups[a11y-element-names].title"
-      ],
-      "lighthouse-core/config/default-config.js | a11yElementNamesGroupDescription": [
-        "categoryGroups[a11y-element-names].description"
-      ],
       "lighthouse-core/config/default-config.js | a11yLanguageGroupTitle": [
         "categoryGroups[a11y-language].title"
       ],
       "lighthouse-core/config/default-config.js | a11yLanguageGroupDescription": [
         "categoryGroups[a11y-language].description"
       ],
-      "lighthouse-core/config/default-config.js | a11yMetaGroupTitle": [
-        "categoryGroups[a11y-meta].title"
+      "lighthouse-core/config/default-config.js | a11yAudioVideoGroupTitle": [
+        "categoryGroups[a11y-audio-video].title"
+      ],
+      "lighthouse-core/config/default-config.js | a11yAudioVideoGroupDescription": [
+        "categoryGroups[a11y-audio-video].description"
+      ],
+      "lighthouse-core/config/default-config.js | a11yTablesListsVideoGroupTitle": [
+        "categoryGroups[a11y-tables-lists].title"
       ],
-      "lighthouse-core/config/default-config.js | a11yMetaGroupDescription": [
-        "categoryGroups[a11y-meta].description"
+      "lighthouse-core/config/default-config.js | a11yTablesListsVideoGroupDescription": [
+        "categoryGroups[a11y-tables-lists].description"
       ]
     }
   }