sh3-core 0.12.0 → 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 (88) 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 +17 -6
  6. package/dist/actions/listeners.test.js +42 -2
  7. package/dist/api.d.ts +16 -0
  8. package/dist/api.js +14 -0
  9. package/dist/apps/lifecycle.js +3 -0
  10. package/dist/apps/lifecycle.test.js +45 -0
  11. package/dist/host.js +12 -0
  12. package/dist/navigation/back-stack.d.ts +29 -0
  13. package/dist/navigation/back-stack.js +87 -0
  14. package/dist/navigation/back-stack.test.d.ts +1 -0
  15. package/dist/navigation/back-stack.test.js +145 -0
  16. package/dist/navigation/index.d.ts +2 -0
  17. package/dist/navigation/index.js +6 -0
  18. package/dist/navigation/platform-web.d.ts +3 -0
  19. package/dist/navigation/platform-web.js +54 -0
  20. package/dist/navigation/platform-web.test.d.ts +1 -0
  21. package/dist/navigation/platform-web.test.js +96 -0
  22. package/dist/overlays/modal.js +7 -0
  23. package/dist/overlays/modal.test.js +35 -0
  24. package/dist/overlays/popup.js +7 -0
  25. package/dist/overlays/popup.test.js +33 -0
  26. package/dist/platform/index.d.ts +15 -0
  27. package/dist/platform/index.js +47 -0
  28. package/dist/primitives/base.css +17 -6
  29. package/dist/primitives/widgets/ColorSwatch.svelte +66 -0
  30. package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +9 -0
  31. package/dist/primitives/widgets/Field.svelte +124 -0
  32. package/dist/primitives/widgets/Field.svelte.d.ts +19 -0
  33. package/dist/primitives/widgets/FilePicker.d.ts +3 -0
  34. package/dist/primitives/widgets/FilePicker.js +19 -0
  35. package/dist/primitives/widgets/FilePicker.svelte +79 -0
  36. package/dist/primitives/widgets/FilePicker.svelte.d.ts +13 -0
  37. package/dist/primitives/widgets/FilePicker.test.d.ts +1 -0
  38. package/dist/primitives/widgets/FilePicker.test.js +44 -0
  39. package/dist/primitives/widgets/IconToggleGroup.d.ts +2 -0
  40. package/dist/primitives/widgets/IconToggleGroup.js +8 -0
  41. package/dist/primitives/widgets/IconToggleGroup.svelte +86 -0
  42. package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +16 -0
  43. package/dist/primitives/widgets/IconToggleGroup.test.d.ts +1 -0
  44. package/dist/primitives/widgets/IconToggleGroup.test.js +19 -0
  45. package/dist/primitives/widgets/NumberInput.d.ts +6 -0
  46. package/dist/primitives/widgets/NumberInput.js +19 -0
  47. package/dist/primitives/widgets/NumberInput.svelte +167 -0
  48. package/dist/primitives/widgets/NumberInput.svelte.d.ts +17 -0
  49. package/dist/primitives/widgets/NumberInput.test.d.ts +1 -0
  50. package/dist/primitives/widgets/NumberInput.test.js +28 -0
  51. package/dist/primitives/widgets/RangeSlider.d.ts +2 -0
  52. package/dist/primitives/widgets/RangeSlider.js +7 -0
  53. package/dist/primitives/widgets/RangeSlider.svelte +124 -0
  54. package/dist/primitives/widgets/RangeSlider.svelte.d.ts +13 -0
  55. package/dist/primitives/widgets/RangeSlider.test.d.ts +1 -0
  56. package/dist/primitives/widgets/RangeSlider.test.js +14 -0
  57. package/dist/primitives/widgets/Segmented.d.ts +9 -0
  58. package/dist/primitives/widgets/Segmented.js +28 -0
  59. package/dist/primitives/widgets/Segmented.svelte +82 -0
  60. package/dist/primitives/widgets/Segmented.svelte.d.ts +10 -0
  61. package/dist/primitives/widgets/Segmented.test.d.ts +1 -0
  62. package/dist/primitives/widgets/Segmented.test.js +24 -0
  63. package/dist/primitives/widgets/Select.d.ts +11 -0
  64. package/dist/primitives/widgets/Select.js +42 -0
  65. package/dist/primitives/widgets/Select.svelte +163 -0
  66. package/dist/primitives/widgets/Select.svelte.d.ts +14 -0
  67. package/dist/primitives/widgets/Select.test.d.ts +1 -0
  68. package/dist/primitives/widgets/Select.test.js +68 -0
  69. package/dist/primitives/widgets/Slider.d.ts +6 -0
  70. package/dist/primitives/widgets/Slider.js +19 -0
  71. package/dist/primitives/widgets/Slider.svelte +205 -0
  72. package/dist/primitives/widgets/Slider.svelte.d.ts +15 -0
  73. package/dist/primitives/widgets/Slider.test.d.ts +1 -0
  74. package/dist/primitives/widgets/Slider.test.js +31 -0
  75. package/dist/primitives/widgets/SliderGroup.svelte +58 -0
  76. package/dist/primitives/widgets/SliderGroup.svelte.d.ts +18 -0
  77. package/dist/primitives/widgets/Textarea.svelte +81 -0
  78. package/dist/primitives/widgets/Textarea.svelte.d.ts +16 -0
  79. package/dist/primitives/widgets/_select-listbox.svelte +228 -0
  80. package/dist/primitives/widgets/_select-listbox.svelte.d.ts +18 -0
  81. package/dist/shell-shard/Terminal.svelte +1 -4
  82. package/dist/shell-shard/verbs/index.js +2 -0
  83. package/dist/shell-shard/verbs/reset.d.ts +2 -0
  84. package/dist/shell-shard/verbs/reset.js +26 -0
  85. package/dist/tokens.css +32 -0
  86. package/dist/version.d.ts +1 -1
  87. package/dist/version.js +1 -1
  88. 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',
