sonance-brand-mcp 1.3.104 → 1.3.105

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.
@@ -141,6 +141,132 @@ function debugLog(message: string, data?: unknown) {
141
141
  }
142
142
  }
143
143
 
144
+ /**
145
+ * Sanitize a JSON string by finding the correct end point using bracket balancing.
146
+ * Handles cases where LLM outputs trailing garbage like extra ]} characters.
147
+ */
148
+ function sanitizeJsonString(text: string): string {
149
+ const trimmed = text.trim();
150
+
151
+ debugLog("[sanitizeJsonString] Starting", {
152
+ inputLength: trimmed.length,
153
+ first100: trimmed.substring(0, 100),
154
+ last100: trimmed.substring(trimmed.length - 100)
155
+ });
156
+
157
+ // Try parsing as-is first
158
+ try {
159
+ JSON.parse(trimmed);
160
+ debugLog("[sanitizeJsonString] Parsed as-is successfully");
161
+ return trimmed;
162
+ } catch (e) {
163
+ const error = e as Error;
164
+ debugLog("[sanitizeJsonString] Initial parse failed", {
165
+ error: error.message,
166
+ // Try to find where the error is
167
+ errorPosition: error.message.match(/position (\d+)/)?.[1]
168
+ });
169
+ }
170
+
171
+ // Find the correct end using balanced bracket counting
172
+ let braceCount = 0;
173
+ let bracketCount = 0;
174
+ let inString = false;
175
+ let escapeNext = false;
176
+ let endIndex = -1;
177
+
178
+ for (let i = 0; i < trimmed.length; i++) {
179
+ const char = trimmed[i];
180
+
181
+ if (escapeNext) {
182
+ escapeNext = false;
183
+ continue;
184
+ }
185
+
186
+ if (char === '\\' && inString) {
187
+ escapeNext = true;
188
+ continue;
189
+ }
190
+
191
+ if (char === '"') {
192
+ inString = !inString;
193
+ continue;
194
+ }
195
+
196
+ if (inString) continue;
197
+
198
+ if (char === '{') braceCount++;
199
+ if (char === '}') braceCount--;
200
+ if (char === '[') bracketCount++;
201
+ if (char === ']') bracketCount--;
202
+
203
+ // Found the end of the root object
204
+ if (braceCount === 0 && bracketCount === 0 && char === '}') {
205
+ endIndex = i;
206
+ break;
207
+ }
208
+ }
209
+
210
+ debugLog("[sanitizeJsonString] Bracket counting complete", {
211
+ endIndex,
212
+ finalBraceCount: braceCount,
213
+ finalBracketCount: bracketCount,
214
+ inString
215
+ });
216
+
217
+ if (endIndex !== -1) {
218
+ const sanitized = trimmed.slice(0, endIndex + 1);
219
+ const removed = trimmed.slice(endIndex + 1);
220
+ debugLog("[sanitizeJsonString] Attempting parse of balanced portion", {
221
+ sanitizedLength: sanitized.length,
222
+ removedLength: removed.length,
223
+ removedChars: removed.substring(0, 50)
224
+ });
225
+ try {
226
+ JSON.parse(sanitized);
227
+ debugLog("[sanitizeJsonString] SUCCESS: Removed trailing garbage", {
228
+ originalLength: trimmed.length,
229
+ sanitizedLength: sanitized.length,
230
+ removedChars: removed
231
+ });
232
+ return sanitized;
233
+ } catch (e) {
234
+ const error = e as Error;
235
+ debugLog("[sanitizeJsonString] Balanced portion still invalid", {
236
+ error: error.message
237
+ });
238
+ }
239
+ }
240
+
241
+ // Last resort: progressively remove trailing characters
242
+ debugLog("[sanitizeJsonString] Starting progressive trimming");
243
+ let attempt = trimmed;
244
+ let iterations = 0;
245
+ const maxIterations = Math.min(1000, trimmed.length); // Safety limit
246
+
247
+ while (attempt.length > 1 && iterations < maxIterations) {
248
+ iterations++;
249
+ try {
250
+ JSON.parse(attempt);
251
+ debugLog("[sanitizeJsonString] SUCCESS via progressive trimming", {
252
+ originalLength: trimmed.length,
253
+ sanitizedLength: attempt.length,
254
+ iterations
255
+ });
256
+ return attempt;
257
+ } catch {
258
+ attempt = attempt.slice(0, -1).trim();
259
+ }
260
+ }
261
+
262
+ debugLog("[sanitizeJsonString] FAILED: Could not sanitize JSON", {
263
+ originalLength: trimmed.length,
264
+ iterations
265
+ });
266
+
267
+ return trimmed; // Return original if all else fails
268
+ }
269
+
144
270
  /**
145
271
  * Extract JSON from LLM response that may contain preamble text
146
272
  * Handles: pure JSON, markdown code fences, and text with embedded JSON
@@ -154,14 +280,14 @@ function debugLog(message: string, data?: unknown) {
154
280
  function extractJsonFromResponse(text: string): string {
155
281
  // Try direct parse first - if it starts with {, it's likely pure JSON
156
282
  const trimmed = text.trim();
157
- if (trimmed.startsWith('{')) return trimmed;
283
+ if (trimmed.startsWith('{')) return sanitizeJsonString(trimmed);
158
284
 
159
285
  // PRIORITY 1: Look specifically for ```json fence (most reliable)
160
286
  const jsonFenceMatch = text.match(/```json\s*([\s\S]*?)```/);
161
287
  if (jsonFenceMatch) {
162
288
  const extracted = jsonFenceMatch[1].trim();
163
289
  debugLog("Extracted JSON from explicit json fence", { previewLength: extracted.length });
164
- return extracted;
290
+ return sanitizeJsonString(extracted);
165
291
  }
166
292
 
167
293
  // PRIORITY 2: Find raw JSON object in text (handles prose + JSON at end)
@@ -169,7 +295,7 @@ function extractJsonFromResponse(text: string): string {
169
295
  const modMatch = text.match(/(\{"modifications"\s*:\s*\[[\s\S]*\](?:\s*,\s*"explanation"\s*:\s*"[^"]*")?\s*\})/);
170
296
  if (modMatch) {
171
297
  debugLog("Extracted JSON via modifications pattern", { length: modMatch[1].length });
172
- return modMatch[1];
298
+ return sanitizeJsonString(modMatch[1]);
173
299
  }
174
300
 
175
301
  // PRIORITY 3: Find any JSON object starting with {"
@@ -181,7 +307,7 @@ function extractJsonFromResponse(text: string): string {
181
307
  preambleLength: jsonStart,
182
308
  jsonLength: extracted.length
183
309
  });
184
- return extracted;
310
+ return sanitizeJsonString(extracted);
185
311
  }
186
312
 
187
313
  // PRIORITY 4 (last resort): Try generic code fence
@@ -192,12 +318,12 @@ function extractJsonFromResponse(text: string): string {
192
318
  // Only use if it looks like JSON
193
319
  if (extracted.startsWith('{')) {
194
320
  debugLog("Extracted JSON from generic fence", { previewLength: extracted.length });
195
- return extracted;
321
+ return sanitizeJsonString(extracted);
196
322
  }
197
323
  }
198
324
 
199
- // Return as-is if no JSON structure found
200
- return text;
325
+ // Return as-is if no JSON structure found (sanitizeJsonString will handle validation)
326
+ return sanitizeJsonString(text);
201
327
  }
202
328
 
203
329
  /**
@@ -328,9 +454,19 @@ function findElementLineInFile(
328
454
  }
329
455
  }
330
456
 
457
+ // Helper: Normalize text for searching (handle bullets, whitespace, special chars)
458
+ const normalizeTextForSearch = (text: string): string => {
459
+ return text
460
+ .replace(/[•·●○◦‣⁃▪▫→←↓↑]/g, '') // Remove bullet/arrow characters
461
+ .replace(/\s+/g, ' ') // Collapse whitespace
462
+ .replace(/[""'']/g, '"') // Normalize quotes
463
+ .trim();
464
+ };
465
+
331
466
  // PRIORITY 2: Exact text content in JSX
332
467
  if (focusedElement.textContent && focusedElement.textContent.trim().length >= 2) {
333
468
  const text = focusedElement.textContent.trim();
469
+ const normalizedText = normalizeTextForSearch(text);
334
470
  // Escape special regex characters in the text
335
471
  const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
336
472
 
@@ -370,6 +506,34 @@ function findElementLineInFile(
370
506
  }
371
507
  }
372
508
  }
509
+
510
+ // PRIORITY 2c: Word-based matching for text with special characters (bullets, etc.)
511
+ // Extract significant words and find lines containing multiple of them
512
+ if (normalizedText.length > 10) {
513
+ const commonWords = ['the', 'this', 'that', 'with', 'from', 'have', 'will', 'your', 'for', 'and', 'use'];
514
+ const significantWords = normalizedText.split(' ')
515
+ .filter(word => word.length >= 4 && !commonWords.includes(word.toLowerCase()))
516
+ .slice(0, 5); // Take up to 5 significant words
517
+
518
+ if (significantWords.length >= 2) {
519
+ for (let i = 0; i < lines.length; i++) {
520
+ const lineLower = lines[i].toLowerCase();
521
+ const matchCount = significantWords.filter(word =>
522
+ lineLower.includes(word.toLowerCase())
523
+ ).length;
524
+
525
+ // Require at least 2 word matches for confidence
526
+ if (matchCount >= 2) {
527
+ return {
528
+ lineNumber: i + 1,
529
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
530
+ confidence: 'medium',
531
+ matchedBy: `word match: ${significantWords.slice(0, 3).join(', ')}... (${matchCount} words)`
532
+ };
533
+ }
534
+ }
535
+ }
536
+ }
373
537
  }
374
538
 
375
539
  // PRIORITY 2b: Look for icon patterns if type is button and no text (like X buttons)
@@ -390,159 +554,72 @@ function findElementLineInFile(
390
554
  }
391
555
  }
392
556
 
393
- // PRIORITY 3: Distinctive className patterns (ONLY semantic/custom classes)
557
+ // PRIORITY 3: Distinctive className patterns (semantic classes from design system)
394
558
  if (focusedElement.className) {
395
- // Filter OUT all Tailwind utility patterns - these are too generic and match wrong elements
396
- const tailwindPatterns = [
397
- /^p[xytblr]?-/, // padding: px-4, py-2, pt-1
398
- /^m[xytblr]?-/, // margin: mx-auto, my-4
399
- /^-?m[xytblr]?-/, // negative margins: -mt-4
400
- /^w-/, // width: w-full, w-4
401
- /^h-/, // height: h-10, h-full
402
- /^min-/, // min-width/height
403
- /^max-/, // max-width/height
404
- /^size-/, // size utilities
405
- /^bg-/, // background
406
- /^text-/, // text color/size
407
- /^font-/, // font-weight
408
- /^leading-/, // line-height
409
- /^tracking-/, // letter-spacing
410
- /^rounded/, // border-radius: rounded-sm, rounded-lg
411
- /^border/, // border
412
- /^outline/, // outline
413
- /^ring/, // ring
414
- /^shadow/, // shadow
415
- /^flex/, // flex utilities
416
- /^grow/, // flex-grow
417
- /^shrink/, // flex-shrink
418
- /^basis-/, // flex-basis
419
- /^grid/, // grid utilities
420
- /^col-/, // grid columns
421
- /^row-/, // grid rows
422
- /^gap-/, // gap
423
- /^items-/, // align-items
424
- /^justify-/, // justify-content
425
- /^self-/, // align-self
426
- /^place-/, // place utilities
427
- /^space-/, // space-x, space-y
428
- /^overflow/, // overflow
429
- /^scroll/, // scroll utilities
430
- /^cursor-/, // cursor
431
- /^pointer-/, // pointer-events
432
- /^select-/, // user-select
433
- /^opacity-/, // opacity
434
- /^visible/, // visibility
435
- /^invisible/, // visibility
436
- /^z-/, // z-index
437
- /^inset/, // inset utilities
438
- /^top-/, // positioning
439
- /^right-/, // positioning
440
- /^bottom-/, // positioning
441
- /^left-/, // positioning
442
- /^static/, // position
443
- /^fixed/, // position
444
- /^absolute/, // position
445
- /^relative/, // position
446
- /^sticky/, // position
447
- /^transition/, // transitions
448
- /^duration-/, // duration
449
- /^ease-/, // timing function
450
- /^delay-/, // delay
451
- /^animate-/, // animations
452
- /^transform/, // transform
453
- /^scale-/, // scale
454
- /^rotate-/, // rotate
455
- /^translate-/, // translate
456
- /^skew-/, // skew
457
- /^origin-/, // transform-origin
458
- /^appearance-/, // appearance
459
- /^accent-/, // accent-color
460
- /^caret-/, // caret-color
461
- /^fill-/, // SVG fill
462
- /^stroke-/, // SVG stroke
463
- /^object-/, // object-fit/position
464
- /^aspect-/, // aspect-ratio
465
- /^container/, // container
466
- /^columns-/, // columns
467
- /^break-/, // word-break
468
- /^truncate/, // text-overflow
469
- /^whitespace-/, // whitespace
470
- /^list-/, // list-style
471
- /^decoration-/, // text-decoration
472
- /^underline/, // underline
473
- /^overline/, // overline
474
- /^line-through/, // line-through
475
- /^no-underline/, // no underline
476
- /^antialiased/, // font-smoothing
477
- /^subpixel/, // subpixel-antialiased
478
- /^italic/, // font-style
479
- /^not-italic/, // font-style
480
- /^uppercase/, // text-transform
481
- /^lowercase/, // text-transform
482
- /^capitalize/, // text-transform
483
- /^normal-case/, // text-transform
484
- /^align-/, // vertical-align
485
- /^indent-/, // text-indent
486
- /^content-/, // content
487
- /^will-change/, // will-change
488
- /^hover:/, // hover states
489
- /^focus:/, // focus states
490
- /^focus-within:/, // focus-within states
491
- /^focus-visible:/, // focus-visible states
492
- /^active:/, // active states
493
- /^visited:/, // visited states
494
- /^disabled:/, // disabled states
495
- /^checked:/, // checked states
496
- /^group-/, // group utilities
497
- /^peer-/, // peer utilities
498
- /^dark:/, // dark mode
499
- /^light:/, // light mode
500
- /^motion-/, // motion utilities
501
- /^print:/, // print utilities
502
- /^portrait:/, // orientation
503
- /^landscape:/, // orientation
504
- /^sm:|^md:|^lg:|^xl:|^2xl:/, // responsive prefixes
505
- /^data-\[/, // data attribute variants
506
- /^aria-/, // aria variants
507
- /^supports-/, // supports variants
508
- /^has-/, // has variants
509
- /^group$/, // group class itself
510
- /^peer$/, // peer class itself
511
- /^sr-only/, // screen reader only
512
- /^not-sr-only/, // not screen reader only
513
- /^isolate/, // isolation
514
- /^block$/, // display
515
- /^inline/, // display
516
- /^hidden$/, // display
517
- /^table/, // display table
559
+ // SEMANTIC CLASS DETECTION: Instead of filtering OUT utilities, filter IN semantics
560
+ // These patterns identify classes that reference CSS variables (design tokens)
561
+ const semanticClassPatterns = [
562
+ // Background colors referencing CSS variables
563
+ /^bg-(card|background|foreground|primary|secondary|accent|destructive|muted|popover|input|sidebar)/,
564
+ // Text colors referencing CSS variables
565
+ /^text-(foreground|muted|primary|secondary|accent|destructive|card)/,
566
+ // Border colors referencing CSS variables
567
+ /^border-(border|card|input|primary|accent|destructive|muted)/,
568
+ // Any class containing these semantic tokens (catches hover:bg-card-hover, etc.)
569
+ /-card($|-)/,
570
+ /-foreground($|-)/,
571
+ /-background($|-)/,
572
+ /-border($|-)/,
573
+ /-primary($|-)/,
574
+ /-secondary($|-)/,
575
+ /-accent($|-)/,
576
+ /-muted($|-)/,
577
+ /-destructive($|-)/,
578
+ /-popover($|-)/,
579
+ /-sidebar($|-)/,
580
+ // Component-specific classes (likely custom)
581
+ /^(btn|card|modal|dialog|panel|sidebar|nav|menu|dropdown|tooltip|badge|chip|tag|avatar|icon)-/,
582
+ // Data attribute classes (often semantic)
583
+ /^\[data-/,
518
584
  ];
519
585
 
520
- const isTailwindUtility = (cls: string) =>
521
- tailwindPatterns.some(pattern => pattern.test(cls));
586
+ const isSemanticClass = (cls: string): boolean => {
587
+ // Remove variant prefixes for checking (hover:, focus:, dark:, etc.)
588
+ const baseClass = cls.replace(/^(hover:|focus:|active:|dark:|light:|md:|lg:|xl:|2xl:|sm:)+/, '');
589
+ return semanticClassPatterns.some(pattern => pattern.test(baseClass));
590
+ };
522
591
 
523
- // Only use classes that are NOT Tailwind utilities (likely custom/semantic)
592
+ // Find semantic classes
524
593
  const semanticClasses = focusedElement.className.split(/\s+/)
525
- .filter(c => c.length > 3 && !isTailwindUtility(c));
594
+ .filter(c => c.length > 3 && isSemanticClass(c));
526
595
 
527
596
  // Only proceed if we found semantic classes
528
597
  if (semanticClasses.length > 0) {
598
+ debugLog("Found semantic classes for element search", {
599
+ semanticClasses,
600
+ originalClasses: focusedElement.className.substring(0, 80)
601
+ });
602
+
529
603
  for (const cls of semanticClasses) {
604
+ // Remove variant prefix for searching (hover:bg-card -> bg-card)
605
+ const searchClass = cls.replace(/^(hover:|focus:|active:|dark:|light:|md:|lg:|xl:|2xl:|sm:)+/, '');
606
+
530
607
  for (let i = 0; i < lines.length; i++) {
531
- if (lines[i].includes(cls)) {
608
+ if (lines[i].includes(searchClass)) {
532
609
  return {
533
610
  lineNumber: i + 1,
534
611
  snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
535
612
  confidence: 'medium',
536
- matchedBy: `semantic className "${cls}"`
613
+ matchedBy: `semantic className "${searchClass}"`
537
614
  };
538
615
  }
539
616
  }
540
617
  }
541
618
  }
542
619
 
543
- // Log when we filtered out all classes (helpful for debugging)
620
+ // Log when we couldn't find semantic classes (helpful for debugging)
544
621
  if (semanticClasses.length === 0 && focusedElement.className.trim().length > 0) {
545
- console.log('[findElementLineInFile] All classes filtered as Tailwind utilities:',
622
+ console.log('[findElementLineInFile] No semantic classes found in:',
546
623
  focusedElement.className.substring(0, 100));
547
624
  }
548
625
  }
@@ -651,39 +728,105 @@ function scoreFilesForTextContent(
651
728
  */
