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.
Files changed (64) 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/contributions/index.d.ts +1 -1
  6. package/dist/contributions/index.js +1 -1
  7. package/dist/contributions/registry.d.ts +17 -1
  8. package/dist/contributions/registry.js +50 -2
  9. package/dist/contributions/scope.test.d.ts +1 -0
  10. package/dist/contributions/scope.test.js +52 -0
  11. package/dist/contributions/types.d.ts +11 -3
  12. package/dist/createShell.js +7 -1
  13. package/dist/fields/address.d.ts +3 -0
  14. package/dist/fields/address.js +36 -0
  15. package/dist/fields/address.test.d.ts +1 -0
  16. package/dist/fields/address.test.js +34 -0
  17. package/dist/fields/decoration.d.ts +7 -0
  18. package/dist/fields/decoration.js +199 -0
  19. package/dist/fields/decoration.svelte.test.d.ts +1 -0
  20. package/dist/fields/decoration.svelte.test.js +177 -0
  21. package/dist/fields/dispatch.d.ts +22 -0
  22. package/dist/fields/dispatch.js +254 -0
  23. package/dist/fields/dispatch.test.d.ts +1 -0
  24. package/dist/fields/dispatch.test.js +175 -0
  25. package/dist/fields/types.d.ts +101 -0
  26. package/dist/fields/types.js +16 -0
  27. package/dist/fields/walker.svelte.test.d.ts +1 -0
  28. package/dist/fields/walker.svelte.test.js +138 -0
  29. package/dist/host.js +27 -2
  30. package/dist/host.svelte.test.d.ts +1 -0
  31. package/dist/host.svelte.test.js +92 -0
  32. package/dist/layout/slotHostPool.svelte.d.ts +8 -0
  33. package/dist/layout/slotHostPool.svelte.js +14 -1
  34. package/dist/overlays/OverlayRoots.svelte +86 -0
  35. package/dist/overlays/OverlayRoots.svelte.d.ts +3 -0
  36. package/dist/platform/tauri-backend.d.ts +3 -3
  37. package/dist/platform/tauri-backend.js +24 -3
  38. package/dist/projects/session-state.svelte.d.ts +3 -3
  39. package/dist/projects/session-state.svelte.js +5 -4
  40. package/dist/runtime/runVerb.js +2 -2
  41. package/dist/satellite/SatelliteShell.svelte +58 -11
  42. package/dist/satellite/SatelliteShell.svelte.test.d.ts +1 -0
  43. package/dist/satellite/SatelliteShell.svelte.test.js +61 -0
  44. package/dist/sh3Api/fields-walker.svelte.test.d.ts +1 -0
  45. package/dist/sh3Api/fields-walker.svelte.test.js +75 -0
  46. package/dist/sh3Api/headless.d.ts +9 -0
  47. package/dist/sh3Api/headless.js +163 -16
  48. package/dist/sh3Api/headless.svelte.test.js +9 -9
  49. package/dist/sh3core-shard/sh3coreShard.svelte.js +2 -2
  50. package/dist/shards/activate-fields.svelte.test.d.ts +1 -0
  51. package/dist/shards/activate-fields.svelte.test.js +121 -0
  52. package/dist/shards/activate-runtime.test.js +8 -8
  53. package/dist/shards/activate.svelte.js +29 -35
  54. package/dist/shards/types.d.ts +14 -75
  55. package/dist/shell-shard/ScrollbackView.svelte +55 -9
  56. package/dist/shell-shard/Terminal.svelte +1 -1
  57. package/dist/shell-shard/scrollback-stick.d.ts +9 -0
  58. package/dist/shell-shard/scrollback-stick.js +21 -0
  59. package/dist/shell-shard/scrollback-stick.test.d.ts +1 -0
  60. package/dist/shell-shard/scrollback-stick.test.js +25 -0
  61. package/dist/verbs/types.d.ts +56 -1
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. 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 DragPreview from './layout/DragPreview.svelte';
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): () => 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;