sh3-core 0.11.7 → 0.12.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 (36) hide show
  1. package/dist/actions/contextMenuModel.d.ts +5 -4
  2. package/dist/actions/contextMenuModel.js +26 -12
  3. package/dist/actions/contextMenuModel.test.js +49 -24
  4. package/dist/actions/listeners.d.ts +2 -0
  5. package/dist/actions/listeners.js +65 -6
  6. package/dist/actions/listeners.test.js +96 -8
  7. package/dist/actions/scope-helpers.d.ts +23 -0
  8. package/dist/actions/scope-helpers.js +47 -0
  9. package/dist/actions/scope-helpers.test.js +56 -1
  10. package/dist/actions/types.d.ts +1 -0
  11. package/dist/api.d.ts +2 -1
  12. package/dist/api.js +1 -1
  13. package/dist/app/store/InstalledView.svelte +2 -1
  14. package/dist/app/store/StoreView.svelte +2 -1
  15. package/dist/apps/lifecycle.d.ts +7 -0
  16. package/dist/apps/lifecycle.js +22 -5
  17. package/dist/apps/lifecycle.test.js +50 -0
  18. package/dist/documents/browse.d.ts +15 -0
  19. package/dist/documents/browse.js +7 -0
  20. package/dist/documents/browse.test.js +41 -0
  21. package/dist/documents/handle.js +3 -1
  22. package/dist/documents/handle.test.js +23 -0
  23. package/dist/host.js +18 -4
  24. package/dist/layout/LayoutRenderer.svelte +5 -1
  25. package/dist/layout/LayoutRenderer.test.js +42 -0
  26. package/dist/layout/SlotContainer.svelte +11 -2
  27. package/dist/layout/SlotContainer.svelte.d.ts +1 -0
  28. package/dist/layout/slotHostPool.svelte.js +10 -3
  29. package/dist/layout/slotHostPool.test.js +15 -0
  30. package/dist/shards/activate-error-isolation.test.d.ts +1 -0
  31. package/dist/shards/activate-error-isolation.test.js +98 -0
  32. package/dist/shards/activate.svelte.d.ts +30 -2
  33. package/dist/shards/activate.svelte.js +62 -17
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/package.json +1 -1
package/dist/api.js CHANGED
@@ -38,7 +38,7 @@ export { COLOR_PICKER_POINT } from './color/api';
38
38
  // and tooling shards that need to visualize framework state. Phase 9
39
39
  // addition: diagnostic used to reach `activate.svelte` directly via $lib;
40
40
  // the package boundary requires routing through the public surface.
41
- export { registeredShards, activeShards } from './shards/activate.svelte';
41
+ export { registeredShards, activeShards, erroredShards } from './shards/activate.svelte';
42
42
  export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
43
43
  export { validateRegistryIndex } from './registry/schema';
44
44
  // Key mint/revoke types — client shards that declare `keys:mint` get ctx.keys.
@@ -278,7 +278,8 @@
278
278
  }
