@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.
@@ -1,136 +1,95 @@
1
1
  /**
2
- * OKLCH <-> hex.
2
+ * OKLCH hex.
3
3
  *
4
4
  * Pure math; no DOM. Ported from `@zakkster/lite-hueforge` so this
5
- * library doesn't depend on Hueforge for a single utility -- and so
5
+ * library doesn't depend on Hueforge for a single utility and so
6
6
  * future consumers can use Gradient Studio's exporters standalone.
7
7
  *
8
8
  * `toHex` is the hot path (called once per stop per export). The
9
9
  * gamut mapper does a 10-iteration binary search on chroma when the
10
- * requested color is out of sRGB -- precision dC ~= 0.0002, which is
10
+ * requested color is out of sRGB precision Δc 0.0002, which is
11
11
  * well below perceptual threshold.
12
- *
13
- * Zero-GC discipline:
14
- * - `oklchToLinearSrgb(L, C, H, out?)`: pass a `[r, g, b]` array to
15
- * `out` for zero-allocation; otherwise allocates one fresh array.
16
- * - `linearSrgbToOklch(r, g, b, out?)`: pass a `{l, c, h}` object to
17
- * `out` for zero-allocation; otherwise allocates one fresh object.
18
- * - The internal binary-search helper uses a module-level scratch and
19
- * takes (L, cosH, sinH) by parameter rather than closing over them,
20
- * so no per-call closure is allocated either way.
21
12
  */
22
13
 
23
- /* Module-level scratch for the gamut-mapping binary search. Single-
24
- * threaded JS: safe to reuse across calls. Not exposed. */
25
- const _gamutTry = [0, 0, 0];
26
-
27
14
  /**
28
- * Evaluate (L, C, H) -> linear-sRGB into `dst[0..2]`.
29
- * Pure helper: takes everything by parameter, no closures. Inlines fine.
30
- */
31
- function evalOklchRgb(L, cosH, sinH, cVal, dst) {
32
- const a = cVal * cosH;
33
- const b = cVal * sinH;
34
- const lP = L + 0.3963377774 * a + 0.2158037573 * b;
35
- const mP = L - 0.1055613458 * a - 0.0638541728 * b;
36
- const sP = L - 0.0894841775 * a - 1.2914855480 * b;
37
- const lL = lP * lP * lP;
38
- const mL = mP * mP * mP;
39
- const sL = sP * sP * sP;
40
- dst[0] = +4.0767416621 * lL - 3.3077115913 * mL + 0.2309699292 * sL;
41
- dst[1] = -1.2684380046 * lL + 2.6097574011 * mL - 0.3413193965 * sL;
42
- dst[2] = -0.0041960863 * lL - 0.7034186147 * mL + 1.7076147010 * sL;
43
- }
44
-
45
- /**
46
- * OKLCH -> linear sRGB triplet, with sRGB-gamut mapping by chroma reduction.
47
- * Output range: each channel in [0, 1].
15
+ * OKLCH linear sRGB triplet, with sRGB-gamut mapping by chroma reduction.
16
+ * Output: `[r, g, b]` each in `[0, 1]`.
48
17
  *
49
- * @param {number} L
50
- * @param {number} C
51
- * @param {number} H
52
- * @param {number[]} [out] 3-element array. If provided, written in place
53
- * and returned (zero allocation). Otherwise a
54
- * fresh `[r, g, b]` array is allocated.
55
- * @returns {number[]} Same `out` if provided, else a new array.
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.
56
21
  */
