sh3-core 0.11.6 → 0.11.7

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 (56) hide show
  1. package/dist/actions/ActionPanel.svelte +49 -11
  2. package/dist/actions/ActionPanel.test.js +94 -6
  3. package/dist/actions/MenuButton.svelte +60 -14
  4. package/dist/actions/MenuButton.svelte.d.ts +3 -2
  5. package/dist/actions/MenuButton.test.js +38 -1
  6. package/dist/actions/contextMenuModel.d.ts +10 -0
  7. package/dist/actions/contextMenuModel.js +44 -9
  8. package/dist/actions/contextMenuModel.test.js +28 -1
  9. package/dist/actions/listeners.d.ts +4 -0
  10. package/dist/actions/listeners.js +77 -17
  11. package/dist/actions/listeners.test.js +50 -0
  12. package/dist/actions/menuBarModel.d.ts +14 -0
  13. package/dist/actions/menuBarModel.js +43 -0
  14. package/dist/actions/menuBarModel.test.js +75 -1
  15. package/dist/actions/palette-scorer.d.ts +4 -0
  16. package/dist/actions/palette-scorer.js +5 -0
  17. package/dist/actions/palette-scorer.test.js +9 -1
  18. package/dist/actions/paletteModel.d.ts +7 -1
  19. package/dist/actions/paletteModel.js +26 -1
  20. package/dist/actions/paletteModel.test.js +43 -0
  21. package/dist/actions/registry.js +5 -0
  22. package/dist/actions/registry.test.js +12 -0
  23. package/dist/actions/types.d.ts +40 -1
  24. package/dist/actions/types.test.d.ts +1 -0
  25. package/dist/actions/types.test.js +31 -0
  26. package/dist/assets/icons.svg +5 -0
  27. package/dist/documents/backends.d.ts +2 -0
  28. package/dist/documents/backends.js +55 -0
  29. package/dist/documents/backends.test.d.ts +1 -1
  30. package/dist/documents/backends.test.js +69 -1
  31. package/dist/documents/browse.d.ts +18 -0
  32. package/dist/documents/browse.js +13 -0
  33. package/dist/documents/browse.test.js +47 -0
  34. package/dist/documents/handle.js +23 -0
  35. package/dist/documents/handle.test.js +51 -0
  36. package/dist/documents/http-backend.d.ts +1 -0
  37. package/dist/documents/http-backend.js +19 -0
  38. package/dist/documents/http-backend.test.js +42 -0
  39. package/dist/documents/types.d.ts +29 -1
  40. package/dist/documents/types.js +4 -0
  41. package/dist/documents/types.test.d.ts +1 -0
  42. package/dist/documents/types.test.js +20 -0
  43. package/dist/layout/LayoutRenderer.browser.test.js +196 -0
  44. package/dist/layout/SlotContainer.svelte +13 -8
  45. package/dist/layout/SlotDropZone.svelte +44 -9
  46. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-7-fixed-slot-drop-protection-still-accepts-a-strip-drop-into-a-fixed-tabs-node-1.png +0 -0
  47. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-8-same-strip-reorder-keeps-the-active-pane-populated-after-moving-the-second-tab-to-first-1.png +0 -0
  48. package/dist/layout/ops.d.ts +10 -0
  49. package/dist/layout/ops.js +30 -2
  50. package/dist/layout/ops.test.js +111 -1
  51. package/dist/layout/slotHostPool.svelte.d.ts +7 -1
  52. package/dist/layout/slotHostPool.svelte.js +27 -8
  53. package/dist/sh3core-shard/sh3coreShard.svelte.js +18 -4
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/package.json +2 -1
@@ -350,3 +350,199 @@ describe('LayoutRenderer browser — E.6 fixed slots', () => {
350
350
  expect(document.querySelector('[data-testid="collapse-toggle-2"]')).toBeNull();
351
351
  });
352
352
  });
