@zakkster/lite-gradient-studio 1.0.0 → 1.1.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 CHANGED
@@ -5,6 +5,165 @@ All notable changes to `@zakkster/lite-gradient-studio` are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
  This library follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.0] — 2026-07-03
9
+
10
+ ### Added — Monochrome mesh
11
+
12
+ Mesh-level analogue of the `monochromeGradient` factory that shipped in
13
+ `@zakkster/lite-gradient` v1.1.0. Both together form a coordinated
14
+ monochrome capability across the ecosystem — 1D continuous gradients in
15
+ lite-gradient, 2D deformable meshes in lite-gradient-studio.
16
+
17
+ - **`monochromeMesh(base, cols, rows, opts?)`** — factory returning a
18
+ `MeshGradient` where every control point shares `base.c` and `base.h`
19
+ (or `c=0` if `mode: 'grayscale'`); only L varies according to
20
+ `direction`. Post-construction, the returned mesh behaves like any
21
+ other `MeshGradient` — `setPointPosition(...)` to warp the L
22
+ distribution off-grid, `rasterizeTo(...)` / `rasterizeDeformedTo(...)`
23
+ to render.
24
+
25
+ - **Options:**
26
+ - `mode`: `'tinted'` (default) | `'grayscale'`
27
+ - `range`: `[lo, hi]` with `0 <= lo < hi <= 1`, default `[0, 1]`
28
+ - `direction`: `'horizontal'` | `'vertical'` | `'diagonal'` (default) | `'radial'`
29
+
30
+ - **Directions:**
31
+ - `'horizontal'` — L varies left-to-right, uniform per row (equivalent
32
+ to a linear gradient, but with mesh deformability post-hoc).
33
+ - `'vertical'` — L varies top-to-bottom, uniform per column.
34
+ - `'diagonal'` (default) — top-left corner (lo) to bottom-right
35
+ corner (hi). Uses both axes meaningfully — the most versatile default.
36
+ - `'radial'` — center (lo) outward to corners (hi). Atmospheric,
37
+ "premium background" feel.
38
+
39
+ - **Type declarations:** `MonoMode`, `MonoMeshDirection`,
40
+ `MonochromeMeshOptions`, plus the factory signature in
41
+ `src/index.d.ts`.
42
+
43
+ - **Re-exports flow through:** `monochromeGradient`, `gradientMonoWarm`,
44
+ `gradientMonoCool` from `@zakkster/lite-gradient` v1.1.0 are already
45
+ visible via the existing `export * from '@zakkster/lite-gradient'`
46
+ in `src/index.js`. No new studio-level 1D monochrome factory needed;
47
+ users import from either package interchangeably.
48
+
49
+ ### Why this matters
50
+
51
+ Designer feedback on the mesh-gradient positioning (the original
52
+ mesh-gradient authoring focus) surfaced two problems: (1) AI image
53
+ generators can produce arbitrary decorative gradients on demand, eating
54
+ the "generate a wild gradient" use case at the low end; (2) designers
55
+ doing client work don't have creative freedom to ship wild mesh
56
+ gradients — they're constrained to brand palettes.
57
+
58
+ Monochrome mesh dodges both problems. The palette is fixed to a single
59
+ brand tone (nothing "wild" or "random-looking"); the mesh capability
60
+ still gives designers organic-feeling backgrounds that flat 1D
61
+ gradients can't — subtle asymmetry via `setPointPosition`, off-center
62
+ radial gradients, non-linear L distributions. Same authoring surface,
63
+ narrower creative space that lands in the client-work zone.
64
+
65
+ ### Peer dependency bump
66
+
67
+ - `@zakkster/lite-gradient`: `^1.0.4` → `^1.1.0`
68
+
69
+ Existing consumers using only pre-1.1.0 exports continue to work
70
+ identically. The bump ensures `monochromeGradient` and the two Mono
71
+ presets are guaranteed available via the re-export.
72
+
73
+ ### Tests
74
+
75
+ 18 new tests across `test/mesh-monochrome.test.js`. Coverage: the four
76
+ directions (verified by inspecting the initialized `stops` array — e.g.
77
+ horizontal makes row 0 == row 1 == row 2; radial makes center = lo and
78
+ all four corners = hi), both modes, custom range, sampling correctness,
79
+ every validation throw path, and base non-mutation.
80
+
81
+ The 4 previously-failing `color-convert.test.js` tests (documented in
82
+ 1.0.1 as a docs-vs-implementation mismatch) now pass — see "Fixed"
83
+ below. Total: 218 tests, **203 pass**, 0 fail, 15 skipped.
84
+
85
+ ### Fixed
86
+
87
+ - **`oklchToLinearSrgb(L, C, H, out?)` and `linearSrgbToOklch(r, g, b, out?)`
88
+ now honor the optional `out` parameter** documented since 1.0.0 in
89
+ `index.d.ts` and covered by tests that had been failing since publish.
90
+ Pass a caller-owned 3-element array (or `{ l, c, h }` object) as the
91
+ final argument; the function writes into it in place and returns the
92
+ same reference — zero allocation on the hot path. Omitting `out` (or
93
+ passing `null` / `undefined`) preserves the existing 3-arg call shape
94
+ and returns a freshly-allocated result.
95
+
96
+ Real impact: `oklchToLinearSrgb` is called per-pixel in `rasterizeTo` /
97
+ `rasterizeDeformedTo` and per-color in every exporter. Callers threading
98
+ a scratch array through that hot loop can now avoid allocating one
99
+ triplet per pixel. Existing 3-arg calls are unchanged.
100
+
101
+ The 1.0.1 CHANGELOG framed this as "docs drifted from implementation"
102
+ and rolled back the docs. That was a stopgap. The correct resolution
103
+ was implementing the feature the docs, types, and tests all documented;
104
+ this ship does that. Non-breaking either way (the 3-arg signature is
105
+ a strict subset of the new 4-arg signature).
106
+
107
+ ### Non-breaking
108
+
109
+ Additive only. No existing API surface changed.
110
+
111
+ ## [1.0.1] — docs accuracy patch
112
+
113
+ Patch release. No code changes — every export, every behavior is
114
+ identical to 1.0.0. The 1.0.0 documentation drifted from the
115
+ implementation in several places; users following the docs exactly
116
+ would hit runtime errors. Comprehensive fix.
117
+
118
+ ### Fixed
119
+
120
+ - `toTokens1d(state, format, opts)` documented order. The 1.0.0 README
121
+ showed `toTokens1d(format, state, opts)`. The implementation is and
122
+ always was `(state, format, opts)`. Same fix for `toTokensMesh(mesh,
123
+ format, opts)`.
124
+ - `extractPalette(pixels, count?)` documented signature. The 1.0.0
125
+ README showed `(pixels, width, height, count)` — width/height aren't
126
+ parameters; the algorithm scans linearly.
127
+ - `MeshGradient.setPoint(col, row, l, c, h)` documented method name.
128
+ The 1.0.0 README called it `setPointColor` with an alpha parameter;
129
+ the actual method is `setPoint` and writes L/C/H only (alpha lives
130
+ on the stop and can be set by direct mutation).
131
+ - Mesh interpolation mode names. The 1.0.0 README used `'linear'` for
132
+ the bilinear mode; the actual accepted values are `'bilinear'`,
133
+ `'smooth'`, `'cubic'`. (`sampleAt` also accepts the legacy boolean.)
134
+ - `rasterizeTo` / `rasterizeDeformedTo` options key. The 1.0.0 README
135
+ showed `{ mode: 'smooth' }`; the actual key is `{ interpolation:
136
+ 'smooth' }` (`opts.smooth: true` is also accepted as a legacy alias).
137
+ Unknown keys silently fell back to `'bilinear'`, so 1.0.0 docs would
138
+ also have made these benchmarks measure the wrong path under wrong
139
+ labels.
140
+ - `oklchToLinearSrgb(L, C, H)` documented return type. Returns
141
+ `[r, g, b]`; the 1.0.0 README incorrectly described a zero-GC out
142
+ param. Same for `linearSrgbToOklch(r, g, b)` which returns
143
+ `{ l, c, h }`.
144
+ - `bakeGradientToLut(gradient, resolution?, opts?)` documented
145
+ signature. Returns the `Uint32Array`; the 1.0.0 README showed a
146
+ caller-owned buffer pattern that doesn't match the implementation.
147
+ - `flattenStopsToBuffer(gradient, out?)` documented first arg. Takes
148
+ a gradient instance (the function reads `gradient.stops`), not a
149
+ stops array directly.
150
+ - `srgbGamma(x) / srgbInverseGamma(x)` documented input range. Operates
151
+ on `[0, 1]` linear values, not `[0, 255]` bytes.
152
+
153
+ ### Changed
154
+
155
+ - Benchmark suite (`npm run bench`) now exercises the correct rasterize
156
+ opts key and includes separate `bilinear` / `smooth` / `cubic`
157
+ rasterize timings. The 1.0.0 bench labels were technically all
158
+ measuring the bilinear path (the `mode` key was silently ignored).
159
+ README throughput numbers updated.
160
+
161
+ If you wrote code against 1.0.0 by copy-pasting from the README and
162
+ hit "Unknown 1D export format: '[object Object]'", "setPointColor is
163
+ not a function", or similar, this release fixes the documentation —
164
+ your code needs to use the actual signatures above. No upgrade is
165
+ required for users who read the source directly.
166
+
8
167
  ## [1.0.0] — first public release