57
22
  export function oklchToLinearSrgb(L, C, H, out) {
58
- if (!out) out = [0, 0, 0];
59
-
60
23
  const hRad = H * Math.PI / 180;
61
24
  const cosH = Math.cos(hRad);
62
25
  const sinH = Math.sin(hRad);
63
26
 
64
- // Try the requested chroma. If it's in-gamut, we're done.
65
- evalOklchRgb(L, cosH, sinH, C, out);
66
- if (
67
- out[0] >= 0 && out[0] <= 1 &&
68
- out[1] >= 0 && out[1] <= 1 &&
69
- out[2] >= 0 && out[2] <= 1
70
- ) {
71
- return out;
27
+ function getRgb(cVal) {
28
+ const a = cVal * cosH;
29
+ const b = cVal * sinH;
30
+ const lP = L + 0.3963377774 * a + 0.2158037573 * b;
31
+ const mP = L - 0.1055613458 * a - 0.0638541728 * b;
32
+ const sP = L - 0.0894841775 * a - 1.2914855480 * b;
33
+ const lL = lP * lP * lP;
34
+ const mL = mP * mP * mP;
35
+ const sL = sP * sP * sP;
36
+ const r = +4.0767416621 * lL - 3.3077115913 * mL + 0.2309699292 * sL;
37
+ const g = -1.2684380046 * lL + 2.6097574011 * mL - 0.3413193965 * sL;
38
+ const bb = -0.0041960863 * lL - 0.7034186147 * mL + 1.7076147010 * sL;
39
+ return [r, g, bb];
72
40
  }
73
41
 
74
- // Out of gamut. Try C=0 (a neutral at the same L). If that's STILL
75
- // out of gamut, L is extreme (caller passed L > 1 or L < 0); clamp
76
- // channelwise and return.
77
- evalOklchRgb(L, cosH, sinH, 0, _gamutTry);
78
- if (
79
- _gamutTry[0] < 0 || _gamutTry[0] > 1 ||
80
- _gamutTry[1] < 0 || _gamutTry[1] > 1 ||
81
- _gamutTry[2] < 0 || _gamutTry[2] > 1
82
- ) {
83
- if (out[0] < 0) out[0] = 0; else if (out[0] > 1) out[0] = 1;
84
- if (out[1] < 0) out[1] = 0; else if (out[1] > 1) out[1] = 1;
85
- if (out[2] < 0) out[2] = 0; else if (out[2] > 1) out[2] = 1;
86
- return out;
42
+ let [r, g, bb] = getRgb(C);
43
+
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; }
46
+ return [r, g, bb];
47
+ }
48
+
49
+ // Gamut map via binary search on chroma. The C=0 point at this L is
50
+ // always in gamut if L ∈ [0,1]; defensive fallback covers caller
51
+ // passing out-of-range L.
52
+ const [gr, gg, gb] = getRgb(0);
53
+ if (gr < 0 || gr > 1 || gg < 0 || gg > 1 || gb < 0 || gb > 1) {
54
+ if (r < 0) r = 0; else if (r > 1) r = 1;
55
+ if (g < 0) g = 0; else if (g > 1) g = 1;
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; }
58
+ return [r, g, bb];
87
59
  }
88
60
 
89
- // Binary search for the largest in-gamut chroma in [0, C]. The fit
90
- // is held in `out`; the candidate at the current `midC` is in
91
- // `_gamutTry`. Precision dC ~= C / 2^10 ~= 0.0002 -- well below
92
- // perceptual threshold.
93
61
  let lo = 0, hi = C;
94
- out[0] = _gamutTry[0]; out[1] = _gamutTry[1]; out[2] = _gamutTry[2];
62
+ let fitR = gr, fitG = gg, fitB = gb;
95
63
  for (let i = 0; i < 10; i++) {
96
64
  const midC = (lo + hi) / 2;
97
- evalOklchRgb(L, cosH, sinH, midC, _gamutTry);
98
- if (
99
- _gamutTry[0] >= 0 && _gamutTry[0] <= 1 &&
100
- _gamutTry[1] >= 0 && _gamutTry[1] <= 1 &&
101
- _gamutTry[2] >= 0 && _gamutTry[2] <= 1
102
- ) {
65
+ const [mr, mg, mbb] = getRgb(midC);
66
+ if (mr >= 0 && mr <= 1 && mg >= 0 && mg <= 1 && mbb >= 0 && mbb <= 1) {
103
67
  lo = midC;
104
- out[0] = _gamutTry[0];
105
- out[1] = _gamutTry[1];
106
- out[2] = _gamutTry[2];
68
+ fitR = mr; fitG = mg; fitB = mbb;
107
69
  } else {
108
70
  hi = midC;
109
71
  }
110
72
  }
111
- return out;
73
+ if (out) { out[0] = fitR; out[1] = fitG; out[2] = fitB; return out; }
74
+ return [fitR, fitG, fitB];
112
75
  }
