@buoy-gg/core 7.0.35 → 7.0.38

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 (111) hide show
  1. package/README.md +57 -66
  2. package/bin/buoy.js +71 -20
  3. package/lib/commonjs/Buoy.js +3 -16
  4. package/lib/commonjs/floatingMenu/AppHost.js +1 -1
  5. package/lib/commonjs/floatingMenu/AppHost.web.js +1 -1
  6. package/lib/commonjs/floatingMenu/AppIcon.web.js +53 -0
  7. package/lib/commonjs/floatingMenu/DevToolsSettingsModal.js +12 -5
  8. package/lib/commonjs/floatingMenu/DevToolsSettingsModal.web.js +14 -34
  9. package/lib/commonjs/floatingMenu/FloatingDevTools.js +32 -48
  10. package/lib/commonjs/floatingMenu/FloatingDevTools.web.js +24 -4
  11. package/lib/commonjs/floatingMenu/FloatingMenu.js +1 -1
  12. package/lib/commonjs/floatingMenu/MinimizedToolsStack.js +83 -4
  13. package/lib/commonjs/floatingMenu/MinimizedToolsStack.web.js +90 -6
  14. package/lib/commonjs/floatingMenu/ToolHost.js +1 -0
  15. package/lib/commonjs/floatingMenu/autoExternalSync.js +3 -2
  16. package/lib/commonjs/floatingMenu/dial/DialDevTools.js +43 -10
  17. package/lib/commonjs/floatingMenu/dial/DialDevTools.web.js +18 -5
  18. package/lib/commonjs/floatingMenu/dial/dialUsageStore.js +13 -17
  19. package/lib/commonjs/floatingMenu/dial/onboardingConstants.js +37 -65
  20. package/lib/commonjs/floatingMenu/dial/onboardingConstants.web.js +37 -29
  21. package/lib/commonjs/floatingMenu/floatingTools.js +23 -889
  22. package/lib/commonjs/floatingMenu/floatingTools.web.js +24 -514
  23. package/lib/commonjs/floatingMenu/optionalRequire.js +4 -0
  24. package/lib/commonjs/telemetry/installPing.js +7 -0
  25. package/lib/commonjs/version.js +1 -1
  26. package/lib/module/Buoy.js +3 -16
  27. package/lib/module/floatingMenu/AppHost.js +1 -1
  28. package/lib/module/floatingMenu/AppHost.web.js +1 -1
  29. package/lib/module/floatingMenu/AppIcon.web.js +47 -0
  30. package/lib/module/floatingMenu/DevToolsSettingsModal.js +13 -6
  31. package/lib/module/floatingMenu/DevToolsSettingsModal.web.js +15 -35
  32. package/lib/module/floatingMenu/FloatingDevTools.js +35 -49
  33. package/lib/module/floatingMenu/FloatingDevTools.web.js +24 -3
  34. package/lib/module/floatingMenu/FloatingMenu.js +1 -1
  35. package/lib/module/floatingMenu/MinimizedToolsStack.js +88 -8
  36. package/lib/module/floatingMenu/MinimizedToolsStack.web.js +92 -8
  37. package/lib/module/floatingMenu/ToolHost.js +1 -0
  38. package/lib/module/floatingMenu/autoExternalSync.js +3 -2
  39. package/lib/module/floatingMenu/dial/DialDevTools.js +43 -10
  40. package/lib/module/floatingMenu/dial/DialDevTools.web.js +18 -5
  41. package/lib/module/floatingMenu/dial/dialUsageStore.js +14 -18
  42. package/lib/module/floatingMenu/dial/onboardingConstants.js +1 -62
  43. package/lib/module/floatingMenu/dial/onboardingConstants.web.js +1 -25
  44. package/lib/module/floatingMenu/floatingTools.js +15 -890
  45. package/lib/module/floatingMenu/floatingTools.web.js +1 -517
  46. package/lib/module/floatingMenu/optionalRequire.js +4 -0
  47. package/lib/module/telemetry/installPing.js +7 -0
  48. package/lib/module/version.js +1 -1
  49. package/lib/typescript/commonjs/Buoy.d.ts +14 -3
  50. package/lib/typescript/commonjs/Buoy.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/floatingMenu/AppIcon.web.d.ts +14 -0
  52. package/lib/typescript/commonjs/floatingMenu/AppIcon.web.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/floatingMenu/DevToolsSettingsModal.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/floatingMenu/DevToolsSettingsModal.web.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.d.ts +4 -14
  56. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.web.d.ts +4 -19
  58. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/floatingMenu/MinimizedToolsStack.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/floatingMenu/MinimizedToolsStack.web.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/floatingMenu/ToolHost.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/floatingMenu/autoExternalSync.d.ts +3 -4
  63. package/lib/typescript/commonjs/floatingMenu/autoExternalSync.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.web.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/floatingMenu/dial/dialUsageStore.d.ts +3 -15
  67. package/lib/typescript/commonjs/floatingMenu/dial/dialUsageStore.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/floatingMenu/dial/onboardingConstants.d.ts +1 -29
  69. package/lib/typescript/commonjs/floatingMenu/dial/onboardingConstants.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/floatingMenu/dial/onboardingConstants.web.d.ts +1 -12
  71. package/lib/typescript/commonjs/floatingMenu/dial/onboardingConstants.web.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/floatingMenu/floatingTools.d.ts +6 -55
  73. package/lib/typescript/commonjs/floatingMenu/floatingTools.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/floatingMenu/floatingTools.web.d.ts +3 -53
  75. package/lib/typescript/commonjs/floatingMenu/floatingTools.web.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/floatingMenu/optionalRequire.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/telemetry/installPing.d.ts +8 -1
  78. package/lib/typescript/commonjs/telemetry/installPing.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/version.d.ts +1 -1
  80. package/lib/typescript/module/Buoy.d.ts +14 -3
  81. package/lib/typescript/module/Buoy.d.ts.map +1 -1
  82. package/lib/typescript/module/floatingMenu/AppIcon.web.d.ts +14 -0
  83. package/lib/typescript/module/floatingMenu/AppIcon.web.d.ts.map +1 -0
  84. package/lib/typescript/module/floatingMenu/DevToolsSettingsModal.d.ts.map +1 -1
  85. package/lib/typescript/module/floatingMenu/DevToolsSettingsModal.web.d.ts.map +1 -1
  86. package/lib/typescript/module/floatingMenu/FloatingDevTools.d.ts +4 -14
  87. package/lib/typescript/module/floatingMenu/FloatingDevTools.d.ts.map +1 -1
  88. package/lib/typescript/module/floatingMenu/FloatingDevTools.web.d.ts +4 -19
  89. package/lib/typescript/module/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
  90. package/lib/typescript/module/floatingMenu/MinimizedToolsStack.d.ts.map +1 -1
  91. package/lib/typescript/module/floatingMenu/MinimizedToolsStack.web.d.ts.map +1 -1
  92. package/lib/typescript/module/floatingMenu/ToolHost.d.ts.map +1 -1
  93. package/lib/typescript/module/floatingMenu/autoExternalSync.d.ts +3 -4
  94. package/lib/typescript/module/floatingMenu/autoExternalSync.d.ts.map +1 -1
  95. package/lib/typescript/module/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
  96. package/lib/typescript/module/floatingMenu/dial/DialDevTools.web.d.ts.map +1 -1
  97. package/lib/typescript/module/floatingMenu/dial/dialUsageStore.d.ts +3 -15
  98. package/lib/typescript/module/floatingMenu/dial/dialUsageStore.d.ts.map +1 -1
  99. package/lib/typescript/module/floatingMenu/dial/onboardingConstants.d.ts +1 -29
  100. package/lib/typescript/module/floatingMenu/dial/onboardingConstants.d.ts.map +1 -1
  101. package/lib/typescript/module/floatingMenu/dial/onboardingConstants.web.d.ts +1 -12
  102. package/lib/typescript/module/floatingMenu/dial/onboardingConstants.web.d.ts.map +1 -1
  103. package/lib/typescript/module/floatingMenu/floatingTools.d.ts +6 -55
  104. package/lib/typescript/module/floatingMenu/floatingTools.d.ts.map +1 -1
  105. package/lib/typescript/module/floatingMenu/floatingTools.web.d.ts +3 -53
  106. package/lib/typescript/module/floatingMenu/floatingTools.web.d.ts.map +1 -1
  107. package/lib/typescript/module/floatingMenu/optionalRequire.d.ts.map +1 -1
  108. package/lib/typescript/module/telemetry/installPing.d.ts +8 -1
  109. package/lib/typescript/module/telemetry/installPing.d.ts.map +1 -1
  110. package/lib/typescript/module/version.d.ts +1 -1
  111. package/package.json +6 -6