652
729
  function findElementInImportedFiles(
653
730
  focusedElement: VisionFocusedElement,
654
- importedFiles: { path: string; content: string }[]
731
+ importedFiles: { path: string; content: string }[],
732
+ pageFilePath?: string // Pass page file for proximity scoring
655
733
  ): { path: string; lineNumber: number; matchedBy: string; content: string; confidence: string } | null {
656
734
  debugLog("Searching imported components for element", {
657
735
  elementType: focusedElement.type,
658
736
  textContent: focusedElement.textContent?.substring(0, 30),
659
- filesCount: importedFiles.length
737
+ filesCount: importedFiles.length,
738
+ pageFilePath
660
739
  });
661
740
 
741
+ // Collect ALL matches with scores instead of returning first match
742
+ const matches: Array<{
743
+ path: string;
744
+ lineNumber: number;
745
+ matchedBy: string;
746
+ content: string;
747
+ confidence: string;
748
+ score: number;
749
+ }> = [];
750
+
751
+ // Extract route/directory info from page file for proximity scoring
752
+ const pageDir = pageFilePath ? path.dirname(pageFilePath) : '';
753
+ const routeName = pageDir.split('/').pop() || ''; // e.g., "typography"
754
+
662
755
  for (const file of importedFiles) {
663
756
  // Focus on component files (where UI elements live)
664
757
  // Skip types, stores, utils, hooks - they don't contain JSX elements
665
- if (!file.path.includes('components/') && !file.path.includes('/ui/')) continue;
758
+ if (!file.path.includes('components/') && !file.path.includes('/ui/') && !file.path.includes('/_components/')) continue;
666
759
 
667
760
  const result = findElementLineInFile(file.content, focusedElement);
668
761
  if (result && result.confidence !== 'low') {
669
- debugLog("Found element in imported component", {
670
- path: file.path,
671
- lineNumber: result.lineNumber,
672
- matchedBy: result.matchedBy,
673
- confidence: result.confidence
674
- });
675
- return {
762
+ let score = 0;
763
+
764
+ // Text content match is highest priority (+100)
765
+ if (result.matchedBy.includes('textContent') || result.matchedBy.includes('word match')) {
766
+ score += 100;
767
+ }
768
+
769
+ // Directory proximity - same directory tree as page file (+50)
770
+ // e.g., for page src/app/typography/page.tsx, prefer src/app/typography/_components/
771
+ if (pageDir && file.path.includes(pageDir)) {
772
+ score += 50;
773
+ }
774
+
775
+ // Route name in filename (+30)
776
+ // e.g., for /typography route, prefer DigitalTypography.tsx
777
+ if (routeName && routeName.length > 2 && file.path.toLowerCase().includes(routeName.toLowerCase())) {
778
+ score += 30;
779
+ }
780
+
781
+ // Confidence bonus
782
+ score += result.confidence === 'high' ? 20 : 10;
783
+
784
+ matches.push({
676
785
  path: file.path,
677
786
  lineNumber: result.lineNumber,
678
787
  matchedBy: result.matchedBy,
679
788
  content: file.content,
680
- confidence: result.confidence
681
- };
789
+ confidence: result.confidence,
790
+ score
791
+ });
682
792
  }
683
793
  }
684
794
 
685
- debugLog("Element not found in any imported component files");
686
- return null;
795
+ // Return highest-scoring match
796
+ if (matches.length === 0) {
797
+ debugLog("Element not found in any imported component files");
798
+ return null;
799
+ }
800
+
801
+ // Sort by score descending
802
+ matches.sort((a, b) => b.score - a.score);
803
+
804
+ debugLog("Scored imported component matches", {
805
+ matchCount: matches.length,
806
+ topMatches: matches.slice(0, 3).map(m => ({
807
+ path: m.path,
808
+ score: m.score,
809
+ matchedBy: m.matchedBy,
810
+ confidence: m.confidence
811
+ }))
812
+ });
813
+
814
+ const bestMatch = matches[0];
815
+ debugLog("Found element in imported component (best match)", {
816
+ path: bestMatch.path,
817
+ lineNumber: bestMatch.lineNumber,
818
+ matchedBy: bestMatch.matchedBy,
819
+ confidence: bestMatch.confidence,
820
+ score: bestMatch.score
821
+ });
822
+
823
+ return {
824
+ path: bestMatch.path,
825
+ lineNumber: bestMatch.lineNumber,
826
+ matchedBy: bestMatch.matchedBy,
827
+ content: bestMatch.content,
828
+ confidence: bestMatch.confidence
829
+ };
687
830
  }