113
76
 
114
- /** sRGB transfer (linear -> gamma-encoded). IEC 61966-2-1. */
77
+ /** sRGB transfer (linear gamma-encoded). IEC 61966-2-1. */
115
78
  export function srgbGamma(x) {
116
79
  return x <= 0.0031308
117
80
  ? x * 12.92
118
81
  : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
119
82
  }
120
83
 
121
- /** sRGB inverse transfer (gamma-encoded -> linear). */
84
+ /** sRGB inverse transfer (gamma-encoded linear). */
122
85
  export function srgbInverseGamma(x) {
123
86
  return x <= 0.04045
124
87
  ? x / 12.92
125
88
  : Math.pow((x + 0.055) / 1.055, 2.4);
126
89
  }
127
90
 
128
- /* Scratch for `toHex`. Single allocation at module load; safe to reuse
129
- * because toHex completes synchronously and JS is single-threaded. */
130
- const _toHexRgb = [0, 0, 0];
131
-
132
91
  /**
133
- * OKLCH -> hex. Emits '#rrggbb' for opaque colors (a >= 1 or undefined)
92
+ * OKLCH hex. Emits '#rrggbb' for opaque colors (a >= 1 or undefined)
134
93
  * and '#rrggbbaa' when alpha is below 1. Output always lowercase.
135
94
  *
136
95
  * Bit-pack trick: `(1<<24) | (R<<16) | (G<<8) | B` produces a number
@@ -139,10 +98,10 @@ const _toHexRgb = [0, 0, 0];
139
98
  * intermediate garbage.
140
99
  */
