@zakkster/lite-gradient-studio 1.0.0 → 1.0.1

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,62 @@ 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.0.1] — docs accuracy patch
9
+
10
+ Patch release. No code changes — every export, every behavior is
11
+ identical to 1.0.0. The 1.0.0 documentation drifted from the
12
+ implementation in several places; users following the docs exactly
13
+ would hit runtime errors. Comprehensive fix.
14
+
15
+ ### Fixed
16
+
17
+ - `toTokens1d(state, format, opts)` documented order. The 1.0.0 README
18
+ showed `toTokens1d(format, state, opts)`. The implementation is and
19
+ always was `(state, format, opts)`. Same fix for `toTokensMesh(mesh,
20
+ format, opts)`.
21
+ - `extractPalette(pixels, count?)` documented signature. The 1.0.0
22
+ README showed `(pixels, width, height, count)` — width/height aren't
23
+ parameters; the algorithm scans linearly.
24
+ - `MeshGradient.setPoint(col, row, l, c, h)` documented method name.
25
+ The 1.0.0 README called it `setPointColor` with an alpha parameter;
26
+ the actual method is `setPoint` and writes L/C/H only (alpha lives
27
+ on the stop and can be set by direct mutation).
28
+ - Mesh interpolation mode names. The 1.0.0 README used `'linear'` for
29
+ the bilinear mode; the actual accepted values are `'bilinear'`,
30
+ `'smooth'`, `'cubic'`. (`sampleAt` also accepts the legacy boolean.)
31
+ - `rasterizeTo` / `rasterizeDeformedTo` options key. The 1.0.0 README
32
+ showed `{ mode: 'smooth' }`; the actual key is `{ interpolation:
33
+ 'smooth' }` (`opts.smooth: true` is also accepted as a legacy alias).
34
+ Unknown keys silently fell back to `'bilinear'`, so 1.0.0 docs would
35
+ also have made these benchmarks measure the wrong path under wrong
36
+ labels.
37
+ - `oklchToLinearSrgb(L, C, H)` documented return type. Returns
38
+ `[r, g, b]`; the 1.0.0 README incorrectly described a zero-GC out
39
+ param. Same for `linearSrgbToOklch(r, g, b)` which returns
40
+ `{ l, c, h }`.
41
+ - `bakeGradientToLut(gradient, resolution?, opts?)` documented
42
+ signature. Returns the `Uint32Array`; the 1.0.0 README showed a
43
+ caller-owned buffer pattern that doesn't match the implementation.
44
+ - `flattenStopsToBuffer(gradient, out?)` documented first arg. Takes
45
+ a gradient instance (the function reads `gradient.stops`), not a
46
+ stops array directly.
47
+ - `srgbGamma(x) / srgbInverseGamma(x)` documented input range. Operates
48
+ on `[0, 1]` linear values, not `[0, 255]` bytes.
49
+
50
+ ### Changed
51
+
52
+ - Benchmark suite (`npm run bench`) now exercises the correct rasterize
53
+ opts key and includes separate `bilinear` / `smooth` / `cubic`
54
+ rasterize timings. The 1.0.0 bench labels were technically all
55
+ measuring the bilinear path (the `mode` key was silently ignored).
56
+ README throughput numbers updated.
57
+
58
+ If you wrote code against 1.0.0 by copy-pasting from the README and
59
+ hit "Unknown 1D export format: '[object Object]'", "setPointColor is
60
+ not a function", or similar, this release fixes the documentation —
61
+ your code needs to use the actual signatures above. No upgrade is
62
+ required for users who read the source directly.
63
+
8
64
  ## [1.0.0] — first public release
9
65
 
10
66
  First npm publish. Internal pre-1.0 versions powered Gradient Studio
@@ -65,30 +121,22 @@ production use.
65
121
  (`'center'`, `'top right'`).
66
122
 
67
123
  #### Palette extraction
68
- - `extractPalette(pixels, count = 5)` -- chroma-weighted hue-bucketing.
124
+ - `extractPalette(rgba, w, h, count)` chroma-weighted hue-bucketing.
69
125
  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
126
+ 50° hue separation between picks so a photo of one warm subject
71
127
  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.
128
+ this photo of a baby in a blue shirt blue shows up in the palette".
75
129
 
76
130
  #### Color conversion
77
- - `toHex({ l, c, h, a })` / `fromHex(str)` -- OKLCH <-> hex with sRGB
131
+ - `toHex({ l, c, h, a })` / `fromHex(str)` OKLCH hex with sRGB
78
132
  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.
133
+ - `oklchToLinearSrgb(l, c, h, out)` / `linearSrgbToOklch(r, g, b, out)`
134
+ zero-GC matrix conversions.
135
+ - `srgbGamma(c)` / `srgbInverseGamma(c)` 8-bit sRGB transfer.
86
136
 
87
137
  ### Tests
