sh3-core 0.7.3 → 0.8.0

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 (174) hide show
  1. package/dist/__test__/fixtures.d.ts +12 -0
  2. package/dist/__test__/fixtures.js +62 -0
  3. package/dist/__test__/render.d.ts +3 -0
  4. package/dist/__test__/render.js +11 -0
  5. package/dist/__test__/reset.d.ts +14 -0
  6. package/dist/__test__/reset.js +34 -0
  7. package/dist/__test__/setup-dom.d.ts +1 -0
  8. package/dist/__test__/setup-dom.js +26 -0
  9. package/dist/__test__/smoke.test.d.ts +1 -0
  10. package/dist/__test__/smoke.test.js +28 -0
  11. package/dist/api.d.ts +15 -2
  12. package/dist/api.js +13 -1
  13. package/dist/app/store/StoreView.svelte +36 -7
  14. package/dist/app/store/storeShard.svelte.js +9 -3
  15. package/dist/app/store/verbs.js +8 -2
  16. package/dist/apps/lifecycle.d.ts +11 -0
  17. package/dist/apps/lifecycle.js +48 -11
  18. package/dist/apps/lifecycle.test.d.ts +1 -0
  19. package/dist/apps/lifecycle.test.js +309 -0
  20. package/dist/apps/registry.svelte.d.ts +2 -0
  21. package/dist/apps/registry.svelte.js +5 -0
  22. package/dist/apps/types.d.ts +24 -2
  23. package/dist/createShell.d.ts +2 -0
  24. package/dist/createShell.js +9 -7
  25. package/dist/documents/handle.js +5 -0
  26. package/dist/documents/index.d.ts +1 -0
  27. package/dist/documents/index.js +1 -0
  28. package/dist/documents/journal-hook.d.ts +6 -0
  29. package/dist/documents/journal-hook.js +16 -0
  30. package/dist/documents/sync/activate-integration.test.d.ts +1 -0
  31. package/dist/documents/sync/activate-integration.test.js +37 -0
  32. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
  33. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
  34. package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
  35. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
  36. package/dist/documents/sync/conflicts.d.ts +30 -0
  37. package/dist/documents/sync/conflicts.js +77 -0
  38. package/dist/documents/sync/conflicts.test.d.ts +1 -0
  39. package/dist/documents/sync/conflicts.test.js +71 -0
  40. package/dist/documents/sync/engine.d.ts +19 -0
  41. package/dist/documents/sync/engine.js +188 -0
  42. package/dist/documents/sync/engine.test.d.ts +1 -0
  43. package/dist/documents/sync/engine.test.js +169 -0
  44. package/dist/documents/sync/handle.d.ts +11 -0
  45. package/dist/documents/sync/handle.js +79 -0
  46. package/dist/documents/sync/handle.test.d.ts +1 -0
  47. package/dist/documents/sync/handle.test.js +56 -0
  48. package/dist/documents/sync/hash.d.ts +1 -0
  49. package/dist/documents/sync/hash.js +13 -0
  50. package/dist/documents/sync/hash.test.d.ts +1 -0
  51. package/dist/documents/sync/hash.test.js +20 -0
  52. package/dist/documents/sync/index.d.ts +6 -0
  53. package/dist/documents/sync/index.js +12 -0
  54. package/dist/documents/sync/journal.d.ts +30 -0
  55. package/dist/documents/sync/journal.js +179 -0
  56. package/dist/documents/sync/journal.test.d.ts +1 -0
  57. package/dist/documents/sync/journal.test.js +87 -0
  58. package/dist/documents/sync/registry.d.ts +10 -0
  59. package/dist/documents/sync/registry.js +66 -0
  60. package/dist/documents/sync/registry.test.d.ts +1 -0
  61. package/dist/documents/sync/registry.test.js +42 -0
  62. package/dist/documents/sync/serialization.d.ts +5 -0
  63. package/dist/documents/sync/serialization.js +24 -0
  64. package/dist/documents/sync/serialization.test.d.ts +1 -0
  65. package/dist/documents/sync/serialization.test.js +26 -0
  66. package/dist/documents/sync/singleton.d.ts +11 -0
  67. package/dist/documents/sync/singleton.js +26 -0
  68. package/dist/documents/sync/tombstones.d.ts +19 -0
  69. package/dist/documents/sync/tombstones.js +58 -0
  70. package/dist/documents/sync/tombstones.test.d.ts +1 -0
  71. package/dist/documents/sync/tombstones.test.js +37 -0
  72. package/dist/documents/sync/types.d.ts +116 -0
  73. package/dist/documents/sync/types.js +27 -0
  74. package/dist/documents/sync/write-hook.test.d.ts +1 -0
  75. package/dist/documents/sync/write-hook.test.js +36 -0
  76. package/dist/env/client.d.ts +10 -5
  77. package/dist/env/client.js +12 -4
  78. package/dist/layout/LayoutRenderer.browser.test.d.ts +1 -0
  79. package/dist/layout/LayoutRenderer.browser.test.js +274 -0
  80. package/dist/layout/LayoutRenderer.svelte +2 -1
  81. package/dist/layout/LayoutRenderer.test.d.ts +1 -0
  82. package/dist/layout/LayoutRenderer.test.js +143 -0
  83. package/dist/layout/SlotContainer.svelte +8 -2
  84. package/dist/layout/SlotDropZone.svelte +19 -0
  85. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-1-drag-tab-between-groups-moves-a-tab-from-one-tabs-group-to-another-1.png +0 -0
  86. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-2-drag-tab-to-quadrant-creates-a-split-when-dropping-a-tab-on-a-quadrant-drop-zone-1.png +0 -0
  87. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-3-splitter-drag-updates-split-sizes-when-the-splitter-handle-is-dragged-1.png +0 -0
  88. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-4-close-policy-removes-closable-tabs--keeps-non-closable--and-awaits-canClose-1.png +0 -0
  89. package/dist/layout/__screenshots__/LayoutRenderer.browser.test.ts/LayoutRenderer-browser---E-5-splitter-collapse-toggle-toggles-collapsed-i--on-double-click-1.png +0 -0
  90. package/dist/layout/drag.svelte.d.ts +5 -0
  91. package/dist/layout/drag.svelte.js +15 -0
  92. package/dist/layout/slotHostPool.svelte.d.ts +16 -1
  93. package/dist/layout/slotHostPool.svelte.js +123 -5
  94. package/dist/layout/slotHostPool.test.d.ts +1 -0
  95. package/dist/layout/slotHostPool.test.js +104 -0
  96. package/dist/layout/store.svelte.d.ts +22 -0
  97. package/dist/layout/store.svelte.js +78 -16
  98. package/dist/layout/tree-walk.d.ts +2 -0
  99. package/dist/layout/tree-walk.js +1 -1
  100. package/dist/layout/types.d.ts +5 -0
  101. package/dist/overlays/float.d.ts +2 -0
  102. package/dist/overlays/float.js +4 -1
  103. package/dist/overlays/float.test.js +102 -1
  104. package/dist/primitives/ResizableSplitter.svelte +2 -0
  105. package/dist/primitives/TabbedPanel.svelte +4 -0
  106. package/dist/primitives/TabbedPanel.svelte.d.ts +2 -0
  107. package/dist/registry/installer.d.ts +10 -7
  108. package/dist/registry/installer.js +39 -35
  109. package/dist/registry/register.d.ts +17 -0
  110. package/dist/registry/register.js +22 -0
  111. package/dist/registry/register.test.d.ts +1 -0
  112. package/dist/registry/register.test.js +28 -0
  113. package/dist/shards/activate.svelte.d.ts +6 -0
  114. package/dist/shards/activate.svelte.js +33 -2
  115. package/dist/shards/registry.d.ts +4 -0
  116. package/dist/shards/registry.js +18 -0
  117. package/dist/shards/types.d.ts +16 -1
  118. package/dist/shell-shard/Terminal.svelte +140 -33
  119. package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
  120. package/dist/shell-shard/auto-relocate.d.ts +12 -0
  121. package/dist/shell-shard/auto-relocate.js +20 -0
  122. package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
  123. package/dist/shell-shard/auto-relocate.test.js +35 -0
  124. package/dist/shell-shard/dispatch.d.ts +15 -0
  125. package/dist/shell-shard/dispatch.js +56 -0
  126. package/dist/shell-shard/modes/builtin.d.ts +5 -0
  127. package/dist/shell-shard/modes/builtin.js +18 -0
  128. package/dist/shell-shard/modes/prefs.d.ts +5 -0
  129. package/dist/shell-shard/modes/prefs.js +31 -0
  130. package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
  131. package/dist/shell-shard/modes/prefs.test.js +46 -0
  132. package/dist/shell-shard/modes/registry.d.ts +7 -0
  133. package/dist/shell-shard/modes/registry.js +27 -0
  134. package/dist/shell-shard/modes/registry.test.d.ts +1 -0
  135. package/dist/shell-shard/modes/registry.test.js +35 -0
  136. package/dist/shell-shard/modes/types.d.ts +8 -0
  137. package/dist/shell-shard/modes/types.js +1 -0
  138. package/dist/shell-shard/protocol.d.ts +6 -0
  139. package/dist/shell-shard/shellShard.svelte.js +5 -1
  140. package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
  141. package/dist/shell-shard/tenant-fs-client.js +44 -0
  142. package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
  143. package/dist/shell-shard/tenant-fs-client.test.js +49 -0
  144. package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
  145. package/dist/shell-shard/terminal-dispatch.test.js +53 -0
  146. package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
  147. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
  148. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
  149. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
  150. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
  151. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
  152. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
  153. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
  154. package/dist/shell-shard/toolbar/slots.d.ts +17 -0
  155. package/dist/shell-shard/toolbar/slots.js +26 -0
  156. package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
  157. package/dist/shell-shard/toolbar/slots.test.js +28 -0
  158. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  159. package/dist/shell-shard/verbs/cat.js +34 -0
  160. package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
  161. package/dist/shell-shard/verbs/cd.test.js +56 -0
  162. package/dist/shell-shard/verbs/env.d.ts +2 -0
  163. package/dist/shell-shard/verbs/env.js +14 -0
  164. package/dist/shell-shard/verbs/index.js +6 -1
  165. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  166. package/dist/shell-shard/verbs/ls.js +29 -0
  167. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  168. package/dist/shell-shard/verbs/ls.test.js +49 -0
  169. package/dist/shell-shard/verbs/session.d.ts +0 -1
  170. package/dist/shell-shard/verbs/session.js +58 -26
  171. package/dist/verbs/types.d.ts +2 -0
  172. package/dist/version.d.ts +1 -1
  173. package/dist/version.js +1 -1
  174. package/package.json +9 -1
