@buoy-gg/core 1.7.8 → 2.1.1

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 (31) hide show
  1. package/lib/commonjs/floatingMenu/AppHost.js +2 -2
  2. package/lib/commonjs/floatingMenu/DevToolsSettingsModal.js +5 -5
  3. package/lib/commonjs/floatingMenu/FloatingDevTools.web.js +1 -2
  4. package/lib/commonjs/floatingMenu/FloatingMenu.js +4 -4
  5. package/lib/commonjs/floatingMenu/MinimizedToolsContext.js +2 -2
  6. package/lib/commonjs/floatingMenu/autoDiscoverPresets.js +15 -0
  7. package/lib/commonjs/floatingMenu/dial/DialDevTools.js +4 -4
  8. package/lib/commonjs/floatingMenu/floatingTools.js +218 -38
  9. package/lib/module/floatingMenu/AppHost.js +3 -3
  10. package/lib/module/floatingMenu/DevToolsSettingsModal.js +7 -7
  11. package/lib/module/floatingMenu/FloatingDevTools.web.js +1 -2
  12. package/lib/module/floatingMenu/FloatingMenu.js +5 -5
  13. package/lib/module/floatingMenu/MinimizedToolsContext.js +3 -3
  14. package/lib/module/floatingMenu/autoDiscoverPresets.js +15 -0
  15. package/lib/module/floatingMenu/dial/DialDevTools.js +5 -5
  16. package/lib/module/floatingMenu/floatingTools.js +219 -39
  17. package/lib/typescript/commonjs/floatingMenu/DevToolsSettingsModal.d.ts.map +1 -1
  18. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
  19. package/lib/typescript/commonjs/floatingMenu/FloatingMenu.d.ts.map +1 -1
  20. package/lib/typescript/commonjs/floatingMenu/autoDiscoverPresets.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/floatingMenu/floatingTools.d.ts +1 -1
  23. package/lib/typescript/commonjs/floatingMenu/floatingTools.d.ts.map +1 -1
  24. package/lib/typescript/module/floatingMenu/DevToolsSettingsModal.d.ts.map +1 -1
  25. package/lib/typescript/module/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
  26. package/lib/typescript/module/floatingMenu/FloatingMenu.d.ts.map +1 -1
  27. package/lib/typescript/module/floatingMenu/autoDiscoverPresets.d.ts.map +1 -1
  28. package/lib/typescript/module/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
  29. package/lib/typescript/module/floatingMenu/floatingTools.d.ts +1 -1
  30. package/lib/typescript/module/floatingMenu/floatingTools.d.ts.map +1 -1
  31. package/package.json +5 -5
