sh3-core 0.10.5 → 0.11.2

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 (115) hide show
  1. package/dist/Shell.svelte +12 -31
  2. package/dist/__test__/reset.js +6 -0
  3. package/dist/actions/CommandPalette.svelte +68 -0
  4. package/dist/actions/CommandPalette.svelte.d.ts +11 -0
  5. package/dist/actions/ContextMenu.svelte +97 -0
  6. package/dist/actions/ContextMenu.svelte.d.ts +9 -0
  7. package/dist/actions/bindings-store.d.ts +8 -0
  8. package/dist/actions/bindings-store.js +27 -0
  9. package/dist/actions/bindings-store.test.d.ts +1 -0
  10. package/dist/actions/bindings-store.test.js +25 -0
  11. package/dist/actions/bindings.d.ts +4 -0
  12. package/dist/actions/bindings.js +17 -0
  13. package/dist/actions/bindings.test.d.ts +1 -0
  14. package/dist/actions/bindings.test.js +30 -0
  15. package/dist/actions/contextMenuModel.d.ts +16 -0
  16. package/dist/actions/contextMenuModel.js +71 -0
  17. package/dist/actions/contextMenuModel.test.d.ts +1 -0
  18. package/dist/actions/contextMenuModel.test.js +44 -0
  19. package/dist/actions/dispatcher.svelte.d.ts +34 -0
  20. package/dist/actions/dispatcher.svelte.js +117 -0
  21. package/dist/actions/dispatcher.test.d.ts +1 -0
  22. package/dist/actions/dispatcher.test.js +155 -0
  23. package/dist/actions/listeners.d.ts +11 -0
  24. package/dist/actions/listeners.js +180 -0
  25. package/dist/actions/listeners.test.d.ts +1 -0
  26. package/dist/actions/listeners.test.js +149 -0
  27. package/dist/actions/palette-scorer.d.ts +11 -0
  28. package/dist/actions/palette-scorer.js +49 -0
  29. package/dist/actions/palette-scorer.test.d.ts +1 -0
  30. package/dist/actions/palette-scorer.test.js +40 -0
  31. package/dist/actions/paletteModel.d.ts +4 -0
  32. package/dist/actions/paletteModel.js +40 -0
  33. package/dist/actions/paletteModel.test.d.ts +1 -0
  34. package/dist/actions/paletteModel.test.js +33 -0
  35. package/dist/actions/registry.d.ts +10 -0
  36. package/dist/actions/registry.js +36 -0
  37. package/dist/actions/registry.test.d.ts +1 -0
  38. package/dist/actions/registry.test.js +49 -0
  39. package/dist/actions/selection.svelte.d.ts +8 -0
  40. package/dist/actions/selection.svelte.js +44 -0
  41. package/dist/actions/selection.test.d.ts +1 -0
  42. package/dist/actions/selection.test.js +51 -0
  43. package/dist/actions/shardContext.test.d.ts +1 -0
  44. package/dist/actions/shardContext.test.js +41 -0
  45. package/dist/actions/shellActions.test.d.ts +1 -0
  46. package/dist/actions/shellActions.test.js +22 -0
  47. package/dist/actions/shortcuts.d.ts +5 -0
  48. package/dist/actions/shortcuts.js +87 -0
  49. package/dist/actions/shortcuts.test.d.ts +1 -0
  50. package/dist/actions/shortcuts.test.js +49 -0
  51. package/dist/actions/state.svelte.d.ts +16 -0
  52. package/dist/actions/state.svelte.js +76 -0
  53. package/dist/actions/state.test.d.ts +1 -0
  54. package/dist/actions/state.test.js +40 -0
  55. package/dist/actions/syncMountedViewIds.test.d.ts +1 -0
  56. package/dist/actions/syncMountedViewIds.test.js +97 -0
  57. package/dist/actions/types.d.ts +56 -0
  58. package/dist/actions/types.js +7 -0
  59. package/dist/api.d.ts +2 -2
  60. package/dist/api.js +1 -1
  61. package/dist/apps/lifecycle.js +13 -3
  62. package/dist/createShell.js +4 -1
  63. package/dist/host.js +6 -3
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.js +2 -0
  66. package/dist/layout/inspection.d.ts +11 -1
  67. package/dist/layout/inspection.js +13 -1
  68. package/dist/layout/ops-locate.test.d.ts +1 -0
  69. package/dist/layout/ops-locate.test.js +103 -0
  70. package/dist/layout/ops.d.ts +8 -0
  71. package/dist/layout/ops.js +27 -0
  72. package/dist/layout/slotHostPool.svelte.js +24 -0
  73. package/dist/layout/slotHostPool.test.js +14 -0
  74. package/dist/layout/types.d.ts +7 -0
  75. package/dist/overlays/FloatFrame.svelte +23 -11
  76. package/dist/overlays/ModalFrame.svelte +9 -1
  77. package/dist/overlays/ModalFrame.svelte.d.ts +1 -0
  78. package/dist/overlays/__test__/DummyFrame.svelte +6 -0
  79. package/dist/overlays/__test__/DummyFrame.svelte.d.ts +6 -0
  80. package/dist/overlays/float.d.ts +6 -0
  81. package/dist/overlays/float.js +24 -9
  82. package/dist/overlays/float.test.js +175 -0
  83. package/dist/overlays/floatDismiss.d.ts +8 -0
  84. package/dist/overlays/floatDismiss.js +68 -0
  85. package/dist/overlays/modal.js +5 -1
  86. package/dist/overlays/modal.test.d.ts +1 -0
  87. package/dist/overlays/modal.test.js +55 -0
  88. package/dist/overlays/popup.d.ts +2 -0
  89. package/dist/overlays/popup.js +24 -4
  90. package/dist/overlays/popup.test.d.ts +1 -0
  91. package/dist/overlays/popup.test.js +95 -0
  92. package/dist/overlays/types.d.ts +17 -1
  93. package/dist/primitives/Button.svelte +144 -0
  94. package/dist/primitives/Button.svelte.d.ts +18 -0
  95. package/dist/primitives/icon-context.d.ts +15 -0
  96. package/dist/primitives/icon-context.js +29 -0
  97. package/dist/sh3core-shard/sh3coreShard.svelte.js +50 -0
  98. package/dist/shards/activate.svelte.js +14 -0
  99. package/dist/shards/types.d.ts +19 -0
  100. package/dist/shards/types.js +5 -4
  101. package/dist/shell-shard/locateSlot.test.d.ts +1 -0
  102. package/dist/shell-shard/locateSlot.test.js +101 -0
  103. package/dist/shell-shard/shellShard.svelte.d.ts +7 -0
  104. package/dist/shell-shard/shellShard.svelte.js +34 -1
  105. package/dist/shellRuntime.svelte.d.ts +19 -0
  106. package/dist/shellRuntime.svelte.js +30 -0
  107. package/dist/tokens.css +11 -1
  108. package/dist/verbs/types.d.ts +9 -0
  109. package/dist/version.d.ts +1 -1
  110. package/dist/version.js +1 -1
  111. package/package.json +1 -1
  112. package/dist/apps/terminal/manifest.d.ts +0 -8
  113. package/dist/apps/terminal/manifest.js +0 -14
  114. package/dist/apps/terminal/terminal-app.d.ts +0 -7
  115. package/dist/apps/terminal/terminal-app.js +0 -14