9
168
 
10
169
  First npm publish. Internal pre-1.0 versions powered Gradient Studio
@@ -65,30 +224,22 @@ production use.
65
224
  (`'center'`, `'top right'`).
66
225
 
67
226
  #### Palette extraction
68
- - `extractPalette(pixels, count = 5)` -- chroma-weighted hue-bucketing.
227
+ - `extractPalette(rgba, w, h, count)` chroma-weighted hue-bucketing.
69
228
  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
229
+ 50° hue separation between picks so a photo of one warm subject
71
230
  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.
231
+ this photo of a baby in a blue shirt blue shows up in the palette".
75
232
 
76
233
  #### Color conversion
77
- - `toHex({ l, c, h, a })` / `fromHex(str)` -- OKLCH <-> hex with sRGB
234
+ - `toHex({ l, c, h, a })` / `fromHex(str)` OKLCH hex with sRGB
78
235
  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.
236
+ - `oklchToLinearSrgb(l, c, h, out)` / `linearSrgbToOklch(r, g, b, out)`
237
+ zero-GC matrix conversions.
238
+ - `srgbGamma(c)` / `srgbInverseGamma(c)` 8-bit sRGB transfer.
86
239
 
87
240
  ### Tests
88
241
 
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`.
242
+ 178 cases over 11 test files. ~1:1 source-to-test ratio.
92
243
 
93
244
  ### Benchmarks
94
245
 
package/README.md CHANGED
@@ -11,6 +11,8 @@
11
11
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
13
13
 
14
+ Authoring engine for OKLCH gradients — linear, radial, conic, and N×M mesh — with zero-GC rasterization, multi-format export, and chroma-weighted palette extraction.
15
+
14
16
  **Everything you need to build a gradient editor, except the UI.**
15
17
 
16
18
  ---
@@ -19,7 +21,7 @@
19
21
 
20
22
  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
23
 
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.
24
+ This library is those pieces. They're tuned for production gradient tooling (used in [Gradient Studio](https://gradient.studio)), tested at 178 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
25
 
24
26
  - **OKLCH-native** — every interpolation, every emission, every export carries OKLCH through. No silent sRGB lerp.
25
27
  - **N×M mesh kernel** — bilinear (smooth) or Catmull-Rom (cubic) over a deformable control grid. Rasterizes 65K pixels per call at >2M px/sec.
@@ -81,7 +83,7 @@ mesh.setPointPosition(/*col*/ 2, /*row*/ 0, /*x*/ 1.15, /*y*/ -0.05);
81
83
  // Rasterize into a pre-allocated Uint32Array (ARGB-packed, sRGB).
82
84
  const W = 1600, H = 640;
83
85
  const buf = new Uint32Array(W * H);
84
- mesh.rasterizeDeformedTo(buf, W, H, { mode: 'smooth' });
86
+ mesh.rasterizeDeformedTo(buf, W, H, { interpolation: 'smooth' });
85
87
 
86
88
  // Blit to canvas without re-allocating the wrapper.
87
89
  const img = new ImageData(new Uint8ClampedArray(buf.buffer), W, H);
@@ -95,12 +97,12 @@ import { toTokens1d, EXPORT_FORMATS_1D } from '@zakkster/lite-gradient-studio';
95
97
 
96
98
  const state = { mode: 'linear', angle: 135, stops: sunset.stops };
97
99
 
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>
100
+ toTokens1d(state, 'css', { name: 'sunset' }); // → "background: linear-gradient(...)"
101
+ toTokens1d(state, 'css-var', { name: 'sunset' }); // → "--sunset: linear-gradient(...);"
102
+ toTokens1d(state, 'scss', { name: 'sunset' }); // → "$sunset: linear-gradient(...);"
103
+ toTokens1d(state, 'tailwind', { name: 'sunset' }); // → Tailwind config snippet
104
+ toTokens1d(state, 'json', { name: 'sunset' }); // → portable JSON
105
+ toTokens1d(state, 'svg', { name: 'sunset' }); // → standalone SVG with <linearGradient>
104
106
 
105
107
  EXPORT_FORMATS_1D;
106
108
  // → ['css', 'css-var', 'scss', 'tailwind', 'json', 'svg']
@@ -112,8 +114,9 @@ EXPORT_FORMATS_1D;
112
114
  import { extractPalette } from '@zakkster/lite-gradient-studio';
113
115
 
114
116
  // Get RGBA pixel data however you want. canvas.getContext('2d').getImageData(...) is typical.
117
+ // Width/height aren't needed — the algorithm scans the buffer linearly.
115
118
  const palette = extractPalette(rgba.data, 5);
116
- // → [{ l, c, h }, { l, c, h }, ...] — 5 picks, chroma-weighted, hue-separated.
119
+ // → [{ l, c, h }, { l, c, h }, ...] — up to 5 picks, chroma-weighted, hue-separated.
117
120
  // A blue-shirted-baby photo returns blue + warm tones, not five skin variants.
118
121
  ```
