sh3-core 0.11.2 → 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 (84) 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/bindings.d.ts +10 -1
  20. package/dist/actions/bindings.js +16 -0
  21. package/dist/actions/bindings.test.js +23 -1
  22. package/dist/actions/contextMenuModel.js +5 -40
  23. package/dist/actions/defaultMenuContainers.d.ts +2 -0
  24. package/dist/actions/defaultMenuContainers.js +7 -0
  25. package/dist/actions/defaultMenuContainers.test.d.ts +1 -0
  26. package/dist/actions/defaultMenuContainers.test.js +23 -0
  27. package/dist/actions/dispatcher.svelte.js +1 -14
  28. package/dist/actions/listActive.d.ts +4 -0
  29. package/dist/actions/listActive.js +42 -0
  30. package/dist/actions/listActive.test.d.ts +1 -0
  31. package/dist/actions/listActive.test.js +86 -0
  32. package/dist/actions/menuBarModel.d.ts +28 -0
  33. package/dist/actions/menuBarModel.js +67 -0
  34. package/dist/actions/menuBarModel.test.d.ts +1 -0
  35. package/dist/actions/menuBarModel.test.js +84 -0
  36. package/dist/actions/paletteModel.js +10 -21
  37. package/dist/actions/paletteModel.test.js +16 -0
  38. package/dist/actions/scope-helpers.d.ts +11 -0
  39. package/dist/actions/scope-helpers.js +51 -0
  40. package/dist/actions/scope-helpers.test.d.ts +1 -0
  41. package/dist/actions/scope-helpers.test.js +62 -0
  42. package/dist/actions/shellActions.test.js +50 -0
  43. package/dist/actions/state.svelte.d.ts +12 -0
  44. package/dist/actions/state.svelte.js +36 -0
  45. package/dist/actions/state.test.js +26 -1
  46. package/dist/actions/types.d.ts +49 -0
  47. package/dist/api.d.ts +5 -0
  48. package/dist/api.js +6 -0
  49. package/dist/apps/lifecycle.js +8 -1
  50. package/dist/apps/lifecycle.test.js +211 -1
  51. package/dist/apps/registry.svelte.d.ts +17 -1
  52. package/dist/apps/registry.svelte.js +20 -1
  53. package/dist/apps/types.d.ts +28 -0
  54. package/dist/assets/favicon.png +0 -0
  55. package/dist/assets/favicon.svg +5 -0
  56. package/dist/color/api.d.ts +38 -0
  57. package/dist/color/api.js +10 -0
  58. package/dist/color/native-fallback.test.d.ts +1 -0
  59. package/dist/color/native-fallback.test.js +43 -0
  60. package/dist/color/primitive.d.ts +2 -0
  61. package/dist/color/primitive.js +40 -0
  62. package/dist/color/primitive.test.d.ts +1 -0
  63. package/dist/color/primitive.test.js +42 -0
  64. package/dist/color/shell-api.d.ts +2 -0
  65. package/dist/color/shell-api.js +11 -0
  66. package/dist/index.d.ts +0 -2
  67. package/dist/index.js +0 -2
  68. package/dist/layout/store.svelte.d.ts +27 -0
  69. package/dist/layout/store.svelte.js +63 -0
  70. package/dist/overlays/ConfirmDialog.svelte +138 -0
  71. package/dist/overlays/ConfirmDialog.svelte.d.ts +13 -0
  72. package/dist/overlays/ConfirmDialog.test.d.ts +1 -0
  73. package/dist/overlays/ConfirmDialog.test.js +123 -0
  74. package/dist/overlays/FloatFrame.svelte +2 -2
  75. package/dist/overlays/ToastItem.svelte +3 -3
  76. package/dist/primitives/base.css +5 -5
  77. package/dist/sh3core-shard/sh3coreShard.svelte.js +20 -0
  78. package/dist/shell-shard/shellShard.svelte.js +0 -4
  79. package/dist/shellRuntime.svelte.d.ts +20 -0
  80. package/dist/shellRuntime.svelte.js +16 -1
  81. package/dist/tokens.css +1 -1
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. 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
+ });
@@ -1,4 +1,13 @@
1
1
  import { type Platform } from './shortcuts';
2
- import type { Action } from './types';
2
+ import type { Action, BindingSource } from './types';
3
3
  export type BindingOverrides = Record<string, string | null>;
4
4
  export declare function effectiveShortcut(action: Action, overrides: BindingOverrides, platform?: Platform): string | null;
