sh3-core 0.6.0 → 0.7.1

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 (92) hide show
  1. package/dist/Shell.svelte +20 -14
  2. package/dist/api.d.ts +7 -3
  3. package/dist/api.js +1 -0
  4. package/dist/app/admin/adminApp.js +2 -1
  5. package/dist/app/admin/adminShard.svelte.js +2 -1
  6. package/dist/app/store/StoreView.svelte +11 -5
  7. package/dist/app/store/storeApp.js +2 -1
  8. package/dist/app/store/storeShard.svelte.js +14 -4
  9. package/dist/app/store/verbs.d.ts +4 -0
  10. package/dist/app/store/verbs.js +220 -0
  11. package/dist/apps/terminal/manifest.js +2 -1
  12. package/dist/apps/types.d.ts +28 -7
  13. package/dist/build.d.ts +5 -2
  14. package/dist/build.js +21 -10
  15. package/dist/env/client.d.ts +10 -2
  16. package/dist/env/client.js +13 -2
  17. package/dist/layout/LayoutRenderer.svelte +21 -9
  18. package/dist/layout/LayoutRenderer.svelte.d.ts +2 -0
  19. package/dist/layout/SlotDropZone.svelte +4 -1
  20. package/dist/layout/SlotDropZone.svelte.d.ts +2 -0
  21. package/dist/layout/drag.svelte.d.ts +5 -2
  22. package/dist/layout/drag.svelte.js +43 -11
  23. package/dist/layout/floats.d.ts +35 -0
  24. package/dist/layout/floats.js +73 -0
  25. package/dist/layout/floats.test.d.ts +1 -0
  26. package/dist/layout/floats.test.js +114 -0
  27. package/dist/layout/inspection.d.ts +2 -2
  28. package/dist/layout/inspection.js +6 -6
  29. package/dist/layout/ops.d.ts +14 -1
  30. package/dist/layout/ops.js +17 -0
  31. package/dist/layout/ops.test.d.ts +1 -0
  32. package/dist/layout/ops.test.js +36 -0
  33. package/dist/layout/presets.d.ts +2 -0
  34. package/dist/layout/presets.js +49 -0
  35. package/dist/layout/presets.test.d.ts +1 -0
  36. package/dist/layout/presets.test.js +71 -0
  37. package/dist/layout/store.svelte.d.ts +17 -13
  38. package/dist/layout/store.svelte.js +98 -36
  39. package/dist/layout/tree-walk.d.ts +12 -1
  40. package/dist/layout/tree-walk.js +13 -0
  41. package/dist/layout/tree-walk.test.d.ts +1 -0
  42. package/dist/layout/tree-walk.test.js +41 -0
  43. package/dist/layout/types.d.ts +96 -6
  44. package/dist/layout/types.js +1 -1
  45. package/dist/overlays/FloatFrame.svelte +142 -0
  46. package/dist/overlays/FloatFrame.svelte.d.ts +7 -0
  47. package/dist/overlays/FloatLayer.svelte +28 -0
  48. package/dist/overlays/FloatLayer.svelte.d.ts +3 -0
  49. package/dist/overlays/float.d.ts +29 -0
  50. package/dist/overlays/float.js +119 -0
  51. package/dist/overlays/float.test.d.ts +1 -0
  52. package/dist/overlays/float.test.js +37 -0
  53. package/dist/overlays/presets.d.ts +21 -0
  54. package/dist/overlays/presets.js +63 -0
  55. package/dist/overlays/presets.test.d.ts +1 -0
  56. package/dist/overlays/presets.test.js +40 -0
  57. package/dist/registry/client.d.ts +14 -0
  58. package/dist/registry/client.js +37 -0
  59. package/dist/registry/client.test.d.ts +1 -0
  60. package/dist/registry/client.test.js +54 -0
  61. package/dist/registry/installer.js +18 -5
  62. package/dist/registry/schema.js +5 -0
  63. package/dist/registry/types.d.ts +9 -0
  64. package/dist/shards/activate.svelte.js +9 -2
  65. package/dist/shards/registry.d.ts +5 -0
  66. package/dist/shards/registry.js +19 -3
  67. package/dist/shards/registry.test.d.ts +1 -0
  68. package/dist/shards/registry.test.js +62 -0
  69. package/dist/shards/types.d.ts +36 -4
  70. package/dist/shell-shard/Terminal.svelte +17 -12
  71. package/dist/shell-shard/manifest.js +2 -1
  72. package/dist/shell-shard/registry.d.ts +2 -64
  73. package/dist/shell-shard/registry.js +9 -17
  74. package/dist/shell-shard/shellShard.svelte.js +4 -1
  75. package/dist/shell-shard/verbs/apps.d.ts +1 -1
  76. package/dist/shell-shard/verbs/clear.d.ts +1 -1
  77. package/dist/shell-shard/verbs/help.d.ts +2 -2
  78. package/dist/shell-shard/verbs/help.js +3 -2
  79. package/dist/shell-shard/verbs/history.d.ts +1 -1
  80. package/dist/shell-shard/verbs/index.d.ts +2 -2
  81. package/dist/shell-shard/verbs/index.js +18 -18
  82. package/dist/shell-shard/verbs/session.d.ts +1 -1
  83. package/dist/shell-shard/verbs/shards.d.ts +1 -1
  84. package/dist/shell-shard/verbs/views.d.ts +1 -1
  85. package/dist/shell-shard/verbs/zones.d.ts +1 -1
  86. package/dist/shellRuntime.svelte.d.ts +6 -0
  87. package/dist/shellRuntime.svelte.js +4 -0
  88. package/dist/verbs/types.d.ts +62 -0
  89. package/dist/verbs/types.js +8 -0
  90. package/dist/version.d.ts +1 -1
  91. package/dist/version.js +1 -1
  92. package/package.json +6 -3
