UP | HOME

Color themes

I've tried to fix some colors before (Random foreground color generation, Enhancing document viewer colors), and thought of generating whole themes from time to time (as mentioned in the web design checklist); recently decided to investigate it once again, and to figure how to generate color themes that respect user-provided settings, and maybe to translate existing themes into those.

1 Legibility

One of the primary concerns for me is legibility, which should be enforced in all the generated themes; rather often even manually composed ones do lack it. For the explanation of the formulae it uses, WCAG refers to a couple of standards (ISO and ANSI) hidden behind a paywall, so it's probably not the best way if you're trying to understand and to tweak those. NASA also has something to say on color graphics, and on legibility in particular; it mostly relies on the L* component of the CIE LAB color space.

2 Color spaces

CIE LAB/LUV provide an L* value, seemingly appropriate for our purposes, and there are color spaces such as HCL – somewhat similar to HSL, but also using CIE's L*. Though perhaps LAB is handy for most purposes, since it's also more or less perceptually uniform (though CIECAM02 is more advanced and modern), and allows to pick different enough colors in a relatively simple way.

3 Existing tools

iWantHue provides a bunch of useful links, including How To Avoid Equidistant HSV Colors, which has nice interactive illustsrations.

4 User settings and theme

A user may either define reference colors, such as background, foreground, and active element (link), or parameters that should be derived from those later – such as BG/FG luminance (L*) ranges, and perhaps preferred color and/or saturation ranges. A theme designer may provide values inside of those ranges, possibly counting from the middle: e.g., for luminance, with theme values of 0.5 and 0.0, and user-defined ranges of [0.0, 0.2] and [0.8, 1.0], the calculated values would be 0.1 and 0.8; but with user-defined ranges [0.8, 1.0] and [0.0, 0.2], they'll be 0.9 and 0.2. That is, a designer just defines how far away we are moving into a user-specified direction, without assuming light-on-dark or dark-on-light.

Perhaps the first stage should operate with user-provided ranges, and its wrapper may derive those from reference colors.

5 Ensuring contrast

The easiest way is to check the ratio just once, for the closest values of user-defined ranges, so that non-legible colors are not possible with those; or even to not check it at all: since both required minimal ratio and the values on which it depends are defined by a user, it doesn't make much sense, if we won't need that exact ratio elsewhere (though it could still be calculated, just to be shown to a user). Although it would impose seemingly unnecessary restrictions on ranges, tweaking them interactively would also require a user-specified contrast ratio, and would either lead to the same values where they should differ, or to scaling – which would be pretty much the same as the first option. So, the easiest way seems to also be the most appropriate one.

6 Preserving colors

We can preserve either hue (using HCL), or relative colors defined with A and B (using LAB). For design purposes, sometimes colors themselves are important, and sometimes just a difference between them is. LAB is a better option to set their difference.

7 Background and foreground

Light-on-dark or dark-on-light colors do become enforced by user settings in this system, which is nice, but sometimes inverted regions may be useful – for e.g. selection, or just as design elements. Perhaps the right thing to do here is to introduce an explicit "invert" flag into theme colors, swapping luminance ranges when set. Probably there's a better name for that.

8 Imaginary colors

Some of the LAB colors can't be perceived, making relative colors to be more tricky. Maybe it could be ignored at first, but should be elaborated later.

9 Variables

The primary variables to consider are user-defined luminance ranges (maybe together with color ranges), and designer/theme-defined colors in Lab (relative L, relative a, relative b) together with an "invert" flag.

10 Trying and applying

Any UI — as well as embeddable UI parts, such as code highlighters — could use that, but perhaps the easiest way to try is on emacs: color.el contains some handy functions already, and it's quite annoying when a emacs package assumes a dark-on-light theme while you are using a light-on-dark one. Though emacs already provides functionality to define alternatives for dark and light backgrounds, as well as for different terminals (color, grayscale, mono), different amounts of available colors, and so on (see (info "(elisp)Defining Faces")). Anyway, here's what I've got:

;;; -*- lexical-binding: t -*-

(defun slab-to-rgb (l a b)
  (apply 'color-rgb-to-hex
         (mapcar 'color-clamp
                 (color-lab-to-srgb (* l 100)
                                    (- (* a 200) 100)
                                    (- (* b 200) 100)))))

(defun scale (min max n &optional back)
  (let ((scaled (* n (- max min))))
    (if back (- max scaled) (+ min scaled))))

