How I turn any handful of colours into a readable theme
You open the theme picker, choose Flatland from the base16 list, and the cards on the page lose their edges. The text is fine and the links are fine, but every border and every 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, which is what you get from two colours an eye can't tell apart.
Flatland wasn't special. This site has 18 hand-made themes and 368 schemes from the tinted-theming catalogue, and once I wrote down what a theme has to do and measured every one 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 hold, because the next scheme added to the catalogue would bring its own problems.
So the rules became code, and next to the rules sits an engine that repairs any palette that breaks them, moving each colour as little as it can. Once that worked on existing themes, the obvious next step was to give it colours nobody had arranged at all. There's a demo of that further down.
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 rules in there aren't about contrast numbers alone. Surfaces and borders have upper limits as well as lower ones, because a card that's as loud as the text stops being 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. Flatland broke exactly that one, with cards and borders a shade darker than the page.
Two more rules came later, and they're about the accents. I'll get to those after the repairs.
Why the engine works in OKLCH
The site stores colours as HSL, and HSL is a bad place to change a colour. 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 one hue in HSL and its apparent hue and saturation drift as well.
OKLCH splits a colour into lightness, chroma and hue in a way that tracks 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. Contrast is still measured with WCAG's relative luminance, because that's the number the rules are written in.
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 goes first. If the foreground is under 4.5 against the background, it's 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 go next. When a surface is out of range or on the wrong side, the engine rebuilds it from the background itself, keeping the background's hue and chroma and moving only the lightness, until it reaches a target. The target is 1.15 for the raised surface and 1.9 for the border. A tinted theme keeps its tint this way, where 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 direction of the step comes from the foreground, and that's the rule about layers in code. A theme with light text grows its surfaces lighter, whatever the palette said.
When the text is too close to a surface, the surface steps back toward the background first, as far as its own minimum allows. The text only moves if that isn't enough. Most themes have a text colour their authors chose carefully, and the surface is easier to give up.
Secondary text has two limits pulling in opposite directions. 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 the body text only brightens when dimming would break readability.
Accents are the last group. Each one keeps its hue and chroma and moves in lightness, in steps of 0.005, 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: if the colour already holds, it 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 is left 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. The difference was rounding. Tokens are stored as H S% L% strings, and the test measured what would be painted. Values that sat right on a limit were kept, then rounded for storage, and crossed it. One scheme's border ended up just over 3.4, another's body text just under 1.4 times its secondary text.
Two changes fixed it. A colour that holds is still kept, but a colour that has to move is pushed past the limit by a margin of 0.02, so rounding can't pull it back under. And base16 colours are rounded to two decimals before the engine sees them, so a colour it keeps is painted with the exact 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 was.
A lot 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 from the cheapest up.
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 came from Flatland again. Its base0F is a brown, 78411c, with a hue about three degrees from where orange sits, so the brown took orange and pushed the real orange into red. With the penalty, the brown only plays an accent when nothing better is near.
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 further than 50 degrees 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, most often 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. Then it takes the most colourful accent that isn't red or orange.
Names and distance
With contrast sorted, every theme was readable, and I measured the accents again. They read on every surface. 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 matters wherever a hue carries meaning, like a red for errors, a 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 sits at 39 degrees and Catppuccin Latte's yellow at 68, and both are what their authors meant.
The repairs follow the same pattern as before. A hue outside its range turns the short way back in, three degrees past the edge, with lightness and chroma kept. 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. After, none do. The hand-made themes changed in 16 values, and those are the 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. I think that's the right call for a site where colours mean things, but it's a real cost, 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 names 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 at all, anywhere from one to twenty of them, and asks for a dark or a light theme.
The background is the darkest colour for a dark theme and the lightest for a light one. If it isn't dark enough, above 0.3 in OKLCH lightness, or light enough, below 0.93, it's pushed there. Its chroma is capped at 0.035, so a vivid pick can tint the page without becoming the page. 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 candidate for an accent.
From there the engine does what it does for a base16 scheme. Surfaces and the border are built from the background, secondary text is dimmed from the foreground, and the accents are cast by hue. There's no slot order to break ties, so 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 is pulled toward what the group shares:
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, which itself is kept between 0.5 and 0.8, since there's no room for colour near black or white. Yellow may sit 0.1 lighter, because a yellow at the others' lightness reads as olive. Chroma stays between 0.7 and 1.4 times the median, and never below 0.1. That floor came out of the random runs. Red and orange sit 28 degrees apart, and below about 0.1 chroma, two colours that far apart are closer than the 0.045 the distance rule asks for.
Try it. 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 ones as surfaces and border, the lightest as text and the rest as accents in the order they were picked. Both sides are drawn with this site's own classes, and both are counted against the contract.
placed by lightness: 8 broken rules
Deploy finished
3 services, 41 seconds ago
$ kubectl rollout status
redorangeyellowgreencyanbluepurple
Promotethrough the engine: 0 broken rules
Deploy finished
3 services, 41 seconds ago
$ kubectl rollout status
redorangeyellowgreencyanbluepurple
PromoteWith the starting colours, placing by lightness breaks 8 rules for a dark theme and 15 for a light one, and the engine's version breaks none. Over 2,000 random sets of 10 to 20 colours, placing by lightness broke at least one rule every 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, both dark and light. Every theme held the contract after rounding.
It does move your colours, though. Of the accents it made from random sets, 29% came out within 0.02 of a colour that was picked, which is 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 that's the price of a theme that reads. Colours that already fit mostly stay put. 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, and Tailwind v4 reads them 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 and 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>. In Node that takes under a millisecond per scheme. The catch 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 it's a new scheme or the engine changed, it paints the raw slots, which are at least the right lightness to avoid a flash, and the full result replaces them once the page hydrates.
theme-harmony.test.ts holds all of it together. 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 runs in about half a second.
Where it stops
The contract checks contrast and where hues sit. It doesn'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 saw.
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 are visible. And the generator only lives in this post for now. Saving a generated theme to the site's theme picker would take one more step, since 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.