sh3-core 0.17.0 → 0.17.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 (77) hide show
  1. package/dist/Sh3.svelte +48 -35
  2. package/dist/__screenshots__/handheld.browser.test.ts/handheld-viewport-flip-e2e-viewport-override-flips-chrome-and-body-branches-1.png +0 -0
  3. package/dist/actions/listActionsFromEntries.test.js +29 -0
  4. package/dist/actions/listActive.js +2 -0
  5. package/dist/actions/listeners.js +4 -0
  6. package/dist/actions/programmatic-dispatch.svelte.test.js +9 -2
  7. package/dist/actions/types.d.ts +8 -0
  8. package/dist/api.d.ts +4 -1
  9. package/dist/chrome/CompactChrome.svelte +96 -0
  10. package/dist/chrome/CompactChrome.svelte.d.ts +3 -0
  11. package/dist/chrome/CompactChrome.svelte.test.d.ts +1 -0
  12. package/dist/chrome/CompactChrome.svelte.test.js +67 -0
  13. package/dist/chrome/MenuSheet.svelte +224 -0
  14. package/dist/chrome/MenuSheet.svelte.d.ts +7 -0
  15. package/dist/chrome/MenuSheet.svelte.test.d.ts +1 -0
  16. package/dist/chrome/MenuSheet.svelte.test.js +46 -0
  17. package/dist/handheld.browser.test.d.ts +1 -0
  18. package/dist/handheld.browser.test.js +90 -0
  19. package/dist/layout/LayoutRenderer.svelte +12 -1
  20. package/dist/layout/LayoutRenderer.svelte.d.ts +2 -1
  21. package/dist/layout/compact/CompactRenderer.svelte +53 -0
  22. package/dist/layout/compact/CompactRenderer.svelte.d.ts +3 -0
  23. package/dist/layout/compact/CompactRenderer.svelte.test.d.ts +1 -0
  24. package/dist/layout/compact/CompactRenderer.svelte.test.js +76 -0
  25. package/dist/layout/compact/derive.d.ts +3 -0
  26. package/dist/layout/compact/derive.js +155 -0
  27. package/dist/layout/compact/derive.test.d.ts +1 -0
  28. package/dist/layout/compact/derive.test.js +160 -0
  29. package/dist/layout/compact/drawerStore.svelte.d.ts +21 -0
  30. package/dist/layout/compact/drawerStore.svelte.js +75 -0
  31. package/dist/layout/compact/drawerStore.svelte.test.d.ts +1 -0
  32. package/dist/layout/compact/drawerStore.svelte.test.js +43 -0
  33. package/dist/layout/compact/resolveRole.d.ts +6 -0
  34. package/dist/layout/compact/resolveRole.js +13 -0
  35. package/dist/layout/compact/resolveRole.test.d.ts +1 -0
  36. package/dist/layout/compact/resolveRole.test.js +18 -0
  37. package/dist/layout/compact/types.d.ts +27 -0
  38. package/dist/layout/compact/types.js +15 -0
  39. package/dist/layout/presets.compactVariant.test.d.ts +1 -0
  40. package/dist/layout/presets.compactVariant.test.js +27 -0
  41. package/dist/layout/presets.d.ts +12 -0
  42. package/dist/layout/presets.js +16 -0
  43. package/dist/layout/store.drawers.svelte.test.d.ts +1 -0
  44. package/dist/layout/store.drawers.svelte.test.js +49 -0
  45. package/dist/layout/store.schemaVersion.test.d.ts +1 -0
  46. package/dist/layout/store.schemaVersion.test.js +35 -0
  47. package/dist/layout/store.svelte.js +52 -2
  48. package/dist/layout/types.d.ts +43 -1
  49. package/dist/layout/types.js +1 -1
  50. package/dist/overlays/DrawerSurface.svelte +141 -0
  51. package/dist/overlays/DrawerSurface.svelte.d.ts +12 -0
  52. package/dist/overlays/DrawerSurface.svelte.test.d.ts +1 -0
  53. package/dist/overlays/DrawerSurface.svelte.test.js +67 -0
  54. package/dist/overlays/OverlayRoots.svelte +12 -9
  55. package/dist/overlays/types.d.ts +1 -1
  56. package/dist/sh3Api/headless.js +9 -1
  57. package/dist/sh3Api/headless.svelte.test.js +45 -1
  58. package/dist/sh3Runtime.svelte.d.ts +36 -0
  59. package/dist/sh3Runtime.svelte.js +33 -0
  60. package/dist/shards/types.d.ts +9 -1
  61. package/dist/tokens.css +3 -2
  62. package/dist/verbs/types.d.ts +5 -2
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/dist/viewport/classify.d.ts +8 -0
  66. package/dist/viewport/classify.js +20 -0
  67. package/dist/viewport/classify.test.d.ts +1 -0
  68. package/dist/viewport/classify.test.js +32 -0
  69. package/dist/viewport/store.browser.test.d.ts +1 -0
  70. package/dist/viewport/store.browser.test.js +33 -0
  71. package/dist/viewport/store.svelte.d.ts +9 -0
  72. package/dist/viewport/store.svelte.js +71 -0
  73. package/dist/viewport/store.svelte.test.d.ts +1 -0
  74. package/dist/viewport/store.svelte.test.js +54 -0
  75. package/dist/viewport/types.d.ts +9 -0
  76. package/dist/viewport/types.js +6 -0
  77. package/package.json +1 -1
