1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
|
// colormath.js — pure color-math core for theme-studio.
//
// One source of truth: node imports this module (tests); generate.py inlines its
// body into the page (stripping the trailing export block) so the browser runs
// the same code. No DOM, no side effects.
//
// Algorithms: OKLab/OKLCH from Bjorn Ottosson (2020,
// https://bottosson.github.io/posts/oklab/); APCA from APCA-W3 0.1.9
// (https://github.com/Myndex/apca-w3); deltaE is OKLab Euclidean distance.
function hex2rgb(h) {
return [parseInt(h.substr(1, 2), 16), parseInt(h.substr(3, 2), 16), parseInt(h.substr(5, 2), 16)];
}
// sRGB transfer (0..1 channel <-> linear-light).
function lin(c) { return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function delin(c) { return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; }
function clamp01(c) { return c < 0 ? 0 : c > 1 ? 1 : c; }
function srgb2oklab(hex) {
const [R, G, B] = hex2rgb(hex);
const r = lin(R / 255), g = lin(G / 255), b = lin(B / 255);
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
const l_ = Math.cbrt(l), m_ = Math.cbrt(m), s_ = Math.cbrt(s);
return {
L: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
};
}
function oklab2oklch(lab) {
let H = Math.atan2(lab.b, lab.a) * 180 / Math.PI;
if (H < 0) H += 360;
return { L: lab.L, C: Math.hypot(lab.a, lab.b), H };
}
function oklch2oklab(L, C, H) {
const hr = H * Math.PI / 180;
return { L, a: C * Math.cos(hr), b: C * Math.sin(hr) };
}
// OKLab -> linear sRGB (may fall outside [0,1] when out of gamut).
function oklab2lrgb(L, a, b) {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const l = l_ * l_ * l_, m = m_ * m_ * m_, s = s_ * s_ * s_;
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
];
}
function inGamut(lrgb) {
const e = 1e-4;
return lrgb.every(c => c >= -e && c <= 1 + e);
}
function lrgb2hex(lrgb) {
return '#' + lrgb.map(c => {
const v = Math.round(clamp01(delin(clamp01(c))) * 255);
return v.toString(16).padStart(2, '0');
}).join('');
}
// OKLCH -> in-gamut sRGB hex. When the requested chroma is unreachable, reduce C
// by binary search holding L and H fixed; report whether clamping happened.
function oklch2hex(L, C, H) {
const lab0 = oklch2oklab(L, C, H);
const lrgb0 = oklab2lrgb(lab0.L, lab0.a, lab0.b);
if (inGamut(lrgb0)) return { hex: lrgb2hex(lrgb0), clamped: false };
let lo = 0, hi = C;
for (let i = 0; i < 24; i++) {
const mid = (lo + hi) / 2;
const lab = oklch2oklab(L, mid, H);
if (inGamut(oklab2lrgb(lab.L, lab.a, lab.b))) lo = mid; else hi = mid;
}
const lab = oklch2oklab(L, lo, H);
return { hex: lrgb2hex(oklab2lrgb(lab.L, lab.a, lab.b)), clamped: true };
}
// APCA-W3 0.1.9. Returns signed Lc: positive for dark-text-on-light, negative
// for light-text-on-dark. Constants transcribed verbatim from the pinned source.
function apcaY(hex) {
const [R, G, B] = hex2rgb(hex);
return 0.2126729 * Math.pow(R / 255, 2.4)
+ 0.7151522 * Math.pow(G / 255, 2.4)
+ 0.0721750 * Math.pow(B / 255, 2.4);
}
function apca(textHex, bgHex) {
const blkThrs = 0.022, blkClmp = 1.414, deltaYmin = 0.0005;
const normBG = 0.56, normTXT = 0.57, revTXT = 0.62, revBG = 0.65;
const scaleBoW = 1.14, scaleWoB = 1.14, loBoWoffset = 0.027, loWoBoffset = 0.027, loClip = 0.1;
let Ytxt = apcaY(textHex), Ybg = apcaY(bgHex);
Ytxt = Ytxt > blkThrs ? Ytxt : Ytxt + Math.pow(blkThrs - Ytxt, blkClmp);
Ybg = Ybg > blkThrs ? Ybg : Ybg + Math.pow(blkThrs - Ybg, blkClmp);
if (Math.abs(Ybg - Ytxt) < deltaYmin) return 0;
let out;
if (Ybg > Ytxt) {
const sapc = (Math.pow(Ybg, normBG) - Math.pow(Ytxt, normTXT)) * scaleBoW;
out = sapc < loClip ? 0 : sapc - loBoWoffset;
} else {
const sapc = (Math.pow(Ybg, revBG) - Math.pow(Ytxt, revTXT)) * scaleWoB;
out = sapc > -loClip ? 0 : sapc + loWoBoffset;
}
return out * 100;
}
// deltaE-OK: Euclidean distance in OKLab.
function deltaE(aHex, bHex) {
const x = srgb2oklab(aHex), y = srgb2oklab(bHex);
return Math.hypot(x.L - y.L, x.a - y.a, x.b - y.b);
}
// --- WCAG 2.x relative luminance + contrast (migrated from the page inline) ---
// rl reuses the canonical lin() above. On 8-bit channels lin's 0.04045 cutoff is
// byte-identical to the WCAG 0.03928 piecewise the inline copy used — no channel
// value falls between the two thresholds (10/255 = 0.0392, 11/255 = 0.0431) — so
// every #rrggbb contrast value is preserved exactly.
function rl(hex) {
const [R, G, B] = hex2rgb(hex);
return 0.2126 * lin(R / 255) + 0.7152 * lin(G / 255) + 0.0722 * lin(B / 255);
}
function contrast(aHex, bHex) {
const L1 = rl(aHex), L2 = rl(bHex), hi = Math.max(L1, L2), lo = Math.min(L1, L2);
return (hi + 0.05) / (lo + 0.05);
}
function rating(r) { return r >= 7 ? 'AAA' : r >= 4.5 ? 'AA' : 'FAIL'; }
// --- HSV <-> sRGB for the color picker (migrated from the page inline) ---
function hsv2rgb(h, s, v) {
h = (h % 360 + 360) % 360 / 360;
const i = Math.floor(h * 6), f = h * 6 - i, p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
let r, g, b;
switch (((i % 6) + 6) % 6) {
case 0: [r, g, b] = [v, t, p]; break;
case 1: [r, g, b] = [q, v, p]; break;
case 2: [r, g, b] = [p, v, t]; break;
case 3: [r, g, b] = [p, q, v]; break;
case 4: [r, g, b] = [t, p, v]; break;
default: [r, g, b] = [v, p, q];
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
function rgb2hsv(r, g, b) {
r /= 255; g /= 255; b /= 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
let h = 0;
if (d) {
if (mx === r) h = ((g - b) / d + 6) % 6;
else if (mx === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
}
return [h, mx ? d / mx : 0, mx];
}
function rgb2hex(r, g, b) {
return '#' + [r, g, b].map(x => Math.max(0, Math.min(255, x)).toString(16).padStart(2, '0')).join('');
}
// One Chroma×Lightness plane cell at a fixed hue: the sRGB color if the (L,C,H)
// is reachable, else flagged out of gamut. Forward-only (one conversion + a
// range check) — the binary-search clamp is reserved for committing a color.
function planeCell(L, C, H) {
const lab = oklch2oklab(L, C, H), lrgb = oklab2lrgb(lab.L, lab.a, lab.b);
return inGamut(lrgb) ? { inGamut: true, hex: lrgb2hex(lrgb) } : { inGamut: false, hex: null };
}
// Pairwise palette analysis. palette is [[hex, name], ...]. Returns the pairs
// closer than threshold (OKLab ΔE), closest-first and capped, the overflow count
// beyond the cap, and each color's nearest-neighbor distance for its chip title.
function paletteWarnings(palette, threshold = 0.02, cap = 5) {
const n = palette.length, nearest = new Array(n).fill(Infinity), pairs = [];
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
const d = deltaE(palette[i][0], palette[j][0]);
if (d < nearest[i]) nearest[i] = d;
if (d < nearest[j]) nearest[j] = d;
if (d < threshold) pairs.push({ i, j, aName: palette[i][1], bName: palette[j][1], dE: d });
}
pairs.sort((a, b) => a.dE - b.dE);
return { warnings: pairs.slice(0, cap), overflow: Math.max(0, pairs.length - cap), nearest };
}
export { srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE, hex2rgb, lin, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex, oklab2lrgb, inGamut, lrgb2hex, planeCell, paletteWarnings };
|