@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,14 +1,18 @@
1
1
  <script lang="ts">
2
2
  import {
3
+ type ControlIdentity,
3
4
  type ControlInteractionEvent,
4
5
  type ControlInteractionRegistry,
5
6
  type ControlKind,
7
+ type ControlRuntimeState,
8
+ type ControlSubject,
6
9
  createControlInteractionRegistry,
10
+ StagedControlReview,
7
11
  setControlInteractionContext,
8
12
  } from '@happyvertical/smrt-ui/forms';
9
13
  import { useI18n } from '@happyvertical/smrt-ui/i18n';
10
14
  import type { Snippet } from 'svelte';
11
- import { onDestroy } from 'svelte';
15
+ import { onDestroy, untrack } from 'svelte';
12
16
  import { useAppState } from '../../hooks/useAppState.svelte.js';
13
17
  import { useSTT } from '../../hooks/useSTT.svelte.js';
14
18
  import { M } from '../../i18n/strings.forms.js';
@@ -19,6 +23,7 @@ import {
19
23
  setFormContext,
20
24
  } from '../../state/form-context.js';
21
25
  import { useWebMcpTool } from '../../web/webmcp.svelte.js';
26
+ import { tryGetWebMcpUiContext } from '../../web/webmcp-ui-context.js';
22
27
  import type { LLMModelId, STTAdapterType } from './types.js';
23
28
 
24
29
  const { t } = useI18n();
