@happyvertical/smrt-svelte 0.43.3 → 0.43.5

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 (118) hide show
  1. package/AGENTS.md +45 -0
  2. package/README.md +73 -0
  3. package/dist/Provider.svelte +83 -26
  4. package/dist/Provider.svelte.d.ts +4 -12
  5. package/dist/Provider.svelte.d.ts.map +1 -1
  6. package/dist/__tests__/provider-webmcp-harness.svelte +14 -0
  7. package/dist/__tests__/provider-webmcp-harness.svelte.d.ts +8 -0
  8. package/dist/__tests__/provider-webmcp-harness.svelte.d.ts.map +1 -0
  9. package/dist/components/forms/AddressInput.svelte +155 -42
  10. package/dist/components/forms/AddressInput.svelte.d.ts.map +1 -1
  11. package/dist/components/forms/CheckboxInput.svelte +40 -13
  12. package/dist/components/forms/CheckboxInput.svelte.d.ts.map +1 -1
  13. package/dist/components/forms/DateRangeInput.svelte +132 -16
  14. package/dist/components/forms/DateRangeInput.svelte.d.ts.map +1 -1
  15. package/dist/components/forms/DateTimeInput.svelte +48 -14
  16. package/dist/components/forms/DateTimeInput.svelte.d.ts.map +1 -1
  17. package/dist/components/forms/Form.svelte +667 -61
  18. package/dist/components/forms/Form.svelte.d.ts +6 -2
  19. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  20. package/dist/components/forms/MeasurementInput.svelte +137 -31
  21. package/dist/components/forms/MeasurementInput.svelte.d.ts.map +1 -1
  22. package/dist/components/forms/MoneyInput.svelte +42 -23
  23. package/dist/components/forms/MoneyInput.svelte.d.ts.map +1 -1
  24. package/dist/components/forms/NumberInput.svelte +59 -27
  25. package/dist/components/forms/NumberInput.svelte.d.ts.map +1 -1
  26. package/dist/components/forms/PhoneInput.svelte +51 -22
  27. package/dist/components/forms/PhoneInput.svelte.d.ts.map +1 -1
  28. package/dist/components/forms/SelectInput.svelte +38 -22
  29. package/dist/components/forms/SelectInput.svelte.d.ts.map +1 -1
  30. package/dist/components/forms/TextInput.svelte +32 -18
  31. package/dist/components/forms/TextInput.svelte.d.ts.map +1 -1
  32. package/dist/components/forms/TextareaInput.svelte +25 -18
  33. package/dist/components/forms/TextareaInput.svelte.d.ts.map +1 -1
  34. package/dist/components/forms/__tests__/AddressInput.behavior.test.js +94 -0
  35. package/dist/components/forms/__tests__/Form.behavior.test.js +1 -1
  36. package/dist/components/forms/__tests__/Form.extraction.test.js +68 -0
  37. package/dist/components/forms/__tests__/Form.test.js +28 -0
  38. package/dist/components/forms/__tests__/Form.webmcp.test.js +2120 -111
  39. package/dist/components/forms/__tests__/FormMicButton.test.js +0 -1
  40. package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte +30 -0
  41. package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts +7 -0
  42. package/dist/components/forms/__tests__/async-invalid-field.fixture.svelte.d.ts.map +1 -0
  43. package/dist/components/forms/__tests__/async-validation-form.fixture.svelte +19 -0
  44. package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts +9 -0
  45. package/dist/components/forms/__tests__/async-validation-form.fixture.svelte.d.ts.map +1 -0
  46. package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte +21 -0
  47. package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts +8 -0
  48. package/dist/components/forms/__tests__/async-validation-snapshot-form.fixture.svelte.d.ts.map +1 -0
  49. package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte +27 -0
  50. package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts +9 -0
  51. package/dist/components/forms/__tests__/compound-registration-wrapper.fixture.svelte.d.ts.map +1 -0
  52. package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte +48 -0
  53. package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts +10 -0
  54. package/dist/components/forms/__tests__/custom-registration-field.fixture.svelte.d.ts.map +1 -0
  55. package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte +26 -0
  56. package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts +13 -0
  57. package/dist/components/forms/__tests__/form-extraction-values.fixture.svelte.d.ts.map +1 -0
  58. package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte +52 -0
  59. package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts +16 -0
  60. package/dist/components/forms/__tests__/form-registration-lifecycle.fixture.svelte.d.ts.map +1 -0
  61. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte +108 -5
  62. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts +34 -0
  63. package/dist/components/forms/__tests__/form-with-fields.fixture.svelte.d.ts.map +1 -1
  64. package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte +24 -0
  65. package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts +11 -0
  66. package/dist/components/forms/__tests__/form-with-policy-field.fixture.svelte.d.ts.map +1 -0
  67. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte +69 -4
  68. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts +32 -0
  69. package/dist/components/forms/__tests__/form-with-structured-fields.fixture.svelte.d.ts.map +1 -1
  70. package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte +44 -0
  71. package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts +9 -0
  72. package/dist/components/forms/__tests__/legacy-form-context.fixture.svelte.d.ts.map +1 -0
  73. package/dist/components/forms/__tests__/policy-field.fixture.svelte +35 -0
  74. package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts +8 -0
  75. package/dist/components/forms/__tests__/policy-field.fixture.svelte.d.ts.map +1 -0
  76. package/dist/components/forms/prepare-field-value.d.ts +5 -0
  77. package/dist/components/forms/prepare-field-value.d.ts.map +1 -0
  78. package/dist/components/forms/prepare-field-value.js +15 -0
  79. package/dist/index.d.ts +3 -0
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +2 -0
  82. package/dist/provider-webmcp.test.js +244 -0
  83. package/dist/state/form-context.d.ts +28 -6
  84. package/dist/state/form-context.d.ts.map +1 -1
  85. package/dist/state/form-context.js +75 -7
  86. package/dist/web/__tests__/provider-ui-registry.fixture.svelte +43 -0
  87. package/dist/web/__tests__/provider-ui-registry.fixture.svelte.d.ts +13 -0
  88. package/dist/web/__tests__/provider-ui-registry.fixture.svelte.d.ts.map +1 -0
  89. package/dist/web/__tests__/provider-ui-registry.test.js +235 -0
  90. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte +62 -0
  91. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts +8 -0
  92. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts.map +1 -0
  93. package/dist/web/__tests__/webmcp-composed.fixture.svelte +52 -0
  94. package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts +12 -0
  95. package/dist/web/__tests__/webmcp-composed.fixture.svelte.d.ts.map +1 -0
  96. package/dist/web/__tests__/webmcp-composed.integration.svelte.test.js +247 -0
  97. package/dist/web/__tests__/webmcp-ssr.fixture.svelte +13 -0
  98. package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts +19 -0
  99. package/dist/web/__tests__/webmcp-ssr.fixture.svelte.d.ts.map +1 -0
  100. package/dist/web/__tests__/webmcp-ui.test.js +586 -0
  101. package/dist/web/__tests__/webmcp.test.js +21 -1
  102. package/dist/web/index.d.ts +3 -0
  103. package/dist/web/index.d.ts.map +1 -1
  104. package/dist/web/index.js +2 -0
  105. package/dist/web/webmcp-provider.d.ts +23 -0
  106. package/dist/web/webmcp-provider.d.ts.map +1 -0
  107. package/dist/web/webmcp-provider.js +1 -0
  108. package/dist/web/webmcp-ui-context.d.ts +12 -0
  109. package/dist/web/webmcp-ui-context.d.ts.map +1 -0
  110. package/dist/web/webmcp-ui-context.js +16 -0
  111. package/dist/web/webmcp-ui.d.ts +21 -0
  112. package/dist/web/webmcp-ui.d.ts.map +1 -0
  113. package/dist/web/webmcp-ui.js +474 -0
  114. package/dist/web/webmcp.d.ts +1 -1
  115. package/dist/web/webmcp.svelte.d.ts +4 -1
  116. package/dist/web/webmcp.svelte.d.ts.map +1 -1
  117. package/dist/web/webmcp.svelte.js +6 -1
  118. package/package.json +8 -6
