sh3-core 0.11.8 → 0.13.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 (104) hide show
  1. package/dist/__test__/reset.js +2 -0
  2. package/dist/actions/MenuButton.svelte +2 -1
  3. package/dist/actions/contextMenuModel.js +8 -0
  4. package/dist/actions/contextMenuModel.test.js +22 -2
  5. package/dist/actions/listeners.js +28 -2
  6. package/dist/actions/listeners.test.js +87 -1
  7. package/dist/actions/scope-helpers.d.ts +17 -0
  8. package/dist/actions/scope-helpers.js +37 -0
  9. package/dist/actions/scope-helpers.test.js +33 -1
  10. package/dist/api.d.ts +18 -1
  11. package/dist/api.js +15 -1
  12. package/dist/app/store/InstalledView.svelte +2 -1
  13. package/dist/app/store/StoreView.svelte +2 -1
  14. package/dist/apps/lifecycle.d.ts +7 -0
  15. package/dist/apps/lifecycle.js +25 -5
  16. package/dist/apps/lifecycle.test.js +95 -0
  17. package/dist/host.js +30 -4
  18. package/dist/layout/LayoutRenderer.svelte +5 -1
  19. package/dist/layout/LayoutRenderer.test.js +42 -0
  20. package/dist/layout/SlotContainer.svelte +11 -2
  21. package/dist/layout/SlotContainer.svelte.d.ts +1 -0
  22. package/dist/layout/slotHostPool.svelte.js +10 -3
  23. package/dist/layout/slotHostPool.test.js +15 -0
  24. package/dist/navigation/back-stack.d.ts +29 -0
  25. package/dist/navigation/back-stack.js +87 -0
  26. package/dist/navigation/back-stack.test.d.ts +1 -0
  27. package/dist/navigation/back-stack.test.js +145 -0
  28. package/dist/navigation/index.d.ts +2 -0
  29. package/dist/navigation/index.js +6 -0
  30. package/dist/navigation/platform-web.d.ts +3 -0
  31. package/dist/navigation/platform-web.js +54 -0
  32. package/dist/navigation/platform-web.test.d.ts +1 -0
  33. package/dist/navigation/platform-web.test.js +96 -0
  34. package/dist/overlays/modal.js +7 -0
  35. package/dist/overlays/modal.test.js +35 -0
  36. package/dist/overlays/popup.js +7 -0
  37. package/dist/overlays/popup.test.js +33 -0
  38. package/dist/platform/index.d.ts +15 -0
  39. package/dist/platform/index.js +47 -0
  40. package/dist/primitives/base.css +17 -6
  41. package/dist/primitives/widgets/ColorSwatch.svelte +66 -0
  42. package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +9 -0
  43. package/dist/primitives/widgets/Field.svelte +124 -0
  44. package/dist/primitives/widgets/Field.svelte.d.ts +19 -0
  45. package/dist/primitives/widgets/FilePicker.d.ts +3 -0
  46. package/dist/primitives/widgets/FilePicker.js +19 -0
  47. package/dist/primitives/widgets/FilePicker.svelte +79 -0
  48. package/dist/primitives/widgets/FilePicker.svelte.d.ts +13 -0
  49. package/dist/primitives/widgets/FilePicker.test.d.ts +1 -0
  50. package/dist/primitives/widgets/FilePicker.test.js +44 -0
  51. package/dist/primitives/widgets/IconToggleGroup.d.ts +2 -0
  52. package/dist/primitives/widgets/IconToggleGroup.js +8 -0
  53. package/dist/primitives/widgets/IconToggleGroup.svelte +86 -0
  54. package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +16 -0
  55. package/dist/primitives/widgets/IconToggleGroup.test.d.ts +1 -0
  56. package/dist/primitives/widgets/IconToggleGroup.test.js +19 -0
  57. package/dist/primitives/widgets/NumberInput.d.ts +6 -0
  58. package/dist/primitives/widgets/NumberInput.js +19 -0
  59. package/dist/primitives/widgets/NumberInput.svelte +167 -0
  60. package/dist/primitives/widgets/NumberInput.svelte.d.ts +17 -0
  61. package/dist/primitives/widgets/NumberInput.test.d.ts +1 -0
  62. package/dist/primitives/widgets/NumberInput.test.js +28 -0
  63. package/dist/primitives/widgets/RangeSlider.d.ts +2 -0
  64. package/dist/primitives/widgets/RangeSlider.js +7 -0
  65. package/dist/primitives/widgets/RangeSlider.svelte +124 -0
  66. package/dist/primitives/widgets/RangeSlider.svelte.d.ts +13 -0
  67. package/dist/primitives/widgets/RangeSlider.test.d.ts +1 -0
  68. package/dist/primitives/widgets/RangeSlider.test.js +14 -0
  69. package/dist/primitives/widgets/Segmented.d.ts +9 -0
  70. package/dist/primitives/widgets/Segmented.js +28 -0
  71. package/dist/primitives/widgets/Segmented.svelte +82 -0
  72. package/dist/primitives/widgets/Segmented.svelte.d.ts +10 -0
  73. package/dist/primitives/widgets/Segmented.test.d.ts +1 -0
  74. package/dist/primitives/widgets/Segmented.test.js +24 -0
  75. package/dist/primitives/widgets/Select.d.ts +11 -0
  76. package/dist/primitives/widgets/Select.js +42 -0
  77. package/dist/primitives/widgets/Select.svelte +163 -0
  78. package/dist/primitives/widgets/Select.svelte.d.ts +14 -0
  79. package/dist/primitives/widgets/Select.test.d.ts +1 -0
  80. package/dist/primitives/widgets/Select.test.js +68 -0
  81. package/dist/primitives/widgets/Slider.d.ts +6 -0
  82. package/dist/primitives/widgets/Slider.js +19 -0
  83. package/dist/primitives/widgets/Slider.svelte +205 -0
  84. package/dist/primitives/widgets/Slider.svelte.d.ts +15 -0
  85. package/dist/primitives/widgets/Slider.test.d.ts +1 -0
  86. package/dist/primitives/widgets/Slider.test.js +31 -0
  87. package/dist/primitives/widgets/SliderGroup.svelte +58 -0
  88. package/dist/primitives/widgets/SliderGroup.svelte.d.ts +18 -0
  89. package/dist/primitives/widgets/Textarea.svelte +81 -0
  90. package/dist/primitives/widgets/Textarea.svelte.d.ts +16 -0
  91. package/dist/primitives/widgets/_select-listbox.svelte +228 -0
  92. package/dist/primitives/widgets/_select-listbox.svelte.d.ts +18 -0
  93. package/dist/shards/activate-error-isolation.test.d.ts +1 -0
  94. package/dist/shards/activate-error-isolation.test.js +98 -0
  95. package/dist/shards/activate.svelte.d.ts +30 -2
  96. package/dist/shards/activate.svelte.js +62 -17
  97. package/dist/shell-shard/Terminal.svelte +1 -4
  98. package/dist/shell-shard/verbs/index.js +2 -0
  99. package/dist/shell-shard/verbs/reset.d.ts +2 -0
  100. package/dist/shell-shard/verbs/reset.js +26 -0
  101. package/dist/tokens.css +32 -0
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/package.json +1 -1
@@ -12,6 +12,7 @@ import { __resetShardRegistryForTest } from '../shards/activate.svelte';
12
12
  import { __resetAppRegistryForTest } from '../apps/registry.svelte';