5
+ /**
6
+ * Like {@link effectiveShortcut}, but also reports the binding source so
7
+ * consumers (e.g., Help views) can distinguish "no shortcut assigned"
8
+ * from "user turned this off" — both of which yield a `null` shortcut.
9
+ */
10
+ export declare function effectiveShortcutWithSource(action: Action, overrides: BindingOverrides, platform?: Platform): {
11
+ shortcut: string | null;
12
+ source: BindingSource;
13
+ };
@@ -15,3 +15,19 @@ export function effectiveShortcut(action, overrides, platform = 'other') {
15
15
  return null;
16
16
  return resolveMod(action.defaultShortcut, platform);
17
17
  }
18
+ /**
19
+ * Like {@link effectiveShortcut}, but also reports the binding source so
20
+ * consumers (e.g., Help views) can distinguish "no shortcut assigned"
21
+ * from "user turned this off" — both of which yield a `null` shortcut.
22
+ */
23
+ export function effectiveShortcutWithSource(action, overrides, platform = 'other') {
24
+ if (action.id in overrides) {
25
+ const o = overrides[action.id];
26
+ if (o === null)
27
+ return { shortcut: null, source: 'disabled' };
28
+ return { shortcut: canonicalizeShortcut(o), source: 'user' };
29
+ }
30
+ if (!action.defaultShortcut)
31
+ return { shortcut: null, source: 'none' };
32
+ return { shortcut: resolveMod(action.defaultShortcut, platform), source: 'default' };
33
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { effectiveShortcut } from './bindings';
2
+ import { effectiveShortcut, effectiveShortcutWithSource } from './bindings';
3
3
  const mkAction = (overrides = {}) => (Object.assign({ id: 'shard.save', label: 'Save', scope: 'home', run: () => { } }, overrides));
4
4
  describe('effectiveShortcut', () => {
5
5
  it('returns defaultShortcut when no override', () => {
@@ -28,3 +28,25 @@ describe('effectiveShortcut', () => {
28
28
  expect(effectiveShortcut(a, { 'shard.save': 'Ctrl+K' }, 'mac')).toBe('Ctrl+K');
29
29
  });
30
30
  });
31
+ describe('effectiveShortcutWithSource', () => {
32
+ it('returns default + "default" when no override and defaultShortcut present', () => {
33
+ const a = mkAction({ defaultShortcut: 'Mod+S' });
34
+ expect(effectiveShortcutWithSource(a, {}, 'other'))
35
+ .toEqual({ shortcut: 'Ctrl+S', source: 'default' });
36
+ });
37
+ it('returns canonicalized override + "user" when user rebound to a key', () => {
38
+ const a = mkAction({ defaultShortcut: 'Mod+S' });
39
+ expect(effectiveShortcutWithSource(a, { 'shard.save': 'Ctrl+K' }, 'other'))
40
+ .toEqual({ shortcut: 'Ctrl+K', source: 'user' });
41
+ });
42
+ it('returns null + "disabled" when user rebound to null', () => {
43
+ const a = mkAction({ defaultShortcut: 'Mod+S' });
44
+ expect(effectiveShortcutWithSource(a, { 'shard.save': null }, 'other'))
45
+ .toEqual({ shortcut: null, source: 'disabled' });
46
+ });
47
+ it('returns null + "none" when no default and no override', () => {
48
+ const a = mkAction();
49
+ expect(effectiveShortcutWithSource(a, {}, 'other'))
50
+ .toEqual({ shortcut: null, source: 'none' });
51
+ });
52
+ });
@@ -3,44 +3,9 @@
3
3
  * dispatcher state, returns a tiered, deduplicated, shortcut-annotated
4
4
  * item list the Svelte component renders without further logic.
5
5
  */
6
- import { isScopeActive, TIER_ORDER, } from './dispatcher.svelte';
6
+ import { TIER_ORDER } from './dispatcher.svelte';
7
7
  import { effectiveShortcut } from './bindings';
8
- function normalizeScope(scope) {
9
- return Array.isArray(scope) ? scope : [scope];
10
- }
11
- function scopeToTier(scope) {
12
- if (scope === 'home')
13
- return 'home';
14
- if (scope === 'app')
15
- return 'app';
16
- if (typeof scope === 'string' && scope.startsWith('view:'))
17
- return 'view';
18
- if (typeof scope === 'string' && scope.startsWith('focus:'))
19
- return 'focus';
20
- return 'element';
21
- }
22
- function innermostActiveTier(scope, state, owner) {
23
- // Build a map of tier → active scopes for this action, then walk
24
- // TIER_ORDER from innermost → outermost to find the first active tier.
25
- const scopes = normalizeScope(scope);
26
- const tierBuckets = {};
27
- for (const s of scopes) {
28
- const tier = scopeToTier(s);
29
- if (!tierBuckets[tier])
30
- tierBuckets[tier] = [];
31
- tierBuckets[tier].push(s);
32
- }
33
- for (const tier of TIER_ORDER) {
34
- const bucket = tierBuckets[tier];
35
- if (!bucket)
36
- continue;
37
- for (const s of bucket) {
38
- if (isScopeActive(s, state, owner))
39
- return tier;
40
- }
41
- }
42
- return null;
43
- }
8
+ import { scopeToTier, innermostActiveScope } from './scope-helpers';
44
9
  export function buildContextMenuModel(entries, state) {
45
10
  var _a;
46
11
  const byTier = {
@@ -52,11 +17,11 @@ export function buildContextMenuModel(entries, state) {
52
17
  continue;
53
18
  if (seen.has(entry.action.id))
54
19
  continue;
55
- const tier = innermostActiveTier(entry.action.scope, state, entry.ownerShardId);
56
- if (!tier)
20
+ const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
21
+ if (!winning)
57
22
  continue;
58
23
  seen.add(entry.action.id);
59
- byTier[tier].push({
24
+ byTier[scopeToTier(winning)].push({
60
25
  id: entry.action.id,
61
26
  label: entry.action.label,
62
27
  shortcut: effectiveShortcut(entry.action, state.bindings, state.platform),
@@ -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
+ });
@@ -5,6 +5,7 @@
5
5
  * state snapshots.
6
6
  */
7
7
  import { effectiveShortcut } from './bindings';
8
+ import { scopeToTier, normalizeScope } from './scope-helpers';
8
9
  export const TIER_ORDER = ['element', 'focus', 'view', 'app', 'home'];
9
10
  export function isScopeActive(scope, state, ownerShardId) {
10
11
  if (scope === 'home') {
@@ -31,20 +32,6 @@ export function isScopeActive(scope, state, ownerShardId) {
31
32
  }
32
33
  return false;
33
34
  }
34
- function scopeToTier(scope) {
35
- if (scope === 'home')
36
- return 'home';
37
- if (scope === 'app')
38
- return 'app';
39
- if (typeof scope === 'string' && scope.startsWith('view:'))
40
- return 'view';
41
- if (typeof scope === 'string' && scope.startsWith('focus:'))
42
- return 'focus';
43
- return 'element';
44
- }
45
- function normalizeScope(scope) {
46
- return Array.isArray(scope) ? scope : [scope];
47
- }
48
35
  export function buildTierIndex(entries, state) {
49
36
  const idx = {
50
37
  element: new Map(),
@@ -0,0 +1,4 @@
1
+ import type { ActionEntry } from './registry';
2
+ import { type DispatcherState } from './dispatcher.svelte';
3
+ import type { ActiveActionDescriptor } from './types';
4
+ export declare function listActiveFromEntries(entries: ActionEntry[], state: DispatcherState): ActiveActionDescriptor[];
@@ -0,0 +1,42 @@
1
+ /*
2
+ * Pure read-side producer for `shell.actions.listActive()`.
3
+ *
4
+ * Given a snapshot of the action registry and the dispatcher state,
5
+ * return one `ActiveActionDescriptor` per currently-active action, in
6
+ * tier-innermost-first order (element > focus > view > app > home),
7
+ * with registration order preserved within a tier.
8
+ *
9
+ * The shell's live wrapper calls this with `listActions()` + `getLiveDispatcherState()`.
10
+ */
11
+ import { TIER_ORDER, } from './dispatcher.svelte';
12
+ import { effectiveShortcutWithSource } from './bindings';
13
+ import { innermostActiveScope, scopeBadge, scopeToTier } from './scope-helpers';
14
+ export function listActiveFromEntries(entries, state) {
15
+ const byTier = {
16
+ element: [], focus: [], view: [], app: [], home: [],
17
+ };
18
+ const seen = new Set();
19
+ for (const entry of entries) {
20
+ if (seen.has(entry.action.id))
21
+ continue;
22
+ const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
23
+ if (!winning)
24
+ continue;
25
+ seen.add(entry.action.id);
26
+ const { shortcut, source } = effectiveShortcutWithSource(entry.action, state.bindings, state.platform);
27
+ byTier[scopeToTier(winning)].push({
28
+ id: entry.action.id,
29
+ label: entry.action.label,
30
+ effectiveShortcut: shortcut,
31
+ bindingSource: source,
32
+ scope: winning,
33
+ scopeBadge: scopeBadge(winning),
34
+ group: entry.action.group,
35
+ icon: entry.action.icon,
36
+ ownerShardId: entry.ownerShardId,
37
+ paletteItem: entry.action.paletteItem !== false,
38
+ contextItem: entry.action.contextItem !== false,
39
+ });
40
+ }
41
+ return TIER_ORDER.flatMap((tier) => byTier[tier]);
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { listActiveFromEntries } from './listActive';
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('listActiveFromEntries', () => {
9
+ it('omits actions whose scope is not active', () => {
10
+ const entries = [mkEntry({ id: 'p', scope: 'app' })];
11
+ expect(listActiveFromEntries(entries, mkState())).toEqual([]);
12
+ });
13
+ it('includes an active home action with default shortcut resolution', () => {
14
+ const entries = [mkEntry({
15
+ id: 'open', label: 'Open', scope: 'home', defaultShortcut: 'Mod+O',
16
+ })];
17
+ const out = listActiveFromEntries(entries, mkState({ platform: 'mac' }));
18
+ expect(out).toHaveLength(1);
19
+ expect(out[0]).toMatchObject({
20
+ id: 'open', label: 'Open',
21
+ effectiveShortcut: 'Meta+O', bindingSource: 'default',
22
+ scope: 'home', scopeBadge: null,
23
+ });
24
+ });
25
+ it('reports innermost tier for multi-scope action', () => {
26
+ const entries = [mkEntry({
27
+ id: 'm', scope: ['app', 'view:editor'],
28
+ }, '__sh3core__')];
29
+ const state = mkState({
30
+ activeAppId: 'a', autostartShards: new Set(['__sh3core__']),
31
+ mountedViewIds: new Set(['editor']),
32
+ });
33
+ const out = listActiveFromEntries(entries, state);
34
+ expect(out[0].scope).toBe('view:editor');
35
+ expect(out[0].scopeBadge).toBe('view:editor');
36
+ });
37
+ it('reports bindingSource=disabled when user null-rebound', () => {
38
+ const entries = [mkEntry({
39
+ id: 'x', scope: 'home', defaultShortcut: 'Mod+S',
40
+ })];
41
+ const state = mkState({ bindings: { x: null } });
42
+ const out = listActiveFromEntries(entries, state);
43
+ expect(out[0].effectiveShortcut).toBeNull();
44
+ expect(out[0].bindingSource).toBe('disabled');
45
+ });
46
+ it('reports bindingSource=none when no default and no override', () => {
47
+ const entries = [mkEntry({ id: 'x', scope: 'home' })];
48
+ const out = listActiveFromEntries(entries, mkState());
49
+ expect(out[0].effectiveShortcut).toBeNull();
50
+ expect(out[0].bindingSource).toBe('none');
51
+ });
52
+ it('carries paletteItem/contextItem defaults and overrides', () => {
53
+ const entries = [mkEntry({
54
+ id: 'a', scope: 'home', paletteItem: false,
55
+ })];
56
+ const out = listActiveFromEntries(entries, mkState());
57
+ expect(out[0].paletteItem).toBe(false);
58
+ expect(out[0].contextItem).toBe(true); // defaults to true
59
+ });
60
+ it('dedupes by action id', () => {
61
+ const entries = [
62
+ mkEntry({ id: 'dup', scope: 'home' }, 'shard.a'),
63
+ mkEntry({ id: 'dup', scope: 'home' }, 'shard.b'),
64
+ ];
65
+ const out = listActiveFromEntries(entries, mkState());
66
+ expect(out).toHaveLength(1);
67
+ expect(out[0].ownerShardId).toBe('shard.a');
68
+ });
69
+ it('orders innermost tier first, stable by registration inside a tier', () => {
70
+ // `app` and `view:editor` both activate under an active app with the
71
+ // owning shard autostart-registered and the view mounted.
72
+ const entries = [
73
+ mkEntry({ id: 'a1', scope: 'app', label: 'A1' }, '__sh3core__'),
74
+ mkEntry({ id: 'v1', scope: 'view:editor', label: 'V1' }, '__sh3core__'),
75
+ mkEntry({ id: 'a2', scope: 'app', label: 'A2' }, '__sh3core__'),
76
+ ];
77
+ const state = mkState({
78
+ activeAppId: 'a',
79
+ autostartShards: new Set(['__sh3core__']),
80
+ mountedViewIds: new Set(['editor']),
81
+ });
82
+ const out = listActiveFromEntries(entries, state);
83
+ // view tier first (innermost of the two), then app tier in reg order.
84
+ expect(out.map((d) => d.id)).toEqual(['v1', 'a1', 'a2']);
85
+ });
86
+ });