119
122
 
@@ -171,17 +174,61 @@ Each box is one module under `src/` with one test file under `test/`. Modules co
171
174
  | Method | Description |
172
175
  |---|---|
173
176
  | `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). |
177
+ | `sampleAt(u, v, out, mode?)` | Sample at `(u, v) ∈ [0,1]²`. `mode`: `'bilinear' \| 'smooth' \| 'cubic'` (default `'bilinear'`). Legacy boolean accepted: `false` → `'bilinear'`, `true` → `'smooth'`. Uses regular-grid topology even when handles are deformed (positions affect rasterization, not the color basis). |
175
178
  | `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
+ | `getPointPosition(col, row, out)` | Read current position into `out = { x, y }`. |
180
+ | `setPoint(col, row, l, c, h)` | Mutate the color at a control point. Stops carry alpha; this method writes L/C/H only — set alpha by direct stop mutation if needed. |
181
+ | `getPoint(col, row, out)` | Read current color into `out = { l, c, h }`. |
182
+ | `resetPositions()` | Snap all handles back to the regular grid. Colors preserved. |
183
+ | `rasterizeTo(out, w, h, opts)` | Fast path — assumes regular grid. Writes ARGB-packed Uint32Array. `opts.interpolation`: `'bilinear' \| 'smooth' \| 'cubic'`. |
184
+ | `rasterizeDeformedTo(out, w, h, opts)` | Honors `(x, y)` deformation via Newton inverse-bilinear. Per-pixel cost ~3-5× of `rasterizeTo`. Same `opts.interpolation`. |
179
185
  | `.cols` `.rows` `.stops` | Read-only mesh structure. |