88
138
 
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`.
139
+ 178 cases over 11 test files. ~1:1 source-to-test ratio.
92
140
 
93
141
  ### Benchmarks
94
142
 
package/README.md CHANGED
@@ -1,16 +1,12 @@
1
1
  # @zakkster/lite-gradient-studio
2
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
3
  [![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
4
  [![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
5
  [![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
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
13
7
 
8
+ Authoring engine for OKLCH gradients — linear, radial, conic, and N×M mesh — with zero-GC rasterization, multi-format export, and chroma-weighted palette extraction.
9
+
14
10
  **Everything you need to build a gradient editor, except the UI.**
15
11
 
16
12
  ---
@@ -19,7 +15,7 @@
19
15
 
20
16
  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
17
 
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.
18
+ 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
19
 
24
20
  - **OKLCH-native** — every interpolation, every emission, every export carries OKLCH through. No silent sRGB lerp.
25
21
  - **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 +77,7 @@ mesh.setPointPosition(/*col*/ 2, /*row*/ 0, /*x*/ 1.15, /*y*/ -0.05);
81
77
  // Rasterize into a pre-allocated Uint32Array (ARGB-packed, sRGB).
82
78
  const W = 1600, H = 640;
83
79
  const buf = new Uint32Array(W * H);
84
- mesh.rasterizeDeformedTo(buf, W, H, { mode: 'smooth' });
80
+ mesh.rasterizeDeformedTo(buf, W, H, { interpolation: 'smooth' });
85
81
 
86
82
  // Blit to canvas without re-allocating the wrapper.
87
83
  const img = new ImageData(new Uint8ClampedArray(buf.buffer), W, H);
@@ -95,12 +91,12 @@ import { toTokens1d, EXPORT_FORMATS_1D } from '@zakkster/lite-gradient-studio';
95
91
 
96
92
  const state = { mode: 'linear', angle: 135, stops: sunset.stops };
97
93
 
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>
94
+ toTokens1d(state, 'css', { name: 'sunset' }); // → "background: linear-gradient(...)"
95
+ toTokens1d(state, 'css-var', { name: 'sunset' }); // → "--sunset: linear-gradient(...);"
96
+ toTokens1d(state, 'scss', { name: 'sunset' }); // → "$sunset: linear-gradient(...);"
97
+ toTokens1d(state, 'tailwind', { name: 'sunset' }); // → Tailwind config snippet
98
+ toTokens1d(state, 'json', { name: 'sunset' }); // → portable JSON
99
+ toTokens1d(state, 'svg', { name: 'sunset' }); // → standalone SVG with <linearGradient>
104
100
 
105
101
  EXPORT_FORMATS_1D;
106
102
  // → ['css', 'css-var', 'scss', 'tailwind', 'json', 'svg']
@@ -112,8 +108,9 @@ EXPORT_FORMATS_1D;
112
108
  import { extractPalette } from '@zakkster/lite-gradient-studio';
113
109
 
114
110
  // Get RGBA pixel data however you want. canvas.getContext('2d').getImageData(...) is typical.
111
+ // Width/height aren't needed — the algorithm scans the buffer linearly.
115
112
  const palette = extractPalette(rgba.data, 5);
116
- // → [{ l, c, h }, { l, c, h }, ...] — 5 picks, chroma-weighted, hue-separated.
113
+ // → [{ l, c, h }, { l, c, h }, ...] — up to 5 picks, chroma-weighted, hue-separated.
117
114
  // A blue-shirted-baby photo returns blue + warm tones, not five skin variants.
118
115
  ```
119
116
 
@@ -171,17 +168,20 @@ Each box is one module under `src/` with one test file under `test/`. Modules co
171
168
  | Method | Description |
172
169
  |---|---|
173
170
  | `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). |
171
+ | `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
172
  | `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`. |
173
+ | `getPointPosition(col, row, out)` | Read current position into `out = { x, y }`. |
174
+ | `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. |
175
+ | `getPoint(col, row, out)` | Read current color into `out = { l, c, h }`. |
176
+ | `resetPositions()` | Snap all handles back to the regular grid. Colors preserved. |
177
+ | `rasterizeTo(out, w, h, opts)` | Fast path — assumes regular grid. Writes ARGB-packed Uint32Array. `opts.interpolation`: `'bilinear' \| 'smooth' \| 'cubic'`. |
178
+ | `rasterizeDeformedTo(out, w, h, opts)` | Honors `(x, y)` deformation via Newton inverse-bilinear. Per-pixel cost ~3-5× of `rasterizeTo`. Same `opts.interpolation`. |
179
179
  | `.cols` `.rows` `.stops` | Read-only mesh structure. |
180
180
 
181
- `opts.mode`:
182
- - `'linear'` — bilinear OKLCH. Fastest. Smooth between adjacent stops, faceted at cell borders.
181
+ `opts.interpolation` (and `mode` for `sampleAt`):
182
+ - `'bilinear'` — bilinear OKLCH. Fastest. Smooth between adjacent stops, faceted at cell borders. Default.
183
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'`.
184
+ - `'cubic'` — Catmull-Rom across cell boundaries. Smoothest; ~2.5× the cost of `'bilinear'`.
185
185
 
186
186
  ### CSS emitters
187
187
 
@@ -198,8 +198,8 @@ Each emitter preserves the original `pos` values; no resampling.
198
198
 
199
199
  | Function | Description |
200
200
  |---|---|
201
- | `toTokens1d(format, state, opts?)` | Dispatch on format id. |
202
- | `toTokensMesh(format, mesh, opts?)` | Same, for mesh. |
201
+ | `toTokens1d(state, format, opts?)` | Dispatch on format id. |
202
+ | `toTokensMesh(mesh, format, opts?)` | Same, for mesh. |
203
203
  | `EXPORT_FORMATS_1D` | Frozen array of supported 1D format ids. |
204
204
  | `EXPORT_FORMATS_MESH` | Frozen array of supported mesh format ids. |
205
205
  | `FORMAT_META` | `{ [id]: { label, hint } }` — UI metadata. |
@@ -217,25 +217,25 @@ Direct format functions are also exported (`toCss1d`, `toScss1d`, `toTailwind1d`
217
217
  | Function | Description |
218
218
  |---|---|
219
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]`). |
220
+ | `fromHex(hex)` | Hex → `{ l, c, h, a }`. |
221
+ | `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. |
222
+ | `linearSrgbToOklch(r, g, b)` | Returns `{ l, c, h }`. Inputs are linear sRGB in `[0, 1]`. |
223
+ | `srgbGamma(x)` / `srgbInverseGamma(x)` | sRGB transfer function. Inputs and outputs in `[0, 1]`. |
224
224
 
225
225
  ### Palette extraction
226
226
 
227
227
  | Function | Description |
228
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`). |
229
+ | `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
230
 
231
231
  ### Rasterization utilities
232
232
 
233
233
  | Function | Description |
234
234
  |---|---|
235
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). |
236
+ | `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. |
237
+ | `sampleLut(lut, t)` | Read a baked LUT at `t ∈ [0, 1]`. Returns the packed ARGB `Uint32` at that index. |
238
+ | `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
239
 
240
240
  ## Benchmarks
241
241
 
@@ -243,17 +243,18 @@ Node v22, single thread, Linux x64. Run yourself: `npm run bench`.
243
243
 
244
244
  | Operation | Throughput |
