Bug 1473037 - Display contrast ratio for text nodes inside the accessibility infobar. r=pbro
authorYura Zenevich <yura.zenevich@gmail.com>
Tue, 02 Oct 2018 13:29:24 +0000
changeset 495095 6bca826c27cf5f951069fe64617dc1e1e58a82ab
parent 495094 fc316e011bb6647781db75fb01e2845410ca9e3a
child 495121 3530790e23d18b6f8f73471e367a942f201dd452
child 495125 853e6a2f456157b664591ba47f30d8a3482b1bf8
push id9984
push userffxbld-merge
push dateMon, 15 Oct 2018 21:07:35 +0000
treeherdermozilla-beta@183d27ea8570 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbro
bugs1473037
milestone64.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1473037 - Display contrast ratio for text nodes inside the accessibility infobar. r=pbro Co-authored-by: Micah Tigley <mtigley@mozilla.com> MozReview-Commit-ID: 1KbcRG0bZA3 Differential Revision: https://phabricator.services.mozilla.com/D4954
devtools/server/actors/accessibility.js
devtools/server/actors/highlighters.css
devtools/server/actors/highlighters/utils/accessibility.js
devtools/server/actors/highlighters/xul-accessible.js
devtools/server/actors/inspector/node.js
devtools/server/actors/inspector/utils.js
devtools/server/actors/utils/accessibility.js
devtools/server/actors/utils/moz.build
devtools/shared/css/color.js
devtools/shared/locales/en-US/accessibility.properties
--- a/devtools/server/actors/accessibility.js
+++ b/devtools/server/actors/accessibility.js
@@ -16,16 +16,17 @@ const {
   accessibleWalkerSpec,
   accessibilitySpec
 } = require("devtools/shared/specs/accessibility");
 
 const { isXUL } = require("devtools/server/actors/highlighters/utils/markup");
 const { isWindowIncluded } = require("devtools/shared/layout/utils");
 const { CustomHighlighterActor, register } =
   require("devtools/server/actors/highlighters");
+const { getContrastRatioFor } = require("devtools/server/actors/utils/accessibility");
 const PREF_ACCESSIBILITY_FORCE_DISABLED = "accessibility.force_disabled";
 
 const nsIAccessibleEvent = Ci.nsIAccessibleEvent;
 const nsIAccessibleStateChangeEvent = Ci.nsIAccessibleStateChangeEvent;
 const nsIAccessibleRole = Ci.nsIAccessibleRole;
 
 const {
   EVENT_TEXT_CHANGED,
@@ -363,16 +364,46 @@ const AccessibleActor = ActorClassWithSp
       keyboardShortcut: this.keyboardShortcut,
       childCount: this.childCount,
       domNodeType: this.domNodeType,
       indexInParent: this.indexInParent,
       states: this.states,
       actions: this.actions,
       attributes: this.attributes
     };
