sh3-core 0.19.1 → 0.19.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 (84) hide show
  1. package/dist/Sh3.svelte +3 -1
  2. package/dist/actions/menuBarModel.js +8 -0
  3. package/dist/actions/menuBarModel.test.js +61 -0
  4. package/dist/api.d.ts +4 -0
  5. package/dist/api.js +3 -0
  6. package/dist/app/admin/ApiKeysView.svelte +6 -5
  7. package/dist/app/store/PermissionConfirmModal.svelte +23 -0
  8. package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
  9. package/dist/app/store/StoreView.svelte +6 -1
  10. package/dist/chrome/CompactChrome.svelte +34 -1
  11. package/dist/chrome/CompactChrome.svelte.test.js +11 -6
  12. package/dist/chrome/FloatsSheet.svelte +236 -0
  13. package/dist/chrome/FloatsSheet.svelte.d.ts +7 -0
  14. package/dist/chrome/FloatsSheet.svelte.test.d.ts +1 -0
  15. package/dist/chrome/FloatsSheet.svelte.test.js +155 -0
  16. package/dist/env/client.d.ts +5 -4
  17. package/dist/env/client.js +11 -17
  18. package/dist/env/serverUrl.d.ts +2 -0
  19. package/dist/env/serverUrl.js +8 -0
  20. package/dist/gestures/index.d.ts +17 -0
  21. package/dist/gestures/index.js +27 -0
  22. package/dist/keys/client.js +6 -7
  23. package/dist/keys/revocation-bus.svelte.js +11 -1
  24. package/dist/layout/compact/CarouselTabs.svelte +150 -14
  25. package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
  26. package/dist/layout/compact/CompactRenderer.svelte +9 -3
  27. package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
  28. package/dist/layout/compact/derive.js +7 -16
  29. package/dist/layout/compact/derive.test.js +30 -9
  30. package/dist/layout/compact/rootStore.svelte.d.ts +20 -0
  31. package/dist/layout/compact/rootStore.svelte.js +59 -0
  32. package/dist/layout/compact/rootStore.svelte.test.d.ts +1 -0
  33. package/dist/layout/compact/rootStore.svelte.test.js +54 -0
  34. package/dist/layout/drag.svelte.js +16 -3
  35. package/dist/layout/floats.d.ts +27 -0
  36. package/dist/layout/floats.js +20 -0
  37. package/dist/layout/floats.test.js +34 -1
  38. package/dist/layout/inspection.d.ts +20 -9
  39. package/dist/layout/inspection.js +91 -13
  40. package/dist/layout/inspection.svelte.test.d.ts +1 -0
  41. package/dist/layout/inspection.svelte.test.js +163 -0
  42. package/dist/layout/store.schemaVersion.test.js +2 -2
  43. package/dist/layout/types.d.ts +11 -8
  44. package/dist/layout/types.js +1 -1
  45. package/dist/layout/types.test.js +2 -2
  46. package/dist/overlays/FloatFrame.svelte +93 -22
  47. package/dist/overlays/FloatLayer.svelte +12 -1
  48. package/dist/overlays/float.d.ts +7 -0
  49. package/dist/overlays/float.js +76 -6
  50. package/dist/overlays/float.test.js +170 -0
  51. package/dist/primitives/ResizableSplitter.svelte +42 -8
  52. package/dist/primitives/widgets/DocumentFilePicker.d.ts +25 -0
  53. package/dist/primitives/widgets/DocumentFilePicker.js +74 -0
  54. package/dist/primitives/widgets/DocumentFilePicker.svelte +144 -0
  55. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +18 -0
  56. package/dist/primitives/widgets/DocumentOpener.svelte +36 -0
  57. package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +17 -0
  58. package/dist/primitives/widgets/DocumentSaver.svelte +36 -0
  59. package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +17 -0
  60. package/dist/primitives/widgets/_DocumentBrowser.svelte +337 -0
  61. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +11 -0
  62. package/dist/registry/checkFetch.d.ts +6 -0
  63. package/dist/registry/checkFetch.js +23 -0
  64. package/dist/sh3/views/KeysAndPeers.svelte +4 -3
  65. package/dist/shards/activate-runtime.test.js +99 -1
  66. package/dist/shards/activate.svelte.js +12 -3
  67. package/dist/shards/registry.d.ts +8 -1
  68. package/dist/shards/registry.js +13 -2
  69. package/dist/shards/registry.test.js +25 -4
  70. package/dist/shards/types.d.ts +14 -1
  71. package/dist/shell-shard/ScrollbackView.svelte +145 -67
  72. package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
  73. package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
  74. package/dist/shell-shard/dispatch-gating.test.js +38 -2
  75. package/dist/shell-shard/dispatch.js +9 -1
  76. package/dist/shell-shard/registry-resolve.test.js +50 -0
  77. package/dist/shell-shard/registry.d.ts +2 -1
  78. package/dist/shell-shard/registry.js +12 -2
  79. package/dist/shell-shard/verbs/help.js +5 -4
  80. package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
  81. package/dist/verbs/types.d.ts +10 -5
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/package.json +1 -1
@@ -23,6 +23,33 @@ export declare function cascadePosition(existing: FloatEntry[], bounds: {
23
23
  };
24
24
  /** Stable, process-unique float id. Not cryptographic — just unique within a session. */
25
25
  export declare function generateFloatId(): string;
26
+ /**
27
+ * Pull a float's rect into the supplied viewport bounds. Used at bind
28
+ * time so a float persisted from a larger viewport doesn't render past
29
+ * the overlay root — Firefox in particular grows the parent's painted
30
+ * area to fit an off-screen abspos child, which visibly bleeds the
31
+ * docked grid (footer ends up below the viewport).
32
+ *
33
+ * Size is shrunk to fit but never below `minSize`; if `minSize` itself
34
+ * exceeds bounds, position is pinned to (0,0) and size stays at min.
35
+ * Position is then clamped so the frame fits within bounds.
36
+ */
37
+ export declare function clampFloatToViewport(rect: {
38
+ position: {
39
+ x: number;
40
+ y: number;
41
+ };
42
+ size: Size;
43
+ }, minSize: Size, bounds: {
44
+ w: number;
45
+ h: number;
46
+ }): {
47
+ position: {
48
+ x: number;
49
+ y: number;
50
+ };
51
+ size: Size;
52
+ };
26
53
  /**
27
54
  * True if a LayoutNode subtree contains no leaf slot with a bound viewId.
28
55
  * Used by the drag-commit auto-close invariant: when the last bound leaf
@@ -55,6 +55,26 @@ export function generateFloatId() {
55
55
  floatIdCounter += 1;
56
56
  return `float-${Date.now().toString(36)}-${floatIdCounter.toString(36)}`;
57
57
  }
58
+ /**
59
+ * Pull a float's rect into the supplied viewport bounds. Used at bind
60
+ * time so a float persisted from a larger viewport doesn't render past
61
+ * the overlay root — Firefox in particular grows the parent's painted
62
+ * area to fit an off-screen abspos child, which visibly bleeds the
63
+ * docked grid (footer ends up below the viewport).
64
+ *
65
+ * Size is shrunk to fit but never below `minSize`; if `minSize` itself
66
+ * exceeds bounds, position is pinned to (0,0) and size stays at min.
67
+ * Position is then clamped so the frame fits within bounds.
68
+ */
69
+ export function clampFloatToViewport(rect, minSize, bounds) {
70
+ const w = Math.max(minSize.w, Math.min(rect.size.w, bounds.w));
71
+ const h = Math.max(minSize.h, Math.min(rect.size.h, bounds.h));
72
+ const maxX = Math.max(0, bounds.w - w);
73
+ const maxY = Math.max(0, bounds.h - h);
74
+ const x = Math.max(0, Math.min(rect.position.x, maxX));
75
+ const y = Math.max(0, Math.min(rect.position.y, maxY));
76
+ return { position: { x, y }, size: { w, h } };
77
+ }
58
78
  /**
59
79
  * True if a LayoutNode subtree contains no leaf slot with a bound viewId.
60
80
  * Used by the drag-commit auto-close invariant: when the last bound leaf
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { computeMinSize, cascadePosition, isEmptyContent } from './floats';
2
+ import { computeMinSize, cascadePosition, isEmptyContent, clampFloatToViewport } from './floats';
3
3
  const slot = (slotId, viewId = 'v') => ({
4
4
  type: 'slot',
5
5
  slotId,
@@ -72,6 +72,39 @@ describe('cascadePosition', () => {
72
72
  expect(cascadePosition(existing, bounds)).toEqual({ x: 48, y: 48 });
73
73
  });
74
74
  });
75
+ describe('clampFloatToViewport', () => {
76
+ const min = { w: 120, h: 80 };
77
+ const bounds = { w: 1024, h: 768 };
78
+ it('returns the rect unchanged when fully inside bounds', () => {
79
+ const out = clampFloatToViewport({ position: { x: 100, y: 200 }, size: { w: 600, h: 400 } }, min, bounds);
80
+ expect(out).toEqual({ position: { x: 100, y: 200 }, size: { w: 600, h: 400 } });
81
+ });
82
+ it('pulls a float that extends past the right edge back inside', () => {
83
+ const out = clampFloatToViewport({ position: { x: 900, y: 50 }, size: { w: 600, h: 400 } }, min, bounds);
84
+ expect(out.position.x).toBe(bounds.w - 600);
85
+ expect(out.position.y).toBe(50);
86
+ expect(out.size).toEqual({ w: 600, h: 400 });
87
+ });
88
+ it('pulls a float that extends past the bottom edge back inside', () => {
89
+ const out = clampFloatToViewport({ position: { x: 50, y: 600 }, size: { w: 600, h: 400 } }, min, bounds);
90
+ expect(out.position.y).toBe(bounds.h - 400);
91
+ });
92
+ it('clamps negative position back to (0,0)', () => {
93
+ const out = clampFloatToViewport({ position: { x: -200, y: -50 }, size: { w: 600, h: 400 } }, min, bounds);
94
+ expect(out.position).toEqual({ x: 0, y: 0 });
95
+ });
96
+ it('shrinks size larger than bounds down to bounds (above min)', () => {
97
+ const out = clampFloatToViewport({ position: { x: 0, y: 0 }, size: { w: 4000, h: 3000 } }, min, bounds);
98
+ expect(out.size).toEqual({ w: bounds.w, h: bounds.h });
99
+ expect(out.position).toEqual({ x: 0, y: 0 });
100
+ });
101
+ it('never shrinks size below the supplied min, even when bounds < min', () => {
102
+ const tiny = { w: 80, h: 60 };
103
+ const out = clampFloatToViewport({ position: { x: 999, y: 999 }, size: { w: 600, h: 400 } }, min, tiny);
104
+ expect(out.size).toEqual(min);
105
+ expect(out.position).toEqual({ x: 0, y: 0 });
106
+ });
107
+ });
75
108
  describe('isEmptyContent', () => {
76
109
  it('true for a slot with null viewId', () => {
77
110
  expect(isEmptyContent({ type: 'slot', slotId: 's', viewId: null })).toBe(true);
@@ -69,19 +69,30 @@ export declare function popoutView(slotId: string): string | null;
69
69
  export declare function dockFloat(floatId: string): boolean;
70
70
  /**
71
71
  * Dock a view into the currently-rendered layout without caring which
72
- * root it is. Used by the Ctrl+` sh3 hotkey and other "just put it
73
- * somewhere sensible" callers. Policy:
72
+ * root it is. Used by the Ctrl+` sh3 hotkey, the `open <viewId>` verb,
73
+ * and other "just put it somewhere sensible" callers. Policy:
74
74
  *
75
75
  * 1. If a tab with the same `viewId` already exists, focus it and
76
76
  * return. Callers don't want a second instance of a singleton view
77
77
  * every time they hit the shortcut.
78
- * 2. Otherwise, append the entry to the first tabs group found
79
- * (`spliceIntoActiveLayout` semantics).
80
- * 3. If there's no tabs group (e.g. the home root is a single slot),
81
- * split the first slot leaf horizontally and put the entry on the
82
- * right. This is the "floating window" fallback described in
83
- * roadmap SH9 / DF3 when floating panels land, callers should
84
- * prefer that path for ephemeral docks.
78
+ * 2. Prefer a tabs group whose entries are explicitly flagged
79
+ * `role: 'body'` — that's the canonical "main content" target when
80
+ * authors marked it. This step matches user intent better than
81
+ * "first tabs group found" in layouts that pair a sidebar tabs
82
+ * group with a body tabs group.
83
+ * 3. Otherwise, prefer a standalone slot explicitly flagged
84
+ * `role: 'body'` split it horizontally and place the new entry
85
+ * on the right (same shape as the generic slot fallback below).
86
+ * 4. Otherwise, append to the first tabs group found.
87
+ * 5. Otherwise, split the first slot leaf horizontally. This is the
88
+ * "floating window" fallback described in roadmap SH9 / DF3.
89
+ *
90
+ * Steps 2-3 use a *strict* role check (`=== 'body'`, not resolveRole).
91
+ * An unmarked slot defaults to body via resolveRole, but treating every
92
+ * unmarked slot as a body anchor would make step 3 swallow every
93
+ * standalone slot in the tree before step 4 ever got a chance — which
94
+ * inverts the documented fallback order. Only explicit body markers
95
+ * promote a node above the generic fallback.
85
96
  *
86
97
  * Returns true if the view was focused or inserted, false if the layout
87
98
  * was empty or otherwise un-dockable.
@@ -16,6 +16,24 @@ import { activeLayout, getActiveRoot } from './store.svelte';
16
16
  import { nodeAtPath, findTabBySlotId, removeTabBySlotId, cleanupTree, splitNodeAtPath, locateSlotIn, } from './ops';
17
17
  import { getSlotHandle } from './slotHostPool.svelte';
18
18
  import { floatManager } from '../overlays/float';
19
+ import { viewportStore } from '../viewport/store.svelte';
20
+ import { compactRootStore } from './compact/rootStore.svelte';
21
+ /**
22
+ * In compact mode the user sees one root at a time. When focus lands on
23
+ * a slot in a float, swap the compact body root to that float before
24
+ * returning. When focus lands in the docked tree, snap back to docked.
25
+ * Desktop is unaffected.
26
+ */
27
+ function maybeSwapForCompact(located) {
28
+ if (viewportStore.current.class !== 'compact')
29
+ return;
30
+ if (located.kind === 'float') {
31
+ compactRootStore.setRoot({ kind: 'float', floatId: located.floatId });
32
+ }
33
+ else {
34
+ compactRootStore.reset();
35
+ }
36
+ }
19
37
  /**
20
38
  * Read-only snapshot of the currently-rendered layout tree. The return
21
39
  * value is the live object — callers MUST NOT mutate it directly;
@@ -48,8 +66,10 @@ export function spliceIntoActiveLayout(entry) {
48
66
  */
49
67
  export function focusTab(slotId) {
50
68
  const tree = activeLayout();
51
- if (focusTabWhere(tree.docked, (entry) => entry.slotId === slotId))
69
+ if (focusTabWhere(tree.docked, (entry) => entry.slotId === slotId)) {
70
+ maybeSwapForCompact({ kind: 'docked' });
52
71
  return true;
72
+ }
53
73
  return focusTabInFloats(tree, (entry) => entry.slotId === slotId);
54
74
  }
55
75
  /**
@@ -58,8 +78,10 @@ export function focusTab(slotId) {
58
78
  */
59
79
  export function focusView(viewId) {
60
80
  const tree = activeLayout();
61
- if (focusTabWhere(tree.docked, (entry) => entry.viewId === viewId))
81
+ if (focusTabWhere(tree.docked, (entry) => entry.viewId === viewId)) {
82
+ maybeSwapForCompact({ kind: 'docked' });
62
83
  return true;
84
+ }
63
85
  return focusTabInFloats(tree, (entry) => entry.viewId === viewId);
64
86
  }
65
87
  /** Walk the tree looking for a tab entry that satisfies `pred`, activate it. */
@@ -85,6 +107,7 @@ function focusTabInFloats(tree, pred) {
85
107
  for (const floatEntry of tree.floats) {
86
108
  if (focusTabWhere(floatEntry.content, pred)) {
87
109
  floatManager.focus(floatEntry.id);
110
+ maybeSwapForCompact({ kind: 'float', floatId: floatEntry.id });
88
111
  return true;
89
112
  }
90
113
  }
@@ -274,19 +297,30 @@ function findFirstSlotPath(node, path = []) {
274
297
  }
275
298
  /**
276
299
  * Dock a view into the currently-rendered layout without caring which
277
- * root it is. Used by the Ctrl+` sh3 hotkey and other "just put it
278
- * somewhere sensible" callers. Policy:
300
+ * root it is. Used by the Ctrl+` sh3 hotkey, the `open <viewId>` verb,
301
+ * and other "just put it somewhere sensible" callers. Policy:
279
302
  *
280
303
  * 1. If a tab with the same `viewId` already exists, focus it and
281
304
  * return. Callers don't want a second instance of a singleton view
282
305
  * every time they hit the shortcut.
283
- * 2. Otherwise, append the entry to the first tabs group found
284
- * (`spliceIntoActiveLayout` semantics).
285
- * 3. If there's no tabs group (e.g. the home root is a single slot),
286
- * split the first slot leaf horizontally and put the entry on the
287
- * right. This is the "floating window" fallback described in
288
- * roadmap SH9 / DF3 when floating panels land, callers should
289
- * prefer that path for ephemeral docks.
306
+ * 2. Prefer a tabs group whose entries are explicitly flagged
307
+ * `role: 'body'` — that's the canonical "main content" target when
308
+ * authors marked it. This step matches user intent better than
309
+ * "first tabs group found" in layouts that pair a sidebar tabs
310
+ * group with a body tabs group.
311
+ * 3. Otherwise, prefer a standalone slot explicitly flagged
312
+ * `role: 'body'` split it horizontally and place the new entry
313
+ * on the right (same shape as the generic slot fallback below).
314
+ * 4. Otherwise, append to the first tabs group found.
315
+ * 5. Otherwise, split the first slot leaf horizontally. This is the
316
+ * "floating window" fallback described in roadmap SH9 / DF3.
317
+ *
318
+ * Steps 2-3 use a *strict* role check (`=== 'body'`, not resolveRole).
319
+ * An unmarked slot defaults to body via resolveRole, but treating every
320
+ * unmarked slot as a body anchor would make step 3 swallow every
321
+ * standalone slot in the tree before step 4 ever got a chance — which
322
+ * inverts the documented fallback order. Only explicit body markers
323
+ * promote a node above the generic fallback.
290
324
  *
291
325
  * Returns true if the view was focused or inserted, false if the layout
292
326
  * was empty or otherwise un-dockable.
@@ -297,20 +331,64 @@ export function dockIntoActiveLayout(entry) {
297
331
  // 1. Already present? Focus it.
298
332
  if (focusView((_a = entry.viewId) !== null && _a !== void 0 ? _a : ''))
299
333
  return true;
300
- // 2. Existing tabs group wins.
334
+ // 2. A tabs group explicitly carrying body content wins.
335
+ const bodyTabs = findBodyTabsNode(root);
336
+ if (bodyTabs) {
337
+ bodyTabs.tabs.push(entry);
338
+ bodyTabs.activeTab = bodyTabs.tabs.length - 1;
339
+ return true;
340
+ }
341
+ // 3. A standalone slot explicitly marked as body is next.
342
+ const bodySlotPath = findBodySlotPath(root);
343
+ if (bodySlotPath) {
344
+ splitNodeAtPath(root, bodySlotPath, entry, 'right');
345
+ return true;
346
+ }
347
+ // 4. Existing tabs group wins (generic fallback).
301
348
  const tabs = findFirstTabsNode(root);
302
349
  if (tabs) {
303
350
  tabs.tabs.push(entry);
304
351
  tabs.activeTab = tabs.tabs.length - 1;
305
352
  return true;
306
353
  }
307
- // 3. Fallback: split the first valid slot leaf.
354
+ // 5. Fallback: split the first valid slot leaf.
308
355
  const slotPath = findFirstSlotPath(root);
309
356
  if (!slotPath)
310
357
  return false;
311
358
  splitNodeAtPath(root, slotPath, entry, 'right');
312
359
  return true;
313
360
  }
361
+ /**
362
+ * Find a tabs group explicitly flagged `role: 'body'`. Strict —
363
+ * defaulted body roles don't count, see the dockIntoActiveLayout
364
+ * docblock for why.
365
+ */
366
+ function findBodyTabsNode(node) {
367
+ if (node.type === 'tabs') {
368
+ return node.role === 'body' ? node : null;
369
+ }
370
+ if (node.type === 'split') {
371
+ for (const c of node.children) {
372
+ const hit = findBodyTabsNode(c);
373
+ if (hit)
374
+ return hit;
375
+ }
376
+ }
377
+ return null;
378
+ }
379
+ /** Path to the first slot leaf explicitly flagged `role: 'body'`. */
380
+ function findBodySlotPath(node, path = []) {
381
+ if (node.type === 'slot')
382
+ return node.role === 'body' ? path : null;
383
+ if (node.type === 'split') {
384
+ for (let i = 0; i < node.children.length; i++) {
385
+ const hit = findBodySlotPath(node.children[i], [...path, i]);
386
+ if (hit)
387
+ return hit;
388
+ }
389
+ }
390
+ return null;
391
+ }
314
392
  /**
315
393
  * Find which root a slot currently lives under in the active layout.
316
394
  * Returns `{ kind: 'docked' }` when the slot is anywhere in the docked
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,163 @@
1
+ /*
2
+ * dockIntoActiveLayout policy tests — verify the body-role preference
3
+ * lands before generic fallbacks. The `open <viewId>` verb routes
4
+ * through this function; getting the policy wrong means views land in
5
+ * sidebar tabs groups instead of body ones in apps that mark both.
6
+ */
7
+ import { describe, it, expect, beforeEach } from 'vitest';
8
+ import { flushSync } from 'svelte';
9
+ import { attachApp, switchToApp, __resetLayoutStoreForTest, layoutStore, } from './store.svelte';
10
+ import { dockIntoActiveLayout } from './inspection';
11
+ let appCounter = 0;
12
+ function makeApp(initialLayout) {
13
+ // Unique id per call so workspace-zone storage from one test doesn't
14
+ // adapt-and-override another test's initialLayout via attachApp's
15
+ // version-gate path.
16
+ return {
17
+ manifest: { id: `test-app-${++appCounter}`, layoutVersion: 1 },
18
+ initialLayout,
19
+ };
20
+ }
21
+ function nextEntry(viewId) {
22
+ return { slotId: `s:${viewId}:${Math.random()}`, viewId, label: viewId };
23
+ }
24
+ describe('dockIntoActiveLayout — body-role preference', () => {
25
+ beforeEach(() => {
26
+ __resetLayoutStoreForTest();
27
+ });
28
+ it('appends into a tabs group flagged as body when one exists alongside a non-body tabs group', () => {
29
+ attachApp(makeApp({
30
+ type: 'split', direction: 'horizontal', sizes: [0.3, 0.7],
31
+ children: [
32
+ {
33
+ type: 'tabs',
34
+ role: 'sidebar',
35
+ tabs: [{ slotId: 'sb-1', viewId: 'sb:explorer', label: 'Explorer' }],
36
+ activeTab: 0,
37
+ },
38
+ {
39
+ type: 'tabs',
40
+ role: 'body',
41
+ tabs: [{ slotId: 'b-1', viewId: 'body:editor', label: 'Editor' }],
42
+ activeTab: 0,
43
+ },
44
+ ],
45
+ }));
46
+ switchToApp();
47
+ flushSync();
48
+ expect(dockIntoActiveLayout(nextEntry('body:newdoc'))).toBe(true);
49
+ const root = layoutStore.root;
50
+ const sidebar = root.children[0];
51
+ const body = root.children[1];
52
+ expect(sidebar.tabs.map((t) => t.viewId)).toEqual(['sb:explorer']);
53
+ expect(body.tabs.map((t) => t.viewId)).toEqual(['body:editor', 'body:newdoc']);
54
+ expect(body.activeTab).toBe(1);
55
+ });
56
+ it('splits a standalone slot flagged as body when no body tabs group exists', () => {
57
+ attachApp(makeApp({
58
+ type: 'split', direction: 'horizontal', sizes: [0.3, 0.7],
59
+ children: [
60
+ { type: 'slot', slotId: 'sb', viewId: 'sb:explorer', role: 'sidebar' },
61
+ { type: 'slot', slotId: 'main', viewId: 'body:editor', role: 'body' },
62
+ ],
63
+ }));
64
+ switchToApp();
65
+ flushSync();
66
+ expect(dockIntoActiveLayout(nextEntry('body:newdoc'))).toBe(true);
67
+ const root = layoutStore.root;
68
+ // The body slot got split — its position in the parent is now a split node.
69
+ const right = root.children[1];
70
+ expect(right.type).toBe('split');
71
+ });
72
+ it('falls back to the first tabs group when no body-flagged target exists', () => {
73
+ attachApp(makeApp({
74
+ type: 'tabs',
75
+ tabs: [{ slotId: 'a', viewId: 'view:a', label: 'A' }],
76
+ activeTab: 0,
77
+ }));
78
+ switchToApp();
79
+ flushSync();
80
+ expect(dockIntoActiveLayout(nextEntry('view:b'))).toBe(true);
81
+ const root = layoutStore.root;
82
+ expect(root.tabs.map((t) => t.viewId)).toEqual(['view:a', 'view:b']);
83
+ expect(root.activeTab).toBe(1);
84
+ });
85
+ it('does NOT prefer a tabs group whose entries are only defaulted-to-body (strict role check)', () => {
86
+ // The "first tabs group" already wins the generic fallback in this
87
+ // shape — the assertion here is that body-preference doesn't kick in
88
+ // (which it would if we used resolveRole and treated unset as body)
89
+ // and accidentally promote a sibling tabs group above the first one.
90
+ attachApp(makeApp({
91
+ type: 'split', direction: 'horizontal', sizes: [0.5, 0.5],
92
+ children: [
93
+ {
94
+ type: 'tabs',
95
+ tabs: [{ slotId: 'l', viewId: 'view:l', label: 'L' }], // role unset
96
+ activeTab: 0,
97
+ },
98
+ {
99
+ type: 'tabs',
100
+ tabs: [{ slotId: 'r', viewId: 'view:r', label: 'R' }], // role unset
101
+ activeTab: 0,
102
+ },
103
+ ],
104
+ }));
105
+ switchToApp();
106
+ flushSync();
107
+ expect(dockIntoActiveLayout(nextEntry('view:new'))).toBe(true);
108
+ const root = layoutStore.root;
109
+ const left = root.children[0];
110
+ const right = root.children[1];
111
+ expect(left.tabs.map((t) => t.viewId)).toEqual(['view:l', 'view:new']);
112
+ expect(right.tabs.map((t) => t.viewId)).toEqual(['view:r']);
113
+ });
114
+ });
115
+ // ---------------------------------------------------------------------------
116
+ // Compact body-root swap on focusView / focusTab
117
+ // ---------------------------------------------------------------------------
118
+ import { compactRootStore, __resetCompactRootStoreForTest, } from './compact/rootStore.svelte';
119
+ import { viewportStore } from '../viewport/store.svelte';
120
+ import { focusView } from './inspection';
121
+ import { floatManager, __resetFloatManagerForTest, bindFloatStore, } from '../overlays/float';
122
+ describe('focusView — compact body-root swap', () => {
123
+ beforeEach(() => {
124
+ __resetLayoutStoreForTest();
125
+ __resetCompactRootStoreForTest();
126
+ __resetFloatManagerForTest();
127
+ viewportStore.override(null);
128
+ bindFloatStore(layoutStore.floats, () => ({ w: 1024, h: 768 }));
129
+ });
130
+ it('compact: focusView in a float swaps body root before activating', () => {
131
+ viewportStore.override('compact');
132
+ const id = floatManager.open('view:in-float', { title: 'In Float' });
133
+ // open() in compact already auto-switches; reset so we can see the swap.
134
+ compactRootStore.reset();
135
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
136
+ const ok = focusView('view:in-float');
137
+ expect(ok).toBe(true);
138
+ expect(compactRootStore.current).toEqual({ kind: 'float', floatId: id });
139
+ viewportStore.override(null);
140
+ });
141
+ it('compact: focusView for a docked tab resets body root', () => {
142
+ viewportStore.override('compact');
143
+ layoutStore.tree.docked = {
144
+ type: 'tabs',
145
+ tabs: [{ slotId: 's-d', viewId: 'view:docked', label: 'Docked' }],
146
+ activeTab: 0,
147
+ };
148
+ floatManager.open('view:other');
149
+ expect(compactRootStore.current.kind).toBe('float');
150
+ const ok = focusView('view:docked');
151
+ expect(ok).toBe(true);
152
+ expect(compactRootStore.current).toEqual({ kind: 'docked' });
153
+ viewportStore.override(null);
154
+ });
155
+ it('desktop: focusView does not touch the compact body root', () => {
156
+ viewportStore.override('desktop');
157
+ floatManager.open('view:any');
158
+ const before = compactRootStore.current;
159
+ focusView('view:any');
160
+ expect(compactRootStore.current).toEqual(before);
161
+ viewportStore.override(null);
162
+ });
163
+ });
@@ -29,7 +29,7 @@ describe('layout schema v4 → v5 backward compatibility', () => {
29
29
  expect(slotA.role).toBeUndefined();
30
30
  expect(v4Blob.drawers).toBeUndefined();
31
31
  });
32
- it('LAYOUT_SCHEMA_VERSION is 6', () => {
33
- expect(LAYOUT_SCHEMA_VERSION).toBe(6);
32
+ it('LAYOUT_SCHEMA_VERSION is 7', () => {
33
+ expect(LAYOUT_SCHEMA_VERSION).toBe(7);
34
34
  });
35
35
  });
@@ -5,8 +5,9 @@ export type SplitDirection = 'horizontal' | 'vertical';
5
5
  * viewports to derive a compact rendering (sidebars/inspectors lift into
6
6
  * drawer surfaces, body slots fill the page).
7
7
  *
8
- * Default `'body'`. Authored on a slot or tab entry; if unset, falls back
9
- * to the view's `defaultRole` (registered via the shard contract). See
8
+ * Default `'body'`. Authored on a slot node or a tabs node (one role
9
+ * per tabs group, applied to every tab); if unset, falls back to the
10
+ * view's `defaultRole` (registered via the shard contract). See
10
11
  * `layout/compact/resolveRole.ts`.
11
12
  */
12
13
  export type SlotRole = 'body' | 'sidebar' | 'inspector';
@@ -56,11 +57,6 @@ export interface TabEntry {
56
57
  label: string;
57
58
  /** Optional icon hint (not yet rendered in phase 8). */
58
59
  icon?: string;
59
- /**
60
- * Slot-role hint for compact rendering. Default `'body'` via
61
- * `resolveRole(slot, viewDefault)`. Inert on desktop.
62
- */
63
- role?: SlotRole;
64
60
  /**
65
61
  * Caller-supplied instance data, threaded to `MountContext.meta`.
66
62
  * Ephemeral — not serialized with the layout tree.
@@ -97,6 +93,13 @@ export interface TabsNode {
97
93
  * Inert when not rendered as a carousel.
98
94
  */
99
95
  wrap?: boolean;
96
+ /**
97
+ * Role hint for the whole tab group in compact rendering. Default
98
+ * `'body'` via `resolveRole(node, viewDefault)`. Inert on desktop.
99
+ * Applies to every tab in the group — mixed-role tab groups are not
100
+ * representable by design.
101
+ */
102
+ role?: SlotRole;
100
103
  }
101
104
  /**
102
105
  * A leaf layout node that holds a single mounted view. `slotId` is the stable
@@ -215,7 +218,7 @@ export type TreeRootRef = {
215
218
  * the default tree takes over — phase 7 deliberately does not ship a
216
219
  * migration framework, only the hook for one.
217
220
  */
218
- export declare const LAYOUT_SCHEMA_VERSION = 6;
221
+ export declare const LAYOUT_SCHEMA_VERSION = 7;
219
222
  /**
220
223
  * The wire shape of a persisted layout in the workspace state zone.
221
224
  * One blob per sh3 (or per program, once per-program layouts exist);
@@ -22,4 +22,4 @@
22
22
  * the default tree takes over — phase 7 deliberately does not ship a
23
23
  * migration framework, only the hook for one.
24
24
  */
25
- export const LAYOUT_SCHEMA_VERSION = 6;
25
+ export const LAYOUT_SCHEMA_VERSION = 7;
@@ -20,7 +20,7 @@ describe('TabsNode.wrap', () => {
20
20
  });
21
21
  });
22
22
  describe('LAYOUT_SCHEMA_VERSION', () => {
23
- it('is 6 (bumped for TabsNode.wrap)', () => {
24
- expect(LAYOUT_SCHEMA_VERSION).toBe(6);
23
+ it('is 7 (bumped: role moved from TabEntry to TabsNode)', () => {
24
+ expect(LAYOUT_SCHEMA_VERSION).toBe(7);
25
25
  });
26
26
  });