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
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { findTabInTree } from './ops';
3
+ describe('findTabInTree', () => {
4
+ const tree = {
5
+ docked: {
6
+ type: 'tabs',
7
+ tabs: [{ slotId: 'docked-a', viewId: 'v', label: 'A' }],
8
+ activeTab: 0,
9
+ },
10
+ floats: [
11
+ {
12
+ id: 'float-1',
13
+ content: {
14
+ type: 'tabs',
15
+ tabs: [{ slotId: 'float-a', viewId: 'v', label: 'A' }],
16
+ activeTab: 0,
17
+ },
18
+ position: { x: 0, y: 0 },
19
+ size: { w: 600, h: 400 },
20
+ },
21
+ ],
22
+ };
23
+ it('finds a tab in the docked tree', () => {
24
+ const hit = findTabInTree(tree, 'docked-a');
25
+ expect(hit).not.toBeNull();
26
+ expect(hit.root).toEqual({ kind: 'docked' });
27
+ });
28
+ it('finds a tab inside a float and reports the float id', () => {
29
+ const hit = findTabInTree(tree, 'float-a');
30
+ expect(hit).not.toBeNull();
31
+ expect(hit.root).toEqual({ kind: 'float', floatId: 'float-1' });
32
+ });
33
+ it('returns null when the slot is not found', () => {
34
+ expect(findTabInTree(tree, 'nonexistent')).toBeNull();
35
+ });
36
+ });
@@ -0,0 +1,2 @@
1
+ import type { LayoutNode, LayoutTree, LayoutPreset, CanonicalPreset } from './types';
2
+ export declare function normalizeInitialLayout(input: LayoutNode | LayoutTree | LayoutPreset[]): CanonicalPreset[];
@@ -0,0 +1,49 @@
1
+ /*
2
+ * Initial layout normalization — converts the three accepted input
3
+ * shapes (bare LayoutNode, LayoutTree, LayoutPreset[]) into a canonical
4
+ * CanonicalPreset[] the framework operates on. The ergonomic `tree`
5
+ * field on LayoutPreset is discarded after normalization in favor of
6
+ * `variants.default`. Non-default variant keys are preserved untouched
7
+ * for the rescoped DF10 selection policy.
8
+ */
9
+ function isLayoutNode(x) {
10
+ if (!x || typeof x !== 'object')
11
+ return false;
12
+ const t = x.type;
13
+ return t === 'split' || t === 'tabs' || t === 'slot';
14
+ }
15
+ function isLayoutTree(x) {
16
+ if (!x || typeof x !== 'object')
17
+ return false;
18
+ const o = x;
19
+ return isLayoutNode(o.docked) && Array.isArray(o.floats);
20
+ }
21
+ function wrapNodeAsTree(node) {
22
+ return { docked: node, floats: [] };
23
+ }
24
+ function canonicalizePreset(p) {
25
+ const variants = {};
26
+ if (p.tree)
27
+ variants.default = p.tree;
28
+ if (p.variants) {
29
+ for (const key of Object.keys(p.variants)) {
30
+ variants[key] = p.variants[key];
31
+ }
32
+ }
33
+ if (!variants.default) {
34
+ throw new Error(`LayoutPreset "${p.name}" must provide either 'tree' or 'variants.default'`);
35
+ }
36
+ return { name: p.name, variants };
37
+ }
38
+ export function normalizeInitialLayout(input) {
39
+ if (Array.isArray(input)) {
40
+ return input.map(canonicalizePreset);
41
+ }
42
+ if (isLayoutTree(input)) {
43
+ return [{ name: 'default', variants: { default: input } }];
44
+ }
45
+ if (isLayoutNode(input)) {
46
+ return [{ name: 'default', variants: { default: wrapNodeAsTree(input) } }];
47
+ }
48
+ throw new Error('normalizeInitialLayout: input is not a LayoutNode, LayoutTree, or LayoutPreset[]');
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { normalizeInitialLayout } from './presets';
3
+ const leafNode = { type: 'slot', slotId: 's1', viewId: 'v1' };
4
+ describe('normalizeInitialLayout', () => {
5
+ it('wraps a bare LayoutNode as a single default preset with empty floats', () => {
6
+ const result = normalizeInitialLayout(leafNode);
7
+ expect(result).toEqual([
8
+ {
9
+ name: 'default',
10
+ variants: {
11
+ default: { docked: leafNode, floats: [] },
12
+ },
13
+ },
14
+ ]);
15
+ });
16
+ it('passes a LayoutTree through as a single default preset', () => {
17
+ const tree = { docked: leafNode, floats: [] };
18
+ const result = normalizeInitialLayout(tree);
19
+ expect(result).toEqual([
20
+ {
21
+ name: 'default',
22
+ variants: { default: tree },
23
+ },
24
+ ]);
25
+ });
26
+ it('canonicalizes a preset list, using tree as default and preserving variants', () => {
27
+ const authorTree = { docked: leafNode, floats: [] };
28
+ const companionTree = {
29
+ docked: { type: 'slot', slotId: 's2', viewId: 'v2' },
30
+ floats: [],
31
+ };
32
+ const presets = [
33
+ {
34
+ name: 'author',
35
+ tree: authorTree,
36
+ variants: { companion: companionTree },
37
+ },
38
+ ];
39
+ const result = normalizeInitialLayout(presets);
40
+ expect(result).toEqual([
41
+ {
42
+ name: 'author',
43
+ variants: {
44
+ default: authorTree,
45
+ companion: companionTree,
46
+ },
47
+ },
48
+ ]);
49
+ });
50
+ it('accepts a preset with only variants (no tree shortcut)', () => {
51
+ const tree = { docked: leafNode, floats: [] };
52
+ const presets = [{ name: 'x', variants: { default: tree } }];
53
+ const result = normalizeInitialLayout(presets);
54
+ expect(result).toEqual([{ name: 'x', variants: { default: tree } }]);
55
+ });
56
+ it('throws if a preset has neither tree nor variants.default', () => {
57
+ const bad = [{ name: 'broken', variants: { companion: { docked: leafNode, floats: [] } } }];
58
+ expect(() => normalizeInitialLayout(bad)).toThrow(/must provide either 'tree' or 'variants.default'/);
59
+ });
60
+ it('when a preset has both tree and variants.default, variants.default wins', () => {
61
+ const fromTree = { docked: leafNode, floats: [] };
62
+ const fromVariant = {
63
+ docked: { type: 'slot', slotId: 's3', viewId: 'v3' },
64
+ floats: [],
65
+ };
66
+ const result = normalizeInitialLayout([
67
+ { name: 'x', tree: fromTree, variants: { default: fromVariant } },
68
+ ]);
69
+ expect(result[0].variants.default).toBe(fromVariant);
70
+ });
71
+ });
@@ -1,4 +1,4 @@
1
- import type { LayoutNode } from './types';
1
+ import type { LayoutNode, LayoutTree, FloatEntry } from './types';
2
2
  import type { App } from '../apps/types';
3
3
  /**
4
4
  * Attach an app: create or hydrate its workspace-zone layout proxy,
@@ -16,24 +16,28 @@ export declare function detachApp(): void;
16
16
  export declare function switchToHome(): void;
17
17
  export declare function switchToApp(): void;
18
18
  /**
19
- * The currently-rendered root. LayoutRenderer reads this through the
20
- * `layoutStore` export below. Home uses the framework constant;
21
- * app uses the workspace-zone proxy's `root` (which is reactive, so
19
+ * The currently-rendered LayoutTree. LayoutRenderer reads this via
20
+ * layoutStore.tree. Home uses a framework constant; app reads the
21
+ * currently-active preset from the workspace-zone proxy (reactive, so
22
22
  * mutations from splitter/drag/ops reach the renderer unchanged).
23
23
  */
24
- export declare function activeLayout(): LayoutNode;
24
+ export declare function activeLayout(): LayoutTree;
25
25
  export declare function getActiveRoot(): 'home' | 'app';
26
26
  export declare function getAttachedAppId(): string | null;
27
27
  /**
28
- * Preserved for callers that still read `layoutStore.root`. The getter
29
- * delegates to `activeLayout()` so every read walks through the
30
- * manager. Writes to `layoutStore.root` are disallowed (mutation is
31
- * expected to happen on the returned tree's nodes in place, as in
32
- * phase 7 splitter drags mutate `sizes[i]`, tab clicks mutate
33
- * `activeTab`, drag-commit calls `ops.ts` functions that mutate
34
- * children arrays). Nothing in the codebase currently reassigns
35
- * `layoutStore.root`, so this getter-only shape is sufficient.
28
+ * Preserved for callers that still read `layoutStore.root`. The `root`
29
+ * getter is an alias for `tree.docked` so existing callers still compile
30
+ * without changes. The `tree` getter exposes the full LayoutTree for
31
+ * new consumers that also need floats. The `floats` getter is a
32
+ * convenience alias for `tree.floats`.
33
+ *
34
+ * Writes to any of these properties are disallowed (mutation happens on
35
+ * the returned tree's nodes in place — splitter drags mutate `sizes[i]`,
36
+ * tab clicks mutate `activeTab`, drag-commit calls ops.ts functions that
37
+ * mutate children arrays).
36
38
  */
37
39
  export declare const layoutStore: {
38
40
  readonly root: LayoutNode;
41
+ readonly tree: LayoutTree;
42
+ readonly floats: FloatEntry[];
39
43
  };
@@ -4,9 +4,10 @@
4
4
  * them without tearing down the held tree.
5
5
  *
6
6
  * The manager is the sole owner of "which layout root is being rendered
7
- * right now". LayoutRenderer reads `layoutStore.root` (a getter on the
8
- * active tree); drag.svelte.ts and any other mutation site do the same.
9
- * Neither needs to know whether the active tree is home or an app.
7
+ * right now". LayoutRenderer reads `layoutStore.root` (an alias for
8
+ * `tree.docked`) and `layoutStore.tree`; drag.svelte.ts and any other
9
+ * mutation site do the same. Neither needs to know whether the active
10
+ * tree is home or an app.
10
11
  *
