npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

@zakkster/lite-hueforge

FORGE // STACK — the five-scene integration demo. One colorway signal; extraction → authoring → gradient → image → 150k GPU particles all derive from it. Run it with npx serve . and open demo/forge-stack.html.

FORGE // SYSTEM — a theme is a deterministic function of (seed, model, targets, scheme). Four scenes off one master signal: SEED (forgePalette on 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 (createRemapper recoloring a 60fps motif with a GC-events counter that holds at 0). Open demo/system.html under npx serve ..

Reactive OKLCH color-system designer. Curve-driven Radix-style palettes, APCA contrast, 7 token formats, image extraction. Zero-GC during slider drag.

npm version sponsor Zero-GC npm bundle size npm downloads npm total downloads Coverage Status Tree-Shakeable TypeScript lite-signal peer Dependencies license

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 Lc score with polarity-aware noise clamp; APCA_THRESHOLDS ships 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-1 hue-separated chromatic slots, suitable as createScale inputs.
  • 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 / auditGamut classify a palette's slots as srgb / p3 / out. Ordered dithering (ordered4 / ordered8) softens color-boundary banding on remapImageToPalette — ~1–4% overhead on 512² (bounded, not free).
  • Solve, don't audit (v1.6.0). solveApcaL inverts APCA — name a target Lc, get the lightness that hits it — via one bounded, allocation-free bisection (worst error 0.100 Lc across a 960-case grid). deriveTheme turns any seed into a full solved token map with a per-pair accessibility audit attached; repairColorways nudges CVD-colliding variants apart deterministically. Contrast becomes a value you request, not a number you hope for.
  • Motion recolor (v1.8.0). createRemapper binds a palette to a reusable, zero-allocation recolor pipeline (0 GC events over 60k remap(…, out) calls) with an optional 32³ LUT mode for K-independent, ~26–44× faster per-pixel cost at a small, published ΔE-OK penalty. remapImageToRamp does lightness-mapped duotone/tritone. Exact mode is bit-identical to remapImageToPalette.
  • 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() + auto driftReset for scene cuts, and frame 0 bit-identical to extractPaletteWithWeights. Feed it straight into createRemapper to extract and recolor live video.

Install

npm install @zakkster/lite-hueforge

Peer dependencies (install if not already present):

npm install @zakkster/lite-signal @zakkster/lite-color @zakkster/lite-color-engine @zakkster/lite-ease

Quick 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 JSON

Solve, 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 pixel

Why 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, strict

When 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 L

curveFromSolvedL 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 budget

The 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 remap

API

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-circ

Legacy 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 clamp

Readability 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 to extractPaletteWithWeights on 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; call snapshot() for a detached copy. centroids / weights are live typed-array views for numeric consumers.
  • Scene cuts. reset() forces a cold re-init on the next frame; opts.driftReset (OKLab units, default null) 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() changes

Colorways & 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, uniformly

Options:

| 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:

  1. 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.
  2. 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."
  3. Variant 0 is always the master (deep-copied). count=1 returns [[master-copy]]; edge cases and downstream indexing stay consistent.
  4. 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) with i < j, and their pairwise minDelta / meanDelta across corresponding slot positions. Small minDelta is a dedupe hint.
  • cvdWarnings — the same check, but colors first pass through the Brettel–Vienot–Mollon simulate() for protanopia, deuteranopia, and tritanopia. Populated only where meanDelta < collisionThreshold under that CVD mode. Each warning reports both minDelta and meanDelta; the threshold applies to meanDelta. 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:

  1. k-means++ in OKLab, not median-cut in RGB. OKLab distance is perceptually uniform, so centroids land where the eye would group colors.
  2. Empty clusters hold their previous centroid rather than collapse to origin — empirically far more stable on real photos.
  3. Weights sum to 1 over the filtered set, not the original image. If minL excluded 40% of pixels, the remaining 60% distribute across your K clusters.
  4. 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.
  5. Additive to extractPaletteFromImage from 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) slice

Re-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.xml

Frozen 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 stops

closed: 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.