@buoy-gg/core 7.0.15 → 7.0.16

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 (99) hide show
  1. package/lib/commonjs/floatingMenu/AppHost.web.js +359 -0
  2. package/lib/commonjs/floatingMenu/AppHostLogic.js +6 -0
  3. package/lib/commonjs/floatingMenu/DevToolsSettingsModal.web.js +787 -418
  4. package/lib/commonjs/floatingMenu/FloatingDevTools.js +1 -1
  5. package/lib/commonjs/floatingMenu/FloatingDevTools.web.js +244 -36
  6. package/lib/commonjs/floatingMenu/FloatingMenu.js +2 -2
  7. package/lib/commonjs/floatingMenu/MinimizedToolsContext.js +2 -3
  8. package/lib/commonjs/floatingMenu/MinimizedToolsStack.web.js +287 -0
  9. package/lib/commonjs/floatingMenu/autoDiscoverPresets.js +16 -0
  10. package/lib/commonjs/floatingMenu/autoExternalSync.js +9 -0
  11. package/lib/commonjs/floatingMenu/dial/DialDevTools.js +7 -4
  12. package/lib/commonjs/floatingMenu/dial/DialDevTools.web.js +373 -231
  13. package/lib/commonjs/floatingMenu/dial/DialIcon.js +25 -3
  14. package/lib/commonjs/floatingMenu/dial/OnboardingTooltip.js +1 -1
  15. package/lib/commonjs/floatingMenu/dial/OnboardingTooltip.web.js +154 -0
  16. package/lib/commonjs/floatingMenu/dial/onboardingConstants.web.js +34 -0
  17. package/lib/commonjs/floatingMenu/floatingTools.js +3 -3
  18. package/lib/commonjs/floatingMenu/floatingTools.web.js +191 -28
  19. package/lib/commonjs/index.js +2 -2
  20. package/lib/commonjs/index.web.js +19 -0
  21. package/lib/commonjs/utils/autoDiscoverPresets.web.js +50 -5
  22. package/lib/module/floatingMenu/AppHost.web.js +351 -0
  23. package/lib/module/floatingMenu/AppHostLogic.js +6 -0
  24. package/lib/module/floatingMenu/DevToolsSettingsModal.web.js +792 -422
  25. package/lib/module/floatingMenu/FloatingDevTools.js +2 -2
  26. package/lib/module/floatingMenu/FloatingDevTools.web.js +247 -38
  27. package/lib/module/floatingMenu/FloatingMenu.js +2 -2
  28. package/lib/module/floatingMenu/MinimizedToolsContext.js +2 -3
  29. package/lib/module/floatingMenu/MinimizedToolsStack.web.js +284 -0
  30. package/lib/module/floatingMenu/autoDiscoverPresets.js +16 -0
  31. package/lib/module/floatingMenu/autoExternalSync.js +9 -0
  32. package/lib/module/floatingMenu/dial/DialDevTools.js +8 -5
  33. package/lib/module/floatingMenu/dial/DialDevTools.web.js +375 -233
  34. package/lib/module/floatingMenu/dial/DialIcon.js +26 -4
  35. package/lib/module/floatingMenu/dial/OnboardingTooltip.js +1 -1
  36. package/lib/module/floatingMenu/dial/OnboardingTooltip.web.js +149 -0
  37. package/lib/module/floatingMenu/dial/onboardingConstants.web.js +27 -0
  38. package/lib/module/floatingMenu/floatingTools.js +3 -3
  39. package/lib/module/floatingMenu/floatingTools.web.js +190 -28
  40. package/lib/module/index.js +2 -2
  41. package/lib/module/index.web.js +5 -0
  42. package/lib/module/utils/autoDiscoverPresets.web.js +53 -7
  43. package/lib/typescript/commonjs/floatingMenu/AppHost.web.d.ts +38 -0
  44. package/lib/typescript/commonjs/floatingMenu/AppHost.web.d.ts.map +1 -0
  45. package/lib/typescript/commonjs/floatingMenu/AppHostLogic.d.ts +21 -1
  46. package/lib/typescript/commonjs/floatingMenu/AppHostLogic.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/floatingMenu/DevToolsSettingsModal.web.d.ts +5 -2
  48. package/lib/typescript/commonjs/floatingMenu/DevToolsSettingsModal.web.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.web.d.ts +2 -14
  50. package/lib/typescript/commonjs/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/floatingMenu/MinimizedToolsContext.d.ts +2 -20
  52. package/lib/typescript/commonjs/floatingMenu/MinimizedToolsContext.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/floatingMenu/MinimizedToolsStack.web.d.ts +21 -0
  54. package/lib/typescript/commonjs/floatingMenu/MinimizedToolsStack.web.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/floatingMenu/autoDiscoverPresets.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/floatingMenu/autoExternalSync.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.web.d.ts +16 -2
  59. package/lib/typescript/commonjs/floatingMenu/dial/DialDevTools.web.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/floatingMenu/dial/DialIcon.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/floatingMenu/dial/OnboardingTooltip.web.d.ts +9 -0
  62. package/lib/typescript/commonjs/floatingMenu/dial/OnboardingTooltip.web.d.ts.map +1 -0
  63. package/lib/typescript/commonjs/floatingMenu/dial/onboardingConstants.web.d.ts +13 -0
  64. package/lib/typescript/commonjs/floatingMenu/dial/onboardingConstants.web.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/floatingMenu/floatingTools.web.d.ts +29 -2
  66. package/lib/typescript/commonjs/floatingMenu/floatingTools.web.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/index.web.d.ts +1 -0
  68. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/utils/autoDiscoverPresets.web.d.ts +2 -0
  70. package/lib/typescript/commonjs/utils/autoDiscoverPresets.web.d.ts.map +1 -1
  71. package/lib/typescript/module/floatingMenu/AppHost.web.d.ts +38 -0
  72. package/lib/typescript/module/floatingMenu/AppHost.web.d.ts.map +1 -0
  73. package/lib/typescript/module/floatingMenu/AppHostLogic.d.ts +21 -1
  74. package/lib/typescript/module/floatingMenu/AppHostLogic.d.ts.map +1 -1
  75. package/lib/typescript/module/floatingMenu/DevToolsSettingsModal.web.d.ts +5 -2
  76. package/lib/typescript/module/floatingMenu/DevToolsSettingsModal.web.d.ts.map +1 -1
  77. package/lib/typescript/module/floatingMenu/FloatingDevTools.web.d.ts +2 -14
  78. package/lib/typescript/module/floatingMenu/FloatingDevTools.web.d.ts.map +1 -1
  79. package/lib/typescript/module/floatingMenu/MinimizedToolsContext.d.ts +2 -20
  80. package/lib/typescript/module/floatingMenu/MinimizedToolsContext.d.ts.map +1 -1
  81. package/lib/typescript/module/floatingMenu/MinimizedToolsStack.web.d.ts +21 -0
  82. package/lib/typescript/module/floatingMenu/MinimizedToolsStack.web.d.ts.map +1 -0
  83. package/lib/typescript/module/floatingMenu/autoDiscoverPresets.d.ts.map +1 -1
  84. package/lib/typescript/module/floatingMenu/autoExternalSync.d.ts.map +1 -1
  85. package/lib/typescript/module/floatingMenu/dial/DialDevTools.d.ts.map +1 -1
  86. package/lib/typescript/module/floatingMenu/dial/DialDevTools.web.d.ts +16 -2
  87. package/lib/typescript/module/floatingMenu/dial/DialDevTools.web.d.ts.map +1 -1
  88. package/lib/typescript/module/floatingMenu/dial/DialIcon.d.ts.map +1 -1
  89. package/lib/typescript/module/floatingMenu/dial/OnboardingTooltip.web.d.ts +9 -0
  90. package/lib/typescript/module/floatingMenu/dial/OnboardingTooltip.web.d.ts.map +1 -0
  91. package/lib/typescript/module/floatingMenu/dial/onboardingConstants.web.d.ts +13 -0
  92. package/lib/typescript/module/floatingMenu/dial/onboardingConstants.web.d.ts.map +1 -0
  93. package/lib/typescript/module/floatingMenu/floatingTools.web.d.ts +29 -2
  94. package/lib/typescript/module/floatingMenu/floatingTools.web.d.ts.map +1 -1
  95. package/lib/typescript/module/index.web.d.ts +1 -0
  96. package/lib/typescript/module/index.web.d.ts.map +1 -1
  97. package/lib/typescript/module/utils/autoDiscoverPresets.web.d.ts +2 -0
  98. package/lib/typescript/module/utils/autoDiscoverPresets.web.d.ts.map +1 -1
  99. package/package.json +5 -5
