# @zakkster/lite-hueforge

> Reactive OKLCH color-system designer. Thin composition layer over the
> @zakkster color stack — curve-driven Radix-style scale generation, 7
> design-token export formats, slice-aware Canvas slider tracks, APCA 0.1.9
> contrast, CVD simulation, image-based palette extraction, hex round-trip.
> Zero-GC during slider drag (< 1 byte/recompute under --expose-gc).

The engine that powers Hueforge.app. v1.0.0 froze the API after ~70
prerelease versions and 6 weeks of hardening.

## API surface (v1.0)

```js
import {
    // reactive palette model
    createPalette, createScale, getStep, selectStep,
    // exports
    toCssVars, toTailwindConfig, toScss, toJsonTokens,
    toFigmaTokens, toSwiftUI, toAndroidXml, toTokens, EXPORT_FORMATS,
    // accessibility
    apcaPair, APCA_THRESHOLDS, simulate, CB_MODES,
    // image
    extractPaletteFromImage,
    // canvas
    bakeSliderTrack,
    // curves
    evalCubicBezier, CURVE_PRESETS,
    // color math
    toHex, fromHex, oklchToLinearSrgb, linearSrgbToOklch,
    // constants
    STEP_LABELS, LMAP, VERSION,
} from '@zakkster/lite-hueforge';
```

28 exports total. Full d.ts ships in the package.

## What it composes (no reinvention)

- `@zakkster/lite-signal` — `signal`, `computed`, `effect` for reactive palette state
- `@zakkster/lite-color` — `toCssOklch` for output formatting
- `@zakkster/lite-color-engine` — `bakeGradientToUint32` for slider tracks
- `@zakkster/lite-ease` — easing functions for the L-axis curve presets

## Architecture model

A palette holds an ordered list of scales (typically Primary / Neutral /
Success / Warning / Danger). Each scale has:

- `base` — an OKLCH anchor `{ l, c, h }`, three reactive signals
- `curve` — an L-axis curve, either a preset name (string), a CSS-style
  cubic-bezier 4-tuple `[p1x, p1y, p2x, p2y]`, or a shared signal
  (typically `palette.curve` for palette-wide curve consistency)
- `minL` / `maxL` — optional lightness range bounds. `null` (default) =
  derived from `LMAP[0]` / `LMAP[n-1]` with a damped `baseL` offset
- `steps` — a computed accessor returning the 12 derived steps as
  `{ step, l, c, h }`

Hue is held constant across each scale (no per-step hue rotation in v1.0).
Chroma uses a **composed curve** with three anchors:
- 80% bell (4t(1-t) base, scaled) — preserves peak chroma at the middle
- 5% light-end ramp ((1-t) lift at t=0) — backgrounds get a subtle icy tint
  of the base hue instead of rendering as flat grey (the modern convention
  in Tailwind 4, Radix Colors, Material 3)
- 40% dark-end ramp (t lift at t=1) — the darkest step keeps real hue
  character. A pre-v0.7.2 15% retention read as muddy grey at low L
  (chroma ≈ 0.03 in OKLCH is below the perceptual threshold for hue ID)

L follows the named curve from `lMin` to `lMax`, both moving together with
baseL via a damped offset (`±0.6 × (baseL - 0.55)`, clamped to `[0.01, 0.99]`).

## Zero-GC slider drag

Each scale's `stepsCache` is a 12-slot array allocated once at `createScale`.
Per recompute:

```js
const stepsCache = new Array(STEP_LABELS.length);
for (let i = 0; i < STEP_LABELS.length; i++) {
    stepsCache[i] = { step: STEP_LABELS[i], l: 0, c: 0, h: 0 };
}
const steps = computed(() => {
    /* ... walk the array, mutate slot.l/c/h IN PLACE ... */
    return stepsCache;
}, { equals: () => false });
```

The computed carries `{ equals: () => false }` so downstream observers fire
despite the unchanged array reference. Empirically verified at 50k mutations
under `--expose-gc`: < 1 byte/recompute, often net-zero.

**Footgun:** `computed(() => scale.steps()[idx()])` with default Object.is
equals will silently dedupe its push when the selected index hasn't changed
but the scale has been edited — the slot reference is identical too because
it's mutated in place. Use `selectStep(scale, idxGetter)` instead; it carries
the propagation lever.

