@buoy-gg/core 2.1.14 → 2.2.0

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/floatingMenu/DevToolsSettingsModal.js +3 -33
  2. package/lib/commonjs/floatingMenu/DevToolsSettingsModal.web.js +3 -25
  3. package/lib/commonjs/floatingMenu/FloatingDevTools.js +14 -1
  4. package/lib/commonjs/floatingMenu/FloatingDevTools.web.js +19 -9
  5. package/lib/commonjs/floatingMenu/FloatingMenu.js +6 -2
  6. package/lib/commonjs/floatingMenu/dial/DialDevTools.js +166 -196
  7. package/lib/commonjs/floatingMenu/dial/DialDevTools.web.js +82 -7
  8. package/lib/commonjs/floatingMenu/dial/DialIcon.js +66 -59
  9. package/lib/commonjs/floatingMenu/dial/DialPagination.js +170 -0
  10. package/lib/commonjs/floatingMenu/dial/dialUsageStore.js +97 -0
  11. package/lib/module/floatingMenu/DevToolsSettingsModal.js +3 -33
  12. package/lib/module/floatingMenu/DevToolsSettingsModal.web.js +4 -28
  13. package/lib/module/floatingMenu/FloatingDevTools.js +14 -1
  14. package/lib/module/floatingMenu/FloatingDevTools.web.js +19 -9
  15. package/lib/module/floatingMenu/FloatingMenu.js +6 -2
  16. package/lib/module/floatingMenu/dial/DialDevTools.js +166 -196
  17. package/lib/module/floatingMenu/dial/DialDevTools.web.js +82 -7
  18. package/lib/module/floatingMenu/dial/DialIcon.js +67 -60
  19. package/lib/module/floatingMenu/dial/DialPagination.js +165 -0
  20. package/lib/module/floatingMenu/dial/dialUsageStore.js +89 -0
  21. package/lib/typescript/commonjs/floatingMenu/DevToolsSettingsModal.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/floatingMenu/DevToolsSettingsModal.web.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/floatingMenu/FloatingMenu.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.d.ts +0 -2
  27. package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.web.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/floatingMenu/dial/DialIcon.d.ts +7 -2
  30. package/lib/typescript/commonjs/floatingMenu/dial/DialIcon.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/floatingMenu/dial/DialPagination.d.ts +22 -0
  32. package/lib/typescript/commonjs/floatingMenu/dial/DialPagination.d.ts.map +1 -0
  33. package/lib/typescript/commonjs/floatingMenu/dial/dialUsageStore.d.ts +34 -0
  34. package/lib/typescript/commonjs/floatingMenu/dial/dialUsageStore.d.ts.map +1 -0
  35. package/lib/typescript/module/floatingMenu/DevToolsSettingsModal.d.ts.map +1 -1
  36. package/lib/typescript/module/floatingMenu/DevToolsSettingsModal.web.d.ts.map +1 -1
  37. package/lib/typescript/module/floatingMenu/FloatingDevTools.d.ts.map +1 -1
  38. package/lib/typescript/module/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
  39. package/lib/typescript/module/floatingMenu/FloatingMenu.d.ts.map +1 -1
  40. package/lib/typescript/module/floatingMenu/dial/DialDevTools.d.ts +0 -2
  41. package/lib/typescript/module/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
  42. package/lib/typescript/module/floatingMenu/dial/DialDevTools.web.d.ts.map +1 -1
  43. package/lib/typescript/module/floatingMenu/dial/DialIcon.d.ts +7 -2
  44. package/lib/typescript/module/floatingMenu/dial/DialIcon.d.ts.map +1 -1
  45. package/lib/typescript/module/floatingMenu/dial/DialPagination.d.ts +22 -0
  46. package/lib/typescript/module/floatingMenu/dial/DialPagination.d.ts.map +1 -0
  47. package/lib/typescript/module/floatingMenu/dial/dialUsageStore.d.ts +34 -0
  48. package/lib/typescript/module/floatingMenu/dial/dialUsageStore.d.ts.map +1 -0
  49. package/package.json +5 -5
@@ -7,6 +7,8 @@ exports.DialDevTools = void 0;
7
7
  var _react = require("react");
8
8
  var _reactNative = require("react-native");
9
9
  var _DialIcon = require("./DialIcon.js");
10
+ var _DialPagination = require("./DialPagination.js");
11
+ var _dialUsageStore = require("./dialUsageStore.js");
10
12
  var _sharedUi = require("@buoy-gg/shared-ui");