package/dist/Sh3.svelte CHANGED
@@ -26,6 +26,9 @@
26
26
  import { startServerSideStream } from './keys/revocation-bus.svelte';
27
27
  import BrandSlot from './BrandSlot.svelte';
28
28
  import MenuBar from './actions/MenuBar.svelte';
29
+ import CompactChrome from './chrome/CompactChrome.svelte';
30
+ import CompactRenderer from './layout/compact/CompactRenderer.svelte';
31
+ import { sh3 } from './sh3Runtime.svelte';
29
32
 
30
33
  const authenticated = $derived(isAuthenticated());
31
34
  const user = $derived(getUser());
@@ -34,6 +37,8 @@
34
37
  // getActiveApp() here: returnToHome() keeps the app warm (activeApp.id
35
38
  // stays set) and only flips the layout store's activeRoot back to 'home'.
36
39
  const onHome = $derived(getActiveRoot() === 'home');
40
+ // Reactive viewport class — drives the compact-vs-desktop chrome/body fork.
41
+ const viewportClass = $derived(sh3.viewport.current.class);
37
42
 
38
43
  // Keep the actions dispatcher's `mountedViewIds` set in sync with the
39
44
  // live layout tree, so `view:<viewId>` scope checks (context menu,
@@ -60,45 +65,53 @@
60
65
  });
61
66
  </script>
62
67
 
