Repository: GoogleChrome/lighthouse
Pull request #6460 (2018-11-02 22:44:17)

report: refactor rendering of top-level failed/passing/etc sections
This makes the process of grouping under failed/passing/manual/notApplicable and then again under audit groups a lot easier to understand. It also sets the stage for making a new `pwa-category-renderer.js` (where they aren't split into failed/passing/etc) much easier.

Before the renderer was mixing up levels of failed/passing/notApplicable and audit groups, putting those states inside groups, then ending up rendering the opposite way (groups inside failed/passing/notApplicable). Manual audits were handled separately, even though it's just another of these states.

Instead, this new code sorts by state, then renders groups in each of them. The code is just a little shorter (longer with new tests), but it's hopefully easier to follow.

Importantly, this makes for an easy interface for `renderTopLevelSection` (which `performance-category-renderer` now uses) and `renderUnexpandableTopLevelSection` (which the pwa one will use).

It also lets us get rid of `_getTotalAuditsLength` and allows easy numbering across groups now.

The numbering is the only thing that has changed from master (so no changed tests, only new ones). *edit: also (collapsed) passed audits in the performance section are now grouped like they are in other categories*

brendankenny: also, we can pick a different name than "top-level section". I couldn't think of one (and overloading "group" seems like a mistake in retrospect :)

Patch:
diff --git a/lighthouse-core/report/html/renderer/category-renderer.js b/lighthouse-core/report/html/renderer/category-renderer.js
index 5e8e2210e..39efef223 100644
--- a/lighthouse-core/report/html/renderer/category-renderer.js
+++ b/lighthouse-core/report/html/renderer/category-renderer.js
@@ -22,6 +22,7 @@
 /** @typedef {import('./report-renderer.js')} ReportRenderer */
 /** @typedef {import('./details-renderer.js')} DetailsRenderer */
 /** @typedef {import('./util.js')} Util */
+/** @typedef {'failed'|'manual'|'passed'|'not-applicable'} TopLevelClumpId */
 
 class CategoryRenderer {
   /**
@@ -39,6 +40,29 @@ class CategoryRenderer {
     this.detailsRenderer.setTemplateContext(this.templateContext);
   }
 
+  /**
+   * Display info per top-level clump. Define on class to avoid race with Util init.
+   */
+  get _clumpDisplayInfo() {
+    return {
+      'failed': {
+        className: 'lh-failed-audits',
+      },
+      'manual': {
+        title: Util.UIStrings.manualAuditsGroupTitle,
+        className: 'lh-audit-group--manual',
+      },
+      'passed': {
+        title: Util.UIStrings.passedAuditsGroupTitle,
+        className: 'lh-passed-audits',
+      },
+      'not-applicable': {
+        title: Util.UIStrings.notApplicableAuditsGroupTitle,
+        className: 'lh-audit-group--not-applicable',
+      },
+    };
+  }
+
   /**
    * @param {LH.ReportResult.AuditRef} audit
    * @param {number} index
@@ -164,7 +188,7 @@ class CategoryRenderer {
   renderAuditGroup(group, opts) {
     const expandable = opts.expandable;
     const groupEl = this.dom.createElement(expandable ? 'details' : 'div', 'lh-audit-group');
-    const summaryEl = this.dom.createChildOf(groupEl, 'summary');
+    const summaryEl = this.dom.createChildOf(groupEl, expandable ? 'summary' : 'div');
     const summaryInnerEl = this.dom.createChildOf(summaryEl, 'div', 'lh-audit-group__summary');
     const headerEl = this.dom.createChildOf(summaryInnerEl, 'div', 'lh-audit-group__header');
     const itemCountEl = this.dom.createChildOf(summaryInnerEl, 'div', 'lh-audit-group__itemcount');
@@ -188,76 +212,111 @@ class CategoryRenderer {
   }
 
   /**
-   * Find the total number of audits contained within a section.
-   * Accounts for nested subsections like Accessibility.
-   * @param {Array<Element>} elements
-   * @return {number}
+   * Takes an array of auditRefs, groups them if requested, then returns an
+   * array of audit and audit-group elements.
+   * @param {Array<LH.ReportResult.AuditRef>} auditRefs
+   * @param {Object<string, LH.Result.ReportGroup>} groupDefinitions
+   * @param {{expandable: boolean}} opts
+   * @return {Array<Element>}
    */
-  _getTotalAuditsLength(elements) {
-    // Create a scratch element to append sections to so we can reuse querySelectorAll().
-    const scratch = this.dom.createElement('div');
-    elements.forEach(function(element) {
-      scratch.appendChild(element);
-    });
-    const subAudits = scratch.querySelectorAll('.lh-audit');
-    if (subAudits.length) {
-      return subAudits.length;
-    } else {
-      return elements.length;
+  _renderGroupedAudits(auditRefs, groupDefinitions, opts) {
+    // Audits grouped by their group (or under notAGroup).
+    /** @type {Map<string, Array<LH.ReportResult.AuditRef>>} */
+    const grouped = new Map();
+
+    // Add audits without a group first so they will appear first.
+    const notAGroup = 'NotAGroup';
+    grouped.set(notAGroup, []);
+
+    for (const auditRef of auditRefs) {
+      const groupId = auditRef.group || notAGroup;
+      const groupAuditRefs = grouped.get(groupId) || [];
+      groupAuditRefs.push(auditRef);
+      grouped.set(groupId, groupAuditRefs);
     }
-  }
 
-  /**
-   * @param {Array<Element>} elements
-   * @return {Element}
-   */
-  _renderFailedAuditsSection(elements) {
-    const failedElem = this.dom.createElement('div');
-    failedElem.classList.add('lh-failed-audits');
-    elements.forEach(elem => failedElem.appendChild(elem));
-    return failedElem;
-  }
+    /** @type {Array<Element>} */
+    const auditElements = [];
+    // Continuous numbering across all groups.
+    let index = 0;
 
-  /**
-   * @param {Array<Element>} elements
-   * @return {Element}
-   */
-  renderPassedAuditsSection(elements) {
-    const passedElem = this.renderAuditGroup({
-      title: Util.UIStrings.passedAuditsGroupTitle,
-    }, {expandable: true, itemCount: this._getTotalAuditsLength(elements)});
-    passedElem.classList.add('lh-passed-audits');
-    elements.forEach(elem => passedElem.appendChild(elem));
-    return passedElem;
+    for (const [groupId, groupAuditRefs] of grouped) {
+      if (groupId === notAGroup) {
+        // Push not-grouped audits individually.
+        for (const auditRef of groupAuditRefs) {
+          auditElements.push(this.renderAudit(auditRef, index++));
+        }
+        continue;
+      }
+
+      // Push grouped audits as a group.
+      const groupDef = groupDefinitions[groupId];
+      const auditGroupElem = this.renderAuditGroup(groupDef, opts);
+      for (const auditRef of groupAuditRefs) {
+        auditGroupElem.appendChild(this.renderAudit(auditRef, index++));
+      }
+      auditGroupElem.classList.add('lh-audit-group--unadorned');
+      auditElements.push(auditGroupElem);
+    }
+
+    return auditElements;
   }
 
   /**
-   * @param {Array<Element>} elements
+   * Take a set of audits, group them if they have groups, then render in a top-level
+   * clump that can't be expanded/collapsed.
+   * @param {Array<LH.ReportResult.AuditRef>} auditRefs
+   * @param {Object<string, LH.Result.ReportGroup>} groupDefinitions
    * @return {Element}
    */
-  _renderNotApplicableAuditsSection(elements) {
-    const notApplicableElem = this.renderAuditGroup({
-      title: Util.UIStrings.notApplicableAuditsGroupTitle,
-    }, {expandable: true, itemCount: this._getTotalAuditsLength(elements)});
-    notApplicableElem.classList.add('lh-audit-group--not-applicable');
-    elements.forEach(elem => notApplicableElem.appendChild(elem));
-    return notApplicableElem;
+  renderUnexpandableClump(auditRefs, groupDefinitions) {
+    const clumpElement = this.dom.createElement('div');
+    const elements = this._renderGroupedAudits(auditRefs, groupDefinitions, {expandable: false});
+    elements.forEach(elem => clumpElement.appendChild(elem));
+    return clumpElement;
   }
 
   /**
-   * @param {Array<LH.ReportResult.AuditRef>} manualAudits
-   * @param {string} [manualDescription]
+   * Renders a clump (a grouping of groups), under a status of failed, manual,
+   * passed, or not-applicable. The result ends up something like:
+   *
+   * clump (e.g. 'failed')
+   *   ├── audit 1 (w/o group)
+   *   ├── audit 2 (w/o group)
+   *   ├── audit group
+   *   |  ├── audit 3
+   *   |  └── audit 4
+   *   └── audit group
+   *      ├── audit 5
+   *      └── audit 6
+   * clump (e.g. 'manual')
+   *   ├── …
+   *   ⋮
+   * @param {TopLevelClumpId} clumpId
+   * @param {{auditRefs: Array<LH.ReportResult.AuditRef>, groupDefinitions: Object<string, LH.Result.ReportGroup>, description?: string}} clumpOpts
    * @return {Element}
    */
-  _renderManualAudits(manualAudits, manualDescription) {
-    const group = {title: Util.UIStrings.manualAuditsGroupTitle, description: manualDescription};
-    const auditGroupElem = this.renderAuditGroup(group,
-        {expandable: true, itemCount: manualAudits.length});
-    auditGroupElem.classList.add('lh-audit-group--manual');
-    manualAudits.forEach((audit, i) => {
-      auditGroupElem.appendChild(this.renderAudit(audit, i));
-    });
-    return auditGroupElem;
+  renderClump(clumpId, {auditRefs, groupDefinitions, description}) {
+    if (clumpId === 'failed') {
+      // Failed audit clump is always expanded and not nested in an lh-audit-group.
+      const failedElem = this.renderUnexpandableClump(auditRefs, groupDefinitions);
+      failedElem.classList.add(this._clumpDisplayInfo.failed.className);
+      return failedElem;
+    }
+
+    const expandable = true;
+    const elements = this._renderGroupedAudits(auditRefs, groupDefinitions, {expandable});
+
+    const clumpInfo = this._clumpDisplayInfo[clumpId];
+    // TODO: renderAuditGroup shouldn't be used to render a clump (since it *contains* audit groups).
+    const groupDef = {title: clumpInfo.title, description};
+    const opts = {expandable, itemCount: auditRefs.length};
+    const clumpElem = this.renderAuditGroup(groupDef, opts);
+    clumpElem.classList.add(clumpInfo.className);
+
+    elements.forEach(elem => clumpElem.appendChild(elem));
+
+    return clumpElem;
   }
 
   /**
@@ -302,105 +361,58 @@ class CategoryRenderer {
     return tmpl;
   }
 
+  /**
+   * Returns the id of the top-level clump to put this audit in.
+   * @param {LH.ReportResult.AuditRef} auditRef
+   * @return {TopLevelClumpId}
+   */
+  _getClumpIdForAuditRef(auditRef) {
+    const scoreDisplayMode = auditRef.result.scoreDisplayMode;
+    if (scoreDisplayMode === 'manual' || scoreDisplayMode === 'not-applicable') {
+      return scoreDisplayMode;
+    }
+
+    if (Util.showAsPassed(auditRef.result)) {
+      return 'passed';
+    } else {
+      return 'failed';
+    }
+  }
+
   /**
    * @param {LH.ReportResult.Category} category
    * @param {Object<string, LH.Result.ReportGroup>} [groupDefinitions]
    * @return {Element}
    */
-  render(category, groupDefinitions) {
+  render(category, groupDefinitions = {}) {
     const element = this.dom.createElement('div', 'lh-category');
     this.createPermalinkSpan(element, category.id);
     element.appendChild(this.renderCategoryHeader(category));
 
-    const auditRefs = category.auditRefs;
-    const manualAudits = auditRefs.filter(audit => audit.result.scoreDisplayMode === 'manual');
-    const nonManualAudits = auditRefs.filter(audit => !manualAudits.includes(audit));
-
-    /** @type {Object<string, {passed: Array<LH.ReportResult.AuditRef>, failed: Array<LH.ReportResult.AuditRef>, notApplicable: Array<LH.ReportResult.AuditRef>}>} */
-    const auditsGroupedByGroup = {};
-    const auditsUngrouped = {passed: [], failed: [], notApplicable: []};
-
-    nonManualAudits.forEach(auditRef => {
-      let group;
-
-      if (auditRef.group) {
-        const groupId = auditRef.group;
-
-        if (auditsGroupedByGroup[groupId]) {
-          group = auditsGroupedByGroup[groupId];
-        } else {
-          group = {passed: [], failed: [], notApplicable: []};
-          auditsGroupedByGroup[groupId] = group;
-        }
-      } else {
-        group = auditsUngrouped;
-      }
-
-      if (auditRef.result.scoreDisplayMode === 'not-applicable') {
-        group.notApplicable.push(auditRef);
-      } else if (Util.showAsPassed(auditRef.result)) {
-        group.passed.push(auditRef);
-      } else {
-        group.failed.push(auditRef);
-      }
-    });
-
-    const failedElements = /** @type {Array<Element>} */ ([]);
-    const passedElements = /** @type {Array<Element>} */ ([]);
-    const notApplicableElements = /** @type {Array<Element>} */ ([]);
-
-    auditsUngrouped.failed.forEach((audit, i) => failedElements.push(this.renderAudit(audit, i)));
-    auditsUngrouped.passed.forEach((audit, i) => passedElements.push(this.renderAudit(audit, i)));
-    auditsUngrouped.notApplicable.forEach((audit, i) => notApplicableElements.push(
-        this.renderAudit(audit, i)));
-
-    Object.keys(auditsGroupedByGroup).forEach(groupId => {
-      if (!groupDefinitions) return; // We never reach here if there aren't groups, but TSC needs convincing
-
-      const group = groupDefinitions[groupId];
-      const groups = auditsGroupedByGroup[groupId];
-
-      if (groups.failed.length) {
-        const auditGroupElem = this.renderAuditGroup(group, {expandable: false});
-        groups.failed.forEach((item, i) => auditGroupElem.appendChild(this.renderAudit(item, i)));
-        auditGroupElem.classList.add('lh-audit-group--unadorned');
-        failedElements.push(auditGroupElem);
-      }
-
-      if (groups.passed.length) {
-        const auditGroupElem = this.renderAuditGroup(group, {expandable: true});
-        groups.passed.forEach((item, i) => auditGroupElem.appendChild(this.renderAudit(item, i)));
-        auditGroupElem.classList.add('lh-audit-group--unadorned');
-        passedElements.push(auditGroupElem);
-      }
-
-      if (groups.notApplicable.length) {
-        const auditGroupElem = this.renderAuditGroup(group, {expandable: true});
-        groups.notApplicable.forEach((item, i) =>
-            auditGroupElem.appendChild(this.renderAudit(item, i)));
-        auditGroupElem.classList.add('lh-audit-group--unadorned');
-        notApplicableElements.push(auditGroupElem);
-      }
-    });
-
-    if (failedElements.length) {
-      const failedElem = this._renderFailedAuditsSection(failedElements);
-      element.appendChild(failedElem);
+    // Top level clumps for audits, in order they will appear in the report.
+    /** @type {Map<TopLevelClumpId, Array<LH.ReportResult.AuditRef>>} */
+    const clumps = new Map();
+    clumps.set('failed', []);
+    clumps.set('manual', []);
+    clumps.set('passed', []);
+    clumps.set('not-applicable', []);
+
+    // Sort audits into clumps.
+    for (const auditRef of category.auditRefs) {
+      const clumpId = this._getClumpIdForAuditRef(auditRef);
+      const clump = /** @type {Array<LH.ReportResult.AuditRef>} */ (clumps.get(clumpId)); // already defined
+      clump.push(auditRef);
+      clumps.set(clumpId, clump);
     }
 
-    if (manualAudits.length) {
-      const manualEl = this._renderManualAudits(manualAudits, category.manualDescription);
-      element.appendChild(manualEl);
-    }
-
-    if (passedElements.length) {
-      const passedElem = this.renderPassedAuditsSection(passedElements);
-      element.appendChild(passedElem);
-    }
+    // Render each clump.
+    for (const [clumpId, clumpRefs] of clumps) {
+      if (clumpRefs.length === 0) continue;
 
-    if (notApplicableElements.length) {
-      const notApplicableElem = this._renderNotApplicableAuditsSection(notApplicableElements);
-      element.appendChild(notApplicableElem);
+      const description = clumpId === 'manual' ? category.manualDescription : undefined;
+      const clumpElem = this.renderClump(clumpId, {auditRefs: clumpRefs, groupDefinitions,
+        description});
+      element.appendChild(clumpElem);
     }
 
     return element;
diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js
index 22a30af5b..8002628ff 100644
--- a/lighthouse-core/report/html/renderer/details-renderer.js
+++ b/lighthouse-core/report/html/renderer/details-renderer.js
@@ -126,10 +126,7 @@ class DetailsRenderer {
       displayedPath = parsed.file === '/' ? parsed.origin : parsed.file;
       displayedHost = parsed.file === '/' ? '' : `(${parsed.hostname})`;
       title = url;
-    } catch (/** @type {!Error} */ e) {
-      if (!e.name.startsWith('TypeError')) {
-        throw e;
-      }
+    } catch (e) {
       displayedPath = url;
     }
 
diff --git a/lighthouse-core/report/html/renderer/performance-category-renderer.js b/lighthouse-core/report/html/renderer/performance-category-renderer.js
index 873c0b765..7924f213f 100644
--- a/lighthouse-core/report/html/renderer/performance-category-renderer.js
+++ b/lighthouse-core/report/html/renderer/performance-category-renderer.js
@@ -209,14 +209,17 @@ class PerformanceCategoryRenderer extends CategoryRenderer {
     }
 
     // Passed audits
-    const passedElements = category.auditRefs
+    const passedAudits = category.auditRefs
         .filter(audit => (audit.group === 'load-opportunities' || audit.group === 'diagnostics') &&
-            Util.showAsPassed(audit.result))
-        .map((audit, i) => this.renderAudit(audit, i));
+            Util.showAsPassed(audit.result));
 
-    if (!passedElements.length) return element;
+    if (!passedAudits.length) return element;
 
-    const passedElem = this.renderPassedAuditsSection(passedElements);
+    const clumpOpts = {
+      auditRefs: passedAudits,
+      groupDefinitions: groups,
+    };
+    const passedElem = this.renderClump('passed', clumpOpts);
     element.appendChild(passedElem);
     return element;
   }
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 04aaddc94..a9d294dd0 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -226,7 +226,7 @@ class ReportUIFeatures {
     this.headerSticky.style.transform = `translateY(${heightDiff * scrollPct * -1}px)`;
     this.headerBackground.style.transform = `translateY(${scrollPct * this.headerOverlap}px)`;
     this.lighthouseIcon.style.transform =
-      `translate3d(calc(var(--report-width) / 2),` +
+      `translate3d(var(--report-width-half),` +
       ` calc(-100% - ${scrollPct * this.headerOverlap * -1}px), 0) scale(${1 - scrollPct})`;
     this.lighthouseIcon.style.opacity = (1 - scrollPct).toString();
 
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index ad9fa10d6..bcb321ddc 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -54,6 +54,8 @@
   --metric-timeline-rule-color: #b3b3b3;
   --display-value-gray: hsl(216, 5%, 39%);
   --report-width: calc(60 * var(--body-font-size));
+  /* Edge doesn't support calc(var(calc())) */
+  --report-width-half: calc(30 * var(--body-font-size));
   --report-header-height: 161px;
   --report-header-color: #202124;
   --navitem-font-size: var(--body-font-size);
@@ -77,19 +79,19 @@
   --lh-section-vpadding: 12px;
   --chevron-size: 12px;
 
-  --pass-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>check</title><path fill="hsl(139, 70%, 30%)" d="M24 4C12.95 4 4 12.95 4 24c0 11.04 8.95 20 20 20 11.04 0 20-8.96 20-20 0-11.05-8.96-20-20-20zm-4 30L10 24l2.83-2.83L20 28.34l15.17-15.17L38 16 20 34z"/></svg>');
-  --average-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>info</title><path fill="hsl(31, 100%, 45%)" d="M24 4C12.95 4 4 12.95 4 24s8.95 20 20 20 20-8.95 20-20S35.05 4 24 4zm2 30h-4V22h4v12zm0-16h-4v-4h4v4z"/></svg>');
-  --fail-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>warn</title><path fill="hsl(1, 73%, 45%)" d="M2 42h44L24 4 2 42zm24-6h-4v-4h4v4zm0-8h-4v-8h4v8z"/></svg>');
+  --pass-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>check</title><path fill="%23178239" d="M24 4C12.95 4 4 12.95 4 24c0 11.04 8.95 20 20 20 11.04 0 20-8.96 20-20 0-11.05-8.96-20-20-20zm-4 30L10 24l2.83-2.83L20 28.34l15.17-15.17L38 16 20 34z"/></svg>');
+  --average-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>info</title><path fill="%23E67700" d="M24 4C12.95 4 4 12.95 4 24s8.95 20 20 20 20-8.95 20-20S35.05 4 24 4zm2 30h-4V22h4v12zm0-16h-4v-4h4v4z"/></svg>');
+  --fail-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><title>warn</title><path fill="%23C7221F" d="M2 42h44L24 4 2 42zm24-6h-4v-4h4v4zm0-8h-4v-8h4v8z"/></svg>');
 
-  --content-paste-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path fill="hsl(216, 5%, 39%)" d="M19 2h-4.18C14.4.84 13.3 0 12 0c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm7 18H5V4h2v3h10V4h2v16z"/><path d="M0 0h24v24H0z" fill="none"/></svg>');
-  --av-timer-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="hsl(216, 5%, 39%)"><path d="M0 0h24v24H0z" fill="none"/><path d="M15 1H9v2h6V1zm-4 13h2V8h-2v6zm8.03-6.61l1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42 1.42A8.962 8.962 0 0 0 12 4c-4.97 0-9 4.03-9 9s4.02 9 9 9a8.994 8.994 0 0 0 7.03-14.61zM12 20c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/></svg>');
-  --photo-filter-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path fill="none" d="M0 0h48v48H0V0z"/><path d="M38.04 20v18H10V10h18V6H10.04c-2.2 0-4 1.8-4 4v28c0 2.2 1.8 4 4 4h28c2.2 0 4-1.8 4-4V20h-4zM34 20l1.88-4.12L40 14l-4.12-1.88L34 8l-1.88 4.12L28 14l4.12 1.88zm-7.5 1.5L24 16l-2.5 5.5L16 24l5.5 2.5L24 32l2.5-5.5L32 24z" fill="hsl(216, 5%, 39%)"/></svg>');
-  --visibility-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M0 0h48v48H0z" fill="none"/><path d="M24 9C14 9 5.46 15.22 2 24c3.46 8.78 12 15 22 15 10.01 0 18.54-6.22 22-15-3.46-8.78-11.99-15-22-15zm0 25c-5.52 0-10-4.48-10-10s4.48-10 10-10 10 4.48 10 10-4.48 10-10 10zm0-16c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6z" fill="hsl(216, 5%, 39%)"/></svg>');
-  --check-circle-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M0 0h48v48H0z" fill="none"/><path d="M24 4C12.95 4 4 12.95 4 24c0 11.04 8.95 20 20 20 11.04 0 20-8.96 20-20 0-11.05-8.96-20-20-20zm-4 30L10 24l2.83-2.83L20 28.34l15.17-15.17L38 16 20 34z" fill="hsl(216, 5%, 39%)"/></svg>');
+  --content-paste-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path fill="%235E6268" d="M19 2h-4.18C14.4.84 13.3 0 12 0c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm7 18H5V4h2v3h10V4h2v16z"/><path d="M0 0h24v24H0z" fill="none"/></svg>');
+  --av-timer-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="%235E6268"><path d="M0 0h24v24H0z" fill="none"/><path d="M15 1H9v2h6V1zm-4 13h2V8h-2v6zm8.03-6.61l1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42 1.42A8.962 8.962 0 0 0 12 4c-4.97 0-9 4.03-9 9s4.02 9 9 9a8.994 8.994 0 0 0 7.03-14.61zM12 20c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/></svg>');
+  --photo-filter-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path fill="none" d="M0 0h48v48H0V0z"/><path d="M38.04 20v18H10V10h18V6H10.04c-2.2 0-4 1.8-4 4v28c0 2.2 1.8 4 4 4h28c2.2 0 4-1.8 4-4V20h-4zM34 20l1.88-4.12L40 14l-4.12-1.88L34 8l-1.88 4.12L28 14l4.12 1.88zm-7.5 1.5L24 16l-2.5 5.5L16 24l5.5 2.5L24 32l2.5-5.5L32 24z" fill="%235E6268"/></svg>');
+  --visibility-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M0 0h48v48H0z" fill="none"/><path d="M24 9C14 9 5.46 15.22 2 24c3.46 8.78 12 15 22 15 10.01 0 18.54-6.22 22-15-3.46-8.78-11.99-15-22-15zm0 25c-5.52 0-10-4.48-10-10s4.48-10 10-10 10 4.48 10 10-4.48 10-10 10zm0-16c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6z" fill="%235E6268"/></svg>');
+  --check-circle-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M0 0h48v48H0z" fill="none"/><path d="M24 4C12.95 4 4 12.95 4 24c0 11.04 8.95 20 20 20 11.04 0 20-8.96 20-20 0-11.05-8.96-20-20-20zm-4 30L10 24l2.83-2.83L20 28.34l15.17-15.17L38 16 20 34z" fill="%235E6268"/></svg>');
   --check-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M0 0h48v48H0z" fill="none"/><path d="M18 32.34L9.66 24l-2.83 2.83L18 38l24-24-2.83-2.83z"/></svg>');
 
-  --search-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M31 28h-1.59l-.55-.55C30.82 25.18 32 22.23 32 19c0-7.18-5.82-13-13-13S6 11.82 6 19s5.82 13 13 13c3.23 0 6.18-1.18 8.45-3.13l.55.55V31l10 9.98L40.98 38 31 28zm-12 0a9 9 0 1 1 .001-18.001A9 9 0 0 1 19 28z" fill="hsl(216, 5%, 39%)"/><path d="M0 0h48v48H0z" fill="none" /></svg>');
-  --remove-circle-icon-url: url('data:image/svg+xml;utf8,<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 11v2h10v-2H7zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="hsl(216, 5%, 39%)"/></svg>');
+  --search-icon-url: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><path d="M31 28h-1.59l-.55-.55C30.82 25.18 32 22.23 32 19c0-7.18-5.82-13-13-13S6 11.82 6 19s5.82 13 13 13c3.23 0 6.18-1.18 8.45-3.13l.55.55V31l10 9.98L40.98 38 31 28zm-12 0a9 9 0 1 1 .001-18.001A9 9 0 0 1 19 28z" fill="%235E6268"/><path d="M0 0h48v48H0z" fill="none" /></svg>');
+  --remove-circle-icon-url: url('data:image/svg+xml;utf8,<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 11v2h10v-2H7zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="%235E6268"/></svg>');
 }
 
 .lh-vars.lh-devtools {
@@ -966,6 +968,8 @@
  */
 .lh-chevron {
   --chevron-angle: 42deg;
+  /* Edge doesn't support transform: rotate(calc(...)), so we define it here */
+  --chevron-angle-right: -42deg;
   width: var(--chevron-size);
   height: var(--chevron-size);
   margin-top: calc((var(--body-line-height) - 12px) / 2);
@@ -988,7 +992,7 @@
 .lh-audit-group[open] > summary > .lh-audit-group__summary > .lh-chevron .lh-chevron__line-left,
 .lh-audit > .lh-expandable-details .lh-chevron__line-right,
 .lh-audit > .lh-expandable-details[open] .lh-chevron__line-left {
- transform: rotate(calc(var(--chevron-angle) * -1));
+ transform: rotate(var(--chevron-angle-right));
 }
 
 .lh-audit-group[open] > summary > .lh-audit-group__summary > .lh-chevron .lh-chevron__line-right,
diff --git a/lighthouse-core/report/html/templates.html b/lighthouse-core/report/html/templates.html
index 6cb4d9979..d280232b2 100644
--- a/lighthouse-core/report/html/templates.html
+++ b/lighthouse-core/report/html/templates.html
@@ -37,8 +37,8 @@ limitations under the License.
 <template id="tmpl-lh-chevron">
   <svg class="lh-chevron" title="See audits" xmlns="http://www.w3.org/2000/svg"  viewbox="0 0 100 100">
     <g class="lh-chevron__lines">
-      <path class="lh-chevron__line lh-chevron__line-left" d="M10 50h40" stroke="#707173"/>
-      <path class="lh-chevron__line lh-chevron__line-right" d="M90 50H50" stroke="#707173"/>
+      <path class="lh-chevron__line lh-chevron__line-left" d="M10 50h40" stroke="#707173"></path>
+      <path class="lh-chevron__line lh-chevron__line-right" d="M90 50H50" stroke="#707173"></path>
     </g>
   </svg>
 </template>
@@ -168,7 +168,7 @@ limitations under the License.
       position: absolute;
       top: var(--report-header-height);
       right: 50%;
-      transform: translate3d(calc(var(--report-width) / 2), -100%, 0);
+      transform: translate3d(var(--report-width-half), -100%, 0);
       opacity: 1;
       transform-origin: bottom right;
       will-change: transform, opacity;
@@ -359,7 +359,7 @@ limitations under the License.
     <svg width="217" height="148" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
       <defs>
         <mask id="a" x="-56" y="-54" width="284" height="202" maskUnits="userSpaceOnUse">
-          <path d="M-56-54h284v202H-56z" fill="#fff"/>
+          <path d="M-56-54h284v202H-56z" fill="#fff"></path>
         </mask>
         <linearGradient id="b" x1="-525.16" y1="560.08" x2="-524.23" y2="560.08" gradientTransform="matrix(91 0 0 -77 47797 43181)" gradientUnits="userSpaceOnUse">
           <stop offset="0" stop-color="#f1f3f4"/>


Test patch:
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 39bfbb9c8..dfa30610d 100644
--- a/lighthouse-core/test/report/html/renderer/category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
@@ -145,6 +145,13 @@ describe('CategoryRenderer', () => {
     assert.ok(categoryDOM.querySelector('.lh-audit-group--manual .lh-audit-group__summary'));
     assert.equal(categoryDOM.querySelectorAll('.lh-audit--manual').length, 3,
         'score shows informative and dash icon');
+
+    assert.ok(pwaCategory.manualDescription);
+    const description = categoryDOM
+      .querySelector('.lh-audit-group--manual .lh-audit-group__description').textContent;
+    // may need to be adjusted if description includes a link at the beginning
+    assert.ok(description.startsWith(pwaCategory.manualDescription.substring(0, 20)),
+        'no manual description');
   });
 
   it('renders not applicable audits if the category contains them', () => {
@@ -218,6 +225,55 @@ describe('CategoryRenderer', () => {
       const auditsElements = categoryDOM.querySelectorAll('.lh-audit');
       assert.equal(auditsElements.length, category.auditRefs.length);
     });
+
+    it('increments the audit index across groups', () => {
+      const elem = renderer.render(category, sampleResults.categoryGroups);
+
+      const passedAudits = elem.querySelectorAll('.lh-passed-audits .lh-audit__index');
+      const failedAudits = elem.querySelectorAll('.lh-failed-audits .lh-audit__index');
+      const manualAudits = elem.querySelectorAll('.lh-audit-group--manual .lh-audit__index');
+      const notApplicableAudits =
+        elem.querySelectorAll('.lh-audit-group--not-applicable .lh-audit__index');
+
+      const assertAllTheIndices = (nodeList) => {
+        // Must be at least one for a decent test.
+        assert.ok(nodeList.length > 0);
+
+        // Assert indices are continuous, starting at 1.
+        nodeList.forEach((node, i) => {
+          const auditIndex = Number.parseInt(node.textContent);
+          assert.strictEqual(auditIndex, i + 1);
+        });
+      };
+
+      assertAllTheIndices(passedAudits);
+      assertAllTheIndices(failedAudits);
+      assertAllTheIndices(manualAudits);
+      assertAllTheIndices(notApplicableAudits);
+    });
+
+    it('renders audits without a group before grouped ones', () => {
+      const categoryClone = JSON.parse(JSON.stringify(category));
+
+      // Remove groups from some audits.
+      const ungroupedAudits = ['color-contrast', 'image-alt', 'link-name'];
+      for (const auditRef of categoryClone.auditRefs) {
+        if (ungroupedAudits.includes(auditRef.id)) {
+          assert.ok(auditRef.group); // Make sure this will change something.
+          delete auditRef.group;
+        }
+      }
+
+      const elem = renderer.render(categoryClone, sampleResults.categoryGroups);
+
+      // Check that the first audits found are the ungrouped ones.
+      const auditElems = Array.from(elem.querySelectorAll('.lh-audit'));
+      const firstAuditElems = auditElems.slice(0, ungroupedAudits.length);
+      for (const auditElem of firstAuditElems) {
+        const auditId = auditElem.id;
+        assert.ok(ungroupedAudits.includes(auditId), auditId);
+      }
+    });
   });
 
   describe('grouping passed/failed/manual', () => {