Bug 1474110 - Choose browserAction text color among white and black, maximizing contrast draft
authorOriol Brufau <oriol-bugzilla@hotmail.com>
Sun, 08 Jul 2018 02:15:27 +0200
changeset 822639 83b641206ff9df825425469ca2d715b0e5023397
parent 821151 6eec814dea789707d04f42bbb01097d44532e2a9
push id117419
push userbmo:oriol-bugzilla@hotmail.com
push dateWed, 25 Jul 2018 15:20:45 +0000
bugs1474110
milestone63.0a1
Bug 1474110 - Choose browserAction text color among white and black, maximizing contrast MozReview-Commit-ID: CykmiUc0BsO
browser/components/extensions/parent/ext-browserAction.js
browser/components/extensions/test/browser/browser_ext_browserAction_context.js
--- a/browser/components/extensions/parent/ext-browserAction.js
+++ b/browser/components/extensions/parent/ext-browserAction.js
@@ -67,17 +67,18 @@ this.browserAction = class extends Exten
     this.eventQueue = [];
 
     this.tabManager = extension.tabManager;
 
     this.defaults = {
       enabled: true,
       title: options.default_title || extension.name,
       badgeText: "",
-      badgeBackgroundColor: null,
+      badgeBackgroundColor: [0xd9, 0, 0, 255],
+      badgeDefaultColor: [255, 255, 255, 255],
       badgeTextColor: null,
       popup: options.default_popup || "",
       area: browserAreas[options.default_area || "navbar"],
     };
     this.globals = Object.create(this.defaults);
 
     this.browserStyle = options.browser_style;
 
@@ -437,31 +438,21 @@ this.browserAction = class extends Exten
       }
 
       if (tabData.enabled) {
         node.removeAttribute("disabled");
       } else {
         node.setAttribute("disabled", "true");
       }
 