11
12
  * Refcount-hold discipline:
12
13
  * The slot host pool is refcount-based with a microtask-deferred
@@ -30,7 +31,9 @@
30
31
  */
31
32
  import { createStateZones, peekZone, clearZone } from '../state/zones.svelte';
32
33
  import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
33
- import { collectSlotRefs } from './tree-walk';
34
+ import { normalizeInitialLayout } from './presets';
35
+ import { collectTreeSlotRefs } from './tree-walk';
36
+ import { bindPresetBlob, unbindPresetBlob } from '../overlays/presets';
34
37
  // ---------- orphan cleanup of pre-phase-8 shell layout key ----------------
35
38
  // Legacy pre-phase-8 orphan cleanup. The literal '__shell__' here is
36
39
  // intentional — it clears data written under the old reserved id before
@@ -47,8 +50,50 @@ const HOME_LAYOUT = {
47
50
  slotId: 'sh3core.home',
48
51
  viewId: 'sh3core:home',
49
52
  };
53
+ const HOME_TREE = {
54
+ docked: HOME_LAYOUT,
55
+ floats: [],
56
+ };
50
57
  let appEntry = $state(null);
51
58
  let activeRoot = $state('home');
59
+ // ---------- read-side adapter helpers -------------------------------------
60
+ /**
61
+ * Read-side adapter from the legacy phase-7/phase-8 blob shape
62
+ * ({ layoutVersion, root: LayoutNode }) to the new preset-map shape.
63
+ * Returns a normalized AppLayoutBlob. Used only when loading a stored
64
+ * blob that lacks the new `presets` field — fresh writes always use the
65
+ * new shape directly.
66
+ */
67
+ function adaptLegacyBlob(stored) {
68
+ if (!stored || typeof stored !== 'object')
69
+ return null;
70
+ const obj = stored;
71
+ // If new shape, pass through unchanged.
72
+ if (obj.presets && typeof obj.presets === 'object' && typeof obj.activePreset === 'string') {
73
+ return obj;
74
+ }
75
+ // Legacy shape: wrap .root into { default: { default: { docked: root, floats: [] } } }.
76
+ if (obj.root && typeof obj.layoutVersion === 'number') {
77
+ const tree = { docked: obj.root, floats: [] };
78
+ return {
79
+ layoutVersion: obj.layoutVersion,
80
+ activePreset: 'default',
81
+ presets: {
82
+ default: { default: tree },
83
+ },
84
+ };
85
+ }
86
+ return null;
87
+ }
88
+ /** Helper: read the active preset's default-variant tree out of a blob. */
89
+ function currentTree(blob) {
90
+ const preset = blob.presets[blob.activePreset];
91
+ if (!preset) {
92
+ throw new Error(`AppLayoutBlob active preset "${blob.activePreset}" not found in presets map`);
93
+ }
94
+ // v1 always uses 'default' variant
95
+ return preset.default;
96
+ }
52
97
  // ---------- public (within-framework) API ---------------------------------
