@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CONTRIBUTING.md +89 -0
  3. package/LICENSE +21 -0
  4. package/MIGRATING.md +189 -0
  5. package/README.md +463 -0
  6. package/package.json +57 -0
  7. package/scripts/pack-check.mjs +18 -0
  8. package/src/bootstrap.js +159 -0
  9. package/src/core/registry.js +81 -0
  10. package/src/core/state.js +36 -0
  11. package/src/core/swatch-class.js +524 -0
  12. package/src/data/cvd-matrices.js +179 -0
  13. package/src/data/named-colors.js +157 -0
  14. package/src/format/css.js +256 -0
  15. package/src/operations/accessibility.js +103 -0
  16. package/src/operations/apca.js +80 -0
  17. package/src/operations/blend.js +72 -0
  18. package/src/operations/channels.js +123 -0
  19. package/src/operations/cvd.js +119 -0
  20. package/src/operations/deltaE.js +207 -0
  21. package/src/operations/gamut.js +206 -0
  22. package/src/operations/image.js +192 -0
  23. package/src/operations/manipulation.js +100 -0
  24. package/src/operations/mix.js +129 -0
  25. package/src/operations/naming.js +158 -0
  26. package/src/operations/palette.js +133 -0
  27. package/src/operations/random.js +75 -0
  28. package/src/operations/temperature.js +126 -0
  29. package/src/operations/tint-shade.js +42 -0
  30. package/src/palettes/colorbrewer.js +232 -0
  31. package/src/palettes/index.js +58 -0
  32. package/src/palettes/viridis.js +59 -0
  33. package/src/parse/css.js +241 -0
  34. package/src/parse/hex.js +38 -0
  35. package/src/parse/index.js +43 -0
  36. package/src/parse/legacy.js +88 -0
  37. package/src/parse/named.js +11 -0
  38. package/src/parse/objects.js +125 -0
  39. package/src/scale/index.js +382 -0
  40. package/src/scale/interpolators.js +83 -0
  41. package/src/spaces/a98.js +55 -0
  42. package/src/spaces/cmyk.js +75 -0
  43. package/src/spaces/display-p3.js +50 -0
  44. package/src/spaces/hsl.js +93 -0
  45. package/src/spaces/hsluv.js +211 -0
  46. package/src/spaces/hsv.js +78 -0
  47. package/src/spaces/hwb.js +48 -0
  48. package/src/spaces/lab.js +70 -0
  49. package/src/spaces/lch.js +65 -0
  50. package/src/spaces/oklab.js +79 -0
  51. package/src/spaces/oklch.js +53 -0
  52. package/src/spaces/prophoto.js +72 -0
  53. package/src/spaces/rec2020.js +65 -0
  54. package/src/spaces/srgb.js +85 -0
  55. package/src/spaces/xyz.js +71 -0
  56. package/src/swatch.js +57 -0
  57. package/src/util/math.js +53 -0
  58. package/src/util/matrix.js +92 -0
  59. package/src/util/suggest.js +66 -0
  60. 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
+ }