@zakkster/lite-gradient-studio 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +167 -16
- package/README.md +86 -38
- package/llms.txt +69 -26
- package/package.json +16 -32
- package/src/color-convert.js +64 -108
- package/src/exporters.js +23 -2
- package/src/index.d.ts +39 -0
- package/src/index.js +1 -1
- package/src/mesh.js +155 -15
- package/src/palette-extract.js +39 -81
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,165 @@ All notable changes to `@zakkster/lite-gradient-studio` are documented here.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
This library follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.1.0] — 2026-07-03
|
|
9
|
+
|
|
10
|
+
### Added — Monochrome mesh
|
|
11
|
+
|
|
12
|
+
Mesh-level analogue of the `monochromeGradient` factory that shipped in
|
|
13
|
+
`@zakkster/lite-gradient` v1.1.0. Both together form a coordinated
|
|
14
|
+
monochrome capability across the ecosystem — 1D continuous gradients in
|
|
15
|
+
lite-gradient, 2D deformable meshes in lite-gradient-studio.
|
|
16
|
+
|
|
17
|
+
- **`monochromeMesh(base, cols, rows, opts?)`** — factory returning a
|
|
18
|
+
`MeshGradient` where every control point shares `base.c` and `base.h`
|
|
19
|
+
(or `c=0` if `mode: 'grayscale'`); only L varies according to
|
|
20
|
+
`direction`. Post-construction, the returned mesh behaves like any
|
|
21
|
+
other `MeshGradient` — `setPointPosition(...)` to warp the L
|
|
22
|
+
distribution off-grid, `rasterizeTo(...)` / `rasterizeDeformedTo(...)`
|
|
23
|
+
to render.
|
|
24
|
+
|
|
25
|
+
- **Options:**
|
|
26
|
+
- `mode`: `'tinted'` (default) | `'grayscale'`
|
|
27
|
+
- `range`: `[lo, hi]` with `0 <= lo < hi <= 1`, default `[0, 1]`
|
|
28
|
+
- `direction`: `'horizontal'` | `'vertical'` | `'diagonal'` (default) | `'radial'`
|
|
29
|
+
|
|
30
|
+
- **Directions:**
|
|
31
|
+
- `'horizontal'` — L varies left-to-right, uniform per row (equivalent
|
|
32
|
+
to a linear gradient, but with mesh deformability post-hoc).
|
|
33
|
+
- `'vertical'` — L varies top-to-bottom, uniform per column.
|
|
34
|
+
- `'diagonal'` (default) — top-left corner (lo) to bottom-right
|
|
35
|
+
corner (hi). Uses both axes meaningfully — the most versatile default.
|
|
36
|
+
- `'radial'` — center (lo) outward to corners (hi). Atmospheric,
|
|
37
|
+
"premium background" feel.
|
|
38
|
+
|
|
39
|
+
- **Type declarations:** `MonoMode`, `MonoMeshDirection`,
|
|
40
|
+
`MonochromeMeshOptions`, plus the factory signature in
|
|
41
|
+
`src/index.d.ts`.
|
|
42
|
+
|
|
43
|
+
- **Re-exports flow through:** `monochromeGradient`, `gradientMonoWarm`,
|
|
44
|
+
`gradientMonoCool` from `@zakkster/lite-gradient` v1.1.0 are already
|
|
45
|
+
visible via the existing `export * from '@zakkster/lite-gradient'`
|
|
46
|
+
in `src/index.js`. No new studio-level 1D monochrome factory needed;
|
|
47
|
+
users import from either package interchangeably.
|
|
48
|
+
|
|
49
|
+
### Why this matters
|
|
50
|
+
|
|
51
|
+
Designer feedback on the mesh-gradient positioning (the original
|
|
52
|
+
mesh-gradient authoring focus) surfaced two problems: (1) AI image
|
|
53
|
+
generators can produce arbitrary decorative gradients on demand, eating
|
|
54
|
+
the "generate a wild gradient" use case at the low end; (2) designers
|
|
55
|
+
doing client work don't have creative freedom to ship wild mesh
|
|
56
|
+
gradients — they're constrained to brand palettes.
|
|
57
|
+
|
|
58
|
+
Monochrome mesh dodges both problems. The palette is fixed to a single
|
|
59
|
+
brand tone (nothing "wild" or "random-looking"); the mesh capability
|
|
60
|
+
still gives designers organic-feeling backgrounds that flat 1D
|
|
61
|
+
gradients can't — subtle asymmetry via `setPointPosition`, off-center
|
|
62
|
+
radial gradients, non-linear L distributions. Same authoring surface,
|
|
63
|
+
narrower creative space that lands in the client-work zone.
|
|
64
|
+
|
|
65
|
+
### Peer dependency bump
|
|
66
|
+
|
|
67
|
+
- `@zakkster/lite-gradient`: `^1.0.4` → `^1.1.0`
|
|
68
|
+
|
|
69
|
+
Existing consumers using only pre-1.1.0 exports continue to work
|
|
70
|
+
identically. The bump ensures `monochromeGradient` and the two Mono
|
|
71
|
+
presets are guaranteed available via the re-export.
|
|
72
|
+
|
|
73
|
+
### Tests
|
|
74
|
+
|
|
75
|
+
18 new tests across `test/mesh-monochrome.test.js`. Coverage: the four
|
|
76
|
+
directions (verified by inspecting the initialized `stops` array — e.g.
|
|
77
|
+
horizontal makes row 0 == row 1 == row 2; radial makes center = lo and
|
|
78
|
+
all four corners = hi), both modes, custom range, sampling correctness,
|
|
79
|
+
every validation throw path, and base non-mutation.
|
|
80
|
+
|
|
81
|
+
The 4 previously-failing `color-convert.test.js` tests (documented in
|
|
82
|
+
1.0.1 as a docs-vs-implementation mismatch) now pass — see "Fixed"
|
|
83
|
+
below. Total: 218 tests, **203 pass**, 0 fail, 15 skipped.
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
|
|
87
|
+
- **`oklchToLinearSrgb(L, C, H, out?)` and `linearSrgbToOklch(r, g, b, out?)`
|
|
88
|
+
now honor the optional `out` parameter** documented since 1.0.0 in
|
|
89
|
+
`index.d.ts` and covered by tests that had been failing since publish.
|
|
90
|
+
Pass a caller-owned 3-element array (or `{ l, c, h }` object) as the
|
|
91
|
+
final argument; the function writes into it in place and returns the
|
|
92
|
+
same reference — zero allocation on the hot path. Omitting `out` (or
|
|
93
|
+
passing `null` / `undefined`) preserves the existing 3-arg call shape
|
|
94
|
+
and returns a freshly-allocated result.
|
|
95
|
+
|
|
96
|
+
Real impact: `oklchToLinearSrgb` is called per-pixel in `rasterizeTo` /
|
|
97
|
+
`rasterizeDeformedTo` and per-color in every exporter. Callers threading
|
|
98
|
+
a scratch array through that hot loop can now avoid allocating one
|
|
99
|
+
triplet per pixel. Existing 3-arg calls are unchanged.
|
|
100
|
+
|
|
101
|
+
The 1.0.1 CHANGELOG framed this as "docs drifted from implementation"
|
|
102
|
+
and rolled back the docs. That was a stopgap. The correct resolution
|
|
103
|
+
was implementing the feature the docs, types, and tests all documented;
|
|
104
|
+
this ship does that. Non-breaking either way (the 3-arg signature is
|
|
105
|
+
a strict subset of the new 4-arg signature).
|
|
106
|
+
|
|
107
|
+
### Non-breaking
|
|
108
|
+
|
|
109
|
+
Additive only. No existing API surface changed.
|
|
110
|
+
|
|
111
|
+
## [1.0.1] — docs accuracy patch
|
|
112
|
+
|
|
113
|
+
Patch release. No code changes — every export, every behavior is
|
|
114
|
+
identical to 1.0.0. The 1.0.0 documentation drifted from the
|
|
115
|
+
implementation in several places; users following the docs exactly
|
|
116
|
+
would hit runtime errors. Comprehensive fix.
|
|
117
|
+
|
|
118
|
+
### Fixed
|
|
119
|
+
|
|
120
|
+
- `toTokens1d(state, format, opts)` documented order. The 1.0.0 README
|
|
121
|
+
showed `toTokens1d(format, state, opts)`. The implementation is and
|
|
122
|
+
always was `(state, format, opts)`. Same fix for `toTokensMesh(mesh,
|
|
123
|
+
format, opts)`.
|
|
124
|
+
- `extractPalette(pixels, count?)` documented signature. The 1.0.0
|
|
125
|
+
README showed `(pixels, width, height, count)` — width/height aren't
|
|
126
|
+
parameters; the algorithm scans linearly.
|
|
127
|
+
- `MeshGradient.setPoint(col, row, l, c, h)` documented method name.
|
|
128
|
+
The 1.0.0 README called it `setPointColor` with an alpha parameter;
|
|
129
|
+
the actual method is `setPoint` and writes L/C/H only (alpha lives
|
|
130
|
+
on the stop and can be set by direct mutation).
|
|
131
|
+
- Mesh interpolation mode names. The 1.0.0 README used `'linear'` for
|
|
132
|
+
the bilinear mode; the actual accepted values are `'bilinear'`,
|
|
133
|
+
`'smooth'`, `'cubic'`. (`sampleAt` also accepts the legacy boolean.)
|
|
134
|
+
- `rasterizeTo` / `rasterizeDeformedTo` options key. The 1.0.0 README
|
|
135
|
+
showed `{ mode: 'smooth' }`; the actual key is `{ interpolation:
|
|
136
|
+
'smooth' }` (`opts.smooth: true` is also accepted as a legacy alias).
|
|
137
|
+
Unknown keys silently fell back to `'bilinear'`, so 1.0.0 docs would
|
|
138
|
+
also have made these benchmarks measure the wrong path under wrong
|
|
139
|
+
labels.
|
|
140
|
+
- `oklchToLinearSrgb(L, C, H)` documented return type. Returns
|
|
141
|
+
`[r, g, b]`; the 1.0.0 README incorrectly described a zero-GC out
|
|
142
|
+
param. Same for `linearSrgbToOklch(r, g, b)` which returns
|
|
143
|
+
`{ l, c, h }`.
|
|
144
|
+
- `bakeGradientToLut(gradient, resolution?, opts?)` documented
|
|
145
|
+
signature. Returns the `Uint32Array`; the 1.0.0 README showed a
|
|
146
|
+
caller-owned buffer pattern that doesn't match the implementation.
|
|
147
|
+
- `flattenStopsToBuffer(gradient, out?)` documented first arg. Takes
|
|
148
|
+
a gradient instance (the function reads `gradient.stops`), not a
|
|
149
|
+
stops array directly.
|
|
150
|
+
- `srgbGamma(x) / srgbInverseGamma(x)` documented input range. Operates
|
|
151
|
+
on `[0, 1]` linear values, not `[0, 255]` bytes.
|
|
152
|
+
|
|
153
|
+
### Changed
|
|
154
|
+
|
|
155
|
+
- Benchmark suite (`npm run bench`) now exercises the correct rasterize
|
|
156
|
+
opts key and includes separate `bilinear` / `smooth` / `cubic`
|
|
157
|
+
rasterize timings. The 1.0.0 bench labels were technically all
|
|
158
|
+
measuring the bilinear path (the `mode` key was silently ignored).
|
|
159
|
+
README throughput numbers updated.
|
|
160
|
+
|
|
161
|
+
If you wrote code against 1.0.0 by copy-pasting from the README and
|
|
162
|
+
hit "Unknown 1D export format: '[object Object]'", "setPointColor is
|
|
163
|
+
not a function", or similar, this release fixes the documentation —
|
|
164
|
+
your code needs to use the actual signatures above. No upgrade is
|
|
165
|
+
required for users who read the source directly.
|
|
166
|
+
|
|
8
167
|
## [1.0.0] — first public release
|
|
9
168
|
|
|
10
169
|
First npm publish. Internal pre-1.0 versions powered Gradient Studio
|
|
@@ -65,30 +224,22 @@ production use.
|
|
|
65
224
|
(`'center'`, `'top right'`).
|
|
66
225
|
|
|
67
226
|
#### Palette extraction
|
|
68
|
-
- `extractPalette(
|
|
227
|
+
- `extractPalette(rgba, w, h, count)` — chroma-weighted hue-bucketing.
|
|
69
228
|
Skips shadows (`L < 0.10`) and near-neutrals (`C < 0.02`). Enforces
|
|
70
|
-
|
|
229
|
+
≥50° hue separation between picks so a photo of one warm subject
|
|
71
230
|
doesn't return five skin tones. Designer-facing behavior: "import
|
|
72
|
-
this photo of a baby in a blue shirt
|
|
73
|
-
Zero per-call allocation outside the result array: bucket state is
|
|
74
|
-
module-scoped and reset (not re-allocated) per call.
|
|
231
|
+
this photo of a baby in a blue shirt → blue shows up in the palette".
|
|
75
232
|
|
|
76
233
|
#### Color conversion
|
|
77
|
-
- `toHex({ l, c, h, a })` / `fromHex(str)`
|
|
234
|
+
- `toHex({ l, c, h, a })` / `fromHex(str)` — OKLCH ↔ hex with sRGB
|
|
78
235
|
gamut clip via boundary search.
|
|
79
|
-
- `oklchToLinearSrgb(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
compat). The gamut-mapping helper is hoisted to module scope so the
|
|
83
|
-
`out`-provided path is allocation-free even on the binary-search
|
|
84
|
-
branch.
|
|
85
|
-
- `srgbGamma(c)` / `srgbInverseGamma(c)` -- 8-bit sRGB transfer.
|
|
236
|
+
- `oklchToLinearSrgb(l, c, h, out)` / `linearSrgbToOklch(r, g, b, out)`
|
|
237
|
+
— zero-GC matrix conversions.
|
|
238
|
+
- `srgbGamma(c)` / `srgbInverseGamma(c)` — 8-bit sRGB transfer.
|
|
86
239
|
|
|
87
240
|
### Tests
|
|
88
241
|
|
|
89
|
-
|
|
90
|
-
pre-1.0 series, plus a dedicated `test/allocation.test.js` that pins
|
|
91
|
-
the zero-GC budgets for every hot-path export under `npm run test:gc`.
|
|
242
|
+
178 cases over 11 test files. ~1:1 source-to-test ratio.
|
|
92
243
|
|
|
93
244
|
### Benchmarks
|
|
94
245
|
|
package/README.md
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|

