sh3-core 0.7.3 → 0.8.0

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 (174) hide show
  1. package/dist/__test__/fixtures.d.ts +12 -0
  2. package/dist/__test__/fixtures.js +62 -0
  3. package/dist/__test__/render.d.ts +3 -0
  4. package/dist/__test__/render.js +11 -0
  5. package/dist/__test__/reset.d.ts +14 -0
  6. package/dist/__test__/reset.js +34 -0
  7. package/dist/__test__/setup-dom.d.ts +1 -0
  8. package/dist/__test__/setup-dom.js +26 -0
  9. package/dist/__test__/smoke.test.d.ts +1 -0
  10. package/dist/__test__/smoke.test.js +28 -0
  11. package/dist/api.d.ts +15 -2
  12. package/dist/api.js +13 -1
  13. package/dist/app/store/StoreView.svelte +36 -7
  14. package/dist/app/store/storeShard.svelte.js +9 -3
  15. package/dist/app/store/verbs.js +8 -2
  16. package/dist/apps/lifecycle.d.ts +11 -0
  17. package/dist/apps/lifecycle.js +48 -11
  18. package/dist/apps/lifecycle.test.d.ts +1 -0
  19. package/dist/apps/lifecycle.test.js +309 -0
  20. package/dist/apps/registry.svelte.d.ts +2 -0
  21. package/dist/apps/registry.svelte.js +5 -0
  22. package/dist/apps/types.d.ts +24 -2
  23. package/dist/createShell.d.ts +2 -0
  24. package/dist/createShell.js +9 -7
  25. package/dist/documents/handle.js +5 -0
  26. package/dist/documents/index.d.ts +1 -0
  27. package/dist/documents/index.js +1 -0
  28. package/dist/documents/journal-hook.d.ts +6 -0
  29. package/dist/documents/journal-hook.js +16 -0
  30. package/dist/documents/sync/activate-integration.test.d.ts +1 -0
  31. package/dist/documents/sync/activate-integration.test.js +37 -0
  32. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
  33. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
  34. package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
  35. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
  36. package/dist/documents/sync/conflicts.d.ts +30 -0
  37. package/dist/documents/sync/conflicts.js +77 -0
  38. package/dist/documents/sync/conflicts.test.d.ts +1 -0
  39. package/dist/documents/sync/conflicts.test.js +71 -0
  40. package/dist/documents/sync/engine.d.ts +19 -0
  41. package/dist/documents/sync/engine.js +188 -0
  42. package/dist/documents/sync/engine.test.d.ts +1 -0
  43. package/dist/documents/sync/engine.test.js +169 -0
  44. package/dist/documents/sync/handle.d.ts +11 -0
  45. package/dist/documents/sync/handle.js +79 -0
  46. package/dist/documents/sync/handle.test.d.ts +1 -0
  47. package/dist/documents/sync/handle.test.js +56 -0
  48. package/dist/documents/sync/hash.d.ts +1 -0
  49. package/dist/documents/sync/hash.js +13 -0
  50. package/dist/documents/sync/hash.test.d.ts +1 -0
  51. package/dist/documents/sync/hash.test.js +20 -0
  52. package/dist/documents/sync/index.d.ts +6 -0
  53. package/dist/documents/sync/index.js +12 -0
  54. package/dist/documents/sync/journal.d.ts +30 -0
  55. package/dist/documents/sync/journal.js +179 -0
  56. package/dist/documents/sync/journal.test.d.ts +1 -0
  57. package/dist/documents/sync/journal.test.js +87 -0
  58. package/dist/documents/sync/registry.d.ts +10 -0
  59. package/dist/documents/sync/registry.js +66 -0
  60. package/dist/documents/sync/registry.test.d.ts +1 -0
  61. package/dist/documents/sync/registry.test.js +42 -0
  62. package/dist/documents/sync/serialization.d.ts +5 -0
  63. package/dist/documents/sync/serialization.js +24 -0
  64. package/dist/documents/sync/serialization.test.d.ts +1 -0
  65. package/dist/documents/sync/serialization.test.js +26 -0
  66. package/dist/documents/sync/singleton.d.ts +11 -0
  67. package/dist/documents/sync/singleton.js +26 -0
  68. package/dist/documents/sync/tombstones.d.ts +19 -0
  69. package/dist/documents/sync/tombstones.js +58 -0
  70. package/dist/documents/sync/tombstones.test.d.ts +1 -0
  71. package/dist/documents/sync/tombstones.test.js +37 -0
  72. package/dist/documents/sync/types.d.ts +116 -0
  73. package/dist/documents/sync/types.js +27 -0
  74. package/dist/documents/sync/write-hook.test.d.ts +1 -0
  75. package/dist/documents/sync/write-hook.test.js +36 -0
  76. package/dist/env/client.d.ts +10 -5
  77. package/dist/env/client.js +12 -4
  78. package/dist/layout/LayoutRenderer.browser.test.d.ts +1 -0
  79. package/dist/layout/LayoutRenderer.browser.test.js +274 -0
  80. package/dist/layout/LayoutRenderer.svelte +2 -1
  81. package/dist/layout/LayoutRenderer.test.d.ts +1 -0
  82. package/dist/layout/LayoutRenderer.test.js +143 -0
  83. package/dist/layout/SlotContainer.svelte +8 -2
  84. package/dist/layout/SlotDropZone.svelte +19 -0
  85. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-1-drag-tab-between-groups-moves-a-tab-from-one-tabs-group-to-another-1.png +0 -0
  86. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-2-drag-tab-to-quadrant-creates-a-split-when-dropping-a-tab-on-a-quadrant-drop-zone-1.png +0 -0
  87. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-3-splitter-drag-updates-split-sizes-when-the-splitter-handle-is-dragged-1.png +0 -0
  88. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-4-close-policy-removes-closable-tabs--keeps-non-closable--and-awaits-canClose-1.png +0 -0
  89. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-5-splitter-collapse-toggle-toggles-collapsed-i--on-double-click-1.png +0 -0
  90. package/dist/layout/drag.svelte.d.ts +5 -0
  91. package/dist/layout/drag.svelte.js +15 -0
  92. package/dist/layout/slotHostPool.svelte.d.ts +16 -1
  93. package/dist/layout/slotHostPool.svelte.js +123 -5
  94. package/dist/layout/slotHostPool.test.d.ts +1 -0
  95. package/dist/layout/slotHostPool.test.js +104 -0
  96. package/dist/layout/store.svelte.d.ts +22 -0
  97. package/dist/layout/store.svelte.js +78 -16
  98. package/dist/layout/tree-walk.d.ts +2 -0
  99. package/dist/layout/tree-walk.js +1 -1
  100. package/dist/layout/types.d.ts +5 -0
  101. package/dist/overlays/float.d.ts +2 -0
  102. package/dist/overlays/float.js +4 -1
  103. package/dist/overlays/float.test.js +102 -1
  104. package/dist/primitives/ResizableSplitter.svelte +2 -0
  105. package/dist/primitives/TabbedPanel.svelte +4 -0
  106. package/dist/primitives/TabbedPanel.svelte.d.ts +2 -0
  107. package/dist/registry/installer.d.ts +10 -7
  108. package/dist/registry/installer.js +39 -35
  109. package/dist/registry/register.d.ts +17 -0
  110. package/dist/registry/register.js +22 -0
  111. package/dist/registry/register.test.d.ts +1 -0
  112. package/dist/registry/register.test.js +28 -0
  113. package/dist/shards/activate.svelte.d.ts +6 -0
  114. package/dist/shards/activate.svelte.js +33 -2
  115. package/dist/shards/registry.d.ts +4 -0
  116. package/dist/shards/registry.js +18 -0
  117. package/dist/shards/types.d.ts +16 -1
  118. package/dist/shell-shard/Terminal.svelte +140 -33
  119. package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
  120. package/dist/shell-shard/auto-relocate.d.ts +12 -0
  121. package/dist/shell-shard/auto-relocate.js +20 -0
  122. package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
  123. package/dist/shell-shard/auto-relocate.test.js +35 -0
  124. package/dist/shell-shard/dispatch.d.ts +15 -0
  125. package/dist/shell-shard/dispatch.js +56 -0
  126. package/dist/shell-shard/modes/builtin.d.ts +5 -0
  127. package/dist/shell-shard/modes/builtin.js +18 -0
  128. package/dist/shell-shard/modes/prefs.d.ts +5 -0
  129. package/dist/shell-shard/modes/prefs.js +31 -0
  130. package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
  131. package/dist/shell-shard/modes/prefs.test.js +46 -0
  132. package/dist/shell-shard/modes/registry.d.ts +7 -0
  133. package/dist/shell-shard/modes/registry.js +27 -0
  134. package/dist/shell-shard/modes/registry.test.d.ts +1 -0
  135. package/dist/shell-shard/modes/registry.test.js +35 -0
  136. package/dist/shell-shard/modes/types.d.ts +8 -0
  137. package/dist/shell-shard/modes/types.js +1 -0
  138. package/dist/shell-shard/protocol.d.ts +6 -0
  139. package/dist/shell-shard/shellShard.svelte.js +5 -1
  140. package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
  141. package/dist/shell-shard/tenant-fs-client.js +44 -0
  142. package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
  143. package/dist/shell-shard/tenant-fs-client.test.js +49 -0
  144. package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
  145. package/dist/shell-shard/terminal-dispatch.test.js +53 -0
  146. package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
  147. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
  148. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
  149. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
  150. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
  151. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
  152. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
  153. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
  154. package/dist/shell-shard/toolbar/slots.d.ts +17 -0
  155. package/dist/shell-shard/toolbar/slots.js +26 -0
  156. package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
  157. package/dist/shell-shard/toolbar/slots.test.js +28 -0
  158. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  159. package/dist/shell-shard/verbs/cat.js +34 -0
  160. package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
  161. package/dist/shell-shard/verbs/cd.test.js +56 -0
  162. package/dist/shell-shard/verbs/env.d.ts +2 -0
  163. package/dist/shell-shard/verbs/env.js +14 -0
  164. package/dist/shell-shard/verbs/index.js +6 -1
  165. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  166. package/dist/shell-shard/verbs/ls.js +29 -0
  167. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  168. package/dist/shell-shard/verbs/ls.test.js +49 -0
  169. package/dist/shell-shard/verbs/session.d.ts +0 -1
  170. package/dist/shell-shard/verbs/session.js +58 -26
  171. package/dist/verbs/types.d.ts +2 -0
  172. package/dist/version.d.ts +1 -1
  173. package/dist/version.js +1 -1
  174. package/package.json +9 -1