353
+ // ---------------------------------------------------------------------------
354
+ // E.7 — fixed positions reject quadrant drops, allow strip drops
355
+ // ---------------------------------------------------------------------------
356
+ describe('LayoutRenderer browser — E.7 fixed-slot drop protection', () => {
357
+ beforeEach(() => { cleanupDOM(); resetFramework(); });
358
+ function mk(x, y, type, buttons = 1) {
359
+ return new PointerEvent(type, {
360
+ bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse',
361
+ clientX: x, clientY: y, screenX: x, screenY: y, buttons, button: 0,
362
+ });
363
+ }
364
+ it('rejects a quadrant drop onto a fixed slot body', async () => {
365
+ stubView();
366
+ registerApp(makeApp({
367
+ manifest: makeAppManifest({ id: 'e7a' }),
368
+ initialLayout: [
369
+ {
370
+ name: 'default',
371
+ tree: makeTree(makeSplitNode([
372
+ makeTabsNode([makeTabEntry({ slotId: 'src' })]),
373
+ makeSlotNode('fixedSlot'),
374
+ ], { fixed: [false, true] })),
375
+ },
376
+ ],
377
+ }));
378
+ await launchApp('e7a');
379
+ renderWithShell(LayoutRenderer, { path: [] });
380
+ await settle(30);
381
+ const beforeRoot = JSON.stringify(layoutStore.root);
382
+ const srcTab = document.querySelector('[role="tab"]');
383
+ if (!srcTab)
384
+ throw new Error('no source tab');
385
+ const fixedHost = document.querySelector('[data-slot-id="fixedSlot"]');
386
+ if (!fixedHost)
387
+ throw new Error('no fixed slot host');
388
+ const fixedRect = fixedHost.getBoundingClientRect();
389
+ const fx = fixedRect.left + fixedRect.width / 2;
390
+ const fy = fixedRect.top + fixedRect.height / 2;
391
+ const sr = srcTab.getBoundingClientRect();
392
+ const sx = sr.left + sr.width / 2;
393
+ const sy = sr.top + sr.height / 2;
394
+ srcTab.dispatchEvent(mk(sx, sy, 'pointerdown'));
395
+ window.dispatchEvent(mk(sx + 6, sy, 'pointermove'));
396
+ window.dispatchEvent(mk(fx, fy, 'pointermove'));
397
+ window.dispatchEvent(mk(fx, fy, 'pointerup', 0));
398
+ await settle();
399
+ expect(JSON.stringify(layoutStore.root)).toBe(beforeRoot);
400
+ });
401
+ it('still accepts a strip drop into a fixed tabs node', async () => {
402
+ stubView();
403
+ registerApp(makeApp({
404
+ manifest: makeAppManifest({ id: 'e7b' }),
405
+ initialLayout: [
406
+ {
407
+ name: 'default',
408
+ tree: makeTree(makeSplitNode([
409
+ makeTabsNode([makeTabEntry({ slotId: 'src' })]),
410
+ makeTabsNode([makeTabEntry({ slotId: 'dst' })]),
411
+ ], { fixed: [false, true] })),
412
+ },
413
+ ],
414
+ }));
415
+ await launchApp('e7b');
416
+ renderWithShell(LayoutRenderer, { path: [] });
417
+ await settle(30);
418
+ const tabs = document.querySelectorAll('[role="tab"]');
419
+ const strips = document.querySelectorAll('[role="tablist"]');
420
+ if (tabs.length < 2 || strips.length < 2)
421
+ throw new Error('layout did not render');
422
+ const src = tabs[0];
423
+ const dstStrip = strips[1];
424
+ const sr = src.getBoundingClientRect();
425
+ const sx = sr.left + sr.width / 2;
426
+ const sy = sr.top + sr.height / 2;
427
+ const tr = dstStrip.getBoundingClientRect();
428
+ const tx = tr.left + tr.width / 2;
429
+ const ty = tr.top + tr.height / 2;
430
+ src.dispatchEvent(mk(sx, sy, 'pointerdown'));
431
+ window.dispatchEvent(mk(sx + 6, sy, 'pointermove'));
432
+ dstStrip.dispatchEvent(mk(tx, ty, 'pointermove'));
433
+ window.dispatchEvent(mk(tx, ty, 'pointerup', 0));
434
+ await settle();
435
+ // After the strip drop, the source (left) tabs node empties and is
436
+ // pruned (it's non-fixed). The split collapses to its sole surviving
437
+ // child — the previously-fixed tabs node — which is now root.
438
+ const root = layoutStore.root;
439
+ expect(root === null || root === void 0 ? void 0 : root.type).toBe('tabs');
440
+ if ((root === null || root === void 0 ? void 0 : root.type) === 'tabs') {
441
+ const slotIds = root.tabs.map((t) => t.slotId).sort();
442
+ expect(slotIds).toEqual(['dst', 'src']);
443
+ }
444
+ });
445
+ it('preserves a fixed tabs node when its last tab is dragged out', async () => {
446
+ stubView();
447
+ registerApp(makeApp({
448
+ manifest: makeAppManifest({ id: 'e7c' }),
449
+ initialLayout: [
450
+ {
451
+ name: 'default',
452
+ tree: makeTree(makeSplitNode([
453
+ makeTabsNode([makeTabEntry({ slotId: 'sink' })]),
454
+ makeTabsNode([makeTabEntry({ slotId: 'lonely' })]),
455
+ ], { fixed: [false, true] })),
456
+ },
457
+ ],
458
+ }));
459
+ await launchApp('e7c');
460
+ renderWithShell(LayoutRenderer, { path: [] });
461
+ await settle(30);
462
+ const tabs = document.querySelectorAll('[role="tab"]');
463
+ const strips = document.querySelectorAll('[role="tablist"]');
464
+ if (tabs.length < 2)
465
+ throw new Error('layout did not render');
466
+ const lonely = Array.from(tabs).find((t) => { var _a; return (_a = t.textContent) === null || _a === void 0 ? void 0 : _a.includes('lonely'); });
467
+ if (!lonely)
468
+ throw new Error('lonely tab not found');
469
+ const sinkStrip = strips[0];
470
+ const sr = lonely.getBoundingClientRect();
471
+ const sx = sr.left + sr.width / 2;
472
+ const sy = sr.top + sr.height / 2;
473
+ const tr = sinkStrip.getBoundingClientRect();
474
+ const tx = tr.left + tr.width / 2;
475
+ const ty = tr.top + tr.height / 2;
476
+ lonely.dispatchEvent(mk(sx, sy, 'pointerdown'));
477
+ window.dispatchEvent(mk(sx + 6, sy, 'pointermove'));
478
+ sinkStrip.dispatchEvent(mk(tx, ty, 'pointermove'));
479
+ window.dispatchEvent(mk(tx, ty, 'pointerup', 0));
480
+ await settle();
481
+ const root = layoutStore.root;
482
+ expect(root === null || root === void 0 ? void 0 : root.type).toBe('split');
483
+ if ((root === null || root === void 0 ? void 0 : root.type) === 'split') {
484
+ expect(root.children).toHaveLength(2);
485
+ const fixed = root.children[1];
486
+ expect(fixed.type).toBe('tabs');
487
+ if (fixed.type === 'tabs')
488
+ expect(fixed.tabs).toHaveLength(0);
489
+ expect(root.fixed).toEqual([false, true]);
490
+ }
491
+ });
492
+ });
493
+ // ---------------------------------------------------------------------------
494
+ // E.8 — same-strip tab reorder preserves mounted views
495
+ // ---------------------------------------------------------------------------
496
+ describe('LayoutRenderer browser — E.8 same-strip reorder', () => {
497
+ beforeEach(() => { cleanupDOM(); resetFramework(); });
498
+ it('keeps the active pane populated after moving the second tab to first', async () => {
499
+ var _a, _b, _c, _d;
500
+ // A view that tags its host with a unique marker so we can verify which
501
+ // slot's host ends up where. The marker survives re-parent because the
502
+ // pool moves the host element rather than recreating it.
503
+ registerView('marker:view', {
504
+ mount: (el) => {
505
+ var _a;
506
+ const tag = document.createElement('span');
507
+ tag.className = 'view-marker';
508
+ tag.textContent = 'M:' + ((_a = el.dataset.slotId) !== null && _a !== void 0 ? _a : '?');
509
+ el.appendChild(tag);
510
+ return { unmount: () => tag.remove() };
511
+ },
512
+ });
513
+ registerApp(makeApp({
514
+ manifest: makeAppManifest({ id: 'e8' }),
515
+ initialLayout: [
516
+ {
517
+ name: 'default',
518
+ tree: makeTree(makeTabsNode([
519
+ makeTabEntry({ slotId: 'A', label: 'A', viewId: 'marker:view' }),
520
+ makeTabEntry({ slotId: 'B', label: 'B', viewId: 'marker:view' }),
521
+ ])),
522
+ },
523
+ ],
524
+ }));
525
+ await launchApp('e8');
526
+ renderWithShell(LayoutRenderer, { path: [] });
527
+ await settle(50);
528
+ // Sanity: both panes have their respective markers.
529
+ const beforePanes = document.querySelectorAll('.tab-body-pane');
530
+ expect(beforePanes.length).toBe(2);
531
+ expect((_a = beforePanes[0].querySelector('.view-marker')) === null || _a === void 0 ? void 0 : _a.textContent).toBe('M:A');
532
+ expect((_b = beforePanes[1].querySelector('.view-marker')) === null || _b === void 0 ? void 0 : _b.textContent).toBe('M:B');
533
+ // Programmatic same-strip reorder: move tab at index 1 to position 0.
534
+ // This is the exact mutation the drag commit applies on a same-strip drop.
535
+ const ops = await import('./ops');
536
+ const root = layoutStore.root;
537
+ if ((root === null || root === void 0 ? void 0 : root.type) !== 'tabs')
538
+ throw new Error('expected tabs root');
539
+ ops.moveTabWithinTabs(root, 1, 0);
540
+ await settle(50);
541
+ // After the reorder: tabs are [B, A], activeTab=0. Pane 0 is the visible
542
+ // one. Both panes must still hold a mounted slot-host with the right view.
543
+ const afterPanes = document.querySelectorAll('.tab-body-pane');
544
+ expect(afterPanes.length).toBe(2);
545
+ expect((_c = afterPanes[0].querySelector('.view-marker')) === null || _c === void 0 ? void 0 : _c.textContent).toBe('M:B');
546
+ expect((_d = afterPanes[1].querySelector('.view-marker')) === null || _d === void 0 ? void 0 : _d.textContent).toBe('M:A');
547
+ });
548
+ });
@@ -51,14 +51,19 @@
51
51
  $effect(() => {
52
52
  if (!wrapper) return;
53
53
 
54
- // Capture slotId at effect-run time so the cleanup closure releases the
55
- // correct slot even when node.slotId has already changed to a new value
56
- // by the time Svelte calls the cleanup (which happens after reactive
57
- // state has been updated). Without this capture, cleanup would call
58
- // releaseSlotHost with the NEW slotId instead of the old one.
54
+ // Capture slotId AND wrapper at effect-run time so the cleanup closure
55
+ // releases the correct slot from the correct wrapper. Both can change
56
+ // before cleanup fires:
57
+ // - node.slotId reads the new value (Svelte updates state before
58
+ // running the effect's cleanup), so we'd release the wrong slot.
59
+ // - the wrapper binding can be re-pointed by Svelte if the component
60
+ // is reused. Capturing locks the release to the wrapper this
61
+ // effect actually appended into — important for the pool's "only
62
+ // detach if still in our wrapper" guard.
59
63
  const currentSlotId = node.slotId;
64
+ const wrapperEl = wrapper;
60
65
  const host = acquireSlotHost(currentSlotId, node.viewId, label || node.viewId || currentSlotId);
61
- wrapper.appendChild(host);
66
+ wrapperEl.appendChild(host);
62
67
 
63
68
  // Local observer exists only to drive the placeholder's dims text;
64
69
  // the view's own onResize is delivered by the pool.
@@ -69,11 +74,11 @@
69
74
  height = Math.round(box.height);
70
75
  }
71
76
  });
