sh3-core 0.16.1 → 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.
- package/dist/Sh3.svelte +50 -108
- package/dist/__screenshots__/handheld.browser.test.ts/handheld-viewport-flip-e2e-viewport-override-flips-chrome-and-body-branches-1.png +0 -0
- package/dist/actions/ctx-actions.svelte.test.js +4 -4
- package/dist/actions/listActionsFromEntries.test.js +29 -0
- package/dist/actions/listActive.js +2 -0
- package/dist/actions/listeners.js +4 -0
- package/dist/actions/programmatic-dispatch.svelte.test.js +9 -2
- package/dist/actions/types.d.ts +8 -0
- package/dist/api.d.ts +6 -1
- package/dist/api.js +1 -0
- package/dist/chrome/CompactChrome.svelte +96 -0
- package/dist/chrome/CompactChrome.svelte.d.ts +3 -0
- package/dist/chrome/CompactChrome.svelte.test.d.ts +1 -0
- package/dist/chrome/CompactChrome.svelte.test.js +67 -0
- package/dist/chrome/MenuSheet.svelte +224 -0
- package/dist/chrome/MenuSheet.svelte.d.ts +7 -0
- package/dist/chrome/MenuSheet.svelte.test.d.ts +1 -0
- package/dist/chrome/MenuSheet.svelte.test.js +46 -0
- package/dist/contributions/index.d.ts +1 -1
- package/dist/contributions/index.js +1 -1
- package/dist/contributions/registry.d.ts +17 -1
- package/dist/contributions/registry.js +50 -2
- package/dist/contributions/scope.test.d.ts +1 -0
- package/dist/contributions/scope.test.js +52 -0
- package/dist/contributions/types.d.ts +11 -3
- package/dist/createShell.js +7 -1
- package/dist/fields/address.d.ts +3 -0
- package/dist/fields/address.js +36 -0
- package/dist/fields/address.test.d.ts +1 -0
- package/dist/fields/address.test.js +34 -0
- package/dist/fields/decoration.d.ts +7 -0
- package/dist/fields/decoration.js +199 -0
- package/dist/fields/decoration.svelte.test.d.ts +1 -0
- package/dist/fields/decoration.svelte.test.js +177 -0
- package/dist/fields/dispatch.d.ts +22 -0
- package/dist/fields/dispatch.js +254 -0
- package/dist/fields/dispatch.test.d.ts +1 -0
- package/dist/fields/dispatch.test.js +175 -0
- package/dist/fields/types.d.ts +101 -0
- package/dist/fields/types.js +16 -0
- package/dist/fields/walker.svelte.test.d.ts +1 -0
- package/dist/fields/walker.svelte.test.js +138 -0
- package/dist/handheld.browser.test.d.ts +1 -0
- package/dist/handheld.browser.test.js +90 -0
- package/dist/host.js +27 -2
- package/dist/host.svelte.test.d.ts +1 -0
- package/dist/host.svelte.test.js +92 -0
- package/dist/layout/LayoutRenderer.svelte +12 -1
- package/dist/layout/LayoutRenderer.svelte.d.ts +2 -1
- package/dist/layout/compact/CompactRenderer.svelte +53 -0
- package/dist/layout/compact/CompactRenderer.svelte.d.ts +3 -0
- package/dist/layout/compact/CompactRenderer.svelte.test.d.ts +1 -0
- package/dist/layout/compact/CompactRenderer.svelte.test.js +76 -0
- package/dist/layout/compact/derive.d.ts +3 -0
- package/dist/layout/compact/derive.js +155 -0
- package/dist/layout/compact/derive.test.d.ts +1 -0
- package/dist/layout/compact/derive.test.js +160 -0
- package/dist/layout/compact/drawerStore.svelte.d.ts +21 -0
- package/dist/layout/compact/drawerStore.svelte.js +75 -0
- package/dist/layout/compact/drawerStore.svelte.test.d.ts +1 -0
- package/dist/layout/compact/drawerStore.svelte.test.js +43 -0
- package/dist/layout/compact/resolveRole.d.ts +6 -0
- package/dist/layout/compact/resolveRole.js +13 -0
- package/dist/layout/compact/resolveRole.test.d.ts +1 -0
- package/dist/layout/compact/resolveRole.test.js +18 -0
- package/dist/layout/compact/types.d.ts +27 -0
- package/dist/layout/compact/types.js +15 -0
- package/dist/layout/presets.compactVariant.test.d.ts +1 -0
- package/dist/layout/presets.compactVariant.test.js +27 -0
- package/dist/layout/presets.d.ts +12 -0
- package/dist/layout/presets.js +16 -0
- package/dist/layout/slotHostPool.svelte.d.ts +8 -0
- package/dist/layout/slotHostPool.svelte.js +14 -1
- package/dist/layout/store.drawers.svelte.test.d.ts +1 -0
- package/dist/layout/store.drawers.svelte.test.js +49 -0
- package/dist/layout/store.schemaVersion.test.d.ts +1 -0
- package/dist/layout/store.schemaVersion.test.js +35 -0
- package/dist/layout/store.svelte.js +52 -2
- package/dist/layout/types.d.ts +43 -1
- package/dist/layout/types.js +1 -1
- package/dist/overlays/DrawerSurface.svelte +141 -0
- package/dist/overlays/DrawerSurface.svelte.d.ts +12 -0
- package/dist/overlays/DrawerSurface.svelte.test.d.ts +1 -0
- package/dist/overlays/DrawerSurface.svelte.test.js +67 -0
- package/dist/overlays/OverlayRoots.svelte +89 -0
- package/dist/overlays/OverlayRoots.svelte.d.ts +3 -0
- package/dist/overlays/types.d.ts +1 -1
- package/dist/platform/tauri-backend.d.ts +3 -3
- package/dist/platform/tauri-backend.js +24 -3
- package/dist/projects/session-state.svelte.d.ts +3 -3
- package/dist/projects/session-state.svelte.js +5 -4
- package/dist/runtime/runVerb.js +2 -2
- package/dist/satellite/SatelliteShell.svelte +58 -11
- package/dist/satellite/SatelliteShell.svelte.test.d.ts +1 -0
- package/dist/satellite/SatelliteShell.svelte.test.js +61 -0
- package/dist/sh3Api/fields-walker.svelte.test.d.ts +1 -0
- package/dist/sh3Api/fields-walker.svelte.test.js +75 -0
- package/dist/sh3Api/headless.d.ts +9 -0
- package/dist/sh3Api/headless.js +171 -16
- package/dist/sh3Api/headless.svelte.test.js +54 -10
- package/dist/sh3Runtime.svelte.d.ts +36 -0
- package/dist/sh3Runtime.svelte.js +33 -0
- package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -2
- package/dist/shards/activate-fields.svelte.test.d.ts +1 -0
- package/dist/shards/activate-fields.svelte.test.js +121 -0
- package/dist/shards/activate-runtime.test.js +8 -8
- package/dist/shards/activate.svelte.js +29 -35
- package/dist/shards/types.d.ts +23 -76
- package/dist/shell-shard/ScrollbackView.svelte +55 -9
- package/dist/shell-shard/Terminal.svelte +1 -1
- package/dist/shell-shard/scrollback-stick.d.ts +9 -0
- package/dist/shell-shard/scrollback-stick.js +21 -0
- package/dist/shell-shard/scrollback-stick.test.d.ts +1 -0
- package/dist/shell-shard/scrollback-stick.test.js +25 -0
- package/dist/tokens.css +3 -2
- package/dist/verbs/types.d.ts +59 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/viewport/classify.d.ts +8 -0
- package/dist/viewport/classify.js +20 -0
- package/dist/viewport/classify.test.d.ts +1 -0
- package/dist/viewport/classify.test.js +32 -0
- package/dist/viewport/store.browser.test.d.ts +1 -0
- package/dist/viewport/store.browser.test.js +33 -0
- package/dist/viewport/store.svelte.d.ts +9 -0
- package/dist/viewport/store.svelte.js +71 -0
- package/dist/viewport/store.svelte.test.d.ts +1 -0
- package/dist/viewport/store.svelte.test.js +54 -0
- package/dist/viewport/types.d.ts +9 -0
- package/dist/viewport/types.js +6 -0
- package/package.json +1 -1
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* DOM smoke for MenuSheet — verifies open/closed rendering. The
|
|
3
|
+
* container/item resolution is exercised via the same model functions
|
|
4
|
+
* MenuBar uses; their unit tests cover the resolution semantics so
|
|
5
|
+
* this test only asserts the wrapper structure.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
8
|
+
import { mount, unmount, flushSync } from 'svelte';
|
|
9
|
+
import MenuSheet from './MenuSheet.svelte';
|
|
10
|
+
const MenuSheetAny = MenuSheet;
|
|
11
|
+
let mounted = null;
|
|
12
|
+
let host = null;
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
if (mounted) {
|
|
15
|
+
unmount(mounted);
|
|
16
|
+
mounted = null;
|
|
17
|
+
}
|
|
18
|
+
if (host) {
|
|
19
|
+
host.remove();
|
|
20
|
+
host = null;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
describe('MenuSheet (dom)', () => {
|
|
24
|
+
it('renders nothing when closed', () => {
|
|
25
|
+
host = document.createElement('div');
|
|
26
|
+
document.body.appendChild(host);
|
|
27
|
+
mounted = mount(MenuSheetAny, {
|
|
28
|
+
target: host,
|
|
29
|
+
props: { open: false, onClose: () => { } },
|
|
30
|
+
});
|
|
31
|
+
flushSync();
|
|
32
|
+
expect(host.querySelector('[data-sh3-region="menu-sheet"]')).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
it('renders a sheet with a Cancel button when open', () => {
|
|
35
|
+
host = document.createElement('div');
|
|
36
|
+
document.body.appendChild(host);
|
|
37
|
+
mounted = mount(MenuSheetAny, {
|
|
38
|
+
target: host,
|
|
39
|
+
props: { open: true, onClose: () => { } },
|
|
40
|
+
});
|
|
41
|
+
flushSync();
|
|
42
|
+
const sheet = host.querySelector('[data-sh3-region="menu-sheet"]');
|
|
43
|
+
expect(sheet).not.toBeNull();
|
|
44
|
+
expect(sheet.querySelector('.cancel').textContent).toContain('Cancel');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export type { ContributionsApi } from './types';
|
|
2
|
-
export { register, list, listPoints, onChange, onAnyChange, __resetContributionsForTest } from './registry';
|
|
2
|
+
export { register, list, listPoints, onChange, onAnyChange, __disposeSlotContributions, __resetContributionsForTest, } from './registry';
|
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
* file is internal-only, re-exporting the registry for activate.svelte.ts
|
|
6
6
|
* and for tests.
|
|
7
7
|
*/
|
|
8
|
-
export { register, list, listPoints, onChange, onAnyChange, __resetContributionsForTest } from './registry';
|
|
8
|
+
export { register, list, listPoints, onChange, onAnyChange, __disposeSlotContributions, __resetContributionsForTest, } from './registry';
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Register a descriptor under the given point. Returns an unregister
|
|
3
3
|
* function; calling it more than once is a safe no-op.
|
|
4
|
+
*
|
|
5
|
+
* Pass `opts.scope.slotId` to tie cleanup to a slot's lifecycle:
|
|
6
|
+
* `__disposeSlotContributions(slotId)` will fire the disposer on slot
|
|
7
|
+
* unmount. The disposer is idempotent — manual dispose detaches it from
|
|
8
|
+
* the slot bag, so a later slot-cleanup pass becomes a no-op for that
|
|
9
|
+
* entry.
|
|
4
10
|
*/
|
|
5
|
-
export declare function register<T = unknown>(pointId: string, descriptor: T
|
|
11
|
+
export declare function register<T = unknown>(pointId: string, descriptor: T, opts?: {
|
|
12
|
+
scope?: {
|
|
13
|
+
slotId?: string;
|
|
14
|
+
};
|
|
15
|
+
}): () => void;
|
|
6
16
|
/** Enumerate descriptors at the named point in registration order. */
|
|
7
17
|
export declare function list<T = unknown>(pointId: string): T[];
|
|
8
18
|
/** Enumerate every point id with at least one registration. */
|
|
@@ -21,6 +31,12 @@ export declare function onChange(pointId: string, cb: () => void): () => void;
|
|
|
21
31
|
* safe no-op. Symmetric with `onChange`, but global.
|
|
22
32
|
*/
|
|
23
33
|
export declare function onAnyChange(cb: (pointId: string) => void): () => void;
|
|
34
|
+
/**
|
|
35
|
+
* Drain every disposer registered with `scope.slotId === slotId`. Safe
|
|
36
|
+
* to call on unknown slot ids. Used by the layout module on slot unmount
|
|
37
|
+
* to release contributions tied to that slot's lifetime.
|
|
38
|
+
*/
|
|
39
|
+
export declare function __disposeSlotContributions(slotId: string): void;
|
|
24
40
|
/**
|
|
25
41
|
* Test-only reset. Not exported from the barrel; tests import it
|
|
26
42
|
* directly from this module.
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
const points = new Map();
|
|
14
14
|
const listeners = new Map();
|
|
15
15
|
const anyListeners = new Set();
|
|
16
|
+
const slotCleanup = new Map();
|
|
16
17
|
function emit(pointId) {
|
|
17
18
|
const set = listeners.get(pointId);
|
|
18
19
|
if (set) {
|
|
@@ -22,11 +23,34 @@ function emit(pointId) {
|
|
|
22
23
|
for (const cb of anyListeners)
|
|
23
24
|
cb(pointId);
|
|
24
25
|
}
|
|
26
|
+
function attachToSlot(slotId, dispose) {
|
|
27
|
+
let bag = slotCleanup.get(slotId);
|
|
28
|
+
if (!bag) {
|
|
29
|
+
bag = new Set();
|
|
30
|
+
slotCleanup.set(slotId, bag);
|
|
31
|
+
}
|
|
32
|
+
bag.add(dispose);
|
|
33
|
+
}
|
|
34
|
+
function detachFromSlot(slotId, dispose) {
|
|
35
|
+
const bag = slotCleanup.get(slotId);
|
|
36
|
+
if (!bag)
|
|
37
|
+
return;
|
|
38
|
+
bag.delete(dispose);
|
|
39
|
+
if (bag.size === 0)
|
|
40
|
+
slotCleanup.delete(slotId);
|
|
41
|
+
}
|
|
25
42
|
/**
|
|
26
43
|
* Register a descriptor under the given point. Returns an unregister
|
|
27
44
|
* function; calling it more than once is a safe no-op.
|
|
45
|
+
*
|
|
46
|
+
* Pass `opts.scope.slotId` to tie cleanup to a slot's lifecycle:
|
|
47
|
+
* `__disposeSlotContributions(slotId)` will fire the disposer on slot
|
|
48
|
+
* unmount. The disposer is idempotent — manual dispose detaches it from
|
|
49
|
+
* the slot bag, so a later slot-cleanup pass becomes a no-op for that
|
|
50
|
+
* entry.
|
|
28
51
|
*/
|
|
29
|
-
export function register(pointId, descriptor) {
|
|
52
|
+
export function register(pointId, descriptor, opts) {
|
|
53
|
+
var _a;
|
|
30
54
|
const handle = Symbol();
|
|
31
55
|
let map = points.get(pointId);
|
|
32
56
|
if (!map) {
|
|
@@ -35,11 +59,14 @@ export function register(pointId, descriptor) {
|
|
|
35
59
|
}
|
|
36
60
|
map.set(handle, descriptor);
|
|
37
61
|
emit(pointId);
|
|
62
|
+
const slotId = (_a = opts === null || opts === void 0 ? void 0 : opts.scope) === null || _a === void 0 ? void 0 : _a.slotId;
|
|
38
63
|
let disposed = false;
|
|
39
|
-
|
|
64
|
+
const dispose = () => {
|
|
40
65
|
if (disposed)
|
|
41
66
|
return;
|
|
42
67
|
disposed = true;
|
|
68
|
+
if (slotId)
|
|
69
|
+
detachFromSlot(slotId, dispose);
|
|
43
70
|
const m = points.get(pointId);
|
|
44
71
|
if (!m)
|
|
45
72
|
return;
|
|
@@ -49,6 +76,9 @@ export function register(pointId, descriptor) {
|
|
|
49
76
|
emit(pointId);
|
|
50
77
|
}
|
|
51
78
|
};
|
|
79
|
+
if (slotId)
|
|
80
|
+
attachToSlot(slotId, dispose);
|
|
81
|
+
return dispose;
|
|
52
82
|
}
|
|
53
83
|
/** Enumerate descriptors at the named point in registration order. */
|
|
54
84
|
export function list(pointId) {
|
|
@@ -98,6 +128,23 @@ export function onAnyChange(cb) {
|
|
|
98
128
|
anyListeners.delete(cb);
|
|
99
129
|
};
|
|
100
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Drain every disposer registered with `scope.slotId === slotId`. Safe
|
|
133
|
+
* to call on unknown slot ids. Used by the layout module on slot unmount
|
|
134
|
+
* to release contributions tied to that slot's lifetime.
|
|
135
|
+
*/
|
|
136
|
+
export function __disposeSlotContributions(slotId) {
|
|
137
|
+
const bag = slotCleanup.get(slotId);
|
|
138
|
+
if (!bag)
|
|
139
|
+
return;
|
|
140
|
+
// Snapshot before iterating: each dispose detaches itself from the bag
|
|
141
|
+
// via detachFromSlot, which mutates the live set.
|
|
142
|
+
const snapshot = Array.from(bag);
|
|
143
|
+
for (const dispose of snapshot)
|
|
144
|
+
dispose();
|
|
145
|
+
// detachFromSlot already removes empty bags, but be explicit.
|
|
146
|
+
slotCleanup.delete(slotId);
|
|
147
|
+
}
|
|
101
148
|
/**
|
|
102
149
|
* Test-only reset. Not exported from the barrel; tests import it
|
|
103
150
|
* directly from this module.
|
|
@@ -106,4 +153,5 @@ export function __resetContributionsForTest() {
|
|
|
106
153
|
points.clear();
|
|
107
154
|
listeners.clear();
|
|
108
155
|
anyListeners.clear();
|
|
156
|
+
slotCleanup.clear();
|
|
109
157
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { register, list, onChange, __disposeSlotContributions, __resetContributionsForTest, } from './registry';
|
|
3
|
+
describe('contributions slot scope', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
__resetContributionsForTest();
|
|
6
|
+
});
|
|
7
|
+
it('register without scope behaves exactly as before', () => {
|
|
8
|
+
const dispose = register('p', { id: 'a' });
|
|
9
|
+
expect(list('p')).toEqual([{ id: 'a' }]);
|
|
10
|
+
dispose();
|
|
11
|
+
expect(list('p')).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
it('register with slot scope is reachable like any contribution', () => {
|
|
14
|
+
register('p', { id: 'a' }, { scope: { slotId: 's1' } });
|
|
15
|
+
expect(list('p')).toEqual([{ id: 'a' }]);
|
|
16
|
+
});
|
|
17
|
+
it('__disposeSlotContributions drains only the targeted slot', () => {
|
|
18
|
+
register('p', { id: 'a-s1' }, { scope: { slotId: 's1' } });
|
|
19
|
+
register('p', { id: 'b-s1' }, { scope: { slotId: 's1' } });
|
|
20
|
+
register('p', { id: 'c-s2' }, { scope: { slotId: 's2' } });
|
|
21
|
+
register('p', { id: 'd-noscope' });
|
|
22
|
+
__disposeSlotContributions('s1');
|
|
23
|
+
const remaining = list('p').map((d) => d.id).sort();
|
|
24
|
+
expect(remaining).toEqual(['c-s2', 'd-noscope']);
|
|
25
|
+
});
|
|
26
|
+
it('__disposeSlotContributions on unknown slot is a no-op', () => {
|
|
27
|
+
register('p', { id: 'a' }, { scope: { slotId: 's1' } });
|
|
28
|
+
expect(() => __disposeSlotContributions('s999')).not.toThrow();
|
|
29
|
+
expect(list('p')).toEqual([{ id: 'a' }]);
|
|
30
|
+
});
|
|
31
|
+
it('manually calling the disposer first makes __disposeSlotContributions a no-op for that entry', () => {
|
|
32
|
+
const dispose = register('p', { id: 'a' }, { scope: { slotId: 's1' } });
|
|
33
|
+
dispose();
|
|
34
|
+
expect(() => __disposeSlotContributions('s1')).not.toThrow();
|
|
35
|
+
expect(list('p')).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
it('double-dispose is idempotent', () => {
|
|
38
|
+
const dispose = register('p', { id: 'a' }, { scope: { slotId: 's1' } });
|
|
39
|
+
dispose();
|
|
40
|
+
dispose();
|
|
41
|
+
expect(list('p')).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
it('slot cleanup fires onChange for the affected pointId', () => {
|
|
44
|
+
const cb = vi.fn();
|
|
45
|
+
onChange('p', cb);
|
|
46
|
+
register('p', { id: 'a' }, { scope: { slotId: 's1' } });
|
|
47
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
48
|
+
cb.mockClear();
|
|
49
|
+
__disposeSlotContributions('s1');
|
|
50
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -5,11 +5,19 @@ export interface ContributionsApi {
|
|
|
5
5
|
* for ergonomics — provider and contributor agree on the shape via
|
|
6
6
|
* a type-only import of the provider's public types.
|
|
7
7
|
*
|
|
8
|
+
* Pass `opts.scope.slotId` to tie cleanup to a slot's lifecycle: the
|
|
9
|
+
* disposer will fire on slot unmount in addition to shard deactivate.
|
|
10
|
+
* Whichever fires first wins; the disposer is idempotent.
|
|
11
|
+
*
|
|
8
12
|
* Returns an unregister function. Calling it is optional (the
|
|
9
|
-
* framework auto-unregisters on shard deactivate
|
|
10
|
-
* more than once.
|
|
13
|
+
* framework auto-unregisters on shard deactivate, and on slot unmount
|
|
14
|
+
* when scoped) and safe to call more than once.
|
|
11
15
|
*/
|
|
12
|
-
register<T = unknown>(pointId: string, descriptor: T
|
|
16
|
+
register<T = unknown>(pointId: string, descriptor: T, opts?: {
|
|
17
|
+
scope?: {
|
|
18
|
+
slotId?: string;
|
|
19
|
+
};
|
|
20
|
+
}): () => void;
|
|
13
21
|
/** Enumerate descriptors at `pointId` in registration order. */
|
|
14
22
|
list<T = unknown>(pointId: string): T[];
|
|
15
23
|
/** Enumerate every point id with at least one registration. */
|
package/dist/createShell.js
CHANGED
|
@@ -70,8 +70,14 @@ export async function createShell(config) {
|
|
|
70
70
|
// via /api/packages aren't in IndexedDB on the satellite's view of the
|
|
71
71
|
// world unless we fetch and register them, same as the main path.
|
|
72
72
|
await loadDiscoveredPackages(config === null || config === void 0 ? void 0 : config.discoveredPackages);
|
|
73
|
+
// For app payloads, defer required-shard activation to launchApp so it
|
|
74
|
+
// runs *after* attachApp() binds the preset manager + slot holds. The
|
|
75
|
+
// payload's activateShards (manifest.requiredShards) are still carried
|
|
76
|
+
// for diagnostics, but launchApp drives activation in the same order
|
|
77
|
+
// as the host bootstrap. Float payloads have no launchApp so the walked
|
|
78
|
+
// view-providing shards must still activate here.
|
|
73
79
|
await bootstrapSatellite({
|
|
74
|
-
activateShardIds: satellite.payload.activateShards,
|
|
80
|
+
activateShardIds: satellite.payload.kind === 'app' ? [] : satellite.payload.activateShards,
|
|
75
81
|
});
|
|
76
82
|
attachGlobalListeners();
|
|
77
83
|
mount(SatelliteShell, { target, props: { payload: satellite.payload } });
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const ID_RE = /^[a-zA-Z0-9.\-_]+$/;
|
|
2
|
+
const SLOT_RE = /^[a-zA-Z0-9.\-_]*$/; // slotId may be empty in the wire form
|
|
3
|
+
function validateIdPart(value, partName) {
|
|
4
|
+
if (value.length === 0)
|
|
5
|
+
throw new Error(`fieldAddress: ${partName} is empty`);
|
|
6
|
+
if (!ID_RE.test(value)) {
|
|
7
|
+
throw new Error(`fieldAddress: invalid ${partName} "${value}" (must match [a-zA-Z0-9.\\-_]+)`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function validateSlotPart(value) {
|
|
11
|
+
if (!SLOT_RE.test(value)) {
|
|
12
|
+
throw new Error(`fieldAddress: invalid slotId "${value}"`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function fieldAddressToString(a) {
|
|
16
|
+
var _a;
|
|
17
|
+
validateIdPart(a.shardId, 'shardId');
|
|
18
|
+
validateIdPart(a.fieldId, 'fieldId');
|
|
19
|
+
if (a.slotId !== undefined)
|
|
20
|
+
validateSlotPart(a.slotId);
|
|
21
|
+
return `${a.shardId}::${(_a = a.slotId) !== null && _a !== void 0 ? _a : ''}::${a.fieldId}`;
|
|
22
|
+
}
|
|
23
|
+
export function fieldAddressFromString(s) {
|
|
24
|
+
const parts = s.split('::');
|
|
25
|
+
if (parts.length !== 3) {
|
|
26
|
+
throw new Error(`fieldAddress: malformed "${s}" (expected three ::-separated parts)`);
|
|
27
|
+
}
|
|
28
|
+
const [shardId, slotPart, fieldId] = parts;
|
|
29
|
+
validateIdPart(shardId, 'shardId');
|
|
30
|
+
validateIdPart(fieldId, 'fieldId');
|
|
31
|
+
validateSlotPart(slotPart);
|
|
32
|
+
const out = { shardId, fieldId };
|
|
33
|
+
if (slotPart.length > 0)
|
|
34
|
+
out.slotId = slotPart;
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { fieldAddressToString, fieldAddressFromString } from './address';
|
|
3
|
+
describe('fieldAddress codec', () => {
|
|
4
|
+
it('roundtrips a slot-scoped address', () => {
|
|
5
|
+
const a = { shardId: 'editor', slotId: 'slot-1', fieldId: 'title' };
|
|
6
|
+
expect(fieldAddressFromString(fieldAddressToString(a))).toEqual(a);
|
|
7
|
+
});
|
|
8
|
+
it('roundtrips a shard-scoped address (slotId absent)', () => {
|
|
9
|
+
const a = { shardId: 'settings', fieldId: 'theme' };
|
|
10
|
+
expect(fieldAddressFromString(fieldAddressToString(a))).toEqual(a);
|
|
11
|
+
});
|
|
12
|
+
it('serializes shard-scoped as <shardId>::<empty>::<fieldId>', () => {
|
|
13
|
+
expect(fieldAddressToString({ shardId: 's', fieldId: 'f' })).toBe('s::::f');
|
|
14
|
+
});
|
|
15
|
+
it('serializes slot-scoped as <shardId>::<slotId>::<fieldId>', () => {
|
|
16
|
+
expect(fieldAddressToString({ shardId: 's', slotId: 'sl', fieldId: 'f' })).toBe('s::sl::f');
|
|
17
|
+
});
|
|
18
|
+
it('rejects malformed input — too few parts', () => {
|
|
19
|
+
expect(() => fieldAddressFromString('s::sl')).toThrow(/malformed/);
|
|
20
|
+
});
|
|
21
|
+
it('rejects malformed input — too many parts', () => {
|
|
22
|
+
expect(() => fieldAddressFromString('s::sl::f::extra')).toThrow(/malformed/);
|
|
23
|
+
});
|
|
24
|
+
it('rejects an empty shardId', () => {
|
|
25
|
+
expect(() => fieldAddressFromString('::sl::f')).toThrow(/shardId/);
|
|
26
|
+
});
|
|
27
|
+
it('rejects an empty fieldId', () => {
|
|
28
|
+
expect(() => fieldAddressFromString('s::sl::')).toThrow(/fieldId/);
|
|
29
|
+
});
|
|
30
|
+
it('rejects characters outside [a-zA-Z0-9.\\-_]', () => {
|
|
31
|
+
expect(() => fieldAddressToString({ shardId: 's space', fieldId: 'f' })).toThrow(/invalid/);
|
|
32
|
+
expect(() => fieldAddressToString({ shardId: 's', fieldId: 'a:b' })).toThrow(/invalid/);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FieldAddress, DecorationHandle } from './types';
|
|
2
|
+
export declare function attachDecoration(addr: FieldAddress, factory: (target: {
|
|
3
|
+
element: HTMLElement;
|
|
4
|
+
rect: DOMRect;
|
|
5
|
+
}) => HTMLElement | DecorationHandle): () => void;
|
|
6
|
+
/** Test-only: tear everything down and reset module state. */
|
|
7
|
+
export declare function __resetDecorationLayerForTest(): void;
|