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,80 @@
1
+ <script lang="ts">
2
+ /*
3
+ * BrandSlot — top-bar context indicator. Three states:
4
+ * - 'brand' → renders <span>SH3</span>
5
+ * - 'app' → renders <span>{label}</span>
6
+ * - 'breadcrumb' → renders SH3 + separator + <button>{label}</button>
7
+ *
8
+ * State derives from (activeAppId, breadcrumbAppId). Click on the
9
+ * breadcrumb's app button re-launches the app (existing handler).
10
+ */
11
+ import { getLiveDispatcherState } from './actions/state.svelte';
12
+ import { launchApp } from './apps/lifecycle';
13
+ import { getBreadcrumbAppId, getRegisteredApp } from './apps/registry.svelte';
14
+
15
+ const activeAppId = $derived(getLiveDispatcherState().activeAppId);
16
+ const breadcrumbId = $derived(getBreadcrumbAppId());
17
+
18
+ const activeLabel = $derived(
19
+ activeAppId ? getRegisteredApp(activeAppId)?.manifest.label ?? activeAppId : null,
20
+ );
21
+ const breadcrumbLabel = $derived(
22
+ breadcrumbId ? getRegisteredApp(breadcrumbId)?.manifest.label ?? breadcrumbId : null,
23
+ );
24
+
25
+ const mode: 'brand' | 'app' | 'breadcrumb' = $derived.by(() => {
26
+ if (activeAppId) return 'app';
27
+ if (breadcrumbId) return 'breadcrumb';
28
+ return 'brand';
29
+ });
30
+
31
+ function reopen() {
32
+ if (breadcrumbId) void launchApp(breadcrumbId);
33
+ }
34
+ </script>
35
+
36
+ <div class="sh3-brand-slot">
37
+ {#if mode === 'brand'}
38
+ <span class="sh3-brand">SH3</span>
39
+ {:else if mode === 'app'}
40
+ <span class="sh3-brand sh3-brand-app">{activeLabel}</span>
41
+ {:else}
42
+ <span class="sh3-brand">SH3</span>
43
+ <span class="sh3-brand-sep" aria-hidden="true">›</span>
44
+ <button type="button" class="sh3-brand-crumb" onclick={reopen}>
45
+ {breadcrumbLabel}
46
+ </button>
47
+ {/if}
48
+ </div>
49
+
50
+ <style>
51
+ .sh3-brand-slot {
52
+ display: inline-flex;
53
+ align-items: center;
54
+ gap: 4px;
55
+ }
56
+ .sh3-brand {
57
+ font-weight: 600;
58
+ color: var(--shell-accent);
59
+ letter-spacing: 0.5px;
60
+ }
61
+ .sh3-brand-app {
62
+ color: var(--shell-fg);
63
+ }
64
+ .sh3-brand-sep {
65
+ color: var(--shell-fg-muted);
66
+ margin: 0 4px;
67
+ }
68
+ .sh3-brand-crumb {
69
+ background: transparent;
70
+ border: 0;
71
+ color: var(--shell-fg);
72
+ font: inherit;
73
+ cursor: pointer;
74
+ padding: 2px 6px;
75
+ border-radius: var(--shell-radius-sm, 3px);
76
+ }
77
+ .sh3-brand-crumb:hover {
78
+ background: var(--shell-bg-elevated);
79
+ }
80
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const BrandSlot: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type BrandSlot = ReturnType<typeof BrandSlot>;
3
+ export default BrandSlot;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mount, unmount, tick } from 'svelte';
3
+ // Mock launchApp so we can assert the click handler invokes it.
4
+ // The real launchApp does heavy lifecycle work; we only care that BrandSlot
5
+ // calls it with the right argument.
6
+ vi.mock('./apps/lifecycle', async (orig) => {
7
+ const actual = await orig();
8
+ return Object.assign(Object.assign({}, actual), { launchApp: vi.fn(actual.launchApp) });
9
+ });
10
+ import BrandSlot from './BrandSlot.svelte';
11
+ import { setActiveApp, __resetDispatcherStateForTest } from './actions/state.svelte';
12
+ import { registerApp, __resetAppRegistryForTest, __resetBreadcrumbForTest, breadcrumbApp, } from './apps/registry.svelte';
13
+ import { launchApp } from './apps/lifecycle';
14
+ let host;
15
+ let cmp = null;
16
+ function makeApp(id, label) {
17
+ return {
18
+ manifest: {
19
+ id, label, version: '0.0.0',
20
+ requiredShards: [], layoutVersion: 1,
21
+ },
22
+ initialLayout: { type: 'leaf', viewId: 'x:v' },
23
+ };
24
+ }
25
+ beforeEach(() => {
26
+ host = document.createElement('div');
27
+ document.body.appendChild(host);
28
+ __resetBreadcrumbForTest();
29
+ __resetDispatcherStateForTest();
30
+ });
31
+ afterEach(() => {
32
+ if (cmp) {
33
+ unmount(cmp);
34
+ cmp = null;
35
+ }
36
+ host.remove();
37
+ __resetAppRegistryForTest();
38
+ __resetDispatcherStateForTest();
39
+ vi.clearAllMocks();
40
+ });
41
+ describe('BrandSlot', () => {
42
+ it('renders SH3 when no app has launched this session', async () => {
43
+ var _a;
44
+ cmp = mount(BrandSlot, { target: host, props: {} });
45
+ await tick();
46
+ expect((_a = host.textContent) === null || _a === void 0 ? void 0 : _a.trim()).toBe('SH3');
47
+ expect(host.querySelector('button')).toBeNull();
48
+ });
49
+ it('renders [App Name] when an app is currently active', async () => {
50
+ registerApp(makeApp('app.a', 'My App'));
51
+ breadcrumbApp.id = 'app.a';
52
+ setActiveApp('app.a', new Set());
53
+ cmp = mount(BrandSlot, { target: host, props: {} });
54
+ await tick();
55
+ expect(host.textContent).toContain('My App');
56
+ expect(host.textContent).not.toContain('SH3');
57
+ });
58
+ it('renders "SH3 > [App Name]" with the app portion clickable when at home with prior app', async () => {
59
+ registerApp(makeApp('app.a', 'My App'));
60
+ breadcrumbApp.id = 'app.a';
61
+ setActiveApp(null, new Set());
62
+ cmp = mount(BrandSlot, { target: host, props: {} });
63
+ await tick();
64
+ expect(host.textContent).toMatch(/SH3.*My App/);
65
+ const btn = host.querySelector('button');
66
+ expect(btn).not.toBeNull();
67
+ expect(btn.textContent).toContain('My App');
68
+ btn.click();
69
+ expect(launchApp).toHaveBeenCalledWith('app.a');
70
+ });
71
+ });
package/dist/Shell.svelte CHANGED
@@ -28,6 +28,8 @@
28
28
  import GuestBanner from './auth/GuestBanner.svelte';