53
98
  /**
54
99
  * Attach an app: create or hydrate its workspace-zone layout proxy,
@@ -61,36 +106,44 @@ export function attachApp(app) {
61
106
  throw new Error(`Layout manager cannot attach app "${app.manifest.id}": app "${appEntry.appId}" is still attached`);
62
107
  }
63
108
  const shardId = `__sh3core__:app:${app.manifest.id}`;
64
- // Version gate: if a stored blob's layoutVersion doesn't match the
65
- // app's current declaration, discard it so createStateZones falls
66
- // back to the defaults (the app's initialLayout).
109
+ // Normalize the app's initialLayout into canonical presets.
110
+ const canonical = normalizeInitialLayout(app.initialLayout);
111
+ if (canonical.length === 0) {
112
+ throw new Error(`App "${app.manifest.id}" normalized to zero presets`);
113
+ }
114
+ // Build the default blob (used as fallback when nothing is stored or
115
+ // the stored blob's version doesn't match).
116
+ const defaultBlob = {
117
+ layoutVersion: app.manifest.layoutVersion,
118
+ activePreset: canonical[0].name,
119
+ presets: Object.fromEntries(canonical.map((p) => [p.name, Object.assign({}, p.variants)])),
120
+ };
121
+ // Version gate: if a stored blob's layoutVersion doesn't match, clear
122
+ // and fall back to defaults.
67
123
  const stored = peekZone('workspace', shardId);
68
- if (stored != null) {
69
- const asBlob = stored;
70
- if (asBlob.layoutVersion !== app.manifest.layoutVersion) {
71
- clearZone('workspace', shardId);
72
- }
124
+ const adapted = adaptLegacyBlob(stored);
125
+ if (adapted && adapted.layoutVersion !== app.manifest.layoutVersion) {
126
+ clearZone('workspace', shardId);
127
+ }
128
+ else if (stored && !adapted) {
129
+ // Unknown/corrupt shape — clear it so createStateZones takes defaults.
130
+ clearZone('workspace', shardId);
73
131
  }
74
132
  const state = createStateZones(shardId, {
75
- workspace: {
76
- layoutVersion: app.manifest.layoutVersion,
77
- root: app.initialLayout,
78
- },
133
+ workspace: defaultBlob,
79
134
  });
80
135
  const proxy = state.workspace;
81
- // Take a refcount hold on every slot in the app's tree. These holds
82
- // keep the pooled hosts alive across home⇄app swaps. They are
83
- // acquired without attaching the returned host anywhere — the
84
- // LayoutRenderer still acquires its own refs when it mounts the
85
- // tree, so the active rendering doesn't double-hold harmfully (the
86
- // pool's destroy logic just sees refcount 2, then 1 on release).
87
- const refs = collectSlotRefs(proxy.root);
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);
139
+ const refs = collectTreeSlotRefs(tree);
88
140
  const heldSlotIds = [];
89
141
  for (const { slotId, viewId, label } of refs) {
90
142
  acquireSlotHost(slotId, viewId, label);
91
143
  heldSlotIds.push(slotId);
92
144
  }
93
145
  appEntry = { appId: app.manifest.id, proxy, heldSlotIds };
146
+ bindPresetBlob(proxy);
94
147
  }
95
148
  /**
96
149
  * Detach the currently-attached app. Releases its refcount holds; the
@@ -100,6 +153,7 @@ export function attachApp(app) {
100
153
  export function detachApp() {
101
154
  if (!appEntry)
102
155
  return;
156
+ unbindPresetBlob();
103
157
  for (const slotId of appEntry.heldSlotIds) {
104
158
  releaseSlotHost(slotId);
105
159
  }
@@ -118,15 +172,15 @@ export function switchToApp() {
118
172
  activeRoot = 'app';
119
173
  }
120
174
  /**
121
- * The currently-rendered root. LayoutRenderer reads this through the
122
- * `layoutStore` export below. Home uses the framework constant;
123
- * app uses the workspace-zone proxy's `root` (which is reactive, so
175
+ * The currently-rendered LayoutTree. LayoutRenderer reads this via
176
+ * layoutStore.tree. Home uses a framework constant; app reads the
177
+ * currently-active preset from the workspace-zone proxy (reactive, so
124
178
  * mutations from splitter/drag/ops reach the renderer unchanged).
125
179
  */
