sh3-core 0.19.5 → 0.20.1

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 (69) hide show
  1. package/dist/api.d.ts +1 -0
  2. package/dist/app/admin/AuthSettingsView.svelte +3 -9
  3. package/dist/app/admin/MountsView.svelte +276 -0
  4. package/dist/app/admin/MountsView.svelte.d.ts +3 -0
  5. package/dist/app/admin/SystemView.svelte +6 -6
  6. package/dist/app/admin/UsersView.svelte +103 -7
  7. package/dist/app/admin/adminApp.js +1 -0
  8. package/dist/app/admin/adminShard.svelte.js +10 -0
  9. package/dist/apps/lifecycle.js +1 -0
  10. package/dist/apps/types.d.ts +7 -0
  11. package/dist/assets/iconIds.generated.d.ts +1 -1
  12. package/dist/assets/iconIds.generated.js +1 -0
  13. package/dist/assets/icons.svg +5 -0
  14. package/dist/auth/admin-users.svelte.js +2 -1
  15. package/dist/auth/auth.svelte.d.ts +4 -5
  16. package/dist/auth/auth.svelte.js +5 -6
  17. package/dist/auth/types.d.ts +0 -2
  18. package/dist/chrome/CompactChrome.svelte +25 -6
  19. package/dist/chrome/FloatsSheet.svelte +7 -32
  20. package/dist/chrome/FloatsSheet.svelte.d.ts +1 -2
  21. package/dist/chrome/FloatsSheet.svelte.test.js +8 -14
  22. package/dist/chrome/MenuSheet.svelte +154 -148
  23. package/dist/chrome/MenuSheet.svelte.d.ts +1 -2
  24. package/dist/chrome/MenuSheet.svelte.test.js +24 -12
  25. package/dist/createShell.js +32 -21
  26. package/dist/createShell.remoteAuth.test.js +9 -3
  27. package/dist/documents/browse.d.ts +18 -1
  28. package/dist/documents/browse.js +40 -7
  29. package/dist/documents/browse.test.js +35 -35
  30. package/dist/documents/config.d.ts +4 -0
  31. package/dist/documents/config.js +15 -2
  32. package/dist/documents/handle.js +25 -17
  33. package/dist/documents/http-backend.js +10 -2
  34. package/dist/documents/index.d.ts +2 -2
  35. package/dist/documents/index.js +1 -1
  36. package/dist/documents/picker-api.d.ts +33 -0
  37. package/dist/documents/picker-api.js +1 -0
  38. package/dist/documents/picker-api.test.d.ts +1 -0
  39. package/dist/documents/picker-api.test.js +162 -0
  40. package/dist/documents/picker-primitive.d.ts +11 -0
  41. package/dist/documents/picker-primitive.js +56 -0
  42. package/dist/documents/types.d.ts +17 -5
  43. package/dist/documents/types.js +2 -0
  44. package/dist/layout/presets.test.js +4 -4
  45. package/dist/layout/types.d.ts +1 -1
  46. package/dist/layouts-shard/LayoutsSection.svelte +3 -16
  47. package/dist/primitives/widgets/DocumentFilePicker.svelte +4 -4
  48. package/dist/primitives/widgets/PickerList.svelte +1 -0
  49. package/dist/primitives/widgets/_DocumentBrowser.svelte +7 -8
  50. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +1 -0
  51. package/dist/projects-shard/DeleteProjectDialog.svelte +32 -1
  52. package/dist/projects-shard/ProjectManage.svelte +197 -28
  53. package/dist/projects-shard/ProjectManage.svelte.test.d.ts +1 -0
  54. package/dist/projects-shard/ProjectManage.svelte.test.js +320 -0
  55. package/dist/projects-shard/ProjectsSection.svelte +3 -16
  56. package/dist/projects-shard/projectsApi.js +2 -1
  57. package/dist/registry/permission-descriptions.js +4 -0
  58. package/dist/server-shard/types.d.ts +21 -0
  59. package/dist/sh3core-shard/HomeSection.svelte +107 -0
  60. package/dist/sh3core-shard/HomeSection.svelte.d.ts +10 -0
  61. package/dist/sh3core-shard/Sh3Home.svelte +9 -23
  62. package/dist/shards/activate.svelte.d.ts +4 -0
  63. package/dist/shards/activate.svelte.js +31 -14
  64. package/dist/shards/types.d.ts +15 -0
  65. package/dist/shell-shard/tenant-fs-client.js +2 -1
  66. package/dist/transport/apiFetch.js +12 -5
  67. package/dist/version.d.ts +1 -1
  68. package/dist/version.js +1 -1
  69. package/package.json +1 -1