245
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 |
246
+ | `Gradient.at(t)` — 5 stops, sweep | ~41 M ops/sec |
247
+ | `Gradient.at(t)` — single call | ~10 M ops/sec |
248
+ | `MeshGradient.sampleAt(u, v)` — 5×5 smooth | ~5.5 M ops/sec |
249
+ | `MeshGradient.sampleAt(u, v)` — 5×5 cubic | ~2.0 M ops/sec |
250
+ | `rasterizeTo` — 5×5 → 256×256 bilinear | ~2.5 M px/sec |
251
+ | `rasterizeTo` — 5×5 → 256×256 cubic | ~1.5 M px/sec |
252
+ | `rasterizeDeformedTo` — 5×5 → 256×256 | ~2.6 M px/sec |
253
+ | `packOklchSingle` — one pixel | ~3.0 M ops/sec |
254
+ | `formatCssLinear` — 3 stops | ~490 K ops/sec |
255
+ | `toCssMesh` — 5×5 multi-radial | ~40 K ops/sec |
256
+ | `parseGradientCss` — 3-stop linear | ~99 K ops/sec |
257
+ | `extractPalette` — 240×240 RGBA, k=5 | ~52 calls/sec |
257
258
 
258
259
  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
 
@@ -289,7 +290,7 @@ At 60 fps you have 16.6 ms per frame. A 1600×640 mesh raster lands well inside
289
290
  - **Caller owns memory in hot paths.** `sampleAt(u, v, out)` writes into `out` you allocated once. No `{l, c, h}` object per pixel.
290
291
  - **Position fidelity.** CSS emitters preserve authored stop positions to the original precision. No resampling that designers would notice and complain about.
291
292
  - **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
+ - **Test-pass before shape.** 178 tests over 1,993 lines; every module has its file in `test/`. Refactor with confidence.
293
294
 
294
295
  ## Versioning
295
296
 
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,25 @@ 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.
70
74
 
71
75
  ## Multi-format export
72
76
 
73
77
  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' });
78
+ toTokens1d(state, 'css', { name: 'sunset' });
79
+ toTokens1d(state, 'tailwind', { name: 'sunset' });
80
+ toTokens1d(state, 'svg', { name: 'sunset' });
81
+
82
+ Signature: toTokens1d(g1dState, formatId, opts) — STATE first, FORMAT
83
+ second. Returns a string. toTokensMesh(mesh, formatId, opts) follows
84
+ the same pattern.
77
85
 
78
86
  Supported 1D formats: 'css', 'css-var', 'scss', 'tailwind', 'json', 'svg'.
79
87
  Supported mesh formats: 'css', 'css-var', 'json'.
@@ -87,28 +95,22 @@ Round-trips with formatCssLinear/Radial/Conic.
87
95
 
88
96
  ## Extract a palette from an image
89
97
 
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
98
+ // rgba is a Uint8ClampedArray of length width*height*4
99
+ const palette = extractPalette(rgba, 5);
100
+ // [{ l, c, h }, ...] up to 5 picks, chroma-weighted, 50° hue separated
101
+
102
+ Signature: extractPalette(pixels, count?). Width/height NOT required —
103
+ the algorithm scans linearly. `count` defaults to 5; output may be
104
+ shorter if the image lacks that many distinct hues.
93
105
 
94
106
  Chroma-weighted means vivid colors win over dominant-by-pixel-count
95
107
  colors. A baby-in-blue-shirt photo returns a palette with blue, not
96
108
  five shades of skin.
97
109
 
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 }
110
+ ## Convert OKLCH hex
106
111
 
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
+ toHex({ l: 0.65, c: 0.26, h: 320 }); // '#cf5fb9' (sRGB-gamut clipped)
113
+ fromHex('#cf5fb9'); // { l, c, h, a }
112
114
 
113
115
  # Stop shape
114
116
 
@@ -148,7 +150,7 @@ the rasterizer writes — no copy on putImageData.
148
150
  # Anti-patterns
149
151
 
150
152
  - DO NOT construct a new Gradient per frame in a hot loop. Make it once,
151
- store the reference, mutate via setPointColor / setPointPosition if
153
+ store the reference, mutate via setPoint / setPointPosition if
152
154
  the colors change.
153
155
  - DO NOT pass a fresh scratch object to sampleAt per call. Allocate
154
156
  one at module scope or per editor instance.
package/package.json CHANGED
@@ -1,34 +1,29 @@
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.0.1",
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",
8
- "main": "./src/index.js",
9
- "module": "./src/index.js",
10
- "types": "./src/index.d.ts",
8
+ "main": "src/index.js",
11
9
  "exports": {
12
- ".": {
13
- "node": "./src/index.js",
14
- "import": "./src/index.js",
15
- "types": "./src/index.d.ts",
16
- "default": "./src/index.js"
17
- }
10
+ ".": "./src/index.js"
18
11
  },
19
12
  "files": [
20
- "src/",
21
- "README.md",
13
+ "src",
22
14
  "llms.txt",
23
15
  "CHANGELOG.md",
24
- "LICENSE"
16
+ "LICENSE",
17
+ "README.md"
25
18
  ],
26
19
  "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"
20
+ "test": "node --test --expose-gc test/*.test.js",
21
+ "bench": "node --expose-gc bench/run.mjs"
22
+ },
23
+ "dependencies": {
24
+ "@zakkster/lite-color": "^1.0.6",
25
+ "@zakkster/lite-color-engine": "^1.0.0",
26
+ "@zakkster/lite-gradient": "^1.0.4"
32
27
  },
33
28
  "keywords": [
34
29
  "gradient",
@@ -43,39 +38,16 @@
43
38
  "color-interpolation",
44
39
  "perceptually-uniform",
45
40
  "zero-gc",
46
- "zero-allocation",
47
41
  "lite",
48
42
  "no-build",
49
- "esm",
50
- "rasterizer",
51
- "deterministic"
43
+ "esm"
52
44
  ],
53
- "homepage": "https://github.com/PeshoVurtoleta/lite-gradient-studio#readme",
45
+ "homepage": "https://github.com/PeshoVurtoleta/lite-gradient-studio#readme",
54
46
  "repository": {
55
47
  "type": "git",
56
- "url": "git+https://github.com/PeshoVurtoleta/lite-gradient-studio.git"
48
+ "url": "git+https://github.com/PeshoVurtoleta/lite-gradient-studio.git"
57
49
  },
58
50
  "bugs": {
59
- "url": "https://github.com/PeshoVurtoleta/lite-gradient-studio/issues",
60
- "email": "shinikchiev@yahoo.com"
61
- },
62
- "engines": {
63
- "node": ">=18"
64
- },
65
- "funding": {
66
- "type": "github",
67
- "url": "https://github.com/sponsors/PeshoVurtoleta"
68
- },
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"
51
+ "url": "https://github.com/PeshoVurtoleta/lite-gradient-studio/issues"
80
52
  }
81
53
  }
