sonance-brand-mcp 1.3.111 → 1.3.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/assets/api/sonance-save-image/route.ts +625 -0
  2. package/dist/assets/api/sonance-vision-apply/image-styling-detection.ts +1360 -0
  3. package/dist/assets/api/sonance-vision-apply/route.ts +988 -57
  4. package/dist/assets/api/sonance-vision-apply/styling-detection.ts +730 -0
  5. package/dist/assets/api/sonance-vision-apply/theme-discovery.ts +1 -1
  6. package/dist/assets/brand-system.ts +13 -12
  7. package/dist/assets/components/accordion.tsx +15 -7
  8. package/dist/assets/components/alert-dialog.tsx +35 -10
  9. package/dist/assets/components/alert.tsx +11 -10
  10. package/dist/assets/components/avatar.tsx +4 -4
  11. package/dist/assets/components/badge.tsx +16 -12
  12. package/dist/assets/components/button.stories.tsx +3 -3
  13. package/dist/assets/components/button.tsx +50 -31
  14. package/dist/assets/components/calendar.tsx +12 -8
  15. package/dist/assets/components/card.tsx +35 -29
  16. package/dist/assets/components/checkbox.tsx +9 -8
  17. package/dist/assets/components/code.tsx +19 -11
  18. package/dist/assets/components/command.tsx +32 -13
  19. package/dist/assets/components/context-menu.tsx +37 -16
  20. package/dist/assets/components/dialog.tsx +8 -5
  21. package/dist/assets/components/divider.tsx +15 -5
  22. package/dist/assets/components/drawer.tsx +4 -3
  23. package/dist/assets/components/dropdown-menu.tsx +15 -13
  24. package/dist/assets/components/hover-card.tsx +4 -1
  25. package/dist/assets/components/image.tsx +1 -1
  26. package/dist/assets/components/input.tsx +29 -14
  27. package/dist/assets/components/kbd.stories.tsx +3 -3
  28. package/dist/assets/components/kbd.tsx +29 -13
  29. package/dist/assets/components/listbox.tsx +8 -8
  30. package/dist/assets/components/menubar.tsx +50 -23
  31. package/dist/assets/components/navbar.stories.tsx +140 -13
  32. package/dist/assets/components/navbar.tsx +22 -5
  33. package/dist/assets/components/navigation-menu.tsx +28 -6
  34. package/dist/assets/components/pagination.tsx +10 -10
  35. package/dist/assets/components/popover.tsx +10 -8
  36. package/dist/assets/components/progress.tsx +6 -4
  37. package/dist/assets/components/radio-group.tsx +5 -5
  38. package/dist/assets/components/select.tsx +49 -29
  39. package/dist/assets/components/separator.tsx +3 -3
  40. package/dist/assets/components/sheet.tsx +4 -4
  41. package/dist/assets/components/sidebar.tsx +10 -10
  42. package/dist/assets/components/skeleton.tsx +13 -5
  43. package/dist/assets/components/slider.tsx +12 -10
  44. package/dist/assets/components/switch.tsx +4 -4
  45. package/dist/assets/components/table.tsx +5 -5
  46. package/dist/assets/components/tabs.tsx +8 -8
  47. package/dist/assets/components/textarea.tsx +11 -9
  48. package/dist/assets/components/toast.tsx +7 -7
  49. package/dist/assets/components/toggle.tsx +27 -7
  50. package/dist/assets/components/tooltip.tsx +10 -8
  51. package/dist/assets/components/user.tsx +8 -6
  52. package/dist/assets/dev-tools/SonanceDevTools.tsx +429 -362
  53. package/dist/assets/dev-tools/components/ApplyFirstPreview.tsx +10 -10
  54. package/dist/assets/dev-tools/components/ChatHistory.tsx +11 -7
  55. package/dist/assets/dev-tools/components/ChatInterface.tsx +61 -20
  56. package/dist/assets/dev-tools/components/ChatTabBar.tsx +1 -1
  57. package/dist/assets/dev-tools/components/DiffPreview.tsx +1 -1
  58. package/dist/assets/dev-tools/components/InlineDiffPreview.tsx +360 -36
  59. package/dist/assets/dev-tools/components/InspectorOverlay.tsx +9 -9
  60. package/dist/assets/dev-tools/components/PropertiesPanel.tsx +743 -93
  61. package/dist/assets/dev-tools/components/ScreenshotAnnotator.tsx +1 -1
  62. package/dist/assets/dev-tools/components/SectionHighlight.tsx +1 -1
  63. package/dist/assets/dev-tools/components/VisionDiffPreview.tsx +7 -7
  64. package/dist/assets/dev-tools/components/VisionModeBorder.tsx +4 -64
  65. package/dist/assets/dev-tools/hooks/index.ts +69 -0
  66. package/dist/assets/dev-tools/hooks/useComponentDetection.ts +132 -0
  67. package/dist/assets/dev-tools/hooks/useComputedStyles.ts +171 -65
  68. package/dist/assets/dev-tools/hooks/useContentHash.ts +212 -0
  69. package/dist/assets/dev-tools/hooks/useElementScanner.ts +398 -0
  70. package/dist/assets/dev-tools/hooks/useImageDetection.ts +162 -0
  71. package/dist/assets/dev-tools/hooks/useTextDetection.ts +217 -0
  72. package/dist/assets/dev-tools/panels/ComponentsPanel.tsx +160 -57
  73. package/dist/assets/dev-tools/panels/TextPanel.tsx +10 -10
  74. package/dist/assets/dev-tools/types.ts +42 -0
  75. package/dist/assets/globals.css +225 -9
  76. package/dist/assets/styles/brand-overrides.css +3 -2
  77. package/dist/assets/utils.ts +2 -1
  78. package/package.json +1 -1
@@ -4,6 +4,7 @@ import * as path from "path";
4
4
  import Anthropic from "@anthropic-ai/sdk";
5
5
  import { randomUUID } from "crypto";
6
6
  import { discoverTheme, formatThemeForPrompt } from "./theme-discovery";
7
+ import { detectStylingSystem, generateStylingGuidance, type StylingAnalysis } from "./styling-detection";
7
8
  import * as babelParser from "@babel/parser";
8
9
 
9
10
  /**
@@ -54,6 +55,8 @@ interface VisionFocusedElement {
54
55
  childIds?: string[];
55
56
  /** Parent section context for section-level changes */
56
57
  parentSection?: ParentSectionInfo;
58
+ /** The src attribute for image elements (for tracing to source code) */
59
+ imageSrc?: string;
57
60
  }
58
61
 