688
831
 
689
832
  /**
@@ -1817,6 +1960,7 @@ export async function POST(request: Request) {
1817
1960
  let smartSearchFiles: { path: string; content: string }[] = [];
1818
1961
  let recommendedFile: { path: string; reason: string } | null = null;
1819
1962
  let deterministicMatch: { path: string; lineNumber: number } | null = null;
1963
+ let phase1VisibleText: string[] = []; // Store Phase 1 visible text for use in file scoring
1820
1964
 
1821
1965
  // ========================================================================
1822
1966
  // PHASE 0: Deterministic Element ID Search (Cursor-style explicit selection)
@@ -1882,6 +2026,9 @@ export async function POST(request: Request) {
1882
2026
  if (!deterministicMatch && screenshot) {
1883
2027
  debugLog("Starting Phase 1: LLM screenshot analysis");
1884
2028
  const analysis = await analyzeScreenshotForSearch(screenshot, userPrompt, apiKey);
2029
+
2030
+ // Store visible text for use in file scoring when no element is focused
2031
+ phase1VisibleText = analysis.visibleText || [];
1885
2032
 
1886
2033
  const hasSearchTerms = analysis.visibleText.length > 0 ||
1887
2034
  analysis.componentNames.length > 0 ||
@@ -2192,7 +2339,8 @@ export async function POST(request: Request) {
2192
2339
 
2193
2340
  const importedMatch = findElementInImportedFiles(
2194
2341
  focusedElements[0],
2195
- pageContext.componentSources
2342
+ pageContext.componentSources,
2343
+ actualTargetFile?.path // Pass page file for proximity scoring
2196
2344
  );
2197
2345
 
2198
2346
  if (importedMatch) {
@@ -2211,6 +2359,48 @@ export async function POST(request: Request) {
2211
2359
  }
2212
2360
  }
2213
2361
  }
2362
+ // ========== AUTO TEXT SCORING (no focused element) ==========
2363
+ // When no element is clicked but we have Phase 1 visible text, use it to find the best file
2364
+ else if (actualTargetFile && (!focusedElements || focusedElements.length === 0) && phase1VisibleText.length > 0) {
2365
+ debugLog("No focused element - using Phase 1 visible text for file scoring", {
2366
+ textCount: phase1VisibleText.length,
2367
+ texts: phase1VisibleText.slice(0, 5)
2368
+ });
2369
+
2370
+ // Score all imported files based on visible text from Phase 1
2371
+ const bestMatch = scoreFilesForTextContent(
2372
+ phase1VisibleText,
2373
+ pageContext.componentSources
2374
+ );
2375
+
2376
+ // Check how well the current target file matches
2377
+ const currentFileScore = phase1VisibleText.filter(text =>
2378
+ actualTargetFile!.content.includes(text.substring(0, 20))
2379
+ ).length;
2380
+
2381
+ debugLog("Phase 1 text scoring comparison", {
2382
+ currentFile: actualTargetFile.path,
2383
+ currentScore: currentFileScore,
2384
+ bestImport: bestMatch?.path || 'none',
2385
+ bestImportScore: bestMatch?.score || 0
2386
+ });
2387
+
2388
+ // Redirect if an imported file has significantly more text matches
2389
+ if (bestMatch && bestMatch.score > currentFileScore && bestMatch.score >= 2) {
2390
+ debugLog("TEXT REDIRECT (no element): Phase 1 visible text found better file", {
2391
+ originalFile: actualTargetFile.path,
2392
+ originalScore: currentFileScore,
2393
+ redirectTo: bestMatch.path,
2394
+ redirectScore: bestMatch.score,
2395
+ matchedTexts: bestMatch.matchedTexts
2396
+ });
2397
+
2398
+ actualTargetFile = {
2399
+ path: bestMatch.path,
2400
+ content: bestMatch.content
2401
+ };
2402
+ }
2403
+ }
2214
2404
 
2215
2405
  debugLog("File redirect complete", {
2216
2406
  originalRecommended: recommendedFileContent?.path || 'none',
@@ -2601,11 +2791,24 @@ This is better than generating patches with made-up code.`,
2601
2791
  try {
2602
2792
  // Use robust JSON extraction that handles preamble text, code fences, etc.
2603
2793
  const jsonText = extractJsonFromResponse(textResponse.text);
2794
+ debugLog("[JSON Parse] Attempting to parse extracted JSON", {
2795
+ extractedLength: jsonText.length,
2796
+ extractedFirst100: jsonText.substring(0, 100),
2797
+ extractedLast100: jsonText.substring(jsonText.length - 100)
2798
+ });
2604
2799
  aiResponse = JSON.parse(jsonText);
2605
- } catch {
2800
+ debugLog("[JSON Parse] Successfully parsed AI response");
2801
+ } catch (parseError) {
2802
+ const error = parseError as Error;
2606
2803
  console.error("Failed to parse AI response:", textResponse.text);
2804
+ debugLog("[JSON Parse] FAILED to parse", {
2805
+ error: error.message,
2806
+ originalLength: textResponse.text.length,
2807
+ originalFirst100: textResponse.text.substring(0, 100),
2808
+ originalLast100: textResponse.text.substring(textResponse.text.length - 100)
2809
+ });
2607
2810
  return NextResponse.json(
2608
- { error: "Failed to parse AI response. Please try again." },
2811
+ { error: `Failed to parse AI response: ${error.message}` },
2609
2812
  { status: 500 }
2610
2813
  );
2611
2814
  }
@@ -137,6 +137,132 @@ function debugLog(message: string, data?: unknown) {
137
137
  }
138
138
  }
139
139
 
140
+ /**
141
+ * Sanitize a JSON string by finding the correct end point using bracket balancing.
142
+ * Handles cases where LLM outputs trailing garbage like extra ]} characters.
143
+ */
144
+ function sanitizeJsonString(text: string): string {
145
+ const trimmed = text.trim();
146
+
147
+ debugLog("[sanitizeJsonString] Starting", {
148
+ inputLength: trimmed.length,
149
+ first100: trimmed.substring(0, 100),
150
+ last100: trimmed.substring(trimmed.length - 100)
151
+ });
152
+
153
+ // Try parsing as-is first
154
+ try {
155
+ JSON.parse(trimmed);
156
+ debugLog("[sanitizeJsonString] Parsed as-is successfully");
157
+ return trimmed;
158
+ } catch (e) {
159
+ const error = e as Error;
160
+ debugLog("[sanitizeJsonString] Initial parse failed", {
161
+ error: error.message,
162
+ // Try to find where the error is
163
+ errorPosition: error.message.match(/position (\d+)/)?.[1]
164
+ });
165
+ }
166
+
167
+ // Find the correct end using balanced bracket counting
168
+ let braceCount = 0;
169
+ let bracketCount = 0;
170
+ let inString = false;
171
+ let escapeNext = false;
172
+ let endIndex = -1;
173
+
174
+ for (let i = 0; i < trimmed.length; i++) {
175
+ const char = trimmed[i];
176
+
177
+ if (escapeNext) {
178
+ escapeNext = false;
179
+ continue;
180
+ }
181
+
182
+ if (char === '\\' && inString) {
183
+ escapeNext = true;
184
+ continue;
185
+ }
186
+
187
+ if (char === '"') {
188
+ inString = !inString;
189
+ continue;
190
+ }
191
+
192
+ if (inString) continue;
193
+
194
+ if (char === '{') braceCount++;
195
+ if (char === '}') braceCount--;
196
+ if (char === '[') bracketCount++;
197
+ if (char === ']') bracketCount--;
198
+
199
+ // Found the end of the root object
200
+ if (braceCount === 0 && bracketCount === 0 && char === '}') {
201
+ endIndex = i;
202
+ break;
203
+ }
204
+ }
205
+
206
+ debugLog("[sanitizeJsonString] Bracket counting complete", {
207
+ endIndex,
208
+ finalBraceCount: braceCount,
209
+ finalBracketCount: bracketCount,
210
+ inString
211
+ });
212
+
213
+ if (endIndex !== -1) {
214
+ const sanitized = trimmed.slice(0, endIndex + 1);
215
+ const removed = trimmed.slice(endIndex + 1);
216
+ debugLog("[sanitizeJsonString] Attempting parse of balanced portion", {
217
+ sanitizedLength: sanitized.length,
218
+ removedLength: removed.length,
219
+ removedChars: removed.substring(0, 50)
220
+ });
221
+ try {
222
+ JSON.parse(sanitized);
223
+ debugLog("[sanitizeJsonString] SUCCESS: Removed trailing garbage", {
224
+ originalLength: trimmed.length,
225
+ sanitizedLength: sanitized.length,
226
+ removedChars: removed
227
+ });
228
+ return sanitized;
229
+ } catch (e) {
230
+ const error = e as Error;
231
+ debugLog("[sanitizeJsonString] Balanced portion still invalid", {
232
+ error: error.message
233
+ });
234
+ }
235
+ }
236
+
237
+ // Last resort: progressively remove trailing characters
238
+ debugLog("[sanitizeJsonString] Starting progressive trimming");
239
+ let attempt = trimmed;
240
+ let iterations = 0;
241
+ const maxIterations = Math.min(1000, trimmed.length); // Safety limit
242
+
243
+ while (attempt.length > 1 && iterations < maxIterations) {
244
+ iterations++;
245
+ try {
246
+ JSON.parse(attempt);
247
+ debugLog("[sanitizeJsonString] SUCCESS via progressive trimming", {
248
+ originalLength: trimmed.length,
249
+ sanitizedLength: attempt.length,
250
+ iterations
251
+ });
252
+ return attempt;
253
+ } catch {
254
+ attempt = attempt.slice(0, -1).trim();
255
+ }
256
+ }
257
+
258
+ debugLog("[sanitizeJsonString] FAILED: Could not sanitize JSON", {
259
+ originalLength: trimmed.length,
260
+ iterations
261
+ });
262
+
263
+ return trimmed; // Return original if all else fails
264
+ }
265
+
140
266
  /**
141
267
  * Extract JSON from LLM response that may contain preamble text
142
268
  * Handles: pure JSON, markdown code fences, and text with embedded JSON
@@ -150,14 +276,14 @@ function debugLog(message: string, data?: unknown) {
150
276
  function extractJsonFromResponse(text: string): string {
151
277
  // Try direct parse first - if it starts with {, it's likely pure JSON
152
278
  const trimmed = text.trim();
153
- if (trimmed.startsWith('{')) return trimmed;
279
+ if (trimmed.startsWith('{')) return sanitizeJsonString(trimmed);
154
280
 
155
281
  // PRIORITY 1: Look specifically for ```json fence (most reliable)
156
282
  const jsonFenceMatch = text.match(/```json\s*([\s\S]*?)```/);
157
283
  if (jsonFenceMatch) {
158
284
  const extracted = jsonFenceMatch[1].trim();
159
285
  debugLog("Extracted JSON from explicit json fence", { previewLength: extracted.length });
160
- return extracted;
286
+ return sanitizeJsonString(extracted);
161
287
  }
162
288
 
163
289
  // PRIORITY 2: Find raw JSON object in text (handles prose + JSON at end)
@@ -165,7 +291,7 @@ function extractJsonFromResponse(text: string): string {
165
291
  const modMatch = text.match(/(\{"modifications"\s*:\s*\[[\s\S]*\](?:\s*,\s*"explanation"\s*:\s*"[^"]*")?\s*\})/);
166
292
  if (modMatch) {
167
293
  debugLog("Extracted JSON via modifications pattern", { length: modMatch[1].length });
168
- return modMatch[1];
294
+ return sanitizeJsonString(modMatch[1]);
169
295
  }
170
296
 
171
297
  // PRIORITY 3: Find any JSON object starting with {"
@@ -177,7 +303,7 @@ function extractJsonFromResponse(text: string): string {
177
303
  preambleLength: jsonStart,
178
304
  jsonLength: extracted.length
179
305
  });
180
- return extracted;
306
+ return sanitizeJsonString(extracted);
181
307
  }
182
308
 
183
309
  // PRIORITY 4 (last resort): Try generic code fence
@@ -188,12 +314,12 @@ function extractJsonFromResponse(text: string): string {
188
314
  // Only use if it looks like JSON
189
315
  if (extracted.startsWith('{')) {
190
316
  debugLog("Extracted JSON from generic fence", { previewLength: extracted.length });
191
- return extracted;
317
+ return sanitizeJsonString(extracted);
192
318
  }
193
319
  }
194
320
 
195
- // Return as-is if no JSON structure found
196
- return text;
321
+ // Return as-is if no JSON structure found (sanitizeJsonString will handle validation)
322
+ return sanitizeJsonString(text);
197
323
  }
198
324
 
199
325
  /**
@@ -324,9 +450,19 @@ function findElementLineInFile(
324
450
  }
325
451
  }
326
452
 
453
+ // Helper: Normalize text for searching (handle bullets, whitespace, special chars)
454
+ const normalizeTextForSearch = (text: string): string => {
455
+ return text
456
+ .replace(/[•·●○◦‣⁃▪▫→←↓↑]/g, '') // Remove bullet/arrow characters
457
+ .replace(/\s+/g, ' ') // Collapse whitespace
458
+ .replace(/[""'']/g, '"') // Normalize quotes
459
+ .trim();
460
+ };
461
+
327
462
  // PRIORITY 2: Exact text content in JSX
328
463
  if (focusedElement.textContent && focusedElement.textContent.trim().length >= 2) {
329
464
  const text = focusedElement.textContent.trim();
465
+ const normalizedText = normalizeTextForSearch(text);
330
466
  // Escape special regex characters in the text
331
467
  const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
332
468
 
@@ -366,6 +502,34 @@ function findElementLineInFile(
366
502
  }
367
503
  }
368
504
  }
505
+
506
+ // PRIORITY 2c: Word-based matching for text with special characters (bullets, etc.)
507
+ // Extract significant words and find lines containing multiple of them
508
+ if (normalizedText.length > 10) {
509
+ const commonWords = ['the', 'this', 'that', 'with', 'from', 'have', 'will', 'your', 'for', 'and', 'use'];
510
+ const significantWords = normalizedText.split(' ')
511
+ .filter(word => word.length >= 4 && !commonWords.includes(word.toLowerCase()))
512
+ .slice(0, 5); // Take up to 5 significant words
513
+
514
+ if (significantWords.length >= 2) {
515
+ for (let i = 0; i < lines.length; i++) {
516
+ const lineLower = lines[i].toLowerCase();
517
+ const matchCount = significantWords.filter(word =>
518
+ lineLower.includes(word.toLowerCase())
519
+ ).length;
520
+
521
+ // Require at least 2 word matches for confidence
522
+ if (matchCount >= 2) {
523
+ return {
524
+ lineNumber: i + 1,
525
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
526
+ confidence: 'medium',
527
+ matchedBy: `word match: ${significantWords.slice(0, 3).join(', ')}... (${matchCount} words)`
528
+ };
529
+ }
530
+ }
531
+ }
532
+ }
369
533
  }
370
534
 
371
535
  // PRIORITY 2b: Look for icon patterns if type is button and no text (like X buttons)
@@ -386,159 +550,72 @@ function findElementLineInFile(
386
550
  }
387
551
  }
388
552
 
389
- // PRIORITY 3: Distinctive className patterns (ONLY semantic/custom classes)
553
+ // PRIORITY 3: Distinctive className patterns (semantic classes from design system)
390
554
  if (focusedElement.className) {
391
- // Filter OUT all Tailwind utility patterns - these are too generic and match wrong elements
392
- const tailwindPatterns = [
393
- /^p[xytblr]?-/, // padding: px-4, py-2, pt-1
394
- /^m[xytblr]?-/, // margin: mx-auto, my-4
395
- /^-?m[xytblr]?-/, // negative margins: -mt-4
396
- /^w-/, // width: w-full, w-4
397
- /^h-/, // height: h-10, h-full
398
- /^min-/, // min-width/height
399
- /^max-/, // max-width/height
400
- /^size-/, // size utilities
401
- /^bg-/, // background
402
- /^text-/, // text color/size
403
- /^font-/, // font-weight
404
- /^leading-/, // line-height
405
- /^tracking-/, // letter-spacing
406
- /^rounded/, // border-radius: rounded-sm, rounded-lg
407
- /^border/, // border
408
- /^outline/, // outline
409
- /^ring/, // ring
410
- /^shadow/, // shadow
411
- /^flex/, // flex utilities
412
- /^grow/, // flex-grow
413
- /^shrink/, // flex-shrink
414
- /^basis-/, // flex-basis
415
- /^grid/, // grid utilities
416
- /^col-/, // grid columns
417
- /^row-/, // grid rows
418
- /^gap-/, // gap
419
- /^items-/, // align-items
420
- /^justify-/, // justify-content
421
- /^self-/, // align-self
422
- /^place-/, // place utilities
423
- /^space-/, // space-x, space-y
424
- /^overflow/, // overflow
425
- /^scroll/, // scroll utilities
426
- /^cursor-/, // cursor
427
- /^pointer-/, // pointer-events
428
- /^select-/, // user-select
429
- /^opacity-/, // opacity
430
- /^visible/, // visibility
431
- /^invisible/, // visibility
432
- /^z-/, // z-index
433
- /^inset/, // inset utilities
434
- /^top-/, // positioning
435
- /^right-/, // positioning
436
- /^bottom-/, // positioning
437
- /^left-/, // positioning
438
- /^static/, // position
439
- /^fixed/, // position
440
- /^absolute/, // position
441
- /^relative/, // position
442
- /^sticky/, // position
443
- /^transition/, // transitions
444
- /^duration-/, // duration
445
- /^ease-/, // timing function
446
- /^delay-/, // delay
447
- /^animate-/, // animations
448
- /^transform/, // transform
449
- /^scale-/, // scale
450
- /^rotate-/, // rotate
451
- /^translate-/, // translate
452
- /^skew-/, // skew
453
- /^origin-/, // transform-origin
454
- /^appearance-/, // appearance
455
- /^accent-/, // accent-color
456
- /^caret-/, // caret-color
457
- /^fill-/, // SVG fill
458
- /^stroke-/, // SVG stroke
459
- /^object-/, // object-fit/position
460
- /^aspect-/, // aspect-ratio
461
- /^container/, // container
462
- /^columns-/, // columns
463
- /^break-/, // word-break
464
- /^truncate/, // text-overflow
465
- /^whitespace-/, // whitespace
466
- /^list-/, // list-style
467
- /^decoration-/, // text-decoration
468
- /^underline/, // underline
469
- /^overline/, // overline
470
- /^line-through/, // line-through
471
- /^no-underline/, // no underline
472
- /^antialiased/, // font-smoothing
473
- /^subpixel/, // subpixel-antialiased
474
- /^italic/, // font-style
475
- /^not-italic/, // font-style
476
- /^uppercase/, // text-transform
477
- /^lowercase/, // text-transform
478
- /^capitalize/, // text-transform
479
- /^normal-case/, // text-transform
480
- /^align-/, // vertical-align
481
- /^indent-/, // text-indent
482
- /^content-/, // content
483
- /^will-change/, // will-change
484
- /^hover:/, // hover states
485
- /^focus:/, // focus states
486
- /^focus-within:/, // focus-within states
487
- /^focus-visible:/, // focus-visible states
488
- /^active:/, // active states
489
- /^visited:/, // visited states
490
- /^disabled:/, // disabled states
491
- /^checked:/, // checked states
492
- /^group-/, // group utilities
493
- /^peer-/, // peer utilities
494
- /^dark:/, // dark mode
495
- /^light:/, // light mode
496
- /^motion-/, // motion utilities
497
- /^print:/, // print utilities
498
- /^portrait:/, // orientation
499
- /^landscape:/, // orientation
500
- /^sm:|^md:|^lg:|^xl:|^2xl:/, // responsive prefixes
501
- /^data-\[/, // data attribute variants
502
- /^aria-/, // aria variants
503
- /^supports-/, // supports variants
504
- /^has-/, // has variants
505
- /^group$/, // group class itself
506
- /^peer$/, // peer class itself
507
- /^sr-only/, // screen reader only
508
- /^not-sr-only/, // not screen reader only
509
- /^isolate/, // isolation
510
- /^block$/, // display
511
- /^inline/, // display
512
- /^hidden$/, // display
513
- /^table/, // display table
555
+ // SEMANTIC CLASS DETECTION: Instead of filtering OUT utilities, filter IN semantics
556
+ // These patterns identify classes that reference CSS variables (design tokens)
557
+ const semanticClassPatterns = [
558
+ // Background colors referencing CSS variables
559
+ /^bg-(card|background|foreground|primary|secondary|accent|destructive|muted|popover|input|sidebar)/,
560
+ // Text colors referencing CSS variables
561
+ /^text-(foreground|muted|primary|secondary|accent|destructive|card)/,
562
+ // Border colors referencing CSS variables
563
+ /^border-(border|card|input|primary|accent|destructive|muted)/,
564
+ // Any class containing these semantic tokens (catches hover:bg-card-hover, etc.)
565
+ /-card($|-)/,
566
+ /-foreground($|-)/,
567
+ /-background($|-)/,
568
+ /-border($|-)/,
569
+ /-primary($|-)/,
570
+ /-secondary($|-)/,
571
+ /-accent($|-)/,
572
+ /-muted($|-)/,
573
+ /-destructive($|-)/,
574
+ /-popover($|-)/,
575
+ /-sidebar($|-)/,
576
+ // Component-specific classes (likely custom)
577
+ /^(btn|card|modal|dialog|panel|sidebar|nav|menu|dropdown|tooltip|badge|chip|tag|avatar|icon)-/,
578
+ // Data attribute classes (often semantic)
579
+ /^\[data-/,
514
580
  ];
515
581
 
516
- const isTailwindUtility = (cls: string) =>
517
- tailwindPatterns.some(pattern => pattern.test(cls));
582
+ const isSemanticClass = (cls: string): boolean => {
583
+ // Remove variant prefixes for checking (hover:, focus:, dark:, etc.)
584
+ const baseClass = cls.replace(/^(hover:|focus:|active:|dark:|light:|md:|lg:|xl:|2xl:|sm:)+/, '');
585
+ return semanticClassPatterns.some(pattern => pattern.test(baseClass));
586
+ };
518
587
 
519
- // Only use classes that are NOT Tailwind utilities (likely custom/semantic)
588
+ // Find semantic classes
520
589
  const semanticClasses = focusedElement.className.split(/\s+/)
521
- .filter(c => c.length > 3 && !isTailwindUtility(c));
590
+ .filter(c => c.length > 3 && isSemanticClass(c));
522
591
 
523
592
  // Only proceed if we found semantic classes
524
593
  if (semanticClasses.length > 0) {
594
+ debugLog("Found semantic classes for element search", {
595
+ semanticClasses,
596
+ originalClasses: focusedElement.className.substring(0, 80)
597
+ });
598
+
525
599
  for (const cls of semanticClasses) {
600
+ // Remove variant prefix for searching (hover:bg-card -> bg-card)
601
+ const searchClass = cls.replace(/^(hover:|focus:|active:|dark:|light:|md:|lg:|xl:|2xl:|sm:)+/, '');
602
+
526
603
  for (let i = 0; i < lines.length; i++) {
527
- if (lines[i].includes(cls)) {
604
+ if (lines[i].includes(searchClass)) {
528
605
  return {
529
606
  lineNumber: i + 1,
530
607
  snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
531
608
  confidence: 'medium',
532
- matchedBy: `semantic className "${cls}"`
609
+ matchedBy: `semantic className "${searchClass}"`
533
610
  };
534
611
  }
535
612
  }
536
613
  }
537
614
  }
538
615
 
539
- // Log when we filtered out all classes (helpful for debugging)
616
+ // Log when we couldn't find semantic classes (helpful for debugging)
540
617
  if (semanticClasses.length === 0 && focusedElement.className.trim().length > 0) {
541
- console.log('[findElementLineInFile] All classes filtered as Tailwind utilities:',
618
+ console.log('[findElementLineInFile] No semantic classes found in:',
542
619
  focusedElement.className.substring(0, 100));
543
620
  }
544
621
  }
@@ -647,39 +724,105 @@ function scoreFilesForTextContent(
647
724
  */
