sh3-core 0.19.1 → 0.19.5

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 (84) hide show
  1. package/dist/Sh3.svelte +3 -1
  2. package/dist/actions/menuBarModel.js +8 -0
  3. package/dist/actions/menuBarModel.test.js +61 -0
  4. package/dist/api.d.ts +4 -0
  5. package/dist/api.js +3 -0
  6. package/dist/app/admin/ApiKeysView.svelte +6 -5
  7. package/dist/app/store/PermissionConfirmModal.svelte +23 -0
  8. package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
  9. package/dist/app/store/StoreView.svelte +6 -1
  10. package/dist/chrome/CompactChrome.svelte +34 -1
  11. package/dist/chrome/CompactChrome.svelte.test.js +11 -6
  12. package/dist/chrome/FloatsSheet.svelte +236 -0
  13. package/dist/chrome/FloatsSheet.svelte.d.ts +7 -0
  14. package/dist/chrome/FloatsSheet.svelte.test.d.ts +1 -0
  15. package/dist/chrome/FloatsSheet.svelte.test.js +155 -0
  16. package/dist/env/client.d.ts +5 -4
  17. package/dist/env/client.js +11 -17
  18. package/dist/env/serverUrl.d.ts +2 -0
  19. package/dist/env/serverUrl.js +8 -0
  20. package/dist/gestures/index.d.ts +17 -0
  21. package/dist/gestures/index.js +27 -0
  22. package/dist/keys/client.js +6 -7
  23. package/dist/keys/revocation-bus.svelte.js +11 -1
  24. package/dist/layout/compact/CarouselTabs.svelte +150 -14
  25. package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
  26. package/dist/layout/compact/CompactRenderer.svelte +9 -3
  27. package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
  28. package/dist/layout/compact/derive.js +7 -16
  29. package/dist/layout/compact/derive.test.js +30 -9
  30. package/dist/layout/compact/rootStore.svelte.d.ts +20 -0
  31. package/dist/layout/compact/rootStore.svelte.js +59 -0
  32. package/dist/layout/compact/rootStore.svelte.test.d.ts +1 -0
  33. package/dist/layout/compact/rootStore.svelte.test.js +54 -0
  34. package/dist/layout/drag.svelte.js +16 -3
  35. package/dist/layout/floats.d.ts +27 -0
  36. package/dist/layout/floats.js +20 -0
  37. package/dist/layout/floats.test.js +34 -1
  38. package/dist/layout/inspection.d.ts +20 -9
  39. package/dist/layout/inspection.js +91 -13
  40. package/dist/layout/inspection.svelte.test.d.ts +1 -0
  41. package/dist/layout/inspection.svelte.test.js +163 -0
  42. package/dist/layout/store.schemaVersion.test.js +2 -2
  43. package/dist/layout/types.d.ts +11 -8
  44. package/dist/layout/types.js +1 -1
  45. package/dist/layout/types.test.js +2 -2
  46. package/dist/overlays/FloatFrame.svelte +93 -22
  47. package/dist/overlays/FloatLayer.svelte +12 -1
  48. package/dist/overlays/float.d.ts +7 -0
  49. package/dist/overlays/float.js +76 -6
  50. package/dist/overlays/float.test.js +170 -0
  51. package/dist/primitives/ResizableSplitter.svelte +42 -8
  52. package/dist/primitives/widgets/DocumentFilePicker.d.ts +25 -0
  53. package/dist/primitives/widgets/DocumentFilePicker.js +74 -0
  54. package/dist/primitives/widgets/DocumentFilePicker.svelte +144 -0
  55. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +18 -0
  56. package/dist/primitives/widgets/DocumentOpener.svelte +36 -0
  57. package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +17 -0
  58. package/dist/primitives/widgets/DocumentSaver.svelte +36 -0
  59. package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +17 -0
  60. package/dist/primitives/widgets/_DocumentBrowser.svelte +337 -0
  61. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +11 -0
  62. package/dist/registry/checkFetch.d.ts +6 -0
  63. package/dist/registry/checkFetch.js +23 -0
  64. package/dist/sh3/views/KeysAndPeers.svelte +4 -3
  65. package/dist/shards/activate-runtime.test.js +99 -1
  66. package/dist/shards/activate.svelte.js +12 -3
  67. package/dist/shards/registry.d.ts +8 -1
  68. package/dist/shards/registry.js +13 -2
  69. package/dist/shards/registry.test.js +25 -4
  70. package/dist/shards/types.d.ts +14 -1
  71. package/dist/shell-shard/ScrollbackView.svelte +145 -67
  72. package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
  73. package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
  74. package/dist/shell-shard/dispatch-gating.test.js +38 -2
  75. package/dist/shell-shard/dispatch.js +9 -1
  76. package/dist/shell-shard/registry-resolve.test.js +50 -0
  77. package/dist/shell-shard/registry.d.ts +2 -1
  78. package/dist/shell-shard/registry.js +12 -2
  79. package/dist/shell-shard/verbs/help.js +5 -4
  80. package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
  81. package/dist/verbs/types.d.ts +10 -5
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/package.json +1 -1
@@ -35,6 +35,7 @@
35
35
  import { makeSelectionApi } from '../actions/selection.svelte';
