Consolidating Colors In a Legacy Web Project
Tags: css, color, a11y, postcss
Published: 2020-10-31 19:09:03


## Introduction

When you're developing a new feature, how do you know what color to use? What about when you have 300 unique colors distributed as 1,000 hex codes in your `.scss` files? How do you ensure your colors are used consistently, or have enough contrast to be accessible? Even if a designer gives you a Figma file with clearly defined colors, it might not match other colors on the same page - it might even bring you to 301 unique colors. 

In my experience, designers and developers picking individual colors has been an unsustainable strategy. In order to maintain consistency, accessibility, and development speed, we need to limit colors to a predefined set and provide meaning to them beyond a hex code.

This article summarizes how I built and used [dcwither/scss-codemods](https://github.com/dcwither/scss-codemods) to reduce a codebase's approximately 1,000 hex codes to a much more maintainable 25.

## Mitigation Strategy

Often designers tire of inconsistent colors before most engineers and will work on a design library to improve brand consistency, starting with colors, spacing, typography, etc. Once designers start this, the best move for engineers is to find and codify a common naming scheme that can be used between engineers and designers. Thus [**design tokens**](https://css-tricks.com/what-are-design-tokens/) are born.

For the purpose of this article, we'll use Bootstrap's design tokens found in [scss/_variables.scss](https://github.com/twbs/bootstrap/blob/main/scss/_variables.scss).

### Future Proofing

Once we have design tokens, we should make sure that no new colors are added to the codebase. We can protect against this using a lint rule.

Although it's not a perfect match, stylelint's [`color-no-hex`](https://stylelint.io/user-guide/rules/color-no-hex) works pretty well to flag this. The rule warns whenever a hex color is used, making design tokens the path of least resistance. We can pair this with a code review tool like [reviewdog](https://github.com/reviewdog/reviewdog) that only applies lint rules to new changes.

## Consolidating

Adopting tokens, however, does not fix all the accumulated debt. We still have 1,000 hex codes throughout the codebase, many of which will be redundant with the design token values.

### Replacing Exact Matches

The exact matches can be fixed with Find and Replace, but with 40 color tokens in our case, it becomes worth it to write a script. For this, I wrote a postcss plugin to make the job a little cleaner for future changes.

```js
module.exports = (opts) => {
  function mapColorsInString(str) {
    return str.replace(
      /#[0-9a-f][0-9a-f][0-9a-f]([0-9a-f][0-9a-f][0-9a-f])?/gi,
      (match) => {
        const mapping = opts.config.find(({ hex }) => {
          return hex.toLowerCase() === match.toLowerCase();
        });
        if (mapping) {
          return mapping.name;
        }
        return match;
      }
    );
  }

  return {
    postcssPlugin: "hex-to-tokens",
    Declaration(decl) {
      decl.value = mapColorsInString(decl.value);
    },
  };
};
```

where `config` represents our design tokens in the following format:

```js
[
  {
    hex: "#ffffff",
    name: "$white"
  },
  {

    hex: "#f8f9fa",
    name: "$gray-100"
  },
  ...
]
```

This works well for projects that consistently use the same hex values throughout, but falls short when developers have used colors inconsistently. In order to solve that, we'll use the Delta E algorithm to map colors to their closest design token.

### ΔE* (Delta E) Algorithm

Delta E is an algorithm that takes two colors and produces a single value that represents the **distance** between them. We'll use Delta E to find the closest design token to the hex colors. We only consider a match and replace it if the distance is within a desired threshold.

> Delta E estimates how humans perceive the difference between two colors rather than a Euclidean distance on the RGB spectrum. It adjusts the "distance" depending on how sensitive human perception is to that part of the color spectrum. You can learn more from the [delta-e package documentation](http://zschuessler.github.io/DeltaE/learn/).

```js
import convert from "color-convert";
import DeltaE from "delta-e";

// DeltaE works in the CIELAB color space, so we use 
// color-convert to get a CIELAB color from our RGB values
function hexToLab(hexColor) {
  const [L, A, B] = convert.hex.lab.raw(hexColor.substr(1));
  return { L, A, B };
}
module.exports (opts) => {
  opts = {
    // default values
    threshold: 0,
    ...opts,
  };

  const mappings = opts.config.map((mapping) => ({
    ...mapping,
    lab: hexToLab(mapping.hex),
  }));

  function mapHexColorToBestMatch(hexColor) {
    const labColor = hexToLab(hexColor);
    let lowestDeltaE = 100;
    let bestMatch = null;
    for (const mapping of mappings) {
      const deltaE = DeltaE.getDeltaE00(mapping.lab, labColor);
      if (lowestDeltaE > deltaE) {
        lowestDeltaE = deltaE;
        bestMatch = mapping;
      }
    }

    if (lowestDeltaE <= opts.threshold) {
      return bestMatch.name;
    } else {
      return hexColor;
    }
  }

  function mapColorsInString(str) {
    return str.replace(
      /#[0-9a-f][0-9a-f][0-9a-f]([0-9a-f][0-9a-f][0-9a-f])?/gi,
      (match) => {
        return mapHexColorToBestMatch(match);
      }
    );
  }

  return {
    postcssPlugin: "hex-to-tokens",
    Declaration(decl) {
      decl.value = mapColorsInString(decl.value);
    },
  };
};
```

We can run this on our codebase with a desired threshold to clean up all the really close colors. I chose a threshold of 2, mapping to changes that require close inspection to spot. We went from about 1,000 hex codes down to about 200 that were not close enough to any design tokens.

> You might want to make exceptions for some colors where even subtle differences can be easily perceived (e.g. `#FFF` for white).

### Manual Migration

The last step for this migration was to generate color recommendations for a product designer and engineer to review. I repeated the Delta E process but logged the closest three colors instead of replacing any.

I copied the output to a Google spreadsheet and used a script to set background colors on cells containing hex codes. The designer picked the best recommendations from the spreadsheet and added other colors where the recommendations didn't make sense.

This final step brought us from about 200 hex codes to only 25 colors outside of the design tokens. We'll need additional design efforts to replace those particular colors. 

## Conclusion

Throughout this project we've:

1. Introduced design tokens
2. Added lint rules to prevent new hex colors
3. Performed an automated migration using Delta E and postcss
4. Manually migrated the rest of our colors using recommendations based on Delta E

We're now in a great state to move forward with future feature development, confident that our colors match what designers expect, and better able to ensure our color contrast meets [WCAG AA accessibility standards](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html).

If you're interested in doing the same kind of migration, check out [dcwither/scss-codemods](https://github.com/dcwither/scss-codemods). The `hex-to-token` command performs the automated Delta E transform on your entire codebase. The manual migration is not yet supported.

{% github dcwither/scss-codemods %}