648
725
  function findElementInImportedFiles(
649
726
  focusedElement: VisionFocusedElement,
650
- importedFiles: { path: string; content: string }[]
727
+ importedFiles: { path: string; content: string }[],
728
+ pageFilePath?: string // Pass page file for proximity scoring
651
729
  ): { path: string; lineNumber: number; matchedBy: string; content: string; confidence: string } | null {
652
730
  debugLog("Searching imported components for element", {
653
731
  elementType: focusedElement.type,
654
732
  textContent: focusedElement.textContent?.substring(0, 30),
655
- filesCount: importedFiles.length
733
+ filesCount: importedFiles.length,
734
+ pageFilePath
656
735
  });
657
736
 
737
+ // Collect ALL matches with scores instead of returning first match
738
+ const matches: Array<{
739
+ path: string;
740
+ lineNumber: number;
741
+ matchedBy: string;
742
+ content: string;
743
+ confidence: string;
744
+ score: number;
745
+ }> = [];
746
+
747
+ // Extract route/directory info from page file for proximity scoring
748
+ const pageDir = pageFilePath ? path.dirname(pageFilePath) : '';
749
+ const routeName = pageDir.split('/').pop() || ''; // e.g., "typography"
750
+
658
751
  for (const file of importedFiles) {
659
752
  // Focus on component files (where UI elements live)
660
753
  // Skip types, stores, utils, hooks - they don't contain JSX elements
661
- if (!file.path.includes('components/') && !file.path.includes('/ui/')) continue;
754
+ if (!file.path.includes('components/') && !file.path.includes('/ui/') && !file.path.includes('/_components/')) continue;
662
755
 
663
756
  const result = findElementLineInFile(file.content, focusedElement);
664
757
  if (result && result.confidence !== 'low') {
665
- debugLog("Found element in imported component", {
666
- path: file.path,
667
- lineNumber: result.lineNumber,
668
- matchedBy: result.matchedBy,
669
- confidence: result.confidence
670
- });
671
- return {
758
+ let score = 0;
759
+
760
+ // Text content match is highest priority (+100)
761
+ if (result.matchedBy.includes('textContent') || result.matchedBy.includes('word match')) {
762
+ score += 100;
763
+ }
764
+
765
+ // Directory proximity - same directory tree as page file (+50)
766
+ // e.g., for page src/app/typography/page.tsx, prefer src/app/typography/_components/
767
+ if (pageDir && file.path.includes(pageDir)) {
768
+ score += 50;
769
+ }
770
+
771
+ // Route name in filename (+30)
772
+ // e.g., for /typography route, prefer DigitalTypography.tsx
773
+ if (routeName && routeName.length > 2 && file.path.toLowerCase().includes(routeName.toLowerCase())) {
774
+ score += 30;
775
+ }
776
+
777
+ // Confidence bonus
778
+ score += result.confidence === 'high' ? 20 : 10;
779
+
780
+ matches.push({
672
781
  path: file.path,
673
782
  lineNumber: result.lineNumber,
674
783
  matchedBy: result.matchedBy,
675
784
  content: file.content,
676
- confidence: result.confidence
677
- };
785
+ confidence: result.confidence,
786
+ score
787
+ });
678
788
  }
679
789
  }
680
790
 
681
- debugLog("Element not found in any imported component files");
682
- return null;
791
+ // Return highest-scoring match
792
+ if (matches.length === 0) {
793
+ debugLog("Element not found in any imported component files");
794
+ return null;
795
+ }
796
+
797
+ // Sort by score descending
798
+ matches.sort((a, b) => b.score - a.score);
799
+
800
+ debugLog("Scored imported component matches", {
801
+ matchCount: matches.length,
802
+ topMatches: matches.slice(0, 3).map(m => ({
803
+ path: m.path,
804
+ score: m.score,
805
+ matchedBy: m.matchedBy,
806
+ confidence: m.confidence
807
+ }))
808
+ });
809
+
810
+ const bestMatch = matches[0];
811
+ debugLog("Found element in imported component (best match)", {
812
+ path: bestMatch.path,
813
+ lineNumber: bestMatch.lineNumber,
814
+ matchedBy: bestMatch.matchedBy,
815
+ confidence: bestMatch.confidence,
816
+ score: bestMatch.score
817
+ });
818
+
819
+ return {
820
+ path: bestMatch.path,
821
+ lineNumber: bestMatch.lineNumber,
822
+ matchedBy: bestMatch.matchedBy,
823
+ content: bestMatch.content,
824
+ confidence: bestMatch.confidence
825
+ };
683
826
  }
