Open the theme picker, choose Flatland from the base16 list, and the cards on the page lose their edges. Text and links are fine, but every border and raised surface has melted into the background. Flatland's base01 and base02 are both 1c1d19, and its background is 1c1e20. The contrast between them is 1.01 to 1, about what you'd get from two colours nobody can tell apart.
Flatland was just the one I noticed. This site has 18 hand-made themes and 368 schemes from the tinted-theming catalogue. Once I wrote down what a theme has to do and measured all of them, 16 of the 18 and all 368 of the schemes failed at least one rule. Fixing them by eye was never going to last, since the next scheme added to the catalogue would bring problems of its own.
So the rules became code, and next to them sits an engine that repairs any palette that breaks them, moving each colour as little as it can. Once it worked on existing themes, I gave it colours nobody had arranged at all.1
What a theme has to do
Every page on this site draws from the same small set of colour tokens: a background and a foreground, two surfaces (accent for raised cards and muted for quieter strips), a border, secondary text in muted-foreground, a gray, seven named accents from red to purple, and brand, the colour interactive things take. Components often use these at partial opacity, border-border/50 or bg-muted/20, so a colour that's a little too close to the background turns into a border you can't see.
The contract is a function, harmonyIssues, that takes those tokens and returns every rule they break. The thresholds are WCAG contrast ratios:
| Rule | Threshold |
|---|---|
| Body text on the background and on both surfaces | at least 4.5 |
| Secondary text on the background | at least 4.5 |
| Secondary text on the muted surface | at least 3 |
| Body text stronger than secondary text | at least 1.4 times its contrast |
| Raised surface against the background | 1.06 to 1.6 |
| Muted surface against the background | 1.15 to 2.4, and a step past the raised one |
| Border against the background | 1.55 to 3.4 |
| Each accent and the gray on the background | at least 3 |
| Each accent and the gray on the raised surface | at least 2.4 |
| Text on a primary button, focus ring | 4.5 and 3 |
Two of those rules go beyond contrast numbers. Surfaces and borders have upper limits as well as lower ones, because a card as loud as the text stops looking like a card. And surfaces and borders have to sit on the foreground's side of the background. In a dark theme the background is the darkest thing on the page and every layer on top of it gets a little lighter, and in a light theme it's the other way round.2
Two more rules, about the accents, came later. They're after the repairs.
Why the engine works in OKLCH
The site stores colours as HSL, which is a bad space to change a colour in, because its lightness isn't the lightness you see. hsl(60 100% 50%) is a yellow and hsl(240 100% 50%) is a blue, both at 50% lightness, and on a near-black background the yellow comes out at 16.29 to 1 while the blue is at 2.04 to 1. Raise the lightness of a hue in HSL and its apparent hue and saturation drift too.
OKLCH splits a colour into lightness, chroma and hue in a way that follows what people see. The engine converts to OKLCH, changes one of the three, and converts back. When a lightness change pushes a colour out of the sRGB gamut, it lowers the chroma until the colour fits, so the hue stays where it was.3
Repairs, in order
The repairs run in the order the rules depend on each other. Surfaces are placed against the text, and borders against the surfaces. Accents come last, because they have to read on everything else.
Text is first. If the foreground is under 4.5 against the background, it gets mixed toward white on a dark theme or black on a light one, and a binary search finds the smallest mix that clears the rule.
Surfaces are next. When one is out of range or on the wrong side, the engine rebuilds it from the background, keeping the background's hue and chroma and moving only the lightness until it hits a target: 1.15 for the raised surface, 1.9 for the border. That way a tinted theme keeps its tint. Mixing toward white or gray would have washed it out.
function surfaceAt(bg: Rgb, fg: Rgb, target: number): Rgb {
const [L, C, h] = toOklch(bg);
const step = luminance(fg) > luminance(bg) ? 0.002 : -0.002;
let candidate = bg;
for (let n = 1; n <= 500; n++) {
const next = Math.min(1, Math.max(0, L + step * n));
candidate = fromOklch(next, C, h);
if (contrast(candidate, bg) >= target || next === 0 || next === 1) break;
}
return candidate;
}
The step's direction comes from the foreground, which is the layering rule written as code. A theme with light text gets lighter surfaces, whatever the palette said.
When the text is too close to a surface, the surface moves back toward the background first, as far as its own minimum lets it. The text only moves if that's not enough. Most theme authors picked their text colour carefully, and the surface is easier to give up.
Secondary text gets pulled two ways. It has to read, at 4.5 on the background, and it has to stay clearly weaker than body text. When both can't hold, the secondary text dims toward the background first, and body text only brightens if dimming would make the secondary text unreadable.
Accents come last. Each keeps its hue and chroma and moves in lightness, 0.005 at a time, until it reads on both the background and the raised surface.
function accentOn(colour: Rgb, surfaces: [Rgb, number][], away: Rgb): Rgb {
const holds = (c: Rgb) => surfaces.every(([s, min]) => contrast(c, s) >= min);
if (holds(colour)) return colour;
const [L, C, h] = toOklch(colour);
const step = toOklch(away)[0] > L ? 0.005 : -0.005;
for (let n = 1; n <= 200; n++) {
const candidate = fromOklch(Math.min(1, Math.max(0, L + step * n)), C, h);
if (holds(candidate)) return candidate;
}
return away;
}
Every repair starts with the same check, and a colour that already holds comes back untouched. A test runs the engine over every hand-made theme and fails if any token changes, so a theme that follows the rules stays exactly as its author wrote it.
Rounding broke five schemes
The first version passed every scheme in my scripts and failed five in the test, and the difference was rounding. Tokens are stored as H S% L% strings, and the test measured what would actually be painted. Values sitting right on a limit were kept, then rounded for storage, and the rounding pushed them over.4
Two changes fixed it. A colour that holds is still kept, but one that has to move now goes 0.02 past the limit, so rounding can't pull it back. And base16 colours are rounded to two decimals before the engine sees them, so a colour it keeps is painted with exactly the value it measured.
Colours that aren't what their slot says
base16 has a convention for the sixteen slots. base00 to base07 go from background to foreground, and base08 to base0F are the hues, red first, then orange, yellow, green, cyan, blue and purple, with base0F left over for whatever the author wants. The old code trusted that: accent-red was base08, whatever colour base08 happened to be.
Plenty of schemes don't follow it, so the engine casts the accents by hue. It measures each hue slot in OKLCH, scores every pairing of accent and colour by how far the colour's hue is from the accent's, and hands out pairings starting from the cheapest.
ACCENTS.forEach((accent, order) => {
for (const { c, i } of chromatic) {
// Conventional base16 order (base08 red … base0E purple) wins ties.
const conventional = slotOrder && i === order ? -12 : 0;
// base0F is the scheme's odd one out, usually a brown; it plays an
// accent only when no real hue is near.
const leftover = slotOrder && i === 7 ? 25 : 0;
// Without an order, the more colourful of two near colours wins.
const vivid = slotOrder ? 0 : -c[1] * 20;
pairs.push({
accent,
i,
cost:
hueDistance(c[2], ACCENT_HUES[accent]) +
conventional +
leftover +
vivid,
});
}
});
The base0F penalty is Flatland's doing again. Its base0F is a brown, 78411c, with a hue about three degrees from where orange sits, so the brown took orange and shoved the real orange into red. With the penalty, the brown only plays an accent when nothing better is close.
Across the catalogue, 195 of the 368 schemes have at least one accent that now comes from a different slot, 455 accents in all. A pairing more than 50 degrees off doesn't count, and an accent left without one is made at its own hue, with the median lightness and chroma of the scheme's colourful slots. 171 schemes were missing at least one hue, usually a yellow or a cyan, and the engine made 303 accents for them.
The brand colour comes from base0D, the slot base16 uses for links. The engine picks the accent nearest its hue, unless the link colour is nearly gray or reads as red, where it would look like an error message. In that case it takes the most colourful accent that isn't red or orange.
Names and distance
With contrast sorted every theme was readable, so I measured the accents again. They read on every surface, but they didn't always match their names, and sometimes two of them were the same colour. Monokai and Rosé Pine each had cyan and blue set to one identical value. Gruvbox Dark's blue was 157 32% 56%, which is a teal.
That's a problem anywhere a hue means something, like red for errors, green for success, or one accent per group in a chart. So the contract got two more rules. Each accent's hue has to fall inside a range for its name, and any two accents have to be at least 0.045 apart in OKLab, about twice the 0.02 that's often taken as the smallest difference you can see in OKLab.
| Accent | OKLCH hue range |
|---|---|
| red | 350 to 45 |
| orange | 30 to 80 |
| yellow | 65 to 125 |
| green | 105 to 175 |
| cyan | 160 to 235 |
| blue | 215 to 290 |
| purple | 275 to 360 |
The ranges overlap on purpose. Solarized's orange is at 39 degrees and Catppuccin Latte's yellow at 68, and both are what their authors meant.
These repairs work like the earlier ones. A hue outside its range turns the short way back in, to three degrees past the edge, keeping its lightness and chroma. When two accents are too alike, the one further from its own hue turns toward it two degrees at a time. If it's already there, or too gray for its hue to show, it moves in lightness instead, in whichever direction pulls the pair further apart, and it goes back through accentOn after each step so contrast still holds.
Measured on what the engine produced before these rules, 166 of the 368 schemes broke them, with 154 accents outside their range and 71 pairs too close. Now none do. The hand-made themes changed in 16 values, and those are changes you can see. Gruvbox Dark's teal turned toward blue, and Rosé Pine's "green", which is its blue-leaning pine, turned toward green. For a site where colours mean things I think that's right, but it does cost something, and a Gruvbox purist would notice.
One theme is exempt. The Matrix theme is green on purpose, every accent included, so the name and distance rules skip it, and the test lists it as the only exception.
A theme from any handful of colours
Everything so far starts from a palette someone arranged. themeFromColours starts from colours nobody arranged, anywhere from one to twenty of them, plus a choice of dark or light.
For a dark theme the background is the darkest colour, and for a light one the lightest. If it isn't dark enough (above 0.3 in OKLCH lightness) or light enough (below 0.93), it gets pushed there. Its chroma is capped at 0.035, so a vivid pick can tint the page without taking it over. The foreground starts from the most readable nearly neutral colour, or from the colour at the other end when there isn't one, and its chroma is capped at 0.04. Every other colour with some chroma is a possible accent.
From there it's the same as for a base16 scheme. Surfaces and the border come from the background, secondary text is dimmed from the foreground, and accents are cast by hue. With no slot order to break ties, the more colourful of two nearby colours wins.
Random picks need one more step, which I call cohesion. Colours chosen one at a time rarely share a lightness or a saturation, and a theme with one neon green and six pastels can pass every contrast rule and still look wrong. So after casting, each accent gets pulled toward what the group has in common:
export const COHESION = {
lightness: 0.12,
yellowLift: 0.1,
chroma: { min: 0.7, max: 1.4 },
chromaFloor: 0.1,
} as const;
An accent's lightness stays within 0.12 of the group's median, and the median itself is kept between 0.5 and 0.8, since there's no room for colour near black or white. Yellow is allowed 0.1 lighter, because a yellow at the others' lightness looks olive. Chroma stays between 0.7 and 1.4 times the median and never drops below 0.1.5
Have a go. Each swatch opens a colour picker, and shuffle picks between 10 and 20 random colours. The first preview places the same colours by lightness alone: darkest as background, the next few as surfaces and border, the lightest as text, and the rest as accents in the order they were picked. Both sides use this site's own classes, and both are counted against the contract.
The theme maker is interactive; try it in the original post.
With the starting colours, placing by lightness breaks 8 rules for a dark theme and 15 for a light one. The engine's version breaks none. Across 2,000 random sets of 10 to 20 colours, placing by lightness broke at least one rule every single time, 17.8 on average.
I ran the generator over 24,000 themes: four seeds, six kinds of input for each (fully random colours, a single hue, pure grays, pastels, neon, and sets of just two colours), 500 sets of each kind, dark and light. Every one held the contract after rounding.
It does move your colours. Of the accents it made from random sets, 29% came out within 0.02 of a picked colour, close enough to look the same, and the median distance was 0.052. A lot of random RGB colours are too dark or too dull to work as accents, and moving them is what it costs to get a theme you can read. Colours that already fit mostly stay where they are. Give it Catppuccin Mocha's own background, text and accents, and the background, red, orange, green, blue and purple come back unchanged. Yellow and cyan get pulled toward the group, and the text loses a little of its blue.
How it runs on this site
Themes are CSS variables holding H S% L% strings, which Tailwind v4 reads through @theme inline, so bg-accent compiles to hsl(var(--accent)). A hand-made theme is a class on <html>. I ran the engine over those, wrote the repaired values into the stylesheet, and the test keeps them there.
A base16 scheme is computed in the browser when you pick it and written as inline variables on <html>.6 The tricky part is the first paint. A small script in the head applies your stored theme before anything renders, and the engine is too large to inline there. So each time a scheme is applied, the computed tokens go into localStorage, keyed by the palette and a hash of the engine's output on a fixed sample scheme. The head script paints from that cache when both match. When they don't, because the scheme is new or the engine changed, it paints the raw slots, which are at least the right lightness to avoid a flash, and the full result takes over once the page hydrates.
theme-harmony.test.ts keeps all of it in line. It checks every hand-made theme, all 368 schemes as they'd be painted, 720 generated themes from a fixed seed, the head script against the cache, and that the engine leaves a theme alone when it already holds. It takes about half a second.
Where it stops
The contract checks contrast and where hues sit. It can't measure taste, and a theme can pass every rule and still not be one you'd pick. Cohesion helps with random colours, but it's a rule of thumb, with numbers I tuned against the failures I happened to see.
It also changes themes people know. The engine rewrote 41 values in the hand-made themes for contrast and 16 more for names and distance, and a few of those show. And for now the generator only lives in this post. Saving a generated theme to the site's theme picker needs one more step, because the picker stores themes as base16 palettes.
How I measured, so you can argue with it
Contrast is the WCAG 2 ratio from relative luminance. Distances are Euclidean in OKLab. Hue ranges and anchors are in OKLCH degrees, with each accent's anchor close to the median hue the catalogue gives it.
"Before" numbers for the hand-made themes and the schemes come from running the current contract over the stylesheet as it was before the engine, and over each scheme's slots placed by the old fixed mapping. The 166 figure comes from running the current contract over the previous engine's output. Every "after" number is measured on tokens rounded to two decimals, the way they're painted.
The random sets come from a linear congruential generator with seeds 1, 7, 42 and 2026, so each run can be repeated. The fidelity numbers use 2,000 sets of 10 to 20 random colours, alternating dark and light. Timings are wall clock in Node, averaged over each 6,000-theme run, and they came out between 0.4 and 1.1 ms per theme across runs.
Originally published at zeybek.dev.
-
There's a demo of that further down. ↩
-
That's the one Flatland broke, with cards and borders a shade darker than the page. ↩
-
Contrast is still measured with WCAG's relative luminance, since that's what the rules are written in. ↩
-
One scheme's border ended up just over 3.4, another's body text just under 1.4 times its secondary text. ↩
-
That floor came out of the random runs: red and orange are 28 degrees apart, and below about 0.1 chroma two colours that far apart end up closer than the 0.045 the distance rule asks for. ↩
-
In Node that's under a millisecond per scheme. ↩
Top comments (0)