29
29
  import ConsentDialog from './keys/ConsentDialog.svelte';
30
30
  import { startServerSideStream } from './keys/revocation-bus.svelte';
31
+ import BrandSlot from './BrandSlot.svelte';
32
+ import MenuBar from './actions/MenuBar.svelte';
31
33
 
32
34
  const authenticated = $derived(isAuthenticated());
33
35
  const user = $derived(getUser());
@@ -110,7 +112,8 @@
110
112
  <use href="{iconsUrl}#house" />
111
113
  </svg>
112
114
  </button>
113
- <span class="shell-tabbar-brand">SH3</span>
115
+ <BrandSlot />
116
+ <MenuBar />
114
117
  {#if authenticated && user}
115
118
  <div class="shell-tabbar-user">
116
119
  <span class="shell-tabbar-user-name">{user.displayName}</span>
@@ -185,7 +188,8 @@
185
188
  }
186
189
 
187
190
  .shell-tabbar {
188
- display: flex;
191
+ display: grid;
192
+ grid-template-columns: auto auto 1fr auto;
189
193
  align-items: center;
190
194
  gap: var(--shell-pad-md);
191
195
  padding: 0 var(--shell-pad-md);
@@ -193,11 +197,6 @@
193
197
  border-bottom: 1px solid var(--shell-border);
194
198
  user-select: none;
195
199
  }
196
- .shell-tabbar-brand {
197
- font-weight: 600;
198
- color: var(--shell-accent);
199
- letter-spacing: 0.5px;
200
- }
201
200
 
202
201
  .shell-content {
203
202
  position: relative;
@@ -258,7 +257,6 @@
258
257
  display: flex;
259
258
  align-items: center;
260
259
  gap: 6px;
261
- margin-left: auto;
262
260
  }
