@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/src/color-convert.js
CHANGED
|
@@ -1,136 +1,95 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OKLCH
|
|
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
|
|
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
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
143
|
-
const R = (srgbGamma(
|
|
144
|
-
const G = (srgbGamma(
|
|
145
|
-
const B = (srgbGamma(
|
|
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
|
|
119
|
+
* Linear sRGB → OKLCH. Used by fromHex and the palette extractor.
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* Linear sRGB → OKLCH. Inverse of `oklchToLinearSrgb`.
|
|
161
123
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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
|
|
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(
|
|
183
|
-
let H = Math.atan2(bb,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
285
|
-
* Zero-GC
|
|
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
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
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
|
|
298
|
-
* - true
|
|
299
|
-
* - 'cubic'
|
|
304
|
+
* - false / undefined → 'bilinear' (original, C⁰)
|
|
305
|
+
* - true → 'smooth' (smoothstep on cu/cv, C¹ at seams)
|
|
306
|
+
* - 'cubic' → Catmull-Rom 2D (C¹ everywhere; 4× 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
|
|
311
|
+
* @param {{l:number,c:number,h:number}} out
|
|
305
312
|
* @param {boolean|string} [modeOrSmooth=false]
|
|
306
|
-
* @returns {{l:number,c:number,h:number
|
|
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
|
+
}
|