sh3-core 0.7.3 → 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 (54) 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 +4 -0
  12. package/dist/apps/lifecycle.js +27 -10
  13. package/dist/apps/lifecycle.test.d.ts +1 -0
  14. package/dist/apps/lifecycle.test.js +260 -0
  15. package/dist/apps/registry.svelte.d.ts +2 -0
  16. package/dist/apps/registry.svelte.js +5 -0
  17. package/dist/apps/types.d.ts +17 -0
  18. package/dist/layout/LayoutRenderer.browser.test.d.ts +1 -0
  19. package/dist/layout/LayoutRenderer.browser.test.js +274 -0
  20. package/dist/layout/LayoutRenderer.svelte +2 -1
  21. package/dist/layout/LayoutRenderer.test.d.ts +1 -0
  22. package/dist/layout/LayoutRenderer.test.js +143 -0
  23. package/dist/layout/SlotContainer.svelte +8 -2
  24. package/dist/layout/SlotDropZone.svelte +19 -0
  25. 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
  26. 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
  27. 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
  28. 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
  29. 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
  30. package/dist/layout/drag.svelte.d.ts +5 -0
  31. package/dist/layout/drag.svelte.js +15 -0
  32. package/dist/layout/slotHostPool.svelte.d.ts +16 -1
  33. package/dist/layout/slotHostPool.svelte.js +123 -5
  34. package/dist/layout/slotHostPool.test.d.ts +1 -0
  35. package/dist/layout/slotHostPool.test.js +104 -0
  36. package/dist/layout/store.svelte.d.ts +22 -0
  37. package/dist/layout/store.svelte.js +78 -16
  38. package/dist/layout/tree-walk.d.ts +2 -0
  39. package/dist/layout/tree-walk.js +1 -1
  40. package/dist/layout/types.d.ts +5 -0
  41. package/dist/overlays/float.d.ts +2 -0
  42. package/dist/overlays/float.js +4 -1
  43. package/dist/overlays/float.test.js +102 -1
  44. package/dist/primitives/ResizableSplitter.svelte +2 -0
  45. package/dist/primitives/TabbedPanel.svelte +4 -0
  46. package/dist/primitives/TabbedPanel.svelte.d.ts +2 -0
  47. package/dist/shards/activate.svelte.d.ts +6 -0
  48. package/dist/shards/activate.svelte.js +10 -0
  49. package/dist/shards/registry.d.ts +4 -0
  50. package/dist/shards/registry.js +18 -0
  51. package/dist/shards/types.d.ts +6 -0
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/package.json +9 -1
@@ -32,9 +32,59 @@
32
32
  * edge-case escape hatch reserved by the design. Not wired yet —
33
33
  * phase 6 has no view that needs it.
34
34
  */
35
- import { getView } from '../shards/registry';
35
+ import { getView, __addViewRegistrationListener } from '../shards/registry';
36
36
  const pool = new Map();
37
37
  const pendingDestroy = new Set();
38
+ /**
39
+ * Called by the registry whenever a new ViewFactory is registered.
40
+ * Scans the pool for entries that have the matching viewId but were not
41
+ * mounted (because the factory wasn't available at acquire-time). Mounts
42
+ * them now.
43
+ *
44
+ * This handles the "late factory registration" case: a shard activates
45
+ * after the layout has already acquired a slot for its view. Rather than
46
+ * requiring the layout to re-acquire, the pool retries the mount here.
47
+ */
48
+ function onViewRegistered(viewId, factory) {
49
+ for (const [slotId, entry] of pool.entries()) {
50
+ if (entry.viewId !== viewId || entry.handle !== undefined)
51
+ continue;
52
+ // Entry is in the pool, matches the view, and has no handle yet —
53
+ // the factory wasn't available when the microtask ran. Mount now.
54
+ const ctx = {
55
+ slotId,
56
+ viewId,
57
+ label: entry.label,
58
+ meta: entry.meta,
59
+ setDirty(dirty) {
60
+ dirtyState[slotId] = dirty;
61
+ },
62
+ };
63
+ queueMicrotask(() => {
64
+ var _a, _b;
65
+ // Re-check: entry may have been released before this microtask fires.
66
+ if (!pool.has(slotId))
67
+ return;
68
+ if (entry.handle !== undefined)
69
+ return; // already mounted by a race
70
+ entry.handle = factory.mount(entry.host, ctx);
71
+ if (((_a = entry.handle) === null || _a === void 0 ? void 0 : _a.closable) || slotId.startsWith('float:')) {
72
+ closableState[slotId] = true;
73
+ }
74
+ if ((_b = entry.handle) === null || _b === void 0 ? void 0 : _b.onResize) {
75
+ const onResize = entry.handle.onResize.bind(entry.handle);
76
+ entry.resizeObserver = new ResizeObserver((entries) => {
77
+ for (const e of entries) {
78
+ const box = e.contentRect;
79
+ onResize(Math.round(box.width), Math.round(box.height));
80
+ }
81
+ });
82
+ entry.resizeObserver.observe(entry.host);
83
+ }
84
+ });
85
+ }
86
+ }
87
+ __addViewRegistrationListener(onViewRegistered);
38
88
  /**
39
89
  * Reactive dirty-state map. Keyed by slotId, values are $state so
40
90
  * Svelte tracks reads in `isSlotDirty()` and re-renders the tab strip
@@ -75,7 +125,7 @@ const closableState = $state({});
75
125
  * is destroyed before its deferred mount ever runs (e.g. rapid
76
126
  * add-then-remove of a slot during a drag).
77
127
  */