13
13
  import { __resetDispatcherStateForTest } from '../actions/state.svelte';
14
14
  import { __resetSelectionForTest } from '../actions/selection.svelte';
15
+ import { __resetBackStackForTest } from '../navigation/back-stack';
15
16
  /**
16
17
  * Return the framework to a deterministic boot state for tests.
17
18
  *
@@ -37,4 +38,5 @@ export function resetFramework() {
37
38
  __resetAppRegistryForTest();
38
39
  __resetDispatcherStateForTest();
39
40
  __resetSelectionForTest();
41
+ __resetBackStackForTest();
40
42
  }
@@ -16,7 +16,8 @@
16
16
  import type { PopupHandle } from '../overlays/types';
17
17
  import ActionPanel, { type ActionPanelSection } from './ActionPanel.svelte';
18
18
  import { listActions } from './registry';
19
- import { getLiveDispatcherState, type DispatcherState } from './state.svelte';
19
+ import { getLiveDispatcherState } from './state.svelte';
20
+ import type { DispatcherState } from './dispatcher.svelte';
20
21
  import { resolveSubmenuItems, type MenuBarItem } from './menuBarModel';
21
22
  import type { MenuContainer } from '../apps/types';
22
23
 
@@ -34,6 +34,14 @@ function matchesAnchor(action, ownerShardId, anchor, state) {
34
34
  if (anchor === 'app' || anchor === 'home') {
35
35
  return isScopeActive(anchor, state, ownerShardId);
36
36
  }
37
+ // Element anchors require their scope to be active — selection.type must
38
+ // match. Parallel to the resolveAnchor rule that walks past inactive
39
+ // element atoms in the DOM (ADR-021 amendment 2026-05-01). Protects the
40
+ // explicit-anchor path (`openContextMenu({ scope: { element } })`) from
41
+ // surfacing element actions when nothing is selected.
42
+ if (typeof anchor === 'object' && 'element' in anchor) {
43
+ return isScopeActive(anchor, state, ownerShardId);
44
+ }
37
45
  return true;
38
46
  }
39
47
  export function buildContextMenuModel(entries, state, anchor) {
@@ -31,14 +31,34 @@ describe('buildContextMenuModel', () => {
31
31
  const model = buildContextMenuModel(entries, state, 'focus:editor');
32
32
  expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['multi']);
33
33
  });
34
- it('matches element scopes by element-type equality', () => {
34
+ it('matches element scopes by element-type equality when the scope is active', () => {
35
+ const state = mkState({
36
+ selection: { type: 'cell', ref: {}, ownerShardId: 'shard.x' },
37
+ });
35
38
  const entries = [
36
39
  mkEntry({ id: 'cell', scope: { element: 'cell' }, contextItem: true, label: 'C' }),
37
40
  mkEntry({ id: 'row', scope: { element: 'row' }, contextItem: true, label: 'R' }),
38
41
  ];
39
- const model = buildContextMenuModel(entries, mkState(), { element: 'cell' });
42
+ const model = buildContextMenuModel(entries, state, { element: 'cell' });
40
43
  expect(model.tiers.flatMap((t) => t.items).map((i) => i.id)).toEqual(['cell']);
41
44
  });
45
+ it('rejects element anchors when no selection is set (scope is inactive)', () => {
46
+ const entries = [
47
+ mkEntry({ id: 'cell', scope: { element: 'cell' }, contextItem: true, label: 'C' }),
48
+ ];
49
+ const model = buildContextMenuModel(entries, mkState(), { element: 'cell' });
50
+ expect(model.tiers).toEqual([]);
51
+ });
52
+ it('rejects element anchors when selection.type does not match the anchor', () => {
53
+ const state = mkState({
54
+ selection: { type: 'row', ref: {}, ownerShardId: 'shard.x' },
55
+ });
56
+ const entries = [
57
+ mkEntry({ id: 'cell', scope: { element: 'cell' }, contextItem: true, label: 'C' }),
58
+ ];
59
+ const model = buildContextMenuModel(entries, state, { element: 'cell' });
60
+ expect(model.tiers).toEqual([]);
61
+ });
42
62
  it('app anchor honors the owner-shard guard', () => {
43
63
  const state = mkState({
44
64
  activeAppId: 'app.a',
@@ -13,6 +13,8 @@ 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';
17
+ import { isScopeActive } from './dispatcher.svelte';
16
18
  import { shell } from '../shellRuntime.svelte';
17
19
  let attached = false;
18
20
  function viewIdOfEl(el) {
@@ -23,10 +25,34 @@ function viewIdOfEl(el) {
23
25
  return (_a = host === null || host === void 0 ? void 0 : host.getAttribute('data-sh3-view')) !== null && _a !== void 0 ? _a : null;
24
26
  }
25
27
  function resolveAnchor(args) {
28
+ var _a, _b;
26
29
  if (args.explicit !== undefined)
27
30
  return args.explicit;
28
- if (args.event && args.event.target) {
29
- const viewId = viewIdOfEl(args.event.target);
31
+ const target = (_a = args.event) === null || _a === void 0 ? void 0 : _a.target;
32
+ if (target instanceof Element) {
33
+ // Preferred path: data-sh3-scope carries the literal AtomicScope encoding
34
+ // (see ADR-021 amendment 2026-05-01). Walk every scope-bearing ancestor
35
+ // so a sub-region can override its enclosing slot host's auto-stamped
36
+ // focus:<viewId>. Element atoms whose scope is not active (no matching
37
+ // selection) are walked past — without an active selection there is no
38
+ // element context, so we fall through to the next ancestor and
39
+ // ultimately the slot host's focus scope.
40
+ let scopeHost = target.closest('[data-sh3-scope]');
41
+ while (scopeHost) {
42
+ const parsed = parseScopeString((_b = scopeHost.getAttribute('data-sh3-scope')) !== null && _b !== void 0 ? _b : '');
43
+ if (parsed) {
44
+ const isInactiveElementAtom = typeof parsed === 'object' && 'element' in parsed && !isScopeActive(parsed, args.state);
45
+ if (!isInactiveElementAtom)
46
+ return parsed;
47
+ }
48
+ // Continue walking from above this scopeHost.
49
+ const parent = scopeHost.parentElement;
50
+ scopeHost = parent ? parent.closest('[data-sh3-scope]') : null;
51
+ }
52
+ // Fallback: data-sh3-view alone still maps to focus:<viewId>. Defensive
53
+ // for stub views and external callers that haven't adopted the new
54
+ // attribute; framework-stamped slot hosts now carry both.
55
+ const viewId = viewIdOfEl(target);
30
56
  if (viewId)
31
57
  return `focus:${viewId}`;
32
58
  }
@@ -3,6 +3,7 @@ import { attachGlobalListeners, detachGlobalListeners, openPalette, openContextM
3
3
  import { registerAction, __resetActionsRegistryForTest } from './registry';
4
4
  import { __resetContributionsForTest } from '../contributions/registry';
5
5
  import { __resetDispatcherStateForTest, setActiveApp, setMountedViewIds, setFocusedViewId, } from './state.svelte';
6
+ import { makeSelectionApi, __resetSelectionForTest } from './selection.svelte';
6
7
  import { registerLayerRoot, unregisterLayerRoot } from '../overlays/roots';
7
8
  import { __resetPopupManagerForTest } from '../overlays/popup';
8
9
  import { modalManager } from '../overlays/modal';
@@ -73,6 +74,7 @@ describe('global contextmenu listener', () => {
73
74
  __resetContributionsForTest();
74
75
  __resetActionsRegistryForTest();
75
76
  __resetDispatcherStateForTest();
77
+ __resetSelectionForTest();
76
78
  attachGlobalListeners();
77
79
  });
78
80
  afterEach(() => {
@@ -80,6 +82,7 @@ describe('global contextmenu listener', () => {
80
82
  __resetPopupManagerForTest();
81
83
  unregisterLayerRoot('popup');
82
84
  popupLayerRoot.remove();
85
+ __resetSelectionForTest();
83
86
  vi.unstubAllGlobals();
84
87
  });
85
88
  it('opens popup with home anchor when no view ancestor and no active app', () => {
@@ -149,7 +152,82 @@ describe('global contextmenu listener', () => {
149
152
  expect(labels).toEqual(['App']);
150
153
  target.remove();
151
154
  });
152
- it('openContextMenu({scope}) uses the explicit anchor', async () => {
155
+ it('right-click inside data-sh3-scope="element:..." anchors to that element atom when active', async () => {
156
+ setActiveApp('app.a', new Set(['shard.x']));
157
+ setMountedViewIds(new Set(['editor']));
158
+ // Activate the element scope by setting selection.type === 'svg-designer:layer'.
159
+ makeSelectionApi('shard.x').set({ type: 'svg-designer:layer', ref: {} });
160
+ registerAction({ id: 'el-only', label: 'El', scope: { element: 'svg-designer:layer' }, contextItem: true, run: () => { } }, 'shard.x');
161
+ registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
162
+ // Slot-host shape: framework auto-stamps both attributes. The inner div
163
+ // overrides scope only — viewId identity is unchanged. closest('[data-sh3-scope]')
164
+ // from the click target finds the inner div first.
165
+ const slotHost = document.createElement('div');
166
+ slotHost.setAttribute('data-sh3-view', 'editor');
167
+ slotHost.setAttribute('data-sh3-scope', 'focus:editor');
168
+ document.body.appendChild(slotHost);
169
+ const inner = document.createElement('div');
170
+ inner.setAttribute('data-sh3-scope', 'element:svg-designer:layer');
171
+ slotHost.appendChild(inner);
172
+ const target = document.createElement('button');
173
+ inner.appendChild(target);
174
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
175
+ Object.defineProperty(ev, 'target', { value: target });
176
+ target.dispatchEvent(ev);
177
+ await Promise.resolve();
178
+ const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
179
+ .map((n) => n.textContent);
180
+ expect(labels).toEqual(['El']);
181
+ slotHost.remove();
182
+ });
183
+ it('right-click inside data-sh3-scope="element:..." falls through to focus:<viewId> when the element scope is inactive', async () => {
184
+ setActiveApp('app.a', new Set(['shard.x']));
185
+ setMountedViewIds(new Set(['editor']));
186
+ // No selection set — element scope { element: 'svg-designer:layer' } is inactive.
187
+ registerAction({ id: 'el-only', label: 'El', scope: { element: 'svg-designer:layer' }, contextItem: true, run: () => { } }, 'shard.x');
188
+ registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
189
+ const slotHost = document.createElement('div');
190
+ slotHost.setAttribute('data-sh3-view', 'editor');
191
+ slotHost.setAttribute('data-sh3-scope', 'focus:editor');
192
+ document.body.appendChild(slotHost);
193
+ const inner = document.createElement('div');
194
+ inner.setAttribute('data-sh3-scope', 'element:svg-designer:layer');
195
+ slotHost.appendChild(inner);
196
+ const target = document.createElement('button');
197
+ inner.appendChild(target);
198
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
199
+ Object.defineProperty(ev, 'target', { value: target });
200
+ target.dispatchEvent(ev);
201
+ await Promise.resolve();
202
+ const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
203
+ .map((n) => n.textContent);
204
+ // Anchor walks past inactive `element:svg-designer:layer`, lands on
205
+ // the slot host's `focus:editor`. Element-only action is filtered out.
206
+ expect(labels).toEqual(['View']);
207
+ slotHost.remove();
208
+ });
209
+ it('right-click outside any data-sh3-scope override falls back to focus:<viewId>', async () => {
210
+ setActiveApp('app.a', new Set(['shard.x']));
211
+ setMountedViewIds(new Set(['editor']));
212
+ registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
213
+ registerAction({ id: 'el-only', label: 'El', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
214
+ const slotHost = document.createElement('div');
215
+ slotHost.setAttribute('data-sh3-view', 'editor');
216
+ slotHost.setAttribute('data-sh3-scope', 'focus:editor');
217
+ document.body.appendChild(slotHost);
218
+ const target = document.createElement('button');
219
+ slotHost.appendChild(target);
220
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
221
+ Object.defineProperty(ev, 'target', { value: target });
222
+ target.dispatchEvent(ev);
223
+ await Promise.resolve();
224
+ const labels = [...document.querySelectorAll('.sh3-context-menu [role="menuitem"] .sh3-ctx-label')]
225
+ .map((n) => n.textContent);
226
+ expect(labels).toEqual(['View']);
227
+ slotHost.remove();
228
+ });
229
+ it('openContextMenu({scope}) uses the explicit anchor when the scope is active', async () => {
230
+ makeSelectionApi('shard.x').set({ type: 'cell', ref: {} });
153
231
  registerAction({ id: 'cell.copy', label: 'Copy Cell', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
154
232
  registerAction({ id: 'home.x', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
155
233
  openContextMenu({ x: 10, y: 20, scope: { element: 'cell' } });
@@ -158,6 +236,14 @@ describe('global contextmenu listener', () => {
158
236
  .map((n) => n.textContent);
159
237
  expect(labels).toEqual(['Copy Cell']);
160
238
  });
239
+ it('openContextMenu({scope: { element }}) opens nothing when the element scope is inactive', async () => {
240
+ // No selection set — element scope is inactive even though the caller
241
+ // passed it explicitly. matchesAnchor's parallel rule rejects.
242
+ registerAction({ id: 'cell.copy', label: 'Copy Cell', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
243
+ openContextMenu({ x: 10, y: 20, scope: { element: 'cell' } });
244
+ await Promise.resolve();
245
+ expect(document.querySelector('.sh3-context-menu')).toBeNull();
246
+ });
161
247
  it('clicking a submenu-parent row opens children filtered by the same anchor', async () => {
162
248
  registerAction({ id: 'p', label: 'Open with…', scope: 'home', contextItem: true, submenu: true }, 'a');
163
249
  registerAction({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p', run: () => { } }, 'a');
@@ -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
@@ -16,6 +16,8 @@ export type { EnvState } from './env/types';
16
16
  export type { App, AppManifest, SourceApp, SourceAppManifest, AppContext, } from './apps/types';
17
17
  export { listRegisteredApps, getActiveApp } from './apps/registry.svelte';
18
18
  export { launchApp, returnToHome, unregisterApp } from './apps/lifecycle';
19
+ export { pushNavEntry } from './navigation';
20
+ export type { NavEntry, NavEntryHandle } from './navigation';
19
21
  export { inspectActiveLayout, spliceIntoActiveLayout, dockIntoActiveLayout, focusTab, focusView, collapseChild, expandChild, closeTab, popoutView, dockFloat, locateSlot, } from './layout/inspection';
20
22
  export type { DocumentHandle, DocumentHandleOptions, DocumentFormat, DocumentMeta, DocumentChange, AutosaveController, } from './documents/types';
21
23
  export { PERMISSION_DOCUMENTS_BROWSE, PERMISSION_DOCUMENTS_READ, PERMISSION_DOCUMENTS_WRITE, } from './documents/types';
@@ -27,7 +29,8 @@ export type { ConflictItem, ConflictBranch as ConflictManagerBranch, ResolveOpti
27
29
  export { CONFLICT_RENDERER_POINT, ConflictPermissionError, ConflictSessionOrphanedError, } from './conflicts/api';
28
30
  export type { ColorPickOptions, ColorContribution, ColorApi, } from './color/api';
29
31
  export { COLOR_PICKER_POINT } from './color/api';
30
- export { registeredShards, activeShards } from './shards/activate.svelte';
32
+ export { registeredShards, activeShards, erroredShards } from './shards/activate.svelte';
33
+ export type { ShardErrorEntry } from './shards/activate.svelte';
31
34
  export type { RegistryIndex, PackageEntry, PackageVersion, RequiredDependency, InstalledPackage, InstallResult, PackageMeta, } from './registry/types';
32
35
  export type { ResolvedPackage } from './registry/client';
33
36
  export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
@@ -52,3 +55,17 @@ export declare const FRAMEWORK_SHARD_IDS: readonly string[];
52
55
  export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
53
56
  export { default as Button } from './primitives/Button.svelte';
54
57
  export { provideIcons, getIconSprite, type ButtonVariant } from './primitives/icon-context';
58
+ export { default as Field } from './primitives/widgets/Field.svelte';
59
+ export { default as Textarea } from './primitives/widgets/Textarea.svelte';
60
+ export { default as NumberInput } from './primitives/widgets/NumberInput.svelte';
61
+ export { default as Segmented } from './primitives/widgets/Segmented.svelte';
62
+ export type { SegmentedOption } from './primitives/widgets/Segmented';
63
+ export { default as IconToggleGroup } from './primitives/widgets/IconToggleGroup.svelte';
64
+ export { default as Slider } from './primitives/widgets/Slider.svelte';
65
+ export { default as RangeSlider } from './primitives/widgets/RangeSlider.svelte';
66
+ export { default as SliderGroup } from './primitives/widgets/SliderGroup.svelte';
67
+ export { default as ColorSwatch } from './primitives/widgets/ColorSwatch.svelte';
68
+ export { default as FilePicker } from './primitives/widgets/FilePicker.svelte';
69
+ export type { FilePickerValue } from './primitives/widgets/FilePicker';
70
+ export { default as Select } from './primitives/widgets/Select.svelte';
71
+ export type { SelectOption } from './primitives/widgets/Select';
package/dist/api.js CHANGED
@@ -27,6 +27,8 @@ export { PERMISSION_STATE_MANAGE } from './state/types';
27
27
  // Host actions callable from inside views (shell home, status bar, etc.).
28
28
  export { listRegisteredApps, getActiveApp } from './apps/registry.svelte';
29
29
  export { launchApp, returnToHome, unregisterApp } from './apps/lifecycle';
30
+ // Navigation — apps push in-app nav entries; framework drives back/forward.
31
+ export { pushNavEntry } from './navigation';
30
32
  // Layout inspection / mutation for advanced shards (diagnostic, etc.).
31
33
  export { inspectActiveLayout, spliceIntoActiveLayout, dockIntoActiveLayout, focusTab, focusView, collapseChild, expandChild, closeTab, popoutView, dockFloat, locateSlot, } from './layout/inspection';
32
34
  export { PERMISSION_DOCUMENTS_BROWSE, PERMISSION_DOCUMENTS_READ, PERMISSION_DOCUMENTS_WRITE, } from './documents/types';
@@ -38,7 +40,7 @@ export { COLOR_PICKER_POINT } from './color/api';
38
40
  // and tooling shards that need to visualize framework state. Phase 9
39
41
  // addition: diagnostic used to reach `activate.svelte` directly via $lib;
40
42
  // the package boundary requires routing through the public surface.
41
- export { registeredShards, activeShards } from './shards/activate.svelte';
43
+ export { registeredShards, activeShards, erroredShards } from './shards/activate.svelte';
42
44
  export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
43
45
  export { validateRegistryIndex } from './registry/schema';
44
46
  // Key mint/revoke types — client shards that declare `keys:mint` get ctx.keys.
@@ -71,3 +73,15 @@ export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './th
71
73
  // `import { Button } from 'sh3-core'` against the runtime shim in loader.ts.
72
74
  export { default as Button } from './primitives/Button.svelte';
73
75
  export { provideIcons, getIconSprite } from './primitives/icon-context';
76
+ // Controllable widget primitives (ADR-022).
77
+ export { default as Field } from './primitives/widgets/Field.svelte';
78
+ export { default as Textarea } from './primitives/widgets/Textarea.svelte';
79
+ export { default as NumberInput } from './primitives/widgets/NumberInput.svelte';
80
+ export { default as Segmented } from './primitives/widgets/Segmented.svelte';
81
+ export { default as IconToggleGroup } from './primitives/widgets/IconToggleGroup.svelte';
82
+ export { default as Slider } from './primitives/widgets/Slider.svelte';
83
+ export { default as RangeSlider } from './primitives/widgets/RangeSlider.svelte';
84
+ export { default as SliderGroup } from './primitives/widgets/SliderGroup.svelte';
85
+ export { default as ColorSwatch } from './primitives/widgets/ColorSwatch.svelte';
86
+ export { default as FilePicker } from './primitives/widgets/FilePicker.svelte';
87
+ export { default as Select } from './primitives/widgets/Select.svelte';
@@ -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,8 @@ 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';
24
+ import { clearAppNavEntries } from '../navigation/back-stack';
23
25
  // ---------- last-active-app user zone ------------------------------------
24
26
  /**
25
27
  * Framework-reserved user-zone slot storing which app to boot into on
@@ -41,6 +43,15 @@ export function readLastApp() {
41
43
  function writeLastApp(id) {
42
44
  lastAppState.user.id = id;
43
45
  }
46
+ /**
47
+ * Public reset for the last-active-app user-zone slot. Used by the host's
48
+ * boot sequence to recover from a sticky last-app launch failure: if the
49
+ * auto-launch fails, this clears the slot so the next reload lands on home
50
+ * instead of looping into the same failure.
51
+ */
52
+ export function clearLastApp() {
53
+ writeLastApp(null);
54
+ }
44
55
  // ---------- app-context state factories ----------------------------------