@@ -38,7 +43,7 @@ export interface Props {
38
43
  llmModel?: LLMModelId;
39
44
  /** Called when form is submitted (if provided, prevents native submission) */
40
45
  onsubmit?: (data: Record<string, unknown>) => void | Promise<void>;
41
- /** Expose this form's submit intent to WebMCP. */
46
+ /** Let WebMCP propose field changes for local human review. */
42
47
  webmcp?: boolean | { name?: string; description?: string };
43
48
  /** Collection/model identity used when naming the generated intent. */
44
49
  collection?: string;
@@ -48,8 +53,12 @@ export interface Props {
48
53
  action?: string;
49
54
  /** Stable identity used by control/agent interaction adapters. */
50
55
  formId?: string;
56
+ /** Default record-qualified identity for registered fields. */
57
+ subject?: ControlSubject;
51
58
  interactionRegistry?: ControlInteractionRegistry;
52
59
  oninteraction?: (event: ControlInteractionEvent) => void;
60
+ /** Render the built-in human review surface for staged changes. */
61
+ stagedReview?: boolean;
53
62
  id?: string;
54
63
  name?: string;
55
64
  class?: string;
@@ -68,8 +77,10 @@ const {
68
77
  method,
69
78
  action,
70
79
  formId,
80
+ subject,
71
81
  interactionRegistry,
72
82
  oninteraction,
83
+ stagedReview = true,
73
84
  id,
74
85
  name,
75
86
  class: className = '',
@@ -80,11 +91,27 @@ const stt = useSTT();
80
91
  const instanceId = $props.id();
81
92
  const generatedFormId = `smrt-form-${instanceId}`;
82
93
  const localInteractionRegistry = createControlInteractionRegistry();
94
+ const providerWebMcpUi = tryGetWebMcpUiContext();
83
95
  const resolvedFormId = $derived(formId ?? id ?? name ?? generatedFormId);
84
96
  const resolvedInteractionRegistry = $derived(
85
- interactionRegistry ?? localInteractionRegistry,
97
+ interactionRegistry ??
98
+ (providerWebMcpUi?.enabled
99
+ ? providerWebMcpUi.controlRegistry
100
+ : undefined) ??
101
+ localInteractionRegistry,
86
102
  );
87
- const interactionDisposers = new Map<string, () => void>();
103
+ interface InteractionRegistration {
104
+ field: FieldDefinition;
105
+ subject: ControlSubject | undefined;
106
+ identity: ControlIdentity;
107
+ ownershipKey: string;
108
+ disposeRegistration: () => void;
109
+ restoreAttributes: () => void;
110
+ }
111
+ const interactionDisposers = new Map<string, InteractionRegistration>();
112
+ let activeInteractionRegistry: ControlInteractionRegistry | undefined;
113
+ let activeInteractionFormId: string | undefined;
114
+ let formElement = $state<HTMLFormElement | null>(null);
88
115
 
89
116
  setControlInteractionContext({
90
117
  get formId() {
@@ -97,11 +124,23 @@ setControlInteractionContext({
97
124
 
98
125
  $effect(() => {
99
126
  if (!oninteraction) return;
100
- return resolvedInteractionRegistry.subscribe(oninteraction);
127
+ return resolvedInteractionRegistry.subscribe((event) => {
128
+ if (event.identity.formId !== resolvedFormId) return;
129
+ try {
130
+ oninteraction(event);
131
+ } catch (error) {
132
+ logger.warn('Form: interaction observer failed', { error });
133
+ }
134
+ });
101
135
  });
102
136
 
103
137
  // Internal state
104
138
  let fields = $state<Map<string, FieldDefinition>>(new Map());
139
+ const fieldGenerations = new Map<string, symbol>();
140
+ const fieldRegistrationOrder = new Map<
141
+ string,
142
+ Array<{ field: FieldDefinition; generation: symbol }>
143
+ >();
105
144
  let isFormListening = $state(false);
106
145
  let isExtracting = $state(false);
107
146
  let spokenText = $state('');
@@ -132,49 +171,457 @@ function interactionKind(field: FieldDefinition): ControlKind {
132
171
  }
133
172
  }
134
173
 
174
+ function fieldRuntimeState(field: FieldDefinition): ControlRuntimeState {
175
+ const controls = fieldControls(field);
176
+ const domState: ControlRuntimeState = {
177
+ disabled:
178
+ controls.length > 0
179
+ ? controls.some((control) => control.matches(':disabled'))
180
+ : undefined,
181
+ readonly:
182
+ controls.some(
183
+ (control) => 'readOnly' in control && control.readOnly === true,
184
+ ) || undefined,
185
+ };
186
+ const declaredState = field.getState?.() ?? {};
187
+ return {
188
+ ...domState,
189
+ ...declaredState,
190
+ disabled: domState.disabled === true || declaredState.disabled === true,
191
+ readonly: domState.readonly === true || declaredState.readonly === true,
192
+ };
193
+ }
194
+
195
+ function fieldControls(field: FieldDefinition) {
196
+ if (!formElement) return [];
197
+ const controls = Array.from(formElement.elements).filter(
198
+ (
199
+ control,
200
+ ): control is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
201
+ (control instanceof HTMLInputElement ||
202
+ control instanceof HTMLSelectElement ||
203
+ control instanceof HTMLTextAreaElement) &&
204
+ control.type !== 'hidden',
205
+ );
206
+ if (field.ownerToken) {
207
+ return controls.filter(
208
+ (control) =>
209
+ control
210
+ .closest('[data-smrt-field-owner]')
211
+ ?.getAttribute('data-smrt-field-owner') === field.ownerToken,
212
+ );
213
+ }
214
+ // Custom registrations without an owner token retain exact-name support,
215
+ // but never claim composite-looking sibling controls heuristically.
216
+ const exactControls = controls.filter(
217
+ (control) => control.name === field.name,
218
+ );
219
+ const hasExactId = exactControls.some((control) => control.id === field.name);
220
+ return hasExactId
221
+ ? exactControls.filter((control) => control.id === field.name)
222
+ : exactControls;
223
+ }
224
+
225
+ function decorateFieldControls(
226
+ field: FieldDefinition,
227
+ currentFormId: string,
228
+ resolvedSubject: ControlSubject | undefined,
229
+ ): () => void {
230
+ const attributes = new Map<string, string | undefined>([
231
+ ['data-smrt-control', field.controlId ?? field.name],
232
+ ['data-smrt-form', currentFormId],
233
+ ['data-smrt-subject-type', resolvedSubject?.type],
234
+ ['data-smrt-subject-id', resolvedSubject?.id],
235
+ ]);
236
+ const controls = fieldControls(field).map((control) => ({
237
+ control,
238
+ previous: new Map(
239
+ Array.from(attributes.keys(), (attribute) => [
240
+ attribute,
241
+ control.getAttribute(attribute),
242
+ ]),
243
+ ),
244
+ }));
245
+ for (const { control } of controls) {
246
+ try {
247
+ for (const [attribute, next] of attributes) {
248
+ if (next === undefined) control.removeAttribute(attribute);
249
+ else control.setAttribute(attribute, next);
250
+ }
251
+ } catch (error) {
252
+ for (const { control: assignedControl, previous } of controls) {
253
+ for (const attribute of attributes.keys()) {
254
+ const original = previous.get(attribute);
255
+ if (original === null || original === undefined) {
256
+ assignedControl.removeAttribute(attribute);
257
+ } else {
258
+ assignedControl.setAttribute(attribute, original);
259
+ }
260
+ }
261
+ }
262
+ throw error;
263
+ }
264
+ }
265
+ return () => {
266
+ for (const { control, previous } of controls) {
267
+ for (const [attribute, assigned] of attributes) {
268
+ if (control.getAttribute(attribute) !== (assigned ?? null)) continue;
269
+ const original = previous.get(attribute);
270
+ if (original === null || original === undefined) {
271
+ control.removeAttribute(attribute);
272
+ } else {
273
+ control.setAttribute(attribute, original);
274
+ }
275
+ }
276
+ }
277
+ };
278
+ }
279
+
280
+ function recordDirectUserEdit(event: Event) {
281
+ if (!event.isTrusted) return;
282
+ const target = event.target;
283
+ if (!(target instanceof HTMLElement)) return;
284
+ const field = Array.from(fields.values()).find((candidate) =>
285
+ fieldControls(candidate).includes(
286
+ target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement,
287
+ ),
288
+ );
289
+ if (!field) return;
290
+ resolvedInteractionRegistry.recordUserEdit?.({
291
+ formId: resolvedFormId,
292
+ controlId: field.controlId ?? field.name,
293
+ subject: resolvedFieldSubject(field),
294
+ });
295
+ }
296
+
297
+ $effect(() => {
298
+ const form = formElement;
299
+ if (!form) return;
300
+ form.addEventListener('input', recordDirectUserEdit);
301
+ form.addEventListener('change', recordDirectUserEdit);
302
+ return () => {
303
+ form.removeEventListener('input', recordDirectUserEdit);
304
+ form.removeEventListener('change', recordDirectUserEdit);
305
+ };
306
+ });
307
+
308
+ function registerInteraction(
309
+ registry: ControlInteractionRegistry,
310
+ currentFormId: string,
311
+ field: FieldDefinition,
312
+ resolvedSubject: ControlSubject | undefined,
313
+ previous?: InteractionRegistration,
314
+ ): InteractionRegistration | undefined {
315
+ const identity = {
316
+ formId: currentFormId,
317
+ controlId: field.controlId ?? field.name,
318
+ subject: resolvedSubject,
319
+ };
320
+ const replacesOwnedIdentity =
321
+ previous !== undefined && sameRegistryIdentity(previous.identity, identity);
322
+ const ownershipKey = Array.from(fields.keys()).sort().join('\0');
323
+ if (registry.get(identity) && !replacesOwnedIdentity) {
324
+ logger.warn('Form: duplicate interaction identity rejected', { identity });
325
+ return undefined;
326
+ }
327
+ // Restore the old DOM decoration while its registry generation is still
328
+ // active. This makes either decoration or registration failure recoverable.
329
+ if (replacesOwnedIdentity) {
330
+ previous.restoreAttributes();
331
+ }
332
+ let restoreAttributes: () => void;
333
+ try {
334
+ restoreAttributes = decorateFieldControls(
335
+ field,
336
+ currentFormId,
337
+ resolvedSubject,
338
+ );
339
+ } catch (error) {
340
+ if (replacesOwnedIdentity) {
341
+ previous.restoreAttributes = decorateFieldControls(
342
+ previous.field,
343
+ previous.identity.formId,
344
+ previous.subject,
345
+ );
346
+ }
347
+ throw error;
348
+ }
349
+ let disposeRegistration: () => void;
350
+ try {
351
+ disposeRegistration = registry.register({
352
+ identity,
353
+ metadata: {
354
+ kind: interactionKind(field),
355
+ get label() {
356
+ return field.label;
357
+ },
358
+ get description() {
359
+ return field.description;
360
+ },
361
+ get sensitivity() {
362
+ return field.sensitivity ?? 'public';
363
+ },
364
+ get readable() {
365
+ return field.readable;
366
+ },
367
+ get writable() {
368
+ return field.writable;
369
+ },
370
+ get constraints() {
371
+ return field.constraints;
372
+ },
373
+ get options() {
374
+ return field.options;
375
+ },
376
+ get unit() {
377
+ return field.unit;
378
+ },
379
+ },
380
+ getValue: field.getValue,
381
+ setValue: field.setValue,
382
+ setValueWithContext: field.setValueWithContext,
383
+ // The registry calls this inside its stage retry loop, so partial
384
+ // structured proposals merge with the latest stable human value.
385
+ prepareValue: (value) => prepareInteractionValue(field, value),
386
+ prepareReviewedValue: field.prepareReviewedValue,
387
+ clear:
388
+ field.clear ??
389
+ (() => {
390
+ field.setValue('');
391
+ }),
392
+ focus:
393
+ field.focus ??
394
+ (() =>
395
+ fieldControls(field)
396
+ .find((control) => !control.disabled)
397
+ ?.focus()),
398
+ reveal: field.reveal,
399
+ highlight: field.highlight,
400
+ validate: field.validate,
401
+ validateValue: field.validateValue,
402
+ getState: () => fieldRuntimeState(field),
403
+ });
404
+ } catch (error) {
405
+ restoreAttributes();
406
+ if (replacesOwnedIdentity) {
407
+ previous.restoreAttributes = decorateFieldControls(
408
+ previous.field,
409
+ previous.identity.formId,
410
+ previous.subject,
411
+ );
412
+ }
413
+ throw error;
414
+ }
415
+ // Install the same-key replacement before retiring the old registry
416
+ // generation, making the old disposer a no-op and preserving internal state.
417
+ if (replacesOwnedIdentity) previous.disposeRegistration();
418
+ return {
419
+ field,
420
+ subject: resolvedSubject,
421
+ identity,
422
+ ownershipKey,
423
+ disposeRegistration,
424
+ restoreAttributes,
425
+ };
426
+ }
427
+
428
+ function disposeInteraction(registration: InteractionRegistration): void {
429
+ registration.disposeRegistration();
430
+ registration.restoreAttributes();
431
+ }
432
+
433
+ function sameRegistryIdentity(
434
+ left: ControlIdentity,
435
+ right: ControlIdentity,
436
+ ): boolean {
437
+ return (
438
+ left.formId === right.formId &&
439
+ left.controlId === right.controlId &&
440
+ left.subject?.type === right.subject?.type &&
441
+ left.subject?.id === right.subject?.id
442
+ );
443
+ }
444
+
445
+ function resolvedFieldSubject(
446
+ field: FieldDefinition,
447
+ ): ControlSubject | undefined {
448
+ const current = field.subject ?? subject;
449
+ return current
450
+ ? { type: current.type, id: current.id, label: current.label }
451
+ : undefined;
452
+ }
453
+
454
+ function sameSubject(
455
+ left: ControlSubject | undefined,
456
+ right: ControlSubject | undefined,
457
+ ): boolean {
458
+ return (
459
+ left?.type === right?.type &&
460
+ left?.id === right?.id &&
461
+ left?.label === right?.label
462
+ );
463
+ }
464
+
465
+ let interactionRegistryRevision = $state(0);
466
+
467
+ $effect(() => {
468
+ const registry = resolvedInteractionRegistry;
469
+ return registry.subscribe((event) => {
470
+ if (
471
+ event.type === 'registered' ||
472
+ event.type === 'unregistered' ||
473
+ (event.type === 'refreshed' && event.identity.formId === resolvedFormId)
474
+ ) {
475
+ interactionRegistryRevision =
476
+ untrack(() => interactionRegistryRevision) + 1;
477
+ }
478
+ });
479
+ });
480
+
481
+ $effect(() => {
482
+ const registry = resolvedInteractionRegistry;
483
+ const currentFormId = resolvedFormId;
484
+ const currentFields = new Map(fields);
485
+ const currentOwnershipKey = Array.from(currentFields.keys())
486
+ .sort()
487
+ .join('\0');
488
+ void interactionRegistryRevision;
489
+ if (
490
+ activeInteractionRegistry !== registry ||
491
+ activeInteractionFormId !== currentFormId
492
+ ) {
493
+ for (const registration of interactionDisposers.values()) {
494
+ disposeInteraction(registration);
495
+ }
496
+ interactionDisposers.clear();
497
+ activeInteractionRegistry = registry;
498
+ activeInteractionFormId = currentFormId;
499
+ }
500
+ for (const [name, registration] of interactionDisposers) {
501
+ const nextField = currentFields.get(name);
502
+ if (!nextField) {
503
+ disposeInteraction(registration);
504
+ interactionDisposers.delete(name);
505
+ continue;
506
+ }
507
+ const nextSubject = resolvedFieldSubject(nextField);
508
+ if (
509
+ nextField === registration.field &&
510
+ sameSubject(registration.subject, nextSubject) &&
511
+ registry.get(registration.identity)
512
+ ) {
513
+ continue;
514
+ }
515
+ const nextIdentity = {
516
+ formId: currentFormId,
517
+ controlId: nextField.controlId ?? nextField.name,
518
+ subject: nextSubject,
519
+ };
520
+ if (!sameRegistryIdentity(registration.identity, nextIdentity)) {
521
+ disposeInteraction(registration);
522
+ interactionDisposers.delete(name);
523
+ }
524
+ }
525
+ for (const [name, field] of currentFields) {
526
+ const previous = interactionDisposers.get(name);
527
+ if (
528
+ previous?.field === field &&
529
+ previous.ownershipKey === currentOwnershipKey &&
530
+ sameSubject(previous.subject, resolvedFieldSubject(field)) &&
531
+ registry.get(previous.identity)
532
+ )
533
+ continue;
534
+ const resolvedSubject = resolvedFieldSubject(field);
535
+ const registration = registerInteraction(
536
+ registry,
537
+ currentFormId,
538
+ field,
539
+ resolvedSubject,
540
+ previous,
541
+ );
542
+ if (!registration) continue;
543
+ interactionDisposers.set(name, registration);
544
+ }
545
+ });
546
+
547
+ // Field metadata and DOM-backed runtime state are live getters. Notify review
548
+ // consumers when either changes so hidden staged entries disappear immediately
549
+ // without unregistering the internal proposal needed for trusted discard.
550
+ $effect(() => {
551
+ const registry = resolvedInteractionRegistry;
552
+ const currentFormId = resolvedFormId;
553
+ for (const field of fields.values()) {
554
+ void field.label;
555
+ void field.description;
556
+ void field.sensitivity;
557
+ void field.readable;
558
+ void field.writable;
559
+ void field.constraints;
560
+ void field.options;
561
+ void field.unit;
562
+ const state = fieldRuntimeState(field);
563
+ void state.disabled;
564
+ void state.readonly;
565
+ }
566
+ registry.refresh?.(currentFormId);
567
+ });
568
+
135
569
  // Create form context
136
570
  const formContext: SMRTFormContext = {
137
571
  get mode() {
138
572
  return app.state.mode === 'smrt' ? 'smrt' : 'default';
139
573
  },
140
574
  registerField(field: FieldDefinition) {
141
- interactionDisposers.get(field.name)?.();
142
- fields.set(field.name, field);
143
- fields = new Map(fields); // Trigger reactivity
144
- interactionDisposers.set(
145
- field.name,
146
- resolvedInteractionRegistry.register({
147
- identity: {
148
- formId: resolvedFormId,
149
- controlId: field.controlId ?? field.name,
150
- },
151
- metadata: {
152
- kind: interactionKind(field),
153
- label: field.label,
154
- description: field.description,
155
- sensitivity: field.sensitivity ?? 'public',
156
- readable: field.readable,
157
- writable: field.writable,
158
- constraints: field.constraints,
159
- options: field.options,
160
- unit: field.unit,
161
- },
162
- getValue: field.getValue,
163
- setValue: field.setValue,
164
- clear: field.clear ?? (() => field.setValue('')),
165
- focus: field.focus,
166
- reveal: field.reveal,
167
- highlight: field.highlight,
168
- validate: field.validate,
169
- getState: field.getState,
170
- }),
171
- );
575
+ const registeredName = field.name;
576
+ const generation = Symbol(registeredName);
577
+ const registrations = fieldRegistrationOrder.get(registeredName) ?? [];
578
+ const registration = { field, generation };
579
+ registrations.push(registration);
580
+ fieldRegistrationOrder.set(registeredName, registrations);
581
+ const currentFields = untrack(() => fields);
582
+ currentFields.set(registeredName, field);
583
+ fieldGenerations.set(registeredName, generation);
584
+ fields = new Map(currentFields); // Trigger reactivity
585
+ return () => {
586
+ const orderedRegistrations = fieldRegistrationOrder.get(registeredName);
587
+ const registrationIndex = orderedRegistrations?.indexOf(registration);
588
+ if (registrationIndex !== undefined && registrationIndex >= 0) {
589
+ orderedRegistrations?.splice(registrationIndex, 1);
590
+ if (orderedRegistrations?.length === 0) {
591
+ fieldRegistrationOrder.delete(registeredName);
592
+ }
593
+ }
594
+ if (fieldGenerations.get(registeredName) !== generation) return;
595
+ const registeredFields = untrack(() => fields);
596
+ if (registeredFields.get(registeredName) !== field) return;
597
+ const previousRegistration = orderedRegistrations?.at(-1);
598
+ if (previousRegistration) {
599
+ fieldGenerations.set(registeredName, previousRegistration.generation);
600
+ registeredFields.set(registeredName, previousRegistration.field);
601
+ } else {
602
+ fieldGenerations.delete(registeredName);
603
+ registeredFields.delete(registeredName);
604
+ }
605
+ fields = new Map(registeredFields);
606
+ };
172
607
  },
173
608
  unregisterField(name: string) {
174
- interactionDisposers.get(name)?.();
175
- interactionDisposers.delete(name);
176
- fields.delete(name);
177
- fields = new Map(fields);
609
+ const registrations = fieldRegistrationOrder.get(name);
610
+ const registration = registrations?.pop();
611
+ if (registrations?.length === 0) fieldRegistrationOrder.delete(name);
612
+ if (!registration) return;
613
+ if (fieldGenerations.get(name) !== registration.generation) return;
614
+ const registeredFields = untrack(() => fields);
615
+ if (registeredFields.get(name) !== registration.field) return;
616
+ const previousRegistration = registrations?.at(-1);
617
+ if (previousRegistration) {
618
+ fieldGenerations.set(name, previousRegistration.generation);
619
+ registeredFields.set(name, previousRegistration.field);
620
+ } else {
621
+ fieldGenerations.delete(name);
622
+ registeredFields.delete(name);
623
+ }
624
+ fields = new Map(registeredFields);
178
625
  },
179
626
  getFieldSchema() {
180
627
  return Array.from(fields.values());
@@ -203,6 +650,7 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
203
650
  case 'measurement':
204
651
  schema = {
205
652
  type: 'object',
653
+ additionalProperties: false,
206
654
  properties: {
207
655
  value: {
208
656
  type: 'number',
@@ -226,6 +674,7 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
226
674
  case 'daterange':
227
675
  schema = {
228
676
  type: 'object',
677
+ additionalProperties: false,
229
678
  properties: {
230
679
  startDate: { type: 'string' },
231
680
  endDate: { type: 'string' },
@@ -238,6 +687,7 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
238
687
  case 'address':
239
688
  schema = {
240
689
  type: 'object',
690
+ additionalProperties: false,
241
691
  properties: {
242
692
  street: { type: 'string' },
243
693
  city: { type: 'string' },
@@ -259,9 +709,14 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
259
709
  };
260
710
  break;
261
711
  case 'number':
262
- case 'money':
263
712
  schema = { type: 'number' };
264
713
  break;
714
+ case 'money':
715
+ schema = {
716
+ type: 'integer',
717
+ description: 'Amount in minor currency units (cents)',
718
+ };
719
+ break;
265
720
  case 'checkbox':
266
721
  schema = { type: 'boolean' };
267
722
  break;
@@ -271,14 +726,25 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
271
726
  }
272
727
 
273
728
  if (field.label) schema.title = field.label;
274
- if (field.description) schema.description = field.description;
729
+ if (field.description) {
730
+ schema.description =
731
+ field.type === 'money'
732
+ ? `${field.description}. WebMCP values use integer minor units (cents).`
733
+ : field.description;
734
+ }
275
735
  if (field.options) {
276
736
  schema.enum = field.options.map((option) => option.value);
277
737
  }
278
- if (schema.type === 'number' && field.constraints?.min !== undefined) {
738
+ if (
739
+ (schema.type === 'number' || schema.type === 'integer') &&
740
+ field.constraints?.min !== undefined
741
+ ) {
279
742
  schema.minimum = Number(field.constraints.min);
280
743
  }
281
- if (schema.type === 'number' && field.constraints?.max !== undefined) {
744
+ if (
745
+ (schema.type === 'number' || schema.type === 'integer') &&
746
+ field.constraints?.max !== undefined
747
+ ) {
282
748
  schema.maximum = Number(field.constraints.max);
283
749
  }
284
750
  if (schema.type === 'string' && field.constraints?.minLength !== undefined) {
@@ -293,17 +759,75 @@ function webMcpFieldSchema(field: FieldDefinition): Record<string, unknown> {
293
759
  return schema;
294
760
  }
295
761
 
762
+ function structuredProposalProperties(
763
+ field: FieldDefinition,
764
+ ): Record<string, unknown> | undefined {
765
+ return (
766
+ webMcpFieldSchema(field) as {
767
+ properties?: Record<string, unknown>;
768
+ }
769
+ ).properties;
770
+ }
771
+
772
+ function prepareInteractionValue(field: FieldDefinition, value: unknown) {
773
+ let preparedValue = value;
774
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
775
+ const properties = structuredProposalProperties(field);
776
+ if (properties) {
777
+ const currentValue = field.getValue();
778
+ const currentObject =
779
+ currentValue !== null &&
780
+ typeof currentValue === 'object' &&
781
+ !Array.isArray(currentValue)
782
+ ? (currentValue as Record<string, unknown>)
783
+ : undefined;
784
+ const projectedCurrent = currentObject
785
+ ? Object.fromEntries(
786
+ Object.entries(currentObject).filter(([key]) =>
787
+ Object.hasOwn(properties, key),
788
+ ),
789
+ )
790
+ : {};
791
+ // WebMCP sanitizes its transport payload before it enters the registry.
792
+ // Preserve unknown members from direct registry callers so the field's
793
+ // validator can reject them rather than silently laundering them away.
794
+ preparedValue = { ...projectedCurrent, ...value };
795
+ }
796
+ }
797
+ if (
798
+ field.type === 'measurement' &&
799
+ preparedValue !== null &&
800
+ typeof preparedValue === 'object' &&
801
+ !Array.isArray(preparedValue) &&
802
+ !('unit' in preparedValue) &&
803
+ field.unit
804
+ ) {
805
+ preparedValue = { ...preparedValue, unit: field.unit };
806
+ }
807
+ return field.prepareValue ? field.prepareValue(preparedValue) : preparedValue;
808
+ }
809
+
296
810
  function formInputSchema(): Record<string, unknown> {
811
+ void interactionRegistryRevision;
297
812
  const properties: Record<string, unknown> = {};
298
813
  const required: string[] = [];
299
814
 
300
815
  for (const field of fields.values()) {
816
+ const state = fieldRuntimeState(field);
817
+ if (
818
+ field.sensitivity === 'secret' ||
819
+ field.writable === false ||
820
+ state.disabled ||
821
+ state.readonly
822
+ )
823
+ continue;
301
824
  properties[field.name] = webMcpFieldSchema(field);
302
825
  if (field.constraints?.required) required.push(field.name);
303
826
  }
304
827
 
305
828
  return {
306
829
  type: 'object',
830
+ additionalProperties: false,
307
831
  properties,
308
832
  ...(required.length > 0 ? { required } : {}),
309
833
  };
@@ -312,16 +836,15 @@ function formInputSchema(): Record<string, unknown> {
312
836
  // Provide context to children
313
837
  setFormContext(formContext);
314
838
 
315
- async function submitForWebMcp(args: Record<string, unknown>): Promise<string> {
316
- // Stage tool arguments through the same field setters used by native input
317
- // controls, then run the shared validation and submit path.
318
- for (const [name, value] of Object.entries(args)) {
319
- fields.get(name)?.setValue(value);
320
- }
321
-
839
+ async function submitCurrentForm(): Promise<string> {
322
840
  const data: Record<string, unknown> = {};
323
841
  let valid = true;
324
- for (const [name, field] of fields) {
842
+ const fieldSnapshot = Array.from(fields);
843
+
844
+ // Snapshot every field before running a validator: a validator may await,
845
+ // while the user can continue editing later controls. The submit handler must
846
+ // receive one coherent gesture-time payload, not a mixture of revisions.
847
+ for (const [name, field] of fieldSnapshot) {
325
848
  data[name] = field.getValue();
326
849
  if (
327
850
  field.constraints?.required &&
@@ -329,9 +852,15 @@ async function submitForWebMcp(args: Record<string, unknown>): Promise<string> {
329
852
  ) {
330
853
  valid = false;
331
854
  }
855
+ }
856
+
857
+ for (const [, field] of fieldSnapshot) {
332
858
  if (field.validate) {
333
859
  try {
334
- valid = field.validate() && valid;
860
+ const validation = field.validate();
861
+ valid =
862
+ (typeof validation === 'boolean' ? validation : await validation) &&
863
+ valid;
335
864
  } catch {
336
865
  valid = false;
337
866
  }
@@ -344,17 +873,73 @@ async function submitForWebMcp(args: Record<string, unknown>): Promise<string> {
344
873
  return 'Submitted successfully';
345
874
  }
346
875
 
876
+ async function stageForWebMcp(args: Record<string, unknown>): Promise<string> {
877
+ const commands = Object.entries(args).flatMap(([name, value]) => {
878
+ const field = fields.get(name);
879
+ const state = field ? fieldRuntimeState(field) : undefined;
880
+ if (
881
+ !field ||
882
+ field.sensitivity === 'secret' ||
883
+ field.writable === false ||
884
+ state?.disabled ||
885
+ state?.readonly
886
+ ) {
887
+ return [];
888
+ }
889
+ let sanitizedValue = value;
890
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
891
+ const properties = structuredProposalProperties(field);
892
+ if (properties) {
893
+ sanitizedValue = Object.fromEntries(
894
+ Object.entries(value).filter(([key]) =>
895
+ Object.hasOwn(properties, key),
896
+ ),
897
+ );
898
+ }
899
+ }
900
+ return [
901
+ {
902
+ action: 'stage' as const,
903
+ identity: {
904
+ formId: resolvedFormId,
905
+ controlId: field.controlId ?? field.name,
906
+ subject: field.subject ?? subject,
907
+ },
908
+ value: sanitizedValue,
909
+ },
910
+ ];
911
+ });
912
+ if (commands.length === 0) return 'No reviewable changes provided';
913
+
914
+ const context = { source: 'agent' as const, actorId: 'webmcp' };
915
+ let batch;
916
+ if (resolvedInteractionRegistry.executeBatch) {
917
+ batch = await resolvedInteractionRegistry.executeBatch(commands, context);
918
+ } else {
919
+ const results = [];
920
+ for (const command of commands) {
921
+ results.push(await resolvedInteractionRegistry.execute(command, context));
922
+ }
923
+ batch = { ok: results.every((result) => result.ok), results };
924
+ }
925
+ const completed = batch.results.filter((result) => result.ok).length;
926
+ const rejected = batch.results.length - completed;
927
+ return rejected === 0
928
+ ? `Staged ${completed} change${completed === 1 ? '' : 's'} for review`
929
+ : `Staged ${completed} changes for review; ${rejected} rejected`;
930
+ }
931
+
347
932
  useWebMcpTool(() => {
348
933
  if (!webmcp) return null;
349
934
  const options = typeof webmcp === 'object' ? webmcp : {};
350
935
  return {
351
- name: options.name ?? `${collection ?? resolvedFormId}_submit`,
936
+ name: options.name ?? `${collection ?? resolvedFormId}_stage_changes`,
352
937
  description:
353
938
  options.description ??
354
- `Submit the ${collection ?? name ?? resolvedFormId} form after validating its fields`,
939
+ `Propose changes to the ${collection ?? name ?? resolvedFormId} form for local human review`,
355
940
  inputSchema: formInputSchema(),
356
941
  annotations: { readOnlyHint: false },
357
- execute: submitForWebMcp,
942
+ execute: stageForWebMcp,
358
943
  };
359
944
  });
360
945
 
@@ -475,12 +1060,24 @@ async function extractFields(text: string): Promise<Record<string, unknown>> {
475
1060
  }
476
1061
 
477
1062
  // Apply extracted values to fields with cleanup
478
- function applyExtractedValues(values: Record<string, unknown>) {
1063
+ async function applyExtractedValues(values: Record<string, unknown>) {
479
1064
  for (const [name, value] of Object.entries(values)) {
480
1065
  const field = fields.get(name);
481
1066
  if (field && value !== undefined && value !== null) {
482
1067
  const cleanedValue = cleanValue(value, field.type);
483
- field.setValue(cleanedValue);
1068
+ try {
1069
+ const preparedValue = field.prepareExtractedValue
1070
+ ? await field.prepareExtractedValue(cleanedValue)
1071
+ : (field.prepareValue?.(cleanedValue) ?? cleanedValue);
1072
+ field.setValue(preparedValue);
1073
+ } catch (error) {
1074
+ // One uncanonicalizable transcript fragment must not abort later
1075
+ // extracted fields or expose an internal preparation error to the UI.
1076
+ logger.warn('Form: skipped invalid extracted field value', {
1077
+ field: name,
1078
+ error,
1079
+ });
1080
+ }
484
1081
  }
485
1082
  }
486
1083
  }
@@ -740,7 +1337,7 @@ async function stopFormListening() {
740
1337
 
741
1338
  try {
742
1339
  const values = await extractFields(textToExtract);
743
- applyExtractedValues(values);
1340
+ await applyExtractedValues(values);
744
1341
  } catch (err) {
745
1342
  extractError =
746
1343
  err instanceof Error ? err.message : 'Failed to extract fields';
@@ -762,7 +1359,9 @@ onDestroy(() => {
762
1359
  clearTimeout(silenceTimer);
763
1360
  }
764
1361
  stopAudioLevelMonitoring();
765
- for (const dispose of interactionDisposers.values()) dispose();
1362
+ for (const registration of interactionDisposers.values()) {
1363
+ disposeInteraction(registration);
1364
+ }
766
1365
  interactionDisposers.clear();
767
1366
  });
768
1367
 
@@ -776,7 +1375,7 @@ function handleSubmit(e: Event) {
776
1375
  // This allows native form submission for SvelteKit form actions
777
1376
  if (onsubmit) {
778
1377
  e.preventDefault();
779
- void submitForWebMcp({}).catch((error) => {
1378
+ void submitCurrentForm().catch((error) => {
780
1379
  logger.error('Form submit failed', { error });
781
1380
  });
782
1381
  }
@@ -797,6 +1396,7 @@ export function getInteractionRegistry(): ControlInteractionRegistry {
797
1396
  </script>
798
1397
 
799
1398
  <form
1399
+ bind:this={formElement}
800
1400
  {id}
801
1401
  {name}
802
1402
  class="smrt-form {className}"
@@ -907,6 +1507,12 @@ export function getInteractionRegistry(): ControlInteractionRegistry {
907
1507
  <div class="form-fields">
908
1508
  {@render children()}
909
1509
  </div>
1510
+ <StagedControlReview
1511
+ registry={resolvedInteractionRegistry}
1512
+ formId={resolvedFormId}
1513
+ {formElement}
1514
+ summary={stagedReview}
1515
+ />
910
1516
  </form>
911
1517
 
912
1518
  {#if isFormListening && spokenText}