@@ -0,0 +1,274 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { page } from '@vitest/browser/context';
3
+ import { resetFramework } from '../__test__/reset';
4
+ import { renderWithShell } from '../__test__/render';
5
+ import LayoutRenderer from './LayoutRenderer.svelte';
6
+ import { registerApp } from '../apps/registry.svelte';
7
+ import { launchApp } from '../apps/lifecycle';
8
+ import { registerView } from '../shards/registry';
9
+ import { makeApp, makeAppManifest, makeSplitNode, makeTabsNode, makeTabEntry, makeSlotNode, makeTree, } from '../__test__/fixtures';
10
+ import { layoutStore } from './store.svelte';
11
+ // ---------------------------------------------------------------------------
12
+ // Utilities
13
+ // ---------------------------------------------------------------------------
14
+ function stubView() {
15
+ registerView('test:view', {
16
+ mount: (el) => {
17
+ el.textContent = 'stub';
18
+ return { unmount: () => { el.textContent = ''; } };
19
+ },
20
+ });
21
+ }
22
+ /** Settle microtasks + a short macrotask gap. */
23
+ function settle(ms = 50) {
24
+ return new Promise((r) => setTimeout(r, ms));
25
+ }
26
+ /** Remove all shell host elements from the body (cleanup between tests). */
27
+ function cleanupDOM() {
28
+ const hosts = document.querySelectorAll('.sh3-shell-host');
29
+ hosts.forEach((h) => h.remove());
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // E.1 — drag tab between groups
33
+ // ---------------------------------------------------------------------------
34
+ describe('LayoutRenderer browser — E.1 drag tab between groups', () => {
35
+ beforeEach(() => { cleanupDOM(); resetFramework(); });
36
+ it('moves a tab from one tabs group to another', async () => {
37
+ stubView();
38
+ registerApp(makeApp({
39
+ manifest: makeAppManifest({ id: 'e1' }),
40
+ initialLayout: [
41
+ {
42
+ name: 'default',
43
+ tree: makeTree(makeSplitNode([
44
+ makeTabsNode([makeTabEntry({ slotId: 'L1', label: 'L1' })]),
45
+ makeTabsNode([makeTabEntry({ slotId: 'R1', label: 'R1' })]),
46
+ ])),
47
+ },
48
+ ],
49
+ }));
50
+ await launchApp('e1');
51
+ renderWithShell(LayoutRenderer, { path: [] });
52
+ // Wait for initial render + slot-host pool microtasks
53
+ await settle(30);
54
+ // Get tabs by role. There are two tabs: L1 (active) and R1.
55
+ const allTabs = document.querySelectorAll('[role="tab"]');
56
+ if (allTabs.length < 2)
57
+ throw new Error(`expected 2 tabs, got ${allTabs.length}`);
58
+ // Get both tab strips (role="tablist") — left and right.
59
+ const strips = document.querySelectorAll('[role="tablist"]');
60
+ if (strips.length < 2)
61
+ throw new Error(`expected 2 tab strips, got ${strips.length}`);
62
+ const srcTab = allTabs[0]; // L1 (in left strip)
63
+ const rightStrip = strips[1]; // right tab strip
64
+ const srcRect = srcTab.getBoundingClientRect();
65
+ const sx = srcRect.left + srcRect.width / 2;
66
+ const sy = srcRect.top + srcRect.height / 2;
67
+ const tgtRect = rightStrip.getBoundingClientRect();
68
+ const tgtX = tgtRect.left + tgtRect.width / 2;
69
+ const tgtY = tgtRect.top + tgtRect.height / 2;
70
+ const mk = (x, y, type, buttons = 1) => new PointerEvent(type, {
71
+ bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse',
72
+ clientX: x, clientY: y, screenX: x, screenY: y, buttons, button: 0,
73
+ });
74
+ // Start drag on source tab
75
+ srcTab.dispatchEvent(mk(sx, sy, 'pointerdown'));
76
+ // Cross the threshold (DRAG_THRESHOLD_PX = 4)
77
+ window.dispatchEvent(mk(sx + 2, sy, 'pointermove'));
78
+ window.dispatchEvent(mk(sx + 6, sy, 'pointermove'));
79
+ // Dispatch pointermove ON the right strip so TabbedPanel.onStripPointerMove fires
80
+ // and calls dragController.onStripHover → setDropTarget({ kind: 'strip' })
81
+ rightStrip.dispatchEvent(mk(tgtX, tgtY, 'pointermove'));
82
+ // Commit
83
+ window.dispatchEvent(mk(tgtX, tgtY, 'pointerup', 0));
84
+ await settle();
85
+ // After the drag, cleanupTree runs and removes the empty left group.
86
+ // The split collapses to just the right tabs node as root. Root is `tabs`
87
+ // with both L1 and R1 in it.
88
+ const root = layoutStore.root;
89
+ if ((root === null || root === void 0 ? void 0 : root.type) === 'split') {
90
+ // Cleanup didn't collapse — check left is empty, right has 2
91
+ const leftTabs = root.children[0];
92
+ const rightTabs = root.children[1];
93
+ expect(leftTabs.type === 'tabs' ? leftTabs.tabs.length : -1).toBe(0);
94
+ expect(rightTabs.type === 'tabs' ? rightTabs.tabs.length : -1).toBe(2);
95
+ }
96
+ else if ((root === null || root === void 0 ? void 0 : root.type) === 'tabs') {
97
+ // Cleanup collapsed the split — the lone right tabs group is now root.
98
+ // Both tabs should be present.
99
+ expect(root.tabs.length).toBe(2);
100
+ const slotIds = root.tabs.map((t) => t.slotId);
101
+ expect(slotIds).toContain('L1');
102
+ expect(slotIds).toContain('R1');
103
+ }
104
+ else {
105
+ throw new Error(`expected split or tabs root, got: ${root === null || root === void 0 ? void 0 : root.type}`);
106
+ }
107
+ });
108
+ });
109
+ // ---------------------------------------------------------------------------
110
+ // E.2 — drag tab to quadrant drop zone
111
+ // ---------------------------------------------------------------------------
112
+ describe('LayoutRenderer browser — E.2 drag tab to quadrant', () => {
113
+ beforeEach(() => { cleanupDOM(); resetFramework(); });
114
+ it('creates a split when dropping a tab on a quadrant drop zone', async () => {
115
+ stubView();
116
+ registerApp(makeApp({
117
+ manifest: makeAppManifest({ id: 'e2' }),
118
+ initialLayout: [
119
+ {
120
+ name: 'default',
121
+ tree: makeTree(makeTabsNode([
122
+ makeTabEntry({ slotId: 'X', label: 'X' }),
123
+ makeTabEntry({ slotId: 'Y', label: 'Y' }),
124
+ ])),
125
+ },
126
+ ],
127
+ }));
128
+ await launchApp('e2');
129
+ renderWithShell(LayoutRenderer, { path: [] });
130
+ await settle(30);
131
+ // Get the X tab (first tab = active one)
132
+ const tabs = document.querySelectorAll('[role="tab"]');
133
+ if (tabs.length < 1)
134
+ throw new Error('no tabs found');
135
+ const srcTab = tabs[0]; // X (active)
136
+ const srcRect = srcTab.getBoundingClientRect();
137
+ const sx = srcRect.left + srcRect.width / 2;
138
+ const sy = srcRect.top + srcRect.height / 2;
139
+ const zone = document.querySelector('.slot-drop-zone');
140
+ if (!zone)
141
+ throw new Error('no .slot-drop-zone found');
142
+ const zRect = zone.getBoundingClientRect();
143
+ // Right quadrant center
144
+ const tgtX = zRect.left + zRect.width * 0.75;
145
+ const tgtY = zRect.top + zRect.height / 2;
146
+ const mk = (x, y, type, buttons = 1) => new PointerEvent(type, {
147
+ bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse',
148
+ clientX: x, clientY: y, screenX: x, screenY: y, buttons, button: 0,
149
+ });
150
+ srcTab.dispatchEvent(mk(sx, sy, 'pointerdown'));
151
+ window.dispatchEvent(mk(sx + 2, sy, 'pointermove'));
152
+ window.dispatchEvent(mk(sx + 6, sy, 'pointermove'));
153
+ // Fire pointermove on the zone element so SlotDropZone.onMove runs
154
+ zone.dispatchEvent(mk(tgtX, tgtY, 'pointermove'));
155
+ // Commit via global pointerup
156
+ window.dispatchEvent(mk(tgtX, tgtY, 'pointerup', 0));
157
+ await settle();
158
+ const root = layoutStore.root;
159
+ expect(root === null || root === void 0 ? void 0 : root.type).toBe('split');
160
+ });
161
+ });
162
+ // ---------------------------------------------------------------------------
163
+ // E.3 — splitter drag updates sizes
164
+ // ---------------------------------------------------------------------------
165
+ describe('LayoutRenderer browser — E.3 splitter drag', () => {
166
+ beforeEach(() => { cleanupDOM(); resetFramework(); });
167
+ it('updates split.sizes when the splitter handle is dragged', async () => {
168
+ stubView();
169
+ registerApp(makeApp({
170
+ manifest: makeAppManifest({ id: 'e3' }),
171
+ initialLayout: [
172
+ {
173
+ name: 'default',
174
+ tree: makeTree(makeSplitNode([makeSlotNode('a', 'test:view'), makeSlotNode('b', 'test:view')])),
175
+ },
176
+ ],
177
+ }));
178
+ await launchApp('e3');
179
+ renderWithShell(LayoutRenderer, { path: [] });
180
+ await settle(30);
181
+ const root0 = layoutStore.root;
182
+ const size0 = (root0 === null || root0 === void 0 ? void 0 : root0.type) === 'split' ? root0.sizes[0] : -1;
183
+ // Get handle element directly from DOM (data-testid)
184
+ const handleEl = document.querySelector('[data-testid="splitter-handle-0"]');
185
+ if (!handleEl)
186
+ throw new Error('splitter handle not in DOM');
187
+ const box = handleEl.getBoundingClientRect();
188
+ const hx = box.left + box.width / 2;
189
+ const hy = box.top + box.height / 2;
190
+ const dx = 100;
191
+ const mk = (x, y, type, buttons = 1) => new PointerEvent(type, {
192
+ bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse',
193
+ clientX: x, clientY: y, screenX: x, screenY: y, buttons, button: 0,
194
+ });
195
+ // ResizableSplitter uses onpointerdown/onpointermove/onpointerup on the handle.
196
+ handleEl.dispatchEvent(mk(hx, hy, 'pointerdown'));
197
+ handleEl.dispatchEvent(mk(hx + dx / 2, hy, 'pointermove'));
198
+ handleEl.dispatchEvent(mk(hx + dx, hy, 'pointermove'));
199
+ handleEl.dispatchEvent(mk(hx + dx, hy, 'pointerup', 0));
200
+ await settle();
201
+ const root1 = layoutStore.root;
202
+ const size1 = (root1 === null || root1 === void 0 ? void 0 : root1.type) === 'split' ? root1.sizes[0] : -1;
203
+ expect(size1).not.toBe(size0);
204
+ });
205
+ });
206
+ // ---------------------------------------------------------------------------
207
+ // E.4 — tab close policy
208
+ // ---------------------------------------------------------------------------
209
+ import { setSlotClosableForTest, setSlotCanCloseForTest } from './slotHostPool.svelte';
210
+ describe('LayoutRenderer browser — E.4 close policy', () => {
211
+ beforeEach(() => { cleanupDOM(); resetFramework(); });
212
+ it('removes closable tabs, keeps non-closable, and awaits canClose', async () => {
213
+ stubView();
214
+ setSlotClosableForTest('free', true);
215
+ setSlotClosableForTest('pinned', false);
216
+ setSlotClosableForTest('guarded', true);
217
+ setSlotCanCloseForTest('guarded', async () => false);
218
+ registerApp(makeApp({
219
+ manifest: makeAppManifest({ id: 'e4' }),
220
+ initialLayout: [
221
+ {
222
+ name: 'default',
223
+ tree: makeTree(makeTabsNode([
224
+ makeTabEntry({ slotId: 'free', label: 'Free' }),
225
+ makeTabEntry({ slotId: 'pinned', label: 'Pinned' }),
226
+ makeTabEntry({ slotId: 'guarded', label: 'Guarded' }),
227
+ ])),
228
+ },
229
+ ],
230
+ }));
231
+ await launchApp('e4');
232
+ renderWithShell(LayoutRenderer, { path: [] });
233
+ await settle(30);
234
+ await page.getByTestId('tab-close-free').click();
235
+ await page.getByTestId('tab-close-guarded').click();
236
+ // Allow async canClose to resolve and layout to update
237
+ await settle(100);
238
+ const root = layoutStore.root;
239
+ if ((root === null || root === void 0 ? void 0 : root.type) !== 'tabs')
240
+ throw new Error('expected tabs root');
241
+ const labels = root.tabs.map((t) => t.label);
242
+ expect(labels).toContain('Pinned');
243
+ expect(labels).toContain('Guarded');
244
+ expect(labels).not.toContain('Free');
245
+ });
246
+ });
247
+ // ---------------------------------------------------------------------------
248
+ // E.5 — double-click splitter toggles collapse
249
+ // ---------------------------------------------------------------------------
250
+ describe('LayoutRenderer browser — E.5 splitter collapse toggle', () => {
251
+ beforeEach(() => { cleanupDOM(); resetFramework(); });
252
+ it('toggles collapsed[i] on double-click', async () => {
253
+ var _a;
254
+ stubView();
255
+ registerApp(makeApp({
256
+ manifest: makeAppManifest({ id: 'e5' }),
257
+ initialLayout: [
258
+ {
259
+ name: 'default',
260
+ tree: makeTree(makeSplitNode([makeSlotNode('a', 'test:view'), makeSlotNode('b', 'test:view')])),
261
+ },
262
+ ],
263
+ }));
264
+ await launchApp('e5');
265
+ renderWithShell(LayoutRenderer, { path: [] });
266
+ await settle(30);
267
+ await page.getByTestId('splitter-handle-0').dblClick();
268
+ await settle(50);
269
+ const root = layoutStore.root;
270
+ if ((root === null || root === void 0 ? void 0 : root.type) !== 'split')
271
+ throw new Error('expected split root');
272
+ expect((_a = root.collapsed) === null || _a === void 0 ? void 0 : _a[0]).toBe(true);
273
+ });
274
+ });
@@ -195,9 +195,10 @@
195
195
  closable={tabClosable(tabs.tabs)}
196
196
  dirty={tabDirty(tabs.tabs)}
197
197
  onClose={(i) => handleTabClose(tabs.tabs, i)}
198
+ tabIds={tabs.tabs.map((t) => t.slotId)}
198
199
  />
199
200
  {#snippet tabBody(i: number)}
200
- {@const entry = tabs.tabs[i]}
201
+ {@const entry = tabs?.tabs[i]}
201
202
  {#if entry}
202
203
  <div class="tab-slot-wrapper">
203
204
  <SlotContainer node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId }} label={entry.label} />
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { tick } from 'svelte';
3
+ import LayoutRenderer from './LayoutRenderer.svelte';
4
+ import { resetFramework } from '../__test__/reset';
5
+ import { renderWithShell } from '../__test__/render';
6
+ import { makeApp, makeAppManifest, makeSplitNode, makeTabsNode, makeTabEntry, makeSlotNode, makeTree, } from '../__test__/fixtures';
7
+ import { registerApp } from '../apps/registry.svelte';
8
+ import { registerView } from '../shards/registry';
9
+ import { launchApp } from '../apps/lifecycle';
10
+ function registerStubView() {
11
+ registerView('test:view', {
12
+ mount(container, ctx) {
13
+ const node = document.createElement('div');
14
+ node.dataset.viewFor = ctx.slotId;
15
+ container.appendChild(node);
16
+ return { unmount: () => node.remove() };
17
+ },
18
+ });
19
+ }
20
+ describe('LayoutRenderer — C.1 split with tabs inside', () => {
21
+ beforeEach(resetFramework);
22
+ it('renders a splitter with two panes and a tab strip', async () => {
23
+ registerStubView();
24
+ registerApp(makeApp({
25
+ manifest: makeAppManifest({ id: 'c1' }),
26
+ initialLayout: [
27
+ {
28
+ name: 'default',
29
+ tree: makeTree(makeSplitNode([
30
+ makeSlotNode('left', 'test:view'),
31
+ makeTabsNode([
32
+ makeTabEntry({ slotId: 'r1', label: 'R1', viewId: 'test:view' }),
33
+ makeTabEntry({ slotId: 'r2', label: 'R2', viewId: 'test:view' }),
34
+ ]),
35
+ ])),
36
+ },
37
+ ],
38
+ }));
39
+ await launchApp('c1');
40
+ const { container } = renderWithShell(LayoutRenderer, { path: [] });
41
+ await tick();
42
+ expect(container.querySelector('[data-view-for="left"]')).toBeTruthy();
43
+ // TabbedPanel renders buttons with role="tab" inside a div[role="tablist"]
44
+ expect(container.querySelectorAll('[role="tab"]').length).toBeGreaterThanOrEqual(2);
45
+ });
46
+ });
47
+ import { layoutStore } from './store.svelte';
48
+ describe('LayoutRenderer — C.2 live tab insertion', () => {
49
+ beforeEach(resetFramework);
50
+ it('shows a new tab button when a tab entry is pushed into an existing tabs node', async () => {
51
+ registerStubView();
52
+ registerApp(makeApp({
53
+ manifest: makeAppManifest({ id: 'c2' }),
54
+ initialLayout: [
55
+ {
56
+ name: 'default',
57
+ tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'a', label: 'A', viewId: 'test:view' })])),
58
+ },
59
+ ],
60
+ }));
61
+ await launchApp('c2');
62
+ const { container } = renderWithShell(LayoutRenderer, { path: [] });
63
+ await tick();
64
+ // TabbedPanel renders buttons with role="tab"
65
+ const before = container.querySelectorAll('[role="tab"]').length;
66
+ const root = layoutStore.root;
67
+ if ((root === null || root === void 0 ? void 0 : root.type) !== 'tabs')
68
+ throw new Error('expected tabs root');
69
+ root.tabs.push({ slotId: 'b', viewId: 'test:view', label: 'B' });
70
+ await tick();
71
+ const after = container.querySelectorAll('[role="tab"]').length;
72
+ expect(after).toBe(before + 1);
73
+ });
74
+ });
75
+ import { presetManager } from '../overlays/presets';
76
+ describe('LayoutRenderer — C.3 tree shape swap', () => {
77
+ beforeEach(resetFramework);
78
+ it('does not throw when swapping from tabs-at-root to split-with-tabs-inside', async () => {
79
+ registerStubView();
80
+ registerApp(makeApp({
81
+ manifest: makeAppManifest({ id: 'c3' }),
82
+ initialLayout: [
83
+ {
84
+ name: 'tabs-root',
85
+ tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 't', viewId: 'test:view' })])),
86
+ },
87
+ {
88
+ name: 'split-root',
89
+ tree: makeTree(makeSplitNode([
90
+ makeSlotNode('left', 'test:view'),
91
+ makeTabsNode([makeTabEntry({ slotId: 'r', viewId: 'test:view' })]),
92
+ ])),
93
+ },
94
+ ],
95
+ }));
96
+ await launchApp('c3');
97
+ const { container } = renderWithShell(LayoutRenderer, { path: [] });
98
+ await tick();
99
+ expect(() => presetManager.switch('split-root')).not.toThrow();
100
+ await tick();
101
+ expect(container.querySelector('[data-view-for="left"]')).toBeTruthy();
102
+ });
103
+ });
104
+ describe('LayoutRenderer — C.4 tabless preset', () => {
105
+ beforeEach(resetFramework);
106
+ it('renders a slot-only preset without accessing any tabs snippet', async () => {
107
+ registerStubView();
108
+ registerApp(makeApp({
109
+ manifest: makeAppManifest({ id: 'c4' }),
110
+ initialLayout: [
111
+ { name: 'default', tree: makeTree(makeSlotNode('only', 'test:view')) },
112
+ ],
113
+ }));
114
+ await launchApp('c4');
115
+ const { container } = renderWithShell(LayoutRenderer, { path: [] });
116
+ await tick();
117
+ expect(container.querySelector('[data-view-for="only"]')).toBeTruthy();
118
+ // TabbedPanel renders buttons with role="tab" — none should appear for a slot-only tree
119
+ expect(container.querySelector('[role="tab"]')).toBeNull();
120
+ });
121
+ });
122
+ describe('LayoutRenderer — C.5 invalid path', () => {
123
+ beforeEach(resetFramework);
124
+ it('renders nothing when the path no longer resolves to a node', async () => {
125
+ registerStubView();
126
+ registerApp(makeApp({
127
+ manifest: makeAppManifest({ id: 'c5' }),
128
+ initialLayout: [
129
+ {
130
+ name: 'default',
131
+ tree: makeTree(makeSplitNode([makeSlotNode('a', 'test:view'), makeSlotNode('b', 'test:view')])),
132
+ },
133
+ ],
134
+ }));
135
+ await launchApp('c5');
136
+ const { container } = renderWithShell(LayoutRenderer, { path: [99] });
137
+ await tick();
138
+ // LayoutRenderer renders nothing when path doesn't resolve — Svelte leaves
139
+ // a comment anchor node in the container but no visible content.
140
+ expect(container.querySelector('[data-view-for]')).toBeNull();
141
+ expect(container.querySelector('.leaf-slot-wrapper, .splitter, .tabbed-panel')).toBeNull();
142
+ });
143
+ });
@@ -51,7 +51,13 @@
51
51
  $effect(() => {
52
52
  if (!wrapper) return;
53
53
 
54
- const host = acquireSlotHost(node.slotId, node.viewId, label || node.viewId || node.slotId);
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.
59
+ const currentSlotId = node.slotId;
60
+ const host = acquireSlotHost(currentSlotId, node.viewId, label || node.viewId || currentSlotId);
55
61
  wrapper.appendChild(host);
56
62
 
57
63
  // Local observer exists only to drive the placeholder's dims text;
@@ -67,7 +73,7 @@
67
73
 
68
74
  return () => {
69
75
  ro.disconnect();
70
- releaseSlotHost(node.slotId);
76
+ releaseSlotHost(currentSlotId);
71
77
  };
72
78
  });
