@trishchuk/coolors-mcp 1.0.0 → 1.1.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 (140) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +20 -8
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +22 -8
  3. package/.github/pull_request_template.md +33 -8
  4. package/.github/workflows/ci.yml +107 -104
  5. package/.github/workflows/deploy-docs.yml +14 -11
  6. package/.github/workflows/release.yml +25 -23
  7. package/README.md +149 -15
  8. package/dist/bin/server.js +997 -256
  9. package/dist/bin/server.js.map +1 -1
  10. package/dist/{chunk-P3ARRKLS.js → chunk-HOMDMKUY.js} +3 -1
  11. package/dist/{chunk-P3ARRKLS.js.map → chunk-HOMDMKUY.js.map} +1 -1
  12. package/dist/{chunk-IQ7NN26V.js → chunk-LHW2ZTOU.js} +14 -2
  13. package/dist/chunk-LHW2ZTOU.js.map +1 -0
  14. package/dist/color/index.js +1 -1
  15. package/dist/coolors-mcp.d.ts +4 -4
  16. package/dist/coolors-mcp.js +1 -1
  17. package/docs/.vitepress/components/ClientGrid.vue +9 -3
  18. package/docs/.vitepress/components/CodeBlock.vue +51 -44
  19. package/docs/.vitepress/components/ConfigModal.vue +151 -67
  20. package/docs/.vitepress/components/DiagramModal.vue +186 -154
  21. package/docs/.vitepress/components/TroubleshootingModal.vue +101 -96
  22. package/docs/.vitepress/config.js +171 -141
  23. package/docs/.vitepress/theme/FundingLayout.vue +65 -54
  24. package/docs/.vitepress/theme/Layout.vue +21 -21
  25. package/docs/.vitepress/theme/components/AdBanner.vue +73 -52
  26. package/docs/.vitepress/theme/components/AdPlaceholder.vue +3 -3
  27. package/docs/.vitepress/theme/components/FundingEffects.vue +77 -53
  28. package/docs/.vitepress/theme/components/FundingHero.vue +78 -63
  29. package/docs/.vitepress/theme/components/SupportSection.vue +106 -89
  30. package/docs/.vitepress/theme/custom-app.css +19 -12
  31. package/docs/.vitepress/theme/custom.css +33 -25
  32. package/docs/.vitepress/theme/index.js +19 -16
  33. package/docs/concepts/accessibility.md +59 -47
  34. package/docs/concepts/color-spaces.md +28 -6
  35. package/docs/concepts/distance-metrics.md +45 -30
  36. package/docs/concepts/hct.md +30 -27
  37. package/docs/concepts/image-analysis.md +52 -21
  38. package/docs/concepts/material-design.md +43 -17
  39. package/docs/concepts/theme-matching.md +64 -40
  40. package/docs/examples/basic-colors.md +92 -108
  41. package/docs/examples/creating-themes.md +104 -108
  42. package/docs/examples/css-refactoring.md +33 -29
  43. package/docs/examples/image-extraction.md +145 -138
  44. package/docs/getting-started.md +45 -34
  45. package/docs/index.md +5 -1
  46. package/docs/installation.md +15 -1
  47. package/docs/tools/accessibility.md +74 -68
  48. package/docs/tools/image-extraction.md +62 -54
  49. package/docs/tools/theme-matching.md +45 -42
  50. package/eslint.config.ts +13 -0
  51. package/jsr.json +1 -1
  52. package/package.json +17 -13
  53. package/src/bin/server.ts +13 -1
  54. package/src/color/__tests__/extract-colors.test.ts +20 -30
  55. package/src/color/apca.ts +105 -0
  56. package/src/color/color-blindness.ts +109 -0
  57. package/src/coolors-mcp.ts +1 -1
  58. package/src/session.ts +10 -2
  59. package/src/theme/matcher.ts +1 -1
  60. package/src/theme/refactor.ts +1 -1
  61. package/src/theme/types.ts +3 -0
  62. package/src/tools/__tests__/cohesion.test.ts +97 -0
  63. package/src/tools/__tests__/color-blindness.test.ts +45 -0
  64. package/src/tools/__tests__/color-conversion.test.ts +38 -0
  65. package/src/tools/__tests__/contrast-checker.test.ts +56 -0
  66. package/src/tools/__tests__/palette-export.test.ts +54 -0
  67. package/src/tools/adjust-color.tool.ts +80 -0
  68. package/src/tools/cohesion.tools.ts +380 -0
  69. package/src/tools/color-blindness.tool.ts +168 -0
  70. package/src/tools/color-conversion.tool.ts +1 -1
  71. package/src/tools/contrast-checker.tool.ts +53 -14
  72. package/src/tools/dislike-analyzer.tool.ts +41 -54
  73. package/src/tools/image-extraction.tools.ts +62 -115
  74. package/src/tools/index.ts +15 -2
  75. package/src/tools/palette-export.tool.ts +174 -0
  76. package/src/tools/palette-with-locks.tool.ts +8 -6
  77. package/src/types.ts +2 -3
  78. package/tsconfig.json +12 -2
  79. package/vitest.config.js +1 -3
  80. package/.claude/settings.local.json +0 -39
  81. package/.env +0 -2
  82. package/.mcp.json +0 -12
  83. package/CLAUDE.md +0 -201
  84. package/DOCUMENTATION.md +0 -274
  85. package/GEMINI.md +0 -54
  86. package/demo/content_based_color.png +0 -0
  87. package/demo/music-player.html +0 -621
  88. package/demo/podcast-player.html +0 -903
  89. package/dist/chunk-IQ7NN26V.js.map +0 -1
  90. package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js +0 -93
  91. package/docs/.vitepress/cache/deps/@braintree_sanitize-url.js.map +0 -7
  92. package/docs/.vitepress/cache/deps/_metadata.json +0 -127
  93. package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js +0 -9
  94. package/docs/.vitepress/cache/deps/chunk-BUSYA2B4.js.map +0 -7
  95. package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js +0 -12683
  96. package/docs/.vitepress/cache/deps/chunk-JD3CXNQ6.js.map +0 -7
  97. package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js +0 -9719
  98. package/docs/.vitepress/cache/deps/chunk-SYPOPCWC.js.map +0 -7
  99. package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js +0 -4710
  100. package/docs/.vitepress/cache/deps/cytoscape-cose-bilkent.js.map +0 -7
  101. package/docs/.vitepress/cache/deps/cytoscape.js +0 -30278
  102. package/docs/.vitepress/cache/deps/cytoscape.js.map +0 -7
  103. package/docs/.vitepress/cache/deps/dayjs.js +0 -285
  104. package/docs/.vitepress/cache/deps/dayjs.js.map +0 -7
  105. package/docs/.vitepress/cache/deps/debug.js +0 -468
  106. package/docs/.vitepress/cache/deps/debug.js.map +0 -7
  107. package/docs/.vitepress/cache/deps/package.json +0 -3
  108. package/docs/.vitepress/cache/deps/prismjs.js +0 -1466
  109. package/docs/.vitepress/cache/deps/prismjs.js.map +0 -7
  110. package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js +0 -228
  111. package/docs/.vitepress/cache/deps/prismjs_components_prism-bash.js.map +0 -7
  112. package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js +0 -142
  113. package/docs/.vitepress/cache/deps/prismjs_components_prism-javascript.js.map +0 -7
  114. package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js +0 -27
  115. package/docs/.vitepress/cache/deps/prismjs_components_prism-json.js.map +0 -7
  116. package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js +0 -65
  117. package/docs/.vitepress/cache/deps/prismjs_components_prism-python.js.map +0 -7
  118. package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js +0 -53
  119. package/docs/.vitepress/cache/deps/prismjs_components_prism-typescript.js.map +0 -7
  120. package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js +0 -73
  121. package/docs/.vitepress/cache/deps/prismjs_components_prism-yaml.js.map +0 -7
  122. package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js +0 -4507
  123. package/docs/.vitepress/cache/deps/vitepress___@vue_devtools-api.js.map +0 -7
  124. package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js +0 -584
  125. package/docs/.vitepress/cache/deps/vitepress___@vueuse_core.js.map +0 -7
  126. package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js +0 -1146
  127. package/docs/.vitepress/cache/deps/vitepress___@vueuse_integrations_useFocusTrap.js.map +0 -7
  128. package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js +0 -1667
  129. package/docs/.vitepress/cache/deps/vitepress___mark__js_src_vanilla__js.js.map +0 -7
  130. package/docs/.vitepress/cache/deps/vitepress___minisearch.js +0 -1814
  131. package/docs/.vitepress/cache/deps/vitepress___minisearch.js.map +0 -7
  132. package/docs/.vitepress/cache/deps/vue.js +0 -344
  133. package/docs/.vitepress/cache/deps/vue.js.map +0 -7
  134. package/examples/theme-matching.md +0 -113
  135. package/mcp-config.json +0 -8
  136. package/note.md +0 -35
  137. package/research_results.md +0 -53
  138. package/src/tools/colors.ts +0 -31
  139. package/src/tools/registry.ts +0 -142
  140. package/src/tools/simple-tools.ts +0 -37
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Visual cohesion tools
3
+ *
4
+ * - generate_tonal_scale: build a Tailwind-style tonal scale (50…950) from a
5
+ * seed color using HCT so steps are perceptually even.
6
+ * - generate_state_colors: derive hover/active/focus/disabled/pressed/selected
7
+ * variants from a base color with consistent tonal deltas.
8
+ * - analyze_palette_consistency: score how visually cohesive a palette is by
9
+ * looking at chroma spread, tonal step uniformity, and hue harmony.
10
+ * - generate_semantic_palette: pick semantic colors (primary/secondary/success/
11
+ * warning/error/info) that harmonize with a brand color via HCT hue rotations.
12
+ */
13
+
14
+ import { z } from "zod";
15
+
16
+ import { argbToRgb, rgbToArgb } from "../color/conversions.js";
17
+ import { Hct } from "../color/hct/hct-class.js";
18
+ import {
19
+ colorDistance,
20
+ parseColor,
21
+ rgbToHex,
22
+ rgbToHsl,
23
+ } from "../color/index.js";
24
+
25
+ const TONAL_STOPS = [
26
+ 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950,
27
+ ] as const;
28
+
29
+ function hctFromColor(input: string): Hct | null {
30
+ const rgb = parseColor(input);
31
+ if (!rgb) return null;
32
+ return Hct.fromInt(rgbToArgb(rgb));
33
+ }
34
+
35
+ function hctToHex(h: number, c: number, t: number): string {
36
+ const hct = Hct.from(h, c, t);
37
+ return rgbToHex(argbToRgb(hct.toInt()));
38
+ }
39
+
40
+ // --- generate_tonal_scale -----------------------------------------------------
41
+
42
+ export const generateTonalScaleTool = {
43
+ description:
44
+ "Generate a complete tonal scale (Tailwind-style 50/100/.../900/950) from a seed color. Uses Google's HCT color space so each step is perceptually even — ideal for building shadable design-system colors.",
45
+ execute: async (args: {
46
+ chromaBoost?: number;
47
+ name?: string;
48
+ seed: string;
49
+ stops?: number[];
50
+ }) => {
51
+ const hct = hctFromColor(args.seed);
52
+ if (!hct) return `Invalid color format: ${args.seed}`;
53
+
54
+ const stops = args.stops?.length ? args.stops : Array.from(TONAL_STOPS);
55
+ const chromaBoost = args.chromaBoost ?? 1;
56
+ const name = (args.name ?? "color").toLowerCase();
57
+
58
+ // Map Tailwind-style stops to HCT tones (50 -> tone 95, 950 -> tone 5).
59
+ const tones = stops.map((s) => Math.max(0, Math.min(100, 100 - s / 10)));
60
+
61
+ let output = `# Tonal scale for ${args.seed}\n`;
62
+ output += `Seed HCT: H=${hct.hue.toFixed(1)}° C=${hct.chroma.toFixed(1)} T=${hct.tone.toFixed(1)}\n\n`;
63
+ output += `| stop | tone | hex |\n|---|---|---|\n`;
64
+
65
+ const rows: { hex: string; stop: number; tone: number }[] = [];
66
+ for (let i = 0; i < stops.length; i++) {
67
+ const tone = tones[i];
68
+ // Reduce chroma near the extremes (very light / very dark) for legibility.
69
+ const edge = Math.abs(50 - tone) / 50; // 0 at mid, 1 at extremes
70
+ const chroma = hct.chroma * chromaBoost * (1 - 0.35 * edge);
71
+ const hex = hctToHex(hct.hue, chroma, tone);
72
+ rows.push({ hex, stop: stops[i], tone });
73
+ output += `| ${stops[i]} | ${tone.toFixed(0)} | ${hex} |\n`;
74
+ }
75
+
76
+ output += `\n## CSS\n\`\`\`css\n:root {\n`;
77
+ for (const r of rows) output += ` --${name}-${r.stop}: ${r.hex};\n`;
78
+ output += `}\n\`\`\`\n`;
79
+
80
+ return output;
81
+ },
82
+ name: "generate_tonal_scale",
83
+ parameters: z.object({
84
+ chromaBoost: z
85
+ .number()
86
+ .min(0)
87
+ .max(2)
88
+ .optional()
89
+ .default(1)
90
+ .describe("Multiplier on seed chroma (1 = preserve, <1 = muted)"),
91
+ name: z
92
+ .string()
93
+ .optional()
94
+ .describe("CSS variable base name (default: 'color')"),
95
+ seed: z.string().describe("Seed color (hex, rgb, hsl)"),
96
+ stops: z
97
+ .array(z.number().min(0).max(1000))
98
+ .optional()
99
+ .describe(
100
+ "Custom stops. Default: [50,100,200,300,400,500,600,700,800,900,950].",
101
+ ),
102
+ }),
103
+ };
104
+
105
+ // --- generate_state_colors ---------------------------------------------------
106
+
107
+ export const generateStateColorsTool = {
108
+ description:
109
+ "Generate consistent interaction-state colors (hover / active / pressed / focus / disabled / selected) from a base color. Steps are computed in HCT space so they keep equal perceptual weight regardless of base hue.",
110
+ execute: async (args: { base: string; isDark?: boolean }) => {
111
+ const hct = hctFromColor(args.base);
112
+ if (!hct) return `Invalid color format: ${args.base}`;
113
+
114
+ const dark = args.isDark ?? false;
115
+ // In dark mode, "hover" should lighten; in light mode, it should darken.
116
+ const dir = dark ? +1 : -1;
117
+
118
+ const states: { hex: string; note: string; state: string; tone: number }[] =
119
+ [
120
+ { hex: "", note: "resting", state: "base", tone: hct.tone },
121
+ {
122
+ hex: "",
123
+ note: "+/- 8 tone",
124
+ state: "hover",
125
+ tone: hct.tone + dir * 8,
126
+ },
127
+ {
128
+ hex: "",
129
+ note: "+/- 16 tone",
130
+ state: "active",
131
+ tone: hct.tone + dir * 16,
132
+ },
133
+ {
134
+ hex: "",
135
+ note: "same as active, alias",
136
+ state: "pressed",
137
+ tone: hct.tone + dir * 16,
138
+ },
139
+ {
140
+ hex: "",
141
+ note: "+/- 4 tone, used as outline",
142
+ state: "focus",
143
+ tone: hct.tone + dir * 4,
144
+ },
145
+ {
146
+ hex: "",
147
+ note: "low chroma, mid tone",
148
+ state: "disabled",
149
+ tone: dark ? 30 : 70,
150
+ },
151
+ {
152
+ hex: "",
153
+ note: "alpha-blend friendly base",
154
+ state: "selected",
155
+ tone: dark ? 25 : 90,
156
+ },
157
+ ];
158
+
159
+ for (const s of states) {
160
+ const chroma =
161
+ s.state === "disabled" ? Math.min(hct.chroma, 6) : hct.chroma;
162
+ const tone = Math.max(0, Math.min(100, s.tone));
163
+ s.hex = hctToHex(hct.hue, chroma, tone);
164
+ s.tone = tone;
165
+ }
166
+
167
+ let out = `# Interaction states for ${args.base} (${dark ? "dark" : "light"} mode)\n\n`;
168
+ out += `| state | tone | hex | notes |\n|---|---|---|---|\n`;
169
+ for (const s of states)
170
+ out += `| ${s.state} | ${s.tone.toFixed(0)} | ${s.hex} | ${s.note} |\n`;
171
+
172
+ return out;
173
+ },
174
+ name: "generate_state_colors",
175
+ parameters: z.object({
176
+ base: z.string().describe("Base/resting color (hex, rgb, hsl)"),
177
+ isDark: z
178
+ .boolean()
179
+ .optional()
180
+ .default(false)
181
+ .describe(
182
+ "Whether the base color sits on a dark background. Hover lightens on dark, darkens on light.",
183
+ ),
184
+ }),
185
+ };
186
+
187
+ // --- analyze_palette_consistency --------------------------------------------
188
+
189
+ export const analyzePaletteConsistencyTool = {
190
+ description:
191
+ "Score how visually cohesive a palette is. Reports tonal step uniformity, chroma spread, hue distribution, and a single 0-100 cohesion score, plus targeted suggestions for tightening it up.",
192
+ execute: async (args: { colors: string[] }) => {
193
+ if (args.colors.length < 2) {
194
+ return "Need at least 2 colors to analyze cohesion.";
195
+ }
196
+
197
+ const parsed = args.colors.map((c) => ({ input: c, rgb: parseColor(c) }));
198
+ if (parsed.some((p) => !p.rgb)) {
199
+ const bad = parsed.filter((p) => !p.rgb).map((p) => p.input);
200
+ return `Invalid color format: ${bad.join(", ")}`;
201
+ }
202
+
203
+ const hcts = parsed.map((p) => Hct.fromInt(rgbToArgb(p.rgb!)));
204
+
205
+ const tones = hcts.map((h) => h.tone).sort((a, b) => a - b);
206
+ const chromas = hcts.map((h) => h.chroma);
207
+ const hues = hcts.map((h) => h.hue);
208
+
209
+ // Tonal step uniformity — std-dev of gaps between sorted tones.
210
+ const gaps: number[] = [];
211
+ for (let i = 1; i < tones.length; i++) gaps.push(tones[i] - tones[i - 1]);
212
+ const avgGap = gaps.reduce((a, b) => a + b, 0) / (gaps.length || 1);
213
+ const gapStd = Math.sqrt(
214
+ gaps.reduce((a, b) => a + (b - avgGap) ** 2, 0) / (gaps.length || 1),
215
+ );
216
+ // Lower std relative to avg = more uniform. Score 100 when std ≈ 0.
217
+ const toneUniformity = Math.max(
218
+ 0,
219
+ 100 - (gapStd / Math.max(1, avgGap)) * 50,
220
+ );
221
+
222
+ // Chroma cohesion — std-dev of chroma, lower is more cohesive.
223
+ const avgChroma = chromas.reduce((a, b) => a + b, 0) / chromas.length;
224
+ const chromaStd = Math.sqrt(
225
+ chromas.reduce((a, b) => a + (b - avgChroma) ** 2, 0) / chromas.length,
226
+ );
227
+ const chromaCohesion = Math.max(0, 100 - chromaStd * 2);
228
+
229
+ // Hue harmony — cluster hues; reward palettes whose hues sit within a few
230
+ // recognized harmonic offsets (0, 30, 60, 90, 120, 150, 180 ±15°).
231
+ const targets = [0, 30, 60, 90, 120, 150, 180];
232
+ let harmonyHits = 0;
233
+ for (let i = 0; i < hues.length; i++) {
234
+ for (let j = i + 1; j < hues.length; j++) {
235
+ let d = Math.abs(hues[i] - hues[j]);
236
+ if (d > 180) d = 360 - d;
237
+ if (targets.some((t) => Math.abs(d - t) <= 15)) harmonyHits++;
238
+ }
239
+ }
240
+ const totalPairs = (hues.length * (hues.length - 1)) / 2;
241
+ const hueHarmony = totalPairs ? (harmonyHits / totalPairs) * 100 : 100;
242
+
243
+ const cohesion =
244
+ 0.4 * toneUniformity + 0.3 * chromaCohesion + 0.3 * hueHarmony;
245
+
246
+ // Find the most "off" color — the one that pushes std the most.
247
+ let outlier = -1;
248
+ let outlierGain = 0;
249
+ for (let i = 0; i < hcts.length; i++) {
250
+ const reduced = hcts.filter((_, k) => k !== i).map((h) => h.chroma);
251
+ const m = reduced.reduce((a, b) => a + b, 0) / reduced.length;
252
+ const std = Math.sqrt(
253
+ reduced.reduce((a, b) => a + (b - m) ** 2, 0) / reduced.length,
254
+ );
255
+ const gain = chromaStd - std;
256
+ if (gain > outlierGain) {
257
+ outlierGain = gain;
258
+ outlier = i;
259
+ }
260
+ }
261
+
262
+ let out = `# Palette cohesion analysis\n\n`;
263
+ out += `Colors: ${args.colors.length}\n\n`;
264
+ out += `| metric | score | detail |\n|---|---|---|\n`;
265
+ out += `| Tonal step uniformity | ${toneUniformity.toFixed(0)} | gap avg ${avgGap.toFixed(1)} ± ${gapStd.toFixed(1)} |\n`;
266
+ out += `| Chroma cohesion | ${chromaCohesion.toFixed(0)} | chroma avg ${avgChroma.toFixed(1)} ± ${chromaStd.toFixed(1)} |\n`;
267
+ out += `| Hue harmony | ${hueHarmony.toFixed(0)} | ${harmonyHits}/${totalPairs} pairs at harmonic angles |\n`;
268
+ out += `| **Overall cohesion** | **${cohesion.toFixed(0)}** | weighted 0.4 / 0.3 / 0.3 |\n\n`;
269
+
270
+ const suggestions: string[] = [];
271
+ if (toneUniformity < 60)
272
+ suggestions.push(
273
+ `Tone gaps are uneven (std ${gapStd.toFixed(1)}). Snap colors to a fixed scale (e.g. tones 10/30/50/70/90).`,
274
+ );
275
+ if (chromaCohesion < 60)
276
+ suggestions.push(
277
+ `Chroma varies widely (std ${chromaStd.toFixed(1)}). Mute saturated colors or boost flat ones toward chroma ${avgChroma.toFixed(0)}.`,
278
+ );
279
+ if (hueHarmony < 50)
280
+ suggestions.push(
281
+ `Hues don't sit at harmonic angles (30/60/90/120/180°). Try rotating outliers onto the nearest harmonic.`,
282
+ );
283
+ if (outlier >= 0)
284
+ suggestions.push(
285
+ `Likely outlier: ${args.colors[outlier]} (chroma ${hcts[outlier].chroma.toFixed(0)} vs avg ${avgChroma.toFixed(0)}).`,
286
+ );
287
+
288
+ if (suggestions.length === 0)
289
+ out += `✓ Palette is visually cohesive across all three axes.\n`;
290
+ else {
291
+ out += `## Suggestions\n`;
292
+ for (const s of suggestions) out += `- ${s}\n`;
293
+ }
294
+
295
+ return out;
296
+ },
297
+ name: "analyze_palette_consistency",
298
+ parameters: z.object({
299
+ colors: z.array(z.string()).min(2).describe("Palette colors to analyze"),
300
+ }),
301
+ };
302
+
303
+ // --- generate_semantic_palette ----------------------------------------------
304
+
305
+ const SEMANTIC_OFFSETS: Record<string, number> = {
306
+ // Hue offsets from brand color (in HCT degrees).
307
+ // Secondary/tertiary use harmonic rotations; semantic statuses anchor to
308
+ // conventional hue ranges (red/yellow/blue) but adjust chroma/tone to feel
309
+ // like they belong to the same family.
310
+ primary: 0,
311
+ secondary: -30,
312
+ tertiary: 60,
313
+ };
314
+
315
+ const SEMANTIC_ANCHORS: Record<string, { chromaMin: number; hue: number }> = {
316
+ // Anchored hues for status colors. Chroma is clamped down to whatever the
317
+ // brand can muster so the status palette doesn't out-shout the brand.
318
+ error: { chromaMin: 40, hue: 25 }, // red
319
+ info: { chromaMin: 30, hue: 240 }, // blue
320
+ success: { chromaMin: 30, hue: 142 }, // green
321
+ warning: { chromaMin: 50, hue: 80 }, // amber
322
+ };
323
+
324
+ export const generateSemanticPaletteTool = {
325
+ description:
326
+ "From a single brand color, generate a complete visually-cohesive semantic palette: primary, secondary, tertiary, plus success/warning/error/info status colors. Tone and chroma are normalized in HCT space so every color feels like part of the same family.",
327
+ execute: async (args: { brand: string; isDark?: boolean }) => {
328
+ const hct = hctFromColor(args.brand);
329
+ if (!hct) return `Invalid color format: ${args.brand}`;
330
+
331
+ const dark = args.isDark ?? false;
332
+ const targetTone = dark ? 80 : 40;
333
+ const familyChroma = Math.max(24, Math.min(hct.chroma, 80));
334
+
335
+ const entries: { hex: string; hue: number; name: string; tone: number }[] =
336
+ [];
337
+
338
+ for (const [name, offset] of Object.entries(SEMANTIC_OFFSETS)) {
339
+ const hue = (hct.hue + offset + 360) % 360;
340
+ const hex = hctToHex(hue, familyChroma, targetTone);
341
+ entries.push({ hex, hue, name, tone: targetTone });
342
+ }
343
+
344
+ for (const [name, anchor] of Object.entries(SEMANTIC_ANCHORS)) {
345
+ const chroma = Math.max(anchor.chromaMin, Math.min(familyChroma, 90));
346
+ const hex = hctToHex(anchor.hue, chroma, targetTone);
347
+ entries.push({ hex, hue: anchor.hue, name, tone: targetTone });
348
+ }
349
+
350
+ // Quick sanity check: how close are the status hues to the brand? Report
351
+ // the perceptual distance so the user knows what they're getting.
352
+ const brandRgb = parseColor(args.brand)!;
353
+
354
+ let out = `# Semantic palette derived from ${args.brand}\n`;
355
+ out += `Mode: ${dark ? "dark" : "light"} (target tone ${targetTone})\n`;
356
+ out += `Family chroma: ${familyChroma.toFixed(0)} (brand was ${hct.chroma.toFixed(0)})\n\n`;
357
+ out += `| role | hue | hex | ΔE2000 from brand | hsl |\n|---|---|---|---|---|\n`;
358
+ for (const e of entries) {
359
+ const rgb = parseColor(e.hex)!;
360
+ const delta = colorDistance(brandRgb, rgb, { metric: "deltaE2000" });
361
+ const hsl = rgbToHsl(rgb);
362
+ out += `| ${e.name} | ${e.hue.toFixed(0)}° | ${e.hex} | ${delta.toFixed(1)} | hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%) |\n`;
363
+ }
364
+
365
+ out += `\n## CSS\n\`\`\`css\n:root {\n`;
366
+ for (const e of entries) out += ` --color-${e.name}: ${e.hex};\n`;
367
+ out += `}\n\`\`\`\n`;
368
+
369
+ return out;
370
+ },
371
+ name: "generate_semantic_palette",
372
+ parameters: z.object({
373
+ brand: z.string().describe("Brand / seed color (hex, rgb, hsl)"),
374
+ isDark: z
375
+ .boolean()
376
+ .optional()
377
+ .default(false)
378
+ .describe("Generate the palette for a dark theme background"),
379
+ }),
380
+ };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Color Blindness Simulation Tool
3
+ * Simulate how colors appear to viewers with color vision deficiency (CVD)
4
+ * and audit palette accessibility for common deficiency types.
5
+ */
6
+
7
+ import { z } from "zod";
8
+
9
+ import {
10
+ CVD_PREVALENCE,
11
+ CvdType,
12
+ simulateCvd,
13
+ } from "../color/color-blindness.js";
14
+ import {
15
+ colorDistance,
16
+ getContrastRatio,
17
+ parseColor,
18
+ rgbToHex,
19
+ } from "../color/index.js";
20
+
21
+ const CVD_TYPES = [
22
+ "protanopia",
23
+ "deuteranopia",
24
+ "tritanopia",
25
+ "protanomaly",
26
+ "deuteranomaly",
27
+ "tritanomaly",
28
+ "achromatopsia",
29
+ ] as const satisfies readonly CvdType[];
30
+
31
+ export const simulateColorBlindnessTool = {
32
+ description:
33
+ "Simulate how one or more colors appear to viewers with color vision deficiency (protanopia, deuteranopia, tritanopia, their milder anomaly forms, or achromatopsia).",
34
+ execute: async (args: { colors: string[]; types?: CvdType[] }) => {
35
+ const types = args.types && args.types.length > 0 ? args.types : CVD_TYPES;
36
+
37
+ const parsed = args.colors.map((c) => ({ input: c, rgb: parseColor(c) }));
38
+ const invalid = parsed.filter((p) => !p.rgb);
39
+ if (invalid.length) {
40
+ return `Invalid color format: ${invalid.map((p) => p.input).join(", ")}`;
41
+ }
42
+
43
+ let output = `# Color Blindness Simulation\n\n`;
44
+ output += `| Original | ${types.join(" | ")} |\n`;
45
+ output += `|${"---|".repeat(types.length + 1)}\n`;
46
+
47
+ for (const { input, rgb } of parsed) {
48
+ const simulated = types.map((t) => rgbToHex(simulateCvd(rgb!, t)));
49
+ output += `| ${rgbToHex(rgb!)} (${input}) | ${simulated.join(" | ")} |\n`;
50
+ }
51
+
52
+ output += `\n## Population prevalence\n`;
53
+ for (const t of types) {
54
+ output += `- **${t}**: ~${CVD_PREVALENCE[t]}% of population\n`;
55
+ }
56
+
57
+ return output;
58
+ },
59
+ name: "simulate_color_blindness",
60
+ parameters: z.object({
61
+ colors: z
62
+ .array(z.string())
63
+ .min(1)
64
+ .describe("Colors to simulate (hex, rgb, hsl)"),
65
+ types: z
66
+ .array(z.enum(CVD_TYPES))
67
+ .optional()
68
+ .describe(
69
+ "Deficiency types to simulate. Defaults to all 7 (dichromacy + anomaly + achromatopsia).",
70
+ ),
71
+ }),
72
+ };
73
+
74
+ export const checkPaletteAccessibilityTool = {
75
+ description:
76
+ "Audit a color palette for color-blind accessibility. For each pair of colors, reports the perceptual distance (Delta E 2000) under each CVD type and flags pairs that become indistinguishable.",
77
+ execute: async (args: {
78
+ colors: string[];
79
+ indistinguishableThreshold?: number;
80
+ types?: CvdType[];
81
+ }) => {
82
+ const types = args.types && args.types.length > 0 ? args.types : CVD_TYPES;
83
+ const threshold = args.indistinguishableThreshold ?? 10;
84
+
85
+ const parsed = args.colors.map((c) => ({ input: c, rgb: parseColor(c) }));
86
+ const invalid = parsed.filter((p) => !p.rgb);
87
+ if (invalid.length) {
88
+ return `Invalid color format: ${invalid.map((p) => p.input).join(", ")}`;
89
+ }
90
+
91
+ const colors = parsed.map((p) => p.rgb!);
92
+ const labels = parsed.map((p) => rgbToHex(p.rgb!));
93
+
94
+ let output = `# Palette Accessibility Audit\n\n`;
95
+ output += `Indistinguishable threshold: ΔE2000 < ${threshold}\n\n`;
96
+
97
+ const problems: string[] = [];
98
+
99
+ for (const type of types) {
100
+ const simulated = colors.map((c) => simulateCvd(c, type));
101
+ const collisions: string[] = [];
102
+
103
+ for (let i = 0; i < simulated.length; i++) {
104
+ for (let j = i + 1; j < simulated.length; j++) {
105
+ const d = colorDistance(simulated[i], simulated[j], {
106
+ metric: "deltaE2000",
107
+ });
108
+ if (d < threshold) {
109
+ collisions.push(
110
+ ` - ${labels[i]} ↔ ${labels[j]}: ΔE=${d.toFixed(1)} (sim: ${rgbToHex(
111
+ simulated[i],
112
+ )} vs ${rgbToHex(simulated[j])})`,
113
+ );
114
+ }
115
+ }
116
+ }
117
+
118
+ output += `## ${type} (~${CVD_PREVALENCE[type]}% of population)\n`;
119
+ if (collisions.length === 0) {
120
+ output += `✓ All ${colors.length} colors remain distinguishable\n\n`;
121
+ } else {
122
+ output += `⚠ ${collisions.length} indistinguishable pair${collisions.length === 1 ? "" : "s"}:\n`;
123
+ output += collisions.join("\n") + "\n\n";
124
+ problems.push(type);
125
+ }
126
+ }
127
+
128
+ output += `## Summary\n`;
129
+ if (problems.length === 0) {
130
+ output += `✓ Palette is accessible across all tested CVD types.\n`;
131
+ } else {
132
+ output += `⚠ Issues detected in: ${problems.join(", ")}.\n`;
133
+ output += `Consider increasing tonal contrast (vary lightness) or chroma; CVD-friendly palettes rely on lightness differences rather than hue alone.\n`;
134
+
135
+ // Quick contrast hint: compute mean luminance contrast within the palette
136
+ let worst = Infinity;
137
+ for (let i = 0; i < colors.length; i++) {
138
+ for (let j = i + 1; j < colors.length; j++) {
139
+ const r = getContrastRatio(colors[i], colors[j]);
140
+ if (r < worst) worst = r;
141
+ }
142
+ }
143
+ output += `Lowest WCAG luminance ratio in palette: ${worst.toFixed(2)}:1 (target ≥ 3:1 for adjacent UI swatches).\n`;
144
+ }
145
+
146
+ return output;
147
+ },
148
+ name: "check_palette_accessibility",
149
+ parameters: z.object({
150
+ colors: z
151
+ .array(z.string())
152
+ .min(2)
153
+ .describe("Palette colors to audit (at least 2)"),
154
+ indistinguishableThreshold: z
155
+ .number()
156
+ .min(1)
157
+ .max(30)
158
+ .optional()
159
+ .default(10)
160
+ .describe(
161
+ "ΔE2000 below which two simulated colors are considered indistinguishable (default 10)",
162
+ ),
163
+ types: z
164
+ .array(z.enum(CVD_TYPES))
165
+ .optional()
166
+ .describe("CVD types to audit. Defaults to all 7."),
167
+ }),
168
+ };
@@ -41,7 +41,7 @@ export const colorConversionTool = {
41
41
  return `lab(${lab.l.toFixed(2)}, ${lab.a.toFixed(2)}, ${lab.b.toFixed(2)})`;
42
42
  }
