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.
Files changed (131) hide show
  1. package/dist/Sh3.svelte +50 -108
  2. package/dist/__screenshots__/handheld.browser.test.ts/handheld-viewport-flip-e2e-viewport-override-flips-chrome-and-body-branches-1.png +0 -0
  3. package/dist/actions/ctx-actions.svelte.test.js +4 -4
  4. package/dist/actions/listActionsFromEntries.test.js +29 -0
  5. package/dist/actions/listActive.js +2 -0
  6. package/dist/actions/listeners.js +4 -0
  7. package/dist/actions/programmatic-dispatch.svelte.test.js +9 -2
  8. package/dist/actions/types.d.ts +8 -0
  9. package/dist/api.d.ts +6 -1
  10. package/dist/api.js +1 -0
  11. package/dist/chrome/CompactChrome.svelte +96 -0
  12. package/dist/chrome/CompactChrome.svelte.d.ts +3 -0
  13. package/dist/chrome/CompactChrome.svelte.test.d.ts +1 -0
  14. package/dist/chrome/CompactChrome.svelte.test.js +67 -0
  15. package/dist/chrome/MenuSheet.svelte +224 -0
  16. package/dist/chrome/MenuSheet.svelte.d.ts +7 -0
  17. package/dist/chrome/MenuSheet.svelte.test.d.ts +1 -0
  18. package/dist/chrome/MenuSheet.svelte.test.js +46 -0
  19. package/dist/contributions/index.d.ts +1 -1
  20. package/dist/contributions/index.js +1 -1
  21. package/dist/contributions/registry.d.ts +17 -1
  22. package/dist/contributions/registry.js +50 -2
  23. package/dist/contributions/scope.test.d.ts +1 -0
  24. package/dist/contributions/scope.test.js +52 -0
  25. package/dist/contributions/types.d.ts +11 -3
  26. package/dist/createShell.js +7 -1
  27. package/dist/fields/address.d.ts +3 -0
  28. package/dist/fields/address.js +36 -0
  29. package/dist/fields/address.test.d.ts +1 -0
  30. package/dist/fields/address.test.js +34 -0
  31. package/dist/fields/decoration.d.ts +7 -0
  32. package/dist/fields/decoration.js +199 -0
  33. package/dist/fields/decoration.svelte.test.d.ts +1 -0
  34. package/dist/fields/decoration.svelte.test.js +177 -0
  35. package/dist/fields/dispatch.d.ts +22 -0
  36. package/dist/fields/dispatch.js +254 -0
  37. package/dist/fields/dispatch.test.d.ts +1 -0
  38. package/dist/fields/dispatch.test.js +175 -0
  39. package/dist/fields/types.d.ts +101 -0
  40. package/dist/fields/types.js +16 -0
  41. package/dist/fields/walker.svelte.test.d.ts +1 -0
  42. package/dist/fields/walker.svelte.test.js +138 -0
  43. package/dist/handheld.browser.test.d.ts +1 -0
  44. package/dist/handheld.browser.test.js +90 -0
  45. package/dist/host.js +27 -2
  46. package/dist/host.svelte.test.d.ts +1 -0
  47. package/dist/host.svelte.test.js +92 -0
  48. package/dist/layout/LayoutRenderer.svelte +12 -1
  49. package/dist/layout/LayoutRenderer.svelte.d.ts +2 -1
  50. package/dist/layout/compact/CompactRenderer.svelte +53 -0
  51. package/dist/layout/compact/CompactRenderer.svelte.d.ts +3 -0
  52. package/dist/layout/compact/CompactRenderer.svelte.test.d.ts +1 -0
  53. package/dist/layout/compact/CompactRenderer.svelte.test.js +76 -0
  54. package/dist/layout/compact/derive.d.ts +3 -0
  55. package/dist/layout/compact/derive.js +155 -0
  56. package/dist/layout/compact/derive.test.d.ts +1 -0
  57. package/dist/layout/compact/derive.test.js +160 -0
  58. package/dist/layout/compact/drawerStore.svelte.d.ts +21 -0
  59. package/dist/layout/compact/drawerStore.svelte.js +75 -0
  60. package/dist/layout/compact/drawerStore.svelte.test.d.ts +1 -0
  61. package/dist/layout/compact/drawerStore.svelte.test.js +43 -0
  62. package/dist/layout/compact/resolveRole.d.ts +6 -0
  63. package/dist/layout/compact/resolveRole.js +13 -0
  64. package/dist/layout/compact/resolveRole.test.d.ts +1 -0
  65. package/dist/layout/compact/resolveRole.test.js +18 -0
  66. package/dist/layout/compact/types.d.ts +27 -0
  67. package/dist/layout/compact/types.js +15 -0
  68. package/dist/layout/presets.compactVariant.test.d.ts +1 -0
  69. package/dist/layout/presets.compactVariant.test.js +27 -0
  70. package/dist/layout/presets.d.ts +12 -0
  71. package/dist/layout/presets.js +16 -0
  72. package/dist/layout/slotHostPool.svelte.d.ts +8 -0
  73. package/dist/layout/slotHostPool.svelte.js +14 -1
  74. package/dist/layout/store.drawers.svelte.test.d.ts +1 -0
  75. package/dist/layout/store.drawers.svelte.test.js +49 -0
  76. package/dist/layout/store.schemaVersion.test.d.ts +1 -0
  77. package/dist/layout/store.schemaVersion.test.js +35 -0
  78. package/dist/layout/store.svelte.js +52 -2
  79. package/dist/layout/types.d.ts +43 -1
  80. package/dist/layout/types.js +1 -1
  81. package/dist/overlays/DrawerSurface.svelte +141 -0
  82. package/dist/overlays/DrawerSurface.svelte.d.ts +12 -0
  83. package/dist/overlays/DrawerSurface.svelte.test.d.ts +1 -0
  84. package/dist/overlays/DrawerSurface.svelte.test.js +67 -0
  85. package/dist/overlays/OverlayRoots.svelte +89 -0
  86. package/dist/overlays/OverlayRoots.svelte.d.ts +3 -0
  87. package/dist/overlays/types.d.ts +1 -1
  88. package/dist/platform/tauri-backend.d.ts +3 -3
  89. package/dist/platform/tauri-backend.js +24 -3
  90. package/dist/projects/session-state.svelte.d.ts +3 -3
  91. package/dist/projects/session-state.svelte.js +5 -4
  92. package/dist/runtime/runVerb.js +2 -2
  93. package/dist/satellite/SatelliteShell.svelte +58 -11
  94. package/dist/satellite/SatelliteShell.svelte.test.d.ts +1 -0
  95. package/dist/satellite/SatelliteShell.svelte.test.js +61 -0
  96. package/dist/sh3Api/fields-walker.svelte.test.d.ts +1 -0
  97. package/dist/sh3Api/fields-walker.svelte.test.js +75 -0
  98. package/dist/sh3Api/headless.d.ts +9 -0
  99. package/dist/sh3Api/headless.js +171 -16
  100. package/dist/sh3Api/headless.svelte.test.js +54 -10
  101. package/dist/sh3Runtime.svelte.d.ts +36 -0
  102. package/dist/sh3Runtime.svelte.js +33 -0
  103. package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -2
  104. package/dist/shards/activate-fields.svelte.test.d.ts +1 -0
  105. package/dist/shards/activate-fields.svelte.test.js +121 -0
  106. package/dist/shards/activate-runtime.test.js +8 -8
  107. package/dist/shards/activate.svelte.js +29 -35
  108. package/dist/shards/types.d.ts +23 -76
  109. package/dist/shell-shard/ScrollbackView.svelte +55 -9
  110. package/dist/shell-shard/Terminal.svelte +1 -1
  111. package/dist/shell-shard/scrollback-stick.d.ts +9 -0
  112. package/dist/shell-shard/scrollback-stick.js +21 -0
  113. package/dist/shell-shard/scrollback-stick.test.d.ts +1 -0
  114. package/dist/shell-shard/scrollback-stick.test.js +25 -0
  115. package/dist/tokens.css +3 -2
  116. package/dist/verbs/types.d.ts +59 -1
  117. package/dist/version.d.ts +1 -1
  118. package/dist/version.js +1 -1
  119. package/dist/viewport/classify.d.ts +8 -0
  120. package/dist/viewport/classify.js +20 -0
  121. package/dist/viewport/classify.test.d.ts +1 -0
  122. package/dist/viewport/classify.test.js +32 -0
  123. package/dist/viewport/store.browser.test.d.ts +1 -0
  124. package/dist/viewport/store.browser.test.js +33 -0
  125. package/dist/viewport/store.svelte.d.ts +9 -0
  126. package/dist/viewport/store.svelte.js +71 -0
  127. package/dist/viewport/store.svelte.test.d.ts +1 -0
  128. package/dist/viewport/store.svelte.test.js +54 -0
  129. package/dist/viewport/types.d.ts +9 -0
  130. package/dist/viewport/types.js +6 -0
  131. 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,7 @@
1
+ type $$ComponentProps = {
2
+ open: boolean;
3
+ onClose: () => void;
4
+ };
5
+ declare const MenuSheet: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type MenuSheet = ReturnType<typeof MenuSheet>;
7
+ export default MenuSheet;
@@ -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): () => void;
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
- return () => {
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) and safe to call
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): () => void;
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. */
@@ -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,3 @@
1
+ import type { FieldAddress } from './types';
2
+ export declare function fieldAddressToString(a: FieldAddress): string;
3
+ export declare function fieldAddressFromString(s: string): FieldAddress;
@@ -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;