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.
- package/dist/Sh3.svelte +3 -1
- package/dist/actions/menuBarModel.js +8 -0
- package/dist/actions/menuBarModel.test.js +61 -0
- package/dist/api.d.ts +4 -0
- package/dist/api.js +3 -0
- package/dist/app/admin/ApiKeysView.svelte +6 -5
- package/dist/app/store/PermissionConfirmModal.svelte +23 -0
- package/dist/app/store/PermissionConfirmModal.svelte.d.ts +1 -0
- package/dist/app/store/StoreView.svelte +6 -1
- package/dist/chrome/CompactChrome.svelte +34 -1
- package/dist/chrome/CompactChrome.svelte.test.js +11 -6
- package/dist/chrome/FloatsSheet.svelte +236 -0
- package/dist/chrome/FloatsSheet.svelte.d.ts +7 -0
- package/dist/chrome/FloatsSheet.svelte.test.d.ts +1 -0
- package/dist/chrome/FloatsSheet.svelte.test.js +155 -0
- package/dist/env/client.d.ts +5 -4
- package/dist/env/client.js +11 -17
- package/dist/env/serverUrl.d.ts +2 -0
- package/dist/env/serverUrl.js +8 -0
- package/dist/gestures/index.d.ts +17 -0
- package/dist/gestures/index.js +27 -0
- package/dist/keys/client.js +6 -7
- package/dist/keys/revocation-bus.svelte.js +11 -1
- package/dist/layout/compact/CarouselTabs.svelte +150 -14
- package/dist/layout/compact/CarouselTabs.svelte.test.js +222 -2
- package/dist/layout/compact/CompactRenderer.svelte +9 -3
- package/dist/layout/compact/CompactRenderer.svelte.test.js +5 -3
- package/dist/layout/compact/derive.js +7 -16
- package/dist/layout/compact/derive.test.js +30 -9
- package/dist/layout/compact/rootStore.svelte.d.ts +20 -0
- package/dist/layout/compact/rootStore.svelte.js +59 -0
- package/dist/layout/compact/rootStore.svelte.test.d.ts +1 -0
- package/dist/layout/compact/rootStore.svelte.test.js +54 -0
- package/dist/layout/drag.svelte.js +16 -3
- package/dist/layout/floats.d.ts +27 -0
- package/dist/layout/floats.js +20 -0
- package/dist/layout/floats.test.js +34 -1
- package/dist/layout/inspection.d.ts +20 -9
- package/dist/layout/inspection.js +91 -13
- package/dist/layout/inspection.svelte.test.d.ts +1 -0
- package/dist/layout/inspection.svelte.test.js +163 -0
- package/dist/layout/store.schemaVersion.test.js +2 -2
- package/dist/layout/types.d.ts +11 -8
- package/dist/layout/types.js +1 -1
- package/dist/layout/types.test.js +2 -2
- package/dist/overlays/FloatFrame.svelte +93 -22
- package/dist/overlays/FloatLayer.svelte +12 -1
- package/dist/overlays/float.d.ts +7 -0
- package/dist/overlays/float.js +76 -6
- package/dist/overlays/float.test.js +170 -0
- package/dist/primitives/ResizableSplitter.svelte +42 -8
- package/dist/primitives/widgets/DocumentFilePicker.d.ts +25 -0
- package/dist/primitives/widgets/DocumentFilePicker.js +74 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte +144 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +18 -0
- package/dist/primitives/widgets/DocumentOpener.svelte +36 -0
- package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +17 -0
- package/dist/primitives/widgets/DocumentSaver.svelte +36 -0
- package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +17 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +337 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +11 -0
- package/dist/registry/checkFetch.d.ts +6 -0
- package/dist/registry/checkFetch.js +23 -0
- package/dist/sh3/views/KeysAndPeers.svelte +4 -3
- package/dist/shards/activate-runtime.test.js +99 -1
- package/dist/shards/activate.svelte.js +12 -3
- package/dist/shards/registry.d.ts +8 -1
- package/dist/shards/registry.js +13 -2
- package/dist/shards/registry.test.js +25 -4
- package/dist/shards/types.d.ts +14 -1
- package/dist/shell-shard/ScrollbackView.svelte +145 -67
- package/dist/shell-shard/ScrollbackView.svelte.test.d.ts +1 -0
- package/dist/shell-shard/ScrollbackView.svelte.test.js +182 -0
- package/dist/shell-shard/dispatch-gating.test.js +38 -2
- package/dist/shell-shard/dispatch.js +9 -1
- package/dist/shell-shard/registry-resolve.test.js +50 -0
- package/dist/shell-shard/registry.d.ts +2 -1
- package/dist/shell-shard/registry.js +12 -2
- package/dist/shell-shard/verbs/help.js +5 -4
- package/dist/shell-shard/verbs/help.svelte.test.js +5 -2
- package/dist/verbs/types.d.ts +10 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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'
|
|
130
|
-
{ slotId: 's1', viewId: null, label: 'Second'
|
|
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
|
-
|
|
158
|
+
role: 'body',
|
|
159
|
+
tabs: [{ slotId: 'top0', viewId: null, label: 'TopActive' }],
|
|
156
160
|
},
|
|
157
161
|
{
|
|
158
162
|
type: 'tabs',
|
|
159
163
|
activeTab: 0,
|
|
160
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|