@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.
- package/.claude/settings.local.json +39 -0
- package/.env +2 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +73 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +71 -0
- package/.github/pull_request_template.md +97 -0
- package/.github/workflows/ci.yml +127 -0
- package/.github/workflows/deploy-docs.yml +56 -0
- package/.github/workflows/release.yml +99 -0
- package/.mcp.json +12 -0
- package/.prettierignore +1 -0
- package/CLAUDE.md +201 -0
- package/DOCUMENTATION.md +274 -0
- package/GEMINI.md +54 -0
- package/LICENSE +21 -0
- package/README.md +401 -0
- package/demo/content_based_color.png +0 -0
- package/demo/music-player.html +621 -0
- package/demo/podcast-player.html +903 -0
- package/dist/bin/coolors-mcp.d.ts +1 -0
- package/dist/bin/coolors-mcp.js +154 -0
- package/dist/bin/coolors-mcp.js.map +1 -0
- package/dist/bin/server.d.ts +1 -0
- package/dist/bin/server.js +3292 -0
- package/dist/bin/server.js.map +1 -0
- package/dist/chunk-IQ7NN26V.js +114 -0
- package/dist/chunk-IQ7NN26V.js.map +1 -0
- package/dist/chunk-P3ARRKLS.js +1214 -0
- package/dist/chunk-P3ARRKLS.js.map +1 -0
- package/dist/color/index.d.ts +716 -0
- package/dist/color/index.js +153 -0
- package/dist/color/index.js.map +1 -0
- package/dist/coolors-mcp.d.ts +136 -0
- package/dist/coolors-mcp.js +7 -0
- package/dist/coolors-mcp.js.map +1 -0
- package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js +93 -0
- package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js.map +7 -0
- package/docs/.vitepress/cache/deps/_metadata.json +127 -0
- package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js +9 -0
- package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js.map +7 -0
- package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js +12683 -0
- package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js.map +7 -0
- package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js +9719 -0
- package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js.map +7 -0
- package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js +4710 -0
- package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js.map +7 -0
- package/docs/.vitepress/cache/deps/cytoscape.js +30278 -0
- package/docs/.vitepress/cache/deps/cytoscape.js.map +7 -0
- package/docs/.vitepress/cache/deps/dayjs.js +285 -0
- package/docs/.vitepress/cache/deps/dayjs.js.map +7 -0
- package/docs/.vitepress/cache/deps/debug.js +468 -0
- package/docs/.vitepress/cache/deps/debug.js.map +7 -0
- package/docs/.vitepress/cache/deps/package.json +3 -0
- package/docs/.vitepress/cache/deps/prismjs.js +1466 -0
- package/docs/.vitepress/cache/deps/prismjs.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js +228 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js +142 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js +27 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js +65 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js +53 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js.map +7 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js +73 -0
- package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js +4507 -0
- package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js +584 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js +1146 -0
- package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js +1667 -0
- package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js.map +7 -0
- package/docs/.vitepress/cache/deps/vitepress___minisearch.js +1814 -0
- package/docs/.vitepress/cache/deps/vitepress___minisearch.js.map +7 -0
- package/docs/.vitepress/cache/deps/vue.js +344 -0
- package/docs/.vitepress/cache/deps/vue.js.map +7 -0
- package/docs/.vitepress/components/ClientGrid.vue +125 -0
- package/docs/.vitepress/components/CodeBlock.vue +231 -0
- package/docs/.vitepress/components/ConfigModal.vue +477 -0
- package/docs/.vitepress/components/DiagramModal.vue +528 -0
- package/docs/.vitepress/components/TroubleshootingModal.vue +472 -0
- package/docs/.vitepress/config.js +162 -0
- package/docs/.vitepress/theme/FundingLayout.vue +251 -0
- package/docs/.vitepress/theme/Layout.vue +134 -0
- package/docs/.vitepress/theme/components/AdBanner.vue +317 -0
- package/docs/.vitepress/theme/components/AdPlaceholder.vue +78 -0
- package/docs/.vitepress/theme/components/FundingEffects.vue +322 -0
- package/docs/.vitepress/theme/components/FundingHero.vue +345 -0
- package/docs/.vitepress/theme/components/SupportSection.vue +511 -0
- package/docs/.vitepress/theme/custom-app.css +339 -0
- package/docs/.vitepress/theme/custom.css +699 -0
- package/docs/.vitepress/theme/index.js +25 -0
- package/docs/README.md +198 -0
- package/docs/concepts/accessibility.md +473 -0
- package/docs/concepts/color-spaces.md +222 -0
- package/docs/concepts/distance-metrics.md +384 -0
- package/docs/concepts/hct.md +261 -0
- package/docs/concepts/image-analysis.md +396 -0
- package/docs/concepts/material-design.md +306 -0
- package/docs/concepts/theme-matching.md +399 -0
- package/docs/examples/basic-colors.md +490 -0
- package/docs/examples/creating-themes.md +898 -0
- package/docs/examples/css-refactoring.md +824 -0
- package/docs/examples/image-extraction.md +882 -0
- package/docs/getting-started.md +366 -0
- package/docs/index.md +190 -0
- package/docs/installation.md +157 -0
- package/docs/tools/README.md +234 -0
- package/docs/tools/accessibility.md +614 -0
- package/docs/tools/color-operations.md +374 -0
- package/docs/tools/image-extraction.md +624 -0
- package/docs/tools/material-design.md +347 -0
- package/docs/tools/theme-matching.md +552 -0
- package/eslint.config.ts +14 -0
- package/examples/theme-matching.md +113 -0
- package/jsr.json +7 -0
- package/mcp-config.json +8 -0
- package/note.md +35 -0
- package/package.json +122 -0
- package/research_results.md +53 -0
- package/src/bin/coolors-mcp.ts +194 -0
- package/src/bin/server.ts +61 -0
- package/src/color/__tests__/conversions-argb.test.ts +198 -0
- package/src/color/__tests__/extract-colors.test.ts +360 -0
- package/src/color/__tests__/image-utils.test.ts +242 -0
- package/src/color/__tests__/reference-colors.test.ts +278 -0
- package/src/color/__tests__/round-trip.test.ts +197 -0
- package/src/color/conversions.test.ts +402 -0
- package/src/color/conversions.ts +393 -0
- package/src/color/dislike/__tests__/dislike-analyzer.test.ts +248 -0
- package/src/color/dislike/dislike-analyzer.ts +114 -0
- package/src/color/extract-colors.ts +228 -0
- package/src/color/hct/__tests__/hct-class.test.ts +232 -0
- package/src/color/hct/harmonization.ts +204 -0
- package/src/color/hct/hct-class.ts +109 -0
- package/src/color/hct/hct-solver.ts +168 -0
- package/src/color/hct/index.ts +39 -0
- package/src/color/hct/tonal-palette.ts +211 -0
- package/src/color/hct/types.ts +88 -0
- package/src/color/image-utils.ts +79 -0
- package/src/color/index.ts +87 -0
- package/src/color/material-theme.ts +157 -0
- package/src/color/metrics.test.ts +276 -0
- package/src/color/metrics.ts +281 -0
- package/src/color/quantize/__tests__/quantizer_celebi.test.ts +195 -0
- package/src/color/quantize/lab_point_provider.ts +55 -0
- package/src/color/quantize/point_provider.ts +27 -0
- package/src/color/quantize/quantizer_celebi.ts +51 -0
- package/src/color/quantize/quantizer_celebi_test.ts +71 -0
- package/src/color/quantize/quantizer_map.ts +47 -0
- package/src/color/quantize/quantizer_wsmeans.ts +232 -0
- package/src/color/quantize/quantizer_wu.ts +472 -0
- package/src/color/score/__tests__/score.test.ts +224 -0
- package/src/color/score/score.ts +175 -0
- package/src/color/types.ts +151 -0
- package/src/color/utils/color_utils.ts +292 -0
- package/src/color/utils/math_utils.ts +145 -0
- package/src/color/utils.test.ts +403 -0
- package/src/color/utils.ts +315 -0
- package/src/constants.ts +5 -0
- package/src/coolors-mcp.ts +37 -0
- package/src/examples/addition.ts +333 -0
- package/src/examples/color-demo.ts +125 -0
- package/src/examples/custom-logger.ts +201 -0
- package/src/examples/oauth-server.ts +113 -0
- package/src/examples/session-context.ts +269 -0
- package/src/session.ts +116 -0
- package/src/theme/__tests__/matcher.test.ts +180 -0
- package/src/theme/__tests__/parser.test.ts +148 -0
- package/src/theme/__tests__/refactor.test.ts +224 -0
- package/src/theme/index.ts +34 -0
- package/src/theme/matcher.ts +395 -0
- package/src/theme/parser.ts +392 -0
- package/src/theme/refactor.ts +360 -0
- package/src/theme/types.ts +152 -0
- package/src/tools/__tests__/gradient-generator.test.ts +206 -0
- package/src/tools/__tests__/palette-with-locks.test.ts +109 -0
- package/src/tools/color-conversion.tool.ts +54 -0
- package/src/tools/color-distance.tool.ts +41 -0
- package/src/tools/colors.ts +31 -0
- package/src/tools/contrast-checker.tool.ts +37 -0
- package/src/tools/dislike-analyzer.tool.ts +247 -0
- package/src/tools/gradient-generator.tool.ts +250 -0
- package/src/tools/image-extraction.tools.ts +289 -0
- package/src/tools/index.ts +39 -0
- package/src/tools/material-theme.tools.ts +250 -0
- package/src/tools/palette-generator.tool.ts +135 -0
- package/src/tools/palette-with-locks.tool.ts +221 -0
- package/src/tools/registry.ts +142 -0
- package/src/tools/simple-tools.ts +37 -0
- package/src/tools/theme-matching.tools.ts +334 -0
- package/src/types.ts +182 -0
- package/src/utils.ts +22 -0
- package/tsconfig.json +8 -0
- 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
|
+
});
|