@zakkster/lite-gradient-studio 1.0.1 → 1.2.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,340 @@ 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.2.0] — 2026-07-14 "Tiles + Dither"
9
+
10
+ The **toroidal-mesh + dither release**. Both roadmap tracks (D1-D6 wrap,
11
+ D7-D8 dither) land together.
12
+
13
+ > Originally cut as two patches, `1.2.0` (wrap) and `1.2.1` (dither), on the
14
+ > assumption that 1.2.0 had shipped. It had not — npm's latest is 1.1.0 — so a
15
+ > patch bump on top of an unpublished minor would have invented a version that
16
+ > never existed. Folded into a single minor.
17
+
18
+ ---
19
+
20
+ ## Wrap (D1-D6)
21
+
22
+ The **toroidal-mesh release**. `MeshGradient` becomes cylindrical or
23
+ toroidal on demand, with a real C¹ seam in cubic mode — the flagship
24
+ feature: read real neighbours across the boundary via modulo indexing
25
+ instead of clamping to duplicated endpoints.
26
+
27
+ ### Added — Structural wrap on the constructor
28
+
29
+ `new MeshGradient(cols, rows, stops, { wrapX, wrapY })`. Both flags are
30
+ optional, default `false`, and independent (torus = both on; cylinder =
31
+ one on). Wrap is stored on the instance as `readonly wrapX` / `readonly
32
+ wrapY` for downstream consumers to branch on.
33
+
34
+ - **UV period.** On a wrapped axis the cell count is `cols`, not
35
+ `cols - 1`. Default control-point positions become `col / cols`.
36
+ - **`sampleAt` (bilinear + smooth).** Wrapped axis wraps the coord via
37
+ `u - Math.floor(u)`, then reads the last-cell neighbour with `(col + 1)
38
+ % cols` — so `sampleAt(1, v)` is exactly `sampleAt(0, v)`, any float
39
+ is a valid position (raw accumulating animation phase, negatives,
40
+ arbitrary magnitudes).
41
+ - **`sampleAt` cubic — C¹ continuity across the seam.** The whole
42
+ reason wrap exists: `_sampleAtCubic` and `_cubicRow` use modulo
43
+ neighbour indexing on wrapped axes (`((col - 1) % cols + cols) % cols`
44
+ and `(col + 2) % cols`). Catmull-Rom now reads real neighbours across
45
+ the boundary, giving a genuinely smooth seam instead of a spline that
46
+ degrades toward bilinear at the edge. Bilinear and smooth get
47
+ seamlessness from the wrap coord alone; cubic is where wrap earns
48
+ "exists in no other library."
49
+ - **`rasterizeTo` period sampling.** On wrapped axes, `u = x / width`
50
+ (drops the `- 1` divisor). Pixel column 0 of the "next tile" lands
51
+ exactly at `u ≡ 0` — no duplicated edge column, so `drawImage`-based
52
+ tiling of the rasterized output butts perfectly.
53
+ - **`rasterizeDeformedTo` fails loudly on wrap.** Throws with
54
+ `err.code = 'WRAP_DEFORMED_UNSUPPORTED'` when called on a wrapped
55
+ mesh. Deformed + wrap needs ghost quads that cross the seam (Newton
56
+ solve against wrapped corner positions) — real work deferred to v1.3.
57
+ Silent seamed output would be worse than the error; consumer code can
58
+ branch on the code string instead of matching messages.
59
+ - **`defaultMeshColor` — wrap-aware defaults.** Trailing `wrapX` /
60
+ `wrapY` args (backward-compat: absent → v1.1.0 output byte-identical).
61
+ When `wrapX`, hue advances a uniform `360/cols` per column (no
62
+ aperiodic compression at the wrap boundary). When `wrapY`, the row
63
+ drift becomes sinusoidal and L switches to `cos(2π · rT)` so
64
+ top and bottom rows agree.
65
+
66
+ ### Guarantees
67
+
68
+ - **Byte-parity on the non-wrap path.** All 218 pre-existing tests pass
69
+ unmodified. The alloc test file gains three new wrapped-rasterize
70
+ invariants (all under the same 128 KB / 100-frame ceiling). Every
71
+ non-wrap code path — constructor with no opts, `sampleAt` on a
72
+ non-wrapped mesh, `rasterizeTo` on a non-wrapped mesh — walks the
73
+ exact same v1.1.0 code, byte for byte.
74
+ - **Empirical wrap-on ceilings (this container, node 22):**
75
+
76
+ | path | v1.1.0 baseline | v1.2.0 wrapped | overhead |
77
+ |---|---|---|---|
78
+ | `sampleAt` 5×5 smooth | 5.2M ops/s | 5.0M wrapX / 4.7M torus | 4–10% |
79
+ | `sampleAt` 5×5 cubic | 1.9M ops/s | 2.0M wrapX / 2.0M torus | within noise |
80
+ | `rasterizeTo` 5×5 → 256² | 2.1M px/s | 2.3M px/s wrapped | JIT ties or wins |
81
+ | `rasterizeTo` 5×5 cubic → 256² | (not measured) | 2.3M px/s wrapX | ceiling set |
82
+
83
+ Wrap paths came in on parity or a hair faster; the branch structure
84
+ turns out to be slightly cleaner for the JIT than the non-wrap
85
+ interior-clamp branches. Empirical, not prior-based — the ceilings
86
+ above are the number to beat in T3.
87
+
88
+ ### Peer bumps
89
+
90
+ - `@zakkster/lite-color-engine`: `^1.0.0 → ^1.5.0`. T3's dither work
91
+ will delegate to engine v1.5's `getBlueNoise64` + dithered packers.
92
+ - `@zakkster/lite-gradient`: `^1.1.0 → ^1.2.0`. Picks up `Gradient`'s
93
+ new `closed: true` for downstream `formatCssConic` work.
94
+
95
+ ### Notes
96
+
97
+ - 31 new wrap tests in `test/mesh-wrap.test.js` covering: D1 (opts +
98
+ guards + cols=2 wrapped legality), D2 (period spacing, seam identity
99
+ in bilinear/smooth, raw-phase and negative-u sampling, tile-equality
100
+ across 21 sample points, non-wrap axis retains clamp), D3 (cubic
101
+ seam identity, C¹ derivative test via central differences,
102
+ real-neighbour indexing verified against a deliberately non-uniform
103
+ L mesh), D4 (rasterize period sampling, non-wrap y retains closed
104
+ interval), D5 (WRAP_DEFORMED_UNSUPPORTED on wrapX / wrapY / torus,
105
+ non-wrap deformed still works), D6 (byte-parity of the four-arg
106
+ form, periodic hue step and L symmetry, integration with
107
+ `MeshGradient` defaults).
108
+ - Wrap suite: 252 green (218 pre-existing + 31 wrap + 3 wrap-alloc
109
+ invariants) at the point wrap landed; 271 with dither folded in.
110
+
111
+ ---
112
+
113
+ ## Known / measured — the allocation gate is a leak gate
114
+
115
+ `test/allocation.test.js` asserts things like *"MeshGradient.rasterizeTo x 100 frames
116
+ at 128x128 stays under 128 KB"*, measured as `gc(); gc(); heapUsed` before and after.
117
+ That is a **leak** gate. It measures RETAINED memory, and it is structurally blind to
118
+ short-lived garbage — a value allocated and dropped inside the loop is scavenged
119
+ before the second sample and never appears in the delta.
120
+
121
+ `rasterizeTo` does allocate, and the gate cannot see it. Measured by counting GC
122
+ events over a fixed wall-clock budget, against a zero-alloc arithmetic control and a
123
+ one-object-per-pixel control in the same process:
124
+
125
+ ```
126
+ zero-alloc floor (arithmetic) 1 GC / 6 s
127
+ allocating ceiling (1 obj/pixel) 3587 GC / 6 s
128
+ rasterizeTo bilinear, no dither 72 GC / 6 s <- not the floor
129
+ ```
130
+
131
+ The source is not a `new` anywhere in the loop — there isn't one. It is
132
+ `const tmp = { l: 0, c: 0, h: 0, a: 1 }`. **V8 removed double-field unboxing in 9.x**,
133
+ so every write of a non-Smi double to an object property boxes a `HeapNumber` — and
134
+ `sampleAt` writes four of them per pixel, 16,384 times per raster. Isolated:
135
+
136
+ ```
137
+ write 4 doubles into an OBJECT, 16384x -> 3519 GC
138
+ write 4 doubles into a Float64Array, 16384x -> 497 GC
139
+ write 4 Smis into an OBJECT, 16384x -> 496 GC
140
+ ```
141
+
142
+ **Cost, in the only unit that matters: 0.37%–0.95% of wall time** in a synthetic loop
143
+ running 150 rasters/sec; roughly half that at a realistic 60/sec. It is real, it is
144
+ measurable, and it does not matter.
145
+
146
+ Not fixed, deliberately. The only way to remove it is a `Float64Array` scratch, which
147
+ means changing `sampleAt(u, v, out, mode)`'s public object-out contract or duplicating
148
+ the sampler. That is a bad trade for half a percent. Logged with the number so the
149
+ call is informed.
150
+
151
+ What *should* change is the gate's name and its comment, which currently imply it
152
+ proves zero-GC. It proves no leak. Those are different claims, and the ecosystem has
153
+ now made this mistake in three packages.
154
+
155
+ ---
156
+
157
+ ## Dither (D7-D8)
158
+
159
+ The dither track (D7/D8). One flag on `rasterizeTo` and one scalar-arg
160
+ packer wrapper. `dither: false` (or absent) is byte-identical to the
161
+ undithered path — verified, not asserted.
162
+
163
+ ### Added — `opts.dither: true` on `MeshGradient.rasterizeTo`
164
+
165
+ Delegates to `@zakkster/lite-color-engine >= 1.5.0`'s
166
+ `packOklchBufferToUint32Dithered` + `getBlueNoise64` — the roadmap's D7
167
+ already ratified the engine as the noise-source owner, so studio
168
+ consumes and does not duplicate:
169
+
170
+ - **Per-pixel tile lookup** — `tile[((y & 63) << 6) | (x & 63)]`. Pure
171
+ bit ops (torus wrap via mask, row stride via shift), zero-GC trivially.
172
+ - **Same noise value shared R/G/B at a pixel** — luminance-patterned
173
+ dither, no chroma speckle. Verified by a test that rasterizes a
174
+ chroma-free mesh with dither on and asserts R === G === B per pixel.
175
+ - **Alpha undithered.** Verified against undithered α on a distinctive
176
+ α mesh.
177
+ - **`noise01 = 0.5` reproduces the plain packer exactly** — the identity
178
+ anchor from D8, verified across seven representative OKLCH triplets
179
+ covering different gamma-encode regions and alpha values.
180
+ - **`dither: false` or absent → byte-identical to v1.2.0.** The branch
181
+ is resolved once per rasterize call, two loop bodies — no per-pixel
182
+ branch cost on the undithered path. Six regression tests assert this
183
+ across bilinear, smooth, cubic, wrapped, and `dither: false` vs
184
+ absent-opt.
185
+
186
+ ### Added — `packOklchSingleDithered(l, c, h, alpha, noise01)`
187
+
188
+ New export from `./bake.js`. Scalar-arg convenience wrapper around the
189
+ engine's `packOklchBufferToUint32Dithered` using the same
190
+ `Float32Array(3)` scratch as `packOklchSingle` — no additional module-
191
+ level allocation, safe to alternate between plain and dithered calls
192
+ in the rasterize inner loop.
193
+
194
+ ### Empirical dither ceilings (node 22, container)
195
+
196
+ | path | undithered | dithered | overhead |
197
+ |---|---|---|---|
198
+ | `rasterizeTo` 5×5 smooth → 256² | 2.1M px/s | 1.6M px/s | ~24% |
199
+ | `rasterizeTo` 5×5 wrapX+smooth | 2.5M px/s | 1.9M px/s | ~24% |
200
+ | `rasterizeTo` 5×5 cubic | (v1.2.0 gate) | 1.7M px/s | ceiling set |
201
+
202
+ The 24% overhead pays for one blue-noise tile lookup + a threshold-offset
203
+ gamma-encode round per pixel (the engine's dithered packer inlines the
204
+ sRGB gamma-encode inline instead of calling out to `linearToSrgbByte`,
205
+ which is why the overhead is small).
206
+
207
+ ### Test count
208
+
209
+ +15 dither tests in `test/mesh-dither.test.js`:
210
+ - Off-flag byte parity: 6 tests (bilinear, smooth, cubic, wrapped,
211
+ `dither: false` explicit, bare-opts).
212
+ - `noise01 = 0.5` identity anchor: 1 test, 7 OKLCH samples.
213
+ - Determinism: 2 tests (plain, wrap+dither composition).
214
+ - Bounded deviation ±1 per channel: 2 tests (typical mesh, shallow-ramp).
215
+ - Contract properties: 2 tests (alpha never dithered, R === G === B on
216
+ chroma-free mesh).
217
+ - Run-length shortening on shallow ramp: 1 test.
218
+ - Zero-GC under `--expose-gc`: 1 test (128 KB / 100 frames).
219
+
220
+ +2 dither-alloc invariants in `test/allocation.test.js` (plain dither,
221
+ wrapX+dither).
222
+
223
+ Total suite: **269/269 green.** Byte-parity gate on the 218 v1.1.0
224
+ tests still holds unmodified.
225
+
226
+ ### Notes
227
+
228
+ - `rasterizeDeformedTo` still throws `WRAP_DEFORMED_UNSUPPORTED` on
229
+ wrapped meshes (unchanged from v1.2.0). Dither on `rasterizeDeformedTo`
230
+ silently ignores the flag — deformed + dither is scheduled to land
231
+ alongside the v1.3 ghost-quad seam work, so consumers on 1.2.x
232
+ should treat this as "dither is rasterizeTo only."
233
+ - The engine's blue-noise tile is decoded once at first use (~sub-ms
234
+ from a base64 blob into a shared `Uint8Array(4096)`). Subsequent
235
+ calls return the same reference — the alloc test's warm-up phase
236
+ triggers the one-time decode before the timed loop.
237
+ - No peer bumps — engine v1.5.0 was already the floor from v1.2.0.
238
+
239
+ ## [1.1.0] — 2026-07-03
240
+
241
+ ### Added — Monochrome mesh
242
+
243
+ Mesh-level analogue of the `monochromeGradient` factory that shipped in
244
+ `@zakkster/lite-gradient` v1.1.0. Both together form a coordinated
245
+ monochrome capability across the ecosystem — 1D continuous gradients in
246
+ lite-gradient, 2D deformable meshes in lite-gradient-studio.
247
+
248
+ - **`monochromeMesh(base, cols, rows, opts?)`** — factory returning a
249
+ `MeshGradient` where every control point shares `base.c` and `base.h`
250
+ (or `c=0` if `mode: 'grayscale'`); only L varies according to
251
+ `direction`. Post-construction, the returned mesh behaves like any
252
+ other `MeshGradient` — `setPointPosition(...)` to warp the L
253
+ distribution off-grid, `rasterizeTo(...)` / `rasterizeDeformedTo(...)`
254
+ to render.
255
+
256
+ - **Options:**
257
+ - `mode`: `'tinted'` (default) | `'grayscale'`
258
+ - `range`: `[lo, hi]` with `0 <= lo < hi <= 1`, default `[0, 1]`
259
+ - `direction`: `'horizontal'` | `'vertical'` | `'diagonal'` (default) | `'radial'`
260
+
261
+ - **Directions:**
262
+ - `'horizontal'` — L varies left-to-right, uniform per row (equivalent
263
+ to a linear gradient, but with mesh deformability post-hoc).
264
+ - `'vertical'` — L varies top-to-bottom, uniform per column.
265
+ - `'diagonal'` (default) — top-left corner (lo) to bottom-right
266
+ corner (hi). Uses both axes meaningfully — the most versatile default.
267
+ - `'radial'` — center (lo) outward to corners (hi). Atmospheric,
268
+ "premium background" feel.
269
+
270
+ - **Type declarations:** `MonoMode`, `MonoMeshDirection`,
271
+ `MonochromeMeshOptions`, plus the factory signature in
272
+ `src/index.d.ts`.
273
+
274
+ - **Re-exports flow through:** `monochromeGradient`, `gradientMonoWarm`,
275
+ `gradientMonoCool` from `@zakkster/lite-gradient` v1.1.0 are already
276
+ visible via the existing `export * from '@zakkster/lite-gradient'`
277
+ in `src/index.js`. No new studio-level 1D monochrome factory needed;
278
+ users import from either package interchangeably.
279
+
280
+ ### Why this matters
281
+
282
+ Designer feedback on the mesh-gradient positioning (the original
283
+ mesh-gradient authoring focus) surfaced two problems: (1) AI image
284
+ generators can produce arbitrary decorative gradients on demand, eating
285
+ the "generate a wild gradient" use case at the low end; (2) designers
286
+ doing client work don't have creative freedom to ship wild mesh
287
+ gradients — they're constrained to brand palettes.
288
+
289
+ Monochrome mesh dodges both problems. The palette is fixed to a single
290
+ brand tone (nothing "wild" or "random-looking"); the mesh capability
291
+ still gives designers organic-feeling backgrounds that flat 1D
292
+ gradients can't — subtle asymmetry via `setPointPosition`, off-center
293
+ radial gradients, non-linear L distributions. Same authoring surface,
294
+ narrower creative space that lands in the client-work zone.
295
+
296
+ ### Peer dependency bump
297
+
298
+ - `@zakkster/lite-gradient`: `^1.0.4` → `^1.1.0`
299
+
300
+ Existing consumers using only pre-1.1.0 exports continue to work
301
+ identically. The bump ensures `monochromeGradient` and the two Mono
302
+ presets are guaranteed available via the re-export.
303
+
304
+ ### Tests
305
+
306
+ 18 new tests across `test/mesh-monochrome.test.js`. Coverage: the four
307
+ directions (verified by inspecting the initialized `stops` array — e.g.
308
+ horizontal makes row 0 == row 1 == row 2; radial makes center = lo and
309
+ all four corners = hi), both modes, custom range, sampling correctness,
310
+ every validation throw path, and base non-mutation.
311
+
312
+ The 4 previously-failing `color-convert.test.js` tests (documented in
313
+ 1.0.1 as a docs-vs-implementation mismatch) now pass — see "Fixed"
314
+ below. Total: 218 tests, **203 pass**, 0 fail, 15 skipped.
315
+
316
+ ### Fixed
317
+
318
+ - **`oklchToLinearSrgb(L, C, H, out?)` and `linearSrgbToOklch(r, g, b, out?)`
319
+ now honor the optional `out` parameter** documented since 1.0.0 in
320
+ `index.d.ts` and covered by tests that had been failing since publish.
321
+ Pass a caller-owned 3-element array (or `{ l, c, h }` object) as the
322
+ final argument; the function writes into it in place and returns the
323
+ same reference — zero allocation on the hot path. Omitting `out` (or
324
+ passing `null` / `undefined`) preserves the existing 3-arg call shape
325
+ and returns a freshly-allocated result.
326
+
327
+ Real impact: `oklchToLinearSrgb` is called per-pixel in `rasterizeTo` /
328
+ `rasterizeDeformedTo` and per-color in every exporter. Callers threading
329
+ a scratch array through that hot loop can now avoid allocating one
330
+ triplet per pixel. Existing 3-arg calls are unchanged.
331
+
332
+ The 1.0.1 CHANGELOG framed this as "docs drifted from implementation"
333
+ and rolled back the docs. That was a stopgap. The correct resolution
334
+ was implementing the feature the docs, types, and tests all documented;
335
+ this ship does that. Non-breaking either way (the 3-arg signature is
336
+ a strict subset of the new 4-arg signature).
337
+
338
+ ### Non-breaking
339
+
340
+ Additive only. No existing API surface changed.
341
+
8
342
  ## [1.0.1] — docs accuracy patch
