@buoy-gg/core 1.7.8 → 2.1.2

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 (49) hide show
  1. package/lib/commonjs/Buoy.js +104 -0
  2. package/lib/commonjs/floatingMenu/AppHost.js +2 -2
  3. package/lib/commonjs/floatingMenu/DevToolsSettingsModal.js +18 -279
  4. package/lib/commonjs/floatingMenu/FloatingDevTools.js +8 -1
  5. package/lib/commonjs/floatingMenu/FloatingDevTools.web.js +1 -2
  6. package/lib/commonjs/floatingMenu/FloatingMenu.js +4 -4
  7. package/lib/commonjs/floatingMenu/MinimizedToolsContext.js +2 -2
  8. package/lib/commonjs/floatingMenu/autoDiscoverPresets.js +15 -0
  9. package/lib/commonjs/floatingMenu/dial/DialDevTools.js +4 -4
  10. package/lib/commonjs/floatingMenu/floatingTools.js +268 -74
  11. package/lib/commonjs/index.js +24 -8
  12. package/lib/module/Buoy.js +100 -0
  13. package/lib/module/floatingMenu/AppHost.js +3 -3
  14. package/lib/module/floatingMenu/DevToolsSettingsModal.js +20 -281
  15. package/lib/module/floatingMenu/FloatingDevTools.js +8 -1
  16. package/lib/module/floatingMenu/FloatingDevTools.web.js +1 -2
  17. package/lib/module/floatingMenu/FloatingMenu.js +5 -5
  18. package/lib/module/floatingMenu/MinimizedToolsContext.js +3 -3
  19. package/lib/module/floatingMenu/autoDiscoverPresets.js +15 -0
  20. package/lib/module/floatingMenu/dial/DialDevTools.js +5 -5
  21. package/lib/module/floatingMenu/floatingTools.js +269 -75
  22. package/lib/module/index.js +3 -1
  23. package/lib/typescript/commonjs/Buoy.d.ts +79 -0
  24. package/lib/typescript/commonjs/Buoy.d.ts.map +1 -0
  25. package/lib/typescript/commonjs/floatingMenu/DevToolsSettingsModal.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.d.ts +25 -5
  27. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/floatingMenu/FloatingMenu.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/floatingMenu/autoDiscoverPresets.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/floatingMenu/floatingTools.d.ts +1 -1
  33. package/lib/typescript/commonjs/floatingMenu/floatingTools.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/index.d.ts +4 -2
  35. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  36. package/lib/typescript/module/Buoy.d.ts +79 -0
  37. package/lib/typescript/module/Buoy.d.ts.map +1 -0
  38. package/lib/typescript/module/floatingMenu/DevToolsSettingsModal.d.ts.map +1 -1
  39. package/lib/typescript/module/floatingMenu/FloatingDevTools.d.ts +25 -5
  40. package/lib/typescript/module/floatingMenu/FloatingDevTools.d.ts.map +1 -1
  41. package/lib/typescript/module/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
  42. package/lib/typescript/module/floatingMenu/FloatingMenu.d.ts.map +1 -1
  43. package/lib/typescript/module/floatingMenu/autoDiscoverPresets.d.ts.map +1 -1
  44. package/lib/typescript/module/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
  45. package/lib/typescript/module/floatingMenu/floatingTools.d.ts +1 -1
  46. package/lib/typescript/module/floatingMenu/floatingTools.d.ts.map +1 -1
  47. package/lib/typescript/module/index.d.ts +4 -2
  48. package/lib/typescript/module/index.d.ts.map +1 -1
  49. package/package.json +5 -5
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
4
4
  import { Pressable, StyleSheet, View, Dimensions, Text, Animated, Easing } from "react-native";
5
5
  // Icons are provided by installedApps; no direct icon imports here.
6
6
  import { DialIcon } from "./DialIcon.js";
7
- import { safeGetItem, safeSetItem, useHintsDisabled, devToolsStorageKeys, buoyColors } from "@buoy-gg/shared-ui";
7
+ import { persistentStorage, useHintsDisabled, devToolsStorageKeys, buoyColors } from "@buoy-gg/shared-ui";
8
8
  import { DevToolsSettingsModal, useDevToolsSettings } from "../DevToolsSettingsModal";
9
9
  import { useIsPro } from "@buoy-gg/license";
10
10
  import { useAppHost } from "../AppHost.js";
