DEV Community

decksmith
decksmith

Posted on AI-assisted

Which CSS variable made this color? Ask the browser three times instead of guessing from the value

One of my color themes sets three CSS variables to the exact same value: --ink, --accent and the inverse background are all #1F2BE0. That was fine for rendering. It stopped being fine when I needed to go from a computed color back to the variable it came from.

The situation: I measure a page once, in a browser, and store the result. Later, a user swaps in their own palette, and I need to repaint the stored result without a browser (the code that does it runs on a server with no headless browser). getComputedStyle only gives you rgb(31, 43, 224). The variable name is gone.

I considered three ways to get it back.

  1. Look the value up in the theme's variable table. With my cobalt theme that returns three candidates for every blue thing on the page, so it's a coin flip. It also can't see through color-mix(), because a mixed color matches nothing in the table.
  2. Walk the CSS declarations and read which var() each element uses. That means reimplementing the cascade, inheritance, color-mix() and gradients. I decided I'd rather not own a second CSS engine.
  3. Measure again with every variable set to a different marker color, and see which elements move with which marker.

I went with the third one. A minimal version of it:

const vars = ['--ink', '--accent', '--bg-inverse', '--paper'];
const markerSets = [
  ['hsl(7 72% 44%)',   'hsl(97 72% 44%)',  'hsl(187 72% 44%)', 'hsl(277 72% 44%)'],
  ['hsl(151 58% 62%)', 'hsl(241 58% 62%)', 'hsl(331 58% 62%)', 'hsl(61 58% 62%)'],
];
// run 1: theme as is. Runs 2 and 3: inject :root{ --ink: marker; ... } after the theme
// then read getComputedStyle for every element and for every variable
Enter fullscreen mode Exit fullscreen mode

I ran this against a four-element page in Chrome 156: a heading using --ink, two boxes using --accent and --bg-inverse, and a box using color-mix(in srgb, var(--accent) 30%, var(--paper)). The first three all compute to rgb(31, 43, 224) in the untouched run, so a value lookup matches three variables for each of them. With markers, the heading follows --ink in both marked runs, the first box follows --accent, the second follows --bg-inverse. Each one is unambiguous after two extra runs.

The mixed box matches no variable in any run, which is the signal that it's a mix. Solving each channel against the two marker values gave a weight of 0.296 to 0.302 for --accent and the rest for --paper, which is the 30% I wrote in the CSS.

Two details that mattered:

  • Use two marker sets rather than one, so a coincidence in one run has to repeat in the next. My real code accepts an assignment only if it fits the observed colors in all three runs.
  • If the theme sets a variable to transparent, give that variable a transparent marker. An opaque marker makes a previously invisible box visible, the page changes shape, and the runs no longer line up element by element.

What I haven't handled: colors that aren't driven by a variable at all (an image, a hard-coded hex) simply never move, and I treat them as constants. I haven't tested anything outside Chromium.

Has anyone solved this without a browser, for example by parsing the stylesheet with something like postcss and resolving var() and color-mix() yourself? I'm curious how far that gets before the cascade bites.

This post was written with AI assistance. The claims about the code were checked against the project source and a small test run in Chrome.

Top comments (0)