sh3-core 0.16.0 → 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.
Files changed (68) hide show
  1. package/dist/Sh3.svelte +2 -73
  2. package/dist/actions/ctx-actions.svelte.test.js +4 -4
  3. package/dist/api.d.ts +2 -0
  4. package/dist/api.js +1 -0
  5. package/dist/build.d.ts +27 -0
  6. package/dist/build.js +59 -1
  7. package/dist/build.test.d.ts +1 -0
  8. package/dist/build.test.js +31 -0
  9. package/dist/contributions/index.d.ts +1 -1
  10. package/dist/contributions/index.js +1 -1
  11. package/dist/contributions/registry.d.ts +17 -1
  12. package/dist/contributions/registry.js +50 -2
  13. package/dist/contributions/scope.test.d.ts +1 -0
  14. package/dist/contributions/scope.test.js +52 -0
  15. package/dist/contributions/types.d.ts +11 -3
  16. package/dist/createShell.js +7 -1
  17. package/dist/fields/address.d.ts +3 -0
  18. package/dist/fields/address.js +36 -0
  19. package/dist/fields/address.test.d.ts +1 -0
  20. package/dist/fields/address.test.js +34 -0
  21. package/dist/fields/decoration.d.ts +7 -0
  22. package/dist/fields/decoration.js +199 -0
  23. package/dist/fields/decoration.svelte.test.d.ts +1 -0
  24. package/dist/fields/decoration.svelte.test.js +177 -0
  25. package/dist/fields/dispatch.d.ts +22 -0
  26. package/dist/fields/dispatch.js +254 -0
  27. package/dist/fields/dispatch.test.d.ts +1 -0
  28. package/dist/fields/dispatch.test.js +175 -0
  29. package/dist/fields/types.d.ts +101 -0
  30. package/dist/fields/types.js +16 -0
  31. package/dist/fields/walker.svelte.test.d.ts +1 -0
  32. package/dist/fields/walker.svelte.test.js +138 -0
  33. package/dist/host.js +27 -2
  34. package/dist/host.svelte.test.d.ts +1 -0
  35. package/dist/host.svelte.test.js +92 -0
  36. package/dist/layout/slotHostPool.svelte.d.ts +8 -0
  37. package/dist/layout/slotHostPool.svelte.js +14 -1
  38. package/dist/overlays/OverlayRoots.svelte +86 -0
  39. package/dist/overlays/OverlayRoots.svelte.d.ts +3 -0
  40. package/dist/platform/tauri-backend.d.ts +3 -3
  41. package/dist/platform/tauri-backend.js +24 -3
  42. package/dist/projects/session-state.svelte.d.ts +3 -3
  43. package/dist/projects/session-state.svelte.js +5 -4
  44. package/dist/runtime/runVerb.js +2 -2
  45. package/dist/satellite/SatelliteShell.svelte +58 -11
  46. package/dist/satellite/SatelliteShell.svelte.test.d.ts +1 -0
  47. package/dist/satellite/SatelliteShell.svelte.test.js +61 -0
  48. package/dist/sh3Api/fields-walker.svelte.test.d.ts +1 -0
  49. package/dist/sh3Api/fields-walker.svelte.test.js +75 -0
  50. package/dist/sh3Api/headless.d.ts +9 -0
  51. package/dist/sh3Api/headless.js +163 -16
  52. package/dist/sh3Api/headless.svelte.test.js +9 -9
  53. package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -2
  54. package/dist/shards/activate-fields.svelte.test.d.ts +1 -0
  55. package/dist/shards/activate-fields.svelte.test.js +121 -0
  56. package/dist/shards/activate-runtime.test.js +8 -8
  57. package/dist/shards/activate.svelte.js +29 -35
  58. package/dist/shards/types.d.ts +14 -75
  59. package/dist/shell-shard/ScrollbackView.svelte +55 -9
  60. package/dist/shell-shard/Terminal.svelte +1 -1
  61. package/dist/shell-shard/scrollback-stick.d.ts +9 -0
  62. package/dist/shell-shard/scrollback-stick.js +21 -0
  63. package/dist/shell-shard/scrollback-stick.test.d.ts +1 -0
  64. package/dist/shell-shard/scrollback-stick.test.js +25 -0
  65. package/dist/verbs/types.d.ts +56 -1
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/package.json +1 -1
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { __resetContributionsForTest, register, } from '../contributions/registry';
3
+ import { listFields, getField, setField, } from './dispatch';
4
+ import { FIELD_POINT_ID } from './types';
5
+ function registerField(entry) {
6
+ return register(FIELD_POINT_ID, entry);
7
+ }
8
+ describe('fields dispatch', () => {
9
+ beforeEach(() => {
10
+ __resetContributionsForTest();
11
+ });
12
+ describe('listFields', () => {
13
+ it('returns [] when no fields are registered', () => {
14
+ expect(listFields()).toEqual([]);
15
+ });
16
+ it('lists imperative fields with readonly=false when set is present', () => {
17
+ registerField({
18
+ owner: { shardId: 'a' },
19
+ descriptor: {
20
+ shape: 'imperative',
21
+ fieldId: 'theme',
22
+ label: 'Theme',
23
+ kind: 'string',
24
+ get: () => 'dark',
25
+ set: () => undefined,
26
+ },
27
+ });
28
+ const out = listFields();
29
+ expect(out).toHaveLength(1);
30
+ expect(out[0]).toMatchObject({
31
+ shardId: 'a',
32
+ fieldId: 'theme',
33
+ label: 'Theme',
34
+ kind: 'string',
35
+ readonly: false,
36
+ source: 'contributed',
37
+ });
38
+ expect(out[0].slotId).toBeUndefined();
39
+ expect(out[0].element).toBeUndefined();
40
+ });
41
+ it('lists imperative fields without set as readonly=true', () => {
42
+ registerField({
43
+ owner: { shardId: 'a' },
44
+ descriptor: {
45
+ shape: 'imperative',
46
+ fieldId: 'count',
47
+ label: 'Count',
48
+ kind: 'integer',
49
+ get: () => 7,
50
+ },
51
+ });
52
+ expect(listFields()[0].readonly).toBe(true);
53
+ });
54
+ it('lists readonly fields as readonly=true', () => {
55
+ registerField({
56
+ owner: { shardId: 'a' },
57
+ descriptor: {
58
+ shape: 'readonly',
59
+ fieldId: 'now',
60
+ label: 'Now',
61
+ kind: 'integer',
62
+ get: () => Date.now(),
63
+ },
64
+ });
65
+ expect(listFields()[0].readonly).toBe(true);
66
+ });
67
+ it('passes the element ref through to FieldView when present', () => {
68
+ const fakeEl = { tagName: 'DIV' };
69
+ registerField({
70
+ owner: { shardId: 'a' },
71
+ descriptor: {
72
+ shape: 'imperative',
73
+ fieldId: 'rich',
74
+ label: 'Rich',
75
+ kind: 'string',
76
+ get: () => 'x',
77
+ element: fakeEl,
78
+ },
79
+ });
80
+ expect(listFields()[0].element).toBe(fakeEl);
81
+ });
82
+ it('filters by shardId', () => {
83
+ registerField({ owner: { shardId: 'a' }, descriptor: { shape: 'imperative', fieldId: 'x', label: 'X', kind: 'string', get: () => 1 } });
84
+ registerField({ owner: { shardId: 'b' }, descriptor: { shape: 'imperative', fieldId: 'y', label: 'Y', kind: 'string', get: () => 2 } });
85
+ expect(listFields({ shardId: 'a' }).map((f) => f.fieldId)).toEqual(['x']);
86
+ });
87
+ it('filters by slotId', () => {
88
+ registerField({ owner: { shardId: 'a', slotId: 's1' }, descriptor: { shape: 'imperative', fieldId: 'x', label: 'X', kind: 'string', get: () => 1 } });
89
+ registerField({ owner: { shardId: 'a', slotId: 's2' }, descriptor: { shape: 'imperative', fieldId: 'y', label: 'Y', kind: 'string', get: () => 2 } });
90
+ expect(listFields({ slotId: 's1' }).map((f) => f.fieldId)).toEqual(['x']);
91
+ });
92
+ it('filters by kind', () => {
93
+ registerField({ owner: { shardId: 'a' }, descriptor: { shape: 'imperative', fieldId: 'n', label: 'N', kind: 'number', get: () => 1 } });
94
+ registerField({ owner: { shardId: 'a' }, descriptor: { shape: 'imperative', fieldId: 's', label: 'S', kind: 'string', get: () => 'a' } });
95
+ expect(listFields({ kind: 'number' }).map((f) => f.fieldId)).toEqual(['n']);
96
+ });
97
+ });
98
+ describe('getField', () => {
99
+ it('throws on unknown address', () => {
100
+ expect(() => getField({ shardId: 'a', fieldId: 'x' })).toThrow(/unknown field/);
101
+ });
102
+ it('returns the imperative descriptor get() value', () => {
103
+ registerField({
104
+ owner: { shardId: 'a' },
105
+ descriptor: {
106
+ shape: 'imperative', fieldId: 'x', label: 'X', kind: 'string', get: () => 'hello',
107
+ },
108
+ });
109
+ expect(getField({ shardId: 'a', fieldId: 'x' })).toBe('hello');
110
+ });
111
+ it('returns readonly descriptor get() value', () => {
112
+ registerField({
113
+ owner: { shardId: 'a' },
114
+ descriptor: {
115
+ shape: 'readonly', fieldId: 'r', label: 'R', kind: 'integer', get: () => 42,
116
+ },
117
+ });
118
+ expect(getField({ shardId: 'a', fieldId: 'r' })).toBe(42);
119
+ });
120
+ it('matches on slot-scoped addresses', () => {
121
+ registerField({
122
+ owner: { shardId: 'a', slotId: 's1' },
123
+ descriptor: {
124
+ shape: 'imperative', fieldId: 'x', label: 'X', kind: 'string', get: () => 'slot',
125
+ },
126
+ });
127
+ expect(getField({ shardId: 'a', slotId: 's1', fieldId: 'x' })).toBe('slot');
128
+ });
129
+ });
130
+ describe('setField', () => {
131
+ it('rejects on unknown address', async () => {
132
+ await expect(setField({ shardId: 'a', fieldId: 'x' }, 'v')).rejects.toThrow(/unknown field/);
133
+ });
134
+ it('rejects when imperative field has no set', async () => {
135
+ registerField({
136
+ owner: { shardId: 'a' },
137
+ descriptor: {
138
+ shape: 'imperative', fieldId: 'x', label: 'X', kind: 'string', get: () => 'r',
139
+ },
140
+ });
141
+ await expect(setField({ shardId: 'a', fieldId: 'x' }, 'v')).rejects.toThrow(/read-only/);
142
+ });
143
+ it('rejects when shape is readonly', async () => {
144
+ registerField({
145
+ owner: { shardId: 'a' },
146
+ descriptor: {
147
+ shape: 'readonly', fieldId: 'x', label: 'X', kind: 'string', get: () => 'r',
148
+ },
149
+ });
150
+ await expect(setField({ shardId: 'a', fieldId: 'x' }, 'v')).rejects.toThrow(/read-only/);
151
+ });
152
+ it('invokes imperative set and awaits a promise', async () => {
153
+ const set = vi.fn().mockResolvedValue(undefined);
154
+ registerField({
155
+ owner: { shardId: 'a' },
156
+ descriptor: {
157
+ shape: 'imperative', fieldId: 'x', label: 'X', kind: 'string',
158
+ get: () => 'r', set,
159
+ },
160
+ });
161
+ await setField({ shardId: 'a', fieldId: 'x' }, 'v');
162
+ expect(set).toHaveBeenCalledWith('v');
163
+ });
164
+ it('propagates errors from the set callback', async () => {
165
+ registerField({
166
+ owner: { shardId: 'a' },
167
+ descriptor: {
168
+ shape: 'imperative', fieldId: 'x', label: 'X', kind: 'string',
169
+ get: () => 'r', set: () => { throw new Error('boom'); },
170
+ },
171
+ });
172
+ await expect(setField({ shardId: 'a', fieldId: 'x' }, 'v')).rejects.toThrow('boom');
173
+ });
174
+ });
175
+ });
@@ -0,0 +1,101 @@
1
+ export type FieldKind = 'string' | 'number' | 'integer' | 'boolean' | 'enum' | 'json';
2
+ export interface FieldBase {
3
+ /** Stable, unique within the (shardId, slotId?) tuple. */
4
+ fieldId: string;
5
+ /** Human-readable label; passed to AI tool descriptions and debug UIs. */
6
+ label: string;
7
+ kind: FieldKind;
8
+ /** Optional one-line description (passed to AI tool callers). */
9
+ description?: string;
10
+ /** For kind: 'enum' — the allowed values. */
11
+ enumValues?: readonly unknown[];
12
+ }
13
+ export interface ImperativeFieldDescriptor extends FieldBase {
14
+ shape: 'imperative';
15
+ get(): unknown;
16
+ /** Omit to make the field read-only. */
17
+ set?: (value: unknown) => void | Promise<void>;
18
+ /** Optional value-change subscription. Caller passes a value-snapshot cb. */
19
+ onChange?: (cb: (value: unknown) => void) => () => void;
20
+ /**
21
+ * Optional visual locator. Set when the field has a DOM presence even
22
+ * though I/O goes through callbacks (contenteditable rich-text editors,
23
+ * ARIA-roled custom widgets, custom elements). The framework NEVER writes
24
+ * to this element directly — exposed only to visual decorators and to
25
+ * FieldView.element for highlighting / anchoring overlays.
26
+ */
27
+ element?: HTMLElement;
28
+ }
29
+ export interface ElementRefFieldDescriptor extends FieldBase {
30
+ shape: 'element';
31
+ /** Framework reads/writes .value and dispatches 'input' + 'change'. */
32
+ element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
33
+ }
34
+ export interface ReadonlyFieldDescriptor extends FieldBase {
35
+ shape: 'readonly';
36
+ get(): unknown;
37
+ /** Optional visual locator (same semantics as ImperativeFieldDescriptor.element). */
38
+ element?: HTMLElement;
39
+ }
40
+ export type ControllableFieldDescriptor = ImperativeFieldDescriptor | ElementRefFieldDescriptor | ReadonlyFieldDescriptor;
41
+ /**
42
+ * Address tuple for ctx.sh3.fields.get/set. shardId 'sh3.walker' identifies
43
+ * walker-produced fields. slotId is present iff the field is slot-scoped or
44
+ * walker-produced.
45
+ */
46
+ export interface FieldAddress {
47
+ shardId: string;
48
+ slotId?: string;
49
+ fieldId: string;
50
+ }
51
+ /**
52
+ * Read-shape returned by ctx.sh3.fields.list() / walk(). Intentionally omits
53
+ * I/O handlers (get/set/onChange) so the gating chokepoint at
54
+ * ctx.sh3.fields.get/set works. Direct DOM access via `element` is fine —
55
+ * any shard could already do document.querySelector.
56
+ */
57
+ export interface FieldView {
58
+ shardId: string;
59
+ slotId?: string;
60
+ fieldId: string;
61
+ label: string;
62
+ kind: FieldKind;
63
+ description?: string;
64
+ enumValues?: readonly unknown[];
65
+ readonly: boolean;
66
+ source: 'contributed' | 'walker';
67
+ element?: HTMLElement;
68
+ }
69
+ /**
70
+ * Returned (or constructed) by an attachDecoration factory.
71
+ * - element: the consumer's content; mounted into the SH3 overlay layer.
72
+ * - update?(rect): called when the field's bounding rect changes.
73
+ * - dispose?(): called at decoration teardown (field unregister or explicit dispose).
74
+ */
75
+ export interface DecorationHandle {
76
+ element: HTMLElement;
77
+ update?(rect: DOMRect): void;
78
+ dispose?(): void;
79
+ }
80
+ /**
81
+ * The Sh3Api.fields surface. See packages/sh3-core/src/fields/dispatch.ts and
82
+ * packages/sh3-core/src/fields/decoration.ts for implementations.
83
+ */
84
+ export interface FieldsApi {
85
+ list(opts?: {
86
+ slotId?: string;
87
+ shardId?: string;
88
+ walker?: 'off' | 'fallback' | 'always';
89
+ kind?: FieldKind;
90
+ }): FieldView[];
91
+ get(addr: FieldAddress): unknown;
92
+ set(addr: FieldAddress, value: unknown): Promise<void>;
93
+ walk(slotId: string): FieldView[];
94
+ onChange(cb: () => void): () => void;
95
+ attachDecoration(addr: FieldAddress, factory: (target: {
96
+ element: HTMLElement;
97
+ rect: DOMRect;
98
+ }) => HTMLElement | DecorationHandle): () => void;
99
+ }
100
+ export declare const FIELD_POINT_ID = "sh3.controllable-field";
101
+ export declare const WALKER_SHARD_ID = "sh3.walker";
@@ -0,0 +1,16 @@
1
+ /*
2
+ * Controllable-field type definitions.
3
+ *
4
+ * Fields are stored as contributions under pointId 'sh3.controllable-field'.
5
+ * Three registration shapes:
6
+ * - 'imperative': caller-supplied get/set callbacks; optional element ref
7
+ * for visual locator (contenteditable / ARIA / custom widget).
8
+ * - 'element': framework drives an HTMLInputElement / HTMLTextAreaElement /
9
+ * HTMLSelectElement directly (.value + dispatched events).
10
+ * - 'readonly': caller-supplied get only; optional element ref.
11
+ *
12
+ * `Sh3Api.fields` exposes the read+invoke surface; FieldView is the
13
+ * read-shape returned by list() / walk().
14
+ */
15
+ export const FIELD_POINT_ID = 'sh3.controllable-field';
16
+ export const WALKER_SHARD_ID = 'sh3.walker';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { __resetContributionsForTest, register, } from '../contributions/registry';
3
+ import { walkSlotContainer, listFields } from './dispatch';
4
+ import { FIELD_POINT_ID } from './types';
5
+ function makeContainer(html) {
6
+ const c = document.createElement('div');
7
+ c.innerHTML = html;
8
+ document.body.appendChild(c);
9
+ return c;
10
+ }
11
+ describe('field walker', () => {
12
+ beforeEach(() => {
13
+ __resetContributionsForTest();
14
+ document.body.innerHTML = '';
15
+ });
16
+ it('returns [] for an empty container', () => {
17
+ const c = makeContainer('');
18
+ expect(walkSlotContainer('s1', c)).toEqual([]);
19
+ });
20
+ it('matches native input, textarea, select', () => {
21
+ const c = makeContainer(`
22
+ <input name="title" />
23
+ <textarea name="body"></textarea>
24
+ <select name="color"><option value="red">Red</option><option value="blue">Blue</option></select>
25
+ `);
26
+ const out = walkSlotContainer('s1', c);
27
+ expect(out.map((f) => f.fieldId).sort()).toEqual(['body', 'color', 'title']);
28
+ });
29
+ it('skips submit/button/reset/file/hidden inputs', () => {
30
+ const c = makeContainer(`
31
+ <input type="submit" name="x" />
32
+ <input type="button" name="y" />
33
+ <input type="reset" name="z" />
34
+ <input type="file" name="f" />
35
+ <input type="hidden" name="h" />
36
+ <input type="text" name="ok" />
37
+ `);
38
+ expect(walkSlotContainer('s1', c).map((f) => f.fieldId)).toEqual(['ok']);
39
+ });
40
+ it('matches [data-sh3-field] elements', () => {
41
+ const c = makeContainer(`<div data-sh3-field="custom">x</div>`);
42
+ expect(walkSlotContainer('s1', c).map((f) => f.fieldId)).toEqual(['custom']);
43
+ });
44
+ it('infers kind from input type', () => {
45
+ const c = makeContainer(`
46
+ <input type="number" name="n" />
47
+ <input type="checkbox" name="b" />
48
+ <input type="text" name="s" />
49
+ `);
50
+ const byId = Object.fromEntries(walkSlotContainer('s1', c).map((f) => [f.fieldId, f.kind]));
51
+ expect(byId).toEqual({ n: 'number', b: 'boolean', s: 'string' });
52
+ });
53
+ it('infers enum from select options', () => {
54
+ const c = makeContainer(`
55
+ <select name="c"><option value="red">Red</option><option value="blue">Blue</option></select>
56
+ `);
57
+ const out = walkSlotContainer('s1', c);
58
+ expect(out[0].kind).toBe('enum');
59
+ expect(out[0].enumValues).toEqual(['red', 'blue']);
60
+ });
61
+ it('id strategy: data-sh3-field > name > id > label > placeholder > field-N', () => {
62
+ const c = makeContainer(`
63
+ <input data-sh3-field="data-id" />
64
+ <input name="name-id" />
65
+ <input id="dom-id" />
66
+ <label for="lbl">My Label</label><input id="lbl" />
67
+ <input placeholder="The Hint" />
68
+ <input />
69
+ `);
70
+ const ids = walkSlotContainer('s1', c).map((f) => f.fieldId);
71
+ expect(ids).toContain('data-id');
72
+ expect(ids).toContain('name-id');
73
+ expect(ids).toContain('dom-id');
74
+ expect(ids).toContain('my-label');
75
+ expect(ids).toContain('the-hint');
76
+ expect(ids.some((i) => /^field-\d+$/.test(i))).toBe(true);
77
+ });
78
+ it('walker fields use shardId "sh3.walker" and slotId of the call', () => {
79
+ const c = makeContainer(`<input name="x" />`);
80
+ const out = walkSlotContainer('slotABC', c);
81
+ expect(out[0]).toMatchObject({
82
+ shardId: 'sh3.walker',
83
+ slotId: 'slotABC',
84
+ source: 'walker',
85
+ });
86
+ });
87
+ it('exposes the matched element on FieldView.element', () => {
88
+ const c = makeContainer(`<input name="x" />`);
89
+ const input = c.querySelector('input');
90
+ expect(walkSlotContainer('s1', c)[0].element).toBe(input);
91
+ });
92
+ it('writeElement on a native input dispatches input + change', async () => {
93
+ const { writeElement } = await import('./dispatch');
94
+ const input = document.createElement('input');
95
+ input.type = 'text';
96
+ document.body.appendChild(input);
97
+ let inputCount = 0, changeCount = 0;
98
+ input.addEventListener('input', () => inputCount++);
99
+ input.addEventListener('change', () => changeCount++);
100
+ writeElement(input, 'hello');
101
+ expect(input.value).toBe('hello');
102
+ expect(inputCount).toBe(1);
103
+ expect(changeCount).toBe(1);
104
+ });
105
+ it('writeElement on a checkbox toggles .checked', async () => {
106
+ const { writeElement } = await import('./dispatch');
107
+ const input = document.createElement('input');
108
+ input.type = 'checkbox';
109
+ document.body.appendChild(input);
110
+ writeElement(input, true);
111
+ expect(input.checked).toBe(true);
112
+ writeElement(input, false);
113
+ expect(input.checked).toBe(false);
114
+ });
115
+ describe('listFields walker integration', () => {
116
+ it('walker: "off" (default) ignores walker', () => {
117
+ makeContainer(`<input name="x" />`);
118
+ expect(listFields({ slotId: 's1' })).toEqual([]);
119
+ });
120
+ it('walker: "always" appends walker fields when a container is registered', () => {
121
+ const c = makeContainer(`<input name="x" />`);
122
+ expect(walkSlotContainer('s1', c)[0].fieldId).toBe('x');
123
+ });
124
+ it('walker dedup: skips elements already covered by an element-ref contribution', () => {
125
+ const c = makeContainer(`<input name="x" /><input name="y" />`);
126
+ const xInput = c.querySelector('input[name="x"]');
127
+ register(FIELD_POINT_ID, {
128
+ owner: { shardId: 'tester', slotId: 's1' },
129
+ descriptor: {
130
+ shape: 'element', fieldId: 'titled-x', label: 'Titled X', kind: 'string', element: xInput,
131
+ },
132
+ });
133
+ const out = walkSlotContainer('s1', c);
134
+ const ids = out.map((f) => f.fieldId);
135
+ expect(ids).toEqual(['y']);
136
+ });
137
+ });
138
+ });
package/dist/host.js CHANGED
@@ -39,6 +39,7 @@ import { returnToHome } from './apps/lifecycle';
39
39
  export { __setBackend };