-      let {badgeBackgroundColor, badgeTextColor} = tabData;
-      let badgeStyle = [];
-      if (badgeBackgroundColor) {
-        let [r, g, b, a] = badgeBackgroundColor;
-        badgeStyle.push(`background-color: rgba(${r}, ${g}, ${b}, ${a / 255})`);
-      }
-      if (badgeTextColor) {
-        let [r, g, b, a] = badgeTextColor;
-        badgeStyle.push(`color: rgba(${r}, ${g}, ${b}, ${a / 255})`);
-      }
-      if (badgeStyle.length) {
-        node.setAttribute("badgeStyle", badgeStyle.join("; "));
-      } else {
-        node.removeAttribute("badgeStyle");
-      }
+      let serializeColor = ([r, g, b, a]) => `rgba(${r}, ${g}, ${b}, ${a / 255})`;
+      node.setAttribute("badgeStyle", [
+        `background-color: ${serializeColor(tabData.badgeBackgroundColor)}`,
+        `color: ${serializeColor(this.getTextColor(tabData))}`,
+      ].join("; "));
 
       let style = this.iconData.get(tabData.icon);
       node.setAttribute("style", style);
     };
     if (sync) {
       callback();
     } else {
       node.ownerGlobal.requestAnimationFrame(callback);
@@ -564,30 +555,80 @@ this.browserAction = class extends Exten
   }
 
   /**
    * Set a global, window specific or tab specific property.
    *
    * @param {Object} details
    *        An object with optional `tabId` or `windowId` properties.
    * @param {string} prop
-   *        String property to set. Should should be one of "icon", "title", "badgeText"
+   *        String property to set. Should should be one of "icon", "title", "badgeText",
    *        "popup", "badgeBackgroundColor", "badgeTextColor" or "enabled".
    * @param {string} value
    *        Value for prop.
+   * @returns {Object}
+   *        The object to which the property has been set.
    */
   setProperty(details, prop, value) {
     let {target, values} = this.getContextData(details);
     if (value === null) {
       delete values[prop];
     } else {
       values[prop] = value;
     }
 
     this.updateOnChange(target);
+    return values;
+  }
+
+  /**
+   * Determines the text badge color to be used in a tab, window, or globally.
+   *
+   * @param {Object} values
+   *        The values associated with the tab or window, or global values.
+   * @returns {ColorArray}
+   */
+  getTextColor(values) {
+    // If a text color has been explicitly provided, use it.
+    let {badgeTextColor} = values;
+    if (badgeTextColor) {
+      return badgeTextColor;
+    }
+
+    // Otherwise, check if the default color to be used has been cached previously.
+    let {badgeDefaultColor} = values;
+    if (badgeDefaultColor) {
+      return badgeDefaultColor;
+    }
+
+    // Choose a color among white and black, maximizing contrast with background
+    // according to https://www.w3.org/TR/WCAG20-TECHS/G18.html#G18-procedure
+    let [r, g, b] = values.badgeBackgroundColor.slice(0, 3).map(function(channel) {
+      channel /= 255;
+      if (channel <= 0.03928) {
+        return channel / 12.92;
+      }
+      return ((channel + 0.055) / 1.055) ** 2.4;
+    });
+    let lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+
+    // The luminance is 0 for black, 1 for white, and `lum` for the background color.
+    // Since `0 <= lum`, the contrast ratio for black is `c0 = (lum + 0.05) / 0.05`.
+    // Since `lum <= 1`, the contrast ratio for white is `c1 = 1.05 / (lum + 0.05)`.
+    // We want to maximize contrast, so black is chosen if `c1 < c0`, that is, if
+    // `1.05 * 0.05 < (L + 0.05) ** 2`. Otherwise white is chosen.
+    let channel = 1.05 * 0.05 < (lum + 0.05) ** 2 ? 0 : 255;
+    let result = [channel, channel, channel, 255];
+
+    // Cache the result as high as possible in the prototype chain
+    while (!Object.getOwnPropertyDescriptor(values, "badgeDefaultColor")) {
+      values = Object.getPrototypeOf(values);
+    }
+    values.badgeDefaultColor = result;
+    return result;
   }
 
   /**
    * Retrieve the value of a global, window specific or tab specific property.
    *
    * @param {Object} details
    *        An object with optional `tabId` or `windowId` properties.
    * @param {string} prop
@@ -687,32 +728,38 @@ this.browserAction = class extends Exten
         },
 
         getPopup: function(details) {
           return browserAction.getProperty(details, "popup");
         },
 
         setBadgeBackgroundColor: function(details) {
           let color = parseColor(details.color, "background");
-          browserAction.setProperty(details, "badgeBackgroundColor", color);
+          let values = browserAction.setProperty(details, "badgeBackgroundColor", color);
+          if (color === null) {
+            // Let the default text color inherit after removing background color
+            delete values.badgeDefaultColor;
+          } else {
+            // Invalidate a cached default color calculated with the old background
+            values.badgeDefaultColor = null;
+          }
         },
 
         getBadgeBackgroundColor: function(details, callback) {
-          let color = browserAction.getProperty(details, "badgeBackgroundColor");
-          return color || [0xd9, 0, 0, 255];
+          return browserAction.getProperty(details, "badgeBackgroundColor");
         },
 
         setBadgeTextColor: function(details) {
           let color = parseColor(details.color, "text");
           browserAction.setProperty(details, "badgeTextColor", color);
         },
 
-        getBadgeTextColor: function(details, callback) {
-          let color = browserAction.getProperty(details, "badgeTextColor");
-          return color || [255, 255, 255, 255];
+        getBadgeTextColor: function(details) {
+          let {values} = browserAction.getContextData(details);
+          return browserAction.getTextColor(values);
         },
 
         openPopup: function() {
           let window = windowTracker.topWindow;
           browserAction.triggerAction(window);
         },
       },
     };
--- a/browser/components/extensions/test/browser/browser_ext_browserAction_context.js
+++ b/browser/components/extensions/test/browser/browser_ext_browserAction_context.js
@@ -755,16 +755,99 @@ add_task(async function testMultipleWind
 
           expect(null, details[1], null, details[0]);
         },
       ];
     },
   });
 });
 
+add_task(async function testDefaultBadgeTextColor() {
+  await runTests({
+    manifest: {
+      "browser_action": {
+        "default_icon": "default.png",
+        "default_popup": "default.html",
+        "default_title": "Default Title",
+      },
+    },
+
+    "files": {
+      "default.png": imageBuffer,
+      "window1.png": imageBuffer,
+      "window2.png": imageBuffer,
+    },
+
+    getTests: function(tabs, windows) {
+      let details = [
+        {"icon": browser.runtime.getURL("default.png"),
+         "popup": browser.runtime.getURL("default.html"),
+         "title": "Default Title",
+         "badge": "",
+         "badgeBackgroundColor": [0xd9, 0x00, 0x00, 0xFF],
+         "badgeTextColor": [0xff, 0xff, 0xff, 0xff],
+         "enabled": true},
+        {"badgeBackgroundColor": [0xff, 0xff, 0x00, 0xFF],
+         "badgeTextColor": [0x00, 0x00, 0x00, 0xff]},
+        {"badgeBackgroundColor": [0x00, 0x00, 0xff, 0xFF],
+         "badgeTextColor": [0xff, 0xff, 0xff, 0xff]},
+        {"badgeBackgroundColor": [0xff, 0xff, 0xff, 0x00],
+         "badgeTextColor": [0x00, 0x00, 0x00, 0xff]},
+        {"badgeBackgroundColor": [0x00, 0x00, 0xff, 0xFF],
+         "badgeTextColor": [0xff, 0x00, 0xff, 0xff]},
+        {"badgeBackgroundColor": [0xff, 0xff, 0xff, 0x00]},
+        {"badgeBackgroundColor": [0x00, 0x00, 0x00, 0x00],
+         "badgeTextColor": [0xff, 0xff, 0xff, 0xff]},
+      ];
+
+      return [
+        async expect => {
+          browser.test.log("Initial state, expect default properties.");
+          expect(null, null, null, details[0]);
+        },
+        async expect => {
+          browser.test.log("Set a global light bgcolor, expect black text.");
+          browser.browserAction.setBadgeBackgroundColor({color: "#ff0"});
+          expect(null, null, details[1], details[0]);
+        },
+        async expect => {
+          browser.test.log("Set a window-specific dark bgcolor, expect white text.");
+          let windowId = windows[0];
+          browser.browserAction.setBadgeBackgroundColor({windowId, color: "#00f"});
+          expect(null, details[2], details[1], details[0]);
+        },
+        async expect => {
+          browser.test.log("Set a tab-specific transparent-white bgcolor, expect black text.");
+          let tabId = tabs[0];
+          browser.browserAction.setBadgeBackgroundColor({tabId, color: "#fff0"});
+          expect(details[3], details[2], details[1], details[0]);
+        },
+        async expect => {
+          browser.test.log("Set a window-specific text color, expect it in the tab.");
+          let windowId = windows[0];
+          browser.browserAction.setBadgeTextColor({windowId, color: "#f0f"});
+          expect(details[5], details[4], details[1], details[0]);
+        },
+        async expect => {
+          browser.test.log("Remove the window-specific text color, expect black again.");
+          let windowId = windows[0];
+          browser.browserAction.setBadgeTextColor({windowId, color: null});
+          expect(details[3], details[2], details[1], details[0]);
+        },
+        async expect => {
+          browser.test.log("Set a tab-specific transparent-black bgcolor, expect white text.");
+          let tabId = tabs[0];
+          browser.browserAction.setBadgeBackgroundColor({tabId, color: "#0000"});
+          expect(details[6], details[2], details[1], details[0]);
+        },
+      ];
+    },
+  });
+});
+
 add_task(async function testNavigationClearsData() {
   let url = "http://example.com/";
   let default_title = "Default title";
   let tab_title = "Tab title";
 
   let {Management: {global: {tabTracker}}} = ChromeUtils.import("resource://gre/modules/Extension.jsm", {});
   let extension, tabs = [];
   async function addTab(...args) {