180
186
 
181
- `opts.mode`:
182
- - `'linear'` — bilinear OKLCH. Fastest. Smooth between adjacent stops, faceted at cell borders.
187
+ `opts.interpolation` (and `mode` for `sampleAt`):
188
+ - `'bilinear'` — bilinear OKLCH. Fastest. Smooth between adjacent stops, faceted at cell borders. Default.
183
189
  - `'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'`.
190
+ - `'cubic'` — Catmull-Rom across cell boundaries. Smoothest; ~2.5× the cost of `'bilinear'`.
191
+
192
+ ### Monochrome mesh (v1.1.0)
193
+
194
+ Mesh-level analogue of `lite-gradient`'s `monochromeGradient` — chroma and hue held
195
+ constant across every control point; only lightness varies. Client-work-friendly:
196
+ subtle premium backgrounds without the "AI-generated random gradient" look.
197
+
198
+ ```js
199
+ import { monochromeMesh } from '@zakkster/lite-gradient-studio';
200
+
201
+ // 3×3 mesh, all points share the brand hue, L varies diagonally
202
+ const mesh = monochromeMesh({ l: 0.5, c: 0.06, h: 245 }, 3, 3);
203
+
204
+ // Rasterize to a canvas
205
+ const buf = new Uint32Array(800 * 600);
206
+ mesh.rasterizeTo(buf, 800, 600);
207
+ const img = new ImageData(new Uint8ClampedArray(buf.buffer), 800, 600);
208
+ ctx.putImageData(img, 0, 0);
209
+
210
+ // Or emit CSS (canvas-free rendering)
211
+ const css = formatCssMesh(mesh);
212
+ element.style.background = css;
213
+ ```
214
+
215
+ **`monochromeMesh(base, cols, rows, opts?)`** → `MeshGradient`
216
+
217
+ | Option | Type | Default | Notes |
218
+ |-------------|---------------------------------------------------------------|----------------|-----------------------------------------------------------------------------|
219
+ | `mode` | `'tinted' \| 'grayscale'` | `'tinted'` | `'tinted'` retains base c/h; `'grayscale'` forces c=0. |
220
+ | `range` | `[number, number]` | `[0, 1]` | L-axis endpoints. Must satisfy `0 ≤ lo < hi ≤ 1`. |
221
+ | `direction` | `'horizontal' \| 'vertical' \| 'diagonal' \| 'radial'` | `'diagonal'` | How L varies across the mesh. See below. |
222
+
223
+ **Directions:**
224
+ - **`'horizontal'`** — L varies left-to-right, uniform across each row. Like a linear gradient, but you can deform post-hoc.
225
+ - **`'vertical'`** — L varies top-to-bottom, uniform across each column.
226
+ - **`'diagonal'`** (default) — top-left corner (lo) to bottom-right corner (hi). Most versatile; uses both axes meaningfully.
227
+ - **`'radial'`** — center (lo) outward to corners (hi). Atmospheric "premium" feel.
228
+
229
+ Post-construction, you can `setPointPosition(...)` to warp the L distribution off-grid
230
+ — something you can't do with a flat 1D gradient. This is what mesh capability buys you
231
+ in the client-work scenario: brand-safe base + designer-controlled organic character.
185
232
 
