@zakkster/lite-color-engine 1.0.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/llms.txt ADDED
@@ -0,0 +1,201 @@
1
+ # @zakkster/lite-color-engine — LLM Reference
2
+
3
+ > Zero-GC, data-oriented OKLCH color engine for WebGL/Canvas rendering pipelines.
4
+ > Buffer-and-offset API. Three layers: authoring (CSS parsing), LUT (gradient
5
+ > baking), runtime (zero-allocation hot path).
6
+
7
+ ## Identity
8
+
9
+ - **Package name:** `@zakkster/lite-color-engine`
10
+ - **Module type:** ESM only (`"type": "module"`)
11
+ - **Peer dependency:** `@zakkster/lite-lerp` (provides `lerp`, `lerpAngle`)
12
+ - **Bundle:** sub-2KB min+gzip
13
+ - **Side effects:** none (`"sideEffects": false`)
14
+ - **Node:** `>=18`
15
+
16
+ ## Mental model
17
+
18
+ Every color in this library is exactly **3 contiguous Float32 entries** in a `Float32Array`: `[L, C, H]`.
19
+
20
+ - `L` (Lightness): `[0, 1]`, perceptually uniform.
21
+ - `C` (Chroma): `[0, ~0.4]` in practice, mathematically unbounded but only a small chroma range maps to displayable sRGB.
22
+ - `H` (Hue): `[0, 360)` degrees. Always canonicalized after every write.
23
+
24
+ Alpha is **not** stored in the OKLCH buffer. Parsers return alpha as a `number`. If you need alpha alongside color, store it in a sibling `Float32Array` or add a fourth slot yourself (the library only reads the first three).
25
+
26
+ The output of the pack functions is a 32-bit unsigned integer in **little-endian RGBA byte order** — exactly what `new Uint32Array(imageData.data.buffer)` consumes. Browsers are universally little-endian; do not byte-swap.
27
+
28
+ ## Public API surface
29
+
30
+ ### Authoring (init-time, allocations OK)
31
+
32
+ ```js
33
+ import {
34
+ parseCSSColor,
35
+ parseHexToBuffer,
36
+ parseRgbToBuffer,
37
+ parseHslToBuffer,
38
+ parseOklchToBuffer,
39
+ parseOklabToBuffer,
40
+ } from '@zakkster/lite-color-engine';
41
+ ```
42
+
43
+ All five format-specific parsers and `parseCSSColor` have the same signature:
44
+
45
+ ```ts
46
+ (str: string, outBuf: Float32Array, offset: number) => number /* alpha [0,1] */
47
+ ```
48
+
49
+ `parseCSSColor` dispatches by string prefix. Use it unless you know the format and care about shaving the dispatch.
50
+
51
+ Throws on invalid input.
52
+
53
+ ### Convert (raw math)
54
+
55
+ ```js
56
+ import { sRgbToOklchBuffer } from '@zakkster/lite-color-engine';
57
+
58
+ sRgbToOklchBuffer(r, g, b, outBuf, outOffset);
59
+ // r, g, b are 0-255 bytes. Writes 3 floats to outBuf.
60
+ ```
61
+
62
+ ### Runtime (zero allocations)
63
+
64
+ ```js
65
+ import {
66
+ lerpOklchBuffer,
67
+ packOklchBufferToUint32,
68
+ packOklchBufferToUint32Fast,
69
+ sampleColorLUT,
70
+ } from '@zakkster/lite-color-engine';
71
+ ```
72
+
73
+ Signatures:
74
+
75
+ ```ts
76
+ lerpOklchBuffer(
77
+ bufA: Float32Array, offsetA: number,
78
+ bufB: Float32Array, offsetB: number,
79
+ t: number,
80
+ outBuf: Float32Array, outOffset: number
81
+ ): void;
82
+
83
+ packOklchBufferToUint32(buf: Float32Array, offset: number, alpha?: number): number;
84
+ packOklchBufferToUint32Fast(buf: Float32Array, offset: number, alpha?: number): number;
85
+
86
+ sampleColorLUT(lut: Uint32Array, t: number): number;
87
+ ```
88
+
89
+ ### LUT (gradient baking)
90
+
91
+ ```js
92
+ import { bakeGradientToUint32 } from '@zakkster/lite-color-engine';
93
+
94
+ bakeGradientToUint32(
95
+ keyframesBuf: Float32Array, // [L0,C0,H0, L1,C1,H1, ...]
96
+ numStops: number, // >= 2
97
+ resolution?: number, // default 256, >= 2
98
+ easeFn?: (t: number) => number,
99
+ packer?: (buf: Float32Array, offset: number, alpha?: number) => number
100
+ ): Uint32Array;
101
+ ```
102
+
103
+ ## Integration patterns
104
+
105
+ ### Pattern 1 — palette load
106
+
107
+ ```js
108
+ const palette = new Float32Array(3 * COLOR_COUNT);
109
+ const alphas = new Float32Array(COLOR_COUNT);
110
+ for (let i = 0; i < cssStrings.length; i++) {
111
+ alphas[i] = parseCSSColor(cssStrings[i], palette, i * 3);
112
+ }
113
+ // palette is now your color storage for the program lifetime.
114
+ ```
115
+
116
+ ### Pattern 2 — per-frame tween
117
+
118
+ ```js
119
+ const scratch = new Float32Array(3); // module-level, allocated ONCE
120
+
121
+ function frame(t) {
122
+ lerpOklchBuffer(palette, 0, palette, 3, t, scratch, 0);
123
+ return packOklchBufferToUint32(scratch, 0, 1.0);
124
+ }
125
+ ```
126
+
127
+ ### Pattern 3 — gradient LUT for a particle system
128
+
129
+ ```js
130
+ const stops = new Float32Array(9);
131
+ parseCSSColor('#ff0000', stops, 0);
132
+ parseCSSColor('#ffd700', stops, 3);
133
+ parseCSSColor('#00aaff', stops, 6);
134
+
135
+ const lut = bakeGradientToUint32(stops, 3, 256);
136
+
137
+ // In the render loop:
138
+ for (let i = 0; i < particles.count; i++) {
139
+ const lifeT = particles.life[i];
140
+ pixels32[particles.pixelIdx[i]] = sampleColorLUT(lut, lifeT);
141
+ }
142
+ ```
143
+
144
+ ### Pattern 4 — direct ImageData write
145
+
146
+ ```js
147
+ const imgData = ctx.createImageData(width, height);
148
+ const pixels = new Uint32Array(imgData.data.buffer); // RGBA-LE view
149
+
150
+ for (let i = 0; i < pixels.length; i++) {
151
+ pixels[i] = sampleColorLUT(lut, i / pixels.length);
152
+ }
153
+ ctx.putImageData(imgData, 0, 0);
154
+ ```
155
+
156
+ ## Invariants the library guarantees
157
+
158
+ - After any successful write, `outBuf[offset + 2]` (hue) is in `[0, 360)`.
159
+ - After any successful write, `outBuf[offset]` (lightness) is in `[0, 1]`.
160
+ - After any successful write, `outBuf[offset + 1]` (chroma) is in `[0, +∞)`.
161
+ - `packOklchBufferToUint32` and `packOklchBufferToUint32Fast` return values that are **always** non-negative integers (they apply `>>> 0`).
162
+ - Hue interpolation is **always** shortest-path (uses `lerpAngle` from `@zakkster/lite-lerp`).
163
+ - Bake output bytes are **always** little-endian RGBA. Same on every browser.
164
+
165
+ ## Common pitfalls
166
+
167
+ - **Mistaking the byte order.** The pack functions return little-endian RGBA, so on x86 the low byte is R. If you mask with `(packed >> 24) & 0xff` expecting red, you'll get alpha. Use `packed & 0xff` for red.
168
+
169
+ - **Storing per-color objects anyway.** The library is explicitly buffer-first. If you find yourself writing `{ l, c, h }` wrappers, you've reintroduced GC pressure; just use the offset-based API.
170
+
171
+ - **Choosing the Fast packer for UI.** `packOklchBufferToUint32Fast` darkens mid-tones by ~10/255 and shifts warm midtones toward black. Use it for transient pixels (particle trails, alpha-blended sprites). Use the accurate variant for anything a designer or QA might compare against the source color.
172
+
173
+ - **Using non-monotonic eases with the LUT.** `bakeGradientToUint32` clamps eased `t` to `[0, 1]`. Overshoot easings (`easeOutBack`, etc.) get silently capped because a fixed-resolution LUT cannot represent overshoot. If you need overshoot, drive the eased `t` at sample time with `sampleColorLUT(lut, easeFn(t))` instead.
174
+
175
+ - **Forgetting alpha is returned, not written.** Parsers return alpha. They do not write it to the buffer. If you mix parsers and hand-written buffers, decide on an alpha layout up front (sibling array, stride-4, or per-instance) and stick with it.
176
+
177
+ ## What this library is NOT
178
+
179
+ - It is not a general-purpose color manipulation library. No `darken`, `lighten`, `mix`, `saturate`, `complement`, etc. methods. Compose your own from the buffer math primitives.
180
+ - It is not a gamut mapper. Pack uses a hard clamp; out-of-gamut OKLCH values are clipped per-channel, not mapped via MINDE chroma reduction. Proper gamut mapping is on the v1.1 roadmap.
181
+ - It is not a CSS parser for everything. It does CSS Color Level 4 colors. It does not handle `color()`, `color-mix()`, relative color syntax, or system colors.
182
+ - It is not async. There are no Promises. Every function is synchronous and side-effect-free (writes only to its `outBuf` argument).
183
+
184
+ ## Performance budget
185
+
186
+ | Operation | Wall-clock estimate (modern V8, 2024 laptop) |
187
+ |---|---|
188
+ | `parseCSSColor` | ~1µs per call (not the hot path; numbers vary by format) |
189
+ | `lerpOklchBuffer` | ~50ns per call |
190
+ | `packOklchBufferToUint32` | ~150ns per call |
191
+ | `packOklchBufferToUint32Fast` | ~80ns per call |
192
+ | `sampleColorLUT` | ~10ns per call |
193
+ | `bakeGradientToUint32(..., 256)` | ~50µs total |
194
+
195
+ Absolute numbers depend on hardware. Relative ratios are stable.
196
+
197
+ ## Versioning
198
+
199
+ `1.0.x` — the locked public API surface above. No breaking changes within the major. Patch releases for bug fixes; minor releases may **add** functions but not remove or rename.
200
+
201
+ `1.1` (planned) — see `ROADMAP.md` if present, or the Pairs-well-with section of the README.
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@zakkster/lite-color-engine",
3
+ "version": "1.0.0",
4
+ "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
5
+ "description": "Zero-GC, data-oriented OKLCH color engine and WebGL/Canvas buffer pipeline.",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "index.js",
9
+ "module": "index.js",
10
+ "types": "index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "index.d.ts",
14
+ "import": "index.js",
15
+ "default": "index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "src/",
20
+ "index.js",
21
+ "index.d.ts",
22
+ "README.md",
23
+ "llms.txt",
24
+ "LICENSE.txt"
25
+ ],
26
+ "keywords": [
27
+ "color",
28
+ "oklch",
29
+ "oklab",
30
+ "zero-gc",
31
+ "webgl",
32
+ "canvas",
33
+ "lut",
34
+ "interpolation",
35
+ "gradient",
36
+ "color-space",
37
+ "dod",
38
+ "data-oriented",
39
+ "high-performance",
40
+ "game-dev"
41
+ ],
42
+ "scripts": {
43
+ "test": "vitest run"
44
+ },
45
+ "dependencies": {
46
+ "@zakkster/lite-lerp": "^1.0.0"
47
+ },
48
+ "devDependencies": {
49
+ "vitest": "^1.0.0"
50
+ },
51
+ "license": "Mit",
52
+ "homepage": "https://github.com/PeshoVurtoleta/lite-color-engine#readme",
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/PeshoVurtoleta/lite-color-engine.git"
56
+ },
57
+ "bugs": {
58
+ "url": "https://github.com/PeshoVurtoleta/lite-color-engine/issues",
59
+ "email": "shinikchiev@yahoo.com"
60
+ },
61
+ "engines": {
62
+ "node": ">=18"
63
+ }
64
+ }
@@ -0,0 +1,262 @@
1
+ import { sRgbToOklchBuffer } from './convert.js';
2
+
3
+ const RAD_TO_DEG = 180 / Math.PI;
4
+
5
+ // ============================================================================
6
+ // 1. UTILITIES
7
+ // ============================================================================
8
+
9
+ /**
10
+ * Parses a CSS numeric token. `'50%'` -> `0.5 * max`. `'50'` -> `50`. `''` / undefined -> undefined.
11
+ * @internal
12
+ */
13
+ const parseVal = (val, max = 1) => {
14
+ if (val === undefined || val === null || val === '') return undefined;
15
+ return val.endsWith('%') ? (parseFloat(val) / 100) * max : parseFloat(val);
16
+ };
17
+
18
+ /**
19
+ * Parses a CSS hue token, accepting `deg` (default), `rad`, and `turn` units.
20
+ * Result is canonicalized to [0, 360).
21
+ * @internal
22
+ */
23
+ const parseHue = (val) => {
24
+ let h = parseFloat(val);
25
+ if (val.includes('rad')) h *= RAD_TO_DEG;
26
+ if (val.includes('turn')) h *= 360;
27
+ const m = h % 360;
28
+ return m < 0 ? m + 360 : m;
29
+ };
30
+
31
+ /**
32
+ * Reference HSL -> sRGB conversion. Used as a stepping stone before sRGB -> OKLCH.
33
+ * @internal
34
+ */
35
+ const hslToRgb = (h, s, l) => {
36
+ h /= 360;
37
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
38
+ const p = 2 * l - q;
39
+ const hue2rgb = (p, q, t) => {
40
+ if (t < 0) t += 1;
41
+ if (t > 1) t -= 1;
42
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
43
+ if (t < 1 / 2) return q;
44
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
45
+ return p;
46
+ };
47
+ return [
48
+ (hue2rgb(p, q, h + 1 / 3) * 255 + 0.5) | 0,
49
+ (hue2rgb(p, q, h) * 255 + 0.5) | 0,
50
+ (hue2rgb(p, q, h - 1 / 3) * 255 + 0.5) | 0
51
+ ];
52
+ };
53
+
54
+ // ============================================================================
55
+ // 2. REGEX DICTIONARY (CSS Color Level 4 Permissive)
56
+ // ============================================================================
57
+
58
+ const HEX_REGEX = /^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i;
59
+ const SHORT_HEX_REGEX = /^#([a-f\d])([a-f\d])([a-f\d])([a-f\d])?$/i;
60
+ const OKLCH_REGEX = /^oklch\(\s*([\d.%]+)\s+([\d.%]+)\s+([\d.%a-z]+)(?:\s*\/\s*([\d.%]+))?\s*\)$/i;
61
+ const OKLAB_REGEX = /^oklab\(\s*([\d.%]+)[,\s]+([\d.-]+)[,\s]+([\d.-]+)(?:[,\s/]+([\d.%]+))?\s*\)$/i;
62
+ const RGB_REGEX = /^rgba?\(\s*([\d.%]+)[,\s]+([\d.%]+)[,\s]+([\d.%]+)(?:(?:,|\s*\/\s*)\s*([\d.%]+))?\s*\)$/i;
63
+ const HSL_REGEX = /^hsla?\(\s*([\d.%a-z]+)[,\s]+([\d.%]+)[,\s]+([\d.%]+)(?:(?:,|\s*\/\s*)\s*([\d.%]+))?\s*\)$/i;
64
+
65
+ // ============================================================================
66
+ // 3. NAMED COLORS FAST-PATH
67
+ // ============================================================================
68
+
69
+ /** CSS Color Module Level 4 named-color table. Single allocation at module load. */
70
+ const NAMED_COLORS = {
71
+ aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4',
72
+ azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000',
73
+ blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a',
74
+ burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00', chocolate: '#d2691e',
75
+ coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c',
76
+ cyan: '#00ffff', darkblue: '#00008b', darkcyan: '#008b8b', darkgoldenrod: '#b8860b',
77
+ darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b',
78
+ darkmagenta: '#8b008b', darkolivegreen: '#556b2f', darkorange: '#ff8c00', darkorchid: '#9932cc',
79
+ darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f', darkslateblue: '#483d8b',
80
+ darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', darkturquoise: '#00ced1', darkviolet: '#9400d3',
81
+ deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
82
+ dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22',
83
+ fuchsia: '#ff00ff', gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700',
84
+ goldenrod: '#daa520', gray: '#808080', green: '#008000', greenyellow: '#adff2f',
85
+ grey: '#808080', honeydew: '#f0fff0', hotpink: '#ff69b4', indianred: '#cd5c5c',
86
+ indigo: '#4b0082', ivory: '#fffff0', khaki: '#f0e68c', lavender: '#e6e6fa',
87
+ lavenderblush: '#fff0f5', lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6',
88
+ lightcoral: '#f08080', lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3',
89
+ lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1', lightsalmon: '#ffa07a',
90
+ lightseagreen: '#20b2aa', lightskyblue: '#87cefa', lightslategray: '#778899', lightslategrey: '#778899',
91
+ lightsteelblue: '#b0c4de', lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32',
92
+ linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa',
93
+ mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', mediumseagreen: '#3cb371',
94
+ mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc', mediumvioletred: '#c71585',
95
+ midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5',
96
+ navajowhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000',
97
+ olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6',
98
+ palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093',
99
+ papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb',
100
+ plum: '#dda0dd', powderblue: '#b0e0e6', purple: '#800080', rebeccapurple: '#663399',
101
+ red: '#ff0000', rosybrown: '#bc8f8f', royalblue: '#4169e1', saddlebrown: '#8b4513',
102
+ salmon: '#fa8072', sandybrown: '#f4a460', seagreen: '#2e8b57', seashell: '#fff5ee',
103
+ sienna: '#a0522d', silver: '#c0c0c0', skyblue: '#87ceeb', slateblue: '#6a5acd',
104
+ slategray: '#708090', slategrey: '#708090', snow: '#fffafa', springgreen: '#00ff7f',
105
+ steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8',
106
+ tomato: '#ff6347', turquoise: '#40e0d0', violet: '#ee82ee', wheat: '#f5deb3',
107
+ white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32',
108
+ transparent: '#00000000'
109
+ };
110
+
111
+ // ============================================================================
112
+ // 4. PARSERS
113
+ // ============================================================================
114
+
115
+ /**
116
+ * Parses a CSS hex string (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`) into an
117
+ * OKLCH triplet at `outBuf[offset..offset+2]`.
118
+ *
119
+ * @param {string} str - The hex string (with leading `#`)
120
+ * @param {Float32Array} outBuf - Pre-allocated destination
121
+ * @param {number} offset - Start index of the L, C, H triplet
122
+ * @returns {number} Parsed alpha in [0, 1]; defaults to `1.0` when omitted.
123
+ * @throws If `str` is not a valid hex literal.
124
+ */
125
+ export const parseHexToBuffer = (str, outBuf, offset) => {
126
+ let match = str.match(HEX_REGEX);
127
+ if (!match) {
128
+ match = str.match(SHORT_HEX_REGEX);
129
+ if (!match) throw new Error(`lite-color-engine: Invalid HEX "${str}"`);
130
+ match = [
131
+ match[0], match[1] + match[1], match[2] + match[2],
132
+ match[3] + match[3], match[4] ? match[4] + match[4] : undefined
133
+ ];
134
+ }
135
+ sRgbToOklchBuffer(parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16), outBuf, offset);
136
+ return match[4] ? parseInt(match[4], 16) / 255 : 1.0;
137
+ };
138
+
139
+ /**
140
+ * Parses a CSS `oklch()` string directly into an OKLCH triplet — no color-space
141
+ * conversion needed.
142
+ *
143
+ * Accepts the modern slash-alpha form: `oklch(60% 0.15 250 / 0.5)`. Hue may
144
+ * carry a `deg`, `rad`, or `turn` unit.
145
+ *
146
+ * @param {string} str - The `oklch(...)` string
147
+ * @param {Float32Array} outBuf - Pre-allocated destination
148
+ * @param {number} offset - Start index of the L, C, H triplet
149
+ * @returns {number} Parsed alpha in [0, 1]; defaults to `1.0` when omitted.
150
+ * @throws If `str` does not match `oklch(...)`.
151
+ */
152
+ export const parseOklchToBuffer = (str, outBuf, offset) => {
153
+ const match = str.match(OKLCH_REGEX);
154
+ if (!match) throw new Error(`lite-color-engine: Invalid OKLCH "${str}"`);
155
+ outBuf[offset] = parseVal(match[1], 1);
156
+ outBuf[offset + 1] = parseVal(match[2], 1);
157
+ outBuf[offset + 2] = parseHue(match[3]);
158
+ return parseVal(match[4], 1) ?? 1.0;
159
+ };
160
+
161
+ /**
162
+ * Parses a CSS `oklab()` string and converts (a, b) -> (C, H) via polar math
163
+ * before writing the OKLCH triplet.
164
+ *
165
+ * @param {string} str - The `oklab(...)` string
166
+ * @param {Float32Array} outBuf - Pre-allocated destination
167
+ * @param {number} offset - Start index of the L, C, H triplet
168
+ * @returns {number} Parsed alpha in [0, 1]; defaults to `1.0` when omitted.
169
+ * @throws If `str` does not match `oklab(...)`.
170
+ */
171
+ export const parseOklabToBuffer = (str, outBuf, offset) => {
172
+ const match = str.match(OKLAB_REGEX);
173
+ if (!match) throw new Error(`lite-color-engine: Invalid OKLAB "${str}"`);
174
+ const l = parseVal(match[1], 1);
175
+ const a = parseFloat(match[2]);
176
+ const b = parseFloat(match[3]);
177
+ const c = Math.sqrt(a * a + b * b);
178
+
179
+ outBuf[offset] = l < 0 ? 0 : (l > 1 ? 1 : l);
180
+ outBuf[offset + 1] = c || 0;
181
+ let h = Math.atan2(b, a) * RAD_TO_DEG;
182
+ if (h < 0) h += 360;
183
+ outBuf[offset + 2] = h;
184
+
185
+ return parseVal(match[4], 1) ?? 1.0;
186
+ };
187
+
188
+ /**
189
+ * Parses a CSS `rgb()` / `rgba()` string and converts to OKLCH.
190
+ *
191
+ * Accepts comma-separated and modern slash-alpha forms, with `0-255` or `%`
192
+ * channel values. Conversion uses the proper sRGB linearization.
193
+ *
194
+ * @param {string} str - The `rgb(...)` / `rgba(...)` string
195
+ * @param {Float32Array} outBuf - Pre-allocated destination
196
+ * @param {number} offset - Start index of the L, C, H triplet
197
+ * @returns {number} Parsed alpha in [0, 1]; defaults to `1.0` when omitted.
198
+ * @throws If `str` does not match `rgb(...)` / `rgba(...)`.
199
+ */
200
+ export const parseRgbToBuffer = (str, outBuf, offset) => {
201
+ const match = str.match(RGB_REGEX);
202
+ if (!match) throw new Error(`lite-color-engine: Invalid RGB "${str}"`);
203
+ sRgbToOklchBuffer(parseVal(match[1], 255), parseVal(match[2], 255), parseVal(match[3], 255), outBuf, offset);
204
+ return parseVal(match[4], 1) ?? 1.0;
205
+ };
206
+
207
+ /**
208
+ * Parses a CSS `hsl()` / `hsla()` string, converts via sRGB, and writes the
209
+ * OKLCH triplet.
210
+ *
211
+ * @param {string} str - The `hsl(...)` / `hsla(...)` string
212
+ * @param {Float32Array} outBuf - Pre-allocated destination
213
+ * @param {number} offset - Start index of the L, C, H triplet
214
+ * @returns {number} Parsed alpha in [0, 1]; defaults to `1.0` when omitted.
215
+ * @throws If `str` does not match `hsl(...)` / `hsla(...)`.
216
+ */
217
+ export const parseHslToBuffer = (str, outBuf, offset) => {
218
+ const match = str.match(HSL_REGEX);
219
+ if (!match) throw new Error(`lite-color-engine: Invalid HSL "${str}"`);
220
+ const [r, g, b] = hslToRgb(parseHue(match[1]), parseVal(match[2], 1), parseVal(match[3], 1));
221
+ sRgbToOklchBuffer(r, g, b, outBuf, offset);
222
+ return parseVal(match[4], 1) ?? 1.0;
223
+ };
224
+
225
+ // ============================================================================
226
+ // 5. MASTER SWITCHBOARD
227
+ // ============================================================================
228
+
229
+ /**
230
+ * Universal CSS color parser. Dispatches by string prefix to the appropriate
231
+ * format-specific parser and writes an OKLCH triplet.
232
+ *
233
+ * Supports: named colors, `#RGB[A]`, `#RRGGBB[AA]`, `rgb()` / `rgba()`,
234
+ * `hsl()` / `hsla()`, `oklch()`, `oklab()`.
235
+ *
236
+ * Intended for the **authoring/init phase** of a render pipeline (level loads,
237
+ * theme parsing, gradient compilation). After that, work with the resulting
238
+ * `Float32Array` buffer directly via {@link lerpOklchBuffer} and
239
+ * {@link packOklchBufferToUint32}.
240
+ *
241
+ * @param {string} str - The CSS color string
242
+ * @param {Float32Array} outBuf - Pre-allocated destination
243
+ * @param {number} offset - Start index of the L, C, H triplet
244
+ * @returns {number} Parsed alpha in [0, 1]
245
+ * @throws If `str` is not a recognized format
246
+ */
247
+ export const parseCSSColor = (str, outBuf, offset) => {
248
+ const cleanStr = str.trim().toLowerCase();
249
+
250
+ const named = NAMED_COLORS[cleanStr];
251
+ if (named) {
252
+ return parseHexToBuffer(named, outBuf, offset);
253
+ }
254
+
255
+ if (cleanStr.startsWith('#')) return parseHexToBuffer(cleanStr, outBuf, offset);
256
+ if (cleanStr.startsWith('oklch')) return parseOklchToBuffer(cleanStr, outBuf, offset);
257
+ if (cleanStr.startsWith('oklab')) return parseOklabToBuffer(cleanStr, outBuf, offset);
258
+ if (cleanStr.startsWith('rgb')) return parseRgbToBuffer(cleanStr, outBuf, offset);
259
+ if (cleanStr.startsWith('hsl')) return parseHslToBuffer(cleanStr, outBuf, offset);
260
+
261
+ throw new Error(`lite-color-engine: Unsupported color format "${str}".`);
262
+ };
package/src/convert.js ADDED
@@ -0,0 +1,58 @@
1
+ const RAD_TO_DEG = 180 / Math.PI;
2
+
3
+ /**
4
+ * IEC 61966-2-1 sRGB linearization.
5
+ * @param {number} c - Normalized non-linear sRGB channel in [0, 1]
6
+ * @returns {number} Linear-light value in [0, 1]
7
+ * @internal
8
+ */
9
+ const linearize = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
10
+
11
+ /**
12
+ * Converts standard sRGB (0-255 byte channels) into a flat OKLCH buffer.
13
+ *
14
+ * Defends against negative floating-point cube roots (numerical noise from the
15
+ * matrix multiply) and hard-clamps output lightness to [0, 1]. Hue is
16
+ * canonicalized to [0, 360).
17
+ *
18
+ * Algorithm: Björn Ottosson's OKLab (2020) — sRGB -> linear sRGB -> LMS ->
19
+ * non-linear LMS (cube root) -> OKLab -> polar OKLCH.
20
+ *
21
+ * @param {number} r - Red channel [0, 255]
22
+ * @param {number} g - Green channel [0, 255]
23
+ * @param {number} b - Blue channel [0, 255]
24
+ * @param {Float32Array} outBuf - Destination buffer
25
+ * @param {number} outOffset - Start index for the L, C, H triplet
26
+ * @returns {void}
27
+ */
28
+ export const sRgbToOklchBuffer = (r, g, b, outBuf, outOffset) => {
29
+ // 1. Normalize and linearize.
30
+ const r_lin = linearize(r / 255);
31
+ const g_lin = linearize(g / 255);
32
+ const b_lin = linearize(b / 255);
33
+
34
+ // 2. Linear sRGB -> linear LMS.
35
+ const lms_l = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin;
36
+ const lms_m = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin;
37
+ const lms_s = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin;
38
+
39
+ // 3. Non-linear LMS (defended against negative-NaN propagation from matrix noise).
40
+ const l_ = Math.cbrt(lms_l < 0 ? 0 : lms_l);
41
+ const m_ = Math.cbrt(lms_m < 0 ? 0 : lms_m);
42
+ const s_ = Math.cbrt(lms_s < 0 ? 0 : lms_s);
43
+
44
+ // 4. Non-linear LMS -> OKLab.
45
+ const lab_l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
46
+ const lab_a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
47
+ const lab_b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
48
+
49
+ // 5. OKLab -> OKLCH (polar).
50
+ const chroma = Math.sqrt(lab_a * lab_a + lab_b * lab_b);
51
+ let hue = Math.atan2(lab_b, lab_a) * RAD_TO_DEG;
52
+ if (hue < 0) hue += 360;
53
+
54
+ // 6. Write to buffer with a strict lightness clamp.
55
+ outBuf[outOffset] = lab_l < 0 ? 0 : (lab_l > 1 ? 1 : lab_l);
56
+ outBuf[outOffset + 1] = chroma;
57
+ outBuf[outOffset + 2] = hue;
58
+ };
package/src/lut.js ADDED
@@ -0,0 +1,68 @@
1
+ import { lerpOklchBuffer, packOklchBufferToUint32 } from './runtime.js';
2
+
3
+ /**
4
+ * Bakes a multi-stop OKLCH gradient into a ready-to-render `Uint32Array`.
5
+ *
6
+ * The output is in little-endian RGBA byte order — drop it directly into a
7
+ * `Uint32Array` view of `Canvas ImageData`, or `texImage2D` with `RGBA / UNSIGNED_BYTE`.
8
+ *
9
+ * Stops are evenly spaced across the gradient (stop _i_ is at `i / (numStops - 1)`).
10
+ * The optional `easeFn` warps the parametric position **before** stop selection,
11
+ * letting you bias which stop dominates which range of the LUT. Easing outputs
12
+ * outside `[0, 1]` are clamped (the LUT is fixed-resolution and cannot
13
+ * represent overshoot).
14
+ *
15
+ * @param {Float32Array} keyframesBuf - Contiguous buffer of `[L0, C0, H0, L1, C1, H1, ...]`
16
+ * @param {number} numStops - Count of color stops in the buffer (must be >= 2)
17
+ * @param {number} [resolution=256] - Number of LUT entries to bake (must be >= 2)
18
+ * @param {(t: number) => number} [easeFn] - Optional easing applied to `t` before stop selection
19
+ * @param {(buf: Float32Array, offset: number, alpha?: number) => number} [packer]
20
+ * Optional packer override. Defaults to the accurate `packOklchBufferToUint32`.
21
+ * Pass `packOklchBufferToUint32Fast` for ~2x throughput at the cost of round-trip accuracy.
22
+ * @returns {Uint32Array} LUT of 32-bit packed colors
23
+ * @throws If `numStops < 2` or `resolution < 2`
24
+ */
25
+ export const bakeGradientToUint32 = (
26
+ keyframesBuf,
27
+ numStops,
28
+ resolution = 256,
29
+ easeFn,
30
+ packer = packOklchBufferToUint32
31
+ ) => {
32
+ if (numStops < 2) {
33
+ throw new Error('lite-color-engine: bakeGradientToUint32 requires numStops >= 2');
34
+ }
35
+ if (resolution < 2) {
36
+ throw new Error('lite-color-engine: bakeGradientToUint32 requires resolution >= 2');
37
+ }
38
+
39
+ const outLUT = new Uint32Array(resolution);
40
+ const tempOklch = new Float32Array(3);
41
+
42
+ const step = 1 / (resolution - 1);
43
+ const last = numStops - 1;
44
+ const hasEase = typeof easeFn === 'function';
45
+
46
+ for (let i = 0; i < resolution; i++) {
47
+ const rawT = i * step;
48
+ let t = hasEase ? easeFn(rawT) : rawT;
49
+
50
+ // LUTs are fixed-resolution; overshoot/undershoot is undefined and
51
+ // would either NaN-poison the buffer or extrapolate silently.
52
+ if (t < 0) t = 0;
53
+ else if (t > 1) t = 1;
54
+
55
+ const scaledT = t * last;
56
+ let index = scaledT | 0; // bitwise fast-floor for positive values
57
+ if (index >= last) index = last - 1;
58
+
59
+ const localT = scaledT - index;
60
+ const offsetA = index * 3;
61
+ const offsetB = offsetA + 3;
62
+
63
+ lerpOklchBuffer(keyframesBuf, offsetA, keyframesBuf, offsetB, localT, tempOklch, 0);
64
+ outLUT[i] = packer(tempOklch, 0, 1.0);
65
+ }
66
+
67
+ return outLUT;
68
+ };