sonance-brand-mcp 1.3.111 → 1.3.113

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 (79) hide show
  1. package/dist/assets/api/sonance-save-image/route.ts +625 -0
  2. package/dist/assets/api/sonance-vision-apply/image-styling-detection.ts +1360 -0
  3. package/dist/assets/api/sonance-vision-apply/route.ts +988 -57
  4. package/dist/assets/api/sonance-vision-apply/styling-detection.ts +730 -0
  5. package/dist/assets/api/sonance-vision-apply/theme-discovery.ts +1 -1
  6. package/dist/assets/brand-system.ts +13 -12
  7. package/dist/assets/components/accordion.tsx +15 -7
  8. package/dist/assets/components/alert-dialog.tsx +35 -10
  9. package/dist/assets/components/alert.tsx +11 -10
  10. package/dist/assets/components/avatar.tsx +4 -4
  11. package/dist/assets/components/badge.tsx +16 -12
  12. package/dist/assets/components/button.stories.tsx +3 -3
  13. package/dist/assets/components/button.tsx +50 -31
  14. package/dist/assets/components/calendar.tsx +12 -8
  15. package/dist/assets/components/card.tsx +35 -29
  16. package/dist/assets/components/checkbox.tsx +9 -8
  17. package/dist/assets/components/code.tsx +19 -11
  18. package/dist/assets/components/command.tsx +32 -13
  19. package/dist/assets/components/context-menu.tsx +37 -16
  20. package/dist/assets/components/dialog.tsx +8 -5
  21. package/dist/assets/components/divider.tsx +15 -5
  22. package/dist/assets/components/drawer.tsx +4 -3
  23. package/dist/assets/components/dropdown-menu.tsx +15 -13
  24. package/dist/assets/components/hover-card.tsx +4 -1
  25. package/dist/assets/components/image.tsx +1 -1
  26. package/dist/assets/components/input.tsx +29 -14
  27. package/dist/assets/components/kbd.stories.tsx +3 -3
  28. package/dist/assets/components/kbd.tsx +29 -13
  29. package/dist/assets/components/listbox.tsx +8 -8
  30. package/dist/assets/components/menubar.tsx +50 -23
  31. package/dist/assets/components/navbar.stories.tsx +140 -13
  32. package/dist/assets/components/navbar.tsx +22 -5
  33. package/dist/assets/components/navigation-menu.tsx +28 -6
  34. package/dist/assets/components/pagination.tsx +10 -10
  35. package/dist/assets/components/popover.tsx +10 -8
  36. package/dist/assets/components/progress.tsx +6 -4
  37. package/dist/assets/components/radio-group.tsx +5 -5
  38. package/dist/assets/components/select.tsx +49 -29
  39. package/dist/assets/components/separator.tsx +3 -3
  40. package/dist/assets/components/sheet.tsx +4 -4
  41. package/dist/assets/components/sidebar.tsx +10 -10
  42. package/dist/assets/components/skeleton.tsx +13 -5
  43. package/dist/assets/components/slider.tsx +12 -10
  44. package/dist/assets/components/switch.tsx +4 -4
  45. package/dist/assets/components/table.tsx +5 -5
  46. package/dist/assets/components/tabs.tsx +8 -8
  47. package/dist/assets/components/textarea.tsx +11 -9
  48. package/dist/assets/components/toast.tsx +7 -7
  49. package/dist/assets/components/toggle.tsx +27 -7
  50. package/dist/assets/components/tooltip.tsx +10 -8
  51. package/dist/assets/components/user.tsx +8 -6
  52. package/dist/assets/dev-tools/SonanceDevTools.tsx +429 -362
  53. package/dist/assets/dev-tools/components/ApplyFirstPreview.tsx +10 -10
  54. package/dist/assets/dev-tools/components/ChatHistory.tsx +11 -7
  55. package/dist/assets/dev-tools/components/ChatInterface.tsx +61 -20
  56. package/dist/assets/dev-tools/components/ChatTabBar.tsx +1 -1
  57. package/dist/assets/dev-tools/components/DiffPreview.tsx +1 -1
  58. package/dist/assets/dev-tools/components/InlineDiffPreview.tsx +360 -36
  59. package/dist/assets/dev-tools/components/InspectorOverlay.tsx +9 -9
  60. package/dist/assets/dev-tools/components/PropertiesPanel.tsx +743 -93
  61. package/dist/assets/dev-tools/components/ScreenshotAnnotator.tsx +1 -1
  62. package/dist/assets/dev-tools/components/SectionHighlight.tsx +1 -1
  63. package/dist/assets/dev-tools/components/VisionDiffPreview.tsx +7 -7
  64. package/dist/assets/dev-tools/components/VisionModeBorder.tsx +4 -64
  65. package/dist/assets/dev-tools/hooks/index.ts +69 -0
  66. package/dist/assets/dev-tools/hooks/useComponentDetection.ts +132 -0
  67. package/dist/assets/dev-tools/hooks/useComputedStyles.ts +171 -65
  68. package/dist/assets/dev-tools/hooks/useContentHash.ts +212 -0
  69. package/dist/assets/dev-tools/hooks/useElementScanner.ts +398 -0
  70. package/dist/assets/dev-tools/hooks/useImageDetection.ts +162 -0
  71. package/dist/assets/dev-tools/hooks/useTextDetection.ts +217 -0
  72. package/dist/assets/dev-tools/panels/ComponentsPanel.tsx +160 -57
  73. package/dist/assets/dev-tools/panels/TextPanel.tsx +10 -10
  74. package/dist/assets/dev-tools/types.ts +42 -0
  75. package/dist/assets/globals.css +225 -9
  76. package/dist/assets/styles/brand-overrides.css +3 -2
  77. package/dist/assets/utils.ts +2 -1
  78. package/dist/index.js +32 -1
  79. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,398 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback, useRef } from "react";