@@ -0,0 +1,309 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { resetFramework } from '../__test__/reset';
3
+ import { makeApp, makeShard, makeAppManifest, makeShardManifest, makeTabsNode, makeTabEntry, makeSlotNode, makeTree, } from '../__test__/fixtures';
4
+ import { launchApp, returnToHome, unregisterApp } from './lifecycle';
5
+ import { registerApp } from './registry.svelte';
6
+ import { registerShard } from '../shards/activate.svelte';
7
+ import { presetManager } from '../overlays/presets';
8
+ import { layoutStore } from '../layout/store.svelte';
9
+ import LayoutRenderer from '../layout/LayoutRenderer.svelte';
10
+ import { renderWithShell } from '../__test__/render';
11
+ import { registerView } from '../shards/registry';
12
+ import { tick } from 'svelte';
13
+ // ---------------------------------------------------------------------------
14
+ // Scenario A.1 — step order
15
+ // ---------------------------------------------------------------------------
16
+ describe('launchApp — scenario A.1 step order', () => {
17
+ beforeEach(resetFramework);
18
+ it('runs attachApp → activate shards → acquireAppSlotHolds → app.activate → switchToApp → onAppReady', async () => {
19
+ const order = [];
20
+ const shard = makeShard({
21
+ manifest: makeShardManifest({ id: 'shard-A' }),
22
+ activate: () => {
23
+ order.push('shard.activate');
24
+ },
25
+ });
26
+ registerShard(shard);
27
+ const app = makeApp({
28
+ manifest: makeAppManifest({
29
+ id: 'app-1',
30
+ requiredShards: ['shard-A'],
31
+ }),
32
+ activate: () => {
33
+ order.push('app.activate');
34
+ },
35
+ onAppReady: () => {
36
+ order.push('app.onAppReady');
37
+ },
38
+ });
39
+ registerApp(app);
40
+ await launchApp('app-1');
41
+ const iShard = order.indexOf('shard.activate');
42
+ const iApp = order.indexOf('app.activate');
43
+ const iReady = order.indexOf('app.onAppReady');
44
+ expect(iShard).toBeGreaterThanOrEqual(0);
45
+ expect(iApp).toBeGreaterThan(iShard);
46
+ expect(iReady).toBeGreaterThan(iApp);
47
+ });
48
+ });
49
+ // ---------------------------------------------------------------------------
50
+ // Scenario A.2 — shard activate failure rolls back attach
51
+ // ---------------------------------------------------------------------------
52
+ describe('launchApp — scenario A.2 shard failure', () => {
53
+ beforeEach(resetFramework);
54
+ it('detaches the app and re-throws when a required shard throws during activate', async () => {
55
+ const badShard = makeShard({
56
+ manifest: makeShardManifest({ id: 'bad' }),
57
+ activate: () => {
58
+ throw new Error('boom');
59
+ },
60
+ });
61
+ registerShard(badShard);
62
+ const app = makeApp({
63
+ manifest: makeAppManifest({ id: 'app-2', requiredShards: ['bad'] }),
64
+ });
65
+ registerApp(app);
66
+ const { getAttachedAppId } = await import('../layout/store.svelte');
67
+ await expect(launchApp('app-2')).rejects.toThrow('boom');
68
+ expect(getAttachedAppId()).toBeNull();
69
+ });
70
+ });
71
+ // ---------------------------------------------------------------------------
72
+ // Scenario A.3 — re-entry from home uses resume, skips shard re-activation
73
+ // ---------------------------------------------------------------------------
74
+ describe('launchApp — scenario A.3 re-entry from home', () => {
75
+ beforeEach(resetFramework);
76
+ it('fires resume and onAppReady on re-entry, does not re-activate shards', async () => {
77
+ const shardActivate = vi.fn();
78
+ const shard = makeShard({
79
+ manifest: makeShardManifest({ id: 'shard-R' }),
80
+ activate: shardActivate,
81
+ });
82
+ registerShard(shard);
83
+ const appResume = vi.fn();
84
+ const appReady = vi.fn();
85
+ const app = makeApp({
86
+ manifest: makeAppManifest({ id: 'app-3', requiredShards: ['shard-R'] }),
87
+ resume: appResume,
88
+ onAppReady: appReady,
89
+ });
90
+ registerApp(app);
91
+ await launchApp('app-3');
92
+ expect(shardActivate).toHaveBeenCalledTimes(1);
93
+ await returnToHome();
94
+ await launchApp('app-3');
95
+ expect(shardActivate).toHaveBeenCalledTimes(1);
96
+ expect(appResume).toHaveBeenCalledTimes(1);
97
+ expect(appReady).toHaveBeenCalledTimes(2);
98
+ });
99
+ });
100
+ // ---------------------------------------------------------------------------
101
+ // Scenario A.4 — returnToHome then launch(same) fast path
102
+ // ---------------------------------------------------------------------------
103
+ describe('launchApp — scenario A.4 fast path', () => {
104
+ beforeEach(resetFramework);
105
+ it('does not call shard.deactivate or shard.activate when relaunching the same app from home', async () => {
106
+ const shardActivate = vi.fn();
107
+ const shardDeactivate = vi.fn();
108
+ registerShard(makeShard({
109
+ manifest: makeShardManifest({ id: 'shard-F' }),
110
+ activate: shardActivate,
111
+ deactivate: shardDeactivate,
112
+ }));
113
+ registerApp(makeApp({
114
+ manifest: makeAppManifest({ id: 'app-4', requiredShards: ['shard-F'] }),
115
+ }));
116
+ await launchApp('app-4');
117
+ await returnToHome();
118
+ await launchApp('app-4');
119
+ expect(shardActivate).toHaveBeenCalledTimes(1);
120
+ expect(shardDeactivate).not.toHaveBeenCalled();
121
+ });
122
+ });
123
+ // ---------------------------------------------------------------------------
124
+ // Scenario A.5 — missing required shard fails fast
125
+ // ---------------------------------------------------------------------------
126
+ describe('launchApp — scenario A.5 missing shard', () => {
127
+ beforeEach(resetFramework);
128
+ it('throws before attachApp when a required shard is not registered', async () => {
129
+ registerApp(makeApp({
130
+ manifest: makeAppManifest({ id: 'app-5', requiredShards: ['missing'] }),
131
+ }));
132
+ const { getAttachedAppId } = await import('../layout/store.svelte');
133
+ await expect(launchApp('app-5')).rejects.toThrow(/missing/);
134
+ expect(getAttachedAppId()).toBeNull();
135
+ });
136
+ });
137
+ // ---------------------------------------------------------------------------
138
+ // Scenario B.1 — presets.switch mutates synchronously
139
+ // ---------------------------------------------------------------------------
140
+ describe('presets — scenario B.1 sync mutation', () => {
141
+ beforeEach(resetFramework);
142
+ it('mutates the active-preset blob synchronously on switch', async () => {
143
+ const app = makeApp({
144
+ manifest: makeAppManifest({ id: 'presets-1' }),
145
+ initialLayout: [
146
+ { name: 'one', tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'a', label: 'A' })])) },
147
+ { name: 'two', tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'b', label: 'B' })])) },
148
+ ],
149
+ });
150
+ registerApp(app);
151
+ await launchApp('presets-1');
152
+ expect(presetManager.active()).toBe('one');
153
+ presetManager.switch('two');
154
+ expect(presetManager.active()).toBe('two');
155
+ expect(layoutStore.root).toMatchObject({ type: 'tabs' });
156
+ });
157
+ });
158
+ // ---------------------------------------------------------------------------
159
+ // Scenario B.2 — shard.activate can call presets.switch
160
+ // ---------------------------------------------------------------------------
161
+ describe('presets — scenario B.2 switch from shard.activate', () => {
162
+ beforeEach(resetFramework);
163
+ it('does not throw "no app attached" when a shard calls presets.switch from activate', async () => {
164
+ registerShard(makeShard({
165
+ manifest: makeShardManifest({ id: 'switcher' }),
166
+ activate: () => {
167
+ presetManager.switch('alt');
168
+ },
169
+ }));
170
+ registerApp(makeApp({
171
+ manifest: makeAppManifest({ id: 'presets-2', requiredShards: ['switcher'] }),
172
+ initialLayout: [
173
+ { name: 'default', tree: makeTree(makeSlotNode('x')) },
174
+ { name: 'alt', tree: makeTree(makeSlotNode('y')) },
175
+ ],
176
+ }));
177
+ await expect(launchApp('presets-2')).resolves.not.toThrow();
178
+ expect(presetManager.active()).toBe('alt');
179
+ });
180
+ });
181
+ // ---------------------------------------------------------------------------
182
+ // Scenario B.3 — unknown preset throws
183
+ // ---------------------------------------------------------------------------
184
+ describe('presets — scenario B.3 unknown name', () => {
185
+ beforeEach(resetFramework);
186
+ it('throws with a useful message when switching to an unknown preset', async () => {
187
+ registerApp(makeApp({
188
+ manifest: makeAppManifest({ id: 'presets-3' }),
189
+ initialLayout: [{ name: 'only', tree: makeTree(makeSlotNode('x')) }],
190
+ }));
191
+ await launchApp('presets-3');
192
+ expect(() => presetManager.switch('nope')).toThrow(/nope/);
193
+ });
194
+ });
195
+ // ---------------------------------------------------------------------------
196
+ // Scenario B.4 — round-trip preserves customization
197
+ // ---------------------------------------------------------------------------
198
+ describe('presets — scenario B.4 round-trip preserves customization', () => {
199
+ beforeEach(resetFramework);
200
+ it('preserves per-preset sizes and activeTab across switch A → B → A', async () => {
201
+ var _a;
202
+ registerApp(makeApp({
203
+ manifest: makeAppManifest({ id: 'presets-4' }),
204
+ initialLayout: [
205
+ {
206
+ name: 'A',
207
+ tree: makeTree(makeTabsNode([
208
+ makeTabEntry({ slotId: 't1', label: 'T1' }),
209
+ makeTabEntry({ slotId: 't2', label: 'T2' }),
210
+ ])),
211
+ },
212
+ { name: 'B', tree: makeTree(makeSlotNode('solo')) },
213
+ ],
214
+ }));
215
+ await launchApp('presets-4');
216
+ const rootA = layoutStore.root;
217
+ expect(rootA === null || rootA === void 0 ? void 0 : rootA.type).toBe('tabs');
218
+ if ((rootA === null || rootA === void 0 ? void 0 : rootA.type) === 'tabs')
219
+ rootA.activeTab = 1;
220
+ presetManager.switch('B');
221
+ expect((_a = layoutStore.root) === null || _a === void 0 ? void 0 : _a.type).toBe('slot');
222
+ presetManager.switch('A');
223
+ const back = layoutStore.root;
224
+ expect(back === null || back === void 0 ? void 0 : back.type).toBe('tabs');
225
+ if ((back === null || back === void 0 ? void 0 : back.type) === 'tabs')
226
+ expect(back.activeTab).toBe(1);
227
+ });
228
+ });
229
+ // ---------------------------------------------------------------------------
230
+ // Scenario B.5 — post-launch switch re-renders (TDD + fix)
231
+ // ---------------------------------------------------------------------------
232
+ describe('presets — scenario B.5 post-launch switch re-renders', () => {
233
+ beforeEach(resetFramework);
234
+ it('updates the rendered DOM when presets.switch is called after launchApp', async () => {
235
+ registerView('test:slot-view', {
236
+ mount(container, ctx) {
237
+ const span = document.createElement('span');
238
+ span.dataset.viewFor = ctx.slotId;
239
+ span.textContent = ctx.slotId;
240
+ container.appendChild(span);
241
+ return { unmount: () => span.remove() };
242
+ },
243
+ });
244
+ registerApp(makeApp({
245
+ manifest: makeAppManifest({ id: 'presets-5' }),
246
+ initialLayout: [
247
+ { name: 'one', tree: makeTree(makeSlotNode('slot-one', 'test:slot-view')) },
248
+ { name: 'two', tree: makeTree(makeSlotNode('slot-two', 'test:slot-view')) },
249
+ ],
250
+ }));
251
+ await launchApp('presets-5');
252
+ const { container } = renderWithShell(LayoutRenderer, { path: [] });
253
+ await tick();
254
+ expect(container.querySelector('[data-view-for="slot-one"]')).toBeTruthy();
255
+ presetManager.switch('two');
256
+ await tick();
257
+ expect(container.querySelector('[data-view-for="slot-two"]')).toBeTruthy();
258
+ expect(container.querySelector('[data-view-for="slot-one"]')).toBeNull();
259
+ });
260
+ });
261
+ // ---------------------------------------------------------------------------
262
+ // unregisterApp — android-style force-close + removal
263
+ // ---------------------------------------------------------------------------
264
+ describe('unregisterApp', () => {
265
+ beforeEach(resetFramework);
266
+ it('removes an inactive app from the registry', async () => {
267
+ const { registeredApps } = await import('./registry.svelte');
268
+ const app = makeApp({ manifest: makeAppManifest({ id: 'app-u1' }) });
269
+ registerApp(app);
270
+ expect(registeredApps.has('app-u1')).toBe(true);
271
+ unregisterApp('app-u1');
272
+ expect(registeredApps.has('app-u1')).toBe(false);
273
+ });
274
+ it('force-closes to home if the app being unregistered is active', async () => {
275
+ const { activeApp, registeredApps } = await import('./registry.svelte');
276
+ const shard = makeShard({ manifest: makeShardManifest({ id: 'shard-U' }) });
277
+ registerShard(shard);
278
+ const app = makeApp({
279
+ manifest: makeAppManifest({ id: 'app-u2', requiredShards: ['shard-U'] }),
280
+ });
281
+ registerApp(app);
282
+ await launchApp('app-u2');
283
+ expect(activeApp.id).toBe('app-u2');
284
+ unregisterApp('app-u2');
285
+ expect(activeApp.id).toBeNull();
286
+ expect(registeredApps.has('app-u2')).toBe(false);
287
+ });
288
+ it('is a no-op for unknown ids', async () => {
289
+ expect(() => unregisterApp('does-not-exist')).not.toThrow();
290
+ });
291
+ });
292
+ // ---------------------------------------------------------------------------
293
+ // installPackage — evict-before-register (via registerLoadedBundle)
294
+ // ---------------------------------------------------------------------------
295
+ describe('installPackage evict-before-register (simulated via registerLoadedBundle)', () => {
296
+ beforeEach(resetFramework);
297
+ it('replaces an existing shard entry when a new version is registered', async () => {
298
+ var _a, _b;
299
+ const { registerLoadedBundle } = await import('../registry/register');
300
+ const { deactivateShard, registeredShards } = await import('../shards/activate.svelte');
301
+ const s1 = makeShard({ manifest: makeShardManifest({ id: 'S', version: '' }) });
302
+ registerLoadedBundle({ shards: [s1], apps: [] }, { version: '1.0.0', sourceRegistry: '', contractVersion: '1' });
303
+ expect((_a = registeredShards.get('S')) === null || _a === void 0 ? void 0 : _a.manifest.version).toBe('1.0.0');
304
+ deactivateShard('S');
305
+ const s2 = makeShard({ manifest: makeShardManifest({ id: 'S', version: '' }) });
306
+ registerLoadedBundle({ shards: [s2], apps: [] }, { version: '1.0.1', sourceRegistry: '', contractVersion: '1' });
307
+ expect((_b = registeredShards.get('S')) === null || _b === void 0 ? void 0 : _b.manifest.version).toBe('1.0.1');
308
+ });
309
+ });
@@ -38,3 +38,5 @@ export declare function getActiveApp(): AppManifest | null;
38
38
  * activate hook. Not re-exported through `api.ts`.
