sonance-brand-mcp 1.3.51 → 1.3.55

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.
@@ -0,0 +1,755 @@
1
+ /**
2
+ * Theme Discovery Module for Sonance DevTools
3
+ *
4
+ * Dynamically discovers and parses theme/design system files from target codebases.
5
+ * Extracts CSS variables, Tailwind colors, and available semantic tokens to provide
6
+ * context to the LLM for intelligent styling decisions.
7
+ *
8
+ * NEW: Includes WCAG contrast ratio calculation to warn about bad color combinations.
9
+ */
10
+
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+
14
+ // ============================================================================
15
+ // TYPE DEFINITIONS
16
+ // ============================================================================
17
+
18
+ /**
19
+ * RGB color representation
20
+ */
21
+ interface RGB {
22
+ r: number;
23
+ g: number;
24
+ b: number;
25
+ }
26
+
27
+ /**
28
+ * Contrast warning for a failing color combination
29
+ */
30
+ interface ContrastWarning {
31
+ background: string;
32
+ text: string;
33
+ ratio: string;
34
+ suggestion: string;
35
+ }
36
+
37
+ /**
38
+ * Safe color pattern that passes WCAG
39
+ */
40
+ interface SafePattern {
41
+ background: string;
42
+ text: string;
43
+ ratio: string;
44
+ }
45
+
46
+ /**
47
+ * Result of contrast analysis
48
+ */
49
+ interface ContrastAnalysis {
50
+ warnings: ContrastWarning[];
51
+ safePatterns: SafePattern[];
52
+ }
53
+
54
+ /**
55
+ * Discovered theme information from a codebase
56
+ */
57
+ export interface DiscoveredTheme {
58
+ // Raw CSS variable definitions found (e.g., { "--accent": "#00D3C8" })
59
+ cssVariables: Record<string, string>;
60
+
61
+ // Tailwind custom colors from config (if found)
62
+ tailwindColors: Record<string, string>;
63
+
64
+ // Available semantic class tokens discovered
65
+ availableTokens: string[];
66
+
67
+ // Full raw theme CSS content for additional context
68
+ rawThemeCSS: string;
69
+
70
+ // Files that were discovered and parsed
71
+ discoveredFiles: string[];
72
+
73
+ // Contrast analysis results
74
+ contrastAnalysis?: ContrastAnalysis;
75
+ }
76
+
77
+ // ============================================================================
78
+ // COLOR PARSING FUNCTIONS
79
+ // ============================================================================
80
+
81
+ /**
82
+ * Convert HSL values to RGB
83
+ * Handles formats: "195 100% 41%" or "hsl(195, 100%, 41%)" or "195deg 100% 41%"
84
+ */
85
+ function hslToRgb(h: number, s: number, l: number): RGB {
86
+ // Normalize h to 0-360, s and l to 0-1
87
+ h = ((h % 360) + 360) % 360;
88
+ s = Math.max(0, Math.min(1, s / 100));
89
+ l = Math.max(0, Math.min(1, l / 100));
90
+
91
+ const k = (n: number) => (n + h / 30) % 12;
92
+ const a = s * Math.min(l, 1 - l);
93
+ const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
94
+
95
+ return {
96
+ r: Math.round(f(0) * 255),
97
+ g: Math.round(f(8) * 255),
98
+ b: Math.round(f(4) * 255),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Parse hex color to RGB
104
+ * Handles: #fff, #ffffff, fff, ffffff
105
+ */
106
+ function hexToRgb(hex: string): RGB | null {
107
+ // Remove # if present
108
+ hex = hex.replace(/^#/, "");
109
+
110
+ // Expand shorthand (e.g., "fff" -> "ffffff")
111
+ if (hex.length === 3) {
112
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
113
+ }
114
+
115
+ if (hex.length !== 6) {
116
+ return null;
117
+ }
118
+
119
+ const r = parseInt(hex.substring(0, 2), 16);
120
+ const g = parseInt(hex.substring(2, 4), 16);
121
+ const b = parseInt(hex.substring(4, 6), 16);
122
+
123
+ if (isNaN(r) || isNaN(g) || isNaN(b)) {
124
+ return null;
125
+ }
126
+
127
+ return { r, g, b };
128
+ }
129
+
130
+ /**
131
+ * Parse CSS color value to RGB
132
+ * Handles multiple formats:
133
+ * - Hex: #fff, #ffffff
134
+ * - RGB: rgb(255, 255, 255), rgb(255 255 255)
135
+ * - HSL: hsl(195, 100%, 41%), hsl(195 100% 41%)
136
+ * - Space-separated HSL: 195 100% 41% (common in Tailwind/shadcn)
137
+ * - oklch: oklch(0.7 0.15 195) - approximated
138
+ */
139
+ function parseColorToRgb(value: string): RGB | null {
140
+ if (!value || typeof value !== "string") {
141
+ return null;
142
+ }
143
+
144
+ const trimmed = value.trim().toLowerCase();
145
+
146
+ // Skip CSS variable references - we can't resolve these without runtime
147
+ if (trimmed.includes("var(")) {
148
+ return null;
149
+ }
150
+
151
+ // Handle hex colors
152
+ if (trimmed.startsWith("#") || /^[0-9a-f]{3,6}$/i.test(trimmed)) {
153
+ return hexToRgb(trimmed);
154
+ }
155
+
156
+ // Handle rgb() and rgba()
157
+ const rgbMatch = trimmed.match(/rgba?\s*\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/);
158
+ if (rgbMatch) {
159
+ return {
160
+ r: parseInt(rgbMatch[1], 10),
161
+ g: parseInt(rgbMatch[2], 10),
162
+ b: parseInt(rgbMatch[3], 10),
163
+ };
164
+ }
165
+
166
+ // Handle hsl() and hsla()
167
+ const hslFuncMatch = trimmed.match(/hsla?\s*\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/);
168
+ if (hslFuncMatch) {
169
+ const h = parseFloat(hslFuncMatch[1]);
170
+ const s = parseFloat(hslFuncMatch[2]);
171
+ const l = parseFloat(hslFuncMatch[3]);
172
+ return hslToRgb(h, s, l);
173
+ }
174
+
175
+ // Handle space-separated HSL (common in Tailwind/shadcn): "195 100% 41%"
176
+ const hslSpaceMatch = trimmed.match(/^([\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%$/);
177
+ if (hslSpaceMatch) {
178
+ const h = parseFloat(hslSpaceMatch[1]);
179
+ const s = parseFloat(hslSpaceMatch[2]);
180
+ const l = parseFloat(hslSpaceMatch[3]);
181
+ return hslToRgb(h, s, l);
182
+ }
183
+
184
+ // Handle oklch() - approximate conversion (not perfect but gives reasonable results)
185
+ const oklchMatch = trimmed.match(/oklch\s*\(\s*([\d.]+)(?:%?)\s+([\d.]+)\s+([\d.]+)/);
186
+ if (oklchMatch) {
187
+ // Simplified oklch to RGB approximation
188
+ // L is lightness (0-1 or 0-100%), C is chroma, H is hue
189
+ let l = parseFloat(oklchMatch[1]);
190
+ if (l > 1) l = l / 100; // Handle percentage format
191
+ const c = parseFloat(oklchMatch[2]);
192
+ const h = parseFloat(oklchMatch[3]);
193
+
194
+ // Very rough approximation: treat as HSL with adjusted values
195
+ // This won't be perfect but gives a reasonable estimate for contrast
196
+ const approxS = Math.min(100, c * 300); // Chroma to saturation approximation
197
+ const approxL = l * 100;
198
+ return hslToRgb(h, approxS, approxL);
199
+ }
200
+
201
+ // Handle named colors (common ones)
202
+ const namedColors: Record<string, RGB> = {
203
+ white: { r: 255, g: 255, b: 255 },
204
+ black: { r: 0, g: 0, b: 0 },
205
+ transparent: { r: 0, g: 0, b: 0 }, // Treat as black for contrast purposes
206
+ red: { r: 255, g: 0, b: 0 },
207
+ green: { r: 0, g: 128, b: 0 },
208
+ blue: { r: 0, g: 0, b: 255 },
209
+ yellow: { r: 255, g: 255, b: 0 },
210
+ cyan: { r: 0, g: 255, b: 255 },
211
+ magenta: { r: 255, g: 0, b: 255 },
212
+ gray: { r: 128, g: 128, b: 128 },
213
+ grey: { r: 128, g: 128, b: 128 },
214
+ };
215
+
216
+ if (namedColors[trimmed]) {
217
+ return namedColors[trimmed];
218
+ }
219
+
220
+ return null;
221
+ }
222
+
223
+ // ============================================================================
224
+ // WCAG CONTRAST CALCULATION
225
+ // ============================================================================
226
+
227
+ /**
228
+ * Calculate relative luminance using WCAG formula
229
+ * https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
230
+ */
231
+ function getLuminance(r: number, g: number, b: number): number {
232
+ const [rs, gs, bs] = [r, g, b].map((c) => {
233
+ c /= 255;
234
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
235
+ });
236
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
237
+ }
238
+
239
+ /**
240
+ * Calculate WCAG contrast ratio between two colors
241
+ * Returns ratio like 4.5, 7.0, 21.0 (max), etc.
242
+ * WCAG AA requires 4.5:1 for normal text, 3:1 for large text
243
+ * WCAG AAA requires 7:1 for normal text, 4.5:1 for large text
244
+ */
245
+ function getContrastRatio(rgb1: RGB, rgb2: RGB): number {
246
+ const l1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
247
+ const l2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
248
+ const lighter = Math.max(l1, l2);
249
+ const darker = Math.min(l1, l2);
250
+ return (lighter + 0.05) / (darker + 0.05);
251
+ }
252
+
253
+ /**
254
+ * Determine which alternative (white or black) provides better contrast
255
+ */
256
+ function getBestAlternative(bgRgb: RGB): { color: string; ratio: number } {
257
+ const whiteRgb: RGB = { r: 255, g: 255, b: 255 };
258
+ const blackRgb: RGB = { r: 0, g: 0, b: 0 };
259
+
260
+ const whiteRatio = getContrastRatio(bgRgb, whiteRgb);
261
+ const blackRatio = getContrastRatio(bgRgb, blackRgb);
262
+
263
+ if (whiteRatio >= blackRatio) {
264
+ return { color: "text-white", ratio: whiteRatio };
265
+ } else {
266
+ return { color: "text-black", ratio: blackRatio };
267
+ }
268
+ }
269
+
270
+ // ============================================================================
271
+ // THEME FILE DISCOVERY
272
+ // ============================================================================
273
+
274
+ /**
275
+ * Patterns for finding theme-related files
276
+ */
277
+ const THEME_FILE_PATTERNS = [
278
+ // CSS files
279
+ "src/app/globals.css",
280
+ "app/globals.css",
281
+ "src/styles/globals.css",
282
+ "styles/globals.css",
283
+ "src/styles/global.css",
284
+ "styles/global.css",
285
+ "src/styles/variables.css",
286
+ "styles/variables.css",
287
+ "src/styles/theme.css",
288
+ "styles/theme.css",
289
+ "src/styles/brand-overrides.css",
290
+ "styles/brand-overrides.css",
291
+ // Tailwind config files
292
+ "tailwind.config.js",
293
+ "tailwind.config.ts",
294
+ "tailwind.config.mjs",
295
+ "tailwind.config.cjs",
296
+ ];
297
+
298
+ /**
299
+ * Find all theme-related files in a project
300
+ */
301
+ export function findThemeFiles(projectRoot: string): string[] {
302
+ const foundFiles: string[] = [];
303
+
304
+ for (const pattern of THEME_FILE_PATTERNS) {
305
+ const fullPath = path.join(projectRoot, pattern);
306
+ if (fs.existsSync(fullPath)) {
307
+ foundFiles.push(pattern);
308
+ }
309
+ }
310
+
311
+ // Also search for any additional CSS files in common locations
312
+ const additionalDirs = ["src/styles", "styles", "src/app", "app"];
313
+ for (const dir of additionalDirs) {
314
+ const dirPath = path.join(projectRoot, dir);
315
+ if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
316
+ try {
317
+ const files = fs.readdirSync(dirPath);
318
+ for (const file of files) {
319
+ if (file.endsWith(".css") && !foundFiles.includes(path.join(dir, file))) {
320
+ foundFiles.push(path.join(dir, file));
321
+ }
322
+ }
323
+ } catch {
324
+ // Ignore read errors
325
+ }
326
+ }
327
+ }
328
+
329
+ return foundFiles;
330
+ }
331
+
332
+ /**
333
+ * Parse CSS content and extract variable definitions
334
+ * Extracts patterns like: --primary: #333F48; or --accent: hsl(180 100% 50%);
335
+ */
336
+ export function parseCSSVariables(cssContent: string): Record<string, string> {
337
+ const variables: Record<string, string> = {};
338
+
339
+ // Match CSS custom property definitions
340
+ // Handles: --name: value; with various value formats
341
+ const regex = /--([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
342
+
343
+ let match;
344
+ while ((match = regex.exec(cssContent)) !== null) {
345
+ const name = match[1].trim();
346
+ const value = match[2].trim();
347
+ variables[`--${name}`] = value;
348
+ }
349
+
350
+ return variables;
351
+ }
352
+
353
+ /**
354
+ * Parse Tailwind config file and extract custom color definitions
355
+ * Handles both JS and TS config formats
356
+ */
357
+ export function parseTailwindConfig(configPath: string, projectRoot: string): Record<string, string> {
358
+ const colors: Record<string, string> = {};
359
+
360
+ const fullPath = path.join(projectRoot, configPath);
361
+ if (!fs.existsSync(fullPath)) {
362
+ return colors;
363
+ }
364
+
365
+ try {
366
+ const content = fs.readFileSync(fullPath, "utf-8");
367
+
368
+ // Extract color definitions from the config
369
+ // This is a simplified parser that handles common patterns
370
+
371
+ // Pattern 1: colors: { name: "value" } or colors: { name: { DEFAULT: "value" } }
372
+ const colorBlockMatch = content.match(/colors\s*:\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}/s);
373
+ if (colorBlockMatch) {
374
+ const colorBlock = colorBlockMatch[1];
375
+
376
+ // Simple key-value pairs: name: "value" or name: 'value'
377
+ const simpleColorRegex = /(\w+[-\w]*)\s*:\s*["']([^"']+)["']/g;
378
+ let match;
379
+ while ((match = simpleColorRegex.exec(colorBlock)) !== null) {
380
+ colors[match[1]] = match[2];
381
+ }
382
+
383
+ // Nested objects with DEFAULT: name: { DEFAULT: "value" }
384
+ const nestedColorRegex = /(\w+[-\w]*)\s*:\s*\{\s*DEFAULT\s*:\s*["']([^"']+)["']/g;
385
+ while ((match = nestedColorRegex.exec(colorBlock)) !== null) {
386
+ colors[match[1]] = match[2];
387
+ }
388
+ }
389
+
390
+ // Pattern 2: extend: { colors: { ... } }
391
+ const extendColorMatch = content.match(/extend\s*:\s*\{[^}]*colors\s*:\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}/s);
392
+ if (extendColorMatch) {
393
+ const extendBlock = extendColorMatch[1];
394
+
395
+ const simpleColorRegex = /(\w+[-\w]*)\s*:\s*["']([^"']+)["']/g;
396
+ let match;
397
+ while ((match = simpleColorRegex.exec(extendBlock)) !== null) {
398
+ colors[match[1]] = match[2];
399
+ }
400
+
401
+ // CSS variable references: name: "var(--name)" or "hsl(var(--name))"
402
+ const varRefRegex = /(\w+[-\w]*)\s*:\s*["'](?:hsl\()?var\(--([^)]+)\)(?:\))?["']/g;
403
+ while ((match = varRefRegex.exec(extendBlock)) !== null) {
404
+ colors[match[1]] = `var(--${match[2]})`;
405
+ }
406
+ }
407
+
408
+ } catch (error) {
409
+ // Silently fail - config parsing is best-effort
410
+ console.warn(`[Theme Discovery] Failed to parse Tailwind config: ${error}`);
411
+ }
412
+
413
+ return colors;
414
+ }
415
+
416
+ /**
417
+ * Extract available semantic tokens from CSS content
418
+ * Finds class-like patterns that suggest available Tailwind/utility classes
419
+ */
420
+ export function extractAvailableTokens(cssVariables: Record<string, string>): string[] {
421
+ const tokens: string[] = [];
422
+
423
+ // Generate bg-* and text-* tokens from CSS variable names
424
+ for (const varName of Object.keys(cssVariables)) {
425
+ // Remove -- prefix
426
+ const name = varName.replace(/^--/, "");
427
+
428
+ // Skip numeric or utility variables
429
+ if (/^\d/.test(name) || name.includes("radius") || name.includes("shadow")) {
430
+ continue;
431
+ }
432
+
433
+ // Common color-related variable patterns
434
+ if (
435
+ name.includes("background") ||
436
+ name.includes("foreground") ||
437
+ name.includes("primary") ||
438
+ name.includes("secondary") ||
439
+ name.includes("accent") ||
440
+ name.includes("muted") ||
441
+ name.includes("destructive") ||
442
+ name.includes("success") ||
443
+ name.includes("warning") ||
444
+ name.includes("border") ||
445
+ name.includes("ring") ||
446
+ name.includes("card") ||
447
+ name.includes("popover")
448
+ ) {
449
+ // Add as potential class token
450
+ tokens.push(name);
451
+ }
452
+ }
453
+
454
+ // Always include common utility tokens
455
+ const commonTokens = [
456
+ "text-white",
457
+ "text-black",
458
+ "bg-white",
459
+ "bg-black",
460
+ "bg-transparent",
461
+ ];
462
+
463
+ return [...new Set([...tokens, ...commonTokens])];
464
+ }
465
+
466
+ // ============================================================================
467
+ // CONTRAST ANALYSIS
468
+ // ============================================================================
469
+
470
+ /**
471
+ * Analyze CSS variables to identify contrast relationships
472
+ * Calculates actual WCAG contrast ratios and returns warnings for failing combinations
473
+ */
474
+ export function analyzeContrastRelationships(
475
+ cssVariables: Record<string, string>
476
+ ): ContrastAnalysis {
477
+ const warnings: ContrastWarning[] = [];
478
+ const safePatterns: SafePattern[] = [];
479
+
480
+ // Track which backgrounds we've analyzed to add white/black alternatives
481
+ const analyzedBackgrounds: { name: string; rgb: RGB }[] = [];
482
+
483
+ // Find all bg/foreground pairs automatically
484
+ for (const [varName, value] of Object.entries(cssVariables)) {
485
+ // Skip if this is a foreground variable
486
+ if (varName.includes("foreground")) continue;
487
+
488
+ // Skip non-color variables
489
+ if (
490
+ varName.includes("radius") ||
491
+ varName.includes("shadow") ||
492
+ varName.includes("ring") ||
493
+ varName.includes("border") && !varName.match(/border$/)
494
+ ) {
495
+ continue;
496
+ }
497
+
498
+ const baseName = varName.replace(/^--/, "");
499
+ const foregroundVar = `--${baseName}-foreground`;
500
+
501
+ // Parse background color
502
+ const bgRgb = parseColorToRgb(value);
503
+ if (!bgRgb) continue;
504
+
505
+ // Track this background for alternative suggestions
506
+ analyzedBackgrounds.push({ name: baseName, rgb: bgRgb });
507
+
508
+ // Check if there's a corresponding foreground variable
509
+ if (cssVariables[foregroundVar]) {
510
+ const fgRgb = parseColorToRgb(cssVariables[foregroundVar]);
511
+
512
+ if (fgRgb) {
513
+ const ratio = getContrastRatio(bgRgb, fgRgb);
514
+ const passes = ratio >= 4.5; // WCAG AA for normal text
515
+
516
+ if (!passes) {
517
+ // Find the best alternative
518
+ const alternative = getBestAlternative(bgRgb);
519
+
520
+ warnings.push({
521
+ background: `bg-${baseName}`,
522
+ text: `text-${baseName}-foreground`,
523
+ ratio: ratio.toFixed(1),
524
+ suggestion: `Use ${alternative.color} instead (${alternative.ratio.toFixed(1)}:1 contrast)`,
525
+ });
526
+ } else {
527
+ safePatterns.push({
528
+ background: `bg-${baseName}`,
529
+ text: `text-${baseName}-foreground`,
530
+ ratio: ratio.toFixed(1),
531
+ });
532
+ }
533
+ }
534
+ }
535
+ }
536
+
537
+ // Add white/black alternatives for all analyzed backgrounds
538
+ const whiteRgb: RGB = { r: 255, g: 255, b: 255 };
539
+ const blackRgb: RGB = { r: 0, g: 0, b: 0 };
540
+
541
+ for (const { name, rgb } of analyzedBackgrounds) {
542
+ // Only add alternatives for semantic color names (primary, accent, etc.)
543
+ if (
544
+ name.includes("primary") ||
545
+ name.includes("secondary") ||
546
+ name.includes("accent") ||
547
+ name.includes("destructive") ||
548
+ name.includes("success") ||
549
+ name.includes("warning") ||
550
+ name.includes("muted")
551
+ ) {
552
+ const whiteRatio = getContrastRatio(rgb, whiteRgb);
553
+ const blackRatio = getContrastRatio(rgb, blackRgb);
554
+
555
+ if (whiteRatio >= 4.5) {
556
+ safePatterns.push({
557
+ background: `bg-${name}`,
558
+ text: "text-white",
559
+ ratio: whiteRatio.toFixed(1),
560
+ });
561
+ }
562
+
563
+ if (blackRatio >= 4.5) {
564
+ safePatterns.push({
565
+ background: `bg-${name}`,
566
+ text: "text-black",
567
+ ratio: blackRatio.toFixed(1),
568
+ });
569
+ }
570
+ }
571
+ }
572
+
573
+ return { warnings, safePatterns };
574
+ }
575
+
576
+ // ============================================================================
577
+ // MAIN ENTRY POINTS
578
+ // ============================================================================
579
+
580
+ /**
581
+ * Main entry point: Discover theme from a project
582
+ */
583
+ export async function discoverTheme(projectRoot: string): Promise<DiscoveredTheme> {
584
+ const result: DiscoveredTheme = {
585
+ cssVariables: {},
586
+ tailwindColors: {},
587
+ availableTokens: [],
588
+ rawThemeCSS: "",
589
+ discoveredFiles: [],
590
+ };
591
+
592
+ // Find theme files
593
+ const themeFiles = findThemeFiles(projectRoot);
594
+ result.discoveredFiles = themeFiles;
595
+
596
+ // Parse each file
597
+ for (const file of themeFiles) {
598
+ const fullPath = path.join(projectRoot, file);
599
+
600
+ try {
601
+ if (file.endsWith(".css")) {
602
+ const content = fs.readFileSync(fullPath, "utf-8");
603
+
604
+ // Accumulate raw CSS
605
+ result.rawThemeCSS += `\n/* === ${file} === */\n${content}\n`;
606
+
607
+ // Parse variables
608
+ const variables = parseCSSVariables(content);
609
+ Object.assign(result.cssVariables, variables);
610
+
611
+ } else if (file.includes("tailwind.config")) {
612
+ // Parse Tailwind config
613
+ const colors = parseTailwindConfig(file, projectRoot);
614
+ Object.assign(result.tailwindColors, colors);
615
+ }
616
+ } catch (error) {
617
+ console.warn(`[Theme Discovery] Error reading ${file}:`, error);
618
+ }
619
+ }
620
+
621
+ // Extract available tokens
622
+ result.availableTokens = extractAvailableTokens(result.cssVariables);
623
+
624
+ // Run contrast analysis
625
+ result.contrastAnalysis = analyzeContrastRelationships(result.cssVariables);
626
+
627
+ return result;
628
+ }
629
+
630
+ /**
631
+ * Format discovered theme as context for the LLM prompt
632
+ * Includes contrast warnings to prevent bad color combinations
633
+ */
634
+ export function formatThemeForPrompt(theme: DiscoveredTheme): string {
635
+ const lines: string[] = [];
636
+
637
+ lines.push("**TARGET CODEBASE THEME (discovered):**");
638
+ lines.push("");
639
+
640
+ // CSS Variables
641
+ if (Object.keys(theme.cssVariables).length > 0) {
642
+ lines.push("CSS Variables:");
643
+
644
+ // Group by category
645
+ const categories: Record<string, string[]> = {
646
+ colors: [],
647
+ other: [],
648
+ };
649
+
650
+ for (const [name, value] of Object.entries(theme.cssVariables)) {
651
+ const entry = ` ${name}: ${value}`;
652
+ if (
653
+ name.includes("background") ||
654
+ name.includes("foreground") ||
655
+ name.includes("primary") ||
656
+ name.includes("secondary") ||
657
+ name.includes("accent") ||
658
+ name.includes("muted") ||
659
+ name.includes("destructive") ||
660
+ name.includes("success") ||
661
+ name.includes("warning") ||
662
+ name.includes("border") ||
663
+ name.includes("card")
664
+ ) {
665
+ categories.colors.push(entry);
666
+ } else {
667
+ categories.other.push(entry);
668
+ }
669
+ }
670
+
671
+ // Only include color-related variables (most relevant for styling)
672
+ lines.push(...categories.colors.slice(0, 30)); // Limit to avoid prompt bloat
673
+
674
+ if (categories.colors.length > 30) {
675
+ lines.push(` ... and ${categories.colors.length - 30} more color variables`);
676
+ }
677
+ lines.push("");
678
+ }
679
+
680
+ // Tailwind Colors
681
+ if (Object.keys(theme.tailwindColors).length > 0) {
682
+ lines.push("Tailwind Custom Colors:");
683
+ for (const [name, value] of Object.entries(theme.tailwindColors)) {
684
+ lines.push(` ${name}: ${value}`);
685
+ }
686
+ lines.push("");
687
+ }
688
+
689
+ // ========== CONTRAST ANALYSIS (NEW) ==========
690
+ // Run contrast analysis if not already done
691
+ const contrastAnalysis = theme.contrastAnalysis || analyzeContrastRelationships(theme.cssVariables);
692
+
693
+ // Show warnings first (most important for LLM)
694
+ if (contrastAnalysis.warnings.length > 0) {
695
+ lines.push("");
696
+ lines.push("⚠️ CONTRAST WARNINGS (these combinations FAIL WCAG in this codebase - DO NOT USE):");
697
+ for (const w of contrastAnalysis.warnings) {
698
+ lines.push(` ❌ ${w.background} + ${w.text}: ${w.ratio}:1 → ${w.suggestion}`);
699
+ }
700
+ }
701
+
702
+ // Show safe patterns
703
+ if (contrastAnalysis.safePatterns.length > 0) {
704
+ lines.push("");
705
+ lines.push("✓ SAFE COLOR COMBINATIONS (use these instead):");
706
+
707
+ // Deduplicate and sort by background, prioritize semantic foregrounds
708
+ const seen = new Set<string>();
709
+ const sorted = contrastAnalysis.safePatterns
710
+ .filter((p) => {
711
+ const key = `${p.background}+${p.text}`;
712
+ if (seen.has(key)) return false;
713
+ seen.add(key);
714
+ return true;
715
+ })
716
+ .sort((a, b) => {
717
+ // Sort by background name, then prioritize -foreground over white/black
718
+ if (a.background !== b.background) {
719
+ return a.background.localeCompare(b.background);
720
+ }
721
+ // Prioritize -foreground variants
722
+ const aIsForeground = a.text.includes("-foreground");
723
+ const bIsForeground = b.text.includes("-foreground");
724
+ if (aIsForeground && !bIsForeground) return -1;
725
+ if (!aIsForeground && bIsForeground) return 1;
726
+ return 0;
727
+ });
728
+
729
+ for (const s of sorted.slice(0, 15)) { // Limit to avoid prompt bloat
730
+ lines.push(` ✓ ${s.background} + ${s.text}: ${s.ratio}:1`);
731
+ }
732
+
733
+ if (sorted.length > 15) {
734
+ lines.push(` ... and ${sorted.length - 15} more safe combinations`);
735
+ }
736
+ }
737
+
738
+ lines.push("");
739
+
740
+ // Available Tokens
741
+ if (theme.availableTokens.length > 0) {
742
+ lines.push(`Available Semantic Tokens: ${theme.availableTokens.slice(0, 20).join(", ")}`);
743
+ if (theme.availableTokens.length > 20) {
744
+ lines.push(` ... and ${theme.availableTokens.length - 20} more`);
745
+ }
746
+ lines.push("");
747
+ }
748
+
749
+ // Discovered Files
750
+ if (theme.discoveredFiles.length > 0) {
751
+ lines.push(`Theme Files Found: ${theme.discoveredFiles.join(", ")}`);
752
+ }
753
+
754
+ return lines.join("\n");
755
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonance-brand-mcp",
3
- "version": "1.3.51",
3
+ "version": "1.3.55",
4
4
  "description": "MCP Server for Sonance Brand Guidelines and Component Library - gives Claude instant access to brand colors, typography, and UI components.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",