59
62
  interface VisionFileModification {
@@ -80,6 +83,27 @@ interface ApplyFirstRequest {
80
83
  previewedModifications?: VisionFileModification[];
81
84
  /** Conversation history for multi-turn context */
82
85
  chatHistory?: ChatHistoryMessage[];
86
+ /** Current theme mode (light/dark) for theme-specific color changes */
87
+ currentTheme?: "light" | "dark";
88
+ /** Property edits from the PropertiesPanel (includes colorLight/colorDark) */
89
+ propertyEdits?: {
90
+ textContent?: string;
91
+ width?: string;
92
+ height?: string;
93
+ opacity?: string;
94
+ borderRadius?: string;
95
+ fontSize?: string;
96
+ fontWeight?: string;
97
+ lineHeight?: string;
98
+ letterSpacing?: string;
99
+ color?: string;
100
+ colorLight?: string;
101
+ colorDark?: string;
102
+ backgroundColor?: string;
103
+ padding?: string;
104
+ margin?: string;
105
+ gap?: string;
106
+ };
83
107
  }
84
108
 
85
109
  interface BackupManifest {
@@ -151,9 +175,10 @@ function debugLog(message: string, data?: unknown) {
151
175
  /**
152
176
  * Sanitize a JSON string by finding the correct end point using bracket balancing.
153
177
  * Handles cases where LLM outputs trailing garbage like extra ]} characters.
178
+ * Also handles leading conversational text before the JSON payload.
154
179
  */
155
180
  function sanitizeJsonString(text: string): string {
156
- const trimmed = text.trim();
181
+ let trimmed = text.trim();
157
182
 
158
183
  debugLog("[sanitizeJsonString] Starting", {
159
184
  inputLength: trimmed.length,
@@ -161,6 +186,21 @@ function sanitizeJsonString(text: string): string {
161
186
  last100: trimmed.substring(trimmed.length - 100)
162
187
  });
163
188
 
189
+ // Skip leading conversational preamble by finding the first '{'
190
+ // This handles cases like "Looking at line 76... { ... }"
191
+ const firstBrace = trimmed.indexOf('{');
192
+ if (firstBrace > 0) {
193
+ const preamble = trimmed.substring(0, firstBrace);
194
+ debugLog("[sanitizeJsonString] Found preamble, skipping", {
195
+ preambleLength: firstBrace,
196
+ preamblePreview: preamble.substring(0, 100)
197
+ });
198
+ trimmed = trimmed.substring(firstBrace);
199
+ } else if (firstBrace === -1) {
200
+ debugLog("[sanitizeJsonString] No JSON object found in text");
201
+ return trimmed; // Return as-is if no { found
202
+ }
203
+
164
204
  // Try parsing as-is first
165
205
  try {
166
206
  JSON.parse(trimmed);
@@ -516,26 +556,31 @@ function findElementLineInFile(
516
556
 
517
557
  // PRIORITY 2c: Word-based matching for text with special characters (bullets, etc.)
518
558
  // Extract significant words and find lines containing multiple of them
519
- if (normalizedText.length > 10) {
520
- const commonWords = ['the', 'this', 'that', 'with', 'from', 'have', 'will', 'your', 'for', 'and', 'use'];
559
+ // IMPROVED: Lower threshold to 3-char words and flexible match requirements
560
+ if (normalizedText.length > 8) {
561
+ const commonWords = ['the', 'this', 'that', 'with', 'from', 'have', 'will', 'your', 'for', 'and', 'use', 'are', 'you', 'can', 'its', 'not', 'but', 'all', 'was', 'has', 'any'];
521
562
  const significantWords = normalizedText.split(' ')
522
- .filter(word => word.length >= 4 && !commonWords.includes(word.toLowerCase()))
523
- .slice(0, 5); // Take up to 5 significant words
524
-
525
- if (significantWords.length >= 2) {
563
+ .filter(word => word.length >= 3 && !commonWords.includes(word.toLowerCase()))
564
+ .slice(0, 8); // Take up to 8 significant words for longer text
565
+
566
+ // Flexible threshold: require fewer matches for longer text
567
+ const minSignificantWords = normalizedText.length > 50 ? 1 : 2;
568
+ const minMatches = normalizedText.length > 50 ? 1 : 2;
569
+
570
+ if (significantWords.length >= minSignificantWords) {
526
571
  for (let i = 0; i < lines.length; i++) {
527
572
  const lineLower = lines[i].toLowerCase();
528
- const matchCount = significantWords.filter(word =>
573
+ const matchCount = significantWords.filter(word =>
529
574
  lineLower.includes(word.toLowerCase())
530
575
  ).length;
531
-
532
- // Require at least 2 word matches for confidence
533
- if (matchCount >= 2) {
576
+
577
+ // Flexible matching: longer text can match with fewer words
578
+ if (matchCount >= minMatches) {
534
579
  return {
535
580
  lineNumber: i + 1,
536
581
  snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
537
- confidence: 'medium',
538
- matchedBy: `word match: ${significantWords.slice(0, 3).join(', ')}... (${matchCount} words)`
582
+ confidence: matchCount >= 3 ? 'medium' : 'low',
583
+ matchedBy: `word match: ${significantWords.slice(0, 3).join(', ')}... (${matchCount}/${significantWords.length} words)`
539
584
  };
540
585
  }
541
586
  }
@@ -561,6 +606,70 @@ function findElementLineInFile(
561
606
  }
562
607
  }
563
608
 
609
+ // PRIORITY 2c: Image source matching (for logo/image elements)
610
+ if (focusedElement.type === 'logo') {
611
+ // If we have imageSrc, try to match it
612
+ if (focusedElement.imageSrc) {
613
+ // Clean the image src - remove Next.js image optimization params
614
+ let cleanSrc = focusedElement.imageSrc;
615
+ if (cleanSrc.includes("/_next/image")) {
616
+ const urlMatch = cleanSrc.match(/url=([^&]+)/);
617
+ if (urlMatch) {
618
+ cleanSrc = decodeURIComponent(urlMatch[1]);
619
+ }
620
+ }
621
+
622
+ // Extract just the filename for flexible matching
623
+ const filename = cleanSrc.split('/').pop()?.replace(/\.[^.]+$/, '') || '';
624
+ const fullPath = cleanSrc.startsWith('/') ? cleanSrc : `/${cleanSrc}`;
625
+
626
+ // Try exact path match first
627
+ for (let i = 0; i < lines.length; i++) {
628
+ if (lines[i].includes(fullPath) || lines[i].includes(cleanSrc)) {
629
+ return {
630
+ lineNumber: i + 1,
631
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
632
+ confidence: 'high',
633
+ matchedBy: `image src="${cleanSrc}"`
634
+ };
635
+ }
636
+ }
637
+
638
+ // Try filename match (handles dynamic sources like brandLogos)
639
+ if (filename && filename.length > 5) {
640
+ for (let i = 0; i < lines.length; i++) {
641
+ if (lines[i].includes(filename)) {
642
+ return {
643
+ lineNumber: i + 1,
644
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
645
+ confidence: 'medium',
646
+ matchedBy: `image filename="${filename}"`
647
+ };
648
+ }
649
+ }
650
+ }
651
+ }
652
+
653
+ // Always try to match the element name (which is usually derived from the image filename)
654
+ // This works even without imageSrc since the name is captured from alt text or data attributes
655
+ if (focusedElement.name && focusedElement.name.length > 5) {
656
+ // Skip generic names
657
+ const genericNames = ['Logo', 'Image', 'Img', 'Picture', 'Photo', 'Icon'];
658
+ if (!genericNames.includes(focusedElement.name)) {
659
+ for (let i = 0; i < lines.length; i++) {
660
+ if (lines[i].includes(focusedElement.name)) {
661
+ return {
662
+ lineNumber: i + 1,
663
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
664
+ confidence: 'medium',
665
+ matchedBy: `image name="${focusedElement.name}"`
666
+ };
667
+ }
668
+ }
669
+ }
670
+ }
671
+ }
672
+
564
673
  // PRIORITY 3: Distinctive className patterns (semantic classes from design system)
565
674
  if (focusedElement.className) {
566
675
  // SEMANTIC CLASS DETECTION: Instead of filtering OUT utilities, filter IN semantics
@@ -676,10 +785,224 @@ function findElementLineInFile(
676
785
  }
677
786
  }
678
787
  }
679
-
788
+
789
+ // FALLBACK 3: Multi-line JSX text parsing
790
+ // This handles cases where text spans multiple lines in JSX
791
+ if (focusedElement.textContent && focusedElement.textContent.trim().length > 5) {
792
+ debugLog("Trying multi-line JSX parsing", { text: focusedElement.textContent.substring(0, 50) });
793
+ const multilineMatch = findTextInMultilineJSX(fileContent, focusedElement.textContent);
794
+ if (multilineMatch) {
795
+ debugLog("Multi-line JSX match found", multilineMatch);
796
+ return multilineMatch;
797
+ }
798
+ }
799
+
680
800
  return null;
681
801
  }
682
802
 
803
+ /**
804
+ * Multi-line JSX text parser
805
+ *
806
+ * Handles text that spans multiple lines in JSX:
807
+ * ```jsx
808
+ * <p className="text-base">
809
+ * A list of selectable items displayed in a dropdown menu
810
+ * </p>
811
+ * ```
812
+ *
813
+ * Returns the starting line number of the element if found.
814
+ */
815
+ function findTextInMultilineJSX(
816
+ fileContent: string,
817
+ searchText: string,
818
+ tagName?: string
819
+ ): { lineNumber: number; snippet: string; confidence: 'high' | 'medium'; matchedBy: string } | null {
820
+ if (!fileContent || !searchText) return null;
821
+
822
+ const lines = fileContent.split('\n');
823
+ const normalizedSearch = searchText.trim().replace(/\s+/g, ' ').toLowerCase();
824
+
825
+ // Skip if text is too short
826
+ if (normalizedSearch.length < 5) return null;
827
+
828
+ // Look for JSX elements that might contain this text
829
+ // Strategy: Find opening tags and then look at subsequent lines for text content
830
+ const textTags = tagName ? [tagName] : ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'div', 'label', 'a', 'li', 'blockquote', 'figcaption'];
831
+
832
+ for (let i = 0; i < lines.length; i++) {
833
+ const line = lines[i];
834
+
835
+ // Check if this line opens a text element
836
+ for (const tag of textTags) {
837
+ const tagPattern = new RegExp(`<${tag}(?:\\s|>|$)`, 'i');
838
+ if (!tagPattern.test(line)) continue;
839
+
840
+ // Found a potential opening tag - collect text until closing tag
841
+ let collectedText = '';
842
+ let endLineIndex = i;
843
+ const closingTag = `</${tag}>`;
844
+
845
+ // Collect text from this line and subsequent lines
846
+ for (let j = i; j < Math.min(i + 10, lines.length); j++) {
847
+ const currentLine = lines[j];
848
+
849
+ // Add text content (strip JSX tags and props)
850
+ const textOnly = currentLine
851
+ .replace(/<[^>]+>/g, ' ') // Remove all tags
852
+ .replace(/\{[^}]*\}/g, '') // Remove JSX expressions
853
+ .replace(/className=["'][^"']*["']/g, '')
854
+ .trim();
855
+
856
+ if (textOnly) {
857
+ collectedText += ' ' + textOnly;
858
+ }
859
+
860
+ // Check if we found the closing tag
861
+ if (currentLine.includes(closingTag) || (j > i && /<\/\w+>/.test(currentLine))) {
862
+ endLineIndex = j;
863
+ break;
864
+ }
865
+ }
866
+
867
+ // Normalize collected text and compare
868
+ const normalizedCollected = collectedText.trim().replace(/\s+/g, ' ').toLowerCase();
869
+
870
+ // Check for match - allow partial matching for long text
871
+ if (normalizedCollected.includes(normalizedSearch) || normalizedSearch.includes(normalizedCollected)) {
872
+ return {
873
+ lineNumber: i + 1,
874
+ snippet: lines.slice(Math.max(0, i - 2), endLineIndex + 3).join('\n'),
875
+ confidence: normalizedCollected === normalizedSearch ? 'high' : 'medium',
876
+ matchedBy: `multi-line JSX <${tag}> text content`
877
+ };
878
+ }
879
+
880
+ // Also check word overlap for fuzzy matching
881
+ const searchWords = normalizedSearch.split(' ').filter(w => w.length >= 3);
882
+ const collectedWords = normalizedCollected.split(' ').filter(w => w.length >= 3);
883
+
884
+ if (searchWords.length >= 3 && collectedWords.length >= 3) {
885
+ const matchingWords = searchWords.filter(w => collectedWords.some(cw => cw.includes(w) || w.includes(cw)));
886
+ const matchRatio = matchingWords.length / searchWords.length;
887
+
888
+ // Require at least 60% word match
889
+ if (matchRatio >= 0.6) {
890
+ return {
891
+ lineNumber: i + 1,
892
+ snippet: lines.slice(Math.max(0, i - 2), endLineIndex + 3).join('\n'),
893
+ confidence: 'medium',
894
+ matchedBy: `multi-line JSX fuzzy match (${Math.round(matchRatio * 100)}% word overlap)`
895
+ };
896
+ }
897
+ }
898
+ }
899
+ }
900
+
901
+ return null;
902
+ }
903
+
904
+ /**
905
+ * AI-assisted element location
906
+ *
907
+ * When regex-based matching fails, use a focused AI call to locate the element.
908
+ * This works for ANY codebase structure since it understands code semantics.
909
+ */
910
+ async function aiAssistedElementLocation(
911
+ fileContent: string,
912
+ element: VisionFocusedElement,
913
+ filePath: string,
914
+ apiKey: string
915
+ ): Promise<{ lineNumber: number; snippet: string; confidence: 'high' | 'medium'; matchedBy: string } | null> {
916
+ debugLog("Trying AI-assisted element location", {
917
+ elementType: element.type,
918
+ textContent: element.textContent?.substring(0, 50),
919
+ file: filePath
920
+ });
921
+
922
+ // Build a focused prompt for element location
923
+ const elementInfo = [
924
+ element.textContent ? `Text content: "${element.textContent}"` : null,
925
+ element.type ? `Element type: ${element.type}` : null,
926
+ element.className ? `CSS classes: ${element.className.substring(0, 100)}` : null,
927
+ element.elementId ? `DOM id: ${element.elementId}` : null,
928
+ element.name ? `Name: ${element.name}` : null,
929
+ ].filter(Boolean).join('\n');
930
+
931
+ // Add line numbers to file content for reference
932
+ const lines = fileContent.split('\n');
933
+ const numberedContent = lines.map((line, i) => `${i + 1}| ${line}`).join('\n');
934
+
935
+ // Keep file content reasonable (first 500 lines or 20KB)
936
+ const maxLines = 500;
937
+ const maxChars = 20000;
938
+ let truncatedContent = numberedContent;
939
+ if (lines.length > maxLines || numberedContent.length > maxChars) {
940
+ truncatedContent = lines.slice(0, maxLines).map((line, i) => `${i + 1}| ${line}`).join('\n');
941
+ truncatedContent += '\n... (file truncated for analysis)';
942
+ }
943
+
944
+ const prompt = `You are a code analyzer. Find the LINE NUMBER where a specific UI element is defined in this React/JSX file.
945
+
946
+ ELEMENT TO FIND:
947
+ ${elementInfo}
948
+
949
+ FILE CONTENT (with line numbers):
950
+ \`\`\`tsx
951
+ ${truncatedContent}
952
+ \`\`\`
953
+
954
+ INSTRUCTIONS:
955
+ 1. Find where this element is defined in the JSX
956
+ 2. Look for matching text content, className, or element type
957
+ 3. Return ONLY a JSON object with the line number
958
+
959
+ Respond with ONLY valid JSON, no explanation:
960
+ {"lineNumber": <number>, "confidence": "high" | "medium"}
961
+
962
+ If you cannot find the element, respond with:
963
+ {"lineNumber": null}`;
964
+
965
+ try {
966
+ const anthropic = new Anthropic({ apiKey });
967
+ const response = await anthropic.messages.create({
968
+ model: "claude-sonnet-4-20250514",
969
+ max_tokens: 100,
970
+ messages: [{ role: "user", content: prompt }]
971
+ });
972
+
973
+ const responseText = response.content[0].type === 'text' ? response.content[0].text : '';
974
+ debugLog("AI element location response", { raw: responseText });
975
+
976
+ // Parse the JSON response
977
+ const jsonMatch = responseText.match(/\{[^}]+\}/);
978
+ if (!jsonMatch) {
979
+ debugLog("AI response not valid JSON");
980
+ return null;
981
+ }
982
+
983
+ const result = JSON.parse(jsonMatch[0]);
984
+ if (!result.lineNumber || typeof result.lineNumber !== 'number') {
985
+ debugLog("AI could not locate element");
986
+ return null;
987
+ }
988
+
989
+ const lineNum = result.lineNumber;
990
+ const confidence = result.confidence === 'high' ? 'high' : 'medium';
991
+
992
+ debugLog("AI located element successfully", { lineNumber: lineNum, confidence });
993
+
994
+ return {
995
+ lineNumber: lineNum,
996
+ snippet: lines.slice(Math.max(0, lineNum - 4), Math.min(lines.length, lineNum + 5)).join('\n'),
997
+ confidence,
998
+ matchedBy: `AI-assisted location (${confidence} confidence)`
999
+ };
1000
+ } catch (error) {
1001
+ debugLog("AI element location failed", { error: String(error) });
1002
+ return null;
1003
+ }
1004
+ }
1005
+
683
1006
  /**
684
1007
  * Search imported component files for specific TEXT CONTENT
685
1008
  * This is used to redirect from parent components to child components
@@ -821,15 +1144,29 @@ function findElementInImportedFiles(
821
1144
  file.path.endsWith('.tsx') ||
822
1145
  file.path.endsWith('.jsx');
823
1146
 
824
- // Skip non-component files but allow page files
825
- if (!isComponentFile) continue;
1147
+ // For logo elements, also search config files that may contain image paths
1148
+ const isConfigFile = focusedElement.type === 'logo' && (
1149
+ file.path.includes('brand') ||
1150
+ file.path.includes('config') ||
1151
+ file.path.includes('constants') ||
1152
+ file.path.includes('assets') ||
1153
+ file.path.includes('images') ||
1154
+ file.path.includes('theme')
1155
+ );
1156
+
1157
+ // Skip non-component files but allow page files and config files for logos
1158
+ if (!isComponentFile && !isConfigFile) continue;
826
1159
 
827
- // Skip known non-UI files
828
- if (file.path.includes('/types') ||
1160
+ // Skip known non-UI files (but allow config files for logos)
1161
+ const isSkippable = file.path.includes('/types') ||
829
1162
  file.path.includes('/hooks/') ||
830
- file.path.includes('/utils/') ||
831
- file.path.includes('/lib/') ||
832
- file.path.includes('.d.ts')) continue;
1163
+ file.path.includes('.d.ts');
1164
+
1165
+ // For non-logo elements, also skip utils and lib
1166
+ const isUtilFile = file.path.includes('/utils/') || file.path.includes('/lib/');
1167
+
1168
+ if (isSkippable) continue;
1169
+ if (isUtilFile && focusedElement.type !== 'logo') continue;
833
1170
 
834
1171
  const result = findElementLineInFile(file.content, focusedElement);
835
1172
  // Accept ALL matches including low confidence - let the scoring decide
@@ -1722,7 +2059,8 @@ function searchFilesSmart(
1722
2059
  return sortedResults.map(r => ({ path: r.path, content: r.content, score: r.score, filenameMatch: r.filenameMatch }));
1723
2060
  }
1724
2061
 
1725
- const VISION_SYSTEM_PROMPT = `You are an expert frontend developer. Edit the code to fulfill the user's request.
2062
+ // Base system prompt - styling guidance is added dynamically based on codebase detection
2063
+ const VISION_SYSTEM_PROMPT_BASE = `You are an expert frontend developer. Edit the code to fulfill the user's request.
1726
2064
 
1727
2065
  CRITICAL: Return ONLY the JSON object. No explanations, no preamble, no markdown code fences.
1728
2066
  Start your response with { and end with }
@@ -1732,6 +2070,54 @@ Output format:
1732
2070
 
1733
2071
  The "search" field must match the file EXACTLY (copy-paste from the code provided).`;
1734
2072
 
2073
+ // Cache for styling analysis to avoid re-detecting on each request
2074
+ let cachedStylingAnalysis: StylingAnalysis | null = null;
2075
+ let cachedStylingGuidance: string | null = null;
2076
+ let cacheTimestamp: number = 0;
2077
+ const CACHE_TTL = 60000; // 1 minute cache
2078
+
2079
+ /**
2080
+ * Build the complete system prompt with dynamic styling guidance
2081
+ */
2082
+ async function buildVisionSystemPrompt(projectRoot: string): Promise<string> {
2083
+ const now = Date.now();
2084
+
2085
+ // Use cache if available and fresh
2086
+ if (cachedStylingGuidance && (now - cacheTimestamp) < CACHE_TTL) {
2087
+ return VISION_SYSTEM_PROMPT_BASE + "\n\n" + cachedStylingGuidance;
2088
+ }
2089
+
2090
+ // Detect styling system
2091
+ try {
2092
+ cachedStylingAnalysis = await detectStylingSystem(projectRoot);
2093
+ cachedStylingGuidance = generateStylingGuidance(cachedStylingAnalysis);
2094
+ cacheTimestamp = now;
2095
+
2096
+ debugLog("Styling system detected", {
2097
+ primary: cachedStylingAnalysis.primarySystem,
2098
+ secondary: cachedStylingAnalysis.secondarySystems,
2099
+ libraries: cachedStylingAnalysis.componentLibraries,
2100
+ confidence: cachedStylingAnalysis.confidence,
2101
+ hasClassMerging: cachedStylingAnalysis.hasClassMerging,
2102
+ });
2103
+ } catch (error) {
2104
+ debugLog("Styling detection failed, using generic guidance", { error });
2105
+ cachedStylingGuidance = generateStylingGuidance({
2106
+ primarySystem: "unknown",
2107
+ secondarySystems: [],
2108
+ componentLibraries: [],
2109
+ hasClassMerging: false,
2110
+ hasCSSVariables: false,
2111
+ hasDesignTokens: false,
2112
+ confidence: 0,
2113
+ evidence: { files: [], patterns: [] },
2114
+ });
2115
+ cacheTimestamp = now;
2116
+ }
2117
+
2118
+ return VISION_SYSTEM_PROMPT_BASE + "\n\n" + cachedStylingGuidance;
2119
+ }
2120
+
1735
2121
  /**
1736
2122
  * PHASE 2: Targeted Patch Generation Prompt
1737
2123
  *
@@ -1831,6 +2217,129 @@ DESIGN CONSTRAINTS
1831
2217
  ═══════════════════════════════════════════════════════════════════════════════
1832
2218
  `;
1833
2219
 
2220
+ /**
2221
+ * Modification Intent Types
2222
+ * - 'instance': User wants to modify specific instances (e.g., "make THESE cards sharp")
2223
+ * - 'component': User wants to modify the component definition (e.g., "make ALL cards sharp")
2224
+ * - 'unknown': Cannot determine intent, default to instance behavior
2225
+ */
2226
+ type ModificationIntent = 'instance' | 'component' | 'unknown';
2227
+
2228
+ /**
2229
+ * Analyze user prompt to determine modification scope
2230
+ *
2231
+ * This helps the AI understand whether to:
2232
+ * - Modify instances in the page file (instance)
2233
+ * - Modify the component definition file (component)
2234
+ *
2235
+ * Examples:
2236
+ * - "make THESE cards sharp" → instance (modify page file)
2237
+ * - "make ALL cards sharp" → component (modify Card.tsx)
2238
+ * - "change the Card component" → component (modify Card.tsx)
2239
+ * - "give this button a red background" → instance (modify page file)
2240
+ */
2241
+ function analyzeModificationIntent(prompt: string): {
2242
+ intent: ModificationIntent;
2243
+ reason: string;
2244
+ componentName?: string; // e.g., "Card", "Button" - extracted from prompt
2245
+ } {
2246
+ const promptLower = prompt.toLowerCase();
2247
+
2248
+ // Component-level patterns (modify the component definition)
2249
+ const componentPatterns = [
2250
+ { pattern: /\b(all|every|each)\s+(cards?|buttons?|inputs?|dialogs?|modals?)/i, reason: 'Plural "all/every" indicates global change' },
2251
+ { pattern: /\bthe\s+(card|button|input|dialog|modal)\s+component/i, reason: 'Explicit component reference' },
2252
+ { pattern: /\bcomponent\s+(default|definition|style)/i, reason: 'Explicit component modification' },
2253
+ { pattern: /\bglobally?\b/i, reason: 'Global keyword detected' },
2254
+ { pattern: /\bdefault\s+(style|behavior|look)/i, reason: 'Default modification requested' },
2255
+ { pattern: /\beverywhere\b/i, reason: 'Everywhere keyword detected' },
2256
+ ];
2257
+
2258
+ // Instance-level patterns (modify specific instances)
2259
+ const instancePatterns = [
2260
+ { pattern: /\b(this|these|that|those)\s+\w+/i, reason: 'Demonstrative pronoun indicates specific instance' },
2261
+ { pattern: /\bthe\s+\w+\s+(here|on this page|in this section)/i, reason: 'Location-specific reference' },
2262
+ { pattern: /\bjust\s+(this|the)/i, reason: 'Restrictive "just" indicates specific instance' },
2263
+ { pattern: /\bonly\s+(this|the|these)/i, reason: 'Restrictive "only" indicates specific instance' },
2264
+ ];
2265
+
2266
+ // Check for component patterns first (they're more specific)
2267
+ for (const { pattern, reason } of componentPatterns) {
2268
+ const match = promptLower.match(pattern);
2269
+ if (match) {
2270
+ // Extract component name if present
2271
+ const componentMatch = prompt.match(/\b(card|button|input|dialog|modal|badge|tabs?|dropdown)/i);
2272
+ return {
2273
+ intent: 'component',
2274
+ reason,
2275
+ componentName: componentMatch ? componentMatch[1].charAt(0).toUpperCase() + componentMatch[1].slice(1).toLowerCase() : undefined
2276
+ };
2277
+ }
2278
+ }
2279
+
2280
+ // Check for instance patterns
2281
+ for (const { pattern, reason } of instancePatterns) {
2282
+ if (pattern.test(promptLower)) {
2283
+ return { intent: 'instance', reason };
2284
+ }
2285
+ }
2286
+
2287
+ // Default to instance when user clicks on specific element
2288
+ return {
2289
+ intent: 'unknown',
2290
+ reason: 'No clear intent markers detected, will use element location as guide'
2291
+ };
2292
+ }
2293
+
2294
+ /**
2295
+ * Find the component definition file for a given component name
2296
+ * Searches common component directories: components/ui/, components/, etc.
2297
+ */
2298
+ function findComponentDefinitionFile(
2299
+ componentName: string,
2300
+ projectRoot: string
2301
+ ): { path: string; content: string } | null {
2302
+ // Common component directory patterns
2303
+ const searchPaths = [
2304
+ `src/components/ui/${componentName.toLowerCase()}.tsx`,
2305
+ `src/components/ui/${componentName.toLowerCase()}.jsx`,
2306
+ `src/components/${componentName.toLowerCase()}.tsx`,
2307
+ `src/components/${componentName.toLowerCase()}.jsx`,
2308
+ `components/ui/${componentName.toLowerCase()}.tsx`,
2309
+ `components/${componentName.toLowerCase()}.tsx`,
2310
+ ];
2311
+
2312
+ for (const searchPath of searchPaths) {
2313
+ const fullPath = path.join(projectRoot, searchPath);
2314
+ if (fs.existsSync(fullPath)) {
2315
+ try {
2316
+ const content = fs.readFileSync(fullPath, 'utf-8');
2317
+ // Verify it's actually a component definition (has export, cva, or component patterns)
2318
+ if (content.includes('export') && (
2319
+ content.includes('cva(') ||
2320
+ content.includes('forwardRef') ||
2321
+ content.includes('function ' + componentName) ||
2322
+ content.includes('const ' + componentName)
2323
+ )) {
2324
+ debugLog("Found component definition file", {
2325
+ componentName,
2326
+ path: searchPath
2327
+ });
2328
+ return { path: searchPath, content };
2329
+ }
2330
+ } catch {
2331
+ // Ignore read errors
2332
+ }
2333
+ }
2334
+ }
2335
+
2336
+ debugLog("Component definition file not found", {
2337
+ componentName,
2338
+ searchedPaths: searchPaths
2339
+ });
2340
+ return null;
2341
+ }
2342
+
1834
2343
  /**
1835
2344
  * Validate patches against identified problems
1836
2345
  * Rejects patches that don't reference a valid problem ID
@@ -1936,7 +2445,7 @@ export async function POST(request: Request) {
1936
2445
 
1937
2446
  try {
1938
2447
  const body: ApplyFirstRequest = await request.json();
1939
- const { action, sessionId, screenshot, pageRoute, userPrompt, focusedElements, previewedModifications, chatHistory } = body;
2448
+ const { action, sessionId, screenshot, pageRoute, userPrompt, focusedElements, previewedModifications, chatHistory, currentTheme, propertyEdits } = body;
1940
2449
  const projectRoot = process.cwd();
1941
2450
 
1942
2451
  // ========== ACCEPT ACTION ==========
@@ -2037,6 +2546,9 @@ export async function POST(request: Request) {
2037
2546
  // Generate a unique session ID
2038
2547
  const newSessionId = randomUUID().slice(0, 8);
2039
2548
 
2549
+ // Build dynamic system prompt based on detected styling system
2550
+ const visionSystemPrompt = await buildVisionSystemPrompt(projectRoot);
2551
+
2040
2552
  // Initialize file discovery variables
2041
2553
  let smartSearchFiles: { path: string; content: string }[] = [];
2042
2554
  let recommendedFile: { path: string; reason: string } | null = null;
@@ -2456,7 +2968,7 @@ export async function POST(request: Request) {
2456
2968
  const importedMatch = findElementInImportedFiles(
2457
2969
  focusedElements[0],
2458
2970
  pageContext.componentSources,
2459
- actualTargetFile?.path // Pass page file for proximity scoring
2971
+ actualTargetFile?.path ?? undefined // Pass page file for proximity scoring
2460
2972
  );
2461
2973
 
2462
2974
  if (importedMatch) {
@@ -2519,17 +3031,71 @@ export async function POST(request: Request) {
2519
3031
  }
2520
3032
 
2521
3033
  // ========== SMART SEARCH FALLBACK ==========
2522
- // If we still don't have a good target file (just the page wrapper),
2523
- // use the smart search top result - it already found relevant files!
2524
- if (actualTargetFile && actualTargetFile.path === pageContext.pageFile && smartSearchTopPath) {
3034
+ // Use smart search fallback when:
3035
+ // 1. Element was NOT found OR found with LOW confidence in the current target file
3036
+ // 2. Current target is still the page wrapper file
3037
+ // 3. We have a smart search result to redirect to
3038
+ // This allows weak matches (low confidence) to be overridden by better search results
3039
+ const isWeakOrNoMatch = !elementLocation || elementLocation.confidence === 'low';
3040
+
3041
+ if (isWeakOrNoMatch && actualTargetFile && actualTargetFile.path === pageContext.pageFile && smartSearchTopPath) {
2525
3042
  const smartSearchFullPath = path.join(projectRoot, smartSearchTopPath);
2526
3043
  if (fs.existsSync(smartSearchFullPath)) {
2527
3044
  const smartSearchContent = fs.readFileSync(smartSearchFullPath, 'utf-8');
3045
+ const previousConfidence = elementLocation?.confidence || 'none';
2528
3046
  actualTargetFile = { path: smartSearchTopPath, content: smartSearchContent };
2529
- debugLog("SMART SEARCH FALLBACK: Using top smart search result as target", {
3047
+ // Reset elementLocation so we search for the element in the new file
3048
+ elementLocation = null;
3049
+ debugLog("SMART SEARCH FALLBACK: Weak/no match in page file, using smart search result", {
2530
3050
  originalFile: pageContext.pageFile,
2531
3051
  redirectTo: smartSearchTopPath,
2532
- contentLength: smartSearchContent.length
3052
+ contentLength: smartSearchContent.length,
3053
+ previousConfidence,
3054
+ reason: previousConfidence === 'low'
3055
+ ? "Low confidence match overridden by smart search"
3056
+ : "Element was not located in the page file"
3057
+ });
3058
+ }
3059
+ } else if (elementLocation && elementLocation.confidence !== 'low' && actualTargetFile?.path === pageContext.pageFile) {
3060
+ debugLog("SMART SEARCH FALLBACK: SKIPPED - Element found with sufficient confidence in page file", {
3061
+ file: actualTargetFile.path,
3062
+ matchedBy: elementLocation.matchedBy,
3063
+ confidence: elementLocation.confidence,
3064
+ wouldHaveRedirectedTo: smartSearchTopPath || 'none'
3065
+ });
3066
+ }
3067
+
3068
+ // ========== INTENT-BASED COMPONENT REDIRECT ==========
3069
+ // Analyze user prompt to determine if they want to modify:
3070
+ // - Instance: specific elements on this page (keep current target)
3071
+ // - Component: the component definition (redirect to component file)
3072
+ const intentAnalysis = analyzeModificationIntent(userPrompt);
3073
+ debugLog("Intent analysis", {
3074
+ intent: intentAnalysis.intent,
3075
+ reason: intentAnalysis.reason,
3076
+ componentName: intentAnalysis.componentName || 'none'
3077
+ });
3078
+
3079
+ if (intentAnalysis.intent === 'component' && intentAnalysis.componentName) {
3080
+ // User wants to modify the component definition (e.g., "make ALL cards sharp")
3081
+ const componentFile = findComponentDefinitionFile(intentAnalysis.componentName, projectRoot);
3082
+
3083
+ if (componentFile) {
3084
+ debugLog("INTENT REDIRECT: Switching to component definition file", {
3085
+ originalTarget: actualTargetFile?.path || 'none',
3086
+ componentName: intentAnalysis.componentName,
3087
+ redirectTo: componentFile.path,
3088
+ reason: intentAnalysis.reason
3089
+ });
3090
+
3091
+ actualTargetFile = componentFile;
3092
+
3093
+ // Clear element location since we're now targeting the component file
3094
+ elementLocation = null;
3095
+ } else {
3096
+ debugLog("INTENT REDIRECT: Could not find component definition, keeping instance target", {
3097
+ componentName: intentAnalysis.componentName,
3098
+ fallbackTarget: actualTargetFile?.path || 'none'
2533
3099
  });
2534
3100
  }
2535
3101
  }
@@ -2537,7 +3103,8 @@ export async function POST(request: Request) {
2537
3103
  debugLog("File redirect complete", {
2538
3104
  originalRecommended: recommendedFileContent?.path || 'none',
2539
3105
  actualTarget: actualTargetFile?.path || 'none',
2540
- wasRedirected: actualTargetFile?.path !== recommendedFileContent?.path
3106
+ wasRedirected: actualTargetFile?.path !== recommendedFileContent?.path,
3107
+ intent: intentAnalysis.intent
2541
3108
  });
2542
3109
 
2543
3110
  // Build text content
@@ -2545,6 +3112,8 @@ export async function POST(request: Request) {
2545
3112
 
2546
3113
  Page Route: ${pageRoute}
2547
3114
  User Request: "${userPrompt}"
3115
+ Modification Scope: ${intentAnalysis.intent === 'component' ? 'COMPONENT DEFINITION (modify all instances globally)' : intentAnalysis.intent === 'instance' ? 'SPECIFIC INSTANCE (modify only what user clicked)' : 'INSTANCE (default - modify in current file)'}
3116
+ ${intentAnalysis.componentName ? `Target Component: ${intentAnalysis.componentName}` : ''}
2548
3117
 
2549
3118
  `;
2550
3119
 
@@ -2597,7 +3166,18 @@ User Request: "${userPrompt}"
2597
3166
  for (const el of focusedElements) {
2598
3167
  textContent += `- ${el.name} (${el.type})`;
2599
3168
  if (el.textContent) {
2600
- textContent += ` with text "${el.textContent.substring(0, 30)}"`;
3169
+ // Provide more text content for better element identification (up to 100 chars)
3170
+ const truncatedText = el.textContent.length > 100
3171
+ ? el.textContent.substring(0, 100) + '...'
3172
+ : el.textContent;
3173
+ textContent += ` with text "${truncatedText}"`;
3174
+ }
3175
+ if (el.className) {
3176
+ // Include className for additional matching context
3177
+ const truncatedClass = el.className.length > 80
3178
+ ? el.className.substring(0, 80) + '...'
3179
+ : el.className;
3180
+ textContent += `\n className="${truncatedClass}"`;
2601
3181
  }
2602
3182
  textContent += `\n`;
2603
3183
 
@@ -2650,28 +3230,163 @@ ${elementLocation.snippet}
2650
3230
  ⚠️ IMPORTANT: Modify ONLY the element at line ${elementLocation.lineNumber}, NOT other similar elements in the file.
2651
3231
 
2652
3232
  `;
3233
+ // Add special guidance for text elements with low/medium confidence
3234
+ if (elementLocation.confidence !== 'high' && focusedElements[0]?.type === 'text') {
3235
+ const colorMatch = userPrompt?.match(/#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgb\([^)]+\)|rgba\([^)]+\)/);
3236
+ const targetColor = colorMatch ? colorMatch[0] : '';
3237
+
3238
+ // Determine theme-specific color from propertyEdits
3239
+ const themeColor = currentTheme === 'dark'
3240
+ ? propertyEdits?.colorDark
3241
+ : propertyEdits?.colorLight;
3242
+ const effectiveColor = themeColor || targetColor || '#hexcolor';
3243
+
3244
+ // Build the appropriate class based on current theme
3245
+ const newThemeClass = currentTheme === 'dark'
3246
+ ? `dark:text-[${effectiveColor}]`
3247
+ : `text-[${effectiveColor}]`;
3248
+
3249
+ // The OTHER mode's prefix pattern to preserve
3250
+ const otherModePattern = currentTheme === 'dark'
3251
+ ? 'text-[' // In dark mode, preserve unprefixed light mode classes
3252
+ : 'dark:text-['; // In light mode, preserve dark: prefixed classes
3253
+
3254
+ textContent += `
3255
+ 📝 TEXT ELEMENT MODIFICATION GUIDANCE:
3256
+ This is a text element matched with ${elementLocation.confidence} confidence.
3257
+ PROCEED with the modification - the element is at line ${elementLocation.lineNumber}.
3258
+
3259
+ 🎨 THEME-AWARE COLOR CHANGE (Current mode: ${currentTheme || 'light'}):
3260
+ The user is changing the color ONLY for ${currentTheme || 'light'} mode.
3261
+
3262
+ THEME-SPECIFIC COLOR RULES:
3263
+ - Light mode colors use UNPREFIXED classes: text-[#hexcolor]
3264
+ - Dark mode colors use dark: PREFIX: dark:text-[#hexcolor]
3265
+ - BOTH can coexist: text-[#lightcolor] dark:text-[#darkcolor]
3266
+
3267
+ Current mode is "${currentTheme || 'light'}", so:
3268
+ ${currentTheme === 'dark'
3269
+ ? `- ADD/REPLACE: dark:text-[${effectiveColor}] (NEW dark mode color)
3270
+ - PRESERVE: any existing text-[#...] class (light mode - DO NOT REMOVE)`
3271
+ : `- ADD/REPLACE: text-[${effectiveColor}] (NEW light mode color)
3272
+ - PRESERVE: any existing dark:text-[#...] class (dark mode - DO NOT REMOVE)`
3273
+ }
3274
+
3275
+ ⚠️ INLINE STYLE HANDLING:
3276
+ If element has inline style with color, REMOVE the inline color and use Tailwind class instead.
3277
+
3278
+ EXAMPLES:
3279
+
3280
+ Example 1 - Preserving other mode's color:
3281
+ {
3282
+ "search": "<span className=\\"${currentTheme === 'dark' ? 'text-[#333333]' : 'dark:text-[#FFFFFF]'}\\">",
3283
+ "replace": "<span className=\\"${currentTheme === 'dark' ? 'text-[#333333] ' + newThemeClass : newThemeClass + ' dark:text-[#FFFFFF]'}\\">"
3284
+ }
3285
+
3286
+ Example 2 - Removing inline style:
3287
+ {
3288
+ "search": "<span style={{color: '#D9D9D6'}}>",
3289
+ "replace": "<span className=\\"${newThemeClass}\\">"
3290
+ }
3291
+
3292
+ DO NOT return empty modifications. The element exists at line ${elementLocation.lineNumber}.
3293
+
3294
+ `;
3295
+ }
2653
3296
  } else {
2654
- // Element NOT found in main file OR any imported components
2655
- // BLOCK the LLM from guessing - require empty modifications
2656
- debugLog("BLOCK: Could not locate focused element anywhere", {
3297
+ // Element NOT found by regex - try AI-assisted location as last resort
3298
+ debugLog("Regex matching failed, trying AI-assisted location...", {
2657
3299
  mainFile: actualTargetFile.path,
2658
- searchedImports: pageContext.componentSources.length,
2659
- focusedElements: focusedElements.map(el => ({
2660
- name: el.name,
2661
- type: el.type,
2662
- textContent: el.textContent?.substring(0, 30),
2663
- className: el.className?.substring(0, 50),
2664
- elementId: el.elementId,
2665
- }))
3300
+ focusedElement: focusedElements[0]?.textContent?.substring(0, 50)
2666
3301
  });
2667
-
2668
- // STRONG BLOCK instruction - tell LLM to NOT guess
2669
- textContent += `
3302
+
3303
+ // Try AI-assisted location for each focused element
3304
+ let aiLocation = null;
3305
+ for (const el of focusedElements) {
3306
+ if (el.textContent && el.textContent.length > 5) {
3307
+ aiLocation = await aiAssistedElementLocation(
3308
+ actualTargetFile.content,
3309
+ el,
3310
+ actualTargetFile.path ?? '',
3311
+ apiKey
3312
+ );
3313
+ if (aiLocation) {
3314
+ elementLocation = aiLocation;
3315
+ debugLog("AI located element successfully", {
3316
+ lineNumber: aiLocation.lineNumber,
3317
+ confidence: aiLocation.confidence,
3318
+ matchedBy: aiLocation.matchedBy
3319
+ });
3320
+ break;
3321
+ }
3322
+ }
3323
+ }
3324
+
3325
+ if (elementLocation) {
3326
+ // AI found the element - add precise targeting info
3327
+ textContent += `
3328
+ ══════════════════════════════════════════════════════════════════════════════
3329
+ PRECISE TARGET LOCATION (${elementLocation.confidence} confidence) - AI-Assisted
3330
+ ══════════════════════════════════════════════════════════════════════════════
3331
+ → Matched by: ${elementLocation.matchedBy}
3332
+ → Line: ${elementLocation.lineNumber}
3333
+
3334
+ THE USER CLICKED ON THE ELEMENT AT LINE ${elementLocation.lineNumber}.
3335
+ Here is the exact code around that element:
3336
+ \`\`\`
3337
+ ${elementLocation.snippet}
3338
+ \`\`\`
3339
+
3340
+ ⚠️ IMPORTANT: Modify ONLY the element at line ${elementLocation.lineNumber}, NOT other similar elements in the file.
3341
+
3342
+ `;
3343
+ // Add special guidance for text elements (AI-assisted location)
3344
+ if (focusedElements[0]?.type === 'text') {
3345
+ const colorMatch = userPrompt?.match(/#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgb\([^)]+\)|rgba\([^)]+\)/);
3346
+ const targetColor = colorMatch ? colorMatch[0] : '';
3347
+
3348
+ textContent += `
3349
+ 📝 TEXT ELEMENT MODIFICATION GUIDANCE:
3350
+ This is a text element located by AI at line ${elementLocation.lineNumber}.
3351
+ PROCEED with the modification.
3352
+
3353
+ For TEXT COLOR changes, use one of these approaches:
3354
+ 1. Tailwind arbitrary value (preferred): className="text-[${targetColor || '#hexcolor'}]"
3355
+ 2. Add style prop: style={{ color: '${targetColor || '#hexcolor'}' }}
3356
+
3357
+ Search for the opening tag of the text element at line ${elementLocation.lineNumber} and add the color styling.
3358
+ Example modification:
3359
+ {
3360
+ "search": "<p className=\\"existing-classes\\">",
3361
+ "replace": "<p className=\\"existing-classes text-[${targetColor || '#hexcolor'}]\\">"
3362
+ }
3363
+
3364
+ DO NOT return empty modifications. The element exists at line ${elementLocation.lineNumber}.
3365
+
3366
+ `;
3367
+ }
3368
+ } else {
3369
+ // Element NOT found even with AI - BLOCK the LLM from guessing
3370
+ debugLog("BLOCK: Could not locate focused element anywhere (including AI fallback)", {
3371
+ mainFile: actualTargetFile.path,
3372
+ searchedImports: pageContext.componentSources.length,
3373
+ focusedElements: focusedElements.map(el => ({
3374
+ name: el.name,
3375
+ type: el.type,
3376
+ textContent: el.textContent?.substring(0, 30),
3377
+ className: el.className?.substring(0, 50),
3378
+ elementId: el.elementId,
3379
+ }))
3380
+ });
3381
+
3382
+ // STRONG BLOCK instruction - tell LLM to NOT guess
3383
+ textContent += `
2670
3384
  ⛔ STOP: CANNOT LOCATE THE CLICKED ELEMENT
2671
3385
 
2672
3386
  The user clicked on a specific element, but it could NOT be found in:
2673
3387
  - ${actualTargetFile.path} (main target file)
2674
3388
  - Any of the ${pageContext.componentSources.length} imported component files
3389
+ - AI-assisted search also failed to locate the element
2675
3390
 
2676
3391
  The element may be:
2677
3392
  - Deeply nested in a component not in the import tree
@@ -2685,6 +3400,7 @@ DO NOT GUESS. Return this exact response:
2685
3400
  }
2686
3401
 
2687
3402
  `;
3403
+ }
2688
3404
  }
2689
3405
  }
2690
3406
 
@@ -2733,32 +3449,45 @@ ${linesWithNumbers}
2733
3449
  usedContext += content.length;
2734
3450
  }
2735
3451
 
2736
- // ========== KEY UI COMPONENTS ==========
3452
+ // ========== KEY UI COMPONENTS (CVA) ==========
2737
3453
  // Include UI component definitions (Button, Card, etc.) so LLM knows available variants/props
2738
- const uiComponentPaths = ['components/ui/button', 'components/ui/card', 'components/ui/input'];
3454
+ // This helps the AI understand what default styles exist and how to properly override them
3455
+ const uiComponentPaths = [
3456
+ 'components/ui/button',
3457
+ 'components/ui/card',
3458
+ 'components/ui/input',
3459
+ 'components/ui/badge',
3460
+ 'components/ui/dialog',
3461
+ 'components/ui/tabs',
3462
+ ];
2739
3463
  const includedUIComponents: string[] = [];
2740
3464
 
2741
3465
  for (const comp of pageContext.componentSources) {
2742
3466
  // Check if this is a UI component we should include
2743
3467
  const isUIComponent = uiComponentPaths.some(uiPath => comp.path.includes(uiPath));
2744
3468
  if (isUIComponent && usedContext + comp.content.length < TOTAL_CONTEXT_BUDGET) {
2745
- // Only include the variants/props section, not the whole file
2746
- const variantsMatch = comp.content.match(/variants:\s*\{[\s\S]*?\n\s*\},\n\s*defaultVariants/);
2747
- if (variantsMatch) {
2748
- textContent += `
2749
- --- UI Component: ${comp.path} (variants only) ---
2750
- ${variantsMatch[0]}
3469
+ // Capture the full CVA definition including variants AND defaultVariants
3470
+ const cvaMatch = comp.content.match(/const \w+Variants = cva\(\s*["`'][\s\S]*?defaultVariants:\s*\{[^}]*\},?\s*\}\s*\)/);
3471
+ if (cvaMatch) {
3472
+ textContent += `
3473
+ --- UI Component: ${comp.path} (CVA definition) ---
3474
+ ${cvaMatch[0]}
3475
+
3476
+ ⚠️ NOTE: This component has DEFAULT STYLES via CVA.
3477
+ - Use variant props when available: size="compact", variant="elevated"
3478
+ - Or override with className: className="rounded-none" (tailwind-merge handles conflicts)
3479
+ - The cn() utility merges classes intelligently, so className WILL override defaults
2751
3480
  ---
2752
3481
 
2753
3482
  `;
2754
3483
  includedUIComponents.push(comp.path);
2755
- usedContext += variantsMatch[0].length + 100;
3484
+ usedContext += cvaMatch[0].length + 200;
2756
3485
  }
2757
3486
  }
2758
3487
  }
2759
3488
 
2760
3489
  if (includedUIComponents.length > 0) {
2761
- debugLog("Included UI component definitions", { components: includedUIComponents });
3490
+ debugLog("Included UI component CVA definitions", { components: includedUIComponents });
2762
3491
  }
2763
3492
 
2764
3493
  // ========== THEME DISCOVERY ==========
@@ -2811,7 +3540,209 @@ Your patch should be:
2811
3540
  }]
2812
3541
  }
2813
3542
 
3543
+ `;
3544
+
3545
+ // Add CRITICAL instruction - softer for text elements with color changes
3546
+ const isTextColorChange = focusedElements?.some(el => el.type === 'text') &&
3547
+ userPrompt?.toLowerCase().includes('color');
3548
+
3549
+ if (isTextColorChange && elementLocation) {
3550
+ // Determine theme-specific color from propertyEdits
3551
+ const themeColor = currentTheme === 'dark'
3552
+ ? propertyEdits?.colorDark
3553
+ : propertyEdits?.colorLight;
3554
+ const colorMatch = userPrompt?.match(/#[0-9A-Fa-f]{6}|#[0-9A-Fa-f]{3}|rgb\([^)]+\)|rgba\([^)]+\)/);
3555
+ const effectiveColor = themeColor || (colorMatch ? colorMatch[0] : '#hexcolor');
3556
+
3557
+ // Build the appropriate class based on current theme
3558
+ // Light mode: unprefixed text-[#color]
3559
+ // Dark mode: dark:text-[#color]
3560
+ const newThemeClass = currentTheme === 'dark'
3561
+ ? `dark:text-[${effectiveColor}]`
3562
+ : `text-[${effectiveColor}]`;
3563
+
3564
+ // The OTHER mode's prefix pattern to preserve
3565
+ const otherModePattern = currentTheme === 'dark'
3566
+ ? 'text-[' // In dark mode, preserve unprefixed light mode classes
3567
+ : 'dark:text-['; // In light mode, preserve dark: prefixed classes
3568
+
3569
+ debugLog("Using theme-aware instruction for text color change", {
3570
+ elementLine: elementLocation.lineNumber,
3571
+ confidence: elementLocation.confidence,
3572
+ matchedBy: elementLocation.matchedBy,
3573
+ currentTheme: currentTheme || 'light',
3574
+ newThemeClass,
3575
+ otherModePattern
3576
+ });
3577
+ textContent += `
3578
+ IMPORTANT: For this THEME-AWARE text color change (${currentTheme || 'light'} mode):
3579
+ 1. Find the text element at line ${elementLocation.lineNumber}
3580
+ 2. ADD or REPLACE the ${currentTheme || 'light'} mode color class: "${newThemeClass}"
3581
+ 3. PRESERVE any existing ${currentTheme === 'dark' ? 'light' : 'dark'} mode color classes (look for "${otherModePattern}...")
3582
+ 4. Your "search" string should match the opening tag of the element
3583
+
3584
+ 🎨 THEME-SPECIFIC COLOR RULES:
3585
+ - Light mode colors use UNPREFIXED classes: text-[#hexcolor]
3586
+ - Dark mode colors use dark: PREFIX: dark:text-[#hexcolor]
3587
+ - BOTH can coexist on the same element: text-[#lightcolor] dark:text-[#darkcolor]
3588
+
3589
+ Current mode is "${currentTheme || 'light'}", so:
3590
+ ${currentTheme === 'dark'
3591
+ ? `- ADD/REPLACE: dark:text-[${effectiveColor}] (this is the NEW dark mode color)
3592
+ - PRESERVE: any existing text-[#...] class (the light mode color - DO NOT REMOVE IT)`
3593
+ : `- ADD/REPLACE: text-[${effectiveColor}] (this is the NEW light mode color)
3594
+ - PRESERVE: any existing dark:text-[#...] class (the dark mode color - DO NOT REMOVE IT)`
3595
+ }
3596
+
3597
+ ⚠️ CRITICAL - INLINE STYLE HANDLING:
3598
+ If the element has an inline style with color (e.g., style={{color: '#...'}}) or style="color: ..."),
3599
+ you MUST REMOVE the inline color from the style prop and use the Tailwind class instead.
3600
+ Inline styles override Tailwind classes due to CSS specificity.
3601
+
3602
+ EXAMPLES:
3603
+
3604
+ Example 1 - Changing light mode color (preserving dark mode):
3605
+ - BEFORE: <span className="dark:text-[#FFFFFF]">text</span>
3606
+ - AFTER: <span className="${currentTheme !== 'dark' ? newThemeClass : 'text-[#333333]'} dark:text-[#FFFFFF]">text</span>
3607
+
3608
+ Example 2 - Changing dark mode color (preserving light mode):
3609
+ - BEFORE: <span className="text-[#333333]">text</span>
3610
+ - AFTER: <span className="text-[#333333] ${currentTheme === 'dark' ? newThemeClass : 'dark:text-[#FFFFFF]'}">text</span>
3611
+
3612
+ Example 3 - Removing inline style and adding theme class:
3613
+ - BEFORE: <span style={{color: '#D9D9D6'}}>text</span>
3614
+ - AFTER: <span className="${newThemeClass}">text</span>
3615
+
3616
+ Example 4 - Both modes already exist, updating ${currentTheme || 'light'} mode:
3617
+ - BEFORE: <span className="text-[#oldLight] dark:text-[#oldDark]">text</span>
3618
+ - AFTER: <span className="${currentTheme === 'dark' ? 'text-[#oldLight] ' + newThemeClass : newThemeClass + ' dark:text-[#oldDark]'}">text</span>
3619
+
3620
+ DO NOT return empty modifications. The element exists at line ${elementLocation.lineNumber}.
3621
+ `;
3622
+ } else {
3623
+ textContent += `
2814
3624
  CRITICAL: Your "search" string MUST exist in the file. If you can't find the exact code, return empty modifications.`;
3625
+ }
3626
+
3627
+ // Add property-specific Tailwind guidance for non-color style changes
3628
+ if (propertyEdits && elementLocation) {
3629
+ const propertyGuidance: string[] = [];
3630
+
3631
+ // Typography properties
3632
+ if (propertyEdits.fontSize) {
3633
+ propertyGuidance.push(`
3634
+ 📐 FONT SIZE: Change to ${propertyEdits.fontSize}
3635
+ Use Tailwind arbitrary value in className: text-[${propertyEdits.fontSize}]
3636
+ Or use standard sizes: text-xs, text-sm, text-base, text-lg, text-xl, text-2xl, etc.
3637
+ Example: className="text-[${propertyEdits.fontSize}]"`);
3638
+ }
3639
+
3640
+ if (propertyEdits.fontWeight) {
3641
+ const weightMap: Record<string, string> = {
3642
+ '100': 'font-thin', '200': 'font-extralight', '300': 'font-light',
3643
+ '400': 'font-normal', '500': 'font-medium', '600': 'font-semibold',
3644
+ '700': 'font-bold', '800': 'font-extrabold', '900': 'font-black'
3645
+ };
3646
+ const tailwindClass = weightMap[propertyEdits.fontWeight] || `font-[${propertyEdits.fontWeight}]`;
3647
+ propertyGuidance.push(`
3648
+ ⚖️ FONT WEIGHT: Change to ${propertyEdits.fontWeight}
3649
+ Use Tailwind class: ${tailwindClass}
3650
+ Example: className="${tailwindClass}"`);
3651
+ }
3652
+
3653
+ if (propertyEdits.lineHeight) {
3654
+ propertyGuidance.push(`
3655
+ ↕️ LINE HEIGHT: Change to ${propertyEdits.lineHeight}
3656
+ Use Tailwind arbitrary value: leading-[${propertyEdits.lineHeight}]
3657
+ Or use standard values: leading-none, leading-tight, leading-normal, leading-relaxed, leading-loose
3658
+ Example: className="leading-[${propertyEdits.lineHeight}]"`);
3659
+ }
3660
+
3661
+ if (propertyEdits.letterSpacing) {
3662
+ propertyGuidance.push(`
3663
+ ↔️ LETTER SPACING: Change to ${propertyEdits.letterSpacing}
3664
+ Use Tailwind arbitrary value: tracking-[${propertyEdits.letterSpacing}]
3665
+ Or use standard values: tracking-tighter, tracking-tight, tracking-normal, tracking-wide, tracking-wider, tracking-widest
3666
+ Example: className="tracking-[${propertyEdits.letterSpacing}]"`);
3667
+ }
3668
+
3669
+ // Layout properties
3670
+ if (propertyEdits.width) {
3671
+ propertyGuidance.push(`
3672
+ 📏 WIDTH: Change to ${propertyEdits.width}
3673
+ Use Tailwind arbitrary value: w-[${propertyEdits.width}]
3674
+ Or use standard widths: w-full, w-1/2, w-auto, w-screen, w-64, w-96, etc.
3675
+ Example: className="w-[${propertyEdits.width}]"`);
3676
+ }
3677
+
3678
+ if (propertyEdits.height) {
3679
+ propertyGuidance.push(`
3680
+ 📐 HEIGHT: Change to ${propertyEdits.height}
3681
+ Use Tailwind arbitrary value: h-[${propertyEdits.height}]
3682
+ Or use standard heights: h-full, h-1/2, h-auto, h-screen, h-64, h-96, etc.
3683
+ Example: className="h-[${propertyEdits.height}]"`);
3684
+ }
3685
+
3686
+ // Appearance properties
3687
+ if (propertyEdits.opacity) {
3688
+ const opacityValue = parseInt(propertyEdits.opacity);
3689
+ propertyGuidance.push(`
3690
+ 👁️ OPACITY: Change to ${propertyEdits.opacity}%
3691
+ Use Tailwind class: opacity-${opacityValue}
3692
+ Standard values: opacity-0, opacity-25, opacity-50, opacity-75, opacity-100
3693
+ Example: className="opacity-${opacityValue}"`);
3694
+ }
3695
+
3696
+ if (propertyEdits.borderRadius) {
3697
+ propertyGuidance.push(`
3698
+ ⬜ BORDER RADIUS: Change to ${propertyEdits.borderRadius}
3699
+ Use Tailwind arbitrary value: rounded-[${propertyEdits.borderRadius}]
3700
+ Or use standard values: rounded-none, rounded-sm, rounded, rounded-md, rounded-lg, rounded-xl, rounded-2xl, rounded-full
3701
+ Example: className="rounded-[${propertyEdits.borderRadius}]"`);
3702
+ }
3703
+
3704
+ // Spacing properties
3705
+ if (propertyEdits.padding) {
3706
+ propertyGuidance.push(`
3707
+ 📦 PADDING: Change to ${propertyEdits.padding}
3708
+ Use Tailwind arbitrary value: p-[${propertyEdits.padding}]
3709
+ Or use standard values: p-0, p-1, p-2, p-4, p-6, p-8, px-4, py-2, pt-4, pb-4, etc.
3710
+ Example: className="p-[${propertyEdits.padding}]"`);
3711
+ }
3712
+
3713
+ if (propertyEdits.margin) {
3714
+ propertyGuidance.push(`
3715
+ ↔️ MARGIN: Change to ${propertyEdits.margin}
3716
+ Use Tailwind arbitrary value: m-[${propertyEdits.margin}]
3717
+ Or use standard values: m-0, m-1, m-2, m-4, m-6, m-8, mx-auto, my-4, mt-4, mb-4, etc.
3718
+ Example: className="m-[${propertyEdits.margin}]"`);
3719
+ }
3720
+
3721
+ if (propertyEdits.gap) {
3722
+ propertyGuidance.push(`
3723
+ 🔲 GAP: Change to ${propertyEdits.gap}
3724
+ Use Tailwind arbitrary value: gap-[${propertyEdits.gap}]
3725
+ Or use standard values: gap-0, gap-1, gap-2, gap-4, gap-6, gap-8, gap-x-4, gap-y-4, etc.
3726
+ Example: className="gap-[${propertyEdits.gap}]"`);
3727
+ }
3728
+
3729
+ if (propertyGuidance.length > 0) {
3730
+ textContent += `
3731
+
3732
+ ═══════════════════════════════════════════════════════════════════════════════
3733
+ PROPERTY-SPECIFIC TAILWIND GUIDANCE
3734
+ ═══════════════════════════════════════════════════════════════════════════════
3735
+ ${propertyGuidance.join('\n')}
3736
+
3737
+ IMPORTANT:
3738
+ 1. Add these Tailwind classes to the element's className prop
3739
+ 2. If the element already has a className, ADD the new class to it
3740
+ 3. If using arbitrary values like w-[200px], ensure the square brackets are included
3741
+ 4. Prefer arbitrary values [value] when exact values are needed
3742
+ 5. Check for existing conflicting classes and replace them (e.g., replace text-sm with text-lg)
3743
+ `;
3744
+ }
3745
+ }
2815
3746
 
2816
3747
  messageContent.push({
2817
3748
  type: "text",
@@ -2835,7 +3766,7 @@ CRITICAL: Your "search" string MUST exist in the file. If you can't find the exa
2835
3766
  if (recommendedFileContent) {
2836
3767
  validFilePaths.add(recommendedFileContent.path);
2837
3768
  }
2838
- if (actualTargetFile && actualTargetFile.path !== recommendedFileContent?.path) {
3769
+ if (actualTargetFile && actualTargetFile.path && actualTargetFile.path !== recommendedFileContent?.path) {
2839
3770
  validFilePaths.add(actualTargetFile.path);
2840
3771
  }
2841
3772
 
@@ -2916,7 +3847,7 @@ This is better than generating patches with made-up code.`,
2916
3847
  model: "claude-sonnet-4-20250514",
2917
3848
  max_tokens: 16384,
2918
3849
  messages: currentMessages,
2919
- system: VISION_SYSTEM_PROMPT,
3850
+ system: visionSystemPrompt,
2920
3851
  });
2921
3852
 
2922
3853
  // Extract text content from response