@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 +334 -0
- package/README.md +147 -0
- package/llms.txt +83 -0
- package/package.json +23 -11
- package/src/bake.js +36 -0
- package/src/color-convert.js +18 -3
- package/src/index.d.ts +122 -1
- package/src/index.js +2 -1
- package/src/mesh.js +407 -74
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
|
[](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
|
|
6
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
7
|
+

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

|
|
12
|
+

|
|
6
13
|
[](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
|
|
4
|
-
"description": "Authoring engine for OKLCH gradients
|
|
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
|
-
".":
|
|
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":
|
|
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":
|
|
25
|
-
"@zakkster/lite-color-engine": "^1.
|
|
26
|
-
"@zakkster/lite-gradient":
|
|
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":
|
|
52
|
+
"homepage": "https://github.com/PeshoVurtoleta/lite-gradient-studio#readme",
|
|
46
53
|
"repository": {
|
|
47
54
|
"type": "git",
|
|
48
|
-
"url":
|
|
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
|
}
|