sh3-core 0.13.1 → 0.13.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 (119) hide show
  1. package/dist/BrandSlot.svelte +62 -13
  2. package/dist/__test__/setup-dom.js +5 -0
  3. package/dist/api.d.ts +3 -0
  4. package/dist/api.js +3 -0
  5. package/dist/apps/lifecycle.js +10 -2
  6. package/dist/apps/types.d.ts +11 -4
  7. package/dist/apps/workspace-rekey.d.ts +1 -0
  8. package/dist/apps/workspace-rekey.js +35 -0
  9. package/dist/apps/workspace-rekey.test.js +23 -0
  10. package/dist/auth/admin-users.svelte.d.ts +9 -0
  11. package/dist/auth/admin-users.svelte.js +42 -0
  12. package/dist/auth/admin-users.test.d.ts +1 -0
  13. package/dist/auth/admin-users.test.js +52 -0
  14. package/dist/createShell.js +5 -5
  15. package/dist/documents/config.d.ts +5 -1
  16. package/dist/documents/config.js +16 -8
  17. package/dist/documents/index.d.ts +1 -1
  18. package/dist/documents/index.js +1 -1
  19. package/dist/host-entry.d.ts +1 -1
  20. package/dist/host-entry.js +1 -1
  21. package/dist/host.d.ts +1 -1
  22. package/dist/host.js +8 -2
  23. package/dist/primitives/Button.svelte +50 -4
  24. package/dist/primitives/Button.svelte.d.ts +3 -1
  25. package/dist/primitives/Collapsible.svelte +110 -0
  26. package/dist/primitives/Collapsible.svelte.d.ts +14 -0
  27. package/dist/primitives/widgets/AppPicker.svelte +41 -0
  28. package/dist/primitives/widgets/AppPicker.svelte.d.ts +9 -0
  29. package/dist/primitives/widgets/AppPicker.svelte.test.d.ts +1 -0
  30. package/dist/primitives/widgets/AppPicker.svelte.test.js +26 -0
  31. package/dist/primitives/widgets/AppPicker.test.d.ts +1 -0
  32. package/dist/primitives/widgets/AppPicker.test.js +74 -0
  33. package/dist/primitives/widgets/ColorSwatch.svelte +7 -2
  34. package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +2 -1
  35. package/dist/primitives/widgets/ColorSwatch.svelte.test.d.ts +1 -0
  36. package/dist/primitives/widgets/ColorSwatch.svelte.test.js +31 -0
  37. package/dist/primitives/widgets/Field.svelte +4 -2
  38. package/dist/primitives/widgets/Field.svelte.d.ts +2 -2
  39. package/dist/primitives/widgets/Field.svelte.test.d.ts +1 -0
  40. package/dist/primitives/widgets/Field.svelte.test.js +33 -0
  41. package/dist/primitives/widgets/FilePicker.svelte +2 -2
  42. package/dist/primitives/widgets/FilePicker.svelte.d.ts +2 -2
  43. package/dist/primitives/widgets/FilePicker.svelte.test.d.ts +1 -0
  44. package/dist/primitives/widgets/FilePicker.svelte.test.js +31 -0
  45. package/dist/primitives/widgets/IconToggleGroup.svelte +4 -4
  46. package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +3 -3
  47. package/dist/primitives/widgets/IconToggleGroup.svelte.test.d.ts +1 -0
  48. package/dist/primitives/widgets/IconToggleGroup.svelte.test.js +40 -0
  49. package/dist/primitives/widgets/NumberInput.svelte +19 -9
  50. package/dist/primitives/widgets/NumberInput.svelte.d.ts +2 -2
  51. package/dist/primitives/widgets/NumberInput.svelte.test.d.ts +1 -0
  52. package/dist/primitives/widgets/NumberInput.svelte.test.js +48 -0
  53. package/dist/primitives/widgets/PickerList.d.ts +24 -0
  54. package/dist/primitives/widgets/PickerList.js +21 -0
  55. package/dist/primitives/widgets/PickerList.svelte +150 -0
  56. package/dist/primitives/widgets/PickerList.svelte.d.ts +16 -0
  57. package/dist/primitives/widgets/PickerList.svelte.test.d.ts +1 -0
  58. package/dist/primitives/widgets/PickerList.svelte.test.js +31 -0
  59. package/dist/primitives/widgets/PickerList.test.d.ts +1 -0
  60. package/dist/primitives/widgets/PickerList.test.js +218 -0
  61. package/dist/primitives/widgets/RangeSlider.svelte +11 -4
  62. package/dist/primitives/widgets/RangeSlider.svelte.d.ts +2 -2
  63. package/dist/primitives/widgets/RangeSlider.svelte.test.d.ts +1 -0
  64. package/dist/primitives/widgets/RangeSlider.svelte.test.js +38 -0
  65. package/dist/primitives/widgets/Segmented.svelte +4 -4
  66. package/dist/primitives/widgets/Segmented.svelte.d.ts +3 -3
  67. package/dist/primitives/widgets/Segmented.svelte.test.d.ts +1 -0
  68. package/dist/primitives/widgets/Segmented.svelte.test.js +25 -0
  69. package/dist/primitives/widgets/Select.svelte +4 -4
  70. package/dist/primitives/widgets/Select.svelte.d.ts +3 -3
  71. package/dist/primitives/widgets/Select.svelte.test.d.ts +1 -0
  72. package/dist/primitives/widgets/Select.svelte.test.js +37 -0
  73. package/dist/primitives/widgets/Slider.svelte +4 -2
  74. package/dist/primitives/widgets/Slider.svelte.d.ts +2 -2
  75. package/dist/primitives/widgets/Slider.svelte.test.d.ts +1 -0
  76. package/dist/primitives/widgets/Slider.svelte.test.js +22 -0
  77. package/dist/primitives/widgets/SliderGroup.svelte +4 -2
  78. package/dist/primitives/widgets/SliderGroup.svelte.d.ts +2 -2
  79. package/dist/primitives/widgets/SliderGroup.svelte.test.d.ts +1 -0
  80. package/dist/primitives/widgets/SliderGroup.svelte.test.js +34 -0
  81. package/dist/primitives/widgets/Textarea.svelte +5 -2
  82. package/dist/primitives/widgets/Textarea.svelte.d.ts +2 -2
  83. package/dist/primitives/widgets/Textarea.svelte.test.d.ts +1 -0
  84. package/dist/primitives/widgets/Textarea.svelte.test.js +29 -0
  85. package/dist/primitives/widgets/UserPicker.svelte +53 -0
  86. package/dist/primitives/widgets/UserPicker.svelte.d.ts +9 -0
  87. package/dist/primitives/widgets/UserPicker.svelte.test.d.ts +1 -0
  88. package/dist/primitives/widgets/UserPicker.svelte.test.js +30 -0
  89. package/dist/primitives/widgets/UserPicker.test.d.ts +1 -0
  90. package/dist/primitives/widgets/UserPicker.test.js +115 -0
  91. package/dist/primitives/widgets/_contract.d.ts +27 -0
  92. package/dist/primitives/widgets/_contract.js +10 -0
  93. package/dist/projects/session-state.svelte.d.ts +17 -0
  94. package/dist/projects/session-state.svelte.js +39 -0
  95. package/dist/projects/session-state.test.d.ts +1 -0
  96. package/dist/projects/session-state.test.js +55 -0
  97. package/dist/projects-shard/DeleteProjectDialog.svelte +150 -0
  98. package/dist/projects-shard/DeleteProjectDialog.svelte.d.ts +12 -0
  99. package/dist/projects-shard/DeleteProjectDialog.test.d.ts +1 -0
  100. package/dist/projects-shard/DeleteProjectDialog.test.js +120 -0
  101. package/dist/projects-shard/ProjectManage.svelte +209 -0
  102. package/dist/projects-shard/ProjectManage.svelte.d.ts +8 -0
  103. package/dist/projects-shard/ProjectsSection.svelte +120 -0
  104. package/dist/projects-shard/ProjectsSection.svelte.d.ts +3 -0
  105. package/dist/projects-shard/index.d.ts +4 -0
  106. package/dist/projects-shard/index.js +4 -0
  107. package/dist/projects-shard/projectsApi.d.ts +20 -0
  108. package/dist/projects-shard/projectsApi.js +44 -0
  109. package/dist/projects-shard/projectsApi.test.d.ts +1 -0
  110. package/dist/projects-shard/projectsApi.test.js +71 -0
  111. package/dist/projects-shard/projectsShard.svelte.d.ts +10 -0
  112. package/dist/projects-shard/projectsShard.svelte.js +148 -0
  113. package/dist/sh3core-shard/ShellHome.svelte +19 -1
  114. package/dist/shards/activate-scopeid.test.d.ts +1 -0
  115. package/dist/shards/{activate-tenantid.test.js → activate-scopeid.test.js} +6 -6
  116. package/dist/version.d.ts +1 -1
  117. package/dist/version.js +1 -1
  118. package/package.json +1 -1
  119. /package/dist/{shards/activate-tenantid.test.d.ts → apps/workspace-rekey.test.d.ts} +0 -0