39
39
  */
40
40
  export declare function getRegisteredApp(id: string): App | undefined;
41
+ /** Test-only reset: clear registered apps and the active-app pointer. */
42
+ export declare function __resetAppRegistryForTest(): void;
@@ -57,3 +57,8 @@ export function getActiveApp() {
57
57
  export function getRegisteredApp(id) {
58
58
  return registeredApps.get(id);
59
59
  }
60
+ /** Test-only reset: clear registered apps and the active-app pointer. */
61
+ export function __resetAppRegistryForTest() {
62
+ registeredApps.clear();
63
+ activeApp.id = null;
64
+ }
@@ -39,8 +39,9 @@ export interface AppManifest {
39
39
  /**
40
40
  * Optional permissions this app requests beyond the default sandbox.
41
41
  * Declared in the manifest and surfaced to the user at install time
42
- * by the store app. Currently recognized: `'state:manage'` — cross-
43
- * shard zone access.
42
+ * by the store app. Currently recognized:
43
+ * - 'state:manage' — cross-shard zone access.
44
+ * - 'documents:sync' — cross-shard document sync API.
44
45
  */
45
46
  permissions?: string[];
46
47
  }
@@ -61,6 +62,10 @@ export interface AppContext {
61
62
  * Cross-shard zone management API. Only present when the app's
62
63
  * manifest declares the `'state:manage'` permission. Check with
63
64
  * `if (ctx.zones)` before use.
65
+ *
66
+ * Related permissions also recognized by the framework:
67
+ * - 'documents:sync' — cross-shard document sync API (exposed on
68
+ * shard contexts as `ctx.sync()`, not on app contexts).
64
69
  */
65
70
  zones?: ZoneManager;
66
71
  }
@@ -96,6 +101,23 @@ export interface App {
96
101
  * same `AppContext` that `activate` received.
97
102
  */
98
103
  resume?(ctx: AppContext): void | Promise<void>;
104
+ /**
105
+ * Called after the framework has switched the rendered root to this
106
+ * app's layout (i.e. `activeLayout()` now returns the app's active
107
+ * preset tree). Fires on both first launch (after `activate`) and
108
+ * on re-entry from home (after `resume`). This is the earliest hook
109
+ * from which layout-mutation APIs like `spliceIntoActiveLayout`,
110
+ * `focusTab`, `dockIntoActiveLayout` reliably target the app's tree.
111
+ *
112
+ * Use this for boot UX that needs to act on the rendered layout —
113
+ * e.g. reopening a last-used document, restoring tab state, or
114
+ * inserting a "welcome" tab into the current preset. Setup that
115
+ * does not touch the rendered layout (registering views, hydrating
116
+ * state, starting bus subscriptions) belongs in `activate`.
117
+ *
118
+ * See ADR-014.
119
+ */
120
+ onAppReady?(ctx: AppContext): void | Promise<void>;
99
121
  }
100
122
  /**
101
123
  * Source-declared shape of an app manifest — what external package authors
@@ -15,6 +15,8 @@ export interface ShellConfig {
15
15
  type: string;
16
16
  version: string;
17
17
  bundleUrl: string;
18
+ sourceRegistry?: string;
19
+ contractVersion?: string;
18
20
  }>;
19
21
  /** Mount target — CSS selector or element (defaults to '#app') */
20
22
  target?: string | HTMLElement;
@@ -15,8 +15,10 @@ import { __setEnvServerUrl } from './env/index';
15
15
  import { __setTenantId } from './documents/config';
16
16
  import { initFromBoot } from './auth/index';
17
17
  import SignInWall from './auth/SignInWall.svelte';
18
+ import { loadBundleModule } from './registry/loader';
19
+ import { registerLoadedBundle } from './registry/register';
18
20
  export async function createShell(config) {
19
- var _a, _b, _c;
21
+ var _a, _b, _c, _d, _e;
20
22
  const sUrl = (_a = config === null || config === void 0 ? void 0 : config.serverUrl) !== null && _a !== void 0 ? _a : '';
21
23
  // 1. Platform detection
22
24
  const platform = await resolvePlatform();
@@ -45,7 +47,7 @@ export async function createShell(config) {
45
47
  bootConfig = await res.json();
46
48
  }
47
49
  }
48
- catch (_d) {
50
+ catch (_f) {
49
51
  // Server unreachable — boot without auth (offline mode)
50
52
  }
51
53
  }
@@ -73,7 +75,6 @@ export async function createShell(config) {
73
75
  }
74
76
  // 5. Load server-discovered packages
75
77
  if ((_c = config === null || config === void 0 ? void 0 : config.discoveredPackages) === null || _c === void 0 ? void 0 : _c.length) {
76
- const { loadBundleModule } = await import('./registry/loader');
77
78
  for (const pkg of config.discoveredPackages) {
78
79
  try {
79
80
  const res = await fetch(pkg.bundleUrl);
@@ -83,10 +84,11 @@ export async function createShell(config) {
83
84
  }
84
85
  const bytes = await res.arrayBuffer();
85
86
  const loaded = await loadBundleModule(bytes);
86
- for (const s of loaded.shards)
87
- registerShard(s);
88
- for (const a of loaded.apps)
89
- registerApp(a);
87
+ registerLoadedBundle(loaded, {
88
+ version: pkg.version,
89
+ sourceRegistry: (_d = pkg.sourceRegistry) !== null && _d !== void 0 ? _d : '',
90
+ contractVersion: (_e = pkg.contractVersion) !== null && _e !== void 0 ? _e : '',
91
+ });
90
92
  console.log(`[sh3] Loaded discovered package: ${pkg.id}`);
91
93
  }
92
94
  catch (err) {
@@ -18,6 +18,8 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
18
18
  };
19
19
  var _AutosaveControllerImpl_instances, _AutosaveControllerImpl_handle, _AutosaveControllerImpl_path, _AutosaveControllerImpl_debounceMs, _AutosaveControllerImpl_pending, _AutosaveControllerImpl_timer, _AutosaveControllerImpl_dirty, _AutosaveControllerImpl_disposed, _AutosaveControllerImpl_scheduleFlush, _AutosaveControllerImpl_clearTimer;
20
20
  import { documentChanges } from './notifications';
21
+ import { notifyJournal } from './journal-hook';
22
+ import { hashContent } from './sync/hash';
21
23
  const DEFAULT_DEBOUNCE_MS = 1000;
22
24
  /**
23
25
  * Create a document handle scoped to a tenant, shard, and file filter.
@@ -53,10 +55,13 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
53
55
  const existed = await backend.exists(tenantId, shardId, path);
54
56
  await backend.write(tenantId, shardId, path, content);
55
57
  emitChange(existed ? 'update' : 'create', path);
58
+ const hash = await hashContent(content);
59
+ await notifyJournal({ shardId, path, op: 'upsert', hash });
56
60
  },
57
61
  async delete(path) {
58
62
  await backend.delete(tenantId, shardId, path);
59
63
  emitChange('delete', path);
64
+ await notifyJournal({ shardId, path, op: 'delete', hash: null });
60
65
  },
61
66
  async exists(path) {
62
67
  return backend.exists(tenantId, shardId, path);
@@ -4,3 +4,4 @@ export { HttpDocumentBackend } from './http-backend';
4
4
  export { createDocumentHandle } from './handle';
5
5
  export { documentChanges } from './notifications';
6
6
  export { getTenantId, getDocumentBackend, __setTenantId, __setDocumentBackend, } from './config';
7
+ export * from './sync';
@@ -6,3 +6,4 @@ export { HttpDocumentBackend } from './http-backend';
6
6
  export { createDocumentHandle } from './handle';
7
7
  export { documentChanges } from './notifications';
8
8
  export { getTenantId, getDocumentBackend, __setTenantId, __setDocumentBackend, } from './config';
9
+ export * from './sync';
@@ -0,0 +1,6 @@
1
+ import type { JournalEntry } from './sync/types';
2
+ type Appender = (entry: Omit<JournalEntry, 'seq' | 'ts'>) => Promise<void>;
3
+ export declare function setJournalAppender(fn: Appender): void;
4
+ export declare function clearJournalAppender(): void;
5
+ export declare function notifyJournal(entry: Omit<JournalEntry, 'seq' | 'ts'>): Promise<void>;
6
+ export {};
@@ -0,0 +1,16 @@
1
+ /*
2
+ * Journal appender hook — lets the sync engine subscribe to regular
3
+ * shard writes/deletes without creating an import cycle between the
4
+ * document handle and the sync subsystem.
5
+ */
6
+ let appender = null;
7
+ export function setJournalAppender(fn) {
8
+ appender = fn;
9
+ }
10
+ export function clearJournalAppender() {
11
+ appender = null;
12
+ }
13
+ export async function notifyJournal(entry) {
14
+ if (appender)
15
+ await appender(entry);
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { MemoryDocumentBackend } from '../backends';
3
+ import { __setDocumentBackend, __setTenantId } from '../config';
4
+ import { __resetShardRegistryForTest, registerShard, activateShard } from '../../shards/activate.svelte';
5
+ import { __resetSyncBundlesForTest } from './singleton';
6
+ import { PERMISSION_DOCUMENTS_SYNC } from './types';
7
+ describe('ctx.sync() gating', () => {
8
+ beforeEach(() => {
9
+ __resetShardRegistryForTest();
10
+ __resetSyncBundlesForTest();
11
+ __setDocumentBackend(new MemoryDocumentBackend());
12
+ __setTenantId('tenant-a');
13
+ });
14
+ it('is undefined without documents:sync permission', async () => {
15
+ let captured;
16
+ const shard = {
17
+ manifest: { id: 's-none', version: '0', views: [] },
18
+ activate: async (ctx) => { captured = ctx; },
19
+ };
20
+ registerShard(shard);
21
+ await activateShard('s-none');
22
+ expect(captured.sync).toBeUndefined();
23
+ });
24
+ it('is a function when documents:sync is declared', async () => {
25
+ let captured;
26
+ const shard = {
27
+ manifest: { id: 's-sync', version: '0', views: [], permissions: [PERMISSION_DOCUMENTS_SYNC] },
28
+ activate: async (ctx) => { captured = ctx; },
29
+ };
30
+ registerShard(shard);
31
+ await activateShard('s-sync');
32
+ expect(typeof captured.sync).toBe('function');
33
+ const h = captured.sync();
34
+ expect(h.connectorId).toBe('s-sync');
35
+ expect(await h.grantedScopes()).toEqual([]);
36
+ });
37
+ });
@@ -0,0 +1,99 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import SyncGrantPicker from './SyncGrantPicker.svelte';
4
+ import { createSyncRegistry, type SyncRegistry } from '../registry';
5
+ import { getDocumentBackend, getTenantId } from '../../config';
6
+ import type { GrantRecord, SyncScope, ConflictResolution } from '../types';
7
+
8
+ interface Props {
9
+ /** Optional connector-specific filter; if omitted, shows everything. */
10
+ connectorId?: string;
11
+ /** Shard IDs whose conflict artifacts should be listed. */
12
+ conflictShardIds?: string[];
13
+ /** Pending grant request, if any — embeds the picker when set. */
14
+ pendingRequest?: { connectorId: string; scope: SyncScope };
15
+ }
16
+
17
+ let { connectorId, conflictShardIds = [], pendingRequest }: Props = $props();
18
+
19
+ let registry: SyncRegistry | null = $state(null);
20
+ let grants: GrantRecord[] = $state([]);
21
+ let conflicts: ConflictResolution[] = $state([]);
22
+
23
+ async function refresh() {
24
+ if (!registry) return;
25
+ grants = await registry.list(connectorId);
26
+ const all: ConflictResolution[] = [];
27
+ for (const shardId of conflictShardIds) {
28
+ all.push(...await registry.listConflicts(shardId));
29
+ }
30
+ conflicts = all;
31
+ }
32
+
33
+ onMount(async () => {
34
+ registry = createSyncRegistry(getDocumentBackend(), getTenantId());
35
+ await refresh();
36
+ });
37
+
38
+ async function revoke(record: GrantRecord) {
39
+ if (!registry) return;
40
+ await registry.revoke(record.connectorId, record.scope);
41
+ await refresh();
42
+ }
43
+
44
+ function describeScope(s: SyncScope): string {
45
+ if (s.kind === 'tenant') return 'entire tenant';
46
+ if (s.kind === 'shard') return `shard:${s.shardId}`;
47
+ return `shard:${s.shardId}/${s.prefix}`;
48
+ }
49
+ </script>
50
+
51
+ <section class="document-sync-explorer" part="container">
52
+ <h2 part="title">Document Sync</h2>
53
+
54
+ {#if pendingRequest}
55
+ <SyncGrantPicker
56
+ connectorId={pendingRequest.connectorId}
57
+ scope={pendingRequest.scope}
58
+ onGranted={refresh}
59
+ />
60
+ {/if}
61
+
62
+ <h3 part="subtitle">Granted scopes</h3>
63
+ {#if grants.length === 0}
64
+ <p part="empty">No scopes granted yet.</p>
65
+ {:else}
66
+ <ul part="grants">
67
+ {#each grants as g}
68
+ <li>
69
+ <span part="grant-connector">{g.connectorId}</span>
70
+ <span part="grant-scope">{describeScope(g.scope)}</span>
71
+ <button type="button" onclick={() => revoke(g)} part="revoke">Revoke</button>
72
+ </li>
73
+ {/each}
74
+ </ul>
75
+ {/if}
76
+
77
+ <h3 part="subtitle">Conflicts</h3>
78
+ {#if conflicts.length === 0}
79
+ <p part="empty">No active conflicts.</p>
80
+ {:else}
81
+ <ul part="conflicts">
82
+ {#each conflicts as c}
83
+ <li>
84
+ <code part="conflict-path">{c.shardId}:{c.path}</code>
85
+ <small part="conflict-artifact">{c.conflictArtifactPath}</small>
86
+ </li>
87
+ {/each}
88
+ </ul>
89
+ {/if}
90
+ </section>
91
+
92
+ <style>
93
+ .document-sync-explorer {
94
+ display: grid;
95
+ gap: 0.75rem;
96
+ }
97
+ ul { list-style: none; padding: 0; margin: 0; }
98
+ li { display: flex; gap: 0.5rem; align-items: center; }
99
+ </style>
@@ -0,0 +1,15 @@
1
+ import type { SyncScope } from '../types';
2
+ interface Props {
3
+ /** Optional connector-specific filter; if omitted, shows everything. */
4
+ connectorId?: string;
5
+ /** Shard IDs whose conflict artifacts should be listed. */
6
+ conflictShardIds?: string[];
7
+ /** Pending grant request, if any — embeds the picker when set. */
8
+ pendingRequest?: {
9
+ connectorId: string;
10
+ scope: SyncScope;
11
+ };
12
+ }
13
+ declare const DocumentSyncExplorer: import("svelte").Component<Props, {}, "">;
14
+ type DocumentSyncExplorer = ReturnType<typeof DocumentSyncExplorer>;
15
+ export default DocumentSyncExplorer;