@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 +105 -0
- package/LICENSE +21 -0
- package/README.md +307 -0
- package/llms.txt +173 -0
- package/package.json +81 -0
- package/src/bake.js +141 -0
- package/src/color-convert.js +233 -0
- package/src/css-emitters.js +86 -0
- package/src/exporters.js +310 -0
- package/src/gradient-parse.js +447 -0
- package/src/index.d.ts +380 -0
- package/src/index.js +38 -0
- package/src/mesh-css.js +113 -0
- package/src/mesh.js +673 -0
- package/src/palette-extract.js +216 -0
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
|
+
[](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
|
|
6
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
7
|
+

|
|
8
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-gradient-studio)
|
|
9
|
+
[](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
|
|
10
|
+
[](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
|
|
11
|
+

|
|
12
|
+
[](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.
|