@@ -1,902 +1,27 @@
1
1
  "use strict";
2
2
 
3
- import { useEffect, useMemo, useRef, useState, useContext, createContext, useCallback, Children } from "react";
4
- import { Animated, Dimensions, View, Text, TouchableOpacity } from "react-native";
5
- import { DraggableHeader, EnvironmentSelectorInline, GripVerticalIcon, buoyColors, getSafeAreaInsets, persistentStorage, useSafeAreaInsets } from "@buoy-gg/shared-ui";
6
- import { calculateTargetPosition } from "./dial/onboardingConstants";
3
+ import { useCallback } from "react";
4
+ import { FloatingTool } from "@buoy-gg/shared-ui";
7
5
  import { MinimizedToolsStack } from "./MinimizedToolsStack";
8
6
  import { useMinimizedTools } from "./MinimizedToolsContext.js";
9
7
  import { useAppHost } from "./AppHost";
10
-
11
- // Shared utilities from floating-tools-core
12
- import { getUserStatusConfig as getStatusConfig, filterValidChildren, floatingToolsColors } from "@buoy-gg/floating-tools-core";
13
-
14
- // Re-export UserRole type for consumers
15
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
16
- // Using Views to render grip dots; no react-native-svg dependency
17
-
18
- const STORAGE_KEYS = {
19
- BUBBLE_POSITION_X: "@react_buoy_bubble_position_x",
20
- BUBBLE_POSITION_Y: "@react_buoy_bubble_position_y"
21
- };
22
-
23
- // Position constants
24
- const VISIBLE_HANDLE_WIDTH = 32;
25
- const EDGE_PADDING = 20;
26
- const LAYOUT_SETTLE_DELAY_MS = 150;
27
-
28
- /** Helper to get current value from Animated.Value without triggering updates */
29
- const getAnimatedValue = value => value.__getValue();
30
-
31
- // =============================
32
- // Position persistence hook
33
- // Extracted logic dedicated to state/IO
34
- // =============================
35
- /**
36
- * Custom hook for managing floating tools position persistence
37
- *
38
- * Handles loading, saving, and validating the position of the floating tools bubble
39
- * with automatic boundary checking and storage management.
40
- *
41
- * @param props - Configuration for position management
42
- * @param props.animatedPosition - Animated.ValueXY for position updates
43
- * @param props.bubbleWidth - Width of the bubble for boundary calculations
44
- * @param props.bubbleHeight - Height of the bubble for boundary calculations
45
- * @param props.enabled - Whether position persistence is enabled
46
- * @param props.visibleHandleWidth - Width of visible handle when bubble is hidden
47
- * @param props.listenersSuspended - Pause automatic listeners without disabling manual saves
48
- * @param props.onPositionLoaded - Callback fired once position is loaded and set
49
- *
50
- * @returns Object containing position management functions and loading state
51
- *
52
- * @performance Uses debounced saving to avoid excessive storage operations
53
- * @performance Validates positions against screen boundaries and safe areas
54
- */
55
- function useFloatingToolsPosition({
56
- animatedPosition,
57
- bubbleWidth = 100,
58
- bubbleHeight = 32,
59
- enabled = true,
60
- visibleHandleWidth = 32,
61
- listenersSuspended = false,
62
- onPositionLoaded
63
- }) {
64
- // Use state instead of ref so we can react to changes and prevent races
65
- const [isInitialized, setIsInitialized] = useState(false);
66
- const saveTimeoutRef = useRef(undefined);
67
- // Track if component is mounted to prevent state updates after unmount
68
- const isMountedRef = useRef(true);
69
-
70
- // Cleanup on unmount - cancel any pending saves
71
- useEffect(() => {
72
- isMountedRef.current = true;
73
- return () => {
74
- isMountedRef.current = false;
75
- // Clear any pending debounced saves on unmount to prevent saving stale/invalid positions
76
- if (saveTimeoutRef.current) {
77
- clearTimeout(saveTimeoutRef.current);
78
- saveTimeoutRef.current = undefined;
79
- }
80
- };
81
- }, []);
82
- const savePosition = useCallback(async (x, y) => {
83
- if (!enabled) return;
84
- // Don't save if component unmounted or position is clearly invalid
85
- if (!isMountedRef.current) return;
86
- // Guard against saving invalid positions (e.g., during HMR with stale dimensions)
87
- const {
88
- width: screenWidth
89
- } = Dimensions.get("window");
90
- if (screenWidth <= 0 || x < 0 || y < 0) return;
91
- try {
92
- await Promise.all([persistentStorage.setItem(STORAGE_KEYS.BUBBLE_POSITION_X, x.toString()), persistentStorage.setItem(STORAGE_KEYS.BUBBLE_POSITION_Y, y.toString())]);
93
- } catch (error) {
94
- // Failed to save position - continue without persistence
95
- }
96
- }, [enabled]);
97
- const debouncedSavePosition = useCallback((x, y) => {
98
- // Don't schedule saves if unmounted
99
- if (!isMountedRef.current) return;
100
- if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
101
- saveTimeoutRef.current = setTimeout(() => {
102
- // Double-check mount status before saving
103
- if (isMountedRef.current) {
104
- savePosition(x, y);
105
- }
106
- }, 500);
107
- }, [savePosition]);
108
- const loadPosition = useCallback(async () => {
109
- if (!enabled) return null;
110
- try {
111
- const [xStr, yStr] = await Promise.all([persistentStorage.getItem(STORAGE_KEYS.BUBBLE_POSITION_X), persistentStorage.getItem(STORAGE_KEYS.BUBBLE_POSITION_Y)]);
112
- if (xStr !== null && yStr !== null) {
113
- const x = parseFloat(xStr);
114
- const y = parseFloat(yStr);
115
- if (!Number.isNaN(x) && !Number.isNaN(y)) {
116
- return {
117
- x,
118
- y
119
- };
120
- }
121
- }
122
- } catch (error) {
123
- // Failed to load position - use default
124
- }
125
- return null;
126
- }, [enabled]);
127
- const validatePosition = useCallback(position => {
128
- const {
129
- width: screenWidth,
130
- height: screenHeight
131
- } = Dimensions.get("window");
132
- const safeArea = getSafeAreaInsets();
133
-
134
- // Guard against invalid screen dimensions (can happen during HMR)
135
- // Return the position as-is but mark as invalid so caller knows not to trust it
136
- if (screenWidth <= 0 || screenHeight <= 0) {
137
- return {
138
- ...position,
139
- isValid: false
140
- };
141
- }
142
-
143
- // Prevent going off left, top, and bottom edges with safe area
144
- // Allow pushing off-screen to the right so only the grab handle remains visible
145
- const minX = safeArea.left;
146
- const maxX = screenWidth - visibleHandleWidth;
147
- // Add padding below the safe area top to ensure bubble doesn't go behind notch
148
- const minY = safeArea.top + EDGE_PADDING;
149
- const maxY = screenHeight - bubbleHeight - safeArea.bottom; // Respect safe area bottom
150
- const clamped = {
151
- x: Math.max(minX, Math.min(position.x, maxX)),
152
- y: Math.max(minY, Math.min(position.y, maxY)),
153
- isValid: true
154
- };
155
- return clamped;
156
- }, [visibleHandleWidth, bubbleHeight]);
157
- useEffect(() => {
158
- if (!enabled || isInitialized) return;
159
- let cancelled = false;
160
- const restore = async () => {
161
- const saved = await loadPosition();
162
-
163
- // Check if cancelled or unmounted before updating state
164
- if (cancelled || !isMountedRef.current) return;
165
- const {
166
- width: screenWidth,
167
- height: screenHeight
168
- } = Dimensions.get("window");
169
- const safeArea = getSafeAreaInsets();
170
-
171
- // If dimensions are invalid (HMR scenario), wait and retry
172
- if (screenWidth <= 0 || screenHeight <= 0) {
173
- // Schedule a retry after a short delay
174
- setTimeout(() => {
175
- if (isMountedRef.current && !cancelled) {
176
- restore();
177
- }
178
- }, 100);
179
- return;
180
- }
181
- let finalPosition;
182
- let wasHidden = false;
183
- if (saved) {
184
- const validated = validatePosition(saved);
185
-
186
- // If validation returned invalid, retry later
187
- if (!validated.isValid) {
188
- setTimeout(() => {
189
- if (isMountedRef.current && !cancelled) {
190
- restore();
191
- }
192
- }, 100);
193
- return;
194
- }
195
-
196
- // Check if the saved position is out of bounds
197
- const wasOutOfBounds = Math.abs(saved.x - validated.x) > 5 || Math.abs(saved.y - validated.y) > 5;
198
- if (wasOutOfBounds) {
199
- // Save the corrected position
200
- await savePosition(validated.x, validated.y);
201
- }
202
- finalPosition = {
203
- x: validated.x,
204
- y: validated.y
205
- };
206
-
207
- // Check if loaded in hidden state (bubble pushed to right edge)
208
- wasHidden = validated.x >= screenWidth - visibleHandleWidth - 5;
209
- } else {
210
- const defaultY = Math.max(safeArea.top + EDGE_PADDING, Math.min(100, screenHeight - bubbleHeight - safeArea.bottom));
211
- finalPosition = {
212
- x: screenWidth - bubbleWidth - EDGE_PADDING,
213
- y: defaultY
214
- };
215
- }
216
-
217
- // Final mount check before updating
218
- if (cancelled || !isMountedRef.current) return;
219
- animatedPosition.setValue(finalPosition);
220
- setIsInitialized(true);
221
- onPositionLoaded?.(finalPosition, wasHidden);
222
- };
223
- restore();
224
- return () => {
225
- cancelled = true;
226
- };
227
- }, [enabled, isInitialized, animatedPosition, loadPosition, validatePosition, savePosition, bubbleWidth, bubbleHeight, visibleHandleWidth, onPositionLoaded]);
228
-
229
- // Listener effect - now properly depends on isInitialized state
230
- useEffect(() => {
231
- // Don't attach listener until position is loaded
232
- if (!enabled || !isInitialized || listenersSuspended) return;
233
- const listener = animatedPosition.addListener(value => {
234
- debouncedSavePosition(value.x, value.y);
235
- });
236
- return () => {
237
- animatedPosition.removeListener(listener);
238
- // Clear debounce timer when listener is removed
239
- if (saveTimeoutRef.current) {
240
- clearTimeout(saveTimeoutRef.current);
241
- saveTimeoutRef.current = undefined;
242
- }
243
- };
244
- }, [enabled, isInitialized, listenersSuspended, animatedPosition, debouncedSavePosition]);
245
- return {
246
- savePosition,
247
- isInitialized
248
- };
249
- }
250
-
251
- // =============================
252
- // UI-only leaf components
253
- // =============================
254
- export function Divider() {
255
- const dividerStyle = {
256
- width: 1,
257
- height: 12,
258
- backgroundColor: buoyColors.textMuted + "66",
259
- flexShrink: 0
260
- };
261
- return /*#__PURE__*/_jsx(View, {
262
- style: dividerStyle
263
- });
264
- }
265
-
266
- // Use shared getUserStatusConfig from floating-tools-core
267
- // Wraps the core function to use our buoyColors (mapped to gameUIColors format)
268
- const gameUIColorsCompat = {
269
- success: "#20C997",
270
- // teal
271
- warning: "#FFA94D",
272
- error: "#EF4444",
273
- muted: buoyColors.textMuted,
274
- secondary: buoyColors.textSecondary,
275
- info: "#20C997",
276
- // teal
277
- primary: "#20C997",
278
- optional: buoyColors.textSecondary // optional status color
279
- };
280
- function getUserStatusConfig(userRole) {
281
- return getStatusConfig(userRole, gameUIColorsCompat);
282
- }
283
-
284
- // Context to avoid brittle prop threading and keep API composable
285
- const FloatingToolsContext = /*#__PURE__*/createContext({
286
- isDragging: false
287
- });
288
- export function UserStatus({
289
- userRole,
290
- onPress
291
- }) {
292
- const {
293
- isDragging
294
- } = useContext(FloatingToolsContext);
295
- const config = getUserStatusConfig(userRole);
296
- const containerStyle = {
297
- flexDirection: "row",
298
- alignItems: "center",
299
- paddingVertical: 6,
300
- paddingHorizontal: 8,
301
- flexShrink: 0
302
- };
303
- const dotStyle = {
304
- width: 6,
305
- height: 6,
306
- borderRadius: 3,
307
- backgroundColor: config.dotColor,
308
- marginRight: 4
309
- };
310
- const textStyle = {
311
- fontSize: 10,
312
- fontWeight: "500",
313
- color: config.textColor,
314
- letterSpacing: 0.3
315
- };
316
- if (!onPress) {
317
- return /*#__PURE__*/_jsxs(View, {
318
- style: containerStyle,
319
- accessibilityLabel: `User status: ${config.label}`,
320
- children: [/*#__PURE__*/_jsx(View, {
321
- style: dotStyle
322
- }), /*#__PURE__*/_jsx(Text, {
323
- style: textStyle,
324
- children: config.label
325
- })]
326
- });
327
- }
328
- return /*#__PURE__*/_jsxs(TouchableOpacity, {
329
- accessibilityRole: "button",
330
- accessibilityLabel: "Open Dev Tools Menu",
331
- testID: "open-dev-tools-button",
332
- onPress: onPress,
333
- hitSlop: {
334
- top: 10,
335
- bottom: 10,
336
- left: 0,
337
- right: 10
338
- },
339
- disabled: isDragging,
340
- activeOpacity: 0.85,
341
- style: containerStyle,
342
- children: [/*#__PURE__*/_jsx(View, {
343
- style: dotStyle
344
- }), /*#__PURE__*/_jsx(Text, {
345
- style: textStyle,
346
- children: config.label
347
- })]
348
- });
349
- }
350
-
351
- // =============================
352
- // Helpers (using shared filterValidChildren from core)
353
- // =============================
354
- function interleaveWithDividers(childrenArray) {
355
- const validChildren = filterValidChildren(childrenArray);
356
- const result = [];
357
- validChildren.forEach((child, index) => {
358
- result.push(child);
359
- if (index < validChildren.length - 1) result.push(/*#__PURE__*/_jsx(Divider, {}, `divider-${index}`));
360
- });
361
- return result;
362
- }
363
-
364
- // =============================
365
- // Main Component (presentation only)
366
- // =============================
367
-
368
- /**
369
- * FloatingTools - A draggable, resizable bubble for development tools
370
- *
371
- * This component provides a floating bubble interface that can contain various
372
- * development tools and controls. It features:
373
- * - Drag and drop positioning with boundary constraints
374
- * - Hide/show functionality by dragging to screen edge
375
- * - Position persistence across app restarts
376
- * - Safe area aware positioning
377
- * - Automatic divider insertion between child components
378
- *
379
- * @param props - Configuration for the floating tools
380
- * @param props.enablePositionPersistence - Whether to save/restore position (default: true)
381
- * @param props.children - Child components to render in the bubble
382
- *
383
- * @returns JSX.Element representing the floating tools bubble
384
- *
385
- * @example
386
- * ```typescript
387
- * <FloatingTools enablePositionPersistence={true}>
388
- * <UserStatus userRole="admin" onPress={handleUserPress} />
389
- * <ToolButton onPress={openSettings} />
390
- * </FloatingTools>
391
- * ```
392
- *
393
- * @performance Uses native driver animations for smooth positioning
394
- * @performance Implements efficient boundary checking and position validation
395
- * @performance Includes debounced position saving for optimal storage performance
396
- */
397
- export function FloatingTools({
398
- enablePositionPersistence = true,
399
- pushToSide = false,
400
- centerOnboarding = false,
401
- children,
402
- environment,
403
- availableEnvironments,
404
- onEnvironmentSwitch,
405
- showEnvironmentSelector = false
406
- }) {
407
- // Animated position and drag state
408
- const animatedPosition = useRef(new Animated.ValueXY()).current;
409
- const [isDragging, setIsDragging] = useState(false);
410
- const [bubbleSize, setBubbleSize] = useState({
411
- width: 100,
412
- height: 32
413
- });
414
- const [isHidden, setIsHidden] = useState(false);
415
-
416
- // Store the position before hiding to restore when showing
417
- const savedPositionRef = useRef(null);
418
-
419
- // Track if the hide state was triggered by pushToSide prop vs user toggle
420
- const isPushedBySideRef = useRef(false);
421
-
422
- // Track if user has explicitly chosen to show the menu (overriding pushToSide)
423
- const userWantsVisibleRef = useRef(false);
424
-
425
- // Track previous pushToSide value to detect transitions
426
- const prevPushToSideRef = useRef(pushToSide);
427
-
428
- // Track previous bubble width for auto-adjustment when tools are added/removed
429
- const prevBubbleWidthRef = useRef(null);
430
-
431
- // Track if layout has settled (after initial render cycle completes)
432
- const [isLayoutSettled, setIsLayoutSettled] = useState(false);
433
- const safeAreaInsets = useSafeAreaInsets();
434
- const {
435
- width: screenWidth,
436
- height: screenHeight
437
- } = Dimensions.get("window");
438
-
439
- // Callback when position is loaded - sets hidden state properly without racing
440
- const handlePositionLoaded = useCallback((position, wasHidden) => {
441
- if (wasHidden) {
442
- setIsHidden(true);
443
- }
444
- }, []);
445
-
446
- // Position persistence (state/IO extracted to hook)
447
- const {
448
- savePosition,
449
- isInitialized: isPositionInitialized
450
- } = useFloatingToolsPosition({
451
- animatedPosition,
452
- bubbleWidth: bubbleSize.width,
453
- bubbleHeight: bubbleSize.height,
454
- enabled: enablePositionPersistence,
455
- visibleHandleWidth: VISIBLE_HANDLE_WIDTH,
456
- listenersSuspended: isDragging,
457
- onPositionLoaded: handlePositionLoaded
458
- });
459
-
460
- // Effect to handle pushToSide prop changes
461
- useEffect(() => {
462
- // Don't process pushToSide until position is initialized (when persistence enabled)
463
- if (enablePositionPersistence && !isPositionInitialized) return;
464
-
465
- // Reset user override when pushToSide becomes true (dial/modal opens)
466
- // This allows auto-hide to work after user manually showed the menu
467
- if (!prevPushToSideRef.current && pushToSide) {
468
- userWantsVisibleRef.current = false;
469
- }
470
- prevPushToSideRef.current = pushToSide;
471
-
472
- // Only push to side if:
473
- // 1. pushToSide is true
474
- // 2. Not currently hidden
475
- // 3. Not being dragged
476
- // 4. User hasn't manually restored (userWantsVisibleRef)
477
- if (pushToSide && !isHidden && !isDragging && !userWantsVisibleRef.current) {
478
- // Push to side
479
- const currentX = getAnimatedValue(animatedPosition.x);
480
- const currentY = getAnimatedValue(animatedPosition.y);
481
-
482
- // Save current position
483
- savedPositionRef.current = {
484
- x: currentX,
485
- y: currentY
486
- };
487
- const hiddenX = screenWidth - VISIBLE_HANDLE_WIDTH;
488
- isPushedBySideRef.current = true;
489
- setIsHidden(true);
490
- Animated.timing(animatedPosition, {
491
- toValue: {
492
- x: hiddenX,
493
- y: currentY
494
- },
495
- duration: 200,
496
- useNativeDriver: false
497
- }).start(() => {
498
- savePosition(hiddenX, currentY);
499
- });
500
- } else if (!pushToSide && isHidden && isPushedBySideRef.current) {
501
- // Restore from side when pushToSide becomes false
502
- if (savedPositionRef.current) {
503
- const {
504
- x: targetX,
505
- y: targetY
506
- } = savedPositionRef.current;
507
- isPushedBySideRef.current = false;
508
- userWantsVisibleRef.current = false; // Reset user override when tools close
509
- setIsHidden(false);
510
- Animated.timing(animatedPosition, {
511
- toValue: {
512
- x: targetX,
513
- y: targetY
514
- },
515
- duration: 200,
516
- useNativeDriver: false
517
- }).start(() => {
518
- savePosition(targetX, targetY);
519
- });
520
- }
521
- }
522
- }, [enablePositionPersistence, isPositionInitialized, pushToSide, isHidden, isDragging, screenWidth, animatedPosition, savePosition]);
523
-
524
- // Hidden state is now set via onPositionLoaded callback - no racing effect needed
525
-
526
- // Wait for layout to settle before enabling width tracking
527
- // This prevents false-positive width changes during initial render when
528
- // the bubble measures multiple times as children render
529
- useEffect(() => {
530
- if (!isPositionInitialized || isLayoutSettled) return;
531
- const timer = setTimeout(() => {
532
- // Capture the current width as the baseline AFTER layout has settled
533
- prevBubbleWidthRef.current = bubbleSize.width;
534
- setIsLayoutSettled(true);
535
- }, LAYOUT_SETTLE_DELAY_MS);
536
- return () => clearTimeout(timer);
537
- }, [isPositionInitialized, isLayoutSettled, bubbleSize.width]);
538
-
539
- // Auto-adjust position when bubble width changes (tools added/removed)
540
- // This keeps the bubble's right edge in the same relative position
541
- useEffect(() => {
542
- // Skip if position not initialized yet
543
- if (!isPositionInitialized) return;
544
-
545
- // Skip until layout has settled (prevents false-positive width changes during initial render)
546
- if (!isLayoutSettled) return;
547
-
548
- // Skip if prevBubbleWidthRef hasn't been set yet
549
- if (prevBubbleWidthRef.current === null) return;
550
-
551
- // Skip if currently dragging - let drag handle position
552
- if (isDragging) {
553
- prevBubbleWidthRef.current = bubbleSize.width;
554
- return;
555
- }
556
- const prevWidth = prevBubbleWidthRef.current;
557
- const newWidth = bubbleSize.width;
558
- const widthDelta = newWidth - prevWidth;
559
-
560
- // Skip if no significant change (avoid jitter)
561
- if (Math.abs(widthDelta) < 2) {
562
- return;
563
- }
564
-
565
- // Update ref first
566
- prevBubbleWidthRef.current = newWidth;
567
-
568
- // If hidden, only adjust the saved position (not the current animated position)
569
- if (isHidden) {
570
- if (savedPositionRef.current) {
571
- const adjustedX = savedPositionRef.current.x - widthDelta;
572
- // Clamp to valid bounds
573
- savedPositionRef.current.x = Math.max(safeAreaInsets.left, Math.min(adjustedX, screenWidth - newWidth - EDGE_PADDING));
574
- }
575
- return;
576
- }
577
-
578
- // Get current position
579
- const currentX = getAnimatedValue(animatedPosition.x);
580
- const currentY = getAnimatedValue(animatedPosition.y);
581
-
582
- // Calculate new X position
583
- // If bubble grew (positive delta), move left (decrease X)
584
- // If bubble shrunk (negative delta), move right (increase X)
585
- const newX = currentX - widthDelta;
586
-
587
- // Validate new position is within bounds
588
- const clampedX = Math.max(safeAreaInsets.left, Math.min(newX, screenWidth - VISIBLE_HANDLE_WIDTH));
589
-
590
- // Only animate if position actually changed
591
- if (Math.abs(clampedX - currentX) > 1) {
592
- Animated.timing(animatedPosition, {
593
- toValue: {
594
- x: clampedX,
595
- y: currentY
596
- },
597
- duration: 150,
598
- useNativeDriver: false
599
- }).start(() => {
600
- savePosition(clampedX, currentY);
601
- // Update savedPositionRef so show/hide works correctly
602
- if (savedPositionRef.current) {
603
- savedPositionRef.current.x = clampedX;
604
- }
605
- });
606
- }
607
- }, [bubbleSize.width, isPositionInitialized, isLayoutSettled, isDragging, isHidden, animatedPosition, screenWidth, safeAreaInsets.left, savePosition]);
608
-
609
- // Default position when persistence disabled or during onboarding
610
- useEffect(() => {
611
- if (!enablePositionPersistence) {
612
- if (centerOnboarding) {
613
- // Center the bubble for onboarding - position under tooltip arrow
614
- // Use shared calculation to ensure perfect alignment across all screens
615
- const bottomOffset = calculateTargetPosition();
616
- const centerX = (screenWidth - bubbleSize.width) / 2;
617
- const centerY = screenHeight - bubbleSize.height - bottomOffset;
618
- animatedPosition.setValue({
619
- x: centerX,
620
- y: centerY
621
- });
622
- } else {
623
- // Default right-side position
624
- const defaultY = Math.max(safeAreaInsets.top + EDGE_PADDING, Math.min(100, screenHeight - bubbleSize.height - safeAreaInsets.bottom));
625
- animatedPosition.setValue({
626
- x: screenWidth - bubbleSize.width - EDGE_PADDING,
627
- y: defaultY
628
- });
629
- }
630
- }
631
- }, [enablePositionPersistence, centerOnboarding, animatedPosition, bubbleSize.width, bubbleSize.height, safeAreaInsets.top, safeAreaInsets.bottom, screenWidth, screenHeight]);
632
-
633
- // Toggle hide/show function
634
- const toggleHideShow = useCallback(() => {
635
- const currentX = getAnimatedValue(animatedPosition.x);
636
- const currentY = getAnimatedValue(animatedPosition.y);
637
-
638
- // Check if the menu is visually off-screen (more than half hidden to the right)
639
- // This handles edge cases where isHidden state might be out of sync with actual position
640
- const isVisuallyOffScreen = currentX > screenWidth - bubbleSize.width / 2;
641
- if (isHidden || isVisuallyOffScreen) {
642
- // Show the bubble - restore to saved position or default visible position
643
- let targetX;
644
- let targetY;
645
- if (savedPositionRef.current && savedPositionRef.current.x < screenWidth - bubbleSize.width / 2) {
646
- // Restore to the saved position only if it's a valid visible position
647
- targetX = savedPositionRef.current.x;
648
- targetY = savedPositionRef.current.y;
649
- } else {
650
- // Default visible position if no saved position or saved position is off-screen
651
- targetX = screenWidth - bubbleSize.width - EDGE_PADDING;
652
- targetY = currentY;
653
- }
654
-
655
- // User explicitly wants the menu visible (overrides pushToSide)
656
- isPushedBySideRef.current = false;
657
- userWantsVisibleRef.current = true;
658
- setIsHidden(false);
659
- Animated.timing(animatedPosition, {
660
- toValue: {
661
- x: targetX,
662
- y: targetY
663
- },
664
- duration: 200,
665
- useNativeDriver: false
666
- }).start(() => {
667
- savePosition(targetX, targetY);
668
- });
669
- } else {
670
- // Hide the bubble - save current position before hiding
671
- savedPositionRef.current = {
672
- x: currentX,
673
- y: currentY
674
- };
675
-
676
- // User explicitly wants the menu hidden
677
- userWantsVisibleRef.current = false;
678
- const hiddenX = screenWidth - VISIBLE_HANDLE_WIDTH;
679
- setIsHidden(true);
680
- Animated.timing(animatedPosition, {
681
- toValue: {
682
- x: hiddenX,
683
- y: currentY
684
- },
685
- duration: 200,
686
- useNativeDriver: false
687
- }).start(() => {
688
- savePosition(hiddenX, currentY);
689
- });
690
- }
691
- }, [animatedPosition, isHidden, bubbleSize.width, savePosition, screenWidth]);
692
- const handleDragStart = useCallback(() => {
693
- setIsDragging(true);
694
- }, []);
695
- const handleDragEnd = useCallback(finalPosition => {
696
- let {
697
- x: currentX,
698
- y: currentY
699
- } = finalPosition;
700
-
701
- // Check if bubble is more than 50% over the right edge
702
- const bubbleMidpoint = currentX + bubbleSize.width / 2;
703
- const shouldHide = bubbleMidpoint > screenWidth;
704
- if (shouldHide) {
705
- // Animate to hidden position (only grabber visible)
706
- const hiddenX = screenWidth - VISIBLE_HANDLE_WIDTH;
707
- setIsHidden(true);
708
-
709
- // Update savedPositionRef to preserve the Y position when dragging while hidden
710
- // This ensures that when showing, the bubble restores to the new Y position
711
- if (savedPositionRef.current) {
712
- savedPositionRef.current.y = currentY;
713
- } else {
714
- // If no saved position exists, create one with default visible X
715
- savedPositionRef.current = {
716
- x: screenWidth - bubbleSize.width - EDGE_PADDING,
717
- y: currentY
718
- };
719
- }
720
- Animated.timing(animatedPosition, {
721
- toValue: {
722
- x: hiddenX,
723
- y: currentY
724
- },
725
- duration: 200,
726
- useNativeDriver: false
727
- }).start(() => {
728
- savePosition(hiddenX, currentY);
729
- });
730
- } else {
731
- // Check if we're in hidden state and user is pulling it back
732
- if (isHidden && currentX < screenWidth - VISIBLE_HANDLE_WIDTH - 10) {
733
- setIsHidden(false);
734
- }
735
-
736
- // Update saved position if bubble is in visible area (not hidden)
737
- if (currentX < screenWidth - bubbleSize.width / 2) {
738
- savedPositionRef.current = {
739
- x: currentX,
740
- y: currentY
741
- };
742
- }
743
- savePosition(currentX, currentY);
744
- }
745
- setIsDragging(false);
746
- }, [animatedPosition, bubbleSize.width, isHidden, savePosition, screenWidth]);
747
-
748
- // Stable styles
749
- const bubbleStyle = useMemo(() => ({
750
- position: "absolute",
751
- zIndex: 1001,
752
- transform: animatedPosition.getTranslateTransform()
753
- }), [animatedPosition]);
754
-
755
- // Get minimized tools context and app host for restoration
8
+ import { jsx as _jsx } from "react/jsx-runtime";
9
+ export { FloatingToolDivider as Divider, FloatingToolUserStatus as UserStatus } from "@buoy-gg/shared-ui";
10
+ /** The DevTools content adapter. All floating behavior lives in shared UI. */
11
+ export function FloatingTools(props) {
756
12
  const {
757
13
  minimizedTools
758
14
  } = useMinimizedTools();
759
15
  const {
760
- restore: restoreInAppHost
16
+ restore
761
17
  } = useAppHost();
762
-
763
- // Check if minimized tools are showing (for seamless connection)
764
- const hasMinimizedTools = minimizedTools.length > 0;
765
- const containerStyle = {
766
- flexDirection: "row",
767
- alignItems: "center",
768
- backgroundColor: buoyColors.card,
769
- // When minimized tools showing, remove top-left radius so they connect seamlessly
770
- borderTopLeftRadius: hasMinimizedTools ? 0 : 6,
771
- borderTopRightRadius: 6,
772
- borderBottomLeftRadius: 6,
773
- borderBottomRightRadius: 6,
774
- // Use individual border widths when minimized tools are showing
775
- // Always show top border when dragging for visual feedback
776
- borderLeftWidth: isDragging ? 2 : 1,
777
- borderRightWidth: isDragging ? 2 : 1,
778
- borderBottomWidth: isDragging ? 2 : 1,
779
- borderTopWidth: isDragging ? 2 : hasMinimizedTools ? 0 : 1,
780
- borderColor: isDragging ? floatingToolsColors.dragActive : buoyColors.textMuted + "66",
781
- overflow: "visible",
782
- elevation: 8,
783
- shadowColor: isDragging ? floatingToolsColors.dragActive + "99" : "#000",
784
- shadowOffset: {
785
- width: 0,
786
- height: isDragging ? 6 : 4
787
- },
788
- shadowOpacity: isDragging ? 0.6 : 0.3,
789
- shadowRadius: isDragging ? 12 : 8
790
- };
791
- const dragHandleStyle = {
792
- paddingHorizontal: 6,
793
- paddingVertical: 6,
794
- backgroundColor: buoyColors.textMuted + "1A",
795
- alignItems: "center",
796
- justifyContent: "center",
797
- width: 32,
798
- borderRightWidth: 1,
799
- borderRightColor: buoyColors.textMuted + "66",
800
- // Remove top-left radius when minimized tools are showing
801
- borderTopLeftRadius: hasMinimizedTools ? 0 : undefined
802
- };
803
- const contentStyle = {
804
- flexDirection: "row",
805
- alignItems: "center",
806
- gap: 6,
807
- paddingRight: 8
808
- };
809
-
810
- // Compose actions row with automatic dividers
811
- const actions = useMemo(() => interleaveWithDividers(Children.toArray(children)), [children]);
812
-
813
- // Handle restore from minimized tools stack
814
- const handleMinimizedRestore = useCallback(tool => {
815
- restoreInAppHost(tool.instanceId, tool.modalState);
816
- }, [restoreInAppHost]);
817
-
818
- // Width for the minimized tools stack - match the drag handle width
819
- const minimizedStackWidth = 32;
820
-
821
- // Don't render until position is initialized (when persistence enabled)
822
- // This prevents the bubble from appearing at {0,0} before position loads
823
- if (enablePositionPersistence && !isPositionInitialized) {
824
- return null;
825
- }
826
- return /*#__PURE__*/_jsx(Animated.View, {
827
- style: bubbleStyle,
828
- children: /*#__PURE__*/_jsxs(View, {
829
- style: {
830
- overflow: "visible"
831
- },
832
- children: [minimizedTools.length > 0 && /*#__PURE__*/_jsx(View, {
833
- style: {
834
- position: "absolute",
835
- bottom: "100%",
836
- left: 0,
837
- width: minimizedStackWidth,
838
- overflow: "visible",
839
- zIndex: 1001
840
- },
841
- children: /*#__PURE__*/_jsx(MinimizedToolsStack, {
842
- onRestore: handleMinimizedRestore,
843
- containerWidth: minimizedStackWidth
844
- })
845
- }), /*#__PURE__*/_jsxs(View, {
846
- hitSlop: {
847
- top: 10,
848
- bottom: 10,
849
- left: 10,
850
- right: 10
851
- },
852
- style: containerStyle,
853
- onLayout: event => {
854
- const {
855
- width,
856
- height
857
- } = event.nativeEvent.layout;
858
- setBubbleSize({
859
- width,
860
- height
861
- });
862
- },
863
- children: [/*#__PURE__*/_jsx(DraggableHeader, {
864
- position: animatedPosition,
865
- testID: "buoy-floating-grip",
866
- accessibilityLabel: "Move dev tools bubble",
867
- onDragStart: handleDragStart,
868
- onDragEnd: handleDragEnd,
869
- onTap: toggleHideShow,
870
- containerBounds: {
871
- width: screenWidth,
872
- height: screenHeight
873
- },
874
- elementSize: bubbleSize,
875
- minPosition: {
876
- x: safeAreaInsets.left,
877
- y: safeAreaInsets.top + EDGE_PADDING
878
- },
879
- style: dragHandleStyle,
880
- enabled: true,
881
- maxOverflowX: bubbleSize.width,
882
- children: /*#__PURE__*/_jsx(GripVerticalIcon, {
883
- size: 12,
884
- color: buoyColors.textSecondary + "CC"
885
- })
886
- }), /*#__PURE__*/_jsx(FloatingToolsContext.Provider, {
887
- value: {
888
- isDragging
889
- },
890
- children: /*#__PURE__*/_jsxs(View, {
891
- style: contentStyle,
892
- children: [showEnvironmentSelector && environment && availableEnvironments && onEnvironmentSwitch && /*#__PURE__*/_jsx(EnvironmentSelectorInline, {
893
- currentEnvironment: environment,
894
- availableEnvironments: availableEnvironments,
895
- onSelect: onEnvironmentSwitch
896
- }), actions]
897
- })
898
- })]
899
- })]
900
- })
18
+ const onRestore = useCallback(tool => restore(tool.instanceId, tool.modalState), [restore]);
19
+ return /*#__PURE__*/_jsx(FloatingTool, {
20
+ ...props,
21
+ hasMinimizedStack: minimizedTools.length > 0,
22
+ minimizedStack: minimizedTools.length ? /*#__PURE__*/_jsx(MinimizedToolsStack, {
23
+ onRestore: onRestore,
24
+ containerWidth: 32
25
+ }) : undefined
901
26
  });
902
27
  }