684
827
 
685
828
  /**
@@ -1786,6 +1929,7 @@ export async function POST(request: Request) {
1786
1929
  let smartSearchFiles: { path: string; content: string }[] = [];
1787
1930
  let recommendedFile: { path: string; reason: string } | null = null;
1788
1931
  let deterministicMatch: { path: string; lineNumber: number } | null = null;
1932
+ let phase1VisibleText: string[] = []; // Store Phase 1 visible text for use in file scoring
1789
1933
 
1790
1934
  // ========================================================================
1791
1935
  // PHASE 0: Deterministic Element ID Search (Cursor-style explicit selection)
@@ -1851,6 +1995,9 @@ export async function POST(request: Request) {
1851
1995
  if (!deterministicMatch && screenshot) {
1852
1996
  debugLog("Starting Phase 1: LLM screenshot analysis");
1853
1997
  const analysis = await analyzeScreenshotForSearch(screenshot, userPrompt, apiKey);
1998
+
1999
+ // Store visible text for use in file scoring when no element is focused
2000
+ phase1VisibleText = analysis.visibleText || [];
1854
2001
 
1855
2002
  const hasSearchTerms = analysis.visibleText.length > 0 ||
1856
2003
  analysis.componentNames.length > 0 ||
@@ -2161,7 +2308,8 @@ export async function POST(request: Request) {
2161
2308
 
2162
2309
  const importedMatch = findElementInImportedFiles(
2163
2310
  focusedElements[0],
2164
- pageContext.componentSources
2311
+ pageContext.componentSources,
2312
+ actualTargetFile?.path ?? undefined // Pass page file for proximity scoring
2165
2313
  );
2166
2314
 
2167
2315
  if (importedMatch) {
@@ -2180,6 +2328,48 @@ export async function POST(request: Request) {
2180
2328
  }
2181
2329
  }
2182
2330
  }
2331
+ // ========== AUTO TEXT SCORING (no focused element) ==========
2332
+ // When no element is clicked but we have Phase 1 visible text, use it to find the best file
2333
+ else if (actualTargetFile && (!focusedElements || focusedElements.length === 0) && phase1VisibleText.length > 0) {
2334
+ debugLog("No focused element - using Phase 1 visible text for file scoring", {
2335
+ textCount: phase1VisibleText.length,
2336
+ texts: phase1VisibleText.slice(0, 5)
2337
+ });
2338
+
2339
+ // Score all imported files based on visible text from Phase 1
2340
+ const bestMatch = scoreFilesForTextContent(
2341
+ phase1VisibleText,
2342
+ pageContext.componentSources
2343
+ );
2344
+
2345
+ // Check how well the current target file matches
2346
+ const currentFileScore = phase1VisibleText.filter(text =>
2347
+ actualTargetFile!.content.includes(text.substring(0, 20))
2348
+ ).length;
2349
+
2350
+ debugLog("Phase 1 text scoring comparison", {
2351
+ currentFile: actualTargetFile.path,
2352
+ currentScore: currentFileScore,
2353
+ bestImport: bestMatch?.path || 'none',
2354
+ bestImportScore: bestMatch?.score || 0
2355
+ });
2356
+
2357
+ // Redirect if an imported file has significantly more text matches
2358
+ if (bestMatch && bestMatch.score > currentFileScore && bestMatch.score >= 2) {
2359
+ debugLog("TEXT REDIRECT (no element): Phase 1 visible text found better file", {
2360
+ originalFile: actualTargetFile.path,
2361
+ originalScore: currentFileScore,
2362
+ redirectTo: bestMatch.path,
2363
+ redirectScore: bestMatch.score,
2364
+ matchedTexts: bestMatch.matchedTexts
2365
+ });
2366
+
2367
+ actualTargetFile = {
2368
+ path: bestMatch.path,
2369
+ content: bestMatch.content
2370
+ };
2371
+ }
2372
+ }
2183
2373
 
2184
2374
  debugLog("File redirect complete", {
2185
2375
  originalRecommended: recommendedFileContent?.path || 'none',
@@ -2562,11 +2752,24 @@ This is better than generating patches with made-up code.`,
2562
2752
  try {
2563
2753
  // Use robust JSON extraction that handles preamble text, code fences, etc.
2564
2754
  const jsonText = extractJsonFromResponse(textResponse.text);
2755
+ debugLog("[JSON Parse] Attempting to parse extracted JSON", {
2756
+ extractedLength: jsonText.length,
2757
+ extractedFirst100: jsonText.substring(0, 100),
2758
+ extractedLast100: jsonText.substring(jsonText.length - 100)
2759
+ });
2565
2760
  aiResponse = JSON.parse(jsonText);
2566
- } catch {
2761
+ debugLog("[JSON Parse] Successfully parsed AI response");
2762
+ } catch (parseError) {
2763
+ const error = parseError as Error;
2567
2764
  console.error("Failed to parse AI response:", textResponse.text);
2765
+ debugLog("[JSON Parse] FAILED to parse", {
2766
+ error: error.message,
2767
+ originalLength: textResponse.text.length,
2768
+ originalFirst100: textResponse.text.substring(0, 100),
2769
+ originalLast100: textResponse.text.substring(textResponse.text.length - 100)
2770
+ });
2568
2771
  return NextResponse.json(
2569
- { error: "Failed to parse AI response. Please try again." },
2772
+ { error: `Failed to parse AI response: ${error.message}` },
2570
2773
  { status: 500 }
2571
2774
  );
2572
2775
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonance-brand-mcp",
3
- "version": "1.3.104",
3
+ "version": "1.3.105",
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",