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
@@ -0,0 +1,471 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import type { VisionFocusedElement } from "../types";
5
+
6
+ export interface ComputedGeometry {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ rotation: number;
12
+ }
13
+
14
+ export interface ComputedTypography {
15
+ fontFamily: string;
16
+ fontSize: string;
17
+ fontWeight: string;
18
+ lineHeight: string;
19
+ letterSpacing: string;
20
+ textAlign: string;
21
+ color: string;
22
+ }
23
+
24
+ export interface ComputedFill {
25
+ type: "solid" | "gradient" | "image";
26
+ color?: string;
27
+ opacity: number;
28
+ }
29
+
30
+ export interface ComputedStroke {
31
+ color: string;
32
+ width: string;
33
+ style: string;
34
+ }
35
+
36
+ export interface ComputedEffect {
37
+ type: "shadow" | "blur";
38
+ value: string;
39
+ }
40
+
41
+ export interface ComputedStyles {
42
+ // Element Info
43
+ tagName: string;
44
+ className: string;
45
+ id: string;
46
+
47
+ // Geometry (Layout)
48
+ geometry: ComputedGeometry;
49
+
50
+ // Appearance
51
+ opacity: number;
52
+ borderRadius: string;
53
+ overflow: string;
54
+
55
+ // Typography (for text elements)
56
+ typography: ComputedTypography | null;
57
+ hasText: boolean;
58
+ textContent: string;
59
+
60
+ // Fills (backgrounds)
61
+ fills: ComputedFill[];
62
+
63
+ // Strokes (borders)
64
+ strokes: ComputedStroke[];
65
+
66
+ // Effects (shadows, filters)
67
+ effects: ComputedEffect[];
68
+
69
+ // Display & Layout
70
+ display: string;
71
+ flexDirection: string;
72
+ alignItems: string;
73
+ justifyContent: string;
74
+ gap: string;
75
+ padding: string;
76
+ margin: string;
77
+ }
78
+
79
+ function parseColor(color: string): { color: string; opacity: number } {
80
+ // Handle rgba
81
+ const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
82
+ if (rgbaMatch) {
83
+ const [, r, g, b, a] = rgbaMatch;
84
+ const hex = `#${parseInt(r).toString(16).padStart(2, '0')}${parseInt(g).toString(16).padStart(2, '0')}${parseInt(b).toString(16).padStart(2, '0')}`;
85
+ return { color: hex.toUpperCase(), opacity: a ? parseFloat(a) : 1 };
86
+ }
87
+
88
+ // Handle hex
89
+ if (color.startsWith('#')) {
90
+ return { color: color.toUpperCase(), opacity: 1 };
91
+ }
92
+
93
+ // Handle transparent
94
+ if (color === 'transparent') {
95
+ return { color: 'transparent', opacity: 0 };
96
+ }
97
+
98
+ return { color, opacity: 1 };
99
+ }
100
+
101
+ function extractFills(styles: CSSStyleDeclaration): ComputedFill[] {
102
+ const fills: ComputedFill[] = [];
103
+
104
+ const bg = styles.backgroundColor;
105
+ if (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') {
106
+ const { color, opacity } = parseColor(bg);
107
+ fills.push({ type: 'solid', color, opacity });
108
+ }
109
+
110
+ const bgImage = styles.backgroundImage;
111
+ if (bgImage && bgImage !== 'none') {
112
+ if (bgImage.includes('gradient')) {
113
+ fills.push({ type: 'gradient', color: bgImage, opacity: 1 });
114
+ } else if (bgImage.includes('url')) {
115
+ fills.push({ type: 'image', opacity: 1 });
116
+ }
117
+ }
118
+
119
+ return fills;
120
+ }
121
+
122
+ function extractStrokes(styles: CSSStyleDeclaration): ComputedStroke[] {
123
+ const strokes: ComputedStroke[] = [];
124
+
125
+ const borderWidth = styles.borderWidth;
126
+ const borderColor = styles.borderColor;
127
+ const borderStyle = styles.borderStyle;
128
+
129
+ if (borderWidth && borderWidth !== '0px' && borderStyle !== 'none') {
130
+ const { color } = parseColor(borderColor);
131
+ strokes.push({
132
+ color,
133
+ width: borderWidth,
134
+ style: borderStyle,
135
+ });
136
+ }
137
+
138
+ return strokes;
139
+ }
140
+
141
+ function extractEffects(styles: CSSStyleDeclaration): ComputedEffect[] {
142
+ const effects: ComputedEffect[] = [];
143
+
144
+ const boxShadow = styles.boxShadow;
145
+ if (boxShadow && boxShadow !== 'none') {
146
+ effects.push({ type: 'shadow', value: boxShadow });
147
+ }
148
+
149
+ const filter = styles.filter;
150
+ if (filter && filter !== 'none') {
151
+ effects.push({ type: 'blur', value: filter });
152
+ }
153
+
154
+ return effects;
155
+ }
156
+
157
+ function isTextElement(element: HTMLElement): boolean {
158
+ const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SPAN', 'A', 'LABEL', 'LI', 'TD', 'TH', 'BUTTON'];
159
+ if (textTags.includes(element.tagName)) return true;
160
+
161
+ // Check if element has direct text content
162
+ const hasDirectText = Array.from(element.childNodes).some(
163
+ node => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
164
+ );
165
+
166
+ return hasDirectText;
167
+ }
168
+
169
+ export function useComputedStyles(elementId: string | null, variantId?: string | null): ComputedStyles | null {
170
+ const [styles, setStyles] = useState<ComputedStyles | null>(null);
171
+
172
+ const extractStyles = useCallback(() => {
173
+ if (!elementId) {
174
+ setStyles(null);
175
+ return;
176
+ }
177
+
178
+ // Try to find element by ID first, then by data attributes
179
+ let element: HTMLElement | null = document.getElementById(elementId);
180
+
181
+ if (!element) {
182
+ // Try finding by component type and variant
183
+ const selector = variantId
184
+ ? `[data-component-type="${elementId}"][data-variant-id="${variantId}"]`
185
+ : `[data-component-type="${elementId}"]`;
186
+ element = document.querySelector(selector);
187
+ }
188
+
189
+ if (!element) {
190
+ // Try finding any element with matching text content or class
191
+ const allElements = document.querySelectorAll('button, a, [role="button"], input, select, textarea, h1, h2, h3, h4, h5, h6, p, span');
192
+ for (const el of allElements) {
193
+ if (el.id === elementId || el.className.includes(elementId)) {
194
+ element = el as HTMLElement;
195
+ break;
196
+ }
197
+ }
198
+ }
199
+
200
+ if (!element) {
201
+ setStyles(null);
202
+ return;
203
+ }
204
+
205
+ const computed = window.getComputedStyle(element);
206
+ const rect = element.getBoundingClientRect();
207
+
208
+ // Extract rotation from transform matrix
209
+ let rotation = 0;
210
+ const transform = computed.transform;
211
+ if (transform && transform !== 'none') {
212
+ const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
213
+ if (matrixMatch) {
214
+ const values = matrixMatch[1].split(',').map(v => parseFloat(v.trim()));
215
+ rotation = Math.round(Math.atan2(values[1], values[0]) * (180 / Math.PI));
216
+ }
217
+ }
218
+
219
+ const hasText = isTextElement(element);
220
+ const textContent = element.textContent?.trim() || '';
221
+
222
+ const computedStyles: ComputedStyles = {
223
+ tagName: element.tagName.toLowerCase(),
224
+ className: element.className?.toString() || '',
225
+ id: element.id || '',
226
+
227
+ geometry: {
228
+ x: Math.round(rect.left),
229
+ y: Math.round(rect.top),
230
+ width: Math.round(rect.width),
231
+ height: Math.round(rect.height),
232
+ rotation,
233
+ },
234
+
235
+ opacity: parseFloat(computed.opacity) * 100,
236
+ borderRadius: computed.borderRadius,
237
+ overflow: computed.overflow,
238
+
239
+ typography: hasText ? {
240
+ fontFamily: computed.fontFamily.split(',')[0].replace(/['"]/g, ''),
241
+ fontSize: computed.fontSize,
242
+ fontWeight: computed.fontWeight,
243
+ lineHeight: computed.lineHeight,
244
+ letterSpacing: computed.letterSpacing,
245
+ textAlign: computed.textAlign,
246
+ color: parseColor(computed.color).color,
247
+ } : null,
248
+ hasText,
249
+ textContent,
250
+
251
+ fills: extractFills(computed),
252
+ strokes: extractStrokes(computed),
253
+ effects: extractEffects(computed),
254
+
255
+ display: computed.display,
256
+ flexDirection: computed.flexDirection,
257
+ alignItems: computed.alignItems,
258
+ justifyContent: computed.justifyContent,
259
+ gap: computed.gap,
260
+ padding: computed.padding,
261
+ margin: computed.margin,
262
+ };
263
+
264
+ setStyles(computedStyles);
265
+ }, [elementId, variantId]);
266
+
267
+ useEffect(() => {
268
+ extractStyles();
269
+
270
+ // Re-extract on resize/scroll
271
+ const handleUpdate = () => extractStyles();
272
+ window.addEventListener('resize', handleUpdate);
273
+ window.addEventListener('scroll', handleUpdate, true);
274
+
275
+ return () => {
276
+ window.removeEventListener('resize', handleUpdate);
277
+ window.removeEventListener('scroll', handleUpdate, true);
278
+ };
279
+ }, [extractStyles]);
280
+
281
+ return styles;
282
+ }
283
+
284
+ /**
285
+ * Multi-strategy element finding for robustness after HMR
286
+ * Tries multiple methods to find the element, falling back through them
287
+ *
288
+ * Priority order:
289
+ * 1. Element ID with styled child detection (most reliable for nested styled elements)
290
+ * 2. Text content with span priority (finds styled inline elements)
291
+ * 3. Coordinates (fallback for when other strategies fail)
292
+ *
293
+ * Note: Coordinates are stored as screen coordinates at click time and become
294
+ * invalid after scroll/layout changes, so they're used as a fallback, not primary.
295
+ */
296
+ function findElementByMultipleStrategies(focusedElement: VisionFocusedElement): HTMLElement | null {
297
+ // Strategy 1: Find by element ID with styled child detection
298
+ // This handles cases where the detected element is a container (like <p>)
299
+ // but the actual styled element is a child (like <span style="color:...">)
300
+ if (focusedElement.elementId) {
301
+ const byId = document.getElementById(focusedElement.elementId);
302
+ if (byId && !byId.closest('[data-sonance-devtools="true"]')) {
303
+ // First, check if there's a styled child element with inline color
304
+ const styledChild = byId.querySelector('[style*="color"]') as HTMLElement;
305
+ if (styledChild) {
306
+ return styledChild;
307
+ }
308
+ // No styled child, return the element itself
309
+ return byId;
310
+ }
311
+ }
312
+
313
+ // Strategy 2: Find by text content, prioritizing span elements (which often have styling)
314
+ if (focusedElement.textContent) {
315
+ const textToFind = focusedElement.textContent.trim();
316
+ if (textToFind.length > 0) {
317
+ // Search specifically for span first (inline styled elements), then other text elements
318
+ const spanCandidates = document.querySelectorAll('span');
319
+ for (const el of spanCandidates) {
320
+ if (el.closest('[data-sonance-devtools="true"]')) continue;
321
+ const elText = el.textContent?.trim() || '';
322
+ const compareLength = Math.min(30, textToFind.length);
323
+ if (elText.startsWith(textToFind.substring(0, compareLength))) {
324
+ return el as HTMLElement;
325
+ }
326
+ }
327
+
328
+ // Fallback to other text elements
329
+ const candidates = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, label, button, a, td, th, li, div');
330
+ for (const el of candidates) {
331
+ if (el.closest('[data-sonance-devtools="true"]')) continue;
332
+ const elText = el.textContent?.trim() || '';
333
+ const compareLength = Math.min(30, textToFind.length);
334
+ if (elText.startsWith(textToFind.substring(0, compareLength))) {
335
+ // Check if this element has a styled span child
336
+ const styledSpan = el.querySelector('span[style*="color"]') as HTMLElement;
337
+ if (styledSpan) {
338
+ return styledSpan;
339
+ }
340
+ return el as HTMLElement;
341
+ }
342
+ }
343
+ }
344
+ }
345
+
346
+ // Strategy 3: Find by coordinates (fallback - coordinates may be stale after scroll)
347
+ const { coordinates } = focusedElement;
348
+ if (coordinates) {
349
+ const centerX = coordinates.x + coordinates.width / 2;
350
+ const centerY = coordinates.y + coordinates.height / 2;
351
+ const byCoords = document.elementFromPoint(centerX, centerY) as HTMLElement;
352
+ if (byCoords && !byCoords.closest('[data-sonance-devtools="true"]')) {
353
+ return byCoords;
354
+ }
355
+ }
356
+
357
+ return null;
358
+ }
359
+
360
+ // Enhanced version that works with full VisionFocusedElement for robust element finding
361
+ export function useElementFromCoordinates(focusedElement: VisionFocusedElement | null): ComputedStyles | null {
362
+ const [styles, setStyles] = useState<ComputedStyles | null>(null);
363
+ const [refreshCounter, setRefreshCounter] = useState(0);
364
+
365
+ useEffect(() => {
366
+ if (!focusedElement) {
367
+ setStyles(null);
368
+ return;
369
+ }
370
+
371
+ // Use multi-strategy element finding for robustness after HMR
372
+ const element = findElementByMultipleStrategies(focusedElement);
373
+
374
+ if (!element) {
375
+ setStyles(null);
376
+ return;
377
+ }
378
+
379
+ const extractAndSetStyles = () => {
380
+ const computed = window.getComputedStyle(element);
381
+ const rect = element.getBoundingClientRect();
382
+
383
+ let rotation = 0;
384
+ const transform = computed.transform;
385
+ if (transform && transform !== 'none') {
386
+ const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
387
+ if (matrixMatch) {
388
+ const values = matrixMatch[1].split(',').map(v => parseFloat(v.trim()));
389
+ rotation = Math.round(Math.atan2(values[1], values[0]) * (180 / Math.PI));
390
+ }
391
+ }
392
+
393
+ const hasText = isTextElement(element);
394
+ const textContent = element.textContent?.trim() || '';
395
+
396
+ setStyles({
397
+ tagName: element.tagName.toLowerCase(),
398
+ className: element.className?.toString() || '',
399
+ id: element.id || '',
400
+
401
+ geometry: {
402
+ x: Math.round(rect.left),
403
+ y: Math.round(rect.top),
404
+ width: Math.round(rect.width),
405
+ height: Math.round(rect.height),
406
+ rotation,
407
+ },
408
+
409
+ opacity: parseFloat(computed.opacity) * 100,
410
+ borderRadius: computed.borderRadius,
411
+ overflow: computed.overflow,
412
+
413
+ typography: hasText ? {
414
+ fontFamily: computed.fontFamily.split(',')[0].replace(/['"]/g, ''),
415
+ fontSize: computed.fontSize,
416
+ fontWeight: computed.fontWeight,
417
+ lineHeight: computed.lineHeight,
418
+ letterSpacing: computed.letterSpacing,
419
+ textAlign: computed.textAlign,
420
+ color: parseColor(computed.color).color,
421
+ } : null,
422
+ hasText,
423
+ textContent,
424
+
425
+ fills: extractFills(computed),
426
+ strokes: extractStrokes(computed),
427
+ effects: extractEffects(computed),
428
+
429
+ display: computed.display,
430
+ flexDirection: computed.flexDirection,
431
+ alignItems: computed.alignItems,
432
+ justifyContent: computed.justifyContent,
433
+ gap: computed.gap,
434
+ padding: computed.padding,
435
+ margin: computed.margin,
436
+ });
437
+ };
438
+
439
+ // Initial extraction
440
+ extractAndSetStyles();
441
+
442
+ // Watch for DOM changes (HMR updates) using MutationObserver
443
+ const observer = new MutationObserver((mutations) => {
444
+ // Check if any mutation affects our element's content
445
+ for (const mutation of mutations) {
446
+ if (mutation.type === 'characterData' ||
447
+ mutation.type === 'childList' ||
448
+ (mutation.type === 'attributes' && mutation.target === element)) {
449
+ // Trigger a refresh
450
+ setRefreshCounter(c => c + 1);
451
+ break;
452
+ }
453
+ }
454
+ });
455
+
456
+ // Observe the element and its subtree for changes
457
+ observer.observe(element, {
458
+ characterData: true,
459
+ childList: true,
460
+ subtree: true,
461
+ attributes: true,
462
+ });
463
+
464
+ return () => {
465
+ observer.disconnect();
466
+ };
467
+ }, [focusedElement, refreshCounter]);
468
+
469
+ return styles;
470
+ }
471
+
@@ -0,0 +1,212 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Content-based hashing utilities for generating stable element IDs
5
+ *
6
+ * Instead of sequential IDs (text-0, logo-1) that change on re-render,
7
+ * we generate IDs based on element content that persist across page loads.
8
+ */
9
+
10
+ /**
11
+ * Fast hash function (djb2 algorithm)
12
+ * Produces a short, consistent hash string from any input
13
+ */
14
+ export function hashString(str: string): string {
15
+ let hash = 5381;
16
+ for (let i = 0; i < str.length; i++) {
17
+ hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
18
+ }
19
+ // Convert to hex and take last 8 chars for brevity
20
+ return (hash >>> 0).toString(16).padStart(8, "0");
21
+ }
22
+
23
+ /**
24
+ * Get a stable DOM path for an element (up to 3 levels)
25
+ * This helps differentiate elements with similar content in different locations
26
+ */
27
+ function getDOMPath(element: Element, maxDepth: number = 3): string {
28
+ const path: string[] = [];
29
+ let current: Element | null = element;
30
+ let depth = 0;
31
+
32
+ while (current && current !== document.body && depth < maxDepth) {
33
+ const tag = current.tagName.toLowerCase();
34
+ const parent: Element | null = current.parentElement;
35
+
36
+ if (parent) {
37
+ // Get index among siblings of same type
38
+ const siblings = Array.from(parent.children).filter(
39
+ (child) => child.tagName === current!.tagName
40
+ );
41
+ const index = siblings.indexOf(current);
42
+ path.unshift(`${tag}[${index}]`);
43
+ } else {
44
+ path.unshift(tag);
45
+ }
46
+
47
+ current = parent;
48
+ depth++;
49
+ }
50
+
51
+ return path.join(">");
52
+ }
53
+
54
+ /**
55
+ * Generate a stable ID for text elements
56
+ * Based on: tagName + truncated textContent + DOM path
57
+ */
58
+ export function generateTextElementId(element: Element): string {
59
+ const tagName = element.tagName.toLowerCase();
60
+ const textContent = (element.textContent || "").trim().substring(0, 50);
61
+ const domPath = getDOMPath(element);
62
+
63
+ const signature = `text:${tagName}:${textContent}:${domPath}`;
64
+ return `text-${hashString(signature)}`;
65
+ }
66
+
67
+ /**
68
+ * Generate a stable ID for image elements
69
+ * Based on: src (normalized) + alt + natural dimensions
70
+ */
71
+ export function generateImageElementId(img: HTMLImageElement): string {
72
+ // Normalize src by removing query params and getting filename
73
+ const src = img.src || img.getAttribute("src") || "";
74
+ const srcPath = src.split("?")[0].split("/").pop() || src;
75
+ const alt = img.alt || "";
76
+ const dimensions = `${img.naturalWidth || 0}x${img.naturalHeight || 0}`;
77
+
78
+ const signature = `img:${srcPath}:${alt}:${dimensions}`;
79
+ return `logo-${hashString(signature)}`;
80
+ }
81
+
82
+ /**
83
+ * Generate a stable ID for component elements
84
+ * Based on: component name + variant styles + DOM path
85
+ */
86
+ export function generateComponentId(element: Element): string {
87
+ const name = element.getAttribute("data-sonance-name") || element.tagName.toLowerCase();
88
+ const className = element.className?.toString() || "";
89
+ const domPath = getDOMPath(element);
90
+
91
+ const signature = `comp:${name}:${className}:${domPath}`;
92
+ return `comp-${hashString(signature)}`;
93
+ }
94
+
95
+ /**
96
+ * Generate a variant ID based on computed styles
97
+ * This groups visually identical components together
98
+ */
99
+ export function generateVariantId(element: Element): string {
100
+ const className = element.className?.toString() || "";
101
+ const computed = window.getComputedStyle(element);
102
+
103
+ const styleSignature = [
104
+ className,
105
+ computed.backgroundColor,
106
+ computed.borderColor,
107
+ computed.borderRadius,
108
+ computed.color,
109
+ ].join("|");
110
+
111
+ return hashString(styleSignature);
112
+ }
113
+
114
+ /**
115
+ * Migration utilities for backwards compatibility with old sequential IDs
116
+ */
117
+
118
+ interface LegacyIdMapping {
119
+ oldId: string;
120
+ newId: string;
121
+ selector: string;
122
+ textContent?: string;
123
+ }
124
+
125
+ /**
126
+ * Migrate old sequential IDs to new content-based IDs
127
+ * Returns a mapping that can be used to update localStorage entries
128
+ */
129
+ export function migrateLegacyIds(
130
+ type: "text" | "logo",
131
+ oldOverrides: Record<string, unknown>
132
+ ): { migrated: Record<string, unknown>; mappings: LegacyIdMapping[] } {
133
+ const migrated: Record<string, unknown> = {};
134
+ const mappings: LegacyIdMapping[] = [];
135
+
136
+ // Pattern for old sequential IDs
137
+ const legacyPattern = type === "text" ? /^text-\d+$/ : /^logo-\d+$/;
138
+
139
+ for (const [oldId, value] of Object.entries(oldOverrides)) {
140
+ if (legacyPattern.test(oldId)) {
141
+ // Try to find the element and generate new ID
142
+ const selector = `[data-sonance-${type}-id="${oldId}"]`;
143
+ const element = document.querySelector(selector);
144
+
145
+ if (element) {
146
+ const newId = type === "text"
147
+ ? generateTextElementId(element)
148
+ : generateImageElementId(element as HTMLImageElement);
149
+
150
+ migrated[newId] = value;
151
+ mappings.push({
152
+ oldId,
153
+ newId,
154
+ selector,
155
+ textContent: element.textContent?.substring(0, 50),
156
+ });
157
+
158
+ // Update the element's data attribute to use new ID
159
+ element.setAttribute(`data-sonance-${type}-id`, newId);
160
+ } else {
161
+ // Element not found - keep old ID for now
162
+ migrated[oldId] = value;
163
+ }
164
+ } else {
165
+ // Already using new format or unknown format - keep as is
166
+ migrated[oldId] = value;
167
+ }
168
+ }
169
+
170
+ return { migrated, mappings };
171
+ }
172
+
173
+ /**
174
+ * Check if an ID is in the legacy sequential format
175
+ */
176
+ export function isLegacyId(id: string): boolean {
177
+ return /^(text|logo)-\d+$/.test(id);
178
+ }
179
+
180
+ /**
181
+ * Storage key constants for migration tracking
182
+ */
183
+ export const MIGRATION_STORAGE_KEY = "sonance-id-migration-complete";
184
+ export const MIGRATION_VERSION = 1;
185
+
186
+ /**
187
+ * Check if migration has already been performed
188
+ */
189
+ export function isMigrationComplete(): boolean {
190
+ try {
191
+ const stored = localStorage.getItem(MIGRATION_STORAGE_KEY);
192
+ if (!stored) return false;
193
+ const { version } = JSON.parse(stored);
194
+ return version >= MIGRATION_VERSION;
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Mark migration as complete
202
+ */
203
+ export function markMigrationComplete(): void {
204
+ try {
205
+ localStorage.setItem(
206
+ MIGRATION_STORAGE_KEY,
207
+ JSON.stringify({ version: MIGRATION_VERSION, timestamp: Date.now() })
208
+ );
209
+ } catch {
210
+ // Ignore storage errors
211
+ }
212
+ }