sh3-core 0.19.1 → 0.19.5

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/Sh3.svelte +3 -1
  2. package/dist/actions/menuBarModel.js +8 -0
  3. package/dist/actions/menuBarModel.test.js +61 -0
  4. package/dist/api.d.ts +4 -0
  5. package/dist/api.js +3 -0
  6. package/dist/app/admin/ApiKeysView.svelte +6 -5
  7. package/dist/app/store/PermissionConfirmModal.svelte +23 -0
  8. package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
  9. package/dist/app/store/StoreView.svelte +6 -1
  10. package/dist/chrome/CompactChrome.svelte +34 -1
  11. package/dist/chrome/CompactChrome.svelte.test.js +11 -6
  12. package/dist/chrome/FloatsSheet.svelte +236 -0
  13. package/dist/chrome/FloatsSheet.svelte.d.ts +7 -0
  14. package/dist/chrome/FloatsSheet.svelte.test.d.ts +1 -0
  15. package/dist/chrome/FloatsSheet.svelte.test.js +155 -0
  16. package/dist/env/client.d.ts +5 -4
  17. package/dist/env/client.js +11 -17
  18. package/dist/env/serverUrl.d.ts +2 -0
  19. package/dist/env/serverUrl.js +8 -0
  20. package/dist/gestures/index.d.ts +17 -0
  21. package/dist/gestures/index.js +27 -0
  22. package/dist/keys/client.js +6 -7
  23. package/dist/keys/revocation-bus.svelte.js +11 -1
  24. package/dist/layout/compact/CarouselTabs.svelte +150 -14
  25. package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
  26. package/dist/layout/compact/CompactRenderer.svelte +9 -3
  27. package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
  28. package/dist/layout/compact/derive.js +7 -16
  29. package/dist/layout/compact/derive.test.js +30 -9
  30. package/dist/layout/compact/rootStore.svelte.d.ts +20 -0
  31. package/dist/layout/compact/rootStore.svelte.js +59 -0
  32. package/dist/layout/compact/rootStore.svelte.test.d.ts +1 -0
  33. package/dist/layout/compact/rootStore.svelte.test.js +54 -0
  34. package/dist/layout/drag.svelte.js +16 -3
  35. package/dist/layout/floats.d.ts +27 -0
  36. package/dist/layout/floats.js +20 -0
  37. package/dist/layout/floats.test.js +34 -1
  38. package/dist/layout/inspection.d.ts +20 -9
  39. package/dist/layout/inspection.js +91 -13
  40. package/dist/layout/inspection.svelte.test.d.ts +1 -0
  41. package/dist/layout/inspection.svelte.test.js +163 -0
  42. package/dist/layout/store.schemaVersion.test.js +2 -2
  43. package/dist/layout/types.d.ts +11 -8
  44. package/dist/layout/types.js +1 -1
  45. package/dist/layout/types.test.js +2 -2
  46. package/dist/overlays/FloatFrame.svelte +93 -22
  47. package/dist/overlays/FloatLayer.svelte +12 -1
  48. package/dist/overlays/float.d.ts +7 -0
  49. package/dist/overlays/float.js +76 -6
  50. package/dist/overlays/float.test.js +170 -0
  51. package/dist/primitives/ResizableSplitter.svelte +42 -8
  52. package/dist/primitives/widgets/DocumentFilePicker.d.ts +25 -0
  53. package/dist/primitives/widgets/DocumentFilePicker.js +74 -0
  54. package/dist/primitives/widgets/DocumentFilePicker.svelte +144 -0
  55. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +18 -0
  56. package/dist/primitives/widgets/DocumentOpener.svelte +36 -0
  57. package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +17 -0
  58. package/dist/primitives/widgets/DocumentSaver.svelte +36 -0
  59. package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +17 -0
  60. package/dist/primitives/widgets/_DocumentBrowser.svelte +337 -0
  61. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +11 -0
  62. package/dist/registry/checkFetch.d.ts +6 -0
  63. package/dist/registry/checkFetch.js +23 -0
  64. package/dist/sh3/views/KeysAndPeers.svelte +4 -3
  65. package/dist/shards/activate-runtime.test.js +99 -1
  66. package/dist/shards/activate.svelte.js +12 -3
  67. package/dist/shards/registry.d.ts +8 -1
  68. package/dist/shards/registry.js +13 -2
  69. package/dist/shards/registry.test.js +25 -4
  70. package/dist/shards/types.d.ts +14 -1
  71. package/dist/shell-shard/ScrollbackView.svelte +145 -67
  72. package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
  73. package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
  74. package/dist/shell-shard/dispatch-gating.test.js +38 -2
  75. package/dist/shell-shard/dispatch.js +9 -1
  76. package/dist/shell-shard/registry-resolve.test.js +50 -0
  77. package/dist/shell-shard/registry.d.ts +2 -1
  78. package/dist/shell-shard/registry.js +12 -2
  79. package/dist/shell-shard/verbs/help.js +5 -4
  80. package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
  81. package/dist/verbs/types.d.ts +10 -5
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/package.json +1 -1
package/dist/Sh3.svelte CHANGED
@@ -30,6 +30,7 @@
30
30
  import CompactRenderer from './layout/compact/CompactRenderer.svelte';