@@ -1159,4 +1159,9 @@
1159
1159
  <circle cx="12" cy="19" r="1" />
1160
1160
  </symbol>
1161
1161
 
1162
+ <!-- lucide/chevron-left -->
1163
+ <symbol id="chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1164
+ <path d="m15 18-6-6 6-6" />
1165
+ </symbol>
1166
+
1162
1167
  </svg>
@@ -7,6 +7,7 @@
7
7
  * the user list. Concurrent calls reuse the in-flight promise; subsequent
8
8
  * calls after completion fetch fresh data.
9
9
  */
10
+ import { apiFetch } from '../transport/apiFetch';
10
11
  export const usersAdminState = $state({ users: [], loading: false, error: null });
11
12
  let inflight = null;
12
13
  export function refreshAdminUsers() {
@@ -16,7 +17,7 @@ export function refreshAdminUsers() {
16
17
  usersAdminState.error = null;
17
18
  inflight = (async () => {
18
19
  try {
19
- const res = await fetch('/api/admin/users', { credentials: 'include' });
20
+ const res = await apiFetch('/api/admin/users');
20
21
  if (!res.ok) {
21
22
  usersAdminState.error = `GET /api/admin/users failed: ${res.status}`;
22
23
  return;
@@ -37,11 +37,10 @@ export declare function register(username: string, password: string, displayName
37
37
  /**
38
38
  * Log out — clear session on server and client.
39
39
  *
40
- * If the boot policy forbids guest browsing (auth.required &&
41
- * !auth.guestAllowed), trigger a full page reload so the boot-time
42
- * hard gate in createShell.ts re-runs and shows the sign-in wall.
43
- * This keeps the policy authoritative in a single place rather than
44
- * duplicating it here.
40
+ * If the boot policy forbids guest browsing (!auth.guestAllowed), trigger
41
+ * a full page reload so the boot-time hard gate in createShell.ts re-runs
42
+ * and shows the sign-in wall. This keeps the policy authoritative in a
43
+ * single place rather than duplicating it here.
45
44
  */
46
45
  export declare function logout(): Promise<void>;
47
46
  /**
@@ -94,11 +94,10 @@ export async function register(username, password, displayName) {
94
94
  /**
95
95
  * Log out — clear session on server and client.
96
96
  *
97
- * If the boot policy forbids guest browsing (auth.required &&
98
- * !auth.guestAllowed), trigger a full page reload so the boot-time
99
- * hard gate in createShell.ts re-runs and shows the sign-in wall.
100
- * This keeps the policy authoritative in a single place rather than
101
- * duplicating it here.
97
+ * If the boot policy forbids guest browsing (!auth.guestAllowed), trigger
98
+ * a full page reload so the boot-time hard gate in createShell.ts re-runs
99
+ * and shows the sign-in wall. This keeps the policy authoritative in a
100
+ * single place rather than duplicating it here.
102
101
  */
103
102
  export async function logout() {
104
103
  try {
@@ -110,7 +109,7 @@ export async function logout() {
110
109
  // Best effort
111
110
  }
112
111
  setAuthToken(null);
113
- if ((authConfig === null || authConfig === void 0 ? void 0 : authConfig.required) && !authConfig.guestAllowed) {
112
+ if (authConfig && !authConfig.guestAllowed) {
114
113
  // Policy forbids guest browsing — re-run the boot-time hard gate.
115
114
  // Do not touch reactive state: the page is leaving.
116
115
  window.location.reload();
@@ -22,7 +22,6 @@ export interface AuthSession {
22
22
  /** Response from GET /api/boot. */
23
23
  export interface BootConfig {
24
24
  auth: {
25
- required: boolean;
26
25
  guestAllowed: boolean;
27
26
  selfRegistration: boolean;
28
27
  };
@@ -39,7 +38,6 @@ export interface BootConfig {
39
38
  /** Global settings shape. */
40
39
  export interface GlobalSettings {
41
40
  auth: {
42
- required: boolean;
43
41
  guestAllowed: boolean;
44
42
  sessionTTL: number;
45
43
  selfRegistration: boolean;
@@ -76,9 +76,31 @@
76
76
  return appLabel;
77
77
  });
78
78
 
79
- let menuOpen = $state(false);
80
79
  let floatsOpen = $state(false);
81
80
 
81
+ function openMenuSheet() {
82
+ sh3.modal.open(
83
+ MenuSheet,
84
+ {},
85
+ { dismissOnBackdrop: true, boxStyle: 'max-width: 320px;' },
86
+ );
87
+ }
88
+
89
+ function toggleFloatsSheet() {
90
+ if (floatsOpen) return;
91
+ floatsOpen = true;
92
+ const handle = sh3.modal.open(
93
+ FloatsSheet,
94
+ {},
95
+ { dismissOnBackdrop: true, boxStyle: 'max-width: 320px;' },
96
+ );
97
+ const origClose = handle.close;
98
+ handle.close = () => {
99
+ origClose();
100
+ floatsOpen = false;
101
+ };
102
+ }
103
+
82
104
  function toggleDrawer(anchor: DrawerAnchor) {
83
105
  sh3.drawers.toggle(anchor);
84
106
  }
@@ -122,15 +144,12 @@
122
144
  ariaLabel="Floats"
123
145
  title="Floats"
124
146
  pressed={floatsOpen}
125
- onclick={() => { floatsOpen = !floatsOpen; }}
147
+ onclick={toggleFloatsSheet}
126
148
  />
127
- <Button variant="icon" icon="ellipsis-vertical" ariaLabel="Open menu" title="Open menu" onclick={() => { menuOpen = true; }} />
149
+ <Button variant="icon" icon="ellipsis-vertical" ariaLabel="Open menu" title="Open menu" onclick={openMenuSheet} />
128
150
  </div>
129
151
  </header>
130
152
 
131
- <MenuSheet open={menuOpen} onClose={() => (menuOpen = false)} />
132
- <FloatsSheet open={floatsOpen} onClose={() => (floatsOpen = false)} />
133
-
134
153
  <style>
135
154
  .sh3-compact-chrome {
136
155
  display: grid;
@@ -18,7 +18,7 @@
18
18
  import { getRegisteredApp } from '../apps/registry.svelte';
19
19
  import type { FloatEntry } from '../layout/types';
20
20
 
21
- let { open, onClose }: { open: boolean; onClose: () => void } = $props();
21
+ let { close }: { close: () => void } = $props();
22
22
 
23
23
  const dispatcher = $derived(getLiveDispatcherState());
24
24
  const dockedLabel = $derived.by(() => {
@@ -60,7 +60,7 @@
60
60
  } else {
61
61
  compactRootStore.setRoot({ kind: 'float', floatId: rowId });
62
62
  }
63
- onClose();
63
+ close();
64
64
  }
65
65
 
66
66
  // ----- swipe-to-close --------------------------------------------------
@@ -135,14 +135,7 @@
135
135
  }
136
136
  </script>
137
137
 
138
- {#if open}
139
- <div
140
- class="backdrop"
141
- onclick={onClose}
142
- onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
143
- role="presentation"
144
- ></div>
145
- <div class="sheet" role="dialog" aria-label="Floats" data-sh3-region="floats-sheet">
138
+ <div class="sh3-floats-sheet" role="dialog" aria-label="Floats" data-sh3-region="floats-sheet">
146
139
  <div class="scroll">
147
140
  {#each rows as row (row.id)}
148
141
  <button
@@ -163,32 +156,16 @@
163
156
  </button>
164
157
  {/each}
165
158
  </div>
166
- <button class="cancel" onclick={onClose}>Cancel</button>
159
+ <button class="cancel" onclick={() => close()}>Cancel</button>
167
160
  </div>
168
- {/if}
169
161
 
170
162
  <style>
171
- .backdrop {
172
- position: absolute;
173
- inset: 0;
174
- background: var(--sh3-overlay-backdrop, rgba(0, 0, 0, 0.35));
175
- pointer-events: auto;
176
- z-index: var(--sh3-z-layer-4);
177
- }
178
- .sheet {
179
- position: absolute;
180
- left: 0;
181
- right: 0;
182
- bottom: 0;
183
- max-height: 70vh;
163
+ .sh3-floats-sheet {
184
164
  display: flex;
185
165
  flex-direction: column;
186
- background: var(--sh3-bg);
166
+ max-height: 70vh;
167
+ overflow: hidden;
187
168
  color: var(--sh3-fg);
188
- border-top: 1px solid var(--sh3-border);
189
- box-shadow: var(--sh3-shadow-md, 0 -4px 16px rgba(0, 0, 0, 0.2));
190
- pointer-events: auto;
191
- z-index: var(--sh3-z-layer-4);
192
169
  }
193
170
  .scroll {
194
171
  flex: 1;
@@ -207,8 +184,6 @@
207
184
  color: var(--sh3-fg);
208
185
  text-align: left;
209
186
  cursor: pointer;
210
- /* Suppress browser-claimed horizontal pan so swipe-to-close survives
211
- past the system scroll-claim threshold on Android/iOS. */
212
187
  touch-action: pan-y;
213
188
  user-select: none;
214
189
  }
@@ -1,6 +1,5 @@
1
1
  type $$ComponentProps = {
2
- open: boolean;
3
- onClose: () => void;
2
+ close: () => void;
4
3
  };
5
4
  declare const FloatsSheet: import("svelte").Component<$$ComponentProps, {}, "">;
6
5
  type FloatsSheet = ReturnType<typeof FloatsSheet>;
@@ -29,8 +29,7 @@ describe('FloatsSheet', () => {
29
29
  });
30
30
  it('renders the active-layout row even when no floats exist', async () => {
31
31
  const { container } = renderWithShell(FloatsSheet, {
32
- open: true,
33
- onClose: () => { },
32
+ close: () => { },
34
33
  });
35
34
  await tick();
36
35
  const rows = container.querySelectorAll('[data-sh3-floats-row]');
@@ -42,8 +41,7 @@ describe('FloatsSheet', () => {
42
41
  layoutStore.tree.floats.push(makeFloat('f-2', 'Editor'));
43
42
  layoutStore.tree.floats.push(Object.assign(Object.assign({}, makeFloat('f-3', 'Picker')), { dismissable: true }));
44
43
  const { container } = renderWithShell(FloatsSheet, {
45
- open: true,
46
- onClose: () => { },
44
+ close: () => { },
47
45
  });
48
46
  await tick();
49
47
  const rows = container.querySelectorAll('[data-sh3-floats-row]');
@@ -56,7 +54,7 @@ describe('FloatsSheet', () => {
56
54
  let closed = false;
57
55
  const { container } = renderWithShell(FloatsSheet, {
58
56
  open: true,
59
- onClose: () => { closed = true; },
57
+ close: () => { closed = true; },
60
58
  });
61
59
  await tick();
62
60
  const row = container.querySelector('[data-sh3-floats-row="f-9"]');
@@ -70,7 +68,7 @@ describe('FloatsSheet', () => {
70
68
  let closed = false;
71
69
  const { container } = renderWithShell(FloatsSheet, {
72
70
  open: true,
73
- onClose: () => { closed = true; },
71
+ close: () => { closed = true; },
74
72
  });
75
73
  await tick();
76
74
  const row = container.querySelector('[data-sh3-floats-row="docked"]');
@@ -82,8 +80,7 @@ describe('FloatsSheet', () => {
82
80
  layoutStore.tree.floats.push(makeFloat('f-11', 'Notes'));
83
81
  compactRootStore.setRoot({ kind: 'float', floatId: 'f-11' });
84
82
  const { container } = renderWithShell(FloatsSheet, {
85
- open: true,
86
- onClose: () => { },
83
+ close: () => { },
87
84
  });
88
85
  await tick();
89
86
  const cur = container.querySelector('[data-current="true"]');
@@ -111,8 +108,7 @@ describe('FloatsSheet — swipe to close', () => {
111
108
  const id = floatManager.open('test:view', { title: 'Notes' });
112
109
  expect(layoutStore.floats.find((f) => f.id === id)).toBeTruthy();
113
110
  const { container } = renderWithShell(FloatsSheet, {
114
- open: true,
115
- onClose: () => { },
111
+ close: () => { },
116
112
  });
117
113
  await tick();
118
114
  const row = container.querySelector(`[data-sh3-floats-row="${id}"]`);
@@ -125,8 +121,7 @@ describe('FloatsSheet — swipe to close', () => {
125
121
  });
126
122
  it('does not let the docked row be swiped (no throw, no state change)', async () => {
127
123
  const { container } = renderWithShell(FloatsSheet, {
128
- open: true,
129
- onClose: () => { },
124
+ close: () => { },
130
125
  });
131
126
  await tick();
132
127
  const row = container.querySelector('[data-sh3-floats-row="docked"]');
@@ -140,8 +135,7 @@ describe('FloatsSheet — swipe to close', () => {
140
135
  it('swiping less than 40% width does not close', async () => {
141
136
  const id = floatManager.open('test:view', { title: 'Notes' });
142
137
  const { container } = renderWithShell(FloatsSheet, {
143
- open: true,
144
- onClose: () => { },
138
+ close: () => { },
145
139
  });
146
140
  await tick();
147
141
  const row = container.querySelector(`[data-sh3-floats-row="${id}"]`);
@@ -1,9 +1,10 @@
1
1
  <script lang="ts">
2
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).
3
+ * MenuSheet modal-hosted menu card for compact mode.
4
+ *
5
+ * Push-navigation: tapping a container (File, Edit, …) or a submenu
6
+ * parent replaces the entire list with the sub-items. A back button
7
+ * returns to the parent level. Leaf items invoke the action + close.
7
8
  *
8
9
  * Reads the same dispatcher state and registry as MenuBar:
9
10
  * resolveMenuContainers(activeAppId, declared)
@@ -20,8 +21,9 @@
20
21
  import { getLiveDispatcherState } from '../actions/state.svelte';
21
22
  import { getRegisteredApp } from '../apps/registry.svelte';
22
23
  import { resolveLabel } from '../actions/types';
24
+ import Button from '../primitives/Button.svelte';
23
25
 
24
- let { open, onClose }: { open: boolean; onClose: () => void } = $props();
26
+ let { close }: { close: () => void } = $props();
25
27
 
26
28
  const dispatcher = $derived(getLiveDispatcherState());
27
29
  const activeAppId = $derived(dispatcher.activeAppId);
@@ -30,39 +32,84 @@
30
32
  return getRegisteredApp(activeAppId)?.manifest.menus;
31
33
  });
32
34
  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
35
 
43
- let expanded = $state(new Set<string>());
44
- let expandedSubmenu = $state(new Set<string>());
36
+ // --- navigation stack ------------------------------------------------
37
+ // Push-navigation replaces inline expand. Tapping a container pushes
38
+ // onto the stack; tapping a submenu parent pushes again. Back pops.
39
+ type NavEntry =
40
+ | { kind: 'root' }
41
+ | { kind: 'container'; containerId: string; label: string }
42
+ | { kind: 'submenu'; parentId: string; label: string };
43
+
44
+ let navStack = $state<NavEntry[]>([{ kind: 'root' }]);
45
+ const currentNav = $derived(navStack[navStack.length - 1]);
45
46
 
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;
47
+ function push(e: NavEntry) {
48
+ navStack = [...navStack, e];
51
49
  }
52
50
 
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;
51
+ function pop() {
52
+ if (navStack.length <= 1) return;
53
+ navStack = navStack.slice(0, -1);
58
54
  }
59
55
 
60
- function invoke(itemId: string) {
61
- const entry = listActions().find((e) => e.action.id === itemId);
62
- if (!entry || typeof entry.action.run !== 'function') return;
56
+ // --- derived items for current nav level ---------------------------
57
+ const currentItems = $derived.by(() => {
58
+ const entries = listActions();
59
+ const nav = currentNav;
60
+
61
+ if (nav.kind === 'root') {
62
+ // Show all containers with items
63
+ return containers
64
+ .filter((c) => resolveMenuItems(entries, dispatcher, c.id).length > 0)
65
+ .map((c) => ({
66
+ id: c.id,
67
+ label: c.label,
68
+ isContainer: true as const,
69
+ }));
70
+ }
71
+
72
+ if (nav.kind === 'container') {
73
+ const items = resolveMenuItems(entries, dispatcher, nav.containerId);
74
+ return items.map((item) => ({
75
+ id: item.id,
76
+ label: item.label,
77
+ shortcut: item.shortcut,
78
+ isSubmenu: item.submenu === true,
79
+ disabled: item.disabled,
80
+ }));
81
+ }
82
+
83
+ // submenu
84
+ const items = resolveSubmenuItems(entries, dispatcher, nav.parentId);
85
+ return items.map((item) => ({
86
+ id: item.id,
87
+ label: item.label,
88
+ shortcut: item.shortcut,
89
+ disabled: item.disabled,
90
+ isSubmenu: item.submenu === true,
91
+ }));
92
+ });
93
+
94
+ // --- actions --------------------------------------------------------
95
+ function handleTap(entry: { id: string; isContainer?: boolean; isSubmenu?: boolean }) {
96
+ if (entry.isContainer) {
97
+ const c = containers.find((x) => x.id === entry.id);
98
+ if (c) push({ kind: 'container', containerId: c.id, label: c.label });
99
+ return;
100
+ }
101
+
102
+ if (entry.isSubmenu) {
103
+ push({ kind: 'submenu', parentId: entry.id, label: entry.label });
104
+ return;
105
+ }
106
+
107
+ // leaf item — invoke action
108
+ const actionEntry = listActions().find((e) => e.action.id === entry.id);
109
+ if (!actionEntry || typeof actionEntry.action.run !== 'function') return;
63
110
  try {
64
- void entry.action.run({
65
- action: { id: itemId, label: resolveLabel(entry.action) },
111
+ void actionEntry.action.run({
112
+ action: { id: entry.id, label: resolveLabel(actionEntry.action) },
66
113
  appId: dispatcher.activeAppId,
67
114
  viewId: dispatcher.focusedViewId ?? undefined,
68
115
  selection: dispatcher.selection ?? undefined,
@@ -70,155 +117,114 @@
70
117
  dispatch: () => {},
71
118
  });
72
119
  } catch (err) {
73
- console.error(`[sh3] menu-sheet action "${itemId}" threw:`, err);
120
+ console.error(`[sh3] menu-sheet action "${entry.id}" threw:`, err);
121
+ }
122
+ close();
123
+ }
124
+
125
+ function back() {
126
+ pop();
127
+ }
128
+
129
+ function onKeydown(e: KeyboardEvent) {
130
+ if (e.key === 'Escape') {
131
+ if (navStack.length > 1) {
132
+ pop();
133
+ e.preventDefault();
134
+ } else {
135
+ close();
136
+ }
137
+ return;
74
138
  }
75
- onClose();
76
139
  }
77
140
  </script>
78
141
 
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>
142
+ <div class="sh3-menu-sheet" role="dialog" aria-label="Menu" tabindex="-1" data-sh3-region="menu-sheet" onkeydown={onKeydown}>
143
+ <div class="head">
144
+ {#if navStack.length > 1}
145
+ <Button variant="icon" icon="chevron-left" ariaLabel="Back" title="Back" onclick={back} />
146
+ {/if}
147
+ <span class="title">
148
+ {currentNav.kind === 'root' ? 'Menu' : currentNav.label}
149
+ </span>
150
+ </div>
151
+ <div class="scroll">
152
+ {#each currentItems as entry (entry.id)}
153
+ <button
154
+ class="item"
155
+ disabled={entry.disabled}
156
+ onclick={() => handleTap(entry)}
157
+ >
158
+ <span class="label">{entry.label}</span>
159
+ {#if entry.isContainer || entry.isSubmenu}
160
+ <span class="chevron">›</span>
161
+ {:else if entry.shortcut}
162
+ <span class="shortcut">{entry.shortcut}</span>
140
163
  {/if}
141
- {/each}
142
- </div>
143
- <button class="cancel" onclick={onClose}>Cancel</button>
164
+ </button>
165
+ {/each}
144
166
  </div>
167
+ {#if currentItems.length === 0}
168
+ <div class="empty">No menu items available.</div>
145
169
  {/if}
170
+ </div>
146
171
 
147
172
  <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;
173
+ .sh3-menu-sheet {
161
174
  display: flex;
162
175
  flex-direction: column;
163
- background: var(--sh3-bg);
176
+ max-height: 70vh;
177
+ overflow: hidden;
164
178
  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);
179
+ }
180
+ .head {
181
+ display: flex;
182
+ align-items: center;
183
+ gap: var(--sh3-pad-xs);
184
+ padding: 6px 8px;
185
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
186
+ }
187
+ .title {
188
+ font-size: 10px;
189
+ text-transform: uppercase;
190
+ letter-spacing: 0.5px;
191
+ color: var(--sh3-fg-muted);
169
192
  }
170
193
  .scroll {
171
194
  flex: 1;
172
195
  min-height: 0;
173
196
  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;
197
+ padding: 4px 0;
188
198
  }
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
199
  .item {
193
200
  display: flex;
194
201
  align-items: center;
195
202
  gap: var(--sh3-pad-sm);
196
203
  width: 100%;
197
- padding: var(--sh3-pad-sm) var(--sh3-pad-md);
204
+ padding: 9px var(--sh3-pad-md);
198
205
  border: none;
199
206
  background: none;
200
207
  color: var(--sh3-fg);
201
208
  text-align: left;
202
209
  cursor: pointer;
210
+ font: inherit;
203
211
  }
204
212
  .item:disabled { opacity: 0.5; cursor: not-allowed; }
205
213
  .item:active:not(:disabled) { background: var(--sh3-bg-sunken); }
206
- .item.child { padding-left: calc(var(--sh3-pad-md) * 2); }
207
214
  .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;
215
+ .chevron {
216
+ opacity: 0.3;
217
+ font-size: 14px;
213
218
  }
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;
219
+ .shortcut {
220
+ color: var(--sh3-fg-muted);
221
+ font-family: var(--sh3-font-mono);
222
+ font-size: 0.9em;
223
+ }
224
+ .empty {
225
+ padding: 20px 12px;
226
+ text-align: center;
227
+ color: var(--sh3-fg-muted);
228
+ font-style: italic;
223
229
  }
224
230
  </style>