@@ -0,0 +1,117 @@
1
+ /*
2
+ * Pure dispatcher — scope activation + tier index build. DOM wiring
3
+ * (keydown, focus tracking, user zone subscribe) lives in listeners.ts.
4
+ * This module exposes testable state transitions; listeners feed it
5
+ * state snapshots.
6
+ */
7
+ import { effectiveShortcut } from './bindings';
8
+ export const TIER_ORDER = ['element', 'focus', 'view', 'app', 'home'];
9
+ export function isScopeActive(scope, state, ownerShardId) {
10
+ if (scope === 'home') {
11
+ return state.activeAppId === null;
12
+ }
13
+ if (scope === 'app') {
14
+ if (state.activeAppId === null)
15
+ return false;
16
+ if (!ownerShardId)
17
+ return false;
18
+ return (state.activeAppRequiredShards.has(ownerShardId) ||
19
+ state.autostartShards.has(ownerShardId));
20
+ }
21
+ if (typeof scope === 'string') {
22
+ if (scope.startsWith('view:')) {
23
+ return state.mountedViewIds.has(scope.slice(5));
24
+ }
25
+ if (scope.startsWith('focus:')) {
26
+ return state.focusedViewId === scope.slice(6);
27
+ }
28
+ }
29
+ if (typeof scope === 'object' && 'element' in scope) {
30
+ return state.selection !== null && state.selection.type === scope.element;
31
+ }
32
+ return false;
33
+ }
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
+ export function buildTierIndex(entries, state) {
49
+ const idx = {
50
+ element: new Map(),
51
+ focus: new Map(),
52
+ view: new Map(),
53
+ app: new Map(),
54
+ home: new Map(),
55
+ };
56
+ for (const entry of entries) {
57
+ const shortcut = effectiveShortcut(entry.action, state.bindings, state.platform);
58
+ if (!shortcut)
59
+ continue;
60
+ for (const scope of normalizeScope(entry.action.scope)) {
61
+ if (!isScopeActive(scope, state, entry.ownerShardId))
62
+ continue;
63
+ const tier = scopeToTier(scope);
64
+ const existing = idx[tier].get(shortcut);
65
+ if (existing && existing !== entry.action.id) {
66
+ console.warn(`[sh3] Shortcut "${shortcut}" in tier "${tier}" is bound to both ` +
67
+ `"${existing}" and "${entry.action.id}"; first-registered wins. ` +
68
+ `Users can rebind to resolve.`);
69
+ continue;
70
+ }
71
+ idx[tier].set(shortcut, entry.action.id);
72
+ }
73
+ }
74
+ return idx;
75
+ }
76
+ function isTextInput(target) {
77
+ if (!(target instanceof Element))
78
+ return false;
79
+ const tag = target.tagName;
80
+ if (tag === 'INPUT' || tag === 'TEXTAREA')
81
+ return true;
82
+ const ce = target.contentEditable;
83
+ if (ce === 'true' || ce === 'plaintext-only')
84
+ return true;
85
+ // Fallback: check attribute directly (happy-dom doesn't reflect the property correctly)
86
+ const attr = target.getAttribute('contenteditable');
87
+ if (attr === 'true' || attr === '' || attr === 'plaintext-only')
88
+ return true;
89
+ return false;
90
+ }
91
+ export function dispatchKeydown(env) {
92
+ var _a, _b, _c;
93
+ const idx = buildTierIndex(env.entries, env.state);
94
+ const actionById = new Map(env.entries.map((e) => [e.action.id, e]));
95
+ for (const tier of TIER_ORDER) {
96
+ const id = idx[tier].get(env.shortcut);
97
+ if (!id)
98
+ continue;
99
+ const entry = actionById.get(id);
100
+ if (!entry)
101
+ continue;
102
+ // Input-target blocking
103
+ if (isTextInput(env.target) && !entry.action.allowInInputs) {
104
+ return null;
105
+ }
106
+ env.runAction(id, {
107
+ action: { id: entry.action.id, label: entry.action.label },
108
+ appId: env.state.activeAppId,
109
+ viewId: (_a = env.state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
110
+ selection: (_b = env.state.selection) !== null && _b !== void 0 ? _b : undefined,
111
+ invokedVia: 'keyboard',
112
+ dispatch: (_c = env.dispatch) !== null && _c !== void 0 ? _c : (() => { }),
113
+ });
114
+ return id;
115
+ }
116
+ return null;
117
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { buildTierIndex, isScopeActive, dispatchKeydown, } from './dispatcher.svelte';
3
+ const mkEntry = (action, owner = 'shard.x') => ({
4
+ ownerShardId: owner,
5
+ action: Object.assign({ id: 'a', label: 'A', scope: 'home', run: () => { } }, action),
6
+ });
7
+ const mkState = (overrides = {}) => (Object.assign({ activeAppId: null, activeAppRequiredShards: new Set(), autostartShards: new Set(), mountedViewIds: new Set(), focusedViewId: null, selection: null, bindings: {}, platform: 'other' }, overrides));
8
+ describe('isScopeActive', () => {
9
+ it('home active iff no app', () => {
10
+ expect(isScopeActive('home', mkState())).toBe(true);
11
+ expect(isScopeActive('home', mkState({ activeAppId: 'app.a' }))).toBe(false);
12
+ });
13
+ it('app active when app is loaded and declaring shard is required', () => {
14
+ const state = mkState({
15
+ activeAppId: 'app.a',
16
+ activeAppRequiredShards: new Set(['shard.x']),
17
+ });
18
+ expect(isScopeActive('app', state, 'shard.x')).toBe(true);
19
+ expect(isScopeActive('app', state, 'shard.y')).toBe(false);
20
+ });
21
+ it('app active for autostart shards regardless of requiredShards', () => {
22
+ const state = mkState({
23
+ activeAppId: 'app.a',
24
+ activeAppRequiredShards: new Set(),
25
+ autostartShards: new Set(['__sh3core__']),
26
+ });
27
+ expect(isScopeActive('app', state, '__sh3core__')).toBe(true);
28
+ });
29
+ it('view:X active iff X is mounted', () => {
30
+ const state = mkState({ mountedViewIds: new Set(['editor']) });
31
+ expect(isScopeActive('view:editor', state)).toBe(true);
32
+ expect(isScopeActive('view:terminal', state)).toBe(false);
33
+ });
34
+ it('focus:X active iff X is the focused view', () => {
35
+ const state = mkState({ focusedViewId: 'editor' });
36
+ expect(isScopeActive('focus:editor', state)).toBe(true);
37
+ expect(isScopeActive('focus:terminal', state)).toBe(false);
38
+ });
39
+ it('element matches selection type', () => {
40
+ const state = mkState({ selection: { type: 'orb', ref: 1, ownerShardId: 'shard.x' } });
41
+ expect(isScopeActive({ element: 'orb' }, state)).toBe(true);
42
+ expect(isScopeActive({ element: 'node' }, state)).toBe(false);
43
+ expect(isScopeActive({ element: 'orb' }, mkState())).toBe(false);
44
+ });
45
+ });
46
+ describe('buildTierIndex', () => {
47
+ it('places a home-scope action in the home tier with its shortcut', () => {
48
+ const entries = [
49
+ mkEntry({ id: 'x', scope: 'home', defaultShortcut: 'Ctrl+S' }),
50
+ ];
51
+ const idx = buildTierIndex(entries, mkState());
52
+ expect(idx.home.get('Ctrl+S')).toBe('x');
53
+ expect(idx.app.size).toBe(0);
54
+ });
55
+ it('ignores actions whose scope is inactive', () => {
56
+ const entries = [
57
+ mkEntry({ id: 'x', scope: 'app', defaultShortcut: 'Ctrl+S' }),
58
+ ];
59
+ // No app loaded → app scope inactive.
60
+ const idx = buildTierIndex(entries, mkState());
61
+ expect(idx.app.size).toBe(0);
62
+ });
63
+ it('places array-scope action in every matching tier', () => {
64
+ const entries = [
65
+ mkEntry({ id: 'p', scope: ['home', 'app'], defaultShortcut: 'Mod+K' }),
66
+ ];
67
+ const state = mkState({
68
+ activeAppId: null, // home active
69
+ autostartShards: new Set(['shard.x']),
70
+ });
71
+ const idx = buildTierIndex(entries, state);
72
+ // Home is active; app is not (no app).
73
+ expect(idx.home.get('Ctrl+K')).toBe('p');
74
+ expect(idx.app.size).toBe(0);
75
+ });
76
+ it('applies user override to the indexed shortcut', () => {
77
+ const entries = [
78
+ mkEntry({ id: 'shard.x.save', scope: 'home', defaultShortcut: 'Ctrl+S' }),
79
+ ];
80
+ const idx = buildTierIndex(entries, mkState({
81
+ bindings: { 'shard.x.save': 'Ctrl+Shift+S' },
82
+ }));
83
+ expect(idx.home.has('Ctrl+S')).toBe(false);
84
+ expect(idx.home.get('Ctrl+Shift+S')).toBe('shard.x.save');
85
+ });
86
+ it('null override disables the shortcut entirely', () => {
87
+ const entries = [
88
+ mkEntry({ id: 'a', scope: 'home', defaultShortcut: 'Ctrl+S' }),
89
+ ];
90
+ const idx = buildTierIndex(entries, mkState({ bindings: { a: null } }));
91
+ expect(idx.home.size).toBe(0);
92
+ });
93
+ it('first-registered wins and warns on same-tier shortcut conflict', () => {
94
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
95
+ const entries = [
96
+ mkEntry({ id: 'first', scope: 'home', defaultShortcut: 'Ctrl+S' }),
97
+ mkEntry({ id: 'second', scope: 'home', defaultShortcut: 'Ctrl+S' }),
98
+ ];
99
+ const idx = buildTierIndex(entries, mkState());
100
+ expect(idx.home.get('Ctrl+S')).toBe('first');
101
+ expect(warn).toHaveBeenCalled();
102
+ warn.mockRestore();
103
+ });
104
+ });
105
+ const mkEnv = (overrides = {}) => (Object.assign({ target: null, shortcut: 'Ctrl+S', state: mkState(), entries: [], runAction: vi.fn() }, overrides));
106
+ describe('dispatchKeydown', () => {
107
+ it('returns null when no match', () => {
108
+ const env = mkEnv();
109
+ const result = dispatchKeydown(env);
110
+ expect(result).toBeNull();
111
+ expect(env.runAction).not.toHaveBeenCalled();
112
+ });
113
+ it('matches in innermost tier and runs the action', () => {
114
+ const runAction = vi.fn();
115
+ const entries = [mkEntry({ id: 'home.x', scope: 'home', defaultShortcut: 'Ctrl+S' })];
116
+ const env = mkEnv({ entries, runAction });
117
+ const result = dispatchKeydown(env);
118
+ expect(result).toBe('home.x');
119
+ expect(runAction).toHaveBeenCalledWith('home.x', expect.objectContaining({ invokedVia: 'keyboard' }));
120
+ });
121
+ it('element tier beats app tier on same shortcut', () => {
122
+ const runAction = vi.fn();
123
+ const entries = [
124
+ mkEntry({ id: 'el', scope: { element: 'orb' }, defaultShortcut: 'Ctrl+S' }),
125
+ mkEntry({ id: 'app', scope: 'app', defaultShortcut: 'Ctrl+S' }, '__sh3core__'),
126
+ ];
127
+ const state = mkState({
128
+ activeAppId: 'a',
129
+ activeAppRequiredShards: new Set(),
130
+ autostartShards: new Set(['__sh3core__']),
131
+ selection: { type: 'orb', ref: 1, ownerShardId: 's' },
132
+ });
133
+ const result = dispatchKeydown(mkEnv({ entries, runAction, state }));
134
+ expect(result).toBe('el');
135
+ });
136
+ it('blocks when target is <input> and allowInInputs is false', () => {
137
+ const inp = document.createElement('input');
138
+ const entries = [mkEntry({ id: 'x', scope: 'home', defaultShortcut: 'Ctrl+S' })];
139
+ const result = dispatchKeydown(mkEnv({ entries, target: inp }));
140
+ expect(result).toBeNull();
141
+ });
142
+ it('fires when target is <input> and allowInInputs is true', () => {
143
+ const inp = document.createElement('input');
144
+ const entries = [mkEntry({ id: 'x', scope: 'home', defaultShortcut: 'Ctrl+S', allowInInputs: true })];
145
+ const result = dispatchKeydown(mkEnv({ entries, target: inp }));
146
+ expect(result).toBe('x');
147
+ });
148
+ it('treats contenteditable like <input>', () => {
149
+ const div = document.createElement('div');
150
+ div.setAttribute('contenteditable', 'true');
151
+ const entries = [mkEntry({ id: 'x', scope: 'home', defaultShortcut: 'Ctrl+S' })];
152
+ const result = dispatchKeydown(mkEnv({ entries, target: div }));
153
+ expect(result).toBeNull();
154
+ });
155
+ });
@@ -0,0 +1,11 @@
1
+ export interface OpenContextMenuOpts {
2
+ x: number;
3
+ y: number;
4
+ }
5
+ export interface OpenPaletteOpts {
6
+ prefill?: string;
7
+ }
8
+ export declare function attachGlobalListeners(): void;
9
+ export declare function detachGlobalListeners(): void;
10
+ export declare function openContextMenu(opts: OpenContextMenuOpts): void;
11
+ export declare function openPalette(opts?: OpenPaletteOpts): void;
@@ -0,0 +1,180 @@
1
+ /*
2
+ * Document-level listener wiring for actions. Single keydown and single
3
+ * contextmenu listener, attached at shell boot (Task 18) and removed on
4
+ * shell teardown.
5
+ */
6
+ import { listActions } from './registry';
7
+ import { dispatchKeydown } from './dispatcher.svelte';
8
+ import { getLiveDispatcherState, setFocusedViewId, } from './state.svelte';
9
+ import { eventToShortcut } from './shortcuts';
10
+ import ContextMenu from './ContextMenu.svelte';
11
+ import { buildContextMenuModel } from './contextMenuModel';
12
+ import CommandPalette from './CommandPalette.svelte';
13
+ import { buildPaletteCandidates } from './paletteModel';
14
+ import { shell } from '../shellRuntime.svelte';
15
+ let attached = false;
16
+ function viewIdOfEl(el) {
17
+ var _a;
18
+ if (!el)
19
+ return null;
20
+ const host = el.closest('[data-sh3-view]');
21
+ return (_a = host === null || host === void 0 ? void 0 : host.getAttribute('data-sh3-view')) !== null && _a !== void 0 ? _a : null;
22
+ }
23
+ function runAction(actionId, ctx) {
24
+ const entry = listActions().find((e) => e.action.id === actionId);
25
+ if (!entry)
26
+ return;
27
+ try {
28
+ void entry.action.run(ctx);
29
+ }
30
+ catch (err) {
31
+ console.error(`[sh3] action "${actionId}" threw:`, err);
32
+ }
33
+ }
34
+ /**
35
+ * Invoke another action by id from within an action's `run`. Builds a fresh
36
+ * dispatch context using current live state with `invokedVia: 'programmatic'`.
37
+ * Called via `ctx.dispatch(id)` on ActionDispatchContext.
38
+ */
39
+ function chainedDispatch(actionId) {
40
+ var _a, _b;
41
+ const entry = listActions().find((e) => e.action.id === actionId);
42
+ if (!entry) {
43
+ console.warn(`[sh3] ctx.dispatch("${actionId}") — no action registered with that id.`);
44
+ return;
45
+ }
46
+ const state = getLiveDispatcherState();
47
+ runAction(actionId, {
48
+ action: { id: entry.action.id, label: entry.action.label },
49
+ appId: state.activeAppId,
50
+ viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
51
+ selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
52
+ invokedVia: 'programmatic',
53
+ dispatch: chainedDispatch,
54
+ });
55
+ }
56
+ function onFocusIn(ev) {
57
+ const id = viewIdOfEl(ev.target);
58
+ setFocusedViewId(id);
59
+ }
60
+ function onFocusOut(_ev) {
61
+ // Defer — a focusout is usually followed immediately by focusin on the next element.
62
+ queueMicrotask(() => {
63
+ const id = viewIdOfEl(document.activeElement);
64
+ setFocusedViewId(id);
65
+ });
66
+ }
67
+ function isNativeOptOut(target) {
68
+ if (!(target instanceof Element))
69
+ return false;
70
+ return target.closest('[data-sh3-context-menu="native"]') !== null;
71
+ }
72
+ function onContextMenu(ev) {
73
+ if (isNativeOptOut(ev.target))
74
+ return;
75
+ const entries = listActions();
76
+ const state = getLiveDispatcherState();
77
+ const model = buildContextMenuModel(entries, state);
78
+ if (model.tiers.length === 0)
79
+ return;
80
+ ev.preventDefault();
81
+ const handle = shell.popup.show(ContextMenu, { anchor: { x: ev.clientX, y: ev.clientY } }, {
82
+ model,
83
+ onInvoke: (id) => {
84
+ var _a, _b;
85
+ const entry = listActions().find((e) => e.action.id === id);
86
+ if (entry) {
87
+ try {
88
+ void entry.action.run({
89
+ action: { id, label: entry.action.label },
90
+ appId: state.activeAppId,
91
+ viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
92
+ selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
93
+ invokedVia: 'context-menu',
94
+ dispatch: chainedDispatch,
95
+ });
96
+ }
97
+ catch (err) {
98
+ console.error(`[sh3] context-menu action "${id}" threw:`, err);
99
+ }
100
+ }
101
+ },
102
+ onClose: () => handle.close(),
103
+ });
104
+ }
105
+ function onKeydown(ev) {
106
+ const shortcut = eventToShortcut(ev);
107
+ if (!shortcut)
108
+ return; // pure modifier press
109
+ const entries = listActions();
110
+ const state = getLiveDispatcherState();
111
+ const matched = dispatchKeydown({
112
+ target: ev.target,
113
+ shortcut,
114
+ state,
115
+ entries,
116
+ runAction,
117
+ dispatch: chainedDispatch,
118
+ });
119
+ if (matched)
120
+ ev.preventDefault();
121
+ }
122
+ export function attachGlobalListeners() {
123
+ if (attached)
124
+ return;
125
+ attached = true;
126
+ document.addEventListener('keydown', onKeydown);
127
+ document.addEventListener('focusin', onFocusIn);
128
+ document.addEventListener('focusout', onFocusOut);
129
+ document.addEventListener('contextmenu', onContextMenu);
130
+ }
131
+ export function detachGlobalListeners() {
132
+ if (!attached)
133
+ return;
134
+ attached = false;
135
+ document.removeEventListener('keydown', onKeydown);
136
+ document.removeEventListener('focusin', onFocusIn);
137
+ document.removeEventListener('focusout', onFocusOut);
138
+ document.removeEventListener('contextmenu', onContextMenu);
139
+ }
140
+ export function openContextMenu(opts) {
141
+ const fakeEvent = { target: null, clientX: opts.x, clientY: opts.y, preventDefault: () => { } };
142
+ onContextMenu(fakeEvent);
143
+ }
144
+ const RECENCY_CAP = 20;
145
+ let recency = [];
146
+ function recordUse(id) {
147
+ recency = [id, ...recency.filter((x) => x !== id)].slice(0, RECENCY_CAP);
148
+ }
149
+ export function openPalette(opts) {
150
+ var _a;
151
+ const entries = listActions();
152
+ const state = getLiveDispatcherState();
153
+ const candidates = buildPaletteCandidates(entries, state);
154
+ const handle = shell.modal.open(CommandPalette, {
155
+ candidates,
156
+ recency,
157
+ prefill: (_a = opts === null || opts === void 0 ? void 0 : opts.prefill) !== null && _a !== void 0 ? _a : '',
158
+ onInvoke: (id) => {
159
+ var _a, _b;
160
+ const entry = listActions().find((e) => e.action.id === id);
161
+ if (!entry)
162
+ return;
163
+ recordUse(id);
164
+ try {
165
+ void entry.action.run({
166
+ action: { id, label: entry.action.label },
167
+ appId: state.activeAppId,
168
+ viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
169
+ selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
170
+ invokedVia: 'palette',
171
+ dispatch: chainedDispatch,
172
+ });
173
+ }
174
+ catch (err) {
175
+ console.error(`[sh3] palette action "${id}" threw:`, err);
176
+ }
177
+ },
178
+ onClose: () => handle.close(),
179
+ }, { dismissOnBackdrop: true });
180
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { attachGlobalListeners, detachGlobalListeners, openPalette } from './listeners';
3
+ import { registerAction, __resetActionsRegistryForTest } from './registry';
4
+ import { __resetContributionsForTest } from '../contributions/registry';
5
+ import { __resetDispatcherStateForTest, setActiveApp, setMountedViewIds, setFocusedViewId, } from './state.svelte';
6
+ import { registerLayerRoot, unregisterLayerRoot } from '../overlays/roots';
7
+ import { __resetPopupManagerForTest } from '../overlays/popup';
8
+ import { modalManager } from '../overlays/modal';
9
+ function pressKey(key, opts = {}, target) {
10
+ const ev = new KeyboardEvent('keydown', Object.assign({ key, bubbles: true, cancelable: true }, opts));
11
+ if (target) {
12
+ Object.defineProperty(ev, 'target', { value: target });
13
+ }
14
+ return document.dispatchEvent(ev);
15
+ }
16
+ describe('global keydown listener', () => {
17
+ beforeEach(() => {
18
+ __resetContributionsForTest();
19
+ __resetActionsRegistryForTest();
20
+ __resetDispatcherStateForTest();
21
+ attachGlobalListeners();
22
+ });
23
+ afterEach(() => {
24
+ detachGlobalListeners();
25
+ });
26
+ it('invokes a matched home-scope action and preventDefault()s', () => {
27
+ const run = vi.fn();
28
+ registerAction({ id: 'a.x', label: 'X', scope: 'home', defaultShortcut: 'Ctrl+S', run }, 'a');
29
+ const defaulted = pressKey('s', { ctrlKey: true });
30
+ expect(run).toHaveBeenCalled();
31
+ expect(defaulted).toBe(false); // preventDefault called → dispatch returns false
32
+ });
33
+ it('lets unmatched keypresses bubble', () => {
34
+ const defaulted = pressKey('s', { ctrlKey: true });
35
+ expect(defaulted).toBe(true);
36
+ });
37
+ it('blocks when target is <input> and allowInInputs is absent', () => {
38
+ const run = vi.fn();
39
+ registerAction({ id: 'a.x', label: 'X', scope: 'home', defaultShortcut: 'Ctrl+S', run }, 'a');
40
+ const input = document.createElement('input');
41
+ document.body.appendChild(input);
42
+ pressKey('s', { ctrlKey: true }, input);
43
+ expect(run).not.toHaveBeenCalled();
44
+ input.remove();
45
+ });
46
+ it('reads focusedViewId from data-sh3-view ancestor of activeElement', () => {
47
+ const run = vi.fn();
48
+ registerAction({ id: 'a.x', label: 'X', scope: 'focus:editor', defaultShortcut: 'Ctrl+S', run }, 'a');
49
+ setActiveApp('app.a', new Set(['a']));
50
+ setMountedViewIds(new Set(['editor']));
51
+ const wrap = document.createElement('div');
52
+ wrap.setAttribute('data-sh3-view', 'editor');
53
+ const btn = document.createElement('button');
54
+ wrap.appendChild(btn);
55
+ document.body.appendChild(wrap);
56
+ btn.focus();
57
+ // Manually set focusedViewId in case happy-dom doesn't fire focusin synchronously
58
+ setFocusedViewId('editor');
59
+ pressKey('s', { ctrlKey: true }, btn);
60
+ expect(run).toHaveBeenCalled();
61
+ wrap.remove();
62
+ });
63
+ });
64
+ describe('global contextmenu listener', () => {
65
+ let popupLayerRoot;
66
+ beforeEach(() => {
67
+ vi.stubGlobal('innerWidth', 2000);
68
+ vi.stubGlobal('innerHeight', 2000);
69
+ popupLayerRoot = document.createElement('div');
70
+ popupLayerRoot.style.position = 'relative';
71
+ document.body.appendChild(popupLayerRoot);
72
+ registerLayerRoot('popup', popupLayerRoot);
73
+ __resetContributionsForTest();
74
+ __resetActionsRegistryForTest();
75
+ __resetDispatcherStateForTest();
76
+ attachGlobalListeners();
77
+ });
78
+ afterEach(() => {
79
+ detachGlobalListeners();
80
+ __resetPopupManagerForTest();
81
+ unregisterLayerRoot('popup');
82
+ popupLayerRoot.remove();
83
+ vi.unstubAllGlobals();
84
+ });
85
+ it('opens popup on contextmenu and preventDefault()s native', () => {
86
+ registerAction({ id: 'a.x', label: 'Dup', scope: 'home', contextItem: true, run: () => { } }, 'a');
87
+ const target = document.createElement('div');
88
+ document.body.appendChild(target);
89
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
90
+ Object.defineProperty(ev, 'target', { value: target });
91
+ const def = target.dispatchEvent(ev);
92
+ expect(def).toBe(false); // preventDefault
93
+ expect(document.querySelector('.sh3-context-menu')).not.toBeNull();
94
+ target.remove();
95
+ });
96
+ it('opts out via data-sh3-context-menu="native"', () => {
97
+ registerAction({ id: 'a.x', label: 'Dup', scope: 'home', contextItem: true, run: () => { } }, 'a');
98
+ const target = document.createElement('div');
99
+ target.setAttribute('data-sh3-context-menu', 'native');
100
+ document.body.appendChild(target);
101
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
102
+ Object.defineProperty(ev, 'target', { value: target });
103
+ const def = target.dispatchEvent(ev);
104
+ expect(def).toBe(true); // native preserved
105
+ expect(document.querySelector('.sh3-context-menu')).toBeNull();
106
+ target.remove();
107
+ });
108
+ it('does not open when no contextItem actions are active', () => {
109
+ registerAction({ id: 'a.x', label: 'Save', scope: 'home', contextItem: false, run: () => { } }, 'a');
110
+ const target = document.createElement('div');
111
+ document.body.appendChild(target);
112
+ const ev = new MouseEvent('contextmenu', { clientX: 10, clientY: 20, bubbles: true, cancelable: true });
113
+ Object.defineProperty(ev, 'target', { value: target });
114
+ const def = target.dispatchEvent(ev);
115
+ expect(def).toBe(true); // no menu → native preserved
116
+ target.remove();
117
+ });
118
+ });
119
+ describe('command palette', () => {
120
+ let modalLayerRoot;
121
+ beforeEach(() => {
122
+ modalLayerRoot = document.createElement('div');
123
+ modalLayerRoot.style.position = 'relative';
124
+ document.body.appendChild(modalLayerRoot);
125
+ registerLayerRoot('modal', modalLayerRoot);
126
+ __resetContributionsForTest();
127
+ __resetActionsRegistryForTest();
128
+ __resetDispatcherStateForTest();
129
+ attachGlobalListeners();
130
+ });
131
+ afterEach(() => {
132
+ detachGlobalListeners();
133
+ modalManager.closeAll();
134
+ unregisterLayerRoot('modal');
135
+ modalLayerRoot.remove();
136
+ });
137
+ it('openPalette mounts the CommandPalette modal', async () => {
138
+ registerAction({ id: 'a.x', label: 'Duplicate Orb', scope: 'home', run: () => { } }, 'a');
139
+ openPalette();
140
+ await Promise.resolve();
141
+ expect(document.querySelector('.sh3-palette')).not.toBeNull();
142
+ });
143
+ it('palette populates from active actions', async () => {
144
+ registerAction({ id: 'a.y', label: 'Rename', scope: 'home', run: () => { } }, 'a');
145
+ openPalette();
146
+ await Promise.resolve();
147
+ expect(document.querySelector('.sh3-palette-item')).not.toBeNull();
148
+ });
149
+ });
@@ -0,0 +1,11 @@
1
+ export interface PaletteCandidate {
2
+ id: string;
3
+ label: string;
4
+ shortcut: string | null;
5
+ scopeBadge: string | null;
6
+ }
7
+ export interface RankedCandidate extends PaletteCandidate {
8
+ score: number;
9
+ }
10
+ export declare function scoreMatch(label: string, query: string): number | null;
11
+ export declare function rankPaletteEntries(candidates: PaletteCandidate[], query: string, recency: string[]): RankedCandidate[];
@@ -0,0 +1,49 @@
1
+ /*
2
+ * Fuzzy scorer for the command palette. Subsequence match with
3
+ * prefix-bonus and tight-run scoring. Session-local recency ring
4
+ * breaks ties.
5
+ */
6
+ export function scoreMatch(label, query) {
7
+ if (!query)
8
+ return 0;
9
+ const L = label.toLowerCase();
10
+ const Q = query.toLowerCase();
11
+ let i = 0, j = 0;
12
+ let score = 0;
13
+ let runLen = 0;
14
+ let prevMatchIdx = -2;
15
+ while (i < L.length && j < Q.length) {
16
+ if (L[i] === Q[j]) {
17
+ const prefixBonus = i === 0 ? 5 : 0;
18
+ const runBonus = i === prevMatchIdx + 1 ? ++runLen : (runLen = 1);
19
+ score += 1 + prefixBonus + runBonus;
20
+ prevMatchIdx = i;
21
+ j++;
22
+ }
23
+ else {
24
+ runLen = 0;
25
+ }
26
+ i++;
27
+ }
28
+ return j === Q.length ? score : null;
29
+ }
30
+ export function rankPaletteEntries(candidates, query, recency) {
31
+ const ranked = [];
32
+ for (const c of candidates) {
33
+ const s = scoreMatch(c.label, query);
34
+ if (s === null)
35
+ continue;
36
+ ranked.push(Object.assign(Object.assign({}, c), { score: s }));
37
+ }
38
+ const recencyIdx = new Map();
39
+ recency.forEach((id, i) => recencyIdx.set(id, recency.length - i));
40
+ ranked.sort((a, b) => {
41
+ var _a, _b;
42
+ if (a.score !== b.score)
43
+ return b.score - a.score;
44
+ const ra = (_a = recencyIdx.get(a.id)) !== null && _a !== void 0 ? _a : 0;
45
+ const rb = (_b = recencyIdx.get(b.id)) !== null && _b !== void 0 ? _b : 0;
46
+ return rb - ra;
47
+ });
48
+ return ranked;
49
+ }