## Performance characteristics (Node 22, sandbox)

```
scale.setBase('l', v) + tracked read   2.4M ops/s   0.003 B/op
scale.setBase('h', v) + tracked read   2.5M ops/s   0.000 B/op
5-scale palette drag                   2.0M ops/s   0.000 B/op
scale.setCurve('preset')               886K ops/s   0.275 B/op
```

Real hardware (Apple Silicon, Node 23): ~2× these numbers.

At 60fps, a typical pointermove-driven slider drag emits ~16 events/second.
The headroom over that lets the rest of a UI frame stay free (lite-color-engine
gradient repaints, lite-signal-dom reconciler runs, etc.).

## Production usage patterns

1. **Bind swatch backgrounds reactively.** `style.background =
   computed(() => toCssOklch(getStep(scale, idx)()))`. Per-swatch string
   allocation per change; avoids the global stylesheet recalc that dynamic
   `<style>` injection would cause.
2. **Debounce slider-track repaints.** `bakeSliderTrack` is one
   `bakeGradientToUint32` + one `putImageData`. At ~16ms debounce via
   `@zakkster/lite-debounce`, drags coalesce to one repaint per frame.
3. **Use the shared `palette.curve` for design-system mental model.** Pass
   it to every `createScale({ curve: palette.curve })`. The curve dropdown
   then affects the whole palette in one signal write.
4. **Use `selectStep` for the "selected step" pattern.** Index changes
   AND scale edits both propagate. The naive `steps()[idx()]` does not.
5. **Light/dark themes** consume the same palette via separate role-var
   layers downstream of this library. lite-hueforge itself is theme-agnostic.

## File map

- `Hueforge.js`           — implementation (~1430 lines, fully JSDoc'd)
- `Hueforge.d.ts`         — TypeScript declarations for all 28 exports
- `README.md`             — public docs
- `CHANGELOG.md`          — library version history (separate from Hueforge.app's)
- `llms.txt`              — this file
- `LICENSE`               — MIT
- `test/01-core.test.js`                 — palette + scale + steps + reactive + getStep + constants (32 tests)
- `test/02-exports.test.js`              — all 7 export formats + toTokens dispatcher (28 tests)
- `test/03-color-math.test.js`           — toHex / fromHex / OKLCH ↔ linear-sRGB round-trips (12 tests)
- `test/04-a11y-and-simulation.test.js`  — APCA 0.1.9 + CB simulation (15 tests)
- `test/05-curves-and-image.test.js`     — image extraction + cubic bezier + chroma curve (15 tests)
- `test/06-zero-gc.test.js`              — cache identity + heap-delta budget under --expose-gc (6 tests)
- `bench/benchmark.mjs`   — internal throughput benchmark (no like-for-like competitor exists)

## Tests

108 deterministic tests on `node --test`. Two tiers:

```bash
npm test            # behavior suite, fast
npm run test:gc     # adds --expose-gc; zero-GC contract engages
```

Heap-delta tests skip silently without `--expose-gc` so `npm test` runs
cleanly without the flag. The zero-GC contract verifies:

- `steps()` returns the same Array reference across recomputes (cache identity)
- Each slot object is the same instance across recomputes (in-place mutation)
- The `{ equals: () => false }` lever ensures effects still fire
- Single-scale L drag retains < 10 B/op (typically < 1 B/op)
- 5-scale palette retains < 10 B/op per cross-scale mutation
- H-axis (hue rotation) takes the same zero-alloc branch

## Known limitations

- **`bakeSliderTrack` is browser-only.** Requires `HTMLCanvasElement` +
  2D context. Not tested under Node (no DOM available).
- **`extractPaletteFromImage` is browser-only.** Same reason — uses
  `Image` + `CanvasRenderingContext2D.getImageData`.
- **Hue is constant across each scale.** Per-step hue rotation is out of
  scope for v1.0. Users with strong opinions can post-process steps.
- **APCA 0.1.9 is the spec.** APCA continues to evolve; we don't track
  pre-release drafts.

## Peer dependency requirements

- `@zakkster/lite-signal` ^1.1.3
- `@zakkster/lite-color` ^1.0.6
- `@zakkster/lite-color-engine` ^1.0.2
- `@zakkster/lite-ease` ^1.1.0

## License

MIT (c) Zahary Shinikchiev