45
56
  const appContexts = new Map();
46
57
  function getOrCreateAppContext(appId) {
@@ -72,7 +83,7 @@ function getOrCreateAppContext(appId) {
72
83
  * @throws If the app is not registered or a required shard is not registered.
73
84
  */
74
85
  export async function launchApp(id) {
75
- var _a, _b, _c, _d, _e, _f, _g;
86
+ var _a, _b, _c, _d, _e, _f, _g, _h;
76
87
  const app = getRegisteredApp(id);
77
88
  if (!app) {
78
89
  throw new Error(`Cannot launch app "${id}": not registered`);
@@ -118,23 +129,30 @@ export async function launchApp(id) {
118
129
  attachApp(app);
119
130
  try {
120
131
  for (const shardId of app.manifest.requiredShards) {
121
- await activateShard(shardId);
132
+ await activateShard(shardId, { phase: 'launch' });
122
133
  }
123
134
  }
124
135
  catch (err) {
125
136
  detachApp();
137
+ try {
138
+ 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 });
139
+ }
140
+ catch (_j) {
141
+ // Toast layer not mounted (e.g. early boot, tests without Shell).
142
+ // Best-effort UX — original error must still propagate.
143
+ }
126
144
  throw err;
127
145
  }
128
146
  // Shards have registered their view factories — safe to take the
129
147
  // refcount holds on the app's slots now (pool's factory lookup
130
148
  // happens in a microtask from this call).
131
149
  acquireAppSlotHolds();
132
- void ((_e = app.activate) === null || _e === void 0 ? void 0 : _e.call(app, getOrCreateAppContext(id)));
150
+ void ((_f = app.activate) === null || _f === void 0 ? void 0 : _f.call(app, getOrCreateAppContext(id)));
133
151
  activeApp.id = id;
134
- setActiveApp(id, new Set((_f = app.manifest.requiredShards) !== null && _f !== void 0 ? _f : []));
152
+ setActiveApp(id, new Set((_g = app.manifest.requiredShards) !== null && _g !== void 0 ? _g : []));
135
153
  void loadUserBindings(id).then(setUserBindings);
136
154
  switchToApp();
137
- void ((_g = app.onAppReady) === null || _g === void 0 ? void 0 : _g.call(app, getOrCreateAppContext(id)));
155
+ void ((_h = app.onAppReady) === null || _h === void 0 ? void 0 : _h.call(app, getOrCreateAppContext(id)));
138
156
  writeLastApp(id);
139
157
  breadcrumbApp.id = id;
140
158
  }
@@ -178,6 +196,7 @@ export function unloadApp(id) {
178
196
  activeApp.id = null;
179
197
  setActiveApp(null, new Set());
180
198
  clearSelectionUnconditional();
199
+ clearAppNavEntries();
181
200
  void loadUserBindings('sh3.home').then(setUserBindings);
182
201
  appContexts.delete(id);
183
202
  }
@@ -227,6 +246,7 @@ export async function returnToHome() {
227
246
  if (app.suspend && (await app.suspend()) === false)
228
247
  return false;
229
248
  }
249
+ clearAppNavEntries();
230
250
  switchToHome();
231
251
  // Mirror unregisterApp: clear the dispatcher's active-app pointer so
232
252
  // 'app'-scope actions become inactive on home. Without this, any action
@@ -517,3 +517,98 @@ 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
+ });
570
+ // ---------------------------------------------------------------------------
571
+ // Nav-entry clearing — returnToHome / unloadApp
572
+ // ---------------------------------------------------------------------------
573
+ describe('lifecycle — clears nav entries', () => {
574
+ beforeEach(resetFramework);
575
+ it('returnToHome clears app nav entries after suspend hooks pass', async () => {
576
+ const { pushNavEntry, dispatchBack } = await import('../navigation/back-stack');
577
+ const app = makeApp({ manifest: makeAppManifest({ id: 'nav-app-1' }) });
578
+ registerApp(app);
579
+ await launchApp('nav-app-1');
580
+ const onPop = vi.fn();
581
+ pushNavEntry({ onPop });
582
+ await returnToHome();
583
+ // After return-to-home, dispatching back must not fire the entry's onPop.
584
+ dispatchBack();
585
+ expect(onPop).not.toHaveBeenCalled();
586
+ });
587
+ it('returnToHome cancelled by suspend preserves nav entries', async () => {
588
+ const { pushNavEntry, dispatchBack } = await import('../navigation/back-stack');
589
+ const app = makeApp({
590
+ manifest: makeAppManifest({ id: 'nav-app-2' }),
591
+ suspend: () => false,
592
+ });
593
+ registerApp(app);
594
+ await launchApp('nav-app-2');
595
+ const onPop = vi.fn();
596
+ pushNavEntry({ onPop });
597
+ const result = await returnToHome();
598
+ expect(result).toBe(false);
599
+ // Entry survived the cancelled return.
600
+ dispatchBack();
601
+ expect(onPop).toHaveBeenCalledTimes(1);
602
+ });
603
+ it('unregisterApp force-close clears entries without firing onPop', async () => {
604
+ const { pushNavEntry, dispatchBack } = await import('../navigation/back-stack');
605
+ const app = makeApp({ manifest: makeAppManifest({ id: 'nav-app-3' }) });
606
+ registerApp(app);
607
+ await launchApp('nav-app-3');
608
+ const onPop = vi.fn();
609
+ pushNavEntry({ onPop });
610
+ unregisterApp('nav-app-3');
611
+ dispatchBack();
612
+ expect(onPop).not.toHaveBeenCalled();
613
+ });
614
+ });