facette
v0.2.0
Published
Perceptual color palette generation via particle repulsion on convex hulls in OKLab space
Maintainers
Readme
Facette
Perceptual color palette generation. Give it a few seed colors and a target size, and it produces a palette where every color is visually distinct and belongs to the same chromatic family.
Zero dependencies. Works in Node, browsers, and edge runtimes.
Installation
npm install facetteUsage
import { generatePalette } from 'facette';
const result = generatePalette(
['#e63946', '#457b9d', '#1d3557'], // seed colors
8 // palette size
);
console.log(result.colors);
// ['#e63946', '#457b9d', '#1d3557', '#7b2d3e', '#2e6a85', ...]Options
const result = generatePalette(seeds, 8, {
vividness: 2, // 0–4, default 2. Controls adaptive chroma preservation.
spread: 1.5, // 1–5, default 1.5. Lightness range expansion.
});vividness— controls how strongly the algorithm preserves chroma on intermediate colors between seeds at wide hue separations. The algorithm computes γ adaptively from seed hue configuration:γ = 1 + vividness × Δh_max / π. At0, no adaptive chroma preservation (γ = 1 always). At2(default), moderate adaptation. Higher values produce more aggressive preservation.spread— controls how much the palette's lightness range extends beyond the seeds. At1(no stretching), colors stay within the seed lightness range. At1.5(default), a 50% expansion. At2, the lightness range is doubled.
Stepper API
For inspecting the optimization process frame by frame:
import { createPaletteStepper } from 'facette';
const stepper = createPaletteStepper(['#e63946', '#457b9d', '#1d3557'], 8);
// Step through the optimization frame by frame
for (const frame of stepper.frames()) {
console.log(`Iteration ${frame.iteration}: energy=${frame.energy.toFixed(4)}, minDeltaE=${frame.minDeltaE.toFixed(4)}`);
}
// Or get everything at once
const trace = stepper.run();
console.log(trace.finalColors); // hex strings
console.log(trace.frames.length); // number of iterations
console.log(trace.geometry.kind); // 'line' or 'hull'API Reference
generatePalette(seeds, size, options?)
| Parameter | Type | Description |
|-----------|------|-------------|
| seeds | string[] | Hex colors (e.g. ['#ff0000', '#0000ff']). Minimum 2, must be distinct. |
| size | number | Total palette size including seeds. Must be >= seed count. |
| options.vividness | number | Adaptive gamma coefficient. Default 2. Range [0, 4]. |
| options.spread | number | Lightness range expansion. Default 1.5. Range [1, 5]. |
Returns PaletteResult:
{
colors: string[]; // hex sRGB colors
seeds: string[]; // input seeds echoed back
metadata: {
minDeltaE: number; // minimum pairwise perceptual distance
iterations: number; // optimization steps taken
clippedCount: number; // colors that needed gamut clipping
};
}createPaletteStepper(seeds, size, options?)
Same parameters as generatePalette. Returns a PaletteStepper:
{
geometry: Geometry; // hull or line segment topology
seeds: Particle[]; // classified seed particles
frames(): Generator<OptimizationFrame>; // iterate frame by frame
run(): OptimizationTrace; // run to completion
}How it works
Facette treats palette generation as a physics simulation in a radially lifted OKLab color space:
- L-stretch + Radial lift — seed lightness values are expanded around their median (controlled by
spread), then a convex radial chroma lift contracts the low-chroma region and anchors vivid seeds. γ adapts automatically to seed hue spread. - Convex hull — the hull of lifted seeds defines the palette's chromatic family
- Particle repulsion — free particles on the hull surface repel each other via Riesz energy, with the distance metric transitioning from lifted-space to gamut-clipped OKLab distances for gamut-aware separation
- Inverse lift — final positions are mapped back to OKLab and clipped to sRGB
The algorithm handles everything automatically: 2 seeds produce a gradient, 3+ seeds define a surface, and the convex hull geometry adapts to any configuration.
License
MIT