+  },
+
+  _isValidTextLeaf(rawAccessible) {
+    return !isDefunct(rawAccessible) &&
+           rawAccessible.role === nsIAccessibleRole.ROLE_TEXT_LEAF &&
+           rawAccessible.name && rawAccessible.name.trim().length > 0;
+  },
+
+  get _nonEmptyTextLeafs() {
+    return this.children().filter(child => this._isValidTextLeaf(child.rawAccessible));
+  },
+
+  /**
+   * Calculate the contrast ratio of the given accessible.
+   */
+  _getContrastRatio() {
+    return getContrastRatioFor(this._isValidTextLeaf(this.rawAccessible) ?
+      this.rawAccessible.DOMNode.parentNode : this.rawAccessible.DOMNode);
+  },
+
+  /**
+   * Audit the state of the accessible object.
+   *
+   * @return {Object|null}
+   *         Audit results for the accessible object.
+  */
+  get audit() {
+    return this.isDefunct ? null : {
+      contrastRatio: this._getContrastRatio()
+    };
   }
 });
 
 /**
  * The AccessibleWalkerActor stores a cache of AccessibleActors that represent
  * accessible objects in a given document.
  *
  * It is also responsible for implicitely initializing and shutting down
@@ -705,23 +736,24 @@ const AccessibleWalkerActor = ActorClass
    * @param  {Object} options
    *         Object used for passing options. Available options:
    *         - duration {Number}
    *                    Duration of time that the highlighter should be shown.
    * @return {Boolean}
    *         True if highlighter shows the accessible object.
    */
   highlightAccessible(accessible, options = {}) {
-    const { bounds, name, role } = accessible;
+    const { bounds } = accessible;
     if (!bounds) {
       return false;
     }
 
+    const { audit, name, role } = accessible;
     return this.highlighter.show({ rawNode: accessible.rawAccessible.DOMNode },
-                                 { ...options, ...bounds, name, role });
+                                 { ...options, ...bounds, name, role, audit });
   },
 
   /**
    * Public method used to hide an accessible object highlighter on the client
    * side.
    */
   unhighlight() {
     this.highlighter.hide();
@@ -798,25 +830,17 @@ const AccessibleWalkerActor = ActorClass
     }
 
     const accessible = await this._findAndAttachAccessible(event);
     if (!accessible) {
       return;
     }
 
     if (this._currentAccessible !== accessible) {
-      const { bounds, role, name } = accessible;
-      if (bounds) {
-        this.highlighter.show({ rawNode: event.originalTarget || event.target }, {
-          ...bounds,
-          role,
-          name
-        });
-      }
-
+      this.highlightAccessible(accessible);
       events.emit(this, "picker-accessible-hovered", accessible);
       this._currentAccessible = accessible;
     }
   },
 
   /**
    * Keyboard event handler for when picking is enabled.
    *
--- a/devtools/server/actors/highlighters.css
+++ b/devtools/server/actors/highlighters.css
@@ -642,23 +642,42 @@
 
 /* Accessible highlighter */
 
 :-moz-native-anonymous .accessible-bounds {
   opacity: 0.6;
   fill: #6a5acd;
 }
 
