everyhexcode 0.0.0-stage → 0.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.
@@ -0,0 +1,364 @@
1
+ // The one colour module: the server renders pages with it and the browser tools are bundled from it
2
+ // (scripts/build-client.mjs), so a value on a page and the same value in a tool always agree.
3
+ // Colour maths is culori (MIT); APCA is in shared/apca.js (APCA-W3 0.0.98G-4g constants).
4
+ import {
5
+ useMode, modeRgb, modeLrgb, modeHsl, modeHsv, modeHwb, modeLab, modeLch, modeOklab, modeOklch, modeP3, modeXyz65,
6
+ formatHex, toGamut, inGamut, clampRgb, differenceEuclidean, wcagContrast, wcagLuminance, interpolate, samples,
7
+ filterDeficiencyProt, filterDeficiencyDeuter, filterDeficiencyTrit, parse as cparse, converter,
8
+ fixupHueShorter, fixupHueLonger, fixupHueIncreasing, fixupHueDecreasing,
9
+ } from 'culori/fn';
10
+
11
+ useMode(modeRgb); useMode(modeLrgb); useMode(modeHsl); useMode(modeHsv); useMode(modeHwb); useMode(modeLab);
12
+ useMode(modeLch); useMode(modeOklab); useMode(modeOklch); useMode(modeP3); useMode(modeXyz65);
13
+
14
+ const okDist = differenceEuclidean('oklab');
15
+ const toSrgb = toGamut('rgb', 'oklch');
16
+ const toP3 = toGamut('p3', 'oklch');
17
+
18
+ import { parseColor } from './parse.js';
19
+
20
+ const HEX6 = /^[0-9a-f]{6}$/;
21
+
22
+ /** Any colour people type or paste → "ff5733" (no #), or null. The forgiving reader is shared/parse.js. */
23
+ export function normHex(input, as) {
24
+ if (input == null) return null;
25
+ const r = parseColor(input, as ? { as } : {});
26
+ return r.hex || null;
27
+ }
28
+ export { parseColor };
29
+
30
+ export const isHex = (s) => HEX6.test(s);
31
+ const col = (hex) => cparse('#' + hex);
32
+ const r1 = (n, d = 0) => { const f = 10 ** d; const v = Math.round(n * f) / f; return Object.is(v, -0) ? 0 : v; };
33
+ const hue = (h) => (h == null || Number.isNaN(h) ? 0 : r1(((h % 360) + 360) % 360, 1));
34
+
35
+ /** Every value a page or tool shows for one colour, as numbers and as copy-ready strings. */
36
+ export function info(hex) {
37
+ const c = col(hex);
38
+ const rgb = { r: Math.round(c.r * 255), g: Math.round(c.g * 255), b: Math.round(c.b * 255) };
39
+ const hsl = useConv('hsl')(c), hsv = useConv('hsv')(c), hwb = useConv('hwb')(c);
40
+ const lab = useConv('lab')(c), lch = useConv('lch')(c), oklab = useConv('oklab')(c), oklch = useConv('oklch')(c);
41
+ const p3 = useConv('p3')(c), xyz = useConv('xyz65')(c);
42
+ const k = 1 - Math.max(c.r, c.g, c.b);
43
+ const cmyk = k >= 1 ? { c: 0, m: 0, y: 0, k: 100 }
44
+ : { c: r1((1 - c.r - k) / (1 - k) * 100), m: r1((1 - c.g - k) / (1 - k) * 100), y: r1((1 - c.b - k) / (1 - k) * 100), k: r1(k * 100) };
45
+ const H = (x) => hue(x);
46
+ const L = r1(oklch.l * 100, 2), C = r1(oklch.c, 4), Hh = oklch.c < 0.0001 ? 0 : r1(H(oklch.h), 2);
47
+ const v = {
48
+ hex: '#' + hex,
49
+ hexUpper: '#' + hex.toUpperCase(),
50
+ rgb, hsl: { h: H(hsl.h), s: r1(hsl.s * 100, 1), l: r1(hsl.l * 100, 1) },
51
+ hsv: { h: H(hsv.h), s: r1(hsv.s * 100, 1), v: r1(hsv.v * 100, 1) },
52
+ hwb: { h: H(hwb.h), w: r1(hwb.w * 100, 1), b: r1(hwb.b * 100, 1) },
53
+ cmyk,
54
+ lab: { l: r1(lab.l, 2), a: r1(lab.a, 2), b: r1(lab.b, 2) },
55
+ lch: { l: r1(lch.l, 2), c: r1(lch.c, 2), h: H(lch.h) },
56
+ oklab: { l: r1(oklab.l, 4), a: r1(oklab.a, 4), b: r1(oklab.b, 4) },
57
+ oklch: { l: L, c: C, h: Hh },
58
+ p3: { r: r1(p3.r, 4), g: r1(p3.g, 4), b: r1(p3.b, 4) },
59
+ xyz: { x: r1(xyz.x * 100, 2), y: r1(xyz.y * 100, 2), z: r1(xyz.z * 100, 2) },
60
+ decimal: parseInt(hex, 16),
61
+ luminance: r1(wcagLuminance(c), 4),
62
+ };
63
+ v.strings = {
64
+ hex: v.hex,
65
+ rgb: `rgb(${rgb.r} ${rgb.g} ${rgb.b})`,
66
+ rgbLegacy: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
67
+ hsl: `hsl(${v.hsl.h} ${v.hsl.s}% ${v.hsl.l}%)`,
68
+ hsv: `hsv(${v.hsv.h}, ${v.hsv.s}%, ${v.hsv.v}%)`,
69
+ hwb: `hwb(${v.hwb.h} ${v.hwb.w}% ${v.hwb.b}%)`,
70
+ cmyk: `cmyk(${cmyk.c}%, ${cmyk.m}%, ${cmyk.y}%, ${cmyk.k}%)`,
71
+ lab: `lab(${v.lab.l}% ${v.lab.a} ${v.lab.b})`,
72
+ lch: `lch(${v.lch.l}% ${v.lch.c} ${v.lch.h})`,
73
+ oklab: `oklab(${r1(oklab.l * 100, 2)}% ${v.oklab.a} ${v.oklab.b})`,
74
+ oklch: `oklch(${L}% ${C} ${Hh})`,
75
+ p3: `color(display-p3 ${v.p3.r} ${v.p3.g} ${v.p3.b})`,
76
+ xyz: `color(xyz-d65 ${r1(xyz.x, 4)} ${r1(xyz.y, 4)} ${r1(xyz.z, 4)})`,
77
+ decimal: String(v.decimal),
78
+ android: `0xFF${hex.toUpperCase()}`,
79
+ swift: `Color(red: ${r1(c.r, 3)}, green: ${r1(c.g, 3)}, blue: ${r1(c.b, 3)})`,
80
+ uikit: `UIColor(red: ${r1(c.r, 3)}, green: ${r1(c.g, 3)}, blue: ${r1(c.b, 3)}, alpha: 1)`,
81
+ flutter: `Color(0xFF${hex.toUpperCase()})`,
82
+ };
83
+ return v;
84
+ }
85
+
86
+ const converters = {};
87
+ function useConv(mode) { return (converters[mode] ||= converter(mode)); }
88
+
89
+ /** WCAG 2.x contrast ratio, two decimals. */
90
+ // Rounded to two decimals, but never up onto a WCAG threshold: 4.499 shows as 4.49 and fails, as WebAIM's does
91
+ export function contrast(a, b) {
92
+ const x = wcagContrast(col(a), col(b)), r = r1(x, 2);
93
+ return [3, 4.5, 7].some((t) => x < t && r >= t) ? Math.floor(x * 100) / 100 : r;
94
+ }
95
+
96
+ /** WCAG 2.x grades for a ratio. */
97
+ export function wcagGrades(ratio) {
98
+ return { aaNormal: ratio >= 4.5, aaLarge: ratio >= 3, aaaNormal: ratio >= 7, aaaLarge: ratio >= 4.5, ui: ratio >= 3 };
99
+ }
100
+
101
+ // APCA lives in its own module (shared/apca.js) under its own licence terms; the CLI is built without it.
102
+ import { apca, apcaUse } from './apca.js';
103
+ export { apca, apcaUse };
104
+
105
+ /** Black or white, whichever has more WCAG 2 contrast on this colour (the better one is always at least 4.58:1). */
106
+ export function textOn(hex) {
107
+ return contrast('000000', hex) >= contrast('ffffff', hex) ? '000000' : 'ffffff';
108
+ }
109
+
110
+ function fromOklch(l, c, h) { return formatHex(toSrgb({ mode: 'oklch', l, c, h })).slice(1); }
111
+
112
+ export const SCALE_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
113
+ // Lightness and relative chroma of each step, taken from Tailwind v4's own red (OKLCH), so scales sit beside its palette.
114
+ const SCALE_L = [0.971, 0.936, 0.885, 0.808, 0.704, 0.637, 0.577, 0.505, 0.444, 0.396, 0.258];
115
+ const SCALE_C = [0.055, 0.135, 0.26, 0.48, 0.81, 1, 1.03, 0.9, 0.75, 0.6, 0.39];
116
+
117
+ /**
118
+ * A Tailwind-style 50–950 scale built in OKLCH around one colour: the hue is kept, lightness follows the steps and
119
+ * chroma tapers at both ends; the given colour replaces the step nearest to its own lightness, exactly.
120
+ */
121
+ export function scale(hex) {
122
+ const o = useConv('oklch')(col(hex));
123
+ const h = o.h || 0;
124
+ let nearest = 0, best = 9;
125
+ SCALE_L.forEach((l, i) => { const d = Math.abs(l - o.l); if (d < best) { best = d; nearest = i; } });
126
+ // the chroma the scale would have at 500; a pale or very dark colour does not imply a vivid middle, so it is capped
127
+ const mid = o.c < 0.015 ? 0 : Math.min(o.c / SCALE_C[nearest], Math.max(o.c * 1.6, 0.03));
128
+ return SCALE_STEPS.map((step, i) => ({
129
+ step,
130
+ hex: i === nearest ? hex : fromOklch(SCALE_L[i], o.c < 0.015 ? o.c : mid * SCALE_C[i], h),
131
+ base: i === nearest,
132
+ }));
133
+ }
134
+
135
+ /** Harmonies by turning the hue in OKLCH (lightness and chroma kept, mapped into sRGB). */
136
+ export function harmonies(hex) {
137
+ const o = useConv('oklch')(col(hex));
138
+ const turn = (d) => fromOklch(o.l, o.c, ((o.h || 0) + d + 360) % 360);
139
+ return {
140
+ complementary: [hex, turn(180)],
141
+ analogous: [turn(-30), hex, turn(30)],
142
+ triadic: [hex, turn(120), turn(240)],
143
+ splitComplementary: [hex, turn(150), turn(210)],
144
+ tetradic: [hex, turn(90), turn(180), turn(270)],
145
+ monochromatic: [0.3, 0.45, 0.6, 0.75, 0.9].map((l) => fromOklch(l, Math.min(o.c, 0.2), o.h || 0)),
146
+ };
147
+ }
148
+
149
+ /** Tints (towards white), shades (towards black) and tones (towards grey), mixed in OKLab, 10 steps each. */
150
+ export function mixes(hex) {
151
+ const step = (to) => samples(11).slice(1, 11).map((t) => formatHex(interpolate([ '#' + hex, to ], 'oklab')(t)).slice(1));
152
+ return { tints: step('#ffffff'), shades: step('#000000'), tones: step('#808080') };
153
+ }
154
+
155
+ /** n colours from a to b (both included), interpolated in a space (oklch keeps gradients from going grey). */
156
+ export function blend(stops, n, space = 'oklch') {
157
+ const f = interpolate(stops.map((h) => '#' + h), space);
158
+ return samples(n).map((t) => formatHex(toSrgb(f(t))).slice(1));
159
+ }
160
+
161
+ /** The spaces CSS can mix a gradient in (`linear-gradient(in <space>, …)`), as culori modes. `legacy` is a gradient with
162
+ * no `in` and hex stops, which browsers mix in sRGB; `modern` is one with no `in` and oklch() stops, mixed in Oklab. */
163
+ export const INTERP = [
164
+ { id: 'legacy', label: 'no in, hex colors', clause: '', mode: 'rgb' },
165
+ { id: 'srgb', label: 'in srgb', clause: 'in srgb', mode: 'rgb' },
166
+ { id: 'srgb-linear', label: 'in srgb-linear', clause: 'in srgb-linear', mode: 'lrgb' },
167
+ { id: 'display-p3', label: 'in display-p3', clause: 'in display-p3', mode: 'p3' },
168
+ { id: 'xyz', label: 'in xyz', clause: 'in xyz', mode: 'xyz65' },
169
+ { id: 'lab', label: 'in lab', clause: 'in lab', mode: 'lab' },
170
+ { id: 'oklab', label: 'in oklab', clause: 'in oklab', mode: 'oklab' },
171
+ { id: 'modern', label: 'no in, oklch() colors', clause: '', mode: 'oklab', modern: true },
172
+ { id: 'oklch', label: 'in oklch', clause: 'in oklch', mode: 'oklch', hue: 'shorter' },
173
+ { id: 'oklch-longer', label: 'in oklch longer hue', clause: 'in oklch longer hue', mode: 'oklch', hue: 'longer' },
174
+ { id: 'oklch-increasing', label: 'in oklch increasing hue', clause: 'in oklch increasing hue', mode: 'oklch', hue: 'increasing' },
175
+ { id: 'oklch-decreasing', label: 'in oklch decreasing hue', clause: 'in oklch decreasing hue', mode: 'oklch', hue: 'decreasing' },
176
+ { id: 'lch', label: 'in lch', clause: 'in lch', mode: 'lch', hue: 'shorter' },
177
+ { id: 'hsl', label: 'in hsl', clause: 'in hsl', mode: 'hsl', hue: 'shorter' },
178
+ { id: 'hwb', label: 'in hwb', clause: 'in hwb', mode: 'hwb', hue: 'shorter' },
179
+ ];
180
+ const FIXUP = { shorter: fixupHueShorter, longer: fixupHueLonger, increasing: fixupHueIncreasing, decreasing: fixupHueDecreasing };
181
+
182
+ /** n colours along a gradient mixed the way CSS mixes it in one of INTERP's spaces (out-of-gamut ones mapped into sRGB). */
183
+ export function interpBlend(stops, n, id) {
184
+ const sp = INTERP.find((x) => x.id === id) || INTERP[0];
185
+ const f = interpolate(stops.map((h) => '#' + h), sp.mode, sp.hue ? { h: { fixup: FIXUP[sp.hue] } } : undefined);
186
+ return samples(n).map((t) => { const c = f(t); return formatHex(inSrgb(c) ? c : okDist(c, clampRgb(c)) < 0.002 ? clampRgb(c) : toSrgb(c)).slice(1); });
187
+ }
188
+ const inSrgb = inGamut('rgb');
189
+
190
+ /** Distance in OKLab (ΔE-OK ×100): under ~2 is hard to tell apart, under ~5 close. */
191
+ export function distance(a, b) { return r1(okDist(col(a), col(b)) * 100, 2); }
192
+
193
+ /** The `n` nearest of a list of {hex, …}, each with its distance. */
194
+ export function nearest(hex, list, n = 5) {
195
+ const c = col(hex);
196
+ return list.map((x) => ({ ...x, d: r1(okDist(c, col(x.hex)) * 100, 2) })).sort((a, b) => a.d - b.d).slice(0, n);
197
+ }
198
+
199
+ /** Where a colour sits: is it in sRGB (every hex is), and how much more colourful P3 could make the same hue. */
200
+ export function gamut(hex) {
201
+ const o = useConv('oklch')(col(hex));
202
+ const vivid = toP3({ mode: 'oklch', l: o.l, c: 0.4, h: o.h || 0 });
203
+ const vo = useConv('oklch')(vivid);
204
+ return { p3MaxChroma: r1(vo.c, 3), srgbChroma: r1(o.c, 3), p3Vivid: `oklch(${r1(o.l * 100, 2)}% ${r1(vo.c, 3)} ${r1(o.h || 0, 2)})` };
205
+ }
206
+
207
+ // OKLCH hue angles (pure red sits at 29, #FFA500 orange at 71, #FFD700 gold at 95); the old cut-offs at 20 and 70 called
208
+ // red an orange and orange a yellow (mba #fg-ehc-pages-quality-read: "Red is a mid-tone orange" on /colors/red)
209
+ const FAMILIES = [
210
+ ['red', 0, 40], ['orange', 40, 78], ['yellow', 78, 110], ['green', 110, 165], ['teal', 165, 200], ['blue', 200, 275],
211
+ ['purple', 275, 330], ['pink', 330, 355], ['red', 355, 361],
212
+ ];
213
+ /** The plain colour family a colour belongs to, for "shades of …" pages. */
214
+ export function family(hex) {
215
+ const o = useConv('oklch')(col(hex));
216
+ if (o.l > 0.96 && o.c < 0.03) return 'white';
217
+ if (o.l < 0.22 && o.c < 0.05) return 'black';
218
+ if (o.c < 0.035) return 'gray';
219
+ const h = o.h || 0;
220
+ if (h >= 20 && h < 90 && o.l < 0.62 && o.c < 0.16) return 'brown';
221
+ if ((h >= 330 || h < 20) && o.l > 0.72) return 'pink';
222
+ const f = FAMILIES.find(([, a, b]) => h >= a && h < b);
223
+ return f ? f[0] : 'red';
224
+ }
225
+ export const FAMILY_NAMES = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'brown', 'gray', 'black', 'white'];
226
+
227
+ /** A colour-vision filter applied the way the Machado (2009) matrices are meant to be: to linear light, not to the
228
+ * gamma-encoded values (applied to sRGB values, pure red under protanopia came out #271d00 instead of about #6d5f00). */
229
+ export function cvd(filter, severity = 1) {
230
+ const f = filter(severity);
231
+ return (c) => {
232
+ const l = useConv('lrgb')(c);
233
+ const o = f({ mode: 'rgb', r: l.r, g: l.g, b: l.b, alpha: c.alpha });
234
+ return useConv('rgb')({ mode: 'lrgb', r: Math.max(0, Math.min(1, o.r)), g: Math.max(0, Math.min(1, o.g)), b: Math.max(0, Math.min(1, o.b)) });
235
+ };
236
+ }
237
+
238
+ /** How the colour looks to people with each common colour-vision deficiency. */
239
+ export function simulate(hex) {
240
+ const c = col(hex);
241
+ const f = (fn) => formatHex(toSrgb(cvd(fn, 1)(c))).slice(1);
242
+ return { protanopia: f(filterDeficiencyProt), deuteranopia: f(filterDeficiencyDeuter), tritanopia: f(filterDeficiencyTrit),
243
+ // the grey of the same luminance, so contrast stays what it is
244
+ achromatopsia: (() => { const y = wcagLuminance(c); return formatHex(useConv('rgb')({ mode: 'lrgb', r: y, g: y, b: y })).slice(1); })() };
245
+ }
246
+
247
+ /**
248
+ * A light and a dark theme from one brand colour: roles a site uses, each checked for contrast against its background.
249
+ */
250
+ export function theme(hex) {
251
+ const o = useConv('oklch')(col(hex));
252
+ const h = o.h || 0, c = o.c;
253
+ const n = (l, cc) => fromOklch(l, Math.min(cc, c || cc), h);
254
+ const make = (dark) => {
255
+ const roles = dark ? {
256
+ background: n(0.17, 0.012), surface: n(0.215, 0.016), muted: n(0.28, 0.02), border: n(0.33, 0.022),
257
+ text: n(0.96, 0.006), 'text-muted': n(0.76, 0.015),
258
+ primary: fromOklch(Math.max(o.l, 0.68), c, h), accent: fromOklch(0.78, Math.min(c * 0.8, 0.14), (h + 40) % 360),
259
+ } : {
260
+ background: n(0.99, 0.004), surface: n(0.965, 0.008), muted: n(0.93, 0.012), border: n(0.88, 0.016),
261
+ text: n(0.2, 0.02), 'text-muted': n(0.47, 0.025),
262
+ primary: fromOklch(Math.min(o.l, 0.56), c, h), accent: fromOklch(0.55, Math.min(c * 0.8, 0.14), (h + 40) % 360),
263
+ };
264
+ roles['on-primary'] = textOn(roles.primary);
265
+ return roles;
266
+ };
267
+ return { light: make(false), dark: make(true) };
268
+ }
269
+
270
+ /** A random palette that holds together: hues spaced on a rule, lightness spread, chroma kept in a band. */
271
+ export function randomPalette(n = 5, rnd = Math.random, locked = []) {
272
+ const base = rnd() * 360;
273
+ const rules = [[0, 30, 60, 180, 210], [0, 120, 240, 60, 300], [0, 15, 30, 45, 200], [0, 150, 210, 30, 180], [0, 0, 0, 180, 180]];
274
+ const rule = rules[Math.floor(rnd() * rules.length)];
275
+ const cBand = 0.04 + rnd() * 0.14;
276
+ const ls = [0.25 + rnd() * 0.1, 0.45 + rnd() * 0.1, 0.62 + rnd() * 0.1, 0.78 + rnd() * 0.08, 0.92 + rnd() * 0.05].sort(() => rnd() - 0.5);
277
+ const out = [];
278
+ for (let i = 0; i < n; i++) {
279
+ if (locked[i]) { out.push(locked[i]); continue; }
280
+ const l = ls[i % ls.length], c = cBand * (0.6 + rnd() * 0.8) * (l > 0.9 ? 0.4 : 1);
281
+ out.push(fromOklch(l, c, (base + rule[i % rule.length] + (rnd() - 0.5) * 16 + 360) % 360));
282
+ }
283
+ return out;
284
+ }
285
+
286
+ /** Every pair in a palette with WCAG ratio and APCA Lc (text first, background second). */
287
+ export function matrix(hexes) {
288
+ return hexes.map((bg) => hexes.map((fg) => ({ fg, bg, ratio: contrast(fg, bg), lc: apca(fg, bg) })));
289
+ }
290
+
291
+ /** The nearest colour along the lightness axis (OKLCH) that reaches a WCAG ratio against `against`, or null. */
292
+ export function fixContrast(hex, against, target = 4.5) {
293
+ const o = useConv('oklch')(col(hex));
294
+ const bgL = useConv('oklch')(col(against)).l;
295
+ const dir = o.l > bgL ? 1 : -1;
296
+ // only away from the other colour: a fix never turns light text dark or a light background dark
297
+ for (const d of [dir]) {
298
+ for (let l = Math.min(1, Math.max(0, o.l)); l >= 0 && l <= 1; l += d * 0.005) {
299
+ const x = fromOklch(l, o.c, o.h || 0);
300
+ if (contrast(x, against) >= target) return x;
301
+ }
302
+ }
303
+ return null;
304
+ }
305
+
306
+ export { formatHex, wcagLuminance };
307
+
308
+ export const SCHEMES = [
309
+ { id: 'complementary', name: 'complementary', about: 'the color, its opposite on the hue wheel, and a light and a dark neutral tinted with its hue' },
310
+ { id: 'analogous', name: 'analogous', about: 'neighbors on the hue wheel, 20° and 40° either side, at different lightness so the set has a background and text' },
311
+ { id: 'triadic', name: 'triadic', about: 'three hues a third of the wheel apart, with a light ground and a dark ink tinted with the base' },
312
+ { id: 'split-complementary', name: 'split complementary', about: 'the color and the two hues beside its opposite: as much contrast as complementary, less tension' },
313
+ { id: 'tetradic', name: 'tetradic', about: 'four hues a quarter of the wheel apart, plus a dark ink' },
314
+ { id: 'monochromatic', name: 'monochromatic', about: 'one hue from near-white to near-black, chroma tapering at both ends' },
315
+ ];
316
+
317
+ /**
318
+ * Five-color palettes for each harmony around one color, built in OKLCH. Each keeps the given color exactly and
319
+ * includes a light and a dark member, so every one holds a text and background pair.
320
+ */
321
+ export function schemePalettes(hex) {
322
+ const o = useConv('oklch')(col(hex));
323
+ const L = o.l, C = Math.max(o.c, 0.04), H = o.h || 0;
324
+ const at = (l, c, d = 0) => fromOklch(l, c, (H + d + 360) % 360);
325
+ const light = at(0.97, Math.min(C * 0.12, 0.02)), dark = at(0.22, Math.min(C * 0.35, 0.05));
326
+ // a partner hue at a lightness that differs from the base, so the pair reads apart
327
+ const lp = (d) => (L > 0.6 ? Math.max(0.42, L - 0.2) : Math.min(0.78, L + 0.2));
328
+ const out = {
329
+ complementary: [light, hex, at(lp(), C, 180), at(Math.min(L + 0.25, 0.9), C * 0.35), dark],
330
+ analogous: [at(0.93, C * 0.25, -40), at(lp(), C * 0.9, -20), hex, at(lp(), C * 0.9, 20), at(0.3, C * 0.6, 40)],
331
+ triadic: [light, hex, at(lp(), C * 0.9, 120), at(lp(), C * 0.9, 240), dark],
332
+ 'split-complementary': [light, hex, at(lp(), C * 0.9, 150), at(lp(), C * 0.9, 210), dark],
333
+ tetradic: [hex, at(lp(), C * 0.85, 90), at(L, C * 0.85, 180), at(lp(), C * 0.85, 270), dark],
334
+ monochromatic: (() => {
335
+ const ls = [0.96, 0.84, 0.66, 0.46, 0.26];
336
+ const k = ls.reduce((b, l, i) => (Math.abs(l - L) < Math.abs(ls[b] - L) ? i : b), 0);
337
+ return ls.map((l, i) => (i === k ? hex : at(l, Math.min(o.c, 0.2) * (1 - Math.abs(l - 0.6) * 0.9))));
338
+ })(),
339
+ };
340
+ return out;
341
+ }
342
+
343
+ /** Plain mood words for a palette, from its lightness and chroma in OKLCH. */
344
+ export function moods(hexes) {
345
+ const all = hexes.map((h) => useConv('oklch')(col(h)));
346
+ // a palette's dark ink is there for text: the mood is read from the other colours
347
+ const darkest = all.reduce((m, x) => (x.l < m.l ? x : m), all[0]);
348
+ const o = all.length > 3 && darkest.l < 0.32 ? all.filter((x) => x !== darkest) : all;
349
+ const avgL = o.reduce((s, x) => s + x.l, 0) / o.length, avgC = o.reduce((s, x) => s + x.c, 0) / o.length;
350
+ const maxC = Math.max(...o.map((x) => x.c));
351
+ const soft = o.filter((x) => x.l > 0.78 && x.c >= 0.015 && x.c < 0.13).length;
352
+ const warm = o.filter((x) => x.c > 0.04 && ((x.h || 0) < 100 || (x.h || 0) > 330)).length;
353
+ const cool = o.filter((x) => x.c > 0.04 && (x.h || 0) > 160 && (x.h || 0) < 300).length;
354
+ const t = [];
355
+ if (soft >= Math.min(3, o.length - 1) && maxC < 0.16) t.push('pastel');
356
+ if (avgL < 0.45) t.push('dark');
357
+ if (avgL > 0.75) t.push('light');
358
+ if (maxC > 0.2) t.push('bright');
359
+ if (avgC < 0.06 && maxC < 0.12) t.push('muted');
360
+ if (maxC < 0.04) t.push('neutral');
361
+ if (warm >= Math.ceil(o.length / 2)) t.push('warm');
362
+ if (cool >= Math.ceil(o.length / 2)) t.push('cool');
363
+ return t;
364
+ }
@@ -0,0 +1,208 @@
1
+ // The forgiving colour reader: one parser for every converter, the hex lookup and the search box. It takes what people
2
+ // paste, not only what CSS accepts: "120 50% 50%" or "120,50,50" for HSL with no hsl() around it, rgb in 0–255,
3
+ // percent or 0–1, hex with or without # or 0x in 3, 4, 6 or 8 digits, cmyk in 0–100 or 0–1, colour names, and colour
4
+ // code from CSS, Swift, SwiftUI, Android, Compose and Flutter. A converter says what it expects (`as`), so bare numbers
5
+ // are read in that model; without it the numbers are guessed. The answer is { hex, alpha, mode } or { error }.
6
+ import {
7
+ useMode, modeRgb, modeHsl, modeHsv, modeHwb, modeLab, modeLch, modeOklab, modeOklch, modeXyz65, modeLrgb, modeP3,
8
+ parse as cparse, formatHex, toGamut, converter,
9
+ } from 'culori/fn';
10
+
11
+ useMode(modeRgb); useMode(modeLrgb); useMode(modeHsl); useMode(modeHsv); useMode(modeHwb); useMode(modeLab); useMode(modeLch);
12
+ useMode(modeOklab); useMode(modeOklch); useMode(modeXyz65); useMode(modeP3);
13
+ const toRgb = toGamut('rgb', 'oklch');
14
+ const asRgb = converter('rgb');
15
+
16
+ /** Model words people write before the numbers, and the model each one means. */
17
+ const WORDS = {
18
+ rgb: 'rgb', rgba: 'rgb', srgb: 'rgb', hsl: 'hsl', hsla: 'hsl', hsv: 'hsv', hsva: 'hsv', hsb: 'hsv', hsba: 'hsv', hwb: 'hwb',
19
+ cmyk: 'cmyk', lab: 'lab', cielab: 'lab', 'cie-lab': 'lab', lch: 'lch', cielch: 'lch', oklab: 'oklab', oklch: 'oklch',
20
+ xyz: 'xyz', 'xyz-d65': 'xyz', ciexyz: 'xyz', argb: 'argb', decimal: 'decimal', int: 'decimal', integer: 'decimal',
21
+ };
22
+ /** What each model needs, for the hint when the numbers do not fit. */
23
+ const NEEDS = {
24
+ rgb: 'red, green and blue (0–255, percentages or 0–1), and an optional alpha', hsl: 'hue, saturation and lightness, e.g. 120 50% 50%',
25
+ hsv: 'hue, saturation and value (brightness), e.g. 120 50% 80%', hwb: 'hue, whiteness and blackness, e.g. 120 20% 30%',
26
+ cmyk: 'cyan, magenta, yellow and black (0–100 or 0–1)', lab: 'L (0–100), a and b, e.g. 54 81 70', lch: 'L (0–100), chroma and hue, e.g. 54 107 40',
27
+ oklab: 'L (0–1 or %), a and b, e.g. 0.63 0.22 0.13', oklch: 'L (0–1 or %), chroma and hue, e.g. 0.63 0.26 29', xyz: 'X, Y and Z (0–1 or 0–100)',
28
+ argb: 'alpha, red, green and blue', decimal: 'one whole number from 0 to 16777215',
29
+ };
30
+
31
+ const fail = (error) => ({ error });
32
+ const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
33
+ const NUM = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(%|deg|°|rad|grad|turn)?$/i;
34
+
35
+ /** "50%", "120deg", ".5", "255/255" → { v, unit, dot } or null. */
36
+ function num(t) {
37
+ const frac = t.match(/^([+-]?[\d.]+)\/([\d.]+)$/);
38
+ if (frac) return { v: Number(frac[1]) / Number(frac[2]), unit: '', dot: true };
39
+ const m = t.match(NUM);
40
+ if (!m) return null;
41
+ return { v: Number(m[1]), unit: (m[2] || '').toLowerCase().replace('°', 'deg'), dot: m[1].includes('.') || /e/i.test(m[1]) };
42
+ }
43
+ const hueOf = (t) => (t.unit === 'rad' ? t.v * 180 / Math.PI : t.unit === 'grad' ? t.v * 0.9 : t.unit === 'turn' ? t.v * 360 : t.v);
44
+ /** An alpha: 0–1, a percentage, or 0–255 when larger than 100 (Android). */
45
+ const alphaOf = (t) => (!t ? 1 : clamp(t.unit === '%' ? t.v / 100 : t.v > 100 ? t.v / 255 : t.v > 1 ? t.v / 100 : t.v));
46
+ /** A 0–1 share from "50%", "50" or "0.5"; `frac` says the whole value is written in 0–1. */
47
+ const share = (t, frac) => clamp(t.unit === '%' ? t.v / 100 : frac ? t.v : t.v / 100);
48
+
49
+ function fromHex(h, argb) {
50
+ if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('');
51
+ if (h.length === 8 && argb) return { hex: h.slice(2), alpha: Math.round(parseInt(h.slice(0, 2), 16) / 255 * 1000) / 1000 };
52
+ if (h.length === 8) return { hex: h.slice(0, 6), alpha: Math.round(parseInt(h.slice(6), 16) / 255 * 1000) / 1000 };
53
+ return { hex: h, alpha: 1 };
54
+ }
55
+
56
+ function done(c, alpha, mode) {
57
+ // a colour inside sRGB converts straight (no round trip through OKLCH, which can move 127.5 to 127)
58
+ const direct = asRgb(c);
59
+ const inside = direct && [direct.r, direct.g, direct.b].every((x) => x >= -1e-6 && x <= 1 + 1e-6);
60
+ const rgb = inside ? { mode: 'rgb', r: clamp(direct.r), g: clamp(direct.g), b: clamp(direct.b) } : toRgb(c);
61
+ if (!rgb || [rgb.r, rgb.g, rgb.b].some((x) => Number.isNaN(x))) return fail('That does not make a color.');
62
+ return { hex: formatHex(rgb).slice(1), alpha: Math.round(clamp(alpha ?? 1) * 1000) / 1000, mode };
63
+ }
64
+
65
+ /** Numbers in a model → a colour. */
66
+ function fromNumbers(mode, ts) {
67
+ const n = ts.length;
68
+ const want = (k) => (n < k ? fail(`${mode.toUpperCase()} needs ${NEEDS[mode]}.`) : null);
69
+ switch (mode) {
70
+ case 'rgb': {
71
+ if (want(3)) return want(3);
72
+ const frac = ts.slice(0, 3).every((t) => t.unit !== '%' && t.v <= 1) && ts.slice(0, 3).some((t) => t.dot);
73
+ const ch = (t) => clamp(t.unit === '%' ? t.v / 100 : frac ? t.v : t.v / 255);
74
+ if (ts.slice(0, 3).some((t) => t.unit !== '%' && t.v > 255)) return fail('RGB channels go from 0 to 255.');
75
+ return done({ mode: 'rgb', r: ch(ts[0]), g: ch(ts[1]), b: ch(ts[2]) }, alphaOf(ts[3]), 'rgb');
76
+ }
77
+ case 'argb': {
78
+ if (want(4)) return want(4);
79
+ const [a, ...rest] = ts;
80
+ const r = fromNumbers('rgb', rest);
81
+ return r.error ? r : { ...r, alpha: alphaOf(a), mode: 'argb' };
82
+ }
83
+ case 'hsl': case 'hsv': case 'hwb': {
84
+ if (want(3)) return want(3);
85
+ const [h, x, y] = ts;
86
+ // all three written as 0–1 (hue too): hue is a turn
87
+ const frac = [x, y].every((t) => t.unit !== '%' && t.v <= 1) && [x, y].some((t) => t.dot);
88
+ const hue = frac && !h.unit && h.v <= 1 && h.dot ? h.v * 360 : hueOf(h);
89
+ const c = mode === 'hsl' ? { mode, h: hue, s: share(x, frac), l: share(y, frac) }
90
+ : mode === 'hsv' ? { mode, h: hue, s: share(x, frac), v: share(y, frac) } : { mode, h: hue, w: share(x, frac), b: share(y, frac) };
91
+ return done(c, alphaOf(ts[3]), mode);
92
+ }
93
+ case 'cmyk': {
94
+ if (want(4)) return want(4);
95
+ const frac = ts.slice(0, 4).every((t) => t.unit !== '%' && t.v <= 1);
96
+ const [c, m, y, k] = ts.slice(0, 4).map((t) => share(t, frac));
97
+ return done({ mode: 'rgb', r: (1 - c) * (1 - k), g: (1 - m) * (1 - k), b: (1 - y) * (1 - k) }, alphaOf(ts[4]), 'cmyk');
98
+ }
99
+ case 'lab': case 'lch': {
100
+ if (want(3)) return want(3);
101
+ const L = ts[0].v; // a percentage of 100 is the same number
102
+ return done(mode === 'lab' ? { mode: 'lab', l: L, a: ts[1].v, b: ts[2].v } : { mode: 'lch', l: L, c: ts[1].v, h: hueOf(ts[2]) }, alphaOf(ts[3]), mode);
103
+ }
104
+ case 'oklab': case 'oklch': {
105
+ if (want(3)) return want(3);
106
+ const L = ts[0].unit === '%' || ts[0].v > 1 ? ts[0].v / 100 : ts[0].v;
107
+ const ab = (t) => (t.unit === '%' ? t.v / 100 * 0.4 : t.v);
108
+ return done(mode === 'oklab' ? { mode: 'oklab', l: L, a: ab(ts[1]), b: ab(ts[2]) } : { mode: 'oklch', l: L, c: ab(ts[1]), h: hueOf(ts[2]) }, alphaOf(ts[3]), mode);
109
+ }
110
+ case 'xyz': {
111
+ if (want(3)) return want(3);
112
+ const k = ts.slice(0, 3).some((t) => t.v > 1.5 || t.unit === '%') ? 100 : 1;
113
+ return done({ mode: 'xyz65', x: ts[0].v / k, y: ts[1].v / k, z: ts[2].v / k }, alphaOf(ts[3]), 'xyz');
114
+ }
115
+ case 'decimal': {
116
+ if (n !== 1 || ts[0].dot || ts[0].unit) return fail(`A decimal color is ${NEEDS.decimal}.`);
117
+ let v = ts[0].v;
118
+ if (v < 0) v += 2 ** 32; // an Android color int is signed ARGB
119
+ if (v > 0xffffffff) return fail(`A decimal color is ${NEEDS.decimal}.`);
120
+ if (v > 0xffffff) return { hex: (v & 0xffffff).toString(16).padStart(6, '0'), alpha: Math.round(Math.floor(v / 2 ** 24) / 255 * 1000) / 1000, mode: 'decimal' };
121
+ return { hex: v.toString(16).padStart(6, '0'), alpha: 1, mode: 'decimal' };
122
+ }
123
+ default: return fail('Unknown color model.');
124
+ }
125
+ }
126
+
127
+ /** Guess the model of bare numbers. */
128
+ function guess(ts) {
129
+ const n = ts.length;
130
+ if (n === 1 && !ts[0].dot && !ts[0].unit) return 'decimal';
131
+ if (ts[0]?.unit && ts[0].unit !== '%') return 'hsl';
132
+ if (n >= 3 && !ts[0].unit && (ts[1].unit === '%' || ts[2].unit === '%')) return 'hsl';
133
+ // four numbers up to 100 with a whole last one ("0 66 80 0") are CMYK; a fraction last ("255 87 51 0.5") is an alpha
134
+ if (n === 4 && !ts[3].dot && ts.every((t) => t.v <= 100)) return 'cmyk';
135
+ if (n === 3 || n === 4) return 'rgb';
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Read a colour from anything people paste. `opts.as` is the model a converter expects ('hsl', 'rgb', 'hex', 'argb',
141
+ * 'decimal', …); `opts.name` looks up a colour name the CSS list does not have (the site's named colours).
142
+ */
143
+ export function parseColor(input, opts = {}) {
144
+ if (input == null) return fail('Type a color.');
145
+ let s = String(input).normalize('NFKC').replace(/[−‒–—]/g, '-').trim().replace(/;+$/, '').trim();
146
+ if (!s) return fail('Type a color.');
147
+ const as = WORDS[opts.as] || opts.as;
148
+ // code around the value: an Android resource, a CSS declaration, a quoted string
149
+ const xml = s.match(/<color[^>]*>\s*([^<]+?)\s*<\/color>/i);
150
+ if (xml) return parseColor(xml[1], { ...opts, as: /^#?[0-9a-f]{8}$/i.test(xml[1]) ? 'argb' : opts.as });
151
+ const decl = s.match(/^(?:--)?([a-z][\w-]*)\s*[:=]\s*(.+)$/i);
152
+ if (decl && !WORDS[decl[1].toLowerCase()] && !/^(red|green|blue|hue)$/i.test(decl[1])) return parseColor(decl[2], opts);
153
+ const low = s.toLowerCase();
154
+ // Swift and SwiftUI: UIColor(red: 1, green: 0.34, blue: 0.2, alpha: 1), Color(hue: …), #colorLiteral(…), NSColor(calibratedRed: …)
155
+ const named = (k) => { const m = low.match(new RegExp(`\\b${k}\\s*:\\s*([+-]?[\\d.]+(?:\\s*/\\s*[\\d.]+)?)`)); return m ? num(m[1].replace(/\s+/g, '')) : null; };
156
+ if (/\b(?:red|calibratedred|devicered)\s*:/.test(low)) {
157
+ const r = named('(?:calibrated|device)?red'), g = named('green'), b = named('blue'), a = named('alpha') || named('opacity');
158
+ if (r && g && b) { const frac = [r, g, b].every((t) => t.v <= 1); return done({ mode: 'rgb', r: frac ? r.v : r.v / 255, g: frac ? g.v : g.v / 255, b: frac ? b.v : b.v / 255 }, a ? alphaOf(a) : 1, 'rgb'); }
159
+ }
160
+ if (/\bhue\s*:/.test(low) && /saturation\s*:/.test(low) && /brightness\s*:/.test(low)) {
161
+ const h = named('hue'), sa = named('saturation'), br = named('brightness'), a = named('alpha') || named('opacity');
162
+ if (h && sa && br) return done({ mode: 'hsv', h: h.v <= 1 ? h.v * 360 : h.v, s: sa.v <= 1 ? sa.v : sa.v / 100, v: br.v <= 1 ? br.v : br.v / 100 }, a ? alphaOf(a) : 1, 'hsv');
163
+ }
164
+ // Android, Compose and Flutter: 0xFFFF5733, Color(0xFFFF5733), Color.parseColor("#…"), Color.rgb(…), Color.argb(…), Color.fromARGB(…), Color.fromRGBO(…)
165
+ const ox = low.match(/0x([0-9a-f]{8}|[0-9a-f]{6})\b/);
166
+ if (ox) return { ...fromHex(ox[1], true), mode: ox[1].length === 8 ? 'argb' : 'hex' };
167
+ const call = low.match(/(?:color\.)?(rgb|argb|fromargb|fromrgbo)\s*\(([^)]*)\)/);
168
+ if (call && !/^(rgb|rgba)\s*\(/.test(low)) {
169
+ const ts = call[2].split(/[\s,]+/).filter(Boolean).map(num);
170
+ if (ts.every(Boolean)) {
171
+ if (call[1] === 'argb' || call[1] === 'fromargb') return fromNumbers('argb', ts);
172
+ return fromNumbers('rgb', ts);
173
+ }
174
+ }
175
+ // Android's Color.parseColor("#AARRGGBB") puts alpha first
176
+ const quoted = s.match(/["'`]\s*([^"'`]+?)\s*["'`]/);
177
+ if (quoted && quoted[1] !== s) return parseColor(quoted[1], /parsecolor/.test(low) ? { ...opts, as: 'argb' } : opts);
178
+ // hex: #, 0x or nothing; 3, 4, 6 or 8 digits, or pairs with spaces ("ff 57 33"). Bare digits are hex only when they
179
+ // cannot be anything else: 3 or 6 of them in one piece, or a converter that expects hex
180
+ const pref = /^(#|0x|&h|\\x)/.test(low), spaced = /\s/.test(low);
181
+ const hx = low.replace(/^(#|0x|&h|\\x)/, '').replace(/\s+/g, '');
182
+ if (/^([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(hx)) {
183
+ const letters = /[a-f]/.test(hx);
184
+ const isHex = pref || as === 'hex' || as === 'argb'
185
+ || (letters && (!spaced || /^([0-9a-f]{2}\s+){2,3}[0-9a-f]{2}$/.test(low)))
186
+ || (!letters && !spaced && !as && (hx.length === 3 || hx.length === 6));
187
+ if (isHex) return { ...fromHex(hx, as === 'argb'), mode: hx.length === 8 && as === 'argb' ? 'argb' : 'hex' };
188
+ }
189
+ // a model word, with or without brackets: "hsl(120 50% 50%)", "hsl 120 50 50", "hsla: 120, 50, 50, .5"
190
+ const fn = low.match(/^([a-z][a-z0-9-]*)\s*[(:]?\s*(.*?)\s*\)?$/);
191
+ let mode = null, body = low;
192
+ if (fn && WORDS[fn[1]]) { mode = WORDS[fn[1]]; body = fn[2]; }
193
+ else if (/^color\s*\(/.test(low)) { const c = cparse(low); return c ? done(c, c.alpha ?? 1, 'rgb') : fail('That CSS color() is not one this reads.'); }
194
+ if (mode || /^[\s\d.,+\-/%°a-z()]+$/.test(body)) {
195
+ const raw = body.replace(/[()]/g, ' ').split(/[\s,]+|\s*\/\s*/).filter(Boolean);
196
+ const ts = raw.map(num);
197
+ if (raw.length && ts.every(Boolean)) {
198
+ mode ||= as && as !== 'hex' && as !== 'name' ? as : guess(ts);
199
+ if (mode) return fromNumbers(mode, ts);
200
+ }
201
+ }
202
+ // a CSS colour name or any CSS colour syntax; then the site's names
203
+ const c = cparse(low);
204
+ if (c) { const r = asRgb(c); if (r) return done(c, c.alpha ?? 1, c.mode === 'rgb' ? (/^[a-z]+$/.test(low) ? 'name' : 'rgb') : c.mode); }
205
+ const byName = opts.name?.(s);
206
+ if (byName) return { hex: byName, alpha: 1, mode: 'name' };
207
+ return fail(as && NEEDS[as] ? `Could not read that as ${as.toUpperCase()}. It needs ${NEEDS[as]}.` : 'Could not read that color. Try #ff5733, rgb 255 87 51, hsl 11 100% 60% or a color name.');
208
+ }