@@ -55,7 +55,7 @@ export const DialDevTools = ({
55
55
  useEffect(() => {
56
56
  const loadSettingsModalState = async () => {
57
57
  try {
58
- const savedModalOpen = await safeGetItem(devToolsStorageKeys.settings.modalOpen());
58
+ const savedModalOpen = await persistentStorage.getItem(devToolsStorageKeys.settings.modalOpen());
59
59
  if (savedModalOpen === "true") {
60
60
  setIsSettingsModalOpen(true);
61
61
  }
@@ -72,7 +72,7 @@ export const DialDevTools = ({
72
72
  useEffect(() => {
73
73
  // Only persist after initial state is loaded to avoid overwriting with default
74
74
  if (!settingsModalStateLoaded) return;
75
- safeSetItem(devToolsStorageKeys.settings.modalOpen(), isSettingsModalOpen ? "true" : "false").catch(error => {
75
+ persistentStorage.setItem(devToolsStorageKeys.settings.modalOpen(), isSettingsModalOpen ? "true" : "false").catch(error => {
76
76
  // Failed to save settings modal state - continue without persistence
77
77
  console.warn("Failed to save settings modal state:", error);
78
78
  });
@@ -107,7 +107,7 @@ export const DialDevTools = ({
107
107
  }
108
108
  const checkOnboarding = async () => {
109
109
  try {
110
- const hasSeenTooltip = await safeGetItem(ONBOARDING_STORAGE_KEY);
110
+ const hasSeenTooltip = await persistentStorage.getItem(ONBOARDING_STORAGE_KEY);
111
111
  if (!hasSeenTooltip) {
112
112
  // Small delay to let the entrance animations play first
113
113
  setTimeout(() => {
@@ -406,7 +406,7 @@ export const DialDevTools = ({
406
406
  setShowOnboardingTooltip(false);
407
407
 
408
408
  // Save to storage asynchronously in the background
409
- safeSetItem(ONBOARDING_STORAGE_KEY, "true").catch(error => {
409
+ persistentStorage.setItem(ONBOARDING_STORAGE_KEY, "true").catch(error => {
410
410
  // Silently fail - user already saw onboarding, just won't persist
411
411
  console.warn("Failed to save dial onboarding state:", error);
412
412
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect, useMemo, useRef, useState, useContext, createContext, useCallback, Children } from "react";
4
4
  import { Animated, Dimensions, View, Text, TouchableOpacity } from "react-native";
5
- import { getSafeAreaInsets, safeGetItem, safeSetItem, buoyColors } from "@buoy-gg/shared-ui";
5
+ import { getSafeAreaInsets, persistentStorage, buoyColors } from "@buoy-gg/shared-ui";
6
6
  import { DraggableHeader } from "./DraggableHeader.js";
7
7
  import { useSafeAreaInsets } from "@buoy-gg/shared-ui";
8
8
  import { calculateTargetPosition } from "./dial/onboardingConstants.js";
@@ -81,7 +81,13 @@ const STORAGE_KEYS = {
81
81
  BUBBLE_POSITION_Y: "@react_buoy_bubble_position_y"
82
82
  };
83
83
 
84
- // Debug logging removed for production
84
+ // Position constants
85
+ const VISIBLE_HANDLE_WIDTH = 32;
86
+ const EDGE_PADDING = 20;
87
+ const LAYOUT_SETTLE_DELAY_MS = 150;
88
+
89
+ /** Helper to get current value from Animated.Value without triggering updates */
90
+ const getAnimatedValue = value => value.__getValue();
85
91
 
86
92
  // =============================
87
93
  // Position persistence hook
@@ -100,8 +106,9 @@ const STORAGE_KEYS = {
100
106
  * @param props.enabled - Whether position persistence is enabled
101
107
  * @param props.visibleHandleWidth - Width of visible handle when bubble is hidden
102
108
  * @param props.listenersSuspended - Pause automatic listeners without disabling manual saves
109
+ * @param props.onPositionLoaded - Callback fired once position is loaded and set
103
110
  *
104
- * @returns Object containing position management functions
111
+ * @returns Object containing position management functions and loading state
105
112
  *
106
113
  * @performance Uses debounced saving to avoid excessive storage operations
107
114
  * @performance Validates positions against screen boundaries and safe areas
@@ -112,33 +119,66 @@ function useFloatingToolsPosition({
112
119
  bubbleHeight = 32,
113
120
  enabled = true,
114
121
  visibleHandleWidth = 32,
115
- listenersSuspended = false
122
+ listenersSuspended = false,
123
+ onPositionLoaded
116
124
  }) {
117
- const isInitialized = useRef(false);
125
+ // Use state instead of ref so we can react to changes and prevent races
126
+ const [isInitialized, setIsInitialized] = useState(false);
118
127
  const saveTimeoutRef = useRef(undefined);
128
+ // Track if component is mounted to prevent state updates after unmount
129
+ const isMountedRef = useRef(true);
130
+
131
+ // Cleanup on unmount - cancel any pending saves
132
+ useEffect(() => {
133
+ isMountedRef.current = true;
134
+ return () => {
135
+ isMountedRef.current = false;
136
+ // Clear any pending debounced saves on unmount to prevent saving stale/invalid positions
137
+ if (saveTimeoutRef.current) {
138
+ clearTimeout(saveTimeoutRef.current);
139
+ saveTimeoutRef.current = undefined;
140
+ }
141
+ };
142
+ }, []);
119
143
  const savePosition = useCallback(async (x, y) => {
120
144
  if (!enabled) return;
145
+ // Don't save if component unmounted or position is clearly invalid
146
+ if (!isMountedRef.current) return;
147
+ // Guard against saving invalid positions (e.g., during HMR with stale dimensions)
148
+ const {
149
+ width: screenWidth
150
+ } = Dimensions.get("window");
151
+ if (screenWidth <= 0 || x < 0 || y < 0) return;
121
152
  try {
122
- await Promise.all([safeSetItem(STORAGE_KEYS.BUBBLE_POSITION_X, x.toString()), safeSetItem(STORAGE_KEYS.BUBBLE_POSITION_Y, y.toString())]);
153
+ await Promise.all([persistentStorage.setItem(STORAGE_KEYS.BUBBLE_POSITION_X, x.toString()), persistentStorage.setItem(STORAGE_KEYS.BUBBLE_POSITION_Y, y.toString())]);
123
154
  } catch (error) {
124
155
  // Failed to save position - continue without persistence
125
156
  }
126
157
  }, [enabled]);
127
158
  const debouncedSavePosition = useCallback((x, y) => {
159
+ // Don't schedule saves if unmounted
160
+ if (!isMountedRef.current) return;
128
161
  if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
129
- saveTimeoutRef.current = setTimeout(() => savePosition(x, y), 500);
162
+ saveTimeoutRef.current = setTimeout(() => {
163
+ // Double-check mount status before saving
164
+ if (isMountedRef.current) {
165
+ savePosition(x, y);
166
+ }
167
+ }, 500);
130
168
  }, [savePosition]);
131
169
  const loadPosition = useCallback(async () => {
132
170
  if (!enabled) return null;
133
171
  try {
134
- const [xStr, yStr] = await Promise.all([safeGetItem(STORAGE_KEYS.BUBBLE_POSITION_X), safeGetItem(STORAGE_KEYS.BUBBLE_POSITION_Y)]);
172
+ const [xStr, yStr] = await Promise.all([persistentStorage.getItem(STORAGE_KEYS.BUBBLE_POSITION_X), persistentStorage.getItem(STORAGE_KEYS.BUBBLE_POSITION_Y)]);
135
173
  if (xStr !== null && yStr !== null) {
136
174
  const x = parseFloat(xStr);
137
175
  const y = parseFloat(yStr);
138
- if (!Number.isNaN(x) && !Number.isNaN(y)) return {
139
- x,
140
- y
141
- };
176
+ if (!Number.isNaN(x) && !Number.isNaN(y)) {
177
+ return {
178
+ x,
179
+ y
180
+ };
181
+ }
142
182
  }
143
183
  } catch (error) {
144
184
  // Failed to load position - use default
@@ -151,62 +191,121 @@ function useFloatingToolsPosition({
151
191
  height: screenHeight
152
192
  } = Dimensions.get("window");
153
193
  const safeArea = getSafeAreaInsets();
194
+
195
+ // Guard against invalid screen dimensions (can happen during HMR)
196
+ // Return the position as-is but mark as invalid so caller knows not to trust it
197
+ if (screenWidth <= 0 || screenHeight <= 0) {
198
+ return {
199
+ ...position,
200
+ isValid: false
201
+ };
202
+ }
203
+
154
204
  // Prevent going off left, top, and bottom edges with safe area
155
205
  // Allow pushing off-screen to the right so only the grab handle remains visible
156
- const minX = safeArea.left; // Respect safe area left
157
- const maxX = screenWidth - visibleHandleWidth; // no right padding, ensure handle is visible
158
- // Add small padding below the safe area top to ensure bubble doesn't go behind notch
159
- const minY = safeArea.top + 20; // Ensure bubble is below safe area
206
+ const minX = safeArea.left;
207
+ const maxX = screenWidth - visibleHandleWidth;
208
+ // Add padding below the safe area top to ensure bubble doesn't go behind notch
209
+ const minY = safeArea.top + EDGE_PADDING;
160
210
  const maxY = screenHeight - bubbleHeight - safeArea.bottom; // Respect safe area bottom
161
211
  const clamped = {
162
212
  x: Math.max(minX, Math.min(position.x, maxX)),
163
- y: Math.max(minY, Math.min(position.y, maxY))
213
+ y: Math.max(minY, Math.min(position.y, maxY)),
214
+ isValid: true
164
215
  };
165
216
  return clamped;
166
217
  }, [visibleHandleWidth, bubbleHeight]);
167
218
  useEffect(() => {
168
- if (!enabled || isInitialized.current) return;
219
+ if (!enabled || isInitialized) return;
220
+ let cancelled = false;
169
221
  const restore = async () => {
170
222
  const saved = await loadPosition();
223
+
224
+ // Check if cancelled or unmounted before updating state
225
+ if (cancelled || !isMountedRef.current) return;
226
+ const {
227
+ width: screenWidth,
228
+ height: screenHeight
229
+ } = Dimensions.get("window");
230
+ const safeArea = getSafeAreaInsets();
231
+
232
+ // If dimensions are invalid (HMR scenario), wait and retry
233
+ if (screenWidth <= 0 || screenHeight <= 0) {
234
+ // Schedule a retry after a short delay
235
+ setTimeout(() => {
236
+ if (isMountedRef.current && !cancelled) {
237
+ restore();
238
+ }
239
+ }, 100);
240
+ return;
241
+ }
242
+ let finalPosition;
243
+ let wasHidden = false;
171
244
  if (saved) {
172
245
  const validated = validatePosition(saved);
246
+
247
+ // If validation returned invalid, retry later
248
+ if (!validated.isValid) {
249
+ setTimeout(() => {
250
+ if (isMountedRef.current && !cancelled) {
251
+ restore();
252
+ }
253
+ }, 100);
254
+ return;
255
+ }
256
+
173
257
  // Check if the saved position is out of bounds
174
258
  const wasOutOfBounds = Math.abs(saved.x - validated.x) > 5 || Math.abs(saved.y - validated.y) > 5;
175
259
  if (wasOutOfBounds) {
176
260
  // Save the corrected position
177
261
  await savePosition(validated.x, validated.y);
178
262
  }
179
- animatedPosition.setValue(validated);
263
+ finalPosition = {
264
+ x: validated.x,
265
+ y: validated.y
266
+ };
267
+
268
+ // Check if loaded in hidden state (bubble pushed to right edge)
269
+ wasHidden = validated.x >= screenWidth - visibleHandleWidth - 5;
180
270
  } else {
181
- const {
182
- width: screenWidth,
183
- height: screenHeight
184
- } = Dimensions.get("window");
185
- const safeArea = getSafeAreaInsets();
186
- const defaultY = Math.max(safeArea.top + 20, Math.min(100, screenHeight - bubbleHeight - safeArea.bottom));
187
- animatedPosition.setValue({
188
- x: screenWidth - bubbleWidth - 20,
189
- y: defaultY // Ensure it's within safe area bounds
190
- });
271
+ const defaultY = Math.max(safeArea.top + EDGE_PADDING, Math.min(100, screenHeight - bubbleHeight - safeArea.bottom));
272
+ finalPosition = {
273
+ x: screenWidth - bubbleWidth - EDGE_PADDING,
274
+ y: defaultY
275
+ };
191
276
  }
192
- isInitialized.current = true;
277
+
278
+ // Final mount check before updating
279
+ if (cancelled || !isMountedRef.current) return;
280
+ animatedPosition.setValue(finalPosition);
281
+ setIsInitialized(true);
282
+ onPositionLoaded?.(finalPosition, wasHidden);
193
283
  };
194
284
  restore();
195
- }, [enabled, animatedPosition, loadPosition, validatePosition, savePosition, bubbleWidth, bubbleHeight]);
285
+ return () => {
286
+ cancelled = true;
287
+ };
288
+ }, [enabled, isInitialized, animatedPosition, loadPosition, validatePosition, savePosition, bubbleWidth, bubbleHeight, visibleHandleWidth, onPositionLoaded]);
289
+
290
+ // Listener effect - now properly depends on isInitialized state
196
291
  useEffect(() => {
197
- if (!enabled || !isInitialized.current || listenersSuspended) return;
292
+ // Don't attach listener until position is loaded
293
+ if (!enabled || !isInitialized || listenersSuspended) return;
198
294
  const listener = animatedPosition.addListener(value => {
199
295
  debouncedSavePosition(value.x, value.y);
200
296
  });
201
297
  return () => {
202
298
  animatedPosition.removeListener(listener);
203
- if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
299
+ // Clear debounce timer when listener is removed
300
+ if (saveTimeoutRef.current) {
301
+ clearTimeout(saveTimeoutRef.current);
302
+ saveTimeoutRef.current = undefined;
303
+ }
204
304
  };
205
- }, [enabled, listenersSuspended, animatedPosition, debouncedSavePosition]);
305
+ }, [enabled, isInitialized, listenersSuspended, animatedPosition, debouncedSavePosition]);
206
306
  return {
207
307
  savePosition,
208
- loadPosition,
209
- isInitialized: isInitialized.current
308
+ isInitialized
210
309
  };
211
310
  }
212
311
 
@@ -368,7 +467,6 @@ export function FloatingTools({
368
467
  }) {
369
468
  // Animated position and drag state
370
469
  const animatedPosition = useRef(new Animated.ValueXY()).current;
371
- const saveTimeoutRef = useRef(null);
372
470
  const [isDragging, setIsDragging] = useState(false);
373
471
  const [bubbleSize, setBubbleSize] = useState({
374
472
  width: 100,
@@ -387,26 +485,44 @@ export function FloatingTools({
387
485
 
388
486
  // Track previous pushToSide value to detect transitions
389
487
  const prevPushToSideRef = useRef(pushToSide);
488
+
489
+ // Track previous bubble width for auto-adjustment when tools are added/removed
490
+ const prevBubbleWidthRef = useRef(null);
491
+
492
+ // Track if layout has settled (after initial render cycle completes)
493
+ const [isLayoutSettled, setIsLayoutSettled] = useState(false);
390
494
  const safeAreaInsets = useSafeAreaInsets();
391
495
  const {
392
496
  width: screenWidth,
393
497
  height: screenHeight
394
498
  } = Dimensions.get("window");
395
499
 
500
+ // Callback when position is loaded - sets hidden state properly without racing
501
+ const handlePositionLoaded = useCallback((position, wasHidden) => {
502
+ if (wasHidden) {
503
+ setIsHidden(true);
504
+ }
505
+ }, []);
506
+
396
507
  // Position persistence (state/IO extracted to hook)
397
508
  const {
398
- savePosition
509
+ savePosition,
510
+ isInitialized: isPositionInitialized
399
511
  } = useFloatingToolsPosition({
400
512
  animatedPosition,
401
513
  bubbleWidth: bubbleSize.width,
402
514
  bubbleHeight: bubbleSize.height,
403
515
  enabled: enablePositionPersistence,
404
- visibleHandleWidth: 32,
405
- listenersSuspended: isDragging
516
+ visibleHandleWidth: VISIBLE_HANDLE_WIDTH,
517
+ listenersSuspended: isDragging,
518
+ onPositionLoaded: handlePositionLoaded
406
519
  });
407
520
 
408
521
  // Effect to handle pushToSide prop changes
409
522
  useEffect(() => {
523
+ // Don't process pushToSide until position is initialized (when persistence enabled)
524
+ if (enablePositionPersistence && !isPositionInitialized) return;
525
+
410
526
  // Reset user override when pushToSide becomes true (dial/modal opens)
411
527
  // This allows auto-hide to work after user manually showed the menu
412
528
  if (!prevPushToSideRef.current && pushToSide) {
@@ -421,15 +537,15 @@ export function FloatingTools({
421
537
  // 4. User hasn't manually restored (userWantsVisibleRef)
422
538
  if (pushToSide && !isHidden && !isDragging && !userWantsVisibleRef.current) {
423
539
  // Push to side
424
- const currentX = animatedPosition.x.__getValue();
425
- const currentY = animatedPosition.y.__getValue();
540
+ const currentX = getAnimatedValue(animatedPosition.x);
541
+ const currentY = getAnimatedValue(animatedPosition.y);
426
542
 
427
543
  // Save current position
428
544
  savedPositionRef.current = {
429
545
  x: currentX,
430
546
  y: currentY
431
547
  };
432
- const hiddenX = screenWidth - 32; // Show only the grabber
548
+ const hiddenX = screenWidth - VISIBLE_HANDLE_WIDTH;
433
549
  isPushedBySideRef.current = true;
434
550
  setIsHidden(true);
435
551
  Animated.timing(animatedPosition, {
@@ -464,22 +580,92 @@ export function FloatingTools({
464
580
  });
465
581
  }
466
582
  }
467
- }, [pushToSide, isHidden, isDragging, screenWidth, animatedPosition, savePosition]);
583
+ }, [enablePositionPersistence, isPositionInitialized, pushToSide, isHidden, isDragging, screenWidth, animatedPosition, savePosition]);
584
+
585
+ // Hidden state is now set via onPositionLoaded callback - no racing effect needed
468
586
 
469
- // Check if bubble is in hidden position on load
587
+ // Wait for layout to settle before enabling width tracking
588
+ // This prevents false-positive width changes during initial render when
589
+ // the bubble measures multiple times as children render
470
590
  useEffect(() => {
471
- if (!enablePositionPersistence) return;
472
- const checkHiddenState = () => {
473
- const currentX = animatedPosition.x.__getValue();
474
- // Check if bubble is at the hidden position (showing only grabber)
475
- if (currentX >= screenWidth - 32 - 5) {
476
- setIsHidden(true);
477
- }
478
- };
479
- // Delay check to ensure position is loaded
480
- const timer = setTimeout(checkHiddenState, 100);
591
+ if (!isPositionInitialized || isLayoutSettled) return;
592
+ const timer = setTimeout(() => {
593
+ // Capture the current width as the baseline AFTER layout has settled
594
+ prevBubbleWidthRef.current = bubbleSize.width;
595
+ setIsLayoutSettled(true);
596
+ }, LAYOUT_SETTLE_DELAY_MS);
481
597
  return () => clearTimeout(timer);
482
- }, [enablePositionPersistence, animatedPosition, screenWidth]);
598
+ }, [isPositionInitialized, isLayoutSettled, bubbleSize.width]);
599
+
600
+ // Auto-adjust position when bubble width changes (tools added/removed)
601
+ // This keeps the bubble's right edge in the same relative position
602
+ useEffect(() => {
603
+ // Skip if position not initialized yet
604
+ if (!isPositionInitialized) return;
605
+
606
+ // Skip until layout has settled (prevents false-positive width changes during initial render)
607
+ if (!isLayoutSettled) return;
608
+
609
+ // Skip if prevBubbleWidthRef hasn't been set yet
610
+ if (prevBubbleWidthRef.current === null) return;
611
+
612
+ // Skip if currently dragging - let drag handle position
613
+ if (isDragging) {
614
+ prevBubbleWidthRef.current = bubbleSize.width;
615
+ return;
616
+ }
617
+ const prevWidth = prevBubbleWidthRef.current;
618
+ const newWidth = bubbleSize.width;
619
+ const widthDelta = newWidth - prevWidth;
620
+
621
+ // Skip if no significant change (avoid jitter)
622
+ if (Math.abs(widthDelta) < 2) {
623
+ return;
624
+ }
625
+
626
+ // Update ref first
627
+ prevBubbleWidthRef.current = newWidth;
628
+
629
+ // If hidden, only adjust the saved position (not the current animated position)
630
+ if (isHidden) {
631
+ if (savedPositionRef.current) {
632
+ const adjustedX = savedPositionRef.current.x - widthDelta;
633
+ // Clamp to valid bounds
634
+ savedPositionRef.current.x = Math.max(safeAreaInsets.left, Math.min(adjustedX, screenWidth - newWidth - EDGE_PADDING));
635
+ }
636
+ return;
637
+ }
638
+
639
+ // Get current position
640
+ const currentX = getAnimatedValue(animatedPosition.x);
641
+ const currentY = getAnimatedValue(animatedPosition.y);
642
+
643
+ // Calculate new X position
644
+ // If bubble grew (positive delta), move left (decrease X)
645
+ // If bubble shrunk (negative delta), move right (increase X)
646
+ const newX = currentX - widthDelta;
647
+
648
+ // Validate new position is within bounds
649
+ const clampedX = Math.max(safeAreaInsets.left, Math.min(newX, screenWidth - VISIBLE_HANDLE_WIDTH));
650
+
651
+ // Only animate if position actually changed
652
+ if (Math.abs(clampedX - currentX) > 1) {
653
+ Animated.timing(animatedPosition, {
654
+ toValue: {
655
+ x: clampedX,
656
+ y: currentY
657
+ },
658
+ duration: 150,
659
+ useNativeDriver: false
660
+ }).start(() => {
661
+ savePosition(clampedX, currentY);
662
+ // Update savedPositionRef so show/hide works correctly
663
+ if (savedPositionRef.current) {
664
+ savedPositionRef.current.x = clampedX;
665
+ }
666
+ });
667
+ }
668
+ }, [bubbleSize.width, isPositionInitialized, isLayoutSettled, isDragging, isHidden, animatedPosition, screenWidth, safeAreaInsets.left, savePosition]);
483
669
 
484
670
  // Default position when persistence disabled or during onboarding
485
671
  useEffect(() => {
@@ -496,29 +682,19 @@ export function FloatingTools({
496
682
  });
497
683
  } else {
498
684
  // Default right-side position
499
- const defaultY = Math.max(safeAreaInsets.top + 20, Math.min(100, screenHeight - bubbleSize.height - safeAreaInsets.bottom));
685
+ const defaultY = Math.max(safeAreaInsets.top + EDGE_PADDING, Math.min(100, screenHeight - bubbleSize.height - safeAreaInsets.bottom));
500
686
  animatedPosition.setValue({
501
- x: screenWidth - bubbleSize.width - 20,
687
+ x: screenWidth - bubbleSize.width - EDGE_PADDING,
502
688
  y: defaultY
503
689
  });
504
690
  }
505
691
  }
506
692
  }, [enablePositionPersistence, centerOnboarding, animatedPosition, bubbleSize.width, bubbleSize.height, safeAreaInsets.top, safeAreaInsets.bottom, screenWidth, screenHeight]);
507
693
 
508
- // Cleanup timeout on component unmount
509
- useEffect(() => {
510
- return () => {
511
- if (saveTimeoutRef.current) {
512
- clearTimeout(saveTimeoutRef.current);
513
- saveTimeoutRef.current = null;
514
- }
515
- };
516
- }, []);
517
-
518
694
  // Toggle hide/show function
519
695
  const toggleHideShow = useCallback(() => {
520
- const currentX = animatedPosition.x.__getValue();
521
- const currentY = animatedPosition.y.__getValue();
696
+ const currentX = getAnimatedValue(animatedPosition.x);
697
+ const currentY = getAnimatedValue(animatedPosition.y);
522
698
 
523
699
  // Check if the menu is visually off-screen (more than half hidden to the right)
524
700
  // This handles edge cases where isHidden state might be out of sync with actual position
@@ -533,7 +709,7 @@ export function FloatingTools({
533
709
  targetY = savedPositionRef.current.y;
534
710
  } else {
535
711
  // Default visible position if no saved position or saved position is off-screen
536
- targetX = screenWidth - bubbleSize.width - 20;
712
+ targetX = screenWidth - bubbleSize.width - EDGE_PADDING;
537
713
  targetY = currentY;
538
714
  }
539
715
 
@@ -560,7 +736,7 @@ export function FloatingTools({
560
736
 
561
737
  // User explicitly wants the menu hidden
562
738
  userWantsVisibleRef.current = false;
563
- const hiddenX = screenWidth - 32; // Only show the 32px grabber
739
+ const hiddenX = screenWidth - VISIBLE_HANDLE_WIDTH;
564
740
  setIsHidden(true);
565
741
  Animated.timing(animatedPosition, {
566
742
  toValue: {
@@ -588,8 +764,20 @@ export function FloatingTools({
588
764
  const shouldHide = bubbleMidpoint > screenWidth;
589
765
  if (shouldHide) {
590
766
  // Animate to hidden position (only grabber visible)
591
- const hiddenX = screenWidth - 32; // Only show the 32px grabber
767
+ const hiddenX = screenWidth - VISIBLE_HANDLE_WIDTH;
592
768
  setIsHidden(true);
769
+
770
+ // Update savedPositionRef to preserve the Y position when dragging while hidden
771
+ // This ensures that when showing, the bubble restores to the new Y position
772
+ if (savedPositionRef.current) {
773
+ savedPositionRef.current.y = currentY;
774
+ } else {
775
+ // If no saved position exists, create one with default visible X
776
+ savedPositionRef.current = {
777
+ x: screenWidth - bubbleSize.width - EDGE_PADDING,
778
+ y: currentY
779
+ };
780
+ }
593
781
  Animated.timing(animatedPosition, {
594
782
  toValue: {
595
783
  x: hiddenX,
@@ -602,7 +790,7 @@ export function FloatingTools({
602
790
  });
603
791
  } else {
604
792
  // Check if we're in hidden state and user is pulling it back
605
- if (isHidden && currentX < screenWidth - 32 - 10) {
793
+ if (isHidden && currentX < screenWidth - VISIBLE_HANDLE_WIDTH - 10) {
606
794
  setIsHidden(false);
607
795
  }
608
796
 
@@ -690,6 +878,12 @@ export function FloatingTools({
690
878
 
691
879
  // Width for the minimized tools stack - match the drag handle width
692
880
  const minimizedStackWidth = 32;
881
+
882
+ // Don't render until position is initialized (when persistence enabled)
883
+ // This prevents the bubble from appearing at {0,0} before position loads
884
+ if (enablePositionPersistence && !isPositionInitialized) {
885
+ return null;
886
+ }
693
887
  return /*#__PURE__*/_jsx(Animated.View, {
694
888
  style: bubbleStyle,
695
889
  children: /*#__PURE__*/_jsxs(View, {
@@ -739,7 +933,7 @@ export function FloatingTools({
739
933
  elementSize: bubbleSize,
740
934
  minPosition: {
741
935
  x: safeAreaInsets.left,
742
- y: safeAreaInsets.top + 20
936
+ y: safeAreaInsets.top + EDGE_PADDING
743
937
  },
744
938
  style: dragHandleStyle,
745
939
  enabled: true,
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
 
3
+ // Main SDK entry point
4
+ export { Buoy, default as BuoyDefault } from "./Buoy.js";
3
5
  // Export unified component - primary interface
4
6
  export { FloatingDevTools } from "./floatingMenu/FloatingDevTools";
5
7
  // Export auto-discovery utilities
@@ -26,4 +28,4 @@ export { MinimizedToolsStack } from "./floatingMenu/MinimizedToolsStack.js";
26
28
 
27
29
  // Re-export license functionality from @buoy-gg/license
28
30
  // These are re-exported for convenience so users can import from @buoy-gg/core
29
- export { LicenseManager, useLicense, useFeatureAccess, useSeats, useIsPro, setLicenseKey, isPro, hasEntitlement } from "@buoy-gg/license";
31
+ export { LicenseManager, DeviceRegistrationModal, useLicense, useFeatureAccess, useIsPro, setLicenseKey, isPro, hasEntitlement } from "@buoy-gg/license";
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Buoy SDK - Main entry point for React Buoy configuration
3
+ *
4
+ * Initialize once at app startup, before rendering any components:
5
+ *
6
+ * @example
7
+ * ```tsx
8
+ * import { Buoy } from '@buoy-gg/core';
9
+ *
10
+ * Buoy.init({
11
+ * licenseKey: 'YOUR_LICENSE_KEY',
12
+ * });
13
+ * ```
14
+ */
15
+ export interface BuoyConfig {
16
+ /**
17
+ * Your Buoy Pro license key
18
+ * Get one at https://buoy.gg/pricing
19
+ */
20
+ licenseKey: string;
21
+ }
22
+ /**
23
+ * Buoy SDK namespace
24
+ *
25
+ * Use `Buoy.init()` to configure your license key at app startup.
26
+ */
27
+ export declare const Buoy: {
28
+ /**
29
+ * Initialize Buoy with your license key
30
+ *
31
+ * Call this once at app startup, before rendering FloatingDevTools.
32
+ * Typically placed in App.tsx or index.js.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * import { Buoy } from '@buoy-gg/core';
37
+ *
38
+ * // With hardcoded key
39
+ * Buoy.init({
40
+ * licenseKey: '36063C-27282E-8E73F5-55488F-2213DF-V3',
41
+ * });
42
+ *
43
+ * // With environment variable
44
+ * Buoy.init({
45
+ * licenseKey: process.env.BUOY_LICENSE_KEY!,
46
+ * });
47
+ * ```
48
+ */
49
+ init(config: BuoyConfig): void;
50
+ /**
51
+ * Initialize Buoy asynchronously
52
+ *
53
+ * Use this if you need to wait for initialization to complete.
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * await Buoy.initAsync({
58
+ * licenseKey: 'YOUR_LICENSE_KEY',
59
+ * });
60
+ * console.log('Buoy is ready!');
61
+ * ```
62
+ */
63
+ initAsync(config: BuoyConfig): Promise<boolean>;
64
+ /**
65
+ * Check if Buoy Pro is active
66
+ */
67
+ isPro(): boolean;
68
+ /**
69
+ * Get the current license state
70
+ */
71
+ getState(): Readonly<import("@buoy-gg/license").LicenseState>;
72
+ /**
73
+ * Force device re-registration (debugging helper)
74
+ * Clears cached fingerprint so next init will register device again
75
+ */
76
+ forceReregister(): Promise<void>;
77
+ };
78
+ export default Buoy;
79
+ //# sourceMappingURL=Buoy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Buoy.d.ts","sourceRoot":"","sources":["../../../src/Buoy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,MAAM,WAAW,UAAU;IACzB;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;GAIG;AACH,eAAO,MAAM,IAAI;IACf;;;;;;;;;;;;;;;;;;;;OAoBG;iBACU,UAAU,GAAG,IAAI;IAc9B;;;;;;;;;;;;OAYG;sBACqB,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC;IAUrD;;OAEG;aACM,OAAO;IAIhB;;OAEG;;IAKH;;;OAGG;uBACsB,OAAO,CAAC,IAAI,CAAC;CAGvC,CAAC;AAGF,eAAe,IAAI,CAAC"}