40
40
  export { setLocalOwner };
41
41
  export { __setActiveScope, __setTenantId, __setDocumentBackend } from './documents/config';
42
+ import { getActiveScopeId } from './documents/config';
42
43
  export function registerShard(shard) {
43
44
  registerShardInternal(shard);
44
45
  }
@@ -66,7 +67,6 @@ export async function bootstrap(config) {
66
67
  // Per ADR-002 amendment, app workspace state is keyed by (scopeId, appId).
67
68
  // Rewrite legacy unkeyed entries to the personal scope namespace.
68
69
  const { migrateLegacyWorkspaceKeys } = await import('./apps/workspace-rekey');
69
- const { getActiveScopeId } = await import('./documents/config');
70
70
  migrateLegacyWorkspaceKeys(getActiveScopeId());
71
71
  }
72
72
  const exShards = new Set(config === null || config === void 0 ? void 0 : config.excludeShards);
@@ -143,8 +143,33 @@ export async function bootstrapSatellite(config) {
143
143
  }
144
144
  // 3. Load any packages installed in a previous session from IndexedDB
145
145
  await loadInstalledPackages();
146
- // 4. Activate exactly the requested shards.
146
+ // 4. Autostart sweep mirror host bootstrap. Every shard with an
147
+ // `autostart` hook runs in satellites too: services (LLM providers),
148
+ // ambient action hosts (`__sh3core__` provides the command palette
149
+ // via `sh3.palette.open`), and any user-installed self-starter.
150
+ // `addAutostartShard` keeps action scoping consistent with the host
151
+ // so palette/global actions remain ambient inside `'app'` scope.
152
+ const autostartActivated = new Set();
153
+ for (const [id, shard] of registeredShards) {
154
+ if (shard.autostart) {
155
+ addAutostartShard(id);
156
+ try {
157
+ await activateShard(id, { phase: 'autostart' });
158
+ autostartActivated.add(id);
159
+ }
160
+ catch (_a) {
161
+ // Already logged + recorded in erroredShards by activateShard.
162
+ // One bad self-starting shard must not prevent the satellite from booting.
163
+ }
164
+ }
165
+ }
166
+ // 5. Activate explicit satellite shards (those carried by activateShards
167
+ // in the payload — typically view-providing shards walked from the
168
+ // layout). Skip ids already activated by the autostart sweep so the
169
+ // diagnostic phase stays correct and we do no redundant work.
147
170
  for (const id of config.activateShardIds) {
171
+ if (autostartActivated.has(id))
172
+ continue;
148
173
  if (registeredShards.has(id)) {
149
174
  try {
150
175
  await activateShard(id, { phase: 'satellite' });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { resetFramework } from './__test__/reset';
3
+ import { makeShard, makeShardManifest } from './__test__/fixtures';
4
+ import { bootstrapSatellite } from './host';
5
+ import { registerShard, activeShards, erroredShards, } from './shards/activate.svelte';
6
+ import { getLiveDispatcherState } from './actions/state.svelte';
7
+ import { listActions } from './actions/registry';
8
+ // loadInstalledPackages reads IndexedDB; neutralize it so the satellite
9
+ // boot path can run in happy-dom without a real storage backend.
10
+ vi.mock('./registry/installer', async (orig) => {
11
+ const real = await orig();
12
+ return Object.assign(Object.assign({}, real), { loadInstalledPackages: vi.fn(async () => { }) });
13
+ });
14
+ describe('bootstrapSatellite — autostart sweep', () => {
15
+ beforeEach(resetFramework);
16
+ it('activates a registered autostart shard even when not in activateShardIds', async () => {
17
+ const activate = vi.fn();
18
+ const autostart = vi.fn();
19
+ const svc = makeShard({
20
+ manifest: makeShardManifest({ id: 'svc-llm' }),
21
+ activate,
22
+ autostart,
23
+ });
24
+ registerShard(svc);
25
+ await bootstrapSatellite({ activateShardIds: [] });
26
+ expect(activate).toHaveBeenCalledTimes(1);
27
+ expect(autostart).toHaveBeenCalledTimes(1);
28
+ expect(activeShards.has('svc-llm')).toBe(true);
29
+ });
30
+ it('adds every activated autostart shard to the dispatcher autostart set', async () => {
31
+ const svc = makeShard({
32
+ manifest: makeShardManifest({ id: 'svc-llm' }),
33
+ autostart: () => { },
34
+ });
35
+ registerShard(svc);
36
+ await bootstrapSatellite({ activateShardIds: [] });
37
+ expect(getLiveDispatcherState().autostartShards.has('svc-llm')).toBe(true);
38
+ });
39
+ it('records a throwing autostart shard with phase "autostart" and continues activating the rest', async () => {
40
+ var _a;
41
+ const goodActivate = vi.fn();
42
+ const bad = makeShard({
43
+ manifest: makeShardManifest({ id: 'svc-bad' }),
44
+ activate: () => {
45
+ throw new Error('boom');
46
+ },
47
+ autostart: () => { },
48
+ });
49
+ const good = makeShard({
50
+ manifest: makeShardManifest({ id: 'svc-good' }),
51
+ activate: goodActivate,
52
+ autostart: () => { },
53
+ });
54
+ registerShard(bad);
55
+ registerShard(good);
56
+ await bootstrapSatellite({ activateShardIds: [] });
57
+ expect((_a = erroredShards.get('svc-bad')) === null || _a === void 0 ? void 0 : _a.phase).toBe('autostart');
58
+ expect(activeShards.has('svc-bad')).toBe(false);
59
+ expect(goodActivate).toHaveBeenCalledTimes(1);
60
+ expect(activeShards.has('svc-good')).toBe(true);
61
+ });
62
+ it('dedupes — an autostart shard listed in activateShardIds activates exactly once', async () => {
63
+ const activate = vi.fn();
64
+ const svc = makeShard({
65
+ manifest: makeShardManifest({ id: 'svc-llm' }),
66
+ activate,
67
+ autostart: () => { },
68
+ });
69
+ registerShard(svc);
70
+ await bootstrapSatellite({ activateShardIds: ['svc-llm'] });
71
+ expect(activate).toHaveBeenCalledTimes(1);
72
+ });
73
+ it('registers sh3.palette.open after bootstrapSatellite completes (palette reachable)', async () => {
74
+ await bootstrapSatellite({ activateShardIds: [] });
75
+ const ids = listActions().map((entry) => entry.action.id);
76
+ expect(ids).toContain('sh3.palette.open');
77
+ });
78
+ it('still activates non-autostart shards passed in activateShardIds with phase "satellite"', async () => {
79
+ var _a;
80
+ const activate = vi.fn(() => {
81
+ throw new Error('explicit-fail');
82
+ });
83
+ const view = makeShard({
84
+ manifest: makeShardManifest({ id: 'view-only' }),
85
+ activate,
86
+ });
87
+ registerShard(view);
88
+ await bootstrapSatellite({ activateShardIds: ['view-only'] });
89
+ expect(activate).toHaveBeenCalledTimes(1);
90
+ expect((_a = erroredShards.get('view-only')) === null || _a === void 0 ? void 0 : _a.phase).toBe('satellite');
91
+ });
92
+ });
@@ -23,6 +23,14 @@ export declare function releaseSlotHost(slotId: string, fromWrapper?: HTMLElemen
23
23
  * by HMR boundaries and tests; not part of normal runtime flow.
24
24
  */
25
25
  export declare function resetSlotHostPool(): void;
26
+ /**
27
+ * Read-only peek at a pooled host. Returns the live entry without
28
+ * affecting refcounts. Used by Sh3Api.fields.walk and friends.
29
+ */
30
+ export declare function peekSlotHost(slotId: string): {
31
+ host: HTMLElement;
32
+ refcount: number;
33
+ } | undefined;
26
34
  /**
27
35
  * Read the current ViewHandle for a slot. Returns undefined if the slot
28
36
  * is not in the pool or hasn't finished mounting yet. Used by the close
@@ -36,6 +36,7 @@ import { getView, __addViewRegistrationListener } from '../shards/registry';
36
36
  import { locateSlotIn } from './ops';
37
37
  import { activeLayout } from './store.svelte';
38
38
  import { scopeToString } from '../actions/scope-helpers';
39
+ import { __disposeSlotContributions } from '../contributions';
39
40
  const pool = new Map();
40
41
  const pendingDestroy = new Set();
41
42
  /**
@@ -284,6 +285,7 @@ export function releaseSlotHost(slotId, fromWrapper) {
284
285
  if (!current || current.refcount > 0)
285
286
  return; // re-acquired, keep
286
287
  (_a = current.resizeObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
288
+ __disposeSlotContributions(slotId);
287
289
  (_b = current.handle) === null || _b === void 0 ? void 0 : _b.unmount();
288
290
  current.cancelPendingMount();
289
291
  current.host.remove();
@@ -299,8 +301,9 @@ export function releaseSlotHost(slotId, fromWrapper) {
299
301
  export function resetSlotHostPool() {
300
302
  var _a, _b;
301
303
  pendingDestroy.clear();
302
- for (const entry of pool.values()) {
304
+ for (const [slotId, entry] of pool.entries()) {
303
305
  (_a = entry.resizeObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
306
+ __disposeSlotContributions(slotId);
304
307
  (_b = entry.handle) === null || _b === void 0 ? void 0 : _b.unmount();
305
308
  entry.cancelPendingMount();
306
309
  entry.host.remove();
@@ -312,6 +315,16 @@ export function resetSlotHostPool() {
312
315
  delete closableState[key];
313
316
  handleOverrides.clear();
314
317
  }
318
+ /**
319
+ * Read-only peek at a pooled host. Returns the live entry without
320
+ * affecting refcounts. Used by Sh3Api.fields.walk and friends.
321
+ */
322
+ export function peekSlotHost(slotId) {
323
+ const entry = pool.get(slotId);
324
+ if (!entry)
325
+ return undefined;
326
+ return { host: entry.host, refcount: entry.refcount };
327
+ }
315
328
  /**
316
329
  * Read the current ViewHandle for a slot. Returns undefined if the slot
317
330
  * is not in the pool or hasn't finished mounting yet. Used by the close