sonance-brand-mcp 1.3.88 → 1.3.90

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.
@@ -229,6 +229,242 @@ function findElementIdInFile(
229
229
  return null;
230
230
  }
231
231
 
232
+ /**
233
+ * Find the line number of a focused element in the source code
234
+ * Uses multiple strategies in priority order:
235
+ * 1. DOM id (highest confidence)
236
+ * 2. Text content
237
+ * 3. ClassName patterns
238
+ *
239
+ * This extends the basic ID matching to handle elements without IDs.
240
+ */
241
+ function findElementLineInFile(
242
+ fileContent: string,
243
+ focusedElement: VisionFocusedElement
244
+ ): { lineNumber: number; snippet: string; confidence: 'high' | 'medium' | 'low'; matchedBy: string } | null {
245
+ if (!fileContent) return null;
246
+
247
+ const lines = fileContent.split('\n');
248
+
249
+ // PRIORITY 1: DOM id - highest confidence (exact match)
250
+ if (focusedElement.elementId) {
251
+ const idPattern = new RegExp(`id=["'\`]${focusedElement.elementId}["'\`]`);
252
+ for (let i = 0; i < lines.length; i++) {
253
+ if (idPattern.test(lines[i])) {
254
+ return {
255
+ lineNumber: i + 1,
256
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
257
+ confidence: 'high',
258
+ matchedBy: `DOM id="${focusedElement.elementId}"`
259
+ };
260
+ }
261
+ }
262
+ }
263
+
264
+ // Also try child IDs with high confidence
265
+ if (focusedElement.childIds && focusedElement.childIds.length > 0) {
266
+ for (const childId of focusedElement.childIds) {
267
+ const idPattern = new RegExp(`id=["'\`]${childId}["'\`]`);
268
+ for (let i = 0; i < lines.length; i++) {
269
+ if (idPattern.test(lines[i])) {
270
+ return {
271
+ lineNumber: i + 1,
272
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
273
+ confidence: 'high',
274
+ matchedBy: `child id="${childId}"`
275
+ };
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ // PRIORITY 2: Exact text content in JSX
282
+ if (focusedElement.textContent && focusedElement.textContent.trim().length >= 2) {
283
+ const text = focusedElement.textContent.trim();
284
+ // Escape special regex characters in the text
285
+ const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
286
+
287
+ for (let i = 0; i < lines.length; i++) {
288
+ const line = lines[i];
289
+ // Match patterns like: >Text<, >Text</tag, "Text", 'Text', {`Text`}
290
+ const textPatterns = [
291
+ `>${escapedText}<`, // JSX content: >Text<
292
+ `"${escapedText}"`, // String literal
293
+ `'${escapedText}'`, // String literal
294
+ `\`${escapedText}\``, // Template literal
295
+ ];
296
+
297
+ for (const pattern of textPatterns) {
298
+ if (line.includes(pattern.replace(/\\/g, ''))) {
299
+ return {
300
+ lineNumber: i + 1,
301
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
302
+ confidence: 'high',
303
+ matchedBy: `textContent="${text.substring(0, 30)}${text.length > 30 ? '...' : ''}"`
304
+ };
305
+ }
306
+ }
307
+ }
308
+
309
+ // Try partial match for longer text (first 15+ chars)
310
+ if (text.length > 15) {
311
+ const partialText = text.substring(0, 15);
312
+ for (let i = 0; i < lines.length; i++) {
313
+ if (lines[i].includes(`>${partialText}`) || lines[i].includes(`"${partialText}`)) {
314
+ return {
315
+ lineNumber: i + 1,
316
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
317
+ confidence: 'medium',
318
+ matchedBy: `partial textContent starting with "${partialText}..."`
319
+ };
320
+ }
321
+ }
322
+ }
323
+ }
324
+
325
+ // PRIORITY 3: Distinctive className patterns
326
+ if (focusedElement.className) {
327
+ // Extract distinctive class names (long, not hover/focus pseudo-classes)
328
+ const classes = focusedElement.className.split(/\s+/)
329
+ .filter(c => c.length > 8 && !c.startsWith('hover:') && !c.startsWith('focus:') && !c.startsWith('active:'));
330
+
331
+ for (const cls of classes) {
332
+ for (let i = 0; i < lines.length; i++) {
333
+ if (lines[i].includes(cls)) {
334
+ return {
335
+ lineNumber: i + 1,
336
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
337
+ confidence: 'medium',
338
+ matchedBy: `className contains "${cls}"`
339
+ };
340
+ }
341
+ }
342
+ }
343
+
344
+ // Try shorter but unique-looking class names (not utility classes)
345
+ const uniqueClasses = focusedElement.className.split(/\s+/)
346
+ .filter(c => c.length > 4 && !c.match(/^(p[xytblr]?-|m[xytblr]?-|w-|h-|bg-|text-|flex|grid|block|hidden)/));
347
+
348
+ for (const cls of uniqueClasses) {
349
+ for (let i = 0; i < lines.length; i++) {
350
+ if (lines[i].includes(cls)) {
351
+ return {
352
+ lineNumber: i + 1,
353
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
354
+ confidence: 'low',
355
+ matchedBy: `className contains "${cls}"`
356
+ };
357
+ }
358
+ }
359
+ }
360
+ }
361
+
362
+ return null;
363
+ }
364
+
365
+ /**
366
+ * PHASE 0: Deterministic Element ID Search (Cursor-style)
367
+ * Grep entire codebase for the element ID. If found in multiple files,
368
+ * use the current route to disambiguate (prioritize page file for route).
369
+ *
370
+ * This is the most reliable signal - like Cursor knowing which file you're in.
371
+ * Element IDs should be unique, so if we find one, that's THE file.
372
+ */
373
+ function findFilesByElementId(
374
+ projectRoot: string,
375
+ elementId: string,
376
+ currentRoute: string,
377
+ discoverPageFileFn: (route: string, projectRoot: string) => string | null
378
+ ): { path: string; lineNumber: number; isRouteMatch: boolean }[] {
379
+ const pattern = new RegExp(`id=["'\`]${elementId}["'\`]`);
380
+ const matches: { path: string; lineNumber: number; isRouteMatch: boolean }[] = [];
381
+
382
+ // Determine expected page file from route
383
+ const routePageFile = discoverPageFileFn(currentRoute, projectRoot);
384
+
385
+ // Search all common project directories that exist
386
+ // This supports: src/app, app/, pages/, components/, lib/ structures
387
+ const commonDirs = ['src', 'app', 'pages', 'components', 'lib'];
388
+ const searchDirs = commonDirs
389
+ .map(dir => path.join(projectRoot, dir))
390
+ .filter(dir => {
391
+ try {
392
+ return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
393
+ } catch {
394
+ return false;
395
+ }
396
+ });
397
+
398
+ // If no standard dirs found, search project root (excluding node_modules)
399
+ if (searchDirs.length === 0) {
400
+ searchDirs.push(projectRoot);
401
+ }
402
+
403
+ debugLog("PHASE 0: Searching for element ID", {
404
+ elementId,
405
+ currentRoute,
406
+ routePageFile,
407
+ searchDirs: searchDirs.map(d => d.replace(projectRoot + '/', ''))
408
+ });
409
+
410
+ function searchDirRecursive(dir: string): void {
411
+ try {
412
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
413
+ for (const entry of entries) {
414
+ const fullPath = path.join(dir, entry.name);
415
+
416
+ // Skip node_modules, hidden directories, and build outputs
417
+ if (entry.isDirectory()) {
418
+ if (
419
+ entry.name.includes('node_modules') ||
420
+ entry.name.startsWith('.') ||
421
+ entry.name === 'dist' ||
422
+ entry.name === 'build' ||
423
+ entry.name === '.next'
424
+ ) {
425
+ continue;
426
+ }
427
+ searchDirRecursive(fullPath);
428
+ } else if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name)) {
429
+ try {
430
+ const content = fs.readFileSync(fullPath, 'utf-8');
431
+ const lines = content.split('\n');
432
+ for (let i = 0; i < lines.length; i++) {
433
+ if (pattern.test(lines[i])) {
434
+ const relativePath = fullPath.replace(projectRoot + '/', '');
435
+ const isRouteMatch = relativePath === routePageFile;
436
+ matches.push({
437
+ path: relativePath,
438
+ lineNumber: i + 1,
439
+ isRouteMatch
440
+ });
441
+ debugLog("PHASE 0: Found ID match", {
442
+ file: relativePath,
443
+ line: i + 1,
444
+ isRouteMatch
445
+ });
446
+ break; // One match per file is enough
447
+ }
448
+ }
449
+ } catch {
450
+ // Skip files that can't be read
451
+ }
452
+ }
453
+ }
454
+ } catch {
455
+ // Skip directories that can't be read
456
+ }
457
+ }
458
+
459
+ // Search all directories
460
+ for (const searchDir of searchDirs) {
461
+ searchDirRecursive(searchDir);
462
+ }
463
+
464
+ // Sort: route matches first
465
+ return matches.sort((a, b) => (b.isRouteMatch ? 1 : 0) - (a.isRouteMatch ? 1 : 0));
466
+ }
467
+
232
468
  /**
233
469
  * Result of LLM screenshot analysis for smart file discovery
234
470
  */
@@ -927,12 +1163,73 @@ export async function POST(request: Request) {
927
1163
  // Generate a unique session ID
928
1164
  const newSessionId = randomUUID().slice(0, 8);
929
1165
 
930
- // PHASE 1+2: LLM-driven smart file discovery
931
- // The LLM analyzes the screenshot and deduces component names, code patterns, and visible text
1166
+ // Initialize file discovery variables
932
1167
  let smartSearchFiles: { path: string; content: string }[] = [];
933
1168
  let recommendedFile: { path: string; reason: string } | null = null;
934
-
935
- if (screenshot) {
1169
+ let deterministicMatch: { path: string; lineNumber: number } | null = null;
1170
+
1171
+ // ========================================================================
1172
+ // PHASE 0: Deterministic Element ID Search (Cursor-style explicit selection)
1173
+ // If we have an element ID, find it directly - no scoring, no heuristics
1174
+ // This is the most reliable signal, like Cursor knowing which file you're in
1175
+ // ========================================================================
1176
+ if (focusedElements?.some(el => el.elementId)) {
1177
+ const elementWithId = focusedElements.find(el => el.elementId)!;
1178
+ debugLog("PHASE 0: Element has ID, starting deterministic search", {
1179
+ elementId: elementWithId.elementId,
1180
+ route: pageRoute
1181
+ });
1182
+
1183
+ const matches = findFilesByElementId(
1184
+ projectRoot,
1185
+ elementWithId.elementId!,
1186
+ pageRoute || "/",
1187
+ discoverPageFile
1188
+ );
1189
+
1190
+ if (matches.length === 1) {
1191
+ // Single match - 100% confidence
1192
+ deterministicMatch = matches[0];
1193
+ recommendedFile = {
1194
+ path: matches[0].path,
1195
+ reason: `Deterministic ID match: id="${elementWithId.elementId}" (unique in codebase)`
1196
+ };
1197
+ debugLog("PHASE 0 SUCCESS: Single ID match - skipping smart search", deterministicMatch);
1198
+ } else if (matches.length > 1) {
1199
+ // Multiple matches - use route to disambiguate
1200
+ const routeMatch = matches.find(m => m.isRouteMatch);
1201
+ if (routeMatch) {
1202
+ deterministicMatch = routeMatch;
1203
+ recommendedFile = {
1204
+ path: routeMatch.path,
1205
+ reason: `Deterministic ID match: id="${elementWithId.elementId}" (route "${pageRoute}" disambiguated from ${matches.length} files)`
1206
+ };
1207
+ debugLog("PHASE 0 SUCCESS: ID found in multiple files, using route match", {
1208
+ match: deterministicMatch,
1209
+ otherFiles: matches.filter(m => !m.isRouteMatch).map(m => m.path)
1210
+ });
1211
+ } else {
1212
+ debugLog("PHASE 0 FALLBACK: ID found in multiple files, no route match", {
1213
+ elementId: elementWithId.elementId,
1214
+ matchCount: matches.length,
1215
+ files: matches.map(m => m.path),
1216
+ route: pageRoute
1217
+ });
1218
+ // Fall through to smart search
1219
+ }
1220
+ } else {
1221
+ debugLog("PHASE 0 FALLBACK: Element ID not found in codebase", {
1222
+ elementId: elementWithId.elementId
1223
+ });
1224
+ // Fall through to smart search
1225
+ }
1226
+ }
1227
+
1228
+ // ========================================================================
1229
+ // PHASE 1+2: LLM-driven smart file discovery (only if Phase 0 didn't match)
1230
+ // The LLM analyzes the screenshot and deduces component names, code patterns, and visible text
1231
+ // ========================================================================
1232
+ if (!deterministicMatch && screenshot) {
936
1233
  debugLog("Starting Phase 1: LLM screenshot analysis");
937
1234
  const analysis = await analyzeScreenshotForSearch(screenshot, userPrompt, apiKey);
938
1235
 
@@ -1024,8 +1321,12 @@ export async function POST(request: Request) {
1024
1321
  return bestMatch;
1025
1322
  };
1026
1323
 
1324
+ // ====================================================================
1325
+ // FALLBACK PRIORITY LOGIC (only reached when Phase 0 didn't find a match)
1326
+ // This handles cases: no element ID, dynamic IDs, or ambiguous ID matches
1327
+ // ====================================================================
1027
1328
  if (phase2aConfirmed) {
1028
- // PRIORITY 1: Phase 2a match confirmed by element search - highest confidence
1329
+ // FALLBACK PRIORITY 1: Phase 2a match confirmed by element search
1029
1330
  const confirmedPath = phase2aMatches.find(p =>
1030
1331
  focusedElementHints.some(h => h.path === p && h.score > 0)
1031
1332
  );
@@ -1033,33 +1334,47 @@ export async function POST(request: Request) {
1033
1334
  path: confirmedPath!,
1034
1335
  reason: `Phase 2a component-name match confirmed by element search`
1035
1336
  };
1036
- debugLog("PRIORITY 1: Phase 2a confirmed by element search", recommendedFile);
1337
+ debugLog("FALLBACK PRIORITY 1: Phase 2a confirmed by element search", recommendedFile);
1037
1338
  } else if (focusedTopScore > smartSearchTopScore * 0.5 && focusedTopScore >= 400) {
1038
- // PRIORITY 2: Focused element has strong absolute score AND relative to smart search
1339
+ // FALLBACK PRIORITY 2: Focused element has strong score
1039
1340
  recommendedFile = {
1040
1341
  path: focusedTopPath!,
1041
1342
  reason: `Focused element match (score: ${focusedTopScore}, exceeds threshold)`
1042
1343
  };
1043
- debugLog("PRIORITY 2: Strong focused element match", recommendedFile);
1344
+ debugLog("FALLBACK PRIORITY 2: Strong focused element match", recommendedFile);
1044
1345
  } else if (phase2aMatches.length > 0) {
1045
- // PRIORITY 2.5: Phase 2a match WITHOUT focused element - still strong signal
1346
+ // FALLBACK PRIORITY 3: Phase 2a match WITHOUT focused element
1046
1347
  // Use prompt-aware selection to pick the best match
1047
1348
  const bestMatch = findBestPhase2aMatch()!;
1048
1349
  recommendedFile = {
1049
1350
  path: bestMatch,
1050
1351
  reason: `Phase 2a component-name match (prompt-aware selection from ${phase2aMatches.length} candidates)`
1051
1352
  };
1052
- debugLog("PRIORITY 2.5: Phase 2a match (no focused element)", {
1353
+ debugLog("FALLBACK PRIORITY 3: Phase 2a match (no focused element)", {
1053
1354
  selectedPath: bestMatch,
1054
1355
  allCandidates: phase2aMatches
1055
1356
  });
1056
- } else if (smartSearchTopPath) {
1057
- // PRIORITY 3: Smart search top result - trusted baseline
1058
- recommendedFile = {
1059
- path: smartSearchTopPath,
1060
- reason: `Smart search top result (score: ${smartSearchTopScore})`
1061
- };
1062
- debugLog("PRIORITY 3: Using smart search top result", recommendedFile);
1357
+ } else {
1358
+ // FALLBACK PRIORITY 4: Use the page file from the current route
1359
+ const routePageFile = discoverPageFile(pageRoute || "/", projectRoot);
1360
+
1361
+ if (routePageFile && fs.existsSync(path.join(projectRoot, routePageFile))) {
1362
+ recommendedFile = {
1363
+ path: routePageFile,
1364
+ reason: `Page file from route "${pageRoute || "/"}"`
1365
+ };
1366
+ debugLog("FALLBACK PRIORITY 4: Using page file from route", {
1367
+ route: pageRoute || "/",
1368
+ pageFile: routePageFile
1369
+ });
1370
+ } else if (smartSearchTopPath) {
1371
+ // FALLBACK PRIORITY 5: Smart search top result - last resort
1372
+ recommendedFile = {
1373
+ path: smartSearchTopPath,
1374
+ reason: `Smart search top result (score: ${smartSearchTopScore})`
1375
+ };
1376
+ debugLog("FALLBACK PRIORITY 5: Using smart search top result", recommendedFile);
1377
+ }
1063
1378
  }
1064
1379
 
1065
1380
  debugLog("Phase 1+2 complete", {
@@ -1148,12 +1463,21 @@ User Request: "${userPrompt}"
1148
1463
  if (recommendedFileContent) {
1149
1464
  const content = recommendedFileContent.content;
1150
1465
 
1151
- // Search for element IDs in the file to enable precise targeting
1152
- let idMatch: { lineNumber: number; matchedId: string; snippet: string } | null = null;
1153
- if (focusedElements && focusedElements.length > 0) {
1466
+ // Search for focused element in the file using multiple strategies
1467
+ // Priority: DOM id > textContent > className patterns
1468
+ let elementLocation: { lineNumber: number; snippet: string; confidence: 'high' | 'medium' | 'low'; matchedBy: string } | null = null;
1469
+ if (focusedElements && focusedElements.length > 0) {
1154
1470
  for (const el of focusedElements) {
1155
- idMatch = findElementIdInFile(content, el.elementId, el.childIds);
1156
- if (idMatch) break;
1471
+ elementLocation = findElementLineInFile(content, el);
1472
+ if (elementLocation) {
1473
+ debugLog("Found focused element in file", {
1474
+ matchedBy: elementLocation.matchedBy,
1475
+ lineNumber: elementLocation.lineNumber,
1476
+ confidence: elementLocation.confidence,
1477
+ file: recommendedFileContent.path,
1478
+ });
1479
+ break;
1480
+ }
1157
1481
  }
1158
1482
  }
1159
1483
 
@@ -1168,22 +1492,36 @@ User Request: "${userPrompt}"
1168
1492
  textContent += `\n`;
1169
1493
  }
1170
1494
 
1171
- // Add precise targeting if we found an ID match
1172
- if (idMatch) {
1495
+ // Add precise targeting with line number and snippet
1496
+ if (elementLocation) {
1173
1497
  textContent += `
1174
- PRECISE TARGET (found by element ID):
1175
- ID: "${idMatch.matchedId}"
1176
- → Line: ${idMatch.lineNumber}
1177
- Look for this ID in the code and modify the element that contains it.
1498
+ ══════════════════════════════════════════════════════════════════════════════
1499
+ PRECISE TARGET LOCATION (${elementLocation.confidence} confidence)
1500
+ ══════════════════════════════════════════════════════════════════════════════
1501
+ Matched by: ${elementLocation.matchedBy}
1502
+ → Line: ${elementLocation.lineNumber}
1503
+
1504
+ THE USER CLICKED ON THE ELEMENT AT LINE ${elementLocation.lineNumber}.
1505
+ Here is the exact code around that element:
1506
+ \`\`\`
1507
+ ${elementLocation.snippet}
1508
+ \`\`\`
1509
+
1510
+ ⚠️ IMPORTANT: Modify ONLY the element at line ${elementLocation.lineNumber}, NOT other similar elements in the file.
1178
1511
 
1179
1512
  `;
1180
- debugLog("Found element ID in file", {
1181
- matchedId: idMatch.matchedId,
1182
- lineNumber: idMatch.lineNumber,
1183
- file: recommendedFileContent.path,
1184
- });
1185
1513
  } else {
1186
1514
  textContent += `\n`;
1515
+ debugLog("Could not locate focused element in file - no ID, textContent, or className match", {
1516
+ file: recommendedFileContent.path,
1517
+ focusedElements: focusedElements.map(el => ({
1518
+ name: el.name,
1519
+ type: el.type,
1520
+ textContent: el.textContent?.substring(0, 30),
1521
+ className: el.className?.substring(0, 50),
1522
+ elementId: el.elementId,
1523
+ }))
1524
+ });
1187
1525
  }
1188
1526
  }
1189
1527
 
@@ -225,6 +225,242 @@ function findElementIdInFile(
225
225
  return null;
226
226
  }
227
227
 
228
+ /**
229
+ * Find the line number of a focused element in the source code
230
+ * Uses multiple strategies in priority order:
231
+ * 1. DOM id (highest confidence)
232
+ * 2. Text content
233
+ * 3. ClassName patterns
234
+ *
235
+ * This extends the basic ID matching to handle elements without IDs.
236
+ */
237
+ function findElementLineInFile(
238
+ fileContent: string,
239
+ focusedElement: VisionFocusedElement
240
+ ): { lineNumber: number; snippet: string; confidence: 'high' | 'medium' | 'low'; matchedBy: string } | null {
241
+ if (!fileContent) return null;
242
+
243
+ const lines = fileContent.split('\n');
244
+
245
+ // PRIORITY 1: DOM id - highest confidence (exact match)
246
+ if (focusedElement.elementId) {
247
+ const idPattern = new RegExp(`id=["'\`]${focusedElement.elementId}["'\`]`);
248
+ for (let i = 0; i < lines.length; i++) {
249
+ if (idPattern.test(lines[i])) {
250
+ return {
251
+ lineNumber: i + 1,
252
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
253
+ confidence: 'high',
254
+ matchedBy: `DOM id="${focusedElement.elementId}"`
255
+ };
256
+ }
257
+ }
258
+ }
259
+
260
+ // Also try child IDs with high confidence
261
+ if (focusedElement.childIds && focusedElement.childIds.length > 0) {
262
+ for (const childId of focusedElement.childIds) {
263
+ const idPattern = new RegExp(`id=["'\`]${childId}["'\`]`);
264
+ for (let i = 0; i < lines.length; i++) {
265
+ if (idPattern.test(lines[i])) {
266
+ return {
267
+ lineNumber: i + 1,
268
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
269
+ confidence: 'high',
270
+ matchedBy: `child id="${childId}"`
271
+ };
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ // PRIORITY 2: Exact text content in JSX
278
+ if (focusedElement.textContent && focusedElement.textContent.trim().length >= 2) {
279
+ const text = focusedElement.textContent.trim();
280
+ // Escape special regex characters in the text
281
+ const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
282
+
283
+ for (let i = 0; i < lines.length; i++) {
284
+ const line = lines[i];
285
+ // Match patterns like: >Text<, >Text</tag, "Text", 'Text', {`Text`}
286
+ const textPatterns = [
287
+ `>${escapedText}<`, // JSX content: >Text<
288
+ `"${escapedText}"`, // String literal
289
+ `'${escapedText}'`, // String literal
290
+ `\`${escapedText}\``, // Template literal
291
+ ];
292
+
293
+ for (const pattern of textPatterns) {
294
+ if (line.includes(pattern.replace(/\\/g, ''))) {
295
+ return {
296
+ lineNumber: i + 1,
297
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
298
+ confidence: 'high',
299
+ matchedBy: `textContent="${text.substring(0, 30)}${text.length > 30 ? '...' : ''}"`
300
+ };
301
+ }
302
+ }
303
+ }
304
+
305
+ // Try partial match for longer text (first 15+ chars)
306
+ if (text.length > 15) {
307
+ const partialText = text.substring(0, 15);
308
+ for (let i = 0; i < lines.length; i++) {
309
+ if (lines[i].includes(`>${partialText}`) || lines[i].includes(`"${partialText}`)) {
310
+ return {
311
+ lineNumber: i + 1,
312
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
313
+ confidence: 'medium',
314
+ matchedBy: `partial textContent starting with "${partialText}..."`
315
+ };
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ // PRIORITY 3: Distinctive className patterns
322
+ if (focusedElement.className) {
323
+ // Extract distinctive class names (long, not hover/focus pseudo-classes)
324
+ const classes = focusedElement.className.split(/\s+/)
325
+ .filter(c => c.length > 8 && !c.startsWith('hover:') && !c.startsWith('focus:') && !c.startsWith('active:'));
326
+
327
+ for (const cls of classes) {
328
+ for (let i = 0; i < lines.length; i++) {
329
+ if (lines[i].includes(cls)) {
330
+ return {
331
+ lineNumber: i + 1,
332
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
333
+ confidence: 'medium',
334
+ matchedBy: `className contains "${cls}"`
335
+ };
336
+ }
337
+ }
338
+ }
339
+
340
+ // Try shorter but unique-looking class names (not utility classes)
341
+ const uniqueClasses = focusedElement.className.split(/\s+/)
342
+ .filter(c => c.length > 4 && !c.match(/^(p[xytblr]?-|m[xytblr]?-|w-|h-|bg-|text-|flex|grid|block|hidden)/));
343
+
344
+ for (const cls of uniqueClasses) {
345
+ for (let i = 0; i < lines.length; i++) {
346
+ if (lines[i].includes(cls)) {
347
+ return {
348
+ lineNumber: i + 1,
349
+ snippet: lines.slice(Math.max(0, i - 3), i + 5).join('\n'),
350
+ confidence: 'low',
351
+ matchedBy: `className contains "${cls}"`
352
+ };
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ return null;
359
+ }
360
+
361
+ /**
362
+ * PHASE 0: Deterministic Element ID Search (Cursor-style)
363
+ * Grep entire codebase for the element ID. If found in multiple files,
364
+ * use the current route to disambiguate (prioritize page file for route).
365
+ *
366
+ * This is the most reliable signal - like Cursor knowing which file you're in.
367
+ * Element IDs should be unique, so if we find one, that's THE file.
368
+ */
369
+ function findFilesByElementId(
370
+ projectRoot: string,
371
+ elementId: string,
372
+ currentRoute: string,
373
+ discoverPageFileFn: (route: string, projectRoot: string) => string | null
374
+ ): { path: string; lineNumber: number; isRouteMatch: boolean }[] {
375
+ const pattern = new RegExp(`id=["'\`]${elementId}["'\`]`);
376
+ const matches: { path: string; lineNumber: number; isRouteMatch: boolean }[] = [];
377
+
378
+ // Determine expected page file from route
379
+ const routePageFile = discoverPageFileFn(currentRoute, projectRoot);
380
+
381
+ // Search all common project directories that exist
382
+ // This supports: src/app, app/, pages/, components/, lib/ structures
383
+ const commonDirs = ['src', 'app', 'pages', 'components', 'lib'];
384
+ const searchDirs = commonDirs
385
+ .map(dir => path.join(projectRoot, dir))
386
+ .filter(dir => {
387
+ try {
388
+ return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
389
+ } catch {
390
+ return false;
391
+ }
392
+ });
393
+
394
+ // If no standard dirs found, search project root (excluding node_modules)
395
+ if (searchDirs.length === 0) {
396
+ searchDirs.push(projectRoot);
397
+ }
398
+
399
+ debugLog("PHASE 0: Searching for element ID", {
400
+ elementId,
401
+ currentRoute,
402
+ routePageFile,
403
+ searchDirs: searchDirs.map(d => d.replace(projectRoot + '/', ''))
404
+ });
405
+
406
+ function searchDirRecursive(dir: string): void {
407
+ try {
408
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
409
+ for (const entry of entries) {
410
+ const fullPath = path.join(dir, entry.name);
411
+
412
+ // Skip node_modules, hidden directories, and build outputs
413
+ if (entry.isDirectory()) {
414
+ if (
415
+ entry.name.includes('node_modules') ||
416
+ entry.name.startsWith('.') ||
417
+ entry.name === 'dist' ||
418
+ entry.name === 'build' ||
419
+ entry.name === '.next'
420
+ ) {
421
+ continue;
422
+ }
423
+ searchDirRecursive(fullPath);
424
+ } else if (entry.isFile() && /\.(tsx?|jsx?)$/.test(entry.name)) {
425
+ try {
426
+ const content = fs.readFileSync(fullPath, 'utf-8');
427
+ const lines = content.split('\n');
428
+ for (let i = 0; i < lines.length; i++) {
429
+ if (pattern.test(lines[i])) {
430
+ const relativePath = fullPath.replace(projectRoot + '/', '');
431
+ const isRouteMatch = relativePath === routePageFile;
432
+ matches.push({
433
+ path: relativePath,
434
+ lineNumber: i + 1,
435
+ isRouteMatch
436
+ });
437
+ debugLog("PHASE 0: Found ID match", {
438
+ file: relativePath,
439
+ line: i + 1,
440
+ isRouteMatch
441
+ });
442
+ break; // One match per file is enough
443
+ }
444
+ }
445
+ } catch {
446
+ // Skip files that can't be read
447
+ }
448
+ }
449
+ }
450
+ } catch {
451
+ // Skip directories that can't be read
452
+ }
453
+ }
454
+
455
+ // Search all directories
456
+ for (const searchDir of searchDirs) {
457
+ searchDirRecursive(searchDir);
458
+ }
459
+
460
+ // Sort: route matches first
461
+ return matches.sort((a, b) => (b.isRouteMatch ? 1 : 0) - (a.isRouteMatch ? 1 : 0));
462
+ }
463
+
228
464
  /**
229
465
  * Result of LLM screenshot analysis for smart file discovery
230
466
  */
@@ -896,12 +1132,73 @@ export async function POST(request: Request) {
896
1132
  );
897
1133
  }
898
1134
 
899
- // PHASE 1+2: LLM-driven smart file discovery
900
- // The LLM analyzes the screenshot and deduces component names, code patterns, and visible text
1135
+ // Initialize file discovery variables
901
1136
  let smartSearchFiles: { path: string; content: string }[] = [];
902
1137
  let recommendedFile: { path: string; reason: string } | null = null;
903
-
904
- if (screenshot) {
1138
+ let deterministicMatch: { path: string; lineNumber: number } | null = null;
1139
+
1140
+ // ========================================================================
1141
+ // PHASE 0: Deterministic Element ID Search (Cursor-style explicit selection)
1142
+ // If we have an element ID, find it directly - no scoring, no heuristics
1143
+ // This is the most reliable signal, like Cursor knowing which file you're in
1144
+ // ========================================================================
1145
+ if (focusedElements?.some(el => el.elementId)) {
1146
+ const elementWithId = focusedElements.find(el => el.elementId)!;
1147
+ debugLog("PHASE 0: Element has ID, starting deterministic search", {
1148
+ elementId: elementWithId.elementId,
1149
+ route: pageRoute
1150
+ });
1151
+
1152
+ const matches = findFilesByElementId(
1153
+ projectRoot,
1154
+ elementWithId.elementId!,
1155
+ pageRoute || "/",
1156
+ discoverPageFile
1157
+ );
1158
+
1159
+ if (matches.length === 1) {
1160
+ // Single match - 100% confidence
1161
+ deterministicMatch = matches[0];
1162
+ recommendedFile = {
1163
+ path: matches[0].path,
1164
+ reason: `Deterministic ID match: id="${elementWithId.elementId}" (unique in codebase)`
1165
+ };
1166
+ debugLog("PHASE 0 SUCCESS: Single ID match - skipping smart search", deterministicMatch);
1167
+ } else if (matches.length > 1) {
1168
+ // Multiple matches - use route to disambiguate
1169
+ const routeMatch = matches.find(m => m.isRouteMatch);
1170
+ if (routeMatch) {
1171
+ deterministicMatch = routeMatch;
1172
+ recommendedFile = {
1173
+ path: routeMatch.path,
1174
+ reason: `Deterministic ID match: id="${elementWithId.elementId}" (route "${pageRoute}" disambiguated from ${matches.length} files)`
1175
+ };
1176
+ debugLog("PHASE 0 SUCCESS: ID found in multiple files, using route match", {
1177
+ match: deterministicMatch,
1178
+ otherFiles: matches.filter(m => !m.isRouteMatch).map(m => m.path)
1179
+ });
1180
+ } else {
1181
+ debugLog("PHASE 0 FALLBACK: ID found in multiple files, no route match", {
1182
+ elementId: elementWithId.elementId,
1183
+ matchCount: matches.length,
1184
+ files: matches.map(m => m.path),
1185
+ route: pageRoute
1186
+ });
1187
+ // Fall through to smart search
1188
+ }
1189
+ } else {
1190
+ debugLog("PHASE 0 FALLBACK: Element ID not found in codebase", {
1191
+ elementId: elementWithId.elementId
1192
+ });
1193
+ // Fall through to smart search
1194
+ }
1195
+ }
1196
+
1197
+ // ========================================================================
1198
+ // PHASE 1+2: LLM-driven smart file discovery (only if Phase 0 didn't match)
1199
+ // The LLM analyzes the screenshot and deduces component names, code patterns, and visible text
1200
+ // ========================================================================
1201
+ if (!deterministicMatch && screenshot) {
905
1202
  debugLog("Starting Phase 1: LLM screenshot analysis");
906
1203
  const analysis = await analyzeScreenshotForSearch(screenshot, userPrompt, apiKey);
907
1204
 
@@ -993,8 +1290,12 @@ export async function POST(request: Request) {
993
1290
  return bestMatch;
994
1291
  };
995
1292
 
1293
+ // ====================================================================
1294
+ // FALLBACK PRIORITY LOGIC (only reached when Phase 0 didn't find a match)
1295
+ // This handles cases: no element ID, dynamic IDs, or ambiguous ID matches
1296
+ // ====================================================================
996
1297
  if (phase2aConfirmed) {
997
- // PRIORITY 1: Phase 2a match confirmed by element search - highest confidence
1298
+ // FALLBACK PRIORITY 1: Phase 2a match confirmed by element search
998
1299
  const confirmedPath = phase2aMatches.find(p =>
999
1300
  focusedElementHints.some(h => h.path === p && h.score > 0)
1000
1301
  );
@@ -1002,33 +1303,47 @@ export async function POST(request: Request) {
1002
1303
  path: confirmedPath!,
1003
1304
  reason: `Phase 2a component-name match confirmed by element search`
1004
1305
  };
1005
- debugLog("PRIORITY 1: Phase 2a confirmed by element search", recommendedFile);
1306
+ debugLog("FALLBACK PRIORITY 1: Phase 2a confirmed by element search", recommendedFile);
1006
1307
  } else if (focusedTopScore > smartSearchTopScore * 0.5 && focusedTopScore >= 400) {
1007
- // PRIORITY 2: Focused element has strong absolute score AND relative to smart search
1308
+ // FALLBACK PRIORITY 2: Focused element has strong score
1008
1309
  recommendedFile = {
1009
1310
  path: focusedTopPath!,
1010
1311
  reason: `Focused element match (score: ${focusedTopScore}, exceeds threshold)`
1011
1312
  };
1012
- debugLog("PRIORITY 2: Strong focused element match", recommendedFile);
1313
+ debugLog("FALLBACK PRIORITY 2: Strong focused element match", recommendedFile);
1013
1314
  } else if (phase2aMatches.length > 0) {
1014
- // PRIORITY 2.5: Phase 2a match WITHOUT focused element - still strong signal
1315
+ // FALLBACK PRIORITY 3: Phase 2a match WITHOUT focused element
1015
1316
  // Use prompt-aware selection to pick the best match
1016
1317
  const bestMatch = findBestPhase2aMatch()!;
1017
1318
  recommendedFile = {
1018
1319
  path: bestMatch,
1019
1320
  reason: `Phase 2a component-name match (prompt-aware selection from ${phase2aMatches.length} candidates)`
1020
1321
  };
1021
- debugLog("PRIORITY 2.5: Phase 2a match (no focused element)", {
1322
+ debugLog("FALLBACK PRIORITY 3: Phase 2a match (no focused element)", {
1022
1323
  selectedPath: bestMatch,
1023
1324
  allCandidates: phase2aMatches
1024
1325
  });
1025
- } else if (smartSearchTopPath) {
1026
- // PRIORITY 3: Smart search top result - trusted baseline
1027
- recommendedFile = {
1028
- path: smartSearchTopPath,
1029
- reason: `Smart search top result (score: ${smartSearchTopScore})`
1030
- };
1031
- debugLog("PRIORITY 3: Using smart search top result", recommendedFile);
1326
+ } else {
1327
+ // FALLBACK PRIORITY 4: Use the page file from the current route
1328
+ const routePageFile = discoverPageFile(pageRoute || "/", projectRoot);
1329
+
1330
+ if (routePageFile && fs.existsSync(path.join(projectRoot, routePageFile))) {
1331
+ recommendedFile = {
1332
+ path: routePageFile,
1333
+ reason: `Page file from route "${pageRoute || "/"}"`
1334
+ };
1335
+ debugLog("FALLBACK PRIORITY 4: Using page file from route", {
1336
+ route: pageRoute || "/",
1337
+ pageFile: routePageFile
1338
+ });
1339
+ } else if (smartSearchTopPath) {
1340
+ // FALLBACK PRIORITY 5: Smart search top result - last resort
1341
+ recommendedFile = {
1342
+ path: smartSearchTopPath,
1343
+ reason: `Smart search top result (score: ${smartSearchTopScore})`
1344
+ };
1345
+ debugLog("FALLBACK PRIORITY 5: Using smart search top result", recommendedFile);
1346
+ }
1032
1347
  }
1033
1348
 
1034
1349
  debugLog("Phase 1+2 complete", {
@@ -1117,12 +1432,21 @@ User Request: "${userPrompt}"
1117
1432
  if (recommendedFileContent) {
1118
1433
  const content = recommendedFileContent.content;
1119
1434
 
1120
- // Search for element IDs in the file to enable precise targeting
1121
- let idMatch: { lineNumber: number; matchedId: string; snippet: string } | null = null;
1122
- if (focusedElements && focusedElements.length > 0) {
1435
+ // Search for focused element in the file using multiple strategies
1436
+ // Priority: DOM id > textContent > className patterns
1437
+ let elementLocation: { lineNumber: number; snippet: string; confidence: 'high' | 'medium' | 'low'; matchedBy: string } | null = null;
1438
+ if (focusedElements && focusedElements.length > 0) {
1123
1439
  for (const el of focusedElements) {
1124
- idMatch = findElementIdInFile(content, el.elementId, el.childIds);
1125
- if (idMatch) break;
1440
+ elementLocation = findElementLineInFile(content, el);
1441
+ if (elementLocation) {
1442
+ debugLog("Found focused element in file", {
1443
+ matchedBy: elementLocation.matchedBy,
1444
+ lineNumber: elementLocation.lineNumber,
1445
+ confidence: elementLocation.confidence,
1446
+ file: recommendedFileContent.path,
1447
+ });
1448
+ break;
1449
+ }
1126
1450
  }
1127
1451
  }
1128
1452
 
@@ -1137,22 +1461,36 @@ User Request: "${userPrompt}"
1137
1461
  textContent += `\n`;
1138
1462
  }
1139
1463
 
1140
- // Add precise targeting if we found an ID match
1141
- if (idMatch) {
1464
+ // Add precise targeting with line number and snippet
1465
+ if (elementLocation) {
1142
1466
  textContent += `
1143
- PRECISE TARGET (found by element ID):
1144
- ID: "${idMatch.matchedId}"
1145
- → Line: ${idMatch.lineNumber}
1146
- Look for this ID in the code and modify the element that contains it.
1467
+ ══════════════════════════════════════════════════════════════════════════════
1468
+ PRECISE TARGET LOCATION (${elementLocation.confidence} confidence)
1469
+ ══════════════════════════════════════════════════════════════════════════════
1470
+ Matched by: ${elementLocation.matchedBy}
1471
+ → Line: ${elementLocation.lineNumber}
1472
+
1473
+ THE USER CLICKED ON THE ELEMENT AT LINE ${elementLocation.lineNumber}.
1474
+ Here is the exact code around that element:
1475
+ \`\`\`
1476
+ ${elementLocation.snippet}
1477
+ \`\`\`
1478
+
1479
+ ⚠️ IMPORTANT: Modify ONLY the element at line ${elementLocation.lineNumber}, NOT other similar elements in the file.
1147
1480
 
1148
1481
  `;
1149
- debugLog("Found element ID in file", {
1150
- matchedId: idMatch.matchedId,
1151
- lineNumber: idMatch.lineNumber,
1152
- file: recommendedFileContent.path,
1153
- });
1154
1482
  } else {
1155
1483
  textContent += `\n`;
1484
+ debugLog("Could not locate focused element in file - no ID, textContent, or className match", {
1485
+ file: recommendedFileContent.path,
1486
+ focusedElements: focusedElements.map(el => ({
1487
+ name: el.name,
1488
+ type: el.type,
1489
+ textContent: el.textContent?.substring(0, 30),
1490
+ className: el.className?.substring(0, 50),
1491
+ elementId: el.elementId,
1492
+ }))
1493
+ });
1156
1494
  }
1157
1495
  }
1158
1496
 
@@ -1215,10 +1215,11 @@ export function SonanceDevTools() {
1215
1215
  console.warn("[Apply-First] Failed to persist session:", e);
1216
1216
  }
1217
1217
 
1218
- // After a brief delay, assume HMR has completed
1218
+ // Force page refresh to ensure changes are visible
1219
+ // Session is already persisted to localStorage, so it survives refresh
1219
1220
  setTimeout(() => {
1220
- setApplyFirstStatus("reviewing");
1221
- }, 1500);
1221
+ window.location.reload();
1222
+ }, 500);
1222
1223
  }, [visionFocusedElements]);
1223
1224
 
1224
1225
  // Accept changes - delete backups
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonance-brand-mcp",
3
- "version": "1.3.88",
3
+ "version": "1.3.90",
4
4
  "description": "MCP Server for Sonance Brand Guidelines and Component Library - gives Claude instant access to brand colors, typography, and UI components.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",