73
79
  </script>
@@ -99,6 +99,12 @@
99
99
  {#if hoveredSide}
100
100
  <div class="quad-highlight quad-{hoveredSide}"></div>
101
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>
107
+ {/if}
102
108
  </div>
103
109
 
104
110
  <style>
@@ -122,4 +128,17 @@
122
128
  .quad-highlight.quad-right { top: 0; bottom: 0; left: 50%; right: 0; }
123
129
  .quad-highlight.quad-top { left: 0; right: 0; top: 0; bottom: 50%; }
124
130
  .quad-highlight.quad-bottom { left: 0; right: 0; top: 50%; bottom: 0; }
131
+
132
+ /* Invisible hit-target overlays for each quadrant — present only during a
133
+ drag so they don't intercept normal pointer events. These give Playwright
134
+ (and pointer-based UI) a stable target area per side without relying on
135
+ the hover-driven highlight. */
136
+ .quad-target {
137
+ position: absolute;
138
+ pointer-events: none; /* the parent zone handles pointermove */
139
+ }
140
+ .quad-target.quad-left { top: 0; bottom: 0; left: 0; right: 50%; }
141
+ .quad-target.quad-right { top: 0; bottom: 0; left: 50%; right: 0; }
142
+ .quad-target.quad-top { left: 0; right: 0; top: 0; bottom: 50%; }
143
+ .quad-target.quad-bottom { left: 0; right: 0; top: 50%; bottom: 0; }
125
144
  </style>
@@ -45,4 +45,9 @@ export declare function beginTabDrag(slotId: string, entry: TabEntry, sourceRoot
45
45
  */
46
46
  export declare function setDropTarget(target: DropTarget): void;
47
47
  export declare function clearDropTarget(match?: (target: DropTarget) => boolean): void;
48
+ /**
49
+ * Test-only reset. Returns dragState to its idle initial value and
50
+ * removes any pointer listeners attached during a prior drag.
51
+ */
52
+ export declare function __resetDragStateForTest(): void;
48
53
  export {};
@@ -230,3 +230,18 @@ export function clearDropTarget(match) {
230
230
  return;
231
231
  dragState.target = null;
232
232
  }
233
+ /**
234
+ * Test-only reset. Returns dragState to its idle initial value and
235
+ * removes any pointer listeners attached during a prior drag.
236
+ */
237
+ export function __resetDragStateForTest() {
238
+ removeGlobalListeners();
239
+ dragState.phase = 'idle';
240
+ dragState.source = null;
241
+ dragState.target = null;
242
+ dragState.pointerX = 0;
243
+ dragState.pointerY = 0;
244
+ clickSuppressedUntil = 0;
245
+ pendingStartX = 0;
246
+ pendingStartY = 0;
247
+ }
@@ -5,7 +5,7 @@ import type { ViewHandle } from '../shards/types';
5
5
  * the pool does not know which wrapper owns the host at any given time,
6
6
  * and that is intentional. The same host may be passed around.
7
7
  */
8
- export declare function acquireSlotHost(slotId: string, viewId: string | null, label: string): HTMLDivElement;
8
+ export declare function acquireSlotHost(slotId: string, viewId: string | null, label: string, meta?: Record<string, unknown>): HTMLDivElement;
9
9
  /**
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
@@ -34,3 +34,18 @@ export declare function isSlotDirty(slotId: string): boolean;
34
34
  * re-render when the deferred mount sets the flag.
35
35
  */
36
36
  export declare function isSlotClosable(slotId: string): boolean;
37
+ /**
38
+ * Test-only: set the closable policy for a slot without going through a
39
+ * ViewFactory. Call BEFORE launchApp so the tab strip reads the right
40
+ * closable state when it renders.
41
+ *
42
+ * `true` — tab shows a close button and is removed on click.
43
+ * `false` — tab shows no close button (non-closable).
44
+ */
45
+ export declare function setSlotClosableForTest(slotId: string, closable: boolean): void;
46
+ /**
47
+ * Test-only: attach a canClose guard to a slot. The slot must have been
48
+ * marked closable via `setSlotClosableForTest` first (or the guard will
49
+ * be installed alongside a `true` closable flag).
50
+ */
51
+ export declare function setSlotCanCloseForTest(slotId: string, canClose: () => Promise<boolean>): void;