11
13
  var _DevToolsSettingsModal = require("../DevToolsSettingsModal");
12
14
  var _license = require("@buoy-gg/license");
@@ -17,7 +19,8 @@ var _jsxRuntime = require("react/jsx-runtime");
17
19
  // Icons are provided by installedApps; no direct icon imports here.
18
20
 
19
21
  const {
20
- width: SCREEN_WIDTH
22
+ width: SCREEN_WIDTH,
23
+ height: SCREEN_HEIGHT
21
24
  } = _reactNative.Dimensions.get("window");
22
25
 
23
26
  // Use shared layout calculation from core
@@ -27,10 +30,17 @@ const layout = (0, _floatingToolsCore.getDialLayout)({
27
30
  const CIRCLE_SIZE = layout.circleSize;
28
31
  const BUTTON_SIZE = layout.buttonSize;
29
32
  const ONBOARDING_STORAGE_KEY = "@react_buoy_settings_tooltip_shown";
33
+ /** A non-interactive placeholder used to fill out the last dial page. */
34
+ const createEmptySlot = slotIndex => ({
35
+ id: `empty-${slotIndex}`,
36
+ name: `empty-${slotIndex}`,
37
+ icon: null,
38
+ color: "transparent",
39
+ onPress: () => {}
40
+ });
30
41
  const DialDevTools = ({
31
42
  onClose,
32
43
  onSettingsPress,
33
- settings: externalSettings,
34
44
  autoOpenSettings = false,
35
45
  apps,
36
46
  state,
@@ -43,18 +53,12 @@ const DialDevTools = ({
43
53
  const onboardingDismissedRef = (0, _react.useRef)(false); // Track if onboarding was dismissed
44
54
  const hintsDisabled = (0, _sharedUi.useHintsDisabled)();
45
55
  const {
46
- settings: hookSettings,
47
56
  refreshSettings
48
57
  } = (0, _DevToolsSettingsModal.useDevToolsSettings)();
49
58
  const {
50
59
  open
51
60
  } = (0, _AppHost.useAppHost)();
52
61
  const isPro = (0, _license.useIsPro)();
53
- // Initialize with external settings if provided, otherwise use hook settings
54
- const [localSettings, setLocalSettings] = (0, _react.useState)(externalSettings || hookSettings);
55
-
56
- // Always use localSettings (which can be updated by the modal)
57
- const settings = localSettings;
58
62
 
59
63
  // Load persisted settings modal state on mount
60
64
  (0, _react.useEffect)(() => {
@@ -83,20 +87,6 @@ const DialDevTools = ({
83
87
  });
84
88
  }, [isSettingsModalOpen, settingsModalStateLoaded]);
85
89
 
86
- // Update local settings when external settings change
87
- (0, _react.useEffect)(() => {
88
- if (externalSettings) {
89
- setLocalSettings(externalSettings);
90
- }
91
- }, [externalSettings]);
92
-
93
- // Update local settings when hook settings change (if no external settings)
94
- (0, _react.useEffect)(() => {
95
- if (!externalSettings) {
96
- setLocalSettings(hookSettings);
97
- }
98
- }, [hookSettings, externalSettings]);
99
-
100
90
  // Auto-open settings modal when prop is true
101
91
  (0, _react.useEffect)(() => {
102
92
  if (autoOpenSettings && !isSettingsModalOpen) {
@@ -133,7 +123,6 @@ const DialDevTools = ({
133
123
  const dialRotation = (0, _react.useRef)(new _reactNative.Animated.Value(0)).current;
134
124
  const centerButtonScale = (0, _react.useRef)(new _reactNative.Animated.Value(0)).current;
135
125
  const iconsProgress = (0, _react.useRef)(new _reactNative.Animated.Value(0)).current;
136
- const glitchOffset = (0, _react.useRef)(new _reactNative.Animated.Value(0)).current;
137
126
  const pulseScale = (0, _react.useRef)(new _reactNative.Animated.Value(1)).current;
138
127
  const availableApps = (0, _react.useMemo)(() => apps.map(({
139
128
  id,
@@ -147,88 +136,111 @@ const DialDevTools = ({
147
136
  description
148
137
  })), [apps]);
149
138
 
150
- // Subtle animations
151
- const floatingAnim = (0, _react.useRef)(new _reactNative.Animated.Value(0)).current;
152
- const breathingScale = (0, _react.useRef)(new _reactNative.Animated.Value(1)).current;
153
- const circuitOpacity = (0, _react.useRef)(new _reactNative.Animated.Value(0)).current;
154
-
155
139
  // Animation tracking refs
156
- const glitchIntervalRef = (0, _react.useRef)(null);
157
140
  const pulseAnimationRef = (0, _react.useRef)(null);
158
141
 
159
- // Map data-driven apps to dial icons, inserting empty slots for disabled items
160
- const dialApps = apps.filter(a => (a.slot ?? "both") !== "row");
142
+ // Dial-eligible apps: everything except row-only tools. All of them are
143
+ // shown paginated across pages of MAX_DIAL_SLOTS so there is no longer
144
+ // a per-tool show/hide setting.
145
+ const dialApps = (0, _react.useMemo)(() => apps.filter(a => (a.slot ?? "both") !== "row"), [apps]);
161
146
 
162
- // Check if settings are "virgin" (user hasn't customized dial tools yet)
163
- // If no explicit settings exist, auto-enable all dial tools by default
164
- const dialToolsKeys = settings?.dialTools ? Object.keys(settings.dialTools) : [];
165
- const hasDialSettings = dialToolsKeys.length > 0;
166
- const isDialEnabled = id => {
167
- // No settings or empty dialTools = auto-enable all dial tools by default
168
- if (!settings?.dialTools || !hasDialSettings) return true;
169
- return settings.dialTools[id] ?? false;
170
- };
171
- const createEmptySlot = slotIndex => ({
172
- id: `empty-${slotIndex}`,
173
- name: `empty-${slotIndex}`,
174
- icon: null,
175
- color: "transparent",
176
- onPress: () => {}
177
- });
178
- const enabledIcons = [];
179
- for (const app of dialApps) {
180
- if (!isDialEnabled(app.id)) {
181
- continue;
182
- }
183
- if (enabledIcons.length >= _floatingToolsCore.MAX_DIAL_SLOTS) {
184
- break;
185
- }
186
- enabledIcons.push({
187
- id: app.id,
188
- name: app.name,
189
- // Pass both the pre-rendered icon (for non-function icons) and the component (for dynamic rendering)
190
- icon: typeof app.icon === "function" ? null // Will be rendered dynamically by DialIcon
191
- : app.icon,
192
- // Cast to the expected type - the function signature is compatible at runtime
193
- iconComponent: typeof app.icon === "function" ? app.icon : undefined,
194
- color: app.color ?? _sharedUi.buoyColors.primary,
195
- onPress: () => {
196
- // Call the app's onPress callback if provided, passing actions for toggle tools
197
- app?.onPress?.(actions);
147
+ // Build a stable IconType for every dial-eligible app, keyed by id.
148
+ const iconsById = (0, _react.useMemo)(() => {
149
+ const map = new Map();
150
+ for (const app of dialApps) {
151
+ map.set(app.id, {
152
+ id: app.id,
153
+ name: app.name,
154
+ // Pass both the pre-rendered icon (for non-function icons) and the
155
+ // component (for dynamic rendering).
156
+ icon: typeof app.icon === "function" ? null : app.icon,
157
+ // Cast to the expected type - the signature is compatible at runtime.
158
+ iconComponent: typeof app.icon === "function" ? app.icon : undefined,
159
+ color: app.color ?? _sharedUi.buoyColors.primary,
160
+ onPress: () => {
161
+ // Record usage so frequently/recently used tools rank toward page 1.
162
+ void (0, _dialUsageStore.recordToolUsage)(app.id);
163
+
164
+ // Call the app's onPress callback if provided, passing actions for
165
+ // toggle tools.
166
+ app?.onPress?.(actions);
167
+
168
+ // Only open modal if not a toggle-only tool.
169
+ if (app.launchMode !== "toggle-only") {
170
+ const resolvedIcon = typeof app.icon === "function" ? app.icon({
171
+ slot: "dial",
172
+ size: 20
173
+ }) : app.icon;
174
+ open({
175
+ id: app.id,
176
+ title: app.name,
177
+ component: app.component,
178
+ props: app.props,
179
+ launchMode: app.launchMode ?? "self-modal",
180
+ singleton: app.singleton ?? true,
181
+ icon: resolvedIcon,
182
+ color: app.color
183
+ });
184
+ }
198
185
 
199
- // Only open modal if not a toggle-only tool
200
- if (app.launchMode !== "toggle-only") {
201
- const resolvedIcon = typeof app.icon === "function" ? app.icon({
202
- slot: "dial",
203
- size: 20
204
- }) : app.icon;
205
- open({
206
- id: app.id,
207
- title: app.name,
208
- component: app.component,
209
- props: app.props,
210
- launchMode: app.launchMode ?? "self-modal",
211
- singleton: app.singleton ?? true,
212
- icon: resolvedIcon,
213
- color: app.color
214
- });
186
+ // Close the dial.
187
+ onClose?.();
215
188
  }
189
+ });
190
+ }
191
+ return map;
192
+ }, [dialApps, actions, open, onClose]);
216
193
 
217
- // Close the dial
218
- onClose?.();
219
- }
220
- });
221
- }
222
- if (__DEV__) {
223
- const totalEnabled = dialApps.filter(app => isDialEnabled(app.id)).length;
224
- if (totalEnabled > _floatingToolsCore.MAX_DIAL_SLOTS) {
225
- // More tools enabled than can be shown - they will be hidden
194
+ // Snapshot the usage-ranked order when the dial opens. It stays stable while
195
+ // open so icons don't jump positions mid-interaction.
196
+ const [rankedIds, setRankedIds] = (0, _react.useState)(() => (0, _dialUsageStore.getRankedToolIds)(dialApps.map(a => a.id)));
197
+ (0, _react.useEffect)(() => {
198
+ const ids = dialApps.map(a => a.id);
199
+ if ((0, _dialUsageStore.isDialUsageLoaded)()) {
200
+ setRankedIds((0, _dialUsageStore.getRankedToolIds)(ids));
201
+ return;
226
202
  }
227
- }
228
- const icons = [...enabledIcons];
229
- while (icons.length < _floatingToolsCore.MAX_DIAL_SLOTS) {
230
- icons.push(createEmptySlot(icons.length));
231
- }
203
+ // Usage data not loaded yet — show default order, then re-rank once ready.
204
+ let cancelled = false;
205
+ (0, _dialUsageStore.loadDialUsage)().then(() => {
206
+ if (!cancelled) setRankedIds((0, _dialUsageStore.getRankedToolIds)(ids));
207
+ });
208
+ return () => {
209
+ cancelled = true;
210
+ };
211
+ }, [dialApps]);
212
+ const pageCount = Math.max(1, Math.ceil(rankedIds.length / _floatingToolsCore.MAX_DIAL_SLOTS));
213
+ const [currentPage, setCurrentPage] = (0, _react.useState)(0);
214
+ const safePage = Math.min(currentPage, pageCount - 1);
215
+
216
+ // Every dial-eligible icon, with the page/slot it occupies. The ranking is
217
+ // snapshotted on open, so each icon's page and slot are fixed for the
218
+ // session — which lets us mount all icons once and paginate purely by
219
+ // toggling visibility (no remounts on page change).
220
+ const allDialIcons = (0, _react.useMemo)(() => {
221
+ return rankedIds.map(id => iconsById.get(id)).filter(icon => Boolean(icon)).map((icon, i) => ({
222
+ icon,
223
+ page: Math.floor(i / _floatingToolsCore.MAX_DIAL_SLOTS),
224
+ slot: i % _floatingToolsCore.MAX_DIAL_SLOTS
225
+ }));
226
+ }, [rankedIds, iconsById]);
227
+
228
+ // Empty slot indices for the current page (only the last page can be
229
+ // partial). These are cheap placeholder dots.
230
+ const emptySlots = (0, _react.useMemo)(() => {
231
+ const onThisPage = allDialIcons.filter(d => d.page === safePage).length;
232
+ const slots = [];
233
+ for (let s = onThisPage; s < _floatingToolsCore.MAX_DIAL_SLOTS; s += 1) slots.push(s);
234
+ return slots;
235
+ }, [allDialIcons, safePage]);
236
+
237
+ // Swap to another page. Every icon is already mounted, so this only toggles
238
+ // which ones are visible — no remount, no re-animation — keeping page
239
+ // changes instant.
240
+ const handlePageChange = next => {
241
+ const clamped = Math.max(0, Math.min(next, pageCount - 1));
242
+ if (clamped !== safePage) setCurrentPage(clamped);
243
+ };
232
244
 
233
245
  // Initialize animations on mount - using shared config from core
234
246
  (0, _react.useEffect)(() => {
@@ -249,13 +261,13 @@ const DialDevTools = ({
249
261
  }
250
262
  },
251
263
  centerButton: {
252
- delay: 300,
264
+ delay: 150,
253
265
  damping: 10,
254
266
  stiffness: 200
255
267
  },
256
268
  icons: {
257
- delay: 500,
258
- duration: 600
269
+ delay: 200,
270
+ duration: 400
259
271
  },
260
272
  circuitTraces: {
261
273
  delay: 600,
@@ -326,81 +338,21 @@ const DialDevTools = ({
326
338
  useNativeDriver: true
327
339
  })]).start();
328
340
 
329
- // Subtle glitch effect - using shared config
330
- const glitchAnimation = () => {
331
- _reactNative.Animated.sequence([_reactNative.Animated.timing(glitchOffset, {
332
- toValue: continuous.glitch.offset,
333
- duration: continuous.glitch.stepDuration,
334
- useNativeDriver: true
335
- }), _reactNative.Animated.timing(glitchOffset, {
336
- toValue: -continuous.glitch.offset,
337
- duration: continuous.glitch.stepDuration,
338
- useNativeDriver: true
339
- }), _reactNative.Animated.timing(glitchOffset, {
340
- toValue: 0,
341
- duration: continuous.glitch.stepDuration,
342
- useNativeDriver: true
343
- })]).start();
344
- };
345
- glitchIntervalRef.current = setInterval(glitchAnimation, continuous.glitch.interval);
346
-
347
- // Pulse animation - using shared config
348
- const startPulse = () => {
349
- pulseAnimationRef.current = _reactNative.Animated.loop(_reactNative.Animated.sequence([_reactNative.Animated.timing(pulseScale, {
350
- toValue: continuous.pulse.maxScale,
351
- duration: continuous.pulse.duration,
352
- easing: _reactNative.Easing.inOut(_reactNative.Easing.ease),
353
- useNativeDriver: true
354
- }), _reactNative.Animated.timing(pulseScale, {
355
- toValue: continuous.pulse.minScale,
356
- duration: continuous.pulse.duration,
357
- easing: _reactNative.Easing.inOut(_reactNative.Easing.ease),
358
- useNativeDriver: true
359
- })]));
360
- pulseAnimationRef.current.start();
361
- };
362
- startPulse();
363
-
364
- // Subtle floating animation for the dial - using shared config
365
- _reactNative.Animated.loop(_reactNative.Animated.sequence([_reactNative.Animated.timing(floatingAnim, {
366
- toValue: continuous.floating.maxY,
367
- duration: continuous.floating.duration,
341
+ // Pulse animation - only continuous effect kept for performance
342
+ pulseAnimationRef.current = _reactNative.Animated.loop(_reactNative.Animated.sequence([_reactNative.Animated.timing(pulseScale, {
343
+ toValue: continuous.pulse.maxScale,
344
+ duration: continuous.pulse.duration,
368
345
  easing: _reactNative.Easing.inOut(_reactNative.Easing.ease),
369
346
  useNativeDriver: true
370
- }), _reactNative.Animated.timing(floatingAnim, {
371
- toValue: continuous.floating.minY,
372
- duration: continuous.floating.duration,
347
+ }), _reactNative.Animated.timing(pulseScale, {
348
+ toValue: continuous.pulse.minScale,
349
+ duration: continuous.pulse.duration,
373
350
  easing: _reactNative.Easing.inOut(_reactNative.Easing.ease),
374
351
  useNativeDriver: true
375
- })])).start();
376
-
377
- // Gentle breathing effect for center button - using shared config
378
- _reactNative.Animated.loop(_reactNative.Animated.sequence([_reactNative.Animated.timing(breathingScale, {
379
- toValue: continuous.breathing.maxScale,
380
- duration: continuous.breathing.duration,
381
- easing: _reactNative.Easing.inOut(_reactNative.Easing.ease),
382
- useNativeDriver: true
383
- }), _reactNative.Animated.timing(breathingScale, {
384
- toValue: continuous.breathing.minScale,
385
- duration: continuous.breathing.duration,
386
- easing: _reactNative.Easing.inOut(_reactNative.Easing.ease),
387
- useNativeDriver: true
388
- })])).start();
389
-
390
- // Circuit traces fade in - using shared config
391
- _reactNative.Animated.timing(circuitOpacity, {
392
- toValue: 1,
393
- duration: entrance.circuitTraces.duration,
394
- delay: entrance.circuitTraces.delay,
395
- useNativeDriver: true
396
- }).start();
352
+ })]));
353
+ pulseAnimationRef.current.start();
397
354
  return () => {
398
- if (glitchIntervalRef.current) {
399
- clearInterval(glitchIntervalRef.current);
400
- }
401
- if (pulseAnimationRef.current) {
402
- pulseAnimationRef.current.stop();
403
- }
355
+ pulseAnimationRef.current?.stop();
404
356
  };
405
357
  }, []);
406
358
  const handleOnboardingDismiss = () => {
@@ -472,8 +424,8 @@ const DialDevTools = ({
472
424
  }
473
425
  });
474
426
  };
475
- const handleIconPress = index => {
476
- setSelectedIcon(index);
427
+ const handleIconPress = icon => {
428
+ setSelectedIcon(1);
477
429
  const interaction = _floatingToolsCore.dialAnimationConfig?.interaction ?? {
478
430
  iconSelect: {
479
431
  pulse: [{
@@ -505,9 +457,9 @@ const DialDevTools = ({
505
457
 
506
458
  // Trigger action - using shared delay
507
459
  setTimeout(() => {
508
- icons[index].onPress();
460
+ icon.onPress();
509
461
  // Only close if it's not the WiFi toggle (by id)
510
- if (icons[index].id !== "wifi") {
462
+ if (icon.id !== "wifi") {
511
463
  handleClose();
512
464
  }
513
465
  }, interaction.iconSelect.actionDelay);
@@ -517,14 +469,9 @@ const DialDevTools = ({
517
469
  const backdropAnimatedStyle = {
518
470
  opacity: backdropOpacity
519
471
  };
520
- const glitchAnimatedStyle = {
521
- transform: [{
522
- translateX: glitchOffset
523
- }]
524
- };
525
472
  const centerButtonAnimatedStyle = {
526
473
  transform: [{
527
- scale: _reactNative.Animated.multiply(centerButtonScale, breathingScale)
474
+ scale: centerButtonScale
528
475
  }]
529
476
  };
530
477
  const pulseAnimatedStyle = {
@@ -547,8 +494,6 @@ const DialDevTools = ({
547
494
  left: (SCREEN_WIDTH - CIRCLE_SIZE) / 2,
548
495
  bottom: 80,
549
496
  transform: [{
550
- translateY: floatingAnim
551
- }, {
552
497
  scale: dialScale
553
498
  }, {
554
499
  rotate: dialRotation.interpolate({
@@ -558,7 +503,7 @@ const DialDevTools = ({
558
503
  }]
559
504
  }],
560
505
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Animated.View, {
561
- style: [styles.circle, glitchAnimatedStyle],
506
+ style: styles.circle,
562
507
  children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
563
508
  style: styles.gradientBackground,
564
509
  children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
@@ -579,14 +524,26 @@ const DialDevTools = ({
579
524
  }]
580
525
  }, i))
581
526
  })]
582
- }), icons.map((icon, i) => /*#__PURE__*/(0, _jsxRuntime.jsx)(_DialIcon.DialIcon, {
583
- selectedIcon: selectedIcon,
527
+ }), allDialIcons.filter(({
528
+ page
529
+ }) => page === safePage).map(({
530
+ icon,
531
+ slot
532
+ }) => /*#__PURE__*/(0, _jsxRuntime.jsx)(_DialIcon.DialIcon, {
584
533
  onPress: handleIconPress,
585
534
  iconsProgress: iconsProgress,
586
535
  icon: icon,
587
- index: i,
588
- totalIcons: icons.length
589
- }, `${i}-${icon.name}`))]
536
+ index: slot,
537
+ totalIcons: _floatingToolsCore.MAX_DIAL_SLOTS,
538
+ active: true
539
+ }, icon.id ?? `page${safePage}-${slot}`)), emptySlots.map(slot => /*#__PURE__*/(0, _jsxRuntime.jsx)(_DialIcon.DialIcon, {
540
+ onPress: handleIconPress,
541
+ iconsProgress: iconsProgress,
542
+ icon: createEmptySlot(slot),
543
+ index: slot,
544
+ totalIcons: _floatingToolsCore.MAX_DIAL_SLOTS,
545
+ active: true
546
+ }, `empty-${slot}`))]
590
547
  }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Animated.View, {
591
548
  style: [styles.buttonContainer, centerButtonAnimatedStyle],
592
549
  children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
@@ -644,19 +601,32 @@ const DialDevTools = ({
644
601
  })]
645
602
  })
646
603
  })]
647
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_DevToolsSettingsModal.DevToolsSettingsModal, {
648
- visible: isSettingsModalOpen,
604
+ }), pageCount > 1 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_DialPagination.DialPagination, {
605
+ page: safePage,
606
+ pageCount: pageCount,
607
+ onPrev: () => handlePageChange(safePage - 1),
608
+ onNext: () => handlePageChange(safePage + 1),
609
+ animatedStyle: {
610
+ position: "absolute",
611
+ left: (SCREEN_WIDTH - CIRCLE_SIZE) / 2,
612
+ // Circle's bottom edge sits at bottom: 80 -> SCREEN_HEIGHT - 80
613
+ // from the top. Place the pager 16px below that edge.
614
+ top: SCREEN_HEIGHT - 80 + 16,
615
+ width: CIRCLE_SIZE,
616
+ opacity: dialScale,
617
+ transform: [{
618
+ scale: dialScale
619
+ }]
620
+ }
621
+ }), isSettingsModalOpen && /*#__PURE__*/(0, _jsxRuntime.jsx)(_DevToolsSettingsModal.DevToolsSettingsModal, {
622
+ visible: true,
649
623
  onClose: () => {
650
624
  setIsSettingsModalOpen(false);
651
625
  refreshSettings(); // Refresh from storage
652
626
  },
653
- onSettingsChange: newSettings => {
654
- // Immediately update local settings for instant feedback
655
- setLocalSettings(newSettings);
656
- },
657
627
  availableApps: availableApps
658
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_OnboardingTooltip.OnboardingTooltip, {
659
- visible: showOnboardingTooltip && !isSettingsModalOpen && !onboardingDismissedRef.current,
628
+ }), showOnboardingTooltip && !isSettingsModalOpen && !onboardingDismissedRef.current && /*#__PURE__*/(0, _jsxRuntime.jsx)(_OnboardingTooltip.OnboardingTooltip, {
629
+ visible: true,
660
630
  onDismiss: handleOnboardingDismiss
661
631
  })]
662
632
  });
@@ -214,8 +214,14 @@ function DialMenu({
214
214
  }), []);
215
215
  const gridRotations = (0, _react.useMemo)(() => (0, _floatingToolsReact.getGridLineRotations)(), []);
216
216
  const positions = (0, _react.useMemo)(() => (0, _floatingToolsReact.getAllIconPositions)(_floatingToolsReact.MAX_DIAL_SLOTS, layout.iconRadius), [layout.iconRadius]);
217
+
218
+ // Pagination: tools are split across pages of MAX_DIAL_SLOTS.
219
+ const [currentPage, setCurrentPage] = (0, _react.useState)(0);
220
+ const pageCount = Math.max(1, Math.ceil(icons.length / _floatingToolsReact.MAX_DIAL_SLOTS));
221
+ const safePage = Math.min(currentPage, pageCount - 1);
217
222
  const paddedIcons = (0, _react.useMemo)(() => {
218
- const result = [...icons.slice(0, _floatingToolsReact.MAX_DIAL_SLOTS)];
223
+ const start = safePage * _floatingToolsReact.MAX_DIAL_SLOTS;
224
+ const result = [...icons.slice(start, start + _floatingToolsReact.MAX_DIAL_SLOTS)];
219
225
  while (result.length < _floatingToolsReact.MAX_DIAL_SLOTS) {
220
226
  result.push({
221
227
  id: `empty-${result.length}`,
@@ -225,7 +231,7 @@ function DialMenu({
225
231
  });
226
232
  }
227
233
  return result;
228
- }, [icons]);
234
+ }, [icons, safePage]);
229
235
 
230
236
  // Inject keyframes
231
237
  (0, _react.useEffect)(() => {
@@ -402,10 +408,37 @@ function DialMenu({
402
408
  }, interaction.iconSelect.actionDelay);
403
409
  }, [interaction.iconSelect, handleClose]);
404
410
 
411
+ // Page navigation - icons are keyed by slot index, so this only swaps
412
+ // their content in place (no remount, no re-animation) for an instant page
413
+ // change.
414
+ const handlePageChange = (0, _react.useCallback)(next => {
415
+ if (isClosingRef.current) return;
416
+ const clamped = Math.max(0, Math.min(next, pageCount - 1));
417
+ if (clamped !== safePage) setCurrentPage(clamped);
418
+ }, [pageCount, safePage]);
419
+
405
420
  // Computed values
406
421
  const buttonContainerSize = layout.buttonSize * _floatingToolsReact.dialStyles.centerButton.containerRatio;
407
422
  const buttonBorderSize = layout.buttonSize * _floatingToolsReact.dialStyles.centerButton.borderRatio;
408
423
  const isAnimating = entranceComplete && !isExiting;
424
+ const pagerButtonStyle = disabled => ({
425
+ display: 'flex',
426
+ alignItems: 'center',
427
+ gap: 4,
428
+ padding: '8px 16px',
429
+ borderRadius: 10,
430
+ border: `1px solid ${_floatingToolsReact.dialColors.dialBorder}`,
431
+ backgroundColor: _floatingToolsReact.dialColors.dialBackground,
432
+ color: disabled ? _floatingToolsReact.dialColors.emptyDotBorder : _floatingToolsReact.dialColors.dialShadow,
433
+ fontSize: 12,
434
+ fontWeight: 900,
435
+ fontFamily: 'monospace',
436
+ letterSpacing: 1.5,
437
+ textTransform: 'uppercase',
438
+ cursor: disabled ? 'default' : 'pointer',
439
+ opacity: disabled ? 0.4 : 1,
440
+ boxShadow: disabled ? 'none' : `0 0 8px ${_floatingToolsReact.dialColors.dialShadow}66`
441
+ });
409
442
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
410
443
  role: "dialog",
411
444
  "aria-label": "Dial Menu",
@@ -430,11 +463,15 @@ function DialMenu({
430
463
  backgroundColor: _floatingToolsReact.dialColors.dialBackdrop,
431
464
  opacity: backdropOpacity
432
465
  }
433
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
466
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
434
467
  style: {
435
- animation: isAnimating ? cssAnimations.floating : 'none'
468
+ animation: isAnimating ? cssAnimations.floating : 'none',
469
+ display: 'flex',
470
+ flexDirection: 'column',
471
+ alignItems: 'center',
472
+ gap: 16
436
473
  },
437
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
474
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
438
475
  style: {
439
476
  animation: triggerGlitch > 0 && isAnimating ? cssAnimations.glitch : 'none'
440
477
  },
@@ -497,7 +534,7 @@ function DialMenu({
497
534
  position: positions[index],
498
535
  progress: iconProgress,
499
536
  onPress: () => handleIconPress(icon)
500
- }, icon.id)), /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
537
+ }, index)), /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
501
538
  style: {
502
539
  position: 'absolute',
503
540
  left: '50%',
@@ -587,7 +624,45 @@ function DialMenu({
587
624
  })]
588
625
  })]
589
626
  })