186
233
  ### CSS emitters
187
234
 
@@ -198,8 +245,8 @@ Each emitter preserves the original `pos` values; no resampling.
198
245
 
199
246
  | Function | Description |
200
247
  |---|---|
201
- | `toTokens1d(format, state, opts?)` | Dispatch on format id. |
202
- | `toTokensMesh(format, mesh, opts?)` | Same, for mesh. |
248
+ | `toTokens1d(state, format, opts?)` | Dispatch on format id. |
249
+ | `toTokensMesh(mesh, format, opts?)` | Same, for mesh. |
203
250
  | `EXPORT_FORMATS_1D` | Frozen array of supported 1D format ids. |
204
251
  | `EXPORT_FORMATS_MESH` | Frozen array of supported mesh format ids. |
205
252
  | `FORMAT_META` | `{ [id]: { label, hint } }` — UI metadata. |
@@ -217,25 +264,25 @@ Direct format functions are also exported (`toCss1d`, `toScss1d`, `toTailwind1d`
217
264
  | Function | Description |
218
265
  |---|---|
219
266
  | `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]`). |
267
+ | `fromHex(hex)` | Hex → `{ l, c, h, a }`. |
268
+ | `oklchToLinearSrgb(L, C, H)` | Returns `[r, g, b]` each in `[0, 1]`. Gamut-mapped by chroma reduction (binary search) so the result is always in sRGB. |
269
+ | `linearSrgbToOklch(r, g, b)` | Returns `{ l, c, h }`. Inputs are linear sRGB in `[0, 1]`. |
270
+ | `srgbGamma(x)` / `srgbInverseGamma(x)` | sRGB transfer function. Inputs and outputs in `[0, 1]`. |
224
271
 
225
272
  ### Palette extraction
226
273
 
227
274
  | Function | Description |
228
275
  |---|---|
229
- | `extractPalette(pixels, count)` | Chroma-weighted picks with ≥50° hue separation between picks. Skips shadows (`L < 0.10`) and near-neutrals (`C < 0.02`). |
276
+ | `extractPalette(pixels, count?)` | Chroma-weighted picks with ≥50° hue separation between picks. Skips shadows (`L < 0.10`) and near-neutrals (`C < 0.02`). `pixels` is a flat RGBA `Uint8ClampedArray` (length must be a multiple of 4); width/height aren't needed because the algorithm scans linearly. `count` defaults to 5; output may be shorter if the image doesn't have that many distinct hues. |
230
277
 
231
278
  ### Rasterization utilities
