sonance-brand-mcp 1.3.104 → 1.3.106

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.
@@ -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,163 +550,122 @@ 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
  }
545
622
 
623
+ // ========== FALLBACK MATCHING (for non-Sonance codebases) ==========
624
+ // These are lower confidence but help find elements in ANY codebase
625
+
626
+ // FALLBACK 1: Flexible text search (just find the text anywhere)
627
+ if (focusedElement.textContent && focusedElement.textContent.trim().length > 5) {
628
+ const searchText = focusedElement.textContent.trim().substring(0, 30);
629
+ for (let i = 0; i < lines.length; i++) {
630
+ if (lines[i].includes(searchText)) {
631
+ debugLog("Fallback: Flexible text match found", { searchText, lineNumber: i + 1 });
632
+ return {
633
+ lineNumber: i + 1,
634
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
635
+ confidence: 'low',
636
+ matchedBy: `flexible text match "${searchText}${focusedElement.textContent.length > 30 ? '...' : ''}"`
637
+ };
638
+ }
639
+ }
640
+ }
641
+
642
+ // FALLBACK 2: Class fingerprint (use first few classes as unique identifier)
643
+ if (focusedElement.className && focusedElement.className.trim().length > 15) {
644
+ // Take first 2-3 distinctive classes as a fingerprint
645
+ const classes = focusedElement.className.trim().split(/\s+/);
646
+ // Filter out very common single-word utilities
647
+ const distinctiveClasses = classes.filter(c =>
648
+ c.length > 4 && !['flex', 'grid', 'block', 'hidden', 'relative', 'absolute'].includes(c)
649
+ ).slice(0, 3);
650
+
651
+ if (distinctiveClasses.length >= 2) {
652
+ const fingerprint = distinctiveClasses.join(' ');
653
+ for (let i = 0; i < lines.length; i++) {
654
+ // Check if line contains ALL fingerprint classes
655
+ const lineHasAll = distinctiveClasses.every(cls => lines[i].includes(cls));
656
+ if (lineHasAll) {
657
+ debugLog("Fallback: Class fingerprint match found", { fingerprint, lineNumber: i + 1 });
658
+ return {
659
+ lineNumber: i + 1,
660
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
661
+ confidence: 'low',
662
+ matchedBy: `class fingerprint "${fingerprint}"`
663
+ };
664
+ }
665
+ }
666
+ }
667
+ }
668
+
546
669
  return null;
547
670
  }
548
671
 
@@ -574,9 +697,14 @@ function scoreFilesForTextContent(
574
697
  filesCount: importedFiles.length
575
698
  });
576
699
 
577
- // Only search component files (where JSX text lives)
700
+ // Search component files and page files (where JSX text lives)
701
+ // Expanded to work with ANY codebase structure
578
702
  const componentFiles = importedFiles.filter(f =>
579
- f.path.includes('components/') || f.path.includes('/ui/')
703
+ f.path.includes('components/') ||
704
+ f.path.includes('/ui/') ||
705
+ f.path.includes('/_components/') ||
706
+ f.path.endsWith('.tsx') ||
707
+ f.path.endsWith('.jsx')
580
708
  );
581
709
 
582
710
  const results: { path: string; content: string; score: number; matchedTexts: string[]; firstMatchLine: number }[] = [];
