sh3-core 0.19.0 → 0.19.3

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 (58) 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/app/admin/ApiKeysView.svelte +6 -5
  5. package/dist/app/store/PermissionConfirmModal.svelte +23 -0
  6. package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
  7. package/dist/app/store/StoreView.svelte +6 -1
  8. package/dist/chrome/CompactChrome.svelte.test.js +7 -4
  9. package/dist/env/client.d.ts +5 -4
  10. package/dist/env/client.js +11 -17
  11. package/dist/env/serverUrl.d.ts +2 -0
  12. package/dist/env/serverUrl.js +8 -0
  13. package/dist/gestures/gestureRegistry.test.js +1 -0
  14. package/dist/gestures/index.d.ts +17 -0
  15. package/dist/gestures/index.js +27 -0
  16. package/dist/keys/client.js +6 -7
  17. package/dist/keys/revocation-bus.svelte.js +11 -1
  18. package/dist/layout/compact/CarouselTabs.svelte +152 -15
  19. package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
  20. package/dist/layout/compact/CompactRenderer.svelte +1 -1
  21. package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
  22. package/dist/layout/compact/derive.js +7 -16
  23. package/dist/layout/compact/derive.test.js +30 -9
  24. package/dist/layout/drag.svelte.js +16 -3
  25. package/dist/layout/inspection.d.ts +20 -9
  26. package/dist/layout/inspection.js +66 -11
  27. package/dist/layout/inspection.svelte.test.d.ts +1 -0
  28. package/dist/layout/inspection.svelte.test.js +114 -0
  29. package/dist/layout/store.schemaVersion.test.js +2 -2
  30. package/dist/layout/types.d.ts +11 -8
  31. package/dist/layout/types.js +1 -1
  32. package/dist/layout/types.test.js +2 -2
  33. package/dist/overlays/FloatFrame.svelte +93 -22
  34. package/dist/primitives/ResizableSplitter.svelte +42 -8
  35. package/dist/registry/checkFetch.d.ts +6 -0
  36. package/dist/registry/checkFetch.js +23 -0
  37. package/dist/sh3/views/KeysAndPeers.svelte +4 -3
  38. package/dist/shards/activate-runtime.test.js +99 -1
  39. package/dist/shards/activate.svelte.js +20 -5
  40. package/dist/shards/ctx-fetch.test.js +70 -0
  41. package/dist/shards/registry.d.ts +8 -1
  42. package/dist/shards/registry.js +13 -2
  43. package/dist/shards/registry.test.js +25 -4
  44. package/dist/shards/types.d.ts +30 -1
  45. package/dist/shell-shard/ScrollbackView.svelte +145 -67
  46. package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
  47. package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
  48. package/dist/shell-shard/dispatch-gating.test.js +38 -2
  49. package/dist/shell-shard/dispatch.js +9 -1
  50. package/dist/shell-shard/registry-resolve.test.js +50 -0
  51. package/dist/shell-shard/registry.d.ts +2 -1
  52. package/dist/shell-shard/registry.js +12 -2
  53. package/dist/shell-shard/verbs/help.js +5 -4
  54. package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
  55. package/dist/verbs/types.d.ts +10 -5
  56. package/dist/version.d.ts +1 -1
  57. package/dist/version.js +1 -1
  58. package/package.json +1 -1