(defun scale* (min max &optional back)
  (lambda (n)
    (list (scale (car min) (car max) (car n) back)
          (scale (cadr min) (cadr max) (cadr n))
          (scale (caddr min) (caddr max) (caddr n)))))

(defun light-on-dark-p ()
  (< (car l-bg-min) (car l-fg-min)))

(defun lab (l a b &optional inv)  
  (apply 'slab-to-rgb
         (funcall (if inv
                      (scale* l-bg-min l-bg-max (light-on-dark-p))
                    (scale* l-fg-min l-fg-max (not (light-on-dark-p))))
                  `(,l ,a ,b))))

(defun labc (x &optional y b &rest r)
  (defun labfg (x y z &optional b)
    `(:foreground ,(lab x y z b)))
  (defun labbg (x y z &optional b)
    `(:background ,(lab x y z (not b))))
  (list (list '((class color) (min-colors 89))
              (append 
               (if x (apply 'labfg (append x (list b))) nil)
               (if y (apply 'labbg (append y (list b))) nil)
               r))))

;; light-on-dark
(setq l-bg-min '(.05 .48 .48)
      l-bg-max '( .1 .52 .52)
      l-fg-min '(.65   0   0)
      l-fg-max '(.95   1   1))

;; dark-on-light
(setq l-bg-min '( .9 .48 .48)
      l-bg-max '(.95 .52 .52)
      l-fg-min '(.05   0   0)
      l-fg-max '(.35   1   1))


(deftheme test
  "test theme")

(let ((class '((class color) (min-colors 89))))
  (custom-theme-set-faces
   'test
   ;; text appearance
   `(default                      ,(labc '( .7  .5  .5) '( .5  .5  .5)))
   ;; text parts, temporary
   `(region                       ,(labc '(  1  .5  .5) '(  0  .5 .55) t))
   ;; parts of the Emacs frame
   `(isearch                      ,(labc '(  1  .5  .5) '(  1  .5   1) t))
   `(query-replace                ,(labc '(  1  .5  .5) '(  1  .6   1) t))
   `(lazy-highlight               ,(labc '(  1  .5  .5) '(  0  .5   1) t))
   `(mode-line                    ,(labc '( .5  .5 .55) '( .8  .5  .5)))
   `(mode-line-inactive           ,(labc '(  0  .5 .51) '(  0  .5  .5)))
   `(fringe                       ,(labc '( .2  .5 .55) '( .5  .5  .5)))
   `(vertical-border              ,(labc '(  0  .5  .5) nil            t))
   `(minibuffer-prompt            ,(labc '( .2  .5 .35)))
   `(cursor                       ,(labc nil            '(  0  .6  .6) t))
   ;; font lock faces
   `(font-lock-comment-face       ,(labc '(  0  .5  .4)))
   `(font-lock-string-face        ,(labc '( .3  .6  .6)))
   `(font-lock-doc-face           ,(labc '( .5  .4 .65)))
   `(font-lock-keyword-face       ,(labc '( .2  .5  .6)))
   `(font-lock-function-name-face ,(labc '( .6 .45 .65)))
   `(font-lock-constant-face      ,(labc '( .5  .4  .5)))
   `(font-lock-variable-name-face ,(labc '( .7  .4  .6)))
   `(font-lock-builtin-face       ,(labc '(  0  .5  .5)))
   ;; faces.el
   `(link                         ,(labc '(  1  .5  .1) nil            nil :underline t))
   `(link-visited                 ,(labc '(  1   1   0) nil            nil :underline t))
   `(highlight                    ,(labc '( .5  .5  .5) '( .5  .5  .3) t   :underline t))
   ))

And here is how it looks: dark on light, light on dark.

11 Altering existing themes

Though it works for emacs, it's not particularly useful there, because emacs is awesome and provides the means to define alternatives – at least manually. On the other hand, there is web, with terrible and ever-changing CSS; that's where color alteration should be really useful. An approximate algorithm:

  1. Find all the colors, convert them into CIE LAB.
  2. Detect light-on-dark and dark-on-light ones; check the body colors, set the "invert" flag everywhere accordingly.
  3. Generate new colors with respect to user settings, and apply them.

But I'm sleepy and not familiar with FF extensions writing, so postponing it.

Update: wrote a userscript, though it's not that nice – doesn't handle dynamically changed elements, and doesn't even show visited links. But illustrates that existing themes can be converted nicely:

// ==UserScript==
// @name        Test
// @include     *
// @grant       none
// @require     https://raw.githubusercontent.com/gka/chroma.js/master/chroma.min.js
// ==/UserScript==

var defaultBG = chroma("#000");
var defaultFG = chroma("#FFF");

var forceDefaults = true;

var bgMin = 0.05;
var bgMax = 0.10;
var fgMin = 0.75;
var fgMax = 0.95;
var bodyBG, bodyFG, origBG, origFG;


function setLum(color, min, max, back) {
    // the standard `luminance`, even with the `lab` parameter, does
    // something weird. for both reading and setting, so avoiding it.
    var scaled = color.lab()[0] / 100 * (max - min);
    if (scaled < 0) { scaled = 0 };
    if (scaled > 1) { scaled = 1 };
    var l = color.lab();
    l[0] = 100 * (back ? (max - scaled) : (min + scaled));
    return chroma(l, 'lab');
}

function adjustColors(bg, fg) {
    var inv = (fg.lab()[0] > bg.lab()[0]) != (origFG.lab()[0] > origBG.lab()[0]);
    var bodyInv = (bodyFG.lab()[0] > bodyBG.lab()[0]) != (origFG.lab()[0] > origBG.lab()[0]);
    inv = (fg.lab()[0] > bg.lab()[0]) != (bodyFG.lab()[0] > bodyBG.lab()[0]);
    var invert = inv ^ bodyInv;

    var bgr, fgr;
    if (invert) {
        bgr = setLum(bg, fgMin, fgMax, true);
        fgr = setLum(fg, bgMin, bgMax, false);
    } else {
        bgr = setLum(bg, bgMin, bgMax, false);
        fgr = setLum(fg, fgMin, fgMax, true);
    }

    return [bgr, fgr];
}


// this runs once CSS is loaded
window.onload = function () {
    var body = document.body;
    body.style.color = "";
    body.style.backgroundColor = "";
    var bodyStyle = window.getComputedStyle(body);
    origBG = chroma(bodyStyle.backgroundColor == "transparent"
                    ? "#FFF"
                    : bodyStyle.backgroundColor);
    origFG = chroma(bodyStyle.color);
    if (forceDefaults) {
        bodyBG = defaultBG;
        bodyFG = defaultFG;
    } else {
        bodyBG = origBG;
        bodyFG = origFG;
    }
    go(body, bodyBG);
}


// flashing prevention
function init() {
    var body = document.body;
    var bodyStyle = window.getComputedStyle(body);
    origBG = chroma(bodyStyle.backgroundColor == "transparent"
                    ? defaultBG
                    : bodyStyle.backgroundColor);
    origFG = chroma(bodyStyle.color);
    if (forceDefaults) {
        bodyBG = defaultBG;
        bodyFG = defaultFG;
    } else {
        bodyBG = origBG;
        bodyFG = origFG;
    }
    go(body, bodyBG);
}
if (document.addEventListener) {
    document.addEventListener("DOMContentLoaded", init, false);
}


function go(elem, prevBG){
    elem.style.backgroundColor = "";
    elem.style.color = "";
    var style = window.getComputedStyle(elem);
    if (style.display == "none") {
        return;
    }
    var bg = chroma(style.backgroundColor == "transparent"
                    ? prevBG
                    : style.backgroundColor);
    var fg = chroma(style.color);
    var clr = adjustColors(bg, fg);

    var childNodes = elem.childNodes;
    for(var i=0; i < childNodes.length; i++) {
        var child = childNodes[i];
        if (child instanceof Element) {
            go(child, bg);
        }
    }

    if (elem.className == "mwe-math-fallback-image-inline tex") {
        // for wikipedia formulæ
        elem.style.backgroundColor = "white";
    } else if (elem.tagName != "IMG") {
        // excluding images, since an appropriate background may be
        // set for transparent ones
        elem.style.backgroundColor = clr[0].css();
        elem.style.color = clr[1].css();
        elem.style.backgroundImage = "none";
    }
}

Examples: 1, 2, 3, 4.

12 Next

Elaborating that userscript might be a worthwhile thing to do, though not quite sure how to fix the mentioned issues.

Sometimes I think of writing a basic web browser, something similar to textual ones, just with a better UI.