@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,114 @@
1
+ import { Hct } from "../hct/hct-class.js";
2
+
3
+ /**
4
+ * Check and/or fix universally disliked colors.
5
+ *
6
+ * Color science studies of color preference indicate universal distaste for
7
+ * dark yellow-greens, and also show this is correlated to distaste for
8
+ * biological waste and rotting food.
9
+ *
10
+ * See Palmer and Schloss, 2010 or Schloss and Palmer's Chapter 21 in Handbook
11
+ * of Color Psychology (2015).
12
+ */
13
+ export class DislikeAnalyzer {
14
+ /**
15
+ * Analyze a batch of colors and return statistics
16
+ * @param colors Array of HCT colors
17
+ * @return Statistics about disliked colors
18
+ */
19
+ static analyzeBatch(colors: Hct[]): {
20
+ disliked: number;
21
+ dislikedIndices: number[];
22
+ percentage: number;
23
+ total: number;
24
+ } {
25
+ const dislikedIndices: number[] = [];
26
+
27
+ colors.forEach((color, index) => {
28
+ if (DislikeAnalyzer.isDisliked(color)) {
29
+ dislikedIndices.push(index);
30
+ }
31
+ });
32
+
33
+ return {
34
+ disliked: dislikedIndices.length,
35
+ dislikedIndices,
36
+ percentage: (dislikedIndices.length / colors.length) * 100,
37
+ total: colors.length,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Fix all disliked colors in a batch
43
+ * @param colors Array of HCT colors
44
+ * @return Array with disliked colors fixed
45
+ */
46
+ static fixBatch(colors: Hct[]): Hct[] {
47
+ return colors.map((color) => DislikeAnalyzer.fixIfDisliked(color));
48
+ }
49
+
50
+ /**
51
+ * If a color is disliked, lighten it to make it likable.
52
+ *
53
+ * @param hct A color to be judged.
54
+ * @return A new color if the original color is disliked, or the original
55
+ * color if it is acceptable.
56
+ */
57
+ static fixIfDisliked(hct: Hct): Hct {
58
+ if (DislikeAnalyzer.isDisliked(hct)) {
59
+ return Hct.from(hct.hue, hct.chroma, 70.0);
60
+ }
61
+
62
+ return hct;
63
+ }
64
+
65
+ /**
66
+ * Fix a hex color if it's disliked
67
+ * @param hex Hex color string
68
+ * @return Fixed hex color or original if not disliked
69
+ */
70
+ static fixIfDislikedHex(hex: string): string {
71
+ const argb = parseInt(hex.replace("#", ""), 16) | 0xff000000;
72
+ const hct = Hct.fromInt(argb);
73
+ const fixed = DislikeAnalyzer.fixIfDisliked(hct);
74
+
75
+ if (fixed === hct) {
76
+ return hex;
77
+ }
78
+
79
+ const fixedArgb = fixed.toInt();
80
+ const r = (fixedArgb >> 16) & 0xff;
81
+ const g = (fixedArgb >> 8) & 0xff;
82
+ const b = fixedArgb & 0xff;
83
+
84
+ return "#" + [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("");
85
+ }
86
+
87
+ /**
88
+ * Returns true if a color is disliked.
89
+ *
90
+ * @param hct A color to be judged.
91
+ * @return Whether the color is disliked.
92
+ *
93
+ * Disliked is defined as a dark yellow-green that is not neutral.
94
+ * Specifically: hue 90-111°, chroma > 16, tone < 65
95
+ */
96
+ static isDisliked(hct: Hct): boolean {
97
+ const huePasses =
98
+ Math.round(hct.hue) >= 90.0 && Math.round(hct.hue) <= 111.0;
99
+ const chromaPasses = Math.round(hct.chroma) > 16.0;
100
+ const tonePasses = Math.round(hct.tone) < 65.0;
101
+
102
+ return huePasses && chromaPasses && tonePasses;
103
+ }
104
+
105
+ /**
106
+ * Check if a color is in the "bile zone" - universally disliked colors
107
+ * @param hex Hex color string
108
+ * @return Whether the color is disliked
109
+ */
110
+ static isDislikedHex(hex: string): boolean {
111
+ const hct = Hct.fromInt(parseInt(hex.replace("#", ""), 16) | 0xff000000);
112
+ return DislikeAnalyzer.isDisliked(hct);
113
+ }
114
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Image color extraction using Material Design quantization
3
+ */
4
+
5
+ import { DislikeAnalyzer } from "./dislike/dislike-analyzer.js";
6
+ import { Hct } from "./hct/index.js";
7
+ import {
8
+ filterExtremeTones,
9
+ imageDataToPixels,
10
+ samplePixels,
11
+ } from "./image-utils.js";
12
+ import { QuantizerCelebi } from "./quantize/quantizer_celebi.js";
13
+ import { Score } from "./score/score.js";
14
+ import * as utils from "./utils/color_utils.js";
15
+
16
+ export interface ExtractedColor {
17
+ hct: { c: number; h: number; t: number };
18
+ hex: string;
19
+ percentage: number;
20
+ population: number;
21
+ rgb: { b: number; g: number; r: number };
22
+ }
23
+
24
+ export interface ExtractionOptions {
25
+ filter?: boolean;
26
+ fixDislikedColors?: boolean;
27
+ maxColors?: number;
28
+ quality?: "high" | "low" | "medium";
29
+ scoringEnabled?: boolean;
30
+ }
31
+
32
+ const QUALITY_SETTINGS = {
33
+ high: { maxPixels: 25000, quantizeColors: 256 },
34
+ low: { maxPixels: 5000, quantizeColors: 64 },
35
+ medium: { maxPixels: 10000, quantizeColors: 128 },
36
+ };
37
+
38
+ /**
39
+ * Extract dominant colors from image data
40
+ */
41
+ export function extractColors(
42
+ imageData: {
43
+ data: number[] | Uint8ClampedArray;
44
+ height: number;
45
+ width: number;
46
+ },
47
+ options: ExtractionOptions = {},
48
+ ): ExtractedColor[] {
49
+ const {
50
+ filter = true,
51
+ fixDislikedColors = false,
52
+ maxColors = 5,
53
+ quality = "medium",
54
+ scoringEnabled = true,
55
+ } = options;
56
+
57
+ const qualitySettings = QUALITY_SETTINGS[quality];
58
+
59
+ // Convert image to pixels
60
+ let pixels = imageDataToPixels(imageData);
61
+
62
+ // Sample for performance
63
+ pixels = samplePixels(pixels, qualitySettings.maxPixels);
64
+
65
+ // Optionally filter extreme tones
66
+ if (filter) {
67
+ pixels = filterExtremeTones(pixels);
68
+ }
69
+
70
+ // Quantize colors using Celebi algorithm
71
+ const quantized = QuantizerCelebi.quantize(
72
+ pixels,
73
+ qualitySettings.quantizeColors,
74
+ );
75
+
76
+ // Score and select best colors
77
+ let selectedColors: number[];
78
+ if (scoringEnabled) {
79
+ selectedColors = Score.score(quantized, {
80
+ desired: maxColors,
81
+ filter: filter,
82
+ });
83
+ } else {
84
+ // Simple selection by population
85
+ const sorted = Array.from(quantized.entries())
86
+ .sort((a, b) => b[1] - a[1])
87
+ .slice(0, maxColors)
88
+ .map((entry) => entry[0]);
89
+ selectedColors = sorted;
90
+ }
91
+
92
+ // Calculate total population for percentages
93
+ const totalPopulation = Array.from(quantized.values()).reduce(
94
+ (sum, pop) => sum + pop,
95
+ 0,
96
+ );
97
+
98
+ // Convert to output format
99
+ return selectedColors.map((argb) => {
100
+ let hct = Hct.fromInt(argb);
101
+
102
+ // Fix disliked colors if requested
103
+ if (fixDislikedColors && DislikeAnalyzer.isDisliked(hct)) {
104
+ hct = DislikeAnalyzer.fixIfDisliked(hct);
105
+ argb = hct.toInt();
106
+ }
107
+
108
+ const r = utils.redFromArgb(argb);
109
+ const g = utils.greenFromArgb(argb);
110
+ const b = utils.blueFromArgb(argb);
111
+ const population = quantized.get(argb) || 0;
112
+
113
+ return {
114
+ hct: { c: hct.chroma, h: hct.hue, t: hct.tone },
115
+ hex: rgbToHex(r, g, b),
116
+ percentage: (population / totalPopulation) * 100,
117
+ population,
118
+ rgb: { b, g, r },
119
+ };
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Extract a color palette suitable for UI themes
125
+ */
126
+ export function extractThemePalette(imageData: {
127
+ data: number[] | Uint8ClampedArray;
128
+ height: number;
129
+ width: number;
130
+ }): {
131
+ error?: ExtractedColor;
132
+ neutral?: ExtractedColor;
133
+ primary: ExtractedColor;
134
+ secondary?: ExtractedColor;
135
+ tertiary?: ExtractedColor;
136
+ } {
137
+ // Extract with high quality and scoring, fixing disliked colors
138
+ const colors = extractColors(imageData, {
139
+ filter: true,
140
+ fixDislikedColors: true, // Always fix disliked colors for themes
141
+ maxColors: 8,
142
+ quality: "high",
143
+ scoringEnabled: true,
144
+ });
145
+
146
+ if (colors.length === 0) {
147
+ throw new Error("No colors could be extracted from image");
148
+ }
149
+
150
+ const result: {
151
+ error?: ExtractedColor;
152
+ neutral?: ExtractedColor;
153
+ primary: ExtractedColor;
154
+ secondary?: ExtractedColor;
155
+ tertiary?: ExtractedColor;
156
+ } = {
157
+ primary: colors[0],
158
+ };
159
+
160
+ // Assign additional colors based on hue differences
161
+ if (colors.length > 1) {
162
+ // Find color with most different hue from primary
163
+ const primaryHue = colors[0].hct.h;
164
+ let maxHueDiff = 0;
165
+ let secondaryIndex = 1;
166
+
167
+ for (let i = 1; i < Math.min(colors.length, 4); i++) {
168
+ const hueDiff = Math.abs(hueDifference(primaryHue, colors[i].hct.h));
169
+ if (hueDiff > maxHueDiff) {
170
+ maxHueDiff = hueDiff;
171
+ secondaryIndex = i;
172
+ }
173
+ }
174
+
175
+ result.secondary = colors[secondaryIndex];
176
+
177
+ // Find tertiary (different from both primary and secondary)
178
+ if (colors.length > 2) {
179
+ const secondaryHue = colors[secondaryIndex].hct.h;
180
+ let bestTertiaryIndex = -1;
181
+ let bestScore = 0;
182
+
183
+ for (let i = 1; i < colors.length; i++) {
184
+ if (i === secondaryIndex) continue;
185
+
186
+ const hue = colors[i].hct.h;
187
+ const primaryDiff = Math.abs(hueDifference(primaryHue, hue));
188
+ const secondaryDiff = Math.abs(hueDifference(secondaryHue, hue));
189
+ const score = Math.min(primaryDiff, secondaryDiff);
190
+
191
+ if (score > bestScore) {
192
+ bestScore = score;
193
+ bestTertiaryIndex = i;
194
+ }
195
+ }
196
+
197
+ if (bestTertiaryIndex !== -1) {
198
+ result.tertiary = colors[bestTertiaryIndex];
199
+ }
200
+ }
201
+ }
202
+
203
+ // Find a neutral color (low chroma)
204
+ const neutral = colors.find((c) => c.hct.c < 20);
205
+ if (neutral) {
206
+ result.neutral = neutral;
207
+ }
208
+
209
+ // Error color (prefer red/orange hue if available, 0-40 or 350-360)
210
+ const errorColor = colors.find((c) => c.hct.h >= 350 || c.hct.h <= 40);
211
+ if (errorColor) {
212
+ result.error = errorColor;
213
+ }
214
+
215
+ return result;
216
+ }
217
+
218
+ function hueDifference(h1: number, h2: number): number {
219
+ const diff = Math.abs(h1 - h2);
220
+ return diff > 180 ? 360 - diff : diff;
221
+ }
222
+
223
+ function rgbToHex(r: number, g: number, b: number): string {
224
+ return (
225
+ "#" +
226
+ [r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join("")
227
+ );
228
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Tests for HCT class (Material Color Utilities compatible)
3
+ */
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import * as utils from "../../utils/color_utils";
8
+ import { Hct } from "../hct-class";
9
+
10
+ describe("Hct Class", () => {
11
+ describe("from static method", () => {
12
+ it("should create HCT from hue, chroma, and tone", () => {
13
+ const hct = Hct.from(180, 50, 50);
14
+
15
+ expect(hct.hue).toBeCloseTo(180, -1); // Within 10 degrees
16
+ // Chroma might be adjusted to achievable value
17
+ expect(hct.chroma).toBeGreaterThan(30); // At least some chroma
18
+ expect(hct.chroma).toBeLessThanOrEqual(50); // Not more than requested
19
+ expect(hct.tone).toBeCloseTo(50, -1); // Within 10 units
20
+ });
21
+
22
+ it("should handle zero chroma (grayscale)", () => {
23
+ const hct = Hct.from(0, 0, 50);
24
+
25
+ expect(hct.chroma).toBeCloseTo(0, 1);
26
+ expect(hct.tone).toBeCloseTo(50, -1); // Within 10 units
27
+ });
28
+
29
+ it("should handle extreme tones", () => {
30
+ const black = Hct.from(0, 0, 0);
31
+ const white = Hct.from(0, 0, 100);
32
+
33
+ expect(black.tone).toBeCloseTo(0, 0);
34
+ expect(white.tone).toBeCloseTo(100, 0);
35
+ });
36
+
37
+ it("should clamp chroma to achievable values", () => {
38
+ // Very high chroma might not be achievable
39
+ const hct = Hct.from(0, 200, 50);
40
+
41
+ // Chroma should be clamped to achievable value
42
+ expect(hct.chroma).toBeLessThanOrEqual(150);
43
+ });
44
+ });
45
+
46
+ describe("fromInt static method", () => {
47
+ it("should create HCT from ARGB integer", () => {
48
+ const argb = utils.argbFromRgb(255, 0, 0); // Red
49
+ const hct = Hct.fromInt(argb);
50
+
51
+ // Red should have hue around 0-30
52
+ expect(hct.hue).toBeGreaterThanOrEqual(0);
53
+ expect(hct.hue).toBeLessThan(60);
54
+ expect(hct.chroma).toBeGreaterThan(0);
55
+ });
56
+
57
+ it("should handle grayscale colors", () => {
58
+ const gray = utils.argbFromRgb(128, 128, 128);
59
+ const hct = Hct.fromInt(gray);
60
+
61
+ expect(hct.chroma).toBeCloseTo(0, 1);
62
+ expect(hct.tone).toBeCloseTo(50, -1); // Within 10 units
63
+ });
64
+
65
+ it("should handle primary colors", () => {
66
+ const red = utils.argbFromRgb(255, 0, 0);
67
+ const green = utils.argbFromRgb(0, 255, 0);
68
+ const blue = utils.argbFromRgb(0, 0, 255);
69
+
70
+ const hctRed = Hct.fromInt(red);
71
+ const hctGreen = Hct.fromInt(green);
72
+ const hctBlue = Hct.fromInt(blue);
73
+
74
+ // Check that primary colors have high chroma
75
+ expect(hctRed.chroma).toBeGreaterThan(100);
76
+ expect(hctGreen.chroma).toBeGreaterThan(100);
77
+ expect(hctBlue.chroma).toBeGreaterThan(80);
78
+ });
79
+ });
80
+
81
+ describe("toInt method", () => {
82
+ it("should convert back to ARGB integer", () => {
83
+ const originalArgb = utils.argbFromRgb(128, 64, 192);
84
+ const hct = Hct.fromInt(originalArgb);
85
+ const resultArgb = hct.toInt();
86
+
87
+ // Should be close to original (some loss due to color space conversion)
88
+ const originalRgb = {
89
+ b: utils.blueFromArgb(originalArgb),
90
+ g: utils.greenFromArgb(originalArgb),
91
+ r: utils.redFromArgb(originalArgb),
92
+ };
93
+
94
+ const resultRgb = {
95
+ b: utils.blueFromArgb(resultArgb),
96
+ g: utils.greenFromArgb(resultArgb),
97
+ r: utils.redFromArgb(resultArgb),
98
+ };
99
+
100
+ expect(resultRgb.r).toBeCloseTo(originalRgb.r, -1); // Within 10
101
+ expect(resultRgb.g).toBeCloseTo(originalRgb.g, -1);
102
+ expect(resultRgb.b).toBeCloseTo(originalRgb.b, -1);
103
+ });
104
+
105
+ it("should maintain opaque alpha channel", () => {
106
+ const hct = Hct.from(180, 50, 50);
107
+ const argb = hct.toInt();
108
+
109
+ // Alpha should be 255 (fully opaque)
110
+ const alpha = (argb >> 24) & 0xff;
111
+ expect(alpha).toBe(0xff);
112
+ });
113
+ });
114
+
115
+ describe("getters", () => {
116
+ it("should return correct hue", () => {
117
+ const hct = Hct.from(123.45, 50, 50);
118
+ expect(hct.hue).toBeCloseTo(123.45, 0);
119
+ });
120
+
121
+ it("should return correct chroma", () => {
122
+ const hct = Hct.from(180, 45.67, 50);
123
+ // Chroma might be adjusted to achievable value
124
+ expect(hct.chroma).toBeGreaterThan(30); // At least some chroma
125
+ expect(hct.chroma).toBeLessThanOrEqual(45.67); // Not more than requested
126
+ });
127
+
128
+ it("should return correct tone", () => {
129
+ const hct = Hct.from(180, 50, 78.9);
130
+ expect(hct.tone).toBeCloseTo(78.9, 0);
131
+ });
132
+ });
133
+
134
+ describe("setters", () => {
135
+ it("should update hue", () => {
136
+ const hct = Hct.from(0, 50, 50);
137
+ hct.hue = 180;
138
+
139
+ expect(hct.hue).toBeCloseTo(180, -1); // Within 10 degrees
140
+ // Chroma might adjust based on new hue
141
+ expect(hct.chroma).toBeGreaterThan(30); // At least some chroma
142
+ expect(hct.tone).toBeCloseTo(50, -1); // Within 10 tone units
143
+ });
144
+
145
+ it("should update chroma", () => {
146
+ const hct = Hct.from(180, 30, 50);
147
+ hct.chroma = 60;
148
+
149
+ // Chroma might be clamped to achievable value
150
+ expect(hct.chroma).toBeGreaterThan(30); // At least original chroma
151
+ expect(hct.chroma).toBeLessThanOrEqual(60); // Not more than requested
152
+ // Hue should be maintained, allowing for some adjustment
153
+ expect(hct.hue).toBeGreaterThan(170);
154
+ expect(hct.hue).toBeLessThan(190);
155
+ expect(hct.tone).toBeCloseTo(50, -1); // Within 10 tone units
156
+ });
157
+
158
+ it("should update tone", () => {
159
+ const hct = Hct.from(180, 50, 30);
160
+ hct.tone = 70;
161
+
162
+ expect(hct.tone).toBeCloseTo(70, 0); // Very close
163
+ // Hue might adjust slightly
164
+ expect(hct.hue).toBeGreaterThan(170);
165
+ expect(hct.hue).toBeLessThan(190);
166
+ });
167
+
168
+ it("should handle hue wrapping", () => {
169
+ const hct = Hct.from(350, 50, 50);
170
+ hct.hue = 370; // Should wrap to 10
171
+
172
+ expect(hct.hue).toBeCloseTo(10, 0); // Should be close
173
+ });
174
+
175
+ it("should clamp tone to valid range", () => {
176
+ const hct = Hct.from(180, 50, 50);
177
+
178
+ hct.tone = -10;
179
+ expect(hct.tone).toBeCloseTo(0, 1);
180
+
181
+ hct.tone = 110;
182
+ expect(hct.tone).toBeCloseTo(100, 1);
183
+ });
184
+
185
+ it("should adjust chroma when not achievable", () => {
186
+ const hct = Hct.from(180, 50, 95); // High tone
187
+ hct.chroma = 100; // High chroma at high tone is difficult
188
+
189
+ // Chroma should be adjusted to achievable value
190
+ expect(hct.chroma).toBeLessThanOrEqual(100);
191
+ });
192
+ });
193
+
194
+ describe("round-trip conversions", () => {
195
+ it("should maintain values through round-trip", () => {
196
+ const originalHct = Hct.from(240, 40, 60);
197
+ const argb = originalHct.toInt();
198
+ const newHct = Hct.fromInt(argb);
199
+
200
+ expect(newHct.hue).toBeCloseTo(originalHct.hue, 0);
201
+ expect(newHct.chroma).toBeCloseTo(originalHct.chroma, 1);
202
+ expect(newHct.tone).toBeCloseTo(originalHct.tone, 1);
203
+ });
204
+
205
+ it("should handle edge cases in round-trip", () => {
206
+ const testCases = [
207
+ { c: 0, h: 0, t: 0 }, // Black
208
+ { c: 0, h: 0, t: 100 }, // White
209
+ { c: 0, h: 0, t: 50 }, // Gray
210
+ { c: 100, h: 0, t: 50 }, // High chroma red
211
+ { c: 100, h: 120, t: 50 }, // High chroma green
212
+ { c: 100, h: 240, t: 50 }, // High chroma blue
213
+ ];
214
+
215
+ for (const testCase of testCases) {
216
+ const hct1 = Hct.from(testCase.h, testCase.c, testCase.t);
217
+ const argb = hct1.toInt();
218
+ const hct2 = Hct.fromInt(argb);
219
+
220
+ // Tone should be very close
221
+ expect(hct2.tone).toBeCloseTo(hct1.tone, 0);
222
+
223
+ // For non-grayscale, hue should be maintained
224
+ if (testCase.c > 0 && hct2.chroma > 1) {
225
+ const hueDiff = Math.abs(hct2.hue - hct1.hue);
226
+ const normalizedDiff = Math.min(hueDiff, 360 - hueDiff);
227
+ expect(normalizedDiff).toBeLessThan(5);
228
+ }
229
+ }
230
+ });
231
+ });
232
+ });