sh3-core 0.11.7 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/actions/contextMenuModel.d.ts +5 -4
  2. package/dist/actions/contextMenuModel.js +26 -12
  3. package/dist/actions/contextMenuModel.test.js +49 -24
  4. package/dist/actions/listeners.d.ts +2 -0
  5. package/dist/actions/listeners.js +65 -6
  6. package/dist/actions/listeners.test.js +96 -8
  7. package/dist/actions/scope-helpers.d.ts +23 -0
  8. package/dist/actions/scope-helpers.js +47 -0
  9. package/dist/actions/scope-helpers.test.js +56 -1
  10. package/dist/actions/types.d.ts +1 -0
  11. package/dist/api.d.ts +2 -1
  12. package/dist/api.js +1 -1
  13. package/dist/app/store/InstalledView.svelte +2 -1
  14. package/dist/app/store/StoreView.svelte +2 -1
  15. package/dist/apps/lifecycle.d.ts +7 -0
  16. package/dist/apps/lifecycle.js +22 -5
  17. package/dist/apps/lifecycle.test.js +50 -0
  18. package/dist/documents/browse.d.ts +15 -0
  19. package/dist/documents/browse.js +7 -0
  20. package/dist/documents/browse.test.js +41 -0
  21. package/dist/documents/handle.js +3 -1
  22. package/dist/documents/handle.test.js +23 -0
  23. package/dist/host.js +18 -4
  24. package/dist/layout/LayoutRenderer.svelte +5 -1
  25. package/dist/layout/LayoutRenderer.test.js +42 -0
  26. package/dist/layout/SlotContainer.svelte +11 -2
  27. package/dist/layout/SlotContainer.svelte.d.ts +1 -0
  28. package/dist/layout/slotHostPool.svelte.js +10 -3
  29. package/dist/layout/slotHostPool.test.js +15 -0
  30. package/dist/shards/activate-error-isolation.test.d.ts +1 -0
  31. package/dist/shards/activate-error-isolation.test.js +98 -0
  32. package/dist/shards/activate.svelte.d.ts +30 -2
  33. package/dist/shards/activate.svelte.js +62 -17
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import type { ActionEntry } from './registry';
2
2
  import { type DispatcherState, type TierName } from './dispatcher.svelte';