279
279
  .installed-update-btn {
280
280
  padding: 4px 12px;
281
- background: var(--shell-warning, #ff9800);
281
+ background: var(--shell-warning, #fbbf24);
282
+ color: var(--shell-fg-on-warning, #1a1b1e);
282
283
  font-size: 0.8125rem;
283
284
  }
284
285
  .installed-update-btn:hover:not(:disabled) {
@@ -594,7 +594,8 @@
594
594
  }
595
595
  .store-update-btn {
596
596
  padding: 5px 14px;
597
- background: var(--shell-warning, #ff9800);
597
+ background: var(--shell-warning, #fbbf24);
598
+ color: var(--shell-fg-on-warning, #1a1b1e);
598
599
  font-size: 0.8125rem;
599
600
  }
600
601
  .store-update-btn:hover:not(:disabled) {
@@ -4,6 +4,13 @@
4
4
  * returned to home or no app has ever been launched.
5
5
  */
6
6
  export declare function readLastApp(): string | null;
7
+ /**
8
+ * Public reset for the last-active-app user-zone slot. Used by the host's
9
+ * boot sequence to recover from a sticky last-app launch failure: if the
10
+ * auto-launch fails, this clears the slot so the next reload lands on home
11
+ * instead of looping into the same failure.
12
+ */
13
+ export declare function clearLastApp(): void;
7
14
  /**
8
15
  * Launch an app by id. Activates all required shards (idempotent for
9
16
  * already-active shards), attaches the app's layout, calls `App.activate`,
@@ -20,6 +20,7 @@ import { PERMISSION_STATE_MANAGE } from '../state/types';
20
20
  import { setActiveApp, setUserBindings } from '../actions/state.svelte';
21
21
  import { clearSelectionUnconditional } from '../actions/selection.svelte';
22
22
  import { loadUserBindings } from '../actions/bindings-store';
23
+ import { toastManager } from '../overlays/toast';
23
24
  // ---------- last-active-app user zone ------------------------------------
24
25
  /**
25
26
  * Framework-reserved user-zone slot storing which app to boot into on
@@ -41,6 +42,15 @@ export function readLastApp() {
41
42
  function writeLastApp(id) {
42
43
  lastAppState.user.id = id;
43
44
  }
45
+ /**
46
+ * Public reset for the last-active-app user-zone slot. Used by the host's
47
+ * boot sequence to recover from a sticky last-app launch failure: if the
48
+ * auto-launch fails, this clears the slot so the next reload lands on home
49
+ * instead of looping into the same failure.
50
+ */
51
+ export function clearLastApp() {
52
+ writeLastApp(null);
53
+ }
44
54
  // ---------- app-context state factories ----------------------------------
45
55
  const appContexts = new Map();
46
56
  function getOrCreateAppContext(appId) {
@@ -72,7 +82,7 @@ function getOrCreateAppContext(appId) {
72
82
  * @throws If the app is not registered or a required shard is not registered.
73
83
  */
74
84
  export async function launchApp(id) {
75
- var _a, _b, _c, _d, _e, _f, _g;
85
+ var _a, _b, _c, _d, _e, _f, _g, _h;
76
86
  const app = getRegisteredApp(id);
77
87
  if (!app) {
78
88
  throw new Error(`Cannot launch app "${id}": not registered`);
@@ -118,23 +128,30 @@ export async function launchApp(id) {
118
128
  attachApp(app);
119
129
  try {
120
130
  for (const shardId of app.manifest.requiredShards) {
121
- await activateShard(shardId);
131
+ await activateShard(shardId, { phase: 'launch' });
122
132
  }
123
133
  }
124
134
  catch (err) {
125
135
  detachApp();
136
+ try {
137
+ toastManager.notify(`Couldn't launch "${(_e = app.manifest.label) !== null && _e !== void 0 ? _e : id}": ${err instanceof Error ? err.message : String(err)}`, { level: 'error', duration: 6000 });
138
+ }
139
+ catch (_j) {
140
+ // Toast layer not mounted (e.g. early boot, tests without Shell).
141
+ // Best-effort UX — original error must still propagate.
142
+ }
126
143
  throw err;
127
144
  }
128
145
  // Shards have registered their view factories — safe to take the
129
146
  // refcount holds on the app's slots now (pool's factory lookup
130
147
  // happens in a microtask from this call).
131
148
  acquireAppSlotHolds();
132
- void ((_e = app.activate) === null || _e === void 0 ? void 0 : _e.call(app, getOrCreateAppContext(id)));
149
+ void ((_f = app.activate) === null || _f === void 0 ? void 0 : _f.call(app, getOrCreateAppContext(id)));
133
150
  activeApp.id = id;
134
- setActiveApp(id, new Set((_f = app.manifest.requiredShards) !== null && _f !== void 0 ? _f : []));
151
+ setActiveApp(id, new Set((_g = app.manifest.requiredShards) !== null && _g !== void 0 ? _g : []));
135
152
  void loadUserBindings(id).then(setUserBindings);
136
153
  switchToApp();
137
- void ((_g = app.onAppReady) === null || _g === void 0 ? void 0 : _g.call(app, getOrCreateAppContext(id)));
154
+ void ((_h = app.onAppReady) === null || _h === void 0 ? void 0 : _h.call(app, getOrCreateAppContext(id)));
138
155
  writeLastApp(id);
139
156
  breadcrumbApp.id = id;
140
157
  }
@@ -517,3 +517,53 @@ describe('breadcrumbAppId', () => {
517
517
  expect(getBreadcrumbAppId()).toBe('app-bc3b');
518
518
  });
519
519
  });
520
+ // ---------------------------------------------------------------------------
521
+ // launchApp — error toast on required-shard activation failure
522
+ // ---------------------------------------------------------------------------
523
+ describe('launchApp — error toast on shard failure', () => {
524
+ beforeEach(resetFramework);
525
+ it('fires an error-level toast when a required shard fails to activate', async () => {
526
+ const { toastManager } = await import('../overlays/toast');
527
+ const notifySpy = vi
528
+ .spyOn(toastManager, 'notify')
529
+ .mockImplementation(() => ({ close: () => { } }));
530
+ const badShard = makeShard({
531
+ manifest: makeShardManifest({ id: 'bad-toast' }),
532
+ activate: () => {
533
+ throw new Error('shard "other" not registered');
534
+ },
535
+ });
536
+ registerShard(badShard);
537
+ registerApp(makeApp({
538
+ manifest: makeAppManifest({
539
+ id: 'app-toast',
540
+ label: 'Toast App',
541
+ requiredShards: ['bad-toast'],
542
+ }),
543
+ }));
544
+ await expect(launchApp('app-toast')).rejects.toThrow('shard "other" not registered');
545
+ expect(notifySpy).toHaveBeenCalledTimes(1);
546
+ const [message, options] = notifySpy.mock.calls[0];
547
+ expect(message).toContain('Toast App');
548
+ expect(message).toContain('shard "other" not registered');
549
+ expect(options === null || options === void 0 ? void 0 : options.level).toBe('error');
550
+ notifySpy.mockRestore();
551
+ });
552
+ });
553
+ // ---------------------------------------------------------------------------
554
+ // clearLastApp — public reset for the last-active-app user-zone slot
555
+ // ---------------------------------------------------------------------------
556
+ describe('clearLastApp', () => {
557
+ beforeEach(resetFramework);
558
+ it('writes null to the last-app user zone', async () => {
559
+ const { clearLastApp, readLastApp } = await import('./lifecycle');
560
+ registerShard(makeShard({ manifest: makeShardManifest({ id: 's-cla' }) }));
561
+ registerApp(makeApp({
562
+ manifest: makeAppManifest({ id: 'app-cla', requiredShards: ['s-cla'] }),
563
+ }));
564
+ await launchApp('app-cla');
565
+ expect(readLastApp()).toBe('app-cla');
566
+ clearLastApp();
567
+ expect(readLastApp()).toBeNull();
568
+ });
569
+ });
@@ -76,6 +76,21 @@ export interface BrowseCapability {
76
76
  renameFrom?(shardId: string, oldPath: string, newPath: string, opts?: {
77
77
  newShardId?: string;
78
78
  }): Promise<void>;
79
+ /**
80
+ * Delete a document in another shard's namespace within the active
81
+ * tenant. Available only when the caller declares both
82
+ * `documents:browse` and `documents:write`. Emits a `'delete'`
83
+ * `DocumentChange` so other shards and the file-explorer pick up
84
+ * the removal. Tenant-scoped — cannot cross tenants.
85
+ *
86
+ * Idempotent: deleting a non-existent path resolves successfully
87
+ * and emits no change event.
88
+ *
89
+ * Absent (undefined) on the capability object when `documents:write`
90
+ * is not declared; feature-detect with
91
+ * `typeof ctx.browse.deleteFrom === 'function'`.
92
+ */
93
+ deleteFrom?(shardId: string, path: string): Promise<void>;
79
94
  }
80
95
  export interface BrowseCapabilityOptions {
81
96
  /** When true, the returned capability exposes `readFrom`. */
@@ -59,6 +59,13 @@ export function createBrowseCapability(tenantId, backend, options = { canRead: f
59
59
  shardId,
60
60
  });
61
61
  };
62
+ capability.deleteFrom = async (shardId, path) => {
63
+ const existed = await backend.exists(tenantId, shardId, path);
64
+ await backend.delete(tenantId, shardId, path);
65
+ if (existed) {
66
+ documentChanges.emit({ type: 'delete', path, tenantId, shardId });
67
+ }
68
+ };
62
69
  }
63
70
  return capability;
64
71
  }
@@ -263,4 +263,45 @@ describe('BrowseCapability', () => {
263
263
  .rejects.toThrow(/does not support resolveConflict/);
264
264
  });
265
265
  });
266
+ describe('deleteFrom (documents:write gate)', () => {
267
+ it('absent when canWrite is false', () => {
268
+ const be = new MemoryDocumentBackend();
269
+ const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
270
+ expect(browse.deleteFrom).toBeUndefined();
271
+ });
272
+ it('present when canWrite is true', () => {
273
+ const be = new MemoryDocumentBackend();
274
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
275
+ expect(typeof browse.deleteFrom).toBe('function');
276
+ });
277
+ it('deletes from the target shard namespace and emits a delete event', async () => {
278
+ const be = new MemoryDocumentBackend();
279
+ await be.write('t1', 'target-shard', 'a.txt', 'hello');
280
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
281
+ const events = [];
282
+ const unsub = documentChanges.subscribe((c) => events.push(c));
283
+ await browse.deleteFrom('target-shard', 'a.txt');
284
+ expect(await be.read('t1', 'target-shard', 'a.txt')).toBeNull();
285
+ expect(events).toEqual([
286
+ { type: 'delete', path: 'a.txt', tenantId: 't1', shardId: 'target-shard' },
287
+ ]);
288
+ unsub();
289
+ });
290
+ it('is idempotent on missing paths and emits no event', async () => {
291
+ const be = new MemoryDocumentBackend();
292
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
293
+ const events = [];
294
+ const unsub = documentChanges.subscribe((c) => events.push(c));
295
+ await expect(browse.deleteFrom('target-shard', 'nope.txt')).resolves.toBeUndefined();
296
+ expect(events).toEqual([]);
297
+ unsub();
298
+ });
299
+ it('never crosses tenants: a t1 capability cannot delete t2 docs', async () => {
300
+ const be = new MemoryDocumentBackend();
301
+ await be.write('t2', 's', 'secret.txt', 'hidden');
302
+ const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
303
+ await browse.deleteFrom('s', 'secret.txt');
304
+ expect(await be.read('t2', 's', 'secret.txt')).toBe('hidden');
305
+ });
306
+ });
266
307
  });
@@ -55,8 +55,10 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
55
55
  emitChange(existed ? 'update' : 'create', path);
56
56
  },
57
57
  async delete(path) {
58
+ const existed = await backend.exists(tenantId, shardId, path);
58
59
  await backend.delete(tenantId, shardId, path);
59
- emitChange('delete', path);
60
+ if (existed)
61
+ emitChange('delete', path);
60
62
  },
61
63
  async rename(oldPath, newPath) {
62
64
  if (!matchesExtensions(newPath)) {
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { MemoryDocumentBackend } from './backends';
3
3
  import { createDocumentHandle } from './handle';
4
+ import { documentChanges } from './notifications';
4
5
  function harness() {
5
6
  const backend = new MemoryDocumentBackend();
6
7
  const handle = createDocumentHandle('tenant1', 'shard1', backend, { format: 'text' });
@@ -141,3 +142,25 @@ describe('DocumentHandle.rename', () => {
141
142
  .rejects.toThrow(/extensions/);
142
143
  });
143
144
  });
145
+ describe('DocumentHandle.delete()', () => {
146
+ it('emits a delete event when the path existed', async () => {
147
+ const { backend, handle } = harness();
148
+ await backend.write('tenant1', 'shard1', 'a.txt', 'hi');
149
+ const events = [];
150
+ const unsub = documentChanges.subscribe((c) => events.push(c));
151
+ await handle.delete('a.txt');
152
+ expect(await backend.read('tenant1', 'shard1', 'a.txt')).toBeNull();
153
+ expect(events).toEqual([
154
+ { type: 'delete', path: 'a.txt', tenantId: 'tenant1', shardId: 'shard1' },
155
+ ]);
156
+ unsub();
157
+ });
158
+ it('emits no event when the path did not exist', async () => {
159
+ const { handle } = harness();
160
+ const events = [];
161
+ const unsub = documentChanges.subscribe((c) => events.push(c));
162
+ await expect(handle.delete('nope.txt')).resolves.toBeUndefined();
163
+ expect(events).toEqual([]);
164
+ unsub();
165
+ });
166
+ });
package/dist/host.js CHANGED
@@ -18,7 +18,7 @@
18
18
  import { registerShard as registerShardInternal, activateShard, registeredShards, } from './shards/activate.svelte';
19
19
  import { addAutostartShard } from './actions/state.svelte';
20
20
  import { registerApp, registeredApps } from './apps/registry.svelte';
21
- import { launchApp, readLastApp } from './apps/lifecycle';
21
+ import { launchApp, readLastApp, clearLastApp } from './apps/lifecycle';
22
22
  import { sh3coreShard } from './sh3core-shard/sh3coreShard.svelte';
23
23
  import { shellShard } from './shell-shard/shellShard.svelte';
24
24
  import { storeShard } from './app/store/storeShard.svelte';
@@ -77,13 +77,27 @@ export async function bootstrap(config) {
77
77
  for (const [id, shard] of registeredShards) {
78
78
  if (shard.autostart) {
79
79
  addAutostartShard(id);
80
- await activateShard(id);
80
+ try {
81
+ await activateShard(id, { phase: 'autostart' });
82
+ }
83
+ catch (_a) {
84
+ // Already logged + recorded in erroredShards by activateShard.
85
+ // One bad self-starting shard must not prevent the shell from booting.
86
+ }
81
87
  }
82
88
  }
83
- // 5. Read the last-active app from the user zone
89
+ // 5. Read the last-active app from the user zone. If auto-launch fails,
90
+ // clear the slot so the next reload lands on home instead of looping
91
+ // into the same failure. No toast — the user did not initiate this.
84
92
  const lastId = readLastApp();
85
93
  if (lastId && registeredApps.has(lastId)) {
86
- await launchApp(lastId);
94
+ try {
95
+ await launchApp(lastId);
96
+ }
97
+ catch (err) {
98
+ console.error(`[sh3] Auto-launch of "${lastId}" failed:`, err);
99
+ clearLastApp();
100
+ }
87
101
  }
88
102
  }
89
103
  export { installPackage, listInstalledPackages } from './registry/installer';
@@ -202,7 +202,11 @@
202
202
  {@const entry = tabs?.tabs[i]}
203
203
  {#if entry}
204
204
  <div class="tab-slot-wrapper">
205
- <SlotContainer node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId }} label={entry.label} />
205
+ <SlotContainer
206
+ node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId }}
207
+ label={entry.label}
208
+ meta={entry.meta}
209
+ />
206
210
  <SlotDropZone {rootRef} path={path} />