78
- function createHost(slotId, viewId, label) {
128
+ function createHost(slotId, viewId, label, meta) {
79
129
  const host = document.createElement('div');
80
130
  host.className = 'slot-host';
81
131
  host.dataset.slotId = slotId;
@@ -93,6 +143,7 @@ function createHost(slotId, viewId, label) {
93
143
  handle: undefined,
94
144
  viewId,
95
145
  label,
146
+ meta,
96
147
  refcount: 0,
97
148
  resizeObserver: undefined,
98
149
  cancelPendingMount: () => {
@@ -108,6 +159,7 @@ function createHost(slotId, viewId, label) {
108
159
  slotId,
109
160
  viewId: viewId !== null && viewId !== void 0 ? viewId : '',
110
161
  label,
162
+ meta,
111
163
  setDirty(dirty) {
112
164
  dirtyState[slotId] = dirty;
113
165
  },
@@ -140,13 +192,13 @@ function createHost(slotId, viewId, label) {
140
192
  * the pool does not know which wrapper owns the host at any given time,
141
193
  * and that is intentional. The same host may be passed around.
142
194
  */
143
- export function acquireSlotHost(slotId, viewId, label) {
195
+ export function acquireSlotHost(slotId, viewId, label, meta) {
144
196
  // If the slot was about to be destroyed, cancel — this acquire is the
145
197
  // "other half" of a re-parent (teardown was the previous container).
146
198
  pendingDestroy.delete(slotId);
147
199
  let entry = pool.get(slotId);
148
200
  if (!entry) {
149
- entry = createHost(slotId, viewId, label);
201
+ entry = createHost(slotId, viewId, label, meta);
150
202
  pool.set(slotId, entry);
151
203
  }
152
204
  entry.refcount++;
@@ -162,8 +214,16 @@ export function releaseSlotHost(slotId) {
162
214
  if (!entry)
163
215
  return;
164
216
  entry.refcount--;
165
- if (entry.refcount > 0)
217
+ if (entry.refcount > 0) {
218
+ // Refcount is still > 0 (e.g. acquireAppSlotHolds holds a ref), but
219
+ // the renderer releasing this slot is done with it. Detach the host
220
+ // from its current DOM parent so it doesn't remain visible in the old
221
+ // SlotContainer. The pool entry (and view) stays alive for re-acquisition
222
+ // — for example, a preset switch back to this slot's preset will re-append
223
+ // the host to a new SlotContainer without destroying the view.
224
+ entry.host.remove();
166
225
  return;
226
+ }
167
227
  pendingDestroy.add(slotId);
168
228
  queueMicrotask(() => {
169
229
  var _a, _b;
@@ -200,6 +260,7 @@ export function resetSlotHostPool() {
200
260
  delete dirtyState[key];
201
261
  for (const key of Object.keys(closableState))
202
262
  delete closableState[key];
263
+ handleOverrides.clear();
203
264
  }
204
265
  /**
205
266
  * Read the current ViewHandle for a slot. Returns undefined if the slot
@@ -208,6 +269,9 @@ export function resetSlotHostPool() {
208
269
  */
209
270
  export function getSlotHandle(slotId) {
210
271
  var _a;
272
+ const override = handleOverrides.get(slotId);
273
+ if (override)
274
+ return override;
211
275
  return (_a = pool.get(slotId)) === null || _a === void 0 ? void 0 : _a.handle;
212
276
  }
213
277
  /**
@@ -225,5 +289,59 @@ export function isSlotDirty(slotId) {
225
289
  */
226
290
  export function isSlotClosable(slotId) {
227
291
  var _a;
292
+ if (handleOverrides.has(slotId)) {
293
+ const h = handleOverrides.get(slotId);
294
+ return !!h.closable;
295
+ }
228
296
  return (_a = closableState[slotId]) !== null && _a !== void 0 ? _a : false;
229
297
  }
298
+ // ---------------------------------------------------------------------------
299
+ // Test-only handle override map — lets tests inject closable/canClose policy
300
+ // without needing a real ViewFactory. Not exported from src/index.ts.
301
+ // ---------------------------------------------------------------------------
302
+ /**
303
+ * Map of slotId → synthetic ViewHandle injected by tests. These take
304
+ * priority over the pool's real handles in `getSlotHandle` and
305
+ * `isSlotClosable`. Cleared by `resetSlotHostPool`.
306
+ */
307
+ const handleOverrides = new Map();
308
+ /**
309
+ * Test-only: set the closable policy for a slot without going through a
310
+ * ViewFactory. Call BEFORE launchApp so the tab strip reads the right
311
+ * closable state when it renders.
312
+ *
313
+ * `true` — tab shows a close button and is removed on click.
314
+ * `false` — tab shows no close button (non-closable).
315
+ */
316
+ export function setSlotClosableForTest(slotId, closable) {
317
+ const existing = handleOverrides.get(slotId);
318
+ if (existing) {
319
+ existing.closable = closable;
320
+ }
321
+ else {
322
+ handleOverrides.set(slotId, { unmount() { }, closable });
323
+ }
324
+ // Keep reactive closableState in sync so the tab strip re-renders.
325
+ if (closable) {
326
+ closableState[slotId] = true;
327
+ }
328
+ else {
329
+ delete closableState[slotId];
330
+ }
331
+ }
332
+ /**
333
+ * Test-only: attach a canClose guard to a slot. The slot must have been
334
+ * marked closable via `setSlotClosableForTest` first (or the guard will
335
+ * be installed alongside a `true` closable flag).
336
+ */
337
+ export function setSlotCanCloseForTest(slotId, canClose) {
338
+ const existing = handleOverrides.get(slotId);
339
+ if (existing) {
340
+ existing.closable = { canClose };
341
+ }
342
+ else {
343
+ handleOverrides.set(slotId, { unmount() { }, closable: { canClose } });
344
+ }
345
+ // Guarded slots are still considered "closable" for tab strip rendering.
346
+ closableState[slotId] = true;
347
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { tick } from 'svelte';
3
+ import { resetFramework } from '../__test__/reset';
4
+ import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
5
+ import { registerView } from '../shards/registry';
6
+ import SlotContainer from './SlotContainer.svelte';
7
+ import { renderWithShell } from '../__test__/render';
8
+ import { switchToHome, switchToApp } from './store.svelte';
9
+ import { makeApp, makeAppManifest, makeSlotNode, makeTree } from '../__test__/fixtures';
10
+ import { registerApp } from '../apps/registry.svelte';
11
+ import { launchApp } from '../apps/lifecycle';
12
+ // ─── D.1 ─────────────────────────────────────────────────────────────────────
13
+ describe('slotHostPool — D.1 refcount destroys once', () => {
14
+ beforeEach(resetFramework);
15
+ it('runs the factory teardown exactly once after the final release, in a microtask', async () => {
16
+ const teardown = vi.fn();
17
+ // ViewFactory is an object: { mount(el, ctx): ViewHandle }
18
+ // ViewHandle has unmount(), NOT dispose()
19
+ registerView('v', {
20
+ mount: () => ({
21
+ unmount: teardown,
22
+ }),
23
+ });
24
+ acquireSlotHost('s1', 'v', 's1');
25
+ acquireSlotHost('s1', 'v', 's1');
26
+ releaseSlotHost('s1');
27
+ await Promise.resolve();
28
+ expect(teardown).not.toHaveBeenCalled();
29
+ releaseSlotHost('s1');
30
+ await Promise.resolve();
31
+ expect(teardown).toHaveBeenCalledTimes(1);
32
+ });
33
+ });
34
+ // ─── D.2 ─────────────────────────────────────────────────────────────────────
35
+ describe('slotHostPool — D.2 re-acquire cancels destroy', () => {
36
+ beforeEach(resetFramework);
37
+ it('does not destroy the host when released and re-acquired in the same microtask', async () => {
38
+ const teardown = vi.fn();
39
+ registerView('v', {
40
+ mount: () => ({
41
+ unmount: teardown,
42
+ }),
43
+ });
44
+ acquireSlotHost('s2', 'v', 's2');
45
+ releaseSlotHost('s2');
46
+ acquireSlotHost('s2', 'v', 's2');
47
+ await Promise.resolve();
48
+ expect(teardown).not.toHaveBeenCalled();
49
+ });
50
+ });
51
+ // ─── D.3 ─────────────────────────────────────────────────────────────────────
52
+ describe('slotHostPool — D.3 late factory registration', () => {
53
+ beforeEach(resetFramework);
54
+ it('mounts the view when a factory registers after acquireSlotHost', async () => {
55
+ const mount = vi.fn((el) => {
56
+ const span = document.createElement('span');
57
+ span.dataset.mountedFor = 'late';
58
+ el.appendChild(span);
59
+ return {
60
+ unmount: () => span.remove(),
61
+ };
62
+ });
63
+ acquireSlotHost('late-slot', 'late:view', 'late-slot');
64
+ await Promise.resolve();
65
+ registerView('late:view', { mount });
66
+ await Promise.resolve();
67
+ expect(mount).toHaveBeenCalledTimes(1);
68
+ });
69
+ });
70
+ // ─── D.4 ─────────────────────────────────────────────────────────────────────
71
+ describe('slotHostPool — D.4 shared host across containers', () => {
72
+ beforeEach(resetFramework);
73
+ it('mounts the factory once across two SlotContainers for the same slotId', async () => {
74
+ const mount = vi.fn((_el) => ({ unmount: () => { } }));
75
+ registerView('shared:view', { mount });
76
+ // SlotNode for both containers — same slotId, same viewId
77
+ const node = { type: 'slot', slotId: 'shared', viewId: 'shared:view' };
78
+ renderWithShell(SlotContainer, { node, label: 'A' });
79
+ renderWithShell(SlotContainer, { node, label: 'B' });
80
+ await tick();
81
+ expect(mount).toHaveBeenCalledTimes(1);
82
+ });
83
+ });
84
+ // ─── D.5 ─────────────────────────────────────────────────────────────────────
85
+ describe('slotHostPool — D.5 root swap preserves app slots', () => {
86
+ beforeEach(resetFramework);
87
+ it('does not destroy app-held pooled hosts when swapping app to home and back', async () => {
88
+ const teardown = vi.fn();
89
+ registerView('persist:view', { mount: () => ({ unmount: teardown }) });
90
+ registerApp(makeApp({
91
+ manifest: makeAppManifest({ id: 'd5' }),
92
+ initialLayout: [
93
+ { name: 'default', tree: makeTree(makeSlotNode('d5-slot', 'persist:view')) },
94
+ ],
95
+ }));
96
+ await launchApp('d5');
97
+ await Promise.resolve();
98
+ switchToHome();
99
+ await Promise.resolve();
100
+ switchToApp();
101
+ await Promise.resolve();
102
+ expect(teardown).not.toHaveBeenCalled();
103
+ });
104
+ });
@@ -7,6 +7,22 @@ import type { App } from '../apps/types';
7
7
  * Does NOT switch the active root. Call switchToApp() separately.
8
8
  */
9
9
  export declare function attachApp(app: App): void;
10
+ /**
11
+ * Second-phase attach: take refcount holds on every slot in the active
12
+ * preset's tree. Must run AFTER required shards have activated (and
13
+ * therefore registered their view factories), so the pool's microtask
14
+ * factory lookup sees them. See the refcount-hold discipline comment at
15
+ * the top of this module and the createHost microtask in slotHostPool.
16
+ *
17
+ * TODO(preset-switch leak): holds are taken for the INITIAL preset only
18
+ * and released only in detachApp. On presetManager.switch(), the old
19
+ * preset's slot hosts stay pool-resident with refcount >= 1 (view handle
20
+ * and ResizeObserver not torn down) for the lifetime of the app attach.
21
+ * Acceptable for small preset sets; revisit if presets become churnier.
22
+ * Proper fix: re-scope holds on preset switch, or make this first-frame
23
+ * only and let renderers own all refcounts.
24
+ */
25
+ export declare function acquireAppSlotHolds(): void;
10
26
  /**
11
27
  * Detach the currently-attached app. Releases its refcount holds; the
12
28
  * pool's microtask cleanup drops the pooled hosts if they also have no
@@ -41,3 +57,9 @@ export declare const layoutStore: {
41
57
  readonly tree: LayoutTree;
42
58
  readonly floats: FloatEntry[];
43
59
  };
60
+ /**
61
+ * Test-only reset. Restores the layout store to its boot state: no app
62
+ * attached, active root = 'home'. Not exported from `src/index.ts` —
63
+ * tests import this submodule path directly.
64
+ */
65
+ export declare function __resetLayoutStoreForTest(): void;
@@ -133,17 +133,40 @@ export function attachApp(app) {
133
133
  workspace: defaultBlob,
134
134
  });
135
135
  const proxy = state.workspace;
136
- // Take refcount holds on every slot in the active preset's tree
137
- // (including floats). See the refcount-hold discipline comment at top.
138
- const tree = currentTree(proxy);
136
+ // Create the entry with no slot holds yet; `acquireAppSlotHolds` does
137
+ // that as a second phase, after shards have had a chance to register
138
+ // their view factories. Binding the preset manager proxy happens here
139
+ // so shards can read/switch presets from their activate() hook.
140
+ appEntry = { appId: app.manifest.id, proxy, heldSlotIds: [] };
141
+ bindPresetBlob(proxy);
142
+ }
143
+ /**
144
+ * Second-phase attach: take refcount holds on every slot in the active
145
+ * preset's tree. Must run AFTER required shards have activated (and
146
+ * therefore registered their view factories), so the pool's microtask
147
+ * factory lookup sees them. See the refcount-hold discipline comment at
148
+ * the top of this module and the createHost microtask in slotHostPool.
149
+ *
150
+ * TODO(preset-switch leak): holds are taken for the INITIAL preset only
151
+ * and released only in detachApp. On presetManager.switch(), the old
152
+ * preset's slot hosts stay pool-resident with refcount >= 1 (view handle
153
+ * and ResizeObserver not torn down) for the lifetime of the app attach.
154
+ * Acceptable for small preset sets; revisit if presets become churnier.
155
+ * Proper fix: re-scope holds on preset switch, or make this first-frame
156
+ * only and let renderers own all refcounts.
157
+ */
158
+ export function acquireAppSlotHolds() {
159
+ if (!appEntry) {
160
+ throw new Error('acquireAppSlotHolds: no app attached');
161
+ }
162
+ if (appEntry.heldSlotIds.length > 0)
163
+ return; // idempotent
164
+ const tree = currentTree(appEntry.proxy);
139
165
  const refs = collectTreeSlotRefs(tree);
140
- const heldSlotIds = [];
141
- for (const { slotId, viewId, label } of refs) {
142
- acquireSlotHost(slotId, viewId, label);
143
- heldSlotIds.push(slotId);
166
+ for (const { slotId, viewId, label, meta } of refs) {
167
+ acquireSlotHost(slotId, viewId, label, meta);
168
+ appEntry.heldSlotIds.push(slotId);
144
169
  }
145
- appEntry = { appId: app.manifest.id, proxy, heldSlotIds };
146
- bindPresetBlob(proxy);
147
170
  }
148
171
  /**
149
172
  * Detach the currently-attached app. Releases its refcount holds; the
@@ -171,6 +194,33 @@ export function switchToApp() {
171
194
  }
172
195
  activeRoot = 'app';
173
196
  }
197
+ // ---------- `layoutStore` back-compat shim -------------------------------
198
+ /**
199
+ * Reactive-derived active LayoutTree. Using `$derived.by` gives us a
200
+ * stable, memoized reactive node that downstream accessors
201
+ * (`layoutStore.root`, `.tree`, `.floats`) all subscribe to once —
202
+ * instead of re-running the proxy-read chain on every access.
203
+ *
204
+ * Reading `blob.activePreset` by name inside this derived is load-bearing:
205
+ * it registers the preset-name signal as a dependency, so
206
+ * `presetManager.switch()` invalidates the derived and triggers re-render.
207
+ * Do not collapse this into a plain function that only reads the preset
208
+ * object — the derived would still recompute, but consumers that cached
209
+ * the result via a different path could miss the invalidation.
210
+ */
211
+ const activeTree = $derived.by(() => {
212
+ if (activeRoot === 'app' && appEntry) {
213
+ // Read activePreset by name explicitly so Svelte tracks this signal.
214
+ const blob = appEntry.proxy;
215
+ const presetName = blob.activePreset;
216
+ const preset = blob.presets[presetName];
217
+ if (!preset) {
218
+ throw new Error(`AppLayoutBlob active preset "${presetName}" not found in presets map`);
219
+ }
220
+ return preset.default;
221
+ }
222
+ return HOME_TREE;
223
+ });
174
224
  /**
175
225
  * The currently-rendered LayoutTree. LayoutRenderer reads this via
176
226
  * layoutStore.tree. Home uses a framework constant; app reads the
@@ -178,9 +228,11 @@ export function switchToApp() {
178
228
  * mutations from splitter/drag/ops reach the renderer unchanged).
179
229
  */
180
230
  export function activeLayout() {
181
- if (activeRoot === 'app' && appEntry)
182
- return currentTree(appEntry.proxy);
183
- return HOME_TREE;
231
+ // Delegates to the $derived.by so callers outside component contexts
232
+ // (e.g. inspection.ts, ops.ts) still read the correct tree. The $derived
233
+ // is recalculated whenever activeRoot, appEntry, or blob.activePreset
234
+ // changes, so these callers always see a fresh snapshot.
235
+ return activeTree;
184
236
  }
185
237
  export function getActiveRoot() {
186
238
  return activeRoot;
@@ -189,7 +241,6 @@ export function getAttachedAppId() {
189
241
  var _a;
190
242
  return (_a = appEntry === null || appEntry === void 0 ? void 0 : appEntry.appId) !== null && _a !== void 0 ? _a : null;
191
243
  }
192
- // ---------- `layoutStore` back-compat shim -------------------------------
193
244
  /**
194
245
  * Preserved for callers that still read `layoutStore.root`. The `root`
195
246
  * getter is an alias for `tree.docked` so existing callers still compile
@@ -204,12 +255,23 @@ export function getAttachedAppId() {
204
255
  */
205
256
  export const layoutStore = {
206
257
  get root() {
207
- return activeLayout().docked;
258
+ return activeTree.docked;
208
259
  },
209
260
  get tree() {
210
- return activeLayout();
261
+ return activeTree;
211
262
  },
212
263
  get floats() {
213
- return activeLayout().floats;
264
+ return activeTree.floats;
214
265
  },
215
266
  };
267
+ /**
268
+ * Test-only reset. Restores the layout store to its boot state: no app
269
+ * attached, active root = 'home'. Not exported from `src/index.ts` —
270
+ * tests import this submodule path directly.
271
+ */
272
+ export function __resetLayoutStoreForTest() {
273
+ appEntry = null;
274
+ activeRoot = 'home';
275
+ HOME_TREE.floats.length = 0;
276
+ HOME_TREE.docked = HOME_LAYOUT;
277
+ }
@@ -12,6 +12,7 @@ export declare function collectSlotRefs(tree: LayoutNode): {
12
12
  slotId: string;
13
13
  viewId: string | null;
14
14
  label: string;
15
+ meta?: Record<string, unknown>;
15
16
  }[];
16
17
  /**
17
18
  * Multi-root version of `collectSlotRefs`: walks the docked tree first
@@ -23,4 +24,5 @@ export declare function collectTreeSlotRefs(tree: LayoutTree): {
23
24
  slotId: string;
24
25
  viewId: string | null;
25
26
  label: string;
27
+ meta?: Record<string, unknown>;
26
28
  }[];
@@ -20,7 +20,7 @@ export function collectSlotRefs(tree) {
20
20
  }
21
21
  if (node.type === 'tabs') {
22
22
  for (const t of node.tabs) {
23
- out.push({ slotId: t.slotId, viewId: t.viewId, label: t.label });
23
+ out.push({ slotId: t.slotId, viewId: t.viewId, label: t.label, meta: t.meta });
24
24
  }
25
25
  return;
26
26
  }
@@ -38,6 +38,11 @@ export interface TabEntry {
38
38
  label: string;
39
39
  /** Optional icon hint (not yet rendered in phase 8). */
40
40
  icon?: string;
41
+ /**
42
+ * Caller-supplied instance data, threaded to `MountContext.meta`.
43
+ * Ephemeral — not serialized with the layout tree.
44
+ */
45
+ meta?: Record<string, unknown>;
41
46
  }
42
47
  /**
43
48
  * A layout node that groups one or more slots as tabs, showing one at a time.
@@ -7,6 +7,8 @@ export interface FloatOptions {
7
7
  y: number;
8
8
  };
9
9
  size?: Size;
10
+ /** Instance data threaded to the view factory via `MountContext.meta`. */
11
+ meta?: Record<string, unknown>;
10
12
  }
11
13
  export interface FloatManager {
12
14
  open(viewId: string, options?: FloatOptions): string;
@@ -74,9 +74,12 @@ function openFloat(viewId, options = {}) {
74
74
  // float body; the frame header still moves the float as a whole.
75
75
  const slotId = mintFloatSlotId(viewId);
76
76
  const label = (_a = options.title) !== null && _a !== void 0 ? _a : viewId;
77
+ const tab = { slotId, viewId, label };
78
+ if (options.meta)
79
+ tab.meta = options.meta;
77
80
  const content = {
78
81
  type: 'tabs',
79
- tabs: [{ slotId, viewId, label }],
82
+ tabs: [tab],
80
83
  activeTab: 0,
81
84
  };
82
85
  const computedMin = computeMinSize(content);
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest';
2
- import { floatManager, __resetFloatManagerForTest } from './float';
2
+ import { floatManager, __resetFloatManagerForTest, bindFloatStore } from './float';
3
+ import { layoutStore } from '../layout/store.svelte';
3
4
  describe('floatManager', () => {
4
5
  beforeEach(() => {
5
6
  __resetFloatManagerForTest();
@@ -34,4 +35,104 @@ describe('floatManager', () => {
34
35
  expect(f.position).toEqual({ x: 100, y: 200 });
35
36
  expect(f.size).toEqual({ w: 800, h: 500 });
36
37
  });
38
+ it('open() threads meta into the content TabEntry', () => {
39
+ const meta = { viewConfigId: 'vc-42' };
40
+ const id = floatManager.open('test:view', { meta });
41
+ const f = floatManager.list().find((e) => e.id === id);
42
+ const tabs = f.content;
43
+ expect(tabs.type).toBe('tabs');
44
+ if (tabs.type === 'tabs') {
45
+ expect(tabs.tabs[0].meta).toEqual({ viewConfigId: 'vc-42' });
46
+ }
47
+ });
48
+ it('open() without meta leaves TabEntry.meta undefined', () => {
49
+ const id = floatManager.open('test:view');
50
+ const f = floatManager.list().find((e) => e.id === id);
51
+ const tabs = f.content;
52
+ expect(tabs.type).toBe('tabs');
53
+ if (tabs.type === 'tabs') {
54
+ expect(tabs.tabs[0].meta).toBeUndefined();
55
+ }
56
+ });
57
+ });
58
+ // ---------------------------------------------------------------------------
59
+ // DOM tests — floatManager + FloatLayer.svelte in happy-dom
60
+ // ---------------------------------------------------------------------------
61
+ import { renderWithShell } from '../__test__/render';
62
+ import FloatLayer from './FloatLayer.svelte';
63
+ import { tick } from 'svelte';
64
+ import { resetFramework } from '../__test__/reset';
65
+ /**
66
+ * Wire the floatManager to the same FloatEntry[] that FloatLayer reads
67
+ * (layoutStore.floats → HOME_TREE.floats). Without this binding, the
68
+ * manager writes to its internal fallback array, which the component
69
+ * never observes.
70
+ */
71
+ function bindManagerToStore() {
72
+ bindFloatStore(layoutStore.floats, () => ({ w: 1024, h: 768 }));
73
+ }
74
+ // ---------------------------------------------------------------------------
75
+ // F.1 — open mounts a frame into the DOM; close removes it
76
+ // ---------------------------------------------------------------------------
77
+ describe('floats — F.1 open/close mounts DOM', () => {
78
+ beforeEach(() => {
79
+ resetFramework();
80
+ bindManagerToStore();
81
+ });
82
+ it('mounts a FloatFrame on open() and removes it on close()', async () => {
83
+ const { container } = renderWithShell(FloatLayer, {});
84
+ const id = floatManager.open('test:view', { title: 'Test Float' });
85
+ await tick();
86
+ // FloatFrame renders a div[role="dialog"] with the title as aria-label.
87
+ const frame = container.querySelector('[role="dialog"][aria-label="Test Float"]');
88
+ expect(frame).toBeTruthy();
89
+ floatManager.close(id);
90
+ await tick();
91
+ expect(container.querySelector('[role="dialog"][aria-label="Test Float"]')).toBeNull();
92
+ });
93
+ });
94
+ // ---------------------------------------------------------------------------
95
+ // F.2 — focus stack: last opened is top, previous is restored after close
96
+ // ---------------------------------------------------------------------------
97
+ describe('floats — F.2 focus stack', () => {
98
+ beforeEach(() => {
99
+ resetFramework();
100
+ bindManagerToStore();
101
+ });
102
+ it('raises the newer float above the prior and restores previous on close', async () => {
103
+ var _a, _b;
104
+ renderWithShell(FloatLayer, {});
105
+ const id1 = floatManager.open('test:view', { title: 'First' });
106
+ const id2 = floatManager.open('test:view', { title: 'Second' });
107
+ await tick();
108
+ // The most recently focused float sits at the end of list() (top z-order).
109
+ expect((_a = floatManager.list().at(-1)) === null || _a === void 0 ? void 0 : _a.id).toBe(id2);
110
+ floatManager.close(id2);
111
+ await tick();
112
+ // After closing the top, id1 becomes the sole entry (top of z-order).
113
+ expect((_b = floatManager.list().at(-1)) === null || _b === void 0 ? void 0 : _b.id).toBe(id1);
114
+ });
115
+ });
116
+ // ---------------------------------------------------------------------------
117
+ // F.3 — close button in FloatFrame calls floatManager.close()
118
+ // ---------------------------------------------------------------------------
119
+ describe('floats — F.3 close button removes float', () => {
120
+ beforeEach(() => {
121
+ resetFramework();
122
+ bindManagerToStore();
123
+ });
124
+ it('clicking the close button removes the float from list()', async () => {
125
+ const { container } = renderWithShell(FloatLayer, {});
126
+ const id = floatManager.open('test:view', { title: 'Closeable' });
127
+ await tick();
128
+ // FloatFrame renders a button[aria-label="Close float"] inside the frame.
129
+ const closeBtn = container.querySelector('[role="dialog"] button[aria-label="Close float"]');
130
+ expect(closeBtn).toBeTruthy();
131
+ closeBtn.click();
132
+ await tick();
133
+ // Float must be gone from the manager's list.
134
+ expect(floatManager.list().some((f) => f.id === id)).toBe(false);
135
+ // And its frame must be removed from the DOM.
136
+ expect(container.querySelector('[role="dialog"][aria-label="Closeable"]')).toBeNull();
137
+ });
37
138
  });
@@ -221,10 +221,12 @@
221
221
  class="splitter-handle"
222
222
  class:dragging={drag?.handleIndex === i}
223
223
  class:disabled={isCollapsed(i) || isCollapsed(i + 1)}
224
+ data-testid="splitter-handle-{i}"
224
225
  onpointerdown={(e) => beginDrag(e, i)}
225
226
  onpointermove={moveDrag}
226
227
  onpointerup={endDrag}
227
228
  onpointercancel={endDrag}
229
+ ondblclick={() => onCollapseToggle?.(i, !isCollapsed(i))}
228
230
  role="separator"
229
231
  aria-orientation={direction === 'horizontal' ? 'vertical' : 'horizontal'}
230
232
  ></div>