@@ -1,136 +1,88 @@
1
1
  /**
2
- * OKLCH <-> hex.
2
+ * OKLCH hex.
3
3
  *
4
4
  * Pure math; no DOM. Ported from `@zakkster/lite-hueforge` so this
5
- * library doesn't depend on Hueforge for a single utility -- and so
5
+ * library doesn't depend on Hueforge for a single utility and so
6
6
  * future consumers can use Gradient Studio's exporters standalone.
7
7
  *
8
8
  * `toHex` is the hot path (called once per stop per export). The
9
9
  * gamut mapper does a 10-iteration binary search on chroma when the
10
- * requested color is out of sRGB -- precision dC ~= 0.0002, which is
10
+ * requested color is out of sRGB precision Δc 0.0002, which is
11
11
  * well below perceptual threshold.
12
- *
13
- * Zero-GC discipline:
14
- * - `oklchToLinearSrgb(L, C, H, out?)`: pass a `[r, g, b]` array to
15
- * `out` for zero-allocation; otherwise allocates one fresh array.
16
- * - `linearSrgbToOklch(r, g, b, out?)`: pass a `{l, c, h}` object to
17
- * `out` for zero-allocation; otherwise allocates one fresh object.
18
- * - The internal binary-search helper uses a module-level scratch and
19
- * takes (L, cosH, sinH) by parameter rather than closing over them,
20
- * so no per-call closure is allocated either way.
21
12
  */
22
13
 
23
- /* Module-level scratch for the gamut-mapping binary search. Single-
24
- * threaded JS: safe to reuse across calls. Not exposed. */
25
- const _gamutTry = [0, 0, 0];
26
-
27
14
  /**
28
- * Evaluate (L, C, H) -> linear-sRGB into `dst[0..2]`.
29
- * Pure helper: takes everything by parameter, no closures. Inlines fine.
30
- */
31
- function evalOklchRgb(L, cosH, sinH, cVal, dst) {
32
- const a = cVal * cosH;
33
- const b = cVal * sinH;
34
- const lP = L + 0.3963377774 * a + 0.2158037573 * b;
35
- const mP = L - 0.1055613458 * a - 0.0638541728 * b;
36
- const sP = L - 0.0894841775 * a - 1.2914855480 * b;
37
- const lL = lP * lP * lP;
38
- const mL = mP * mP * mP;
39
- const sL = sP * sP * sP;
40
- dst[0] = +4.0767416621 * lL - 3.3077115913 * mL + 0.2309699292 * sL;
41
- dst[1] = -1.2684380046 * lL + 2.6097574011 * mL - 0.3413193965 * sL;
42
- dst[2] = -0.0041960863 * lL - 0.7034186147 * mL + 1.7076147010 * sL;
43
- }
44
-
45
- /**
46
- * OKLCH -> linear sRGB triplet, with sRGB-gamut mapping by chroma reduction.
47
- * Output range: each channel in [0, 1].
48
- *
49
- * @param {number} L
50
- * @param {number} C
51
- * @param {number} H
52
- * @param {number[]} [out] 3-element array. If provided, written in place
53
- * and returned (zero allocation). Otherwise a
54
- * fresh `[r, g, b]` array is allocated.
55
- * @returns {number[]} Same `out` if provided, else a new array.
15
+ * OKLCH linear sRGB triplet, with sRGB-gamut mapping by chroma reduction.
16
+ * Output: [r, g, b] each in [0, 1].
56
17
  */
