@zakkster/lite-gradient-studio 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/CHANGELOG.md ADDED
@@ -0,0 +1,105 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@zakkster/lite-gradient-studio` are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ This library follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] — first public release
9
+
10
+ First npm publish. Internal pre-1.0 versions powered Gradient Studio
11
+ through ~40 iterations; the API surface below is what survived that
12
+ production use.
13
+
14
+ ### Added
15
+
16
+ #### Core gradient layer
17
+ - `Gradient` (re-exported from `@zakkster/lite-gradient`) — 1D OKLCH
18
+ gradient with `at(t, out)` zero-GC sampling and N-stop support.
19
+ - `MeshGradient` — N×M control grid with `sampleAt(u, v, out, mode)`
20
+ in `'linear'` / `'smooth'` / `'cubic'` modes. Catmull-Rom blending
21
+ across cell boundaries for cubic, with hue-axis alignment so 350°
22
+ and 10° don't fight at boundaries.
23
+ - `setPointPosition(col, row, x, y)` — deform a handle to any (x, y)
24
+ including outside `[0, 1]`. Geometry only; the color basis stays on
25
+ the regular grid.
26
+ - `setPointColor(col, row, l, c, h, a?)` — mutate stop color in place.
27
+
28
+ #### Rasterization
29
+ - `rasterizeTo(out, w, h, opts)` — regular-grid fast path. Writes
30
+ ARGB-packed `Uint32Array`, byte-order compatible with `new
31
+ ImageData(new Uint8ClampedArray(buf.buffer), W, H)` for zero-copy
32
+ blit.
33
+ - `rasterizeDeformedTo(out, w, h, opts)` — Newton inverse-bilinear
34
+ for arbitrary handle positions. Per-pixel cost ~3-5× of `rasterizeTo`
35
+ depending on convergence; covers folded-quad cases without painting
36
+ outside the mesh.
37
+ - `packOklchSingle(l, c, h, a?)` — single-pixel OKLCH → ARGB pack
38
+ for use as a rasterizer pre-fill (mesh-gap fallback, etc.).
39
+ - `bakeGradientToLut(gradient, lut, size)` + `sampleLut(lut, t)` — 1D
40
+ gradient → flat Uint32Array LUT for cheap lookup-based sampling.
41
+ - `flattenStopsToBuffer(stops, buf)` — Float32Array layout for GPU upload.
42
+
43
+ #### CSS emit
44
+ - `formatCssLinear(gradient, { angle, oklchInterp })`.
45
+ - `formatCssRadial(gradient, { shape, position, oklchInterp })`.
46
+ - `formatCssConic(gradient, { from, position, oklchInterp })`.
47
+ - `formatCssMesh(mesh)` — multi-radial approximation. One radial per
48
+ stop, layered by visual weight. No canvas required at consume time.
49
+ - All emitters preserve the originally authored stop positions; no
50
+ resampling.
51
+
52
+ #### Multi-format export
53
+ - `toTokens1d(format, state, opts)` / `toTokensMesh(format, mesh, opts)`
54
+ dispatchers.
55
+ - Direct format functions: `toCss1d`, `toCssVar1d`, `toScss1d`,
56
+ `toTailwind1d`, `toJson1d`, `toSvg1d`, `toCssMesh`, `toCssVarMesh`,
57
+ `toJsonMesh`.
58
+ - `EXPORT_FORMATS_1D` / `EXPORT_FORMATS_MESH` — frozen format-id arrays.
59
+ - `FORMAT_META` — UI labels + one-line hints per format.
60
+
61
+ #### CSS parsing
62
+ - `parseGradientCss(css)` — accepts `linear-gradient`, `radial-gradient`,
63
+ `conic-gradient`. Round-trips with the emitters. Backward-compatible
64
+ with pre-v0.0.17 internal snapshots that used keyword position strings
65
+ (`'center'`, `'top right'`).
66
+
67
+ #### Palette extraction
68
+ - `extractPalette(pixels, count = 5)` -- chroma-weighted hue-bucketing.
69
+ Skips shadows (`L < 0.10`) and near-neutrals (`C < 0.02`). Enforces
70
+ >= 50 deg hue separation between picks so a photo of one warm subject
71
+ doesn't return five skin tones. Designer-facing behavior: "import
72
+ this photo of a baby in a blue shirt -> blue shows up in the palette".
73
+ Zero per-call allocation outside the result array: bucket state is
74
+ module-scoped and reset (not re-allocated) per call.
75
+
76
+ #### Color conversion
77
+ - `toHex({ l, c, h, a })` / `fromHex(str)` -- OKLCH <-> hex with sRGB
78
+ gamut clip via boundary search.
79
+ - `oklchToLinearSrgb(L, C, H, out?)` / `linearSrgbToOklch(r, g, b, out?)`
80
+ -- zero-GC matrix conversions when the optional `out` parameter is
81
+ supplied. Without `out` they allocate a fresh array / object (back-
82
+ compat). The gamut-mapping helper is hoisted to module scope so the
83
+ `out`-provided path is allocation-free even on the binary-search
84
+ branch.
85
+ - `srgbGamma(c)` / `srgbInverseGamma(c)` -- 8-bit sRGB transfer.
86
+
87
+ ### Tests
88
+
89
+ 200 cases over 14 test files. Same ~1:1 source-to-test ratio as the
90
+ pre-1.0 series, plus a dedicated `test/allocation.test.js` that pins
91
+ the zero-GC budgets for every hot-path export under `npm run test:gc`.
92
+
93
+ ### Benchmarks
94
+
95
+ `npm run bench` produces per-machine numbers. Indicative figures on
96
+ Node v22, Linux x64:
97
+
98
+ | Operation | Throughput |
99
+ |---|---|
100
+ | `Gradient.at(t)` — single | ~9.4 M ops/sec |
101
+ | `MeshGradient.sampleAt` — 5×5 smooth | ~4.9 M ops/sec |
102
+ | `MeshGradient.sampleAt` — 5×5 cubic | ~1.9 M ops/sec |
103
+ | `rasterizeDeformedTo` — 5×5 → 256×256 | ~2.1 M px/sec |
104
+ | `formatCssLinear` — 3 stops | ~360 K ops/sec |
105
+ | `extractPalette` — 240×240, k=5 | ~41 calls/sec |
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zahary Shinikchiev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,307 @@
1
+ # @zakkster/lite-gradient-studio
2
+
3
+ > Authoring engine for OKLCH gradients -- linear, radial, conic, and N×M mesh -- with zero-GC rasterization, multi-format export, and chroma-weighted palette extraction.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-gradient-studio.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
6
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
7
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Engine-00C853?style=for-the-badge&logo=leaf&logoColor=white)
8
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-gradient-studio?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-gradient-studio)
9
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-gradient-studio?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
10
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-gradient-studio?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
11
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
12
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
13
+
14
+ **Everything you need to build a gradient editor, except the UI.**
15
+
16
+ ---
17
+
18
+ ## Why This Library
19
+
20
+ If you're building anything that *creates* gradients (a design tool, a theme builder, a Figma plugin, a card-generator backend, a procedural background system), you need a lot of small pieces working together: color interpolation that doesn't go through dead grey, a mesh kernel that doesn't cost 400ms per frame, CSS emitters that round-trip, format exporters for handoff, and a palette extractor that picks the vivid blue instead of all five shades of the dominant skin tone.
21
+
22
+ This library is those pieces. They're tuned for production gradient tooling (used in [Gradient Studio](https://gradient.studio)), tested at 200 cases, and built to compose — `lite-gradient-studio` is the **authoring layer** that sits between low-level color math (`@zakkster/lite-color`) and an editor UI.
23
+
24
+ - **OKLCH-native** — every interpolation, every emission, every export carries OKLCH through. No silent sRGB lerp.
25
+ - **N×M mesh kernel** — bilinear (smooth) or Catmull-Rom (cubic) over a deformable control grid. Rasterizes 65K pixels per call at >2M px/sec.
26
+ - **Multi-format exporters** — CSS, CSS variables, SCSS, Tailwind config, JSON, SVG; single-call `toTokens1d(format, state)` dispatch.
27
+ - **CSS round-trip** — `parseGradientCss()` reads what your `formatCssLinear()` writes (and what designers paste in from elsewhere).
28
+ - **Palette extraction that's not stupid** — chroma-weighted hue-bucketing means a photo of a baby in a blue shirt produces a palette that includes blue, not five variants of skin.
29
+ - **Zero-GC hot path** — `sampleAt(u, v, out)`, `lerpOklchTo(a, b, t, out)` mutate caller-owned scratch; `rasterizeTo` writes Uint32Array directly.
30
+ - **No build step** — pure ESM, ships as `src/*.js`. Vendor it or `npm i` it.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ npm install @zakkster/lite-gradient-studio
36
+ ```
37
+
38
+ Dependencies (`@zakkster/lite-color`, `@zakkster/lite-color-engine`, `@zakkster/lite-gradient`) install automatically — no peer-dep dance.
39
+
40
+ ## Quick Start
41
+
42
+ ### 1D gradient (linear / radial / conic)
43
+
44
+ ```javascript
45
+ import { Gradient, formatCssLinear } from '@zakkster/lite-gradient-studio';
46
+
47
+ const sunset = new Gradient([
48
+ { l: 0.42, c: 0.22, h: 270, pos: 0.0 }, // deep purple
49
+ { l: 0.65, c: 0.26, h: 320, pos: 0.5 }, // vivid magenta
50
+ { l: 0.82, c: 0.18, h: 55, pos: 1.0 }, // warm gold
51
+ ]);
52
+
53
+ // Sample at any t — zero allocation with caller scratch.
54
+ const scratch = { l: 0, c: 0, h: 0, a: 1 };
55
+ sunset.at(0.5, scratch); // → { l: 0.65, c: 0.26, h: 320, a: 1 }
56
+
57
+ // Emit CSS with the OKLCH interpolation hint.
58
+ formatCssLinear(sunset, { angle: 135, oklchInterp: true });
59
+ // → "linear-gradient(135deg in oklch, oklch(0.42 0.22 270) 0%, ...)"
60
+ ```
61
+
62
+ ### Mesh gradient
63
+
64
+ ```javascript
65
+ import { MeshGradient } from '@zakkster/lite-gradient-studio';
66
+
67
+ const mesh = new MeshGradient(3, 3, [
68
+ // Row-major: top-left → top-right → ... → bottom-right
69
+ { l: 0.42, c: 0.22, h: 250 }, { l: 0.55, c: 0.25, h: 290 }, { l: 0.62, c: 0.24, h: 330 },
70
+ { l: 0.55, c: 0.22, h: 280 }, { l: 0.65, c: 0.26, h: 320 }, { l: 0.72, c: 0.22, h: 0 },
71
+ { l: 0.68, c: 0.18, h: 320 }, { l: 0.78, c: 0.20, h: 30 }, { l: 0.82, c: 0.18, h: 60 },
72
+ ]);
73
+
74
+ // Sample at any (u, v) ∈ [0,1]²
75
+ const scratch = { l: 0, c: 0, h: 0, a: 1 };
76
+ mesh.sampleAt(0.5, 0.5, scratch, 'smooth');
77
+
78
+ // Deform a handle — drag from regular grid to anywhere in unit square.
79
+ mesh.setPointPosition(/*col*/ 2, /*row*/ 0, /*x*/ 1.15, /*y*/ -0.05);
80
+
81
+ // Rasterize into a pre-allocated Uint32Array (ARGB-packed, sRGB).
82
+ const W = 1600, H = 640;
83
+ const buf = new Uint32Array(W * H);
84
+ mesh.rasterizeDeformedTo(buf, W, H, { mode: 'smooth' });
85
+
86
+ // Blit to canvas without re-allocating the wrapper.
87
+ const img = new ImageData(new Uint8ClampedArray(buf.buffer), W, H);
88
+ ctx.putImageData(img, 0, 0);
89
+ ```
90
+
91
+ ### Multi-format export
92
+
93
+ ```javascript
94
+ import { toTokens1d, EXPORT_FORMATS_1D } from '@zakkster/lite-gradient-studio';
95
+
96
+ const state = { mode: 'linear', angle: 135, stops: sunset.stops };
97
+
98
+ toTokens1d('css', state, { name: 'sunset' }); // → "background: linear-gradient(...)"
99
+ toTokens1d('css-var', state, { name: 'sunset' }); // → "--sunset: linear-gradient(...);"
100
+ toTokens1d('scss', state, { name: 'sunset' }); // → "$sunset: linear-gradient(...);"
101
+ toTokens1d('tailwind', state, { name: 'sunset' }); // → Tailwind config snippet
102
+ toTokens1d('json', state, { name: 'sunset' }); // → portable JSON
103
+ toTokens1d('svg', state, { name: 'sunset' }); // → standalone SVG with <linearGradient>
104
+
105
+ EXPORT_FORMATS_1D;
106
+ // → ['css', 'css-var', 'scss', 'tailwind', 'json', 'svg']
107
+ ```
108
+
109
+ ### Palette extraction from an image
110
+
111
+ ```javascript
112
+ import { extractPalette } from '@zakkster/lite-gradient-studio';
113
+
114
+ // Get RGBA pixel data however you want. canvas.getContext('2d').getImageData(...) is typical.
115
+ const palette = extractPalette(rgba.data, 5);
116
+ // → [{ l, c, h }, { l, c, h }, ...] — 5 picks, chroma-weighted, hue-separated.
117
+ // A blue-shirted-baby photo returns blue + warm tones, not five skin variants.
118
+ ```
119
+
120
+ ## Architecture
121
+
122
+ ```mermaid
123
+ flowchart LR
124
+ subgraph Inputs
125
+ Stops["1D stop list<br/>{ l, c, h, pos }[]"]
126
+ Mesh["N×M control grid<br/>{ x, y, l, c, h }[]"]
127
+ Img["RGBA pixel buffer"]
128
+ CssIn["CSS gradient string"]
129
+ end
130
+
131
+ subgraph Core
132
+ Gradient["Gradient<br/>sample, multi-stop"]
133
+ MeshK["MeshGradient<br/>bilinear / cubic kernel"]
134
+ Palette["extractPalette<br/>chroma-weighted"]
135
+ Parser["parseGradientCss"]
136
+ end
137
+
138
+ subgraph Emit
139
+ CssOut["CSS / SCSS / Tailwind<br/>JSON / SVG"]
140
+ MeshCss["Multi-radial mesh CSS"]
141
+ Raster["rasterize → Uint32Array<br/>ARGB-packed"]
142
+ end
143
+
144
+ Stops --> Gradient
145
+ Mesh --> MeshK
146
+ Img --> Palette
147
+ CssIn --> Parser
148
+
149
+ Gradient --> CssOut
150
+ MeshK --> MeshCss
151
+ MeshK --> Raster
152
+ Palette --> Gradient
153
+ Palette --> MeshK
154
+ Parser --> Gradient
155
+ ```
156
+
157
+ Each box is one module under `src/` with one test file under `test/`. Modules compose; no module reaches into another's internals.
158
+
159
+ ## API Reference
160
+
161
+ ### `Gradient` (re-exported from `@zakkster/lite-gradient`)
162
+
163
+ | Method | Description |
164
+ |---|---|
165
+ | `new Gradient(stops)` | Construct from sorted `{ l, c, h, pos }[]`. |
166
+ | `at(t, out)` | Zero-GC sample at `t ∈ [0,1]`. Writes into caller-owned `out`. |
167
+ | `.stops` | Read-only sorted stop list. |
168
+
169
+ ### `MeshGradient`
170
+
171
+ | Method | Description |
172
+ |---|---|
173
+ | `new MeshGradient(cols, rows, stops?)` | Construct N×M mesh. If `stops` omitted, fills with a default theme. |
174
+ | `sampleAt(u, v, out, mode)` | Sample at `(u, v) ∈ [0,1]²`. `mode`: `'linear' \| 'smooth' \| 'cubic'`. Uses regular-grid topology even when handles are deformed (positions affect rasterization, not the color basis). |
175
+ | `setPointPosition(col, row, x, y)` | Move a control point to `(x, y)`. Not clamped — handles can go outside the unit square. |
176
+ | `setPointColor(col, row, l, c, h, a?)` | Mutate the color at a control point. |
177
+ | `rasterizeTo(out, w, h, opts)` | Fast path — assumes regular grid. Writes ARGB-packed Uint32Array. |
178
+ | `rasterizeDeformedTo(out, w, h, opts)` | Honors `(x, y)` deformation via Newton inverse-bilinear. Per-pixel cost ~3-5× of `rasterizeTo`. |
179
+ | `.cols` `.rows` `.stops` | Read-only mesh structure. |
180
+
181
+ `opts.mode`:
182
+ - `'linear'` — bilinear OKLCH. Fastest. Smooth between adjacent stops, faceted at cell borders.
183
+ - `'smooth'` — bilinear with smoothstep-eased u/v. Softens cell-border artifacts.
184
+ - `'cubic'` — Catmull-Rom across cell boundaries. Smoothest; ~2.5× the cost of `'linear'`.
185
+
186
+ ### CSS emitters
187
+
188
+ | Function | Description |
189
+ |---|---|
190
+ | `formatCssLinear(gradient, opts)` | `linear-gradient(...)` with optional `in oklch` hint. |
191
+ | `formatCssRadial(gradient, opts)` | `radial-gradient(...)` — `shape`, `position`, hint. |
192
+ | `formatCssConic(gradient, opts)` | `conic-gradient(...)` — `from`, `position`, hint. |
193
+ | `formatCssMesh(mesh)` | Multi-radial approximation. Layers one radial per stop. Renders the visual without canvas support. |
194
+
195
+ Each emitter preserves the original `pos` values; no resampling.
196
+
197
+ ### Multi-format export
198
+
199
+ | Function | Description |
200
+ |---|---|
201
+ | `toTokens1d(format, state, opts?)` | Dispatch on format id. |
202
+ | `toTokensMesh(format, mesh, opts?)` | Same, for mesh. |
203
+ | `EXPORT_FORMATS_1D` | Frozen array of supported 1D format ids. |
204
+ | `EXPORT_FORMATS_MESH` | Frozen array of supported mesh format ids. |
205
+ | `FORMAT_META` | `{ [id]: { label, hint } }` — UI metadata. |
206
+
207
+ Direct format functions are also exported (`toCss1d`, `toScss1d`, `toTailwind1d`, etc.) for when you want to skip the dispatcher.
208
+
209
+ ### CSS parsing
210
+
211
+ | Function | Description |
212
+ |---|---|
213
+ | `parseGradientCss(css)` | Parse `linear-gradient(...)`, `radial-gradient(...)`, or `conic-gradient(...)`. Returns `{ mode, angle?, shape?, stops, ... }` matching the state shape exporters expect. Round-trips with the emitters above. |
214
+
215
+ ### Color conversion
216
+
217
+ | Function | Description |
218
+ |---|---|
219
+ | `toHex({ l, c, h, a? })` | OKLCH → `'#rrggbb'` or `'#rrggbbaa'` with sRGB gamut clip. |
220
+ | `fromHex(hexStr)` | Hex → `{ l, c, h, a }`. |
221
+ | `oklchToLinearSrgb(L, C, H, out?)` | OKLCH → linear sRGB. With `out` (a 3-element array), writes in place and returns it (zero allocation). Without `out`, allocates a fresh array. Gamut-mapped via 10-iteration chroma binary search when out of sRGB. |
222
+ | `linearSrgbToOklch(r, g, b, out?)` | Inverse. With `out` (a `{l,c,h}` object), writes in place and returns it. The `a` field is left untouched so callers can plumb alpha through the shared object. |
223
+ | `srgbGamma(c)` / `srgbInverseGamma(c)` | sRGB transfer function (8-bit `[0, 255]`). |
224
+
225
+ ### Palette extraction
226
+
227
+ | Function | Description |
228
+ |---|---|
229
+ | `extractPalette(pixels, count)` | Chroma-weighted picks with ≥50° hue separation between picks. Skips shadows (`L < 0.10`) and near-neutrals (`C < 0.02`). |
230
+
231
+ ### Rasterization utilities
232
+
233
+ | Function | Description |
234
+ |---|---|
235
+ | `packOklchSingle(l, c, h, a?)` | Single OKLCH → ARGB-packed `Uint32`. |
236
+ | `bakeGradientToLut(gradient, lut, size)` | Pre-bake a 1D gradient into a Uint32Array LUT for cheap sampling. |
237
+ | `sampleLut(lut, t)` | Read a baked LUT. |
238
+ | `flattenStopsToBuffer(stops, buf)` | Write OKLCH stops to a flat Float32Array (GPU upload friendly). |
239
+
240
+ ## Benchmarks
241
+
242
+ Node v22, single thread, Linux x64. Run yourself: `npm run bench`.
243
+
244
+ | Operation | Throughput |
245
+ |-----------------------------------------------|----------------|
246
+ | `Gradient.at(t)` — 5 stops, sweep | ~30 M ops/sec |
247
+ | `Gradient.at(t)` — single call | ~9.4 M ops/sec |
248
+ | `MeshGradient.sampleAt(u, v)` — 5×5 smooth | ~4.9 M ops/sec |
249
+ | `MeshGradient.sampleAt(u, v)` — 5×5 cubic | ~1.9 M ops/sec |
250
+ | `rasterizeTo` — 5×5 → 256×256 | ~2.1 M px/sec |
251
+ | `rasterizeDeformedTo` — 5×5 → 256×256 | ~2.1 M px/sec |
252
+ | `packOklchSingle` — one pixel | ~2.4 M ops/sec |
253
+ | `formatCssLinear` — 3 stops | ~360 K ops/sec |
254
+ | `toCssMesh` — 5×5 multi-radial | ~30 K ops/sec |
255
+ | `parseGradientCss` — 3-stop linear | ~68 K ops/sec |
256
+ | `extractPalette` — 240×240 RGBA, k=5 | ~41 calls/sec |
257
+
258
+ At 60 fps you have 16.6 ms per frame. A 1600×640 mesh raster lands well inside that on this hardware; deformed rasters at the same size sit around 8-12 ms in browser benchmarks (V8/Chrome) — the offscreen-canvas quarter-res pattern shown in [Gradient Studio](https://gradient.studio)'s `mesh/render.js` keeps drag-time interaction at full 120 Hz.
259
+
260
+ ## Comparison
261
+
262
+ | Feature | lite-gradient-studio | chroma.js | culori | colorjs.io | d3-color |
263
+ |---|---|---|---|---|---|
264
+ | OKLCH interpolation | ✔ | ✔ | ✔ | ✔ | ✔ |
265
+ | N×M mesh kernel | ✔ | ✘ | ✘ | ✘ | ✘ |
266
+ | Deformable mesh handles | ✔ | ✘ | ✘ | ✘ | ✘ |
267
+ | Multi-radial mesh CSS emit | ✔ | ✘ | ✘ | ✘ | ✘ |
268
+ | Zero-GC sample API | ✔ | ✘ | ✘ | ✘ | ✘ |
269
+ | Direct `Uint32Array` rasterize | ✔ | ✘ | ✘ | ✘ | ✘ |
270
+ | Multi-format export (CSS/SCSS/Tailwind/SVG) | ✔ | partial | partial | ✘ | ✘ |
271
+ | CSS gradient string parser | ✔ | ✘ | ✘ | ✘ | ✘ |
272
+ | Chroma-weighted palette extract | ✔ | ✘ | ✘ | ✘ | ✘ |
273
+ | No build step (pure ESM) | ✔ | ✔ | ✔ | ✔ | ✔ |
274
+
275
+ `chroma.js` / `culori` / `colorjs.io` are excellent general color libraries. **`lite-gradient-studio` is the missing layer above them** — the bit that turns OKLCH math into a gradient-editor backend.
276
+
277
+ ## Use Cases
278
+
279
+ - **Gradient editor UIs** — sliders, handles, mesh deformation, export modal. This library is the engine; you supply the React/Vue/whatever.
280
+ - **Theme builders** — extract palette from brand image, emit Tailwind tokens.
281
+ - **Figma plugins** — import gradient, edit OKLCH-correctly, export back as a frame fill.
282
+ - **Procedural backgrounds** — generate mesh gradients from seed colors at build time, ship pre-baked CSS.
283
+ - **Card / OG-image generators** — server-side rasterize a gradient into PNG.
284
+ - **Game development** — perceptually-uniform color ramps for heatmaps, palette cycling, HUD elements.
285
+
286
+ ## Design Principles
287
+
288
+ - **One module, one job.** `mesh.js` doesn't know about CSS. `css-emitters.js` doesn't know how to sample a gradient. Composable through the index re-exports.
289
+ - **Caller owns memory in hot paths.** `sampleAt(u, v, out)` writes into `out` you allocated once. No `{l, c, h}` object per pixel.
290
+ - **Position fidelity.** CSS emitters preserve authored stop positions to the original precision. No resampling that designers would notice and complain about.
291
+ - **The math you'd want.** Bilinear, Catmull-Rom, Newton inverse-bilinear, OKLCH ↔ sRGB matrix, gamut clip via boundary search — all the standard recipes, no clever shortcuts that turn out wrong.
292
+ - **Test-pass before shape.** 200 tests over ~2,500 lines; every module has its file in `test/`. Refactor with confidence.
293
+
294
+ ## Versioning
295
+
296
+ Semantic. Pre-1.0.0 was internal-only; 1.0.0 is the first npm publish and locks the public API surface listed above. Breaking changes will land in 2.x with a migration note in `CHANGELOG.md`.
297
+
298
+ ## License
299
+
300
+ MIT © Zahary Shinikchiev
301
+
302
+ ## See Also
303
+
304
+ - [`@zakkster/lite-color`](https://www.npmjs.com/package/@zakkster/lite-color) — the OKLCH color math this builds on
305
+ - [`@zakkster/lite-gradient`](https://www.npmjs.com/package/@zakkster/lite-gradient) — the 1D `Gradient` class re-exported here
306
+ - [`@zakkster/lite-hueforge`](https://www.npmjs.com/package/@zakkster/lite-hueforge) — palette/theme generation, complements this for color-system tooling
307
+ - [Gradient Studio](https://gradient.studio) — the editor this library powers
package/llms.txt ADDED
@@ -0,0 +1,173 @@
1
+ @zakkster/lite-gradient-studio — LLM-targeted reference
2
+
3
+ # Identity
4
+
5
+ Authoring engine for OKLCH gradients. Linear, radial, conic, and N×M
6
+ mesh. Zero-GC rasterization, multi-format export, palette extraction.
7
+ Used in production by Gradient Studio (https://gradient.studio).
8
+
9
+ # Install
10
+
11
+ npm install @zakkster/lite-gradient-studio
12
+
13
+ Deps install automatically: @zakkster/lite-color,
14
+ @zakkster/lite-color-engine, @zakkster/lite-gradient.
15
+
16
+ # Import
17
+
18
+ import {
19
+ Gradient,
20
+ MeshGradient,
21
+ formatCssLinear, formatCssRadial, formatCssConic, formatCssMesh,
22
+ toTokens1d, toTokensMesh, EXPORT_FORMATS_1D, EXPORT_FORMATS_MESH, FORMAT_META,
23
+ toHex, fromHex, oklchToLinearSrgb, linearSrgbToOklch,
24
+ extractPalette,
25
+ parseGradientCss,
26
+ packOklchSingle, bakeGradientToLut, sampleLut, flattenStopsToBuffer,
27
+ defaultMeshColor,
28
+ } from '@zakkster/lite-gradient-studio';
29
+
30
+ # Most common patterns
31
+
32
+ ## Sample a 1D gradient
33
+
34
+ const g = new Gradient([
35
+ { l: 0.4, c: 0.2, h: 270, pos: 0 },
36
+ { l: 0.8, c: 0.2, h: 60, pos: 1 },
37
+ ]);
38
+ const out = { l: 0, c: 0, h: 0, a: 1 };
39
+ g.at(0.5, out); // mutates out, no allocation
40
+
41
+ ## Emit CSS
42
+
43
+ formatCssLinear(g, { angle: 135, oklchInterp: true });
44
+ formatCssRadial(g, { shape: 'circle', position: '50% 50%', oklchInterp: true });
45
+ formatCssConic(g, { from: 0, position: '50% 50%', oklchInterp: true });
46
+
47
+ `oklchInterp: true` adds `in oklch` to the gradient — modern browsers
48
+ do the interpolation in OKLCH space. Without the hint they lerp in sRGB.
49
+
50
+ ## Build, deform, and rasterize a mesh
51
+
52
+ const m = new MeshGradient(5, 5);
53
+ m.setPointColor(2, 2, 0.65, 0.26, 320);
54
+ m.setPointPosition(0, 0, -0.1, -0.1); // pull a corner outside [0,1]
55
+ const buf = new Uint32Array(W * H);
56
+ m.rasterizeDeformedTo(buf, W, H, { mode: 'smooth' });
57
+ // buf is ARGB-packed sRGB. Wrap as ImageData:
58
+ const img = new ImageData(new Uint8ClampedArray(buf.buffer), W, H);
59
+
60
+ `MeshGradient.sampleAt(u, v, out, mode)` is uv-on-REGULAR-grid: deformed
61
+ positions affect rasterization geometry but NOT the color basis. To
62
+ "resample colors at fractional positions of the original grid" you call
63
+ sampleAt and ignore the deformation. That's the right behavior when
64
+ resizing a mesh and preserving its theme.
65
+
66
+ Modes:
67
+ - 'linear' — bilinear OKLCH. Fastest. Facet-visible at cell borders.
68
+ - 'smooth' — bilinear with smoothstep ease. Default in most editors.
69
+ - 'cubic' — Catmull-Rom across cells. Smoothest, ~2.5× cost.
70
+
71
+ ## Multi-format export
72
+
73
+ const state = { mode: 'linear', angle: 135, stops };
74
+ toTokens1d('css', state, { name: 'sunset' });
75
+ toTokens1d('tailwind', state, { name: 'sunset' });
76
+ toTokens1d('svg', state, { name: 'sunset' });
77
+
78
+ Supported 1D formats: 'css', 'css-var', 'scss', 'tailwind', 'json', 'svg'.
79
+ Supported mesh formats: 'css', 'css-var', 'json'.
80
+
81
+ ## Parse a gradient CSS string
82
+
83
+ const parsed = parseGradientCss('linear-gradient(135deg, oklch(0.4 0.2 270) 0%, oklch(0.8 0.2 60) 100%)');
84
+ // → { mode: 'linear', angle: 135, stops: [{ l, c, h, pos }, ...] }
85
+
86
+ Round-trips with formatCssLinear/Radial/Conic.
87
+
88
+ ## Extract a palette from an image
89
+
90
+ // pixels is a Uint8ClampedArray of length width*height*4
91
+ const palette = extractPalette(pixels, 5);
92
+ // -> [{ l, c, h }, ...] -- 5 picks, chroma-weighted, >= 50 deg hue separated
93
+
94
+ Chroma-weighted means vivid colors win over dominant-by-pixel-count
95
+ colors. A baby-in-blue-shirt photo returns a palette with blue, not
96
+ five shades of skin.
97
+
98
+ Note: extractPalette reads `pixels.length` directly; it does NOT need
99
+ width and height as separate parameters. Earlier draft docs claimed a
100
+ `(rgba, w, h, count)` signature -- corrected in 1.0.
101
+
102
+ ## Convert OKLCH <-> hex
103
+
104
+ toHex({ l: 0.65, c: 0.26, h: 320 }); // -> '#cf5fb9' (sRGB-gamut clipped)
105
+ fromHex('#cf5fb9'); // -> { l, c, h, a }
106
+
107
+ // Zero-GC matrix conversions: pass an `out` to avoid allocation.
108
+ const rgbOut = [0, 0, 0];
109
+ oklchToLinearSrgb(0.5, 0.2, 240, rgbOut); // writes into rgbOut, returns rgbOut
110
+ const lchOut = { l: 0, c: 0, h: 0 };
111
+ linearSrgbToOklch(0.4, 0.3, 0.8, lchOut); // writes into lchOut, returns lchOut
112
+
113
+ # Stop shape
114
+
115
+ 1D stops are { l, c, h, pos, a? }:
116
+ - l: OKLCH lightness, 0..1
117
+ - c: OKLCH chroma, 0..~0.4 (in-gamut for sRGB usually <0.30)
118
+ - h: OKLCH hue, degrees 0..360
119
+ - pos: position on the gradient, 0..1
120
+ - a: alpha, 0..1, defaults to 1
121
+
122
+ Mesh stops are { l, c, h, a?, x?, y? }:
123
+ - Same color fields as 1D
124
+ - x, y: deformed position in [0,1] (or outside). Optional in the
125
+ constructor — defaults to the regular-grid layout (col / (cols-1),
126
+ row / (rows-1)).
127
+
128
+ # Concurrency
129
+
130
+ Stateless. All operations are pure given inputs. No globals, no module
131
+ state. Concurrent rasterizes to different buffers are safe; concurrent
132
+ mutations of the same MeshGradient are NOT (no locking — caller's job).
133
+
134
+ # Performance
135
+
136
+ Benchmarks at npm run bench. Recent numbers (Node v22, Linux x64):
137
+ - Gradient.at: ~9M ops/sec, ~30M when called in a tight sweep
138
+ - MeshGradient.sampleAt (5×5 smooth): ~5M ops/sec
139
+ - rasterizeTo / rasterizeDeformedTo (5×5 → 256×256): ~2M px/sec
140
+ - formatCssLinear: ~360K ops/sec
141
+ - extractPalette (240×240 RGBA, k=5): ~41 calls/sec
142
+
143
+ Hot-path zero-GC pattern: pre-allocate Uint32Array buffers and scratch
144
+ { l, c, h, a } objects, reuse across frames. `new ImageData(new
145
+ Uint8ClampedArray(buf.buffer), W, H)` wraps the same backing memory
146
+ the rasterizer writes — no copy on putImageData.
147
+
148
+ # Anti-patterns
149
+
150
+ - DO NOT construct a new Gradient per frame in a hot loop. Make it once,
151
+ store the reference, mutate via setPointColor / setPointPosition if
152
+ the colors change.
153
+ - DO NOT pass a fresh scratch object to sampleAt per call. Allocate
154
+ one at module scope or per editor instance.
155
+ - DO NOT trust `gradient.stops` to be sorted post-mutation. The 1D
156
+ Gradient sorts on construction; if you build a stop array, sort by
157
+ `pos` ascending before constructing.
158
+ - DO NOT rasterize at retina resolution every frame during a drag.
159
+ Use a quarter-res offscreen canvas during drag, full-res on release.
160
+ See Gradient Studio's mesh/render.js for the pattern.
161
+
162
+ # Why this library exists
163
+
164
+ The web has settled on OKLCH for color. The CSS Color Level 4 spec
165
+ adds the `in oklch` interpolation hint to gradients. Browsers shipped
166
+ oklch() in 2023. But editor-grade tooling — the layer above raw color
167
+ math, where you author multi-stop gradients with handles, deform a
168
+ mesh, and export to whichever target your downstream needs — is still
169
+ mostly stuck on HSL or sRGB.
170
+
171
+ This library is that layer. Built once, factored to compose, tuned for
172
+ production rasterization. Pair it with @zakkster/lite-color (the OKLCH
173
+ foundation) and any UI framework to ship a gradient editor.