@stnd/lab 1.4.1 → 1.5.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/color.js ADDED
@@ -0,0 +1,365 @@
1
+ /**
2
+ * @stnd/lab — OKLCH & CSS Color Utilities
3
+ *
4
+ * Perceptual OKLCH <-> sRGB math (Björn Ottosson) and DOM color resolution
5
+ * so the Design Lab color picker always reflects the true color of the page
6
+ * and edits natively in OKLCH or Hex.
7
+ */
8
+
9
+ /**
10
+ * Clamp a number to [min, max].
11
+ * @param {number} v
12
+ * @param {number} min
13
+ * @param {number} max
14
+ * @returns {number}
15
+ */
16
+ export function clamp(v, min, max) {
17
+ return Math.max(min, Math.min(max, v));
18
+ }
19
+
20
+ /**
21
+ * Convert an sRGB channel (0..1) to linear-light value.
22
+ * @param {number} v
23
+ * @returns {number}
24
+ */
25
+ export function srgbToLinear(v) {
26
+ v = Number(v) || 0;
27
+ if (v <= 0.04045) return v / 12.92;
28
+ return Math.pow((v + 0.055) / 1.055, 2.4);
29
+ }
30
+
31
+ /**
32
+ * Convert a linear-light channel to sRGB (0..1).
33
+ * @param {number} v
34
+ * @returns {number}
35
+ */
36
+ export function linearToSrgb(v) {
37
+ v = Number(v) || 0;
38
+ if (v <= 0.0031308) return v * 12.92;
39
+ return 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
40
+ }
41
+
42
+ /**
43
+ * Convert sRGB (0..255) to OKLCH { L: 0..1, C: 0..0.4, h: 0..360 }.
44
+ * @param {number} r
45
+ * @param {number} g
46
+ * @param {number} b
47
+ * @returns {{ L: number, C: number, h: number }}
48
+ */
49
+ export function rgbToOklch(r, g, b) {
50
+ const R = srgbToLinear(clamp(r / 255, 0, 1));
51
+ const G = srgbToLinear(clamp(g / 255, 0, 1));
52
+ const B = srgbToLinear(clamp(b / 255, 0, 1));
53
+
54
+ const l_ = 0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B;
55
+ const m_ = 0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B;
56
+ const s_ = 0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B;
57
+
58
+ const l = Math.cbrt(l_);
59
+ const m = Math.cbrt(m_);
60
+ const s = Math.cbrt(s_);
61
+
62
+ let L = clamp(0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 0, 1);
63
+ if (Math.abs(L - 1) < 1e-5) L = 1;
64
+ if (L < 1e-5) L = 0;
65
+
66
+ const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
67
+ const b_ = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
68
+
69
+ let C = Math.sqrt(a * a + b_ * b_);
70
+ if (C < 1e-4 || (r === g && g === b)) {
71
+ C = 0;
72
+ }
73
+ let h = C === 0 ? 0 : Math.atan2(b_, a) * (180 / Math.PI);
74
+ if (h < 0) h += 360;
75
+
76
+ return { L, C, h };
77
+ }
78
+
79
+ /**
80
+ * Convert OKLCH { L: 0..1, C: >=0, h: 0..360 } to sRGB { r, g, b, inGamut }.
81
+ * @param {number} L
82
+ * @param {number} C
83
+ * @param {number} h
84
+ * @returns {{ r: number, g: number, b: number, inGamut: boolean }}
85
+ */
86
+ export function oklchToRgb(L, C, h) {
87
+ const rad = ((Number(h) || 0) * Math.PI) / 180;
88
+ const a = (Number(C) || 0) * Math.cos(rad);
89
+ const b = (Number(C) || 0) * Math.sin(rad);
90
+
91
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
92
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
93
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b;
94
+
95
+ const l = l_ * l_ * l_;
96
+ const m = m_ * m_ * m_;
97
+ const s = s_ * s_ * s_;
98
+
99
+ const linR = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
100
+ const linG = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
101
+ const linB = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
102
+
103
+ const eps = 0.001;
104
+ const inGamut =
105
+ linR >= -eps &&
106
+ linR <= 1 + eps &&
107
+ linG >= -eps &&
108
+ linG <= 1 + eps &&
109
+ linB >= -eps &&
110
+ linB <= 1 + eps;
111
+
112
+ const R = linearToSrgb(clamp(linR, 0, 1));
113
+ const G = linearToSrgb(clamp(linG, 0, 1));
114
+ const B = linearToSrgb(clamp(linB, 0, 1));
115
+
116
+ return {
117
+ r: clamp(Math.round(R * 255), 0, 255),
118
+ g: clamp(Math.round(G * 255), 0, 255),
119
+ b: clamp(Math.round(B * 255), 0, 255),
120
+ inGamut,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Format an RGB object as #rrggbb.
126
+ * @param {{ r: number, g: number, b: number }} rgb
127
+ * @returns {string}
128
+ */
129
+ export function rgbToHex({ r, g, b }) {
130
+ const hr = clamp(Math.round(r), 0, 255).toString(16).padStart(2, "0");
131
+ const hg = clamp(Math.round(g), 0, 255).toString(16).padStart(2, "0");
132
+ const hb = clamp(Math.round(b), 0, 255).toString(16).padStart(2, "0");
133
+ return `#${hr}${hg}${hb}`;
134
+ }
135
+
136
+ /**
137
+ * Format OKLCH components into a clean CSS `oklch(L C H)` string.
138
+ * @param {number} L
139
+ * @param {number} C
140
+ * @param {number} h
141
+ * @returns {string}
142
+ */
143
+ export function formatOklch(L, C, h) {
144
+ const lStr = Number(clamp(L, 0, 1).toFixed(4));
145
+ const cStr = Number(clamp(C, 0, 0.4).toFixed(4));
146
+ const hStr = Number((((h % 360) + 360) % 360).toFixed(1));
147
+ return `oklch(${lStr} ${cStr} ${hStr})`;
148
+ }
149
+
150
+ /**
151
+ * Parse a direct `#hex` string (`#rgb`, `#rrggbb`, `#rrggbbaa`) into `{ r, g, b }` or `null`.
152
+ * @param {string} str
153
+ * @returns {{ r: number, g: number, b: number } | null}
154
+ */
155
+ export function parseHexString(str) {
156
+ if (!str || typeof str !== "string") return null;
157
+ let hex = str.trim();
158
+ if (!hex.startsWith("#")) return null;
159
+ hex = hex.slice(1);
160
+ if (hex.length === 3 || hex.length === 4) {
161
+ hex = hex
162
+ .slice(0, 3)
163
+ .split("")
164
+ .map((c) => c + c)
165
+ .join("");
166
+ } else if (hex.length === 8) {
167
+ hex = hex.slice(0, 6);
168
+ }
169
+ if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null;
170
+ const n = parseInt(hex, 16);
171
+ return {
172
+ r: (n >> 16) & 255,
173
+ g: (n >> 8) & 255,
174
+ b: n & 255,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Parse a literal `oklch(L C H)` string (where L, C, H are numbers/percentages, not `from ...`)
180
+ * into `{ L, C, h }` or `null`.
181
+ * @param {string} str
182
+ * @returns {{ L: number, C: number, h: number } | null}
183
+ */
184
+ export function parseOklchString(str) {
185
+ if (!str || typeof str !== "string") return null;
186
+ const m = str
187
+ .trim()
188
+ .match(
189
+ /^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)(%?)\s+([\d.]+|none)(?:deg|rad|turn)?(?:\s*\/\s*[\d.]+%?)?\s*\)$/i,
190
+ );
191
+ if (!m) return null;
192
+ let L = parseFloat(m[1]);
193
+ if (m[2] === "%") L /= 100;
194
+ let C = parseFloat(m[3]);
195
+ if (m[4] === "%") C = (C / 100) * 0.4;
196
+ let h = m[5].toLowerCase() === "none" ? 0 : parseFloat(m[5]);
197
+ if (Number.isNaN(L) || Number.isNaN(C) || Number.isNaN(h)) return null;
198
+ return {
199
+ L: clamp(L, 0, 1),
200
+ C: clamp(C, 0, 0.4),
201
+ h: Number((((h % 360) + 360) % 360).toFixed(4)),
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Parse a literal `rgb(...)` or `rgba(...)` string into `{ r, g, b }` or `null`.
207
+ * @param {string} str
208
+ * @returns {{ r: number, g: number, b: number } | null}
209
+ */
210
+ export function parseRgbString(str) {
211
+ if (!str || typeof str !== "string") return null;
212
+ const m = str
213
+ .trim()
214
+ .match(
215
+ /^rgba?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)\s*[,\s]\s*([\d.]+)(?:\s*[,/]\s*[\d.]+%?)?\s*\)$/i,
216
+ );
217
+ if (!m) return null;
218
+ return {
219
+ r: clamp(Math.round(parseFloat(m[1])), 0, 255),
220
+ g: clamp(Math.round(parseFloat(m[2])), 0, 255),
221
+ b: clamp(Math.round(parseFloat(m[3])), 0, 255),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Parse any CSS color string (including `oklch(...)`, `#hex`, `rgb(...)`, named colors,
227
+ * or relative `oklch(from ...)` in the DOM) into `{ L, C, h, hex, rgb, css }`.
228
+ *
229
+ * @param {string} css
230
+ * @param {HTMLElement} [contextEl]
231
+ * @returns {{ L: number, C: number, h: number, hex: string, rgb: { r: number, g: number, b: number }, css: string }}
232
+ */
233
+ export function parseCssColor(css, contextEl) {
234
+ const raw = (css || "").trim();
235
+ if (!raw || raw.toLowerCase() === "black") {
236
+ return {
237
+ L: 0,
238
+ C: 0,
239
+ h: 0,
240
+ hex: "#000000",
241
+ rgb: { r: 0, g: 0, b: 0 },
242
+ css: raw || "#000000",
243
+ };
244
+ }
245
+ if (raw.toLowerCase() === "white") {
246
+ return {
247
+ L: 1,
248
+ C: 0,
249
+ h: 0,
250
+ hex: "#ffffff",
251
+ rgb: { r: 255, g: 255, b: 255 },
252
+ css: "#ffffff",
253
+ };
254
+ }
255
+
256
+ // 1. Direct oklch(L C H) string — preserves exact perceptual L, C, H
257
+ const directOklch = parseOklchString(raw);
258
+ if (directOklch) {
259
+ const rgb = oklchToRgb(directOklch.L, directOklch.C, directOklch.h);
260
+ const hex = rgbToHex(rgb);
261
+ return {
262
+ ...directOklch,
263
+ rgb,
264
+ hex,
265
+ css: formatOklch(directOklch.L, directOklch.C, directOklch.h),
266
+ };
267
+ }
268
+
269
+ // 2. Direct #hex string
270
+ const directHex = parseHexString(raw);
271
+ if (directHex) {
272
+ const oklch = rgbToOklch(directHex.r, directHex.g, directHex.b);
273
+ const hex = rgbToHex(directHex);
274
+ return {
275
+ ...oklch,
276
+ rgb: directHex,
277
+ hex,
278
+ css: hex,
279
+ };
280
+ }
281
+
282
+ // 3. Direct rgb(...) string
283
+ const directRgb = parseRgbString(raw);
284
+ if (directRgb) {
285
+ const oklch = rgbToOklch(directRgb.r, directRgb.g, directRgb.b);
286
+ const hex = rgbToHex(directRgb);
287
+ return {
288
+ ...oklch,
289
+ rgb: directRgb,
290
+ hex,
291
+ css: formatOklch(oklch.L, oklch.C, oklch.h),
292
+ };
293
+ }
294
+
295
+ // 4. Resolve via DOM & Canvas for relative colors (`oklch(from ...)`), `var(...)`, `light-dark(...)`, or named colors
296
+ if (typeof document !== "undefined" && document.body) {
297
+ try {
298
+ const parent = contextEl || document.body;
299
+ const probe = document.createElement("span");
300
+ probe.style.display = "none";
301
+ probe.style.color = raw;
302
+ parent.appendChild(probe);
303
+ const resolved = getComputedStyle(probe).color.trim();
304
+ probe.remove();
305
+
306
+ const resolvedOklch = parseOklchString(resolved);
307
+ if (resolvedOklch) {
308
+ const rgb = oklchToRgb(
309
+ resolvedOklch.L,
310
+ resolvedOklch.C,
311
+ resolvedOklch.h,
312
+ );
313
+ const hex = rgbToHex(rgb);
314
+ return {
315
+ ...resolvedOklch,
316
+ rgb,
317
+ hex,
318
+ css: formatOklch(resolvedOklch.L, resolvedOklch.C, resolvedOklch.h),
319
+ };
320
+ }
321
+
322
+ const resolvedRgb = parseRgbString(resolved);
323
+ if (resolvedRgb) {
324
+ const oklch = rgbToOklch(resolvedRgb.r, resolvedRgb.g, resolvedRgb.b);
325
+ const hex = rgbToHex(resolvedRgb);
326
+ return {
327
+ ...oklch,
328
+ rgb: resolvedRgb,
329
+ hex,
330
+ css: formatOklch(oklch.L, oklch.C, oklch.h),
331
+ };
332
+ }
333
+
334
+ // Fallback: 1x1 canvas pixel readback for color(srgb ...), oklab(...), etc.
335
+ const canvas = document.createElement("canvas");
336
+ canvas.width = 1;
337
+ canvas.height = 1;
338
+ const ctx = canvas.getContext("2d");
339
+ if (ctx && resolved) {
340
+ ctx.fillStyle = resolved;
341
+ ctx.fillRect(0, 0, 1, 1);
342
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
343
+ const oklch = rgbToOklch(r, g, b);
344
+ const hex = rgbToHex({ r, g, b });
345
+ return {
346
+ ...oklch,
347
+ rgb: { r, g, b },
348
+ hex,
349
+ css: formatOklch(oklch.L, oklch.C, oklch.h),
350
+ };
351
+ }
352
+ } catch {
353
+ /* noop */
354
+ }
355
+ }
356
+
357
+ return {
358
+ L: 0,
359
+ C: 0,
360
+ h: 0,
361
+ hex: "#000000",
362
+ rgb: { r: 0, g: 0, b: 0 },
363
+ css: "#000000",
364
+ };
365
+ }
package/color.test.js ADDED
@@ -0,0 +1,82 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ clamp,
5
+ srgbToLinear,
6
+ linearToSrgb,
7
+ rgbToOklch,
8
+ oklchToRgb,
9
+ rgbToHex,
10
+ formatOklch,
11
+ parseHexString,
12
+ parseOklchString,
13
+ parseRgbString,
14
+ parseCssColor,
15
+ } from "./color.js";
16
+
17
+ test("clamp constraints", () => {
18
+ assert.equal(clamp(5, 0, 10), 5);
19
+ assert.equal(clamp(-2, 0, 10), 0);
20
+ assert.equal(clamp(15, 0, 10), 10);
21
+ });
22
+
23
+ test("linear and sRGB roundtrip", () => {
24
+ for (const v of [0, 0.25, 0.5, 0.75, 1]) {
25
+ const lin = srgbToLinear(v);
26
+ const srgb = linearToSrgb(lin);
27
+ assert.ok(Math.abs(srgb - v) < 1e-4, `Expected ${v}, got ${srgb}`);
28
+ }
29
+ });
30
+
31
+ test("rgbToOklch and oklchToRgb roundtrip", () => {
32
+ // Test white
33
+ const whiteOklch = rgbToOklch(255, 255, 255);
34
+ assert.ok(Math.abs(whiteOklch.L - 1) < 1e-3);
35
+ assert.ok(whiteOklch.C < 1e-3);
36
+
37
+ // Test black
38
+ const blackOklch = rgbToOklch(0, 0, 0);
39
+ assert.ok(Math.abs(blackOklch.L) < 1e-3);
40
+ assert.ok(blackOklch.C < 1e-3);
41
+
42
+ // Test hex roundtrip for a typical color (#262626)
43
+ const oklch = rgbToOklch(0x26, 0x26, 0x26);
44
+ const back = oklchToRgb(oklch.L, oklch.C, oklch.h);
45
+ assert.equal(rgbToHex(back), "#262626");
46
+ });
47
+
48
+ test("parseHexString", () => {
49
+ assert.deepEqual(parseHexString("#ffffff"), { r: 255, g: 255, b: 255 });
50
+ assert.deepEqual(parseHexString("#000"), { r: 0, g: 0, b: 0 });
51
+ assert.deepEqual(parseHexString("#262626"), { r: 38, g: 38, b: 38 });
52
+ assert.equal(parseHexString("invalid"), null);
53
+ });
54
+
55
+ test("parseOklchString", () => {
56
+ const parsed = parseOklchString("oklch(0.7721 0.0228 96.4)");
57
+ assert.ok(parsed);
58
+ assert.equal(parsed.L, 0.7721);
59
+ assert.equal(parsed.C, 0.0228);
60
+ assert.equal(parsed.h, 96.4);
61
+
62
+ const parsedPct = parseOklchString("oklch(75% 0.02 180deg)");
63
+ assert.ok(parsedPct);
64
+ assert.equal(parsedPct.L, 0.75);
65
+ });
66
+
67
+ test("parseCssColor", () => {
68
+ const oklchColor = parseCssColor("oklch(0.7721 0.0228 96.4)");
69
+ assert.ok(oklchColor);
70
+ assert.equal(oklchColor.L, 0.7721);
71
+ assert.equal(oklchColor.C, 0.0228);
72
+ assert.equal(oklchColor.h, 96.4);
73
+ assert.ok(oklchColor.hex.startsWith("#"));
74
+
75
+ const hexColor = parseCssColor("#ffffff");
76
+ assert.equal(hexColor.L, 1);
77
+ assert.equal(hexColor.hex, "#ffffff");
78
+
79
+ const blackColor = parseCssColor("#000000");
80
+ assert.equal(blackColor.L, 0);
81
+ assert.equal(blackColor.hex, "#000000");
82
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stnd/lab",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "svelte": "./Lab.svelte",
6
6
  "author": "Francis Fontaine",
@@ -16,7 +16,7 @@
16
16
  "dependencies": {
17
17
  "bezier-easing": "^3.0.1",
18
18
  "svelte": "5.57.2",
19
- "@stnd/ui": "0.14.0"
19
+ "@stnd/ui": "0.16.0"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "astro": ">=5.0.0"
@@ -26,5 +26,8 @@
26
26
  },
27
27
  "devDependencies": {
28
28
  "wrangler": "^4.148.0"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test"
29
32
  }
30
33
  }