@@ -647,39 +775,121 @@ function scoreFilesForTextContent(
647
775
  */
648
776
  function findElementInImportedFiles(
649
777
  focusedElement: VisionFocusedElement,
650
- importedFiles: { path: string; content: string }[]
778
+ importedFiles: { path: string; content: string }[],
779
+ pageFilePath?: string // Pass page file for proximity scoring
651
780
  ): { path: string; lineNumber: number; matchedBy: string; content: string; confidence: string } | null {
652
781
  debugLog("Searching imported components for element", {
653
782
  elementType: focusedElement.type,
654
783
  textContent: focusedElement.textContent?.substring(0, 30),
655
- filesCount: importedFiles.length
784
+ filesCount: importedFiles.length,
785
+ pageFilePath
656
786
  });
657
787
 
788
+ // Collect ALL matches with scores instead of returning first match
789
+ const matches: Array<{
790
+ path: string;
791
+ lineNumber: number;
792
+ matchedBy: string;
793
+ content: string;
794
+ confidence: string;
795
+ score: number;
796
+ }> = [];
797
+
798
+ // Extract route/directory info from page file for proximity scoring
799
+ const pageDir = pageFilePath ? path.dirname(pageFilePath) : '';
800
+ const routeName = pageDir.split('/').pop() || ''; // e.g., "typography"
801
+
658
802
  for (const file of importedFiles) {
659
- // Focus on component files (where UI elements live)
803
+ // Focus on component/page files (where UI elements live)
660
804
  // Skip types, stores, utils, hooks - they don't contain JSX elements
661
- if (!file.path.includes('components/') && !file.path.includes('/ui/')) continue;
805
+ // Expanded to work with ANY codebase structure
806
+ const isComponentFile =
807
+ file.path.includes('components/') ||
808
+ file.path.includes('/ui/') ||
809
+ file.path.includes('/_components/') ||
810
+ file.path.endsWith('.tsx') ||
811
+ file.path.endsWith('.jsx');
812
+
813
+ // Skip non-component files but allow page files
814
+ if (!isComponentFile) continue;
815
+
816
+ // Skip known non-UI files
817
+ if (file.path.includes('/types') ||
818
+ file.path.includes('/hooks/') ||
819
+ file.path.includes('/utils/') ||
820
+ file.path.includes('/lib/') ||
821
+ file.path.includes('.d.ts')) continue;
662
822
 
663
823
  const result = findElementLineInFile(file.content, focusedElement);
664
824
  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 {
825
+ let score = 0;
826
+
827
+ // Text content match is highest priority (+100)
828
+ if (result.matchedBy.includes('textContent') || result.matchedBy.includes('word match')) {
829
+ score += 100;
830
+ }
831
+
832
+ // Directory proximity - same directory tree as page file (+50)
833
+ // e.g., for page src/app/typography/page.tsx, prefer src/app/typography/_components/
834
+ if (pageDir && file.path.includes(pageDir)) {
835
+ score += 50;
836
+ }
837
+
838
+ // Route name in filename (+30)
839
+ // e.g., for /typography route, prefer DigitalTypography.tsx
840
+ if (routeName && routeName.length > 2 && file.path.toLowerCase().includes(routeName.toLowerCase())) {
841
+ score += 30;
842
+ }
843
+
844
+ // Confidence bonus
845
+ score += result.confidence === 'high' ? 20 : 10;
846
+
847
+ matches.push({
672
848
  path: file.path,
673
849
  lineNumber: result.lineNumber,
674
850
  matchedBy: result.matchedBy,
675
851
  content: file.content,
676
- confidence: result.confidence
677
- };
852
+ confidence: result.confidence,
853
+ score
854
+ });
678
855
  }
679
856
  }
680
857
 
681
- debugLog("Element not found in any imported component files");
682
- return null;
858
+ // Return highest-scoring match
859
+ if (matches.length === 0) {
860
+ debugLog("Element not found in any imported component files");
861
+ return null;
862
+ }
863
+
864
+ // Sort by score descending
865
+ matches.sort((a, b) => b.score - a.score);
866
+
867
+ debugLog("Scored imported component matches", {
868
+ matchCount: matches.length,
869
+ topMatches: matches.slice(0, 3).map(m => ({
870
+ path: m.path,
871
+ score: m.score,
872
+ matchedBy: m.matchedBy,
873
+ confidence: m.confidence
874
+ }))
875
+ });
876
+
877
+ const bestMatch = matches[0];
878
+ debugLog("Found element in imported component (best match)", {
879
+ path: bestMatch.path,
880
+ lineNumber: bestMatch.lineNumber,
881
+ matchedBy: bestMatch.matchedBy,
882
+ confidence: bestMatch.confidence,
883
+ score: bestMatch.score
884
+ });
885
+
886
+ return {
887
+ path: bestMatch.path,
888
+ lineNumber: bestMatch.lineNumber,
889
+ matchedBy: bestMatch.matchedBy,
890
+ content: bestMatch.content,
891
+ confidence: bestMatch.confidence
892
+ };
683
893
  }
684
894
 
