Repository: GoogleChrome/lighthouse
Pull request #7778 (2019-03-28 16:29:29)

core(tap-targets): don't exclude visible position absolute elements from audit
**Summary**

- include tap targets that are position absolute or have a position absolute ancestor
- check that tap targets are visible using elementFromPoint

![Screenshot 2019-03-28 at 16 29 48](https://user-images.githubusercontent.com/1303660/55175213-c3886f00-5176-11e9-82f5-752dc9a3e101.png)

Also catching this now:

![Screenshot 2019-04-25 at 13 58 45](https://user-images.githubusercontent.com/1303660/56745470-51ee1180-6772-11e9-8357-23eea7518184.png)


**Related Issues/PRs**

Closes https://github.com/GoogleChrome/lighthouse/issues/7365

mattzeunert: > high level looks pretty good! did you have the stats on sites that start failing more now in the issue or was that something else I was remembering?

I did a few of those recently, so you're probably thinking of something else. But I do plan to get some stats for this one too.
mattzeunert: DZL, do a barrel roll!
devtools-bot: DZL is done! Go check it out http://lh-dzl-7778.surge.sh
devtools-bot: DZL is done! Go check it out http://lh-dzl-7778.surge.sh
mattzeunert: Here are [some examples of how scores are impacted](https://gist.github.com/mattzeunert/7e1d8f3bfd2ce7aa29ab1b4e6790966c). Generally it looks good, not just in terms of including position absolute elements, we're also detecting much better whether elements are visible. Do you think we need to do anything about the `overflow: hidden` issue on Godaddy?

For 100 sites I tested there's a change of ±5.7 point on average. Positive and negative changes even out overall and there's a drop of just over 1 point in the average score.

devtools-bot: DZL is done! Go check it out http://lh-dzl-7778.surge.sh
devtools-bot: DZL is done! Go check it out http://lh-dzl-7778.surge.sh
mattzeunert: @patrickhulce Should be good for some more review now 🙂
devtools-bot: DZL is done! Go check it out http://lh-dzl-7778.surge.sh
devtools-bot: DZL is done! Go check it out http://lh-dzl-7778.surge.sh
devtools-bot: DZL is done! Go check it out http://lh-dzl-7778.surge.sh
mattzeunert: > to sidestep the need for switching on/off pointer-events, did you consider document.elementsFromPoint()? Since the returned array is in paint order, seems like it would be possible to shift off any starting elements that are fixed or sticky?

No I didn't consider it, that's a good idea!

I tried it now and `elementsFP` is a lot slower than `elementFP`. (200x slower in a simple loop for the same position over and over.) The gatherer as a whole is 2.5x slower on the List of 2017 albums.


devtools-bot: DZL is done! Go check it out http://lh-dzl-7778.surge.sh
paulirish: @brendankenny want another pass or is this g2g?

Patch:
diff --git a/docs/plugins.md b/docs/plugins.md
index f778be202..7ae9db821 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -350,5 +350,6 @@ Most artifacts will try to represent as truthfully as possible what was observed
 
 ## Examples
 
+- [Field Performance](https://github.com/treosh/lighthouse-plugin-field-performance) - A plugin to gather and display Chrome UX Report field data
 - [Publisher Ads Audits](https://github.com/googleads/pub-ads-lighthouse-plugin) - a well-written, but complex, plugin
 - [Lighthouse Plugin Recipe](./recipes/lighthouse-plugin-example)
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/proto/sample_v2_round_trip.json b/proto/sample_v2_round_trip.json
index 0196d5311..36cf5bd0c 100644
--- a/proto/sample_v2_round_trip.json
+++ b/proto/sample_v2_round_trip.json
@@ -2,14 +2,9 @@
     "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": 1.0,
-            "scoreDisplayMode": "binary",
+            "score": null,
+            "scoreDisplayMode": "notApplicable",
             "title": "`[accesskey]` values are unique"
         },
         "appcache-manifest": {
@@ -152,9 +147,14 @@
         },
         "button-name": {
             "description": "When a button doesn't have an accessible name, screen readers announce it as \"button\", making it unusable for users who rely on screen readers. [Learn more](https://dequeuniversity.com/rules/axe/3.1/button-name?application=lighthouse).",
+            "details": {
+                "headings": [],
+                "items": [],
+                "type": "table"
+            },
             "id": "button-name",
-            "score": null,
-            "scoreDisplayMode": "notApplicable",
+            "score": 1.0,
+            "scoreDisplayMode": "binary",
             "title": "Buttons have an accessible name"
         },
         "bypass": {
@@ -3163,7 +3163,7 @@
                 {
                     "group": "a11y-navigation",
                     "id": "accesskeys",
-                    "weight": 3.0
+                    "weight": 0.0
                 },
                 {
                     "group": "a11y-aria",
@@ -3208,7 +3208,7 @@
                 {
                     "group": "a11y-names-labels",
                     "id": "button-name",
-                    "weight": 0.0
+                    "weight": 10.0
                 },
                 {
                     "group": "a11y-navigation",
@@ -3383,7 +3383,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.38,
+            "score": 0.46,
             "title": "Accessibility"
         },
         "best-practices": {


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})),
     },
   },
 ];
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 789eb8220..56c0fc764 100644
--- a/lighthouse-core/test/report/html/renderer/category-renderer-test.js
+++ b/lighthouse-core/test/report/html/renderer/category-renderer-test.js
@@ -242,7 +242,7 @@ describe('CategoryRenderer', () => {
       const categoryDOM = renderer.render(category, sampleResults.categoryGroups);
 
       const gauge = categoryDOM.querySelector('.lh-gauge__percentage');
-      assert.equal(gauge.textContent.trim(), '38', 'score is 0-100');
+      assert.equal(gauge.textContent.trim(), '46', 'score is 0-100');
 
       const score = categoryDOM.querySelector('.lh-category-header');
       const value = categoryDOM.querySelector('.lh-gauge__percentage');
diff --git a/lighthouse-core/test/results/artifacts/artifacts.json b/lighthouse-core/test/results/artifacts/artifacts.json
index 66b699a1f..2cbcd6481 100644
--- a/lighthouse-core/test/results/artifacts/artifacts.json
+++ b/lighthouse-core/test/results/artifacts/artifacts.json
@@ -1337,29 +1337,28 @@
     ],
     "notApplicable": [
       {
-        "id": "aria-allowed-attr",
+        "id": "accesskeys",
         "impact": null,
         "tags": [
-          "cat.aria",
-          "wcag2a",
-          "wcag412"
+          "best-practice",
+          "cat.keyboard"
         ],
-        "description": "Ensures ARIA attributes are allowed for an element's role",
-        "help": "Elements must only use allowed ARIA attributes",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/aria-allowed-attr?application=axeAPI",
+        "description": "Ensures every accesskey attribute value is unique",
+        "help": "accesskey attribute value must be unique",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/accesskeys?application=axeAPI",
         "nodes": []
       },
       {
-        "id": "aria-dpub-role-fallback",
+        "id": "aria-allowed-attr",
         "impact": null,
         "tags": [
           "cat.aria",
           "wcag2a",
-          "wcag131"
+          "wcag412"
         ],
-        "description": "Ensures unsupported DPUB roles are only used on elements with implicit fallback roles",
-        "help": "Unsupported DPUB ARIA roles should be used on elements with implicit fallback roles",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/aria-dpub-role-fallback?application=axeAPI",
+        "description": "Ensures ARIA attributes are allowed for an element's role",
+        "help": "Elements must only use allowed ARIA attributes",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/aria-allowed-attr?application=axeAPI",
         "nodes": []
       },
       {
@@ -1452,22 +1451,7 @@
         ],
         "description": "Ensures <audio> elements have captions",
         "help": "<audio> elements must have a captions track",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/audio-caption?application=axeAPI",
-        "nodes": []
-      },
-      {
-        "id": "button-name",
-        "impact": null,
-        "tags": [
-          "cat.name-role-value",
-          "wcag2a",
-          "wcag412",
-          "section508",
-          "section508.22.a"
-        ],
-        "description": "Ensures buttons have discernible text",
-        "help": "Buttons must have discernible text",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/button-name?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/audio-caption?application=axeAPI",
         "nodes": []
       },
       {
@@ -1493,33 +1477,7 @@
         ],
         "description": "Ensures <dt> and <dd> elements are contained by a <dl>",
         "help": "<dt> and <dd> elements must be contained by a <dl>",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/dlitem?application=axeAPI",
-        "nodes": []
-      },
-      {
-        "id": "duplicate-id-active",
-        "impact": null,
-        "tags": [
-          "cat.parsing",
-          "wcag2a",
-          "wcag411"
-        ],
-        "description": "Ensures every id attribute value of active elements is unique",
-        "help": "IDs of active elements must be unique",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/duplicate-id-active?application=axeAPI",
-        "nodes": []
-      },
-      {
-        "id": "duplicate-id-aria",
-        "impact": null,
-        "tags": [
-          "cat.parsing",
-          "wcag2a",
-          "wcag411"
-        ],
-        "description": "Ensures every id attribute value used in ARIA and in labels is unique",
-        "help": "IDs used in ARIA and labels must be unique",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/duplicate-id-aria?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/dlitem?application=axeAPI",
         "nodes": []
       },
       {
@@ -1548,20 +1506,7 @@
         ],
         "description": "Ensures the lang attribute of the <html> element has a valid value",
         "help": "<html> element must have a valid value for the lang attribute",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/html-lang-valid?application=axeAPI",
-        "nodes": []
-      },
-      {
-        "id": "html-xml-lang-mismatch",
-        "impact": null,
-        "tags": [
-          "cat.language",
-          "wcag2a",
-          "wcag311"
-        ],
-        "description": "Ensure that HTML elements with both valid lang and xml:lang attributes agree on the base language of the page",
-        "help": "HTML elements with lang and xml:lang must have the same base language",
-        "helpUrl": "https://dequeuniversity.com/rules/axe/3.1/html-xml-lang-mismatch?application=axeAPI",
+        "helpUrl": "https://dequeuniversity.com/rules/axe/3.2/html-lang-valid?application=axeAPI",
         "nodes": []
       },
       {
diff --git a/lighthouse-core/test/results/sample_v2.json b/lighthouse-core/test/results/sample_v2.json
index ad1b5d0b8..c8a46b36f 100644
--- a/lighthouse-core/test/results/sample_v2.json
+++ b/lighthouse-core/test/results/sample_v2.json
@@ -1493,13 +1493,8 @@
       "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",
-      "details": {
-        "type": "table",
-        "headings": [],
-        "items": []
-      }
+      "score": null,
+      "scoreDisplayMode": "notApplicable"
     },
     "aria-allowed-attr": {
       "id": "aria-allowed-attr",
@@ -1561,8 +1556,13 @@
       "id": "button-name",
       "title": "Buttons have an accessible name",
       "description": "When a button doesn't have an accessible name, screen readers announce it as \"button\", making it unusable for users who rely on screen readers. [Learn more](https://dequeuniversity.com/rules/axe/3.1/button-name?application=lighthouse).",
-      "score": null,
-      "scoreDisplayMode": "notApplicable"
+      "score": 1,
+      "scoreDisplayMode": "binary",
+      "details": {
+        "type": "table",
+        "headings": [],
+        "items": []
+      }
     },
     "bypass": {
       "id": "bypass",
@@ -3523,7 +3523,7 @@
       "auditRefs": [
         {
           "id": "accesskeys",
-          "weight": 3,
+          "weight": 0,
           "group": "a11y-navigation"
         },
         {
@@ -3568,7 +3568,7 @@
         },
         {
           "id": "button-name",
-          "weight": 0,
+          "weight": 10,
           "group": "a11y-names-labels"
         },
         {
@@ -3742,7 +3742,7 @@
         }
       ],
       "id": "accessibility",
-      "score": 0.38
+      "score": 0.46
     },
     "best-practices": {
       "title": "Best Practices",