@buoy-gg/shared-ui 7.0.15 → 7.0.17

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 (121) hide show
  1. package/lib/commonjs/JsModal.web.js +1588 -0
  2. package/lib/commonjs/env/EnvironmentIndicator.web.js +114 -0
  3. package/lib/commonjs/env/EnvironmentSelectorInline.web.js +178 -0
  4. package/lib/commonjs/hooks/index.js +7 -0
  5. package/lib/commonjs/hooks/safe-area-impl.js +1 -1
  6. package/lib/commonjs/hooks/usePersistedState.js +71 -0
  7. package/lib/commonjs/icons/index.js +28 -0
  8. package/lib/commonjs/index.js +45 -3
  9. package/lib/commonjs/storage/devToolsStorageKeys.js +22 -0
  10. package/lib/commonjs/stores/ignoredPatternsCore.js +68 -0
  11. package/lib/commonjs/stores/ignoredPatternsStore.js +16 -52
  12. package/lib/commonjs/stores/ignoredPatternsStore.web.js +236 -0
  13. package/lib/commonjs/stores/index.js +1 -1
  14. package/lib/commonjs/ui/components/ModalHeader.js +69 -0
  15. package/lib/commonjs/ui/components/ToolButton.js +90 -0
  16. package/lib/commonjs/ui/components/index.js +7 -0
  17. package/lib/commonjs/ui/gameUI/constants/gameUIColors.js +5 -3
  18. package/lib/commonjs/ui/index.js +8 -0
  19. package/lib/commonjs/ui/toolTheme.js +96 -0
  20. package/lib/commonjs/utils/index.js +38 -0
  21. package/lib/commonjs/utils/stateSnapshotRegistry.js +62 -0
  22. package/lib/module/JsModal.web.js +1586 -0
  23. package/lib/module/env/EnvironmentIndicator.web.js +108 -0
  24. package/lib/module/env/EnvironmentSelectorInline.web.js +173 -0
  25. package/lib/module/hooks/index.js +1 -0
  26. package/lib/module/hooks/safe-area-impl.js +1 -1
  27. package/lib/module/hooks/usePersistedState.js +68 -0
  28. package/lib/module/icons/index.js +2 -0
  29. package/lib/module/index.js +7 -3
  30. package/lib/module/storage/devToolsStorageKeys.js +22 -0
  31. package/lib/module/stores/ignoredPatternsCore.js +62 -0
  32. package/lib/module/stores/ignoredPatternsStore.js +5 -51
  33. package/lib/module/stores/ignoredPatternsStore.web.js +221 -0
  34. package/lib/module/stores/index.js +1 -1
  35. package/lib/module/ui/components/ModalHeader.js +70 -2
  36. package/lib/module/ui/components/ToolButton.js +86 -0
  37. package/lib/module/ui/components/index.js +1 -0
  38. package/lib/module/ui/gameUI/constants/gameUIColors.js +4 -2
  39. package/lib/module/ui/index.js +3 -0
  40. package/lib/module/ui/toolTheme.js +93 -0
  41. package/lib/module/utils/index.js +2 -0
  42. package/lib/module/utils/stateSnapshotRegistry.js +55 -0
  43. package/lib/typescript/commonjs/JsModal.web.d.ts +138 -0
  44. package/lib/typescript/commonjs/JsModal.web.d.ts.map +1 -0
  45. package/lib/typescript/commonjs/env/EnvironmentIndicator.web.d.ts +16 -0
  46. package/lib/typescript/commonjs/env/EnvironmentIndicator.web.d.ts.map +1 -0
  47. package/lib/typescript/commonjs/env/EnvironmentSelectorInline.web.d.ts +8 -0
  48. package/lib/typescript/commonjs/env/EnvironmentSelectorInline.web.d.ts.map +1 -0
  49. package/lib/typescript/commonjs/hooks/index.d.ts +1 -0
  50. package/lib/typescript/commonjs/hooks/index.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/hooks/safe-area-impl.d.ts +1 -1
  52. package/lib/typescript/commonjs/hooks/usePersistedState.d.ts +16 -0
  53. package/lib/typescript/commonjs/hooks/usePersistedState.d.ts.map +1 -0
  54. package/lib/typescript/commonjs/icons/index.d.ts +2 -0
  55. package/lib/typescript/commonjs/icons/index.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/index.d.ts +1 -1
  57. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/storage/devToolsStorageKeys.d.ts +19 -0
  59. package/lib/typescript/commonjs/storage/devToolsStorageKeys.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/stores/ignoredPatternsCore.d.ts +37 -0
  61. package/lib/typescript/commonjs/stores/ignoredPatternsCore.d.ts.map +1 -0
  62. package/lib/typescript/commonjs/stores/ignoredPatternsStore.d.ts +3 -27
  63. package/lib/typescript/commonjs/stores/ignoredPatternsStore.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/stores/ignoredPatternsStore.web.d.ts +72 -0
  65. package/lib/typescript/commonjs/stores/ignoredPatternsStore.web.d.ts.map +1 -0
  66. package/lib/typescript/commonjs/ui/components/ModalHeader.d.ts +13 -1
  67. package/lib/typescript/commonjs/ui/components/ModalHeader.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ui/components/ToolButton.d.ts +24 -0
  69. package/lib/typescript/commonjs/ui/components/ToolButton.d.ts.map +1 -0
  70. package/lib/typescript/commonjs/ui/components/index.d.ts +4 -0
  71. package/lib/typescript/commonjs/ui/components/index.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/ui/gameUI/constants/gameUIColors.d.ts +56 -0
  73. package/lib/typescript/commonjs/ui/gameUI/constants/gameUIColors.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/ui/index.d.ts +1 -0
  75. package/lib/typescript/commonjs/ui/index.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/ui/toolTheme.d.ts +68 -0
  77. package/lib/typescript/commonjs/ui/toolTheme.d.ts.map +1 -0
  78. package/lib/typescript/commonjs/utils/index.d.ts +2 -0
  79. package/lib/typescript/commonjs/utils/index.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/utils/stateSnapshotRegistry.d.ts +67 -0
  81. package/lib/typescript/commonjs/utils/stateSnapshotRegistry.d.ts.map +1 -0
  82. package/lib/typescript/module/JsModal.web.d.ts +138 -0
  83. package/lib/typescript/module/JsModal.web.d.ts.map +1 -0
  84. package/lib/typescript/module/env/EnvironmentIndicator.web.d.ts +16 -0
  85. package/lib/typescript/module/env/EnvironmentIndicator.web.d.ts.map +1 -0
  86. package/lib/typescript/module/env/EnvironmentSelectorInline.web.d.ts +8 -0
  87. package/lib/typescript/module/env/EnvironmentSelectorInline.web.d.ts.map +1 -0
  88. package/lib/typescript/module/hooks/index.d.ts +1 -0
  89. package/lib/typescript/module/hooks/index.d.ts.map +1 -1
  90. package/lib/typescript/module/hooks/safe-area-impl.d.ts +1 -1
  91. package/lib/typescript/module/hooks/usePersistedState.d.ts +16 -0
  92. package/lib/typescript/module/hooks/usePersistedState.d.ts.map +1 -0
  93. package/lib/typescript/module/icons/index.d.ts +2 -0
  94. package/lib/typescript/module/icons/index.d.ts.map +1 -1
  95. package/lib/typescript/module/index.d.ts +1 -1
  96. package/lib/typescript/module/index.d.ts.map +1 -1
  97. package/lib/typescript/module/storage/devToolsStorageKeys.d.ts +19 -0
  98. package/lib/typescript/module/storage/devToolsStorageKeys.d.ts.map +1 -1
  99. package/lib/typescript/module/stores/ignoredPatternsCore.d.ts +37 -0
  100. package/lib/typescript/module/stores/ignoredPatternsCore.d.ts.map +1 -0
  101. package/lib/typescript/module/stores/ignoredPatternsStore.d.ts +3 -27
  102. package/lib/typescript/module/stores/ignoredPatternsStore.d.ts.map +1 -1
  103. package/lib/typescript/module/stores/ignoredPatternsStore.web.d.ts +72 -0
  104. package/lib/typescript/module/stores/ignoredPatternsStore.web.d.ts.map +1 -0
  105. package/lib/typescript/module/ui/components/ModalHeader.d.ts +13 -1
  106. package/lib/typescript/module/ui/components/ModalHeader.d.ts.map +1 -1
  107. package/lib/typescript/module/ui/components/ToolButton.d.ts +24 -0
  108. package/lib/typescript/module/ui/components/ToolButton.d.ts.map +1 -0
  109. package/lib/typescript/module/ui/components/index.d.ts +4 -0
  110. package/lib/typescript/module/ui/components/index.d.ts.map +1 -1
  111. package/lib/typescript/module/ui/gameUI/constants/gameUIColors.d.ts +56 -0
  112. package/lib/typescript/module/ui/gameUI/constants/gameUIColors.d.ts.map +1 -1
  113. package/lib/typescript/module/ui/index.d.ts +1 -0
  114. package/lib/typescript/module/ui/index.d.ts.map +1 -1
  115. package/lib/typescript/module/ui/toolTheme.d.ts +68 -0
  116. package/lib/typescript/module/ui/toolTheme.d.ts.map +1 -0
  117. package/lib/typescript/module/utils/index.d.ts +2 -0
  118. package/lib/typescript/module/utils/index.d.ts.map +1 -1
  119. package/lib/typescript/module/utils/stateSnapshotRegistry.d.ts +67 -0
  120. package/lib/typescript/module/utils/stateSnapshotRegistry.d.ts.map +1 -0
  121. package/package.json +4 -4
