@zakkster/lite-gradient-studio 1.0.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 +105 -0
- package/LICENSE +21 -0
- package/README.md +307 -0
- package/llms.txt +173 -0
- package/package.json +81 -0
- package/src/bake.js +141 -0
- package/src/color-convert.js +233 -0
- package/src/css-emitters.js +86 -0
- package/src/exporters.js +310 -0
- package/src/gradient-parse.js +447 -0
- package/src/index.d.ts +380 -0
- package/src/index.js +38 -0
- package/src/mesh-css.js +113 -0
- package/src/mesh.js +673 -0
- package/src/palette-extract.js +216 -0
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zakkster/lite-gradient-studio",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Authoring engine for OKLCH gradients -- linear, radial, conic, and NxM mesh -- with zero-GC rasterization, multi-format export (CSS/SCSS/Tailwind/JSON/SVG), CSS parser, and chroma-weighted palette extraction.",
|
|
5
|
+
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.js",
|
|
9
|
+
"module": "./src/index.js",
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"node": "./src/index.js",
|
|
14
|
+
"import": "./src/index.js",
|
|
15
|
+
"types": "./src/index.d.ts",
|
|
16
|
+
"default": "./src/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src/",
|
|
21
|
+
"README.md",
|
|
22
|
+
"llms.txt",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test --test-reporter=spec test/*.test.js",
|
|
28
|
+
"test:gc": "node --expose-gc --test --test-reporter=spec test/*.test.js",
|
|
29
|
+
"test:coverage": "c8 node --test --test-reporter=spec test/*.test.js",
|
|
30
|
+
"bench": "node --expose-gc bench/run.mjs",
|
|
31
|
+
"verify": "npm run test:gc && npm run bench"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"gradient",
|
|
35
|
+
"mesh-gradient",
|
|
36
|
+
"oklch",
|
|
37
|
+
"color",
|
|
38
|
+
"canvas",
|
|
39
|
+
"css",
|
|
40
|
+
"tailwind",
|
|
41
|
+
"svg",
|
|
42
|
+
"palette-extraction",
|
|
43
|
+
"color-interpolation",
|
|
44
|
+
"perceptually-uniform",
|
|
45
|
+
"zero-gc",
|
|
46
|
+
"zero-allocation",
|
|
47
|
+
"lite",
|
|
48
|
+
"no-build",
|
|
49
|
+
"esm",
|
|
50
|
+
"rasterizer",
|
|
51
|
+
"deterministic"
|
|
52
|
+
],
|
|
53
|
+
"homepage": "https://github.com/PeshoVurtoleta/lite-gradient-studio#readme",
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/PeshoVurtoleta/lite-gradient-studio.git"
|
|
57
|
+
},
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/PeshoVurtoleta/lite-gradient-studio/issues",
|
|
60
|
+
"email": "shinikchiev@yahoo.com"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=18"
|
|
64
|
+
},
|
|
65
|
+
"funding": {
|
|
66
|
+
"type": "github",
|
|
67
|
+
"url": "https://github.com/sponsors/PeshoVurtoleta"
|
|
68
|
+
},
|
|
69
|
+
"publishConfig": {
|
|
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
|
+
}
|
|
81
|
+
}
|
package/src/bake.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pixel-buffer bake helpers.
|
|
3
|
+
*
|
|
4
|
+
* Builds on @zakkster/lite-color-engine's bakeGradientToUint32, with one
|
|
5
|
+
* correctness wrapper: lite-color-engine treats keyframes as evenly spaced
|
|
6
|
+
* internally. Gradients with custom stop positions must be pre-sampled at
|
|
7
|
+
* LUT resolution so the position warp is baked in before packing.
|
|
8
|
+
*
|
|
9
|
+
* The pre-sample path also handles the more important second job: any
|
|
10
|
+
* single-color "ramp" or fully-custom stop layout still bakes correctly
|
|
11
|
+
* without having to teach the underlying engine about positions.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
bakeGradientToUint32,
|
|
16
|
+
packOklchBufferToUint32,
|
|
17
|
+
} from '@zakkster/lite-color-engine';
|
|
18
|
+
|
|
19
|
+
const EVEN_SPACING_EPS = 1e-9;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ stops: Array<{pos: number}> }} gradient
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
function stopsAreEvenlySpaced(gradient) {
|
|
26
|
+
const stops = gradient.stops;
|
|
27
|
+
const n = stops.length;
|
|
28
|
+
if (n < 2) return true;
|
|
29
|
+
const denom = n - 1;
|
|
30
|
+
for (let i = 0; i < n; i++) {
|
|
31
|
+
const expected = i / denom;
|
|
32
|
+
if (Math.abs(stops[i].pos - expected) > EVEN_SPACING_EPS) return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Flatten gradient stops into a Float32Array of [L, C, H, L, C, H, ...].
|
|
39
|
+
* Caller-owned output for zero-GC if a buffer is supplied.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ stops: Array<{l:number,c:number,h:number}> }} gradient
|
|
42
|
+
* @param {Float32Array} [out]
|
|
43
|
+
* @returns {Float32Array}
|
|
44
|
+
*/
|
|
45
|
+
export function flattenStopsToBuffer(gradient, out) {
|
|
46
|
+
const stops = gradient.stops;
|
|
47
|
+
const n = stops.length;
|
|
48
|
+
const buf = out && out.length >= n * 3 ? out : new Float32Array(n * 3);
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
const s = stops[i];
|
|
51
|
+
const o = i * 3;
|
|
52
|
+
buf[o] = s.l;
|
|
53
|
+
buf[o + 1] = s.c;
|
|
54
|
+
buf[o + 2] = s.h;
|
|
55
|
+
}
|
|
56
|
+
return buf;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Bake a Gradient into a fixed-resolution Uint32Array LUT of packed RGBA
|
|
61
|
+
* (little-endian byte order — directly aliasable as ImageData).
|
|
62
|
+
*
|
|
63
|
+
* Honors custom stop positions via a pre-sample step. Use this for any
|
|
64
|
+
* high-fidelity preview or PNG export path where Canvas2D's sRGB
|
|
65
|
+
* interpolation between sampled stops is not acceptable.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} gradient A lite-gradient Gradient instance.
|
|
68
|
+
* @param {number} [resolution=256]
|
|
69
|
+
* @param {object} [opts]
|
|
70
|
+
* @param {(t:number)=>number} [opts.easeFn] Easing applied before stop selection (even-spaced path only).
|
|
71
|
+
* @param {Function} [opts.packer] Override packer (e.g. packOklchBufferToUint32Fast).
|
|
72
|
+
* @returns {Uint32Array}
|
|
73
|
+
*/
|
|
74
|
+
export function bakeGradientToLut(gradient, resolution = 256, opts = {}) {
|
|
75
|
+
const { easeFn, packer } = opts;
|
|
76
|
+
|
|
77
|
+
if (stopsAreEvenlySpaced(gradient)) {
|
|
78
|
+
// Fast path: hand raw stops to bake, let it interpolate up to LUT res.
|
|
79
|
+
const keyframes = flattenStopsToBuffer(gradient);
|
|
80
|
+
return bakeGradientToUint32(
|
|
81
|
+
keyframes,
|
|
82
|
+
gradient.stops.length,
|
|
83
|
+
resolution,
|
|
84
|
+
easeFn,
|
|
85
|
+
packer,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Position-warped path: pre-sample at LUT resolution so authored
|
|
90
|
+
// positions are honored, then bake at numStops=resolution (per-cell
|
|
91
|
+
// interp degenerates to no-ops; effectively just packs).
|
|
92
|
+
//
|
|
93
|
+
// Note: easeFn is intentionally ignored here. Pre-sampling already
|
|
94
|
+
// resolves authored positions to evenly spaced output cells, so warping
|
|
95
|
+
// a second time would compound. Apply easing at sampleArray-call time
|
|
96
|
+
// if you need it on this path.
|
|
97
|
+
const sampled = new Float32Array(resolution * 3);
|
|
98
|
+
gradient.sampleArray(sampled, resolution);
|
|
99
|
+
return bakeGradientToUint32(
|
|
100
|
+
sampled,
|
|
101
|
+
resolution,
|
|
102
|
+
resolution,
|
|
103
|
+
undefined,
|
|
104
|
+
packer,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sample a single color from a baked LUT at parametric t.
|
|
110
|
+
* Helper for testing and one-off lookups; for fills, write straight to
|
|
111
|
+
* ImageData via Uint32Array aliasing instead.
|
|
112
|
+
*
|
|
113
|
+
* @param {Uint32Array} lut
|
|
114
|
+
* @param {number} t In [0, 1]; clamped.
|
|
115
|
+
* @returns {number} Packed RGBA in little-endian byte order.
|
|
116
|
+
*/
|
|
117
|
+
export function sampleLut(lut, t) {
|
|
118
|
+
if (t <= 0) return lut[0];
|
|
119
|
+
if (t >= 1) return lut[lut.length - 1];
|
|
120
|
+
const idx = (t * (lut.length - 1)) | 0;
|
|
121
|
+
return lut[idx];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Pack a single OKLCH color directly to a 32-bit RGBA value.
|
|
126
|
+
* Convenience re-export wrapping a 3-float scratch buffer so callers
|
|
127
|
+
* don't have to manage one.
|
|
128
|
+
*
|
|
129
|
+
* @param {number} l
|
|
130
|
+
* @param {number} c
|
|
131
|
+
* @param {number} h
|
|
132
|
+
* @param {number} [alpha=1]
|
|
133
|
+
* @returns {number}
|
|
134
|
+
*/
|
|
135
|
+
const _packScratch = new Float32Array(3);
|
|
136
|
+
export function packOklchSingle(l, c, h, alpha = 1) {
|
|
137
|
+
_packScratch[0] = l;
|
|
138
|
+
_packScratch[1] = c;
|
|
139
|
+
_packScratch[2] = h;
|
|
140
|
+
return packOklchBufferToUint32(_packScratch, 0, alpha);
|
|
141
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKLCH <-> hex.
|
|
3
|
+
*
|
|
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
|
|
6
|
+
* future consumers can use Gradient Studio's exporters standalone.
|
|
7
|
+
*
|
|
8
|
+
* `toHex` is the hot path (called once per stop per export). The
|
|
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
|
|
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
|
+
*/
|
|
22
|
+
|
|
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
|
+
/**
|
|
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].
|
|
48
|
+
*
|
|
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.
|
|
56
|
+
*/
|
|
57
|
+
export function oklchToLinearSrgb(L, C, H, out) {
|
|
58
|
+
if (!out) out = [0, 0, 0];
|
|
59
|
+
|
|
60
|
+
const hRad = H * Math.PI / 180;
|
|
61
|
+
const cosH = Math.cos(hRad);
|
|
62
|
+
const sinH = Math.sin(hRad);
|
|
63
|
+
|
|
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;
|
|
72
|
+
}
|
|
73
|
+
|
|
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;
|
|
87
|
+
}
|
|
88
|
+
|
|
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
|
+
let lo = 0, hi = C;
|
|
94
|
+
out[0] = _gamutTry[0]; out[1] = _gamutTry[1]; out[2] = _gamutTry[2];
|
|
95
|
+
for (let i = 0; i < 10; i++) {
|
|
96
|
+
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
|
+
) {
|
|
103
|
+
lo = midC;
|
|
104
|
+
out[0] = _gamutTry[0];
|
|
105
|
+
out[1] = _gamutTry[1];
|
|
106
|
+
out[2] = _gamutTry[2];
|
|
107
|
+
} else {
|
|
108
|
+
hi = midC;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** sRGB transfer (linear -> gamma-encoded). IEC 61966-2-1. */
|
|
115
|
+
export function srgbGamma(x) {
|
|
116
|
+
return x <= 0.0031308
|
|
117
|
+
? x * 12.92
|
|
118
|
+
: 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** sRGB inverse transfer (gamma-encoded -> linear). */
|
|
122
|
+
export function srgbInverseGamma(x) {
|
|
123
|
+
return x <= 0.04045
|
|
124
|
+
? x / 12.92
|
|
125
|
+
: Math.pow((x + 0.055) / 1.055, 2.4);
|
|
126
|
+
}
|
|
127
|
+
|
|
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
|
+
/**
|
|
133
|
+
* OKLCH -> hex. Emits '#rrggbb' for opaque colors (a >= 1 or undefined)
|
|
134
|
+
* and '#rrggbbaa' when alpha is below 1. Output always lowercase.
|
|
135
|
+
*
|
|
136
|
+
* Bit-pack trick: `(1<<24) | (R<<16) | (G<<8) | B` produces a number
|
|
137
|
+
* whose hex string is exactly `1RRGGBB`. One `.toString(16)` + one
|
|
138
|
+
* `.slice(1)` beats three `padStart(2, '0')` calls in both speed and
|
|
139
|
+
* intermediate garbage.
|
|
140
|
+
*/
|
|
141
|
+
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;
|
|
146
|
+
const rgb = '#' + ((1 << 24) | (R << 16) | (G << 8) | B).toString(16).slice(1);
|
|
147
|
+
// Opacity: skip the alpha byte when fully opaque, so the common case
|
|
148
|
+
// produces clean 6-char hex matching design-tool conventions.
|
|
149
|
+
if (a === undefined || a >= 1) return rgb;
|
|
150
|
+
const A = clampByte(a * 255);
|
|
151
|
+
return rgb + (A < 16 ? '0' : '') + A.toString(16);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function clampByte(v) {
|
|
155
|
+
const n = (v + 0.5) | 0;
|
|
156
|
+
return n < 0 ? 0 : n > 255 ? 255 : n;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Linear sRGB -> OKLCH. Used by fromHex and the palette extractor.
|
|
161
|
+
*
|
|
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.
|
|
170
|
+
*/
|
|
171
|
+
export function linearSrgbToOklch(r, g, b, out) {
|
|
172
|
+
if (!out) out = { l: 0, c: 0, h: 0 };
|
|
173
|
+
const lLms = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
174
|
+
const mLms = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
175
|
+
const sLms = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
176
|
+
const lP = Math.cbrt(lLms);
|
|
177
|
+
const mP = Math.cbrt(mLms);
|
|
178
|
+
const sP = Math.cbrt(sLms);
|
|
179
|
+
const L = 0.2104542553 * lP + 0.7936177850 * mP - 0.0040720468 * sP;
|
|
180
|
+
const aa = 1.9779984951 * lP - 2.4285922050 * mP + 0.4505937099 * sP;
|
|
181
|
+
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;
|
|
184
|
+
if (H < 0) H += 360;
|
|
185
|
+
out.l = L; out.c = C; out.h = H;
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Hex string -> { l, c, h, a }. Accepts:
|
|
191
|
+
* - 3-char '#rgb' (alpha defaults to 1)
|
|
192
|
+
* - 4-char '#rgba' (each nibble doubled)
|
|
193
|
+
* - 6-char '#rrggbb' (alpha defaults to 1)
|
|
194
|
+
* - 8-char '#rrggbbaa'
|
|
195
|
+
* '#' prefix is optional; case-insensitive.
|
|
196
|
+
*
|
|
197
|
+
* @throws on malformed input.
|
|
198
|
+
*/
|
|
199
|
+
export function fromHex(hex) {
|
|
200
|
+
let s = String(hex).trim();
|
|
201
|
+
if (s.startsWith('#')) s = s.slice(1);
|
|
202
|
+
|
|
203
|
+
let r, g, b, a = 1;
|
|
204
|
+
if (s.length === 3 && /^[0-9a-fA-F]{3}$/.test(s)) {
|
|
205
|
+
r = parseInt(s[0] + s[0], 16) / 255;
|
|
206
|
+
g = parseInt(s[1] + s[1], 16) / 255;
|
|
207
|
+
b = parseInt(s[2] + s[2], 16) / 255;
|
|
208
|
+
} else if (s.length === 4 && /^[0-9a-fA-F]{4}$/.test(s)) {
|
|
209
|
+
r = parseInt(s[0] + s[0], 16) / 255;
|
|
210
|
+
g = parseInt(s[1] + s[1], 16) / 255;
|
|
211
|
+
b = parseInt(s[2] + s[2], 16) / 255;
|
|
212
|
+
a = parseInt(s[3] + s[3], 16) / 255;
|
|
213
|
+
} else if (s.length === 6 && /^[0-9a-fA-F]{6}$/.test(s)) {
|
|
214
|
+
r = parseInt(s.slice(0, 2), 16) / 255;
|
|
215
|
+
g = parseInt(s.slice(2, 4), 16) / 255;
|
|
216
|
+
b = parseInt(s.slice(4, 6), 16) / 255;
|
|
217
|
+
} else if (s.length === 8 && /^[0-9a-fA-F]{8}$/.test(s)) {
|
|
218
|
+
r = parseInt(s.slice(0, 2), 16) / 255;
|
|
219
|
+
g = parseInt(s.slice(2, 4), 16) / 255;
|
|
220
|
+
b = parseInt(s.slice(4, 6), 16) / 255;
|
|
221
|
+
a = parseInt(s.slice(6, 8), 16) / 255;
|
|
222
|
+
} else {
|
|
223
|
+
throw new Error(`fromHex: invalid hex "${hex}"`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const oklch = linearSrgbToOklch(
|
|
227
|
+
srgbInverseGamma(r),
|
|
228
|
+
srgbInverseGamma(g),
|
|
229
|
+
srgbInverseGamma(b),
|
|
230
|
+
);
|
|
231
|
+
oklch.a = a;
|
|
232
|
+
return oklch;
|
|
233
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS gradient emitters — linear, radial, conic.
|
|
3
|
+
*
|
|
4
|
+
* Why these exist (vs lite-gradient's built-in toCssLinear): the
|
|
5
|
+
* upstream emitter resamples at N evenly spaced positions, discarding
|
|
6
|
+
* the authored stop positions. These emit the actual stops at their
|
|
7
|
+
* authored positions and optionally tag the gradient with the
|
|
8
|
+
* `in oklch` interpolation hint so the browser interpolates in OKLCH
|
|
9
|
+
* (matching the canvas preview perceptually instead of through sRGB).
|
|
10
|
+
*
|
|
11
|
+
* Conic isn't in lite-gradient at all — this module is its only home.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { toCssOklch } from '@zakkster/lite-color';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} gradient { stops: [{l,c,h,pos}, ...] }
|
|
18
|
+
* @param {object} [opts]
|
|
19
|
+
* @param {number} [opts.angle=90] 90 = left→right.
|
|
20
|
+
* @param {boolean} [opts.oklchInterp=true] Emit `in oklch` hint.
|
|
21
|
+
*/
|
|
22
|
+
export function formatCssLinear(gradient, opts = {}) {
|
|
23
|
+
const { angle = 90, oklchInterp = true } = opts;
|
|
24
|
+
const scratch = { l: 0, c: 0, h: 0, a: 1 };
|
|
25
|
+
const parts = [];
|
|
26
|
+
for (let i = 0; i < gradient.stops.length; i++) {
|
|
27
|
+
const s = gradient.stops[i];
|
|
28
|
+
scratch.l = s.l; scratch.c = s.c; scratch.h = s.h;
|
|
29
|
+
scratch.a = s.a === undefined ? 1 : s.a;
|
|
30
|
+
parts.push(`${toCssOklch(scratch)} ${(s.pos * 100).toFixed(2)}%`);
|
|
31
|
+
}
|
|
32
|
+
const prefix = oklchInterp ? `${angle}deg in oklch` : `${angle}deg`;
|
|
33
|
+
return `linear-gradient(${prefix}, ${parts.join(', ')})`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} gradient
|
|
38
|
+
* @param {object} [opts]
|
|
39
|
+
* @param {string} [opts.shape='circle'] 'circle' | 'ellipse'
|
|
40
|
+
* @param {string} [opts.size='farthest-corner']
|
|
41
|
+
* @param {string} [opts.position='center']
|
|
42
|
+
* @param {boolean} [opts.oklchInterp=true]
|
|
43
|
+
*/
|
|
44
|
+
export function formatCssRadial(gradient, opts = {}) {
|
|
45
|
+
const {
|
|
46
|
+
shape = 'circle',
|
|
47
|
+
size = 'farthest-corner',
|
|
48
|
+
position = 'center',
|
|
49
|
+
oklchInterp = true,
|
|
50
|
+
} = opts;
|
|
51
|
+
const scratch = { l: 0, c: 0, h: 0, a: 1 };
|
|
52
|
+
const parts = [];
|
|
53
|
+
for (let i = 0; i < gradient.stops.length; i++) {
|
|
54
|
+
const s = gradient.stops[i];
|
|
55
|
+
scratch.l = s.l; scratch.c = s.c; scratch.h = s.h;
|
|
56
|
+
scratch.a = s.a === undefined ? 1 : s.a;
|
|
57
|
+
parts.push(`${toCssOklch(scratch)} ${(s.pos * 100).toFixed(2)}%`);
|
|
58
|
+
}
|
|
59
|
+
const geometry = `${shape} ${size} at ${position}`;
|
|
60
|
+
const prefix = oklchInterp ? `${geometry} in oklch` : geometry;
|
|
61
|
+
return `radial-gradient(${prefix}, ${parts.join(', ')})`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stops are mapped from gradient's [0,1] range to [0deg, 360deg].
|
|
66
|
+
*
|
|
67
|
+
* @param {object} gradient
|
|
68
|
+
* @param {object} [opts]
|
|
69
|
+
* @param {number} [opts.from=0] Starting angle in degrees.
|
|
70
|
+
* @param {string} [opts.position='center']
|
|
71
|
+
* @param {boolean} [opts.oklchInterp=true]
|
|
72
|
+
*/
|
|
73
|
+
export function formatCssConic(gradient, opts = {}) {
|
|
74
|
+
const { from = 0, position = 'center', oklchInterp = true } = opts;
|
|
75
|
+
const scratch = { l: 0, c: 0, h: 0, a: 1 };
|
|
76
|
+
const parts = [];
|
|
77
|
+
for (let i = 0; i < gradient.stops.length; i++) {
|
|
78
|
+
const s = gradient.stops[i];
|
|
79
|
+
scratch.l = s.l; scratch.c = s.c; scratch.h = s.h;
|
|
80
|
+
scratch.a = s.a === undefined ? 1 : s.a;
|
|
81
|
+
parts.push(`${toCssOklch(scratch)} ${(s.pos * 360).toFixed(2)}deg`);
|
|
82
|
+
}
|
|
83
|
+
const geometry = `from ${from}deg at ${position}`;
|
|
84
|
+
const prefix = oklchInterp ? `${geometry} in oklch` : geometry;
|
|
85
|
+
return `conic-gradient(${prefix}, ${parts.join(', ')})`;
|
|
86
|
+
}
|