@@ -6,16 +6,22 @@
6
6
  * Uses shared layout calculations, styling, and animation config from @buoy-gg/floating-tools-core.
7
7
  * Platform-specific: CSS animations, mouse events, web JSX.
8
8
  *
9
- * Animation behavior matches mobile exactly - see docs/dial-animation-spec.md
9
+ * Visuals mirror the native DialDevTools.tsx exactly — values that never made
10
+ * it into the shared dial.ts are inlined here with provenance comments.
10
11
  *
11
12
  * @platform React DOM Web (Vite, CRA, Next.js, Electron)
12
13
  */
13
14
 
14
15
  import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
15
- import { getDialLayout, getAllIconPositions, getGridLineRotations, getIconStaggerInputRange, MAX_DIAL_SLOTS, dialAnimationConfig, getDialCSSKeyframesString, getDialCSSAnimationStyles, dialColors, dialStyles } from '@buoy-gg/floating-tools-react';
16
+ import { getDialLayout, getAllIconPositions, getGridLineRotations, getIconStaggerInputRange, MAX_DIAL_SLOTS, DIAL_ICON_SIZE, dialAnimationConfig, getDialCSSKeyframesString, getDialCSSAnimationStyles, dialColors, dialStyles, withAlpha, floatingToolsColors } from '@buoy-gg/floating-tools-react';
17
+ import { persistentStorage } from '@buoy-gg/shared-ui';
18
+ import { OnboardingTooltip } from "./OnboardingTooltip.web.js";
16
19
 
17
- // Inject CSS keyframes once on module load
20
+ // native DialDevTools.tsx SETTINGS_TOOLTIP_KEY
18
21
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
22
+ const SETTINGS_TOOLTIP_KEY = '@react_buoy_settings_tooltip_shown';
23
+
24
+ // Inject CSS keyframes once on module load
19
25
  let keyframesInjected = false;