263
261
  .shell-tabbar-user-name {
264
262
  font-size: 12px;
@@ -269,10 +267,10 @@
269
267
  font-weight: 700;
270
268
  text-transform: uppercase;
271
269
  letter-spacing: 0.08em;
272
- color: #fff;
270
+ color: var(--shell-fg-on-accent);
273
271
  background: var(--shell-accent);
274
272
  padding: 1px 6px;
275
- border-radius: 6px;
273
+ border-radius: var(--shell-radius-md);
276
274
  }
277
275
  .shell-tabbar-signout {
278
276
  display: flex;
@@ -0,0 +1,105 @@
1
+ <script lang="ts" module>
2
+ import type { MenuBarItem } from './menuBarModel';
3
+
4
+ export interface ActionPanelSection {
5
+ id: string;
6
+ items: MenuBarItem[];
7
+ }
8
+ </script>
9
+
10
+ <script lang="ts">
11
+ /*
12
+ * ActionPanel — shared dropdown body for action lists. Renders sections
13
+ * (each a group of items) with separators between them. Owns: row
14
+ * rendering, hover/focus state, keyboard nav, click-dispatch. Does NOT
15
+ * own: positioning, backdrop, the popover surface itself — those stay
16
+ * with the consumer (ContextMenu, MenuButton, etc.).
17
+ */
18
+
19
+ let { sections, onInvoke, onDismiss }: {
20
+ sections: ActionPanelSection[];
21
+ onInvoke: (id: string) => void;
22
+ onDismiss: () => void;
23
+ } = $props();
24
+
25
+ const flatItems: MenuBarItem[] = $derived(sections.flatMap((s) => s.items));
26
+ let cursor = $state(0);
27
+
28
+ function onKeydown(ev: KeyboardEvent) {
29
+ if (ev.key === 'ArrowDown') {
30
+ cursor = (cursor + 1) % flatItems.length;
31
+ ev.preventDefault();
32
+ } else if (ev.key === 'ArrowUp') {
33
+ cursor = (cursor - 1 + flatItems.length) % flatItems.length;
34
+ ev.preventDefault();
35
+ } else if (ev.key === 'Enter') {
36
+ if (flatItems[cursor]) {
37
+ onInvoke(flatItems[cursor].id);
38
+ onDismiss();
39
+ }
40
+ ev.preventDefault();
41
+ } else if (ev.key === 'Escape') {
42
+ onDismiss();
43
+ ev.preventDefault();
44
+ } else if (ev.key.length === 1) {
45
+ const q = ev.key.toLowerCase();
46
+ const start = (cursor + 1) % flatItems.length;
47
+ for (let i = 0; i < flatItems.length; i++) {
48
+ const idx = (start + i) % flatItems.length;
49
+ if (flatItems[idx].label.toLowerCase().startsWith(q)) {
50
+ cursor = idx;
51
+ break;
52
+ }
53
+ }
54
+ }
55
+ }
56
+ </script>
57
+
58
+ <!-- svelte-ignore a11y_autofocus -->
59
+ <div class="sh3-context-menu" role="menu" tabindex="0" onkeydown={onKeydown} autofocus>
60
+ {#each sections as section, sIdx (section.id)}
61
+ {#if sIdx > 0}<div class="sh3-ctx-sep" role="separator"></div>{/if}
62
+ {#each section.items as item (item.id)}
63
+ {@const globalIdx = flatItems.indexOf(item)}
64
+ <button
65
+ class="sh3-ctx-item"
66
+ class:sh3-ctx-active={globalIdx === cursor}
67
+ role="menuitem"
68
+ onpointerenter={() => { cursor = globalIdx; }}
69
+ onclick={() => { onInvoke(item.id); onDismiss(); }}
70
+ >
71
+ <span class="sh3-ctx-label">{item.label}</span>
72
+ {#if item.shortcut}<span class="sh3-ctx-shortcut">{item.shortcut}</span>{/if}
73
+ </button>
74
+ {/each}
75
+ {/each}
76
+ </div>
77
+
78
+ <style>
79
+ .sh3-context-menu {
80
+ min-width: 200px;
81
+ background: var(--shell-bg-elevated, #222);
82
+ color: var(--shell-fg, #eee);
83
+ border-radius: 4px;
84
+ padding: 4px 0;
85
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
86
+ outline: none;
87
+ }
88
+ .sh3-ctx-item {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 16px;
92
+ width: 100%;
93
+ padding: 4px 10px;
94
+ background: none;
95
+ border: 0;
96
+ text-align: left;
97
+ color: inherit;
98
+ cursor: default;
99
+ font: inherit;
100
+ }
101
+ .sh3-ctx-active { background: var(--shell-accent, #6ea8fe); color: var(--shell-fg,#e4e6eb); }
102
+ .sh3-ctx-label { flex: 1; }
103
+ .sh3-ctx-shortcut { opacity: 0.6; font-size: 0.9em; }
104
+ .sh3-ctx-sep { height: 1px; background: rgba(255, 255, 255, 0.1); margin: 4px 0; }
105
+ </style>
@@ -0,0 +1,13 @@
1
+ import type { MenuBarItem } from './menuBarModel';
2
+ export interface ActionPanelSection {
3
+ id: string;
4
+ items: MenuBarItem[];
5
+ }
6
+ type $$ComponentProps = {
7
+ sections: ActionPanelSection[];
8
+ onInvoke: (id: string) => void;
9
+ onDismiss: () => void;
10
+ };
11
+ declare const ActionPanel: import("svelte").Component<$$ComponentProps, {}, "">;
12
+ type ActionPanel = ReturnType<typeof ActionPanel>;
13
+ export default ActionPanel;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { mount, unmount, tick } from 'svelte';
3
+ import ActionPanel from './ActionPanel.svelte';
4
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
+ function mountPanel(props) {
6
+ const el = document.createElement('div');
7
+ document.body.appendChild(el);
8
+ const cmp = mount(ActionPanel, { target: el, props });
9
+ return {
10
+ el,
11
+ cmp,
12
+ cleanup: () => { unmount(cmp); el.remove(); },
13
+ };
14
+ }
15
+ const baseProps = (overrides = {}) => (Object.assign({ sections: [
16
+ {
17
+ id: 'group:edit',
18
+ items: [
19
+ { id: 'copy', label: 'Copy', shortcut: 'Ctrl+C', group: 'edit', icon: undefined },
20
+ { id: 'paste', label: 'Paste', shortcut: 'Ctrl+V', group: 'edit', icon: undefined },
21
+ ],
22
+ },
23
+ ], onInvoke: vi.fn(), onDismiss: vi.fn() }, overrides));
24
+ describe('ActionPanel', () => {
25
+ let panel;
26
+ afterEach(() => panel === null || panel === void 0 ? void 0 : panel.cleanup());
27
+ it('renders one button per item', () => {
28
+ panel = mountPanel(baseProps());
29
+ expect(panel.el.querySelectorAll('[role="menuitem"]')).toHaveLength(2);
30
+ });
31
+ it('renders shortcut hint when provided, omits when null', () => {
32
+ panel = mountPanel(baseProps({
33
+ sections: [{
34
+ id: 'g',
35
+ items: [
36
+ { id: 'a', label: 'A', shortcut: 'Ctrl+A', group: '', icon: undefined },
37
+ { id: 'b', label: 'B', shortcut: null, group: '', icon: undefined },
38
+ ],
39
+ }],
40
+ }));
41
+ const shortcuts = panel.el.querySelectorAll('.sh3-ctx-shortcut');
42
+ expect(shortcuts).toHaveLength(1);
43
+ expect(shortcuts[0].textContent).toBe('Ctrl+A');
44
+ });
45
+ it('inserts a separator between distinct sections', () => {
46
+ panel = mountPanel(baseProps({
47
+ sections: [
48
+ { id: 'g1', items: [{ id: 'a', label: 'A', shortcut: null, group: 'g1', icon: undefined }] },
49
+ { id: 'g2', items: [{ id: 'b', label: 'B', shortcut: null, group: 'g2', icon: undefined }] },
50
+ ],
51
+ }));
52
+ expect(panel.el.querySelectorAll('[role="separator"]')).toHaveLength(1);
53
+ });
54
+ it('click on an item calls onInvoke with its id and onDismiss', () => {
55
+ const onInvoke = vi.fn();
56
+ const onDismiss = vi.fn();
57
+ panel = mountPanel(baseProps({ onInvoke, onDismiss }));
58
+ const items = panel.el.querySelectorAll('[role="menuitem"]');
59
+ items[1].click();
60
+ expect(onInvoke).toHaveBeenCalledWith('paste');
61
+ expect(onDismiss).toHaveBeenCalled();
62
+ });
63
+ it('ArrowDown moves the cursor and Enter dispatches the focused item', async () => {
64
+ const onInvoke = vi.fn();
65
+ panel = mountPanel(baseProps({ onInvoke }));
66
+ const root = panel.el.querySelector('[role="menu"]');
67
+ root.focus();
68
+ root.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
69
+ await tick();
70
+ root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
71
+ expect(onInvoke).toHaveBeenCalledWith('paste');
72
+ });
73
+ it('Escape calls onDismiss', () => {
74
+ const onDismiss = vi.fn();
75
+ panel = mountPanel(baseProps({ onDismiss }));
76
+ const root = panel.el.querySelector('[role="menu"]');
77
+ root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
78
+ expect(onDismiss).toHaveBeenCalled();
79
+ });
80
+ });
@@ -1,11 +1,12 @@
1
1
  <script lang="ts">
2
2
  /*
3
- * ContextMenu — popup-rendered, tier-grouped action list. Receives
4
- * a pre-built model and an onInvoke callback. Dismiss and anchoring
5
- * are handled by PopupFrame / popupManager this component only
6
- * renders the list and handles keyboard navigation within it.
3
+ * ContextMenu — popup-rendered, tier-grouped action list. Receives a
4
+ * pre-built model and an onInvoke callback. Dismiss and anchoring are
5
+ * handled by PopupFrame / popupManager. The list rendering, keyboard
6
+ * nav, and click dispatch are delegated to ActionPanel.
7
7
  */
8
- import type { ContextMenuModel, MenuItem } from './contextMenuModel';
8
+ import type { ContextMenuModel } from './contextMenuModel';
9
+ import ActionPanel, { type ActionPanelSection } from './ActionPanel.svelte';
9
10
 
10
11
  let { model, onInvoke, onClose }: {
11
12
  model: ContextMenuModel;
@@ -13,85 +14,16 @@
13
14
  onClose: () => void;
14
15
  } = $props();
15
16
 
16
- const flatItems: MenuItem[] = $derived(model.tiers.flatMap((t) => t.items));
17
- let cursor = $state(0);
18
-
19
- function onKeydown(ev: KeyboardEvent) {
20
- if (ev.key === 'ArrowDown') {
21
- cursor = (cursor + 1) % flatItems.length;
22
- ev.preventDefault();
23
- } else if (ev.key === 'ArrowUp') {
24
- cursor = (cursor - 1 + flatItems.length) % flatItems.length;
25
- ev.preventDefault();
26
- } else if (ev.key === 'Enter') {
27
- if (flatItems[cursor]) {
28
- onInvoke(flatItems[cursor].id);
29
- onClose();
30
- }
31
- ev.preventDefault();
32
- } else if (ev.key === 'Escape') {
33
- onClose();
34
- ev.preventDefault();
35
- } else if (ev.key.length === 1) {
36
- // type-ahead: jump to next item whose label starts with the pressed character
37
- const q = ev.key.toLowerCase();
38
- const start = (cursor + 1) % flatItems.length;
39
- for (let i = 0; i < flatItems.length; i++) {
40
- const idx = (start + i) % flatItems.length;
41
- if (flatItems[idx].label.toLowerCase().startsWith(q)) {
42
- cursor = idx;
43
- break;
44
- }
45
- }
46
- }
47
- }
17
+ // Adapt ContextMenuModel.tiers ActionPanel.sections. The MenuItem
18
+ // shape from contextMenuModel already carries id/label/shortcut/group;
19
+ // ActionPanel expects an `icon?` field — context menu items don't carry
20
+ // one today, so it stays undefined.
21
+ const sections: ActionPanelSection[] = $derived(
22
+ model.tiers.map((t) => ({
23
+ id: `tier:${t.tier}`,
24
+ items: t.items.map((i) => ({ ...i, icon: undefined })),
25
+ })),
26
+ );
48
27
  </script>
49
28
 
50
- <!-- svelte-ignore a11y_autofocus -->
51
- <div class="sh3-context-menu" role="menu" tabindex="0" onkeydown={onKeydown} autofocus>
52
- {#each model.tiers as tier, tIdx (tier.tier)}
53
- {#if tIdx > 0}<div class="sh3-ctx-sep" role="separator"></div>{/if}
54
- {#each tier.items as item (item.id)}
55
- {@const globalIdx = flatItems.indexOf(item)}
56
- <button
57
- class="sh3-ctx-item"
58
- class:sh3-ctx-active={globalIdx === cursor}
59
- role="menuitem"
60
- onpointerenter={() => { cursor = globalIdx; }}
61
- onclick={() => { onInvoke(item.id); onClose(); }}
62
- >
63
- <span class="sh3-ctx-label">{item.label}</span>
64
- {#if item.shortcut}<span class="sh3-ctx-shortcut">{item.shortcut}</span>{/if}
65
- </button>
66
- {/each}
67
- {/each}
68
- </div>
69
-
70
- <style>
71
- .sh3-context-menu {
72
- min-width: 200px;
73
- background: var(--shell-bg-elevated, #222);
74
- color: var(--shell-fg, #eee);
75
- border-radius: 4px;
76
- padding: 4px 0;
77
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
78
- outline: none;
79
- }
80
- .sh3-ctx-item {
81
- display: flex;
82
- align-items: center;
83
- gap: 16px;
84
- width: 100%;
85
- padding: 4px 10px;
86
- background: none;
87
- border: 0;
88
- text-align: left;
89
- color: inherit;
90
- cursor: default;
91
- font: inherit;
92
- }
93
- .sh3-ctx-active { background: var(--shell-accent, #6ea8fe); color: var(--shell-fg,#e4e6eb); }
94
- .sh3-ctx-label { flex: 1; }
95
- .sh3-ctx-shortcut { opacity: 0.6; font-size: 0.9em; }
96
- .sh3-ctx-sep { height: 1px; background: rgba(255, 255, 255, 0.1); margin: 4px 0; }
97
- </style>
29
+ <ActionPanel {sections} {onInvoke} onDismiss={onClose} />
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ /*
3
+ * MenuBar — top-bar row of menu containers. Renders one MenuButton per
4
+ * container in the active app's manifest.menus (or DEFAULT_MENU_CONTAINERS
5
+ * fallback). Skips containers whose resolved item list is empty.
6
+ * Renders nothing when no app is active.
7
+ */
8
+ import MenuButton from './MenuButton.svelte';
9
+ import { resolveMenuContainers, resolveMenuItems } from './menuBarModel';
10
+ import { getLiveDispatcherState } from './state.svelte';
11
+ import { listActions } from './registry';
12
+ import { getRegisteredApp } from '../apps/registry.svelte';
13
+
14
+ // Snapshot the live dispatcher state on each derive — `getLiveDispatcherState`
15
+ // reads from $state internals, so this re-derives whenever activeAppId, focus,
16
+ // bindings, or selection change. Mirrors the listeners.ts call pattern.
17
+ const state = $derived(getLiveDispatcherState());
18
+ const activeAppId = $derived(state.activeAppId);
19
+
20
+ const declaredMenus = $derived.by(() => {
21
+ if (!activeAppId) return undefined;
22
+ return getRegisteredApp(activeAppId)?.manifest.menus;
23
+ });
24
+
25
+ const containers = $derived(resolveMenuContainers(activeAppId, declaredMenus));
26
+
27
+ // Per-container item lists. Recomputes whenever actions or scope state
28
+ // change — same reactivity model as the palette/context menu.
29
+ const containerItems = $derived.by(() => {
30
+ const out: { container: typeof containers[number]; items: ReturnType<typeof resolveMenuItems> }[] = [];
31
+ const entries = listActions();
32
+ for (const c of containers) {
33
+ const items = resolveMenuItems(entries, state, c.id);
34
+ if (items.length > 0) out.push({ container: c, items });
35
+ }
36
+ return out;
37
+ });
38
+ </script>
39
+
40
+ <!--
41
+ Always render the wrapper so it reliably occupies its 1fr grid cell
42
+ in the shell tabbar (and pushes user chrome to the rightmost column).
43
+ Empty when no app is active or no container has visible items.
44
+ -->
45
+ <div class="sh3-menubar" role="menubar">
46
+ {#each containerItems as { container, items } (container.id)}
47
+ <MenuButton {container} {items} />
48
+ {/each}
49
+ </div>
50
+
51
+ <style>
52
+ .sh3-menubar {
53
+ display: inline-flex;
54
+ align-items: center;
55
+ height: 100%;
56
+ }
57
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const MenuBar: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type MenuBar = ReturnType<typeof MenuBar>;
3
+ export default MenuBar;
@@ -0,0 +1 @@
1
+ export {};