@trishchuk/coolors-mcp 1.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 (197) hide show
  1. package/.claude/settings.local.json +39 -0
  2. package/.env +2 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +73 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +71 -0
  5. package/.github/pull_request_template.md +97 -0
  6. package/.github/workflows/ci.yml +127 -0
  7. package/.github/workflows/deploy-docs.yml +56 -0
  8. package/.github/workflows/release.yml +99 -0
  9. package/.mcp.json +12 -0
  10. package/.prettierignore +1 -0
  11. package/CLAUDE.md +201 -0
  12. package/DOCUMENTATION.md +274 -0
  13. package/GEMINI.md +54 -0
  14. package/LICENSE +21 -0
  15. package/README.md +401 -0
  16. package/demo/content_based_color.png +0 -0
  17. package/demo/music-player.html +621 -0
  18. package/demo/podcast-player.html +903 -0
  19. package/dist/bin/coolors-mcp.d.ts +1 -0
  20. package/dist/bin/coolors-mcp.js +154 -0
  21. package/dist/bin/coolors-mcp.js.map +1 -0
  22. package/dist/bin/server.d.ts +1 -0
  23. package/dist/bin/server.js +3292 -0
  24. package/dist/bin/server.js.map +1 -0
  25. package/dist/chunk-IQ7NN26V.js +114 -0
  26. package/dist/chunk-IQ7NN26V.js.map +1 -0
  27. package/dist/chunk-P3ARRKLS.js +1214 -0
  28. package/dist/chunk-P3ARRKLS.js.map +1 -0
  29. package/dist/color/index.d.ts +716 -0
  30. package/dist/color/index.js +153 -0
  31. package/dist/color/index.js.map +1 -0
  32. package/dist/coolors-mcp.d.ts +136 -0
  33. package/dist/coolors-mcp.js +7 -0
  34. package/dist/coolors-mcp.js.map +1 -0
  35. package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js +93 -0
  36. package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js.map +7 -0
  37. package/docs/.vitepress/cache/deps/_metadata.json +127 -0
  38. package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js +9 -0
  39. package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js.map +7 -0
  40. package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js +12683 -0
  41. package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js.map +7 -0
  42. package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js +9719 -0
  43. package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js.map +7 -0
  44. package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js +4710 -0
  45. package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js.map +7 -0
  46. package/docs/.vitepress/cache/deps/cytoscape.js +30278 -0
  47. package/docs/.vitepress/cache/deps/cytoscape.js.map +7 -0
  48. package/docs/.vitepress/cache/deps/dayjs.js +285 -0
  49. package/docs/.vitepress/cache/deps/dayjs.js.map +7 -0
  50. package/docs/.vitepress/cache/deps/debug.js +468 -0
  51. package/docs/.vitepress/cache/deps/debug.js.map +7 -0
  52. package/docs/.vitepress/cache/deps/package.json +3 -0
  53. package/docs/.vitepress/cache/deps/prismjs.js +1466 -0
  54. package/docs/.vitepress/cache/deps/prismjs.js.map +7 -0
  55. package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js +228 -0
  56. package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js.map +7 -0
  57. package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js +142 -0
  58. package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js.map +7 -0
  59. package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js +27 -0
  60. package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js.map +7 -0
  61. package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js +65 -0
  62. package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js.map +7 -0
  63. package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js +53 -0
  64. package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js.map +7 -0
  65. package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js +73 -0
  66. package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js.map +7 -0
  67. package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js +4507 -0
  68. package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map +7 -0
  69. package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js +584 -0
  70. package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map +7 -0
  71. package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js +1146 -0
  72. package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js.map +7 -0
  73. package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js +1667 -0
  74. package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js.map +7 -0
  75. package/docs/.vitepress/cache/deps/vitepress___minisearch.js +1814 -0
  76. package/docs/.vitepress/cache/deps/vitepress___minisearch.js.map +7 -0
  77. package/docs/.vitepress/cache/deps/vue.js +344 -0
  78. package/docs/.vitepress/cache/deps/vue.js.map +7 -0
  79. package/docs/.vitepress/components/ClientGrid.vue +125 -0
  80. package/docs/.vitepress/components/CodeBlock.vue +231 -0
  81. package/docs/.vitepress/components/ConfigModal.vue +477 -0
  82. package/docs/.vitepress/components/DiagramModal.vue +528 -0
  83. package/docs/.vitepress/components/TroubleshootingModal.vue +472 -0
  84. package/docs/.vitepress/config.js +162 -0
  85. package/docs/.vitepress/theme/FundingLayout.vue +251 -0
  86. package/docs/.vitepress/theme/Layout.vue +134 -0
  87. package/docs/.vitepress/theme/components/AdBanner.vue +317 -0
  88. package/docs/.vitepress/theme/components/AdPlaceholder.vue +78 -0
  89. package/docs/.vitepress/theme/components/FundingEffects.vue +322 -0
  90. package/docs/.vitepress/theme/components/FundingHero.vue +345 -0
  91. package/docs/.vitepress/theme/components/SupportSection.vue +511 -0
  92. package/docs/.vitepress/theme/custom-app.css +339 -0
  93. package/docs/.vitepress/theme/custom.css +699 -0
  94. package/docs/.vitepress/theme/index.js +25 -0
  95. package/docs/README.md +198 -0
  96. package/docs/concepts/accessibility.md +473 -0
  97. package/docs/concepts/color-spaces.md +222 -0
  98. package/docs/concepts/distance-metrics.md +384 -0
  99. package/docs/concepts/hct.md +261 -0
  100. package/docs/concepts/image-analysis.md +396 -0
  101. package/docs/concepts/material-design.md +306 -0
  102. package/docs/concepts/theme-matching.md +399 -0
  103. package/docs/examples/basic-colors.md +490 -0
  104. package/docs/examples/creating-themes.md +898 -0
  105. package/docs/examples/css-refactoring.md +824 -0
  106. package/docs/examples/image-extraction.md +882 -0
  107. package/docs/getting-started.md +366 -0
  108. package/docs/index.md +190 -0
  109. package/docs/installation.md +157 -0
  110. package/docs/tools/README.md +234 -0
  111. package/docs/tools/accessibility.md +614 -0
  112. package/docs/tools/color-operations.md +374 -0
  113. package/docs/tools/image-extraction.md +624 -0
  114. package/docs/tools/material-design.md +347 -0
  115. package/docs/tools/theme-matching.md +552 -0
  116. package/eslint.config.ts +14 -0
  117. package/examples/theme-matching.md +113 -0
  118. package/jsr.json +7 -0
  119. package/mcp-config.json +8 -0
  120. package/note.md +35 -0
  121. package/package.json +122 -0
  122. package/research_results.md +53 -0
  123. package/src/bin/coolors-mcp.ts +194 -0
  124. package/src/bin/server.ts +61 -0
  125. package/src/color/__tests__/conversions-argb.test.ts +198 -0
  126. package/src/color/__tests__/extract-colors.test.ts +360 -0
  127. package/src/color/__tests__/image-utils.test.ts +242 -0
  128. package/src/color/__tests__/reference-colors.test.ts +278 -0
  129. package/src/color/__tests__/round-trip.test.ts +197 -0
  130. package/src/color/conversions.test.ts +402 -0
  131. package/src/color/conversions.ts +393 -0
  132. package/src/color/dislike/__tests__/dislike-analyzer.test.ts +248 -0
  133. package/src/color/dislike/dislike-analyzer.ts +114 -0
  134. package/src/color/extract-colors.ts +228 -0
  135. package/src/color/hct/__tests__/hct-class.test.ts +232 -0
  136. package/src/color/hct/harmonization.ts +204 -0
  137. package/src/color/hct/hct-class.ts +109 -0
  138. package/src/color/hct/hct-solver.ts +168 -0
  139. package/src/color/hct/index.ts +39 -0
  140. package/src/color/hct/tonal-palette.ts +211 -0
  141. package/src/color/hct/types.ts +88 -0
  142. package/src/color/image-utils.ts +79 -0
  143. package/src/color/index.ts +87 -0
  144. package/src/color/material-theme.ts +157 -0
  145. package/src/color/metrics.test.ts +276 -0
  146. package/src/color/metrics.ts +281 -0
  147. package/src/color/quantize/__tests__/quantizer_celebi.test.ts +195 -0
  148. package/src/color/quantize/lab_point_provider.ts +55 -0
  149. package/src/color/quantize/point_provider.ts +27 -0
  150. package/src/color/quantize/quantizer_celebi.ts +51 -0
  151. package/src/color/quantize/quantizer_celebi_test.ts +71 -0
  152. package/src/color/quantize/quantizer_map.ts +47 -0
  153. package/src/color/quantize/quantizer_wsmeans.ts +232 -0
  154. package/src/color/quantize/quantizer_wu.ts +472 -0
  155. package/src/color/score/__tests__/score.test.ts +224 -0
  156. package/src/color/score/score.ts +175 -0
  157. package/src/color/types.ts +151 -0
  158. package/src/color/utils/color_utils.ts +292 -0
  159. package/src/color/utils/math_utils.ts +145 -0
  160. package/src/color/utils.test.ts +403 -0
  161. package/src/color/utils.ts +315 -0
  162. package/src/constants.ts +5 -0
  163. package/src/coolors-mcp.ts +37 -0
  164. package/src/examples/addition.ts +333 -0
  165. package/src/examples/color-demo.ts +125 -0
  166. package/src/examples/custom-logger.ts +201 -0
  167. package/src/examples/oauth-server.ts +113 -0
  168. package/src/examples/session-context.ts +269 -0
  169. package/src/session.ts +116 -0
  170. package/src/theme/__tests__/matcher.test.ts +180 -0
  171. package/src/theme/__tests__/parser.test.ts +148 -0
  172. package/src/theme/__tests__/refactor.test.ts +224 -0
  173. package/src/theme/index.ts +34 -0
  174. package/src/theme/matcher.ts +395 -0
  175. package/src/theme/parser.ts +392 -0
  176. package/src/theme/refactor.ts +360 -0
  177. package/src/theme/types.ts +152 -0
  178. package/src/tools/__tests__/gradient-generator.test.ts +206 -0
  179. package/src/tools/__tests__/palette-with-locks.test.ts +109 -0
  180. package/src/tools/color-conversion.tool.ts +54 -0
  181. package/src/tools/color-distance.tool.ts +41 -0
  182. package/src/tools/colors.ts +31 -0
  183. package/src/tools/contrast-checker.tool.ts +37 -0
  184. package/src/tools/dislike-analyzer.tool.ts +247 -0
  185. package/src/tools/gradient-generator.tool.ts +250 -0
  186. package/src/tools/image-extraction.tools.ts +289 -0
  187. package/src/tools/index.ts +39 -0
  188. package/src/tools/material-theme.tools.ts +250 -0
  189. package/src/tools/palette-generator.tool.ts +135 -0
  190. package/src/tools/palette-with-locks.tool.ts +221 -0
  191. package/src/tools/registry.ts +142 -0
  192. package/src/tools/simple-tools.ts +37 -0
  193. package/src/tools/theme-matching.tools.ts +334 -0
  194. package/src/types.ts +182 -0
  195. package/src/utils.ts +22 -0
  196. package/tsconfig.json +8 -0
  197. package/vitest.config.js +15 -0
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Color space conversion functions
3
+ */
4
+
5
+ import type { HEX, HSL, HSV, LAB, RGB, XYZ } from "./types.js";
6
+
7
+ import { ColorConstants } from "./types.js";
8
+
9
+ /**
10
+ * Convert ARGB format to RGB
11
+ */
12
+ export function argbToRgb(argb: number): RGB {
13
+ const r = (argb >> 16) & 0xff;
14
+ const g = (argb >> 8) & 0xff;
15
+ const b = argb & 0xff;
16
+ return { b, g, r };
17
+ }
18
+
19
+ /**
20
+ * Convert hexadecimal to RGB color
21
+ */
22
+ export function hexToRgb(hex: HEX): RGB {
23
+ // Remove # if present
24
+ const cleanHex = hex.replace("#", "");
25
+
26
+ // Handle 3-digit hex
27
+ const fullHex =
28
+ cleanHex.length === 3
29
+ ? cleanHex
30
+ .split("")
31
+ .map((c) => c + c)
32
+ .join("")
33
+ : cleanHex;
34
+
35
+ const num = parseInt(fullHex, 16);
36
+
37
+ return {
38
+ b: num & 255,
39
+ g: (num >> 8) & 255,
40
+ r: (num >> 16) & 255,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Convert HSL to RGB color space
46
+ */
47
+ export function hslToRgb(hsl: HSL): RGB {
48
+ const h = hsl.h / 360;
49
+ const s = hsl.s / 100;
50
+ const l = hsl.l / 100;
51
+
52
+ let b: number, g: number, r: number;
53
+
54
+ if (s === 0) {
55
+ r = g = b = l; // achromatic
56
+ } else {
57
+ const hue2rgb = (p: number, q: number, t: number): number => {
58
+ if (t < 0) t += 1;
59
+ if (t > 1) t -= 1;
60
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
61
+ if (t < 1 / 2) return q;
62
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
63
+ return p;
64
+ };
65
+
66
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
67
+ const p = 2 * l - q;
68
+
69
+ r = hue2rgb(p, q, h + 1 / 3);
70
+ g = hue2rgb(p, q, h);
71
+ b = hue2rgb(p, q, h - 1 / 3);
72
+ }
73
+
74
+ return {
75
+ b: Math.round(b * 255),
76
+ g: Math.round(g * 255),
77
+ r: Math.round(r * 255),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Convert HSV to RGB color space
83
+ */
84
+ export function hsvToRgb(hsv: HSV): RGB {
85
+ const h = hsv.h / 360;
86
+ const s = hsv.s / 100;
87
+ const v = hsv.v / 100;
88
+
89
+ const i = Math.floor(h * 6);
90
+ const f = h * 6 - i;
91
+ const p = v * (1 - s);
92
+ const q = v * (1 - f * s);
93
+ const t = v * (1 - (1 - f) * s);
94
+
95
+ let b: number, g: number, r: number;
96
+
97
+ switch (i % 6) {
98
+ case 0:
99
+ r = v;
100
+ g = t;
101
+ b = p;
102
+ break;
103
+ case 1:
104
+ r = q;
105
+ g = v;
106
+ b = p;
107
+ break;
108
+ case 2:
109
+ r = p;
110
+ g = v;
111
+ b = t;
112
+ break;
113
+ case 3:
114
+ r = p;
115
+ g = q;
116
+ b = v;
117
+ break;
118
+ case 4:
119
+ r = t;
120
+ g = p;
121
+ b = v;
122
+ break;
123
+ case 5:
124
+ r = v;
125
+ g = p;
126
+ b = q;
127
+ break;
128
+ default:
129
+ r = 0;
130
+ g = 0;
131
+ b = 0;
132
+ }
133
+
134
+ return {
135
+ b: Math.round(b * 255),
136
+ g: Math.round(g * 255),
137
+ r: Math.round(r * 255),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Convert LAB to RGB color space
143
+ */
144
+ export function labToRgb(lab: LAB): RGB {
145
+ return xyzToRgb(labToXyz(lab));
146
+ }
147
+
148
+ /**
149
+ * Convert LAB to XYZ color space
150
+ */
151
+ export function labToXyz(lab: LAB): XYZ {
152
+ const { D65, EPSILON, KAPPA } = ColorConstants;
153
+
154
+ const fy = (lab.l + 16) / 116;
155
+ const fx = lab.a / 500 + fy;
156
+ const fz = fy - lab.b / 200;
157
+
158
+ const x3 = Math.pow(fx, 3);
159
+ const y3 = Math.pow(fy, 3);
160
+ const z3 = Math.pow(fz, 3);
161
+
162
+ const x = x3 > EPSILON ? x3 : (116 * fx - 16) / KAPPA;
163
+ const y = lab.l > KAPPA * EPSILON ? y3 : lab.l / KAPPA;
164
+ const z = z3 > EPSILON ? z3 : (116 * fz - 16) / KAPPA;
165
+
166
+ return {
167
+ x: x * D65.X,
168
+ y: y * D65.Y,
169
+ z: z * D65.Z,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Parse color string to RGB
175
+ * Supports: hex (#fff, #ffffff), rgb(r,g,b), hsl(h,s,l)
176
+ */
177
+ export function parseColor(color: string): null | RGB {
178
+ const trimmed = color.trim().toLowerCase();
179
+
180
+ // Hex color
181
+ if (trimmed.startsWith("#")) {
182
+ try {
183
+ return hexToRgb(trimmed);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ // RGB color
190
+ const rgbMatch = trimmed.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/);
191
+ if (rgbMatch) {
192
+ return {
193
+ b: parseInt(rgbMatch[3], 10),
194
+ g: parseInt(rgbMatch[2], 10),
195
+ r: parseInt(rgbMatch[1], 10),
196
+ };
197
+ }
198
+
199
+ // HSL color
200
+ const hslMatch = trimmed.match(/^hsla?\((\d+),\s*(\d+)%,\s*(\d+)%/);
201
+ if (hslMatch) {
202
+ return hslToRgb({
203
+ h: parseInt(hslMatch[1], 10),
204
+ l: parseInt(hslMatch[3], 10),
205
+ s: parseInt(hslMatch[2], 10),
206
+ });
207
+ }
208
+
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * Convert RGB to ARGB format (32-bit integer)
214
+ */
215
+ export function rgbToArgb(rgb: RGB): number {
216
+ const r = Math.round(Math.max(0, Math.min(255, rgb.r)));
217
+ const g = Math.round(Math.max(0, Math.min(255, rgb.g)));
218
+ const b = Math.round(Math.max(0, Math.min(255, rgb.b)));
219
+ // Use >>> 0 to convert to unsigned 32-bit integer
220
+ return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0;
221
+ }
222
+
223
+ /**
224
+ * Convert RGB to hexadecimal color
225
+ */
226
+ export function rgbToHex(rgb: RGB): HEX {
227
+ const toHex = (n: number): string => {
228
+ const hex = Math.round(Math.max(0, Math.min(255, n))).toString(16);
229
+ return hex.length === 1 ? "0" + hex : hex;
230
+ };
231
+
232
+ return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
233
+ }
234
+
235
+ /**
236
+ * Convert RGB to HSL color space
237
+ */
238
+ export function rgbToHsl(rgb: RGB): HSL {
239
+ const r = rgb.r / 255;
240
+ const g = rgb.g / 255;
241
+ const b = rgb.b / 255;
242
+
243
+ const max = Math.max(r, g, b);
244
+ const min = Math.min(r, g, b);
245
+ const diff = max - min;
246
+
247
+ let h = 0;
248
+ let s = 0;
249
+ const l = (max + min) / 2;
250
+
251
+ if (diff !== 0) {
252
+ s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min);
253
+
254
+ switch (max) {
255
+ case b:
256
+ h = ((r - g) / diff + 4) / 6;
257
+ break;
258
+ case g:
259
+ h = ((b - r) / diff + 2) / 6;
260
+ break;
261
+ case r:
262
+ h = ((g - b) / diff + (g < b ? 6 : 0)) / 6;
263
+ break;
264
+ }
265
+ }
266
+
267
+ return {
268
+ h: Math.round(h * 360),
269
+ l: Math.round(l * 100),
270
+ s: Math.round(s * 100),
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Convert RGB to HSV color space
276
+ */
277
+ export function rgbToHsv(rgb: RGB): HSV {
278
+ const r = rgb.r / 255;
279
+ const g = rgb.g / 255;
280
+ const b = rgb.b / 255;
281
+
282
+ const max = Math.max(r, g, b);
283
+ const min = Math.min(r, g, b);
284
+ const diff = max - min;
285
+
286
+ let h = 0;
287
+ const s = max === 0 ? 0 : diff / max;
288
+ const v = max;
289
+
290
+ if (diff !== 0) {
291
+ switch (max) {
292
+ case b:
293
+ h = ((r - g) / diff + 4) / 6;
294
+ break;
295
+ case g:
296
+ h = ((b - r) / diff + 2) / 6;
297
+ break;
298
+ case r:
299
+ h = ((g - b) / diff + (g < b ? 6 : 0)) / 6;
300
+ break;
301
+ }
302
+ }
303
+
304
+ return {
305
+ h: Math.round(h * 360),
306
+ s: Math.round(s * 100),
307
+ v: Math.round(v * 100),
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Convert RGB to LAB color space
313
+ */
314
+ export function rgbToLab(rgb: RGB): LAB {
315
+ return xyzToLab(rgbToXyz(rgb));
316
+ }
317
+
318
+ /**
319
+ * Convert RGB to XYZ color space
320
+ * Using sRGB working space and D65 illuminant
321
+ */
322
+ export function rgbToXyz(rgb: RGB): XYZ {
323
+ let r = rgb.r / 255;
324
+ let g = rgb.g / 255;
325
+ let b = rgb.b / 255;
326
+
327
+ // Apply gamma correction
328
+ r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92;
329
+ g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92;
330
+ b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92;
331
+
332
+ // Multiply by 100 to get standard XYZ values
333
+ r *= 100;
334
+ g *= 100;
335
+ b *= 100;
336
+
337
+ // Apply transformation matrix (sRGB to XYZ)
338
+ return {
339
+ x: r * 0.4124564 + g * 0.3575761 + b * 0.1804375,
340
+ y: r * 0.2126729 + g * 0.7151522 + b * 0.072175,
341
+ z: r * 0.0193339 + g * 0.119192 + b * 0.9503041,
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Convert XYZ to LAB color space
347
+ * Using D65 illuminant
348
+ */
349
+ export function xyzToLab(xyz: XYZ): LAB {
350
+ const { D65, EPSILON, KAPPA } = ColorConstants;
351
+
352
+ // Normalize by reference white
353
+ const x = xyz.x / D65.X;
354
+ const y = xyz.y / D65.Y;
355
+ const z = xyz.z / D65.Z;
356
+
357
+ // Apply transformation
358
+ const fx = x > EPSILON ? Math.cbrt(x) : (KAPPA * x + 16) / 116;
359
+ const fy = y > EPSILON ? Math.cbrt(y) : (KAPPA * y + 16) / 116;
360
+ const fz = z > EPSILON ? Math.cbrt(z) : (KAPPA * z + 16) / 116;
361
+
362
+ return {
363
+ a: 500 * (fx - fy),
364
+ b: 200 * (fy - fz),
365
+ l: 116 * fy - 16,
366
+ };
367
+ }
368
+
369
+ /**
370
+ * Convert XYZ to RGB color space
371
+ */
372
+ export function xyzToRgb(xyz: XYZ): RGB {
373
+ // Normalize by 100
374
+ const x = xyz.x / 100;
375
+ const y = xyz.y / 100;
376
+ const z = xyz.z / 100;
377
+
378
+ // Apply inverse transformation matrix (XYZ to sRGB)
379
+ let r = x * 3.2404542 + y * -1.5371385 + z * -0.4985314;
380
+ let g = x * -0.969266 + y * 1.8760108 + z * 0.041556;
381
+ let b = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;
382
+
383
+ // Apply inverse gamma correction
384
+ r = r > 0.0031308 ? 1.055 * Math.pow(r, 1 / 2.4) - 0.055 : 12.92 * r;
385
+ g = g > 0.0031308 ? 1.055 * Math.pow(g, 1 / 2.4) - 0.055 : 12.92 * g;
386
+ b = b > 0.0031308 ? 1.055 * Math.pow(b, 1 / 2.4) - 0.055 : 12.92 * b;
387
+
388
+ return {
389
+ b: Math.round(Math.max(0, Math.min(255, b * 255))),
390
+ g: Math.round(Math.max(0, Math.min(255, g * 255))),
391
+ r: Math.round(Math.max(0, Math.min(255, r * 255))),
392
+ };
393
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Tests for DislikeAnalyzer - identifies and fixes universally disliked colors
3
+ */
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { Hct } from "../../hct/index.js";
8
+ import { DislikeAnalyzer } from "../dislike-analyzer";
9
+
10
+ describe("DislikeAnalyzer", () => {
11
+ describe("isDisliked", () => {
12
+ it("should like Monk Skin Tone Scale colors", () => {
13
+ // From https://skintone.google#/get-started
14
+ const monkSkinToneScaleColors = [
15
+ 0xfff6ede4, 0xfff3e7db, 0xfff7ead0, 0xffeadaba, 0xffd7bd96, 0xffa07e56,
16
+ 0xff825c43, 0xff604134, 0xff3a312a, 0xff292420,
17
+ ];
18
+
19
+ for (const color of monkSkinToneScaleColors) {
20
+ const hct = Hct.fromInt(color >>> 0);
21
+ expect(DislikeAnalyzer.isDisliked(hct)).toBe(false);
22
+ }
23
+ });
24
+
25
+ it("should dislike bile/waste colors", () => {
26
+ const unlikableColors = [
27
+ 0xff95884b, // Dark yellow-green (H:96)
28
+ 0xff716b40, // Dark olive (H:100)
29
+ 0xff9a8c00, // Dark yellow-green (H:91)
30
+ 0xff4c4308, // Very dark yellow-green (H:95)
31
+ 0xff464521, // Dark muddy green (H:104)
32
+ ];
33
+
34
+ for (const color of unlikableColors) {
35
+ const hct = Hct.fromInt(color >>> 0);
36
+ expect(DislikeAnalyzer.isDisliked(hct)).toBe(true);
37
+ }
38
+ });
39
+
40
+ it("should identify colors in the bile zone", () => {
41
+ // Colors with hue 90-111, chroma > 16, tone < 65 should be disliked
42
+ const bileZoneColor = Hct.from(100, 30, 50); // Middle of bile zone
43
+ expect(DislikeAnalyzer.isDisliked(bileZoneColor)).toBe(true);
44
+
45
+ const borderlineColor = Hct.from(95, 20, 60); // On the edge
46
+ expect(DislikeAnalyzer.isDisliked(borderlineColor)).toBe(true);
47
+ });
48
+
49
+ it("should not dislike colors outside the bile zone", () => {
50
+ // Test colors just outside the dislike zone
51
+ const hueOutsideLow = Hct.from(89, 30, 50); // Hue just below 90
52
+ const hueOutsideHigh = Hct.from(112, 30, 50); // Hue just above 111
53
+ const lowChroma = Hct.from(100, 15, 50); // Chroma below 16
54
+ const highTone = Hct.from(100, 30, 66); // Tone above 65
55
+
56
+ expect(DislikeAnalyzer.isDisliked(hueOutsideLow)).toBe(false);
57
+ expect(DislikeAnalyzer.isDisliked(hueOutsideHigh)).toBe(false);
58
+ expect(DislikeAnalyzer.isDisliked(lowChroma)).toBe(false);
59
+ expect(DislikeAnalyzer.isDisliked(highTone)).toBe(false);
60
+ });
61
+
62
+ it("should like neutral colors even in yellow-green hue range", () => {
63
+ // Low chroma colors should be liked even if hue is in the bile range
64
+ const neutral = Hct.from(100, 5, 50); // Very low chroma
65
+ expect(DislikeAnalyzer.isDisliked(neutral)).toBe(false);
66
+ });
67
+
68
+ it("should like light colors even with bile hue", () => {
69
+ // High tone colors should be liked
70
+ const lightColor = Hct.from(100, 30, 75); // Light tone
71
+ expect(DislikeAnalyzer.isDisliked(lightColor)).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe("fixIfDisliked", () => {
76
+ it("should fix disliked colors by lightening them", () => {
77
+ const unlikableColors = [
78
+ 0xff95884b,
79
+ 0xff716b40,
80
+ 0xff9a8c00, // Changed to ensure hue is in range
81
+ 0xff4c4308,
82
+ 0xff464521,
83
+ ];
84
+
85
+ for (const color of unlikableColors) {
86
+ const hct = Hct.fromInt(color >>> 0);
87
+ expect(DislikeAnalyzer.isDisliked(hct)).toBe(true);
88
+
89
+ const fixed = DislikeAnalyzer.fixIfDisliked(hct);
90
+ expect(DislikeAnalyzer.isDisliked(fixed)).toBe(false);
91
+
92
+ // Check that fix preserves hue and chroma, only changes tone
93
+ expect(fixed.hue).toBeCloseTo(hct.hue, 0);
94
+ expect(fixed.chroma).toBeCloseTo(hct.chroma, 0);
95
+ expect(fixed.tone).toBeCloseTo(70, 0); // Within 1 unit
96
+ }
97
+ });
98
+
99
+ it("should not change liked colors", () => {
100
+ const likedColor = Hct.from(200, 50, 50); // Blue, clearly liked
101
+ const fixed = DislikeAnalyzer.fixIfDisliked(likedColor);
102
+
103
+ expect(fixed.toInt()).toBe(likedColor.toInt());
104
+ expect(fixed).toBe(likedColor); // Should return same instance
105
+ });
106
+
107
+ it("should fix colors at tone 67 to tone 70", () => {
108
+ const color = Hct.from(100, 50, 67); // Above threshold but close
109
+ expect(DislikeAnalyzer.isDisliked(color)).toBe(false);
110
+
111
+ const fixed = DislikeAnalyzer.fixIfDisliked(color);
112
+ expect(fixed.toInt()).toBe(color.toInt());
113
+ });
114
+
115
+ it("should handle edge case at tone 64", () => {
116
+ const color = Hct.from(100, 30, 64); // Just below threshold
117
+ expect(DislikeAnalyzer.isDisliked(color)).toBe(true);
118
+
119
+ const fixed = DislikeAnalyzer.fixIfDisliked(color);
120
+ expect(fixed.tone).toBeCloseTo(70, 0); // Within 1 unit
121
+ expect(DislikeAnalyzer.isDisliked(fixed)).toBe(false);
122
+ });
123
+ });
124
+
125
+ describe("hex color helpers", () => {
126
+ it("should check hex colors for dislike", () => {
127
+ expect(DislikeAnalyzer.isDislikedHex("#95884B")).toBe(true);
128
+ expect(DislikeAnalyzer.isDislikedHex("#0080ff")).toBe(false);
129
+ });
130
+
131
+ it("should fix disliked hex colors", () => {
132
+ const disliked = "#95884B";
133
+ const fixed = DislikeAnalyzer.fixIfDislikedHex(disliked);
134
+
135
+ expect(fixed).not.toBe(disliked);
136
+ expect(DislikeAnalyzer.isDislikedHex(fixed)).toBe(false);
137
+ });
138
+
139
+ it("should return same hex for liked colors", () => {
140
+ const liked = "#0080ff";
141
+ const fixed = DislikeAnalyzer.fixIfDislikedHex(liked);
142
+
143
+ expect(fixed).toBe(liked);
144
+ });
145
+ });
146
+
147
+ describe("batch operations", () => {
148
+ it("should analyze batch of colors", () => {
149
+ const colors = [
150
+ Hct.from(100, 30, 50), // Disliked
151
+ Hct.from(200, 50, 50), // Liked
152
+ Hct.from(95, 20, 60), // Disliked
153
+ Hct.from(300, 40, 40), // Liked
154
+ ];
155
+
156
+ const analysis = DislikeAnalyzer.analyzeBatch(colors);
157
+
158
+ expect(analysis.total).toBe(4);
159
+ expect(analysis.disliked).toBe(2);
160
+ expect(analysis.percentage).toBe(50);
161
+ expect(analysis.dislikedIndices).toEqual([0, 2]);
162
+ });
163
+
164
+ it("should fix batch of colors", () => {
165
+ const colors = [
166
+ Hct.from(100, 30, 50), // Disliked
167
+ Hct.from(200, 50, 50), // Liked
168
+ Hct.from(95, 20, 60), // Disliked
169
+ ];
170
+
171
+ const fixed = DislikeAnalyzer.fixBatch(colors);
172
+
173
+ expect(fixed).toHaveLength(3);
174
+
175
+ // First color should be fixed
176
+ expect(fixed[0].tone).toBeCloseTo(70, 0); // Within 1 unit
177
+ expect(DislikeAnalyzer.isDisliked(fixed[0])).toBe(false);
178
+
179
+ // Second color should be unchanged
180
+ expect(fixed[1].toInt()).toBe(colors[1].toInt());
181
+
182
+ // Third color should be fixed
183
+ expect(fixed[2].tone).toBeCloseTo(70, 0); // Within 1 unit
184
+ expect(DislikeAnalyzer.isDisliked(fixed[2])).toBe(false);
185
+ });
186
+
187
+ it("should handle empty batch", () => {
188
+ const analysis = DislikeAnalyzer.analyzeBatch([]);
189
+
190
+ expect(analysis.total).toBe(0);
191
+ expect(analysis.disliked).toBe(0);
192
+ expect(analysis.percentage).toBeNaN(); // 0/0
193
+ expect(analysis.dislikedIndices).toEqual([]);
194
+
195
+ const fixed = DislikeAnalyzer.fixBatch([]);
196
+ expect(fixed).toEqual([]);
197
+ });
198
+
199
+ it("should handle batch with all disliked colors", () => {
200
+ const colors = [
201
+ Hct.from(100, 30, 50),
202
+ Hct.from(95, 20, 60),
203
+ Hct.from(105, 25, 45),
204
+ ];
205
+
206
+ const analysis = DislikeAnalyzer.analyzeBatch(colors);
207
+ expect(analysis.disliked).toBe(3);
208
+ expect(analysis.percentage).toBe(100);
209
+
210
+ const fixed = DislikeAnalyzer.fixBatch(colors);
211
+ const fixedAnalysis = DislikeAnalyzer.analyzeBatch(fixed);
212
+ expect(fixedAnalysis.disliked).toBe(0);
213
+ });
214
+ });
215
+
216
+ describe("real-world color examples", () => {
217
+ it("should handle olive green colors correctly", () => {
218
+ // Olive greens that might appear in military/outdoor themes
219
+ const oliveGreen = Hct.fromInt(0xff556b2f >>> 0); // DarkOliveGreen
220
+ const isOliveDisliked = DislikeAnalyzer.isDisliked(oliveGreen);
221
+
222
+ // This specific olive might be on the edge - test behavior
223
+ if (isOliveDisliked) {
224
+ const fixed = DislikeAnalyzer.fixIfDisliked(oliveGreen);
225
+ expect(fixed.tone).toBeGreaterThan(oliveGreen.tone);
226
+ }
227
+ });
228
+
229
+ it("should not dislike pleasant greens", () => {
230
+ // Forest green, mint green, etc. should be liked
231
+ const forestGreen = Hct.fromInt(0xff228b22 >>> 0);
232
+ const mintGreen = Hct.fromInt(0xff98ff98 >>> 0);
233
+
234
+ expect(DislikeAnalyzer.isDisliked(forestGreen)).toBe(false);
235
+ expect(DislikeAnalyzer.isDisliked(mintGreen)).toBe(false);
236
+ });
237
+
238
+ it("should handle brand colors appropriately", () => {
239
+ // Some brand yellows might be in the danger zone
240
+ const brightYellow = Hct.fromInt(0xffffd700 >>> 0); // Gold
241
+ const mustardYellow = Hct.fromInt(0xffffdb58 >>> 0); // Mustard
242
+
243
+ // These should be light enough to not be disliked
244
+ expect(DislikeAnalyzer.isDisliked(brightYellow)).toBe(false);
245
+ expect(DislikeAnalyzer.isDisliked(mustardYellow)).toBe(false);
246
+ });
247
+ });
248
+ });