207
211
  </div>
208
212
  {/if}
@@ -119,6 +119,48 @@ describe('LayoutRenderer — C.4 tabless preset', () => {
119
119
  expect(container.querySelector('[role="tab"]')).toBeNull();
120
120
  });
121
121
  });
122
+ describe('LayoutRenderer — C.6 TabEntry.meta threading', () => {
123
+ beforeEach(resetFramework);
124
+ it('forwards meta on a tab added post-launch (e.g. via floatManager.open)', async () => {
125
+ let captured;
126
+ registerView('test:meta-view', {
127
+ mount(_container, ctx) {
128
+ captured = ctx.meta;
129
+ return { unmount: () => { } };
130
+ },
131
+ });
132
+ // Launch with a placeholder tab — its slotId differs from the one we
133
+ // push later, so acquireAppSlotHolds takes no hold for the new tab.
134
+ // The new tab's first acquire is therefore SlotContainer's $effect,
135
+ // which is the gap the bug lives in.
136
+ registerApp(makeApp({
137
+ manifest: makeAppManifest({ id: 'c6' }),
138
+ initialLayout: [
139
+ {
140
+ name: 'default',
141
+ tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'placeholder', viewId: 'test:view' })])),
142
+ },
143
+ ],
144
+ }));
145
+ await launchApp('c6');
146
+ renderWithShell(LayoutRenderer, { path: [] });
147
+ await tick();
148
+ const root = layoutStore.root;
149
+ if ((root === null || root === void 0 ? void 0 : root.type) !== 'tabs')
150
+ throw new Error('expected tabs root');
151
+ root.tabs.push({
152
+ slotId: 'meta-slot',
153
+ viewId: 'test:meta-view',
154
+ label: 'M',
155
+ meta: { source: 'data:url', isBlob: false, label: 'pic.png' },
156
+ });
157
+ root.activeTab = 1;
158
+ await tick();
159
+ // acquireSlotHost defers factory.mount via queueMicrotask — flush it.
160
+ await Promise.resolve();
161
+ expect(captured).toEqual({ source: 'data:url', isBlob: false, label: 'pic.png' });
162
+ });
163
+ });
122
164
  describe('LayoutRenderer — C.5 invalid path', () => {
123
165
  beforeEach(resetFramework);
124
166
  it('renders nothing when the path no longer resolves to a node', async () => {
@@ -38,7 +38,11 @@
38
38
  import { getView } from '../shards/registry';
39
39
  import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
40
40
 
41
- let { node, label = '' }: { node: SlotNode; label?: string } = $props();
41
+ let {
42
+ node,
43
+ label = '',
44
+ meta,
45
+ }: { node: SlotNode; label?: string; meta?: Record<string, unknown> } = $props();
42
46
 
43
47
  let wrapper: HTMLDivElement | undefined = $state();
44
48
  let width = $state(0);
@@ -62,7 +66,12 @@
62
66
  // detach if still in our wrapper" guard.
63
67
  const currentSlotId = node.slotId;
64
68
  const wrapperEl = wrapper;
65
- const host = acquireSlotHost(currentSlotId, node.viewId, label || node.viewId || currentSlotId);
69
+ const host = acquireSlotHost(
70
+ currentSlotId,
71
+ node.viewId,
72
+ label || node.viewId || currentSlotId,
73
+ meta,
74
+ );
66
75
  wrapperEl.appendChild(host);
67
76
 
68
77
  // Local observer exists only to drive the placeholder's dims text;
@@ -2,6 +2,7 @@ import type { SlotNode } from './types';
2
2
  type $$ComponentProps = {
3
3
  node: SlotNode;
4
4
  label?: string;
5
+ meta?: Record<string, unknown>;
5
6
  };
6
7
  declare const SlotContainer: import("svelte").Component<$$ComponentProps, {}, "">;
7
8
  type SlotContainer = ReturnType<typeof SlotContainer>;
@@ -35,6 +35,7 @@
35
35
  import { getView, __addViewRegistrationListener } from '../shards/registry';
36
36
  import { locateSlotIn } from './ops';
37
37
  import { activeLayout } from './store.svelte';
38
+ import { scopeToString } from '../actions/scope-helpers';
38
39
  const pool = new Map();
39
40
  const pendingDestroy = new Set();
40
41
  /**
@@ -134,8 +135,10 @@ function createHost(slotId, viewId, label, meta) {
134
135
  const host = document.createElement('div');
135
136
  host.className = 'slot-host';
136
137
  host.dataset.slotId = slotId;
137
- if (viewId)
138
+ if (viewId) {
138
139
  host.setAttribute('data-sh3-view', viewId);
140
+ host.setAttribute('data-sh3-scope', scopeToString(`focus:${viewId}`));
141
+ }
139
142
  // Position:absolute inset:0 so the host fills whichever wrapper it is
140
143
  // attached to. The wrapper is what the layout engine sizes; the host
141
144
  // just tracks it. Styles are set inline (not in a class) so consumers
@@ -220,10 +223,14 @@ export function acquireSlotHost(slotId, viewId, label, meta) {
220
223
  `but existing pooled entry has viewId "${entry.viewId}". Attribute synced; ` +
221
224
  `view handle unchanged.`);
222
225
  entry.viewId = viewId;
223
- if (viewId)
226
+ if (viewId) {
224
227
  entry.host.setAttribute('data-sh3-view', viewId);
225
- else
228
+ entry.host.setAttribute('data-sh3-scope', scopeToString(`focus:${viewId}`));
229
+ }
230
+ else {
226
231
  entry.host.removeAttribute('data-sh3-view');
232
+ entry.host.removeAttribute('data-sh3-scope');
233
+ }
227
234
  }
228
235
  entry.refcount++;
229
236
  return entry.host;
@@ -116,3 +116,18 @@ describe('slotHostPool — D.6 data-sh3-view attribute', () => {
116
116
  releaseSlotHost('slot-2');
117
117
  });
118
118
  });
119
+ // ─── D.7 ─────────────────────────────────────────────────────────────────────
120
+ describe('slotHostPool — D.7 data-sh3-scope attribute', () => {
121
+ beforeEach(resetFramework);
122
+ it('pooled host has data-sh3-scope="focus:<viewId>" alongside data-sh3-view', () => {
123
+ const host = acquireSlotHost('slot-3', 'editor', 'Editor');
124
+ expect(host.getAttribute('data-sh3-view')).toBe('editor');
125
+ expect(host.getAttribute('data-sh3-scope')).toBe('focus:editor');
126
+ releaseSlotHost('slot-3');
127
+ });
128
+ it('pooled host has no data-sh3-scope when viewId is null', () => {
129
+ const host = acquireSlotHost('slot-4', null, 'Empty');
130
+ expect(host.hasAttribute('data-sh3-scope')).toBe(false);
131
+ releaseSlotHost('slot-4');
132
+ });
133
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { MemoryDocumentBackend } from '../documents/backends';
3
+ import { __setDocumentBackend, __setTenantId } from '../documents/config';
4
+ import { registerShard, activateShard, registeredShards, activeShards, __resetShardRegistryForTest, erroredShards, } from './activate.svelte';
5
+ describe('erroredShards map', () => {
6
+ beforeEach(() => {
7
+ __resetShardRegistryForTest();
8
+ __setDocumentBackend(new MemoryDocumentBackend());
9
+ __setTenantId('tenant-a');
10
+ });
11
+ it('is empty after reset', () => {
12
+ expect(erroredShards.size).toBe(0);
13
+ });
14
+ it('supports the Map read API used by callers', () => {
15
+ expect(typeof erroredShards.has).toBe('function');
16
+ expect(typeof erroredShards.get).toBe('function');
17
+ expect(erroredShards.has('anything')).toBe(false);
18
+ });
19
+ });
20
+ describe('activateShard — unwind on activation failure', () => {
21
+ beforeEach(() => {
22
+ __resetShardRegistryForTest();
23
+ __setDocumentBackend(new MemoryDocumentBackend());
24
+ __setTenantId('tenant-a');
25
+ });
26
+ it('unwinds partial state and records the error when activate throws', async () => {
27
+ const shard = {
28
+ manifest: {
29
+ id: 'broken',
30
+ label: 'Broken',
31
+ version: '0.0.0',
32
+ views: [],
33
+ },
34
+ activate(ctx) {
35
+ ctx.registerView('broken:view', { mount: () => ({ unmount() { } }) });
36
+ throw new Error('dependency missing');
37
+ },
38
+ };
39
+ registerShard(shard);
40
+ await expect(activateShard('broken')).rejects.toThrow('dependency missing');
41
+ expect(activeShards.has('broken')).toBe(false);
42
+ expect(registeredShards.has('broken')).toBe(true);
43
+ const entry = erroredShards.get('broken');
44
+ expect(entry).toBeDefined();
45
+ expect(entry === null || entry === void 0 ? void 0 : entry.id).toBe('broken');
46
+ expect(entry === null || entry === void 0 ? void 0 : entry.phase).toBe('launch');
47
+ expect(entry === null || entry === void 0 ? void 0 : entry.error).toBeInstanceOf(Error);
48
+ expect(typeof (entry === null || entry === void 0 ? void 0 : entry.timestamp)).toBe('number');
49
+ const { getView } = await import('./registry');
50
+ expect(getView('broken:view')).toBeUndefined();
51
+ });
52
+ it('records phase "autostart" when called with that option', async () => {
53
+ var _a;
54
+ const shard = {
55
+ manifest: { id: 'broken-auto', label: 'B', version: '0.0.0', views: [] },
56
+ activate() {
57
+ throw new Error('no');
58
+ },
59
+ };
60
+ registerShard(shard);
61
+ await expect(activateShard('broken-auto', { phase: 'autostart' })).rejects.toThrow('no');
62
+ expect((_a = erroredShards.get('broken-auto')) === null || _a === void 0 ? void 0 : _a.phase).toBe('autostart');
63
+ });
64
+ it('clears the error entry when the shard is re-registered', async () => {
65
+ const broken = {
66
+ manifest: { id: 'reborn', label: 'R', version: '0.0.0', views: [] },
67
+ activate() {
68
+ throw new Error('first try');
69
+ },
70
+ };
71
+ registerShard(broken);
72
+ await expect(activateShard('reborn')).rejects.toThrow('first try');
73
+ expect(erroredShards.has('reborn')).toBe(true);
74
+ const fixed = {
75
+ manifest: { id: 'reborn', label: 'R', version: '0.0.1', views: [] },
76
+ activate() { },
77
+ };
78
+ registerShard(fixed);
79
+ expect(erroredShards.has('reborn')).toBe(false);
80
+ });
81
+ it('clears the error entry when activation eventually succeeds', async () => {
82
+ let shouldFail = true;
83
+ const shard = {
84
+ manifest: { id: 'flaky', label: 'F', version: '0.0.0', views: [] },
85
+ activate() {
86
+ if (shouldFail)
87
+ throw new Error('first try');
88
+ },
89
+ };
90
+ registerShard(shard);
91
+ await expect(activateShard('flaky')).rejects.toThrow('first try');
92
+ expect(erroredShards.has('flaky')).toBe(true);
93
+ shouldFail = false;
94
+ await activateShard('flaky');
95
+ expect(erroredShards.has('flaky')).toBe(false);
96
+ expect(activeShards.has('flaky')).toBe(true);
97
+ });
98
+ });
@@ -9,6 +9,19 @@ import type { Shard, ShardContext } from './types';
9
9
  */
10
10
  export declare const registeredShards: Map<string, Shard>;
11
11
  export declare const activeShards: Map<string, Shard>;
12
+ /**
13
+ * Reactive map of shard ids that failed to activate. Populated by
14
+ * `activateShard`'s catch block; cleared when the shard is successfully
15
+ * re-registered or activated. Read-only for shards; intended for diagnostic
16
+ * and admin tooling that wants to surface broken shards to the user.
17
+ */
18
+ export interface ShardErrorEntry {
19
+ id: string;
20
+ error: unknown;
21
+ phase: 'autostart' | 'launch';
22
+ timestamp: number;
23
+ }
24
+ export declare const erroredShards: Map<string, ShardErrorEntry>;
12
25
  /**
13
26
  * Register (or re-register) a shard with the framework so it can later be
14
27
  * activated. Records the shard in `registeredShards` but does not run
@@ -20,16 +33,31 @@ export declare const activeShards: Map<string, Shard>;
20
33
  * activated on next launch.
21
34
  */
22
35
  export declare function registerShard(shard: Shard): void;
36
+ export interface ActivateShardOpts {
37
+ /**
38
+ * Where this activation was initiated from. Determines the `phase` field
39
+ * recorded in `erroredShards` if activation fails. Defaults to 'launch'
40
+ * (the common case — required by an app being launched).
41
+ */
42
+ phase?: 'autostart' | 'launch';
43
+ }
23
44
  /**
24
45
  * Activate a registered shard. Builds a `ShardContext`, calls `shard.activate`,
25
46
  * verifies that every view declared in the manifest received a factory, then
26
47
  * calls `shard.autostart` if defined. Idempotent — calling on an already-active
27
48
  * shard is a no-op.
28
49
  *
50
+ * If `shard.activate` throws, partial state (registered views, verbs,
51
+ * contributions, document handles, actions, env subscription) is unwound
52
+ * and the failure is recorded in `erroredShards` before the error is
53
+ * re-thrown. Callers in `host.ts` (autostart loop) and `launchApp`
54
+ * (required-shard loop) decide how to react.
55
+ *
29
56
  * @param id - The `ShardManifest.id` of the shard to activate. Must be registered.
30
- * @throws If the shard is not registered, or if a manifest view has no factory after activation.
57
+ * @param opts - Optional. `phase` is recorded in `erroredShards` on failure (default 'launch').
58
+ * @throws If the shard is not registered, if `shard.activate` throws, or if a manifest view has no factory after activation.
31
59
  */
32
- export declare function activateShard(id: string): Promise<void>;
60
+ export declare function activateShard(id: string, opts?: ActivateShardOpts): Promise<void>;
33
61
  /**
34
62
  * Deactivate an active shard. Calls `shard.deactivate`, flushes and disposes
35
63
  * all document handles, unregisters all view factories, and removes the shard