@@ -1,19 +1,24 @@
1
1
  <script lang="ts">
2
2
  /*
3
- * BrandSlot — top-bar context indicator. Three states:
4
- * - 'brand' → renders <span>SH3</span>
5
- * - 'app' → renders <span>{label}</span>
6
- * - 'breadcrumb' → renders SH3 + separator + <button>{label}</button>
3
+ * BrandSlot — top-bar context indicator. Mode resolves from the
4
+ * (activeAppId, breadcrumbAppId, activeProjectId) triple:
7
5
  *
8
- * State derives from (activeAppId, breadcrumbAppId). Click on the
9
- * breadcrumb's app button re-launches the app (existing handler).
6
+ * brand → SH3
7
+ * app [App Name]
8
+ * breadcrumb → SH3 › [App Name] (clickable app)
9
+ * project-home → SH3 › [Project] (clickable SH3 → exit project)
10
+ * project-app → [Project] › [App] (no SH3 — use home button to exit)
11
+ * project-breadcrumb → SH3 › [Project] › [App] (clickable SH3, project, app)
10
12
  */
11
13
  import { getLiveDispatcherState } from './actions/state.svelte';
12
- import { launchApp } from './apps/lifecycle';
14
+ import { launchApp, returnToHome } from './apps/lifecycle';
13
15
  import { getBreadcrumbAppId, getRegisteredApp } from './apps/registry.svelte';