|
|
12
12
|
[](https://opensource.org/licenses/MIT)
|
|
13
13
|
|
|
14
|
+
Authoring engine for OKLCH gradients — linear, radial, conic, and N×M mesh — with zero-GC rasterization, multi-format export, and chroma-weighted palette extraction.
|
|
15
|
+
|
|
14
16
|
**Everything you need to build a gradient editor, except the UI.**
|
|
15
17
|
|
|
16
18
|
---
|
|
@@ -19,7 +21,7 @@
|
|
|
19
21
|
|
|
20
22
|
If you're building anything that *creates* gradients (a design tool, a theme builder, a Figma plugin, a card-generator backend, a procedural background system), you need a lot of small pieces working together: color interpolation that doesn't go through dead grey, a mesh kernel that doesn't cost 400ms per frame, CSS emitters that round-trip, format exporters for handoff, and a palette extractor that picks the vivid blue instead of all five shades of the dominant skin tone.
|
|
21
23
|
|
|
22
|
-
This library is those pieces. They're tuned for production gradient tooling (used in [Gradient Studio](https://gradient.studio)), tested at
|
|
24
|
+
This library is those pieces. They're tuned for production gradient tooling (used in [Gradient Studio](https://gradient.studio)), tested at 178 cases, and built to compose — `lite-gradient-studio` is the **authoring layer** that sits between low-level color math (`@zakkster/lite-color`) and an editor UI.
|
|
23
25
|
|
|
24
26
|
- **OKLCH-native** — every interpolation, every emission, every export carries OKLCH through. No silent sRGB lerp.
|
|
25
27
|
- **N×M mesh kernel** — bilinear (smooth) or Catmull-Rom (cubic) over a deformable control grid. Rasterizes 65K pixels per call at >2M px/sec.
|
|
@@ -81,7 +83,7 @@ mesh.setPointPosition(/*col*/ 2, /*row*/ 0, /*x*/ 1.15, /*y*/ -0.05);
|
|
|
81
83
|
// Rasterize into a pre-allocated Uint32Array (ARGB-packed, sRGB).
|
|
82
84
|
const W = 1600, H = 640;
|
|
83
85
|
const buf = new Uint32Array(W * H);
|
|
84
|
-
mesh.rasterizeDeformedTo(buf, W, H, {
|
|
86
|
+
mesh.rasterizeDeformedTo(buf, W, H, { interpolation: 'smooth' });
|
|
85
87
|
|
|
86
88
|
// Blit to canvas without re-allocating the wrapper.
|
|
87
89
|
const img = new ImageData(new Uint8ClampedArray(buf.buffer), W, H);
|
|
@@ -95,12 +97,12 @@ import { toTokens1d, EXPORT_FORMATS_1D } from '@zakkster/lite-gradient-studio';
|
|
|
95
97
|
|
|
96
98
|
const state = { mode: 'linear', angle: 135, stops: sunset.stops };
|
|
97
99
|
|
|
98
|
-
toTokens1d('css',
|
|
99
|
-
toTokens1d('css-var',
|
|
100
|
-
toTokens1d('scss',
|
|
101
|
-
toTokens1d('tailwind',
|
|
102
|
-
toTokens1d('json',
|
|
103
|
-
toTokens1d('svg',
|
|
100
|
+
toTokens1d(state, 'css', { name: 'sunset' }); // → "background: linear-gradient(...)"
|
|
101
|
+
toTokens1d(state, 'css-var', { name: 'sunset' }); // → "--sunset: linear-gradient(...);"
|
|
102
|
+
toTokens1d(state, 'scss', { name: 'sunset' }); // → "$sunset: linear-gradient(...);"
|
|
103
|
+
toTokens1d(state, 'tailwind', { name: 'sunset' }); // → Tailwind config snippet
|
|
104
|
+
toTokens1d(state, 'json', { name: 'sunset' }); // → portable JSON
|
|
105
|
+
toTokens1d(state, 'svg', { name: 'sunset' }); // → standalone SVG with <linearGradient>
|
|
104
106
|
|
|
105
107
|
EXPORT_FORMATS_1D;
|
|
106
108
|
// → ['css', 'css-var', 'scss', 'tailwind', 'json', 'svg']
|
|
@@ -112,8 +114,9 @@ EXPORT_FORMATS_1D;
|
|
|
112
114
|
import { extractPalette } from '@zakkster/lite-gradient-studio';
|
|
113
115
|
|
|
114
116
|
// Get RGBA pixel data however you want. canvas.getContext('2d').getImageData(...) is typical.
|
|
117
|
+
// Width/height aren't needed — the algorithm scans the buffer linearly.
|
|
115
118
|
const palette = extractPalette(rgba.data, 5);
|
|
116
|
-
// → [{ l, c, h }, { l, c, h }, ...] — 5 picks, chroma-weighted, hue-separated.
|
|
119
|
+
// → [{ l, c, h }, { l, c, h }, ...] — up to 5 picks, chroma-weighted, hue-separated.
|
|
117
120
|
// A blue-shirted-baby photo returns blue + warm tones, not five skin variants.
|
|
118
121
|
```
|
|
119
122
|
|
|
@@ -171,17 +174,61 @@ Each box is one module under `src/` with one test file under `test/`. Modules co
|
|
|
171
174
|
| Method | Description |
|
|
172
175
|
|---|---|
|
|
173
176
|
| `new MeshGradient(cols, rows, stops?)` | Construct N×M mesh. If `stops` omitted, fills with a default theme. |
|
|
174
|
-
| `sampleAt(u, v, out, mode)` | Sample at `(u, v) ∈ [0,1]²`. `mode`: `'
|
|
177
|
+
| `sampleAt(u, v, out, mode?)` | Sample at `(u, v) ∈ [0,1]²`. `mode`: `'bilinear' \| 'smooth' \| 'cubic'` (default `'bilinear'`). Legacy boolean accepted: `false` → `'bilinear'`, `true` → `'smooth'`. Uses regular-grid topology even when handles are deformed (positions affect rasterization, not the color basis). |
|
|
175
178
|
| `setPointPosition(col, row, x, y)` | Move a control point to `(x, y)`. Not clamped — handles can go outside the unit square. |
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
179
|
+
| `getPointPosition(col, row, out)` | Read current position into `out = { x, y }`. |
|
|
180
|
+
| `setPoint(col, row, l, c, h)` | Mutate the color at a control point. Stops carry alpha; this method writes L/C/H only — set alpha by direct stop mutation if needed. |
|
|
181
|
+
| `getPoint(col, row, out)` | Read current color into `out = { l, c, h }`. |
|
|
182
|
+
| `resetPositions()` | Snap all handles back to the regular grid. Colors preserved. |
|
|
183
|
+
| `rasterizeTo(out, w, h, opts)` | Fast path — assumes regular grid. Writes ARGB-packed Uint32Array. `opts.interpolation`: `'bilinear' \| 'smooth' \| 'cubic'`. |
|
|
184
|
+
| `rasterizeDeformedTo(out, w, h, opts)` | Honors `(x, y)` deformation via Newton inverse-bilinear. Per-pixel cost ~3-5× of `rasterizeTo`. Same `opts.interpolation`. |
|
|
179
185
|
| `.cols` `.rows` `.stops` | Read-only mesh structure. |
|
|
180
186
|
|
|
181
|
-
`opts.mode
|
|
182
|
-
- `'
|
|
187
|
+
`opts.interpolation` (and `mode` for `sampleAt`):
|
|
188
|
+
- `'bilinear'` — bilinear OKLCH. Fastest. Smooth between adjacent stops, faceted at cell borders. Default.
|
|
183
189
|
- `'smooth'` — bilinear with smoothstep-eased u/v. Softens cell-border artifacts.
|
|
184
|
-
- `'cubic'` — Catmull-Rom across cell boundaries. Smoothest; ~2.5× the cost of `'
|
|
190
|
+
- `'cubic'` — Catmull-Rom across cell boundaries. Smoothest; ~2.5× the cost of `'bilinear'`.
|
|
191
|
+
|
|
192
|
+
### Monochrome mesh (v1.1.0)
|
|
193
|
+
|
|
194
|
+
Mesh-level analogue of `lite-gradient`'s `monochromeGradient` — chroma and hue held
|
|
195
|
+
constant across every control point; only lightness varies. Client-work-friendly:
|
|
196
|
+
subtle premium backgrounds without the "AI-generated random gradient" look.
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
import { monochromeMesh } from '@zakkster/lite-gradient-studio';
|
|
200
|
+
|
|
201
|
+
// 3×3 mesh, all points share the brand hue, L varies diagonally
|
|
202
|
+
const mesh = monochromeMesh({ l: 0.5, c: 0.06, h: 245 }, 3, 3);
|
|
203
|
+
|
|
204
|
+
// Rasterize to a canvas
|
|
205
|
+
const buf = new Uint32Array(800 * 600);
|
|
206
|
+
mesh.rasterizeTo(buf, 800, 600);
|
|
207
|
+
const img = new ImageData(new Uint8ClampedArray(buf.buffer), 800, 600);
|
|
208
|
+
ctx.putImageData(img, 0, 0);
|
|
209
|
+
|
|
210
|
+
// Or emit CSS (canvas-free rendering)
|
|
211
|
+
const css = formatCssMesh(mesh);
|
|
212
|
+
element.style.background = css;
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**`monochromeMesh(base, cols, rows, opts?)`** → `MeshGradient`
|
|
216
|
+
|
|
217
|
+
| Option | Type | Default | Notes |
|
|
218
|
+
|-------------|---------------------------------------------------------------|----------------|-----------------------------------------------------------------------------|
|
|
219
|
+
| `mode` | `'tinted' \| 'grayscale'` | `'tinted'` | `'tinted'` retains base c/h; `'grayscale'` forces c=0. |
|
|
220
|
+
| `range` | `[number, number]` | `[0, 1]` | L-axis endpoints. Must satisfy `0 ≤ lo < hi ≤ 1`. |
|
|
221
|
+
| `direction` | `'horizontal' \| 'vertical' \| 'diagonal' \| 'radial'` | `'diagonal'` | How L varies across the mesh. See below. |
|
|
222
|
+
|
|
223
|
+
**Directions:**
|
|
224
|
+
- **`'horizontal'`** — L varies left-to-right, uniform across each row. Like a linear gradient, but you can deform post-hoc.
|
|
225
|
+
- **`'vertical'`** — L varies top-to-bottom, uniform across each column.
|
|
226
|
+
- **`'diagonal'`** (default) — top-left corner (lo) to bottom-right corner (hi). Most versatile; uses both axes meaningfully.
|
|
227
|
+
- **`'radial'`** — center (lo) outward to corners (hi). Atmospheric "premium" feel.
|
|
228
|
+
|
|
229
|
+
Post-construction, you can `setPointPosition(...)` to warp the L distribution off-grid
|
|
230
|
+
— something you can't do with a flat 1D gradient. This is what mesh capability buys you
|
|
231
|
+
in the client-work scenario: brand-safe base + designer-controlled organic character.
|
|
185
232
|
|
|
186
233
|
### CSS emitters
|
|
187
234
|
|
|
@@ -198,8 +245,8 @@ Each emitter preserves the original `pos` values; no resampling.
|
|
|
198
245
|
|
|
199
246
|
| Function | Description |
|
|
200
247
|
|---|---|
|
|
201
|
-
| `toTokens1d(
|
|
202
|
-
| `toTokensMesh(
|
|
248
|
+
| `toTokens1d(state, format, opts?)` | Dispatch on format id. |
|
|
249
|
+
| `toTokensMesh(mesh, format, opts?)` | Same, for mesh. |
|
|
203
250
|
| `EXPORT_FORMATS_1D` | Frozen array of supported 1D format ids. |
|
|
204
251
|
| `EXPORT_FORMATS_MESH` | Frozen array of supported mesh format ids. |
|
|
205
252
|
| `FORMAT_META` | `{ [id]: { label, hint } }` — UI metadata. |
|
|
@@ -217,25 +264,25 @@ Direct format functions are also exported (`toCss1d`, `toScss1d`, `toTailwind1d`
|
|
|
217
264
|
| Function | Description |
|
|
218
265
|
|---|---|
|
|
219
266
|
| `toHex({ l, c, h, a? })` | OKLCH → `'#rrggbb'` or `'#rrggbbaa'` with sRGB gamut clip. |
|
|
220
|
-
| `fromHex(
|
|
221
|
-
| `oklchToLinearSrgb(L, C, H
|
|
222
|
-
| `linearSrgbToOklch(r, g, b
|
|
223
|
-
| `srgbGamma(
|
|
267
|
+
| `fromHex(hex)` | Hex → `{ l, c, h, a }`. |
|
|
268
|
+
| `oklchToLinearSrgb(L, C, H)` | Returns `[r, g, b]` each in `[0, 1]`. Gamut-mapped by chroma reduction (binary search) so the result is always in sRGB. |
|
|
269
|
+
| `linearSrgbToOklch(r, g, b)` | Returns `{ l, c, h }`. Inputs are linear sRGB in `[0, 1]`. |
|
|
270
|
+
| `srgbGamma(x)` / `srgbInverseGamma(x)` | sRGB transfer function. Inputs and outputs in `[0, 1]`. |
|
|
224
271
|
|
|
225
272
|
### Palette extraction
|
|
226
273
|
|
|
227
274
|
| Function | Description |
|
|
228
275
|
|---|---|
|
|
229
|
-
| `extractPalette(pixels, count)` | Chroma-weighted picks with ≥50° hue separation between picks. Skips shadows (`L < 0.10`) and near-neutrals (`C < 0.02`). |
|
|
276
|
+
| `extractPalette(pixels, count?)` | Chroma-weighted picks with ≥50° hue separation between picks. Skips shadows (`L < 0.10`) and near-neutrals (`C < 0.02`). `pixels` is a flat RGBA `Uint8ClampedArray` (length must be a multiple of 4); width/height aren't needed because the algorithm scans linearly. `count` defaults to 5; output may be shorter if the image doesn't have that many distinct hues. |
|
|
230
277
|
|
|
231
278
|
### Rasterization utilities
|
|
232
279
|
|
|
233
280
|
| Function | Description |
|
|
234
281
|
|---|---|
|
|
235
282
|
| `packOklchSingle(l, c, h, a?)` | Single OKLCH → ARGB-packed `Uint32`. |
|
|
236
|
-
| `bakeGradientToLut(gradient,
|
|
237
|
-
| `sampleLut(lut, t)` | Read a baked LUT. |
|
|
238
|
-
| `flattenStopsToBuffer(
|
|
283
|
+
| `bakeGradientToLut(gradient, resolution?, opts?)` | Pre-bake a 1D gradient into a Uint32Array LUT. Returns the LUT. `resolution` defaults to 256. `opts.easeFn` lets you apply easing on the evenly-spaced path; `opts.packer` overrides the default packer. |
|
|
284
|
+
| `sampleLut(lut, t)` | Read a baked LUT at `t ∈ [0, 1]`. Returns the packed ARGB `Uint32` at that index. |
|
|
285
|
+
| `flattenStopsToBuffer(gradient, out?)` | Write OKLCH stops to a Float32Array as `[L, C, H, L, C, H, ...]` (GPU-upload friendly). Optional `out` buffer reused if large enough. |
|
|
239
286
|
|
|
240
287
|
## Benchmarks
|
|
241
288
|
|
|
@@ -243,17 +290,18 @@ Node v22, single thread, Linux x64. Run yourself: `npm run bench`.
|
|
|
243
290
|
|
|
244
291
|
| Operation | Throughput |
|
|
245
292
|
|-----------------------------------------------|----------------|
|
|
246
|
-
| `Gradient.at(t)` — 5 stops, sweep | ~
|
|
247
|
-
| `Gradient.at(t)` — single call | ~
|
|
248
|
-
| `MeshGradient.sampleAt(u, v)` — 5×5 smooth | ~
|
|
249
|
-
| `MeshGradient.sampleAt(u, v)` — 5×5 cubic | ~
|
|
250
|
-
| `rasterizeTo` — 5×5 → 256×256
|
|
251
|
-
| `
|
|
252
|
-
| `
|
|
253
|
-
| `
|
|
254
|
-
| `
|
|
255
|
-
| `
|
|
256
|
-
| `
|
|
293
|
+
| `Gradient.at(t)` — 5 stops, sweep | ~41 M ops/sec |
|
|
294
|
+
| `Gradient.at(t)` — single call | ~10 M ops/sec |
|
|
295
|
+
| `MeshGradient.sampleAt(u, v)` — 5×5 smooth | ~5.5 M ops/sec |
|
|
296
|
+
| `MeshGradient.sampleAt(u, v)` — 5×5 cubic | ~2.0 M ops/sec |
|
|
297
|
+
| `rasterizeTo` — 5×5 → 256×256 bilinear | ~2.5 M px/sec |
|
|
298
|
+
| `rasterizeTo` — 5×5 → 256×256 cubic | ~1.5 M px/sec |
|
|
299
|
+
| `rasterizeDeformedTo` — 5×5 → 256×256 | ~2.6 M px/sec |
|
|
300
|
+
| `packOklchSingle` — one pixel | ~3.0 M ops/sec |
|
|
301
|
+
| `formatCssLinear` — 3 stops | ~490 K ops/sec |
|
|
302
|
+
| `toCssMesh` — 5×5 multi-radial | ~40 K ops/sec |
|
|
303
|
+
| `parseGradientCss` — 3-stop linear | ~99 K ops/sec |
|
|
304
|
+
| `extractPalette` — 240×240 RGBA, k=5 | ~52 calls/sec |
|
|
257
305
|
|
|
258
306
|
At 60 fps you have 16.6 ms per frame. A 1600×640 mesh raster lands well inside that on this hardware; deformed rasters at the same size sit around 8-12 ms in browser benchmarks (V8/Chrome) — the offscreen-canvas quarter-res pattern shown in [Gradient Studio](https://gradient.studio)'s `mesh/render.js` keeps drag-time interaction at full 120 Hz.
|
|
259
307
|
|
|
@@ -289,7 +337,7 @@ At 60 fps you have 16.6 ms per frame. A 1600×640 mesh raster lands well inside
|
|
|
289
337
|
- **Caller owns memory in hot paths.** `sampleAt(u, v, out)` writes into `out` you allocated once. No `{l, c, h}` object per pixel.
|
|
290
338
|
- **Position fidelity.** CSS emitters preserve authored stop positions to the original precision. No resampling that designers would notice and complain about.
|
|
291
339
|
- **The math you'd want.** Bilinear, Catmull-Rom, Newton inverse-bilinear, OKLCH ↔ sRGB matrix, gamut clip via boundary search — all the standard recipes, no clever shortcuts that turn out wrong.
|
|
292
|
-
- **Test-pass before shape.**
|
|
340
|
+
- **Test-pass before shape.** 178 tests over 1,993 lines; every module has its file in `test/`. Refactor with confidence.
|
|
293
341
|
|
|
294
342
|
## Versioning
|
|
295
343
|
|
package/llms.txt
CHANGED
|
@@ -50,10 +50,10 @@ do the interpolation in OKLCH space. Without the hint they lerp in sRGB.
|
|
|
50
50
|
## Build, deform, and rasterize a mesh
|
|
51
51
|
|
|
52
52
|
const m = new MeshGradient(5, 5);
|
|
53
|
-
m.
|
|
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, {
|
|
56
|
+
m.rasterizeDeformedTo(buf, W, H, { interpolation: 'smooth' });
|
|
57
57
|
// buf is ARGB-packed sRGB. Wrap as ImageData:
|
|
58
58
|
const img = new ImageData(new Uint8ClampedArray(buf.buffer), W, H);
|
|
59
59
|
|
|
@@ -63,17 +63,66 @@ positions affect rasterization geometry but NOT the color basis. To
|
|
|
63
63
|
sampleAt and ignore the deformation. That's the right behavior when
|
|
64
64
|
resizing a mesh and preserving its theme.
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
- '
|
|
69
|
-
- '
|
|
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. C¹ at seams.
|
|
70
|
+
- 'cubic' — Catmull-Rom across cells. Smoothest, ~2.5× cost.
|
|
71
|
+
|
|
72
|
+
Legacy boolean accepted for sampleAt: `false` → bilinear, `true` → smooth.
|
|
73
|
+
Rasterize also accepts `opts.smooth: true` as a legacy alias.
|
|
74
|
+
|
|
75
|
+
## Monochrome mesh (v1.1.0)
|
|
76
|
+
|
|
77
|
+
Factory for meshes where every control point shares base c/h (or c=0 for
|
|
78
|
+
grayscale) and only L varies across the grid. Client-work-friendly:
|
|
79
|
+
never looks like an "AI-generated random gradient" because the palette
|
|
80
|
+
is fixed to one brand tone.
|
|
81
|
+
|
|
82
|
+
import { monochromeMesh, formatCssMesh } from '@zakkster/lite-gradient-studio';
|
|
83
|
+
|
|
84
|
+
// Brand-tinted mesh, L varies diagonally
|
|
85
|
+
const m = monochromeMesh({ l: 0.5, c: 0.06, h: 245 }, 3, 3);
|
|
86
|
+
|
|
87
|
+
// Or explicit direction + grayscale + printable-safe range
|
|
88
|
+
const gm = monochromeMesh(
|
|
89
|
+
{ l: 0.5, c: 0, h: 0 },
|
|
90
|
+
4, 4,
|
|
91
|
+
{ mode: 'grayscale', range: [0.05, 0.95], direction: 'radial' }
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Emit CSS without canvas
|
|
95
|
+
element.style.background = formatCssMesh(m);
|
|
96
|
+
|
|
97
|
+
Options:
|
|
98
|
+
- mode: 'tinted' (default) | 'grayscale'
|
|
99
|
+
- range: [lo, hi] with 0 <= lo < hi <= 1 (default [0, 1])
|
|
100
|
+
- direction: 'horizontal' | 'vertical' | 'diagonal' (default) | 'radial'
|
|
101
|
+
|
|
102
|
+
Directions:
|
|
103
|
+
- horizontal: L varies left-to-right, uniform per row
|
|
104
|
+
- vertical: L varies top-to-bottom, uniform per column
|
|
105
|
+
- diagonal: top-left corner (lo) to bottom-right corner (hi)
|
|
106
|
+
- radial: center (lo) outward to corners (hi)
|
|
107
|
+
|
|
108
|
+
Returns a MeshGradient. All post-construction methods (setPointPosition,
|
|
109
|
+
setPoint, sampleAt, rasterizeTo, etc.) work as normal — the factory just
|
|
110
|
+
initializes the color basis. Deform handles off-grid to warp L across
|
|
111
|
+
the surface without disturbing the brand palette.
|
|
112
|
+
|
|
113
|
+
Pairs with @zakkster/lite-gradient's monochromeGradient(base, opts) which
|
|
114
|
+
returns a continuous 1D Gradient; monochromeMesh returns a 2D MeshGradient.
|
|
70
115
|
|
|
71
116
|
## Multi-format export
|
|
72
117
|
|
|
73
118
|
const state = { mode: 'linear', angle: 135, stops };
|
|
74
|
-
toTokens1d('css',
|
|
75
|
-
toTokens1d('tailwind',
|
|
76
|
-
toTokens1d('svg',
|
|
119
|
+
toTokens1d(state, 'css', { name: 'sunset' });
|
|
120
|
+
toTokens1d(state, 'tailwind', { name: 'sunset' });
|
|
121
|
+
toTokens1d(state, 'svg', { name: 'sunset' });
|
|
122
|
+
|
|
123
|
+
Signature: toTokens1d(g1dState, formatId, opts) — STATE first, FORMAT
|
|
124
|
+
second. Returns a string. toTokensMesh(mesh, formatId, opts) follows
|
|
125
|
+
the same pattern.
|
|
77
126
|
|
|
78
127
|
Supported 1D formats: 'css', 'css-var', 'scss', 'tailwind', 'json', 'svg'.
|
|
79
128
|
Supported mesh formats: 'css', 'css-var', 'json'.
|
|
@@ -87,28 +136,22 @@ Round-trips with formatCssLinear/Radial/Conic.
|
|
|
87
136
|
|
|
88
137
|
## Extract a palette from an image
|
|
89
138
|
|
|
90
|
-
//
|
|
91
|
-
const palette = extractPalette(
|
|
92
|
-
//
|
|
139
|
+
// rgba is a Uint8ClampedArray of length width*height*4
|
|
140
|
+
const palette = extractPalette(rgba, 5);
|
|
141
|
+
// → [{ l, c, h }, ...] — up to 5 picks, chroma-weighted, ≥50° hue separated
|
|
142
|
+
|
|
143
|
+
Signature: extractPalette(pixels, count?). Width/height NOT required —
|
|
144
|
+
the algorithm scans linearly. `count` defaults to 5; output may be
|
|
145
|
+
shorter if the image lacks that many distinct hues.
|
|
93
146
|
|
|
94
147
|
Chroma-weighted means vivid colors win over dominant-by-pixel-count
|
|
95
148
|
colors. A baby-in-blue-shirt photo returns a palette with blue, not
|
|
96
149
|
five shades of skin.
|
|
97
150
|
|
|
98
|
-
|
|
99
|
-
width and height as separate parameters. Earlier draft docs claimed a
|
|
100
|
-
`(rgba, w, h, count)` signature -- corrected in 1.0.
|
|
101
|
-
|
|
102
|
-
## Convert OKLCH <-> hex
|
|
103
|
-
|
|
104
|
-
toHex({ l: 0.65, c: 0.26, h: 320 }); // -> '#cf5fb9' (sRGB-gamut clipped)
|
|
105
|
-
fromHex('#cf5fb9'); // -> { l, c, h, a }
|
|
151
|
+
## Convert OKLCH ↔ hex
|
|
106
152
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
oklchToLinearSrgb(0.5, 0.2, 240, rgbOut); // writes into rgbOut, returns rgbOut
|
|
110
|
-
const lchOut = { l: 0, c: 0, h: 0 };
|
|
111
|
-
linearSrgbToOklch(0.4, 0.3, 0.8, lchOut); // writes into lchOut, returns lchOut
|
|
153
|
+
toHex({ l: 0.65, c: 0.26, h: 320 }); // → '#cf5fb9' (sRGB-gamut clipped)
|
|
154
|
+
fromHex('#cf5fb9'); // → { l, c, h, a }
|
|
112
155
|
|
|
113
156
|
# Stop shape
|
|
114
157
|
|
|
@@ -148,7 +191,7 @@ the rasterizer writes — no copy on putImageData.
|
|
|
148
191
|
# Anti-patterns
|
|
149
192
|
|
|
150
193
|
- DO NOT construct a new Gradient per frame in a hot loop. Make it once,
|
|
151
|
-
store the reference, mutate via
|
|
194
|
+
store the reference, mutate via setPoint / setPointPosition if
|
|
152
195
|
the colors change.
|
|
153
196
|
- DO NOT pass a fresh scratch object to sampleAt per call. Allocate
|
|
154
197
|
one at module scope or per editor instance.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-gradient-studio",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Authoring engine for OKLCH gradients
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Authoring engine for OKLCH gradients — linear, radial, conic, and N×M mesh — with zero-GC rasterization, multi-format export (CSS/SCSS/Tailwind/JSON/SVG), and chroma-weighted palette extraction.",
|
|
5
5
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
@@ -10,25 +10,27 @@
|
|
|
10
10
|
"types": "./src/index.d.ts",
|
|
11
11
|
"exports": {
|
|
12
12
|
".": {
|
|
13
|
+
"types": "./src/index.d.ts",
|
|
13
14
|
"node": "./src/index.js",
|
|
14
15
|
"import": "./src/index.js",
|
|
15
|
-
"types": "./src/index.d.ts",
|
|
16
16
|
"default": "./src/index.js"
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
|
-
"src
|
|
21
|
-
"README.md",
|
|
20
|
+
"src",
|
|
22
21
|
"llms.txt",
|
|
23
22
|
"CHANGELOG.md",
|
|
24
|
-
"LICENSE"
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"README.md"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"test": "node --test --
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
27
|
+
"test": "node --test --expose-gc test/*.test.js",
|
|
28
|
+
"bench": "node --expose-gc bench/run.mjs"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@zakkster/lite-color": "^1.0.6",
|
|
32
|
+
"@zakkster/lite-color-engine": "^1.0.0",
|
|
33
|
+
"@zakkster/lite-gradient": "^1.1.0"
|
|
32
34
|
},
|
|
33
35
|
"keywords": [
|
|
34
36
|
"gradient",
|
|
@@ -43,12 +45,9 @@
|
|
|
43
45
|
"color-interpolation",
|
|
44
46
|
"perceptually-uniform",
|
|
45
47
|
"zero-gc",
|
|
46
|
-
"zero-allocation",
|
|
47
48
|
"lite",
|
|
48
49
|
"no-build",
|
|
49
|
-
"esm"
|
|
50
|
-
"rasterizer",
|
|
51
|
-
"deterministic"
|
|
50
|
+
"esm"
|
|
52
51
|
],
|
|
53
52
|
"homepage": "https://github.com/PeshoVurtoleta/lite-gradient-studio#readme",
|
|
54
53
|
"repository": {
|
|
@@ -56,26 +55,11 @@
|
|
|
56
55
|
"url": "git+https://github.com/PeshoVurtoleta/lite-gradient-studio.git"
|
|
57
56
|
},
|
|
58
57
|
"bugs": {
|
|
59
|
-
"url": "https://github.com/PeshoVurtoleta/lite-gradient-studio/issues"
|
|
60
|
-
"email": "shinikchiev@yahoo.com"
|
|
61
|
-
},
|
|
62
|
-
"engines": {
|
|
63
|
-
"node": ">=18"
|
|
58
|
+
"url": "https://github.com/PeshoVurtoleta/lite-gradient-studio/issues"
|
|
64
59
|
},
|
|
65
60
|
"funding": {
|
|
66
61
|
"type": "github",
|
|
67
62
|
"url": "https://github.com/sponsors/PeshoVurtoleta"
|
|
68
63
|
},
|
|
69
|
-
"
|
|
70
|
-
"access": "public"
|
|
71
|
-
},
|
|
72
|
-
"sideEffects": false,
|
|
73
|
-
"dependencies": {
|
|
74
|
-
"@zakkster/lite-color": "^1.0.6",
|
|
75
|
-
"@zakkster/lite-color-engine": "^1.0.0",
|
|
76
|
-
"@zakkster/lite-gradient": "^1.0.4"
|
|
77
|
-
},
|
|
78
|
-
"devDependencies": {
|
|
79
|
-
"c8": "^11.0.0"
|
|
80
|
-
}
|
|
64
|
+
"sideEffects": false
|
|
81
65
|
}
|