72
- ro.observe(wrapper);
77
+ ro.observe(wrapperEl);
73
78
 
74
79
  return () => {
75
80
  ro.disconnect();
76
- releaseSlotHost(currentSlotId);
81
+ releaseSlotHost(currentSlotId, wrapperEl);
77
82
  };
78
83
  });
79
84
  </script>
@@ -17,11 +17,20 @@
17
17
  * we don't support "merge into same tabs group" via body drop,
18
18
  * only via strip drop. This keeps the UX unambiguous: body = split,
19
19
  * strip = merge.
20
+ *
21
+ * Fixed positions:
22
+ * When the SplitNode parent of this zone's path has fixed=true at the
23
+ * relevant index, the zone reports no targets and renders no highlight or
24
+ * hit boxes. The strip drop is computed elsewhere (LayoutRenderer
25
+ * .onStripHover) and is not affected by this guard. See
26
+ * docs/superpowers/specs/2026-04-28-fixed-slot-drop-protection-design.md.
20
27
  */
21
28
 
22
29
  import { dragState, setDropTarget, clearDropTarget, type DropTarget } from './drag.svelte';
23
30
  import type { LayoutPath, SplitSide } from './ops';
24
- import type { TreeRootRef } from './types';
31
+ import { isPathFixedByParent } from './ops';
32
+ import type { LayoutNode, TreeRootRef } from './types';
33
+ import { layoutStore } from './store.svelte';
25
34
 