57
- export function oklchToLinearSrgb(L, C, H, out) {
58
- if (!out) out = [0, 0, 0];
59
-
18
+ export function oklchToLinearSrgb(L, C, H) {
60
19
  const hRad = H * Math.PI / 180;
61
20
  const cosH = Math.cos(hRad);
62
21
  const sinH = Math.sin(hRad);
63
22
 
64
- // Try the requested chroma. If it's in-gamut, we're done.
65
- evalOklchRgb(L, cosH, sinH, C, out);
66
- if (
67
- out[0] >= 0 && out[0] <= 1 &&
68
- out[1] >= 0 && out[1] <= 1 &&
69
- out[2] >= 0 && out[2] <= 1
70
- ) {
71
- return out;
23
+ function getRgb(cVal) {
24
+ const a = cVal * cosH;
25
+ const b = cVal * sinH;
26
+ const lP = L + 0.3963377774 * a + 0.2158037573 * b;
27
+ const mP = L - 0.1055613458 * a - 0.0638541728 * b;
28
+ const sP = L - 0.0894841775 * a - 1.2914855480 * b;
29
+ const lL = lP * lP * lP;
30
+ const mL = mP * mP * mP;
31
+ const sL = sP * sP * sP;
32
+ const r = +4.0767416621 * lL - 3.3077115913 * mL + 0.2309699292 * sL;
33
+ const g = -1.2684380046 * lL + 2.6097574011 * mL - 0.3413193965 * sL;
34
+ const bb = -0.0041960863 * lL - 0.7034186147 * mL + 1.7076147010 * sL;
35
+ return [r, g, bb];
36
+ }
37
+
38
+ let [r, g, bb] = getRgb(C);
39
+
40
+ if (r >= 0 && r <= 1 && g >= 0 && g <= 1 && bb >= 0 && bb <= 1) {
41
+ return [r, g, bb];
72
42
  }
73
43
 
74
- // Out of gamut. Try C=0 (a neutral at the same L). If that's STILL
75
- // out of gamut, L is extreme (caller passed L > 1 or L < 0); clamp
76
- // channelwise and return.
77
- evalOklchRgb(L, cosH, sinH, 0, _gamutTry);
78
- if (
79
- _gamutTry[0] < 0 || _gamutTry[0] > 1 ||
80
- _gamutTry[1] < 0 || _gamutTry[1] > 1 ||
81
- _gamutTry[2] < 0 || _gamutTry[2] > 1
82
- ) {
83
- if (out[0] < 0) out[0] = 0; else if (out[0] > 1) out[0] = 1;
84
- if (out[1] < 0) out[1] = 0; else if (out[1] > 1) out[1] = 1;
85
- if (out[2] < 0) out[2] = 0; else if (out[2] > 1) out[2] = 1;
86
- return out;
44
+ // Gamut map via binary search on chroma. The C=0 point at this L is
45
+ // always in gamut if L [0,1]; defensive fallback covers caller
46
+ // passing out-of-range L.
47
+ const [gr, gg, gb] = getRgb(0);
48
+ if (gr < 0 || gr > 1 || gg < 0 || gg > 1 || gb < 0 || gb > 1) {
49
+ if (r < 0) r = 0; else if (r > 1) r = 1;
50
+ if (g < 0) g = 0; else if (g > 1) g = 1;
51
+ if (bb < 0) bb = 0; else if (bb > 1) bb = 1;
52
+ return [r, g, bb];
87
53
  }
88
54
 
89
- // Binary search for the largest in-gamut chroma in [0, C]. The fit
90
- // is held in `out`; the candidate at the current `midC` is in
91
- // `_gamutTry`. Precision dC ~= C / 2^10 ~= 0.0002 -- well below
92
- // perceptual threshold.
93
55
  let lo = 0, hi = C;
94
- out[0] = _gamutTry[0]; out[1] = _gamutTry[1]; out[2] = _gamutTry[2];
56
+ let fitR = gr, fitG = gg, fitB = gb;
95
57
  for (let i = 0; i < 10; i++) {
96
58
  const midC = (lo + hi) / 2;
97
- evalOklchRgb(L, cosH, sinH, midC, _gamutTry);
98
- if (
99
- _gamutTry[0] >= 0 && _gamutTry[0] <= 1 &&
100
- _gamutTry[1] >= 0 && _gamutTry[1] <= 1 &&
101
- _gamutTry[2] >= 0 && _gamutTry[2] <= 1
102
- ) {
59
+ const [mr, mg, mbb] = getRgb(midC);
60
+ if (mr >= 0 && mr <= 1 && mg >= 0 && mg <= 1 && mbb >= 0 && mbb <= 1) {
103
61
  lo = midC;
104
- out[0] = _gamutTry[0];
105
- out[1] = _gamutTry[1];
106
- out[2] = _gamutTry[2];
62
+ fitR = mr; fitG = mg; fitB = mbb;
107
63
  } else {
108
64
  hi = midC;
109
65
  }
110
66
  }
111
- return out;
67
+ return [fitR, fitG, fitB];
112
68
  }
113
69
 
114
- /** sRGB transfer (linear -> gamma-encoded). IEC 61966-2-1. */
70
+ /** sRGB transfer (linear gamma-encoded). IEC 61966-2-1. */
115
71
  export function srgbGamma(x) {
116
72
  return x <= 0.0031308
117
73
  ? x * 12.92
118
74
  : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
119
75
  }
120
76
 
121
- /** sRGB inverse transfer (gamma-encoded -> linear). */
77
+ /** sRGB inverse transfer (gamma-encoded linear). */
122
78
  export function srgbInverseGamma(x) {
123
79
  return x <= 0.04045
124
80
  ? x / 12.92
125
81
  : Math.pow((x + 0.055) / 1.055, 2.4);
126
82
  }
127
83
 
128
- /* Scratch for `toHex`. Single allocation at module load; safe to reuse
129
- * because toHex completes synchronously and JS is single-threaded. */
130
- const _toHexRgb = [0, 0, 0];
131
-
132
84
  /**
133
- * OKLCH -> hex. Emits '#rrggbb' for opaque colors (a >= 1 or undefined)
85
+ * OKLCH hex. Emits '#rrggbb' for opaque colors (a >= 1 or undefined)
134
86
  * and '#rrggbbaa' when alpha is below 1. Output always lowercase.
135
87
  *
136
88
  * Bit-pack trick: `(1<<24) | (R<<16) | (G<<8) | B` produces a number
@@ -139,10 +91,10 @@ const _toHexRgb = [0, 0, 0];
139
91
  * intermediate garbage.
140
92
  */
141
93
  export function toHex({ l, c, h, a }) {
142
- oklchToLinearSrgb(l, c, h, _toHexRgb);
143
- const R = (srgbGamma(_toHexRgb[0]) * 255 + 0.5) | 0;
144
- const G = (srgbGamma(_toHexRgb[1]) * 255 + 0.5) | 0;
145
- const B = (srgbGamma(_toHexRgb[2]) * 255 + 0.5) | 0;
94
+ const linRgb = oklchToLinearSrgb(l, c, h);
95
+ const R = (srgbGamma(linRgb[0]) * 255 + 0.5) | 0;
96
+ const G = (srgbGamma(linRgb[1]) * 255 + 0.5) | 0;
97
+ const B = (srgbGamma(linRgb[2]) * 255 + 0.5) | 0;
146
98
  const rgb = '#' + ((1 << 24) | (R << 16) | (G << 8) | B).toString(16).slice(1);
147
99
  // Opacity: skip the alpha byte when fully opaque, so the common case
148
100
  // produces clean 6-char hex matching design-tool conventions.
@@ -157,19 +109,9 @@ function clampByte(v) {
157
109
  }
158
110
 
159
111
  /**
160
- * Linear sRGB -> OKLCH. Used by fromHex and the palette extractor.
161
- *
162
- * @param {number} r Linear red, [0, 1].
163
- * @param {number} g
164
- * @param {number} b
165
- * @param {{l:number,c:number,h:number,a?:number}} [out]
166
- * If provided, written in place and returned (zero allocation).
167
- * The `a` field is left untouched -- callers that need alpha
168
- * plumbing set it themselves.
169
- * @returns {{l:number,c:number,h:number}} Same `out` if provided.
112
+ * Linear sRGB OKLCH. Used by fromHex and the palette extractor.
170
113
  */
171
- export function linearSrgbToOklch(r, g, b, out) {
172
- if (!out) out = { l: 0, c: 0, h: 0 };
114
+ export function linearSrgbToOklch(r, g, b) {
173
115
  const lLms = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
174
116
  const mLms = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
175
117
  const sLms = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
@@ -177,17 +119,16 @@ export function linearSrgbToOklch(r, g, b, out) {
177
119
  const mP = Math.cbrt(mLms);
178
120
  const sP = Math.cbrt(sLms);
179
121
  const L = 0.2104542553 * lP + 0.7936177850 * mP - 0.0040720468 * sP;
180
- const aa = 1.9779984951 * lP - 2.4285922050 * mP + 0.4505937099 * sP;
122
+ const a = 1.9779984951 * lP - 2.4285922050 * mP + 0.4505937099 * sP;
181
123
  const bb = 0.0259040371 * lP + 0.7827717662 * mP - 0.8086757660 * sP;
182
- const C = Math.sqrt(aa * aa + bb * bb);
183
- let H = Math.atan2(bb, aa) * 180 / Math.PI;
124
+ const C = Math.sqrt(a * a + bb * bb);
125
+ let H = Math.atan2(bb, a) * 180 / Math.PI;
184
126
  if (H < 0) H += 360;
185
- out.l = L; out.c = C; out.h = H;
186
- return out;
127
+ return { l: L, c: C, h: H };
187
128
  }
188
129
 
189
130
  /**
190
- * Hex string -> { l, c, h, a }. Accepts:
131
+ * Hex string { l, c, h, a }. Accepts:
191
132
  * - 3-char '#rgb' (alpha defaults to 1)
192
133
  * - 4-char '#rgba' (each nibble doubled)
193
134
  * - 6-char '#rrggbb' (alpha defaults to 1)
package/src/exporters.js CHANGED
@@ -82,6 +82,27 @@ export function toTokensMesh(mesh, format, opts = {}) {
82
82
 
83
83
  /* ── 1D exporters ────────────────────────────────────────────────── */
84
84
 
85
+ /**
86
+ * Read the position field from a stop, accepting both conventions.
87
+ *
88
+ * The 1D state shape carries position as `stop` (matches the editor's
89
+ * field name and the exporter signature documented in `toTokens1d`).
90
+ * The `Gradient` class (re-exported from `@zakkster/lite-gradient`)
91
+ * carries it as `pos`. README quick-start examples that pipe
92
+ * `gradient.stops` directly into `toTokens1d` would otherwise produce
93
+ * `NaN%` positions; this fallback makes either shape work without a
94
+ * caller-side map step.
95
+ *
96
+ * Precedence: `stop` first (it's the documented field), `pos` as
97
+ * fallback. Returns 0 if neither is set — defensive, mirrors how the
98
+ * rest of the exporter treats malformed input.
99
+ */
100
+ function readStopPos(s) {
101
+ if (s.stop !== undefined) return s.stop;
102
+ if (s.pos !== undefined) return s.pos;
103
+ return 0;
104
+ }
105
+
85
106
  /** Return the raw CSS gradient string for this 1D state. */
86
107
  function cssBodyFor1d(g) {
87
108
  // Build a Gradient-shaped stops array (renames `stop` → `pos`).
@@ -90,7 +111,7 @@ function cssBodyFor1d(g) {
90
111
  const stopsForCss = g.stops.map((s) => ({
91
112
  l: s.l, c: s.c, h: s.h,
92
113
  a: s.a === undefined ? 1 : s.a,
93
- pos: s.stop,
114
+ pos: readStopPos(s),
94
115
  }));
95
116
  const fake = { stops: stopsForCss }; // formatCss* only reads .stops
96
117
  if (g.mode === 'linear') {
@@ -162,7 +183,7 @@ export function toJson1d(g, opts = {}) {
162
183
  interpolation: 'oklch',
163
184
  stops: g.stops.map((s) => {
164
185
  const stop = {
165
- position: round3(s.stop),
186
+ position: round3(readStopPos(s)),
166
187
  color: { l: round3(s.l), c: round3(s.c), h: round1(s.h) },
167
188
  hex: toHex(s),
168
189
  };
package/src/mesh.js CHANGED
@@ -281,29 +281,36 @@ export class MeshGradient {
281
281
  }
282
282
 
283
283
  /**
284
- * Sample the gradient at parametric (u, v) in [0, 1]^2 into `out`.
285
- * Zero-GC: writes into the caller-owned `out` object to avoid the
286
- * per-pixel allocation a return-by-value version would force.
287
- * Bilinear in OKLCH, hue-shortest-path via `lerpOklchTo`.
284
+ * Sample the mesh at normalized (u, v) [0, 1]² into `out`.
285
+ * Zero-GC. Bilinear in OKLCH, hue-shortest-path via lerpOklchTo.
288
286
  *
289
- * Hue normalization matters: `lerpOklchTo` picks the shortest hue
290
- * path but does NOT bound its output to [0, 360). At extreme corners
291
- * or after wrap-crossing lerps, the intermediate hue can land at 390
292
- * or -30. Left unnormalized, that propagates into the second lerp
293
- * and adds another 360 to the final hue. We normalize after each
294
- * stage.
287
+ * Hue normalization: lerpOklchTo takes the shortest-path delta but
288
+ * does NOT bound its output to [0, 360). At extreme corners or after
289
+ * wrap-crossing lerps, the intermediate hue can land at 390 or -30.
290
+ * Left unnormalized, that propagates into the second lerp and adds
291
+ * another 360 to the final hue. We normalize after each stage.
292
+ *
293
+ /**
294
+ * Sample the gradient at parametric (u, v) ∈ [0, 1]². Writes into `out`
295
+ * to avoid the per-pixel allocation a return-by-value version would force.
296
+ *
297
+ * Hue normalization matters: `lerpOklchTo` picks the shortest hue path but
298
+ * does NOT bound its output to [0, 360). At extreme corners or after
299
+ * wrap-crossing lerps, the intermediate hue can land at 390 or -30.
300
+ * Left unnormalized, that propagates into the second lerp and adds
301
+ * another 360 to the final hue. We normalize after each stage.
295
302
  *
296
303
  * The optional fourth parameter selects interpolation. Accepts:
297
- * - false / undefined -> 'bilinear' (original, C^0)
298
- * - true -> 'smooth' (smoothstep on cu/cv, C^1 at seams)
299
- * - 'cubic' -> Catmull-Rom 2D (C^1 everywhere; ~4x cost)
304
+ * - false / undefined 'bilinear' (original, C)
305
+ * - true 'smooth' (smoothstep on cu/cv, C¹ at seams)
306
+ * - 'cubic' Catmull-Rom 2D (C¹ everywhere; cost)
300
307
  * - any of the strings above explicitly
301
308
  *
302
309
  * @param {number} u
303
310
  * @param {number} v
304
- * @param {{l:number,c:number,h:number,a?:number}} out
311
+ * @param {{l:number,c:number,h:number}} out
305
312
  * @param {boolean|string} [modeOrSmooth=false]
306
- * @returns {{l:number,c:number,h:number,a:number}} same `out`
313
+ * @returns {{l:number,c:number,h:number}} same `out`
307
314
  */
308
315
  sampleAt(u, v, out, modeOrSmooth = false) {
309
316
  // Normalize the legacy boolean to the string form. Hot path —
@@ -11,32 +11,32 @@
11
11
  * top buckets from the warm cluster (variants of skin) and the blue
12
12
  * either gets dedup'd against the warm picks or ranked below them.
13
13
  * Designer's lived expectation: "import this photo of a baby in a blue
14
- * shirt -> I should see blue in the palette."
14
+ * shirt I should see blue in the palette."
15
15
  *
16
- * The fix is to count CHROMA, not pixels -- and to enforce a minimum hue
17
- * separation between picks. A handful of vivid-blue pixels at C ~= 0.20
18
- * contribute more weight than a thousand near-grey pixels at C ~= 0.02.
19
- * A 50 deg hue-separation rule then guarantees the second pick can't be
16
+ * The fix is to count CHROMA, not pixels and to enforce a minimum hue
17
+ * separation between picks. A handful of vivid-blue pixels at C 0.20
18
+ * contribute more weight than a thousand near-grey pixels at C 0.02.
19
+ * A 50° hue-separation rule then guarantees the second pick can't be
20
20
  * another shade of the first pick's hue.
21
21
  *
22
22
  * Algorithm:
23
23
  * 1. Single-pass walk of RGBA pixels. Convert each to OKLCH inline
24
- * (no per-pixel function call overhead -- the matrix path is
24
+ * (no per-pixel function call overhead the matrix path is
25
25
  * duplicated from color-convert.js for speed).
26
- * 2. Skip pixels darker than `darkFloor` (L < 0.10) -- shadows, not
26
+ * 2. Skip pixels darker than `darkFloor` (L < 0.10) shadows, not
27
27
  * "colors". Skip fully-transparent pixels.
28
28
  * 3. Pixels below `chromaticThreshold` (C < 0.02) feed a NEUTRAL
29
- * pool -- accumulated L average becomes the single representative
29
+ * pool accumulated L average becomes the single representative
30
30
  * neutral output, slotted at the desired position.
31
- * 4. Chromatic pixels are binned into 24 hue buckets (15 deg each).
31
+ * 4. Chromatic pixels are binned into 24 hue buckets (15° each).
32
32
  * Per bucket we track: total chroma weight, count, and the
33
33
  * MOST VIVID pixel's L/C/H (this becomes the bucket's
34
- * representative -- picking the most-vivid avoids muddy
34
+ * representative picking the most-vivid avoids muddy
35
35
  * "average" colors that real-photo k-means produces).
36
36
  * 5. Sort buckets by total chroma weight. Greedy pick top buckets,
37
37
  * enforcing `minHueSeparation` between picks. If we can't fill
38
- * enough slots at 50 deg, progressively relax to 30 / 15 / 0.
39
- * 6. Sort the final picks by L for natural dark->light gradient order.
38
+ * enough slots at 50°, progressively relax to 30° / 15° / 0°.
39
+ * 6. Sort the final picks by L for natural darklight gradient order.
40
40
  *
41
41
  * Behavior preserved from v1:
42
42
  * - Returns Array<{l, c, h}> sorted by lightness ascending.
@@ -51,38 +51,18 @@
51
51
  * 1D callers still get the same neutral via the standard pick path
52
52
  * when they request enough slots.
53
53
  *
54
- * Performance: a 240x180 sample = 43 200 pixels iterates in ~5 ms on
55
- * a typical laptop. Zero per-pixel allocation, zero per-call allocation
56
- * once the module is loaded: the 24-entry bucket state is module-scoped
57
- * and reset (not re-allocated) on each call.
54
+ * Performance: a 240×180 sample = 43 200 pixels iterates in ~5 ms on
55
+ * a typical laptop. Single Map allocation (none — pre-allocated
56
+ * buckets array). Zero per-pixel allocations in the inner loop.
58
57
  */
59
58
 
60
- const HUE_BUCKETS = 24; // 15 deg per bucket
59
+ const HUE_BUCKETS = 24; // 15° per bucket
61
60
  const DEFAULT_MIN_HUE_SEP = 50; // initial hue separation requirement
62
- const RELAXATION_LADDER = [30, 15, 0]; // tried in order if 50 deg underfills
63
- const CHROMATIC_THRESHOLD = 0.02; // C below this -> neutral pool
64
- const DARK_FLOOR = 0.10; // L below this -> skip (shadow)
61
+ const RELAXATION_LADDER = [30, 15, 0]; // tried in order if 50° underfills
62
+ const CHROMATIC_THRESHOLD = 0.02; // C below this neutral pool
63
+ const DARK_FLOOR = 0.10; // L below this skip (shadow)
65
64
  const NEUTRAL_CHROMA = 0.005; // tiny tint for the neutral slot
66
65
 
67
- /* Module-level bucket state. One allocation at module load; reset per
68
- * call. Single-threaded JS: safe to share across calls because each
69
- * `extractPalette` runs synchronously to completion. */
70
- const _buckets = new Array(HUE_BUCKETS);
71
- for (let i = 0; i < HUE_BUCKETS; i++) {
72
- _buckets[i] = { weight: 0, count: 0, maxC: 0, mcL: 0, mcH: 0 };
73
- }
74
-
75
- /* Reusable scratch for the "filtered + sorted buckets" view. Sized to
76
- * fit all chromatic buckets; trim length per call. */
77
- const _sortedBuckets = new Array(HUE_BUCKETS);
78
-
79
- function resetBuckets() {
80
- for (let i = 0; i < HUE_BUCKETS; i++) {
81
- const b = _buckets[i];
82
- b.weight = 0; b.count = 0; b.maxC = 0; b.mcL = 0; b.mcH = 0;
83
- }
84
- }
85
-
86
66
  /**
87
67
  * @param {Uint8ClampedArray} pixels RGBA, length must be multiple of 4.
88
68
  * @param {number} [count] Target palette size (default 5).
@@ -92,25 +72,27 @@ function resetBuckets() {
92
72
  export function extractPalette(pixels, count = 5) {
93
73
  if (count < 1) return [];
94
74
 
95
- // Reset module-scope state. Doing this once per call beats allocating
96
- // 24 fresh objects every call.
97
- resetBuckets();
75
+ // Pre-allocated bucket state written in place, never reallocated.
76
+ const buckets = new Array(HUE_BUCKETS);
77
+ for (let i = 0; i < HUE_BUCKETS; i++) {
78
+ buckets[i] = { weight: 0, count: 0, maxC: 0, mcL: 0, mcH: 0 };
79
+ }
98
80
  let neutralLsum = 0;
99
81
  let neutralCount = 0;
100
82
 
101
- // Single pass. Convert each pixel sRGB byte -> OKLCH inline to keep
83
+ // Single pass. Convert each pixel sRGB byte OKLCH inline to keep
102
84
  // the hot path allocation-free. The matrix coefficients are the
103
- // canonical OKLab transform (Bjorn Ottosson, 2020).
85
+ // canonical OKLab transform (Björn Ottosson, 2020).
104
86
  for (let i = 0; i < pixels.length; i += 4) {
105
87
  if (pixels[i + 3] < 128) continue; // skip transparent
106
88
 
107
89
  const r8 = pixels[i], g8 = pixels[i + 1], b8 = pixels[i + 2];
108
90
  const rN = r8 / 255, gN = g8 / 255, bN = b8 / 255;
109
- // sRGB inverse gamma -> linear sRGB.
91
+ // sRGB inverse gamma linear sRGB.
110
92
  const rL = rN <= 0.04045 ? rN / 12.92 : Math.pow((rN + 0.055) / 1.055, 2.4);
111
93
  const gL = gN <= 0.04045 ? gN / 12.92 : Math.pow((gN + 0.055) / 1.055, 2.4);
112
94
  const bL = bN <= 0.04045 ? bN / 12.92 : Math.pow((bN + 0.055) / 1.055, 2.4);
113
- // Linear sRGB -> LMS' (cube-rooted) -> OKLab.
95
+ // Linear sRGB LMS' (cube-rooted) OKLab.
114
96
  const lp = Math.cbrt(0.4122214708 * rL + 0.5363325363 * gL + 0.0514459929 * bL);
115
97
  const mp = Math.cbrt(0.2119034982 * rL + 0.6806995451 * gL + 0.1073969566 * bL);
116
98
  const sp = Math.cbrt(0.0883024619 * rL + 0.2817188376 * gL + 0.6299787005 * bL);
@@ -121,18 +103,18 @@ export function extractPalette(pixels, count = 5) {
121
103
 
122
104
  if (L < DARK_FLOOR) continue; // shadows are not colors
123
105
 
124
- if (C < CHROMATIC_THRESHOLD) { // near-grey -> neutral pool
106
+ if (C < CHROMATIC_THRESHOLD) { // near-grey neutral pool
125
107
  neutralLsum += L;
126
108
  neutralCount++;
127
109
  continue;
128
110
  }
129
111
 
130
- // Hue in [0, 360). atan2 returns [-pi, pi].
112
+ // Hue in [0, 360). atan2 returns [-π, π].
131
113
  let H = Math.atan2(B, A) * 180 / Math.PI;
132
114
  if (H < 0) H += 360;
133
115
 
134
116
  const bucketIdx = (H * HUE_BUCKETS / 360) | 0;
135
- const bkt = _buckets[bucketIdx];
117
+ const bkt = buckets[bucketIdx];
136
118
  bkt.weight += C; // chroma-weighted, NOT pixel-count weighted
137
119
  bkt.count++;
138
120
  if (C > bkt.maxC) { // track the most-vivid pixel as the rep
@@ -142,25 +124,18 @@ export function extractPalette(pixels, count = 5) {
142
124
  }
143
125
  }
144
126
 
145
- // Filter occupied buckets into the sorted view. In-place sort by
146
- // total chroma weight, descending.
147
- let sortedLen = 0;
148
- for (let i = 0; i < HUE_BUCKETS; i++) {
149
- if (_buckets[i].count > 0) _sortedBuckets[sortedLen++] = _buckets[i];
150
- }
151
- // Truncate-length sort via a small bubble-like pass would dominate
152
- // for sortedLen <= 24; Array.prototype.sort on the sliced view is
153
- // O(n log n) and the allocation is one tiny temporary. We sort the
154
- // module-scope view itself in-place over its first sortedLen entries.
155
- sortInPlaceByWeightDesc(_sortedBuckets, sortedLen);
127
+ // Sort buckets by total chroma weight, descending. Only consider
128
+ // buckets that actually received pixels.
129
+ const sortedBuckets = buckets
130
+ .filter((b) => b.count > 0)
131
+ .sort((a, b) => b.weight - a.weight);
156
132
 
157
133
  // Greedy pick respecting min hue separation. The separation rule is
158
134
  // what surfaces cool subject colors from a warm-dominated photo.
159
135
  const pickWithSeparation = (separation) => {
160
136
  const picks = [];
161
- for (let i = 0; i < sortedLen; i++) {
137
+ for (const b of sortedBuckets) {
162
138
  if (picks.length >= count) break;
163
- const b = _sortedBuckets[i];
164
139
  let tooClose = false;
165
140
  for (const p of picks) {
166
141
  const d = Math.abs(b.mcH - p.h) % 360;
@@ -171,7 +146,7 @@ export function extractPalette(pixels, count = 5) {
171
146
  return picks;
172
147
  };
173
148
 
174
- // Try the canonical 50 deg separation. If the image is genuinely low-
149
+ // Try the canonical 50° separation. If the image is genuinely low-
175
150
  // diversity (monochrome, hue-collapsed), relax progressively.
176
151
  let picks = pickWithSeparation(DEFAULT_MIN_HUE_SEP);
177
152
  if (picks.length < count) {
@@ -193,24 +168,7 @@ export function extractPalette(pixels, count = 5) {
193
168
  });
194
169
  }
195
170
 
196
- // Lightness-ascending output -- natural dark->light gradient order.
171
+ // Lightness-ascending output natural darklight gradient order.
197
172
  picks.sort((a, b) => a.l - b.l);
198
173
  return picks;
199
174
  }
200
-
201
- /* In-place insertion sort of buckets[0..n-1] by `weight` descending.
202
- * For n <= 24 (HUE_BUCKETS) this is O(n^2 / 2) ~= 288 comparisons in
203
- * the worst case -- faster and allocation-free vs `Array.prototype
204
- * .sort` which builds a temporary copy. */
205
- function sortInPlaceByWeightDesc(arr, n) {
206
- for (let i = 1; i < n; i++) {
207
- const cur = arr[i];
208
- const curW = cur.weight;
209
- let j = i - 1;
210
- while (j >= 0 && arr[j].weight < curW) {
211
- arr[j + 1] = arr[j];
212
- j--;
213
- }
214
- arr[j + 1] = cur;
215
- }
216
- }