232
279
 
233
280
  | Function | Description |
234
281
  |---|---|
235
282
  | `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). |
283
+ | `bakeGradientToLut(gradient, resolution?, opts?)` | Pre-bake a 1D gradient into a Uint32Array LUT. Returns the LUT. `resolution` defaults to 256. `opts.easeFn` lets you apply easing on the evenly-spaced path; `opts.packer` overrides the default packer. |
284
+ | `sampleLut(lut, t)` | Read a baked LUT at `t ∈ [0, 1]`. Returns the packed ARGB `Uint32` at that index. |
285
+ | `flattenStopsToBuffer(gradient, out?)` | Write OKLCH stops to a Float32Array as `[L, C, H, L, C, H, ...]` (GPU-upload friendly). Optional `out` buffer reused if large enough. |
239
286
 
240
287
  ## Benchmarks
241
288
 
@@ -243,17 +290,18 @@ Node v22, single thread, Linux x64. Run yourself: `npm run bench`.
243
290
 
244
291
  | Operation | Throughput |
245
292
  |-----------------------------------------------|----------------|
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 |
293
+ | `Gradient.at(t)` — 5 stops, sweep | ~41 M ops/sec |
294
+ | `Gradient.at(t)` — single call | ~10 M ops/sec |
295
+ | `MeshGradient.sampleAt(u, v)` — 5×5 smooth | ~5.5 M ops/sec |
296
+ | `MeshGradient.sampleAt(u, v)` — 5×5 cubic | ~2.0 M ops/sec |
297
+ | `rasterizeTo` — 5×5 → 256×256 bilinear | ~2.5 M px/sec |
298
+ | `rasterizeTo` — 5×5 → 256×256 cubic | ~1.5 M px/sec |
299
+ | `rasterizeDeformedTo` — 5×5 → 256×256 | ~2.6 M px/sec |
300
+ | `packOklchSingle` — one pixel | ~3.0 M ops/sec |
301
+ | `formatCssLinear` — 3 stops | ~490 K ops/sec |
302
+ | `toCssMesh` — 5×5 multi-radial | ~40 K ops/sec |
303
+ | `parseGradientCss` — 3-stop linear | ~99 K ops/sec |
304
+ | `extractPalette` — 240×240 RGBA, k=5 | ~52 calls/sec |
257
305
 
258
306
  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
307
 
@@ -289,7 +337,7 @@ At 60 fps you have 16.6 ms per frame. A 1600×640 mesh raster lands well inside
289
337
  - **Caller owns memory in hot paths.** `sampleAt(u, v, out)` writes into `out` you allocated once. No `{l, c, h}` object per pixel.
290
338
  - **Position fidelity.** CSS emitters preserve authored stop positions to the original precision. No resampling that designers would notice and complain about.
291
339
  - **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.
340
+ - **Test-pass before shape.** 178 tests over 1,993 lines; every module has its file in `test/`. Refactor with confidence.
293
341
 
294
342
  ## Versioning
295
343
 
package/llms.txt CHANGED
@@ -50,10 +50,10 @@ do the interpolation in OKLCH space. Without the hint they lerp in sRGB.
50
50
  ## Build, deform, and rasterize a mesh
51
51
 
52
52
  const m = new MeshGradient(5, 5);
53
- m.setPointColor(2, 2, 0.65, 0.26, 320);
53
+ m.setPoint(2, 2, 0.65, 0.26, 320); // mutate L/C/H at (col=2, row=2)
54
54
  m.setPointPosition(0, 0, -0.1, -0.1); // pull a corner outside [0,1]
55
55
  const buf = new Uint32Array(W * H);
56
- m.rasterizeDeformedTo(buf, W, H, { mode: 'smooth' });
56
+ m.rasterizeDeformedTo(buf, W, H, { interpolation: 'smooth' });
57
57
  // buf is ARGB-packed sRGB. Wrap as ImageData:
58
58
  const img = new ImageData(new Uint8ClampedArray(buf.buffer), W, H);
59
59
 
@@ -63,17 +63,66 @@ positions affect rasterization geometry but NOT the color basis. To
63
63
  sampleAt and ignore the deformation. That's the right behavior when
64
64
  resizing a mesh and preserving its theme.
65
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.
66
+ Mesh interpolation modes (sampleAt's `mode` arg / rasterize's
67
+ `opts.interpolation`):
68
+ - 'bilinear' — bilinear OKLCH. Fastest. Facet-visible at cell borders. Default.
69
+ - 'smooth' bilinear with smoothstep ease. at seams.
70
+ - 'cubic' — Catmull-Rom across cells. Smoothest, ~2.5× cost.
71
+
72
+ Legacy boolean accepted for sampleAt: `false` → bilinear, `true` → smooth.
73
+ Rasterize also accepts `opts.smooth: true` as a legacy alias.
74
+
75
+ ## Monochrome mesh (v1.1.0)
76
+
77
+ Factory for meshes where every control point shares base c/h (or c=0 for
78
+ grayscale) and only L varies across the grid. Client-work-friendly:
79
+ never looks like an "AI-generated random gradient" because the palette
80
+ is fixed to one brand tone.
81
+
82
+ import { monochromeMesh, formatCssMesh } from '@zakkster/lite-gradient-studio';
83
+
84
+ // Brand-tinted mesh, L varies diagonally
85
+ const m = monochromeMesh({ l: 0.5, c: 0.06, h: 245 }, 3, 3);
86
+
87
+ // Or explicit direction + grayscale + printable-safe range
88
+ const gm = monochromeMesh(
89
+ { l: 0.5, c: 0, h: 0 },
90
+ 4, 4,
91
+ { mode: 'grayscale', range: [0.05, 0.95], direction: 'radial' }
92
+ );
93
+
94
+ // Emit CSS without canvas
95
+ element.style.background = formatCssMesh(m);
96
+
97
+ Options:
98
+ - mode: 'tinted' (default) | 'grayscale'
99
+ - range: [lo, hi] with 0 <= lo < hi <= 1 (default [0, 1])
100
+ - direction: 'horizontal' | 'vertical' | 'diagonal' (default) | 'radial'
101
+
102
+ Directions:
103
+ - horizontal: L varies left-to-right, uniform per row
104
+ - vertical: L varies top-to-bottom, uniform per column
105
+ - diagonal: top-left corner (lo) to bottom-right corner (hi)
106
+ - radial: center (lo) outward to corners (hi)
107
+
108
+ Returns a MeshGradient. All post-construction methods (setPointPosition,
109
+ setPoint, sampleAt, rasterizeTo, etc.) work as normal — the factory just
110
+ initializes the color basis. Deform handles off-grid to warp L across
111
+ the surface without disturbing the brand palette.
112
+
113
+ Pairs with @zakkster/lite-gradient's monochromeGradient(base, opts) which
114
+ returns a continuous 1D Gradient; monochromeMesh returns a 2D MeshGradient.
70
115
 
71
116
  ## Multi-format export
72
117
 
73
118
  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' });
119
+ toTokens1d(state, 'css', { name: 'sunset' });
120
+ toTokens1d(state, 'tailwind', { name: 'sunset' });
121
+ toTokens1d(state, 'svg', { name: 'sunset' });
122
+
123
+ Signature: toTokens1d(g1dState, formatId, opts) — STATE first, FORMAT
124
+ second. Returns a string. toTokensMesh(mesh, formatId, opts) follows
125
+ the same pattern.
77
126
 
78
127
  Supported 1D formats: 'css', 'css-var', 'scss', 'tailwind', 'json', 'svg'.
79
128
  Supported mesh formats: 'css', 'css-var', 'json'.
@@ -87,28 +136,22 @@ Round-trips with formatCssLinear/Radial/Conic.
87
136
 
88
137
  ## Extract a palette from an image
89
138
 
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
139
+ // rgba is a Uint8ClampedArray of length width*height*4
140
+ const palette = extractPalette(rgba, 5);
141
+ // [{ l, c, h }, ...] up to 5 picks, chroma-weighted, 50° hue separated
142
+
143
+ Signature: extractPalette(pixels, count?). Width/height NOT required —
144
+ the algorithm scans linearly. `count` defaults to 5; output may be
145
+ shorter if the image lacks that many distinct hues.
93
146
 
94
147
  Chroma-weighted means vivid colors win over dominant-by-pixel-count
95
148
  colors. A baby-in-blue-shirt photo returns a palette with blue, not
96
149
  five shades of skin.
97
150
 
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 }
151
+ ## Convert OKLCH hex
106
152
 
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
153
+ toHex({ l: 0.65, c: 0.26, h: 320 }); // '#cf5fb9' (sRGB-gamut clipped)
154
+ fromHex('#cf5fb9'); // { l, c, h, a }
112
155
 
113
156
  # Stop shape
114
157
 
@@ -148,7 +191,7 @@ the rasterizer writes — no copy on putImageData.
148
191
  # Anti-patterns
149
192
 
150
193
  - DO NOT construct a new Gradient per frame in a hot loop. Make it once,
151
- store the reference, mutate via setPointColor / setPointPosition if
194
+ store the reference, mutate via setPoint / setPointPosition if
152
195
  the colors change.
153
196
  - DO NOT pass a fresh scratch object to sampleAt per call. Allocate
154
197
  one at module scope or per editor instance.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zakkster/lite-gradient-studio",
3
- "version": "1.0.0",
4
- "description": "Authoring engine for OKLCH gradients -- linear, radial, conic, and NxM mesh -- with zero-GC rasterization, multi-format export (CSS/SCSS/Tailwind/JSON/SVG), CSS parser, and chroma-weighted palette extraction.",
3
+ "version": "1.1.0",
4
+ "description": "Authoring engine for OKLCH gradients linear, radial, conic, and N×M mesh with zero-GC rasterization, multi-format export (CSS/SCSS/Tailwind/JSON/SVG), and chroma-weighted palette extraction.",
5
5
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -10,25 +10,27 @@
10
10
  "types": "./src/index.d.ts",
11
11
  "exports": {
12
12
  ".": {
13
+ "types": "./src/index.d.ts",
13
14
  "node": "./src/index.js",
14
15
  "import": "./src/index.js",
15
- "types": "./src/index.d.ts",
16
16
  "default": "./src/index.js"
17
17
  }
18
18
  },
19
19
  "files": [
20
- "src/",
21
- "README.md",
20
+ "src",
22
21
  "llms.txt",
23
22
  "CHANGELOG.md",
24
- "LICENSE"
23
+ "LICENSE",
24
+ "README.md"
25
25
  ],
26
26
  "scripts": {
27
- "test": "node --test --test-reporter=spec test/*.test.js",
28
- "test:gc": "node --expose-gc --test --test-reporter=spec test/*.test.js",
29
- "test:coverage": "c8 node --test --test-reporter=spec test/*.test.js",
30
- "bench": "node --expose-gc bench/run.mjs",
31
- "verify": "npm run test:gc && npm run bench"
27
+ "test": "node --test --expose-gc test/*.test.js",
28
+ "bench": "node --expose-gc bench/run.mjs"
29
+ },
30
+ "dependencies": {
31
+ "@zakkster/lite-color": "^1.0.6",
32
+ "@zakkster/lite-color-engine": "^1.0.0",
33
+ "@zakkster/lite-gradient": "^1.1.0"
32
34
  },
33
35
  "keywords": [
34
36
  "gradient",
@@ -43,12 +45,9 @@
43
45
  "color-interpolation",
44
46
  "perceptually-uniform",
45
47
  "zero-gc",
46
- "zero-allocation",
47
48
  "lite",
48
49
  "no-build",
49
- "esm",
50
- "rasterizer",
51
- "deterministic"
50
+ "esm"
52
51
  ],
53
52
  "homepage": "https://github.com/PeshoVurtoleta/lite-gradient-studio#readme",
54
53
  "repository": {
@@ -56,26 +55,11 @@
56
55
  "url": "git+https://github.com/PeshoVurtoleta/lite-gradient-studio.git"
57
56
  },
58
57
  "bugs": {
59
- "url": "https://github.com/PeshoVurtoleta/lite-gradient-studio/issues",
60
- "email": "shinikchiev@yahoo.com"
61
- },
62
- "engines": {
63
- "node": ">=18"
58
+ "url": "https://github.com/PeshoVurtoleta/lite-gradient-studio/issues"
64
59
  },
65
60
  "funding": {
66
61
  "type": "github",
67
62
  "url": "https://github.com/sponsors/PeshoVurtoleta"
68
63
  },
69
- "publishConfig": {
70
- "access": "public"
71
- },
72
- "sideEffects": false,
73
- "dependencies": {
74
- "@zakkster/lite-color": "^1.0.6",
75
- "@zakkster/lite-color-engine": "^1.0.0",
76
- "@zakkster/lite-gradient": "^1.0.4"
77
- },
78
- "devDependencies": {
79
- "c8": "^11.0.0"
80
- }
64
+ "sideEffects": false
81
65
  }