@@ -1,11 +1,83 @@
1
1
  /**
2
2
  * SMRT Form context for field registration and coordination
3
3
  */
4
- import { getContext, setContext } from 'svelte';
4
+ import { getAllContexts, getContext, setContext } from 'svelte';
5
5
  /**
6
6
  * Context key for SMRT form
7
7
  */
8
8
  export const SMRT_FORM_KEY = Symbol('smrt-form');
9
+ // Svelte gives every component its own context map, copying inherited values
10
+ // into that map. Cache a facade by this map rather than by the inherited
11
+ // context value: repeated calls from one component share ownership, while
12
+ // descendants that inherit a facade receive their own ownership boundary.
13
+ const callerFormContextFacades = new WeakMap();
14
+ function createFormContextFacade(ctx) {
15
+ const registrations = new Map();
16
+ const facade = {
17
+ get mode() {
18
+ return ctx.mode;
19
+ },
20
+ registerField(field) {
21
+ const registeredName = field.name;
22
+ const registeredDisposer = ctx.registerField(field);
23
+ const disposeField = typeof registeredDisposer === 'function'
24
+ ? registeredDisposer
25
+ : () => ctx.unregisterField(registeredName);
26
+ let active = true;
27
+ const dispose = () => {
28
+ if (!active)
29
+ return;
30
+ active = false;
31
+ const ownedRegistrations = registrations.get(registeredName);
32
+ ownedRegistrations?.delete(dispose);
33
+ if (ownedRegistrations?.size === 0) {
34
+ registrations.delete(registeredName);
35
+ }
36
+ disposeField();
37
+ };
38
+ const ownedRegistrations = registrations.get(registeredName) ?? new Set();
39
+ ownedRegistrations.add(dispose);
40
+ registrations.set(registeredName, ownedRegistrations);
41
+ return dispose;
42
+ },
43
+ unregisterField(name) {
44
+ const ownedRegistrations = registrations.get(name);
45
+ const dispose = ownedRegistrations?.values().next().value;
46
+ dispose?.();
47
+ },
48
+ getFieldSchema: () => ctx.getFieldSchema(),
49
+ get isFormListening() {
50
+ return ctx.isFormListening;
51
+ },
52
+ get isExtracting() {
53
+ return ctx.isExtracting;
54
+ },
55
+ toggleListening: () => ctx.toggleListening(),
56
+ get interactionRegistry() {
57
+ return ctx.interactionRegistry;
58
+ },
59
+ get formId() {
60
+ return ctx.formId;
61
+ },
62
+ };
63
+ return facade;
64
+ }
65
+ function getCallerFormContext(required) {
66
+ const contextMap = getAllContexts();
67
+ const existingFacade = callerFormContextFacades.get(contextMap);
68
+ if (existingFacade)
69
+ return existingFacade;
70
+ const ctx = getContext(SMRT_FORM_KEY);
71
+ if (!ctx) {
72
+ if (!required)
73
+ return null;
74
+ throw new Error('Form context not found. Make sure to wrap your inputs with <SMRTForm>');
75
+ }
76
+ const facade = createFormContextFacade(ctx);
77
+ callerFormContextFacades.set(contextMap, facade);
78
+ setContext(SMRT_FORM_KEY, facade);
79
+ return facade;
80
+ }
9
81
  /**
10
82
  * Set form context (used by SMRTForm)
11
83
  */