16
+ import { sessionState, setActiveProjectId } from './projects/session-state.svelte';
17
+ import { projectsState } from './projects-shard/projectsShard.svelte';
14
18
 
15
19
  const activeAppId = $derived(getLiveDispatcherState().activeAppId);
16
20
  const breadcrumbId = $derived(getBreadcrumbAppId());
21
+ const projectId = $derived(sessionState.activeProjectId);
17
22
 
18
23
  const activeLabel = $derived(
19
24
  activeAppId ? getRegisteredApp(activeAppId)?.manifest.label ?? activeAppId : null,
@@ -21,16 +26,34 @@
21
26
  const breadcrumbLabel = $derived(
22
27
  breadcrumbId ? getRegisteredApp(breadcrumbId)?.manifest.label ?? breadcrumbId : null,
23
28
  );
29
+ const projectLabel = $derived(
30
+ projectId ? projectsState.projects.find((p) => p.id === projectId)?.name ?? projectId : null,
31
+ );
32
+
33
+ type Mode = 'brand' | 'app' | 'breadcrumb' | 'project-home' | 'project-app' | 'project-breadcrumb';
24
34
 
25
- const mode: 'brand' | 'app' | 'breadcrumb' = $derived.by(() => {
35
+ const mode: Mode = $derived.by(() => {
36
+ if (projectId) {
37
+ if (activeAppId) return 'project-app';
38
+ if (breadcrumbId) return 'project-breadcrumb';
39
+ return 'project-home';
40
+ }
26
41
  if (activeAppId) return 'app';
27
42
  if (breadcrumbId) return 'breadcrumb';
28
43
  return 'brand';
29
44
  });
30
45
 
31
- function reopen() {
46
+ function reopenApp() {
32
47
  if (breadcrumbId) void launchApp(breadcrumbId);
33
48
  }
49
+
50
+ function exitProject() {
51
+ setActiveProjectId(null);
52
+ }
53
+
54
+ function reenterProjectHome() {
55
+ if (activeAppId) void returnToHome();
56
+ }
34
57
  </script>
35
58
 
36
59
  <div class="sh3-brand-slot">
@@ -38,12 +61,24 @@
38
61
  <span class="sh3-brand">SH3</span>
39
62
  {:else if mode === 'app'}
40
63
  <span class="sh3-brand sh3-brand-app">{activeLabel}</span>
41
- {:else}
64
+ {:else if mode === 'breadcrumb'}
42
65
  <span class="sh3-brand">SH3</span>
43
66
  <span class="sh3-brand-sep" aria-hidden="true">›</span>
44
- <button type="button" class="sh3-brand-crumb" onclick={reopen}>
45
- {breadcrumbLabel}
46
- </button>
67
+ <button type="button" class="sh3-brand-crumb" onclick={reopenApp}>{breadcrumbLabel}</button>
68
+ {:else if mode === 'project-home'}
69
+ <button type="button" class="sh3-brand sh3-brand-clickable" onclick={exitProject} title="Exit project">SH3</button>
70
+ <span class="sh3-brand-sep" aria-hidden="true">›</span>
71
+ <span class="sh3-brand-project">{projectLabel}</span>
72
+ {:else if mode === 'project-app'}
73
+ <span class="sh3-brand sh3-brand-project">{projectLabel}</span>
74
+ <span class="sh3-brand-sep" aria-hidden="true">›</span>
75
+ <span class="sh3-brand sh3-brand-app">{activeLabel}</span>
76
+ {:else if mode === 'project-breadcrumb'}
77
+ <button type="button" class="sh3-brand sh3-brand-clickable" onclick={exitProject} title="Exit project">SH3</button>
78
+ <span class="sh3-brand-sep" aria-hidden="true">›</span>
79
+ <button type="button" class="sh3-brand-crumb" onclick={reenterProjectHome}>{projectLabel}</button>
80
+ <span class="sh3-brand-sep" aria-hidden="true">›</span>
81
+ <button type="button" class="sh3-brand-crumb" onclick={reopenApp}>{breadcrumbLabel}</button>
47
82
  {/if}
48
83
  </div>
49
84
 
@@ -61,6 +96,9 @@
61
96
  .sh3-brand-app {
62
97
  color: var(--shell-fg);
63
98
  }
99
+ .sh3-brand-project {
100
+ color: var(--shell-fg);
101
+ }
64
102
  .sh3-brand-sep {
65
103
  color: var(--shell-fg-muted);
66
104
  margin: 0 4px;
@@ -77,4 +115,15 @@
77
115
  .sh3-brand-crumb:hover {
78
116
  background: var(--shell-bg-elevated);
79
117
  }
118
+ .sh3-brand-clickable {
119
+ background: transparent;
120
+ border: 0;
121
+ font: inherit;
122
+ cursor: pointer;
123
+ padding: 2px 6px;
124
+ border-radius: var(--shell-radius-sm, 3px);
125
+ }
126
+ .sh3-brand-clickable:hover {
127
+ background: var(--shell-bg-elevated);
128
+ }
80
129
  </style>
@@ -1,4 +1,9 @@
1
1
  import '@testing-library/jest-dom/vitest';
2
+ import { afterEach } from 'vitest';
3
+ import { cleanup } from '@testing-library/svelte';
4
+ // @testing-library/svelte v5 only auto-registers cleanup when vitest
5
+ // globals are enabled. We use globals: false, so wire it manually.
6
+ afterEach(() => cleanup());
2
7
  /**
3
8
  * happy-dom creates per-window Comment subclasses (via WindowContextClassExtender)
4
9
  * but actually instantiates nodes from the base CommentImplementation class. This
package/dist/api.d.ts CHANGED
@@ -55,6 +55,7 @@ export declare const FRAMEWORK_SHARD_IDS: readonly string[];
55
55
  export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
56
56
  export { default as Button } from './primitives/Button.svelte';
57
57
  export { provideIcons, getIconSprite, type ButtonVariant } from './primitives/icon-context';
58
+ export { default as Collapsible } from './primitives/Collapsible.svelte';
58
59
  export { default as Field } from './primitives/widgets/Field.svelte';
59
60
  export { default as Textarea } from './primitives/widgets/Textarea.svelte';
60
61
  export { default as NumberInput } from './primitives/widgets/NumberInput.svelte';
@@ -69,3 +70,5 @@ export { default as FilePicker } from './primitives/widgets/FilePicker.svelte';
69
70
  export type { FilePickerValue } from './primitives/widgets/FilePicker';
70
71
  export { default as Select } from './primitives/widgets/Select.svelte';
71
72
  export type { SelectOption } from './primitives/widgets/Select';
73
+ export { default as AppPicker } from './primitives/widgets/AppPicker.svelte';
74
+ export { default as UserPicker } from './primitives/widgets/UserPicker.svelte';
package/dist/api.js CHANGED
@@ -73,6 +73,7 @@ export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './th
73
73
  // `import { Button } from 'sh3-core'` against the runtime shim in loader.ts.
74
74
  export { default as Button } from './primitives/Button.svelte';
75
75
  export { provideIcons, getIconSprite } from './primitives/icon-context';
76
+ export { default as Collapsible } from './primitives/Collapsible.svelte';
76
77
  // Controllable widget primitives (ADR-022).
77
78
  export { default as Field } from './primitives/widgets/Field.svelte';
78
79
  export { default as Textarea } from './primitives/widgets/Textarea.svelte';
@@ -85,3 +86,5 @@ export { default as SliderGroup } from './primitives/widgets/SliderGroup.svelte'
85
86
  export { default as ColorSwatch } from './primitives/widgets/ColorSwatch.svelte';
86
87
  export { default as FilePicker } from './primitives/widgets/FilePicker.svelte';
87
88
  export { default as Select } from './primitives/widgets/Select.svelte';
89
+ export { default as AppPicker } from './primitives/widgets/AppPicker.svelte';
90
+ export { default as UserPicker } from './primitives/widgets/UserPicker.svelte';
@@ -22,6 +22,8 @@ import { clearSelectionUnconditional } from '../actions/selection.svelte';
22
22
  import { loadUserBindings } from '../actions/bindings-store';
23
23
  import { toastManager } from '../overlays/toast';
24
24
  import { clearAppNavEntries } from '../navigation/back-stack';
25
+ import { getActiveScopeId } from '../documents/config';
26
+ import { sessionState } from '../projects/session-state.svelte';
25
27
  // ---------- last-active-app user zone ------------------------------------
26
28
  /**
27
29
  * Framework-reserved user-zone slot storing which app to boot into on
@@ -54,13 +56,19 @@ export function clearLastApp() {
54
56
  }
55
57
  // ---------- app-context state factories ----------------------------------
56
58
  const appContexts = new Map();
57
- function getOrCreateAppContext(appId) {
59
+ function resolveLaunchScope() {
60
+ var _a;
61
+ return (_a = sessionState.activeProjectId) !== null && _a !== void 0 ? _a : getActiveScopeId();
62
+ }
63
+ function getOrCreateAppContext(appId, scopeId) {
58
64
  var _a;
59
65
  let ctx = appContexts.get(appId);
60
66
  if (!ctx) {
61
67
  const app = getRegisteredApp(appId);
68
+ const scope = scopeId !== null && scopeId !== void 0 ? scopeId : resolveLaunchScope();
62
69
  ctx = {
63
- state: (schema) => createStateZones(`__app__:${appId}`, schema),
70
+ scopeId: scope,
71
+ state: (schema) => createStateZones(`__app__:${appId}:scope:${scope}`, schema),
64
72
  zones: ((_a = app === null || app === void 0 ? void 0 : app.manifest.permissions) === null || _a === void 0 ? void 0 : _a.includes(PERMISSION_STATE_MANAGE))
65
73
  ? createZoneManager()
66
74
  : undefined,
@@ -82,10 +82,17 @@ export interface AppManifest {
82
82
  */
83
83
  export interface AppContext {
84
84
  /**
85
- * App-scoped state zones. The shardId underneath is the app id, so
86
- * `state({ workspace: { x: 0 } }).workspace.x = 1` persists to
87
- * `sh3:workspace:<appId>` with no collision risk against any shard
88
- * of the same name.
85
+ * The scope (personal or project) this app instance is bound to for its
86
+ * lifetime. Set at launch from `session.activeProjectId`, falling back to
87
+ * the user's personal scope. The app's document handles are bound to this
88
+ * scope and cannot reach across scopes — exiting a scope unloads the app.
89
+ */
90
+ scopeId: string;
91
+ /**
92
+ * App-scoped state zones. The shardId underneath is the app id plus the
93
+ * scope, so `state({ workspace: { x: 0 } }).workspace.x = 1` persists to
94
+ * `sh3:workspace:__app__:<appId>:scope:<scopeId>` with no collision risk
95
+ * against any shard of the same name and no leakage between scopes.
89
96
  */
90
97
  state<T extends ZoneSchema>(schema: T): StateZones<T>;
91
98
  /**
@@ -0,0 +1 @@
1
+ export declare function migrateLegacyWorkspaceKeys(personalScopeId: string): void;
@@ -0,0 +1,35 @@
1
+ /*
2
+ * Workspace state-zone key migration.
3
+ *
4
+ * Per ADR-002 amendment (2026-05-04), the workspace zone is keyed by
5
+ * `(scopeId, appId)`. Pre-existing localStorage entries written under
6
+ * the old `sh3:workspace:__app__:<appId>` prefix are rewritten to
7
+ * `sh3:workspace:__app__:<appId>:scope:<personalScopeId>` on first
8
+ * boot after upgrade. Idempotent — re-running on already-migrated
9
+ * entries is a no-op.
10
+ *
11
+ * Only entries whose shardId starts with the framework `__app__:`
12
+ * marker are migrated; bare shard keys are left alone.
13
+ */
14
+ const APP_PREFIX = 'sh3:workspace:__app__:';
15
+ const SCOPE_MARKER = ':scope:';
16
+ export function migrateLegacyWorkspaceKeys(personalScopeId) {
17
+ if (typeof localStorage === 'undefined')
18
+ return;
19
+ const toMove = [];
20
+ for (let i = 0; i < localStorage.length; i++) {
21
+ const key = localStorage.key(i);
22
+ if (!key || !key.startsWith(APP_PREFIX))
23
+ continue;
24
+ if (key.includes(SCOPE_MARKER))
25
+ continue;
26
+ toMove.push([key, `${key}${SCOPE_MARKER}${personalScopeId}`]);
27
+ }
28
+ for (const [oldKey, newKey] of toMove) {
29
+ const value = localStorage.getItem(oldKey);
30
+ if (value !== null) {
31
+ localStorage.setItem(newKey, value);
32
+ localStorage.removeItem(oldKey);
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { migrateLegacyWorkspaceKeys } from './workspace-rekey';
3
+ describe('migrateLegacyWorkspaceKeys', () => {
4
+ beforeEach(() => localStorage.clear());
5
+ it('rewrites legacy app keys to scope-suffixed keys', () => {
6
+ localStorage.setItem('sh3:workspace:__app__:notes', JSON.stringify({ x: 1 }));
7
+ localStorage.setItem('sh3:workspace:__app__:files', JSON.stringify({ y: 2 }));
8
+ migrateLegacyWorkspaceKeys('user-1');
9
+ expect(localStorage.getItem('sh3:workspace:__app__:notes')).toBeNull();
10
+ expect(localStorage.getItem('sh3:workspace:__app__:notes:scope:user-1')).toBe(JSON.stringify({ x: 1 }));
11
+ expect(localStorage.getItem('sh3:workspace:__app__:files:scope:user-1')).toBe(JSON.stringify({ y: 2 }));
12
+ });
13
+ it('is idempotent', () => {
14
+ localStorage.setItem('sh3:workspace:__app__:notes:scope:user-1', JSON.stringify({ x: 1 }));
15
+ migrateLegacyWorkspaceKeys('user-1');
16
+ expect(localStorage.getItem('sh3:workspace:__app__:notes:scope:user-1')).toBe(JSON.stringify({ x: 1 }));
17
+ });
18
+ it('does not touch non-app workspace keys', () => {
19
+ localStorage.setItem('sh3:workspace:my-shard', JSON.stringify({ z: 3 }));
20
+ migrateLegacyWorkspaceKeys('user-1');
21
+ expect(localStorage.getItem('sh3:workspace:my-shard')).toBe(JSON.stringify({ z: 3 }));
22
+ });
23
+ });
@@ -0,0 +1,9 @@
1
+ import type { AuthUser } from './types';
2
+ export declare const usersAdminState: {
3
+ users: AuthUser[];
4
+ loading: boolean;
5
+ error: string | null;
6
+ };
7
+ export declare function refreshAdminUsers(): Promise<void>;
8
+ /** Test-only reset. Clears state and any in-flight promise. */
9
+ export declare function __resetAdminUsersForTest(): void;
@@ -0,0 +1,42 @@
1
+ /*
2
+ * Shared admin-users cache.
3
+ *
4
+ * Wraps a single GET /api/admin/users behind a $state slot so multiple
5
+ * admin-form components (e.g., UserPicker instances inside ProjectManage,
6
+ * a future invite-user dialog, etc.) share one fetch without prop-drilling
7
+ * the user list. Concurrent calls reuse the in-flight promise; subsequent
8
+ * calls after completion fetch fresh data.
9
+ */
10
+ export const usersAdminState = $state({ users: [], loading: false, error: null });
11
+ let inflight = null;
12
+ export function refreshAdminUsers() {
13
+ if (inflight)
14
+ return inflight;
15
+ usersAdminState.loading = true;
16
+ usersAdminState.error = null;
17
+ inflight = (async () => {
18
+ try {
19
+ const res = await fetch('/api/admin/users', { credentials: 'include' });
20
+ if (!res.ok) {
21
+ usersAdminState.error = `GET /api/admin/users failed: ${res.status}`;
22
+ return;
23
+ }
24
+ usersAdminState.users = (await res.json());
25
+ }
26
+ catch (e) {
27
+ usersAdminState.error = e.message;
28
+ }
29
+ finally {
30
+ usersAdminState.loading = false;
31
+ inflight = null;
32
+ }
33
+ })();
34
+ return inflight;
35
+ }
36
+ /** Test-only reset. Clears state and any in-flight promise. */
37
+ export function __resetAdminUsersForTest() {
38
+ usersAdminState.users = [];
39
+ usersAdminState.loading = false;
40
+ usersAdminState.error = null;
41
+ inflight = null;
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { usersAdminState, refreshAdminUsers, __resetAdminUsersForTest, } from './admin-users.svelte';
3
+ beforeEach(() => {
4
+ __resetAdminUsersForTest();
5
+ vi.stubGlobal('fetch', vi.fn());
6
+ });
7
+ describe('refreshAdminUsers', () => {
8
+ it('GETs /api/admin/users and populates the state', async () => {
9
+ globalThis.fetch.mockResolvedValue({
10
+ ok: true,
11
+ json: async () => [
12
+ { id: 'u-1', username: 'alice', displayName: 'Alice', role: 'user', createdAt: '', updatedAt: '' },
13
+ { id: 'u-2', username: 'bob', displayName: 'Bob', role: 'admin', createdAt: '', updatedAt: '' },
14
+ ],
15
+ });
16
+ await refreshAdminUsers();
17
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/admin/users', expect.objectContaining({ credentials: 'include' }));
18
+ expect(usersAdminState.users).toHaveLength(2);
19
+ expect(usersAdminState.users[0].username).toBe('alice');
20
+ expect(usersAdminState.loading).toBe(false);
21
+ expect(usersAdminState.error).toBeNull();
22
+ });
23
+ it('sets error and clears loading on non-ok response', async () => {
24
+ globalThis.fetch.mockResolvedValue({ ok: false, status: 403 });
25
+ await refreshAdminUsers();
26
+ expect(usersAdminState.users).toEqual([]);
27
+ expect(usersAdminState.loading).toBe(false);
28
+ expect(usersAdminState.error).toMatch(/403/);
29
+ });
30
+ it('sets error and clears loading on network throw', async () => {
31
+ globalThis.fetch.mockRejectedValue(new Error('network down'));
32
+ await refreshAdminUsers();
33
+ expect(usersAdminState.error).toBe('network down');
34
+ expect(usersAdminState.loading).toBe(false);
35
+ });
36
+ it('concurrent calls share one in-flight promise', async () => {
37
+ let resolveFetch = null;
38
+ globalThis.fetch.mockImplementation(() => new Promise((res) => { resolveFetch = res; }));
39
+ const a = refreshAdminUsers();
40
+ const b = refreshAdminUsers();
41
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
42
+ resolveFetch({ ok: true, json: async () => [] });
43
+ await Promise.all([a, b]);
44
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
45
+ });
46
+ it('a follow-up call after completion fetches again', async () => {
47
+ globalThis.fetch.mockResolvedValue({ ok: true, json: async () => [] });
48
+ await refreshAdminUsers();
49
+ await refreshAdminUsers();
50
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
51
+ });
52
+ });
@@ -12,7 +12,7 @@ import { registerShard, registerApp, bootstrap, __setBackend, setLocalOwner, } f
12
12
  import { resolvePlatform } from './platform/index';
13
13
  import { hydrateTokenOverrides } from './theme';
14
14
  import { __setEnvServerUrl } from './env/index';
15
- import { __setTenantId } from './documents/config';
15
+ import { __setActiveScope } from './documents/config';
16
16
  import { initFromBoot } from './auth/index';
17
17
  import SignInWall from './auth/SignInWall.svelte';
18
18
  import { loadBundleModule } from './registry/loader';
@@ -54,13 +54,13 @@ export async function createShell(config) {
54
54
  }
55
55
  // 4. Auth decision point
56
56
  if (platform.localOwner) {
57
- // Local-owner (Tauri/dev): no auth, no sign-in, tenant is 'local'.
57
+ // Local-owner (Tauri/dev): no auth, no sign-in, scope is 'local'.
58
58
  // setLocalOwner() already called above — admin is assumed.
59
- __setTenantId('local');
59
+ __setActiveScope('local');
60
60
  }
61
61
  else if (bootConfig) {
62
62
  initFromBoot(sUrl, bootConfig);
63
- __setTenantId(bootConfig.tenantId);
63
+ __setActiveScope(bootConfig.tenantId);
64
64
  const { auth, session } = bootConfig;
65
65
  // Hard gate: no session, auth required, no guest allowed → sign-in wall
66
66
  if (!session && auth.required && !auth.guestAllowed) {
@@ -70,7 +70,7 @@ export async function createShell(config) {
70
70
  if (res.ok) {
71
71
  bootConfig = await res.json();
72
72
  initFromBoot(sUrl, bootConfig);
73
- __setTenantId(bootConfig.tenantId);
73
+ __setActiveScope(bootConfig.tenantId);
74
74
  }
75
75
  }
76
76
  }
@@ -1,7 +1,11 @@
1
1
  import type { DocumentBackend } from './types';
2
+ export declare function getActiveScopeId(): string;
3
+ /** @deprecated use getActiveScopeId — kept until callers migrate. */
2
4
  export declare function getTenantId(): string;
3
5
  export declare function getDocumentBackend(): DocumentBackend;
4
- /** Host-only. Set the tenant id before bootstrap(). */
6
+ /** Host-only. Set the active scope id before bootstrap(). */
7
+ export declare function __setActiveScope(id: string): void;
8
+ /** @deprecated use __setActiveScope — kept until callers migrate. */
5
9
  export declare function __setTenantId(id: string): void;
6
10
  /** Host-only. Swap the document backend before bootstrap(). */
7
11
  export declare function __setDocumentBackend(b: DocumentBackend): void;
@@ -2,24 +2,32 @@
2
2
  * Document zone configuration — module-level singletons.
3
3
  *
4
4
  * Mirrors the __setBackend pattern in state/zones.svelte.ts. The host
5
- * calls __setTenantId and __setDocumentBackend before bootstrap() to
6
- * configure multi-tenancy and swap backends (e.g. Tauri FS).
5
+ * calls __setActiveScope and __setDocumentBackend before bootstrap() to
6
+ * configure multi-scope routing and swap backends (e.g. Tauri FS).
7
7
  *
8
- * Defaults: tenantId='local' (single-user self-hosted), backend=IndexedDB.
8
+ * Defaults: scopeId='local' (single-user self-hosted), backend=IndexedDB.
9
9
  */
10
10
  import { IndexedDBDocumentBackend } from './backends';
11
- const DEFAULT_TENANT = 'local';
12
- let tenantId = DEFAULT_TENANT;
11
+ const DEFAULT_SCOPE = 'local';
12
+ let scopeId = DEFAULT_SCOPE;
13
13
  let backend = new IndexedDBDocumentBackend();
14
+ export function getActiveScopeId() {
15
+ return scopeId;
16
+ }
17
+ /** @deprecated use getActiveScopeId — kept until callers migrate. */
14
18
  export function getTenantId() {
15
- return tenantId;
19
+ return scopeId;
16
20
  }
17
21
  export function getDocumentBackend() {
18
22
  return backend;
19
23
  }
20
- /** Host-only. Set the tenant id before bootstrap(). */
24
+ /** Host-only. Set the active scope id before bootstrap(). */
25
+ export function __setActiveScope(id) {
26
+ scopeId = id;
27
+ }
28
+ /** @deprecated use __setActiveScope — kept until callers migrate. */
21
29
  export function __setTenantId(id) {
22
- tenantId = id;
30
+ __setActiveScope(id);
23
31
  }
24
32
  /** Host-only. Swap the document backend before bootstrap(). */
25
33
  export function __setDocumentBackend(b) {
@@ -3,6 +3,6 @@ export { MemoryDocumentBackend, IndexedDBDocumentBackend } from './backends';
3
3
  export { HttpDocumentBackend } from './http-backend';
4
4
  export { createDocumentHandle } from './handle';
5
5
  export { documentChanges } from './notifications';
6
- export { getTenantId, getDocumentBackend, __setTenantId, __setDocumentBackend, } from './config';
6
+ export { getActiveScopeId, getTenantId, getDocumentBackend, __setActiveScope, __setTenantId, __setDocumentBackend, } from './config';
7
7
  export type { SyncPolicy, SyncPolicyRule, DocStatus, ConflictFile, ConflictBranch, } from './sync-types';
8
8
  export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY, } from './sync-types';
@@ -5,5 +5,5 @@ export { MemoryDocumentBackend, IndexedDBDocumentBackend } from './backends';
5
5
  export { HttpDocumentBackend } from './http-backend';
6
6
  export { createDocumentHandle } from './handle';
7
7
  export { documentChanges } from './notifications';
8
- export { getTenantId, getDocumentBackend, __setTenantId, __setDocumentBackend, } from './config';
8
+ export { getActiveScopeId, getTenantId, getDocumentBackend, __setActiveScope, __setTenantId, __setDocumentBackend, } from './config';
9
9
  export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY, } from './sync-types';
@@ -1,6 +1,6 @@
1
1
  export { registerShard, registerApp, bootstrap, __setBackend, setLocalOwner } from './host';
2
2
  export type { BootstrapConfig } from './host';
3
- export { __setTenantId, __setDocumentBackend } from './host';
3
+ export { __setActiveScope, __setTenantId, __setDocumentBackend } from './host';
4
4
  export type { Backend } from './state/types';
5
5
  export type { DocumentBackend } from './documents/types';
6
6
  export { HttpDocumentBackend } from './documents/http-backend';
@@ -6,7 +6,7 @@
6
6
  * should touch this path. Shards and apps must not import from here.
7
7
  */
8
8
  export { registerShard, registerApp, bootstrap, __setBackend, setLocalOwner } from './host';
9
- export { __setTenantId, __setDocumentBackend } from './host';
9
+ export { __setActiveScope, __setTenantId, __setDocumentBackend } from './host';
10
10
  export { HttpDocumentBackend } from './documents/http-backend';
11
11
  export { __setEnvServerUrl } from './env/index';
12
12
  // Install API (host-only).
package/dist/host.d.ts CHANGED
@@ -4,7 +4,7 @@ import { __setBackend } from './state/zones.svelte';
4
4
  import { setLocalOwner } from './auth/index';
5
5
  export { __setBackend };
6
6
  export { setLocalOwner };
7
- export { __setTenantId, __setDocumentBackend } from './documents/config';
7
+ export { __setActiveScope, __setTenantId, __setDocumentBackend } from './documents/config';
8
8
  export declare function registerShard(shard: Parameters<typeof registerShardInternal>[0]): void;
9
9
  export { registerApp };
10
10
  export interface BootstrapConfig {
package/dist/host.js CHANGED
@@ -22,6 +22,7 @@ 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';
25
+ import { projectsShard } from './projects-shard/projectsShard.svelte';
25
26
  import { __setBackend, backends } from './state/zones.svelte';
26
27
  import { loadInstalledPackages } from './registry/installer';
27
28
  import { setLocalOwner } from './auth/index';
@@ -34,7 +35,7 @@ import { installWebEmitter } from './navigation/platform-web';
34
35
  import { returnToHome } from './apps/lifecycle';
35
36
  export { __setBackend };
36
37
  export { setLocalOwner };
37
- export { __setTenantId, __setDocumentBackend } from './documents/config';
38
+ export { __setActiveScope, __setTenantId, __setDocumentBackend } from './documents/config';
38
39
  export function registerShard(shard) {
39
40
  registerShardInternal(shard);
40
41
  }
@@ -58,10 +59,15 @@ export async function bootstrap(config) {
58
59
  // already in place when shards activate.
59
60
  if (typeof globalThis.localStorage !== 'undefined') {
60
61
  runShellRenameMigration(createWorkspaceZoneAdapter(), globalThis.localStorage);
62
+ // Per ADR-002 amendment, app workspace state is keyed by (scopeId, appId).
63
+ // Rewrite legacy unkeyed entries to the personal scope namespace.
64
+ const { migrateLegacyWorkspaceKeys } = await import('./apps/workspace-rekey');
65
+ const { getActiveScopeId } = await import('./documents/config');
66
+ migrateLegacyWorkspaceKeys(getActiveScopeId());
61
67
  }
62
68
  const exShards = new Set(config === null || config === void 0 ? void 0 : config.excludeShards);
63
69
  // 1. Framework-owned shards
64
- const frameworkShards = [sh3coreShard, shellShard, storeShard, adminShard];
70
+ const frameworkShards = [sh3coreShard, shellShard, storeShard, adminShard, projectsShard];
65
71
  for (const shard of frameworkShards) {
66
72
  if (!exShards.has(shard.manifest.id)) {
67
73
  registerShardInternal(shard);