Repository: GoogleChrome/lighthouse
Pull request #9057 (2019-05-25 01:09:59)

report: make gauge font size and positioning dynamic
The text score (eg "69") inside the gauge has had a slightly odd position. 
Now its position is exactly in the half, due to a line-height: 0;
And the font-size is dynamically determined based off the circle size.

![Kapture 2019-05-24 at 18 09 17](https://user-images.githubusercontent.com/39191/58362716-1b79e400-7e4f-11e9-87c7-2b15834aba80.gif)



Patch:
diff --git a/lighthouse-core/config/config-helpers.js b/lighthouse-core/config/config-helpers.js
new file mode 100644
index 000000000..fdff4420f
--- /dev/null
+++ b/lighthouse-core/config/config-helpers.js
@@ -0,0 +1,210 @@
+/**
+ * @license Copyright 2019 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const path = require('path');
+const Audit = require('../audits/audit.js');
+const Runner = require('../runner.js');
+
+/**
+ * If any items with identical `path` properties are found in the input array,
+ * merge their `options` properties into the first instance and then discard any
+ * other instances.
+ * Until support of jsdoc templates with constraints, type in config.d.ts.
+ * See https://github.com/Microsoft/TypeScript/issues/24283
+ * @type {LH.Config.MergeOptionsOfItems}
+ */
+const mergeOptionsOfItems = function(items) {
+  /** @type {Array<{id: string, path?: string, options?: Object<string, any>}>} */
+  const mergedItems = [];
+
+  for (const item of items) {
+    const existingItem = item.path && mergedItems.find(candidate => candidate.path === item.path);
+    if (!existingItem) {
+      mergedItems.push(item);
+      continue;
+    }
+
+    existingItem.options = Object.assign({}, existingItem.options, item.options);
+  }
+
+  return mergedItems;
+};
+
+/**
+ * Throws an error if the provided object does not implement the required properties of an audit
+ * definition.
+ * @param {LH.Config.AuditDefn} auditDefinition
+ */
+function assertValidAudit(auditDefinition) {
+  const {implementation, path: auditPath} = auditDefinition;
+  const auditName = auditPath ||
+    (implementation && implementation.meta && implementation.meta.id) ||
+    'Unknown audit';
+
+  if (typeof implementation.audit !== 'function' || implementation.audit === Audit.audit) {
+    throw new Error(`${auditName} has no audit() method.`);
+  }
+
+  if (typeof implementation.meta.id !== 'string') {
+    throw new Error(`${auditName} has no meta.id property, or the property is not a string.`);
+  }
+
+  if (typeof implementation.meta.title !== 'string') {
+    throw new Error(`${auditName} has no meta.title property, or the property is not a string.`);
+  }
+
+  // If it'll have a ✔ or ✖ displayed alongside the result, it should have failureTitle
+  if (
+    typeof implementation.meta.failureTitle !== 'string' &&
+    implementation.meta.scoreDisplayMode === Audit.SCORING_MODES.BINARY
+  ) {
+    throw new Error(`${auditName} has no failureTitle and should.`);
+  }
+
+  if (typeof implementation.meta.description !== 'string') {
+    throw new Error(
+      `${auditName} has no meta.description property, or the property is not a string.`
+    );
+  } else if (implementation.meta.description === '') {
+    throw new Error(
+      `${auditName} has an empty meta.description string. Please add a description for the UI.`
+    );
+  }
+
+  if (!Array.isArray(implementation.meta.requiredArtifacts)) {
+    throw new Error(
+      `${auditName} has no meta.requiredArtifacts property, or the property is not an array.`
+    );
+  }
+}
+
+/**
+ * Expands the audits from user-specified JSON to an internal audit definition format.
+ * @param {LH.Config.Json['audits']} audits
+ * @return {?Array<{id?: string, path: string, options?: {}} | {id?: string, implementation: typeof Audit, path?: string, options?: {}}>}
+ */
+function expandAuditShorthand(audits) {
+  if (!audits) {
+    return null;
+  }
+
+  const newAudits = audits.map(audit => {
+    if (typeof audit === 'string') {
+      // just 'path/to/audit'
+      return {path: audit, options: {}};
+    } else if ('implementation' in audit && typeof audit.implementation.audit === 'function') {
+      // {implementation: AuditClass, ...}
+      return audit;
+    } else if ('path' in audit && typeof audit.path === 'string') {
+      // {path: 'path/to/audit', ...}
+      return audit;
+    } else if ('audit' in audit && typeof audit.audit === 'function') {
+      // just AuditClass
+      return {implementation: audit, options: {}};
+    } else {
+      throw new Error('Invalid Audit type ' + JSON.stringify(audit));
+    }
+  });
+
+  return newAudits;
+}
+
+/**
+ * Take an array of audits and audit paths and require any paths (possibly
+ * relative to the optional `configDir`) using `resolveModule`,
+ * leaving only an array of AuditDefns.
+ * @param {LH.Config.Json['audits']} audits
+ * @param {string=} configDir
+ * @return {Array<LH.Config.AuditDefn>|null}
+ */
+function requireAudits(audits, configDir) {
+  const expandedAudits = expandAuditShorthand(audits);
+  if (!expandedAudits) {
+    return null;
+  }
+
+  const coreList = Runner.getAuditList();
+  const auditDefns = expandedAudits.map(audit => {
+    let implementation;
+    if ('implementation' in audit) {
+      implementation = audit.implementation;
+    } else {
+      // See if the audit is a Lighthouse core audit.
+      const auditPathJs = `${audit.path}.js`;
+      const coreAudit = coreList.find(a => a === auditPathJs);
+      let requirePath = `../audits/${audit.path}`;
+      if (!coreAudit) {
+        // Otherwise, attempt to find it elsewhere. This throws if not found.
+        requirePath = resolveModule(audit.path, configDir, 'audit');
+      }
+      implementation = /** @type {typeof Audit} */ (require(requirePath));
+    }
+
+    return {
+      implementation,
+      path: audit.path,
+      options: audit.options || {},
+    };
+  });
+
+  const mergedAuditDefns = mergeOptionsOfItems(auditDefns);
+  mergedAuditDefns.forEach(audit => assertValidAudit(audit));
+  return mergedAuditDefns;
+}
+
+/**
+ * Resolves the location of the specified module and returns an absolute
+ * string path to the file. Used for loading custom audits and gatherers.
+ * Throws an error if no module is found.
+ * @param {string} moduleIdentifier
+ * @param {string=} configDir The absolute path to the directory of the config file, if there is one.
+ * @param {string=} category Optional plugin category (e.g. 'audit') for better error messages.
+ * @return {string}
+ * @throws {Error}
+ */
+function resolveModule(moduleIdentifier, configDir, category) {
+  // First try straight `require()`. Unlikely to be specified relative to this
+  // file, but adds support for Lighthouse modules from npm since
+  // `require()` walks up parent directories looking inside any node_modules/
+  // present. Also handles absolute paths.
+  try {
+    return require.resolve(moduleIdentifier);
+  } catch (e) {}
+
+  // See if the module resolves relative to the current working directory.
+  // Most useful to handle the case of invoking Lighthouse as a module, since
+  // then the config is an object and so has no path.
+  const cwdPath = path.resolve(process.cwd(), moduleIdentifier);
+  try {
+    return require.resolve(cwdPath);
+  } catch (e) {}
+
+  const errorString =
+    'Unable to locate ' +
+    (category ? `${category}: ` : '') +
+    `${moduleIdentifier} (tried to require() from '${__dirname}' and load from '${cwdPath}'`;
+
+  if (!configDir) {
+    throw new Error(errorString + ')');
+  }
+
+  // Finally, try looking up relative to the config file path. Just like the
+  // relative path passed to `require()` is found relative to the file it's
+  // in, this allows module paths to be specified relative to the config file.
+  const relativePath = path.resolve(configDir, moduleIdentifier);
+  try {
+    return require.resolve(relativePath);
+  } catch (requireError) {}
+
+  throw new Error(errorString + ` and '${relativePath}')`);
+}
+
+module.exports = {
+  mergeOptionsOfItems,
+  requireAudits,
+  resolveModule,
+};
diff --git a/lighthouse-core/config/config.js b/lighthouse-core/config/config.js
index 7b26f588a..2a857035e 100644
--- a/lighthouse-core/config/config.js
+++ b/lighthouse-core/config/config.js
@@ -14,10 +14,10 @@ const i18n = require('./../lib/i18n/i18n.js');
 const isDeepEqual = require('lodash.isequal');
 const log = require('lighthouse-logger');
 const path = require('path');
-const Audit = require('../audits/audit.js');
 const Runner = require('../runner.js');
 const ConfigPlugin = require('./config-plugin.js');
 const Budget = require('./budget.js');
+const {requireAudits, mergeOptionsOfItems, resolveModule} = require('./config-helpers.js');
 
 /** @typedef {typeof import('../gather/gatherers/gatherer.js')} GathererConstructor */
 /** @typedef {InstanceType<GathererConstructor>} Gatherer */
@@ -26,7 +26,7 @@ const Budget = require('./budget.js');
  * @param {Config['passes']} passes
  * @param {Config['audits']} audits
  */
-function validatePasses(passes, audits) {
+function assertValidPasses(passes, audits) {
   if (!Array.isArray(passes)) {
     return;
   }
@@ -61,7 +61,7 @@ function validatePasses(passes, audits) {
  * @param {Config['audits']} audits
  * @param {Config['groups']} groups
  */
-function validateCategories(categories, audits, groups) {
+function assertValidCategories(categories, audits, groups) {
   if (!categories) {
     return;
   }
@@ -99,51 +99,6 @@ function validateCategories(categories, audits, groups) {
   });
 }
 
-/**
- * @param {typeof Audit} auditDefinition
- * @param {string=} auditPath
- */
-function assertValidAudit(auditDefinition, auditPath) {
-  const auditName = auditPath ||
-    (auditDefinition && auditDefinition.meta && auditDefinition.meta.id);
-
-  if (typeof auditDefinition.audit !== 'function' || auditDefinition.audit === Audit.audit) {
-    throw new Error(`${auditName} has no audit() method.`);
-  }
-
-  if (typeof auditDefinition.meta.id !== 'string') {
-    throw new Error(`${auditName} has no meta.id property, or the property is not a string.`);
-  }
-
-  if (typeof auditDefinition.meta.title !== 'string') {
-    throw new Error(
-      `${auditName} has no meta.title property, or the property is not a string.`
-    );
-  }
-
-  // If it'll have a ✔ or ✖ displayed alongside the result, it should have failureTitle
-  if (typeof auditDefinition.meta.failureTitle !== 'string' &&
-    auditDefinition.meta.scoreDisplayMode === Audit.SCORING_MODES.BINARY) {
-    throw new Error(`${auditName} has no failureTitle and should.`);
-  }
-
-  if (typeof auditDefinition.meta.description !== 'string') {
-    throw new Error(
-      `${auditName} has no meta.description property, or the property is not a string.`
-    );
-  } else if (auditDefinition.meta.description === '') {
-    throw new Error(
-      `${auditName} has an empty meta.description string. Please add a description for the UI.`
-    );
-  }
-
-  if (!Array.isArray(auditDefinition.meta.requiredArtifacts)) {
-    throw new Error(
-      `${auditName} has no meta.requiredArtifacts property, or the property is not an array.`
-    );
-  }
-}
-
 /**
  * @param {Gatherer} gathererInstance
  * @param {string=} gathererName
@@ -301,31 +256,6 @@ function deepCloneConfigJson(json) {
   return cloned;
 }
 
-/**
- * If any items with identical `path` properties are found in the input array,
- * merge their `options` properties into the first instance and then discard any
- * other instances.
- * Until support of jsdoc templates with constraints, type in config.d.ts.
- * See https://github.com/Microsoft/TypeScript/issues/24283
- * @type {LH.Config.MergeOptionsOfItems}
- */
-const mergeOptionsOfItems = (function(items) {
-  /** @type {Array<{path?: string, options?: Object<string, any>}>} */
-  const mergedItems = [];
-
-  for (const item of items) {
-    const existingItem = item.path && mergedItems.find(candidate => candidate.path === item.path);
-    if (!existingItem) {
-      mergedItems.push(item);
-      continue;
-    }
-
-    existingItem.options = Object.assign({}, existingItem.options, item.options);
-  }
-
-  return mergedItems;
-});
-
 class Config {
   /**
    * @constructor
@@ -385,8 +315,8 @@ class Config {
 
     Config.filterConfigIfNeeded(this);
 
-    validatePasses(this.passes, this.audits);
-    validateCategories(this.categories, this.audits, this.groups);
+    assertValidPasses(this.passes, this.audits);
+    assertValidCategories(this.categories, this.audits, this.groups);
 
     // TODO(bckenny): until tsc adds @implements support, assert that Config is a ConfigJson.
     /** @type {LH.Config.Json} */
@@ -472,7 +402,7 @@ class Config {
     for (const pluginName of pluginNames) {
       assertValidPluginName(configJSON, pluginName);
 
-      const pluginPath = Config.resolveModule(pluginName, configDir, 'plugin');
+      const pluginPath = resolveModule(pluginName, configDir, 'plugin');
       const rawPluginJson = require(pluginPath);
       const pluginJson = ConfigPlugin.parsePlugin(rawPluginJson, pluginName);
 
@@ -522,37 +452,6 @@ class Config {
     return settingsWithFlags;
   }
 
-  /**
-   * Expands the audits from user-specified JSON to an internal audit definition format.
-   * @param {LH.Config.Json['audits']} audits
-   * @return {?Array<{path: string, options?: {}} | {implementation: typeof Audit, path?: string, options?: {}}>}
-   */
-  static expandAuditShorthand(audits) {
-    if (!audits) {
-      return null;
-    }
-
-    const newAudits = audits.map(audit => {
-      if (typeof audit === 'string') {
-        // just 'path/to/audit'
-        return {path: audit, options: {}};
-      } else if ('implementation' in audit && typeof audit.implementation.audit === 'function') {
-        // {implementation: AuditClass, ...}
-        return audit;
-      } else if ('path' in audit && typeof audit.path === 'string') {
-        // {path: 'path/to/audit', ...}
-        return audit;
-      } else if ('audit' in audit && typeof audit.audit === 'function') {
-        // just AuditClass
-        return {implementation: audit, options: {}};
-      } else {
-        throw new Error('Invalid Audit type ' + JSON.stringify(audit));
-      }
-    });
-
-    return newAudits;
-  }
-
   /**
    * Expands the gatherers from user-specified to an internal gatherer definition format.
    *
@@ -796,7 +695,7 @@ class Config {
 
   /**
    * Take an array of audits and audit paths and require any paths (possibly
-   * relative to the optional `configDir`) using `Config.resolveModule`,
+   * relative to the optional `configDir`) using `resolveModule`,
    * leaving only an array of AuditDefns.
    * @param {LH.Config.Json['audits']} audits
    * @param {string=} configDir
@@ -805,39 +704,9 @@ class Config {
   static requireAudits(audits, configDir) {
     const status = {msg: 'Requiring audits', id: 'lh:config:requireAudits'};
     log.time(status, 'verbose');
-    const expandedAudits = Config.expandAuditShorthand(audits);
-    if (!expandedAudits) {
-      return null;
-    }
-
-    const coreList = Runner.getAuditList();
-    const auditDefns = expandedAudits.map(audit => {
-      let implementation;
-      if ('implementation' in audit) {
-        implementation = audit.implementation;
-      } else {
-        // See if the audit is a Lighthouse core audit.
-        const auditPathJs = `${audit.path}.js`;
-        const coreAudit = coreList.find(a => a === auditPathJs);
-        let requirePath = `../audits/${audit.path}`;
-        if (!coreAudit) {
-          // Otherwise, attempt to find it elsewhere. This throws if not found.
-          requirePath = Config.resolveModule(audit.path, configDir, 'audit');
-        }
-        implementation = /** @type {typeof Audit} */ (require(requirePath));
-      }
-
-      return {
-        implementation,
-        path: audit.path,
-        options: audit.options || {},
-      };
-    });
-
-    const mergedAuditDefns = mergeOptionsOfItems(auditDefns);
-    mergedAuditDefns.forEach(audit => assertValidAudit(audit.implementation, audit.path));
+    const auditDefns = requireAudits(audits, configDir);
     log.timeEnd(status);
-    return mergedAuditDefns;
+    return auditDefns;
   }
 
   /**
@@ -853,7 +722,7 @@ class Config {
     let requirePath = `../gather/gatherers/${path}`;
     if (!coreGatherer) {
       // Otherwise, attempt to find it elsewhere. This throws if not found.
-      requirePath = Config.resolveModule(path, configDir, 'gatherer');
+      requirePath = resolveModule(path, configDir, 'gatherer');
     }
 
     const GathererClass = /** @type {GathererConstructor} */ (require(requirePath));
@@ -869,7 +738,7 @@ class Config {
   /**
    * Takes an array of passes with every property now initialized except the
    * gatherers and requires them, (relative to the optional `configDir` if
-   * provided) using `Config.resolveModule`, returning an array of full Passes.
+   * provided) using `resolveModule`, returning an array of full Passes.
    * @param {?Array<Required<LH.Config.PassJson>>} passes
    * @param {string=} configDir
    * @return {Config['passes']}
@@ -916,52 +785,6 @@ class Config {
     log.timeEnd(status);
     return fullPasses;
   }
-
-  /**
-   * Resolves the location of the specified module and returns an absolute
-   * string path to the file. Used for loading custom audits and gatherers.
-   * Throws an error if no module is found.
-   * @param {string} moduleIdentifier
-   * @param {string=} configDir The absolute path to the directory of the config file, if there is one.
-   * @param {string=} category Optional plugin category (e.g. 'audit') for better error messages.
-   * @return {string}
-   * @throws {Error}
-   */
-  static resolveModule(moduleIdentifier, configDir, category) {
-    // First try straight `require()`. Unlikely to be specified relative to this
-    // file, but adds support for Lighthouse modules from npm since
-    // `require()` walks up parent directories looking inside any node_modules/
-    // present. Also handles absolute paths.
-    try {
-      return require.resolve(moduleIdentifier);
-    } catch (e) {}
-
-    // See if the module resolves relative to the current working directory.
-    // Most useful to handle the case of invoking Lighthouse as a module, since
-    // then the config is an object and so has no path.
-    const cwdPath = path.resolve(process.cwd(), moduleIdentifier);
-    try {
-      return require.resolve(cwdPath);
-    } catch (e) {}
-
-    const errorString = 'Unable to locate ' +
-        (category ? `${category}: ` : '') +
-        `${moduleIdentifier} (tried to require() from '${__dirname}' and load from '${cwdPath}'`;
-
-    if (!configDir) {
-      throw new Error(errorString + ')');
-    }
-
-    // Finally, try looking up relative to the config file path. Just like the
-    // relative path passed to `require()` is found relative to the file it's
-    // in, this allows module paths to be specified relative to the config file.
-    const relativePath = path.resolve(configDir, moduleIdentifier);
-    try {
-      return require.resolve(relativePath);
-    } catch (requireError) {}
-
-    throw new Error(errorString + ` and '${relativePath}')`);
-  }
 }
 
 module.exports = Config;
diff --git a/lighthouse-core/gather/gatherers/seo/tap-targets.js b/lighthouse-core/gather/gatherers/seo/tap-targets.js
index aed7ce086..bd763492a 100644
--- a/lighthouse-core/gather/gatherers/seo/tap-targets.js
+++ b/lighthouse-core/gather/gatherers/seo/tap-targets.js
@@ -5,11 +5,16 @@
  */
 'use strict';
 
-/* global getComputedStyle, getElementsInDocument, Node, getNodePath, getNodeSelector, getNodeLabel */
+/* global document, window, getComputedStyle, getElementsInDocument, Node, getNodePath, getNodeSelector, getNodeLabel */
 
 const Gatherer = require('../gatherer.js');
 const pageFunctions = require('../../../lib/page-functions.js');
-const {rectContainsString, rectContains} = require('../../../lib/rect-helpers.js');
+const {
+  rectContains,
+  getRectArea,
+  getRectCenterPoint,
+  getLargestRect,
+} = require('../../../lib/rect-helpers.js');
 
 const TARGET_SELECTORS = [
   'button',
@@ -66,64 +71,6 @@ function elementIsVisible(element) {
   return true;
 }
 
-/**
- * @param {LH.Artifacts.Rect[]} clientRects
- */
-/* istanbul ignore next */
-function allClientRectsEmpty(clientRects) {
-  return clientRects.every(cr => cr.width === 0 && cr.height === 0);
-}
-
-/**
- * @param {Element} element
- */
-/* istanbul ignore next */
-function getVisibleClientRects(element) {
-  if (!elementIsVisible(element)) {
-    return [];
-  }
-
-  let clientRects = getClientRects(element);
-
-  if (allClientRectsEmpty(clientRects)) {
-    return [];
-  }
-
-  // Treating overflowing content in scroll containers as invisible could mean that
-  // most of a given page is deemed invisible. But:
-  // - tap targets audit doesn't consider different containers/layers
-  // - having most content in an explicit scroll container is rare
-  // - treating them as hidden only generates false passes, which is better than false failures
-  clientRects = filterClientRectsWithinAncestorsVisibleScrollArea(element, clientRects);
-
-  return clientRects;
-}
-
-/**
- *
- * @param {Element} element
- * @param {LH.Artifacts.Rect[]} clientRects
- * @returns {LH.Artifacts.Rect[]}
- */
-/* istanbul ignore next */
-function filterClientRectsWithinAncestorsVisibleScrollArea(element, clientRects) {
-  const parent = element.parentElement;
-  if (!parent) {
-    return clientRects;
-  }
-  if (getComputedStyle(parent).overflowY !== 'visible') {
-    const parentBCR = parent.getBoundingClientRect();
-    clientRects = clientRects.filter(cr => rectContains(parentBCR, cr));
-  }
-  if (parent.parentElement && parent.parentElement.tagName !== 'BODY') {
-    return filterClientRectsWithinAncestorsVisibleScrollArea(
-      parent,
-      clientRects
-    );
-  }
-  return clientRects;
-}
-
 /**
  * @param {Element} element
  * @returns {LH.Artifacts.Rect[]}
@@ -220,22 +167,6 @@ function elementIsInTextBlock(element) {
   }
 }
 
-/**
- * @param {Element} element
- * @returns {boolean}
- */
-/* istanbul ignore next */
-function elementIsPositionFixedStickyOrAbsolute(element) {
-  const {position} = getComputedStyle(element);
-  if (position === 'fixed' || position === 'absolute' || position === 'sticky') {
-    return true;
-  }
-  if (element.parentElement) {
-    return elementIsPositionFixedStickyOrAbsolute(element.parentElement);
-  }
-  return false;
-}
-
 /**
  * @param {string} str
  * @param {number} maxLength
@@ -249,6 +180,53 @@ function truncate(str, maxLength) {
   return str.slice(0, maxLength - 1) + '…';
 }
 
+/**
+ * @param {Element} el
+ * @param {{x: number, y: number}} elCenterPoint
+ */
+/* istanbul ignore next */
+function elementCenterIsAtZAxisTop(el, elCenterPoint) {
+  const viewportHeight = window.innerHeight;
+  const targetScrollY = Math.floor(elCenterPoint.y / viewportHeight) * viewportHeight;
+  if (window.scrollY !== targetScrollY) {
+    window.scrollTo(0, targetScrollY);
+  }
+
+  const topEl = document.elementFromPoint(
+    elCenterPoint.x,
+    elCenterPoint.y - window.scrollY
+  );
+
+  return topEl === el || el.contains(topEl);
+}
+
+/**
+ * Finds all position sticky/absolute elements on the page and adds a class
+ * that disables pointer events on them.
+ * @returns {() => void} - undo function to re-enable pointer events
+ */
+/* istanbul ignore next */
+function disableFixedAndStickyElementPointerEvents() {
+  const className = 'lighthouse-disable-pointer-events';
+  const styleTag = document.createElement('style');
+  styleTag.textContent = `.${className} { pointer-events: none !important }`;
+  document.body.appendChild(styleTag);
+
+  document.querySelectorAll('*').forEach(el => {
+    const position = getComputedStyle(el).position;
+    if (position === 'fixed' || position === 'sticky') {
+      el.classList.add(className);
+    }
+  });
+
+  return function undo() {
+    Array.from(document.getElementsByClassName(className)).forEach(el => {
+      el.classList.remove(className);
+    });
+    styleTag.remove();
+  };
+}
+
 /**
  * @returns {LH.Artifacts.TapTarget[]}
  */
@@ -257,10 +235,18 @@ function gatherTapTargets() {
   /** @type {LH.Artifacts.TapTarget[]} */
   const targets = [];
 
+  // Capture element positions relative to the top of the page
+  window.scrollTo(0, 0);
+
   /** @type {Element[]} */
   // @ts-ignore - getElementsInDocument put into scope via stringification
   const tapTargetElements = getElementsInDocument(tapTargetsSelector);
 
+  /** @type {{
+    tapTargetElement: Element,
+    clientRects: ClientRect[]
+  }[]} */
+  const tapTargetsWithClientRects = [];
   tapTargetElements.forEach(tapTargetElement => {
     // Filter out tap targets that are likely to cause false failures:
     if (elementHasAncestorTapTarget(tapTargetElement)) {
@@ -273,20 +259,54 @@ function gatherTapTargets() {
       // in the Web Content Accessibility Guidelines https://www.w3.org/TR/WCAG21/#target-size
       return;
     }
-    if (elementIsPositionFixedStickyOrAbsolute(tapTargetElement)) {
-      // Absolutely positioned elements might not be visible if they have a lower z-index
-      // than other tap targets, but if we don't ignore them we can get false failures.
-      //
-      // TODO: rewrite logic to use elementFromPoint to check if an element is visible
-      // (this should also mean we'd no longer need to check ancestor visible scroll area)
+    if (!elementIsVisible(tapTargetElement)) {
       return;
     }
 
-    const visibleClientRects = getVisibleClientRects(tapTargetElement);
-    if (visibleClientRects.length === 0) {
-      return;
+    tapTargetsWithClientRects.push({
+      tapTargetElement,
+      clientRects: getClientRects(tapTargetElement),
+    });
+  });
+
+  // Disable pointer events so that tap targets below them don't get
+  // detected as non-tappable (they are tappable, just not while the viewport
+  // is at the current scroll position)
+  const reenableFixedAndStickyElementPointerEvents = disableFixedAndStickyElementPointerEvents();
+
+  /** @type {{
+    tapTargetElement: Element,
+    visibleClientRects: ClientRect[]
+  }[]} */
+  const tapTargetsWithVisibleClientRects = [];
+  // We use separate loop here to get visible client rects because that involves
+  // scrolling around the page for elementCenterIsAtZAxisTop, which would affect the
+  // client rect positions.
+  tapTargetsWithClientRects.forEach(({tapTargetElement, clientRects}) => {
+    // Filter out empty client rects
+    let visibleClientRects = clientRects.filter(cr => cr.width !== 0 && cr.height !== 0);
+
+    // Filter out client rects that are invisible, e.g because they are in a position absolute element
+    // with a lower z-index than the main content.
+    // This will also filter out all position fixed or sticky tap targets elements because we disable pointer
+    // events on them before running this. That's the correct behavior because whether a position fixed/stick
+    // element overlaps with another tap target depends on the scroll position.
+    visibleClientRects = visibleClientRects.filter(rect => {
+      // Just checking the center can cause false failures for large partially hidden tap targets,
+      // but that should be a rare edge case
+      const rectCenterPoint = getRectCenterPoint(rect);
+      return elementCenterIsAtZAxisTop(tapTargetElement, rectCenterPoint);
+    });
+
+    if (visibleClientRects.length > 0) {
+      tapTargetsWithVisibleClientRects.push({
+        tapTargetElement,
+        visibleClientRects,
+      });
     }
+  });
 
+  for (const {tapTargetElement, visibleClientRects} of tapTargetsWithVisibleClientRects) {
     targets.push({
       clientRects: visibleClientRects,
       snippet: truncate(tapTargetElement.outerHTML, 300),
@@ -298,7 +318,9 @@ function gatherTapTargets() {
       nodeLabel: getNodeLabel(tapTargetElement),
       href: /** @type {HTMLAnchorElement} */(tapTargetElement)['href'] || '',
     });
-  });
+  }
+
+  reenableFixedAndStickyElementPointerEvents();
 
   return targets;
 }
@@ -312,17 +334,18 @@ class TapTargets extends Gatherer {
     const expression = `(function() {
       const tapTargetsSelector = "${tapTargetsSelector}";
       ${pageFunctions.getElementsInDocumentString};
-      ${filterClientRectsWithinAncestorsVisibleScrollArea.toString()};
-      ${elementIsPositionFixedStickyOrAbsolute.toString()};
+      ${disableFixedAndStickyElementPointerEvents.toString()};
       ${elementIsVisible.toString()};
       ${elementHasAncestorTapTarget.toString()};
-      ${getVisibleClientRects.toString()};
+      ${elementCenterIsAtZAxisTop.toString()}
       ${truncate.toString()};
       ${getClientRects.toString()};
       ${hasTextNodeSiblingsFormingTextBlock.toString()};
       ${elementIsInTextBlock.toString()};
-      ${allClientRectsEmpty.toString()};
-      ${rectContainsString};
+      ${getRectArea.toString()};
+      ${getLargestRect.toString()};
+      ${getRectCenterPoint.toString()};
+      ${rectContains.toString()};
       ${pageFunctions.getNodePathString};
       ${pageFunctions.getNodeSelectorString};
       ${pageFunctions.getNodeLabelString};
diff --git a/lighthouse-core/lib/rect-helpers.js b/lighthouse-core/lib/rect-helpers.js
index ced6ba6b1..63e99d79f 100644
--- a/lighthouse-core/lib/rect-helpers.js
+++ b/lighthouse-core/lib/rect-helpers.js
@@ -29,11 +29,6 @@ function rectContains(rect1, rect2) {
     rect2.left >= rect1.left;
 }
 
-
-const rectContainsString = `
-  ${rectContains.toString()};
-`;
-
 /**
  * @param {LH.Artifacts.Rect[]} rects
  * @returns {LH.Artifacts.Rect[]}
@@ -68,6 +63,7 @@ function filterOutRectsContainedByOthers(rects) {
 /**
  * @param {LH.Artifacts.Rect} rect
  */
+/* istanbul ignore next */
 function getRectCenterPoint(rect) {
   return {
     x: rect.left + rect.width / 2,
@@ -198,6 +194,7 @@ function getRectAtCenter(rect, centerRectSize) {
 /**
  * @param {LH.Artifacts.Rect} rect
  */
+/* istanbul ignore next */
 function getRectArea(rect) {
   return rect.width * rect.height;
 }
@@ -205,6 +202,7 @@ function getRectArea(rect) {
 /**
  * @param {LH.Artifacts.Rect[]} rects
  */
+/* istanbul ignore next */
 function getLargestRect(rects) {
   let largestRect = rects[0];
   for (const rect of rects) {
@@ -234,12 +232,12 @@ function allRectsContainedWithinEachOther(rectListA, rectListB) {
 module.exports = {
   rectContainsPoint,
   rectContains,
-  rectContainsString,
   addRectWidthAndHeight,
   addRectTopAndBottom,
   getRectOverlapArea,
   getRectAtCenter,
   getLargestRect,
+  getRectArea,
   getRectCenterPoint,
   getBoundingRect,
   getBoundingRectWithPadding,
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 370cbd326..8be7d1206 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -1111,14 +1111,13 @@
 
 .lh-gauge__percentage {
   width: 100%;
-  height: var(--inset-size);
+  height: var(--gauge-circle-size);
   position: absolute;
-  border-radius: inherit;
   font-family: var(--monospace-font-family);
-  font-size: var(--score-number-font-size);
-  line-height: var(--score-number-font-size);
+  font-size: calc(var(--gauge-circle-size) * 0.34 + 1.3px);
+  line-height: 0;
   text-align: center;
-  top: calc(var(--score-container-padding) + var(--gauge-circle-size) / 3);
+  top: calc(var(--score-container-padding) + var(--gauge-circle-size) / 2);
 }
 
 .lh-category .lh-gauge__percentage {
diff --git a/lighthouse-core/scripts/roll-to-devtools.sh b/lighthouse-core/scripts/roll-to-devtools.sh
index fd1068566..963cb42c0 100755
--- a/lighthouse-core/scripts/roll-to-devtools.sh
+++ b/lighthouse-core/scripts/roll-to-devtools.sh
@@ -49,4 +49,4 @@ cp -r dist/dt-report-resources/ $fe_lh_dir
 VERSION=$(node -e "console.log(require('./package.json').version)")
 sed -i '' -e "s/Version:.*/Version: $VERSION/g" "$tests_dir"/*-expected.txt
 
-echo "Done. To rebase the test expectations, run: ~/chromium/src/third_party/blink/tools/run_web_tests.py --no-retry 'http/tests/devtools/audits2/*' --reset-results"
+echo "Done. To rebase the test expectations, run: yarn --cwd ~/chromium/src/third_party/blink/renderer/devtools test 'http/tests/devtools/audits2/*' --reset-results"


Test patch:
diff --git a/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html b/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
index 141011099..9f76e82d8 100644
--- a/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
+++ b/lighthouse-cli/test/fixtures/seo/seo-tap-targets.html
@@ -27,9 +27,19 @@
   <!-- Big tap target, but it's invisible because it's behind the main content div -->
   <a style="background: red; position: absolute; top: 0; bottom: 0; left: 0; right: 0; z-index: -1;"></a>
 
-  <a style="background: red; position: sticky; top: 0; left: 0; display: block; width: 100vw; height: 40px; z-index: 1;">sticky</a>
+  <a style="background: red; position: sticky; top: 0; left: 0; display: block; width: 100vw; height: 30px; z-index: 1;">sticky</a>
 
   <div style="background: white; padding: 20px;">
+
+    <div style="height: 300px">
+      <a
+        data-gathered-target="large-link-at-bottom-of-page"
+        style="background: #afa; position: absolute; top: 0; display: block; width: calc(100% - 40px); height: 300px;">
+        This link is intentionally placed at the top of the page, so that the sticky header is hard to tap on.
+        It should not fail though because overlap with a sticky element depends on the scroll position.
+      </a>
+    </div>
+      
     <h1>SEO Tap targets</h1>
 
     <!-- Invisible nodes don't cause failures -->
@@ -47,14 +57,14 @@
         width 0 and overflow x hidden
       </a>
       <!-- Visible target should not fail because nothing overlaps it -->
-      <a>visible target</a>
+      <a data-gathered-target="visible-target">visible target</a>
     </div>
     <br/><br/>
 
     
     <div style="overflow: hidden; position: relative">
       <!-- Should be counted as visible although part of it is outside the scroll container -->
-      <a>
+      <a data-gathered-target="target-with-client-rect-outside-scroll-container">
         <div style="position: absolute;top: -100px">invisible</div>
         visible
       </a>
@@ -64,18 +74,18 @@
 
     <!-- Link contains large inline block element - no failure because finger
       should be placed in the center of the whole link area -->
-    <a style="background: red;">
+    <a style="background: red;" data-gathered-target="link-containing-large-inline-block-element">
       <div style="display: inline-block; height: 30px; width: 30px; background: orange;"></div>
       Link
     </a>
     <br/>
-    <a style="display: block; padding: 30px; background: #ddf;">
+    <a style="display: block; padding: 30px; background: #ddf;" data-gathered-target="link-next-to-link-containing-large-inline-block-element">
       Link that the top one would overlap with, if it weren't for the inline-block child.
     </a>
 
     <br/><br/>
 
-    <div role="button">
+    <div role="button" data-gathered-target="tap-target-containing-other-tap-targets">
       Tap target with children that are also tap targets should not fail.
       (Children should not be counted as independent tap targets that appear
       in the list.)
@@ -85,29 +95,78 @@
 
     <br/><br/>
 
-    <div style="position: relative">
-      <a style="display: block; position: absolute; top:0; height: 40px; width: 40px;">inner</a>
-      <a style="display: block; height: 100px; width: 100px; background: yellowgreen">outer</a>
+    <!-- The left tap target has a large client rect that would normally overlap with the right tap target, 
+         but the left link is overflow hidden, so there should be no failure. We should however
+         detect both tap targets (and not say the left one is invisible because the center of the big client rect is invisible) -->
+    <div style="overflow: hidden">
+      <a
+        data-gathered-target="child-client-rect-hidden-by-overflow-hidden"
+        style="display: block; height: 40px; width: 40px; background: #fca; float: left; overflow: hidden; position: relative;">
+        <div style="background: #afa; height: 40px; width: 80px; position: absolute; left: 30px">
+          left    
+        </div>
+      </a>
+      <a
+        data-gathered-target="tap-target-next-to-child-client-rect-hidden-by-overflow-hidden"
+        style="display: block; height: 40px; width: 60px; background: yellowgreen; float: left">right</a>
     </div>
 
     <br/><br/>
 
-    <!-- Only target that's being overlapped should fail, the overlapping one shouldn't -->
-    <div>
-      <a style="display: block; width: 100px; height: 30px;background: #ddd;">
-        too small target
-      </a>
-      <a style="display: block; width: 100px; height: 100px;background: #aaa;">
-        big enough target
+    <!--Similar to previous test, except that the position absolute child isn't hidden by "overflow: hidden" on the parent. -->
+      <div style="overflow: hidden"></div>
+      <a
+        data-gathered-target="child-client-rect-overlapping-other-target"
+        style="display: block; height: 40px; width: 40px; background: #fca; float: left; position: relative;">
+        <div style="background: #afa; height: 40px; width: 30px; position: absolute; left: 30px">
+          left    
+        </div>
       </a>
+      <a
+        data-gathered-target="tap-target-overlapped-by-other-targets-position-absolute-child-rect"
+        style="display: block; height: 40px; width: 60px; background: yellowgreen; float: left">right</a>
+    </div>
+
+    <br/><br/>
+
+    <div style="position: relative">
+      <a
+        data-gathered-target="position-absolute-tap-target-fully-contained-in-other-target"
+        style="display: block; position: absolute; top:0; height: 40px; width: 40px;">inner</a>
+      <a
+        data-gathered-target="tap-target-fully-containing-position-absolute-target"
+        style="display: block; height: 100px; width: 100px; background: yellowgreen">outer</a>
+    </div>
+
+    <br/><br/>
+
+    <!-- position: absolute wrapper shouldn't stop not prevent the check (fixed/sticky would) -->
+    <div style="position: relative; height: 200px;">
+      <div style="position: absolute;">
+        <!-- Only target that's being overlapped should fail, the overlapping one shouldn't -->
+        <a
+          data-gathered-target="too-small-failing-tap-target"
+          style="display: block; width: 100px; height: 30px;background: #ddd;">
+          too small target
+        </a>
+        <a
+          data-gathered-target="large-enough-tap-target-next-to-too-small-tap-target"
+          style="display: block; width: 100px; height: 100px;background: #aaa;">
+          big enough target
+        </a>
+      </div>
     </div>
 
     <br/><br/>
 
     <!-- Should not fail if the two links have the same link target -->
     <div>
-      <a style="display: block; width: 10px; height: 10px;" href="../seo/"></a>
-      <a style="display: block; width: 10px; height: 10px;" href="./"></a>
+      <a
+        data-gathered-target="links-with-same-link-target-1"
+        style="display: block; width: 10px; height: 10px;" href="../seo/"></a>
+      <a
+        data-gathered-target="links-with-same-link-target-2"
+        style="display: block; width: 10px; height: 10px;" href="./"></a>
     </div>
 
     <!-- Links in text blocks are exempted from size/overlap requirements and should not fail -->
@@ -118,12 +177,6 @@
       This is <a>a link in a text block.</a>
       This is <a>a link in a text block.</a>
     </p>
-
-    <a style="background: #afa; display: block; width: 100%; height: 800px;">
-      This link is intentionally placed at the bottom of the page, so that when we've scrolled
-      to the bottom of the page it overlaps with the sticky header. (Should not fail though
-      because the overlap depends on the current scroll position.)
-    </a>
   </div>
 </body>
 </html>
diff --git a/lighthouse-cli/test/smokehouse/seo/expectations.js b/lighthouse-cli/test/smokehouse/seo/expectations.js
index 8b8e5f71a..a913495e7 100644
--- a/lighthouse-cli/test/smokehouse/seo/expectations.js
+++ b/lighthouse-cli/test/smokehouse/seo/expectations.js
@@ -16,6 +16,60 @@ function headersParam(headers) {
   return new URLSearchParams([['extra_header', headerString]]).toString();
 }
 
+const expectedGatheredTapTargets = [
+  {
+    snippet: /large-link-at-bottom-of-page/,
+  },
+  {
+    snippet: /visible-target/,
+  },
+  {
+    snippet: /target-with-client-rect-outside-scroll-container/,
+  },
+  {
+    snippet: /link-containing-large-inline-block-element/,
+  },
+  {
+    snippet: /link-next-to-link-containing-large-inline-block-element/,
+  },
+  {
+    snippet: /tap-target-containing-other-tap-targets/,
+  },
+  {
+    snippet: /child-client-rect-hidden-by-overflow-hidden/,
+  },
+  {
+    snippet: /tap-target-next-to-child-client-rect-hidden-by-overflow-hidden/,
+  },
+  {
+    snippet: /child-client-rect-overlapping-other-target/,
+    shouldFail: true,
+  },
+  {
+    snippet: /tap-target-overlapped-by-other-targets-position-absolute-child-rect/,
+    shouldFail: true,
+  },
+  {
+    snippet: /position-absolute-tap-target-fully-contained-in-other-target/,
+  },
+  {
+    snippet: /tap-target-fully-containing-position-absolute-target/,
+  },
+  {
+    snippet: /too-small-failing-tap-target/,
+    shouldFail: true,
+  },
+  {
+    snippet: /large-enough-tap-target-next-to-too-small-tap-target/,
+  },
+  {
+    snippet: /links-with-same-link-target-1/,
+  },
+  {
+    snippet: /links-with-same-link-target-2/,
+  },
+];
+
 const failureHeaders = headersParam([[
   'x-robots-tag',
   'none',
@@ -190,38 +244,50 @@ module.exports = [
       audits: {
         'tap-targets': {
           score: (() => {
-            const PASSING_TAP_TARGETS = 11;
-            const TOTAL_TAP_TARGETS = 12;
+            const totalTapTargets = expectedGatheredTapTargets.length;
+            const passingTapTargets = expectedGatheredTapTargets.filter(t => !t.shouldFail).length;
             const SCORE_FACTOR = 0.89;
-            return Math.floor(PASSING_TAP_TARGETS / TOTAL_TAP_TARGETS * SCORE_FACTOR * 100) / 100;
+            return Math.round(passingTapTargets / totalTapTargets * SCORE_FACTOR * 100) / 100;
           })(),
           details: {
             items: [
               {
                 'tapTarget': {
                   'type': 'node',
-                  'snippet': '<a ' +
-                 'style="display: block; width: 100px; height: 30px;background: #ddd;">' +
-                 '\n        too small target\n      </a>',
-                  'path': '2,HTML,1,BODY,3,DIV,21,DIV,0,A',
+                  'path': '2,HTML,1,BODY,10,DIV,0,DIV,1,A',
                   'selector': 'body > div > div > a',
                   'nodeLabel': 'too small target',
                 },
                 'overlappingTarget': {
                   'type': 'node',
-                  'snippet': '<a ' +
-                  'style="display: block; width: 100px; height: 100px;background: #aaa;">' +
-                  '\n        big enough target\n      </a>',
-                  'path': '2,HTML,1,BODY,3,DIV,21,DIV,1,A',
+                  'path': '2,HTML,1,BODY,10,DIV,0,DIV,2,A',
                   'selector': 'body > div > div > a',
                   'nodeLabel': 'big enough target',
                 },
-                'size': '100x30',
-                'width': 100,
-                'height': 30,
                 'tapTargetScore': 1440,
                 'overlappingTargetScore': 432,
                 'overlapScoreRatio': 0.3,
+                'size': '100x30',
+                'width': 100,
+                'height': 30,
+              },
+              {
+                'tapTarget': {
+                  'type': 'node',
+                  'path': '2,HTML,1,BODY,3,DIV,24,A',
+                  'selector': 'body > div > a',
+                },
+                'overlappingTarget': {
+                  'type': 'node',
+                  'path': '2,HTML,1,BODY,3,DIV,25,A',
+                  'selector': 'body > div > a',
+                },
+                'tapTargetScore': 1920,
+                'overlappingTargetScore': 560,
+                'overlapScoreRatio': 0.2916666666666667,
+                'size': '40x40',
+                'width': 40,
+                'height': 40,
               },
             ],
           },
@@ -229,9 +295,7 @@ module.exports = [
       },
     },
     artifacts: {
-      TapTargets: {
-        length: 11,
-      },
+      TapTargets: expectedGatheredTapTargets.map(({snippet}) => ({snippet})),
     },
   },
 ];