@@ -18,9 +18,17 @@ export declare function fetchEnvState(shardId: string): Promise<Record<string, u
18
18
  export declare function putEnvState(shardId: string, state: Record<string, unknown>): Promise<void>;
19
19
  /**
20
20
  * Install a package on the server via multipart upload.
21
- * The client has already fetched and integrity-verified the bundle.
21
+ * The client has already fetched and integrity-verified the client bundle
22
+ * (and the server bundle, if present and serverIntegrity was declared).
23
+ *
24
+ * @param manifest - Package manifest metadata to persist server-side.
25
+ * @param clientBundle - Verified client ESM bundle bytes.
26
+ * @param serverBundle - Optional verified server ESM bundle bytes. When
27
+ * provided, the server writes it to `server.js` and hot-mounts the
28
+ * shard's routes. If the mount fails, the entire install is rolled
29
+ * back server-side.
22
30
  */
23
- export declare function serverInstallPackage(manifest: Record<string, unknown>, clientBundle: ArrayBuffer): Promise<{
31
+ export declare function serverInstallPackage(manifest: Record<string, unknown>, clientBundle: ArrayBuffer, serverBundle?: ArrayBuffer): Promise<{
24
32
  ok: boolean;
25
33
  id: string;
26
34
  error?: string;
@@ -50,9 +50,17 @@ export async function putEnvState(shardId, state) {
50
50
  }
51
51
  /**
52
52
  * Install a package on the server via multipart upload.
53
- * The client has already fetched and integrity-verified the bundle.
53
+ * The client has already fetched and integrity-verified the client bundle
54
+ * (and the server bundle, if present and serverIntegrity was declared).
55
+ *
56
+ * @param manifest - Package manifest metadata to persist server-side.
57
+ * @param clientBundle - Verified client ESM bundle bytes.
58
+ * @param serverBundle - Optional verified server ESM bundle bytes. When
59
+ * provided, the server writes it to `server.js` and hot-mounts the
60
+ * shard's routes. If the mount fails, the entire install is rolled
61
+ * back server-side.
54
62
  */
55
- export async function serverInstallPackage(manifest, clientBundle) {
63
+ export async function serverInstallPackage(manifest, clientBundle, serverBundle) {
56
64
  var _a;
57
65
  if (!isAdmin())
58
66
  throw new Error('Cannot install: not elevated to admin');
@@ -60,6 +68,9 @@ export async function serverInstallPackage(manifest, clientBundle) {
60
68
  const form = new FormData();
61
69
  form.append('manifest', new Blob([JSON.stringify(manifest)], { type: 'application/json' }), 'manifest.json');
62
70
  form.append('client', new Blob([clientBundle], { type: 'application/javascript' }), 'client.js');
71
+ if (serverBundle !== undefined) {
72
+ form.append('server', new Blob([serverBundle], { type: 'application/javascript' }), 'server.js');
73
+ }
63
74
  const headers = {};
64
75
  if (auth)
65
76
  headers['Authorization'] = auth;
@@ -26,7 +26,7 @@
26
26
  * path via `[...path, i]`.
27
27
  */
28
28
 
29
- import type { TabsNode, LayoutNode } from './types';
29
+ import type { TabsNode, LayoutNode, TreeRootRef } from './types';
30
30
  import ResizableSplitter from '../primitives/ResizableSplitter.svelte';
31
31
  import TabbedPanel, { type TabDragController } from '../primitives/TabbedPanel.svelte';
32
32
  import SlotContainer from './SlotContainer.svelte';
@@ -44,7 +44,10 @@
44
44
  suppressNextClick,
45
45
  } from './drag.svelte';
46
46
 
47
- let { path = [] }: { path?: number[] } = $props();
47
+ let {
48
+ path = [],
49
+ rootRef = { kind: 'docked' } as TreeRootRef,
50
+ }: { path?: number[]; rootRef?: TreeRootRef } = $props();
48
51
 
49
52
  /**
50
53
  * Resolve the current node by walking `layoutStore.root` along the
@@ -53,7 +56,16 @@
53
56
  * cleanup pass can collapse nodes out from under a recursive
54
57
  * renderer), we render null.
55
58
  */
56
- const node = $derived(nodeAtPath(layoutStore.root, path));
59
+ const node = $derived.by(() => {
60
+ let rootNode: LayoutNode | null;
61
+ if (rootRef.kind === 'docked') {
62
+ rootNode = layoutStore.root;
63
+ } else {
64
+ const entry = layoutStore.tree.floats.find((f) => f.id === rootRef.floatId);
65
+ rootNode = entry?.content ?? null;
66
+ }
67
+ return rootNode ? nodeAtPath(rootNode, path) : null;
68
+ });
57
69
 
58
70
  /**
59
71
  * Build a TabDragController bound to the current tabs node.
@@ -68,7 +80,7 @@
68
80
  onPointerDown(index, event, element) {
69
81
  const entry = tabsNode.tabs[index];
70
82
  if (!entry) return;
71
- beginTabDrag(entry.slotId, entry, event, element);
83
+ beginTabDrag(entry.slotId, entry, rootRef, event, element);
72
84
  },
73
85
  onStripHover(stripRect, pointerX, pointerY, tabRects) {
74
86
  if (pointerY < stripRect.top || pointerY > stripRect.bottom) {
@@ -92,7 +104,7 @@
92
104
  const srcIdx = tabsNode.tabs.findIndex((t) => t.slotId === source.slotId);
93
105
  if (srcIdx >= 0 && srcIdx < insertIndex) insertIndex -= 1;
94
106
  }
95
- setDropTarget({ kind: 'strip', tabsNode, insertIndex });
107
+ setDropTarget({ kind: 'strip', root: rootRef, tabsNode, insertIndex });
96
108
  return insertIndex;
97
109
  },
98
110
  onStripLeave() {
@@ -141,7 +153,7 @@
141
153
  */
142
154
  function onEmptyTabsDrop(_e: PointerEvent, tabsNode: import('./types').TabsNode) {
143
155
  if (dragState.phase !== 'dragging') return;
144
- setDropTarget({ kind: 'strip', tabsNode, insertIndex: 0 });
156
+ setDropTarget({ kind: 'strip', root: rootRef, tabsNode, insertIndex: 0 });
145
157
  }
146
158
 
147
159
  function onEmptyTabsLeave() {
@@ -166,7 +178,7 @@
166
178
  }}
167
179
  />
168
180
  {#snippet splitPane(i: number)}
169
- <Self path={[...path, i]} />
181
+ <Self {rootRef} path={[...path, i]} />
170
182
  {/snippet}
171
183
  {:else if node.type === 'tabs'}
172
184
  {@const tabs = asTabs(node)}
@@ -189,7 +201,7 @@
189
201
  {#if entry}
190
202
  <div class="tab-slot-wrapper">
191
203
  <SlotContainer node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId }} label={entry.label} />
192
- <SlotDropZone path={path} />
204
+ <SlotDropZone {rootRef} path={path} />
193
205
  </div>
194
206
  {/if}
195
207
  {/snippet}
@@ -213,7 +225,7 @@
213
225
  {@const slot = asSlot(node)!}
214
226
  <div class="leaf-slot-wrapper">
215
227
  <SlotContainer node={slot} />
216
- <SlotDropZone path={path} />
228
+ <SlotDropZone {rootRef} path={path} />
217
229
  </div>
218
230
  {/if}
219
231
  {/if}
@@ -1,5 +1,7 @@
1
+ import type { TreeRootRef } from './types';
1
2
  type $$ComponentProps = {
2
3
  path?: number[];
4
+ rootRef?: TreeRootRef;
3
5
  };
4
6
  declare const LayoutRenderer: import("svelte").Component<$$ComponentProps, {}, "">;
5
7
  type LayoutRenderer = ReturnType<typeof LayoutRenderer>;
@@ -21,10 +21,13 @@
21
21
 
22
22
  import { dragState, setDropTarget, clearDropTarget, type DropTarget } from './drag.svelte';
23
23
  import type { LayoutPath, SplitSide } from './ops';
24
+ import type { TreeRootRef } from './types';
24
25
 
25
26
  let {
27
+ rootRef,
26
28
  path,
27
29
  }: {
30
+ rootRef: TreeRootRef;
28
31
  /** Path of the node this zone covers, used when reporting the drop. */
29
32
  path: LayoutPath;
30
33
  } = $props();
@@ -72,7 +75,7 @@
72
75
  const side = quadrantFor(e.clientX, e.clientY, rect);
73
76
  if (side !== hoveredSide) {
74
77
  hoveredSide = side;
75
- const target: DropTarget = { kind: 'split', path: [...path], side };
78
+ const target: DropTarget = { kind: 'split', root: rootRef, path: [...path], side };
76
79
  setDropTarget(target);
77
80
  }
78
81
  }
@@ -1,5 +1,7 @@
1
1
  import type { LayoutPath } from './ops';
2
+ import type { TreeRootRef } from './types';
2
3
  type $$ComponentProps = {
4
+ rootRef: TreeRootRef;
3
5
  /** Path of the node this zone covers, used when reporting the drop. */
4
6
  path: LayoutPath;
5
7
  };
@@ -1,18 +1,21 @@
1
- import type { TabEntry, TabsNode } from './types';
1
+ import type { TabEntry, TabsNode, TreeRootRef } from './types';
2
2
  import { type LayoutPath, type SplitSide } from './ops';
3
3
  export type DropTarget = {
4
4
  kind: 'strip';
5
+ root: TreeRootRef;
5
6
  tabsNode: TabsNode;
6
7
  /** Insertion index within tabsNode.tabs (0..length). */
7
8
  insertIndex: number;
8
9
  } | {
9
10
  kind: 'split';
11
+ root: TreeRootRef;
10
12
  path: LayoutPath;
11
13
  side: SplitSide;
12
14
  };
13
15
  interface DragSource {
14
16
  slotId: string;
15
17
  entry: TabEntry;
18
+ sourceRoot: TreeRootRef;
16
19
  /** The tab's viewport rect at drag start — used to offset the ghost. */
17
20
  startRect: DOMRect;
18
21
  /** Pointer offset inside the tab at drag start. */
@@ -33,7 +36,7 @@ export declare function suppressNextClick(): boolean;
33
36
  * This does not yet enter the dragging phase — movement past the
34
37
  * threshold is required.
35
38
  */
36
- export declare function beginTabDrag(slotId: string, entry: TabEntry, event: PointerEvent, tabElement: HTMLElement): void;
39
+ export declare function beginTabDrag(slotId: string, entry: TabEntry, sourceRoot: TreeRootRef, event: PointerEvent, tabElement: HTMLElement): void;
37
40
  /**
38
41
  * Called by drop zone components when the pointer is over them. The
39
42
  * last call wins, so innermost / most-specific zones should call this
@@ -42,6 +42,7 @@
42
42
  */
43
43
  import { cleanupTree, insertTabIntoTabs, moveTabWithinTabs, removeTabBySlotId, splitNodeAtPath, } from './ops';
44
44
  import { layoutStore } from './store.svelte';
45
+ import { isEmptyContent } from './floats';
45
46
  export const dragState = $state({
46
47
  phase: 'idle',
47
48
  source: null,
@@ -69,7 +70,7 @@ let pendingStartY = 0;
69
70
  * This does not yet enter the dragging phase — movement past the
70
71
  * threshold is required.
71
72
  */
72
- export function beginTabDrag(slotId, entry, event, tabElement) {
73
+ export function beginTabDrag(slotId, entry, sourceRoot, event, tabElement) {
73
74
  if (dragState.phase !== 'idle')
74
75
  return;
75
76
  const rect = tabElement.getBoundingClientRect();
@@ -77,6 +78,7 @@ export function beginTabDrag(slotId, entry, event, tabElement) {
77
78
  dragState.source = {
78
79
  slotId,
79
80
  entry,
81
+ sourceRoot,
80
82
  startRect: rect,
81
83
  offsetX: event.clientX - rect.left,
82
84
  offsetY: event.clientY - rect.top,
@@ -130,11 +132,38 @@ function onPointerUp(_e) {
130
132
  function onPointerCancel(_e) {
131
133
  teardown();
132
134
  }
135
+ function rootNode(ref) {
136
+ const tree = layoutStore.tree;
137
+ if (ref.kind === 'docked')
138
+ return tree.docked;
139
+ const f = tree.floats.find((e) => e.id === ref.floatId);
140
+ return f ? f.content : null;
141
+ }
142
+ /**
143
+ * If `ref` points at a float whose content is now empty (no bound leaf
144
+ * slots), remove the float from the tree. No-op for docked refs and for
145
+ * floats that still contain a bound view. Called after a commit that
146
+ * removed a tab from a float's content.
147
+ */
148
+ function autoCloseEmptyFloat(ref) {
149
+ if (ref.kind !== 'float')
150
+ return;
151
+ const tree = layoutStore.tree;
152
+ const idx = tree.floats.findIndex((f) => f.id === ref.floatId);
153
+ if (idx < 0)
154
+ return;
155
+ if (isEmptyContent(tree.floats[idx].content)) {
156
+ tree.floats.splice(idx, 1);
157
+ }
158
+ }
133
159
  function commit() {
134
160
  const { source, target } = dragState;
135
161
  if (!source || !target)
136
162
  return;
137
- const root = layoutStore.root;
163
+ const sourceRoot = rootNode(source.sourceRoot);
164
+ const targetRoot = rootNode(target.root);
165
+ if (!sourceRoot || !targetRoot)
166
+ return;
138
167
  // Same-group strip drop: atomic move. The two-step remove/insert
139
168
  // flow splices the reactive tabs array twice; splice's internal
140
169
  // `[[Delete]]` trips Svelte's proxy deleteProperty trap, which can
@@ -152,25 +181,28 @@ function commit() {
152
181
  const srcIdx = target.tabsNode.tabs.findIndex((t) => t.slotId === source.slotId);
153
182
  if (srcIdx >= 0) {
154
183
  moveTabWithinTabs(target.tabsNode, srcIdx, target.insertIndex);
155
- cleanupTree(root);
184
+ cleanupTree(sourceRoot);
185
+ if (targetRoot !== sourceRoot)
186
+ cleanupTree(targetRoot);
187
+ autoCloseEmptyFloat(source.sourceRoot);
156
188
  return;
157
189
  }
158
190
  }
159
- // Cross-group strip drop or split drop: remove from source, then
160
- // insert into destination (or split target into a new group). The
161
- // destination's tabs array only grows in the strip case, so no
162
- // observer sees a shrunk intermediate — the splice-based flow is
163
- // fine here.
164
- const removed = removeTabBySlotId(root, source.slotId);
191
+ // Cross-group strip drop or split drop: remove from SOURCE root, then
192
+ // insert/split into the target.
193
+ const removed = removeTabBySlotId(sourceRoot, source.slotId);
165
194
  if (!removed)
166
195
  return;
167
196
  if (target.kind === 'strip') {
168
197
  insertTabIntoTabs(target.tabsNode, removed, target.insertIndex);
169
198
  }
170
199
  else {
171
- splitNodeAtPath(root, target.path, removed, target.side);
200
+ splitNodeAtPath(targetRoot, target.path, removed, target.side);
172
201
  }
173
- cleanupTree(root);
202
+ cleanupTree(sourceRoot);
203
+ if (targetRoot !== sourceRoot)
204
+ cleanupTree(targetRoot);
205
+ autoCloseEmptyFloat(source.sourceRoot);
174
206
  }
175
207
  function teardown() {
176
208
  dragState.phase = 'idle';
@@ -0,0 +1,35 @@
1
+ import type { LayoutNode, FloatEntry } from './types';
2
+ export declare const DEFAULT_SLOT_MIN: {
3
+ readonly w: 120;
4
+ readonly h: 80;
5
+ };
6
+ export interface Size {
7
+ w: number;
8
+ h: number;
9
+ }
10
+ export declare function computeMinSize(node: LayoutNode): Size;
11
+ /**
12
+ * Given the list of currently-open floats, return the position a new
13
+ * float should appear at. Each new float offsets (+32, +32) from the
14
+ * most recently opened one. If the resulting position would push the
15
+ * float's header outside `bounds`, wraps back to CASCADE_BASE.
16
+ */
17
+ export declare function cascadePosition(existing: FloatEntry[], bounds: {
18
+ w: number;
19
+ h: number;
20
+ }): {
21
+ x: number;
22
+ y: number;
23
+ };
24
+ /** Stable, process-unique float id. Not cryptographic — just unique within a session. */
25
+ export declare function generateFloatId(): string;
26
+ /**
27
+ * True if a LayoutNode subtree contains no leaf slot with a bound viewId.
28
+ * Used by the drag-commit auto-close invariant: when the last bound leaf
29
+ * leaves a float, the float is removed from the tree.
30
+ *
31
+ * A tabs node with zero tabs is empty. A split node is empty iff every
32
+ * child is empty. Only leaf slots with a non-null viewId are considered
33
+ * "filled".
34
+ */
35
+ export declare function isEmptyContent(node: LayoutNode): boolean;
@@ -0,0 +1,73 @@
1
+ /*
2
+ * Pure helpers for float content: recursive min-size computation over a
3
+ * LayoutNode subtree, cascade-position generation, and stable id minting.
4
+ *
5
+ * Min-size rule (see spec 2026-04-11-layout-topology-design.md):
6
+ * - slot: framework-constant DEFAULT_SLOT_MIN (120×80). Real per-view
7
+ * min-size reading is a follow-up; see rescoped DF10.
8
+ * - tabs: element-wise max of all tabs' slot minimums (only one visible).
9
+ * - split: sum along the split axis, max on the cross axis.
10
+ */
11
+ export const DEFAULT_SLOT_MIN = { w: 120, h: 80 };
12
+ export function computeMinSize(node) {
13
+ if (node.type === 'slot') {
14
+ return Object.assign({}, DEFAULT_SLOT_MIN);
15
+ }
16
+ if (node.type === 'tabs') {
17
+ // All tabs fight for the same area; the min is the element-wise max.
18
+ return Object.assign({}, DEFAULT_SLOT_MIN);
19
+ }
20
+ // split
21
+ const children = node.children.map(computeMinSize);
22
+ if (node.direction === 'horizontal') {
23
+ return {
24
+ w: children.reduce((a, c) => a + c.w, 0),
25
+ h: children.reduce((a, c) => Math.max(a, c.h), 0),
26
+ };
27
+ }
28
+ return {
29
+ w: children.reduce((a, c) => Math.max(a, c.w), 0),
30
+ h: children.reduce((a, c) => a + c.h, 0),
31
+ };
32
+ }
33
+ const CASCADE_STEP = 32;
34
+ const CASCADE_BASE = { x: 48, y: 48 };
35
+ /**
36
+ * Given the list of currently-open floats, return the position a new
37
+ * float should appear at. Each new float offsets (+32, +32) from the
38
+ * most recently opened one. If the resulting position would push the
39
+ * float's header outside `bounds`, wraps back to CASCADE_BASE.
40
+ */
41
+ export function cascadePosition(existing, bounds) {
42
+ if (existing.length === 0)
43
+ return Object.assign({}, CASCADE_BASE);
44
+ const last = existing[existing.length - 1];
45
+ const next = { x: last.position.x + CASCADE_STEP, y: last.position.y + CASCADE_STEP };
46
+ // Wraparound if the header would leave the tree-allocated area
47
+ if (next.x + 120 > bounds.w || next.y + 32 > bounds.h) {
48
+ return Object.assign({}, CASCADE_BASE);
49
+ }
50
+ return next;
51
+ }
52
+ let floatIdCounter = 0;
53
+ /** Stable, process-unique float id. Not cryptographic — just unique within a session. */
54
+ export function generateFloatId() {
55
+ floatIdCounter += 1;
56
+ return `float-${Date.now().toString(36)}-${floatIdCounter.toString(36)}`;
57
+ }
58
+ /**
59
+ * True if a LayoutNode subtree contains no leaf slot with a bound viewId.
60
+ * Used by the drag-commit auto-close invariant: when the last bound leaf
61
+ * leaves a float, the float is removed from the tree.
62
+ *
63
+ * A tabs node with zero tabs is empty. A split node is empty iff every
64
+ * child is empty. Only leaf slots with a non-null viewId are considered
65
+ * "filled".
66
+ */
67
+ export function isEmptyContent(node) {
68
+ if (node.type === 'slot')
69
+ return node.viewId == null;
70
+ if (node.type === 'tabs')
71
+ return node.tabs.every((t) => t.viewId == null);
72
+ return node.children.every(isEmptyContent);
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeMinSize, cascadePosition, isEmptyContent } from './floats';
3
+ const slot = (slotId, viewId = 'v') => ({
4
+ type: 'slot',
5
+ slotId,
6
+ viewId,
7
+ });
8
+ const DEFAULT_SLOT_MIN = { w: 120, h: 80 };
9
+ describe('computeMinSize', () => {
10
+ it('returns slot default for a single slot with no declared minSize', () => {
11
+ expect(computeMinSize(slot('s1'))).toEqual(DEFAULT_SLOT_MIN);
12
+ });
13
+ });
14
+ describe('computeMinSize — tabs', () => {
15
+ it('tabs node matches single slot minimum', () => {
16
+ const tabs = {
17
+ type: 'tabs',
18
+ tabs: [
19
+ { slotId: 'a', viewId: 'va', label: 'A' },
20
+ { slotId: 'b', viewId: 'vb', label: 'B' },
21
+ ],
22
+ activeTab: 0,
23
+ };
24
+ expect(computeMinSize(tabs)).toEqual(DEFAULT_SLOT_MIN);
25
+ });
26
+ });
27
+ describe('computeMinSize — splits', () => {
28
+ it('horizontal split sums widths, maxes heights', () => {
29
+ const split = {
30
+ type: 'split',
31
+ direction: 'horizontal',
32
+ sizes: [1, 1],
33
+ children: [slot('a'), slot('b')],
34
+ };
35
+ expect(computeMinSize(split)).toEqual({ w: 240, h: 80 });
36
+ });
37
+ it('vertical split maxes widths, sums heights', () => {
38
+ const split = {
39
+ type: 'split',
40
+ direction: 'vertical',
41
+ sizes: [1, 1],
42
+ children: [slot('a'), slot('b')],
43
+ };
44
+ expect(computeMinSize(split)).toEqual({ w: 120, h: 160 });
45
+ });
46
+ });
47
+ describe('cascadePosition', () => {
48
+ const bounds = { w: 1600, h: 900 };
49
+ it('returns base when no floats exist', () => {
50
+ expect(cascadePosition([], bounds)).toEqual({ x: 48, y: 48 });
51
+ });
52
+ it('offsets 32px from the most recent float', () => {
53
+ const existing = [
54
+ {
55
+ id: 'f1',
56
+ content: slot('s'),
57
+ position: { x: 100, y: 200 },
58
+ size: { w: 600, h: 400 },
59
+ },
60
+ ];
61
+ expect(cascadePosition(existing, bounds)).toEqual({ x: 132, y: 232 });
62
+ });
63
+ it('wraps back to base when the next position would exit bounds', () => {
64
+ const existing = [
65
+ {
66
+ id: 'f1',
67
+ content: slot('s'),
68
+ position: { x: 1599, y: 100 },
69
+ size: { w: 600, h: 400 },
70
+ },
71
+ ];
72
+ expect(cascadePosition(existing, bounds)).toEqual({ x: 48, y: 48 });
73
+ });
74
+ });
75
+ describe('isEmptyContent', () => {
76
+ it('true for a slot with null viewId', () => {
77
+ expect(isEmptyContent({ type: 'slot', slotId: 's', viewId: null })).toBe(true);
78
+ });
79
+ it('false for a slot with a bound viewId', () => {
80
+ expect(isEmptyContent({ type: 'slot', slotId: 's', viewId: 'v' })).toBe(false);
81
+ });
82
+ it('true for tabs with all null viewIds', () => {
83
+ expect(isEmptyContent({
84
+ type: 'tabs',
85
+ tabs: [{ slotId: 'a', viewId: null, label: 'A' }],
86
+ activeTab: 0,
87
+ })).toBe(true);
88
+ });
89
+ it('true for tabs with no tabs at all', () => {
90
+ expect(isEmptyContent({ type: 'tabs', tabs: [], activeTab: 0 })).toBe(true);
91
+ });
92
+ it('false for a split with at least one bound leaf', () => {
93
+ expect(isEmptyContent({
94
+ type: 'split',
95
+ direction: 'horizontal',
96
+ sizes: [1, 1],
97
+ children: [
98
+ { type: 'slot', slotId: 'a', viewId: null },
99
+ { type: 'slot', slotId: 'b', viewId: 'v' },
100
+ ],
101
+ })).toBe(false);
102
+ });
103
+ it('true for a split where every child is empty', () => {
104
+ expect(isEmptyContent({
105
+ type: 'split',
106
+ direction: 'vertical',
107
+ sizes: [1, 1],
108
+ children: [
109
+ { type: 'slot', slotId: 'a', viewId: null },
110
+ { type: 'slot', slotId: 'b', viewId: null },
111
+ ],
112
+ })).toBe(true);
113
+ });
114
+ });
@@ -1,4 +1,4 @@
1
- import type { LayoutNode, TabEntry } from './types';
1
+ import type { TabEntry, LayoutTree } from './types';
2
2
  /**
3
3
  * Read-only snapshot of the currently-rendered layout tree. The return
4
4
  * value is the live object — callers MUST NOT mutate it directly;
@@ -7,7 +7,7 @@ import type { LayoutNode, TabEntry } from './types';
7
7
  * updates without manual subscription wiring.
8
8
  */
9
9
  export declare function inspectActiveLayout(): {
10
- root: LayoutNode;
10
+ root: LayoutTree;
11
11
  source: 'home' | 'app';
12
12
  };
13
13
  /**
@@ -32,7 +32,7 @@ export function inspectActiveLayout() {
32
32
  * variants arrive later.
33
33
  */
34
34
  export function spliceIntoActiveLayout(entry) {
35
- const root = activeLayout();
35
+ const root = activeLayout().docked;
36
36
  const target = findFirstTabsNode(root);
37
37
  if (!target) {
38
38
  throw new Error('spliceIntoActiveLayout: no tabs group found in the active layout; ' +
@@ -46,7 +46,7 @@ export function spliceIntoActiveLayout(entry) {
46
46
  * layout. Returns `true` if a matching tab was found and activated.
47
47
  */
48
48
  export function focusTab(slotId) {
49
- const root = activeLayout();
49
+ const root = activeLayout().docked;
50
50
  return focusTabWhere(root, (entry) => entry.slotId === slotId);
51
51
  }
52
52
  /**
@@ -54,7 +54,7 @@ export function focusTab(slotId) {
54
54
  * layout. Returns `true` if a matching tab was found and activated.
55
55
  */
56
56
  export function focusView(viewId) {
57
- const root = activeLayout();
57
+ const root = activeLayout().docked;
58
58
  return focusTabWhere(root, (entry) => entry.viewId === viewId);
59
59
  }
60
60
  /** Walk the tree looking for a tab entry that satisfies `pred`, activate it. */
@@ -90,7 +90,7 @@ export function expandChild(splitPath, childIndex) {
90
90
  return setCollapsed(splitPath, childIndex, false);
91
91
  }
92
92
  function setCollapsed(splitPath, childIndex, value) {
93
- const root = activeLayout();
93
+ const root = activeLayout().docked;
94
94
  const node = nodeAtPath(root, splitPath);
95
95
  if (!node || node.type !== 'split')
96
96
  return false;
@@ -115,7 +115,7 @@ function setCollapsed(splitPath, childIndex, value) {
115
115
  * the sole authority on tree mutations.
116
116
  */
117
117
  export async function closeTab(slotId) {
118
- const root = activeLayout();
118
+ const root = activeLayout().docked;
119
119
  const located = findTabBySlotId(root, slotId);
120
120
  if (!located)
121
121
  return false;
@@ -189,7 +189,7 @@ function findFirstSlotPath(node, path = []) {
189
189
  */
190
190
  export function dockIntoActiveLayout(entry) {
191
191
  var _a;
192
- const root = activeLayout();
192
+ const root = activeLayout().docked;
193
193
  // 1. Already present? Focus it.
194
194
  if (focusView((_a = entry.viewId) !== null && _a !== void 0 ? _a : ''))
195
195
  return true;
@@ -1,4 +1,4 @@
1
- import type { LayoutNode, SplitNode, TabsNode, SlotNode, TabEntry } from './types';
1
+ import type { LayoutNode, SplitNode, TabsNode, SlotNode, TabEntry, LayoutTree, TreeRootRef } from './types';
2
2
  /** A path down the tree: the list of child indices walked from the root. */
3
3
  export type LayoutPath = number[];
4
4
  /** A located tab: where it lives and which entry index inside its group. */
@@ -20,6 +20,19 @@ export declare function findSlotBySlotId(root: LayoutNode, slotId: string): {
20
20
  parent: SplitNode | null;
21
21
  index: number;
22
22
  } | null;
23
+ /** A located tab across all roots of a LayoutTree. */
24
+ export interface LocatedTabInTree {
25
+ root: TreeRootRef;
26
+ /** The docked-space located tab record from findTabBySlotId. */
27
+ located: LocatedTab;
28
+ }
29
+ /**
30
+ * Multi-root version of `findTabBySlotId`: searches the docked tree first,
31
+ * then each float's content in order. Returns the root reference along with
32
+ * the `LocatedTab` so drag commit can route its mutation to the right root.
33
+ * Returns null if the slot id is not present in any root.
34
+ */
35
+ export declare function findTabInTree(tree: LayoutTree, slotId: string): LocatedTabInTree | null;
23
36
  /**
24
37
  * Remove a tab from its current location, returning the removed entry
25
38
  * (or null if not found). The tabs group it was in may become empty
@@ -76,6 +76,23 @@ export function findSlotBySlotId(root, slotId) {
76
76
  };
77
77
  return walk(root, null, 0);
78
78
  }
79
+ /**
80
+ * Multi-root version of `findTabBySlotId`: searches the docked tree first,
81
+ * then each float's content in order. Returns the root reference along with
82
+ * the `LocatedTab` so drag commit can route its mutation to the right root.
83
+ * Returns null if the slot id is not present in any root.
84
+ */
85
+ export function findTabInTree(tree, slotId) {
86
+ const inDocked = findTabBySlotId(tree.docked, slotId);
87
+ if (inDocked)
88
+ return { root: { kind: 'docked' }, located: inDocked };
89
+ for (const f of tree.floats) {
90
+ const hit = findTabBySlotId(f.content, slotId);
91
+ if (hit)
92
+ return { root: { kind: 'float', floatId: f.id }, located: hit };
93
+ }
94
+ return null;
95
+ }
79
96
  // ---------- Tab removal ----------------------------------------------------
80
97
  /**
81
98
  * Remove a tab from its current location, returning the removed entry
@@ -0,0 +1 @@
1
+ export {};