sonance-brand-mcp 1.3.110 → 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 (84) hide show
  1. package/dist/assets/api/sonance-ai-edit/route.ts +30 -7
  2. package/dist/assets/api/sonance-save-image/route.ts +625 -0
  3. package/dist/assets/api/sonance-vision-apply/image-styling-detection.ts +1360 -0
  4. package/dist/assets/api/sonance-vision-apply/route.ts +1020 -64
  5. package/dist/assets/api/sonance-vision-apply/styling-detection.ts +730 -0
  6. package/dist/assets/api/sonance-vision-apply/theme-discovery.ts +1 -1
  7. package/dist/assets/api/sonance-vision-edit/route.ts +33 -8
  8. package/dist/assets/brand-system.ts +13 -12
  9. package/dist/assets/components/accordion.tsx +15 -7
  10. package/dist/assets/components/alert-dialog.tsx +35 -10
  11. package/dist/assets/components/alert.tsx +11 -10
  12. package/dist/assets/components/avatar.tsx +4 -4
  13. package/dist/assets/components/badge.tsx +16 -12
  14. package/dist/assets/components/button.stories.tsx +3 -3
  15. package/dist/assets/components/button.tsx +50 -31
  16. package/dist/assets/components/calendar.tsx +12 -8
  17. package/dist/assets/components/card.tsx +35 -29
  18. package/dist/assets/components/checkbox.tsx +9 -8
  19. package/dist/assets/components/code.tsx +19 -11
  20. package/dist/assets/components/command.tsx +32 -13
  21. package/dist/assets/components/context-menu.tsx +37 -16
  22. package/dist/assets/components/dialog.tsx +8 -5
  23. package/dist/assets/components/divider.tsx +15 -5
  24. package/dist/assets/components/drawer.tsx +4 -3
  25. package/dist/assets/components/dropdown-menu.tsx +15 -13
  26. package/dist/assets/components/hover-card.tsx +4 -1
  27. package/dist/assets/components/image.tsx +1 -1
  28. package/dist/assets/components/input.tsx +29 -14
  29. package/dist/assets/components/kbd.stories.tsx +3 -3
  30. package/dist/assets/components/kbd.tsx +29 -13
  31. package/dist/assets/components/listbox.tsx +8 -8
  32. package/dist/assets/components/menubar.tsx +50 -23
  33. package/dist/assets/components/navbar.stories.tsx +140 -13
  34. package/dist/assets/components/navbar.tsx +22 -5
  35. package/dist/assets/components/navigation-menu.tsx +28 -6
  36. package/dist/assets/components/pagination.tsx +10 -10
  37. package/dist/assets/components/popover.tsx +10 -8
  38. package/dist/assets/components/progress.tsx +6 -4
  39. package/dist/assets/components/radio-group.tsx +5 -5
  40. package/dist/assets/components/select.tsx +49 -29
  41. package/dist/assets/components/separator.tsx +3 -3
  42. package/dist/assets/components/sheet.tsx +4 -4
  43. package/dist/assets/components/sidebar.tsx +10 -10
  44. package/dist/assets/components/skeleton.tsx +13 -5
  45. package/dist/assets/components/slider.tsx +12 -10
  46. package/dist/assets/components/switch.tsx +4 -4
  47. package/dist/assets/components/table.tsx +5 -5
  48. package/dist/assets/components/tabs.tsx +8 -8
  49. package/dist/assets/components/textarea.tsx +11 -9
  50. package/dist/assets/components/toast.tsx +7 -7
  51. package/dist/assets/components/toggle.tsx +27 -7
  52. package/dist/assets/components/tooltip.tsx +10 -8
  53. package/dist/assets/components/user.tsx +8 -6
  54. package/dist/assets/dev-tools/SonanceDevTools.tsx +851 -708
  55. package/dist/assets/dev-tools/components/ApplyFirstPreview.tsx +10 -10
  56. package/dist/assets/dev-tools/components/ChatHistory.tsx +145 -0
  57. package/dist/assets/dev-tools/components/ChatInterface.tsx +444 -295
  58. package/dist/assets/dev-tools/components/ChatTabBar.tsx +82 -0
  59. package/dist/assets/dev-tools/components/DiffPreview.tsx +1 -1
  60. package/dist/assets/dev-tools/components/InlineDiffPreview.tsx +528 -0
  61. package/dist/assets/dev-tools/components/InspectorOverlay.tsx +21 -18
  62. package/dist/assets/dev-tools/components/PropertiesPanel.tsx +1345 -0
  63. package/dist/assets/dev-tools/components/ScreenshotAnnotator.tsx +1 -1
  64. package/dist/assets/dev-tools/components/SectionHighlight.tsx +1 -1
  65. package/dist/assets/dev-tools/components/VisionDiffPreview.tsx +7 -7
  66. package/dist/assets/dev-tools/components/VisionModeBorder.tsx +12 -63
  67. package/dist/assets/dev-tools/constants.ts +38 -6
  68. package/dist/assets/dev-tools/hooks/index.ts +69 -0
  69. package/dist/assets/dev-tools/hooks/useComponentDetection.ts +132 -0
  70. package/dist/assets/dev-tools/hooks/useComputedStyles.ts +471 -0
  71. package/dist/assets/dev-tools/hooks/useContentHash.ts +212 -0
  72. package/dist/assets/dev-tools/hooks/useElementScanner.ts +398 -0
  73. package/dist/assets/dev-tools/hooks/useImageDetection.ts +162 -0
  74. package/dist/assets/dev-tools/hooks/useTextDetection.ts +217 -0
  75. package/dist/assets/dev-tools/index.ts +3 -0
  76. package/dist/assets/dev-tools/panels/AnalysisPanel.tsx +32 -32
  77. package/dist/assets/dev-tools/panels/ComponentsPanel.tsx +384 -131
  78. package/dist/assets/dev-tools/panels/TextPanel.tsx +10 -10
  79. package/dist/assets/dev-tools/types.ts +93 -2
  80. package/dist/assets/globals.css +225 -9
  81. package/dist/assets/styles/brand-overrides.css +3 -2
  82. package/dist/assets/utils.ts +2 -1
  83. package/dist/index.js +22 -3
  84. package/package.json +2 -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 {
@@ -64,6 +67,11 @@ interface VisionFileModification {
64
67
  explanation: string;
65
68
  }
66
69
 
70
+ interface ChatHistoryMessage {
71
+ role: string;
72
+ content: string;
73
+ }
74
+
67
75
  interface ApplyFirstRequest {
68
76
  action: "apply" | "accept" | "revert" | "preview";
69
77
  sessionId?: string;
@@ -73,6 +81,29 @@ interface ApplyFirstRequest {
73
81
  focusedElements?: VisionFocusedElement[];
74
82
  // For applying a previously previewed set of modifications
75
83
  previewedModifications?: VisionFileModification[];
84
+ /** Conversation history for multi-turn context */
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
+ };
76
107
  }
77
108
 
78
109
  interface BackupManifest {
@@ -144,9 +175,10 @@ function debugLog(message: string, data?: unknown) {
144
175
  /**
145
176
  * Sanitize a JSON string by finding the correct end point using bracket balancing.
146
177
  * Handles cases where LLM outputs trailing garbage like extra ]} characters.
178
+ * Also handles leading conversational text before the JSON payload.
147
179
  */
148
180
  function sanitizeJsonString(text: string): string {
149
- const trimmed = text.trim();
181
+ let trimmed = text.trim();
150
182
 
151
183
  debugLog("[sanitizeJsonString] Starting", {
152
184
  inputLength: trimmed.length,
@@ -154,6 +186,21 @@ function sanitizeJsonString(text: string): string {
154
186
  last100: trimmed.substring(trimmed.length - 100)
155
187
  });
156
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
+
157
204
  // Try parsing as-is first
158
205
  try {
159
206
  JSON.parse(trimmed);
@@ -509,26 +556,31 @@ function findElementLineInFile(
509
556
 
510
557
  // PRIORITY 2c: Word-based matching for text with special characters (bullets, etc.)
511
558
  // 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'];
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'];
514
562
  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) {
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) {
519
571
  for (let i = 0; i < lines.length; i++) {
520
572
  const lineLower = lines[i].toLowerCase();
521
- const matchCount = significantWords.filter(word =>
573
+ const matchCount = significantWords.filter(word =>
522
574
  lineLower.includes(word.toLowerCase())
523
575
  ).length;
524
-
525
- // Require at least 2 word matches for confidence
526
- if (matchCount >= 2) {
576
+
577
+ // Flexible matching: longer text can match with fewer words
578
+ if (matchCount >= minMatches) {
527
579
  return {
528
580
  lineNumber: i + 1,
529
581
  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)`
582
+ confidence: matchCount >= 3 ? 'medium' : 'low',
583
+ matchedBy: `word match: ${significantWords.slice(0, 3).join(', ')}... (${matchCount}/${significantWords.length} words)`
532
584
  };
533
585
  }
534
586
  }
@@ -554,6 +606,70 @@ function findElementLineInFile(
554
606
  }
555
607
  }
556
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
+
557
673
  // PRIORITY 3: Distinctive className patterns (semantic classes from design system)
558
674
  if (focusedElement.className) {
559
675
  // SEMANTIC CLASS DETECTION: Instead of filtering OUT utilities, filter IN semantics
@@ -669,10 +785,224 @@ function findElementLineInFile(
669
785
  }
670
786
  }
671
787
  }
672
-
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
+
800
+ return null;
801
+ }
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
+
673
901
  return null;
674
902
  }
675
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
+
676
1006
  /**
677
1007
  * Search imported component files for specific TEXT CONTENT
678
1008
  * This is used to redirect from parent components to child components
@@ -814,15 +1144,29 @@ function findElementInImportedFiles(
814
1144
  file.path.endsWith('.tsx') ||
815
1145
  file.path.endsWith('.jsx');
816
1146
 
817
- // Skip non-component files but allow page files
818
- 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;
819
1159
 
820
- // Skip known non-UI files
821
- if (file.path.includes('/types') ||
1160
+ // Skip known non-UI files (but allow config files for logos)
1161
+ const isSkippable = file.path.includes('/types') ||
822
1162
  file.path.includes('/hooks/') ||
823
- file.path.includes('/utils/') ||
824
- file.path.includes('/lib/') ||
825
- 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;
826
1170
 
827
1171
  const result = findElementLineInFile(file.content, focusedElement);
828
1172
  // Accept ALL matches including low confidence - let the scoring decide
@@ -1715,7 +2059,8 @@ function searchFilesSmart(
1715
2059
  return sortedResults.map(r => ({ path: r.path, content: r.content, score: r.score, filenameMatch: r.filenameMatch }));
1716
2060
  }
1717
2061
 
1718
- 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.
1719
2064
 
1720
2065
  CRITICAL: Return ONLY the JSON object. No explanations, no preamble, no markdown code fences.
1721
2066
  Start your response with { and end with }
@@ -1725,6 +2070,54 @@ Output format:
1725
2070
 
1726
2071
  The "search" field must match the file EXACTLY (copy-paste from the code provided).`;
1727
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
+
1728
2121
  /**
1729
2122
  * PHASE 2: Targeted Patch Generation Prompt
1730
2123
  *
@@ -1824,6 +2217,129 @@ DESIGN CONSTRAINTS
1824
2217
  ═══════════════════════════════════════════════════════════════════════════════
1825
2218
  `;
1826
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
+
1827
2343
  /**
1828
2344
  * Validate patches against identified problems
1829
2345
  * Rejects patches that don't reference a valid problem ID
@@ -1929,7 +2445,7 @@ export async function POST(request: Request) {
1929
2445
 
1930
2446
  try {
1931
2447
  const body: ApplyFirstRequest = await request.json();
1932
- const { action, sessionId, screenshot, pageRoute, userPrompt, focusedElements, previewedModifications } = body;
2448
+ const { action, sessionId, screenshot, pageRoute, userPrompt, focusedElements, previewedModifications, chatHistory, currentTheme, propertyEdits } = body;
1933
2449
  const projectRoot = process.cwd();
1934
2450
 
1935
2451
  // ========== ACCEPT ACTION ==========
@@ -2030,6 +2546,9 @@ export async function POST(request: Request) {
2030
2546
  // Generate a unique session ID
2031
2547
  const newSessionId = randomUUID().slice(0, 8);
2032
2548
 
2549
+ // Build dynamic system prompt based on detected styling system
2550
+ const visionSystemPrompt = await buildVisionSystemPrompt(projectRoot);
2551
+
2033
2552
  // Initialize file discovery variables
2034
2553
  let smartSearchFiles: { path: string; content: string }[] = [];
2035
2554
  let recommendedFile: { path: string; reason: string } | null = null;
@@ -2449,7 +2968,7 @@ export async function POST(request: Request) {
2449
2968
  const importedMatch = findElementInImportedFiles(
2450
2969
  focusedElements[0],
2451
2970
  pageContext.componentSources,
2452
- actualTargetFile?.path // Pass page file for proximity scoring
2971
+ actualTargetFile?.path ?? undefined // Pass page file for proximity scoring
2453
2972
  );
2454
2973
 
2455
2974
  if (importedMatch) {
@@ -2512,17 +3031,71 @@ export async function POST(request: Request) {
2512
3031
  }
2513
3032
 
2514
3033
  // ========== SMART SEARCH FALLBACK ==========
2515
- // If we still don't have a good target file (just the page wrapper),
2516
- // use the smart search top result - it already found relevant files!
2517
- 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) {
2518
3042
  const smartSearchFullPath = path.join(projectRoot, smartSearchTopPath);
2519
3043
  if (fs.existsSync(smartSearchFullPath)) {
2520
3044
  const smartSearchContent = fs.readFileSync(smartSearchFullPath, 'utf-8');
3045
+ const previousConfidence = elementLocation?.confidence || 'none';
2521
3046
  actualTargetFile = { path: smartSearchTopPath, content: smartSearchContent };
2522
- 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", {
2523
3050
  originalFile: pageContext.pageFile,
2524
3051
  redirectTo: smartSearchTopPath,
2525
- 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'
2526
3099
  });
2527
3100
  }
2528
3101
  }
@@ -2530,7 +3103,8 @@ export async function POST(request: Request) {
2530
3103
  debugLog("File redirect complete", {
2531
3104
  originalRecommended: recommendedFileContent?.path || 'none',
2532
3105
  actualTarget: actualTargetFile?.path || 'none',
2533
- wasRedirected: actualTargetFile?.path !== recommendedFileContent?.path
3106
+ wasRedirected: actualTargetFile?.path !== recommendedFileContent?.path,
3107
+ intent: intentAnalysis.intent
2534
3108
  });
2535
3109
 
2536
3110
  // Build text content
@@ -2538,6 +3112,8 @@ export async function POST(request: Request) {
2538
3112
 
2539
3113
  Page Route: ${pageRoute}
2540
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}` : ''}
2541
3117
 
2542
3118
  `;
2543
3119
 
@@ -2590,7 +3166,18 @@ User Request: "${userPrompt}"
2590
3166
  for (const el of focusedElements) {
2591
3167
  textContent += `- ${el.name} (${el.type})`;
2592
3168
  if (el.textContent) {
2593
- 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}"`;
2594
3181
  }
2595
3182
  textContent += `\n`;
2596
3183
 
@@ -2643,28 +3230,163 @@ ${elementLocation.snippet}
2643
3230
  ⚠️ IMPORTANT: Modify ONLY the element at line ${elementLocation.lineNumber}, NOT other similar elements in the file.
2644
3231
 
2645
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
+ }
2646
3296
  } else {
2647
- // Element NOT found in main file OR any imported components
2648
- // BLOCK the LLM from guessing - require empty modifications
2649
- 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...", {
2650
3299
  mainFile: actualTargetFile.path,
2651
- searchedImports: pageContext.componentSources.length,
2652
- focusedElements: focusedElements.map(el => ({
2653
- name: el.name,
2654
- type: el.type,
2655
- textContent: el.textContent?.substring(0, 30),
2656
- className: el.className?.substring(0, 50),
2657
- elementId: el.elementId,
2658
- }))
3300
+ focusedElement: focusedElements[0]?.textContent?.substring(0, 50)
2659
3301
  });
2660
-
2661
- // STRONG BLOCK instruction - tell LLM to NOT guess
2662
- 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 += `
2663
3384
  ⛔ STOP: CANNOT LOCATE THE CLICKED ELEMENT
2664
3385
 
2665
3386
  The user clicked on a specific element, but it could NOT be found in:
2666
3387
  - ${actualTargetFile.path} (main target file)
2667
3388
  - Any of the ${pageContext.componentSources.length} imported component files
3389
+ - AI-assisted search also failed to locate the element
2668
3390
 
2669
3391
  The element may be:
2670
3392
  - Deeply nested in a component not in the import tree
@@ -2678,6 +3400,7 @@ DO NOT GUESS. Return this exact response:
2678
3400
  }
2679
3401
 
2680
3402
  `;
3403
+ }
2681
3404
  }
2682
3405
  }
2683
3406
 
@@ -2726,32 +3449,45 @@ ${linesWithNumbers}
2726
3449
  usedContext += content.length;
2727
3450
  }
2728
3451
 
2729
- // ========== KEY UI COMPONENTS ==========
3452
+ // ========== KEY UI COMPONENTS (CVA) ==========
2730
3453
  // Include UI component definitions (Button, Card, etc.) so LLM knows available variants/props
2731
- 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
+ ];
2732
3463
  const includedUIComponents: string[] = [];
2733
3464
 
2734
3465
  for (const comp of pageContext.componentSources) {
2735
3466
  // Check if this is a UI component we should include
2736
3467
  const isUIComponent = uiComponentPaths.some(uiPath => comp.path.includes(uiPath));
2737
3468
  if (isUIComponent && usedContext + comp.content.length < TOTAL_CONTEXT_BUDGET) {
2738
- // Only include the variants/props section, not the whole file
2739
- const variantsMatch = comp.content.match(/variants:\s*\{[\s\S]*?\n\s*\},\n\s*defaultVariants/);
2740
- if (variantsMatch) {
2741
- textContent += `
2742
- --- UI Component: ${comp.path} (variants only) ---
2743
- ${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
2744
3480
  ---
2745
3481
 
2746
3482
  `;
2747
3483
  includedUIComponents.push(comp.path);
2748
- usedContext += variantsMatch[0].length + 100;
3484
+ usedContext += cvaMatch[0].length + 200;
2749
3485
  }
2750
3486
  }
2751
3487
  }
2752
3488
 
2753
3489
  if (includedUIComponents.length > 0) {
2754
- debugLog("Included UI component definitions", { components: includedUIComponents });
3490
+ debugLog("Included UI component CVA definitions", { components: includedUIComponents });
2755
3491
  }
2756
3492
 
2757
3493
  // ========== THEME DISCOVERY ==========
@@ -2804,7 +3540,209 @@ Your patch should be:
2804
3540
  }]
2805
3541
  }
2806
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 += `
2807
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
+ }
2808
3746
 
2809
3747
  messageContent.push({
2810
3748
  type: "text",
@@ -2828,7 +3766,7 @@ CRITICAL: Your "search" string MUST exist in the file. If you can't find the exa
2828
3766
  if (recommendedFileContent) {
2829
3767
  validFilePaths.add(recommendedFileContent.path);
2830
3768
  }
2831
- if (actualTargetFile && actualTargetFile.path !== recommendedFileContent?.path) {
3769
+ if (actualTargetFile && actualTargetFile.path && actualTargetFile.path !== recommendedFileContent?.path) {
2832
3770
  validFilePaths.add(actualTargetFile.path);
2833
3771
  }
2834
3772
 
@@ -2840,13 +3778,31 @@ CRITICAL: Your "search" string MUST exist in the file. If you can't find the exa
2840
3778
  let finalExplanation: string | undefined;
2841
3779
 
2842
3780
  while (retryCount <= MAX_RETRIES) {
2843
- // Build messages for this attempt
2844
- const currentMessages: Anthropic.MessageCreateParams["messages"] = [
2845
- {
2846
- role: "user",
2847
- content: messageContent,
2848
- },
2849
- ];
3781
+ // Build messages for this attempt, starting with chat history if available
3782
+ const currentMessages: Anthropic.MessageCreateParams["messages"] = [];
3783
+
3784
+ // Add conversation history for multi-turn context (e.g., "make it darker", "undo that")
3785
+ if (chatHistory && chatHistory.length > 0) {
3786
+ for (const msg of chatHistory) {
3787
+ // Only include user and assistant messages, skip system messages
3788
+ if (msg.role === "user" || msg.role === "assistant") {
3789
+ currentMessages.push({
3790
+ role: msg.role as "user" | "assistant",
3791
+ content: msg.content,
3792
+ });
3793
+ }
3794
+ }
3795
+ debugLog("Added chat history to context", {
3796
+ messageCount: chatHistory.length,
3797
+ preview: chatHistory.slice(-2).map(m => ({ role: m.role, content: m.content.substring(0, 50) }))
3798
+ });
3799
+ }
3800
+
3801
+ // Add current user message with screenshot
3802
+ currentMessages.push({
3803
+ role: "user",
3804
+ content: messageContent,
3805
+ });
2850
3806
 
2851
3807
  // If this is a retry, add feedback about what went wrong
2852
3808
  if (retryCount > 0 && lastPatchErrors.length > 0) {
@@ -2891,7 +3847,7 @@ This is better than generating patches with made-up code.`,
2891
3847
  model: "claude-sonnet-4-20250514",
2892
3848
  max_tokens: 16384,
2893
3849
  messages: currentMessages,
2894
- system: VISION_SYSTEM_PROMPT,
3850
+ system: visionSystemPrompt,
2895
3851
  });
2896
3852
 
2897
3853
  // Extract text content from response