63
- <div class="sh3">
64
- <header class="sh3-tabbar" data-sh3-region="tabbar">
65
- <button
66
- type="button"
67
- class="sh3-tabbar-home-button"
68
- onclick={() => returnToHome()}
69
- disabled={onHome}
70
- title="Home"
71
- >
72
- <svg class="sh3-tabbar-home-icon" aria-hidden="true">
73
- <use href="{iconsUrl}#house" />
74
- </svg>
75
- </button>
76
- <BrandSlot />
77
- <MenuBar />
78
- {#if authenticated && user}
79
- <div class="sh3-tabbar-user">
80
- <span class="sh3-tabbar-user-name">{user.displayName}</span>
81
- <span class="sh3-tabbar-tag">{elevated ? 'admin' : 'user'}</span>
82
- {#if !isLocalOwner()}
83
- <button
84
- type="button"
85
- class="sh3-tabbar-signout"
86
- onclick={() => logout()}
87
- title="Sign out"
88
- >
89
- <svg class="sh3-tabbar-signout-icon" aria-hidden="true">
90
- <use href="{iconsUrl}#log-out" />
91
- </svg>
92
- </button>
93
- {/if}
94
- </div>
95
- {/if}
96
- </header>
68
+ <div class="sh3" data-sh3-viewport={viewportClass}>
69
+ {#if viewportClass === 'desktop'}
70
+ <header class="sh3-tabbar" data-sh3-region="tabbar">
71
+ <button
72
+ type="button"
73
+ class="sh3-tabbar-home-button"
74
+ onclick={() => returnToHome()}
75
+ disabled={onHome}
76
+ title="Home"
77
+ >
78
+ <svg class="sh3-tabbar-home-icon" aria-hidden="true">
79
+ <use href="{iconsUrl}#house" />
80
+ </svg>
81
+ </button>
82
+ <BrandSlot />
83
+ <MenuBar />
84
+ {#if authenticated && user}
85
+ <div class="sh3-tabbar-user">
86
+ <span class="sh3-tabbar-user-name">{user.displayName}</span>
87
+ <span class="sh3-tabbar-tag">{elevated ? 'admin' : 'user'}</span>
88
+ {#if !isLocalOwner()}
89
+ <button
90
+ type="button"
91
+ class="sh3-tabbar-signout"
92
+ onclick={() => logout()}
93
+ title="Sign out"
94
+ >
95
+ <svg class="sh3-tabbar-signout-icon" aria-hidden="true">
96
+ <use href="{iconsUrl}#log-out" />
97
+ </svg>
98
+ </button>
99
+ {/if}
100
+ </div>
101
+ {/if}
102
+ </header>
103
+ {:else}
104
+ <CompactChrome />
105
+ {/if}
97
106
 
98
107
  <GuestBanner />
99
108
 
100
109
  <main class="sh3-content" data-sh3-region="content" data-sh3-layer="0">
101
- <LayoutRenderer />
110
+ {#if viewportClass === 'desktop'}
111
+ <LayoutRenderer />
112
+ {:else}
113
+ <CompactRenderer />
114
+ {/if}
102
115
  </main>
103
116
 
104
117
  <footer class="sh3-statusbar" data-sh3-region="statusbar">
@@ -75,4 +75,33 @@ describe('listActionsFromEntries', () => {
75
75
  expect(out).toHaveLength(1);
76
76
  expect(out[0].ownerShardId).toBe('shard.a');
77
77
  });
78
+ it('passes through submenu=true on a parent descriptor', () => {
79
+ const entries = [{
80
+ ownerShardId: 'shard.x',
81
+ action: { id: 'p', label: 'P', scope: 'home', submenu: true },
82
+ }];
83
+ const out = listActionsFromEntries(entries, mkState());
84
+ expect(out[0].submenu).toBe(true);
85
+ expect(out[0].submenuOf).toBeUndefined();
86
+ });
87
+ it('passes through submenuOf on a child descriptor', () => {
88
+ const entries = [
89
+ {
90
+ ownerShardId: 'shard.x',
91
+ action: {
92
+ id: 'p:dark', label: 'Dark', scope: 'home',
93
+ submenuOf: 'p', run: () => { },
94
+ },
95
+ },
96
+ ];
97
+ const out = listActionsFromEntries(entries, mkState());
98
+ expect(out[0].submenuOf).toBe('p');
99
+ expect(out[0].submenu).toBeUndefined();
100
+ });
101
+ it('omits submenu and submenuOf on plain actions', () => {
102
+ const entries = [mkEntry({ id: 'plain', scope: 'home' })];
103
+ const out = listActionsFromEntries(entries, mkState());
104
+ expect(out[0].submenu).toBeUndefined();
105
+ expect(out[0].submenuOf).toBeUndefined();
106
+ });
78
107
  });
@@ -50,6 +50,8 @@ export function listActionsFromEntries(entries, state) {
50
50
  ownerShardId: entry.ownerShardId,
51
51
  paletteItem: entry.action.paletteItem !== false,
52
52
  contextItem: entry.action.contextItem !== false,
53
+ submenu: entry.action.submenu,
54
+ submenuOf: entry.action.submenuOf,
53
55
  active,
54
56
  };
55
57
  if (active) {
@@ -94,6 +94,10 @@ export function dispatchActionProgrammatic(actionId, _opts) {
94
94
  const state = getLiveDispatcherState();
95
95
  const desc = listActionsFromEntries(entries, state).find((d) => d.id === actionId);
96
96
  if (!desc || !desc.active) {
97
+ if (entry.action.submenu === true &&
98
+ typeof entry.action.run !== 'function') {
99
+ return Promise.reject(new Error(`action "${actionId}" is a submenu parent — list children with listActions({ submenuOf: "${actionId}" })`));
100
+ }
97
101
  return Promise.reject(new Error(`action "${actionId}" is not active in current scope`));
98
102
  }
99
103
  // run is guaranteed non-null by `desc.active === true`.
@@ -34,11 +34,18 @@ describe('dispatchActionProgrammatic', () => {
34
34
  await expect(dispatchActionProgrammatic('d')).rejects.toThrow(/action "d" is not active/);
35
35
  expect(run).not.toHaveBeenCalled();
36
36
  });
37
- it('rejects on a submenu parent without run', async () => {
37
+ it('rejects on a submenu parent with the parent-specific message', async () => {
38
38
  registerAction({
39
39
  id: 's', label: 'S', scope: 'home', submenu: true,
40
40
  }, 'shard.x');
41
- await expect(dispatchActionProgrammatic('s')).rejects.toThrow(/action "s" is not active/);
41
+ await expect(dispatchActionProgrammatic('s')).rejects.toThrow(/action "s" is a submenu parent — list children with listActions\(\{ submenuOf: "s" \}\)/);
42
+ });
43
+ it('still uses the generic inactive message for non-submenu inactive actions', async () => {
44
+ registerAction({
45
+ id: 'gated2', label: 'G', scope: 'app', run: () => { },
46
+ }, 'shard.x');
47
+ // No active app -> 'app' scope is inactive.
48
+ await expect(dispatchActionProgrammatic('gated2')).rejects.toThrow(/action "gated2" is not active/);
42
49
  });
43
50
  it('invokes run with invokedVia="programmatic" on happy path', async () => {
44
51
  let captured = null;
@@ -148,6 +148,10 @@ export interface ActiveActionDescriptor {
148
148
  ownerShardId: string;
149
149
  paletteItem: boolean;
150
150
  contextItem: boolean;
151
+ /** True when this action is a submenu parent (children opened by drill). */
152
+ submenu?: true;
153
+ /** Parent action id when this action is a submenu child. */
154
+ submenuOf?: string;
151
155
  }
152
156
  /**
153
157
  * Read-only snapshot describing one action in the registry. Produced by
@@ -183,6 +187,10 @@ export interface ActionDescriptor {
183
187
  ownerShardId: string;
184
188
  paletteItem: boolean;
185
189
  contextItem: boolean;
190
+ /** True when this action is a submenu parent (children opened by drill). */
191
+ submenu?: true;
192
+ /** Parent action id when this action is a submenu child. */
193
+ submenuOf?: string;
186
194
  /** True when `runAction(id)` would dispatch right now. */
187
195
  active: boolean;
188
196
  }
package/dist/api.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export { sh3 } from './sh3Runtime.svelte';
2
- export type { Sh3 } from './sh3Runtime.svelte';
2
+ export type { Sh3, Sh3Drawers, Sh3Viewport } from './sh3Runtime.svelte';
3
+ export type { ViewportClass, ViewportInfo } from './viewport/types';
4
+ export type { SlotRole } from './layout/types';
5
+ export type { DrawerAnchor, DrawerSpec, DrawerState, DrawerStateMap, CompactRendering, } from './layout/compact/types';
3
6
  export type { Shard, ShardManifest, SourceShard, SourceShardManifest, ShardContext, ViewDeclaration, ViewFactory, ViewHandle, MountContext, } from './shards/types';
4
7
  export type { LayoutNode, SplitNode, TabsNode, SlotNode, TabEntry, SplitDirection, SizeMode, LayoutTree, FloatEntry, LayoutPreset, CanonicalPreset, TreeRootRef as SlotLocation, } from './layout/types';
5
8
  export type { ActionDescriptor, ActiveActionDescriptor, BindingSource, AtomicScope, ActionScope, } from './actions/types';
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ /*
3
+ * Top app bar for compact mode. Three-column grid:
4
+ * leading — one button per non-null drawer anchor (read from
5
+ * the active CompactRendering)
6
+ * title — active app name
7
+ * trailing — palette button + overflow (menu sheet) button
8
+ *
9
+ * MenuSheet handles the overflow menu; this component owns the open
10
+ * state and renders MenuSheet conditionally.
11
+ */
12
+ import { sh3 } from '../sh3Runtime.svelte';
13
+ import { layoutStore } from '../layout/store.svelte';
14
+ import { derive } from '../layout/compact/derive';
15
+ import { getLiveDispatcherState } from '../actions/state.svelte';
16
+ import { getRegisteredApp } from '../apps/registry.svelte';
17
+ import MenuSheet from './MenuSheet.svelte';
18
+ import type { DrawerAnchor } from '../layout/compact/types';
19
+
20
+ const rendering = $derived(derive(layoutStore.root));
21
+ const dispatcher = $derived(getLiveDispatcherState());
22
+ const title = $derived.by(() => {
23
+ const id = dispatcher.activeAppId;
24
+ if (!id) return 'SH3';
25
+ return getRegisteredApp(id)?.manifest.label ?? id;
26
+ });
27
+
28
+ let menuOpen = $state(false);
29
+
30
+ function toggleDrawer(anchor: DrawerAnchor) {
31
+ sh3.drawers.toggle(anchor);
32
+ }
33
+ function openPalette() {
34
+ sh3.actions.openPalette();
35
+ }
36
+ </script>
37
+
38
+ <header class="sh3-compact-chrome" data-sh3-region="compact-chrome">
39
+ <div class="leading">
40
+ {#if rendering.drawers.left}
41
+ <button onclick={() => toggleDrawer('left')} aria-label="Toggle left drawer" data-sh3-anchor="left">≡</button>
42
+ {/if}
43
+ {#if rendering.drawers.right}
44
+ <button onclick={() => toggleDrawer('right')} aria-label="Toggle right drawer" data-sh3-anchor="right">▣</button>
45
+ {/if}
46
+ {#if rendering.drawers.top}
47
+ <button onclick={() => toggleDrawer('top')} aria-label="Toggle top drawer" data-sh3-anchor="top">⫶</button>
48
+ {/if}
49
+ </div>
50
+ <div class="title">{title}</div>
51
+ <div class="trailing">
52
+ <button onclick={openPalette} aria-label="Open command palette">⌘</button>
53
+ <button onclick={() => (menuOpen = true)} aria-label="Open menu">⋯</button>
54
+ </div>
55
+ </header>
56
+
57
+ <MenuSheet open={menuOpen} onClose={() => (menuOpen = false)} />
58
+
59
+ <style>
60
+ .sh3-compact-chrome {
61
+ display: grid;
62
+ grid-template-columns: auto 1fr auto;
63
+ align-items: center;
64
+ height: var(--sh3-tabbar-height);
65
+ padding: 0 var(--sh3-pad-sm);
66
+ gap: var(--sh3-pad-sm);
67
+ background: var(--sh3-grad-bg-elevated, var(--sh3-bg-elevated));
68
+ border-bottom: 1px solid var(--sh3-border);
69
+ color: var(--sh3-fg);
70
+ }
71
+ .leading,
72
+ .trailing {
73
+ display: inline-flex;
74
+ gap: var(--sh3-pad-xs);
75
+ }
76
+ button {
77
+ width: 40px;
78
+ height: 40px;
79
+ font-size: var(--sh3-font-lg);
80
+ border: none;
81
+ background: none;
82
+ cursor: pointer;
83
+ border-radius: var(--sh3-radius-sm);
84
+ color: var(--sh3-fg);
85
+ }
86
+ button:active {
87
+ background: var(--sh3-bg-sunken);
88
+ }
89
+ .title {
90
+ font-weight: 600;
91
+ text-align: center;
92
+ overflow: hidden;
93
+ text-overflow: ellipsis;
94
+ white-space: nowrap;
95
+ }
96
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const CompactChrome: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type CompactChrome = ReturnType<typeof CompactChrome>;
3
+ export default CompactChrome;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ /*
2
+ * DOM smoke for CompactChrome — verifies the toolbar renders the
3
+ * expected leading drawer toggles based on the active layout's
4
+ * derived rendering, plus the trailing palette + overflow buttons.
5
+ */
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+ import { mount, unmount, flushSync } from 'svelte';
8
+ import CompactChrome from './CompactChrome.svelte';
9
+ import { __resetLayoutStoreForTest, attachApp, detachApp, switchToApp, } from '../layout/store.svelte';
10
+ import { drawerStore } from '../layout/compact/drawerStore.svelte';
11
+ const CompactChromeAny = CompactChrome;
12
+ function fakeApp() {
13
+ return {
14
+ manifest: { id: 'cc-app', label: 'CC App', layoutVersion: 5 },
15
+ initialLayout: {
16
+ type: 'split', direction: 'horizontal', sizes: [0.2, 0.6, 0.2],
17
+ children: [
18
+ { type: 'slot', slotId: 'sb', viewId: 'v:sb', role: 'sidebar' },
19
+ { type: 'slot', slotId: 'body', viewId: 'v:body', role: 'body' },
20
+ { type: 'slot', slotId: 'ins', viewId: 'v:ins', role: 'inspector' },
21
+ ],
22
+ },
23
+ };
24
+ }
25
+ let mounted = null;
26
+ let host = null;
27
+ afterEach(() => {
28
+ if (mounted) {
29
+ unmount(mounted);
30
+ mounted = null;
31
+ }
32
+ if (host) {
33
+ host.remove();
34
+ host = null;
35
+ }
36
+ detachApp();
37
+ });
38
+ beforeEach(() => {
39
+ __resetLayoutStoreForTest();
40
+ drawerStore.__reset();
41
+ });
42
+ describe('CompactChrome (dom)', () => {
43
+ it('renders a leading toggle for each present drawer anchor', () => {
44
+ attachApp(fakeApp());
45
+ switchToApp();
46
+ flushSync();
47
+ host = document.createElement('div');
48
+ document.body.appendChild(host);
49
+ mounted = mount(CompactChromeAny, { target: host });
50
+ flushSync();
51
+ const leading = host.querySelectorAll('.leading button');
52
+ expect(leading.length).toBe(2);
53
+ expect(host.querySelector('.leading button[data-sh3-anchor="left"]')).not.toBeNull();
54
+ expect(host.querySelector('.leading button[data-sh3-anchor="right"]')).not.toBeNull();
55
+ });
56
+ it('renders palette + overflow buttons in the trailing section', () => {
57
+ attachApp(fakeApp());
58
+ switchToApp();
59
+ flushSync();
60
+ host = document.createElement('div');
61
+ document.body.appendChild(host);
62
+ mounted = mount(CompactChromeAny, { target: host });
63
+ flushSync();
64
+ const trailing = host.querySelectorAll('.trailing button');
65
+ expect(trailing.length).toBe(2);
66
+ });
67
+ });
@@ -0,0 +1,224 @@
1
+ <script lang="ts">
2
+ /*
3
+ * Touch-friendly replacement for MenuBar — bottom-anchored sheet with
4
+ * collapsible sections per menu container. Tapping a submenu parent
5
+ * expands its children inline (no nested popover stack — see
6
+ * docs/superpowers/specs/2026-05-09-action-submenu-discoverability-design.md).
7
+ *
8
+ * Reads the same dispatcher state and registry as MenuBar:
9
+ * resolveMenuContainers(activeAppId, declared)
10
+ * resolveMenuItems(entries, dispatcherState, containerId)
11
+ * resolveSubmenuItems(entries, dispatcherState, parentId)
12
+ */
13
+ import {
14
+ resolveMenuContainers,
15
+ resolveMenuItems,
16
+ resolveSubmenuItems,
17
+ type MenuBarItem,
18
+ } from '../actions/menuBarModel';
19
+ import { listActions } from '../actions/registry';
20
+ import { getLiveDispatcherState } from '../actions/state.svelte';
21
+ import { getRegisteredApp } from '../apps/registry.svelte';
22
+ import { resolveLabel } from '../actions/types';
23
+
24
+ let { open, onClose }: { open: boolean; onClose: () => void } = $props();
25
+
26
+ const dispatcher = $derived(getLiveDispatcherState());
27
+ const activeAppId = $derived(dispatcher.activeAppId);
28
+ const declaredMenus = $derived.by(() => {
29
+ if (!activeAppId) return undefined;
30
+ return getRegisteredApp(activeAppId)?.manifest.menus;
31
+ });
32
+ const containers = $derived(resolveMenuContainers(activeAppId, declaredMenus));
33
+ const containerItems = $derived.by(() => {
34
+ const out: { containerId: string; label: string; items: MenuBarItem[] }[] = [];
35
+ const entries = listActions();
36
+ for (const c of containers) {
37
+ const items = resolveMenuItems(entries, dispatcher, c.id);
38
+ if (items.length > 0) out.push({ containerId: c.id, label: c.label, items });
39
+ }
40
+ return out;
41
+ });
42
+
43
+ let expanded = $state(new Set<string>());
44
+ let expandedSubmenu = $state(new Set<string>());
45
+
46
+ function toggleContainer(id: string) {
47
+ const next = new Set(expanded);
48
+ if (next.has(id)) next.delete(id);
49
+ else next.add(id);
50
+ expanded = next;
51
+ }
52
+
53
+ function toggleSubmenu(id: string) {
54
+ const next = new Set(expandedSubmenu);
55
+ if (next.has(id)) next.delete(id);
56
+ else next.add(id);
57
+ expandedSubmenu = next;
58
+ }
59
+
60
+ function invoke(itemId: string) {
61
+ const entry = listActions().find((e) => e.action.id === itemId);
62
+ if (!entry || typeof entry.action.run !== 'function') return;
63
+ try {
64
+ void entry.action.run({
65
+ action: { id: itemId, label: resolveLabel(entry.action) },
66
+ appId: dispatcher.activeAppId,
67
+ viewId: dispatcher.focusedViewId ?? undefined,
68
+ selection: dispatcher.selection ?? undefined,
69
+ invokedVia: 'palette',
70
+ dispatch: () => {},
71
+ });
72
+ } catch (err) {
73
+ console.error(`[sh3] menu-sheet action "${itemId}" threw:`, err);
74
+ }
75
+ onClose();
76
+ }
77
+ </script>
78
+
79
+ {#if open}
80
+ <div
81
+ class="backdrop"
82
+ onclick={onClose}
83
+ onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
84
+ role="presentation"
85
+ ></div>
86
+ <div class="sheet" role="dialog" aria-label="Menu" data-sh3-region="menu-sheet">
87
+ <div class="scroll">
88
+ {#each containerItems as { containerId, label, items } (containerId)}
89
+ <button
90
+ class="container"
91
+ aria-expanded={expanded.has(containerId)}
92
+ onclick={() => toggleContainer(containerId)}
93
+ >
94
+ <span class="caret" class:open={expanded.has(containerId)}>▸</span>
95
+ <span class="label">{label}</span>
96
+ </button>
97
+ {#if expanded.has(containerId)}
98
+ <div class="items">
99
+ {#each items as item (item.id)}
100
+ {#if item.submenu}
101
+ <button
102
+ class="item submenu"
103
+ aria-expanded={expandedSubmenu.has(item.id)}
104
+ disabled={item.disabled}
105
+ onclick={() => toggleSubmenu(item.id)}
106
+ >
107
+ <span class="caret" class:open={expandedSubmenu.has(item.id)}>▸</span>
108
+ <span class="label">{item.label}</span>
109
+ </button>
110
+ {#if expandedSubmenu.has(item.id)}
111
+ <div class="subitems">
112
+ {#each resolveSubmenuItems(listActions(), dispatcher, item.id) as sub (sub.id)}
113
+ <button
114
+ class="item child"
115
+ disabled={sub.disabled}
116
+ onclick={() => invoke(sub.id)}
117
+ >
118
+ <span class="label">{sub.label}</span>
119
+ {#if sub.shortcut}
120
+ <span class="shortcut">{sub.shortcut}</span>
121
+ {/if}
122
+ </button>
123
+ {/each}
124
+ </div>
125
+ {/if}
126
+ {:else}
127
+ <button
128
+ class="item"
129
+ disabled={item.disabled}
130
+ onclick={() => invoke(item.id)}
131
+ >
132
+ <span class="label">{item.label}</span>
133
+ {#if item.shortcut}
134
+ <span class="shortcut">{item.shortcut}</span>
135
+ {/if}
136
+ </button>
137
+ {/if}
138
+ {/each}
139
+ </div>
140
+ {/if}
141
+ {/each}
142
+ </div>
143
+ <button class="cancel" onclick={onClose}>Cancel</button>
144
+ </div>
145
+ {/if}
146
+
147
+ <style>
148
+ .backdrop {
149
+ position: absolute;
150
+ inset: 0;
151
+ background: var(--sh3-overlay-backdrop, rgba(0, 0, 0, 0.35));
152
+ pointer-events: auto;
153
+ z-index: var(--sh3-z-layer-4);
154
+ }
155
+ .sheet {
156
+ position: absolute;
157
+ left: 0;
158
+ right: 0;
159
+ bottom: 0;
160
+ max-height: 70vh;
161
+ display: flex;
162
+ flex-direction: column;
163
+ background: var(--sh3-bg);
164
+ color: var(--sh3-fg);
165
+ border-top: 1px solid var(--sh3-border);
166
+ box-shadow: var(--sh3-shadow-md, 0 -4px 16px rgba(0, 0, 0, 0.2));
167
+ pointer-events: auto;
168
+ z-index: var(--sh3-z-layer-4);
169
+ }
170
+ .scroll {
171
+ flex: 1;
172
+ min-height: 0;
173
+ overflow: auto;
174
+ padding: var(--sh3-pad-sm) 0;
175
+ }
176
+ .container {
177
+ display: flex;
178
+ align-items: center;
179
+ gap: var(--sh3-pad-sm);
180
+ width: 100%;
181
+ padding: var(--sh3-pad-sm) var(--sh3-pad-md);
182
+ border: none;
183
+ background: none;
184
+ color: var(--sh3-fg);
185
+ font-weight: 600;
186
+ text-align: left;
187
+ cursor: pointer;
188
+ }
189
+ .container:active { background: var(--sh3-bg-sunken); }
190
+ .items { padding-left: var(--sh3-pad-md); }
191
+ .subitems { padding-left: var(--sh3-pad-md); }
192
+ .item {
193
+ display: flex;
194
+ align-items: center;
195
+ gap: var(--sh3-pad-sm);
196
+ width: 100%;
197
+ padding: var(--sh3-pad-sm) var(--sh3-pad-md);
198
+ border: none;
199
+ background: none;
200
+ color: var(--sh3-fg);
201
+ text-align: left;
202
+ cursor: pointer;
203
+ }
204
+ .item:disabled { opacity: 0.5; cursor: not-allowed; }
205
+ .item:active:not(:disabled) { background: var(--sh3-bg-sunken); }
206
+ .item.child { padding-left: calc(var(--sh3-pad-md) * 2); }
207
+ .label { flex: 1; }
208
+ .shortcut { color: var(--sh3-fg-muted); font-family: var(--sh3-font-mono); }
209
+ .caret {
210
+ display: inline-block;
211
+ width: 1em;
212
+ transition: transform 120ms;
213
+ }
214
+ .caret.open { transform: rotate(90deg); }
215
+ .cancel {
216
+ padding: var(--sh3-pad-md);
217
+ border: none;
218
+ border-top: 1px solid var(--sh3-border);
219
+ background: var(--sh3-bg-elevated);
220
+ color: var(--sh3-fg);
221
+ font-weight: 600;
222
+ cursor: pointer;
223
+ }
224
+ </style>
@@ -0,0 +1,7 @@
1
+ type $$ComponentProps = {
2
+ open: boolean;
3
+ onClose: () => void;
4
+ };
5
+ declare const MenuSheet: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type MenuSheet = ReturnType<typeof MenuSheet>;
7
+ export default MenuSheet;
@@ -0,0 +1 @@
1
+ export {};