590
- }, triggerGlitch)
627
+ }, triggerGlitch), pageCount > 1 && /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
628
+ style: {
629
+ display: 'flex',
630
+ alignItems: 'center',
631
+ gap: 12,
632
+ opacity: Math.min(1, dialScale)
633
+ },
634
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("button", {
635
+ type: "button",
636
+ "aria-label": "Previous dial page",
637
+ disabled: safePage <= 0,
638
+ onClick: () => handlePageChange(safePage - 1),
639
+ style: pagerButtonStyle(safePage <= 0),
640
+ children: "\u2039 PREV"
641
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)("span", {
642
+ style: {
643
+ fontSize: 13,
644
+ fontWeight: 900,
645
+ fontFamily: 'monospace',
646
+ letterSpacing: 2,
647
+ color: '#FFFFFF',
648
+ textShadow: `0 0 6px ${_floatingToolsReact.dialColors.dialShadow}`
649
+ },
650
+ children: [String(safePage + 1).padStart(2, '0'), /*#__PURE__*/(0, _jsxRuntime.jsxs)("span", {
651
+ style: {
652
+ color: _floatingToolsReact.dialColors.iconLabel,
653
+ textShadow: 'none'
654
+ },
655
+ children: [' / ', String(pageCount).padStart(2, '0')]
656
+ })]
657
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)("button", {
658
+ type: "button",
659
+ "aria-label": "Next dial page",
660
+ disabled: safePage >= pageCount - 1,
661
+ onClick: () => handlePageChange(safePage + 1),
662
+ style: pagerButtonStyle(safePage >= pageCount - 1),
663
+ children: "NEXT \u203A"
664
+ })]
665
+ })]
591
666
  })]
592
667
  });
593
668
  }