sonance-brand-mcp 1.3.53 → 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.
@@ -4,11 +4,53 @@
4
4
  * Dynamically discovers and parses theme/design system files from target codebases.
5
5
  * Extracts CSS variables, Tailwind colors, and available semantic tokens to provide
6
6
  * context to the LLM for intelligent styling decisions.
7
+ *
8
+ * NEW: Includes WCAG contrast ratio calculation to warn about bad color combinations.
7
9
  */
8
10
 
9
11
  import * as fs from "fs";
10
12
  import * as path from "path";
11
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
+
12
54
  /**
13
55
  * Discovered theme information from a codebase
14
56
  */
@@ -27,8 +69,208 @@ export interface DiscoveredTheme {
27
69
 
28
70
  // Files that were discovered and parsed
29
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 };
30
128
  }
31
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
+
32
274
  /**
33
275
  * Patterns for finding theme-related files
34
276
  */
@@ -221,49 +463,120 @@ export function extractAvailableTokens(cssVariables: Record<string, string>): st
221
463
  return [...new Set([...tokens, ...commonTokens])];
222
464
  }
223
465
 
466
+ // ============================================================================
467
+ // CONTRAST ANALYSIS
468
+ // ============================================================================
469
+
224
470
  /**
225
471
  * Analyze CSS variables to identify contrast relationships
226
- * Returns warnings about potentially problematic color combinations
472
+ * Calculates actual WCAG contrast ratios and returns warnings for failing combinations
227
473
  */
228
474
  export function analyzeContrastRelationships(
229
475
  cssVariables: Record<string, string>
230
- ): { warnings: string[]; goodPairs: { bg: string; text: string }[] } {
231
- const warnings: string[] = [];
232
- const goodPairs: { bg: string; text: string }[] = [];
476
+ ): ContrastAnalysis {
477
+ const warnings: ContrastWarning[] = [];
478
+ const safePatterns: SafePattern[] = [];
233
479
 
234
- // Common pattern: --{name} is background, --{name}-foreground is text
235
- // Check if foreground values look like they might have contrast issues
480
+ // Track which backgrounds we've analyzed to add white/black alternatives
481
+ const analyzedBackgrounds: { name: string; rgb: RGB }[] = [];
236
482
 
483
+ // Find all bg/foreground pairs automatically
237
484
  for (const [varName, value] of Object.entries(cssVariables)) {
238
485
  // Skip if this is a foreground variable
239
486
  if (varName.includes("foreground")) continue;
240
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
+
241
498
  const baseName = varName.replace(/^--/, "");
242
499
  const foregroundVar = `--${baseName}-foreground`;
243
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
244
509
  if (cssVariables[foregroundVar]) {
245
- const bgValue = value;
246
- const fgValue = cssVariables[foregroundVar];
510
+ const fgRgb = parseColorToRgb(cssVariables[foregroundVar]);
247
511
 
248
- // Simple heuristic: if both values reference same color or are similar
249
- // This is a basic check - real contrast would need color parsing
250
- if (bgValue === fgValue) {
251
- warnings.push(
252
- `WARNING: ${varName} and ${foregroundVar} have identical values - text will be invisible`
253
- );
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
+ }
254
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);
255
554
 
256
- // Add as a known pair (user should verify contrast)
257
- goodPairs.push({
258
- bg: `bg-${baseName}`,
259
- text: `text-${baseName}-foreground`,
260
- });
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
+ }
261
570
  }
262
571
  }
263
572
 
264
- return { warnings, goodPairs };
573
+ return { warnings, safePatterns };
265
574
  }
266
575
 
576
+ // ============================================================================
577
+ // MAIN ENTRY POINTS
578
+ // ============================================================================
579
+
267
580
  /**
268
581
  * Main entry point: Discover theme from a project
269
582
  */
@@ -308,11 +621,15 @@ export async function discoverTheme(projectRoot: string): Promise<DiscoveredThem
308
621
  // Extract available tokens
309
622
  result.availableTokens = extractAvailableTokens(result.cssVariables);
310
623
 
624
+ // Run contrast analysis
625
+ result.contrastAnalysis = analyzeContrastRelationships(result.cssVariables);
626
+
311
627
  return result;
312
628
  }
313
629
 
314
630
  /**
315
631
  * Format discovered theme as context for the LLM prompt
632
+ * Includes contrast warnings to prevent bad color combinations
316
633
  */
317
634
  export function formatThemeForPrompt(theme: DiscoveredTheme): string {
318
635
  const lines: string[] = [];
@@ -369,6 +686,57 @@ export function formatThemeForPrompt(theme: DiscoveredTheme): string {
369
686
  lines.push("");
370
687
  }
371
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
+
372
740
  // Available Tokens
373
741
  if (theme.availableTokens.length > 0) {
374
742
  lines.push(`Available Semantic Tokens: ${theme.availableTokens.slice(0, 20).join(", ")}`);
@@ -385,4 +753,3 @@ export function formatThemeForPrompt(theme: DiscoveredTheme): string {
385
753
 
386
754
  return lines.join("\n");
387
755
  }
388
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonance-brand-mcp",
3
- "version": "1.3.53",
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",