@luntta/swatch 3.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 +56 -0
- package/CONTRIBUTING.md +89 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +189 -0
- package/README.md +463 -0
- package/package.json +57 -0
- package/scripts/pack-check.mjs +18 -0
- package/src/bootstrap.js +159 -0
- package/src/core/registry.js +81 -0
- package/src/core/state.js +36 -0
- package/src/core/swatch-class.js +524 -0
- package/src/data/cvd-matrices.js +179 -0
- package/src/data/named-colors.js +157 -0
- package/src/format/css.js +256 -0
- package/src/operations/accessibility.js +103 -0
- package/src/operations/apca.js +80 -0
- package/src/operations/blend.js +72 -0
- package/src/operations/channels.js +123 -0
- package/src/operations/cvd.js +119 -0
- package/src/operations/deltaE.js +207 -0
- package/src/operations/gamut.js +206 -0
- package/src/operations/image.js +192 -0
- package/src/operations/manipulation.js +100 -0
- package/src/operations/mix.js +129 -0
- package/src/operations/naming.js +158 -0
- package/src/operations/palette.js +133 -0
- package/src/operations/random.js +75 -0
- package/src/operations/temperature.js +126 -0
- package/src/operations/tint-shade.js +42 -0
- package/src/palettes/colorbrewer.js +232 -0
- package/src/palettes/index.js +58 -0
- package/src/palettes/viridis.js +59 -0
- package/src/parse/css.js +241 -0
- package/src/parse/hex.js +38 -0
- package/src/parse/index.js +43 -0
- package/src/parse/legacy.js +88 -0
- package/src/parse/named.js +11 -0
- package/src/parse/objects.js +125 -0
- package/src/scale/index.js +382 -0
- package/src/scale/interpolators.js +83 -0
- package/src/spaces/a98.js +55 -0
- package/src/spaces/cmyk.js +75 -0
- package/src/spaces/display-p3.js +50 -0
- package/src/spaces/hsl.js +93 -0
- package/src/spaces/hsluv.js +211 -0
- package/src/spaces/hsv.js +78 -0
- package/src/spaces/hwb.js +48 -0
- package/src/spaces/lab.js +70 -0
- package/src/spaces/lch.js +65 -0
- package/src/spaces/oklab.js +79 -0
- package/src/spaces/oklch.js +53 -0
- package/src/spaces/prophoto.js +72 -0
- package/src/spaces/rec2020.js +65 -0
- package/src/spaces/srgb.js +85 -0
- package/src/spaces/xyz.js +71 -0
- package/src/swatch.js +57 -0
- package/src/util/math.js +53 -0
- package/src/util/matrix.js +92 -0
- package/src/util/suggest.js +66 -0
- package/types/swatch.d.ts +664 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// CVD (Color Vision Deficiency) simulation matrices.
|
|
2
|
+
//
|
|
3
|
+
// We build dichromat projection matrices in linear sRGB space by
|
|
4
|
+
// lifting the LMS plane projection through standard linear-RGB ↔ LMS
|
|
5
|
+
// transforms:
|
|
6
|
+
//
|
|
7
|
+
// M_RGB = M_LMS→RGB · M_dichromat_LMS · M_RGB→LMS
|
|
8
|
+
//
|
|
9
|
+
// The LMS plane for each dichromat type is constructed via the
|
|
10
|
+
// Brettel/Viénot anchor-line method: the plane passes through white
|
|
11
|
+
// and a confusion-line anchor (blue for protan/deutan, red for
|
|
12
|
+
// tritan). The missing cone's response is reconstructed as
|
|
13
|
+
// a·X + b·Y in LMS space, solved via Cramer's rule.
|
|
14
|
+
|
|
15
|
+
import { multiplyMatrices, invertMatrix } from "../util/matrix.js";
|
|
16
|
+
import { appendSuggestion } from "../util/suggest.js";
|
|
17
|
+
|
|
18
|
+
const CVD_TYPES = [
|
|
19
|
+
"protan",
|
|
20
|
+
"protanopia",
|
|
21
|
+
"protanomaly",
|
|
22
|
+
"deutan",
|
|
23
|
+
"deuteranopia",
|
|
24
|
+
"deuteranomaly",
|
|
25
|
+
"tritan",
|
|
26
|
+
"tritanopia",
|
|
27
|
+
"tritanomaly",
|
|
28
|
+
"achroma",
|
|
29
|
+
"achromatopsia",
|
|
30
|
+
"achromatomaly"
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// sRGB→XYZ (D65), ported from src/swatch.js:1737-1743.
|
|
34
|
+
const M_RGB_TO_XYZ = [
|
|
35
|
+
[0.4124564, 0.3575761, 0.1804375],
|
|
36
|
+
[0.2126729, 0.7151522, 0.072175],
|
|
37
|
+
[0.0193339, 0.119192, 0.9503041]
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Hunt-Pointer-Estevez XYZ→LMS (normalized to equal-energy).
|
|
41
|
+
const M_XYZ_TO_LMS = [
|
|
42
|
+
[0.4002, 0.7076, -0.0808],
|
|
43
|
+
[-0.2263, 1.1653, 0.0457],
|
|
44
|
+
[0, 0, 0.9182]
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export const M_LINEAR_RGB_TO_LMS = multiplyMatrices(
|
|
48
|
+
M_XYZ_TO_LMS,
|
|
49
|
+
M_RGB_TO_XYZ
|
|
50
|
+
);
|
|
51
|
+
export const M_LMS_TO_LINEAR_RGB = invertMatrix(M_LINEAR_RGB_TO_LMS);
|
|
52
|
+
|
|
53
|
+
function mat3mul3(M, v) {
|
|
54
|
+
return [
|
|
55
|
+
M[0][0] * v[0] + M[0][1] * v[1] + M[0][2] * v[2],
|
|
56
|
+
M[1][0] * v[0] + M[1][1] * v[1] + M[1][2] * v[2],
|
|
57
|
+
M[2][0] * v[0] + M[2][1] * v[1] + M[2][2] * v[2]
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function linearRgbToLms(rgb) {
|
|
62
|
+
return mat3mul3(M_LINEAR_RGB_TO_LMS, rgb);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// LMS primaries used as dichromat anchors.
|
|
66
|
+
const blueLms = linearRgbToLms([0, 0, 1]);
|
|
67
|
+
const redLms = linearRgbToLms([1, 0, 0]);
|
|
68
|
+
const whiteLms = linearRgbToLms([1, 1, 1]);
|
|
69
|
+
|
|
70
|
+
// Protan: L is missing → L = a·M + b·S, anchor = blue.
|
|
71
|
+
function dichromatProtanopia() {
|
|
72
|
+
const wL = whiteLms[0],
|
|
73
|
+
wM = whiteLms[1],
|
|
74
|
+
wS = whiteLms[2];
|
|
75
|
+
const bL = blueLms[0],
|
|
76
|
+
bM = blueLms[1],
|
|
77
|
+
bS = blueLms[2];
|
|
78
|
+
const det = wM * bS - bM * wS;
|
|
79
|
+
const a = (wL * bS - bL * wS) / det;
|
|
80
|
+
const b = (wM * bL - bM * wL) / det;
|
|
81
|
+
return [
|
|
82
|
+
[0, a, b],
|
|
83
|
+
[0, 1, 0],
|
|
84
|
+
[0, 0, 1]
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Deutan: M is missing → M = a·L + b·S, anchor = blue.
|
|
89
|
+
function dichromatDeuteranopia() {
|
|
90
|
+
const wL = whiteLms[0],
|
|
91
|
+
wM = whiteLms[1],
|
|
92
|
+
wS = whiteLms[2];
|
|
93
|
+
const bL = blueLms[0],
|
|
94
|
+
bM = blueLms[1],
|
|
95
|
+
bS = blueLms[2];
|
|
96
|
+
const det = wL * bS - bL * wS;
|
|
97
|
+
const a = (wM * bS - bM * wS) / det;
|
|
98
|
+
const b = (wL * bM - bL * wM) / det;
|
|
99
|
+
return [
|
|
100
|
+
[1, 0, 0],
|
|
101
|
+
[a, 0, b],
|
|
102
|
+
[0, 0, 1]
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Tritan: S is missing → S = a·L + b·M, anchor = red.
|
|
107
|
+
function dichromatTritanopia() {
|
|
108
|
+
const wL = whiteLms[0],
|
|
109
|
+
wM = whiteLms[1];
|
|
110
|
+
const rL = redLms[0],
|
|
111
|
+
rM = redLms[1],
|
|
112
|
+
rS = redLms[2];
|
|
113
|
+
const det = wL * rM - rL * wM;
|
|
114
|
+
const a = (whiteLms[2] * rM - rS * wM) / det;
|
|
115
|
+
const b = (wL * rS - rL * whiteLms[2]) / det;
|
|
116
|
+
return [
|
|
117
|
+
[1, 0, 0],
|
|
118
|
+
[0, 1, 0],
|
|
119
|
+
[a, b, 0]
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildRgbMatrix(dichromatLms) {
|
|
124
|
+
return multiplyMatrices(
|
|
125
|
+
M_LMS_TO_LINEAR_RGB,
|
|
126
|
+
multiplyMatrices(dichromatLms, M_LINEAR_RGB_TO_LMS)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const CVD_RGB_MATRICES = {
|
|
131
|
+
protan: buildRgbMatrix(dichromatProtanopia()),
|
|
132
|
+
deutan: buildRgbMatrix(dichromatDeuteranopia()),
|
|
133
|
+
tritan: buildRgbMatrix(dichromatTritanopia())
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function normalizeCVDType(type) {
|
|
137
|
+
if (typeof type !== "string") {
|
|
138
|
+
throw new Error("CVD type must be a string");
|
|
139
|
+
}
|
|
140
|
+
const t = type.toLowerCase();
|
|
141
|
+
if (t === "protan" || t === "protanopia" || t === "protanomaly")
|
|
142
|
+
return "protan";
|
|
143
|
+
if (t === "deutan" || t === "deuteranopia" || t === "deuteranomaly")
|
|
144
|
+
return "deutan";
|
|
145
|
+
if (t === "tritan" || t === "tritanopia" || t === "tritanomaly")
|
|
146
|
+
return "tritan";
|
|
147
|
+
if (t === "achroma" || t === "achromatopsia" || t === "achromatomaly")
|
|
148
|
+
return "achroma";
|
|
149
|
+
throw new Error(
|
|
150
|
+
appendSuggestion(`Unknown CVD type: ${type}`, type, CVD_TYPES)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const IDENTITY3 = [
|
|
155
|
+
[1, 0, 0],
|
|
156
|
+
[0, 1, 0],
|
|
157
|
+
[0, 0, 1]
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
// Rec. 709 luminance row, used for the achromatopsia projection.
|
|
161
|
+
export const ACHROMA_MATRIX = [
|
|
162
|
+
[0.2126, 0.7152, 0.0722],
|
|
163
|
+
[0.2126, 0.7152, 0.0722],
|
|
164
|
+
[0.2126, 0.7152, 0.0722]
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
export function interpolateMatrix3(A, B, t) {
|
|
168
|
+
const out = [
|
|
169
|
+
[0, 0, 0],
|
|
170
|
+
[0, 0, 0],
|
|
171
|
+
[0, 0, 0]
|
|
172
|
+
];
|
|
173
|
+
for (let i = 0; i < 3; i++) {
|
|
174
|
+
for (let j = 0; j < 3; j++) {
|
|
175
|
+
out[i][j] = A[i][j] + (B[i][j] - A[i][j]) * t;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// CSS Color Module Level 4 named-color list. Lowercase keys → 6-digit
|
|
2
|
+
// hex (without leading "#"). Includes the legacy `transparent` keyword
|
|
3
|
+
// (mapped to fully transparent black) and the British/American
|
|
4
|
+
// `grey`/`gray` synonyms.
|
|
5
|
+
|
|
6
|
+
const namedColors = {
|
|
7
|
+
aliceblue: "f0f8ff",
|
|
8
|
+
antiquewhite: "faebd7",
|
|
9
|
+
aqua: "00ffff",
|
|
10
|
+
aquamarine: "7fffd4",
|
|
11
|
+
azure: "f0ffff",
|
|
12
|
+
beige: "f5f5dc",
|
|
13
|
+
bisque: "ffe4c4",
|
|
14
|
+
black: "000000",
|
|
15
|
+
blanchedalmond: "ffebcd",
|
|
16
|
+
blue: "0000ff",
|
|
17
|
+
blueviolet: "8a2be2",
|
|
18
|
+
brown: "a52a2a",
|
|
19
|
+
burlywood: "deb887",
|
|
20
|
+
cadetblue: "5f9ea0",
|
|
21
|
+
chartreuse: "7fff00",
|
|
22
|
+
chocolate: "d2691e",
|
|
23
|
+
coral: "ff7f50",
|
|
24
|
+
cornflowerblue: "6495ed",
|
|
25
|
+
cornsilk: "fff8dc",
|
|
26
|
+
crimson: "dc143c",
|
|
27
|
+
cyan: "00ffff",
|
|
28
|
+
darkblue: "00008b",
|
|
29
|
+
darkcyan: "008b8b",
|
|
30
|
+
darkgoldenrod: "b8860b",
|
|
31
|
+
darkgray: "a9a9a9",
|
|
32
|
+
darkgreen: "006400",
|
|
33
|
+
darkgrey: "a9a9a9",
|
|
34
|
+
darkkhaki: "bdb76b",
|
|
35
|
+
darkmagenta: "8b008b",
|
|
36
|
+
darkolivegreen: "556b2f",
|
|
37
|
+
darkorange: "ff8c00",
|
|
38
|
+
darkorchid: "9932cc",
|
|
39
|
+
darkred: "8b0000",
|
|
40
|
+
darksalmon: "e9967a",
|
|
41
|
+
darkseagreen: "8fbc8f",
|
|
42
|
+
darkslateblue: "483d8b",
|
|
43
|
+
darkslategray: "2f4f4f",
|
|
44
|
+
darkslategrey: "2f4f4f",
|
|
45
|
+
darkturquoise: "00ced1",
|
|
46
|
+
darkviolet: "9400d3",
|
|
47
|
+
deeppink: "ff1493",
|
|
48
|
+
deepskyblue: "00bfff",
|
|
49
|
+
dimgray: "696969",
|
|
50
|
+
dimgrey: "696969",
|
|
51
|
+
dodgerblue: "1e90ff",
|
|
52
|
+
firebrick: "b22222",
|
|
53
|
+
floralwhite: "fffaf0",
|
|
54
|
+
forestgreen: "228b22",
|
|
55
|
+
fuchsia: "ff00ff",
|
|
56
|
+
gainsboro: "dcdcdc",
|
|
57
|
+
ghostwhite: "f8f8ff",
|
|
58
|
+
gold: "ffd700",
|
|
59
|
+
goldenrod: "daa520",
|
|
60
|
+
gray: "808080",
|
|
61
|
+
green: "008000",
|
|
62
|
+
greenyellow: "adff2f",
|
|
63
|
+
grey: "808080",
|
|
64
|
+
honeydew: "f0fff0",
|
|
65
|
+
hotpink: "ff69b4",
|
|
66
|
+
indianred: "cd5c5c",
|
|
67
|
+
indigo: "4b0082",
|
|
68
|
+
ivory: "fffff0",
|
|
69
|
+
khaki: "f0e68c",
|
|
70
|
+
lavender: "e6e6fa",
|
|
71
|
+
lavenderblush: "fff0f5",
|
|
72
|
+
lawngreen: "7cfc00",
|
|
73
|
+
lemonchiffon: "fffacd",
|
|
74
|
+
lightblue: "add8e6",
|
|
75
|
+
lightcoral: "f08080",
|
|
76
|
+
lightcyan: "e0ffff",
|
|
77
|
+
lightgoldenrodyellow: "fafad2",
|
|
78
|
+
lightgray: "d3d3d3",
|
|
79
|
+
lightgreen: "90ee90",
|
|
80
|
+
lightgrey: "d3d3d3",
|
|
81
|
+
lightpink: "ffb6c1",
|
|
82
|
+
lightsalmon: "ffa07a",
|
|
83
|
+
lightseagreen: "20b2aa",
|
|
84
|
+
lightskyblue: "87cefa",
|
|
85
|
+
lightslategray: "778899",
|
|
86
|
+
lightslategrey: "778899",
|
|
87
|
+
lightsteelblue: "b0c4de",
|
|
88
|
+
lightyellow: "ffffe0",
|
|
89
|
+
lime: "00ff00",
|
|
90
|
+
limegreen: "32cd32",
|
|
91
|
+
linen: "faf0e6",
|
|
92
|
+
magenta: "ff00ff",
|
|
93
|
+
maroon: "800000",
|
|
94
|
+
mediumaquamarine: "66cdaa",
|
|
95
|
+
mediumblue: "0000cd",
|
|
96
|
+
mediumorchid: "ba55d3",
|
|
97
|
+
mediumpurple: "9370db",
|
|
98
|
+
mediumseagreen: "3cb371",
|
|
99
|
+
mediumslateblue: "7b68ee",
|
|
100
|
+
mediumspringgreen: "00fa9a",
|
|
101
|
+
mediumturquoise: "48d1cc",
|
|
102
|
+
mediumvioletred: "c71585",
|
|
103
|
+
midnightblue: "191970",
|
|
104
|
+
mintcream: "f5fffa",
|
|
105
|
+
mistyrose: "ffe4e1",
|
|
106
|
+
moccasin: "ffe4b5",
|
|
107
|
+
navajowhite: "ffdead",
|
|
108
|
+
navy: "000080",
|
|
109
|
+
oldlace: "fdf5e6",
|
|
110
|
+
olive: "808000",
|
|
111
|
+
olivedrab: "6b8e23",
|
|
112
|
+
orange: "ffa500",
|
|
113
|
+
orangered: "ff4500",
|
|
114
|
+
orchid: "da70d6",
|
|
115
|
+
palegoldenrod: "eee8aa",
|
|
116
|
+
palegreen: "98fb98",
|
|
117
|
+
paleturquoise: "afeeee",
|
|
118
|
+
palevioletred: "db7093",
|
|
119
|
+
papayawhip: "ffefd5",
|
|
120
|
+
peachpuff: "ffdab9",
|
|
121
|
+
peru: "cd853f",
|
|
122
|
+
pink: "ffc0cb",
|
|
123
|
+
plum: "dda0dd",
|
|
124
|
+
powderblue: "b0e0e6",
|
|
125
|
+
purple: "800080",
|
|
126
|
+
rebeccapurple: "663399",
|
|
127
|
+
red: "ff0000",
|
|
128
|
+
rosybrown: "bc8f8f",
|
|
129
|
+
royalblue: "4169e1",
|
|
130
|
+
saddlebrown: "8b4513",
|
|
131
|
+
salmon: "fa8072",
|
|
132
|
+
sandybrown: "f4a460",
|
|
133
|
+
seagreen: "2e8b57",
|
|
134
|
+
seashell: "fff5ee",
|
|
135
|
+
sienna: "a0522d",
|
|
136
|
+
silver: "c0c0c0",
|
|
137
|
+
skyblue: "87ceeb",
|
|
138
|
+
slateblue: "6a5acd",
|
|
139
|
+
slategray: "708090",
|
|
140
|
+
slategrey: "708090",
|
|
141
|
+
snow: "fffafa",
|
|
142
|
+
springgreen: "00ff7f",
|
|
143
|
+
steelblue: "4682b4",
|
|
144
|
+
tan: "d2b48c",
|
|
145
|
+
teal: "008080",
|
|
146
|
+
thistle: "d8bfd8",
|
|
147
|
+
tomato: "ff6347",
|
|
148
|
+
turquoise: "40e0d0",
|
|
149
|
+
violet: "ee82ee",
|
|
150
|
+
wheat: "f5deb3",
|
|
151
|
+
white: "ffffff",
|
|
152
|
+
whitesmoke: "f5f5f5",
|
|
153
|
+
yellow: "ffff00",
|
|
154
|
+
yellowgreen: "9acd32"
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export default namedColors;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// CSS Color 4 serializer.
|
|
2
|
+
//
|
|
3
|
+
// `formatCss(swatch, opts)` returns a CSS string. `opts.format` picks
|
|
4
|
+
// the form; if omitted we pick the best default for the swatch's source
|
|
5
|
+
// space (preserving it losslessly where possible).
|
|
6
|
+
//
|
|
7
|
+
// Supported formats:
|
|
8
|
+
// 'hex' — #rrggbb (drops alpha)
|
|
9
|
+
// 'hex-alpha' — #rrggbbaa
|
|
10
|
+
// 'rgb' — rgb(r g b) or rgb(r g b / a) (modern slash syntax)
|
|
11
|
+
// 'rgb-legacy' — rgb(r, g, b) or rgba(r, g, b, a)
|
|
12
|
+
// 'hsl' — hsl(h s% l%) modern
|
|
13
|
+
// 'hsl-legacy' — hsl(h, s%, l%) / hsla(...)
|
|
14
|
+
// 'hwb' — hwb(h w% b%)
|
|
15
|
+
// 'lab' — lab(L a b) (D50, CSS spec)
|
|
16
|
+
// 'lch' — lch(L C H)
|
|
17
|
+
// 'oklab' — oklab(L a b)
|
|
18
|
+
// 'oklch' — oklch(L C H)
|
|
19
|
+
// 'color' — color(<space> r g b [/ a]) — uses the source space
|
|
20
|
+
//
|
|
21
|
+
// Precision: numbers are serialized with up to 6 significant digits by
|
|
22
|
+
// default to keep strings short; pass `{ precision }` to override.
|
|
23
|
+
|
|
24
|
+
const DEFAULT_PRECISION = 6;
|
|
25
|
+
|
|
26
|
+
function fmtNum(n, precision = DEFAULT_PRECISION) {
|
|
27
|
+
if (!Number.isFinite(n)) return "0";
|
|
28
|
+
if (n === 0) return "0";
|
|
29
|
+
// Strip trailing zeros after toPrecision.
|
|
30
|
+
const s = n.toPrecision(precision);
|
|
31
|
+
// toPrecision may return scientific notation for very small/large; for
|
|
32
|
+
// color values we'd rather see fixed form within a sane range.
|
|
33
|
+
if (s.indexOf("e") < 0) {
|
|
34
|
+
return parseFloat(s).toString();
|
|
35
|
+
}
|
|
36
|
+
return parseFloat(n.toFixed(precision)).toString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fmtInt(n) {
|
|
40
|
+
return Math.round(n).toString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fmtAlpha(a, precision = DEFAULT_PRECISION) {
|
|
44
|
+
if (a >= 1) return "1";
|
|
45
|
+
if (a <= 0) return "0";
|
|
46
|
+
return fmtNum(a, precision);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hex2(n) {
|
|
50
|
+
const v = Math.max(0, Math.min(255, Math.round(n * 255)));
|
|
51
|
+
return v.toString(16).padStart(2, "0");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toHex(swatch, withAlpha) {
|
|
55
|
+
const { r, g, b } = swatch.srgb;
|
|
56
|
+
const base = "#" + hex2(r) + hex2(g) + hex2(b);
|
|
57
|
+
if (withAlpha) return base + hex2(swatch.alpha);
|
|
58
|
+
return base;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toRgbModern(swatch, precision) {
|
|
62
|
+
const { r, g, b } = swatch.srgb;
|
|
63
|
+
const R = fmtInt(r * 255);
|
|
64
|
+
const G = fmtInt(g * 255);
|
|
65
|
+
const B = fmtInt(b * 255);
|
|
66
|
+
if (swatch.alpha < 1) {
|
|
67
|
+
return `rgb(${R} ${G} ${B} / ${fmtAlpha(swatch.alpha, precision)})`;
|
|
68
|
+
}
|
|
69
|
+
return `rgb(${R} ${G} ${B})`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toRgbLegacy(swatch, precision) {
|
|
73
|
+
const { r, g, b } = swatch.srgb;
|
|
74
|
+
const R = fmtInt(r * 255);
|
|
75
|
+
const G = fmtInt(g * 255);
|
|
76
|
+
const B = fmtInt(b * 255);
|
|
77
|
+
if (swatch.alpha < 1) {
|
|
78
|
+
return `rgba(${R}, ${G}, ${B}, ${fmtAlpha(swatch.alpha, precision)})`;
|
|
79
|
+
}
|
|
80
|
+
return `rgb(${R}, ${G}, ${B})`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toHslModern(swatch, precision) {
|
|
84
|
+
const { h, s, l } = swatch.hsl;
|
|
85
|
+
const H = fmtNum(h, precision);
|
|
86
|
+
const S = fmtNum(s, precision);
|
|
87
|
+
const L = fmtNum(l, precision);
|
|
88
|
+
if (swatch.alpha < 1) {
|
|
89
|
+
return `hsl(${H} ${S}% ${L}% / ${fmtAlpha(swatch.alpha, precision)})`;
|
|
90
|
+
}
|
|
91
|
+
return `hsl(${H} ${S}% ${L}%)`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function toHslLegacy(swatch, precision) {
|
|
95
|
+
const { h, s, l } = swatch.hsl;
|
|
96
|
+
const H = fmtNum(h, precision);
|
|
97
|
+
const S = fmtNum(s, precision);
|
|
98
|
+
const L = fmtNum(l, precision);
|
|
99
|
+
if (swatch.alpha < 1) {
|
|
100
|
+
return `hsla(${H}, ${S}%, ${L}%, ${fmtAlpha(swatch.alpha, precision)})`;
|
|
101
|
+
}
|
|
102
|
+
return `hsl(${H}, ${S}%, ${L}%)`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toHwb(swatch, precision) {
|
|
106
|
+
const { h, w, b } = swatch.hwb;
|
|
107
|
+
const H = fmtNum(h, precision);
|
|
108
|
+
const W = fmtNum(w, precision);
|
|
109
|
+
const B = fmtNum(b, precision);
|
|
110
|
+
if (swatch.alpha < 1) {
|
|
111
|
+
return `hwb(${H} ${W}% ${B}% / ${fmtAlpha(swatch.alpha, precision)})`;
|
|
112
|
+
}
|
|
113
|
+
return `hwb(${H} ${W}% ${B}%)`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toLab(swatch, precision) {
|
|
117
|
+
// CSS lab() is D50.
|
|
118
|
+
const coords = swatch._getCoordsIn("lab-d50");
|
|
119
|
+
const L = fmtNum(coords[0], precision);
|
|
120
|
+
const a = fmtNum(coords[1], precision);
|
|
121
|
+
const b = fmtNum(coords[2], precision);
|
|
122
|
+
if (swatch.alpha < 1) {
|
|
123
|
+
return `lab(${L} ${a} ${b} / ${fmtAlpha(swatch.alpha, precision)})`;
|
|
124
|
+
}
|
|
125
|
+
return `lab(${L} ${a} ${b})`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function toLch(swatch, precision) {
|
|
129
|
+
const coords = swatch._getCoordsIn("lch-d50");
|
|
130
|
+
const L = fmtNum(coords[0], precision);
|
|
131
|
+
const C = fmtNum(coords[1], precision);
|
|
132
|
+
const H = fmtNum(coords[2], precision);
|
|
133
|
+
if (swatch.alpha < 1) {
|
|
134
|
+
return `lch(${L} ${C} ${H} / ${fmtAlpha(swatch.alpha, precision)})`;
|
|
135
|
+
}
|
|
136
|
+
return `lch(${L} ${C} ${H})`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toOklab(swatch, precision) {
|
|
140
|
+
const { l, a, b } = swatch.oklab;
|
|
141
|
+
const L = fmtNum(l, precision);
|
|
142
|
+
const A = fmtNum(a, precision);
|
|
143
|
+
const B = fmtNum(b, precision);
|
|
144
|
+
if (swatch.alpha < 1) {
|
|
145
|
+
return `oklab(${L} ${A} ${B} / ${fmtAlpha(swatch.alpha, precision)})`;
|
|
146
|
+
}
|
|
147
|
+
return `oklab(${L} ${A} ${B})`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function toOklch(swatch, precision) {
|
|
151
|
+
const { l, c, h } = swatch.oklch;
|
|
152
|
+
const L = fmtNum(l, precision);
|
|
153
|
+
const C = fmtNum(c, precision);
|
|
154
|
+
const H = fmtNum(h, precision);
|
|
155
|
+
if (swatch.alpha < 1) {
|
|
156
|
+
return `oklch(${L} ${C} ${H} / ${fmtAlpha(swatch.alpha, precision)})`;
|
|
157
|
+
}
|
|
158
|
+
return `oklch(${L} ${C} ${H})`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Map registry ids back to CSS color() space tokens.
|
|
162
|
+
const SPACE_TO_COLOR_FN = {
|
|
163
|
+
srgb: "srgb",
|
|
164
|
+
"srgb-linear": "srgb-linear",
|
|
165
|
+
"display-p3": "display-p3",
|
|
166
|
+
rec2020: "rec2020",
|
|
167
|
+
a98: "a98-rgb",
|
|
168
|
+
prophoto: "prophoto-rgb",
|
|
169
|
+
xyz: "xyz-d65",
|
|
170
|
+
"xyz-d65": "xyz-d65",
|
|
171
|
+
"xyz-d50": "xyz-d50"
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
function toColorFn(swatch, precision, spaceOverride) {
|
|
175
|
+
const spaceId = spaceOverride || swatch.space;
|
|
176
|
+
const token = SPACE_TO_COLOR_FN[spaceId];
|
|
177
|
+
if (!token) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`formatCss: space "${spaceId}" has no color() serialization`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const coords = swatch._getCoordsIn(spaceId);
|
|
183
|
+
const c1 = fmtNum(coords[0], precision);
|
|
184
|
+
const c2 = fmtNum(coords[1], precision);
|
|
185
|
+
const c3 = fmtNum(coords[2], precision);
|
|
186
|
+
if (swatch.alpha < 1) {
|
|
187
|
+
return `color(${token} ${c1} ${c2} ${c3} / ${fmtAlpha(swatch.alpha, precision)})`;
|
|
188
|
+
}
|
|
189
|
+
return `color(${token} ${c1} ${c2} ${c3})`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Pick a default format for a swatch based on its source space.
|
|
193
|
+
function defaultFormat(swatch) {
|
|
194
|
+
switch (swatch.space) {
|
|
195
|
+
case "srgb":
|
|
196
|
+
return swatch.alpha < 1 ? "rgb" : "hex";
|
|
197
|
+
case "hsl":
|
|
198
|
+
return "hsl";
|
|
199
|
+
case "hwb":
|
|
200
|
+
return "hwb";
|
|
201
|
+
case "lab":
|
|
202
|
+
case "lab-d50":
|
|
203
|
+
return "lab";
|
|
204
|
+
case "lch":
|
|
205
|
+
case "lch-d50":
|
|
206
|
+
return "lch";
|
|
207
|
+
case "oklab":
|
|
208
|
+
return "oklab";
|
|
209
|
+
case "oklch":
|
|
210
|
+
return "oklch";
|
|
211
|
+
case "display-p3":
|
|
212
|
+
case "rec2020":
|
|
213
|
+
case "a98":
|
|
214
|
+
case "prophoto":
|
|
215
|
+
case "srgb-linear":
|
|
216
|
+
case "xyz":
|
|
217
|
+
case "xyz-d65":
|
|
218
|
+
case "xyz-d50":
|
|
219
|
+
return "color";
|
|
220
|
+
default:
|
|
221
|
+
return "hex";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function formatCss(swatch, opts = {}) {
|
|
226
|
+
const precision = opts.precision ?? DEFAULT_PRECISION;
|
|
227
|
+
const format = opts.format ?? defaultFormat(swatch);
|
|
228
|
+
switch (format) {
|
|
229
|
+
case "hex":
|
|
230
|
+
return toHex(swatch, false);
|
|
231
|
+
case "hex-alpha":
|
|
232
|
+
return toHex(swatch, true);
|
|
233
|
+
case "rgb":
|
|
234
|
+
return toRgbModern(swatch, precision);
|
|
235
|
+
case "rgb-legacy":
|
|
236
|
+
return toRgbLegacy(swatch, precision);
|
|
237
|
+
case "hsl":
|
|
238
|
+
return toHslModern(swatch, precision);
|
|
239
|
+
case "hsl-legacy":
|
|
240
|
+
return toHslLegacy(swatch, precision);
|
|
241
|
+
case "hwb":
|
|
242
|
+
return toHwb(swatch, precision);
|
|
243
|
+
case "lab":
|
|
244
|
+
return toLab(swatch, precision);
|
|
245
|
+
case "lch":
|
|
246
|
+
return toLch(swatch, precision);
|
|
247
|
+
case "oklab":
|
|
248
|
+
return toOklab(swatch, precision);
|
|
249
|
+
case "oklch":
|
|
250
|
+
return toOklch(swatch, precision);
|
|
251
|
+
case "color":
|
|
252
|
+
return toColorFn(swatch, precision, opts.space);
|
|
253
|
+
default:
|
|
254
|
+
throw new Error(`formatCss: unknown format "${format}"`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// WCAG 2.1 accessibility helpers.
|
|
2
|
+
//
|
|
3
|
+
// luminance(c) — relative luminance (Y) per WCAG 2.1
|
|
4
|
+
// contrast(a, b) — WCAG contrast ratio, symmetric, clamped ≥ 1
|
|
5
|
+
// isReadable(a, b, { level, size }) — checks WCAG AA/AAA thresholds
|
|
6
|
+
// ensureContrast(a, b, { minRatio, direction, step }) —
|
|
7
|
+
// walks HSL L (preserving hue/saturation) until the ratio meets
|
|
8
|
+
// the target. Future: add { space: 'oklch' } to walk OKLCh L.
|
|
9
|
+
//
|
|
10
|
+
// These operate on displayable sRGB values. Wide-gamut inputs are
|
|
11
|
+
// first gamut-mapped into sRGB before the metric is computed.
|
|
12
|
+
|
|
13
|
+
import { Swatch, swatch } from "../core/swatch-class.js";
|
|
14
|
+
import { inGamut, toGamut } from "./gamut.js";
|
|
15
|
+
|
|
16
|
+
function toSwatch(input) {
|
|
17
|
+
return input instanceof Swatch ? input : swatch(input);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toAccessibleSrgb(input) {
|
|
21
|
+
const s = toSwatch(input);
|
|
22
|
+
if (inGamut(s, "srgb")) return s.to("srgb");
|
|
23
|
+
return toGamut(s, { space: "srgb" });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// WCAG 2.1 relative luminance from gamma-encoded sRGB in [0,1].
|
|
27
|
+
export function luminance(input) {
|
|
28
|
+
const s = toAccessibleSrgb(input);
|
|
29
|
+
const { r, g, b } = s.srgb;
|
|
30
|
+
const rl =
|
|
31
|
+
r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
|
|
32
|
+
const gl =
|
|
33
|
+
g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
|
|
34
|
+
const bl =
|
|
35
|
+
b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);
|
|
36
|
+
return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function contrast(a, b) {
|
|
40
|
+
const la = luminance(a);
|
|
41
|
+
const lb = luminance(b);
|
|
42
|
+
const ratio = (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
43
|
+
return ratio;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Thresholds:
|
|
47
|
+
// normal AA = 4.5, normal AAA = 7
|
|
48
|
+
// large AA = 3, large AAA = 4.5
|
|
49
|
+
// ui = 3 (AA only per WCAG 2.1)
|
|
50
|
+
export function isReadable(a, b, { level = "AA", size = "normal" } = {}) {
|
|
51
|
+
let threshold;
|
|
52
|
+
if (size === "large") {
|
|
53
|
+
threshold = level === "AAA" ? 4.5 : 3;
|
|
54
|
+
} else if (size === "ui") {
|
|
55
|
+
threshold = 3;
|
|
56
|
+
} else {
|
|
57
|
+
threshold = level === "AAA" ? 7 : 4.5;
|
|
58
|
+
}
|
|
59
|
+
return contrast(a, b) >= threshold;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Walk the HSL L of `color` until contrast against `other` meets
|
|
63
|
+
// `minRatio`. If one direction fails, try the other; if neither
|
|
64
|
+
// succeeds, fall back to pure white or black.
|
|
65
|
+
export function ensureContrast(
|
|
66
|
+
color,
|
|
67
|
+
other,
|
|
68
|
+
{ minRatio = 4.5, direction = "auto", step = 1 } = {}
|
|
69
|
+
) {
|
|
70
|
+
const original = toSwatch(color);
|
|
71
|
+
const cs = toAccessibleSrgb(color);
|
|
72
|
+
const os = toAccessibleSrgb(other);
|
|
73
|
+
|
|
74
|
+
if (contrast(cs, os) >= minRatio) return original;
|
|
75
|
+
|
|
76
|
+
let dir = direction;
|
|
77
|
+
if (dir === "auto") {
|
|
78
|
+
dir = luminance(os) > 0.5 ? "darker" : "lighter";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const baseHsl = cs.hsl;
|
|
82
|
+
|
|
83
|
+
function tryWalk(sign) {
|
|
84
|
+
let l = baseHsl.l;
|
|
85
|
+
while (true) {
|
|
86
|
+
l += sign * step;
|
|
87
|
+
if (l < 0 || l > 100) return null;
|
|
88
|
+
const candidate = swatch({
|
|
89
|
+
space: "hsl",
|
|
90
|
+
coords: [baseHsl.h, baseHsl.s, l],
|
|
91
|
+
alpha: cs.alpha
|
|
92
|
+
});
|
|
93
|
+
if (contrast(candidate, os) >= minRatio) return candidate;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const primary = tryWalk(dir === "lighter" ? 1 : -1);
|
|
98
|
+
if (primary) return primary;
|
|
99
|
+
const fallback = tryWalk(dir === "lighter" ? -1 : 1);
|
|
100
|
+
if (fallback) return fallback;
|
|
101
|
+
|
|
102
|
+
return swatch(luminance(os) > 0.5 ? "#000000" : "#ffffff");
|
|
103
|
+
}
|