START
Palette
A whole theme, solved from one colour. Each role is worked out from the contrast it owes rather than chosen and checked afterwards, so a generated theme cannot fail the contract. No dependencies, and it runs in Node as well as in a browser.
import { generate } from '@pipejesus/higreg-ds/palette';
const theme = generate('#7fb2d0', {
scheme: 'analogous', // monochrome · analogous · triadic · split · complementary
chroma: 0.5, // how colourful it may get
contrast: 0.5, // 0.5 is what this design uses
separation: 0.5, // how far the three depths sit apart
warmth: 0.5, // how much of your hue is in the greys
depth: 1, // 1 is no second pass. Below it, the fills deepen
});
// → { 'portfolio-dark': { …24 roles… }, 'portfolio-light': { … } }This is Adobe Leonardo's idea. The ordinary way to build a palette is to choose lightness values you like and audit the contrast afterwards; Leonardo turns that around and generates from the ratio you need. It fits here because the colour contract already is 14 stated floors, and a generator that starts from them cannot produce a theme that fails them.
Material's tonal palettes were the alternative and they guarantee a ladder — tone 10, 20, 30 — which is a different promise from the one check-presets makes. Radix's twelve semantic steps are a second scale laid over 24 roles that are already semantic.
Because it is the only common colour space where holding two of lightness, chroma and hue still while you search the third means anything. In HSL a "50% lightness" yellow and a "50% lightness" blue are nowhere near each other, so the same search would return a palette that glares in one hue and vanishes in another. Björn Ottosson built Oklab to fix exactly that.
OKLCH will also happily name colours sRGB cannot show. Those are brought back by reducing chroma and holding lightness and hue — a colour that cannot be this vivid becomes less vivid rather than becoming a different colour, which is what clipping each channel separately does.
None of these is a screenshot or a hand-picked example. Each row came out of generate() at build time, at the default dials.
#7fb2d0the library’s own accent#b4472aa terracotta#3f7d4ea green#6b4fa8a violetThe classical schemes, and the angle each puts the second hue at. There are five rather than nine because a scheme is only useful here if it produces a second hue that a warm plate and a placeholder tint can be built from — tetradic and hexadic give four and six, and this vocabulary has room for two.
monochromethe same hueOne hue, and everything is a lightness of it. Quiet, and hard to get wrong.
analogous+30°Next door, 30° away. A theme that reads as one temperature.
triadic+120°A third of the way round. Vivid, and still balanced.
split+150°Beside the opposite. The complement’s contrast without its tension.
complementary+180°Straight across. The most contrast two hues can have.
Notes
Every constant in the solver was measured off the two shipped themes: the hue anchors, the chroma range, the surface steps, the ratios the hairlines actually sit at. The only test of that worth running is whether it can get back. Generating from #7fb2d0 on the monochrome scheme reproduces portfolio-dark at a mean perceptual distance of 0.009 and portfolio-light at 0.016, where 0.02 is a just-noticeable difference. All 48 values land inside one.
The first version solved every role to its floor. Every check passed and the result looked like it had been left out in the sun: --ds-ink at 7.3:1 where this design sits at 13.9, --ds-accent at 4.7 where it sits at 7.7. Legal, and nobody would ship it.
So each role aims at what the design uses and is guaranteed the contract's floor. The contrast dial scales the aim and the floor clamps it from below, which is why 0.5 rather than 0 is the middle of that slider.
The dials above are a taste between two ends, so 0.5 is the middle of each. depth is not one of them. It runs after every role is solved and pushes the FILLS away from the middle: darker on ink, whiter on paper, because both are the same move. 1 leaves the palette exactly as the other four made it, and it only goes down.
🛑 It exists because somebody did it by hand and the arithmetic turned out to be one number. A theme generated here was taken away, deepened by eye, and brought back: across the five fills the lightness ratios were 0.84, 0.81, 0.81, 0.83 and 0.82. A multiplier, the same one each time, with hue moving one to three degrees and chroma barely at all. So the dial is that multiplier, and depth: 0.82 reproduces what the hand did.
It cannot break the contract, and the reason is structural rather than lucky: everything it touches is a fill or a line between two fills, and pushing a surface away from the ink on it can only RAISE the contrast between them. ink-disabled is the one ink on the list, because its whole job is to sit too close to its surface to read — leave it behind while the surface deepens and a disabled control quietly starts looking enabled.
What it costs is the ladder. Multiplying lightness compresses the steps between ground, sunk and raised in the same proportion, so a deeper page is also a flatter one: the gap holds 92% of its spread at 0.90, 81% at 0.82, and 62% at the floor of 0.60. That is where it stops, because past it the page keeps getting darker and stops being able to say which surface is which.
An error message is warm-red and a success message is green in every theme this will ever produce, because those two are carrying a meaning rather than a mood. They lean ten degrees toward your hue and no further; their chroma and lightness move with everything else, which is what keeps them part of the theme rather than bolted on.
One consequence: seed this with a terracotta and your error colour will look a great deal like your accent. That is what happens when your brand colour is the colour of a warning.
--ds-cream is the one bright surface in this design, and it is the same value in both grounds because a warm plate is an object rather than a decision about light. An early version interpolated it two thirds of the way from the scheme's hue to the warm anchor, which sounds like "mostly warm" and is not: from 265° the short way round to 82° goes through red, so the generated cream came out #f9dddc. Pink.
Every contract check passed while it did, because nothing in the contract says a warm surface must be warm. check-palette says it now, across 750 themes — along with three more things the contract cannot: the two placeholder tints stay tellable apart, disabled ink stays dimmer than AA and still visible, and the same colour always gives the same theme.
This is a separate entry point, like the Alpine plugin. The CSS never learns it exists and a consumer who does not import it ships the same bytes as before. No dependencies, no DOM, and it returns a plain object — so it works in a build script writing a stylesheet as well as behind the theme builder.