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.
- 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. - 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. - 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 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.