-:-moz-native-anonymous .accessible-infobar-name {
-  color:var(--highlighter-infobar-color);
+:-moz-native-anonymous .accessible-infobar-name,
+:-moz-native-anonymous .accessible-infobar-audit {
+  color: var(--highlighter-infobar-color);
   max-width: 90%;
 }
 
-:-moz-native-anonymous .accessible-infobar-name:not(:empty) {
-  color: var(--highlighter-infobar-color);
+:-moz-native-anonymous .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AA:after,
+:-moz-native-anonymous .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AAA:after {
+  color: #90E274;
+}
+
+:-moz-native-anonymous .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).fail:after {
+  color: #E57180;
+  content: " ⚠️";
+}
+
+:-moz-native-anonymous .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AA:after {
+  content: " AA\2713";
+}
+
+:-moz-native-anonymous .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AAA:after {
+  content: " AAA\2713";
+}
+
+:-moz-native-anonymous .accessible-infobar-name:not(:empty),
+:-moz-native-anonymous .accessible-infobar-audit:not(:empty) {
   border-inline-start: 1px solid #5a6169;
   margin-inline-start: 6px;
   padding-inline-start: 6px;
 }
 
 :-moz-native-anonymous .accessible-infobar-role {
   color: #9CDCFE;
 }
--- a/devtools/server/actors/highlighters/utils/accessibility.js
+++ b/devtools/server/actors/highlighters/utils/accessibility.js
@@ -1,29 +1,35 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
+const DevToolsUtils = require("devtools/shared/DevToolsUtils");
 const { getCurrentZoom, getViewportDimensions } = require("devtools/shared/layout/utils");
 const { moveInfobar, createNode } = require("./markup");
 const { truncateString } = require("devtools/shared/inspector/utils");
 
+const STRINGS_URI = "devtools/shared/locales/accessibility.properties";
+loader.lazyRequireGetter(this, "LocalizationHelper", "devtools/shared/l10n", true);
+DevToolsUtils.defineLazyGetter(this, "L10N", () => new LocalizationHelper(STRINGS_URI));
+
 // Max string length for truncating accessible name values.
 const MAX_STRING_LENGTH = 50;
 
 /**
  * The AccessibleInfobar is a class responsible for creating the markup for the
  * accessible highlighter. It is also reponsible for updating content within the
  * infobar such as role and name values.
  */
 class Infobar {
   constructor(highlighter) {
     this.highlighter = highlighter;
+    this.audit = new Audit(this);
   }
 
   get document() {
     return this.highlighter.win.document;
   }
 
   get bounds() {
     return this.highlighter._bounds;
@@ -105,23 +111,27 @@ class Infobar {
       nodeType: "span",
       parent: infobarText,
       attributes: {
         "class": "infobar-name",
         "id": "infobar-name",
       },
       prefix: this.prefix,
     });
+
+    this.audit.buildMarkup(infobarText);
   }
 
   /**
    * Destroy the Infobar's highlighter.
    */
   destroy() {
     this.highlighter = null;
+    this.audit.destroy();
+    this.audit = null;
   }
 
   /**
    * Gets the element with the specified ID.
    *
    * @param  {String} id
    *         Element ID.
    * @return {Element} The element with specified ID.
@@ -163,20 +173,21 @@ class Infobar {
     // Update the infobar's position and content.
     this.update(container);
   }
 
   /**
    * Update content of the infobar.
    */
   update(container) {
-    const { name, role } = this.options;
+    const { audit, name, role } = this.options;
 
     this.updateRole(role, this.getElement("infobar-role"));
     this.updateName(name, this.getElement("infobar-name"));
+    this.audit.update(audit);
 
     // Position the infobar.
     this._moveInfobar(container);
   }
 
   /**
    * Sets the text content of the specified element.
    *
@@ -351,16 +362,155 @@ class XULWindowInfobar extends Infobar {
    *         Text for content.
    */
   setTextContent(el, text) {
     el.textContent = text;
   }
 }
 
 /**
+ * Audit component used within the accessible highlighter infobar. This component is
+ * responsible for rendering and updating its containing AuditReport components that
+ * display various audit information such as contrast ratio score.
+ */
+class Audit {
+  constructor(infobar) {
+    this.infobar = infobar;
+
+    // A list of audit reports to be shown on the fly when highlighting an accessible
+    // object.
+    this.reports = [
+      new ContrastRatio(this)
+    ];
+  }
+
+  get prefix() {
+    return this.infobar.prefix;
+  }
+
+  get win() {
+    return this.infobar.win;
+  }
+
+  buildMarkup(root) {
+    const audit = createNode(this.win, {
+      nodeType: "span",
+      parent: root,
+      attributes: {
+        "class": "infobar-audit",
+        "id": "infobar-audit",
+      },
+      prefix: this.prefix,
+    });
+
+    this.reports.forEach(report => report.buildMarkup(audit));
+  }
+
+  update(audit = {}) {
+    const el = this.getElement("infobar-audit");
+    el.setAttribute("hidden", true);
+
+    let updated = false;
+    this.reports.forEach(report => {
+      if (report.update(audit)) {
+        updated = true;
+      }
+    });
+
+    if (updated) {
+      el.removeAttribute("hidden");
+    }
+  }
+
+  getElement(id) {
+    return this.infobar.getElement(id);
+  }
+
+  setTextContent(el, text) {
+    return this.infobar.setTextContent(el, text);
+  }
+
+  destroy() {
+    this.infobar = null;
+    this.reports.forEach(report => report.destroy());
+    this.reports = null;
+  }
+}
+
+/**
+ * A common interface between audit report components used to render accessibility audit
+ * information for the currently highlighted accessible object.
+ */
+class AuditReport {
+  constructor(audit) {
+    this.audit = audit;
+  }
+
+  get prefix() {
+    return this.audit.prefix;
+  }
+
+  get win() {
+    return this.audit.win;
+  }
+
+  getElement(id) {
+    return this.audit.getElement(id);
+  }
+
+  setTextContent(el, text) {
+    return this.audit.setTextContent(el, text);
+  }
+
+  destroy() {
+    this.audit = null;
+  }
+}
+
+/**
+ * Contrast ratio audit report that is used to display contrast ratio score as part of the
+ * inforbar,
+ */
+class ContrastRatio extends AuditReport {
+  buildMarkup(root) {
+    createNode(this.win, {
+      nodeType: "span",
+      parent: root,
+      attributes: {
+        "class": "contrast-ratio",
+        "id": "contrast-ratio",
+      },
+      prefix: this.prefix,
+    });
+  }
+
+  /**
+   * Update contrast ratio score infobar markup.
+   * @param  {Number}
+   *         Contrast ratio for an accessible object being highlighted.
+   * @return {Boolean}
+   *         True if the contrast ratio markup was updated correctly and infobar audit
+   *         block should be visible.
+   */
+  update({ contrastRatio }) {
+    const el = this.getElement("contrast-ratio");
+    ["fail", "AA", "AAA"].forEach(style => el.classList.remove(style));
+
+    if (!contrastRatio) {
+      return false;
+    }
+
+    el.classList.add(getContrastRatioScoreStyle(contrastRatio));
+    this.setTextContent(el,
+      L10N.getFormatStr("accessibility.contrast.ratio", contrastRatio.ratio.toFixed(2)));
+    return true;
+  }
+}
+
+/**
  * A helper function that calculate accessible object bounds and positioning to
  * be used for highlighting.
  *
  * @param  {Object} win
  *         window that contains accessible object.
  * @param  {Object} options
  *         Object used for passing options:
  *         - {Number} x
@@ -405,12 +555,35 @@ function getBounds(win, { x, y, w, h, zo
   bottom *= zoomFactor;
 
   const width = right - left;
   const height = bottom - top;
 
   return { left, right, top, bottom, width, height };
 }
 
+/**
+ * Get contrast ratio score styling to be applied on the element that renders the contrast
+ * ratio.
+ * @param  {Number} options.ratio
+ *         Value of the contrast ratio for a given accessible object.
+ * @param  {Boolean} options.largeText
+ *         True if the accessible object contains large text.
+ * @return {String}
+ *         CSS class that represents the appropriate contrast ratio score styling.
+ */
+function getContrastRatioScoreStyle({ ratio, largeText }) {
+  const levels = largeText ? { AA: 3, AAA: 4.5 } : { AA: 4.5, AAA: 7 };
+
+  let style = "fail";
+  if (ratio >= levels.AAA) {
+    style = "AAA";
+  } else if (ratio >= levels.AA) {
+    style = "AA";
+  }
+
+  return style;
+}
+
 exports.MAX_STRING_LENGTH = MAX_STRING_LENGTH;
 exports.getBounds = getBounds;
 exports.Infobar = Infobar;
 exports.XULWindowInfobar = XULWindowInfobar;
