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.
- package/dist/__test__/reset.js +2 -0
- package/dist/actions/MenuButton.svelte +2 -1
- package/dist/actions/contextMenuModel.js +8 -0
- package/dist/actions/contextMenuModel.test.js +22 -2
- package/dist/actions/listeners.js +28 -2
- package/dist/actions/listeners.test.js +87 -1
- package/dist/actions/scope-helpers.d.ts +17 -0
- package/dist/actions/scope-helpers.js +37 -0
- package/dist/actions/scope-helpers.test.js +33 -1
- package/dist/api.d.ts +18 -1
- package/dist/api.js +15 -1
- package/dist/app/store/InstalledView.svelte +2 -1
- package/dist/app/store/StoreView.svelte +2 -1
- package/dist/apps/lifecycle.d.ts +7 -0
- package/dist/apps/lifecycle.js +25 -5
- package/dist/apps/lifecycle.test.js +95 -0
- package/dist/host.js +30 -4
- package/dist/layout/LayoutRenderer.svelte +5 -1
- package/dist/layout/LayoutRenderer.test.js +42 -0
- package/dist/layout/SlotContainer.svelte +11 -2
- package/dist/layout/SlotContainer.svelte.d.ts +1 -0
- package/dist/layout/slotHostPool.svelte.js +10 -3
- package/dist/layout/slotHostPool.test.js +15 -0
- package/dist/navigation/back-stack.d.ts +29 -0
- package/dist/navigation/back-stack.js +87 -0
- package/dist/navigation/back-stack.test.d.ts +1 -0
- package/dist/navigation/back-stack.test.js +145 -0
- package/dist/navigation/index.d.ts +2 -0
- package/dist/navigation/index.js +6 -0
- package/dist/navigation/platform-web.d.ts +3 -0
- package/dist/navigation/platform-web.js +54 -0
- package/dist/navigation/platform-web.test.d.ts +1 -0
- package/dist/navigation/platform-web.test.js +96 -0
- package/dist/overlays/modal.js +7 -0
- package/dist/overlays/modal.test.js +35 -0
- package/dist/overlays/popup.js +7 -0
- package/dist/overlays/popup.test.js +33 -0
- package/dist/platform/index.d.ts +15 -0
- package/dist/platform/index.js +47 -0
- package/dist/primitives/base.css +17 -6
- package/dist/primitives/widgets/ColorSwatch.svelte +66 -0
- package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +9 -0
- package/dist/primitives/widgets/Field.svelte +124 -0
- package/dist/primitives/widgets/Field.svelte.d.ts +19 -0
- package/dist/primitives/widgets/FilePicker.d.ts +3 -0
- package/dist/primitives/widgets/FilePicker.js +19 -0
- package/dist/primitives/widgets/FilePicker.svelte +79 -0
- package/dist/primitives/widgets/FilePicker.svelte.d.ts +13 -0
- package/dist/primitives/widgets/FilePicker.test.d.ts +1 -0
- package/dist/primitives/widgets/FilePicker.test.js +44 -0
- package/dist/primitives/widgets/IconToggleGroup.d.ts +2 -0
- package/dist/primitives/widgets/IconToggleGroup.js +8 -0
- package/dist/primitives/widgets/IconToggleGroup.svelte +86 -0
- package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +16 -0
- package/dist/primitives/widgets/IconToggleGroup.test.d.ts +1 -0
- package/dist/primitives/widgets/IconToggleGroup.test.js +19 -0
- package/dist/primitives/widgets/NumberInput.d.ts +6 -0
- package/dist/primitives/widgets/NumberInput.js +19 -0
- package/dist/primitives/widgets/NumberInput.svelte +167 -0
- package/dist/primitives/widgets/NumberInput.svelte.d.ts +17 -0
- package/dist/primitives/widgets/NumberInput.test.d.ts +1 -0
- package/dist/primitives/widgets/NumberInput.test.js +28 -0
- package/dist/primitives/widgets/RangeSlider.d.ts +2 -0
- package/dist/primitives/widgets/RangeSlider.js +7 -0
- package/dist/primitives/widgets/RangeSlider.svelte +124 -0
- package/dist/primitives/widgets/RangeSlider.svelte.d.ts +13 -0
- package/dist/primitives/widgets/RangeSlider.test.d.ts +1 -0
- package/dist/primitives/widgets/RangeSlider.test.js +14 -0
- package/dist/primitives/widgets/Segmented.d.ts +9 -0
- package/dist/primitives/widgets/Segmented.js +28 -0
- package/dist/primitives/widgets/Segmented.svelte +82 -0
- package/dist/primitives/widgets/Segmented.svelte.d.ts +10 -0
- package/dist/primitives/widgets/Segmented.test.d.ts +1 -0
- package/dist/primitives/widgets/Segmented.test.js +24 -0
- package/dist/primitives/widgets/Select.d.ts +11 -0
- package/dist/primitives/widgets/Select.js +42 -0
- package/dist/primitives/widgets/Select.svelte +163 -0
- package/dist/primitives/widgets/Select.svelte.d.ts +14 -0
- package/dist/primitives/widgets/Select.test.d.ts +1 -0
- package/dist/primitives/widgets/Select.test.js +68 -0
- package/dist/primitives/widgets/Slider.d.ts +6 -0
- package/dist/primitives/widgets/Slider.js +19 -0
- package/dist/primitives/widgets/Slider.svelte +205 -0
- package/dist/primitives/widgets/Slider.svelte.d.ts +15 -0
- package/dist/primitives/widgets/Slider.test.d.ts +1 -0
- package/dist/primitives/widgets/Slider.test.js +31 -0
- package/dist/primitives/widgets/SliderGroup.svelte +58 -0
- package/dist/primitives/widgets/SliderGroup.svelte.d.ts +18 -0
- package/dist/primitives/widgets/Textarea.svelte +81 -0
- package/dist/primitives/widgets/Textarea.svelte.d.ts +16 -0
- package/dist/primitives/widgets/_select-listbox.svelte +228 -0
- package/dist/primitives/widgets/_select-listbox.svelte.d.ts +18 -0
- package/dist/shards/activate-error-isolation.test.d.ts +1 -0
- package/dist/shards/activate-error-isolation.test.js +98 -0
- package/dist/shards/activate.svelte.d.ts +30 -2
- package/dist/shards/activate.svelte.js +62 -17
- package/dist/shell-shard/Terminal.svelte +1 -4
- package/dist/shell-shard/verbs/index.js +2 -0
- package/dist/shell-shard/verbs/reset.d.ts +2 -0
- package/dist/shell-shard/verbs/reset.js +26 -0
- package/dist/tokens.css +32 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/__test__/reset.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
29
|
-
|
|
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('
|
|
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, #
|
|
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, #
|
|
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) {
|
package/dist/apps/lifecycle.d.ts
CHANGED
|
@@ -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`,
|
package/dist/apps/lifecycle.js
CHANGED
|
@@ -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 ((
|
|
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((
|
|
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 ((
|
|
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
|
+
});
|