36
36
  import { spawnSatellite } from '../sh3Api/window';
37
37
  import { walkShardsForContent } from '../satellite/walkShards';
38
+ import { logGesture } from '../gestures';
38
39
 
39
40
  const isTauri =
40
41
  typeof (globalThis as Record<string, unknown>).__TAURI_INTERNALS__ !== 'undefined';
@@ -64,8 +65,23 @@
64
65
 
65
66
  let dragging = $state(false);
66
67
  let dragOffset = { x: 0, y: 0 };
68
+ /**
69
+ * Pointer id of the active header drag (null when idle). All header-pointer
70
+ * handlers filter on this so a second finger / palm / stylus ghost touch
71
+ * landing on the same element cannot interfere with — or worse,
72
+ * `pointercancel` away — the primary drag.
73
+ *
74
+ * Previously this component used `setPointerCapture` on the header. On
75
+ * Android, transferring implicit capture from the original pointerdown
76
+ * target (a child span) to the header element fires `pointercancel` on
77
+ * the original target, which bubbled up to the `onpointercancel` handler
78
+ * here and ended the drag instantly. Switching to pointer-id tracking +
79
+ * implicit capture is the same fix the carousel landed (commit 638d75a).
80
+ */
81
+ let dragPointerId: number | null = null;
67
82
  let resizing = $state(false);
68
83
  let resizeStart = { pointer: { x: 0, y: 0 }, size: { w: 0, h: 0 }, min: { w: 0, h: 0 } };
84
+ let resizePointerId: number | null = null;
69
85
  let frameEl: HTMLDivElement | undefined = $state();
70
86
 
71
87
  const isMaximized = $derived(floatManager.isMaximized(entry.id));
@@ -99,20 +115,40 @@
99
115
  };
100
116
  });
101
117
 
118
+ // Remove any in-flight document-level pointer listeners if the float is
119
+ // closed mid-drag (e.g. the close button is hit, or the framework removes
120
+ // the float for another reason). Without this, dangling listeners on
121
+ // document would keep firing on the next pointermove and leak the
122
+ // closure references to the destroyed component.
123
+ $effect(() => {
124
+ return () => {
125
+ if (dragging) endHeaderDrag();
126
+ if (resizing) endGripResize();
127
+ };
128
+ });
129
+
102
130
  function onHeaderPointerDown(e: PointerEvent): void {
103
131
  if (e.button !== 0) return;
104
132
  if ((e.target as HTMLElement).closest('.sh3-float-close')) return;
105
133
  if ((e.target as HTMLElement).closest('.sh3-float-maximize')) return;
106
134
  if ((e.target as HTMLElement).closest('.sh3-float-popout')) return;
107
- const target = e.currentTarget as HTMLElement;
108
- target.setPointerCapture?.(e.pointerId);
135
+ if (dragging) return; // already mid-drag; second finger is ignored
109
136
  dragging = true;
137
+ dragPointerId = e.pointerId;
110
138
  dragOffset = { x: e.clientX - entry.position.x, y: e.clientY - entry.position.y };
111
139
  floatManager.focus(entry.id);
140
+ // Listen on document so the drag survives the pointer leaving the
141
+ // header. Mouse pointer capture is NOT implicit (touch is) — without
142
+ // these listeners, a fast mouse move that crosses the header edge
143
+ // would never deliver another pointermove and the drag would stall.
144
+ // The carousel uses the same pattern.
145
+ document.addEventListener('pointermove', onHeaderPointerMove);
146
+ document.addEventListener('pointerup', onHeaderPointerUp);
147
+ document.addEventListener('pointercancel', onHeaderPointerCancel);
112
148
  }
113
149
 
114
150
  function onHeaderPointerMove(e: PointerEvent): void {
115
- if (!dragging) return;
151
+ if (!dragging || e.pointerId !== dragPointerId) return;
116
152
  // Implicit un-maximize on first drag movement (no-op if not maximized).
117
153
  // We unmaximize on move rather than pointerdown so a casual click —
118
154
  // which precedes a dblclick — does not destroy the saved prev rect
@@ -122,13 +158,30 @@
122
158
  entry.position.y = e.clientY - dragOffset.y;
123
159
  }