43
43
  case "rgb":
44
- return `rgb(${Math.round(rgb.r * 255)}, ${Math.round(rgb.g * 255)}, ${Math.round(rgb.b * 255)})`;
44
+ return `rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`;
45
45
  default:
46
46
  return `Invalid format: ${args.to}`;
47
47
  }
@@ -1,15 +1,22 @@
1
1
  /**
2
2
  * Contrast Checker Tool
3
- * Check WCAG contrast ratio between two colors
3
+ * Check contrast between two colors using WCAG 2.x luminance ratio,
4
+ * APCA (WCAG 3 draft), or both.
4
5
  */
5
6
 
6
7
  import { z } from "zod";
7
8
 
9
+ import { apcaContrast, apcaLevel } from "../color/apca.js";
8
10
  import { getContrastRatio, parseColor } from "../color/index.js";
9
11
 
10
12
  export const contrastCheckerTool = {
11
- description: "Check WCAG contrast ratio between two colors",
12
- execute: async (args: { background: string; foreground: string }) => {
13
+ description:
14
+ "Check contrast between two colors. Supports WCAG 2.x luminance ratio (default), APCA Lc (WCAG 3 draft), or both algorithms side-by-side.",
15
+ execute: async (args: {
16
+ algorithm?: "apca" | "both" | "wcag";
17
+ background: string;
18
+ foreground: string;
19
+ }) => {
13
20
  const fg = parseColor(args.foreground);
14
21
  const bg = parseColor(args.background);
15
22
 
@@ -17,20 +24,52 @@ export const contrastCheckerTool = {
17
24
  return `Invalid color format: ${!fg ? args.foreground : args.background}`;
18
25
  }
19
26
 
20
- const ratio = getContrastRatio(fg, bg);
21
- const aa = ratio >= 4.5;
22
- const aaLarge = ratio >= 3;
23
- const aaa = ratio >= 7;
24
- const aaaLarge = ratio >= 4.5;
25
-
26
- return `Contrast Ratio: ${ratio.toFixed(2)}:1
27
- WCAG AA: ${aa ? "✓ Pass" : "✗ Fail"} (normal text)
28
- WCAG AA: ${aaLarge ? "✓ Pass" : "✗ Fail"} (large text)
29
- WCAG AAA: ${aaa ? "✓ Pass" : "✗ Fail"} (normal text)
30
- WCAG AAA: ${aaaLarge ? "✓ Pass" : "✗ Fail"} (large text)`;
27
+ const algorithm = args.algorithm ?? "wcag";
28
+
29
+ const wcagBlock = () => {
30
+ const ratio = getContrastRatio(fg, bg);
31
+ const aaLarge = ratio >= 3;
32
+ const aa = ratio >= 4.5;
33
+ const aaaLarge = ratio >= 4.5;
34
+ const aaa = ratio >= 7;
35
+ return `## WCAG 2.x luminance ratio
36
+ Contrast Ratio: ${ratio.toFixed(2)}:1
37
+ - AA (normal text): ${aa ? "✓ Pass" : "✗ Fail"} (need 4.5:1)
38
+ - AA (large text): ${aaLarge ? "✓ Pass" : "✗ Fail"} (need 3:1)
39
+ - AAA (normal text): ${aaa ? "✓ Pass" : "✗ Fail"} (need 7:1)
40
+ - AAA (large text): ${aaaLarge ? "✓ Pass" : "✗ Fail"} (need 4.5:1)`;
41
+ };
42
+
43
+ const apcaBlock = () => {
44
+ const lc = apcaContrast(fg, bg);
45
+ const level = apcaLevel(lc);
46
+ const polarity =
47
+ lc > 0
48
+ ? "dark text on light bg"
49
+ : lc < 0
50
+ ? "light text on dark bg"
51
+ : "no contrast";
52
+ return `## APCA (WCAG 3 draft)
53
+ Lc: ${lc.toFixed(1)} (${polarity})
54
+ - Body text (|Lc| ≥ 75): ${level.body ? "✓ Pass" : "✗ Fail"}
55
+ - Content text (|Lc| ≥ 60): ${level.content ? "✓ Pass" : "✗ Fail"}
56
+ - Large text (|Lc| ≥ 45): ${level.large ? "✓ Pass" : "✗ Fail"}
57
+ - Spot / non-content (|Lc| ≥ 30): ${level.spot ? "✓ Pass" : "✗ Fail"}`;
58
+ };
59
+
60
+ if (algorithm === "wcag") return wcagBlock();
61
+ if (algorithm === "apca") return apcaBlock();
62
+ return `${wcagBlock()}\n\n${apcaBlock()}`;
31
63
  },
32
64
  name: "check_contrast",
33
65
  parameters: z.object({
66
+ algorithm: z
67
+ .enum(["wcag", "apca", "both"])
68
+ .optional()
69
+ .default("wcag")
70
+ .describe(
71
+ "Contrast algorithm: 'wcag' (WCAG 2.x ratio), 'apca' (Lc, WCAG 3 draft), or 'both'",
72
+ ),
34
73
  background: z.string().describe("Background color"),
35
74
  foreground: z.string().describe("Foreground/text color"),
36
75
  }),