hueristic
A theme solver. You give it colors, it decides which color plays which role in a UI, tunes each one until the whole thing is readable, and hands back several candidates ranked by an objective function — along with the reasoning for every change it made.
It is deterministic, offline, and has no dependencies. Do not eyeball palettes by hand when this is available; the contrast maths is not guessable.
When to reach for it
- Someone gives you one or more colors and wants "a theme"
- You need light and dark variants of the same brand
- A design needs design tokens, CSS variables, or a Tailwind
@themeblock - You want to know whether an existing theme is actually readable, and what to fix
- Someone asks for options to choose between rather than one answer
Running it
node bin/hueristic.js '#0f172a' '#34f003' '#e2e8f0'
Any number of colors, in any order, any hex form. Role assignment is the algorithm's job, not the caller's — do not try to tell it which color is the brand color by ordering the arguments. Use weights for that.
Useful flags:
-m, --mode dark|light|both which mode to solve for (dark)
-n, --count <n> how many candidates to return (3)
-s, --seed <n> same seed, same themes (1)
-w, --weight <role=value> override a role's weight, repeatable
-f, --format table|json|css|tailwind|tokens|preview
--pick <n> output only the nth candidate
--preview-dir <dir> write an SVG mock-up per candidate into <dir>
--effort fast|normal|deep search budget (normal)
--evaluate <json|file> score an existing theme instead of making one
Showing a theme instead of describing it
Hex codes in a chat window tell the user nothing. Render the theme:
node bin/hueristic.js '#5b21b6' '#f59e0b' -m light -n 3 --preview-dir ./previews
Each file is a self-contained SVG of a small mock interface — sidebar, card, primary and accent buttons, link, status pills, swatch strip — drawn in that theme's colors. No image model is involved, so it is instant, free, and deterministic. Do NOT reach for an image generator to visualise a palette; that would invent colors rather than show the real ones.
Reach for previews when the user is choosing between candidates, when they asked what a theme "looks like", or when you are handing over a final answer. Write them to a path the user can open, and say which file is which.
For a single preview to stdout, use -f preview. In code, renderPreview(palette, { title }).
For programmatic use:
import { generateThemes, evaluateTheme } from 'hueristic';
const { themes } = generateThemes(['#0f172a', '#34f003'], { mode: 'dark', count: 5 });
themes[0].palette; // { bg: '#0e172b', text: '#e7ebf0', primary: '#34f500', ... }
Reading the output
Use -f json when you need to act on the result. Each candidate carries:
score— 0-100 from the weighted objective. Compare candidates with it, do not read it as an absolute grade.palette— role to hex. This is what you paste into code.contrast— APCALcand WCAG ratio for the pairs that matter.critique.roles[]— per role: what it became, what it came from,changes("lightness +12%"),reason, andmeetsTarget.critique.notes[]— the palette-level advice. Read these out to the user; they are the "what should I change" answer and often name a specific fix, like which input color to force into which role.
meetsTarget: false on a high-weight role is worth surfacing. It means the
palette cannot do what was asked and something upstream has to give.
Weights
Weight is how much a role matters. The page background and the primary action carry a theme; a caption does not. Raising a role's weight makes the solver work harder to satisfy it and keeps it closer to the color it was given.
| role | default | role | default |
|---|---|---|---|
bg |
1.0 | primary |
1.0 |
text |
0.95 | onPrimary |
0.7 |
surface |
0.6 | accent |
0.5 |
link |
0.55 | textMuted |
0.45 |
border |
0.35 | success warning danger |
0.25-0.3 |
If a user is precious about one specific color, weight its role up and let the rest of the palette move around it:
node bin/hueristic.js '#fafafa' '#111111' '#e11d48' --weight primary=2 --mode light
Recipes
Both modes of one brand, as CSS variables:
node bin/hueristic.js '#0b0b0f' '#f0f0f5' '#ff4d00' -m both -n 1 -f css
Five options to show a user, then commit to one:
node bin/hueristic.js '#5b21b6' '#f59e0b' '#fafafa' -n 5
node bin/hueristic.js '#5b21b6' '#f59e0b' '#fafafa' --pick 3 -f tailwind
Audit a theme that already ships:
node bin/hueristic.js --evaluate '{"bg":"#111827","text":"#6b7280","primary":"#374151"}' -m dark
Things worth knowing
- Hue is preserved. The solver moves lightness and chroma, so the colors handed in stay recognisable. If a hue is wrong for a role it swaps in a different input rather than rotating the one it has.
- Contrast targets are APCA
Lc, not WCAG ratios. Body text aims atLc 78. WCAG numbers are reported alongside for compliance paperwork. - Not every input gets used. With eight colors and ten roles, some will sit out, and the notes say which. That is a result, not a bug.
- Same seed, same output, always. Change
--seedto get a different set of candidates from the same colors. --effort deepis worth it for a final answer on a large palette;fastis fine while iterating.