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