sh3-core 0.7.1 → 0.7.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 (61) hide show
  1. package/dist/Shell.svelte +3 -2
  2. package/dist/__test__/fixtures.d.ts +12 -0
  3. package/dist/__test__/fixtures.js +62 -0
  4. package/dist/__test__/render.d.ts +3 -0
  5. package/dist/__test__/render.js +11 -0
  6. package/dist/__test__/reset.d.ts +14 -0
  7. package/dist/__test__/reset.js +34 -0
  8. package/dist/__test__/setup-dom.d.ts +1 -0
  9. package/dist/__test__/setup-dom.js +26 -0
  10. package/dist/__test__/smoke.test.d.ts +1 -0
  11. package/dist/__test__/smoke.test.js +28 -0
  12. package/dist/api.d.ts +4 -0
  13. package/dist/apps/lifecycle.js +27 -10
  14. package/dist/apps/lifecycle.test.d.ts +1 -0
  15. package/dist/apps/lifecycle.test.js +260 -0
  16. package/dist/apps/registry.svelte.d.ts +2 -0
  17. package/dist/apps/registry.svelte.js +5 -0
  18. package/dist/apps/types.d.ts +17 -0
  19. package/dist/contract.d.ts +10 -0
  20. package/dist/contract.js +10 -0
  21. package/dist/layout/LayoutRenderer.browser.test.d.ts +1 -0
  22. package/dist/layout/LayoutRenderer.browser.test.js +274 -0
  23. package/dist/layout/LayoutRenderer.svelte +2 -1
  24. package/dist/layout/LayoutRenderer.test.d.ts +1 -0
  25. package/dist/layout/LayoutRenderer.test.js +143 -0
  26. package/dist/layout/SlotContainer.svelte +8 -2
  27. package/dist/layout/SlotDropZone.svelte +19 -0
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. package/dist/layout/drag.svelte.d.ts +5 -0
  34. package/dist/layout/drag.svelte.js +15 -0
  35. package/dist/layout/inspection.js +58 -7
  36. package/dist/layout/ops.js +25 -6
  37. package/dist/layout/ops.test.js +51 -1
  38. package/dist/layout/slotHostPool.svelte.d.ts +16 -1
  39. package/dist/layout/slotHostPool.svelte.js +124 -6
  40. package/dist/layout/slotHostPool.test.d.ts +1 -0
  41. package/dist/layout/slotHostPool.test.js +104 -0
  42. package/dist/layout/store.svelte.d.ts +22 -0
  43. package/dist/layout/store.svelte.js +80 -18
  44. package/dist/layout/tree-walk.d.ts +2 -0
  45. package/dist/layout/tree-walk.js +1 -1
  46. package/dist/layout/types.d.ts +5 -0
  47. package/dist/overlays/FloatFrame.svelte +1 -0
  48. package/dist/overlays/float.d.ts +2 -0
  49. package/dist/overlays/float.js +4 -1
  50. package/dist/overlays/float.test.js +102 -1
  51. package/dist/primitives/ResizableSplitter.svelte +2 -0
  52. package/dist/primitives/TabbedPanel.svelte +4 -0
  53. package/dist/primitives/TabbedPanel.svelte.d.ts +2 -0
  54. package/dist/shards/activate.svelte.d.ts +6 -0
  55. package/dist/shards/activate.svelte.js +10 -0
  56. package/dist/shards/registry.d.ts +4 -0
  57. package/dist/shards/registry.js +18 -0
  58. package/dist/shards/types.d.ts +6 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/package.json +9 -1
package/dist/contract.js CHANGED
@@ -17,12 +17,22 @@ export const contract = {
17
17
  * listed in shardImports or hostImports is illegal. */
18
18
  packagePrefix: 'sh3-core',
19
19
  shard: {
20
+ /** Fields external authors must declare. */
21
+ sourceRequiredFields: ['id', 'label', 'views'],
22
+ /** Fields the framework stamps at load time — must NOT appear in source. */
23
+ runtimeRequiredFields: ['version'],
24
+ /** Union of both — the full runtime shape. Kept for backward compat. */
20
25
  requiredFields: ['id', 'label', 'version', 'views'],
21
26
  views: {
22
27
  requiredFields: ['id', 'label'],
23
28
  },
24
29
  },
25
30
  app: {
31
+ /** Fields external authors must declare. */
32
+ sourceRequiredFields: ['id', 'label', 'requiredShards', 'layoutVersion'],
33
+ /** Fields the framework stamps at load time — must NOT appear in source. */
34
+ runtimeRequiredFields: ['version'],
35
+ /** Union of both — the full runtime shape. Kept for backward compat. */
26
36
  requiredFields: ['id', 'label', 'version', 'requiredShards', 'layoutVersion'],
27
37
  },
28
38
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ }
@@ -15,6 +15,7 @@
15
15
  import { activeLayout, getActiveRoot } from './store.svelte';