@@ -14,6 +14,7 @@ import ActionPanel from './ActionPanel.svelte';
14
14
  import CommandPalette from './CommandPalette.svelte';
15
15
  import { buildPaletteCandidates } from './paletteModel';
16
16
  import { parseScopeString } from './scope-helpers';
17
+ import { isScopeActive } from './dispatcher.svelte';
17
18
  import { shell } from '../shellRuntime.svelte';
18
19
  let attached = false;
19
20
  function viewIdOfEl(el) {
@@ -30,13 +31,23 @@ function resolveAnchor(args) {
30
31
  const target = (_a = args.event) === null || _a === void 0 ? void 0 : _a.target;
31
32
  if (target instanceof Element) {
32
33
  // 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) {
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) {
37
42
  const parsed = parseScopeString((_b = scopeHost.getAttribute('data-sh3-scope')) !== null && _b !== void 0 ? _b : '');
38
- if (parsed)
39
- return parsed;
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;
40
51
  }
41
52
  // Fallback: data-sh3-view alone still maps to focus:<viewId>. Defensive
42
53
  // for stub views and external callers that haven't adopted the new
@@ -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,9 +152,11 @@ describe('global contextmenu listener', () => {
149
152
  expect(labels).toEqual(['App']);
150
153
  target.remove();
151
154
  });
152
- it('right-click inside data-sh3-scope="element:..." anchors to that element atom', async () => {
155
+ it('right-click inside data-sh3-scope="element:..." anchors to that element atom when active', async () => {
153
156
  setActiveApp('app.a', new Set(['shard.x']));
154
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: {} });
155
160
  registerAction({ id: 'el-only', label: 'El', scope: { element: 'svg-designer:layer' }, contextItem: true, run: () => { } }, 'shard.x');
156
161
  registerAction({ id: 'view-only', label: 'View', scope: 'focus:editor', contextItem: true, run: () => { } }, 'shard.x');
157
162
  // Slot-host shape: framework auto-stamps both attributes. The inner div
@@ -175,6 +180,32 @@ describe('global contextmenu listener', () => {
175
180
  expect(labels).toEqual(['El']);
176
181
  slotHost.remove();
177
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
+ });
178
209
  it('right-click outside any data-sh3-scope override falls back to focus:<viewId>', async () => {
179
210
  setActiveApp('app.a', new Set(['shard.x']));
180
211
  setMountedViewIds(new Set(['editor']));
@@ -195,7 +226,8 @@ describe('global contextmenu listener', () => {
195
226
  expect(labels).toEqual(['View']);
196
227
  slotHost.remove();
197
228
  });
198
- it('openContextMenu({scope}) uses the explicit anchor', async () => {
229
+ it('openContextMenu({scope}) uses the explicit anchor when the scope is active', async () => {
230
+ makeSelectionApi('shard.x').set({ type: 'cell', ref: {} });
199
231
  registerAction({ id: 'cell.copy', label: 'Copy Cell', scope: { element: 'cell' }, contextItem: true, run: () => { } }, 'shard.x');
200
232
  registerAction({ id: 'home.x', label: 'Home', scope: 'home', contextItem: true, run: () => { } }, 'shard.x');
201
233
  openContextMenu({ x: 10, y: 20, scope: { element: 'cell' } });
@@ -204,6 +236,14 @@ describe('global contextmenu listener', () => {
204
236
  .map((n) => n.textContent);
205
237
  expect(labels).toEqual(['Copy Cell']);
206
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
+ });
207
247
  it('clicking a submenu-parent row opens children filtered by the same anchor', async () => {
208
248
  registerAction({ id: 'p', label: 'Open with…', scope: 'home', contextItem: true, submenu: true }, 'a');
209
249
  registerAction({ id: 'p.a', label: 'A', scope: 'home', submenuOf: 'p', run: () => { } }, 'a');
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';
@@ -53,3 +55,17 @@ export declare const FRAMEWORK_SHARD_IDS: readonly string[];
53
55
  export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
54
56
  export { default as Button } from './primitives/Button.svelte';
55
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';
@@ -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';
@@ -21,6 +21,7 @@ import { setActiveApp, setUserBindings } from '../actions/state.svelte';
21
21
  import { clearSelectionUnconditional } from '../actions/selection.svelte';
22
22
  import { loadUserBindings } from '../actions/bindings-store';
23
23
  import { toastManager } from '../overlays/toast';
24
+ import { clearAppNavEntries } from '../navigation/back-stack';
24
25
  // ---------- last-active-app user zone ------------------------------------
25
26
  /**
26
27
  * Framework-reserved user-zone slot storing which app to boot into on
@@ -195,6 +196,7 @@ export function unloadApp(id) {
195
196
  activeApp.id = null;
196
197
  setActiveApp(null, new Set());
197
198
  clearSelectionUnconditional();
199
+ clearAppNavEntries();
198
200
  void loadUserBindings('sh3.home').then(setUserBindings);
199
201
  appContexts.delete(id);
200
202
  }
@@ -244,6 +246,7 @@ export async function returnToHome() {
244
246
  if (app.suspend && (await app.suspend()) === false)
245
247
  return false;
246
248
  }
249
+ clearAppNavEntries();
247
250
  switchToHome();
248
251
  // Mirror unregisterApp: clear the dispatcher's active-app pointer so
249
252
  // 'app'-scope actions become inactive on home. Without this, any action
@@ -567,3 +567,48 @@ describe('clearLastApp', () => {
567
567
  expect(readLastApp()).toBeNull();
568
568
  });
569
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
+ });
package/dist/host.js CHANGED
@@ -29,6 +29,9 @@ import { storeApp } from './app/store/storeApp';
29
29
  import { adminShard } from './app/admin/adminShard.svelte';
30
30
  import { adminApp } from './app/admin/adminApp';
31
31
  import { runShellRenameMigration, } from './migrations/shell-rename';
32
+ import { setLifecycleHandlers } from './navigation/back-stack';
33
+ import { installWebEmitter } from './navigation/platform-web';
34
+ import { returnToHome } from './apps/lifecycle';
32
35
  export { __setBackend };
33
36
  export { setLocalOwner };
34
37
  export { __setTenantId, __setDocumentBackend } from './documents/config';
@@ -99,5 +102,14 @@ export async function bootstrap(config) {
99
102
  clearLastApp();
100
103
  }
101
104
  }
105
+ // 6. Wire navigation lifecycle handlers and install the web back/forward
106
+ // emitter. Order: after autostart shards and the optional last-app
107
+ // launch, so the emitter's synthetic history entries don't interleave
108
+ // with boot-time launches. The window/history guard makes bootstrap
109
+ // safe to call from non-DOM environments (Node tests, future SSR).
110
+ setLifecycleHandlers({ returnToHome, launchApp });
111
+ if (typeof window !== 'undefined' && typeof history !== 'undefined') {
112
+ installWebEmitter();
113
+ }
102
114
  }
103
115
  export { installPackage, listInstalledPackages } from './registry/installer';
@@ -0,0 +1,29 @@
1
+ export interface NavEntry {
2
+ /** Optional label. Reserved for future breadcrumb extension; not surfaced in v1. */
3
+ label?: string;
4
+ /** Called when this entry is popped via back. Synchronous; not cancellable in v1. */
5
+ onPop: () => void;
6
+ }
7
+ export interface NavEntryHandle {
8
+ /** Pop this entry without firing onPop. Idempotent; no-op if already popped. */
9
+ remove(): void;
10
+ }
11
+ export interface DismissableRegistration {
12
+ /** Remove the dismissable from the cascade. Idempotent. */
13
+ unregister(): void;
14
+ }
15
+ interface LifecycleHandlers {
16
+ returnToHome: () => void | Promise<unknown>;
17
+ launchApp: (id: string) => void | Promise<unknown>;
18
+ }
19
+ export declare function setLifecycleHandlers(handlers: LifecycleHandlers): void;
20
+ export declare function pushNavEntry(entry: NavEntry): NavEntryHandle;
21
+ export declare function registerDismissable(dismiss: () => void): DismissableRegistration;
22
+ export declare function clearAppNavEntries(): void;
23
+ export declare function dispatchBack(): void;
24
+ export declare function dispatchForward(): void;
25
+ /** @internal — test reset helper used by the central resetFramework. */
26
+ export declare function __resetBackStackForTest(): void;
27
+ /** @internal — test injection helper. */
28
+ export declare function __setLifecycleHandlersForTest(handlers: LifecycleHandlers): void;
29
+ export {};
@@ -0,0 +1,87 @@
1
+ /*
2
+ * Navigation back-cascade — global LIFO stacks for dismissable overlays
3
+ * and app-internal nav entries. Drives the response to back/forward
4
+ * signals (delivered by platform emitters in this same module folder).
5
+ *
6
+ * Cascade on back:
7
+ * 1. Top-most dismissable overlay (modal/popup) → close it.
8
+ * 2. Top-most app nav entry → fire its onPop.
9
+ * 3. Active app exists → returnToHome (cancellable suspend hooks).
10
+ * 4. Otherwise → no-op.
11
+ *
12
+ * Forward is asymmetric: only acts on home and only relaunches the
13
+ * breadcrumb app (matches the existing BrandSlot click semantics).
14
+ *
15
+ * Lifecycle handlers (returnToHome, launchApp) are injected during host
16
+ * bootstrap rather than imported directly. This avoids module-eval cycles
17
+ * with apps/lifecycle and keeps this module unit-testable in isolation.
18
+ */
19
+ import { activeApp, breadcrumbApp } from '../apps/registry.svelte';
20
+ const dismissables = [];
21
+ const appNavEntries = [];
22
+ let lifecycleHandlers = null;
23
+ export function setLifecycleHandlers(handlers) {
24
+ lifecycleHandlers = handlers;
25
+ }
26
+ export function pushNavEntry(entry) {
27
+ if (!activeApp.id) {
28
+ throw new Error('pushNavEntry requires an active app');
29
+ }
30
+ const id = Symbol('nav-entry');
31
+ appNavEntries.push({ id, label: entry.label, onPop: entry.onPop });
32
+ return {
33
+ remove() {
34
+ const idx = appNavEntries.findIndex((e) => e.id === id);
35
+ if (idx >= 0)
36
+ appNavEntries.splice(idx, 1);
37
+ },
38
+ };
39
+ }
40
+ export function registerDismissable(dismiss) {
41
+ const id = Symbol('dismissable');
42
+ dismissables.push({ id, dismiss });
43
+ return {
44
+ unregister() {
45
+ const idx = dismissables.findIndex((d) => d.id === id);
46
+ if (idx >= 0)
47
+ dismissables.splice(idx, 1);
48
+ },
49
+ };
50
+ }
51
+ export function clearAppNavEntries() {
52
+ appNavEntries.length = 0;
53
+ }
54
+ export function dispatchBack() {
55
+ if (dismissables.length > 0) {
56
+ const top = dismissables.pop();
57
+ top.dismiss();
58
+ return;
59
+ }
60
+ if (appNavEntries.length > 0) {
61
+ const top = appNavEntries.pop();
62
+ top.onPop();
63
+ return;
64
+ }
65
+ if (activeApp.id && lifecycleHandlers) {
66
+ void lifecycleHandlers.returnToHome();
67
+ }
68
+ }
69
+ export function dispatchForward() {
70
+ if (activeApp.id)
71
+ return;
72
+ if (!breadcrumbApp.id)
73
+ return;
74
+ if (!lifecycleHandlers)
75
+ return;
76
+ void lifecycleHandlers.launchApp(breadcrumbApp.id);
77
+ }
78
+ /** @internal — test reset helper used by the central resetFramework. */
79
+ export function __resetBackStackForTest() {
80
+ dismissables.length = 0;
81
+ appNavEntries.length = 0;
82
+ lifecycleHandlers = null;
83
+ }
84
+ /** @internal — test injection helper. */
85
+ export function __setLifecycleHandlersForTest(handlers) {
86
+ lifecycleHandlers = handlers;
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { pushNavEntry, dispatchBack, dispatchForward, registerDismissable, clearAppNavEntries, __resetBackStackForTest, __setLifecycleHandlersForTest, } from './back-stack';
3
+ import { activeApp, breadcrumbApp } from '../apps/registry.svelte';
4
+ beforeEach(() => {
5
+ __resetBackStackForTest();
6
+ activeApp.id = null;
7
+ breadcrumbApp.id = null;
8
+ });
9
+ describe('back-stack — empty state', () => {
10
+ it('dispatchBack with empty stacks and no active app is a no-op', () => {
11
+ const returnToHome = vi.fn();
12
+ const launchApp = vi.fn();
13
+ __setLifecycleHandlersForTest({ returnToHome, launchApp });
14
+ dispatchBack();
15
+ expect(returnToHome).not.toHaveBeenCalled();
16
+ expect(launchApp).not.toHaveBeenCalled();
17
+ });
18
+ it('dispatchBack with empty stacks and active app calls returnToHome', () => {
19
+ activeApp.id = 'some-app';
20
+ const returnToHome = vi.fn();
21
+ __setLifecycleHandlersForTest({ returnToHome, launchApp: vi.fn() });
22
+ dispatchBack();
23
+ expect(returnToHome).toHaveBeenCalledTimes(1);
24
+ });
25
+ });
26
+ describe('back-stack — app nav entries', () => {
27
+ it('pushNavEntry throws when no active app', () => {
28
+ expect(() => pushNavEntry({ onPop: () => { } })).toThrow(/active app/i);
29
+ });
30
+ it('dispatchBack pops the top entry and fires onPop', () => {
31
+ activeApp.id = 'app';
32
+ const popA = vi.fn();
33
+ const popB = vi.fn();
34
+ pushNavEntry({ onPop: popA });
35
+ pushNavEntry({ onPop: popB });
36
+ dispatchBack();
37
+ expect(popB).toHaveBeenCalledTimes(1);
38
+ expect(popA).not.toHaveBeenCalled();
39
+ });
40
+ it('dispatchBack with two entries fires popA on the second back', () => {
41
+ activeApp.id = 'app';
42
+ const popA = vi.fn();
43
+ const popB = vi.fn();
44
+ pushNavEntry({ onPop: popA });
45
+ pushNavEntry({ onPop: popB });
46
+ dispatchBack();
47
+ dispatchBack();
48
+ expect(popA).toHaveBeenCalledTimes(1);
49
+ expect(popB).toHaveBeenCalledTimes(1);
50
+ });
51
+ it('handle.remove() removes the entry without firing onPop', () => {
52
+ activeApp.id = 'app';
53
+ const onPop = vi.fn();
54
+ const handle = pushNavEntry({ onPop });
55
+ handle.remove();
56
+ dispatchBack();
57
+ expect(onPop).not.toHaveBeenCalled();
58
+ });
59
+ it('handle.remove() is idempotent', () => {
60
+ activeApp.id = 'app';
61
+ const handle = pushNavEntry({ onPop: () => { } });
62
+ handle.remove();
63
+ expect(() => handle.remove()).not.toThrow();
64
+ });
65
+ it('clearAppNavEntries drops all entries without firing onPop', () => {
66
+ activeApp.id = 'app';
67
+ const popA = vi.fn();
68
+ const popB = vi.fn();
69
+ pushNavEntry({ onPop: popA });
70
+ pushNavEntry({ onPop: popB });
71
+ clearAppNavEntries();
72
+ dispatchBack();
73
+ expect(popA).not.toHaveBeenCalled();
74
+ expect(popB).not.toHaveBeenCalled();
75
+ });
76
+ });
77
+ describe('back-stack — dismissables', () => {
78
+ it('dispatchBack runs the most recent dismissable', () => {
79
+ const dismissA = vi.fn();
80
+ const dismissB = vi.fn();
81
+ registerDismissable(dismissA);
82
+ registerDismissable(dismissB);
83
+ dispatchBack();
84
+ expect(dismissB).toHaveBeenCalledTimes(1);
85
+ expect(dismissA).not.toHaveBeenCalled();
86
+ });
87
+ it('dismissable goes before app nav entries', () => {
88
+ activeApp.id = 'app';
89
+ const onPop = vi.fn();
90
+ const dismiss = vi.fn();
91
+ pushNavEntry({ onPop });
92
+ registerDismissable(dismiss);
93
+ dispatchBack();
94
+ expect(dismiss).toHaveBeenCalledTimes(1);
95
+ expect(onPop).not.toHaveBeenCalled();
96
+ });
97
+ it('dismissable goes before app→home', () => {
98
+ activeApp.id = 'app';
99
+ const dismiss = vi.fn();
100
+ const returnToHome = vi.fn();
101
+ __setLifecycleHandlersForTest({ returnToHome, launchApp: vi.fn() });
102
+ registerDismissable(dismiss);
103
+ dispatchBack();
104
+ expect(dismiss).toHaveBeenCalledTimes(1);
105
+ expect(returnToHome).not.toHaveBeenCalled();
106
+ });
107
+ it('unregister removes the dismissable from the cascade', () => {
108
+ const dismiss = vi.fn();
109
+ const reg = registerDismissable(dismiss);
110
+ reg.unregister();
111
+ dispatchBack();
112
+ expect(dismiss).not.toHaveBeenCalled();
113
+ });
114
+ it('unregister is idempotent', () => {
115
+ const reg = registerDismissable(() => { });
116
+ reg.unregister();
117
+ expect(() => reg.unregister()).not.toThrow();
118
+ });
119
+ });
120
+ describe('back-stack — forward', () => {
121
+ it('dispatchForward on home with breadcrumb relaunches the app', () => {
122
+ activeApp.id = null;
123
+ breadcrumbApp.id = 'last-app';
124
+ const launchApp = vi.fn();
125
+ __setLifecycleHandlersForTest({ returnToHome: vi.fn(), launchApp });
126
+ dispatchForward();
127
+ expect(launchApp).toHaveBeenCalledWith('last-app');
128
+ });
129
+ it('dispatchForward on home without breadcrumb is a no-op', () => {
130
+ activeApp.id = null;
131
+ breadcrumbApp.id = null;
132
+ const launchApp = vi.fn();
133
+ __setLifecycleHandlersForTest({ returnToHome: vi.fn(), launchApp });
134
+ dispatchForward();
135
+ expect(launchApp).not.toHaveBeenCalled();
136
+ });
137
+ it('dispatchForward in-app is a no-op even with breadcrumb', () => {
138
+ activeApp.id = 'current';
139
+ breadcrumbApp.id = 'last-app';
140
+ const launchApp = vi.fn();
141
+ __setLifecycleHandlersForTest({ returnToHome: vi.fn(), launchApp });
142
+ dispatchForward();
143
+ expect(launchApp).not.toHaveBeenCalled();
144
+ });
145
+ });
@@ -0,0 +1,2 @@
1
+ export { pushNavEntry } from './back-stack';
2
+ export type { NavEntry, NavEntryHandle } from './back-stack';
@@ -0,0 +1,6 @@
1
+ /*
2
+ * Navigation public surface — re-exports the api consumers (apps) need.
3
+ * Internal pieces (dispatchBack, dispatchForward, registerDismissable,
4
+ * lifecycle handler wiring, platform emitters) stay private to this folder.
5
+ */
6
+ export { pushNavEntry } from './back-stack';
@@ -0,0 +1,3 @@
1
+ export declare function installWebEmitter(): void;
2
+ /** @internal — test cleanup. Removes the listener; does not unwind history. */
3
+ export declare function __uninstallWebEmitterForTest(): void;