@@ -92,7 +92,7 @@ const AppHostProvider = ({
92
92
  (0, _react.useEffect)(() => {
93
93
  const restoreOpenApps = async () => {
94
94
  try {
95
- const saved = await (0, _sharedUi.safeGetItem)(STORAGE_KEY_OPEN_APPS);
95
+ const saved = await _sharedUi.persistentStorage.getItem(STORAGE_KEY_OPEN_APPS);
96
96
  if (saved) {
97
97
  const parsed = JSON.parse(saved);
98
98
  // Handle both old format (string[]) and new format (PersistedAppState[])
@@ -138,7 +138,7 @@ const AppHostProvider = ({
138
138
  id: app.id,
139
139
  minimized: app.minimized ?? false
140
140
  }));
141
- (0, _sharedUi.safeSetItem)(STORAGE_KEY_OPEN_APPS, JSON.stringify(appStates));
141
+ _sharedUi.persistentStorage.setItem(STORAGE_KEY_OPEN_APPS, JSON.stringify(appStates));
142
142
  }, PERSISTENCE_DELAY);
143
143
  return () => {
144
144
  if (persistenceTimeoutRef.current) {
@@ -184,7 +184,7 @@ const DevToolsSettingsModal = ({
184
184
  (0, _react.useEffect)(() => {
185
185
  const loadActiveTab = async () => {
186
186
  try {
187
- const savedTab = await (0, _sharedUi.safeGetItem)(_sharedUi.devToolsStorageKeys.settings.activeTab());
187
+ const savedTab = await _sharedUi.persistentStorage.getItem(_sharedUi.devToolsStorageKeys.settings.activeTab());
188
188
  if (savedTab && ["dial", "floating", "settings", "pro"].includes(savedTab)) {
189
189
  setActiveTab(savedTab);
190
190
  }
@@ -201,7 +201,7 @@ const DevToolsSettingsModal = ({
201
201
  (0, _react.useEffect)(() => {
202
202
  // Only persist after initial state is loaded to avoid overwriting with default
203
203
  if (!activeTabLoaded) return;
204
- (0, _sharedUi.safeSetItem)(_sharedUi.devToolsStorageKeys.settings.activeTab(), activeTab).catch(error => {
204
+ _sharedUi.persistentStorage.setItem(_sharedUi.devToolsStorageKeys.settings.activeTab(), activeTab).catch(error => {
205
205
  // Failed to save active tab - continue without persistence
206
206
  console.warn("Failed to save settings active tab:", error);
207
207
  });
@@ -244,7 +244,7 @@ const DevToolsSettingsModal = ({
244
244
 
245
245
  const loadSettings = (0, _react.useCallback)(async () => {
246
246
  try {
247
- const savedSettings = await (0, _sharedUi.safeGetItem)(STORAGE_KEY);
247
+ const savedSettings = await _sharedUi.persistentStorage.getItem(STORAGE_KEY);
248
248
  if (savedSettings) {
249
249
  const parsed = JSON.parse(savedSettings);
250
250
  const merged = mergeWithDefaults(defaultSettings, parsed, {
@@ -288,7 +288,7 @@ const DevToolsSettingsModal = ({
288
288
  ...newSettings,
289
289
  dialTools: enforceDialLimit(newSettings.dialTools)
290
290
  };
291
- await (0, _sharedUi.safeSetItem)(STORAGE_KEY, JSON.stringify(limitedSettings));
291
+ await _sharedUi.persistentStorage.setItem(STORAGE_KEY, JSON.stringify(limitedSettings));
292
292
  setSettings(limitedSettings);
293
293
  onSettingsChange?.(limitedSettings);
294
294
  // Notify listeners (e.g., floating bubble) to refresh immediately
@@ -1438,7 +1438,7 @@ const useDevToolsSettings = () => {
1438
1438
  const [settings, setSettings] = (0, _react.useState)(effectiveDefaults);
1439
1439
  const loadSettings = (0, _react.useCallback)(async () => {
1440
1440
  try {
1441
- const savedSettings = await (0, _sharedUi.safeGetItem)(STORAGE_KEY);
1441
+ const savedSettings = await _sharedUi.persistentStorage.getItem(STORAGE_KEY);
1442
1442
  if (savedSettings) {
1443
1443
  const parsed = JSON.parse(savedSettings);
1444
1444
  const merged = mergeWithDefaults(effectiveDefaults, parsed);
@@ -141,9 +141,8 @@ function FloatingDevTools({
141
141
  visible: isSettingsOpen,
142
142
  onClose: handleCloseSettings,
143
143
  availableTools: availableTools,
144
- onSettingsChange: newSettings => {
144
+ onSettingsChange: _newSettings => {
145
145
  // Settings are already saved by the hook
146
- console.log('Settings updated:', newSettings);
147
146
  }
148
147
  })]
149
148
  });
@@ -53,7 +53,7 @@ const FloatingMenu = ({
53
53
  (0, _react.useEffect)(() => {
54
54
  const loadDialState = async () => {
55
55
  try {
56
- const savedDialOpen = await (0, _sharedUi.safeGetItem)(_sharedUi.devToolsStorageKeys.dial.isOpen());
56
+ const savedDialOpen = await _sharedUi.persistentStorage.getItem(_sharedUi.devToolsStorageKeys.dial.isOpen());
57
57
  if (savedDialOpen === "true") {
58
58
  setShowDial(true);
59
59
  }
@@ -70,7 +70,7 @@ const FloatingMenu = ({
70
70
  (0, _react.useEffect)(() => {
71
71
  // Only persist after initial state is loaded to avoid overwriting with default
72
72
  if (!dialStateLoaded) return;
73
- (0, _sharedUi.safeSetItem)(_sharedUi.devToolsStorageKeys.dial.isOpen(), showDial ? "true" : "false").catch(error => {
73
+ _sharedUi.persistentStorage.setItem(_sharedUi.devToolsStorageKeys.dial.isOpen(), showDial ? "true" : "false").catch(error => {
74
74
  // Failed to save dial state - continue without persistence
75
75
  console.warn("Failed to save dial state:", error);
76
76
  });
@@ -97,7 +97,7 @@ const FloatingMenu = ({
97
97
  }
98
98
  const checkOnboarding = async () => {
99
99
  try {
100
- const hasSeenOnboarding = await (0, _sharedUi.safeGetItem)(FLOATING_MENU_ONBOARDING_KEY);
100
+ const hasSeenOnboarding = await _sharedUi.persistentStorage.getItem(FLOATING_MENU_ONBOARDING_KEY);
101
101
  if (!hasSeenOnboarding) {
102
102
  // Small delay to let the UI settle before showing tooltip
103
103
  setTimeout(() => {
@@ -202,7 +202,7 @@ const FloatingMenu = ({
202
202
  setOnboardingStep(null);
203
203
 
204
204
  // Save to storage asynchronously in the background
205
- (0, _sharedUi.safeSetItem)(FLOATING_MENU_ONBOARDING_KEY, "true").catch(error => {
205
+ _sharedUi.persistentStorage.setItem(FLOATING_MENU_ONBOARDING_KEY, "true").catch(error => {
206
206
  // Silently fail - user already saw onboarding, just won't persist
207
207
  console.warn("Failed to save onboarding state:", error);
208
208
  });
@@ -115,7 +115,7 @@ function MinimizedToolsProvider({
115
115
  (0, _react.useEffect)(() => {
116
116
  const restoreMinimizedTools = async () => {
117
117
  try {
118
- const saved = await (0, _sharedUi.safeGetItem)(STORAGE_KEY);
118
+ const saved = await _sharedUi.persistentStorage.getItem(STORAGE_KEY);
119
119
  if (saved) {
120
120
  const serialized = JSON.parse(saved);
121
121
  // Reconstruct tools with icons
@@ -145,7 +145,7 @@ function MinimizedToolsProvider({
145
145
  icon,
146
146
  ...rest
147
147
  }) => rest);
148
- (0, _sharedUi.safeSetItem)(STORAGE_KEY, JSON.stringify(serialized));
148
+ _sharedUi.persistentStorage.setItem(STORAGE_KEY, JSON.stringify(serialized));
149
149
  }, PERSISTENCE_DELAY);
150
150
  return () => {
151
151
  if (persistenceTimeoutRef.current) {
@@ -194,6 +194,21 @@ function autoDiscoverPresets() {
194
194
  return null;
195
195
  }
196
196
  }
197
+ },
198
+ // Events Timeline
199
+ {
200
+ name: "@buoy-gg/events",
201
+ loader: () => {
202
+ try {
203
+ // @ts-ignore - Dynamic import that may not exist
204
+ const {
205
+ eventsToolPreset
206
+ } = require("@buoy-gg/events");
207
+ return eventsToolPreset;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
197
212
  }];
198
213
 
199
214
  // Attempt to load each preset
@@ -60,7 +60,7 @@ const DialDevTools = ({
60
60
  (0, _react.useEffect)(() => {
61
61
  const loadSettingsModalState = async () => {
62
62
  try {
63
- const savedModalOpen = await (0, _sharedUi.safeGetItem)(_sharedUi.devToolsStorageKeys.settings.modalOpen());
63
+ const savedModalOpen = await _sharedUi.persistentStorage.getItem(_sharedUi.devToolsStorageKeys.settings.modalOpen());
64
64
  if (savedModalOpen === "true") {
65
65
  setIsSettingsModalOpen(true);
66
66
  }
@@ -77,7 +77,7 @@ const DialDevTools = ({
77
77
  (0, _react.useEffect)(() => {
78
78
  // Only persist after initial state is loaded to avoid overwriting with default
79
79
  if (!settingsModalStateLoaded) return;
80
- (0, _sharedUi.safeSetItem)(_sharedUi.devToolsStorageKeys.settings.modalOpen(), isSettingsModalOpen ? "true" : "false").catch(error => {
80
+ _sharedUi.persistentStorage.setItem(_sharedUi.devToolsStorageKeys.settings.modalOpen(), isSettingsModalOpen ? "true" : "false").catch(error => {
81
81
  // Failed to save settings modal state - continue without persistence
82
82
  console.warn("Failed to save settings modal state:", error);
83
83
  });
@@ -112,7 +112,7 @@ const DialDevTools = ({
112
112
  }
113
113
  const checkOnboarding = async () => {
114
114
  try {
115
- const hasSeenTooltip = await (0, _sharedUi.safeGetItem)(ONBOARDING_STORAGE_KEY);
115
+ const hasSeenTooltip = await _sharedUi.persistentStorage.getItem(ONBOARDING_STORAGE_KEY);
116
116
  if (!hasSeenTooltip) {
117
117
  // Small delay to let the entrance animations play first
118
118
  setTimeout(() => {
@@ -411,7 +411,7 @@ const DialDevTools = ({
411
411
  setShowOnboardingTooltip(false);
412
412
 
413
413
  // Save to storage asynchronously in the background
414
- (0, _sharedUi.safeSetItem)(ONBOARDING_STORAGE_KEY, "true").catch(error => {
414
+ _sharedUi.persistentStorage.setItem(ONBOARDING_STORAGE_KEY, "true").catch(error => {
415
415
  // Silently fail - user already saw onboarding, just won't persist
416
416
  console.warn("Failed to save dial onboarding state:", error);
417
417
  });
@@ -104,8 +104,9 @@ const STORAGE_KEYS = {
104
104
  * @param props.enabled - Whether position persistence is enabled
105
105
  * @param props.visibleHandleWidth - Width of visible handle when bubble is hidden
106
106
  * @param props.listenersSuspended - Pause automatic listeners without disabling manual saves
107
+ * @param props.onPositionLoaded - Callback fired once position is loaded and set
107
108
  *
108
- * @returns Object containing position management functions
109
+ * @returns Object containing position management functions and loading state
109
110
  *
110
111
  * @performance Uses debounced saving to avoid excessive storage operations
111
112
  * @performance Validates positions against screen boundaries and safe areas
@@ -116,26 +117,57 @@ function useFloatingToolsPosition({
116
117
  bubbleHeight = 32,
117
118
  enabled = true,
118
119
  visibleHandleWidth = 32,
119
- listenersSuspended = false
120
+ listenersSuspended = false,
121
+ onPositionLoaded
120
122
  }) {
121
- const isInitialized = (0, _react.useRef)(false);
123
+ // Use state instead of ref so we can react to changes and prevent races
124
+ const [isInitialized, setIsInitialized] = (0, _react.useState)(false);
122
125
  const saveTimeoutRef = (0, _react.useRef)(undefined);
126
+ // Track if component is mounted to prevent state updates after unmount
127
+ const isMountedRef = (0, _react.useRef)(true);
128
+
129
+ // Cleanup on unmount - cancel any pending saves
130
+ (0, _react.useEffect)(() => {
131
+ isMountedRef.current = true;
132
+ return () => {
133
+ isMountedRef.current = false;
134
+ // Clear any pending debounced saves on unmount to prevent saving stale/invalid positions
135
+ if (saveTimeoutRef.current) {
136
+ clearTimeout(saveTimeoutRef.current);
137
+ saveTimeoutRef.current = undefined;
138
+ }
139
+ };
140
+ }, []);
123
141
  const savePosition = (0, _react.useCallback)(async (x, y) => {
124
142
  if (!enabled) return;
143
+ // Don't save if component unmounted or position is clearly invalid
144
+ if (!isMountedRef.current) return;
145
+ // Guard against saving invalid positions (e.g., during HMR with stale dimensions)
146
+ const {
147
+ width: screenWidth
148
+ } = _reactNative.Dimensions.get("window");
149
+ if (screenWidth <= 0 || x < 0 || y < 0) return;
125
150
  try {
126
- await Promise.all([(0, _sharedUi.safeSetItem)(STORAGE_KEYS.BUBBLE_POSITION_X, x.toString()), (0, _sharedUi.safeSetItem)(STORAGE_KEYS.BUBBLE_POSITION_Y, y.toString())]);
151
+ await Promise.all([_sharedUi.persistentStorage.setItem(STORAGE_KEYS.BUBBLE_POSITION_X, x.toString()), _sharedUi.persistentStorage.setItem(STORAGE_KEYS.BUBBLE_POSITION_Y, y.toString())]);
127
152
  } catch (error) {
128
153
  // Failed to save position - continue without persistence
129
154
  }
130
155
  }, [enabled]);
131
156
  const debouncedSavePosition = (0, _react.useCallback)((x, y) => {
157
+ // Don't schedule saves if unmounted
158
+ if (!isMountedRef.current) return;
132
159
  if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
133
- saveTimeoutRef.current = setTimeout(() => savePosition(x, y), 500);
160
+ saveTimeoutRef.current = setTimeout(() => {
161
+ // Double-check mount status before saving
162
+ if (isMountedRef.current) {
163
+ savePosition(x, y);
164
+ }
165
+ }, 500);
134
166
  }, [savePosition]);
135
167
  const loadPosition = (0, _react.useCallback)(async () => {
136
168
  if (!enabled) return null;
137
169
  try {
138
- const [xStr, yStr] = await Promise.all([(0, _sharedUi.safeGetItem)(STORAGE_KEYS.BUBBLE_POSITION_X), (0, _sharedUi.safeGetItem)(STORAGE_KEYS.BUBBLE_POSITION_Y)]);
170
+ const [xStr, yStr] = await Promise.all([_sharedUi.persistentStorage.getItem(STORAGE_KEYS.BUBBLE_POSITION_X), _sharedUi.persistentStorage.getItem(STORAGE_KEYS.BUBBLE_POSITION_Y)]);
139
171
  if (xStr !== null && yStr !== null) {
140
172
  const x = parseFloat(xStr);
141
173
  const y = parseFloat(yStr);
@@ -155,6 +187,16 @@ function useFloatingToolsPosition({
155
187
  height: screenHeight
156
188
  } = _reactNative.Dimensions.get("window");
157
189
  const safeArea = (0, _sharedUi.getSafeAreaInsets)();
190
+
191
+ // Guard against invalid screen dimensions (can happen during HMR)
192
+ // Return the position as-is but mark as invalid so caller knows not to trust it
193
+ if (screenWidth <= 0 || screenHeight <= 0) {
194
+ return {
195
+ ...position,
196
+ isValid: false
197
+ };
198
+ }
199
+
158
200
  // Prevent going off left, top, and bottom edges with safe area
159
201
  // Allow pushing off-screen to the right so only the grab handle remains visible
160
202
  const minX = safeArea.left; // Respect safe area left
@@ -164,53 +206,103 @@ function useFloatingToolsPosition({
164
206
  const maxY = screenHeight - bubbleHeight - safeArea.bottom; // Respect safe area bottom
165
207
  const clamped = {
166
208
  x: Math.max(minX, Math.min(position.x, maxX)),
167
- y: Math.max(minY, Math.min(position.y, maxY))
209
+ y: Math.max(minY, Math.min(position.y, maxY)),
210
+ isValid: true
168
211
  };
169
212
  return clamped;
170
213
  }, [visibleHandleWidth, bubbleHeight]);
171
214
  (0, _react.useEffect)(() => {
172
- if (!enabled || isInitialized.current) return;
215
+ if (!enabled || isInitialized) return;
216
+ let cancelled = false;
173
217
  const restore = async () => {
174
218
  const saved = await loadPosition();
219
+
220
+ // Check if cancelled or unmounted before updating state
221
+ if (cancelled || !isMountedRef.current) return;
222
+ const {
223
+ width: screenWidth,
224
+ height: screenHeight
225
+ } = _reactNative.Dimensions.get("window");
226
+ const safeArea = (0, _sharedUi.getSafeAreaInsets)();
227
+
228
+ // If dimensions are invalid (HMR scenario), wait and retry
229
+ if (screenWidth <= 0 || screenHeight <= 0) {
230
+ // Schedule a retry after a short delay
231
+ setTimeout(() => {
232
+ if (isMountedRef.current && !cancelled) {
233
+ restore();
234
+ }
235
+ }, 100);
236
+ return;
237
+ }
238
+ let finalPosition;
239
+ let wasHidden = false;
175
240
  if (saved) {
176
241
  const validated = validatePosition(saved);
242
+
243
+ // If validation returned invalid, retry later
244
+ if (!validated.isValid) {
245
+ setTimeout(() => {
246
+ if (isMountedRef.current && !cancelled) {
247
+ restore();
248
+ }
249
+ }, 100);
250
+ return;
251
+ }
252
+
177
253
  // Check if the saved position is out of bounds
178
254
  const wasOutOfBounds = Math.abs(saved.x - validated.x) > 5 || Math.abs(saved.y - validated.y) > 5;
179
255
  if (wasOutOfBounds) {
180
256
  // Save the corrected position
181
257
  await savePosition(validated.x, validated.y);
182
258
  }
183
- animatedPosition.setValue(validated);
259
+ finalPosition = {
260
+ x: validated.x,
261
+ y: validated.y
262
+ };
263
+
264
+ // Check if loaded in hidden state (bubble pushed to right edge)
265
+ wasHidden = validated.x >= screenWidth - visibleHandleWidth - 5;
184
266
  } else {
185
- const {
186
- width: screenWidth,
187
- height: screenHeight
188
- } = _reactNative.Dimensions.get("window");
189
- const safeArea = (0, _sharedUi.getSafeAreaInsets)();
190
267
  const defaultY = Math.max(safeArea.top + 20, Math.min(100, screenHeight - bubbleHeight - safeArea.bottom));
191
- animatedPosition.setValue({
268
+ finalPosition = {
192
269
  x: screenWidth - bubbleWidth - 20,
193
- y: defaultY // Ensure it's within safe area bounds
194
- });
270
+ y: defaultY
271
+ };
195
272
  }
196
- isInitialized.current = true;
273
+
274
+ // Final mount check before updating
275
+ if (cancelled || !isMountedRef.current) return;
276
+ animatedPosition.setValue(finalPosition);
277
+ setIsInitialized(true);
278
+ onPositionLoaded?.(finalPosition, wasHidden);
197
279
  };
198
280
  restore();
199
- }, [enabled, animatedPosition, loadPosition, validatePosition, savePosition, bubbleWidth, bubbleHeight]);
281
+ return () => {
282
+ cancelled = true;
283
+ };
284
+ }, [enabled, isInitialized, animatedPosition, loadPosition, validatePosition, savePosition, bubbleWidth, bubbleHeight, visibleHandleWidth, onPositionLoaded]);
285
+
286
+ // Listener effect - now properly depends on isInitialized state
200
287
  (0, _react.useEffect)(() => {
201
- if (!enabled || !isInitialized.current || listenersSuspended) return;
288
+ // Don't attach listener until position is loaded
289
+ if (!enabled || !isInitialized || listenersSuspended) return;
202
290
  const listener = animatedPosition.addListener(value => {
203
291
  debouncedSavePosition(value.x, value.y);
204
292
  });
205
293
  return () => {
206
294
  animatedPosition.removeListener(listener);
207
- if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
295
+ // Clear debounce timer when listener is removed
296
+ if (saveTimeoutRef.current) {
297
+ clearTimeout(saveTimeoutRef.current);
298
+ saveTimeoutRef.current = undefined;
299
+ }
208
300
  };
209
- }, [enabled, listenersSuspended, animatedPosition, debouncedSavePosition]);
301
+ }, [enabled, isInitialized, listenersSuspended, animatedPosition, debouncedSavePosition]);
210
302
  return {
211
303
  savePosition,
212
304
  loadPosition,
213
- isInitialized: isInitialized.current
305
+ isInitialized
214
306
  };
215
307
  }
216
308
 
@@ -391,26 +483,41 @@ function FloatingTools({
391
483
 
392
484
  // Track previous pushToSide value to detect transitions
393
485
  const prevPushToSideRef = (0, _react.useRef)(pushToSide);
486
+
487
+ // Track previous bubble width for auto-adjustment when tools are added/removed
488
+ const prevBubbleWidthRef = (0, _react.useRef)(null);
394
489
  const safeAreaInsets = (0, _sharedUi.useSafeAreaInsets)();
395
490
  const {
396
491
  width: screenWidth,
397
492
  height: screenHeight
398
493
  } = _reactNative.Dimensions.get("window");
399
494
 
495
+ // Callback when position is loaded - sets hidden state properly without racing
496
+ const handlePositionLoaded = (0, _react.useCallback)((position, wasHidden) => {
497
+ if (wasHidden) {
498
+ setIsHidden(true);
499
+ }
500
+ }, []);
501
+
400
502
  // Position persistence (state/IO extracted to hook)
401
503
  const {
402
- savePosition
504
+ savePosition,
505
+ isInitialized: isPositionInitialized
403
506
  } = useFloatingToolsPosition({
404
507
  animatedPosition,
405
508
  bubbleWidth: bubbleSize.width,
406
509
  bubbleHeight: bubbleSize.height,
407
510
  enabled: enablePositionPersistence,
408
511
  visibleHandleWidth: 32,
409
- listenersSuspended: isDragging
512
+ listenersSuspended: isDragging,
513
+ onPositionLoaded: handlePositionLoaded
410
514
  });
411
515
 
412
516
  // Effect to handle pushToSide prop changes
413
517
  (0, _react.useEffect)(() => {
518
+ // Don't process pushToSide until position is initialized (when persistence enabled)
519
+ if (enablePositionPersistence && !isPositionInitialized) return;
520
+
414
521
  // Reset user override when pushToSide becomes true (dial/modal opens)
415
522
  // This allows auto-hide to work after user manually showed the menu
416
523
  if (!prevPushToSideRef.current && pushToSide) {
@@ -468,22 +575,77 @@ function FloatingTools({
468
575
  });
469
576
  }
470
577
  }
471
- }, [pushToSide, isHidden, isDragging, screenWidth, animatedPosition, savePosition]);
578
+ }, [enablePositionPersistence, isPositionInitialized, pushToSide, isHidden, isDragging, screenWidth, animatedPosition, savePosition]);
579
+
580
+ // Hidden state is now set via onPositionLoaded callback - no racing effect needed
472
581
 
473
- // Check if bubble is in hidden position on load
582
+ // Auto-adjust position when bubble width changes (tools added/removed)
583
+ // This keeps the bubble's right edge in the same relative position
474
584
  (0, _react.useEffect)(() => {
475
- if (!enablePositionPersistence) return;
476
- const checkHiddenState = () => {
477
- const currentX = animatedPosition.x.__getValue();
478
- // Check if bubble is at the hidden position (showing only grabber)
479
- if (currentX >= screenWidth - 32 - 5) {
480
- setIsHidden(true);
585
+ // Skip if not initialized or first measurement
586
+ if (!isPositionInitialized || prevBubbleWidthRef.current === null) {
587
+ prevBubbleWidthRef.current = bubbleSize.width;
588
+ return;
589
+ }
590
+
591
+ // Skip if currently dragging - let drag handle position
592
+ if (isDragging) {
593
+ prevBubbleWidthRef.current = bubbleSize.width;
594
+ return;
595
+ }
596
+ const prevWidth = prevBubbleWidthRef.current;
597
+ const newWidth = bubbleSize.width;
598
+ const widthDelta = newWidth - prevWidth;
599
+
600
+ // Skip if no significant change (avoid jitter)
601
+ if (Math.abs(widthDelta) < 2) {
602
+ return;
603
+ }
604
+
605
+ // Update ref first
606
+ prevBubbleWidthRef.current = newWidth;
607
+
608
+ // If hidden, only adjust the saved position (not the current animated position)
609
+ if (isHidden) {
610
+ if (savedPositionRef.current) {
611
+ const adjustedX = savedPositionRef.current.x - widthDelta;
612
+ // Clamp to valid bounds
613
+ savedPositionRef.current.x = Math.max(safeAreaInsets.left, Math.min(adjustedX, screenWidth - newWidth - 20));
481
614
  }
482
- };
483
- // Delay check to ensure position is loaded
484
- const timer = setTimeout(checkHiddenState, 100);
485
- return () => clearTimeout(timer);
486
- }, [enablePositionPersistence, animatedPosition, screenWidth]);
615
+ return;
616
+ }
617
+
618
+ // Get current position
619
+ const currentX = animatedPosition.x.__getValue();
620
+ const currentY = animatedPosition.y.__getValue();
621
+
622
+ // Calculate new X position
623
+ // If bubble grew (positive delta), move left (decrease X)
624
+ // If bubble shrunk (negative delta), move right (increase X)
625
+ const newX = currentX - widthDelta;
626
+
627
+ // Validate new position is within bounds
628
+ const clampedX = Math.max(safeAreaInsets.left, Math.min(newX, screenWidth - 32) // Ensure handle still visible
629
+ );
630
+
631
+ // Only animate if position actually changed
632
+ if (Math.abs(clampedX - currentX) > 1) {
633
+ _reactNative.Animated.timing(animatedPosition, {
634
+ toValue: {
635
+ x: clampedX,
636
+ y: currentY
637
+ },
638
+ duration: 150,
639
+ useNativeDriver: false
640
+ }).start(() => {
641
+ savePosition(clampedX, currentY);
642
+ // Update savedPositionRef so show/hide works correctly
643
+ if (savedPositionRef.current) {
644
+ savedPositionRef.current.x = clampedX;
645
+ }
646
+ });
647
+ }
648
+ }, [bubbleSize.width, isPositionInitialized, isDragging, isHidden, animatedPosition, screenWidth, safeAreaInsets.left, savePosition]);
487
649
 
488
650
  // Default position when persistence disabled or during onboarding
489
651
  (0, _react.useEffect)(() => {
@@ -594,6 +756,18 @@ function FloatingTools({
594
756
  // Animate to hidden position (only grabber visible)
595
757
  const hiddenX = screenWidth - 32; // Only show the 32px grabber
596
758
  setIsHidden(true);
759
+
760
+ // Update savedPositionRef to preserve the Y position when dragging while hidden
761
+ // This ensures that when showing, the bubble restores to the new Y position
762
+ if (savedPositionRef.current) {
763
+ savedPositionRef.current.y = currentY;
764
+ } else {
765
+ // If no saved position exists, create one with default visible X
766
+ savedPositionRef.current = {
767
+ x: screenWidth - bubbleSize.width - 20,
768
+ y: currentY
769
+ };
770
+ }
597
771
  _reactNative.Animated.timing(animatedPosition, {
598
772
  toValue: {
599
773
  x: hiddenX,
@@ -694,6 +868,12 @@ function FloatingTools({
694
868
 
695
869
  // Width for the minimized tools stack - match the drag handle width
696
870
  const minimizedStackWidth = 32;
871
+
872
+ // Don't render until position is initialized (when persistence enabled)
873
+ // This prevents the bubble from appearing at {0,0} before position loads
874
+ if (enablePositionPersistence && !isPositionInitialized) {
875
+ return null;
876
+ }
697
877
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Animated.View, {
698
878
  style: bubbleStyle,
699
879
  children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- import { safeGetItem, safeSetItem } from "@buoy-gg/shared-ui";
3
+ import { persistentStorage } from "@buoy-gg/shared-ui";
4
4
  import React, { createContext, useCallback, useContext, useMemo, useState, useEffect, useRef } from "react";
5
5
  import { BackHandler, Modal, StyleSheet, View } from "react-native";
6
6
  import { resolveOpenAppsState } from "./AppHostLogic.js";
@@ -87,7 +87,7 @@ export const AppHostProvider = ({
87
87
  useEffect(() => {
88
88
  const restoreOpenApps = async () => {
89
89
  try {
90
- const saved = await safeGetItem(STORAGE_KEY_OPEN_APPS);
90
+ const saved = await persistentStorage.getItem(STORAGE_KEY_OPEN_APPS);
91
91
  if (saved) {
92
92
  const parsed = JSON.parse(saved);
93
93
  // Handle both old format (string[]) and new format (PersistedAppState[])
@@ -133,7 +133,7 @@ export const AppHostProvider = ({
133
133
  id: app.id,
134
134
  minimized: app.minimized ?? false
135
135
  }));
136
- safeSetItem(STORAGE_KEY_OPEN_APPS, JSON.stringify(appStates));
136
+ persistentStorage.setItem(STORAGE_KEY_OPEN_APPS, JSON.stringify(appStates));
137
137
  }, PERSISTENCE_DELAY);
138
138
  return () => {
139
139
  if (persistenceTimeoutRef.current) {
@@ -3,10 +3,10 @@
3
3
  import { useState, useEffect, useCallback, useMemo } from "react";
4
4
  import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Dimensions } from "react-native";
5
5
  import { settingsBus } from "./settingsBus.js";
6
- import { SentryBugIcon, WifiCircuitIcon, BenchmarkIcon, RenderCountIcon, Info, Layers, ChevronRightIcon, ChevronDown, safeGetItem, safeSetItem, getStorageBackendType, persistentStorage, Database, Trash2, CheckCircle2, AlertTriangle, Zap, FileText, HardDrive, Copy, FileCode, RefreshCw, Smartphone, Plus, copyToClipboard, LicenseEntryModal, SectionHeader } from "@buoy-gg/shared-ui";
6
+ import { SentryBugIcon, WifiCircuitIcon, BenchmarkIcon, RenderCountIcon, Info, Layers, ChevronRightIcon, ChevronDown, getStorageBackendType, persistentStorage, Database, Trash2, CheckCircle2, AlertTriangle, Zap, FileText, HardDrive, Copy, FileCode, RefreshCw, Smartphone, Plus, copyToClipboard, LicenseEntryModal, SectionHeader } from "@buoy-gg/shared-ui";
7
7
  import { EnvIcon, StorageIcon, RoutesIcon, NetworkIcon, QueryIcon, HighlighterIcon, ReduxIcon } from "@buoy-gg/floating-tools-core";
8
8
  import { useDefaultConfig } from "./DefaultConfigContext.js";
9
- import { JsModal, devToolsStorageKeys, safeGetItem as sharedSafeGetItem, safeSetItem as sharedSafeSetItem } from "@buoy-gg/shared-ui";
9
+ import { JsModal, devToolsStorageKeys } from "@buoy-gg/shared-ui";
10
10
  import { useSafeAreaInsets } from "@buoy-gg/shared-ui";
11
11
  import { ModalHeader } from "@buoy-gg/shared-ui";
12
12
  import { TabSelector } from "@buoy-gg/shared-ui";
@@ -185,7 +185,7 @@ export const DevToolsSettingsModal = ({
185
185
  useEffect(() => {
186
186
  const loadActiveTab = async () => {
187
187
  try {
188
- const savedTab = await sharedSafeGetItem(devToolsStorageKeys.settings.activeTab());
188
+ const savedTab = await persistentStorage.getItem(devToolsStorageKeys.settings.activeTab());
189
189
  if (savedTab && ["dial", "floating", "settings", "pro"].includes(savedTab)) {
190
190
  setActiveTab(savedTab);
191
191
  }
@@ -202,7 +202,7 @@ export const DevToolsSettingsModal = ({
202
202
  useEffect(() => {
203
203
  // Only persist after initial state is loaded to avoid overwriting with default
204
204
  if (!activeTabLoaded) return;
205
- sharedSafeSetItem(devToolsStorageKeys.settings.activeTab(), activeTab).catch(error => {
205
+ persistentStorage.setItem(devToolsStorageKeys.settings.activeTab(), activeTab).catch(error => {
206
206
  // Failed to save active tab - continue without persistence
207
207
  console.warn("Failed to save settings active tab:", error);
208
208
  });
@@ -245,7 +245,7 @@ export const DevToolsSettingsModal = ({
245
245
 
246
246
  const loadSettings = useCallback(async () => {
247
247
  try {
248
- const savedSettings = await safeGetItem(STORAGE_KEY);
248
+ const savedSettings = await persistentStorage.getItem(STORAGE_KEY);
249
249
  if (savedSettings) {
250
250
  const parsed = JSON.parse(savedSettings);
251
251
  const merged = mergeWithDefaults(defaultSettings, parsed, {
@@ -289,7 +289,7 @@ export const DevToolsSettingsModal = ({
289
289
  ...newSettings,
290
290
  dialTools: enforceDialLimit(newSettings.dialTools)
291
291
  };
292
- await safeSetItem(STORAGE_KEY, JSON.stringify(limitedSettings));
292
+ await persistentStorage.setItem(STORAGE_KEY, JSON.stringify(limitedSettings));
293
293
  setSettings(limitedSettings);
294
294
  onSettingsChange?.(limitedSettings);
295
295
  // Notify listeners (e.g., floating bubble) to refresh immediately
@@ -1438,7 +1438,7 @@ export const useDevToolsSettings = () => {
1438
1438
  const [settings, setSettings] = useState(effectiveDefaults);
1439
1439
  const loadSettings = useCallback(async () => {
1440
1440
  try {
1441
- const savedSettings = await safeGetItem(STORAGE_KEY);
1441
+ const savedSettings = await persistentStorage.getItem(STORAGE_KEY);
1442
1442
  if (savedSettings) {
1443
1443
  const parsed = JSON.parse(savedSettings);
1444
1444
  const merged = mergeWithDefaults(effectiveDefaults, parsed);
@@ -137,9 +137,8 @@ export function FloatingDevTools({
137
137
  visible: isSettingsOpen,
138
138
  onClose: handleCloseSettings,
139
139
  availableTools: availableTools,
140
- onSettingsChange: newSettings => {
140
+ onSettingsChange: _newSettings => {
141
141
  // Settings are already saved by the hook
142
- console.log('Settings updated:', newSettings);
143
142
  }
144
143
  })]
145
144
  });