16
16
  import { nodeAtPath, findTabBySlotId, removeTabBySlotId, cleanupTree, splitNodeAtPath, } from './ops';
17
17
  import { getSlotHandle } from './slotHostPool.svelte';
18
+ import { floatManager } from '../overlays/float';
18
19
  /**
19
20
  * Read-only snapshot of the currently-rendered layout tree. The return
20
21
  * value is the live object — callers MUST NOT mutate it directly;
@@ -46,16 +47,20 @@ export function spliceIntoActiveLayout(entry) {
46
47
  * layout. Returns `true` if a matching tab was found and activated.
47
48
  */
48
49
  export function focusTab(slotId) {
49
- const root = activeLayout().docked;
50
- return focusTabWhere(root, (entry) => entry.slotId === slotId);
50
+ const tree = activeLayout();
51
+ if (focusTabWhere(tree.docked, (entry) => entry.slotId === slotId))
52
+ return true;
53
+ return focusTabInFloats(tree, (entry) => entry.slotId === slotId);
51
54
  }
52
55
  /**
53
56
  * Activate the first tab whose `viewId` matches in the currently-rendered
54
57
  * layout. Returns `true` if a matching tab was found and activated.
55
58
  */
56
59
  export function focusView(viewId) {
57
- const root = activeLayout().docked;
58
- return focusTabWhere(root, (entry) => entry.viewId === viewId);
60
+ const tree = activeLayout();
61
+ if (focusTabWhere(tree.docked, (entry) => entry.viewId === viewId))
62
+ return true;
63
+ return focusTabInFloats(tree, (entry) => entry.viewId === viewId);
59
64
  }
60
65
  /** Walk the tree looking for a tab entry that satisfies `pred`, activate it. */
61
66
  function focusTabWhere(node, pred) {
@@ -75,6 +80,16 @@ function focusTabWhere(node, pred) {
75
80
  }
76
81
  return false;
77
82
  }
83
+ /** Search floats for a matching tab; activate it and raise the float. */
84
+ function focusTabInFloats(tree, pred) {
85
+ for (const floatEntry of tree.floats) {
86
+ if (focusTabWhere(floatEntry.content, pred)) {
87
+ floatManager.focus(floatEntry.id);
88
+ return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
78
93
  /**
79
94
  * Collapse a child of a split node at the given path. Returns true if
80
95
  * the split was found and the child was collapsed.
@@ -115,10 +130,13 @@ function setCollapsed(splitPath, childIndex, value) {
115
130
  * the sole authority on tree mutations.
116
131
  */
117
132
  export async function closeTab(slotId) {
118
- const root = activeLayout().docked;
133
+ const tree = activeLayout();
134
+ const root = tree.docked;
119
135
  const located = findTabBySlotId(root, slotId);
120
- if (!located)
121
- return false;
136
+ // Not found in docked tree — check floats.
137
+ if (!located) {
138
+ return closeFloatTab(tree, slotId);
139
+ }
122
140
  const handle = getSlotHandle(slotId);
123
141
  const closable = handle === null || handle === void 0 ? void 0 : handle.closable;
124
142
  // Non-closable: no action.
@@ -143,6 +161,39 @@ export async function closeTab(slotId) {
143
161
  cleanupTree(root);
144
162
  return true;
145
163
  }
164
+ /**
165
+ * Close a tab that lives inside a float entry. Float tabs are
166
+ * auto-closable (views gain closability when mounted in a float) but
167
+ * guarded canClose() on the view handle is still respected.
168
+ * Closing the last tab in a float removes the entire float.
169
+ */
170
+ async function closeFloatTab(tree, slotId) {
171
+ for (const entry of tree.floats) {
172
+ const located = findTabBySlotId(entry.content, slotId);
173
+ if (!located)
174
+ continue;
175
+ // Respect guarded canClose() if the view declared one.
176
+ const handle = getSlotHandle(slotId);
177
+ const closable = handle === null || handle === void 0 ? void 0 : handle.closable;
178
+ if (typeof closable === 'object') {
179
+ const allowed = await closable.canClose();
180
+ if (!allowed)
181
+ return false;
182
+ // Re-verify after async gap.
183
+ if (!findTabBySlotId(entry.content, slotId))
184
+ return false;
185
+ }
186
+ // Remove the tab from the float's content tree.
187
+ removeTabBySlotId(entry.content, slotId);
188
+ // If the float's content is now empty, remove the entire float.
189
+ const tabs = entry.content.type === 'tabs' ? entry.content : null;
190
+ if (!tabs || tabs.tabs.length === 0) {
191
+ floatManager.close(entry.id);
192
+ }
193
+ return true;
194
+ }
195
+ return false;
196
+ }
146
197
  function findFirstTabsNode(node) {
147
198
  if (node.type === 'tabs')
148
199
  return node;