sh3-core 0.22.5 → 0.23.2

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 (77) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/api.js +1 -1
  3. package/dist/app/admin/adminApp.js +2 -0
  4. package/dist/app/admin/adminShard.svelte.js +1 -0
  5. package/dist/app/store/storeApp.js +3 -1
  6. package/dist/app/store/storeShard.svelte.js +1 -0
  7. package/dist/app-appearance/appearanceShard.svelte.js +1 -0
  8. package/dist/apps/lifecycle.js +22 -10
  9. package/dist/apps/lifecycle.test.js +53 -1
  10. package/dist/apps/types.d.ts +9 -0
  11. package/dist/chrome/CompactChrome.svelte +11 -7
  12. package/dist/createShell.js +40 -0
  13. package/dist/documents/picker-api.test.js +40 -0
  14. package/dist/documents/picker-primitive.d.ts +39 -1
  15. package/dist/documents/picker-primitive.js +5 -4
  16. package/dist/host.js +30 -7
  17. package/dist/layout/slotHostPool.svelte.d.ts +11 -0
  18. package/dist/layout/slotHostPool.svelte.js +41 -17
  19. package/dist/layout/slotHostPool.test.js +45 -1
  20. package/dist/layouts-shard/layoutsShard.svelte.js +1 -0
  21. package/dist/overlays/OverlayRoots.svelte +15 -4
  22. package/dist/overlays/__test__/OverlayBindHarness.svelte +20 -0
  23. package/dist/overlays/__test__/OverlayBindHarness.svelte.d.ts +3 -0
  24. package/dist/overlays/float-compact-bind.svelte.test.d.ts +1 -0
  25. package/dist/overlays/float-compact-bind.svelte.test.js +51 -0
  26. package/dist/overlays/modal.js +3 -0
  27. package/dist/overlays/modal.test.js +45 -0
  28. package/dist/overlays/types.d.ts +9 -0
  29. package/dist/primitives/widgets/ShardPicker.svelte +38 -0
  30. package/dist/primitives/widgets/ShardPicker.svelte.d.ts +9 -0
  31. package/dist/primitives/widgets/_DocumentBrowser.svelte +11 -3
  32. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +2 -0
  33. package/dist/projects/scope-gate.d.ts +4 -0
  34. package/dist/projects/scope-gate.js +51 -0
  35. package/dist/projects/scope-gate.test.d.ts +1 -0
  36. package/dist/projects/scope-gate.test.js +92 -0
  37. package/dist/projects-shard/ProjectManage.svelte +42 -2
  38. package/dist/projects-shard/ProjectManage.svelte.test.js +10 -9
  39. package/dist/projects-shard/projectsApi.d.ts +3 -2
  40. package/dist/projects-shard/projectsApi.test.js +1 -1
  41. package/dist/projects-shard/projectsShard.svelte.js +1 -0
  42. package/dist/runtime/runVerb.d.ts +9 -0
  43. package/dist/runtime/runVerb.js +4 -4
  44. package/dist/runtime/runVerb.test.js +29 -0
  45. package/dist/sh3Api/headless.d.ts +7 -0
  46. package/dist/sh3Api/headless.js +3 -1
  47. package/dist/sh3Api/headless.svelte.test.js +42 -0
  48. package/dist/sh3core-shard/Sh3Home.svelte +3 -3
  49. package/dist/sh3core-shard/sh3coreShard.svelte.js +1 -0
  50. package/dist/shards/lifecycle.svelte.d.ts +8 -2
  51. package/dist/shards/lifecycle.svelte.js +65 -7
  52. package/dist/shards/lifecycle.test.js +110 -1
  53. package/dist/shards/types.d.ts +13 -0
  54. package/dist/shell-shard/Terminal.svelte +1 -4
  55. package/dist/shell-shard/Terminal.svelte.d.ts +0 -2
  56. package/dist/shell-shard/dispatch.d.ts +0 -2
  57. package/dist/shell-shard/dispatch.js +0 -2
  58. package/dist/shell-shard/display-cwd.test.js +4 -4
  59. package/dist/shell-shard/manifest.js +1 -0
  60. package/dist/shell-shard/shellShard.svelte.d.ts +1 -1
  61. package/dist/shell-shard/shellShard.svelte.js +9 -4
  62. package/dist/shell-shard/verbs/cat.js +3 -3
  63. package/dist/shell-shard/verbs/cat.test.js +1 -2
  64. package/dist/shell-shard/verbs/ls.js +2 -2
  65. package/dist/shell-shard/verbs/ls.test.js +1 -2
  66. package/dist/shell-shard/verbs/mkdir.js +3 -3
  67. package/dist/shell-shard/verbs/mkdir.test.js +1 -2
  68. package/dist/shell-shard/verbs/mv.js +3 -3
  69. package/dist/shell-shard/verbs/mv.test.js +1 -2
  70. package/dist/shell-shard/verbs/rm.js +3 -3
  71. package/dist/shell-shard/verbs/rm.test.js +1 -2
  72. package/dist/shell-shard/verbs/xfer.js +5 -5
  73. package/dist/shell-shard/verbs/xfer.test.js +2 -2
  74. package/dist/verbs/types.d.ts +10 -2
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/package.json +1 -1
package/dist/api.d.ts CHANGED
@@ -38,7 +38,7 @@ export type { ConflictItem, ConflictBranch as ConflictManagerBranch, ResolveOpti
38
38
  export { CONFLICT_RENDERER_POINT, ConflictPermissionError, ConflictSessionOrphanedError, } from './conflicts/api';
39
39
  export type { ColorPickOptions, ColorContribution, ColorApi, } from './color/api';
40
40
  export { COLOR_PICKER_POINT } from './color/api';
41
- export { registeredShards, activeShards, erroredShards } from './shards/lifecycle.svelte';
41
+ export { registeredShards, activeShards, erroredShards, listRegisteredShards } from './shards/lifecycle.svelte';
42
42
  export type { ShardErrorEntry } from './shards/lifecycle.svelte';
43
43
  export type { RegistryIndex, PackageEntry, PackageVersion, RequiredDependency, InstalledPackage, InstallResult, PackageMeta, RemoteInstallRequest, } from './registry/types';
44
44
  export type { ResolvedPackage } from './registry/client';
package/dist/api.js CHANGED
@@ -42,7 +42,7 @@ export { COLOR_PICKER_POINT } from './color/api';
42
42
  // and tooling shards that need to visualize framework state. Phase 9
43
43
  // addition: diagnostic used to reach `activate.svelte` directly via $lib;
44
44
  // the package boundary requires routing through the public surface.
45
- export { registeredShards, activeShards, erroredShards } from './shards/lifecycle.svelte';
45
+ export { registeredShards, activeShards, erroredShards, listRegisteredShards } from './shards/lifecycle.svelte';
46
46
  export { fetchRegistries, fetchArchive, buildPackageMeta } from './registry/client';
47
47
  export { validateRegistryIndex } from './registry/schema';
48
48
  // Key mint/revoke types — client shards that declare `keys:mint` get ctx.keys.
@@ -8,6 +8,8 @@ export const adminApp = {
8
8
  manifest: {
9
9
  id: 'sh3-admin-app',
10
10
  label: 'Admin',
11
+ icon: 'cpu',
12
+ color: '#D16F19',
11
13
  version: VERSION,
12
14
  requiredShards: ['sh3-admin'],
13
15
  layoutVersion: 1,
@@ -24,6 +24,7 @@ export const adminShard = {
24
24
  id: 'sh3-admin',
25
25
  label: 'Admin',
26
26
  version: VERSION,
27
+ kind: 'system',
27
28
  permissions: ['documents:mount'],
28
29
  views: [
29
30
  { id: 'sh3-admin:users', label: 'Users' },
@@ -10,7 +10,9 @@ import { VERSION } from '../../version';
10
10
  export const storeApp = {
11
11
  manifest: {
12
12
  id: 'sh3-store-app',
13
- label: 'Package Store',
13
+ label: 'Package Manager',
14
+ icon: 'file-archive',
15
+ color: '#D16F19',
14
16
  version: VERSION,
15
17
  requiredShards: ['sh3-store'],
16
18
  layoutVersion: 1,
@@ -65,6 +65,7 @@ export const storeShard = {
65
65
  id: 'sh3-store',
66
66
  label: 'Package Store',
67
67
  version: VERSION,
68
+ kind: 'system',
68
69
  views: [
69
70
  { id: 'sh3-store:browse', label: 'Store' },
70
71
  ],
@@ -47,6 +47,7 @@ export const appearanceShard = {
47
47
  id: '__app-appearance__',
48
48
  label: 'App Appearance',
49
49
  version: VERSION,
50
+ kind: 'system',
50
51
  views: [],
51
52
  },
52
53
  register(ctx) {
@@ -11,11 +11,13 @@
11
11
  * `{ id: string | null }`. Writing happens on launch (id) and on
12
12
  * return-to-home (null). Boot reads it to decide whether to auto-launch.
13
13
  */
14
+ import { flushSync } from 'svelte';
14
15
  import { createStateZones } from '../state/zones.svelte';
15
16
  import { createGestureRegistry } from '../gestures';
16
17
  import { registeredShards, } from '../shards/lifecycle.svelte';
17
18
  import { shardEntries, runAppActivate, runAppDeactivate, registerAllShards, erroredShards } from '../shards/lifecycle.svelte';
18
19
  import { attachApp, acquireAppSlotHolds, detachApp, switchToApp, switchToHome, getActiveAppTree, } from '../layout/store.svelte';
20
+ import { flushPendingDestroys } from '../layout/slotHostPool.svelte';
19
21
  import { activeApp, breadcrumbApp, getRegisteredApp, registeredApps } from './registry.svelte';
20
22
  import { createZoneManager } from '../state/manage';
21
23
  import { PERMISSION_STATE_MANAGE } from '../state/types';
@@ -227,19 +229,29 @@ export function unloadApp(id, skipSwitchToHome = false) {
227
229
  if (!app)
228
230
  return;
229
231
  void ((_a = app.deactivate) === null || _a === void 0 ? void 0 : _a.call(app));
230
- // v3: call runAppDeactivate for every required shard. The shard stays
231
- // active (its register() output is intact); only its per-app bindings
232
- // and per-app contribution registrations are torn down.
233
- for (const shardId of app.manifest.requiredShards) {
234
- void runAppDeactivate(shardId, id);
235
- }
236
- // Detach layout (releases the refcount holds; pool cleanup runs on
237
- // the next microtask for any slots that no longer have a renderer).
238
- // Switch to home first so LayoutRenderer stops reading the app's
239
- // tree before detachApp drops its references.
232
+ // Teardown order is load-bearing. Views must be fully unmounted before
233
+ // any shard's onAppDeactivate runs, so shards can safely close stores /
234
+ // nullify reactive state in onAppDeactivate without tripping leaked
235
+ // $derived references in still-mounted views (the leaked-pool bug).
236
+ //
237
+ // 1) switchToHome flips the rendered root so LayoutRenderer stops
238
+ // reading the app's tree. SlotContainer cleanups are scheduled.
239
+ // 2) flushSync forces Svelte to run those cleanups now (drops the
240
+ // renderer's refcount on every app slot).
241
+ // 3) detachApp releases the per-app refcount holds. Pool entries for
242
+ // the app's slots are now at refcount 0 and queued in pendingDestroy.
243
+ // 4) flushPendingDestroys synchronously runs the destroy body for
244
+ // those entries — the Svelte components for the app's views are
245
+ // unmounted now, not on a deferred microtask.
246
+ // 5) Only now do we call runAppDeactivate on each required shard.
240
247
  if (!skipSwitchToHome)
241
248
  switchToHome();
249
+ flushSync();
242
250
  detachApp();
251
+ flushPendingDestroys();
252
+ for (const shardId of app.manifest.requiredShards) {
253
+ void runAppDeactivate(shardId, id);
254
+ }
243
255
  activeApp.id = null;
244
256
  setActiveApp(null, new Set());
245
257
  clearSelectionUnconditional();
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { resetFramework } from '../__test__/reset';
3
3
  import { makeApp, makeShard, makeAppManifest, makeShardManifest, makeTabsNode, makeTabEntry, makeSlotNode, makeTree, } from '../__test__/fixtures';
4
- import { launchApp, returnToHome, unregisterApp } from './lifecycle';
4
+ import { launchApp, returnToHome, unloadApp, unregisterApp } from './lifecycle';
5
5
  import { registerApp } from './registry.svelte';
6
6
  import { registerShard } from '../shards/lifecycle.svelte';
7
7
  import { presetManager } from '../overlays/presets';
@@ -703,6 +703,58 @@ describe('unloadApp — onAppDeactivate hook', () => {
703
703
  expect(deactivated).toEqual(['deact-app']);
704
704
  });
705
705
  });
706
+ describe('unloadApp — view unmount happens before onAppDeactivate', () => {
707
+ beforeEach(resetFramework);
708
+ it('unmounts all of the app\'s pooled views before any shard\'s onAppDeactivate fires', async () => {
709
+ const order = [];
710
+ const shard = makeShard({
711
+ manifest: makeShardManifest({ id: 'order-shard' }),
712
+ register() { },
713
+ onAppDeactivate() { order.push('onAppDeactivate'); },
714
+ });
715
+ registerShard(shard);
716
+ // Register two views so the assertion covers an active slot AND an
717
+ // inactive-but-held slot. Both must unmount before deactivate.
718
+ registerView('order:view-a', {
719
+ mount: () => ({ unmount: () => order.push('view-a:unmount') }),
720
+ });
721
+ registerView('order:view-b', {
722
+ mount: () => ({ unmount: () => order.push('view-b:unmount') }),
723
+ });
724
+ const app = makeApp({
725
+ manifest: makeAppManifest({ id: 'order-app', requiredShards: ['order-shard'] }),
726
+ initialLayout: [
727
+ {
728
+ name: 'default',
729
+ tree: makeTree({
730
+ type: 'split',
731
+ direction: 'horizontal',
732
+ children: [
733
+ makeSlotNode('a-slot', 'order:view-a'),
734
+ makeSlotNode('b-slot', 'order:view-b'),
735
+ ],
736
+ sizes: [0.5, 0.5],
737
+ }),
738
+ },
739
+ ],
740
+ });
741
+ registerApp(app);
742
+ await launchApp('order-app');
743
+ // Let the mount microtasks settle so factories have run.
744
+ await Promise.resolve();
745
+ await Promise.resolve();
746
+ unloadApp('order-app');
747
+ // Both views must have unmounted BEFORE onAppDeactivate ran.
748
+ const deactivateIdx = order.indexOf('onAppDeactivate');
749
+ const unmountAIdx = order.indexOf('view-a:unmount');
750
+ const unmountBIdx = order.indexOf('view-b:unmount');
751
+ expect(deactivateIdx).toBeGreaterThanOrEqual(0);
752
+ expect(unmountAIdx).toBeGreaterThanOrEqual(0);
753
+ expect(unmountBIdx).toBeGreaterThanOrEqual(0);
754
+ expect(unmountAIdx).toBeLessThan(deactivateIdx);
755
+ expect(unmountBIdx).toBeLessThan(deactivateIdx);
756
+ });
757
+ });
706
758
  describe('launchApp — onLayoutWillRestore / onLayoutRestored hooks', () => {
707
759
  beforeEach(resetFramework);
708
760
  it('calls onLayoutWillRestore before slot acquisition and onLayoutRestored after switchToApp', async () => {
@@ -46,6 +46,15 @@ export interface AppManifest {
46
46
  * starting shards (diagnostic, __sh3core__) stay running.
47
47
  */
48
48
  requiredShards: string[];
49
+ /**
50
+ * Optional list of shard ids that ship in the same bundle as this app
51
+ * (combo packages). Mirrors the server-side `project-allowlist`
52
+ * middleware's resolution rule — these are part of the project-scope
53
+ * closure when the app is allowlisted, but they are NOT activated
54
+ * automatically (use `requiredShards` for that). Defaults to empty
55
+ * when omitted.
56
+ */
57
+ bundledShards?: string[];
49
58
  /**
50
59
  * Bump to invalidate persisted layouts. On launch, a persisted blob
51
60
  * whose version doesn't match this is discarded and `initialLayout`
@@ -89,16 +89,20 @@
89
89
  function toggleFloatsSheet() {
90
90
  if (floatsOpen) return;
91
91
  floatsOpen = true;
92
- const handle = sh3.modal.open(
92
+ sh3.modal.open(
93
93
  FloatsSheet,
94
94
  {},
95
- { dismissOnBackdrop: true, boxStyle: 'max-width: 320px;' },
95
+ {
96
+ dismissOnBackdrop: true,
97
+ boxStyle: 'max-width: 320px;',
98
+ // Reset on every dismissal path. Wrapping handle.close after
99
+ // open() returns doesn't work — ModalFrame captured the close
100
+ // reference at mount and bypasses any later monkey-patch.
101
+ onClose: () => {
102
+ floatsOpen = false;
103
+ },
104
+ },
96
105
  );
97
- const origClose = handle.close;
98
- handle.close = () => {
99
- origClose();
100
- floatsOpen = false;
101
- };
102
106
  }
103
107
 
104
108
  function toggleDrawer(anchor: DrawerAnchor) {
@@ -23,6 +23,9 @@ import { attachGlobalListeners } from './actions/listeners';
23
23
  import { detectSatelliteMode } from './boot/satelliteMode';
24
24
  import { MemoryBackend } from './state/backends';
25
25
  import { sessionState, readPendingScope, PENDING_SCOPE_KEY } from './projects/session-state.svelte';
26
+ import { projectsApi } from './projects-shard/projectsApi';
27
+ import { projectsState } from './projects-shard/projectsShard.svelte';
28
+ import { toastManager } from './overlays/toast';
26
29
  import SatelliteShell from './satellite/SatelliteShell.svelte';
27
30
  export async function createShell(config) {
28
31
  var _a, _b;
@@ -87,6 +90,19 @@ export async function createShell(config) {
87
90
  if (satellite.payload.projectId) {
88
91
  sessionState.activeProjectId = satellite.payload.projectId;
89
92
  }
93
+ // Mirror main-mode: eager-fetch the active project so the register
94
+ // gate computed in bootstrapSatellite has the allowlist. Fail-closed
95
+ // identically — drop to personal scope on error.
96
+ if (sessionState.activeProjectId !== null) {
97
+ try {
98
+ const record = await projectsApi.get(sessionState.activeProjectId);
99
+ projectsState.projects = [record];
100
+ }
101
+ catch (err) {
102
+ console.warn(`[sh3] Satellite: failed to load project "${sessionState.activeProjectId}"; dropping to personal scope:`, err instanceof Error ? err.message : err);
103
+ sessionState.activeProjectId = null;
104
+ }
105
+ }
90
106
  __setScopeResolver(() => sessionState.activeProjectId);
91
107
  __setShardScopeResolver(() => sessionState.activeProjectId ? 'project' : 'tenant');
92
108
  if (config === null || config === void 0 ? void 0 : config.shards)
@@ -154,6 +170,30 @@ export async function createShell(config) {
154
170
  }
155
171
  }
156
172
  }
173
+ // 4b. Eager-fetch the active project record so the register gate
174
+ // (computed in bootstrap()) has the appAllowlist available.
175
+ // Fail-closed: a failed fetch drops us to personal scope rather
176
+ // than booting with no gate (which would leak shards into the
177
+ // project). The user sees a toast on next paint.
178
+ if (sessionState.activeProjectId !== null) {
179
+ try {
180
+ const record = await projectsApi.get(sessionState.activeProjectId);
181
+ projectsState.projects = [record];
182
+ }
183
+ catch (err) {
184
+ console.warn(`[sh3] Failed to load project "${sessionState.activeProjectId}"; dropping to personal scope:`, err instanceof Error ? err.message : err);
185
+ sessionState.activeProjectId = null;
186
+ if (typeof sessionStorage !== 'undefined') {
187
+ sessionStorage.removeItem(PENDING_SCOPE_KEY);
188
+ }
189
+ queueMicrotask(() => {
190
+ try {
191
+ toastManager.notify('Could not load that project; dropped to personal scope.', { level: 'error', duration: 6000 });
192
+ }
193
+ catch ( /* overlay not ready; swallow */_a) { /* overlay not ready; swallow */ }
194
+ });
195
+ }
196
+ }
157
197
  // 5. Load server-discovered packages
158
198
  await loadDiscoveredPackages(config === null || config === void 0 ? void 0 : config.discoveredPackages);
159
199
  // 6. Register consumer-provided shards and apps
@@ -44,6 +44,46 @@ beforeEach(() => {
44
44
  });
45
45
  describe('createDocumentPicker', () => {
46
46
  const sampleDoc = { shardId: 'my-shard', path: 'readme.md', kind: 'file' };
47
+ describe('options forwarding to browser modal', () => {
48
+ it('threads listFolders/handle/readOnlyShard/initialShardId into the modal props', async () => {
49
+ const listFn = async () => [];
50
+ const listFolders = vi.fn(async () => ['empty']);
51
+ const handle = {
52
+ mkdir: vi.fn(async () => { }),
53
+ rmdir: vi.fn(async () => { }),
54
+ renameFolder: vi.fn(async () => { }),
55
+ rename: vi.fn(async () => { }),
56
+ delete: vi.fn(async () => { }),
57
+ };
58
+ const readOnlyShard = vi.fn(() => false);
59
+ const picker = createDocumentPicker(listFn, {
60
+ listFolders, handle, readOnlyShard,
61
+ initialShardId: 'svg-designer', lockToShard: true,
62
+ });
63
+ mockModal();
64
+ picker.save();
65
+ await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
66
+ const props = mockModalOpen.mock.calls[0][1];
67
+ expect(props.listFolders).toBe(listFolders);
68
+ expect(props.handle).toBe(handle);
69
+ expect(props.readOnlyShard).toBe(readOnlyShard);
70
+ expect(props.initialShardId).toBe('svg-designer');
71
+ expect(props.lockToShard).toBe(true);
72
+ });
73
+ it('omits options when none provided (backward-compatible single-arg call)', async () => {
74
+ const listFn = async () => [];
75
+ const picker = createDocumentPicker(listFn);
76
+ mockModal();
77
+ picker.open();
78
+ await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
79
+ const props = mockModalOpen.mock.calls[0][1];
80
+ expect(props.listFolders).toBeUndefined();
81
+ expect(props.handle).toBeUndefined();
82
+ expect(props.readOnlyShard).toBeUndefined();
83
+ expect(props.initialShardId).toBeUndefined();
84
+ expect(props.lockToShard).toBeUndefined();
85
+ });
86
+ });
47
87
  describe('open() — modal (no anchor)', () => {
48
88
  it('resolves with OpenerValue when user commits', async () => {
49
89
  const listFn = async () => [{ shardId: 'my-shard', path: 'readme.md', size: 100, lastModified: 0 }];
@@ -1,4 +1,42 @@
1
1
  import type { DocumentPickerApi, DocListFn } from './picker-api';
2
+ /**
3
+ * Folder mutation surface forwarded to the document browser modal.
4
+ * Mirrors the `handle` prop of `_DocumentBrowser.svelte`.
5
+ */
6
+ export interface PickerHandleOps {
7
+ mkdir: (shardId: string, path: string) => Promise<void>;
8
+ rmdir: (shardId: string, path: string, opts: {
9
+ recursive: boolean;
10
+ }) => Promise<void>;
11
+ renameFolder: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
12
+ rename: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
13
+ delete: (shardId: string, path: string) => Promise<void>;
14
+ }
15
+ /**
16
+ * Construction-time options for `createDocumentPicker`. All optional —
17
+ * absent values fall back to a read-only listing of `listFn`.
18
+ */
19
+ export interface DocumentPickerOptions {
20
+ /** Enumerates immediate child folders of `prefix` in `shardId`. When set,
21
+ * empty folders (with no documents underneath) appear in the browser. */
22
+ listFolders?: (shardId: string, prefix: string) => Promise<string[]>;
23
+ /** Folder/file mutation surface. When set, the browser exposes a toolbar
24
+ * with new-folder / rename / delete actions (gated per shard by
25
+ * `readOnlyShard`). */
26
+ handle?: PickerHandleOps;
27
+ /** Returns true for shards the caller cannot mutate. The browser hides
28
+ * the edit toolbar while navigated into such shards. */
29
+ readOnlyShard?: (shardId: string) => boolean;
30
+ /** When set, the browser opens pre-navigated into this shard's namespace
31
+ * instead of the (often empty) tenant-wide shard list. The "SH3" crumb
32
+ * still lets the user back out — useful in browse mode. */
33
+ initialShardId?: string;
34
+ /** When true, the browser is hard-scoped to `initialShardId`: the "SH3"
35
+ * breadcrumb is hidden and Backspace at the shard root is a no-op.
36
+ * Use this for non-browse shards that can only ever work within their
37
+ * own namespace, so the user can't navigate into a dead-end root. */
38
+ lockToShard?: boolean;
39
+ }
2
40
  /**
3
41
  * Create a document picker API bound to a document listing function.
4
42
  * The listFn is derived from the shard's document zone + browse permission
@@ -8,4 +46,4 @@ import type { DocumentPickerApi, DocListFn } from './picker-api';
8
46
  * (anchored near the element). Without an anchor it opens as a centered
9
47
  * modal (the expected default for file-browser dialogs).
10
48
  */
11
- export declare function createDocumentPicker(listFn: DocListFn): DocumentPickerApi;
49
+ export declare function createDocumentPicker(listFn: DocListFn, options?: DocumentPickerOptions): DocumentPickerApi;
@@ -11,14 +11,15 @@ const MODAL_OPTS = { dismissOnBackdrop: true, boxStyle: BOX_STYLE };
11
11
  * (anchored near the element). Without an anchor it opens as a centered
12
12
  * modal (the expected default for file-browser dialogs).
13
13
  */
14
- export function createDocumentPicker(listFn) {
15
- /** Resolve handle for either popup (anchored) or modal (centered) path. */
14
+ export function createDocumentPicker(listFn, options = {}) {
15
+ const { listFolders, handle, readOnlyShard, initialShardId, lockToShard } = options;
16
16
  function openBrowser(browserProps, anchor) {
17
+ const props = Object.assign(Object.assign({}, browserProps), { listFolders, handle, readOnlyShard, initialShardId, lockToShard });
17
18
  if (anchor) {
18
19
  const rect = anchor.getBoundingClientRect();
19
- return sh3.popup.show(DocumentBrowser, { anchor: { x: rect.left + rect.width / 2, y: rect.top } }, browserProps);
20
+ return sh3.popup.show(DocumentBrowser, { anchor: { x: rect.left + rect.width / 2, y: rect.top } }, props);
20
21
  }
21
- return sh3.modal.open(DocumentBrowser, browserProps, MODAL_OPTS);
22
+ return sh3.modal.open(DocumentBrowser, props, MODAL_OPTS);
22
23
  }
23
24
  function wrapHandle(handle, resolve) {
24
25
  const origClose = handle.close;
package/dist/host.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * import-hygiene rule is: shards and apps import from `api.ts`, the host
16
16
  * imports from `host.ts`.
17
17
  */
18
- import { registerShard as registerShardInternal, registerAllShards, } from './shards/lifecycle.svelte';
18
+ import { registerShard as registerShardInternal, registerAllShards, listRegisteredShards, } from './shards/lifecycle.svelte';
19
19
  import { registerApp, registeredApps } from './apps/registry.svelte';
20
20
  import { launchApp, readLastApp, clearLastApp } from './apps/lifecycle';
21
21
  import { sh3coreShard } from './sh3core-shard/sh3coreShard.svelte';
@@ -35,6 +35,9 @@ import { runModeIdRenameMigration } from './migrations/mode-id-rename';
35
35
  import { setLifecycleHandlers } from './navigation/back-stack';
36
36
  import { installWebEmitter } from './navigation/platform-web';
37
37
  import { returnToHome } from './apps/lifecycle';
38
+ import { resolveAllowedShardIds } from './projects/scope-gate';
39
+ import { sessionState } from './projects/session-state.svelte';
40
+ import { projectsState } from './projects-shard/projectsShard.svelte';
38
41
  export { __setBackend };
39
42
  export { setLocalOwner };
40
43
  export { __setActiveScope, __setDocumentBackend } from './documents/config';
@@ -58,6 +61,7 @@ function createWorkspaceZoneAdapter() {
58
61
  };
59
62
  }
60
63
  export async function bootstrap(config) {
64
+ var _a;
61
65
  // Run before anything touches the workspace zone so renamed keys are
62
66
  // already in place when shards activate.
63
67
  if (typeof globalThis.localStorage !== 'undefined') {
@@ -83,10 +87,22 @@ export async function bootstrap(config) {
83
87
  }
84
88
  // 3. Load any packages installed in a previous session from IndexedDB
85
89
  await loadInstalledPackages();
86
- // 4. v3: run register(ctx) on every registered shard. Lifecycle module
87
- // handles error isolation; one failing shard does not block boot.
88
- await registerAllShards();
89
- // 5. Read the last-active app from the user zone. If auto-launch fails,
90
+ // 4. Compute the project-scope register gate. Null when in personal
91
+ // scope or when the active project has an empty allowlist; otherwise
92
+ // a Set of shard ids whose register() may run. Shards not in the
93
+ // set are skipped they remain in `registeredShards` so the apps
94
+ // registry can still display their manifests, but their boot
95
+ // side-effects (verbs, contributions, gestures, network calls)
96
+ // are suppressed.
97
+ const activeProject = sessionState.activeProjectId
98
+ ? (_a = projectsState.projects.find((p) => p.id === sessionState.activeProjectId)) !== null && _a !== void 0 ? _a : null
99
+ : null;
100
+ const allowed = resolveAllowedShardIds(activeProject, registeredApps, listRegisteredShards());
101
+ // 5. v3: run register(ctx) on every registered shard (gated by allowed).
102
+ // Lifecycle module handles error isolation; one failing shard does
103
+ // not block boot.
104
+ await registerAllShards(allowed);
105
+ // 6. Read the last-active app from the user zone. If auto-launch fails,
90
106
  // clear the slot so the next reload lands on home instead of looping
91
107
  // into the same failure. No toast — the user did not initiate this.
92
108
  const lastId = readLastApp();
@@ -99,7 +115,7 @@ export async function bootstrap(config) {
99
115
  clearLastApp();
100
116
  }
101
117
  }
102
- // 6. Wire navigation lifecycle handlers and install the web back/forward
118
+ // 7. Wire navigation lifecycle handlers and install the web back/forward
103
119
  // emitter. Order: after autostart shards and the optional last-app
104
120
  // launch, so the emitter's synthetic history entries don't interleave
105
121
  // with boot-time launches. The window/history guard makes bootstrap
@@ -118,6 +134,7 @@ export async function bootstrap(config) {
118
134
  * - lastApp auto-launch (satellites have no last-app concept)
119
135
  */
120
136
  export async function bootstrapSatellite(config) {
137
+ var _a;
121
138
  // 1. Framework-owned shards (same list as bootstrap, no excludes for satellites)
122
139
  const frameworkShards = [sh3coreShard, shellShard, storeShard, adminShard, projectsShard, appearanceShard, layoutsShard];
123
140
  for (const shard of frameworkShards) {
@@ -133,7 +150,13 @@ export async function bootstrapSatellite(config) {
133
150
  // 4. v3: one-pass register sweep replaces the old autostart + satellite
134
151
  // passes. `config.activateShardIds` is now a no-op (the field is
135
152
  // retained on the interface for back-compat; Phase 6 deletes it).
153
+ // Satellites inherit the host's project scope, so apply the same
154
+ // register gate.
136
155
  void config;
137
- await registerAllShards();
156
+ const activeProject = sessionState.activeProjectId
157
+ ? (_a = projectsState.projects.find((p) => p.id === sessionState.activeProjectId)) !== null && _a !== void 0 ? _a : null
158
+ : null;
159
+ const allowed = resolveAllowedShardIds(activeProject, registeredApps, listRegisteredShards());
160
+ await registerAllShards(allowed);
138
161
  }
139
162
  export { installPackage, listInstalledPackages } from './registry/installer';
@@ -19,6 +19,17 @@ export declare function acquireSlotHost(slotId: string, viewId: string | null, l
19
19
  * unconditional detach would yank the host out of its new home.
20
20
  */
21
21
  export declare function releaseSlotHost(slotId: string, fromWrapper?: HTMLElement): void;
22
+ /**
23
+ * Synchronously drain `pendingDestroy`: for every slot id queued for
24
+ * deferred destruction, run the destroy body immediately if refcount is
25
+ * 0. Called by `unloadApp` so that all of the unloading app's views are
26
+ * unmounted before `onAppDeactivate` runs — preventing leaked `$derived`s
27
+ * in still-mounted views from tripping on state the shard nullifies.
28
+ *
29
+ * Safe to call when `pendingDestroy` is empty (no-op). Safe to call
30
+ * multiple times in succession.
31
+ */
32
+ export declare function flushPendingDestroys(): void;
22
33
  /**
23
34
  * Test / teardown helper — destroys every pooled host immediately. Used
24
35
  * by HMR boundaries and tests; not part of normal runtime flow.
@@ -279,23 +279,47 @@ export function releaseSlotHost(slotId, fromWrapper) {
279
279
  return;
280
280
  }
281
281
  pendingDestroy.add(slotId);
282
- queueMicrotask(() => {
283
- var _a, _b;
284
- if (!pendingDestroy.has(slotId))
285
- return;
286
- pendingDestroy.delete(slotId);
287
- const current = pool.get(slotId);
288
- if (!current || current.refcount > 0)
289
- return; // re-acquired, keep
290
- (_a = current.resizeObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
291
- __disposeSlotContributions(slotId);
292
- (_b = current.handle) === null || _b === void 0 ? void 0 : _b.unmount();
293
- current.cancelPendingMount();
294
- current.host.remove();
295
- pool.delete(slotId);
296
- delete dirtyState[slotId];
297
- delete closableState[slotId];
298
- });
282
+ queueMicrotask(() => destroyIfPending(slotId));
283
+ }
284
+ /**
285
+ * Internal: run the destroy body for one slotId if it is still pending
286
+ * and its refcount is 0. Shared between the microtask deferred destroy
287
+ * and the synchronous `flushPendingDestroys`.
288
+ */
289
+ function destroyIfPending(slotId) {
290
+ var _a, _b;
291
+ if (!pendingDestroy.has(slotId))
292
+ return;
293
+ pendingDestroy.delete(slotId);
294
+ const current = pool.get(slotId);
295
+ if (!current || current.refcount > 0)
296
+ return; // re-acquired, keep
297
+ (_a = current.resizeObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
298
+ __disposeSlotContributions(slotId);
299
+ (_b = current.handle) === null || _b === void 0 ? void 0 : _b.unmount();
300
+ current.cancelPendingMount();
301
+ current.host.remove();
302
+ pool.delete(slotId);
303
+ delete dirtyState[slotId];
304
+ delete closableState[slotId];
305
+ }
306
+ /**
307
+ * Synchronously drain `pendingDestroy`: for every slot id queued for
308
+ * deferred destruction, run the destroy body immediately if refcount is
309
+ * 0. Called by `unloadApp` so that all of the unloading app's views are
310
+ * unmounted before `onAppDeactivate` runs — preventing leaked `$derived`s
311
+ * in still-mounted views from tripping on state the shard nullifies.
312
+ *
313
+ * Safe to call when `pendingDestroy` is empty (no-op). Safe to call
314
+ * multiple times in succession.
315
+ */
316
+ export function flushPendingDestroys() {
317
+ // Snapshot the set so destroyIfPending's `pendingDestroy.delete(slotId)`
318
+ // doesn't mutate the iteration source.
319
+ const ids = [...pendingDestroy];
320
+ for (const slotId of ids) {
321
+ destroyIfPending(slotId);
322
+ }
299
323
  }
300
324
  /**
301
325
  * Test / teardown helper — destroys every pooled host immediately. Used