@@ -0,0 +1,1586 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * JsModal.web — DOM port of JsModal.tsx (React Native).
5
+ *
6
+ * The dual-mode tool-panel container: draggable bottom sheet ⇄ floating
7
+ * window. Same exported names (`JsModal`, `ModalMode`, `ModalState`), same
8
+ * prop contract, same gesture spec and persistence key/JSON shape as the
9
+ * native file. Zero react-native imports — header chrome that lives in
10
+ * shared-ui RN components (`WindowControls`, `DraggableHeader`, `ModalHeader`,
11
+ * `ModalHintBanner`) is replicated as DOM with provenance comments, the same
12
+ * way DevToolsSettingsModal.web.tsx replicates the WindowControls close dot.
13
+ *
14
+ * Gesture spec (verbatim from native):
15
+ * - Sheet header drag: steal at |dy| > 3px, absolute finger anchoring
16
+ * (height = screenH − (pointerY − grab offset)), clamped to
17
+ * [minHeight, maxHeight ?? screenH − safeTop]. Fast downward swipe closes:
18
+ * (vy > 0.8 px/ms && dy > 50) || (dy > 150 && height <= minHeight).
19
+ * - Floating header drag (DraggableHeader.tsx): drag starts at
20
+ * |dx|+|dy| > 5, position unclamped during move, clamped on release to
21
+ * x ∈ [0, screenW − width], y ∈ [safeTop, screenH − height].
22
+ * - Corner resize (bottom corners only): moves < 0.5px ignored; rect clamped
23
+ * to safe-area bounds; min size = screenW*0.25 × 80.
24
+ * - Header taps (both modes): taps accumulate within a 500ms window and
25
+ * settle after 300ms — 2 taps toggles mode, ≥3 taps minimizes.
26
+ *
27
+ * Persistence (verbatim from native ModalStorage): the `persistenceKey` prop
28
+ * is the storage key, used verbatim (native callers pass fully-derived
29
+ * `@react_buoy…` keys or short keys — no extra derivation happens here
30
+ * either). JSON shape `{ mode, panelHeight, dimensions: {width, height, top,
31
+ * left}, isVisible }`, saved on a 500ms debounce after state loads, with the
32
+ * same in-memory cache in front of storage. Backed by a local
33
+ * localStorage adapter matching the `persistentStorage` API — importing
34
+ * `./utils/persistentStorage` is avoided on purpose: its lazy `require()`
35
+ * probing of expo-file-system/AsyncStorage doesn't survive Vite's ESM graph.
36
+ * The hint-acknowledged flag uses the real `devToolsStorageKeys` helper
37
+ * (zero-dep) so the key matches native exactly.
38
+ *
39
+ * Intentional deviations from the native file (all logged):
40
+ * - No native-driver springs: rAF drives all animations. The open/close
41
+ * spring (tension 180, friction 22, ~300ms settle) is approximated with a
42
+ * 300ms ease-out-cubic; native `Animated.timing` calls keep their exact
43
+ * durations (200ms fades, 300ms minimize) with ease-in-out.
44
+ * - No RN Modal/overlay layer: fixed-position divs with the `zIndex` prop
45
+ * (default 1000, same as the native styles).
46
+ * - `benchMotion` and `animatedHeight` (DEV-only benchmark seams) are
47
+ * accepted and no-op'd — the web build has no tool-bench harness.
48
+ * - `CyberpunkModalHeader` complete-replacement detection in ModalHeader is
49
+ * not ported (that component is RN-only); custom header content renders
50
+ * inside the standard header structure, with `onMinimize` injected via
51
+ * cloneElement like native's non-replacement path.
52
+ * - Native captures `Dimensions.get("window")` once at module load; the web
53
+ * twin reads `window.innerWidth/innerHeight` lazily so late layout (Tauri/
54
+ * WKWebView) doesn't bake in a 0×0 screen.
55
+ * - Safe-area insets come from a one-shot `env(safe-area-inset-*)` probe
56
+ * (same trick as floatingTools.web.tsx) instead of the RN hook.
57
+ * - On restore-from-minimize, the floating position re-syncs from the last
58
+ * committed dimensions when the panel becomes visible: the web AppHost
59
+ * doesn't pass `initialModalState` back, and without the re-sync the panel
60
+ * would reappear at the minimize target.
61
+ * - `JsModalProps` is additionally exported (native keeps it private) so web
62
+ * consumers can type wrappers without redeclaring the contract.
63
+ * - `visible={false}` renders children in a `display:none` div (state and
64
+ * listeners survive) instead of native's offscreen −9999 container.
65
+ * - The hidden/floating/bottomSheet root branches carry distinct React keys:
66
+ * the rAF animations write opacity/transform straight to the DOM, and DOM
67
+ * reuse across branches (e.g. an async persisted-mode flip mid-entrance)
68
+ * would leak those styles onto unrelated chrome (blank header bug).
69
+ *
70
+ * @platform React DOM Web (Vite, Tauri, Capacitor/Ionic, Electron)
71
+ */
72
+
73
+ import { useState, useRef, useEffect, useLayoutEffect, useCallback, memo, isValidElement, cloneElement } from "react";
74
+ import { devToolsStorageKeys } from "./storage/devToolsStorageKeys.js";
75
+ import { useHintsDisabled } from "./context/index.js";
76
+
77
+ // ============================================================================
78
+ // PALETTE — inlined from shared-ui with provenance (RN-only modules on web)
79
+ // ============================================================================
80
+
81
+ /** buoyColors from ui/gameUI/constants/gameUIColors.ts — inlined verbatim. */
82
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
83
+ const buoy = {
84
+ base: "#121212",
85
+ card: "#1A1A1A",
86
+ border: "#333333",
87
+ textMuted: "#888888",
88
+ primary: "#20C997" // Buoy teal
89
+ };
90
+
91
+ /**
92
+ * gameUIColors resolved values (macOS theme active — see gameUIColors.ts
93
+ * `activeTheme = macOSTheme` and macOSDesignSystemColors.ts).
94
+ */
95
+ const gameUI = {
96
+ primary: "#F5F5F7",
97
+ // macOSColors.text.primary — header title
98
+ primaryLight: "#F5F5F7",
99
+ // macOS theme maps primaryLight to text.primary too
100
+ secondary: "#A1A1A6",
101
+ // macOSColors.text.secondary — header subtitle
102
+ muted: "#8E8E93",
103
+ // macOSColors.text.muted — hint text
104
+ success: "#34C759",
105
+ // macOSColors.semantic.success — resize grips/handles
106
+ info: "#00B8E6" // macOSColors.semantic.info — hint banner
107
+ };
108
+
109
+ // ============================================================================
110
+ // CONSTANTS — same values as native JsModal.tsx
111
+ // ============================================================================
112
+ const MIN_HEIGHT = 100;
113
+ const DEFAULT_HEIGHT = 400;
114
+ const FLOATING_HEIGHT = 500;
115
+ const FLOATING_MIN_HEIGHT = 80; // header (60) + 20px content
116
+ // native: FLOATING_WIDTH = SCREEN.width, FLOATING_MIN_WIDTH = SCREEN.width/4 —
117
+ // read lazily on web (see deviations in the file header).
118
+ const screenW = () => typeof window !== "undefined" ? window.innerWidth : 0;
119
+ const screenH = () => typeof window !== "undefined" ? window.innerHeight : 0;
120
+
121
+ // ============================================================================
122
+ // STORAGE — exact native ModalStorage semantics over localStorage
123
+ // ============================================================================
124
+
125
+ /**
126
+ * Local localStorage-backed adapter matching the shared `persistentStorage`
127
+ * API surface JsModal uses (getItem/setItem). Same recipe as the app shims
128
+ * (example-tauri/src/shims/shared-ui.web.ts).
129
+ */
130
+ const webPersistentStorage = {
131
+ async getItem(key) {
132
+ try {
133
+ return window.localStorage.getItem(key);
134
+ } catch {
135
+ return null;
136
+ }
137
+ },
138
+ async setItem(key, value) {
139
+ try {
140
+ window.localStorage.setItem(key, value);
141
+ } catch {
142
+ // Best-effort, like native's swallowed save errors.
143
+ }
144
+ }
145
+ };
146
+
147
+ /** Persisted JSON shape — identical to native `PersistedModalState`. */
148
+
149
+ /** Native `ModalStorage` twin: memory cache in front of persistent storage. */
150
+ class ModalStorage {
151
+ static memoryCache = {};
152
+ static async save(key, value) {
153
+ try {
154
+ this.memoryCache[key] = value;
155
+ await webPersistentStorage.setItem(key, JSON.stringify(value));
156
+ } catch {
157
+ // Failed to save modal state
158
+ }
159
+ }
160
+ static async load(key) {
161
+ try {
162
+ if (this.memoryCache[key]) {
163
+ return this.memoryCache[key];
164
+ }
165
+ const stored = await webPersistentStorage.getItem(key);
166
+ if (stored) {
167
+ const parsed = JSON.parse(stored);
168
+ this.memoryCache[key] = parsed;
169
+ return parsed;
170
+ }
171
+ } catch {
172
+ // Failed to load modal state
173
+ }
174
+ return null;
175
+ }
176
+ }
177
+
178
+ // ============================================================================
179
+ // TYPES — same exported names as native
180
+ // ============================================================================
181
+
182
+ /** Modal state that can be saved and restored when minimizing/restoring. */
183
+
184
+ // ============================================================================
185
+ // rAF ANIMATION HELPERS (native-driver springs/timings stand-in)
186
+ // ============================================================================
187
+
188
+ const easeOutCubic = t => 1 - Math.pow(1 - t, 3);
189
+ // Animated.timing's default easing is ease-in-out
190
+ const easeInOut = t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
191
+ function runAnim(duration, ease, onFrame, onDone) {
192
+ let raf = 0;
193
+ let cancelled = false;
194
+ const start = performance.now();
195
+ const tick = now => {
196
+ if (cancelled) return;
197
+ const t = Math.min((now - start) / duration, 1);
198
+ onFrame(ease(t));
199
+ if (t < 1) {
200
+ raf = requestAnimationFrame(tick);
201
+ } else {
202
+ onDone?.();
203
+ }
204
+ };
205
+ raf = requestAnimationFrame(tick);
206
+ return () => {
207
+ cancelled = true;
208
+ cancelAnimationFrame(raf);
209
+ };
210
+ }
211
+
212
+ // ============================================================================
213
+ // SAFE AREA — env() probe (floatingTools.web.tsx pattern), one-shot on mount
214
+ // ============================================================================
215
+
216
+ function readSafeAreaInsets() {
217
+ if (typeof document === "undefined") {
218
+ return {
219
+ top: 0,
220
+ left: 0,
221
+ right: 0,
222
+ bottom: 0
223
+ };
224
+ }
225
+ const probe = document.createElement("div");
226
+ probe.style.cssText = "position:fixed;top:env(safe-area-inset-top);left:env(safe-area-inset-left);" + "right:env(safe-area-inset-right);bottom:env(safe-area-inset-bottom);" + "visibility:hidden;pointer-events:none";
227
+ document.body.appendChild(probe);
228
+ const rect = probe.getBoundingClientRect();
229
+ document.body.removeChild(probe);
230
+ return {
231
+ top: Math.max(0, rect.top),
232
+ left: Math.max(0, rect.left),
233
+ right: Math.max(0, window.innerWidth - rect.right),
234
+ bottom: Math.max(0, window.innerHeight - rect.bottom)
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Keep a floating rect fully inside the safe area. Applied on every entry
240
+ * path — initial position, persisted restore, drag release — so the window
241
+ * can never sit under a notch/Dynamic Island or the home indicator.
242
+ * (Deliberate improvement over native, whose release clamp allows the
243
+ * window to reach the raw screen bottom.)
244
+ */
245
+ function clampRectToSafeArea(rect, insets) {
246
+ const maxW = Math.max(FLOATING_MIN_HEIGHT, window.innerWidth - insets.left - insets.right);
247
+ const maxH = Math.max(FLOATING_MIN_HEIGHT, window.innerHeight - insets.top - insets.bottom);
248
+ const width = Math.min(rect.width, maxW);
249
+ const height = Math.min(rect.height, maxH);
250
+ return {
251
+ width,
252
+ height,
253
+ left: Math.max(insets.left, Math.min(rect.left, window.innerWidth - insets.right - width)),
254
+ top: Math.max(insets.top, Math.min(rect.top, window.innerHeight - insets.bottom - height))
255
+ };
256
+ }
257
+
258
+ // ============================================================================
259
+ // ICONS — DOM twins of shared-ui icons (icons/lucide-icons.tsx)
260
+ // ============================================================================
261
+
262
+ /** shared-ui X icon (lucide-icons.tsx). */
263
+ const XIcon = ({
264
+ size = 24,
265
+ color = "currentColor",
266
+ strokeWidth = 2
267
+ }) => /*#__PURE__*/_jsxs("svg", {
268
+ width: size,
269
+ height: size,
270
+ viewBox: "0 0 24 24",
271
+ fill: "none",
272
+ children: [/*#__PURE__*/_jsx("line", {
273
+ x1: 18,
274
+ y1: 6,
275
+ x2: 6,
276
+ y2: 18,
277
+ stroke: color,
278
+ strokeWidth: strokeWidth,
279
+ strokeLinecap: "round"
280
+ }), /*#__PURE__*/_jsx("line", {
281
+ x1: 6,
282
+ y1: 6,
283
+ x2: 18,
284
+ y2: 18,
285
+ stroke: color,
286
+ strokeWidth: strokeWidth,
287
+ strokeLinecap: "round"
288
+ })]
289
+ });
290
+
291
+ /** shared-ui Minus icon (lucide-icons.tsx). */
292
+ const MinusIcon = ({
293
+ size = 24,
294
+ color = "currentColor",
295
+ strokeWidth = 2
296
+ }) => /*#__PURE__*/_jsx("svg", {
297
+ width: size,
298
+ height: size,
299
+ viewBox: "0 0 24 24",
300
+ fill: "none",
301
+ children: /*#__PURE__*/_jsx("line", {
302
+ x1: 5,
303
+ y1: 12,
304
+ x2: 19,
305
+ y2: 12,
306
+ stroke: color,
307
+ strokeWidth: strokeWidth,
308
+ strokeLinecap: "round"
309
+ })
310
+ });
311
+
312
+ /** shared-ui DockBottom icon — outer frame + filled bottom panel. */
313
+ const DockBottomIcon = ({
314
+ size = 24,
315
+ color = "currentColor",
316
+ strokeWidth = 2
317
+ }) => /*#__PURE__*/_jsxs("svg", {
318
+ width: size,
319
+ height: size,
320
+ viewBox: "0 0 24 24",
321
+ fill: "none",
322
+ children: [/*#__PURE__*/_jsx("rect", {
323
+ x: 3,
324
+ y: 3,
325
+ width: 18,
326
+ height: 18,
327
+ rx: 2,
328
+ stroke: color,
329
+ strokeWidth: strokeWidth
330
+ }), /*#__PURE__*/_jsx("rect", {
331
+ x: 3,
332
+ y: 14,
333
+ width: 18,
334
+ height: 7,
335
+ fill: color
336
+ })]
337
+ });
338
+
339
+ /** shared-ui FloatWindow icon — window with offset shadow. */
340
+ const FloatWindowIcon = ({
341
+ size = 24,
342
+ color = "currentColor",
343
+ strokeWidth = 2
344
+ }) => /*#__PURE__*/_jsxs("svg", {
345
+ width: size,
346
+ height: size,
347
+ viewBox: "0 0 24 24",
348
+ fill: "none",
349
+ children: [/*#__PURE__*/_jsx("rect", {
350
+ x: 3,
351
+ y: 3,
352
+ width: 14,
353
+ height: 14,
354
+ rx: 2,
355
+ stroke: color,
356
+ strokeWidth: strokeWidth
357
+ }), /*#__PURE__*/_jsx("line", {
358
+ x1: 7,
359
+ y1: 17,
360
+ x2: 7,
361
+ y2: 21,
362
+ stroke: color,
363
+ strokeWidth: strokeWidth
364
+ }), /*#__PURE__*/_jsx("line", {
365
+ x1: 7,
366
+ y1: 21,
367
+ x2: 21,
368
+ y2: 21,
369
+ stroke: color,
370
+ strokeWidth: strokeWidth
371
+ }), /*#__PURE__*/_jsx("line", {
372
+ x1: 21,
373
+ y1: 21,
374
+ x2: 21,
375
+ y2: 7,
376
+ stroke: color,
377
+ strokeWidth: strokeWidth
378
+ }), /*#__PURE__*/_jsx("line", {
379
+ x1: 17,
380
+ y1: 7,
381
+ x2: 21,
382
+ y2: 7,
383
+ stroke: color,
384
+ strokeWidth: strokeWidth
385
+ })]
386
+ });
387
+
388
+ // ============================================================================
389
+ // WINDOW CONTROLS — DOM twin of ui/components/WindowControls.tsx.
390
+ // Native renders the non-expandable branch on web (EXPANDABLE_SUPPORTED =
391
+ // Platform.OS !== "web"), so the plain macOS dots ARE the 1:1 web look.
392
+ // ============================================================================
393
+ // native WindowControls COLORS
394
+ const WC_COLORS = {
395
+ close: "#FF5F57",
396
+ minimize: "#FEBC2E",
397
+ toggleMode: "#28C840"
398
+ };
399
+ const WC_BUTTON_SIZE = 12; // native BUTTON_SIZE
400
+ const WC_BUTTON_SPACING = 8; // native BUTTON_SPACING
401
+ const WC_ICON_SIZE = 8; // native ICON_SIZE
402
+
403
+ const wcDotStyle = bg => ({
404
+ width: WC_BUTTON_SIZE,
405
+ height: WC_BUTTON_SIZE,
406
+ borderRadius: WC_BUTTON_SIZE / 2,
407
+ display: "flex",
408
+ alignItems: "center",
409
+ justifyContent: "center",
410
+ backgroundColor: bg,
411
+ border: "none",
412
+ padding: 0,
413
+ cursor: "pointer",
414
+ flexShrink: 0
415
+ });
416
+ function WindowControls({
417
+ onClose,
418
+ onMinimize,
419
+ onToggleMode,
420
+ mode
421
+ }) {
422
+ const ToggleModeIcon = mode === "floating" ? DockBottomIcon : FloatWindowIcon;
423
+ const toggleModeLabel = mode === "floating" ? "Dock to bottom sheet" : "Make floating window";
424
+ return /*#__PURE__*/_jsxs("div", {
425
+ style: {
426
+ display: "flex",
427
+ flexDirection: "row",
428
+ alignItems: "center",
429
+ gap: WC_BUTTON_SPACING
430
+ },
431
+ children: [onMinimize && /*#__PURE__*/_jsx("button", {
432
+ type: "button",
433
+ onClick: onMinimize,
434
+ style: wcDotStyle(WC_COLORS.minimize),
435
+ "aria-label": "Minimize modal",
436
+ children: /*#__PURE__*/_jsx(MinusIcon, {
437
+ size: WC_ICON_SIZE,
438
+ color: "#7A5A00",
439
+ strokeWidth: 1.5
440
+ })
441
+ }), onToggleMode && /*#__PURE__*/_jsx("button", {
442
+ type: "button",
443
+ onClick: onToggleMode,
444
+ style: wcDotStyle(WC_COLORS.toggleMode),
445
+ "aria-label": toggleModeLabel,
446
+ children: /*#__PURE__*/_jsx(ToggleModeIcon, {
447
+ size: WC_ICON_SIZE,
448
+ color: "#004A1A",
449
+ strokeWidth: 1.5
450
+ })
451
+ }), /*#__PURE__*/_jsx("button", {
452
+ type: "button",
453
+ onClick: onClose,
454
+ style: wcDotStyle(WC_COLORS.close),
455
+ "aria-label": "Close modal",
456
+ children: /*#__PURE__*/_jsx(XIcon, {
457
+ size: WC_ICON_SIZE,
458
+ color: "#4A0000",
459
+ strokeWidth: 1.5
460
+ })
461
+ })]
462
+ });
463
+ }
464
+
465
+ // ============================================================================
466
+ // DRAG INDICATOR — DOM twin of native DragIndicator
467
+ // ============================================================================
468
+ const DragIndicator = /*#__PURE__*/memo(function DragIndicator({
469
+ isResizing,
470
+ mode,
471
+ hasCustomContent = false
472
+ }) {
473
+ const floating = mode === "floating";
474
+ return /*#__PURE__*/_jsxs("div", {
475
+ style: {
476
+ display: "flex",
477
+ flexDirection: "column",
478
+ alignItems: "center",
479
+ position: "relative",
480
+ // native dragIndicatorContainer: paddingVertical 8;
481
+ // custom variant: paddingTop 6, paddingBottom 2
482
+ paddingTop: hasCustomContent ? 6 : 8,
483
+ paddingBottom: hasCustomContent ? 2 : 8,
484
+ backgroundColor: "transparent"
485
+ },
486
+ children: [/*#__PURE__*/_jsx("div", {
487
+ style: {
488
+ // native dragIndicator: 40x3 teal bar, radius 2, teal glow
489
+ // (shadowOpacity 0.6, radius 4); floating variant: 50x5 textMuted;
490
+ // active (isResizing/isDragging): teal, width 40
491
+ width: isResizing ? 40 : floating ? 50 : 40,
492
+ height: floating ? 5 : 3,
493
+ backgroundColor: isResizing ? buoy.primary : floating ? buoy.textMuted : buoy.primary,
494
+ borderRadius: 2,
495
+ boxShadow: `0 0 4px ${buoy.primary}99`
496
+ }
497
+ }), isResizing && mode === "bottomSheet" && /*#__PURE__*/_jsx("div", {
498
+ style: {
499
+ position: "absolute",
500
+ display: "flex",
501
+ flexDirection: "row",
502
+ gap: 2,
503
+ // native resizeGripContainer: gap 2
504
+ marginTop: 12 // native: marginTop 12
505
+ },
506
+ children: [0, 1, 2].map(i => /*#__PURE__*/_jsx("div", {
507
+ style: {
508
+ width: 12,
509
+ // native resizeGripLine: 12x1
510
+ height: 1,
511
+ backgroundColor: gameUI.success,
512
+ opacity: 0.6
513
+ }
514
+ }, i))
515
+ })]
516
+ });
517
+ });
518
+
519
+ // ============================================================================
520
+ // MODAL HINT BANNER — DOM twin of ui/components/ModalHintBanner.tsx
521
+ // ============================================================================
522
+ const ModalHintBanner = /*#__PURE__*/memo(function ModalHintBanner({
523
+ onAcknowledge
524
+ }) {
525
+ return /*#__PURE__*/_jsx("div", {
526
+ style: {
527
+ // native container: info+15 bg, 3px info left border, radius 8,
528
+ // marginHorizontal 12, mt 8, mb 4, info glow (opacity 0.3, radius 8)
529
+ backgroundColor: `${gameUI.info}15`,
530
+ borderLeft: `3px solid ${gameUI.info}`,
531
+ borderRadius: 8,
532
+ margin: "8px 12px 4px",
533
+ overflow: "hidden",
534
+ boxShadow: `0 0 8px ${gameUI.info}4D`,
535
+ flexShrink: 0
536
+ },
537
+ children: /*#__PURE__*/_jsxs("div", {
538
+ style: {
539
+ display: "flex",
540
+ flexDirection: "row",
541
+ alignItems: "center",
542
+ padding: 12,
543
+ // native inner: padding 12, gap 12
544
+ gap: 12
545
+ },
546
+ children: [/*#__PURE__*/_jsxs("div", {
547
+ style: {
548
+ width: 32,
549
+ height: 32,
550
+ position: "relative",
551
+ display: "flex",
552
+ alignItems: "center",
553
+ justifyContent: "center",
554
+ flexShrink: 0
555
+ },
556
+ children: [/*#__PURE__*/_jsx("div", {
557
+ style: {
558
+ position: "absolute",
559
+ width: 20,
560
+ height: 20,
561
+ borderRadius: 10,
562
+ border: `2px solid ${gameUI.info}`,
563
+ opacity: 0.6,
564
+ transform: "scale(0.8)",
565
+ boxSizing: "border-box"
566
+ }
567
+ }), /*#__PURE__*/_jsx("div", {
568
+ style: {
569
+ position: "absolute",
570
+ width: 20,
571
+ height: 20,
572
+ borderRadius: 10,
573
+ border: `2px solid ${gameUI.info}`,
574
+ boxSizing: "border-box"
575
+ }
576
+ })]
577
+ }), /*#__PURE__*/_jsxs("div", {
578
+ style: {
579
+ flex: 1,
580
+ display: "flex",
581
+ flexDirection: "column",
582
+ gap: 2
583
+ },
584
+ children: [/*#__PURE__*/_jsx("span", {
585
+ style: {
586
+ fontSize: 13,
587
+ fontWeight: 600,
588
+ color: gameUI.info,
589
+ letterSpacing: 0.3
590
+ },
591
+ children: "\uD83D\uDCA1 Pro Tip"
592
+ }), /*#__PURE__*/_jsx("span", {
593
+ style: {
594
+ fontSize: 12,
595
+ color: gameUI.primaryLight,
596
+ lineHeight: "16px"
597
+ },
598
+ children: "Double-tap the header to transform this into a floating modal"
599
+ })]
600
+ }), /*#__PURE__*/_jsx("button", {
601
+ type: "button",
602
+ onClick: onAcknowledge,
603
+ style: {
604
+ backgroundColor: `${gameUI.info}30`,
605
+ padding: "6px 12px",
606
+ // native: paddingVertical 6 / horizontal 12
607
+ borderRadius: 6,
608
+ border: `1px solid ${gameUI.info}50`,
609
+ fontSize: 12,
610
+ fontWeight: 600,
611
+ color: gameUI.info,
612
+ cursor: "pointer",
613
+ fontFamily: "inherit",
614
+ flexShrink: 0
615
+ },
616
+ children: "Got it!"
617
+ })]
618
+ })
619
+ });
620
+ });
621
+
622
+ // ============================================================================
623
+ // MODAL HEADER — DOM twin of native ModalHeader (title/subtitle/hint text)
624
+ // ============================================================================
625
+ function ModalHeaderContent({
626
+ header,
627
+ isResizing,
628
+ mode,
629
+ onMinimize
630
+ }) {
631
+ if (header?.customContent) {
632
+ // native (non-CyberpunkModalHeader path): inject onMinimize into the
633
+ // custom element so it can trigger minimize; render inside the standard
634
+ // header structure with the compact drag indicator.
635
+ let customContent = header.customContent;
636
+ if (/*#__PURE__*/isValidElement(header.customContent) && onMinimize) {
637
+ customContent = /*#__PURE__*/cloneElement(header.customContent, {
638
+ onMinimize
639
+ });
640
+ }
641
+ return /*#__PURE__*/_jsxs("div", {
642
+ style: {
643
+ flex: 1,
644
+ display: "flex",
645
+ flexDirection: "column",
646
+ justifyContent: "center",
647
+ minWidth: 0
648
+ },
649
+ children: [/*#__PURE__*/_jsx(DragIndicator, {
650
+ isResizing: isResizing,
651
+ mode: mode,
652
+ hasCustomContent: true
653
+ }), customContent]
654
+ });
655
+ }
656
+ return /*#__PURE__*/_jsxs("div", {
657
+ style: {
658
+ flex: 1,
659
+ display: "flex",
660
+ flexDirection: "column",
661
+ justifyContent: "center",
662
+ position: "relative",
663
+ minWidth: 0
664
+ },
665
+ children: [/*#__PURE__*/_jsx(DragIndicator, {
666
+ isResizing: isResizing,
667
+ mode: mode
668
+ }), /*#__PURE__*/_jsxs("div", {
669
+ style: {
670
+ // native headerContent: paddingHorizontal 16, alignItems center
671
+ padding: "0 16px",
672
+ display: "flex",
673
+ flexDirection: "column",
674
+ alignItems: "center"
675
+ },
676
+ children: [header?.title && /*#__PURE__*/_jsx("span", {
677
+ style: {
678
+ fontSize: 16,
679
+ // native headerTitle
680
+ fontWeight: 600,
681
+ color: gameUI.primary
682
+ },
683
+ children: header.title
684
+ }), header?.subtitle && /*#__PURE__*/_jsx("span", {
685
+ style: {
686
+ fontSize: 12,
687
+ // native headerSubtitle
688
+ color: gameUI.secondary,
689
+ paddingTop: 4
690
+ },
691
+ children: header.subtitle
692
+ })]
693
+ }), /*#__PURE__*/_jsx("div", {
694
+ style: {
695
+ position: "absolute",
696
+ inset: 0,
697
+ display: "flex",
698
+ alignItems: "center",
699
+ justifyContent: "center",
700
+ pointerEvents: "none"
701
+ },
702
+ children: /*#__PURE__*/_jsx("span", {
703
+ style: {
704
+ fontSize: 10,
705
+ // native hintText
706
+ color: gameUI.muted,
707
+ fontStyle: "italic"
708
+ },
709
+ children: "Double tap: Toggle \u2022 Triple tap: Close"
710
+ })
711
+ })]
712
+ });
713
+ }
714
+
715
+ // ============================================================================
716
+ // MAIN COMPONENT
717
+ // ============================================================================
718
+ const JsModalComponent = ({
719
+ visible,
720
+ onClose,
721
+ children,
722
+ header,
723
+ styles: customStyles = {},
724
+ minHeight = MIN_HEIGHT,
725
+ maxHeight,
726
+ initialHeight = DEFAULT_HEIGHT,
727
+ animatedHeight: _animatedHeight,
728
+ // no-op on web (DEV benchmark seam)
729
+ benchMotion: _benchMotion,
730
+ // no-op on web (DEV benchmark seam)
731
+ initialMode = "bottomSheet",
732
+ onModeChange,
733
+ persistenceKey,
734
+ enablePersistence = true,
735
+ initialFloatingPosition,
736
+ footer,
737
+ footerHeight = 0,
738
+ onBack: _onBack,
739
+ // native never invokes it from JsModal either
740
+ onMinimize,
741
+ minimizeTargetPosition,
742
+ initialModalState,
743
+ zIndex,
744
+ disableScrollWrapper = false
745
+ }) => {
746
+ const [insets] = useState(readSafeAreaInsets);
747
+ const [isStateLoaded, setIsStateLoaded] = useState(!enablePersistence);
748
+ const [mode, setMode] = useState(initialMode);
749
+ const [isResizing, setIsResizing] = useState(false);
750
+ const [isDragging, setIsDragging] = useState(false);
751
+ const [panelHeight, setPanelHeight] = useState(initialHeight);
752
+ const [dimensions, setDimensions] = useState(() => clampRectToSafeArea({
753
+ width: screenW(),
754
+ // native FLOATING_WIDTH = SCREEN.width
755
+ height: FLOATING_HEIGHT,
756
+ top: initialFloatingPosition?.y ?? (screenH() - FLOATING_HEIGHT) / 2,
757
+ left: initialFloatingPosition?.x ?? 0 // flush with left edge
758
+ }, insets));
759
+ const [showHint, setShowHint] = useState(false);
760
+ const [hintLoaded, setHintLoaded] = useState(false);
761
+ const hintsDisabled = useHintsDisabled();
762
+
763
+ // -- imperative value refs (native Animated.Value stand-ins) --------------
764
+ const currentHeightRef = useRef(initialHeight);
765
+ const currentDimensionsRef = useRef(dimensions);
766
+ const posRef = useRef({
767
+ x: dimensions.left,
768
+ y: dimensions.top
769
+ });
770
+
771
+ // -- DOM refs -------------------------------------------------------------
772
+ const sheetWrapperRef = useRef(null);
773
+ const sheetRef = useRef(null);
774
+ const floatRef = useRef(null);
775
+
776
+ // Active rAF animations (cancelled on new visibility runs / unmount).
777
+ const animCancelsRef = useRef([]);
778
+ const cancelAnims = useCallback(() => {
779
+ animCancelsRef.current.forEach(c => c());
780
+ animCancelsRef.current = [];
781
+ }, []);
782
+ useEffect(() => {
783
+ currentDimensionsRef.current = dimensions;
784
+ }, [dimensions]);
785
+ useEffect(() => cancelAnims, [cancelAnims]);
786
+ const effectiveMaxHeight = maxHeight || screenH() - insets.top;
787
+
788
+ // ==========================================================================
789
+ // PERSISTENCE — exact native key usage, shape and 500ms debounce
790
+ // ==========================================================================
791
+ useEffect(() => {
792
+ if (!enablePersistence || !persistenceKey) {
793
+ setIsStateLoaded(true);
794
+ return;
795
+ }
796
+ let mounted = true;
797
+ const loadState = async () => {
798
+ const savedState = await ModalStorage.load(persistenceKey);
799
+ if (mounted && savedState) {
800
+ if (savedState.mode) {
801
+ setMode(savedState.mode);
802
+ onModeChange?.(savedState.mode);
803
+ }
804
+ if (savedState.panelHeight) {
805
+ setPanelHeight(savedState.panelHeight);
806
+ currentHeightRef.current = savedState.panelHeight;
807
+ }
808
+ if (savedState.dimensions) {
809
+ // Clamp persisted rects too — a position saved on a different
810
+ // viewport (or before the safe-area fix) may be out of bounds.
811
+ const clamped = clampRectToSafeArea(savedState.dimensions, insets);
812
+ setDimensions(clamped);
813
+ posRef.current = {
814
+ x: clamped.left,
815
+ y: clamped.top
816
+ };
817
+ }
818
+ }
819
+ if (mounted) setIsStateLoaded(true);
820
+ };
821
+ loadState();
822
+ return () => {
823
+ mounted = false;
824
+ };
825
+ // eslint-disable-next-line react-hooks/exhaustive-deps
826
+ }, [persistenceKey, enablePersistence, onModeChange]);
827
+ useEffect(() => {
828
+ if (!enablePersistence || !persistenceKey || !isStateLoaded) return;
829
+ const timeoutId = setTimeout(() => {
830
+ ModalStorage.save(persistenceKey, {
831
+ mode,
832
+ panelHeight: currentHeightRef.current,
833
+ dimensions,
834
+ isVisible: visible
835
+ });
836
+ }, 500); // native: 500ms debounce
837
+ return () => clearTimeout(timeoutId);
838
+ }, [mode, panelHeight, dimensions, visible, persistenceKey, enablePersistence, isStateLoaded]);
839
+
840
+ // ==========================================================================
841
+ // HINT BANNER — same @react_buoy_modal_hint_acknowledged key as native
842
+ // ==========================================================================
843
+ useEffect(() => {
844
+ let mounted = true;
845
+ const loadHintStatus = async () => {
846
+ try {
847
+ const hintKey = devToolsStorageKeys.modal.hintAcknowledged();
848
+ const acknowledged = await webPersistentStorage.getItem(hintKey);
849
+ if (mounted) {
850
+ setShowHint(acknowledged !== "true");
851
+ setHintLoaded(true);
852
+ }
853
+ } catch {
854
+ if (mounted) {
855
+ setShowHint(true);
856
+ setHintLoaded(true);
857
+ }
858
+ }
859
+ };
860
+ loadHintStatus();
861
+ return () => {
862
+ mounted = false;
863
+ };
864
+ }, []);
865
+ const handleHintAcknowledge = useCallback(async () => {
866
+ try {
867
+ const hintKey = devToolsStorageKeys.modal.hintAcknowledged();
868
+ await webPersistentStorage.setItem(hintKey, "true");
869
+ setShowHint(false);
870
+ } catch {
871
+ setShowHint(false);
872
+ }
873
+ }, []);
874
+
875
+ // ==========================================================================
876
+ // MODE TOGGLE + MINIMIZE
877
+ // ==========================================================================
878
+ const toggleMode = useCallback(() => {
879
+ setIsDragging(false);
880
+ setIsResizing(false);
881
+ setMode(prev => {
882
+ const newMode = prev === "bottomSheet" ? "floating" : "bottomSheet";
883
+ onModeChange?.(newMode);
884
+ return newMode;
885
+ });
886
+ }, [onModeChange]);
887
+
888
+ // Belt-and-suspenders: also clear flags when mode changes (native parity).
889
+ useEffect(() => {
890
+ setIsDragging(false);
891
+ setIsResizing(false);
892
+ }, [mode]);
893
+ const handleMinimize = useCallback(() => {
894
+ if (!onMinimize) return;
895
+ const currentState = {
896
+ mode,
897
+ panelHeight: currentHeightRef.current,
898
+ floatingPosition: {
899
+ x: currentDimensionsRef.current.left,
900
+ y: currentDimensionsRef.current.top
901
+ },
902
+ floatingDimensions: {
903
+ width: currentDimensionsRef.current.width,
904
+ height: currentDimensionsRef.current.height
905
+ }
906
+ };
907
+
908
+ // native defaults: SCREEN.width - 50 / SCREEN.height - 150
909
+ const targetX = minimizeTargetPosition?.x ?? screenW() - 50;
910
+ const targetY = minimizeTargetPosition?.y ?? screenH() - 150;
911
+ cancelAnims();
912
+ if (mode === "bottomSheet") {
913
+ const wrapper = sheetWrapperRef.current;
914
+ if (!wrapper) {
915
+ onMinimize(currentState);
916
+ return;
917
+ }
918
+ const distance = screenH();
919
+ // native: parallel timing(300) opacity→0 + translateY→SCREEN.height
920
+ animCancelsRef.current.push(runAnim(300, easeInOut, v => {
921
+ wrapper.style.opacity = String(1 - v);
922
+ wrapper.style.transform = `translateY(${v * distance}px)`;
923
+ }, () => onMinimize(currentState)));
924
+ } else {
925
+ const el = floatRef.current;
926
+ if (!el) {
927
+ onMinimize(currentState);
928
+ return;
929
+ }
930
+ const from = {
931
+ ...posRef.current
932
+ };
933
+ // native animates floatingScale too, but the floating render never
934
+ // applies scale in its transform list — so visually it's fade + move,
935
+ // which is what we replicate.
936
+ animCancelsRef.current.push(runAnim(300, easeInOut, v => {
937
+ el.style.opacity = String(1 - v);
938
+ const x = from.x + (targetX - from.x) * v;
939
+ const y = from.y + (targetY - from.y) * v;
940
+ el.style.transform = `translate(${x}px, ${y}px)`;
941
+ }, () => onMinimize(currentState)));
942
+ }
943
+ }, [mode, onMinimize, minimizeTargetPosition, cancelAnims]);
944
+
945
+ // Apply initial modal state when restoring from minimized (native parity).
946
+ useEffect(() => {
947
+ if (!initialModalState) return;
948
+ if (initialModalState.mode) {
949
+ setMode(initialModalState.mode);
950
+ onModeChange?.(initialModalState.mode);
951
+ }
952
+ if (initialModalState.panelHeight) {
953
+ setPanelHeight(initialModalState.panelHeight);
954
+ currentHeightRef.current = initialModalState.panelHeight;
955
+ }
956
+ if (initialModalState.floatingDimensions && initialModalState.floatingPosition) {
957
+ const newDimensions = {
958
+ width: initialModalState.floatingDimensions.width,
959
+ height: initialModalState.floatingDimensions.height,
960
+ top: initialModalState.floatingPosition.y,
961
+ left: initialModalState.floatingPosition.x
962
+ };
963
+ setDimensions(newDimensions);
964
+ posRef.current = {
965
+ x: initialModalState.floatingPosition.x,
966
+ y: initialModalState.floatingPosition.y
967
+ };
968
+ }
969
+ // eslint-disable-next-line react-hooks/exhaustive-deps
970
+ }, [initialModalState]);
971
+
972
+ // ==========================================================================
973
+ // VISIBILITY / OPEN ANIMATIONS (mirrors native visibility effect: runs on
974
+ // visible AND mode changes; visible=false skips — the hidden tree renders)
975
+ // ==========================================================================
976
+ useLayoutEffect(() => {
977
+ if (!visible) return;
978
+ cancelAnims();
979
+ if (mode === "bottomSheet") {
980
+ const wrapper = sheetWrapperRef.current;
981
+ if (!wrapper) return;
982
+ const distance = screenH();
983
+ // native: reset translateY to SCREEN.height + progress 0, then
984
+ // spring(tension 180, friction 22) → 0 and timing(200) opacity → 1.
985
+ wrapper.style.transform = `translateY(${distance}px)`;
986
+ wrapper.style.opacity = "0";
987
+ animCancelsRef.current.push(
988
+ // spring stand-in: 300ms ease-out-cubic (logged deviation)
989
+ runAnim(300, easeOutCubic, v => {
990
+ wrapper.style.transform = `translateY(${(1 - v) * distance}px)`;
991
+ }), runAnim(200, easeInOut, v => {
992
+ wrapper.style.opacity = String(v);
993
+ }));
994
+ } else {
995
+ const el = floatRef.current;
996
+ if (!el) return;
997
+ // Restore-from-minimize deviation (see header): re-sync position from
998
+ // the last committed rect so the panel doesn't reopen at the minimize
999
+ // target the web AppHost never restores via initialModalState.
1000
+ posRef.current = {
1001
+ x: currentDimensionsRef.current.left,
1002
+ y: currentDimensionsRef.current.top
1003
+ };
1004
+ el.style.transform = `translate(${posRef.current.x}px, ${posRef.current.y}px)`;
1005
+ // native: floatingScale jumps to 1 (no pop) + timing(200) opacity → 1
1006
+ el.style.opacity = "0";
1007
+ animCancelsRef.current.push(runAnim(200, easeInOut, v => {
1008
+ el.style.opacity = String(v);
1009
+ }));
1010
+ }
1011
+ }, [visible, mode, cancelAnims]);
1012
+
1013
+ // ==========================================================================
1014
+ // TAP COUNTING — native spec: 500ms window, 300ms settle;
1015
+ // 2 taps → toggle mode, ≥3 taps → minimize
1016
+ // ==========================================================================
1017
+ const lastTapRef = useRef(0);
1018
+ const tapCountRef = useRef(0);
1019
+ const tapTimeoutRef = useRef(null);
1020
+ const registerTap = useCallback(() => {
1021
+ const now = Date.now();
1022
+ if (now - lastTapRef.current > 500) {
1023
+ tapCountRef.current = 0;
1024
+ }
1025
+ tapCountRef.current++;
1026
+ lastTapRef.current = now;
1027
+ if (tapTimeoutRef.current) clearTimeout(tapTimeoutRef.current);
1028
+ tapTimeoutRef.current = setTimeout(() => {
1029
+ if (tapCountRef.current === 2) {
1030
+ toggleMode();
1031
+ } else if (tapCountRef.current >= 3) {
1032
+ if (onMinimize) handleMinimize();
1033
+ }
1034
+ tapCountRef.current = 0;
1035
+ }, 300);
1036
+ }, [toggleMode, onMinimize, handleMinimize]);
1037
+ useEffect(() => {
1038
+ return () => {
1039
+ if (tapTimeoutRef.current) clearTimeout(tapTimeoutRef.current);
1040
+ };
1041
+ }, []);
1042
+
1043
+ // ==========================================================================
1044
+ // GESTURE: bottom-sheet header drag-resize (native bottomSheetPanResponder)
1045
+ // ==========================================================================
1046
+ const sheetGestureRef = useRef({
1047
+ active: false,
1048
+ resizing: false,
1049
+ startY: 0,
1050
+ headerOffsetY: 0,
1051
+ samples: []
1052
+ });
1053
+ const handleSheetPointerDown = useCallback(e => {
1054
+ if (mode !== "bottomSheet") return;
1055
+ // Ignore clicks on interactive header children (window controls live
1056
+ // outside the header, but custom content may contain buttons).
1057
+ if (e.target.closest("button")) return;
1058
+ const g = sheetGestureRef.current;
1059
+ g.active = true;
1060
+ g.resizing = false;
1061
+ g.startY = e.clientY;
1062
+ // native onPanResponderGrant: locationY — where inside the header the
1063
+ // pointer grabbed (absolute finger anchoring).
1064
+ const rect = e.currentTarget.getBoundingClientRect();
1065
+ g.headerOffsetY = e.clientY - rect.top;
1066
+ g.samples = [{
1067
+ t: performance.now(),
1068
+ y: e.clientY
1069
+ }];
1070
+ e.currentTarget.setPointerCapture(e.pointerId);
1071
+ }, [mode]);
1072
+ const handleSheetPointerMove = useCallback(e => {
1073
+ const g = sheetGestureRef.current;
1074
+ if (!g.active) return;
1075
+ const dy = e.clientY - g.startY;
1076
+ // native onMoveShouldSetPanResponder: |dy| > 3
1077
+ if (!g.resizing && Math.abs(dy) > 3) {
1078
+ g.resizing = true;
1079
+ cancelAnims();
1080
+ setIsResizing(true);
1081
+ }
1082
+ if (!g.resizing) return;
1083
+ // native onPanResponderMove: sheetTop = pageY - headerTouchOffset;
1084
+ // height = SCREEN.height - sheetTop, clamped
1085
+ const sheetTop = e.clientY - g.headerOffsetY;
1086
+ let targetHeight = screenH() - sheetTop;
1087
+ targetHeight = Math.max(minHeight, Math.min(targetHeight, effectiveMaxHeight));
1088
+ currentHeightRef.current = targetHeight;
1089
+ if (sheetRef.current) sheetRef.current.style.height = `${targetHeight}px`;
1090
+ // velocity samples (~last 100ms) for the swipe-to-close check
1091
+ const now = performance.now();
1092
+ g.samples.push({
1093
+ t: now,
1094
+ y: e.clientY
1095
+ });
1096
+ while (g.samples.length > 2 && now - g.samples[0].t > 100) g.samples.shift();
1097
+ }, [minHeight, effectiveMaxHeight, cancelAnims]);
1098
+ const handleSheetPointerUp = useCallback(e => {
1099
+ const g = sheetGestureRef.current;
1100
+ if (!g.active) return;
1101
+ g.active = false;
1102
+ if (!g.resizing) {
1103
+ // Tap path — native: TouchableWithoutFeedback keeps the touch when
1104
+ // movement stays under the 3px steal threshold.
1105
+ registerTap();
1106
+ return;
1107
+ }
1108
+ setIsResizing(false);
1109
+ const finalHeight = currentHeightRef.current;
1110
+ const dy = e.clientY - g.startY;
1111
+ // vy in px/ms — same unit as RN gestureState.vy
1112
+ const first = g.samples[0];
1113
+ const last = g.samples[g.samples.length - 1];
1114
+ const vy = last && first && last.t > first.t ? (last.y - first.y) / (last.t - first.t) : 0;
1115
+
1116
+ // native close spec: (vy > 0.8 && dy > 50) || (dy > 150 && h <= minHeight)
1117
+ const shouldClose = vy > 0.8 && dy > 50 || dy > 150 && finalHeight <= minHeight;
1118
+ if (shouldClose) {
1119
+ const wrapper = sheetWrapperRef.current;
1120
+ cancelAnims();
1121
+ if (wrapper) {
1122
+ const distance = screenH();
1123
+ // native: timing(200) opacity→0 + spring translateY→SCREEN.height
1124
+ animCancelsRef.current.push(runAnim(200, easeInOut, v => {
1125
+ wrapper.style.opacity = String(1 - v);
1126
+ }), runAnim(300, easeOutCubic, v => {
1127
+ wrapper.style.transform = `translateY(${v * distance}px)`;
1128
+ }, () => onClose()));
1129
+ } else {
1130
+ onClose();
1131
+ }
1132
+ return;
1133
+ }
1134
+
1135
+ // Already at the finger-tracked height; just commit state.
1136
+ setPanelHeight(finalHeight);
1137
+ }, [minHeight, onClose, registerTap, cancelAnims]);
1138
+ const handleSheetPointerCancel = useCallback(() => {
1139
+ const g = sheetGestureRef.current;
1140
+ if (!g.active) return;
1141
+ g.active = false;
1142
+ g.resizing = false;
1143
+ setIsResizing(false);
1144
+ }, []);
1145
+
1146
+ // ==========================================================================
1147
+ // GESTURE: floating header drag (native DraggableHeader semantics)
1148
+ // ==========================================================================
1149
+ const dragGestureRef = useRef({
1150
+ active: false,
1151
+ dragging: false,
1152
+ startX: 0,
1153
+ startY: 0,
1154
+ startPos: {
1155
+ x: 0,
1156
+ y: 0
1157
+ }
1158
+ });
1159
+ const handleFloatPointerDown = useCallback(e => {
1160
+ // native: DraggableHeader enabled = mode === "floating" && !isResizing
1161
+ if (mode !== "floating" || isResizing) return;
1162
+ if (e.target.closest("button")) return;
1163
+ const g = dragGestureRef.current;
1164
+ g.active = true;
1165
+ g.dragging = false;
1166
+ g.startX = e.clientX;
1167
+ g.startY = e.clientY;
1168
+ g.startPos = {
1169
+ ...posRef.current
1170
+ };
1171
+ e.currentTarget.setPointerCapture(e.pointerId);
1172
+ }, [mode, isResizing]);
1173
+ const handleFloatPointerMove = useCallback(e => {
1174
+ const g = dragGestureRef.current;
1175
+ if (!g.active) return;
1176
+ const dx = e.clientX - g.startX;
1177
+ const dy = e.clientY - g.startY;
1178
+ // native DraggableHeader: drag begins when |dx| + |dy| > 5
1179
+ if (!g.dragging && Math.abs(dx) + Math.abs(dy) > 5) {
1180
+ g.dragging = true;
1181
+ setIsDragging(true);
1182
+ }
1183
+ if (!g.dragging) return;
1184
+ // relative dx/dy motion, unclamped during move (native parity)
1185
+ const x = g.startPos.x + dx;
1186
+ const y = g.startPos.y + dy;
1187
+ posRef.current = {
1188
+ x,
1189
+ y
1190
+ };
1191
+ if (floatRef.current) {
1192
+ floatRef.current.style.transform = `translate(${x}px, ${y}px)`;
1193
+ }
1194
+ }, []);
1195
+ const handleFloatPointerUp = useCallback(() => {
1196
+ const g = dragGestureRef.current;
1197
+ if (!g.active) return;
1198
+ g.active = false;
1199
+ if (!g.dragging) {
1200
+ registerTap(); // native: DraggableHeader onTap → handleFloatingTap
1201
+ return;
1202
+ }
1203
+ setIsDragging(false);
1204
+ // Release clamp keeps the whole window inside the safe area (all four
1205
+ // insets — deliberate improvement over native, which only clamps top).
1206
+ const dims = currentDimensionsRef.current;
1207
+ const clampedRect = clampRectToSafeArea({
1208
+ ...dims,
1209
+ left: posRef.current.x,
1210
+ top: posRef.current.y
1211
+ }, insets);
1212
+ const clampedX = clampedRect.left;
1213
+ const clampedY = clampedRect.top;
1214
+ posRef.current = {
1215
+ x: clampedX,
1216
+ y: clampedY
1217
+ };
1218
+ if (floatRef.current) {
1219
+ floatRef.current.style.transform = `translate(${clampedX}px, ${clampedY}px)`;
1220
+ }
1221
+ setDimensions(prev => ({
1222
+ ...prev,
1223
+ left: clampedX,
1224
+ top: clampedY
1225
+ }));
1226
+ }, [insets, registerTap]);
1227
+ const handleFloatPointerCancel = useCallback(() => {
1228
+ const g = dragGestureRef.current;
1229
+ if (!g.active) return;
1230
+ g.active = false;
1231
+ g.dragging = false;
1232
+ setIsDragging(false);
1233
+ }, []);
1234
+
1235
+ // ==========================================================================
1236
+ // GESTURE: floating corner resize (native createResizeHandler — only the
1237
+ // bottom corners are rendered, to avoid conflicts with the window controls)
1238
+ // ==========================================================================
1239
+ const resizeGestureRef = useRef({
1240
+ active: false,
1241
+ corner: "bottomRight",
1242
+ startX: 0,
1243
+ startY: 0,
1244
+ start: {
1245
+ width: 0,
1246
+ height: 0,
1247
+ top: 0,
1248
+ left: 0
1249
+ }
1250
+ });
1251
+ const handleResizePointerDown = useCallback(corner => e => {
1252
+ if (mode !== "floating") return;
1253
+ e.stopPropagation();
1254
+ const g = resizeGestureRef.current;
1255
+ g.active = true;
1256
+ g.corner = corner;
1257
+ g.startX = e.clientX;
1258
+ g.startY = e.clientY;
1259
+ g.start = {
1260
+ ...currentDimensionsRef.current
1261
+ };
1262
+ setIsResizing(true);
1263
+ e.currentTarget.setPointerCapture(e.pointerId);
1264
+ }, [mode]);
1265
+ const handleResizePointerMove = useCallback(e => {
1266
+ const g = resizeGestureRef.current;
1267
+ if (!g.active) return;
1268
+ const dx = e.clientX - g.startX;
1269
+ const dy = e.clientY - g.startY;
1270
+ // native: moves under 0.5px are ignored
1271
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
1272
+
1273
+ // native safe-area-aware bounds
1274
+ const minLeft = Math.max(0, insets.left);
1275
+ const maxBottom = screenH() - Math.max(0, insets.bottom);
1276
+ const maxRight = screenW() - Math.max(0, insets.right);
1277
+ const floatingMinWidth = screenW() * 0.25; // native FLOATING_MIN_WIDTH
1278
+ const start = g.start;
1279
+ const startRight = start.left + start.width;
1280
+ const startBottom = start.top + start.height;
1281
+ let left = start.left;
1282
+ const top = start.top; // bottom corners anchor the top edge
1283
+ let right = startRight;
1284
+ let bottom = startBottom;
1285
+ if (g.corner === "bottomLeft") {
1286
+ // move left & bottom; anchor right & top
1287
+ left = Math.max(minLeft, Math.min(start.left + dx, startRight - floatingMinWidth));
1288
+ bottom = Math.min(maxBottom, Math.max(startBottom + dy, start.top + FLOATING_MIN_HEIGHT));
1289
+ } else {
1290
+ // bottomRight: move right & bottom; anchor left & top
1291
+ right = Math.min(maxRight, Math.max(startRight + dx, start.left + floatingMinWidth));
1292
+ bottom = Math.min(maxBottom, Math.max(startBottom + dy, start.top + FLOATING_MIN_HEIGHT));
1293
+ }
1294
+ const updatedWidth = Math.max(floatingMinWidth, right - left);
1295
+ const updatedHeight = Math.max(FLOATING_MIN_HEIGHT, bottom - top);
1296
+ const next = {
1297
+ width: updatedWidth,
1298
+ height: updatedHeight,
1299
+ left,
1300
+ top
1301
+ };
1302
+ currentDimensionsRef.current = next;
1303
+ posRef.current = {
1304
+ x: left,
1305
+ y: top
1306
+ };
1307
+ // native pushes setDimensions per move (state drives width/height)
1308
+ setDimensions(next);
1309
+ if (floatRef.current) {
1310
+ floatRef.current.style.transform = `translate(${left}px, ${top}px)`;
1311
+ }
1312
+ }, [insets.left, insets.right, insets.bottom]);
1313
+ const handleResizePointerUp = useCallback(() => {
1314
+ const g = resizeGestureRef.current;
1315
+ if (!g.active) return;
1316
+ g.active = false;
1317
+ setIsResizing(false);
1318
+ setDimensions(currentDimensionsRef.current);
1319
+ }, []);
1320
+
1321
+ // ==========================================================================
1322
+ // RENDER
1323
+ // ==========================================================================
1324
+
1325
+ // Hidden but mounted — panel state and listeners survive minimize/close-
1326
+ // to-background, same as native's offscreen container.
1327
+ // The three branches below carry distinct keys ON PURPOSE: the open/
1328
+ // minimize animations write opacity/transform imperatively (rAF, outside
1329
+ // React's style props), so if React reused a DOM node across branches —
1330
+ // e.g. a fresh mount whose async-persisted mode flips bottomSheet→floating
1331
+ // mid-entrance — the old sheet wrapper (opacity ≈ 0, translateY offscreen)
1332
+ // would be recycled as the floating header and render a blank strip.
1333
+ // React never clears styles it didn't set via props; distinct keys force a
1334
+ // clean remount of the chrome instead. (Children remount with it, matching
1335
+ // native, whose mode branches are different trees too — tool panels hold
1336
+ // their state above JsModal or in external stores.)
1337
+ if (!visible) {
1338
+ return /*#__PURE__*/_jsx("div", {
1339
+ style: {
1340
+ display: "none"
1341
+ },
1342
+ "aria-hidden": true,
1343
+ children: children
1344
+ }, "hidden");
1345
+ }
1346
+ const dialogLabel = header?.title || "Dev tool panel";
1347
+
1348
+ // Content block shared by both modes — native styles.content:
1349
+ // flex 1, bg base #121212, borderBottomRadius 16, overflow hidden.
1350
+ const contentBlock = /*#__PURE__*/_jsxs("div", {
1351
+ style: {
1352
+ flex: 1,
1353
+ minHeight: 0,
1354
+ position: "relative",
1355
+ display: "flex",
1356
+ flexDirection: "column",
1357
+ backgroundColor: buoy.base,
1358
+ borderBottomLeftRadius: 16,
1359
+ borderBottomRightRadius: 16,
1360
+ overflow: "hidden",
1361
+ ...customStyles.content
1362
+ },
1363
+ children: [disableScrollWrapper ?
1364
+ /*#__PURE__*/
1365
+ // children manage their own scrolling (native: plain flex View)
1366
+ _jsx("div", {
1367
+ style: {
1368
+ flex: 1,
1369
+ minHeight: 0,
1370
+ display: "flex",
1371
+ flexDirection: "column",
1372
+ paddingBottom: footerHeight
1373
+ },
1374
+ children: children
1375
+ }) :
1376
+ /*#__PURE__*/
1377
+ // native: ScrollView with flexGrow 1 + paddingBottom footerHeight
1378
+ _jsx("div", {
1379
+ style: {
1380
+ flex: 1,
1381
+ minHeight: 0,
1382
+ overflowY: "auto",
1383
+ paddingBottom: footerHeight
1384
+ },
1385
+ children: children
1386
+ }), footer ? /*#__PURE__*/_jsx("div", {
1387
+ style: {
1388
+ // native footerStyles.footerContainer
1389
+ position: "absolute",
1390
+ left: 0,
1391
+ right: 0,
1392
+ bottom: 0,
1393
+ backgroundColor: buoy.base,
1394
+ borderBottomLeftRadius: 16,
1395
+ borderBottomRightRadius: 16
1396
+ },
1397
+ children: footer
1398
+ }) : null]
1399
+ });
1400
+ const windowControlsBlock =
1401
+ /*#__PURE__*/
1402
+ // native windowControlsContainer: absolute top 4 right 4, zIndex 10
1403
+ _jsx("div", {
1404
+ style: {
1405
+ position: "absolute",
1406
+ top: 4,
1407
+ right: 4,
1408
+ zIndex: 10
1409
+ },
1410
+ children: /*#__PURE__*/_jsx(WindowControls, {
1411
+ onClose: onClose,
1412
+ onMinimize: onMinimize ? handleMinimize : undefined,
1413
+ onToggleMode: toggleMode,
1414
+ mode: mode
1415
+ })
1416
+ });
1417
+
1418
+ // ----- FLOATING MODE ------------------------------------------------------
1419
+ if (mode === "floating") {
1420
+ const active = isDragging || isResizing;
1421
+ return /*#__PURE__*/_jsxs("div", {
1422
+ ref: floatRef,
1423
+ role: "dialog",
1424
+ "aria-label": dialogLabel,
1425
+ style: {
1426
+ // native floatingModal: absolute, card bg, radius 6, 1px #333
1427
+ // border, teal glow (shadowOpacity 0.3, radius 20), zIndex 1000.
1428
+ // Dragging: 2px teal border + tighter teal glow
1429
+ // (primary+"99" × shadowOpacity 0.8 ≈ 0.48 effective alpha).
1430
+ position: "fixed",
1431
+ left: 0,
1432
+ top: 0,
1433
+ width: dimensions.width,
1434
+ height: dimensions.height,
1435
+ transform: `translate(${posRef.current.x}px, ${posRef.current.y}px)`,
1436
+ display: "flex",
1437
+ flexDirection: "column",
1438
+ backgroundColor: buoy.card,
1439
+ borderRadius: 6,
1440
+ border: active ? `2px solid ${buoy.primary}` : `1px solid ${buoy.border}`,
1441
+ boxShadow: active ? `0 0 12px ${buoy.primary}7A` : `0 0 20px ${buoy.primary}4D`,
1442
+ boxSizing: "border-box",
1443
+ // RN borders draw inside the rect
1444
+ zIndex: zIndex ?? 1000,
1445
+ ...customStyles.container
1446
+ },
1447
+ children: [/*#__PURE__*/_jsx("div", {
1448
+ onPointerDown: handleFloatPointerDown,
1449
+ onPointerMove: handleFloatPointerMove,
1450
+ onPointerUp: handleFloatPointerUp,
1451
+ onPointerCancel: handleFloatPointerCancel,
1452
+ style: {
1453
+ // native styles.header (+ floatingHeader top radius 6)
1454
+ borderTopLeftRadius: 6,
1455
+ borderTopRightRadius: 6,
1456
+ backgroundColor: buoy.card,
1457
+ minHeight: 56,
1458
+ border: `1px solid ${buoy.border}`,
1459
+ boxSizing: "border-box",
1460
+ display: "flex",
1461
+ flexDirection: "column",
1462
+ justifyContent: "center",
1463
+ flexShrink: 0,
1464
+ touchAction: "none",
1465
+ userSelect: "none",
1466
+ cursor: isDragging ? "grabbing" : "grab" // web-only affordance
1467
+ },
1468
+ children: /*#__PURE__*/_jsx(ModalHeaderContent, {
1469
+ header: header,
1470
+ isResizing: isDragging || isResizing,
1471
+ mode: mode,
1472
+ onMinimize: onMinimize ? handleMinimize : undefined
1473
+ })
1474
+ }), windowControlsBlock, contentBlock, ["bottomLeft", "bottomRight"].map(corner => /*#__PURE__*/_jsx("div", {
1475
+ onPointerDown: handleResizePointerDown(corner),
1476
+ onPointerMove: handleResizePointerMove,
1477
+ onPointerUp: handleResizePointerUp,
1478
+ onPointerCancel: handleResizePointerUp,
1479
+ style: {
1480
+ position: "absolute",
1481
+ width: 30,
1482
+ height: 30,
1483
+ bottom: 4,
1484
+ ...(corner === "bottomLeft" ? {
1485
+ left: 4
1486
+ } : {
1487
+ right: 4
1488
+ }),
1489
+ zIndex: 1000,
1490
+ touchAction: "none",
1491
+ cursor: corner === "bottomLeft" ? "nesw-resize" : "nwse-resize"
1492
+ },
1493
+ children: /*#__PURE__*/_jsx("div", {
1494
+ style: {
1495
+ width: 20,
1496
+ height: 20,
1497
+ borderRadius: 10,
1498
+ boxSizing: "border-box",
1499
+ backgroundColor: active ? `${gameUI.success}1A` : "transparent",
1500
+ border: active ? `2px solid ${gameUI.success}` : "none",
1501
+ boxShadow: active ? `0 0 8px ${gameUI.success}99` : "none"
1502
+ }
1503
+ })
1504
+ }, corner))]
1505
+ }, "floating");
1506
+ }
1507
+
1508
+ // ----- BOTTOM SHEET MODE --------------------------------------------------
1509
+ return (
1510
+ /*#__PURE__*/
1511
+ // native fullScreenContainer: absolute fill, zIndex 1000, box-none
1512
+ _jsx("div", {
1513
+ style: {
1514
+ position: "fixed",
1515
+ inset: 0,
1516
+ zIndex: zIndex ?? 1000,
1517
+ pointerEvents: "none"
1518
+ },
1519
+ children: /*#__PURE__*/_jsx("div", {
1520
+ ref: sheetWrapperRef,
1521
+ style: {
1522
+ position: "absolute",
1523
+ bottom: 0,
1524
+ left: 0,
1525
+ right: 0,
1526
+ pointerEvents: "auto"
1527
+ },
1528
+ children: /*#__PURE__*/_jsxs("div", {
1529
+ ref: sheetRef,
1530
+ role: "dialog",
1531
+ "aria-label": dialogLabel,
1532
+ style: {
1533
+ // native bottomSheet: card bg, top radius 6, 1px #333 border,
1534
+ // teal shadow (offset 0/-4, opacity 0.2, radius 12)
1535
+ height: currentHeightRef.current,
1536
+ display: "flex",
1537
+ flexDirection: "column",
1538
+ backgroundColor: buoy.card,
1539
+ borderTopLeftRadius: 6,
1540
+ borderTopRightRadius: 6,
1541
+ border: `1px solid ${buoy.border}`,
1542
+ boxShadow: `0 -4px 12px ${buoy.primary}33`,
1543
+ boxSizing: "border-box",
1544
+ position: "relative",
1545
+ ...customStyles.container
1546
+ },
1547
+ children: [/*#__PURE__*/_jsx("div", {
1548
+ onPointerDown: handleSheetPointerDown,
1549
+ onPointerMove: handleSheetPointerMove,
1550
+ onPointerUp: handleSheetPointerUp,
1551
+ onPointerCancel: handleSheetPointerCancel,
1552
+ style: {
1553
+ // native styles.header
1554
+ borderTopLeftRadius: 6,
1555
+ borderTopRightRadius: 6,
1556
+ backgroundColor: buoy.card,
1557
+ minHeight: 56,
1558
+ border: `1px solid ${buoy.border}`,
1559
+ boxSizing: "border-box",
1560
+ display: "flex",
1561
+ flexDirection: "column",
1562
+ justifyContent: "center",
1563
+ flexShrink: 0,
1564
+ touchAction: "none",
1565
+ userSelect: "none",
1566
+ cursor: "ns-resize" // web-only affordance
1567
+ },
1568
+ children: /*#__PURE__*/_jsx(ModalHeaderContent, {
1569
+ header: header,
1570
+ isResizing: isResizing,
1571
+ mode: mode,
1572
+ onMinimize: onMinimize ? handleMinimize : undefined
1573
+ })
1574
+ }), windowControlsBlock, hintLoaded && showHint && !hintsDisabled && /*#__PURE__*/_jsx(ModalHintBanner, {
1575
+ onAcknowledge: handleHintAcknowledge
1576
+ }), contentBlock]
1577
+ })
1578
+ })
1579
+ }, "bottomSheet")
1580
+ );
1581
+ };
1582
+
1583
+ // ============================================================================
1584
+ // EXPORT — memoized like native
1585
+ // ============================================================================
1586
+ export const JsModal = /*#__PURE__*/memo(JsModalComponent);