9
343
 
10
344
  Patch release. No code changes — every export, every behavior is
package/README.md CHANGED
@@ -1,8 +1,15 @@
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
+
3
5
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-gradient-studio.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
6
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
7
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Engine-00C853?style=for-the-badge&logo=leaf&logoColor=white)
4
8
  [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-gradient-studio?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-gradient-studio)
5
9
  [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-gradient-studio?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
10
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-gradient-studio?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
11
+ ![Tree-Shakeable](https://img.shields.io/badge/tree--shakeable-yes-brightgreen)
12
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
6
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
7
14
 
8
15
  Authoring engine for OKLCH gradients — linear, radial, conic, and N×M mesh — with zero-GC rasterization, multi-format export, and chroma-weighted palette extraction.
@@ -183,6 +190,47 @@ Each box is one module under `src/` with one test file under `test/`. Modules co
183
190
  - `'smooth'` — bilinear with smoothstep-eased u/v. Softens cell-border artifacts.
184
191
  - `'cubic'` — Catmull-Rom across cell boundaries. Smoothest; ~2.5× the cost of `'bilinear'`.
185
192
 
193
+ ### Monochrome mesh (v1.1.0)
194
+
195
+ Mesh-level analogue of `lite-gradient`'s `monochromeGradient` — chroma and hue held
196
+ constant across every control point; only lightness varies. Client-work-friendly:
197
+ subtle premium backgrounds without the "AI-generated random gradient" look.
198
+
199
+ ```js
200
+ import { monochromeMesh } from '@zakkster/lite-gradient-studio';
201
+
202
+ // 3×3 mesh, all points share the brand hue, L varies diagonally
203
+ const mesh = monochromeMesh({ l: 0.5, c: 0.06, h: 245 }, 3, 3);
204
+
205
+ // Rasterize to a canvas
206
+ const buf = new Uint32Array(800 * 600);
207
+ mesh.rasterizeTo(buf, 800, 600);
208
+ const img = new ImageData(new Uint8ClampedArray(buf.buffer), 800, 600);
209
+ ctx.putImageData(img, 0, 0);
210
+
211
+ // Or emit CSS (canvas-free rendering)
212
+ const css = formatCssMesh(mesh);
213
+ element.style.background = css;
214
+ ```
215
+
216
+ **`monochromeMesh(base, cols, rows, opts?)`** → `MeshGradient`
217
+
218
+ | Option | Type | Default | Notes |
219
+ |-------------|---------------------------------------------------------------|----------------|-----------------------------------------------------------------------------|
220
+ | `mode` | `'tinted' \| 'grayscale'` | `'tinted'` | `'tinted'` retains base c/h; `'grayscale'` forces c=0. |
221
+ | `range` | `[number, number]` | `[0, 1]` | L-axis endpoints. Must satisfy `0 ≤ lo < hi ≤ 1`. |
222
+ | `direction` | `'horizontal' \| 'vertical' \| 'diagonal' \| 'radial'` | `'diagonal'` | How L varies across the mesh. See below. |
223
+
224
+ **Directions:**
225
+ - **`'horizontal'`** — L varies left-to-right, uniform across each row. Like a linear gradient, but you can deform post-hoc.
226
+ - **`'vertical'`** — L varies top-to-bottom, uniform across each column.
227
+ - **`'diagonal'`** (default) — top-left corner (lo) to bottom-right corner (hi). Most versatile; uses both axes meaningfully.
228
+ - **`'radial'`** — center (lo) outward to corners (hi). Atmospheric "premium" feel.
229
+
230
+ Post-construction, you can `setPointPosition(...)` to warp the L distribution off-grid
231
+ — something you can't do with a flat 1D gradient. This is what mesh capability buys you
232
+ in the client-work scenario: brand-safe base + designer-controlled organic character.
233
+
186
234
  ### CSS emitters
187
235
 
188
236
  | Function | Description |
@@ -237,6 +285,105 @@ Direct format functions are also exported (`toCss1d`, `toScss1d`, `toTailwind1d`
237
285
  | `sampleLut(lut, t)` | Read a baked LUT at `t ∈ [0, 1]`. Returns the packed ARGB `Uint32` at that index. |
238
286
  | `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
287
 
288
+ ## Toroidal mesh (v1.2.0 "Tiles")
289
+
290
+ `MeshGradient` gains a fourth constructor argument:
291
+
292
+ ```js
293
+ new MeshGradient(cols, rows, stops, { wrapX: true, wrapY: true });
294
+ ```
295
+
296
+ Both flags default to `false` and are independent — turn on `wrapX` for a
297
+ cylinder, both for a torus. Wrap is structural: it changes the UV period,
298
+ the cell count, and the default control-point positions in one atomic
299
+ step. The instance carries `readonly wrapX` and `readonly wrapY` fields
300
+ for downstream consumers to branch on.
301
+
302
+ **The flagship:** cubic mode has real **C¹ continuity across the seam**.
303
+ `_sampleAtCubic` reads real neighbours across the wrap boundary via
304
+ modulo indexing instead of clamping to a duplicated endpoint. Bilinear
305
+ and smooth get seamlessness from the wrap coord alone; cubic is where
306
+ wrap earns "exists in no other library."
307
+
308
+ Sampling accepts any float on wrapped axes — a raw animation phase, a
309
+ negative, arbitrary magnitude:
310
+
311
+ ```js
312
+ let phase = 0;
313
+ function frame(dt) {
314
+ phase += dt * 0.0002;
315
+ mesh.sampleAt(u + phase, v, out, 'cubic'); // no `phase % 1` needed
316
+ }
317
+ ```
318
+
319
+ **Tiled rasterization.** On wrapped axes, `rasterizeTo` samples the
320
+ period (`x / width`, no `- 1` divisor), so pixel column 0 of the "next
321
+ tile" lands exactly at `u ≡ 0` — `drawImage`-based tile scrolling butts
322
+ perfectly with no duplicated edge column.
323
+
324
+ **Deformed + wrap fails loudly.** `rasterizeDeformedTo` throws with
325
+ `err.code = 'WRAP_DEFORMED_UNSUPPORTED'` when called on a wrapped mesh.
326
+ Ghost-quad seam crossing (Newton solve against wrapped corner positions)
327
+ is real work, deferred to v1.3. Consumer code can branch on the code
328
+ string cleanly rather than string-matching messages.
329
+
330
+ **Wrap-aware defaults.** `defaultMeshColor(col, row, cols, rows, wrapX?,
331
+ wrapY?)` gains trailing optional args. When set, the aperiodic
332
+ `240 + 120·cT` hue sweep is swapped for a uniform `360/cols` step, and
333
+ row-driven L switches to `cos(2π · rT)` so the top and bottom rows
334
+ agree. Four-arg calls are byte-identical to v1.1.0.
335
+
336
+ **Byte-parity guarantee.** The non-wrap path is untouched — 218
337
+ pre-existing tests pass unmodified, benches within noise of v1.1.0.
338
+
339
+ **Cascade note.** Blue-noise dithering (D7/D8 in the Tiles roadmap) lands
340
+ in v1.2.0 with its full gate — see below.
341
+
342
+ ## Blue-noise dither (v1.2.0)
343
+
344
+ `rasterizeTo` gains one flag:
345
+
346
+ ```js
347
+ mesh.rasterizeTo(buf, W, H, { dither: true, interpolation: 'smooth' });
348
+ ```
349
+
350
+ Delegates to `@zakkster/lite-color-engine >= 1.5.0`'s dithered packer
351
+ and shared 64×64 void-and-cluster tile. Per-pixel overhead is roughly
352
+ 24% on typical raster sizes — one tile lookup plus a threshold-offset
353
+ gamma round replacing the standard `+ 0.5` rounding.
354
+
355
+ **Contract, exhaustively tested:**
356
+
357
+ - **`noise01 = 0.5` reproduces the plain packer exactly** — the identity
358
+ anchor. Consequence: dither adds an *at-most* ±1 per-channel deviation
359
+ from the undithered output.
360
+ - **Same noise value shared R/G/B per pixel** — luminance-patterned
361
+ dither, no chroma speckle. On a chroma-free mesh, dithered output is
362
+ chroma-free too (verified).
363
+ - **Alpha never dithered.** Byte-parity with the undithered α.
364
+ - **`dither: false` or absent → byte-identical to v1.2.0.** The branch
365
+ is resolved once per rasterize call, two loop bodies, no per-pixel
366
+ branch cost when off.
367
+ - **Composes with wrap.** `rasterizeTo` with both `wrapX` and `dither`
368
+ set produces per-channel ±1 deviation and preserves seamlessness. The
369
+ two features are orthogonal — wrap operates on sampling, dither on
370
+ packing.
371
+
372
+ **Where it shines:** shallow ramps and near-monochrome fills where the
373
+ undithered output shows visible 1-byte-wide bands. On a 4-stop L=0.30..0.34
374
+ gradient at 256px width, dither cuts the longest identical-pixel run by
375
+ more than 33% (from ~64 pixels undithered to ~40 dithered).
376
+
377
+ **Where it doesn't help:** high-contrast meshes where color transitions
378
+ already span multiple integer bytes per column. Dither is bounded ±1;
379
+ that's a lot of visual improvement on smooth surfaces, but invisible on
380
+ sharp gradients.
381
+
382
+ **Not on `rasterizeDeformedTo`.** The deformed rasterizer silently
383
+ ignores the flag (also throws `WRAP_DEFORMED_UNSUPPORTED` on wrapped
384
+ meshes, unchanged). Deformed + dither is scheduled with the v1.3
385
+ ghost-quad seam work.
386
+
240
387
  ## Benchmarks
241
388
 
242
389
  Node v22, single thread, Linux x64. Run yourself: `npm run bench`.
package/llms.txt CHANGED
@@ -72,6 +72,47 @@ Mesh interpolation modes (sampleAt's `mode` arg / rasterize's
72
72
  Legacy boolean accepted for sampleAt: `false` → bilinear, `true` → smooth.
73
73
  Rasterize also accepts `opts.smooth: true` as a legacy alias.
74
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.
115
+
75
116
  ## Multi-format export
76
117
 
77
118
  const state = { mode: 'linear', angle: 135, stops };
@@ -173,3 +214,45 @@ mostly stuck on HSL or sRGB.
173
214
  This library is that layer. Built once, factored to compose, tuned for
174
215
  production rasterization. Pair it with @zakkster/lite-color (the OKLCH
175
216
  foundation) and any UI framework to ship a gradient editor.
217
+
218
+ ## v1.2.0 "Tiles" — toroidal mesh
219
+
220
+ `new MeshGradient(cols, rows, stops, { wrapX, wrapY })` — 4th opts arg with two independent flags (torus = both, cylinder = one). Both default `false`; when off, all code paths byte-identical to v1.1.0.
221
+
222
+ Structural wrap semantics:
223
+ - UV period: cell count is `cols` (not `cols - 1`) on wrapped axes; default control-point positions become `col / cols`.
224
+ - `sampleAt` on wrapped axes: `u = u - Math.floor(u)` replaces clamp; last-cell neighbour uses `(col + 1) % cols`. `sampleAt(0, v) === sampleAt(1, v)` exactly; any float is a valid coord (raw accumulating animation phase, negatives).
225
+ - Cubic mode + wrap = C¹ across the seam. `_sampleAtCubic` and `_cubicRow` use modulo neighbour indexing (`((col - 1) % cols + cols) % cols` and `(col + 2) % cols`) so Catmull-Rom reads real cells across the boundary. The flagship — no other mesh gradient lib does this.
226
+ - `rasterizeTo` on wrapped axes: `u = x / width` (drops the `- 1`). Pixel column 0 of the "next tile" lands at u ≡ 0, so `drawImage`-based tiling of the rasterized output butts perfectly.
227
+ - `rasterizeDeformedTo` throws `err.code = 'WRAP_DEFORMED_UNSUPPORTED'` on a wrapped mesh. Ghost-quad seam crossing is v1.3 work.
228
+ - `defaultMeshColor` accepts trailing `wrapX`, `wrapY` args. When set, hue advances a uniform `360/cols` per column (wrapX) or L switches to `cos(2π · rT)` and drift becomes sinusoidal (wrapY). Four-arg form byte-identical to v1.1.0.
229
+
230
+ Instance exposes `readonly wrapX: boolean`, `readonly wrapY: boolean` for downstream consumers.
231
+
232
+ Peer bumps: `@zakkster/lite-color-engine` `^1.5.0`, `@zakkster/lite-gradient` `^1.2.0`.
233
+
234
+ Cascade note: dither (D7/D8) slips clean to v1.2.0 with full gate. Both roadmap tracks (wrap D1-D6, dither D7-D8) ship together in 1.2.0.
235
+
236
+ ## v1.2.0 "Tiles + Dither"
237
+
238
+ Blue-noise dithering on `MeshGradient.rasterizeTo` — D7/D8 from the Tiles roadmap, slipped intact from v1.2.0 per the cascade rule. No peer bumps (engine v1.5.0 already floor from v1.2.0).
239
+
240
+ Usage:
241
+ ```
242
+ mesh.rasterizeTo(buf, W, H, { dither: true });
243
+ mesh.rasterizeTo(buf, W, H, { dither: true, interpolation: 'cubic' });
244
+ mesh.rasterizeTo(buf, W, H, { dither: true /* + wrap */ });
245
+ ```
246
+
247
+ Semantics:
248
+ - Delegates to `@zakkster/lite-color-engine >= 1.5.0`'s `packOklchBufferToUint32Dithered` + `getBlueNoise64`. Tile is a shared `Uint8Array(4096)` (64×64), decoded once from a base64 blob at first use.
249
+ - Per-pixel: `noise01 = (tile[((y & 63) << 6) | (x & 63)] + 0.5) / 256` — pure bit ops, torus wrap via mask. Same noise01 shared across R/G/B (no chroma speckle). Alpha undithered.
250
+ - Contract: `noise01 = 0.5` reproduces the plain packer exactly. Consequence: dither adds at most ±1 per-channel deviation vs undithered.
251
+ - Branch resolved ONCE per rasterizeTo call — two loop bodies. `dither: false` (or absent) walks the exact v1.2.0 code path, byte-identical output.
252
+ - Not on `rasterizeDeformedTo` (v1.3 ghost-quad work).
253
+
254
+ New export: `packOklchSingleDithered(l, c, h, alpha, noise01)` — scalar-arg wrapper reusing the same Float32Array(3) scratch as `packOklchSingle`.
255
+
256
+ Empirical dither overhead (5×5 smooth, 256²): 2.1M → 1.6M px/s, ~24%. Recorded in bench.
257
+
258
+ Total tests: 269/269 (218 v1.1.0 + 31 wrap + 3 wrap-alloc + 15 dither + 2 dither-alloc). Byte-parity gate on the v1.1.0 tests still holds unmodified.
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "@zakkster/lite-gradient-studio",
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.",
3
+ "version": "1.2.0",
4
+ "description": "Authoring engine for OKLCH gradients \u2014 linear, radial, conic, and N\u00d7M mesh \u2014 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",
8
+ "main": "./src/index.js",
9
+ "module": "./src/index.js",
10
+ "types": "./src/index.d.ts",
9
11
  "exports": {
10
- ".": "./src/index.js"
12
+ ".": {
13
+ "types": "./src/index.d.ts",
14
+ "node": "./src/index.js",
15
+ "import": "./src/index.js",
16
+ "default": "./src/index.js"
17
+ }
11
18
  },
12
19
  "files": [
13
20
  "src",
@@ -17,13 +24,13 @@
17
24
  "README.md"
18
25
  ],
19
26
  "scripts": {
20
- "test": "node --test --expose-gc test/*.test.js",
27
+ "test": "node --test --expose-gc test/*.test.js",
21
28
  "bench": "node --expose-gc bench/run.mjs"
22
29
  },
23
30
  "dependencies": {
24
- "@zakkster/lite-color": "^1.0.6",
25
- "@zakkster/lite-color-engine": "^1.0.0",
26
- "@zakkster/lite-gradient": "^1.0.4"
31
+ "@zakkster/lite-color": "^1.0.6",
32
+ "@zakkster/lite-color-engine": "^1.5.0",
33
+ "@zakkster/lite-gradient": "^1.2.0"
27
34
  },
28
35
  "keywords": [
29
36
  "gradient",
@@ -42,12 +49,17 @@
42
49
  "no-build",
43
50
  "esm"
44
51
  ],
45
- "homepage": "https://github.com/PeshoVurtoleta/lite-gradient-studio#readme",
52
+ "homepage": "https://github.com/PeshoVurtoleta/lite-gradient-studio#readme",
46
53
  "repository": {
47
54
  "type": "git",
48
- "url": "git+https://github.com/PeshoVurtoleta/lite-gradient-studio.git"
55
+ "url": "git+https://github.com/PeshoVurtoleta/lite-gradient-studio.git"
49
56
  },
50
57
  "bugs": {
51
58
  "url": "https://github.com/PeshoVurtoleta/lite-gradient-studio/issues"
52
- }
59
+ },
60
+ "funding": {
61
+ "type": "github",
62
+ "url": "https://github.com/sponsors/PeshoVurtoleta"
63
+ },
64
+ "sideEffects": false
53
65
  }