@zakkster/lite-color-engine 1.0.4 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +37 -312
- package/index.d.ts +101 -9
- package/index.js +11 -2
- package/llms.txt +29 -192
- package/package.json +23 -5
- package/src/Gamut.d.ts +37 -0
- package/src/Gamut.js +228 -0
- package/src/Remap.d.ts +87 -0
- package/src/Remap.js +306 -0
- package/src/authoring.js +47 -6
- package/src/convert.js +127 -5
- package/src/delta.js +37 -0
- package/src/runtime.js +64 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.2.0] - 2026-07-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Wide Gamut Support (Display P3)**
|
|
7
|
+
- `parseCSSColor('color(display-p3 r g b / alpha)')` now supported in the universal parser
|
|
8
|
+
- New `displayP3ToOklchBuffer()` conversion function with accurate P3 → XYZ → OKLab pipeline
|
|
9
|
+
- `packOklchBufferToUint32P3()` — accurate packer for `display-p3` canvas contexts
|
|
10
|
+
- `packOklchBufferToUint32P3Fast()` — high-speed sqrt approximation for P3 output
|
|
11
|
+
- `oklchToLinearP3()` inverse conversion helper
|
|
12
|
+
|
|
13
|
+
- **Accuracy Tiers** (now clearly documented)
|
|
14
|
+
- Fast (`packOklchBufferToUint32Fast`)
|
|
15
|
+
- Accurate-Clamp (default `packOklchBufferToUint32`)
|
|
16
|
+
- Gamut-Mapped (`packOklchBufferToUint32MINDE` / P3 equivalent)
|
|
17
|
+
|
|
18
|
+
- `bakeGradientToUint32()` already supported custom packers — now officially documented for P3 usage.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Improved documentation around color gamut handling and when to use each packer.
|
|
22
|
+
- Minor internal cleanup for better tree-shaking of P3 code paths.
|
|
23
|
+
|
|
24
|
+
### Notes
|
|
25
|
+
- P3 remains strictly opt-in. Default paths and bundle size are unchanged.
|
|
26
|
+
- All hot paths remain zero-GC and allocation-free.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## [1.1.0] - Previous
|
|
31
|
+
- Added `deltaEOK` and MINDE gamut mapping (`/gamut` sub-export)
|
|
32
|
+
- Added palette remapping utilities (`/remap`)
|
package/README.md
CHANGED
|
@@ -1,339 +1,64 @@
|
|
|
1
1
|
# @zakkster/lite-color-engine
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
5
|
-
[](https://bundlephobia.com/result?p=@zakkster/lite-color-engine)
|
|
6
|
-
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
7
|
-
[](https://www.npmjs.com/package/@zakkster/lite-color-engine)
|
|
8
|
-

|
|
9
|
-

|
|
10
|
-
[](./LICENSE.txt)
|
|
3
|
+
**Zero-GC, data-oriented OKLCH color engine** for high-performance WebGL / Canvas pipelines.
|
|
11
4
|
|
|
12
|
-
##
|
|
5
|
+
## v1.2 Highlights — Wide Gamut + Accuracy Tiers
|
|
13
6
|
|
|
14
|
-
|
|
7
|
+
- Full `color(display-p3 r g b / alpha)` parsing support
|
|
8
|
+
- Dedicated high-accuracy `packOklchBufferToUint32P3()` and fast variant
|
|
9
|
+
- Three clear accuracy tiers for sRGB output:
|
|
10
|
+
1. **Fast** — `packOklchBufferToUint32Fast`
|
|
11
|
+
2. **Accurate-Clamp** — default `packOklchBufferToUint32`
|
|
12
|
+
3. **Gamut-Mapped** — `packOklchBufferToUint32MINDE`
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
P3 output is **opt-in only** — never affects default paths or bundle size.
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
## Core Philosophy
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
- 🔵 **LUT layer** — `bakeGradientToUint32()`. Runs once at gradient compile. Multi-stop OKLCH input, optional easing, fixed-resolution `Uint32Array` output ready for `texImage2D` or a `Uint32Array` view of `ImageData`.
|
|
22
|
-
- 🔴 **Runtime layer** — `lerpOklchBuffer()`, `packOklchBufferToUint32()`, `sampleColorLUT()`. Zero allocations on the hot path. Zero closures. Branch-clamped, SoA-friendly, V8-monomorphic.
|
|
18
|
+
Parse once at init → work with `Float32Array` OKLCH buffers → zero allocations on the hot path.
|
|
23
19
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
## 🧬 Where it fits
|
|
27
|
-
|
|
28
|
-
`lite-color-engine` is the **color pipeline kernel** of the zero-GC rendering stack — the bridge between human-authored CSS colors and per-frame `Uint32` pixel writes:
|
|
29
|
-
|
|
30
|
-
```mermaid
|
|
31
|
-
flowchart LR
|
|
32
|
-
A[Designer / CSS<br/><sub>any CSS color string</sub>]:::input --> B[parseCSSColor<br/><sub>authoring</sub>]:::author
|
|
33
|
-
B --> C[OKLCH Float32 buffer<br/><sub>L, C, H triplets</sub>]:::buffer
|
|
34
|
-
C --> D[lerpOklchBuffer<br/><sub>shortest-path hue</sub>]:::runtime
|
|
35
|
-
C --> E[bakeGradientToUint32<br/><sub>LUT baking</sub>]:::lut
|
|
36
|
-
E --> F[Uint32Array LUT<br/><sub>RGBA-LE</sub>]:::buffer
|
|
37
|
-
F --> G[sampleColorLUT<br/><sub>zero-GC sampler</sub>]:::runtime
|
|
38
|
-
D --> H[packOklchBufferToUint32<br/><sub>sRGB transfer</sub>]:::runtime
|
|
39
|
-
G --> I[Canvas ImageData<br/>WebGL texImage2D]:::render
|
|
40
|
-
H --> I
|
|
41
|
-
|
|
42
|
-
classDef input fill:#fef3c7,stroke:#d97706,color:#78350f
|
|
43
|
-
classDef author fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e
|
|
44
|
-
classDef buffer fill:#f3f4f6,stroke:#6b7280,color:#1f2937
|
|
45
|
-
classDef lut fill:#dcfce7,stroke:#16a34a,color:#14532d
|
|
46
|
-
classDef runtime fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
|
|
47
|
-
classDef render fill:#fecaca,stroke:#dc2626,color:#7f1d1d
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
Every layer is independent. You can use just the parser. You can hand-author OKLCH buffers and skip the parser entirely. You can sample baked LUTs without ever touching `lerpOklchBuffer`. No framework lock-in, no global state.
|
|
51
|
-
|
|
52
|
-
## 🚀 Install
|
|
20
|
+
## Installation
|
|
53
21
|
|
|
54
22
|
```bash
|
|
55
23
|
npm install @zakkster/lite-color-engine
|
|
56
24
|
```
|
|
57
25
|
|
|
58
|
-
##
|
|
59
|
-
|
|
60
|
-
### Authoring — parse once, store forever
|
|
61
|
-
|
|
62
|
-
```js
|
|
63
|
-
import { parseCSSColor } from '@zakkster/lite-color-engine';
|
|
64
|
-
|
|
65
|
-
// Pre-allocate the entire palette as one contiguous buffer.
|
|
66
|
-
// Each color is 3 floats: [L, C, H].
|
|
67
|
-
const palette = new Float32Array(3 * 4);
|
|
68
|
-
const alphas = new Float32Array(4);
|
|
69
|
-
|
|
70
|
-
alphas[0] = parseCSSColor('#ff0000', palette, 0); // hex
|
|
71
|
-
alphas[1] = parseCSSColor('rgba(0, 200, 100, 0.5)', palette, 3); // rgb
|
|
72
|
-
alphas[2] = parseCSSColor('oklch(60% 0.15 250)', palette, 6); // oklch
|
|
73
|
-
alphas[3] = parseCSSColor('rebeccapurple', palette, 9); // named
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
That `Float32Array` is now your color storage for the rest of the program. No object headers, no GC pressure, perfectly cache-friendly.
|
|
77
|
-
|
|
78
|
-
### Runtime — interpolate and pack with zero allocations
|
|
79
|
-
|
|
80
|
-
```js
|
|
81
|
-
import { lerpOklchBuffer, packOklchBufferToUint32 } from '@zakkster/lite-color-engine';
|
|
82
|
-
|
|
83
|
-
const tempColor = new Float32Array(3); // single scratch buffer for the program lifetime
|
|
84
|
-
|
|
85
|
-
// Tween color 0 → color 2 over time, packing for ImageData.
|
|
86
|
-
function tick(t) {
|
|
87
|
-
lerpOklchBuffer(palette, 0, palette, 6, t, tempColor, 0);
|
|
88
|
-
return packOklchBufferToUint32(tempColor, 0, 1.0);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Inside your render loop:
|
|
92
|
-
const u32 = tick(progress);
|
|
93
|
-
imageDataU32View[pixelIndex] = u32; // direct write — no string parsing, no Color object
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
### LUT — bake a gradient once, sample forever
|
|
26
|
+
## Quick Start
|
|
97
27
|
|
|
98
28
|
```js
|
|
99
|
-
import {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
parseCSSColor('#0000ff', stops, 6);
|
|
106
|
-
|
|
107
|
-
const lut = bakeGradientToUint32(stops, 3, 256); // 256-entry Uint32Array
|
|
108
|
-
|
|
109
|
-
// Hot path: zero allocations, zero allocations, zero allocations.
|
|
110
|
-
function colorAt(t) {
|
|
111
|
-
return sampleColorLUT(lut, t);
|
|
112
|
-
}
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
## 🧠 Why this exists
|
|
116
|
-
|
|
117
|
-
CSS color is a string-first API. Every existing color library (`color`, `colord`, `culori`, `chroma-js`) treats this seriously: it gives you an object you call methods on. That's correct for tooling. It's wrong for **rendering**.
|
|
118
|
-
|
|
119
|
-
A 60fps loop with 10,000 particles allocates 600,000 color objects per second if each particle calls `.mix()` or `.toRgb()`. Even if each object is 100 bytes, you've handed the GC 60MB/sec of churn. The frame time isn't the math — it's the major GC pause.
|
|
120
|
-
|
|
121
|
-
`lite-color-engine` solves this by **separating the parse from the use**:
|
|
122
|
-
|
|
123
|
-
1. **Authoring time** (once, off the critical path): expand CSS strings into a flat `Float32Array` of OKLCH triplets. Allocate the buffer yourself, at the size you want. Done.
|
|
124
|
-
2. **Runtime** (every frame, every particle): pure scalar math against that buffer. Lerp two triplets into a third. Pack a triplet to `Uint32`. Sample a baked LUT by `t`. Zero allocations, zero closures, zero indirection.
|
|
125
|
-
|
|
126
|
-
The buffer-and-offset API is the contract. It looks like a C-style API in JavaScript because that's what zero-GC actually means — you control the memory, the library reads and writes it.
|
|
127
|
-
|
|
128
|
-
### Why OKLCH
|
|
129
|
-
|
|
130
|
-
Linear RGB interpolation is fast but **wrong**: red → blue passes through muddy purple-gray at the midpoint because RGB isn't perceptually uniform. HSL/HSV are perceptually uniform in name only — the saturation axis interacts non-linearly with lightness. OKLCH (Björn Ottosson, 2020) is the modern fix: hue, chroma, and lightness are independently meaningful, and linear interpolation between any two OKLCH points stays on a perceptually smooth curve.
|
|
131
|
-
|
|
132
|
-
The library uses OKLCH as its **internal representation** so every interpolation and every gradient bake is perceptually correct by construction. Pack-to-RGBA happens only at the moment of pixel write.
|
|
133
|
-
|
|
134
|
-
## 🔥 Algorithm
|
|
135
|
-
|
|
136
|
-
The end-to-end pipeline:
|
|
137
|
-
|
|
138
|
-
```mermaid
|
|
139
|
-
flowchart TD
|
|
140
|
-
A[CSS string<br/>#ff0000]:::start --> B{prefix?}
|
|
141
|
-
B -->|named| N[NAMED_COLORS<br/>lookup]:::lut
|
|
142
|
-
B -->|#| H[HEX_REGEX]:::regex
|
|
143
|
-
B -->|rgb| R[RGB_REGEX]:::regex
|
|
144
|
-
B -->|hsl| S[HSL_REGEX]:::regex
|
|
145
|
-
B -->|oklch| K[OKLCH_REGEX<br/>direct]:::regex
|
|
146
|
-
B -->|oklab| L[OKLAB_REGEX<br/>+ polar]:::regex
|
|
147
|
-
N --> H
|
|
148
|
-
H --> M[sRGB byte 0-255]:::buf
|
|
149
|
-
R --> M
|
|
150
|
-
S --> M
|
|
151
|
-
M --> O[linearize<br/><sub>pow 2.4</sub>]:::math
|
|
152
|
-
O --> P[matrix → LMS]:::math
|
|
153
|
-
P --> Q[cbrt]:::math
|
|
154
|
-
Q --> T[matrix → OKLab]:::math
|
|
155
|
-
T --> U[atan2 / hypot<br/>→ OKLCH]:::math
|
|
156
|
-
U --> V[outBuf L, C, H]:::buf
|
|
157
|
-
K --> V
|
|
158
|
-
L --> V
|
|
159
|
-
|
|
160
|
-
V --> W[lerpOklchBuffer<br/><sub>shortest-arc hue</sub>]:::run
|
|
161
|
-
W --> X[OKLCH → linear sRGB<br/><sub>cube + matrix</sub>]:::math
|
|
162
|
-
X --> Y[hard gamut clamp]:::math
|
|
163
|
-
Y --> Z[sRGB transfer<br/><sub>pow 1/2.4</sub>]:::math
|
|
164
|
-
Z --> AA["byte pack<br/><sub>A<<24 | B<<16 | G<<8 | R</sub>"]:::math
|
|
165
|
-
AA --> AB[Uint32 RGBA-LE]:::buf
|
|
166
|
-
|
|
167
|
-
classDef start fill:#e0f2fe,stroke:#0284c7
|
|
168
|
-
classDef regex fill:#fef3c7,stroke:#d97706,color:#78350f
|
|
169
|
-
classDef lut fill:#dcfce7,stroke:#16a34a,color:#14532d
|
|
170
|
-
classDef math fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
|
|
171
|
-
classDef buf fill:#f3f4f6,stroke:#6b7280,color:#1f2937
|
|
172
|
-
classDef run fill:#fecaca,stroke:#dc2626,color:#7f1d1d
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
**Key invariants:**
|
|
176
|
-
|
|
177
|
-
- Every parser writes exactly **3 contiguous Float32 entries** at the offset you supply: `[L, C, H]`. Alpha is returned by value, not stored in the buffer — this keeps the OKLCH stride at 3, which divides cleanly into SoA layouts.
|
|
178
|
-
- Hue is **always** in `[0, 360)` after a write — every parser, every lerp, every conversion canonicalizes.
|
|
179
|
-
- Lightness is **always** clamped to `[0, 1]` after a write.
|
|
180
|
-
- Chroma is **always** clamped to `[0, +∞)` — never negative.
|
|
181
|
-
- LUT output is **always** little-endian RGBA byte order — drop straight into a `Uint32Array` view of `ImageData.data.buffer`. No byte-swapping. No platform check (browsers are universally LE).
|
|
182
|
-
|
|
183
|
-
## 🏗️ Buffer layout
|
|
29
|
+
import {
|
|
30
|
+
parseCSSColor,
|
|
31
|
+
lerpOklchBuffer,
|
|
32
|
+
packOklchBufferToUint32,
|
|
33
|
+
packOklchBufferToUint32P3
|
|
34
|
+
} from '@zakkster/lite-color-engine';
|
|
184
35
|
|
|
185
|
-
|
|
36
|
+
const buf = new Float32Array(3);
|
|
37
|
+
parseCSSColor('color(display-p3 0.9 0.4 0.1)', buf, 0);
|
|
186
38
|
|
|
39
|
+
// Later in render loop (zero GC)
|
|
40
|
+
lerpOklchBuffer(bufA, 0, bufB, 0, t, temp, 0);
|
|
41
|
+
const pixel = packOklchBufferToUint32P3(temp, 0); // or packOklchBufferToUint32
|
|
187
42
|
```
|
|
188
|
-
palette : [ L0 C0 H0 | L1 C1 H1 | L2 C2 H2 | ... ] // float32 stride = 3
|
|
189
|
-
alphas : [ a0 | a1 | a2 | ... ] // float32 stride = 1
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Why a separate alpha array? Because **most colors are opaque**, and packing alpha into the OKLCH stride wastes 25% of every cache line on a `1.0` constant. A separate `Float32Array` (or even `Uint8Array`) lets opaque colors skip alpha entirely, and lets you swap alpha layouts (per-color vs per-instance) without changing the color storage.
|
|
193
|
-
|
|
194
|
-
If you genuinely want premultiplied OKLCHa as a stride-4, you can — the API doesn't stop you, you just write `outBuf[offset + 3] = alpha`. The library functions only touch the first three slots.
|
|
195
|
-
|
|
196
|
-
## 📊 Comparison
|
|
197
|
-
|
|
198
|
-
| | **lite-color-engine** | culori | colord | chroma-js | hand-rolled |
|
|
199
|
-
|---|---|---|---|---|---|
|
|
200
|
-
| Bundle (min+gzip) | **<2 KB** | ~14 KB | ~7 KB | ~14 KB | depends |
|
|
201
|
-
| Hot-path allocations | **0** | several / call | 1 / call | several / call | usually 0 |
|
|
202
|
-
| OKLCH-native runtime | **yes** | yes (multi-space) | via plugin | yes | manual |
|
|
203
|
-
| Float32Array I/O | **yes** | no (objects) | no (objects) | no (objects) | yes |
|
|
204
|
-
| Bake-to-Uint32 LUT | **yes** | no | no | no | manual |
|
|
205
|
-
| Direct `ImageData.data.buffer` write | **yes** (LE-RGBA) | no | no | no | manual |
|
|
206
|
-
| CSS Color Level 4 parsing | **yes** | yes | yes | partial | manual |
|
|
207
|
-
| Canonical hue + lightness clamps | **yes** | yes | partial | partial | manual |
|
|
208
|
-
| SoA / ECS friendly | **yes** | no | no | no | yes |
|
|
209
|
-
| TypeScript types | **yes (full)** | yes | yes | yes | n/a |
|
|
210
|
-
| Framework lock-in | **none** | none | none | none | none |
|
|
211
|
-
|
|
212
|
-
`lite-color-engine` is **not** a replacement for `culori` or `colord`. It does not handle every color space, gamut mapping algorithm, or color difference metric. It does **one** thing: turn a CSS string into a per-frame allocation-free pixel writer.
|
|
213
|
-
|
|
214
|
-
## ⚙️ API
|
|
215
|
-
|
|
216
|
-
### Authoring
|
|
217
|
-
|
|
218
|
-
#### `parseCSSColor(str, outBuf, offset): number`
|
|
219
|
-
|
|
220
|
-
Universal parser. Dispatches by string prefix to the appropriate format-specific parser. Writes `[L, C, H]` at `outBuf[offset..offset+2]`.
|
|
221
|
-
|
|
222
|
-
| Param | Type | Description |
|
|
223
|
-
|---|---|---|
|
|
224
|
-
| `str` | `string` | Any CSS color: hex, named, rgb/rgba, hsl/hsla, oklch, oklab. |
|
|
225
|
-
| `outBuf` | `Float32Array` | Pre-allocated destination. Must have length ≥ `offset + 3`. |
|
|
226
|
-
| `offset` | `number` | Start index of the L, C, H triplet. |
|
|
227
|
-
| **Returns** | `number` | Parsed alpha in `[0, 1]`. Defaults to `1.0` when omitted in the string. |
|
|
228
|
-
|
|
229
|
-
**Throws** if `str` is not a recognized format.
|
|
230
|
-
|
|
231
|
-
Format-specific parsers (`parseHexToBuffer`, `parseRgbToBuffer`, `parseHslToBuffer`, `parseOklchToBuffer`, `parseOklabToBuffer`) have identical signatures and skip the dispatch — call directly when you know the format.
|
|
232
|
-
|
|
233
|
-
### Convert
|
|
234
|
-
|
|
235
|
-
#### `sRgbToOklchBuffer(r, g, b, outBuf, outOffset): void`
|
|
236
|
-
|
|
237
|
-
Raw sRGB-byte → OKLCH conversion. The kernel underneath the parsers. Use when you have integer RGB triplets (e.g. from canvas pixel reads) and want to skip the regex layer.
|
|
238
|
-
|
|
239
|
-
### Runtime
|
|
240
|
-
|
|
241
|
-
#### `lerpOklchBuffer(bufA, offsetA, bufB, offsetB, t, outBuf, outOffset): void`
|
|
242
|
-
|
|
243
|
-
Zero-GC OKLCH interpolation. Hue uses **shortest-path** so `350° → 10°` correctly passes through `0°` instead of taking the long way around. Source and destination buffers may alias.
|
|
244
|
-
|
|
245
|
-
| Param | Type | Description |
|
|
246
|
-
|---|---|---|
|
|
247
|
-
| `t` | `number` | Interpolation factor. Values outside `[0, 1]` extrapolate, then clamp. `NaN` propagates. |
|
|
248
|
-
|
|
249
|
-
#### `packOklchBufferToUint32(buf, offset, alpha?): number`
|
|
250
43
|
|
|
251
|
-
|
|
44
|
+
## Accuracy Tiers
|
|
252
45
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
- warm midtones (browns, golds) shift toward black
|
|
259
|
-
|
|
260
|
-
Use for ephemeral pixels (particle trails, alpha-blended sprite tints) where the round-trip identity isn't observable. **Avoid** for UI tokens, palette previews, or anywhere a designer compares the output to the input.
|
|
261
|
-
|
|
262
|
-
#### `sampleColorLUT(lut, t): number`
|
|
263
|
-
|
|
264
|
-
Zero-GC LUT sampler. Inline `t`-clamp + bitwise-truncated index — no allocations, no function calls beyond this one. Use inside particle systems, fragment-shader-style canvas loops, etc.
|
|
265
|
-
|
|
266
|
-
### LUT
|
|
267
|
-
|
|
268
|
-
#### `bakeGradientToUint32(keyframesBuf, numStops, resolution?, easeFn?, packer?): Uint32Array`
|
|
269
|
-
|
|
270
|
-
Bakes a multi-stop OKLCH gradient into a `Uint32Array` of packed RGBA-LE colors.
|
|
271
|
-
|
|
272
|
-
| Param | Type | Description |
|
|
273
|
-
|---|---|---|
|
|
274
|
-
| `keyframesBuf` | `Float32Array` | Contiguous `[L0, C0, H0, L1, C1, H1, ...]`. |
|
|
275
|
-
| `numStops` | `number` | Stop count; must be `≥ 2`. |
|
|
276
|
-
| `resolution` | `number` | LUT entry count. Default `256`. Must be `≥ 2`. |
|
|
277
|
-
| `easeFn` | `(t) => number` | Optional. Warps `t` before stop selection. Outputs outside `[0, 1]` are clamped. |
|
|
278
|
-
| `packer` | `OklchPackerFn` | Optional. Defaults to the accurate packer. Pass `packOklchBufferToUint32Fast` for ~2× bake throughput. |
|
|
279
|
-
|
|
280
|
-
**Throws** if `numStops < 2` or `resolution < 2`.
|
|
281
|
-
|
|
282
|
-
## ⚡ Performance characteristics
|
|
283
|
-
|
|
284
|
-
| Operation | Cost |
|
|
285
|
-
|---|---|
|
|
286
|
-
| `parseCSSColor()` — named color | regex + map lookup + sRGB→OKLCH (~30 multiplies, 3 cbrt) |
|
|
287
|
-
| `parseCSSColor()` — oklch direct | regex + 3 number parses (no color math) |
|
|
288
|
-
| `lerpOklchBuffer()` | 3 lerps, 1 modulo, 4 comparisons. Zero allocations. |
|
|
289
|
-
| `packOklchBufferToUint32()` | ~30 multiplies, 1 cos, 1 sin, 3 cubes, 3 `pow(_, 1/2.4)`. Zero allocations. |
|
|
290
|
-
| `packOklchBufferToUint32Fast()` | Same minus the 3 `pow`s, plus 3 `sqrt`. Zero allocations. |
|
|
291
|
-
| `sampleColorLUT()` | 2 comparisons, 1 multiply, 1 bitwise truncate, 1 array load. Zero allocations. |
|
|
292
|
-
| `bakeGradientToUint32()` | `resolution × (lerp + pack)`; ~25k ops for a default 256-entry LUT. **One** allocation (the output Uint32Array). |
|
|
293
|
-
| Hot-path allocations | **zero** across the entire runtime API |
|
|
294
|
-
| Module-load allocations | one `Float32Array(3)` scratch (single instance, shared) |
|
|
295
|
-
|
|
296
|
-
## 🛡️ Validation
|
|
297
|
-
|
|
298
|
-
The hot path trusts its inputs. There is no runtime validation in `lerpOklchBuffer`, `packOklchBufferToUint32`, or `sampleColorLUT` — bad data produces predictable bad output, not exceptions.
|
|
299
|
-
|
|
300
|
-
| Input | Behavior |
|
|
301
|
-
|---|---|
|
|
302
|
-
| `t < 0` or `t > 1` in lerp | Extrapolates, then output is clamped (lightness → `[0, 1]`, chroma → `[0, +∞)`) |
|
|
303
|
-
| `t === NaN` | NaN propagates through math; output may be `0` after clamps |
|
|
304
|
-
| Out-of-range OKLCH values | Pack clamps r/g/b channels to `[0, 1]` before encoding |
|
|
305
|
-
| Wrong-length `outBuf` in parser | Throws (`out of bounds`) only if offset is invalid; otherwise writes garbage past the buffer |
|
|
306
|
-
| Invalid CSS string in parser | **Throws** with a descriptive `lite-color-engine: Invalid X` error |
|
|
307
|
-
|
|
308
|
-
The asymmetry is deliberate: the parsers run at init time where exceptions are useful for catching authoring mistakes; the runtime functions run thousands of times per frame where every branch is a tax.
|
|
309
|
-
|
|
310
|
-
## 📦 TypeScript
|
|
311
|
-
|
|
312
|
-
Full TypeScript declarations in `index.d.ts`. Every public function has a single overload — no union types in the API surface, no inference puzzles.
|
|
313
|
-
|
|
314
|
-
```ts
|
|
315
|
-
import {
|
|
316
|
-
parseCSSColor,
|
|
317
|
-
lerpOklchBuffer,
|
|
318
|
-
packOklchBufferToUint32,
|
|
319
|
-
bakeGradientToUint32,
|
|
320
|
-
} from '@zakkster/lite-color-engine';
|
|
321
|
-
import type { OklchPackerFn } from '@zakkster/lite-color-engine';
|
|
322
|
-
|
|
323
|
-
const palette = new Float32Array(12);
|
|
324
|
-
const alpha: number = parseCSSColor('oklch(0.7 0.2 250)', palette, 0);
|
|
325
|
-
```
|
|
46
|
+
| Tier | Function | Use Case | Trade-off |
|
|
47
|
+
|-------------------|----------------------------------|-----------------------------------|--------------------|
|
|
48
|
+
| Fast | `packOklchBufferToUint32Fast` | Particles, high volume | ~10/255 midtone error |
|
|
49
|
+
| Accurate-Clamp | `packOklchBufferToUint32` | Most UI / general use | Hard clamp (hue shift near edge) |
|
|
50
|
+
| Gamut-Mapped | `packOklchBufferToUint32MINDE` | Critical gradients & authoring | ~30x slower, best quality |
|
|
326
51
|
|
|
327
|
-
|
|
52
|
+
> `packOklchBufferToUint32MINDE` ships on the `/gamut` subpath:
|
|
53
|
+
> `import { packOklchBufferToUint32MINDE } from '@zakkster/lite-color-engine/gamut';`
|
|
54
|
+
> The `Fast`, `Accurate-Clamp`, and both `P3` packers come from the main entry.
|
|
328
55
|
|
|
329
|
-
|
|
56
|
+
For Display P3 output, use the `P3` variants.
|
|
330
57
|
|
|
331
|
-
##
|
|
58
|
+
## Documentation
|
|
332
59
|
|
|
333
|
-
|
|
334
|
-
- [`@zakkster/lite-cubic-bezier`](https://www.npmjs.com/package/@zakkster/lite-cubic-bezier) — supplies `cubicBezier(...)` easing functions you can hand directly to `bakeGradientToUint32`.
|
|
335
|
-
- [`@zakkster/lite-ease`](https://www.npmjs.com/package/@zakkster/lite-ease) — the Penner library. Same compatibility — pass any of the 30 functions as the `easeFn` argument.
|
|
60
|
+
See full API in `llms.txt` or the source `src/` files.
|
|
336
61
|
|
|
337
62
|
## License
|
|
338
63
|
|
|
339
|
-
|
|
64
|
+
MIT
|
package/index.d.ts
CHANGED
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
* - H (Hue): [0, 360) degrees
|
|
10
10
|
*
|
|
11
11
|
* Pack output is a 32-bit unsigned integer in **little-endian RGBA** byte
|
|
12
|
-
* order
|
|
12
|
+
* order - drop straight into `new Uint32Array(imageData.data.buffer)`.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
// ============================================================================
|
|
16
|
-
// Authoring (CSS Parsing
|
|
16
|
+
// Authoring (CSS Parsing -> OKLCH Buffer)
|
|
17
17
|
// ============================================================================
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -33,7 +33,7 @@ export function parseHexToBuffer(str: string, outBuf: Float32Array, offset: numb
|
|
|
33
33
|
export function parseOklchToBuffer(str: string, outBuf: Float32Array, offset: number): number;
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Parses a CSS `oklab(...)` string and converts (a, b)
|
|
36
|
+
* Parses a CSS `oklab(...)` string and converts (a, b) -> polar (C, H) before
|
|
37
37
|
* writing the OKLCH triplet.
|
|
38
38
|
* @returns Parsed alpha in `[0, 1]`; defaults to `1.0` when omitted.
|
|
39
39
|
* @throws If the string is not a valid `oklab(...)` literal.
|
|
@@ -61,17 +61,32 @@ export function parseHslToBuffer(str: string, outBuf: Float32Array, offset: numb
|
|
|
61
61
|
* format-specific parser.
|
|
62
62
|
*
|
|
63
63
|
* Supports: 148 named colors, `#RGB[A]`, `#RRGGBB[AA]`, `rgb()`, `rgba()`,
|
|
64
|
-
* `hsl()`, `hsla()`, `oklch()`, `oklab()`.
|
|
64
|
+
* `hsl()`, `hsla()`, `oklch()`, `oklab()`, `color(display-p3 r g b / alpha?)`.
|
|
65
65
|
*
|
|
66
66
|
* Intended for the **authoring/init phase** of a render pipeline. After
|
|
67
67
|
* compilation, work with the resulting `Float32Array` buffers directly via
|
|
68
68
|
* {@link lerpOklchBuffer} and {@link packOklchBufferToUint32}.
|
|
69
69
|
*
|
|
70
|
+
* `color(display-p3 ...)` is opt-in wide-gamut input; it never affects the
|
|
71
|
+
* default sRGB hot path.
|
|
72
|
+
*
|
|
70
73
|
* @returns Parsed alpha in `[0, 1]`.
|
|
71
74
|
* @throws If the string is not a recognized format.
|
|
72
75
|
*/
|
|
73
76
|
export function parseCSSColor(str: string, outBuf: Float32Array, offset: number): number;
|
|
74
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Parses a CSS `color(display-p3 r g b / alpha?)` string into the OKLCH triplet
|
|
80
|
+
* at `outBuf[offset..offset+2]`. Components accept `0-1` numbers or `%`.
|
|
81
|
+
*
|
|
82
|
+
* Wide-gamut authoring entry point: the resulting OKLCH may carry higher chroma
|
|
83
|
+
* than any sRGB-gamut color.
|
|
84
|
+
*
|
|
85
|
+
* @returns Parsed alpha in `[0, 1]`; defaults to `1.0` when omitted.
|
|
86
|
+
* @throws If the string is not a valid `color(display-p3 ...)` literal.
|
|
87
|
+
*/
|
|
88
|
+
export function parseDisplayP3ToBuffer(str: string, outBuf: Float32Array, offset: number): number;
|
|
89
|
+
|
|
75
90
|
// ============================================================================
|
|
76
91
|
// Convert (Raw Math)
|
|
77
92
|
// ============================================================================
|
|
@@ -80,7 +95,7 @@ export function parseCSSColor(str: string, outBuf: Float32Array, offset: number)
|
|
|
80
95
|
* Converts standard sRGB (0-255 byte channels) into a flat OKLCH buffer at
|
|
81
96
|
* `outBuf[outOffset..outOffset+2]`.
|
|
82
97
|
*
|
|
83
|
-
* Implements
|
|
98
|
+
* Implements Bjorn Ottosson's OKLab (2020) algorithm with defenses against
|
|
84
99
|
* NaN-propagating negative cube-root inputs and a strict lightness clamp.
|
|
85
100
|
*/
|
|
86
101
|
export function sRgbToOklchBuffer(
|
|
@@ -91,6 +106,30 @@ export function sRgbToOklchBuffer(
|
|
|
91
106
|
outOffset: number
|
|
92
107
|
): void;
|
|
93
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Converts Display P3 (0-255 byte channels) into a flat OKLCH buffer, using the
|
|
111
|
+
* P3 primaries via a P3 -> XYZ -> LMS -> OKLab pipeline. Lets colors outside the
|
|
112
|
+
* sRGB gamut be represented with their true (higher) chroma.
|
|
113
|
+
*
|
|
114
|
+
* Same OKLab defenses and lightness clamp as {@link sRgbToOklchBuffer}.
|
|
115
|
+
*/
|
|
116
|
+
export function displayP3ToOklchBuffer(
|
|
117
|
+
r: number,
|
|
118
|
+
g: number,
|
|
119
|
+
b: number,
|
|
120
|
+
outBuf: Float32Array,
|
|
121
|
+
outOffset: number
|
|
122
|
+
): void;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Inverse of {@link displayP3ToOklchBuffer}: converts an OKLCH triplet to
|
|
126
|
+
* **linear-light** Display P3 and writes `[r, g, b]` into `out` (length >= 3).
|
|
127
|
+
*
|
|
128
|
+
* Output channels may fall outside `[0, 1]` for colors beyond the P3 gamut;
|
|
129
|
+
* clamp or gamut-map before encoding. Used by the P3 packers.
|
|
130
|
+
*/
|
|
131
|
+
export function oklchToLinearP3(L: number, C: number, H: number, out: Float32Array): void;
|
|
132
|
+
|
|
94
133
|
// ============================================================================
|
|
95
134
|
// Runtime (Zero-GC Hot Path)
|
|
96
135
|
// ============================================================================
|
|
@@ -100,7 +139,7 @@ export function sRgbToOklchBuffer(
|
|
|
100
139
|
*
|
|
101
140
|
* Hue uses **shortest-path** interpolation (`lerpAngle`) so gradients never
|
|
102
141
|
* wrap the long way around the wheel. Lightness is hard-clamped to `[0, 1]`
|
|
103
|
-
* and chroma to `[0,
|
|
142
|
+
* and chroma to `[0, +inf)`. Hue is canonicalized to `[0, 360)`.
|
|
104
143
|
*
|
|
105
144
|
* Source and destination buffers may alias (same buffer, different offsets).
|
|
106
145
|
*
|
|
@@ -118,11 +157,11 @@ export function lerpOklchBuffer(
|
|
|
118
157
|
|
|
119
158
|
/**
|
|
120
159
|
* Encodes an OKLCH triplet to a 32-bit unsigned integer in **little-endian
|
|
121
|
-
* RGBA** byte order
|
|
160
|
+
* RGBA** byte order - the format consumed directly by `Canvas ImageData` via
|
|
122
161
|
* a `Uint32Array` view.
|
|
123
162
|
*
|
|
124
163
|
* Uses the proper sRGB transfer function (`pow(c, 1/2.4)` branch).
|
|
125
|
-
* Round-tripping `sRGB
|
|
164
|
+
* Round-tripping `sRGB -> OKLCH -> here` recovers the original byte exactly
|
|
126
165
|
* (within rounding).
|
|
127
166
|
*
|
|
128
167
|
* For a faster `Math.sqrt`-approximated sibling (~2x throughput, ~10/255
|
|
@@ -157,6 +196,39 @@ export function packOklchBufferToUint32Fast(
|
|
|
157
196
|
alpha?: number
|
|
158
197
|
): number;
|
|
159
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Encodes an OKLCH triplet to a 32-bit unsigned integer in little-endian RGBA
|
|
201
|
+
* byte order for a **Display P3** canvas context
|
|
202
|
+
* (`getContext('2d', { colorSpace: 'display-p3' })`).
|
|
203
|
+
*
|
|
204
|
+
* Colors that are out of sRGB but inside P3 are preserved at their true
|
|
205
|
+
* saturation instead of being clamped down. Transfer function is identical to
|
|
206
|
+
* sRGB (IEC 61966-2-1).
|
|
207
|
+
*
|
|
208
|
+
* @param alpha Alpha in `[0, 1]`. Default `1.0`.
|
|
209
|
+
* @returns 32-bit unsigned integer in little-endian RGBA byte order.
|
|
210
|
+
*/
|
|
211
|
+
export function packOklchBufferToUint32P3(
|
|
212
|
+
buf: Float32Array,
|
|
213
|
+
offset: number,
|
|
214
|
+
alpha?: number
|
|
215
|
+
): number;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Faster, less accurate variant of {@link packOklchBufferToUint32P3}: uses a
|
|
219
|
+
* `Math.sqrt` transfer approximation (same trade-off as
|
|
220
|
+
* {@link packOklchBufferToUint32Fast}). For high-volume P3 particle/gradient
|
|
221
|
+
* baking where exact round-trip is not required.
|
|
222
|
+
*
|
|
223
|
+
* @param alpha Alpha in `[0, 1]`. Default `1.0`.
|
|
224
|
+
* @returns 32-bit unsigned integer in little-endian RGBA byte order.
|
|
225
|
+
*/
|
|
226
|
+
export function packOklchBufferToUint32P3Fast(
|
|
227
|
+
buf: Float32Array,
|
|
228
|
+
offset: number,
|
|
229
|
+
alpha?: number
|
|
230
|
+
): number;
|
|
231
|
+
|
|
160
232
|
/**
|
|
161
233
|
* Zero-GC sampler for a baked LUT. Inline `t`-clamp + bitwise-truncated index.
|
|
162
234
|
*
|
|
@@ -176,7 +248,7 @@ export type OklchPackerFn = (buf: Float32Array, offset: number, alpha?: number)
|
|
|
176
248
|
/**
|
|
177
249
|
* Bakes a multi-stop OKLCH gradient into a ready-to-render `Uint32Array`.
|
|
178
250
|
*
|
|
179
|
-
* Output bytes are little-endian RGBA
|
|
251
|
+
* Output bytes are little-endian RGBA - drop into a `Uint32Array` view of
|
|
180
252
|
* `Canvas ImageData`, or upload as `RGBA / UNSIGNED_BYTE` via `texImage2D`.
|
|
181
253
|
*
|
|
182
254
|
* Stops are evenly distributed: stop _i_ is at `i / (numStops - 1)`. The
|
|
@@ -200,3 +272,23 @@ export function bakeGradientToUint32(
|
|
|
200
272
|
easeFn?: (t: number) => number,
|
|
201
273
|
packer?: OklchPackerFn
|
|
202
274
|
): Uint32Array;
|
|
275
|
+
|
|
276
|
+
// ============================================================================
|
|
277
|
+
// Difference (deltaE-OK)
|
|
278
|
+
// ============================================================================
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* deltaE-OK color difference: Euclidean distance in OKLab between two buffered
|
|
282
|
+
* OKLCH colors. Zero-allocation.
|
|
283
|
+
*
|
|
284
|
+
* Typical scale: `0.02` indistinguishable, `0.05` subtle, `0.15+` unambiguous.
|
|
285
|
+
*
|
|
286
|
+
* Use for palette dedupe, nearest-color lookup, contrast checks, and the
|
|
287
|
+
* CVD-audit workflow in `lite-hueforge/colorways`.
|
|
288
|
+
*/
|
|
289
|
+
export function deltaEOK(
|
|
290
|
+
bufA: Float32Array,
|
|
291
|
+
offsetA: number,
|
|
292
|
+
bufB: Float32Array,
|
|
293
|
+
offsetB: number
|
|
294
|
+
): number;
|
package/index.js
CHANGED
|
@@ -4,7 +4,8 @@ export {
|
|
|
4
4
|
parseHslToBuffer,
|
|
5
5
|
parseOklabToBuffer,
|
|
6
6
|
parseOklchToBuffer,
|
|
7
|
-
parseRgbToBuffer
|
|
7
|
+
parseRgbToBuffer,
|
|
8
|
+
parseDisplayP3ToBuffer
|
|
8
9
|
} from './src/authoring.js';
|
|
9
10
|
|
|
10
11
|
export { bakeGradientToUint32 } from './src/lut.js';
|
|
@@ -13,7 +14,15 @@ export {
|
|
|
13
14
|
lerpOklchBuffer,
|
|
14
15
|
packOklchBufferToUint32,
|
|
15
16
|
packOklchBufferToUint32Fast,
|
|
17
|
+
packOklchBufferToUint32P3,
|
|
18
|
+
packOklchBufferToUint32P3Fast,
|
|
16
19
|
sampleColorLUT
|
|
17
20
|
} from './src/runtime.js';
|
|
18
21
|
|
|
19
|
-
export {
|
|
22
|
+
export {
|
|
23
|
+
sRgbToOklchBuffer,
|
|
24
|
+
displayP3ToOklchBuffer,
|
|
25
|
+
oklchToLinearP3
|
|
26
|
+
} from './src/convert.js';
|
|
27
|
+
|
|
28
|
+
export { deltaEOK } from './src/delta.js';
|