@zakkster/lite-hueforge
v1.0.1
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.
Downloads
270
Maintainers
Readme
@zakkster/lite-hueforge
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.
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 JSONAPI
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.
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.
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().
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.
