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
@@ -202,25 +202,32 @@ describe('CarouselTabs (gestures)', () => {
202
202
  flushSync();
203
203
  expect(node.activeTab).toBe(0);
204
204
  });
205
- it('pointerdown inside an overflow-x:scroll element does not initiate a swipe', () => {
205
+ it('pointerdown inside an overflow-x:scroll element with actual horizontal overflow does not initiate a swipe', () => {
206
206
  const node = makeNode(['A', 'B', 'C'], 0);
207
207
  mountCarousel({ node, wrap: false });
208
208
  const slide = host.querySelector('[data-sh3-slide]');
209
209
  const scroller = document.createElement('div');
210
210
  scroller.style.overflowX = 'scroll';
211
+ Object.defineProperty(scroller, 'scrollWidth', { value: 1000, configurable: true });
212
+ Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true });
211
213
  slide.appendChild(scroller);
214
+ // Pointer-down in the center (x=250 < 276 right-gutter boundary).
215
+ // dx=-170 would commit if the bail were absent, so a passing test
216
+ // genuinely proves the gesture never started.
212
217
  scroller.dispatchEvent(fakePointer('pointerdown', 250, 100));
213
218
  document.dispatchEvent(fakePointer('pointermove', 80, 100));
214
219
  document.dispatchEvent(fakePointer('pointerup', 80, 100));
215
220
  flushSync();
216
221
  expect(node.activeTab).toBe(0);
217
222
  });