@@ -128,26 +128,26 @@ describe('derive', () => {
128
128
  describe('tabs nodes', () => {
129
129
  it('tabs of body slots stay in body root', () => {
130
130
  const tree = {
131
- type: 'tabs', activeTab: 0,
131
+ type: 'tabs', activeTab: 0, role: 'body',
132
132
  tabs: [
133
- { slotId: 't1', viewId: 'v:t1', label: 'Tab 1', role: 'body' },
134
- { slotId: 't2', viewId: 'v:t2', label: 'Tab 2', role: 'body' },
133
+ { slotId: 't1', viewId: 'v:t1', label: 'Tab 1' },
134
+ { slotId: 't2', viewId: 'v:t2', label: 'Tab 2' },
135
135
  ],
136
136
  };
137
137
  const result = derive(tree);
138
138
  expect(result.bodyRoot.type).toBe('tabs');
139
139
  });
140
- it('tabs with mixed roles split body tabs from sidebar slots', () => {
140
+ it('body tabs node alongside sidebar slot stays in body root', () => {
141
141
  var _a;
142
142
  const tree = {
143
143
  type: 'split', direction: 'horizontal', sizes: [0.2, 0.8],
144
144
  children: [
145
145
  { type: 'slot', slotId: 'sb', viewId: 'v:sb', role: 'sidebar' },
146
146
  {
147
- type: 'tabs', activeTab: 0,
147
+ type: 'tabs', activeTab: 0, role: 'body',
148
148
  tabs: [
149
- { slotId: 't1', viewId: 'v:t1', label: 'Tab 1', role: 'body' },
150
- { slotId: 't2', viewId: 'v:t2', label: 'Tab 2', role: 'body' },
149
+ { slotId: 't1', viewId: 'v:t1', label: 'Tab 1' },
150
+ { slotId: 't2', viewId: 'v:t2', label: 'Tab 2' },
151
151
  ],
152
152
  },
153
153
  ],
@@ -156,13 +156,33 @@ describe('derive', () => {
156
156
  expect(result.bodyRoot.type).toBe('tabs');
157
157
  expect((_a = result.drawers.left) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['sb']);
158
158
  });
159
+ it('sidebar-tagged tabs node lifts all tabs into a drawer', () => {
160
+ var _a;
161
+ const tree = {
162
+ type: 'split', direction: 'horizontal', sizes: [0.3, 0.7],
163
+ children: [
164
+ {
165
+ type: 'tabs', activeTab: 0, role: 'sidebar',
166
+ tabs: [
167
+ { slotId: 'sb-1', viewId: 'v:sb-1', label: 'Files' },
168
+ { slotId: 'sb-2', viewId: 'v:sb-2', label: 'Search' },
169
+ ],
170
+ },
171
+ { type: 'slot', slotId: 'body', viewId: 'v:body', role: 'body' },
172
+ ],
173
+ };
174
+ const result = derive(tree);
175
+ expect((_a = result.drawers.left) === null || _a === void 0 ? void 0 : _a.slots.map((s) => s.slotId)).toEqual(['sb-1', 'sb-2']);
176
+ expect(result.bodyRoot.slotId).toBe('body');
177
+ });
159
178
  });
160
179
  describe('carousels', () => {
161
180
  it('includes a carousels Map in the output', () => {
162
181
  var _a;
163
182
  const tree = {
164
183
  type: 'tabs',
165
- tabs: [{ slotId: 't0', viewId: null, label: 'Only', role: 'body' }],
184
+ role: 'body',
185
+ tabs: [{ slotId: 't0', viewId: null, label: 'Only' }],
166
186
  activeTab: 0,
167
187
  };
168
188
  const out = derive(tree);
@@ -184,7 +204,8 @@ describe('derive', () => {
184
204
  { type: 'slot', slotId: 'sb', viewId: null, role: 'sidebar' },
185
205
  {
186
206
  type: 'tabs',
187
- tabs: [{ slotId: 't0', viewId: null, label: 'Body Tab', role: 'body' }],
207
+ role: 'body',
208
+ tabs: [{ slotId: 't0', viewId: null, label: 'Body Tab' }],
188
209
  activeTab: 0,
189
210
  },
190
211
  ],
@@ -44,7 +44,7 @@ import { cleanupTree, insertTabIntoTabs, moveTabWithinTabs, removeTabBySlotId, s
44
44
  import { layoutStore } from './store.svelte';
45
45
  import { isEmptyContent } from './floats';
46
46
  import { claim, revoke } from '../gestures/pointerClaim';
47
- import { ancestorCount } from '../gestures';
47
+ import { ancestorCount, logGesture } from '../gestures';
48
48
  export const dragState = $state({
49
49
  phase: 'idle',
50
50
  source: null,
@@ -116,6 +116,8 @@ function removeGlobalListeners() {
116
116
  delete document.body.dataset.dragging;
117
117
  }
118
118
  function onPointerMove(e) {
119
+ if (e.pointerId !== activeDragPointerId)
120
+ return;
119
121
  dragState.pointerX = e.clientX;
120
122
  dragState.pointerY = e.clientY;
121
123
  if (dragState.phase === 'pending') {
@@ -126,7 +128,9 @@ function onPointerMove(e) {
126
128
  }
127
129
  }
128
130
  }
129
- function onPointerUp(_e) {
131
+ function onPointerUp(e) {
132
+ if (e.pointerId !== activeDragPointerId)
133
+ return;
130
134
  const wasDragging = dragState.phase === 'dragging';
131
135
  if (wasDragging) {
132
136
  commit();
@@ -137,7 +141,16 @@ function onPointerUp(_e) {
137
141
  }
138
142
  teardown();
139
143
  }
140
- function onPointerCancel(_e) {
144
+ function onPointerCancel(e) {
145
+ // Filter by pointer id. Without this, any pointercancel on the window
146
+ // (palm contact, ghost touch, stylus cancellation while a finger drag
147
+ // is active) would tear down a legitimate tab drag — same touch-only
148
+ // auto-release class as in carousel / floatframe / splitter.
149
+ if (e.pointerId !== activeDragPointerId) {
150
+ logGesture('tabdrag:cancel-other-id', e, { activeDragPointerId });
151
+ return;
152
+ }
153
+ logGesture('tabdrag:cancel-our-id', e, { activeDragPointerId });
141
154
  teardown();
142
155
  }
143
156
  function rootNode(ref) {
@@ -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.
@@ -274,19 +274,30 @@ function findFirstSlotPath(node, path = []) {
274
274
  }
275
275
  /**
276
276
  * 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:
277
+ * root it is. Used by the Ctrl+` sh3 hotkey, the `open <viewId>` verb,
278
+ * and other "just put it somewhere sensible" callers. Policy:
279
279
  *
280
280
  * 1. If a tab with the same `viewId` already exists, focus it and
281
281
  * return. Callers don't want a second instance of a singleton view
282
282
  * 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.
283
+ * 2. Prefer a tabs group whose entries are explicitly flagged
284
+ * `role: 'body'` — that's the canonical "main content" target when
285
+ * authors marked it. This step matches user intent better than
286
+ * "first tabs group found" in layouts that pair a sidebar tabs
287
+ * group with a body tabs group.
288
+ * 3. Otherwise, prefer a standalone slot explicitly flagged
289
+ * `role: 'body'` split it horizontally and place the new entry
290
+ * on the right (same shape as the generic slot fallback below).
291
+ * 4. Otherwise, append to the first tabs group found.
292
+ * 5. Otherwise, split the first slot leaf horizontally. This is the
293
+ * "floating window" fallback described in roadmap SH9 / DF3.
294
+ *
295
+ * Steps 2-3 use a *strict* role check (`=== 'body'`, not resolveRole).
296
+ * An unmarked slot defaults to body via resolveRole, but treating every
297
+ * unmarked slot as a body anchor would make step 3 swallow every
298
+ * standalone slot in the tree before step 4 ever got a chance — which
299
+ * inverts the documented fallback order. Only explicit body markers
300
+ * promote a node above the generic fallback.
290
301
  *
291
302
  * Returns true if the view was focused or inserted, false if the layout
292
303
  * was empty or otherwise un-dockable.
@@ -297,20 +308,64 @@ export function dockIntoActiveLayout(entry) {
297
308
  // 1. Already present? Focus it.
298
309
  if (focusView((_a = entry.viewId) !== null && _a !== void 0 ? _a : ''))
299
310
  return true;
300
- // 2. Existing tabs group wins.
311
+ // 2. A tabs group explicitly carrying body content wins.
312
+ const bodyTabs = findBodyTabsNode(root);
313
+ if (bodyTabs) {
314
+ bodyTabs.tabs.push(entry);
315
+ bodyTabs.activeTab = bodyTabs.tabs.length - 1;
316
+ return true;
317
+ }
318
+ // 3. A standalone slot explicitly marked as body is next.
319
+ const bodySlotPath = findBodySlotPath(root);
320
+ if (bodySlotPath) {
321
+ splitNodeAtPath(root, bodySlotPath, entry, 'right');
322
+ return true;
323
+ }
324
+ // 4. Existing tabs group wins (generic fallback).
301
325
  const tabs = findFirstTabsNode(root);
302
326
  if (tabs) {
303
327
  tabs.tabs.push(entry);
304
328
  tabs.activeTab = tabs.tabs.length - 1;
305
329
  return true;
306
330
  }
307
- // 3. Fallback: split the first valid slot leaf.
331
+ // 5. Fallback: split the first valid slot leaf.
308
332
  const slotPath = findFirstSlotPath(root);
309
333
  if (!slotPath)
310
334
  return false;
311
335
  splitNodeAtPath(root, slotPath, entry, 'right');
312
336
  return true;
313
337
  }
338
+ /**
339
+ * Find a tabs group explicitly flagged `role: 'body'`. Strict —
340
+ * defaulted body roles don't count, see the dockIntoActiveLayout
341
+ * docblock for why.
342
+ */
343
+ function findBodyTabsNode(node) {
344
+ if (node.type === 'tabs') {
345
+ return node.role === 'body' ? node : null;
346
+ }
347
+ if (node.type === 'split') {
348
+ for (const c of node.children) {
349
+ const hit = findBodyTabsNode(c);
350
+ if (hit)
351
+ return hit;
352
+ }
353
+ }
354
+ return null;
355
+ }
356
+ /** Path to the first slot leaf explicitly flagged `role: 'body'`. */
357
+ function findBodySlotPath(node, path = []) {
358
+ if (node.type === 'slot')
359
+ return node.role === 'body' ? path : null;
360
+ if (node.type === 'split') {
361
+ for (let i = 0; i < node.children.length; i++) {
362
+ const hit = findBodySlotPath(node.children[i], [...path, i]);
363
+ if (hit)
364
+ return hit;
365
+ }
366
+ }
367
+ return null;
368
+ }
314
369
  /**
315
370
  * Find which root a slot currently lives under in the active layout.
316
371
  * Returns `{ kind: 'docked' }` when the slot is anywhere in the docked
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,114 @@
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
+ });
@@ -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
  });