3
+ import type { AtomicScope } from './types';
3
4
  export interface MenuItem {
4
5
  id: string;
5
6
  label: string;
@@ -17,10 +18,10 @@ export interface MenuTier {
17
18
  export interface ContextMenuModel {
18
19
  tiers: MenuTier[];
19
20
  }
20
- export declare function buildContextMenuModel(entries: ActionEntry[], state: DispatcherState): ContextMenuModel;
21
+ export declare function buildContextMenuModel(entries: ActionEntry[], state: DispatcherState, anchor: AtomicScope): ContextMenuModel;
21
22
  /**
22
23
  * Active children of a context-menu submenu parent. Single flat list,
23
- * de-duplicated by id, in registration order. No tier grouping inside
24
- * a submenu popup one continuous list, separators driven by `group`.
24
+ * de-duplicated by id, in registration order. The same anchor filter that
25
+ * admitted the parent is applied to children.
25
26
  */
26
- export declare function buildContextMenuSubmenu(entries: ActionEntry[], state: DispatcherState, parentId: string): MenuItem[];
27
+ export declare function buildContextMenuSubmenu(entries: ActionEntry[], state: DispatcherState, parentId: string, anchor: AtomicScope): MenuItem[];
@@ -1,11 +1,14 @@
1
1
  /*
2
2
  * Pure model layer for the context menu: takes the action registry +
3
- * dispatcher state, returns a tiered, deduplicated, flag-annotated item
4
- * list the Svelte component renders without further logic.
3
+ * dispatcher state + an anchor scope, returns a tier-grouped, deduplicated,
4
+ * flag-annotated item list the Svelte component renders without further
5
+ * logic. Items pass iff their declared scope list contains the anchor;
6
+ * `app`/`home` anchors additionally require the existing owner-shard
7
+ * activation guard.
5
8
  */
6
- import { TIER_ORDER } from './dispatcher.svelte';
9
+ import { TIER_ORDER, isScopeActive, } from './dispatcher.svelte';
7
10
  import { effectiveShortcut } from './bindings';
8
- import { scopeToTier, innermostActiveScope } from './scope-helpers';
11
+ import { scopeToTier, innermostActiveScope, scopeEquals, normalizeScope, } from './scope-helpers';
9
12
  function evalFlag(v) {
10
13
  if (v === undefined)
11
14
  return false;
@@ -24,7 +27,17 @@ function toMenuItem(entry, state) {
24
27
  submenu: entry.action.submenu === true,
25
28
  };
26
29
  }
27
- export function buildContextMenuModel(entries, state) {
30
+ function matchesAnchor(action, ownerShardId, anchor, state) {
31
+ const inList = normalizeScope(action.scope).some((s) => scopeEquals(s, anchor));
32
+ if (!inList)
33
+ return false;
34
+ if (anchor === 'app' || anchor === 'home') {
35
+ return isScopeActive(anchor, state, ownerShardId);
36
+ }
37
+ return true;
38
+ }
39
+ export function buildContextMenuModel(entries, state, anchor) {
40
+ var _a;
28
41
  const byTier = {
29
42
  element: [], focus: [], view: [], app: [], home: [],
30
43
  };
@@ -36,9 +49,11 @@ export function buildContextMenuModel(entries, state) {
36
49
  continue;
37
50
  if (seen.has(entry.action.id))
38
51
  continue;
39
- const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
40
- if (!winning)
52
+ if (!matchesAnchor(entry.action, entry.ownerShardId, anchor, state))
41
53
  continue;
54
+ // Tier is determined by the anchor itself; the per-action winning scope
55
+ // is preserved purely for any future tier-aware rendering.
56
+ const winning = (_a = innermostActiveScope(entry.action.scope, state, entry.ownerShardId)) !== null && _a !== void 0 ? _a : anchor;
42
57
  seen.add(entry.action.id);
43
58
  byTier[scopeToTier(winning)].push(toMenuItem(entry, state));
44
59
  }
@@ -50,10 +65,10 @@ export function buildContextMenuModel(entries, state) {
50
65
  }
51
66
  /**
52
67
  * Active children of a context-menu submenu parent. Single flat list,
53
- * de-duplicated by id, in registration order. No tier grouping inside
54
- * a submenu popup one continuous list, separators driven by `group`.
68
+ * de-duplicated by id, in registration order. The same anchor filter that
69
+ * admitted the parent is applied to children.
55
70
  */
56
- export function buildContextMenuSubmenu(entries, state, parentId) {
71
+ export function buildContextMenuSubmenu(entries, state, parentId, anchor) {
57
72
  const out = [];
58
73
  const seen = new Set();
59
74
  for (const entry of entries) {
@@ -61,8 +76,7 @@ export function buildContextMenuSubmenu(entries, state, parentId) {
61
76
  continue;
62
77
  if (seen.has(entry.action.id))
63
78
  continue;
64
- const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
65
- if (!winning)
79
+ if (!matchesAnchor(entry.action, entry.ownerShardId, anchor, state))
66
80
  continue;
67
81
  seen.add(entry.action.id);
68
82
  out.push(toMenuItem(entry, state));
@@ -9,40 +9,57 @@ describe('buildContextMenuModel', () => {
9
9
  it('returns only actions with contextItem: true', () => {
10
10
  const entries = [
11
11
  mkEntry({ id: 'a', scope: 'home', contextItem: true, label: 'A' }),
12
- mkEntry({ id: 'b', scope: 'home', label: 'B' }), // no contextItem
12
+ mkEntry({ id: 'b', scope: 'home', label: 'B' }),
13
13
  ];
14
- const model = buildContextMenuModel(entries, mkState());
14
+ const model = buildContextMenuModel(entries, mkState(), 'home');
15
15
  expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['a']);
16
16
  });
17
- it('groups by scope tier in innermost-first order with separators', () => {
18
- const state = mkState({
19
- activeAppId: 'app.a',
20
- activeAppRequiredShards: new Set(['shard.x']),
21
- selection: { type: 'orb', ref: 1, ownerShardId: 'shard.x' },
22
- });
17
+ it('admits only actions whose scope list contains the anchor', () => {
18
+ const state = mkState({ mountedViewIds: new Set(['editor']), focusedViewId: 'editor' });
23
19
  const entries = [
24
- mkEntry({ id: 'el', scope: { element: 'orb' }, contextItem: true, label: 'Dup' }),
25
- mkEntry({ id: 'ap', scope: 'app', contextItem: true, label: 'Undo' }),
20
+ mkEntry({ id: 'view-only', scope: 'focus:editor', contextItem: true, label: 'V' }),
21
+ mkEntry({ id: 'home-only', scope: 'home', contextItem: true, label: 'H' }),
26
22
  ];
27
- const model = buildContextMenuModel(entries, state);
28
- expect(model.tiers.map((t) => t.tier)).toEqual(['element', 'app']);
29
- expect(model.tiers[0].items[0].id).toBe('el');
23
+ const model = buildContextMenuModel(entries, state, 'focus:editor');
24
+ expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['view-only']);
30
25
  });
31
- it('de-duplicates multi-scope action to innermost tier', () => {
26
+ it('admits a multi-scope action when the anchor is one of its scopes', () => {
27
+ const state = mkState({ mountedViewIds: new Set(['editor']), focusedViewId: 'editor' });
28
+ const entries = [
29
+ mkEntry({ id: 'multi', scope: ['focus:editor', 'app'], contextItem: true, label: 'M' }),
30
+ ];
31
+ const model = buildContextMenuModel(entries, state, 'focus:editor');
32
+ expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['multi']);
33
+ });
34
+ it('matches element scopes by element-type equality', () => {
35
+ const entries = [
36
+ mkEntry({ id: 'cell', scope: { element: 'cell' }, contextItem: true, label: 'C' }),
37
+ mkEntry({ id: 'row', scope: { element: 'row' }, contextItem: true, label: 'R' }),
38
+ ];
39
+ const model = buildContextMenuModel(entries, mkState(), { element: 'cell' });
40
+ expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['cell']);
41
+ });
42
+ it('app anchor honors the owner-shard guard', () => {
32
43
  const state = mkState({
33
44
  activeAppId: 'app.a',
34
45
  activeAppRequiredShards: new Set(['shard.x']),
35
- autostartShards: new Set(['shard.x']),
36
46
  });
37
47
  const entries = [
38
- mkEntry({ id: 'p', scope: ['home', 'app'], contextItem: true, label: 'P' }),
48
+ mkEntry({ id: 'mine', scope: 'app', contextItem: true, label: 'M' }, 'shard.x'),
49
+ mkEntry({ id: 'foreign', scope: 'app', contextItem: true, label: 'F' }, 'shard.y'),
39
50
  ];
40
- const model = buildContextMenuModel(entries, state);
41
- expect(model.tiers).toHaveLength(1);
42
- expect(model.tiers[0].tier).toBe('app');
51
+ const model = buildContextMenuModel(entries, state, 'app');
52
+ expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['mine']);
53
+ });
54
+ it('home anchor only admits actions when no app is active', () => {
55
+ const noApp = mkState();
56
+ const withApp = mkState({ activeAppId: 'app.a' });
57
+ const entries = [
58
+ mkEntry({ id: 'h', scope: 'home', contextItem: true, label: 'H' }),
59
+ ];
60
+ expect(buildContextMenuModel(entries, noApp, 'home').tiers).toHaveLength(1);
61
+ expect(buildContextMenuModel(entries, withApp, 'home').tiers).toHaveLength(0);
43
62
  });
44
- });
45
- describe('buildContextMenuModel — extended fields', () => {
46
63
  it('flags checked / disabled / submenu and excludes children', () => {
47
64
  const entries = [
48
65
  mkEntry({ id: 'p', label: 'P', scope: 'home', contextItem: true, submenu: true }),
@@ -50,22 +67,30 @@ describe('buildContextMenuModel — extended fields', () => {
50
67
  mkEntry({ id: 't', label: 'T', scope: 'home', contextItem: true, checked: true }),
51
68
  mkEntry({ id: 'd', label: 'D', scope: 'home', contextItem: true, disabled: () => true }),
52
69
  ];
53
- const model = buildContextMenuModel(entries, mkState());
70
+ const model = buildContextMenuModel(entries, mkState(), 'home');
54
71
  const homeItems = model.tiers.find((t) => t.tier === 'home').items;
55
72
  expect(homeItems.map((i) => i.id)).toEqual(['p', 't', 'd']);
56
73
  expect(homeItems[0].submenu).toBe(true);
57
74
  expect(homeItems[1].checked).toBe(true);
58
75
  expect(homeItems[2].disabled).toBe(true);
59
76
  });
77
+ it('returns an empty model when no action matches the anchor', () => {
78
+ const entries = [
79
+ mkEntry({ id: 'x', scope: 'home', contextItem: true, label: 'X' }),
80
+ ];
81
+ const model = buildContextMenuModel(entries, mkState(), 'focus:nope');
82
+ expect(model.tiers).toEqual([]);
83
+ });
60
84
  });
61
85
  describe('buildContextMenuSubmenu', () => {
62
- it('returns active children of a parent', () => {
86
+ it('returns children of the parent that match the anchor', () => {
63
87
  const entries = [
64
88
  mkEntry({ id: 'p', label: 'P', scope: 'home', contextItem: true, submenu: true }),
65
89
  mkEntry({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p' }),
66
90
  mkEntry({ id: 'p.b', label: 'B', scope: 'home', submenuOf: 'p' }),
91
+ mkEntry({ id: 'p.c', label: 'C', scope: 'app', submenuOf: 'p' }),
67
92
  ];
68
- const items = buildContextMenuSubmenu(entries, mkState(), 'p');
93
+ const items = buildContextMenuSubmenu(entries, mkState(), 'p', 'home');
69
94
  expect(items.map((i) => i.id)).toEqual(['p.a', 'p.b']);
70
95
  });
71
96
  });
@@ -1,6 +1,8 @@
1
+ import type { AtomicScope } from './types';
1
2
  export interface OpenContextMenuOpts {
2
3
  x: number;
3
4
  y: number;
5
+ scope?: AtomicScope;
4
6
  }
5
7
  export interface OpenPaletteOpts {
6
8
  prefill?: string;
@@ -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) {
@@ -22,6 +23,30 @@ function viewIdOfEl(el) {
22
23
  const host = el.closest('[data-sh3-view]');
23
24
  return (_a = host === null || host === void 0 ? void 0 : host.getAttribute('data-sh3-view')) !== null && _a !== void 0 ? _a : null;
24
25
  }
26
+ function resolveAnchor(args) {
27
+ var _a, _b;
28
+ if (args.explicit !== undefined)
29
+ return args.explicit;
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);
45
+ if (viewId)
46
+ return `focus:${viewId}`;
47
+ }
48
+ return args.state.activeAppId ? 'app' : 'home';
49
+ }
25
50
  function runAction(actionId, ctx) {
26
51
  const entry = listActions().find((e) => e.action.id === actionId);
27
52
  if (!entry || typeof entry.action.run !== 'function')
@@ -71,7 +96,7 @@ function isNativeOptOut(target) {
71
96
  return false;
72
97
  return target.closest('[data-sh3-context-menu="native"]') !== null;
73
98
  }
74
- function openContextSubmenu(parentId, state, handle) {
99
+ function openContextSubmenu(parentId, state, handle, anchor) {
75
100
  const root = document.querySelector('.sh3-popup-host');
76
101
  if (!root)
77
102
  return;
@@ -84,7 +109,7 @@ function openContextSubmenu(parentId, state, handle) {
84
109
  sub.style.left = `${anchorRect.right + 2}px`;
85
110
  sub.style.top = `${anchorRect.top}px`;
86
111
  root.appendChild(sub);
87
- const subItems = buildContextMenuSubmenu(listActions(), state, parentId);
112
+ const subItems = buildContextMenuSubmenu(listActions(), state, parentId, anchor);
88
113
  mount(ActionPanel, {
89
114
  target: sub,
90
115
  props: {
@@ -118,7 +143,8 @@ function onContextMenu(ev) {
118
143
  return;
119
144
  const entries = listActions();
120
145
  const state = getLiveDispatcherState();
121
- const model = buildContextMenuModel(entries, state);
146
+ const anchor = resolveAnchor({ event: ev, state });
147
+ const model = buildContextMenuModel(entries, state, anchor);
122
148
  if (model.tiers.length === 0)
123
149
  return;
124
150
  ev.preventDefault();
@@ -130,7 +156,7 @@ function onContextMenu(ev) {
130
156
  if (!entry)
131
157
  return;
132
158
  if (entry.action.submenu === true) {
133
- openContextSubmenu(id, state, handle);
159
+ openContextSubmenu(id, state, handle, anchor);
134
160
  return;
135
161
  }
136
162
  if (typeof entry.action.run !== 'function')
@@ -188,8 +214,41 @@ export function detachGlobalListeners() {
188
214
  document.removeEventListener('contextmenu', onContextMenu);
189
215
  }
190
216
  export function openContextMenu(opts) {
191
- const fakeEvent = { target: null, clientX: opts.x, clientY: opts.y, preventDefault: () => { } };
192
- onContextMenu(fakeEvent);
217
+ const entries = listActions();
218
+ const state = getLiveDispatcherState();
219
+ const anchor = resolveAnchor({ explicit: opts.scope, state });
220
+ const model = buildContextMenuModel(entries, state, anchor);
221
+ if (model.tiers.length === 0)
222
+ return;
223
+ const handle = shell.popup.show(ContextMenu, { anchor: { x: opts.x, y: opts.y } }, {
224
+ model,
225
+ onInvoke: (id) => {
226
+ var _a, _b;
227
+ const entry = listActions().find((e) => e.action.id === id);
228
+ if (!entry)
229
+ return;
230
+ if (entry.action.submenu === true) {
231
+ openContextSubmenu(id, state, handle, anchor);
232
+ return;
233
+ }
234
+ if (typeof entry.action.run !== 'function')
235
+ return;
236
+ try {
237
+ void entry.action.run({
238
+ action: { id, label: entry.action.label },
239
+ appId: state.activeAppId,
240
+ viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
241
+ selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
242
+ invokedVia: 'context-menu',
243
+ dispatch: chainedDispatch,
244
+ });
245
+ }
246
+ catch (err) {
247
+ console.error(`[sh3] context-menu action "${id}" threw:`, err);
248
+ }
249
+ },
250
+ onClose: () => handle.close(),
251
+ });
193
252
  }
194
253
  const RECENCY_CAP = 20;
195
254
  let recency = [];
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { attachGlobalListeners, detachGlobalListeners, openPalette } from './listeners';
2
+ import { attachGlobalListeners, detachGlobalListeners, openPalette, openContextMenu } from './listeners';
3
3
  import { registerAction, __resetActionsRegistryForTest } from './registry';
4
4
  import { __resetContributionsForTest } from '../contributions/registry';
5
5
  import { __resetDispatcherStateForTest, setActiveApp, setMountedViewIds, setFocusedViewId, } from './state.svelte';
@@ -82,14 +82,14 @@ describe('global contextmenu listener', () => {
82
82
  popupLayerRoot.remove();
83
83
  vi.unstubAllGlobals();
84
84
  });
85
- it('opens popup on contextmenu and preventDefault()s native', () => {
85
+ it('opens popup with home anchor when no view ancestor and no active app', () => {
86
86
  registerAction({ id: 'a.x', label: 'Dup', scope: 'home', contextItem: true, run: () => { } }, 'a');
87
87
  const target = document.createElement('div');
88
88
  document.body.appendChild(target);
89
89
  const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
90
90
  Object.defineProperty(ev, 'target', { value: target });
91
91
  const def = target.dispatchEvent(ev);
92
- expect(def).toBe(false); // preventDefault
92
+ expect(def).toBe(false);
93
93
  expect(document.querySelector('.sh3-context-menu')).not.toBeNull();
94
94
  target.remove();
95
95
  });
@@ -101,21 +101,110 @@ describe('global contextmenu listener', () => {
101
101
  const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
102
102
  Object.defineProperty(ev, 'target', { value: target });
103
103
  const def = target.dispatchEvent(ev);
104
- expect(def).toBe(true); // native preserved
104
+ expect(def).toBe(true);
105
105
  expect(document.querySelector('.sh3-context-menu')).toBeNull();
106
106
  target.remove();
107
107
  });
108
- it('does not open when no contextItem actions are active', () => {
108
+ it('does not open when no contextItem actions match the anchor', () => {
109
109
  registerAction({ id: 'a.x', label: 'Save', scope: 'home', contextItem: false, run: () => { } }, 'a');
110
110
  const target = document.createElement('div');
111
111
  document.body.appendChild(target);
112
112
  const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
113
113
  Object.defineProperty(ev, 'target', { value: target });
114
114
  const def = target.dispatchEvent(ev);
115
- expect(def).toBe(true); // no menu → native preserved
115
+ expect(def).toBe(true);
116
116
  target.remove();
117
117
  });
118
- it('clicking a submenu-parent row opens a nested ActionPanel listing children', async () => {
118
+ it('right-click inside data-sh3-view anchors to focus:<viewId>', async () => {
119
+ setActiveApp('app.a', new Set(['shard.x']));
120
+ setMountedViewIds(new Set(['editor']));
121
+ registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
122
+ registerAction({ id: 'home-only', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
123
+ const wrap = document.createElement('div');
124
+ wrap.setAttribute('data-sh3-view', 'editor');
125
+ document.body.appendChild(wrap);
126
+ const inner = document.createElement('button');
127
+ wrap.appendChild(inner);
128
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
129
+ Object.defineProperty(ev, 'target', { value: inner });
130
+ inner.dispatchEvent(ev);
131
+ await Promise.resolve();
132
+ const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
133
+ .map((n) => n.textContent);
134
+ expect(labels).toEqual(['View']);
135
+ wrap.remove();
136
+ });
137
+ it('right-click outside any view falls back to app anchor when an app is active', async () => {
138
+ setActiveApp('app.a', new Set(['shard.x']));
139
+ registerAction({ id: 'app.a', label: 'App', scope: 'app', contextItem: true, run: () => { } }, 'shard.x');
140
+ registerAction({ id: 'h.a', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
141
+ const target = document.createElement('div');
142
+ document.body.appendChild(target);
143
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
144
+ Object.defineProperty(ev, 'target', { value: target });
145
+ target.dispatchEvent(ev);
146
+ await Promise.resolve();
147
+ const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
148
+ .map((n) => n.textContent);
149
+ expect(labels).toEqual(['App']);
150
+ target.remove();
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
+ });
198
+ it('openContextMenu({scope}) uses the explicit anchor', async () => {
199
+ registerAction({ id: 'cell.copy', label: 'Copy Cell', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
200
+ registerAction({ id: 'home.x', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
201
+ openContextMenu({ x: 10, y: 20, scope: { element: 'cell' } });
202
+ await Promise.resolve();
203
+ const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
204
+ .map((n) => n.textContent);
205
+ expect(labels).toEqual(['Copy Cell']);
206
+ });
207
+ it('clicking a submenu-parent row opens children filtered by the same anchor', async () => {
119
208
  registerAction({ id: 'p', label: 'Open with…', scope: 'home', contextItem: true, submenu: true }, 'a');
120
209
  registerAction({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p', run: () => { } }, 'a');
121
210
  registerAction({ id: 'p.b', label: 'B', scope: 'home', submenuOf: 'p', run: () => { } }, 'a');
@@ -125,7 +214,6 @@ describe('global contextmenu listener', () => {
125
214
  Object.defineProperty(ev, 'target', { value: target });
126
215
  target.dispatchEvent(ev);
127
216
  await Promise.resolve();
128
- expect(document.querySelectorAll('.sh3-context-menu')).toHaveLength(1);
129
217
  const parentRow = document.querySelector('.sh3-context-menu [role="menuitem"]');
130
218
  parentRow.click();
131
219
  await Promise.resolve();
@@ -9,3 +9,26 @@ export declare function scopeBadge(scope: AtomicScope): string | null;
9
9
  * scope is active.
10
10
  */
11
11
  export declare function innermostActiveScope(scope: ActionScope, state: DispatcherState, ownerShardId: string): AtomicScope | null;
12
+ /**
13
+ * Value equality for `AtomicScope`. String atoms compare by string equality;
14
+ * element atoms (`{ element: T }`) compare by their `element` field. A string
15
+ * atom is never equal to an element atom.
16
+ */
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;
@@ -49,3 +49,50 @@ export function innermostActiveScope(scope, state, ownerShardId) {
49
49
  }
50
50
  return null;
51
51
  }
52
+ /**
53
+ * Value equality for `AtomicScope`. String atoms compare by string equality;
54
+ * element atoms (`{ element: T }`) compare by their `element` field. A string
55
+ * atom is never equal to an element atom.
56
+ */
57
+ export function scopeEquals(a, b) {
58
+ if (typeof a === 'string' || typeof b === 'string')
59
+ return a === b;
60
+ return a.element === b.element;
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, } 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', () => {
@@ -60,3 +60,58 @@ describe('innermostActiveScope', () => {
60
60
  expect(winner).toEqual({ element: 'row' });
61
61
  });
62
62
  });
63
+ describe('scopeEquals', () => {
64
+ it('returns true for identical string atoms', () => {
65
+ expect(scopeEquals('home', 'home')).toBe(true);
66
+ expect(scopeEquals('app', 'app')).toBe(true);
67
+ expect(scopeEquals('view:editor', 'view:editor')).toBe(true);
68
+ expect(scopeEquals('focus:pane-1', 'focus:pane-1')).toBe(true);
69
+ });
70
+ it('returns false for different string atoms', () => {
71
+ expect(scopeEquals('home', 'app')).toBe(false);
72
+ expect(scopeEquals('view:editor', 'view:other')).toBe(false);
73
+ expect(scopeEquals('focus:pane-1', 'view:pane-1')).toBe(false);
74
+ });
75
+ it('returns true for element atoms with the same element type', () => {
76
+ expect(scopeEquals({ element: 'cell' }, { element: 'cell' })).toBe(true);
77
+ });
78
+ it('returns false for element atoms with different element types', () => {
79
+ expect(scopeEquals({ element: 'cell' }, { element: 'row' })).toBe(false);
80
+ });
81
+ it('returns false when comparing string atom to element atom', () => {
82
+ expect(scopeEquals('home', { element: 'cell' })).toBe(false);
83
+ expect(scopeEquals({ element: 'cell' }, 'home')).toBe(false);
84
+ });
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
+ });
@@ -88,6 +88,7 @@ export interface ActionsApi {
88
88
  openContextMenu(opts: {
89
89
  x: number;
90
90
  y: number;
91
+ scope?: AtomicScope;
91
92
  }): void;
92
93
  openPalette(opts?: {
93
94
  prefill?: string;
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';