218
- it('pointerdown inside an overflow-x:auto element does not initiate a swipe', () => {
223
+ it('pointerdown inside an overflow-x:auto element with actual horizontal overflow does not initiate a swipe', () => {
219
224
  const node = makeNode(['A', 'B', 'C'], 0);
220
225
  mountCarousel({ node, wrap: false });
221
226
  const slide = host.querySelector('[data-sh3-slide]');
222
227
  const scroller = document.createElement('div');
223
228
  scroller.style.overflowX = 'auto';
229
+ Object.defineProperty(scroller, 'scrollWidth', { value: 1000, configurable: true });
230
+ Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true });
224
231
  slide.appendChild(scroller);
225
232
  scroller.dispatchEvent(fakePointer('pointerdown', 250, 100));
226
233
  document.dispatchEvent(fakePointer('pointermove', 80, 100));
@@ -228,6 +235,25 @@ describe('CarouselTabs (gestures)', () => {
228
235
  flushSync();
229
236
  expect(node.activeTab).toBe(0);
230
237
  });
238
+ it('overflow-x:auto with no actual horizontal overflow does NOT block the swipe', () => {
239
+ // The common false-positive: a body view sets `overflow: auto` to get
240
+ // vertical scrolling; the resolved overflow-x is `auto` even though the
241
+ // content fits horizontally. Previously this killed every carousel swipe
242
+ // initiated inside the body. With the scrollWidth>clientWidth tightening,
243
+ // only genuinely scrollable regions block.
244
+ const node = makeNode(['A', 'B', 'C'], 0);
245
+ mountCarousel({ node, wrap: false });
246
+ const slide = host.querySelector('[data-sh3-slide]');
247
+ const wrapper = document.createElement('div');
248
+ wrapper.style.overflowX = 'auto';
249
+ // scrollWidth / clientWidth default to 0 in happy-dom — no actual overflow.
250
+ slide.appendChild(wrapper);
251
+ wrapper.dispatchEvent(fakePointer('pointerdown', 250, 100));
252
+ document.dispatchEvent(fakePointer('pointermove', 80, 100));
253
+ document.dispatchEvent(fakePointer('pointerup', 80, 100));
254
+ flushSync();
255
+ expect(node.activeTab).toBe(1);
256
+ });
231
257
  it('pointerdown inside an overflow-x:hidden element still allows swipe', () => {
232
258
  const node = makeNode(['A', 'B', 'C'], 0);
233
259
  mountCarousel({ node, wrap: false });
@@ -267,6 +293,200 @@ describe('CarouselTabs (gestures)', () => {
267
293
  expect(track.classList.contains('sh3-carousel-track--dragging')).toBe(false);
268
294
  });
269
295
  });
296
+ describe('CarouselTabs — multi-pointer filter', () => {
297
+ // The carousel attaches its pointermove / pointerup / pointercancel
298
+ // listeners on `document`, so any pointer's events for those types
299
+ // reach our handler. Without filtering by pointer id, an unrelated
300
+ // pointer's release or cancel (palm contact, second finger, stylus
301
+ // ghost) would tear down a legitimate drag.
302
+ it('pointercancel for a DIFFERENT pointer id does not abort the active drag', () => {
303
+ const node = makeNode(['A', 'B', 'C'], 0);
304
+ mountCarousel({ node, wrap: false });
305
+ const track = host.querySelector('[data-sh3-carousel-track]');
306
+ // Drag with pointer id 1.
307
+ track.dispatchEvent(fakePointer('pointerdown', 250, 100, 1));
308
+ document.dispatchEvent(fakePointer('pointermove', 200, 100, 1));
309
+ document.dispatchEvent(fakePointer('pointermove', 100, 100, 1));
310
+ // A different pointer (id 2) is cancelled — must NOT end our gesture.
311
+ document.dispatchEvent(fakePointer('pointercancel', 0, 0, 2));
312
+ // Our pointer keeps going past the commit threshold and releases.
313
+ document.dispatchEvent(fakePointer('pointermove', 80, 100, 1));
314
+ document.dispatchEvent(fakePointer('pointerup', 80, 100, 1));
315
+ flushSync();
316
+ expect(node.activeTab).toBe(1);
317
+ });
318
+ it('pointerup for a DIFFERENT pointer id does not end the active drag', () => {
319
+ const node = makeNode(['A', 'B', 'C'], 0);
320
+ mountCarousel({ node, wrap: false });
321
+ const track = host.querySelector('[data-sh3-carousel-track]');
322
+ track.dispatchEvent(fakePointer('pointerdown', 250, 100, 1));
323
+ document.dispatchEvent(fakePointer('pointermove', 200, 100, 1));
324
+ document.dispatchEvent(fakePointer('pointermove', 100, 100, 1));
325
+ // A different pointer (id 2) is released — must NOT end our gesture.
326
+ document.dispatchEvent(fakePointer('pointerup', 0, 0, 2));
327
+ document.dispatchEvent(fakePointer('pointermove', 80, 100, 1));
328
+ document.dispatchEvent(fakePointer('pointerup', 80, 100, 1));
329
+ flushSync();
330
+ expect(node.activeTab).toBe(1);
331
+ });
332
+ it('pointercancel for OUR pointer id still aborts', () => {
333
+ // Sanity check — the existing abort-on-cancel behavior is preserved
334
+ // when the cancellation is for our active pointer.
335
+ const node = makeNode(['A', 'B', 'C'], 0);
336
+ mountCarousel({ node, wrap: false });
337
+ const track = host.querySelector('[data-sh3-carousel-track]');
338
+ track.dispatchEvent(fakePointer('pointerdown', 250, 100, 1));
339
+ document.dispatchEvent(fakePointer('pointermove', 200, 100, 1));
340
+ document.dispatchEvent(fakePointer('pointermove', 100, 100, 1));
341
+ document.dispatchEvent(fakePointer('pointercancel', 100, 100, 1));
342
+ flushSync();
343
+ expect(node.activeTab).toBe(0);
344
+ });
345
+ });
346
+ describe('CarouselTabs — edge gutter invariant', () => {
347
+ // EDGE_PX = 24. Container is 300px wide. Left gutter: x < 24.
348
+ // Right gutter: x > 276.
349
+ it('left-edge pointer-down initiates a swipe even on an editable target', () => {
350
+ // Without the gutter override, an <input> sitting under the finger at
351
+ // the screen edge would silently swallow the drag (isEditableTarget bail).
352
+ const node = makeNode(['A', 'B', 'C'], 1);
353
+ mountCarousel({ node, wrap: false });
354
+ const slide = host.querySelectorAll('[data-sh3-slide]')[1];
355
+ const input = document.createElement('input');
356
+ slide.appendChild(input);
357
+ // x=10 — inside left edge gutter. Swipe right → previous tab.
358
+ input.dispatchEvent(fakePointer('pointerdown', 10, 100));
359
+ document.dispatchEvent(fakePointer('pointermove', 80, 100));
360
+ document.dispatchEvent(fakePointer('pointermove', 200, 100));
361
+ document.dispatchEvent(fakePointer('pointerup', 200, 100));
362
+ flushSync();
363
+ expect(node.activeTab).toBe(0);
364
+ });
365
+ it('right-edge pointer-down initiates a swipe even inside an overflow-x:auto region with real overflow', () => {
366
+ const node = makeNode(['A', 'B', 'C'], 0);
367
+ mountCarousel({ node, wrap: false });
368
+ const slide = host.querySelectorAll('[data-sh3-slide]')[0];
369
+ const scroller = document.createElement('div');
370
+ scroller.style.overflowX = 'auto';
371
+ Object.defineProperty(scroller, 'scrollWidth', { value: 1000, configurable: true });
372
+ Object.defineProperty(scroller, 'clientWidth', { value: 200, configurable: true });
373
+ slide.appendChild(scroller);
374
+ // x=290 — inside right edge gutter (300 - 24 = 276). Swipe left → next tab.
375
+ scroller.dispatchEvent(fakePointer('pointerdown', 290, 100));
376
+ document.dispatchEvent(fakePointer('pointermove', 200, 100));
377
+ document.dispatchEvent(fakePointer('pointermove', 80, 100));
378
+ document.dispatchEvent(fakePointer('pointerup', 80, 100));
379
+ flushSync();
380
+ expect(node.activeTab).toBe(1);
381
+ });
382
+ it('left-edge pointer-down on a contenteditable still initiates a swipe', () => {
383
+ const node = makeNode(['A', 'B', 'C'], 1);
384
+ mountCarousel({ node, wrap: false });
385
+ const slide = host.querySelectorAll('[data-sh3-slide]')[1];
386
+ const editable = document.createElement('div');
387
+ editable.setAttribute('contenteditable', 'true');
388
+ slide.appendChild(editable);
389
+ editable.dispatchEvent(fakePointer('pointerdown', 5, 100));
390
+ document.dispatchEvent(fakePointer('pointermove', 80, 100));
391
+ document.dispatchEvent(fakePointer('pointermove', 200, 100));
392
+ document.dispatchEvent(fakePointer('pointerup', 200, 100));
393
+ flushSync();
394
+ expect(node.activeTab).toBe(0);
395
+ });
396
+ it('gutter pointer-down: transfers pointer capture to the carousel container on threshold-cross', () => {
397
+ // Concrete proof that the "invincible gutter" mechanism actually fires:
398
+ // once horizontal dominance is established on a gutter-initiated drag,
399
+ // setPointerCapture must be called on the carousel container so the
400
+ // descendant the touch landed on no longer receives the pointer stream.
401
+ const node = makeNode(['A', 'B', 'C'], 1);
402
+ mountCarousel({ node, wrap: false });
403
+ const carousel = host.querySelector('.sh3-carousel');
404
+ const track = host.querySelector('[data-sh3-carousel-track]');
405
+ let captured = null;
406
+ carousel.setPointerCapture = (id) => { captured = id; };
407
+ carousel.hasPointerCapture = (id) => captured === id;
408
+ carousel.releasePointerCapture = (id) => { if (captured === id)
409
+ captured = null; };
410
+ track.dispatchEvent(fakePointer('pointerdown', 10, 100, 7)); // left gutter
411
+ document.dispatchEvent(fakePointer('pointermove', 80, 100, 7)); // crosses threshold
412
+ expect(captured).toBe(7);
413
+ });
414
+ it('mid-track pointer-down: does NOT transfer pointer capture (contract preserved)', () => {
415
+ // The "invincible" treatment is scoped to the gutter on purpose. Inside
416
+ // the slide, the existing rule still holds: descendants can claim and
417
+ // the carousel aborts.
418
+ const node = makeNode(['A', 'B', 'C'], 1);
419
+ mountCarousel({ node, wrap: false });
420
+ const carousel = host.querySelector('.sh3-carousel');
421
+ const track = host.querySelector('[data-sh3-carousel-track]');
422
+ let captured = null;
423
+ carousel.setPointerCapture = (id) => { captured = id; };
424
+ carousel.hasPointerCapture = (id) => captured === id;
425
+ carousel.releasePointerCapture = (id) => { if (captured === id)
426
+ captured = null; };
427
+ track.dispatchEvent(fakePointer('pointerdown', 150, 100, 7)); // center, not gutter
428
+ document.dispatchEvent(fakePointer('pointermove', 80, 100, 7));
429
+ expect(captured).toBeNull();
430
+ });
431
+ it('gutter swipe: a pointercancel within the transfer window is swallowed, drag continues', () => {
432
+ // Synthetic Android-style "transfer cancel": setPointerCapture on the
433
+ // container fires a pointercancel on the original target. Without the
434
+ // ignoreCancelUntil window, that cancel would abort the very drag the
435
+ // gutter just guaranteed.
436
+ const node = makeNode(['A', 'B', 'C'], 1);
437
+ mountCarousel({ node, wrap: false });
438
+ const carousel = host.querySelector('.sh3-carousel');
439
+ const track = host.querySelector('[data-sh3-carousel-track]');
440
+ carousel.setPointerCapture = () => { };
441
+ carousel.hasPointerCapture = () => false;
442
+ carousel.releasePointerCapture = () => { };
443
+ track.dispatchEvent(fakePointer('pointerdown', 10, 100, 9)); // left gutter
444
+ document.dispatchEvent(fakePointer('pointermove', 80, 100, 9)); // crosses threshold → capture armed
445
+ // Transfer cancel — should be swallowed:
446
+ document.dispatchEvent(fakePointer('pointercancel', 80, 100, 9));
447
+ // Drag continues rightward; with starting activeTab=1, rightward swipe
448
+ // (positive dx) goes to previous tab (activeTab=0).
449
+ document.dispatchEvent(fakePointer('pointermove', 200, 100, 9));
450
+ document.dispatchEvent(fakePointer('pointerup', 200, 100, 9));
451
+ flushSync();
452
+ expect(node.activeTab).toBe(0);
453
+ });
454
+ it('gutter swipe: only the FIRST cancel within the window is swallowed; a second cancel aborts', () => {
455
+ // Defensive: the swallow is single-shot. After one cancel the window
456
+ // closes so a subsequent real cancel still aborts the drag.
457
+ const node = makeNode(['A', 'B', 'C'], 1);
458
+ mountCarousel({ node, wrap: false });
459
+ const carousel = host.querySelector('.sh3-carousel');
460
+ const track = host.querySelector('[data-sh3-carousel-track]');
461
+ carousel.setPointerCapture = () => { };
462
+ carousel.hasPointerCapture = () => false;
463
+ carousel.releasePointerCapture = () => { };
464
+ track.dispatchEvent(fakePointer('pointerdown', 10, 100, 11));
465
+ document.dispatchEvent(fakePointer('pointermove', 80, 100, 11)); // claim, arm window
466
+ document.dispatchEvent(fakePointer('pointercancel', 80, 100, 11)); // swallow
467
+ document.dispatchEvent(fakePointer('pointercancel', 80, 100, 11)); // abort
468
+ // Subsequent move/up should now be ignored:
469
+ document.dispatchEvent(fakePointer('pointermove', 200, 100, 11));
470
+ document.dispatchEvent(fakePointer('pointerup', 200, 100, 11));
471
+ flushSync();
472
+ expect(node.activeTab).toBe(1);
473
+ });
474
+ it('center pointer-down on the same editable target still bails (gutter is not the whole carousel)', () => {
475
+ // Sanity check: the override is scoped to the edge zones only —
476
+ // mid-track pointer-downs on inputs continue to focus the input.
477
+ const node = makeNode(['A', 'B', 'C'], 0);
478
+ mountCarousel({ node, wrap: false });
479
+ const slide = host.querySelector('[data-sh3-slide]');
480
+ const input = document.createElement('input');
481
+ slide.appendChild(input);
482
+ // x=150 — mid-track, not in either gutter.
483
+ input.dispatchEvent(fakePointer('pointerdown', 150, 100));
484
+ document.dispatchEvent(fakePointer('pointermove', 30, 100));
485
+ document.dispatchEvent(fakePointer('pointerup', 30, 100));
486
+ flushSync();
487
+ expect(node.activeTab).toBe(0);
488
+ });
489
+ });
270
490
  describe('CarouselTabs — PointerClaim integration', () => {
271
491
  it('does not start gesture when pointer is already claimed by an app', () => {
272
492
  const node = makeNode(['A', 'B'], 0);
@@ -11,18 +11,24 @@
11
11
  * the docked content so the surfaces stack correctly.
12
12
  *
13
13
  * View-default role lookup is intentionally omitted in v1 — derive()
14
- * reads slot.role / tab.role directly. Apps that want non-body slots
14
+ * reads slot.role / tabs.role directly. Apps that want non-body slots
15
15
  * tag them at authoring time. View-default fall-through ships when the
16
16
  * registry exposes a pre-mount lookup (deferred from this PR).
17
17
  */
18
- import { layoutStore } from '../store.svelte';
19
18
  import { drawerStore } from './drawerStore.svelte';
20
19
  import { derive } from './derive';
20
+ import { resolveCompactBodyRoot } from './rootStore.svelte';
21
21
  import LayoutRenderer from '../LayoutRenderer.svelte';
22
22
  import DrawerSurface from '../../overlays/DrawerSurface.svelte';
23
23
  import type { DrawerAnchor } from './types';
24
24
 
25
- const rendering = $derived(derive(layoutStore.root));
25
+ // Compact body shows whatever compactRootStore currently points at —
26
+ // the docked tree by default, or one float when the user navigates
27
+ // there via the FloatsSheet. Drawer derivation runs on the same root,
28
+ // so a float carrying role-tagged sidebar slots gets the same drawer
29
+ // chrome the docked tree gets.
30
+ const bodyRoot = $derived(resolveCompactBodyRoot());
31
+ const rendering = $derived(derive(bodyRoot));
26
32
 
27
33
  const anchors: DrawerAnchor[] = ['left', 'right', 'top'];
28
34
  </script>
@@ -81,9 +81,10 @@ describe('CompactRenderer — carousels', () => {
81
81
  initialLayout: {
82
82
  type: 'tabs',
83
83
  activeTab: 0,
84
+ role: 'body',
84
85
  tabs: [
85
- { slotId: 's0', viewId: null, label: 'A', role: 'body' },
86
- { slotId: 's1', viewId: null, label: 'B', role: 'body' },
86
+ { slotId: 's0', viewId: null, label: 'A' },
87
+ { slotId: 's1', viewId: null, label: 'B' },
87
88
  ],
88
89
  },
89
90
  };
@@ -107,7 +108,8 @@ describe('CompactRenderer — carousels', () => {
107
108
  {
108
109
  type: 'tabs',
109
110
  activeTab: 0,
110
- tabs: [{ slotId: 'l0', viewId: null, label: 'L', role: 'body' }],
111
+ role: 'body',
112
+ tabs: [{ slotId: 'l0', viewId: null, label: 'L' }],
111
113
  },
112
114
  { type: 'slot', slotId: 'r', viewId: null, role: 'body' },
113
115
  ],
@@ -11,7 +11,7 @@
11
11
  * locks that anchor — inner splits don't retag (a vertical split
12
12
  * inside a left-anchored subtree keeps both children on the left).
13
13
  *
14
- * Note: this transform reads slot.role / tab.role only. View-level
14
+ * Note: this transform reads slot.role / tabs.role only. View-level
15
15
  * defaultRole resolution happens at the call site via resolveRole(),
16
16
  * which materializes a tree with effective roles before passing to
17
17
  * derive(). See layout/compact/CompactRenderer.svelte.
@@ -36,7 +36,7 @@ function collectSlots(node) {
36
36
  viewId: t.viewId,
37
37
  label: t.label,
38
38
  icon: t.icon,
39
- role: effectiveRole(t.role),
39
+ role: effectiveRole(node.role),
40
40
  }));
41
41
  }
42
42
  return node.children.flatMap(collectSlots);
@@ -49,16 +49,7 @@ function stripNonBody(node) {
49
49
  return effectiveRole(node.role) === 'body' ? node : null;
50
50
  }
51
51
  if (node.type === 'tabs') {
52
- const bodyTabs = node.tabs.filter((t) => effectiveRole(t.role) === 'body');
53
- if (bodyTabs.length === 0)
54
- return null;
55
- if (bodyTabs.length === node.tabs.length)
56
- return node;
57
- return {
58
- type: 'tabs',
59
- activeTab: Math.min(node.activeTab, bodyTabs.length - 1),
60
- tabs: bodyTabs,
61
- };
52
+ return effectiveRole(node.role) === 'body' ? node : null;
62
53
  }
63
54
  // split
64
55
  const survivors = [];
@@ -95,11 +86,11 @@ function partitionDrawers(node) {
95
86
  return;
96
87
  }
97
88
  if (n.type === 'tabs') {
89
+ const role = effectiveRole(n.role);
90
+ if (role === 'body')
91
+ return;
92
+ const anchor = hint !== null && hint !== void 0 ? hint : defaultAnchor(role);
98
93
  for (const t of n.tabs) {
99
- const role = effectiveRole(t.role);
100
- if (role === 'body')
101
- continue;
102
- const anchor = hint !== null && hint !== void 0 ? hint : defaultAnchor(role);
103
94
  buckets[anchor].push({
104
95
  slotId: t.slotId, viewId: t.viewId, label: t.label, icon: t.icon, role,
105
96
  });
@@ -128,26 +128,26 @@ describe('derive', () => {
128
128
  describe('tabs nodes', () => {
129
129
  it('tabs of body slots stay in body root', () => {
130
130
  const tree = {
131
- type: 'tabs', activeTab: 0,
131
+ type: 'tabs', activeTab: 0, role: 'body',
132
132
  tabs: [
133
- { slotId: 't1', viewId: 'v:t1', label: 'Tab 1', role: 'body' },
134
- { slotId: 't2', viewId: 'v:t2', label: 'Tab 2', role: 'body' },
133
+ { slotId: 't1', viewId: 'v:t1', label: 'Tab 1' },
134
+ { slotId: 't2', viewId: 'v:t2', label: 'Tab 2' },
135
135
  ],
136
136
  };
137
137
  const result = derive(tree);
138
138
  expect(result.bodyRoot.type).toBe('tabs');
139
139
  });
140
- it('tabs with mixed roles split body tabs from sidebar slots', () => {
140
+ it('body tabs node alongside sidebar slot stays in body root', () => {
141
141
  var _a;
142
142
  const tree = {
143
143
  type: 'split', direction: 'horizontal', sizes: [0.2, 0.8],
144
144
  children: [
145
145
  { type: 'slot', slotId: 'sb', viewId: 'v:sb', role: 'sidebar' },
146
146
  {
147
- type: 'tabs', activeTab: 0,
147
+ type: 'tabs', activeTab: 0, role: 'body',
148
148
  tabs: [
149
- { slotId: 't1', viewId: 'v:t1', label: 'Tab 1', role: 'body' },
150
- { slotId: 't2', viewId: 'v:t2', label: 'Tab 2', role: 'body' },
149
+ { slotId: 't1', viewId: 'v:t1', label: 'Tab 1' },
150
+ { slotId: 't2', viewId: 'v:t2', label: 'Tab 2' },
151
151
  ],
152
152
  },
153
153
  ],
@@ -156,13 +156,33 @@ describe('derive', () => {
156
156
  expect(result.bodyRoot.type).toBe('tabs');
157
157
  expect((_a = result.drawers.left) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['sb']);
158
158
  });
159
+ it('sidebar-tagged tabs node lifts all tabs into a drawer', () => {
160
+ var _a;
161
+ const tree = {
162
+ type: 'split', direction: 'horizontal', sizes: [0.3, 0.7],
163
+ children: [
164
+ {
165
+ type: 'tabs', activeTab: 0, role: 'sidebar',
166
+ tabs: [
167
+ { slotId: 'sb-1', viewId: 'v:sb-1', label: 'Files' },
168
+ { slotId: 'sb-2', viewId: 'v:sb-2', label: 'Search' },
169
+ ],
170
+ },
171
+ { type: 'slot', slotId: 'body', viewId: 'v:body', role: 'body' },
172
+ ],
173
+ };
174
+ const result = derive(tree);
175
+ expect((_a = result.drawers.left) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['sb-1', 'sb-2']);
176
+ expect(result.bodyRoot.slotId).toBe('body');
177
+ });
159
178
  });
160
179
  describe('carousels', () => {
161
180
  it('includes a carousels Map in the output', () => {
162
181
  var _a;
163
182
  const tree = {
164
183
  type: 'tabs',
165
- tabs: [{ slotId: 't0', viewId: null, label: 'Only', role: 'body' }],
184
+ role: 'body',
185
+ tabs: [{ slotId: 't0', viewId: null, label: 'Only' }],
166
186
  activeTab: 0,
167
187
  };
168
188
  const out = derive(tree);
@@ -184,7 +204,8 @@ describe('derive', () => {
184
204
  { type: 'slot', slotId: 'sb', viewId: null, role: 'sidebar' },
185
205
  {
186
206
  type: 'tabs',
187
- tabs: [{ slotId: 't0', viewId: null, label: 'Body Tab', role: 'body' }],
207
+ role: 'body',
208
+ tabs: [{ slotId: 't0', viewId: null, label: 'Body Tab' }],
188
209
  activeTab: 0,
189
210
  },
190
211
  ],
@@ -0,0 +1,20 @@
1
+ import type { LayoutNode } from '../types';
2
+ export type CompactRoot = {
3
+ kind: 'docked';
4
+ } | {
5
+ kind: 'float';
6
+ floatId: string;
7
+ };
8
+ export declare const compactRootStore: {
9
+ readonly current: CompactRoot;
10
+ setRoot(r: CompactRoot): void;
11
+ reset(): void;
12
+ };
13
+ /**
14
+ * Resolve the LayoutNode the compact body should render. Returns
15
+ * `layoutStore.root` when current is docked OR when the referenced float
16
+ * has been removed (self-heal: also resets `current` to docked).
17
+ */
18
+ export declare function resolveCompactBodyRoot(): LayoutNode;
19
+ /** Test-only reset. Not exported from src/index.ts. */
20
+ export declare function __resetCompactRootStoreForTest(): void;
@@ -0,0 +1,59 @@
1
+ /*
2
+ * compactRootStore — selects which LayoutNode the compact body shows.
3
+ *
4
+ * In compact mode the user sees exactly one root at a time: either the
5
+ * docked tree (the "active layout"), or the content of one float. This
6
+ * module is the source of truth for that selection. Desktop never reads it.
7
+ *
8
+ * Reset triggers (called from existing modules):
9
+ * - bindFloatStore (active tree changed)
10
+ * - closeFloat(id) when current.floatId === id
11
+ * - resolveCompactBodyRoot self-heal when the referenced float is gone
12
+ *
13
+ * Set triggers:
14
+ * - floatManager.open in compact + non-dismissable
15
+ * - focusView / focusTab in compact when the target lives in a float
16
+ * - FloatsSheet row tap
17
+ */
18
+ import { layoutStore } from '../store.svelte';
19
+ let current = $state({ kind: 'docked' });
20
+ export const compactRootStore = {
21
+ get current() {
22
+ return current;
23
+ },
24
+ setRoot(r) {
25
+ if (r.kind === 'float') {
26
+ const exists = layoutStore.tree.floats.some((f) => f.id === r.floatId);
27
+ if (!exists) {
28
+ throw new Error(`compactRootStore.setRoot: float id "${r.floatId}" is not in the active tree`);
29
+ }
30
+ }
31
+ current = r;
32
+ },
33
+ reset() {
34
+ current = { kind: 'docked' };
35
+ },
36
+ };
37
+ /**
38
+ * Resolve the LayoutNode the compact body should render. Returns
39
+ * `layoutStore.root` when current is docked OR when the referenced float
40
+ * has been removed (self-heal: also resets `current` to docked).
41
+ */
42
+ export function resolveCompactBodyRoot() {
43
+ // Snapshot locally — narrowing on a module-level $state binding is lost
44
+ // across function calls because the narrowed-out branch could in theory
45
+ // reassign before the next read.
46
+ const cur = current;
47
+ if (cur.kind === 'docked')
48
+ return layoutStore.root;
49
+ const entry = layoutStore.tree.floats.find((f) => f.id === cur.floatId);
50
+ if (!entry) {
51
+ current = { kind: 'docked' };
52
+ return layoutStore.root;
53
+ }
54
+ return entry.content;
55
+ }
56
+ /** Test-only reset. Not exported from src/index.ts. */
57
+ export function __resetCompactRootStoreForTest() {
58
+ current = { kind: 'docked' };
59
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { compactRootStore, resolveCompactBodyRoot, __resetCompactRootStoreForTest, } from './rootStore.svelte';
3
+ import { __resetLayoutStoreForTest, layoutStore, } from '../store.svelte';
4
+ function makeFloat(id, viewId = 'v') {
5
+ return {
6
+ id,
7
+ content: { type: 'slot', slotId: `slot-${id}`, viewId },
8
+ position: { x: 0, y: 0 },
9
+ size: { w: 200, h: 200 },
10
+ };
11
+ }
12
+ describe('compactRootStore', () => {
13
+ beforeEach(() => {
14
+ __resetLayoutStoreForTest();
15
+ __resetCompactRootStoreForTest();
16
+ });
17
+ it('starts at { kind: "docked" }', () => {
18
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
19
+ });
20
+ it('setRoot accepts a float id present in the active tree', () => {
21
+ const f = makeFloat('f-1');
22
+ layoutStore.tree.floats.push(f);
23
+ compactRootStore.setRoot({ kind: 'float', floatId: 'f-1' });
24
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: 'f-1' });
25
+ });
26
+ it('setRoot throws when the float id is not in the active tree', () => {
27
+ expect(() => compactRootStore.setRoot({ kind: 'float', floatId: 'missing' })).toThrow(/missing/);
28
+ });
29
+ it('reset returns to docked', () => {
30
+ layoutStore.tree.floats.push(makeFloat('f-2'));
31
+ compactRootStore.setRoot({ kind: 'float', floatId: 'f-2' });
32
+ compactRootStore.reset();
33
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
34
+ });
35
+ it('resolveCompactBodyRoot returns docked content when current is docked', () => {
36
+ expect(resolveCompactBodyRoot()).toEqual(layoutStore.tree.docked);
37
+ });
38
+ it('resolveCompactBodyRoot returns the float content when current points at it', () => {
39
+ const f = makeFloat('f-3');
40
+ layoutStore.tree.floats.push(f);
41
+ compactRootStore.setRoot({ kind: 'float', floatId: 'f-3' });
42
+ // Workspace-zone reactivity proxies the pushed object, so identity
43
+ // (`toBe`) does not hold; the structural content is what we care about.
44
+ expect(resolveCompactBodyRoot()).toEqual(f.content);
45
+ });
46
+ it('resolveCompactBodyRoot self-heals on stale id (returns docked + resets)', () => {
47
+ const f = makeFloat('f-4');
48
+ layoutStore.tree.floats.push(f);
49
+ compactRootStore.setRoot({ kind: 'float', floatId: 'f-4' });
50
+ layoutStore.tree.floats.length = 0;
51
+ expect(resolveCompactBodyRoot()).toEqual(layoutStore.tree.docked);
52
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
53
+ });
54
+ });
@@ -44,7 +44,7 @@ import { cleanupTree, insertTabIntoTabs, moveTabWithinTabs, removeTabBySlotId, s
44
44
  import { layoutStore } from './store.svelte';
45
45
  import { isEmptyContent } from './floats';
46
46
  import { claim, revoke } from '../gestures/pointerClaim';
47
- import { ancestorCount } from '../gestures';
47
+ import { ancestorCount, logGesture } from '../gestures';
48
48
  export const dragState = $state({
49
49
  phase: 'idle',
50
50
  source: null,
@@ -116,6 +116,8 @@ function removeGlobalListeners() {
116
116
  delete document.body.dataset.dragging;
117
117
  }
118
118
  function onPointerMove(e) {
119
+ if (e.pointerId !== activeDragPointerId)
120
+ return;
119
121
  dragState.pointerX = e.clientX;
120
122
  dragState.pointerY = e.clientY;
121
123
  if (dragState.phase === 'pending') {
@@ -126,7 +128,9 @@ function onPointerMove(e) {
126
128
  }
127
129
  }
128
130
  }
129
- function onPointerUp(_e) {
131
+ function onPointerUp(e) {
132
+ if (e.pointerId !== activeDragPointerId)
133
+ return;
130
134
  const wasDragging = dragState.phase === 'dragging';
131
135
  if (wasDragging) {
132
136
  commit();
@@ -137,7 +141,16 @@ function onPointerUp(_e) {
137
141
  }
138
142
  teardown();
139
143
  }
140
- function onPointerCancel(_e) {
144
+ function onPointerCancel(e) {
145
+ // Filter by pointer id. Without this, any pointercancel on the window
146
+ // (palm contact, ghost touch, stylus cancellation while a finger drag
147
+ // is active) would tear down a legitimate tab drag — same touch-only
148
+ // auto-release class as in carousel / floatframe / splitter.
149
+ if (e.pointerId !== activeDragPointerId) {
150
+ logGesture('tabdrag:cancel-other-id', e, { activeDragPointerId });
151
+ return;
152
+ }
153
+ logGesture('tabdrag:cancel-our-id', e, { activeDragPointerId });
141
154
  teardown();
142
155
  }
143
156
  function rootNode(ref) {