141
100
  export function toHex({ l, c, h, a }) {
142
- oklchToLinearSrgb(l, c, h, _toHexRgb);
143
- const R = (srgbGamma(_toHexRgb[0]) * 255 + 0.5) | 0;
144
- const G = (srgbGamma(_toHexRgb[1]) * 255 + 0.5) | 0;
145
- const B = (srgbGamma(_toHexRgb[2]) * 255 + 0.5) | 0;
101
+ const linRgb = oklchToLinearSrgb(l, c, h);
102
+ const R = (srgbGamma(linRgb[0]) * 255 + 0.5) | 0;
103
+ const G = (srgbGamma(linRgb[1]) * 255 + 0.5) | 0;
104
+ const B = (srgbGamma(linRgb[2]) * 255 + 0.5) | 0;
146
105
  const rgb = '#' + ((1 << 24) | (R << 16) | (G << 8) | B).toString(16).slice(1);
147
106
  // Opacity: skip the alpha byte when fully opaque, so the common case
148
107
  // produces clean 6-char hex matching design-tool conventions.
@@ -157,19 +116,16 @@ function clampByte(v) {
157
116
  }
158
117
 
159
118
  /**
160
- * Linear sRGB -> OKLCH. Used by fromHex and the palette extractor.
119
+ * Linear sRGB OKLCH. Used by fromHex and the palette extractor.
120
+ */
121
+ /**
122
+ * Linear sRGB → OKLCH. Inverse of `oklchToLinearSrgb`.
161
123
  *
162
- * @param {number} r Linear red, [0, 1].
163
- * @param {number} g
164
- * @param {number} b
165
- * @param {{l:number,c:number,h:number,a?:number}} [out]
166
- * If provided, written in place and returned (zero allocation).
167
- * The `a` field is left untouched -- callers that need alpha
168
- * plumbing set it themselves.
169
- * @returns {{l:number,c:number,h:number}} Same `out` if provided.
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.
170
127
  */
171
128
  export function linearSrgbToOklch(r, g, b, out) {
172
- if (!out) out = { l: 0, c: 0, h: 0 };
173
129
  const lLms = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
174
130
  const mLms = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
175
131
  const sLms = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
@@ -177,17 +133,17 @@ export function linearSrgbToOklch(r, g, b, out) {
177
133
  const mP = Math.cbrt(mLms);
178
134
  const sP = Math.cbrt(sLms);
179
135
  const L = 0.2104542553 * lP + 0.7936177850 * mP - 0.0040720468 * sP;
180
- const aa = 1.9779984951 * lP - 2.4285922050 * mP + 0.4505937099 * sP;
136
+ const a = 1.9779984951 * lP - 2.4285922050 * mP + 0.4505937099 * sP;
181
137
  const bb = 0.0259040371 * lP + 0.7827717662 * mP - 0.8086757660 * sP;
182
- const C = Math.sqrt(aa * aa + bb * bb);
183
- let H = Math.atan2(bb, aa) * 180 / Math.PI;
138
+ const C = Math.sqrt(a * a + bb * bb);
139
+ let H = Math.atan2(bb, a) * 180 / Math.PI;
184
140
  if (H < 0) H += 360;
185
- out.l = L; out.c = C; out.h = H;
186
- return out;
141
+ if (out) { out.l = L; out.c = C; out.h = H; return out; }
142
+ return { l: L, c: C, h: H };
187
143
  }
188
144
 
189
145
  /**
190
- * Hex string -> { l, c, h, a }. Accepts:
146
+ * Hex string { l, c, h, a }. Accepts:
191
147
  * - 3-char '#rgb' (alpha defaults to 1)
192
148
  * - 4-char '#rgba' (each nibble doubled)
193
149
  * - 6-char '#rrggbb' (alpha defaults to 1)
package/src/exporters.js CHANGED
@@ -82,6 +82,27 @@ export function toTokensMesh(mesh, format, opts = {}) {
82
82
 
83
83
  /* ── 1D exporters ────────────────────────────────────────────────── */
84
84
 
85
+ /**
86
+ * Read the position field from a stop, accepting both conventions.
87
+ *
88
+ * The 1D state shape carries position as `stop` (matches the editor's
89
+ * field name and the exporter signature documented in `toTokens1d`).
90
+ * The `Gradient` class (re-exported from `@zakkster/lite-gradient`)
91
+ * carries it as `pos`. README quick-start examples that pipe
92
+ * `gradient.stops` directly into `toTokens1d` would otherwise produce
93
+ * `NaN%` positions; this fallback makes either shape work without a
94
+ * caller-side map step.
95
+ *
96
+ * Precedence: `stop` first (it's the documented field), `pos` as
97
+ * fallback. Returns 0 if neither is set — defensive, mirrors how the
98
+ * rest of the exporter treats malformed input.
99
+ */
100
+ function readStopPos(s) {
101
+ if (s.stop !== undefined) return s.stop;
102
+ if (s.pos !== undefined) return s.pos;
103
+ return 0;
104
+ }
105
+
85
106
  /** Return the raw CSS gradient string for this 1D state. */
86
107
  function cssBodyFor1d(g) {
87
108
  // Build a Gradient-shaped stops array (renames `stop` → `pos`).
@@ -90,7 +111,7 @@ function cssBodyFor1d(g) {
90
111
  const stopsForCss = g.stops.map((s) => ({
91
112
  l: s.l, c: s.c, h: s.h,
92
113
  a: s.a === undefined ? 1 : s.a,
93
- pos: s.stop,
114
+ pos: readStopPos(s),
94
115
  }));
95
116
  const fake = { stops: stopsForCss }; // formatCss* only reads .stops
96
117
  if (g.mode === 'linear') {
@@ -162,7 +183,7 @@ export function toJson1d(g, opts = {}) {
162
183
  interpolation: 'oklch',
163
184
  stops: g.stops.map((s) => {
164
185
  const stop = {
165
- position: round3(s.stop),
186
+ position: round3(readStopPos(s)),
166
187
  color: { l: round3(s.l), c: round3(s.c), h: round1(s.h) },
167
188
  hex: toHex(s),
168
189
  };
package/src/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
@@ -281,29 +281,36 @@ export class MeshGradient {
281
281
  }
282
282
 
283
283
  /**
284
- * Sample the gradient at parametric (u, v) in [0, 1]^2 into `out`.
285
- * Zero-GC: writes into the caller-owned `out` object to avoid the
286
- * per-pixel allocation a return-by-value version would force.
287
- * Bilinear in OKLCH, hue-shortest-path via `lerpOklchTo`.
284
+ * Sample the mesh at normalized (u, v) [0, 1]² into `out`.
285
+ * Zero-GC. Bilinear in OKLCH, hue-shortest-path via lerpOklchTo.
288
286
  *
289
- * Hue normalization matters: `lerpOklchTo` picks the shortest hue
290
- * path but does NOT bound its output to [0, 360). At extreme corners
291
- * or after wrap-crossing lerps, the intermediate hue can land at 390
292
- * or -30. Left unnormalized, that propagates into the second lerp
293
- * and adds another 360 to the final hue. We normalize after each
294
- * stage.
287
+ * Hue normalization: lerpOklchTo takes the shortest-path delta but
288
+ * does NOT bound its output to [0, 360). At extreme corners or after
289
+ * wrap-crossing lerps, the intermediate hue can land at 390 or -30.
290
+ * Left unnormalized, that propagates into the second lerp and adds
291
+ * another 360 to the final hue. We normalize after each stage.
292
+ *
293
+ /**
294
+ * Sample the gradient at parametric (u, v) ∈ [0, 1]². Writes into `out`
295
+ * to avoid the per-pixel allocation a return-by-value version would force.
296
+ *
297
+ * Hue normalization matters: `lerpOklchTo` picks the shortest hue path but
298
+ * does NOT bound its output to [0, 360). At extreme corners or after
299
+ * wrap-crossing lerps, the intermediate hue can land at 390 or -30.
300
+ * Left unnormalized, that propagates into the second lerp and adds
301
+ * another 360 to the final hue. We normalize after each stage.
295
302
  *
296
303
  * The optional fourth parameter selects interpolation. Accepts:
297
- * - false / undefined -> 'bilinear' (original, C^0)
298
- * - true -> 'smooth' (smoothstep on cu/cv, C^1 at seams)
299
- * - 'cubic' -> Catmull-Rom 2D (C^1 everywhere; ~4x cost)
304
+ * - false / undefined 'bilinear' (original, C)
305
+ * - true 'smooth' (smoothstep on cu/cv, C¹ at seams)
306
+ * - 'cubic' Catmull-Rom 2D (C¹ everywhere; cost)
300
307
  * - any of the strings above explicitly
301
308
  *
302
309
  * @param {number} u
303
310
  * @param {number} v
304
- * @param {{l:number,c:number,h:number,a?:number}} out
311
+ * @param {{l:number,c:number,h:number}} out
305
312
  * @param {boolean|string} [modeOrSmooth=false]
306
- * @returns {{l:number,c:number,h:number,a:number}} same `out`
313
+ * @returns {{l:number,c:number,h:number}} same `out`
307
314
  */
308
315
  sampleAt(u, v, out, modeOrSmooth = false) {
309
316
  // Normalize the legacy boolean to the string form. Hot path —
@@ -671,3 +678,136 @@ export class MeshGradient {
671
678
  this._scratchBot = null;
672
679
  }
673
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
+ }