@@ -17,15 +89,11 @@ export function setFormContext(ctx) {
17
89
  * @throws If called outside of SMRTForm
18
90
  */
19
91
  export function getFormContext() {
20
- const ctx = getContext(SMRT_FORM_KEY);
21
- if (!ctx) {
22
- throw new Error('Form context not found. Make sure to wrap your inputs with <SMRTForm>');
23
- }
24
- return ctx;
92
+ return getCallerFormContext(true);
25
93
  }
26
94
  /**
27
95
  * Try to get form context (returns null if not available)
28
96
  */
29
97
  export function tryGetFormContext() {
30
- return getContext(SMRT_FORM_KEY) ?? null;
98
+ return getCallerFormContext(false);
31
99
  }
@@ -0,0 +1,43 @@
1
+ <script lang="ts">
2
+ import type {
3
+ ControlInteractionEvent,
4
+ ControlInteractionRegistry,
5
+ } from '@happyvertical/smrt-ui/forms';
6
+ import Form from '../../components/forms/Form.svelte';
7
+ import TextInput from '../../components/forms/TextInput.svelte';
8
+ import Provider from '../../Provider.svelte';
9
+
10
+ let {
11
+ providerRegistry,
12
+ explicitRegistry,
13
+ enableUi = true,
14
+ onSharedInteraction,
15
+ onSiblingInteraction,
16
+ showSibling = true,
17
+ }: {
18
+ providerRegistry: ControlInteractionRegistry;
19
+ explicitRegistry: ControlInteractionRegistry;
20
+ enableUi?: boolean;
21
+ onSharedInteraction?: (event: ControlInteractionEvent) => void;
22
+ onSiblingInteraction?: (event: ControlInteractionEvent) => void;
23
+ showSibling?: boolean;
24
+ } = $props();
25
+ </script>
26
+
27
+ <Provider
28
+ webmcp={enableUi
29
+ ? { ui: { controlRegistry: providerRegistry } }
30
+ : { definitions: [] }}
31
+ >
32
+ <Form formId="shared-form" oninteraction={onSharedInteraction}>
33
+ <TextInput name="shared-name" label="Shared name" />
34
+ </Form>
35
+ {#if showSibling}
36
+ <Form formId="sibling-form" oninteraction={onSiblingInteraction}>
37
+ <TextInput name="sibling-name" label="Sibling name" />
38
+ </Form>
39
+ {/if}
40
+ <Form formId="explicit-form" interactionRegistry={explicitRegistry}>
41
+ <TextInput name="explicit-name" label="Explicit name" />
42
+ </Form>
43
+ </Provider>
@@ -0,0 +1,13 @@
1
+ import type { ControlInteractionEvent, ControlInteractionRegistry } from '@happyvertical/smrt-ui/forms';
2
+ type $$ComponentProps = {
3
+ providerRegistry: ControlInteractionRegistry;
4
+ explicitRegistry: ControlInteractionRegistry;
5
+ enableUi?: boolean;
6
+ onSharedInteraction?: (event: ControlInteractionEvent) => void;
7
+ onSiblingInteraction?: (event: ControlInteractionEvent) => void;
8
+ showSibling?: boolean;
9
+ };
10
+ declare const ProviderUiRegistry: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type ProviderUiRegistry = ReturnType<typeof ProviderUiRegistry>;
12
+ export default ProviderUiRegistry;
13
+ //# sourceMappingURL=provider-ui-registry.fixture.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider-ui-registry.fixture.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/provider-ui-registry.fixture.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,uBAAuB,EACvB,0BAA0B,EAC3B,MAAM,8BAA8B,CAAC;AAKrC,KAAK,gBAAgB,GAAI;IACxB,gBAAgB,EAAE,0BAA0B,CAAC;IAC7C,gBAAgB,EAAE,0BAA0B,CAAC;IAC7C,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,mBAAmB,CAAC,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,IAAI,CAAC;IAC/D,oBAAoB,CAAC,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,IAAI,CAAC;IAChE,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB,CAAC;AAmCF,QAAA,MAAM,kBAAkB,sDAAwC,CAAC;AACjE,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAChE,eAAe,kBAAkB,CAAC"}
@@ -0,0 +1,235 @@
1
+ import { createControlInteractionRegistry } from '@happyvertical/smrt-ui/forms';
2
+ import { render } from '@testing-library/svelte';
3
+ import { tick } from 'svelte';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import Fixture from './provider-ui-registry.fixture.svelte';
6
+ afterEach(() => {
7
+ delete document.modelContext;
8
+ });
9
+ describe('Provider WebMCP UI registry context', () => {
10
+ it('aggregates descendant forms while preserving explicit registry overrides', async () => {
11
+ const providerRegistry = createControlInteractionRegistry();
12
+ const explicitRegistry = createControlInteractionRegistry();
13
+ const signals = [];
14
+ const names = [];
15
+ document.modelContext = {
16
+ async registerTool(tool, options) {
17
+ names.push(tool.name);
18
+ if (options?.signal)
19
+ signals.push(options.signal);
20
+ },
21
+ };
22
+ const view = render(Fixture, {
23
+ props: { providerRegistry, explicitRegistry },
24
+ });
25
+ await tick();
26
+ expect(names).toEqual([
27
+ 'smrt_ui_list_form_controls',
28
+ 'smrt_ui_inspect_form_control',
29
+ 'smrt_ui_execute_form_control',
30
+ 'smrt_ui_list_data_surfaces',
31
+ 'smrt_ui_inspect_data_surface',
32
+ 'smrt_ui_execute_data_surface_control',
33
+ ]);
34
+ expect(providerRegistry.list().map((entry) => entry.identity)).toEqual([
35
+ { formId: 'shared-form', controlId: 'shared-name' },
36
+ { formId: 'sibling-form', controlId: 'sibling-name' },
37
+ ]);
38
+ expect(explicitRegistry.list().map((entry) => entry.identity)).toEqual([
39
+ { formId: 'explicit-form', controlId: 'explicit-name' },
40
+ ]);
41
+ view.unmount();
42
+ expect(providerRegistry.list()).toEqual([]);
43
+ expect(explicitRegistry.list()).toEqual([]);
44
+ expect(signals).toHaveLength(6);
45
+ expect(signals.every((signal) => signal.aborted)).toBe(true);
46
+ });
47
+ it('keeps form interaction callbacks scoped when forms share a Provider registry', async () => {
48
+ const providerRegistry = createControlInteractionRegistry();
49
+ const onSharedInteraction = vi.fn();
50
+ const onSiblingInteraction = vi.fn();
51
+ const view = render(Fixture, {
52
+ props: {
53
+ providerRegistry,
54
+ explicitRegistry: createControlInteractionRegistry(),
55
+ onSharedInteraction,
56
+ onSiblingInteraction,
57
+ },
58
+ });
59
+ await tick();
60
+ onSharedInteraction.mockClear();
61
+ onSiblingInteraction.mockClear();
62
+ await providerRegistry.execute({
63
+ action: 'stage',
64
+ identity: { formId: 'sibling-form', controlId: 'sibling-name' },
65
+ value: 'private sibling value',
66
+ });
67
+ expect(onSharedInteraction).not.toHaveBeenCalled();
68
+ expect(onSiblingInteraction).toHaveBeenCalled();
69
+ expect(onSiblingInteraction.mock.calls.every(([event]) => event.identity.formId === 'sibling-form')).toBe(true);
70
+ view.unmount();
71
+ });
72
+ it('moves mounted controls when the Provider registry changes', async () => {
73
+ const firstRegistry = createControlInteractionRegistry();
74
+ const secondRegistry = createControlInteractionRegistry();
75
+ const explicitRegistry = createControlInteractionRegistry();
76
+ const view = render(Fixture, {
77
+ props: { providerRegistry: firstRegistry, explicitRegistry },
78
+ });
79
+ await tick();
80
+ expect(firstRegistry.list()).toHaveLength(2);
81
+ await view.rerender({
82
+ providerRegistry: secondRegistry,
83
+ explicitRegistry,
84
+ });
85
+ await tick();
86
+ expect(firstRegistry.list()).toEqual([]);
87
+ expect(secondRegistry.list()).toHaveLength(2);
88
+ view.unmount();
89
+ });
90
+ it('preserves staged sibling state when another field unmounts', async () => {
91
+ const providerRegistry = createControlInteractionRegistry();
92
+ const explicitRegistry = createControlInteractionRegistry();
93
+ const view = render(Fixture, {
94
+ props: { providerRegistry, explicitRegistry },
95
+ });
96
+ await tick();
97
+ await providerRegistry.execute({
98
+ action: 'stage',
99
+ identity: { formId: 'shared-form', controlId: 'shared-name' },
100
+ value: 'keep staged',
101
+ });
102
+ await view.rerender({
103
+ providerRegistry,
104
+ explicitRegistry,
105
+ showSibling: false,
106
+ });
107
+ await tick();
108
+ expect(providerRegistry.get({
109
+ formId: 'shared-form',
110
+ controlId: 'shared-name',
111
+ })?.state.stagedValue).toBe('keep staged');
112
+ view.unmount();
113
+ });
114
+ it('retries a shared identity after the existing registration unmounts', async () => {
115
+ const providerRegistry = createControlInteractionRegistry();
116
+ const unregisterExisting = providerRegistry.register({
117
+ identity: { formId: 'shared-form', controlId: 'shared-name' },
118
+ metadata: { kind: 'text', label: 'Existing' },
119
+ getValue: () => 'existing value',
120
+ });
121
+ const view = render(Fixture, {
122
+ props: {
123
+ providerRegistry,
124
+ explicitRegistry: createControlInteractionRegistry(),
125
+ },
126
+ });
127
+ await tick();
128
+ expect(providerRegistry.get({
129
+ formId: 'shared-form',
130
+ controlId: 'shared-name',
131
+ })?.state.value).toBe('existing value');
132
+ unregisterExisting();
133
+ await tick();
134
+ expect(providerRegistry.get({
135
+ formId: 'shared-form',
136
+ controlId: 'shared-name',
137
+ })?.metadata.label).toBe('Shared name');
138
+ view.unmount();
139
+ expect(providerRegistry.get({
140
+ formId: 'shared-form',
141
+ controlId: 'shared-name',
142
+ })).toBeUndefined();
143
+ });
144
+ it('recovers after its shared identity is displaced and removed', async () => {
145
+ const providerRegistry = createControlInteractionRegistry();
146
+ const view = render(Fixture, {
147
+ props: {
148
+ providerRegistry,
149
+ explicitRegistry: createControlInteractionRegistry(),
150
+ },
151
+ });
152
+ await tick();
153
+ const unregisterReplacement = providerRegistry.register({
154
+ identity: { formId: 'shared-form', controlId: 'shared-name' },
155
+ metadata: { kind: 'text', label: 'Replacement' },
156
+ getValue: () => 'replacement value',
157
+ });
158
+ await tick();
159
+ expect(providerRegistry.get({
160
+ formId: 'shared-form',
161
+ controlId: 'shared-name',
162
+ })?.state.value).toBe('replacement value');
163
+ unregisterReplacement();
164
+ await tick();
165
+ expect(providerRegistry.get({
166
+ formId: 'shared-form',
167
+ controlId: 'shared-name',
168
+ })?.metadata.label).toBe('Shared name');
169
+ view.unmount();
170
+ });
171
+ it('contains duplicate Provider prefixes without crashing the app', async () => {
172
+ const names = [];
173
+ document.modelContext = {
174
+ async registerTool(tool) {
175
+ names.push(tool.name);
176
+ },
177
+ };
178
+ const first = render(Fixture, {
179
+ props: {
180
+ providerRegistry: createControlInteractionRegistry(),
181
+ explicitRegistry: createControlInteractionRegistry(),
182
+ },
183
+ });
184
+ await tick();
185
+ const second = render(Fixture, {
186
+ props: {
187
+ providerRegistry: createControlInteractionRegistry(),
188
+ explicitRegistry: createControlInteractionRegistry(),
189
+ },
190
+ });
191
+ await tick();
192
+ expect(names).toHaveLength(6);
193
+ second.unmount();
194
+ first.unmount();
195
+ });
196
+ it('isolates throwing interaction observers from registry command outcomes', async () => {
197
+ const providerRegistry = createControlInteractionRegistry();
198
+ const view = render(Fixture, {
199
+ props: {
200
+ providerRegistry,
201
+ explicitRegistry: createControlInteractionRegistry(),
202
+ onSiblingInteraction: () => {
203
+ throw new Error('observer failed');
204
+ },
205
+ },
206
+ });
207
+ await tick();
208
+ await expect(providerRegistry.execute({
209
+ action: 'stage',
210
+ identity: { formId: 'sibling-form', controlId: 'sibling-name' },
211
+ value: 'still staged',
212
+ })).resolves.toMatchObject({ ok: true, action: 'stage' });
213
+ view.unmount();
214
+ });
215
+ it('preserves legacy object configs that did not opt into UI tools', async () => {
216
+ const names = [];
217
+ const providerRegistry = createControlInteractionRegistry();
218
+ document.modelContext = {
219
+ async registerTool(tool) {
220
+ names.push(tool.name);
221
+ },
222
+ };
223
+ const view = render(Fixture, {
224
+ props: {
225
+ providerRegistry,
226
+ explicitRegistry: createControlInteractionRegistry(),
227
+ enableUi: false,
228
+ },
229
+ });
230
+ await tick();
231
+ expect(names).toEqual([]);
232
+ expect(providerRegistry.list()).toEqual([]);
233
+ view.unmount();
234
+ });
235
+ });
@@ -0,0 +1,62 @@
1
+ <script lang="ts">
2
+ import type { DataSurfaceRegistry } from '@happyvertical/smrt-ui/data';
3
+ import {
4
+ type DataSurfaceDescriptor,
5
+ DataTable,
6
+ } from '@happyvertical/smrt-ui/data';
7
+ import Form from '../../components/forms/Form.svelte';
8
+ import TextInput from '../../components/forms/TextInput.svelte';
9
+ import { useWebMcpTool } from '../webmcp.svelte.js';
10
+
11
+ let { dataSurfaceRegistry }: { dataSurfaceRegistry: DataSurfaceRegistry } =
12
+ $props();
13
+
14
+ useWebMcpTool(() => ({
15
+ name: 'fixture_component_preview',
16
+ description:
17
+ 'Preview the composed fixture without changing application state.',
18
+ inputSchema: { type: 'object', properties: {} },
19
+ annotations: { readOnlyHint: true, openWorldHint: false },
20
+ execute: () => JSON.stringify({ ok: true, source: 'bespoke-component' }),
21
+ }));
22
+
23
+ const tableDescriptor: DataSurfaceDescriptor = {
24
+ version: 1,
25
+ identity: { surfaceId: 'fixture-items', kind: 'table' },
26
+ schemaVersion: 1,
27
+ label: 'Fixture items',
28
+ rowKey: 'id',
29
+ columns: [
30
+ { id: 'id', label: 'ID', capabilities: ['read', 'project'] },
31
+ {
32
+ id: 'name',
33
+ label: 'Name',
34
+ capabilities: ['read', 'search', 'filter', 'sort', 'project'],
35
+ },
36
+ ],
37
+ query: { modes: ['rows', 'count'], projectableColumnIds: ['id', 'name'] },
38
+ controls: ['set-search', 'toggle-sorting'].map((id) => ({ id, label: id })),
39
+ actions: [],
40
+ limits: { maxQueryRows: 50, maxQueryBytes: 10_000, maxSelectionSize: 10 },
41
+ };
42
+
43
+ const rows = [
44
+ { id: 'item-1', name: 'First' },
45
+ { id: 'item-2', name: 'Second' },
46
+ ];
47
+ const columns = [
48
+ { id: 'id', label: 'ID', accessor: 'id' },
49
+ { id: 'name', label: 'Name', accessor: 'name', sortable: true },
50
+ ];
51
+ </script>
52
+
53
+ <Form formId="fixture-form">
54
+ <TextInput name="title" label="Title" />
55
+ </Form>
56
+ <DataTable
57
+ data={rows}
58
+ {columns}
59
+ rowKey="id"
60
+ sortable={true}
61
+ dataSurface={{ registry: dataSurfaceRegistry, descriptor: tableDescriptor }}
62
+ />
@@ -0,0 +1,8 @@
1
+ import type { DataSurfaceRegistry } from '@happyvertical/smrt-ui/data';
2
+ type $$ComponentProps = {
3
+ dataSurfaceRegistry: DataSurfaceRegistry;
4
+ };
5
+ declare const WebmcpComposed: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type WebmcpComposed = ReturnType<typeof WebmcpComposed>;
7
+ export default WebmcpComposed;
8
+ //# sourceMappingURL=webmcp-composed.child.fixture.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webmcp-composed.child.fixture.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/webmcp-composed.child.fixture.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAStE,KAAK,gBAAgB,GAAI;IAAE,mBAAmB,EAAE,mBAAmB,CAAA;CAAE,CAAC;AAyDvE,QAAA,MAAM,cAAc,sDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -0,0 +1,52 @@
1
+ <script lang="ts">
2
+ import type { DataSurfaceRegistry } from '@happyvertical/smrt-ui/data';
3
+ import type {
4
+ RegisterWebMcpToolsOptions,
5
+ WebMcpToolDefinition,
6
+ } from '@happyvertical/smrt-web';
7
+ import Provider from '../../Provider.svelte';
8
+ import type { WebMcpProviderConfig } from '../webmcp-provider.js';
9
+ import Child from './webmcp-composed.child.fixture.svelte';
10
+
11
+ let {
12
+ dataSurfaceRegistry,
13
+ generatedDefinitions = [],
14
+ fetchFn,
15
+ showChild = true,
16
+ }: {
17
+ dataSurfaceRegistry: DataSurfaceRegistry;
18
+ generatedDefinitions?: readonly WebMcpToolDefinition[];
19
+ fetchFn?: RegisterWebMcpToolsOptions['fetchFn'];
20
+ showChild?: boolean;
21
+ } = $props();
22
+
23
+ let cachedDefinitions: readonly WebMcpToolDefinition[] | undefined;
24
+ let cachedFetchFn: RegisterWebMcpToolsOptions['fetchFn'] | undefined;
25
+ let cachedRegistry: DataSurfaceRegistry | undefined;
26
+ let cachedWebmcp: WebMcpProviderConfig | undefined;
27
+ const webmcp = $derived.by(() => {
28
+ if (
29
+ cachedWebmcp &&
30
+ cachedDefinitions === generatedDefinitions &&
31
+ cachedFetchFn === fetchFn &&
32
+ cachedRegistry === dataSurfaceRegistry
33
+ ) {
34
+ return cachedWebmcp;
35
+ }
36
+ cachedDefinitions = generatedDefinitions;
37
+ cachedFetchFn = fetchFn;
38
+ cachedRegistry = dataSurfaceRegistry;
39
+ cachedWebmcp = {
40
+ definitions: generatedDefinitions,
41
+ ...(fetchFn ? { fetchFn } : {}),
42
+ ui: { dataSurfaceRegistry },
43
+ };
44
+ return cachedWebmcp;
45
+ });
46
+ </script>
47
+
48
+ <Provider {webmcp}>
49
+ {#if showChild}
50
+ <Child {dataSurfaceRegistry} />
51
+ {/if}
52
+ </Provider>
@@ -0,0 +1,12 @@
1
+ import type { DataSurfaceRegistry } from '@happyvertical/smrt-ui/data';
2
+ import type { RegisterWebMcpToolsOptions, WebMcpToolDefinition } from '@happyvertical/smrt-web';
3
+ type $$ComponentProps = {
4
+ dataSurfaceRegistry: DataSurfaceRegistry;
5
+ generatedDefinitions?: readonly WebMcpToolDefinition[];
6
+ fetchFn?: RegisterWebMcpToolsOptions['fetchFn'];
7
+ showChild?: boolean;
8
+ };
9
+ declare const WebmcpComposed: import("svelte").Component<$$ComponentProps, {}, "">;
10
+ type WebmcpComposed = ReturnType<typeof WebmcpComposed>;
11
+ export default WebmcpComposed;
12
+ //# sourceMappingURL=webmcp-composed.fixture.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webmcp-composed.fixture.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/webmcp-composed.fixture.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AACvE,OAAO,KAAK,EACV,0BAA0B,EAC1B,oBAAoB,EACrB,MAAM,yBAAyB,CAAC;AAKhC,KAAK,gBAAgB,GAAI;IACxB,mBAAmB,EAAE,mBAAmB,CAAC;IACzC,oBAAoB,CAAC,EAAE,SAAS,oBAAoB,EAAE,CAAC;IACvD,OAAO,CAAC,EAAE,0BAA0B,CAAC,SAAS,CAAC,CAAC;IAChD,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC;AAgDF,QAAA,MAAM,cAAc,sDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}