--- a/devtools/server/actors/highlighters/xul-accessible.js
+++ b/devtools/server/actors/highlighters/xul-accessible.js
@@ -68,23 +68,42 @@ const ACCESSIBLE_BOUNDS_SHEET = "data:te
 
   .accessible-infobar-text {
     overflow: hidden;
     white-space: nowrap;
     display: flex;
     justify-content: center;
   }
 
-  .accessible-infobar-name {
-    color: rgb(221, 0, 169);
+  .accessible-infobar-name,
+  .accessible-infobar-audit {
+    color: hsl(210, 30%, 85%);
     max-width: 90%;
   }
 
-  .accessible-infobar-name:not(:empty) {
-    color: hsl(210, 30%, 85%);
+  .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AA:after,
+  .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AAA:after {
+    color: #90E274;
+  }
+
+  .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).fail:after {
+    color: #E57180;
+    content: " ⚠️";
+  }
+
+  .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AA:after {
+    content: " AA\u2713";
+  }
+
+  .accessible-infobar-audit .accessible-contrast-ratio:not(:empty).AAA:after {
+    content: " AAA\u2713";
+  }
+
+  .accessible-infobar-name:not(:empty),
+  .accessible-infobar-audit:not(:empty) {
     border-inline-start: 1px solid #5a6169;
     margin-inline-start: 6px;
     padding-inline-start: 6px;
   }
 
   .accessible-infobar-role {
     color: #9CDCFE;
   }`);
--- a/devtools/server/actors/inspector/node.js
+++ b/devtools/server/actors/inspector/node.js
@@ -5,18 +5,16 @@
 "use strict";
 
 const { Cu } = require("chrome");
 const Services = require("Services");
 const InspectorUtils = require("InspectorUtils");
 const protocol = require("devtools/shared/protocol");
 const { nodeSpec, nodeListSpec } = require("devtools/shared/specs/node");
 
-loader.lazyRequireGetter(this, "colorUtils", "devtools/shared/css/color", true);
-
 loader.lazyRequireGetter(this, "getCssPath", "devtools/shared/inspector/css-logic", true);
 loader.lazyRequireGetter(this, "getXPath", "devtools/shared/inspector/css-logic", true);
 loader.lazyRequireGetter(this, "findCssSelector", "devtools/shared/inspector/css-logic", true);
 
 loader.lazyRequireGetter(this, "isAfterPseudoElement", "devtools/shared/layout/utils", true);
 loader.lazyRequireGetter(this, "isAnonymous", "devtools/shared/layout/utils", true);
 loader.lazyRequireGetter(this, "isBeforePseudoElement", "devtools/shared/layout/utils", true);
 loader.lazyRequireGetter(this, "isDirectShadowHostChild", "devtools/shared/layout/utils", true);
@@ -681,36 +679,25 @@ const NodeActor = protocol.ActorClassWit
       fillStyle: fillStyle
     };
     const { dataURL, size } = getFontPreviewData(font, doc, options);
 
     return { data: LongStringActor(this.conn, dataURL), size: size };
   },
 
   /**
-   * Finds the computed background color of the closest parent with
-   * a set background color.
-   * Returns a string with the background color of the form
-   * rgba(r, g, b, a). Defaults to rgba(255, 255, 255, 1) if no
-   * background color is found.
+   * Finds the computed background color of the closest parent with a set background
+   * color.
+   *
+   * @return {String}
+   *         String with the background color of the form rgba(r, g, b, a). Defaults to
+   *         rgba(255, 255, 255, 1) if no background color is found.
    */
   getClosestBackgroundColor: function() {
-    let current = this.rawNode;
-    while (current) {
-      const computedStyle = CssLogic.getComputedStyle(current);
-      const currentStyle = computedStyle.getPropertyValue("background-color");
-      if (colorUtils.isValidCSSColor(currentStyle)) {
-        const currentCssColor = new colorUtils.CssColor(currentStyle);
-        if (!currentCssColor.isTransparent()) {
-          return currentCssColor.rgba;
-        }
-      }
-      current = current.parentNode;
-    }
-    return "rgba(255, 255, 255, 1)";
+    return InspectorActorUtils.getClosestBackgroundColor(this.rawNode);
   },
 
   /**
    * Returns an object with the width and height of the node's owner window.
    *
    * @return {Object}
    */
   getOwnerGlobalDimensions: function() {
--- a/devtools/server/actors/inspector/utils.js
+++ b/devtools/server/actors/inspector/utils.js
@@ -1,24 +1,27 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const {Cu} = require("chrome");
 
+loader.lazyRequireGetter(this, "colorUtils", "devtools/shared/css/color", true);
 loader.lazyRequireGetter(this, "AsyncUtils", "devtools/shared/async-utils");
 loader.lazyRequireGetter(this, "flags", "devtools/shared/flags");
 loader.lazyRequireGetter(this, "DevToolsUtils", "devtools/shared/DevToolsUtils");
 loader.lazyRequireGetter(this, "nodeFilterConstants", "devtools/shared/dom-node-filter-constants");
 
 loader.lazyRequireGetter(this, "isNativeAnonymous", "devtools/shared/layout/utils", true);
 loader.lazyRequireGetter(this, "isXBLAnonymous", "devtools/shared/layout/utils", true);
 
+loader.lazyRequireGetter(this, "CssLogic", "devtools/server/actors/inspector/css-logic", true);
+
 const XHTML_NS = "http://www.w3.org/1999/xhtml";
 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 const IMAGE_FETCHING_TIMEOUT = 500;
 
 /**
  * Returns the properly cased version of the node's tag name, which can be
  * used when displaying said name in the UI.
  *
@@ -240,16 +243,75 @@ const imageToImageData = async function(
     size: {
       naturalWidth: imgWidth,
       naturalHeight: imgHeight,
       resized: resizeRatio !== 1
     }
   };
 };
 
+/**
+ * Finds the computed background color of the closest parent with a set background color.
+ *
+ * @param  {DOMNode}  node
+ *         Node for which we want to find closest background color.
+ * @return {String}
+ *         String with the background color of the form rgba(r, g, b, a). Defaults to
+ *         rgba(255, 255, 255, 1) if no background color is found.
+ */
+function getClosestBackgroundColor(node) {
+  let current = node;
+
+  while (current) {
+    const computedStyle = CssLogic.getComputedStyle(current);
+    if (computedStyle) {
+      const currentStyle = computedStyle.getPropertyValue("background-color");
+      if (colorUtils.isValidCSSColor(currentStyle)) {
+        const currentCssColor = new colorUtils.CssColor(currentStyle);
+        if (!currentCssColor.isTransparent()) {
+          return currentCssColor.rgba;
+        }
+      }
+    }
+
+    current = current.parentNode;
+  }
+
+  return "rgba(255, 255, 255, 1)";
+}
+
+/**
+ * Finds the background image of the closest parent where it is set.
+ *
+ * @param  {DOMNode}  node
+ *         Node for which we want to find the background image.
+ * @return {String}
+ *         String with the value of the background iamge property. Defaults to "none" if
+ *         no background image is found.
+ */
+function getClosestBackgroundImage(node) {
+  let current = node;
+
+  while (current.ownerDocument) {
+    const computedStyle = CssLogic.getComputedStyle(current);
+    if (computedStyle) {
+      const currentBackgroundImage = computedStyle.getPropertyValue("background-image");
+      if (currentBackgroundImage !== "none") {
+        return currentBackgroundImage;
+      }
+    }
+
+    current = current.parentNode;
+  }
+
+  return "none";
+}
+
 module.exports = {
   allAnonymousContentTreeWalkerFilter,
+  getClosestBackgroundColor,
+  getClosestBackgroundImage,
   getNodeDisplayName,
   imageToImageData,
   isNodeDead,
   nodeDocument,
   standardTreeWalkerFilter,
 };
new file mode 100644
--- /dev/null
+++ b/devtools/server/actors/utils/accessibility.js
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+loader.lazyRequireGetter(this, "colorUtils", "devtools/shared/css/color", true);
+loader.lazyRequireGetter(this, "CssLogic", "devtools/server/actors/inspector/css-logic", true);
+loader.lazyRequireGetter(this, "InspectorActorUtils", "devtools/server/actors/inspector/utils");
+
+/**
+ * Calculates the contrast ratio of the referenced DOM node.
+ *
+ * @param  {DOMNode} node
+ *         The node for which we want to calculate the contrast ratio.
+ *
+ * @return {Number|null} Contrast ratio value.
+*/
+function getContrastRatioFor(node) {
+  const backgroundColor = InspectorActorUtils.getClosestBackgroundColor(node);
+  const backgroundImage = InspectorActorUtils.getClosestBackgroundImage(node);
+  const computedStyles = CssLogic.getComputedStyle(node);
+  if (!computedStyles) {
+    return null;
+  }
+
+  const { color, "font-size": fontSize, "font-weight": fontWeight } = computedStyles;
+  const isBoldText = parseInt(fontWeight, 10) >= 600;
+  const backgroundRgbaColor = new colorUtils.CssColor(backgroundColor, true);
+  const textRgbaColor = new colorUtils.CssColor(color, true);
+
+  // TODO: For cases where text color is transparent, it likely comes from the color of
+  // the background that is underneath it (commonly from background-clip: text property).
+  // With some additional investigation it might be possible to calculate the color
+  // contrast where the color of the background is used as text color and the color of
+  // the ancestor's background is used as its background.
+  if (textRgbaColor.isTransparent()) {
+    return null;
+  }
+
+  // TODO: these cases include handling gradient backgrounds and the actual image
+  // backgrounds. Each one needs to be handled individually.
+  if (backgroundImage !== "none") {
+    return null;
+  }
+
+  let { r: bgR, g: bgG, b: bgB, a: bgA} = backgroundRgbaColor.getRGBATuple();
+  let { r: textR, g: textG, b: textB, a: textA } = textRgbaColor.getRGBATuple();
+
+  // If the element has opacity in addition to text and background alpha values, take it
+  // into account.
+  const opacity = parseFloat(computedStyles.opacity);
+  if (opacity < 1) {
+    bgA = opacity * bgA;
+    textA = opacity * textA;
+  }
+
+  return {
+    ratio: colorUtils.calculateContrastRatio([ bgR, bgG, bgB, bgA ],
+                                             [ textR, textG, textB, textA ]),
+    largeText: Math.ceil(parseFloat(fontSize) * 72) / 96 >= (isBoldText ? 14 : 18)
+  };
+}
+
+exports.getContrastRatioFor = getContrastRatioFor;
--- a/devtools/server/actors/utils/moz.build
+++ b/devtools/server/actors/utils/moz.build
@@ -1,15 +1,16 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DevToolsModules(
+    'accessibility.js',
     'actor-registry-utils.js',
     'actor-registry.js',
     'audionodes.json',
     'automation-timeline.js',
     'breakpoint-actor-map.js',
     'call-watcher.js',
     'css-grid-utils.js',
     'event-loop.js',
--- a/devtools/shared/css/color.js
+++ b/devtools/shared/css/color.js
@@ -1158,24 +1158,52 @@ function calculateLuminance(rgba) {
     rgba[i] /= 255;
     rgba[i] = (rgba[i] < 0.03928) ? (rgba[i] / 12.92) :
                                     Math.pow(((rgba[i] + 0.055) / 1.055), 2.4);
   }
   return 0.2126 * rgba[0] + 0.7152 * rgba[1] + 0.0722 * rgba[2];
 }
 
 /**
+ * Blend background and foreground colors takign alpha into account.
+ * @param  {Array} foregroundColor
+ *         An array with [r,g,b,a] values containing the foreground color.
+ * @param  {Array} backgroundColor
+ *         An array with [r,g,b,a] values containing the background color. Defaults to
+ *         [ 255, 255, 255, 1 ].
+ * @return {Array}
+ *         An array with combined [r,g,b,a] colors.
+ */
+function blendColors(foregroundColor, backgroundColor = [ 255, 255, 255, 1 ]) {
+  const [ fgR, fgG, fgB, fgA ] = foregroundColor;
+  const [ bgR, bgG, bgB, bgA ] = backgroundColor;
+  if (fgA === 1) {
+    return foregroundColor;
+  }
+
+  return [
+    (1 - fgA) * bgR + fgA * fgR,
+    (1 - fgA) * bgG + fgA * fgG,
+    (1 - fgA) * bgB + fgA * fgB,
+    fgA + bgA * (1 - fgA)
+  ];
+}
+
+/**
  * Calculates the contrast ratio of 2 rgba tuples based on the formula in
  * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#visual-audio-contrast7
  *
  * @param {Array} backgroundColor An array with [r,g,b,a] values containing
  * the background color.
  * @param {Array} textColor An array with [r,g,b,a] values containing
  * the text color.
  * @return {Number} The calculated luminance.
  */
 function calculateContrastRatio(backgroundColor, textColor) {
+  backgroundColor = blendColors(backgroundColor);
+  textColor = blendColors(textColor, backgroundColor);
+
   const backgroundLuminance = calculateLuminance(backgroundColor);
   const textLuminance = calculateLuminance(textColor);
   const ratio = (textLuminance + 0.05) / (backgroundLuminance + 0.05);
 
   return (ratio > 1.0) ? ratio : (1 / ratio);
 }
new file mode 100644
--- /dev/null
+++ b/devtools/shared/locales/en-US/accessibility.properties
@@ -0,0 +1,8 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# LOCALIZATION NOTE (accessibility.contrast.ratio): A title text for the color contrast
+# ratio description, used by the accessibility highlighter to display the value. %S in the
+# content will be replaced by the contrast ratio numerical value.
+accessibility.contrast.ratio=Contrast: %S