sh3-core 0.11.8 → 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.
@@ -13,6 +13,7 @@ import { buildContextMenuModel, buildContextMenuSubmenu } from './contextMenuMod
13
13
  import ActionPanel from './ActionPanel.svelte';
14
14
  import CommandPalette from './CommandPalette.svelte';
15
15
  import { buildPaletteCandidates } from './paletteModel';
16
+ import { parseScopeString } from './scope-helpers';
16
17
  import { shell } from '../shellRuntime.svelte';
17
18
  let attached = false;
18
19
  function viewIdOfEl(el) {
@@ -23,10 +24,24 @@ function viewIdOfEl(el) {
23
24
  return (_a = host === null || host === void 0 ? void 0 : host.getAttribute('data-sh3-view')) !== null && _a !== void 0 ? _a : null;
24
25
  }
25
26
  function resolveAnchor(args) {
27
+ var _a, _b;
26
28
  if (args.explicit !== undefined)
27
29
  return args.explicit;
28
- if (args.event && args.event.target) {
29
- const viewId = viewIdOfEl(args.event.target);
30
+ const target = (_a = args.event) === null || _a === void 0 ? void 0 : _a.target;
31
+ if (target instanceof Element) {
32
+ // Preferred path: data-sh3-scope carries the literal AtomicScope encoding
33
+ // (see ADR-021 amendment 2026-05-01). Walked first so a sub-region
34
+ // overrides its enclosing slot host's auto-stamped focus:<viewId>.
35
+ const scopeHost = target.closest('[data-sh3-scope]');
36
+ if (scopeHost) {
37
+ const parsed = parseScopeString((_b = scopeHost.getAttribute('data-sh3-scope')) !== null && _b !== void 0 ? _b : '');
38
+ if (parsed)
39
+ return parsed;
40
+ }
41
+ // Fallback: data-sh3-view alone still maps to focus:<viewId>. Defensive
42
+ // for stub views and external callers that haven't adopted the new
43
+ // attribute; framework-stamped slot hosts now carry both.
44
+ const viewId = viewIdOfEl(target);
30
45
  if (viewId)
31
46
  return `focus:${viewId}`;
32
47
  }
@@ -149,6 +149,52 @@ describe('global contextmenu listener', () => {
149
149
  expect(labels).toEqual(['App']);
150
150
  target.remove();
151
151
  });
152
+ it('right-click inside data-sh3-scope="element:..." anchors to that element atom', async () => {
153
+ setActiveApp('app.a', new Set(['shard.x']));
154
+ setMountedViewIds(new Set(['editor']));
155
+ registerAction({ id: 'el-only', label: 'El', scope: { element: 'svg-designer:layer' }, contextItem: true, run: () => { } }, 'shard.x');
156
+ registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
157
+ // Slot-host shape: framework auto-stamps both attributes. The inner div
158
+ // overrides scope only — viewId identity is unchanged. closest('[data-sh3-scope]')
159
+ // from the click target finds the inner div first.
160
+ const slotHost = document.createElement('div');
161
+ slotHost.setAttribute('data-sh3-view', 'editor');
162
+ slotHost.setAttribute('data-sh3-scope', 'focus:editor');
163
+ document.body.appendChild(slotHost);
164
+ const inner = document.createElement('div');
165
+ inner.setAttribute('data-sh3-scope', 'element:svg-designer:layer');
166
+ slotHost.appendChild(inner);
167
+ const target = document.createElement('button');
168
+ inner.appendChild(target);
169
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
170
+ Object.defineProperty(ev, 'target', { value: target });
171
+ target.dispatchEvent(ev);
172
+ await Promise.resolve();
173
+ const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
174
+ .map((n) => n.textContent);
175
+ expect(labels).toEqual(['El']);
176
+ slotHost.remove();
177
+ });
178
+ it('right-click outside any data-sh3-scope override falls back to focus:<viewId>', async () => {
179
+ setActiveApp('app.a', new Set(['shard.x']));
180
+ setMountedViewIds(new Set(['editor']));
181
+ registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
182
+ registerAction({ id: 'el-only', label: 'El', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
183
+ const slotHost = document.createElement('div');
184
+ slotHost.setAttribute('data-sh3-view', 'editor');
185
+ slotHost.setAttribute('data-sh3-scope', 'focus:editor');
186
+ document.body.appendChild(slotHost);
187
+ const target = document.createElement('button');
188
+ slotHost.appendChild(target);
189
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
190
+ Object.defineProperty(ev, 'target', { value: target });
191
+ target.dispatchEvent(ev);
192
+ await Promise.resolve();
193
+ const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
194
+ .map((n) => n.textContent);
195
+ expect(labels).toEqual(['View']);
196
+ slotHost.remove();
197
+ });
152
198
  it('openContextMenu({scope}) uses the explicit anchor', async () => {
153
199
  registerAction({ id: 'cell.copy', label: 'Copy Cell', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
154
200
  registerAction({ id: 'home.x', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
@@ -15,3 +15,20 @@ export declare function innermostActiveScope(scope: ActionScope, state: Dispatch
15
15
  * atom is never equal to an element atom.
16
16
  */
17
17
  export declare function scopeEquals(a: AtomicScope, b: AtomicScope): boolean;
18
+ /**
19
+ * Canonical string encoding of an `AtomicScope` for transport over surfaces
20
+ * that can only carry strings — most notably `data-sh3-scope` DOM attribute
21
+ * values. String atoms (`home`, `app`, `view:<id>`, `focus:<id>`) pass
22
+ * through unchanged; element atoms encode as `element:<type>`. The element
23
+ * type itself may contain colons (e.g. `shard:type`) — only the leading
24
+ * `element:` is reserved by the encoding.
25
+ */
26
+ export declare function scopeToString(scope: AtomicScope): string;
27
+ /**
28
+ * Inverse of `scopeToString`. Returns `null` for inputs that do not parse to
29
+ * a valid `AtomicScope` so callers (e.g. the contextmenu listener reading
30
+ * `data-sh3-scope` from arbitrary DOM) can fall back gracefully without
31
+ * throwing on a malformed attribute. Empty bodies after a known prefix
32
+ * (`view:`, `focus:`, `element:`) also return null.
33
+ */
34
+ export declare function parseScopeString(s: string): AtomicScope | null;
@@ -59,3 +59,40 @@ export function scopeEquals(a, b) {
59
59
  return a === b;
60
60
  return a.element === b.element;
61
61
  }
62
+ /**
63
+ * Canonical string encoding of an `AtomicScope` for transport over surfaces
64
+ * that can only carry strings — most notably `data-sh3-scope` DOM attribute
65
+ * values. String atoms (`home`, `app`, `view:<id>`, `focus:<id>`) pass
66
+ * through unchanged; element atoms encode as `element:<type>`. The element
67
+ * type itself may contain colons (e.g. `shard:type`) — only the leading
68
+ * `element:` is reserved by the encoding.
69
+ */
70
+ export function scopeToString(scope) {
71
+ if (typeof scope === 'string')
72
+ return scope;
73
+ return `element:${scope.element}`;
74
+ }
75
+ /**
76
+ * Inverse of `scopeToString`. Returns `null` for inputs that do not parse to
77
+ * a valid `AtomicScope` so callers (e.g. the contextmenu listener reading
78
+ * `data-sh3-scope` from arbitrary DOM) can fall back gracefully without
79
+ * throwing on a malformed attribute. Empty bodies after a known prefix
80
+ * (`view:`, `focus:`, `element:`) also return null.
81
+ */
82
+ export function parseScopeString(s) {
83
+ if (s === 'home' || s === 'app')
84
+ return s;
85
+ if (s.startsWith('view:')) {
86
+ const rest = s.slice('view:'.length);
87
+ return rest.length > 0 ? s : null;
88
+ }
89
+ if (s.startsWith('focus:')) {
90
+ const rest = s.slice('focus:'.length);
91
+ return rest.length > 0 ? s : null;
92
+ }
93
+ if (s.startsWith('element:')) {
94
+ const rest = s.slice('element:'.length);
95
+ return rest.length > 0 ? { element: rest } : null;
96
+ }
97
+ return null;
98
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { scopeToTier, normalizeScope, scopeBadge, innermostActiveScope, scopeEquals, } from './scope-helpers';
2
+ import { scopeToTier, normalizeScope, scopeBadge, innermostActiveScope, scopeEquals, scopeToString, parseScopeString, } from './scope-helpers';
3
3
  const mkState = (o = {}) => (Object.assign({ activeAppId: null, activeAppRequiredShards: new Set(), autostartShards: new Set(), mountedViewIds: new Set(), focusedViewId: null, selection: null, bindings: {}, platform: 'other' }, o));
4
4
  describe('scopeToTier', () => {
5
5
  it('maps atoms to tier names', () => {
@@ -83,3 +83,35 @@ describe('scopeEquals', () => {
83
83
  expect(scopeEquals({ element: 'cell' }, 'home')).toBe(false);
84
84
  });
85
85
  });
86
+ describe('scopeToString / parseScopeString', () => {
87
+ const cases = [
88
+ 'home',
89
+ 'app',
90
+ 'view:editor',
91
+ 'focus:pane-1',
92
+ { element: 'cell' },
93
+ // Element type containing a colon — common shape (e.g. shard:type).
94
+ { element: 'svg-designer:layer' },
95
+ ];
96
+ it('round-trips every AtomicScope kind', () => {
97
+ for (const s of cases) {
98
+ const parsed = parseScopeString(scopeToString(s));
99
+ expect(parsed).toEqual(s);
100
+ }
101
+ });
102
+ it('encodes element atoms with the element: prefix', () => {
103
+ expect(scopeToString({ element: 'cell' })).toBe('element:cell');
104
+ expect(scopeToString({ element: 'svg-designer:layer' })).toBe('element:svg-designer:layer');
105
+ });
106
+ it('passes string atoms through unchanged', () => {
107
+ expect(scopeToString('home')).toBe('home');
108
+ expect(scopeToString('focus:pane-1')).toBe('focus:pane-1');
109
+ });
110
+ it('returns null on unknown / malformed inputs', () => {
111
+ expect(parseScopeString('')).toBeNull();
112
+ expect(parseScopeString('bogus')).toBeNull();
113
+ expect(parseScopeString('element:')).toBeNull();
114
+ expect(parseScopeString('view:')).toBeNull();
115
+ expect(parseScopeString('focus:')).toBeNull();
116
+ });
117
+ });
package/dist/api.d.ts CHANGED
@@ -27,7 +27,8 @@ export type { ConflictItem, ConflictBranch as ConflictManagerBranch, ResolveOpti
27
27
  export { CONFLICT_RENDERER_POINT, ConflictPermissionError, ConflictSessionOrphanedError, } from './conflicts/api';
28
28
  export type { ColorPickOptions, ColorContribution, ColorApi, } from './color/api';
29
29
  export { COLOR_PICKER_POINT } from './color/api';
30
- export { registeredShards, activeShards } from './shards/activate.svelte';
30
+ export { registeredShards, activeShards, erroredShards } from './shards/activate.svelte';
31
+ export type { ShardErrorEntry } from './shards/activate.svelte';
31
32
  export type { RegistryIndex, PackageEntry, PackageVersion, RequiredDependency, InstalledPackage, InstallResult, PackageMeta, } from './registry/types';
32
33
  export type { ResolvedPackage } from './registry/client';
33
34
  export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
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
+ });
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
@@ -47,6 +47,7 @@ export const registeredShards = $state(new Map());
47
47
  */
48
48
  const active = new Map();
49
49
  export const activeShards = $state(new Map());
50
+ export const erroredShards = $state(new Map());
50
51
  /**
51
52
  * Register (or re-register) a shard with the framework so it can later be
52
53
  * activated. Records the shard in `registeredShards` but does not run
@@ -63,6 +64,9 @@ export function registerShard(shard) {
63
64
  deactivateShard(id);
64
65
  }
65
66
  registeredShards.set(id, shard);
67
+ // Re-registering wipes any prior error: the new shard module gets a
68
+ // clean slate, and a hot-reload of a fixed shard removes the stale entry.
69
+ erroredShards.delete(id);
66
70
  }
67
71
  /**
68
72
  * Activate a registered shard. Builds a `ShardContext`, calls `shard.activate`,
@@ -70,11 +74,18 @@ export function registerShard(shard) {
70
74
  * calls `shard.autostart` if defined. Idempotent — calling on an already-active
71
75
  * shard is a no-op.
72
76
  *
77
+ * If `shard.activate` throws, partial state (registered views, verbs,
78
+ * contributions, document handles, actions, env subscription) is unwound
79
+ * and the failure is recorded in `erroredShards` before the error is
80
+ * re-thrown. Callers in `host.ts` (autostart loop) and `launchApp`
81
+ * (required-shard loop) decide how to react.
82
+ *
73
83
  * @param id - The `ShardManifest.id` of the shard to activate. Must be registered.
74
- * @throws If the shard is not registered, or if a manifest view has no factory after activation.
84
+ * @param opts - Optional. `phase` is recorded in `erroredShards` on failure (default 'launch').
85
+ * @throws If the shard is not registered, if `shard.activate` throws, or if a manifest view has no factory after activation.
75
86
  */
76
- export async function activateShard(id) {
77
- var _a, _b, _c, _d, _e;
87
+ export async function activateShard(id, opts) {
88
+ var _a, _b, _c, _d, _e, _f;
78
89
  const shard = registeredShards.get(id);
79
90
  if (!shard) {
80
91
  throw new Error(`Cannot activate shard "${id}": not registered`);
@@ -201,24 +212,57 @@ export async function activateShard(id) {
201
212
  }
202
213
  active.set(id, entry);
203
214
  activeShards.set(id, shard);
204
- await shard.activate(ctx);
205
- for (const view of shard.manifest.views) {
206
- if (!entry.viewIds.has(view.id)) {
207
- throw new Error(`Shard "${id}" declared view "${view.id}" in its manifest but registered no factory for it.`);
215
+ try {
216
+ await shard.activate(ctx);
217
+ for (const view of shard.manifest.views) {
218
+ if (!entry.viewIds.has(view.id)) {
219
+ throw new Error(`Shard "${id}" declared view "${view.id}" in its manifest but registered no factory for it.`);
220
+ }
208
221
  }
209
- }
210
- // Hydrate env state if the shard declared it via ctx.env().
211
- if (envState.proxy && envState.defaults) {
212
- try {
213
- const stored = await fetchEnvState(id);
214
- const merged = Object.assign({}, envState.defaults, stored);
215
- Object.assign(envState.proxy, merged);
222
+ // Hydrate env state if the shard declared it via ctx.env().
223
+ if (envState.proxy && envState.defaults) {
224
+ try {
225
+ const stored = await fetchEnvState(id);
226
+ const merged = Object.assign({}, envState.defaults, stored);
227
+ Object.assign(envState.proxy, merged);
228
+ }
229
+ catch (err) {
230
+ console.warn(`[sh3] Failed to hydrate env state for shard "${id}":`, err instanceof Error ? err.message : err);
231
+ }
216
232
  }
217
- catch (err) {
218
- console.warn(`[sh3] Failed to hydrate env state for shard "${id}":`, err instanceof Error ? err.message : err);
233
+ }
234
+ catch (err) {
235
+ // Unwind partial state. Mirror deactivateShard's body, minus the
236
+ // shard.deactivate?.() call — the shard never finished activating.
237
+ // Each cleanup fn runs inside its own swallow so a teardown failure
238
+ // cannot mask the original activation error.
239
+ for (const fn of entry.cleanupFns) {
240
+ try {
241
+ void fn();
242
+ }
243
+ catch (_g) {
244
+ // intentionally swallowed: original error is what matters.
245
+ }
219
246
  }
247
+ for (const name of entry.verbNames)
248
+ fwUnregisterVerb(name);
249
+ for (const viewId of entry.viewIds)
250
+ unregisterView(viewId);
251
+ clearSelectionForShard(id);
252
+ active.delete(id);
253
+ activeShards.delete(id);
254
+ erroredShards.set(id, {
255
+ id,
256
+ error: err,
257
+ phase: (_e = opts === null || opts === void 0 ? void 0 : opts.phase) !== null && _e !== void 0 ? _e : 'launch',
258
+ timestamp: Date.now(),
259
+ });
260
+ console.error(`[sh3] Shard "${id}" failed to activate:`, err);
261
+ throw err;
220
262
  }
221
- void ((_e = shard.autostart) === null || _e === void 0 ? void 0 : _e.call(shard, ctx));
263
+ // Activation succeeded clear any prior error record for this shard.
264
+ erroredShards.delete(id);
265
+ void ((_f = shard.autostart) === null || _f === void 0 ? void 0 : _f.call(shard, ctx));
222
266
  }
223
267
  /**
224
268
  * Deactivate an active shard. Calls `shard.deactivate`, flushes and disposes
@@ -288,4 +332,5 @@ export function __resetShardRegistryForTest() {
288
332
  active.clear();
289
333
  activeShards.clear();
290
334
  registeredShards.clear();
335
+ erroredShards.clear();
291
336
  }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Auto-generated from package.json — do not edit manually. */
2
- export declare const VERSION = "0.11.8";
2
+ export declare const VERSION = "0.12.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Auto-generated from package.json — do not edit manually. */
2
- export const VERSION = '0.11.8';
2
+ export const VERSION = '0.12.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.11.8",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"