26
35
  let {
27
36
  rootRef,
@@ -39,6 +48,22 @@
39
48
  // the zone would shadow the slot's own interactions.
40
49
  const active = $derived(dragState.phase === 'dragging');
41
50
 
51
+ /**
52
+ * True when the parent split of this zone's path has fixed=true at the
53
+ * relevant index. While true, the zone does not report split targets and
54
+ * does not render the quadrant highlight or hit boxes.
55
+ */
56
+ const parentFixed = $derived.by(() => {
57
+ let root: LayoutNode | null;
58
+ if (rootRef.kind === 'docked') {
59
+ root = layoutStore.root;
60
+ } else {
61
+ const entry = layoutStore.tree.floats.find((f) => f.id === rootRef.floatId);
62
+ root = entry?.content ?? null;
63
+ }
64
+ return root ? isPathFixedByParent(root, path) : false;
65
+ });
66
+
42
67
  function quadrantFor(x: number, y: number, rect: DOMRect): SplitSide {
43
68
  const cx = rect.left + rect.width / 2;
44
69
  const cy = rect.top + rect.height / 2;
@@ -57,6 +82,13 @@
57
82
 
58
83
  function onMove(e: PointerEvent) {
59
84
  if (!zoneEl) return;
85
+ if (parentFixed) {
86
+ if (hoveredSide !== null) {
87
+ hoveredSide = null;
88
+ clearDropTarget((t) => t.kind === 'split' && t.path.join('/') === path.join('/'));
89
+ }
90
+ return;
91
+ }
60
92
  const rect = zoneEl.getBoundingClientRect();
61
93
  // If pointer is outside the zone (pointercapture from elsewhere),
62
94
  // clear.
@@ -92,18 +124,21 @@
92
124
  <div
93
125
  class="slot-drop-zone"
94
126
  class:active
127
+ class:locked={parentFixed}
95
128
  bind:this={zoneEl}
96
129
  onpointermove={onMove}
97
130
  onpointerleave={onLeave}
98
131
  >
99
- {#if hoveredSide}
100
- <div class="quad-highlight quad-{hoveredSide}"></div>
101
- {/if}
102
- {#if active}
103
- <div class="quad-target quad-left" data-testid="drop-zone-left"></div>
104
- <div class="quad-target quad-right" data-testid="drop-zone-right"></div>
105
- <div class="quad-target quad-top" data-testid="drop-zone-top"></div>
106
- <div class="quad-target quad-bottom" data-testid="drop-zone-bottom"></div>
132
+ {#if !parentFixed}
133
+ {#if hoveredSide}
134
+ <div class="quad-highlight quad-{hoveredSide}"></div>
135
+ {/if}
136
+ {#if active}
137
+ <div class="quad-target quad-left" data-testid="drop-zone-left"></div>
138
+ <div class="quad-target quad-right" data-testid="drop-zone-right"></div>
139
+ <div class="quad-target quad-top" data-testid="drop-zone-top"></div>
140
+ <div class="quad-target quad-bottom" data-testid="drop-zone-bottom"></div>
141
+ {/if}
107
142
  {/if}
108
143
  </div>
109
144
 
@@ -108,6 +108,16 @@ export declare function makeSplitWithNewTab(existing: LayoutNode, entry: TabEntr
108
108
  export declare function splitNodeAtPath(root: LayoutNode, path: LayoutPath, entry: TabEntry, side: SplitSide): void;
109
109
  /** Walk a LayoutPath and return the node at its tip, or null. */
110
110
  export declare function nodeAtPath(root: LayoutNode, path: LayoutPath): LayoutNode | null;
111
+ /**
112
+ * True when the parent split of `path` exists and has `fixed[lastIndex] === true`.
113
+ *
114
+ * The drag layer and the ops layer both consult this to decide whether a
115
+ * quadrant-split drop may land at `path`. A `path.length === 0` always returns
116
+ * false: the root has no parent split. If the path leads through a non-split
117
+ * (tabs/slot) ancestor, no parent split exists at the relevant slice and the
118
+ * function returns false.
119
+ */
120
+ export declare function isPathFixedByParent(root: LayoutNode, path: LayoutPath): boolean;
111
121
  /**
112
122
  * Post-mutation cleanup pass. Removes empty tabs groups from their
113
123
  * parents and collapses single-child splits. Runs until the tree
@@ -250,6 +250,8 @@ export function splitNodeAtPath(root, path, entry, side) {
250
250
  const target = nodeAtPath(root, path);
251
251
  if (!target)
252
252
  return;
253
+ if (isPathFixedByParent(root, path))
254
+ return;
253
255
  if (path.length === 0) {
254
256
  // Root case: target IS root. Snapshot it so makeSplitWithNewTab
255
257
  // embeds the clone, not root itself — avoids a circular reference
@@ -289,6 +291,24 @@ export function nodeAtPath(root, path) {
289
291
  }
290
292
  return cur;
291
293
  }
294
+ /**
295
+ * True when the parent split of `path` exists and has `fixed[lastIndex] === true`.
296
+ *
297
+ * The drag layer and the ops layer both consult this to decide whether a
298
+ * quadrant-split drop may land at `path`. A `path.length === 0` always returns
299
+ * false: the root has no parent split. If the path leads through a non-split
300
+ * (tabs/slot) ancestor, no parent split exists at the relevant slice and the
301
+ * function returns false.
302
+ */
303
+ export function isPathFixedByParent(root, path) {
304
+ var _a;
305
+ if (path.length === 0)
306
+ return false;
307
+ const parent = nodeAtPath(root, path.slice(0, -1));
308
+ if (!parent || parent.type !== 'split')
309
+ return false;
310
+ return ((_a = parent.fixed) === null || _a === void 0 ? void 0 : _a[path[path.length - 1]]) === true;
311
+ }
292
312
  // ---------- Cleanup --------------------------------------------------------
293
313
  /**
294
314
  * Post-mutation cleanup pass. Removes empty tabs groups from their
@@ -307,6 +327,7 @@ export function cleanupTree(root) {
307
327
  }
308
328
  }
309
329
  function cleanupPass(node, parent, indexInParent) {
330
+ var _a;
310
331
  if (node.type === 'split') {
311
332
  // Recurse first so we collapse bottom-up.
312
333
  let recursed = false;
@@ -314,16 +335,23 @@ function cleanupPass(node, parent, indexInParent) {
314
335
  if (cleanupPass(node.children[i], node, i))
315
336
  recursed = true;
316
337
  }
317
- // Drop empty tabs children — unless the tabs node is persistent.
338
+ // Drop empty tabs children — unless the tabs node is persistent OR is
339
+ // fixed in its parent. A fixed child must keep its position in the
340
+ // parent split's children array; pruning would mutate that array.
318
341
  for (let i = node.children.length - 1; i >= 0; i--) {
319
342
  const child = node.children[i];
320
- if (child.type === 'tabs' && child.tabs.length === 0 && !child.persistent) {
343
+ if (child.type === 'tabs'
344
+ && child.tabs.length === 0
345
+ && !child.persistent
346
+ && !((_a = node.fixed) === null || _a === void 0 ? void 0 : _a[i])) {
321
347
  node.children.splice(i, 1);
322
348
  node.sizes.splice(i, 1);
323
349
  if (node.pinned)
324
350
  node.pinned.splice(i, 1);
325
351
  if (node.collapsed)
326
352
  node.collapsed.splice(i, 1);
353
+ if (node.fixed)
354
+ node.fixed.splice(i, 1);
327
355
  recursed = true;
328
356
  }
329
357
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { findTabInTree, splitNodeAtPath, cleanupTree, findTabBySlotId } from './ops';
2
+ import { findTabInTree, splitNodeAtPath, cleanupTree, findTabBySlotId, isPathFixedByParent, } from './ops';
3
+ import { makeSlotNode, makeSplitNode, makeTabsNode, makeTabEntry, } from '../__test__/fixtures';
3
4
  describe('findTabInTree', () => {
4
5
  const tree = {
5
6
  docked: {
@@ -84,3 +85,112 @@ describe('splitNodeAtPath — root case (path = [])', () => {
84
85
  }
85
86
  });
86
87
  });
88
+ describe('isPathFixedByParent', () => {
89
+ it('returns false for root path', () => {
90
+ const root = makeSplitNode([makeSlotNode('a'), makeSlotNode('b')], { fixed: [true, true] });
91
+ expect(isPathFixedByParent(root, [])).toBe(false);
92
+ });
93
+ it('returns false when parent has no fixed array', () => {
94
+ const root = makeSplitNode([makeSlotNode('a'), makeSlotNode('b')]);
95
+ expect(isPathFixedByParent(root, [0])).toBe(false);
96
+ });
97
+ it('returns false when fixed[index] is false or undefined', () => {
98
+ const root = makeSplitNode([makeSlotNode('a'), makeSlotNode('b')], { fixed: [false, false] });
99
+ expect(isPathFixedByParent(root, [0])).toBe(false);
100
+ expect(isPathFixedByParent(root, [1])).toBe(false);
101
+ const sparse = makeSplitNode([makeSlotNode('a'), makeSlotNode('b')], { fixed: [true] });
102
+ expect(isPathFixedByParent(sparse, [1])).toBe(false);
103
+ });
104
+ it('returns true when fixed[index] is true', () => {
105
+ const root = makeSplitNode([makeSlotNode('a'), makeSlotNode('b')], { fixed: [true, false] });
106
+ expect(isPathFixedByParent(root, [0])).toBe(true);
107
+ expect(isPathFixedByParent(root, [1])).toBe(false);
108
+ });
109
+ it('returns true for a fixed grandchild reached through a non-fixed parent', () => {
110
+ const inner = makeSplitNode([makeSlotNode('x'), makeSlotNode('y')], { fixed: [true, false] });
111
+ const root = makeSplitNode([inner, makeSlotNode('b')]);
112
+ expect(isPathFixedByParent(root, [0, 0])).toBe(true);
113
+ expect(isPathFixedByParent(root, [0, 1])).toBe(false);
114
+ });
115
+ it('returns false when the path lands inside a tabs node (parent is not a split)', () => {
116
+ const tabs = makeTabsNode([makeTabEntry({ slotId: 't1' })]);
117
+ const root = makeSplitNode([tabs, makeSlotNode('b')], { fixed: [true, false] });
118
+ expect(isPathFixedByParent(root, [0])).toBe(true);
119
+ // A path beyond a tabs node walks no further (nodeAtPath returns null).
120
+ expect(isPathFixedByParent(root, [0, 0])).toBe(false);
121
+ });
122
+ });
123
+ describe('splitNodeAtPath — fixed-parent guard', () => {
124
+ const newEntry = () => ({ slotId: 'new', viewId: 'v', label: 'New' });
125
+ it('is a no-op when the parent has fixed=true at the target index', () => {
126
+ const tabsA = makeTabsNode([makeTabEntry({ slotId: 'a' })]);
127
+ const tabsB = makeTabsNode([makeTabEntry({ slotId: 'b' })]);
128
+ const root = makeSplitNode([tabsA, tabsB], { fixed: [true, false] });
129
+ const before = JSON.stringify(root);
130
+ splitNodeAtPath(root, [0], newEntry(), 'right');
131
+ splitNodeAtPath(root, [0], newEntry(), 'top');
132
+ expect(JSON.stringify(root)).toBe(before);
133
+ });
134
+ it('still splits at a non-fixed sibling index', () => {
135
+ const tabsA = makeTabsNode([makeTabEntry({ slotId: 'a' })]);
136
+ const tabsB = makeTabsNode([makeTabEntry({ slotId: 'b' })]);
137
+ const root = makeSplitNode([tabsA, tabsB], { fixed: [true, false] });
138
+ splitNodeAtPath(root, [1], newEntry(), 'right');
139
+ expect(root.children[1].type).toBe('split');
140
+ });
141
+ it('still splits a descendant of a fixed sub-split that is not itself fixed', () => {
142
+ const inner = makeSplitNode([makeTabsNode([makeTabEntry({ slotId: 'x' })]), makeTabsNode([makeTabEntry({ slotId: 'y' })])], { fixed: [false, false] });
143
+ const root = makeSplitNode([inner, makeSlotNode('b')], { fixed: [true, false] });
144
+ splitNodeAtPath(root, [0, 0], newEntry(), 'right');
145
+ const after = root.children[0].children[0];
146
+ expect(after.type).toBe('split');
147
+ });
148
+ it('still allows splitting the root (path = []) regardless of any fixed flags', () => {
149
+ const root = makeTabsNode([makeTabEntry({ slotId: 'a' })]);
150
+ splitNodeAtPath(root, [], newEntry(), 'right');
151
+ expect(root.type).toBe('split');
152
+ });
153
+ });
154
+ describe('cleanupTree — fixed empty tabs preservation', () => {
155
+ it('keeps an empty tabs child whose parent has fixed=true at that index', () => {
156
+ const empty = makeTabsNode([]);
157
+ const sibling = makeTabsNode([makeTabEntry({ slotId: 'a' })]);
158
+ const root = makeSplitNode([empty, sibling], { fixed: [true, false] });
159
+ cleanupTree(root);
160
+ expect(root.type).toBe('split');
161
+ if (root.type === 'split') {
162
+ expect(root.children).toHaveLength(2);
163
+ const first = root.children[0];
164
+ expect(first.type).toBe('tabs');
165
+ if (first.type === 'tabs')
166
+ expect(first.tabs).toHaveLength(0);
167
+ }
168
+ });
169
+ it('still prunes an empty tabs child whose parent fixed flag is false', () => {
170
+ const empty = makeTabsNode([]);
171
+ const sibling = makeTabsNode([makeTabEntry({ slotId: 'a' })]);
172
+ const root = makeSplitNode([empty, sibling], { fixed: [false, false] });
173
+ cleanupTree(root);
174
+ expect(root.type).toBe('tabs');
175
+ });
176
+ it('still prunes an empty tabs child when the parent has no fixed array at all', () => {
177
+ const empty = makeTabsNode([]);
178
+ const sibling = makeTabsNode([makeTabEntry({ slotId: 'a' })]);
179
+ const root = makeSplitNode([empty, sibling]);
180
+ cleanupTree(root);
181
+ expect(root.type).toBe('tabs');
182
+ });
183
+ it('preserves the parent fixed array length when a non-fixed empty tabs child IS pruned', () => {
184
+ const empty = makeTabsNode([]);
185
+ const fixedKept = makeTabsNode([makeTabEntry({ slotId: 'k' })]);
186
+ const other = makeTabsNode([makeTabEntry({ slotId: 'o' })]);
187
+ const root = makeSplitNode([empty, fixedKept, other], { fixed: [false, true, false] });
188
+ cleanupTree(root);
189
+ expect(root.type).toBe('split');
190
+ if (root.type === 'split') {
191
+ expect(root.children).toHaveLength(2);
192
+ expect(root.fixed).toEqual([true, false]);
193
+ expect(root.sizes).toHaveLength(2);
194
+ }
195
+ });
196
+ });
@@ -10,8 +10,14 @@ export declare function acquireSlotHost(slotId: string, viewId: string | null, l
10
10
  * Release the pooled host. If this was the last reference, a
11
11
  * destruction is queued to run in a microtask; a later acquire before
12
12
  * that microtask cancels the destroy (the re-parent case).
13
+ *
14
+ * When `fromWrapper` is provided, the host is detached from the DOM only
15
+ * if its current parent is still that wrapper. This guards the same-strip
16
+ * tab reorder scenario (see below) where another caller has already moved
17
+ * the host to a different wrapper before this release fires; an
18
+ * unconditional detach would yank the host out of its new home.
13
19
  */
14
- export declare function releaseSlotHost(slotId: string): void;
20
+ export declare function releaseSlotHost(slotId: string, fromWrapper?: HTMLElement): void;
15
21
  /**
16
22
  * Test / teardown helper — destroys every pooled host immediately. Used
17
23
  * by HMR boundaries and tests; not part of normal runtime flow.
@@ -232,20 +232,39 @@ export function acquireSlotHost(slotId, viewId, label, meta) {
232
232
  * Release the pooled host. If this was the last reference, a
233
233
  * destruction is queued to run in a microtask; a later acquire before
234
234
  * that microtask cancels the destroy (the re-parent case).
235
+ *
236
+ * When `fromWrapper` is provided, the host is detached from the DOM only
237
+ * if its current parent is still that wrapper. This guards the same-strip
238
+ * tab reorder scenario (see below) where another caller has already moved
239
+ * the host to a different wrapper before this release fires; an
240
+ * unconditional detach would yank the host out of its new home.
235
241
  */
236
- export function releaseSlotHost(slotId) {
242
+ export function releaseSlotHost(slotId, fromWrapper) {
237
243
  const entry = pool.get(slotId);
238
244
  if (!entry)
239
245
  return;
240
246
  entry.refcount--;
241
247
  if (entry.refcount > 0) {
242
- // Refcount is still > 0 (e.g. acquireAppSlotHolds holds a ref), but
243
- // the renderer releasing this slot is done with it. Detach the host
244
- // from its current DOM parent so it doesn't remain visible in the old
245
- // SlotContainer. The pool entry (and view) stays alive for re-acquisition
246
- // — for example, a preset switch back to this slot's preset will re-append
247
- // the host to a new SlotContainer without destroying the view.
248
- entry.host.remove();
248
+ // Refcount remains positive (e.g. acquireAppSlotHolds keeps a hold for
249
+ // every slot in the active layout). Detach the host from its current
250
+ // DOM parent ONLY when that parent is the wrapper the caller is
251
+ // releasing from i.e. nobody else has moved it elsewhere yet.
252
+ //
253
+ // Same-strip tab reorder is the exact case this guard exists for:
254
+ // Svelte runs the two SlotContainer effects as
255
+ // [cleanup0, body0, cleanup1, body1]. By the time cleanup1 fires,
256
+ // body0 has already `appendChild`'d the host into the OTHER pane's
257
+ // wrapper (move semantics auto-detach from the original). An
258
+ // unconditional `.remove()` would empty the pane that just claimed
259
+ // the host and leave the active tab visibly blank.
260
+ //
261
+ // Same-instance prop change (preset switch on a single SlotContainer)
262
+ // still works correctly: the host's parent is still our wrapper at
263
+ // cleanup time, so the detach runs and the wrapper is empty when the
264
+ // new acquire appends a different host.
265
+ if (fromWrapper && entry.host.parentNode === fromWrapper) {
266
+ entry.host.remove();
267
+ }
249
268
  return;
250
269
  }
251
270
  pendingDestroy.add(slotId);
@@ -96,9 +96,22 @@ export const sh3coreShard = {
96
96
  };
97
97
  ctx.registerView('sh3core:home', factory);
98
98
  ctx.registerView('shell:keys-and-peers', keysFactory);
99
- // Dynamic launcher actions: one "Launch <App>" per registered app, kept
100
- // in sync as packages install/uninstall via the registry. Re-launching
101
- // the active app is harmless (lifecycle treats it as a resume).
99
+ // Launcher parent submenu drill host. No `run` needed: the
100
+ // dispatcher's default behavior opens a sub-palette filtered to
101
+ // `submenuOf === 'sh3.app.launch'`. The single parent replaces the
102
+ // per-app palette entries that used to flood the idle palette.
103
+ ctx.actions.register({
104
+ id: 'sh3.app.launch',
105
+ label: 'Launch app',
106
+ scope: ['home', 'app'],
107
+ submenu: true,
108
+ paletteItem: true,
109
+ });
110
+ // Dynamic launcher children: one per registered app, kept in sync
111
+ // as packages install/uninstall via the registry. Children inherit
112
+ // the parent's surface placement, so they don't set paletteItem
113
+ // themselves. Direct text match (e.g. typing the app name) still
114
+ // surfaces them at the root palette via the scorer.
102
115
  const launcherUnregisters = new Map();
103
116
  $effect.root(() => {
104
117
  $effect(() => {
@@ -109,8 +122,9 @@ export const sh3coreShard = {
109
122
  continue;
110
123
  const off = ctx.actions.register({
111
124
  id: `sh3.app.launch:${id}`,
112
- label: `Launch ${app.manifest.label}`,
125
+ label: app.manifest.label,
113
126
  scope: ['home', 'app'],
127
+ submenuOf: 'sh3.app.launch',
114
128
  run() {
115
129
  void launchApp(id);
116
130
  },
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Auto-generated from package.json — do not edit manually. */
2
- export declare const VERSION = "0.11.6";
2
+ export declare const VERSION = "0.11.7";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Auto-generated from package.json — do not edit manually. */
2
- export const VERSION = '0.11.6';
2
+ export const VERSION = '0.11.7';