@zakkster/lite-gradient-studio 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,109 @@ 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
+
8
111
  ## [1.0.1] — docs accuracy patch
9
112
 
10
113
  Patch release. No code changes — every export, every behavior is
package/README.md CHANGED
@@ -1,8 +1,14 @@
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
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
6
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
7
13
 
8
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.
@@ -183,6 +189,47 @@ Each box is one module under `src/` with one test file under `test/`. Modules co
183
189
  - `'smooth'` — bilinear with smoothstep-eased u/v. Softens cell-border artifacts.
184
190
  - `'cubic'` — Catmull-Rom across cell boundaries. Smoothest; ~2.5× the cost of `'bilinear'`.
185
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.
232
+
186
233
  ### CSS emitters
187
234
 
188
235
  | Function | Description |
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 };
package/package.json CHANGED
@@ -1,13 +1,20 @@
1
1
  {
2
2
  "name": "@zakkster/lite-gradient-studio",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Authoring engine for OKLCH gradients — linear, radial, conic, and N×M mesh — with zero-GC rasterization, multi-format export (CSS/SCSS/Tailwind/JSON/SVG), and chroma-weighted palette extraction.",
5
5
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
- "main": "src/index.js",
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",
31
+ "@zakkster/lite-color": "^1.0.6",
25
32
  "@zakkster/lite-color-engine": "^1.0.0",
26
- "@zakkster/lite-gradient": "^1.0.4"
33
+ "@zakkster/lite-gradient": "^1.1.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
  }
@@ -13,9 +13,13 @@
13
13
 
14
14
  /**
15
15
  * OKLCH → linear sRGB triplet, with sRGB-gamut mapping by chroma reduction.
16
- * Output: [r, g, b] each in [0, 1].
16
+ * Output: `[r, g, b]` each in `[0, 1]`.
17
+ *
18
+ * Zero-GC path: pass a caller-owned 3-element array as `out`. The function
19
+ * writes into it and returns the same reference. Omit `out` (or pass null)
20
+ * to get a fresh allocated array — back-compat with the 3-arg call shape.
17
21
  */