685
895
  /**
@@ -1786,6 +1996,7 @@ export async function POST(request: Request) {
1786
1996
  let smartSearchFiles: { path: string; content: string }[] = [];
1787
1997
  let recommendedFile: { path: string; reason: string } | null = null;
1788
1998
  let deterministicMatch: { path: string; lineNumber: number } | null = null;
1999
+ let phase1VisibleText: string[] = []; // Store Phase 1 visible text for use in file scoring
1789
2000
 
1790
2001
  // ========================================================================
1791
2002
  // PHASE 0: Deterministic Element ID Search (Cursor-style explicit selection)
@@ -1851,6 +2062,9 @@ export async function POST(request: Request) {
1851
2062
  if (!deterministicMatch && screenshot) {
1852
2063
  debugLog("Starting Phase 1: LLM screenshot analysis");
1853
2064
  const analysis = await analyzeScreenshotForSearch(screenshot, userPrompt, apiKey);
2065
+
2066
+ // Store visible text for use in file scoring when no element is focused
2067
+ phase1VisibleText = analysis.visibleText || [];
1854
2068
 
1855
2069
  const hasSearchTerms = analysis.visibleText.length > 0 ||
1856
2070
  analysis.componentNames.length > 0 ||
@@ -2161,7 +2375,8 @@ export async function POST(request: Request) {
2161
2375
 
2162
2376
  const importedMatch = findElementInImportedFiles(
2163
2377
  focusedElements[0],
2164
- pageContext.componentSources
2378
+ pageContext.componentSources,
2379
+ actualTargetFile?.path ?? undefined // Pass page file for proximity scoring
2165
2380
  );
2166
2381
 
2167
2382
  if (importedMatch) {
@@ -2180,6 +2395,48 @@ export async function POST(request: Request) {
2180
2395
  }
2181
2396
  }
2182
2397
  }
2398
+ // ========== AUTO TEXT SCORING (no focused element) ==========
2399
+ // When no element is clicked but we have Phase 1 visible text, use it to find the best file
2400
+ else if (actualTargetFile && (!focusedElements || focusedElements.length === 0) && phase1VisibleText.length > 0) {
2401
+ debugLog("No focused element - using Phase 1 visible text for file scoring", {
2402
+ textCount: phase1VisibleText.length,
2403
+ texts: phase1VisibleText.slice(0, 5)
2404
+ });
2405
+
2406
+ // Score all imported files based on visible text from Phase 1
2407
+ const bestMatch = scoreFilesForTextContent(
2408
+ phase1VisibleText,
2409
+ pageContext.componentSources
2410
+ );
2411
+
2412
+ // Check how well the current target file matches
2413
+ const currentFileScore = phase1VisibleText.filter(text =>
2414
+ actualTargetFile!.content.includes(text.substring(0, 20))
2415
+ ).length;
2416
+
2417
+ debugLog("Phase 1 text scoring comparison", {
2418
+ currentFile: actualTargetFile.path,
2419
+ currentScore: currentFileScore,
2420
+ bestImport: bestMatch?.path || 'none',
2421
+ bestImportScore: bestMatch?.score || 0
2422
+ });
2423
+
2424
+ // Redirect if an imported file has significantly more text matches
2425
+ if (bestMatch && bestMatch.score > currentFileScore && bestMatch.score >= 2) {
2426
+ debugLog("TEXT REDIRECT (no element): Phase 1 visible text found better file", {
2427
+ originalFile: actualTargetFile.path,
2428
+ originalScore: currentFileScore,
2429
+ redirectTo: bestMatch.path,
2430
+ redirectScore: bestMatch.score,
2431
+ matchedTexts: bestMatch.matchedTexts
2432
+ });
2433
+
2434
+ actualTargetFile = {
2435
+ path: bestMatch.path,
2436
+ content: bestMatch.content
2437
+ };
2438
+ }
2439
+ }
2183
2440
 
2184
2441
  debugLog("File redirect complete", {
2185
2442
  originalRecommended: recommendedFileContent?.path || 'none',
@@ -2562,11 +2819,24 @@ This is better than generating patches with made-up code.`,
2562
2819
  try {
2563
2820
  // Use robust JSON extraction that handles preamble text, code fences, etc.
2564
2821
  const jsonText = extractJsonFromResponse(textResponse.text);
2822
+ debugLog("[JSON Parse] Attempting to parse extracted JSON", {
2823
+ extractedLength: jsonText.length,
2824
+ extractedFirst100: jsonText.substring(0, 100),
2825
+ extractedLast100: jsonText.substring(jsonText.length - 100)
2826
+ });
2565
2827
  aiResponse = JSON.parse(jsonText);
2566
- } catch {
2828
+ debugLog("[JSON Parse] Successfully parsed AI response");
2829
+ } catch (parseError) {
2830
+ const error = parseError as Error;
2567
2831
  console.error("Failed to parse AI response:", textResponse.text);
2832
+ debugLog("[JSON Parse] FAILED to parse", {
2833
+ error: error.message,
2834
+ originalLength: textResponse.text.length,
2835
+ originalFirst100: textResponse.text.substring(0, 100),
2836
+ originalLast100: textResponse.text.substring(textResponse.text.length - 100)
2837
+ });
2568
2838
  return NextResponse.json(
2569
- { error: "Failed to parse AI response. Please try again." },
2839
+ { error: `Failed to parse AI response: ${error.message}` },
2570
2840
  { status: 500 }
2571
2841
  );
2572
2842
  }