4
+ import { DetectedElement, ElementFilters, OriginalLogoState, OriginalTextState } from "../types";
5
+ import { getActiveModalContent } from "../utils";
6
+ import { detectComponents } from "./useComponentDetection";
7
+ import { detectImages, extractLogoName } from "./useImageDetection";
8
+ import { detectTextElements } from "./useTextDetection";
9
+ import {
10
+ migrateLegacyIds,
11
+ isMigrationComplete,
12
+ markMigrationComplete,
13
+ } from "./useContentHash";
14
+
15
+ /**
16
+ * Scanner configuration
17
+ */
18
+ export interface ElementScannerOptions {
19
+ /** Whether scanning is enabled */
20
+ enabled: boolean;
21
+ /** Element type filters */
22
+ filters: ElementFilters;
23
+ /** Whether inspector highlighting is active (affects text detection) */
24
+ inspectorEnabled?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Scanner result
29
+ */
30
+ export interface ElementScannerResult {
31
+ /** All detected elements */
32
+ elements: DetectedElement[];
33
+ /** Original logo states (for reset functionality) */
34
+ originalLogoStates: Record<string, OriginalLogoState>;
35
+ /** Original text states (for reset functionality) */
36
+ originalTextStates: Record<string, OriginalTextState>;
37
+ /** Trigger a manual re-scan */
38
+ rescan: () => void;
39
+ /** Reset all original states */
40
+ resetStates: () => void;
41
+ }
42
+
43
+ /**
44
+ * Debounce delay for mutation observer (ms)
45
+ */
46
+ const MUTATION_DEBOUNCE_MS = 100;
47
+
48
+ /**
49
+ * Throttle limit for scans per second
50
+ */
51
+ const MAX_SCANS_PER_SECOND = 5;
52
+
53
+ /**
54
+ * Helper: Check if element is inside a scroll container and clip rect to visible bounds
55
+ */
56
+ function getVisibleRect(el: Element, rect: DOMRect): DOMRect | null {
57
+ const scrollParent = el.closest("aside, nav, [data-sidebar]");
58
+
59
+ if (scrollParent) {
60
+ const parentRect = scrollParent.getBoundingClientRect();
61
+
62
+ // Check if element is fully outside the scroll container
63
+ if (
64
+ rect.bottom < parentRect.top ||
65
+ rect.top > parentRect.bottom ||
66
+ rect.right < parentRect.left ||
67
+ rect.left > parentRect.right
68
+ ) {
69
+ return null;
70
+ }
71
+
72
+ // Clip the rect to parent's visible bounds
73
+ const clippedTop = Math.max(rect.top, parentRect.top);
74
+ const clippedBottom = Math.min(rect.bottom, parentRect.bottom);
75
+ const clippedLeft = Math.max(rect.left, parentRect.left);
76
+ const clippedRight = Math.min(rect.right, parentRect.right);
77
+
78
+ // If clipped area is too small, skip it
79
+ if (clippedBottom - clippedTop < 10 || clippedRight - clippedLeft < 10) {
80
+ return null;
81
+ }
82
+
83
+ return new DOMRect(
84
+ clippedLeft,
85
+ clippedTop,
86
+ clippedRight - clippedLeft,
87
+ clippedBottom - clippedTop
88
+ );
89
+ }
90
+
91
+ return rect;
92
+ }
93
+
94
+ /**
95
+ * Helper: Check if element is in the active layer (not behind a modal)
96
+ */
97
+ function createActiveLayerChecker(): (el: Element) => boolean {
98
+ const activeModalContent = getActiveModalContent();
99
+
100
+ return (el: Element): boolean => {
101
+ // Always exclude DevTools panel
102
+ if (el.closest("[data-sonance-devtools]")) return false;
103
+
104
+ // If a modal is active, only include elements inside the modal
105
+ if (activeModalContent) {
106
+ return activeModalContent.contains(el) || el === activeModalContent;
107
+ }
108
+
109
+ return true;
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Perform a one-time migration of legacy sequential IDs in localStorage
115
+ */
116
+ function performLegacyMigration(): void {
117
+ if (isMigrationComplete()) return;
118
+
119
+ try {
120
+ // Migrate text overrides
121
+ const textOverridesRaw = localStorage.getItem("sonance-text-overrides");
122
+ if (textOverridesRaw) {
123
+ const textOverrides = JSON.parse(textOverridesRaw);
124
+ const { migrated, mappings } = migrateLegacyIds("text", textOverrides);
125
+
126
+ if (mappings.length > 0) {
127
+ console.log("[Sonance] Migrated text element IDs:", mappings);
128
+ localStorage.setItem("sonance-text-overrides", JSON.stringify(migrated));
129
+ }
130
+ }
131
+
132
+ // Migrate logo configs
133
+ const logoConfigsRaw = localStorage.getItem("sonance-logo-configs");
134
+ if (logoConfigsRaw) {
135
+ const logoConfigs = JSON.parse(logoConfigsRaw);
136
+ const { migrated, mappings } = migrateLegacyIds("logo", logoConfigs);
137
+
138
+ if (mappings.length > 0) {
139
+ console.log("[Sonance] Migrated logo element IDs:", mappings);
140
+ localStorage.setItem("sonance-logo-configs", JSON.stringify(migrated));
141
+ }
142
+ }
143
+
144
+ markMigrationComplete();
145
+ } catch (e) {
146
+ console.warn("[Sonance] Migration failed:", e);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Main element scanner hook
152
+ *
153
+ * Uses MutationObserver for efficient change detection instead of
154
+ * continuous requestAnimationFrame polling.
155
+ */
156
+ export function useElementScanner(options: ElementScannerOptions): ElementScannerResult {
157
+ const { enabled, filters, inspectorEnabled = false } = options;
158
+
159
+ // State
160
+ const [elements, setElements] = useState<DetectedElement[]>([]);
161
+
162
+ // Refs for original states (avoid re-renders on state changes)
163
+ const originalLogoStatesRef = useRef<Record<string, OriginalLogoState>>({});
164
+ const originalTextStatesRef = useRef<Record<string, OriginalTextState>>({});
165
+
166
+ // Throttling refs
167
+ const lastScanTimeRef = useRef<number>(0);
168
+ const pendingScanRef = useRef<ReturnType<typeof setTimeout> | null>(null);
169
+ const rafIdRef = useRef<number | null>(null);
170
+
171
+ // Mounted ref for cleanup
172
+ const mountedRef = useRef(true);
173
+
174
+ /**
175
+ * Core scan function - detects all elements based on current filters
176
+ */
177
+ const performScan = useCallback(() => {
178
+ if (!mountedRef.current) return;
179
+
180
+ const now = Date.now();
181
+ const timeSinceLastScan = now - lastScanTimeRef.current;
182
+ const minInterval = 1000 / MAX_SCANS_PER_SECOND;
183
+
184
+ // Throttle if scanning too frequently
185
+ if (timeSinceLastScan < minInterval) {
186
+ if (!pendingScanRef.current) {
187
+ pendingScanRef.current = setTimeout(() => {
188
+ pendingScanRef.current = null;
189
+ performScan();
190
+ }, minInterval - timeSinceLastScan);
191
+ }
192
+ return;
193
+ }
194
+
195
+ lastScanTimeRef.current = now;
196
+
197
+ const allElements: DetectedElement[] = [];
198
+ const isInActiveLayer = createActiveLayerChecker();
199
+
200
+ // Scan components
201
+ if (filters.components) {
202
+ const componentConfig = {
203
+ includeModals: true,
204
+ isInActiveLayer,
205
+ getVisibleRect,
206
+ };
207
+ const componentElements = detectComponents(componentConfig);
208
+ allElements.push(...componentElements);
209
+ }
210
+
211
+ // Scan images/logos
212
+ if (filters.images) {
213
+ const imageConfig = {
214
+ isInActiveLayer,
215
+ existingOriginalStates: originalLogoStatesRef.current,
216
+ };
217
+ const { elements: imageElements, originalStates: newLogoStates } = detectImages(imageConfig);
218
+ allElements.push(...imageElements);
219
+ originalLogoStatesRef.current = newLogoStates;
220
+ }
221
+
222
+ // Scan text elements (only when inspector is enabled)
223
+ if (filters.text && inspectorEnabled) {
224
+ const textConfig = {
225
+ isInActiveLayer,
226
+ existingOriginalStates: originalTextStatesRef.current,
227
+ };
228
+ const { elements: textElements, originalStates: newTextStates } = detectTextElements(textConfig);
229
+ allElements.push(...textElements);
230
+ originalTextStatesRef.current = newTextStates;
231
+ }
232
+
233
+ setElements(allElements);
234
+ }, [filters, inspectorEnabled]);
235
+
236
+ /**
237
+ * Position update function - uses RAF for smooth position tracking
238
+ * Only updates rects, doesn't re-detect elements
239
+ */
240
+ const updatePositions = useCallback(() => {
241
+ if (!mountedRef.current || !enabled) return;
242
+
243
+ setElements((prevElements) => {
244
+ if (prevElements.length === 0) return prevElements;
245
+
246
+ let hasChanges = false;
247
+ const updated = prevElements.map((el) => {
248
+ // Find the actual DOM element
249
+ let domEl: Element | null = null;
250
+
251
+ if (el.type === "logo" && el.logoId) {
252
+ domEl = document.querySelector(`[data-sonance-logo-id="${el.logoId}"]`);
253
+ } else if (el.type === "text" && el.textId) {
254
+ domEl = document.querySelector(`[data-sonance-text-id="${el.textId}"]`);
255
+ } else if (el.type === "component" && el.variantId) {
256
+ domEl = document.querySelector(`[data-sonance-variant="${el.variantId}"]`);
257
+ }
258
+
259
+ if (!domEl) return el;
260
+
261
+ const newRect = domEl.getBoundingClientRect();
262
+ const visibleRect = getVisibleRect(domEl, newRect);
263
+
264
+ if (!visibleRect) {
265
+ // Element is no longer visible
266
+ hasChanges = true;
267
+ return null;
268
+ }
269
+
270
+ // Check if position actually changed
271
+ if (
272
+ Math.abs(el.rect.left - visibleRect.left) > 1 ||
273
+ Math.abs(el.rect.top - visibleRect.top) > 1 ||
274
+ Math.abs(el.rect.width - visibleRect.width) > 1 ||
275
+ Math.abs(el.rect.height - visibleRect.height) > 1
276
+ ) {
277
+ hasChanges = true;
278
+ return { ...el, rect: visibleRect };
279
+ }
280
+
281
+ return el;
282
+ }).filter((el): el is DetectedElement => el !== null);
283
+
284
+ return hasChanges ? updated : prevElements;
285
+ });
286
+ }, [enabled]);
287
+
288
+ /**
289
+ * Rescan trigger (exposed to parent)
290
+ */
291
+ const rescan = useCallback(() => {
292
+ performScan();
293
+ }, [performScan]);
294
+
295
+ /**
296
+ * Reset all original states
297
+ */
298
+ const resetStates = useCallback(() => {
299
+ originalLogoStatesRef.current = {};
300
+ originalTextStatesRef.current = {};
301
+ }, []);
302
+
303
+ // Perform legacy ID migration on mount
304
+ useEffect(() => {
305
+ performLegacyMigration();
306
+ }, []);
307
+
308
+ // Main effect: Set up MutationObserver and position tracking
309
+ useEffect(() => {
310
+ mountedRef.current = true;
311
+
312
+ if (!enabled) {
313
+ setElements([]);
314
+ return;
315
+ }
316
+
317
+ // Initial scan
318
+ performScan();
319
+
320
+ // Set up MutationObserver for DOM changes
321
+ let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
322
+
323
+ const observer = new MutationObserver((mutations) => {
324
+ // Skip mutations inside DevTools
325
+ const hasRelevantMutation = mutations.some((mutation) => {
326
+ const target = mutation.target as Element;
327
+ return !target.closest?.("[data-sonance-devtools]");
328
+ });
329
+
330
+ if (!hasRelevantMutation) return;
331
+
332
+ // Debounce to batch rapid changes
333
+ if (debounceTimeout) {
334
+ clearTimeout(debounceTimeout);
335
+ }
336
+
337
+ debounceTimeout = setTimeout(() => {
338
+ debounceTimeout = null;
339
+ performScan();
340
+ }, MUTATION_DEBOUNCE_MS);
341
+ });
342
+
343
+ observer.observe(document.body, {
344
+ childList: true,
345
+ subtree: true,
346
+ attributes: true,
347
+ attributeFilter: ["class", "style", "src", "data-state"],
348
+ });
349
+
350
+ // Set up scroll/resize listeners for position updates
351
+ const handlePositionUpdate = () => {
352
+ if (rafIdRef.current) {
353
+ cancelAnimationFrame(rafIdRef.current);
354
+ }
355
+ rafIdRef.current = requestAnimationFrame(updatePositions);
356
+ };
357
+
358
+ window.addEventListener("scroll", handlePositionUpdate, true);
359
+ window.addEventListener("resize", handlePositionUpdate);
360
+
361
+ // Cleanup
362
+ return () => {
363
+ mountedRef.current = false;
364
+ observer.disconnect();
365
+
366
+ if (debounceTimeout) {
367
+ clearTimeout(debounceTimeout);
368
+ }
369
+ if (pendingScanRef.current) {
370
+ clearTimeout(pendingScanRef.current);
371
+ }
372
+ if (rafIdRef.current) {
373
+ cancelAnimationFrame(rafIdRef.current);
374
+ }
375
+
376
+ window.removeEventListener("scroll", handlePositionUpdate, true);
377
+ window.removeEventListener("resize", handlePositionUpdate);
378
+ };
379
+ }, [enabled, performScan, updatePositions]);
380
+
381
+ // Re-scan when filters change
382
+ useEffect(() => {
383
+ if (enabled) {
384
+ performScan();
385
+ }
386
+ }, [enabled, filters, inspectorEnabled, performScan]);
387
+
388
+ return {
389
+ elements,
390
+ originalLogoStates: originalLogoStatesRef.current,
391
+ originalTextStates: originalTextStatesRef.current,
392
+ rescan,
393
+ resetStates,
394
+ };
395
+ }
396
+
397
+ // Re-export utilities for use in other components
398
+ export { extractLogoName };