sh3-core 0.16.1 → 0.17.0
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 +2 -73
- package/dist/actions/ctx-actions.svelte.test.js +4 -4
- package/dist/api.d.ts +2 -0
- package/dist/api.js +1 -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/host.js +27 -2
- package/dist/host.svelte.test.d.ts +1 -0
- package/dist/host.svelte.test.js +92 -0
- package/dist/layout/slotHostPool.svelte.d.ts +8 -0
- package/dist/layout/slotHostPool.svelte.js +14 -1
- package/dist/overlays/OverlayRoots.svelte +86 -0
- package/dist/overlays/OverlayRoots.svelte.d.ts +3 -0
- 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 +163 -16
- package/dist/sh3Api/headless.svelte.test.js +9 -9
- 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 +14 -75
- 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/verbs/types.d.ts +56 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/Sh3.svelte
CHANGED
|
@@ -15,11 +15,7 @@
|
|
|
15
15
|
import './tokens.css';
|
|
16
16
|
import './primitives/base.css';
|
|
17
17
|
import LayoutRenderer from './layout/LayoutRenderer.svelte';
|
|
18
|
-
import
|
|
19
|
-
import FloatLayer from './overlays/FloatLayer.svelte';
|
|
20
|
-
import type { OverlayLayer } from './overlays/types';
|
|
21
|
-
import { registerLayerRoot, unregisterLayerRoot } from './overlays/roots';
|
|
22
|
-
import { bindFloatStore, unbindFloatStore } from './overlays/float';
|
|
18
|
+
import OverlayRoots from './overlays/OverlayRoots.svelte';
|
|
23
19
|
import { returnToHome, isAdmin } from './api';
|
|
24
20
|
import { getActiveRoot, layoutStore } from './layout/store.svelte';
|
|
25
21
|
import { syncMountedViewIdsFromLayout } from './actions/state.svelte';
|
|
@@ -39,41 +35,6 @@
|
|
|
39
35
|
// stays set) and only flips the layout store's activeRoot back to 'home'.
|
|
40
36
|
const onHome = $derived(getActiveRoot() === 'home');
|
|
41
37
|
|
|
42
|
-
// Layer metadata — order matches the stack in docs/design/layout.md.
|
|
43
|
-
// Index 0 here is layer 1 (floating panels); layer 0 is the content area.
|
|
44
|
-
const overlayLayers: { layer: number; name: OverlayLayer }[] = [
|
|
45
|
-
{ layer: 1, name: 'floating' },
|
|
46
|
-
{ layer: 2, name: 'drag-preview' },
|
|
47
|
-
{ layer: 3, name: 'popup' },
|
|
48
|
-
{ layer: 4, name: 'modal' },
|
|
49
|
-
{ layer: 5, name: 'toast' },
|
|
50
|
-
{ layer: 6, name: 'command' },
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
// Populated by bind:this during render; registered with the overlay
|
|
54
|
-
// module via $effect after mount so layer managers (sh3.modal,
|
|
55
|
-
// sh3.popup, sh3.toast) can find their target DOM roots.
|
|
56
|
-
const overlayRoots: Partial<Record<OverlayLayer, HTMLDivElement>> = $state({});
|
|
57
|
-
|
|
58
|
-
$effect(() => {
|
|
59
|
-
for (const { name } of overlayLayers) {
|
|
60
|
-
const el = overlayRoots[name];
|
|
61
|
-
if (el) registerLayerRoot(name, el);
|
|
62
|
-
}
|
|
63
|
-
return () => {
|
|
64
|
-
for (const { name } of overlayLayers) unregisterLayerRoot(name);
|
|
65
|
-
};
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
$effect(() => {
|
|
69
|
-
const tree = layoutStore.tree;
|
|
70
|
-
bindFloatStore(tree.floats, () => ({
|
|
71
|
-
w: window.innerWidth,
|
|
72
|
-
h: window.innerHeight,
|
|
73
|
-
}));
|
|
74
|
-
return () => unbindFloatStore();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
38
|
// Keep the actions dispatcher's `mountedViewIds` set in sync with the
|
|
78
39
|
// live layout tree, so `view:<viewId>` scope checks (context menu,
|
|
79
40
|
// palette, keyboard) see currently-mounted views. Deep Svelte 5
|
|
@@ -144,28 +105,7 @@
|
|
|
144
105
|
<!-- alpha tag moved to Sh3Home title row -->
|
|
145
106
|
</footer>
|
|
146
107
|
|
|
147
|
-
|
|
148
|
-
Overlay roots. Each is absolutely positioned over the entire sh3 with
|
|
149
|
-
pointer-events: none by default; layer managers enable pointer events on
|
|
150
|
-
the specific surfaces they portal in.
|
|
151
|
-
-->
|
|
152
|
-
<div class="sh3-overlays" aria-hidden="true">
|
|
153
|
-
{#each overlayLayers as { layer, name } (layer)}
|
|
154
|
-
<div
|
|
155
|
-
class="sh3-overlay-root"
|
|
156
|
-
data-sh3-overlay={name}
|
|
157
|
-
data-sh3-layer={layer}
|
|
158
|
-
style="z-index: var(--sh3-z-layer-{layer});"
|
|
159
|
-
bind:this={overlayRoots[name]}
|
|
160
|
-
>
|
|
161
|
-
{#if name === 'floating'}
|
|
162
|
-
<FloatLayer />
|
|
163
|
-
{:else if name === 'drag-preview'}
|
|
164
|
-
<DragPreview />
|
|
165
|
-
{/if}
|
|
166
|
-
</div>
|
|
167
|
-
{/each}
|
|
168
|
-
</div>
|
|
108
|
+
<OverlayRoots />
|
|
169
109
|
|
|
170
110
|
<!--
|
|
171
111
|
Sh3-owned consent dialog for ctx.keys.mint().
|
|
@@ -217,17 +157,6 @@
|
|
|
217
157
|
user-select: none;
|
|
218
158
|
}
|
|
219
159
|
|
|
220
|
-
.sh3-overlays {
|
|
221
|
-
position: absolute;
|
|
222
|
-
inset: 0;
|
|
223
|
-
pointer-events: none;
|
|
224
|
-
}
|
|
225
|
-
.sh3-overlay-root {
|
|
226
|
-
position: absolute;
|
|
227
|
-
inset: 0;
|
|
228
|
-
pointer-events: none;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
160
|
.sh3-tabbar-home-button {
|
|
232
161
|
display: flex;
|
|
233
162
|
align-items: center;
|
|
@@ -35,7 +35,7 @@ describe('ShardContext.listActions / runAction (integration)', () => {
|
|
|
35
35
|
});
|
|
36
36
|
await activateShard('producer');
|
|
37
37
|
await activateShard('consumer');
|
|
38
|
-
const list = consumerCtx.listActions();
|
|
38
|
+
const list = consumerCtx.sh3.listActions();
|
|
39
39
|
const ids = list.map((d) => d.id);
|
|
40
40
|
expect(ids).toContain('producer.do');
|
|
41
41
|
const desc = list.find((d) => d.id === 'producer.do');
|
|
@@ -62,7 +62,7 @@ describe('ShardContext.listActions / runAction (integration)', () => {
|
|
|
62
62
|
});
|
|
63
63
|
await activateShard('producer');
|
|
64
64
|
await activateShard('consumer');
|
|
65
|
-
const snapshot = consumerCtx.listActions({ activeOnly: true });
|
|
65
|
+
const snapshot = consumerCtx.sh3.listActions({ activeOnly: true });
|
|
66
66
|
const ids = snapshot.map((d) => d.id);
|
|
67
67
|
expect(ids).toContain('home.go');
|
|
68
68
|
expect(ids).not.toContain('app.go');
|
|
@@ -87,7 +87,7 @@ describe('ShardContext.listActions / runAction (integration)', () => {
|
|
|
87
87
|
});
|
|
88
88
|
await activateShard('producer');
|
|
89
89
|
await activateShard('consumer');
|
|
90
|
-
await consumerCtx.runAction('producer.go');
|
|
90
|
+
await consumerCtx.sh3.runAction('producer.go');
|
|
91
91
|
expect(invokedVia).toBe('programmatic');
|
|
92
92
|
});
|
|
93
93
|
it('runAction rejects when the target action is inactive', async () => {
|
|
@@ -106,6 +106,6 @@ describe('ShardContext.listActions / runAction (integration)', () => {
|
|
|
106
106
|
});
|
|
107
107
|
await activateShard('producer');
|
|
108
108
|
await activateShard('consumer');
|
|
109
|
-
await expect(consumerCtx.runAction('gated.go')).rejects.toThrow(/not active/);
|
|
109
|
+
await expect(consumerCtx.sh3.runAction('gated.go')).rejects.toThrow(/not active/);
|
|
110
110
|
});
|
|
111
111
|
});
|
package/dist/api.d.ts
CHANGED
|
@@ -80,3 +80,5 @@ export { default as Select } from './primitives/widgets/Select.svelte';
|
|
|
80
80
|
export type { SelectOption } from './primitives/widgets/Select';
|
|
81
81
|
export { default as AppPicker } from './primitives/widgets/AppPicker.svelte';
|
|
82
82
|
export { default as UserPicker } from './primitives/widgets/UserPicker.svelte';
|
|
83
|
+
export type { FieldKind, FieldAddress, FieldView, ControllableFieldDescriptor, ImperativeFieldDescriptor, ElementRefFieldDescriptor, ReadonlyFieldDescriptor, FieldsApi, DecorationHandle, } from './fields/types';
|
|
84
|
+
export { fieldAddressToString, fieldAddressFromString } from './fields/address';
|
package/dist/api.js
CHANGED
|
@@ -97,3 +97,4 @@ 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 { fieldAddressToString, fieldAddressFromString } from './fields/address';
|
|
@@ -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;
|