31
31
  import { sh3 } from './sh3Runtime.svelte';
32
32
  import { claim, revoke } from './gestures/pointerClaim';
33
+ import { EDGE_PX } from './gestures';
33
34
 
34
35
  let contentEl: HTMLElement | undefined = $state();
35
36
 
@@ -70,7 +71,8 @@
70
71
  // Register left/right edge zones as priority:'edge' pointer claims so that
71
72
  // any shard with a priority:'normal' claim automatically beats the shell's
72
73
  // edge-swipe gesture (carousel navigation, future side-panel reveals).
73
- const EDGE_PX = 24;
74
+ // EDGE_PX is shared with carousel/swipe sites so the gutter width stays
75
+ // consistent across the framework.
74
76
  $effect(() => {
75
77
  const el = contentEl;
76
78
  if (!el) return;
@@ -62,6 +62,12 @@ export function resolveMenuItems(entries, state, containerId) {
62
62
  const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
63
63
  if (!winning)
64
64
  continue;
65
+ // Menu surface only: when the action is active via the 'app' tier,
66
+ // require the owner shard to be in the active app's requiredShards.
67
+ // Dispatcher / palette / context menu / hotkey paths keep autostart
68
+ // activation. See issue #32 and the design spec dated 2026-05-12.
69
+ if (winning === 'app' && !state.activeAppRequiredShards.has(entry.ownerShardId))
70
+ continue;
65
71
  seen.add(entry.action.id);
66
72
  out.push({
67
73
  id: entry.action.id,
@@ -95,6 +101,8 @@ export function resolveSubmenuItems(entries, state, parentId) {
95
101
  const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
96
102
  if (!winning)
97
103
  continue;
104
+ if (winning === 'app' && !state.activeAppRequiredShards.has(entry.ownerShardId))
105
+ continue;
98
106
  seen.add(entry.action.id);
99
107
  out.push({
100
108
  id: entry.action.id,
@@ -156,3 +156,64 @@ describe('resolveSubmenuItems', () => {
156
156
  expect(resolveSubmenuItems([], stateWithApp, 'nope')).toEqual([]);
157
157
  });
158
158
  });
159
+ describe("resolveMenuItems — required-shard menu filter (issue #32)", () => {
160
+ it("omits scope:'app' actions when owner shard is autostart-only (not required by active app)", () => {
161
+ const state = mkState({
162
+ activeAppId: 'other-app',
163
+ activeAppRequiredShards: new Set(['other-shard']),
164
+ autostartShards: new Set(['guml.core']),
165
+ });
166
+ const entries = [
167
+ mkEntry({ id: 'guml.project.new', scope: 'app', menuItem: 'file', label: 'New Project…' }, 'guml.core'),
168
+ ];
169
+ expect(resolveMenuItems(entries, state, 'file')).toEqual([]);
170
+ });
171
+ it("includes scope:'app' actions when owner shard IS in active app's requiredShards", () => {
172
+ const state = mkState({
173
+ activeAppId: 'guml-ide',
174
+ activeAppRequiredShards: new Set(['guml.core']),
175
+ autostartShards: new Set(['guml.core']),
176
+ });
177
+ const entries = [
178
+ mkEntry({ id: 'guml.project.new', scope: 'app', menuItem: 'file', label: 'New Project…' }, 'guml.core'),
179
+ ];
180
+ expect(resolveMenuItems(entries, state, 'file').map((i) => i.id))
181
+ .toEqual(['guml.project.new']);
182
+ });
183
+ it('still includes the action when a more-specific tier wins (view:editor over app)', () => {
184
+ const state = mkState({
185
+ activeAppId: 'other-app',
186
+ activeAppRequiredShards: new Set(['other-shard']),
187
+ autostartShards: new Set(['guml.core']),
188
+ mountedViewIds: new Set(['editor']),
189
+ });
190
+ const entries = [
191
+ mkEntry({ id: 'fmt', scope: ['view:editor', 'app'], menuItem: 'file', label: 'Format' }, 'guml.core'),
192
+ ];
193
+ expect(resolveMenuItems(entries, state, 'file').map((i) => i.id)).toEqual(['fmt']);
194
+ });
195
+ it("omits scope:['home','app'] action in an app that does not require the autostart owner", () => {
196
+ const state = mkState({
197
+ activeAppId: 'other-app',
198
+ activeAppRequiredShards: new Set(['other-shard']),
199
+ autostartShards: new Set(['guml.core']),
200
+ });
201
+ const entries = [
202
+ mkEntry({ id: 'g.global', scope: ['home', 'app'], menuItem: 'file', label: 'Global' }, 'guml.core'),
203
+ ];
204
+ expect(resolveMenuItems(entries, state, 'file')).toEqual([]);
205
+ });
206
+ it('drops submenu children whose owner shard is autostart-only', () => {
207
+ const state = mkState({
208
+ activeAppId: 'other-app',
209
+ activeAppRequiredShards: new Set(['other-shard']),
210
+ autostartShards: new Set(['guml.core']),
211
+ });
212
+ const entries = [
213
+ mkEntry({ id: 'g.parent', scope: 'app', menuItem: 'file', label: 'GUML', submenu: true }, 'guml.core'),
214
+ mkEntry({ id: 'g.parent.a', scope: 'app', label: 'A', submenuOf: 'g.parent' }, 'guml.core'),
215
+ ];
216
+ expect(resolveMenuItems(entries, state, 'file')).toEqual([]);
217
+ expect(resolveSubmenuItems(entries, state, 'g.parent')).toEqual([]);
218
+ });
219
+ });
package/dist/api.d.ts CHANGED
@@ -87,5 +87,9 @@ export { default as Select } from './primitives/widgets/Select.svelte';
87
87
  export type { SelectOption } from './primitives/widgets/Select';
88
88
  export { default as AppPicker } from './primitives/widgets/AppPicker.svelte';
89
89
  export { default as UserPicker } from './primitives/widgets/UserPicker.svelte';
90
+ export { default as DocumentFilePicker } from './primitives/widgets/DocumentFilePicker.svelte';
91
+ export { default as DocumentOpener } from './primitives/widgets/DocumentOpener.svelte';
92
+ export { default as DocumentSaver } from './primitives/widgets/DocumentSaver.svelte';
93
+ export type { OpenerValue, SaverValue } from './primitives/widgets/DocumentFilePicker';
90
94
  export type { FieldKind, FieldAddress, FieldView, ControllableFieldDescriptor, ImperativeFieldDescriptor, ElementRefFieldDescriptor, ReadonlyFieldDescriptor, FieldsApi, DecorationHandle, } from './fields/types';
91
95
  export { fieldAddressToString, fieldAddressFromString } from './fields/address';
package/dist/api.js CHANGED
@@ -97,4 +97,7 @@ export { default as FilePicker } from './primitives/widgets/FilePicker.svelte';
97
97
  export { default as Select } from './primitives/widgets/Select.svelte';
98
98
  export { default as AppPicker } from './primitives/widgets/AppPicker.svelte';
99
99
  export { default as UserPicker } from './primitives/widgets/UserPicker.svelte';
100
+ export { default as DocumentFilePicker } from './primitives/widgets/DocumentFilePicker.svelte';
101
+ export { default as DocumentOpener } from './primitives/widgets/DocumentOpener.svelte';
102
+ export { default as DocumentSaver } from './primitives/widgets/DocumentSaver.svelte';
100
103
  export { fieldAddressToString, fieldAddressFromString } from './fields/address';
@@ -3,6 +3,9 @@
3
3
  * Admin API Keys view — list, create, reveal, revoke API keys.
4
4
  */
5
5
 
6
+ import { apiFetch } from '../../transport/apiFetch';
7
+ import { getEnvServerUrl } from '../../env/serverUrl';
8
+
6
9
  interface ApiKeyPublic {
7
10
  id: string;
8
11
  label: string;
@@ -29,7 +32,7 @@
29
32
  loading = true;
30
33
  error = null;
31
34
  try {
32
- const res = await fetch('/api/admin/keys', { credentials: 'include' });
35
+ const res = await apiFetch(`${getEnvServerUrl()}/api/admin/keys`);
33
36
  if (!res.ok) throw new Error('Failed to fetch keys');
34
37
  keys = await res.json();
35
38
  } catch (err) {
@@ -42,10 +45,9 @@
42
45
  async function createKey() {
43
46
  createError = null;
44
47
  try {
45
- const res = await fetch('/api/admin/keys', {
48
+ const res = await apiFetch(`${getEnvServerUrl()}/api/admin/keys`, {
46
49
  method: 'POST',
47
50
  headers: { 'Content-Type': 'application/json' },
48
- credentials: 'include',
49
51
  body: JSON.stringify({ label: newLabel }),
50
52
  });
51
53
  if (!res.ok) {
@@ -66,9 +68,8 @@
66
68
  async function revokeKey(id: string) {
67
69
  confirmingId = null;
68
70
  try {
69
- await fetch(`/api/admin/keys/${id}`, {
71
+ await apiFetch(`${getEnvServerUrl()}/api/admin/keys/${id}`, {
70
72
  method: 'DELETE',
71
- credentials: 'include',
72
73
  });
73
74
  if (justCreated?.id === id) justCreated = null;
74
75
  await fetchKeys();
@@ -21,6 +21,7 @@
21
21
  permissions?: string[];
22
22
  added?: string[];
23
23
  removed?: string[];
24
+ warnings?: string[];
24
25
  onConfirm: () => void;
25
26
  onCancel: () => void;
26
27
  }
@@ -32,6 +33,7 @@
32
33
  permissions = [],
33
34
  added = [],
34
35
  removed = [],
36
+ warnings = [],
35
37
  onConfirm,
36
38
  onCancel,
37
39
  }: Props = $props();
@@ -111,6 +113,17 @@
111
113
  </ul>
112
114
  {/if}
113
115
  {/if}
116
+ {#if warnings.length > 0}
117
+ <p class="perm-modal-intro perm-modal-warn-heading">Potential compatibility issues:</p>
118
+ <ul class="perm-modal-list perm-modal-warnings">
119
+ {#each warnings as msg (msg)}
120
+ <li class="perm-modal-item perm-modal-warn-item">
121
+ <div class="perm-modal-item-title">⚠ Compatibility warning</div>
122
+ <div class="perm-modal-item-desc">{msg}</div>
123
+ </li>
124
+ {/each}
125
+ </ul>
126
+ {/if}
114
127
  </div>
115
128
 
116
129
  <footer class="perm-modal-footer">
@@ -203,6 +216,16 @@
203
216
  .perm-modal-removed .perm-modal-item {
204
217
  opacity: 0.75;
205
218
  }
219
+ .perm-modal-warn-heading {
220
+ margin-top: 12px;
221
+ }
222
+ .perm-modal-warn-item {
223
+ border-color: color-mix(in srgb, var(--sh3-warning, #ff9800) 60%, var(--sh3-border, #444));
224
+ background: color-mix(in srgb, var(--sh3-warning, #ff9800) 8%, var(--sh3-input-bg, #2a2a2a));
225
+ }
226
+ .perm-modal-warn-item .perm-modal-item-title {
227
+ color: var(--sh3-warning, #ff9800);
228
+ }
206
229
  .perm-modal-footer {
207
230
  padding: 12px 20px;
208
231
  border-top: 1px solid var(--sh3-border, #444);
@@ -9,6 +9,7 @@ interface Props {
9
9
  permissions?: string[];
10
10
  added?: string[];
11
11
  removed?: string[];
12
+ warnings?: string[];
12
13
  onConfirm: () => void;
13
14
  onCancel: () => void;
14
15
  }
@@ -12,6 +12,7 @@
12
12
  import { loadBundleModule, type LoadedBundle } from '../../registry/loader';
13
13
  import { extractBundlePermissions } from '../../registry/permission-descriptions';
14
14
  import { serverInstallPackage } from '../../env/client';
15
+ import { checkBundleFetch } from '../../registry/checkFetch';
15
16
  import { contract } from '../../contract';
16
17
  import type { ResolvedPackage } from '../../registry/client';
17
18
  import type { InstalledPackage } from '../../registry/types';
@@ -38,6 +39,7 @@
38
39
  bundle: ArrayBuffer;
39
40
  meta: ReturnType<typeof buildPackageMeta>;
40
41
  serverBundle: ArrayBuffer | undefined;
42
+ warnings: string[];
41
43
  }>(null);
42
44
 
43
45
  let updateModal = $state<null | {
@@ -186,7 +188,9 @@
186
188
 
187
189
  // 4. Show the confirmation modal. The actual install happens in
188
190
  // confirmInstall() once the user clicks Install.
189
- installModal = { pkg, permissions, loaded, bundle, meta, serverBundle };
191
+ const bundleText = new TextDecoder().decode(new Uint8Array(bundle));
192
+ const warnings = checkBundleFetch(bundleText);
193
+ installModal = { pkg, permissions, loaded, bundle, meta, serverBundle, warnings };
190
194
  } catch (err) {
191
195
  installError = err instanceof Error ? err.message : String(err);
192
196
  const next = new Set(installingIds);
@@ -400,6 +404,7 @@
400
404
  author: installModal.pkg.entry.author.name,
401
405
  }}
402
406
  permissions={installModal.permissions}
407
+ warnings={installModal.warnings}
403
408
  onConfirm={confirmInstall}
404
409
  onCancel={cancelInstall}
405
410
  />
@@ -12,14 +12,23 @@
12
12
  import { sh3 } from '../sh3Runtime.svelte';
13
13
  import { layoutStore, getActiveRoot } from '../layout/store.svelte';
14
14
  import { derive } from '../layout/compact/derive';
15
+ import {
16
+ compactRootStore,
17
+ resolveCompactBodyRoot,
18
+ } from '../layout/compact/rootStore.svelte';
15
19
  import { getLiveDispatcherState } from '../actions/state.svelte';
16
20
  import { getRegisteredApp } from '../apps/registry.svelte';
17
21
  import { returnToHome } from '../apps/lifecycle';
18
22
  import Button from '../primitives/Button.svelte';
19
23
  import MenuSheet from './MenuSheet.svelte';
24
+ import FloatsSheet from './FloatsSheet.svelte';
20
25
  import type { DrawerAnchor } from '../layout/compact/types';
21
26
 
22
- const rendering = $derived(derive(layoutStore.root));
27
+ // Drawer toggles re-derive against whatever is currently the body —
28
+ // when the user is on a float, its role-tagged slots drive the chrome,
29
+ // not the docked tree's.
30
+ const bodyRoot = $derived(resolveCompactBodyRoot());
31
+ const rendering = $derived(derive(bodyRoot));
23
32
  const dispatcher = $derived(getLiveDispatcherState());
24
33
  const onHome = $derived(getActiveRoot() === 'home');
25
34
  const appLabel = $derived.by(() => {
@@ -47,13 +56,28 @@
47
56
  return bestLabel;
48
57
  });
49
58
 
59
+ const floatTitle = $derived.by(() => {
60
+ const cur = compactRootStore.current;
61
+ if (cur.kind !== 'float') return null;
62
+ const f = layoutStore.tree.floats.find((x) => x.id === cur.floatId);
63
+ if (!f) return null;
64
+ if (f.title) return f.title;
65
+ if (f.content.type === 'tabs') {
66
+ const t = f.content.tabs[f.content.activeTab] ?? f.content.tabs[0];
67
+ return t?.label ?? null;
68
+ }
69
+ return null;
70
+ });
71
+
50
72
  const title = $derived.by(() => {
73
+ if (floatTitle) return floatTitle;
51
74
  const carouselLabel = topmostCarouselLabel;
52
75
  if (carouselLabel) return `${appLabel} › ${carouselLabel}`;
53
76
  return appLabel;
54
77
  });
55
78
 
56
79
  let menuOpen = $state(false);
80
+ let floatsOpen = $state(false);
57
81
 
58
82
  function toggleDrawer(anchor: DrawerAnchor) {
59
83
  sh3.drawers.toggle(anchor);
@@ -92,11 +116,20 @@
92
116
  <div class="title">{title}</div>
93
117
  <div class="trailing">
94
118
  <Button variant="icon" icon="command" ariaLabel="Open command palette" title="Open command palette" onclick={openPalette} />
119
+ <Button
120
+ variant="icon"
121
+ icon="layers"
122
+ ariaLabel="Floats"
123
+ title="Floats"
124
+ pressed={floatsOpen}
125
+ onclick={() => { floatsOpen = !floatsOpen; }}
126
+ />
95
127
  <Button variant="icon" icon="ellipsis-vertical" ariaLabel="Open menu" title="Open menu" onclick={() => { menuOpen = true; }} />
96
128
  </div>
97
129
  </header>
98
130
 
99
131
  <MenuSheet open={menuOpen} onClose={() => (menuOpen = false)} />
132
+ <FloatsSheet open={floatsOpen} onClose={() => (floatsOpen = false)} />
100
133
 
101
134
  <style>
102
135
  .sh3-compact-chrome {
@@ -65,7 +65,7 @@ describe('CompactChrome (dom)', () => {
65
65
  expect(host.querySelector('.leading [data-sh3-anchor="right"] button')).not.toBeNull();
66
66
  expect(host.querySelector('.leading [data-sh3-anchor="top"] button')).toBeNull();
67
67
  });
68
- it('renders palette + overflow buttons in the trailing section', () => {
68
+ it('renders palette + floats + overflow buttons in the trailing section', () => {
69
69
  attachApp(fakeApp());
70
70
  switchToApp();
71
71
  flushSync();
@@ -74,7 +74,9 @@ describe('CompactChrome (dom)', () => {
74
74
  mounted = mount(CompactChromeAny, { target: host });
75
75
  flushSync();
76
76
  const trailing = host.querySelectorAll('.trailing button');
77
- expect(trailing.length).toBe(2);
77
+ expect(trailing.length).toBe(3);
78
+ const labels = Array.from(trailing).map((b) => b.getAttribute('aria-label'));
79
+ expect(labels).toEqual(['Open command palette', 'Floats', 'Open menu']);
78
80
  });
79
81
  });
80
82
  describe('CompactChrome — home button', () => {
@@ -125,9 +127,10 @@ describe('CompactChrome — breadcrumb', () => {
125
127
  initialLayout: {
126
128
  type: 'tabs',
127
129
  activeTab: 1,
130
+ role: 'body',
128
131
  tabs: [
129
- { slotId: 's0', viewId: null, label: 'First', role: 'body' },
130
- { slotId: 's1', viewId: null, label: 'Second', role: 'body' },
132
+ { slotId: 's0', viewId: null, label: 'First' },
133
+ { slotId: 's1', viewId: null, label: 'Second' },
131
134
  ],
132
135
  },
133
136
  };
@@ -152,12 +155,14 @@ describe('CompactChrome — breadcrumb', () => {
152
155
  {
153
156
  type: 'tabs',
154
157
  activeTab: 0,
155
- tabs: [{ slotId: 'top0', viewId: null, label: 'TopActive', role: 'body' }],
158
+ role: 'body',
159
+ tabs: [{ slotId: 'top0', viewId: null, label: 'TopActive' }],
156
160
  },
157
161
  {
158
162
  type: 'tabs',
159
163
  activeTab: 0,
160
- tabs: [{ slotId: 'bot0', viewId: null, label: 'BottomActive', role: 'body' }],
164
+ role: 'body',
165
+ tabs: [{ slotId: 'bot0', viewId: null, label: 'BottomActive' }],
161
166
  },
162
167
  ],
163
168
  },
@@ -0,0 +1,236 @@
1
+ <script lang="ts">
2
+ /*
3
+ * FloatsSheet — bottom-anchored navigation sheet for compact mode.
4
+ *
5
+ * Lists the active-layout entry plus one row per non-dismissable float.
6
+ * Tapping a row calls compactRootStore.setRoot(...) and closes the sheet.
7
+ * Dismissable pickers (anchored popovers) are excluded — they aren't
8
+ * "places to go", they're transient overlays.
9
+ *
10
+ * The active-layout row label tracks the active app (or "Home" when no
11
+ * app is attached). The current row is marked with data-current="true"
12
+ * for styling.
13
+ */
14
+ import { layoutStore, getActiveRoot } from '../layout/store.svelte';
15
+ import { compactRootStore } from '../layout/compact/rootStore.svelte';
16
+ import { floatManager } from '../overlays/float';
17
+ import { getLiveDispatcherState } from '../actions/state.svelte';
18
+ import { getRegisteredApp } from '../apps/registry.svelte';
19
+ import type { FloatEntry } from '../layout/types';
20
+
21
+ let { open, onClose }: { open: boolean; onClose: () => void } = $props();
22
+
23
+ const dispatcher = $derived(getLiveDispatcherState());
24
+ const dockedLabel = $derived.by(() => {
25
+ if (getActiveRoot() === 'home') return 'Home';
26
+ const id = dispatcher.activeAppId;
27
+ if (!id) return 'Home';
28
+ return getRegisteredApp(id)?.manifest.label ?? id;
29
+ });
30
+
31
+ function floatLabel(f: FloatEntry): string {
32
+ if (f.title) return f.title;
33
+ if (f.content.type === 'tabs') {
34
+ const t = f.content.tabs[f.content.activeTab] ?? f.content.tabs[0];
35
+ if (t) return t.label;
36
+ }
37
+ return f.id;
38
+ }
39
+
40
+ const rows = $derived.by(() => {
41
+ const out: { id: 'docked' | string; label: string }[] = [
42
+ { id: 'docked', label: dockedLabel },
43
+ ];
44
+ for (const f of layoutStore.tree.floats) {
45
+ if (f.dismissable) continue;
46
+ out.push({ id: f.id, label: floatLabel(f) });
47
+ }
48
+ return out;
49
+ });
50
+
51
+ function isCurrent(rowId: 'docked' | string): boolean {
52
+ const cur = compactRootStore.current;
53
+ if (rowId === 'docked') return cur.kind === 'docked';
54
+ return cur.kind === 'float' && cur.floatId === rowId;
55
+ }
56
+
57
+ function activate(rowId: 'docked' | string): void {
58
+ if (rowId === 'docked') {
59
+ compactRootStore.reset();
60
+ } else {
61
+ compactRootStore.setRoot({ kind: 'float', floatId: rowId });
62
+ }
63
+ onClose();
64
+ }
65
+
66
+ // ----- swipe-to-close --------------------------------------------------
67
+ // Horizontal pointer drag on a float row past 40% of its width closes the
68
+ // float. The active-layout row is non-swipeable. Document-level pointer
69
+ // listeners survive the pointer leaving the row mid-drag, mirroring the
70
+ // float-frame drag pattern.
71
+ const SWIPE_THRESHOLD = 0.4;
72
+ let swipingId = $state<string | null>(null);
73
+ let swipeDx = $state(0);
74
+ let swipeStartX = 0;
75
+ let swipePointerId: number | null = null;
76
+ let swipeRowEl: HTMLElement | null = null;
77
+
78
+ function rowOffset(rowId: 'docked' | string): number {
79
+ return swipingId === rowId ? swipeDx : 0;
80
+ }
81
+
82
+ function rowTransition(rowId: 'docked' | string): string {
83
+ return swipingId === rowId ? 'none' : 'transform 160ms ease-out';
84
+ }
85
+
86
+ function onRowPointerDown(e: PointerEvent, rowId: 'docked' | string): void {
87
+ if (rowId === 'docked') return;
88
+ if (e.button !== 0) return;
89
+ if (swipingId !== null) return;
90
+ swipingId = rowId;
91
+ swipeStartX = e.clientX;
92
+ swipeDx = 0;
93
+ swipePointerId = e.pointerId;
94
+ // Prefer e.currentTarget; fall back to a DOM query so synthetic events
95
+ // (vitest dispatchEvent) that may not populate currentTarget still
96
+ // resolve the row width for the threshold check.
97
+ swipeRowEl =
98
+ (e.currentTarget as HTMLElement | null) ??
99
+ (document.querySelector(`[data-sh3-floats-row="${rowId}"]`) as HTMLElement | null);
100
+ document.addEventListener('pointermove', onSwipeMove);
101
+ document.addEventListener('pointerup', onSwipeUp);
102
+ document.addEventListener('pointercancel', onSwipeCancel);
103
+ }
104
+
105
+ function onSwipeMove(e: PointerEvent): void {
106
+ if (e.pointerId !== swipePointerId) return;
107
+ swipeDx = e.clientX - swipeStartX;
108
+ }
109
+
110
+ function endSwipe(): void {
111
+ document.removeEventListener('pointermove', onSwipeMove);
112
+ document.removeEventListener('pointerup', onSwipeUp);
113
+ document.removeEventListener('pointercancel', onSwipeCancel);
114
+ swipingId = null;
115
+ swipePointerId = null;
116
+ swipeStartX = 0;
117
+ swipeDx = 0;
118
+ swipeRowEl = null;
119
+ }
120
+
121
+ function onSwipeUp(e: PointerEvent): void {
122
+ if (e.pointerId !== swipePointerId) return;
123
+ const id = swipingId;
124
+ const width = swipeRowEl?.clientWidth ?? 0;
125
+ const dx = swipeDx;
126
+ endSwipe();
127
+ if (id && id !== 'docked' && width > 0 && Math.abs(dx) >= width * SWIPE_THRESHOLD) {
128
+ floatManager.close(id);
129
+ }
130
+ }
131
+
132
+ function onSwipeCancel(e: PointerEvent): void {
133
+ if (e.pointerId !== swipePointerId) return;
134
+ endSwipe();
135
+ }
136
+ </script>
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">
146
+ <div class="scroll">
147
+ {#each rows as row (row.id)}
148
+ <button
149
+ class="row"
150
+ data-sh3-floats-row={row.id}
151
+ data-current={isCurrent(row.id) ? 'true' : 'false'}
152
+ onclick={() => activate(row.id)}
153
+ onpointerdown={(e) => onRowPointerDown(e, row.id)}
154
+ style:transform="translateX({rowOffset(row.id)}px)"
155
+ style:transition={rowTransition(row.id)}
156
+ >
157
+ <span class="label">{row.label}</span>
158
+ {#if row.id === 'docked'}
159
+ <span class="kind">layout</span>
160
+ {:else}
161
+ <span class="kind">float</span>
162
+ {/if}
163
+ </button>
164
+ {/each}
165
+ </div>
166
+ <button class="cancel" onclick={onClose}>Cancel</button>
167
+ </div>
168
+ {/if}
169
+
170
+ <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;
184
+ display: flex;
185
+ flex-direction: column;
186
+ background: var(--sh3-bg);
187
+ 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
+ }
193
+ .scroll {
194
+ flex: 1;
195
+ min-height: 0;
196
+ overflow: auto;
197
+ padding: var(--sh3-pad-sm) 0;
198
+ }
199
+ .row {
200
+ display: flex;
201
+ align-items: center;
202
+ gap: var(--sh3-pad-sm);
203
+ width: 100%;
204
+ padding: var(--sh3-pad-md);
205
+ border: none;
206
+ background: none;
207
+ color: var(--sh3-fg);
208
+ text-align: left;
209
+ cursor: pointer;
210
+ /* Suppress browser-claimed horizontal pan so swipe-to-close survives
211
+ past the system scroll-claim threshold on Android/iOS. */
212
+ touch-action: pan-y;
213
+ user-select: none;
214
+ }
215
+ .row[data-current='true'] {
216
+ background: var(--sh3-bg-sunken);
217
+ font-weight: 600;
218
+ }
219
+ .row:active { background: var(--sh3-bg-sunken); }
220
+ .label { flex: 1; }
221
+ .kind {
222
+ color: var(--sh3-fg-muted);
223
+ font-size: 11px;
224
+ text-transform: uppercase;
225
+ letter-spacing: 0.05em;
226
+ }
227
+ .cancel {
228
+ padding: var(--sh3-pad-md);
229
+ border: none;
230
+ border-top: 1px solid var(--sh3-border);
231
+ background: var(--sh3-bg-elevated);
232
+ color: var(--sh3-fg);
233
+ font-weight: 600;
234
+ cursor: pointer;
235
+ }
236
+ </style>
@@ -0,0 +1,7 @@
1
+ type $$ComponentProps = {
2
+ open: boolean;
3
+ onClose: () => void;
4
+ };
5
+ declare const FloatsSheet: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type FloatsSheet = ReturnType<typeof FloatsSheet>;
7
+ export default FloatsSheet;
@@ -0,0 +1 @@
1
+ export {};