@zakkster/lite-hueforge
v1.10.0
Published
Reactive OKLCH color-system designer — thin composer over the @zakkster color stack. Curve-driven scale generation, APCA contrast, design-token exports, and Canvas slider-track baking.
Maintainers
Readme
@zakkster/lite-hueforge
FORGE // STACK — the five-scene integration demo. One
colorwaysignal; extraction → authoring → gradient → image → 150k GPU particles all derive from it. Run it withnpx serve .and opendemo/forge-stack.html.FORGE // SYSTEM — a theme is a deterministic function of (seed, model, targets, scheme). Four scenes off one
mastersignal: SEED (forgePaletteon an OKLCH disc, or drop an image), SOLVE (the APCA ladder, honest on a dark surface), THEME (a full dashboard reskinned in one frame with a live audit), and MOTION (createRemapperrecoloring a 60fps motif with a GC-events counter that holds at 0). Opendemo/system.htmlundernpx serve ..
Reactive OKLCH color-system designer. Curve-driven Radix-style palettes, APCA contrast, 7 token formats, image extraction. Zero-GC during slider drag.
The engine that powers Hueforge.app. Usable standalone for any OKLCH-aware design-system tooling: build palettes from a base color and a lightness curve, render 12-step Radix-style scales, verify pairwise APCA contrast, simulate color-vision deficiency, and export to CSS / Tailwind / SCSS / DTCG JSON / Figma Tokens / SwiftUI / Android XML.
Highlights
- Reactive. Built on
@zakkster/lite-signal. Editing any input on any scale recomputes every dependent step in the same frame, through the signal graph — no manual diffing. - Zero-GC slider drag. Each scale's 12-step output is a pre-allocated array of pre-allocated slot objects, mutated in place per recompute. Under
--expose-gc, 100k base-axis mutations retain < 1 byte per recompute. - 22 curve presets + CSS cubic-bezier custom curves. The full lite-ease family plus identity; switch via dropdown or evaluate
[p1x, p1y, p2x, p2y]tuples. - Real APCA 0.1.9. Not the WCAG 2 contrast ratio (which the APCA spec author calls flawed). Signed
Lcscore with polarity-aware noise clamp;APCA_THRESHOLDSships the tier constants. - Brettel-Vienot-Mollon CVD simulation. Protanopia / Deuteranopia / Tritanopia — the matrices applied in linear sRGB, round-tripped to OKLCH.
- CSS Color 4 gamut mapping. High-chroma OKLCH that exceeds sRGB clips in the chroma direction (not channel-by-channel in linear sRGB), preserving hue stability across the top of every scale.
- Image-based seeding.
extractPaletteFromImage(img, n)returns one neutral +n-1hue-separated chromatic slots, suitable ascreateScaleinputs. - Wide-gamut & dithering (v1.4.0). Display-P3 CSS / SCSS / Tailwind / JSON / Figma / SwiftUI exporters with CSS Color 4 gamut mapping. P3 slider tracks via canvas
colorSpace: 'display-p3'with silent sRGB fallback.gamutOf/auditGamutclassify a palette's slots assrgb/p3/out. Ordered dithering (ordered4/ordered8) softens color-boundary banding onremapImageToPalette— ~1–4% overhead on 512² (bounded, not free). - Solve, don't audit (v1.6.0).
solveApcaLinverts APCA — name a targetLc, get the lightness that hits it — via one bounded, allocation-free bisection (worst error 0.100 Lc across a 960-case grid).deriveThemeturns any seed into a full solved token map with a per-pair accessibility audit attached;repairColorwaysnudges CVD-colliding variants apart deterministically. Contrast becomes a value you request, not a number you hope for. - Motion recolor (v1.8.0).
createRemapperbinds a palette to a reusable, zero-allocation recolor pipeline (0 GC events over 60kremap(…, out)calls) with an optional 32³ LUT mode for K-independent, ~26–44× faster per-pixel cost at a small, published ΔE-OK penalty.remapImageToRampdoes lightness-mapped duotone/tritone. Exact mode is bit-identical toremapImageToPalette. - Temporal extraction (v1.9.0).
createExtractor(K)extracts a palette per video frame with stable slot identity — each frame warm-starts from the previous frame's centroids, so clusters track the footage instead of strobing. Zero steady-state allocation (size-independent B/op),reset()+ autodriftResetfor scene cuts, and frame 0 bit-identical toextractPaletteWithWeights. Feed it straight intocreateRemapperto extract and recolor live video.
Install
npm install @zakkster/lite-hueforgePeer dependencies (install if not already present):
npm install @zakkster/lite-signal @zakkster/lite-color @zakkster/lite-color-engine @zakkster/lite-easeQuick start
import { createPalette, createScale, toTokens } from '@zakkster/lite-hueforge';
import { effect } from '@zakkster/lite-signal';
// 1. Build a palette
const palette = createPalette('brand');
const primary = createScale({
name: 'Primary',
base: { l: 0.55, c: 0.22, h: 268 },
curve: 'ease-in-out-quad',
});
palette.addScale(primary);
// 2. React to changes — every step recomputes through the lite-signal graph
effect(() => {
const steps = primary.steps();
console.log('Primary 600:', steps[6]); // { step: "600", l, c, h }
});
// 3. Edit any axis, watch it propagate
primary.setBase('h', 240); // shift hue -> all 12 steps update
primary.setCurve('ease-in-out-cubic'); // switch curve preset
primary.setCurve([0.42, 0, 0.58, 1]); // OR a CSS cubic-bezier tuple
// 4. Export design tokens
console.log(toTokens(palette, 'tailwind')); // tailwind.config.js snippet
console.log(toTokens(palette, 'json')); // DTCG JSON
console.log(toTokens(palette, 'figma')); // Tokens Studio JSONSolve, don't audit (v1.6.0)
Every other contrast tool in this library — every other contrast tool, mostly —
works the same way: you pick a color, then you check it. apcaPair gives you a
number; you compare it to a threshold; if it fails you nudge and check again.
That loop is backwards. You already know the number you want. What you don't know
is the lightness that produces it.
solveApcaL inverts the relationship. You name the signed target Lc; it returns
the text {l, c, h} that hits it against a given background — one bounded
bisection, no search, no guessing:
import { solveApcaL } from '@zakkster/lite-hueforge';
const bg = { l: 0.98, c: 0.01, h: 250 }; // near-white surface
const text = solveApcaL(bg, 75, { c: 0.02, h: 250 });
// → { l: 0.42…, c: 0.02, h: 250, achievedLc: 75.0, clamped: false, reason: 'ok' }
// the L that reads at exactly Lc 75 — measured on the rendered pixelWhy one bisection is enough. Signed APCA Lc is strictly monotone-decreasing
in text lightness for a fixed background: sweep L from 0 to 1 and Lc falls
smoothly from its most-positive (dark text) through zero (the luminance crossover)
to its most-negative (light text). No local minima to fall into, and the sign of
the target selects the polarity for free — positive means dark-on-light, negative
means light-on-dark. Worst error across a 960-case grid (background L×C×H × target):
0.100 Lc against a 0.5 tolerance, with zero monotonicity breaks. The
bisection loop is scratch-based, so its churn is independent of iteration count.
The sign has to match the background. Because the sign is the polarity
request, a positive target against a background too dark to host dark text (or a
negative target against a background too light for light text) asks for contrast
that surface can never produce. solveApcaL treats that as a caller error and
throws a RangeError naming the fix — under every onUnreachable policy, since
no chroma or lightness move can rescue a wrong sign:
solveApcaL({ l: 0.15, c: 0.02, h: 280 }, 60);
// → RangeError: target Lc 60 is unachievable on a background of L=0.15;
// light-on-dark uses a negative target (did you mean -60?).This is the natural mistake — every APCA threshold is quoted as a positive
magnitude, and both APCA_LADDER_* presets are positive — so if you want the
library to pick the sign per background for you, feed magnitudes to
solveScaleToApca(bg, ladder, { polarity: 'auto' }) instead of signing by hand.
Honesty when the target is out of reach
Not every Lc is reachable at every chroma. A saturated background simply can't
host Lc 90 text at full chroma — the gamut runs out first. onUnreachable
decides what happens, and none of the options quietly overstate contrast:
solveApcaL(bg, 90, { c: 0.3, onUnreachable: 'clampL' }); // best L, clamped:true
solveApcaL(bg, 90, { c: 0.3, onUnreachable: 'reduceC' }); // sheds chroma to fit
solveApcaL(bg, 90, { c: 0.3, onUnreachable: 'throw' }); // hard fail, strictWhen a result is clamped, the reason field says which wall it hit —
'magnitude' (the target Lc is past the reachable boundary) or 'gamut' (the
chroma got clipped to fit sRGB) — so clampL callers can act without parsing a
message. A reached result reports reason: 'ok'.
This is the split from Leonardo. Adobe's
Leonardo also generates colors to contrast targets, and its default behavior maps
onto reduceC: when a target can't be met in gamut, it desaturates until the color
fits. That's a legitimate choice — and it's one line here. But it's a choice, not
the only truth, because desaturating changes the design. clampL (the default)
keeps your chroma and tells you honestly, via clamped, that the target fell short
— so you can decide whether to accept the near-miss, lighten the surface, or shed
chroma deliberately. Leonardo picks for you; solveApcaL surfaces the trade and
lets you pick, then reports achievedLc measured on the gamut-mapped pixel so
the number is never aspirational.
From one color to a whole scale
solveScaleToApca is the 12-step vectorization. Hand it a background and a ladder
of Lc magnitudes — two presets ship, APCA_LADDER_TEXT (body-legible) and
APCA_LADDER_UI (component/border) — and polarity: 'auto' reads whether the
background wants dark or light text:
import { solveScaleToApca, curveFromSolvedL, createScale, APCA_LADDER_TEXT } from '@zakkster/lite-hueforge';
const solved = solveScaleToApca(bg, APCA_LADDER_TEXT, { c: 0.04, polarity: 'auto' });
// each entry is a superset of OklchColor: { l, c, h, achievedLc, clamped, targetLc }
// Keep it LIVE: pin the solved lightness axis into a reactive scale.
const scale = createScale({
name: 'Text',
base: { l: 0.5, c: 0.04, h: 250 },
lValues: curveFromSolvedL(solved), // L follows the contrast ladder…
});
scale.setBase('h', 30); // …while C and H still track edits
scale.setLValues(null); // release → back to curve-driven LcurveFromSolvedL lifts the L axis out of the solved scale; the new lValues
option on createScale pins it while chroma and hue keep deriving from the base +
curve reactively. A solved scale that still moves under your hand.
deriveTheme — a seed becomes a design system
A theme is a function of (seed, scheme, contrast). deriveTheme takes any
Palette, colorway, or OklchColor[], promotes the most-chromatic slot to the
accent, tints the neutrals with its hue, and solves every foreground/surface
pair — returning a token map plus an audit that proves each pair rather than
promising it:
import { deriveTheme, toCssVars } from '@zakkster/lite-hueforge';
const { tokens, audit } = deriveTheme(seedColors, {
scheme: 'dark',
contrast: 'high',
intents: true, // also emit ok / warn / danger
});
audit.every(row => row.pass); // true — 144/144 across all scheme×contrast
console.log(toCssVars({ tokens })); // :root { --bg: …; --text-hi: …; --accent: … }toCssVars now accepts a theme directly (the { tokens } result or a bare token
map) and emits a flat :root block, camelCase kebab-cased (textHi → --text-hi).
One honest nuance: audit passes against a role floor, not the aspirational
target. High-contrast targets keep pushing Lc upward, but a pair passes once it
clears the legibility floor for its role. onAccent on a mid-luminance brand accent
is the classic case — the aspirational target may sit where no fixed accent can
reach, yet the text is fully legible. Each row reports both targetLc (what we
pushed for) and floorLc (what pass is judged on), so nothing hides.
repairColorways — the audit's other half
auditColorways has flagged CVD collisions since v1.3. Now repairColorways fixes
them: for each colliding pair it nudges the higher-indexed, non-master variant apart
in simulated space — hue first, then chroma, re-simulating each step — until the
pair clears or a step budget runs out. It never mutates its input, never touches
variant 0, and reports an honest residual for anything it couldn't separate:
import { createColorways, repairColorways } from '@zakkster/lite-hueforge';
const ways = createColorways(master, { count: 6, locked: [0] }); // pin the brand slot
const { colorways, moved, residual } = repairColorways(ways, { cvd: 'all' });
// moved: which variants shifted; residual: pairs still colliding after the budgetThe new locked option on createColorways holds chosen slot indices
byte-identical across every variant — anchor a brand color or fixed neutral while
the rest of the palette explores.
Full signatures for all of the above are in the API reference below.
forgePalette — one color becomes a palette (v1.7.0)
Everything above starts from an image or a hand-picked set of colors.
forgePalette starts from one. Hand it a seed and a harmony model and it
places slots at exact geometric offsets on the OKLCH hue wheel, clamps each into
your target gamut, and hands back an OklchColor[] that feeds deriveTheme,
createColorways and toGradientStops with zero adapters:
import { forgePalette, deriveTheme, toCssVars } from '@zakkster/lite-hueforge';
// one seed -> a triadic palette -> a full, audited theme
const palette = forgePalette({ l: 0.62, c: 0.16, h: 264 }, { model: 'triadic' });
const { tokens, audit } = deriveTheme(palette, { scheme: 'dark', contrast: 'high' });
audit.every(row => row.pass); // true — for every harmony model, all 4 combos
console.log(toCssVars({ tokens }));Seven models (mono, analogous, complementary, split, triadic,
tetradic, square). spread scales the geometry; count beyond the anchors
adds deterministic mulberry32-jittered slots (lRange, cJitter). Identical
inputs regenerate byte-for-byte, so an approved palette always comes back.
Around it, four measurement-and-shape ops — nearestInPalette (the recolor
kernel's slot choice, exposed), sortPalette (circular hue included),
dedupePalette (weight-conserving), scorePalette (numbers, not opinions) —
plus identity: serializePalette / parsePalette for exact round-trips and
paletteKey for a real cache key over a palette's content.
import { paletteKey, scorePalette, nearestInPalette } from '@zakkster/lite-hueforge';
paletteKey(palette); // 'abf12db8' — stable content hash
scorePalette(palette).hueSpread; // circular variance, 0..1
nearestInPalette({ l: 0.6, c: 0.1, h: 100 }, palette).index; // matches remapAPI
Palettes & scales
createPalette(name?: string): Palette
Reactive palette container with a name signal, a scales array signal, and a
shared palette.curve signal. Pass palette.curve to createScale({ curve })
to make a scale follow palette-wide curve changes.
createScale(opts): Scale
Build a reactive 12-step scale from a base OKLCH color and a curve preset.
createScale({
name: 'Primary', // required
base: { l: 0.55, c: 0.22, h: 268 }, // required
curve: 'ease-in-out-quad', // preset name, bezier tuple, or shared signal
minL: null, // optional: lock step "50" L
maxL: null, // optional: lock step "1000" L
});Edits to base.l/c/h, curve, minL, or maxL propagate to the 12 steps via
the lite-signal graph. Default endpoints come from LMAP and a damped baseL
offset (±0.6 × (baseL - 0.55), gamut-clamped) — adjust baseL to brighten or
darken the whole scale; lock minL / maxL to override.
Curve presets (full lite-ease family + identity):
linear
ease-in-sine, ease-out-sine, ease-in-out-sine
ease-in-quad, ease-out-quad, ease-in-out-quad
ease-in-cubic, ease-out-cubic, ease-in-out-cubic
ease-in-quart, ease-out-quart, ease-in-out-quart
ease-in-quint, ease-out-quint, ease-in-out-quint
ease-in-expo, ease-out-expo, ease-in-out-expo
ease-in-circ, ease-out-circ, ease-in-out-circLegacy aliases 'ease-in-out' / 'ease-in' / 'ease-out' are also accepted (map
to -quad / -cubic / -sine respectively for v0.1.x back-compat).
Custom bezier curves via setCurve([p1x, p1y, p2x, p2y]) — CSS-cubic-bezier
semantics (P0 = (0,0), P3 = (1,1)). p1x and p2x are clamped to [0, 1]
per spec; y axis can overshoot for springy curves.
getStep(scale, index): () => ScaleStep
Tracking accessor for one step (0..11).
selectStep(scaleOrGetter, indexGetter): () => ScaleStep
Reactive accessor for the step at a dynamic index — typically driven by a
"selected step" UI signal. Use this instead of
computed(() => scale.steps()[idx()]) — the naive version silently fails to
re-fire when the index hasn't changed but the scale itself has been edited
(see the zero-GC note below).
Accepts either a Scale directly or a function returning one (for the
"active scale switches" pattern).
Color math
| Function | Signature | Notes |
|---|---|---|
| toHex | (color) => '#rrggbb' | OKLCH → hex, sRGB-cube clamped |
| fromHex | (hex) => OklchColor | hex → OKLCH (3- and 6-char, with/without #) |
| oklchToLinearSrgb | (L, C, H) => [r, g, b] | OKLCH → linear sRGB, gamut-clamped |
| linearSrgbToOklch | (r, g, b) => OklchColor | inverse of above |
Round-trips preserve color within ±1 byte per channel.
Accessibility
apcaPair(text, bg): number
APCA 0.1.9 contrast between two OKLCH colors. Returns signed Lc:
apcaPair({ l: 0, c: 0, h: 0 }, { l: 1, c: 0, h: 0 }); // 106.0 black on white
apcaPair({ l: 1, c: 0, h: 0 }, { l: 0, c: 0, h: 0 }); // -108.0 white on black (reverse polarity)
apcaPair({ l: 0.55, c: 0, h: 0 }, { l: 0.55, c: 0, h: 0 }); // 0 identical -> below noise clampReadability tiers (use Math.abs(lc)):
| Lc | Tier |
|---:|---|
| ≥ 90 | preferred body text |
| ≥ 75 | Tier 1: body text minimum (conventional "pass") |
| ≥ 60 | Tier 2: medium-weight text |
| ≥ 45 | Tier 3: large or decorative text |
| < 30 | illegible |
Constants exposed as APCA_THRESHOLDS = { BODY: 75, MEDIUM: 60, LARGE: 45, MIN: 30 }.
simulate(color, mode): OklchColor
CVD simulation via Brettel-Vienot-Mollon matrices, applied in linear sRGB and
round-tripped to OKLCH. Modes: 'none' | 'deuteranopia' | 'protanopia' | 'tritanopia'.
Unknown modes pass through unchanged. Frozen mode list: CB_MODES.
Solver, theme & repair (v1.6.0)
See Solve, don't audit above for the narrative and worked examples; these are the signatures.
solveApcaL(bg, targetLc, opts?): SolveResult
APCA inversion. Returns the text { l, c, h, achievedLc, clamped } that hits a
signed targetLc against bg. opts: c (text chroma, default 0), h
(default bg.h), onUnreachable ('clampL' | 'reduceC' | 'throw', default
'clampL'), tolerance (0.5), maxIter (24), cReduceStep (0.005). achievedLc
is measured on the gamut-mapped color; clamped flags an Lc-window or chroma
shortfall. Throws TypeError on bad bg/targetLc, RangeError on a bad policy,
and (under 'throw') Error when unreachable. Frozen defaults: SOLVE_DEFAULTS;
policy names: UNREACHABLE_POLICIES.
solveScaleToApca(bg, targets, opts?): SolvedStep[]
12-step vectorization of solveApcaL. targets is a number[] of Lc
magnitudes; each result adds targetLc to the SolveResult shape. opts adds
polarity ('auto' | 'normal' | 'reverse', default 'auto'). Preset ladders:
APCA_LADDER_TEXT, APCA_LADDER_UI.
curveFromSolvedL(solved): number[]
Lifts the per-step l out of a solved scale into a plain array for
createScale({ lValues }). Throws TypeError on an empty array or a non-numeric
.l.
createScale({ lValues }) / scale.setLValues(values)
New reactive per-step L override. A number[] pins each step's lightness while C
and H still track the base + curve; null (default) restores the byte-identical
curve-driven path. Exposed on the scale as the lValues signal plus setLValues.
deriveTheme(input, opts?): { tokens, audit }
Seed (Palette | colorway | OklchColor[]) → semantic token map. opts: scheme
('light' | 'dark'), contrast ('normal' | 'high'), intents (bool),
onUnreachable. tokens keys are THEME_TOKENS (+ THEME_INTENT_TOKENS under
intents); audit rows carry { name, on, targetLc, floorLc, achievedLc, pass,
clamped }, judged against a role floor. Feed { tokens } (or a bare token map) to
toCssVars for a flat kebab-cased :root block.
createColorways({ locked })
New locked option: number[] slot indices copied byte-identical into every
variant. Out-of-range indices throw RangeError; omitting it is byte-identical to
prior behavior.
repairColorways(colorways, opts?): { colorways, moved, residual }
Separates CVD-colliding pairs flagged by auditColorways. opts: cvd ('all' |
'none' | a CbMode, default 'all'), collisionThreshold (0.02), stepBudget
(24), hueStep (6°), chromaStep (0.02). Never mutates input, never moves variant
0; moved lists shifted variants and residual the pairs it couldn't separate.
Synthesis — one color to a palette (v1.7.0)
forgePalette(seed: OklchColor, opts?): OklchColor[]
Harmony synthesis on the OKLCH hue wheel. opts.model is one of 'mono',
'analogous', 'complementary', 'split', 'triadic', 'tetradic',
'square' (default 'triadic'); opts.count (default 0 = the model's anchor
count) requests more slots, filled with deterministic mulberry32 jitter seeded
by opts.seed. opts.spread scales the anchor offsets, opts.lRange /
opts.cJitter set the jitter magnitude, opts.gamut ('srgb' | 'p3') is the
gamut every slot is clamped into (chroma toward the seed's hue line, L and H
preserved). HARMONY_MODELS and FORGE_DEFAULTS are exported.
nearestInPalette(color, palette, opts?): { index, color }
The slot in palette perceptually nearest color, by the same OKLab metric
remapImageToPalette uses — the slot the recolor kernel would pick.
opts.preserveLightness ignores L (matches chroma/hue only). Accepts
OklchColor[] or weighted PaletteSlot[].
sortPalette(palette, by): palette
New sorted array (non-mutating). by: 'l' | 'chroma' | 'hue' | 'weight'.
Hue sort is circular, rotated to start just after the largest gap so a wheel
reads as a wheel. 'weight' is descending on PaletteSlot[], a no-op copy on
OklchColor[].
dedupePalette(slots, opts?): slots
Merge slots within opts.threshold deltaEok (default 0.02). On PaletteSlot[]
merged weights are summed (total conserved); on OklchColor[] a plain de-dup.
scorePalette(palette): { hueSpread, lRange, meanDeltaE, minDeltaE, gamut }
Geometric measurements: hue spread (circular variance 0..1), L range, mean and
min pairwise deltaEok, and gamut share { srgb, p3, out } (fractions).
serializePalette(p): string · parsePalette(s): palette · paletteKey(p): string
serializePalette emits a versioned {"v":1,"slots":[...]} JSON string (weights
included); parsePalette reverses it exactly (fails closed on an unknown
version). paletteKey is a stable 8-hex FNV-1a over a canonical serialization —
equal palettes key equal, distinct palettes key distinct.
Recolor & motion (v1.8.0)
remapImageToPalette recolors one image per call. For video, live drags, or any
repeated recolor against a fixed palette, createRemapper binds the palette once
and reuses every buffer, so the steady state allocates nothing.
createRemapper(palette, opts?): Remapper
Returns { remap(imageData, out?), lastMs, palette }. Compute the palette state
once; call remap per frame. Pass the same out buffer back each call for a
zero-allocation steady state (proven by the churn gate: 0 GC events over 60k
calls).
import { createRemapper, forgePalette } from '@zakkster/lite-hueforge';
const palette = forgePalette({ l: 0.55, c: 0.16, h: 30 }, { model: 'tetradic', count: 8 });
const remapper = createRemapper(palette); // accel: 'exact' (default)
const out = new ImageData(frame.width, frame.height);
function onFrame(frame) {
remapper.remap(frame, out); // zero-alloc: out is reused
ctx.putImageData(out, 0, 0);
hud.textContent = remapper.lastMs.toFixed(2) + ' ms';
}Two acceleration modes. accel: 'exact' (default) is bit-identical to
remapImageToPalette and supports preserveLightness + the full dither
family. accel: 'lut' builds a 32×32×32 OKLab nearest-index table (32 KB) once
at factory time, giving K-independent per-pixel cost — but it is an
approximation: pixels near a palette boundary take the whole cell's single index.
The measured penalty on a broad 256×256 synthetic sweep (harder than
photographic content — it covers the gamut more uniformly), palette from
forgePalette('tetradic'):
| K | throughput vs exact | avg ΔE-OK (whole image) | max ΔE-OK (boundary) | pixels affected | |----|---------------------|-------------------------|----------------------|-----------------| | 4 | ~26× | ~0.002 | ~0.28 | ~1.2% | | 8 | ~30× | ~0.002 | ~0.27 | ~2.6% | | 16 | ~44× | ~0.002 | ~0.27 | ~4.6% |
The average penalty across the whole image is negligible; the ~0.28 max is
confined to the thin band of pixels that sit near a palette Voronoi boundary
(both candidate colors are palette members, so either is a valid posterization).
In motion, those boundary pixels can shimmer frame-to-frame — prefer 'exact'
when fidelity matters, 'lut' for large K under load where the speed wins. The
committed bound (max ΔE-OK ≤ 0.35 on the fixture set) is asserted in the tests.
accel: 'lut' requires preserveLightness: false, dither: 'none', and a
palette of ≤ 256 slots; each otherwise throws.
remapImageToRamp(imageData, ramp, opts?): ImageDataLike
Duotone / tritone: map each pixel's OKLab lightness onto a color ramp, so the
source's light-and-shade structure survives while hue and chroma come entirely
from the ramp. ramp accepts anything toGradientStops accepts (≥ 2 colors);
it is baked once to a 256-entry LUT — reusing the engine's out buffer on
@zakkster/lite-color-engine >= 1.6 (a silent optimization; older engines
allocate the LUT and behave identically). ramp[0] owns L=0 and the last stop
owns L=1 exactly.
import { remapImageToRamp, toGradientStops } from '@zakkster/lite-hueforge';
// classic navy->cream duotone
const duo = remapImageToRamp(photo, [
{ l: 0.15, c: 0.04, h: 265 },
{ l: 0.95, c: 0.06, h: 90 },
]);
// 30% blend with the source, keep alpha
const subtle = remapImageToRamp(photo, toGradientStops(master), { mix: 0.3 });Options: preserveAlpha (default true), mix (0..1, default 1; 0 returns
the source byte-for-byte, 1 is the full ramp), and out for buffer reuse.
Temporal extraction — recolor a video (v1.9.0)
createExtractor(K, opts?): Extractor
extractPaletteWithWeights extracts a palette from one image and re-runs
k-means from scratch every call. createExtractor is its video counterpart: it
warm-starts each frame from the previous frame's centroids, so the K clusters
track the footage instead of jumping around, and it keeps a stable slot
order — slot k is the same colour region frame to frame. That stability is
what lets you pipe it straight into createRemapper without colours swapping
between frames.
import { createExtractor, createRemapper } from '@zakkster/lite-hueforge';
const ex = createExtractor(6, { maxIterPerFrame: 3, driftReset: 0.06 });
function onFrame(imageData) { // e.g. a <video> drawn to a canvas
const slots = ex.extract(imageData); // live view, valid until next call
const remapper = createRemapper(slots.map(s => s.color));
return remapper.remap(imageData, outBuffer);
}- Frame 0 (and any
reset()/ drift re-init) runs full k-means++ and is bit-identical toextractPaletteWithWeightson the same seed and filters (as a set — the one-shot sorts by weight, the extractor keeps slot order). - Zero steady-state allocation. All scratch is hoisted; a warm
extract()allocates nothing (churn gate: size-independent B/op — 4× the pixels does not raise per-op bytes).extract()returns a live slots view mutated in place; callsnapshot()for a detached copy.centroids/weightsare live typed-array views for numeric consumers. - Scene cuts.
reset()forces a cold re-init on the next frame;opts.driftReset(OKLab units, defaultnull) auto-detects a cut when a warm frame's mean centroid drift exceeds it. Empty clusters keep their previous centroid, so a colour that briefly leaves frame keeps its slot.
Options: seed, maxIter (cold, default 50), maxIterPerFrame (warm,
default 3), convergenceThreshold, minL / maxL / maxC, sampleStride,
driftReset. Per-frame cost is a warm k-means pass over the filtered pixel set;
at maxIterPerFrame: 3 a 256² frame extracts in well under a frame budget for
K ∈ {4, 8, 16} (see npm run bench, scenario I).
Image extraction
extractPaletteFromImage(image, scaleCount = 5, opts?): OklchColor[]
Returns one neutral slot (averaged grayscale of the image) plus scaleCount - 1
chromatic slots separated by at least opts.minHueDistance degrees of hue
(default 30°). Suitable for seeding a palette from a brand reference, photo,
or screenshot. Browser-only: requires HTMLImageElement + Canvas 2D.
Monochrome scales
Pure static generators for monochromatic OKLCH scales. Return
OklchColor[] arrays (not reactive scales — wrap in computed(...) if
you need slider-driven live output).
monochromeScale(base, opts?): OklchColor[]
N-step monochromatic scale. Distributes L evenly across range, holds
chroma and hue constant across every step.
import { monochromeScale, CURVE_PRESETS, toHex } from '@zakkster/lite-hueforge';
const steps = monochromeScale({ l: 0.5, c: 0.15, h: 260 });
// 12 steps, tinted with base chroma/hue, linear L from 0 → 1
steps.map(toHex);
// [ '#000000', '#0e0e1c', ..., '#ffffff' ]
// Grayscale mode: force c=0
const gray = monochromeScale({ l: 0.5, c: 0.15, h: 260 }, { mode: 'grayscale' });
// Custom step count + range + non-linear curve
const printSafe = monochromeScale(
{ l: 0.5, c: 0.15, h: 260 },
{ steps: 9, range: [0.05, 0.95], curve: CURVE_PRESETS['ease-in-out-quad'] }
);Options:
| Option | Type | Default | Notes |
|---------|-----------------------------------|------------|----------------------------------------------------------------|
| steps | number (integer ≥ 2) | 12 | Number of steps in the returned array. |
| mode | 'tinted' \| 'grayscale' | 'tinted' | 'grayscale' forces chroma to 0; 'tinted' retains base c/h. |
| range | [number, number] | [0, 1] | L-axis endpoints. Must satisfy 0 ≤ lo < hi ≤ 1. |
| curve | (t: number) => number | null | null | Non-linear L distribution. Output clamped to [0, 1]. |
Throws RangeError on invalid steps or range, TypeError on
invalid mode or curve.
zoneScale(base, opts?): OklchColor[]
Thin 11-step wrapper implementing Ansel Adams' Zone System. Zone 0 (pure black, index 0) through Zone V (middle gray, index 5) to Zone X (pure white, index 10). Grayscale by default per Zone System convention.
import { zoneScale, ZONE_LABELS, toHex } from '@zakkster/lite-hueforge';
const zones = zoneScale({ l: 0.5, c: 0, h: 0 });
// 11 achromatic slots: L=0, 0.1, 0.2, ..., 1.0
// Pair with ZONE_LABELS for a labeled table
const labeled = zones.map((c, i) => ({
zone: ZONE_LABELS[i], hex: toHex(c)
}));
// [ { zone: '0', hex: '#000000' }, { zone: 'I', hex: '#1a1a1a' }, ... ]
// Tinted variant retains base chroma/hue (retro-photography look)
const sepia = zoneScale({ l: 0.5, c: 0.08, h: 60 }, { mode: 'tinted' });MONO_MODES, ZONE_LABELS
Frozen constants for iteration and labeling.
import { MONO_MODES, ZONE_LABELS } from '@zakkster/lite-hueforge';
MONO_MODES; // ['grayscale', 'tinted'] (frozen)
ZONE_LABELS; // ['0', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'] (frozen)Not reactive. Each monochromeScale/zoneScale call allocates a
fresh array. For a slider-driven live monochrome ramp, wrap the call in
a lite-signal computed:
import { signal, computed } from '@zakkster/lite-signal';
import { monochromeScale } from '@zakkster/lite-hueforge';
const base = signal({ l: 0.5, c: 0.15, h: 260 });
const ramp = computed(() => monochromeScale(base(), { steps: 12 }));
// ramp() re-runs whenever base() changesColorways & artist-facing exports (v1.2.0)
Two static generators for palette-level design work, plus two exporters that open in the tools designers actually use (GIMP, Photoshop, Illustrator, Affinity, Procreate, Aseprite, Clip Studio Paint).
deltaEok(a, b): number
Perceptual distance between two OKLCH colors, computed as Euclidean distance in
OKLab. Zero-alloc primitive math. Building block for auditColorways; useful
standalone for dedupe or nearest-slot mapping.
Typical scale:
| Value | Reads as | |-------------|------------------------| | 0.00 – 0.02 | indistinguishable | | 0.02 – 0.05 | subtle | | 0.05 – 0.15 | clearly different | | 0.15+ | unambiguously different|
createColorways(master, opts?): OklchColor[][]
Generate N colorway variants of a master palette — "the same motif, differently colored."
import { createColorways } from '@zakkster/lite-hueforge';
const master = [
{ l: 0.20, c: 0.05, h: 240 },
{ l: 0.50, c: 0.15, h: 240 },
{ l: 0.80, c: 0.08, h: 240 }
];
const variants = createColorways(master, { count: 6, seed: 42 });
// variants[0] === master (deep-copied)
// variants[1..5] apply per-variant hue delta + chroma scalar, uniformlyOptions:
| Option | Type | Default | Notes |
|---------------|-----------------------------------|----------------|--------------------------------------------------------------------------------|
| count | number (integer ≥ 1) | 6 | Total variants including variant 0 (master). |
| seed | number | 0 | Deterministic PRNG seed. Same (master, seed, opts) → identical output. |
| hueRange | [number, number] (degrees) | [-60, 60] | Per-variant hue delta ∈ range. Applied uniformly across all slots. |
| chromaRange | [number, number] (multiplicative) | [0.7, 1.3] | Per-variant chroma scalar ∈ range. Applied uniformly across all slots. |
Design decisions baked in:
- Lightness is always preserved per slot. Keeps the motif's shading structure intact under recolor. If a designer needs L variation, compose it as a separate transform on the output.
- Uniform per-variant transform. One hue delta + one chroma scalar per variant, applied to every slot equally. Preserves the relative relationships between slots — the difference between "recolored motif" and "random palette."
- Variant 0 is always the master (deep-copied).
count=1returns[[master-copy]]; edge cases and downstream indexing stay consistent. - Deterministic seeding. An approved colorway must regenerate identically
months later without a database of frozen bytes. Same
(master, seed, opts)triple → byte-identical output, forever. Inline mulberry32; no new dep.
COLORWAY_DEFAULTS
Frozen defaults constant. Spread when overriding a subset.
import { createColorways, COLORWAY_DEFAULTS } from '@zakkster/lite-hueforge';
createColorways(master, { ...COLORWAY_DEFAULTS, seed: 42 });auditColorways(colorways, opts?): AuditResult
Pairwise perceptual audit across colorway variants. Returns { pairs,
cvdWarnings }:
pairs— every(i, j)withi < j, and their pairwiseminDelta/meanDeltaacross corresponding slot positions. SmallminDeltais a dedupe hint.cvdWarnings— the same check, but colors first pass through the Brettel–Vienot–Mollonsimulate()for protanopia, deuteranopia, and tritanopia. Populated only wheremeanDelta < collisionThresholdunder that CVD mode. Each warning reports bothminDeltaandmeanDelta; the threshold applies tomeanDelta. Surfaces "variants A and C look distinct in normal vision but collapse under deuteranopia" — a niche check no other tool surfaces automatically.
import { createColorways, auditColorways } from '@zakkster/lite-hueforge';
const variants = createColorways(master, { count: 8, seed: 42 });
const { pairs, cvdWarnings } = auditColorways(variants, {
cvd: 'all',
collisionThreshold: 0.02
});
// pairs.length === 28 (8 * 7 / 2 pairs)
// cvdWarnings — each entry: { i, j, mode, minDelta, meanDelta }Options:
| Option | Type | Default | Notes |
|----------------------|------------------------------------------------------------------|---------|--------------------------------------------------------------------------|
| cvd | 'none' \| 'protanopia' \| 'deuteranopia' \| 'tritanopia' \| 'all' | 'all' | Which CVD modes to check. |
| collisionThreshold | number | 0.02 | Threshold on meanDelta under each CVD mode. Applied to mean since v1.3.1. |
v1.3.1 semantic fix. Prior to 1.3.1, the threshold applied to
minDelta — the minimum position-matched ΔE across slots. That produced
false positives on every variant pair whenever the master palette contained a
low-chroma slot (any Neutral scale), because createColorways applies one
hue delta and one chroma scalar uniformly across all slots — a chroma-zero
slot stays near-identical across every variant regardless of the transform,
guaranteeing a slot-level match. Mean captures overall palette similarity,
which is what "visual identity collision" actually means. minDelta is still
reported on each warning for API compat.
Decoupled from createColorways — accepts any OklchColor[][] including
hand-picked, AI-generated, or externally-imported palette variants.
toGimpPalette(input, opts?): string
GIMP .gpl plain-text palette. Opens in GIMP, Inkscape, Krita, Aseprite, and
Procreate (via import).
import { toGimpPalette } from '@zakkster/lite-hueforge';
const gpl = toGimpPalette(palette, {
name: 'Brand Autumn',
columns: 4,
slotPrefix: 'Autumn'
});
// Write to disk
fs.writeFileSync('brand-autumn.gpl', gpl);| Option | Type | Default |
|--------------|-----------|---------------------|
| name | string | 'Hueforge Palette'|
| columns | number | 0 (auto) |
| slotPrefix | string | 'Color' |
toAseSwatch(input, opts?): Uint8Array
Adobe Swatch Exchange .ase binary swatch. Opens in Photoshop, Illustrator,
InDesign, Affinity suite, Procreate, Clip Studio Paint.
Big-endian per spec, RGB color model, gamma-encoded [0, 1] floats,
Normal color type.
import { toAseSwatch } from '@zakkster/lite-hueforge';
const buf = toAseSwatch(palette, { slotPrefix: 'Brand' });
// Write to disk
fs.writeFileSync('brand.ase', buf);Polymorphic input
Both exporters accept the same input shapes, discriminated by shape at runtime:
Palette(all scales flattened in order)Scale(that scale's 12 steps)Scale[](each scale flattened, concatenated)OklchColor[](raw color list — colorway output, extracted palette, hand-picked subset)
Non-reactive: reads signals via .peek(). Call from event handlers or inside
untrack().
Both are outside the toTokens dispatcher — that dispatcher returns strings
uniformly, and .ase is binary. Rather than force a union return type, the
artist exporters stand alone.
Image recolor & weighted extraction (v1.3.0)
Two exports that close the AI-motif recolor loop. Extract a palette from any image with coverage weights, edit the scale in the authoring UI, generate audited colorway variants, and recolor the source back — all in-browser, all deterministic. This is the pipeline that turns "we have color primitives" into "we have a product that AI image generators can't replicate."
Requires @zakkster/lite-color-engine >= 1.1.0 (for the /remap
sub-export).
remapImageToPalette(imageData, palette, opts?): ImageDataLike
Recolor every pixel of an image to its nearest palette color (by ΔE-OK in
OKLab). Alpha byte passes through. Sits directly on top of the engine's
remapPixelsToPalette kernel.
import { remapImageToPalette } from '@zakkster/lite-hueforge';
// Baseline: nearest palette color per pixel
const recolored = remapImageToPalette(sourceImageData, palette);
// AI motif recolor: keep the source's shading, replace only the palette
const preservedShading = remapImageToPalette(sourceImageData, palette, {
preserveLightness: true
});
// Live-drag scenario: zero-alloc via buffer reuse
const preview = ctx.createImageData(w, h);
function onColorwayChange(chosenColorway) {
remapImageToPalette(source, chosenColorway, {
preserveLightness: true,
out: preview // reused every frame, zero allocation
});
ctx.putImageData(preview, 0, 0);
}| Option | Type | Default |
|---------------------|-----------------------------------|------------|
| preserveLightness | boolean | false |
| out | ImageDataLike | (fresh) |
Performance guidance (browser V8, indicative):
| Path | Real-time (60fps) up to |
|-------------------------------|-------------------------|
| Fast path (nearest by ΔE-OK) | ~512² pixels |
| preserveLightness: true | ~256² pixels |
For larger live-drag scenarios, downscale a preview to 256²–512² and rebuild at full resolution on export — standard image-editor pattern.
extractPaletteWithWeights(imageData, K, opts?): PaletteSlot[]
Extract a K-color palette from an image with coverage weights per slot. Uses deterministic k-means++ initialization in OKLab space.
import { extractPaletteWithWeights } from '@zakkster/lite-hueforge';
const slots = extractPaletteWithWeights(imageData, 6, {
seed: 1337,
minL: 0.05, // ignore near-black anti-aliased edges
sampleStride: 4 // 16× faster, negligible drift on smooth art
});
// slots is PaletteSlot[] sorted by descending weight:
// [{ color: { l, c, h }, weight: 0.42 }, { color: ..., weight: 0.24 }, ...]
// Weights sum to 1.0 across the filtered pixel set.Options:
| Option | Type | Default | Notes |
|------------------------|-----------|--------------|----------------------------------------------------------------------------------------------------|
| seed | number | 0 | PRNG seed. Same (imageData, K, seed) → identical output. |
| maxIter | number | 50 | k-means iteration cap. |
| convergenceThreshold | number | 1e-4 | Max centroid drift for early exit. |
| minL, maxL | number | 0, 1 | Exclude pixels outside an L range. Kills the "dominant slot is #000000 from AA edges" problem. |
| maxC | number | Infinity | Exclude highly-saturated pixels. Useful when you want the muted body, not the eye-grabbing accents. |
| sampleStride | number | 1 | Cluster every Nth pixel. 2048² image with stride 4 → 16× faster. |
Design decisions baked in:
- k-means++ in OKLab, not median-cut in RGB. OKLab distance is perceptually uniform, so centroids land where the eye would group colors.
- Empty clusters hold their previous centroid rather than collapse to origin — empirically far more stable on real photos.
- Weights sum to 1 over the filtered set, not the original image. If
minLexcluded 40% of pixels, the remaining 60% distribute across your K clusters. - Slots sorted by weight descending. Dominant colors first — matches how
humans read a palette bar and lets UI code render
slots.map(...)in the natural order. - Additive to
extractPaletteFromImagefrom v1.0, not a replacement. Callers who need weights use the new name; existing callers unchanged.
The end-to-end pipeline
Compose with the v1.2.0 colorway primitives for the full workflow:
import {
extractPaletteWithWeights,
createColorways,
auditColorways,
remapImageToPalette
} from '@zakkster/lite-hueforge';
// 1. Extract weighted palette from any source image.
const slots = extractPaletteWithWeights(source, 6, { seed: 1337 });
const master = slots.map(s => s.color);
// 2. Generate seeded colorway variants (uniform hue+chroma per variant).
const colorways = createColorways(master, { count: 6, seed: 42 });
// 3. Surface any CVD collisions before showing the variants to the user.
const audit = auditColorways(colorways, { cvd: 'all' });
// audit.cvdWarnings → show a badge next to affected variants
// 4. On user selection, recolor the source live.
const chosen = colorways[selectedIdx];
const output = remapImageToPalette(source, chosen, {
preserveLightness: true // motif stays shaded, not posterized
});Every step is deterministic. Every step runs client-side. Every step composes cleanly with the others.
Slider tracks
bakeSliderTrack(canvas, axis, fixed, opts?)
Paint a slice-aware OKLCH gradient onto a <canvas>. The track shows how
axis varies while the other two axes are held at fixed's values:
bakeSliderTrack(canvasEl, 'l', { l: 0, c: 0.22, h: 268 });
// L track from 0 -> 1 at the (C=0.22, H=268) sliceRe-bake on (C, H) change to keep the L track visually accurate to the
slice the user is editing.
Exports
7 token formats, all dispatched via the same builder:
toTokens(palette, 'css'); // CSS Custom Properties
toTokens(palette, 'tailwind'); // tailwind.config.js snippet
toTokens(palette, 'scss'); // SCSS variables + Sass maps
toTokens(palette, 'json'); // DTCG JSON (Style Dictionary v4)
toTokens(palette, 'figma'); // Tokens Studio plugin JSON
toTokens(palette, 'swiftui'); // SwiftUI Color extension
toTokens(palette, 'android'); // colors.xmlFrozen list: EXPORT_FORMATS. Each format also has a direct export
(toCssVars, toTailwindConfig, etc.) accepting format-specific options
(e.g. { format: 'hex' } to emit #rrggbb instead of oklch() literals
for legacy build chains).
All exporters read non-reactively. Call from an event handler or inside
untrack().
Wide-gamut & dithering (v1.4.0)
Every v1.4.0 addition is non-breaking: default options on every extended function produce byte-identical output to v1.3.1.
Gamut classification
import { gamutOf, auditGamut, GAMUT_TIERS } from '@zakkster/lite-hueforge';
gamutOf({ l: 0.65, c: 0.30, h: 29 }) // → 'srgb' | 'p3' | 'out'
GAMUT_TIERS // frozen ['srgb', 'p3', 'out']
const audit = auditGamut(palette); // Palette | Scale | Scale[] | OklchColor[]
// → { slots: [{ gamut, l, c, h }, ...],
// counts: { srgb: N, p3: N, out: N } }gamutOf is zero-alloc — uses module-level scratch buffers. Classifies
the raw need: a color that would gamut-map to sRGB in toHex still
classifies as 'p3' here if that's what it really needs. Surface it in
a palette audit and let the designer decide.
Display-P3 exporters
// Emit color(display-p3 R G B) tokens on any exporter that takes format:
toCssVars(palette, { format: 'p3' });
toScss(palette, { format: 'p3' });
toTailwindConfig(palette, { format: 'p3' });
toJsonTokens(palette, { format: 'p3' });
toFigmaTokens(palette, { format: 'p3' });
// Fallback-first CSS emission: sRGB primary + @media (color-gamut: p3) override.
// Older parsers see the sRGB block and stop.
toCssVars(palette, { p3Fallback: true }); // format defaults to 'oklch' primary
toCssVars(palette, { format: 'hex', p3Fallback: true });
// SwiftUI displayP3 initializer:
toSwiftUI(palette, { mode: 'displayP3' });
// → Color(.displayP3, red: R, green: G, blue: B, opacity: 1)Out-of-P3 colors chroma-reduce via the same CSS Color 4 binary-search
gamut mapper toHex uses for sRGB — hue preserved, only chroma is
reduced. Consistent story across the whole exporter surface.
toAndroidXml is unchanged. Android XML <color> resources have no
color-space attribute — for P3-accurate mobile assets, emit
.displayP3 SwiftUI for iOS and build P3 image assets for Android.
Display-P3 slider tracks
bakeSliderTrack(canvas, 'l', { l:0.6, c:0.15, h:250 }, {
colorSpace: 'display-p3',
});Routes through canvas.getContext('2d', { alpha:false, colorSpace:'display-p3' })
and the engine's packOklchBufferToUint32P3 custom packer. Silent sRGB
fallback when the browser rejects or downgrades the P3 attribute — a
slightly-less-vivid track is correct on an sRGB-only display, not an
error.
Ordered dithering
remapImageToPalette(imageData, palette, {
dither: 'ordered4', // 'none' | 'ordered4' | 'ordered8'
ditherAmplitude: 0.03, // (0, 0.2], default 0.03
preserveLightness: false,
out: existingBuffer,
});dither: 'none' (the default) continues to call the monolithic engine
remapPixelsToPalette kernel — byte-identical to v1.3.1. Dither modes
activate a split-stage pipeline (sRgba8ToOklabBuffer → Bayer perturb
→ nearestPaletteIndexBuffer → composed output). Bayer 4×4 and 8×8
matrices are generated at module init by recursive construction.
preserveLightness × dither interaction. In preserveLightness
mode, nearestPaletteIndexBuffer ignores L in the distance metric —
so an L-perturb would be a silent no-op. The perturb applies to
(a, b) in preserve mode and to L in the standard mode. That's what
actually affects each mode's decision boundaries.
Honest performance ratio at 512×512 (Node 24, sandbox — see
bench/benchmark.mjs Scenario E):
| Config | ms/op | Overhead vs plain |
| ------------------------------------- | ------- | ----------------- |
| plain (dither: 'none') | 63.30 | — |
| ordered4 amplitude 0.03 | 64.26 | +1.5% |
| ordered8 amplitude 0.03 | 63.88 | +0.9% |
| plain + preserveLightness | 103.37 | — |
| ordered4 + preserveLightness | 107.75 | +4.2% |
Bounded, not free, and not free-in-disguise either. Use dither when the banding is visible, not because it's cheap.
Color-stack integration (v1.5.0)
Three additions that make hueforge cooperate more directly with the neighbouring packages. All additive, all guarded behind engine peer-version checks that throw specific error messages instead of hard-failing at module load.
Blue-noise dither
remapImageToPalette accepts dither: 'bluenoise' — a third mode alongside
'ordered4' and 'ordered8'. Same amplitude semantics, same
preserveLightness composition rule (perturbs chromatic axes when L is
ignored by the search). Spectrally superior to Bayer on photographic
motifs — no visible weave — using the shared 64×64 void-and-cluster tile
that ships in @zakkster/lite-color-engine >= 1.5.0.
const recolored = remapImageToPalette(img, palette, {
dither: 'bluenoise',
ditherAmplitude: 0.08, // 0.03 is often too subtle for photos
preserveLightness: true,
});Requires engine >= 1.5.0. On older engines the call throws: `lite-hueforge: dither: 'bluenoise' requires @zakkster/lite-color-engine
= 1.5.0 (missing getBlueNoise64). Upgrade the peer, or use dither: 'ordered4' | 'ordered8' | 'none'.`
toGradientStops(input, opts) — the gradient bridge
Polymorphic bridge from hueforge shapes to
@zakkster/lite-gradient
stops. Handles Palette | Scale | Scale[] | OklchColor[] | PaletteSlot[]
via structural duck-typing:
import { toGradientStops, createColorways, extractPaletteWithWeights }
from '@zakkster/lite-hueforge';
import { Gradient } from '@zakkster/lite-gradient';
// (1) Colorway → cyclic hue-wheel gradient
const colorway = createColorways(master, { count: 5 })[0]; // OklchColor[]
const gr = new Gradient(toGradientStops(colorway, { closed: true }));
// (2) Weighted image palette → proportional-span gradient
const slots = extractPaletteWithWeights(image, 6); // PaletteSlot[]
const gr2 = new Gradient(toGradientStops(slots, { weighted: true }));
// (3) Design-system scale → 12-stop UI ramp
const gr3 = new Gradient(toGradientStops(myScale)); // 12 stopsclosed: true uses period spacing i / n (last stop lands just before 1,
matching engine 1.5's closed LUT bake).
weighted: true is only meaningful on PaletteSlot[] inputs. Stop i sits at
sum(weights[0..i-1]) / total, so the gap to the next stop is exactly
weights[i] / total — a colour covering 80% of the source image dominates 80% of
the gradient. The last stop therefore lands at (total - weights[n-1]) / total,
not at 1, and the final colour holds from there to the end of the bar; that is
how CSS linear-gradient and the engine's LUT bake render a trailing stop, and it
is the only way the final colour can own a span at all. closed has no effect on
weighted positioning. Negative or non-finite weights throw; weights summing to zero
throw. Ignored silently on non-weighted inputs.
Palette and Scale[] inputs flatten to one stop per scale, anchored on the
'500' step — the design-token convention that Primary-500 is the colour of
the Primary scale. The anchor is resolved by label, not by steps.length >> 1
(which is index 6 on a 12-step scale, i.e. '600').
lerpPaletteTo(a, b, t, out) — the palette-morph primitive
Slot-wise OKLCH interpolation between two same-length palettes / colorways.
Uses the engine's batch kernel; linear L and C, angular H via shortest arc.
out[i] object identities preserved (mutated in place). Zero allocations
after the first call at a given length.
const from = colorways[0]; // OklchColor[]
const to = colorways[1]; // OklchColor[], same length
const out = colorways[0].map(c => ({ l: c.l, c: c.c, h: c.h })); // reused
// Every animation frame:
lerpPaletteTo(from, to, sliderT(), out);
rebakeMeshFromPalette(out);Requires engine >= 1.3.0 (lerpOklchBufferN). The declared peer range stays
at >=1.2.0 — the first call reflects on the engine namespace and throws a
version-named error if the batch kernel isn't present. This is the
guard-not-bump strategy: floor bumps inside a minor version break existing
installs, so the new features degrade gracefully with a clear message
rather than blocking module load for anyone on engine 1.2.
Zero-GC slider drag
The headline performance claim. Each scale's stepsCache is a 12-slot array
allocated once at createScale. Recompute walks the array and mutates
slot.l/c/h in place; the array reference is reused across reads. The
computed carries { equals: () => false } so downstream observers still fire.
// Under --expose-gc, on Node 22 sandbox:
//
// scale.setBase('l', v) + read steps() 2.4M ops/s 0.003 B/op
// scale.setBase('h', v) + read steps() 2.5M ops/s 0.000 B/op
// 5-scale palette, mutate one per tick 2.0M ops/s 0.000 B/op
//
// Real hardware (Apple Silicon, Node 23): ~2x these numbers.Footgun avoided by selectStep: because the array reference is stable,
the naive pattern computed(() => scale.steps()[idx()]) short-circuits
lite-signal's Object.is dedupe — the slot ref is the same too, the L/C/H
were just mutated in place. Use selectStep(scale, idx); it carries the
{ equals: () => false } lever and propagates correctly.
TypeScript
Full declarations ship as Hueforge.d.ts. All 28 exports typed, including
CurvePreset (union of preset names), CurveBezier (4-tuple), CurveValue
(the setCurve argument), ExportFormat, CbMode, OklchColor, ScaleStep.
Testing
node:test (no test-runner dependency), six files:
npm test # 108 tests, fast
npm run test:gc # adds --expose-gc; zero-GC contract engages
npm run bench # internal throughput baseline| File | Tests | Coverage |
|---|---:|---|
| test/01-core.test.js | 32 | palette + scale construction, steps generation, reactivity, getStep / selectStep, constants |
| test/02-exports.test.js | 28 | toCssVars, toTailwindConfig, toScss, toJsonTokens, toFigmaTokens, toSwiftUI, toAndroidXml, toTokens dispatcher |
| test/03-color-math.test.js | 12 | toHex / fromHex round-trips, OKLCH ↔ linear-sRGB, gamut clamping, 3-char / 6-char hex parsing |
| test/04-a11y-and-simulation.test.js | 15 | APCA 0.1.9 (polarity, identical-pair, near-noise), APCA_THRESHOLDS, CB simulation per mode |
| test/05-curves-and-image.test.js | 15 | extractPaletteFromImage (hue separation, neutral slot, scaleCount), evalCubicBezier, chroma-curve anchors |
| test/06-zero-gc.test.js | 6 | cache identity (same array, same slots), heap-delta budget for single-scale L drag / 5-scale palette / H rotation |
| total | 108 | |
The 06-zero-gc heap-delta tests skip without --expose-gc so npm test
runs cleanly. npm run test:gc engages them.
License
MIT — © Zahary Shinikchiev.
Built on the @zakkster/lite-*
zero-runtime-dependency ecosystem.