20
26
  function injectKeyframes() {
21
27
  if (keyframesInjected || typeof document === 'undefined') return;
@@ -48,6 +54,8 @@ function DialIconItem({
48
54
  const [isHovered, setIsHovered] = useState(false);
49
55
  const [isPressed, setIsPressed] = useState(false);
50
56
  const isEmpty = !icon.icon && icon.id.startsWith('empty-');
57
+ const isUnavailable = !isEmpty && icon.unavailable === true;
58
+ const interactive = !isEmpty && !isUnavailable;
51
59
  const {
52
60
  icons: iconConfig
53
61
  } = dialAnimationConfig;
@@ -65,12 +73,13 @@ function DialIconItem({
65
73
  iconProgress = 1;
66
74
  }
67
75
  iconProgress = Math.max(0, Math.min(1, iconProgress));
68
- const iconSize = 60;
76
+ const iconSize = DIAL_ICON_SIZE;
69
77
  const pressedScale = iconConfig.pressIn.scale;
70
- const hoveredScale = 1.1;
78
+ const hoveredScale = 1.1; // web-only hover affordance (no native counterpart)
79
+
71
80
  const scale = iconProgress * (isPressed ? pressedScale : isHovered ? hoveredScale : 1);
72
81
 
73
- // Opacity interpolation
82
+ // Opacity interpolation — native DialIcon.tsx: [0, 0.3, 1] -> [0, 0.3, 1]
74
83
  let opacity = 0;
75
84
  if (iconProgress <= 0) {
76
85
  opacity = 0;
@@ -84,15 +93,15 @@ function DialIconItem({
84
93
  return /*#__PURE__*/_jsx("button", {
85
94
  role: "menuitem",
86
95
  "aria-label": isEmpty ? 'Empty slot' : icon.name,
87
- "aria-disabled": isEmpty,
88
- tabIndex: isEmpty ? -1 : 0,
89
- onClick: isEmpty ? undefined : onPress,
90
- onMouseEnter: () => !isEmpty && setIsHovered(true),
96
+ "aria-disabled": !interactive,
97
+ tabIndex: interactive ? 0 : -1,
98
+ onClick: interactive ? onPress : undefined,
99
+ onMouseEnter: () => interactive && setIsHovered(true),
91
100
  onMouseLeave: () => {
92
101
  setIsHovered(false);
93
102
  setIsPressed(false);
94
103
  },
95
- onMouseDown: () => !isEmpty && setIsPressed(true),
104
+ onMouseDown: () => interactive && setIsPressed(true),
96
105
  onMouseUp: () => setIsPressed(false),
97
106
  style: {
98
107
  position: 'absolute',
@@ -106,7 +115,7 @@ function DialIconItem({
106
115
  flexDirection: 'column',
107
116
  alignItems: 'center',
108
117
  justifyContent: 'center',
109
- cursor: isEmpty ? 'default' : 'pointer',
118
+ cursor: interactive ? 'pointer' : 'default',
110
119
  transform: `translate(${x}px, ${y}px) scale(${scale})`,
111
120
  opacity,
112
121
  transition: isHovered || isPressed ? 'transform 100ms ease' : 'none',
@@ -148,25 +157,130 @@ function DialIconItem({
148
157
  alignItems: 'center',
149
158
  justifyContent: 'center',
150
159
  fontSize: 32,
160
+ // native DialIcon.tsx: icon rendered at size 32
151
161
  filter: isHovered ? 'brightness(1.2)' : 'none'
152
162
  },
153
163
  children: icon.icon
154
164
  }), /*#__PURE__*/_jsx("span", {
155
165
  style: {
156
- fontSize: dialStyles.icon.label.fontSize,
166
+ fontSize:
167
+ // native: adjustsFontSizeToFit minimumFontScale 0.7 — long
168
+ // names shrink instead of wrapping/overflowing the slot.
169
+ icon.name.length > 8 ? Math.max(dialStyles.icon.label.fontSize * 0.7, dialStyles.icon.label.fontSize * 8 / icon.name.length) : dialStyles.icon.label.fontSize,
157
170
  fontWeight: dialStyles.icon.label.fontWeight,
158
171
  fontFamily: dialStyles.icon.label.fontFamily,
159
172
  color: dialColors.iconLabel,
160
173
  letterSpacing: dialStyles.icon.label.letterSpacing,
161
174
  marginTop: dialStyles.icon.label.marginTop,
162
- textTransform: 'uppercase'
175
+ textTransform: 'uppercase',
176
+ whiteSpace: 'nowrap'
163
177
  },
164
178
  children: icon.name
179
+ }), isUnavailable &&
180
+ /*#__PURE__*/
181
+ // native DialIcon.tsx unavailableCaption: label size - 1, muted
182
+ _jsx("span", {
183
+ style: {
184
+ fontSize: dialStyles.icon.label.fontSize - 1,
185
+ fontWeight: dialStyles.icon.label.fontWeight,
186
+ fontFamily: dialStyles.icon.label.fontFamily,
187
+ letterSpacing: dialStyles.icon.label.letterSpacing,
188
+ color: floatingToolsColors.muted,
189
+ textTransform: 'uppercase',
190
+ whiteSpace: 'nowrap'
191
+ },
192
+ children: "Unavailable"
165
193
  })]
166
194
  })
167
195
  });
168
196
  }
169
197
 
198
+ // =============================
199
+ // Pagination (mirrors native DialPagination.tsx)
200
+ // =============================
201
+
202
+ /**
203
+ * Chevron glyph matching the native pager's ChevronLeft/ChevronRight from
204
+ * @buoy-gg/shared-ui (lucide two-line chevron, size 18, strokeWidth 2).
205
+ * Inlined as SVG because shared-ui is React Native-only.
206
+ */
207
+ function PagerChevron({
208
+ side,
209
+ color
210
+ }) {
211
+ // native lucide-icons.tsx ChevronLeft: (15,18)->(9,12)->(15,6); ChevronRight mirrored
212
+ const lines = side === 'prev' ? [[15, 18, 9, 12], [9, 12, 15, 6]] : [[9, 18, 15, 12], [15, 12, 9, 6]];
213
+ return /*#__PURE__*/_jsx("svg", {
214
+ width: 18,
215
+ height: 18,
216
+ viewBox: "0 0 24 24",
217
+ "aria-hidden": "true",
218
+ focusable: "false",
219
+ children: lines.map(([x1, y1, x2, y2], i) => /*#__PURE__*/_jsx("line", {
220
+ x1: x1,
221
+ y1: y1,
222
+ x2: x2,
223
+ y2: y2,
224
+ stroke: color,
225
+ strokeWidth: 2
226
+ }, i))
227
+ });
228
+ }
229
+ function PagerButton({
230
+ side,
231
+ disabled,
232
+ onClick
233
+ }) {
234
+ const [isPressed, setIsPressed] = useState(false);
235
+
236
+ // native DialPagination.tsx: accent = disabled ? emptyDotBorder : buoyColors.primary
237
+ // (buoyColors.primary === floatingToolsColors.info === dialColors.dialShadow, #20C997)
238
+ const accent = disabled ? dialColors.emptyDotBorder : dialColors.dialShadow;
239
+ return /*#__PURE__*/_jsxs("button", {
240
+ type: "button",
241
+ "aria-label": `${side === 'prev' ? 'Previous' : 'Next'} dial page`,
242
+ disabled: disabled,
243
+ onClick: onClick,
244
+ onMouseDown: () => !disabled && setIsPressed(true),
245
+ onMouseUp: () => setIsPressed(false),
246
+ onMouseLeave: () => setIsPressed(false),
247
+ style: {
248
+ display: 'flex',
249
+ alignItems: 'center',
250
+ gap: 4,
251
+ // native DialPagination.tsx: gap 4
252
+ padding: '10px 16px',
253
+ // native: paddingVertical 10, paddingHorizontal 16
254
+ borderRadius: 10,
255
+ border: `1px solid ${dialColors.dialBorder}`,
256
+ backgroundColor: dialColors.dialBackground,
257
+ color: accent,
258
+ fontSize: 12,
259
+ fontWeight: 900,
260
+ fontFamily: 'monospace',
261
+ letterSpacing: 1.5,
262
+ textTransform: 'uppercase',
263
+ // native: textShadow buoyColors.primary radius 4 (removed when disabled)
264
+ textShadow: disabled ? 'none' : `0 0 4px ${accent}`,
265
+ cursor: disabled ? 'default' : 'pointer',
266
+ opacity: disabled ? 0.4 : 1,
267
+ // native: buttonDisabled opacity 0.4
268
+ // native: shadowColor dialShadow, shadowOpacity 0.4, shadowRadius 8
269
+ boxShadow: disabled ? 'none' : `0 0 8px ${withAlpha(dialColors.dialShadow, 0.4)}`,
270
+ // native: press-in springs to scale 0.92
271
+ transform: isPressed ? 'scale(0.92)' : 'scale(1)',
272
+ transition: 'transform 100ms ease'
273
+ },
274
+ children: [side === 'prev' && /*#__PURE__*/_jsx(PagerChevron, {
275
+ side: "prev",
276
+ color: accent
277
+ }), side === 'prev' ? 'PREV' : 'NEXT', side === 'next' && /*#__PURE__*/_jsx(PagerChevron, {
278
+ side: "next",
279
+ color: accent
280
+ })]
281
+ });
282
+ }
283
+
170
284
  // =============================
171
285
  // Main DialMenu Component
172
286
  // =============================
@@ -175,7 +289,9 @@ export function DialMenu({
175
289
  icons,
176
290
  onClose,
177
291
  centerLabel = 'MENU',
178
- onCenterPress
292
+ onCenterPress,
293
+ settingsOpen = false,
294
+ hintsDisabled = false
179
295
  }) {
180
296
  // Animation values
181
297
  const [backdropOpacity, setBackdropOpacity] = useState(0);
@@ -187,20 +303,17 @@ export function DialMenu({
187
303
 
188
304
  // UI state
189
305
  const [isHoveringCenter, setIsHoveringCenter] = useState(false);
190
- const [triggerGlitch, setTriggerGlitch] = useState(0);
191
306
  const [isExiting, setIsExiting] = useState(false);
192
307
  const [entranceComplete, setEntranceComplete] = useState(false);
193
308
 
194
309
  // Refs
195
310
  const animationRef = useRef(null);
196
- const glitchIntervalRef = useRef(null);
197
311
  const isClosingRef = useRef(false);
198
312
 
199
313
  // Config
200
314
  const {
201
315
  entrance,
202
316
  exit,
203
- continuous,
204
317
  interaction
205
318
  } = dialAnimationConfig;
206
319
  const cssAnimations = getDialCSSAnimationStyles();
@@ -243,7 +356,6 @@ export function DialMenu({
243
356
  const centerButtonDelay = entrance.centerButton.delay;
244
357
  const iconsDelay = entrance.icons.delay;
245
358
  const iconsDuration = entrance.icons.duration;
246
- const glitchInterval = continuous.glitch.interval;
247
359
  const startTime = performance.now();
248
360
  const animate = currentTime => {
249
361
  const elapsed = currentTime - startTime;
@@ -296,14 +408,8 @@ export function DialMenu({
296
408
  }
297
409
  };
298
410
  animationRef.current = requestAnimationFrame(animate);
299
-
300
- // Glitch interval
301
- glitchIntervalRef.current = setInterval(() => {
302
- setTriggerGlitch(prev => prev + 1);
303
- }, glitchInterval);
304
411
  return () => {
305
412
  if (animationRef.current) cancelAnimationFrame(animationRef.current);
306
- if (glitchIntervalRef.current) clearInterval(glitchIntervalRef.current);
307
413
  };
308
414
  }, []);
309
415
 
@@ -312,16 +418,14 @@ export function DialMenu({
312
418
  if (isClosingRef.current) return;
313
419
  isClosingRef.current = true;
314
420
  setIsExiting(true);
315
- if (glitchIntervalRef.current) {
316
- clearInterval(glitchIntervalRef.current);
317
- glitchIntervalRef.current = null;
318
- }
319
421
  if (animationRef.current) {
320
422
  cancelAnimationFrame(animationRef.current);
321
423
  }
322
424
  const startTime = performance.now();
323
425
  const iconsDuration = exit.icons.duration;
324
- const dialDuration = Math.max(exit.centerButton.duration, exit.dialScale.duration);
426
+ const centerDuration = exit.centerButton.duration;
427
+ const dialDuration = exit.dialScale.duration;
428
+ const step2Duration = Math.max(centerDuration, dialDuration);
325
429
  const backdropDuration = exit.backdrop.duration;
326
430
  const animate = currentTime => {
327
431
  const elapsed = currentTime - startTime;
@@ -334,19 +438,21 @@ export function DialMenu({
334
438
  return;
335
439
  }
336
440
 
337
- // Step 2: Dial + Center scale down (300 - 550ms)
441
+ // Step 2: Dial + Center scale down in parallel — native runs the center
442
+ // button (200ms) and dial (250ms) with their own durations.
338
443
  const step2Start = iconsDuration;
339
- if (elapsed < step2Start + dialDuration) {
444
+ if (elapsed < step2Start + step2Duration) {
340
445
  setIconProgress(0);
341
- const progress = easeInCubic((elapsed - step2Start) / dialDuration);
342
- setDialScale(1 - progress);
343
- setCenterButtonScale(1 - progress);
446
+ const centerProgress = Math.min(1, (elapsed - step2Start) / centerDuration);
447
+ const dialProgress = Math.min(1, (elapsed - step2Start) / dialDuration);
448
+ setCenterButtonScale(1 - easeInCubic(centerProgress));
449
+ setDialScale(1 - easeInCubic(dialProgress));
344
450
  animationRef.current = requestAnimationFrame(animate);
345
451
  return;
346
452
  }
347
453
 
348
454
  // Step 3: Backdrop fade (550 - 750ms)
349
- const step3Start = step2Start + dialDuration;
455
+ const step3Start = step2Start + step2Duration;
350
456
  if (elapsed < step3Start + backdropDuration) {
351
457
  setDialScale(0);
352
458
  setCenterButtonScale(0);
@@ -374,6 +480,33 @@ export function DialMenu({
374
480
  return () => document.removeEventListener('keydown', handleKeyDown);
375
481
  }, [handleClose]);
376
482
 
483
+ // Settings onboarding tooltip — native DialDevTools.tsx:174-196: shown
484
+ // 1200ms after the dial mounts (entrance animations settle first) if never
485
+ // dismissed; suppressed while settings is open; dismissed forever when
486
+ // settings opens.
487
+ const [showSettingsTooltip, setShowSettingsTooltip] = useState(false);
488
+ useEffect(() => {
489
+ if (hintsDisabled) return;
490
+ let cancelled = false;
491
+ const timer = setTimeout(async () => {
492
+ const seen = await persistentStorage.getItem(SETTINGS_TOOLTIP_KEY);
493
+ if (!cancelled && !seen) setShowSettingsTooltip(true);
494
+ }, 1200);
495
+ return () => {
496
+ cancelled = true;
497
+ clearTimeout(timer);
498
+ };
499
+ }, [hintsDisabled]);
500
+ const dismissSettingsTooltip = useCallback(() => {
501
+ setShowSettingsTooltip(false);
502
+ void persistentStorage.setItem(SETTINGS_TOOLTIP_KEY, 'true');
503
+ }, []);
504
+ useEffect(() => {
505
+ if (settingsOpen && showSettingsTooltip) {
506
+ dismissSettingsTooltip();
507
+ }
508
+ }, [settingsOpen, showSettingsTooltip, dismissSettingsTooltip]);
509
+
377
510
  // Icon press handler
378
511
  const handleIconPress = useCallback(icon => {
379
512
  if (icon.id.startsWith('empty-') || isClosingRef.current) return;
@@ -418,24 +551,6 @@ export function DialMenu({
418
551
  const buttonContainerSize = layout.buttonSize * dialStyles.centerButton.containerRatio;
419
552
  const buttonBorderSize = layout.buttonSize * dialStyles.centerButton.borderRatio;
420
553
  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
- });
439
554
  return /*#__PURE__*/_jsxs("div", {
440
555
  role: "dialog",
441
556
  "aria-label": "Dial Menu",
@@ -443,10 +558,7 @@ export function DialMenu({
443
558
  style: {
444
559
  position: 'fixed',
445
560
  inset: 0,
446
- zIndex: 10000,
447
- display: 'flex',
448
- alignItems: 'center',
449
- justifyContent: 'center'
561
+ zIndex: 10000
450
562
  },
451
563
  children: [/*#__PURE__*/_jsx("div", {
452
564
  role: "button",
@@ -462,204 +574,234 @@ export function DialMenu({
462
574
  }
463
575
  }), /*#__PURE__*/_jsxs("div", {
464
576
  style: {
465
- animation: isAnimating ? cssAnimations.floating : 'none',
466
- display: 'flex',
467
- flexDirection: 'column',
468
- alignItems: 'center',
469
- gap: 16
577
+ position: 'absolute',
578
+ left: '50%',
579
+ marginLeft: -layout.circleSize / 2,
580
+ bottom: 80,
581
+ // native DialDevTools.tsx: bottom: 80
582
+ width: layout.circleSize,
583
+ height: layout.circleSize,
584
+ borderRadius: '50%',
585
+ backgroundColor: dialColors.dialBackground,
586
+ border: `${dialStyles.circle.borderWidth}px solid ${dialColors.dialBorder}`,
587
+ // native: shadowColor dialShadow at shadowOpacity 0.5, shadowRadius 20
588
+ boxShadow: `0 0 ${dialStyles.circle.shadowRadius}px ${withAlpha(dialColors.dialShadow, dialStyles.circle.shadowOpacity)}`,
589
+ transform: `scale(${dialScale}) rotate(${dialRotation}deg)`,
590
+ opacity: dialScale > 0.01 ? 1 : 0,
591
+ overflow: 'hidden'
470
592
  },
471
593
  children: [/*#__PURE__*/_jsx("div", {
472
594
  style: {
473
- animation: triggerGlitch > 0 && isAnimating ? cssAnimations.glitch : 'none'
595
+ position: 'absolute',
596
+ inset: 0,
597
+ borderRadius: '50%',
598
+ backgroundColor: dialColors.dialGradient1,
599
+ opacity: dialStyles.gradientLayers[0].opacity
600
+ }
601
+ }), /*#__PURE__*/_jsx("div", {
602
+ style: {
603
+ position: 'absolute',
604
+ inset: 0,
605
+ top: '30%',
606
+ left: '30%',
607
+ borderRadius: '50%',
608
+ backgroundColor: dialColors.dialGradient2,
609
+ opacity: dialStyles.gradientLayers[1].opacity
610
+ }
611
+ }), /*#__PURE__*/_jsx("div", {
612
+ style: {
613
+ position: 'absolute',
614
+ inset: 0,
615
+ top: '50%',
616
+ left: '50%',
617
+ borderRadius: '50%',
618
+ backgroundColor: dialColors.dialGradient3,
619
+ opacity: dialStyles.gradientLayers[2].opacity
620
+ }
621
+ }), gridRotations.map(deg => /*#__PURE__*/_jsx("div", {
622
+ style: {
623
+ position: 'absolute',
624
+ top: '50%',
625
+ left: 0,
626
+ right: 0,
627
+ height: dialStyles.grid.lineHeight,
628
+ backgroundColor: dialColors.dialGridLine,
629
+ transform: `rotate(${deg}deg)`,
630
+ transformOrigin: 'center'
631
+ }
632
+ }, deg)), paddedIcons.map((icon, index) => /*#__PURE__*/_jsx(DialIconItem, {
633
+ icon: icon,
634
+ index: index,
635
+ totalIcons: paddedIcons.length,
636
+ position: positions[index],
637
+ progress: iconProgress,
638
+ onPress: () => handleIconPress(icon)
639
+ }, index)), /*#__PURE__*/_jsxs("div", {
640
+ style: {
641
+ position: 'absolute',
642
+ left: '50%',
643
+ top: '50%',
644
+ width: buttonContainerSize,
645
+ height: buttonContainerSize,
646
+ marginLeft: -buttonContainerSize / 2,
647
+ marginTop: -buttonContainerSize / 2,
648
+ display: 'flex',
649
+ alignItems: 'center',
650
+ justifyContent: 'center',
651
+ backgroundColor: dialColors.dialBackground,
652
+ borderRadius: layout.buttonSize,
653
+ overflow: 'hidden',
654
+ transform: `scale(${centerButtonScale * centerPulseScale})`,
655
+ opacity: centerButtonScale > 0.01 ? 1 : 0
474
656
  },
475
- children: /*#__PURE__*/_jsxs("div", {
657
+ children: [/*#__PURE__*/_jsx("div", {
658
+ style: {
659
+ position: 'absolute',
660
+ inset: 0,
661
+ backgroundColor: dialColors.dialGradient1,
662
+ opacity: 0.5,
663
+ borderRadius: layout.buttonSize
664
+ }
665
+ }), /*#__PURE__*/_jsx("div", {
666
+ style: {
667
+ position: 'absolute',
668
+ inset: 0,
669
+ top: '20%',
670
+ left: '20%',
671
+ backgroundColor: dialColors.dialGradient2,
672
+ opacity: 0.3,
673
+ borderRadius: layout.buttonSize
674
+ }
675
+ }), /*#__PURE__*/_jsx("div", {
476
676
  style: {
477
- position: 'relative',
478
- width: layout.circleSize,
479
- height: layout.circleSize,
480
- borderRadius: '50%',
481
- backgroundColor: dialColors.dialBackground,
482
- border: `${dialStyles.circle.borderWidth}px solid ${dialColors.dialBorder}`,
483
- boxShadow: `0 0 ${dialStyles.circle.shadowRadius}px ${dialColors.dialShadow}`,
484
- transform: `scale(${dialScale}) rotate(${dialRotation}deg)`,
485
- opacity: dialScale > 0.01 ? 1 : 0,
486
- overflow: 'hidden'
677
+ position: 'absolute',
678
+ inset: 0,
679
+ top: '40%',
680
+ left: '40%',
681
+ backgroundColor: dialColors.dialGradient3,
682
+ opacity: 0.2,
683
+ borderRadius: layout.buttonSize
684
+ }
685
+ }), /*#__PURE__*/_jsx("div", {
686
+ style: {
687
+ width: buttonBorderSize,
688
+ height: buttonBorderSize,
689
+ borderRadius: layout.buttonSize * dialStyles.centerButton.borderRadiusRatio,
690
+ backgroundColor: dialColors.dialGridLine,
691
+ border: `${dialStyles.centerButton.borderWidth}px solid ${dialColors.dialBorder}`,
692
+ display: 'flex',
693
+ alignItems: 'center',
694
+ justifyContent: 'center'
487
695
  },
488
- children: [/*#__PURE__*/_jsx("div", {
489
- style: {
490
- position: 'absolute',
491
- inset: 0,
492
- borderRadius: '50%',
493
- backgroundColor: dialColors.dialGradient1,
494
- opacity: dialStyles.gradientLayers[0].opacity
495
- }
496
- }), /*#__PURE__*/_jsx("div", {
696
+ children: /*#__PURE__*/_jsx("div", {
697
+ onClick: onCenterPress,
698
+ onMouseEnter: () => setIsHoveringCenter(true),
699
+ onMouseLeave: () => setIsHoveringCenter(false),
497
700
  style: {
498
- position: 'absolute',
499
- inset: 0,
500
- top: '30%',
501
- left: '30%',
502
- borderRadius: '50%',
503
- backgroundColor: dialColors.dialGradient2,
504
- opacity: dialStyles.gradientLayers[1].opacity
505
- }
506
- }), /*#__PURE__*/_jsx("div", {
507
- style: {
508
- position: 'absolute',
509
- inset: 0,
510
- top: '50%',
511
- left: '50%',
512
- borderRadius: '50%',
513
- backgroundColor: dialColors.dialGradient3,
514
- opacity: dialStyles.gradientLayers[2].opacity
515
- }
516
- }), gridRotations.map(deg => /*#__PURE__*/_jsx("div", {
517
- style: {
518
- position: 'absolute',
519
- top: '50%',
520
- left: 0,
521
- right: 0,
522
- height: dialStyles.grid.lineHeight,
523
- backgroundColor: dialColors.dialGridLine,
524
- transform: `rotate(${deg}deg)`,
525
- transformOrigin: 'center'
526
- }
527
- }, deg)), paddedIcons.map((icon, index) => /*#__PURE__*/_jsx(DialIconItem, {
528
- icon: icon,
529
- index: index,
530
- totalIcons: paddedIcons.length,
531
- position: positions[index],
532
- progress: iconProgress,
533
- onPress: () => handleIconPress(icon)
534
- }, index)), /*#__PURE__*/_jsxs("div", {
535
- style: {
536
- position: 'absolute',
537
- left: '50%',
538
- top: '50%',
539
- width: buttonContainerSize,
540
- height: buttonContainerSize,
541
- marginLeft: -buttonContainerSize / 2,
542
- marginTop: -buttonContainerSize / 2,
701
+ width: layout.buttonSize,
702
+ height: layout.buttonSize,
703
+ borderRadius: layout.buttonSize / 2,
543
704
  display: 'flex',
705
+ flexDirection: 'column',
544
706
  alignItems: 'center',
545
707
  justifyContent: 'center',
546
- backgroundColor: dialColors.dialBackground,
547
- borderRadius: layout.buttonSize,
548
- overflow: 'hidden',
549
- transform: `scale(${centerButtonScale * centerPulseScale})`,
550
- opacity: centerButtonScale > 0.01 ? 1 : 0
708
+ cursor: onCenterPress ? 'pointer' : 'default',
709
+ transform: isHoveringCenter ? `scale(${interaction.centerHover.scale})` : 'scale(1)',
710
+ transition: `transform ${interaction.centerHover.duration}ms ease`,
711
+ animation: isAnimating && !isHoveringCenter ? cssAnimations.pulse : 'none'
551
712
  },
552
- children: [/*#__PURE__*/_jsx("div", {
553
- style: {
554
- position: 'absolute',
555
- inset: 0,
556
- backgroundColor: dialColors.dialGradient1,
557
- opacity: 0.5,
558
- borderRadius: layout.buttonSize
559
- }
560
- }), /*#__PURE__*/_jsx("div", {
561
- style: {
562
- position: 'absolute',
563
- inset: 0,
564
- top: '20%',
565
- left: '20%',
566
- backgroundColor: dialColors.dialGradient2,
567
- opacity: 0.3,
568
- borderRadius: layout.buttonSize
569
- }
570
- }), /*#__PURE__*/_jsx("div", {
571
- style: {
572
- position: 'absolute',
573
- inset: 0,
574
- top: '40%',
575
- left: '40%',
576
- backgroundColor: dialColors.dialGradient3,
577
- opacity: 0.2,
578
- borderRadius: layout.buttonSize
579
- }
580
- }), /*#__PURE__*/_jsx("div", {
713
+ children: settingsOpen ?
714
+ /*#__PURE__*/
715
+ // native DialDevTools.tsx:716-724 — stacked CLOSE / SETTINGS
716
+ _jsxs("span", {
581
717
  style: {
582
- width: buttonBorderSize,
583
- height: buttonBorderSize,
584
- borderRadius: layout.buttonSize * dialStyles.centerButton.borderRadiusRatio,
585
- backgroundColor: dialColors.dialGridLine,
586
- border: `${dialStyles.centerButton.borderWidth}px solid ${dialColors.dialBorder}`,
587
718
  display: 'flex',
719
+ flexDirection: 'column',
588
720
  alignItems: 'center',
589
- justifyContent: 'center'
721
+ fontSize: dialStyles.centerButton.text.fontSize - 2,
722
+ fontWeight: dialStyles.centerButton.text.fontWeight,
723
+ fontFamily: dialStyles.centerButton.text.fontFamily,
724
+ color: '#FFFFFF',
725
+ letterSpacing: dialStyles.centerButton.text.letterSpacing,
726
+ textAlign: 'center',
727
+ textShadow: `0 0 4px ${dialColors.centerTextGlow}`,
728
+ textTransform: 'uppercase',
729
+ lineHeight: 1.2
590
730
  },
591
- children: /*#__PURE__*/_jsx("div", {
592
- onClick: onCenterPress,
593
- onMouseEnter: () => setIsHoveringCenter(true),
594
- onMouseLeave: () => setIsHoveringCenter(false),
731
+ children: [/*#__PURE__*/_jsx("span", {
595
732
  style: {
596
- width: layout.buttonSize,
597
- height: layout.buttonSize,
598
- borderRadius: layout.buttonSize / 2,
599
- display: 'flex',
600
- flexDirection: 'column',
601
- alignItems: 'center',
602
- justifyContent: 'center',
603
- cursor: onCenterPress ? 'pointer' : 'default',
604
- transform: isHoveringCenter ? `scale(${interaction.centerHover.scale})` : 'scale(1)',
605
- transition: `transform ${interaction.centerHover.duration}ms ease`,
606
- animation: isAnimating && !isHoveringCenter ? cssAnimations.breathing : 'none'
733
+ marginBottom: -2
607
734
  },
608
- children: /*#__PURE__*/_jsx("span", {
609
- style: {
610
- fontSize: dialStyles.centerButton.text.fontSize,
611
- fontWeight: dialStyles.centerButton.text.fontWeight,
612
- fontFamily: dialStyles.centerButton.text.fontFamily,
613
- color: dialColors.centerText,
614
- letterSpacing: dialStyles.centerButton.text.letterSpacing,
615
- textShadow: `0 0 4px ${dialColors.centerTextGlow}`,
616
- textTransform: 'uppercase'
617
- },
618
- children: centerLabel
619
- })
620
- })
621
- })]
622
- })]
623
- })
624
- }, triggerGlitch), pageCount > 1 && /*#__PURE__*/_jsxs("div", {
735
+ children: "CLOSE"
736
+ }), /*#__PURE__*/_jsx("span", {
737
+ style: {
738
+ marginTop: -2
739
+ },
740
+ children: "SETTINGS"
741
+ })]
742
+ }) : /*#__PURE__*/_jsx("span", {
743
+ style: {
744
+ fontSize: dialStyles.centerButton.text.fontSize,
745
+ fontWeight: dialStyles.centerButton.text.fontWeight,
746
+ fontFamily: dialStyles.centerButton.text.fontFamily,
747
+ color: '#FFFFFF',
748
+ // native DialDevTools.tsx centerText: #FFFFFF
749
+ letterSpacing: dialStyles.centerButton.text.letterSpacing,
750
+ textAlign: 'center',
751
+ textShadow: `0 0 4px ${dialColors.centerTextGlow}`,
752
+ textTransform: 'uppercase'
753
+ },
754
+ children: centerLabel
755
+ })
756
+ })
757
+ })]
758
+ })]
759
+ }), pageCount > 1 && /*#__PURE__*/_jsxs("div", {
760
+ style: {
761
+ position: 'absolute',
762
+ left: '50%',
763
+ marginLeft: -layout.circleSize / 2,
764
+ top: 'calc(100% - 64px)',
765
+ // native: screenHeight - 80 + 16
766
+ width: layout.circleSize,
767
+ display: 'flex',
768
+ alignItems: 'center',
769
+ justifyContent: 'space-between',
770
+ opacity: Math.min(1, dialScale),
771
+ transform: `scale(${Math.min(1, dialScale)})`
772
+ },
773
+ children: [/*#__PURE__*/_jsx(PagerButton, {
774
+ side: "prev",
775
+ disabled: safePage <= 0,
776
+ onClick: () => handlePageChange(safePage - 1)
777
+ }), /*#__PURE__*/_jsxs("span", {
625
778
  style: {
626
- display: 'flex',
627
- alignItems: 'center',
628
- gap: 12,
629
- opacity: Math.min(1, dialScale)
779
+ padding: '6px 12px',
780
+ // native indicator: paddingVertical 6, paddingHorizontal 12
781
+ fontSize: 13,
782
+ fontWeight: 900,
783
+ fontFamily: 'monospace',
784
+ letterSpacing: 2,
785
+ color: '#FFFFFF',
786
+ textShadow: `0 0 6px ${dialColors.dialShadow}`
630
787
  },
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", {
788
+ children: [String(safePage + 1).padStart(2, '0'), /*#__PURE__*/_jsxs("span", {
639
789
  style: {
640
- fontSize: 13,
641
- fontWeight: 900,
642
- fontFamily: 'monospace',
643
- letterSpacing: 2,
644
- color: '#FFFFFF',
645
- textShadow: `0 0 6px ${dialColors.dialShadow}`
790
+ color: dialColors.iconLabel,
791
+ textShadow: 'none'
646
792
  },
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"
793
+ children: [' / ', String(pageCount).padStart(2, '0')]
661
794
  })]
795
+ }), /*#__PURE__*/_jsx(PagerButton, {
796
+ side: "next",
797
+ disabled: safePage >= pageCount - 1,
798
+ onClick: () => handlePageChange(safePage + 1)
662
799
  })]
800
+ }), showSettingsTooltip && !settingsOpen && /*#__PURE__*/_jsx(OnboardingTooltip, {
801
+ title: "Welcome to Buoy!",
802
+ message: "Tap the center button to configure and add tools to your dial menu.",
803
+ onDismiss: dismissSettingsTooltip,
804
+ showArrow: false
663
805
  })]
664
806
  });
665
807
  }