sh3-core 0.11.4 → 0.11.6

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 (47) hide show
  1. package/dist/BrandSlot.svelte +80 -0
  2. package/dist/BrandSlot.svelte.d.ts +3 -0
  3. package/dist/BrandSlot.test.d.ts +1 -0
  4. package/dist/BrandSlot.test.js +71 -0
  5. package/dist/Shell.svelte +8 -10
  6. package/dist/actions/ActionPanel.svelte +105 -0
  7. package/dist/actions/ActionPanel.svelte.d.ts +13 -0
  8. package/dist/actions/ActionPanel.test.d.ts +1 -0
  9. package/dist/actions/ActionPanel.test.js +80 -0
  10. package/dist/actions/ContextMenu.svelte +17 -85
  11. package/dist/actions/MenuBar.svelte +57 -0
  12. package/dist/actions/MenuBar.svelte.d.ts +3 -0
  13. package/dist/actions/MenuBar.test.d.ts +1 -0
  14. package/dist/actions/MenuBar.test.js +109 -0
  15. package/dist/actions/MenuButton.svelte +104 -0
  16. package/dist/actions/MenuButton.svelte.d.ts +9 -0
  17. package/dist/actions/MenuButton.test.d.ts +1 -0
  18. package/dist/actions/MenuButton.test.js +88 -0
  19. package/dist/actions/defaultMenuContainers.d.ts +2 -0
  20. package/dist/actions/defaultMenuContainers.js +7 -0
  21. package/dist/actions/defaultMenuContainers.test.d.ts +1 -0
  22. package/dist/actions/defaultMenuContainers.test.js +23 -0
  23. package/dist/actions/menuBarModel.d.ts +28 -0
  24. package/dist/actions/menuBarModel.js +67 -0
  25. package/dist/actions/menuBarModel.test.d.ts +1 -0
  26. package/dist/actions/menuBarModel.test.js +84 -0
  27. package/dist/actions/types.d.ts +8 -0
  28. package/dist/apps/lifecycle.js +8 -1
  29. package/dist/apps/lifecycle.test.js +211 -1
  30. package/dist/apps/registry.svelte.d.ts +17 -1
  31. package/dist/apps/registry.svelte.js +20 -1
  32. package/dist/apps/types.d.ts +28 -0
  33. package/dist/layout/store.svelte.d.ts +27 -0
  34. package/dist/layout/store.svelte.js +63 -0
  35. package/dist/overlays/ConfirmDialog.svelte +138 -0
  36. package/dist/overlays/ConfirmDialog.svelte.d.ts +13 -0
  37. package/dist/overlays/ConfirmDialog.test.d.ts +1 -0
  38. package/dist/overlays/ConfirmDialog.test.js +123 -0
  39. package/dist/overlays/FloatFrame.svelte +2 -2
  40. package/dist/overlays/ToastItem.svelte +3 -3
  41. package/dist/primitives/base.css +5 -5
  42. package/dist/sh3core-shard/sh3coreShard.svelte.js +20 -0
  43. package/dist/shell-shard/shellShard.svelte.js +0 -4
  44. package/dist/tokens.css +1 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +1 -1
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mount, unmount, tick } from 'svelte';
3
+ import MenuBar from './MenuBar.svelte';
4
+ import { registerLayerRoot, unregisterLayerRoot } from '../overlays/roots';
5
+ import { __resetPopupManagerForTest } from '../overlays/popup';
6
+ import { setActiveApp, __resetDispatcherStateForTest, } from './state.svelte';
7
+ import { registerAction, __resetActionsRegistryForTest, } from './registry';
8
+ import { __resetContributionsForTest } from '../contributions/registry';
9
+ import { registerApp, __resetAppRegistryForTest, } from '../apps/registry.svelte';
10
+ let layerRoot;
11
+ let host;
12
+ let cmp = null;
13
+ function makeApp(id, menus) {
14
+ return {
15
+ manifest: {
16
+ id, label: id, version: '0.0.0',
17
+ requiredShards: ['shard.x'], layoutVersion: 1,
18
+ menus,
19
+ },
20
+ initialLayout: { type: 'leaf', viewId: 'shard.x:v' },
21
+ };
22
+ }
23
+ beforeEach(() => {
24
+ vi.stubGlobal('innerWidth', 2000);
25
+ vi.stubGlobal('innerHeight', 2000);
26
+ layerRoot = document.createElement('div');
27
+ layerRoot.style.position = 'relative';
28
+ document.body.appendChild(layerRoot);
29
+ registerLayerRoot('popup', layerRoot);
30
+ host = document.createElement('div');
31
+ document.body.appendChild(host);
32
+ });
33
+ afterEach(() => {
34
+ if (cmp) {
35
+ unmount(cmp);
36
+ cmp = null;
37
+ }
38
+ host.remove();
39
+ __resetPopupManagerForTest();
40
+ unregisterLayerRoot('popup');
41
+ layerRoot.remove();
42
+ __resetActionsRegistryForTest();
43
+ __resetContributionsForTest();
44
+ __resetAppRegistryForTest();
45
+ __resetDispatcherStateForTest();
46
+ vi.unstubAllGlobals();
47
+ });
48
+ describe('MenuBar', () => {
49
+ it('renders an empty menubar when no app is active', async () => {
50
+ cmp = mount(MenuBar, { target: host, props: {} });
51
+ await tick();
52
+ // The wrapper always renders (it occupies a grid cell in the shell
53
+ // tabbar so user chrome stays anchored right). When no app is active
54
+ // it's just empty.
55
+ const bar = host.querySelector('.sh3-menubar');
56
+ expect(bar).not.toBeNull();
57
+ expect(bar.querySelectorAll('.sh3-menubar-button')).toHaveLength(0);
58
+ });
59
+ it('renders one button per declared container when an app is active', async () => {
60
+ registerApp(makeApp('app.a', [
61
+ { id: 'file', label: 'File' },
62
+ { id: 'help', label: 'Help' },
63
+ ]));
64
+ registerAction({
65
+ id: 'open', label: 'Open', scope: 'app',
66
+ menuItem: 'file', run: () => { },
67
+ }, 'shard.x');
68
+ registerAction({
69
+ id: 'docs', label: 'Docs', scope: 'app',
70
+ menuItem: 'help', run: () => { },
71
+ }, 'shard.x');
72
+ setActiveApp('app.a', new Set(['shard.x']));
73
+ cmp = mount(MenuBar, { target: host, props: {} });
74
+ await tick();
75
+ const buttons = host.querySelectorAll('.sh3-menubar-button');
76
+ expect(buttons).toHaveLength(2);
77
+ expect(buttons[0].textContent).toContain('File');
78
+ expect(buttons[1].textContent).toContain('Help');
79
+ });
80
+ it('falls back to DEFAULT_MENU_CONTAINERS when manifest.menus is absent', async () => {
81
+ registerApp(makeApp('app.b'));
82
+ registerAction({
83
+ id: 'q', label: 'Quit', scope: 'app',
84
+ menuItem: 'file', run: () => { },
85
+ }, 'shard.x');
86
+ setActiveApp('app.b', new Set(['shard.x']));
87
+ cmp = mount(MenuBar, { target: host, props: {} });
88
+ await tick();
89
+ const buttons = host.querySelectorAll('.sh3-menubar-button');
90
+ expect(buttons).toHaveLength(1);
91
+ expect(buttons[0].textContent).toContain('File');
92
+ });
93
+ it('hides containers whose item list is empty', async () => {
94
+ registerApp(makeApp('app.c', [
95
+ { id: 'file', label: 'File' },
96
+ { id: 'edit', label: 'Edit' },
97
+ ]));
98
+ registerAction({
99
+ id: 'open', label: 'Open', scope: 'app',
100
+ menuItem: 'file', run: () => { },
101
+ }, 'shard.x');
102
+ setActiveApp('app.c', new Set(['shard.x']));
103
+ cmp = mount(MenuBar, { target: host, props: {} });
104
+ await tick();
105
+ const buttons = host.querySelectorAll('.sh3-menubar-button');
106
+ expect(buttons).toHaveLength(1);
107
+ expect(buttons[0].textContent).toContain('File');
108
+ });
109
+ });
@@ -0,0 +1,104 @@
1
+ <script lang="ts">
2
+ /*
3
+ * MenuButton — one menu bar container. A button that opens a popup
4
+ * containing an ActionPanel rendered with this container's items.
5
+ * Items are passed in (the parent MenuBar resolves them via
6
+ * menuBarModel.resolveMenuItems).
7
+ */
8
+ import { popupManager } from '../overlays/popup';
9
+ import ActionPanel, { type ActionPanelSection } from './ActionPanel.svelte';
10
+ import { listActions } from './registry';
11
+ import { getLiveDispatcherState } from './state.svelte';
12
+ import type { MenuBarItem } from './menuBarModel';
13
+ import type { MenuContainer } from '../apps/types';
14
+
15
+ let { container, items }: {
16
+ container: MenuContainer;
17
+ items: MenuBarItem[];
18
+ } = $props();
19
+
20
+ let buttonEl: HTMLButtonElement | undefined = $state();
21
+
22
+ function makeSections(list: MenuBarItem[]): ActionPanelSection[] {
23
+ const buckets = new Map<string, MenuBarItem[]>();
24
+ const order: string[] = [];
25
+ for (const item of list) {
26
+ const key = item.group || '';
27
+ if (!buckets.has(key)) {
28
+ buckets.set(key, []);
29
+ order.push(key);
30
+ }
31
+ buckets.get(key)!.push(item);
32
+ }
33
+ return order.map((k) => ({ id: `group:${k || '_default'}`, items: buckets.get(k)! }));
34
+ }
35
+
36
+ function openPopup() {
37
+ if (!buttonEl) return;
38
+ const state = getLiveDispatcherState();
39
+ popupManager.show(
40
+ ActionPanel,
41
+ { anchor: buttonEl, placement: 'bottom-start' },
42
+ {
43
+ sections: makeSections(items),
44
+ onInvoke: (id: string) => {
45
+ const entry = listActions().find((e) => e.action.id === id);
46
+ if (!entry) return;
47
+ try {
48
+ void entry.action.run({
49
+ action: { id, label: entry.action.label },
50
+ appId: state.activeAppId,
51
+ viewId: state.focusedViewId ?? undefined,
52
+ selection: state.selection ?? undefined,
53
+ invokedVia: 'palette',
54
+ dispatch: () => {},
55
+ });
56
+ } catch (err) {
57
+ console.error(`[sh3] menu-bar action "${id}" threw:`, err);
58
+ }
59
+ },
60
+ onDismiss: () => popupManager.close(),
61
+ },
62
+ );
63
+ }
64
+
65
+ const iconPosition = $derived(container.iconPosition ?? 'before');
66
+ </script>
67
+
68
+ <button
69
+ type="button"
70
+ class="sh3-menubar-button"
71
+ bind:this={buttonEl}
72
+ onclick={openPopup}
73
+ >
74
+ {#if container.icon && iconPosition === 'before'}
75
+ <span class="sh3-menubar-icon" data-icon={container.icon}></span>
76
+ {/if}
77
+ <span class="sh3-menubar-label">{container.label}</span>
78
+ {#if container.icon && iconPosition === 'after'}
79
+ <span class="sh3-menubar-icon" data-icon={container.icon}></span>
80
+ {/if}
81
+ </button>
82
+
83
+ <style>
84
+ .sh3-menubar-button {
85
+ display: inline-flex;
86
+ align-items: center;
87
+ gap: 6px;
88
+ padding: 0 var(--shell-pad-md);
89
+ height: 100%;
90
+ background: transparent;
91
+ color: var(--shell-fg);
92
+ border: 0;
93
+ font: inherit;
94
+ cursor: default;
95
+ }
96
+ .sh3-menubar-button:hover {
97
+ background: var(--shell-bg-elevated);
98
+ }
99
+ .sh3-menubar-icon {
100
+ width: 14px;
101
+ height: 14px;
102
+ display: inline-block;
103
+ }
104
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { MenuBarItem } from './menuBarModel';
2
+ import type { MenuContainer } from '../apps/types';
3
+ type $$ComponentProps = {
4
+ container: MenuContainer;
5
+ items: MenuBarItem[];
6
+ };
7
+ declare const MenuButton: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type MenuButton = ReturnType<typeof MenuButton>;
9
+ export default MenuButton;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mount, unmount, tick } from 'svelte';
3
+ import MenuButton from './MenuButton.svelte';
4
+ import { registerLayerRoot, unregisterLayerRoot } from '../overlays/roots';
5
+ import { __resetPopupManagerForTest } from '../overlays/popup';
6
+ let layerRoot;
7
+ let host;
8
+ let cmp = null;
9
+ beforeEach(() => {
10
+ vi.stubGlobal('innerWidth', 2000);
11
+ vi.stubGlobal('innerHeight', 2000);
12
+ layerRoot = document.createElement('div');
13
+ layerRoot.style.position = 'relative';
14
+ document.body.appendChild(layerRoot);
15
+ registerLayerRoot('popup', layerRoot);
16
+ host = document.createElement('div');
17
+ document.body.appendChild(host);
18
+ });
19
+ afterEach(() => {
20
+ if (cmp) {
21
+ unmount(cmp);
22
+ cmp = null;
23
+ }
24
+ host.remove();
25
+ __resetPopupManagerForTest();
26
+ unregisterLayerRoot('popup');
27
+ layerRoot.remove();
28
+ vi.unstubAllGlobals();
29
+ });
30
+ describe('MenuButton', () => {
31
+ it('renders the container label', () => {
32
+ cmp = mount(MenuButton, {
33
+ target: host,
34
+ props: {
35
+ container: { id: 'file', label: 'File' },
36
+ items: [],
37
+ },
38
+ });
39
+ expect(host.textContent).toContain('File');
40
+ });
41
+ it('renders icon before label when iconPosition is "before" (default)', () => {
42
+ cmp = mount(MenuButton, {
43
+ target: host,
44
+ props: {
45
+ container: { id: 'file', label: 'File', icon: 'folder' },
46
+ items: [],
47
+ },
48
+ });
49
+ const btn = host.querySelector('button');
50
+ const children = Array.from(btn.children);
51
+ const iconIdx = children.findIndex((c) => c.classList.contains('sh3-menubar-icon'));
52
+ const labelIdx = children.findIndex((c) => c.classList.contains('sh3-menubar-label'));
53
+ expect(iconIdx).toBeGreaterThanOrEqual(0);
54
+ expect(iconIdx).toBeLessThan(labelIdx);
55
+ });
56
+ it('renders icon after label when iconPosition is "after"', () => {
57
+ cmp = mount(MenuButton, {
58
+ target: host,
59
+ props: {
60
+ container: { id: 'file', label: 'File', icon: 'folder', iconPosition: 'after' },
61
+ items: [],
62
+ },
63
+ });
64
+ const btn = host.querySelector('button');
65
+ const children = Array.from(btn.children);
66
+ const iconIdx = children.findIndex((c) => c.classList.contains('sh3-menubar-icon'));
67
+ const labelIdx = children.findIndex((c) => c.classList.contains('sh3-menubar-label'));
68
+ expect(labelIdx).toBeLessThan(iconIdx);
69
+ });
70
+ it('clicking the button opens a popup with ActionPanel mounted', async () => {
71
+ cmp = mount(MenuButton, {
72
+ target: host,
73
+ props: {
74
+ container: { id: 'file', label: 'File' },
75
+ items: [
76
+ { id: 'open', label: 'Open', shortcut: 'Ctrl+O', group: '', icon: undefined },
77
+ ],
78
+ },
79
+ });
80
+ await tick();
81
+ const btn = host.querySelector('button');
82
+ btn.click();
83
+ await tick();
84
+ const items = layerRoot.querySelectorAll('[role="menuitem"]');
85
+ expect(items.length).toBe(1);
86
+ expect(items[0].textContent).toContain('Open');
87
+ });
88
+ });
@@ -0,0 +1,2 @@
1
+ import type { MenuContainer } from '../apps/types';
2
+ export declare const DEFAULT_MENU_CONTAINERS: readonly MenuContainer[];
@@ -0,0 +1,7 @@
1
+ export const DEFAULT_MENU_CONTAINERS = Object.freeze([
2
+ { id: 'file', label: 'File' },
3
+ { id: 'edit', label: 'Edit' },
4
+ { id: 'view', label: 'View' },
5
+ { id: 'window', label: 'Window' },
6
+ { id: 'help', label: 'Help' },
7
+ ]);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { DEFAULT_MENU_CONTAINERS } from './defaultMenuContainers';
3
+ describe('DEFAULT_MENU_CONTAINERS', () => {
4
+ it('declares the canonical five containers in fixed order', () => {
5
+ expect(DEFAULT_MENU_CONTAINERS.map((c) => c.id))
6
+ .toEqual(['file', 'edit', 'view', 'window', 'help']);
7
+ });
8
+ it('declares matching labels', () => {
9
+ expect(DEFAULT_MENU_CONTAINERS.map((c) => c.label))
10
+ .toEqual(['File', 'Edit', 'View', 'Window', 'Help']);
11
+ });
12
+ it('declares no icons by default (text-only fallback)', () => {
13
+ for (const c of DEFAULT_MENU_CONTAINERS) {
14
+ expect(c.icon).toBeUndefined();
15
+ }
16
+ });
17
+ it('is frozen — mutating throws or is silently dropped', () => {
18
+ expect(() => {
19
+ // @ts-expect-error — testing runtime immutability of a readonly constant
20
+ DEFAULT_MENU_CONTAINERS.push({ id: 'x', label: 'X' });
21
+ }).toThrow();
22
+ });
23
+ });
@@ -0,0 +1,28 @@
1
+ import type { ActionEntry } from './registry';
2
+ import type { DispatcherState } from './dispatcher.svelte';
3
+ import type { MenuContainer } from '../apps/types';
4
+ export interface MenuBarItem {
5
+ id: string;
6
+ label: string;
7
+ shortcut: string | null;
8
+ group: string;
9
+ icon: string | undefined;
10
+ }
11
+ /**
12
+ * Resolved container list for the currently-active app:
13
+ * - activeAppId == null → returns []
14
+ * - declared has entries → returns declared, sorted by `order`
15
+ * ascending then declaration order
16
+ * for ties / undefined
17
+ * - declared is undefined → returns a copy of DEFAULT_MENU_CONTAINERS
18
+ *
19
+ * Callers can render unconditionally; the empty-array case naturally
20
+ * suppresses the menu bar at home.
21
+ */
22
+ export declare function resolveMenuContainers(activeAppId: string | null, declared: readonly MenuContainer[] | undefined): MenuContainer[];
23
+ /**
24
+ * Items targeting `containerId`, filtered by current scope activation
25
+ * and de-duplicated to the innermost active scope per action id (mirrors
26
+ * contextMenuModel).
27
+ */
28
+ export declare function resolveMenuItems(entries: readonly ActionEntry[], state: DispatcherState, containerId: string): MenuBarItem[];
@@ -0,0 +1,67 @@
1
+ /*
2
+ * Pure model layer for the menu bar: resolves container list for the
3
+ * active app, and resolves per-container item lists by filtering the
4
+ * action registry by `menuItem` + scope-activation. Mirrors the
5
+ * de-duplication semantics of contextMenuModel.
6
+ */
7
+ import { effectiveShortcut } from './bindings';
8
+ import { innermostActiveScope } from './scope-helpers';
9
+ import { DEFAULT_MENU_CONTAINERS } from './defaultMenuContainers';
10
+ /**
11
+ * Resolved container list for the currently-active app:
12
+ * - activeAppId == null → returns []
13
+ * - declared has entries → returns declared, sorted by `order`
14
+ * ascending then declaration order
15
+ * for ties / undefined
16
+ * - declared is undefined → returns a copy of DEFAULT_MENU_CONTAINERS
17
+ *
18
+ * Callers can render unconditionally; the empty-array case naturally
19
+ * suppresses the menu bar at home.
20
+ */
21
+ export function resolveMenuContainers(activeAppId, declared) {
22
+ if (activeAppId == null)
23
+ return [];
24
+ if (declared == null)
25
+ return DEFAULT_MENU_CONTAINERS.slice();
26
+ const indexed = declared.map((c, i) => ({ c, i }));
27
+ indexed.sort((a, b) => {
28
+ const ao = a.c.order;
29
+ const bo = b.c.order;
30
+ if (ao != null && bo != null)
31
+ return ao - bo || a.i - b.i;
32
+ if (ao != null)
33
+ return -1;
34
+ if (bo != null)
35
+ return 1;
36
+ return a.i - b.i;
37
+ });
38
+ return indexed.map((x) => x.c);
39
+ }
40
+ /**
41
+ * Items targeting `containerId`, filtered by current scope activation
42
+ * and de-duplicated to the innermost active scope per action id (mirrors
43
+ * contextMenuModel).
44
+ */
45
+ export function resolveMenuItems(entries, state, containerId) {
46
+ var _a;
47
+ const out = [];
48
+ const seen = new Set();
49
+ for (const entry of entries) {
50
+ if (entry.action.menuItem !== containerId)
51
+ continue;
52
+ if (seen.has(entry.action.id))
53
+ continue;
54
+ const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
55
+ if (!winning)
56
+ continue;
57
+ seen.add(entry.action.id);
58
+ out.push({
59
+ id: entry.action.id,
60
+ label: entry.action.label,
61
+ shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
62
+ group: (_a = entry.action.group) !== null && _a !== void 0 ? _a : '',
63
+ icon: entry.action.icon,
64
+ });
65
+ }
66
+ return out;
67
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveMenuContainers, resolveMenuItems, } from './menuBarModel';
3
+ const mkEntry = (a, owner = 'shard.x') => ({
4
+ ownerShardId: owner,
5
+ action: Object.assign({ id: 'a', label: 'A', scope: 'home', run: () => { } }, a),
6
+ });
7
+ const mkState = (o = {}) => (Object.assign({ activeAppId: null, activeAppRequiredShards: new Set(), autostartShards: new Set(), mountedViewIds: new Set(), focusedViewId: null, selection: null, bindings: {}, platform: 'other' }, o));
8
+ describe('resolveMenuContainers', () => {
9
+ it('returns [] when no app is active', () => {
10
+ expect(resolveMenuContainers(null, undefined)).toEqual([]);
11
+ });
12
+ it('returns DEFAULT_MENU_CONTAINERS when app has no manifest.menus', () => {
13
+ const out = resolveMenuContainers('app.a', undefined);
14
+ expect(out.map((c) => c.id)).toEqual(['file', 'edit', 'view', 'window', 'help']);
15
+ });
16
+ it('returns manifest.menus when declared', () => {
17
+ const declared = [
18
+ { id: 'project', label: 'Project' },
19
+ { id: 'help', label: 'Help' },
20
+ ];
21
+ expect(resolveMenuContainers('app.a', declared).map((c) => c.id))
22
+ .toEqual(['project', 'help']);
23
+ });
24
+ it('sorts by `order` ascending, then by declaration order for ties/undefined', () => {
25
+ const declared = [
26
+ { id: 'a', label: 'A', order: 10 },
27
+ { id: 'b', label: 'B' },
28
+ { id: 'c', label: 'C', order: 5 },
29
+ { id: 'd', label: 'D' },
30
+ ];
31
+ expect(resolveMenuContainers('app.a', declared).map((c) => c.id))
32
+ .toEqual(['c', 'a', 'b', 'd']);
33
+ });
34
+ });
35
+ describe('resolveMenuItems', () => {
36
+ const stateWithApp = mkState({
37
+ activeAppId: 'app.a',
38
+ activeAppRequiredShards: new Set(['shard.x']),
39
+ });
40
+ it('returns only actions whose menuItem matches the container id', () => {
41
+ const entries = [
42
+ mkEntry({ id: 'open', scope: 'app', menuItem: 'file', label: 'Open' }),
43
+ mkEntry({ id: 'copy', scope: 'app', menuItem: 'edit', label: 'Copy' }),
44
+ mkEntry({ id: 'close', scope: 'app', menuItem: 'file', label: 'Close' }),
45
+ ];
46
+ const out = resolveMenuItems(entries, stateWithApp, 'file');
47
+ expect(out.map((i) => i.id)).toEqual(['open', 'close']);
48
+ });
49
+ it('skips actions whose scope is not currently active', () => {
50
+ const entries = [
51
+ mkEntry({ id: 'open', scope: 'app', menuItem: 'file', label: 'Open' }),
52
+ mkEntry({ id: 'help', scope: 'home', menuItem: 'file', label: 'Help' }),
53
+ ];
54
+ const out = resolveMenuItems(entries, stateWithApp, 'file');
55
+ expect(out.map((i) => i.id)).toEqual(['open']);
56
+ });
57
+ it('skips actions without a menuItem field', () => {
58
+ const entries = [
59
+ mkEntry({ id: 'a', scope: 'app', menuItem: 'file', label: 'A' }),
60
+ mkEntry({ id: 'b', scope: 'app', label: 'B' }),
61
+ ];
62
+ const out = resolveMenuItems(entries, stateWithApp, 'file');
63
+ expect(out.map((i) => i.id)).toEqual(['a']);
64
+ });
65
+ it('returns [] for an unknown container id', () => {
66
+ const entries = [
67
+ mkEntry({ id: 'open', scope: 'app', menuItem: 'file', label: 'Open' }),
68
+ ];
69
+ expect(resolveMenuItems(entries, stateWithApp, 'sausage')).toEqual([]);
70
+ });
71
+ it('de-duplicates multi-scope actions by innermost active scope', () => {
72
+ const state = mkState({
73
+ activeAppId: 'app.a',
74
+ activeAppRequiredShards: new Set(['shard.x']),
75
+ autostartShards: new Set(['shard.x']),
76
+ });
77
+ const entries = [
78
+ mkEntry({ id: 'p', scope: ['home', 'app'], menuItem: 'file', label: 'P' }),
79
+ ];
80
+ const out = resolveMenuItems(entries, state, 'file');
81
+ expect(out).toHaveLength(1);
82
+ expect(out[0].id).toBe('p');
83
+ });
84
+ });
@@ -8,6 +8,14 @@ export interface Action {
8
8
  scope: ActionScope;
9
9
  contextItem?: boolean;
10
10
  paletteItem?: boolean;
11
+ /**
12
+ * Optional menu container id. When set and the active app's declared
13
+ * (or canonical fallback) menu list contains this id, the action
14
+ * appears in that container's dropdown. Orphaned values render
15
+ * nowhere in the menu bar; the action remains reachable via
16
+ * palette/hotkey/context menu.
17
+ */
18
+ menuItem?: string;
11
19
  defaultShortcut?: string;
12
20
  icon?: string;
13
21
  group?: string;
@@ -14,7 +14,7 @@
14
14
  import { createStateZones } from '../state/zones.svelte';
15
15
  import { activateShard, deactivateShard, getShardContext, registeredShards, } from '../shards/activate.svelte';
16
16
  import { attachApp, acquireAppSlotHolds, detachApp, switchToApp, switchToHome, } from '../layout/store.svelte';
17
- import { activeApp, getRegisteredApp, registeredApps } from './registry.svelte';
17
+ import { activeApp, breadcrumbApp, getRegisteredApp, registeredApps } from './registry.svelte';
18
18
  import { createZoneManager } from '../state/manage';
19
19
  import { PERMISSION_STATE_MANAGE } from '../state/types';
20
20
  import { setActiveApp, setUserBindings } from '../actions/state.svelte';
@@ -96,6 +96,7 @@ export async function launchApp(id) {
96
96
  switchToApp();
97
97
  void ((_c = app.onAppReady) === null || _c === void 0 ? void 0 : _c.call(app, getOrCreateAppContext(id)));
98
98
  writeLastApp(id);
99
+ breadcrumbApp.id = id;
99
100
  setActiveApp(id, new Set((_d = app.manifest.requiredShards) !== null && _d !== void 0 ? _d : []));
100
101
  void loadUserBindings(id).then(setUserBindings);
101
102
  return;
@@ -135,6 +136,7 @@ export async function launchApp(id) {
135
136
  switchToApp();
136
137
  void ((_g = app.onAppReady) === null || _g === void 0 ? void 0 : _g.call(app, getOrCreateAppContext(id)));
137
138
  writeLastApp(id);
139
+ breadcrumbApp.id = id;
138
140
  }
139
141
  // ---------- unload --------------------------------------------------------
140
142
  /**
@@ -226,6 +228,11 @@ export async function returnToHome() {
226
228
  return false;
227
229
  }
228
230
  switchToHome();
231
+ // Mirror unregisterApp: clear the dispatcher's active-app pointer so
232
+ // 'app'-scope actions become inactive on home. Without this, any action
233
+ // registered with scope: ['app'] keeps appearing in the palette while
234
+ // the user is on home.
235
+ setActiveApp(null, new Set());
229
236
  writeLastApp(null);
230
237
  return true;
231
238
  }