18
- export function oklchToLinearSrgb(L, C, H) {
22
+ export function oklchToLinearSrgb(L, C, H, out) {
19
23
  const hRad = H * Math.PI / 180;
20
24
  const cosH = Math.cos(hRad);
21
25
  const sinH = Math.sin(hRad);
@@ -38,6 +42,7 @@ export function oklchToLinearSrgb(L, C, H) {
38
42
  let [r, g, bb] = getRgb(C);
39
43
 
40
44
  if (r >= 0 && r <= 1 && g >= 0 && g <= 1 && bb >= 0 && bb <= 1) {
45
+ if (out) { out[0] = r; out[1] = g; out[2] = bb; return out; }
41
46
  return [r, g, bb];
42
47
  }
43
48
 
@@ -49,6 +54,7 @@ export function oklchToLinearSrgb(L, C, H) {
49
54
  if (r < 0) r = 0; else if (r > 1) r = 1;
50
55
  if (g < 0) g = 0; else if (g > 1) g = 1;
51
56
  if (bb < 0) bb = 0; else if (bb > 1) bb = 1;
57
+ if (out) { out[0] = r; out[1] = g; out[2] = bb; return out; }
52
58
  return [r, g, bb];
53
59
  }
54
60
 
@@ -64,6 +70,7 @@ export function oklchToLinearSrgb(L, C, H) {
64
70
  hi = midC;
65
71
  }
66
72
  }
73
+ if (out) { out[0] = fitR; out[1] = fitG; out[2] = fitB; return out; }
67
74
  return [fitR, fitG, fitB];
68
75
  }
69
76
 
@@ -111,7 +118,14 @@ function clampByte(v) {
111
118
  /**
112
119
  * Linear sRGB → OKLCH. Used by fromHex and the palette extractor.
113
120
  */
114
- export function linearSrgbToOklch(r, g, b) {
121
+ /**
122
+ * Linear sRGB → OKLCH. Inverse of `oklchToLinearSrgb`.
123
+ *
124
+ * Zero-GC path: pass a caller-owned `{ l, c, h }` as `out`. The function
125
+ * writes into it and returns the same reference. Omit `out` (or pass null)
126
+ * to get a fresh allocated object — back-compat with the 3-arg call shape.
127
+ */
128
+ export function linearSrgbToOklch(r, g, b, out) {
115
129
  const lLms = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
116
130
  const mLms = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
117
131
  const sLms = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
@@ -124,6 +138,7 @@ export function linearSrgbToOklch(r, g, b) {
124
138
  const C = Math.sqrt(a * a + bb * bb);
125
139
  let H = Math.atan2(bb, a) * 180 / Math.PI;
126
140
  if (H < 0) H += 360;
141
+ if (out) { out.l = L; out.c = C; out.h = H; return out; }
127
142
  return { l: L, c: C, h: H };
128
143
  }
129
144
 
package/src/index.d.ts CHANGED
@@ -143,6 +143,45 @@ export class MeshGradient {
143
143
  destroy(): void;
144
144
  }
145
145
 
146
+ // ---------------------------------------------------------------------------
147
+ // Monochrome mesh (v1.1.0)
148
+ // ---------------------------------------------------------------------------
149
+
150
+ export type MonoMode = 'tinted' | 'grayscale';
151
+ export type MonoMeshDirection = 'horizontal' | 'vertical' | 'diagonal' | 'radial';
152
+
153
+ export interface MonochromeMeshOptions {
154
+ /**
155
+ * Chroma handling.
156
+ * - `'tinted'` (default): retain base color's chroma/hue at every point.
157
+ * - `'grayscale'`: force chroma to 0 for pure achromatic tones.
158
+ */
159
+ mode?: MonoMode;
160
+ /** L-axis endpoints [lo, hi], must satisfy `0 <= lo < hi <= 1`. Default `[0, 1]`. */
161
+ range?: readonly [number, number];
162
+ /**
163
+ * How L varies across the mesh:
164
+ * - `'horizontal'`: left-to-right, uniform per row.
165
+ * - `'vertical'`: top-to-bottom, uniform per column.
166
+ * - `'diagonal'` (default): top-left corner (lo) to bottom-right corner (hi).
167
+ * - `'radial'`: center (lo) outward to corners (hi).
168
+ */
169
+ direction?: MonoMeshDirection;
170
+ }
171
+
172
+ /**
173
+ * Build a monochromatic MeshGradient from a single base OKLCH color.
174
+ * Chroma and hue are held constant across every control point; only
175
+ * lightness varies according to `direction`. Client-work-friendly:
176
+ * never looks "AI-generated" because the palette is fixed to one brand tone.
177
+ */
178
+ export function monochromeMesh(
179
+ base: OklchColor,
180
+ cols: number,
181
+ rows: number,
182
+ opts?: MonochromeMeshOptions
183
+ ): MeshGradient;
184
+
146
185
  // ---------------------------------------------------------------------------
147
186
  // CSS emitters
148
187
  // ---------------------------------------------------------------------------
package/src/index.js CHANGED
@@ -21,7 +21,7 @@ export {
21
21
  sampleLut,
22
22
  packOklchSingle,
23
23
  } from './bake.js';
24
- export { MeshGradient, defaultMeshColor } from './mesh.js';
24
+ export { MeshGradient, defaultMeshColor, monochromeMesh } from './mesh.js';
25
25
  export { formatCssLinear, formatCssRadial, formatCssConic } from './css-emitters.js';
26
26
  export { formatCssMesh } from './mesh-css.js';
27
27
  export {
package/src/mesh.js CHANGED
@@ -678,3 +678,136 @@ export class MeshGradient {
678
678
  this._scratchBot = null;
679
679
  }
680
680
  }
681
+
682
+ // ---- Monochrome mesh (v1.1.0) --------------------------------------------
683
+
684
+ /**
685
+ * Build a monochromatic MeshGradient from a single base OKLCH color.
686
+ *
687
+ * Chroma and hue are held constant across every control point in the mesh;
688
+ * only lightness varies according to `direction`. This is the mesh-level
689
+ * analogue to lite-gradient's `monochromeGradient(base, opts)` — a
690
+ * client-work-friendly mesh that never looks like an "AI-generated random
691
+ * gradient" because the palette is fixed to a single brand tone.
692
+ *
693
+ * Typical use: subtle premium backgrounds for cards, hero sections,
694
+ * editorial layouts. The mesh structure means users can post-hoc
695
+ * `setPointPosition(...)` control points off-grid to warp the L
696
+ * distribution — impossible with a flat 1D gradient.
697
+ *
698
+ * @param {{ l: number, c: number, h: number }} base
699
+ * OKLCH base color. `c` and `h` are held constant across the mesh
700
+ * (or c=0 if `mode: 'grayscale'`).
701
+ * @param {number} cols Number of columns (integer >= 2).
702
+ * @param {number} rows Number of rows (integer >= 2).
703
+ * @param {Object} [opts]
704
+ * @param {'tinted' | 'grayscale'} [opts.mode='tinted']
705
+ * @param {[number, number]} [opts.range=[0, 1]]
706
+ * L-axis endpoints. Must satisfy `0 <= lo < hi <= 1`.
707
+ * @param {'horizontal' | 'vertical' | 'diagonal' | 'radial'} [opts.direction='diagonal']
708
+ * How L varies across the mesh:
709
+ * - `'horizontal'`: L varies left-to-right, uniform across each row.
710
+ * - `'vertical'`: L varies top-to-bottom, uniform across each column.
711
+ * - `'diagonal'`: L varies from top-left (lo) to bottom-right (hi).
712
+ * - `'radial'`: L is `lo` at center, `hi` at corners.
713
+ * @returns {MeshGradient}
714
+ *
715
+ * @throws {TypeError} On invalid `base`, `mode`, `direction`, or `range` shape.
716
+ * @throws {RangeError} On invalid `range` values or `cols`/`rows` < 2.
717
+ *
718
+ * @example
719
+ * // Subtle premium background for a brand card
720
+ * const mesh = monochromeMesh({ l: 0.5, c: 0.06, h: 245 }, 3, 3);
721
+ * const buf = new Uint32Array(800 * 600);
722
+ * mesh.rasterizeTo(buf, 800, 600);
723
+ * const img = new ImageData(new Uint8ClampedArray(buf.buffer), 800, 600);
724
+ * ctx.putImageData(img, 0, 0);
725
+ */
726
+ export function monochromeMesh(base, cols, rows, opts) {
727
+ if (base == null || typeof base !== 'object' ||
728
+ typeof base.l !== 'number' || typeof base.c !== 'number' ||
729
+ typeof base.h !== 'number') {
730
+ throw new TypeError(
731
+ 'monochromeMesh: base must be { l, c, h } with numeric fields'
732
+ );
733
+ }
734
+ if (!Number.isInteger(cols) || cols < 2) {
735
+ throw new RangeError(
736
+ 'monochromeMesh: cols must be an integer >= 2, got ' + cols
737
+ );
738
+ }
739
+ if (!Number.isInteger(rows) || rows < 2) {
740
+ throw new RangeError(
741
+ 'monochromeMesh: rows must be an integer >= 2, got ' + rows
742
+ );
743
+ }
744
+
745
+ const o = opts || {};
746
+ const mode = o.mode == null ? 'tinted' : o.mode;
747
+ const range = o.range == null ? [0, 1] : o.range;
748
+ const direction = o.direction == null ? 'diagonal' : o.direction;
749
+
750
+ if (mode !== 'tinted' && mode !== 'grayscale') {
751
+ throw new TypeError(
752
+ 'monochromeMesh: mode must be "tinted" or "grayscale", got ' + mode
753
+ );
754
+ }
755
+ if (!Array.isArray(range) || range.length !== 2) {
756
+ throw new TypeError(
757
+ 'monochromeMesh: range must be a two-element [lo, hi] array'
758
+ );
759
+ }
760
+ const lo = range[0];
761
+ const hi = range[1];
762
+ if (typeof lo !== 'number' || typeof hi !== 'number' ||
763
+ !(lo >= 0 && hi <= 1 && lo < hi)) {
764
+ throw new RangeError(
765
+ 'monochromeMesh: range must satisfy 0 <= lo < hi <= 1, got [' +
766
+ lo + ', ' + hi + ']'
767
+ );
768
+ }
769
+ if (direction !== 'horizontal' && direction !== 'vertical' &&
770
+ direction !== 'diagonal' && direction !== 'radial') {
771
+ throw new TypeError(
772
+ 'monochromeMesh: direction must be "horizontal", "vertical", ' +
773
+ '"diagonal", or "radial", got ' + direction
774
+ );
775
+ }
776
+
777
+ const c = mode === 'grayscale' ? 0 : base.c;
778
+ const h = base.h;
779
+ const span = hi - lo;
780
+ const cx = (cols - 1) / 2;
781
+ const cy = (rows - 1) / 2;
782
+ // Half-diagonal from center to a corner, used to normalize radial t.
783
+ const maxRadial = Math.sqrt(cx * cx + cy * cy);
784
+ // Diagonal length in grid units (top-left to bottom-right corner),
785
+ // used to normalize diagonal t.
786
+ const diagDenom = (cols - 1) + (rows - 1);
787
+
788
+ const total = cols * rows;
789
+ const stops = new Array(total);
790
+
791
+ for (let i = 0; i < total; i++) {
792
+ const col = i % cols;
793
+ const row = (i / cols) | 0;
794
+
795
+ let t;
796
+ if (direction === 'horizontal') {
797
+ t = cols === 1 ? 0 : col / (cols - 1);
798
+ } else if (direction === 'vertical') {
799
+ t = rows === 1 ? 0 : row / (rows - 1);
800
+ } else if (direction === 'diagonal') {
801
+ t = diagDenom === 0 ? 0 : (col + row) / diagDenom;
802
+ } else {
803
+ // radial: 0 at center, 1 at corners
804
+ const dx = col - cx;
805
+ const dy = row - cy;
806
+ t = maxRadial === 0 ? 0 : Math.sqrt(dx * dx + dy * dy) / maxRadial;
807
+ }
808
+
809
+ stops[i] = { l: lo + span * t, c, h };
810
+ }
811
+
812
+ return new MeshGradient(cols, rows, stops);
813
+ }