124
160
 
125
- function onHeaderPointerUp(e: PointerEvent): void {
126
- if (!dragging) return;
161
+ function endHeaderDrag(): void {
127
162
  dragging = false;
128
- const target = e.currentTarget as HTMLElement;
129
- if (target.hasPointerCapture?.(e.pointerId)) {
130
- target.releasePointerCapture(e.pointerId);
163
+ dragPointerId = null;
164
+ document.removeEventListener('pointermove', onHeaderPointerMove);
165
+ document.removeEventListener('pointerup', onHeaderPointerUp);
166
+ document.removeEventListener('pointercancel', onHeaderPointerCancel);
167
+ }
168
+
169
+ function onHeaderPointerUp(e: PointerEvent): void {
170
+ if (!dragging || e.pointerId !== dragPointerId) return;
171
+ endHeaderDrag();
172
+ }
173
+
174
+ function onHeaderPointerCancel(e: PointerEvent): void {
175
+ // Only abort if the cancel is for OUR pointer. Without this filter, any
176
+ // ghost cancellation on the header (palm contact, a stylus touching
177
+ // while a finger drags, descendant losing implicit capture) would tear
178
+ // down the active drag — the touch-only auto-release symptom.
179
+ if (e.pointerId !== dragPointerId) {
180
+ logGesture('floatframe:cancel-other-id', e, { dragPointerId });
181
+ return;
131
182
  }
183
+ logGesture('floatframe:cancel-our-id', e, { dragPointerId });
184
+ endHeaderDrag();
132
185
  }
133
186
 
134
187
  function onHeaderDblClick(e: MouseEvent): void {
@@ -141,19 +194,22 @@
141
194
  function onGripPointerDown(e: PointerEvent): void {
142
195
  if (e.button !== 0) return;
143
196
  e.stopPropagation();
144
- const target = e.currentTarget as HTMLElement;
145
- target.setPointerCapture?.(e.pointerId);
197
+ if (resizing) return;
146
198
  resizing = true;
199
+ resizePointerId = e.pointerId;
147
200
  resizeStart = {
148
201
  pointer: { x: e.clientX, y: e.clientY },
149
202
  size: { w: entry.size.w, h: entry.size.h },
150
203
  min: computeMinSize(entry.content),
151
204
  };
152
205
  floatManager.focus(entry.id);
206
+ document.addEventListener('pointermove', onGripPointerMove);
207
+ document.addEventListener('pointerup', onGripPointerUp);
208
+ document.addEventListener('pointercancel', onGripPointerCancel);
153
209
  }
154
210
 
155
211
  function onGripPointerMove(e: PointerEvent): void {
156
- if (!resizing) return;
212
+ if (!resizing || e.pointerId !== resizePointerId) return;
157
213
  floatManager.unmaximize(entry.id);
158
214
  const dx = e.clientX - resizeStart.pointer.x;
159
215
  const dy = e.clientY - resizeStart.pointer.y;
@@ -161,13 +217,26 @@
161
217
  entry.size.h = Math.max(resizeStart.min.h, resizeStart.size.h + dy);
162
218
  }
163
219
 
164
- function onGripPointerUp(e: PointerEvent): void {
165
- if (!resizing) return;
220
+ function endGripResize(): void {
166
221
  resizing = false;
167
- const target = e.currentTarget as HTMLElement;
168
- if (target.hasPointerCapture?.(e.pointerId)) {
169
- target.releasePointerCapture(e.pointerId);
222
+ resizePointerId = null;
223
+ document.removeEventListener('pointermove', onGripPointerMove);
224
+ document.removeEventListener('pointerup', onGripPointerUp);
225
+ document.removeEventListener('pointercancel', onGripPointerCancel);
226
+ }
227
+
228
+ function onGripPointerUp(e: PointerEvent): void {
229
+ if (!resizing || e.pointerId !== resizePointerId) return;
230
+ endGripResize();
231
+ }
232
+
233
+ function onGripPointerCancel(e: PointerEvent): void {
234
+ if (e.pointerId !== resizePointerId) {
235
+ logGesture('floatframe-grip:cancel-other-id', e, { resizePointerId });
236
+ return;
170
237
  }
238
+ logGesture('floatframe-grip:cancel-our-id', e, { resizePointerId });
239
+ endGripResize();
171
240
  }
172
241
 
173
242
  function onFrameClick(): void {
@@ -226,9 +295,6 @@
226
295
  data-sh3-scope="element:float-header"
227
296
  data-float-id={entry.id}
228
297
  onpointerdown={onHeaderPointerDown}
229
- onpointermove={onHeaderPointerMove}
230
- onpointerup={onHeaderPointerUp}
231
- onpointercancel={onHeaderPointerUp}
232
298
  ondblclick={onHeaderDblClick}
233
299
  oncontextmenu={openHeaderContextMenu}
234
300
  >
@@ -261,9 +327,6 @@
261
327
  role="presentation"
262
328
  aria-hidden="true"
263
329
  onpointerdown={onGripPointerDown}
264
- onpointermove={onGripPointerMove}
265
- onpointerup={onGripPointerUp}
266
- onpointercancel={onGripPointerUp}
267
330
  ></div>
268
331
  </div>
269
332
 
@@ -291,6 +354,11 @@
291
354
  border-top-left-radius: var(--sh3-radius);
292
355
  border-top-right-radius: var(--sh3-radius);
293
356
  flex-shrink: 0;
357
+ /* Suppress browser-claimed touch gestures (pan / pinch) on the drag
358
+ handle. Without this, Android/iOS fire `pointercancel` as soon as the
359
+ finger moves past the system's scroll-claim threshold, killing the
360
+ header drag mid-pan. */
361
+ touch-action: none;
294
362
  }
295
363
  .sh3-float-title {
296
364
  font-size: 12px;
@@ -335,6 +403,9 @@
335
403
  width: 16px;
336
404
  height: 16px;
337
405
  cursor: nwse-resize;
406
+ /* Same rationale as the header: opt out of browser-claimed touch
407
+ gestures so the resize drag survives past the scroll-claim threshold. */
408
+ touch-action: none;
338
409
  /* Subtle visual hint without being obtrusive — two diagonal lines made
339
410
  from a CSS gradient stripe. */
340
411
  background:
@@ -7,14 +7,25 @@
7
7
  -->
8
8
  <script lang="ts">
9
9
  import { layoutStore } from '../layout/store.svelte';
10
+ import { viewportStore } from '../viewport/store.svelte';
10
11
  import FloatFrame from './FloatFrame.svelte';
11
12
 
12
13
  const floats = $derived(layoutStore.floats);
14
+ // In compact mode, non-dismissable floats are body-or-menu only — the
15
+ // compact-floats-menu design demotes them to the FloatsSheet so the
16
+ // user sees one root at a time. Dismissable pickers (anchored
17
+ // popovers) keep floating because they're transient.
18
+ const compact = $derived(viewportStore.current.class === 'compact');
19
+ function shouldRender(entry: { dismissable?: boolean }): boolean {
20
+ return !compact || entry.dismissable === true;
21
+ }
13
22
  </script>
14
23
 
15
24
  <div class="sh3-float-layer">
16
25
  {#each floats as entry, i (entry.id)}
17
- <FloatFrame bind:entry={floats[i]} />
26
+ {#if shouldRender(entry)}
27
+ <FloatFrame bind:entry={floats[i]} />
28
+ {/if}
18
29
  {/each}
19
30
  </div>
20
31
 
@@ -74,6 +74,13 @@ export interface FloatManager {
74
74
  * Bind the manager to the active LayoutTree's `floats` array. Called
75
75
  * from Sh3.svelte during boot. `getBounds` returns the current
76
76
  * tree-allocated area for cascade-position wraparound.
77
+ *
78
+ * Persisted floats observed for the first time are pulled into the
79
+ * supplied viewport — without this, a float whose position/size came
80
+ * from a larger window renders past the overlay root, which Firefox
81
+ * paints by growing the parent and visibly shifts the docked grid.
82
+ * Ids already in `clampedIds` (re-bound on viewport-class swap, etc.)
83
+ * are left alone.
77
84
  */
78
85
  export declare function bindFloatStore(floats: FloatEntry[], getBounds: () => {
79
86
  w: number;
@@ -26,21 +26,58 @@
26
26
  * in-memory fallback array is used — this is both the test environment
27
27
  * and the pre-boot state.
28
28
  */
29
- import { computeMinSize, cascadePosition, generateFloatId } from '../layout/floats';
29
+ import { computeMinSize, cascadePosition, generateFloatId, clampFloatToViewport, } from '../layout/floats';
30
30
  import { findEnclosingOverlayHost } from './parentHost';
31
+ import { compactRootStore } from '../layout/compact/rootStore.svelte';
32
+ import { viewportStore } from '../viewport/store.svelte';
31
33
  import { setMaximizedReactive, readMaximizedReactive, __resetMaximizedReactiveForTest, } from './floatMaximized.svelte';
32
34
  // ----- storage binding ---------------------------------------------------
33
35
  let fallbackFloats = [];
34
36
  let boundFloats = null;
35
37
  let getTreeBounds = () => ({ w: 1600, h: 900 });
38
+ /**
39
+ * Float ids that have already been pulled into the viewport — either at
40
+ * open() time, or on the bind that first observed them as persisted.
41
+ * Subsequent binds (viewport-class swap, preset switch back-and-forth)
42
+ * skip these so a user-positioned float isn't snapped on every re-bind.
43
+ *
44
+ * Ids are time+counter unique per session, so this set never confuses a
45
+ * recycled id; it grows for the lifetime of the session.
46
+ */
47
+ const clampedIds = new Set();
48
+ function clampFloatRect(entry, bounds) {
49
+ const minSize = computeMinSize(entry.content);
50
+ const clamped = clampFloatToViewport({ position: entry.position, size: entry.size }, minSize, bounds);
51
+ entry.position.x = clamped.position.x;
52
+ entry.position.y = clamped.position.y;
53
+ entry.size.w = clamped.size.w;
54
+ entry.size.h = clamped.size.h;
55
+ }
36
56
  /**
37
57
  * Bind the manager to the active LayoutTree's `floats` array. Called
38
58
  * from Sh3.svelte during boot. `getBounds` returns the current
39
59
  * tree-allocated area for cascade-position wraparound.
60
+ *
61
+ * Persisted floats observed for the first time are pulled into the
62
+ * supplied viewport — without this, a float whose position/size came
63
+ * from a larger window renders past the overlay root, which Firefox
64
+ * paints by growing the parent and visibly shifts the docked grid.
65
+ * Ids already in `clampedIds` (re-bound on viewport-class swap, etc.)
66
+ * are left alone.
40
67
  */
41
68
  export function bindFloatStore(floats, getBounds) {
42
69
  boundFloats = floats;
43
70
  getTreeBounds = getBounds;
71
+ const bounds = getBounds();
72
+ for (const entry of floats) {
73
+ if (clampedIds.has(entry.id))
74
+ continue;
75
+ clampFloatRect(entry, bounds);
76
+ clampedIds.add(entry.id);
77
+ }
78
+ // Active tree changed (app/preset switch) — drop any compact body
79
+ // selection so the user lands on the new docked tree.
80
+ compactRootStore.reset();
44
81
  }
45
82
  export function unbindFloatStore() {
46
83
  boundFloats = null;
@@ -53,6 +90,7 @@ export function __resetFloatManagerForTest() {
53
90
  getTreeBounds = () => ({ w: 1600, h: 900 });
54
91
  parentHosts.clear();
55
92
  maximizedRects.clear();
93
+ clampedIds.clear();
56
94
  __resetMaximizedReactiveForTest();
57
95
  }
58
96
  function activeStore() {
@@ -80,8 +118,16 @@ function mintFloatSlotId(viewId) {
80
118
  }
81
119
  // ----- API ---------------------------------------------------------------
82
120
  const DEFAULT_SIZE = { w: 600, h: 400 };
83
- function maxSize(a, b) {
84
- return { w: Math.max(a.w, b.w), h: Math.max(a.h, b.h) };
121
+ /**
122
+ * The default opening size: prefer DEFAULT_SIZE, but cap each axis at
123
+ * the current viewport so phones don't get a 600×400 float on a 360×800
124
+ * screen. Never shrunk below the content's computed minimum.
125
+ */
126
+ function defaultOpenSize(min, bounds) {
127
+ return {
128
+ w: Math.max(min.w, Math.min(DEFAULT_SIZE.w, bounds.w)),
129
+ h: Math.max(min.h, Math.min(DEFAULT_SIZE.h, bounds.h)),
130
+ };
85
131
  }
86
132
  function openFloat(viewId, options = {}) {
87
133
  var _a, _b, _c;
@@ -118,8 +164,9 @@ function openFloat(viewId, options = {}) {
118
164
  };
119
165
  }
120
166
  const computedMin = computeMinSize(content);
121
- const size = (_b = options.size) !== null && _b !== void 0 ? _b : maxSize(DEFAULT_SIZE, computedMin);
122
- const position = (_c = options.position) !== null && _c !== void 0 ? _c : cascadePosition(store, getTreeBounds());
167
+ const bounds = getTreeBounds();
168
+ const size = (_b = options.size) !== null && _b !== void 0 ? _b : defaultOpenSize(computedMin, bounds);
169
+ const position = (_c = options.position) !== null && _c !== void 0 ? _c : cascadePosition(store, bounds);
123
170
  const entry = {
124
171
  id,
125
172
  content,
@@ -134,14 +181,26 @@ function openFloat(viewId, options = {}) {
134
181
  if (host)
135
182
  parentHosts.set(id, host);
136
183
  }
184
+ // Pull the chosen rect into the current viewport (handles user-supplied
185
+ // off-screen positions and oversized user-supplied sizes).
186
+ clampFloatRect(entry, bounds);
187
+ // Mark already-clamped so the next bind doesn't snap it again.
188
+ clampedIds.add(id);
137
189
  store.push(entry);
190
+ // Compact mode demotes non-dismissable floats from overlays to body-or-menu;
191
+ // a fresh open auto-switches the body to the new float so the user actually
192
+ // sees what they just opened. Pickers stay floating per the design.
193
+ if (viewportStore.current.class === 'compact' && !options.dismissable) {
194
+ compactRootStore.setRoot({ kind: 'float', floatId: id });
195
+ }
138
196
  return id;
139
197
  }
140
198
  function openFloatWithContent(options) {
141
199
  var _a;
142
200
  const store = activeStore();
143
201
  const id = generateFloatId();
144
- const position = (_a = options.position) !== null && _a !== void 0 ? _a : cascadePosition(store, getTreeBounds());
202
+ const bounds = getTreeBounds();
203
+ const position = (_a = options.position) !== null && _a !== void 0 ? _a : cascadePosition(store, bounds);
145
204
  const entry = {
146
205
  id,
147
206
  content: options.content,
@@ -149,7 +208,12 @@ function openFloatWithContent(options) {
149
208
  size: options.size,
150
209
  title: options.title,
151
210
  };
211
+ clampFloatRect(entry, bounds);
212
+ clampedIds.add(id);
152
213
  store.push(entry);
214
+ if (viewportStore.current.class === 'compact') {
215
+ compactRootStore.setRoot({ kind: 'float', floatId: id });
216
+ }
153
217
  return id;
154
218
  }
155
219
  function closeFloat(floatId) {
@@ -160,6 +224,12 @@ function closeFloat(floatId) {
160
224
  store.splice(idx, 1);
161
225
  parentHosts.delete(floatId);
162
226
  maximizedRects.delete(floatId);
227
+ // If this float was the compact body, snap back to docked.
228
+ const cur = compactRootStore.current;
229
+ if (cur.kind === 'float' && cur.floatId === floatId) {
230
+ compactRootStore.reset();
231
+ }
232
+ clampedIds.delete(floatId);
163
233
  setMaximizedReactive(floatId, false);
164
234
  }
165
235
  function listFloats() {
@@ -80,6 +80,176 @@ describe('floatManager', () => {
80
80
  expect(f.content.type).toBe('tabs');
81
81
  });
82
82
  });
83
+ describe('bindFloatStore — clamp persisted floats to viewport', () => {
84
+ beforeEach(() => {
85
+ __resetFloatManagerForTest();
86
+ });
87
+ it('pulls an off-screen persisted float back into the bound viewport', () => {
88
+ // Persisted state from a previous, larger viewport: x is past the
89
+ // current right edge, y is past the current bottom.
90
+ const floats = [
91
+ {
92
+ id: 'persisted-1',
93
+ content: {
94
+ type: 'tabs',
95
+ tabs: [{ slotId: 'float:v:1', viewId: 'v', label: 'V' }],
96
+ activeTab: 0,
97
+ },
98
+ position: { x: 2400, y: 1800 },
99
+ size: { w: 600, h: 400 },
100
+ },
101
+ ];
102
+ bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
103
+ expect(floats[0].position.x).toBe(1024 - 600);
104
+ expect(floats[0].position.y).toBe(768 - 400);
105
+ expect(floats[0].size).toEqual({ w: 600, h: 400 });
106
+ });
107
+ it('shrinks an oversized persisted float to fit the bound viewport', () => {
108
+ const floats = [
109
+ {
110
+ id: 'persisted-2',
111
+ content: {
112
+ type: 'tabs',
113
+ tabs: [{ slotId: 'float:v:2', viewId: 'v', label: 'V' }],
114
+ activeTab: 0,
115
+ },
116
+ position: { x: 0, y: 0 },
117
+ size: { w: 4000, h: 3000 },
118
+ },
119
+ ];
120
+ bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
121
+ expect(floats[0].size).toEqual({ w: 1024, h: 768 });
122
+ });
123
+ it('leaves a float that already fits unchanged', () => {
124
+ const floats = [
125
+ {
126
+ id: 'persisted-3',
127
+ content: {
128
+ type: 'tabs',
129
+ tabs: [{ slotId: 'float:v:3', viewId: 'v', label: 'V' }],
130
+ activeTab: 0,
131
+ },
132
+ position: { x: 100, y: 200 },
133
+ size: { w: 600, h: 400 },
134
+ },
135
+ ];
136
+ bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
137
+ expect(floats[0].position).toEqual({ x: 100, y: 200 });
138
+ expect(floats[0].size).toEqual({ w: 600, h: 400 });
139
+ });
140
+ it('does not re-clamp a float on subsequent binds (e.g. viewport-class swap)', () => {
141
+ // Simulate a user-positioned float that's outside a smaller viewport.
142
+ // First bind clamps it; the user then moves it back to (700, 200);
143
+ // a second bind (compact ↔ desktop) must not snap it again.
144
+ const floats = [
145
+ {
146
+ id: 'persisted-4',
147
+ content: {
148
+ type: 'tabs',
149
+ tabs: [{ slotId: 'float:v:4', viewId: 'v', label: 'V' }],
150
+ activeTab: 0,
151
+ },
152
+ position: { x: 2400, y: 0 },
153
+ size: { w: 600, h: 400 },
154
+ },
155
+ ];
156
+ bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
157
+ // User drags it back to an out-of-bounds spot (allowed mid-session).
158
+ floats[0].position.x = 2400;
159
+ floats[0].position.y = 1800;
160
+ bindFloatStore(floats, () => ({ w: 1024, h: 768 }));
161
+ expect(floats[0].position).toEqual({ x: 2400, y: 1800 });
162
+ });
163
+ });
164
+ describe('floatManager.open — default size respects viewport', () => {
165
+ beforeEach(() => {
166
+ __resetFloatManagerForTest();
167
+ });
168
+ it('caps the default size at viewport bounds (phone-sized window)', () => {
169
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
170
+ const id = floatManager.open('test:view', { title: 'Phone' });
171
+ const f = floatManager.list().find((e) => e.id === id);
172
+ expect(f.size.w).toBeLessThanOrEqual(360);
173
+ expect(f.size.h).toBeLessThanOrEqual(740);
174
+ // And the float is fully on-screen.
175
+ expect(f.position.x + f.size.w).toBeLessThanOrEqual(360);
176
+ expect(f.position.y + f.size.h).toBeLessThanOrEqual(740);
177
+ });
178
+ it('uses DEFAULT_SIZE when the viewport is large enough', () => {
179
+ bindFloatStore(layoutStore.floats, () => ({ w: 1280, h: 800 }));
180
+ const id = floatManager.open('test:view', { title: 'Desktop' });
181
+ const f = floatManager.list().find((e) => e.id === id);
182
+ expect(f.size).toEqual({ w: 600, h: 400 });
183
+ });
184
+ it('clamps a user-supplied oversized size to viewport on open', () => {
185
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
186
+ const id = floatManager.open('test:view', {
187
+ title: 'Phone',
188
+ size: { w: 2000, h: 2000 },
189
+ });
190
+ const f = floatManager.list().find((e) => e.id === id);
191
+ expect(f.size).toEqual({ w: 360, h: 740 });
192
+ });
193
+ });
194
+ // ---------------------------------------------------------------------------
195
+ // Compact body-root integration: floatManager auto-switches in compact mode,
196
+ // resets on close-of-current and on bind.
197
+ // ---------------------------------------------------------------------------
198
+ import { compactRootStore, __resetCompactRootStoreForTest, } from '../layout/compact/rootStore.svelte';
199
+ import { viewportStore } from '../viewport/store.svelte';
200
+ describe('floatManager — compact body root integration', () => {
201
+ beforeEach(() => {
202
+ __resetFloatManagerForTest();
203
+ __resetCompactRootStoreForTest();
204
+ viewportStore.override(null);
205
+ });
206
+ afterEach(() => {
207
+ viewportStore.override(null);
208
+ });
209
+ it('open() in compact mode switches the body to the new (non-dismissable) float', () => {
210
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
211
+ viewportStore.override('compact');
212
+ const id = floatManager.open('test:view', { title: 'Notes' });
213
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: id });
214
+ });
215
+ it('open() in compact mode does NOT switch the body for dismissable pickers', () => {
216
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
217
+ viewportStore.override('compact');
218
+ floatManager.open('picker:view', { dismissable: true });
219
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
220
+ });
221
+ it('open() on desktop does not touch the compact body root', () => {
222
+ bindFloatStore(layoutStore.floats, () => ({ w: 1280, h: 800 }));
223
+ viewportStore.override('desktop');
224
+ floatManager.open('test:view');
225
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
226
+ });
227
+ it('close(id) resets when the closed float is the current body', () => {
228
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
229
+ viewportStore.override('compact');
230
+ const id = floatManager.open('test:view');
231
+ floatManager.close(id);
232
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
233
+ });
234
+ it('close(id) leaves the body root alone when closing a different float', () => {
235
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
236
+ viewportStore.override('compact');
237
+ floatManager.open('view:a');
238
+ const b = floatManager.open('view:b');
239
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: b });
240
+ floatManager.close(layoutStore.floats[0].id);
241
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: b });
242
+ });
243
+ it('bindFloatStore resets the body root (covers app/preset switch)', () => {
244
+ bindFloatStore(layoutStore.floats, () => ({ w: 360, h: 740 }));
245
+ viewportStore.override('compact');
246
+ const id = floatManager.open('test:view');
247
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: id });
248
+ // Re-bind (e.g. preset switch).
249
+ bindFloatStore([], () => ({ w: 360, h: 740 }));
250
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
251
+ });
252
+ });
83
253
  describe('floatManager — anchor-aware parent host', () => {
84
254
  beforeEach(() => {
85
255
  __resetFloatManagerForTest();
@@ -20,7 +20,7 @@
20
20
  import type { Snippet } from 'svelte';
21
21
  import type { SizeMode, SplitDirection } from '../layout/types';
22
22
  import { claim, revoke } from '../gestures/pointerClaim';
23
- import { ancestorCount } from '../gestures';
23
+ import { ancestorCount, logGesture } from '../gestures';
24
24
 
25
25
  const MIN_PX = 40;
26
26
  const COLLAPSED_PX = 28;
@@ -128,7 +128,16 @@
128
128
  activeDragPointerId = e.pointerId;
129
129
 
130
130
  e.preventDefault();
131
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
131
+ // Document-level listeners (not element-level attributes) so the drag
132
+ // survives the pointer leaving the handle. Mouse pointer capture is
133
+ // not implicit; without document listeners, a fast mouse move past
134
+ // the handle edge stops delivering pointermove. setPointerCapture
135
+ // would work for mouse but reintroduces the Android pointercancel
136
+ // bug (see CarouselTabs commit 638d75a). document listeners avoid
137
+ // both pitfalls — same pattern the carousel uses.
138
+ document.addEventListener('pointermove', moveDrag);
139
+ document.addEventListener('pointerup', endDrag);
140
+ document.addEventListener('pointercancel', cancelDrag);
132
141
 
133
142
  const rect = container.getBoundingClientRect();
134
143
  const containerPx = direction === 'horizontal' ? rect.width : rect.height;
@@ -154,6 +163,7 @@
154
163
 
155
164
  function moveDrag(e: PointerEvent) {
156
165
  if (!drag) return;
166
+ if (e.pointerId !== activeDragPointerId) return;
157
167
  const client = direction === 'horizontal' ? e.clientX : e.clientY;
158
168
  const deltaPx = client - drag.startClient;
159
169
 
@@ -203,15 +213,42 @@
203
213
  }
204
214
  }
205
215
 
206
- function endDrag(e: PointerEvent) {
207
- if (!drag) return;
208
- (e.target as HTMLElement).releasePointerCapture(e.pointerId);
216
+ function teardownDrag(): void {
209
217
  if (activeDragPointerId !== null) {
210
218
  revoke(activeDragPointerId, 'sh3:splitter');
211
219
  activeDragPointerId = null;
212
220
  }
213
221
  drag = null;
222
+ document.removeEventListener('pointermove', moveDrag);
223
+ document.removeEventListener('pointerup', endDrag);
224
+ document.removeEventListener('pointercancel', cancelDrag);
225
+ }
226
+
227
+ function endDrag(e: PointerEvent) {
228
+ if (!drag) return;
229
+ if (e.pointerId !== activeDragPointerId) return;
230
+ teardownDrag();
214
231
  }
232
+
233
+ function cancelDrag(e: PointerEvent) {
234
+ // Filter so a ghost pointercancel for an unrelated pointer (palm, stylus,
235
+ // second finger on multi-touch) doesn't abort the active resize.
236
+ if (!drag) return;
237
+ if (e.pointerId !== activeDragPointerId) {
238
+ logGesture('splitter:cancel-other-id', e, { activeDragPointerId });
239
+ return;
240
+ }
241
+ logGesture('splitter:cancel-our-id', e, { activeDragPointerId });
242
+ teardownDrag();
243
+ }
244
+
245
+ // If the splitter unmounts mid-drag, remove the document listeners and
246
+ // release the claim so the next component doesn't see a leaked pointer.
247
+ $effect(() => {
248
+ return () => {
249
+ if (drag) teardownDrag();
250
+ };
251
+ });
215
252
  </script>
216
253
 
217
254
  <div
@@ -281,9 +318,6 @@
281
318
  class:frozen={isHandleFrozen(i)}
282
319
  data-testid="splitter-handle-{i}"
283
320
  onpointerdown={(e) => beginDrag(e, i)}
284
- onpointermove={moveDrag}
285
- onpointerup={endDrag}
286
- onpointercancel={endDrag}
287
321
  ondblclick={() => {
288
322
  if (isHandleFrozen(i)) return;
289
323
  if (!canCollapse(i) && !isCollapsed(i)) return;