126
180
  export function activeLayout() {
127
181
  if (activeRoot === 'app' && appEntry)
128
- return appEntry.proxy.root;
129
- return HOME_LAYOUT;
182
+ return currentTree(appEntry.proxy);
183
+ return HOME_TREE;
130
184
  }
131
185
  export function getActiveRoot() {
132
186
  return activeRoot;
@@ -137,17 +191,25 @@ export function getAttachedAppId() {
137
191
  }
138
192
  // ---------- `layoutStore` back-compat shim -------------------------------
139
193
  /**
140
- * Preserved for callers that still read `layoutStore.root`. The getter
141
- * delegates to `activeLayout()` so every read walks through the
142
- * manager. Writes to `layoutStore.root` are disallowed (mutation is
143
- * expected to happen on the returned tree's nodes in place, as in
144
- * phase 7 splitter drags mutate `sizes[i]`, tab clicks mutate
145
- * `activeTab`, drag-commit calls `ops.ts` functions that mutate
146
- * children arrays). Nothing in the codebase currently reassigns
147
- * `layoutStore.root`, so this getter-only shape is sufficient.
194
+ * Preserved for callers that still read `layoutStore.root`. The `root`
195
+ * getter is an alias for `tree.docked` so existing callers still compile
196
+ * without changes. The `tree` getter exposes the full LayoutTree for
197
+ * new consumers that also need floats. The `floats` getter is a
198
+ * convenience alias for `tree.floats`.
199
+ *
200
+ * Writes to any of these properties are disallowed (mutation happens on
201
+ * the returned tree's nodes in place — splitter drags mutate `sizes[i]`,
202
+ * tab clicks mutate `activeTab`, drag-commit calls ops.ts functions that
203
+ * mutate children arrays).
148
204
  */
149
205
  export const layoutStore = {
150
206
  get root() {
207
+ return activeLayout().docked;
208
+ },
209
+ get tree() {
151
210
  return activeLayout();
152
211
  },
212
+ get floats() {
213
+ return activeLayout().floats;
214
+ },
153
215
  };
@@ -1,4 +1,4 @@
1
- import type { LayoutNode } from './types';
1
+ import type { LayoutNode, LayoutTree } from './types';
2
2
  /**
3
3
  * Collect the slot id / view id pairs of every slot leaf (including the
4
4
  * slots embedded inside tabs entries) in a layout tree. Used by the
@@ -13,3 +13,14 @@ export declare function collectSlotRefs(tree: LayoutNode): {
13
13
  viewId: string | null;
14
14
  label: string;
15
15
  }[];
16
+ /**
17
+ * Multi-root version of `collectSlotRefs`: walks the docked tree first
18
+ * and then each float's content in order. Used by the layout manager to
19
+ * take refcount holds on slot ids across every tree-owned view when
20
+ * attaching an app whose preset includes floats.
21
+ */
22
+ export declare function collectTreeSlotRefs(tree: LayoutTree): {
23
+ slotId: string;
24
+ viewId: string | null;
25
+ label: string;
26
+ }[];
@@ -31,3 +31,16 @@ export function collectSlotRefs(tree) {
31
31
  walk(tree);
32
32
  return out;
33
33
  }
34
+ /**
35
+ * Multi-root version of `collectSlotRefs`: walks the docked tree first
36
+ * and then each float's content in order. Used by the layout manager to
37
+ * take refcount holds on slot ids across every tree-owned view when
38
+ * attaching an app whose preset includes floats.
39
+ */
40
+ export function collectTreeSlotRefs(tree) {
41
+ const out = collectSlotRefs(tree.docked);
42
+ for (const f of tree.floats) {
43
+ out.push(...collectSlotRefs(f.content));
44
+ }
45
+ return out;
46
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { collectSlotRefs, collectTreeSlotRefs } from './tree-walk';
3
+ const slot = (slotId, viewId) => ({
4
+ type: 'slot',
5
+ slotId,
6
+ viewId,
7
+ });
8
+ describe('collectSlotRefs (single LayoutNode — existing behavior)', () => {
9
+ it('returns a single entry for a lone slot', () => {
10
+ expect(collectSlotRefs(slot('s1', 'v1'))).toEqual([
11
+ { slotId: 's1', viewId: 'v1', label: 'v1' },
12
+ ]);
13
+ });
14
+ });
15
+ describe('collectTreeSlotRefs (LayoutTree — new)', () => {
16
+ it('returns docked slots followed by float content slots in order', () => {
17
+ const tree = {
18
+ docked: slot('docked1', 'v-docked'),
19
+ floats: [
20
+ {
21
+ id: 'f1',
22
+ content: slot('float1', 'v-float1'),
23
+ position: { x: 0, y: 0 },
24
+ size: { w: 600, h: 400 },
25
+ },
26
+ {
27
+ id: 'f2',
28
+ content: slot('float2', 'v-float2'),
29
+ position: { x: 32, y: 32 },
30
+ size: { w: 600, h: 400 },
31
+ },
32
+ ],
33
+ };
34
+ const refs = collectTreeSlotRefs(tree);
35
+ expect(refs.map((r) => r.slotId)).toEqual(['docked1', 'float1', 'float2']);
36
+ });
37
+ it('returns only docked slots when floats is empty', () => {
38
+ const tree = { docked: slot('only', 'v'), floats: [] };
39
+ expect(collectTreeSlotRefs(tree).map((r) => r.slotId)).toEqual(['only']);
40
+ });
41
+ });
@@ -79,6 +79,80 @@ export interface SlotNode {
79
79
  * these three types: `split` → children, `tabs` → slots, `slot` → leaf.
80
80
  */
81
81
  export type LayoutNode = SplitNode | TabsNode | SlotNode;
82
+ /**
83
+ * A detached panel that floats above the docked tree. Each float owns a
84
+ * `LayoutNode` subtree (so it can itself contain splits and tabs) plus
85
+ * positioning metadata. Floats cannot contain other floats — the type
86
+ * system enforces this by using `LayoutNode`, not `LayoutNode | FloatEntry`.
87
+ */
88
+ export interface FloatEntry {
89
+ /** Stable identifier for this float. Survives re-parents and reloads. */
90
+ id: string;
91
+ /** The float's content subtree. Split | tabs | slot — no nested floats. */
92
+ content: LayoutNode;
93
+ /** Top-left position of the float frame, relative to the tree-allocated area. */
94
+ position: {
95
+ x: number;
96
+ y: number;
97
+ };
98
+ /** Size of the float frame in pixels. */
99
+ size: {
100
+ w: number;
101
+ h: number;
102
+ };
103
+ /** Optional human-readable title; defaults to the active view's label. */
104
+ title?: string;
105
+ }
106
+ /**
107
+ * Root shape of a workspace layout. The docked tree is the primary
108
+ * topology; floats are a parallel collection that share the same area
109
+ * visually but live outside the recursive-tree invariant. Persisted to
110
+ * the workspace state zone as part of an `AppLayoutBlob` preset entry.
111
+ */
112
+ export interface LayoutTree {
113
+ docked: LayoutNode;
114
+ floats: FloatEntry[];
115
+ }
116
+ /**
117
+ * A named layout blueprint. Apps ship one or more presets in their
118
+ * manifest; users switch between them at runtime. The ergonomic `tree`
119
+ * field is shorthand for `variants.default`; the normalizer canonicalizes
120
+ * every preset into a variants-only shape on load. v1 always uses the
121
+ * `default` variant; other variant keys (e.g. `companion`) are reserved
122
+ * for the rescoped DF10 selection policy and are persisted but inert.
123
+ */
124
+ export interface LayoutPreset {
125
+ name: string;
126
+ /** Ergonomic shortcut — discarded after normalization in favor of `variants.default`. */
127
+ tree?: LayoutTree;
128
+ /** Variant map. After normalization always contains at least `default`. */
129
+ variants?: {
130
+ [variant: string]: LayoutTree;
131
+ };
132
+ }
133
+ /**
134
+ * Canonical form of a preset after normalization. Used internally by
135
+ * the framework; authors write `LayoutPreset` in their manifests.
136
+ */
137
+ export interface CanonicalPreset {
138
+ name: string;
139
+ variants: {
140
+ [variant: string]: LayoutTree;
141
+ };
142
+ }
143
+ /**
144
+ * Reference to a root within the current `LayoutTree`. Either the
145
+ * primary docked tree, or the content subtree of a specific float by id.
146
+ * Used by `LayoutRenderer` to target a subtree without passing a `node`
147
+ * prop (which would break the ownership-by-path contract — see the
148
+ * component's header comment for rationale).
149
+ */
150
+ export type TreeRootRef = {
151
+ kind: 'docked';
152
+ } | {
153
+ kind: 'float';
154
+ floatId: string;
155
+ };
82
156
  /**
83
157
  * Schema version for persisted layouts. Bump this when the shape of
84
158
  * `LayoutNode` (or anything reachable from it) changes incompatibly.
@@ -86,7 +160,7 @@ export type LayoutNode = SplitNode | TabsNode | SlotNode;
86
160
  * the default tree takes over — phase 7 deliberately does not ship a
87
161
  * migration framework, only the hook for one.
88
162
  */
89
- export declare const LAYOUT_SCHEMA_VERSION = 3;
163
+ export declare const LAYOUT_SCHEMA_VERSION = 4;
90
164
  /**
91
165
  * The wire shape of a persisted layout in the workspace state zone.
92
166
  * One blob per shell (or per program, once per-program layouts exist);
@@ -94,15 +168,31 @@ export declare const LAYOUT_SCHEMA_VERSION = 3;
94
168
  */
95
169
  export interface PersistedLayout {
96
170
  version: typeof LAYOUT_SCHEMA_VERSION;
97
- root: LayoutNode;
171
+ tree: LayoutTree;
98
172
  }
99
173
  /**
100
174
  * Per-app layout blob written to the workspace state zone under
101
- * `__sh3core__:app:<appId>`. The `layoutVersion` is the app's own
102
- * `AppManifest.layoutVersion`; on launch a mismatch discards the blob
103
- * and the app's `initialLayout` is used.
175
+ * `__sh3core__:app:<appId>`. Holds the full multi-preset, multi-variant
176
+ * tree collection. On launch, a stored blob whose `layoutVersion` does
177
+ * not match the app's current `AppManifest.layoutVersion` is discarded
178
+ * and the app's normalized `initialLayout` is used in its place.
179
+ *
180
+ * Legacy phase-7/phase-8 blobs stored `{ layoutVersion, root: LayoutNode }`.
181
+ * The `attachApp` read path wraps that shape into the new form rather
182
+ * than discarding it; see `layout/store.svelte.ts`.
104
183
  */
105
184
  export interface AppLayoutBlob {
106
185
  layoutVersion: number;
107
- root: LayoutNode;
186
+ /** Name of the currently-active preset. Must be a key of `presets`. */
187
+ activePreset: string;
188
+ /**
189
+ * Preset map. Each entry holds a variant map; v1 only reads/writes
190
+ * the `default` variant, other keys are reserved for the rescoped
191
+ * DF10 selection policy and pass through untouched.
192
+ */
193
+ presets: {
194
+ [presetName: string]: {
195
+ [variantName: string]: LayoutTree;
196
+ };
197
+ };
108
198
  }
@@ -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 = 3;
25
+ export const LAYOUT_SCHEMA_VERSION = 4;